程序凭什么能输出文字?

我们平时写 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
    ...
);

这就是「请求内核」这个动作的全部。没有任何魔法,就是:

  1. 把数字塞进寄存器
  2. 执行 syscall 指令
  3. CPU 被内核接管,内核去看寄存器,干活
  4. 内核把结果放回 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 给我们用。我们要自己:

  1. 搞清楚:在这台机器上,怎么跟内核说话(ABI 说明书)
  2. 实现:真正触发系统调用的那一下(syscall3 的汇编)
  3. 封装:把难用的寄存器操作,包装成人能看懂的 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 步的全部实现。 剩下的(真正往屏幕画字符)是内核的事,我们管不着,也不需要管。