《Linux内核设计的艺术:图解Linux操做系统架构设计与实现原理》——1.3 开始向32位模式转变,为main函数的调用作准备...

1.3 开始向32位模式转变,为main函数的调用作准备

接下来,操做系统要使计算机在32位保护模式下工做。这期间要作大量的重建工做,而且持续工做到操做系统的main函数的执行过程当中。在本节中,操做系统执行的操做包括打开32位的寻址空间、打开保护模式、创建保护模式下的中断响应机制等与保护模式配套的相关工做、创建内存的分页机制,最后作好调用main函数的准备。
1.3.1 关中断并将system移动到内存地址起始位置0x00000
如图1-16所示,这个准备工做先要关闭中断,即将CPU的标志寄存器(EFLAGS)中的中断容许标志(IF)置0。这意味着,程序在接下来的执行过程当中,不管是否发生中断,系统都再也不对此中断进行响应,直到下一章要讲解的main函数中可以适应保护模式的中断服务体系被重建完毕才会打开中断,而那时候响应中断的服务程序将再也不是BIOS提供的中断服务程序,取而代之的是由系统自身提供的中断服务程序。代码以下:javascript

//代码路径:boot/setup.s
…
cli            ! no interrupts allowed !
…

小贴士
EFLAGS:标志寄存器,存在于CPU中,32位,包含一组状态标志、控制标志及系统标志。如第0位的CF(Carry Flag)为CPU计算用到的进位标志,及图1-16所示的关中断操做涉及的第9位IF(Interrupt Flag)中断容许标志。
点评
关中断(cli)和开中断(sti)操做将在操做系统代码中频繁出现,其意义深入。慢慢的你会发现,cli、sti老是在一个完整操做过程的两头出现,目的是避免中断在此期间的介入。接下来的代码将为操做系统进入保护模式作准备。此处即将进行实模式下中断向量表和保护模式下中断描述符表(IDT)的交接工做。试想,若是没有cli,又刚好发生中断,如用户不当心碰了一下键盘,中断就要切进来,就不得不面对实模式的中断机制已经废除、保护模式的中断机制还没有完成的尴尬局面,结果就是系统崩溃。cli、sti保证了这个过程当中,IDT可以完整建立,以免不可预料中断的进入形成IDT建立不完整或新老中断机制混用。甚至能够理解为cli、sti是为了保护一个新的计算机生命的完整而建立的。
下面,setup程序作了一个影响深远的动做:将位于0x10000的内核程序复制至内存地址起始位置0x00000处!代码以下:java

//代码路径:boot/setup.s
    …
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
    …

图1-17准确标识了复制操做系统内核代码的源位置和目标位置及复制动做的方向。编程

image

回顾一下图1-2的内容,0x00000这个位置原来存放着由BIOS创建的中断向量表及BIOS数据区。这个复制动做将BIOS中断向量表和BIOS数据区彻底覆盖,使它们不复存在。直到新的中断服务体系构建完毕以前,操做系统再也不具有响应并处理中断的能力。如今,咱们开始体会到图1-16中的关中断操做的意义。
点评
这样作能取得“一箭三雕”的效果:
1)废除BIOS的中断向量表,等同于废除了BIOS提供的实模式下的中断服务程序。
2)收回刚刚结束使用寿命的程序所占内存空间。
3)让内核代码占据内存物理地址最开始的、自然的、有利的位置。
“破旧立新”这个成语用在这里特别贴切。system模块复制到0x00000这个动做,废除了BIOS的中断向量表,也就是废除了16位的中断机制。操做系统是不能没有中断的,对外设的使用、系统调用、进程调度都离不开中断。Linux操做系统是32位的现代操做系统,16位的中断机制对32位的操做系统而言,显然是不合适的,这也是废除16位中断机制的根本缘由。为了创建32位的操做系统,咱们不但要“破旧”,还要“立新”——创建新的中断机制。
1.3.2 设置中断描述符表和全局描述符表
setup程序继续为保护模式作准备。此时要经过setup程序自身提供的数据信息对中断描述符表寄存器(IDTR)和全局描述符表寄存器(GDTR)进行初始化设置。
小贴士
GDT(Global Descriptor Table,全局描述符表),在系统中惟一的存放段寄存器内容(段描述符)的数组,配合程序进行保护模式下的段寻址。它在操做系统的进程切换中具备重要意义,可理解为全部进程的总目录表,其中存放每个任务(task)局部描述符表(LDT,Local Descriptor Table)地址和任务状态段(TSS,Task Structure Segment)地址,完成进程中各段的寻址、现场保护与现场恢复。
GDTR(Global Descriptor Table Register,GDT基地址寄存器),GDT能够存放在内存的任何位置。当程序经过段寄存器引用一个段描述符时,须要取得GDT的入口, GDTR标识的即为此入口。在操做系统对GDT的初始化完成后,能够用LGDT(Load GDT)指令将GDT基地址加载至GDTR。
IDT(Interrupt Descriptor Table,中断描述符表),保存保护模式下全部中断服务程序的入口地址,相似于实模式下的中断向量表。
IDTR(Interrupt Descriptor Table Register,IDT基地址寄存器),保存IDT的起始地址。
内核实现代码以下:数组

//代码路径:boot/setup.s
    …
end_move:
    mov    ax,#SETUPSEG    ! right, forgot this at first. didn't work :-)
    mov    ds,ax
    lidt    idt_48        ! load idt with 0,0
    lgdt    gdt_48        ! load gdt with whatever appropriate
    …
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
    …

这些代码设置所须要的数据分别在idt_48和gdt_48所对应的标号处,它们和寄存器的对应方式如图1-18所示。安全

image

点评
32位的中断机制和16位的中断机制,在原理上有比较大的差异。最明显的是16位的中断机制用的是中断向量表,中断向量表的起始位置在0x00000处,这个位置是固定的;32位的中断机制用的是中断描述符表(IDT),位置是不固定的,能够由操做系统的设计者根据设计要求灵活安排,由IDTR来锁定其位置。
GDT是保护模式下管理段描述符的数据结构,对操做系统自身的运行以及管理、调度进程有重大意义,后面的章节会有详细讲解。
由于,此时此刻内核还没有真正运行起来,尚未进程,因此如今建立的GDT第一项为空,第二项为内核代码段描述符,第三项为内核数据段描述符,其他项皆为空。
IDT虽然已经设置,实为一张空表,缘由是目前已关中断,无需调用中断服务程序。此处反映的是数据“够用即得”的思想。
建立这两个表的过程可理解为是分两步进行的:
1)在设计内核代码时,已经将两个表写好,而且把须要的数据也写好。
2)将专用寄存器(IDTR、GDTR)指向表。
此处的数据区域是在内核源代码中设定、编译并直接加载至内存造成的一块数据区域。专用寄存器的指向由程序中的lidt和lgdt指令完成,具体操做见图1-18。
值得一提的是,在内存中作出数据的方法有两种:
1)划分一块内存区域并初始化数据,“看住”这块内存区域,使之能被找到;
2)由代码作出数据,如用push代码压栈,“作出”数据。
此处采用的是第一种方法。
1.3.3 打开A20,实现32位寻址
下面是标志性的动做——打开A20!
打开A20,意味着CPU能够进行32位寻址,最大寻址空间为4 GB。注意图1-19中内存条范围的变化:从5个F扩展到8个F,即0xFFFFFFFF——4 GB。markdown

image

如今看来,Linux 0.11还显得有些稚嫩,最大只能支持16 MB的物理内存,可是其线性寻址空间已是彻彻底底的4 GB。
打开A20的代码(boot/setup.s)以下:数据结构

//代码路径:boot/setup.s
    …
! that was painless, now we enable A20
    call    empty_8042
    mov    al,#0xD1        ! command write
    out    #0x64,al
    call    empty_8042
    mov    al,#0xDF        ! A20 on
    out    #0x60,al
    call    empty_8042
    …

点评
实模式下CPU寻址范围为0~0xFFFFF,共1 MB寻址空间,须要0~19号共20根地址线。进入保护模式后,将使用32位寻址模式,即采用32根地址线进行寻址,第21根(A20)至第32根地址线的选通控制将意味着寻址模式的切换。
实模式下,当程序寻址超过0xFFFFF时,CPU将“回滚”至内存地址起始处寻址(注意,在只有20根地址线的条件下,0xFFFFF + 1 = 0x00000,最高位溢出)。例如,系统的段寄存器(如CS)的最大容许地址为0xFFFF,指令指针(IP)的最大容许段内偏移也为0xFFFF,二者肯定的最大绝对地址为0x10FFEF,这将意味着程序中可产生的实模式下的寻址范围比1 MB多出将近64 KB(一些特殊寻址要求的程序就利用了这个特色)。这样,此处对A20地址线的启用至关于关闭CPU在实模式下寻址的“回滚”机制。在后续代码中也将看到利用此特色来验证A20地址线是否确实已经打开。
1.3.4 为保护模式下执行head.s作准备
为了创建保护模式下的中断机制,setup程序将对可编程中断控制器8259A进行从新编程。
小贴士
8259A:专门为了对8085A和8086/8088进行中断控制而设计的芯片,是能够用程序控制的中断控制器。单个的8259A能管理8级向量优先级中断,在不增长其余电路的状况下,最多能够级联成64级的向量优先级中断系统。
具体代码以下:架构

//代码路径:boot/setup.s
    …
    mov         al,#0x11        ! initialization sequence
    out        #0x20,al        ! send it to 8259A-1
    .word        0x00eb,0x00eb        ! jmp $ + 2, jmp $ + 2
    out        #0xA0,al        ! and to 8259A-2
    .word        0x00eb,0x00eb
    mov        al,#0x20        ! start of hardware int's (0x20)
    out        #0x21,al
    .word        0x00eb,0x00eb
    mov        al,#0x28        ! start of hardware int's 2 (0x28)
    out        #0xA1,al
    .word        0x00eb,0x00eb
    mov        al,#0x04        ! 8259-1 is master
    out        #0x21,al
    .word        0x00eb, 0x00eb
    mov         al, #0x02        ! 8259-2 is slave
    out        #0xA1,al
    .word        0x00eb,0x00eb
    mov        al,#0x01        ! 8086 mode for both
    out        #0x21,al
    .word        0x00eb,0x00eb
    out        #0xA1,al
    .word        0x00eb,0x00eb
    mov        al,#0xFF        ! mask off all interrupts for now
    out        #0x21,al
    .word        0x00eb,0x00eb
    out        #0xA1,al
    …

从新编程的结果在图1-20中有直观的表述。
CPU在保护模式下,int 0x00~int 0x1F被Intel保留做为内部(不可屏蔽)中断和异常中断。若是不对8259A进行从新编程, int 0x00~int 0x1F中断将被覆盖。例如,IRQ0(时钟中断)为8号(int 0x08)中断,但在保护模式下此中断号是Intel保留的“Double Fault”(双重故障)。所以,必须经过8259A编程将原来的IRQ0x00~IRQ0x0F对应的中断号从新分布,即在保护模式下,IRQ0x00~IRQ0x0F的中断号是int 0x20~int 0x2F。app

image

setup程序经过下面代码的前两行将CPU工做方式设为保护模式。将CR0寄存器第0位(PE)置1,即设定处理器工做方式为保护模式。
小贴士
CR0寄存器:0号32位控制寄存器,存放系统控制标志。第0位为PE(Protected Mode Enable,保护模式使能)标志,置1时CPU工做在保护模式下,置0时为实模式。
具体代码以下:less

//代码路径:boot/setup.s
    …
    mov    ax,#0x0001    ! protected mode (PE) bit
    lmsw    ax        ! This is it!
    jmpi    0,8        ! jmp offset 0 of segment 8 (cs)
    …

图1-21对此作出了直观的标示。
CPU工做方式转变为保护模式,一个重要的特征就是要根据GDT决定后续执行哪里的程序。
注意看图1-18中对GDT的设置,这些设置都是setup事先安排好了的默认设置。从setup程序跳转到head程序的方式如图1-22所示。
    
image

具体代码以下:

//代码路径:boot/setup.s
    …    
    jmpi    0, 8
    …

这一行代码中的“0”是段内偏移,“8”是保护模式下的段选择符,用于选择描述符表和描述符表项以及所要求的特权级。这里“8”的解读方式颇有意思。若是把“8”当作六、七、8……中的“8”这个数来看待,这行程序的意思就很难理解了。必须把“8”当作二进制的1000,再把先后相关的代码联合起来当作一个总体看,在头脑中造成相似图1-23所示的图,才能真正明白这行代码究竟在说什么。注意:这是一个以位为操做单位的数据使用方式,4 bit的每一位都有明确的意义,这是底层源代码的一个特色。
image

图1-23 保护模式开启先后的指令寻址方式对比示意图

image

这里1000的最后两位(00)表示内核特权级,与之相对的用户特权级是11;第三位的0表示GDT,若是是1,则表示LDT;1000的1表示所选的表(在此就是GDT)的1项(GDT项号排序为0项、1项、2项,这里也就是第2项)来肯定代码段的段基址和段限长等信息。从图1-23中咱们能够看到,代码是从段基址0x00000000、偏移为0处,也就是head程序的开始位置开始执行的,这意味着执行head程序。
到这里为止,setup就执行完毕了,它为系统可以在保护模式下运行作了一系列的准备工做。但这些准备工做还不够,后续的准备工做将由head程序来完成。
1.3.5 head.s开始执行
在讲解head程序以前,咱们先介绍一下从bootsect到main函数执行的总体技术策略。
在执行main函数以前,先要执行三个由汇编代码生成的程序,即bootsect、setup和head。以后,才执行由main函数开始的用C语言编写的操做系统内核程序。
前面咱们讲过,第一步,加载bootsect到0x07C00,而后复制到0x90000;第二步,加载setup到0x90200。值得注意的是,这两段程序是分别加载、分别执行的。
head程序与它们的加载方式有所不一样。大体的过程是,先将head.s汇编成目标代码,将用C语言编写的内核程序编译成目标代码,而后连接成system模块。也就是说,system模块里面既有内核程序,又有head程序。二者是紧挨着的。要点是,head程序在前,内核程序在后,因此head程序名字为“head”。head程序在内存中占有25 KB + 184 B的空间。前面讲解过,system模块加载到内存后,setup将system模块复制到0x00000位置,因为head程序在system的前面,因此实际上,head程序就在0x00000这个位置。head程序、以main函数开始的内核程序在system模块中的布局示意图如图1-24所示。

image

head程序除了作一些调用main的准备工做以外,还作了一件对内核程序在内存中的布局及内核程序的正常运行有重大意义的事,就是用程序自身的代码在程序自身所在的内存空间建立了内核分页机制,即在0x000000的位置建立了页目录表、页表、缓冲区、GDT、IDT,并将head程序已经执行过的代码所占内存空间覆盖。这意味着head程序本身将本身废弃,main函数即将开始执行。
以上就是head程序执行过程的总体策略。咱们参照这个策略,看看head到底是怎么执行的。
在讲解head程序执行以前,咱们先来关注一个标号:_pg_dir,以下面的代码(boot/head.s)所示:

//代码路径:boot/head.s
    …
.text
.globl _idt,_gdt,_pg_dir,_tmp_floppy_area
_pg_dir:
startup_32:
movl     $0x10,%eax
    mov         %ax,%ds
    mov         %ax,%es
    mov         %ax,%fs
    mov         %ax,%gs
    …

标号_pg_dir标识内核分页机制完成后的内核起始位置,也就是物理内存的起始位置0x000000。head程序立刻就要在此处创建页目录表,为分页机制作准备。这一点很是重要,是内核可以掌控用户进程的基础之一,后续章节将逐步讲解。图1-25中描述了页目录表在内存中所占的位置。

image

如今head程序正式开始执行,一切都是为适应保护模式作准备。在图1-25中,其本质就是让CS的用法从实模式转变到保护模式。在实模式下,CS自己就是代码段基址。在保护模式下,CS自己不是代码段基址,而是代码段选择符。经过对图1-25的分析得知,jmpi 0, 8这句代码使CS和GDT的第2项关联,而且使代码段基址指向0x000000。
从如今开始,要将DS、ES、FS和GS等其余寄存器从实模式转变到保护模式。执行代码以下:

//代码路径:boot/head.s
    …
startup_32:
    movl     $0x10,%eax
    mov     %ax,%ds
    mov     %ax,%es
    mov     %ax,%fs
    mov     %ax,%gs
    …

执行完毕后,DS、ES、FS和GS中的值都成为0x10。与前面提到的jmpi 0, 8中的8的分析方法相同,0x10也应当作二进制的00010000,最后三位与前面讲解的同样,其中最后两位(00)表示内核特权级,从后数第3位(0)表示选择GDT,第四、5两位(10)是GDT的2项,也就是第3项。也就是说,4个寄存器用的是同一个全局描述符,它们的段基址、段限长、特权级都是相同的。特别要注意的是,影响段限长的关键字段的值是0x7FF,段限长就是8 MB。
图1-26给出了详细示意。

image

具体的设置方式与图1-23相似,都要参考GDT中的内容。上述代码中的movl $0x10,%eax中的0x10是GDT中的偏移值(用二进制表示就是10000),即要参考GDT中第2项的信息(GDT项号排序为第0项、第1项、第2项)来设置这些段寄存器,这一项就是内核数据段描述符。
点评
各段重叠,这样的编码操做方式须要头脑很是清楚!
SS如今也要转变为栈段选择符,栈顶指针也成为32位的esp,以下所示。
lss _stack_start,%esp
在kernel/sched.c中,stack_start = { & user_stack [PAGE_SIZE>>2] , 0x10 }这行代码将栈顶指针指向user_stack数据结构的最末位置。这个数据结构是在kernel/sched.c中定义的,以下所示:
long user_stack [ PAGE_SIZE>>2 ]
咱们测算出其起始位置为0x1E25C。
小贴士
设置段寄存器指令(Load Segment Instruction):该组指令的功能是把内存单元的一个“低字”传送给指令中指定的16位寄存器,把随后的一个“高字”传给相应的段寄存器(DS、ES、FS、GS和SS)。其指令格式以下:
LDS/LES/LFS/LGS/LSS  Mem, Reg
指令LDS(Load Data Segment Register)和LES(Load Extra Segment Register)在8086 CPU中就存在,而LFS和LGS、LSS(Load Stack Segment Register)是80386及其之后CPU中才有的指令。若Reg是16位寄存器,则Mem必须是32位指针;若Reg是32位寄存器,则Mem必须是48位指针,其低32位给指令中指定的寄存器,高16位给指令中的段寄存器。
0x10将SS设置为与前面4个段选择符的值相同。这样SS与前面讲解过的4个段选择符相同,段基址都是指向0x000000,段限长都是8 MB,特权级都是内核特权级,后面的压栈动做就要在这里进行。
特别值得一提的是,如今刚刚从实模式转变到保护模式,段基址的使用方法和实模式差异很是大,要使用GDT产生段基址,前面讲到的那几行设置段选择符的指令自己都是要用GDT寻址的。如今就能清楚地看出,若是没有setup程序在16位实模式下模拟32位保护模式而建立的GDT,恐怕前面这几行指令都没法执行。
注意,栈顶的增加方向是从高地址向低地址的,参见图1-27。注意栈段基址和ESP在图中的位置。

image

咱们如今回忆一下图1-8中对栈顶指针的设置,那时候是设置SP,而这时候是设置ESP,多加了一个字母E,这是为适应保护模式而作的调整。这段内容对应的代码以下:

//代码路径:boot/head.s
lss _stack_start, %esp

head程序接下来对IDT进行设置,代码以下:

//代码路径:boot/head.s
startup_32:
    movl     $0x10,%eax
    mov     %ax,%ds
    mov     %ax,%es
    mov     %ax,%fs
    mov     %ax,%gs
    lss     _stack_start,%esp
    call     setup_idt
    call     setup_gdt
    …
setup_idt:
    lea     ignore_int,%edx
    movl     $0x00080000,%eax    /*8应该当作1000,这个值在第2章初始化IDT时会用到
    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
    …
.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
    …
.align 2
.word 0
idt_descr:
    .word 256*8-1            # idt contains 256 entries
    .long _idt
    …
.align 3
_idt:    .fill 256,8,0        # idt is uninitialized
    …

小贴士
一个中断描述符的结构如图1-28所示。

image

中断描述符为64位,包含了其对应中断服务程序的段内偏移地址(OFFSET)、所在段选择符(SELECTOR)、描述符特权级(DPL)、段存在标志(P)、段描述符类型(TYPE)等信息,供CPU在程序中须要进行中断服务时找到相应的中断服务程序。其中,第0~15位和第48~63位组合成32位的中断服务程序的段内偏移地址(OFFSET);第16~31位为段选择符(SELECTOR),定位中断服务程序所在段;第47位为段存在标志(P),用于标识此段是否存在于内存中,为虚拟存储提供支持;第45~46位为特权级标志(DPL),特权级范围为0~3;第40~43位为段描述符类型标志(TPYE),中断描述符对应的类型标志为0111(0xE),即将此段描述符标记为“386中断门”。
这是重建保护模式下中断服务体系的开始。程序先让全部的中断描述符默认指向ignore_int这个位置(未来main函数里面还要让中断描述符对应具体的中断服务程序),以后还要对IDT寄存器的值进行设置。图1-29显示了具体的操做状态。

image

点评
构造IDT,使中断机制的总体架构先搭建起来(实际的中断服务程序挂接则在main函数中完成) ,并使全部中断服务程序指向同一段只显示一行提示信息就返回的服务程序。从编程技术上讲,这种初始化操做,既能够防止无心中覆盖代码或数据而引发的逻辑混乱,也能够对开发过程当中的误操做给出及时的提示。IDT有256个表项,实际只使用了几十个,对于误用未使用的中断描述符,这样的提示信息能够提醒开发人员注意错误。
如今,head程序要废除已有的GDT,并在内核中的新位置从新建立GDT,如图1-30所示。其中第2项和第3项分别为内核代码段描述符和内核数据段描述符,其段限长均被设置为16 MB,并设置GDTR的值。

image

代码以下:

//代码路径:boot/head.s
    …
startup_32:
    movl     $0x10,%eax
    mov     %ax,%ds
    mov     %ax,%es
    mov     %ax,%fs
    mov     %ax,%gs
    lss     _stack_start,%esp
    call     setup_idt
    call     setup_gdt
    …
setup_gdt:
    lgdt     gdt_descr
    ret
    …
.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 3
_idt:    .fill 256,8,0    # idt is uninitialized

_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 */

点评
为何要废除原来的(GDT)而从新设置一套GDT呢?
原来GDT所在的位置是设计代码时在setup.s里面设置的数据,未来这个setup模块所在的内存位置会在设计缓冲区时被覆盖。若是不改变位置,未来GDT的内容确定会被缓冲区覆盖掉,从而影响系统的运行。这样一来,未来整个内存中惟一安全的地方就是如今head.s所在的位置了。
那么有没有可能在执行setup程序时直接把GDT的内容复制到head.s所在的位置呢?确定不能。若是先复制GDT的内容,后移动system模块,它就会被后者覆盖;若是先移动system模块,后复制GDT的内容,它又会把head.s对应的程序覆盖,而这时head.s尚未执行。因此,不管如何,都要从新创建GDT。
GDT的位置和内容发生了变化,特别要注意最后的三位是FFF,说明段限长不是原来的8 MB,而是如今的16 MB。若是后面的代码第一次使用这几个段选择符,就是访问8 MB之后的地址空间,将会产生段限长超限报警。为了防止这类可能发生的状况,这里再次对一些段选择符进行从新设置,包括DS、ES、FS、GS及SS,方法与图1-26相似,主要是段限长增长了一倍,变为16 MB。上述过程如图1-31所示。

image

调整DS、ES等寄存器的代码以下:

//代码路径:boot/head.s
    …
    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
    …

如今,栈顶指针esp指向user_stack数据结构的外边缘,也就是内核栈的栈底。这样,当后面的程序须要压栈时,就能够最大限度地使用栈空间。栈顶的增加方向是从高地址向低地址的,如图1-32所示。设置esp的代码以下:

//代码路径:boot/head.s
    …
    lss     _stack_start,%esp
    …

image

由于A20地址线是否打开影响保护模式是否有效,因此,要检验A20地址线是否确实打开了。图1-33给出了直观的标示。

image

检验A20是否打开的代码以下:

//代码路径:boot/head.s
    …
    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若是没打开,则计算机处于20位的寻址模式,超过0xFFFFF寻址必然“回滚”。一个特例是0x100000会回滚到0x000000,也就是说,地址0x100000处存储的值必然和地址0x000000处存储的值彻底相同(参见对图1-31的描述)。经过在内存0x000000位置写入一个数据,而后比较此处和1 MB(0x100000,注意,已超过实模式寻址范围)处数据是否一致,就能够检验A20地址线是否已打开。
肯定A20地址线已经打开以后,head程序若是检测到数学协处理器存在,则将其设置为保护模式工做状态,如图1-34所示。
小贴士
x87协处理器:为了弥补x86系列在进行浮点运算时的不足,Intel于1980年推出了x87系列数学协处理器,那时是一个外置的、可选的芯片(笔者当时的80386计算机上就没安装80387协处理器)。1989年,Intel发布了486处理器。自从486开始,之后的CPU通常都内置了协处理器。这样,对于486之前的计算机而言,操做系统检验x87协处理器是否存在就很是必要了。
检测数学协处理器对应的代码以下:

//代码路径:boot/head.s
    …
    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

/*
 * We depend on ET to be correct. This checks for 287/387.
 */
check_x87:
    fninit
    fstsw     %ax
    cmpb     $0,%al
    je 1f            /* no coprocessor: have to set bits */
    movl     %cr0,%eax
    xorl     $6,%eax        /* reset MP, set EM */
    movl     %eax,%cr0
    ret
.align 2
1:    .byte 0xDB,0xE4    /* fsetpm for 287, ignored by 387 */
    Ret
    …

head程序将为调用main函数作最后的准备。这是head程序执行的最后阶段,也是main函数执行前的最后阶段。具体如图1-35所示。

image

head程序将L6标号和main函数入口地址压栈,栈顶为main函数地址,目的是使head程序执行完后经过ret指令就能够直接执行main函数。具体如图1-36所示。

image

点评
main函数在正常状况下是不该该退出的。若是main函数异常退出,就会返回这里的标号L6处继续执行,此时,还能够作一些系统调用……另外有意思的是,即便main函数退出了,若是还有进程存在,仍然可以进行轮转。
执行代码以下:

//代码路径:boot/head.s
    …
    orl     $2,%eax        # set MP
    movl     %eax,%cr0
    call     check_x87
    jmp     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
    …

这些压栈动做完成后,head程序将跳转至setup_paging:去执行,开始建立分页机制。
先要将页目录表和4个页表放在物理内存的起始位置,从内存起始位置开始的5页空间内容所有清零(每页4 KB),为初始化页目录和页表作准备。注意,这个动做起到了用1个页目录表和4个页表覆盖head程序自身所占内存空间的做用。图1-37给出了直观的标示。

image

点评
将页目录表和4个页表放在物理内存的起始位置,这个动做的意义重大,是操做系统可以掌控全局、掌控进程在内存中安全运行的基石之一,后续章节会逐步论述。
head程序将页目录表和4个页表所占物理内存空间清零后,设置页目录表的前4项,使之分别指向4个页表,如图1-38所示。

image

head程序设置完页目录表后,Linux 0.11在保护模式下支持的最大寻址地址为0xFFFFFF(16 MB),此处将第4个页表(由pg3指向的位置)的最后一个页表项(pg3 + 4902指向的位置)指向寻址范围的最后一个页面,即0xFFF000开始的4 KB字节大小的内存空间。具体请看图1-39的标示。

image

而后开始从高地址向低地址方向填写4个页表,依次指向内存从高地址向低地址方向的各个页面。图1-39所示是首次设置页表。
继续设置页表。将第4个页表(由pg3指向的位置)的倒数第二个页表项(pg3-4 + 4902指向的位置)指向倒数第二个页面,即0xFFF000~0x1000(0x1000即4 KB,一个页面的大小)开始的4 KB字节内存空间。请读者认真对比图1-40和图1-39,有多处位置发生了变化。

image

最终,从高地址向低地址方向完成4个页表的填写,页表中的每个页表项分别指向内存从高地址向低地址方向的各个页面,如图1-41所示。其整体效果如图1-42所示。

image

这4个页表都是内核专属的页表,未来每一个用户进程都会有它们专属的页表。对于二者在寻址范围方面的区别,咱们将在用户进程与内存管理一章中详细介绍。
图1-39~图1-41中发生动做的相应代码以下:

//代码路径:boot/head.s
    …
.align 2
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
/*下面几行中的7应当作二进制的111,是页属性,表明u/s、r/w、present,
111表明:用户u、读写rw、存在p,000表明:内核s、只读r、不存在  */
    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
    …

这些工做完成后,内存中的布局如图1-43所示。能够看出,只有184字节的剩余代码。因而可知,在设计head程序和system模块时,其计算是很是精确的,对head.s的代码量的控制很是到位。
head程序已将页表设置完毕了,但分页机制的创建尚未完成,还须要设置页目录表基址寄存器CR3,使之指向页目录表,再将CR0寄存器设置的最高位(31位)置为1,如图1-44所示。

image

image

小贴士
PG(Paging)标志:CR0寄存器的第31位,分页机制控制位。当CPU的控制寄存器CR0第0位PE(保护模式)置为1时,可设置PG位为开启。当开启后,地址映射模式采起分页机制。当CPU的控制寄存器CR0第0位PE(保护模式)置为0时,设置PG位将引发CPU发生异常。
CR3寄存器:3号32位控制寄存器,其高20位存放页目录表的基地址。当CR0中的PG标志置位时,CPU使用CR3指向的页目录表和页表进行虚拟地址到物理地址的映射。
执行代码以下:

//代码路径:boot/head.s
    …
    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 */
    …

前两行代码的动做是将CR3指向页目录表,意味着操做系统认定0x0000这个位置就是页目录表的起始位置;后3行代码的动做是启动分页机制开关PG标志置位,以启用分页寻址模式。两个动做一鼓作气。到这里为止,内核的分页机制构建完毕。后续章节还会讲解如何创建用户进程的分页机制。
最重要的是下面这一行代码。它看似简单,但用意深远。

xorl     %eax,%eax         /* pg_dir is at 0x0000 */

回过头来看,图1-17将system模块移动到0x00000处,图1-25在内存的起始位置创建内核分页机制,最后就是上面的这行代码,认定页目录表在内存的起始位置。三个动做联合起来为操做系统中最重要的目的——内核控制用户程序奠基了基础。这个位置是内核经过分页机制可以实现线性地址等于物理地址的惟一块儿始位置。咱们会在后续章节逐层展开讨论。
head程序执行最后一步:ret。这要经过跳入main函数程序执行。
在图1-36中,main函数的入口地址被压入了栈顶。如今执行ret了,正好将压入的main函数的执行入口地址弹出给EIP。图1-45标示了出栈动做。

image

这部分代码用了底层代码才会使用的技巧。咱们结合图1-45对这个技巧进行详细讲解。
咱们先看看普通函数的调用和返回的方法。由于Linux 0.11 用返回方法调用main函数,返回位置和main函数的入口在同一段内,所示咱们只讲解段内调用和返回,如图1-46(仿call示意图)所示。
call指令会将EIP的值自动压栈,保护返回现场,而后执行被调函数的程序。等到执行被调函数的ret指令时,自动出栈给EIP并还原现场,继续执行call的下一行指令。这是一般的函数调用方法。对操做系统的main函数来讲,这个方法就有些怪异了。main函数是操做系统的。若是用call调用操做系统的main函数,那么ret时返回给谁呢?难道还有一个更底层的系统程序接收操做系统的返回吗?操做系统已是最底层的系统了,因此逻辑上不成立。那么如何既调用了操做系统的main函数,又不须要返回呢?操做系统的设计者采用了图1-46(仿call示意图)所示的方法。

image


这个方法的妙处在于,是用ret实现的调用操做系统的main函数。既然是ret调用,固然就不须要再用ret了。不过,call作的压栈和跳转的动做谁来作呢?操做系统的设计者作了一个仿call的动做,手工编写代码压栈和跳转,模仿了call的所有动做,实现了调用setup_paging函数。注意,压栈的EIP值并非调用setup_paging函数的下一行指令的地址,而是操做系统的main函数的执行入口地址_main。这样,当setup_paging函数执行到ret时,从栈中将操做系统的main函数的执行入口地址_main自动出栈给EIP,EIP指向main函数的入口地址,实现了用返回指令“调用”main函数。
在图1-46中,将压入的main函数的执行入口地址弹出给CS:EIP,这句话等价于CPU开始执行main函数程序。图1-47标示了这个状态。

image

点评为何没有最早调用main函数?学过C语言的人都知道,用C语言设计的程序都有一个main函数,并且是从main函数开始执行的。Linux 0.11的代码是用C语言编写的。奇怪的是,为何在操做系统启动时先执行的是三个由汇编语言写成的程序,而后才开始执行main函数;为何不是像咱们熟知的C语言程序那样,从main函数开始执行呢。一般,咱们用C语言编写的程序都是用户应用程序。这类程序的执行有一个重要的特征,就是必须在操做系统的平台上执行,也就是说,要由操做系统为应用程序建立进程,并把应用程序的可执行代码从硬盘加载到内存。如今咱们讨论的是操做系统,不是普通的应用程序,这样就出现了一个问题:应用程序是由操做系统加载的,操做系统该由谁加载呢?从前面的节中咱们知道,加载操做系统的时候,计算机刚刚加电,只有BIOS程序在运行,并且此时计算机处在16位实模式状态,经过BIOS程序自身的代码造成的16位的中断向量表及相关的16位的中断服务程序,将操做系统在软盘上的第一扇区(512字节)的代码加载到内存,BIOS能主动操做的内容也就到此为止了。准确地说,这是一个约定。对于第一扇区代码的加载,不管是什么操做系统都是同样的;从第二扇区开始,就要由第一扇区中的代码来完成后续的代码加载工做。当加载工做完成后,好像仍然没有当即执行main函数,而是打开A20,打开pe、pg,创建IDT、GDT……而后才开始执行main函数,这是什么道理?缘由是,Linux 0.11是一个32位的实时多任务的现代操做系统,main函数确定要执行的是32位的代码。编译操做系统代码时,是有16位和32位不一样的编译选项的。若是选了16位,C语言编译出来的代码是16位模式的,结果多是一个int型变量,只有2字节,而不是32位的4字节……这不是Linux 0.11想要的。Linux 0.11要的是32位的编译结果。只有这样才能成为32位的操做系统代码。这样的代码才能用到32位总线(打开A20后的总线),才能用到保护模式和分页,才能成为32位的实时多任务的现代操做系统。开机时的16位实模式与main函数执行须要的32位保护模式之间有很大的差距,这个差距谁来填补? head.s作的就是这项工做。这期间,head程序打开A20,打开pe、pg,废弃旧的、16位的中断响应机制,创建新的32位的IDT……这些工做都作完了,计算机已经处在32位的保护模式状态了,调用32位main函数的一切条件已经准备完毕,这时瓜熟蒂落地调用main函数。后面的操做就能够用32位编译的main函数完成。至此,Linux 0.11内核启动的一个重要阶段已经完成,接下来就要进入main函数对应的代码了。特别须要提示的是,此时仍处在关闭中断的状态!