文章

XV6 的中断是怎么做的?

复习一下 XV6 中断的执行流程

XV6 的中断是怎么做的?

今天来好好研究研究 xv6 的中断是怎样的流程。之前在 MIT 6.S081 的实验中做过相关的实验,隔了一段时间之后,忘的差不多了。昨天面试的时候面试官抓着中断这块狠狠问,我记不清 xv6 里面的“中断向量表”是怎么实现的,只是知道这个和系统调用那个表不一样,所以今天来整理复习一下。代码就直接看原仓库

回顾——中断是什么

先把中断相关的概念理清楚,这里仅给出简要的版本,详细内容可以自行搜索。中断是计算机在运行时,当出现紧急情况/特定事件时,暂停当前执行程序,转而去执行处理该事件的程序,处理完成后再返回

常见的中断源有两种:

  • 硬件中断源:外部设备(键盘、鼠标、打印机),定时器,硬件故障(电源故障、内存错误)
  • 软件中断源:程序特定指令或执行异常,如系统调用、除数为 0,非法指令等

XV6 的中断流程

uservec

XV6 是基于 riscv 的操作系统,所以首先需要了解 riscv 是如何处理中断的。riscv 架构有 stvecscause 这两个相关寄存器,在发生中断时,处理器会将中断号存在 scause 中,并跳到 stvec 所在的位置执行相应的处理程序

中断号和异常号

异常通常是由程序执行过程中的错误或特殊指令触发的,scause 寄存器最高位为 0;中断是由外部设备或者定时器等触发的,scause 寄存器最高位为 1

  • 常见异常号
异常号 名称 描述
0 指令地址对齐错误
Instruction address misaligned
当取指地址未按要求对齐时触发
1 指令访问故障
Instruction access fault
取指时发生访问错误,比如访问了无效的内存地址
2 非法指令
Illegal instruction
执行了不合法的指令
3 断点
Breakpoint
执行到断点指令时触发
4 加载地址对齐错误
Load address misaligned
加载数据时地址未按要求对齐
5 加载访问故障
Load access fault
加载数据时发生访问错误
6 存储 / 条件式内存访问地址对齐错误
Store/AMO address misaligned
存储数据或执行原子内存操作时地址未按要求对齐
7 存储 / 条件式内存访问访问故障
Store/AMO access fault
存储数据或执行原子内存操作时发生访问错误
8 环境调用从用户模式
Environment call from U-mode
用户模式下执行 ecall 指令触发
9 环境调用从监管者模式
Environment call from S-mode
监管者模式下执行 ecall 指令触发
11 环境调用从机器模式
Environment call from M-mode
机器模式下执行 ecall 指令触发
12 指令页故障
Instruction page fault
取指时发生页故障
13 加载页故障
Load page fault
加载数据时发生页故障
15 存储 / 条件式内存访问页故障
Store/AMO page fault
存储数据或执行原子内存操作时发生页故障
  • 常见中断号
中断号 描述
0x8000000000000001L 用户模式下的软件中断
0x8000000000000005L 监督者模式下的定时器中断
0x8000000000000009L 监督者模式下的外部中断,通常由外设通过 PLIC 触发

如何跳转到 uservec

riscv 中并没有一个实际的“中断向量表”,而是在中断发生时直接跳转到 stvec 所在的地址执行命令,并将中断原因存在 scause 中,下面是设置 stvec 的代码:

1
2
3
4
  // kernel/trap.c:99-101 usertrapret()
  // send syscalls, interrupts, and exceptions to uservec in trampoline.S
  uint64 trampoline_uservec = TRAMPOLINE + (uservec - trampoline);
  w_stvec(trampoline_uservec);

这里会有一个问题,usertrapret 这个函数是在中断返回时调用的,那操作系统启动时第一次调用它是在什么时候?这个问题暂且不表,之后会解释

所以说一旦发生中断,XV6 会直接跳转到 kernel/trampoline.S 中的 uservec 这段汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
...
# 声明 uservec 为全局符号,方便其他文件引用
.globl uservec
# uservec 标签,是实际处理陷阱的入口点
uservec:
    #
    # trap.c 文件会将 stvec 寄存器设置为指向此处,
    # 所以当用户空间发生陷阱时,会从这里开始执行,
    # 此时处于监管模式,但使用的是用户页表。
    #

    # 将用户空间的 a0 寄存器的值保存到 sscratch 寄存器中,
    # 这样 a0 寄存器就可以用来访问陷阱帧(TRAPFRAME)。
    csrw sscratch, a0

    # 每个进程都有独立的 p->trapframe 内存区域,
    # 但在每个进程的用户页表中,它都被映射到相同的虚拟地址(TRAPFRAME)。
    # 将 TRAPFRAME 的地址加载到 a0 寄存器
    li a0, TRAPFRAME

    # 把用户空间的寄存器保存到陷阱帧(TRAPFRAME)中
    sd ra, 40(a0)
    sd sp, 48(a0)
    sd gp, 56(a0)
    sd tp, 64(a0)
    sd t0, 72(a0)
    sd t1, 80(a0)
    sd t2, 88(a0)
    sd s0, 96(a0)
    sd s1, 104(a0)
    sd a1, 120(a0)
    sd a2, 128(a0)
    sd a3, 136(a0)
    sd a4, 144(a0)
    sd a5, 152(a0)
    sd a6, 160(a0)
    sd a7, 168(a0)
    sd s2, 176(a0)
    sd s3, 184(a0)
    sd s4, 192(a0)
    sd s5, 200(a0)
    sd s6, 208(a0)
    sd s7, 216(a0)
    sd s8, 224(a0)
    sd s9, 232(a0)
    sd s10, 240(a0)
    sd s11, 248(a0)
    sd t3, 256(a0)
    sd t4, 264(a0)
    sd t5, 272(a0)
    sd t6, 280(a0)

    # 把用户空间的 a0 寄存器的值保存到 p->trapframe->a0 位置
    # 从 sscratch 寄存器读取之前保存的用户 a0 的值到 t0 寄存器
    csrr t0, sscratch
    # 将 t0(即用户 a0 的值)保存到陷阱帧偏移 112 处
    sd t0, 112(a0)

    # 从 p->trapframe->kernel_sp 位置加载内核栈指针并初始化 sp 寄存器
    # 从陷阱帧偏移 8 处加载内核栈指针的值到 sp 寄存器
    ld sp, 8(a0)

    # 让 tp 寄存器保存当前硬件线程 ID(hartid),从 p->trapframe->kernel_hartid 获取
    # 从陷阱帧偏移 32 处加载当前硬件线程 ID 的值到 tp 寄存器
    ld tp, 32(a0)

    # 从 p->trapframe->kernel_trap 位置加载 usertrap() 函数的地址到 t0 寄存器
    ld t0, 16(a0)

    # 从 p->trapframe->kernel_satp 位置获取内核页表的地址到 t1 寄存器
    ld t1, 0(a0)

    # 等待之前的内存操作完成,确保它们使用的是用户页表
    sfence.vma zero, zero

    # 安装内核页表,将内核页表地址写入 satp 寄存器
    csrw satp, t1

    # 刷新 TLB 中过时的用户页表项
    sfence.vma zero, zero

    # 跳转到 usertrap() 函数开始执行陷阱处理代码,此函数不会返回
    jr t0

简单来说,这段汇编的作用就是在用户空间发生陷阱时,保存用户上下文,切换到内核页表,然后跳转到 usertrap() 函数进行陷阱处理。这里要注意的是:

  1. sscratch 寄存器将 a0 的值先暂存起来,方便后面加载 TRAPFRAME 的地址,最后 a0 的值也会放回原位
  2. 系统调用号是存在 proc->trapframe->a7 这个位置的
  3. 页表切换时需要使用 sfence.vma zero, zero 刷新 TLB
  4. 这个 satp 存的是页表基地址

usertrap

代码写的比较清晰,直接看代码吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
void
usertrap(void)
{
  int which_dev = 0;

  if((r_sstatus() & SSTATUS_SPP) != 0)
    panic("usertrap: not from user mode");

  // send interrupts and exceptions to kerneltrap(),
  // since we're now in the kernel.
  w_stvec((uint64)kernelvec);

  struct proc *p = myproc();

  // save user program counter.
  p->trapframe->epc = r_sepc();

  if(r_scause() == 8){
    // system call

    if(killed(p))
      exit(-1);

    // sepc points to the ecall instruction,
    // but we want to return to the next instruction.
    p->trapframe->epc += 4;

    // an interrupt will change sepc, scause, and sstatus,
    // so enable only now that we're done with those registers.
    intr_on();

    syscall();
  } else if((which_dev = devintr()) != 0){
    // ok
  } else {
    printf("usertrap(): unexpected scause 0x%lx pid=%d\n", r_scause(), p->pid);
    printf("            sepc=0x%lx stval=0x%lx\n", r_sepc(), r_stval());
    setkilled(p);
  }

  if(killed(p))
    exit(-1);

  // give up the CPU if this is a timer interrupt.
  if(which_dev == 2)
    yield();

  usertrapret();
}

做了这么几件事:

  1. 在内核时也可能会发生中断,所以将寄存器 stvec 的值设为 kernaltrap
  2. 保存用户 pc 的值
  3. 如果 scause 的值是 8,执行系统调用(并且要打开中断)
  4. 如果 scause 是其它值,执行 devintr()(这个函数还是通过 scause 寄存器判断是 PILC、时钟中断还是其它)
  5. 如果是时钟中断,yield 出去
  6. 调用 usertrapret 返回

简单来说,usertrap 这一步就是检查一下 scause 这个值,如果是 8 就走系统调用,如果是其它值,就走别的处理函数。这里其实也能发现,如果想添加对缺页中断的处理,只需要判断下 r_scause() == 13 就行

系统调用的 scause 为 8(用户主动调用中断),此时会进入 syscall() 函数。在这个函数中,有一个系统调用表,执行的函数是 a7 寄存器对应位置的函数,函数的参数保存在 a0 ~ a5 这几个寄存器中。这部分反倒比较简单;当所有中断程序处理完成后,程序会调用 usertrapret 从内核态返回到用户态

usertrapret

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
//
// return to user space
//
void
usertrapret(void)
{
  struct proc *p = myproc();

  // we're about to switch the destination of traps from
  // kerneltrap() to usertrap(), so turn off interrupts until
  // we're back in user space, where usertrap() is correct.
  intr_off();

  // send syscalls, interrupts, and exceptions to uservec in trampoline.S
  uint64 trampoline_uservec = TRAMPOLINE + (uservec - trampoline);
  w_stvec(trampoline_uservec);

  // set up trapframe values that uservec will need when
  // the process next traps into the kernel.
  p->trapframe->kernel_satp = r_satp();         // kernel page table
  p->trapframe->kernel_sp = p->kstack + PGSIZE; // process's kernel stack
  p->trapframe->kernel_trap = (uint64)usertrap;
  p->trapframe->kernel_hartid = r_tp();         // hartid for cpuid()

  // set up the registers that trampoline.S's sret will use
  // to get to user space.
  
  // set S Previous Privilege mode to User.
  unsigned long x = r_sstatus();
  x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode
  x |= SSTATUS_SPIE; // enable interrupts in user mode
  w_sstatus(x);

  // set S Exception Program Counter to the saved user pc.
  w_sepc(p->trapframe->epc);

  // tell trampoline.S the user page table to switch to.
  uint64 satp = MAKE_SATP(p->pagetable);

  // jump to userret in trampoline.S at the top of memory, which 
  // switches to the user page table, restores user registers,
  // and switches to user mode with sret.
  uint64 trampoline_userret = TRAMPOLINE + (userret - trampoline);
  ((void (*)(uint64))trampoline_userret)(satp);
}

这部分主要做了如下工作:

  1. 关闭中断:避免切换过程中被中断干扰,确保操作原子性。
  2. 设置陷阱向量:将陷阱处理入口指向 trampoline.S 中的 uservec,用于后续用户态陷阱处理。
  3. 配置陷阱帧(trapframe)
    • 内核页表:保存内核页表地址(kernel_satp)。
    • 内核栈:指向进程内核栈顶部(kernel_sp)。
    • 陷阱处理函数:指定 usertrap 为下次内核态陷阱入口。
    • hart ID:记录当前 CPU 核心 ID(kernel_hartid)。
  4. 设置特权寄存器
    • sstatus:
      • 清除 SPP 标志(特权模式为用户态)。
      • 启用用户态中断(SPIE 置位)。
    • sepc:恢复用户程序计数器(epc),指定返回用户态后的执行起点。
  5. 切换页表:计算用户页表的 satp 值,传递给 trampoline.S 的 userret 函数。
  6. 跳转至用户态
    • 调用 userret 函数,完成:
      • 页表切换(内核 → 用户)。
      • 恢复用户寄存器。
      • 通过 sret 指令进入用户态并开启中断。

userret 这个函数就是将 TRAPFRAME 上保留的寄存器的值全部保存到 CPU 中,继续执行原先的用户线程

如果在中断时又来一个中断怎么办

如果在执行系统调用时又发生了时钟中断等其他事情,这时候已经在用户态了,该怎么办?XV6 当然考虑了这种情况。前面在 usertrap 函数开始时就将 stvec 设置为了 kernelvec,如果发生中断,就跳到 kernelvec 对应的汇编代码。这一段汇编相对来说更加简单,代码不给了,简单说一下:

  1. sp 减 256,留够寄存器的空间
  2. 将当前的寄存器的值存到栈上
  3. 调用 kerneltrap
  4. 将步骤 2 保存的值 restore
  5. sp 加 256,将预留的空间回退

kerneltrap 函数中只检查了两种事情:硬件中断以及时钟中断

第一次中断怎么设置的

如果认真研究了代码,会发现 stvec 只会在调用 usertrapret 的时候进行设置,那问题来了:第一次调用 usertrapret 是在哪里呢?

经过简单的搜索,发现这个函数是在 forkret 函数调用的,而这个函数是在 allocproc 函数中设置的。所以说,其实每个进程的 stvec 都是在创建进程的第一时间执行的,这样就确保了该进程在执行过程中可以正确处理中断。

那第一个进程是在哪里执行的呢?是 userinit()。让我们看看操作系统的 main 函数吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void
main()
{
  if(cpuid() == 0){
    consoleinit();
    printfinit();
    printf("\n");
    printf("xv6 kernel is booting\n");
    printf("\n");
    kinit();         // physical page allocator
    ...
    userinit();      // first user process
    __sync_synchronize();
    started = 1;
  } else {
    ...
  }

  scheduler();
}

main 函数的 userinit 中创建了第一个用户进程,这其实是一个祖宗进程,其他所有的进程都是从这里 fork 得到的。第一个进程的含金量也挺高的,可以大概看看它干了些什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
int
main(void)
{
  int pid, wpid;

  if(open("console", O_RDWR) < 0){
    mknod("console", CONSOLE, 0);
    open("console", O_RDWR);
  }
  dup(0);  // stdout
  dup(0);  // stderr

  for(;;){
    printf("init: starting sh\n");
    pid = fork();
    if(pid < 0){
      printf("init: fork failed\n");
      exit(1);
    }
    if(pid == 0){
      exec("sh", argv);
      printf("init: exec sh failed\n");
      exit(1);
    }

    for(;;){
      // this call to wait() returns if the shell exits,
      // or if a parentless process exits.
      wpid = wait((int *) 0);
      if(wpid == pid){
        // the shell exited; restart it.
        break;
      } else if(wpid < 0){
        printf("init: wait returned an error\n");
        exit(1);
      } else {
        // it was a parentless process; do nothing.
      }
    }
  }
}

其实就是先创建标准输入输出,然后 fork 一个进程调用 shell,最后死循环

时钟中断和进程调度

话说回来,操作系统的 main 函数中,调用完 usertrap,就会进入 scheduler 函数调度这些进程,这是一个及其简单切简陋的死循环时间轮调度,因为所有的中断都是在 userinit 创建的进程中处理的,scheduler 函数不会收到任何影响,怎么理解呢?看下 scheduler 的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void
scheduler(void)
{
  struct proc *p;
  struct cpu *c = mycpu();

  c->proc = 0;
  for(;;){
    // The most recent process to run may have had interrupts
    // turned off; enable them to avoid a deadlock if all
    // processes are waiting.
    intr_on();

    int found = 0;
    for(p = proc; p < &proc[NPROC]; p++) {
      acquire(&p->lock);
      if(p->state == RUNNABLE) {
        ...
        swtch(&c->context, &p->context);

        ...
      }
      release(&p->lock);
    }
    ...
  }
}

通过 swtch 执行对应的进程。如果发生时钟中断,就会执行 usertrap 中的 yield 函数,yield 函数调用后会直接跳到 schedulerswtch 的下一行继续执行

总结

XV6 通过 uservecusertrapusertrapret 的闭环实现用户态与内核态的中断处理,结合 RISC-V 特权指令(如 sretcsrw)和页表切换,确保中断响应的高效性与安全性。其设计核心在于 寄存器上下文管理 和 页表动态切换,为多任务并发提供了底层支持

对于时钟中断,还得看一下 scheduler 函数中 swtchyield 这两个函数的配合

本文由作者按照 CC BY 4.0 进行授权