摘要:一個(gè)有意思的 Crash 探究過(guò)程,Clang 有 GCC 沒(méi)有
本文首發(fā)于 Nebula Graph 官方博客:https://nebula-graph.com.cn/posts/troubleshooting-crash-clang-compiler-optimization/

如果有人告訴你,下面的 C++ 函數(shù)會(huì)導(dǎo)致程序 crash,你會(huì)想到哪些原因呢?
std::string b2s(bool b) {
return b ? "true" : "false";
}
如果再多給一些描述,比如:
- Crash 以一定的概率復(fù)現(xiàn)
- Crash 原因是段錯(cuò)誤(SIGSEGV)
- 現(xiàn)場(chǎng)的 Backtrace 經(jīng)常是不完整甚至完全丟失的。
- 只有優(yōu)化級(jí)別在 -O2 以上才會(huì)(更容易)復(fù)現(xiàn)
- 僅在 Clang 下復(fù)現(xiàn),GCC 復(fù)現(xiàn)不了
好了,一些老鳥(niǎo)可能已經(jīng)有線索了,下面給出一個(gè)最小化的復(fù)現(xiàn)程序和步驟:
// 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;
}
$ 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 ?? ()
因?yàn)?backtrace 信息不完整,說(shuō)明程序并不是在第一時(shí)間 crash 的。面對(duì)這種情況,為了快速找出第一現(xiàn)場(chǎng),我們可以試試 AddressSanitizer(ASan):
$ 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 給出的信息,我們可以定位到是函數(shù) b2s(bool) 在讀取字符串常量 "true" 的時(shí)候,發(fā)生了“全局緩沖區(qū)溢出”。好了,我們?cè)俅我陨系垡暯菍徱曇幌聠?wèn)題函數(shù)和復(fù)現(xiàn)程序,“似乎”可以得出結(jié)論:因?yàn)?b2s 的布爾類(lèi)型參數(shù) b 沒(méi)有初始化,所以 b 中存儲(chǔ)的是一個(gè) 0 和 1 之外的值[1]。那么問(wèn)題來(lái)了,為什么 b 的這種取值會(huì)導(dǎo)致“緩沖區(qū)溢出”呢?感興趣的可以將 b 的類(lèi)型由 bool 改成 char 或者 int,問(wèn)題就可以得到修復(fù)。
想要解答這個(gè)問(wèn)題,我們不得不看下 clang++ 為 b2s 生成了怎樣的指令(之前我們提到 GCC 下沒(méi)有出現(xiàn) crash,所以問(wèn)題可能和代碼生成有關(guān))。在此之前,我們應(yīng)該了解:
- 樣例程序中,
b2s的返回值是一個(gè)臨時(shí)的std::string對(duì)象,是保存在棧上的 - C++ 11 之后,GCC 的
std::string默認(rèn)實(shí)現(xiàn)使用了 SBO(Small Buffer Optimization),其定義大致為std::string{ char *ptr; size_t size; union{ char buf[16]; size_t capacity}; }。對(duì)于長(zhǎng)度小于16的字符串,不需要額外申請(qǐng)內(nèi)存。
OK,那我們現(xiàn)在來(lái)看一下 b2s 的反匯編并給出關(guān)鍵注解:
(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,%ecx # 將 "true" 的起始地址保存至 ecx
0x0040120c <+12>: mov $0x402015,%eax # 將 "false" 的起始地址保存至 eax
0x00401211 <+17>: test %esi,%esi # “測(cè)試” 參數(shù) b 是否非零
0x00401213 <+19>: cmovne %rcx,%rax # 如果 b 非零,則將 "true" 地址保存至 rax
0x00401217 <+23>: lea 0x10(%rdi),%rdi # 將 string 中的 buf 起始地址保存至 rdi
# (同時(shí)也是后面 memcpy 的第一個(gè)參數(shù))
0x0040121b <+27>: mov %rdi,(%r14) # 將 rdi 保存至 string 的 ptr 字段,即 SBO
0x0040121e <+30>: mov %esi,%ebx # 將 b 的值保存至 ebx
0x00401220 <+32>: xor $0x5,%rbx # 將 0x5 異或到 rbx(也即 ebx)
# 注意,如果 rbx 非 0 即 1,那么 rbx 保存的就是 4 或 5,
# 即 "true" 或 "false" 的長(zhǎng)度
0x00401224 <+36>: mov %rax,%rsi # 將字符串起始地址保存至 rsi,即 memcpy 的第二個(gè)參數(shù)
0x00401227 <+39>: mov %rbx,%rdx # 將字符串的長(zhǎng)度保存至 rdx,即 memcpy 的第三個(gè)參數(shù)
0x0040122a <+42>: callq <memcpy@plt> # 調(diào)用 memcpy
0x0040122f <+47>: mov %rbx,0x8(%r14) # 將字符串長(zhǎng)度保存到 string::size
0x00401233 <+51>: movb $0x0,0x10(%r14,%rbx,1) # 將 string 以 '\0' 結(jié)尾
0x00401239 <+57>: mov %r14,%rax # 將 string 地址保存至 rax,即返回值
0x0040123c <+60>: add $0x8,%rsp
0x00401240 <+64>: pop %rbx
0x00401241 <+65>: pop %r14
0x00401243 <+67>: retq
End of assembler dump.
到這里,問(wèn)題就無(wú)比清晰了:
- clang++ 假設(shè)了
bool類(lèi)型的值非0即1 - 在編譯期,
”true”和”false”長(zhǎng)度已知 - 使用異或指令(
0x5 ^ false == 5,0x5 ^ true == 4)計(jì)算要拷貝的字符串的長(zhǎng)度 - 當(dāng)
bool類(lèi)型不符合假設(shè)時(shí),長(zhǎng)度計(jì)算錯(cuò)誤 - 因?yàn)?
memcpy目標(biāo)地址在棧上(僅對(duì)本例而言),因此棧上的緩沖區(qū)也可能溢出,從而導(dǎo)致程序跑飛,backtrace 缺失。
注:
- C++ 標(biāo)準(zhǔn)要求
bool類(lèi)型至少能夠表示兩個(gè)狀態(tài):true和false,但并沒(méi)有規(guī)定sizeof(bool)的大小。但在幾乎所有的編譯器實(shí)現(xiàn)上,bool都占用一個(gè)尋址單位,即字節(jié)。因此,從存儲(chǔ)角度,取值范圍為0x00-0xFF,即256個(gè)狀態(tài)。
喜歡這篇文章?來(lái)來(lái)來(lái),給我們的 GitHub 點(diǎn)個(gè) star 表鼓勵(lì)啦~~ ???♂????♀? [手動(dòng)跪謝]
交流圖數(shù)據(jù)庫(kù)技術(shù)?交個(gè)朋友,Nebula Graph 官方小助手微信:NebulaGraphbot 拉你進(jìn)交流群~~