当堆栈指针没有16字节对齐时,libc的system()函数会导致分段错误。

11

当我在x86-64架构的Linux上使用libc的system()函数时,我注意到了一个非常奇怪的行为,有时调用system()函数会失败并导致段错误,下面是我在使用gdb进行调试后得到的信息。

我发现这个段错误是在以下代码行中引发的:

=> 0x7ffff7a332f6 <do_system+1094>: movaps XMMWORD PTR [rsp+0x40],xmm0

根据手册,这就是SIGSEGV的原因:

当源操作数或目标操作数是内存操作数时,操作数必须在16字节边界上对齐,否则会生成通用保护异常(#GP)。

进一步深入查看后,我发现我的rsp值确实没有16字节填充(即其十六进制表示不以0结尾)。在调用system之前手动修改rsp的值确实使一切正常。
所以我编写了以下程序:
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    register long long int sp asm ("rsp");
    printf("%llx\n", sp);

    if (sp & 0x8) /* == 0x8*/
    { 
        printf("running system...\n");
        system("touch hi");
    } 

    return 0;
}

使用gcc 7.3.0编译 观察输出结果:

sha@sha-desktop:~/Desktop/tda$ ltrace -f ./o_sample2
[pid 26770] printf("%llx\n", 0x7ffe3eabe6c87ffe3eabe6c8
)                                           = 13
[pid 26770] puts("running system..."running system...
)                                                  = 18
[pid 26770] system("touch hi" <no return ...>
[pid 26771] --- SIGSEGV (Segmentation fault) ---
[pid 26771] +++ killed by SIGSEGV +++
[pid 26770] --- SIGCHLD (Child exited) ---
[pid 26770] <... system resumed> )           = 139
[pid 26770] +++ exited (status 0) +++

因此,使用这个程序,我不能执行system()
另外还有一件小事,我无法确定它是否与问题有关,几乎所有的运行都以错误的rsp值和被SEGSEGV杀死的子进程结束。
这让我想到了一些问题:
  1. system为什么要干扰xmm寄存器?
  2. 这是正常的行为吗?或者我在正确使用system()函数方面可能遗漏了一些基本的东西?
提前感谢。

1
16(或32字节)对齐是X86-64 System V ABI的要求。它需要适当的对齐。_C_库通常出于性能原因使用XMM寄存器和相关指令。这是正常行为。使用register long long int sp asm("rsp");并将其作为变量访问而不使用扩展内联汇编是未定义的行为。只有幸运才能工作。我想看看原始代码,看看它为什么失败了。你能给我们展示一下你调用系统时失败的原始代码吗?这听起来像一个XY问题。 - Michael Petch
3
问题在于你的代码。编译器会自动保持对齐。你的 ROB 代码有问题,需要按照 x86-64 ABI 在调用 system 前维护对齐。像你建议的将 RSP 四舍五入到最近的 16 字节边界可以解决问题。我猜你是用 and rsp, -16 这样的方法实现的吧?ABI 规定,在函数调用时,栈指针需要 16 字节(某些情况下为 32 字节)对齐。使用对齐向量指令来提高性能(这很正常)也没有问题,比如 system 函数。 - Michael Petch
@MichaelPetch 所以我的代码的 rsp 没有对齐是因为 asm 的原因吗?我认为这是一个已知的指令对于 gcc,除了编译我的程序之外,它不应该也不会“搞乱”我的 rsp,对吗? - shaqed
1
程序可以编译,但这并不意味着它们会正确运行。是的,汇编语言是问题的根源。GCC维护其自身代码的对齐以确保满足对齐要求。它不知道任何ROB代码正在运行。在调用“system”之前,ROB代码必须确保维护对齐。如果您在ROB代码中进行的任何操作使堆栈失去对齐,则需要您来对齐它。 - Michael Petch
@MichaelPetch:我认为 int foo asm("rsp") 不应该导致你的代码编译错误,特别是如果你从未对该变量进行赋值。如果gcc允许自己生成错误的代码而没有警告,那么这是一个bug。手册说唯一支持的用途是使 "r"(var) 约束选择你想要的寄存器(https://gcc.gnu.org/onlinedocs/gcc/Local-Register-Variables.html),但它并不意味着仅仅使用这样的变量其他时间会有问题。 - Peter Cordes
显示剩余4条评论
1个回答

7
x86-64 System V ABI保证在call之前有16字节的栈对齐,因此libc的system可以利用它进行16字节对齐的加载/存储。如果你破坏了ABI,那么如果出现问题,就是你的问题。
进入函数后,在call推送返回地址后,RSP+-8是16字节对齐的,并且再push一次将为您设置调用另一个函数的环境。
当然,GCC通常没有问题,使用奇数个push或使用sub rsp, 16*n + 8来保留栈空间。使用具有asm("rsp")的寄存器asm本地变量不会破坏这一点,只要您只读取变量,而不是对其进行赋值。

您说您正在使用GCC7.3。我将您的代码放在Godbolt编译器探索器上,并使用-O3-O2-O1-O0进行了编译。它遵循所有优化级别的ABI,在开始于sub rsp, 8main函数中,并且在函数内部不修改RSP(除了call)直到函数结束。

所以我检查了所有版本和优化级别的clang和gcc,它们都是如此。
这是gcc7.3 -O3的代码生成: 请注意,在函数体内部仅读取RSP,因此如果使用有效的RSP (16字节对齐-8) 调用main,则main的所有函数调用也将使用16字节对齐的RSP。 (并且它永远不会发现sp & 8为真,因此它永远不会首先调用system)
# gcc7.3 -O3
main:
        sub     rsp, 8
        xor     eax, eax
        mov     edi, OFFSET FLAT:.LC0
        mov     rsi, rsp          # read RSP.
        call    printf
        test    spl, 8            # low 8 bits of RSP
        je      .L2
        mov     edi, OFFSET FLAT:.LC1
        call    puts
        mov     edi, OFFSET FLAT:.LC2
        call    system
.L2:
        xor     eax, eax
        add     rsp, 8
        ret

如果您以某种非标准的方式调用main,那么您将违反ABI。由于您在问题中没有解释清楚,因此这不是一个MCVE
正如我在Does the C++ standard allow for an uninitialized bool to crash a program?中所解释的那样,编译器可以发出利用目标平台ABI提供的任何保证的代码。这包括使用movaps进行16字节的加载/存储以在堆栈上复制内容,并利用传入的对齐保证。
这是一个被错过的优化,gcc没有像clang一样完全优化掉if()。但是,clang实际上将其视为未初始化的变量;在不使用asm语句的情况下,因此对于clang来说,寄存器本地的asm("rsp")没有任何效果。在第一个printf调用之前,Clang保留RSI不变,所以Clang的main实际上打印了argv,根本没有读取RSP。
Clang允许这样做:寄存器-asm局部变量的唯一支持用途是使"r"(var)扩展-asm约束选择您想要的寄存器。(https://gcc.gnu.org/onlinedocs/gcc/Local-Register-Variables.html)。
手册并未暗示仅在其他时间使用此类变量可能会有问题,因此根据编写的规则,我认为这段代码通常应该是安全的,同时实际上也可以工作。
手册确实指出使用被调用破坏的寄存器(例如x86上的"rcx")会导致变量被函数调用破坏,因此使用rsp的变量可能会受到编译器生成的push/pop的影响吗?
这是一个有趣的测试案例:在Godbolt链接中查看。
// gcc won't compile this: "error: unable to find a register to spill"
// clang simply copies the value back out of RDX before idiv
int sink;
int divide(int a, int b) {
    register long long int dx asm ("rdx") = b;
    asm("" : "+r"(dx));  // actually make the compiler put the value in RDX

    sink = a/b;   // IDIV uses EDX as an input

    return dx;
}

没有 asm("" : "+r"(dx));,gcc 可以很好地编译它,根本不会将 b 放入 RDX 中。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接