中断与系统调用 (Interrupts and System Calls)


章节概述

本章揭开系统调用的神秘面纱。在 第一章 中,我们提到系统调用是用户态进入内核态的唯一合法途径。在本章,我们将从 CPU 中断架构开始,逐层追踪 printf("hello") 如何一步步触发硬件中断、进入内核、找到处理函数、操作硬件、最终返回用户态。这是一次从 C 代码到硅片的完整旅程。

中断是 CPU 与外部世界交互的核心机制。系统调用本质上是一种”软件中断”——用户程序主动触发,请求内核提供服务。理解中断框架,对于编写正确的内核代码(尤其是驱动和同步相关)至关重要。

本章内容:

  • x86 中断架构:IDT、中断门、异常向量
  • 异常 vs 中断:CPU 故障、陷阱、外部中断
  • 系统调用机制:syscall 指令、syscall table、参数传递
  • 完整追踪:从 printf → write → syscall → 内核 → 设备 → 返回
  • strace 深度使用:用户态系统调用追踪
  • 添加自定义系统调用:教育目的
  • 上下文切换开销测量

前置要求


第一节:x86 中断体系结构


1.1 中断描述符表 (IDT)

graph TD
 subgraph "x86 中断处理架构"
 IDTR["IDTR 寄存器<br/>└→ IDT 基址 + 长度"]
 IDT["中断描述符表 (IDT)<br/>256 个条目 × 16 字节<br/>每个条目 = 一个门描述符"]
 
 V0["#0 除零错误"]
 V1["#1 调试"]
 V2["#2 NMI"]
 V3["#3 断点 (int3)"]
 V4["#4 溢出 (into)"]
 V5["#5 边界检查"]
 V6["#6 无效操作码 (#UD)"]
 V7["#7 设备不可用 (FPU)"]
 V8["#8 双重错误"]
 V10["#10 无效 TSS"]
 V11["#11 段不存在"]
 V12["#12 栈段错误"]
 V13["#13 通用保护 (#GP)"]
 V14["#14 页错误 (#PF)"]
 V16["#16 FPU 错误"]
 V19["#19 SIMD 浮点错误"]
 V32_2["#32-255: 外部中断<br/>和系统调用"]
 
 IDTR --> IDT
 IDT --> V0
 IDT --> V1
 IDT --> V14
 IDT --> V32_2
 end

1.2 中断门描述符 (Gate Descriptor)

// x86-64 中断门描述符 (128 位, 16 字节)
//
// 结构:
// ┌─────────────────────────────────────────────────────────────┐
// │ 63 32│31 16│15 8│7 6 5 4 3 0 │
// ├─────────────────────────────────────────────────────────────┤
// │ ISR 地址 [63:32] │ 保留 │ IST │DPL│P│ GateType(0xE) │
// ├─────────────────────────────────────────────────────────────┤
// │ 段选择子 │ ISR 地址 [31:16] │
// ├─────────────────────────────────────────────────────────────┤
// │ ISR 地址 [15:0] │ │
// └─────────────────────────────────────────────────────────────┘
//
// 字段说明:
// - ISR 地址: 中断服务例程的入口虚拟地址 (64 位)
// - 段选择子: 必须指向内核代码段 (__KERNEL_CS = 0x10)
// - DPL (Descriptor Privilege Level): 门的特权级
// 0: 只能内核态触发
// 3: 用户态也能触发 (如 int3, int 0x80)
// - P (Present): 1 = 此门有效
// - GateType: 0xE = 中断门 (禁用中断), 0xF = 陷阱门 (不禁用中断)
// - IST (Interrupt Stack Table): 要使用的内核栈索引 (0-7)
 
// Intel 和 AMD 手册中有完整定义:
// Intel SDM Vol. 3, Chapter 6: Interrupt and Exception Handling

1.3 异常分类

// ============================================
// x86 异常分类 (按 Intel 分类)
// ============================================
 
// 1. 故障 (Fault) —— 可纠正的异常
// 保存的 RIP 指向出错指令本身
// 处理完毕后重新执行该指令
// 例如:
// - #PF (页错误): 页面不存在 → 调入 → 重试访问
// - #GP (一般保护): 段访问违规
// - #NM (设备不可用): FPU 首次使用 → 初始化 → 重试
 
// 2. 陷阱 (Trap) —— 用于调试和系统调用
// 保存的 RIP 指向出错指令的下一条
// 例如:
// - #BP (int3): 软件断点 → 执行断点处理 → 继续执行下一条
// - #OF (into): 溢出检查 → 溢出处理
// - 系统调用: syscall → 内核处理 → 返回到下一条指令
 
// 3. 中止 (Abort) —— 不可恢复的错误
// RIP 不可靠,进程/系统可能不稳定
// 例如:
// - #DF (双重错误): 在处理一个异常时又发生异常
// - 机器检查 (Machine Check): 硬件错误
 
// Linux 中的异常处理示例:
// arch/x86/kernel/traps.c — 异常处理入口
// arch/x86/mm/fault.c — #PF 页错误处理
// arch/x86/kernel/idt.c — IDT 初始化

1.4 中断处理的总流程

sequenceDiagram
 participant HW as 硬件 / 软件指令
 participant CPU as CPU
 participant IDT as IDT
 participant ASM as 汇编入口<br/>(entry_64.S)
 participant C as C 处理函数<br/>(do_IRQ / do_syscall)
 participant KERN as 内核子系统
 
 HW->>CPU: 中断 / 异常 / syscall
 Note over CPU: 1. 检查 DPL<br/>2. 切换栈 (如果需要)<br/>3. 压栈: SS, RSP, RFLAGS, CS, RIP<br/>4. 如有错误码, 压栈 error_code
 
 CPU->>IDT: 用向量号索引 IDT
 IDT-->>CPU: 门描述符 (ISR 地址 + 段选择子)
 CPU->>ASM: 跳转到 ISR 入口
 
 Note over ASM: 1. 保存所有通用寄存器 (SAVE_ALL)<br/>2. 切换到内核页表 (如果尚未)<br/>3. 设置内核栈
 ASM->>C: 调用 C 处理函数
 
 C->>KERN: 派发到具体子系统
 Note over KERN: do_syscall_64 → syscall table<br/>do_IRQ → 遍历共享此 IRQ 的 ISR
 
 KERN-->>C: 处理完成
 C-->>ASM: 返回
 Note over ASM: 1. 恢复寄存器 (RESTORE_ALL)<br/>2. iretq (中断返回)
 ASM-->>CPU: iretq
 Note over CPU: 1. 恢复 RIP, CS, RFLAGS, RSP, SS<br/>2. 恢复特权级<br/>3. 继续执行

第二节:系统调用机制


2.1 系统调用表 (sys_call_table)

// ============================================
// Linux 系统调用表
// 定义在 arch/x86/entry/syscalls/syscall_64.tbl
// 编译后生成 arch/x86/include/generated/asm/syscalls_64.h
// ============================================
 
// 系统调用表的本质:
// sys_call_table 是一个函数指针数组
// 每个索引对应一个系统调用号
//
// 简化定义:
// typedef long (*sys_call_ptr_t)(unsigned long, unsigned long,
// unsigned long, unsigned long,
// unsigned long, unsigned long);
// extern const sys_call_ptr_t sys_call_table[];
 
// 常用系统调用及其编号 (x86-64):
//
// #0 sys_read — 读文件
// #1 sys_write — 写文件
// #2 sys_open — 打开文件
// #3 sys_close — 关闭文件
// #8 sys_lseek — 文件定位
// #9 sys_mmap — 内存映射
// #10 sys_mprotect — 修改内存保护
// #11 sys_munmap — 取消内存映射
// #12 sys_brk — 修改堆边界
// #56 sys_clone — 创建子进程/线程
// #57 sys_fork — 创建子进程 (已淘汰, 由 clone 取代)
// #59 sys_execve — 执行程序
// #60 sys_exit — 进程退出
// #61 sys_wait4 — 等待子进程
// #62 sys_kill — 发送信号
// #63 sys_uname — 获取系统名
// #79 sys_getcwd — 获取当前工作目录
// #231 sys_exit_group — 所有线程退出
 
// 查看你的系统的 syscall 编号:
// /usr/include/asm-generic/unistd.h
// /usr/include/x86_64-linux-gnu/asm/unistd_64.h

2.2 系统调用的内核入口

// ============================================
// entry_SYSCALL_64 (arch/x86/entry/entry_64.S)
// 简化伪代码表示
// ============================================
 
void entry_SYSCALL_64(void)
{
 // syscall 指令已经:
 // - RCX = 用户 RIP (syscall 的下一条指令)
 // - R11 = 用户 RFLAGS
 // - CS = 内核代码段选择子
 // - SS = 内核栈段选择子
 
 // 1. 切换栈
 // swapgs_restore_regs_and_return_to_usermode
 // 从 per-CPU 区域加载内核栈指针
 // mov %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp0)
 
 // 2. 构建 pt_regs 结构体 (保存所有用户态寄存器)
 // 按照 pt_regs 的顺序压栈
 // pushq $__USER_DS // ss
 // pushq PER_CPU_VAR(cpu_tss_rw + TSS_sp0) // rsp
 // pushq %r11 // rflags
 // pushq $__USER_CS // cs
 // pushq %rcx // rip
 // pushq $0 // orig_ax
 // pushq %rdi, %rsi, %rdx, %rcx, %rax, %r8, %r9, %r10, %r11
 // pushq %rbx, %rbp, %r12, %r13, %r14, %r15
 
 // 3. 调用 C 处理函数
 // movq %rsp, %rdi // 第一个参数: pt_regs *
 // call do_syscall_64
 
 // 4. 返回用户态
 // movq %rax, (pt_regs->orig_ax 位置)
 // jmp syscall_return_via_sysret 或 iretq
}
 
// 内核 C 处理函数 (arch/x86/entry/common.c)
__visible noinstr void do_syscall_64(struct pt_regs *regs, int nr)
{
 // nr = 系统调用号 (用户态 RAX 的值)
 
 if (likely(nr < NR_syscalls)) {
 sys_call_ptr_t fn = sys_call_table[nr]; // 查表!
 
 if (unlikely(!fn)) // 调用号在范围内但未实现
 goto invoc;
 
 // 调用实际的系统调用实现
 // 参数: regs->di, regs->si, regs->dx,
 // regs->r10(代替rcx, 因为syscall破坏了rcx),
 // regs->r8, regs->r9
 regs->ax = fn(regs->di, regs->si, regs->dx,
 regs->r10, regs->r8, regs->r9);
 return;
 }
 
invoc:
 regs->ax = -ENOSYS; // 调用号不存在
}

2.3 参数传递规则

// ============================================
// x86-64 系统调用参数传递
// ============================================
//
// 寄存器使用约定:
// RAX: 系统调用号
// RDI: 参数 1 (arg0)
// RSI: 参数 2 (arg1)
// RDX: 参数 3 (arg2)
// R10: 参数 4 (arg3) ← 注意: 不是 RCX!
// R8: 参数 5 (arg4)
// R9: 参数 6 (arg5)
//
// 为什么 R10 而不是 RCX?
// 因为 syscall 指令自动用 RCX 保存返回地址(RIP)!
// 所以 RCX 被破坏了, 第4个参数改用 R10
//
// 返回值: RAX (成功时) / -errno (错误时)
 
// 示例: write(1, "hello", 5) 的汇编等价:
//
// mov $1, %rax # 系统调用号: __NR_write
// mov $1, %rdi # fd = stdout
// mov $buf, %rsi # 用户空间缓冲区地址
// mov $5, %rdx # 长度
// syscall # 进入内核
// cmp $-4096, %rax # 检查返回值
// ja error # 如果 > -4096, 表示错误

2.4 系统调用号的演变

// 对比不同架构的系统调用号:
 
// x86-64 (unistd_64.h):
#define __NR_read 0
#define __NR_write 1
#define __NR_open 2
#define __NR_close 3
 
// ARM64 (unistd.h):
// (架构独立编号, 从 asm-generic 继承)
#define __NR_read 63
#define __NR_write 64
#define __NR_openat 56
 
// 注意: 不同架构的系统调用号不同!
// 但 C 标准库 (glibc) 隐藏了这一差异
// 用户程序只需要调用 write(), glibc 负责选择正确的调用号

第三节:从 printf 到 write —— 完整追踪


3.1 完整的调用链

graph TD
 subgraph "用户态 (Ring 3)"
 A["printf('Hello World\\n')"]
 B["glibc vfprintf()<br/>格式化字符串<br/>缓冲输出"]
 C["glibc write(1, buf, len)"]
 D["syscall 指令<br/>mov $1, %rax<br/>syscall"]
 end
 
 subgraph "内核态 (Ring 0)"
 E["entry_SYSCALL_64<br/>arch/x86/entry/entry_64.S<br/>保存寄存器, 切换栈"]
 F["do_syscall_64()<br/>arch/x86/entry/common.c<br/>查 sys_call_table<br/>RAX=1 → sys_write"]
 G["ksys_write()<br/>fs/read_write.c<br/>fd = 1 (stdout)"]
 H["fdget(fd) → struct file*<br/>file->f_op->write_iter()<br/>对终端设备: tty_write()"]
 I["n_tty_write()<br/>drivers/tty/n_tty.c<br/>行规则处理"]
 J["uart_write()<br/>drivers/tty/serial/xxx.c<br/>向 UART 写入字节"]
 K["写寄存器 → 硬件 (UART)"]
 end
 
 subgraph "硬件"
 L["UART 发送 Shift 寄存器<br/>逐 bit 发送到串口"]
 M["终端模拟器 / 物理终端<br/>显示字符"]
 end
 
 A --> B --> C --> D
 D --> E --> F --> G --> H --> I --> J --> K
 K --> L --> M

3.2 逐层代码分析

// ============================================
// Layer 1: 用户态 printf
// ============================================
#include <stdio.h>
int main(void) {
 printf("Hello, World!\n");
 return 0;
}
 
// printf 在 glibc 中的实现路径 (简化):
// printf → vfprintf → __do_fprintf → 格式化处理 → _IO_file_write → __write
 
// ============================================
// Layer 2: glibc 的 write 包装
// ============================================
 
// glibc/sysdeps/unix/sysv/linux/write.c (简化):
ssize_t __libc_write(int fd, const void *buf, size_t count)
{
 // 1. 检查是否有缓冲区需要刷新 (stdio 缓冲)
 // 2. 设置系统调用号和参数
 // 3. 执行 syscall 指令
 // 4. 处理返回值和 errno
 
 long int result;
 
 // 对于 x86-64:
 result = INLINE_SYSCALL_CALL(write, fd, buf, count);
 // 展开为:
 // {
 // unsigned long int resultvar;
 // asm volatile (
 // "syscall\n\t"
 // : "=a" (resultvar)
 // : "a" (__NR_write), // RAX = 1
 // "D" (fd), // RDI = fd
 // "S" (buf), // RSI = buf
 // "d" (count) // RDX = count
 // : "rcx", "r11", "memory"
 // );
 // resultvar;
 // }
 //
 // 注意: rcx 和 r11 被 clobber!
 // rcx = 内核返回后保存的用户 RIP
 // r11 = 内核返回后保存的用户 RFLAGS
 
 // 错误处理
 if (result >= (unsigned long int) -4095) {
 __set_errno(-result);
 return -1;
 }
 
 return result;
}
weak_alias(__libc_write, __write)
libc_hidden_weak(__write)
// ============================================
// Layer 3: 内核端 — sys_write 实现
// ============================================
// 实际实现在 fs/read_write.c
 
SYSCALL_DEFINE3(write, unsigned int, fd, 
 const char __user *, buf, size_t, count)
{
 // SYSCALL_DEFINE3 宏展开为:
 // asmlinkage long sys_write(unsigned int fd,
 // const char __user *buf,
 // size_t count)
 // { return ksys_write(fd, buf, count); }
 
 return ksys_write(fd, buf, count);
}
 
ssize_t ksys_write(unsigned int fd, const char __user *buf, size_t count)
{
 struct fd f = fdget_pos(fd); // 从文件描述符表获取 struct file*
 ssize_t ret = -EBADF;
 
 if (f.file) {
 loff_t pos, *ppos = file_ppos(f.file);
 // 对于 stdout (终端): file->f_pos 没有意义, ppos = NULL
 
 // 调用 VFS 层, 最终走到终端驱动
 ret = vfs_write(f.file, buf, count, ppos);
 
 fdput_pos(f); // 释放 fd 引用
 }
 
 return ret;
}
 
// ============================================
// Layer 4: VFS — vfs_write
// ============================================
// 详见 [[03_文件系统|文件系统]] 中的完整分析
 
ssize_t vfs_write(struct file *file, const char __user *buf,
 size_t count, loff_t *pos)
{
 ssize_t ret;
 
 if (!(file->f_mode & FMODE_WRITE))
 return -EBADF;
 if (!(file->f_mode & FMODE_CAN_WRITE))
 return -EINVAL;
 
 // ...rw_verify_area 权限检查...
 // ...security_file_permission...
 
 // 多态调用: 对终端设备, file->f_op = tty_fops
 if (file->f_op->write)
 ret = file->f_op->write(file, buf, count, pos);
 else if (file->f_op->write_iter)
 ret = new_sync_write(file, buf, count, pos);
 else
 ret = -EINVAL;
 
 if (ret > 0) {
 fsnotify_modify(file); // inotify 通知
 add_wchar(current, ret);
 }
 
 return ret;
}
 
// ============================================
// Layer 5: TTY 层 — tty_write
// ============================================
// drivers/tty/tty_io.c
 
static ssize_t tty_write(struct file *file, const char __user *buf,
 size_t count, loff_t *ppos)
{
 struct tty_struct *tty = file_tty(file);
 struct tty_ldisc *ld;
 ssize_t ret;
 
 if (tty->ops->write_room)
 tty->ops->write_room(tty); // 检查缓冲区空间
 
 ld = tty_ldisc_ref_wait(tty); // 获取行规则模块 (line discipline)
 
 // 调用行规则模块的 write 方法
 ret = do_tty_write(ld->ops->write, tty, file, buf, count);
 
 tty_ldisc_deref(ld);
 return ret;
}
 
// ============================================
// Layer 6: N_TTY 行规则 — n_tty_write
// ============================================
// drivers/tty/n_tty.c
 
// ============================================
// Layer 7: UART 驱动 — 最终写入硬件寄存器
// ============================================
// drivers/tty/serial/8250/8250_port.c
static void serial8250_console_putchar(struct uart_port *port,
 unsigned char ch)
{
 struct uart_8250_port *up = up_to_u8250p(port);
 
 // 等待发送器就绪 (UART 状态寄存器)
 wait_for_xmitr(up, UART_LSR_THRE);
 
 // 写入数据到 UART 的发送保持寄存器
 // 这就是最终的操作! 一个 outb 或 writel 指令
 serial_port_out(port, UART_TX, ch);
}
// 练习 1: 追踪系统调用
// 1. 使用 strace 观察一个简单程序的系统调用:
// strace -tt -T -o trace.log ./hello
// cat trace.log
//
// 2. 使用 ftrace (内核函数追踪器) 追踪内核函数:
// echo function_graph > /sys/kernel/debug/tracing/current_tracer
// echo SyS_write > /sys/kernel/debug/tracing/set_ftrace_filter
// echo 1 > /sys/kernel/debug/tracing/tracing_on
// ./hello
// echo 0 > /sys/kernel/debug/tracing/tracing_on
// cat /sys/kernel/debug/tracing/trace
//
// 3. 使用 bpftrace 追踪系统调用:
// sudo bpftrace -e 'tracepoint:syscalls:sys_enter_write
// { printf("%d: %s\n", pid, str(args->buf)); }'

第四节:strace 深度使用


4.1 strace 基础

# strace 是理解系统调用的头号工具
# 它追踪用户态程序和内核之间的系统调用接口
 
# 基础用法
strace ls # 显示所有系统调用
strace -c ls # 统计摘要
strace -e trace=open,read,write ls # 只看特定调用
strace -e trace=file ls # 所有文件相关调用
strace -e trace=network curl example.com # 网络相关
strace -e trace=signal kill -TERM $$ # 信号相关
strace -e trace=process bash -c 'ls' # 进程管理相关
 
# 时间分析
strace -T ls # 显示每个调用的耗时
strace -tt ls # 显示精确时间戳
strace -c -w ls # 按耗时排序
 
# 子进程追踪
strace -f make # 同时追踪子进程 (-f = follow forks)
strace -ff -o trace make # 每个子进程输出到单独的 trace.PID 文件
 
# 过滤和格式化
strace -e trace=write -e write=1 ls # 只看 fd=1 (stdout) 的 write
strace -v ls # 详细模式 (显示结构体内容)
strace -s 1024 ls # 显示更多字符串内容 (默认 32 字节)
strace -x ls # 十六进制显示
strace -y ls # 显示 fd 关联的路径
strace -yy ls # 显示更详细的 fd 信息
 
# 注入
strace -e inject=write:error=EIO cat /etc/hostname 
# 注入错误: 让 write 返回 EIO
strace -e inject=write:delay_enter=1000000 cat /etc/hostname
# 注入延迟: 让 write 延迟 1 秒

4.2 分析真实案例

# 案例 1: 理解 shell 管道的系统调用
strace -f bash -c 'echo hello | wc -c' 2>&1 | head -50
# 关键系统调用序列:
# pipe() → 创建管道
# clone() → 创建子进程 (echo)
# clone() → 创建子进程 (wc)
# dup2/close → 重定向 stdin/stdout
# execve() → 执行 echo
# execve() → 执行 wc
# read/write → 数据传输
# wait4() → 等待子进程结束
 
# 案例 2: 理解动态链接器的系统调用
strace /bin/true 2>&1 | grep -E "openat|mmap|mprotect"
# 可以看到:
# openat 加载 ld-linux.so, libc.so...
# mmap 将 library 映射到地址空间
# mprotect 设置代码段为只读/可执行
 
# 案例 3: 追踪性能瓶颈
strace -c -w dd if=/dev/zero of=/dev/null bs=1M count=1000
# 观察哪个系统调用消耗最多时间
// 练习 2: strace 分析实验
// 1. 对比以下两个操作的系统调用开销:
// a. 循环 1000 次 write(1, "x", 1)
// b. 单次 write(1, "xxxx...", 1000)
// strace -c 来统计, 解释为什么 b 比 a 快得多
//
// 2. 追踪一个 C 程序的 full execution lifecycle:
// - execve 开始
// - mmap 加载共享库
// - brk 扩展堆
// - write 输出结果
// - exit_group 退出
//
// 3. 使用 strace 调试 "too many open files" 错误:
// strace -e trace=open,close ./fd_leak_program

第五节:添加自定义系统调用(教学)


警告: 以下操作仅用于教育目的。不要在生产内核上添加自定义系统调用。内核的系统调用表是 ABI 稳定接口,不可随意修改。推荐使用内核模块 + /proc/sys 接口暴露自定义功能。

5.1 添加步骤

// ============================================
// 步骤 1: 实现系统调用函数
// 在 kernel/ 下新建 sys_hello.c
// ============================================
 
#include <linux/kernel.h>
#include <linux/syscalls.h> // SYSCALL_DEFINE 宏
 
/*
 * sys_hello — 一个教学用的自定义系统调用
 * 输入: 无
 * 返回值: 0 成功
 *
 * SYSCALL_DEFINE0 宏(无参数), 实际定义:
 * asmlinkage long sys_hello(void)
 */
SYSCALL_DEFINE0(hello)
{
 printk(KERN_INFO "sys_hello: Called by process %s (pid=%d)\n",
 current->comm, current->pid);
 
 printk(KERN_INFO "Hello from kernel space!\n");
 
 // 返回信息给用户态
 // 注意: 系统调用不能返回负数作为成功值
 // 负数会被解释为 -errno
 return 42; // 返回一个有趣的值
}
 
/*
 * sys_set_my_nice — 带参数的系统调用
 * 将当前进程的 nice 值设置并返回旧的 nice 值
 *
 * SYSCALL_DEFINE1 宏(1个参数)
 */
SYSCALL_DEFINE1(set_my_nice, int, new_nice)
{
 int old_nice;
 
 if (new_nice < -20 || new_nice > 19)
 return -EINVAL;
 
 // task_nice(current) 返回当前 nice 值
 old_nice = task_nice(current);
 
 // set_user_nice 修改进程的 nice 值
 int ret = set_user_nice(current, new_nice);
 if (ret)
 return ret;
 
 printk(KERN_INFO "sys_set_my_nice: pid=%d, old=%d, new=%d\n",
 current->pid, old_nice, new_nice);
 
 return old_nice;
}
 
// ============================================
// 步骤 2: 更新内核 Makefile
// kernel/Makefile 中添加:
// obj-y += sys_hello.o
// ============================================
 
// ============================================
// 步骤 3: 注册系统调用号
// arch/x86/entry/syscalls/syscall_64.tbl 中添加:
//
// 335 common hello sys_hello
// 336 common set_my_nice sys_set_my_nice
//
// 注意: 选择未使用的编号, 并增加 NR_syscalls
// arch/x86/include/generated/asm/syscalls_64.h 中的:
// #define __NR_syscalls 337
// 
// 在 include/linux/syscalls.h 中添加声明:
// asmlinkage long sys_hello(void);
// asmlinkage long sys_set_my_nice(int new_nice);
// ============================================

5.2 测试自定义系统调用

// ============================================
// test_syscall.c —— 用户态测试程序
// ============================================
#include <stdio.h>
#include <unistd.h>
#include <sys/syscall.h>
 
int main(void)
{
 long ret;
 
 // 调用自定义系统调用
 // 使用 syscall() 函数 (glibc 提供的通用系统调用接口)
 
 printf("Calling sys_hello()...\n");
 ret = syscall(335); // 335 = __NR_hello (我们的自定义编号)
 printf("sys_hello returned: %ld\n", ret);
 
 printf("\nCalling sys_set_my_nice(5)...\n");
 ret = syscall(336, 5);
 printf("Old nice value: %ld\n", ret);
 
 printf("\nCalling sys_set_my_nice(30)... (invalid)\n");
 ret = syscall(336, 30);
 printf("Return: %ld (should be -EINVAL = -22)\n", ret);
 
 return 0;
}
 
// 输出:
// Calling sys_hello()...
// sys_hello returned: 42
//
// 在 dmesg 中:
// [xxxxx] sys_hello: Called by process test_syscall (pid=12345)
// [xxxxx] Hello from kernel space!

5.3 为什么不推荐添加系统调用

// 1. ABI 稳定性: 系统调用号一旦分配, 就不能修改或移除
// 这是 Linux 的"永不破坏用户空间"承诺
//
// 2. 更好的替代方案:
// - 内核模块 + ioctl: 灵活, 不需要修改内核源码
// - procfs / sysfs: 简单的读写接口
// - debugfs: 调试和开发用
// - BPF: Linux 6.0+ 可通过 BPF 创建安全的内核函数
// - io_uring: 高性能异步 IO 框架
//
// 3. 系统调用是全局的, 加载在内核空间不能像模块一样卸载
//
// 4. 维护成本: 新 syscall 需要跨所有架构实现 (x86, ARM, RISC-V...)
// 练习 3: syscall 深度实验
// 1. 列出你的系统中所有系统调用的编号和名称:
// 使用 ausyscall --dump (如果可用)
// 或写 C 程序遍历 /usr/include/x86_64-linux-gnu/asm/unistd_64.h
//
// 2. 使用 syscall() 函数直接调用内核的系统调用,
// 不依赖 glibc:
// - 用 syscall(__NR_getpid) 代替 getpid()
// - 用 syscall(__NR_mmap, ...) 自己实现 mini-malloc
//
// 3. 查看内核编译后的 sys_call_table:
// cat /proc/kallsyms | grep sys_call_table
// (可能需要 root 和 CONFIG_KALLSYMS_ALL=y)

第六节:上下文切换开销


6.1 上下文切换的类型

// ============================================
// 三种上下文切换
// ============================================
 
// 1. 进程上下文切换 (最昂贵)
// 进程 A → 内核 → 进程 B
// 开销:
// - 保存/恢复通用寄存器 (~16个 64-bit)
// - 保存/恢复 FPU/SSE/AVX 寄存器 (~512-2048 bytes)
// - 切换页表 (mov to CR3) → TLB 刷新 (非全局页)
// - 切换内核栈
// - 可能的缓存污染 (L1/L2/L3 cache misses)
// 总开销: ~1-5 微秒 (取决于架构和缓存状态)
//
// 触发原因:
// - 时间片耗尽 (调度器抢占)
// - 进程阻塞 (等待 IO/锁)
// - 高优先级进程就绪 (抢占)
// - 进程主动让出 CPU (sched_yield)
//
// 2. 线程上下文切换 (较便宜)
// 相同进程的线程 A → 内核 → 线程 B
// 开销:
// - 保存/恢复通用寄存器
// - 保存/恢复 FPU
// - 不需要切换 CR3 (共享地址空间!)
// - 切换内核栈
// 总开销: ~0.5-2 微秒
//
// 3. 系统调用 (最轻量, 但仍是上下文切换)
// 用户态 → 内核态 → 用户态 (同一个进程)
// 开销:
// - syscall 指令 (~250 周期)
// - 保存/恢复部分寄存器
// - 切换栈
// - iret/sysret (~250 周期)
// 总开销: ~0.05-0.5 微秒 (不包括系统调用内部操作)

6.2 测量上下文切换开销

// ============================================
// 测量系统调用开销 (使用 rdtsc)
// ============================================
#include <stdio.h>
#include <stdint.h>
#include <unistd.h>
 
// 读取时间戳计数器 (TSC)
static __inline__ uint64_t rdtsc(void)
{
 uint32_t lo, hi;
 __asm__ __volatile__("rdtsc" : "=a"(lo), "=d"(hi));
 return ((uint64_t)hi << 32) | lo;
}
 
int main(void)
{
 const int N = 1000000;
 uint64_t start, end, sum = 0;
 int i;
 
 // 预热 (避免第一次调用的冷开销)
 getpid();
 
 start = rdtsc();
 for (i = 0; i < N; i++) {
 // 测量空系统调用的开销
 // getpid() 是最便宜的系统调用之一
 // (不涉及 IO, 只是读取 current->pid)
 getpid();
 }
 end = rdtsc();
 
 uint64_t total_cycles = end - start;
 double cycles_per_call = (double)total_cycles / N;
 
 printf("getpid() x %d:\n", N);
 printf(" Total cycles: %lu\n", total_cycles);
 printf(" Per call: %.1f cycles\n", cycles_per_call);
 
 // 对比: 测量空函数调用的开销
 sum = 0;
 start = rdtsc();
 for (i = 0; i < N; i++) {
 sum += i; // 简单的运算, 不会离开 Ring 3
 }
 end = rdtsc();
 
 uint64_t func_cycles = end - start;
 printf("\nSimple loop x %d:\n", N);
 printf(" Total cycles: %lu\n", func_cycles);
 printf(" Per iter: %.1f cycles\n", (double)func_cycles / N);
 printf(" Syscall overhead: %.1fx\n",
 cycles_per_call / ((double)func_cycles / N));
 
 // 典型结果 (现代 CPU):
 // - 空系统调用: ~100-300 周期
 // - 简单循环: ~0.25-1 周期 (IPC ~2-4)
 // - 系统调用比普通函数至少慢 100 倍
 
 return 0;
}
# 使用 perf 测量系统调用的上下文切换
perf stat -e cycles:u,cycles:k,instructions:u,instructions:k \
 -e context-switches,cpu-migrations \
 ./syscall_bench
 
# 使用 lmbench 套件 (更专业的测量工具)
# git clone https://github.com/intel/lmbench
# cd lmbench && make
# ./bin/x86_64-linux-gnu/lat_syscall null
# ./bin/x86_64-linux-gnu/lat_ctx -s 0 2
// 练习 4: 上下文切换实验
// 1. 编写程序, 使用 pthread 测量线程上下文切换开销:
// - 两个线程通过 pipe 互相 ping-pong
// - 每次 ping-pong 涉及 2 个线程的上下文切换
// - 打时间戳并计算平均开销
//
// 2. 编写程序测量进程上下文切换开销:
// - 两个进程通过 pipe 或 shared memory + futex
// - 对比进程切换和线程切换的开销差异
// 解释为什么进程切换慢 (TLB flush!)
//
// 3. 使用 perf 比较:
// perf stat -e context-switches -e dTLB-load-misses -- ./your_program

章节测试


判断题 1

syscall 指令执行后,CPU 自动从 Ring 3 切换到 Ring 0,并将用户态 RIP 保存在 RCX 寄存器中。 ( )

  • 正确

  • 错误

判断题 2

IDT (中断描述符表) 有 4096 个条目,即 4096 种中断/异常。 ( )

  • 正确

  • 错误

判断题 3

系统调用表 sys_call_table 是一个函数指针数组,索引为系统调用号。 ( )

  • 正确

  • 错误

判断题 4

页错误 (#PF) 是不可恢复的异常,总是导致进程崩溃。 ( )

  • 正确

  • 错误

判断题 5

glibc 中的 write() 函数直接执行 write 系统调用,没有额外的缓冲层。 ( )

  • 正确

  • 错误

判断题 6

不同的 CPU 架构(x86-64、ARM64、RISC-V)使用不同的系统调用号,但 glibc 自动处理差异。 ( )

  • 正确

  • 错误

判断题 7

strace 使用 ptrace 系统调用来追踪目标进程,因此会显著减慢目标进程执行。 ( )

  • 正确

  • 错误

判断题 8

中断门 (Interrupt Gate) 和陷阱门 (Trap Gate) 的唯一区别是 trap 会禁用中断。 ( )

  • 正确

  • 错误

判断题 9

系统调用的参数通过栈传递,在 x86-64 上均是如此。 ( )

  • 正确

  • 错误

判断题 10

内核空间可以通过 syscall() 函数调用任意系统调用,就像用户空间一样。 ( )

  • 正确

  • 错误


选择题 1

x86-64 系统调用使用哪条指令进入内核?

  • A. int $0x80

  • B. sysenter

  • C. syscall

  • D. svc

选择题 2

以下哪个异常是”故障 (Fault)“类型,处理器会在处理后重新执行出错指令?

  • A. bp (断点)

  • B. pf (页错误)

  • C. mc (机器检查)

  • D. db (调试)

选择题 3

syscall 指令执行后,哪个寄存器保存了用户态 RIP?

  • A. RAX

  • B. RCX

  • C. R10

  • D. R11

选择题 4

strace -e trace=openat,read,write cat /etc/hostname 的作用是什么?

  • A. 只追踪 cat 命令中的 openat、read 和 write 系统调用

  • B. 追踪 cat 命令,并只显示以 “openat”、“read”、“write” 开头的函数

  • C. 向 cat 命令注入 openat/read/write 系统调用

  • D. 统计 cat 命令中所有 openat/read/write 调用的数量

选择题 5

页错误 (#PF) 发生时,出错的虚拟地址被保存在哪个寄存器中?

  • A. RAX

  • B. CR2

  • C. CR3

  • D. RIP

选择题 6

以下正确描述了”上下文切换”的是?

  • A. 进程从用户态进入内核态(如系统调用)的过程

  • B. CPU 从一个进程切换到另一个进程,包括保存/恢复寄存器和切换页表

  • C. 函数调用的现场保存和恢复

  • D. IDE 中的文件切换

选择题 7

SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, size_t, count) 宏的作用是什么?

  • A. 定义用户态 write 函数的包装

  • B. 在内核中定义 sys_write 系统调用的实现

  • C. 声明系统调用编号

  • D. 在系统调用表中注册新的调用号

选择题 8

以下哪种追踪器不需要修改内核源码?

  • A. kprobes / ftrace

  • B. 添加 printk 到内核

  • C. 重新编译内核

  • D. 修改 sys_call_table

选择题 9

从 printf(“hello\n”) 到字符出现在终端上,不涉及以下哪个内核子系统?

  • A. VFS (虚拟文件系统)

  • B. TTY 子系统

  • C. UART 驱动

  • D. 网络栈

选择题 10

为什么现代内核倾向于使用 BPF 而不是添加新的系统调用?

  • A. BPF 比系统调用更安全,可以在运行时验证和加载

  • B. 系统调用号有限,且一旦分配不能修改

  • C. BPF 程序可以动态加载/卸载,不需要重新编译内核

  • D. 以上全部


编程练习题

练习题 1:汇编级系统调用实现()

难度: 简单

不使用 glibc,纯汇编编写一个程序,调用 write 和 exit 系统调用输出 “Hello from pure syscall!”。

要求:

  1. 使用 x86-64 AT&T 汇编语法
  2. 不链接任何 C 库(gcc -nostdlib -static
  3. 直接使用 syscall 指令
  4. 实现第二个版本使用 int $0x80(老 32 位方法),比较差异

提示: 程序入口点需要自己定义 _start,使用 syscall 指令。

练习题 2:系统调用延迟测量()

难度: 简单

编写程序测量以下系统调用的开销(循环次数/周期):

  1. getpid() —— 最轻
  2. read(0, NULL, 0) —— 带参数但无实际操作
  3. write(1, buf, 1) —— 有实际输出
  4. open/close 一个 tmpfs 文件
  5. mmap/munmap 一个小区域

使用 rdtsc 测量,绘制柱状图对比。解释为什么某些调用慢得多(VFS 开销、权限检查、锁等)。

练习题 3:构建最小 Linux 引导程序()

难度: 简单

编写一个在 QEMU 中运行的极简程序,不使用操作系统:

  1. 在 x86-64 保护模式下自行设置 IDT
  2. 实现一个基本的键盘中断处理程序(IRQ 1)
  3. 从键盘接收输入并回显到屏幕(使用 VGA 文本缓冲区 0xB8000)
  4. 当按下 ESC 键时退出(通过 QEMU debugcon)

提示: 这是一个引导扇区或 ELF 引导程序。需要设置 GDT、IDT、PIC(可编程中断控制器)。这本质上是一个微型操作系统内核——也是理解 Linux 内核设计的最佳方式。


知识网络