记一次EMOCK引起的段错误异常

EMOCK简介

可以去看我的另一篇文章,介绍了EMOCK的功能和其工作原理。

发生了什么问题

在一些X86机器上,突然发现定义变量时发生了Segmentation Fault。

1
2
3
4
5
6
int test_function_body()
{
// ... 几十行代码
void *ctx = nullptr; // 通过GDB发现这行发生了段错误,并且必现
// ... 几十行代码
}

定位思路

思路1:堆栈溢出

通常在栈中发生了段错误,第一反应就是栈溢出,而栈溢出通常罪魁祸首只有两个,一个是无限递归,另一个是栈中出现过大的变量。

通过检视代码,发现最大的栈变量也只有几KB的大小,并且在gdb中使用backtrace命令也未发现递归调用的情况,这两种情况都排除了后,我开始思考更进一步的可能性。

思路2:越界写

排除了溢出的可能性,我考虑的第一种可能性是越界写栈数组导致存储上一级(或多级)函数的栈顶指针被破坏,在函数退出恢复寄存器时导致%rsp内容有误,进而导致写入位置错误。这种问题也同样容易排除,只需要在gdb中使用info reg就可以获得寄存器数值。程序由gdb启动时,gdb会关闭其ASLR,所以每次运行到同一位置时每个变量的内存地址都是固定的。查看%rsp寄存器数值,发现其处于通常的栈范围内0x7fff...

当时我认为这种可能性很低,因为%rsp被破坏通常也伴随着pc也被破坏,然而执行流程没有错误,这就让这种可能性降到很低了。不过由于这个排查非常容易,还是先排查了这个。

思路3:从根本原因出发

在容易排查的问题排查过后,我决定从根本原因出发。在非嵌入式设备上,CPU和操作系统共同通过MMU来对进程的内存进行页式管理。程序的虚拟内存虽然有128TB(x86_64用户空间典型值),但是需要被操作系统映射之后才能被使用,并且其有读取、写入、执行三个权限。当程序尝试访问未被映射的内存或操作与内存的权限不匹配时(例如尝试写入r-xp的内存),操作系统则会给进程发送SIGINT信号,这通常会导致进程因Segmetation Fault终止。

在有以上的基础知识后,我们可以通过 cat /proc/$pid/maps在*nix系统上获取对应进程的内存映射布局。例如,可以使用 cat /proc/self/maps获取我们正在使用的这个cat进程的内存布局,示例如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
59bf1d469000-59bf1d46b000 r--p 00000000 103:01 580                       /usr/bin/cat
59bf1d46b000-59bf1d46f000 r-xp 00002000 103:01 580 /usr/bin/cat
59bf1d46f000-59bf1d471000 r--p 00006000 103:01 580 /usr/bin/cat
59bf1d471000-59bf1d472000 r--p 00007000 103:01 580 /usr/bin/cat
59bf1d472000-59bf1d473000 rw-p 00008000 103:01 580 /usr/bin/cat
59bf1e4ea000-59bf1e50b000 rw-p 00000000 00:00 0 [heap]
........ 省略了一些不必要的细节
7e6c76600000-7e6c76628000 r--p 00000000 103:01 68112 /usr/lib/x86_64-linux-gnu/libc.so.6
........ 省略了一些不必要的细节
7e6c76979000-7e6c7697b000 rw-p 00039000 103:01 34594 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fff01941000-7fff01962000 rw-p 00000000 00:00 0 [stack]
7fff019de000-7fff019e2000 r--p 00000000 00:00 0 [vvar]
7fff019e2000-7fff019e4000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]

这里忽略了一些so和其他文件的映射,以专注于栈内存和其他内存的区别。

后面标注了[stack]的就是栈内存,其他的内存有堆、二进制本身、系统调用、动态链接库,这些map的作用这里不做详细介绍,这里我们只看栈的内存。

可以通过计算器轻松算出这个进程栈的大小 0x7fff01962000 - 0x7fff01941000 = 135_168,为132KB,共计33个4K页面。可以看到这个进程占用的栈空间是很小的。当时发生错误的程序也是大约100K左右的内存。那么是因为进程的栈空间太小才发生了这个问题吗?显然不是的。

在Linux上,可以使用ulimit -s获得栈的最大大小,单位为KB,通常栈的大小在Linux上为8192KB,也即8MB,Windows上为1024KB,而实际上当时机器的最大栈大小都是8MB。

Linux栈内存的特殊性

我的另一篇文章对Linux内核的内存管理做了简单的分析,那里提到了向下增长栈Linux在使用没有被映射的栈内存时,会有一个expand_downwards的过程,这个过程通常需要有足够的,简单来说,就是一个进程的主线程的栈的大小会随着使用自动向下扩展,而非一开始就map出足够的空间。这也就导致了大部分程序的主线程栈都只会map几十到几百KB,因为这些程序实际只使用了这么大的空间,所以操作系统也只会为栈分配这么多空间,但只有主线程才会这样做。

由EMOCK导致的问题

通过获取该进程的map,果然发现了一个问题。我发现就在栈顶(也就是栈的最低地址处)有另一个映射紧贴着栈顶分配了4KB空间,这导致栈无法自动向下扩展,当栈变量用完了原有的栈空间,并且也用完了这紧贴着的4KB空间后,栈无法继续增长,也就导致了栈变量赋值到未映射的内存中,进而导致了进程段错误。

1
2
3
7e6c76979000-7e6c7697b000 rw-p 00039000 103:01 34594                     ld.so.2
7fff01940000-7fff01941000 rwxp 00000000 00:00 0 <==== 这里多出来一个没名字的映射顶到栈了
7fff01941000-7fff01962000 rw-p 00000000 00:00 0 [stack]

由于这段内存具有少见的可执行属性(rwxp中的x),而我们的程序并没有JIT系统,所以优先怀疑emock等可能申请可执行内存的库。不过此时代码检视就显得过于麻烦,于是我采取了另一种方案:使用gdb跟踪mmap函数。因为这种类型的内存一定是mmap分配出来的。

于是,我通过gdb为mmap函数做断点,成功在发现一次EMOCK调用后,该未知内存被分配出来。但是我没有进一步定位问题根因(懒狗),推测有两种可能性,一种是EMOCK的bug,另一种是不当使用EMOCK,导致EMOCK分配位置错误。

定位到该原因后,虽然无法立即解决,但可以为该问题做规避方案了。于是我新增一个函数,并在初始化过程中调用。

1
2
3
4
5
6
7
8
__attribute__((noinline, optimize("O0"))) void fill_stack()
{
#define SIZE (4 * 1024 *1024)
char data[SIZE];
for (int i = SIZE - 1; i >= 0; i++) {
data[i] = 1;
}
}

这段代码会在整个测试的初始化时期调用,通过调用该代码,函数会先保存至少4MB的栈空间,这样就可以让程序有足够的栈空间运行而不会发生段错误,而EMOCK在这之后即使想要紧贴栈顶map内存,也要距离当前栈指针差不多4MB空间才可以map,这样就最大程度避免了栈空间还不大时就无法向下扩展的问题。

另外简单介绍几个其中的trick:

  1. 这里没有初始化数组,并且是反向遍历,而非正向遍历,这是尽可能沿着栈生长的方向填充。
  2. 填充1而不是0,其实在指定不优化时,0和1都没什么区别,但是优化时填充1可以保证让系统实际map物理内存,虽然在这个场景下实际map物理内存并没有什么意义。
  3. 通过 __attribute__指定函数不被内联,让函数return后栈空间一定可以被其他函数使用。
  4. 同样通过__attribute__指定不优化,否则这种代码很可能被聪明的编译器优化没。

最终定位

几天后这个问题被注意到,于是尝试确定问题根因,最终定位到居然是编译器的问题。其中一行代码如下。

1
2
3
if (std::labs((long)dst - (long)last_end) < kMaxAllocationDelta /*这个数字是2G*/)) {
// 这里应该触发
}

gdb断点追踪,多次逐行运行,查看变量并计算发现其确实小于2G,但却没有触发mmap,实在无法理解的我开始逐个查看寄存器与汇编。发现其对应的汇编如下。

1
2
3
4
5
6
; ...
mov -0x10e0(%rbp), %rdx
sub %rbx, %rdx
cmp $0x7fffffff, %rdx
ja 0x4ff810 ; emock::TrampolineAllocate 这里是无符号比较 (jmp above)
; ...

可以看到,其加载到寄存器后,仅仅做了一个减法,并将结果与2G-1进行比较,如果无符号整数的情况下大于2G-1则跳转出去不去运行if块的内容,而这个剑法的结果为负数,补码表示远远大于2G-1,这也就导致了被跳过。也就是说,这里的std::labs根本没有生效。

而一个真正做了std::labs的函数,其反汇编是这样的。

1
2
3
4
5
6
7
sub      %rax, %rbx          ; 先做正常的减法
mov %rbx, %rax ; 将结果复制一份
sar $0x3f, %rax ; 实现负数取绝对值的关键操作,如果是负数这里64位全1,正数则全0
xor %rax, %rbx ; 负数异或全1了以后符号位和数字位全部取反,相当于 rbx = -rbx - 1,正数则异或全0不变
sub %rax, %rbx ; 这里是 rbx = rbx - rax ,如果是负数就减-1,把上条指令的rbx少的1又加了回来,正数则减0不变
cmp $0x7fffffff, %rbx ; 与2G-1比较
jg if_block ; 有符号跳转 (jmp greater)

总结

真是屎山上又拉了一坨看上去毫无意义的屎,没想到有一天我也能写出“不要删除这个函数,虽然它看上去没有用,但删除它程序会崩溃”的代码。

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2019-2025 Ytyan

请我喝杯咖啡吧~