第1章
从开机加电到执行main函数之前的过程
从开机到main函数的执行分三步完成,目的是实现从启动盘加载操作系统程序,完成执行main函数所需要的准备工作。**步,启动BIOS,准备实模式下的中断向量表和中断服务程序;第二步,从启动盘加载操作系统程序到内存,加载操作系统程序的工作就是利用**步中准备的中断服务程序实现的;第三步,为执行32位的main函数做过渡工作。本章将详细分析这三步在计算机中是如何完成的,以及每一步在内存中都做了些什么。
小贴士
实模式(Real Mode)是Intel
80286和之后的80x86兼容CPU的操作模式(应该包括8086)。实模式的特性是一个20位的存储器地址空间(2^20 =
1?048?576,即1?MB的存储器可被寻址),可以直接软件访问BIOS以及周边硬件,没有硬件支持的分页机制和实时多任务概念。从80286开始,所有的80x86
CPU的开机状态都是实模式;8086等早期的CPU只有一种操作模式,类似于实模式。
1.1 启动BIOS,准备实模式下的中断向量表和中断服务程序
相信大家都知道一台计算机必须要安装一个所谓“操作系统”的软件,才能让我们使用计算机,否则计算机将是一堆毫无生命力的冰冷的硬家伙。在为计算机安装了操作系统后,当你按下计算机电源按钮的那一刻,计算机机箱传来了嗡嗡的声音。这时你感觉到,计算机开始启动工作了。然而,在计算机的启动过程中,操作系统底层与计算机硬件之间究竟做了哪些复杂的交互动作?下面我们将根据操作系统实际的启动和运行过程对此进行逐步的剖析和讲解。
计算机的运行是离不开程序的。然而,加电的一瞬间,计算机的内存中,准确地说是RAM中,空空如也,什么程序也没有。软盘里虽然有操作系统程序,但CPU的逻辑电路被设计为只能运行内存中的程序,没有能力直接从软盘运行操作系统。如果要运行软盘中的操作系统,必须将软盘中的操作系统程序加载到内存(RAM)中。
特别注意
我们假定本书所用的计算机是基于IA—32系列CPU,安装了标准单色显示器、标准键盘、一个软驱、一块硬盘、16
MB内存,在内存中开辟了2 MB内存作为虚拟盘,并在BIOS中设置软驱为启动设备。后续所有的讲解都以此为基础。
小贴士
RAM(Random Access
Memory):随机存取存储器,常见的内存条就是一类RAM,其特点是加电状态下可任意读、写,断电后信息消失。
问题:在RAM中什么程序也没有的时候,谁来完成加载软盘中操作系统的任务呢?
答案是:BIOS。
1.1.1 BIOS的启动原理
在了解BIOS是如何将操作系统程序加载到内存中之前,我们先来了解一下BIOS程序自身是如何启动的。从我们使用计算机的经验得知:要想执行一个程序,必须在窗口中双击它,或者在命令行界面中输入相应的执行命令。从计算机底层机制上讲,其实是在一个已经运行起来的操作系统的可视化界面或命令行界面中执行一个程序。但是,在开机加电的一瞬间,内存中什么程序也没有,没有任何程序在运行,不可能有操作系统,更不可能有操作系统的用户界面。我们无法人为地执行BIOS程序,那么BIOS程序又是由谁来执行的呢?
秘诀是:0xFFFF0 !!!
从体系的角度看,不难得出这样的结论:既然用软件方法不可能执行BIOS,就只能靠硬件方法完成了。
从硬件角度看,Intel
80x86系列的CPU可以分别在16位实模式和32位保护模式下运行。为了兼容,也为了解决*开始的启动问题,Intel
将所有80x86系列的CPU,包括*新型号的CPU的硬件都设计为加电即进入16位实模式状态运行。同时,还有一点非常关键的是,将CPU硬件逻辑设计为加电瞬间强行将CS的值置为0xF000、IP的值置为0xFFF0,这样CS:IP就指向0xFFFF0这个地址位置,如图1-1所示。从图1-1中可以清楚地看到,0xFFFF0指向了BIOS的地址范围。
小贴士
IP/EIP(Instruction
Pointer):指令指针寄存器,存在于CPU中,记录将要执行的指令在代码段内的偏移地址,和CS组合即为将要执行的指令的内存地址。实模式为**地址,指令指针为16位,即IP;保护模式下为线性地址,指令指针为32位,即EIP。
图1-1 启动时BIOS在内存的状态及初始执行位置
小贴士
CS(Code Segment
Register):代码段寄存器,存在于CPU中,指向CPU当前执行代码在内存中的区域(定义了存放代码的存储器的起始地址)。
注意,这是一个纯硬件完成的动作!如果此时这个位置没有可执行代码,那么就什么也不用说了,计算机就此死机。反之,如果这个位置有可执行代码,计算机将从这里的代码开始,沿着后续程序一直执行下去。
BIOS程序的入口地址恰恰就是0xFFFF0 ! 也就是说,BIOS程序的**条指令就设计在这个位置。
1.1.2 BIOS 在内存中加载中断向量表和中断服务程序
BIOS程序的代码量并不大,却非常精深,需要对整个计算机硬件体系结构非常熟悉才能看得明白。要想把BIOS是如何运行的讲清楚,也得写很厚的一本书,这显然超出了本书的主题和范围。我们的主题是操作系统,所以只把与启动操作系统有直接关系的部分简单地讲解一下。
BIOS程序被固化在计算机主机板上的一块很小的ROM芯片里。通常不同的主机板所用的BIOS也有所不同。就启动部分而言,各种类型的BIOS的基本原理大致相似。为了便于大家理解,我们选用的BIOS程序只有8
KB,所占地址段为0xFE000~0xFFFFF,如图1-1所示。现在CS:IP已经指向0xFFFF0这个位置了,这意味着BIOS开始启动了。随着BIOS程序的执行,屏幕上会显示显卡的信息、内存的信息……说明BIOS程序在检测显卡、内存……这期间,有一项对启动(boot)操作系统至关重要的工作,那就是BIOS在内存中建立中断向量表和中断服务程序。
小贴士
ROM(Read Only
Memory):只读存储器。现在通常用闪存芯片做ROM。虽然闪存芯片在特定的条件下是可写的,但在谈到主机板上存储BIOS的闪存芯片时,业内人士把它看做ROM。ROM有一个特性,就是断电之后仍能保存信息,这一点和硬盘类似。
BIOS程序在内存*开始的位置(0x00000)用1
KB的内存空间(0x00000~0x003FF)构建中断向量表,在紧挨着它的位置用256字节的内存空间构建BIOS数据区(0x00400~0x004FF),并在大约57
KB以后的位置(0x0E05B)加载了8 KB左右的与中断向量表相应的若干中断服务程序。图1-2中****注了这些位置。
小贴士
一个容易计算的方法:0x00100是256字节,0x00400就是4×256字节 =1024字节,也就是1
KB。因为是从0x00000开始计算,所以1
KB的高地址端不是0x00400,而是0x00400?1,也就是0x003FF。
图1-2 BIOS在内存中加载中断向量表和中断服务程序
中断向量表中有256个中断向量,每个中断向量占4字节,其中两个字节是CS的值,两个字节是IP的值。每个中断向量都指向一个具体的中断服务程序。
下面将详细讲解后续程序是如何利用这些中断服务程序把系统内核程序从软盘加载至内存的。
小贴士
INT(INTerrupt):中断,顾名思义,中途打断一件正在进行中的事。其*初的意思是:外在的事件打断正在执行的程序,转而执行处理这个事件的特定程序,处理结束后,回到被打断的程序继续执行。现在,可以先将中断理解为一种技术手段,在这一点上与C语言的函数调用有些类似。
中断对操作系统来说是一个意义重大的概念,后面我们还会深入讨论。
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)程序:通过中断向量表的索引对中断进行响应服务,是一些具有特定功能的程序。
图1-3 响应int 0x19中断
按照这个简单、“生硬”的规则,int
0x19中断向量所指向的中断服务程序,即启动加载服务程序,将软驱0号磁头对应盘面的0磁道1扇区的内容复制至内存0x07C00处。我们可以在图1-4的左边看到**扇区加载的具体位置。
图1-4 把软盘**扇区中的程序加载到内存中的指定位置
这个扇区里的内容就是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中都被明确**注了出来。设置这些位置就是为了确保将要载入内存的代码与已经载入内存的代码及数据各在其位,互不覆盖,并且各自有够用的内存空间。大家在后续的章节会逐渐看到内存规划的意义和作用。
图1-5 实模式下的内存使用规划
从现在起,我们的头脑中要时刻牢记这样一个概念:操作系统的设计者是要全面地、整体地考虑内存的规划的。我们会在后续的章节中不断地了解到,精心安排内存是操作系统设计者时时刻刻都要关心的事。我们带着这样的观念继续了解bootsect程序的执行。
2.复制bootsect
接下来,bootsect启动程序将它自身(全部的512
B内容)从内存0x07C00(BOOTSEG)处复制至内存0x90000(INITSEG)处。这个动作和目标位置如图1-6所示。
图1-6 bootsect复制自身
执行这个操作的代码(boot/bootsect.s)如下:
//代码路径:boot/bootsect.s
…
entry start
start:
movax,#BOOTSEG
movds,ax
movax,#INITSEG
moves,ax
movcx,#256
subsi,si
subdi,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的状态,如图右下方所示。
图1-7 跳转到go处继续执行
此前的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: movax, cs
movds, ax
moves, ax
! put stack at 0x9ff00.
movss, ax
movsp, #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对此做了非常直观的描述。
图1-8 调整各个段寄存器值
下面着重介绍一下与栈操作相关的寄存器的设置。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中断向量的位置以及这个中断向量所指向的磁盘服务程序的入口位置。
图1-9 调用int 0x13中断
这个中断服务程序的执行过程与图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
int0x13! read it
jncok_load_setup ! ok-continue
mov dx, #0x0000
mov ax, #0x0000 ! reset the diskette
int0x13
jload_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这个程序就要开始工作了。
图1-10 加载setup程序
注意,图1-8中SS:SP指向的位置为0x9FF00,这与setup程序的起始位置0x90200还有很大的距离,即便setup加载进来后,系统仍然有足够的内存空间用来执行数据压栈操作;而且,在启动部分,要压栈的数据毕竟也是有限的。大家在后续的章节中会逐渐体会到,设计者在此是进行过精密测算的。
1.2.3 加载第三部分内核代码——system模块
第二批代码已经载入内存,现在要加载第三批代码。仍然使用BIOS提供的int
0x13中断,如图1-11所示,方法与图1-9所示的方法基本相同。
图1-11 再次调用int 0x13中断
接下来,bootsect程序要执行第三批程序的载入工作,即将系统模块载入内存。
这次载入从底层技术上看,与前面的setup程序的载入没有本质的区别。比较突出的特点是这次加载的扇区数是240个,足足是之前的4个扇区的60倍,所需时间也是几十倍。为了防止加载期间用户误认为是机器故障而执行不适当的操作,Linus在此设计了显示一行屏幕信息“Loading
system
...”以提示用户计算机此时正在加载系统。值得注意的是,此时操作系统的main函数还没有开始执行,在屏幕上显示一行字符串远没有用C语言写一句printf("Loading
system
...
")调用那么容易,所有工作都要靠一行一行的汇编代码来实现。从体系结构的角度看,显示器也是一个外设,所以还要用到其他BIOS中断。这些代码比较多,对理解操作系统的启动原理没有特别直接的帮助,只要知道大意就可以了。我们真正需要掌握的是,bootsect借着BIOS中断int
0x13,将240个扇区的system模块加载进内存。加载工作主要是由bootsect调用read_it子程序完成的。这个子程序将软盘第六扇区开始的约240个扇区的system模块加载至内存的SYSSEG(0x10000)处往后的120
KB空间中。
图1-12对system模块所占用的内存空间给出了形象的说明。
图1-12 加载system模块
由于是长时间操作软盘,所以需要对软盘设备进行更多的监控,对读盘结果不断地进行检测。因此read_it后续的调用步骤比较多一些。但读盘工作*终是由0x13对应的中断服务程序完成的。
到此为止,第三批程序已经加载完毕,整个操作系统的代码已全部加载至内存。bootsect的主体工作已经做完了,还有一点小事,就是要再次确定一下根设备号,如图1-13所示。
图1-13 确认根设备号
小贴士
根文件系统设备(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:
jmpundef_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
…
图1-14 setup开始执行
setup程序现在开始执行。它做的**件事情就是利用BIOS提供的中断服务程序从设备上提取内核运行所需的机器系统数据,其中包括光标位置、显示页面等数据,并分别从中断向量0x41和0x46向量值所指的内存地址处获取硬盘参数表1、硬盘参数表2,把它们存放在0x9000:0x0080和0x9000:0x0090处。
这些机器系统数据被加载到内存的0x90000~0x901FC位置。图1-15标出了其内容及准确的位置。这些数据将在以后main函数执行时发挥重要作用。
提取机器系统数据的具体代码如下:
//代码路径:boot/setup.s
movax, #INITSEG! this is done in bootsect already, but...
movds, ax
movah, #0x03! read cursor pos
xorbh, bh
int 0x10 ! save it in known place, con_init fetches
mov [0],dx ! it from 0x90000.
! Get memory size (extended mem, kB)
movah, #0x88
int 0x15
mov[2], ax
movcx, #0x10
movax, #0x00
rep
stosb
…
这段代码大约70行,由于篇幅限制,我们省略了大部分代码。
图1-15 加载机器系统数据
注意,BIOS提取的机器系统数据将覆盖bootsect程序所在部分区域。这些数据由于是要留用的,所以在它们失去使用价值之前,一定不能被覆盖掉。
点评
机器系统数据所占的内存空间为0x90000~0x901FD,共510字节,即原来bootsect只有2字节未被覆盖。可见,操作系统对内存的使用是非常严谨的。在空间上,操作系统对内存严格按需使用,要加载的数据刚好占用一个扇区的位置(只差2字节),而启动扇区bootsect又恰好是一个扇区,内存的使用规划像一个账本,前后对应;在时间上,使用完毕的空间立即挪作他用,启动扇区bootsect程序刚结束其使命,执行setup时立刻就将其用数据覆盖,内存的使用率极高。虽然这与当时的硬件条件有限不无关系,但这种严谨的内存规划风格是很值得学习的。
到此为止,操作系统内核程序的加载工作已经完成。接下来的操作对Linux
0.11而言具有战略意义。系统通过已经加载到内存中的代码,将实现从实模式到保护模式的转变,使Linux
0.11真正成为“现代”操作系统。
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准确标识了复制操作系统内核代码的源位置和目标位置及复制动作的方向。
图1-17 复制system模块至内存起始处
回顾一下图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所示。
图1-18 设置GDTR和IDTR
点评
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。
图1-19 打开A20
现在看来,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:专门为了对808**和8086/8088进行中断控制而设计的芯片,是可以用程序控制的中断控制器。单个的8259A能管理8级向量优先级中断,在不增加其他电路的情况下,*多可以级联成64级的向量优先级中断系统。
具体代码如下:
//代码路径:boot/setup.s
…
mov al,#0x11! initialization sequence
out#0x20,al! send it to 8259A-1
.word0x00eb,0x00eb! jmp $ + 2, jmp $ + 2
out#0xA0,al! and to 8259A-2
.word0x00eb,0x00eb
moval,#0x20! start of hardware int's (0x20)
out#0x21,al
.word0x00eb,0x00eb
moval,#0x28! start of hardware int's 2 (0x28)
out#0xA1,al
.word0x00eb,0x00eb
moval,#0x04! 8259-1 is master
out#0x21,al
.word0x00eb, 0x00eb
mov al, #0x02! 8259-2 is slave
out#0xA1,al
.word0x00eb,0x00eb
moval,#0x01! 8086 mode for both
out#0x21,al
.word0x00eb,0x00eb
out#0xA1,al
.word0x00eb,0x00eb
moval,#0xFF! mask off all interrupts for now
out#0x21,al
.word0x00eb,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。
图1-20 对可编程中断控制器重新编程
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所示。
图1-21 打开保护模式 图1-22 程序段间跳转
具体代码如下:
//代码路径:boot/setup.s
…
jmpi 0, 8
…
这一行代码中的“0”是段内偏移,“8”是保护模式下的段选择符,用于选择描述符表和描述符表项以及所要求的特权级。这里“8”的解读方式很有意思。如果把“8”当做6、7、8……中的“8”这个数来看待,这行程序的意思就很难理解了。必须把“8”看成二进制的1000,再把前后相关的代码联合起来当做一个整体看,在头脑中形成类似图1-23所示的图,才能真正明白这行代码究竟在说什么。注意:这是一个以位为操作单位的数据使用方式,4
bit的每一位都有明确的意义,这是底层源代码的一个特点。
图1-23 保护模式开启前后的指令寻址方式对比示意图
图1-23 (续)
这里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所示。
图1-24 system在内存中的分布示意图
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中描述了页目录表在内存中所占的位置。
图1-25 建立内核分页机制
现在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给出了详细示意。
图1-26 设置DS、ES、FS、GS
具体的设置方式与图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在图中的位置。
图1-27 设置栈
我们现在回忆一下图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
callsetup_idt
callsetup_gdt
…
setup_idt:
leaignore_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
jnerp_sidt
lidtidt_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所示。
图1-28?中断描述符
中断描述符为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显示了具体的操作状态。
图1-29 设置IDT
点评
构造IDT,使中断机制的整体架构先搭建起来(实际的中断服务程序挂接则在main函数中完成)
,并使所有中断服务程序指向同一段只显示一行提示信息就返回的服务程序。从编程技术上讲,这种初始化操作,既可以防止无意中覆盖代码或数据而引起的逻辑混乱,也可以对开发过程中的误操作给出及时的提示。IDT有256个表项,实际只使用了几十个,对于误用未使用的中断描述符,这样的提示信息可以提醒开发人员注意错误。
现在,head程序要废除已有的GDT,并在内核中的新位置重新创建GDT,如图1-30所示。其中第2项和第3项分别为内核代码段描述符和内核数据段描述符,其段限长均被设置为16
MB,并设置GDTR的值。
图1-30 重新创建GDT
代码如下:
//代码路径:boot/head.s
…
startup_32:
movl$0x10,%eax
mov%ax,%ds
mov%ax,%es
mov%ax,%fs
mov%ax,%gs
lss_stack_start,%esp
callsetup_idt
callsetup_gdt
…
setup_gdt:
lgdtgdt_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所示。
图1-31 再一次调整DS、ES、FS、GS
调整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
…
图1-32 设置内核栈
因为A20地址线是否打开影响保护模式是否有效,所以,要检验A20地址线是否确实打开了。图1-33给出了直观的标示。
图1-33 检验A20是否打开
检验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所示。
图1-35 将envp、argv、argc压栈
head程序将L6标号和main函数入口地址压栈,栈顶为main函数地址,目的是使head程序执行完后通过ret指令就可以直接执行main函数。具体如图1-36所示。
图1-36 将main函数入口地址和L6标号压栈
点评
main函数在正常情况下是不应该退出的。如果main函数异常退出,就会返回这里的标号L6处继续执行,此时,还可以做一些系统调用……另外有意思的是,即使main函数退出了,如果还有进程存在,仍然能够进行轮转。
执行代码如下:
//代码路径:boot/head.s
…
orl$2,%eax# set MP
movl%eax,%cr0
callcheck_x87
jmpafter_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:
jmpL6# main should never return here, but
…
这些压栈动作完成后,head程序将跳转至setup_paging:去执行,开始创建分页机制。
先要将页目录表和4个页表放在物理内存的起始位置,从内存起始位置开始的5页空间内容全部清零(每页4
KB),为初始化页目录和页表做准备。注意,这个动作起到了用1个页目录表和4个页表覆盖head程序自身所占内存空间的作用。图1-37给出了直观的标示。
图1-37 将页目录表和页表放在内存起始位置
点评
将页目录表和4个页表放在物理内存的起始位置,这个动作的意义重大,是操作系统能够掌控全局、掌控进程在内存中**运行的基石之一,后续章节会逐步论述。
head程序将页目录表和4个页表所占物理内存空间清零后,设置页目录表的前4项,使之分别指向4个页表,如图1-38所示。
图1-38 使页目录表的前4项指向4个页表
head程序设置完页目录表后,Linux 0.11在保护模式下支持的*大寻址地址为0xFFFFFF(16
MB),此处将第4个页表(由pg3指向的位置)的*后一个页表项(pg3 +
4902指向的位置)指向寻址范围的*后一个页面,即0xFFF000开始的4
KB字节大小的内存空间。具体请看图1-39的标示。
图1-39 页目录表设置完成后的状态
然后开始从高地址向低地址方向填写4个页表,依次指向内存从高地址向低地址方向的各个页面。图1-39所示是**设置页表。
继续设置页表。将第4个页表(由pg3指向的位置)的倒数第二个页表项(pg3-4 +
4902指向的位置)指向倒数第二个页面,即0xFFF000~0x1000(0x1000即4 KB,一个页面的大小)开始的4
KB字节内存空间。请读者认真对比图1-40和图1-39,有多处位置发生了变化。
图1-40 设置页表
*终,从高地址向低地址方向完成4个页表的填写,页表中的每一个页表项分别指向内存从高地址向低地址方向的各个页面,如图1-41所示。其总体效果如图1-42所示。
图1-41 页目录表和页表设置完毕的状态
图1-42 总体效果图
这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所示。
图1-43 内存分布示意图
图1-44 分页机制完成后的总体状态
小贴士
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标示了出栈动作。
图1-45 执行ret,将main函数入口地址弹出给EIP
这部分代码用了底层代码才会使用的技巧。我们结合图1-45对这个技巧进行详细讲解。
我们先看看普通函数的调用和返回的方法。因为Linux 0.11
用返回方法调用main函数,返回位置和main函数的入口在同一段内,所示我们只讲解段内调用和返回,如图1-46(仿call示意图)所示。
call指令会将EIP的值自动压栈,保护返回现场,然后执行被调函数的程序。等到执行被调函数的ret指令时,自动出栈给EIP并还原现场,继续执行call的下一行指令。这是通常的函数调用方法。对操作系统的main函数来说,这个方法就有些怪异了。main函数是操作系统的。如果用call调用操作系统的main函数,那么ret时返回给谁呢?难道还有一个更底层的系统程序接收操作系统的返回吗?操作系统已经是*底层的系统了,所以逻辑上不成立。那么如何既调用了操作系统的main函数,又不需要返回呢?操作系统的设计者采用了图1-46(仿call示意图)所示的方法。
这个方法的妙处在于,是用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标示了这个状态。
图1-47 开始执行main函数
点评
为什么没有*先调用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函数对应的代码了。
特别需要提示的是,此时仍处在关闭中断的状态!
1.4 本章小结
本章的内容主要分为两大部分。**部分为加载操作系统;第二部分为32位保护、分页模式下的main函数的执行做准备。
从借助B