C语言未定义行为一览

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

4 收藏 18 评论

关于作者:cjpan

海上钢琴摄影攻城师,好吧,←每个位面都只是刚起步而已。(新浪微博:@潘成杰V) 个人主页 · 我的文章 · 3

相关文章

可能感兴趣的话题



直接登录
最新评论
  • stone   2013/12/12

    纠结于这些无聊的问题.写这种代码的人该枪毙.

  • AntiLinuxism   2013/12/12

    我比较懒,不愿读spec,碰到这种情况,一般是把所有后缀++都拿到表达式最后,看来和某些编译器想到一块儿了

    把后缀++写成3个放不同的地方当成一个考试题,当然是没有意义的。
    但是这种规定本身是有意义的,写循环的时候,或者表示“哥已到此一游,请后来者读后面数据”的时候,有时候会方便一点,视觉上直接表达出来意图了。

    一棍子打死说不让用后缀++也不行的,正宗的C程序员不会答应,呵呵

  • bel   2013/12/12

    这也许不是什么新知识,也许换一个编译器就是另外一个结果了.这只是编译器不同实现的方式

  • suifengerbi   2013/12/12

    你这解释也太麻烦了,i++;其实就是在这条“语句执行之后再给i加上的!!!
    在vc上能够得出结果
    版本为下面的gcc就报错le
    Configured with: --prefix=/Applications/Xcode.app/Contents/Developer/usr --with-gxx-include-dir=/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.9.sdk/usr/include/c++/4.2.1
    Apple LLVM version 5.0 (clang-500.2.79) (based on LLVM 3.3svn)
    Target: x86_64-apple-darwin13.0.0
    Thread model: posix

  • lslxdx   2013/12/12

    "结果是,那个副作用只被定义为只被付诸于各顺序点之间。"

    原文是:
    It turns out that side effects are only defined to have been committed between sequence points.

    “have been committed”是不是可以直译成“被提交”呢?

  • Alph   2013/12/12

    我用OSX编译这段代码,结果就是140.

    不同的编译器有不同的实现,这根本没有什么好纠结的。关键是写出这种代码的人应该切腹谢罪。

  • wkoji   2013/12/13

    加个括号什么都解决了。自己研究是无所谓,假如写商业或者工业代码这么来写,直接毙掉

  • [...] 另外这里有一份更加权威的解答,《C语言未定义行为一览》,里面更加详细地解释了为什么会出现 60 这个答案,原因在于  int r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++]; 这个表达式展现了 C 语言中的未定义行为,这条表达式最终会输出什么结果,完全取决于编译器是怎样认为的。 [...]

  • alpha   2013/12/13

    这种问题还要纠结……k&r里关于副作用何时生效已经说得很清楚了

  • 这个跟编译器有关系吧?
    int a[]={10,20,30,40};
    int r = 1 * a[++i] + 2 * a[++i] + 3 * a[++i];

    我在linux下直接编译结果是190,
    在vs中时240,

    不太明白190是怎么来的。
    求解释。

    int i=0;
    8048395: c7 45 f4 00 00 00 00 movl $0x0,0xfffffff4(%ebp)
    int a[]={10,20,30,40};
    804839c: c7 45 e4 0a 00 00 00 movl $0xa,0xffffffe4(%ebp)
    80483a3: c7 45 e8 14 00 00 00 movl $0x14,0xffffffe8(%ebp)
    80483aa: c7 45 ec 1e 00 00 00 movl $0x1e,0xffffffec(%ebp)
    80483b1: c7 45 f0 28 00 00 00 movl $0x28,0xfffffff0(%ebp)
    int r=1*a[++i]+2*a[++i]+3*a[++i];
    80483b8: 83 45 f4 01 addl $0x1,0xfffffff4(%ebp)
    80483bc: 8b 45 f4 mov 0xfffffff4(%ebp),%eax
    80483bf: 8b 4c 85 e4 mov 0xffffffe4(%ebp,%eax,4),%ecx
    80483c3: 83 45 f4 01 addl $0x1,0xfffffff4(%ebp)
    80483c7: 8b 45 f4 mov 0xfffffff4(%ebp),%eax
    80483ca: 8b 54 85 e4 mov 0xffffffe4(%ebp,%eax,4),%edx
    80483ce: 89 d0 mov %edx,%eax
    80483d0: 01 c0 add %eax,%eax
    80483d2: 8d 14 10 lea (%eax,%edx,1),%edx
    80483d5: 83 45 f4 01 addl $0x1,0xfffffff4(%ebp)
    80483d9: 8b 45 f4 mov 0xfffffff4(%ebp),%eax
    80483dc: 8b 44 85 e4 mov 0xffffffe4(%ebp,%eax,4),%eax
    80483e0: 01 c0 add %eax,%eax
    80483e2: 8d 04 02 lea (%edx,%eax,1),%eax
    80483e5: 8d 04 01 lea (%ecx,%eax,1),%eax
    80483e8: 89 45 f8 mov %eax,0xfffffff8(%ebp)
    printf("-----------%d--------\n",r);

  • Edward Shen   2013/12/16

    可以这么写代码吗?在一个表达式里多次进行++/--这样的操作。其顺序是未定义的。这种代码是不合格的。
    其结果应该对于不同的编译器有不同的输出!
    根本不值得讨论!

  • marco   2013/12/19

    纠结这种无聊的问题.还不如看下代码规范或者人家写的东西.而且文章内容对不住标题...
    优秀的代码规范比纠结这样的问题要好得多...

    这个文章唯一告诉我们的就是:
    一个优秀的c码农应该在代码里尽量避免诸如此类能让编译器误会的问题...

    面试的时候谁写这样的代码就TM直接让他回炉...

  • bombless   2014/07/11

    这个规则很有意思,不是完全没有用的。
    看上去就是C++的临时对象何时析构的翻版:临时对象在最外层表达式的最后析构,也就是往外搜索,直到表达式不再是另一个表达式的一部分。

    因此这个部分不只是有趣的知识,它是应该被准确掌握的。

  • 这个结果应该与具体的编译器和运行环境有关,经过尝试在“gcc (Ubuntu 4.8.2-19ubuntu1) 4.8.2”和“Linux nick-VB 3.16.0-31-generic #41~14.04.1-Ubuntu SMP Wed Feb 11 19:30:13 UTC 2015 x86_64 x86_64 x86_64 GNU/Linux
    ”环境中结果为140。
    不过最后提到的“最好的应用是避免构造复杂的前缀后缀表达式”深表同意!

  • 傻蛋   2015/05/12

    Gcc是140,vc6.0是60!

跳到底部
返回顶部