本文是《Linux内核设计的艺术》第一章节的复习笔记。本课程由该书的作者中科院杨力详老师授予。
前言
从开机到main函数的执行分三步完成,目的是实现从启动盘加载操作系统程序,完成执行main函数所需要的准备工作。
第一步,启动BIOS, 准备实模式下的中断向量表和中断服务程序。
1
实模式:20位的存储器地址空间,即1MB的(0x00000~0xFFFFF)存储器可被寻址,可以直接软件访问BIOS以及周边硬件,没有硬件支持的分页机制和**实时多任务(现代操作系统的特征)**概念,80286开始,开机状态都是实模式。
第二步,从启动盘加载操作系统程序到内存,加载操作系统程序的工作就是利用第一步中准备的中断服务程序实现的。
第三步,为执行32位的main函数做过渡工作。
第一步:启动BIOS
计算机的运行离不开程序。然而加电的瞬间,计算机内存(RAM)中没有程序。软盘里虽然有操作系统程序,但CPU的逻辑电路被设计为只能运行内存中的程序,没有能力直接从软盘运行操作系统。如果要运行软盘中的操作系统,必须将软盘中的操作系统程序加载到内存中(第二步),下面会看到,这个任务实际上是由BIOS完成的。
BIOS的启动管理
在了解BIOS如何将操作系统加载到内存中(第二步)之前,我们先要了解BIOS程序自身是如何启动的。由于开机加电瞬间没有任何程序在运行,我们无法人为地执行BIOS程序,既然软件方法不可能执行BIOS,那么只能靠硬件方法完成了。
从硬件角度看,80x86系列的CPU可以分别在16位实模式和32位保护模式下运行。为了兼容,也为了解决最开始的启动问题,CPU大都设计成加电即进入16位实模式状态运行。同时将CPU硬件逻辑设计为加电瞬间强行将CS的值为0xF000、IP的值为0xFFF0, 这样CS:IP就指向了0xFFFF0这个地址位置。
1 | CPU内存寻址使用:CS、IP两个寄存器。 |
这里面的问题是,这个内存地址是指RAM,还是主板芯片上的ROM。按照前文逻辑,此时RAM上空空如也,ROM上倒是有BIOS程序。这里涉及到另外一个概念,Shadow RAM 即RAM中被写保护的内存区域。开机加电的瞬间,BIOS信息会首先从ROM中装载到Shadow RAM中的指定区域里。由于Shadow RAM的物理编址与对应的ROM相同,所以当需要访问BIOS时,只需访问Shadow RAM而不必再访问ROM,这就能大大加快计算机系统的运算时间。
也就是说BIOS程序的入口地址0xFFFF0就是指RAM的物理绝对地址,BIOS程序的第一条指令就设计在这个位置。
BIOS在内存中加载中断向量表和中断服务程序
BIOS程序被固化在计算机主机板上的一块很小的ROM芯片里。本文选取的BIOS程序只有8KB,所占地址段为0xFE000~0xFFFFF,现在CS:IP指向0xFFFF0这个位置,这意味着BIOS开始启动了。
BIOS程序从第一条指令开始的首要工作是,在内存(RAM)最开始的位置(0x00000)用1KB的内存空间(0x00000~0x003FF)构建中断向量表,在紧挨着它的位置用256字节的内存空间构建BIOS数据区**(0x00400~0x004FF),并在大约57KB以后的位置(0x0E05B~0x0FFFE)加载了8KB左右与中断向量表相应的若干中断服务程序**。
1 | 容易计算方法:0x00100:256字节(2进制100000000,2^8),0x00400就是4*256B=1024B=1KB,因为起始地址为0x00000, 因此末端地址为0x00400-1=0x003FF。 |
中断向量表中有256个中断向量,每个中断向量占4个字节,其中两个字节为CS的值,两个字节为IP的值。每个中断向量都指向一个具体的中断服务程序。
下面将详细讲解后续BIOS程序是如何利用这些中断服务程序把系统内核程序从软盘加载至内存(RAM)中。
第二步:加载操作系统内核程序并为保护模式做准备
现在开始,要执行真正的boot操作了,即把软盘中的操作系统程序加载至内存。计算机将分三批逐次加载操作系统的内核代码,第一批由BIOS中断int0x19把第一扇区bootsect的内容加载到内存;第二批、第三批在bootsect的指挥下,分别把其后的4个扇区和随后的240个扇区的内容加载至内存。
加载第一部分内核代码:引导程序bootsect
由于计算机硬件体系结构的设计与BIOS的联手操作,会在BIOS程序完成计算机自检工作后,让CPU接收到一个int0x19中断,CPU接收到这个中断后,会立即在中断向量表中找到int0x19这个中断向量。接下来,中断向量把CPU指向0x0E6F2,这个位置就是int0x19相对应的中断服务程序的入口地址,也就是加载操作系统内核服务程序的入口位置。这个中断服务程序的作用是把软盘第一扇区(512B大小)中的程序加载到内存中指定的位置0x07C00。这个中断服务程序是BIOS事先设计好的,代码是固定的,与操作系统无关。这段BIOS程序的唯一目的就是“找到软盘”,并“加载第一扇区”。
具体而言,启动服务程序会将软驱0号磁头对应盘面的0磁道1扇区的内容复制到内存0x07C00处。这个扇区中的内容就是Linux的引导程序,也就是我们将要讲解的bootsect,bootsect的作用是陆续将软盘中的操作系统程序载入内存。这样制作的第一扇区就称为启动扇区boot sector, 第一扇区的载入,标志着Linux中的代码即将发挥作用。
这是非常关键的动作,从此计算机开始和软盘上的操作系统产生了关联。第一扇区中的程序由bootsect.s中的汇编程序汇编而成,这是计算机自开机以来,内存中第一次有了Linux操作系统的代码,虽然只是启动代码。
1 | 计算机硬件体系结构的设计与BIOS的联手操作:理论上,计算机可以安装任何操作系统,每个操作系统设计者都可以设计出一套自己操作系统的启动方案,而操作系统和BIOS通常是由不同的团队设计和开发,为了协同工作,必须建立协调机制。“两头约定”、“定位识别”。 |
加载第二部分内核代码——setup
bootsect对内存的规划
BIOS已经把bootsect引导程序载入内存了,现在的作用就是把第二批、第三批程序陆续加载到内存中,为了把第二批和第三批程序加载到内存中适当位置,bootsect首要工作就是规划内存。
在实模式下,寻址的最大范围为1MB。几个重要参数如下:
1 | SETUPLEN=4 ! 将要加载的setup程序的扇区数 |
这些代码对后续操作涉及的内存位置进行设置,上述都是CS段基址,如上注释所示。
复制移动bootsect
bootsect启动程序把它自身(512B)内容从内存0x07C00处复制至内存0x90000处。从中可以看出,操作系统开始根据自己的需求安排内存了,而不是根据约定被迫加载到0x07C00处。
bootsect复制到新位置后,执行如下代码
1 | ... |
上述跳转代码非常巧妙,现在程序会转到0x90000新的位置接着原来的程序代码继续执行下去。因此接下来执行的是bootsect代码!
之后会把数据段寄存器DS,附加段寄存器ES,栈基址寄存器SS(Stack Segment)都设置成与代码段寄存器CS相同的位置0x9000,并把栈顶指针SP(Stack Pointer)指向偏移位置0x9FF00处。
SS和SP是与栈操作相关的寄存器。栈是有方向的,压栈的时候由高地址到低地址的方向。从现在开始,程序可以执行一些更为复杂的数据运算类指令了。
将setup程序加载到内存中
前面bootsect的第一步操作,即规划内存并把自身从0x07C00复制到0x90000的位置的动作已经完成。现在是第二步操作,将setup程序加载到内存中。
借助BIOS的int0x13中断向量所指向的中断服务程序(磁盘服务程序)来完成。和int0x19有所不同,int0x19指向的启动加载服务程序是BIOS执行的,而int0x13是Linux自身启动代码bootsect执行的。int0x19只负责把软盘第一扇区的代码加载到0x07C00。而int0x13可以根据设计者意图,把指定扇区的代码加载到内存指定位置。
实际执行是将软盘第二扇区开始的4个扇区(2~5),即setup.s对应的程序加载至内存的SETUPSEG=0x90200处。因为bootsect起始是0x90000,大小是1个扇区,也就是512B,则bootsect的尾端就是0x90200。因此setup和bootsect是连在一起的。
加载第三部分内核代码——System模块
仍然使用int0x13中断,方法仍然相同,只不过传参不一样(扇区起始、扇区数、加载的内存位置)。只不过这次加载了240个扇区,时间比较久,系统一般会提示”loading system…”。bootsect调用read_it子程序将软盘第6扇区开始的240个扇区,即system模块对应的代码加载到内存SYSSEG=0x10000起始的往后120KB空间里。
bootsect工作完成后,执行jmpi 0, SETUPSEG,跳转到setup程序的第一条指令继续执行。setup第一件事是利用BIOS的中断服务程序从设备上提取内核运行所需要的机器系统数据(光标位置、显示页面等数据),以及硬盘参数表1、2。这些数据被加载到0x90000~0x901FC位置。可以发现这些机器系统数据将覆盖bootsect程序所在的部分区域,实际上覆盖了510B,只有2B没有覆盖。
开始向32位模式转变:为main函数做准备
这里面工作包括打开32位的寻址空间,打开保护模式,建立保护模式下的中断响应机制,建立内存的分页机制,最后为调用main函数做准备。
关中断并将system移到内存起始位置0x00000
首先要关闭中断cli,即将CPU的标志寄存器EFLAGS中的中断允许标志IF置为0,这意味着程序接下来执行过程中不会对中断进行响应,直到保护模式下的中断服务体系被重建完毕才会打开中断,那时候的中断服务程序不再是BIOS提供的,而是系统自身提供的。
关完中断后,setup将0x10000的内核程序复制到内存地址起始位置0x00000处。这意味着覆盖了BIOS中断向量表和数据区,直到新中断服务体系建立完毕之前,操作系统不再具备响应并处理中断的能力。破旧立新的开始。
设置中断描述符表和全局描述符表
使用setup自身提供的数据信息对中断描述符表寄存器IDTR和全局描述符表寄存器GDTR进行初始化设置。目的是在保护模式打开后,根据GDT决定后续执行哪里的程序。
1 | - GDT:Global Descriptor Table,全局描述符表,在系统中唯一存放段描述符的数组,配合程序进行保护模式的段寻址。在进程切换中很重要,可理解为所有进程的总目录表,其中存放每一个任务局部描述符LDT地址和任务状态段TSS地址,完成进程中各段寻址,现场保护与现场恢复。 |
目前内核尚未真正运行,还没有进程。GDT表的限长为2KB,因为每项8B,共256项,也就是2KB。现在创建的GDT只有3项,数据都是通过代码硬编码方式写入的,第一项为空,第二项为内核代码段描述符,第三项为内核数据段描述符,其余项无任何东西。IDT目前设置,但是是空表。
打开A20,实现32位寻址
线性地址空间变为32G,物理地址空间在Linux0.11里面从1MB变为16MB,也就是0x00000~0xFFFFF变为0x000000~0xFFFFFF。
为保护模式下执行head.s做准备
首先对可编程中断控制器8259A进行重新编程。在保护模式下,IRQ0x00~IRQ0x0F的中断号是int0x20~int0x2F。
其次,将CPU工作方式设为保护模式,即将CR0寄存器第0位PE置为1。
接着执行jmpi 0,8。这是很关键的一句话,CPU转到保护模式,一个很重要的特征就是要根据GDT决定后续执行哪里的程序。0是EIP的值,8是CS的值,也就是段选择子,将8拆成二进制为1000,最后2位代表特权级,第2位代表GDT还是LDT,0代表GDT,第1位为GDT中段描述符的位置,这里1对应的是内核代码段描述符。
head.s开始执行
jmpi 0,8 根据内核代码段描述符找到段基址为0x00000000,因为eip=0,因此从0x00000000(线性地址)处开始执行代码。前面我们说过,system模块的代码已经移到了0x00000(物理地址)。而head.s是用c语言编写,然后先编译成目标代码,再链接到system模块,也就是说system模块里既有内核程序,又有head程序,二者紧挨,且head程序在前,head从0x00000开始占用25KB+184B的空间(物理地址)。因此此时执行的就是head.s的代码。
1 | 这里面有一个很隐晦的知识点,即线性地址和物理地址起始部分的地址是一致的。因为段选择子得到的只是将要执行代码的线性地址,由于此时分页机制还没起来,因此得不到物理地址。Linux设计将线性地址和物理地址开始部分直接映射,因此地址是一样的,这样我们才能直接将线性地址0x00000000转化成物理地址0x000000。 |
内核分页机制
head.s的首要工作是用程序自身的代码在程序自身所在的物理内存空间创建内核分页机制,即从0x000000(物理地址)的位置创建页目录表、页表、缓冲区、GDT、IDT,并将head程序已经执行过的代码所占内存空间覆盖了,相当于自我毁灭。做完这些事情之后,main函数才会开始执行。
具体而言,首先在_pg_dir标志的位置(0x000000)开始建立页目录表,为分页机制作准备。0x0000~0x4FFF共20KB留给1个页目录表和4个页表使用。
1 | 每个页目录表占4KB,每个页目录表项4B,共1K项,每项指向一个页表,目前4个页表,实际上只使用了4项页目录表项。而每个页表也是4KB,每个页表项4B,共1K项,每项指向一个具体的物理页。每个物理页占4KB。因此1个页目录表实际上能管辖1K*1K*4K=4G的物理地址。而1个页目录表项能管辖1K*4K=4M的物理空间。 |
接着将DS、ES、FS、GS等寄存器从实模式转成保护模式,也就是全都指向0x10,代表的是内核数据段描述符。SS和ESP同样也要设置。SS指向0x10,ESP指向user_stack的末尾。
构建IDT
IDT存放中断描述符,每个中断描述符为64位,即4B,共256项,因此IDT总长度为2KB。此时所有的中断描述符项都默认指向中断服务程序ignore_int这个位置。
重新构建GDT
head程序要废除setup中构建的GDT,并在内核新位置重新创建GDT,从原来的0x90200+GDT偏移变到了现在的0x54B2处。并且将内核数据段描述符和代码段描述符中的段限长从8MB改到16MB。之所以要重新构建,一个是因为setup模块所在的位置将来会被缓存区覆盖。
接下来要检验A20是否打开,利用的是实模式地址回滚机制和保护模式地址不回滚进行对比验证。
为main的执行做最后的准备
将main的参数envp,argv,argc压栈,将L6和main函数入口地址也压栈。栈顶为main函数的地址,目的是使head程序执行完通过ret指令就可以直接执行main函数。
压栈完成后跳转到set_paging,开始创建分页机制。前面只是留了20B的空间给分页机制使用,现在开始具体的创建。包括清零,相当于也把head程序自身的内存空间覆盖了。注意页目录表和4个页表是在物理内存的起始位置。接着将页目录表的前4项使之分别指向4个页表,然后将第4个页表的最后一个页表项指向物理内存寻址的最后一个页面起始位置0xFFF000,以及倒数第二个页表项指向物理内存寻址的倒数第二个页面0XFFE000。
这4个页表是内核专属的页表,将来每个用户进程都会有它们专属的页表。对于一个页表项,前20位对应了物理页的具体物理内存地址(对于16M物理内存,实际上只需要12位就够了,因为16M物理内存,每页4KB,则16M/4KB=2^12,也就是12位,实际上20位最多可以支持4GB的物理内存),后12位代表物理页的一些属性状态。比如最后3位,从高往低分别代表用户还是内核权、读写状态、是否存在。
页表设置完毕后,将页目录表的基址写入CR3寄存器(高20位),再将CR0寄存器的最高位(31位)设置为1,代表地址映射将采用分页机制。
最后head执行完后,需要执行main函数。此时main函数的入口和目前的代码处在同一个段内,通过仿call调用,使用ret指令将栈顶的main地址pop给EIP,这样拿到main函数的CS:EIP之后,相当于CPU将要开始执行main函数程序。
总结
- 开机瞬间ROM复制到Shadow RAM——>
- CS:IP强制指向0xFFFF0执行BIOS第一条指令—–>
- 构建中断向量表和中断服务程序——>
- CPU自动接收到int0x19中断——–>
- BIOS中断服务程序读取第一扇区(bootsect对应程序)到0x07C00——>
- 复制bootsect程序到0x9000——>
- jmpi go, INITSEG执行bootsect代码——>
- bootsect使用int0x13中断加载setup程序—–>
- jmpi 0, SETUPSEG执行setup代码——>
- setup使用int0x13中断加载system模块——->
- setup复制system模块到0x00000起始,废除BIOS中断体系——>