0x00 序
想了好久,实在是不知道该写什么hhhh,不过想着既然在上计组,就顺手写一下内存寻址吧。这东西也算是我早些时候看的,但却一直没心情整理。另外,个人觉得这篇文章还是挺助眠的,一般看到一半差不多就开始想关屏幕睡觉了。
0x01 x86架构的微处理器模式
实模式
实模式是自80286CPU及之后为了兼容CPU的一种模式。而且也只有在CPU刚启动时才会处于实模式下,当操作系统运行起来后便会切换到保护模式。在实模式下,地址总线的宽度为20,数据总线的宽度为16,这也是因为早期CPU性能有限,一共只有20根地址线,同时也意味着它只有1M(2的20次方)的访问空间。其实说到这里我想你也差不多明白了,所谓的实模式其实就是8086及早期处理器所仅有的一种操作模式,而在80286之后还定义它不过是为了维持与早期兼容而已。
而且在实模式下程序可以无限制地直接访问所有可寻址的存储器,I / O地址和外围硬件。而且实模式也不支持内存保护,多任务或代码特权级别。
所以总结下实模式大概有以下特性:
- 具有最大 1M的存储器访问空间
- 所有寄存器均为16位
- 默认CPU操作数长度也为16位
- 具有分段,通过段基址+偏移寻址
- 没有内存分页机制,没有虚拟地址,只有物理地址
(显然,从上面的描述里根本不可能总结出4、5两条,那两条写在那里其实只是为了好看和维持总结的完整23333
不过关于分段和分页会在下面讲到
保护模式
其实最早的保护模式在80286就已经被提出了,但由于受限它的硬件,其保护模式极其原始,因此很快便被淘汰。而到后来在80386上出现了真正的32位地址,即它的大多数寄存器和地址总线均扩展到了32位。这也意味着它的寻址空间达到空前的大小——4GB,在早期还不像现在加个内存都要8G起步的年代,寻址仅靠段内偏移便可以访问内存的每一个角落,这便被称之为“平坦模型”。这一点在64位下,即长模式中体现的更为明显。
当然从保护模式这个名称你应该也可以猜出,保护模式相较于实模式更为安全。在保护模式中内存的管理方式出现了两种,即分段和分页,而在页(页默认大小为4k)上却是定义了不同的权限,这样可以更好的限制程序对相应内存的操作。
从下图载入的程序也可以很直观的看出,不同内存页所拥有不同权限。
所以总结下保护模式大概有以下特性:
- 32位的数据总线地址总线
- 除段寄存器外,大多数寄存器为32位宽度。
- 受保护的物理内存
- 通过分段和分页机制寻址
- 多任务处理
- 拥有四个特权级别:ring0-ring3
(显然,我这里要说什么你应该能猜到
长模式
其实相较于上面那个两个执行模式,下面的这些写起来就没什么意思了,不过想着既然名字是 x86架构的微处理器模式 还是放在一起一并写了吧。
所谓的长模式不过是64位操作系统可以使用64位指令和寄存器的模式。此时64位程序以称为64位模式的子模式运行,而32位程序和16位保护模式程序则以兼容模式执行。在长模式下实模式和虚拟8086模式程序是不能运行的。
其实长模式实在没什么好讲了,毕竟它拥有着2的64次方的寻址空间。对它而言内存一切平坦,光靠着他那巨大的寻址能力就可以寻遍内存所有空间,分不分段已经没什么意义了。
虚拟8086模式
在上面也说了在80386微处理器及之后版本中,只有在CPU刚启动时才会处于实模式下,当操作系统运行起来后便会切换到保护模式。而实模式应用无法在处理器运行保护模式操作系统时直接在保护模式下运行,所以虚拟8086模式(也称为虚拟-真实模式,V86模式或VM86)的作用正是为了可以在保护模式下执行实模式的程序。因此归根结底,所谓的虚拟8086模式只是保护模式的功能,而不是真正实模式。
系统管理模式
系统管理模式(SMM)是一种专有操作模式,用于处理系统的功能,例如电源管理,系统硬件控制等。它只能用于系统固件(我想你最熟悉的应该就是BIOS了),不能用于普通的程序。在SMM下与之前实模式与保护模式都不同,在其之下你所熟知的所有中断都将被屏蔽。而且SMM下也不存在分段和分页机制,换而言之CPU可以直接通过真实的物理地址寻址4GB的空间。并且在SMM下可以执行所有的特权指令,一切内存保护全部失效。
0x02 内存寻址
实模式寻址
在总结实模式时就就已经提到了,它是靠着靠段基址+偏移去寻址的。在开始你可能并不知道为什么,但刚刚也说了8086CPU他有着16位的数据总线和20位的地址总线。这样你就会发现一件很难受的事情,16位的数据总线所能表达的数据只能是0-2^16 (即0-64KB),而20位的地址总线的寻址能力却是0-2^20(即0-1MB),那么问题来了当地址总线需要寻到64KB向上的地址时它要怎样将这超过数据总线表达能力的数据给它呢?
因此分段寻址机制就出现了,8086CPU会将1MB大小的内存空间按段划分,每个段最大不超过64KB,并不是一定为64KB。在分段完成后每个段就会有一个段基址,而这段基址正是每个段的首地址高16位,他们被存放在段寄存器中。之后寻址时就可以用 段基址+段内偏移 的方式来表达20位长的地址。
举个例子:
1 | 段寄存器里所存地址值为 :0x5000 |
保护模式寻址
到了保护模式下寻址方式复杂性就要远远超过实模式了,这不单单是因为增加了分页机制,就连原本的分段机制也被改的复杂了许多。毕竟像实模式那样直接靠着 段基址+偏移 寻址也是过于危险。而且因为引入了分页,内存的管理机制也变成了两种,即 段式内存管理 和 页式内存管理。不过在在讲它们之前你需要知道x86下的三种不同的地址,其实这部分的内容应该放在这篇文章开始前,不过我觉得放在这,在你看完实模式寻址,对分段有了初步了解后,理解起来应该会更容易些。
1 | 1.逻辑地址:我想你在c语言里一定使用过指针变量,而那个指针里存的就是所谓的逻辑地址,显然在x86下每一个逻辑地址都是由段寄存器和偏移组合而成。 |
分段存储管理
首先,值得一提的是从16位到32位的x86汇编中,倘若你有所了解的话,你会很惊奇的发现原本在16位下的6个段寄存器在32位下依然被保留了。因为你没学过汇编,在这里我也不会过多的谈及它们的特性。不过你要需要知道的是在保护模式下它们里面存到却不再是段基址,而是一种被称为段选择符(也被翻译为段选择子)的东西。
而这段选择子(为了防止你看乱,下面将一律使用此名称)里存放着用于寻找段描述符的索引号,而所有的段描述符在一起组成了描述符表,因此段选择子正是通过其索引号在描述符表中找出相应的段描述符。在段描述符中又有着段的首字节线性地址(即段基址),因此我们就能很清晰的理出逻辑地址转换到到线性地址的宏观流程。上面我也说了,线性地址大概算是逻辑地址转换到物理地址的一个过渡,而之所以会有这个过渡,大概就是因为分页的存在吧。也因此当不启用分页内存管理的情况下,此时得到的线性地址就是我们所需要的物理地址。
我知道看到这,突然冒出了一堆新的名词你大概又开始懵了,不过下面我会慢慢解释。
段描述符
对于段描述符你只需要知道它是由8个字节(即64个位)组成用来描述段的特征以及它其中一个字节放的是段基址(这在上面也有提过)就好。至于其他7个字节的作用,这里不会提及。
描述符表
在上面也大致说过,所谓的描述符表,就是由段描述符组成的,之后通过段选择子里的索引号来寻找相应的段描述符。其实说到这你应该也能感觉出,这张表就像是一个数组,而段选择子里的索引号就像是数组的下标。
不过要特别提及的是,描述符表是有两种的,即 全局描述符表(Global Descriptor Table)简称GDT 和 局部描述符表(Local Descriptor Table)简称LDT。
在整个系统中,每个CPU都会有且仅有一张只属于自己的全局描述符表GDT,而且GDT可以被放置在内存的任意位置。但是因为CPU必须知道GDT的入口地址(基地址),所以在CPU上还有着个名为GDTR的寄存器,这个寄存器里面存放的正是GDT的基地址和GDT的表长。这样CPU就可以根据这个寄存器值来访问GDT。
不过到了局部描述符表LDT就有点不一样了,LDT可以有不止一张,准确的说,每一个进程都可以拥有一张属于自己的LDT,而且LDT是嵌套在GDT里的。跟GDT相同的是,LDT也拥有着属于它的寄存器LDTR,但是LDTR里内容却是一个段选择子(!!注意与上面那个段选择子区分!!)。显然,既然LDTR不能用来记录LDT的基地址和大小,那势必会记录在其他地方。之前也说过LDT是嵌套在GDT里的,之所以用了嵌套这个词,正是因为有关LDT的基地址等信息是记录在GDT的一个段描述符里的,有一个LDT便在GDT中有其对应的描述符。而LDTR里段选择子的作用正是用来索引GDT中相应的LDT的段描述符。所以说来在上面将描述符表想成一个数组其实并不准确。
段选择子
依然先提醒,这个段选择子是放在段寄存器中索引的对象是段描述符,不像LDTR索引的是GDT里相应的LDT描述符。之前也说了段寄存器是延续自16位下,所以这6个段寄存器即便是已在32位下,其依旧是16位大小。
如上图,便是段选择子的结构,在这16个位里面其实索引只用了13位,在这里面还有两个字段分别是占了1个位的TI和占了另两位的RPL。
- TI:当TI值为0时,表示索引对象为全局描述符表GDT。当TI为1时,表示索引对象为局部描述符表LDT。
- RPL:至于RPL探讨分段机制时可以暂时不关注。RPL的作用是代表当前段选择子的特权级。
还记得保护模式下面的总结第6条吗,拥有四个特权级别:ring0-ring3。而这四个特权等级,从0到3特权级依次降低,0为内核态,3为用户态。而进程中每个段都有一个相应的特权级,只有当段选择子的特权级不小于所要访问段的特权级时,才会被准许访问。同样还记得在讲段描述符的最后那句话吗,至于其他7个字节的作用,这里不会提及。而这7个字节里正有两个字节是用来定义所描述段的特权等级。
好了,讲到这该解释的差不多也解释完了,我们也能真正的整理分段机制的流程了。
整个分段机制最复杂的地方无非就是索引时用的究竟是GDT或LDT,即段选择子里TI=0或TI=1罢了。
其实当TI为0时相对简单些,只要在GDT里按段选择子索引寻到相应段就好,之后再结合偏移地址便可以在分页机制未开启时准确的寻到相应的物理地址。
至于TI=1,即段选择子索引的是LDT时,因为其嵌套的关系的确稍显复杂了些,不过也还好。
你要明白LDTR中的段选择子是用来索引到相应LDT描述符,之后根据索引到的LDT描述符里所给的LDT基址便可以在内存中找到相应的局部描述符表LDT。而段寄存器中的段选择子此刻才真正起作用,因为它的TI=1,所以其索引对象是LDT。它根据自己的索引值在LDT中继续寻到相应的段描述符。在之后就像上面一样了,根据段描述符确定了内存线性地址的基地址,再加上偏移地址,在分页机制未开启时便可以直接寻到相应的物理地址。
其实当你能看懂上面的两张图时,我想保护模式下的分段寻址机制,你应该就已经明白了。
分页存储管理
当你大致明白分段存储管理机制时,你肯定能看出所谓的分段的作用对象一直都是线性的内存地址,不过到了下面的分页你会发现,内存的表示从线变成了一个面。
我之前也提过,线性地址就像是一个过渡。当分页机制未开启时所谓的线性地址就是物理地址,而在这里分页机制开启了,也正是分页机制实现了线性地址到物理地址的转换。我们也能很好的梳理出逻辑地址到物理地址的转换流程。
从分页这个名字你应该也可以猜出,它的管理对象是页,而这些页就是大小固定的内存块。分页机制把整个线性地址空间和主存上整个物理地址空间都看作页的形式,线性地址上任何一页都可以映射在物理地址上的任何一页,而可以被映射的物理地址上的页被称为页框(page frame)。在这里你应该也可以很明显看出与段机制的不同,段之于线性地址它只能是连续的,而页之于物理地址却是可以随意的映射。
如前所述,分页是将地址空间分成大小相同的页,而从80386起,Intel处理器的分页单元默认将页划分为4KB大小。因此假定此刻是32位则根据其寻址能力,线性地址空间大小便达到4G(2^32),此时页大小4K,如果装在一张页表中索引的话,你口算一下就可以得出个页表项会达到2^20个。也就是说在每个页表项占4个字节时,一共就会消耗4M的内存,而且这些内存还必须是连续的。显然这样的连续是不合分页机制规矩的,因此页表真正的存储结构是一个两级页表结构。(虽然我一直觉得这个结构是一个不可理喻的存在)
在这两级页表结构中,第一级称为页目录,存储在一个4k大小的页面内。则这个页目录一共有2^10个表项,每个表项4字节。而这些表项每一项都指向第二级的一个页表(即里面存着页表基址)。同样第二级每个页表也是存储在一个4k大小的页面内,包含2^10个表项,不过它们的每个表项包含的却是一个页的物理基址。到这里两级页表结构已经很清晰了,不过要将线性地址转换为物理地址,肯定是需要线性地址参与的。
首先要解释一下那个突然出现的CR3,它其实是一个寄存器,也会被称为页目录基地址寄存器PDBR,当分页机制开始作用时CR3寄存器会被相应的进程置为进程所对应的页目录基址,以此来确定相应的页目录。
其实根据上图线性地址转换为物理地址的流程已经很清晰了。就和分段里的段选择子类似,当分页机制开启时,线性地址也是作为一个索引存在。在32位的线性地址里,高10位上(即22-31位)存放着页目录的索引,通过这个索引确定相应的目录项。之后通过目录项确定页表的基地址,此刻线性地址的12-21位继续做为页表的索引,从而确定相应的页表项。而后页表项里又得到所映射物理页的基地址,同时线性地址的低12位(即0-11)便是页内偏移,通过相加得到最终的物理地址。至此线性地址到物理地址转换完成。
到这里我们就可以完整的整理出,在分段和分页机制同时开启(即段页式内存管理)时,内存寻址的全过程。
讲到这里保护模式下内存寻址流程也是梳理的差不多了,其实你再回过头来看看会发现,分页的流程较之分段其实要简单不少,毕竟分段还要分两种情况呢。不过我也说了是流程,关于分页存储机制还有很多我是没写的,就比如内存页的分配和回收算法,线性地址如何才能更快的映射到物理内存上,又或者是当线性地址空间很大,随之页表也会很大,这样又该如何解决。 但毕竟这篇文章的主线是内存寻址,我觉得写到这里就已经可以了。以后如果有缘,我会再开一篇新的文章来写这些。
0x03 Extra
参考文献
《深入理解计算机系统》
《Glibc内存管理 Ptmalloc2 源代码分析》
《链接器和加载器》
《深入理解Linux内核》
《IA-32 架构软件开发人员手册》