你好,我是海纳。
上一节课我们讲了虚拟内存的概念,分析了线性地址(虚拟地址)是如何映射到物理地址上的。
不过,在x86架构诞生之初,其实是没有虚拟内存的概念的。1978年发行的8086芯片是x86架构的首款芯片,它在内存管理上使用的是直接访问物理内存的方式,这种工作方式,有一个专门的名称,那就是实模式(Real Mode)。上节课我们也曾简单提到过,直接访问物理内存的工作方式让程序员必须要关心自己使用的内存会不会与其他进程产生冲突,为程序员带来极大的心智负担。
后来,CPU上就出现虚拟内存的概念,它可以将每个进程的地址空间都隔离开,极大地减轻了程序员的负担,同时由于页表项中有多种权限保护标志,极大地提高了应用程序的数据安全。所以人们把CPU的这种工作模式称为保护模式(Protection Mode)。
从实模式演进到保护模式,x86体系架构的内存管理发生了重大的变化,最大的不同就体现在段式管理和中断的管理上。所以今天这节课,我们会围绕这两个重点,让你彻底理解x86体系架构下的内存管理演进。你也能通过这节课的学习,学会阅读Linux内核源码的段管理和中断管理的相关部分,还可以增加调试coredump文件的能力。
这里我们就按照时间顺序,从8086芯片中的实模式开始讲起。
8086芯片是Intel公司在1978年推出的CPU芯片,它定义的指令集对计算机的发展历程影响十分巨大,之后的286、386、486、奔腾处理器等等都是在8086的基础上演变而来。这一套指令集也被称为x86指令集。直到今天,很多大学里的微机原理课和汇编语言课还是使用8086进行讲解。
8086的寄存器只有16位,我们也习惯于称8086的工作模式是16位模式。而且,后面的CPU为了保持兼容,在芯片上电了以后,还必须运行在16位模式之下,这种模式有个正式的名字,叫做实模式(Real Mode)。在实模式下,程序员是不能通过内存管理单元(Memory Management Unit, MMU)访问地址的,程序必须直接访问物理内存。
那实模式下,我们是怎么访问存储的物理地址的呢?
8086的寄存器位宽是16位,但地址总线却有20位,地址的编码可以从20位0到20位1,这意味着8086的寻址空间是2^20 = 1M。但是在写程序的时候,我们没有办法把一个地址完整地放到一个寄存器里,因为它的寄存器相比地址少了4位。
为了解决这个问题,8086就引入了段寄存器,例如cs、ds、es、gs、ss等。段寄存器中记录了一个段基地址,通过计算可以得到我们存储的真实地址,也就是物理地址。物理地址可以使用“段寄存器:段内偏移”这样的格式来表示,计算的公式是:
物理地址 =段寄存器 << 4 + 段内偏移
不过,在我们写汇编代码的时候(如果你对汇编不熟悉,可以先去看看我前面讲的导学(一)和导学(二)),也不一定就要使用段寄存器来表示段基址,也可以使用“段基址:段内偏移”这样的立即数的写法,比如你可以看下这个节选自Linux的bootsect中的代码:
BOOTSEG = 0x7c0
_start:
jmpl $BOOTSEG, $start2
start2:
movw $BOOTSEG, %ax
movw %ax, %ds
...
这块代码里,它跳转的目标地址就是0x7c0 << 4 + OFFSET(start2)。跳转成功以后,cs段寄存器中的值就是段基址0x7c0,start2的偏移值是8,所以记录当前执行指令地址的ip寄存器中的值就是实际地址0x7c08。
而且,这块代码里也包含了段基址和段内偏移值这种地址形式,这显然有别于我们所讲的虚拟地址。这种包含了段基址和段内偏移值的地址形式有一个专门的名字,叫做逻辑地址。你可以看到,虚拟地址是一个整数,而逻辑地址是一对整数。所以说,在8086芯片中,逻辑地址要经过一步计算才可以得到物理地址。
在8086中,cs被用来做为代码段基址寄存器,比如上面示例代码中的jmp指令,跳转成功就会把段基址自动存入cs寄存器。ds被用来做为数据段基址寄存器,你可以看看下面这个代码:
INITSEG = 0x9000
....
movw $INITSEG, %ax
movw %ax, %ds
movb $0x03, %ah
xor %bh, %bh
int $0x10
movw %dx, (0)
movb $0x88, %ah
int $0x15
movw %ax, (2)
上述代码的第7行执行0x10号BIOS中断,它的结果存放在dx寄存器中,然后第8行,将结果存入内存0x90000,9至11行再把0x15号BIOS中断的结果存到0x90002处。
在寻址时,我们并没有明确地声明数据段基址存储在段寄存器ds中,但是CPU在执行时会默认使用ds做为数据段寄存器。类似的还有ss,它是做为栈基址寄存器,当我们在使用push指令的时候,要保存的数据会放在ss:(sp)的位置。
CPU没有强制规定代码段和数据段分离,也就意味着,你使用ds段寄存器去访问指令,CPU也是允许的。但在实际编程时,我们还是会把数据和代码分到不同的段里,并且将数据段的起始地址放到ds寄存器,把代码段的地址放到cs寄存器。这种按功能分段的管理内存方式就是段式管理。关于段式管理和页式管理的对比,我们稍后会加以介绍。
到这里8086的实模式,我们已经基本讲完了。8086是最古老的x86芯片,在实模式下,它只能直接操作物理内存,非常不便于编程,这一点,我们在第1节课也提到了。接下来,我们把目光转向x86体系架构中的保护模式,它是实模式的进一步发展。
经过十年的发展,x86 CPU迎来了历史上使用最广泛、影响力最大的32位CPU,这就是i386芯片。i386与8086的一个很大的不同,就是它采用了全新的保护模式。这个体现在,i386中的段式管理机制,相比8086发生了重大变化;同时,i386芯片在段式管理的基础上,还引入了页式管理。
i386在完成各种初始化动作以后,就会开启页表,从此程序员就不必再直接操作物理内存的地址空间了,代替它的是线性地址空间。而且由于段和页都能提供对内存的保护,安全性也得到了提升,所以这种工作模式被称为保护模式(Protection Mode)。i386的保护模式是一种段式管理和页式管理混合使用的模式。
至于页式管理,我们第1节课已经讲过了,所以这里我们就来看一下相比8086,段式管理在i386上有了哪些变化。
变化一:段选择子和全局描述符表
在i386上,地址总线是32位的,通用寄存器也变成32位的,这就意味着因为寄存器位数不够而产生的段基址寄存器已经失去了作用。
但是i386没有直接放弃掉段寄存器,而是将它进化成了新的段式内存管理。段寄存器仍然是16位寄存器,但是其中存的不再是段基址,而是被称为段选择子的东西。
相比8086芯片,i386中多了一个叫全局描述符表(Global Descriptor Table, GDT)的结构。它本质上是一个数组,其中的每一项都是一个全局描述符,32位的段基址就存储在这个描述符里。段选择子本质上就是这个数组的下标。具体你可以看看下面这张图:
GDT的地址也要保存在寄存器里,这个寄存器就是GDTR,这个做法和第1节课我们讲到的CR3寄存器的做法十分相似。
在上面这张图中,CPU在处理一个逻辑地址“cs:offset”的时候,就会将GDTR中的基址加上cs中的下标值来得到一个段描述符,再从这个段描述符中取出段基址,最后将段基址与偏移值相加,这样就可以得到线性地址了。这个线性地址就是我们第1节课中所讲的虚拟地址。
得到线性地址以后,剩下的工作我们就非常熟悉了:由CPU的MMU将线性地址映射为物理地址,然后就可以交给地址总线去进行读写了。
变化二:段寄存器对段的保护能力增强
在8086中,段寄存器只起到了段基址的作用,对于段的各种属性并没有加以定义。例如,在实模式下,任何指令都可以对代码段进行随意地更改。
但在i386中,对段的保护能力加强了,我们先来看一下i386中段描述符(也就是GDT中的每一项)的结构。
你会看到,描述符中除了记录了段基址之外,还记录了段的长度,以及定义了一些与段相关的属性,其中比较重要的属性有P位、DPL、S位、G位和Type。我们接下来一个个来分析。
P位是一个比特,指示了段在内存中是否存在,1表示段在内存中存在,0则表示不存在。
DPL,占据了两个比特,指的是描述符特权级,英文是Descriptor Privilege Level。Intel规定了CPU工作的4个特权级,分别是0、1、2、3,数字越小,权限越高。
以Linux为例,Linux只使用了0和3两个特权级,并且规定0是内核态,3是用户态。特权级的切换是比较复杂的一种机制,但Linux只使用了中断这一种,后面我们会再讲到中断。
接下来我们再看S位,S为1代表该描述符是数据段/代码段描述符,为0则代表系统段/门描述符。门是i386提供的用于切换特权级的机制,有调用门、陷阱门、中断门、任务门等。在Linux系统中,只使用了中断门描述符。
然后是G位,它指的是定义段颗粒度(Granularity),它的值为0时,段界限的单位是字节,为1时段界限以4KB为单位,也就是一页。
我们也可以从图中看出定义段长度的“段界限”字段并不是连续的,它一共有20位,分散在两个地方。当G=1时,段界限的最大值是2^20 * 4K = 4G,这是i386一个段的最大长度。
最后是Type属性,它定义了描述符类型,我把比较重要的类型用表列在了下面,你可以看看。
到这里,我们已经解释清楚了,i386中保护模式相比8086实模式在段式管理上的升级。那么在现代的CPU和操作系统中,段式管理和页式管理又是怎样的关系呢?要讲清楚这一点就要先对比这两种内存管理方式的优缺点。
段式管理会按功能把内存空间分割成不同段,有代码段、数据段、只读数据段、堆栈段,等等,为不同的段赋予了不同的读写权限和特权级。通过段式管理,操作系统可以进一步区分内核数据段、内核代码段、用户态数据段、用户态代码段等,为系统提供了更好的安全性。
但是段的长度往往是不能固定的,例如不同的应用程序中,代码段的长度各不相同。如果以段为单位进行内存的分配和回收的话,数据结构非常难于设计,而且难免会造成各种内存空间的浪费。页式管理则不按照功能区分,而是按照固定大小将内存分割成很多大小相同的页面,不管是存放数据,还是存放代码,都要先分配一个页,再将内容存进页里。
所以,你可以看到,相比页式管理,段式管理的优点是提供更好的安全性,按照内存的用途进行划分更符合人的直观思维。它的缺点就是由于不定长,难于进行分配、回收调度。
而页式管理的优点是大小固定,分配回收都比较容易。而且段式管理所能提供的安全性,在现代CPU上也可以被页表项中的属性替代,所以现在段式管理已经变得越来越不重要了。像64位Linux系统,它把所有段的基地址都设成了从0开始,段长度设置为最大。这样段式管理的重要性就大大下降了。
但是,如果我们以x86的历史演进来看,你会发现段式管理其实是最早出现的(8086芯片),然后才出现了页式管理(i386芯片)。而且,我们现代的x86架构的CPU,也同时兼容段式管理和页式管理,我们可以认为是一种混合的段页式管理(当然,并不是所有人都认可这种命名方式)。
总的来说,现代的操作系统都是采用段式管理来做基本的权限管理,而对于内存的分配、回收、调度都是依赖页式管理。
到这里,我们就讲清楚了8086实模式到i386保护模式下段式管理的演进,并且进一步分析了段式管理和页式管理的对比和现状。
保护模式相比实模式,发生重大变化的不止是内存管理,同时还有中断管理。因为管理中断的结构与段式管理的全局描述符表的结构非常相似,所以我们在讲保护模式时也一起讲一下。你可以将中断机制与段管理机制比较着一起学习。
中断描述符表(Interruption Description Table, IDT),是i386中一个非常重要的描述符表,它也是保护模式对比实模式的另一大不同。你在后面学习fork、execve的实现时,涉及到的写保护中断,缺页中断等机制都要依赖它。
CPU与外设之间的协同工作是以中断机制来进行的。例如,我们敲击键盘的时候,键盘的控制器就会向CPU发起一个中断请求。CPU在接到请求以后,就会停下正在做的工作,把当前的寄存器状态全部保存好,然后去调用中断服务程序。当然,这个过程中有一些是CPU的工作,有一些是操作系统的工作,但因为我们关注的重点是内存,所以就没必要计较这里面细微的差别了。
中断根据中断来源的不同,又可以细分为Fault、Trap、Abort以及普通中断。我们这门课对它们也不加区分,例如执行除法的时候除数为0的情况、访问数据时权限不足引发的保护错误、由用户使用int指令产生的中断等,虽然中断源不同,它们的类型也不相同,但我们统一称它们为中断。
硬件负责产生中断,CPU会响应中断,但是中断来了以后要做什么事情是由操作系统定义的。操作系统要通过设置某个中断号的中断描述符,来指定中断到达以后要调用的函数。中断描述符表(IDT)的作用就体现在这了,它的本质就是中断描述符的数组。
IDT的基地址存储在idtr寄存器中,这和GDTR的设计如出一辙。每个中断都有一个编号与其对应,我们称之为中断向量号。中断向量号是CPU提前分配好的,我也把比较重要的中断向量号放在了下表里,你可以看看。
在这个表里,我们没有看到前边所提到的键盘中断,这是因为键盘中断都是由一个名为8259A的芯片在管理。
两片级联的8259A芯片可以管理16个中断,其中包括了时钟中断、键盘中断,还有软盘、硬盘、鼠标的中断等等。这些中断的中断向量号是可以通过对8259A编程进行设置的。虽然8259A的编程比较繁琐,但好在只需要操作系统开机引导时设置一次。
你也可以看到,Linux系统把中断向量表的32号中断(用户自定义中断的第一位)设置成8259A的0号中断,也就是说IDT的32号至47号都分配给了8259A所管理的中断。键盘、软盘、硬盘、鼠标的中断服务程序就设置在这里。
关于中断,我们掌握这么多就已经足够了,更多的知识我们会在后面的课程按需讲解。
现在,我们可以通过一个例子,体验一下中断的使用。在Linux系统上,我们把下面这个代码保存到文件hello.c中,并且使用"gcc -o hello hello.c"编译,得到可执行程序hello。再运行它,你就可以看到屏幕上打印出一行"hello"。
// compile command : gcc -o hello hello.c
void sayHello() {
const char* s = "hello\n";
__asm__("int $0x80\n\r"
::"a"(4), "b"(1), "c"(s), "d"(6):);
}
int main() {
sayHello();
return 0
}
相比于使用printf进行打印,需要引入头文件"stdio.h",我们这段代码里没有使用任何头文件,但一样可以在控制台上进行打印。
这是因为,我们使用了0x80号中断进行了Linux系统调用。系统调用号在eax中,也就是4,代表write这个调用。第一个参数在ebx中,其值为1,代表控制台的标准输出;第二个参数是字符串"hello"的地址,在rcx中;第三个参数是字符串的长度,也就是6,存储在edx中。
这样,我们就通过中断,就不必再使用C语言的printf进行输出,这就绕过了C语言的基础库,完成了向控制台打印的功能。
今天我们拆解了x86体系架构下的实模式和保护模式,也认识了两个x86演进史上非常重要的CPU。
8086是16位的CPU,我们称8086的工作模式为实模式,它的特点是直接操作物理内存,内存管理容易出错,要十分小心,代码编写和调试都很困难。
之后出现的i386,则采用了和实模式不同的保护模式。相比实模式,i386中的保护模式,采用了页式管理,但它没有彻底放弃8086的段式管理,而是将段寄存器中的值由段基址变成了段选择子。段选择子本质是GDT表的下标值,段基址都转移到GDT中去了。
段式管理负责将逻辑地址转换为线性地址,或者称为虚拟地址,页式管理负责将线性地址映射到物理地址。i386的保护模式采用了段页式混合管理的模式,兼具了段式管理和页式管理的优点。
除了段页式内存管理这个不同之外,保护模式和实模式的区别还体现在中断描述符表(IDT)上。IDT是保护模式的一个重要组成部分,它保存着i386中断服务程序的入口地址。
8086和i386对x86架构的CPU影响巨大。直到今天,x86架构的CPU在上电以后,为了与8086保持兼容,还是运行在16位实模式下,也就是说所有访存指令访问的都是物理内存地址。在启动操作系统后,才会切换到保护模式下进行工作。
我们今天这节课只讲了16位CPU和32位CPU,并没有讲64位CPU的段式管理是怎么做的。实际上64位CPU的段式管理和32位的结构非常相似,惟一的区别是段描述符的段基址和段长度字段都被废弃了,也就是说不管你将段基址设置成什么,都会被CPU自动识别为0。
那么请你思考,CPU为什么要这么设计呢?一方面,它还保留了段寄存器,另一方面,它又不再起到逻辑地址转换线性地址的作用,这不是很奇怪吗?请你站在CPU架构师的角度思考一下原因。欢迎你在留言区分享你的想法和收获,我在留言区等你。
好啦,这节课到这就结束啦。欢迎你把这节课分享给更多对计算机内存感兴趣的朋友。我是海纳,我们下节课再见!