作者: 小牛呼噜噜
大家好,我是呼噜噜,由于x86保护模式是比较复杂晦涩的,所以特地单拉出来,实模式和保护模式一个重要的更新就是对内存的管理与保护,并且随着软件的发展,为了极致地压榨CPU的性能,硬件和软件都做出了许多努力,为了更好的管理内存,引入分段,分页,段页等等。本文会沿着内存的主线,穿插于实模式和保护模式之间,并结合历史渊源,更好地讲解这里面的发展与变化。
实模式
代号8086
当计算机启动时,实模式运行的时间对我们人来说是无感的,但是并不是其不重要,本文笔者想讲的故事,它的起点来源一个产品,一个划时代芯片,
8086
,其是Intel公司推出的最早,也是最流行的面向个人电脑的CPU型号
我们可以看到上图有10个
引脚
,由于芯片是对称的,所以8086芯片一共(只)有20个引脚。不像现在的CPU那样成百上千的都有,脚这么多可不仅仅是为了爬得快
我们一起来看下8086的引脚图:
这些引脚有哪些作用?主要有下面这几种:
- 电源线Vcc(40),地线GND(1和20)
- 地址/数据引脚
- 地址/数据 分时复用 引脚AD15-AD0(39,2-16):传送地址时单向输出,传送数据时双向输入或输出
- 地址状态 分时复用 引脚A19/S6~ A16/S3(35-38):输出、三态引脚。T1状态做地址线,T2-T4状态用于输出状态信息
-
所谓
分时复用
就是
在同一根传输线上,在不同时间传送不同的信息
,所以8086对应的地址线16+4=20根
- 控制引脚
NMI(17):非屏蔽中断请求信号,不受IF影响,此信号一出现,当前指令,执行结束后立即进行中断处理。
INTR(18):可屏蔽中断请求信号,输入高电平有效。
CLK(19):系统时钟,输入
RESET(21):复位信号,输入,高电平有效。复位信号使处理器马上结束现行操作,对处理器的内部寄存器进行初始化
READY(22):数据准备好信号线,输入,高电平有效,由存储器或I/O端口发来。CPU在每个总线周期的T3状态对READY采样,若为低电平,则自动插入一个或几个等待状态Tw,直到变为高电平才能进入T4状态
TEST(23):等待测试信号,输入,CPU执行 WAIT指令时,每隔5个时钟周期对引脚进行一次测试,若为高电平,CPU处于等待状态;低电平时执行下一条指令。
RD(32):读控制信号,输出。RD=0,表示执行一个对存储器或I/O端口的读操作。
BHE/S7(34):高八位数据总线允许/状态复用引脚输出。
MN/MX(33):最小/最大工作方式控制信号,输入。接高电平时为最小工作方式。
...大家了解一下即可
这里需要特别注意地址总线,我们知道CPU除了还能访问内存,还能访问硬件,这些都是通过 总线 来实现的。
总线 是贯穿整个系统的是一组电子管道,是连接各个部件的信息传输线,是各个部件共享的传输介质,称作总线,它 携带信息字节并负责在各个计算机部件间传递 。总线按系统总线传输信息内容的不同,可以分为3 种: 数据总线、地址总线和控制总线 。
我们可以发现8086的寻址空间是1M,这个是怎么得来的呢?寻址空间主要受地址总线宽度影响,地址总线宽度20,也就表示有20根地址线,又因为内存的单位是字节Byte,所以
2^20B=1024KB=1MB
对总线感兴趣地,拓展可见: 什么是计算机中的高速公路-总线?
分段机制
由于8086那个时代CPU、内存都很昂贵, CPU 和寄存器等宽度都是 16 位的,在段不重叠的情况下,能表示的最大地址
0xFFFF
,最大可寻址2^16=64KB,然而8086有20根地址线,可寻址的最大内存空间是1MB。CPU和寄存器的寻址能力远远不能满足使用
所以Inte工程师们耗尽头发,发明了 分段技术 ,将内存分为一个个"段",段最大可为64KB,段由三部分组成:
- 段基址(Base address):段的初始地址
- 段界限(limit):表示段的长度,段界限决定了偏移量的最大值,也就是段内偏移最大能够寻址到的位置,
- 段属性(Attributes):表示段的属性,比如是否可读,可写,权限等
那么16 的位的寄存器究竟该如何能访问20位的地址空间呢?
计算方式是:
实际物理地址 = segment段基址 <<4 + offset段内偏移地址
,左移4位就是乘以16。这样就实现用16位的寄存器,生成20位的地址。从而扩大CPU寻址能力,实现对1MB内存空间的寻址
为了实现分段,同时8086引入
专门为分段而生
的
段寄存器
,如
CS、DS、ES、SS
:
- CS:代码段寄存器,存放代码段的段基址
- DS是数据段寄存器,存放数据段的段基址
- ES是扩展段寄存器,存放当前程序使用附加数据段的段基址,该段是串操作指令中目的串所在的段
- SS是堆栈段寄存器,存放堆栈段的段基址
- 后面80836还新增2个寄存器:FS标志段寄存器、GS全局段寄存器。
在采用分段机制之前,工程师要在程序中要访问内存,需要把物理地址写死在程序中,简单而粗暴,但是如果其他程序也同时需要同一块内存地址,只能排队等待,这太让人着急了,所以采用分段机制的另一个重要的好处是:程序可以 重定位
重定向 就是将程序中指令的地址改成另一个地址,但该地址处的内容还是原内存地址处的内容。即使分段后, 程序还是直接操作同一块实际物理内存 ,但在程序中的逻辑地址是不一样的,这样计算机多道程序得以勉强的"并发"运行。笔者认为分段的初衷更多是程序重定向问题的解决
由于这样程序中指令了只用到16位地址,缩短了指令长度,也变相地提高了程序执行速度。
保护模式
但随着8086的普及,人们渐渐发现"实模式"(那个时候还没有实模式、保护模式的概念,只有一个工作模式)有个最大问题,就是安全问题,实模式哪怕引入段后,还是直接操作系统的实际内存,程序之间的地址没有隔离,自己写个程序可以访问别人的程序地址,甚至是操作系统的程序地址,所以一不小心就直接把操作系统给干挂了,所以那个时候的程序员编写程序都得小心翼翼的
保护模式
概念首次出现于80286,并将以前"老办法"称为
实模式
,80286 虽然有了保护模式,地址总线是 24根,寻址空间变成了
2^24 =16MB
, 但其CPU、通用寄存器还是16位, 即单独的一个寄存器还是只能访问64KB的空间,要想访问完整的 16MB 内存,只能频繁地变换段基址,非常影响计算机的性能
因此80286太鸡肋了,很快Intel推出了 80386DX ,CPU、寄存器、地址总线都是32位的,寻址空间直接达4GB,在当时CPU非常昂贵的时代背景下,可以说"硬件直接拉满",从这个时候开始,保护模式才大放异彩!
需要注意的是80386并不是立即升到32位的,先出的 80386SX 的CPU、通用寄存器还是16位,地址总线是 24根
此时CPU、寄存器、地址总线都支持寻址4GB,更换偏移地址,就能够访问内存的每一个字节,那么 其实已经不需要分段机制 了。但是为了向前兼容,兼容性是CPU能否长久保持生命力的一个重要保证,还是保留了分段机制,但保护模式下的段基地址都设为了 0 ,意味着每个段的起始地址都是一样的,其实 在操作系统层不再分段
那时的程序员访问内存时
被迫用多个小段再加上不断换段基址的方式访问
,非常容易写着写着就忘了前面的内存地址,对程序员的心智产生极大的负担,
不再分段
也叫做
平坦模式
,嗯,对程序员来说以后访问内存操作一路平坦
80386和8086常用寄存器
保护模式与实模式相比有了许多变化,我们先来看下80386和8086寄存器的前后对比,由于80386的寄存器大部分变成32位,同时还必须兼容实模式,所以实模式只用寄存器的前16位
80386寄存器主要为3类:
- 通用寄存器。这八个 32 位通用寄存器主要用于包含算术和逻辑运算的操作数。这8个通用寄存器都是由8086的相应16位通用寄存器扩展成32位而得。名字分别是:EAX,EBX,ECX,EDX,ESI,EDI,EBP,ESP
- 段寄存器。段寄存器CS、DS、SS、ES、FS、GS就是用来标识这6个当前可寻址的内存段。80386新增FS标志段寄存器、GS全局段寄存器,段寄存器因为16位够用了,所以并没有扩展到32位。这些专用寄存器允许系统软件设计者选择平面或分段的内存组织模型
- 状态和指令指针寄存器。这些专用寄存器用于记录和改变 80386 处理器状态的某些方面,指令指针寄存器EIP是一个32位寄存器,是从8086的IP扩充而来。标志寄存器EFlagS也是一个32位寄存器,其中只使用了15位,从8086的FLAGS寄存器扩展而来。
为了帮助大家理解,笔者特点画了张图,其中粉红色代表80386的扩展部分:
当然80386还有其他一些特殊的寄存器,比如IDTR、GDTR、CR0、CR1、CR2和CR3等,这个我们留待下文再讲
GDT、GDTR
我们需要思考一个问题,保护模式是如何保护 程序访问内存时安全的?
保护程序访问内存时安全的,其实换个角度就是,让程序只能访问安全的内存,更进一步地说,我们可以对内存进行权限控制,规定哪些内存可以被哪一类地程序访问。
所以保护模式下会在访问内存时增加了许多"描述信息",比如段自身的访问权限,段的最大长度限制(16位)、段的线性基址(32位)、段的特权级、段是否在内存、读写许可等等相关信息
那么这些信息,首先需要一个 数据结构 来保存所有的相关描述信息,这就是 段描述符, 段描述符8个字节长,也就是64bit。需要注意 每个段都需要一个段描述符 。
下面我们就是 80386段描述符 的结构图:
段描述符核心就是:段基地址,段界限,访问权限DPL。
段描述符的具体参数,笔者这里就不详细贴出来了,太多太杂,感兴趣地可以自行去看 Global Descriptor Table - OSDev Wiki
如果我们直接通过一个64bit段描述符来引用一个段的时候,就必须使用一个64bit长的段寄存器装入这个段描述符,但是我们刚刚看到段寄存器仍然是16bit,这是Intel为了兼容实模式。所以我们就无法直接通过段寄存器来直接引用64bit的段描述符。
而且每个段都有自己的段描述符,这些信息非常庞大,不是一个或者几个寄存器就能够保存的下去的,需要在内存中开辟出一段空间,当操作系统启动时,加载到 内存 中。在这个专门的内存空间中,所有的段描述符都依次排放在一起,这就构成一个 全局描述符表GDT (Global Descriptor Table ),GDT是全局的,所以对一个系统来说是唯一的
又因为 全局描述符表GDT 是在内存中的,CPU是无法直接找到的,需要告诉它,这就是需要一个全新的寄存器 GDTR ,来专门告诉CPU,GDT在内存的位置
问题又来了,现在 全局描述符表GDT 有了,有了它我们就能去找内存所有的段,但是我们如何去查这张表呢?我们这里借鉴一下实模式(同时也是为了兼容实模式),在保护模式下,段寄存器(比如 ds、ss、cs)中存放的不再是寻址段的基地址,而是一个一个"GDT表索引",称为 段选择符 (或称 段选择子 )
在保护模式下,通过段寄存器存放的 段选择符 (或称段选择子),由段选择符从 全局描述符表GDT 中找到8个字节长的 段描述符 ,段描述符里存储着 段基址, 再加上 偏移地址 就可以得到 实际内存物理地址 。这里我们只考虑了段模式,页模式暂不展开,其实页模式也是基于段模式的
我们段寄存器还是16位,那么段选择符也是16位的,其中的13bit用来作"索引index",下面我们看下
80386段选择符
的结构图:
当地址访问时,如果 段选择符 的 请求特权级别RPL 的权限低于 段描述符 的 特权级DPL 时(一共分为四层:0、1、2、3,其中0为最高特权级,3 为最低特权级),就会拒绝访问,于是就达到了"保护"的作用!
LDT、LDTR
LDT局部描述符表,LDT结构和GDT是差不多的,主要区别在于GDT是全局的,而LDT是局部的(local),GDT在整个操作系统中是唯一的,而LDT在系统中可以存在多个
每一个LDT自身作为一个段存在,存放在LDT类型的段里,这个LDT既然也是段,那么它也会有一个描述符,就放在GDT里面。 寄存器LDTR 内容是一个段选择符,它是用来到GDT里面寻找LDT的
LDT只是一个可选的数据结构,我们可以完全不使用它,使用它或许可以带来一些方便性,但同时也带来复杂性,如果我们想让自己的操作系统内核保持简洁,以及可移植性,则最好不要使用它。这里只做简单地科普介绍
IDT、IDTR
IDT ,Interrupt Descriptor Table,即 中断描述符表 ,和GDT类似,记录着0~255的中断号和调用函数之间的关系,与中段向量表有些相似,但要包含更多的信息。
中断机制是操作系统中极为重要的一个部分。操作系统在管理输人输出设备时,在处理外部的各种事件时,都需要通过中断机制进行处理,操作系统在管理输人输出设备时,在处理外部的各种事件时,都需要通过中断机制进行处理
实模式下,16位的中断机制依赖的是
中断向量表
,中断向量表初始化在
0x0000
处,位置是固定的。为了让操作系统的代码中的逻辑地址和实际物理地址一致,操作系统启动时会把system模块搬到零地址处,这样中断向量表就会被覆盖
而在保护模式下,中断机制用的是中断描述符表(IDT),位置是不固定的,设计操作系统时可以灵活设置,只需最后把其地址赋值给 IDTR寄存器 。中断描述符表寄存器 IDTR 是一个48位的寄存器,其低16位保存中断描述符表的大小,高32位保存IDT的基址。
当中断发生时,CPU获取到中断向量后,通过IDTR的值,去查找IDT中断描述符表,得到相应的中断描述符,再根据中断描述符记录的信息来作权限判断,运行级别转换,最终调用相应的中断处理程序
段页机制
在分段机制下的保护模式一切都岁月静好,直到有一天,我们系统有大量程序在运行,比如微信,钉钉等,把内存都占了,只剩下2个空闲内存段1和空闲内存段2。现在我们想在我们系统中运行百度网盘(假设运行需占用2个内存段),明明我们内存中有足够的内存段,但就因为不是连续的,会导致百度网盘运行失败。
我们只能把钉钉先关了,然后百度网盘才能正常打开 或者把钉钉先移到磁盘中,然后就可以运行百度网盘了,这个叫内存交换,但是段的大小比较大,而且磁盘和内存相比要慢很多,所以这种方式效率不高。
通过上面的小例子,相信大家理解了分段机制一些不足的地方:段的大小比较大,而且 由于段的大小是不固定的 ,导致内存碎片化(内存有断断续续的间隙,且每个间隙都不一样大!);程序无法动态使用内存;程序只能存放在连续的内存中......
所以Intel引入了 分页机制 ,分页的初衷是为了解决内存不足,但由于80286的 段交换 时性能堪忧,决定引入分页,同时为了兼容x86的分段机制,就形成独特的 段页机制
将内存划分为一个个比段更精细的"页", 页的大小固定为4K ,方便更精细化管理。由于分段机制下,程序都是需要提前指定基地址,加载到指定内存中,现在为了实现程序运行时,内存地址自动分配,并按需加载。那必须得先解除线性地址与物理地址对应的关系,这一切需要增加一个"中间层"来实现。
这个中间层主要是3个部分:
CR3 控制寄存器,页目录表page directory,页表page Table
。当页功能开启时,段部件产生的地址就不再是物理地址了,而是线性地址,线性地址还要经页部件转换后,才是物理地址。我们来看下段页机制的工作流程:
CPU内部有一个控制寄存器 CR3 ,存放着当前进程的 页目录表 的物理内存基地址,页目录表存放的是 页表 的物理内存基地址,页表存放的是 页 的物理内存基地址
其中当操作系统开启分页后,分页机制接收的线性地址其实是 虚拟地址 , 在操作系统看来它是连续的 ,但它实际上通过页表映射到 多个不连续的物理内存页 ,这样就极大的利用了物理内存,不会出现使用分段机制后产生的大量内存碎片那种情况。
因为页表需要映射整个内存地址,如果是单一的,那么线性地址前20位都查一张表的话,
2^20=1M
, 每个页表项是4字节,如果页表项全满的话, 便是4M大小,换句话说就是页表本身也占用了4MB的物理内存空间。如果我们结合系统资源分配和调度运行的基本单位-进程来说,为了保证进程的正常执行,
每个进程都得有自己的页表
,那么如果进程一多,页表会占有很大的内存空间。
所以现代操作系统都是采取 二级页表 的方式: 页目录表和页表 ,这也是我们上图画的结构。其实本质就是 拆分,把一个大表(页表)拆成多个小表,而且不一次性地将全部页表项建好,可以在需要时能够动态创建页表 。 然后统一由一个页目录表来存储这些页表 ,其中页目录项和页表项一样,大小都是4KB
我们将二级页表内存转换流程联系在一起就是:将线性地址,分为
高10位、中间10位、低12位
三个部分,其中高10位作为页目录表的索引(页目录表中有
2^10=1024
个项,PDE),中间10位作为页表的索引(每个页表也有1024个项,PTE),
低12位就是偏移地址,大小
2^12=4KB
,和页的固定大小正好相等。
所以二级页表能够寻址
4KB*1024*1024=4G
,这也是32根地址线能够寻址的最大地址了。
分页其实并不是由操作系统决定的,而是由CPU决定的 。因为线性地址到物理地址的转换算法如上图,已经固定流程套路,而且是比较复杂的(从页目录表到页表再到物理页),为了加快转换的效率,我们直接在硬件上让它自动执行转化。所以CPU中集成了专门用来干这项工作的硬件模块,这个模块被称为 页部件
当程序中给出一个线性地址时, 页部件 分析线性地址, 按照以上算法,自动在页表中检索到物理地址。我们需要注意的是 CR3寄存器 存放的是实际物理地址,这个是给 CPU 看的,不是给操作系统看的。操作系统要访问内存就必须知道它的线性地址才行,线性地址必须连续,至于线性地址的对应实际物理地址可以不连续!
页目录表和页表的参数如下,和之前的gdt是类似的,大家感兴趣地可以自行查阅intel开发手册,我们这就不展开了
段机制实现虚拟地址到线性地址的转换,分页机制实现线性地址到物理地址的转换
,一切的改变都是为了更好地管理与保护内存!
尾语
通过本文的阅读与理解,带着大家穿插了解那个年代x86的历史渊源,大家会更容易明白实模式和保护模式的区别以及分段,段页的所遇到的局限和改进,许多奇奇怪怪地设定都是为了向前兼容,难免负重而行,但一个成熟的产品,良好的兼容性就是它生命力重要的体现。
实模式和保护模式是现代操作系统的前置知识,即使现代操作系统已经天翻地覆的改变,但依旧有他们的影子,理解它们,会让大家对底层知识有更深刻地理解。笔者能力有限,本文还是有许多细节没有讲到,欢迎大家讨论
参考资料:
英特尔® 64 位和 IA-32 架构开发人员手册:卷 3A-英特尔®
https://pdos.csail.mit.edu/6.828/2008/readings/i386/s02_03.htm
作者:小牛呼噜噜 ,首发于公众号「 小牛呼噜噜 」,高质量好文等你关注!