引言: 不要忘了自己的初心

为什么有保护模式

实模式不安全, 真实的物理地址随便访问, 用户进程想让想指向哪片内存就去哪. 访问内存区域也非常麻烦, 超过64kb就要切换段基址, 一次只能运行一个程序…

从16位到32位的硬件演进

保护模式其实是随着新硬件的发展而提出的新模式,主要体现在从16位处理器到32位处理器的跃迁:

  • 处理器位数提升: 从8086的16位发展到80386的32位
  • 寻址能力扩展: 从1MB扩展到4GB的内存寻址空间
  • 寄存器扩展: 通用寄存器从16位扩展到32位 (如AX→EAX)

寻址模式的根本变化

保护模式下的寻址发生了很大的区别:

  1. 摆脱段基址左移: 不再需要实模式下的"段基址×16+偏移"这种移位方法
  2. 平坦内存模型: 可以实现真正的线性地址空间,内存访问更加直观
  3. 灵活的基址寄存器: 可以用任意通用寄存器作为基址寄存器

指令兼容性问题

由于模式的切换,对应的机器码和指令也有一些兼容问题:

  • 16位与32位指令: 需要考虑16位和32位的命令翻译
  • 常见指令影响: mul、div、push等指令在不同模式下的行为可能不同
  • 模式反转: 在实模式下使用eax(32位寄存器)会触发保护模式运行

全局描述符表

保护模式最重要的就是全局描述符表(GDT), 因为段的信息增加了很多, 需要提前把段定义好才能使用。就像家庭成员需要上户口一样,在户口簿上登记过才算合法。

GDT的核心作用

重点在于如何定义好GDT和进入保护模式,这样才能平坦地访问内存。

为了解决实模式存在的问题:

  1. 用户程序可以破坏存储代码的内存区域 -> 添加内存段
  2. 用户程序和系统程序的同一级别 -> 添加特权级
  3. 限制访问内存的范围 -> 增加约束条件

段描述符结构

这些描述内存段的属性被定义到了一个叫段描述符的结构里,占8个字节,可以参考这个链接里的讲解GDT

一个段描述符只用来定义(描述)一个内存段。代码段要占用一个段描述符、数据段和栈段等,多个内存段也要各自占用一个段描述符,这些描述符放在哪里呢?答案是放在全局描述符表。

GDT的重要特性

  1. 存储位置: GDT存在内存里,有一个专门的寄存器GDTR指向它,存储它的内存地址和界限,这是个48位寄存器
  2. 访问方式: 对于GDTR的访问需要lgdt指令,lgdt的指令格式是:lgdt 48位内存数据
  3. 容量限制: GDT中可容纳8192个段或门
  4. 第0个描述符: GDT的第0个描述符是不可用的,这是为了防止忘记初始化选择子的问题 —— 当选择子为0时,会触发异常,这是一种安全机制

段选择子

段寄存器在保护模式下,变成了段选择子,详细结构见p167。段选择子不再直接存储段基址,而是作为索引指向GDT中的具体段描述符。

特权级保护机制

同时进入保护模式后,考虑了一些特权级的问题,让程序更加安全:

  • 4个特权级: Ring0(内核级) 到 Ring3(用户级)
  • 特权级检查: CPU会自动检查代码段、数据段的访问权限
  • 安全隔离: 低特权级程序无法直接访问高特权级的内存段
  • 系统调用: 提供了安全的特权级切换机制

打开A20地址线

打开A20地址线其实就是,打开第21条地址线,不在回绕,让访存地址从 1MB 到 4GB(20位到32位)
使用固定的汇编代码即可

打开PE (Protection Enable)

PE位是CR0控制寄存器的第0位,也叫保护使能位。这是真正进入保护模式的开关

CR0寄存器简介

CR0是32位的控制寄存器,包含了系统控制标志:

  • PE位(第0位): Protection Enable,保护模式使能位
  • 当PE=0: CPU工作在实模式
  • 当PE=1: CPU工作在保护模式

进入保护模式的完整步骤

要成功进入保护模式,必须按以下顺序执行:

  1. 准备GDT: 在内存中构建全局描述符表
  2. 加载GDTR: 使用lgdt指令加载GDT的地址和界限
  3. 打开A20地址线: 启用21位地址线,突破1MB限制
  4. 设置PE位: 将CR0寄存器的PE位设置为1
  5. 跳转刷新: 使用远跳转指令刷新指令流水线

关键汇编代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
; 1. 加载GDT
lgdt [gdt_ptr]

; 2. 打开A20地址线 (具体实现略)
; ...

; 3. 设置CR0的PE位
mov eax, cr0
or eax, 0x00000001 ; 设置PE位为1
mov cr0, eax

; 4. 远跳转进入保护模式并刷新流水线
jmp dword SELECTOR_CODE:p_mode_start

[bits 32] ; 从这里开始是32位代码
p_mode_start:
; 现在已经在保护模式中了
; 需要重新设置段寄存器
mov ax, SELECTOR_DATA
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax

注意事项

  • 顺序很重要: 必须先准备好GDT再设置PE位
  • 远跳转必需: 用于刷新CPU的指令预取队列
  • 段寄存器重设: 进入保护模式后必须重新加载所有段寄存器
  • 不可逆转: 一旦进入保护模式,返回实模式需要复杂的步骤

通过以上步骤,CPU就从16位实模式成功切换到32位保护模式,可以享受4GB地址空间和内存保护机制带来的好处。

代码实践

boot.inc

参考一些网上的资料,但是他们的 boot.inc 有部分错误,我的是完全正确的, 注意这个显存段起始位置0xb8000 的高8位也就是 16~23位是0x0b 不是 00

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
;---------------loader and kernel---------------
LOADER_BASE_ADDR equ 0x900
LOADER_START_SECTOR equ 0x2

;---------------GDT 描述符属性------------------
DESC_G_4K equ 1_00000000000000000000000b ;颗粒度:4K
DESC_D_32 equ 1_0000000000000000000000b ;操作数和地址大小:32位
DESC_L equ 0_000000000000000000000b ;是否是64位代码段:否
DESC_AVL equ 0_00000000000000000000b ;不用此位,暂设置为:0
DESC_LIMIT_CODE2 equ 1111_0000000000000000b ;段界限19-16位
DESC_LIMIT_DATA2 equ DESC_LIMIT_CODE2 ;段界限19-16位
DESC_LIMIT_VIDEO2 equ 0000_000000000000000b ;?????
DESC_P equ 1_000000000000000b ;表示段存在
DESC_DPL_0 equ 00_0000000000000b ;特权级:0
DESC_DPL_1 equ 01_0000000000000b ;特权级:1
DESC_DPL_2 equ 10_0000000000000b ;特权级:2
DESC_DPL_3 equ 11_0000000000000b ;特权级:3
DESC_S_CODE equ 1_000000000000b ;表示非系统段
DESC_S_DATA equ DESC_S_CODE ;同上
DESC_S_SYS equ 0_000000000000b ;表示系统段
DESC_TYPE_CODE equ 1000_00000000b ;Type字段-代码段:x=1,c=0,r=0,a=0
DESC_TYPE_DATA equ 0010_00000000b ;Type字段-数据段:x=0,e=0,w=1,a=0

; 这里是段描述符中高32位
DESC_CODE_HIGH4 equ (0x00<<24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_CODE2 + DESC_P + DESC_DPL_0 + DESC_S_CODE + DESC_TYPE_CODE + 0x00
DESC_DATA_HIGH4 equ (0x00<<24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_DATA2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x00
DESC_VIDEO_HIGH4 equ (0x00<<24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_VIDEO2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x0B

;--------------选择子 属性-------------------
RPL0 equ 00b
RPL1 equ 01b
RPL2 equ 10b
RPL3 equ 11b
TI_GDT equ 000b
TI_LDT equ 100b

mbr

下面是熟悉的mbr 代码主要功能就是加载loader从硬盘到内存,注意我们的loader 变大,所以要加载4个扇区

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
;主引导程序
;-----------------------------------------------------
%include "boot.inc" ;包含boot.inc文件,里面有一些常用的函数
SECTION MBR vstart=0x7c00 ;告诉编译器,起始地址是0x7c00
mov ax,cs ;因为BIOS执行完毕后cs:ip为0x0:0x7c00,所以用cs初始化各寄存器(此时cs=0)
mov ds,ax ;ds、es、ss、fs不能给立即数初始化,需要用ax寄存器初始化
mov es,ax
mov ss,ax
mov fs,ax
mov sp,0x7c00 ;初始化堆栈指针,因为目前0x7c00以下的内存暂时可用
mov ax,0xb800 ;设置显存段地址
mov gs,ax

;清屏利用0x06号功能,上卷全部行,则可清屏
;-----------------------------------------------------
;INT 0x10 功能号:0x06 功能描述:上卷窗口
;-----------------------------------------------------
;输入:
;AH 功能号= 0x06
;AL = 上卷行数(如果为0,表示全部)
;BH = 上卷行属性
;(CL,CH) = 窗口左上角的(X,Y)位置
;(DL,DH) = 窗口右下角的(X,Y)位置
;无返回值:

mov ax,0x600 ;上卷行数:全部 功能号:06
mov bx,0x700 ;上卷属性
mov cx,0 ;左上角:(0,0)
mov dx,0x184f ;右下角:(80,25)
;VGA文本模式中,一行只能容纳80字节,共25行
;下标从0开始,所以0x18=24,0x4f=79
int 0x10 ;int 0x10


; 输出背景色绿色, 前景色红色, 并且跳动的字符串"1 MBR"

mov byte [gs:0x00], '1'
mov byte [gs:0x01], 0xA4 ;设置背景色为绿色闪烁,前景色为红色

mov byte [gs:0x02], ' '
mov byte [gs:0x03], 0xA4

mov byte [gs:0x04], 'M'
mov byte [gs:0x05], 0xA4

mov byte [gs:0x06], 'B'
mov byte [gs:0x07], 0xA4

mov byte [gs:0x08], 'R'
mov byte [gs:0x09], 0xA4

;--------------------------------------------------------------------------


mov eax ,LOADER_START_SECTOR ;起始扇区lba地址
mov bx ,LOADER_BASE_ADDR ;写入的地址
mov cx ,4 ;待读入的扇区数
call rd_disk_m_16 ;以下读取程序的起始部分

jmp LOADER_BASE_ADDR ;跳转到Loader

;--------------------------------------------------------------------------
;功能:读取eax=LBA扇区号
rd_disk_m_16:
mov esi ,eax ;备份eax
mov di ,cx ;备份cx

;读写硬盘
;1---设置要读取的扇区数
mov dx ,0x1f2 ;设置端口号,dx用来存储端口号的
mov al ,cl
out dx ,al ;读取的扇区数

mov eax ,esi ;恢复eax


;2---将LBA地址存入0x1f3~0x1f6
;LBA 7~0位写入端口0x1f3
mov dx ,0x1f3
out dx ,al

;LBA 15~8位写入端口0x1f4
mov cl ,8
shr eax ,cl ;逻辑右移8位,将eax的最低8位移掉,让最低8位al的值变成接下来8位
mov dx ,0x1f4
out dx ,al

;LBA 24~16位写入端口0x1f5
shr eax ,cl
mov dx ,0x1f5
out dx ,al

shr eax ,cl
and al ,0x0f ;设置lba 24~27位
or al ,0xe0 ;设置7~4位是1110表示LBA模式
mov dx ,0x1f6
out dx ,al

;3---向0x1f7端口写入读命令0x20
mov dx ,0x1f7
mov al ,0x20
out dx ,al

;4---检测硬盘状态
.not_ready:
;同写入命令端口,读取时标示硬盘状态,写入时是命令
nop
in al ,dx
and al ,0x88 ;第三位为1表示已经准备好了,第7位为1表示硬盘忙
cmp al ,0x08
jnz .not_ready

;5---0x1f0端口读取数据
mov ax ,di ;要读取的扇区数
mov dx ,256 ;一个扇区512字节,一次读取2字节,需要读取256次
mul dx ;结果放在ax里
mov cx ,ax ;要读取的次数

mov dx ,0x1f0
.go_on_read:
in ax, dx
mov [bx], ax ;bx是要读取到的内存地址
add bx, 0x02
loop .go_on_read ;循环cx次
ret

times 510-($-$$) db 0
db 0x55,0xaa

loader

最后是loader 部分,其实前面已经有去掉gdt的版本,其实就是按着步骤来进入保护模式,只是需要一些数据定义,一些地址线,cpu寄存器开关,打开这些模式,转换译码方法。
没什么是难的,只要慢慢来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
%include "boot.inc"
SECTION LOADER vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR

jmp loader_start

;构建 GDT 及其内部的描述符
GDT_BASE:
dd 0x00000000
dd 0x00000000

CODE_DESC:
dd 0x0000FFFF
dd DESC_CODE_HIGH4

DATA_STACK_DESC: ;直接用普通的数据段作为栈段
dd 0x0000FFFF
dd DESC_DATA_HIGH4

VIDEO_DESC:
dd 0x80000007 ;limit=(0xbffff - 0xb8000)/4k = 7
dd DESC_VIDEO_HIGH4;此时dpl为0

GDT_SIZE equ $ - GDT_BASE ;获取 GDT 大小
GDT_LIMIT equ GDT_SIZE - 1 ;获取 段界限

times 60 dq 0 ;预留60个空位,为以后填入中断描述符表和任务状态段TSS描述符留空间
;times 60 表示后面的内容循环60次,是nasm提供的伪指令

SELECTOR_CODE equ (0x0001 << 3) + TI_GDT + RPL0
SELECTOR_DATA equ (0x0002 << 3) + TI_GDT + RPL0
SELECTOR_VIDEO equ (0x0003 << 3) + TI_GDT + RPL0

;以下是 gdt 指针,前2字节是gdt界限,后4字节是gdt起始地址
gdt_ptr dw GDT_LIMIT
dd GDT_BASE

loadermsg db '2 loader in real.'

;---------------------------------------------------------
;INT 0x10 功能号:0x13 功能描述符:打印字符串
;---------------------------------------------------------
;输入:
;AH 子功能号=13H
;BH = 页码
;BL = 属性(若AL=00H或01H)
;CX = 字符串长度
;(DH,DL)=坐标(行,列)
;ES:BP=字符串地址
;AL=显示输出方式
;0——字符串中只含显示字符,其显示属性在BL中。显示后,光标位置不变
;1——字符串中只含显示字符,其显示属性在BL中。显示后,光标位置改变
;2——字符串中只含显示字符和显示属性。显示后,光标位置不变。
;3——字符串中只含显示字符和显示属性。显示后,光标位置改变。
;无返回值


loader_start:
;显示字符串,表示当前在实模式
mov sp, LOADER_BASE_ADDR
mov bp, loadermsg ;ES:BP 字符串地址
mov cx, 17 ;字符串长度
mov ax, 0x1301 ;AH=13h,AL=01h
mov bx, 0x001f ;页号为0(BH=0h),蓝底粉红字(BL=1fh)
mov dx, 0x1800 ;
int 0x10 ;int 10 BIOS中断

;准备进入保护模式
;1.打开A20地址线
in al, 0x92
or al, 00000010B
out 0x92, al

;2.加载GDT
lgdt [gdt_ptr]

;3.将CR0的PE位置1
mov eax, cr0
or eax, 0x00000001
mov cr0, eax

jmp dword SELECTOR_CODE:p_mode_start ;刷新流水线
;流水线是CPU 的工作方式,会把当前指令和后面的几个指令同时放在流水线中重叠执行,由于之前的代码是16位,接下来的代码变成32位了,指令按照16位进行译码会出错,通过刷新流水线可以解决这个问题

[bits 32] ;编译成32位程序
p_mode_start:
mov ax, SELECTOR_DATA
mov ds, ax
mov es, ax
mov ss, ax
mov esp, LOADER_STACK_TOP
mov ax, SELECTOR_VIDEO
mov gs, ax
mov byte [gs:160],'P'

jmp $

编译与写入磁盘

1
2
3
4
nasm -I include/ -o mbr.bin mbr.s
nasm -I include/ -o loader.bin loader.s
dd if=./mbr.bin of=/home/meiran/bochs/bin/hd60M.img bs=512 count=1 conv=notrunc
dd if=/home/meiran/Documents/OS/protect4/loader.bin of=/home/meiran/bochs/bin/hd60M.img bs=512 count=4 seek=2 conv=notrunc

实验结果展示

可以看到我们在mbr和loader里打印的字符都出现了
最后成果

从实模式到保护模式的内存布局

回顾我们之前讨论的1MB内存空间分布,现在我们来看看具体的内存布局图和模式切换的影响:

实模式下的1MB内存布局详解

地址范围 大小 用途 重要说明
0x00000-0x003FF 1KB 🔗 中断向量表 256个中断向量,每个4字节
0x00400-0x004FF 256B 📋 BIOS数据区 设备状态、配置信息
0x00500-0x07BFF ~30KB 💾 可用内存区域1 Loader加载区(0x00900)在此
0x07C00-0x07DFF 512B 🎯 MBR区域 BIOS加载主引导记录到此
0x07E00-0x9FBFF ~608KB 💾 可用内存区域2 可放置操作系统代码
0x9FC00-0x9FFFF 1KB 📋 扩展BIOS数据区 扩展设备信息
0xA0000-0xBFFFF 128KB 📺 显存区域 0xB8000开始为文本显存
0xC0000-0xEFFFF 192KB 🔧 ROM扩展区 显卡BIOS等
0xF0000-0xFFFFF 64KB 🔒 BIOS ROM 系统固件代码

保护模式后的内存访问变化

关键变化:Loader进入保护模式后,使用32位寄存器选择同样的内存部分执行代码

注意我们代码中的代码段描述符起始地址是0,段界限是4gb,也就是所有的内存都能放代码,同时当前实模式下loader 所在的内存片段,
在保护模式的重新映射下不会有偏移。所以我们刷新流水线后修改段寄存器,就能根据偏移flag无痛的继续执行保护模式代码,这与我们设置 vstart 0x900也有关系。

内存保护的实现

进入保护模式后,虽然物理内存布局不变,但访问控制完全改变:

  • 段限长检查: 防止越界访问
  • 特权级检查: Ring0-Ring3的访问控制
  • 读写权限: 代码段只读,数据段可读写
  • 类型检查: 严格区分代码段和数据段

这样,同样的物理内存0x900处的Loader代码,在保护模式下通过32位寄存器和GDT描述符进行更安全、更高效的访问