操作系统之进入内核前的准备

本来是想着入门内核的,然后看着看着就看到了这篇文章,觉得学到很多,反正以后操作系统也要接触,就当初探了一下,写个学习笔记记录一下学到的东西。(果然只有自己再总结加工才能真正吸收xs)

开机会发生什么?

当按下开机键后究竟发生了什么呢?

网上搜出来大体是这样:

1
BIOS 按照“启动顺序”,把控制权转交给排在第一位的存储设备:硬盘。然后在硬盘里寻找主引导记录的分区,这个分区告诉电脑操作系统在哪里,并把操作系统被加载到内存中,然后你就能看到经典的启动界面了,这个开机过程也就完成了。

现在就来具体讲讲(也没有很具体)整个过程:

先放一张实模式下的内存分布图:(不用管实模式是什么)

未命名图片

可以简单理解为计算机刚开机的时候就只有 1M 的内存可用,可以看到 BIOS 被映射到了 0xC0000 - 0xFFFFF 的位置。

众所周知,CPU 会把内存中的指令放入 PC 寄存器 并执行。BIOS 程序的入口地址规定是 0xFFFF0,开机的一瞬间,CPU 的 PC 寄存器会被强制初始化为 0xFFFF0;说得再具体一点就是:CPU 将 段寄存器CS 初始化为 0xF000,将偏移地址寄存器 IP 初始化为 0xFFF0,再根据最终地址的计算规则,将段寄存器左移 4 位,再加上偏移地址,得到的最终的物理地址就是 0xFFFF0。这也就是为啥开机最先执行的是 BIOS 程序了。

上面说了 BIOS 被映射到了 0xC0000 - 0xFFFFF 的位置,而开机 CPU 跳转的地方是 0xFFFF0,只剩下 16 个字节可以写,这有啥用呢?很显然根本干不了啥,所以 0xFFFF0 这个入口地址处存的是个跳转指令,跳到一个更大范围的空间去执行代码。

0xFFFF0 处存储的机器指令,翻译成汇编语言就是:

1
jmp far f000:e05b

意思就是跳转到 0xfe05b 处执行。

也就是说 0xfe05b 的代码,才是 BIOS 程序真正要执行的代码,这一段代码会执行很多事情,比如检测/初始化……但最重要的还是最后执行的加载启动区。

1
2
3
这里浅说一下 BIOS 是如何找到启动区的:

它会按顺序读取启动盘(磁盘)位于 `0盘0道1扁区` 的内容,并判断末尾的两个字节是否分别是 `0x55` 和 `0xaa`,如果是就将其判定为启动区,如果末尾不是这两个字节,就会继续向下寻找,找不到报错。

加载的意思就是把设备程序复制到内存的进程中

在这里就是找到启动区之后 BIOS 就会把启动区的内容复制到内存中的 0x7c00(开发团队定的) 处,指令就会在这执行。

启动区 512 字节的代码示例:

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
; hello-os
; TAB=4

ORG 0x7c00 ;程序加载到内存的 0x7c00 这个位置

;程序主体

entry:
MOV AX,0 ;初始化寄存器
MOV SS,AX
MOV SP,0x7c00
MOV DS,AX ;段寄存器初始化为 0
MOV ES,AX
MOV SI,msg
putloop:
MOV AL,[SI]
ADD SI,1
CMP AL,0 ;如果遇到 0 结尾的,就跳出循环不再打印新字符
JE fin
MOV AH,0x0e ;指定文字
MOV BX,15 ;指定颜色
INT 0x10 ;调用 BIOS 显示字符函数
JMP putloop
fin:
HLT
JMP fin
msg:
DB 0x0a,0x0a ;换行、换行
DB "hello-os"
DB 0x0a ;换行
DB 0 ;0 结尾

RESB 0x7dfe-$ ;填充0到512字节
DB 0x55, 0xaa ;可启动设备标识

附上这个流程的简图:

CE781E6560EB019D97000D6CDC4F1EC4


以 Linux-0.11 为例

BIOS 将启动区复制到内存0x7c00

开机 BIOS 会将硬盘中启动区的 512 字节的数据,复制到内存 0x7c00 的位置,并跳转到该处执行。

用图表示就是:

未命名图片

最开始的代码是用汇编语言写的 bootsect.s,,位于 boot 文件夹下,存放在启动区中,也就是我们复制到 内存 0x7c00 处地址的内容。

所以就从这开始分析

1
2
mov ax,0x07c0
mov ds,ax

首先会将 ds 寄存器赋值为 0x07c0 ,众所周知,ds 寄存器是数据段寄存器,存储着数据段的起始地址。之后的地址都会以它为标准进行偏移,也方便内存利用这个地址进行寻址。

但是为啥不是 0x7c00 呢?是因为地址的寻址方式,使得段地址要先左移四位。


再挪个位置:内存0x90000

接着往下看

1
2
3
4
5
6
mov ax,0x9000
mov es,ax
mov cx,#256
sub si,si
sub di,di
rep movw

把 es(额外段寄存器) 赋值成 0x9000,cx 寄存器变成 十进制的 256(代码里是用十进制表示的,和其他地方有些不一样),si、di 为 0。

整合一下所有的寄存器就是:

1
2
3
4
ds = 0x07c0
es = 0x9000
cx = 256
di = si =0

然后执行最后一句 ‘rep movw’

rep 表示重复执行后面的指令,movw 表示复制一个字,所有整句话的意思就是 重复复制一个字

那么问题来了:

复制多少次?因为 cx 记录次数,所以会复制 256 次。

从哪复制到哪呢?是从 ds:si 复制到 es:di

一次复制多少呢? 刚刚有说过复制一个字,也就是两个字节(一个字不一定是两个字节,只是这个例子是这样)

所以整段话用更通俗的语言描述出来就是:将内存地址 0x7c00 处开始往后的 512 字节的数据复制到 0x90000 处

所以经过这么一折腾,操作系统最开始的代码,又被挪到了另一个地方:0x90000。


内存的初步规划

接着往下看

1
2
3
4
jmpi go,0x9000
go:
mov ax,cs
mov ds,ax

jmpi 是一个段间跳转指令,表示跳转到 0x9000:go 处执行,根据寻址方式可以知道,go 是一个偏移地址,所以这句指令意思就是跳到 0x90000+go 处地址执行

也就是后面的

1
2
3
4
5
go: mov ax,cs
mov ds,ax
mov es,ax
mov ss,ax
mov sp,#0xFF00

全是赋值操作,把 cs 寄存器的值分别复制给 ds、es、ss 寄存器,然后把 0xff00 给了sp 寄存器。

CS 寄存器表示代码段寄存器,是 CPU 当前正在执行的代码在内存中的位置,由 CS:IP 这组寄存器配合指向的,CS 是基址,IP 是偏移。

所以之前执行 jmpi go,0x9000 后,CS 就被赋值为了 0x9000,之后的 mov 操作就把 ds、es、ss 这些寄存器都赋值为了 0x9000。

ds为数据段寄存器,之前代码在 0x7c00 处,所以它被赋值为了 0x07c0,现在代码在 0x90000 处,所以自然就被赋值成了 0x9000。

ss 是栈段寄存器,会配合栈基址寄存器 sp 来表示栈顶,此时 sp 被赋值为了 0xFF00,所以现在的栈顶地址是 ss:sp 所指向的 0x9FF00。这么设置是因为代码段在 0x90000,栈顶离代码段是很远的,所以栈向下发展就很难和代码段撞上。

总结一下就是,利用跳转指令跳到空间更大的地方,给一些寄存器赋值,说白了就是做了一个内存的初步规划,通过设置基址的方法访问代码和数据,设置栈顶指针的方法访问栈。


将整个操作系统代码搬到内存中

接着往下看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
load_setup:
mov dx,#0x0000 ; drive 0, head 0
mov cx,#0x0002 ; sector 2, track 0
mov bx,#0x0200 ; address = 512, in 0x9000
mov ax,#0x0200+4 ; service 2, nr of sectors
int 0x13 ; read it
jnc ok_load_setup ; ok - continue
mov dx,#0x0000
mov ax,#0x0000 ; reset the diskette
int 0x13
jmp load_setup

ok_load_setup:
...

int 0x13 表示发起 0x13 号中断,上面被赋值的寄存器都作为它的参数。这句指令表示读取磁盘

整句代码的意思就是将硬盘的第 2 个扇区开始,把数据加载到内存 0x90200 处,共加载 4 个扇区。

大概长这样:

未命名图片

可以看到程序有个判断,意思就是如果读取成功,就会跳到 ok_load_setup ,如果失败就会重试。

1
2
3
4
5
6
7
ok_load_setup:
...
mov ax,#0x1000
mov es,ax ; segment of 0x10000
call read_it
...
jmpi 0,SETUPSEG ;SETUPSEG = 0x9020

只看重要部分,这一段代码的意思就是把从硬盘第 6 个扇区开始往后的 240 个扇区,加载到内存 0x10000 处。

在这之后,整个操作系统的代码,就全部从磁盘搬到内存中了。

未命名图片

之后再通过 jmpi 0,0x9020 ,跳到 0x90200 处,也就是硬盘里第二个扇区的开始。

1
2
3
4
再补充一下,操作系统的编译过程,整个过程是通过 Makefile 和  build.c 配合完成的
1. 把 bootsect.s 编译成 bootsect 放在硬盘的 1 扇区
2. 把 setup.s 编译成 setup 放在硬盘的 2~5 扇区。
3.把剩下的全部代码(head.s 作为开头)编译成 system 放在硬盘的随后 240 个扇区。

又一次做内存调整

所以现在就会执行第二扇区的开始内容了,也就是 setup.s 文件。先看看 setup.s 的开头

1
2
3
4
5
6
7
start:
mov ax,#0x9000 ; this is done in bootsect already, but...
mov ds,ax
mov ah,#0x03 ; read cursor pos
xor bh,bh
int 0x10 ; save it in known place, con_init fetches
mov [0],dx ; it from 0x90000.

INT 0x10 也是一个中断指令,用来触发屏幕及显示器的服务程序。ah 寄存器被赋为 0x03 表示读光标位置。

当这个程序执行完毕并返回时,dx 寄存器的值表示光标的位置,其中 dh 存储行号,dl 存储列号。

mov [0],dx 表示把光标的位置存储到偏移为 0 的地方,也就是 0x90000。

接下来的代码也和之前的套路差不多,一一对应着看就行。(参考链接:https://zh.wikipedia.org/zh-cn/BIOS%E4%B8%AD%E6%96%B7%E5%91%BC%E5%8F%AB)

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
比如获取内存信息。
; Get memory size (extended mem, kB)
mov ah,#0x88
int 0x15
mov [2],ax
获取显卡显示模式。
; Get video-card data:
mov ah,#0x0f
int 0x10
mov [4],bx ; bh = display page
mov [6],ax ; al = video mode, ah = window width
检查显示方式并取参数
; check for EGA/VGA and some config parameters
mov ah,#0x12
mov bl,#0x10
int 0x10
mov [8],ax
mov [10],bx
mov [12],cx
获取第一块硬盘的信息。
; Get hd0 data
mov ax,#0x0000
mov ds,ax
lds si,[4*0x41]
mov ax,#INITSEG
mov es,ax
mov di,#0x0080
mov cx,#0x10
rep
movsb
获取第二块硬盘的信息。
; Get hd1 data
mov ax,#0x0000
mov ds,ax
lds si,[4*0x46]
mov ax,#INITSEG
mov es,ax
mov di,#0x0090
mov cx,#0x10
rep
movsb

经过这一连串的代码后,各地址存储的内容如下:

之前说过 0x90000 往后的 512 字节被复制成了 bootsect,所以这个地方相当于覆盖掉了一些 bootsect 的内容。

未命名图片

之后在分析用 C语言 编写的操作系统时,相应的变量会在上面给出的对应地址中取。

存储好信息后,接着往下看

1
cli         ; no interrupts allowed ;

cli 是关闭中断的意思

继续看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
; first we move the system to it's rightful place
mov ax,#0x0000
cld ; 'direction'=0, movs moves forward
do_move:
mov es,ax ; destination segment
add ax,#0x1000
cmp ax,#0x9000
jz end_move
mov ds,ax ; source segment
sub di,di
sub si,si
mov cx,#0x8000
rep movsw
jmp do_move
; then we load the segment descriptors
end_move:
...

结合之前分析的,这段代码的意思就是把内存 0x10000 到 0x90000 的内容复制到 0x00000 处。

未命名图片

也就是 0x00000 到 0x80000 的内容被复制成了 system,包括之前的 0x7c00 处的 bootsect 也被覆盖了;system 可以理解为操作系统的全部,是除 bootsect 和 setup 之外全部程序链接在一起的结果。

那么现在的内存布局就是这样:

未命名图片

总结一下前面,操作系统又给自己移了下位置:从 0 开始存放着操作系统的所有代码,即 system,0x90000 之后的几十个字节存放了一些设备信息。


模式转换

接着会进行一项大工程,模式转换,即把现在 16 位的实模式转换成 32 位的保护模式。先别急着纠结什么是实模式和保护模式,等会就说。

1
2
lidt  idt_48      ; load idt with 0,0
lgdt gdt_48 ; load gdt with whatever appropriate

要读懂这些指令,得先清楚实模式和保护模式寻址的区别:

实模式:物理地址=段基址左移四位+偏移地址。

保护模式:段寄存器(比如 ds、ss、cs)里存储的是段选择子,段选择子去全局描述符表 gdt 中寻找段描述符,从中取出段基址。

整个过程如下:

640 (2)

取出段基址的前提是 CPU 要知道存段基址的地方在哪,所以操作系统会把这个位置存储在一个叫 gdtr 的寄存器中。

未命名图片

lgdt gdt_48 就是用来干这事的。

lgdt 指令就是把后面的 gdt_48 存入 gdtr 寄存器中。

再具体看看 gdt_48 长什么样:

1
2
.word	0x800		! gdt limit=2048, 256 GDT entries
.word 512+gdt,0x9 ! gdt base = 0X9xxxx

看标签可以看出它是个 48 位的数据,其中高 32 位存储着 gdt 的内存地址:0x90200+gdt

指令中的 gdt 代表处在当下文件的偏移量,而文件又是 setup.s,所以基址就是 0x90200,所以 全局描述符表 gdt 所在的地址就是 0x90200+gdt。

gdt 里存储了一系列段描述符:

1
2
3
4
5
6
7
8
9
10
11
12
gdt:
.word 0,0,0,0 ! dummy

.word 0x07FF ! 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ! base address=0
.word 0x9A00 ! code read/exec
.word 0x00C0 ! granularity=4096, 386

.word 0x07FF ! 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ! base address=0
.word 0x9200 ! data read/write
.word 0x00C0 ! granularity=4096, 386

看一下全局描述符表的构成:

未命名图片

可以看到目前有三个段描述符,第一个为空,第二个是代码段描述符,第三个是数据段描述符。

第二个和第三个段描述符的段基址都是 0 ,也就是说通过段选择子找到的无论是代码段还是数据段,取出的段地址都是 0 ,所以物理地址就是直接给出的偏移地址。

现在再来讲讲 lidt idt_48 是干嘛的

1
2
3
idt_48:
.word 0 ! idt limit=0
.word 0,0 ! idt base=0L

功能和 lgdt gdt_48 差不多

之前有说过 gdtr 寄存器存储着全局描述符表gdt的地址

idtr 寄存器存储的是中断描述符表idt的地址,当发生中断时,CPU 就会通过中断号去中断描述符表中找到中断处理程序的地址,然后跳过去执行。

总结一下前面,利用两个指令将全局描述符表和中断描述符表地址放入相应的寄存器中,也讲了描述表的地址要怎么看。

现在内存结构:

640


正式开启保护模式,跳至system处执行

上面说的只是进入保护模式前准备工作的一部分,接着往下看。

1
2
3
4
mov al,#0xD1        ; command write
out #0x64,al
mov al,#0xDF ; A20 on
out #0x60,al

这段代码的意思是:打开 A20 地址线

我们知道的是 8086 的 CPU 地址总线是 20 位,所以如果程序给出 21 位的内存地址,多出来的一位就会被忽略,举个例子就是:1 0000 00000000 00000000

那个 1 因为处在第 21 位,所以会被忽略,整个内存地址算下来就是 0,如今 CPU 已经有 32位 和 64位,但是因为兼容性,还是要保持只能用 20 位地址线的模式,所以打开 A20 地址线就相当于让地址总线突破 20 位变为 32位。

接着往下就是一大坨代码,不需要特别了解,就是对可编程中断控制器 8259 芯片进行的编程,然后引脚和中断号关系对应如下:

未命名图片

然后才到真正切换模式的时候:

1
2
3
mov ax,#0x0001  ; protected mode (PE) bit
lmsw ax ; This is it;
jmpi 0,8 ; jmp offset 0 of segment 8 (cs)

前两行把 cr0 这个寄存器位 0 置 1,就会切到保护模式

后一行 jmpi 0,8,8 表示 cs 中的值,0 表示偏移地址。因为现在是保护模式,所以 cs 里存的是段选择子。8 用二进制表示就是:00000,0000,0000,1000

未命名图片

对应着表的结构图可以发现,描述符索引就是1,再根据这个索引去全局描述符表里找段描述符,从而取出地址。

之前有说过,全局描述符表中0对应的空,1对应的代码段描述符(可读可执行),2对应的数据段描述符(可读可写),所以这里对应的就是代码段描述符,段基址都为0。又因为偏移也是0,所以整个地址就是0。也就是跳到内存地址为0处。

内存地址为 0 处之前也说过是system,system 可以理解为操作系统的全部,是除 bootsect 和 setup 之外全部程序链接在一起的结果。现在 bootsect 和 setup 两个文件都分析过了,所以盲猜 head.s 就是 system 的一部分,我们接下来就分析它。

总结一下之前的,开启了保护模式,并跳转到了 system 处执行。


再次设置 idt 和 gdt

1
2
3
4
5
6
7
8
9
_pg_dir:
.globl startup_32
_startup_32:
mov eax,0x10
mov ds,ax
mov es,ax
mov fs,ax
mov gs,ax
lss esp,_stack_start

_pg_dir 表示页目录,之后设置分页机制,页目录会存放在这。

之后就是把 ds、es、fs、gs 段寄存器赋值为 0x10,0x10对应的二进制数是10000,对应全局描述符表里的数据段描述符。(描述符索引里是 10,转换成十进制就是 2 )

lss 指令表示把 esp 指向 _stack_start(之前在 0x9FF00)


这里补充一下 _stack_start,它被定义在 sched.c 里

1
2
3
4
5
6
7
8
long user_stack[4096 >> 2];

struct
{
long *a;
short b;
}
stack_start = {&user_stack[4096 >> 2], 0x10};

stack_start结构中高位 8 字节是 0x10,会赋值给 ss 栈段寄存器,低位 16 字节是 user_stack 这个数组的最后一个元素的地址值,会赋值给 esp 寄存器。又因为 0x10 对应全局描述符表里的数据段描述符,基址为 0。所以整个栈顶的地址,就是 esp 里存的地址。


继续往下看

1
2
3
4
5
6
7
8
call setup_idt ;设置中断描述符表
call setup_gdt ;设置全局描述符表
mov eax,10h
mov ds,ax
mov es,ax
mov fs,ax
mov gs,ax
lss esp,_stack_start

设置了 idt 和 gdt ,然后又重新执行了一次之前的操作

重新再执行一次的原因是因为上面修改了 gdt

先来看看 setup_idt ,即设置 idt 的具体代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
setup_idt:
lea edx,ignore_int
mov eax,00080000h
mov ax,dx
mov dx,8E00h
lea edi,_idt
mov ecx,256
rp_sidt:
mov [edi],eax
mov [edi+4],edx
add edi,8
dec ecx
jne rp_sidt
lidt fword ptr idt_descr
ret

idt_descr:
dw 256*8-1
dd _idt

_idt:
DQ 256 dup(0)

这段代码的作用就是,设置了 256 个中断描述符,每个中断描述符都指向 ignore_int 的函数地址,ignore_int 是一个默认的中断处理程序,之后会被具体的中断程序覆盖。就比如你现在敲键盘是没什么反应的,因为键盘模块的中断程序还没覆盖掉它,任何中断都是调用 ignore_int 程序。

setup_gdt 也是同理就不多说了,直接看设置好了的结果吧

1
2
3
4
5
6
_gdt:
DQ 0000000000000000h ;/* NULL descriptor */
DQ 00c09a0000000fffh ;/* 16Mb */
DQ 00c0920000000fffh ;/* 16Mb */
DQ 0000000000000000h ;/* TEMPORARY - don't use */
DQ 252 dup(0)

其实和之前设置的一样,也还是有代码段描述符和数据段描述符这些。

为什么之前设置过了现在又要设置一遍呢?

是因为之前设置的 gdt 所处的 setup 程序中,之后会被缓冲区覆盖,所以要重新给它挪个位置,挪到head。然后结果就变成了这样:

640 (1)

总结一下之前说的就是,把 idt 和 gdt 移位,其中 idt 方面给每个中断设置了一个默认中断程序 ignore_int,gdt 方面没有太多变化。


分页

1
2
3
4
5
6
7
8
9
10
11
jmp after_page_tables
...
after_page_tables:
push 0
push 0
push 0
push L6
push _main
jmp setup_paging
L6:
jmp L6

这就是开启分页机制,并且跳转到 main 函数。

先来看看什么叫分页机制。

还记不记得,在保护模式下,我们在代码中给出一个内存地址,要经过分段机制的转换,才变成最终物理地址。

640 (2)

开了分页机制后,就多了一步转换

640 (3)

也就是说,在没有开启分页机制时,程序员给出的逻辑地址,通过分段机制转换成物理地址;

开启分页机制后,逻辑地址仍然要通过分段机制进行转化,但是这个时候得到的是线性地址,再通过一次分页机制转换,才得到最终的物理地址。

分页地址是如何转换的呢?

比如给出一个线性地址 15M ,二进制表示就是 0000000011_0100000000_000000000000

整个过程:(个人感觉应该是 13 M)

640 (4)

也就是说,线性地址会被拆成 高 10 位:中间 10 位:后 12 位

高 10 位负责在页目录表中找到一个页目录项,中间 10 位再去该页目录项中找到相应的页表项,这个页表项的值,再加上后 12 位的偏移地址,得到最终的物理地址。(由内存管理单元MMU负责)

浅看一下页目录项和页表项的结构:

640 (5)

当我们开启分页机制的开关,只需要更改 cr0 寄存器的一位即可,和之前开保护模式差不多

640

所以之前的那段代码,就是帮我们把页表和页目录存在内存中,然后开启 cr0 寄存器的分页开关。再粘出来看看。

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
setup_paging:
mov ecx,1024*5
xor eax,eax
xor edi,edi
pushf
cld
rep stosd
mov eax,_pg_dir
mov [eax],pg0+7
mov [eax+4],pg1+7
mov [eax+8],pg2+7
mov [eax+12],pg3+7
mov edi,pg3+4092
mov eax,00fff007h
std
L3: stosd
sub eax,00001000h
jge L3
popf
xor eax,eax
mov cr3,eax
mov eax,cr0
or eax,80000000h
mov cr0,eax
ret

这段代码会产生什么效果呢?

当时 linux-0.11 认为,总共可以使用的内存不会超过 16M,即最大地址空间为 0xFFFFFF。

而对应分页机制,1 个页目录表最多包含 1024 个页目录项(页表),1 个页目录项最多包含 1024 个页表项(页),1 个页表项位 4 kb(因为偏移有 12 位),所以 16 M 的地址空间可以用 1 个页目录表 + 4 个页表搞定:4(页表数)* 1024(页表项数) * 4KB(一页大小)= 16MB

所以,上面这段代码,会将页目录表放在内存地址的最开头,也就是 head.s 的最开头的 _pg_dir

1
2
3
4
5
6
_pg_dir:
.globl startup_32
_startup_32:
mov eax,0x10
mov ds,ax
...

之后紧挨着这个页目录表,放置 4 个页表

1
2
3
4
5
.org 0x1000 pg0:
.org 0x2000 pg1:
.org 0x3000 pg2:
.org 0x4000 pg3:
.org 0x5000

最终布局如下:

640 (1)

同 idt 和 gdt 一样,我们需要告诉 CPU 这些页表存在了什么地方,通过下面这个代码实现:

1
2
xor eax,eax
mov cr3,eax

将 cr3 赋为0,0 地址处就是页目录表。

现在,看一下整个内存的布局是什么样的:

640 (2)

页表设置好了,我们再来看看内存是怎样映射的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
setup_paging:
...
mov eax,_pg_dir
mov [eax],pg0+7
mov [eax+4],pg1+7
mov [eax+8],pg2+7
mov [eax+12],pg3+7
mov edi,pg3+4092
mov eax,00fff007h
std
L3: stosd
sub eax, 1000h
jpe L3
...

前 5 行,以第 2 行为例,[eax]被赋为 pg0+7,也就是 0x1007,页表地址为 0x1000,页属性为 0x07 表示改页存在、用户可读写。

后面几行表示,填充 4 个页表的每一项,一共 4 * 1024 = 4096 项,依次映射到内存的前 16MB 空间。

经过这套分页机制,线性地址将恰好和最终转换的物理地址一样。

也就是如下图的效果:

640 (4)

至今为止,该设置的什么 idt、gdt、页表都设置好了,也开启了保护模式,之后就正式进入 main.c 了。


正式进入 main.c 还是有一个过程的,现在来讲一下这个过程。

main.c 是怎么被执行的呢?还是要回去看一下 head.s,设置分页的那个地方。

1
2
3
4
5
6
7
8
9
10
11
after_page_tables:
push 0
push 0
push 0
push L6
push _main
jmp setup_paging
...
setup_paging:
...
ret

进行五次压栈,然后跳到 setup_paging,注意 setup_paging 函数最后有一个 ret 指令,会将栈顶的元素值当作返回地址(将esp赋值给eip),然后跳转过去。

整个栈结构如下:

640 (3)

L6 作为 main 函数的返回地址,三个 0 作为 main 函数的参数,都不用太关心。