MIT 6.S081 教材第五章内容 -- 中断与设备驱动--下
- 引言
- 关于RISC-V特权级架构说明
- RISC-V特权模式
- OpenSBI介绍
- RISC-V启动过程
- RISC-V中的异常
- M模式下的异常
- 1. 硬件中断的处理(以时钟中断为例)
- 2. M模式下的异常相关寄存器
- 3. 同步异常的处理
- S模式下的异常
- 1. 委托机制
- 2. S模式下时钟中断处理流程
- 3. 中断前后硬件的自动转换
- 定时器中断
- 真实世界
引言
MIT 6.S081 2020 操作系统
本文为MIT 6.S081课程第五章教材内容翻译加整理。
本课程前置知识主要涉及:
- C语言(建议阅读C程序语言设计—第二版)
- RISC-V汇编
- 推荐阅读: 程序员的自我修养-装载,链接与库
关于RISC-V特权级架构说明
本部分内容主要参考: 浙大操作系统 Lab 1: RV64 内核引导
RISC-V指令集中有一类特殊寄存器CSRs(Control and Status Registers),这类寄存器存储了CPU的相关信息,只有特定的控制状态寄存器指令 (csrrc、csrrs、csrrw、csrrci、csrrsi、csrrwi等)才能够读写CSRs。
- 例如,保存
sepc
的值至内存时需要先使用相应的CSR指令将其读入寄存器,再通过寄存器保存该值,写入sepc时同理。
csrr t0, sepc
sd t0, 0(sp)
RISC-V特权模式
- RISC-V有三个特权模式:U(user)模式、S(supervisor)模式和M(machine)模式。
- 它通过设置不同的特权级别模式来管理系统资源的使用。
- 其中M模式是最高级别,该模式下的操作被认为是安全可信的,主要为对硬件的操作;
- U模式是最低级别,该模式主要执行用户程序,操作系统中对应于用户态;
- S模式介于M模式和U模式之间,操作系统中对应于内核态,当用户需要内核资源时,向内核申请,并切换到内核态进行处理。
- 其实还有一个H模式,用于为以后的虚拟化做准备,该层位于S和M层之间。
OpenSBI介绍
- SBI (Supervisor Binary Interface)是 S-Mode 的 kernel 和 M-Mode 执行环境之间的标准接口,而OpenSBI项目的目标是为在M模式下执行的平台特定固件提供RISC-V SBI规范的开源参考实现。为了使操作系统内核可以适配不同硬件,OpenSBI提出了一系列规范对m-mode下的硬件进行了抽象,运行在s-mode下的内核可以按照标准对这些硬件进行操作。
- OpenSBI:运行在m模式下的一套软件,提供接口给操作系统内核调用,以操作硬件,实现字符输出及时钟设定等工作。
- OpenSBI就是一个开源的RISC-V虚拟化二进制接口的通用的规范。
- SBI的野心很大,可以借助SBI让内核代码与底层硬件设施解耦,做到像JVM一样一次编译处处运行。
- SBI还可以帮助S态获取M态下的资源,例如读取mtime寄存器的值
- SBI还为虚拟化模式打下了基础
RISC-V启动过程
上图是RISC-V架构计算机的启动过程:
- ZSBL(Zeroth Stage Boot Loader):片上ROM程序,烧录在硬件上,是芯片上电后最先运行的代码。它的作用是加载FSBL到指定位置并运行。
- FSBL(First Stage Boot Loader ):启动PLLs和初始化DDR内存,对硬件进行初始化,加载下一阶段的bootloader。
- OpenSBI:运行在m模式下的一套软件,提供接口给操作系统内核调用,以操作硬件,实现字符输出及时钟设定等工作。OpenSBI就是一个开源的RISC-V虚拟化二进制接口的通用的规范。
- Bootloader:OpenSBI初始化结束后会通过mret指令将系统特权级切换到s模式,并跳转到操作系统内核的初始化代码。这一阶段,将会完成中断地址设置等一系列操作。之后便进入了操作系统。
更多内容可参考:
- An Introduction to RISC-V Boot Flow。
从ZSBL到OpenSBI运行这一阶段的工作已通过QEMU模拟器完成。运行QEMU时,我们使用-bios default选项将OpenSBI代码加载到
0x80000000起始处。OpenSBI初始化完成后,会跳转到0x80200000处。因此,我们所编译的代码需要放到0x80200000处。
RISC-V中的异常
本部分内容主要参考: 浙大操作系统 lab2 时钟中断处理
异常(trap)是指是不寻常的运行时事件,由硬件或软件产生,当异常产生时控制权将会转移至异常处理程序。异常是操作系统最基础的概念,一个没有异常的操作系统无法进行正常交互。
RISC-V将异常分为两类。一类是硬件中断(interrupt),它是与指令流异步的外部事件,比如鼠标的单击。另外一类是同步异常(exception),这类异常在指令执行期间产生,如访问了无效的存储器地址或执行了具有无效操作码的指令。
这里我们用异常(trap)作为硬件中断(interrupt)和同步异常(exception)的集合,另外trap指的是发生硬件中断或者同步异常时控制权转移到handler的过程。
后文统一用异常指代trap,中断/硬件中断指代interrupt,同步异常指代exception。
M模式下的异常
1. 硬件中断的处理(以时钟中断为例)
简单地来说,中断处理经过了三个流程:中断触发、判断处理还是忽略、可处理时调用处理函数。
-
中断触发:时钟中断的触发条件是这个hart(硬件线程)的时间比较器
mtimecmp
小于实数计数器mtime
。 -
判断是否可处理:
- 当时钟中断触发时,并不一定会响应中断信号。
- M模式只有在全局中断使能位
mstatus[mie]
置位时才会产生中断,如果在S模式下触发了M模式的中断,此时无视mstatus[mie]
直接响应,即运行在低权限模式下,高权限模式的全局中断使能位一直是enable状态。 - 此外,每个中断在控制状态寄存器
mie
中都有自己的使能位,对于特定中断来说,需要考虑自己对应的使能位,而控制状态寄存器mip
中又指示目前待处理的中断。 - 以时钟中断为例,只有当
mstatus[mie]
=1,mie[mtie]
=1,且mip[mtip]
=1时,才可以处理机器的时钟中断。其中mstatus[mie]
以及mie[mtie]
需要我们自己设置,而mip[mtip]
在中断触发时会被硬件自动置位。
-
调用处理函数:
- 当满足对应中断的处理条件时,硬件首先会发生一些状态转换,并跳转到对应的异常处理函数中,在异常处理函数中我们可以通过分析异常产生的原因判断具体为哪一种,然后执行对应的处理。
- 为了处理异常结束后不影响hart正常的运行状态,我们首先需要保存当前的状态即上下文切换。我们可以先用栈上的一段空间来把全部寄存器保存,保存完之后执行到我们编写的异常处理函数主体,结束后退出。
2. M模式下的异常相关寄存器
M模式异常需要使用的寄存器首先有lab1提到的mstatus
,mip
,mie
,mtvec
寄存器,这些寄存器需要我们操作;剩下还有mepc
,mcause
寄存器,这些寄存器在异常发生时硬件会自动置位,它们的功能如下:
mepc
:存放着中断或者异常发生时的指令地址,当我们的代码没有按照预期运行时,可以查看这个寄存器中存储的地址了解异常处的代码。通常指向异常处理后应该恢复执行的位置。mcause
:存储了异常发生的原因。mstatus
:Machine Status Register,其中m代表M模式。此寄存器中保持跟踪以及控制hart(hardware thread)的运算状态。通过对mstatus
进行位运算,可以实现对不同bit位的设置,从而控制不同运算状态。mie
、mip
:mie
以及mip
寄存器是Machine Interrup Registers,用来保存中断相关的一些信息,通过mstatus
上mie以及mip位的设置,以及mie
和mip
本身两个寄存器的设置可以实现对硬件中断的控制。注意mip位和mip
寄存器并不相同。mtvec
:Machine Trap-Vector Base-Address Register,主要保存M模式下的trap vector(可理解为中断向量)的设置,包含一个基地址以及一个mode。
与时钟中断相关的还有mtime
和mtimecmp
寄存器,它们的功能如下:
mtime
:Machine Time Register。保存时钟计数,这个值会由硬件自增。mtimecmp
:Machine Time Compare Register。保存需要比较的时钟计数,当mtime
的值大于或等于mtimecmp
的值时,触发时钟中断。
需要注意的是,mtime
和mtimecmp
寄存器需要用MMIO的方式即使用内存访问指令(sd,ld等)的方式交互,可以将它们理解为M模式下的一个外设。
事实上,异常还与mideleg
和medeleg
两个寄存器密切相关,它们的功能将在S模式下的异常部分讲解,主要用于将M模式的一些异常处理委托给S模式。
3. 同步异常的处理
同步异常的触发条件是当前指令执行了未经定义的行为,例如:
- Illegal instruction:跳过判断可以处理还是忽略的步骤,硬件会直接经历一些状态转换,然后跳到对应的异常处理函数。
- 环境调用同步异常ecall:主要在低权限的mode需要高权限的mode的相关操作时使用的,比如系统调用时U-mode call S-mode ,在S-mode需要操作某些硬件时S-mode call M-mode。
需要注意的是,不管是中断还是同步异常,都会经历相似的硬件状态转换,并跳到同一个异常处理地址(由mtvec
/stvec
寄存器指定),异常处理函数根据mcause
寄存器的值判断异常出现原因,针对不同的异常进行不同的处理。
S模式下的异常
由于hart位于S模式,我们需要在S模式下处理异常。这时首先要提到委托(delegation)机制。
1. 委托机制
RISC-V架构所有mode的异常在默认情况下都跳转到M模式处理。为了提高性能,RISC-V支持将低权限mode产生的异常委托给对应mode处理,该过程涉及了mideleg
和medeleg
这两个寄存器。
mideleg
:Machine Interrupt Delegation。该寄存器控制将哪些中断委托给S模式处理,它的结构可以参考mip
寄存器,如mideleg[5]
对应于 S模式的时钟中断,如果把它置位, S模式的时钟中断将会移交 S模式的异常处理程序,而不是 M模式的异常处理程序。medeleg
:Machine Exception Delegation。该寄存器控制将哪些同步异常委托给对应mode处理,它的各个位对应mcause
寄存器的返回值。
2. S模式下时钟中断处理流程
事实上,即使在mideleg
中设置了将S模式产生的时钟中断委托给S模式,委托仍未完成,因为硬件产生的时钟中断仍会发到M模式(mtime
寄存器是M模式的设备),所以我们需要手动触发S模式下的时钟中断。
此前,假设设置好[m|s]status
以及[m|s]ie
,即我们已经满足了时钟中断在两种mode下触发的使能条件。接下来一个时钟中断的委托流程如下:
- 当
mtimecmp
小于mtime
时,触发M模式时钟中断,硬件自动置位mip[mtip]
。 - 此时
mstatus[mie]
=1,mie[mtie]
=1,且mip[mtip]
=1 表示可以处理M模式的时钟中断。 - 此时hart发生了异常,硬件会自动经历状态转换,其中
pc
被设置为mtvec
的值,即程序将跳转到我们设置好的M模式处理函数入口。
(注:
pc
寄存器是用来存储指向下一条指令的地址,即下一步要执行的指令代码。)
- M模式处理函数将分析异常原因,判断为时钟中断,为了将时钟中断委托给S模式,于是将
mip[stip]
置位,并且为了防止在S模式处理时钟中断时继续触发M模式时钟中断,于是同时将mie[mtie]
清零。 - M模式处理函数处理完成并退出,此时
sstatus[sie]
=1,sie[stie]
=1,且sip[stip]
=1(由于sip是mip的子集,所以第4步中令mip[stip]
置位等同于将sip[stip]
置位),于是触发S模式的时钟中断。 - 此时hart发生了异常,硬件自动经历状态转换,其中
pc
被设置为stvec,即跳转到我们设置好的S模式处理函数入口。 - S模式处理函数分析异常原因,判断为时钟中断,于是进行相应的操作,然后利用
ecall
触发异常,跳转到M模式的异常处理函数进行最后的收尾。 - M模式异常处理函数分析异常原因,发现为ecall from S-mode,于是设置
mtimecmp
+=100000,将mip[stip]
清零,表示S模式时钟中断处理完毕,并且设置mie[mtie]
恢复M模式的中断使能,保证下一次时钟中断可以触发。 - 函数逐级返回,整个委托的时钟中断处理完毕。
3. 中断前后硬件的自动转换
当mtime
寄存器中的的值大于mtimecmp
时,sip[stip]
会被置位。此时,如果sstatus[sie]
与sie[stie]
也都是1,硬件会自动经历以下的状态转换(这里只列出S模式下的变化):
- 发生异常的时
pc
的值被存入sepc
,且pc
被设置为stvec
。 scause
按图 10.3根据异常类型设置,stval
被设置成出错的地址或者其它特定异 常的信息字。sstatus
CSR中的 SIE 位置零,屏蔽中断,且中断发生前的sstatus[sie]
会被存入sstatus[spie]
。- 发生异常时的权限模式被保存在
sstatus[spp]
,然后设置当前模式为 S模式。
在我们处理完中断或异常,并将寄存器现场恢复为之前的状态后,我们需要用sret
指令回到之前的任务中。sret
指令会做以下事情:
- 将
pc
设置为sepc
。 - 通过将
sstatus
的 SPIE域复制到sstatus[sie]
来恢复之前的中断使能设置。 - 并将权限模式设置为
sstatus[spp]
。
定时器中断
上面铺垫了很多,下面我们来看看xv6定时器中断时如何实现的吧。
Xv6使用定时器中断来维持其时钟,并使其能够在受计算量限制的进程(compute-bound processes)之间切换;usertrap
和kerneltrap
中的yield
调用会导致这种切换。定时器中断来自附加到每个RISC-V CPU上的时钟硬件。Xv6对该时钟硬件进行编程,以定期中断每个CPU。
RISC-V要求定时器中断在机器模式而不是管理模式下进行。
为什么明明设置了中断委托,定时器中断还是不能直接交给S态处理呢?
- 上面铺垫了很多,其中也说明了原因:
- 事实上,即使在
mideleg
中设置了将S模式产生的时钟中断委托给S模式,委托仍未完成,因为硬件产生的时钟中断仍会发到M模式(mtime
寄存器是M模式的设备),所以我们需要手动触发S模式下的时钟中断。
RISC-V机器模式无需分页即可执行,并且有一组单独的控制寄存器,因此在机器模式下运行普通的xv6内核代码是不实际的。因此,xv6处理定时器中断完全不同于上面列出的陷阱机制。
机器模式下执行的代码位于main
之前的start.c中,它设置了接收定时器中断(kernel/start.c:57)。工作的一部分是对CLINT(core-local interruptor)硬件编程,以在特定延迟后生成中断。另一部分是设置一个scratch区域,类似于trapframe,以帮助定时器中断处理程序保存寄存器和CLINT寄存器的地址。最后,start
将mtvec
设置为timervec
,并使能定时器中断。
- timerinit函数负责完成对定时器模块的初始化
// set up to receive timer interrupts in machine mode,
// which arrive at timervec in kernelvec.S,
// which turns them into software interrupts for
// devintr() in trap.c.
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;
// prepare information in scratch[] for timervec.
// scratch[0..3] : space for timervec to save registers.
// scratch[4] : address of CLINT MTIMECMP register.
// scratch[5] : desired interval (in cycles) between timer interrupts.
uint64 *scratch = &mscratch0[32 * id];
scratch[4] = CLINT_MTIMECMP(id);
scratch[5] = interval;
//指向定时器中断上下文保存地址
w_mscratch((uint64)scratch);
// set the machine-mode trap handler.
//设置m态下时钟中断处理器函数的地址
w_mtvec((uint64)timervec);
// enable machine-mode interrupts.
w_mstatus(r_mstatus() | MSTATUS_MIE);
// enable machine-mode timer interrupts.
// 要开启m态下的时钟中断
w_mie(r_mie() | MIE_MTIE);
}
计时器中断可能发生在用户或内核代码正在执行的任何时候;内核无法在临界区操作期间禁用计时器中断。因此,计时器中断处理程序必须保证不干扰中断的内核代码。基本策略是处理程序要求RISC-V发出“软件中断”并立即返回。RISC-V用普通陷阱机制将软件中断传递给内核,并允许内核禁用它们。处理由定时器中断产生的软件中断的代码可以在devintr
(kernel/trap.c:204)中看到。
机器模式定时器中断向量是timervec
(kernel/kernelvec.S:93)。它在start
准备的scratch区域中保存一些寄存器,以告诉CLINT何时生成下一个定时器中断,要求RISC-V引发软件中断,恢复寄存器,并且返回。定时器中断处理程序中没有C代码。
mret返回S态后,触发S态下的软件中断,然后进入S态Trap处理流程,最终由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.
// 读取claim寄存器,以获取待处理的中断源
int irq = plic_claim();
// 判断是否是uart外部中断源
if(irq == UART0_IRQ){
uartintr();
} else if(irq == VIRTIO0_IRQ){
// 为磁盘外部中断源
virtio_disk_intr();
} else if(irq){
// 暂不支持的中断源
printf("unexpected interrupt irq=%d\n", irq);
}
// the PLIC allows each device to raise at most one
// interrupt at a time; tell the PLIC the device is
// now allowed to interrupt again.
// 更新中断不再是待处理状态,而是已经处理完毕
if(irq)
plic_complete(irq);
return 1;
} else if(scause == 0x8000000000000001L){
// software interrupt from a machine-mode timer interrupt,
// forwarded by timervec in kernelvec.S.
// m态下的时钟中断
if(cpuid() == 0){
//时钟中断处理
clockintr();
}
// acknowledge the software interrupt by clearing
// the SSIP bit in sip.
// S态下手动清除SSIP,避免软件中断重复触发
w_sip(r_sip() & ~2);
//返回2,随后在usertrap中检测到是时钟中断,通过yiled完成调度
return 2;
} else {
return 0;
}
}
void
clockintr()
{
acquire(&tickslock);
//记录时钟中断触发次数
ticks++;
//唤醒调用Sleep阻塞的进程
wakeup(&ticks);
release(&tickslock);
}
// Wake up all processes sleeping on chan.
// Must be called without any p->lock.
void
wakeup(void *chan)
{
struct proc *p;
for(p = proc; p < &proc[NPROC]; p++) {
acquire(&p->lock);
if(p->state == SLEEPING && p->chan == chan) {
p->state = RUNNABLE;
}
release(&p->lock);
}
M态的时钟中断处理程序中没有直接设置STIP,是因为S态不能直接清除STIP,所以改用软件中断的方式,也就是SSIP,因为S态下可以写入SSIP。
真实世界
Xv6允许在内核中执行时以及在执行用户程序时触发设备和定时器中断。定时器中断迫使定时器中断处理程序进行线程切换(调用yield
),即使在内核中执行时也是如此。如果内核线程有时花费大量时间计算而不返回用户空间,则在内核线程之间公平地对CPU进行时间分割的能力非常有用。然而,内核代码需要注意它可能被挂起(由于计时器中断),然后在不同的CPU上恢复,这是xv6中一些复杂性的来源。如果设备和计时器中断只在执行用户代码时发生,内核可以变得简单一些。
在一台典型的计算机上支持所有设备是一项艰巨的工作,因为有许多设备,这些设备有许多特性,设备和驱动程序之间的协议可能很复杂,而且缺乏文档。在许多操作系统中,驱动程序比核心内核占用更多的代码。
UART驱动程序读取UART控制寄存器,一次检索一字节的数据;因为软件驱动数据移动,这种模式被称为程序I/O(Programmed I/O)。程序I/O很简单,但速度太慢,无法在高数据速率下使用。需要高速移动大量数据的设备通常使用直接内存访问(DMA)。DMA设备硬件直接将传入数据写入内存,并从内存中读取传出数据。现代磁盘和网络设备使用DMA。DMA设备的驱动程序将在RAM中准备数据,然后使用对控制寄存器的单次写入来告诉设备处理准备好的数据。
当一个设备在不可预知的时间需要注意时,中断是有意义的,而且不是太频繁。但是中断有很高的CPU开销。因此,如网络和磁盘控制器的高速设备,使用一些技巧减少中断需求。一个技巧是对整批传入或传出的请求发出单个中断。另一个技巧是驱动程序完全禁用中断,并定期检查设备是否需要注意。这种技术被称为轮询(polling)。如果设备执行操作非常快,轮询是有意义的,但是如果设备大部分空闲,轮询会浪费CPU时间。一些驱动程序根据当前设备负载在轮询和中断之间动态切换。
UART驱动程序首先将传入的数据复制到内核中的缓冲区,然后复制到用户空间。这在低数据速率下是可行的,但是这种双重复制会显著降低快速生成或消耗数据的设备的性能。一些操作系统能够直接在用户空间缓冲区和设备硬件之间移动数据,通常带有DMA。