程序凭什么能输出文字?
我们平时写 println!("hello"),它最后会显示在屏幕上。但我们的程序只是一个跑在内存里的进程,它根本没资格直接碰屏幕、碰键盘、碰文件——这些硬件归操作系统(内核)管。
那程序怎么才能输出文字?答案是:向内核「请求」。
这个「请求」的动作,就叫系统调用(syscall)。
系统调用的本质:一个非常「原始」的对话协议
程序请求内核帮忙,没有 API、没有函数、没有 println,只有一套最底层的约定:
内核和我们约定:我们把「想让我干嘛」写进某个寄存器,「参数」写进另外几个寄存器,然后执行一条特殊指令。我看到这条指令,就去寄存器里读我们的请求,办完事把结果放回某个寄存器。
这套约定,就是这一节标题里的 ABI(应用二进制接口)。
我们写的东西,就是把「原始协议」包装成「好用函数」
把整个文件从上到下串起来看,我们其实是搭了一个三层结构:
第1层(底层协议):直接对着 CPU 和内核说话
└─ syscall3() ← 用汇编指令 syscall 触发系统调用
第2层(具体请求):告诉内核具体做什么事
└─ sys_write / sys_read / sys_close / sys_exit
第3层(知识描述):记录不同 CPU 架构的"协议规则"
└─ x86_64_abi / aarch64_abi / riscv64_abi
我们写的 syscall3 —— 最底层的那一下
core::arch::asm!(
"syscall", // 1. 执行这条指令,打断 CPU,交给内核
inlateout("rax") id as isize => ret, // 2. 把"想干嘛"放 rax,结果也从 rax 读回
in("rdi") arg0, // 3. 参数1放 rdi
in("rsi") arg1, // 参数2放 rsi
in("rdx") arg2, // 参数3放 rdx
...
);
这就是「请求内核」这个动作的全部。没有任何魔法,就是:
- 把数字塞进寄存器
- 执行 syscall 指令
- CPU 被内核接管,内核去看寄存器,干活
- 内核把结果放回 rax
我们写的 sys_write —— 把"具体请求"翻译成"寄存器操作"
但用户不可能自己记住「write 的调用号是 1,要写进 rax,参数要放进 rdi/rsi/rdx」。太反人类了。
所以 sys_write 做的就是翻译:
pub fn sys_write(fd: usize, buf: &[u8]) -> isize {
syscall3(
NATIVE_SYS_WRITE, // "我要 write",换成数字 1
fd, // 参数1
buf.as_ptr() as usize, // 参数2:数据在内存的哪个地址
buf.len(), // 参数3:数据有多长
)
}
用户只需要说「往 fd 写这些字节」,sys_write 自动帮我们把它们塞进正确的寄存器,然后触发 syscall。
我们写的 x86_64_abi —— 记录"协议规则"的说明书
pub fn x86_64_abi() -> SyscallABI {
SyscallABI {
instruction: "syscall", // 触发指令是 syscall
id_reg: "rax", // 调用号放 rax
ret_reg: "rax", // 结果从 rax 读
arg_regs: &["rdi", "rsi", "rdx", ...], // 参数放这些寄存器
sys_write: 1, // write 的调用号是 1
...
}
}
这个函数不执行任何东西,它只是一份「说明文档」,告诉别人:在 x86_64 这台机器上,这套协议长什么样。而 aarch64、riscv64 各有各的说明书(寄存器名不同、调用号不同)。
所以「在干嘛」的答案
我们做的这一整套东西,就是在从零实现一个最小、最底层的「系统调用库」——就像标准库里的 std::fs::write 背后的原理一样。
在没有标准库(no_std)的世界里,没有现成的 println、read、exit 给我们用。我们要自己:
- 搞清楚:在这台机器上,怎么跟内核说话(ABI 说明书)
- 实现:真正触发系统调用的那一下(syscall3 的汇编)
- 封装:把难用的寄存器操作,包装成人能看懂的 sys_write/sys_read 这些函数
这就是写操作系统、写嵌入式、写底层运行时的人,每天在做的事。我们现在做的,就是把「看不见的标准库底层」亲手实现了一遍。
一个具体的例子串起来
假设我们调用 sys_write(1, b"hi\n"),实际发生了什么:
1. sys_write 收到 fd=1, buf="hi\n"
2. 它调 syscall3(1, 1, 0x地址, 3)
└─ 1 是 write 的调用号
└─ 第二个 1 是 stdout 的文件描述符
└─ 0x地址 是 "hi\n" 这三个字节在内存里的位置
└─ 3 是字节数
3. syscall3 执行汇编:
rax=1, rdi=1, rsi=地址, rdx=3,然后 "syscall"
4. 内核接管,看到 rax=1 知道我们要 write,
去 rsi 指的地址读 3 个字节,写到 rdi 指的文件(1=屏幕)
5. 内核把"写了几个字节"放回 rax,交还 CPU
6. syscall3 把 rax 的值 return 给我们
7. 我们看到屏幕上出现了 "hi"
我们写的这几十行代码,就是上面第 2、3、6 步的全部实现。 剩下的(真正往屏幕画字符)是内核的事,我们管不着,也不需要管。