CPU TechTalk:x86/x64内存管理(I)

​    大家好,这里是第五位面壁者。

    在正式开始之前要先和大家说两声抱歉,第一句抱歉是因为上次我有说将RWLinux放在CSDN上让大家免积分随意下载,但是后来经过小伙伴提醒发现,CSDN系统总是自动提高下载积分限制,如果我强制让所有人零积分下载,CSDN审核就一直过不去,这一点是我欠考虑了,大家如果需要的话还是发邮件到我的邮箱,地址在这里;第二句抱歉为了我的更新速度,诸位请放心,x86这个系列,我肯定会更完的,但是受限于个人水平和精力,速度肯定没那么快。同样,所有平台同步更新,账号如上。

    好了废话不多说,开始我们今天的内容,这一期我们一起学习x86架构的内存管理,这一期可以说是这个系列的基石和精华,往后的中断与异常管理和任务管理都和这一章紧密相连,而搞懂这三章,对大家理解操作系统底层运行原理也大有裨益,把硬件和操作系统底层原理搞清楚了,才能啃得动虚拟机高并发多线程这些高端技能。具体的内容安排,咱们由难到易,先从8086实模式的内存管理讲起,然后再到保护模式,最后是IA-32e 模式,如果由余力的话,咱们再一起看看Linux的内存管理。这里要多说一句,x86最显著的特定就两个字:兼容,所以有时候学习x86就跟考古似的,如果某个机制你在2021年的文档张无法理解,可能找一份1994年的文档再看看就搞明白了,这也是为什么我要先从8086讲起了。

    所有的硬件都是为软件服务的,我们不能脱离于软件去讲硬件的原理,所以在讲8086的内存管理之前,我们先来看看8086时代操作系统的运作方式,这样我们可以知道当时的操作系统是怎么使用硬件的,然后我们再反过来看8086是怎么满足操作系统的这些需求的。单任务操作系统,咱们这个年纪能叫出来名字也只有DOS了。DOS,全名是磁盘操作系统,和现代操作系统的概念其实差的特别远,严格的意义上来说,DOS都算不上个操作系统,它并没有向用户完全屏蔽用户信息,在某些情况下,程序员可能只有在充分了解硬件细节的情况下才能写出高性能的DOS软件。从整体框架上来看,DOS为用户提供了三个基本部件,第一个就是命令行CLI,为用户提供了最基本的交互界面;第二个是程序加载器,当你的程序没有在运行的时候,默认肯定是磁盘里的,有了程序加载器,我们只需要在命令行上正确的输入路径和程序文件名,加载器会把程序加载到内存里,然后让程序指针跳转到被加载程序对应的内存地址处开始执行;第三个就是OS Service是,我们搞操作系统,是为了方便人类使用计算机,更准确的说是利用计算机里的CPU提供的算力,这就需要不断的有原始数据从外设送进CPU,或者运算结果不断的从CPU送到外设,比如我们从键盘输入1+1,然后在屏幕上可以看到1+1=2。在8086那个年代,CPU想要和外设读取数据,只能通过IO/OUT指令操作外设对应的IO端口, 因为所有的外设寄存器都映射到了IO地址空间。这对于新人来说可能不好理解,咱们对着图来说。我们可以这样理解,RAM对于CPU来说就跟自家后院儿似的,想拿什么数据直接搬运就行,所以当CPU看到一笔MOV AX,[0x0],它就知道这是一笔内存地址直接寻址,地址为0,于是在地址总线配合S0S1S2就把这笔memory read cycle放到了地址总线上,RAM芯片看到后就明白这是跟我来要数据了,然后就响应,随后把数据从数据总线送回去。IO操作也差不多,过程类似,不过就是S0S1S2会告诉外设芯片,这笔IN AX,60H是和你要数据,外设芯片能解码,也会把数据送回去。这就是x86为什么会有IO地址空间的原因,只是因为8086没有把RAM和外设统一编制而已。等到了后来,x86 chipset可以把内存和外设统一编址,产生了MMIO机制,我们才可以向访问内存一样访问IO设备的寄存器。这就是x86CPU控制硬件的基本原理,然后我们再说会DOS的OS Server,抛开MMIO不谈,因为那个时代MMIO还没出现,如果想在DOS环境里控制硬件,程序员可以自行编写程序利用IO/OUT指令直接操控外设,但是这样子需要程序员对硬件了如指掌,写起来也很繁琐;对于一些基本的硬件,比如键盘屏幕打印机等,BIOS提前进行了初始化,并且在地址为0的位置建立了中断向量表,BIOS会负责建立部分中断向量函数用户进行外设硬件的控制,DOS程序员可以在程序里使用软中断指令INT n直接调用现成的,其中n是中断向量号,比如0号调用就是从键盘读取一个字符。业界管这样的硬件控制方式叫做BIOS功能调用。

    此外还有一种方式叫做DOS功能调用,原理上其实和BIOS调用类似,也是借助INT n软中断,只不过响应的中断处理函数是DOS系统负责初始化并将函数地址注册到中断向量表中的。例如上图里的9号调用,先把数据放到DS里,然后往AH里写9,再调用21H,21H的中断处理函数就会产看AH,看到是多少又会再此去中断向量表那里拿到对应的中断处理函数的地址,再跳转过去执行。DOS系统程序远如果想增加系统服务,只需要编好中断处理函数然后注册到中断向量表即可。这个程序里面用了调用了两次21H,是因为你调用完09,09执行完以后,会跳回到当前这个程序,然后程序再调用一次4C结束运行,把系统控制权交还给了DOS系统,这个4C系统调用的作用就是结束当前程序。

    了解完了DOS的三大基础部件后,我们现在可以想象DOS的运行过程,先上电,然后BIOS初始化硬件建立中断向量表建立BIOS调用中断处理函数,引导DOS到内存,然后DOS初始化,建立DOS调用中断处理函数再注册到中断向量表,然后建立CLI等待用户输入,用户输入一个程序,程序加载器把程序文件从磁盘加载到内存并跳转过去,程序开始执行,中间进行若干此系统调用,执行完毕再调用一次4C,然后回到DOS,然后等待用户键入下一次程序或者任务。这是一个完全串行的过程,中间的每步都是环环相扣的,上一条完了才能下一条。那在这种单任务的环境下,其实DOS对于8086的要求也不会很高,程序和数据分开放这是起码的吧,毕竟程序不会变,数据一直变,但是分开放的同事还不能耽误随时用;过程调用和返回也是起码的吧,这中间就包含被中断现成的保护和恢复;然后呢,好像也没啥了,接下了我们看看8086也就是x86实模式是怎么实现这两个需求的

    我们先来解决第一个问题,程序和数据分开来放的同时还要合起来用。8086给出的回答就是内存的分段机制。之前我们讲过8086的寄存器组,其中有个叫段寄存器的,一开始有四个后来扩展为六个,分为是CS/DS/SS/ES/FS/GS,后面的不管,我们主要关注CS/DS/SS。8086使用其中的CS配合程序计数寄存器IP用于访问代码或者指令,CS*4+IP指示了CPU当前要读取的指令的地址,相当于8086限制CPU只能在CS为base起始地址,以IP为offse的范围内进行取指操作,由于IP是16位宽,那么这个范围的大小就是64KB,我们将这个64KB大小用来存放程序代码的连续内存地址空间叫做8086的一个代码段,不同段码段之间通过修改CS的数值进行跳转。举个例子来说明一下。比如说上面这张图里,程序A放在1000:0000起始的这个代码段里,程序B放在2000:0000起始的这个代码段里,我把程序A执行完了,想去执行程序B,很简单把CS从1000改成2000,然后IP清零就行;数据段的访问本质也类似类似,利用的是DS*4+offset,这个offset可以是立即数也可以用通用寄存器里的数据,由于8086立即数和寄存器的位宽都是16位,所以一个数据段大小也只能是64KB,DS是默认的数据段起始地址,ES/FS/GS是附加的,如果想要从这里访问数据,需要显示的声明一下,如这边的代码所示,大家都学过微机原理,我就不多说了。有的同学可能犯嘀咕了,刚才讲代码段的时候我拿着这个图说的,但是这图里这俩位置明明写者数据段啊,莫非我搁这里瞎讲呢。不是瞎讲,我是故意的,这么是想让各位避免对内存分段的一个误解。说到分段机制,最直观的想法就是,经过分段机制,把内存分成一截截相互独立的段落,里面有的是数据段用来存放数据,有的是代码段用来存放代码or指令,但大家要注意,一段内存空间是代码段还是数据段是当你的CS或者DS切过去访问它的时候才有所谓代码或者数据的区分,你不访问它的时候,它就是一段内存地址空间,里面放的东西就是一堆普通的二进制数据。我们还是拿1000:0000和2000:0000打个比方好了,比如我们当前的程序内容是把1000:0000起始的这64KB拷贝到2000:000这64KB,你可以把DS设置为1000,然后把ES设置为2000,然后不断循环lMOV  DS:[xxxx], ES:[xxxx],其中xxxx从0到2^16-1,所以这两个位置都是数据段,然后拷贝完成,我把CS设置为1000,IP设成0,让CPU跑到2000这个代码段开始执行,数儿还是那些数儿,地方也没变,但是取决于CS or DS的跳转,同一段区域就可能是数据段也可能是代码段。接下来是堆栈段,也类似,以SS为起始地址的64KB大小的内存空间就是8086的栈空间,SP寄存器用来指示栈顶,BP寄存器用来指示栈低,这三个配合使用就可以完成过程调用和参数传递,这个我在第一期里面已经详细的讲过了,不再赘述。同样还要在强调一次,一段64KB的内存空间,它可以是代码段也可以是数据段还可以是堆栈段,它是什么段是随着CS/DS/SS的变化而变化的,今天我把从0开始的64KB为堆栈段,但是我的调试工具就是想看看堆栈的运行情况,我就可以把DS设置成0,然后去看看。上面就是8086内存管理的全部内容,说白了就是利用几个段寄存器切来切去,但是很好的解决了程序和数据分开来放合起来用的问题,至于过程调用和返回可以利用堆栈+BP/SP进行。这就是CPU设计的神奇之处,讲复杂的任务分解为简单的,然后再创建几个寄存器外加合适的指令,就可以应对复杂的任务了。我们可以想象8086和DOS是怎么配合的,上电之后BIOS先上手,然后把DOS的镜像搬到内存里面去,然后在切到DOS系统,DOS系统开始执行后提供CLI给用户,用户加载一个或者多个程序,程序加载器把这些程序文件加载到内存的代码段里,然后再跳过去执行,执行完了这个然后再下一个,中间有中断来了去执行一下中断服务程序,最后再回到DOS CLI等待用户的输入,单任务的操作环境就是这么的单纯。

    

    说完单任务,我们再来看看多任务,PPT做的很简单啊,因为关键信息就这些,剩下的主要靠各位自行想象。多任务操作系统的基本实现原理其实没多了不起,最先出现的就是简单粗暴的时间片轮转法,比如现在我们想同时执行三个任务ABC,操作系统先为每个任务划分时间片,一片比如说就10ms,设定一个timer,从10ms往下开始倒计时,先让CPU执行A,A执行了10ms,定时器会发一个中断给CPU,在定时器中断的中断服务程序里,让CPU把任务A挂起,就是把CS/IP和其他比较关键的寄存器和数据存到一个数据结构里,x86管这个数据结构叫做TSS,后面讲任务管理我们会详细阐述,任务A挂起之前,再去执行任务B,B 再执行10ms,再挂起B,跑去执行C,依次类图,挨个挂起和重启这三个任务让他们轮流执行,由于CPU的速度非常快,用户感知不到这种切换,从宏观上着三个任务是并行执行的,从微观上其实还是串行,当然这是一个CPU的情况,多个CPU的话更复杂一些,这里我们不展开讨论;

    时间片轮转的方式虽然可以解决多任务并行问题,但是漏掉了一个特别要命的因素,那就是IO。为什么呢,因为CPU的速度虽然快,但是IO的速度太慢了,比如说任务A里需要从磁盘读取数据到内存,路径是从磁盘到CPU寄存器,再从CPU寄存器放到内存,这样的话10ms可能还不够任务A里的数据走一半路程的,好在后来中断+DMA技术拯救了这种情况,任务A直接向DMA控制器发起一笔DMA操作,然后就主动挂起自己把系统控制器交给其他任务,DMA控制器主导数据拷贝,当数据就位后发起一笔中断给CPU,CPU再挂起当前执行任何回来执行任务A。好了,实现多任务操作系统的基本技术路径就讲完了,PPT做的很简单,但不代表实现起来容易,这里只是让大家对多任务操作系统有点感觉,想要详细了解的话可以参考其他专业资料。

    了解了多任务的实现路径,我们就会发现,8086,换句话说 x86处理器的实模式对于多任务工况就有点不够用了,这页PPT依旧很简单,麻烦大家继续展开自己的想象。首当其冲的就是,这么多任务依次挂起和执行,每个任务在被执行的时候,只能感知到他们自己和操作系统,且可以访问系统中几乎任意内存位置,那极有可能破坏掉其他任务的数据甚至代码;其次,系统中有多个任务,但是IO只有一套,如果任务A发起一笔磁盘操作被挂起了,然后任务B也发起一笔磁盘操作,刚巧任务A请求的磁盘数据回来了,任务B拿到以后接着执行,这必然会出现问题;再有,普通应用程序是任务,那么操作系统常驻在内存的程序也是任务,二者从硬件的角度上看没有区别,遇到资源冲突的情况,普通应用程序拿掉优先权,系统程序得不到响应,系统是不是有可能挂了;还有,实模式下的一些程序,如果不像被打断执行过程,一般会直接发一笔CLI指令,这个指令会让处理器屏蔽所有中断,但是相同的程序跑到多任务环境下就由大问题,中断被屏蔽,就没办法进行多任务调度了,即使不出现点系统故障,性能肯定也会下降一大半;最后,之前很多IO操作是依赖BIOS调用的,一旦执行,相当于硬件跑去执行BIOS程序了,什么时候把系统控制权交还回来由BIOS说了算,如果无法满足BIOS设定的返回条件,整个系统只能死等,这种不确定性是系统程序员无法容忍的;

    为了应对这一大堆问题,英特尔从386开始引入了保护模式,第一版Linux内核也是基于386编写的,虽然其中的很多特性现代操作系统都弃之不用了,比如保护模式提供的硬件实现的任务切换,但是其中的很多思想深深的影响了内核的发展,所以我们带着上面这些问题学习一下保护模式,然后再去学习linux内核有些概念就没那么晦涩了。

    保护模式比实模式复杂很多,为了让大家容易理解嗯,我们先挑咱们熟悉的来,首先介绍一下分段机制和段寄存器在保护模式里面的变化。在实模式里,我们需要把段寄存器里的段地址左移四位然后在加上偏移地址才能得到最终的内存地址,在保护模式下,这个段地址+偏移地址的框架还没有改变,但是获得段地址的方式发生了变化。首先我们要考虑一个问题,保护模式已经是32位CPU的时代,一个段可能出现在整个4G地址空间的任何位置,为了保证兼容,之前的段寄存器还是16位,它只能表示1M以内的地址空间,为了解决如让16位的段寄存器表示4G地址范围的问题,保护模式引入了段选择子Segment Selector/段描述符Segment Descriptor/描述符表Descriptor Table的概念。大体上来说,先定义一个8Byte长度的数据结构,里面包含了一个段的基地址/大小/属性/特权等级等所有用来描述这个段的一切信息,Intel称这个8Byte长度的数据结构为Segment Descriptor段描述符,然后把这些段描述符放在一切存放组成一个顺序表,这个表被称为Descriptor Table,然后再改进段寄存器,分为可见的16bit部分和不可见的32bit部分,如果你想选中一个段,需要让段寄存器载入一个段选择子的东西,这个段选择子的本质可以理解为被选择的段在段描述符表里的Index,当然还包括一些其他的信息。当你把16位宽的段选择子写入到段寄存器,硬件会自动的从段描述符表中按照段选择子提供的Index把对应的条目内容,包括段基地址/段属性/段大小加载到段寄存器不可见的32位Cache Register里面,包括段属性大小基地址之类的信息,就可以访问这个段了。

    大致框架就是这样,我们再关注一些细节,首先看一下段选择子,整个16位分为了三个部分,高13位就是我们刚说的table index,在这里是不是就可以知道一个Table最多由多少条目了,2^13=8192个,然后是bit2,用于选择Table的种类,0是GDT,1是LDT,GDT是全局描述符表,整个系统只存在一个,LDT是局部描述符表,你如果不嫌麻烦可以一个任务定义一个或者多个。需要注意的是,针对GDT,保护模式引入了一个叫做GDTR的寄存器用于存放GDT的内存地址,在切到保护模式之前,BIOS或者OS内核需要负责先在内存里创建好GDT的所有条目,然后使用LGDTR把GDT起始地址写到GDTR里面,然后在保护模式里才可以通过段选择子对GDT进行Index访问;LDT的情况略有不同,它在系统中可能不止一张,每张LDT在GDT中都存在对应的条目,所以需要先用LLDTR把LDT在GDT里对应的Selector载入到LDTR里,这样再往段寄存器里载入当前LDT的Selector。

    最后两bit有点点麻烦,这两个bit代表了一个段选择子的被要求实现的特权等级,在CS和SS段寄存器的对应位置也有两个bit,这里叫做CPL,C是Current的意思,代表当前正在执行代码所在代码段的特权等级,一会我们在介绍段描述符的时候会发现描述符里面还有一个DPL,代表可以本段的权限级别。CCPL/Selector.RPL/Descriptor.DPL一起配合构成了保护模式完整的权限检查。这里我们先稍微展开说一下,详细的情况后面再细讲。这里所说的特权等级的概念是x86在保护模式开始提出的,从 高到低分别是Ring0到Ring3。熟悉Linux 的朋友可能知道程序允许的内核态和用户态,内核态就是这里的Ring0,用户态就是这里的Ring3.刚才讲的CPL/RPL/DPL都是两个bit,可以编码为0到3,也是对应这里的ring0到ring3.打个比方,假设我是美国总统(CPL=Ring0),我想去视察一下新奥尔良市(DPL=Ring2),我拿着本级别(RPL=Ring0)或者国防部长的级别(RPL=1),新奥尔良市不得不好好接待我,但是我拿着一个普通市民(PRL=Ring3)去访问,我连政府大门都进不去。

    现在我们再来介绍下,descriptor,段描述符的格式。先说咱们熟悉的,低4B的bit31:16+高4B的bit7:0+高4B的bit31:24共同构成了32bit的段基地址,当段选择子被加载到段寄存器的时候,硬件会自动的把这个地址加载到段寄存器不可见部分的对应位置,随后是DPL,我们在下一页会再仔细讲讲它和CPL/RPL的用法,TYPE有四个bit,当S bit为1的时候,Type的高1bit代表当前段是数据段还是代码段,低3bit代表当前的访问权限,我们可以拿这张表感觉一下。大家发现没有,这里面好像没有堆栈段了,那是为啥呢,因为堆栈段被可以使用可读写且向下增长的数据段代替,所以这两个是一码事,但是SS堆栈段寄存器还是存在的,只不过需要系统程序员为SS载入特定数据段的段选择子。当Sbit为0的时候,表示当前段是一个系统段,这些系统段主要用于做任务切换 /过程调用/中断调用,具体的用法我们会在后面相关里面讲,这里略过。然后是这个limit配合G,limit顾名思义就是用来指定段大小的,G为0,limlit代表段的大小范围是1B到1M,G为1,limit代表段的大小从4K到4G,这里有一个,之前我们是不是说过,堆栈段算是数据段的一个特殊类型,那么起始对于数据段来说,就存在向上增长数据段和向下增长数据段,刚才的描述对于向上增长数据段来说就是offset应该是从0到limit这么大,但是对于向下增长的数据段,limit代表的是从Base往上不属于这个段的部分,那么向下增长数据段的offset应该是limit+1到FFFFFFFF(4G)/FFFF(64K),取决于D/B位;D/B位需要分情况讨论,如果通过CS或者DS选择这个段,那么当D/B为1代表这个data or code segment是32bit的,否则为16bit的,如果通过SS选择这个段,那么这个bit为1的时候代表系统使用32bit堆栈指针,且堆栈段上限为FFFFFFFFH(4G),如果为0,堆栈段的上限边界为FFFFH(64K)这样结合起来大家明白了limit的用法了吗?然后是P,代表当前段在不在内存里,如果过为0,说明它在磁盘或者其他什么地方,后面我们讲到x86内存的分页机制你自然会明白P代表什么。

    之前在讲8086在应对多任务操作的困境那一节,我们吐槽人家实模式对于不同代码段和数据段的交叉访问根本就没什么保护机制,在讲保护模式分段机制的时候我们顺着对CPL/RPL/DPL的介绍,大致介绍了下保护模式下权限权限控制的大致原理,让大家对此有了一个最基本的概念,现在我们一起针对各种情况细致研究一下这部分。首先拿最常见的情况,代码段内指令去访问数据段。在这部分需要在心里明白我们说的的这些PL在不同情况下的主客体。在代码段访问数据段这种情况下,CPL位于CS寄存低两位,表面的是当前正在进行数据访问的这段代码所在代码段的特权等级,DPL位于被访问数据段的段描述符里,这里的DPL代表一个程序或者任务能否访问此数据段的特权等级的最大数值,这么说有点拗口,直白的来说就是最低什么级别才可以访问本段,比DPL里写着的级别还低的就免谈,RPL位于被访问数据段的段选择子里,而这个段选择子是会被放到DS/ES/FS/GS这些段寄存器里面,代表当前程序打算以什么样的特权等级去访问本段。我们拿右边举个例子,现在代码段D开始执行,它的CPL为0,准备访问数据段E,它的DPL为2,直观上代码段D应该是碾压数据段E的,但是到底能不能访问成功,取决与代码段D选择什么样的段选择子,如果D使用的选择子1和2,这样的话,RPL的级别就高于或者等于DPL,那么D就可以正常访问E,如果D选择了E3选择子,虽然D自身的级别高,但是下场干活动手用的级别低于E,那么这个访问还是会被拒绝;还有一种情况,那就是RPL的特权级高于CPL,比如说代码段C想要访问数据段E,为了能访问成功,它使用的选择子是E2,那么这时候怎么算呢,谁数值大听谁的,所以最后还是要拿代码段C的CPL跟数据段的DPL去PK,最后还是不能访问。图中还有代码段ABC,大家可以自行配合E1/E2/E3看能否正常访问E。

    我们再来看看对于代码段的访问,先来个名词解释,什么是conforming code segment,什么是non-conforming code segment,在intel的文档里有如上定义,一个代码段要么是conforming要么是non-conforming,它俩的定义是从代码段和代码段之前的转移得来的,注意这里是转移,不是访问了,转移说是往CS里面填一个新的代码段选择子,完事儿后EIP就要跳到新代码段执行了。如果当前代码段要转移到一个non-confirming代码段去执行,那么就要求当前段的CPL和目标段的DPL一致,不能大也不能小,否则就报错;比如图里的C就是一个non-conforming,里面的B和D都不能访问C,因为CPL和D的DPL不匹配,A可以,但是不要忽略PRL,A可以访问C的前提是使用的段选择子的RPL的特权级要高于或者等于DPL,在这个例子里,A使用的选择子可以是0/1/2,但是绝不能为3;但是即使RPL的特权级高于CPL, 访问成功后的CPL仍然是访问前的CPL,特权级没有发生切换。

    如果当前代码段要转移到一个conforming 代码段去执行,首先要明确,此时的DPL代码可以访问本段的最高优先级是多少,低于DPL优先级的都可以访问,高于此的都无法访问,而段选择子的RPL在这种情况下被忽略,所以拿图中的例子来说,段A/B可以随意的访问段D,但如果段D的DPL改成了3,那么段A就不能访问段D了,只有段B可以访问。需要注意的是,在段切换完成之后,CS的CPL还是和之前一样,虽然段A可以调用位于ring1的段D,但是段D执行时候的权限还是保持为段A的Ring1,当前系统的特权级并没有发生切换。

    为什么要这么分呢,起始是为了方便后面的操作系统使用,比如说我们把用户代码和内核代码分别放在不同的non-conforming段,并设置内核non-conforming段的DPL是0,用户non-conforming段的DPL是3,这样的话用户代码和内核代码就各自调用各自的。但是对于一些本身属于操作系统提供但是会被应用程序调用的代码,比如说C标准库,你总得留条路能让普通用户程序调用吧,那就把这些程序放在DPL为0的conforming segment,这样位于ring3的用户程序可以调用,而且调用的时候当前系统的CPL依然保持为ring3,没有发生特权级切换。

    但有的朋友可能纳闷了,这两种情况的特权级都没有发生改变,那有时候就是需要发生切换,尤其是低特权级代码切到高特权级代码允许,比如说linux动不动就需要用户态陷入内核态,这是怎么实现的?

    为了应付不同特权级别的代码段调用,英特尔又增加了一种描述符,Gate,直译过来就门。我觉得这个名字很好,很形象,你可以把这个门想想成哆啦A梦的任意门,而每个代码段是一个个独立的房间,有了门,你就是可以从一个房间开门直接进入到另一个房间,当然前提是要满足某些条件。Gate的类型分为四种,如上,里面的Trap和Interrupt是和中断调用相关的,后期在中断管理的章节中会讲到,里面的Task Gate是用来调度任务的,后期会在任务管理的章节中讲到,今天我们只讲Call Gate,就是我们最常见的函数调用的。如果调用函数和被调用函数在一个代码段里,那没啥好说,直接调用;如果是跨段调用,刚才我们讲过了,如果要调用non-conforming段,那需要发起者的CPL和被调用者的DPL完全相同,如果要调用conforming段,需要发起者的CPL优先级低于被调用者的DPL优先级,但是执行过程仍旧按照发起者的优先级执行,但是总有些时候,我们需要突破上面的访问限制,这是会Call Gate就要派上用用场了

    我们首先看一个Call Gate描述符的格式,大家注意到没,之前在code/data segment描述符里对应base address地方的定义发生了变化,低4B的31:16代表的是被调用代码段在GDT or LDT里的段选择子,然后剩下的两个部分组合起来代表被调用段的Entry Point,换句话说,这三个区域合起来的信息就是当前要调用那个段,并且从这个段里的某某位置开始执行。P flag的含义也发生了变化,代表这个Call gate是不是有效的;还有一个变化就是高4B的低4bit,字面意思是参数个数,怎么用呢,这就涉及一个新问题,那就是堆栈切换。我们可以想象一个,如果我们使用的Gate,这就代表目前系统需要切换到不同的CPL执行程序,如果不同CPL级别的程序使用的是同一个堆栈,那么相互之间就会交叉污染,操作系统需要保证,每个任务到要为每个特权级设定单独的堆栈并讲段选择子和栈顶指针保存在自己的TSS里,在发生特权级别切换的时候,需要切换到自己对应的堆栈上,这里的Param Count就是在进行切栈时候,希望硬件自动压入栈内参数的个数。

    我们换到这张图来大致讲讲切栈和parameter counter,后面再讲任务管理的时候再详细讨论细节。当低特权段使用call gate调用高特权段的时候,会发生特权等级切换为更高,然后会导致发生切栈,处理器会自动的进行以下几步:首先,检查被调用段DPL,然后从任务的TSS里,把DPL对应的堆栈段选择子和栈顶指针独出,其次暂存当前的SS和ESP,然后把当读出来的DPL对应的选择子和栈顶指针载入到SS/ESP中,这样就建立了一个新的堆栈,然后把暂存的旧堆栈对应的SS和ESP压入到新堆栈中,再把旧堆栈里parameter counter对应个数的参数从旧堆栈拷贝到新堆栈里,随后把当前的CS/EIP压入新堆栈,然后就把call gate里面的新段选择子和entry point载入到CS和EIP中,就开始执行新代码了;

返回的时候过程类似,具体的我们在任务管理那一章细讲。

    

    需要补充说明的是,切栈的过程起始就是往SS里面填段选择子,这一步也是存在特权等级检查的,规矩很简单,需要保持CPL/RPL/DPL相等即可,所以刚才我们讲切栈时候,注意到是CPL先切换到高优先级,然后才发生的切栈。

    说完了的Gate的结构和切栈的原理,现在我们回到主线话题:低特权级的代码如何切到高特权级代码运行,跳转的大致过程相信在介绍call gate描述符的时候大家也能想象得到,使用call和jmp指令只是处理器进行跳转,call gate的段选择子被载入到CS段寄存器里,硬件就根据call gate描述符里的段选择子就确定了要透过call gate跳去的代码段,再加上offset就是最后的程序入口,中间需要经过特权等级检查,整个切换过程的特权检查涉及四个部分,分别是:当前代码段的特权级CS.CPL,call gate的段选择子的RPL, call gate的DPL,最后是call gate指向的代码段的DPL,但是具体的限制规则,CALL指令和JMP指令稍显不同,如上,光看这个表格可能有点懵,我们索性举个实例来说明下。

    首先,call gate的DPL代表可以访问本call gate的最低优先级,只有优先级高于或者等于call gate DPL的CS.CPL才可以访问这个call gate,比如图中的call gate A的DPL是3,图中所有的code segment都可以访问,而图中的call gate B的DPL是2,只有CPL为0/1/2的代码段执行是才可以调用call gate B,所以我们看到code segment A就调不动 call gate B;call gate段选择子RPL同样也需要高于call gate的DPL,所以图中code segment C如果通过B1/B2是可以调用call gate B的,但是使用A/B3就没有权限调用call gate B;CPL。RPL和call gate DPL的特权等级检查通过之后,处理器就会对比call gate DPL和call gate指向的目的段的DPL。之前我们讲过,代码段不是conforming就是non-forming,对于conforming segment,call/jmp指令是一样的,比如对于图中的code segment D,code segment A/B/C使用selector B1/B2,通过call gate B都可以访问,但是调用成功后的CPL不会发生改变,也不会发生堆栈切换;对于non-conforming代码段,就开始出现区分了,JMP指令只能在CPL和目的代码段DPL相等的情况才能成功,比如这个例子里,ABC都不行的里的代码使用JMP都没办法调用得了E,但是如果使用call指令就可以了,只有call指令才运行调用call gate调用更高特权级的代码段,比如图中的代码段A通过call gate A可以调用non-conforming code segment E,并且调用成功后的CPL会被修改为0,那么按照我们之前说的,特权级发生变化的是要进行堆栈切换的,具体过程大家结合之前内容自行脑补一下。

    还有一种情况,代码段把其他代码段当作数据来访问,啥意思呢,就是当前代码段里往DS/ES/FS/GS里放段选择子用来访问数据,但是这个段选择子指向的是是一个代码段。又分为三种情况,如果想要访问的是一个nonconforming code segment,那么就和访问一个数据段的权限检查规则类似,只要CPL/RPL的权限高于DPL就可以正常访问;如果想要访问的是一个conforming那么就没有限制,CPL是多少,就按照当前CPL的权限进行访问,无论DPL是多少;起始这里的因果关系我没有搞太清楚,但是这么理解好像也没问题,如果哪位大佬搞明白了麻烦私信告诉我,就是method2后面这句;第三种情况应该是当前段自己访问自己代码段里的数据,这个也是always valid,因为二者的DPL和CPL肯定是一样的;

感谢各位,今天这期的内容先到此为止,后续我们打算再录制2到3期才能把x86的内存管理全部讲完,下一期我们讲x86的内存分页机制及其保护,我们下期再见~

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值