写在前面:本文主要是介绍了中断的原理以及驱动的设计,其中介绍了大部分的xv6的驱动代码,建议读者可以提前阅读一下相关源码,在阅读本文是可以使用GDB去调试过一遍中断的流程。对于时钟中断的代码可能有点晦涩,因为在课程中没有介绍,所有这些都是看xv6书和源码得到的结论,本文对此的介绍可能有些许问题,仅供参考。

中断简介

中断对应的场景,就是硬件需要得到操作系统的关注,例如网卡收到了一个packet,网卡会生成一个中断;用户通过键盘按下了一个按键,键盘会产生一个中断。操作系统需要做的是,保存当前的工作,处理中断,处理完成之后再恢复之前的工作。

中断与系统调用的不同,同时也是驱动设计的难点

  1. 异步性(asynchronous)。当硬件生成中断时,interrupt handler与当前运行的进程在CPU上没有任何关联。但如果是系统调用的话,系统调用发生在运行进程的上下文(context)下。

  2. 并行性(concurrency)。对于中断来说,CPU和生成中断的设备是并行的在运行。网卡自己独立的处理来自网络的packet,然后在某个时间点产生中断,但是同时,CPU也在运行。所以我们在CPU和设备之间是真正的并行的,我们必须管理这里的并行。

  3. 可编程设备(program device)。例如网卡,UART,而这些设1备需要被编程。每个设备都有一个编程手册,就像RISC-V有一个包含了指令和寄存器的手册一样。设备的编程手册包含了它有什么样的寄存器,它能执行什么样的操作,在读写控制寄存器的时候,设备会如何响应。不过通常来说,设备的手册不如RISC-V的手册清楚,这会使得对于设备的编程会更加复杂。

中断的产生

如下图所示,是一个SiFive主板,这上面连接了大量的设备,如:以太网卡,MicroUSB,MicroSD等,主板上的各种线路将外设和CPU连接在一起。

Sifive

下图是SiFive有关处理器的文档,图中的右侧是各种各样的设备,例如UART0(地址在memlayout.h中定义0x100000000)。其映射的地址为类似于读写内存,通过向相应的设备地址执行load/store指令,我们就可以对例如UART的设备进行编程。

通用异步收发器(Universal Asynchronous Receiver/Transmitter),通常称作UART,是一种串行、异步、全双工的通信协议,在嵌入式领域应用的非常广泛。在xv6充当的是串口控制台设备驱动程序。

Device

所有的设备都连接到处理器上,处理器上是通过Platform Level Interrupt Control,简称PLIC来处理设备中断。PLIC会管理来自于外设的中断。进一步查看PLIC的结构图。

PLIC

从左上角可以看出,我们有53个不同的来自于设备的中断。这些中断到达PLIC之后,PLIC会路由这些中断。图的U54是CPU的核,PLIC会将中断路由到某一个CPU的核。如果所有的CPU核都正在处理中断,PLIC会保留中断直到有一个CPU核可以用来处理中断。所以PLIC需要保存一些内部数据来跟踪中断的状态。具体流程是:

  • PLIC会通知当前有一个待处理的中断
  • 其中一个CPU核会Claim接收中断,这样PLIC就不会把中断发给其他的CPU处理
  • CPU核处理完中断之后,CPU会通知PLIC
  • PLIC将不再保存中断的信息

以上是中断的硬件部分,接着介绍中断的软件部分。

设备驱动简介

在使用windows操作系统时,都会用到驱动精灵或者时官方的驱动更新硬件驱动,那么驱动是什么呢?

驱动:操作系统中用于管理硬件设备的代码程序。主要的功能是配置硬件设备,通知设备执行特定操作,处理由此产生的中断,并与可能正在等待设备I/O的进程进行交互。

中断与设备驱动

设备需要得到操作系统的关注,设备便能够被操作系统配置生成中断。中断在trap章节我们节有所提及,是三种trap类型的其中之一。

那么如trap章节所讲,trap处理代码同样也需要检查设备是否发起中断调用驱动interrupt handler,在xv6中是由devintr函数进行调度(dispatch)。

驱动代码组成

许多的驱动代码会被分为两段:

  • top half:通常是用户进程,或者内核的其他部分调用的接口。在进程的内核线程中运行。例如UART设备的接口(write、read)被调用时,需要设备执行I/O,这段代码需要等待设备操作的完成。如shell进程等待键盘的输入。
  • bottom half:通常是中断处理程序(interrupt handler)。在中断期间执行,当设备完成操作时将会发起中断,interrupt handler将会查询那个操作被完成,唤醒等待的进程,并通知设备去执行下一个等待操作。

通常情况下,驱动中会有一些队列(或者说buffer),top部分的代码会从队列中读写数据,而Interrupt handler(bottom部分)同时也会向队列中读写数据。这里的队列可以将并行运行的设备和CPU解耦开来。

通常对于Interrupt handler来说存在一些限制,因为它并没有运行在任何进程的context中,所以进程的page table并不知道该从哪个地址读写数据,也就无法直接从Interrupt handler读写数据。驱动的top部分通常与用户的进程交互,并进行数据的读写。

如何对设备进行编程

通常来说,设备编程是通过memory mapped I/O完成的。在SiFive的手册中,设备地址出现在物理地址的特定区间内(页表章节有所提及,memlayout.h定义了这些地址),这个区间由主板制造商决定。

操作系统需要知道这些设备位于物理地址空间的具体位置,然后再通过普通的load/store指令对这些地址进行编程。load/store指令实际上的工作就是读写设备的控制寄存器。

例如,对网卡执行store指令时,CPU会修改网卡的某个控制寄存器,进而操作网卡发送一个packet。所以这里的load/store指令不会读写内存,而是会操作设备。

以上便是中断的软件部分——设备驱动,接着我们更加深入的去理解这驱动代码,如何使用设备编程。

UART驱动设计

本节以shell的"$ "命令为示例,以代码的视角观察:"$ "如何通过shell调用接口输出到console文件设备的,来介绍UART驱动设计。

中断相关的寄存器

  • 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:记录trampoline(trampoline.S)与kernelvec(kernelvec.S)的入口地址,当在用户空间触发trap(像是ecall)就进入trampoline函数;在内核空间则进入kernelvec函数。

对于scause、stvec这两个就可以忽略了,接着主要使用到与中断设置相关的寄存器。

xv6设置中断

现在我们又回到chapter 2所讲述的xv6启动的代码,在entry.S中设置好每个CPU的栈后,便进入了start函数开始设置中断相关的寄存器,让cpu能够处于接受中断的状态。

start:设置sie寄存器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void
start()
{
....

// delegate all interrupts and exceptions to supervisor mode.
w_medeleg(0xffff);
w_mideleg(0xffff);
w_sie(r_sie() | SIE_SEIE | SIE_STIE | SIE_SSIE);

// configure Physical Memory Protection to give supervisor mode
// access to all of physical memory.
w_pmpaddr0(0x3fffffffffffffull);
w_pmpcfg0(0xf);

timerinit();
....
}

start函数中,将所有的中断异常处理都委托给管理者模式(Supervisor mode,也可以称为内核模式,之后使用S-mode代替),然后设置sie寄存器来接收外部的(设备),软件和定时器中断,之后初始化定时器。

接着进入main函数,设置处理外部中断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
----kernel/main.c
void
main(){
if(cpuid() == 0){
consoleinit();
....
plicinit(); // set up interrupt controller
plicinithart(); // ask PLIC for device interrupts
__sync_synchronize();
started = 1;
}else{
....
}
scheduler();
}

初始化Console设备

在console.c中的consoleinit函数首先初始化锁,这里并不需要关心锁的问题。并且将console文件设备的读写接口连接到write与read系统调用上。接着调用了uartinit函数(kernel/uart.c)处理UART设备。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
----kernel/console.c
//初始化控制台,设置读写系统调用连接到控制台
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;
}

初始化UART设备:主要是管理设备的寄存器

这里的流程是先关闭中断,之后设置波特率(串口线的传输速率),设置字符长度为8bit,重置FIFO,最后再重新打开中断。看下述代码注释也可以知晓大部分的含义。

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
----uart.c
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");
}

以上就是uartinit函数,运行完这个函数之后,原则上UART就可以生成中断了。但此时还没有对PLIC编程,所以外部(设备)中断不能被CPU感知。最终,在main函数中,需要调用plicinit函数。

设置PLIC:让CPU能够感知外部中断

PLIC与外设一样,也占用了一个I/O地址(0xC0000000),主要是设置设备的IRQ(Interupt ReQuest)寄存器的I/O地址,从而可以在PLIC中判断中断的设备,并可以路由到CPU。

  • 第一行设置PLIC接收来自UART的中断,进而将中断路由到CPU。
  • 第二行设置PLIC接收来自IO磁盘的中断。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
----plic.c
void
plicinit(void)
{
// set desired IRQ priorities non-zero (otherwise disabled).
*(uint32*)(PLIC + UART0_IRQ*4) = 1;
*(uint32*)(PLIC + VIRTIO0_IRQ*4) = 1;
}

----memlayout.h
// qemu puts platform-level interrupt controller (PLIC) here.
#define PLIC 0x0c000000L
// qemu puts UART registers here in physical memory.
#define UART0 0x10000000L
#define UART0_IRQ 10

main函数中,plicinit函数之后就是plicinithart函数。plicinit是由0号CPU运行,之后,每个CPU的核都需要调用plicinithart函数。

  • 设置CPU允许S-mode能够感知UART与VIRTIO中断。
  • 并且将S-mode的特权级设置为0,也就是忽略中断的优先级。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
----memlayout.c
#define PLIC_PRIORITY (PLIC + 0x0)
#define PLIC_SENABLE(hart) (PLIC + 0x2080 + (hart)*0x100)
//每个核都调用该函数
void
plicinithart(void)
{
int hart = cpuid();

// set enable bits for this hart's S-mode
// for the uart and virtio disk.
*(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;
}

到目前为止,我们有了生成中断的外部设备(console与UART),PLIC可以传递中断到单个的CPU

但是CPU自己还没有设置好接收中断,因为我们还没有设置好sstatus寄存器。在main函数的最后,程序调用了scheduler函数。

scheduler:设置每个核的sstatus寄存器

scheduler是内核进程,调度CPU资源给RUNNABLE用户进程,在chapter 7 调度章节会重点讲解。在scheduler中调用了intr_on()函数,开启中断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
----proc.h
void
scheduler(void)
{
struct proc *p;
struct cpu *c = mycpu();

c->proc = 0;
for(;;){
// Avoid deadlock by ensuring that devices can interrupt.
intr_on();
....
}
}
}

在实际运行进程之前,会执行intr_on函数通过设置sstatus的SIE位,使得CPU能接收中断。

1
2
3
4
5
6
7
----riscv.h
// enable device interrupts
static inline void
intr_on()
{
w_sstatus(r_sstatus() | SSTATUS_SIE);
}

在这个时间点,中断被完全打开了。如果PLIC正好有待处理(pending)的中断,那么这个CPU核会收到中断。

UART驱动Top部分

由sh调用write输出'$'' '字符到Console,接着我们看看Console是如何得来的。

创建Console文件设备

首先这个进程的main函数创建了一个代表Console的设备。这里通过mknod操作创建了console设备。

因为这是第一个打开的文件,所以这里的文件描述符0。之后通过dup创建stdout和stderr。这里实际上通过复制文件描述符0,得到了另外两个文件描述符1,2,最终文件描述符0,1,2都用来代表Console。

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
----user/init.c
int
main(void)
{
int pid, wpid;

if(open("console", O_RDWR) < 0){
mknod("console", CONSOLE, 0);
open("console", O_RDWR);
}
//复制文件描述符,设置为标准输出、标准错误
dup(0); // stdout = 1
dup(0); // stderr = 2

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);
}
....
}
}

write系统调用,向Console中输出”$ “字符串

sh.c程序中getcmd函数,通过使用write调用先要求Console中输出"$ "字符,之后再调用gets(实际上是调用read读取命令)函数。

1
2
3
4
5
6
7
8
9
10
11
----sh.c
int
getcmd(char *buf, int nbuf)
{
write(2, "$ ", 2);
memset(buf, 0, nbuf);
gets(buf, nbuf);
if(buf[0] == 0) // EOF
return -1;
return 0;
}

尽管Console背后是UART设备,但是从应用程序来看,它就像是一个普通的文件,也就是向文件输入数据,通过UART串口输出到显示器。Shell程序只是向文件描述符2写了数据,它并不知道文件描述符2对应的是什么。

进入write系统调用

所以由Shell输出的每一个字符都会触发一个write系统调用。之前我们已经看过了write系统调用最终会走到sysfile.c文件的sys_write函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
----sysfile.c
uint64
sys_write(void)
{
....
return filewrite(f, p, n);
}
----file.c
int
filewrite(struct file *f, uint64 addr, int n)
{
....
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;
//在console.c中将其指向了consolewrite
ret = devsw[f->major].write(1, addr, n);
}
....
}

filewrite函数中首先会判断文件描述符的类型。mknod生成的文件描述符属于设备(FD_DEVICE),而对于设备类型的文件描述符,我们会为这个特定的设备执行设备相应的write函数。因为我们现在的设备是Console,所以我们知道这里会调用console.c中的consolewrite函数。

write系统调用与Console文件交互

这里先通过either_copyin将字符"$ "拷入,之后调用uartputc函数。uartputc函数将字符写入给UART设备,所以可以认为consolewrite是一个UART驱动的top部分。uart.c文件中的uartputc函数会实际的打印字符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
----console.c
int
consolewrite(int user_src, uint64 src, int n)
{
int i;

for(i = 0; i < n; i++){
char c;
if(either_copyin(&c, user_src, src+i, 1) == -1)
break;
uartputc(c);
}

return i;
}

Console文件设备与UART设备交互

uartputc函数会稍微有趣一些。在UART的内部会有一个buffer用来发送数据,buffer的大小是32个字符。同时还有一个为consumer提供的读指针和为producer提供的写指针,来构建一个环形的buffer(环形队列)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
----uart.c
#define UART_TX_BUF_SIZE 32
char uart_tx_buf[UART_TX_BUF_SIZE];
uint64 uart_tx_w; // write next to uart_tx_buf[uart_tx_w % UART_TX_BUF_SIZE]
uint64 uart_tx_r; // read next from uart_tx_buf[uart_tx_r % UART_TX_BUF_SIZE]
void
uartputc(int c)
{
acquire(&uart_tx_lock);

if(panicked){
for(;;)
;
}
while(uart_tx_w == uart_tx_r + UART_TX_BUF_SIZE){
// buffer is full.
// wait for uartstart() to open up space in the buffer.
sleep(&uart_tx_r, &uart_tx_lock);
}
uart_tx_buf[uart_tx_w % UART_TX_BUF_SIZE] = c;
uart_tx_w += 1;
uartstart();
release(&uart_tx_lock);
}

在UART的内部会有一个buffer用来发送数据,buffer的大小是32个字符。同时还有一个为consumer提供的读指针和为producer提供的写指针,来构建一个环形的buffer(环形队列)。

在本例中,Shell程序是生产者(producer,输入字符串),所以需要调用uartputc函数。

在uartputc函数中第一件事情是判断环形buffer是否已经满了。如果读写指针相同,那么buffer是空的,如果写指针加1等于读指针,那么buffer满了。当buffer是满的时候,向其写入数据是没有意义的,所以这里会sleep一段时间,将CPU出让给其他进程。

当然,在本例中,buffer必然不是满的,因为提示符“$”是我们送出的第一个字符。所以代码会走到else,字符会被送到buffer中,更新写指针,之后再调用uartstart函数。

uartstart:发送数据,引起中断

首先是检查当前设备是否空闲,如果空闲的话,我们会从buffer中读出数据,然后将数据写入到THR(Transmission Holding Register)发送寄存器。这里相当于告诉设备,我这里有一个字节需要你来发送。一旦数据送到了设备,系统调用会返回,用户应用程序Shell就可以继续执行。

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
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_BUF_SIZE];
uart_tx_r += 1;

// maybe uartputc() is waiting for space in the buffer.
wakeup(&uart_tx_r);

WriteReg(THR, c);
}
}

上述代码中结构一个死循环,只有将buffer中的字符全部读取完毕后才会退出或是读取LSR寄存器(Line State Register)判断UART的THR寄存器已满(也就是没有UART接收的字符没有发出去)。

到后面则是读取buffer,将值写入到THR寄存器,如果你使用GDB进行调试的话这时屏幕就已经显示'$'字符了。简单来说就是将字符写入THR寄存器就可以UART可以向显示设备发送数据了,显示设备也可以显示字符了。

值得注意的是,每当UART设备发送了一个字节数据后,将会生成一个中断,从而调用uartintr,这个函数便是interrupt handler,也就是驱动的bottom部分。接着我们看看bottom部分如何设计。

UART驱动Bottom部分

在shell通过write完成uart设备字节发送后(bootm),GDB中使用backtrace命令会发现uartintr是由kerneltrap所调用的,可以得知是在内核也会引起中断。

uartintr会再次调用uartstart函数,这个函数在top部分已经被uartputc函数所调用过,那么为什么又要生成一个中断再调用uartstart函数呢?原因是需要检测设备是否完成了buffer内所有字节的发送,并将UART的buffer清空。

如果一个进程写入多个字节到console,那么当uartputc调用uartstart发送一个字节后,生成一个中断后uartintr会不断调用uartstart函数,把buffer内的数据全部发送完毕。

Console:output

上图所表示的便是驱动的Top与Bottom部分的结合,那么接着以xv6的代码了解一下,内核是如何识别中断,并以此调用对应设备的interrupt handler。

Kerneltrap识别中断:Console输出字符

之前已经在sstatus寄存器中打开了中断,所以处理器会被中断。在上文中shell以及通过write向UART发送了一个中断并且发向了PLIC,PLIC会将中断路由给一个特定的CPU核,并且如果这个CPU核设置了sie寄存器的E bit(外部中断的bit位),那么会发生以下事情,像syscall一样从用户空间进入内核空间:

  1. 首先,会清除sie寄存器相应的bit,这样可以阻止CPU核被其他中断打扰,该CPU核可以专心处理当前中断。处理完成之后,可以再次恢复sie寄存器相应的bit。
  2. 设置SEPC寄存器为当前的程序计数器。
  3. 保存当前的mode。在我们的例子里面,因为当前运行的是Shell程序,所以会记录user mode。
  4. 将mode设置为S-mode。
  5. 将程序计数器的值设置成stvec的值。在我们的例子中,Shell运行在用户空间,所以stvec保存的是uservec函数的地址。

devintr:中断处理

此时shell程序接收外部中断时(键盘),像是系统调用一样进入内核,进入usertrap函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void
usertrap(void)
{
....
if(r_scause() == 8){
if(killed(p))
exit(-1);
p->trapframe->epc += 4;
intr_on();
syscall();
} else if((which_dev = devintr()) != 0){
// ok
}
....
}

devintr函数中,首先会通过scause寄存器判断当前中断是否是来自于外设中断。如果是的话,再调用plic_claim函数来获取中断。

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
int
devintr()
{
uint64 scause = r_scause();
//scause -> 64bit = 16 bit in hex
//第一个是判断trap是否是中断
//第二个是判断是否是Supervisor Enternal interrupt
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);
}

if(irq)
plic_complete(irq);

return 1;
}
....
}

plic_claim:获取中断

plic_claim函数位于plic.c文件中。在这个函数中,当前CPU核会告知PLIC,自己要处理中断,PLIC_SCLAIM会将中断号返回,对于UART设备来说,返回的中断号是10。

1
2
3
4
5
6
7
8
9
10
11
----plic.c
int
plic_claim(void)
{
int hart = cpuid();
int irq = *(uint32*)PLIC_SCLAIM(hart);
return irq;
}

----memlayout.c
#define PLIC_SCLAIM(hart) (PLIC + 0x201004 + (hart)*0x2000)

devintr函数调用完plic_claim函数后,判断中断的设备号,如果是UART中断,那么会调用uartintr函数(uart.c)。会从UART的接受寄存器中读取数据,之后将获取到的数据传递给consoleintr函数。

UART接收数据

首先RHR与THR是同一个寄存器,那么uartgetc通过读取RHR就可以获取键盘的字符数据,如果没有输入的话RHR就是空为-1,再次进入进入uartstart

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
----uart.c
#define RHR 0 // receive holding register (for input bytes)
#define THR 0 // transmit holding register (for output bytes)
int
uartgetc(void)
{
if(ReadReg(LSR) & 0x01){
// input data is ready.
return ReadReg(RHR);
} else {
return -1;
}
}

void
uartintr(void)
{
// read and process incoming characters.
while(1){
int c = uartgetc();
if(c == -1)
break;
consoleintr(c);
}

// send buffered characters.
acquire(&uart_tx_lock);
uartstart();
release(&uart_tx_lock);
}

所以代码会直接运行到uartstart函数,这个函数会将Shell存储在buffer中的任意字符送出。实际上在提示符“$”之后,Shell还会输出一个空格字符,write系统调用可以在UART发送提示符“$”的同时,并发的将空格字符写入到buffer中。所以UART的发送中断触发时,可以发现在buffer中还有一个空格字符,之后会将这个空格字符送出。

UART接收键盘中断

Top部分

本节主要是介绍console.c这个设备文件为主,介绍一下当我们按下键盘的一个键后,中断设备是如何进行相应处理,或者是说我们按下一个键后sh程序是如何将字符显示出来的。

读取字节:read系统调用

在sh.c文件的getcmd函数,在shell输出完”$ “字符串后,就会使用gets函数字符串。那么在gets函数中也主要是通过对Console文件设备(fd=0)发起read系统调用,就可以获得完整的字符串。

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
----sh.c
int
getcmd(char *buf, int nbuf)
{
write(2, "$ ", 2);
memset(buf, 0, nbuf);
gets(buf, nbuf);
if(buf[0] == 0) // EOF
return -1;
return 0;
}

char*
gets(char *buf, int max)
{
int i, cc;
char c;

for(i=0; i+1 < max; ){
cc = read(0, &c, 1);
if(cc < 1)
break;
buf[i++] = c;
if(c == '\n' || c == '\r')
break;
}
buf[i] = '\0';
return buf;
}

实际上read系统调用是一个阻塞I/O命令,会导致shell进程放弃cpu的资源使用。此时所需要的就是等待Console设备的输入,也就是等待键盘键入字符。

接着了解一下如何read系统调用过程。

read系统调用与Console文件交互

fileread函数与前面所讲的filewrite函数原理相似,这也就是说read系统调用所参数fd=0的情况下,此时read系统调用是与console的consoleread函数所绑定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
----file.c
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);
}
....

return r;
}

consoleread:从console设备中读取字符

console设备像UART设备一样都有一个环形buffer用于读取数据,在该函数中通过从buffer中读取字节,再通过copyout将字符返回到用户空间。

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
int
consoleread(int user_dst, uint64 dst, int n)
{
uint target;
int c;
char cbuf;

target = n;
acquire(&cons.lock);
while(n > 0){
c = cons.buf[cons.r++ % INPUT_BUF_SIZE];
....
// copy the input byte to the user-space buffer.
cbuf = c;
if(either_copyout(user_dst, dst, &cbuf, 1) == -1)
break;

dst++;
--n;

if(c == '\n'){
// a whole line has arrived, return to
// the user-level read().
break;
}
}
release(&cons.lock);

return target - n;
}

到此我们知道了如何通过read向console设备中读取文件,但是buffer的数据从何而来?接着我们查看一下Bottom部分代码,了解如何从键盘键入字符发起中断,将字符加入console的buffer中。

Bottom部分

需要谨记的一点是,接收键盘中断与shell发送字符不同点在于,shell程序(read调用)是一个消费者(consumer),而键盘输入(或者是console)是一个生产者(producer)。

kerneltrapuarintr的过程与之前shell的write调用是一样的,因此就不赘述了。

uartintr:读取键盘中断

在前面的uartintr不同的是,因为有键盘的输入,此时调用uartgetc是可以从RHR寄存器中读取到字符的,那么得到的字符发送到consoleintr函数中就进入console文件中断处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void
uartintr(void)
{
// read and process incoming characters.
while(1){
int c = uartgetc();
if(c == -1)
break;
consoleintr(c);
}

// send buffered characters.
acquire(&uart_tx_lock);
uartstart();
release(&uart_tx_lock);
}

consoleintr:将键盘输入添加到环形buffer中

当键盘输出的是一个普通的字符时,consoleintr会调用consputc,在该函数中会调用uartputc_sync(与uartputc功能相同,主要区别是这个函数能够防止冲突)。

那么在之前也讲到调用uartputc函数,会向UART的THR寄存器发送字节,那么相应的也会使屏幕中显示键盘输入的字符。

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
----console.c
void
consputc(int c)
{
if(c == BACKSPACE){
// if the user typed backspace, overwrite with a space.
uartputc_sync('\b'); uartputc_sync(' '); uartputc_sync('\b');
} else {
uartputc_sync(c);
}
}

void
consoleintr(int c)
{
acquire(&cons.lock);

switch(c){
....
default:
if(c != 0 && cons.e-cons.r < INPUT_BUF_SIZE){
c = (c == '\r') ? '\n' : c;

// echo back to the user.
consputc(c);

// store for consumption by consoleread().
cons.buf[cons.e++ % INPUT_BUF_SIZE] = c;

if(c == '\n' || c == C('D') || cons.e-cons.r == INPUT_BUF_SIZE){
cons.w = cons.e;
wakeup(&cons.r);
}
}
break;
}
release(&cons.lock);
}

在最后我们要需要主要 if(c == '\n' || c == C('D') || cons.e-cons.r == INPUT_BUF_SIZE)语句的判断,简单来说就是,只有在我们键盘敲下回车键后该函数才会调用wakeup(&cons.r);将buffer的读指针唤醒,也就是说consoleread在此次之后才能够移动console的buffer的读指针,也就是只有敲下回车后,read系统调用才会返回用户空间。

接着我们下来以GDB的视角去确认一下这个结论。

GDB调试

  1. 打开两个终端T1T2,在T1中输入make qemu-gdb,然后再T2中输入gdb-multiarch

  2. 在T2中先输入b consoleread命令,然后使用continue命令运行xv6.

  3. 再输入b uartintr命令,再uartintr函数中设置断点

  4. 然后到T1中先按下一个键——a,此时在T2中会发现我们程序停在了uartintr函数处,也就说明UART设备是接收键盘中断的。也可以在T2窗口中使用backtrace命令查看函数调用栈,可以确认uartintr是由kerneltrap调用。

image-20230531011640028

  1. T2窗口中输入layout src命令开启源代码窗口,输入next命令到consoleintr函数处。

uartintr1

  1. 使用step命令进入该函数,再使用next命令到consoleputc函数。那么在此时我们继续使用next命令,就可以看到T1窗口显示字符了。

如果你想看到如何一步步使,T1窗口中显示我们输入的字符的可以使用step命令进入该函数,但是在前面也讲到了是将字符写入THR寄存器导致的

output

  1. 之后使用continue命令,执行完整个键盘输入字符的中断过程,会发现我们之前设置consoleread函数断点没有触发。那么此时敲下回车键,又在uartintr函数产生中断,使用两次continue命令运行整个中断过程,那么会发现此时程序停在了consoleread的断点处。

为什么要continue两次?因为键盘中断调用一次uartintr函数,此次调用会向THR寄存器发送数据。在之前讲过,每次向THR发送一个字节就会生成一次中断也会调用一次uartintr

consoleread

以上便是键盘输入产生中断,并通过read调用获取输入返回用户空间的过程了。

驱动中的并发

锁:并发控制

在之前我们所讲到的代码中,会发现到在interrupt handler中都有使用acquirerelease加锁核释放锁的过程(锁的内容会在chapter 6中讲到)。

这里简单的说一下锁的作用,以console设备的buffer数据结构为例,使用acquire函数获取锁后,就可以保护这个buffer避免并发的访问

xv6中并行控制

设备与CPU是并行运行的。例如当UART向Console发送字符的时候,CPU会返回执行Shell,而Shell可能会再执行一次系统调用,向buffer中写入另一个字符,这些都是在并行的执行。这里的并行称为producer-consumer并行。

中断会停止当前运行的程序。例如,Shell正在运行第212个指令,突然来了个中断,Shell的执行会立即停止。对于用户空间代码,当从中断中返回时,我们会恢复用户空间代码,并继续执行执行停止的指令。

当内核被中断打断时(调用kerneltrap),这意味着即使是内核代码,也不是直接串行运行的。在两个内核指令之间,取决于中断是否打开,可能会被中断打断执行。对于一些代码来说,如果不能在执行期间被中断,这时内核需要临时关闭中断,来确保这段代码的原子性

驱动的top和bottom部分是并行运行的

例如,Shell会在传输完提示符“$”之后再调用write系统调用传输空格字符,代码会走到UART驱动的top部分(uartputc),将空格写入到buffer中。但是同时在另一个CPU核,可能会收到来自于UART的中断,进而执行UART驱动的bottom部分,查看相同的buffer。所以一个驱动的top和bottom部分可以并行的在不同的CPU上运行。

这里我们通过锁来管理并行。因为这里有共享的数据,要求buffer在一个时间只被一个CPU核所操作。

producer/consumser并发

这是驱动中的非常常见的典型现象。在驱动(如console.c或是uart.c)中会有一个buffer,在我们之前的例子中,buffer是32字节大小。并且有两个指针,分别是读指针和写指针。

这里是以shell调用write输出“$ ”为例:

producer(write调用)可以一直写入数据,直到写指针 + 1等于读指针,此时32字节的buffer就已经满了。当buffer满了的时候,producer必须停止运行。在uartputc函数了解到,如果buffer满了,将会调用sleep,暂时搁置Shell并运行其他的进程。

consumer(Interrupt handler),也就是uartintr函数,,每当有一个中断,并且读指针落后于写指针,uartintr函数就会从读指针中读取一个字符再通过UART设备发送,并且将读指针加1。当读指针追上写指针,也就是两个指针相等的时候,buffer为空,这时就不用做任何操作。

queue

这两个部分是并发执行的,值得区分的是producer/consumer因为需要加锁的原因,不能够同时访问buffer。因为buffer在内存中只有一个副本,为了避免冲突所以加锁。

在同一个时间片内,只有一者能够访问buffer数据,那为什么说这样是并发的呢?对于共享数据并发执行会导致冲突,但是在上述情景下,两个CPU分别扮演producer与consumer,只有两者都运行到uartintr才会造成其中一个独占buffer的情况,另一个则是需要等待持锁CPU释放才能运行。在时间上也就可以视为两个CPU时并发执行的,因为只有在两者都运行到interrupt handler函数内会让其中一者等待,其余的时间都是并行的执行的。

时钟中断

简介

操作系统中会使用时钟中断(timer interrupt)来维护它的时钟(clock),时钟设备有一个间隔值如0.1s产生一个中断,就会触发内核选择进行调度,使内核能够切换正在运行的进程。这个过程我们称为调度,这节的知识会在chatper 7 讲解,想要提前了解调度算法的可以看这一篇文章操作系统导论:调度 - 知乎 (zhihu.com)

usertrapkerneltrap 中的 yield 调用会导致这种切换。每个 RISC-V CPU的时钟硬件都会生成时钟中断。Xv6 对这个时钟硬件进行编程,使其定期中断相应的CPU。

中断设置

RISC-V 要求在机器模式(M-mode)下处理时钟中断,而不是S-mode。RISC-V机器模式执行时没有分页,并且有一套单独的控制寄存器(以m开头的寄存器),在机器模式下运行普通的 xv6 内核代码是不实用的。xv6对定时器中断的处理与上面谈到的 trap 机制完全分离了。

时钟初始化代码

在机器模式下执行start.c程序,在start函数中调用timerinit设置了接收时钟中断 。主要是通过设置M-mode的特殊寄存器:作用与S-mode的寄存器相似,不同点在于这些是M-mode的寄存器。

  • mstvec:记录timervec函数的入口
  • mscratch:预留时钟中断的一个临时区域,每个CPU有40字节区域。将后两个8字节区域设置为CLINT_MTIMECMP寄存器地址与时钟中断间隔的interval值。
  • mstatus:设置在M-mode下CPU能够处理外部中断。
  • msie:开启时钟中断。

一部分工作是对CLINT(硬件core-local interruptor)进行编程,使其每隔一定时间产生一次中断,在代码中是0.1s中断一次。因此时钟中断是设备主动发起的中断,而不是像键盘输入一样需要人为的干预。

另一部分是设置一个类似于trapframe的scratch区域,帮助时钟中断处理程序保存寄存器CLINT寄存器的地址。最后,start函数将mtvec设置为timervec,启用定时器中断。

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
----memlayout.h
#define CLINT 0x2000000L
#define CLINT_MTIMECMP(hartid) (CLINT + 0x4000 + 8*(hartid))
#define CLINT_MTIME (CLINT + 0xBFF8) // cycles since boot.
----start.c
// a scratch area per CPU for machine-mode timer interrupts.
uint64 timer_scratch[NCPU][5];
void
timerinit()
{
// each CPU has a separate source of timer interrupts.
int id = r_mhartid();

// ask the CLINT for a timer interrupt.
int interval = 1000000; // cycles; about 1/10th second in qemu.

*(uint64*)CLINT_MTIMECMP(id) = *(uint64*)CLINT_MTIME + interval;

uint64 *scratch = &timer_scratch[id][0];
scratch[3] = CLINT_MTIMECMP(id);
scratch[4] = interval;
w_mscratch((uint64)scratch);

// set the machine-mode trap handler.
w_mtvec((uint64)timervec);

// enable machine-mode interrupts.
w_mstatus(r_mstatus() | MSTATUS_MIE);

// enable machine-mode timer interrupts.
w_mie(r_mie() | MIE_MTIE);
}

中断生成

在用户或内核代码执行的任何时候都会产生时钟中断。内核无法禁用时钟中断(在M-mode才能够禁用时钟中断)。因此,时钟中断处理程序必须以保证不干扰被中断的内核代码的方式进行工作。

基本策略是处理程序要求RISC-V引发一个软件中断并立即返回。RISC-V 用普通的 trap 机制将软件中断传递给内核,并允许内核禁用它们。

生成软件中断:内核空间进入机器模式

机器模式的时钟中断向量是timervec。像是用户空间进入内核空间一样,当触发时钟中断后,将会跳转到timervec函数中(kernelvec.S),再最后通过mret命令再回到内核空间。

start函数中准备scratch区域保存一些寄存器,告诉 CLINT 何时产生下一个时钟中断,使RISC-V产生一个软件中断,恢复寄存器,然后返回。在定时器中断处理程序中没有 C 代码。

中断生成流程

  1. 读取mscratch寄存器地址到a0寄存器,将a1-a3寄存器保存到scratch前24字节中。
  2. 将scratch区域的后16字节(scratch[3],scratch[4])分别读取到a1、a2寄存器中。a1为、a2为中断间隔。
  3. 增加MTIMECMP寄存器,其实我也不太清楚这个寄存器有何用,上文可以猜测当时钟到达这个寄存器的值的时候会引起中断。
  4. 之后便是生成软件中断,主要是通过在M-mode设置S-mode的sip寄存器,开启SSIP位,也就代表有pending的中断,代表着生成了软件中断,这个中断也是会触发。
  5. 恢复中断前的a0-a1寄存器。
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
.globl timervec
.align 4
timervec:

csrrw a0, mscratch, a0
sd a1, 0(a0)
sd a2, 8(a0)
sd a3, 16(a0)

ld a1, 24(a0) #address of CLINT_MTIMECMP(hart)
ld a2, 32(a0) # interval
ld a3, 0(a1) #cpu=0 a3 = 2,002,681
add a3, a3, a2 # a3+interval
sd a3, 0(a1) #cpu=0 a3 = 3,002,681
#设置软件中断,返回中断处
li a1, 2
csrw sip, a1
#恢复中断前寄存器
ld a3, 16(a0)
ld a2, 8(a0)
ld a1, 0(a0)
csrrw a0, mscratch, a0

mret

中断处理

处理时钟中断产生的软件中断的代码可以在devintr(kernel/trap.c:204)中看到。在此之后可以看到w_sip(r_sip() & ~2)代码,这里便是完成了时钟中断后,sip的SSIP位清空,也就代表着pending的中断得到了处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
----trap.c
int
devintr()
{
uint64 scause = r_scause();

if((scause & 0x8000000000000000L) &&
(scause & 0xff) == 9){
....
} else if(scause == 0x8000000000000001L){
// software interrupt from a machine-mode timer interrupt,
// forwarded by timervec in kernelvec.S.
if(cpuid() == 0){
clockintr();
}

w_sip(r_sip() & ~2);

return 2;
}
}

通过判断scause寄存器,判断中断类型。可以发现是时钟中断,并调用时钟中断的处理函数clockintr。值得注意的是这个中断是再M-mode内生成的软件中断。

1
2
3
4
5
6
7
8
9
10
----trap.c
uint ticks;
void
clockintr()
{
acquire(&tickslock);
ticks++;
wakeup(&ticks);
release(&tickslock);
}

到此便介绍完了时钟中断,之部分内容xv6书对此介绍也少,所有只能给出自己的理解,想要更深入的了解可以去观察硬件驱动的参考文档。

总结

本章节是目前为止,是花费最多时间来介绍代码的内容。学习完本节内容熟悉一下几点:

  • 中断与设备驱动之间的概念与关系
  • 驱动程序的设计
  • 中断的产生与处理

因为涉及大量代码所有本节知识也需要自己花费时间让自己消化,建议使用GDB在interrupt handler处打上断点进行调试,尝试理解中断生成与处理的过程。