今天我们来看看从实模式是如何进入到保护模式的以及为何要进入保护模式。在这之前,我们先来看看什么叫实模式,什么是保护模式。
实模式具有以下几个特点:a> 它是远古时期的程序开发方式,也就是直接操作物理内存; b> CPU 指令的操作数直接使用实地址(也就是实际内存地址); c> 程序员拥有绝对的权利(也就是利用 CPU 指哪打哪)。那么这样好吗?对程序员来说是很好的,因为绝对权是掌握在自己手里的,换句话说,自己掌握程序的生杀大权。but 凡事有利必有弊,它的弊端慢慢就呈现出来了,最主要的体现在两方面:a> 难以重定位,因为程序每次都需要同样的内存地址来执行; b> 给多道程序设计带来障碍,不管内存多大,但凡一个字节被其他程序占用就会出现无法执行的情况。
下来我们就来说说在出现这样的局面以后,便会有改革出现。那便是堪称 CPU 历史的里程碑 -- 8086 处理器的出现。为何这样说呢?因为它是现代计算处理器的鼻祖,开创了段地址 + 偏移地址的源头。下来我们来看看它的一些特点:a> 地址线宽度为 20 位,可访问 1M 的内存空间; b> 引入 [ 段地址:偏移地址 ] 的内存访问方式。8086 的段寄存器和通用寄存器为 16 位,单个寄存器寻址最多访问 64K 的内存空间。它是需要两个寄存器配合使用,以此来完成内存空间的访问。那么我们来深入解析下 [ 段地址:偏移地址 ] 的这种方式。其中硬件所做的工作是将段地址左移 4 位,构成 20 位的基地址(起始地址),基地址 + 偏移地址 = 实地址;那么对于开发中来说,能更有效的划分内存 的功能(数据段,代码段等),当程序地址出现冲突时,我们只需修改段基址就能快速的解决问题。下来我们来看个示例代码,如下
mov ax, [0x1234] ; 实地址 = (ds << 4) + 0x1234 mov ax, [es:0x1234] ; 实地址 = (es << 4) + 0x1234
我们看到在直接操作某个偏移地址时,其实硬件已经做了相应的工作,将其基地址默认设为 ds 寄存器。别的正常操作就执行相应的段地址 + 偏移地址来完成的。那么我们在之前所说的 8086 的段寄存器和通用寄存器为 16 位,就意味着它所能访问的最大地址为 0xFFFF : 0xFFFF,即 10FFEF;这个地址早都已经超过了 1MB 的空间,那么 CPU 是如何处理的呢?很遗憾的是在 8086 CPU 中,它的处理方式便是不处理。说到这我们就有必要来说一个概念:HMA,8086 中的高端地址区(High Memory Area),我们通过下图来说什么是 HMA
我们看到多出来的 0xFFF0 的空间便是 HMA 了。因为 8086 只有 20 位地址线,因此最高位会被丢弃(也就是我们现在说的溢出),这种方式也叫作回卷。如下
不得不说 8086 在当时是非常成功的一个产品,但是它也有不足。主要体现在以下几个方面:1、1MB 的内存完全是不够用的;2、开发者在程序中大量使用内存回卷技术(也就是 HMA 地址被使用);3、应用程序之间没有界限,相互之间随意干扰,也就是 A 程序可以随意访问 B 程序中的数据,C 程序可以修改吸引调度程序的指令。由于存在以上的种种不足,那么 8086 的二代 80286 也就由此粉墨登场了。它既然是 8086 的二代,那么就必须兼容 8086 的开发方式,在原来基础上增大内存容量,增加地址线数量(24 位)。同时 [ 段地址:偏移地址 ] 的方式也被强化,为每个段提供更多属性(如范围、特权级等),为每个段的定义提供固定方式。
80286 在默认情况下完全兼容 8086 的运行方式(实模式),也就是默认可直接访问 1MB 的内存空间,通过特殊的方式访问 1MB+ 的内存空间。那么既然支持原来的实模式,也就是说它现在是另一种特殊的模式,这种特殊的方式指的是什么呢?那便是我们所要讲的保护模式了,具体区别如下所示
我们来看看保护模式具有哪些特点呢?1、每一段内存拥有一个属性定义(描述符 Descriptor);2、每个段的属性定义构成一张表(描述符表 Descriptor Table);3、段寄存器保存的是属性定义在表中的索引(选择子 Selector)。我们来看看描述符(Descriptor)的内存结构,如下图所示
描述附表(Descriptor Table)如下图所示
选择子(Selector)的结构如下图所示
那么我们是如何进入保护模式的呢?通过以下几个步骤便可进入到保护模式:1、定义描述附表;2、打开 A20 地址线;3、加载描述表;4、通知 CPU 进入保护模式。虽然 80286 在 8086 的基础上增加了一些新功能,但是它也有缺陷。80286 的历史意义是引入了保护模式,为现代操作系统和应用程序奠定了基础。它的缺陷表现在段寄存器为 24 位,但是通用寄存器只有 16 位(显得有点不伦不类)。理论上,段存器中的数值可以直接作为段基址,16 位通用寄存器最多可以访问 64K 的内存,为了访问 16M 内存,必须不停切换段基址。基于以上的缺陷,第三代 80386 便出现了,它是计算机新时期的标志。它具有以下几个特点:1、32 位地址总线(可支持 4G 的内存空间,这便是我们现代内存的鼻祖了);2、段寄存器和通用寄存器都为 32 位,任何一个寄存器都能访问到内存的任意角落。可以说它开启了平坦内存模式的新时代,段基址为 0,使用通用寄存器访问 4G 内存空间。那么新时期的内存使用方式也就有了 3 个模式:1、实模式,为了兼容 8086 的内存使用方式(指哪打哪);2、分段模式,通过 [ 段地址:偏移地址 ] 的方式将内存从功能上分段(数据段、代码段等);3、平坦模式,所有内存就是一个段 [ 0 : 32位偏移地址 ]。
我们下来来看看关于段属性的定义,如下图所示
选择子属性定义,如下图所示
保护模式中的段定义,如下图所示
汇编中的 section 关键字用于“逻辑的”定义一段代码集合,section 定义的代码段不同于 [ 段地址:偏移地址 ] 的代码段。section 定义的代码段仅限于源码中的代码段(代码节),[ 段地址:偏移地址 ] 中的代码段指内存中的代码段。如下
比如我们在前面定义了 .s1 为 0x1,在后面继续定义它为 0x2,那么它的最终值为 0x01020000,剩下的位全部补 0。在汇编中,编译器进行编译的时候会分为 16 位方式和 32 位方式进行编译。具体是 [bits 16] 指示编译器将代码按照 16 位方式进行编译,[bits 32] 指示编译器将代码按照 32 位方式进行编译。其中要注意的事项:a> 段描述附表中的第 0 个描述符不使用(仅用于占位);b> 代码中必须显示的指明 16 位代码段和 32 位代码段;c> 必须使用 jmp 指令从 16 位代码段跳转到 32 位代码段。我们接下来来看看保护模式的代码是怎么编写的,具体源码如下
%include "inc.asm" org 0x9000 jmp CODE16_SEGMENT [section .gdt] GDT_ENTRY : Descriptor 0, 0, 0 CODE32_DESC : Descriptor 0, Code32SegLen - 1, DA_C + DA_32 GdtLen equ $ - GDT_ENTRY GdtPtr: dw GdtLen - 1 dd 0 Code32Selector equ (0x0001 << 3) + SA_TIG +SA_RPL0 ; end of gdt [section .s16] [bits 16] CODE16_SEGMENT: mov ax, cs mov ds, ax mov es, ax mov ss, ax mov sp, 0x7c00 mov eax, 0 mov ax, cs shl eax, 4 add eax, CODE32_SEGMENT mov word [CODE32_DESC + 2], ax shr eax, 16 mov byte [CODE32_DESC + 4], al mov byte [CODE32_DESC + 7], ah mov eax, 0 mov ax, ds shl eax, 4 add eax, GDT_ENTRY mov dword [GdtPtr + 2], eax ; 1. load GDT lgdt [GdtPtr] ; 2. close interrupt cli ; 3. open A20 in al, 0x92 or al, 00000010b out 0x92, al ; 4. enter protect mode mov eax, cr0 or eax, 0x01 mov cr0, eax ; 5. jump to 32 bits code jmp dword Code32Selector : 0 [section .s32] [bits 32] CODE32_SEGMENT: mov eax, 0 jmp CODE32_SEGMENT Code32SegLen equ $ - CODE32_SEGMEN
我们在进入保护模式后,进入一个死循环用以验证代码的正确性。运行效果如下
我们在进行反汇编之后,在第 5 步打上断点,单步执行用以验证代码的正确性,我们看到代码是正确的。我们在上面的代码中为什么不直接使用标签定义描述符中的段基地址?为什么 16 位代码段到 32 位代码段必须无条件跳转呢?那么在汇编中,NASM 将汇编文件当成一个独立的代码段进行编译,汇编代码中的标签(Label)代表的是段内偏移地址,实模式下需要配合段寄存器中的值计算标签的物理地址,这便是我们不直接使用标签定义描述符中的段基地址的原因了。代码跳转则是由于在汇编中存在一个流水线技术的概念。什么是流水线技术呢?处理器为了提高效率将当前指令和后续指令预取到流水线,因此,可能同时预期的指令中既有 16 位代码又有 32 位代码。为了避免将 32 位代码用 16 位代码的方式运行,需要刷新流水线,此时便需要使用无条件跳转 jmp 技术才能强制刷新流水线。
我们在上面代码的编写中,在第 5 步进行跳转的时候是用了 dword 关键字,那么它的作用是什么呢?去掉它有何影响呢?我们从 s16 ==> s32 的时候,在 16 位代码中,所有的立即数默认为 16 位,所以从 16 位代码段跳转到 32 位代码段时,必须做强制转换。否则,段内的偏移地址可能会被截断,如果执行的是 jmp dword Code32Selector : 0x12345678 这句指令时,它就会被截断。到时传过去的就只剩 0x5678 了。那么我们在之前虽然从实模式进入到了保护模式,可是连最基本的 hello world 都打印不出来,下来我们就继续深入保护模式之定义显存段。我们知道,为了显示数据的话,必须得存在两大硬件:显卡 + 显示器。显卡是为显示器提供需要显示的数据,控制显示器的模式和状态;而显示器则是将目标数据以可见的方式呈现在屏幕上。
显存是什么呢?显卡拥有自己内部的数据存储器,即为显存。显存在本质上和普通内存并无差别,用于存储目标数据,操作显存中的数据将导致显示器上内容的改变。显卡的工作模式分为两种:文本模式和图形模式。在不同模式下,显卡对显存内容的解释是不同的,可以使用专属指令或 int 0x10 中断改变显卡的工作模式。在文本模式下:显存的地址范围映射为:[ 0xB8000, 0XBFFF ],一完整屏幕可以显示 25 行,每行 80 个字符。下来我们来看看显卡的文本模式原理,如下图所示
文本模式下显示字符示例代码如下
具体源码如下
%include "inc.asm" org 0x9000 jmp CODE16_SEGMENT [section .gdt] GDT_ENTRY : Descriptor 0, 0, 0 CODE32_DESC : Descriptor 0, Code32SegLen - 1, DA_C + DA_32 VIEDO_DESC : Descriptor 0xB8000, 0X07FFF, DA_DRWA + DA_32 GdtLen equ $ - GDT_ENTRY GdtPtr: dw GdtLen - 1 dd 0 Code32Selector equ (0x0001 << 3) + SA_TIG + SA_RPL0 VideoSelector equ (0x0002 << 3) + SA_TIG + SA_RPL0 ; end of gdt [section .s16] [bits 16] CODE16_SEGMENT: mov ax, cs mov ds, ax mov es, ax mov ss, ax mov sp, 0x7c00 ; initialize GDT for 32 bits code segment mov eax, 0 mov ax, cs shl eax, 4 add eax, CODE32_SEGMENT mov word [CODE32_DESC + 2], ax shr eax, 16 mov byte [CODE32_DESC + 4], al mov byte [CODE32_DESC + 7], ah mov eax, 0 mov ax, ds shl eax, 4 add eax, GDT_ENTRY mov dword [GdtPtr + 2], eax ; 1. load GDT lgdt [GdtPtr] ; 2. close interrupt cli ; 3. open A20 in al, 0x92 or al, 00000010b out 0x92, al ; 4. enter protect mode mov eax, cr0 or eax, 0x01 mov cr0, eax ; 5. jump to 32 bits code jmp dword Code32Selector : 0 [section .s32] [bits 32] CODE32_SEGMENT: mov ax, VideoSelector mov gs, ax mov edi, (80 * 12 + 37) * 2 mov ah, 0x0c mov al, 'P' mov [gs:edi], ax jmp $ Code32SegLen equ $ - CODE32_SEGMENT
我们先来定义一个显存的段,再来定义一个对应的选择子,最后在 32 代码段中进行相应的数据操作。我们来看看屏幕上是否会打印出红色的字符 P。运行结果如下
我们看到已经打印出一个红色的字符 P 了。那么我们现在已经可以打印出一个字符了,打印 hello,world 就不是什么难事了。我们继续来完成这个在保护模式下的入门级编程实验,具体该如何完成呢?1、定义全局堆栈段(.gs),用于保护模式下的函数调用;2、定义全局数据段(.dat),用于定义只读数据(Hello,World!);3、利用对显存段的操作定义字符串打印函数(PrintString)。那么这个打印函数应如何设计呢?流程如下图所示
在这块会涉及到 32 位保护模式下的乘法操作,我们就顺便来看看这个操作是如何完成的。首先是将被乘数放到 AX 寄存器中,接着将乘数放到通用寄存器或 内存单元(16 位),最后是将相乘的结果放到EAX 寄存器中。那么在汇编中,$ 和 $$ 有何区别呢? $ 表示当前行相对于代码起始位置处的偏移量,而 $$ 则表示的是当前代码节(section)的起始位置。示例如下
我们来看看实现字符串的具体源码是怎么编写的,如下
%include "inc.asm" org 0x9000 jmp CODE16_SEGMENT [section .gdt] GDT_ENTRY : Descriptor 0, 0, 0 CODE32_DESC : Descriptor 0, Code32SegLen - 1, DA_C + DA_32 VIEDO_DESC : Descriptor 0xB8000, 0X07FFF, DA_DRWA + DA_32 DATA32_DESC : Descriptor 0, Data32SegLen - 1, DA_DR + DA_32 STACK_DESC : Descriptor 0, TopOfStackInit, DA_DRW + DA_32 GdtLen equ $ - GDT_ENTRY GdtPtr: dw GdtLen - 1 dd 0 Code32Selector equ (0x0001 << 3) + SA_TIG + SA_RPL0 VideoSelector equ (0x0002 << 3) + SA_TIG + SA_RPL0 Data32Selector equ (0x0003 << 3) + SA_TIG + SA_RPL0 StackSelector equ (0x0004 << 3) + SA_TIG + SA_RPL0 ; end of gdt TopOfStackInit equ 0x7c00 [section .dat] [bits 32] DATA32_SEGMENT: DTOS db "D.T.OS!", 0 DTOS_OFFSET equ DTOS - $$ HELLO_WORLD db "Hello World!", 0 HELLO_WORLD_OFFSET equ HELLO_WORLD - $$ Data32SegLen equ $ - DATA32_SEGMENT [section .s16] [bits 16] CODE16_SEGMENT: mov ax, cs mov ds, ax mov es, ax mov ss, ax mov sp, TopOfStackInit ; initialize GDT for 32 bits code segment mov esi, CODE32_SEGMENT mov edi, CODE32_DESC call InitDescItem mov esi, DATA32_SEGMENT mov edi, DATA32_DESC call InitDescItem mov eax, 0 mov ax, ds shl eax, 4 add eax, GDT_ENTRY mov dword [GdtPtr + 2], eax ; 1. load GDT lgdt [GdtPtr] ; 2. close interrupt cli ; 3. open A20 in al, 0x92 or al, 00000010b out 0x92, al ; 4. enter protect mode mov eax, cr0 or eax, 0x01 mov cr0, eax ; 5. jump to 32 bits code jmp dword Code32Selector : 0 ; esi --> code segment label ; edi --> descriptor label InitDescItem: push eax mov eax, 0 mov ax, cs shl eax, 4 add eax, esi mov word [edi + 2], ax shr eax, 16 mov byte [edi + 4], al mov byte [edi + 7], ah pop eax ret [section .s32] [bits 32] CODE32_SEGMENT: mov ax, VideoSelector mov gs, ax mov ax, StackSelector mov ss, ax mov ax, Data32Selector mov ds, ax mov ebp, DTOS_OFFSET mov bx, 0x0c mov dh, 12 mov dl, 33 call PrintString mov ebp, HELLO_WORLD_OFFSET mov bx, 0x0c mov dh, 13 mov dl, 31 call PrintString jmp $ ; ds:ebp --> string address ; bx --> attribute ; dx --> dh : row, dl : col PrintString: push ebp push eax push edi push cx push dx print: mov cl, [ds:ebp] cmp cl, 0 je end mov eax, 80 mul dh add al, dl shl eax, 1 mov edi, eax mov ah, bl mov al, cl mov [gs:edi], ax inc ebp inc dl jmp print end: pop dx pop cx pop edi pop eax pop ebp ret Code32SegLen equ $ - CODE32_SEGMENT
运行结果如下
我们看到已经成功打印出 hello world 了,我们将生成的 data.img 拷贝至 window 中,在创建的虚拟机中运行,看看结果是否一致
我们看到还是正确打印出了字符串,这虽然和我们之前所打印的效果是一样的,但是在本质上已经发生了翻天覆地的变化。现在是在保护模式下的 32 位代码段中打印出来的,截止目前为止,我们已经成功地从实模式切换到了保护模式。通过对保护模式的学习,总结如下:1、[ 段地址:偏移地址 ] 的寻址方式解决了早期程序重定位难的问题,8086 实模式下的程序无法保证安全性;2、80286 中剔除了保护模式,加强了内存段的安全性;3、出于兼容性的考虑,80286 之后的处理器都有 2 种工作模式,处理器需要设置特定步骤才能进入到保护模式,默认为实模式哦;4、80386 处理器开启了新处理器的篇章,32 位的寄存器和地址总线能够直接访问 4G 内存的任意角落;5、需要在 16 位实模式中对 GDT 中的数据进行初始化,代码中需要为 GDT 定义一个标识数据结构(GdtPtr),需要使用 jmp 指令从 16 位代码跳转到 32 位代码;6、实模式下可以使用 32 位寄存器和 32 位地址,显存是显卡内部的存储单元,其本质上与普通内存无任何差别;7、显卡有两种工作模式:文本模式 & 图形模式,文本模式下操作显存单元中的数据能够立即反应到显示器。