引言
- 从【保护模式】中,我们可以总结出,实现保护模式需要一下几个部分:
- 段描述符
- 段描述符表
- 段选择符
- 进入保护模式
- 接下来我们一一实现上面几个部分
段描述符的实现
- 每个段描述符的长度是 8 个字节,含有 3 个主要字段:段基地址、段界限、段属性
- 下面用汇编来定义一个段描述符数据结构
%macro Descriptor 3 ; 有三个参数:段基址、段界限、段属性 dw %2 & 0xFFFF ; 段界限 1 (2 字节) dw %1 & 0xFFFF ; 段基址 1 (2 字节) db (%1 >> 16) & 0xFF ; 段基址 2 (1 字节) dw ((%2 >> 8) & 0xF00) | (%3 & 0xF0FF) ; 段属性 1 + 段界限 2 + 段属性 2 (2 字节) db (%1 >> 24) & 0xFF ; 段基址 3 (1 字节) %endmacro ; 共 8 个字节
- 使用 Descriptor 宏来定义一个连续 8 个字节的数据块
- 这个宏传入 3 个参数:段基址、段界限、段属性
- 第一个参数:段基址,32位,表示物理地址
- 第二个参数:段界限,20位,表示段的总长度 这里并不是地址,而是段的字节长度
- 第三个参数:段属性,12位, 系统、门、数据等属性
- dw %2 & 0xFFFF ; 将第二个参数(段界限)的 bit0-bit15 位放入数据块的第 1、2 个字节
- dw %1 & 0xFFFF ; 将第一个参数(段基址)的 bit0-bit15 放入数据块的第 3、4 个字节
- db (%1 >> 16) & 0xFF ; 将第一个参数(段基址)的 bit16-bit23 放入数据块的第 5 个字节
- dw ((%2 >> 8) & 0xF00) | (%3 & 0xF0FF) ; 将第三个参数(段属性)的 bit8-bit11 清零,然后再位或上第二个参数(段界限)的 bit16-bit19 ,然后放入数据块的第 6、7 个字节
- db (%1 >> 24) & 0xFF ; 将第一个参数(段基址)的 bit24-bit31 放入数据块的第 8 个字节
- 使用 Descriptor 宏定义一个段描述符(实例化):DES : Descriptor Addr, Limit, Attr
- 段描述符中段属性也是比较复杂的,我们也是可以通过宏定义的方式来定义好各种属性,方便使用
DA_DR equ 0x90 ; 数据,只读 DA_DRA equ 0x91 ; 数据,只读,已访问 DA_DRW equ 0x92 ; 数据,可读/写 ; ... DA_CX equ 0x98 ; 代码,仅执行 DA_CA equ 0x99 ; 代码,仅执行,已访问 ; ...
段描述符表的实现
- 有了一个段描述符的定义方法,那么如何来实现段描述符表的定义呢?猜测一下,连续定义多个段描述符不就构成了一个段描述符表了嘛,机智如我
DES0 : Descriptor Addr0, Limit0, Attr0 DES1 : Descriptor Addr1, Limit1, Attr1 DES2 : Descriptor Addr2, Limit2, Attr2 ; ... DES_LEN equ $ - DES0 ; DES 长度 = 当前地址 - DES0 地址
- 使用数组时不光需要知道数组首地址,还需要知道数组长度,DES_LEN 就相当于数组长度
段选择符的实现
- 根据段选择符的格式,定义其属性,方便使用
; RPL SA_RPL0 equ 0 SA_RPL1 equ 1 SA_RPL1 equ 2 SA_RPL1 equ 3 ; TI SA_TIG equ 0 ; GDT SA_TIG equ 4 ; LDT
进入保护模式的实现
- 在实现进入保护模式代码之前,还有一些知识点需要注意
- 代码中必须明确指明是 16 位代码段还是 32 位代码段
- 同样一句汇编代码,编译成 16 位机器码和编译成 32 位机器码是不一样的。别问为啥不一样,它就是不一样,设计 CPU 的人没办法弄成一样。
- 实模式是 16 位的,而保护模式是 32 位的,这就带来问题了,编译生成的机器码怎么区分 16 还是 32 位的呢?
- 你会说,管它干嘛?编译器会帮我们做的,然而,实际上是编译器它就没那么智能,无法自己区分。
- 为此,编译器提供了伪指令 bits ,由程序员显性的告知编译器
- [bits 16] 是告诉编译器,下面的代码帮我编译成 16 位的机器码
- [bits 32] 是告诉编译器,下面的代码帮我编译成 32 位的机器码
- 段描述符表中的第 0 个描述符不适用(仅用于占位)
- 必须使用 jmp 指令从 16 位代码段跳转到 32 位代码段
- 进入保护模式步骤
- 定义描述符表:上面已经说明,这里不再描述
- 打开 A20 地址线:实模式下只使用 20 条地址线,如今我们是在保护模式下,我们 需要突破第 20 条地址线去访问更大的内存空间。方式极其简单,将端口 0x92 的第 1 位置 1 就可以了
in al,0x92 or al,0000_0010B out 0x92,al
- 加载描述符表
- 前面说过我们通过一个特殊寄存器来指向描述符表,这个特殊寄存器便是 GDTR ,关于 GDTR 可以不做过多的了解,你只知道软件是通过 lgdt 指令操作 GDTR 就可以了。
- 其格式是 lgdt [6 个字节的内存数据首地址]
- 这 6 个字节内存数据划分为两部分,其中前 16 位是 GDT 以字节为单位的界限值,所以这 16 位相当于 GDT 的字节大小减 1。后 32 位是 GDT 的起始地址。
- 我们只需要先定义 48 位的数据
GDT_PTR : dw GDT_LEN - 1 dd GDT_BASE
- 然后再使用 lgdt 就可以了
lgdt [GDT_PTR]
- 通知 CPU 进入保护模式:就是将 CR0 寄存器的 PE(bit0) 位置 1
mov eax, cr0 or eax, 0x01 mov cr0, eax
- OK,最终实现的代码 loader.asm
- 补充知识点一:为什么不直接使用标签定义描述符中的段基址?即全局描述符表定义中 CODE32_DESC 定义时段基址为什么是 0 ,而不是 CODE32_START 标签
- NASM 将汇编文件当成一个独立的代码段编译
- 汇编代码中的标签代表的是段内偏移地址,并不是真实的物理地址
- 而描述符中的段基址需要的是物理地址,物理地址 = cs*16+偏移地址
- 补充知识点二:“jmp dword CODE32_SELECTOR:0” 理解
- 虽然到了保护模式,但访问内存还是要用 “段基址:段内偏移 地址”的形式。实模式下段寄存器中放的是偏移地址,而现在已经是选择子。段基址在段描述符中,用给出的选择子索引到描述符后,CPU 自动从段描述符中取出段基址,这样再加上段内偏移地址,便凑成了 “段基址:段内偏移地址” 的形式。
- 偏移地址是 0 ,32 位的代码段,所以直接跳到 32 位代码段的第一条指令 mov eax, 0x09 执行
- 流水线技术
- 处理器为了提高效率将当前指令和后续指令预取到流水线
- 因此可能同时出现预取的指令中既有 16 位指令,又有 32 位指令
- 为了避免 32 位代码当 16 位执行,需要在出现第一条 32 位指令时就强制刷新流水线,保证流水线中全都是 32 位指令
- 无条件跳转 jmp 能强制刷新流水线
如何验证
- 由于代码还在 16 位模式下打印字符 “Loader...”,跳转到 32 位保护模式后 print 打印函数也不能使用了,那该如何验证是否成功进入 32 位保护模式了呢
- 可以通过 bochs 断点调试的方法看看程序有没有跳转到 32 位代码中执行
- 首先进行反汇编指令,生成 loader.txt
ndisasm -o 0x900 loader.bin > loader.txt
- 从 “loader.txt” 文件中,找到 “jmp dword CODE32_SELECTOR:0” 这条汇编代码对于的地址是 0x974 ,在 0x974 地址处打个断点,运行到断点处后,单步调试查看到在循环执行 32 位代码,这说明我们的代码是 OK 的
<bochs:1> b 0x974 <bochs:2> info b Num Type Disp Enb Address 1 pbreakpoint keep y 0x00000974 <bochs:3> c (0) Breakpoint 1, 0x00000974 in ?? () Next at t=16762500 (0) [0x00000974] 0000:00000974 (unk. ctxt): jmp far 0008:00000000 ; 66ea000000000800 <bochs:4> s Next at t=16762501 (0) [0x0000098d] 0008:00000000 (unk. ctxt): mov eax, 0x00000009 ; b809000000 <bochs:5> s Next at t=16762502 (0) [0x00000992] 0008:00000005 (unk. ctxt): jmp .-7 (0x0000098d) ; ebf9 <bochs:6> s Next at t=16762503 (0) [0x0000098d] 0008:00000000 (unk. ctxt): mov eax, 0x00000009 ; b809000000 <bochs:7> s Next at t=16762504 (0) [0x00000992] 0008:00000005 (unk. ctxt): jmp .-7 (0x0000098d) ; ebf9
实战
- 实战内容:保护模式下的显存操作
- 前面在 完善MBR 中在做过实验,通过显存的方式来打印字符,只不过那个是实模式下的,我们现在来做个保护模式下的显存操作
- 接下来先显示一个字符 'P'
- 准备工作就两点:
- 显存段描述符:VIDEO_DESC
- 显存段选择符:VIDEO_SELECTOR
- 将上面实现的 loader.asm 改动一丢丢,改动后程序:loader.asm
; 改动处 1 VIDEO_DESC : Descriptor 0xB8000, 0xBFFFF - 0xB8000, DA_DRWA + DA_32 ; 改动处 2 VIDEO_SELECTOR equ (0x0002 << 3) + SA_RPL0 + SA_TIG ; 改动处 3 mov ax, VIDEO_SELECTOR mov gs, ax mov al, 'P' mov ah, 0x0F mov [gs:320], ax ; 320 = (80*2 +0)*2 第三行第一列 ; 每行最多显示 80 个字符, ; 每个字符显示都需要两个字节
- 我们的任务结束了吗?肯定不会那么简单啦,实战当然要有一丢丢深度吧
- 首先我们就要来实现一个 print_str_32 函数,用来打印指定内存中的字符串
- 既然是函数,那么就需要设置栈顶用于函数的入栈和出栈
mov esp, 0x900
- 其实不设置也没关系,因为 16 位实模式下设置过 sp 寄存器了,进入保护模式后 esp 的值依是 sp 中的值
- 我们又可以定义一个数据段用来存放要打印的数据。
- 同上,我们需要先定义一个数据段,然后设置数据段描述符和段选择符
; 定义一个数据段 DATA_SEGMENT: msg2 db "Enter protection", 0 ; 以 0 为字符串结束标志 msg2Offset equ msg2 - DATA_SEGMENT ; msg2 在数据段 DATA_SEGMENT 中的偏移量 DATA_SEG_LEN equ $ - DATA_SEGMENT ; 数据段描述符 DATA_DESC : Descriptor 0, DATA_SEG_LEN - 1, DA_DR + DA_32 ; 数据段选择符 DATA_SELECTOR equ (0x0004 << 3) + SA_RPL0 + SA_TIG ; 初始化数据段描述符中的段基址 mov esi, DATA_SEGMENT mov edi, DATA_DESC call InitDescItem
- DATA_DESC 这个描述符在定义的时候跟 CODE32_DESC 一样,他们的段基址在都为 0 ,后面需要再做赋值操作,重复的操作我们当然要封装成函数了
- 源代码:
mov eax, 0 mov ax, cs shl eax, 4 add eax, CODE32_START mov word [CODE32_DESC + 2], ax shr eax, 16 mov byte [CODE32_DESC + 4], al mov byte [CODE32_DESC + 7], ah • CODE32_START 和 CODE32_DESC 可以看成函数参数。用 esi 替代 CODE32_START ,用 edi 替代 CODE32_DESC ,封装后: ; 参数: esi --> 代码段标签 ; 参数: edi --> 段描述符标签 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
- 封装好了 InitDescItem 函数后,初始化段描述符中段基址操作如下:
mov esi, CODE32_START mov edi, CODE32_DESC call InitDescItem mov esi, DATA_SEGMENT mov edi, DATA_DESC call InitDescItem
- 最后一点点了,32 位模式下,我们再封装一个使用显存方式打印字符串函数(16 位的打印函数不能在 32 位中使用了)
- 其原理就是将要打印的数据一个一个的搬到显存中,遇到 0 则终止搬运
; 使用显存方式打印字符串 ; ds:ebp --> 打印的数据起始地址(相对于段基址的偏移地址) ; bl --> 打印属性 ; dx --> 打印起始坐标 (dl, dh) print_str_32: push ebp push eax push edi push cx push dx ; 循环 s: mov cl, [ds:ebp] ; 取地址 ds:ebp 中的数据存到 cl 寄存器 cmp cl, 0 ; 比较 cl 是否为 0 je end ; 若为 0 ,就结束打印 ; 根据坐标 (dl, dh) 计算出偏移量,存入 eax 中 ; (80 * dh + dl)*2 ; 每行最多显示 80 个字符 mov eax, 80 mul dh add al, dl shl eax, 1 ; eax = eax*2 mov edi, eax ; edi :显存中的偏移量 mov ah, bl ; 显示属性 mov al, cl ; 要打印的字符 mov [gs:edi], ax ; 显示数据放入显存 inc ebp ; 自增 inc dl ; 自增 jmp s ; 循环 ; 打印结束 end: pop dx pop cx pop edi pop eax pop ebp ret
- 至此,32 位保护模式下打印 "Enter protection" 程序:loader.asm
- 让我们看一下最终效果吧
- 为了进一步理解 32 位保护模式下的段,我们给保护模式下专门设置一个栈段
- 同上,我们需要先定义一个栈段,然后设置栈段描述符和段选择符
; 定义一个栈段 STACK32_SEGMENT: times 1024 * 4 db 0 ; 开辟 4K 的内存空间当做栈 STACK32_SEG_LEN equ $ - STACK32_SEGMENT TOP_OF_STACK32 equ STACK32_SEG_LEN - 1 ; 栈顶 ; 栈段描述符 STACK32_DESC : Descriptor 0, TOP_OF_STACK32, DA_DRW + DA_32 ; 栈段选择符 STACK32_SELECTOR equ (0x0004 << 3) + SA_RPL0 + SA_TIG ; 初始化栈段描述符中的段基址 mov esi, STACK32_SEGMENT mov edi, STACK32_DESC call InitDescItem
- 对于栈,初始化时还需要设置 ss 和 esp 寄存器
mov ax, STACK32_SELECTOR mov ss, ax mov eax, TOP_OF_STACK32 mov esp, eax
- 嗯~,这个没啥现象!就不验证了。最终程序:loader.asm
- 注意了,原先我们在将 loader.bin 写入硬盘 a.img 的时候只写了一个扇区,由于给栈段分配了 4K 的内存,那么 loader.bin 的大小已经超过 1 个扇区大小(512 字节)。我们的 Makefile 和 boot.asm 文件中扇区数相关处也需要改动
# 将 loader.bin 写入硬盘 a.img 的第 2 个扇区开始的连续 20 个扇区(10K) dd if=$(LOADER_BIN) of=$(IMG) bs=512 count=20 seek=2 conv=notrunc mov eax, 0x02 mov bx, 0x900 ; mov cx, 0x01 mov cx, 20 call rd_disk_to_mem