Placement New Conditionally Zeros out Memory

之前公司在把项目使用的gcc版本从5.4升级到7.5,迁移的过程中发现了一个存在了很久的访问未初始化成员导致 segfault 的问题。但奇怪的问题是使用旧版本gcc来编译时从来没有触发过 segfault,经过一番调试后发现各大主流编译器都会在placement new调用对象的构造函数之前插入清零目的内存的指令,这导致之前使用gcc 5.4编译时那个未初始化的成员在实际上被置零了,而在切换到gcc 7.5之后同样的编译选项编译器不再在placement new之前插入内存清零动作,从而导致了 segfault。

举个例子

考虑如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <new>
#include <string.h>
#include <stdio.h>

template<class T>
class TestA {
public:
TestA() {
a = 3;
}

virtual ~TestA() {
}

void Print() {
printf("%x\n", a);
for (int i = 0; i < sizeof b; i++) {
printf("%hhx ", b[i]); /* UB here actually */
}
}

private:
int a;
unsigned char b[sizeof(T)];
};

class Dummy {
public:
char b[124];
};

class TestB : public TestA<Dummy> {
public:
TestB() = default;
};

int main() {
auto *buffer = new unsigned char[500];
memset(buffer, 0xa0, 500);
auto *a = new (&buffer[0]) TestB();
a->Print();
a->~TestB();
delete[] buffer;
return 0;
}

linux-amd64平台,上述代码使用g++-5 -O1编译后运行会输出3\n0 0 0 0 0 0...,而使用g++-7 -O1编译后则会输出3\na0 a0 a0 a0 a0...

汇编分析

利用 https://gcc.godbolt.org 这个网站可以方便地查看主流编译器的汇编代码,以文中的两个gcc版本为例:

太小的话可以查看原始网页

注意其中auto *a = new (&buffer[0]) TestB();这行对应的汇编代码。在gcc 5.4 中,其对应的汇编为:

1
2
3
4
5
6
7
8
test    r12, r12
je .L8
mov ecx, 17
mov eax, 0
mov rdi, r12
rep stosq
mov DWORD PTR [r12+8], 3
...

以上汇编中r12即为buffer的地址,该段代码的作用为将地址r12开始到r12+17*8的地址中的内容置为0,也即对new的对象内存进行了清零操作。其后的mov DWORD PTR [r12+8], 3则是TestA构造函数中对Test::a的初始化,其中r12+8跳过了vtable部分。

而在gcc 7.5中对应的汇编为:

1
2
3
4
test    r12, r12
je .L5
mov DWORD PTR [r12+8], 3
...

可见只剩下了TestA::A构造函数相关的汇编代码,并没有在其之前对内存进行清零。清零的行为从gcc 6.1开始发生变化,但是我在gcc 6.1的 changelog 中并没有找到相关的描述,再详细的原因可能需要去扒编译器代码了。MSVC 也会有这个行为,而且即使开了/O2优化仍然会进行清零动作,感兴趣的读者可以自己尝试看看不同编译器以及不同平台所产生的汇编。