跳转至

内存地图与指针 — 向 UEFI 问清这块地

这一章讲什么: UEFI 走后,内核要自己管内存;管内存的第一步是知道「哪块能用」。 本章先换一个正经的开发工具包(EDK II)重写 Hello World——它从此有了名字 MikanLoader, 使命是把将来的内核搬进内存;然后调用 UEFI 的 GetMemoryMap() 拿到内存地图,存成文件。 为了读懂这些代码,作者插播了两节指针课——这是全书反复要用到的武器。 这是全书第一个「为操作系统本身做的功能」,之前的程序都只是演示。

1. 先看现象:一个文件里的内存账本

主线例子:让 MikanLoader 在测试机(或 QEMU)上跑一圈,它会在 U 盘里留下一个 memmap 文件,长这样:

  index, Type, PhysicalStart, NumberOfPages, Attribute
  1, EfiLoaderCode      , 0x00000000, 0x1  , 0xF
  2, EfiConvensionalMemory, 0x00001000, 0x9F, 0xF
  3, EfiConvensionalMemory, 0x00100000, 0x700, 0xF
  …

  图说:每一行是一块连续的内存区域——从哪个地址开始(PhysicalStart)、
  是什么用途(Type)、有多大(NumberOfPages,以 4KiB 页为单位)。
  这个文件就是「内存地图」,CSV 格式,逗号分隔。

这张表是干嘛的?告诉未来的内核:这块地里哪块是空地(EfiConvensionalMemory), 哪块住着 UEFI 自己的代码、CPU 的配置信息、ACPI 表——碰都不能碰。1 书里警告得很直接:覆盖了含 CPU 配置信息的区域,「可能会导致 CPU 故障」2。

2. 顶层全景:两个任务串成一条线

  任务一(2.2 节):换上 EDK II 工具包,重写 Hello World
      hello.c(第1章,什么都自己写)
        → MikanLoaderPkg(四个文件,大部分苦力交给 EDK II 库)
  任务二(2.5–2.7 节):向 UEFI 要内存地图
      gBS->GetMemoryMap() 填一张表 → 打开 \memmap 文件 → 逐行 Write() → 存进 U 盘
  暗线(2.8–2.9 节):指针两节课
      指针 = 变量的编号 + 类型 → 箭头运算符 → 指针的指针(多返回值的手法)

  图说:本章结束时,引导加载器已经能「侦察地形并把情报带回来」——
  这正是第 3 章把它升级成真正搬运工的前置条件。

3. 核心原理

3.1 EDK II:不用从零造轮子的 UEFI 开发包

EDK II 最初是 Intel 对 UEFI 的实现,后来开源,现在是开发 UEFI 应用(以及 UEFI BIOS 本身)的标准工具包3。 第 1 章的 hello.c 里那些又臭又长的代码,在 EDK II 里变成两行 #include—— EFI_STATUS、EFI_SYSTEM_TABLE、Print() 这些非标准 C 的东西都是库里现成的4。

它的世界由「包(Pkg)」组成,本章涉及的:

文件/包 角色
edksetup.sh 环境变量设置脚本(环境变量 = 进程级的配置项,如 PATH),编译前必须先 source(读入)它
Conf/target.txt 设置编译什么(填 MikanLoaderPkg/MikanLoaderPkg.dsc)、用什么工具链
MdePkg 基础库,其他所有包都建在它的组件上
OvmfPkg OVMF——开源的 UEFI BIOS 实现,第 01 章 QEMU 用的固件就是它

写 UEFI 应用必须配齐三个描述文件:包声明(.dec)、包描述(.dsc)、组件定义(.inf)。 琐碎得像抄模板,附录 A 有全文,正文只讲一个关键点:.inf 里的 ENTRY_POINT 是入口点 ——普通 C 程序的入口固定叫 main(),EDK II 里你可以随便起名字5。

新程序起名 MikanLoader:mikan 是作者的名字(MikanOS 的由来), Loader 点明它的使命——把操作系统从 U 盘加载进主存储器。此刻它还只是个 Hello World, 但名字先把志向立住了6。

3.2 主存储器与地址:一条没有空隙的字节长队

硬件上,主存储器是多块内存芯片拼的;软件上看,它就是一排首尾相接、没有空隙的字节, 每个字节有一个从 0 开始的序列号——这个序列号叫地址,CPU 按地址逐个读写字节7。

记住两件事:

  • 地址的单位是字节;32 位 CPU 的地址范围大约从 0 到 4 亿(4 GiB 量级);
  • UEFI 内存地图里,地址用字节表示、大小却用「页」表示(一页 = 4 KiB),两个单位混用,书里也抱怨「有点儿混乱」8。

3.3 内存地图:一张一维的城市地图

内存映射(memory map)是「主内存的哪个部分用于何种用途」的地图。 作者拿它类比美国式地址:全城只有一条路,门牌号沿道路连续编排——找到路,顺着号走就到9。

表的结构(EFI_MEMORY_DESCRIPTOR,每行 5 个字段)10:

字段 含义
Type 这块地的用途(数字编号)
PhysicalStart 起始物理地址(字节)
VirtualStart 虚拟地址(本章不用,第 8、19 章才登场)
NumberOfPages 大小,以 4 KiB 页计
Attribute 这块地的属性标志

Type 值里最重要的一个:7 = EfiConvensionalMemory = 空闲区域——内核将来就从这里划地11。

两个工程细节,后文会回来讨债:

  1. 地图里可能有孔洞:上一行的 PhysicalStart + NumberOfPages×4KiB 不一定等于下一行的起始地址,遍历代码不能假设连续12;
  2. 地图是会变的:每次获取有个 MapKey 标识版本,程序动了内存(哪怕调了 UEFI 函数),地图就可能刷新——这个 MapKey 到第 3 章调用 ExitBootServices() 时是必需品13。

3.4 向 UEFI 要地图:GetMemoryMap()

UEFI 的功能分两类:引导服务(操作系统启动前用,内核接管后作废)和运行时服务(启动前后都能用)。 内存管理在引导服务里,EDK II 用全局变量 gBS 代表它14。

调用 gBS->GetMemoryMap() 的姿势很能代表 UEFI 风格——一次调用,五个参数,其中三个是输出:

参数 输入还是输出 干什么
MemoryMapSize 进出(IN OUT) 进去时说「我准备的缓冲区有多大」,出来时变成「地图实际需要多大」
MemoryMap 出(OUT) 缓冲区指针,地图填进去
MapKey 出 地图的版本号
DescriptorSize 出 每行描述符占多少字节
DescriptorVersion 出 版本号(本书不用)

缓冲区不够时函数返回 EFI_BUFFER_TOO_SMALL——这是 UEFI 的典型套路:先问要多大,再问一次要数据15。

还有个反直觉的细节:遍历地图时,行间距要用函数告诉你的 DescriptorSize,而不是 sizeof(EFI_MEMORY_DESCRIPTOR)—— 因为某些 UEFI 实现会塞私有扩展,行宽可能不一样16。

3.5 把地图存盘:文件操作与指针的第一次实战

存盘三步:OpenRootDir() 打开 U 盘根目录 → root_dir->Open() 以写模式打开(不存在就创建)\memmap 文件 → SaveMemoryMap() 循环遍历地图,把每行格式化成字符串(一串字符),用 file->Write() 写进去17。

格式化用的是 EDK II 的 AsciiSPrint()(类似标准 C 的 sprintf,把数字按格式变成字符串), 类型编号到类型名(EfiLoaderCode 这种)靠程序里自己写的一张对照函数 GetMemoryTypeUnicode()18。

这里出现全书第一个「看不懂也不用怕」的标记:指针的指针(&root_dir、打开文件时传 EFI_FILE_PROTOCOL**)。 作者安慰你说 2.8 节马上讲19——它确实讲了,而且讲得很好,见下两节。

3.6 指针课(1):指针是「变量的编号 + 类型」

C++ 里指针(pointer)的意思就是「指向变量的东西」。三行入门:

int i = 42;
int* p = &i;   // & 取地址:p 里存着 i 的「编号」
int r = *p;    // * 间接运算符:顺着编号找到 i,读出 42
*p = 1;        // 也可以顺着编号改 i 的值

关键在实现层面:C++ 标准没规定这个「编号」具体是什么,但指针和整数可以互相转换, 转换回指针必须能区分不同变量。实际上(Clang 也是如此)这个编号就是内存地址—— 变量在内存里按字节编了号,指针存的就是那个号20。

uintptr_t 是「装得下任何指针的整数类型」;指针→整数是剥掉类型信息,整数→指针是贴回类型信息21。 这一来一回,就是 2.6 节遍历地图时那句「把整数 iter 转成 EFI_MEMORY_DESCRIPTOR*」的真相22。

3.7 指针课(2):箭头运算符与指针的指针

结构体的成员按声明顺序排在内存里,成员距结构体开头的字节数叫偏移量(offset): 比如 buffer_size 偏移 0 字节,buffer 偏移 8 字节(64 位环境里指针占 8 字节)23。

指向结构体的指针,读写成员用箭头 ->:

pm->map_size = 0;
(*pm).map_size = 0;   // 完全等价,只是更绕

这两种写法处理方式完全相同,-> 只是「语法糖」——意义不变,写得省力24。

指针的指针(如 EFI_FILE_PROTOCOL**)看着吓人,其实一句话就能拆穿: 指针变量也是变量、也住在内存里、也有地址;给指针变量的地址再包一层指针,就是指针的指针。 UEFI 里到处用它,是因为 UEFI 函数的返回值 EFI_STATUS 被用来报「成没成功」, 想再返回一个指针(比如打开的文件)只能走参数——把「指针变量的地址」传给函数,函数改写那个指针变量。 这就是「一个函数返回多个值」在 C 里的标准手法25。

4. 作者的判断与证据

判断:用 EDK II 重写得值。 hello.c 里大半内容是手工模仿 UEFI 接口;换 EDK II 后同样的程序 「一眼就能看出更简洁」——库替你写的部分,正是最容易写错的部分26。

判断:内核接管前必须先摸清内存。 这不是理论洁癖,是硬约束:用错内存会踩坏 CPU 配置、UEFI 数据, 「如果要使用内存,就必须找到空闲空间」27。所以全书把「获取内存地图」排在引导加载器的第一个正式功能。

证据类型: 本章证据是「跑出来的文件」——memmap 文件真实存在于你的 U 盘里, 可以 cat 出来、可以拿电子表格软件画成图28。这种「让机器交一份实物作业」的验证方式,全书一以贯之。

5. 边界与局限

  • GetMemoryMap() 是引导服务,内核接管后它就没了。 地图必须在「还信 UEFI 的时候」拿够、存好、传下去——第 3 章会看到这一步的正式仪式(ExitBootServices)。
  • 缓冲区大小的经验值(8 KiB 不够、16 KiB 凑合)只对作者的环境成立;你自己的机器地图更大,就得开更大缓冲区29。
  • 指针两节讲得清楚,但只是「够用的最小集」:数组指针算术、const 指针、悬垂指针这些一概没讲,遇到再查 C 教材。

6. 可带走的

本章主线例子,一行写完: MikanLoader 跑起来 → gBS->GetMemoryMap() 填表 → 打开 \memmap → 逐行写 CSV → U 盘里多一张内存地图。

  1. 内存地图回答「哪块能用」:Type=7(EfiConvensionalMemory)是空地;地图可能有孔洞、会变(MapKey 记版本);
  2. UEFI 的调用风格:缓冲区不够就返回 TOO_SMALL,问两次;行间距用函数给的 DescriptorSize,别用 sizeof;
  3. EDK II 三件套(dec/dsc/inf)是写 UEFI 应用的门票,ENTRY_POINT 自由命名;
  4. 指针 = 变量编号(内存地址) + 类型;& 取编号,* 顺编号找人;
  5. 指向结构体的指针用 -> 访问成员,它只是 (*pm). 的语法糖;
  6. 指针的指针 = 多返回值手法:返回值被状态码占了,额外的结果靠「指针变量的地址」带回来;
  7. EDK II 里 gBS 是引导服务的门,gRT 是运行时服务的门(本书用不到后者)。

7. 原文地图

主题 原书章 原文位置
EDK II 是什么、包结构 2.1 EDK II简介 text/19-ch02-01-2-1-edk-ii.txt:4(搜「最初是Intel」) · text/19-ch02-01-2-1-edk-ii.txt:25(搜「MdePkg」)
MikanLoader 命名与三件套 2.2 EDK II的Hello World程序(osbook_day02 text/20-ch02-02-2-2-edk-ii-hello-world-osbook-day02.txt:4(搜「MikanLoader」) · text/20-ch02-02-2-2-edk-ii-hello-world-osbook-day02.txt:16(搜「必须创建的文件」)
入口点 ENTRY_POINT 2.2 EDK II的Hello World程序(osbook_day02 text/20-ch02-02-2-2-edk-ii-hello-world-osbook-day02.txt:22(搜「ENTRY_POINT」)
主存储器 = 一排字节,地址 2.3 主存储器 text/21-ch02-03-2-3.txt:18(搜「一条直线上」) · text/22-ch02-04-2-4.txt:5(搜「序列号被称为地址」)
内存地图与 Type 表 2.4 内存映射 text/22-ch02-04-2-4.txt:16(搜「内存映射是一种显示」) · text/22-ch02-04-2-4.txt:47(搜「Type值」)
页与单位混用、孔洞 2.4 内存映射 text/22-ch02-04-2-4.txt:55(搜「NumberOfPages」) · text/22-ch02-04-2-4.txt:58(搜「孔洞」)
引导服务/运行时服务、gBS 2.5 获取内存映射(osbook_day02b) text/23-ch02-05-2-5-osbook-day02b.txt:12(搜「引导服务」)
GetMemoryMap 五参数 2.5 获取内存映射(osbook_day02b) text/23-ch02-05-2-5-osbook-day02b.txt:33(搜「EFI_BUFFER_TOO_SMALL」) · text/23-ch02-05-2-5-osbook-day02b.txt:39(搜「MapKey」) · text/23-ch02-05-2-5-osbook-day02b.txt:42(搜「DescriptorSize」)
EFI_MEMORY_DESCRIPTOR 五字段 2.5 获取内存映射(osbook_day02b) text/24-ch02-06-2-6.txt:1(搜「结构体的定义」)
存盘三步与指针的指针预告 2.6 将内存映射保存到文件中 text/24-ch02-06-2-6.txt:34(搜「OpenRootDir」) · text/24-ch02-06-2-6.txt:42(搜「16KiB」)
迭代器与类型转换 2.6 将内存映射保存到文件中 text/24-ch02-06-2-6.txt:57(搜「类型转换」)
检查 memmap 文件 2.7 检查内存映射 text/25-ch02-07-2-7.txt:27(搜「memmap」)
指针入门三行 2.8 指针简介(1):地址和指针 text/26-ch02-08-2-8-1.txt:4(搜「指针的意思是」) · text/26-ch02-08-2-8-1.txt:8(搜「int i = 42」)
指针 = 编号 + 类型、uintptr_t 2.8 指针简介(1):地址和指针 text/26-ch02-08-2-8-1.txt:31(搜「唯一编号」) · text/26-ch02-08-2-8-1.txt:36(搜「uintptr_t」)
地址由处理器决定、优化会重排 2.9 指针和箭头运算符 text/27-ch02-09-2-9.txt:4(搜「由处理器决定」) · text/27-ch02-09-2-9.txt:14(搜「启用优化」)
偏移量、箭头运算符、语法糖 2.9 指针和箭头运算符 text/27-ch02-09-2-9.txt:20(搜「偏移量」) · text/27-ch02-09-2-9.txt:49(搜「pm->map_size = 0」) · text/27-ch02-09-2-9.txt:53(搜「语法糖」)
指针的指针与多返回值 2.9 指针和箭头运算符 text/27-ch02-09-2-9.txt:78(搜「经常使用的一种技术」) · text/27-ch02-09-2-9.txt:91(搜「重写memmap」)

  1. 出处:「2.4 内存映射」(text/22-ch02-04-2-4.txt:16,搜「内存映射是一种显示」;:47,搜「Type值」)。Type 各项含义表见同节(text/22-ch02-04-2-4.txt:46)。 ↩

  2. 出处:「2.4 内存映射」(text/22-ch02-04-2-4.txt:61,搜「覆盖包含CPU配置信息」)。 ↩

  3. 出处:「2.1 EDK II简介」(text/19-ch02-01-2-1-edk-ii.txt:4,搜「最初是Intel」)。 ↩

  4. 出处:「2.2 EDK II的Hello World程序」(text/20-ch02-02-2-2-edk-ii-hello-world-osbook-day02.txt:36,搜「include」;:39,搜「EFI_STATUS」)。 ↩

  5. 出处:「2.2 EDK II的Hello World程序」(text/20-ch02-02-2-2-edk-ii-hello-world-osbook-day02.txt:22,搜「ENTRY_POINT」)。 ↩

  6. 出处:「2.2 EDK II的Hello World程序」(text/20-ch02-02-2-2-edk-ii-hello-world-osbook-day02.txt:4,搜「MikanLoader」)。 ↩

  7. 出处:「2.3 主存储器」(text/21-ch02-03-2-3.txt:18,搜「一条直线上」;text/22-ch02-04-2-4.txt:5,搜「序列号被称为地址」)。 ↩

  8. 出处:「2.4 内存映射」(text/22-ch02-04-2-4.txt:55,搜「NumberOfPages表示内存区域的大小」;:57,搜「有点儿混乱」)。4 KiB 一页的约定见同节脚注。 ↩

  9. 出处:「2.4 内存映射」(text/22-ch02-04-2-4.txt:16,搜「内存映射是一种显示」;:38,搜「美式地址」)。 ↩

  10. 出处:「2.5 获取内存映射」(text/24-ch02-06-2-6.txt:1,搜「结构体的定义」),表 2.4 列出 Type/PhysicalStart/VirtualStart/NumberOfPages/Attribute 五个字段。 ↩

  11. 出处:「2.4 内存映射」(text/22-ch02-04-2-4.txt:52,搜「空闲区域」)。 ↩

  12. 出处:「2.4 内存映射」(text/22-ch02-04-2-4.txt:58,搜「孔洞」)。 ↩

  13. 出处:「2.5 获取内存映射」(text/23-ch02-05-2-5-osbook-day02b.txt:39,搜「MapKey」;:41,搜「ExitBootServices」)。 ↩

  14. 出处:「2.5 获取内存映射」(text/23-ch02-05-2-5-osbook-day02b.txt:12,搜「引导服务」)。 ↩

  15. 出处:「2.5 获取内存映射」(text/23-ch02-05-2-5-osbook-day02b.txt:33,搜「EFI_BUFFER_TOO_SMALL」;:34,搜「MemoryMapSize」)。 ↩

  16. 出处:「2.5 获取内存映射」(text/23-ch02-05-2-5-osbook-day02b.txt:44,搜「某些UEFI实现可能有扩展结构」)。 ↩

  17. 出处:「2.6 将内存映射保存到文件中」(text/24-ch02-06-2-6.txt:34,搜「OpenRootDir」;:43,搜「CSV」)。 ↩

  18. 出处:「2.6 将内存映射保存到文件中」(text/24-ch02-06-2-6.txt:68,搜「AsciiSPrint」)。 ↩

  19. 出处:「2.6 将内存映射保存到文件中」(text/24-ch02-06-2-6.txt:38,搜「指针的指针」)。原文:「在使用OpenRootDir()和root_dir->Open()中,使用了『指针的指针』技术」。 ↩

  20. 出处:「2.8 指针简介(1)」(text/26-ch02-08-2-8-1.txt:31,搜「唯一编号」))与「2.9 指针和箭头运算符」(text/27-ch02-09-2-9.txt:5,搜「内存地址」))。原文:「指针包含分配给每个变量的唯一编号。可以说,指针是变量的特定编号和类型信息的组合」;「许多编译器,包括本书中使用的Clang,都会使用变量所在的内存地址」。 ↩

  21. 出处:「2.8 指针简介(1)」(text/26-ch02-08-2-8-1.txt:36,搜「uintptr_t」;:37,搜「删除指针中的类型信息」)。 ↩

  22. 出处:「2.6 将内存映射保存到文件中」(text/24-ch02-06-2-6.txt:57,搜「类型转换」;:60,搜「指针很难理解」——原文安慰「即使不理解也不用担心(请阅读2.8节)」)。 ↩

  23. 出处:「2.9 指针和箭头运算符」(text/27-ch02-09-2-9.txt:20,搜「偏移量」)。 ↩

  24. 出处:「2.9 指针和箭头运算符」(text/27-ch02-09-2-9.txt:49,搜「pm->map_size = 0」;:53,搜「语法糖」)。 ↩

  25. 出处:「2.9 指针和箭头运算符」专栏2.2(text/27-ch02-09-2-9.txt:83,搜「EFI_FILE_PROTOCOL**」;:91,搜「别无选择」;:97,搜「多个返回值」)。 ↩

  26. 出处:「2.2 EDK II的Hello World程序」(text/20-ch02-02-2-2-edk-ii-hello-world-osbook-day02.txt:30,搜「更简洁」)。 ↩

  27. 出处:「2.4 内存映射」(text/22-ch02-04-2-4.txt:61,搜「正确理解主内存」;:64,搜「获取内存映射」)。 ↩

  28. 出处:「2.7 检查内存映射」(text/25-ch02-07-2-7.txt:32,搜「cat命令」);:32,搜「电子表格软件」)。 ↩

  29. 出处:「2.6 将内存映射保存到文件中」(text/24-ch02-06-2-6.txt:41,搜「8KiB」)。 ↩