记一起由 Clang 编译器优化触发的 Crash
如果有人告诉你,下面的 C++ 函数会导致程序 crash,你会想到哪些原因呢?
如果再多给一些描述,比如:
● Crash 以一定的概率复现
● Crash 原因是段错误(SIGSEGV)
● 现场的 Backtrace 经常是不完整甚至完全丢失的。
● 只有优化级别在 -O2 以上才会(更容易)复现
● 仅在 Clang 下复现,GCC 复现不了
好了,一些老鸟可能已经有线索了,下面给出一个最小化的复现程序和步骤:
因为 backtrace 信息不完整,说明程序并不是在第一时间 crash 的。面对这种情况,为了快速找出第一现场,我们可以试试 AddressSanitizer(ASan):
从 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 的反汇编并给出关键注解:
到这里,问题就无比清晰了:
1. clang++ 假设了 bool 类型的值非 0 即 1
2. 在编译期, ”true” 和 ”false” 长度已知
3. 使用异或指令( 0x5 ^ false == 5, 0x5 ^ true == 4 )计算要拷贝的字符串的长度
4. 当 bool 类型不符合假设时,长度计算错误
5. 因为 memcpy 目标地址在栈上(仅对本例而言),因此栈上的缓冲区也可能溢出,从而导致程序跑飞,backtrace 缺失。
注:
1. C++ 标准要求 bool 类型至少能够表示两个状态: true 和 false ,但并没有规定 sizeof(bool) 的大小。但在几乎所有的编译器实现上, bool 都占用一个寻址单位,即字节。因此,从存储角度,取值范围为 0x00-0xFF ,即 256 个状态。