linux启动过程浅析(3)

来源:互联网 发布:实用的科技网站知乎 编辑:程序博客网 时间:2024/06/05 05:47

 

这是本文得第三部分,在前两部分中,我已经讲述了Linux操作系统是如何被机器boot到,并且load到制定的内存地址的.我们将继续第二部分的内

容,看看操作系统在完成了bootsect.s和setup.s的运行后,在head.s中做了些什么.
让我们回忆一下,在setup.s中,我们把整个system模块从地址0x10000出往下移动了0x10000的距离,也就是说,现在system模块已经位于0x0000地

址上了.而且,由于head.s会被编译到system模块的最前处,所以在head.s开始运行是,程序计数器指向的位置其实是0x0000处.
从这段程序开始,Linux应该算已经被正式load完成了,并且也顺利进入了保护模式.接下来的工作,就想所有具有一定规模的系统一样,需要开始

初始化了.我根据编程语会有对系统的进一步初始化言的区别,将Linux的初始化过程分为两部分.第一部分为head.s中的初始化工作,可以称之为

asm初始化.而接下来系统将会进入的main()函数中,,可以称之为c初始化.让我们先来看一看asm初始化的过程:

.text
.globl idt,gdt,pg_dir,tmp_floppy_area
pg_dir:
.globl startup_32
startup_32:
 movl $0x10,%eax
 mov %ax,%ds
 mov %ax,%es
 mov %ax,%fs
 mov %ax,%gs
首先出现的这一段作用是把ds,es,fs,gs段寄存器的内容全部指向在setup.s中设置的GDT的数据段.是否还记得,在GDT中我们定义了三个段描述

符,第一个为全零,其实是弃之不用的,第二个与第三个都指向了地址0x0000处,分别为代码段和数据段. 需要注意的是,我们已经进入了保护模式

,也就是说现在段寄存器中存放的已经不应该是段的起始地址了,而是应该为段选择符. 在movl $0x10,%eax中,直接数0x10展开成二进制既

是:0000000000010000. 对于intel 80x86系列的CPU来说,选择符的0位和1位表示特权级别, 2位是TI(table indicator),它为0时表示使用GDT,

为1时表示使用LDT.而从3位到15位才是需要选择的描述符的index.如图:
               +--------------------------------------------+
               +          INDEX             | TI  |   RPL   |
               +--------------------------------------------+
                15                        3    2     1   0
所以,0000000000010000从3位到15位实际上时0000000000010,既是2.也就是说,我们把ds,es,fs,gs全部设置成GDT的第二项.

接下来的一行:
 lss stack_start,%esp
的意思是将stack_start放置到ss:esp中,既设置了堆栈起始指针.

然后:
 call setup_idt
用来调用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
这段程序的前半部分是将中断描述符的内容存入eax,edx中. eax中存放的是中断描述符的低四字节的值,edx中存放的是中断描述符的高四字节

的值.我们可以看到,程序把ignore_int放入了eax的低16位中,这个位置正是描述符指向中断处理程序指针的存放地址.我们可以看一下

ignore_int子过程:
int_msg:
 .asciz "Unknown interrupt/n/r"
.align 2
ignore_int:
 pushl %eax
 pushl %ecx
 pushl %edx
 push %ds
 push %es
 push %fs
 movl $0x10,%eax
 mov %ax,%ds
 mov %ax,%es
 mov %ax,%fs
 pushl $int_msg
 call printk
 popl %eax
 pop %fs
 pop %es
 pop %ds
 popl %edx
 popl %ecx
 popl %eax
 iret
其实这个过程任何实质性的工作都没有,仅仅是把int_msg打印了一遍.事实上,Linux在目前状态下,无意去做真正的中断向量表的初始化,而是把

所有的中断处理程序全部置位这个"哑"函数.真正的处理函数由各个模块按照自己需要自行初始化.
我们还可以看到,程序把0x0008放入了eax的高16位中(低16位被随后的复制语句重写成了ignor_int的相对地址),0x0008既是0000000000001000,

根据前文所讲的选择符的规则,既是指向GDT中第一个描述符.这很正确,因为我们在setup.s中把它初始化位了代码断描述符.
在setup_idt子过程的后半部分,我们遍历了整个IDT的范围,把每个中断描述符都设置成指向ignor_int的内容.最后我们把中断描述符表的头指

针load到机器中去(使用lidt操作).

又然后:
 call setup_gdt
调用了setup_gdt子过程,顾名思义,就是设置全局描述符表的子过程.让我们也来看一下:
setup_gdt:
 lgdt gdt_descr
 ret
这里很简单,只是把gdt_descr的值load进来.因为我们并不需要完全设置gdt的256项. gdt的头几项是直接hard code的:
gdt_descr:
 .word 256*8-1  # so does gdt (not that that's any
 .long gdt  # magic number, but it works for me :^)

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 */
到现在为止,Linux已经有了新的gdt了,在setup.s中的gdt将不在被使用.
我们可以看一下Linux现在的gdt表中都有些什么:
第一项: .quad 0x0000000000000000这是一向全零的值,事实上,GDT的第一项都是不被使用的.
第二项: .quad 0x00c09a0000000fff,先看最低的16位:0x0fff,这个位置的值表示段限长,单位是4K(这是由颗粒度位,23位决定的).所以段现场

为16M.事实上第二与第三个全局描述符其实是系统级别的代码运行需要的代码断和数据段,而0.11版本的linux又最多支持16Mb的内存. 因为系

统在最高级别上应该对所有内存区域都具有掌控权,所以将段长设置为了16M.段描述符的基址其实由这64位中的32位指出,但是这32位被分别存

放在了不同的3段中,分别是16-31,32-39,24-31位上,由于这些位在第二项与第三项上全为0,所以基址既是0x0000.这也很正确,因为现在system

模块的确已经在0x0000其实的地址上了.另外,在其中,还由一些段的类型,特权级别等信息.最后,这个段描述符的含义就是从0x0000处其实的,拥

有最高级别特权的,长度为16M(覆盖整个内存区域)的段.
第三项:.quad 0x00c0920000000fff,与第二项的差别仅仅是段的类型不同,分别为代码段和数据段.Linux内核的代码段和数据段是共享的.
第四项:.quad 0x0000000000000000,也是一个空项,保留不用.
以后的所有项都填上全零了.
综上所述,Linux在head.s的起始处对idt和gdt做了第一步初始化,并且他们的地址位置被放在了head.s的尾端,在main()函数之前.

做完以上的初始化后,机器需要刷行寄存器的值:
 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

接下来的这段:
 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
又是有关A20地址线的问题了,这就是我最为不爽的一段.它的作用只是测试一下我们现在的机器是不是已经支持1M以上的内存了.
跳过吧...

下面这段是检查x87协处理器是否存在,我并不关心这个...
 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
最后通过一个jump,跳到after_page_tables:处,我们可以看一下接下来系统又需要做些什么.

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.
个人认为这段代码的风格就不是太好了,可能是我们的Linus大人也喜欢玩些小花样.虽然可读性有些不佳,但是代码还是写得很妙,^_^.
首先压入三个0,不知何用.可能是为了代码上的某些对齐.
然后压入L6:,我们可以看到,在L6:之后是一个死循环,也就是说如果main()函数因为某些原因返回了,那一定是某些地方出错了.一个理想状态的

操作系统是永远不会运行结束的(除非用户关机),那在这里,系统就进入死循环.接着,系统又把main函数的地址压栈了,并且在其之后跳转到

setup_paging去执行.这里有些小奥妙,因为setup_paging运行完毕后会使用ret操作,那样会导致系统把栈中的一个地址弹出,并且跳转到那里继

续执行.Linus就是使用了这样的方法将程序计数器指向了main()函数. 或者我们可以这样认为:这里的pushl $main其实就是模拟了调用main()

函数之前的压栈动作,只不过系统在压栈之后先去运行了一遍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 */
以上这段是head.s的最后一段,这段过后,我们将进入C语言编写的main函数。这段代码是为了内存管理(MM)模块而写的。它的作用是设置页

目录表,并且为前16M物理内存做好页表的映射。关于MM模块和x86分页机制的相关信息,请读者自行寻找资料,以下对代码的说明将假设读者

对这部分知识已经有了相关的了解。如果您还不明白分页机制或操作系统内存管理的相关知识的话,也可跳过此段,待日后再研究。
首先,在setup_paging:标记后的前四行,是把物理内存的前1024×5个字节的内容全部清零。事实上,这个操作将会导致的结果是把head.s的

部分内容抹掉,Linux的机制是将唯一的页目录表放在物理内存的最低处,并且在其之后设置四个页表,这四个页表是为了给init进程(0号进

程)使用的。这里我有一个比较有趣的比喻:我们知道,在Linux系统完成初始化并且运行起来后,init进程将作为所有进程的一个协调者,我

们可以把它比喻成世界的执法官,而现在我们所看到的head.s和之前的boot.s,setup.s是为了创建整个Linux世界而存在的,我们可以把它比喻

成“神”。现在的情况是,系统已经完成了一部分初始化工作了,这些初始化工作只会被运行一次,之前的代码已经不再被需要了。那么,神

的位置已经是多余了,他应该给更需要资源的执法官让路,于是他把自己的一部分让给了分页机制,而这些分页机制又是为init进程服务的。
好了,我们看看之后的代码是如何设置页目录和页表的。它首先设置了系统的页目录,随后有对16M空间的物理内存做了映射,最后,系统在设

置玩控制寄存器打开分页机制后,使用ret返回到了main()函数上。
至此,asm初始化工作已经完成。让我们总结一下,现在系统的情况是:整个system模块已经到位(在0x0000处),在head.s的高端上存在着系

统的GDT和LDT,在head.s的低端上存在着系统的页目录和起始的四个页表,head.s已经被破坏,程序计数器已经跳转到main()函数上继续运行