musl libc 编译出来的程序会直接用 syscall 指令,传的是 Linux x86-64 标准调用号。我们之前的调用号是自己编的,如果不对齐,musl 程序一个 syscall 都跑不通。这一章把内核的 syscall 接口全面对齐 Linux ABI。

Linux x86-64 syscall 约定

Linux 的 x86-64 syscall 约定:

  • 调用:rax=syscall号,rdi=a1,rsi=a2,rdx=a3,r10=a4,r8=a5,r9=a6
  • 返回:rax(负数表示错误,如 -ENOENT = -2)
  • CPU 自动保存:rcx=用户 rip,r11=用户 rflags

注意第 4 个参数是 r10,不是 rcx。原因是 syscall 指令会把用户 rip 存进 rcx,所以 r10 顶替了 rcx 的位置。这一点非常容易踩坑。

关键改动

1. syscall 号全部换成 Linux 标准

之前的调用号是自己定义的,现在全部换成 Linux 标准值:

功能 之前 现在
read 6 0
write 1 1 ✓
open 5 2
close 7 3
fork 3 57
exit 2 60
kill 12 62

write 碰巧没变,其他大部分都不一样。

2. syscall_entry.asm 改为传 6 个参数

Linux 用 r10 传第 4 个参数,但 C 调用约定用 rcx,所以入口汇编需要做一次搬运:

mov r9,  r8         ; a5
mov r8,  r10        ; a4(Linux 用 r10 传第4参数)
mov rcx, rdx        ; a3
mov rdx, rsi        ; a2
mov rsi, rdi        ; a1
mov rdi, rax        ; nr
call syscall_handler

handler 签名改为 6 个参数:

uint64_t syscall_handler(uint64_t nr, uint64_t a1, uint64_t a2, uint64_t a3,
                         uint64_t a4, uint64_t a5)

3. 错误返回值改成负数 errno 风格

之前所有错误都返回 -1,现在返回具体的负 errno:

return (uint64_t)(-ENOENT);   // -2,文件不存在
return (uint64_t)(-EBADF);    // -9,非法文件描述符
return (uint64_t)(-ENOMEM);   // -12,内存不足
return (uint64_t)(-ENOSYS);   // -38,未实现的 syscall

musl libc 收到负数返回值后,会设 errno = -返回值,这是 Linux 的标准约定。之前统一返回 -1 会让 musl 无法区分不同的错误类型,很多逻辑就会走错路径。

4. 新增 mmap 和 brk

musl libc 的 malloc 会调用 brkmmap(MAP_ANONYMOUS) 来分配内存,这两个是绕不过的。

brk 实现

进程 PCB 新增 brk 字段记录当前 program break。

  • brk(0) 返回当前 break 地址
  • brk(new_addr) 扩展堆,逐页分配物理内存并映射到进程页表:
case SYS_BRK: {
    uint64_t new_brk = a1;
    if (new_brk == 0) return current->brk;
    // 逐页分配并映射
    uint64_t old = PAGE_ALIGN(current->brk);
    uint64_t end = PAGE_ALIGN(new_brk);
    for (uint64_t va = old; va < end; va += 0x1000) {
        uint64_t pa = pmm_alloc();
        memset((void *)pa, 0, 0x1000);
        vmm_map(current->pml4, va, pa, PTE_USER | PTE_WRITE | PTE_PRESENT);
    }
    current->brk = new_brk;
    return new_brk;
}

mmap 匿名映射

从固定基地址 0x50000000 开始分配虚拟地址,每次调用分配所需页数,物理页清零后映射到用户地址空间:

case SYS_MMAP: {
    uint64_t len  = PAGE_ALIGN(a1);
    uint64_t base = current->mmap_next;
    current->mmap_next += len;
    for (uint64_t off = 0; off < len; off += 0x1000) {
        uint64_t pa = pmm_alloc();
        memset((void *)pa, 0, 0x1000);
        vmm_map(current->pml4, base + off, pa, PTE_USER | PTE_WRITE | PTE_PRESENT);
    }
    return base;
}

5. 新增的 syscall 列表

musl libc 启动时会碰到各种 syscall,需要逐一覆盖:

syscall 说明
getpid / getppid 返回进程 PID
exit_group 等同 exit(musl 用这个退出,不用 exit)
uname 返回系统信息,sysname 填 “Linux”
wait4 替代旧的 wait,支持 wstatus 参数
execve 执行新程序
nanosleep 精确睡眠
clock_gettime 获取时间
gettimeofday 获取时间(timeval 格式)
getuid/gid/euid/egid 返回 0(假装是 root)
getcwd 返回当前目录(固定为 /)
fcntl stub,返回 0
openat 委托给 open
sigaction / sigprocmask stub,返回 0
mprotect / munmap stub,返回 0

为什么 uname 要返回 “Linux”

musl libc 内部会调用 uname 检测运行环境,如果 sysname 不是 “Linux”,某些初始化路径会走不同分支。填 “Linux” 能让 musl 最大概率走正常初始化路径。

case SYS_UNAME: {
    struct utsname *u = (struct utsname *)a1;
    strcpy(u->sysname,  "Linux");
    strcpy(u->nodename, "myos");
    strcpy(u->release,  "5.0.0");
    strcpy(u->version,  "#1");
    strcpy(u->machine,  "x86_64");
    return 0;
}

验收

之前用汇编写的 hello.elf shell 仍然正常工作,因为 write 调用号没变(都是 1)。exit 调用号从 2 改成了 60,汇编写的 hello 需要相应更新。

下一章将用 musl 静态编译一个真正的 C 程序,在这个内核上跑起来。

小结

这一章做的事情看起来是"改改调用号",实际上牵涉到整个 syscall 接口的全面重构:调用号对齐、参数传递约定、错误返回格式、以及 musl 必须的 brk/mmap 实现。每一项单独来看都不复杂,但缺一不可。现在内核已经"说 Linux 的语言"了,musl 程序可以迈进来了。