Skip to content

跟 BIOS 打交道 ​

入口桩把世界铺平了,现在轮到 MbrMain 干它的正事:把磁盘上紧挨着 MBR 的那几个扇区读进内存,然后跳了过去。读盘这件事、实模式里咱们自己干不了,磁盘控制器是 BIOS 初始化的,怎么转盘、怎么寻道,咱们一概不知情。所以整个 MbrMain 其实是一场外交:咱们的 C++,去请 BIOS——唯一的中间人——出马。

起手一个 ljmp,把 CS 定下来 ​

开始之前咱们还得还上一节欠的一笔:桩开头那道 ljmp $0x0000, $mbr_real_start,管的是段归一化。

事情是这样的。BIOS 把咱们放到 0x7C00 这个物理地址、这一点没有争议、可"段:偏移"两半各写多少,BIOS 之间没有统一:大多数按 0000:7C00 交了棒、CS 是 0。偏偏有那么一批,按 07C0:0000 交了棒、CS 是 0x07C0。问题在哪?

问题在咱们的代码从这一刻起,处处都按"CS 是 0"的口径行事。链接器按"段 0、偏移即地址"排布一切,桩里 DS 也归了零,大家全在同一个平的世界里。可要是 CS 进来的是 0x07C0,它和 DS 就不是一家了:同样的偏移,从 CS 里读出来和从 DS 里读出来、差了 0x7C00。往后任何跟 CS 挂钩的动作——取指令算地址,往别的段远跳——都会踩在这道裂缝上。这类错误最阴险的地方在于,它在 QEMU 里可能永远不发作:QEMU 用的 SeaBIOS,恰恰是按 0000:7C00 交棒的那一派。咱们的最小实验当时没做归一化也点得着。您可别误会成咱们做得对——是 QEMU 替咱们兜着——正式代码不赌运气。桩的第一条指令就是 ljmp $0x0000, $mbr_real_start:不管 BIOS 用哪种姿势把咱们送进来,咱们落地头一件事,就是远跳到"段 0"名下的下一个标号:mbr_real_start 和 ljmp 只差一条指令、同一段代码。跳完之后 CS 被定在了 0、世界从此确定。您还记得实模式那一段说的吧,CS 是唯一不能 mov 改的段寄存器、想动它只有远跳转一条路。限制在这儿正好派上用场:归一化 CS 的动作、天生就只能用 ljmp 做。

BIOS 中断不是函数 ​

世界确定了,咱们去请 BIOS。请的方式前面提过:软件中断。咱们要用的那路服务、中断号 0x13,行话叫 INT 13h——BIOS 的磁盘服务总机。

可在写代码之前咱们得把一个观念掰正:BIOS 中断、不是函数。咱们平常用惯的函数,编译器替咱们立了完整的约定:参数怎么传、返回值放哪、哪些寄存器会被弄脏,写得明明白白、还替咱们把关。BIOS 中断呢?约定是有的、可薄得很。它承诺的、无非"功能号从 AH 进、返回状态从 AH 出"一类寥寥几条。至于其它寄存器它用完就还给您一个脏的。当年第一遍的引导代码里,笔者就栽在它上头,调完一次 BIOS、顺手接着用某个还存着老值的寄存器、算盘打得很美。可那个值、早被 BIOS 服务里头的某条指令改掉了。后面一连串的错、全从这一步来。

在纯汇编里、哪些寄存器会脏、全靠咱们的人脑记。在 C++ 里咱们有一个正经的机制替咱们记,那就是 GCC 内联汇编的约束和 clobber 声明。您可以把它们理解成"写给编译器看的调用约定"。咱们直接看 MbrMain 里的调用现场:

C++
extern "C" [[noreturn]] void MbrMain() {
    unsigned short       reg_ax = 0x4200;  // NOLINT(misc-const-correctness)
    const unsigned short kDrive = g_boot_drive;

    cinux::boot::serial::PutString("Ready to call bios\n");
    // Call Bios
    asm volatile("int $0x13" : "+a"(reg_ax) : "S"(&g_dap), "d"(kDrive) : "memory");
    if ((reg_ax >> 8) != 0) {
        boot_failed(reg_ax >> 8);
    }

"int $0x13" 是指令本体:咱们头一个看 volatile,它的活得说准、眼前这道 asm 带着输出约束,编译器本来就知道它"有产出"、而不至于把它当废代码删掉。可 int $0x13 真正干的事,编译器一样看不见——它动了设备、动了内存、这些副作用一个都不在操作数里。挂上 volatile、是告诉编译器:asm 语句您别删、别挪,也别跟别的 asm 调换次序。它在、就得按咱们写下的位置执行。

"+a"(reg_ax) 是约束、咱们一个字母一个字母读。a 说的是"用 AX 寄存器"。前面的 + 说的是"同一块既是输入又是输出"。为什么得读写双向?AX 在这里身兼两职。进去的时候,0x4200 的高字节 0x42 是功能号——扩展读。出来的时候,BIOS 把返回状态写在了 AH 里:零是成功、非零是错误码。同一个寄存器、进和出两种身份、+ 就是把这件事告诉了编译器:调用完,您得从 AX 里把新值读回来,别拿调用前的老值继续算。底下那行 if ((reg_ax >> 8) != 0)、读的正是返回来的新值——错误码在 AH,也就是 reg_ax 的高八位。

"S"(&g_dap) 和 "d"(kDrive) 是纯输入:S 用 SI 寄存器装 g_dap 的地址、d 用 DX 装盘号。合起来、INT 13h 扩展读的参数约定就齐了:AH 功能号、DL 盘号,DS:SI 指向参数包——等一下、您可能要问——说好的 DS:SI,DS 打哪来的?

好好,咱们翻回入口桩:DS 一落地就被归了零、之后从没人动过它。这就是归一化的红利之二:约定里没写的半边,咱们用"世界是平的"的地基保住了。您往后在别处看到一段 16 位代码,调 BIOS 前手忙脚乱地设 DS,那多半是它的世界还没铺平。

末尾的 "memory" 是 clobber 列表,本行唯一的成员。它的意思是:这段 asm 读了写了内存,编译器您别把前后的内存操作挪位置。具体到咱们这场:BIOS 要往参数包指定的目标地址里写读回来的数据,写的是内存——编译器要是不知道这回事,完全可能把"读盘前摆好数据"和"读盘后取数据"的指令翻来倒去排。一句 "memory"、把这些副作用全报到了。

咱们顺手交代一行小字:代码里 reg_ax 头上挂着一条 NOLINT 注释,拦的是静态检查器的一条误报——它读不懂 "+a" 一类的读写约束,以为 reg_ax 从没被改过、劝咱们给它加 const。意见不能听:加了 const,调用约定就破了。旁边的 kDrive 倒是真该 const,纯输入嘛,检查器在它身上就报对了——同一个检查、一真一假。您往后跟内联汇编打交道,这类『工具读不到什么』式的误报会常常碰见、心里有数就行。

AH=42h:参数打包成一张单子 ​

功能号 0x42 这个选择、咱们也把来龙去脉说清。INT 13h 总机上有两路读盘服务、老的那路是 AH=02h:您得把"柱面-磁头-扇区"三个数分别塞进 CH、CL、DH 好几个寄存器,让 BIOS 按老式硬盘的物理几何去寻址。CHS 寻址是 1980 年代的遗产,天花板低得可怜:BIOS 和老硬盘接口各自的限制叠在一起:早年是 504MB、后来用几何翻译撑到 8GB、再往上,就彻底没有地址可编了。

90 年代、IBM 和微软给总机扩了一路新服务、后来由 Phoenix 等厂商整理成扩展磁盘服务(EDD):功能号 40h 往后,参数不往寄存器里塞了、改成打包成一个结构体,把结构体的地址递过去。磁盘地址也不用柱面磁头扇区了、改用 LBA——逻辑块地址。它把整块盘从头到尾按扇区线性地编号:第一扇区是 0,往后的 1、2、3 递增。LBA 是 64 位的、按 512 字节一个扇区算、能编到 ZB 级,咱们这辈子用不完。

咱们选扩展读(AH=42h)、理由不光是天花板。头一条、LBA 的编号方式和咱们拼镜像的方式天然对齐,镜像的第一扇区是 MBR、那它就是 LBA 0。紧挨着的 stage2 从第二扇区开始、那它就是 LBA 1。构建脚本把 stage2 摆在哪个扇区、代码里就照着哪个号读、一是一、二是二、没有"这块盘每磁道几个扇区"式的因盘而异换算,干脆说、没有任何的换算。第二条、参数打包成结构体、对 C++ 太友好了,结构体是咱们的母语、寄存器芭蕾不是。

参数单子的学名叫 DAP,Disk Address Packet、磁盘地址包。咱们把它安置在 boot/mbr/disk_address_packet.hpp:

C++
struct [[gnu::packed]] Dap {
    unsigned char      size;      ///< Packet size in bytes, 16 for this layout.
    unsigned char      reserved;  ///< Must be zero on entry.
    unsigned short     count;     ///< Number of sectors to transfer.
    unsigned short     offset;    ///< Destination buffer offset, half of a segment:offset pair.
    unsigned short     segment;   ///< Destination buffer segment, half of a segment:offset pair.
    unsigned long long lba;       ///< Starting sector as a zero-based absolute block address.
};

咱们逐字段过一遍:十六个字节、每一个都有说法。

size,一个字节、写 16——单子本身的长度。BIOS 拿它辨认单子的版本:结构后来扩过一次、长到 24 字节,多出一个 64 位的平面缓冲区地址。BIOS 看 size、就知道您递的是哪一版。reserved,保留字节、进门前必须为零、您照办就行。count,读几个扇区:咱们填的是 kStage2Sectors,4 个扇区、2048 字节——stage2 在 layout.hpp 里的全部预算。

offset 和 segment 一对、说的是读回来的数据往哪放:目的地地址的段:偏移两半。咱们填 0x0000:0x7E00,就是地图上紧贴 MBR 后脑勺的那块自由区。您留意一件妙事:这一对在结构体里、是"偏移在前、段在后":x86 是小端序、段:偏移两个 16 位数在内存里落位,低地址的排在前头,固件认的就是这个次序。好在咱们在 C++ 里按字段名写、次序由结构体定义定死、不劳您操心。

lba,八字节呢、从哪个扇区开始读、从 0 数起。咱们填 1:LBA 0 是 MBR 自己的家,LBA 1 起、就是 stage2 了。

十六个字节挨在一起时,字段次序和每一段的宽度会比代码更直观。图里地址从上往下增长,右侧是本站真正交给 BIOS 的值:

INT 13h 扩展读使用的 16 字节 DAP

再抬头看那个 [[gnu::packed]]、咱们可以说它是承重墙、绝不是装饰。咱们把头两个字节的排布算一算就明白了:size 是一个字节的 unsigned char,后面跟着的 count 是两个字节的 unsigned short。按 C++ 平常的对齐习惯,short 要落在偶数地址上。可 size 占了偏移 0、偏移 1 是奇数,编译器会在这儿垫一个字节的空、把 count 挪到偏移 2 去。咱们平常写程序、这道空隙无伤大雅。可 DAP 是写给固件看的格式、十六个字节排得满满当当、一道空隙都没有。垫了它、后面所有字段全体错位,BIOS 从错误的位置读错误的值、读盘结果完全不可预期、还不报错。packed 一挂、编译器垫空隙的习惯被关掉,内存布局和固件的期待、严丝合缝。

咱们在 mbr.cpp 里把单子填成这样:

C++
namespace {
cinux::boot::Dap g_dap = {.size     = 16,
                          .reserved = 0,
                          .count    = cinux::boot::kStage2Sectors,
                          .offset   = cinux::boot::kStage2LoadOff,
                          .segment  = cinux::boot::kStage2LoadSeg,
                          .lba      = 1};
}

指定初始化器、C++ 的老朋友了:字段名写在里头,谁是谁一目了然——这里另有一层讲究,单子是带初值的全局变量、编译器把它安排进 .data 段。咱们往磁盘镜像里写 mbr.bin 的时候、这些初值原样躺在扇区里。BIOS 读咱们进内存,单子就已经是填好的——不等代码跑、数据到位。这一点在 16 位的世界里格外要紧:BIOS 可不负责清内存。.bss 里那些"没初值的全局变量"醒来是什么值,全看上辈子住在这的住户留下什么。所以本阶段的边界是:全局数据一律带初值、躺进 .data。您真要一块干净内存、自己动手清。桩外那句定义 unsigned char g_boot_drive = 0; 倒不心疼那个 0——上一节说过的,真值反正有桩里的那道 movb 送进来。

尾声的 ljmp:把世界交出去 ​

读盘成功、stage2 已经躺在 0x7E00 了。MbrMain 的收尾,咱们只留一个动作:跳过去。

C++
    cinux::boot::serial::PutString("Jump to Stage 2\n");

    asm volatile("ljmp %0, %1"
                 :
                 : "i"(cinux::boot::kStage2LoadSeg), "i"(cinux::boot::kStage2LoadOff)
                 : "memory");
    // And if, we failed, runs into the unreachable
    returned_from_stage2();

    __builtin_unreachable();

跳转本身还是 ljmp、可写法和桩里的那道不一样:讲究、就在两个 "i" 约束上。"i" 的意思是:把操作数作为立即数直接编进指令。咱们递给它的是 layout.hpp 里的 kStage2LoadSeg 和 kStage2LoadOff、编译器把它们变成 ljmp $0x0000, $0x7E00,再由汇编器落成五个字节的远跳指令——ea 00 7e 00 00,咱们反汇编看过、一字不差。

这一手的意义在于:目标地址不再是手写在汇编字符串里的死数,它变成了从 layout.hpp 流过来的常量。您哪天想挪 stage2 的落点、改 layout.hpp 一处,ljmp 的指令编码、DAP 的目标地址、全都跟着走。要是当时咱们偷懒、在 asm 字符串里手写 ljmp $0, $0x7E00、再在 DAP 里另填一份 0x7E00,两处数字就成了一对没人看管的兄弟。改了一处,忘了另一处,读盘读到一个地方,跳转跳去另一个地方——到时候您手里,连一条能对上的线索都没有。

您再看一眼 ljmp 那道 asm:它连一个输出操作数都没有。真到"什么产出都看不见"的形态,优化器是完全可能把整条语句当废代码扔掉的。到头来、volatile 就是它唯一的保命符,咱们前面讲的保险、在这里一寸都让不得。

returned_from_stage2 和 __builtin_unreachable、是给编译器的一个交代。咱们知道 ljmp 有去无回,可编译器看不见 asm 字符串肚子里装的是什么——在它眼里,那就是一条普通的语句、执行完会自然落到下一行——既然控制流"会往下走",去处总得有东西接着——咱们安排的落点、是打一行 unexpected: returned from stage2、然后停机。哪天您在 debugcon 里看见这行字,您不用翻代码:跳转压根没成、现场就是遗言。

最后咱们看一眼 ljmp 出发前的机器,数数 stage2 要接过的家当:CS、DS、ES、SS 全部归零,地址是平的:栈立在 0x7C00 向下、随取随用。咱们把启动盘号存在 g_boot_drive、失传不了。stage2 的四个扇区在 0x7E00 躺好、预算多少、落在哪,layout.hpp 一份头文件说了算。stage2 伸手接过的、远不止一台裸机,还有上面这一整套约定——往后等咱们把内核接生出来、跟它正式办移交,这些如今全靠默契传下去的东西,会长成一份正经的结构体,那是后面的事了。眼下咱们把今天的世界交出去。

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