【保护模式】2.基于段的保护
# 段寄存器的扩展
我们需要划分权限,让操作系统和应用处在不同的权限中,以保证系统安全;
并且决定基于段机制进行延展。
相较于实模式的 1MB 寻址 (220),保护模式下的寻址范围已经达到了 4GB (232),在汇编指令上可以直接书写 4 字节的内存地址。
此时,段寄存器的 16 位宽度已经显得有些相形见绌了。
为了扩展,段寄存器在保护模式下,不再直接存放段的基址,而是存放了索引。
# 全局描述符表 (Global Descriptor Table)
具体是索引什么呢?
这里引入了一张表,叫做全局描述符表,其实就是在内存中存放的数组。
其元素叫做段描述符,在内存中顺序组织起来,也就是一张表。
- 简称 GDT
# 段选择子
段寄存器由于有了新的用途,因此也有了新的名字,叫做段选择子。
大概是意为用于选择段的寄存器吧
# 段描述符
那么说回段描述符,它究竟有什么作用呢?为什么会有这么一个东西呢?
段描述符是 GDT 或 LDT (暂时忽略) 中的元素;
它为处理器提供诸如段基址,段大小,访问权限及状态等信息。
- 先来看英特尔白皮书上对段描述符的图示
每个段描述符是 8 字节,由多个字段组成。
我们发现,字段的排列有些混乱,基址 (Base)、界限 (Limit) 甚至需要跨几个字段组合。
据说是英特尔为了兼容,这里也不做探究。
字段这么多,咱们先来看最熟悉的基址字段。
还记得 4. 段寄存器 小节中的 CPU 对内存的访问 那部分吗?
在实模式下,cpu 访问任何在汇编指令中显式书写的地址,都会将地址视作偏移 (逻辑地址),加上段基地址形成真正的物理地址。
在这里也不例外,只不过段寄存器并不直接存放段基址了,而是存放用于了在 GDT 中选择段描述符的索引值。
# 访问内存
至此,我们也能初步设想在保护模式下,CPU 是如何基于段访问内存的:
1 | mov eax, dword ptr ds:[0x12345678] |
- 汇编指令中的内存地址 (偏移,offset) 是 0x12345678;
- 汇编指令中指定使用的段寄存器是 ds;
- 访问 ds 段寄存器,得到索引 (index);
- 访问 GDT [index],得到段描述符;
- 解析段描述符中的 Base 字段;
- ds.Base + offset = 最终的物理地址;
- 通过物理地址访问内存。
# 地址分类
至此我们基本了解了,保护模式下 CPU 如何基于段描述符进行寻址。
并且在上文,我列出了 CPU 将汇编指令中书写的地址转换为物理地址的猜想。
在保护模式下,实际寻址过程的各个阶段的地址也都是有命名的。
为了向下深入学习,先了解一下还是有必要的。
# 逻辑地址 / 相对地址
百度百科的解释是:
指在计算机体系结构中是指应用程序角度看到的内存单元(memory cell)、存储单元(storage element)、网络主机(network host)的地址。 逻辑地址往往不同于物理地址(physical address),通过地址翻译器(address translator)或映射函数可以把逻辑地址转化为物理地址。
咱们简单一点,还是理解成在汇编指令中显式书写的地址。
如上一节举例的汇编指令:
1 | mov eax, dword ptr ds:[0x12345678] |
我们在当时把它叫做偏移 (offset),其实它应该叫做逻辑地址。
在实模式下,逻辑地址 + 段基地址 = 物理地址;
在保护模式下, 逻辑地址 + 段基地址 = 线性地址。
# 线性地址 / 虚拟地址
摘自百度百科:
线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
线性地址是在保护模式下出现的,通过页表将线性地址转换成物理地址。
- 在上节举例时,我们将 "线性地址" 称为 "物理地址";
- 线性地址到物理地址的转换涉及到分页机制,在未学习分页机制之前,请暂时将笔记中所有 "线性地址" 视作 "物理地址"。
# 物理地址
摘自百度百科:
在存储器里以字节为单位存储信息,为正确地存放或取得信息,每一个字节单元给以一个唯一的存储器地址,称为物理地址(Physical Address),又叫实际地址或绝对地址。
CPU 最终通过物理地址去访问真正的存储单元。
# 段描述符缓存
# GDTR
我们说到,既然 CPU 访问内存,需要先访问 GDT,那么 GDT 的地址又是从哪里来的呢?
CPU 提供了一个 48bit、名为 GDTR 的寄存器。
高 32bit 存放 GDT 的首地址(线性地址);
低 16bit 存放 GDT 的界限,即整个 GDT 表的长度。
- 通过 windbg 调试 Windows XP,查看 GDTR 的内容:
关于 windbg 的使用,在内核的学习阶段是非常重要的,所学的知识都需要自己动手实验、验证;
如果有需要的话,我会考虑再找时间写一篇关于环境配置的文章。
在 windbg 下:
查询 gdtr,即查询 GDT 的首地址;
查询 gdtl,即查询 GDT 的界限。
CPU 每次访问 GDT 时,都是从 GDTR 中获取线性地址。
而 GDTR 的值,是由操作系统在初始化阶段填入的。
# 不可见寄存器
解决了一个问题,当然又会出现新的问题。
通过引入 GDT,我们解决了段寄存器宽度太小无法满足保护模式需求的问题。
但是每次访问内存,都要先查 GDT,这是不是有点浪费 CPU 的性能了?
是的,内存访问对于 CPU 而言,是很慢的行为,为了避免这种性能浪费,引入了名为描述符缓存的寄存器。
实际上,描述符缓存是不可见的,它在保护模式下是属于段寄存器的一部分。
保护模式下的段寄存器,就分为了两个部分:
- 段选择子
- 原先的段寄存器,可见。
- 描述符缓存
- 对段寄存器进行扩展,不可见。
虽然它不可见,无法直接操作,但是是真实存在的。
# 段寄存器
- 保护模式下,将段选择子与描述符缓存部分 合称为段寄存器
# 加载段寄存器
在加载段选择子时,CPU 会通过我们给定的段选择子,查询 GDT,得到描述符。
解析描述符,将字段填入描述符缓存中。
未来每次发生内存访问,都不会去查询 GDT,而是直接从描述符缓存中获取字段。
那么,段选择子是由谁加载的呢?为什么我们平时没有见到过相关的代码呢?
我们可以在 windows 下打开随意 x64dbg、od 之类的调试器,拖入一个程序,就可以看到,段选择子是存在初始值的,而加载的工作是由操作系统负责的。
我们也应该明白,所谓的加载段选择子,实际上也是加载描述符缓存,合称为加载段寄存器。
# 访问内存
至此,我们可以将上一节的设想进行改进:
1 | mov eax, dword ptr ds:[0x12345678] |
- 逻辑地址是 0x12345678;
- 汇编指令中指定使用的段寄存器是 ds;
- 从描述符缓存中取得 Base 字段
- ds.Base + 逻辑地址 = 线性地址;
- 通过线性地址访问内存。
# 访问控制
那么,说了这么久保护,究竟应该怎样才能做到所谓的保护呢?
在第 5 节我们简单提及了权限的建立,通过为不同的段设置权限级别,以控制不同程序对内存的访问。
# 特权级划分
接下来咱们想一下,既然是权限,那自然是有高有低,就像身份一样,我是排长,你是士卒,那我的级别自然就比你高。
接下来我们尝试用两个数字表示两种权限级别:
- 0
- 最高权限级别,表示当前的 CPU 是以系统身份在跑的,操作系统运行在此级别下;
- 3
- 最低权限级别,表示当前的 CPU 是以用户身份在跑的,应用程序运行在此级别下。
# 再次构思
地址 | 程序 | 基地址 (Base) | 访问此段需要权限 (DPL) |
---|---|---|---|
0x00 ~ 0x3f | 提供给操作系统内核存放数据 | 0x00 | 0 |
0x40 ~ 0x7f | 提供给操作系统内核存放代码 | 0x40 | 0 |
0x80 ~ 0xcf | 提供给程序 A 存放数据 | 0x80 | 3 |
0xd0 ~ 0xff | 提供给程序 A 存放代码 | 0xd0 | 3 |
… | … | … | … |
内核是指操作系统驻留在内存中的最基本的部分。
- 同时,我们已经学习过段描述符了,段基址是放到段描述符中的,那么访问权限自然也可以放到段描述符中。
# 基本控制
假定我是 CPU,此刻我的 ip 指针指向了应用程序中的指令序列,并且我的当前身份是用户。
一旦我试图访问我不应该访问的内存 (如提供给操作系统内核存放数据的段),因为我没有那么高的特权,就应当受到制止。
# 表明当前程序的身份
既然我们知道,访问内存中的段增加了一项对权限的例行检查,那么自然就需要有一项能表示我们当前身份的东西了。
# DPL
组成段描述符的字段之一,表明段描述符的特权级,访问该段应具备的权限。
- Descriptor Privilege Level,描述符特权级
# RPL
还记得我们之前学过的段选择子吗?当时我们只说了,段选择子用于从 GDT 中选择段描述符并加载,实际上,段选择子一共有 16bit,其中高 13bit,才是 GDT 的索引。
在段选择子中,低 3bit 是另作他用的,其中低 2bit,用于表示 CPU 加载段描述符时的请求权限。
- 即 RPL,Requested Privilege Level ,请求特权级
意为发起访问请求时的特权级
# CPL
cs 段选择子与 ss 段选择子的 RPL 字段。
- 又称为 CPL,Current Privilege Level,当前特权级
# 示例
- 接下来我们通过使用机密文档来举个栗子,尝试理解它。
文档 | 最低阅读准许级别 |
---|---|
文档 A | 排长 |
文档 B | 连长 |
-
首先,假定军官可以申请阅读机密文档,并且每个机密文档都有对应的权限要求;
-
又假定我是排长,想阅读排长级别才能阅读的机密文件,于是我写了一份申请报告 (请求),上面写着 **“排长级别”,并且对审核人员说,我希望能阅读文档 A**,审核人员在查看了报告上的请求级别,再与文档 A 的级别进行比较,如果报告上的级别达到了文档 A 的阅读准许级别,审核人员则会批准,我就可以阅读了。
-
但是当我想阅读具有连长身份才能阅读的机密文档时,于是我依旧提交申请报告,填写 **“排长级别”,并告诉他我希望阅读文档 B**,负责审批的人一看,你这不对啊,你这申请报告上写的是排长级别,但你想阅读的文档是连长级别才能阅读的机密文档,拒绝也就是理所当然的事。
此处 以军官的身份与阅读机密文件进行比喻 仅出于个人认为易于理解的想法,无其他意义,我尊敬军人,热爱祖国。
既然有 RPL 了,那么为什么要多此一举,弄出来一个 CPL,这个 CPL 又是个什么东西呢?
在上面我们强调的是请求,那么为什么要划分请求和当前呢?
就好比阅读机密文档,需要有足够的身份,才能拥有对应的权限。
- 请求
- 即我希望阅读的机密文档的级别;
- 当前
- 表示我现在的身份。
如果没有对当前身份的检查,那么即便我是排长,我也可以提交一个 "连长级别" 的申请报告。
- 只校验请求是不够的,更重要的是当前的身份。
程序的运行是依赖于 CPU 的,而 CPU 通过 CPL,辨认当前被 CPU 取指执行的程序的身份;通过 RPL,确定当前程序发起的请求时指定的特权级。
可能有的同学又要发炎啦,啊那我直接看身份级别不就行了,为什么还要多此一举,弄出来一个请求级别。
# ARPL
咱们再看一个例子,假设我有一个朋友,他是团长,在平时我抽不开身的时候,就干脆让他帮我递交申请报告,并且告诉他我想查看什么文档,让他替我带回文档。
而 RPL 的意义在于此,假设我想阅读文档 B,但是我转交给他的申请报告写的是 "排长级别",就算他以团长的身份递交申请报告,因为申请报告中填写的级别不足,依旧会被拒绝。
这个时候新的问题又来了,如果我转交给他的是一个 "连长级别" 的申请报告呢?这个时候的检查工作就落在团长的身上,他必须先检查我的身份和我的申请报告,如果我的申请报告与我的身份存在问题,那么他就会将我递交的申请报告进行修改,虽然他依旧会原样传达我的话 (我想阅读文档 B),但是审核人员可以通过查阅申请报告,以及团长传递的话,从而选择拒绝与否。
# 检查工作
而这个检查与修改的过程就是 ARPL 指令所做的工作
-
我们的应用 (连长) 委托操作系统 (团长) 访问指定的段 (机密文档);
-
应用的 RPL (申请报告),以及应用的 CPL (身份),操作系统通过 ARPL 指令进行校验以及修改,以保证操作系统不会不小心替应用访问了不应该访问的段。
-
最后由 cpu (审核员) 检查段的访问权限 DPL (机密文档的阅读权限);
-
而 应用是如何委托操作系统 等内容,请在学习权限切换后,再次回来复习。
# 权限检查
我们讲述了基于段机制的内存访问是如何受到控制的。
我想,各位看完之后依旧会存在不少疑惑,比如所谓的访问,对权限的检查,具体是发生在什么时候呢?
# 段的分类
我们知道,由地址上连续的多个内存单元组织而成的内存区域,就可以将其称之为段。
为了减少错误的出现与降低开发难度,在实模式时,就已经开始将内存划分为多个段,并且根据用途为段进行了分类。
在前面的笔记我们粗略提及过段的分类,本篇再对保护模式下的段的分类进行讲述。
# 实模式
在实模式下,段的分类更偏向于程序设计者自主安排,我不强求,你想怎么安排,就可以怎么安排,重要的是哪个段寄存器指向了那块内存区域。
如将 x8000 这个地址作为段的基址,我可以将其赋值给 cs,也可以将其赋值给 ds,取决于我如何使用它。
# 保护模式
为何我会说实模式的段分类下是程序设计者的自主安排,难道保护模式就不是了吗?
实际上,对于应用程序设计者而言,确实是的。
你也许会想,实模式的段寄存器我可以随便加载,保护模式的段选择子就不可以了吗?
很遗憾,应用程序设计者确实没有这么大的权限,要不为何会着重保护二字呢?
# 段描述符相关字段
咱们还是要看英特尔白皮书中对段描述符的解释,在往后的笔记中还会经常与它见面。
- {% asset_img 1.png 这是一张图片 %}
# S 字段
- S 字段为 1
- 当前段描述符描述的段是代码段或数据段
- S 字段为 0
- 当前段描述符描述的段是系统段
# 数据段
数据段是指用于存放数据的内存区域,向下还能再细分为只读数据段、栈段等,供 CPU 读或写。
# 代码段
代码段是指用于存放指令序列的内存区域,供 CPU 执行。