C语言未定义行为一览

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

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

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

  1. #include <stdio.h> 
  2.   
  3. int main(){ 
  4.     int i = 0; 
  5.     int a[] = {10,20,30}; 
  6.   
  7.     int r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++]; 
  8.     printf("%d\n", r); 
  9.     return 0; 

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

  1. int r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++]; 
  2. //    =    a[0]    + 2 * a[1]  + 3 * a[2]; 
  3. //    =     10     +     40    +    90; 
  4. //    = 140 

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

  1. int r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++]; 
  2. //    =    a[2]    + 2 * a[1]  + 3 * a[0]; 
  3. //    =     30     +     40    +    30; 
  4. //    = 100 

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

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

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

  1. Disassembly of section .text: 
  2.   
  3. 0000000000000000 <main>
  4. #include <stdio.h> 
  5.   
  6. int main(){ 
  7.    0:   55                      push   %rbp 
  8.    1:   48 89 e5                mov    %rsp,%rbp 
  9.    4:   48 83 ec 20             sub    $0x20,%rsp 
  10.     int i = 0
  11.    8:   c7 45 e8 00 00 00 00    movl   $0x0,-0x18(%rbp) 
  12.     int a[] = {10,20,30}; 
  13.    f:   c7 45 f0 0a 00 00 00    movl   $0xa,-0x10(%rbp) 
  14.   16:   c7 45 f4 14 00 00 00    movl   $0x14,-0xc(%rbp) 
  15.   1d:   c7 45 f8 1e 00 00 00    movl   $0x1e,-0x8(%rbp) 
  16.     int r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++]; 
  17.   24:   8b 45 e8                mov    -0x18(%rbp),%eax 
  18.   27:   48 98                   cltq  
  19.   29:   8b 54 85 f0             mov    -0x10(%rbp,%rax,4),%edx 
  20.   2d:   8b 45 e8                mov    -0x18(%rbp),%eax 
  21.   30:   48 98                   cltq  
  22.   32:   8b 44 85 f0             mov    -0x10(%rbp,%rax,4),%eax 
  23.   36:   01 c0                   add    %eax,%eax 
  24.   38:   8d 0c 02                lea    (%rdx,%rax,1),%ecx 
  25.   3b:   8b 45 e8                mov    -0x18(%rbp),%eax 
  26.   3e:   48 98                   cltq  
  27.   40:   8b 54 85 f0             mov    -0x10(%rbp,%rax,4),%edx 
  28.   44:   89 d0                   mov    %edx,%eax 
  29.   46:   01 c0                   add    %eax,%eax 
  30.   48:   01 d0                   add    %edx,%eax 
  31.   4a:   01 c8                   add    %ecx,%eax 
  32.   4c:   89 45 ec                mov    %eax,-0x14(%rbp) 
  33.   4f:   83 45 e8 01             addl   $0x1,-0x18(%rbp) 
  34.   53:   83 45 e8 01             addl   $0x1,-0x18(%rbp) 
  35.   57:   83 45 e8 01             addl   $0x1,-0x18(%rbp) 
  36.     printf("%d\n", r); 
  37.   5b:   8b 45 ec                mov    -0x14(%rbp),%eax 
  38.   5e:   89 c6                   mov    %eax,%esi 
  39.   60:   bf 00 00 00 00          mov    $0x0,%edi 
  40.   65:   b8 00 00 00 00          mov    $0x0,%eax 
  41.   6a:   e8 00 00 00 00          callq  6f <main+0x6f> 
  42.     return 0; 
  43.   6f:   b8 00 00 00 00          mov    $0x0,%eax 
  44.   74:   c9                      leaveq 
  45.   75:   c3                      retq 

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

  1. 24:   8b 45 e8                mov    -0x18(%rbp),%eax 
  2. 27:   48 98                   cltq  
  3. 29:   8b 54 85 f0             mov    -0x10(%rbp,%rax,4),%edx 

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

  1. 2d:   8b 45 e8                mov    -0x18(%rbp),%eax 
  2. 30:   48 98                   cltq  
  3. 32:   8b 44 85 f0             mov    -0x10(%rbp,%rax,4),%eax 
  4. 36:   01 c0                   add    %eax,%eax 
  5. 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]的值。事情开始看起来有一些奇怪了,然而再一次,也许某个编译器魔法在发生。

  1. 3b:   8b 45 e8                mov    -0x18(%rbp),%eax 
  2. 3e:   48 98                   cltq  
  3. 40:   8b 54 85 f0             mov    -0x10(%rbp,%rax,4),%edx 
  4. 44:   89 d0                   mov    %edx,%eax 

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

  1. 46:   01 c0                   add    %eax,%eax 
  2. 48:   01 d0                   add    %edx,%eax 
  3. 4a:   01 c8                   add    %ecx,%eax 
  4. 4c:   89 45 ec                mov    %eax,-0x14(%rbp) 

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

  1. 4f:   83 45 e8 01             addl   $0x1,-0x18(%rbp) 
  2. 53:   83 45 e8 01             addl   $0x1,-0x18(%rbp) 
  3. 57:   83 45 e8 01             addl   $0x1,-0x18(%rbp) 

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

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

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

原文链接:http://blog.chris-cole.net/2013/11/30/a-glimpse-of-undefined-behavior-in-c/

译文链接:http://blog.jobbole.com/53211/

责任编辑:陈四芳 来源: 伯乐在线
相关推荐

2014-03-27 15:01:50

算法C++

2012-02-20 09:06:20

JVM

2024-02-05 14:18:07

自然语言处理

2009-09-16 13:53:47

WebForm

2009-08-03 13:55:03

C#基础知识

2009-08-26 09:26:04

Visual Stud

2010-03-26 09:32:54

CSS

2010-10-14 16:55:00

MySQL联结查询

2009-03-03 20:44:06

桌面虚拟化Xendesktop虚拟化

2017-03-06 16:34:12

虚拟个人助理

2020-02-17 15:29:00

石墨文档

2021-06-08 09:47:44

Java面向对象

2011-01-11 09:53:28

linux进程

2011-01-11 10:06:14

linux进程

2010-11-15 09:55:35

Oracle转换函数

2019-04-26 14:21:34

手机色彩苹果

2023-11-08 07:45:47

Spring微服务

2010-01-26 17:44:32

Visual C++开

2009-08-13 09:46:49

C#历史C# 4.0新特性

2010-10-21 15:40:05

SQL Server服
点赞
收藏

51CTO技术栈公众号