操作系统构建Part9:PLIC外部中断响应机制

9.1 前言

        本章将介绍dummyxv6的外部中断响应机制。dummyxv6是运行在RISC-V架构的处理器之上的教学级操作系统,其外部中断机制,主要依赖RISC-V的PLIC(Platform Level Interrupt Controller)来实现。本章首先会介绍PLIC响应外部中断的整体流程;然后在PLIC中断响应机制上,dummyxv6如何处理键盘按键事件,并输出到屏幕上;接着会介绍这些流程的实现代码。

9.2 RISC-V的外部中断响应机制

        RISC-V架构的处理器如何响应外部设备的中断事件呢?前言中也提到过,它有一个叫做PLIC的模块,外部设备与它进行连接。每个外部设备都有一个唯一的编号,这些编号是CPU用来区分不同类型的中断事件用的。比如键盘的按键中断事件的编号为10,当用户按下某个按键时,会将中断信号发送给PLIC,PLIC除了能接收到有中断事件到达外,还会接收到当前中断设备的编码,而后通知一个空闲的CPU核心来处理中断事件,内核逻辑会根据中断编码执行对应的中断处理事件(在本例中是将案件字符输出到屏幕),然后从寄存器中读取输入的字符,并输出到屏幕上。
        PLIC的结构如图1所示[1],它主要由两个部分组成,一部分是连接中断信号源的PLIC Gateways。另一部分是PLIC Core,它负责向target(也就是CPU核心)派发中断事件。简单的来说,图1中的Interrupt Signals就是外部设备发送的各种中断信号,比如Interrupt 1 Signals是来自键盘按键事件,Interrupt 2 Signals来自鼠标点击事件,以此类推。发送这些中断信号的设备,被称为中断源(Interrupt Source)。

image图1

        每个外部设备都有一个唯一的编号,当设备向PLIC发送中断信号的时候,也会将自己的编码发送过去。此外每个设备都有自己的中断优先级,当多种设备同时向PLIC发送中断信号时,会选择中断优先级最高的那个发送给CPU的核心。每个核心可以设置各自的中断响应优先级的阈值(如图中的Threshold),只有中断优先级高于阈值的中断信号,才能发往对应的CPU核心。接下来,通过一个例子来更详细得介绍PLIC的运作流程。
        如图2所示,CPU核心hart 0、hart 1以及外部设备keyboard和mouse都完成初始化。其中keyboard的中断编码是10,mouse的中断编码是9,它们的优先级分别是2和1。此外,hart 0的中断优先级阈值时0,也就是说任何优先级的中断信号都可以发往hart 0。而hart 1的中断优先级阈值时1,也就是说只有优先级大于1的中断信号,才能发往hart 1。
image图2
        此时,用户点击鼠标左键并向PLIC发送中断信号,PLIC接收到中断信号后会向所有的hart广播有中断信号到达,如图3所示。
image图3
        由于mouse的中断优先级是1,但是hart 1的优先级阈值要求大于1,因此hart 1不会接收到该中断请求,只有hart 0能够响应这个中断请求。hart 0收到有中断信号到达后,就向PLIC发送plic claim请求,并获取中断编码并将EIP置为0,后面中断请求都不会往hart 0发送,因为EIP为0。此时得到图4的结果。
image图4
        hart 0在完成中断事件处理之后,就向PLIC Core发送PLIC complete信号,表示中断事件处理完了,此时EIP会被重置为1,hart 0又能够响应新的中断事件了。
image图5
        如果此时,键盘和鼠标同时发起中断,那么有可能的情况是hart 0先响应了键盘事件,因为键盘中断的优先级更高,然后再响应鼠标中断事件。整个PLIC处理中断的时序流程如图6所示。
image图6
        到这里,笔者就完成了PLIC处理中断流程的介绍了。

9.3 dummyxv6的外部中断事件响应流程

        现在笔者结合dummyxv6来介绍它是怎样响应键盘按键事件,并将按键字符输出到屏幕的。如图7所示,中间方框的部分执行的是9.2节介绍过的中断响应流程。当CPU的hart响应中断事件之后,首先会根据当前的状态来决定执行不同的执行流。
        如果当前CPU处于用户态,执行用户进程的指令,那么此时会触发调用uservec函数,执行从用户态切换到内核态的逻辑,在完成上下文切换和页表切换之后,hart会执行usertrap函数里的逻辑。usertrap函数执行的是从用户态到内核态执行系统调用或者中断事件的处理逻辑,此时usertrap函数会根据中断编码(通过plic claim来获取)选择合适的中断处理函数(也就是我们所说的Interrupt Handler),在本例里是调用uartintr函数,这个函数负责读取或写入字符,现在是从RHR寄存器里读取字符,并打印到屏幕上。完成这个操作之后,告知PLIC中断处理完成(通过plic complete来通知)。完成之后通过usertrapret函数,从内核态返回用户态,恢复用户进程上下文,继续执行用户进程的指令。
        如果当前CPU处于内核态,并且正在执行某个system call(注意,处于内核态的CPU是禁止接收中断事件的,但是开始system call之前,会重新打开接收中断事件的使能位),那么会触发调用kernelvec函数,在保存完上下文之后,调用kerneltrap函数,kerneltrap函数会处理中断事件,在本例中会先通过plic claim获得中断编码,因为中断编码是处理键位读写,因此调用uartintr函数,从RHR寄存器读取字符后输出到屏幕,接着调用plic complete通知PLIC中断处理完成了。完成这一系列操作之后,继续返回执行system call。 image图7

9.4 dummyxv6的外部中断实现

        本节笔者将介绍前言所说内容的具体实现,首先是要复习一下dummyxv6的内存模型,如图8所示,左边的内存模型是dummyxv6的虚拟地址空间,右边是物理地址空间。关于物理地址和虚拟地址的概念、它们之间的关系,在前面讲解虚拟内存空间的章节已经详细介绍过,本章不再赘述。
        虽然如此,本章还是要关心PLIC的地址空间,在dummyxv6中,PLIC的基地址是0x0C000000,寻找PLIC相关的所有寄存器地址,都位于0x0C000000~0x10000000之间。 image图8
        第一步,先来看一下PLIC的地址定义:

// kernel/memlayout.h
#define PLIC 0x0C000000L
#define PLIC_PRIORITY (PLIC + 0x0)
#define PLIC_PENDING (PLIC + 0x1000)
#define PLIC_SENABLE(hart) (PLIC + 0x2080 + (hart) * 0x100)
#define PLIC_SPRIORITY(hart) (PLIC + 0x201000 + (hart) * 0x2000)
#define PLIC_SCLAIM(hart) (PLIC + 0x201004 + (hart) * 0x2000)

代码片段1

现在笔者逐个宏进行讲解。

  • PLIC:PLIC是基地址宏,后续所有的寄存器地址定义,都是基于这个基地址的偏移。
  • PLIC_PRIORITY:这个宏从PLIC基地址开始,每4个字节为单位,分属不同的设备,存储不同设备的中断优先级信息,相当于存储图2中的keyboard的priority值存储到(PLIC_PRIORITY + UART_IRQ * 4)中,而mouse的priority值存储到(PLIC_PRIORITY + MOUSE_IRQ * 4)中。其中UART_IRQ的值是10,指代键盘中断编号;MOUSE_IRQ的值是9,指代鼠标中断编号。
  • PLIC_PENDING:这个宏可以获取当前是否有未处理的中断请求信息。
  • PLIC_SENABLE:用户可以通过这个宏,设置各个hart是否能够在Supervisor特权模式下接收中断请求。
  • PLIC_SPRIORITY:用户可以通过这个宏,设置各个hart的中断优先级阈值(Threshold)。
  • PLIC_SCLAIM:用户可以通过这个宏,为指定hart获取PLIC待处理的中断编号,同时通过给这个宏对应的地址复制,实现PLIC complete的处理。

        PLIC地址空间中,物理地址和虚拟地址空间是一模一样的,先来看一下进入调度之前,初始化流程要做哪些事情。

// kernel/main.c

1  static volatile int is_initialized = 0;
2  
3  void main() {
4      if (cpuid() == 0) {
5          consoleinit();
6          printfinit();
7  
8          printf("dummyxv6 booting!\n");
9          printf("start to initialize the kernel.\n");
10         kinit();
11 
12         kvminit();
13         kvmhartinit();
14 
15         procinit();
16         prochartinit();
17 
18         plicinit();
19         plicinithart();
20 
21         test_userinit();
22         
23         // printf("CPU%d initialize success.\n", cpuid());
24 
25         __sync_synchronize();
26 
27         is_initialized = 1;
28     } 
29     else {
30         while (is_initialized == 0);
31 
32         __sync_synchronize();
33 
34         printf("CPU%d starts initializing.\n", cpuid());
35 
36         kvmhartinit();
37         prochartinit();
38         plicinithart();
39 
40         printf("Initialization of CPU%d complete.\n", cpuid());
41     }
42 
43     scheduler();
44 }

代码片段2
哪些地方发生了变化呢?代码片段2中的kvminit函数,增加了PLIC部分,虚拟地址空间到物理地址空间的映射。

// kernel/vm.c
...
1  void kvminit() {
2      kvmmake();
3  }
4  
5  
6  void kvmmake() {
7      kpgtbl = (pagetable_t)kalloc();
8      if (kpgtbl == 0) {
9          panic("kvmmake:fail to kalloc");
10     }
11     memset(kpgtbl, 0, PGSIZE);
12 
13     // uart registers
14     kvmmap(kpgtbl, UART0, UART0, PGSIZE, PTE_R | PTE_W);
15 
16     // PLIC (Platform-Level Interrupt Controller)
17     kvmmap(kpgtbl, PLIC, PLIC, 0x4000000L, PTE_R | PTE_W);
18 
19     // kernel text
20     kvmmap(kpgtbl, KERNEL_BASE, KERNEL_BASE, (uint64_t)etext - KERNEL_BASE, PTE_R | PTE_X);
21 
22     // kernel data
23     kvmmap(kpgtbl, (uint64_t)etext, (uint64_t)etext, PHYSTOP - (uint64_t)etext, PTE_R | PTE_W);
24 
25     // trampoline
26     kvmmap(kpgtbl, TRAMPOLINE, (uint64_t)trampoline, PGSIZE, PTE_R | PTE_X);
27 
28     proc_mapstack(kpgtbl);
29 }
...

代码片段3
上述代码中的第17行,就是执行了这个操作,将从0x0C000000~0x10000000的物理地址,映射到同样地址段的虚拟地址空间中。接下来回到代码片段2,查阅第18行和第19行代码,调用了plicinit和plicinithart函数,它们的逻辑实现了9.2节中,图2的初始化流程。

// kernel/plic.c
1  /**
2   * Initialize the PLIC (Platform-Level Interrupt Controller) by enabling
3   * the UART0 interrupt request (IRQ).
4   */
5  void plicinit(void)
6  {
7      *(uint32_t *)(PLIC + UART0_IRQ * 4) = 1; // Enable UART0 IRQ
8  }
9  
10 /**
11  * plicinithart - Initialize the PLIC (Platform-Level Interrupt Controller) for the current hart (hardware thread).
12  * Enables UART0 interrupt and sets priority threshold to 0 for this hart.
13  */
14 void plicinithart(void)
15 {
16     int hart = cpuid();
17 
18     // Enable UART0 IRQ for this hart
19     *(uint32_t*)PLIC_SENABLE(hart) = (1 << UART0_IRQ);     
20     
21     // Set this hart's priority threshold to 0.
22     *(uint32_t*)PLIC_SPRIORITY(hart) = 0;
23 }
24 

代码片段4
代码片段4中,第5行到第8行,就是设置不同设备中断优先级的,这里只有对键盘中断事件进行设置。第14到第23行,对不同的hart设置中断响应使能,并且设置不同hart的中断优先级阈值。到这里,就完成了PLIC初始化流程的介绍了,接下来要介绍的是中断发生时的流程。

        上一章介绍过调度机制中的定时器中断流程,9.3节也介绍了键盘按键中断事件的调用流程,本节就不对触发中断的通用流程进行赘述。而是直接介绍设备中断处理流程,设备中断处理的函数是devintr函数,其实现逻辑如代码片段5所示:

// kernel/trap.c

1  int devintr(uint64_t scause) { 
2      // check if the trap is from a device interrupt
3      if (scause == 0x8000000000000009L) {
4          int irq = plicclaim();
5  
6          if (irq == UART0_IRQ) {
7              uartintr();
8          } else if (irq != 0) {
9              printf("devintr(): unexpected IRQ %d\n", irq);
10         }
11 
12         pliccomplete(irq);
13         return 1;
14     }
15     // timer interrupt
16     else if (scause == 0x8000000000000005L) {
17         clockintr();
18         return 2;
19     }
20     else {
21         printf("devintr():unexpected scause %llu\n", scause);
22     }
23 
24     return 0;
25 }

代码片段5
处理中断设备的时候,会根据scause寄存器的值,判定中断是定时器中断还是设备中断,在本章的范例中,就是设备中断,因此进入第一个分支。然后通过plicclaim函数获取设备编码–irq的值,再调用uartintr函数读取用户按键输入的字符,并输出到屏幕。完成之后调用pliccomplete函数告知PLIC中断处理完成,后续可以接收新的设备中断信号。plicclaim和pliccomplete的实现如代码片段6所示:

// kernel/plic.c

1  /**
2   * plicclaim - Claims the highest priority pending interrupt for the current hart.
3   *
4   * @return The interrupt number of the claimed IRQ, or 0 if no interrupt is pending.
5   */
6  int plicclaim(void)
7  {
8      int hart = cpuid();
9      int irq = *(uint32_t *)PLIC_SCLAIM(hart); // Claim the highest priority IRQ for this hart
10 
11     return irq; // Return the claimed IRQ number
12 }
13 
14 /**
15  * pliccomplete - Complete handling of a PLIC interrupt request for the current hart.
16  * @irq: The interrupt request number to complete.
17  *
18  * Writes the IRQ number to the PLIC claim register to signal completion.
19  */
20 void pliccomplete(int irq)
21 {
22     int hart = cpuid();
23 
24     // Complete the IRQ handling for this hart
25     *(uint32_t*)PLIC_SCLAIM(hart) = irq;
26 }

代码片段6
        代码非常简单,这里不作过多解释,读者们结合9.2节的内容来理解即可。到这里,完成内核编译后执行,可以得到图9的效果,现在终于可以在屏幕上打印按键字符了,用户可以点击键盘输出字符到屏幕,按下退格键光标可以返回到上一个位置。
image图9

9.5 结束语

        到这里,对设备中断机制的论述就完成了。本章先介绍了PLIC的通用处理流程,然后再论述了dummyxv6的设备中断事件处理流程,最后介绍了关键实现细节,希望对读者有帮助。

Reference

[1] 《riscv-privileged-v1.10》 P70 Figure 7.1: Platform-Level Interrupt Controller (PLIC) conceptual block diagram.