第一个内核与第一个像素 — 把 UEFI 踢开,接管机器
第一个内核与第一个像素 — 把 UEFI 踢开,接管机器
这一章讲什么: 第 2 章的引导加载器只会「侦察地形」,本章它第一次干正事: 把内核文件从 U 盘读进内存、举行「卸任仪式」(停止 UEFI 引导服务)、跳去执行内核—— 从这一步起,机器归你的内核管,UEFI 的一切便利(Print、文件、内存分配)全部作废。 然后马上做全书第一个「啊哈」:往帧缓冲区写数,屏幕变色——窗口、文字、鼠标, 往后一切都是这个等式的展开。 穿插其间的是两件保命技能:QEMU 监视器调试法,和一节「指针在汇编里长什么样」。
1. 先看现象:交接仪式,一步一步
主线例子——从按下电源到内核接管的完整链条:
U 盘:/EFI/BOOT/BOOTX64.EFI(引导加载器) + /kernel.elf(内核)
│
▼
UEFI BIOS 加载 BOOTX64.EFI,执行 UefiMain()
│
▼
引导加载器五步走:
① 打开 \kernel.elf,GetInfo() 问出文件大小
② AllocatePages() 在 0x100000 处分配够大的内存
③ Read() 把整个文件读进那块内存
④ ExitBootServices(map_key) —— UEFI 正式卸任(可能失败一次,要重试)
⑤ 从 ELF 头读出入口点地址 0x101000,算出它在内存里的位置,当作函数调用跳过去
│
▼
KernelMain() 开始跑:__asm__("hlt") 死循环 —— 屏幕上不再出现 "All done"
图说:第 ④ 步是全书最不可逆的操作——引导服务一停,Print() 都不能再用了。
0x100000 / 0x101000 等数值来自作者示例(readelf 显示的入口点),你的构建可能不同。第 ⑤ 步为什么不是「直接跳」? 入口点地址只是 ELF 头里一个 8 字节整数(偏移 24 字节处),
要把它当 C 函数调用,得给它补上「参数和返回值的类型信息」——书里造了一个新类型
typedef void EntryPointType(void),把整数强转成这种函数指针再调用1。
指针 = 地址 + 类型,上一章的口头禅,这里又救了一次场。
2. 顶层全景:本章的三个板块
板块一(3.1–3.2):调试装备 —— QEMU 监视器(info registers / x 内存转储)+ 寄存器速览
板块二(3.3):交接仪式 —— 内核编译、加载、ExitBootServices、调用入口点
板块三(3.4–3.6):点亮像素 —— GOP 拿帧缓冲区 → 白屏 → 内核按位置画图案 → 补错误处理
暗线(3.7):指针的汇编视角 —— mov、[]、lea,看穿「指针变量和普通变量没区别」
图说:交接(板块二)是本心,像素(板块三)是奖励,调试(板块一)是保险。3. 核心原理
3.1 QEMU 监视器:真机给不了的透视眼
引导加载器和操作系统的调试有个特点:症状会骗人。「注释掉一行 Print() 居然就好了」 这种怪事,在低层开发中是常态——因为内存损坏、CPU 配置不对时,改动任何代码都可能改变症状2。
QEMU 监视器是模拟器内置的调试控制台(真机上可没有),两个救命命令3:
| 命令 | 干什么 | 示例输出 |
|---|---|---|
info registers | 显示 CPU 所有寄存器的当前值 | RIP=00000000067ae4c4 RFL=00000202 … |
x /fmt addr | 显示 addr 开始的内存(fmt = 个数+格式+单位,如 /4xb = 4 个十六进制字节,/2i = 反汇编 2 条指令) | 0xeb 0xfe 0x66 0x90 → 反汇编出 jmp 自己 |
验证内核真在跑的办法:多看几次 info registers,RIP(下一条待执行指令的地址)不变,
说明在原地转圈;再 x /2i 看 RIP 指的内存,看到 jmp 转到的地方躺着 hlt 指令,就放心了4。
注意书里那个 jmp 0x67ae4c4——跳转目标正是它自己的地址,这就是 C 的 while (1); 编译出来的样子5。
3.2 寄存器:CPU 肚子里的高速小抽屉
寄存器是 CPU 内部的存储区域,功能和主内存类似,但容量小得多、速度快得多: 主内存大约 16 GB,而 x86-64 全部 16 个通用寄存器加起来只有 128 字节; 内存读写约 100 ns,寄存器几乎零延迟(2 GHz 的 CPU 一个时钟周期 0.5 ns)6。
三类必须认识的:
- 16 个通用寄存器:RAX、RBX、RCX、RDX、RBP、RSI、RDI、RSP、R8–R15,
都是 8 字节;x86 的运算指令一般两个操作数,左边被写、右边被读(
add rax, rbx就是rax += rbx)7; - RIP:存下一条要执行指令的地址,自动前进,遇跳转被改写;
- RFLAGS / CR0:标志与 CPU 设置。CR0 尤其神:往它的第 0 位写 1,CPU 进保护模式; 往第 31 位写 1,启用分页——写寄存器这个动作本身就在操作硬件8。
3.3 交接仪式的五个技术细节
① 内核是独立编译的 ELF 文件。 引导加载器是 UEFI 应用(PE),内核是 ELF——分开编译,
两边都自由。内核的编译选项里藏着全书的基调:-ffreestanding(独立环境,没有操作系统托底)、
-fno-exceptions、-fno-rtti(禁用需要 OS 支持的 C++ 特性)、-mno-red-zone、
--image-base 0x100000(预定住在内存 0x100000)9。
② 分配内存要按页向上取整。 页数 = (文件大小 + 0xfff) / 0x1000——加 0xfff 是为了向上舍入, 因为整数除法会截断小数,分配小了文件就读不全10。
③ ExitBootServices 可能失败一次。 它要求「最新内存地图的 MapKey」; 而第一次取地图之后你又调了引导服务(打开文件、分配内存都会改地图),key 就过期了。 所以书里是循环:失败 → 重新取地图拿新 key → 再试;第二次基本成功,再失败就死循环停机11。
④ hlt:死循环里也要讲公德。 __asm__("hlt") 是内联汇编,直接嵌入一条机器指令。
hlt 让 CPU 进入低功耗停止状态(有中断才醒);不放 hlt 的空转循环会让 CPU 占用率 100%,
费电发热12。省电这个主题,第 14、16 章还会升级。
④.5 读内核文件的两个伏笔。 引导加载器读 kernel.elf 时有两处细节,后文都会回收:
一是先调 GetInfo() 问文件大小——传进去的缓冲区要故意多给 sizeof(CHAR16)*12 字节,
因为 EFI_FILE_INFO 结构体末尾的文件名是「长度不定的数组」,sizeof 算不出真实大小,
多留的余量就是给文件名的13;二是 AllocatePages() 选 AllocateAddress 模式直接钉在
0x100000,「如果将其放置在该位置以外的其他位置,那么内核文件将无法正常运行」——
链接时 --image-base 0x100000 把绝对地址写进了机器语言,加载地址必须和它一致14。
⑤ 红区(Red Zone)是编译器埋的雷。 System V ABI 允许函数用 RSP 之下 128 字节当临时区,
省一条调整 RSP 的指令、略快;但中断处理程序可能踩掉它——OS 开发里中断随时会来,
所以编译内核必须 -mno-red-zone 禁掉15。
3.4 帧缓冲区:屏幕就是一块内存
画像素需要向 UEFI 要四样信息(GOP,Graphics Output Protocol):帧缓冲区首地址、 显示区域宽高、帧缓冲区整行宽度(右侧可能有不显示的填充)、单像素的数据格式16。
帧缓冲区(frame buffer)是放置像素值的内存区域:往里写数,显示器上对应的点就变色。
第一个程序懒得管像素格式——「向每个字节写 255(0xff,8 个位全是 1),不管什么格式都是白」,
于是白屏17。下一步把「写数」从引导加载器挪进内核:
引导加载器把帧缓冲区首地址和大小作为 KernelMain() 的两个整数参数传过去,
内核里用 reinterpret_cast 把这个整数「贴回」指针类型,按像素位置算出颜色,
画出随位置变化的图案——同一个文件,换个机器颜色可能不同,因为像素格式被忽略了18。
这里有个新工具:<cstdint> 里的 uint8_t、uint64_t 这类固定位数整数类型——
C++ 标准不保证 short/int 到底几位,而帧缓冲区地址是 64 位值,必须用 64 位变量接,否则出错19。
3.5 错误处理与指针转换的规矩
引导加载器此前的代码对错误一律无视;本章补课。错误处理是操作系统的生命线:
内存不足、磁盘写失败时不处理,程序就崩;更糟的是未定义行为(空指针访问、缓冲区溢出)
会变成漏洞20。UEFI 的惯例:函数返回 EFI_STATUS,失败就 Print 出来(%r 格式能把状态码翻成可读信息),
然后调用 Halt()——一个 hlt 死循环。为什么不直接返回?因为 UEFI 会放弃这个加载器继续引导,
屏幕一刷新错误信息就没了21。
专栏还立了两条指针使用规矩:
- 「留一块 UINT8 数组,再转换成你要的结构体指针」是 OS 开发的常见手法——同一块内存, 解读成什么类型全看指针,转换本身只是贴标签22;
- 但要注意对齐(alignment):大于 1 字节的类型要求放在 N 字节倍数的地址上,
否则读写可能出错甚至触发 CPU 异常;用
alignas(alignof(T))声明数组可以强制满足23。
3.6 指针的汇编视角(3.7 节)
把 int i = 42; int* p = &i; int r1 = *p; 编译出来看,指针的神秘感就没了:
mov dword ptr [rbp-4], 42 ; 把 42 写进「rbp-4 开始的 4 字节」——这就是变量 i
lea rax, [rbp-4] ; lea = 只算地址不访问内存:rax = i 的地址
mov qword ptr [rbp-16], rax ; 把地址写进「rbp-16 开始的 8 字节」——这就是变量 p
mov rax, qword ptr [rbp-16] ; 读出 p 的值(一个地址)
mov ecx, dword ptr [rax] ; 顺着这个地址再读 4 字节——这就是 *p
mov dword ptr [rbp-20], ecx ; 写进 r1
图说:处理指针变量和处理普通变量没有任何区别,都是 mov + [地址];
唯一差别是访问内存一次还是两次。关键记号:[xxx] 表示「访问地址 xxx 处的内存」,dword=4 字节、qword=8 字节;
局部变量就是栈里 RBP 之下的一个个格子24。这一节的价值:以后在内核里踩到指针 bug,
你脑子里有它编译后的真实样子。
4. 作者的判断与证据
判断:引导加载器与内核必须分开、异构。 「开发引导加载器和操作系统本身(内核)的方法有很多种, 但在本书中,我们把引导加载器开发成 UEFI 应用,把内核开发成 ELF 二进制文件」——分开的理由是自由: 内核不必背着 UEFI 的限制25。证据就是交接仪式本身能跑通:启动后死循环停住、最后的 Print 不再执行, 再用 QEMU 监视器交叉验证 RIP 与 hlt26。
判断:错误处理用「中断处理」策略,不做恢复。 作者承认恢复更难,引导加载器的选择是: 出错就打印、停机。对引导器这个「一次性」程序,这是合理取舍27。
一处诚实的坦白: 画图案「没有考虑像素的数据格式,因此图案和颜色可能会因机型的不同而不同」28—— 明知留了坑,先跑起来,第 4 章再正经处理格式。
5. 边界与局限
- ExitBootServices 之后的代码不能再用任何引导服务——这是单向门,所以帧缓冲区等情报 必须先拷出来(本章是传参,第 4 章起改成传结构体,第 8 章内存管理接手)。
- 对齐、红区这类坑是 x86-64 + System V ABI 特有的;换架构(如 ARM),规矩要重学。
- QEMU 监视器的技巧只在模拟器里可用;真机调试要靠串口打印等别的手段(本书没展开)。
- 3.4 的白屏是「作弊」:全写 255 只是碰巧全格式通用,正经的颜色表示等到第 4 章。
6. 可带走的
本章主线例子,一行写完: 引导加载器读 kernel.elf 进 0x100000 → ExitBootServices(失败就换新 MapKey 重试)→ 从 ELF 头 +24 字节处读入口点地址 → 强转成函数指针调用 → 内核 hlt 死循环。
- CPU 只执行内存里的程序;ExitBootServices 是引导加载器生涯的最后一场戏,过了这扇门 UEFI 功能全废;
- MapKey 把「内存地图的版本」这件事显性化了:取了地图之后再用引导服务,key 就过期;
- 屏幕 = 帧缓冲区:写内存即改像素;GOP 给你首地址、分辨率、行宽、像素格式四件事;
- 调试裸机程序:QEMU 监视器的 info registers + x /Ni,真机做不到;
- 寄存器是 CPU 内部的高速小存储;RIP 指下一条指令,CR0 的位直接是硬件开关(PE=保护模式,PG=分页);
- 空转必须
hlt;编译内核必须-mno-red-zone(中断会踩红区); - 指针 = 地址 + 类型:整数与函数指针之间,靠 typedef 函数类型来安全转换;
- OS 里处理错误的基本姿势:查状态码 → 打印(
%r)→ Halt 死循环,别让 UEFI 替你清屏; - 「UINT8 数组 + 强制转换」好用,但记得 alignas 对齐。
7. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| QEMU 监视器与低层调试的怪事 | 3.1 QEMU监视器 | text/28-ch03-01-3-1-qemu.txt:12(搜「调试代码的影响」) · text/28-ch03-01-3-1-qemu.txt:27(搜「QEMU监视器是QEMU的标准功能」) |
| info registers、x 转储、jmp 死循环 | 3.1 QEMU监视器 | text/28-ch03-01-3-1-qemu.txt:42(搜「info registers」) · text/28-ch03-01-3-1-qemu.txt:69(搜「x /fmt addr」) · text/28-ch03-01-3-1-qemu.txt:86(搜「绕来绕去」) |
| 寄存器容量/速度对照、16 个通用寄存器 | 3.2 寄存器 | text/29-ch03-02-3-2.txt:9(搜「16GB」) · text/29-ch03-02-3-2.txt:13(搜「R8~R15」) |
| RIP/RFLAGS/CR0 | 3.2 寄存器 | text/29-ch03-02-3-2.txt:33(搜「RIP」) · text/30-ch03-03-3-3-osbook-day03a.txt:3(搜「保护模式」) |
| 内核 = ELF,引导加载器调用它 | 3.3 第一个内核(osbook_day03a) | text/30-ch03-03-3-3-osbook-day03a.txt:11(搜「把内核开发成ELF」) |
| freestanding 选项组 | 3.3 第一个内核(osbook_day03a) | text/30-ch03-03-3-3-osbook-day03a.txt:46(搜「ffreestanding」) · text/30-ch03-03-3-3-osbook-day03a.txt:70(搜「托管环境」) |
| hlt 与内联汇编 | 3.3 第一个内核(osbook_day03a) | text/30-ch03-03-3-3-osbook-day03a.txt:28(搜「内联汇编」) · text/30-ch03-03-3-3-osbook-day03a.txt:37(搜「低功耗状态」) |
| AllocatePages、向上取整公式 | 3.3 第一个内核(osbook_day03a) | text/30-ch03-03-3-3-osbook-day03a.txt:133(搜「AllocatePages」) · text/30-ch03-03-3-3-osbook-day03a.txt:153(搜「0xfff」) |
| ExitBootServices 与 MapKey 重试 | 3.3 第一个内核(osbook_day03a) | text/30-ch03-03-3-3-osbook-day03a.txt:175(搜「ExitBootServices」) · text/30-ch03-03-3-3-osbook-day03a.txt:183(搜「使用与之关联的映射键」) |
| ELF 头入口点、EntryPointType | 3.3 第一个内核(osbook_day03a) | text/30-ch03-03-3-3-osbook-day03a.txt:197(搜「readelf」) · text/30-ch03-03-3-3-osbook-day03a.txt:212(搜「偏移24字节」) · text/30-ch03-03-3-3-osbook-day03a.txt:245(搜「EntryPointType」) |
| 用 QEMU 验证内核在死循环 | 3.3 第一个内核(osbook_day03a) | text/30-ch03-03-3-3-osbook-day03a.txt:297(搜「info registers命令检查RIP」) |
| 红区 | 3.4 从引导加载器绘制像素(osbook_day03b) | text/31-ch03-04-3-4-osbook-day03b.txt:6(搜「RSP之前128字节」) · text/31-ch03-04-3-4-osbook-day03b.txt:30(搜「禁用红区」) |
| GOP 四信息与帧缓冲区定义 | 3.4 从引导加载器绘制像素(osbook_day03b) | text/31-ch03-04-3-4-osbook-day03b.txt:37(搜「GOP」) · text/31-ch03-04-3-4-osbook-day03b.txt:44(搜「帧缓冲区」) |
| 全写 255 即白 | 3.4 从引导加载器绘制像素(osbook_day03b) | text/31-ch03-04-3-4-osbook-day03b.txt:73(搜「255」) · text/32-ch03-05-3-5-osbook-day03c.txt:3(搜「すべてのビッ」——日文残页) |
| 参数传帧缓冲区、按位置画图案 | 3.5 从内核绘制像素(osbook_day03c) | text/32-ch03-05-3-5-osbook-day03c.txt:18(搜「从引导加载器传递到内核」) · text/32-ch03-05-3-5-osbook-day03c.txt:32(搜「根据像素的位置改变颜色」) |
| uintX_t 与 buildenv.sh | 3.5 从内核绘制像素(osbook_day03c) | text/32-ch03-05-3-5-osbook-day03c.txt:33(搜「cstdint」) |
| 错误处理的必要性、%r、Halt | 3.6 错误处理(osbook_day03d) | text/33-ch03-06-3-6-osbook-day03d.txt:16(搜「检测并处理」) · text/33-ch03-06-3-6-osbook-day03d.txt:31(搜「%r」) · text/33-ch03-06-3-6-osbook-day03d.txt:36(搜「死循环」) |
| UINT8 数组转换与对齐 | 3.6 错误处理(osbook_day03d) | text/33-ch03-06-3-6-osbook-day03d.txt:96(搜「常见的方法」) · text/33-ch03-06-3-6-osbook-day03d.txt:97(搜「对齐」) |
| 汇编里的指针:mov、[]、lea | 3.7 指针简介(2):指针与汇编语言 | text/34-ch03-07-3-7-2.txt:42(搜「视为对地址」) · text/34-ch03-07-3-7-2.txt:52(搜「lea」) · text/34-ch03-07-3-7-2.txt:62(搜「读取内存操作」) |
Footnotes
-
出处:「3.3 第一个内核」(
text/30-ch03-03-3-3-osbook-day03a.txt:235,搜「仅凭函数定义在内存中的位置信息」;:244,搜「EntryPointType」)。 ↩ -
出处:「3.1 QEMU监视器」(
text/28-ch03-01-3-1-qemu.txt:12,搜「受调试代码的影响」;:23,搜「怪事」)。 ↩ -
出处:「3.1 QEMU监视器」(
text/28-ch03-01-3-1-qemu.txt:27,搜「QEMU监视器是QEMU的标准功能」;:42,搜「info registers」;:69,搜「x /fmt addr」)。 ↩ -
出处:「3.3 第一个内核」(
text/30-ch03-03-3-3-osbook-day03a.txt:302,搜「值没有变化」;:302,搜「hlt」)。 ↩ -
出处:「3.1 QEMU监视器」(
text/28-ch03-01-3-1-qemu.txt:86,搜「绕来绕去」)。原文:「这条汇编指令是while (1);的编译结果」。 ↩ -
出处:「3.2 寄存器」(
text/29-ch03-02-3-2.txt:9,搜「16GB」;:10,搜「0.5ns」)。 ↩ -
出处:「3.2 寄存器」(
text/29-ch03-02-3-2.txt:13,搜「R8~R15」;:15,搜「add rax, rbx」;:18,搜「左边的操作数被写入」)。 ↩ -
出处:「3.3 第一个内核」(
text/30-ch03-03-3-3-osbook-day03a.txt:3,搜「保护模式」)与「3.2 寄存器」(text/29-ch03-02-3-2.txt:33,搜「RIP」)。 ↩ -
出处:「3.3 第一个内核」(
text/30-ch03-03-3-3-osbook-day03a.txt:46,搜「target=x86_64-elf」;:70,搜「托管环境」)。 ↩ -
出处:「3.3 第一个内核」(
text/30-ch03-03-3-3-osbook-day03a.txt:153,搜「0xfff」;:157,搜「截断小数」)。 ↩ -
出处:「3.3 第一个内核」(
text/30-ch03-03-3-3-osbook-day03a.txt:175,搜「ExitBootServices」;:183,搜「使用与之关联的映射键」);:185,搜「严重的错误」)。 ↩ -
出处:「3.3 第一个内核」(
text/30-ch03-03-3-3-osbook-day03a.txt:37,搜「低功耗状态」;:37,搜「100%」)。 ↩ -
出处:「3.3 第一个内核」(
text/30-ch03-03-3-3-osbook-day03a.txt:119,搜「没有写数组元素的个数」;:128,搜「任意数量的字符」;:130,搜「12个字符」)。 ↩ -
出处:「3.3 第一个内核」(
text/30-ch03-03-3-3-osbook-day03a.txt:138,搜「AllocateAddress」;:140,搜「无法正常运」)。 ↩ -
出处:「3.4 从引导加载器绘制像素」专栏3.1(
text/31-ch03-04-3-4-osbook-day03b.txt:6,搜「128字节」;:29,搜「数据将被破坏」)。 ↩ -
出处:「3.4 从引导加载器绘制像素」(
text/31-ch03-04-3-4-osbook-day03b.txt:37,搜「GOP」;:40–:56列出四项信息)。 ↩ -
出处:「3.4 从引导加载器绘制像素」(
text/31-ch03-04-3-4-osbook-day03b.txt:44,搜「帧缓冲区」;:72,搜「255」)。 ↩ -
出处:「3.5 从内核绘制像素」(
text/32-ch03-05-3-5-osbook-day03c.txt:18,搜「从引导加载器传递到内核」;:27,搜「reinterpret_cast」;text/33-ch03-06-3-6-osbook-day03d.txt:9,搜「因机型的不同而不同」)。 ↩ -
出处:「3.5 从内核绘制像素」(
text/32-ch03-05-3-5-osbook-day03c.txt:33,搜「cstdint」;:35,搜「64位」)。 ↩ -
出处:「3.6 错误处理」(
text/33-ch03-06-3-6-osbook-day03d.txt:16,搜「检测并处理」;:20,搜「漏洞」)。 ↩ -
出处:「3.6 错误处理」(
text/33-ch03-06-3-6-osbook-day03d.txt:31,搜「%r」;:36,搜「Halt」;:41,搜「错误信息消失」)。 ↩ -
出处:「3.6 错误处理」专栏3.2(
text/33-ch03-06-3-6-osbook-day03d.txt:96,搜「常见的方法」)。 ↩ -
出处:「3.6 错误处理」专栏3.2(
text/33-ch03-06-3-6-osbook-day03d.txt:97,搜「对齐」;text/34-ch03-07-3-7-2.txt:4,搜「alignas」)。 ↩ -
出处:「3.7 指针简介(2)」(
text/34-ch03-07-3-7-2.txt:42,搜「视为对地址」;:53,搜「lea」;:64,搜「并没有什么区别」)。 ↩ -
出处:「3.3 第一个内核」(
text/30-ch03-03-3-3-osbook-day03a.txt:11,搜「把内核开发成ELF」)。 ↩ -
出处:「3.3 第一个内核」(
text/30-ch03-03-3-3-osbook-day03a.txt:283,搜「All done」;:297,搜「info registers命令检查RIP」)。 ↩ -
出处:「3.6 错误处理」(
text/33-ch03-06-3-6-osbook-day03d.txt:27,搜「中断处理」——原书脚注:「引导加载器的错误处理一直采用中断处理的策略」)。 ↩ -
出处:「3.5 从内核绘制像素」(
text/33-ch03-06-3-6-osbook-day03d.txt:9,搜「机型的不同而不同」)。 ↩