第三部分 入门阶段


第4章 BootLoader引导启动机制详解

计算机从按下电源按钮到桌面出现在眼前,这中间发生了什么?大多数人从来不会去想这个问题,因为一切都发生得很快,似乎理所当然。但如果你要亲手编写一个操作系统,那就必须把这个过程搞得一清二楚。因为操作系统内核并不是凭空就能运行起来的——在内核拿到控制权之前,有一段不起眼但至关重要的程序在默默工作,它就是BootLoader,引导加载程序。

BootLoader的职责简单来说就是三件事:初始化最基本的硬件环境,把操作系统内核从磁盘加载到内存里,然后把CPU的控制权交给内核。听起来简单,做起来却需要跟最底层的硬件打交道,而且必须在极其有限的条件下完成工作——没有操作系统的支持,没有标准库可以调用,甚至连一个像样的运行环境都没有。

本章将从计算机上电的第一条指令开始,一步步讲解整个引导启动过程的实现细节。

4.1 Boot引导程序

Boot引导程序是整个启动链条的第一环。理解它的工作原理,需要先理解计算机上电后到底发生了什么。

4.1.1 BIOS引导机制的工作原理

当你按下电源按钮的一瞬间,电源开始向主板供电。CPU在接收到电力后做的第一件事,是把自己的所有寄存器重置到一个预定义的初始状态。对于x86架构的CPU来说,上电后它会处于16位实模式(Real Mode),代码段寄存器CS被设置为0xF000,指令指针寄存器IP被设置为0xFFF0。这意味着CPU执行的第一条指令位于物理地址0xFFFF0处——这个地址正好在BIOS ROM芯片的映射范围内。

BIOS,全称Basic Input/Output System,基本输入输出系统。它是一段固化在主板上ROM芯片中的程序,由主板制造商编写。BIOS的历史几乎和PC一样长——1981年IBM推出第一台IBM PC时,BIOS就是其中的核心组件之一。虽然现在UEFI(Unified Extensible Firmware Interface,统一可扩展固件接口)已经在大部分新电脑上取代了传统BIOS,但UEFI通常仍然提供一个叫做CSM(Compatibility Support Module)的兼容模式来支持传统的BIOS引导方式。而且从学习操作系统原理的角度来看,理解传统BIOS引导是更好的起点,因为它的机制更加简单直接。

CPU跳转到BIOS代码后,BIOS开始执行一系列初始化操作。首先是POST(Power-On Self-Test,上电自检),检查CPU、内存、主板芯片组等关键硬件是否正常工作。如果你启动电脑时听到"嘀"一声,那就是POST通过的信号;如果听到连续的蜂鸣声或者根本没有声音,通常意味着某个硬件出了问题。不同的蜂鸣模式对应不同的硬件故障,这在早年没有显示器输出的阶段是唯一的诊断手段。

POST通过后,BIOS会初始化各种硬件设备——设置中断向量表、初始化PIC(可编程中断控制器)、检测内存大小、初始化显示设备、扫描PCI总线上的设备等。BIOS还会在内存中建立一系列数据结构,供后续的引导程序使用。比如中断向量表(IVT)被放在内存的最低端(0x0000到0x03FF),BIOS数据区(BDA)被放在0x0400到0x04FF。

这些BIOS中断服务是引导程序的重要工具。在实模式下,引导程序可以通过INT指令调用各种BIOS服务:INT 0x10用于屏幕显示、INT 0x13用于磁盘读写、INT 0x15用于获取内存信息、INT 0x16用于键盘输入。这些中断服务虽然功能有限、效率不高,但在引导阶段是唯一可用的硬件访问方式。等操作系统内核启动后,通常会用自己的设备驱动来替代这些BIOS服务。

BIOS完成所有初始化工作后,就该把控制权交给操作系统了。但BIOS并不知道操作系统在哪里、长什么样,它只知道一个简单的约定:去磁盘的第一个扇区找一段程序来执行。

具体过程是这样的:BIOS按照设置好的启动顺序(可以在BIOS设置界面中配置),依次尝试从各个存储设备(硬盘、软盘、光驱、U盘、网络等)读取第一个扇区(512字节)。如果这个扇区的最后两个字节是0x55和0xAA(注意字节序,在内存中是先0x55后0xAA),BIOS就认为这是一个有效的引导扇区,把它加载到内存地址0x7C00处,然后跳转到0x7C00开始执行。

为什么是0x7C00这个看似奇怪的地址?这要追溯到1981年IBM PC的设计。当时IBM PC使用的8088 CPU只有640KB可寻址内存(实际上只有低640KB是常规内存,0xA0000以上的地址被保留给显存和ROM BIOS)。在这640KB中,最低端的1KB被中断向量表占用,接下来的256字节是BIOS数据区。BIOS需要在内存中留出足够的空间给引导程序以及引导程序要加载的更大的程序。0x7C00 = 31744,也就是距离0地址大约31KB的位置。IBM的工程师选择这个地址,是为了在引导扇区代码下方留出约30KB的可用空间(给栈和数据使用),同时在引导扇区上方还有几百KB的空间可以用来加载更大的程序。这个选择在当时是合理的,而且由于兼容性的原因,一直沿用至今。

这里有一个很重要的限制:引导扇区只有512字节。减去末尾的2字节签名(0x55AA),如果是一个带分区表的硬盘MBR(Master Boot Record),还需要64字节的分区表和一些其他数据,实际可用的代码空间就更少了。在如此有限的空间里,你显然不可能放下一个完整的操作系统内核。所以引导过程通常分为多个阶段:引导扇区中的代码(Boot程序)只负责加载一个更大的加载器程序(Loader),然后由Loader来完成更复杂的工作——检测硬件信息、设置CPU模式、加载内核等。

商业操作系统的引导过程也遵循类似的分阶段策略。GRUB(Grand Unified Bootloader)是Linux世界中最常用的引导加载器,它就分为多个阶段:Stage 1存放在MBR中,只有446字节的代码空间,它的唯一任务就是加载Stage 1.5或Stage 2;Stage 1.5通常存放在MBR和第一个分区之间的间隙中,包含文件系统驱动,能够从文件系统中读取Stage 2;Stage 2是GRUB的主体,提供了菜单界面、内核加载、命令行等完整功能。

Windows的引导过程也类似。传统BIOS模式下,MBR中的代码加载VBR(Volume Boot Record,卷引导记录),VBR加载bootmgr,bootmgr读取BCD(Boot Configuration Data)配置,最终加载winload.exe来启动Windows内核。在UEFI模式下,固件直接从EFI系统分区(ESP)中加载bootmgfw.efi,整个过程更加现代化。

macOS自从苹果转向Intel平台后也使用了EFI引导,后来随着苹果自研M系列芯片的推出,引导过程又发生了变化,但基本思想是一样的——固件初始化硬件,然后找到并加载引导加载器,引导加载器再加载操作系统内核。

理解了BIOS引导机制后,我们就可以开始编写自己的Boot引导程序了。

4.1.2 编写一个Boot引导程序

编写引导扇区代码是操作系统开发中最令人兴奋的时刻之一——这是你的代码第一次直接运行在裸机上,没有任何操作系统的支撑。

引导扇区代码必须满足以下条件:总大小恰好512字节;最后两个字节必须是0x55和0xAA;代码从0x7C00开始执行;运行在16位实模式下。

让我们从一个能在屏幕上显示信息的Boot程序开始。首先需要设置段寄存器。在实模式下,CPU通过段寄存器和偏移量来计算物理地址,公式是:物理地址 = 段基地址 * 16 + 偏移量。BIOS把引导扇区加载到0x7C00后跳转过来,但此时段寄存器的值可能是任意的(不同的BIOS实现可能不同),所以我们首先要把段寄存器设置为已知的值。

nasm

org 0x7c00
[bits 16]

; 设置段寄存器
xor ax, ax
mov ds, ax          ; 数据段基地址 = 0
mov es, ax          ; 附加段基地址 = 0
mov ss, ax          ; 栈段基地址 = 0
mov sp, 0x7c00      ; 栈指针指向0x7C00,栈向下增长

使用 org 0x7c00 告诉汇编器代码将被加载到0x7C00处,这样汇编器在计算标签地址时会加上这个偏移量。段寄存器都清零后,配合 org 0x7c00,代码中的标签地址就能正确对应到物理内存地址。

栈指针设置为0x7C00,这意味着栈从0x7C00向下增长(向低地址方向)。引导扇区代码本身从0x7C00到0x7DFF,栈从0x7BFF往下。这样栈和代码互不干扰。

接下来实现一个字符串打印功能。在实模式下,我们可以通过BIOS的INT 0x10中断来显示字符:

nasm

; 清屏
mov ax, 0x0600      ; AH=06h 功能:上滚窗口,AL=00 全部清除
mov bx, 0x0700      ; BH=07 属性(灰白色字符,黑色背景)
mov cx, 0           ; CH,CL = 左上角坐标 (0,0)
mov dx, 0x184f      ; DH,DL = 右下角坐标 (24,79)
int 0x10

; 设置光标位置
mov ah, 0x02        ; AH=02h 功能:设置光标位置
mov bh, 0           ; 页号
mov dh, 0           ; 行号
mov dl, 0           ; 列号
int 0x10

; 打印欢迎信息
mov si, welcome_msg
call print_string

jmp $               ; 无限循环

; 打印字符串子程序
; 输入:SI = 字符串地址(以0结尾)
print_string:
    push ax
    push bx
.loop:
    lodsb            ; 从[DS:SI]加载一个字节到AL,SI自增
    or al, al        ; 检查是否为0(字符串结束标志)
    jz .done
    mov ah, 0x0e     ; AH=0Eh 功能:以电传打字机方式输出字符
    mov bx, 0x0007   ; 页号0,属性7(灰白色)
    int 0x10
    jmp .loop
.done:
    pop bx
    pop ax
    ret

welcome_msg: db 'Booting OS...', 0x0d, 0x0a, 0

; 填充到510字节
times 510 - ($ - $$) db 0
; 引导扇区签名
dw 0xaa55

这段代码虽然简单,但已经包含了引导扇区的所有基本要素。让我们逐一分析每个部分的作用。

清屏操作使用INT 0x10的06h功能。这个BIOS中断功能号最初是用来滚动窗口的,但当AL=0时,效果就是清除整个屏幕。参数中的BH指定了清屏后的字符属性,0x07表示黑色背景、灰白色前景,这是PC文本模式的默认配色。CX和DX分别指定了要清除的矩形区域的左上角和右下角坐标。标准VGA文本模式是80列25行(0到79列,0到24行),所以右下角坐标是(24, 79),即0x184F。

设置光标位置使用INT 0x10的02h功能。清屏后光标的位置不确定,所以我们把它显式地设置到屏幕左上角(0, 0)。

打印字符串使用了INT 0x10的0Eh功能(Teletype Output,电传打字机输出)。这个功能比较方便——它会在当前光标位置显示一个字符,然后自动把光标前移一个位置。如果到达行末会自动换行,如果到达屏幕底部会自动滚屏。遇到特殊字符(如0x0D回车、0x0A换行)也会做相应处理。

times 510 - ($ - $$) db 0 这行代码的作用是用零字节填充,使得整个文件的大小达到510字节。其中 $ 表示当前位置的地址,$$ 表示当前段的起始地址,$ - $$ 就是当前已经使用了多少字节。最后 dw 0xaa55 写入两字节的引导签名,使得总大小恰好是512字节。

这个Boot程序目前只能显示一条信息然后停住。在后续的小节中,我们会逐步给它添加从磁盘读取数据、加载Loader程序等功能,最终让它能够启动完整的操作系统。

在实际的引导扇区开发中,有几个常见的陷阱需要注意。第一个是段寄存器的初始值问题。不同版本的BIOS在跳转到0x7C00时,CS:IP的组合可能不同——有的BIOS设置为CS=0x0000, IP=0x7C00,有的设置为CS=0x07C0, IP=0x0000。两种方式计算出来的物理地址都是0x7C00,但如果你的代码假设了特定的CS值,就可能在某些BIOS上出错。最安全的做法是在代码开头使用一个远跳转来同时设置CS和IP:

nasm

jmp 0x0000:start
start:
    ; 现在CS确定为0x0000
    xor ax, ax
    mov ds, ax
    ...

第二个陷阱是方向标志位(Direction Flag)。LODSB、STOSB等字符串操作指令的行为取决于方向标志位DF——DF=0时地址递增,DF=1时地址递减。BIOS不保证DF的值,所以最好在代码开头执行 cld 指令来清除DF。

第三个陷阱是中断的影响。在设置栈之前,如果发生中断,中断处理程序可能会使用未初始化的栈,导致不可预料的后果。虽然BIOS通常会设置一个临时栈,但为了安全,可以在设置段寄存器之前先关闭中断(cli),设置完栈之后再开启中断(sti)。

4.1.3 制作虚拟软盘镜像文件

写好了引导扇区代码,还需要把它放到一个虚拟磁盘镜像中,才能在虚拟机或模拟器中运行。最简单的方式是制作一个软盘镜像。

标准的3.5英寸高密度软盘(1.44MB)的物理参数是:80个磁道(柱面),每个磁道2个磁头(正反两面),每个磁头18个扇区,每个扇区512字节。总容量 = 80 * 2 * 18 * 512 = 1,474,560字节 = 1440KB = 1.44MB。

创建一个软盘镜像非常简单,在Linux下使用dd命令:

bash

dd if=/dev/zero of=boot.img bs=512 count=2880

这条命令创建了一个全部填充为零的文件,大小为2880个扇区(2880 * 512 = 1,474,560字节),正好是一张1.44MB软盘的容量。

然后把编译好的引导扇区写入镜像的第一个扇区:

bash

nasm -f bin boot.asm -o boot.bin
dd if=boot.bin of=boot.img bs=512 count=1 conv=notrunc

conv=notrunc 参数很重要,它告诉dd不要截断输出文件。如果不加这个参数,dd会把boot.img截断为512字节,丢掉后面的内容。

你也可以用一条命令来完成镜像制作——先创建空白镜像,然后在引导扇区位置写入Boot代码。或者更简单地,你可以在NASM源文件中直接把文件大小填充到1.44MB:

nasm

; 在引导扇区代码之后,填充到整个软盘大小
times 1474560 - ($ - $$) db 0

不过这种方式会生成一个1.44MB的文件,如果后续还需要在软盘的其他扇区写入Loader程序和内核,用dd命令分别写入会更灵活。

软盘镜像之所以适合学习操作系统开发,是因为它的结构非常简单——没有分区表,第一个扇区就是引导扇区,后续的扇区可以按照你自己定义的方式来使用。现代操作系统使用的硬盘引导要复杂得多,涉及MBR分区表或GPT分区方案、各种文件系统等,对于学习阶段来说不是必须的。

当然,如果你想让你的操作系统更"正规"一些,也可以制作硬盘镜像。创建一个硬盘镜像的方式与软盘类似,但需要考虑的因素更多。硬盘的寻址方式有CHS(柱面-磁头-扇区)和LBA(逻辑块地址)两种。现代硬盘都使用LBA方式,从0开始编号,每个逻辑块512字节(或者4096字节的高级格式硬盘)。在BIOS的INT 0x13中断中,扩展读取功能(AH=42h)支持LBA寻址,比传统的CHS寻址(AH=02h)更方便使用。

4.1.4 使用Bochs运行Boot程序

有了软盘镜像,下一步就是在Bochs模拟器中运行它。首先需要创建一个Bochs配置文件。

创建一个名为bochsrc的文本文件,内容如下:

routeros

# CPU配置
cpu: model=corei7_sandy_bridge_2600k, count=1, ips=50000000, reset_on_triple_fault=1

# 内存大小
megs: 512

# BIOS和VGA BIOS路径
romimage: file=/usr/local/share/bochs/BIOS-bochs-latest
vgaromimage: file=/usr/local/share/bochs/VGABIOS-lgpl-latest

# 启动设备
boot: floppy

# 软盘配置
floppya: 1_44=boot.img, status=inserted

# 日志文件
log: bochs.log

# 显示方式
display_library: x, options="gui_debug"

关键配置项的含义:boot: floppy 指定从软盘启动;floppya: 1_44=boot.img 指定A驱动器使用boot.img作为1.44MB软盘镜像;megs: 512 分配512MB内存给虚拟机。

然后在终端中启动Bochs:

bash

bochs -f bochsrc -q

-f 参数指定配置文件,-q 参数跳过启动菜单直接运行。

如果一切正常,你应该能看到Bochs的显示窗口中出现"Booting OS..."的文字。这是你的代码在没有任何操作系统支持的情况下直接在(模拟的)硬件上运行的结果。

如果程序没有按预期工作,就需要使用Bochs的调试功能来排查问题。启动Bochs的调试模式后,你可以在BIOS执行完毕、即将跳转到引导扇区时设置断点:

b 0x7c00
c

第一条命令在0x7C00处设置断点,第二条命令让Bochs继续执行。当BIOS完成初始化并跳转到0x7C00时,Bochs会停下来等待你的进一步指令。此时你可以用 s 命令单步执行,用 r 命令查看寄存器状态,用 xp /16bx 0x7c00 查看内存内容。

一个常见的调试技巧是在代码中故意放置一条 xchg bx, bx 指令。这条指令对程序本身没有任何影响(它把BX寄存器的值跟自己交换,结果不变),但Bochs可以配置为在遇到这条指令时自动中断(在配置文件中设置 magic_break: enabled=1)。这样你就可以在代码的关键位置放置"魔法断点",而不需要手动计算地址来设置断点。

除了Bochs,你也可以用QEMU来测试。QEMU的启动命令更加简洁:

bash

qemu-system-x86_64 -fda boot.img

QEMU的运行速度比Bochs快得多,适合日常的快速测试。但QEMU的调试需要配合GDB使用,操作上不如Bochs内置调试器方便。

4.1.5 将Loader程序加载到内存

到目前为止,我们的Boot程序只是在屏幕上打印了一条消息。但它真正的使命是加载Loader程序。引导扇区只有512字节,不可能完成所有启动准备工作,所以需要一个更大的第二阶段引导程序来接手。

将Loader从磁盘读取到内存中,需要使用BIOS的INT 0x13中断提供的磁盘读取服务。INT 0x13有两套接口——传统接口(AH=02h)使用CHS(柱面-磁头-扇区)寻址,扩展接口(AH=42h)使用LBA(逻辑块地址)寻址。对于软盘来说,使用传统的CHS接口就够了。

INT 0x13 AH=02h的参数如下:

  • AH = 02h(读扇区功能)
  • AL = 要读取的扇区数
  • CH = 柱面号(低8位)
  • CL = 扇区号(1-63)以及柱面号的高2位
  • DH = 磁头号
  • DL = 驱动器号(软盘A=0x00,软盘B=0x01,硬盘C=0x80)
  • ES:BX = 数据缓冲区地址

返回值:

  • CF = 0 表示成功,CF = 1 表示失败
  • AH = 返回状态码(0=成功)
  • AL = 实际读取的扇区数

但是这里有个问题——Loader程序在软盘上的哪个位置?如果我们只是简单地把Loader放在引导扇区后面的固定位置(比如从第2个扇区开始),那确实可以直接用CHS地址去读取。但如果我们使用了文件系统(比如FAT12),Loader就是软盘上的一个文件,我们需要先解析文件系统才能找到它。

这里有两种策略。第一种是不使用文件系统,直接把Loader写到软盘的固定扇区位置。这种方式最简单,但不够灵活——每次修改Loader后都需要记住它在软盘上的确切位置。第二种是在软盘上建立FAT12文件系统,把Loader作为一个文件存储在文件系统中,Boot程序通过解析FAT12的目录和文件分配表来找到并加载Loader。第二种方式更加专业,也是原书中采用的方法。

FAT12是一种非常简单的文件系统,最初为软盘设计。它的名字中的"12"表示文件分配表(FAT,File Allocation Table)中每个条目占12位。FAT12软盘的结构包含以下几个区域:

  1. 引导扇区(扇区0):包含文件系统的参数信息(BPB,BIOS Parameter Block)和引导代码
  2. FAT区域(扇区1开始):记录每个簇的分配状态和链接关系
  3. 根目录区域:紧接在FAT区域之后,包含根目录下的文件和子目录条目
  4. 数据区域:存放实际的文件数据

在引导扇区中嵌入FAT12的BPB,可以让这张软盘同时作为引导盘和FAT12格式的数据盘使用。BPB包含了一系列文件系统参数:

nasm

jmp short start
nop

; FAT12 BPB (BIOS Parameter Block)
OEM_Name:           db 'MYOS    '    ; OEM名称,8字节
BytesPerSector:     dw 512           ; 每扇区字节数
SectorsPerCluster:  db 1             ; 每簇扇区数
ReservedSectors:    dw 1             ; 保留扇区数(包括引导扇区)
NumberOfFATs:       db 2             ; FAT表的份数
RootEntries:        dw 224           ; 根目录最大条目数
TotalSectors:       dw 2880          ; 总扇区数
MediaDescriptor:    db 0xf0          ; 介质描述符(0xF0=1.44MB软盘)
SectorsPerFAT:      dw 9             ; 每个FAT表占的扇区数
SectorsPerTrack:    dw 18            ; 每磁道扇区数
NumberOfHeads:      dw 2             ; 磁头数
HiddenSectors:      dd 0             ; 隐藏扇区数
TotalSectorsLarge:  dd 0             ; 总扇区数(大于65535时使用)
DriveNumber:        db 0             ; 驱动器号
Reserved:           db 0             ; 保留
BootSignature:      db 0x29          ; 扩展引导标志
VolumeID:           dd 0x12345678    ; 卷序列号
VolumeLabel:        db 'MY OS DISK '  ; 卷标,11字节
FileSystemType:     db 'FAT12   '    ; 文件系统类型,8字节

加上BPB之后,引导扇区代码的可用空间进一步减少。但这些参数是必要的,因为Boot程序需要用这些参数来计算根目录区域和数据区域的起始位置。

要在FAT12软盘上找到Loader文件,Boot程序需要执行以下步骤:

第一步,计算根目录区域的起始扇区。根目录区域紧接在FAT区域之后:

根目录起始扇区 = 保留扇区数 + FAT表数 * 每个FAT表的扇区数
                = 1 + 2 * 9 = 19

第二步,计算根目录区域占用的扇区数。每个目录条目32字节,根目录最多224个条目:

根目录占用扇区数 = (224 * 32 + 511) / 512 = 14

第三步,计算数据区域的起始扇区:

数据区起始扇区 = 根目录起始扇区 + 根目录占用扇区数 = 19 + 14 = 33

第四步,读取根目录区域的扇区到内存,逐个检查目录条目,查找文件名为"LOADER BIN"(FAT12的文件名格式是8+3,短文件名用空格填充)的条目。

第五步,找到Loader的目录条目后,从中获取文件的起始簇号和文件大小。

第六步,根据起始簇号,从FAT表中读取簇链(因为文件可能占据多个不连续的簇),依次把每个簇的数据读取到内存中的指定位置。

第七步,所有数据读取完毕后,Loader就完整地加载到了内存中。

这个过程在512字节的引导扇区中实现是相当紧凑的。每一条指令都要精打细算,不能有丝毫浪费。这也是为什么很多教程选择不使用文件系统,而是直接把Loader放到固定位置——这样可以把更多注意力放在理解引导过程本身,而不是纠结于FAT12的解析细节。

在读取磁盘的过程中,需要注意的一个问题是LBA到CHS的转换。虽然我们在逻辑上用LBA(扇区号从0开始计数)来定位数据,但INT 0x13的传统接口需要CHS参数。转换公式如下:

柱面 = LBA / (每磁道扇区数 * 磁头数)
磁头 = (LBA / 每磁道扇区数) % 磁头数
扇区 = (LBA % 每磁道扇区数) + 1    (注意CHS的扇区号从1开始)

另一个需要注意的问题是DMA边界。BIOS读取磁盘数据时使用DMA(直接内存访问),DMA传输不能跨越64KB边界。也就是说,目标缓冲区的起始地址和结束地址必须在同一个64KB段内。如果Loader程序较大,可能需要分多次读取,每次确保不跨越64KB边界。

在商业操作系统中,引导扇区的磁盘读取代码也面临同样的限制。GRUB的Stage 1代码因为空间太小,甚至不自己解析文件系统,而是由安装程序在安装时直接把Stage 1.5的磁盘块地址硬编码到Stage 1中。Windows的MBR代码也是类似的策略——它知道活动分区VBR的确切磁盘位置,直接读取就行了。

Loader程序应该被加载到内存的什么地址?这需要考虑实模式下的内存布局。实模式下可用的内存空间大致如下:

  • 0x00000 - 0x003FF:中断向量表(1KB)
  • 0x00400 - 0x004FF:BIOS数据区(256字节)
  • 0x00500 - 0x07BFF:可用空间(约30KB)
  • 0x07C00 - 0x07DFF:引导扇区代码(512字节)
  • 0x07E00 - 0x9FBFF:可用空间(约608KB)
  • 0x9FC00 - 0x9FFFF:扩展BIOS数据区
  • 0xA0000 - 0xBFFFF:显存映射区
  • 0xC0000 - 0xFFFFF:BIOS ROM映射区

Loader程序通常被加载到0x10000(64KB处)或其他方便的位置。选择一个足够大的连续空间,确保Loader代码和数据不会覆盖到引导扇区或其他重要区域。

4.1.6 从Boot跳转至Loader程序

当Loader成功加载到内存后,Boot程序的使命就完成了。最后一步是跳转到Loader的入口地址,把CPU的控制权交给Loader。

这个跳转看似简单,但有几个细节需要注意。首先是跳转方式。如果Loader的入口地址在同一个段内(距离0x7C00不超过64KB),可以使用近跳转。但通常Loader被加载到了不同的段,这时需要使用远跳转来同时设置CS和IP:

nasm

jmp 0x1000:0x0000    ; 跳转到0x10000(CS=0x1000, IP=0x0000)

或者如果Loader的入口地址存储在一个变量中,可以使用间接远跳转。

其次是确保Loader代码的入口点在预期的位置。Loader的第一条指令必须位于加载地址的起始处,或者在加载地址处放一条跳转指令跳到实际的入口点。

第三是传递信息。Boot程序在执行过程中可能收集了一些有用的信息(比如启动驱动器号、内存检测结果等),这些信息需要以某种方式传递给Loader。最简单的方式是通过寄存器——比如在DL寄存器中保存启动驱动器号,这样Loader也能知道自己是从哪个驱动器启动的,后续需要读取内核时可以继续使用这个驱动器号。也可以把信息存储在内存的约定位置,让Loader去读取。

跳转完成后,Boot程序的代码和数据虽然还在内存中(0x7C00-0x7DFF),但已经不会再被执行了。这块内存后续可以被Loader或内核覆盖重用。

完整的Boot到Loader的跳转过程可以总结为:

  1. Boot程序初始化段寄存器和栈
  2. Boot程序在屏幕上显示启动信息
  3. Boot程序从磁盘读取Loader到内存
  4. Boot程序跳转到Loader入口地址
  5. Loader接管控制权,开始执行

至此,引导的第一阶段就完成了。控制权从BIOS转移到了Boot,再从Boot转移到了Loader。接下来就是Loader的舞台。

4.2 Loader引导加载程序

如果说Boot程序是一个"开门的钥匙",那么Loader就是"进门后的向导"。Boot程序受限于512字节的空间,能做的事情非常有限。Loader没有这个限制——它可以占据多个扇区,有足够的空间来完成更加复杂的任务。

4.2.1 Loader程序的功能与原理

Loader在整个启动链条中承上启下,它需要完成以下几项关键任务:

获取硬件信息

在从实模式切换到保护模式或长模式之后,BIOS中断就不能再使用了。所以在切换之前,Loader需要利用BIOS中断来获取尽可能多的硬件信息,特别是内存布局信息。

获取内存信息最常用的BIOS中断是INT 0x15 EAX=0xE820。这个功能可以返回系统的完整内存映射,包括每个内存区域的起始地址、长度和类型(可用内存、保留内存、ACPI可回收内存等)。内核启动后需要这些信息来建立物理内存管理。

nasm

get_memory_map:
    xor ebx, ebx            ; EBX = 0,第一次调用
    mov di, memory_map_buf   ; ES:DI = 缓冲区地址
.loop:
    mov eax, 0xe820          ; 功能号
    mov ecx, 20              ; 每个条目20字节
    mov edx, 0x534d4150      ; 'SMAP' 签名
    int 0x15
    jc .done                 ; CF=1 表示出错或结束
    add di, 20               ; 缓冲区指针前移
    or ebx, ebx              ; EBX=0 表示这是最后一个条目
    jnz .loop
.done:
    ret

每次调用INT 0x15 EAX=0xE820,BIOS会在ES:DI指向的缓冲区中填入一个20字节(或24字节)的内存区域描述符,包含基地址(8字节)、长度(8字节)和类型(4字节)。EBX是一个由BIOS维护的游标,每次调用后BIOS会更新它,直到返回EBX=0表示所有条目都已经返回完毕。

除了内存信息,Loader还可能需要获取显示模式信息(通过INT 0x10 AX=4F00h/4F01h的VBE接口)、磁盘参数等。这些信息都需要在切换到保护模式之前获取,因为切换之后就没有BIOS可用了。

Linux内核的实模式启动代码(arch/x86/boot/目录下)就包含了大量的硬件检测代码——检测内存、检测显卡、检测APM/ACPI支持等。虽然Linux内核通常由GRUB来加载而不需要自己编写Boot和Loader,但内核的实模式启动代码仍然需要执行一些BIOS调用来获取硬件信息。

设置GDT(全局描述符表)

从实模式切换到保护模式需要先设置GDT。GDT是x86保护模式下的核心数据结构之一,它定义了内存段的属性——基地址、大小、访问权限等。在保护模式下,段寄存器中存储的不再是段基地址,而是一个"选择子"(Selector),它实际上是GDT中某个描述符的索引。CPU通过查找GDT来获取段的实际属性。

虽然在64位长模式下,分段机制基本上被"废弃"了(代码段和数据段的基地址固定为0,大小覆盖整个地址空间),但在从实模式切换到长模式的过程中,仍然需要经过保护模式,所以GDT是必不可少的。

一个最基本的GDT通常包含三个描述符:

  • 空描述符(索引0):x86规定GDT的第一个条目必须是空的
  • 代码段描述符:定义可执行的代码段
  • 数据段描述符:定义可读写的数据段

nasm

gdt_start:
    dq 0                    ; 空描述符

gdt_code:                   ; 代码段描述符
    dw 0xffff               ; 段限长 (0-15位)
    dw 0x0000               ; 段基地址 (0-15位)
    db 0x00                 ; 段基地址 (16-23位)
    db 10011010b            ; 访问权限: Present=1, DPL=00, S=1, Type=1010(可执行/可读)
    db 11001111b            ; 标志+段限长: G=1, D/B=1, L=0, AVL=0, 段限长(16-19)=1111
    db 0x00                 ; 段基地址 (24-31位)

gdt_data:                   ; 数据段描述符
    dw 0xffff
    dw 0x0000
    db 0x00
    db 10010010b            ; 访问权限: Present=1, DPL=00, S=1, Type=0010(可读写)
    db 11001111b
    db 0x00

gdt_end:

gdt_descriptor:
    dw gdt_end - gdt_start - 1   ; GDT大小 - 1
    dd gdt_start                  ; GDT基地址

GDT描述符的格式很复杂——一个段描述符占8字节,其中基地址和限长都被分成了多个不连续的字段。这是Intel在设计80286时做出的选择,后来为了向后兼容一直保留至今。虽然看起来很别扭,但理解了格式之后并不难设置。

4.2.2 编写Loader程序

Loader程序比Boot程序大得多,也复杂得多。它通常从实模式开始执行,完成所有需要BIOS服务的操作后,逐步切换到保护模式,最终进入64位长模式,然后加载并跳转到内核。

Loader程序的开头通常也需要初始化段寄存器和栈,因为Boot程序跳转过来时段寄存器的值可能需要调整。然后开始执行各项准备工作:

nasm

[org 0x10000]       ; Loader被加载到0x10000
[bits 16]

loader_start:
    ; 初始化段寄存器
    mov ax, cs
    mov ds, ax
    mov es, ax
    mov ss, ax
    mov sp, 0x7c00    ; 设置栈

    ; 在屏幕上显示信息
    mov si, loader_msg
    call print_string

    ; 获取内存信息
    call get_memory_map

    ; 从磁盘加载内核到临时缓冲区
    call load_kernel

    ; 准备进入保护模式
    cli                ; 关闭中断
    lgdt [gdt_descriptor]  ; 加载GDT

    ; 开启A20地址线
    call enable_a20

    ; 设置CR0的PE位,进入保护模式
    mov eax, cr0
    or eax, 1
    mov cr0, eax

    ; 远跳转到32位代码段,刷新CS
    jmp 0x08:protected_mode_entry

这里出现了一个之前没提到的概念——A20地址线。这是x86历史上最有名的"历史包袱"之一。

早期的8086 CPU只有20根地址线(A0-A19),可以寻址1MB的内存(2的20次方 = 1,048,576字节)。由于8086的段寻址机制,最大物理地址可以达到0xFFFF0 + 0xFFFF = 0x10FFEF,超过了1MB。超过的部分会"回绕"到低端地址(因为只有20根地址线,第21位被忽略)。一些早期的软件利用了这个回绕特性。

当IBM推出使用80286的IBM PC/AT时,80286有24根地址线,可以寻址16MB。为了保持与8086软件的兼容性,IBM在主板上加了一个门电路来控制第21根地址线(A20)。默认情况下A20被禁用,地址的第21位被强制清零,从而模拟8086的回绕行为。要使用超过1MB的内存,必须先启用A20地址线。

启用A20有多种方式。最常见的是通过键盘控制器(8042芯片)来控制,因为IBM当初就是把A20门电路连到了键盘控制器上。也可以通过BIOS INT 0x15 AX=2401h来启用,或者通过System Port A(端口0x92)来启用。在实际编程中,通常会尝试多种方法以确保兼容性:

nasm

enable_a20:
    ; 方法1:通过BIOS
    mov ax, 0x2401
    int 0x15
    jnc .done

    ; 方法2:通过键盘控制器
    call .wait_8042
    mov al, 0xd1
    out 0x64, al
    call .wait_8042
    mov al, 0xdf
    out 0x60, al
    call .wait_8042

    ; 方法3:通过Fast A20 Gate
    in al, 0x92
    or al, 2
    out 0x92, al

.done:
    ret

.wait_8042:
    in al, 0x64
    test al, 2
    jnz .wait_8042
    ret

A20地址线的故事也许是计算机历史上"向后兼容"的代价最好的例子之一。一个1981年的兼容性hack,直到今天的x86处理器中还存在——虽然现代处理器在上电时默认就启用了A20,不需要软件额外操作,但BIOS有时会在初始化过程中重新禁用它。

这个问题在商业操作系统中也需要处理。Linux内核的启动代码中就有A20启用的相关处理。Windows的引导过程中同样包含了A20处理代码。虽然这只是一个小小的硬件兼容性问题,但如果忘记处理,后果可能很严重——在A20被禁用的情况下,任何涉及到1MB以上内存的操作都会出错,而且错误的表现可能非常诡异(比如写入高地址的数据莫名其妙地出现在低地址)。

4.2.3 从实模式切换到保护模式再到IA-32e长模式

CPU模式切换是Loader程序最核心也是最复杂的部分。我们需要把CPU从16位实模式一路切换到64位长模式(Intel称之为IA-32e模式,AMD称之为Long Mode,本质上是同一回事)。

这个切换过程分为几个阶段,每个阶段都有严格的操作顺序要求。如果操作顺序不对或者数据结构设置有误,CPU可能会触发异常或者进入未定义状态。下面详细讲解每个阶段。

第一阶段:实模式到32位保护模式

实模式到保护模式的切换相对简单,核心步骤是:

  1. 关闭中断(CLI指令)。在模式切换期间不能处理中断,因为实模式的中断向量表在保护模式下是无效的。
  2. 启用A20地址线(前面已经详细讨论过)。
  3. 加载GDT(LGDT指令)。
  4. 设置CR0寄存器的PE(Protection Enable)位为1。
  5. 执行一个远跳转(Far JMP)来刷新CPU的指令流水线并加载新的CS选择子。

nasm

    cli
    lgdt [gdt_descriptor]

    mov eax, cr0
    or eax, 1          ; 设置PE位
    mov cr0, eax

    jmp 0x08:pm_entry  ; 远跳转到保护模式代码
                        ; 0x08是GDT中代码段描述符的选择子

远跳转之后,CPU就正式进入了32位保护模式。此时需要立即设置数据段寄存器:

nasm

[bits 32]
pm_entry:
    mov ax, 0x10        ; 0x10是GDT中数据段描述符的选择子
    mov ds, ax
    mov es, ax
    mov fs, ax
    mov gs, ax
    mov ss, ax
    mov esp, 0x200000   ; 设置栈指针到2MB处

第二阶段:32位保护模式到64位长模式

从32位保护模式切换到64位长模式要复杂得多。长模式的关键特征是使用4级页表进行地址转换(在较新的处理器上可以扩展到5级)。与32位保护模式不同,长模式要求必须启用分页。所以在切换之前,我们需要先设置好页表。

64位长模式使用4级页表结构:

  • PML4(Page Map Level 4):最高级页表,每个条目覆盖512GB的地址空间
  • PDPT(Page Directory Pointer Table):每个条目覆盖1GB
  • PD(Page Directory):每个条目覆盖2MB
  • PT(Page Table):每个条目覆盖4KB

每级页表有512个条目,每个条目8字节,所以一张页表占4KB(恰好一个物理页)。

对于引导阶段,我们不需要映射全部的虚拟地址空间——只需要确保Loader代码和数据所在的内存区域能被正确映射就行了。最简单的做法是使用2MB大页(跳过PT这一级),建立一个恒等映射(虚拟地址 = 物理地址),至少覆盖低端几MB的内存。

nasm

; 设置页表
; 假设页表结构放在0x90000处

    ; 清零页表区域
    mov edi, 0x90000
    xor eax, eax
    mov ecx, 4096       ; 清零4个4KB页表 = 16KB
    rep stosd

    ; PML4[0] -> PDPT
    mov dword [0x90000], 0x91000 | 3    ; 基地址=0x91000, Present=1, R/W=1

    ; PDPT[0] -> PD
    mov dword [0x91000], 0x92000 | 3    ; 基地址=0x92000, Present=1, R/W=1

    ; PD[0] -> 2MB大页, 映射0x000000 - 0x1FFFFF
    mov dword [0x92000], 0x000000 | 0x83  ; 基地址=0, Present=1, R/W=1, PS=1(大页)

    ; PD[1] -> 2MB大页, 映射0x200000 - 0x3FFFFF
    mov dword [0x92008], 0x200000 | 0x83

    ; 可以继续添加更多的映射...

这里的 | 3 表示设置页表条目的Present位和Read/Write位,| 0x83 还额外设置了PS(Page Size)位,表示这是一个2MB大页而不是指向下一级页表的指针。

页表设置好之后,进入长模式的步骤如下:

  1. 把PML4表的物理地址加载到CR3寄存器
  2. 通过MSR(Model-Specific Register)启用IA-32e模式
  3. 启用分页(设置CR0的PG位)
  4. 执行远跳转到64位代码段

nasm

    ; 将PML4表地址加载到CR3
    mov eax, 0x90000
    mov cr3, eax

    ; 启用PAE(Physical Address Extension)
    mov eax, cr4
    or eax, (1 << 5)    ; 设置CR4.PAE位
    mov cr4, eax

    ; 设置IA32_EFER MSR的LME位,启用长模式
    mov ecx, 0xC0000080  ; IA32_EFER的MSR地址
    rdmsr
    or eax, (1 << 8)     ; 设置LME(Long Mode Enable)位
    wrmsr

    ; 启用分页(同时也激活长模式)
    mov eax, cr0
    or eax, (1 << 31)    ; 设置CR0.PG位
    mov cr0, eax

    ; 此时CPU进入了IA-32e模式的兼容子模式
    ; 需要远跳转到64位代码段来进入64位子模式
    jmp 0x18:long_mode_entry   ; 0x18是GDT中64位代码段的选择子

等一下——0x18?这意味着我们的GDT需要增加一个64位代码段描述符。在前面的基本GDT中,我们只定义了32位的代码段和数据段。对于64位长模式,我们需要一个新的代码段描述符,其中L(Long Mode)位设置为1,D(Default Operation Size)位设置为0:

nasm

gdt64_code:                 ; 64位代码段描述符
    dw 0x0000               ; 段限长(在64位模式下忽略)
    dw 0x0000               ; 段基地址(忽略)
    db 0x00                 ; 段基地址(忽略)
    db 10011010b            ; 访问权限
    db 00100000b            ; L=1, D=0
    db 0x00                 ; 段基地址(忽略)

注意在64位长模式下,段描述符中的基地址和限长字段大部分被忽略(对于CS、DS、ES、SS段来说)。段的基地址固定为0,段的大小覆盖整个64位虚拟地址空间。唯一还使用段基地址的是FS和GS段,它们可以通过MSR来设置非零的基地址,这个功能在操作系统中被用于实现线程本地存储(TLS)等机制。

远跳转到64位代码段后,CPU正式进入64位长模式的64位子模式。此时我们可以使用64位寄存器、64位地址空间以及所有的64位指令:

nasm

[bits 64]
long_mode_entry:
    ; 设置64位数据段
    mov ax, 0x20        ; 64位数据段选择子
    mov ds, ax
    mov es, ax
    mov fs, ax
    mov gs, ax
    mov ss, ax

    ; 设置64位栈
    mov rsp, 0x200000

    ; 在屏幕上显示一个字符来确认已进入64位模式
    ; 直接写入VGA文本模式的显存
    mov byte [0xb8000], 'L'      ; 字符
    mov byte [0xb8001], 0x0a     ; 属性(亮绿色)

    ; 现在可以做更多的事情了...

在这段代码中,我们直接向VGA文本模式的显存地址(0xB8000)写入数据来在屏幕上显示字符,而不再使用BIOS中断——因为BIOS中断在保护模式和长模式下不可用。VGA文本模式显存的格式是每个字符占2字节,第一个字节是ASCII码,第二个字节是属性(前景色、背景色、闪烁等)。标准VGA文本模式的分辨率是80列25行,所以显存大小为80 * 25 * 2 = 4000字节,从0xB8000到0xB8FA0。

整个模式切换的过程总结如下:

1c

实模式 (16位)
    |
    |-- 关闭中断
    |-- 启用A20
    |-- 加载GDT
    |-- 设置CR0.PE=1
    |-- 远跳转(刷新CS)
    |
    v
保护模式 (32位)
    |
    |-- 设置数据段寄存器
    |-- 创建页表
    |-- 加载CR3
    |-- 启用PAE (CR4.PAE=1)
    |-- 启用IA-32e (IA32_EFER.LME=1)
    |-- 启用分页 (CR0.PG=1)
    |-- 远跳转到64位代码段
    |
    v
长模式/IA-32e (64位)
    |
    |-- 设置64位段寄存器和栈
    |-- 继续后续的初始化工作

这个过程看起来步骤很多,但每一步都有明确的硬件要求。如果跳过任何一步或者顺序不对,CPU都会触发异常。比如在没有设置页表的情况下就启用分页,CPU会立即触发Page Fault;在没有启用PAE的情况下就设置LME位,CPU会在尝试启用分页时触发General Protection Fault。

在调试这些模式切换代码时,Bochs是最好的工具。你可以在每一步之后检查CPU的状态——CR0、CR3、CR4寄存器的值,GDT的内容,页表的设置是否正确,以及CPU当前处于什么模式。Bochs的 info cpu 命令可以显示详细的CPU状态信息,包括当前的运行模式。

从商业角度来看,所有的x86-64操作系统都必须经历类似的模式切换过程。Linux内核在启动时也需要从实模式切换到长模式——这部分代码位于 arch/x86/boot/arch/x86/kernel/head_64.S。Windows的winload.exe也包含类似的模式切换代码。即使是UEFI固件,虽然它在跳转到操作系统引导加载器时已经处于保护模式或长模式,但从上电到UEFI代码开始执行之前,处理器同样经历了从实模式开始的切换过程——只不过这部分工作由固件开发者完成,对操作系统开发者是透明的。

关于模式切换,还有一个值得注意的技术细节——"非实模式"(Unreal Mode,也叫Big Real Mode)。这是一种黑科技:在保护模式下把数据段描述符的限长设置为4GB,然后切回实模式。此时虽然CPU处于实模式,但数据段的限长仍然保持为4GB(因为CPU内部有段描述符的缓存),这样就可以在实模式下访问4GB的内存空间。一些早期的引导加载器和DOS下的游戏使用了这种技术。不过对于我们的操作系统来说,直接切换到长模式是更正规的做法。

4.2.4 从Loader跳转到操作系统内核

进入64位长模式后,Loader的最后一项任务就是加载内核并跳转执行。这一步需要把内核的可执行文件从磁盘(通过之前在实模式下设置好的临时缓冲区,或者通过自己实现的磁盘驱动)加载到内存中的正确位置,然后跳转到内核的入口地址。

内核通常以ELF(Executable and Linkable Format)格式存储。ELF是Linux和其他类UNIX系统上标准的可执行文件格式。一个ELF文件包含以下主要结构:

  • ELF头(ELF Header):位于文件开头,包含文件类型、目标架构、入口地址、程序头表和段头表的偏移量等信息。
  • 程序头表(Program Header Table):描述了文件中的各个段(Segment)应该被加载到内存的什么位置。每个程序头包含段类型、文件偏移、虚拟地址、物理地址、文件大小、内存大小等信息。
  • 段头表(Section Header Table):描述了文件中的各个节(Section)的信息,主要用于链接和调试。

Loader需要解析ELF头来获取入口地址和程序头表的位置,然后遍历程序头表,把每个需要加载的段(类型为PT_LOAD)复制到指定的内存地址。

c

// 简化的ELF加载过程(伪代码)
struct elf64_header *elf = (struct elf64_header *)kernel_buffer;

// 验证ELF魔数
if (elf->e_ident[0] != 0x7f || elf->e_ident[1] != 'E' ||
    elf->e_ident[2] != 'L'  || elf->e_ident[3] != 'F') {
    // 不是有效的ELF文件
    error("Invalid ELF file");
}

// 遍历程序头表
struct elf64_phdr *phdr = (struct elf64_phdr *)((char *)elf + elf->e_phoff);
for (int i = 0; i < elf->e_phnum; i++) {
    if (phdr[i].p_type == PT_LOAD) {
        // 将段从文件复制到内存
        memcpy((void *)phdr[i].p_vaddr,
               (char *)elf + phdr[i].p_offset,
               phdr[i].p_filesz);
        // 如果内存大小大于文件大小,用零填充余下部分(BSS段)
        if (phdr[i].p_memsz > phdr[i].p_filesz) {
            memset((void *)(phdr[i].p_vaddr + phdr[i].p_filesz),
                   0,
                   phdr[i].p_memsz - phdr[i].p_filesz);
        }
    }
}

// 获取入口地址
uint64_t entry_point = elf->e_entry;

内核加载完毕后,还需要在跳转之前做一些最后的准备工作:

  1. 确保页表正确映射了内核将要使用的虚拟地址空间。如果内核被链接到了高地址(比如0xFFFF800000000000这样的高半核地址),就需要在页表中建立从高地址到物理地址的映射。

  2. 把收集到的硬件信息(内存映射、显示模式等)整理成一个结构体,把指向这个结构体的指针作为参数传递给内核。在x86-64的调用约定中,第一个参数通过RDI寄存器传递。

  3. 最后一步,跳转到内核入口地址。对于64位代码,可以使用间接跳转:

nasm

    ; 假设内核入口地址已经存储在rax中
    ; rdi中存放传递给内核的参数(boot信息结构体的地址)
    mov rdi, boot_info
    jmp rax

从这一刻起,内核开始执行。Loader的历史使命彻底完成。

在Linux系统中,GRUB加载内核时遵循的是Linux Boot Protocol,这是一套定义了引导加载器如何加载Linux内核、如何传递参数的规范。GRUB按照这个协议设置好各种寄存器和内存中的数据结构,然后跳转到内核的入口点。内核启动后首先执行的是 arch/x86/boot/header.S 中的代码,它会检查引导加载器传来的参数,执行必要的硬件初始化,然后进入C语言编写的 start_kernel() 函数,那才是内核真正的起点。

Windows的启动过程也类似。winload.exe加载ntoskrnl.exe(Windows内核)到内存,设置好各种数据结构(如处理器控制块、加载器参数块等),然后跳转到内核入口点 KiSystemStartup()

对于我们自己的操作系统来说,虽然不需要遵循Linux Boot Protocol或Windows的规范,但设计一个清晰的引导协议同样重要。这个协议应该明确规定:内核文件的格式(ELF)、内核的加载地址、入口点在哪里、引导信息如何传递、传递哪些信息等。把这些约定清楚地文档化,不仅方便当前的开发,也方便日后的维护和扩展。


回顾整个第4章的内容,我们从计算机上电的第一条指令讲到内核代码开始执行,完整地走过了操作系统引导启动的全过程。这个过程可以用一条链来表示:

clean

电源接通 -> CPU复位 -> BIOS执行 -> POST自检 -> 硬件初始化
-> 读取引导扇区 -> Boot程序执行 -> 加载Loader -> Loader执行
-> 获取硬件信息 -> 设置GDT -> 启用A20 -> 进入保护模式
-> 创建页表 -> 进入长模式 -> 加载内核ELF -> 跳转到内核入口
-> 内核开始执行

链条中的每一环都不可或缺。引导启动虽然只是操作系统开发的起步阶段,但它涉及到的知识面非常广——BIOS机制、x86实模式架构、磁盘I/O、文件系统结构、保护模式切换、分页机制、ELF文件格式等。理解了这些内容,你就掌握了操作系统开发最基础也是最关键的一环。

从这里开始,CPU已经运行在64位长模式下,虚拟内存的大门已经打开,操作系统内核有了广阔的舞台。后续的工作——中断处理、内存管理、进程调度、设备驱动、系统调用——都将在这个基础之上展开。每一步都会让你的操作系统变得更加强大,每一步也都会让你对计算机系统的理解更加深入。

写操作系统是一项需要耐心的工程。当你在Bochs中调试引导代码,为一个寄存器的值不对而苦恼时,当你的代码因为页表设置错误而触发三重故障导致虚拟机重启时,当你花了一整天终于让CPU成功切换到长模式时——这些经历,每一个操作系统开发者都经历过。Linux之父Linus Torvalds在开发Linux的最初几个月里,也在反复调试引导代码和模式切换。唯一的区别是,他在1991年没有Bochs可以用,只能在真实硬件上反复重启。从这个角度来说,今天的操作系统开发者比当年幸福多了。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐