摘要:一个有意思的 Crash 探究过程,Clang 有 GCC 没有
本文首发于 Nebula Graph 官方博客:https://nebula-graph.com.cn/posts/troubleshooting-crash-clang-compiler-optimization/
如果有人告诉你,下面的 C 函数会导致程序 crash,你会想到哪些原因呢?
代码语言:txt复制std::string b2s(bool b) {
return b ? "true" : "false";
}
如果再多给一些描述,比如:
- Crash 以一定的概率复现
- Crash 原因是段错误(SIGSEGV)
- 现场的 Backtrace 经常是不完整甚至完全丢失的。
- 只有优化级别在 -O2 以上才会(更容易)复现
- 仅在 Clang 下复现,GCC 复现不了
好了,一些老鸟可能已经有线索了,下面给出一个最小化的复现程序和步骤:
代码语言:txt复制// file crash.cpp
#include <iostream>
#include <string>
std::string __attribute__((noinline)) b2s(bool b) {
return b ? "true" : "false";
}
union {
unsigned char c;
bool b;
} volatile u;
int main() {
u.c = 0x80;
std::cout << b2s(u.b) << std::endl;
return 0;
}
代码语言:txt复制$ clang -O2 crash.cpp
$ ./a.out
truefalse,d$x4DdzRx
Segmentation fault (core dumped)
$ gdb ./a.out core.3699
Core was generated by `./a.out'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0 0x0000012cfffff0d4 in ?? ()
(gdb) bt
#0 0x0000012cfffff0d4 in ?? ()
#1 0x00000064fffff0f4 in ?? ()
#2 0x00000078fffff124 in ?? ()
#3 0x000000b4fffff1e4 in ?? ()
#4 0x000000fcfffff234 in ?? ()
#5 0x00000144fffff2f4 in ?? ()
#6 0x0000018cfffff364 in ?? ()
#7 0x0000000000000014 in ?? ()
#8 0x0110780100527a01 in ?? ()
#9 0x0000019008070c1b in ?? ()
#10 0x0000001c00000010 in ?? ()
#11 0x0000002ffffff088 in ?? ()
#12 0xe2ab001010074400 in ?? ()
#13 0x0000000000000000 in ?? ()
因为 backtrace 信息不完整,说明程序并不是在第一时间 crash 的。面对这种情况,为了快速找出第一现场,我们可以试试 AddressSanitizer(ASan):
代码语言:txt复制$ clang -g -O2 -fno-omit-frame-pointer -fsanitize=address crash.cpp
$ ./a.out
=================================================================
==3699==ERROR: AddressSanitizer: global-buffer-overflow on address 0x000000552805 at pc 0x0000004ff83a bp 0x7ffd7610d240 sp 0x7ffd7610c9f0
READ of size 133 at 0x000000552805 thread T0
#0 0x4ff839 in __asan_memcpy (a.out 0x4ff839)
#1 0x5390a7 in b2s[abi:cxx11](bool) crash.cpp:6
#2 0x5391be in main crash.cpp:16:18
#3 0x7faed604df42 in __libc_start_main (/usr/lib64/libc.so.6 0x23f42)
#4 0x41c43d in _start (a.out 0x41c43d)
0x000000552805 is located 59 bytes to the left of global variable '<string literal>' defined in 'crash.cpp:6:25' (0x552840) of size 6
'<string literal>' is ascii string 'false'
0x000000552805 is located 0 bytes to the right of global variable '<string literal>' defined in 'crash.cpp:6:16' (0x552800) of size 5
'<string literal>' is ascii string 'true'
SUMMARY: AddressSanitizer: global-buffer-overflow (/home/dutor.hou/Wdir/nebula-graph/build/bug/a.out 0x4ff839) in __asan_memcpy
Shadow bytes around the buggy address:
…
...
从 ASan 给出的信息,我们可以定位到是函数 b2s(bool)
在读取字符串常量 "true"
的时候,发生了“全局缓冲区溢出”。好了,我们再次以上帝视角审视一下问题函数和复现程序,“似乎”可以得出结论:因为 b2s
的布尔类型参数 b
没有初始化,所以 b
中存储的是一个 0
和 1
之外的值1。那么问题来了,为什么 b
的这种取值会导致“缓冲区溢出”呢?感兴趣的可以将 b
的类型由 bool
改成 char
或者 int
,问题就可以得到修复。
想要解答这个问题,我们不得不看下 clang 为 b2s
生成了怎样的指令(之前我们提到 GCC 下没有出现 crash,所以问题可能和代码生成有关)。在此之前,我们应该了解:
- 样例程序中,
b2s
的返回值是一个临时的std::string
对象,是保存在栈上的 - C 11 之后,GCC 的
std::string
默认实现使用了 SBO(Small Buffer Optimization),其定义大致为std::string{ char *ptr; size_t size; union{ char buf[16]; size_t capacity}; }
。对于长度小于16
的字符串,不需要额外申请内存。
OK,那我们现在来看一下 b2s
的反汇编并给出关键注解:
(gdb) disas b2s
Dump of assembler code for function b2s[abi:cxx11](bool):
0x00401200 < 0>: push %r14
0x00401202 < 2>: push %rbx
0x00401203 < 3>: push %rax
0x00401204 < 4>: mov %rdi,%r14 # 将返回值(string)的起始地址保存到 r14
0x00401207 < 7>: mov $0x402010,�x # 将 "true" 的起始地址保存至 ecx
0x0040120c < 12>: mov $0x402015,�x # 将 "false" 的起始地址保存至 eax
0x00401211 < 17>: test %esi,%esi # “测试” 参数 b 是否非零
0x00401213 < 19>: cmovne %rcx,%rax # 如果 b 非零,则将 "true" 地址保存至 rax
0x00401217 < 23>: lea 0x10(%rdi),%rdi # 将 string 中的 buf 起始地址保存至 rdi
# (同时也是后面 memcpy 的第一个参数)
0x0040121b < 27>: mov %rdi,(%r14) # 将 rdi 保存至 string 的 ptr 字段,即 SBO
0x0040121e < 30>: mov %esi,�x # 将 b 的值保存至 ebx
0x00401220 < 32>: xor $0x5,%rbx # 将 0x5 异或到 rbx(也即 ebx)
# 注意,如果 rbx 非 0 即 1,那么 rbx 保存的就是 4 或 5,
# 即 "true" 或 "false" 的长度
0x00401224 < 36>: mov %rax,%rsi # 将字符串起始地址保存至 rsi,即 memcpy 的第二个参数
0x00401227 < 39>: mov %rbx,%rdx # 将字符串的长度保存至 rdx,即 memcpy 的第三个参数
0x0040122a < 42>: callq <memcpy@plt> # 调用 memcpy
0x0040122f < 47>: mov %rbx,0x8(%r14) # 将字符串长度保存到 string::size
0x00401233 < 51>: movb $0x0,0x10(%r14,%rbx,1) # 将 string 以 '