操作系统实现(三):二级引导程序loader
本节背景
一级引导程序将处理器控制权交出后,便由二级引导程序完成主要的引导任务,包括硬件信息检测、处理器模式转换、页表配置等,并最终实现控制权向内核程序的转移。
本节目的
- 编写二级引导程序loader.asm
- 将二级引导程序装载到虚拟软盘镜像中
- 编写Makefile文件
实现
由于自本节起,每节的任务量及代码量陡增,故不再依次贴上对应模块的代码,只有关键部分放相应代码,完整代码在github上查看。
首先照例定义本部分需要用到的常量,并规定起始地址0x10000。
此地址与boot的起始地址0x7c00的规定不同,boot的起始地址是早期的Intel大叔们规定并延续至今,在BIOS中写死的;而loader的起始地址是我们在boot中自己设置的,是在物理内存中较为随便地找出一块合适的空闲区域放置(具体物理内存空间分配见文章末尾)。
接着寄存器设置、清屏及显示加载信息等操作。
在完成了上述准备工作后,便是二级引导程序的第一个重点——加载内核程序。
实模式 -> 保护模式 -> Big Real Mode
为了后续能够加载内核到1MB以上的内存,我们需要先打开A20地址线(关于A20地址线的补充知识见文末)。这里我们采用通过访问A20快速门来开启A20功能,即将0x92端口的第1位置位。:
; 打开A20地址线, 使用20根以上的地址总线, 以便能访问1MB以上的内存 |
当A20功能开启后,紧接着使用cli指令关闭外部中断,再通过lgdt指令加载保护模式段信息(关于GDT的补充知识见文末),并置位CR0寄存器的第0位来开启保护模式。当进入保护模式后,为FS段寄存器加载新的数据段值,并自动更新其缓冲区。一旦完成数据加载就从保护模式中退出,并重新开启外部中断。
整个动作一气呵成,实现了保护模式的开启和关闭。看似多此一举的代码,其目的只是为了让FS段寄存器可以在实模式下寻址能力超过1 MB,也就是进入传说中的Big Real Mode(关于处理器模式的补充知识见文末):
; 关闭中断 |
寻找内核文件
搜索kernel.bin的操作与在boot中搜索loader.bin相同,同样使用三层循环。
未找到内核文件
如果未在文件系统目录中找到内核文件名,则显示错误信息并停止运行。
加载并转移内核文件
如果搜索到内核程序文件kernel.bin,则将磁盘中的kernel.bin文件读取至内存中。
本系统将内核程序起始地址放置于物理地址0x100000(1 MB)处,因为1 MB以下的物理地址并不全是可用内存地址空间(见文末物理内存分布)。随着内核体积的不断增长,未来的内核程序很可能会超过1 MB,因此让内核程序跳过这些分布复杂的内存空间,从平坦的1 MB地址开始行进,是一个非常不错的选择。
但由于BIOS是运行在实模式下的,只能访问1MB以下的物理内存,因此我们在使用BIOS的INT 0x13号中断从磁盘读取内核文件时,只能先将其加载到1MB以下的某个临时缓冲区**(这里我们选用0x7e00处),之后再将其复制转移到1MB处:
FileFound: |
当Loader引导程序完成内核的加载工作后,软盘驱动器将不再使用,通过向I/O端口0x3f2写入控制命令关闭软驱马达:
Loaded: |
在使用out汇编指令操作I/O端口时,需要特别注意8位端口与16位端口的使用区别:out指令的源操作数根据端口位宽可以选用al/ax/eax寄存器;目的操作数可以是立即数或dx寄存器,其中立即数的取值范围只能是8位宽(0xff),而dx寄存器允许的取值范围是16位宽(0xffff)。
获取内存信息
使用BIOS的INT 0x15号中断来获取物理地址空间信息,并将其保存在刚刚加载内核使用的临时转存缓冲区(0x7E00)处,操作系统会在初始化内存管理单元时解析该结构体数组(包括可用物理内存地址空间、设备寄存器地址空间、内存空洞等):
; 获取内存信息 |
设置显示模式
本来这里打算直接设置为图形模式(关于显示模式的补充知识见文末)的,但考虑到内核开发前期几乎不会用到图形显示,而且还会徒增复杂度与工作量,遂决定还是先设置为文本模式,待完成了中断、内存管理和进程管理等工作后,如果还要继续进行用户图形程序的开发(即GUI)工作,再反过来重新改为图形模式也不迟,只是后期可能会麻烦一些。
这里直接调用BIOS的INT 0x10中断将显示模式设置为单色文本模式(VGA 80x25):
; 设置单色文本模式 |
文本模式显示效果(需要完成kernel main部分的编写):

如果,注意这里说的是如果,确实想要使用图形模式的话,则需要通过VBE(VESA BIOS Extensions)BIOS中断拓展来获取可用的SVGA模式信息并设置为合适的模式:
;设置图形模式 |
这里贴一下图形模式的显示效果(需要完成kernel main部分的编写):

进入保护模式
当我们使用BIOS中断完成对硬件信息的检测后,就没有必要继续停留在实模式(或者说“大实模式”)下了,是时候进一步迈入保护模式了,保护模式不仅限制了程序的执行权限,还引入了分页机制。而对于我们的系统来说,保护模式也只是一个跳板,用于后续继续跳到我们最终需要的64位的长模式。
为了进入保护模式,处理器需要依次完成以下工作:
- 启用A20:关于A20我们前面在进入“Big Real Mode”时已经开启过了。A20 地址线控制 CPU 是否能访问 1MB 以上的内存。实模式默认禁用 A20 地址线(向上溢出到 0x000000),必须手动启用。
- 关闭中断:在进入保护模式前,必须先关闭中断,以防止 CPU 在转换过程中响应实模式的中断,导致不可预知的行为。并在真正初始化具体的中断处理程序后重新打开中断。
- 加载GDT:由于保护模式使用段描述符而非实模式的段寄存器,因此需要定义 GDT 数据结构并需要使用
lgdt指令将其加载到 CPU 的GDTR寄存器。 - 加载IDT:进入保护模式后,CPU 不能再使用实模式的中断向量表(IVT,位于0x00000-0x003ff)。如果不加载新的IDT,CPU 可能会遇到异常。不过,如果内核最初不使用中断,可以暂时加载一个“空 IDT”以避免异常。
- 启用分页机制:保护模式本身只提供但不要求开启分页,如果确实需要高地址映射,需要将
CR0控制寄存器中用于控制分页机制的PG(Paging Enable)标志位(bit 31)置1。在开启分页机制(置位PG标志位)前,必须在内存中至少存在一个页目录(PD)和页表(PT)(分别占一个物理页4KB),并将页目录的物理地址加载到CR3控制寄存器(或称PDBR寄存器)。
启用分页时,需同时设置CR0的PE位(保护模式使能)。 - 使能保护模式:要开启保护模式,需要将
CR0寄存器的PE(Protection Enable)位(bit 0)设置为 1。 - 跳转到32位代码:进入保护模式后,实模式的分段机制不再适用,必须手动使用
jmp指令跳转到 GDT 定义的 32 位代码段,jmp指令会自动更新cs代码段寄存器。 - 重新加载段寄存器:对于其他段寄存器(
DS、ES、SS、FS、GS),进入保护模式后,需要重新加载以使用 32 位数据段。
这里我们暂时不开启分页机制,并使用一个临时的GDT及一个空的IDT:
ChangeMode: |
[ gdt] |
; 临时空IDT |
进入IA-32e模式
我们最终的目的是进入64位的长模式,同进入保护模式类似,开启64位模式需要以下步骤:
- 检查CPU是否支持IA-32e模式:首先,我们需要使用
cpuid指令检查 CPU 是否支持IA-32e模式。 - 加载 64 位 GDT:IA-32e模式的段结构与保护模式的段结构极其相似,不过此处的数据显得更为简单。因为IA-32e模式简化了保护模式的段结构,删减掉冗余的段基地址和段限长,使段直接覆盖整个线性地址空间,进而变成平坦地址空间。
- 启用物理地址扩展:如果处理器支持64位模式,则先置位
CR4控制寄存器的PAE(Physical Address Extension)标志位(bit 5),开启物理地址扩展功能,以将处理器支持的最大物理地址(而不是虚拟地址)从保护模式的32位(4GB)拓展到36位(64GB)(36 位物理地址是 Intel设计的限制,不是 PAE 技术本身的限制)。PAE必须在设置LME和PG之前开启。 - 配置页表:IA-32e模式必须使用 4 级分页,即
PML4(Page Map Level 4),并将页表根目录(顶层页表)基地址加载到CR3寄存器中。
由于我们loader的主要目标是尽快进入64位模式并加载内核,而配置页表只是进入64位模式的必要要求,因此,我们将只在loader进行基本的页表初始化,而到kernel的内核入口部分再构建正式的页表,提供完整的内存管理功能。如此的两次页表配置,固然有些重复嫌疑,但模块化的操作会使得复杂的工程变得稍许灵活且易维护(即loader只完成其必要的操作,各司其职),而这样重复的代价就显得可以接受了。 - 启用长模式:设置
IA32_EFER(Extended Feature Enable Register)寄存器的LME(Long Mode Enable)标志位(bit 8)以使能64位模式,但只有在CR0.PG=1(即分页使能后) 时才正式生效。
而IA32_EFER寄存器位于MSR寄存器组内,为了操作IA32_EFER寄存器,必须借助特殊汇编指令RDMSR/WRMSR。 - 开启分页:IA-32e模式必须开启分页,否则 CPU 会崩溃。但
LME必须在PG(在CR0控制寄存器的第31位)之前设置,即在LME使能前分页必须是关闭状态(在向保护模式切换的过程中未开启分页机制,便是考虑到稍后的IA-32e模式切换过程必须关闭分页机制重新构造页表结构)。此后PG 一旦开启,CPU 即切换到 64 位模式。 - 跳转到 64 位代码:到这里,我们已经完成了从保护模式到IA-32e模式的所有必要步骤。接下来就是使用一条
jmp指令跳转到64 位代码并配置数据段寄存器。
; 检测CPU是否支持64位模式 |
跳转到kernel
至此,处理器已成功切换到64位模式,接下来我们只需要一条跳转指令,即可和引导程序挥手告别,正式迈入内核开发:
; 至此,处理器完成了进入IA-32e模式前所有的准备工作 |
成果
在完成loader.asm文件的编写后,我们就可以使用nasm汇编器将其汇编成二进制文件:
> nasm loader.asm -o loader.bin |
由于此时我们的虚拟软盘镜像已经实现了FAT12文件系统的初始化,因此我就可以使用复制命令而不是强制写入命令将我们的loader.bin文件装载到虚拟软盘镜像bootloader.img中。这里我们先使用Linux自带的挂载指令mount将虚拟软盘镜像挂载到电脑文件系统的某个目录下,然后就可以使用cp命令将loader.bin文件复制到刚刚挂载的文件目录下即可完成loader文件的装载:
> sudo mount bootloader.img /media/ -t vfat -o loop |
此时使用十六进制阅读器打开bootloader.img会发现,在地址0x1400处(即FAT表起始扇区)保存着FAT12簇信息:
在地址0x2600处(即根目录区起始扇区)保存着文件目录/文件名信息:
在地址0x4400处(即数据区起始扇区)保存着文件内容:
继续使用上节的bochs命令运行该虚拟镜像模拟启动:

由于我们还没有装载kernel文件,因此loader会在搜索kernel文件时提示错误信息File Not Found!,但第一行的信息已经从原本的Hello,MyOS!变成了Loading…,说明我们已经成功运行在了二级引导程序中。
为了更好的演示效果,我们先编写一个临时的空kernel文件,里面只有一条使处理器停止运行的hlt指令:
; kernel.asm |
将其按照loader.asm装载的方式,先汇编,后复制到bootloader.img虚拟软盘镜像中去。方便起见,从这里我们就开始使用Makefile文件来辅助编译过程。编写Makefile文件如下:
# Makefile |
使用make bochs命令运行Makefile文件,模拟结果如下:
可以看到,之前的显示信息已经消失,这是因为我们在loader中重置了显示模式,目前虽然仍处于VGA 80x25的单色文本模式,但与之前的窗口不同,这是我们自己设置的。
关闭bochs模拟窗口,在bochs的终端信息中会发现:
表明我们成功进入64位的长模式!
完结撒花!!!
总结
- 完成二级引导程序loader的编写
- 完成处理器模式切换(实模式 - > 保护模式 - > 长模式)
- 成功将loader装载到虚拟软盘镜像,完成全部引导程序的编写
补充
物理内存分布
| 物理地址 | 用途 |
|---|---|
| 0x100000 - 1MB以上内存 | 内核及用户程序(自定义) |
| 0xf0000 - 0xfffff | 系统BIOS |
| 0xe0000-0xeffff | 扩展BIOS |
| 0xc8000-0xdffff | 保留 |
| 0xc0000-0xc7fff | 显卡BIOS |
| 0xb8000-0xbffff | 彩色文本模式显存 |
| 0xb0000-0xb7fff | 单色文本模式显存 |
| 0xa0000-0xaffff | VGA显存 |
| 0x07e00-0x9ffff | 保留 |
| 0x07c00-0x07dff | 引导扇区 |
| 0x00500-0x07bff | 保留 |
| 0x00400-0x004ff | BDA(BIOS数据区) |
| 0x00000-0x003ff | IVT(中断向量表) |
编址方式
- 虚拟地址(Virtual Address)是抽象地址的统称, 逻辑地址和线性地址都是虚拟地址的一种
- 逻辑地址的形式是段地址:偏移地址,逻辑地址最终都会被转换为线性地址, 再转换为物理地址。
- 线性地址是逻辑地址经过段机制转换后的地址空间中的一个平坦地址(Flat Address),是逻辑地址到物理地址的中间层,是分页机制的输入。如果不启用分页,那么线性地址就是物理地址。
- 狭义的虚拟地址是操作系统使用的概念(操作系统为每个进程提供的独立的地址空间),但线性地址是CPU使用的概念(CPU在段机制后、分页前看到的地址)。在启用分页后,这两个概念基本等价。
- 物理地址(Physical Address)是真实存在于硬件设备上的, 在处理器开启分页机制的情况下,线性地址需要经过页表映射才能转换成物理地址;否则线性地址将直接映射为物理地址。
显示模式
- 文本模式(Text Mode)
- VGA 80x25(默认)
- VGA 80x50
- 图形模式(Garphic Mode)
- VGA(Video Graphics Array)是 IBM 设计的标准(1987 年),只支持低分辨率。
- VESA(Video Electronics Standards Association)是一个行业组织,制定了显示标准。
- SVGA(Super VGA)是 VGA 的扩展(1989 年),但早期不同厂商实现不同。
- VBE(VESA BIOS Extensions)是 VESA 提出的 BIOS 扩展(1991 年),让软件能统一访问 SVGA 功能。
CPU模式
real mode(16位)
- 实模式作为Intel处理器家族诞生的第一种运行模式已经存在了很多年。现在它仅用于引导启动操作系统和更新硬件设备的ROM固件,为了兼顾处理器的向下兼容性,它将一直存在于处理器的体系结构中。
- 在Intel官方白皮书中,英文术语Real Mode或Read-Address Mode均指实模式。实模式的特点是采用独特的段寻址方式进行地址访问,处理器在此模式可直接访问物理地址。在实模式下,通用寄存器的位宽只有16位,这使得实模式的寻址能力极其有限,就算借助段寻址方式,通常情况下实模式也只能寻址1 MB的物理地址空间。
- 实模式采用逻辑地址编址(见文末补充知识)方式,通过段基地址加段内偏移地址的形式进行地址寻址,其书写格式为Segment:Offset。其中的段基地址值Segment保存在段寄存器中,段内偏移地址值Offset可以保存在寄存器内或使用立即数代替。
- 实模式下逻辑地址的段基址通过左移4位并于段内偏移相加组成线性地址, 这种逻辑地址编址方式将原本只有16位寻址能力的处理器扩展至20位,通过特殊手段(big real mode)可将实模式的寻址能力扩展至4GB。
big real mode(16位)
- 在实模式下, 可以轻松操纵BIOS等, 但却只能访问1M的内存;而在保护模式下, 可以访问4G的内存, 但使用BIOS中断却比较麻烦。
- 在开启A20地址线后, 处理器可以使用20根以上的地址总线, 段:偏移计算后的结果不必再回环, 可以访问1MB以上的内存(即使未进入保护模式)。
- 为了减少地址转换时间与编码的复杂性,处理器为保护模式下的
CS、SS、DS、ES、FS以及GS段寄存器各自加入了缓存区域,这些段寄存器的缓存区域记录着段描述符的基地址、限长和属性信息。当段选择子被处理器加载到段寄存器的可见区域(实际的16位寄存器)后,处理器会自动将段描述符(包括基地址、长度和属性信息)载入到段寄存器的不可见区域(对应的段寄存器缓冲区), 处理器通过这些缓存信息,可直接进行地址转换,进而免去了重复读取内存中的段描述符的时间开销。 - 如果想在实模式下访问1M以上的空间(打开A20只是使得高位地址线可用),则需要修改段寄存器中的段界限,但是在实模式下又无法做出修改,所以必须先跳到保护模式下修改此值(给段寄存器赋值),然后再跳回实模式。这时段寄存器缓冲区就存在一个远大于0xffff的段界限,即可访问相应大小的内存空间。此时处于的状态即称为big real mode。
protected mode(32位)
- 保护模式目前仅作为实模式到长模式的过渡模式存在,它是Intel处理器家族中的第二种运行模式。保护模式的特点是采用分段机制和分页机制进行地址访问,处理器在此模式下可访问4 GB的线性地址空间。
- 对于实模式的段机制而言,它仅仅规定了逻辑地址与线性地址间的转换方式,却没有限制访
问目标段的权限,这使得应用程序可以肆无忌惮地对系统核心进行操作。但在保护模式下,若想对系统核心进行操作必须拥有足够的访问权限才行,这就是保护的意义:操作系统可在处理器级防止程序有意或无意地破坏其他程序和数据。 - 虽然保护模式支持分段和分页两种管理机制,但是处理器必须先经过分段管理机制将逻辑地址转换成线性地址后,才能使用分页管理机制进一步把线性地址转换成物理地址(注意,分页管理机制是可选项,而分段管理机制是必选项)。
- 在保护模式下, 段:偏移不再解释为段基址*16+偏移地址, 而是先通过段寄存器中的段描述符索引找到相应的段描述符, 再通过段描述符中的基址与偏移地址一起计算出线性地址
long mode(64位)
- 长模式也被称为IA-32e模式,它是Intel处理器家族中的第三种运行模式。长模式在保护模式的基础上进行了扩展,它支持64位的线性地址空间,最大可寻址至16 EB(即2^64字节)。
- 在保护模式的段级保护措施中,从段结构组织的复杂性,到段间权限检测的繁琐性,再到执行时的效率上,都显得臃肿,而且还降低了程序的执行效率和编程的灵活性。当页管理单元出现后,段机制显得更加多余。随着硬件速度不断提升和对大容量内存的不断渴望,IA-32e模式便应运而生。 IA-32e模式不仅简化段级保护措施的复杂性,升级内存寻址能力,同时还扩展页管理单元的组织结构和页面大小,推出新的系统调用方式和高级可编程中断控制器(APIC)。
A20地址线
我们上节在末尾的寄存器部分提到,段寄存器出现的原因在于8086中CPU的数据总线(即ALU算数逻辑单元)宽度为16位, 但地址总线宽度为20位, 为了能够访问1MB的内存空间, 采用了段地址x16+偏移地址的方式, 通过段寄存器存储段地址, 通过偏移地址存储偏移地址。
而A20地址线是Intel 80286处理器中引入的, 用于解决8086/8088处理器在实模式下只能寻址1MB内存的问题。开启A20地址线前, 处理器最多只能使用20根地址总线, 即段:偏移计算后的结果只能使用最多20位, 即1MB内存; 而开启A20地址线后, 处理器可以使用20根以上的地址总线, 段:偏移计算后的结果不必再回环, 可以访问1MB以上的内存(即使未进入保护模式)。
当时的8042键盘控制器上恰好有空闲的端口引脚(输出端口P2,引脚P21),从而使用此引脚作为功能控制开关,即A20功能。如果A20引脚为低电平(数值0),那么地址总线只有低20位有效,其他位均为0。
在机器上电时,默认情况下A20地址线是被禁用的,所以操作系统必须采用适当的方法开启它。由于硬件平台的兼容设备种类繁杂,进而出现多种开启A20功能的方法:
- 键盘控制器:开启A20功能的常用方法是操作键盘控制器,但由于键盘控制器是低速设备,因此功能开启速度相对较慢。
- A20快速门(Fast Gate A20):使用I/O端口0x92来处理A20信号线。对于不含键盘控制器的操作系统,就只能使用0x92端口来控制,但是该端口有可能被其他设备使用。
- BIOS中断:使用BIOS中断服务程序INT 15h的主功能号AX=0x2401可开启A20地址线,功能号AX=0x2400可禁用A20地址线,功能号AX=0x2403可查询A20地址线的当前状态。
- 还有一种方法是,通过读0xee端口来开启A20信号线,而写该端口则会禁止A20信号线。
GDT
- GDT(Global (segment) Descriptor Table)全局描述符表, 整个系统只有一张, 用于存储段描述符, 一个段描述符占8字节(即一个GDT表项占64位), 包括段基址、段界限(长度)、段属性等信息。GDT 至少包含三个描述符:
- 空描述符(NULL Descriptor):GDT 的第一个描述符必须是空的。
- 代码段描述符(Code Segment Descriptor):指向 一个32 位代码段。
- 数据段描述符(Data Segment Descriptor):指向 一个32 位数据段。
- 由于段寄存器为16位, 但低3位为指示信息, 只有高13位作为段描述符索引,因此最多只能有2^13=8192个段, 81928B=64KB, 因此GDT表的大小为*64KB, 存储在内存中的某个位置, 由开发人员自行设置, 并由CPU的GDTR特殊寄存器指向。
- GDTR(Global Descriptor Table Register)全局描述符表寄存器, 用于存储GDT表的基址和界限, 48位, 高32位为GDT表的基址, 低16位为GDT表的限长
- LDT(Local Descriptor Table)局部描述符表, 每个进程可以私有一个LDT, 用于记录本任务中涉及的各个代码段、数据段和堆栈段以及本任务的使用的门描述符。LDTR(Local Descriptor Table Register)局部描述符表寄存器, 16位, 高13位为LDT表的索引, 低3位为指示信息。
- 在32位模式下,GDT表项的段基址为32位,段界限为20位,由于20位只能指定1MB大小的段,若想指定最大4GB的段,需要在段的属性里设一个标志位(第55位,粒度),这个标志位是1的时候,limit的单位不解释成字节(byte),而解释成页(page,4KB)。最后段属性占据12位,段属性又称为“段的访问权属性”,在程序中用变量名access_right或ar来表示。其中12位段属性中的高4位放在limit_high字节的高4位里。ar的高4位被称为“扩展访问权”,因为这高4位的访问属性在80286的时代还不存在,到386以后才可以使用。这4位是由“GD00”构成的,其中G是指刚才所说的段粒度,D是指段的模式,1是指32位模式,0是指16位模式。ar的低8位从80286时代就已经有了,这里简单地介绍一下。
- 00000000(0x00):未使用的记录表(descriptor table)。
- 10010010(0x92):系统专用,可读写的段。不可执行。
- 10011010(0x9a):系统专用,可执行的段。可读不可写。
- 11110010 (0xf2):应用程序用,可读写的段。不可执行。
- 11111010 (0xfa):应用程序用,可执行的段。可读不可写。
- 而在64位模式下,A-32e简化了保护模式的段结构,删减掉冗余的段基地址和段限长(设为0),使段直接覆盖整个线性地址空间,进而变成平坦地址空间。
IDT
- IDT(Interrupt Descriptor Table)中断描述符表, 用于存储中断描述符, 在32位保护模式下,一个中断描述符占8字节(即一个IDT表项占64位),而在64位长模式下拓展为16字节(128位),包括中断处理程序的段选择子、中断处理程序的偏移地址、中断门属性等信息:
- 偏移(Offset):32 位中断处理函数地址
- 选择子(Selector):指向 GDT 中的代码段
- 属性(Type & Attr):定义中断门、陷阱门等
- 最多设置256个中断号, 对应256个中断处理函数
- IDTR(Interrupt Descriptor Table Register)中断描述符表寄存器, 用于存储IDT表的基址和界限, 48位, 高32位为IDT表的基址, 低16位为IDT表的限长。
