从零开始搭建自制操作系统——基础概念&bootloader编写

collectcrop Lv3

基础概念

实模式与保护模式

特性 实模式(Real Mode) 保护模式(Protected Mode)
CPU 架构 最初 8086 从 80286 起支持
最大寻址内存 1MB 4GB(32位)或更高(64位)
位宽 16 位 32 位(或后来的 64 位)
内存访问方式 段:偏移(Segment:Offset) 线性地址 + 分段/分页机制
支持分页 ❌ 无 ✅ 有
支持特权级 ❌ 无 ✅ 有(Ring 0~3)
多任务 ✅ 可以通过 TSS 实现
程序加载 BIOS 调用 操作系统加载器
中断管理 直接访问中断向量表(0x0000:0x0000) 支持 IDT + 更复杂的中断系统

实模式(Real Mode)

段寄存器 + 偏移地址 访问内存:

  • 8086 的地址由 segment * 16 + offset 计算而得
  • 示例:CS = 0x1000, IP = 0x0010 → 实际地址 = 0x10000 + 0x10 = 0x10010

没有权限管理

  • 所有程序都可以访问所有内存,没有保护,容易导致错误程序覆盖内存。

只能访问 1MB

  • 虽然地址可以算出超过 1MB,但由于只使用 20 位地址线,超过部分会 wrap around。

BIOS、bootloader 都运行在实模式

  • 开机时 BIOS 会设置 CPU 为实模式,从 0x7c00 加载 MBR 并执行。

保护模式(Protected Mode)

段寄存器不再直接表示物理地址

  • 段寄存器变成 选择子(Selector),用来索引 GDT(全局描述符表) 中的段描述符。
  • 每个段描述符有:
    • 基址(Base)
    • 段限长(Limit)
    • 访问权限(Read/Write, Ring0~Ring3 等)

支持分页机制(Paging)

  • 将虚拟地址 → 线性地址 → 物理地址,能隔离程序地址空间
  • 支持内存保护、懒加载(demand paging)等高级特性

有中断描述符表(IDT)

  • 支持更强的中断管理,异常处理,软中断等

支持多任务(TSS, 特权级切换)

  • Task State Segment(TSS)提供任务切换所需上下文保存
  • 特权级(Ring0 内核,Ring3 用户程序)可防止非法访问

从实模式进入保护模式需要:

  1. 设置好 GDT(将描述符数组地址传入GDTR寄存器)
  2. 设置 CR0 寄存器的 PE 位(bit 0)为 1
  3. 跳转到 32 位代码段执行

GDT

GDT(Global Descriptor Table)

GDTR寄存器

首先我们先来看一下GDTR寄存器的含义,其中基地址就是gdt具体的描述符数组的起始地址,而界限描述了描述符数组的大小,由于界限占两个字节,而每个描述符占8字节,所以理论上最多有65536 / 8 = 8192个描述符。

一般汇编实现如下:

1
2
3
4
5
6
lgdt [gdt_descriptor]   ; 加载全局描述符表(GDT)


gdt_descriptor:
dw gdt_end - gdt_start - 1 ; limit,16bytes
dd gdt_start ; base,32bytes

GDT核心数据结构是一个描述符数组,每个描述符占8字节。

GDT段描述符结构

其中Access各位表示如下:

  • P:段是否存在于内存中。这个位一定要设为 1 才能被正常使用。
  • DPL:描述符的特权等级,共 2 位。值范围从00(Ring 0,内核)到 11(Ring 3,用户态)。
  • S:
    • 0:系统段(如 TSS、LDT、调用门等)。
    • 1:普通段(即代码段或数据段)。
  • E:
    • 0:这是一个数据段。
    • 1:这是一个代码段,可以执行指令。
  • DC:如果这是 数据段(E=0):0表示段向上增长(通常情况)。1表示段向下增长(如堆栈段)。如果这是 代码段(E=1):0表示非一致性(non-conforming)段,只能从相同特权级访问此段。1表示一致性(conforming)段,可以从更低权限的代码段跳转过来执行此段中的代码(但特权级不会改变)。
  • RW:如果是代码段(E=1):0表示代码段不可读(不常用);1表示代码段可读(大多数操作系统都设为可读);两者都不可写。如果是数据段(E=0):0表示数据段不可写;1表示数据段可读可写(正常的数据段通常设为可写);两者都可读。
  • A:CPU 会在首次访问段时自动设置这一位为 1。如果该位初始为 0,则第一次访问会触发 CPU 设置这个位(需要段描述符可写)。如果段描述符是只读的内存区域(比如页表标记只读),就可能触发 page fault。一般直接置一。

而flag各位表示如下:

  • G:

    • 0:limit 的单位是 字节(Byte)
    • 1:limit 的单位是 4KB(Page)。可以更方便地表示大内存段。
  • DB:

    对代码段(E=1):

    • 0:段中默认是 16 位指令(16 位保护模式代码段)。
    • 1:段中默认是 32 位指令(32 位保护模式代码段)。

    对数据段(E=0):

    • 0:使用 16 位偏移。
    • 1:使用 32 位偏移。
  • L:

    只用于代码段

    0:不是 64 位代码段(也就是 16 位或 32 位段)。

    164 位代码段仅在长模式下使用,也就是 x86_64)。

⚠️ 设置了 L=1 时,必须设置 D/B=0(它俩互斥)。

汇编表示如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
gdt_start:
gdt_null: ; 空描述符
dq 0

gdt_code: ; 代码段描述符(base=0, limit=4GB, type=code)
dw 0xFFFF ; limit low 16bits
dw 0x0000 ; base low 16bits
db 0x00 ; base middle 8bits
db 10011011b ; flags 8bits
db 11001111b ; limit high 4bits + flags 4bits
db 0x00 ; base high 8bits

gdt_end:

实际上GDT 的第一个描述符(gdt_null)是一个空描述符,它的作用是 占位,为了避免出现无效的段选择子。具体原因:

  • 段选择子为 0(gdt_null)不被使用:GDT 中的第一个描述符是一个保留的空描述符,索引为 0 的选择子(0x00)从来不会被使用。这样可以防止程序错误地引用 GDT 中的第一个描述符。
  • 段选择子从 0x8 开始:实际有效的段选择子从 0x08 开始(第二个描述符)。这是因为描述符索引是基于 8 字节对齐的,所以 gdt_code 对应的选择子就是 0x08,即在 GDT 中的第一个有效描述符。

段选择子的计算:实际上段选择子是由 GDT 索引(index)计算出来的,公式如下:

1
Selector = (Index << 3) | (TI << 2) | RPL
  • Index: GDT 中段描述符的索引。
  • TI: 指示该段描述符是来自 GDT 还是 LDT,GDT 的 TI = 0
  • RPL: 请求的特权级(Ring 级别),通常内核模式使用 RPL = 0

CR系列寄存器作用

CR0 — 控制模式和开关

位名 位号 含义
PE 0 Protection Enable(置 1 开启保护模式)✅
MP 1 Monitor Coprocessor(浮点协处理)
EM 2 Emulation(禁用 FPU)
TS 3 Task Switched
ET 4 Extension Type(硬件兼容用)
NE 5 Numeric Error(启用内部异常处理)
WP 16 Write Protect(分页写保护)
AM 18 Alignment Mask(内存对齐检查)
PG 31 Paging(置 1 开启分页机制)✅

CR1 — 未定义(永远保留)

CR2 — 页面错误地址

当触发 页面错误异常(#PF) 时,CR2 会保存访问的故障地址

这是处理分页错误的重要调试信息(比如缺页中断)

CR3 — 页目录基地址寄存器(也叫 PDBR)

当开启分页(PG=1)时:

  • CR3 要设置为 页目录(Page Directory) 的物理地址
  • 操作系统每次切换进程时,都会更新 CR3,从而切换虚拟地址映射

CR4 — 启用新功能

位名 位号 功能
PSE 4 Page Size Extension(4MB 大页)
PAE 5 Physical Address Extension(36-bit 地址)
PGE 7 Page Global Enable(全局页)
OSFXSR 9 SSE 支持
... ... 更多高级功能

BIOS启动方式

BIOS 启动后,会寻找第一个可启动设备(通常是硬盘)。然后读取该设备 扇区 0(LBA 0) 的前 512 字节 到内存的 0x7C00 处执行。这 512 字节被称为 MBR(主引导记录)。该主引导记录格式如下:

1
2
3
4
5
Offset | 含义
----------------------------
0x000 | 启动代码(最多 446 字节)
0x1BE | 分区表(4 个分区,每个 16 字节)
0x1FE | 结束标志 0xAA55

所以写的 bootloader 必须非常精简,并以 0xAA55 结尾,才能被 BIOS 正确识别。

启动时的第一个MB的内存布局如下:

BIOS常用中断

中断号 用途 举例
0x10 显示输出 打印字符、设置文本/图形模式
0x13 磁盘读写 加载 stage2 / kernel
0x15 获取内存信息 用于内核初始化内存管理
0x16 获取键盘输入 等待用户按键

0x10中断

功能号 (AH) 功能描述 输入参数(常用)
0x00 设置显示模式(Set Video Mode) AL = 模式号(如 0x03 文本)
0x0E TTY 模式输出字符(Print char) AL = 字符,AH = 0x0E,BH = 页号,BL = 颜色
0x13 写字符串(Write string) AL=写入方式, ES:BP=字符串地址, CX=字符数

示例:

1
2
3
mov ah, 0x0E
mov al, 'H'
int 0x10 ; 打印一个字符

0x13中断

功能号 功能描述 输入参数(常用)
0x02 读取扇区 AL = 扇区数, CH = 柱面, CL = 扇区, DH = 磁头, DL = 驱动器号, ES:BX = 缓冲区
0x03 写扇区 同上
0x08 获取驱动器参数 DL = 驱动器号(0x00 = floppy, 0x80 = HDD)
  • 实模式下扇区读写有 BIOS 限制(如最大每次读 127 个扇区)
  • 地址通过 CH(柱面)、CL(扇区)、DH(磁头)指定(CHS 模式)

示例(读取 1 个扇区):

1
2
3
4
5
6
7
8
9
10
11
mov ah, 0x02        ; 功能号:读扇区
mov al, 0x01 ; 读取 1 个扇区
mov ch, 0x00 ; 柱面 0
mov cl, 0x02 ; 扇区 2
mov dh, 0x00 ; 磁头 0
mov dl, 0x00 ; 驱动器 0(软盘)
mov bx, 0x8000 ; 缓冲区
mov es, bx
xor bx, bx
int 0x13
jc error ; 如果 CF = 1,说明出错

0x15中断

功能号 (AX) 功能描述
0xE820 获取可用内存映射(现代方式)
0xE801 获取内存大小(老方式)
0x88 获取扩展内存大小(单位 KB)

示例:获取扩展内存

1
2
3
mov ah, 0x88
int 0x15
; AX = 扩展内存大小(KB),最大到 65535KB = 64MB

0x16中断

功能号 (AH) 功能描述 输出寄存器
0x00 等待并读取按键 AH = 扫描码,AL = ASCII
0x01 检查是否有键按下 ZF = 1 表示无按键
0x02 获取键盘状态 AL = 状态字节

示例:

1
2
3
mov ah, 0x00
int 0x16
; 等待用户按下任意键,返回的 ASCII 在 AL 中

加载流程设计

一般而言采用两阶段引导加载,因为一个扇区的512字节实在太少了

1
BIOS --> bootloader (stage1, 512字节) --> stage2 loader (几个扇区) --> 加载 & 跳转 kernel

实际开发中需要 stage2的原因如下:

原因 说明
bootloader 的大小限制 MBR(第一个扇区)只有 512 字节,其中 BIOS 要求最后两个字节是 0x55AA,真正能用的只有 446 字节左右,太小了,根本放不下文件系统解析、GDT、分页、ELF解析等功能。
内核格式可能是 ELF/复杂结构 操作系统内核常用的是 ELF 格式,需要解析段表(program headers)、加载多个段、重定位。bootloader 不足以处理这些格式。
加载多个扇区太麻烦 boot.bin 不支持 FAT、EXT 等文件系统,无法定位内核的位置,只能靠硬编码 LBA 扇区号,比较脆弱。
需要更灵活的设置(分页、多核、多任务) stage2 可以做更复杂的初始化,比如启用分页、加载多核、准备内核参数等,boot.bin 装不下这些逻辑。
保持职责清晰 boot 只负责最小化地进入一个“干净的 32 位环境”,stage2 才开始进行高级的系统初始化和内核加载。

ld链接脚本

ld 是 GNU 的链接器(Linker),它把多个目标文件(.o 文件)链接成一个最终的可执行程序。.ld 文件告诉链接器:

  • 程序从哪里开始执行(ENTRY(...)
  • 各个段(.text, .data, .bss)应该放到内存的哪个地址
  • 各个段的排列顺序和对齐方式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ENTRY(_start)       /* 程序入口,定义了主函数 */

SECTIONS {
. = 0x1000; /* 从内存地址 0x1000 开始加载 */

.text : {
*(.text) /* 收集所有 .text 段(代码段) */
}

.rodata : {
*(.rodata) /* 只读数据,比如字符串常量 */
}

.data : {
*(.data) /* 已初始化的数据段 */
}

.bss : {
*(.bss COMMON) /* 未初始化的数据段(清零) */
}
}

语法 含义
ENTRY(_start) 指定程序入口为 _start 标签
. 当前地址指针,. = 0x1000 表示链接从 0x1000 开始
.text : { *(.text) } 把所有目标文件里的 .text 段收集放入 .text
*(...) 星号表示“所有目标文件中”的匹配段
.bss COMMON COMMON 表示未初始化的变量也放进 .bss

构成启动盘镜像

一般使用dd指令来构建启动盘镜像,基本格式为dd if="输入文件" of="输出文件" bs="数据块" count="数量",seek=n表示从文件偏移n×bs开始写。我们启动过程一般需要以如下方式构建启动盘镜像。

1
2
3
4
dd if=/dev/zero of=floppy.img bs=512 count=2880                    # 创建空白软盘镜像(512 * 2880 = 1.44MB)
dd if=boot.bin of=floppy.img bs=512 count=1 conv=notrunc # 写入 bootloader
dd if=stage2.bin of=floppy.img bs=512 count=2 seek=1 conv=notrunc # 写入 stage2(2 扇区 = 1024 字节)
dd if=kernel.bin of=floppy.img bs=512 seek=3 conv=notrunc # 写入 kernel(从第3扇区)

所以后续我们写好boot.asm,stage2.asm,kernel.c后就可以靠makefile一键编译打包好成一个软盘,然后用qemu启动。

扇区地址结构CHS

这个结构使用柱面(Cylinder)、磁头(Head)和扇区(Sector)来定位数据。

  • 柱面 (Cylinder):硬盘或软盘的物理圆柱体,所有具有相同轨道位置的扇区属于同一个柱面。
  • 磁头 (Head):硬盘或软盘的磁头,负责读取和写入数据。通常有多个磁头,分布在多个柱面上。
  • 扇区 (Sector):硬盘或软盘的最小数据单元,一个扇区通常为 512 字节。

硬盘的物理布局通常是以“柱面 × 磁头 × 扇区”进行划分的。

地址计算

假设有如下的磁盘参数:

  • 柱面数 (C):通常取决于硬盘或软盘的容量。
  • 磁头数 (H):也取决于硬盘或软盘的结构,一般来说,硬盘的每个磁盘面会有多个磁头。
  • 扇区数 (S):每个磁头的一个柱面上有多个扇区,通常一个柱面有 63 个扇区(对于传统的硬盘,软盘是 18)。

每个扇区的物理地址可以通过以下公式计算:

  • 柱面 (Cylinder) = address / (heads \* sectors)
  • 磁头 (Head) = (address / sectors) % heads
  • 扇区 (Sector) = address % sectors

QEMU 模拟的软盘与真实硬盘的物理参数有所不同。QEMU 默认模拟的软盘参数通常是标准的 1.44MB 软盘格式,具体来说,它的常见 CHS 参数如下:

  • 磁头数 (Heads):通常为 2(即双面软盘)。

  • 每柱面扇区数 (Sectors per Cylinder):通常为 18 个扇区。

  • 柱面数 (Cylinders):软盘总共约 80 个柱面,计算方法是:总扇区数 / (扇区数/柱面 * 磁头数)。例如,1.44MB 软盘总共有 2880 个扇区(1.44MB = 1440KB,1 扇区 = 512 字节,2880 = 1440KB / 512B)。所以柱面数为: \[ 2880 ÷ (18 \times 2) = 80 \text{ 柱面} \]

所以 QEMU 模拟的 1.44MB 软盘的 CHS 参数通常为:

  • 柱面数 (Cylinder):80
  • 磁头数 (Head):2
  • 扇区数 (Sector):18

我们可以通过qemu-img info floppy.img来查看磁盘的基本信息。

调试方法

这里我们采用qemu虚拟化平台来跑我们的内核,在qemu启动时加上-s选项就可以启动一个调试的服务在1234端口,我们可以用gdb来进行连接调试。这里layout asm来查看原始的汇编代码,layout regs来查看寄存器情况。

1
2
3
4
5
6
7
8
9
qemu-system-i386 -fda $(TARGET)  -s -S

sudo gdb kernel.elf \
-ex 'target remote localhost:1234' \
-ex 'set architecture i8086' \
-ex 'layout asm' \
-ex 'layout regs' \
-ex 'break *(0x7c00)' \
-ex 'continue'

但实际发现即使我们指定了i8086的架构,gdb还是会以32位进行解析,gdb不执行分段:偏移计算,所以这里用了一个好用的脚本来扩充gdb显示16位下的assembly。其中这个脚本里我们需要把里面的set architecture i8086的注释取消掉。

还需要搭配使用两个文件,将这两个文件以及上面给的脚本放在运行gdb的同一个目录下(也可以绝对路径直接指定好):

1
2
echo '<?xml version="1.0"?><!DOCTYPE target SYSTEM "gdb-target.dtd"><target><architecture>i8086</architecture><xi:include href="i386-32bit.xml"/></target>' > target.xml
wget https://raw.githubusercontent.com/qemu/qemu/master/gdb-xml/i386-32bit.xml

新的使用方式如下,在使用前需要关闭其它gdb插件,比如pwndbg和gef等,否则会报double free错误:

1
2
3
4
5
6
#! /usr/bin/bash

gdb -ix "gdb_init_real_mode.txt" \
-ex "set tdesc filename target.xml" \
-ex "target remote localhost:1234" \
-ex "br *0x7c00" -ex "c"

然后就能进行调试,可以正常显示16位的寄存器。

实际后面调试时还是会存在gdb错误解析的问题,比如我们切换到保护模式之后,CS代码段寄存器存的0x8应该是表示选择子,但实际由于用了上面的实模式显示,所以会在code段显示到别的地方(但IP还是正确的,就是每次执行后我们都要手动打印对应地址的指令比较麻烦)。实际上我们切换到保护模式后就可以退出这个real-mode-gdb了。这里我观察了下扩展脚本的源码,发现问题如下,其$rip一开始就设置成实模式的寻址方式,那么后面code展现就会出现问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
define compute_regs
set $rax = ((unsigned long)$eax & 0xFFFF)
set $rbx = ((unsigned long)$ebx & 0xFFFF)
set $rcx = ((unsigned long)$ecx & 0xFFFF)
set $rdx = ((unsigned long)$edx & 0xFFFF)
set $rsi = ((unsigned long)$esi & 0xFFFF)
set $rdi = ((unsigned long)$edi & 0xFFFF)
set $rbp = ((unsigned long)$ebp & 0xFFFF)
set $rsp = ((unsigned long)$esp & 0xFFFF)
set $rcs = ((unsigned long)$cs & 0xFFFF)
set $rds = ((unsigned long)$ds & 0xFFFF)
set $res = ((unsigned long)$es & 0xFFFF)
set $rss = ((unsigned long)$ss & 0xFFFF)
set $rip = ((((unsigned long)$cs & 0xFFFF) << 4) + ((unsigned long)$eip & 0xFFFF)) & $ADDRESS_MASK
set $r_ss_sp = ((((unsigned long)$ss & 0xFFFF) << 4) + ((unsigned long)$esp & 0xFFFF)) & $ADDRESS_MASK
set $r_ss_bp = ((((unsigned long)$ss & 0xFFFF) << 4) + ((unsigned long)$ebp & 0xFFFF)) & $ADDRESS_MASK
end

.......
define context
printf "---------------------------[ STACK ]---\n"
_dump_memw $r_ss_sp 8
printf "\n"
set $_a = $r_ss_sp + 16
_dump_memw $_a 8
printf "\n"
printf "---------------------------[ DS:SI ]---\n"
print_data $ds $rsi
printf "---------------------------[ ES:DI ]---\n"
print_data $es $rdi

printf "----------------------------[ CPU ]----\n"
print_regs
print_eflags
printf "---------------------------[ CODE ]----\n"

set $_code_size = $CODE_SIZE

# disassemble
# first call x/i with an address
# subsequent calls to x/i will increment address
if ($_code_size > 0)
x /i $rip
set $_code_size--
end
while ($_code_size > 0)
x /i
set $_code_size--
end
end

......
define hook-stop
compute_regs
if ($SHOW_CONTEXT > 0)
context
end
end

那么我们只需将脚本经过如下修改,就可以让code最后显示正确了,这里默认cs为0说明是实模式,因为在保护模式下0号段选择器是空。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
define compute_regs
set $rax = ((unsigned long)$eax & 0xFFFF)
set $rbx = ((unsigned long)$ebx & 0xFFFF)
set $rcx = ((unsigned long)$ecx & 0xFFFF)
set $rdx = ((unsigned long)$edx & 0xFFFF)
set $rsi = ((unsigned long)$esi & 0xFFFF)
set $rdi = ((unsigned long)$edi & 0xFFFF)
set $rbp = ((unsigned long)$ebp & 0xFFFF)
set $rsp = ((unsigned long)$esp & 0xFFFF)
set $rcs = ((unsigned long)$cs & 0xFFFF)
set $rds = ((unsigned long)$ds & 0xFFFF)
set $res = ((unsigned long)$es & 0xFFFF)
set $rss = ((unsigned long)$ss & 0xFFFF)
if $rcs > 0
set $rip = ((unsigned long)$eip & 0xFFFF)
set $r_ss_sp = ((unsigned long)$esp & 0xFFFF)
set $r_ss_bp = ((unsigned long)$ebp & 0xFFFF)
set architecture i386
else
set $rip = ((((unsigned long)$cs & 0xFFFF) << 4) + ((unsigned long)$eip & 0xFFFF)) & $ADDRESS_MASK
set $r_ss_sp = ((((unsigned long)$ss & 0xFFFF) << 4) + ((unsigned long)$esp & 0xFFFF)) & $ADDRESS_MASK
set $r_ss_bp = ((((unsigned long)$ss & 0xFFFF) << 4) + ((unsigned long)$ebp & 0xFFFF)) & $ADDRESS_MASK
set architecture i8086
end
end

也可以在 QEMU Monitor(通常是 Ctrl-Alt-2 切换到 QEMU Monitor 窗口)里使用下面的指令来查看寄存器信息。

1
info registers

bootloader编写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
; boot.asm - 启动程序(16 位实模式)

[org 0x7C00] ; BIOS 会把这段代码加载到 0x7C00
[BITS 16]

start:
cli ; 关中断
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7000 ; 设置堆栈

; 读取 stage2 (加载 stage2.asm 编译后的镜像) 到 0x8000
mov ah, 0x02 ; BIOS function: read sector
mov al, 2 ; 读取 2 个扇区
mov ch, 0 ; 柱面
mov cl, 2 ; 起始扇区(第2个扇区)
mov dh, 0 ; 磁头
mov dl, 0x00 ; 软盘
mov bx, 0x8000 ; 加载地址
int 0x13
jc disk_error

; 读取 kernel (加载 kernel.c 编译后的镜像) 到 0x10000
mov ah, 0x02 ; BIOS function: read sector
mov al, 5 ; 读取 5 个扇区
mov ch, 0 ; 柱面
mov cl, 4 ; 起始扇区(第4个扇区)
mov dh, 0 ; 磁头
mov dl, 0x00 ; 软盘
mov bx, 0x9000 ; 加载地址
int 0x13
jc disk_error


lgdt [gdt_descriptor] ; 加载全局描述符表(GDT)

; 开启保护模式
mov eax, cr0
or eax, 1
mov cr0, eax


; 使用远跳转更新 CS
jmp 0x08:protected_mode_entry


[BITS 32]
protected_mode_entry:
; 设置段寄存器(CS 已经在 far jump 中设置了)
mov ax, 0x10
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax

mov esp, 0x90000 ; 设置新栈

jmp 0x8000 ; 跳转到 stage2 加载地址

disk_error:
hlt
jmp $


; ----- GDT -----
gdt_start:
gdt_null: ; 空描述符
dq 0

gdt_code: ; 代码段描述符(base=0, limit=4GB, type=code)
dw 0xFFFF ; limit low 16bits
dw 0x0000 ; base low 16bits
db 0x00 ; base middle 8bits
db 10011010b ; access bytes 8bits
db 11001111b ; limit high 4bits + flags 4bits
db 0x00 ; base high 8bits

gdt_data:
dw 0xFFFF
dw 0x0000
db 0x00
db 10010010b ; P=1, DPL=0, S=1(data), DC=0(grows up), RW=1
db 11001111b ; G=1, D/B=1, limit_high=0xF
db 0x00

gdt_end:

gdt_descriptor:
dw gdt_end - gdt_start ; limit
dd gdt_start ; base

; ----- 填充到 510 字节 -----
times 510 - ($ - $$) db 0
dw 0xAA55 ; MBR 魔数

1
2
3
4
5
6
; stage2.asm

[BITS 32]
start:
jmp 0x08:0x9000 ; 跳转到 kernel_main

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// kernel.c
void clear_screen();
void print_string(const char *str);
void kernel_main(void) {
clear_screen(); // 清屏
print_string("Hello, Kernel!"); // 打印一行字符
while (1) { }
}

void clear_screen() {
unsigned short *video = (unsigned short *) 0xB8000;
for (int i = 0; i < 80 * 25; i++) {
video[i] = 0x0F20; // 清屏,0x0F 是前景色,0x20 是空格
}
}

void print_string(const char *str) {
unsigned short *video = (unsigned short *) 0xB8000;
while (*str) {
*video = (0x0F << 8) | *str; // 设置字符和颜色
video++;
str++;
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# makefile
DIR := $(shell pwd)
LINK_LD := $(DIR)/linker.ld
TARGET := $(DIR)/focus-os.img

.PHONY: all clean run

all: $(TARGET)

boot.bin: boot.asm
nasm -f bin boot.asm -o boot.bin

stage2.bin: stage2.asm
nasm -f bin stage2.asm -o stage2.bin

kernel.o: kernel.c
gcc -m32 -g -ffreestanding -c kernel.c -o kernel.o

kernel.bin: kernel.o linker.ld
ld -m elf_i386 -g -T $(LINK_LD) -o kernel.elf kernel.o
objcopy -O binary kernel.elf kernel.bin

$(TARGET): boot.bin kernel.bin stage2.bin
dd if=/dev/zero of=$(TARGET) bs=512 count=2880
dd if=boot.bin of=$(TARGET) bs=512 count=1 conv=notrunc
dd if=stage2.bin of=$(TARGET) bs=512 count=2 seek=1 conv=notrunc
dd if=kernel.bin of=$(TARGET) bs=512 count=5 seek=3 conv=notrunc

run:
qemu-system-i386 -fda $(TARGET) -s -S


clean:
rm -f *.bin *.o *.elf $(TARGET)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# linker.ld
ENTRY(kernel_main) /* 程序入口,定义了主函数 */

SECTIONS {
. = 0x0; /* 从内存地址 0x0 开始加载 */

.text : {
*(.text) /* 收集所有 .text 段(代码段) */
}

.rodata : {
*(.rodata) /* 只读数据,比如字符串常量 */
}

.data : {
*(.data) /* 已初始化的数据段 */
}

.bss : {
*(.bss COMMON) /* 未初始化的数据段(清零) */
}
}

我们一步一步拆开来看,首先[org 0x7C00]会让BIOS把这段代码加载到固定的0x7c00地址处,即我们的IP从0x7c00开始,然后后面的[BITS 16]指定了目前是16位的实模式。

1
2
3
4
5
6
7
8
9
10
11
12
; boot.asm - 启动程序(16 位实模式)

[org 0x7C00] ; BIOS 会把这段代码加载到 0x7C00
[BITS 16]

start:
cli ; 关中断
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7000 ; 设置堆栈

然后是通过从软盘读取对应的其它部分到内存,这部分需要在切换到保护模式之前完成,因为到了保护模式之后就不能用BIOS的int 0x13中断来加载内存了。这里加载部分需要配合后面虚拟软盘设计进行填入参数,这里我们只用先知道可以把别的编译好的二进制程序加载到对应内存中即可,后面我们就可以通过固定的地址跳转来转到别的二进制程序中。这里我们先采用软盘模式启动,所以这里dl设置为0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
; 读取 stage2 (加载 stage2.asm 编译后的镜像) 到 0x8000
mov ah, 0x02 ; BIOS function: read sector
mov al, 2 ; 读取 2 个扇区
mov ch, 0 ; 柱面
mov cl, 2 ; 起始扇区(第2个扇区)
mov dh, 0 ; 磁头
mov dl, 0x00 ; 软盘
mov bx, 0x8000 ; 加载地址
int 0x13
jc disk_error

; 读取 kernel (加载 kernel.c 编译后的镜像) 到 0x10000
mov ah, 0x02 ; BIOS function: read sector
mov al, 5 ; 读取 5 个扇区
mov ch, 0 ; 柱面
mov cl, 4 ; 起始扇区(第4个扇区)
mov dh, 0 ; 磁头
mov dl, 0x00 ; 软盘
mov bx, 0x9000 ; 加载地址
int 0x13
jc disk_error

实际上我们在进行调试时,遇到int的中断,需要手动在它后面一个指令打断点再continue,否则si/ni会跳转到一些奇怪的地方。这里我们跟进到boot.asm的int 0x13之后,发现确实把stage2.asm的内容加载到了0x8000位置处。

然后加载GDT后修改cr0开启保护模式,用远跳转跳到protected_mode_entry处并更新CS为我们的Selector 1。GDT表的设置主要是先把第一个8字节处置空,然后我们用到两个描述符,一个存代码一个存数据。其中我们limit位都设置的很大,为0x11ffff,并且base都置零,这里不同的gdt描述符的区域可以重叠,后续到了内核才会对不同内存段进行区分,这里的描述符区别在于access bytes和后面flags里规定的读写权限以及一些配置。然后最后需要有一个gdt_descriptor,其base字段指向gdb描述符表。然后就可以lgdt [gdt_descriptor]来加载全局描述符表了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
lgdt [gdt_descriptor]   ; 加载全局描述符表(GDT)

; 开启保护模式
mov eax, cr0
or eax, 1
mov cr0, eax


; 使用远跳转更新 CS
jmp 0x08:protected_mode_entry

......
; ----- GDT -----
gdt_start:
gdt_null: ; 空描述符
dq 0

gdt_code: ; 代码段描述符(base=0, type=code)
dw 0xFFFF ; limit low 16bits
dw 0x0000 ; base low 16bits
db 0x00 ; base middle 8bits
db 10011010b ; access bytes 8bits
db 11001111b ; limit high 4bits + flags 4bits
db 0x00 ; base high 8bits

gdt_data:
dw 0xFFFF
dw 0x0000
db 0x00
db 10010010b ; P=1, DPL=0, S=1(data), DC=0(grows up), RW=1
db 11001111b ; G=1, D/B=1, limit_high=0xF
db 0x00

gdt_end:

gdt_descriptor:
dw gdt_end - gdt_start ; limit
dd gdt_start ; base

然后进入保护模式后,就可以跳转到stage2进行执行,跳转之前需要设置好各个段寄存器指向gdt_data,然后把esp指向新的栈空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
[BITS 32]
protected_mode_entry:
; 设置段寄存器(CS 已经在 far jump 中设置了)
mov ax, 0x10
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax

mov esp, 0x90000 ; 设置新栈

jmp 0x8000 ; 跳转到 stage2 加载地址

stage2目前先不用加入别的功能,先直接跳转到内核。

1
2
3
4
5
6
; stage2.asm

[BITS 32]
start:
jmp 0x08:0x9000 ; 跳转到 kernel_main

最后就能进入进行执行,注意这里我们不能使用c的一些标准库进行输出,而是得往0xB8000这个Video Memory的RAM中写入,其格式是一个字节表示颜色,一个字节表示字符,所以我们可以用short的数据类型存每个字符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// kernel.c
// 函数声明
void clear_screen();
void print_string(const char *str);
void kernel_main(void) {
clear_screen(); // 清屏
print_string("Hello, Kernel!"); // 打印一行字符
while (1) { }
}

void clear_screen() {
unsigned short *video = (unsigned short *) 0xB8000;
for (int i = 0; i < 80 * 25; i++) {
video[i] = 0x0F20; // 清屏,0x0F 是前景色,0x20 是空格
}
}

void print_string(const char *str) {
unsigned short *video = (unsigned short *) 0xB8000;
while (*str) {
*video = (0x0F << 8) | *str; // 设置字符和颜色
video++;
str++;
}
}

之后就是需要链接编译程序后执行,使用到的makefile如下。其中最重要的部分是最后生成软盘这个输出目标。用的是dd指令,首先创建了一个空的软盘,然后往第一扇区写入boot.bin,长度为一个扇区;往第二扇区写入stage2.bin,长度为两个扇区;往第四扇区写入kernel.bin,长度为5个扇区。那么之前调用0x13中断从软盘映射到内存的一些参数就可以填进去了。其中run的伪目标开的-s和-S是用来调试的,在本地起1234端口提供调试服务。如果开了-S,qemu会在一开始进去时就卡住等待调试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
DIR := $(shell pwd)
LINK_LD := $(DIR)/linker.ld
TARGET := $(DIR)/focus-os.img

.PHONY: all clean run

all: $(TARGET)

boot.bin: boot.asm
nasm -f bin boot.asm -o boot.bin

stage2.bin: stage2.asm
nasm -f bin stage2.asm -o stage2.bin

kernel.o: kernel.c
gcc -m32 -g -ffreestanding -c kernel.c -o kernel.o

kernel.bin: kernel.o linker.ld
ld -m elf_i386 -g -T $(LINK_LD) -o kernel.elf kernel.o
objcopy -O binary kernel.elf kernel.bin

$(TARGET): boot.bin kernel.bin stage2.bin
dd if=/dev/zero of=$(TARGET) bs=512 count=2880
dd if=boot.bin of=$(TARGET) bs=512 count=1 conv=notrunc
dd if=stage2.bin of=$(TARGET) bs=512 count=2 seek=1 conv=notrunc
dd if=kernel.bin of=$(TARGET) bs=512 count=5 seek=3 conv=notrunc

run:
qemu-system-i386 -fda $(TARGET) -s -S


clean:
rm -f *.bin *.o *.elf $(TARGET)

最后就可以跑通在屏幕上显示我们的内容。

参考资料

https://cloud.tencent.com/developer/ask/sof/112517341

https://www.cnblogs.com/jiangbo4444/p/17079748.html

https://wiki.osdev.org/Bootloader

  • 标题: 从零开始搭建自制操作系统——基础概念&bootloader编写
  • 作者: collectcrop
  • 创建于 : 2025-04-29 16:04:27
  • 更新于 : 2025-04-29 16:04:49
  • 链接: https://collectcrop.github.io/2025/04/29/从零开始搭建自制操作系统——基础概念-bootloader编写/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。