7.1 异常、中断与系统调用
用户态机器指令可能因整数除法错误或非法地址访问触发异常,设备和计时器也可请求中断。OS 配置受控入口并保存上下文;事件也可能发生在内核态,并非每次都从用户态开始。C 的除零表达式是未定义行为,不能据此保证某种 signal;普通非陷阱浮点除法还可能产生无穷或 NaN。
page fault 展示了处理器怎样暂停当前指令并进入内核。本章建立更完整的异常控制流模型,追踪一次 Linux system call;POSIX signal 放到下一篇单独处理。
read 看似是函数,权限边界却不是普通调用
用户程序可以做算术、访问自己的映射,却不能直接配置页表、调度其他进程或随意控制设备。读取文件时,它通过内核提供的 system call 接口请求服务。下面的 read_once.c 用 cc -std=c17 -Wall -Wextra -Werror read_once.c -o read_once 编译;它只读取一次、最多 64 byte,并写出本次实际读到的内容,不是整文件复制器。
#include <errno.h>
#include <stdio.h>
#include <unistd.h>
static ssize_t read_retry(int file_descriptor, void *buffer, size_t capacity) {
for (;;) {
ssize_t result = read(file_descriptor, buffer, capacity);
if (result < 0 && errno == EINTR) {
continue;
}
return result;
}
}
int main(void) {
char buffer[64];
ssize_t count = read_retry(STDIN_FILENO, buffer, sizeof buffer);
if (count < 0) {
perror("read");
return 1;
}
size_t written = 0;
while (written < (size_t)count) {
ssize_t result = write(STDOUT_FILENO,
buffer + written,
(size_t)count - written);
if (result > 0) {
written += (size_t)result;
} else if (result < 0 && errno == EINTR) {
continue;
} else {
if (result < 0) {
perror("write");
} else {
fputs("write made no progress\n", stderr);
}
return 1;
}
}
return 0;
}连这段小程序也说明了系统调用不是“要多少就一定完成多少”:read 可以短读,write 可以部分完成,signal 可能产生 EINTR,文件描述符还可能是 nonblocking。本例针对阻塞 fd;nonblocking 的 EAGAIN 会报错,需事件循环重试时应另设计等待逻辑。EINTR 重试也要服从取消策略。若输出管道已关闭,默认 SIGPIPE 可先终止进程;要观察 EPIPE,须按应用协议安排 signal disposition。
异常控制流把事件交给处理入口
正常 control flow 由 branch、call、return 等指令改变。exceptional control flow 则由处理器与 OS 协作,把控制转到预先配置的 handler。
跨架构都能看到几类来源:
- 当前指令同步触发的异常,如 page fault、非法指令、断点;
- 外部设备或 timer 异步请求的 interrupt;
- 软件显式执行的 system-call 指令;
- machine check 等严重硬件事件。
“interrupt、trap、fault、abort”这组四分法常见于 x86/教材:trap 通常返回到下一条指令,fault 在修复后可重试当前指令,abort 通常无法可靠恢复。但术语和返回 PC 规则属于具体 ISA;RISC-V 将同步事件统称 exception,并与 asynchronous interrupt 共同归入 trap。阅读内核时先确认目标架构词汇。
当机器 store 确实执行且零地址未映射时,一条典型路径是(不是对 C 未定义行为的承诺):
用户指令尝试 store
-> MMU 无法得到允许写入的 translation
-> 同步 page-fault exception
-> 内核查 VMA,判断无法修复
-> 向 faulting thread 安排 SIGSEGV
-> 默认 disposition 终止进程并可能生成 core dumpCPU 检测的是翻译/权限失败,OS 决定是否能修复及发送什么 signal,shell 才可能打印 “Segmentation fault”。把三者都叫“CPU 杀进程”会丢掉关键边界。
用户模式与内核模式是权限,不是两台 CPU
这里讨论支持用户/内核隔离的通用 OS 平台,处理器提供非特权执行级和高特权级;不是所有微控制器都具备相同分层。x86 常用 ring 3 运行用户进程、ring 0 运行 OS kernel;AArch64 使用 exception level,RISC-V 使用 U/S/M mode。
“CPU 只有用户态和内核态两种状态”不正确:hypervisor、firmware、secure monitor 和 machine mode 都可能存在。高权限也不等于绕过所有系统约束;IOMMU、secure world、硬件错误和平台策略仍然限制行为。
用户模式通常不能:
- 修改 page-table root 或中断控制配置;
- 执行 privileged instruction;
- 访问标成 supervisor-only 的 virtual mapping;
- 直接把任意设备 DMA 指向任意物理内存。
这种边界让内核集中验证资源 ownership、地址与权限。驱动、文件系统和网络栈仍可能有漏洞,所以 privilege boundary 需要最小权限、内存安全与攻击面控制共同支撑。
一次 x86-64 Linux system call 的抽象路径
以 glibc read(fd, buffer, count) 为例:
- 用户代码按 C ABI 调用 libc wrapper;
- wrapper 按 Linux x86-64 syscall ABI 放置编号与参数;
syscall指令进入内核配置的入口,改变 privilege 与部分处理器状态;- entry assembly 建立 Linux 需要的 kernel execution context,保存用户状态并切到合适 kernel stack;
- dispatcher 校验编号并调用 read 路径;
- VFS/文件实现检查 file descriptor、权限、用户 buffer 和当前文件状态;
- 数据可能已在 page cache、可能来自 pipe/socket,也可能让线程阻塞等待设备完成;
- 内核返回结果或负的 internal error code,entry path 恢复可返回的用户状态;
- libc wrapper 把错误转换为
-1并设置errno,否则返回 byte 数。
x86-64 Linux 的前六个参数通常使用 rdi、rsi、rdx、r10、r8、r9,system call number 在 rax,返回值也在 rax;syscall 还会改写 rcx、r11。这是 Linux ABI,不是 ISO C,也不适用于 AArch64/RISC-V。
在 64-bit 模式,syscall 不会自动切换 RSP 到内核栈;它按架构规则保存返回地址/标志等部分状态,Linux 入口 assembly 再完成所需栈切换与寄存器保存。interrupt gate 也只由硬件压入规定的部分上下文,两者都不是“自动保存所有寄存器”。
用 libc 的 syscall 验证编号,不手写脆弱 inline asm
Linux/glibc 提供 syscall(2) 函数,可绕过特定 wrapper,但仍由 libc 安排正确寄存器与错误转换:
#define _GNU_SOURCE
#include <stdio.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <unistd.h>
int main(void) {
long raw_path = syscall(SYS_getpid);
pid_t wrapper_path = getpid();
printf("syscall(SYS_getpid)=%ld getpid()=%ld\n",
raw_path, (long)wrapper_path);
return raw_path == (long)wrapper_path ? 0 : 1;
}它适合实验或调用尚无 wrapper 的 Linux syscall,不适合替代清晰、可移植的标准 API。手写 inline assembly 很容易漏掉 clobber、约束、stack alignment、errno 和架构差异;只有在明确需要且经过 ABI 审查时才做。
并非所有“向 OS 询问信息”的 libc 函数每次都进入 kernel。Linux vDSO 可让 clock_gettime、gettimeofday 等在条件满足时从用户态读取内核维护的数据。benchmark 前应用 strace、perf trace 或反汇编确认实际路径。
system call 固定开销来自哪些部分
一次 privilege transition 的成本不是“上下文切换”的同义词。当前 thread 可以进入 kernel 再返回,并没有换成另一个 thread。开销可能来自:
- 入口/出口指令和寄存器状态管理;
- 安全缓解措施与架构 serialization;
- dispatcher、权限、file descriptor 与参数验证;
copy_from_user/copy_to_user及潜在 fault;- kernel code/data 对 cache、TLB 和 branch predictor 的影响;
- 真正服务工作、锁竞争、调度、I/O wait。
SMEP/SMAP 是限制内核执行/访问 user mapping 的硬件机制,不是完整的“用户指针合法性检查器”。内核仍要用专门 user-access primitive 安全复制并处理 fault。
KPTI 可能在某些平台切换 page table 以缓解侧信道;PCID 等机制又可减少 TLB flush。不能说每次 syscall 必然清空 TLB 或分支预测器。CPU、kernel config、microcode 与安全策略都会改变成本。
测量空服务路径,而不是背一个纳秒数
getpid 的实际路径与耗时依系统实现而异。下面的 Linux 程序保存为 time_getpid.c,用 cc -std=c17 -O2 -Wall -Wextra -Werror time_getpid.c -o time_getpid 编译。它执行 syscall(SYS_getpid),减去近似循环基线;计时或 syscall 失败就停止,不把错误路径算成成功测量:
#define _GNU_SOURCE
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/syscall.h>
#include <time.h>
#include <unistd.h>
enum { ITERATIONS = 1000000 };
static uint64_t now_nanoseconds(void) {
struct timespec timestamp;
if (clock_gettime(CLOCK_MONOTONIC, ×tamp) != 0) {
perror("clock_gettime");
exit(EXIT_FAILURE);
}
return (uint64_t)timestamp.tv_sec * UINT64_C(1000000000)
+ (uint64_t)timestamp.tv_nsec;
}
int main(void) {
volatile long sink = 0;
uint64_t baseline_start = now_nanoseconds();
for (int index = 0; index < ITERATIONS; index++) {
sink ^= index & 1;
}
uint64_t baseline = now_nanoseconds() - baseline_start;
uint64_t syscall_start = now_nanoseconds();
for (int index = 0; index < ITERATIONS; index++) {
long pid = syscall(SYS_getpid);
if (pid < 0) {
perror("syscall(SYS_getpid)");
return 1;
}
sink ^= pid;
}
uint64_t syscall_total = now_nanoseconds() - syscall_start;
if (syscall_total <= baseline) {
puts("measurement noise is larger than the observed difference");
return sink == -1;
}
printf("estimated syscall path: %.1f ns/call\n",
(double)(syscall_total - baseline) / ITERATIONS);
return sink == -1;
}结果包含 libc 通用 syscall wrapper、返回值检查与两种循环的差异,不是纯硬件权限切换时间。基线减法不能消除所有误差。可信实验还应:
- 固定 CPU affinity,做 warm-up 和多轮分布;
- 记录 CPU、kernel、mitigation、governor 和虚拟化环境;
- 检查
clock_gettime本身走 vDSO; - 用 profiler/trace 确认循环确实执行目标 syscall;
- 避免把普通函数的 inline 结果拿来作不对等比较。
system call 本身可能只需很短时间,read 的整体 latency 却可能被 pipe 等待、网络、page fault 或存储 I/O 主导。不要把 /dev/zero 的结果叫“从磁盘读取”。
批量化减少的是往返与边界工作
每 byte 调一次 read 通常浪费大量固定开销,使用适度 buffer 能减少入口次数。writev/readv 可 scatter/gather,batching syscall 可一次提交多个请求,io_uring 在支持场景下可以减少提交/完成往返。
但 buffer 越大并非越好。交互 latency、memory footprint、backpressure、partial I/O 和公平性都会参与选择。网络协议也可能要求及时 flush。正确原则是基于工作负载摊薄边界成本,而不是把“4096”当作统一最优值。
异步 I/O 也没有“绕过所有 syscall”。初始化、注册、提交策略、completion 与 fallback 仍涉及 kernel interface;它优化的是交互方式和摊销路径。
用 trace 看事实,不把 trace 当零成本观察
strace 可观察 Linux system call 及结果:
strace -c ./program
strace -f -e trace=openat,read,write,close ./program-c 汇总调用次数、错误与 trace 观察到的时间;-f 跟随创建的线程/进程。结果受 tracing 本身、调度和 I/O 状态影响,不等于无探针的生产 latency。
strace 主要借助 ptrace 等 Linux 机制截获 syscall stop,现代版本也可能利用其他内核能力优化特定功能。动态链接器和运行时在 main 之前就会产生 mmap、openat 等调用,所以 trace 中出现的 syscall 不一定来自你写的那一行。
当目标是低开销统计或内核路径分析,可结合 perf、eBPF、ftrace 等,但每种工具都有权限、采样与观测盲区。
异常返回必须恢复可观察状态
内核返回用户态前要确保:
- 返回 PC 与 stack 指向允许的用户范围;
- privilege 与 interrupt state 合法;
- 待处理工作按 OS 规则完成,例如 signal delivery、reschedule;
- system call 返回值按内核 ABI 编码正确,由 libc wrapper 按 API 更新用户态
errno; - 不泄露不应暴露的 kernel state。
某些 system call 被 signal 中断后可能返回 EINTR,也可能在 SA_RESTART 等条件下自动重启。若已有部分数据传输,API 可能直接返回已完成 byte 数。调用者不能只写一个“失败就重试”模板覆盖所有有副作用操作。
动手越过边界
- 用
strace -c比较逐 byte 与 64 KiB buffer 读取,记录调用次数与总吞吐。 - 让 input 来自 regular file、pipe 与
/dev/zero,解释同一个read为什么走不同路径。 - 对比 libc
getpid与syscall(SYS_getpid),用 trace 确认各自是否进入 kernel。 - 列出一次 syscall entry 中 ISA、Linux syscall ABI 和 C ABI 各自负责什么。
- 解释 page fault 为何是同步 exception,而完成磁盘 I/O 的 device interrupt 又是异步事件。
- 构造 partial write 场景,验证示例循环不会丢 byte。
内核还会异步敲响用户程序的门
system call 是程序主动越界,signal 则由 kernel 安排在用户上下文中执行通知。下一篇进入POSIX 信号与异步安全,讨论 pending、mask、sigaction、EINTR 与 self-pipe。