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
信息。这里的循环是因为我们只接收了中断,而中断引发的异常并未被处理,寄存器未被复位所以不断触发异常。