Interrupts
# 1. 真实操作系统内存的使用情况
在介绍本节课的主题 interrupt 之前,先来看一下内存是如何被真实的 OS 使用的。
下面是一个 Linux 服务器的 top 指令输出,如果你查看 Mem 这一行:
首先是计算机总共内存量(33,048,332),如果再往后看的话,你会发现大部分内存都被使用了(4,214,604 + 26,988,148),但这其中大部分内存并不是被应用程序所使用,而是被 buff/cache 用掉了。这在一个操作系统中还挺常见的,因为我们不想让物理内存就在那闲置着,而是想将物理内存被用起来,所以这里大块的内存被用作 buff/cache,另外还有一小部分是空闲的(1,845,580),但是并不多。
以上是一个非常常见的场景,大部分操作系统运行时几乎没有任何空闲的内存。现有的空闲内存或许足够几个 page 用,但如果某个时间点需要大量内存的话,OS 就必须从应用程序或者 buffer/cache 中撤回一部分已经使用的内存。所以,当内核在分配内存的时候,通常都不是一个低成本的操作,因为并不总是有足够的可用内存,为了分配内存必须先撤回一些内存。
另外,这里将 top 的输出按照 RES 进行了排序,在输出的每一行中,VIRT 代表虚拟内存地址空间的大小,RES 是实际使用的内存数量。从这里可以看出,实际使用的内存数量远小于地址空间的大小。所以我们上节课讨论的基于虚拟内存和 page fault 提供的非常酷的功能在这里都有使用,比如说 demand paging。
有关这台机器的其它信息还有:
- 即使它有103个用户登录进来了,它的负载还是很低
- 它有许多许多的进程
- 这台机器已经运行了249天,我们的 XV6 系统或许运行不了这么久
这里想传达的信息:大部分内存都被使用了,并且 RES 内存远小于 VIRT 内存。
# 2. Interrupt 硬件部分
# 2.1 什么是中断
这节课主要内容是中断。中断对应的场景很简单:硬件想要得到操作系统的关注。
比如说,网卡收到了一个 packet,网卡会生成一个中断;用户通过键盘按下了一个按键,键盘会产生一个中断。操作系统需要做的是,保存当前的工作,处理中断,处理完成之后再恢复之前的工作。这里保存和恢复工作的过程与之前的系统调用过程十分相似,所以 system call、page fault、interrupt 都是使用的相同的 trap 机制。
但中断又有一些地方不太一样,这就是为什么我们要花一节课的时间来讲它。中断与系统调用主要有 3 个小的差别:
- asynchronous。当硬件生成中断时,interrupt handler 与当前运行的进程在 CPU 状态上没有任何关联,但如果是 system call 却是发生在运行进程的 context 下。
- concurrency。对于中断来说,CPU 和生成中断的设备是并行运行。网卡自己独立的处理来自网络的 packet,然后在某个时间点产生中断,但是同时,CPU 也在运行。所以我们在 CPU 和设备之间是真正的并行的,我们必须管理这里的并行。
- program device。我们这节课主要关注外部设备,例如网卡,UART,而这些设备需要被编程。每个设备都有一个编程手册,就像 RISC-V 有一个包含了指令和寄存器的手册一样。设备的编程手册包含了它有什么样的寄存器,它能执行什么样的操作,在读写控制寄存器的时候,设备会如何响应。不过通常来说,设备的手册不如 RISC-V 的手册清晰,这会使得对于设备的编程会更加复杂。
这节课的内容主要在于讨论:
- console 中的提示符
$
是如何显示出来的 - 如果你在键盘输入
ls
,这些字符是怎么最终在 console 中显示出来的
# 2.2 中断的产生
我们首先要关心的是,中断是从哪里产生的?
因为我们主要关心的是外部设备的中断,而不是定时器中断或者软件中断。外设中断来自于主板上的设备,下图是一个 SiFive 主板,如果你查看这个主板,你可以发现有大量的设备连接在或者可以连接到这个主板上。
主板可以连接以太网卡,MicroUSB,MicroSD 等,主板上的各种线路将外设和CPU连接在一起。这节课的大部分内容都会介绍当设备产生中断时CPU会发生什么,以及如何从设备读写数据。
下图是来自于SiFive有关处理器的文档,图中的右侧是各种各样的设备,例如 UART0。我们在之前的课程已经知道 UART0 会映射到内核内存地址的某处,而所有的物理内存都映射在地址空间的 0x80000000 之上。类似于读写内存,通过向相应的设备地址执行 load/store 指令,我们就可以对例如 UART 的设备进行编程。
所有的设备都连接到处理器上,处理器上是通过 Platform Level Interrupt Control,简称 PLIC 来处理设备中断。PLIC 会管理来自于外设的中断。如果我们再进一步深入的查看 PLIC 的结构图,
从左上角可以看出,我们有 53 个不同的来自于设备的中断。这些中断到达 PLIC 之后,PLIC 会路由这些中断。图的右下角是 CPU 的核,PLIC 会将中断路由到某一个 CPU 的核。如果所有的 CPU 核都正在处理中断,PLIC 会保留中断直到有一个 CPU 核可以用来处理中断。所以 PLIC 需要保存一些内部数据来跟踪中断的状态。
按照文档,这里的大概流程是:
- PLIC 会通知当前有一个待处理的中断
- 其中一个 CPU 核会 claim 接收中断,这样 PLIC 就不会把中断发给其他的 CPU 处理
- CPU 核处理完中断之后,CPU 会通知 PLIC
- PLIC 将不再保存中断的信息
Question:PLIC 有没有什么机制能确保中断一定被处理?
Prof:这里取决于内核以什么样的方式来对 PLIC 进行编程。PLIC 只是分发中断,而内核需要对 PLIC 进行编程来告诉它中断应该分发到哪。实际上,内核可以对中断优先级进行编程,这里非常的灵活。
Question:当 UART 触发中断的时候,所有的 CPU 核都能收到中断吗?
Prof:取决于你如何对 PLIC 进行编程。对于 XV6 来说,所有的 CPU 都能收到中断,但是只有一个 CPU 会 claim 相应的中断。
以上是有关中断的硬件部分,下面来看中断的软件部分。
# 3. 设备驱动概述
# 3.1 什么是驱动
通常来说,管理设备的代码称为驱动,所有的驱动都在内核中。
我们今天要看的是 UART 设备的驱动,代码在 uart.c 文件中。如果我们查看代码的结构,我们可以发现大部分驱动都分为两个部分,bottom/top:
- bottom 部分通常是 Interrupt handler。当一个中断送到了CPU,并且CPU设置接收这个中断,CPU会调用相应的Interrupt handler。Interrupt handler并不运行在任何特定进程的context中,它只是处理中断。
- top 部分是用户进程,或者内核的其他部分调用的接口。对于 UART 来说,这里有 read/write 接口,这些接口可以被更高层级的代码调用。
通常情况下,驱动中会有一些队列(或者说 buffer),top 部分的代码会从队列中读写数据,而 Interrupt handler(bottom 部分)同时也会向队列中读写数据。这里的队列可以将并行运行的设备和 CPU 解耦开来。
通常对于 Interrupt handler 来说存在一些限制,因为它并没有运行在任何进程的 context 中,所以进程的 page table 并不知道该从哪个地址读写数据,也就无法直接从 Interrupt handler 读写数据。驱动的 top 部分通常与用户的进程交互,并进行数据的读写。我们后面会看更多的细节,这里是一个驱动的典型架构。
在很多操作系统中,驱动代码加起来可能会比内核还要大,主要是因为,对于每个设备,你都需要一个驱动,而设备又很多。
# 3.2 如何对设备进行编程
接下来我们看一下如何对设备进行编程。通常来说,编程是通过 memory mapped I/O 完成的。在 SiFive 的手册中,设备地址出现在物理地址的特定区间内,这个区间由主板制造商决定。操作系统需要知道这些设备位于物理地址空间的具体位置,然后再通过普通的 load/store 指令对这些地址进行编程。load/store 指令实际上的工作就是读写设备的控制记录器。例如,对网卡执行 store 指令时,CPU 会修改网卡的某个控制寄存器,进而导致网卡发送一个 packet。所以这里的 load/store 指令不会读写内存,而是会操作设备。并且你需要阅读设备的文档来弄清楚设备的寄存器和相应的行为,有的时候文档很清晰,有的时候文档不是那么清晰。
下图中是 SiFive 主板中的对应设备的物理地址:
例如,0x200_0000 对应 CLINT,0xC000000 对应的是 PLIC。在这个图中 UART0 对应的是 0x1001*_*0000,但是在 QEMU 中,我们的 UART0 的地址略有不同,因为在 QEMU 中我们并不是完全的模拟 SiFive 主板,而是模拟与 SiFive 主板非常类似的东西。
以上就是 Memory-mapped IO。
下图是 UART 的文档。16550 是QEMU模拟的 UART 设备,QEMU 用这个模拟的设备来与键盘和 Console 进行交互:
这是一个很简单的芯片,图中表明了芯片拥有的寄存器。例如对于控制寄存器000,如果写它会将数据写入到寄存器中并被传输到其他地方,如果读它可以读出存储在寄存器中的内容。UART可以让你能够通过串口发送数据bit,在线路的另一侧会有另一个UART芯片,能够将数据bit组合成一个个Byte。
这里还有一些其他可以控制的地方,例如控制寄存器001,可以通过它来控制UART是否产生中断。实际上对于一个寄存器,其中的每个bit都有不同的作用。例如对于寄存器001,也就是IER寄存器,bit0-bit3分别控制了不同的中断。这个文档还有很多内容,但是对于我们这节课来说,上图就足够了。不过即使是这么简单的一个设备,它的文档也有很多页。
Question:如果你写入数据到Transmit Holding Register,然后再次写入,那么前一个数据不会被覆盖掉吗?
Prof:这是我们需要注意的一件事情。我们通过load将数据写入到这个寄存器中,之后UART芯片会通过串口线将这个Byte送出。当完成了发送,UART会生成一个中断给内核,这个时候才能再次写入下一个数据。所以内核和设备之间需要遵守一些协议才能确保一切工作正常。上图中的UART芯片会有一个容量是16的FIFO,但是你还是要小心,因为如果阻塞了16个Byte之后再次写入还是会造成数据覆盖。
# 4. 在 XV6 中设置中断
xv6 启动时,shell 会打印提示符 $
,我们键盘也可也可以输入 ls
,最终可以看到 $ ls
。接下来我们研究一下 console 是如何显示出 $ ls
的,并来看一下设备终端是如何工作的。
# 4.1 $
的显示
对于 $
来说,实际上就是设备会将字符传输给 UART 的寄存器,UART 之后会在发送完字符之后产生一个中断。在 QEMU 中,模拟的线路的另一端会有另一个 UART 芯片(模拟的),这个 UART 芯片连接到了虚拟的 Console,它会进一步将“$ ”显示在 console 上。
# 4.2 ls
的显示
对于 ls
,这是用户输入的字符。键盘连接到了 UART 的输入线路,当你在键盘上按下一个按键,UART 芯片会将按键字符通过串口线发送到另一端的 UART 芯片。另一端的 UART 芯片先将数据 bit 合并成一个 Byte,之后再产生一个中断,并告诉处理器说这里有一个来自于键盘的字符。之后 Interrupt handler 会处理来自 UART 的字符。
我们接下来会深入通过这两部分来弄清楚这里是如何工作的。
# 4.3 与中断相关的寄存器
RISC-V 有许多与中断相关的寄存器:
- SIE(Supervisor Interrupt Enable)寄存器。这个寄存器中有一个bit(E)专门针对例如UART的外部设备的中断;有一个bit(S)专门针对软件中断,软件中断可能由一个CPU核触发给另一个CPU核;还有一个bit(T)专门针对定时器中断。我们这节课只关注外部设备的中断。
- SSTATUS(Supervisor Status)寄存器。这个寄存器中有一个 bit 来打开或者关闭中断。每一个 CPU 核都有独立的 SIE 和 SSTATUS 寄存器,除了通过 SIE 寄存器来单独控制特定的中断,还可以通过 SSTATUS 寄存器中的一个 bit 来控制所有的中断。
- SIP(Supervisor Interrupt Pending)寄存器。当发生中断时,处理器可以通过查看这个寄存器知道当前是什么类型的中断。
- SCAUSE 寄存器,用来指示进入 trap 的原因,表明当前状态的原因是中断。
- STVEC 寄存器,保存 trap、page fault 或 interrupt 发生时 CPU 运行的用户程序的程序计数器,用来在之后恢复程序的运行。
今天不会再讨论 SCAUSE 和 STVEC 寄存器,因为在中断处理流程中,它们基本上与之前的工作方式是一样的。接下来我们看看 XV6 是如何对其他寄存器进行编程,使得 CPU 处于一个能接受中断的状态。
# 4.4 编程
接下来看看代码,首先是位于 start.c 的 start 函数:
// entry.S jumps here in machine mode on stack0.
void
start()
{
// set M Previous Privilege mode to Supervisor, for mret.
unsigned long x = r_mstatus();
x &= ~MSTATUS_MPP_MASK;
x |= MSTATUS_MPP_S;
w_mstatus(x);
// set M Exception Program Counter to main, for mret.
// requires gcc -mcmodel=medany
w_mepc((uint64)main);
// disable paging for now.
w_satp(0);
// delegate all interrupts and exceptions to supervisor mode.
w_medeleg(0xffff);
w_mideleg(0xffff);
w_sie(r_sie() | SIE_SEIE | SIE_STIE | SIE_SSIE);
// ask for clock interrupts.
timerinit();
// keep each CPU's hartid in its tp register, for cpuid().
int id = r_mhartid();
w_tp(id);
// switch to supervisor mode and jump to main().
asm volatile("mret");
}
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
这里将所有的中断都设置在 supervisor mode,然后设置 SIE 寄存器来接收外部设备(external)、软件和定时器中断,之后初始化定时器。
接下来我们看一下 main 函数中是如何处理 External 中断:
// start() jumps here in supervisor mode on all CPUs.
void
main()
{
if(cpuid() == 0){
consoleinit();
printfinit();
printf("\n");
printf("xv6 kernel is booting\n");
printf("\n");
kinit(); // physical page allocator
kvminit(); // create kernel page table
kvminithart(); // turn on paging
procinit(); // process table
trapinit(); // trap vectors
trapinithart(); // install kernel trap vector
plicinit(); // set up interrupt controller
plicinithart(); // ask PLIC for device interrupts
binit(); // buffer cache
iinit(); // inode cache
fileinit(); // file table
virtio_disk_init(); // emulated hard disk
userinit(); // first user process
__sync_synchronize();
started = 1;
} else {
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
我们第一个外设是 console,这是我们 print 的输出位置。查看位于 console.c 的 consoleinit 函数:
void
consoleinit(void)
{
initlock(&cons.lock, "cons");
uartinit();
// connect read and write system calls
// to consoleread and consolewrite.
devsw[CONSOLE].read = consoleread;
devsw[CONSOLE].write = consolewrite;
}
2
3
4
5
6
7
8
9
10
11
12
这里首先初始化了锁,我们现在还不关心这个锁。然后调用了 uartinit,uartinit 函数位于 uart.c 文件。这个函数实际上就是配置好UART芯片使其可以被使用:
void
uartinit(void)
{
// disable interrupts.
WriteReg(IER, 0x00);
// special mode to set baud rate.
WriteReg(LCR, LCR_BAUD_LATCH);
// LSB for baud rate of 38.4K.
WriteReg(0, 0x03);
// MSB for baud rate of 38.4K.
WriteReg(1, 0x00);
// leave set-baud mode,
// and set word length to 8 bits, no parity.
WriteReg(LCR, LCR_EIGHT_BITS);
// reset and enable FIFOs.
WriteReg(FCR, FCR_FIFO_ENABLE | FCR_FIFO_CLEAR);
// enable transmit and receive interrupts.
WriteReg(IER, IER_TX_ENABLE | IER_RX_ENABLE);
initlock(&uart_tx_lock, "uart");
}
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
这里的流程是先关闭中断,之后设置波特率,设置字符长度为8bit,重置FIFO,最后再重新打开中断。
波特率是串口线的传输速率。
以上就是uartinit函数,运行完这个函数之后,原则上UART就可以生成中断了。但是因为我们还没有对PLIC编程,所以中断不能被CPU感知。最终,在main函数中,需要调用plicinit函数。下面是plicinit函数:
void
plicinit(void)
{
// set desired IRQ priorities non-zero (otherwise disabled).
*(uint32*)(PLIC + UART0_IRQ*4) = 1;
*(uint32*)(PLIC + VIRTIO0_IRQ*4) = 1;
}
2
3
4
5
6
7
PLIC与外设一样,也占用了一个I/O地址(0xC000_0000)。代码的第一行使能了UART的中断,这里实际上就是设置PLIC会接收哪些中断,进而将中断路由到CPU。类似的,代码的第二行设置PLIC接收来自IO磁盘的中断,我们这节课不会介绍这部分内容。
main函数中,plicinit之后就是plicinithart函数。plicinit是由0号CPU运行,之后,每个CPU的核都需要调用plicinithart函数表明对于哪些外设中断感兴趣:
void
plicinithart(void)
{
int hart = cpuid();
// set uart's enable bit for this hart's S-mode.
*(uint32*)PLIC_SENABLE(hart)= (1 << UART0_IRQ) | (1 << VIRTIO0_IRQ);
// set this hart's S-mode priority threshold to 0.
*(uint32*)PLIC_SPRIORITY(hart) = 0;
}
2
3
4
5
6
7
8
9
10
11
所以在plicinithart函数中,每个CPU的核都表明自己对来自于UART和VIRTIO的中断感兴趣。因为我们忽略中断的优先级,所以我们将优先级设置为0。
到目前为止,我们有了生成中断的外部设备,我们有了PLIC可以传递中断到单个的CPU。但是CPU自己还没有设置好接收中断,因为我们还没有设置好SSTATUS寄存器。在main函数的最后,程序调用了scheduler函数,scheduler函数主要是运行进程。但是在实际运行进程之前,会执行intr_on函数来使得CPU能接收中断:
// enable device interrupts
static inline void
intr_on()
{
w_sstatus(r_sstatus() | SSTATUS_SIE);
}
2
3
4
5
6
intr_on 函数只完成一件事情,就是设置 SSTATUS 寄存器,打开中断标志位。
在这个时间点,中断被完全打开了。如果 PLIC 正好有 pending 的中断,那么这个 CPU 核会收到中断。
任何一个调用了 intr_on 的 CPU 核,都会接收中断。实际上所有的 CPU 核都会运行 intr_on 函数。
以上就是中断的基本设置。
# 5. UART 驱动的 top 部分
接下来我想看一下如何从 Shell 程序输出提示符“$ ”到 Console。首先我们看 init.c 中的 main 函数,这是系统启动后运行的第一个进程。
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);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
首先这个进程的main函数创建了一个代表Console的设备。这里通过mknod操作创建了console设备。因为这是第一个打开的文件,所以这里的文件描述符0。之后通过dup创建stdout和stderr。这里实际上通过复制文件描述符0,得到了另外两个文件描述符1,2。最终文件描述符0,1,2都用来代表Console。
mknod 函数生成的文件描述符属于设备。
Shell 程序首先打开文件描述符 0,1,2。之后 Shell 向文件描述符 2 打印提示符“$ ”:
int
getcmd(char *buf, int nbuf)
{
fprintf(2, "$ ");
memset(buf, 0, nbuf);
gets(buf, nbuf);
if(buf[0] == 0) // EOF
return -1;
return 0;
}
2
3
4
5
6
7
8
9
10
尽管 console 背后是 UART 设备,但从应用程序来看,它就像一个普通的文件。Shell 程序只是向文件描述符 2 写了数据,它并不知道文件描述符 2 对应的是什么。在 Unix 系统中,设备是由文件表示。这里的 fprintf 本质上是做了一个 write 的系统调用:
static void
putc(int fd, char c)
{
write(fd, &c, 1);
}
2
3
4
5
所以由 Shell 输出的每一个字符都会触发一个 write 系统调用。之前我们已经看过了 write 系统调用最终会走到 sysfile.c 文件的 sys_write 函数:
uint64
sys_write(void)
{
struct file *f;
int n;
uint64 p;
if(argfd(0, 0, &f) < 0 || argint(2, &n) < 0 || argaddr(1, &p) < 0)
return -1;
return filewrite(f, p, n);
}
2
3
4
5
6
7
8
9
10
11
12
这个函数先对参数做了校验,然后调用了 filewrite 函数(file.c):
// Write to file f.
// addr is a user virtual address.
int
filewrite(struct file *f, uint64 addr, int n)
{
int r, ret = 0;
if(f->writable == 0)
return -1;
if(f->type == FD_PIPE){
ret = pipewrite(f->pipe, addr, n);
} else if(f->type == FD_DEVICE){
if(f->major < 0 || f->major >= NDEV || !devsw[f->major].write)
return -1;
ret = devsw[f->major].write(1, addr, n);
} else if(f->type == FD_INODE){
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
在 filewrite 函数中首先会判断文件描述符的类型。mknod 生成的文件描述符属于设备(FD_DEVICE),而对于设备类型的文件描述符,我们会为这个特定的设备执行设备相应的 write 函数。因为我们现在的设备是 Console,所以我们知道这里会调用 console.c 中的 consolewrite 函数:
//
// user write()s to the console go here.
//
int
consolewrite(int user_src, uint64 src, int n)
{
int i;
acquire(&cons.lock);
for(i = 0; i < n; i++){
char c;
if(either_copyin(&c, user_src, src+i, 1) == -1)
break;
uartputc(c);
}
release(&cons.lock);
return i;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
这里先通过 either_copyin 将字符拷入,之后调用 uartputc 函数。uartputc 函数将字符写入给 UART 设备,所以你可以认为 consolewrite 是一个 UART 驱动的 top 部分。uart.c 文件中的 uartputc 函数会实际的打印字符。
userputc 函数实现了将字符写入 UART 设备,内部有一个 buffer 用来发送数据,buffer 的大小是 32 个字符,同时还有一个为 consumer 提供的读指针和为 producer 提供的写指针,从而构建一个环形的 buffer:
userputc 代码:
// add a character to the output buffer and tell the
// UART to start sending if it isn't already.
// blocks if the output buffer is full.
// because it may block, it can't be called
// from interrupts; it's only suitable for use
// by write().
void
uartputc(int c)
{
acquire(&uart_tx_lock);
if(panicked){
for(;;)
;
}
while(1){
if(((uart_tx_w + 1) % UART_TX_BUF_SIZE) == uart_tx_r){
// buffer is full.
// wait for uartstart() to open up space in the buffer.
sleep(&uart_tx_r, &uart_tx_lock);
} else {
uart_tx_buf[uart_tx_w] = c;
uart_tx_w = (uart_tx_w + 1) % UART_TX_BUF_SIZE;
uartstart();
release(&uart_tx_lock);
return;
}
}
}
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
在我们的例子中,Shell是producer,所以需要调用uartputc函数。在函数中第一件事情是判断环形buffer是否已经满了。如果读写指针相同,那么buffer是空的,如果写指针加1等于读指针,那么buffer满了。当buffer是满的时候,向其写入数据是没有意义的,所以这里会sleep一段时间,将CPU出让给其他进程。当然,对于我们来说,buffer必然不是满的,因为提示符“$”是我们送出的第一个字符。所以代码会走到else,字符会被送到buffer中,更新写指针,之后再调用 uartstart 函数。
uartstart 函数:
// if the UART is idle, and a character is waiting
// in the transmit buffer, send it.
// caller must hold uart_tx_lock.
// called from both the top- and bottom-half.
void
uartstart()
{
while(1){
if(uart_tx_w == uart_tx_r){
// transmit buffer is empty.
return;
}
if((ReadReg(LSR) & LSR_TX_IDLE) == 0){
// the UART transmit holding register is full,
// so we cannot give it another byte.
// it will interrupt when it's ready for a new byte.
return;
}
int c = uart_tx_buf[uart_tx_r];
uart_tx_r = (uart_tx_r + 1) % UART_TX_BUF_SIZE;
// maybe uartputc() is waiting for space in the buffer.
wakeup(&uart_tx_r);
WriteReg(THR, c);
}
}
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
uartstart 就是通知设备执行操作。首先是检查当前设备是否空闲,如果空闲的话,我们会从 buffer 中读出数据,然后将数据写入到 THR(Transmission Holding Register)发送寄存器。这里相当于告诉设备,我这里有一个字节需要你来发送。一旦数据送到了设备,系统调用会返回,用户应用程序 Shell 就可以继续执行。这里从内核返回到用户空间的机制与 lec06 的 trap 机制是一样的。
与此同时,UART 设备会将数据送出。在某个时间点,我们会收到中断,因为我们之前设置了要处理 UART 设备中断。接下来我们看一下,当发生中断时,实际会发生什么。
# 6. UART 驱动的 bottom 部分
在我们向 Console 输出字符时,如果发生了中断,RISC-V 会做什么操作?
我们之前已经在 SSTATUS 寄存器中打开了中断,所以处理器会被中断。假设键盘生成了一个中断并且发向了 PLIC,PLIC 会将中断路由给一个特定的 CPU 核,并且如果这个 CPU 核设置了 SIE 寄存器的E bit(注,针对外部中断的 bit 位),那么会发生以下事情:
- 首先,会清除 SIE 寄存器相应的 bit,这样可以阻止 CPU 核被其他中断打扰,该 CPU 核可以专心处理当前中断。处理完成之后,可以再次恢复 SIE 寄存器相应的 bit。
- 之后,会设置 SEPC 寄存器为当前的 PC 值。我们假设 Shell 正在用户空间运行,突然来了一个中断,那么当前 Shell 的程序计数器会被保存。
- 之后,要保存当前的 mode。在我们的例子里面,因为当前运行的是 Shell 程序,所以会记录 user mode。
- 再将 mode 置为 supervisor mode
- 最后将 PC 设置成 STVEC 的值。(注,STVEC用来保存trap处理程序的地址,详见lec06)在XV6中,STVEC保存的要么是uservec或者kernelvec函数的地址,具体取决于发生中断时程序运行是在用户空间还是内核空间。在我们的例子中,Shell运行在用户空间,所以STVEC保存的是uservec函数的地址。而从之前的课程我们可以知道uservec函数会调用usertrap函数。所以最终,我们在usertrap函数中。我们这节课不会介绍trap过程中的拷贝,恢复过程,因为在之前的课程中已经详细的介绍过了。
处理中断时,会来到 trap.c 的 usertrap 函数,我们已经多次见到过它了,现在看看它是如何处理中断的:
} else if((which_dev = devintr()) != 0){
// ok
} else {
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
p->killed = 1;
}
2
3
4
5
6
7
从上面可以看到,usertrap 函数处理中断时使用了 devintr() 函数:
// check if it's an external interrupt or software interrupt,
// and handle it.
// returns 2 if timer interrupt,
// 1 if other device,
// 0 if not recognized.
int
devintr()
{
uint64 scause = r_scause();
if((scause & 0x8000000000000000L) &&
(scause & 0xff) == 9){
// this is a supervisor external interrupt, via PLIC.
// irq indicates which device interrupted.
int irq = plic_claim();
if(irq == UART0_IRQ){
uartintr();
} else if(irq == VIRTIO0_IRQ){
virtio_disk_intr();
} else if(irq){
printf("unexpected interrupt irq=%d\n", irq);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
在 trap.c 的 devintr 函数中,首先会通过 SCAUSE 寄存器判断当前中断是否是来自于外设的中断。如果是的话,再调用 plic_claim 函数来获取中断。
plic_claim 函数位于 plic.c 文件中。在这个函数中,当前 CPU 核会告知 PLIC,自己要处理中断,``PLIC_SCLAIM()` 会将中断号返回,对于 UART 来说,返回的中断号是 10:
// ask the PLIC what interrupt we should serve.
int
plic_claim(void)
{
int hart = cpuid();
int irq = *(uint32*)PLIC_SCLAIM(hart);
return irq;
}
2
3
4
5
6
7
8
从 devintr 函数可以看出,如果是 UART 中断,那么会调用 uartintr 函数。位于 uart.c 文件的 uartintr 函数,会从 UART 的接受寄存器中读取数据,之后将获取到的数据传递给 consoleintr 函数。哦,不好意思,我搞错了。我们现在讨论的是向 UART 发送数据。因为我们现在还没有通过键盘输入任何数据,所以 UART 的接受寄存器现在为空。
所以代码会直接运行到uartstart函数,这个函数会将Shell存储在buffer中的任意字符送出。实际上在提示符“
这样,驱动的 top 部分和 bottom 部分就解耦开了。
学生提问:
Q: UART对于键盘来说很重要,来自于键盘的字符通过UART走到CPU再到我们写的代码。但是我不太理解UART对于Shell输出字符究竟有什么作用?因为在这个场景中,并没有键盘的参与。
Prof:显示设备与UART也是相连的。所以UART连接了两个设备,一个是键盘,另一个是显示设备,也就是Console。QEMU也是通过模拟的UART与Console进行交互,而Console的作用就是将字符在显示器上画出来。
Q:uartinit只被调用了一次,所以才导致了所有的CPU核都共用一个buffer吗?
Prof:因为只有一个UART设备,一个buffer只针对一个UART设备,而这个buffer会被所有的CPU核共享,这样运行在多个CPU核上的多个程序可以同时向Console打印输出,而驱动中是通过锁来确保多个CPU核上的程序串行的向Console打印输出。
Q:我们之所以需要锁是因为有多个CPU核,但是却只有一个Console,对吧?
Prof:是的,如我们之前说的驱动的top和bottom部分可以并行的运行。所以一个CPU核可以执行uartputc函数,而另个一CPU核可以执行uartintr函数,我们需要确保它们是串行执行的,而锁确保了这一点。
# 7. Interrupt 相关的并发
接下来我们讨论一下与中断相关的并发,并发加大了中断编程的难度。这里的并发包括以下几个方面:
- 设备与 CPU 是并行运行的。例如当 UART 向 Console 发送字符的时候,CPU 会返回执行 Shell,而 Shell 可能会再执行一次系统调用,向 buffer 中写入另一个字符,这些都是在并行的执行。这里的并行称为 producer-consumer 并行。
- 中断会停止当前运行的程序。在执行一个指令时,可能会突然来一个中断,这时就需要去处理这个中断,之后再恢复现场并继续运行。中断与 system call 和 page fault 不同的一点是,代码即使运行在 kernel mode 时也会被中断。这意味着即使是内核代码,也不是直接串行运行的。在两个内核指令之间,取决于中断是否打开,可能会被中断打断执行。对于一些代码来说,如果不能在执行期间被中断,这时内核需要临时关闭中断,来确保这段代码的原子性。
- 驱动的 top 和 bottom 部分是并行运行的。例如,Shell会在传输完提示符“$”之后再调用write系统调用传输空格字符,代码会走到UART驱动的top部分(注,uartputc函数),将空格写入到buffer中。但是同时在另一个CPU核,可能会收到来自于UART的中断,进而执行UART驱动的bottom部分,查看相同的buffer。所以一个驱动的top和bottom部分可以并行的在不同的CPU上运行。这里我们通过lock来管理并行。因为这里有共享的数据,我们想要 buffer 在一个时间只被一个 CPU 核所操作。
这里我们主要关注第一点,也就是 producer/consumer 的并发。这是驱动中非常常见的典型现象,如你们所见,在驱动中会有一个 buffer,在我们之前的例子中,buffer 是 32 字节大小。并且有两个指针,分别是读指针和写指针:
如果两个指针相等,那么 buffer 是空的。当 Shell 调用 uartputc 函数时,会将字符,例如提示符“$”,写入到写指针的位置,并将写指针加 1。这就是 producer 对于 buffer 的操作:
producer 可以一直写入数据,直到写指针 +1 等于读指针,因为这时,buffer 已经满了。当 buffer 满了的时候,producer 必须停止运行。我们之前在 uartputc 函数中看过,如果 buffer 满了,代码会 sleep,暂时搁置 Shell 并运行其他的进程。
Interrupt handler,也就是 uartintr 函数,在这个场景下是 consumer,每当有一个中断,并且读指针落后于写指针,uartintr 函数就会从读指针中读取一个字符再通过 UART 设备发送,并且将读指针加 1。当读指针追上写指针,也就是两个指针相等的时候,buffer 为空,这时就不用做任何操作。
学生提问:这里的buffer对于所有的CPU核都是共享的吗?
Frans教授:这里的buffer存在于内存中,并且只有一份,所以,所有的CPU核都并行的与这一份数据交互。所以我们才需要lock。
学生提问:对于uartputc中的sleep,它怎么知道应该让Shell去sleep?
Frans教授: sleep会将当前在运行的进程存放于sleep数据中。它传入的参数是需要等待的信号,在这个例子中传入的是uart_tx_r的地址。在uartstart函数中,一旦buffer中有了空间,会调用与sleep对应的函数wakeup,传入的也是uart_tx_r的地址。任何等待在这个地址的进程都会被唤醒。有时候这种机制被称为conditional synchronization。
以上就是 Shell 输出提示符“$ ”的全部内容。如你们所见,过程还挺复杂的,许多代码一起工作才将这两个字符传输到了 Console。
# 8. UART 读取键盘输入
在 UART 的另一侧,会有类似的事情发生,有时 Shell 会调用 read 从键盘中读取字符。 在 read 系统调用的底层,会调用 fileread 函数。在这个函数中,如果读取的文件类型是设备,会调用相应设备的 read 函数。
// Read from file f.
// addr is a user virtual address.
int
fileread(struct file *f, uint64 addr, int n)
{
int r = 0;
if(f->readable == 0)
return -1;
if(f->type == FD_PIPE){
r = piperead(f->pipe, addr, n);
} else if(f->type == FD_DEVICE){
if(f->major < 0 || f->major >= NDEV || !devsw[f->major].read)
return -1;
r = devsw[f->major].read(1, addr, n);
} else if(f->type == FD_INODE){
ilock(f->ip);
if((r = readi(f->ip, 1, addr, f->off, n)) > 0)
f->off += r;
iunlock(f->ip);
} else {
panic("fileread");
}
return r;
}
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
在我们的例子中,read 函数就是 console.c 文件中的 consoleread 函数。
//
// user read()s from the console go here.
// copy (up to) a whole input line to dst.
// user_dist indicates whether dst is a user
// or kernel address.
//
int
consoleread(int user_dst, uint64 dst, int n)
{
uint target;
int c;
char cbuf;
target = n;
acquire(&cons.lock);
while(n > 0){
// wait until interrupt handler has put some
// input into cons.buffer.
while(cons.r == cons.w){
if(myproc()->killed){
release(&cons.lock);
return -1;
}
sleep(&cons.r, &cons.lock);
}
c = cons.buf[cons.r++ % INPUT_BUF];
if(c == C('D')){ // end-of-file
if(n < target){
// Save ^D for next time, to make sure
// caller gets a 0-byte result.
cons.r--;
}
break;
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
这里与 UART 类似,也有一个 buffer,包含了 128 个字符。其他的基本一样,也有 producer 和 consumser。但是在这个场景下 Shell 变成了 consumser,因为 Shell 是从 buffer 中读取数据。而键盘是 producer,它将数据写入到 buffer 中。
从 consoleread 函数中可以看出,当读指针和写指针一样时,说明 buffer 为空,进程会 sleep。所以 Shell 在打印完“$ ”之后,如果键盘没有输入,Shell 进程会 sleep,直到键盘有一个字符输入。所以在某个时间点,假设用户通过键盘输入了“l”,这会导致“l”被发送到主板上的 UART 芯片,产生中断之后再被 PLIC 路由到某个 CPU 核,之后会触发 devintr 函数,devintr 可以发现这是一个 UART 中断,然后通过 uartgetc 函数获取到相应的字符,之后再将字符传递给 consoleintr 函数。
默认情况下,字符会通过 consputc,输出到 console 上给用户查看。之后,字符被存放在 buffer 中。在遇到换行符的时候,唤醒之前 sleep 的进程,也就是 Shell,再从 buffer 中将数据读出。
所以这里也是通过 buffer 将 consumer 和 producer 之间解耦,这样它们才能按照自己的速度,独立的并行运行。如果某一个运行的过快了,那么 buffer 要么是满的要么是空的,consumer 和 producer 其中一个会 sleep 并等待另一个追上来。
# 9. Interrupt 的演进
最后介绍一下 Interrupt 在最近几十年的演进。Unix 刚被开发出来的时候,Interrupt 处理还是很快的。这使得硬件可以很简单,当外设有数据需要处理时,硬件可以中断 CPU 的执行,并让 CPU 处理硬件的数据。
而现在,中断相对处理器来说变慢了。从前面的介绍可以看出来这一点,需要很多步骤才能真正的处理中断数据。如果一个设备在高速的产生中断,处理器将会很难跟上。所以如果查看现在的设备,可以发现,现在的设备相比之前做了更多的工作。所以在产生中断之前,设备上会执行大量的操作,这样可以减轻 CPU 的处理负担。所以现在硬件变得更加复杂。
如果你有一个高性能的设备,例如你有一个千兆网卡,这个网卡收到了大量的小包,网卡每秒可以生成 1.5Mpps,这意味着每一个微秒,CPU 都需要处理一个中断,这就超过了 CPU 的处理能力。那么当网卡收到大量包,并且处理器不能处理这么多中断的时候该怎么办呢?
这里的解决方法就是使用 polling。除了依赖 Interrupt,CPU 可以一直读取外设的控制寄存器,来检查是否有数据。对于 UART 来说,我们可以一直读取 RHR 寄存器,来检查是否有数据。现在,CPU 不停的在轮询设备,直到设备有了数据。这种方法浪费了 CPU cycles,当我们在使用 CPU 不停的检查寄存器的内容时,我们并没有用 CPU 来运行任何程序。在我们之前的例子中,如果没有数据,内核会让 Shell 进程 sleep,这样可以运行另一个进程。所以,对于一个慢设备,你肯定不想一直轮询它来得到数据。我们想要在没有数据的时候切换出来运行一些其他程序。但是如果是一个快设备,那么 Interrupt 的 overhead 也会很高,那么我们在 polling 设备的时候,是经常能拿到数据的,这样可以节省进出中断的代价。
所以对于一个高性能的网卡,如果有大量的包要传入,那么应该用polling。对于一些精心设计的驱动,它们会在polling和Interrupt之间动态切换(注,也就是网卡的NAPI)。