C++中那些你不知道的未定义行为
引子
开篇我们先看一个非常有趣的引子:
// test.cpp
int f(long *a, int *b) {
*b = 5;
*a = 1;
return *b;
}
int main() {
int x = 10;
int *p = &x;
auto q = (long *)&x;
auto ret = f(q, p);
std::cout << x << std::endl;
std::cout << ret << std::endl;
return 0;
}
请问输出的x
和ret
分别是什么?
相信大家都能一眼看出,在函数f
中,a
和b
指向的都是main
中的x
,而*a = 1
后执行,所以x
的值应该是1
,而*b
同样表示x
的值,所以程序输出的结果应该是两个1
。
没错!我们简单编译、运行一下,发现确实是输出两个1。但,有趣的事情来了!如果在编译的时候,加上一个-O2
参数,情况就会超出你的想象:
$ g++ test.cpp -o test.out
$ ./test.out
1
1
$ g++ -O2 test.cpp -o test.out
$ ./test.out
1
5
这种神奇的现象是如何产生的呢?这一篇我们就来盘一盘C++中的未定义行为(Undefined Behavior,简称UB),以及编译期优化(Optimization)。
语义(Semantics)&实现(Implementation)
这应该是笔者第N+1次提及这两个概念了,要想深入掌握C++,语义和实现的问题是一定绕不开的。再次说明,「语义」表达的是代码字面上的含义,或者说它代表了程序员的意愿;而「实现」则是编译器层面,如何将一种语义翻译为机器指令的过程。
同一种语义可以有不同的编译器实现方式,当然,也就存在优化好的实现和优化差的实现,不过通常情况下无论哪种编译器,哪种实现方式,都是一定要保证其行为一致的,否则我们就可以认为这个编译器写得有问题。
然而,对于一些语义中的未定义行为(以下都简称UB),则不同的编译器实现就可能出现不同的行为。这是C++非常纠结与奇怪的地方,照理说,UB都应该直接报错,不允许编译才对,但C++很特殊,它在引入高层语义的同时又保留了很多C语言继承来的底层特性,这样做的好处有两个:其一,是为了兼容C,比如说在项目中引入C的开源库,那你就必须支持C的灵活性;其二,是C++有一个设计哲学,就是说如果你不希望使用某一部分特性,那么这部分特性就不会对你产生附加影响(虽然说不能百分百做到吧,但至少是这种导向)。
了解了这件事情以后,我们就来回头看一下引子中的例子:
auto q = (long *)&x;
这是一个C风格的强转,如果用cast
方式表示,它应当写作:
auto q = reinterpret_cast<long *>(&x);
这其实就是一个UB,原因在于,int
和long
的长度是不同的,因此对q
进行操作的时候,会向后多操作一片空间。
我们再来看看前面的函数:
int f(long *a, int *b) {
*b = 5;
*a = 1;
return *b;
}
由于a
跟b
类型不同,并且长度也不同,那么在编译器的-O2
优化当中,就会认为他们一定指向的不是同一个对象。而在函数内部*b = 5
中,5
已经是一个常量了,因此,就可以在编译期确定,而最后函数return *b
,那么就可以判断返回值一定是5
。
因此,这就是一个UB导致了编译器优化后行为与预期不一致的情况,因为在这个例子中,return *b
并没有做真正的解指针操作,而是被编译期优化为了return 5
。
以下是这段代码的AMD64汇编情况,不进行优化时:
f(long*, int*):
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
mov QWORD PTR [rbp-16], rsi
mov rax, QWORD PTR [rbp-16]
mov DWORD PTR [rax], 5
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], 1
mov rax, QWORD PTR [rbp-16]
mov eax, DWORD PTR [rax] ; 返回值入eax,这里是真实的取内存数据
pop rbp
ret
而加上-O2
优化后则是:
f(long*, int*):
mov DWORD PTR [rsi], 5
mov eax, 5 ; 返回值入eax,直接写的5
mov QWORD PTR [rdi], 1
ret
那么,如何解决这个问题呢?如何可以在-O2
优化时,还能让他实际去取数据呢?这个问题我们在下一节探讨。
std::launder
std::launder
绝对可以算得上C++的黑魔法之一,我最初看到它的说明的时候可以说是一脸懵逼,我们来看一下它的原型:
template <class T>
[[nodiscard]] constexpr T* launder(T* p) noexcept;
需要注意,这个[[nodiscard]]
是在C++20标准上补充进去的,C++17时没有强行做规定,但实际上它的返回值确实不可丢弃,否则无意义。
那么,std::launder
究竟做了什么?我可以负责人的告诉你,其实它什么都没做,它就是把参数原样输出了。也就是说:
std::lanuder(p) == p; // true
std::lanuder(q) == q; // true
你穿进去一个指针,它会把这个指针原样输出……
啥玩意?!在逗我吗?!这就是C++的黑魔法了。从语义上来说,std::launder
确实是什么都没做,但它是给编译器看的,也就是说,它改变了「实现」而不是「语义」。
它的功能就是告诉编译器,对于传入的指针所指向的地址不进行静态优化,强制编译成运行时从内存取数据的操作。所以,拿到它的返回值后必须立刻处理才能达成这个目的,这也是这个函数返回值不可丢弃的原因,因为如果丢弃的话就没有什么意义了。
对应引子中的例子,我们既然已经知道出问题的语句是在f
函数中的返回语句上,编译期会认为*b
的值一定是5
,所以常量化了,那么,在这一行语句上加上std::launder
就可以解决问题:
#include <new> // launder在这个头文件中声明
int f(long *a, int *b) {
*b = 5;
*a = 1;
return *std::launder(b);
}
再用-O2
参数编译,可以得到结果:
f(long*, int*):
mov DWORD PTR [rsi], 5
mov eax, DWORD PTR [rsi] ; 这里真的去取数据了,而不是直接写常数5
mov QWORD PTR [rdi], 1
ret
唉?等会!好像还是不对呀,虽然它确实是从[rsi]
中取数据放到eax
中了,但是,这时候仍然放进去的是5
呀!因为赋值时1
的那句在下面。
别急,这个操作会有连带反应,这时我们找一个调用它的函数也编译一下:
void Demo() {
int x = 10;
int *p = &x;
auto q = (long *)&x;
auto ret = f(q, p);
std::printf("%d", ret);
}
会变成这样:
.LC0:
.string "%d"
Demo():
mov edi, OFFSET FLAT:.LC0
mov DWORD PTR [rsp-12], 1 ; 这里会写进去1
call printf
惊不惊喜!意不意外!实际上外层都没有实际去调用f
函数,它是整个优化了。所以这里-O2
参数法就是「语义」跟「实现」基本八竿子打不着的优化方式了,编译器似乎特别有自己的想法似的,它会认为它「读懂」了代码,然后重写了一份新的再去编译,看起来就是把整个代码搞得面目全非。
不过这里要强调一点,对于UB来说,编译期是可以自由发挥的,如果读者自行尝试时输出结果跟我不一致也不用奇怪,不同的编译器、甚至不同版本的编译器都可能有不同的行为。
而本节介绍的std::launder
仅仅是强制编译器取取地址而已,而如果代码顺序发生变化,那么它的结果可能仍然不是我们想要的,所以并不是说有了launder
就万事大吉,根本上来说要想彻底解决这个问题,只能是不开启-O
优化,或者是不使用这次UB。
UB到底能不能用?
需要注意的是,前面章节提到的这个例子是一个很极端的例子,必须很多条件都要同时具备,比如说:
- 必须是gcc较高版本的编译器;
- 必须开启
-O2
或更高级别的优化; f
函数的两个参数必须是不同含义的(比如说int *
和float *
也会触发,但如果是int *
和unsigned *
则不会触发);- 必须是编译为AMD64架构的指令;
因此,并不是说只要是UB就一定会触发bug,很多时候要随缘。所以相信很多读者也跟我一样,有一个虽然标准定义为UB的写法,但是写了很多年也从来没有出现过问题。
那么我们就可以发现,UB要想触发bug,必须满足两个条件:
- 代码中出现了未定义行为(UB);
- 编译器进行了优化,并且正巧这种优化引发了bug。
这个很好理解,如果你的代码本身就是标准定义的行为,那么无论编译器怎么优化,都必须保证行为一致。而即便用了UB,编译器如果不进行优化,那么我们还是可以知道这个行为的后果,做一些魔法操作。而就算编译器对UB进行优化了,也未必就会导致一个不合预期的结果。所以说,能复现出这种现象还是比较难的。
那么,到底能不能写UB呢?其实结论也显而易见,如果你能够确定这段代码编译后的行为,那么你就可以使用,否则不行。
举例来说,对于服务器后端开发的同学而言,通常情况下服务器的架构是明确的,程序只会进行一次编译,之后只要没有改动,那么就会一直在服务器上运行。对于这种场景来说,只要你的程序能通过测试,那么它就是正确的,因为UB是针对代码而言的,一旦经过编译,那么行为就是确定的了。
而如果对于那些写代码库的同学而言,你的代码可能会被不同的项目所引用,而使用你代码库的项目会使用哪种编译器、哪种架构、哪种编译参数等都是不确定的。那么在这种情况下,UB就可能造成部分实例命中bug。
因此,UB也并不是说一定不能使用,我们还是要根据实际场景,毕竟对于C++来说,UB呀、告警呀什么的也是家常便饭了……(手动狗头保命~)
4中行为(Behavior)
接下来我们就来详细介绍一下C++当中可能出现的4种行为,它们分别是:
- WB,Well-defined Behavior,明确定义的行为
- IB,Implementation-defined Behavior,由实现方定义的行为
- USB,Unspecified Behavior,未明确定义的行为
- UB,Undefined Behavior,未定义的行为
其中、WB和IB又合称SB,Specified Behavior,确定的行为。下面我们来分别解释:
Well-defined Behavior
这一个应当很好理解,WB就是语言标准中明确定义的行为,任何编译器实现都必须按照语言规定的方式,与优化与否无关。
Implementation-defined Behavior
IB则是语言标准中未定义,但是在编译器实现的时候会明确定义的,IB的行为也是确定的,但是对编译环境有要求,不同环境可能会出现不同行为。
举个例子来说,sizeof(long)
的行为就是一个IB,因为long
在不同环境下的长度不同,32位环境下长度是4,而64位环境下长度是8。但对于这个IB来说,只要环境的字长确定了,那么这个值就也是确定的,所以它也属于SB,也就是确定的行为。
Unspecified Behavior
USB这个缩写歧义比较大(你懂得……),所以一般也不会出现,我这里就直接用中文翻译来讲了。
未明确定义的行为指的是,它的行为一定在一个集合内,但具体不确定。举例来说,一个非常经典的逗号运算的结合顺序:
int i = 0;
int j = ++i, i; // 请问j的值?
我们知道逗号运算的返回值是最右边的表达式,但是在逗号运算中是从左到右处理还是从右到左处理,这是没有明确定义的。但是,它的结果只能是「从左到右」或「从右到左」的其中一种,不可能出现「从中间劈开处理」的这种……
再比如说:
int f1(int &a) {
if (a > 0) {
a++;
}
return a;
}
int f2(int &a) {
a = 0;
return 0;
}
void Demo() {
int i = 0;
int j = f1(i) + f2(i); // 请问j的值?
}
同理,f1
和f2
谁先算是不确定的,但是反正肯定要么先算f1
,要么先算f2
,不会出现其他的情况,所以这也是一个未明确定义的行为。
未明确定义的行为虽然说结果也不确定,但一般都是比较可控的,而且即便出现了不符合预期的情况,它也不会太离谱,通常情况下可以快速定位到。
Undefined Behavior
UB就是今天的主角了,它就是哪哪都没有定义的行为,非常的薛定谔,哪怕就是换个编译器版本,或者只是换个编译参数,都可能会导致行为发生改变。这也是我们需要注意的地方,因为UB命中bug的时候(参考引子中的例子)通常都会完全超乎意料之外,非常不容易定位到。
几个有趣的UB
那么既然能遇到薛定谔的编译器优化,会让UB变得也如此薛定谔,那我们就来盘一盘哪些UB是我们可能容易踩到的,在编译器优化的时候会出现离谱的情况。(注:这里笔者也只是总结了一些常见的比较经典的UB,但肯定不全面,后续发现了还会进行补充,如果读者知道其他有趣的UB也欢迎评论区补充~)
重解释转换
这个应该不用多说了,前面篇幅的例子已经很好地解释了这个问题。对指针进行重解释转换后,再使用原先的指针,就会触发UB。
我们知道在语义层面,「指针」是与「对象」相绑定的,也就是说一个指针,就表示某一个对象的索引(正是因为这种语义,C++才引入了「引用」,因为它可以更好地表示这种属性)。而在「实现」层面,指针则是一个单独的变量,只不过保存了一个内存地址而已。如果进行重解释转换,在语义上就表示所指对象发生了改变,那么原本的对象就应该没有了才对。
int16_t a = 5; // 原本的对象
int16_t *p = &a; // p就是a的指针
uint8_t *q = reinterpret_cast<uint8_t *>(p); // 语义上来说,这里应当是生成了一个新的对象,只是新的对象复用了原本对象的地址而已
*q = 8; // WB
*p = 0; // UB
a = 1; // UB
上面的例子中,p
和q
在语义层面就是指向两个不同的对象的,但是实现的时候,它们却指向了同一片内存空间。所以,如果编译器进行优化,就会忽略它们可能重叠的这一特性。
因此,如果对指针进行了重解释转换,那么从语义上来说,原来的对象就应当被替代了。用上面的例子讲就是,当p
转换为q
后,q
指向的是一个新的uint8_t
类型的对象,代替了原本int16_t
类型的对象,那么这一句之后,对p
进行解指针就会成为UB,同理操作变量a
也是UB。
placement new
这个跟上一节的UB比较类似,都是「复用了地址」但「不同对象」的情况。举例来说:
struct Test {
int a, b;
};
void Demo() {
Test *p1 = new Test{1, 2}; // 原对象
Test *p2 = new(p1) Test{10, 20}; // 就地构造新对象
p2->a = 5; // WB
p1->b = 10; // UB
}
从语义上来说,p1
和p2
指向两个对象,但由于p2
是在p1
指向的位置上构造的,那么照理说,原本的对象就被代替了,不应当再去使用。所以,在placement new语句后,再对p1
进行任何解操作就属于UB。
不过这种UB(包括上一节的情况,以及比如说realloc
之类的情况)是可以有办法破解的,就是使用std::launder
,比如说:
p1->b = 10; // UB
std::launder(p1)->b = 10; // WB
为什么呢?因为launder
的语义就是「读取内存」,它已经脱离了原本「指向某个对象」的这种语义了。这里p1
指向的对象已经被代替了,所以操作它是UB,而std::launder(p1)
表示的是「操作p1
所指向的内存空间」,这种语义下并不再指向原本的对象,那自然就不是UB了。
if语句的假定
来看下面这个例子:
void Demo(int *p) {
*p = 5;
if (p == nullptr) {
// ...
return;
}
}
我们是在对p
进行解操作以后才对p
进行判空的(这里可能是不小心写错了,或者说考虑了一些多线程的情况之类的吧,反正就是结果写成上面这样了),但这已经出发了UB,编译期优化时会认为,如果p
为空,那么*p = 5
这一句就会CoreDump了,所以,能走到if
这里就说明p
一定不为空,所以判定这是一个恒为假的if
语句,从而直接不编译。
再来看一种:
int Demo(int a) {
int b; // 这里没有初始化
if (a > 0) {
b = 5;
}
return b;
}
变量不进行初始化,会触发UB,在编译器优化时,会认为直接把未初始化的变量返回是不正常的,所以就会把这里的if
优化为恒为真,也就是默认a
是大于零的,因此最终会把调用Demo
函数的地方直接编译成常数5
。(有木有很离谱~)
返回值遗漏
看这样一个例子:
int g_count; // 一个全局变量计数器
int Demo() {
while (g_count-- > 0) {
std::cout << "Get it!" << std::endl;
}
// 这里丢弃了返回值
}
一个有int
返回值的函数,但没有书写返回语句,会触发UB。而在编译器优化时,会直接把上面的循环编译成死循环,也就是一直输出"Get it!"。
溢出
有一个比较离谱的事情,无符号数的反转是WB,但有符号的溢出是UB,请看下面的例子:
bool f1(int8_t x) {
return x > x + 1;
}
bool f2(uint8_t x) {
return x > x + 1;
}
正常来讲,f1
中如果x
是127
时,加一发生溢出,应当返回true
,其他情况返回false
。而f2
中是255
时加一发生反转,返回true
,其他情况返回false
。
但有「溢出」是一种UB,反转则是「WB」,所以f1
会被优化为返回恒false
,而f2
则会被优化为return !(x ^ 0xff);
。
编译器的优化方向
其实从上一章的几个例子中,我们能发现一个规律,就是说编译器在优化时,都是「假定UB不会发生」为原则去进行优化的,如果出现了UB,则会以当前语句不会执行到为前提进行预想,然后优化代码。
就比如说引子中的例子,重解释转换后使用原指针是UB,那么编译期优化的时候就会假定两个参数并不是通过重解释转换得来的,也就是说两个指针的值一定不相同。以这个为原则进行优化,自然就会出现一开始的结果。
总结
以上就是以一个有趣的例子为引子,引发笔者对UB和编译器优化方向的一些学习和研究,分享给大家。