《Linux内核设计的艺术:图解Linux操作系统架构设计与实现原理》——1.2 加载操作系统内核程序并为保护模式做准备-阿里云开发者社区

开发者社区> 华章出版社> 正文
登录阅读全文

《Linux内核设计的艺术:图解Linux操作系统架构设计与实现原理》——1.2 加载操作系统内核程序并为保护模式做准备

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

1.2 加载操作系统内核程序并为保护模式做准备

从现在开始,就要执行真正的boot操作了,即把软盘中的操作系统程序加载至内存。对于Linux 0.11操作系统而言,计算机将分三批逐次加载操作系统的内核代码。第一批由BIOS中断int 0x19把第一扇区bootsect的内容加载到内存;第二批、第三批在bootsect的指挥下,分别把其后的4个扇区和随后的240个扇区的内容加载至内存。
1.2.1 加载第一部分内核代码——引导程序(bootsect)
按照我们使用计算机的经验,如果在开机的时候马上按Del键,屏幕上会显示一个BIOS画面,可以在里面设置启动设备。现在我们基本上都是将硬盘设置为启动盘。Linux 0.11是1991年设计的操作系统,那时常用的启动设备是软驱以及其中的软盘。站在体系结构的角度看,从软盘启动和从硬盘启动的基本原理和机制是类似的。
经过执行一系列BIOS代码之后,计算机完成了自检等操作(这些和我们讲的启动操作系统没有直接的关系,读者不必关心)。由于我们把软盘设置为启动设备,计算机硬件体系结构的设计与BIOS联手操作,会让CPU接收到一个int 0x19中断。CPU接收到这个中断后,会立即在中断向量表中找到int 0x19中断向量。我们在图1-3的左下方可以看到int 0x19中断向量在内存中所在的准确位置,这个位置几乎紧挨着内存的0x00000位置。
接下来,中断向量把CPU指向0x0E6F2,这个位置就是int 0x19相对应的中断服务程序的入口地址,即图1-3所示的“启动加载服务程序”的入口地址。这个中断服务程序的作用就是把软盘第一扇区中的程序(512 B)加载到内存中的指定位置。这个中断服务程序的功能是BIOS事先设计好的,代码是固定的,与Linux操作系统无关。无论Linux 0.11的内核是如何设计的,这段BIOS程序所要做的就是“找到软盘”并“加载第一扇区”,其余的它什么都不知道,也不必知道。
小贴士
中断向量表(Interrupt Vector Table):实模式中断机制的重要组成部分,记录所有中断号对应的中断服务程序的内存地址。
中断服务(Interrupt Service)程序:通过中断向量表的索引对中断进行响应服务,是一些具有特定功能的程序。

image

按照这个简单、“生硬”的规则,int 0x19中断向量所指向的中断服务程序,即启动加载服务程序,将软驱0号磁头对应盘面的0磁道1扇区的内容复制至内存0x07C00处。我们可以在图1-4的左边看到第一扇区加载的具体位置。

image

这个扇区里的内容就是Linux 0.11的引导程序,也就是我们将要讲解的bootsect,其作用就是陆续把软盘中的操作系统程序载入内存。这样制作的第一扇区就称为启动扇区(boot sector)。第一扇区程序的载入,标志着Linux 0.11中的代码即将发挥作用了。
这是非常关键的动作,从此计算机开始和软盘上的操作系统程序产生关联。第一扇区中的程序由bootsect.s中的汇编程序汇编而成(以后简称bootsect)。这是计算机自开机以来,内存中第一次有了Linux操作系统自己的代码,虽然只是启动代码。
至此,已经把第一批代码bootsect从软盘载入计算机的内存了。下面的工作就是执行bootsect把软盘的第二批、第三批代码载入内存。
点评
注意:BIOS程序固化在主机板上的ROM中,是根据具体的主机板而不是根据具体的操作系统设计的。
理论上,计算机可以安装任何适合其安装的操作系统,既可以安装Windows,也可以安装Linux。不难想象每个操作系统的设计者都可以设计出一套自己的操作系统启动方案,而操作系统和BIOS通常是由不同的专业团队设计和开发的,为了能协同工作,必须建立操作系统和BIOS之间的协调机制。
与已有的操作系统建立一一对应的协调机制虽然麻烦,但尚有可能,难点在于与未来的操作系统应该如何建立协调机制。现行的方法是“两头约定”和“定位识别”。
对操作系统(这里指Linux 0.11)而言,“约定”操作系统的设计者必须把最开始执行的程序“定位”在启动扇区(软盘中的0盘面0磁道1扇区),其余的程序可以依照操作系统的设计顺序加载在后续的扇区中。
对BIOS而言,“约定”接到启动操作系统的命令,“定位识别”只从启动扇区把代码加载到0x07C00 (BOOTSEG)这个位置(参见Seabios 0.6.0/Boot.c文件中的boot_disk函数)。至于这个扇区中是否是启动程序、是什么操作系统,则不闻不问、一视同仁。如果不是启动代码,只会提示错误,其余是用户的责任,与BIOS无关。
这样构建协调机制的好处是站在整个体系的高度,统一设计、统一安排,简单、有效。只要BIOS和操作系统的生产厂商开发的所有系统版本全部遵循此机制的约定,就可以各自灵活地设计出具有自己特色的系统版本。
1.2.2 加载第二部分内核代码——setup
1.bootsect对内存的规划
BIOS已经把bootsect也就是引导程序载入内存了,现在它的作用就是把第二批和第三批程序陆续加载到内存中。为了把第二批和第三批程序加载到内存中的适当位置,bootsect首先做的工作就是规划内存。
通常,我们是用高级语言编写应用程序的,这些程序是在操作系统的平台上运行的。我们只管写高级语言的代码、数据。至于这些代码、数据在运行的时候放在内存的什么地方,是否会相互覆盖,我们都不用操心,因为操作系统和高级语言的编译器替我们做了大量的看护工作,确保不会出错。现在我们讨论的是,操作系统本身使用的是汇编语言,没有高级语言编译器替操作系统提供保障,只有靠操作系统的设计者把内存的安排想清楚,确保无论操作系统如何运行,都不会出现代码与代码、数据与数据、代码与数据之间相互覆盖的情况。为了更准确地理解操作系统的运行机制,我们必须清楚操作系统的设计者是如何规划内存的。
在实模式状态下,寻址的最大范围是1 MB。为了规划内存,bootsect首先设计了如下代码:

//代码路径:boot/bootsect.s
    …
.globl begtext, begdata, begbss, endtext, enddata, endbss
.text
begtext:
.data
begdata:
.bss
begbss:
.text

SETUPLEN= 4                ! nr of setup-sectors
BOOTSEG = 0x07c0                ! original address of boot-sector
INITSEG = 0x9000                ! we move boot here-out of the way
SETUPSEG= 0x9020                ! setup starts here
SYSSEG  = 0x1000                ! system loaded at 0x10000 (65536).
ENDSEG  = SYSSEG + SYSSIZE        ! where to stop loading

! ROOT_DEV:0x000 - same type of floppy as boot.
!        0x301 - first partition on first drive etc
ROOT_DEV= 0x306
…

这些源代码的作用就是对后续操作所涉及的内存位置进行设置,包括将要加载的setup程序的扇区数(SETUPLEN)以及被加载到的位置(SETUPSEG);启动扇区被BIOS加载的位置(BOOTSEG)及将要移动到的新位置(INITSEG);内核(kernel)被加载的位置(SYSSEG)、内核的末尾位置(ENDSEG)及根文件系统设备号(ROOT_DEV)。这些位置在图1-5中都被明确地标注了出来。设置这些位置就是为了确保将要载入内存的代码与已经载入内存的代码及数据各在其位,互不覆盖,并且各自有够用的内存空间。大家在后续的章节会逐渐看到内存规划的意义和作用。

image

从现在起,我们的头脑中要时刻牢记这样一个概念:操作系统的设计者是要全面地、整体地考虑内存的规划的。我们会在后续的章节中不断地了解到,精心安排内存是操作系统设计者时时刻刻都要关心的事。我们带着这样的观念继续了解bootsect程序的执行。
2.复制bootsect
接下来,bootsect启动程序将它自身(全部的512 B内容)从内存0x07C00(BOOTSEG)处复制至内存0x90000(INITSEG)处。这个动作和目标位置如图1-6所示。

image

执行这个操作的代码(boot/bootsect.s)如下:

//代码路径:boot/bootsect.s
…
entry start
start:
    mov        ax,#BOOTSEG
    mov        ds,ax
    mov        ax,#INITSEG
    mov        es,ax
    mov        cx,#256
    sub        si,si
    sub        di,di
    rep
    movw
…

在这次复制过程中,ds(0x07C0)和si(0x0000)联合使用,构成了源地址0x07C00;es(0x9000)和di(0x0000)联合使用,构成了目的地址0x90000(见图1-6),而mov cx, #256这一行循环控制量,提供了需要复制的“字”数(一个字为2字节,256个字正好是512字节,也就是第一扇区的字节数)。
通过代码我们还可以看出,图1-5提到的BOOTSEG和INITSEG现在开始发挥作用了。注意,此时CPU的段寄存器(CS)指向0x07C0 (BOOTSEG),即原来bootsect程序所在的位置。
点评
由于“两头约定”和“定位识别”,所以在开始时bootsect“被迫”加载到0x07C00位置。现在将自身移至0x90000处,说明操作系统开始根据自己的需要安排内存了。
bootsect复制到新位置完毕后,会执行下面的代码:

//代码路径:boot/bootsect.s
    …
    rep
    movw
    jmpi go, INITSEG
go:    mov ax,cs
    mov ds,ax
    …

从图1-6中我们已经了解到当时CS的值为0x07C0,执行完这个跳转后,CS值变为0x9000 (INITSEG),IP的值为从0x9000 (INITSEG)到go: mov ax, cs 这一行对应指令的偏移。换句话说,此时CS:IP指向go: mov ax, cs这一行,程序从这一行开始往下执行。图1-7形象地表示了跳转到go: mov ax, cs这一行执行时CS和IP的状态,如图右下方所示。

image

此前的0x07C00这个位置是根据“两头约定”和“定位识别”而确定的。从现在起,操作系统已经不需要完全依赖BIOS,可以按照自己的意志把自己的代码安排在内存中自己想要的位置。
点评

jmpi go, INITSEG 
go: mov ax, cs

这两行代码写得很巧。复制bootsect完成后,在内存的0x07C00和0x90000位置有两段完全相同的代码。请大家注意,复制代码这件事本身也是要靠指令执行的,执行指令的过程就是CS和IP不断变化的过程。执行到jmpi go, INITSEG这行之前,代码的作用就是复制代码自身;执行了jmpi go, INITSEG之后,程序就转到执行0x90000这边的代码了。Linus的设计意图是想跳转之后,在新位置接着执行后面的mov ax, cs,而不是死循环。jmpi go, INITSEG与go: mov ax, cs配合,巧妙地实现了 “到新位置后接着原来的执行序继续执行下去”的目的。
bootsect复制到了新的地方,并且要在新的地方继续执行。因为代码的整体位置发生了变化,所以代码中的各个段也会发生变化。前面已经改变了CS,现在对DS、ES、SS和SP进行调整。我们看看下面的代码:

//代码路径:boot/bootsect.s
    …
go:    mov         ax, cs
    mov         ds, ax
    mov         es, ax
! put stack at 0x9ff00.
    mov         ss, ax
    mov         sp, #0xFF00    ! arbitrary value >>512
! load the setup-sectors directly after the bootblock.
! Note that 'es' is already set up.
    …

上述代码的作用是通过ax,用CS的值0x9000来把数据段寄存器(DS)、附加段寄存器(ES)、栈基址寄存器(SS)设置成与代码段寄存器(CS)相同的位置,并将栈顶指针SP指向偏移地址为0xFF00处。图1-8对此做了非常直观的描述。

image

下面着重介绍一下与栈操作相关的寄存器的设置。SS和SP联合使用,就构成了栈数据在内存中的位置值。对这两个寄存器的设置为后面程序的栈操作(如push、pop等)打下了基础。
现在可以观察一下bootsect中的程序,在执行设置SS和SP的代码之前,没有出现过栈操作指令,而在此之后就陆续使用。这里对SS和SP进行的设置是分水岭。它标志着从现在开始,程序可以执行一些更为复杂的数据运算类指令了。
栈操作是有方向的。图1-8中标识了压栈的方向,注意是由高地址到低地址的方向。
小贴士
DS/ES/FS/GS/SS:这些段寄存器存在于CPU中,其中SS(Stack Segment)指向栈段,此区域将按栈机制进行管理。
SP(Stack Pointer):栈顶指针寄存器,指向栈段的当前栈顶。
注意:很多计算机书上使用“堆栈”这个词。本书用堆、栈表示两个概念。栈表示stack,特指在C语言程序的运行时结构中,以“后进先出”机制运作的内存空间;堆表示heap,特指用C语言库函数malloc创建、free释放的动态内存空间。
至此,bootsect的第一步操作,即规划内存并把自身从0x07C00的位置复制到0x90000的位置的动作已经完成了。
3.将setup程序加载到内存中
下面,bootsect程序要执行它的第二步工作:将setup程序加载到内存中。
加载setup这个程序,要借助BIOS提供的int 0x13中断向量所指向的中断服务程序(也就是磁盘服务程序)来完成。图1-9标注了int 0x13中断向量的位置以及这个中断向量所指向的磁盘服务程序的入口位置。

image

这个中断服务程序的执行过程与图1-3和图1-4中讲解过的int 0x19中断向量所指向的启动加载服务程序不同。
int 0x19中断向量所指向的启动加载服务程序是BIOS执行的,而int 0x13的中断服务程序是Linux操作系统自身的启动代码bootsect执行的。
int 0x19的中断服务程序只负责把软盘的第一扇区的代码加载到0x07C00位置,而
int 0x13的中断服务程序则不然,它可以根据设计者的意图,把指定扇区的代码加载到内存的指定位置。
针对服务程序的这个特性,使用int 0x13中断时,就要事先将指定的扇区、加载的内存位置等信息传递给服务程序,即传参。执行代码如下:

//代码路径:boot/bootsect.s
    …                        !注意:SETUPLEN为4
load_setup:
    mov         dx, #0x0000            ! 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
    mov         dx, #0x0000
    mov         ax, #0x0000            ! reset the diskette
    int        0x13
    j        load_setup
    …

从代码开始处的4个mov指令可以看出,系统给BIOS中断服务程序传参是通过几个通用寄存器实现的。这是汇编程序的常用方法,与C语言的函数调用形式有很大不同。
参数传递完毕后,执行int 0x13指令,产生0x13中断,通过中断向量表找到这个中断服务程序,将软盘第二扇区开始的4个扇区,即setup.s对应的程序加载至内存的SETUPSEG(0x90200)处。根据对图1-5的讲解,复制后的bootsect的起始位置是0x90000,占用512字节的内存空间。不难看出0x90200紧挨着bootsect的尾端,所以bootsect和setup是连在一起的。
图1-10表示了软盘中所要加载的扇区位置和扇区数,以及载入内存的目标位置和占用空间。
现在,操作系统已经从软盘中加载了5个扇区的代码。等bootsect执行完毕后,setup这个程序就要开始工作了。

image

注意,图1-8中SS:SP指向的位置为0x9FF00,这与setup程序的起始位置0x90200还有很大的距离,即便setup加载进来后,系统仍然有足够的内存空间用来执行数据压栈操作;而且,在启动部分,要压栈的数据毕竟也是有限的。大家在后续的章节中会逐渐体会到,设计者在此是进行过精密测算的。
1.2.3 加载第三部分内核代码——system模块
第二批代码已经载入内存,现在要加载第三批代码。仍然使用BIOS提供的int 0x13中断,如图1-11所示,方法与图1-9所示的方法基本相同。

image

接下来,bootsect程序要执行第三批程序的载入工作,即将系统模块载入内存。
这次载入从底层技术上看,与前面的setup程序的载入没有本质的区别。比较突出的特点是这次加载的扇区数是240个,足足是之前的4个扇区的60倍,所需时间也是几十倍。为了防止加载期间用户误认为是机器故障而执行不适当的操作,Linus在此设计了显示一行屏幕信息“Loading system ...”以提示用户计算机此时正在加载系统。值得注意的是,此时操作系统的main函数还没有开始执行,在屏幕上显示一行字符串远没有用C语言写一句printf("Loading system ...n")调用那么容易,所有工作都要靠一行一行的汇编代码来实现。从体系结构的角度看,显示器也是一个外设,所以还要用到其他BIOS中断。这些代码比较多,对理解操作系统的启动原理没有特别直接的帮助,只要知道大意就可以了。我们真正需要掌握的是,bootsect借着BIOS中断int 0x13,将240个扇区的system模块加载进内存。加载工作主要是由bootsect调用read_it子程序完成的。这个子程序将软盘第六扇区开始的约240个扇区的system模块加载至内存的SYSSEG(0x10000)处往后的120 KB空间中。
图1-12对system模块所占用的内存空间给出了形象的说明。

image

由于是长时间操作软盘,所以需要对软盘设备进行更多的监控,对读盘结果不断地进行检测。因此read_it后续的调用步骤比较多一些。但读盘工作最终是由0x13对应的中断服务程序完成的。
到此为止,第三批程序已经加载完毕,整个操作系统的代码已全部加载至内存。bootsect的主体工作已经做完了,还有一点小事,就是要再次确定一下根设备号,如图1-13所示。

image

小贴士
根文件系统设备(Root Device):Linux 0.11使用Minix操作系统的文件系统管理方式,要求系统必须存在一个根文件系统,其他文件系统挂接其上,而不是同等地位。Linux 0.11没有提供在设备上建立文件系统的工具,故必须在一个正在运行的系统上利用工具(类似FDISK和Format)做出一个文件系统并加载至本机。因此Linux 0.11的启动需要两部分数据,即系统内核镜像和根文件系统。
注意:这里的文件系统指的不是操作系统内核中的文件系统代码,而是有配套的文件系统格式的设备,如一张格式化好的软盘。
因为本书假设所用的计算机安装了一个软盘驱动器、一个硬盘驱动器,在内存中开辟了2 MB的空间作为虚拟盘(见第2章的main函数),并在BIOS中设置软盘驱动器为启动盘,所以,经过一系列检测,确认计算机中实际安装的软盘驱动器为根设备,并将信息写入机器系统数据。第2章中main函数一开始就用机器系统数据中的这个信息设置根设备,并为“根文件系统加载”奠定基础。
执行代码如下:

//代码路径:boot/bootsect.s
    …
    seg cs
    mov    ax,root_dev
    cmp    ax,#0
    jne    root_defined
    seg cs
    mov    bx,sectors
    mov    ax,#0x0208    ! /dev/ps0 - 1.2Mb
    cmp    bx,#15
    je    root_defined
    mov    ax,#0x021c    ! /dev/PS0 - 1.44Mb
    cmp    bx,#18
    je    root_defined
undef_root:
    jmp     undef_root
root_defined:        ! 根据前面检测计算机中实际安装的驱动器信息,确认根设备
    seg cs
    mov    root_dev,ax
    …
.org 508            !注意:508即为0x1FC,当前段是0x9000,所以地址是0x901FC
root_dev:
    .word ROOT_DEV
boot_flag:
    .word 0xAA55
    …

现在,bootsect程序的任务都已经完成!
下面要通过执行“jmpi 0, SETUPSEG”这行语句跳转至0x90200处,就是前面讲过的第二批程序——setup程序加载的位置。CS:IP指向setup程序的第一条指令,意味着由setup程序接着bootsect程序继续执行。图1-14形象地描述了跳转到setup程序后的起始状态,对应的代码如下:

//代码路径:boot/bootsect.s
    …
jmpi 0, SETUPSEG
…

image

setup程序现在开始执行。它做的第一件事情就是利用BIOS提供的中断服务程序从设备上提取内核运行所需的机器系统数据,其中包括光标位置、显示页面等数据,并分别从中断向量0x41和0x46向量值所指的内存地址处获取硬盘参数表1、硬盘参数表2,把它们存放在0x9000:0x0080和0x9000:0x0090处。
这些机器系统数据被加载到内存的0x90000~0x901FC位置。图1-15标出了其内容及准确的位置。这些数据将在以后main函数执行时发挥重要作用。
提取机器系统数据的具体代码如下:

//代码路径:boot/setup.s
    mov     ax, #INITSEG        ! this is done in bootsect already, but...
    mov     ds, ax
    mov     ah, #0x03        ! read cursor pos
    xor     bh, bh
    int    0x10            ! save it in known place, con_init fetches
    mov    [0],dx            ! it from 0x90000.

! Get memory size (extended mem, kB)
    mov     ah, #0x88
    int    0x15
    mov     [2], ax
mov     cx, #0x10
mov     ax, #0x00
rep
stosb
…

这段代码大约70行,由于篇幅限制,我们省略了大部分代码。

image

注意,BIOS提取的机器系统数据将覆盖bootsect程序所在部分区域。这些数据由于是要留用的,所以在它们失去使用价值之前,一定不能被覆盖掉。
点评
机器系统数据所占的内存空间为0x90000~0x901FD,共510字节,即原来bootsect只有2字节未被覆盖。可见,操作系统对内存的使用是非常严谨的。在空间上,操作系统对内存严格按需使用,要加载的数据刚好占用一个扇区的位置(只差2字节),而启动扇区bootsect又恰好是一个扇区,内存的使用规划像一个账本,前后对应;在时间上,使用完毕的空间立即挪作他用,启动扇区bootsect程序刚结束其使命,执行setup时立刻就将其用数据覆盖,内存的使用率极高。虽然这与当时的硬件条件有限不无关系,但这种严谨的内存规划风格是很值得学习的。
到此为止,操作系统内核程序的加载工作已经完成。接下来的操作对Linux 0.11而言具有战略意义。系统通过已经加载到内存中的代码,将实现从实模式到保护模式的转变,使Linux 0.11真正成为“现代”操作系统。

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

分享: