上一章对齐了 Linux syscall ABI,现在试着跑一个真正用 musl libc 静态编译的 C 程序。目标很简单:

#include <stdio.h>
int main(int argc, char **argv) {
    printf("hello from musl libc!\n");
    printf("argc=%d\n", argc);
    return 0;
}

编译:musl-gcc -static -O2 -o hello.elf hello.c

结果踩了两个隐藏很深的寄存器 bug,花了不少时间才找出来。

新增的 syscall

musl libc 启动时会调用一批 syscall,其中有三个是绕不过的:

syscall 编号 说明
arch_prctl(ARCH_SET_FS, addr) 158 设置 TLS base(FS 寄存器)
set_tid_address(tidptr) 218 设置线程 ID 地址,返回 pid
writev(fd, iov, iovcnt) 20 向量写,musl 的 stdio 用它输出

arch_prctl 需要写 MSR 0xC0000100(FS.base),这是 TLS 的基地址,musl 用来存放线程局部变量:

case SYS_ARCH_PRCTL:
    if (a1 == ARCH_SET_FS) {
        wrmsr(0xC0000100, a2);   // FS.base MSR
        return 0;
    }
    return (uint64_t)(-EINVAL);

writev 需要遍历 iov 数组,把多段数据拼起来写到 tty:

struct iovec { void *base; size_t len; };

case SYS_WRITEV: {
    struct iovec *iov = (struct iovec *)a2;
    size_t iovcnt = a3;
    ssize_t total = 0;
    for (size_t i = 0; i < iovcnt; i++) {
        tty_write(iov[i].base, iov[i].len);
        total += iov[i].len;
    }
    return total;
}

printf 的调用链

printf 底层走的路径比想象中复杂:

printf()
  └─ fwrite() / fputs()
       └─ __fwritex()
            └─ __stdout_write()   ← 先 ioctl(TIOCGWINSZ) 探测终端
                 └─ __stdio_write()
                      └─ writev(fd=1, iov, 2)  ← syscall 0x14

__stdio_write 把 FILE 内部缓冲区(iov[0])和本次数据(iov[1])合并成一次 writev返回值必须等于总长度,否则它认为写失败,会不断重试。这个细节在第二个 bug 里会很关键。

ELF 加载与用户栈布局

musl 的 ELF 有 6 个 LOAD 段(包括 .note.eh_frame 等),ELF 加载器需要支持任意多个 LOAD 段,之前只处理了两个的情况要改掉。

musl 的 _start 期望栈顶有标准的 System V AMD64 ABI 布局:

[rsp+0]   argc
[rsp+8]   argv[0]  (指向文件名字符串)
[rsp+16]  NULL     (argv 结束)
[rsp+24]  NULL     (envp 结束)
[rsp+32]  0        (auxv AT_NULL type)
[rsp+40]  0        (auxv AT_NULL value)

内核在 execve 里把这个结构写好,然后把 rsp 指向这里跳进用户态。

Bug 1:context_t 缺少 caller-saved 寄存器

现象

定时器中断发生后,用户进程恢复执行时 rax/rcx/rdx/rdi/rsi/r8-r11 全部丢失,变成 0。

原因

context_t 只保存了 callee-saved 寄存器(rbx/rbp/r12-r15),没有保存 caller-saved 寄存器。sched_tick 保存上下文时也只保存了这几个。

musl 的代码大量使用这些寄存器,中途被定时器打断后恢复时全部清零,自然就崩了。

修复

扩展 context_t,加入所有 caller-saved 寄存器:

typedef struct {
    uint64_t r15, r14, r13, r12;
    uint64_t rbx, rbp;
    uint64_t r11, r10, r9, r8;        // 新增
    uint64_t rdi, rsi, rdx, rcx, rax; // 新增
    uint64_t rip, rsp, cs, ss, rflags;
} context_t;

同步更新 sched_tick(保存时多保存这些寄存器)和 enter_usermode_ctx(恢复时多恢复这些寄存器)。

注意:修改 process.h 后必须 make clean && make,否则旧的 .o 文件里记录的 struct 偏移是错的,数据会从错误的位置读取,出现非常诡异的现象。

Bug 2:signal_dispatch 破坏 syscall 返回值

现象

writev 明明写了 22 字节,但用户程序(musl 的 __stdio_write)认为返回了 0,不断重试,出现无限循环。

原因

syscall_entry.asm 的调用流程大致是:

call syscall_handler   ; rax = 22(返回值)
lea rdi, [rsp+104]
lea rsi, [rsp+112]
call signal_dispatch   ; void 函数,但破坏了 rax!
; 此时 rax ≠ 22
sysret                 ; 用户看到 writev 返回 0

signal_dispatch 是 void 函数,C 编译器不保证调用后 rax 的值。它内部检查 pending_signals == 0 然后直接 return——rax 变成了 0,恰好是个合法值,看起来像成功但返回了 0 字节。

修复

在调用 signal_dispatch 前后保存/恢复 rax

call syscall_handler
push rax                    ; 保存返回值
lea rdi, [rsp + 108]        ; &user_rip(偏移因 push 而 +8)
lea rsi, [rsp + 116]        ; &user_rsp
call signal_dispatch
pop rax                     ; 恢复返回值
sysret

教训

从汇编调用 C 函数后,所有 caller-saved 寄存器(包括 rax)都可能被破坏,即使是 void 函数也不例外。syscall 返回值必须在调用任何 C 函数之前保存好。

关键寄存器行为(x86-64 syscall ABI)

  • syscall 指令:CPU 自动把 RIP→rcx,RFLAGS→r11,不保存其他寄存器
  • sysret 指令:从 rcx 恢复 RIP,从 r11 恢复 RFLAGS,不恢复其他寄存器

所以内核的 syscall_entry.asm 必须手动保存/恢复所有用户寄存器,没有 CPU 帮忙。

最终结果

hello from musl libc!
argc=1

两个 bug 修完,printf 正常输出。

小结

这一章踩的两个 bug 都和寄存器有关:

Bug 1:context_t 没保存 caller-saved 寄存器,定时器中断后用户程序的寄存器丢失。修复是扩展 context_t,保存全部寄存器,并记得 make clean。

Bug 2:syscall 返回值存在 rax,调用 signal_dispatch 之后 rax 被破坏。修复是用 push/pop 保护返回值。

这两个 bug 有一个共同点:在简单的汇编用户程序上完全不会触发,只有 musl 这种大量使用所有寄存器、对 syscall 返回值有严格依赖的程序才会暴露出来。