编程语言研究书库

第一个内核与第一个像素 — 把 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。

专栏还立了两条指针使用规矩:

  1. 「留一块 UINT8 数组,再转换成你要的结构体指针」是 OS 开发的常见手法——同一块内存, 解读成什么类型全看指针,转换本身只是贴标签22;
  2. 但要注意对齐(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 死循环。

  1. CPU 只执行内存里的程序;ExitBootServices 是引导加载器生涯的最后一场戏,过了这扇门 UEFI 功能全废;
  2. MapKey 把「内存地图的版本」这件事显性化了:取了地图之后再用引导服务,key 就过期;
  3. 屏幕 = 帧缓冲区:写内存即改像素;GOP 给你首地址、分辨率、行宽、像素格式四件事;
  4. 调试裸机程序:QEMU 监视器的 info registers + x /Ni,真机做不到;
  5. 寄存器是 CPU 内部的高速小存储;RIP 指下一条指令,CR0 的位直接是硬件开关(PE=保护模式,PG=分页);
  6. 空转必须 hlt;编译内核必须 -mno-red-zone(中断会踩红区);
  7. 指针 = 地址 + 类型:整数与函数指针之间,靠 typedef 函数类型来安全转换;
  8. OS 里处理错误的基本姿势:查状态码 → 打印(%r)→ Halt 死循环,别让 UEFI 替你清屏;
  9. 「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/CR03.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 头入口点、EntryPointType3.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.sh3.5 从内核绘制像素(osbook_day03c)text/32-ch03-05-3-5-osbook-day03c.txt:33(搜「cstdint」)
错误处理的必要性、%r、Halt3.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、[]、lea3.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

  1. 出处:「3.3 第一个内核」(text/30-ch03-03-3-3-osbook-day03a.txt:235,搜「仅凭函数定义在内存中的位置信息」;:244,搜「EntryPointType」)。 ↩

  2. 出处:「3.1 QEMU监视器」(text/28-ch03-01-3-1-qemu.txt:12,搜「受调试代码的影响」;:23,搜「怪事」)。 ↩

  3. 出处:「3.1 QEMU监视器」(text/28-ch03-01-3-1-qemu.txt:27,搜「QEMU监视器是QEMU的标准功能」;:42,搜「info registers」;:69,搜「x /fmt addr」)。 ↩

  4. 出处:「3.3 第一个内核」(text/30-ch03-03-3-3-osbook-day03a.txt:302,搜「值没有变化」;:302,搜「hlt」)。 ↩

  5. 出处:「3.1 QEMU监视器」(text/28-ch03-01-3-1-qemu.txt:86,搜「绕来绕去」)。原文:「这条汇编指令是while (1);的编译结果」。 ↩

  6. 出处:「3.2 寄存器」(text/29-ch03-02-3-2.txt:9,搜「16GB」;:10,搜「0.5ns」)。 ↩

  7. 出处:「3.2 寄存器」(text/29-ch03-02-3-2.txt:13,搜「R8~R15」;:15,搜「add rax, rbx」;:18,搜「左边的操作数被写入」)。 ↩

  8. 出处:「3.3 第一个内核」(text/30-ch03-03-3-3-osbook-day03a.txt:3,搜「保护模式」)与「3.2 寄存器」(text/29-ch03-02-3-2.txt:33,搜「RIP」)。 ↩

  9. 出处:「3.3 第一个内核」(text/30-ch03-03-3-3-osbook-day03a.txt:46,搜「target=x86_64-elf」;:70,搜「托管环境」)。 ↩

  10. 出处:「3.3 第一个内核」(text/30-ch03-03-3-3-osbook-day03a.txt:153,搜「0xfff」;:157,搜「截断小数」)。 ↩

  11. 出处:「3.3 第一个内核」(text/30-ch03-03-3-3-osbook-day03a.txt:175,搜「ExitBootServices」;:183,搜「使用与之关联的映射键」);:185,搜「严重的错误」)。 ↩

  12. 出处:「3.3 第一个内核」(text/30-ch03-03-3-3-osbook-day03a.txt:37,搜「低功耗状态」;:37,搜「100%」)。 ↩

  13. 出处:「3.3 第一个内核」(text/30-ch03-03-3-3-osbook-day03a.txt:119,搜「没有写数组元素的个数」;:128,搜「任意数量的字符」;:130,搜「12个字符」)。 ↩

  14. 出处:「3.3 第一个内核」(text/30-ch03-03-3-3-osbook-day03a.txt:138,搜「AllocateAddress」;:140,搜「无法正常运」)。 ↩

  15. 出处:「3.4 从引导加载器绘制像素」专栏3.1(text/31-ch03-04-3-4-osbook-day03b.txt:6,搜「128字节」;:29,搜「数据将被破坏」)。 ↩

  16. 出处:「3.4 从引导加载器绘制像素」(text/31-ch03-04-3-4-osbook-day03b.txt:37,搜「GOP」;:40–:56 列出四项信息)。 ↩

  17. 出处:「3.4 从引导加载器绘制像素」(text/31-ch03-04-3-4-osbook-day03b.txt:44,搜「帧缓冲区」;:72,搜「255」)。 ↩

  18. 出处:「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,搜「因机型的不同而不同」)。 ↩

  19. 出处:「3.5 从内核绘制像素」(text/32-ch03-05-3-5-osbook-day03c.txt:33,搜「cstdint」;:35,搜「64位」)。 ↩

  20. 出处:「3.6 错误处理」(text/33-ch03-06-3-6-osbook-day03d.txt:16,搜「检测并处理」;:20,搜「漏洞」)。 ↩

  21. 出处:「3.6 错误处理」(text/33-ch03-06-3-6-osbook-day03d.txt:31,搜「%r」;:36,搜「Halt」;:41,搜「错误信息消失」)。 ↩

  22. 出处:「3.6 错误处理」专栏3.2(text/33-ch03-06-3-6-osbook-day03d.txt:96,搜「常见的方法」)。 ↩

  23. 出处:「3.6 错误处理」专栏3.2(text/33-ch03-06-3-6-osbook-day03d.txt:97,搜「对齐」;text/34-ch03-07-3-7-2.txt:4,搜「alignas」)。 ↩

  24. 出处:「3.7 指针简介(2)」(text/34-ch03-07-3-7-2.txt:42,搜「视为对地址」;:53,搜「lea」;:64,搜「并没有什么区别」)。 ↩

  25. 出处:「3.3 第一个内核」(text/30-ch03-03-3-3-osbook-day03a.txt:11,搜「把内核开发成ELF」)。 ↩

  26. 出处:「3.3 第一个内核」(text/30-ch03-03-3-3-osbook-day03a.txt:283,搜「All done」;:297,搜「info registers命令检查RIP」)。 ↩

  27. 出处:「3.6 错误处理」(text/33-ch03-06-3-6-osbook-day03d.txt:27,搜「中断处理」——原书脚注:「引导加载器的错误处理一直采用中断处理的策略」)。 ↩

  28. 出处:「3.5 从内核绘制像素」(text/33-ch03-06-3-6-osbook-day03d.txt:9,搜「机型的不同而不同」)。 ↩

On this page