rust写个操作系统——课程实验blogos移至armV8深度解析:实验四 中断
你将在每个实验对应分支上都看到这句话,确保作者实验代码在被下载后,能在正确的环境中运行。
运行环境请参考: lab1 环境搭建
cargo build |
实验四 中断
中断、异常和陷阱指令是操作系统的基石,现代操作系统就是由中断驱动的。实验四的目的在于深刻理解中断的原理和机制,掌握CPU访问设备控制器的方法,掌握Arm体系结构的中断机制和规范,实现时钟中断服务和部分异常处理等。
中断是一种硬件机制。借助于中断,CPU可以不必再采用轮询这种低效的方式访问外部设备。将所有的外部设备与CPU直接相连是不现实的,外部设备的中断请求一般经由中断控制器,由中断控制器仲裁后再转发给CPU。Arm采用的中断控制器叫做GIC,即general interrupt controller。gic包括多个版本,如GICv1(已弃用),GICv2,GICv3,GICv4。简单起见,我们实验将选用GICv2版本。
为了配置好gicv2中断控制器,我们需要阅读其技术参考手册,以及上一个实验中讲到的设备树中关于gic的内存映射范围、中断基本说明,为gic编写内核驱动。
另外,为了检验我们中断的成功运行,我们在这节实验中也一并为linux高精度计时器timer编写应用。timer的精确计时依赖着系统的时钟中断,可以作为中断发生的检验方式。
实验目的
实验指导书是这么写的:
本实验的目的在于深刻理解中断的原理和机制,掌握CPU访问设备控制器的方法,掌握Arm体系结构的中断机制和规范,实现时钟中断服务和部分异常处理等。
但实际上,异常处理是在实验五才进行处理的,这个实验指编写了异常发生时的回调函数规范。因此实验目的如下:
理解中断原理和机制
掌握CPU访问设备控制器(这里是
GIC)的方法,即为设备编写驱动和初始化等基本方法掌握Arm体系结构的中断机制和规范,即定义异常向量表
掌握异常回调函数的写法
了解
timer计时器原理,实现时间中断服务。
中断原理
中断是什么
中断是一种硬件机制。简单的说,在cpu执行程序的过程中,突然发生异常 (包括复位、指令错误等等异常,中断只是异常其中一种),可以打断当前正在执行的程序,临时先处理比较紧急的事情,当处理完成了,再回到原来的程序继续执行。
借助于中断,CPU可以不必再采用轮询这种低效的方式访问外部设备。将所有的外部设备与CPU直接相连是不现实的,外部设备的中断请求一般经由中断控制器,由中断控制器仲裁后再转发给CPU。如下图所示Arm的中断系统。

其中nIRQ是普通中断,nFIQ是快速中断。 Arm采用的中断控制器叫做GIC,即general interrupt controller。
中断如何发生
首先,在一个cpu中 中断源有很多(比如gpio中断、定时器中断等等),那么为了管理这些中断,就需要一个中断控制器。
当发生中断时,相应的中断源会给中断控制器发出信号,中断控制器再给cpu发信号,最后cpu处理中断。
中断的大概流程
初始化:
使能中断源(允许发生中断)
中断控制器可以选择屏蔽或不屏蔽中断,设置中断优先级等
cpu 使能中断总开关
中断跳转:
cpu 每执行完一条指令就会查看有无异常发生
发生异常,cpu 分辩中断源
cpu 被强制跳转到中断向量表(汇编)中的跳转地址
跳转到相应的中断服务函数
中断处理回调函数:
保护现场,保证当前执行的程序能完好返回(存储指令寄存器,以及存数据的寄存器等等各种寄存器,会采用压栈的方式)
获取中断id(a7 架构,其中可能还需要切换处理器模式等等),根据id跳转到对应的中断处理函数。
中断处理函数可以是我们自己编写的,代表的是中断发生后要处理的事情
处理完成,返回中断服务函数。
还原现场(将各个寄存器的值还回,指令寄存器需要-4后再返回,这里涉及到arm处理器的3级指令流水线,不做细讲)
GIC内核驱动编写及调用
在实现我们的中断控制器驱动前,首先还是要先了解GIC。由于实验中只需要实现GICv2,故在此只对GICv2进行介绍。
中断控制器GICv2
GIC 是联系外设中断和 CPU 的桥梁,也是各 CPU 之间中断互联的通道(也带有管理功能),它负责检测、管理、分发中断,可以做到:
1、使能或禁止中断;
2、把中断分组到Group0还是Group1(Group0作为安全模式使用连接FIQ ,Group1 作为非安全模式使用,连接IRQ );
3、多核系统中将中断分配到不同处理器上;
4、设置电平触发还是边沿触发方式(不等于外设的触发方式);
5、虚拟化扩展。
ARM CPU 对外的连接只有2 个中断: IRQ和FIQ ,相对应的处理模式分别是一般中断(IRQ )处理模式和快速中断(FIQ )处理模式。所以GIC 最后要把中断汇集成2 条线,与CPU 对接。
而在我们的实验中无需实现这么多功能。qemu模拟的virt机器作为单核系统,是不需要作过多的考虑的。而虚拟化扩展更非我们需要考虑实现的功能。
在gicv2中,gic由两个大模块distributor和interface组成:
distributor:主要负责中断源的管理、优先级、中断使能、中断屏蔽等,如下:
中断分发,对于PPI,SGI是各个core独有的中断,不参与目的core的仲裁,SPI,是所有core共享的,根据配置决定中断发往的core。
中断优先级的处理,将最高优先级中断发送给cpu interface。
**寄存器使用 GICD_ 作为前缀。一个gic中,只有一个GICD。**
cpu interface:要用于连接处理器,与处理器进行交互。将GICD发送的中断信息,通过IRQ,FIQ管脚,传输给core。
寄存器使用 GICC_ 作为前缀。每一个core,有一个cpu interface。
另外还有专门服务于虚拟中断的
virtual cpu interface,这里并不考虑。
gic中断分发器(Distributor)
分发器的主要的作用是检测各个中断源的状态,控制各个中断源的行为,分发各个中断源产生的中断事件到指定的一个或者多个CPU接口上。虽然分发器可以管理多个中断源,但是它总是把优先级最高的那个中断请求送往CPU接口。分发器对中断的控制包括:
打开或关闭每个中断。Distributor对中断的控制分成两个级别。一个是全局中断的控制
(GIC_DIST_CTRL)。一旦关闭了全局的中断,那么任何的中断源产生的中断事件都不会被传递到 CPU interface。另外一个级别是对针对各个中断源进行控制(GIC_DIST_ENABLE_CLEAR),关闭某一个中断源会导致该中断事件不会分发到 CPU interface,但不影响其他中断源产生中断事件的分发。控制将当前优先级最高的中断事件分发到一个或者一组
CPU interface。当一个中断事件分发到多个CPU interface的时候,GIC 的内部逻辑应该保证只assert一个CPU。优先级控制。
interrupt属性设定。设置每个外设中断的触发方式:电平触发、边缘触发。interrupt group的设定。设置每个中断的 Group,其中 Group0 用于安全中断,支持 FIQ 和 IRQ,Group1 用于非安全中断,只支持 IRQ。将SGI中断分发到目标CPU上。
每个中断的状态可见。
提供软件机制来设置和清除外设中断的pending状态。
gic中断接口(cpu interface)
CPU接口主要用于和CPU进行接口。主要功能包括:
打开或关闭
CPU interface向连接的CPU assert中断事件。对于 ARM,CPU interface 和 CPU 之间的中断信号线是 nIRQCPU 和 nFIQCPU。如果关闭了中断,即便是 Distributor 分发了一个中断事件到 CPU interface,也不会 assert 指定的 nIRQ 或者 nFIQ 通知 Core。中断的确认。Core 会向
CPU interface应答中断(应答当前优先级最高的那个中断),中断一旦被应答,Distributor就会把该中断的状态从pending修改成active或者pending and active(这是和该中断源的信号有关,例如如果是电平中断并且保持了该asserted电平,那么就是pending and active)。ack 了中断之后,CPU interface 就会 deassertnIRQCPU和nFIQCPU信号线。中断处理完毕的通知。当
interrupt handler处理完了一个中断的时候,会向写CPU interface的寄存器通知 GIC CPU 已经处理完该中断。做这个动作一方面是通知Distributor将中断状态修改为deactive,另外一方面,CPU interface 会 priority drop,从而允许其他的 pending 的中断向 CPU 提交。为 CPU 设置中断优先级掩码。通过
priority mask,可以屏蔽掉一些优先级比较低的中断,这些中断不会通知到 CPU。设置 CPU 的中断抢占(preemption)策略。
在多个中断事件同时到来的时候,选择一个优先级最高的通知 CPU。
关于gicv2就先介绍这么多。接下来我们开始一边实现我们需要实现的部分,一边继续介绍gicv2的细节。
gicv2内核驱动
寄存器定义
编写驱动首先需要对寄存器以一些常量化的方式表示,以便我们更好的调用。阅读设备树中关于gicv2部分的代码:
intc@8000000 { |
其中reg一行约定了gic的寄存器在内存中的映射范围,并结合gicv2的文档ARM Generic Interrupt Controller可知:
GICD寄存器说明中:
reg = <0x00 0x8000000 0x00 0x10000 0x00 0x8010000 0x00 0x10000>;
约定:GICD寄存器映射到内存的位置为0x8000000,长度为0x10000, GICC寄存器映射到内存的位置为0x8010000,长度为0x10000
GICD中断说明中:
#interrupt-cells = <0x03>;
结合文档可知:约定:第一个cell为中断类型,0表示SPI,1表示PPI;第二个cell为中断号,SPI范围为
[0-987],PPI为[0-15];第三个cell为flags,其中[3:0]位表示触发类型,4表示高电平触发,[15:8]为PPI的cpu中断掩码,每1位对应一个cpu,为1表示该中断会连接到对应的cpu。
由此我们知道了gicv2的寄存器基址及其范围。阅读文档ARM Generic Interrupt Controller Architecture version 2.0 - Architecture Specification,可知寄存器的相对基址的映射地址及其功能。
其中寄存器表如下:
GICD部分寄存器(文档P75):

新建
src/interrupts.rs文件,定义寄存器表如下://GICD寄存器基址
const GICD_BASE: u64 = 0x08000000;
//GICD实验所需寄存器
const GICD_CTLR: *mut u32 = (GICD_BASE + 0x0) as *mut u32;
const GICD_ISENABLER: *mut u32 = (GICD_BASE + 0x0100) as *mut u32;
// const GICD_ICENABLER: *mut u32 = (GICD_BASE + 0x0180) as *mut u32;(此寄存器用于中断disable,此实验并未使用该函数,故注释
const GICD_ICPENDR: *mut u32 = (GICD_BASE + 0x0280) as *mut u32;
const GICD_IPRIORITYR: *mut u32 = (GICD_BASE + 0x0400) as *mut u32;
const GICD_ICFGR: *mut u32 = (GICD_BASE + 0x0c00) as *mut u32;
//GICD常量值
const GICD_CTLR_ENABLE: u32 = 1; // Enable GICD
const GICD_CTLR_DISABLE: u32 = 0; // Disable GICD
const GICD_ISENABLER_SIZE: u32 = 32;
// const GICD_ICENABLER_SIZE: u32 = 32; 注释理由同上
const GICD_ICPENDR_SIZE: u32 = 32;
const GICD_IPRIORITY_SIZE: u32 = 4;
const GICD_IPRIORITY_BITS: u32 = 8;
const GICD_ICFGR_SIZE: u32 = 16;
const GICD_ICFGR_BITS: u32 = 2;GICC部分寄存器(文档P76)

继续编辑
src/interrupts.rs文件,定义寄存器表如下://GICC寄存器基址
const GICD_BASE: u64 = 0x08010000;
//GICC实验所需寄存器
const GICC_CTLR: *mut u32 = (GICC_BASE + 0x0) as *mut u32;
const GICC_PMR: *mut u32 = (GICC_BASE + 0x0004) as *mut u32;
const GICC_BPR: *mut u32 = (GICC_BASE + 0x0008) as *mut u32;
//GICC常量值
const GICC_CTLR_ENABLE: u32 = 1; // Enable GICC
const GICC_CTLR_DISABLE: u32 = 0; // Disable GICC
const GICC_PMR_PRIO_LOW: u32 = 0xff; // 优先级掩码寄存器,中断优先级过滤器,较高优先级对应较低优先级字段值。
const GICC_BPR_NO_GROUP: u32 = 0x00; // 优先级分组是将GICC_BPR(Binary PointRegister)分为两个域,组优先级(group priority)和组内优先级(subpriority)。当决定抢占(Preemption)的时候,组优先级相同的中断被视为一样的,不考虑组内优先级。那就意味着在每个优先级组内只能有一个中断被激活。组优先级又被称为抢占级别(preemption level)。这里令其无组优先级。
GIC初始化
阅读文档(P77)的4.1.5节,可以看到如何对GIC的初始化启用。在这我们以一个对于rust而言不安全的方式(直接写入寄存器)来实现
use core::ptr; |
先禁用gicv2再进行初始化配置,是为了避免上一次的关机未对gicv2禁用后对初始化造成的影响。当对寄存器做好配置后我们再启用它。
对GICC_PMR优先级掩码寄存器配置初始值0xff。通过该寄存器中的值,可以屏蔽低优先级中断,这样它们就永远不会被触发,我们设置0xff,由于值0xff对应于最低优先级,0x00对应于最高优先级,故为接受所有中断。而对GICC_BPR设置为0,则最高优先级的挂起中断将被传递给处理器,而不考虑组优先级。
GIC相关函数
对于某个中断号,我们本身需要有多种函数对其作相应的处理。继续向代码中添加如下内容:
// 使能中断号为interrupt的中断 |
由于disable函数在本实验从未使用过,未避免rust安全性报错/警告,这里选择注释。enable函数则参照文档P93中4.3.5节编写,disable函数参照4.3.6节,clear函数参照4.3.8节,set_priority函数参照4.3.11节,set_config函数参照4.3.13节。具体不在这里说明。
自此,我们已经基本完成了一个简略版的gicv2内核驱动,基本上可以满足实验的需求。
ArmV8中断机制及异常回调
ARMv8 架构定义了两种执行状态(Execution States),AArch64 和 AArch32。分别对应使用64位宽通用寄存器或32位宽通用寄存器的执行。
上图所示为AArch64中的异常级别(Exception levels)的组织。可见AArch64中共有4个异常级别,分别为EL0,EL1,EL2和EL3。在AArch64中,Interrupt是Exception的子类型,称为异常。 AArch64 中有四种类型的异常:
Sync(Synchronous exceptions,同步异常),在执行时触发的异常,例如在尝试访问不存在的内存地址时。
IRQ (Interrupt requests,中断请求),由外部设备产生的中断
FIQ (Fast Interrupt Requests,快速中断请求),类似于IRQ,但具有更高的优先级,因此 FIQ 中断服务程序不能被其他 IRQ 或 FIQ 中断。
SError (System Error,系统错误),用于外部数据中止的异步中断。
当异常发生时,处理器将执行与该异常对应的异常处理代码。在ARM架构中,这些异常处理代码将会被保存在内存的异常向量表中。每一个异常级别(EL0,EL1,EL2和EL3)都有其对应的异常向量表。需要注意的是,与x86等架构不同,该表包含的是要执行的指令,而不是函数地址 3 。
异常向量表的基地址由VBAR_ELn给出,然后每个表项都有一个从该基地址定义的偏移量。 每个表有16个表项,每个表项的大小为128(0x80)字节(32 条指令)。 该表实际上由4组,每组4个表项组成。 分别是:
发生于当前异常级别的异常且SPSel寄存器选择SP0 4 , Sync、IRQ、FIQ、SError对应的4个异常处理。
发生于当前异常级别的异常且SPSel寄存器选择SPx 4 , Sync、IRQ、FIQ、SError对应的4个异常处理。
发生于较低异常级别的异常且执行状态为AArch64, Sync、IRQ、FIQ、SError对应的4个异常处理。
发生于较低异常级别的异常且执行状态为AArch32, Sync、IRQ、FIQ、SError对应的4个异常处理。
异常向量表
阅读AArch64 Exception and Interrupt Handling可得知以下异常向量表的地址定义:

故我们新建src/exceptions.s,并定义异常向量表如下:
.section .text.exceptions_vector_table |
并定义异常向量表使用的EXCEPTION_VECTOR宏和宏中用的.exit_exception函数:
.equ CONTEXT_SIZE, 264 |
并处理链接脚本aarch64-qemu.ld,为在src/exceptions.s中所定义的exceptions_vector_table选择位置,同时满足其4K对齐要求。
.text : |
然后在src/start.s中载入异常向量表exception_vector_table
.section ".text.boot" |
异常处理回调函数
在exceptions.s中我们定义了EXCEPTION_VECTOR宏。在其中,每一类中断都对应一个处理函数,以el1_sp0_sync为例,其代码如下:
const EL1_SP0_SYNC: &'static str = "EL1_SP0_SYNC"; |
此处还算不上处理,准确的说是定义了一个函数来作为异常发生时的应答,具体如何处理我们将在下一个实验中看到。
完整的各类处理应答如下:在src/interrupts.rs中新增如下代码:
global_asm!(include_str!("exceptions.s")); |
至此,我们已经在EL1级别定义了完整的中断处理框架,可以开始处理实际的中断了。
Timer计时器的原理和时钟中断服务实现
Timer计时器介绍
任何AArch64 CPU都应该有一个通用计时器,但是有些板也可以包含外部计时器。arm架构对应的timer文档在https://developer.arm.com/documentation/102379/0000/What-is-the-Generic-Timer-?lang=en,里面介绍了timer通用计时器的一些说明。参照设备树中timer的部分
timer { |
设备树中说明,timer设备中包括4个中断。以第二个中断的参数0x01 0x0e 0x104为例,其指明该中断为PPI类型的中断,中断号14, 路由到第一个cpu,且高电平触发。但注意到PPI的起始中断号为16,所以实际上该中断在GICv2中的中断号应为16 + 14 = 30。我们将基于此,实现计时器触发中断。
这里也简单介绍一下timer的计时器:
在ARM体系结构中,处理器内部有通用计时器,通用计时器包含一组比较器,用来与系统计数器进行比较,一旦通用计时器的值小于等于系统计数器时便会产生时钟中断。timer寄存器如下:
CNTPCT_EL0- physical counter value register
CNTP_CTL_EL0- physical counter control register
CNTP_TVAL_EL0 and CNTP_CVAL_EL0- two threshold value registers, 定时寄存器(TVAL) and 比较寄存器(CVAL)
CNTFRQ_EL0- counter frequency register
对于系统计数器来说,可以通过读取控制寄存器CNTPCT_EL0来获得当前的系统计数值(无论处于哪个异常级别)
比较寄存器有64位,如果设置了之后,当系统计数器达到或超过了这个值之后(CVAL<系统计数器),就会触发定时器中断。
定时寄存器有32位,如果设置了之后,会将比较寄存器设置成当前系统计数器加上设置的定时寄存器的值(CVAL=系统计数器+TVAL)
每组定时器都还有一个控制寄存器(CTL),其只有最低三位有意义,其它的60位全是保留的,设置成0.
0:ENABLE:是否打开定时器,使其工作;
1:IMASK:中断掩码,如果设置成1,则即使定时器是工作的,仍然不会发出中断;
2:ISTATUS:如果定时器打开的话,且满足了触发条件,则将这一位设置成1。
原理上讲,我们只需要在时钟开始时对定时器进行一次初始化,而在计时时间到达时,系统将会触发一次时钟中断,从而引发一次el1_irq异常。之后相对应的异常回调函数将调用输出,打印异常。
时钟中断服务
了解了原理之后,我们尝试实现时钟中断。我们首先需要在系统启动时进行初始化,启用定时器并启用时钟中断(设置控制寄存器),然后设置定时。我们在src/interrupts.rs文件的init_gicv2初始化函数中新增如下内容:
// 电平触发 |
在这里我们将这个函数设置成为了低电平触发,所以我们在主函数调用时需要将系统转入低电平的运行状态。
这里的wfi是使系统进入休眠状态,这里我们并不需要,低电平触发是指系统的每一个指令周期结束时触发。
由于我们的系统一执行完输出就结束了,我们希望它能够保持开机状态,故使用一个死循环来保证系统不会关机。编辑src/main.rs,结果如下:
// 不使用标准库 |
然后编译运行:
cargo build |
运行结果如下:

系统不断打印触发了el1_irq信息。这里的循环是因为我们只接收了中断,而中断引发的异常并未被处理,寄存器未被复位所以不断触发异常。

