目录
1. 内核镜像的构建
1.1 内核源码结构
1.1.1 boot
1.1.2 fs
1.1.3 include
1.1.4 init
1.1.5 kernel
1.1.6 lib
1.1.7 mm
1.1.8 tool
1.2 Makefile分析
1.2.1 内核编译概述
1.2.2 重点流程说明
1.2.3 内核镜像的使用
1.3 build工具分析
1.3.1 处理参数
1.3.2 处理bootsect模块
1.3.3 处理setup模块
1.3.4 处理system模块
2. 计算机核心工作原理
2.1 核心 = 取指 + 执行
2.2 起始工作条件
2.2.1 CS:IP初始值
2.2.2 从BIOS开始
3. 启动过程详解
3.1 BIOS阶段
3.2 bootsect阶段
3.2.1 入口状态
3.2.2 迁移bootsect
3.2.3 设置栈空间
3.2.4 加载setup组件
3.2.5 获取磁盘参数
3.2.6 打印提示信息
3.2.7 加载system组件
3.2.8 判断root device
3.2.9 跳转到setup执行
3.2.10 其他说明
3.3 setup阶段
3.3.1 读取硬件系统参数
3.3.2 迁移system模块到0地址
3.3.3 设置gdt & idt
3.3.4 使能A20地址线
3.3.5 重置外部中断向量
3.3.6 使能保护模式
3.3.7 跳转到system执行
3.4 head.s阶段
3.4.1 设置段寄存器
3.4.2 设置栈空间
3.4.3 重新设置idt
3.4.4 重新设置gdt
3.4.5 判断A20地址线是否使能
3.4.6 判断是否有数学协处理器
3.4.7 设置页表
3.4.8 跳转到main函数
3.5 进入main函数
3.5.1 硬件系统参数的获取
3.5.2 主内存区初始化
Linux 0.11内核源码结构如下图所示,
下面简介各目录中的内容
boot目录包含引导启动程序,主要功能是当计算机上电时引导内核启动,将内核加载到内存中,并做一些进入32位保护模式前的系统初始化工作
① bootsect.s
磁盘引导扇区(也就是MBR),编译后驻留在磁盘的0磁头0磁道1扇区,被BIOS加载到内存的0x7C00处执行
② setup.s
用于读取PC的硬件配置参数,并将内核模块system移动到内存的适当位置
③ head.s
被编译链接在system模块的最前部分,主要进行硬件设备的探测设置和内存管理相关初始化
fs目录包含文件系统实现程序
程序之间的调用关系如下图所示,
说明:在使用块设备时,由于其数据吞吐量大,为了能够高效率地使用块设备上的数据,在用户进程与块设备之间使用了高速缓冲机制。在访问块设备上的数据时,系统首先以数据块的形式把块设备上的数据读入高速缓冲区,然后再提供给用户
其中,
① include/asm是体系结构相关头文件
② include/linux是Linue内核专用头文件
③ include/sys是系统专用数据结构头文件
init目录中只包含main.c文件,用于执行内核所有的初始化工作,然后切换到用户态创建新进程
kernel目录包含所有处理任务的程序
除blk_drv / chr_drv / math子目录之外的程序,分类如下图所示,
① blk_drv子目录
② chr_drv子目录
chr_drv子目录中的文件实现了对串行端口rs-232、串行中断、控制台终端设备和键盘的驱动
③ math子目录
当PC中没有数学协处理器,但是CPU又执行了数学协处理器的指令时,会触发int 7中断,该中断的中断处理函数即实现在math_emulate.c中,可用于软件仿真数学协处理器的功能
但是当前版本中,并没有实现仿真,只是打印信息并向进程发送SIGFPE信号
内核库函数用于为内核初始化程序init/main.c运行在用户态的进程(进程0、1)提供调用支持
mm目录包含管理主内存区的程序,其中page.s包含缺页异常(page fault)的处理函数
tools目录只包含build.c程序,该程序用于将各个目录中被分别编译生成的目标代码合并成一个内核镜像文件Image
Linux 0.11内核编译的总体步骤如下,
① 使用as86汇编器编译boot目录下的bootsect.s和setup.s,分别生成bootsect & setup模块
② 使用gcc编译器编译其他内核组件,并链接为system模块
③ 使用gcc编译器编译tools/build工具
④ 使用build工具将bootset / setup / system三个模块组合成内核镜像文件Image
说明:为何使用2种汇编编译器
对于Intel X86处理器,GNU编译器仅支持i386及其后的CPU,不支持生成运行在实模式下的程序
1.2.2.1 生成bootsect模块
① 使用8086汇编和链接器生成bootsect模块
② 链接时的-s选项表示要去除目标文件中的符号信息
1.2.2.2 生成setup模块
① 使用8086汇编和链接器生成setup模块
② 链接时的-s选项表示要去除目标文件中的符号信息
说明:仅使用8086汇编和链接器生成bootsect和setup模块,他们在80386启动后的实模式下工作;其余使用gcc编译的模块在32位保护模式下工作
1.2.2.3 生成system模块
① 链接在system模块最前端的是boot/head.o,该部分会被setup模块加载到内存中执行
② 在生成system模块后,会据此生成内核符号表System.map,System.map为所有内核符号及其对应地址的列表
说明1:nm命令符号类型
nm命令符号类型可通过其man手册查看,汇总如下
关于其中大小写的区别,一般而言,小写字母表示本地(local)符号,大写字母表示全局(global / external)符号
当然,也有特例,有些小写字母(u / v / w)表示的符号也是全局的
说明2:System.map的用途
内核本身并不使用System.map,但是其他程序(e.g. klogd、lsof、ps)都需要有一个正确的System.map文件。利用该文件,这些程序可以根据已知的内存地址查找出对应的内核符号名称,便于对内核的调试
以内核日志记录后台程序klogd为例,在系统启动时,如果没有使用参数向klogd传递System.map的路径,klogd将会依次搜索如下3个路径,
/boot/System.mapSystem.map /usr/src/linux/System.map
1.2.2.4 生成build工具
build工具的具体内容将在下节说明
1.2.2.5 生成内核镜像
① tools/system模块会被objcopy工具转换为纯二进制文件,之后以tools/kernel的形式加入内核镜像
② 注意此处调用tools/build工具时传递的参数及其顺序,详细分析见下节
③ 最后的sync同步命令用于将缓冲块数据立即写入磁盘并更新超级块
说明:关于ROOT_DEV
ROOT_DEV用于指定根文件系统所在位置,此处为空,则会使用build工具中的默认值,详细分析见下节
① 在实际PC上,将内核镜像Image烧写到软盘上就可以启动操作系统了
② 在bochs虚拟机中,是用一个文件来模拟磁盘,如果直接以操作系统镜像文件作为bochs的软盘,就相当于给bochs安装了操作系统
我们可以查看实验环境的配置,
其中,floppya设置了软盘(即a盘),ata0-master用来设置硬盘。boot: a表示将软盘作为系统启动盘
首先我们回顾一下Makefile中对build工具的调用命令
tools/build boot/bootsect boot/setup tools/kernel $(ROOT_DEV) > \
Image
这里可配置的参数只有ROOT_DEV,所以build工具的参数处理部分,主要就是处理根文件系统所在设备
可见默认的主次设备号为[3, 1],而实际可设置的主设备号只有2 & 3,我们来看一下,在块设备中,这2个主设备号对应什么设备
可见2代表软盘,3代表硬盘或光盘,那么默认值设置为3,也就是使用硬盘或光盘是否合理呢 ? 这就要看bochs虚拟机中的配置项(bochs/bochsrc.bxrc文件)
根据bochs虚拟机就是将根文件系统部署在ATA硬盘上,所以此处对根文件系统所在设备的设置是合理的
说明1:bootsect文件包含minix header
根据代码,build工具先读取了MINIX_HEADER(32B)大小的内容用于校验,然后在不修改文件访问偏移量的情况下继续读取512B的内容,所以bootsect应该为544B
说明2:MBR最后2B必须为0xAA55
MBR或者启动区是一种符合一定特征的区域,BIOS会根据该特征确定该存储设备是否为启动设备,而这个特征就是0磁头0磁道1扇区的最后2B为0xAA55
如果当前设备不是启动设备,BIOS会按顺序继续检查下个设备的0磁头0磁道1扇区内容是否符合特征。如果最终没有找到符合条件的设备,则报出无启动设备的错误
tips:我们修改BIOS的启动顺序,就是设置BIOS检查设备0磁头0磁道1扇区的顺序
由于使用0填充,setup模块在Image中固定占用4个扇区,即2KB
说明:setup模块固定占用4个扇区,是因为在bootsect中固定加载4个扇区的内容到内存。所以要想增加setup模块的大小,就要同步修改bootsect模块
可见系统对system模块的大小是有限制的,该限制也是与bootsect模块的实现有关,可见bootsect.s中的注释(注释中计算的196KB是以1000个B为1KB)
说明:Image镜像布局
build工具最终生成的Image镜像布局如下图所示,
计算机的核心工作原理源自冯诺依曼的存储程序思想,其中,
① 将程序和数据存放在计算机内部的存储器中
② 计算机在程序的控制下一步一步进行处理
CPU一旦上电之后,就需要开始进行"取指 + 执行"操作,这里就需要处理如下2个问题,
① CPU上电后从哪个地址开始取指
② CPU上电后要访问的地址具备访问条件
① X86 CPU上电后默认运行在16位实模式
② [CS:IP]初始值由硬件设置为[0xFFFF:0x0000],也就是从0xFFFF0地址开始取指执行
由于CPU从0xFFFF0地址开始取指执行,就需要理解如下2个问题,
2.2.2.1 0xFFFF0是哪个区域
X86 CPU在实模式下的寻址范围为1MB(2 ^ 20),其memory map如下图所示,
可见0xFFFF0位与ROM区,也就是BIOS所在区域
说明1:并非所有memory map都分配给内存
如上图所示,就是将0xF0000地址开始的部分映射到了BIOS ROM,以便配合系统启动。所以可在实模式下寻址1MB,并不是说就完全配置1MB内存
说明2:为何只能从ROM开始取指执行
因为在系统刚上电时,RAM尚未初始化,因此其中没有任何有效内容,因此只能从含有固化指令的ROM中取指执行
说明3:关于BIOS中断向量表
中断向量表是实模式中断机制的重要组成部分,记录所有中断号对应的中断服务程序的内存地址
16位实模式下的中断向量表的起始位置固定在0x00000处,共有256个中断向量,每个中断向量占4B,其中2B是CS的值,2B为IP的值。每个中断向量都指向一个具体的中断服务程序
作为对比,32位保护模式下的中断机制使用中断描述符表(IDT),且位置不固定,可以由操作系统的设计者灵活安排,由IDTR来记录其位置
2.2.2.2 第1条指令做什么
从0xFFFF0到0xFFFFF只有16B,一般部署一条跳转指令,跳转到ROM中的某个区域,开始执行BIOS的内容
说明:BIOS的功能
BIOS(Basic Input Output System,基本输入输出系统)包含的代码完成如下功能,
① 对基本硬件进行检测,e.g. 主板检测、内存检测
如果检测中发现异常,则终止启动
② 提供一些让用户调用硬件基本输入输出功能的子程序,即BIOS中断提供的服务
这点是通过在物理地址0处初始化中断向量实现的
① BIOS完成硬件检测后,如果没有异常,则进入下一步启动流程
② BIOS根据设置的启动设备顺序,读取该设备0磁头0磁道1扇区(即启动扇区)并判读其启动标志(0xAA55)
这里是将启动扇区读取到内存的[0x07C0 : 0x0000] = 0x7C00处
③ 如果启动标志判断成功,则跳转到0x7C00处执行
说明1:在本实验环境中拷贝到0x7C00处并执行的内容,就是由bootsect.s编译出的启动扇区内容
说明2:为何将启动扇区拷贝到0x7C00处
第一个BIOS开发团队是IBM PC 5150 BIOS,他假设其所要服务的操作系统需要的最小内存为32KB。BIOS希望自己所加载的启动区代码尽量靠后,这样比较安全,不至于过早地被其他程序覆盖掉
可是如果仅保留512B又感觉太悬了,还有一些栈空间需要预留,因此扩大到1KB。这样32KB的末尾是0x8000,减去1KB(0x400),就得到0x7C00(31KB)
① bootsect.s编译出的启动扇区内容被拷贝到0x7C00地址处
② [CS:IP] = [0x07C0:0x0000]
说明1:将bootsect整体从[0x07C0:0x0000]拷贝到[0x9000:0x0000]
本次迁移是为后续拷贝system组件腾出空间,详解后文分析
说明2:使用jmpi断间跳转(jump intersegment)继续执行bootsect
跳转目标为[CS:IP] = [0x9000:go],也就是跳转到拷贝后的bootsect的go标号处继续执行
说明3:因为整体将bootsect组件从0x0C700迁移到0x90000处,所以需要更新段寄存器的值,更新的基准就是跳转时已更新的CS寄存器值
这是启动过程中首次设置栈空间,栈空间被设置在0x9FF00处
本段操作将setup组件所占据的4个扇区读取到内存的0x90200处
说明1:int 0x13服务
我们以int 0x13服务为例,说明BIOS中断的使用方式。inx 0x13为直接磁盘服务(Direct Disk Service),可用于读取磁盘系统状态,读写扇区等
此处使用的是读取扇区功能,入口参数如下,
AH = 0x02:读取扇区子功能号
AL = 0x04:要读取的扇区数,此处为setup组件的4个扇区
CH = 0x00:柱面编号
CL = 0x02:扇区编号
DH = 0x00:磁头编号
DL = 0x00:驱动器编号,其中0x00 ~ 0x7F为软盘,0x80 ~ 0xFF为硬盘
[ES:BX] = [0x9000:0x0200] = 0x90200:缓冲区地址
根据代码中的参数,就是从0磁头0柱面2扇区开始,读取4个扇区的内容到内存的0x90200处,也就实现了加载setup组件的功能
读取扇区功能的出参如下,
CF:CF为0表示操作成功,所以可以用jnc判断操作结果
操作成功时,AH = 0x00,AL = 传输的扇区数
操作失败时,AH = 状态代码
当读取扇区失败时,bootsect有如下代码片段,
mov dx,#0x0000
mov ax,#0x0000 ! reset the diskette
int 0x13
j load_setup
此处以AH = 0x00为参数调用int 0x13中断,可以实现磁盘系统复位功能
参考资料:
BIOS 中断大全_Throne的博客-CSDN博客_bios中断
说明2:栈空间大小
指定的栈空间地址为0x9FF00,拷贝setup组件后的上界为0x90A00,因此可用的栈空间为0x9FF00 - 0x90A00 = 0xF500(约61KB)
说明:AH = 0x08 + int 0x13中断,用于读取驱动器参数
入口参数如下,
AH = 0x08:子功能号
DL = 0x00:驱动器编号
出口参数如下,
CF:CF为0表示操作成功,操作失败时,AH = 状态代码
操作成功时,
BL可判断软盘类型(0x01 - 360K / 0x02 - 1.2M / 0x03 - 720K / 0x04 - 1.44M)
CH = 柱面数的低8位
CL的bit[7:6] = 柱面数的高2位
CL的bit[5:0] = 扇区数
DH = 磁头数
DL = 驱动器数
[ES:DI] = 磁盘驱动器参数表地址
此处主要是为了获取磁道的扇区数,后续拷贝system组件时会使用该值
说明:int 0x10为显示服务(Video Service),其中AH = 0x03为读取光标信息,AH = 0x13为在Teletype模式下显示字符串
在bochs虚拟机中显示效果如下,
read_it函数中,使用int 0x13中断,将system组件加载到内存0x10000处
system组件从0x10000开始,bootsect的下界为0x90000,所以中间有0x80000(512KB),足够容纳system组件(默认上限只有192KB)
此处指定的默认root device为0x0306,该编号表示第2个硬盘的第1个分区,之所以是第2个硬盘,是因为0x0300表示/dev/hd0,代表整个第1个硬盘;0x0301 ~ 0x0304表示/dev/hd1 ~ /dev/hd4,即第1个硬盘的4个分区
所以0x0305表示/dev/hd0,即整个第2个硬盘,0x0306表示/dev/hd1,即第2个硬盘的第1个分区
说明1:上述命令方式是Linux的老式命令方式,从Linux 0.95开始已使用与现在相同的命名方式,主要差别在于表示整个硬盘的设备文件名不再有后缀序号,而分区设备文件名依然从1开始
说明2:如上文所述,build程序会将bootsect组件中的root device修改为0x0301,也就是第1个硬盘的第1个分区,这也是与bochs的设置匹配的
此处使用断间跳转,将[CS:IP]设置为[0x9020:0x0000],跳转到setup组件运行
① bootsect.s段重叠
在bootsect.s的起始部分,以如下方式定义了代码段、数据段和bss段
此处说明.text / .data / .bss 3个段重叠
说明:段起始与段终止标号
代码中还定义了begtext / begdata / begbss标号,用于标识段的起始地址;相应地,在bootsect.s的结尾,定义了endtext / enddata / endbss标号
② 启动设备标志
在bootsect的结尾,存储了启动设备标志0xAA55
说明1:为何获取参数
操作系统初始化必备的一些基本参数需要在setup阶段获取,比如内存大小、硬盘大小等
说明2:如何获取参数
通过BIOS中断获取,这也说明在setup阶段仍然要使用BIOS部署在0地址处的中断向量表
说明3:如何存储参数
在获取参数前,将DS寄存器设置为0x9000,后续通过间接索引内存。比如将光标位置存储在[0],也就是[0x9000:0x0] = 0x90000
可见参数被存储在原先bootsect组件所在区域,覆盖了原先的内容。这么做是安全的,因为bootsect组件此时已执行完毕
说明4:存储哪些参数
setup阶段读取并保存的参数如下,
注意0x901FC(= 0x90000 + 508)处存储的root device编号,该编号在bootsect阶段设置
说明1:迁移system模块之前关中断
因为要将system模块迁移到0地址处,将覆盖BIOS设置的中断向量表,后续会部署保护模式下的idt。在此过程中CPU无法正确响应中断,所以此处先关闭中断
说明2:拷贝过程
将0x100000 ~ 0x8FFFF的内容拷贝到0地址处,每次拷贝64KB,循环8次,共512KB,该长度超过system组件的长度
下面给出前3次拷贝的参数示例,
第1次拷贝 |
第2次拷贝 |
第3次拷贝 |
|
源地址[DS:SI] |
[0x1000:0x0000] |
[0x2000:0x0000] |
[0x3000:0x0000] |
目的地址[ES:DI] |
[0x0000:0x0000] |
[0x1000:0x0000] |
[0x2000:0x0000] |
说明3:为什么要将system迁移到0地址处
将system迁移到0地址处,统一了system组件的运行地址和链接地址,相当于代码重定位
说明4:为何bootsect阶段不直接将system加载到0地址处
如上文所述,setup阶段仍需要使用BIOS设置的中断向量表获取硬件参数,所以在该阶段不能覆盖中断向量表
setup阶段会设置CPU进入32位保护模式,在此之前需要设置gdt & idt
说明1:为何要设置DS为0x9020
gdt / idt_48 / gdt_48标号在setup.s中定义,链接地址以0为基地址。但是setup组件被拷贝到0x90200地址处运行,所以实际寻址时要以0x90200为基地址
说明2:idt表与lidt
① idt为保护模式使用的中断描述符表,在setup阶段其实并不设置,实际设置由system组件完成
② lidt指令将idt表信息加载到IDTR(Interrupt Descriptor Table Register)寄存器中,该寄存器保存了idt的16位表界限与32位基地址
③ idt_48标号存储的就是idt的表界限与基地址,此处设置表界限为0,即为空表
说明3:gdt表与lgdt
① gdt为保护模式使用的段描述符表,在setup阶段因为要进入保护模式所以临时设置,在system阶段会重新设置
② setup中设置了3个段描述符,第1个为dummy项,实际不使用;第2个为内核代码段描述符;第3个为内核数据段描述符
其中内核代码段 & 内核数据段段机制均为0,段界限均为8MB,满足以保护模式运行system组件的需求
③ lgdt指令将gdt表信息加载到GDTR(Global Descriptor Table Register)寄存器中,该寄存器也是保存gdt的16位表界限与32位基地址
④ gdt_48标号存储的就是gdt的表界限与基地址,其中,
a. 表界限为0x800 = 2KB,即256个GDT表项(每个GDT表项8B)
b. 32位基地址为0x0009 0200 + gdt_offset,此处的设置方法也考虑到了setup组件的实际运行地址
需要注意的是,由于setup组件上限为4个扇区(2KB),所以0200 + gdt_offset不会超过2B的范围,所以将512+gdt存储在低2B是合理的
说明4:当前内存布局如下图所示,
① A20地址线问题是因为X86体系结构需要兼容实模式与保护模式
② 在16位实模式下,仅使用20根地址线(A0 ~ A19),超出0xFFFFF的寻址将会绕回
③ 在32位保护模式下,寻址范围超过1MB,因此需要使能A20地址线(在CPU启动时,默认条件下A20地址线是禁止的)
④ 根据不同兼容机使用的芯片集,使能A20地址线的方式有多种,比较经典的是使用8042键盘控制器上的空闲引脚
① 在PC/AT系列兼容机中,使用两片8259A芯片管理15个外部中断
② CPU在响应中断时,8259A芯片从数据线D7 ~ D0将编程设定的当前服务对象的中断号送出,CPU由此获取对应的中断向量值,并执行中断服务程序
③ 在保护模式下,int 0 ~ int 31被Intel预留作为CPU的陷阱中断,而外部中断对应的中断号从int 32(0x20)开始
因此需要对8259A进行编程,重置中断向量号为int 32 ~ int 47
说明1:CR0寄存器
CR0寄存器中的PE位为保护模式开启位(Protection Enable),如果设置了该比特位,CPU就会在保护模式下运行
说明2:lmsw指令
lmsw(Load Machine Status Word)指令用于加载机器状态字,也就是设置CR0寄存器。此处将CR0寄存器设置为0x0001,也就是开启了保护模式,之后的所有代码将会在保护模式下运行
终于到了激动人心的时刻,jmpi 0, 8将是第1条在保护模式下执行的指令,该指令会将[CS:EIP]设置为[0x0008:0x00000000],但是这里的CS不再是段基址,而是段选择符
说明1:段选择符结构
在保护模式下加载到段寄存器中的,就是段选择符,具体构成如下,
① 索引
a. 段描述表的索引,用以指明当前使用的是哪个段描述符
b. 索引字段共13位,也就是最多可索引8K个段,实际的段远没有这么多
② TI(图中有误)
TI(Table Indicator)为选择域,
当TI = 0,从GDT表选择段描述符
当TI = 1,从LDT表选择段描述符
③ RPL
RPL(Request Privilege Level)为请求者的特权级,保护模式会比较RPL与要访问段的DPL,只有高特权级可以访问地特权级,低特权级无法访问高特权级
对应到此处设置的8(0b1000),
TI = 0,即使用GDT表
索引 = 1,即选择GDT表中的第1个段描述符,也就是内核代码段描述符
RPL = 0,即请求的特权级为0
说明2:段描述符结构
段描述符共8B,组成方式如下,
① 32位段基址
此处段基址为0x00000000
② 粒度位G位:
当G = 0时,段长以字节为单位,则最大段长为2^20B = 1MB
当G = 1时,段长以页(4KB)为单位,则最大段长为2^20 * 4KB = 4GB
此处粒度为4KB
③ 20位段界限,即段长,粒度由G位控制
此处段界限为0x007FF,结合4KB的粒度,即为2048 * 4KB = 8MB
④ 缺省操作数大小位D位:
当D = 0,操作数为16位
当D = 1,操作数为32位
此处为32位
⑤ 存取权限字节
a. 存在位P位(Present):表示该段是否在内存中,P = 1在内存中,P = 0不在内存中
b. 描述符特权级DPL(Descriptor Privilege Level):值为0 ~ 3,用来确定这个段的特权级,即保护等级
c. S位(System):表示这个段是系统段还是用户段,S = 0为系统段,S = 1为用户程序的代码段、数据段或堆栈段
d. 类型位,共由3位组成,不同的类型组合可用于标识不同类型的段
e. A位(accessed):表示一个段最近是否被访问过,准确地说是指明从上次操作系统复位后,该段是否被访问过
此处为0x9A(0b1 00 1 101 0),即P = 1,DPL = 0,S = 1,类型 = 101,A = 0
说明3:在保护模式下跳转到head.s执行
① 执行jmpi 0,8指令跳转时,CPU已经处于32位保护模式
② 经过段机制,将调转到物理0地址处运行
③ 之前已经将system组件拷贝到物理0地址处,且head.s被链接到system组件的起始位置,因此CPU的控制流程将跳转到head.s执行
补充:
启动过程中完整的内存状态如下图所示,
如前文所述,目前CPU已运行在32位保护模式,段寄存器中存储的已经不是段基址而是段选择符。在跳转到head.s时已经设置了CS,此处设置其他段寄存器
说明1:此处将DS / ES / FS / GS均设置为0x10(0b10 0 00),根据段选择符结构,
索引 = 2,TI = 0,RPL = 0
也就是使用gdt的第2个表项,即内核数据段
注意1:这里使用的gdt还是setup阶段设置的,head.s中会重置
注意2:这里没有设置SS,SS将在下一个步骤中设置
说明2:关于pg_dir标号
此处在startup_32标号的位置也设置了pg_dir标号,也就是pg_dir标号也设置在head.s的起始位置
head.s后续将设置页目录,页目录的位置就在pg_dir标号处,也就是页目录部署在物理0地址处,因此页目录的设置会覆盖部分已经运行过的head.s代码
这是启动阶段第2次设置栈空间(第1次是在bootsect阶段)
说明1:关于lss指令
① lss指令属于设置段寄存器指令(Load Segment Instruction),该组指令的功能是将内存单元的一个"低字"传送给指令中指定的16位寄存器,把随后的一个"高字"传给相应的段寄存器(DS、ES、FS、GS和SS),指令格式如下,
lds / les / lfs / lgs / lss Mem, Reg
其中lsd / les在8086 CPU中就存在,而lfs / lgs / lss是80386及之后的CPU中才有的指令
② 若Reg是16位寄存器,则Mem必须是32位指针;若Reg是32位寄存器,则Mem必须是48位指针,其低32位给指令中指定的寄存器,高16位给指令中的段寄存器
说明2:_statck_start在何处定义
stack_start在kernel/sched.c中定义,此处同时定义了栈的地址与栈所在段的选择符
此处栈的大小为4KB,之所以右移2位,是因为数组类型为long,在32位CPU中是4B
同时,SS的段选择符也设置为0x10,也就是内核数据段
说明3:满减栈类型
esp的值被设置为&user_stack[PAGE_SIZE>>2],是user_stack最后一个元素的后一个元素位置,可见此处栈的类型为满减栈
说明4:关于变量名称的存疑
sched.c中定义的变量名为stack_start,而head.s中使用的标号名为_stack_start,多了一个下划线
后续main函数名也类似,在head.s中使用的标号为_main
此处只能猜测是相应的编译器支持这种用法
在《Linux内核完全注释》中,确实提到添加下划线是编译后模块中的内部表示法
此处将所有256个中断向量均设置为ignore_int,实际中断处理函数将在后续初始化过程中设置
说明1:中断描述符表项结构
代码中eax & edx分别用于存储中断描述符表项的4B,其中,
eax = 段选择符0x10(内核代码段) + ignore_int_offset(使用bit0 ~ bit15已经足够)
edx = P(0x1) + DPL(0x00)
后续将eax & edx的内容在循环中写入_idt标号指定的内存处,共256个中断描述符
说明2:ignore_int实现
ignore_int的核心就是打印一行提示字符串
补充:在将ds / es / fs入栈时,虽然是16位的段寄存器,但是入栈时仍然会以32位形式入栈,即占用4B
这是因为在段描述符中,缺省操作数大小就是设置为32位
说明3:_idt标号
_idt标号是存储idt的位置,初始值为全0
说明4:idt_descr标号
idt_descr标号存储的内容,共lidt指令设置IDTR
可见此处设置的段描述符与bootsect阶段设置的相比,只是将段界限从8MB扩大到16MB(4096 * 4KB)
说明1:为何在head.s中要重置gdt & idt
① 之前的gdt & idt在setup阶段设置,是为进入32位保护模式临时设置的
② setup阶段设置的gdt & idt在0x90200所在段,这部分内存后续会被缓冲区覆盖,所以在head.s中重置了gdt & idt
③ 又由于head.s所在的system组件将常驻在内存0地址处,所以在这部分设置gdt & idt是安全且合理的
说明2:剩余252个表项的使用
这252个表项用于存储任务的局部描述符(LDT)和任务状态段描述符(TSS),因此最多可以容纳126个任务
但实际上Linux 0.11中设置的任务上限为64个(因为每个任务分配占用64MB线性地址空间,所以4GB只能容纳64个任务),所以预留的表项个数是足够的
说明3:设置完gdt后需要再次重置段寄存器和栈空间
注释中说明CS已经在setup_gdt中重置,猜测是call setup_gdt & setup_gdt中的ret指令对将CS入栈再出栈,实现了CS的重置
判断A20地址线是否使能的方法,就是向0x0地址处写入1,然后与0x100000(1MB)地址处的内容进行比较。如果相等,则说明访问绕回,A20地址线没有使能,此时将陷入死循环
根据判断结果,会去设置CR0寄存器
设置页表这段操作比较精彩,实际函数为setup_paging,之前的操作是为了借助函数出栈跳转到main函数,这点在下节说明
3.4.7.1 页表设置概述
目标:使用2级页表恒等映射16MB物理内存
第1级为页目录,其中存放页表的信息
第2级为页表,其中存放物理页面的信息
3.4.7.2 页目录与页表的划分
① 每个虚拟页面 & 物理页框均为4KB
② 页目录项 & 页表项结构相同,每个表项为4B,所以一个页面最多可以容纳1024个表项
③ 2级页表中页目录 & 页表的位数划分有各种组合,但是二者均为10bit时效率最高,因为填满一个页目录或者页表正好为4KB,在一个页面中
④ 页目录与页表的位数划分是一个软硬件结合的过程,需要硬件支持相应的分页模式,80386使用的就是上图中10bit + 10bit的划分模式
⑤ 使用10bit + 10bit的划分模式,每个页目录项指向一个页表,每个页目录可以映射4MB(2^22)内存。对于目前的16MB内存,需要1个页目录 + 4个页表
⑥ 在页表中,每个页表项映射4KB内存,所以一个页表可以映射4MB内存
3.4.7.3 页表项结构
注意:页表项结构是由体系结构而不是操作系统决定的,操作系统需要适配体系结构
无论是页目录项还是页表项,都使用上图的表项结构,每个表项4B
下面简要说明下页表项的页面属性,
① P位(Present)
P = 1表示页装入到内存中;如果P = 0表示不在内存中
说明:当要访问的页目录项或页表项的P位为0时,说明此页没有装入内存中,此时分页机制在转换线性地址的同时会产生一个缺页异常(这就是内存管理的核心机制)
② R/W(Read / Write)和U/S(User / Supervisor)
这2位为页表或页提供硬件保护,当U/S = 0时,只有处于内核态的操作系统才能对此页或页表进行寻址
③ PWT位(Page Write-Through)
表示是否采用写透方式,当PWT = 1时,写透方式就是既写内存也写高速缓存
④ PCD位(Page Cache Disable)
表示是否启用高速缓存,当PCD = 1时,表示不启用高速缓存
⑤ Page Size(bit 7)
只适用于页目录项,如果设置为1,页目录项指的是4MB的页
tips:可理解为大页模式,即只有一级页表
3.4.7.4 页目录填充
首先说明页目录和页表的位置,在head.s中,将页目录设置在起始位置,占用1个页面;其后为4个页表,每个占用1个页面
此处填充了页目录中的4项,内容为页表地址 + 权限位(0x7),对应的权限为,
P = 1,即页表在内存中
U/S = 1 & R/W = 1,即在用户态 & 内核态均可以对页表进行读写
3.4.7.5 页表填充
首先概述此处页表填充的方法,
① 总共要填充的表项为4(页表个数)*1024(页表项个数)= 4096
② 填充时从最后一个页表项开始,页就是pg3的最后一个表项,位置为pg3 + 1023 * 4 = pg3 + 4092
代码分析如下,
// edi中存储最后一个表项的地址
movl $pg3+4092,%edi
// eax中存储最后一个表项的表项值
// 其中最后一个物理页面的地址为0xFFF000
// 权限位则均为7
movl $0xfff007,%eax
// 方向设置位,edi值递减
std
// 填充一个表项
1: stosl
// 每填充一个表项,物理地址减4KB
subl $0x1000,%eax
// 循环填充
jge 1b
3.4.7.6 启动分页机制
① 将页目录地址写入CR3寄存器,此处页目录的地址为0
② 将CR0寄存器的bit 31置1,启动分页机制
3.4.7.7 如何确保页表初始化的覆盖是安全的
其实页目录 & 页表使用的空间是预留出来的,after_page_tables标号所在的位置已经在0x5000(20KB)之后
因此跳转到after_page_tables时使用的是jmp指令,因为也无法返回了,跳转处的内存即将被页表覆盖
说明1:关于软盘缓冲区
从上图中可见,在内存中预留了1KB的软盘缓冲区(_tmp_floppy_area)
说明2:此处设置的4个页表是内核专属页表,将来每个用户进程都会有他们专属的页表
Linux 0.11使用了一种很巧妙的方式跳转到main函数,可以拆分为如下2个步骤,
① 人为压栈 + jmp跳转
② 无call调用的ret返回
要理解这一用法,需要先理解call & ret指令对的作用
3.4.8.1 函数调用 & 返回指令
① 函数调用
call 0x12345
// pushl %eip (*)
// movl 0x12345, %eip (*)
② 函数返回
ret
// popl %eip (*)
特别说明:上述标有(*)的指令都是作为说明用的伪指令,不能被程序员直接调用。因为eip的值不能被直接修改,只能通过特殊指令间接修改
因此call & ret指令配对使用,即可以实现返回函数调用点继续执行
3.4.8.2 人为压栈 + jmp跳转
这里的人为压栈分为3组,
① 压入的3个0实际上是main函数的参数,分别对应envp、argv、argc,但是main函数实际并未使用
② pushl $L6压入的是main函数的返回地址
main函数不应该返回,如果返回,就会进入L6标号的死循环
③ pushl $_main压入的可以理解为setup_paging函数的返回地址,但是却以jmp的方式跳转到setup_paging函数,也就是此时栈顶元素为main函数地址
3.4.8.3 无call调用的ret返回
虽然以jmp的方式跳转到setup_paging函数运行,但是该函数依然调用ret指令,这样就可以将控制流跳转到main函数运行
说明:跳转到main函数时内存布局
至此,系统启动流程进入了C语言阶段,在本篇笔记中我们分析main函数中参数的获取和内存初始化的内容,其他组件的分析,随后续笔记逐渐展开
在setup阶段,通过BIOS中断获取了各种硬件系统参数,并存储在从0x90000开始的位置,这些参数将在系统初始化阶段被读取与使用
① root device设备信息
② 硬盘参数信息
③ 扩展内存信息
① 扩展内存信息即1MB内存之外,系统的内容容量,在我们的实验环境中,总的内存容量为16MB
② 实验环境中内存布局参数如下,
memory_end = 0x0100 0000(16MB)
buffer_memory_end = 4 * 1024 * 1024(4MB)
main_memory_start = 4MB
也就是说,主内存区为4MB ~ 16MB
说明1:Linux 0.11对内存的使用布局如下图所示,
① 系统内核system固定占用0 ~ 1MB内存
② 高速缓冲区是用于磁盘等块设备临时存放数据的地方,以1KB为一个数据块单位,内核程序可以自由访问高速缓冲区中的数据
此处为1MB ~ 4MB范围(注意需要扣除被显存和ROM BIOS占用的部分)
③ 主内存区是由内存管理模块通过分页机制进行管理分配,以4KB为一个内存页单位,内核程序需要通过内存管理模块分配使用内存页面
此处为4MB ~ 16MB范围
说明2:在计算memory_end时,将其与0xFFFFF000做按位与运算,是为了进行4KB对齐,不足1页的内存将被丢弃
3.5.2.1 主内存管理数据结构
内核中通过mem_map数组管理主内存区所有页面,该数组标识对应的页是否空闲
// 分页内存15MB,主内存区最多15MB
#define PAGING_MEMORY (15*1024*1024)
// 分页后的物理内存页数,15MB为3840个page
#define PAGING_PAGES (PAGING_MEMORY>>12)
static unsigned char mem_map [ PAGING_PAGES ] = {0,};
说明:mem_map数组管理的是从1MB开始的页面,对于1MB以内的内存空间,由于内核使用的数据在该空间,不将其列入内存管理范围内
3.5.2.2 mem_init流程
// 页面被占用标志
#define USED 100
// 内存低端(1MB)
#define LOW_MEM 0x100000
// MAP_NR宏将指定内存地址映射为页号
// 注意这里减去了不再主内存区的1MB
#define MAP_NR(addr) (((addr)-LOW_MEM)>>12)
void mem_init(long start_mem, long end_mem)
{
int i;
// HIGH_MEMORY作为内存上界
HIGH_MEMORY = end_mem;
// 将mem_map所有成员标记为USED
for (i=0 ; i>= 12;
// 将主内存区域页面标记为未使用
while (end_mem-->0)
mem_map[i++]=0;
}
初始化后的数组如下图所示,
补充:操作系统启动的基本过程如下图所示