Appearance
系统调用:请求操作系统做事
概念
上一篇我们学会了写函数、建栈帧、递归。但程序还是"闭着眼睛在自转"——它只能在自己的寄存器和栈里折腾。
一旦需要跟"外面"打交道,就卡住了:
c
printf("hello\n"); /* 要往屏幕写 */
int x; scanf("%d", &x); /* 要从键盘读 */
FILE *f = fopen("a.txt","r"); /* 要访问磁盘 */
int *p = malloc(100); /* 要申请更多内存 */这四件事有一个共同点:
为什么干不了? 想一个具体场景:如果任何程序都能直接往磁盘控制器写命令——
- 一个循环里的下标算错(比如多写了一个扇区),就覆盖掉了文件系统元数据;
- 一个恶意程序就能直接读走别人的密码文件;
- 一个死循环程序就能霸占磁盘,谁都别想用。
所以硬件和操作系统必须联手做一件"隔离"的事:
"系统调用(system call)"就是那个窗口:用户程序通过它,请求操作系统代劳。
与普通函数调用的本质区别只有一条:特权级会变。
普通函数调用(jal) | 系统调用(syscall) | |
|---|---|---|
| 执行的代码 | 同一个程序里的代码 | 操作系统的代码 |
| 特权级 | 不变(一直在用户态) | 用户态 → 内核态 → 用户态 |
| 你信不信它 | 信(自己的代码) | 不完全信(内核要检查参数) |
| 能不能拒绝 | 不会 | 会(返回权限错误) |
| 谁提供 | 编译器 / 你 | 操作系统内核 |
"特权级"这个机制在 MIPS 里长这样:
| 状态 | 能做什么 | 不能做什么 |
|---|---|---|
| 用户态(User) | 执行普通指令;只能访问自己地址空间里被允许的页 | 不能执行特权指令;不能访问别的进程的内存;不能碰 I/O 端口 |
| 内核态(Kernel) | 执行全部指令(含特权指令);可访问全部地址与硬件 | 理论上什么都能干(所以内核里的 bug 是致命的) |
三种"拦住"的手段,合起来就是保护:
| 手段 | 靠什么 | 拦住什么 |
|---|---|---|
| 特权指令 | CPU 在执行特权指令时检查当前态 | eret、关中断、改页表、改 CP0 |
| 内存保护 | 页表里的权限位 + 地址翻译(L2/L4 讲) | 访问不属于自己的地址 |
| 异常机制 | 硬件在违规时立刻陷入内核 | 不是"忽略",而是"报给内核处理" |
本篇在主线上的位置:L4 的操作系统会系统讲"系统调用接口、进程、文件描述符、内核态与用户态"。 但那套东西的"物理基础"就是这一篇:一条
syscall指令 + 几个 CP0 寄存器 + 一个固定的异常入口地址。 操作系统不过是那个异常入口处的一大段代码。 也正因为如此,这一篇是从 L3(汇编)通向 L4(操作系统)的那道门。
原理
一、syscall 执行时到底发生了什么
MIPS32 的系统调用指令就叫 syscall(机器码 0x0000000C,见 lang/01-isa.md 的编码表)。它做的事情比 jal 复杂得多:
约定:
| 项 | 放哪 |
|---|---|
| 服务号(要哪个服务) | $v0 |
| 参数 | $a0、$a1、$a2…(最多 3~4 个,多的压栈) |
| 返回值 | $v0(第二个用 $v1) |
执行流程(六步,值得逐步记住):
用户态 内核态
│ │
│ ① li $v0, 1 (服务号 = 打印整数) │
│ li $a0, 42 (参数) │
│ syscall ← 陷阱指令 │
│──────────────────────────────────────────────────┤
│ ② 硬件:把 PC 存进 CP0.EPC(异常返回地址) │
│ ③ 硬件:状态存进 CP0.Status;切到内核态 │
│ ④ 硬件:跳到固定异常入口 0x80000180 │
│ ⑤ 内核:按 $v0 分派,
│ 执行服务,结果放 $v0
│ ⑥ 内核:执行 eret │
│◄──────────────────────────────────────────────────┤
│ 硬件:恢复现场,切回用户态,PC = EPC │
│ 程序从 syscall 的下一条指令继续 │
▼三个必须记住的硬件细节:
| 细节 | 说明 |
|---|---|
| 异常入口地址是固定的 | MIPS32 的通用异常向量在 0x80000180(所谓 BEV=0 时的位置)。用户程序无法选择跳到哪——这是安全性的关键:入口点由硬件定死 |
CP0.EPC 保存返回地址 | eret 会把 PC 恢复成 EPC——所以系统调用"看起来"像一条普通指令,回到下一条继续执行 |
CP0.Cause 里记录了异常类型 | 硬件用不同的异常码区分"系统调用""地址错误""溢出""缺页"等,内核的入口代码先看 Cause 再决定怎么处理 |
CP0 的四个常用寄存器(内核态专用,用户态访问它们会触发异常):
| 寄存器 | 全名 | 记什么 |
|---|---|---|
Status | 状态寄存器 | 当前特权级、中断使能、异常级别 |
Cause | 原因寄存器 | 异常类型码、待处理中断位 |
EPC | 异常 PC | 出错/陷入时的那条指令地址 |
BadVAddr | 坏地址 | 触发地址错误的那个地址 |
一个重要的语义细节:syscall 返回后,哪些寄存器还留着?
$v0/$v1由内核填(返回值);$a0~$a3可能被内核改(它们本来就是 caller-saved);$t0~$t9不可信——内核是个"被调用者",它有权改 caller-saved 寄存器;$s0~$s7必须保持——内核必须保证"被调用者保存"的契约(否则用户程序的所有变量都会莫名变化,系统直接崩)。
这就是为什么"寄存器约定"在操作系统层面是硬约束而不是建议:内核和所有用户程序是"互相不认识的两方",只有约定才能让它们协作。
二、用户态与内核态:怎么"分界"
"当前是什么态"这件事,存在哪? 存在 CP0.Status 的某一位里(MIPS32 里是 KSU 字段)。硬件每条指令执行前都可以检查它。
两类指令的划分:
| 类别 | 例子 | 用户态能执行吗 |
|---|---|---|
| 普通指令 | add lw beq jal | 能 |
| 特权指令 | eret、写 CP0、写 TLB、关中断 | 不能(执行就陷入异常) |
| 敏感内存访问 | 访问内核地址空间的页、访问别的进程的页 | 不能(页表权限位拦住) |
注意最后一行:内存保护不是靠指令类型,而是靠"地址翻译过程中的权限检查"。
每一次
lw都要先查页表、拿到权限位、才能访存(L4 的虚拟内存会详讲)。 所以"用户态不能访问内核内存"这条,是在lw执行的那一瞬间由硬件拦下的——这也是为什么"越权访问"会变成一次异常而不是"读到 0"。
"陷阱(trap)"这个词的由来:
| 术语 | 触发方式 | 典型例子 | 可恢复? |
|---|---|---|---|
| 中断(interrupt) | 外部设备、异步 | 时钟中断、键盘、网卡 | 是 |
| 故障(fault) | 内部、同步、可重试 | 缺页、地址未对齐 | 是(处理完重执行) |
| 自陷(trap) | 内部、同步、有意为之 | syscall、断点 | 是(返回下一条) |
| 终止(abort) | 内部、不可恢复 | 硬件故障、除零(某些架构) | 否 |
系统调用属于"自陷"——它是"程序有意跳进内核"。 这是它和"缺页"(故障)最本质的区别:
缺页是"出了意外被拦下";系统调用是"主动敲门"。但两者殊途同归:都通过同一个异常入口,进入内核。
三、服务号:MARS 的教学表与真实的 Linux 表
"服务号"就是 ABI 的一部分——不同的系统用不同的编号。这里给两张表:MARS 模拟器(教学用,本站示例采用)和真实 Linux/MIPS。
MARS 的常用服务号
$v0 | 服务 | 参数 | 返回 |
|---|---|---|---|
| 1 | print_int | $a0 = 整数 | — |
| 2 | print_float | $f12 = 浮点数 | — |
| 4 | print_string | $a0 = 字符串地址 | — |
| 5 | read_int | — | $v0 = 读到的整数 |
| 6 | read_float | — | $f0 = 浮点数 |
| 8 | read_string | $a0 = 缓冲区, $a1 = 最大长度 | — |
| 9 | sbrk | $a0 = 字节数 | $v0 = 新内存地址 |
| 10 | exit | — | —(进程终止) |
| 11 | print_char | $a0 = 字符 | — |
| 12 | read_char | — | $v0 = 字符 |
| 13 | open | $a0 = 文件名, $a1 = 模式 | $v0 = 描述符 |
| 14 | read | $a0 = 描述符, $a1 = 缓冲区, $a2 = 长度 | $v0 = 实读字节数 |
| 15 | write | 同 read | $v0 = 实写字节数 |
| 16 | close | $a0 = 描述符 | — |
| 17 | exit2 | $a0 = 退出码 | — |
记忆抓手:1 打印整数、4 打印字符串、5 读整数、8 读字符串、9 申请内存、10 退出。 这六个覆盖 90% 的教学代码。
真实 Linux/MIPS(o32 ABI)的编号
Linux 的 MIPS o32 ABI 有一个著名的"怪癖":
为什么加 4000? 历史原因:MIPS 的 syscall 指令原本被 System V / IRIX 占用了 0~3999 这一段编号,Linux 为了避开冲突,把自己的编号整体挪到 4000 以后。
| 服务 | 标准编号 n | Linux/MIPS 用的 $v0 |
|---|---|---|
exit | 1 | 4001 |
read | 3 | 4003 |
write | 4 | 4004 |
open | 5 | 4005 |
close | 6 | 4006 |
brk | 45 | 4045 |
另一个 MIPS 特有的约定:错误怎么报?
Linux/MIPS o32 不用"负返回值",而是用一个额外的寄存器
$a3当"错误标志": 返回时若$a3 ≠ 0,表示出错,真正的错误码(errno)在$v0里。
这个设计的原因很直白:read/write 的合法返回值本身就是"无符号字节数",可以是任何 32 位值——没有"负数"这个空位来标记错误(x86 的做法是"返回负的 errno",对 MIPS 来说不安全)。所以 MIPS 干脆另开一个寄存器做标志位。
x86-64 对照(附录)
| 项 | MIPS32 | x86-64 |
|---|---|---|
| 指令 | syscall | syscall(指令同名!历史巧合) |
| 服务号放哪 | $v0 | rax |
| 参数 | $a0~$a3 | rdi, rsi, rdx, r10, r8, r9 |
| 返回 | $v0 | rax(负值 = −errno) |
| 入口 | 固定向量 0x80000180 | 由 MSR_LSTAR 配置(内核启动时设) |
注意 x86-64 的"负值 = −errno":因为 x86 的返回值语义里**"小于 −4095 的负数都不可能是合法返回值"**,所以可以安全地用负数表错误。MIPS 因为要多传一个大范围的无符号数,只好换个办法。 这是"ABI 是被历史与需求塑造的"的一个好例子。
四、系统调用 vs 函数调用:七项对比
这是本篇最该掌握的一张表(也是操作系统与汇编的交叉考点):
| 维度 | 普通函数调用(jal) | 系统调用(syscall) |
|---|---|---|
| 1. 特权级 | 不变 | 用户态 → 内核态 → 用户态(2 次切换) |
| 2. 跳转目标 | 任意地址(编译器决定) | 固定异常入口(硬件定死,如 0x80000180) |
| 3. 返回方式 | jr $ra | eret(用 CP0.EPC 恢复 PC) |
| 4. 现场保存 | 按约定存 $ra/$s | 硬件保存 EPC/Status;内核再保存其余 |
| 5. 开销 | 纳秒级(几条指令) | 百纳秒~微秒级(切态 + 入口处理 + 可能刷 TLB) |
| 6. 能否被拒 | 不会 | 会(权限不足 → 返回错误码) |
| 7. 可移植性 | 同 ISA 内通用 | 各操作系统不同(编号表、ABI 全不一样) |
第 7 条最值得展开:同一份 MIPS 汇编,"打印一个整数"在 MARS、Linux、IRIX 上用的服务号完全不同。
所以"系统调用"这一层是"不可移植"的边界——这也是为什么标准 C 库要把它们包起来:
printf内部调用write,你写printf就与平台无关了。L4 会讲"C 库 = 系统调用的薄封装",指的就是这件事。glibc里的syscall()函数,本质上就是"把参数搬进$a0~$a3,把号搬进$v0,再执行syscall"。
五、为什么要缓冲区:系统调用很贵
"贵"有三个具体来源:
| 贵在哪 | 说明 |
|---|---|
| 特权级切换 | 切态本身要动 Status、可能刷流水线、影响分支预测 |
| 内核入口/出口处理 | 保存寄存器、查 Cause、分派、检查参数合法性、再做一遍恢复 |
| 副作用 | 切态可能扰动 Cache 和 TLB(arch/12-cache.md 的局部性被打破) |
所以"逐字节读文件"是灾难,"按块读"才正常:
| 方式 | 系统调用次数(读 1 MB 文件,块 4096 字节) |
|---|---|
| 每次读 1 字节 | 1,048,576 次 |
| 每次读 1 KB | 1,024 次 |
| 每次读 4 KB | 256 次 |
| 一次读完 | 1 次 |
这就是"缓冲区(buffer)"存在的第一性理由:
而且这正是 C 库 printf 的默认行为:printf 先写进内存缓冲区,攒够了(或遇到 \n 且是行缓冲、或程序退出)才真正调 write 系统调用一次。
所以"为什么
printf不立刻显示"这个新手常见困惑,答案在这里:它在等缓冲区满。 加fflush(stdout)就是"强制现在调用一次write"。
示例
例 1:一个完整的系统调用程序 —— 读两个整数求和
任务:用 MARS 的约定写一个完整程序:输出提示 → 读两个整数 → 求和 → 打印结果 → 退出。
asm
.data
prompt: .asciiz "请输入两个整数:"
result: .asciiz "它们的和是:"
nl: .asciiz "\n"
.text
.globl main
main:
# ① 打印提示 → 服务号 4(print_string),参数在 $a0
li $v0, 4
la $a0, prompt
syscall
# ② 读第一个整数 → 服务号 5(read_int),结果在 $v0
li $v0, 5
syscall
move $t0, $v0 # 存起来!$v0 马上要被下一次调用覆盖
# ③ 读第二个整数 → 服务号 5
li $v0, 5
syscall
move $t1, $v0
# ④ 求和(纯用户态计算,不涉及内核)
addu $t2, $t0, $t1
# ⑤ 打印结果 → 先打印提示串,再打印整数
li $v0, 4
la $a0, result
syscall
li $v0, 1 # print_int
move $a0, $t2
syscall
li $v0, 4 # 换行
la $a0, nl
syscall
# ⑥ 退出 → 服务号 10
li $v0, 10
syscall注意 ②→③ 之间那个 move $t0, $v0:
$v0是"系统调用的返回值寄存器",下一次系统调用会覆盖它。而$t0是 caller-saved——内核对它"有权改"!严格说它不该用来跨系统调用保存数据。正确的做法是用$s0/$s1(被调用者保存):内核必须保证$s系列在系统调用前后不变。 教学代码里用$t通常也能跑(内核实际上会保存所有寄存器),但这是"靠实现细节吃饭",不是约定保证。
改写后的"正确版本":
asm
li $v0, 5
syscall
move $s0, $v0 # ✅ $s0 是内核必须保护的
li $v0, 5
syscall
move $s1, $v0
addu $s2, $s0, $s1用 Python 把"这 6 次系统调用"的轨迹和状态跑一遍:
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
# 程序的系统调用序列:(服务号, 服务名, 参数/结果, 说明)
TRACE = [
(4, "print_string", 'a0 = "请输入两个整数:"', "用户态:准备参数,内核只负责写"),
(5, "read_int", "v0 = 7", "内核读键盘 → 返回 7"),
(5, "read_int", "v0 = 5", "内核读键盘 → 返回 5"),
(4, "print_string", 'a0 = "它们的和是:"', "再写一次屏幕"),
(1, "print_int", "a0 = 12", "打印和(7+5=12,计算在用户态完成)"),
(10, "exit", "", "终止进程,不再返回用户态"),
]
print(f" {'#':>2} {'v0':>3} " + pad("服务", 13) + pad("参数/结果", 26) + "说明")
for i, (v0, name, arg, note) in enumerate(TRACE, 1):
print(f" {i:>2} {v0:>3} " + pad(name, 13) + pad(arg, 26) + note)
io = sum(1 for t in TRACE if t[1] in ("print_string", "print_int", "read_int"))
print(f"\n 系统调用共 {len(TRACE)} 次:{io} 次 I/O + 1 次终止")
print(f" 用户态计算只有 1 次加法(7+5=12)—— '计算归用户、危险动作归内核'")
print(f" 每次调用 2 次特权级切换 → 本程序共 {len(TRACE) * 2} 次切换(末次终止不返回,实为 {(len(TRACE)-1)*2} 次)")
print("\n ★ 关键:$v0 是返回值寄存器,下一次调用会覆盖它 → 中间结果要搬进 $t/$s")
print(" $t 是 caller-saved(内核有权改,不保证);$s 才是内核必须保护的")预期输出:
# v0 服务 参数/结果 说明
1 4 print_string a0 = "请输入两个整数:" 用户态:准备参数,内核只负责写
2 5 read_int v0 = 7 内核读键盘 → 返回 7
3 5 read_int v0 = 5 内核读键盘 → 返回 5
4 4 print_string a0 = "它们的和是:" 再写一次屏幕
5 1 print_int a0 = 12 打印和(7+5=12,计算在用户态完成)
6 10 exit 终止进程,不再返回用户态
系统调用共 6 次:5 次 I/O + 1 次终止
用户态计算只有 1 次加法(7+5=12)—— '计算归用户、危险动作归内核'
每次调用 2 次特权级切换 → 本程序共 12 次切换(末次终止不返回,实为 10 次)
★ 关键:$v0 是返回值寄存器,下一次调用会覆盖它 → 中间结果要搬进 $t/$s
$t 是 caller-saved(内核有权改,不保证);$s 才是内核必须保护的三条结论:
| 结论 | 说明 |
|---|---|
| 程序里的"计算"和"服务请求"是分开的 | 6 次系统调用里5 次都不做算术,真正的加法在用户态 |
| 每次系统调用 = 2 次特权级切换 | 这是它"贵"的直接来源(下一篇的对比表) |
| 返回值寄存器会被覆盖 | $v0 不能当"变量"用——中间结果必须搬走,且搬进 $s 才符合约定 |
例 2:sbrk —— 从内核那里要内存
任务:用服务号 9 申请 40 字节,放进 10 个 int,把地址算清楚。
asm
li $v0, 9 # sbrk
li $a0, 40 # 申请 40 字节 = 10 个 int
syscall # 返回新空间起始地址到 $v0
move $s0, $v0 # $s0 = 数组基址
li $t0, 0 # i = 0
loop:
sll $t1, $t0, 2 # i * 4(lang/02 的地址算术)
addu $t1, $s0, $t1 # &arr[i]
mul $t2, $t0, $t0 # i * i
sw $t2, 0($t1) # arr[i] = i*i
addiu $t0, $t0, 1
slti $t3, $t0, 10
bne $t3, $zero, looppython
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
HEAP0, N_BYTES = 0x10040000, 40
N_INT = N_BYTES // 4
print(f" sbrk 前堆顶 = 0x{HEAP0:08X}")
print(f" 执行: li $v0,9 ; li $a0,{N_BYTES} ; syscall")
print(f" 返回: $v0 = 0x{HEAP0:08X}(新空间首地址)")
print(f"\n " + pad("i", 4) + pad("地址", 14) + pad("存的值", 8) + "说明")
for i in range(N_INT):
print(" " + pad(str(i), 4) + pad("0x%08X" % (HEAP0 + i * 4), 14)
+ pad(str(i * i), 8) + ("arr[%d] = %d*%d" % (i, i, i)))
print(f"\n sbrk 后堆顶 = 0x{HEAP0 + N_BYTES:08X}(上涨 {N_BYTES} 字节 = 10 个 int)")
print(f" 地址增量校验:{N_INT} 个 int × 4 字节 = {N_INT * 4} 字节,与申请量一致 = {N_INT * 4 == N_BYTES}")
print("\n=== 一次申请 vs 十次申请 ===")
print(" " + pad("策略", 34) + pad("系统调用次数", 14) + "备注")
print(" " + pad("每次申请 4 字节 × 10 次", 34) + pad("10", 14) + "每次都要切态,且地址不一定连续")
print(" " + pad("一次申请 40 字节 × 1 次", 34) + pad("1", 14) + "一次切态拿到连续 40 字节")
print("\n 真实 malloc 的做法:一次向内核要一大块(如 64 KB),")
print(" 自己在用户态切片分给调用者 → '要 10 次' 变成 '只有第 1 次要麻烦内核'")预期输出:
sbrk 前堆顶 = 0x10040000
执行: li $v0,9 ; li $a0,40 ; syscall
返回: $v0 = 0x10040000(新空间首地址)
i 地址 存的值 说明
0 0x10040000 0 arr[0] = 0*0
1 0x10040004 1 arr[1] = 1*1
2 0x10040008 4 arr[2] = 2*2
3 0x1004000C 9 arr[3] = 3*3
4 0x10040010 16 arr[4] = 4*4
5 0x10040014 25 arr[5] = 5*5
6 0x10040018 36 arr[6] = 6*6
7 0x1004001C 49 arr[7] = 7*7
8 0x10040020 64 arr[8] = 8*8
9 0x10040024 81 arr[9] = 9*9
sbrk 后堆顶 = 0x10040028(上涨 40 字节 = 10 个 int)
地址增量校验:10 个 int × 4 字节 = 40 字节,与申请量一致 = True
=== 一次申请 vs 十次申请 ===
策略 系统调用次数 备注
每次申请 4 字节 × 10 次 10 每次都要切态,且地址不一定连续
一次申请 40 字节 × 1 次 1 一次切态拿到连续 40 字节
真实 malloc 的做法:一次向内核要一大块(如 64 KB),
自己在用户态切片分给调用者 → '要 10 次' 变成 '只有第 1 次要麻烦内核'三条结论:
| 结论 | 说明 |
|---|---|
sbrk 返回的是"新空间的首地址" | 堆是"向上生长"的——每次 sbrk 返回一块紧接着上一块的空间 |
地址算术完全沿用 lang/02 的规则 | i*4 用 sll,基址 + 偏移用 addu——系统调用没有引入任何新的寻址方式 |
| "一次要一大块"是所有分配器的共同策略 | malloc 不是"每次要 4 字节都问内核"——它一次要几十 KB,然后自己切(lang/14-heap.md 详讲) |
例 3:开销量级 —— 为什么必须用缓冲区
任务:把"系统调用比函数调用贵多少"以及"缓冲区能省多少"算成具体数字。
说明:下面的纳秒数是量级示意(不同 CPU、不同内核版本差异很大),重点看比值与结论,不要记绝对数。
python
JAL_NS, SYS_NS = 2, 500 # 量级示意:普通调用 vs 系统调用(含两次特权级切换)
N = 1 << 20 # 要传输 1 MiB = 1,048,576 字节
print("=== 单次开销(量级示意)===")
print(f" 普通函数调用 jal ≈ {JAL_NS} ns")
print(f" 系统调用 syscall ≈ {SYS_NS} ns (含 用户→内核→用户 + 入口/出口处理)")
print(f" 倍数 ≈ {SYS_NS / JAL_NS:.0f}×")
print(f"\n=== 传 1 MiB({N:,} 字节)数据,不同块大小的调用次数与耗时 ===")
print(f" {'每块字节数':>12}{'调用次数':>12}{'总耗时':>14}{'相对一次读完':>16}")
for chunk in (1, 16, 1024, 4096, N):
calls = N // chunk # chunk = N 时正好 1 次
ms = calls * SYS_NS / 1e6 # 换算成毫秒
t = f"{ms * 1000:.1f} μs" if ms < 1 else f"{ms:.1f} ms"
print(f" {chunk:>12}{calls:>12}{t:>14}{(calls):>15}×")
print("\n=== 结论 ===")
print(f" 逐字节(1 字节/次):{N:,} 次调用,约 {N * SYS_NS / 1e6:.0f} ms —— 慢到不可用")
print(f" 4 KB 一块 :{N // 4096:,} 次调用,约 {N // 4096 * SYS_NS / 1e3:.1f} μs")
print(f" 一次读完 :1 次调用,约 {SYS_NS / 1e6:.3f} ms")
print(f" 提速倍数 ≈ {(N * SYS_NS) / (N // 4096 * SYS_NS):.0f}×(1 字节 → 4 KB)")
print("\n=== 这正是 printf 的行为 ===")
print(" printf 默认'行缓冲':先写进用户态缓冲区,遇到换行或缓冲满才真正 write 一次")
print(" → 所以 'printf 了但屏幕上没出现' 通常不是 bug,是缓冲区还没满")
print(" → fflush(stdout) 就是'现在立刻调用一次 write 系统调用'")预期输出:
=== 单次开销(量级示意)===
普通函数调用 jal ≈ 2 ns
系统调用 syscall ≈ 500 ns (含 用户→内核→用户 + 入口/出口处理)
倍数 ≈ 250×
=== 传 1 MiB(1,048,576 字节)数据,不同块大小的调用次数与耗时 ===
每块字节数 调用次数 总耗时 相对一次读完
1 1048576 524.3 ms 1048576×
16 65536 32.8 ms 65536×
1024 1024 512.0 μs 1024×
4096 256 128.0 μs 256×
1048576 1 0.5 μs 1×
=== 结论 ===
逐字节(1 字节/次):1,048,576 次调用,约 524 ms —— 慢到不可用
4 KB 一块 :256 次调用,约 128.0 μs
一次读完 :1 次调用,约 0.001 ms
提速倍数 ≈ 4096×(1 字节 → 4 KB)
=== 这正是 printf 的行为 ===
printf 默认'行缓冲':先写进用户态缓冲区,遇到换行或缓冲满才真正 write 一次
→ 所以 'printf 了但屏幕上没出现' 通常不是 bug,是缓冲区还没满
→ fflush(stdout) 就是'现在立刻调用一次 write 系统调用'三条结论:
| 结论 | 说明 |
|---|---|
| 系统调用比函数调用贵两个数量级 | 量级示意里是 250×,来源是两次特权级切换 + 内核入口出口处理 |
| 块大小是关键杠杆 | 1 字节/次 → 4 KB/次,调用次数降 4096 倍 |
| 所以缓冲区不是"优化",是"必需品" | stdio 的缓冲、磁盘的块、网络的分组——全是同一个理由 |
考点
考点
1. 系统调用的机制(必考)
syscall指令:服务号在$v0,参数在$a0~$a2,返回值在$v0;- 六步流程:用户态准备 →
syscall→ 硬件存EPC/Status、切内核态、跳到固定入口 → 内核按$v0分派 →eret→ 回到下一条指令; - 异常入口地址由硬件定死(MIPS32 通用向量
0x80000180)——用户程序不能自己选择跳哪; - CP0 四件套:
Status(特权级/中断使能)、Cause(异常类型)、EPC(异常返回地址)、BadVAddr(坏地址)。
2. 用户态与内核态
| 用户态 | 内核态 | |
|---|---|---|
| 指令 | 只能执行非特权指令 | 全部指令 |
| 内存 | 只能访问自己地址空间中被允许的页 | 全部地址 + 硬件 |
- 拦住的三种手段:特权指令检查 + 页表权限位 + 异常机制;
- 内存越权不是"读到 0",而是在
lw那一刻被硬件拦下、变成一次异常。
3. 中断 / 异常分类(与 OS 交叉)
| 类型 | 触发 | 可恢复 |
|---|---|---|
| 中断(外中断) | 外部设备、异步 | 是 |
| 故障(fault) | 内部、同步、可重试(缺页) | 是(重执行) |
| 自陷(trap) | 内部、同步、有意为之(syscall) | 是(返回下一条) |
| 终止(abort) | 内部、不可恢复 | 否 |
- 系统调用属于"自陷":"主动敲门",而缺页是 "出了意外被拦下"。
4. 与普通函数调用的区别(背这张表)
①特权级变(2 次切换)②目标地址固定 ③返回用 eret 而非 jr $ra ④现场由硬件+内核保存 ⑤开销差两个数量级 ⑥可能被拒绝 ⑦不可移植。
5. 服务号(记常用的)
- MARS:1 打印整数、4 打印字符串、5 读整数、8 读字符串、9
sbrk、10exit; - Linux/MIPS o32:编号 = 4000 + n(
write=4004、read=4003、exit=4001)——因为 0~3999 被 System V/IRIX 占了; - Linux/MIPS 的错误约定:
$a3 ≠ 0表示出错,错误码在$v0(不用负数,因为返回值本身可能很大); - x86-64 对照:号在
rax,参数rdi/rsi/rdx/r10/r8/r9,返回rax(负值 = −errno)。
6. 指针寄存器与"谁保护"
$v0会被下一次系统调用覆盖 → 中间结果必须搬走;$t是 caller-saved:内核对它"有权改",不保证 → 跨系统调用保存要用$s;$s是内核必须保护的("被调用者保存"契约,内核是最大的被调用者)。
7. 开销与缓冲区
- 系统调用 ≈ 250× 普通函数调用(量级示意);
- 缓冲区的作用:把大量昂贵小调用换成少量便宜大调用;
printf是行缓冲:"打印了但没显示"通常是缓冲未满;fflush(stdout)= 立刻write一次;sbrk的用法:一次要一大块,自己切——这就是malloc的底层。
小结
- 用户程序不能直接碰硬件,因为那意味着"一个 bug 毁掉整个系统"。系统调用是唯一受检的窗口。
syscall的本质是"特权级切换":用户态 → 内核态 → 用户态;服务号在$v0,参数在$a0~$a2,返回值在$v0。- 六步流程:准备 →
syscall→ 硬件存EPC/Status、切态、跳固定入口 → 内核分派 →eret→ 回下一条。入口地址由硬件定死(0x80000180)。 - CP0 四件套:
Status(特权级)、Cause(异常类型)、EPC(返回地址)、BadVAddr(坏地址)。 - 拦住的三种手段:特权指令 + 页表权限位 + 异常机制;越权访问会变成异常,而不是"静默读到 0"。
- 系统调用属于"自陷":主动敲门;与"缺页"这种被动故障共用一个入口,但语义不同。
- 与函数调用的七项差别:特权级、跳转目标、返回方式、现场保存、开销、可否被拒、可移植性。"不可移植"这一条催生了 C 标准库这层封装。
- 服务号是 ABI 的一部分:MARS 用 1/4/5/8/9/10;Linux/MIPS 用 4000+n(
write=4004),错误靠$a3标志。 - 系统调用很贵(约 250×),所以必须要缓冲区——
printf慢半拍显示、fflush的语义、malloc一次要一大块,全是这一条的直接推论。
回到主线:到这里,汇编这门"语言"的全部零件都齐了——顺序、分支、循环、函数、系统调用,五件事都能手写。
但"会写零件"和"能跑通一个完整程序"是两回事。
.data段和.text段怎么摆?main是谁调用的?la取到的是地址还是内容?一个 50 行的程序,怎么从"能读懂"变成"真跑起来"?更重要的是:怎么验证它跑对了——没有编译器、没有模拟器的环境下,怎么用手算和脚本对账?
这就是下一篇(可选实践):手写一段汇编并在模拟器/真机跑通。
再往后,就是 lang 的第二部分——C 语言:int、char、struct、指针、堆,看编译器怎么把"高级"的东西落到本篇这些寄存器和内存地址上。
评论(0)
当前浏览器不允许本地存储,评论无法保存。
还没有评论,来说两句。