《操作系统真象还原》chapter1-5笔记与总结

First Post:

Last Update:


引题:操作系统是如何被启动的?

主板接电以后,内嵌在主板上的ROM中的BIOS会将 0盘0道1扇区 中的MBR(Main Boot Record)读取到内存中一个固定的位置,然后自动跳转到该位置(0x7c00),之后由MBR取代BIOS接管系统。此时,系统处于“实模式”,此时只能使用寄存器的低16位。

但MBR最大只能有一个扇区(512字节),可做的事情极其有限,因此MBR只从硬盘读取Loader到内存(读取位置也是约定好的),同时再跳转到Loader,由其取代MBR接管系统。

(至于读到哪里,实际上无所谓,只要最开始做好约定,让其能够跳转达到即可)

Loader则能够做到所有初始化工作。进入保护模式、加载内核、启动分页等工作。

MBR主引导记录:

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
; 主引导程序
;-----------------------------------------------
%include "boot.inc"
SECTION MBR vstart=0x7c00
;起始于0x7c00
;如下为初始段寄存器,cs在加载时会被置为代码段地址
;0xb800对应了显存,对该内存写就相当于将内容打印在显示屏上
mov ax, cs
mov ds, ax
mov es, ax
mov ss, ax
mov fs, ax
mov sp, 0x7c00
mov ax, 0xb800
mov gs, ax

; 清屏
;---------------------------------------------------
mov ax, 0600h
mov bx, 0700h
mov cx, 0
mov dx, 184fh
int 10h

; 显示"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], 'A'
mov byte [gs:0x09], 0xA4

mov eax, LOADER_START_SECTOR
mov bx, LOADER_BASE_ADDR

; 读取4个扇区
mov cx, 4
call rd_disk_m_16

; 直接跳到loader的起始代码执行
jmp LOADER_BASE_ADDR + 0x300

;-----------------------------------------------------------
; 读取磁盘的n个扇区,用于加载loader
; eax保存从硬盘读取到的数据的保存地址,ebx为起始扇区,cx为读取的扇区数
rd_disk_m_16:
;-----------------------------------------------------------

mov esi, eax
mov di, cx

mov dx, 0x1f2
mov al, cl
out dx, al

mov eax, esi

mov dx, 0x1f3
out dx, al

mov cl, 8
shr eax, cl
mov dx, 0x1f4
out dx, al

shr eax, cl
mov dx, 0x1f5
out dx, al

shr eax, cl
and al, 0x0f
or al, 0xe0
mov dx, 0x1f6
out dx, al

mov dx, 0x1f7
mov al, 0x20
out dx, al

.not_ready:
nop
in al, dx
and al, 0x88
cmp al, 0x08
jnz .not_ready

mov ax, di
mov dx, 256
mul dx
mov cx, ax
mov dx, 0x1f0

.go_on_read:
in ax, dx
mov [bx], ax
add bx, 2
loop .go_on_read
ret

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

Loader:

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
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
%include "boot.inc"

section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR

; 这里其实就是GDT的起始地址,第一个描述符为空
GDT_BASE: dd 0x00000000
dd 0x00000000

; 代码段描述符,一个dd为4字节,段描述符为8字节,上面为低4字节
CODE_DESC: dd 0x0000FFFF
dd DESC_CODE_HIGH4

; 栈段描述符,和数据段共用
DATA_STACK_DESC: dd 0x0000FFFF
dd DESC_DATA_HIGH4

; 显卡段,非平坦
VIDEO_DESC: dd 0x80000007
dd DESC_VIDEO_HIGH4

GDT_SIZE equ $ - GDT_BASE
GDT_LIMIT equ GDT_SIZE - 1
times 120 dd 0
SELECTOR_CODE equ (0x0001 << 3) + TI_GDT + RPL0
SELECTOR_DATA equ (0x0002 << 3) + TI_GDT + RPL0
SELECTOR_VIDEO equ (0x0003 << 3) + TI_GDT + RPL0

; 内存大小,单位字节,此处的内存地址是0xb00
total_memory_bytes dd 0

gdt_ptr dw GDT_LIMIT
dd GDT_BASE

ards_buf times 244 db 0
ards_nr dw 0

loader_start:

xor ebx, ebx
mov edx, 0x534d4150
mov di, ards_buf

.e820_mem_get_loop:
mov eax, 0x0000e820
mov ecx, 20
int 0x15

jc .e820_mem_get_failed

add di, cx
inc word [ards_nr]
cmp ebx, 0
jnz .e820_mem_get_loop

mov cx, [ards_nr]
mov ebx, ards_buf
xor edx, edx

.find_max_mem_area:
mov eax, [ebx]
add eax, [ebx + 8]
add ebx, 20
cmp edx, eax
jge .next_ards
mov edx, eax

.next_ards:
loop .find_max_mem_area
jmp .mem_get_ok

.e820_mem_get_failed:
mov byte [gs:0], 'f'
mov byte [gs:2], 'a'
mov byte [gs:4], 'i'
mov byte [gs:6], 'l'
mov byte [gs:8], 'e'
mov byte [gs:10], 'd'
; 内存检测失败,不再继续向下执行
jmp $

.mem_get_ok:
mov [total_memory_bytes], edx

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

; 加载gdt
lgdt [gdt_ptr]

; cr0第0位置1
mov eax, cr0
or eax, 0x00000001
mov cr0, eax

; 刷新流水线
jmp dword SELECTOR_CODE:p_mode_start

[bits 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

; 加载kernel
mov eax, KERNEL_START_SECTOR
mov ebx, KERNEL_BIN_BASE_ADDR
mov ecx, 200

call rd_disk_m_32

call setup_page

; 保存gdt表
sgdt [gdt_ptr]

; 重新设置gdt描述符, 使虚拟地址指向内核的第一个页表
mov ebx, [gdt_ptr + 2]
or dword [ebx + 0x18 + 4], 0xc0000000
add dword [gdt_ptr + 2], 0xc0000000

add esp, 0xc0000000

; 页目录基地址寄存器
mov eax, PAGE_DIR_TABLE_POS
mov cr3, eax

; 打开分页
mov eax, cr0
or eax, 0x80000000
mov cr0, eax

lgdt [gdt_ptr]

; 初始化kernel
jmp SELECTOR_CODE:enter_kernel

enter_kernel:
call kernel_init
mov esp, 0xc009f000
jmp KERNEL_ENTRY_POINT

jmp $

; 创建页目录以及页表
setup_page:
; 页目录表占据4KB空间,清零之
mov ecx, 4096
mov esi, 0
.clear_page_dir:
mov byte [PAGE_DIR_TABLE_POS + esi], 0
inc esi
loop .clear_page_dir

; 创建页目录表(PDE)
.create_pde:
mov eax, PAGE_DIR_TABLE_POS
; 0x1000为4KB,加上页目录表起始地址便是第一个页表的地址
add eax, 0x1000
mov ebx, eax

; 设置页目录项属性
or eax, PG_US_U PG_RW_W PG_P
; 设置第一个页目录项
mov [PAGE_DIR_TABLE_POS], eax
; 第768(内核空间的第一个)个页目录项,与第一个相同,这样第一个和768个都指向低端4MB空间
mov [PAGE_DIR_TABLE_POS + 0xc00], eax
; 最后一个表项指向自己,用于访问页目录本身
sub eax, 0x1000
mov [PAGE_DIR_TABLE_POS + 4092], eax

; 创建页表
mov ecx, 256
mov esi, 0
mov edx, PG_US_U PG_RW_W PG_P
.create_pte:
mov [ebx + esi * 4], edx
add edx, 4096
inc esi
loop .create_pte

; 创建内核的其它PDE
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x2000
or eax, PG_US_U PG_RW_W PG_P
mov ebx, PAGE_DIR_TABLE_POS
mov ecx, 254
mov esi, 769
.create_kernel_pde:
mov [ebx + esi * 4], eax
inc esi
add eax, 0x1000
loop .create_kernel_pde
ret

; 保护模式的硬盘读取函数
rd_disk_m_32:

mov esi, eax
mov di, cx

mov dx, 0x1f2
mov al, cl
out dx, al

mov eax, esi

mov dx, 0x1f3
out dx, al

mov cl, 8
shr eax, cl
mov dx, 0x1f4
out dx, al

shr eax, cl
mov dx, 0x1f5
out dx, al

shr eax, cl
and al, 0x0f
or al, 0xe0
mov dx, 0x1f6
out dx, al

mov dx, 0x1f7
mov al, 0x20
out dx, al

.not_ready:
nop
in al, dx
and al, 0x88
cmp al, 0x08
jnz .not_ready

mov ax, di
mov dx, 256
mul dx
mov cx, ax
mov dx, 0x1f0

.go_on_read:
in ax, dx
mov [bx], ax
add bx, 2
loop .go_on_read
ret

kernel_init:
xor eax, eax
xor ebx, ebx
xor ecx, ecx
xor edx, edx

mov dx, [KERNEL_BIN_BASE_ADDR + 42]
mov ebx, [KERNEL_BIN_BASE_ADDR + 28]

add ebx, KERNEL_BIN_BASE_ADDR
mov cx, [KERNEL_BIN_BASE_ADDR + 44]

.each_segment:
cmp byte [ebx], PT_NULL
je .PTNULL

; 准备mem_cpy参数
push dword [ebx + 16]
mov eax, [ebx + 4]
add eax, KERNEL_BIN_BASE_ADDR
push eax
push dword [ebx + 8]

call mem_cpy
add esp, 12

.PTNULL:
add ebx, edx
loop .each_segment
ret

mem_cpy:
cld
push ebp
mov ebp, esp
push ecx

mov edi, [ebp + 8]
mov esi, [ebp + 12]
mov ecx, [ebp + 16]
rep movsb

pop ecx
pop ebp
ret

实模式下的地址拓展于今日而言似乎并没有太大意义了,随着寄存器和总线位数拓宽,不再需要像DOS时代那样仅使用1MB的内存了,因此这里不做记录,只需要记住其寻址最大值是0xffff:0xffff(0x10ffef)即可。

但内存的寻址方式和进入保护模式以后的段寄存器的用途却十分耐人寻味,一言蔽之即为“描述符–>>内存”

GDT(Global Descriptor Table):全局描述符表(段描述符表)

该表用于储存一系列逻辑门、内存段的地址。

其第一项默认留空,称之为哑描述符。之所以这样规定,似是为了防止在未初始化选择子时违规访问到该描述符,于是索性就对其留空,让无意的访问直接错误。

而专门有一个寄存器GDT Register(48bit)用于加载该表的地址,使用R0专用的指令 lgdt 加载,通过 sgdt 保存。(我有点怀疑,之所以只有48bit是因为Intel芯片只有48根总线,支持内存只有2^48 BYTE,不过目前64位系统中,这个寄存器又达到79bit了,但目前没有确信)

其结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
// base: 基址
// limit: 寻址最大范围 tells the maximum addressable unit
// flags: 标志位
// access: 访问权限
struct gdt_entry {
uint16_t limit_low;
uint16_t base_low;
uint8_t base_middle;
uint8_t access;
unsigned limit_high: 4;
unsigned flags: 4;
uint8_t base_high;
} __attribute__((packed));

除此之外,我暂时不对GDT做过多深入

A20(A20GATE):特殊总线的控制端口

实则对应第21根总线的控制端口。在80286时代,被使用的总线为0~19,但内存只有1M-0x100000,大于该部分是地址会被回绕。但打通A20(第21根总线)之后,硬件就知道应该拓展地址了,而不应该继续回绕。对应到32位的现代芯片,当A20被开启(置1)以后,处理器将不再把16位以上的地址回绕。(体现为:将0x92端口低位置1)

1
2
3
in al, 0x92
or al, 00000010B
out 0x92, al

CR0 Register:

处理器控制位图。不过多深究,仅记录PE(Protection Enable):置1则标识进入保护模式。之后,CPU将以4字节为单位读取指令。

分页机制:

分页总的能够概况成一句话:“32位地址能够表示2^32空间”

似乎没什么特殊的,但当时读完整章之后,我最大的感想就是这句。

保护模式下,可寻址范围扩大到 4G ,共32位,但出于安全考虑,我们不应该让内存能够被 “平坦地访问” 。所谓平坦,指的是整个内存的地址空间连续,从0~4G可以直接通过地址的加减来访问对应内存;但只要系统会被用户使用,就应该避免内核数据能够被用户直接读写。显然,“平坦模式”下(也就是从加电直到分页之前),我们没办法直接实现这个功能。

因此需要引入“内存分段(页)”,对于权限低的人,只允许他访问限定好的页,而对于最高权限的内核,则允许它访问整个内存。对于“操作系统占用内存高地址的1GB,用户占用低地址3GB”的设想也是因此得以实现的。可以看出,这个1GB和3GB已经指的是“虚拟地址”了,但这里的虚拟地址又和每个进程都有的“虚拟地址空间”不是同一个东西,后者是进程独立的,而前者则属于操作系统自身。

概念如此,具体表现在:对32位地址的分割

首先,按照每页4K来划分内存,4G/4K=2^20,意味着如果对每一页都使用一个索引去表示,需要 1MB * 4=4MB 的内存。但这个肯定是不允许的,因为占用实在太大了,因此我们可以再做一份二级页表(此处称之为“页目录表”(PDE:Page Directory Entry)),该表也按照4K分页,则4MB/4K=2^10,则页目录表只占用 1KB * 4=4KB 大小(1024条目)。

假设现在,我们容许为此耗费4KB,那么就不需要再继续分页了,过度的分页对导致效率降低。PDE的目的是为了索引页表,共1024个条目(Entry),每个PDE的条目都会指向一个页表,而一张页表对应1K * 4KB = 4MB 内存。

因此,只需要划出页目录表的后256个条目供内核使用,就能够界定这1GB空间,而只要禁止用户去访问这部分页目录,那么用户程序自然就没办法直接访问内核数据了。

显然的是,索引1024个条目不需要32位,10位足够了,所以我们完全可以留出一些内容提供额外的信息(上文所述的属性),比如访问权限等(但这是后话,并不是本章的重点,以下仅给出结构而不详细说明)。(另外一个事实是,如果您读到这里都没觉得奇怪,就说明您已经接受了一个条目占用32位的事实了,不过事实确实如此,也说明这并没有反直觉)

1
2
3
4
5
6
7
8
9
10
11
12
mov eax, PAGE_DIR_TABLE_POS
; 0x1000为4KB,加上页目录表起始地址便是第一个页表的地址
add eax, 0x1000
mov ebx, eax
or eax, PG_US_U PG_RW_W PG_P
; 设置第一个页目录项
mov [PAGE_DIR_TABLE_POS], eax
; 第768(内核空间的第一个)个页目录项,与第一个相同,这样第一个和768个都指向低端4MB空间
mov [PAGE_DIR_TABLE_POS + 0xc00], eax
; 最后一个表项指向自己,用于访问页目录本身
sub eax, 0x1000
mov [PAGE_DIR_TABLE_POS + 4092], eax

第768(内核空间的第一个)个页目录项之所以要和第一项相同,是为了保证分页之后的Loader程序的虚拟地址和物理地址一致。因为Loader也算是内核程序,应该保证它在虚拟地址的高1GB内,所以要把自己这一页放到768项(0xc000000对应的就是768页目录所标识的页表)

在设置完内核页表以后,调整esp到虚拟地址空间,修改GDT指向虚拟地址的对应位置,将页目录表的地址存入cr3 Register(该寄存器专门用于此功能),置cr0 Register的PG位为1。加载 GDT,从此开启了内核的分页,往后的地址将全都使用虚拟地址替代物理地址。

显然,当下的“虚拟地址”是由硬件和操作系统共同完成的,它不同于多进程下每个进程独立的“虚拟地址空间”,后者理应是由操作系统单独提供的能力(暂时还没看到后面,但就目前我个人猜测,认为理应如此)。

然后只需要从硬盘读取内核到0xc0000000,然后跳转到_start函数即可由内核接管以后的工作。且因为自此之后,MBR,Loader等均不再工作,直接在内存中覆盖掉也完全无妨。另外,开启虚拟地址功能以后,所有的思考都应该直接通过虚拟地址完成,不再需要进行物理地址的计算和转换,因为处理器会完成这一切。

此时的内核已经加载到内存,接下来根据ELF的文件头来获取相关信息以后,将内核按照节区(Section)划分复制到虚拟地址对应的位置后直接用长跳转刷新流水线后达到内核的第一条指令。

如上内容的流程图:

特权级:

首先需要涉及TSS(Task State Segment)结构:

这是一个针对任务的结构体,每个任务都会拥有一个TSS结构体(这个“任务”将在以后成为进程)。

SS代表栈段寄存器,用以储存不同等级下的栈基址,分别有R0,R1,R2这三个等级;至于R3,因为R3向高权限区域访问时会将自己的SS入栈;而高权限区域从来不需要向R3“主动跳转”,因此不需要SS3(“主动”指的是类似中断、call等,ret等指令我称之为被动跳转)。

不过就要提到上述的GDT以及下文涉及的门描述符了。其结构如下图。

type值

存在位置

用法

任务门

0101

GDT、LDT、IDT

与TSS配合实现任务切换,不过大多数操作系统都不这么玩

中断门

1110

IDT

进入中断后屏蔽中断(eflags的IF位置0),linux利用此实现系统调用,int 0x80

陷阱门

1111

IDT

进入中断后不屏蔽中断

调用门

1100

GDT、LDT

用户用call或jmp指令从用户进程进入0特权级

GDT(Global Descriptor Table)除了一般的段描述符外,还储存各类门描述符。

(但也可能从LDT(局部描述符)中寻找,但原理是一样的)

一个门描述符中包含了对应的例程选择子和例程偏移量,像该门跳转的过程同一般的jmp相近,但特别的是,需要经过处理器的权限检查。

必须明确的是:

描述符 (Descriptor):用以描述一个段的属性

选择子(Selector):用以访问内存

因此描述符规定了访问该内存所需的权限,而选择子都表明了访问者拥有的权限。

每个描述符中的DPL(Descriptor Privilege Level)标识该描述符所拥有的权级,03对应R0R3的权限。接下来分为两个情况:

请求数据:

处理器对比当前CPL(Current Privilege Level)和目标段选择子中的DPL(Descriptor Privilege Level),若CPL<=DPL,则允许访问。

因此对于R3下的程序,如果尝试读取内核数据,就会因为CPL大于DPL而被阻止。

CPL即为当前CS和SS寄存器选择子中的RPL(Request Privilege Level),意味请求特权级。

  • 检查时机:特权级检查会发生在往 数据段寄存器 中加载 段选择子 的时候,数据段寄存器包括 DS 和附加段寄存器 ES、FS、GS,如

mov ds,ax

  • 检查条件CPL <= 目标数据段DPL && RPL <= 目标数据段DPL (只能高特权级的指令访问地特权级的数据)

跳转执行:

倘若程序企图直接从R3跳转到R0权限执行,就需要通过门进行了。但情况还是要分为两种,跳转目标是/非一致代码段。

call 内核选择子

  • 检查条件
    • 无门结构且目标为非一致代码段:CPL = RPL = 目标代码段DPL
    • 无门结构且目标为一致代码段:CPL >= 目标数据段DPL && RPL >= 目标数据段DPL
    • 有门结构:DPL_GATE >= CPL >= DPL_CODE && RPL <= DPL_GATE(从低特权级跳到高特权级需要通过门)

转移前的栈结构如下:

同时,当SS切换到高权级的时,会自动将这些内容复制到新的栈中。最后会通过iret或retf指令返回到R3

RPL意味着“请求者”的权限等级,而非“承包商”的。

思考这样一个情况:

  1. 用户程序发出读取硬盘调用请求,操作系统接收,进入内核(CPL=0/RPL=3)
  2. 操作系统执行调用,将数据写入缓冲区(CPL=0/RPL=3)

倘若缓冲区位于R3段,那么DPL=3,能够正常写;但倘若缓冲区位于R0,那么DPL=0,应该被阻止。RPL相当于发出调用请求的用户程序,而CPL则相当于执行请求的“承包商”,不能因为“当前权限允许就去执行”,还需要判断“发起人是否有足够的权力这样做”。

至于RPL是在什么时候被写换的,内核态时,RPL为什么不会是0?

首先,Selector是由操作系统提供的,在提供该Selector时会将RPL改为用户的CPL,因此用户手中的Selector对应的RPL必定会是3。

然后,CPL是在用户程序加载时由操作系统设定的,并且操作系统规定,处于R3的权限下不能将CS段中的低两位降低,所以用户的CPL必定为3,且不由用户控制。

最后,在用户需要提交自己的选择子时,不论用户能否伪造它,只要用户无法伪造CS 寄存器,它就无法修改自己提交的选择子。因为只要用户提交选择子,操作系统就会用CPL来替换选择子中的RPL,而该选择子的RPL意味着请求以后的RPL。

这两个耦合的键值加上操作系统的强权,硬是把权限限死了……

另外,当调用门完成调用之后,需要从R0切换回R3,只需要把栈中的数据恢复即可。但值得注意的是,DS、ES、FS、GS等寄存器的值如果不属于返回目标对应的DPL区域,会直接被置0,以防止数据泄露。在之后的运行过程中,如果需要调用该寄存器,就会触发处理器异常,然后调用到对应的处理函数去。

至于本章最后的I/O特权级,我个人认为作为了解性知识即可,便不再过多赘述了。

.

.

.

插画ID:93302401