引言
- 回顾一下上一章节所做的 int 0x80 软中断实验,虽然实验让我们更好的理解了中断,但是在实际工程当中却远远不够
- 实验缺陷:中断前后代码是执行在 0 特权级状态下,在实际情况,一般会由应用程序(特权级 3)触发中断,从而跳转到内核中(特权级 0)执行
当代操作系统的 int 0x80 设计
- 比如 Linux,应用程序执行在用户态(特权级为 3),当需要调用比较深层次的系统程序时就会陷入内核态(特权级为 0)
- 什么叫陷入内核态?其实就是由特权级 3 转移到特权级 0 的内核程序而已
- 陷入的方式就是通过自定义 int 0x80 软中断实现由应用程序(特权级为 3)跳转到内核程序(特权级为 0)
- 中断服务程序运行于内核态(特权级为 0)
深入中断特权级转移
- 与调用门类似,当中断处理涉及到特权级转移时,那么也必须为不同的特权级转变不同的栈(回顾特权级转移相关章节知识,包括 TSS 等)
- CPU 根据中断向量号找到对应得中断描述符
- 找到描述符了肯定少不了特权级检查
- 由于中断是通过中断向量号通知处理器的,中断向量号只是个整数,并不像选择子一样拥有 RPL,所以对于中断引发的特权级转移不涉及 RPL
- 特权级的检查规则又是什么呢?
- 软中断:(目标代码段 DPL <= CPL) && (CPL <= 中断描述符 DPL)
- 外部中断:CPL >= 目标代码段 DPL
- 这里是数值上的比较,数值越小,代表等级越高,数值越大,代表等级越低
- 好了,特权级检查通过了接下来又要干嘛呢?
- 接下来就是压栈,那么把什么压栈,又为啥要压栈?
- 先搞清楚压栈的原因,不清楚原因那你学到最后可能还是稀里糊涂的,回想一下前面章节提到过的函数调用 call / ret 这对指令的本质,call 指令自动将 cs/eip 入栈,ret 指令再将其出栈,这样子执行完 ret 后程序是不是又可以调回原来的地址处执行了。中断也很类似不是,也要记录中断前的位置、状态等信息,中断服务程序结束后才能够恢复到中断前的位置、状态。有些地方说的中断保存上下文或者中断现场保护其实本质就是这个意思,而具体实现就是把相应的寄存器入栈
- 共计 5 个寄存器需要入栈,它们分别是 ss、esp、eflags、cs、eip
- 中断发生时的压栈示意图
- 中断发生时,eflags 中的 NT 位和 TF 位会被置 0。如果中断对应的门描述符是中断门,标志寄存器 eflags 中的 IF 位被自动置 0,避免中断嵌套。若中断发生时对应的描述符是任务门或陷阱门的话, CPU 是不会将 IF 位清 0 的
- 捣鼓完 eflags 标志然后 CPU 又干嘛了?
- 此时 cs:eip 指向中断服务程序入口地址了,接下来就是执行终端服务程序主体啦
- 中断服务程序执行完毕,iret 中断返回,使得处理器从内核态(特权级为 0)返回用户态(特权级为 3)
- 中断返回时还要对段寄存器强制清零(当然是为了内核安全,防止应用程序拿到内核数据,这个知识点前面也提到过)
- 与压栈对应,看一下中断返回时的出栈情况吧
实验
- 实验目标:使用软中断 int 0x80 实现系统调用
- 当 ax = 1 时,字符串打印
- 当 ax = 2 时,启动时钟中断
- 实验步骤分解
- 定义 32 位核心代码段(特权级为 0)
- 定义 32 位任务代码段(用户程序, 特权级为 3)
- 定义 32 位系统函数代码段(特权级为 0)
- 由于涉及到特权级转移,所以需为每个特权级定义一个栈段以及 TSS
- 程序由核心代码段通过 retf 指令降低特权级跳转到任务代码段执行
- 在任务代码段中通过调用软中断 int 0x80 转移到内核态调用系统函数(特权级由低到高转移)
- 在系统函数代码段中通过 iret 指令降低特权级回到任务代码段
- 图形直观理解
特权级由高到底转移
- 将上一章节实验代码 loader.asm 作为基础代码,在这个基础上进行改动实验
- CODE32_START 代码段就可以看成是核心代码段
- 由于整个实验步骤有点繁琐,所以我们先一步一步的实现代码,现在我们先实现第一步:由核心代码段 CODE32_START(特权级为 0) 通过 retf 指令降特权级跳转到任务代码段(特权级为 3)执行吧
- 第一步实验完整代码:loader.asm
- 现在,跟着我一步一步实现代码吧
- 先把核心代码段 CODE32_START 中断触发相关代码注释掉,因为中断触发需要移到任务代码段(特权级为 3)中
; int 0x80
; mov al, '0' ; 默认从 '0' 开始打印
; sti
- 参照前面学过的 [特权级转移(由低到高)] 代码 loader.asm
- 复制其中的 TASK A 相关代码(从 328 行到最后),去掉调用门跳转 call FUNC_PRINT_SELECTOR : 0 这条指令,本次实验不涉及调用门。
; ====================================== ; Task A code segment ; ====================================== ... ; call TaskAPrintString ; call FUNC_PRINT_SELECTOR : 0 ; call FUNC_SELECTOR : printOffset jmp $ TASK_A_CODE32_SEG_LEN equ $ - TASK_A_CODE32_SEGMENT
- TSS 任务状态段相关代码不要忘记复制了,TSS 中只包含特权级 0 的栈,却没有特权级 3 的栈,原因前面已经讲过了,这里就不再重复了
TSS_SEGMENT: dd 0 dd TOP_OF_STACK32 ; esp0 dd STACK32_SELECTOR ; ss0 dd 0 ; esp1 dd 0 ; ss1 dd 0 ; esp2 dd 0 ; ss2 times 4 * 18 dd 0 dw 0 dw $ - TSS_SEGMENT + 2 db 0xFF TSS_LEN equ $ - TSS_SEGMENT
- 对应的描述符和选择符不要忘记
; 全局描述符 TASK_A_LDT_DESC : Descriptor 0, TASK_A_LDT_LEN - 1, DA_LDT TSDESC : Descriptor 0, TSS_LEN -1, DA_TSS + DA_DPL0 ; 选择符 TASK_A_LDT_SELECTOR equ (0x0005 << 3) + SA_RPL0 + SA_TIG TSS_SELECTOR equ (0x0006 << 3) + SA_RPL0 + SA_TIG
- 注意了,新加的全局段描述符以及局部段描述符中段基址都默认为 0,所以需要初始化给段基址赋值
; 初始化段描述符中的段基址 mov esi, TSS_SEGMENT mov edi, TSDESC call InitDescItem mov esi, TASK_A_LDT_BASE mov edi, TASK_A_LDT_DESC call InitDescItem mov esi, TASK_A_CODE32_SEGMENT mov edi, TASK_A_CODE32_DESC call InitDescItem mov esi, TASK_A_DATA32_SEGMENT mov edi, TASK_A_DATA32_DESC call InitDescItem mov esi, TASK_A_STACK32_SEGMENT mov edi, TASK_A_STACK32_DESC call InitDescItem
- 别忘了得告诉 CPU 新增的 TSS 段和局部描述符表位置
; 加载任务状态段 TSS mov ax, TSS_SELECTOR ltr ax ; 加载局部段描述符表 mov ax, TASK_A_LDT_SELECTOR lldt ax
- 原来在调用门实现打印字符串 "Task A",现在不需要调用门了,但是还想把 "Task A" 字符串打印出来以证明程序成功由特权级为 0 的核心代码段转移到特权级为 3 的任务代码段,怎么办?
- 复制 print_str_32 函数到代码段 TASK_A_CODE32_SEGMENT 下面,名称修改一下,同一个名称编译肯定报错啦,名字就改为 TASK_A_print_str
- 接下来调用 TASK_A_print_str ,使其打印字符串 "Task A"
mov ebp, TASK_A_STRING_OFFSET mov bl, 0x0F ; 打印属性,黑底白字 ; 坐标 (0, 3) mov dl, 0x00 mov dh, 0x03 call TASK_A_print_str
- 编译一下,报错了,把 TASK_A_print_str 函数里面的标号名称也改掉
- 好了,程序编译没问题了
- 下面就是调用 retf 指令实现核心代码段(特权级为 0)跳转到任务代码段(特权级为 3)执行了。这个直接复制过来就可以了
push TASK_A_STACK32_SELECTOR push TASK_A_TOP_OF_STACK32 push TASK_A_CODE32_SELECTOR push 0 retf
- 编译运行一下,程序运行崩溃了,提示如下:
00016767988e[CPU0 ] check_cs(0x0004): non-conforming code seg descriptor dpl != cpl, dpl=3, cpl=0 load_seg_reg(GS, 0x0010): RPL & CPL must be <= DPL 00016767989e[CPU0 ] check_cs(0x0004): non-conforming code seg descriptor dpl != cpl, dpl=3, cpl=0 00016767990e[CPU0 ] fetch_raw_descriptor: LDT: index (77) e > limit (17) 00016767991e[CPU0 ] fetch_raw_descriptor: LDT: index (77) e > limit (17) 00016767992e[CPU0 ] fetch_raw_descriptor: LDT: index (77) e > limit (17) ...
- 看起来是特权级问题,找了半天,发现显存段特权级为 0,TASK A 特权级为 3,在保护模式下肯定无法读写显存,于是把显存段特权级改为 3
VIDEO_DESC : Descriptor 0xB8000, 0xBFFFF - 0xB8000, DA_DRWA + DA_32 + DA_DPL3
- 修改过后,果然 OK 了,来看一下效果
- 通过 Ctrl + C 强制退出程序,使用 sreg 命令查看 cs 寄存器bit0-bit1 值为 11b,证明程序成功由特权级 0 转移到了特权级 3
int 0x80 实现特权级由低到高转移
- 继续实验,第二步:实现任务代码段(特权级为 3)通过 int 0x80 中断跳转到中断服务程序(特权级为 0)
- 第二步实验完整代码:loader.asm
- 回想一下特权级转移时候调用门的蹦床示意图,这里中断门也是一样的,门的特权级最低
- 给 IDT 中断描述符表定义中门描述符特权级改为 3
Gate CODE32_SELECTOR, Int0x80Handler_Offset, 0, DA_INTR_GATE + DA_DPL3
- 在 TASK A 数据段中增加个字符串用于中断打印
ASK_A_DATA32_SEGMENT: ... TASK_A_INT_STRING db "Task A INT", 0 TASK_A_INT_STRING_OFFSET equ TASK_A_STRING - TASK_A_DATA32_SEGMENT ; 相对于段基址的偏移地址 TASK_A_DATA32_SEG_LEN equ $ - TASK_A_DATA32_SEGMENT
- 修改一下 int 0x80 中断服务函数
Int0x80Handler: mov ebp, TASK_A_INT_STRING_OFFSET mov bl, 0x0F ; 打印属性,黑底白字 ; 坐标 (0, 4) mov dl, 0x00 mov dh, 0x04 call print_str_32 iret Int0x80Handler_Offset equ Int0x80Handler-CODE32_START
- 接下来只要在 TASK A 任务状态段中调用 int 0x80 触发软中断即可打印出字符串 "TASK A INT",看一下最终实现效果吧
模拟系统调用
- 其实上面的实验已经完成了中断与特权级转移验证,但为了更进一步模拟系统调用,还需要对代码进行一定的改动
- 本次实验完整代码:loader.asm
- 上面的实验是直接在 TASK A 任务代码段里直接调用 int x80 指令触发中断的,我们现在要改成调用函数 call xxxFunc 的形式实现
- 在回到整个实验最开始的实验目标,根据实验目标重新实现一下 Int0x80Handler 中断服务函数
Int0x80Handler: cmp ax, 1 je .print_int ; 如果 ax=1,跳转到 .print_int cmp ax, 2 je .enable_timer ; 如果 ax=2,跳转到 .enable_timer iret .print_int: mov ebp, TASK_A_INT_STRING_OFFSET mov bl, 0x0F ; 打印属性,黑底白字 ; 坐标 (0, 4) mov dl, 0x00 mov dh, 0x04 call print_str_32 iret .enable_timer: sti ; 开启外部中断 iret
- int 0x80 中断服务函数实现好了之后,再来封装一下应用程序接口函数吧
user_print_int: push ax mov ax, 1 int 0x80 pop ax ret user_print_timer: push ax mov ax, 2 int 0x80 pop ax ret
- 注释掉 TASK A 代码段中的 int 0x80,改为调用函数
; int 0x80 call user_print_int call user_print_timer
- 编译运行,结果是 "TASK A INT" 成功打印出来,但是没有循环打印出 '0'-'9',好吧,反汇编断点调试呗,反汇编后我们找到 int 0x80 中断服务程序中 enable_timer 的 sti 指令地址是 0x12d8,于是我们在这个地址处打上断点,调试信息如下
<bochs:1> b 0x12d8 <bochs:2> c ... (0) [0x000012d8] 0008:0000009b (unk. ctxt): sti ; fb <bochs:3> reg ... eflags 0x00000046: id vip vif ac vm rf nt IOPL=0 of df if tf sf ZF af PF cf <bochs:4> s Next at t=16768295 (0) [0x000012d9] 0008:0000009c (unk. ctxt): iretd ; cf <bochs:5> reg ... eflags 0x00000246: id vip vif ac vm rf nt IOPL=0 of df IF tf sf ZF af PF cf <bochs:6> s Next at t=16768296 (0) [0x00002973] 0007:0000007a (unk. ctxt): pop ax ; 6658 <bochs:7> reg ... eflags 0x00000046: id vip vif ac vm rf nt IOPL=0 of df if tf sf ZF af PF cf <bochs:8> s Next at t=16768297 (0) [0x00002975] 0007:0000007c (unk. ctxt): ret ; c3 <bochs:9> s Next at t=16768298 (0) [0x0000292c] 0007:00000033 (unk. ctxt): jmp .-2 (0x0000292c) ; ebfe <bochs:10> s Next at t=16768299 (0) [0x0000292c] 0007:00000033 (unk. ctxt): jmp .-2 (0x0000292c) ; ebfe <bochs:11> reg ... eflags 0x00000046: id vip vif ac vm rf nt IOPL=0 of df if tf sf ZF af PF cf
- 通过调试信息我们可以看出,IF 位被 sti 指令置 1 了,但是当 iret 返回后,IF 始终为 0,所以外部时钟中断不再触发了。这说明中断中不能使用 sti 指令
- 于是只在 CODE32_START 调用 sti 指令使能外部中断,此时 '0'-'9' 已经被循环打印了,但是 user_print_timer 函数不就没有作用了吗?想个办法让他起一下作用
- 在 enable_timer 给 dx 赋值为 9,然后在时钟中断服务函数 TimerHandler 中加上限制条件,如果 dx 不等于 9,则直接跳出中断,不再打印
.enable_timer: ; sti ; 开启外部中断 mov dx, 9 iret TimerHandler: cmp dx, 9 jne .timer_end ... .timer_end: iret TimerHandler_Offset equ TimerHandler-CODE32_START
- 很烂的方法,哈哈哈,反正做实验而已,就别管代码质量了,能证明结论就行。最后上个实验成功截图