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 会调用 brk 或 mmap(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 程序可以迈进来了。