12. 系统调用
前 11 章编写的程序都运行在 CPU 可执行的纯计算和访存闭环中。但程序不能永远只算算术——它需要向终端输出文字,从文件读取数据,分配动态内存,甚至优雅地退出。这些能力由操作系统通过系统调用(syscall)提供。系统调用是用户态程序与内核之间唯一的合法接口。
ecall 指令原理
ecall(Environment Call)是 RISC-V 特权架构定义的一条指令,触发从当前特权级向更高特权级的同步陷阱(trap)。在 Linux 场景中,用户态程序运行在 U 模式(User mode),ecall 将 CPU 切换到 S 模式(Supervisor mode),跳转到内核的陷阱处理程序。内核根据约定的寄存器内容执行对应的服务,完成后通过 sret 返回用户态。
关键点:RISC-V 指令集规范只定义了 ecall 指令本身——它不规定如何编码系统调用号或参数。这些约定完全由操作系统自行定义。以下约定适用于 Linux RISC-V。
Linux RISC-V 系统调用约定
| 寄存器 | 角色 | 说明 |
|---|---|---|
| a7 | 系统调用号 | 唯一标识请求的服务 |
| a0 | 参数 1 / 返回值 | 入参也是出参 |
| a1 | 参数 2 | |
| a2 | 参数 3 | |
| a3 | 参数 4 | |
| a4 | 参数 5 | |
| a5 | 参数 6 | |
| a0 | 返回值 | 成功 → 非负值;失败 → 负值(绝对值 = errno) |
注意:return value 约定与 C 库的封装不同。C 库的 write() 返回 -1 并设置 errno;而原始 syscall 直接返回负的 errno 值——例如文件不存在时 a0 = -2(-ENOENT,其中 ENOENT = 2)。C 库在封装层做负值检测并转换为 errno 机制。
最小系统调用示例
# exit(42) —— 最简单的系统调用
li a0, 42 # 退出码
li a7, 93 # syscall 号:93 = __NR_exit (RV64)
ecall # 进程终止,不再返回exit 系统调用最简洁——给它一个退出码,进程终止。这是验证 ecall 机制能正确工作的最小测试。
常用系统调用
以下是 Linux RISC-V (RV64) 中最常用的系统调用。完整列表见内核头文件 arch/riscv/include/uapi/asm/unistd.h 或在线搜索 "RISC-V syscall table"。
| 调用名 | 编号 | 参数 | 返回值 | 说明 |
|---|---|---|---|---|
sys_read | 63 | a0=fd, a1=buf, a2=len | a0=实际读取字节数 | 从文件读取 |
sys_write | 64 | a0=fd, a1=buf, a2=len | a0=实际写入字节数 | 向文件写入 |
sys_openat | 56 | a0=dirfd, a1=path, a2=flags, a3=mode | a0=fd | 打开文件 |
sys_close | 57 | a0=fd | a0=0(成功) | 关闭文件 |
sys_exit | 93 | a0=退出码 | 不返回 | 终止进程 |
sys_exit_group | 94 | a0=退出码 | 不返回 | 终止全部线程 |
sys_brk | 214 | a0=new_break | a0=新堆顶地址 | 修改数据段边界 |
sys_mmap | 222 | a0=addr, a1=len, a2=prot, a3=flags, a4=fd, a5=offset | a0=映射地址 | 映射文件或匿名内存 |
sys_munmap | 215 | a0=addr, a1=len | a0=0 | 解除映射 |
sys_nanosleep | 101 | a0=rqtp, a1=rmtp | a0=0 | 休眠 |
sys_getpid | 172 | 无 | a0=pid | 获取进程 ID |
sys_writev | 66 | a0=fd, a1=iov, a2=iovcnt | a0=写入字节数 | 聚集写 |
write 系统调用详解
write(2) 向文件描述符写入数据。文件描述符 fd 0/1/2 分别预留给标准输入/标准输出/标准错误。
# write(1, msg, 13) —— 向 stdout 输出 13 字节
# 相当于 C 的 write(STDOUT_FILENO, "Hello World!\n", 13)
.section .rodata
msg:
.asciz "Hello World!\n" # 14 字节(含 '\0')
.text
.globl _start
_start:
li a0, 1 # fd = 1(stdout)
la a1, msg # buf = msg
li a2, 13 # len = 13(不含 '\0',只输出 13 个可见字符)
li a7, 64 # syscall 号:64 = __NR_write
ecall
# 检查返回值(可选)
# a0 < 0 表示错误,|a0| = errno 值
# 正常退出
li a0, 0
li a7, 93
ecall注意 len = 13 而非 14——我们不想输出字符串的结尾 NUL 字符到终端。
read 系统调用详解
read(2) 从文件描述符读取数据。返回值是实际读取的字节数,读到文件末尾返回 0。
# read(0, buffer, 256) —— 从 stdin 最多读 256 字节
.bss
buffer:
.skip 256 # 预留 256 字节缓冲区
.text
read_stdin:
li a0, 0 # fd = 0(stdin)
la a1, buffer # buf = buffer
li a2, 256 # len = 256(缓冲区大小)
li a7, 63 # syscall 号:63 = __NR_read
ecall
# a0 = 实际读取的字节数
# a0 == 0 → EOF
# a0 < 0 → 错误(|a0| = errno)
# a0 > 0 → 成功读取 a0 字节openat 系统调用详解
openat(2) 是 Linux 上打开文件的首选系统调用(现代 Linux 上 open(2) 在 C 库中通过 openat(AT_FDCWD, path, flags, mode) 实现)。
# openat(AT_FDCWD, "output.txt", O_WRONLY|O_CREAT|O_TRUNC, 0644)
.section .rodata
filename:
.asciz "output.txt"
.text
open_file:
li a0, -100 # AT_FDCWD = -100(相对于当前工作目录)
la a1, filename
li a2, 0x241 # O_WRONLY(1) | O_CREAT(0x40) | O_TRUNC(0x200)
li a3, 0o644 # 权限:rw-r--r--
li a7, 56 # syscall 号:56 = __NR_openat
ecall
# a0 = 文件描述符(非负)或负 errno常用 open flag 常量:
| Flag | 值 (八进制) | 值 (十六进制) | 含义 |
|---|---|---|---|
| O_RDONLY | 0 | 0x0 | 只读 |
| O_WRONLY | 1 | 0x1 | 只写 |
| O_RDWR | 2 | 0x2 | 读写 |
| O_CREAT | 0100 | 0x40 | 不存在则创建 |
| O_TRUNC | 01000 | 0x200 | 截断为 0 |
| O_APPEND | 02000 | 0x400 | 追加模式 |
brk 系统调用
brk(2) 是动态内存分配的最低层接口。C 的 malloc 在需要新内存时最终通过 brk 或 mmap 扩展堆。传入 0 可查询当前堆顶地址(program break)。
# 查询当前堆顶
li a0, 0 # 查询,不修改
li a7, 214
ecall # a0 = 当前 program break
# 扩展堆 4096 字节
addi a0, a0, 4096 # 新 break = 当前 break + 4096
li a7, 214
ecall # a0 = 新 program break(失败 = 当前 break 未变)封装 syscall 为可复用的宏
手工写 li a7, N; ecall 在多个调用点会变得冗长。使用汇编器宏封装:
# 定义通用 syscall 宏
.macro syscall_exit code
li a0, \code
li a7, 93
ecall
.endm
.macro syscall_write fd, buf, len
li a0, \fd
la a1, \buf
li a2, \len
li a7, 64
ecall
.endm
.macro syscall_read fd, buf, len
li a0, \fd
la a1, \buf
li a2, \len
li a7, 63
ecall
.endm
# 使用示例
syscall_write 1, msg, 13
syscall_exit 0关于宏的详细语法见第 13 章(汇编器与链接器)。
独立可运行程序
_start:真正的程序入口
链接 libc 的程序以 main 为入口——但 main 不是 ELF 的真正入口。_start 才是。C 运行时(crt0.o)在 _start 中做一系列初始化(设置栈、初始化全局变量、调用 C 构造函数等),然后才调 main。
对于不链接 libc 的纯汇编程序,必须自己提供 _start,并直接使用 syscall 完成所有 I/O:
# Minimal viable program: Hello World without libc
.section .rodata
msg:
.asciz "Hello, World!\n"
.text
.globl _start
_start:
# 栈已经由内核设置,sp 指向有效栈空间
# 无需额外初始化栈(除非需要特定对齐)
# write(1, msg, 14)
li a0, 1 # stdout
la a1, msg
li a2, 14 # 含 '\n',不含 '\0'
li a7, 64
ecall
# exit(0)
li a0, 0
li a7, 93
ecall # 永不返回处理命令行参数
内核在 _start 入口时已将命令行参数放在栈上(遵循 ELF ABI 的 auxv 约定):
_start:
# 入口时栈布局(从 sp 开始):
# sp+0: argc(参数个数)
# sp+8: argv[0] 指针(程序名)
# sp+16: argv[1] 指针
# ...
# sp+8*argc: NULL(argv 终止符)
# 之后:envp[] 和 auxv[]
ld a0, 0(sp) # a0 = argc
addi a1, sp, 8 # a1 = argv(指向指针数组)
# 现在 a0 和 a1 就像 main(argc, argv) 的参数
# 可以调用我们的实际逻辑
call main # 如果定义了 main完整的输入回显程序
以下程序从 stdin 读取一行并回显到 stdout——纯 syscall,不依赖 libc:
# echo.s —— 简单的行回显程序
.bss
buf:
.skip 256 # 输入缓冲区
.text
.globl _start
_start:
read_loop:
# read(0, buf, 256)
li a0, 0
la a1, buf
li a2, 256
li a7, 63
ecall
# 检查返回值
blez a0, exit_prog # a0 <= 0 → EOF 或错误
mv s0, a0 # s0 = 实际读取的字节数
# write(1, buf, s0)
li a0, 1
la a1, buf
mv a2, s0
li a7, 64
ecall
j read_loop # 继续读取下一块
exit_prog:
li a0, 0
li a7, 93
ecall这个程序已经是一个功能完整的命令行工具——可以用管道输入、可以重定向文件、可以与 shell 组合使用。纯汇编,零运行时依赖。
构建与运行
工具安装
# macOS
brew install riscv64-elf-gcc riscv64-elf-binutils qemu
# Ubuntu / Debian
sudo apt install gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu qemu-user-staticriscv64-linux-gnu-* 工具链生成的 ELF 以 Linux ABI 为目标——syscall 在 Linux 上工作。riscv64-elf-* 以裸机为目标,无 OS syscall 支持。
汇编与链接
# 1. 汇编:.s → .o(relocatable object)
riscv64-linux-gnu-as -o hello.o hello.s
# 2. 链接:.o → 可执行 ELF
riscv64-linux-gnu-ld -o hello hello.o
# 3. 运行:通过 QEMU 用户态模拟
qemu-riscv64 ./hello
# 输出:Hello, World!as 产生的是可重定位目标文件(relocatable object),包含机器码和重定位信息(符号→地址的待填槽)。ld 进行符号解析和重定位,输出完整的 ELF 可执行文件。QEMU 用户态模拟器将 RISC-V 指令翻译为宿主机指令执行,并将 RISC-V 的 syscall 透明转发为宿主机的 syscall。
验证程序行为
# 编译我们的 echo.s,命名为 echo
riscv64-linux-gnu-as -o echo.o echo.s
riscv64-linux-gnu-ld -o echo echo.o
# 交互式测试
echo "test message" | qemu-riscv64 ./echo
# → test message
# 检查退出码
qemu-riscv64 ./hello
echo $?
# → 0查看 syscall 行为(宿主机上)
在宿主机 Linux 上可以用 strace 观察 QEMU 模拟的程序的 syscall——注意 QEMU 用户模式下,宿主机看到的是 QEMU 进程的 syscall,不是客户程序的。
更直接的方法:在 QEMU 中运行完整 RISC-V Linux 系统镜像,并在其中使用 RISC-V 版 strace:
# 在 RISC-V QEMU 系统模拟中
strace /path/to/hello
# 会显示 write(1, "Hello, World!\n", 14) = 14
# exit_group(0) = ?系统调用 vs C 库函数
理解分层关系有助于调试:
你的程序
↓
C 库 (printf / malloc / read / ...)
↓ 有时直接系统调用,有时有用户态缓存
系统调用 (syscall) ←── 本章讨论的这一层
↓ ecall 陷入
Linux 内核
↓
硬件C 库不等于系统调用。printf 内部调用 write,但有格式化层和缓冲层。malloc 内部调用 brk 或 mmap,但管理着复杂的分配器(bins、free lists)。当用纯汇编写程序时,这些缓冲和管理都没有——每一字节 I/O 就是一个 ecall。对于性能敏感的应用,缓冲是必需项(你需要自己实现或链接 libc)。
本章要点
ecall触发从 U 模式到 S 模式的同步陷阱;Linux 约定 a7=syscall 号、a0-a5=参数、a0=返回值(负值=errno)write(64)/read(63)/exit(93)/openat(56)/brk(214)是最常用的系统调用,覆盖 I/O、进程控制和内存管理_start是不链接 libc 的真正 ELF 入口——纯汇编程序必须自己提供_start并直接用 syscall 完成所有 OS 交互- 汇编器宏可封装重复的 syscall 调用序列;构建流程:
as → .o → ld → ELF → qemu-riscv64 运行 - C 库函数在系统调用之上提供缓冲和抽象层;原始 syscall 每字节 I/O 即一次陷入,高性能场景需自行实现缓冲