在32位的操作系统上,Linux只需要使用二级页表(即页目录表和页表)就可以支持4GB地址空间寻址。所以,接下来就可以将页目录表放在物理地址0处,它的大小是4KB,共包含1024个页目录项,每个页目录项的大小为4B。
因为setup模块已经将内核代码搬到物理地址0处,所以代表页目录表的pd_dir就可以放到head.S代码的开始处。设置页表的代码如代码清单3-1所示。
1 .code32
2 .text
3 .globl startup_32,idt,gdt,pg_dir,tmp_floppy_area
4 pg_dir:
5 startup_32:
6 movl $0x10, %eax
7 ...
8
9 xorl %eax, %eax
10 1:
11 incl %eax
12 movl %eax,0x000000
13 cmpl %eax,0x100000
14 je 1b
15
16 jmp after_page_tables
17
18 setup_idt:
19 leal ignore_int,%edx
20 ...
21
22 setup_gdt:
23 lgdt gdt_descr
24 ret
25
26 .org 0x1000
27 pg0:
28
29 .org 0x2000
30 pg1:
31
32 .org 0x3000
33 pg2:
34
35 .org 0x4000
36 pg3:
37
38.org 0x5000
39
40 tmp_floppy_area:
41 .fill 1024,1,0
42
43 after_page_tables:
44 /* 我们可以在此处跳转至main函数
45 pushl $0
46 pushl $0
47 pushl $0
48 pushl $L6
49 pushl $main
50 mp setup_paging
51 L6:
52 jmp L6
53 ...
54
55 .align 4
56 setup_paging:
57 movl $1024 *5,%ecx
58 xorl %eax, %eax
59 xorl %edi,%edi
60 cld
61 rep
62 stosl
63
64 movl $pg0+7,pg_dir
65 movl $pg1+7,pg_dir+4
66 movl $pg2+7,pg_dir+8
67 movl $pg3+7,pg_dir+12
68 movl $pg3+4092,%edi
69 movl $0xfff007, %eax
70 std 711:
72 stosl
73 subl $0x1000, %eax
74 jge1b
75 xorl %eax, %eax
76 movl %eax,%cr3
77 movl %cr0, %eax
78 orl$0x80000000, %eax
79 movl %eax,%cr0
80
81 ret
注意,bootsect中已经使用过上述代码的.org伪指令,该指令可以规定程序的起始地址,例如pg0就被强制放在了文件开头偏移0x1000的位置,这就相当于在二进制文件中预留下了页目录表和页表的空间。bootsect将head模块加载进内存以后,setup模块还会进一步将它移到物理内存的0的位置。移动以后的内存布局如图3-1所示。
图3-1 内存布局
实际上,在图3-1中,pg_dir处不仅有页目录项,还有很多代码,即startup_32处的那些代码。这些代码会先执行,当它们执行完以后就跳到after_page_tables处继续执行。跳转以后,startup_32处的代码就再也不会被使用了,这时就可以设置页目录表,将这部分内存全部重写。
第3行将gdt、idt、pg_dir等符号导出,以让内核的其他模块可以访问这些符号。第4行,即head.S的开头处,定义了pg_dir,前面已经介绍过了,这是定义页目录表的位置。在第26行,.org伪指令为页目录表预留了4KB(0x1000)的空间,pg0定义了第一张页表,第29行的伪指令也是相同的作用,它为pg0页表预留了4KB空间。后面的pg1、pg2、pg3的定义与pg0是相同的,这里不再赘述。
第40行的代码又定义了一个名为tmp_floppy_area,这个空间是为虚拟软盘准备的,这里先不介绍,等后文讲解到软盘的输入/输出实现时再加以介绍。
在这些数据结构定义之后,才是设置页表的代码。这些代码必须放在tmp_floppy_area之后,否则就会被新的页目录表覆盖。这段代码的开始处以after_page_tables进行标识。第45~49行代码往栈上存放了一个名为main的符号。这个动作的作用稍后讲解,我们还是先把设置页表的动作讲解完。
setup_paging一开始先把从0~5KB这一段物理内存全部清零(第57~62行),从这里开始,head.S的部分代码就会永远地从内存中擦除了。第64~67行代码负责将前4个页表,即pg0~pg3的地址填入相应的页目录项。这里的地址加上7表示页目录项的低三位都是1,参考第2章中介绍的页目录项的结构(参考图2-6),可以知道,这代表页目录项所对应的页表是用户态可读写,并且在内存中存在。
第68行至第74行代码使用了一个循环,将0x0~0xfff000的物理内存的地址填入页表。因为每个物理页的大小是4KB(0x1000),所以在每一次循环中,地址的值都会减掉0x1000。stosl指令的作用是将eax寄存器中的值保存到edi指向的地址中,若eflags中的方向已置位(即在stosl指令前使用std指令),则edi自减4。所以这里的循环会把16MB(0xfff000)物理内存的地址都设置在页表中,共需要4096个页表项,刚好占据4个页表。这4个页表的最后位置是pg3+4092,所以将edi的初始值设成页表的末尾地址,然后每一次循环设置一个页表项,之后edi减4,直到地址0被填入pg0的第一页,循环就会结束(第71~74行)。
最后,把页目录表的物理地址(也就是0),送入cr3寄存器(第75~76行),然后把cr0的最高位置为1,代表打开保护模式的分页机制(第77~79行)。至此,页表的设置就全部完成了。
在第81行有一个ret指令。你可能会有这样的疑惑:我们明明没有使用call指令进行控制流的转移,为什么要使用ret指令呢?这个ret指令又“返回”到哪里执行呢?实际上,call/ret指令并不是一定要成对出现的。call指令的作用是把返回地址放到栈上,然后转移到目标地址执行。而ret指令的作用是把返回地址从栈上取出,然后跳转到返回地址继续执行。可见,只要把返回地址放到栈上,然后执行ret指令就可以让CPU转移到目标地址执行,而把地址放到栈上这个操作并一定非得使用call指令才能实现,使用push指令也可以实现同样的功能。第49行代码将main这个符号放到栈上,所以当CPU执行第81行的ret指令时,就会跳转到main函数的入口执行。45~48行是为main函数准备的参数和返回地址,实际上这些值不会再起作用,可以忽略。这里当然也可以使用call指令或者jmp指令进行控制流的转移。但以后内核的代码还会使用iret进行特权级的转换,这里为了统一,就都使用ret指令了。
而main这个符号位于main.c中,main.c的代码如下所示:
1 #define__LIBRARY__
2
3 void main(void)
4 {
5 __asm__("int $0x80\n\r"::);
6 __asm__ __volatile__(
7 "loop:\n\r"
8 "jmp loop"
9 ::);
10 }
main函数里包含了两段内嵌汇编代码,第5行的汇编代码触发了一个0x80号中断,实际上第6行的汇编代码开启了一个死循环,让main函数不会结束。第6行换成while语句或者for语句效果是一样的,读者可以自己动手尝试一下。同时,makefile文件中也要添加对main.c文件的编译支持:
1 GCC:=gcc
2 CCFLAG:=-I../include-nostdinc-Wall-fomit-frame-pointer-c
3 LDFLAG:=-Ttext 0x0-s--oformat binary-m elf_x86_64
4 INCDIR:=../include
5 OBJS:=head.o main.o
6
7 system: $(OBJS)
8 $(LD) $(LDFLAG)-e startup_32-o$@ $^
9
10 head.o:head.S
11 $(GCC)-traditional-c-o$@ $<
12
13 main.o:main.c
14 $(GCC) $(CCFLAG)-o$@ $<
这种方式可以把head.S和main.c两个文件链接在一起,形成了新的内核文件。相比第2章,这里把“int 0x80”指令从head.S移到了main函数里,这说明操作系统确实进入main函数执行了。最后运行的结果虽然与第2章结尾时是一样的,但现在已经利用C语言里编程了。