通用概念
- 中断(Interrupt)
- 外部硬件设备所产生的信号
- 异步:产生原因和当前执行指令无关,如程序被磁盘读打断
- 异常(Exception)
- 软件的程序执行而产生的事件
- 包括系统调用
- 同步:产生和当前执行或试图执行的指令相关
- 系统调用
- 是一种特殊的异常
- 操作系统里面要主动实现的异常
- 用户程序请求操作系统提供服务
不同体系结构术语的对应关系
Aarch64的中断(异步异常)
- 重置(reset)
- 最高级别的异常,用以执行代码初始化CPU核心
- 由系统首次上电或控制软件、Watchdog等触发
- 中断(Interrupt)
- CPU外部的信号触发,打断当前执行
- 如计时器中断、键盘中断等
Aarch的(同步)异常
- 中止(Abort)
- 失败的指令获取或数据访问
- 如访问不可读的内存地址等
- 异常产生指令(Exception generating Instruments)
- SVC: 用户程序->操作系统
- HVC: 客户用户程序->虚拟机管理器
- SMC: Normal World->Secure World
X86-64术语
- 中断(设备产生、异步)
- 可屏蔽:设备产生的信号,通过中断控制器与处理器相连,可被暂时屏蔽(如键盘、网络事件)
- 不可屏蔽:一些关键硬件的崩溃(如内存校验错误)
- 异常(软件产生、同步)
- 错误(Fault):如缺页异常(可恢复)、段错误(不可恢复)等
- 陷阱(Trap): 无需恢复,如断点(int3)、系统调用(int 80)
- 中止(Abort):严重的错误,不可恢复(机器检查)
中断的产生
中断控制器
ARM SOC的结构,里面可以看到很重要的一块GIC(通用中断控制器)
GIC连出很多根线,和各种设备进行了连接,同时也有一根线和CPU(core)进行了连接
GIC在这里相当于是中断源的处理,它把各个设备发出的中断汇集到GIC这里,再有GIC决定这个中断是发给哪个CPU还是外设
- 中断控制器需要考虑的问题
- 如何指定不同中断的优先级
- 中断交给谁处理
- 如何与软件协同
AArch64中断的分类
不同ARM GIC的实现
- 早期的ARM中断控制器
- 厂商制定模型
- 向量中断控制器VIC
- ARM提出
- 放在AMBA告诉总线上
- 32种非向量中断(不同中断相同的处理入口)
- 16种向量中断(不同中断不同的处理入口)
- 现在->GIC:通用中断控制器
- 中断类型变多,将中断分发给不同的核(对称或非对称)进行处理
- 主要功能
- 分发:管理所有中断、决定优先级、路由
- CPU接口:给每个CPU核有对应的接口
GIC中断来源分类
GIC有很多版本,这里以下图这种举例
- SPI:共享外围中断
- 可以被路由到一个或多个核,找到可用的核进行处理
- Distributor可配置路由
- 如UART中断(可以配置由哪个核处理)
- 中断ID:32~1019
- PPI:私有外围中断
- 对每个CPU都有一个PPI,所以可以指定核处理(是私有的,每个CPU都可以有自己的PPI)
- Distributor可配置路由
- 如WatchingDog -> A5 core
- 中断ID:16~31
- SGI:软件产生中断
- 核间通信(如多处理器的情况下,一个CPU往另一个CPU发一个跨处理器的中断)
- 中断ID:0~15
- NT:虽然是由软件发出的,但也是异步的,因为发给另外一个处理器B后,处理器B什么时候处理它和中断处理的过程是有关系的
GIC的路由配置——以ChCore启用timer为例
#define GICD_ISENABLER (KBASE+0xE82B1100) //定义GIC enable对应的地址:Kernel base+偏移
void plat_interrupt_init(void) // 进行Interrupt的初始化
{
u32 cpuid = smp_get_cpu_id();
if(cpuid == 0)
gicv2_dist_init();
gicv2_cpu_init();
/* 启用timer */
put32(GICD_ISENABLER, 0x08000000);
// 往中断控制器中写一个enable的值(这里实际上是某一个位置成了1)
timer_init();
}
使用MMIO,设置GIC中寄存器,启用timer
put32对应指令:
BEGIN_FUNC(put32)
str w1, [x0]
ret
END_FUNC(put32)
GIC中断信息获取
如:如何handle irq
这里对应已经从中断向量表里跳到了这个函数
void plat_handle_irq(void)
{
u32 cpuid = 0;
unsigned int irq_src, irq;
cpuid = smp_get_cpu_id();
irq_src = get32(core_irq_source[cpuid]); //从中断向量表里获取信息
irq = 1 << ctzl(irq_src);
switch(irq){
case INT_SRC_TIMER3:
handle_timer_irq();
break;
default:
kinfo("Unsupported IRQ %d\n", irq);
}
return;
}
使用MMIO,从GIC中的寄存器里获得中断信息
内存映射输入输出
Memory-Mapped I/O,MMIO,是一种常见的CPU控制和访问设备的方式
原理:把输入输出设备和物理内存放到同一块地址空间,为设备内部的内存和寄存器也分配相应的地址
当CPU通过MMIO方式为一个设备分配了地址之后,CPU可以使用和访问物理内存一样的指令(ldr和str)来访问设备的地址;设备通过总线监听CPU分配给自己的地址,然后完成CPU访问请求
Aarch64将MMIO作为CPU访问设备的重要方式
轮询与中断
->CPU获取有输入事件发生
通过MMIO方式获取树莓派上的异步收发传输器(UART)为例,OS获取输入的可能方式:
- 轮询:CPU不断通过MMIO查看UART是否有输入
- 中断:UART收到输入后,打断CPU正常执行,CPU再从UART获取输入
中断机制除了使得设备能主动通知CPU外,还包括让一个CPU核心去通知另一个CPU核心
MMIO使CPU可以主动访问设备,中断使设备能主动通知CPU->CPU与设备之间交互的重要方式
中断处理不能做太多事情
做太多事情会使处理效率非常低
因为中断在处理时CPU是不能做其他事情的,且其他任务也在等着这个中断的处理
- 解决方案
- 将几个中断一起处理,减少整体中断占用时间(如网卡,一次中断将多个数据一起读上来)
- delay,推迟执行(但是要注意delay什么,尤其是硬件,随意delay会出问题)(比如delay了一个中断,时间有点长的话会丢失一部分数据,用户明显会感受到卡顿)
案例:Linux的中断处理理念
- 在中断处理中做尽量少的事情
- 推迟非关键行为
- 结构:Top half & Bottom half
- Top half:做最少得工作后返回(如在中断处理里面,收到中断后可能将相关的数据先放置,标记信号已经收到,先回复信号发出者信号已经收到了)(比如到达目的地后先报平安,再去找酒店)
- Bottom half:推迟处理(softirq, tasklets, 工作队列,内核线程)
Top Half
-
马上做
- 最小的、公共行为
- 保存寄存器,屏蔽其他中断
- 恢复寄存器,返回原来场景
- 最重要:调用合适的由硬件驱动提供的中断处理handler
- 因为中断被屏蔽,所以不要做太多事情(时间、空间)
- 使用将请求放入队列,或者设置标志位将其他处理推迟到Bottom half
- 最小的、公共行为
-
找到handler
- 现代处理器中,多个I/O设备共享一个IRQ和中断向量
- 多个ISR(interrupt service routines)可以结合在一个向量上
- 调用每个设备对应该IRQ的ISR
Bottom Half:延迟完成
- 提供一些推迟完成任务的机制
- softirqs
- tasklets(建立在softirqs之上)
- 工作队列
- 内核线程
- 这些工作可以被中断
中断处理中的一些约束
中断处理没有进程上下文
- 中断(和异常相比)和具体的某条指令无关
- 和中断时正在跑的进程、用户程序无关
- 中断处理handler不能睡眠
- 不能睡眠(或者调用可能会睡眠的任务)
- 不能调用schedule()调度(上下文相关,会引起睡眠)
- 不能释放信号或调用可能睡眠的操作(释放信号可能会涉及唤醒,处理复杂)
- 不能和用户地址空间交换数据
异常处理
处理流程
异常处理必做事项
- 进入异常时
- 需保存处理器状态,方便之后恢复执行
- 需准备好在高特权级下进行执行的环境
- 需选择合适的异常处理器代码进行执行
- 需保证用户态和内核态之间的隔离
- 处理异常时
- 需获得关于异常的信息,如系统调用参数、错误原因等
- 从异常返回时
- 需恢复处理器状态,返回低特权级,继续正常执行流
AArch64的异常处理
- 进入异常——信息保存
- 异常发生后,硬件会将错误码和部分上下文信息存储在寄存器中
- 处理器装填(PSTATE)->Saved Program Status Register(SPSR_EL1)
- 当前指令地址(PC)->Exception Link Register(ELR_EL1)
- 异常发生原因->
- System error与异常:Exception Syndrome Register(ESR_EL1)
- 中断:GIC中的寄存器(使用MMIO读取)
- 安全性问题
上述寄存器均不可在用户态(EL0)中访问(防止用户态读取内核信息)
- 进入异常——进入EL1
- 硬件会适当修改处理器状态(PSTATE),进入EL1执行
- 问题:栈内存的安全性
- 进入EL1级别后,栈指针(sp)会自动换用SP_EL1
- 从而实现用户栈->内核栈
- 如需在EL1下使用SP_EL0作为栈指针,可配置SPSel寄存器
(在EL1下用EL0的作为栈指针,是因为切换到EL1后有些状态还是在EL0的,若切换到新的栈,需要重新将这些数据从EL0复制到EL1)
- 寻找handler的代码
-
使用异常向量表(Exception Vector Table)
- 每个异常级别存在独立的异常向量表
- 表项为异常向量(Exception Vector),是处理异常或跳转到异常handler的小段汇编代码
- 地址位于VBAR_EL1寄存器中(向量表基址寄存器)
- 选择表项取决于
- 异常类型(同步、IRQ、FIQ、Serror)
- 异常发生的特权级
- 异常发生时的处理器状态(使用的栈指针/运行状态)
-
如下向量基址与包含的内容
-
- 从异常中返回(Exception Return)
- eret指令
- ELR_EL1->PC,恢复PC状态
- SPSR_EL1->PSTATE,恢复处理器状态
- 降至EL0,硬件自动使用SP_EL0作为栈指针
- 恢复执行
- eret指令
X86-64的异常处理
- 进入异常
- 硬件会将上下文信息和错误码存储在内核上
- 用异常向量表寻找handler
- 不分级
- 异常向量表中存handler的地址
- iret返回
- 恢复程序上下文
- 从内核态返回用户态
- 继续执行用户程序
例:ChCore
异常向量表配置
BEGIN_FUNC(set_exception_vector) //定义函数
adr x0, el1_vector // 用地址寄存器获取这个地址的X0,
msr vbar_el1, x0 // 再把x0写到vbar_el1->这就配置了中断向量表的基址
ret
END_FUNC(set_exception_vector)
查看vbar_el1
异常处理器
系统调用(Syscall)
- 指运行在用户空间的程序向操作系统内核请求需要更高权限运行的服务
- 系统调用提供用户程序与操作系统之间的接口
- 系统调用方式——程序员视角
- 系统调用方式——硬件视角
Linux追踪系统调用
- 每当有系统调用产生时,Linux可以打印发生的系统调用、系统调用的参数和系统调用的返回值
- ptrace()可追踪Linux中的系统调用情况(一个应用追踪另一个应用)
- 广泛应用在各种debugger中
- 命令行中
- strace追踪系统调用
- ltrace追踪库函数的调用
案例分析:ChCore中的usys_exit()
- usys_exit:标明当前线程已返回
void main(int argc, char *argv[], char *envp[]) { print("hello world"); usys_exit(0); } void usys_exit(int ret) { syscall(SYS_exit, ret, 0,0,0,0,0,0,0,0); }
系统调用与安全
- AArch64使用寄存器传参,个数优先
- 如ChCore的系统调用支持使用寄存器X0-X7最多8个参数
- 若系统调用需要更多参数
- 使用结构体打包参数,并将结构体的指针作为参数
- 问题:内存安全性
- 作为参数的指针必须经过检测!(因为传进来的是用户态的指针,就尅传恶意)
- 指向NULL -> kernel crash
- 指向内核内存->安全漏洞
凡是系统调用所需要用到的用户态的指针,一定要检查
用户指针检查
- 完备的指针检测非常耗时
- 需要遍历用户进程的所有合法内存区域进行检测(不可能完成)
- Linux解决方法:非全面检查
- Linux仅初步检测用户指针是否属于对应进程的用户内存区域的最大可能边界
(如检查用户态指针是否存了一个内核态地址,这就是变相的让我去踩内存)
(如pid号,将pid改成0,那就是root权限,这就会使内核自己将权限改成root权限) - 即使通过初步检测,用户指针仍然可能非法(如指向尚未分配的栈空间等)
(最少得检查时保证用户态指针一定是用户态的,而不是内核地址空间的的) - 直接将非法指针交给内核使用会导致内核出现页错误,内核态的页错误通常意味着bug,内核会打印异常信息并终止用户进程
- Linux采用了一些复杂机制来防止这一情况发生
- Linux仅初步检测用户指针是否属于对应进程的用户内存区域的最大可能边界
处理用户指针问题
- 内核代码仅使用特定代码片段访问用户指针(如copy_from_user)
- 由访问用户指针而导致内核内存错误的代码段是确定的
- 当内核发生页异常(Page Fault)时,内核会检查异常发生的PC
- 若异常发生的PC属于访问用户指针的代码段,Linux尝试对其进行修复
- 若不属于,则报告问题并终止用户程序
- However
- Linux中很多地方违反了这一规定,导致了许多安全漏洞(做到这一点很困难)
系统调用与性能
- 系统调用会造成大量性能开销->这就需要进行平衡
- 硬件优化:新的系统调用指令
- x86提出了syscall/sysenter/sysexit来代替int进行系统调用
- 软件优化