notebook notebook
首页
  • 计算机网络
  • 计算机系统
  • 数据结构与算法
  • 计算机专业课
  • 设计模式
  • 前端 (opens new window)
  • Java 开发
  • Python 开发
  • Golang 开发
  • Git
  • 软件设计与架构
  • 大数据与分布式系统
  • 常见开发工具

    • Nginx
  • 爬虫
  • Python 数据分析
  • 数据仓库
  • 中间件

    • MySQL
    • Redis
    • Elasticsearch
    • Kafka
  • 深度学习
  • 机器学习
  • 知识图谱
  • 图神经网络
  • 应用安全
  • 渗透测试
  • Linux
  • 云原生
面试
  • 收藏
  • paper 好句
GitHub (opens new window)

学习笔记

啦啦啦,向太阳~
首页
  • 计算机网络
  • 计算机系统
  • 数据结构与算法
  • 计算机专业课
  • 设计模式
  • 前端 (opens new window)
  • Java 开发
  • Python 开发
  • Golang 开发
  • Git
  • 软件设计与架构
  • 大数据与分布式系统
  • 常见开发工具

    • Nginx
  • 爬虫
  • Python 数据分析
  • 数据仓库
  • 中间件

    • MySQL
    • Redis
    • Elasticsearch
    • Kafka
  • 深度学习
  • 机器学习
  • 知识图谱
  • 图神经网络
  • 应用安全
  • 渗透测试
  • Linux
  • 云原生
面试
  • 收藏
  • paper 好句
GitHub (opens new window)
  • csapp

  • MIT 6.S081

    • Introduction and Examples
    • OS Organization and System Calls
    • Page tables
    • Calling conventions and stack frames RISC-V
    • Trap
    • Page faults
    • Interrupts
      • 1. 真实操作系统内存的使用情况
      • 2. Interrupt 硬件部分
        • 2.1 什么是中断
        • 2.2 中断的产生
      • 3. 设备驱动概述
        • 3.1 什么是驱动
        • 3.2 如何对设备进行编程
      • 4. 在 XV6 中设置中断
        • 4.1 $ 的显示
        • 4.2 ls 的显示
        • 4.3 与中断相关的寄存器
        • 4.4 编程
      • 5. UART 驱动的 top 部分
      • 6. UART 驱动的 bottom 部分
      • 7. Interrupt 相关的并发
      • 8. UART 读取键盘输入
      • 9. Interrupt 的演进
    • Multiprocessors and locking
    • Thread switching
    • Sleep & Wake up
  • 计算机系统
  • MIT 6.S081
yubin
2024-02-03
目录

Interrupts

# 1. 真实操作系统内存的使用情况

在介绍本节课的主题 interrupt 之前,先来看一下内存是如何被真实的 OS 使用的。

下面是一个 Linux 服务器的 top 指令输出,如果你查看 Mem 这一行:

image-20240204131545761

首先是计算机总共内存量(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 个小的差别:

  1. asynchronous。当硬件生成中断时,interrupt handler 与当前运行的进程在 CPU 状态上没有任何关联,但如果是 system call 却是发生在运行进程的 context 下。
  2. concurrency。对于中断来说,CPU 和生成中断的设备是并行运行。网卡自己独立的处理来自网络的 packet,然后在某个时间点产生中断,但是同时,CPU 也在运行。所以我们在 CPU 和设备之间是真正的并行的,我们必须管理这里的并行。
  3. program device。我们这节课主要关注外部设备,例如网卡,UART,而这些设备需要被编程。每个设备都有一个编程手册,就像 RISC-V 有一个包含了指令和寄存器的手册一样。设备的编程手册包含了它有什么样的寄存器,它能执行什么样的操作,在读写控制寄存器的时候,设备会如何响应。不过通常来说,设备的手册不如 RISC-V 的手册清晰,这会使得对于设备的编程会更加复杂。

这节课的内容主要在于讨论:

  • console 中的提示符 $ 是如何显示出来的
  • 如果你在键盘输入 ls,这些字符是怎么最终在 console 中显示出来的

# 2.2 中断的产生

我们首先要关心的是,中断是从哪里产生的?

因为我们主要关心的是外部设备的中断,而不是定时器中断或者软件中断。外设中断来自于主板上的设备,下图是一个 SiFive 主板,如果你查看这个主板,你可以发现有大量的设备连接在或者可以连接到这个主板上。

image-20240204140247475

主板可以连接以太网卡,MicroUSB,MicroSD 等,主板上的各种线路将外设和CPU连接在一起。这节课的大部分内容都会介绍当设备产生中断时CPU会发生什么,以及如何从设备读写数据。

下图是来自于SiFive有关处理器的文档,图中的右侧是各种各样的设备,例如 UART0。我们在之前的课程已经知道 UART0 会映射到内核内存地址的某处,而所有的物理内存都映射在地址空间的 0x80000000 之上。类似于读写内存,通过向相应的设备地址执行 load/store 指令,我们就可以对例如 UART 的设备进行编程。

image-20240204140443353

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

image-20240204140940283

从左上角可以看出,我们有 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 解耦开来。

image-20240204145327340

通常对于 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 主板中的对应设备的物理地址:

image-20240204150159919

例如,0x200_0000 对应 CLINT,0xC000000 对应的是 PLIC。在这个图中 UART0 对应的是 0x1001*_*0000,但是在 QEMU 中,我们的 UART0 的地址略有不同,因为在 QEMU 中我们并不是完全的模拟 SiFive 主板,而是模拟与 SiFive 主板非常类似的东西。

以上就是 Memory-mapped IO。

下图是 UART 的文档。16550 是QEMU模拟的 UART 设备,QEMU 用这个模拟的设备来与键盘和 Console 进行交互:

image-20240204150402841

这是一个很简单的芯片,图中表明了芯片拥有的寄存器。例如对于控制寄存器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");
}
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

这里将所有的中断都设置在 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 {
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

我们第一个外设是 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;
}
1
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");
}
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

这里的流程是先关闭中断,之后设置波特率,设置字符长度为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;
}
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;
}
1
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);
}
1
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);
    }
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;
}
1
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);
}
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);
}
1
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){
1
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;
}
1
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:

image-20240204165926071

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

在我们的例子中,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);
  }
}
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

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;
  }
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);
    }
1
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;
}
1
2
3
4
5
6
7
8

从 devintr 函数可以看出,如果是 UART 中断,那么会调用 uartintr 函数。位于 uart.c 文件的 uartintr 函数,会从 UART 的接受寄存器中读取数据,之后将获取到的数据传递给 consoleintr 函数。哦,不好意思,我搞错了。我们现在讨论的是向 UART 发送数据。因为我们现在还没有通过键盘输入任何数据,所以 UART 的接受寄存器现在为空。

image-20240204174621167

所以代码会直接运行到uartstart函数,这个函数会将Shell存储在buffer中的任意字符送出。实际上在提示符“之后,还会输出一个空格字符,系统调用可以在发送提示符”的同时,并发的将空格字符写入到buffer中。所以UART的发送中断触发时,可以发现在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 字节大小。并且有两个指针,分别是读指针和写指针:

image-20240204175547769

如果两个指针相等,那么 buffer 是空的。当 Shell 调用 uartputc 函数时,会将字符,例如提示符“$”,写入到写指针的位置,并将写指针加 1。这就是 producer 对于 buffer 的操作:

image-20240204175629622

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

在我们的例子中,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;
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

这里与 UART 类似,也有一个 buffer,包含了 128 个字符。其他的基本一样,也有 producer 和 consumser。但是在这个场景下 Shell 变成了 consumser,因为 Shell 是从 buffer 中读取数据。而键盘是 producer,它将数据写入到 buffer 中。

image-20240204180222881

从 consoleread 函数中可以看出,当读指针和写指针一样时,说明 buffer 为空,进程会 sleep。所以 Shell 在打印完“$ ”之后,如果键盘没有输入,Shell 进程会 sleep,直到键盘有一个字符输入。所以在某个时间点,假设用户通过键盘输入了“l”,这会导致“l”被发送到主板上的 UART 芯片,产生中断之后再被 PLIC 路由到某个 CPU 核,之后会触发 devintr 函数,devintr 可以发现这是一个 UART 中断,然后通过 uartgetc 函数获取到相应的字符,之后再将字符传递给 consoleintr 函数。

image-20240204180326864

默认情况下,字符会通过 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)。

编辑 (opens new window)
上次更新: 2024/02/04, 10:24:37
Page faults
Multiprocessors and locking

← Page faults Multiprocessors and locking→

最近更新
01
Deep Reinforcement Learning
10-03
02
误删数据后怎么办
04-06
03
MySQL 一主多从
03-22
更多文章>
Theme by Vdoing | Copyright © 2021-2024 yubincloud | MIT License
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式
×