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

开发者社区> 华章出版社> 正文

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

简介: 本节书摘来自华章计算机《Linux内核设计的艺术:图解Linux操作系统架构设计与实现原理》一书中的第1章,第1.3节,作者:新设计团队著, 更多章节内容可以访问云栖社区“华章计算机”公众号查看。

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

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

//代码路径: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处!代码如下:

//代码路径: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。

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。

image

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

//代码路径: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”当做6、7、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,第4、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函数对应的代码了。
特别需要提示的是,此时仍处在关闭中断的状态!

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

分享:

华章出版社

官方博客
官网链接