上一章对齐了 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 返回值有严格依赖的程序才会暴露出来。