从裸机启动开始运行一个C++程序(六)

先序文章请看
从裸机启动开始运行一个C++程序(五)
从裸机启动开始运行一个C++程序(四)
从裸机启动开始运行一个C++程序(三)
从裸机启动开始运行一个C++程序(二)
从裸机启动开始运行一个C++程序(一)

从8086到80286

我们知道8086有20位地址总线,而80286有24位地址总线。因此,如果按照8086的寻址方式,即便是我们打开了A20M,也只是最多能达到21位的寻址空间。那么如何利用好这24位地址总线呢?

比较容易想到的一种方案是这样的。既然在8086里,我们是通过2个16位寄存器拼出一个20位地址的,那么,稍微改变一下偏移的策略,也是可以用2个16位寄存器拼出一个24位地址的嘛。也就是说段地址偏移8位(不再是4位)再加上偏移地址就好了。

上面这种方法确实可以简单地解决问题,但在80286诞生的时候,8086已经有相当一部分市场了,如果这时Intel推出一款新的处理器是新的寻址方式的话,就无法兼容现有的软件。因此,要想让8086的软件能够正常在80286上运行,80286就需要一种向下兼容的运行模式,也就是8086模式,在这种模式下,要按照8086的寻址方式来工作。

不过照理说,这样也不冲突,8086模式下我们按偏移4位的方式,286模式下我们按偏移8位的方式,也可以解决问题,但Intel并没有选择这样做,原因其实是在于恶意软件的防范(通俗的说法就是防病毒)。

在8086上,由于段寄存器是可以随便填写的,所有的程序都拥有最高权限,都可以访问任意的内存空间。随着计算机功能的不断扩充,逐渐也就不满足于单进程的运行方式了,那么,就需要引入操作系统的概念,区分「内核进程」和「用户进程」,让每个程序只能操作自己的一亩三分地,不能越权。因此,Intel就在80286上引入了这种保护基址,通过将内存分段,并且给每个段分配一个地址范围和操作权限。OS负责统筹段的选择,在进入用户进程之前,配置好对应的段,然后再执行用户进程,这样,用户进程的执行过程中就无法跨段操作,从而保证程序安全。

既然分段,还要额外配置段的范围以及权限,那么显然这个内容就不是一个简单的段地址这么简单了,因此,在286模式下,「段寄存器」就不能简简单单只保存一个段基址了,因为这样的信息是不够的。

286模式的解决方法,是把「段配置」这样的配置信息存在内存中,包括需要分几个段,每个段的首地址、末地址是多少,段的权限是怎样的。然后,把需要使用的段的序号传入段寄存器。寻址时,会首先根据段寄存器中保存的段序号找到对应的段基址,然后用基址加上偏移地址得到物理地址。之后,会根据段配置的权限,对当前操作指令进行合法性判断,例如对于只读的段来说,如果遇到写入操作,那么就会认为操作非法,从而触发系统中断。

大致的过程就是上述的这样,我们把段寄存器中保存的段序号称为「段选择子」,而这种通过段的提前配置,然后用段选择子+偏移地址的这种寻址方式称为「保护模式」,与之对应的原本8086的寻址方式则被称为「实模式」。

所以,在进入保护模式之前,我们得先配置一份段的描述表,否则进入保护模式后寻址会出问题的。

全局描述表

全局描述表,又称GDT(Global Descriptor Table),就是用来配置我们的内存要如何分段的数据表。

由于目前我们还在实模式下,所以,咱们也只能在当前可用的内存中间中,找一个地方,来放GDT,这里,我们就暂且选择0x7e00这个位置,以后进入保护模式后可以再更换。

0x7e00位置开始,每8个字节配置1个段,并且要求最开头的段(0号段)强制为空(为什么要强制为空呢?当程序发生不合法的短访问时,系统中断会使得段寄存器强制归零,那么此时相当于选择了0号段,因此,0号段必须是一个不合法的段才行。)

之后,每一个段的配置要符合下面的描述表:

内存偏移量(bit) 符号 解释
0~15 Limit 段界限
16~39 Base 段基址
40 A 访问位
41~43 Type 描述符类型
44 S 系统/数据
45~46 DPL 权限级别
47 P 存在位
48~63 AVL 保留位

分别解释一下,首先,段界限Limit很好理解,就是当前这个段的大小,不过这里跟我们通常意义上理解的「大小」有一点区别,它想表明的是「段的最后一个字节的偏移量」,也就是说,这个值仍然在段内,只不过是段的最后一个字节。所以,Limit跟我们理解的Size其实差了1个字节。举例来说,如果我们希望这个段的大小是1KB,那么Limit应该填1023或者0x3ff而不是1024

段基址Base就不用说了,就是这个段的首地址对应的内存大小。由于80286是24位地址空间,所以段基址也应该是个24位的内存地址。

访问位A可以理解为OS用作段访问标记的,用来让OS统计段的访问频率,以及虚拟内存策略使用。所以这一位对我们目前来说没有什么用途,填0就好。

子类型Type用于表示当前这个段的作用,Type内的3位分别叫做X``E``WXW这两个用过Unix的小伙伴一定很熟悉,分别表示「可执行」「可写」。如果X为0,那么当前段只能存数据,而不能用作执行指令。如果W为0,那么当前段只读,不可更改。

E则表示段扩展方向,如果E=0则表示向高地址扩展,E=1表示向低地址扩展,不过目前我们暂时还不用考虑段的扩展问题,所以都默认填0就好。

S位表示这个段是否是「系统段」,系统段也是依赖于OS的概念,目前我们用不到,所以默认填0,表示他是普通的段。

DPL是权限等级,占2位,所以就是说一共可以划分4个等级,0位最高权限等级,3是最低等级。我们目前还是需要一个高权限来控制内核的,所以这里也应当填0

P是存在位,或者也可以理解成「使能」位,如果P为0,则当前段失效。为1时当前段有效。这个主要是做虚拟内存进出策略时用到的,我们当前应当保证P为1。

最后的AVL是保留位,在80286模式下没有用处,我们留白即可。

那么,按照上述描述,咱们就可以着手配置一个段表了,代码如下:

; 下面配置GDT
mov ax, 0x07e0
mov es, ax

; 空白段
mov [es:0x00], dword 0
mov [es:0x04], dword 0

; 1号段
; 基址0x8000,大小8KB
mov [es:0x08], word 0x1fff ; Limit=0x1fff
mov [es:0x0a], word 0x8000 ; Base=0x008000,这是低16位
mov [es:0x0c], byte 0      ; 这是Base的高8位
mov [es:0x0d], byte 1_00_1_100_0b ; P=1, DPL=0, S=1, Type=100b, A=0
mov [es:0x0e], word 0	   ; 预留位,先填0

大家着重看一下这里的1号段,由于我们当前的目的并不是像OS那样做特别合理的段规划,因此,咱们暂时就配一个超管段,然后进入保护模式即可。我们就选取0x8000作为基址,配一个8KB的可写、可执行的段。

那么配置好的GDT,还需要想办法让它生效才行。80286提供了一个48位寄存器,用于存储GDT的配置,这个寄存器叫做GDTR,它的低16位表示GDT的界限(也就是limit逻辑,总长度-1),16~35位表示GDT的首地址,36~47位预留。

地址 符号 说明
0~15 GDT Limit GDT的限度(长度-1)
16~35 GDT Base GDT的首地址
36~47 AVL 预留位

我们来看代码:

; 下面是gdt信息的配置(暂且放在0x07f00的位置)
mov ax, 0x07f0
mov ex, ax
mov [es:0x00], word 15      ; 因为目前配了2个段,长度为16,所以limit为15
mov [es:0x02], dword 0x7e00 ; GDT配置表的首地址
; 把gdt配置进gdtr
lgdt [es:0x00]

最后用lgdt命令,把数据存入GDTR中,那么GDT就配置完毕了。

不过,当前我们仅仅是配置了GDT,但机器仍然还在8086模式下,所以它暂时还是没什么用的,我们需要切换机器的运行模式。

注意这里,我的措辞是「8086模式」「切换模式」,原因很简单,因为我们现在并不是真的在用8086的CPU,甚至也不是80286的CPU,咱们模拟器的是一个AMD64架构的CPU,因此8086模式也好,286模式也好,都只是它的一种工作模式罢了。不过也正因如此,我们才能做切换,要是真的是8086的CPU的话,你无论如何也没法切换成286的。

那么,如何切换至286模式呢?其实在CPU内部,有一个控制寄存器,叫做MSW,说是寄存器,其实就是控制开关啦,只要我们改变其中某些位的数值,就可以改变CPU的运行状态。MSW的最后一位就表示了寻址方式,计算机启动时它是默认至0的,这是寻址方式为8086方式(也就是实模式),当我们把它切为1的时候,就会切换至286模式(也就是保护模式)。

但另一个很难受的问题在于,80286的MSW这个16位控制寄存器仅出现在了80286这款CPU上,到了80386的时候就淘汰了,所以,目前的x86汇编器都很难支持这个操作这个寄存器的指令。原本笔者是想让大家体验一把「纯粹」的286,但出于这个原因,无奈,我们还是只能用更「高档」的语法来实现这个功能。

其实MSW寄存器在80386上,成为了CR0寄存器的一部分。在80386上,CR0是一个控制寄存器,32位的,而它的低16位正好对应了MSW的功能。所以,我们更改CR0的最后一位,同样也可以切换至保护模式。只不过这里就不得不提前让我们来操作32位寄存器了,希望读者体谅,并且了解这步操作并不是80286的,而是我们当前的环境是AMD64模拟器,不得已而为之的,代码如下:

mov eax, cr0
or eax, 0x01 ; PE位置1,启动保护模式
mov cr0, eax

说明一下,eax是32位通用寄存器,它的低16位就是ax,这同样也是80386才支持的寄存器,我们此刻也只是无奈来使用一下它,希望读者悉知。

那么,当CR0被改变的那一刹那开始,CPU就进入保护模式了,那这个时候最大的问题就是,当前我们的CS寄存器里还是原来的数据,也就是段基址。我们得给他变成段选择子才行,而CS寄存器又不能直接改写,那怎么办呢?答案很简单,用远跳指令来强制刷新段寄存器,代码如下:

jmp 00001_00_0b:0 ; 远跳指令可以刷新cs,使用1号段

目前读者理解到这里就好,下一节我们会用一个完整的实例来演示。

小结

本节主要介绍了80286的保护模式和一些历史情况,最后通过汇编指令进入了保护模式。

下一节我们会把这些知识点结合起来,用一个完整实例,并且还会在保护模式中执行一些代码。

从裸机启动开始运行一个C++程序(七)

你可能感兴趣的:(底层,x86,汇编)