Skip to content
Published at:

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 机制。

最小系统调用示例

asm
# 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_read63a0=fd, a1=buf, a2=lena0=实际读取字节数从文件读取
sys_write64a0=fd, a1=buf, a2=lena0=实际写入字节数向文件写入
sys_openat56a0=dirfd, a1=path, a2=flags, a3=modea0=fd打开文件
sys_close57a0=fda0=0(成功)关闭文件
sys_exit93a0=退出码不返回终止进程
sys_exit_group94a0=退出码不返回终止全部线程
sys_brk214a0=new_breaka0=新堆顶地址修改数据段边界
sys_mmap222a0=addr, a1=len, a2=prot, a3=flags, a4=fd, a5=offseta0=映射地址映射文件或匿名内存
sys_munmap215a0=addr, a1=lena0=0解除映射
sys_nanosleep101a0=rqtp, a1=rmtpa0=0休眠
sys_getpid172a0=pid获取进程 ID
sys_writev66a0=fd, a1=iov, a2=iovcnta0=写入字节数聚集写

write 系统调用详解

write(2) 向文件描述符写入数据。文件描述符 fd 0/1/2 分别预留给标准输入/标准输出/标准错误。

asm
# 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。

asm
# 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) 实现)。

asm
# 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_RDONLY00x0只读
O_WRONLY10x1只写
O_RDWR20x2读写
O_CREAT01000x40不存在则创建
O_TRUNC010000x200截断为 0
O_APPEND020000x400追加模式

brk 系统调用

brk(2) 是动态内存分配的最低层接口。C 的 malloc 在需要新内存时最终通过 brkmmap 扩展堆。传入 0 可查询当前堆顶地址(program break)。

asm
# 查询当前堆顶
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 在多个调用点会变得冗长。使用汇编器宏封装:

asm
# 定义通用 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:

asm
# 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 约定):

asm
_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:

asm
# 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 组合使用。纯汇编,零运行时依赖。

构建与运行

工具安装

bash
# 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-static

riscv64-linux-gnu-* 工具链生成的 ELF 以 Linux ABI 为目标——syscall 在 Linux 上工作。riscv64-elf-* 以裸机为目标,无 OS syscall 支持。

汇编与链接

bash
# 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。

验证程序行为

bash
# 编译我们的 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:

bash
# 在 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 内部调用 brkmmap,但管理着复杂的分配器(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 即一次陷入,高性能场景需自行实现缓冲