实模和保护模式的概念
接下来比较重要的一点就是从实模式到保护模式的切换了. 这一块概念比较多, 我也是参考了多份资料才分析清楚了之间的关系. 好在这部分网上相关的文章很多, 这里我挑重点用自己的理解概括一下.
CPU有多种工作模式, 这里我们提一下实模式和保护模式.
-
实模式 : 操作系统刚启动就算实模式, 这种模式下, 寻址是通过 : 物理地址 = 段值 * 16 + 偏移实现的 (由于8086时代寄存器和数据总线都是16位, 而地址总线是20位),由于段值和偏移均为16位(段值通常是存在DS、ES、FS、GS、SS这些段寄存器中的), 所以最终寻址能力只有1M.
-
保护模式 : 从80386开始, 寄存器和地址总线均升级到了32位, 此时CPU获得了最大4GB的寻址能力. 但是这时候采用了一种新的策略, 段值不再是地址的一部分, 它成为了一个索引, 这里我们引入GDT(global descriptor table)的概念. 在GDT中, 内存被分割成了很多个段, 表中的每一项分别代表一个段, 分别记录了每一个段的起始位置, 界限(端的具体大小), 属性(操作权限)等等. 这张表具体长什么样, 网上图片很多自己可以去搜索. 这里我用C语言中的结构体来表示.
typedef struct
{
uint16_t limit_low; // 段界限 15~0
uint16_t base_low; // 段基地址 15~0
uint8_t base_middle; // 段基地址 23~16
uint8_t access; // 段存在位、描述符特权级、描述符类型、描述符子类别
uint8_t granularity; // 其他标志、段界限 19~16
uint8_t base_high; // 段基地址 31~24
} __attribute__((packed)) gdt_entry_t;
这里__attribute__ ((packed))
的作用就是告诉编译器取消结构在编译过程中的优化对齐,按照实际占用字节数进行对齐,是GCC特有的语法。由此我们也可以发现这个结构体正好是8个字节.
那么这个时候. 这里指的注意的是, 段寄存器不像通用寄存器已经升级为了32位, 仍然是16位, 之前说到它保存的不再是地址的一部分而是一个索引, 现在这个值有个特别的名字叫做选择子, 具体结构是 : 前两位是RPL(request privilege level), 代表的是用什么权限去访问, 第三位是TI(table indicator), 0表示查找GDT, 1表示查找LDT. 剩下的13位才表示的是表中的索引.
但是这时候其实还有一个概念没有说, 就是CPU如何找到这个GDT呢? 这里靠的就是GDTR(global descriptor table register), 这也是80386的一个改进, 增加了48位的寄存器GDTR. 他的结构更加简单, 前16位表示GDT的界限(表的具体大小), 后32位表示表的位置. 这里你会发现前16表示的表最大也只能是64k, 这与选择子中使用13位所能索引的最大位置8k * 8(每一项8字节) = 64k保持了一致.
从这里可以看到保护模式的好处就在于 -----> 寻址能力增大以及增加了权限机制保证了安全性.
切换到保护模式的工作
现在可以明确一下下一步要做的工作了, 即切换到保护模式. 那么切换到保护模式之前我们必须要保证 :
- 完成GDT初始化, 对其中各个表项进行正确赋值
- 加载GDTR, 确保其值正确.
- 打开A20
- 置cr0的PE位
- 跳转进入保护模式.
首先是GDT初始化, C语言版本 :
#define GDT_LENGTH 5
// 全局描述符表定义
gdt_entry_t gdt_entries[GDT_LENGTH];
// GDTR
gdt_ptr_t gdt_ptr;
// 全局描述符表构造函数,根据下标构造
static void gdt_set_gate(int32_t num, uint32_t base, uint32_t limit, uint8_t access, uint8_t gran);
// 声明内核栈地址
extern uint32_t stack;
// 初始化全局描述符表
void init_gdt()
{
// 全局描述符表界限 e.g. 从 0 开始,所以总长要 - 1
gdt_ptr.limit = sizeof(gdt_entry_t) * GDT_LENGTH - 1;
gdt_ptr.base = (uint32_t)&gdt_entries;
// 采用 Intel 平坦模型
gdt_set_gate(0, 0, 0, 0, 0); // 按照 Intel 文档要求,第一个描述符必须全 0
gdt_set_gate(1, 0, 0xFFFFFFFF, 0x9A, 0xCF); // 指令段
gdt_set_gate(2, 0, 0xFFFFFFFF, 0x92, 0xCF); // 数据段
gdt_set_gate(3, 0, 0xFFFFFFFF, 0xFA, 0xCF); // 用户模式代码段
gdt_set_gate(4, 0, 0xFFFFFFFF, 0xF2, 0xCF); // 用户模式数据段
// 加载全局描述符表地址到 GPTR 寄存器
gdt_flush((uint32_t)&gdt_ptr);
}
这里gdt_set_gate
用于设置GDT中的表项, 代码如下 :
static void gdt_set_gate(int32_t num, uint32_t base, uint32_t limit, uint8_t access, uint8_t gran)
{
gdt_entries[num].base_low = (base & 0xFFFF);
gdt_entries[num].base_middle = (base >> 16) & 0xFF;
gdt_entries[num].base_high = (base >> 24) & 0xFF;
gdt_entries[num].limit_low = (limit & 0xFFFF);
gdt_entries[num].granularity = (limit >> 16) & 0x0F;
gdt_entries[num].granularity |= gran & 0xF0;
gdt_entries[num].access = access;
}
同时最后一行的作用是加载GDTR, 目前只看前两行即可 :
[GLOBAL gdt_flush]
gdt_flush:
mov eax, [esp+4] ; 参数存入 eax 寄存器
lgdt [eax] ; 加载到 GDTR [修改原先GRUB设置]
;@--------- ignore -------------------------------
mov ax, 0x10 ; 加载我们的数据段描述符
mov ds, ax ; 更新所有可以更新的段寄存器
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
jmp 0x08:.flush ; 远跳转,0x08是我们的代码段描述符
; 远跳目的是清空流水线并串行化处理器
.flush:
ret
下面是另外一种 :
%include "pm.inc" ; 常量, 宏, 以及一些说明
org 07c00h
jmp LABEL_BEGIN
[SECTION .gdt]
; GDT
; 段基址, 段界限 , 属性
LABEL_GDT: Descriptor 0, 0, 0 ; 空描述符
LABEL_DESC_CODE32: Descriptor 0, SegCode32Len - 1, DA_C + DA_32; 非一致代码段
;@ 0B8000 - 0BFFFF 该段地址映射文本模式的显存, 这是固定的.
LABEL_DESC_VIDEO: Descriptor 0B8000h, 0ffffh, DA_DRW ; 显存首地址
; GDT 结束
GdtLen equ $ - LABEL_GDT ; GDT长度
GdtPtr dw GdtLen - 1 ; GDT界限
dd 0 ; GDT基地址
; GDT 选择子
SelectorCode32 equ LABEL_DESC_CODE32 - LABEL_GDT ;@此时忽略结构子前三位的特殊意义
SelectorVideo equ LABEL_DESC_VIDEO - LABEL_GDT
; END of [SECTION .gdt]
[SECTION .s16]
[BITS 16]
LABEL_BEGIN:
mov ax, cs
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0100h
; 初始化 32 位代码段描述符
xor eax, eax ;@eax 归0
;@接下来两行模拟从实模式寻址, 并将地址存在eax当中.
mov ax, cs
shl eax, 4
add eax, LABEL_SEG_CODE32
;@将描述符表中第二项的基地址改为32位代码段的入口
mov word [LABEL_DESC_CODE32 + 2], ax
shr eax, 16
mov byte [LABEL_DESC_CODE32 + 4], al
mov byte [LABEL_DESC_CODE32 + 7], ah
; 为加载 GDTR 作准备
;@接下来两行模拟从实模式寻址, 并将地址存在eax当中.
xor eax, eax
mov ax, ds
shl eax, 4
add eax, LABEL_GDT ; eax <- gdt 基地址
;@将GDT指针的入口改为GDT真实的入口, 上面初始化为0了.
mov dword [GdtPtr + 2], eax ; [GdtPtr + 2] <- gdt 基地址
; 加载 GDTR
lgdt [GdtPtr]
; 关中断
cli
; 打开地址线A20
in al, 92h
or al, 00000010b
out 92h, al
; 准备切换到保护模式
mov eax, cr0
or eax, 1
mov cr0, eax
; 真正进入保护模式
jmp dword SelectorCode32:0 ; 执行这一句会把 SelectorCode32 装入 cs,
; 并跳转到 Code32Selector:0 处
; END of [SECTION .s16]
[SECTION .s32]; 32 位代码段. 由实模式跳入.
[BITS 32]
LABEL_SEG_CODE32:
mov ax, SelectorVideo
mov gs, ax ; 视频段选择子(目的)
mov edi, (80 * 11 + 79) * 2 ; 屏幕第 11 行, 第 79 列。
mov ah, 0Ch ; 0000: 黑底 1100: 红字
mov al, 'P'
mov [gs:edi], ax
; 到此停止
jmp $
SegCode32Len equ $ - LABEL_SEG_CODE32
; END of [SECTION .s32]
在开头的.gdt段中, Descriptor是宏定义, 其具体内容是这样的:
;
; 描述符
; usage: Descriptor Base, Limit, Attr
; Base: dd
; Limit: dd (low 20 bits available)
; Attr: dw (lower 4 bits of higher byte are always 0)
%macro Descriptor 3
dw %2 & 0FFFFh ; 段界限1
dw %1 & 0FFFFh ; 段基址1
db (%1 >> 16) & 0FFh ; 段基址2
dw ((%2 >> 8) & 0F00h) | (%3 & 0F0FFh) ; 属性1 + 段界限2 + 属性2
db (%1 >> 24) & 0FFh ; 段基址3
%endmacro ; 共 8 字节
;
步骤中的前两点已经给出了很清楚的解释, 接下来解释一下什么叫打开A20. 因为早期实模式下寻址范围最多1M, 那么如果如果试图访问超过1M的地址时, 实际系统会回卷(wrap)到零开始重新寻址, 那么后来寻址能力已经突破到4G了, 这样显然是不行的, 必须禁用这种回卷的机制. 但是如果直接禁用的话又不能保证系统的向下兼容性, 于是乎便出现了A20地址线的开关, 默认是关闭的, 此时系统保持向下兼容性, 如果打开, 则不再回卷, 寻址能力增加到4G, 这就是所谓的打开A20. 具体的打开方式有很多, 这是其中一种(我也不懂, 反正照这些就行了).
接下来的一点, 真正决定CPU工作模式的, 是控制寄存器CR0(在80386此类寄存器共有5个), 这里我们只需要将CR0的PE位(其实就是0号位), 从0修改为1, CPU就将从默认的实模式改为保护模式.
至于为什么是jmp dword + 地址
而不是jmp + 地址
, 书中给出的解释是不加的话编译出来的代码仍然是16位的(注意此时仍然处于[BITS 16]的作用域中), 这样的话如果后面的地址很大, 超过16位会被截断(因为我们是调到32位的代码中), 所以必须要这么写. 至此从实模式到保护模式的过程就圆满结束了.