0x00

我已启动

0x01 加载 kernel

这里的加载有两重含义:

一是在 loader 开启分页进入虚拟地址空间后,就应该跳转到内核,由内核来接管了。但在这之前当然要先从硬盘把 kernel 读进来。

二是 kernel 是一个 ELF 文件,内核被读入到内存后,loader 还要通过分析其 elf 结构将其再加载到新的位置,所以说,内核在内存中有 两份拷贝,一份是 elf 格式的原文件 kernel.bin,另一份是 loader 解析 elf 格式的 kernel.bin 后在内存中生成的 内核映像

先说第一点,和 mbr 读入 loader 很类似,只不过是在保护模式下执行,需要注意一些细节。比如本人写出的一个bug,仍然使用了 bx 存储加载到的基地址,但 kernel 的读入地址当然大于 16 位…所以要使用 ebx

另外为了 kernel 未来的扩展有足够的空间,将读入的原始 elf 文件放入比较高的地址处。并且原始 elf 文件在 kernel 完全加载完后被覆盖了也没关系。

再说第二点,再加载是什么意思? ELF 文件也是一种可执行文件,只不过加了一个 header 来标注一些文件信息和规范。

在 header 中记录了各个段加载的虚拟地址、大小等等,通过识别这些信息来将各个段加载到对应的虚拟地址以便于后续来执行其代码。

可能有人会问:直接跳转到这个 ELF 的代码入口点进行执行不就完了?为什么要将各个段再加载一次到对应的虚拟地址呢?ELF 文件在磁盘上的偏移地址 ≠ 程序运行的虚拟地址。其实也就是进入了虚拟地址空间后,我们的程序中使用的地址都被 cpu 认为是虚拟地址,如果不加载到指定虚拟地址,cpu 执行指令时候访问必然会出错。

那么我们编写内核时也要注意不能任凭编译器和链接器帮我们做地址规划,我们要自行规划(毕竟整个虚拟地址空间都是我们自己规划的)。不然谁知道内核代码会被指定到哪个虚拟地址?所以我们要手动编译手动链接。例如不依赖任何 glibc 库,例如指定好 kernel 程序 text 段的起始地址为 0xc0001500 。为何选择这个地址?根据此前的虚拟地址映射,这个地址对应的是物理地址 0x1500

另外我将 loader 放在了 0x900 处。给loader也留点空间 (loader毕竟还要加载 kernel),所以 kernel 就选在 0x1500了。

对了现在加载的所谓 kernel 还只有一个 while(1); 循环

0x02 DPL | CPL | RPL

如上图所示。

还记得段描述符(GDT) 中有几位就是要用来表明某个段的特权级,也就是 DPLDescriptor Privilege Level)。关于处理器所处于的特权级,有 cs 寄存器的最低两位也就是 CPL 位(Current Privilege Level)。段选择子中低两位则是 RPLRequest Privilege Level)。看字面意思就知道,CPL 表明当前 cpu 的特权级,RPL 表明 cpu 访问某个段时所用的请求特权级。

跳转到(比如 call / jmp)到 非一致性代码段 时, CPU 会检查目标段 DPL 是否允许你访问(要求 CPL== RPL == 目标代码段 DPL),如果允许,CPL 会被更新为目标段的 DPL也就是说, CPU 特权级限随目标段的 DPL 改变。当目标段是 一致性代码段 时(要求 CPL ≥ 目标代码段 DPL && RPL ≥ 目标代码段 DPL),CPU 并不会把 CPL 改成目标段的 DPL,而是 依从调用者的 CPL。访问数据段时,要求 CPL ≤目标数据段 DPL && RPL ≤ 目标数据段 DPL。

可能会有疑惑,为什么代码不能更高特权级执行更低特权级?因为更高特权级代码可以类比成特级厨师,更低特权级代码类比为普通大厨。特级厨师应该有能力自己做好一道菜,而非去寻求普通厨师的帮助。(毕竟高特权级代码权限更高)

注意访问数据段或者一致性代码段时 有效权限 = max(CPL, RPL) 。也就是 cpu 只接受用这两者中更低的那个特权级去访问。也就是说可以”微服私访”,但不能真的随便退位(因为访问数据段或者一致性代码段时 CPL 不会改变)。

不过,唯一一种处理器会从高特权降到低特权运行的情况:处理器从中断处理程序中返回到用户态的时候。因为此时只是从用户态碰到中断进入内核态然后恢复到用户态。

那么低特权级到高特权级呢?处理器只有通过“门结构”才能由低特权级转移到高特权级(这是 cpu 的设计)

1
2
3
4
5
| 门类型                | 用途        	| CPL/DPL 变化     		| 栈切换     			| 是否屏蔽中断      
| 调用门 Call Gate | 跨特权调用函数 | CPL 会变为目标段 CPL | 会切换栈 | 不改变 IF
| 中断门 Interrupt Gate | 外部/内部中断 | CPL 变为目标段 CPL | 会切换栈 | IF 清零(禁止中断)
| 陷阱门 Trap Gate | 异常/陷阱 | CPL 变为目标段 CPL | 会切换栈 | IF 保持(允许中断)
| 任务门 Task Gate | 切换任务(TSS) | CPL 随 TSS | 切换到新任务栈 | 根据 TSS 控制

门结构就是记录一段程序起始地址的描述符。此前说过段描述符(GDT)来描述内存段。而门描述符是用来描述一段程序。

调用门是用来实现系统调用的,但为了兼容等原因,我们平时接触的操作系统很少使用调用门实现系统调用,如 Linux 就是用中断门代替。陷阱门是供调试器用的。

现在就可以说明 RPL 存在的必要性了: 毕竟我们可能想:明明只需要 CPLDPL 就可以做好特权级的比较啊。

设想如果用户程序通过某个硬盘读取的调用门,CPL 提升为 0,假设调用门调用的函数需要磁盘逻辑扇区号 LBA、内 存缓冲区所在数据段的选择子、内存缓冲区的偏移地址。然后用户提供选择子参数时候提供一个内核数据段的选择子,而此时 cpu 的 CPL0,可以访问!也就是说,此时用户可以更改内核数据。

而如果加上 RPL 机制,要么选择子由操作系统提供,要么每次用户提交选择子参数,将选择子中的 RPL 设置为用户程序的 CPL,而用户程序的 CPLcs寄存器中,用户程序是无法修改的。这样一来自然就无法访问内核数据了。这是怎么做到的?

1
arpl selector, CS_val

这个arpl 指令就可以将选择子中的 RPL 设置为用户程序的 CPLCS_val 怎么获取的呢?则用户进入调用门时,处理器会进入 0 特权级,此时,由于是远转移,处理器会自动将段寄存器 CS 和 EIP 的值压栈,而且特权级也发生了变化,所以寄存器 SS、ESP也会压栈(要使用不同特权级的栈)。这时候自然可以从栈中获取 CS_val

0x03 IOPL

I/O 读写操作也有特权级的限制,标志寄存器 eflags 中的 IOPL 位和 TSS 中的 IO 位 图决定。

IO 相关的指令只有在当前特权级大于等于 IOPL 时才 能执行,比如以下指令

1
2
3
4
in
out
cli
sti

只有 CPL ≤ IOPL 才能执行。所以其实不只是操作系统可以进行 IO 端口访问,用户进程理论上也是可以的,只是特权级不够所以做不到

IO 位图又是干什么的?任意特权级下,cpu 都可以通过 I/O 位图为相应特权级的程序开启特定的端口。位图中对应端口的值为 0 则允许访问。注意 CPL 特权级高于 IOPL 时所有端口都可访问,CPL 特权级低于 IOPL 时只有 IO 位图指定的端口可访问。

为什么要用 IO 位图呢?毕竟当要频繁使用 IO 端口时,如果每次都专门切换 cpu 特权级,开销太大了,所以设置 IO 位图来开放某些端口的访问,比如方便给驱动程序使用。

哦对了什么是 TSS 呢?TSS 是一种数据结构,每个任务都有。

TSS 是栈地址和任务状态的存储区。相关内容以后再说。

0x04 特权指令

有一些对系统影响很大的指令只允许最高特权级 ring0 使用

1
2
3
hlt ; 直接停机
lgdt ; 加载全局 GDT
popf ; pop 到 eflags 寄存器

等等