head.s干了什么事

head.s 执行前的内存布局如下所示,此时 head.s 和操作系统剩余部分的代码被移动到物理地址 0 到 0x80000 处,0x90000 处存放着 setup.s 中获取的一些设备信息,过后要用到。0x90020 开始是 setup.s 的代码和数据,里面包括之前设置的临时 idt 和 gdt,此时 cpu 中的 idtr,gdtr 寄存器指向之前的临时 idt 和 gdt。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
        +------------------+
| |
| |
+------------------+ +-----------+
| idt <--------------+ | |
| setup | | | +-------+ |
| gdt <----------+ +----+ idtr | |
| | | | +-------+ |
0x90020 +------------------+ +--------+ gdtr | |
| | | +-------+ |
| | | |
+------------------+ +-----------+
|临时存放的变量 | CPU
0x90000 +------------------+
| |
0x80000 +------------------+
| |
|system(操作系统全 |
|部代码) |
| |
0 +------------------+

进入head.s

1
2
3
4
pg_dir:
.globl startup_32
startup_32:
movl $0x10,%eax

pg_dir是页目录,位于物理地址 0 处,后面的代码在之后会被页目录的内容覆盖,但是那时这段代码已经执行完毕,所以无所谓。首先把 0x10 赋值给 eax。0x10 对应的段选择符如下,TI = 0表示在 gdt 中,请求特权级为 0,gdt 中的第二个描述符对应的是之前 setup.s 中临时设置的数据段描述符。

1
2
3
4
 描述符索引=2     TI RPL
+-----------------------+
|0000 0000 0001 0| 0| 00|
+-----------------------+

之后使用 0x10 加载 ds, es, fs, gs 四个数据段寄存器,使用 stack_start 加载 ss 和 esp。

1
2
3
4
5
mov %ax,%ds
mov %ax,%es
mov %ax,%fs
mov %ax,%gs
lss stack_start,%esp

stack_start 定义在 sched.c 中,是一个48位的长指针。低4字节是 user_stack 的末端地址,高2字节 0x10 是数据段的段选择符。

1
2
3
4
5
6
long user_stack [ PAGE_SIZE>>2 ] ;

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

LSS 指令的作用是从源操作数指定的内存中加载一个32位或48位的长指针到 ss:目的操作数指定的寄存器中,在这里就是 ss:esp 中。

Opcode Instruction Op/En Description
0F B2 /r LSS r16,m16:16 RM Load SS:r16 with far pointer from memory.
0F B2 /r LSS r32,m16:32 RM Load SS:r32 with far pointer from memory.
REX + 0F B2 /r LSS r64,m16:64 RM Load SS:r64 with far pointer from memory.

所以 lss stack_start,%esp 的作用就是将 stack_start 处的低32位,也就是 user_stack 的末端地址赋值给 esp,并将 stack_start 的高16字节,也就是数据段的段选择符赋值给 ss 寄存器。从而将 user_stack 设置为当前使用的栈。

接下来重新设置 idt 和 gdt。因为原先在 setup.s 中的 idt 和 gdt 之后会被缓冲区覆盖掉,所以肯定要把 idt 和 gdt 挪到 system 部分。

1
2
call setup_idt
call setup_gdt

先来看 setup_idt,ignore_int 是个只输出错误信息的哑中断子程序,setup_idt 让 idt 中的256个中断描述符都指向 ignore_int。其中 2~5 行是将要设置的中断门内容填充到 eax 和 ebx 中,其中 eax 存放着低4字节,ebx 存放着高4字节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
setup_idt:
lea ignore_int,%edx
movl $0x00080000,%eax
movw %dx,%ax /* selector = 0x0008 = cs */
movw $0x8E00,%dx /* interrupt gate - dpl=0, present */

lea idt,%edi
mov $256,%ecx
rp_sidt:
movl %eax,(%edi)
movl %edx,4(%edi)
addl $8,%edi
dec %ecx
jne rp_sidt
lidt idt_descr
ret

中断门格式如下:

1
2
3
4
5
6
7
8
9
31                             15 14 13 12       5 4     0
+-----------------------------+--+-----+----------+--------+
| 过程入口点偏移 31...16 | P| DPL | 01110000 | |
+-----------------------------+--+-----+----------+--------+
31 16 15 0
+----------------------------+-----------------------------+
| 段选择符 | 过程入口点偏移 0...15 |
+----------------------------+-----------------------------+

2~5 行执行完后 eax 和 ebx 的内容如下,可以和上面的中断门格式对应上

1
2
3
4
5
6
7
8
9
                             P DPL
+----------------------+---------------------+
ebx: | ignore_int 地址高16位 |1| 00 |01110000|00000|
+----------------------+---------------------+
段选择符(代码段)
+---------------------+----------------------+
eax: | 0x0008 | ignore_int 地址低16位 |
+---------------------+----------------------+

然后让 edi 指向 idt 表第一项,循环256次,依次填充 idt 表的每一项。因为一个中断门8个字节,所以每次循环 edi + 8,指向表中下一项。最后 lidt 指令加载 idtr 寄存器。

setup_gdt 非常简单,直接用 lgdt 指令加载 gdtr 寄存器,因为 gdt 的内容已经硬编码好了,第一项是空描述符,第二项是代码段描述符,第三项是数据段描述符,后面还预留了 252 个空项,用于之后的 LDT 和 TSS。

1
2
3
setup_gdt:
lgdt gdt_descr
ret
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.align 2
.word 0
idt_descr:
.word 256*8-1 # idt contains 256 entries
.long idt
.align 2
.word 0
gdt_descr:
.word 256*8-1 # so does gdt (not that that's any
.long gdt # magic number, but it works for me :^)

.align 8
idt: .fill 256,8,0 # idt is uninitialized

gdt: .quad 0x0000000000000000 /* NULL descriptor */
.quad 0x00c09a0000000fff /* 16Mb */ /* 00000000 11000000 10011010 00000000 00000000 00000000 00001111 11111111 */
.quad 0x00c0920000000fff /* 16Mb */ /* 00000000 11000000 10010010 00000000 00000000 00000000 00001111 11111111 */
.quad 0x0000000000000000 /* TEMPORARY - don't use */
.fill 252,8,0 /* space for LDT's and TSS's etc */

在重新设置 gdt 后要重新加载各个段寄存器:

1
2
3
4
5
6
movl $0x10,%eax		# reload all the segment registers
mov %ax,%ds # after changing gdt. CS was already
mov %ax,%es # reloaded in 'setup_gdt'
mov %ax,%fs
mov %ax,%gs
lss stack_start,%esp

后面的代码检测了 A20 是否开启,检测数学协处理器,这些历史包袱问题不重要所以没有细看,重要的是后面的 after_page_tables。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
	xorl %eax,%eax
1: incl %eax # check that A20 really IS enabled
movl %eax,0x000000 # loop forever if it isn't
cmpl %eax,0x100000
je 1b

/*
* NOTE! 486 should set bit 16, to check for write-protect in supervisor
* mode. Then it would be unnecessary with the "verify_area()"-calls.
* 486 users probably want to set the NE (#5) bit also, so as to use
* int 16 for math errors.
*/
movl %cr0,%eax # check math chip
andl $0x80000011,%eax # Save PG,PE,ET
/* "orl $0x10020,%eax" here for 486 might be good */
orl $2,%eax # set MP
movl %eax,%cr0
call check_x87
jmp after_page_tables

在 after_page_tables 中,先把 main 函数参数压栈,然后是 main 的返回地址 L6,理论上 main 不应该返回,如果main 函数执行 ret 返回,会弹出并跳转到 L6 执行,这是个死循环。随后将 main 函数地址入栈,当 setup_paging 返回时,会弹出并跳转到 main 执行。

这4张页表是内核专用页表,它们将映射线性地址空间的前 16MB 一一映射到 16MB 的物理空间。需要4张页表是因为页大小是 4KB,一个页表项是4字节,一张页表有 4KB / 4 = 1024 项,四张页表有4096项,能映射 4096 * 4KB = 16MB 的物理内存。页目录项/页表项格式如下:

1
2
3
4
5
 31         12 11 10 9 87 6 5 43  2   1  0
+-------------+---------------------------+
| 页帧地址 | AVL |00|D|A|00|U/S|R/W|P|
+-------------+---------------------------+

  • P :存在位,表示页面是否在内存中,还是已被换出
  • R/W :读写位,1表示可读/写/执行,0表示可读/执行
  • U/S:超级用户标志,为1时任何特权级都可访问,为0时只有特权级0,1,2可访问
  • A:访问位
  • D:脏位
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*
* I put the kernel page tables right after the page directory,
* using 4 of them to span 16 Mb of physical memory. People with
* more than 16MB will have to expand this.
*/
.org 0x1000
pg0:

.org 0x2000
pg1:

.org 0x3000
pg2:

.org 0x4000
pg3:
1
2
3
4
5
6
7
8
9
10
after_page_tables:
pushl $0 # These are the parameters to main :-)
pushl $0
pushl $0
pushl $L6 # return address for main, if it decides to.
pushl $main
jmp setup_paging
L6:
jmp L6 # main should never return here, but
# just in case, we know what happens.

接下来是最重要的初始化页目录和内核页表的代码。2~5 行代码将页目录和4张页表所在内存清零。这里使用带 rep 前缀的 stosl 字符串操作指令。首先将循环次数存入 ecx,将 eax, edi 置零。cld 指令作用是将 EFLAGS 寄存器的 DF 标志清零。当 DF 标志位为0时,在字符串操作指令中 esi, edi 变址寄存器是自增方向,否则是自减方向。stosl 指令单独使用的话是将 eax 的值存入 es:edi 指向的内存地址中,当结合 rep 前缀使用时,重复次数由 ecx 的值指定。(比较奇怪的是我在 Intel 手册中没有找到 stosl 指令,只有 stos, stosb, stosw, stosd, stosq)。这里重复次数之所以是 1024 * 5 是因为一个页表项/页目录项是4字节,stosl 操作数也是4字节,而页目录/页表大小都是 4KB,包含 4KB / 4 = 1024 个页目录项/页表项,所以清零一个页目录/页表需要循环1024次,这里有一个页目录 + 4张页表,需要 1024 * 5 次循环。

第 6~9 行设置页目录。每个页目录项初始化为 ADDR(pgn) + 7,也就是存在位为1,可读/写/执行,任何特权级可访问。(暂时不明白这里为什么设置成允许任何特权级访问)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
setup_paging:
movl $1024*5,%ecx /* 5 pages - pg_dir+4 page tables */
xorl %eax,%eax
xorl %edi,%edi /* pg_dir is at 0x000 */
cld;rep;stosl
movl $pg0+7,pg_dir /* set present bit/user r/w */
movl $pg1+7,pg_dir+4 /* --------- " " --------- */
movl $pg2+7,pg_dir+8 /* --------- " " --------- */
movl $pg3+7,pg_dir+12 /* --------- " " --------- */
movl $pg3+4092,%edi
movl $0xfff007,%eax /* 16Mb - 4096 + 7 (r/w user,p) */
std
1: stosl /* fill pages backwards - more efficient :-) */
subl $0x1000,%eax
jge 1b
xorl %eax,%eax /* pg_dir is at 0x0000 */
movl %eax,%cr3 /* cr3 - page directory start */
movl %cr0,%eax
orl $0x80000000,%eax
movl %eax,%cr0 /* set paging (PG) bit */
ret /* this also flushes prefetch-queue */

未完待续……