C++ 的神奇运算
条评论最近同学问我,为什么他的程序跑的结果不符合预期,甚至跑出了非常离谱的结果,于是我们进行了一番调查。
简化后的程序如下所示:
1 | int a = 2; |
当我们调用这个函数,并使用 g++ -O3
编译运行时发现,这个函数无限循环了下去,并且输出了 i = 231904, a = 2, i<a: 1
这样的内容,宣称 231904 < 2。
问题描述
为了描述问题,我们先放上完整的复现代码,保存为“test.cpp”文件:
1 |
|
可以看到,本程序的意图应该是运行两次后,就结束循环。
然后使用 g++ 编译并运行:
1 | g++ -O3 -o test.o test.cpp |
看到如下输出
1 | i = 0, a = 2, i<a: 1 |
循环没有结束,而是无限进行了下去,甚至在 printf
中,明明 i
的值一直在增长并超过了 a
,程序却算出了 i < a
,非常奇怪。
然而,当我在循环后面再加上一些语句时,却又不会无限循环了
1 | int func() |
输出
1 | i = 0, a = 2, i<a: 1 |
这时程序不会再死循环,但是也报了段错误。因此我开始判断它和带返回值的函数没有 return
一个值有关。
汇编代码
从个人经验判断,出现这种奇怪的问题,往往是因为编译器在优化时做了某些奇怪的假设,当我关闭 -O3
优化后,问题也确实消失了。于是我尝试查看编译出来的汇编代码来确定编译器究竟做了什么样的优化。
从 https://godbolt.org/ 上选择最新的“x86-64 gcc 13.2”,设置 -O3
选项,生成的汇编代码如下:
左右两边相同颜色的行为 C++ 代码所生成的对应汇编代码。从汇编的第 7 - 15 行可以看到,此循环被编译成了无条件跳转(第 15 行跳转到 L2),也就是死循环。
原因分析
究其原因,我认为是,在一个返回值类型不是 void
的函数中不 return
是未定义的行为(UB),GCC 在优化时,是在假定你的程序不会出现 UB 的基础上进行的。如此一来,如果一个函数的最后一个语句是循环,那么编译器就可以推定你一定会在这个循环中 return
(实际上,他原来的代码有比较复杂的判断逻辑,在有些分支是会返回的),也就是循环条件应该是一直满足的,于是就可以大胆地把循环优化成死循环。而 printf
比较大小的结果错误,我想,是因为输出内容的计算公式和循环的判断条件相同(i < a
),而既然循环条件推定满足了,输出内容就不需要再浪费 CPU 周期进行计算了。实际上,在汇编第 9 行可以看到,编译出的代码直接把 1
传给了 printf
。
C++ 规范
为了确认在有返回值的函数中不 return
是 UB,我查阅了相关的规范,但遗憾的是,并没有找到完全解释现象的描述。C++ 规范 ISO/IEC 9899:201x 中是这样描述的(6.9.1 节,12)
If the
}
that terminates a function is reached, and the value of the function call is used by the caller, the behavior is undefined.
根据 C++ 规范,只有在该返回值被使用时,才是 UB。这样的话,由于测试代码中并没有使用 func
的返回值,死循环似乎不符合规范?
结论
在编写 C++ 程序时,应该格外注意避免出现 UB。本身编译器把 UB 编译成任何东西都是合法的,经过编译器的优化,就更加可能出现预料之外的行为。而当以 DEBUG 模式编译,或者增加调试用的输出时,有时也会破坏优化前提,导致无法再复现。如果编译本例的代码,编译器会给出警告,告诉你缺失 return
语句。所以编写 C++ 代码时应该更加重视编译器的警告。
1 | test.cpp: In function ‘int func()’: |
当然,原本的程序是更复杂的,而且涉及多线程,在排查时也都考虑过多种可能的情况,比如尝试在某些地方添加 mfence
等。最终才确认是 UB 与编译器优化组合出了意料之外的结果,与多线程无关。