在我们阅读boot loader代码时,遇到了两个非常重要的概念,实模式(real mode)和保护模式(protected mode)。
首先我们要知道这两种模式都是CPU的工作模式,实模式是早期CPU运行的工作模式,而保护模式则是现代CPU运行的模式。
但是为什么现代CPU在运行boot loader时仍旧要先进入实模式呢?就是为了实现软件的向后兼容性不得已才这样的。
下面我们分别看下这两种工作模式的基本原理。
实模式(real mode)
实模式出现于早期8088CPU时期。当时由于CPU的性能有限,一共只有20位地址线(所以地址空间只有1MB),以及8个16位的通用寄存器,以及4个16位的段寄存器。所以为了能够通过这些16位的寄存器去构成20位的主存地址,必须采取一种特殊的方式。当某个指令想要访问某个内存地址时,它通常需要用下面的这种格式来表示:
(段基址:段偏移量)
其中第一个字段是段基址,它的值是由段寄存器提供的。段寄存器有4种,%cs,%ds,%ss,%es。具体这个指令采用哪个段寄存器是由这个指令的类型来决定的。比如要取指令就是采用%cs寄存器,要读取或写入数据就是%ds寄存器,如果要对堆栈操作就是%ss寄存器。总之,不管什么指令,都会有一个段寄存器提供一个16位的段基址。
第二字段是段内偏移量,代表你要访问的这个内存地址距离这个段基址的偏移。它的值就是由通用寄存器来提供的,所以也是16位。那么问题来了,两个16位的值如何组合成一个20位的地址呢?这里采用的方式是把段寄存器所提供的段基址先向左移4位。这样就变成了一个20位的值,然后再与段偏移量相加。所以算法如下:
物理地址 = 段基址<<4 + 段内偏移
所以假设 %cs中的值是0xff00,%ax = 0x0110。则(%cs:%ax)这个地址对应的真实物理地址是 0xff00<<4 + 0x0110 = 0xff110。
上面就是实模式访问内存地址的原理。
保护模式(protected mode)
但是随着CPU的发展,CPU的地址线的个数也从原来的20根变为现在的32根,所以可以访问的内存空间也从1MB变为现在4GB,寄存器的位数也变为32位。所以实模式下的内存地址计算方式就已经不再适合了。所以就引入了现在的保护模式,实现更大空间的,更灵活的内存访问。
在介绍保护模式的工作原理之前,我们必须先清楚以下几个容易混淆的概念。逻辑地址(logical address),虚拟地址(virtual address),线性地址(linear address),物理地址(physical address)。
我们都知道,如今在编写程序时,程序时运行在虚拟地址空间下的,也就是说,在程序员编写程序时指令中出现的地址并不一定时这个程序在内存中运行时真正要访问的内存地址。这样做的目的就是为了能够让程序员在编程时不需要直接操作真实地址,因为当它在真实运行时,内存中各个程序的分布情况是不可能在你编写程序时就知道的。所以这个程序的这条指令到底要访问哪个内存单元是由操作系统来确定的。所以这就是一个从虚拟地址(virtual address)到真实主存中的物理地址(physical address)的转换。
那么逻辑地址(logical address)又是什么呢?根据上面一段文字我们知道,程序员编写时看到的是虚拟地址,但是并不是说程序员是直接把这个虚拟地址写到指令中的。它是由逻辑地址推导得到的。所以指令中真实出现的是逻辑地址。一个逻辑地址是由两部分组成的,一个段选择子(segment selector),一个段内偏移量(offset),通常被写作segment:offset。而且采用哪个段选择子通常也是在指令中隐含的,程序员通常只需要指明段内偏移量。然后分段管理机构(segmentation hardware)将会把这个逻辑地址转换为线性地址(linear address)。如果该机器没有采用分页机制(paging hardware)的话,此时linear address就是最后的主存物理地址。但是如果机器中还有分页设备的话,比如内存大小实际只有1G,但是根据前面我们知道可访问的空间有4G。所以此时还需要分页机构(paging hardware)把这个线性地址转换为最终的真实物理地址。所以可见虚拟地址和线性地址的含义是差不多的。我们可以再下图中看到我们上面叙述的地址转换过程。在boot loader中,并没有开启分页机构。所以计算出来的线性地址就是真实要访问的主存地址。
那么在保护模式下,我们是如何通过segment:offset最终得到物理地址的呢?
首先,在计算机中存在两个表,GDT,LDT。它们两个其实是同类型的表,前者叫做全局段描述符表,后者叫做本地段描述符表。他们都是用来存放关于某个运行在内存中的程序的分段信息的。比如某个程序的代码段是从哪里开始,有多大;数据段又是从哪里开始,有多大。GDT表是全局可见的,也就是说每一个运行在内存中的程序都能看到这个表。所以操作系统内核程序的段信息就存在这里面。还有一个LDT表,这个表是每一个在内存中的程序都包含的,里面指明了每一个程序的段信息。我们可以看一下这两个表的结构,如下图所示:
我们从图中可以看到,无论是GDT,还是LDT。每一个表项都包括三个字段:
Base : 32位,代表这个程序的这个段的基地址。
Limit : 20位,代表这个程序的这个段的大小。
Flags :12位,代表这个程序的这个段的访问权限。
当程序中给出逻辑地址 segment:offset时,他并不是像实模式那样,用segment的值作为段基址。而是把这个segment的值作为一个selector,代表这个段的段表项在GDT/LDT表的索引。比如你当前要访问的地址是segment:offset = 0x01:0x0000ffff,此时由于每个段表项的长度为8,所以此时应该取出地址8处的段表项。然后首先根据Flags字段来判断是否可以访问这个段的内容,这样做是为了能够实现进程间地址的保护。如果能访问,则把Base字段的内容取出,直接与offset相加,就得到线性地址(linear address)了。之后就是要根据是否有分页机构来进行地址转换了。
比如当前Base字段的值是0x00f0000,则最后线性地址的值为0x00f0ffff。
如上所述就是保护模式下,内存地址的计算方法。
综述,通过上面的叙述可见,保护模式还是要比实模式的工作方式灵活许多,可以在以下几个方面看出来:
1. 实模式下段基地址必须是16的整数倍,保护模式下段基地址可以是4GB空间内的任意一个地址。
2. 实模式下段的长度是65536B,但是保护模式下段的长度也是可以达到4GB的。
3. 保护模式下可以对内存的访问多加一层保护,但是实模式没有。
有什么问题,大家可以给我发邮件~
[email protected]