从零开始搭建自制操作系统——基础概念&bootloader编写
基础概念
实模式与保护模式
特性
实模式(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 用户程序)可防止非法访问
从实模式进入保护模式需要:
设置好 GDT(将描述符数组地址传入GDTR寄存器)
设置 CR0 寄存器的 PE 位(bit 0)为 1
跳转到 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各位表示如下:
⚠️ 设置了 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 dd if =boot.bin of=floppy.img bs=512 count=1 conv=notrunc dd if =stage2.bin of=floppy.img bs=512 count=2 seek=1 conv=notrunc dd if =kernel.bin of=floppy.img bs=512 seek=3 conv=notrunc
所以后续我们写好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.xmlwget 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 窗口)里使用下面的指令来查看寄存器信息。
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 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 ; } } 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 DIR := $(shell pwd) LINK_LD := $(DIR) /linker.ld TARGET := $(DIR) /focus-os.img .PHONY : all clean runall: $(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 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 ; } } 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 runall: $(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