x86 保护模式浅析(三)

工具准备

  • NASM 汇编器,以及一个文本编辑器
  • VirtualBox 虚拟机(或者 Bochs 也可以)

开始实战

进入保护模式的汇编代码

gdt_base equ 7e00h  ; gdt_base 表示 GDT 头部的地址

; 这一段代码主要是将 GDT 的头部地址,转换为 “段:偏移” 的形式,在实模式下的寻址
mov ax, gdt_base
xor dx, dx
mov bx, 10h
div bx
mov ds, ax
mov bx, dx

; 构造 GDT(系统约定,第一个选择子要用 0 填充)
mov dword [ds:bx], 0         ; 填为 0
mov dword [ds:bx + 04h], 0   ; 填为 0

; 代码段选择子(设置属性:可执行,可读,ring0 特权级执行)
mov dword [ds:bx + 08h], 7c0001ffh
mov dword [ds:bx + 0Ch], 00409A00h

; 数据段选择子(设置属性:不可执行,可写,地址向上增长)
mov dword [ds:bx + 10h], 8000ffffh
mov dword [ds:bx + 14h], 0040920Bh

; 堆栈段选择子(设置属性:不可执行,可写,地址向下增长)
mov dword [ds:bx + 18h], 00007A00h
mov dword [ds:bx + 1Ch], 00409600h

; 对向量的低字,写入 GDT 界限(GDT 总字节数 - 1)
mov word [cs:gdt_vector], 20h - 1

; 对向量的高双字,写入 GDT 的线性地址
mov dword [cs:gdt_vector + 2], gdt_base

; 启用 A20 地址线
in al, 92h
or al, 2
out 92h, al

; 关闭中断
cli

; 加载 GDTR 寄存器
lgdt [cs:gdt_vector]

; 设置 PE 位,使 CPU 进入保护模式
mov eax, cr0
or al, 1
mov cr0, eax

; 命运的跳转,清空流水线,并串行化地执行跳转后的机器代码
jmp 8h:flush

[bits 32]
flush:	
	mov ax, 10h  ; 设置段选择子(这里设置的数据段即是显存区域)
	mov ds, ax

    ; 通过段选择子,直接访问内存,向屏幕第一行第一列打印红色的 'P'
	mov byte [ds:0], 'P'
	mov byte [ds:1], 0Ch
	
    ; 死循环(进入保护模式就大功告成了,现在什么也不做,让 CPU 空转)
	jmp $

; GDT 向量,低字表示 GDT 界限,高双字表示 GDT 线性地址
gdt_vector dw 0
           dd 0

times 510 - ($ - $$) db 0  ; 填充剩余扇区空间
db 55h, 0aah  ; 可引导扇区标识符

使用汇编器进行汇编

NASM 汇编器的目录位置如果被添加到 PATH 环境变量中去了,就可以直接在命令提示符中使用。笔者这里源文件叫做 mbr.asm,运行如下命令将在当前目录下生成一个 mbr.bin 文件,这就是生成的机器码文件

nasm -o mbr.bin mbr.asm

写 VHD 虚拟磁盘

如果你需要将引导程序写入虚拟磁盘中,最好使用 VHD 格式的固定大小的磁盘,因为笔者写了一个命令行工具 —— Fixed VHD Writer 放在了 Github 上。它可以使用 LBA 的方式对固定大小的 VHD 磁盘进行写扇区的操作,如果要使用笔者的工具,在创建虚拟机时,使用固定大小的 VHD 格式虚拟磁盘是必要的

Fixed VHD Writer
[-h] 显示帮助
[-r] 指定读取的文件
[-w] 指定写入的固定大小的 VHD 磁盘
[-a] 指定 LBA 扇区编号
Fixed VHD Writer 使用示范

使用笔者的工具,将 mbr.bin 写入固定大小的 VHD 磁盘的 0 扇区,写入工具输出了一些信息,写入字节数、写入扇区数等

启动虚拟机

现在可以启动虚拟机了,如果一切顺利,那么你将看到虚拟机屏幕开头会显示出一个红色的 ‘P’ 字符,这表示 CPU 成功进入保护模式,大功告成!

进入保护模式成功,红色的 ‘P’ 字符

结语

基本上,进入保护模式可以简单地划分为 5 个步骤:

  • 设置 A20 地址线(本篇采用 Fast A20 Gate 方式)
  • 关闭中断
  • 加载 GDT 到 GDTR 寄存器
  • 设置 CR0 控制寄存器的 PE 位
  • 命运的跳转(清空流水线)

至于 GDT 的数据结构、GDT 向量,其实都只是起到描述作用。读者可以先不看笔者的代码自己实现一下,这样可以加深印象,真正地理解并且吃透

参考资料

[1] 李忠 , 王晓波 . x86汇编语言:从实模式到保护模式 [M] . 北京 : 电子工业出版社 , 2013.

x86 保护模式浅析(二)

走进保护模式

A20 地址线

在本文中,要完全解释 A20 地址线的作用以及原理是不太现实的。对于我们程序员而言,我们只需要了解一些大致的概念。简单来说,因为考虑到向下兼容性,A20 地址线在默认情况下是禁用的。于是,我们得启用它,这样我们就可以访问全部内存,这对于 CPU 在保护模式下正常工作是十分重要的

至于如何启用 A20 地址线,OSDev 上面列举了很多方法,可以参考链接:https://wiki.osdev.org/A20
这里,我们列举比较简单的一种方法,在大多数情况下是可正常使用的(对于一些老主板,需要主板CMOS设置中启用快速 A20 支持 )

  • 快速 A20 门(Fast A20 Gate)
in al, 92h    ; 从 92h 端口读一字节
or al, 2      ; 置字节的第 2 位为 1
out 92h, al   ; 写回 92h 端口

上述的 Fast A20 Gate 方式只需要 3 条指令就可以启用 A20 地址线,当执行完这 3 条指令后,CPU 就有能力访问全部内存了

关闭中断(包括 NMI 在内)

保护模式下,CPU 的中断机制与实模式是不相同的。BIOS 中断、中断向量表都不再适用于保护模式,在进入保护模式之前,需要先使用 CLI 指令以关闭中断

cli    ; 关闭中断

设置 GDTR 寄存器

在上一篇文章中,我们讲到 GDT、段选择子的概念,在进入保护模式前,首先要正确地设置 GDTR 寄存器,确保 GDT 的设置是有效的

lgdt m48    ; 48 位(6字节)向量,低字表示 GDT 界限(字节大小减1),高双字表示 GDT 头部的线性地址

设置控制寄存器的PE位

CPU 内部的 0 号控制寄存器(CR0)中的最低位即是PE(Protection Enable,保护生效)位,将其置为 1 时,CPU 将工作在保护模式下,汇编代码可以像下面这样写

mov eax, cr0
or al, 1      ; 置 PE 位为 1
mov cr0, eax

命运的跳转(jmp dword 方式)

jmp dword 段选择子偏移:跳转到的地址

其中,段选择子偏移就是对应段选择子(一般是代码段选择子)相对于GDT头部的偏移字节数,而跳转到的地址,即是在段选择子的段界限内的地址。因为 PE 位的设置,现在 CPU 在保护模式下,所以得用段选择子和线性地址的方式去访问内存

至于为什么要 jmp dword,是因为现在 CPU 还在 16 位机器代码上执行跳转,但是跳转的地址却是用 32 位保护模式的方式寻址的。jmp dword 能够使 CPU 的流水线清空,并串行化执行跳转后的 32 位机器代码,这样 CPU 就能在保护模式下正常运行。

后续

光说不练假把式,也许你还是只有一些笼统的概念。下一篇文章,笔者将用整个实例来演示并穿插解释一段引导扇区程序是如何一步步进入保护模式的。感谢阅读本文!

x86 保护模式浅析(一)

实模式下的内存访问

实模式下,CPU通过段寄存器(16位)和偏移地址(16位)来访问物理地址,这个过程可以抽象地简述成以下步骤:
1、将段寄存器中存放的地址,左移4位(乘以2的4次方,即乘以16)得到基址(为了方便理解暂且这么叫吧)
2、将上一步得到的基址加上偏移地址,得到真实的物理地址

在上面的步骤中,我们不难看出的这几点特性:

  • 在实模式下,当段寄存器为 0 时,物理地址所能表示的最大范围是 0 ~ FFFFF,即 1MB 连续内存空间
  • 在实模式下,无论段寄存器为何值(无论基址为何值),CPU 总是只能访问 1MB 的连续内存空间
  • 在实模式下,段寄存器的作用更像是确定一个访问起点,偏移地址是用于基于这个起点开始计算距离,最终确定内存位置

初探 GDT

x86 保护模式使得 CPU 不再局限于 1MB 的内存空间,使其寻址能力达到了 4GB(后文会解释原因),同时它提供了更多优良的特性。废话少说,我们可以先来了解一下以下概念。

段选择子(Selector)

要进入保护模式,CPU 必须要设置一些必要的参数,这个工作由程序员完成。程序员通过设置全局描述符表(简称 GDT),使得 CPU 从中获取必要的参数,这样 CPU 才能顺利进入保护模式。全局描述符表(GDT)一般由多个段选择子(Selector)组成。下面笔者使用类 C 伪代码表示单个段选择子的相关结构,并给出解释

  • 单个全局描述符表的表项(即段选择子)的数据结构(从低字节到高字节排列):
struct gdt_entry {    // 单个段选择子,也就是全局描述符表的每一个表项的结构
    WORD low_limit;        // 段界限的  0 ~ 15 位
    WORD low_base;         // 段基址的  0 ~ 15 位
    BYTE high_base;        // 段基址的 16 ~ 23 位
    BYTE access_byte;      // 访问控制(一字节)(下文有展开说明)
    HALF_BYTE high_limit;  // 段界限的 16 ~ 19 位(段界限共20位)
    HALF_BYTE flags;       // 标志(半字节)(下文有展开说明)
    BYTE high_high_base;   // 段基址的 24 ~ 31 位(段基址共32位)
}selector; // 段选择子
  • 访问控制字节(access_byte)(从低位到高位排列)
BYTE access_byte {  // 没办法,理解性地,暂且这么定义吧
    _bit AC;  // 被访问标志位:一般置为 0,当该段被访问时,CPU将其置为 1
    _bit RW;  // 读写标志位:若是代码段,则此位表示该段是否可读;若是数据段,则此位表示该段是否可写
    _bit DC;  // 方向/顺应标志位:若是代码段,则此位表示是否可以从相等或较低的特权级开始执行(否则只能被“特权级标志”中描述的特权级所执行);若是数据段,则此位表示该段是否是向下(向低地址方向)增长的
    _bit Ex;  // 可执行标志位:此位表示该段是否可被执行,若置 0 则是数据段(不可执行)
    _bit S;   // 描述符类型标志位:置 1 表示该段是代码段或数据段,置 0 表示该段是系统段(暂时先置 1 吧)
    _double_bit Privl; // 特权级标志双位:双位可置 0 ~ 3,表示该段所具有特权级(ring0 ~ ring3)
    _bit Pr;  // 存在标志位:应当永远置 1,表示该选择子(Selector)有效
};
  • 标志位半字节(flags)(从低位到高位排列)
HALF_BYTE flags {  // 没办法,理解性地,暂且这么定义吧
    _bit zero;  // 一般置为 0
    _bit zero;  // 一般置为 0
    _bit Sz;    // 宽度标志位:此位表示选择子是否定义的是 32 位保护模式(置 0 表示选择子定义了 16 位保护模式)
    _bit Gr;    // 粒度标志位:此位置 1 表示段界限粒度为页(4KB),置 0 表示段界限粒度为字节(1Byte)
};

在保护模式下,段已经不同于实模式中的段。实际上,更多地是访问方式、起到的作用发生了改变。这可能需要读者自己去循序渐进地体会。下文将会讲到全局描述符表的结构以及加载方式,段选择子的使用方式

全局描述符表(Global Descriptor Table)

我们从前述中得知,段选择子就是全局描述符表的表项,也就是说 GDT 的每一个表项都是 8 字节的段选择子。段选择子中定义了段的基址、段的界限、还有一些描述性的信息。因为系统中一般将用到多个段,所以用线性的方式去存放这些段的选择子,于是构成了 GDT。我们可以先用类 C 的伪代码来定义 GDT 的数据结构

  • GDT(全局描述符表)(从低字节到高字节)
struct gdt {
    gdt_entry selector_null;  // 第 1 个选择子(全局描述符表项)(填充为 0)
    gdt_entry selector1;      // 第 2 个选择子(全局描述符表项)
    gdt_entry selector2;      // 第 3 个选择子(全局描述符表项)
    /* ...... */
};

GDT 的一个段选择子(第一个表项),必须全部填充为 0,这是系统约定俗成

看到这里,你应该已经对 GDT 的结构豁然开朗了。下文我们将介绍如何加载 GDT,为初探 x86 保护模式的难点画上句号

加载 GDT

假设我们在内存的某个位置定义了一个包含多个段选择子的 GDT,它包含我们进入保护模式后,CPU 在保护模式正常运行的相关信息。在我们进入保护模式之前需要让 CPU 加载这个 GDT,现在,我们有两样法宝,一个是 LGDT 机器指令、另外一个是 GDTR 寄存器。

顾名思义,LGDT 指令能够将 GDT 载入到 GDTR 寄存器中,这样 CPU 就“得到”了在保护模式下正常运作的相关信息。问题是,具体该如何做到。我们先来了解 GDTR 寄存器的结构,如图所示

Size 即是GDT所占总字节数减 1 的值(边界值),Offset 即是 GDT 头部的线性地址(即绝对地址)

不难看出,GDT 中最多能容纳 0x10000 / 8 = 8192 个段选择子。话说回来,我们可以使用 LGDT 指令从指定内存位置读取这个 48 位(6字节)的 GDT 向量到 GDTR 寄存器中。这样,CPU 就将 GDT 的相关信息“牢记在心”

后续

下一篇文章,笔者将讲述如何为真正进入保护模式扫清障碍,有关 A20 地址线、PE 标志位的设置、以及段选择子的用法、保护模式下的内存寻址方式。感谢阅读本文!