中断机制
- 处理器会在固定的指令周期内检测是否有中断号,之后用这个中断号索引,在 IDT 中找到对应的中断描述符 —— 里面保存着 中断例程 ISR 的段选择子
- 处理器用该段选择子从 GDT 中取得对应的段描述符 —— 里面保存了 ISR 的段基址等属性信息,然后进行特权级检查 (由处理器负责,不用担心)
- 通过特权级后,处理器会保护现场(利用内核栈来保护),然后跳转至 ISR
- ISR 执行完毕后通过
iret
/iretd
指令恢复原线程执行流
内核的任务是实现 ISR,在 IDTR 上填入一个 IDT 的地址,IDT 的组织(本质上是个数组)也是由内核来负责
另一个值得一提的是中断压栈,借用 《操作系统真象还原,郑钢,7.4.2 章节》 的示意图:
上述两个示意图只会发生一个。当中断信号到达处理器,则处理器自动将上面这些寄存器环境压栈。一个线程至多拥有两个栈(hoo
只涉及两个 特权级,ring0
对应内核态,ring3
对应用户态),左图的场景是中断前 ring3
,中断后陷入 ring0
;右图场景是中断前后均为 ring0
举一个具体的例子,当用户在 shell
输入一个字符,即在键盘按下了一个键。则处理器会收到一个中断信号,中断之前执行流是 shell
进程,即 ring3
,中断时陷入内核态 ISR,需要切换为 ring0
。此时属于左图的场景,shell
进程使用的 ring3
栈是不会被带入到 ISR 的执行时的,陷入内核态时会从 TSS 中取出 ring0
栈,最后处理器会自动将上述寄存器环境保存到 ring0
栈
至于右图的场景,比如执行内核任务时,时间片耗尽。中断前是内核态,中断时跳转 ISR 依然是内核态。之前使用的栈就是 ring0
栈,则跳转 ISR 后依然使用原来的栈,不涉及栈的切换,处理器依然会压栈寄存器环境,只是不会压栈旧 %ss
和 %esp
上述寄存器环境有一个信息叫做 “错误码”,它是一些有关该中断信号的额外信息,比如缺页异常处理器会压栈错误码,这个时候的错误码是 PDE / PTE 标识位的组合,hoo
通过这个错误码实现了 缺页异常的 COW
实现
如图所示,hoo
将 IDT 数组元素视为一个函数地址,这个函数地址就是 ISR 入口。执行流到达 ISR 入口后,结合中断向量会计算得到 ISR 数组的索引,最后跳入 ISR 数组。ISR 数组元素也是一个函数地址
第一步处理器访问 IDT 数组,具体代码详见 kern/intr/isr.S,以下是关键代码片段:
1 | # 宏定义 |
借助 x86 AT&T 风格汇编宏定义,为 IDT 数组定义函数,这是因为有些中断处理器会入栈错误码,另一些没有错误码的中断就需要手动入栈一个 0 来保持栈格式的统一,方便后面保护现场、恢复现场的操作
第二步定义 ISR 入口,详见 kern/intr/trampoline.S:
1 | # ISR 入口 |
逻辑分三段,保护和恢复上下文比较直白,主要看跳转 ISR 的逻辑:
1 | movl $(2 * 8), %eax |
第一个指令是取下标为 2 的 gdt,hoo
设置的 gdt 的设置详见 kern/module/conf.c:
1 | // #0 空表 |
所以这里是将内核数据段加载到段寄存器 %ds
和 %es
另外的指令:
1 | movl 48(%esp), %eax |
看指令是从栈上面取出偏移 48 字节的栈元素,再经过进一步计算得到 ISR 数组的索引,最后跳入该 ISR 元素指向的地址。下面是这个 48 字节的由来:
- 最开始的时候,处理器刚接收到中断信号,会自动压栈橙色部分的寄存器环境
- 之后处理器通过 IDTR 找到 IDT 数组,再找到 IDT 元素,即访问到前面汇编宏定义的内容,压栈黄色部分(橙色和黄色部分
hoo
定义为处理器中断栈,见 kern/intr/intr_stack.h) - 后续执行流便进入 ISR 入口,压栈绿色部分的寄存器环境(绿色部分
hoo
定义为内核中断栈,见 kern/intr/intr_stack.h),最后栈顶停留在图示位置。因此栈顶偏移 48 字节即越过了整个绿色部分,访问到黄色部分的 中断向量号。而 ISR 数组的定义见 kern/module/do_intr.c,是一个函数指针数组,对于32-bit
系统,一个指针字是 4 字节,因此中断向量号乘上 4 就是 ISR 数组索引
整个中断执行流至此完毕,具体的 ISR 会放在「内置命令」一文,现在只提供一个默认的 ISR 赋值给所有的 ISR 数组元素,默认 ISR 定义详见 kern/intr/routine.c,主要是输出 ISR 名称、输出上下文环境、执行 hlt
命令停机。赋值逻辑详见 kern/module/do_intr.c:
1 |
|
hoo
提供了两个接口 set_isr_entry()
和 set_idt_entry()
用来设置 ISR 数组和 IDT 数组,前者直接就是给 ISR 数组赋值;后者由于 IDT 表项有 格式,所以需要额外提供 PL_KERN
、INTER_GATE
等属性,但本质也是给 IDT 数组赋值
1 | static idtr_t __idtr; |
最后执行 lidt
将内存中的 __idtr
结构体加载到 IDTR,完成
常见 ISR
缺页异常
缺页异常 是现代操作系统中很常见的一个异常类型
很多场景都会触发缺页异常,这里主要考虑当访问不在内存的 PDE 或 PTE 的场景,此时 %cr2
会保存缺页的线性地址,同时中断错误码会保存 paging-structure 表项的属性位,这些属性位用来标识触发缺页的场景
比如,当错误码是 1 时,对应着 PTE 或 PDE 的表项,可以发现 bit-0
都是 P
属性位,此时对应的场景是访问 paging-structure 时发现不在内存,可以借此实现换入换出机制(swapping);当错误码是 2 时,对应着 R/W
属性位,此时对应的场景是当前线程对目标页没有写入权限,可以借此实现 写时复制,COW(Copy on Write)
hoo
没有实现换入换出,而实现了 C.O.W。C.O.W 的场景是,子进程通过 fork()
系统调用克隆了父进程,此时子进程所有页表也都是指向和父进程一样的物理页的。不同的是子进程共享的物理页不设置 R/W
属性位,当子进程写入物理页时,才进行 C.O.W
关于写操作触发 page fault 还有两个概念需要补充,详见 《IA32 Architectures Software Developer’s Manual, Volume 3A》,Sections 4.6.1 访问地址的规则:
- 访问模式
- supervisor-mode access:发起访问的 CPL < 3,即内核态线程访问一个线性地址
- user-mode access:发起访问的 CPL = 3,即用户态线程访问一个线性地址
- 线性地址模式
- supervisor-mode address:
U/S
属性位至少在一个 paging-structure entry 上(PTE 或 PDE)是 0 - user-mode address:
U/S
属性位在所有 paging-structure entry 上都是 1
- supervisor-mode address:
写入一个线性地址会让处理器抛出 page fault 的情景是:用户态线程访问 user-mode 线性地址,即 CPL 为 3 的线程访问 paging-structure entry 都是 1 的线性地址
具体实现详见 kern/intr/routine.c,下面代码片段有删减:
1 |
|
- 这里
err
变量是中断错误码,一个 32 位无符号整型值,从ring0
栈中取出(从栈中偏移多少字节取出这里不关心)。然后去判断属性位是否设置了R/W
位,是说明需要为当前线程分配一个新页,将缺失页上面的数据拷贝过去 - 分配新页的流程是,分配新的物理页,从自己的堆空间中分配新的线性地址,建立映射。然后将缺失页的线性地址上的数据,拷贝到新分配的线性地址。最后,释放这个新线性地址回去堆空间,因为当前线程最后依然会使用缺失页的线性地址
- 最后将新分配的物理地址写入页表对应 PTE
1 | // 设置 %cr0.WP |
还有一点要注意的是,R/W
属性会受到 %cr0
的影响,详见 《IA32 Architectures Software Developer’s Manual, Volume 3A》,Sections 2.5 控制寄存器组,以下是一些精简的说明:
- CR0.WP(Write Protect):
- 置位:阻止内核态线程写入一个只读的物理页
- 清位:允许上述情况
1 | void |
最后将 C.O.W 放到缺页异常 ISR 逻辑里面,并通过 set_isr_entry()
接口注册 ISR
时间片中断
时间片中断会涉及「调度机制」一章实现的 调度器,可以先把它当成黑盒,详见 kern/intr/routine.c:
1 | // 时间片中断 |
最后通过 set_isr_entry()
接口注册 ISR
系统调用
由于发起系统调用的整个执行流有一些前置内容,所以具体内容放到「内置命令」一文,这里先把系统调用的函数接口视为一个黑盒
1 | extern void syscall(void); |
最后通过 set_isr_entry()
接口注册 ISR