001.txt
来自「会变语言实现的一些程序」· 文本 代码 · 共 344 行 · 第 1/4 页
TXT
344 行
2. 80386的内存寻址机制
Windows的内存管理和DOS的内存管理有很大的不同,在了解Windows的内存管理模式之前,需要对80386保护模式下内存分页机制有些了解。为了做个对比,先来看实模式下的内存寻址方式,即DOS下的寻址方式,如图1.2所示。
图1.2 实模式下的内存寻址方式
在实模式下,一个完整的地址由段地址和偏移地址两部分组成。段地址放在16位的段寄存器中,然后在指令中用16位的偏移地址寻址。处理器换算时先将段地址乘以10h,得到段在物理内存中的起始地址;然后加上16位的偏移地址得到实际的物理地址。如xxxx:yyyy格式的虚拟地址在内存中的实际位置是xxxx×10h+yyyy。
当80386处理器工作在保护模式和虚拟8086模式的时候,可以使用全部32根地址线访问4 GB大的内存。段地址加偏移地址的计算方法显然无法覆盖这么大的范围。但计算一下就可以发现,实际上和8086同样的限制已经不复存在,因为80386所有的通用寄存器都是32位的,2的32次方相当于4G,所以用任何一个通用寄存器来间接寻址,不必分段就已经可以访问到所有的内存地址。
这是不是说,在保护模式下,段寄存器就不再有用了呢?答案是否定的。实际上段寄存器更有用了,虽然在寻址上不再有分段的限制问题,但在保护模式下,一个地址空间是否可以被写入,可以被多少优先级的代码写入,是不是允许执行等涉及保护的问题就出来了。要解决这些问题,必须对一个地址空间定义一些安全上的属性。段寄存器这时就派上了用途。但是涉及属性和保护模式下段的其他参数,要表示的信息太多了,要用64位长的数据才能表示。我们把这64位的属性数据叫做段描述符(Segment Descriptor)。
80386的段寄存器是16位的,无法放下保护模式下64位的段描述符。如何解决这个新的问题呢?解决办法是把所有段的段描述符顺序放在内存中的指定位置,组成一个段描述符表(Descriptor Table);而段寄存器中的16位用来做索引信息,指定这个段的属性用段描述符表中的第几个描述符来表示。这时,段寄存器中的信息不再是段地址了,而是段选择器(Segment Selector)。可以通过它在段描述符表中“选择”一个项目以得到段的全部信息。
既然这样,段描述符表放在那里呢?80386中引入了两个新的寄存器来管理段描述符表。一个是48位的全局描述符表寄存器GDTR,一个是16位的局部描述符表寄存器LDTR。那么,为什么有两个描述符表寄存器呢?
GDTR指向的描述符表为全局描述符表GDT(Global Descriptor Table)。它包含系统中所有任务都可用的段描述符,通常包含描述操作系统所使用的代码段、数据段和堆栈段的描述符及各任务的LDT段等;全局描述符表只有一个。
LDTR则指向局部描述符表LDT(Local Descriptor Table)。80386处理器设计成每个任务都有一个独立的LDT。它包含有每个任务私有的代码段、数据段和堆栈段的描述符,也包含该任务所使用的一些门描述符,如任务门和调用门描述符等。
不同任务的局部描述符表分别组成不同的内存段,描述这些内存段的描述符当做系统描述符放在全局描述符表中。和GDTR直接指向内存地址不同,LDTR和CS,DS等段选择器一样只存放索引值,指向局部描述符表内存段对应的描述符在全局描述符表中的位置。随着任务的切换,只要改变LDTR的值,系统当前的局部描述符表LDT也随之切换,这样便于各任务之间数据的隔离。但GDT并不随着任务的切换而切换。
看到这里,读者可能会提出一个问题,既然有全局描述符表和局部描述符表两个表,那么段选择器中的索引值对应哪个表中的描述符呢。实际上,16位的段选择器中只有高13位表示索引值。剩下的3个数据位中,第0,1位表示程序的当前优先级RPL;第2位TI位用来表示在段描述符的位置;TI=0表示在GDT中,TI=1表示在LDT中。
以图1.3为例,在保护模式下,同样以xxxx:yyyyyyyy格式表示一个虚拟地址。单单凭段选择器中的数值xxxx根本无法反映出段的基址在哪里。对于这个地址,首先要看xxxx的TI位是否为0,如果是的话,则先从GDTR寄存器中获取GDT的基址(图中的步骤①),然后在GDT中以段选择器xxxx的高12位当做位置索引得到段描述符(步骤②)。段描述符包含段的基址、限长、优先级等各种属性,这就得到了段的起始地址(步骤③);如果xxxx的TI位为1的话就更复杂了,这表示段描述符在LDT中,这时第一步的操作还是从GDTR寄存器中获取GDT的基址(步骤1~),并且要从LDTR中获取LDT所在段的位置索引(步骤2~);然后以这个位置索引在GDT中得到LDT段的位置(步骤3~);然后才是用xxxx做索引从LDT段中获得段描述符(步骤4~),再以这个段描述符得到段的基址等信息(步骤5~)。分这两种情况得到段的基址后(图中Result所示),再以基址加上偏移地址yyyyyyyy才得到最后的线性地址。
关于段描述符的格式定义,读者可以参考其他讲述保护模式的书籍。
图1.3 保护模式下GDTR,LDTR,全局描述符表,局部描述符表和选择器的关系
3. 80386的内存分页机制
读者可以注意到,在实模式下寻址的时候,段寄存器+偏移地址经过转换计算以后得到的地址是“物理地址”,也就是在物理内存中的实际地址。而保护模式下,段选择器+偏移地址转换后的地址被称为“线性地址”而不是“物理地址”。那么,线性地址就是物理地址吗?
答案可能是“是”,也可能是“不是”,这取决于80386的内存分页机制是否被使用。
在单任务的DOS系统中,一个应用程序可以使用所有的空闲内存。程序退出后,操作系统回收所有的碎片内存并且合并成一个大块内存继续供下一个程序使用。内存合并过程中的一个极端情况是当系统中有多个TSR程序时,早装入内存的TSR卸载后,后装入的TSR会留在内存的中间部位,把空闲内存隔成两个区域。这时应用程序使用的最大内存块只能是这两块内存中较大的一块,无法将它们合并使用。
对于一个多任务的操作系统,内存的碎片化是不能容忍的。否则,经过一段时间后,即使空闲内存的总和很大,也可能出现任何一片内存都小到无法装入执行程序的地步。所以多任务操作系统中碎片内存的合并是个很重要的问题。
80386处理器的分页机制可以很好地解决这个问题。80386处理器把4 KB大小的一块内存当做一“页”内存,每页物理内存可以根据“页目录”和“页表”,随意映射到不同的线性地址上。这样,就可以将物理地址不连续的内存的映射连到一起,在线性地址上视为连续。在80386处理器中,除了和CR3寄存器(指定当前页目录的地址)相关的指令使用的是物理地址外,其他所有指令都是用线性地址寻址的。
是否启用内存分页机制是由80386处理器新增的CR0寄存器中的位31(PG位)决定的。如果PG=0,则分页机制不启用,这时所有指令寻址的地址(线性地址)就是系统中实际的物理地址;当PG=1的时候,80386处理器进入内存分页管理模式,所有的线性地址要经过页表的映射才得到最后的物理地址。
以图1.4为例,一个xxxx:yyyyyyyy格式的虚拟地址,经过图1.3所示的段地址转换步骤后得到32位的线性地址zzzzzzzz(步骤①)。当禁用分页机制时,线性地址就是物理地址,处理器直接从物理内存存取数据(步骤②);当启用分页机制时,得到线性地址的方法还是一样(步骤1~),但是还要根据页目录和页表指定的映射关系把地址映射到物理内存的真正位置上(步骤3~)。然后,CPU以映射后的物理地址在物理内存中存取数据。这个过程对于指令来说是透明的。
图1.4 80386的内存地址转换
内存分页管理只能在保护模式下才可以实现,实模式不支持分页机制。但不管在哪种模式下,所有寻址指令使用的都是线性地址,程序不用关心数据最后究竟存放在物理内存的哪个地方。
页表规定的不仅是地址的映射,同时还规定了页的访问属性,如是否可写、可读和可执行等。比如把代码所在的内存页设置为可读与可执行,那么权限不够的代码向它写数据就会引发保护异常。利用这个机制可以在硬件层次上支持虚拟内存的实现。
如图1.5所示,页表可以指定一个页面并不真正映射到物理内存中。这样,访问这个页的指令会引发页异常错误。这时,处理器会自动转移到页异常处理程序中去。操作系统可以在异常处理程序中将硬盘上的虚拟内存读到内存中并修改页表重新映射,然后重新执行引发异常的指令。这样指令可以正常执行下去。
图1.5 虚拟内存的实现
4. Windows的内存安排
Windows系统一般在硬盘上建立大小为物理内存两倍左右的交换文件(文件名在Windows 9x下为Win386.swp,Windows NT下为PageFile.sys)用做虚拟内存。利用80386处理器的内存分页机制,交换文件在寻址上可以很方便地作为物理内存使用。只需在真正访问到的时候将硬盘文件的内容读入物理内存,然后重新将线性地址映射到这块物理内存就可以了。同样道理,被执行的可执行文件也不必真正装入内存,只要在页表中建立映射关系,以后到真正访问到的时候再调入物理内存。
如果把虚拟内存暂时先视为物理内存的一部分,从物理内存中的层次看,Windows操作系统和DOS一样,也是所有的内容共享内存,如操作系统使用的代码和数据(GDT,LDT与页表等),当前执行中的所有程序的代码和数据以及这些程序调用的DLL的代码和数据等,如图1.6的左上角所示。
但是从应用程序代码的层次看,也就是说从分页映射后线性地址的层次看,内存的安排却不是这个样子。因为Windows是一个分时的多任务操作系统,CPU时间被分成一个个的时间片后分配给不同程序轮流使用,在一个程序的时间片中,和这个程序执行无关的东西(如其他程序的代码和数据)并不需要映射到线性地址中去。
图1.6 Windows的内存安排
如图1.6所示,Windows操作系统通过切换不同的页表内容让线性地址在不同的时间片中映射不同的内容。图中的右边是Windows 98操作系统在单个时间片中线性地址的安排(Windows NT稍微有些不同)。在物理内存中,操作系统和系统DLL的代码需要供每个应用程序调用,所以在所有的时间片中都必须被映射;用户程序只在自己所属的时间片内被映射;而用户DLL则有选择地被映射。假设程序A和程序C都要用到xxx.dll,那么物理内存中xxx.dll的代码在图中的时间片1和n中被映射,其他的时间片就不需要映射,当然,物理内存中只需要一份xxx.dll的代码。
由此可以引出Win32编程中几个很重要的概念:
● 每个应用程序都有自己的4 GB的寻址空间。该空间可存放操作系统、系统DLL和用户DLL的代码,它们之中有各种函数供应用程序调用。再除去其他的一些空间,余下的是应用程序的代码、数据和可以分配的地址空间。
● 不同应用程序的线性地址空间是隔离的。虽然它们在物理内存中同时存在,但在某个程序所属的时间片中,其他应用程序的代码和数据没有被映射到可寻址的线性地址中,所以是不可访问的。从编程的角度看,程序可以使用4 GB的寻址空间,而且这个空间是“私有”的。
● DLL程序没有自己“私有”的空间。它们总是被映射到其他应用程序的地址空间中,当做其他应用程序的一部分运行。原因很简单,如果它不和其他程序同属一个地址空间,应用程序该如何调用它呢?
⌨️ 快捷键说明
复制代码Ctrl + C
搜索代码Ctrl + F
全屏模式F11
增大字号Ctrl + =
减小字号Ctrl + -
显示快捷键?