FreeBSD 系统调用比 Linux 破坏更多的寄存器?内联汇编优化级别之间的不同行为

fsd*_*kds 7 c freebsd x86-64 system-calls inline-assembly

最近我在玩 freebsd 系统调用,我对 i386 部分没有问题,因为它在这里有很好的记录但是我找不到 x86_64 的相同文档。

我看到人们在 linux 上使用相同的方式,但他们只使用程序集而不是 c。我想在我的例子中,系统调用实际上改变了一些被高优化级别使用的寄存器,所以它给出了不同的行为。

/* for SYS_* constants */
#include <sys/syscall.h>

/* for types like size_t */
#include <unistd.h>

ssize_t sys_write(int fd, const void *data, size_t size){
    register long res __asm__("rax");
    register long arg0 __asm__("rdi") = fd;
    register long arg1 __asm__("rsi") = (long)data;
    register long arg2 __asm__("rdx") = size;
    __asm__ __volatile__(
        "syscall"
        : "=r" (res)
        : "0" (SYS_write), "r" (arg0), "r" (arg1), "r" (arg2)
        : "rcx", "r11", "memory"
    );
    return res;
}

int main(){
    for(int i = 0; i < 1000; i++){
        char a = 0;
        int some_invalid_fd = -1;
        sys_write(some_invalid_fd, &a, 1);
    }
    return 0;
}
Run Code Online (Sandbox Code Playgroud)

在上面的代码中,我只是希望它调用 sys_write 1000 次然后返回 main。我使用 truss 检查系统调用及其参数。-O0 一切正常,但是当我使用 -O3 for 循环时,它会永远卡住。我相信系统调用改变i变量或1000一些奇怪的东西。

转储函数 main 的汇编代码:

0x0000000000201900 <+0>:     push   %rbp
0x0000000000201901 <+1>:     mov    %rsp,%rbp
0x0000000000201904 <+4>:     mov    $0x3e8,%r8d
0x000000000020190a <+10>:    lea    -0x1(%rbp),%rsi
0x000000000020190e <+14>:    mov    $0x1,%edx
0x0000000000201913 <+19>:    mov    $0xffffffffffffffff,%rdi
0x000000000020191a <+26>:    nopw   0x0(%rax,%rax,1)
0x0000000000201920 <+32>:    movb   $0x0,-0x1(%rbp)
0x0000000000201924 <+36>:    mov    $0x4,%eax
0x0000000000201929 <+41>:    syscall 
0x000000000020192b <+43>:    add    $0xffffffff,%r8d
0x000000000020192f <+47>:    jne    0x201920 <main+32>
0x0000000000201931 <+49>:    xor    %eax,%eax
0x0000000000201933 <+51>:    pop    %rbp
0x0000000000201934 <+52>:    ret
Run Code Online (Sandbox Code Playgroud)

有什么问题sys_write()?为什么 for 循环卡住了?

Pet*_*des 5

优化级别决定了 clang 决定在哪里保留其循环计数器:在内存中(未优化)或在寄存器中,在这种情况下r8d(优化)。R8D是编译器的一个合乎逻辑的选择:这是一个呼叫重挫REG它没有在开始/结束节能可以使用main,而你告诉它所有它可以使用寄存器没有REX前缀(如ECX),要么是输入/ asm 语句的输出或破坏。

注意:如果 FreeBSD 与 MacOS 类似,系统调用错误/无错误状态将在 CF(进位标志)中返回,而不是通过 -4095..-1 范围内的 RAX。在这种情况下,您需要一个 GCC6 标志输出操作数,例如"=@ccc" (err)for int err( #ifdef __GCC_ASM_FLAG_OUTPUTS__- example ) 或setc %cl模板中的 a 来手动实现布尔值。(CL 是一个不错的选择,因为您可以将其用作输出而不是 clobber。)


FreeBSD 的syscall处理垃圾 R8、R9 和 R10,除了 Linux 所做的最低限度的破坏:RAX(retval)和 RCX/R11(syscall指令本身使用它们来保存 RIP/RFLAGS,因此内核可以找到返回给用户的方法) -space,因此内核甚至永远不会看到原始值。)

也可能是 RDX,我们不确定;评论称其为“返回值 2”(即作为 RDX:RAX 返回值的一部分?)。我们也不知道 FreeBSD 打算在未来的内核中维护哪些面向未来的 ABI 保证。

您不能假设 R8-R10 在之后为零,syscall因为在跟踪/单步执行时它们实际上被保留而不是归零。(因为内核选择不通过 返回sysret,原因与 Linux 相同:如果在系统调用内部时寄存器可能已被 ptrace 修改,则硬件/设计错误会使其不安全。例如,尝试sysret使用非规范 RIP 将 # Intel CPU 上环 0(内核模式)中的 GP!这是一场灾难,因为此时 RSP = 用户堆栈。)


相关的内核代码sysret路径(由@NateEldredge以及斑点;我发现系统调用入口点通过搜索swapgs,但还没有得到的望着返回路径)。

函数调用保留的寄存器不需要由该代码恢复,因为调用 C 函数并没有首先破坏它们。并且代码确实恢复了函数调用破坏的“遗留”寄存器 RDI、RSI 和 RDX。

R8-R11 是在函数调用约定中被调用破坏的寄存器,并且在原始 8 x86 寄存器之外。所以这就是让他们“特别”的原因。(R11 不会归零;syscall/sysret 将它用于 RFLAGS,所以这就是您之后会在那里找到的值syscall

归零比加载它们快,并且在正常情况下(syscalllibc 包装函数内的指令)您将返回到仅假设函数调用约定的调用者,因此将假设 R8-R11 被丢弃( RDI、RSI、RDX 和 RCX 也是如此,尽管 FreeBSD出于某种原因确实费心去恢复它们。)


这种归零仅在非单步或跟踪(例如truss或 GDB si)时发生。syscallamd64 内核 (Github)入口点确实保存了所有传入的寄存器,因此它们可以通过内核之外的其他方式进行恢复。


更新的asm()包装器

// Should be fixed for FreeBSD, plus other improvements
ssize_t sys_write(int fd, const void *data, size_t size){
    register ssize_t res __asm__("rax");
    register int arg0 __asm__("edi") = fd;
    register const void *arg1 __asm__("rsi") = data;  // you can use real types
    register size_t arg2 __asm__("rdx") = size;
    __asm__ __volatile__(
        "syscall"
                    // RDX *maybe* clobbered
        : "=a" (res), "+r" (arg2)
                           // RDI, RSI preserved
        : "a" (SYS_write), "r" (arg0), "r" (arg1)
          // An arg in R10, R8, or R9 definitely would be
        : "rcx", "r11", "memory", "r8", "r9", "r10"   ////// The fix: r8-r10
         // see below for a version that avoids the "memory" clobber with a dummy input operand
    );
    return res;
}
Run Code Online (Sandbox Code Playgroud)

"+r"输出/输入操作数与需要register long arg3 asm("r10")或类似 r8 或 r9 的任何参数一起使用。

这是在包装函数内部,因此 C 变量的修改值被丢弃,每次都强制重复调用来设置 args。这将是“防御性”方法,直到另一个答案确定更明确的非垃圾寄存器。


我确实中断了 *0x000000000020192b 然后信息在中断发生时注册。r8 为零。在这种情况下程序仍然卡住

我假设在您在整个指令中执行 GDB 之前,r8 它不是零。是的,该测试证实了 FreeBSD 内核在非单步执行时正在破坏。(并且行为方式与我们在源代码中看到的相匹配。)continuesyscallr8


请注意,您可以告诉编译器write系统调用仅使用虚拟输入操作数而不是clobber读取内存(而不是写入)。这将让它提升循环外的存储。(如何指示可以使用内联 ASM 参数*指向*的内存?"m""memory"c

"m"(*(const char (*)[size]) data) 作为输入而不是"memory"clobber。

如果您要为您使用的每个系统调用编写特定的包装器,而不是为每个 3 操作数系统调用使用的通用包装器只是将所有操作数强制转换为unsigned long,这就是您可以从中获得的优势。

说到这里,让你的 syscall args 完全没有意义long;将用户空间符号扩展int fd到 64 位寄存器只是浪费指令。内核 ABI 将(几乎可以肯定)忽略窄 args 的寄存器的高字节,就像 Linux 一样。(同样,除非您正在制作一个通用syscall3包装器,您只是使用不同的SYS_数字来定义写入、读取和其他 3 操作数系统调用;那么您会将所有内容都转换为 register-width 并只使用"memory"clobber)。

我对下面的修改版本进行了这些更改。

另请注意,对于 RDI、RSI 和 RDX,您可以使用特定寄存器字母约束来代替 register-asm 本地变量,就像您在 RAX ( "=a") 中对返回值所做的一样。顺便说一句,您实际上并不需要电话号码的匹配约束,只需使用"a"输入即可;它更容易阅读,因为您不需要查看另一个操作数来检查您是否匹配了正确的输出。

// assuming RDX *is* clobbered.
// could remove the + if it isn't.
ssize_t sys_write(int fd, const void *data, size_t size)
{
    // register long arg3 __asm__("r10") = ??;
    // register-asm is useful for R8 and up

    ssize_t res;
    __asm__ __volatile__("syscall"
                    // RDX
        : "=a" (res), "+d" (size)
         //  EAX/RAX       RDI       RSI
        : "a" (SYS_write), "D" (fd), "S" (data),
          "m" (*(const char (*)[size]) data) // tells compiler this mem is an input
        : "rcx", "r11"    //, "memory"
#ifndef __linux__
              , "r8", "r9", "r10"   // Linux always restores these
#endif
    );
    return res;
}
Run Code Online (Sandbox Code Playgroud)

有些人更喜欢register ... asm("")所有操作数,因为您可以使用完整的寄存器名称,并且不必记住 RDI/EDI/DI/DIL 的完全不明显的“D”与 RDX/EDX 的“d” /DX/DL


归档时间:

查看次数:

259 次

最近记录:

4 年,6 月 前