MIT 6.1810 · 第 6 课

系统调用入口/出口

用户程序如何通过 ecall 进入内核?内核又如何安全地返回用户态?本课通过 GDB 单步跟踪 xv6 的 write() 系统调用,深入理解用户/内核切换的每一个细节。

授课:rtm (Robert Morris) 日期:2025-02-06 难度:较高

系统调用进入内核时发生了什么?

当用户程序调用 write(2, "$ ", 2) 时,CPU 当前处于用户态:

需要完成:

  1. 切换到 supervisor mode
  2. 保存 32 个用户寄存器和 PC
  3. 切换到内核页表
  4. 切换到内核栈
  5. 跳转到内核 C 代码

额外目标

RISC-V 关键寄存器与指令

寄存器/指令作用
satp当前页表的物理地址
stvectrap 入口点(ecall 跳转的目标)
sepc保存发生 trap 时的 PC
scausetrap 原因(系统调用/异常/中断)
sscratchscratch 寄存器,supervisor 可自由使用
sstatusSPP(前一模式)、SPIE(前一中断使能)、SIE(中断使能)
ecall切换到 supervisor,保存 PC 到 sepc,跳转到 stvec,禁用中断
sret切换到保存的模式,恢复中断,跳转到 sepc

系统调用流程总览

write()                 write() returns
ecall                   sret
  ↓                       ↓
uservec (trampoline.S)  userret (trampoline.S)
  ↓                       ↑
usertrap() (trap.c)     usertrapret() (trap.c)
  ↓                       ↑
syscall() (syscall.c)   --|
  ↓
sys_write() (sysfile.c) --|

GDB 单步跟踪

1. 设置断点

$ make CPUS=1 qemu-gdb
$ gdb
(gdb) b *0xca8              # write() 中的 ecall 指令
(gdb) c
(gdb) x/3i 0xca6            # 查看 ecall 附近的指令

2. 检查用户态状态

(gdb) p $pc               # 低地址 → 用户空间
(gdb) p $sp               # 用户栈指针
(gdb) p $a0               # fd = 2
(gdb) p/x $a1             # buf 地址
(gdb) x/s $a1             # "$ "
(gdb) p $a2               # n = 2

3. 执行 ecall

(gdb) p/x $stvec          # 内核设置的 trap 入口地址
(gdb) stepi               # 执行 ecall

# 现在 PC 在高地址 → Trampoline 页
(gdb) p $pc               # 0x3ffffff...
(gdb) x/6i $pc            # uservec 的第一条指令
(gdb) p $sp               # 仍然是用户栈
(gdb) p/x $sepc           # 保存的用户 PC
(gdb) p/x $sstatus        # SIE=0, SPIE=1

ecall 做了什么:

  1. 模式:user → supervisor
  2. 保存 PC 到 sepc
  3. 跳转到 stvec(uservec)
  4. 禁用中断(保存 SIE 到 SPIE)

为什么 ecall 这么"精简"?

RISC-V 设计师有意让 ecall 做最少的工作,给 OS 设计师优化的空间——某些 trap 可以不切换页表、不保存所有寄存器、不切换栈。

Trampoline:用户/内核的桥梁

问题:在哪里保存 32 个用户寄存器?

解决方案

  1. Trapframe 页:每个进程有一个额外的页,映射在用户页表的固定地址 0x3fffffe000,用于保存寄存器和内核入口信息。
  2. sscratch 寄存器

uservec 入口(trampoline.S)

# kernel/trampoline.S
uservec:
    csrw sscratch, a0       # 临时保存 a0 到 sscratch
    li a0, TRAPFRAME        # a0 = trapframe 地址

    # 保存 32 个用户寄存器到 trapframe
    sd ra, 40(a0)
    sd sp, 48(a0)
    sd gp, 56(a0)
    ...
    sd t0, 0*8(a0)  # 注意:这里保存的是 sscratch 的旧值(即原 a0)
    ...
    # 恢复 a0(从 sscratch 中)
    csrr a0, sscratch
    sd a0, 11*8(a0)  # 保存到 trapframe->a0

    # 加载内核栈指针
    ld sp, 8(a0)

    # 加载 hart ID
    csrr tp, sscratch

    # 加载 usertrap 地址
    ld t0, 16(a0)

    # 加载内核页表地址并切换
    ld t1, 0(a0)
    csrw satp, t1
    sfence.vma

    # 跳转到 usertrap()
    jr t0

为什么切换 satp 不会崩溃?

Trampoline 页在用户页表和内核页表中映射到相同的虚拟地址。切换页表后,PC 仍指向 Trampoline,可以继续执行。

内核 C 代码:usertrap()

// kernel/trap.c
void usertrap(void) {
    // 检查 trap 原因
    if ((r_sstatus() & SSTATUS_SPP) != 0)
        panic("usertrap: not from user mode");

    // 中断向量 → kernelvec
    w_stvec((uint64)kernelvec);

    struct proc *p = myproc();
    p->trapframe->epc = r_sepc();  // 保存用户 PC

    if (r_scause() == 8) {
        // 系统调用(Environment call from U-mode)
        if (killed(p))
            exit(-1);
        p->trapframe->epc += 4;  // 跳过 ecall 指令
        intr_on();
        syscall();  // → syscall.c
    }
    // ...

syscall() 分发

// kernel/syscall.c
void syscall(void) {
    struct proc *p = myproc();
    int num = p->trapframe->a7;  // 系统调用号

    if (num > 0 && num < NELEM(syscalls) && syscalls[num]) {
        // 调用对应的实现函数
        p->trapframe->a0 = syscalls[num]();
    } else {
        p->trapframe->a0 = -1;  // 错误的系统调用号
    }
}

为什么从 trapframe 读取 a7,而不是直接读 a7?

因为 syscall() 被调用时,a7 可能已经被 C 函数调用约定修改(用作临时寄存器)。trapframe 保存的是用户态的原始值。

返回用户态

usertrapret():准备返回

// kernel/trap.c
void usertrapret(void) {
    struct proc *p = myproc();

    intr_off();  // 关闭中断

    // 为下一次 trap 做准备
    w_stvec((uint64)kernelvec);  // ← 注意:这里应该是 uservec!
    p->trapframe->kernel_satp = r_satp();
    p->trapframe->kernel_sp = p->kstack + PGSIZE;
    p->trapframe->kernel_trap = (uint64)usertrap;
    p->trapframe->kernel_hartid = r_tp();

    // 设置 sret 需要的寄存器
    w_sepc(p->trapframe->epc);  # 用户 PC
    w_sstatus(r_sstatus() & ~SSTATUS_SPP);  # SPP = 0 (user mode)

    // 切换到用户页表
    uint64 satp = MAKE_SATP(p->pagetable);

    // 跳转到 userret(在 trampoline.S 中)
    uint64 fn = TRAMPFOLINE + (userret - trampoline);
    ((void (*)(uint64))fn)(satp);
}

userret:恢复用户态

# kernel/trampoline.S
userret:
    # a0 = 用户页表地址
    csrw satp, a0
    sfence.vma

    # 恢复所有用户寄存器
    ld ra, 40(a0)
    ld sp, 48(a0)
    ...
    ld a0, 11*8(a0)  # 最后恢复 a0

    sret  # → sepc → PC, 模式 → user, 中断恢复

sret 做了什么:

  1. PC = sepc
  2. 模式 = SPP(user)
  3. SIE = SPIE(恢复中断)
  4. 继续执行用户程序

知识检查

Q1:RISC-V 的 ecall 指令做了什么?

Q2:为什么 xv6 需要 Trampoline 页?

Q3:sscratch 寄存器在 uservec 中的作用是什么?

Q4:sret 指令做了什么?

Q5:为什么 syscall() 从 trapframe 读取 a7 而不是直接读 a7 寄存器?


本课要点

ecall 精简设计

ecall 只做最少工作(切模式、存 PC、跳转),给 OS 优化空间。

Trampoline 是关键

Trampoline 页映射在用户/内核页表相同 VA,使用户→内核切换可行。

Trapframe 保存上下文

32 个用户寄存器保存在 trapframe,用于透明恢复。

sret 恢复用户态

sret 恢复 PC、模式、中断——回到用户程序继续执行。

延伸阅读