第6章 引导启动程序boot
主要介绍boot/目录的三个汇编代码文件,
boot.s和setup.s是实模式下的16位代码程序,编译和汇编使用的as86和ld86。
head.s使用的GNU的汇编格式。使用gas编译。
6.1 总体功能
Linux启动流程
在第2章 微型计算机组成结构的2.3.2节有叙述过这个过程,可以参考。
后面setup程序会把system模块移动到内存的起始处。
head.s的任务:
- 加载IDT、GDT和LDT
- 确认处理器和协处理
- 设置分页
- 调用init/main.c中的main()程序。
Linux的运行需要基本的文件系统支持,即根文件系统。Linux0.11内核仅支持MINIX的1.0文件系统。
bootsect.s程序中给出了根文件系统所在的默认块设备号。在内核初始化时会使用编译内核时放在引导扇区第509、510(0x1fc-0x1fd)字节中的指定设备号。
BIOS转到boot时的内存分布:
BIOS设置的中断向量表,BIOS数据区以及中断服务程序如图所示。
6.2 bootsect.s程序
6.2.1 功能描述
boosect.s代码是磁盘引导块程序。驻留在磁盘的第一个扇区中(引导扇区,0磁道(柱面),0磁头,第一个扇区)。
6.2.2 代码注释
Linux各个版本源码查看网站网址
Linux各个版本下载网址网址
书中第43行:现在Linux中硬盘的命名规则是什么?有待继续学习,然后这个命名又是如何让机器能够识别的呢?
67-77行:读取setup模块,关于int13中断的使用参考INT 13H。
当ah=02时,实现的功能是Read Sectors From Drive。dl表示驱动器号,其命名规则为:
当ah=00时,其实现的功能为Reset Disk System。
74行和75行就是在重置第一个软盘的系统。
jnc: 如果CF=0,则跳转。
对83-86行,读取磁盘驱动器的参数,对1.44M软盘来讲,其规格为单盘(2个盘面),每个盘面80个磁道,每个磁道18个扇区,每个扇区有512个字节,这样一共是28018*0.5k=1440k=1.44M。
对98-102行:int0x10可以参考int 0x10因为前面90行设置过ex了,所以这里不用设置,对bl中的值为0x07,参考本书2.4.6节表2-4的内容,0x07表示黑色背景上的白色字符。
对第107-110行:关于read_it和call_it后面会说。
对147-149行。sread表示已经读取的扇区数,目前是1个引导扇区+4个setup函数扇区。软盘的数据组织方式是:磁头(head),磁道( Cylinder)和扇区(Sector)。前面提到过磁盘有两个盘面,就是两个磁头,每个磁头有80个磁道,每个磁道有18个扇区,每个扇区512字节。
对162-169行:jnc,如果没有产生进位,当前读入所有扇区之后地址没有超过64kb,跳转到ok2_load执行;je ok2_load,产生进位,CF位为1,但是ZF位也为1,说明读取之后恰好是64KB,则跳转到ok2_load执行。因为段内偏移是用bx来表示的,而bx能够表示的最大偏移值为64kb,因此一次读取的字节数不能超过64kb-bx。
对170-172行:如果发现读取所有的扇区之后,会超过64kb,则计算最大可以读取多少个扇区。也即(64kb-bx)/512个。
对180-193行:主要是软盘数据组织的问题,参考软盘结构及软盘数据的读取,可知软盘编号顺序为:
因此代码会是读完扇区之后,先判断盘面。如果当前是0盘面,就读1盘面,磁道数不变;如果当前是1盘面,就读0盘面,磁道数加1。
对192-195行,因为最开的时候其实es:bx指向的是0x10000,然后读取是以0.5k为单位的,所以bx一定是0.5k的倍数,最后到64k再归零。
read_track函数较为简单,主体是一个int13中断调用。注意这个函数没有修改主函数中任何一个寄存器的值,没有返回值。
kill_motor函数是关闭软件驱动马达,关于软盘控制可以参考Floppy-disk controller。这个关闭了这么多的功能,到时候是不是还要打开呢?后面学到软盘驱动的时候好好看看。
后面是一些常量。
最后0xAA55供BIOS中的程序加载扇区时识别使用。
主程序和read_it程序大致流程:
6.2.3 其他信息
6.2.3.1 Linux0.11硬盘设备号
各个设备的主设备号为:
- 1-内存
- 2-磁盘
- 3-硬盘
- 4-ttyx
- 5-tty
- 6-并行口
- 7-非命名管道
每个硬盘可以有1-4个分区,因此硬盘的逻辑设备号为:设备号=主设备号*256+次设备号。
书里面总是提到0.95后就不使用这种命名方式了,这里摘出0.96bootsect.s中根目录设备的相关代码,备将来学习之用。
# 定义部分
! ROOT_DEV & SWAP_DEV are now written by "build".
! 这句注释说明0.96是用build来写入ROOT_DEV
ROOT_DEV = 0
SWAP_DEV = 0
# 这边好像和0.11也一样啊。
! After that we check which root-device to use. If the device is
! defined (!= 0), nothing is done and the given device is used.
! Otherwise, either /dev/PS0 (2,28) or /dev/at0 (2,8), depending
! on the number of sectors that the BIOS reports currently.
seg cs
mov ax,root_dev
or ax,ax
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
mov ax,#0x0200 ! /dev/fd0 - autodetect
root_defined:
seg cs
mov root_dev,ax
看了一下build还是没太看懂,后面再好好学习看看。
6.2.3.2 从硬盘启动系统
这段没有看太懂。从硬盘启动分区为什么会这样呢?先向后看,到时候再回过头来研究。
6.3 setup.s程序
setup.s 是一个操作系统加载程序。其工作流程为:
可以发现根设备号没有被覆盖。
在setup中临时设置了中断描述符表LDT和全局描述符表GDT,在head.s中会根据内核需要重新设置。4.9节中的实例也是如此。
这边是对描述符格式和段选择符格式再次进行了简单的说明,并进行了举例。不得不夸一下作者是真的细,很多教科书大概只是会在这里说哪一章已经讲过了。我并不觉得作者这样做繁琐,反而会觉得很贴心,作为一个菜鸡真的是很需要这种重复、细致的讲解。
这边可以作为参考,更细致的内容可以看第4章,然后在第4章 80x86保护模式及其编程(3)——一个简单的多任务内核实例中对其中出现过的描述符都进行了分析,可以参考。
6.3.2 代码注释
对第36-41行:首先重新设置ds的值,我觉得也是有必要的,我就已经忘记了bootsect运行结束时各个寄存器的状态了;然后最后保存值的时候,默认使用的段寄存器是ds,当基址寄存器是EBP或ESP时,默认的段寄存器是SS,否则,默认的段寄存器是DS。
关于45–47行:这里并没有判断获取是否出错。
关于98-104行:参考stosb,在rep之前加入一条CLD指令复位DF会更好,保证地址向上增长。
对131-134行:加载idtr和gdtr寄存器,两个寄存器的结构见4.1.2节。
对138-144:开启A20地址线。关于A20地址线可以参考A20 Line。本书后面也有讲,8042是键盘控制器。对键盘接口的说明可以参考附录“关于A20 Gate”,其中0xD1是固定指令,写入0x64端口说明需要写内部端口output port的数据,A20的控制线是在output port的第2位。接下来向0x60端口写入数据,这里是0xDF,根据接线图来看应该是除了输入缓冲区全部置1,这个数据是为什么呢?后面看看有没有这个解释。
对154-179行:关于8259A的编程可以参考:8259A手册、8259A PPT、8259 PIC,(PIC是programmable interrupt controller可编程中断控制器的缩写)还有这个中断控制器。0x20、0xA0是控制端口,0x21、0xA1是数据端口。
对ICW1有:
因此书上设置为0x11表示是ICW1,边沿触发,多级串联,写入ICW4。
对ICW2有:
代码中设置主片中断号起始位置为0x20,从片中断号起始位置为0x28。
对ICW3有:
代码中设置主片:0x04,也即第3位为1,说明从片连接到第IR2上;
设置从片:0x02,表示从片INT连接到主片IR2上。如下图所示:
ICW4有:
代码中设置主片和从片的ICW4均为0x01,即为全嵌套模式、非缓冲模式工作,中断手动结束,x86处理器。
注意,ICW4确定了中断是手动结束还是自动结束。
注意,对系统来讲其实是凡是A0位是1的,就写入0x21/0xA1端口,凡是A0位是0的,就写入0x20/0xA0端口。这个应该是和8295和CPU的接线决定的。但是除了初始化是A0=0&D4=1这样确定的标志,其他的好像都没有什么标志,难道是按照顺序读入来判别当前是哪个端口的值的吗?ICW是按顺序,OCW2和3也是有标志位的。参见本书6.3.3.5节。
这边感觉还是需要找一块主板把电路图看一下,不是框图,是纯纯电路图,这样才能更好的理解端口的编址,也更能明白CPU引脚与芯片的对应。
对191-193行:这一段和4.9节boot.s程序的最后很像。CR0的格式:
具体各位的含义参考本书4.1.3节。这里是吧最后一位置1,即PE位置1,开启保护模式,这边比较疑惑的就是直接是把ax载入了CR0,这样的话不仅把PE位置1了,其他位也置0了,这样的对的吗?在第4章也是这么干的,应该这边之前都是0,很多都是开启保护之后才会用到的标志,协处理器相关的,不知道后面会不会设置,需要留意一下。然后跳转到system运行。此时是保护模式,段选择符为8,说明请求特权级为0,使用全局描述符表GDT,第1个描述符表项(从0开始数),偏移地址为0,查找GDT表
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
可知此描述符为0x00c09A00000007FF。即内核代码段,基地址为0,段限长为2048,颗粒度为4kb,在内存中,DPL为0,代码段可读/执行。因此是跳转到0x0地址运行,即system代码段运行。
对198-203行:参考附录“关于A20 Gate”可知读0x64端口就是读8042的Status Register,其各位定义为:
test指令参考TEST ,test al,#2 是测试al的第1位(从0开始数),忽略其他位,如果第二位为0则ZF设置为1。如果为0,则说明当前输入缓冲区没有数据,则可以返回;如果不为0 说明还需要继续等待。
对205-216行,设置初始GDT表。参考4.9节boot.s程序。
对218-220行:idtr寄存器数值。设置idtr的表长度为0,说明暂时没有idt。前面已经把中断关掉了,现在不需要这个。
对222-224: gdtr寄存器数值,限长是2k,可以有256个表项,注意此时没有开启分页,所以线性地址等于物理地址,因此此时的位置是0x90000+512+gdt。512是bootsect的大小。
setup程序的大致流程如下:
6.3.3 其他信息
6.3.3.1 当前内存映像
当setup程序结束之后,当前的内存分布为:
6.3.3.2 BIOS视频中断0x10
这边里面的参数具体的意义还是不是很清楚,主要是因为对视频显示的相关知识并不熟悉,这个后面用到再看,这个地方可以作为查表用
6.3.3.3 硬盘基本参数表(“INT 0x41”)
好像没有查过硬盘的数据组织形式,所以里面有些参数不是很懂,后面用到了再看。
6.3.3.4 A20地址线问题
讲述了A20地址线出现的原因以及除了本书程序中的设置键盘控制器外的另外两种开启A20地址线的方法(操作0x92端口以及读写0xee端口)
6.3.3.5 8259A中断控制器的编程方法
1.8259A芯片工作原理
8259A芯片逻辑框图:
- IRR:Interrupt Request Register 中断请求寄存器,用于保存中断请求输入引脚上所有请求服务中断级,8位分别对应8个引脚;
- IMR:Interrupt Mask Register 中断屏蔽寄存器,用于保存被屏蔽的中断请求线对应的比特位,8位对应8个引脚,位置1代表屏蔽;
- PR: Priority Resolver 优先级解析器,确定IRR中所设置比特位的优先级,选通最高优先级的中断请求到正在服务寄存器ISR
- ISR: In-Service Register,正在服务寄存器,保存着正在接收服务的中断请求。
- ICW: Initialization Command Words,初始化命令字,在可以正常操作之前设置
- OCW: Operation Command Words,写入操作命令字,在工作过程中希写入,随时设置和管理8259A的工作方式。
- A0:选择操作的寄存器,当A0线是0时,芯片的端口地址为0x20(主)和0xA0(从);当A0=1时,芯片的端口地址是0x21(主)和0xA1(从)
- IR0-IR7:接收不同设备的中断请求。
- AEOI:Automatic End of Interrupt,自动结束中断。
2.初始化命令字编程
8259A共四种工作方式:
- 全嵌套方式
- 循环优先级方式
- 特殊屏蔽方式
- 程序查询方式
接下来的这部分书上说的很清楚了,这里就不再赘述了,可以作为查表使用。
3.操作命令字编程
看书作为查表,书中给出了8259A是如何判断当前指令是不是OCW2和3,说的很详细。这些在datasheet中可以找到应该,但是因为是英文,所以看起来需要花时间,好在作者详细的翻译了。真实很好很体贴了。
4.8259A操作方式说明
(1)全嵌套方式
(2)中断结束(EOI)方法
(3)特殊全嵌套方式(没太看懂,后面再看)
(4)多片级联方式
(5)自动循环优先级方式
(6)中断屏蔽方式
(7)度寄存器状态
以上在树上有详细内容。对ocw1的读取,在kernel/blk_drv/floppy.c中461行为:
outb(inb_p(0x21)&~0x40,0x21);
inb_p为:
#define inb_p(port) ({ \
unsigned char _v; \
__asm__ volatile ("inb %%dx,%%al\n" \
"\tjmp 1f\n" \
"1:\tjmp 1f\n" \
"1:":"=a" (_v):"d" (port)); \
_v; \
})
其实就是一个inb再加上两个跳转作为延时。是直接在0x21端口读取OCW2的。
6.4 head.s程序
6.4.1 功能描述
head程序在system模块的最开始部分。system模块从磁盘第6个扇区(1:bootsect;2-5:setup)开始。linux0.11 的system模块大约是120KB左右,大概占240个扇区。
此时,内核运行在保护模式。head.s是用AT&T格式编写,用gas和gld编译链接。
head.s的起始物理地址为0x0,其主要流程为:
中断描述符表项:
具体介绍参考本书4.6.8节。
关于页目录表:head.s将页目录表放在绝对物理地址0开始处,因此head.s的代码会被覆盖掉。页目录表项和页表项格式为:
其介绍可以参考4.4.2节。
上面这段没有太看懂,后面再慢慢理解,为什么这样做可以保证任务0和任务1不能随意访问内核资源呢?后面再好好看一下
6.4.2 代码注释
对18-23行:主要是第23行,在kernel/sched.c中的stack_start定义为:
struct {
long * a;
short b;
} stack_start = { & user_stack [PAGE_SIZE>>2] , 0x10 };
这个栈的位置确实没有看明白,后面再看
对24-30行:主要是修改gdt表之后,cs需不需要重新加载的问题,gdtr更新之后,cs会自动更新吗?从书中的注释来看,bochs里应该是不会的,我感觉实际上应该也不会,不然下一条指令岂不是容易跑飞?书里的注释阐述了在这里不手动加载cs也问题不大。按道理重新加载会更好。
对43-48行:CR0的结构:
这里保护的是PG——分页启动标志;ET——扩展类型,当EM=1时,该位被忽略;PE:保护模式标志,在setup.s的最后启动了PE。第44行将除了这三位所有位都清零了,第46行将第二位也就是MP位设置为1,然后47行将设置好的CR0写入到寄存器。然后调用check_x87。对check_x87的讲解见下面。
在测试完协处理器是否存在之后,跳转到第135行after_page_tables,这是因为前面要开始设置页表了。
对check_x87函数:fninit:协处理器初始化执行;fstsw,取协处理器状态字到ax寄存器中,这里我不知道在不存在协处理的情况下ax会不会改变,但是前面ax已经设置过肯定不为0。第60行,原来已经将CR0中的MP位置1,EM位置0,这里将其与1与或,最终MP变为0,EM位置为1,其他的数据保持不变。 所以总体的这段代码的意思是:检查是否存在协处理器,如果存在,就把协处理器设置为保护模式返回;如果不存在,就复位CR0寄存器的MP位,置位CR0寄存器的EM位,也就是使用模拟协处理器。但是MP位是做什么用的我还是不太明白,后面再看。
对set_idt函数:可参考第4章 80x86保护模式及其编程(3)——一个简单的多任务内核实例head.s里面的set_idt理解,这里再次记录:书里的注释说的很清楚了,使用eax构造中断描述符的低4个字节,使用edx构造中断描述符表的高四个字节。则段选择符在eax的高2字节,这里选择内核代码段,即0x0008;过程入口偏移地址在eax的低2字节以及edx的高2字节,因此在将ignore_int的32位地址保存到edx之后,又将dx(edx低2字节)写到ax(eax)低2字节中;dx再写入0x8E00即0b 1000 1110 0000 0000,对照下图可知为在内存中,DPL=0,是中断描述符。然后从中断描述符的初始位置_idt设置256个表项,每个表项都是edx和eax中的内容。注意后面lidt 的idt_sescr中,idt的线性基地址直接选择了 .long _idt,这是因为内核代码段的起始地址为0,因此_idt即在内核代码段中的偏移地址,也就是实际的线性地址。但是这边这么设置了,后面开启分页的时候应该还会把这一段的线性地址和物理地址设置成相等。这个后面看看是不是这样
对set_gdt函数:这个就比较简单了,直接加载新的gdt_descr就行。在gdt_descr中的基地址设置与idt中的基地址设置一样。因为段基址是0所以为编程带来很多的方便。
前面应该是5*4kb=20kb的大小,也即从0-0x4FFF是用于设置页目录和页表的。前4k,也即0-0x0FFF是页目录,后面16k也即0x1000-0x4FFF是页表项。由于设计最多管理16MB内存,所以4个页表项就够了。
对132行,当DMA(直接存储器访问)不能访问缓冲块时,_tmp_floppy_area供软盘驱动使用,其地址需要对齐调整。这边具体是什么意思还不是很清楚,后面看这一块是怎么被应用的
对136-143行:参考3.4.1.1节,图3-4,对应压入栈中的数据,可以发现这边应该是有两层调用。首先是返回值为L6的函数调用了main函数,然后是main函数虚拟调用了当前代码(也就是没有实际调用,这是我们人工营造出来的一种场景,来实现把程序转移到main函数运行的操作。类似于4.9节示例中营造的中断返回。),L6调用main函数有三个参数,main函数虚拟调用当前代码没有参数。其实这样一个L6函数调用了main函数,这里只是虚拟设置一个这样的返回地址,便于当mian函数不正常退出的时候知道是出了什么问题。注意此时栈中保留了数据。
设置完main函数返回的需要的环境之后,跳转到setup_paging(198行)执行。
对149行,注意此处字节对齐是2的幂次,但是现在gas好像已经改成实际字节值,在这里应该是4。这个在编写的时候要核实。
对默认中断处理函数ignore_int:首先是注释中说的虽然ds,es,fs和gs为16位寄存器,但是仍然会以32位的形式入栈。这段代码实际实现的就是在触发未定义中断时,调用_printk显示int_msg的值。之前有了解过gcc编译的时候会在函数名之前加下划线,也就是注释中说的内部表示法,这个还需进一步确认。
174-196行的注释似乎是在说1MB以内的内存区域被定义为内核区,内核专用,如果内核需要使用1MB以外的内存区域,则需要向内存管理程序申请。
而且注意,页目录表是统一的,但是页表是每个进程独有的。这边还是比较糊涂,后面好好理解一下
对199-202行:主要是第202行的三个指令,首先是cld,其作用是将标志寄存器eflag的方向标志位df清零;rep:重复执行某一条指令,其中ECX的值是重复的次数;stosl:将eax中的值拷贝到ES:EDI指向的地址,如果df置位,则edi递减,如果df复位,则edi递增,前面的cld是为了设置方向为递增。es在
前面28行设置过,是0x10,指向内核数据段。
对第203-206行:设置页目录前4项。页目录表项的格式为:
注意这里的页帧地址是实际的物理地址。这里设置末3位为1,说明是转换有效,可读/写/执行,任何特权级程序都可以访问页面。以第1个页目录项(从0开始数)为例,其为0x2000+0x7=0x2007,说明第二个页表的起始物理地址为0x2000,且次页表中的各个页帧数据都是转换有效(存在)、用户可读写执行,且任何特权级都可以访问,参考4.4.2节。
对207-212行:Linux0.11最多索引16MB的内存,因此只需要4个页页表即可,每页1024项,每项4kb,则一页是4MB,4页16MB。这段代码是从后向前写的,即先第3个(从0开始数)页表的最后一项,其实地址为0x4000+4092。对这一项来讲,其对应的物理内存地址为16MB中的最后4kb,然后设置后12位标志位为7,也就是0x1000000-0x1000+0x7=0xfff007,实际的物理地址。然后前面一项是在这一项的基础上减去4kb,也即211行做的事情。这里std是将df置位,即edi单调递减。最后jge的意思为:大于或等于转移,也即当eax大于等于0时,转移,直到eax小于0结束。注意,按照这样的映射规则,0x0000 0000,对应的是就是第0页页表的第一项(从0开始数),按照上面的规则,最后对应的页表项应该是0x0000 0007,也就是物理地址0x0000 0000起始处的一页,因此在后面开启分页之后,gdt表和idt表是不用变的,是线性地址和物理地址是一样的。对其他的线性地址呢?比如0x0000 1000起始的一页,其线性地址应该为
0b 0000 0000 0000 0000 0001 0000 0000 0000 也即 0x0000 1000,也是一致的。按照这个页表线性地址和物理地址在16MB范围内应该都是完全一致的吧。这个后面再映证。
对213-214:这边注意CR3是实际物理地址。
对218行:注意这里的返回是函数返回,返回到前面140行压入堆栈中的main程序的地址。此外还会刷新预取指令队列,因为开启了分页机制,所以要重新来。mian函数已经加载到内存中了,其保存在栈中的地址应该是EIP,也即虚拟地址中的偏移部分,需要与CS联合查询GDT表才能得到线性地址,此时开启了分页,线性地址又需要查找页表变成物理地址。有些复杂,总担心是不是会出问题,不过好在这里好像分页之后线性地址也是与物理地址相等的,因此问题不大应该。后面要好好看看内存分配机制。
后面是敢于gdt和idt的一些数据定义。Linux0.11中gdt表与idt表一样长度是256个描述符。
对idt的设置前面set_idt函数已经讲过。
gdt中各个表项的意义,在前面也提到过,同时可以参考4.9节的说明。注意这边把第3个(从0开始数)gdt描述符空出来了,前面有提到过,这是原来想给syscall使用的。
整个head.s的大致流程为:
6.4.3 其他信息
6.4.3.1 程序执行结束后的内存映像
head.s程序运行结束之后,内存中的内容分布为:
6.4.3.2 Intel 32位保护运行机制
注意idtr、gdtr和ldtr中保存的是线性地址,而不是实际的物理地址,当然在本程序中映射规则决定线性地址和物理地址相同。
在保护模式下,程序运行时可以使用GDT中的描述符以及当前任务的LDT中的描述符。Linux0.11最多可以有64个任务同时执行。
目前16MB内存好像都设置了页表,并且存在位P都设置了1,后面该怎么组织管理呢?如果系统实际内存不到16MB,又该怎么办呢?后面好好看看内存管理部分
6.4.3.3 前导符(伪指令)align
在3.2.6.1节有过介绍。这里再次总结:
- 作用:在编译时指示编译器填充位置计数器(类似指令计数器)到一个指定的内存边界处。
- 目的:提高CPU访问内存中代码或数据的速度或效率。
- 格式:.align val1, val2, val3
- val1: 需要的对齐值
- val2: 填充字节指定值,默认值为0
- val3:指明最大用于填充或跳过的直接数,如果进行边界对齐会超过val3,则不会进行对齐操作
- 如需要val3但是想省略val2,放置两个逗号即可。(这一点很多代码中有,但是我这是第一次看到作者把这一点指出来,不得不说,作者是真的够细。对初学者真的友好)
对使用a.out以及elf目标格式.align指令的不同在于:
6.5 本章小结
作者又对三个.s文件实现的大致功能进行了总结,并且说明了下一章详细描述init/main.c