2.加载操作系统内核程序,并为保护模式做准备
加载操作系统的过程分为三步
加载第一部分代码---引导程序bootsect
加载第二部分代码---setup
SETUPLEN = 4 ! nr of setup-sectors
BOOTSEG = 0x07c0 ! original address of boot-sector
INITSEG = DEF_INITSEG ! we move boot here - out of the way (0x9000)
SETUPSEG = DEF_SETUPSEG ! setup starts here (0x9020)
SYSSEG = DEF_SYSSEG ! system loaded at 0x10000 (65536).
ENDSEG = SYSSEG + SYSSIZE ! where to stop loading
接着,bootsect启动程序将它自身(512B内容)从内存0x07C00复制到内存0x90000(INITSEG)处
mov ax,#BOOTSEG
mov ds,ax
mov ax,#INITSEG
mov es,ax
mov cx,#256
sub si,si
sub di,di
rep
movw
由于“两头约定”和“定位识别”的作用,所以bootsect在开始时“被迫”加载到0x07C00处。现在将其自身移至0x90000处,说明操作系统开始根据自己需要安排内存了
bootsect复制完成之后,内存位置0x7C000和0x9000位置有相同的代码。这段代码复制完成之后便需要将CS:IP的值设置到0x9000的位置,这一功能使用jmpi go, INITSEG
代码来实现,执行这段代码之后,程序就转到执行0x90000处来执行新位置的代码了。Linus的设计思路是:跳转到新位置之后在新位置接着执行后面的mov ax, cs,而不是死循环。jmpi go, INITSEG
与go: mov ax, cs
配合,巧妙地实现了“到新位置后接着原来的执行序继续执行下去”的目的。
由于bootsect复制到了新的地方,并且要在新的地方继续执行。因为代码的整体位置发生了变化,那么代码的的各个段也会发生变化,现在需要对DS、ES、SS和SP进行调整。
go: mov ax,cs
mov dx,#0xfef4 ! arbitrary value >>512 - disk parm size
mov ds,ax
mov es,ax
push ax
mov ss,ax ! put stack at 0x9ff00 - 12.
mov sp,dx
load_setup:
xor dx, dx ! drive 0, head 0
mov cx,#0x0002 ! sector 2, track 0
mov bx,#0x0200 ! address = 512, in INITSEG
mov ax,#0x0200+SETUPLEN ! service 2, nr of sectors
int 0x13 ! read it
jnc ok_load_setup ! ok - continue
push ax ! dump error code
call print_nl
mov bp, sp
call print_hex
pop ax
xor dl, dl ! reset FDC
xor ah, ah
int 0x13
j load_setup
加载第三部分代码---system模块
三部分代码加载完之后,bootsect还需要确定下根设备号,经过一系列检测,得知软盘为根设备,所有就把根设备好保存在root_dev中,这个根设备号作为机器系统数据之一,它将在根文件系统加载中发挥关键作用。这一步完成之后,bootsect的所有工作都做完了,接着执行jmpi 0, SETUPSEG
跳转至0x90200处,这地方存放的是setup程序,这意味着由bootsect程序继续执行。
3.由16位模式切换到32位模式,为main函数的调用做准备
操作系统要将计算机由16位的实模式转换到32位的保护模式,在这期间需要做大量的重建工作,并且继续工作到操作系统的main函数执行过程中。操作系统需要做的工作包括:
关中断,并将system移动到内存地址的起始位置0x00000
setup程序做了一个影响深远的工作:将位于0x10000的内核程序拷贝至内存起始地址为0x00000处。代码如下:
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
这样做能取得一箭三雕的效果:
废除BIOS的中断向量表,等价于废除了BIOS提供的实模式下的中断服务程序
- 收回使用寿命刚刚结束的程序所占用的内存空间
- 让内核代码占据内存物理地址最开始的、最天然的、最有利的位置
我们废除了16位中断机制,但操作系统是不能没有中断的,对外设的使用、系统调用、进程调度都离不开中断,因此,接下来操作系统需要建立新的32位的中断机制
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
idt_48:
.word 0 ! idt limit=0
.word 0,0 ! idt base=0L
gdt_48:
.word 0x800 ! gdt limit=2048, 256 GDT entries
.word 512+gdt,0x9 ! gdt base = 0X9xxxx
为了建立保护模式下的中断机制,setup程序需要对8259A中断控制器进行重新编程。CPU工作方式由实模式转变为保护模式,一个重要的特征就是要根据GDT表来决定后续将执行哪里的程序。
注意这段代码jmpi 0,8 ! jmp offset 0 of segment 8 (cs)
这句中的“0”是段内偏移,“8”是保护模式下的段选择符,用于选择描述符表和描述符表项以及所要求的特权级。这里的8的解读方式很有意思,如果把8当做十进制的8来看待,这行程序的意思就很难理解了。必须把8看成二进制的1000,再把前后代码联合起来当做一个整体来看,便可形成下图,才能明白这行代码的真实意图。注意,这个是以位为单位的数据使用方式,4bit的每一位都有明确的意义,这是底层代码的一个特色。这里的1000的最后两位00表示内核特权级,与之相对应的用户特权级是11,第三位的0表示GDT表,如果是1,则表示LDT。1000的1表示所选的表(此时就是GDT表)的1项(GDT表项号排序为0项、1项、2项,也就是第2项)来确定代码段的段基址和段限长等信息,从上图可以看到,代码是从段基址0x00000000、偏移为0处开始执行的,也就是head程序的开始位置,这意味着将执行head程序。到此为止,setup就执行完毕了,它为系统能够在保护模式下运行做了一系列的准备工作,但这些还不够,后续准备工作将由head程序来完成
head.s开始执行
25KB+184B
的空间,这个数字很重要,望留心movl $0x10, %eax
的解析与前面语句jmpi 0,8
的解析方式相同。将0x10也看成二进制00010000. _pg_dir:
startup_32:
movl $0x10,%eax
mov %ax,%ds
mov %ax,%es
mov %ax,%fs
mov %ax,%gs
call setup_idt
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
这是重建保护模式下中断服务体系的开始,程序先让所有中断描述符默认指向ignor_int这个位置(将来main函数里面还要让中断描述符对应具体的中断服务程序),之后还需要对中断描述符表寄存器的值进行设置,具体操作状态如下:
call setup_gdt
setup_gdt:
lgdt gdt_descr
ret
_gdt: .quad 0x0000000000000000 /* NULL descriptor */
.quad 0x00c09a0000000fff /* 16Mb */
.quad 0x00c0920000000fff /* 16Mb */
.quad 0x0000000000000000 /* TEMPORARY - don't use */
.fill 252,8,0 /* space for LDT's and TSS's etc */
ret
指令就可以执行main函数 pushl $L6 # return address for main, if it decides to.
pushl $_main
jmp setup_paging
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 */
首先会将页目录表和4个页表放在物理内存的起始位置。从内存起始位置开始的5页空间内容全部清零(每页4KB),为初始化页目录和页表做准备。注意,这个动作启用了一个页目录和4个页表覆盖了head程序自身所在内存空间的作用。head程序将也目录表和4个页表所占物理内存空间清零后,设置页目录表的前4项,使之分别指向4个页表。设置完页目录表后,linux 0.11在保护模式下支持的最大寻址地址为0xFFFFFF(16MB),此处将第4张页表(由pg3指向的位置)的最后一个页表项(pg3+4092指向的位置)指向寻址范围的最后一个页面,即0xFFF000开始的4KB字节大小的内存空间。然后开始从高地址向低地址方向填写全部4个页表,依次指向内存从高地址向低地址方向的各个页面。填写过程如下列各图所示。 注意这4个页表都是内核专属页表,将来每个用户进程都有他们专属的页表,两者在寻址范围方面的区别将在后文介绍。
将页目录表和4个页表放在物理内存的起始位置,这个动作意义重大,是操作系统能够掌控全局、掌控进程在内存中安全运行的基石之一
head程序已将页表设置完毕了,但分页机制的建立还没有完成。需要设置页目录基址寄存器CR3,使之指向页目录表,再将CR0寄存器设置的最高位(31位)置位1,如下图所示
所有设置完成之后的内存布局为如下,可以看出,只有184字节的剩余代码,由此可见在设计head程序和system模块时,其计算是非常精确的,对head.s的代码量的控制非常到位
head程序执行的最后一步:ret 跳入main函数程序中执行。这个函数调用方法与普通函数调用方法有很大差别。
先看看普通函数的调用和返回方法。普通函数都是使用CALL指令来实现。 CALL指令会将EIP的值自动压栈,保护返回现场,然后执行被调函数的程序。等到执行被调用函数的
ret
指令时,自动出栈给EIP并恢复现场,继续执行CALL的下一条指令。这是通常的函数调用方法。但对操作系统的main函数来说,这个方法就有些怪异了。main函数式操作系统的,如果用CALL调用操作系统的main函数,那么ret时返回给谁呢?难道还有一个更底层的系统程序接收操作系统的返回么?操作系统已经是最底层的系统了,所以逻辑上不成立。那么如何调用了操作系统的main函数,又不需要返回呢?操作系统的设计者采用了下图的下半部分所示的方法。 这个方法的妙处在于用ret是实现的调用操作系统的main函数,既然是ret调用,当然就不需要再用ret了。不过CALL做的压栈和跳转动作谁来完成呢?操作系统的设计者做了一个仿CALL的动作,手工编写压栈和跳转代码,模仿了CALL的全部动作,实现了调用setup_paging函数。注意,压栈的EIP值并不是调用setup_paging函数的下一条指令的地址,而是操作系统的main函数的执行入口地址_main。这样,当setup_paging函数执行到ret时,从栈中将操作系统的main函数的执行入口地址_main自动出栈给EIP,EIP指向main函数的入口地址,实现了用返回指令“调用”main函数。