Skip to content

用 C++ 写引导扇区 ​

最小实验给了咱们底气,现在把正式的 boot/ 目录立起来。本节咱们写的东西不多,但每一行都有讲究——16 位的世界对 C++ 很不友好,咱们得把"为什么非得这么写"一件件讲解明白。咱们从文件怎么分家说起,一路走到那块最要紧的入口桩。

三份头文件,按需付体积 ​

咱们看全貌:boot/ 下头一份代码,轮不到 mbr.cpp 本体——而是三份小头文件加一份布局常量、各自管一层。咱们从最底下那层看起。它叫 early/、取"早期"的意思:机器刚醒、什么都还没有的阶段,能用得上的原语就住这。它家里头一份是 early/boot_port.hpp、刨去文件头的注释、拢共几行:

C++
#pragma once

namespace cinux::boot::serial {
inline constexpr unsigned short kDebugconPort = 0xE9;
}

咱们再往上一层看,early/boot_console.hpp,咱们拿端口号干活:

C++
namespace cinux::boot::serial {

inline void PutChar(char character) {
    asm volatile("outb %0, %1" : : "a"(character), "Nd"(kDebugconPort));
}

inline void PutString(char const* a_string) {
    while (*a_string != '\0') {
        PutChar(*a_string);
        ++a_string;
    }
}

}  // namespace cinux::boot::serial

第三份是 mbr/disk_address_packet.hpp、读盘的参数包,下一节的主角。boot/ 根上还躺着一份 layout.hpp、里面是低内存布局的全部常量,咱们待会儿就见到它。

0xE9 是什么端口,咱们本节末尾专门说。这里咱们看结构:上面这些函数全是 inline,整个实现躺在了头文件里、没有 .cpp、没有库——图省事倒谈不上,它实在是 MBR 的处境逼出来的选择。

您想啊,常规的做法是把公共函数编进一个 .o、谁用谁链接。可链接器认 .o 是整只吞的:哪怕您只调用一个函数,整个 .o 跟着进您的镜像。当年第一遍的引导代码里有一份公共的汇编串口模块、MBR 就一直不敢链它。用——咱们是想用的,可人家整个搬进来、512 字节的家瞬间被撑爆。头文件实现的走法正好反过来:函数写成 inline,哪个翻译单元调用了它,编译器就把函数本体实例化进调用它的那个目标文件。MBR 调了 PutString,mbr.bin 里就只有 PutString 用到的东西。stage2 也调了它,stage2 自己再实例化一份——MBR 眼下那一百七十来字节的富余,就是这么省出来的。

-m16:把 C++ 拨回 1978 ​

分好了家,咱们说编译。本站所有 16 位阶段的源文件、编译时都带一面新旗子:-m16。它的意思直白:让 GCC 按 16 位实模式的语义生成代码。

它背后干的活,值得咱们看一眼。GCC 拿到 -m16 之后会在汇编输出的开头自动插一行 .code16gcc。这行指令告诉汇编器:接下来的指令按 16 位模式编码,并且按 GCC 的习惯来。妙处在于咱们写在 C++ 里的内联汇编,也自动继承了那套 16 位语义。您在 asm() 字符串里写 %ax,它就是 16 位的 AX。您写 mov %ax, %ds,编出来就是不带任何前缀的 16 位指令。不用咱们在每个汇编片段头上手工标注 .code16,GCC 全程给咱们把着舵。

编译器替咱们把着舵、这件事值多少钱,笔者是有切肤之痛的。实模式死法排行榜上有一类经典:16 位的地界上,混用了 32 位宽度的指令。同一个 call、有人用 16 位编码、它的搭档 ret 却按 32 位解释、一进一出,栈指针就对不上数了。栈悄悄毁掉、崩的位置离肇事的地方十万八千里。当年笔者在它身上死过一整晚。这一遍呢,-m16 从编译器层面把指令宽度统一了,C++ 生成的代码,不会再犯宽度混搭的毛病。

不过咱们也别高兴得太满。编译器管的是它生成的代码,咱们亲手写在 asm() 字符串里的指令,宽度仍然是自己的责任。内联汇编片段里要是指定了 32 位寄存器,GCC 会老老实实帮您加操作数前缀、让指令用上 32 位寄存器。加了前缀的指令在实模式是能跑的,可您得自己清楚自己在干什么。本站的汇编片段全用 16 位寄存器名,和外面的世界保持同一个口径。

16 位世界,零标准头 ​

接下来说一条容易翻车的日常:#include。武器库立的允许清单里、<cstdint> 榜上有名,咱们一路用得顺顺当当。到了 -m16 的地界,它反而炸了。

炸的原因有点哭笑不得。<cstdint> 一类标准头在 GCC 里是层层包装的,里面会去包含目标平台对应的系统头。-m16 在 GCC 那边落实成 -m32 的 16 位变体,于是包含链条一路摸到了 gnu/stubs-32.h——一份 32 位环境才有的头。您的机器要是没装 GCC 的 32 位支持包、多数发行版默认不装,编译当场就报了一个"找不到头文件",报错的位置离您写的代码隔着七八层标准头、看着莫名其妙。

所以笔者在这里立一条边界:16 位世界、零标准头:一个 #include <...> 都不许有,类型全用语言内建的写法:unsigned char、unsigned short、unsigned long long。这些名字土了点,可它们不依赖任何的头文件,走到哪都编得过——宽度靠什么保证?layout.hpp 的头一行就干这个:

C++
namespace cinux::boot {

static_assert(sizeof(unsigned short) == 2);

inline constexpr unsigned short kMbrBase       = 0x7C00;
inline constexpr unsigned short kStage2LoadSeg = 0x0000;
inline constexpr unsigned short kStage2LoadOff = 0x7E00;
inline constexpr unsigned short kStage2Sectors = 4;

}  // namespace cinux::boot

您看,static_assert 在编译期把"unsigned short 必须是两个字节"定住了。段:偏移的整套算术全押在它身上。您哪天换一个工具链,把宽度悄悄改了,构建当场翻了脸,不给它漂的机会。底下的常量,就是上一节咱们摊开过的低内存地图的文字版——MBR 在 0x7C00,stage2 紧跟在 0x7E00、预算的 4 个扇区。这些数字往后 MBR 用、stage2 用、构建脚本也用——所以它们只此一份,住在 layout.hpp 里:谁都不许在自己的家里抄一份私藏——单一来源,这是从上一站一路延续下来的纪律。

入口桩:抢在 call 之前,把世界铺好 ​

现在看 mbr.cpp 里最要紧的一段。C++ 的世界再好,有一个瞬间它替咱们做不了主:BIOS 跳进 0x7C00 的那一刻。那一刻机器上是什么光景?咱们盘点一下:除了 CS:IP 指着咱们、DL 里揣着启动盘号,其它寄存器全是没定义的。段寄存器们是什么值、没有承诺。栈呢——压根不存在。而 C++ 的函数,是没法在如此光景里开工的。

为什么开不了工?咱们把一个普通 C++ 函数编译出来看看:它进来头几条指令里就有压栈动作,保存帧指针、留局部变量的地——这些全是 push。而 push 要用栈、栈要靠 SS:SP 指着一块正经内存——栈不存在,push 写到哪去?写到没定义的 SS:SP 指着的随机地方,机器当场就没了。您也许想问:优化会不会把这段序言省掉?咱们实测过、-Os 都不省。它是编译器对函数调用约定的承诺,不是可砍的赘肉。

所以这一段必须用纯汇编写,而且它的使命只有一个:在把控制权交给任何 C++ 函数之前,把 C++ 需要的世界铺好。咱们管它叫入口桩。mbr.cpp 里它是这么一段顶层 asm:

C++
asm(".section .text.boot, \"ax\"\n"
    ".global mbr_start\n"
    "mbr_start:\n"
    "   ljmp $0x0000, $mbr_real_start\n"
    "mbr_real_start:\n"
    "   xor %ax, %ax\n"
    "   mov %ax, %ds\n"
    "   mov %ax, %es\n"
    "   mov %ax, %ss\n"
    "   movw $0x7c00, %sp\n"
    "   movb %dl, g_boot_drive\n"
    "   cld\n"
    "   call MbrMain\n"
    "0: hlt\n"
    "   jmp 0b\n"
    ".text\n");

咱们挨行走一遍。开头的 .section .text.boot,是把这段代码放进一个专属的段,好让链接脚本把它排在镜像最前面:BIOS 是从扇区第一个字节开始执行的,入口必须正好落在偏移 0——这一手等咱们讲到构建再细说。紧跟的 ljmp 是段归一化,咱们下一节专门伺候它、这里把它按下不表。

真正的铺世界、从 xor %ax, %ax 开始:AX 清零,然后 DS、ES、SS 三个段寄存器挨个归了零。为什么是零?咱们的链接地址,是按"段 0、偏移即物理地址"的口径算的、段寄存器全归零之后,偏移就是物理地址,世界从此是平的。接着 movw $0x7c00, %sp 呢:栈顶指到 0x7C00,也就是咱们自己的头顶。栈是向下长的,往下伸进的是 0x500 到 0x7BFF 那片自由区,不跟任何住户抢地——上一节地图上咱们标过的那块,就是给栈留的。到这里、SS:SP 有了一块正经内存、push 和 call 终于有家可回。

下一条 movb %dl, g_boot_drive、是把 DL 里的启动盘号存了档。这个数咱们单独说两句。BIOS 全程就这一回告诉咱们"您是从哪块盘启动的":0x00 是第一软盘,0x80 是第一硬盘、等咱们调 BIOS 读盘的时候、还得把它原样递回去、不然 BIOS 不知道找哪块盘——可 DL 是个寄存器,下一次中断调用它就可能被改写、不存下来、这个数就失传了。所以桩里抢在读盘之前、把它落进全局变量 g_boot_drive——抢在失传之前存档。

再往下 cld、把方向标志清了零、告诉串指令"向前走"、它容易被忽略:BIOS 退出时,方向标志是什么值同样没有承诺。咱们后头要是用上串操作,方向反了,就是数据写反的静默事故——一条指令、买个保险。

最后 call MbrMain、C++ 的世界开门。您注意——在 call 之前、桩没有碰过任何 C++ 函数,顺序就是它的命:设段、立栈、存盘号、然后才 call:要是反过来、call 完再到 C++ 里铺世界,那个函数的序言在第一下 push 就把机器送走了。笔者给这套顺序起了个名字,叫C++ 入口约定:凡是 16 位世界里的 C++ 代码、都得有人把段和栈铺成这个样子,它才能活——本站咱们在 MBR 里铺。往后 stage2 的入口桩为什么能短到三条指令,答案也在这——到时候您就知道了。

这份约定有严格的先后关系。下面把 BIOS 留下的未知状态一步步收拢成 C++ 能接手的世界;单步播放时,留意 call MbrMain 为什么只能排在最后:

0.0s / 31.9s
STEP 01开场

桩的尾巴是 0: hlt 和 jmp 0b:HLT 让 CPU 睡过去省电。可中断一来它还是会醒、醒了就顺着 jmp 转回去接着睡——死也死得安稳,不会睡醒之后往野地址里跑。咱们把休止符写在桩里,图的就是个安稳。

extern "C":救命的还不仅是函数 ​

桩里引用了两个名字:MbrMain 和 g_boot_drive。咱们看它们在 mbr.cpp 里的长相、会发现共同点:都顶着 extern "C"。变量的那两行长这样:

C++
extern "C" unsigned char g_boot_drive;      // Linkers dont know the g_boot_drive, export it
unsigned char            g_boot_drive = 0;  // and then we sadly init it

咱们再看 MbrMain 那边:定义头上、一句 extern "C" [[noreturn]] void MbrMain()。

extern "C" 咱们都不陌生:告诉编译器,这名字按 C 的语言链接性来、别做名字改编。可它在这里为什么是救命题,值得咱们展开细说。

桩是一段汇编字符串、咱们在里面写 call MbrMain,引用的是裸符号 MbrMain:链接器拿 MbrMain 七个字符去找人。可 C++ 的编译器默认会把函数名改编成一串编码,把命名空间、参数类型全都缝了进去。哪怕 MbrMain 不进任何命名空间、编出来也是 _Z9MbrMainv 这样的形态,链接器翻遍全场、也找不到同名的人。咱们拿 GCC 实测过三组、nm 一照:普通函数、改编、永远改编。全局变量进了 namespace、改编。裸的全局变量呢,倒恰好好好的、名字原样躺着。最后一组最唬人——它给您一种"C++ 变量本来就不改名"的错觉,其实那不过是当前 ABI 一时的厚道、《标准》可没承诺过。变量出了全局作用域、进了哪个 namespace、厚道立刻收回。

所以 extern "C" 在这儿的分量、对 MbrMain 是生死:不加,桩里的 call 找不到人、链接当场报 undefined reference。这还算好的——至少死得明白。对 g_boot_drive 则是把"碰巧不改名"升格成白纸黑字的合同:咱们要的就是那个裸名字、写明它,往后哪个编译器版本改了默认行为,咱们也不受影响。

再看那两行分开写的代码、也有讲究:声明归声明、定义归定义。一行 extern "C" unsigned char g_boot_drive = 0; 在语言上其实合法,可 GCC 见了要念叨一句"'g_boot_drive' initialized and declared 'extern'"——声明带 extern 又带初始化,它拿不准您到底想干嘛。平时嘛、它只是句闲话,上一站把警告当错误的纪律一开、闲话就是编译失败。分成两行、声明不带初始化、定义不带 extern、各归各的口径、警告闭嘴。风格倒是还在其次,要紧的是,它是唯一能过闸的形状。顺带的,= 0 也有一半是写给读代码的人看的:静态存储期的变量反正会被零初始化、那个 0 只算占位,不算谁真的初始化了它。真值来自桩里的那道 movb %dl, g_boot_drive,来自 BIOS 跳进来的那一刻。

0xE9:输出真空期里最便宜的喇叭 ​

世界铺好了,可咱们环顾四周,发现这是一片输出的真空:没有终端、没有格式引擎,连武器库里那套断言报告都送不出去了。咱们要是想在这阶段确认"代码活着",靠什么?

本能的候选是屏幕——往 VGA 文本模式的显存 0xB8000 写字符。可在 16 位阶段、此路被物理封死了。显存地址超过 0xFFFF,而咱们刚刚把段寄存器全归了零,偏移是 16 位的、顶到天 0xFFFF。您在代码里写 *(char*)0xB8000,汇编器好心好意提醒您"这个地址被截断了"。什么意思?0xB8000 的低位装进 16 位偏移、高位直接扔掉。您写的和您以为的压根就不是同一个地方!显存,不行。

真正的答案是:0xE9 端口。QEMU 给虚拟机装了一个"调试控制台"。它的前辈 Bochs 也有同款。您把一个字节写进 0xE9 端口,QEMU 就把它原样吐到您眼前的终端上。干活的就是 boot_console.hpp 里那两行:outb %0, %1、操作数分别是字符和端口号。没有初始化、没有握手、不碰任何真实硬件的状态:机器醒来到断气,通道全程在线——代价嘛、单向:只能往外说、听不见回应。可对一个连"活着没有"都证明不了的阶段、能往外说一个字,已经是天大的奢侈了。

往后本章里您会看到咱们在代码的几个关口各写一句 PutString——读盘前一句,跳转前一句——它们就是本站的"printf 调试法":不值钱,可眼下满机器没有第二个能出声的通道,这就是咱们全部的嗓门。

r05_long_mode-2-gfc5569b · fc5569b · 2026-10-01