C语言未定义行为一览

本文探讨了一段C语言代码中出现的意外结果,通过分析该代码的编译过程及反汇编代码,揭示了后缀自增运算符在特定上下文中可能导致的未定义行为及其原因。

转自:http://blog.jobbole.com/53211/

本文由 伯乐在线 - cjpan翻译自 Christopher Cole。欢迎加入技术翻译小组。转载请参见文章末尾处的要求。


几周前,我的一位同事带着一个编程问题来到我桌前。最近我们一直在互相考问C语言的知识,所以我微笑着鼓起勇气面对无疑即将到来的地狱。

他在白板上写了几行代码,并问这个程序会输出什么?

 

 

#include <stdio.h>

int main(){

int i = 0;

int a[] = {10,20,30};

int r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++];

printf("%d\n", r);

return 0;

}

看上去相当简单明了。我解释了操作符的优先顺序——后缀操作比乘法先计算、乘法比加法先计算,并且乘法和加法的结合性都是从左到右,于是我抓出运算符号并开始写出算式。

 

int r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++];

// = a[0] + 2 * a[1] + 3 * a[2];

// = 10 + 40 + 90;

// = 140

我自鸣得意地写下答案后,我的同事回应了一个简单的“不”。我想了几分钟后,还是被难住了。我不太记得后缀操作符的结合顺序了。此外,我知道那个顺序甚至不会改变这里的值计算的顺序,因为结合规则只会应用于同级的操作符之间。但我想到了应该根据后缀操作符都从右到左求值的规则,尝试算一遍这条算式。看上去相当简单明了。

 

int r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++];

// = a[2] + 2 * a[1] + 3 * a[0];

// = 30 + 40 + 30;

// = 100

我的同事再一次回答说,答案仍是错的。这时候我只好认输了,问他答案是什么。这段短小的样例代码原来是从他写过的更大的代码段里删减出来的。为了验证他的问题,他编译并且运行了那个更大的代码样例,但是惊奇地发现那段代码没有按照他预想的运行。他删减了不需要的步骤后得到了上面的样例代码,用gcc 4.7.3编译了这段样例代码,结果输出了令人吃惊的结果:“60”。

这时我被迷住了。我记得,C语言里,函数参数的计算求值顺序是未定义的,所以我们以为后缀操作符只是遵照某个随机的、而非从左至右的顺序,计算的。我们仍然确信后缀比加法和乘法拥有更高的操作优先级,所以很快证明我们自己,不存在我们可以计算i++的顺序,使得这三个数组元素一起加起来、乘起来得到60。

现在我已对此入迷了。我的第一个想法是,查看这段代码的反汇编代码,然后尝试查出它实际上发生了什么。我用调试符号(debuggingsymbols)编译了这段样例代码,用了objdump后很快得到了带注释的x86_64反汇编代码。

 

Disassembly of section .text:

0000000000000000 <main>:

#include <stdio.h>

int main(){

0: 55 push %rbp

1: 48 89 e5 mov %rsp,%rbp

4: 48 83 ec 20 sub $0x20,%rsp

int i = 0;

8: c7 45 e8 00 00 00 00 movl $0x0,-0x18(%rbp)

int a[] = {10,20,30};

f: c7 45 f0 0a 00 00 00 movl $0xa,-0x10(%rbp)

16: c7 45 f4 14 00 00 00 movl $0x14,-0xc(%rbp)

1d: c7 45 f8 1e 00 00 00 movl $0x1e,-0x8(%rbp)

int r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++];

24: 8b 45 e8 mov -0x18(%rbp),%eax

27: 48 98 cltq

29: 8b 54 85 f0 mov -0x10(%rbp,%rax,4),%edx

2d: 8b 45 e8 mov -0x18(%rbp),%eax

30: 48 98 cltq

32: 8b 44 85 f0 mov -0x10(%rbp,%rax,4),%eax

36: 01 c0 add %eax,%eax

38: 8d 0c 02 lea (%rdx,%rax,1),%ecx

3b: 8b 45 e8 mov -0x18(%rbp),%eax

3e: 48 98 cltq

40: 8b 54 85 f0 mov -0x10(%rbp,%rax,4),%edx

44: 89 d0 mov %edx,%eax

46: 01 c0 add %eax,%eax

48: 01 d0 add %edx,%eax

4a: 01 c8 add %ecx,%eax

4c: 89 45 ec mov %eax,-0x14(%rbp)

4f: 83 45 e8 01 addl $0x1,-0x18(%rbp)

53: 83 45 e8 01 addl $0x1,-0x18(%rbp)

57: 83 45 e8 01 addl $0x1,-0x18(%rbp)

printf("%d\n", r);

5b: 8b 45 ec mov -0x14(%rbp),%eax

5e: 89 c6 mov %eax,%esi

60: bf 00 00 00 00 mov $0x0,%edi

65: b8 00 00 00 00 mov $0x0,%eax

6a: e8 00 00 00 00 callq 6f <main+0x6f>

return 0;

6f: b8 00 00 00 00 mov $0x0,%eax

}

74: c9 leaveq

75: c3 retq

最先和最后的几个指令只建立了堆栈结构,初始化变量的值,调用printf函数,还从main函数返回。所以我们实际上只需要关心从0×24到0×57之间的指令。那是令人关注的行为发生的地方。让我们每次查看几个指令。

 

24: 8b 45 e8 mov -0x18(%rbp),%eax

27: 48 98 cltq

29: 8b 54 85 f0 mov -0x10(%rbp,%rax,4),%edx

最先的三个指令与我们预期的一致。首先,它把i(0)的值加载到eax寄存器,带符号扩展到64位,然后加载a[0]到edx寄存器。这里的乘以1的运算(1*)显然被编译器优化后去除了,但是一切看起来都正常。接下来的几个指令开始时也大致相同。

 

2d: 8b 45 e8 mov -0x18(%rbp),%eax

30: 48 98 cltq

32: 8b 44 85 f0 mov -0x10(%rbp,%rax,4),%eax

36: 01 c0 add %eax,%eax

38: 8d 0c 02 lea (%rdx,%rax,1),%ecx

第一个mov指令把i的值(仍然是0)加载进eax寄存器,带符号扩展到64位,然后加载a[0]进eax寄存器。有意思的事情发生了——我们再次期待i++在这三条指令之前已经运行过了,但也许最后两条指令会用某种汇编的魔法来得到预期的结果(2*a[1])。这两条指令把eax寄存器的值自加了一次,实际上执行了2*a[0]的操作,然后把结果加到前面的计算结果上,并存进ecx寄存器。此时指令已经求得了a[0] + 2 * a[0]的值。事情开始看起来有一些奇怪了,然而再一次,也许某个编译器魔法在发生。

 

3b: 8b 45 e8 mov -0x18(%rbp),%eax

3e: 48 98 cltq

40: 8b 54 85 f0 mov -0x10(%rbp,%rax,4),%edx

44: 89 d0 mov %edx,%eax

接下来这些指令开始看上去相当熟悉。他们加载i的值(仍然是0),带符号扩展至64位,加载a[0]到edx寄存器,然后拷贝edx里的值到eax。嗯,好吧,让我们在多看一些:

 

46: 01 c0 add %eax,%eax

48: 01 d0 add %edx,%eax

4a: 01 c8 add %ecx,%eax

4c: 89 45 ec mov %eax,-0x14(%rbp)

在这里把a[0]自加了3次,再加上之前的计算结果,然后存入到变量“r”。现在不可思议的事情——我们的变量r现在包含了a[0] + 2 * a[0] + 3 * a[0]。足够肯定的是,那就是程序的输出:“60”。但是那些后缀操作符上发生了什么?他们都在最后:

 

4f: 83 45 e8 01 addl $0x1,-0x18(%rbp)

53: 83 45 e8 01 addl $0x1,-0x18(%rbp)

57: 83 45 e8 01 addl $0x1,-0x18(%rbp)

看上去我们编译版本的代码完全错了!为什么后缀操作符被扔到最底下、所有任务已经完成之后?随着我对现实的信仰减少,我决定直接去找本源。不,不是编译器的源代码——那只是实现——我抓起了C11语言规范。

这个问题处在后缀操作符的细节。在我们的案例中,我们在单个表达式里对数组下标执行了三次后缀自增。当计算后缀操作符时,它返回变量的初始值。把新的值再分配回变量是一个副作用。结果是,那个副作用只被定义为只被付诸于各顺序点之间。参照标准的5.1.2.3章节,那里定义了顺序点的细节。但在我们的例子中,我们的表达式展示了未定义行为。它完全取决于编译器对于 什么时候 给变量分配新值的副作用会执行相对于表达式的其他部分。

最终,我俩都学到了一点新的C语言知识。众所周知,最好的应用是避免构造复杂的前缀后缀表达式,这就是一个关于为什么要这样的极好例子。

 

### C语言未定义行为的原因 在C语言编程过程中,未定义行为(Undefined Behavior, UB)是指编译器对于某些特定操作的结果不做任何保证的情况。当程序执行到这些情况时,可能会引发不可预测的行为,包括但不限于程序崩溃、数据损坏或安全漏洞。 #### 整数溢出 整数类型的变量超出其表示范围会触发未定义行为。例如,在有符号整型运算中,如果结果超出了该类型所能表达的最大值,则会产生不确定的结果[^4]。 ```c int a = INT_MAX; a++; // 这里可能发生未定义行为 ``` #### 非法指针访问 非法解引用空指针或者已经释放过的内存区域也会造成未定义行为。这通常发生在程序员忘记初始化指针或将指向动态分配对象的指针再次用于`free()`之后继续使用它的情况下[^3]。 ```c char* p = NULL; *p = 'A'; // 尝试向NULL地址写入字符'A',这是未定义行为 ``` #### 数组越界 数组下标超过了声明大小同样属于未定义行为的一种形式。这种情况下读取或修改不属于当前数组部分的数据可能导致任意后果。 ```c int arr[5]; arr[5] = 10; // 访问了不存在的位置6(索引从0开始),即为越界 ``` ### 解决方案与预防措施 为了防止上述提到的各种未定义行为的发生,建议采取如下策略: - **严格遵循标准**:始终按照ISO/IEC发布的官方文档来编写代码,并保持良好的编码风格。 - **启用警告选项并重视它们**:现代编译器提供了丰富的诊断功能,应该充分利用这些特性找出潜在的风险点;同时也要认真对待每一个告警提示,及时修正源码中存在的隐患。 - **利用静态分析工具**:借助第三方插件如Clang Static Analyzer 或 Coverity Scan 对项目进行全面扫描,提前发现隐藏较深的问题所在之处。 - **运行时检测机制**:采用诸如AddressSanitizer这样的库来进行实时监控,一旦遇到异常状况立即报告给开发者以便快速定位问题根源。 - **测试驱动开发(TDD)**:通过构建全面而详尽的单元测试集覆盖尽可能多的功能分支路径,从而提高系统的健壮性和稳定性。 综上所述,理解什么是未定义行为及其产生的根本原因是至关重要的第一步,只有这样才能有的放矢地制定相应的防范对策,进而写出更加可靠高效的C/C++应用程序[^1]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值