Skip to content

问 BIOS 要一张内存图 ​

咱们要的东西,严格地说,单问一句内存有多大交不了差,还得问出内存长什么样子。老一代的服务报的就是光秃秃一个数:INT 12h 报低内存多少 KB,INT 15h AH=88h 报的是 1MB 以上多少 KB,还各有各的天花板:88h 报到头也就 64MB,并且只认连续的那一段。可物理地址空间里有洞:显存、BIOS ROM、ACPI 表(固件的配置和电源管理数据,也实打实占着内存)、内存映射的设备,全混在 RAM 的中间。一个数是说不清这些的,E820 报的也不是数,报的是一张完整的图:哪一段能用、哪一段碰不得,一段一行地排开。2002 年以后的机器全数支持。它也是唯一看得见 4GB 之上的 BIOS 内存服务——这话不虚,等会儿咱们自己的机器会当场演示。

约定,逐寄存器过一遍 ​

E820 的全名是 INT 15h、EAX=0xE820。咱们把进出的寄存器挨个过。

EAX 进门装的是功能号 0xE820,高 16 位咱们必须给成零。EBX 进门装的是续传令牌,头一次调用传的就是零,出门它就变成了下一轮的令牌,这套机制咱们马上细讲。ECX 进门装的是缓冲区宽度,咱们递 24,意思是:一条条目我备了 24 字节的房子,出门它被 BIOS 改写成了实际写入的字节数。EDX 进门必须装的是 0x534D4150,四个字母 SMAP 的 ASCII,接头用的暗号,递错了 BIOS 可不认。ES:DI 是目的地指针,指向咱们备好的条目。

回来的时候,咱们挨个收:CF 清零,表示的是顺利。EAX 被 BIOS 写回了 'SMAP',算是它递给咱们的回执。ES:DI 倒是原样不动。您注意,BIOS 可不替咱们把 DI 往前挪,一条问完了,下一问的目的地,得咱们自己算。条目本身长什么样、为什么备 24 字节的房子,咱们留到下一节专门伺候。本节咱们只管把问答的礼仪学对。

E820 的寄存器合同

BIOS 不是函数,E820 版 ​

BIOS 不是函数——这个观念上一站咱们立过一遍,E820 这边咱们得再立一遍,因为它把不讲理又推远了一截:约定薄了,例外也多了。您想,咱们递进去 24 字节的房子——BIOS 可能只写 20。咱们递对了暗号,个别 BIOS 出来时 EAX 是脏的。要是问过了头,它也不给个客气的返回值,直接把 CF 一拉了事——正常收工走的是另一副样子:出门的 EBX 归了零,CF 也是清的,这个咱们讲到续传令牌时再对上。您说约定薄不薄?拿到什么信什么,垃圾就当数据存进了家底。所以咱们的单次查询 query_one,回来后的头一件事是过三道闸:

C++
constexpr uint32_t kMinimumEntryBytes =
    sizeof(MemoryMapEntry) - sizeof(MemoryMapEntry::acpi_attr);

return carry_flag == 0 && register_ax == kSmapSignature
    && register_cx >= kMinimumEntryBytes;

头一道查的是 CF。等进位标志拉起来了,要么 BIOS 不认这个功能,要么清单到了头,要么它中途翻了脸——不管哪种,返回值咱们都不能信。第二道查 EAX 的回执。约定说了:顺利时 BIOS 会把 'SMAP' 写回 EAX。咱们要是没等到 'SMAP' 写回,说明对方压根没按 E820 的剧本演,天才知道寄存器里剩的是什么。第三道查 ECX 的实数。咱们递了 24,它写的可能只有 20 字节,20 是不含 ACPI 扩展字段的老口径,ABI 是允许的。kMinimumEntryBytes 就是从条目本身推出来的:24 减 4 得 20,合同的下限从条目身上自己算出来,不劳咱们手抄。写了不到 20 字节的,交回来的房子缺斤短两,咱们不能收。

然后咱们看完整的查询现场:

C++
bool query_one(uint32_t& continuation, MemoryMapEntry* entry) {
    uint32_t       register_ax = kQueryMemoryMapMagic;
    uint32_t       register_cx = sizeof(MemoryMapEntry);
    const uint32_t kRegisterDx = kSmapSignature;
    uint8_t        carry_flag;

    asm volatile(
        "pushw %%ds\n"
        "popw %%es\n"
        "int $0x15\n"
        "setc %[carry_flag]"
        : [ax] "+a"(register_ax), [continuation] "+b"(continuation),
          [bytes] "+c"(register_cx), [carry_flag] "=q"(carry_flag)
        : [signature] "d"(kRegisterDx), [entry] "D"(entry)
        : "memory");
    ...
}

咱们给六个操作数换成了具名写法——%[ax] 比 %0 好认,上一站三个操作数咱们还扛得住,这一把变成了六个,人脑是记不住编号的。"+a"、"+c" 还是读写:AX 进的是功能号、出的是回执,CX 进的是宽度、出的是实数,也算得上一体两面了。"d" 担任的是纯输入,装的是暗号。"D" 把条目指针装进了 DI。中间的 pushw/popw 是 ES 的自救,咱们欠着,压到本节的末尾再讲。

这里笔者要交代一桩真事。探测件定稿之前咱们把它单独拎出来,真编、真链、真跑了一回。头一版就在 EDX 上翻了车:kRegisterDx 明明装着暗号,却忘了写 "d" 约束。可 asm 块里写的是裸 int $0x15,进 BIOS 的 EDX 是什么,全看上一个住户留了什么。等端到端跑完了,三道闸把垃圾应答拦在了门外,终端上是一行干干净净的 E820 FAILED。闸倒是把好闸,这一关它把住了。咱们反汇编一查才发现,EDX 从头到尾就没被赋过什么值。您顺着两边的职责看就明白了:绑进了约束的操作数,编译器会忠实地放进约束指名的寄存器,这半边它管得倒是一板一眼。可 asm 字符串里那道裸的 int $0x15 期待暗号落在 EDX。这样的期待跟约束清单对不对得上,编译器是看不见的:它不读 asm 字符串的意思。漏绑的 kRegisterDx 至多换来一条未用变量的告警,还只在告警集开着的时候才响。asm 边界的对错,编译期是看不见的,链接期也是看不见的,只有咱们端到端真跑一遍才算数。

您想亲手破一回也容易:把 kRegisterDx 连变量带 "d" 约束一起删了——注意是连变量一起删,只删约束的话,变量就留在了原地,轮到 -Werror 的未用变量错误把构建拦下来——然后咱们重建 run。终端上等着咱们的就是那行 E820 FAILED,三道闸把垃圾应答拦在了门外,跟笔者头一回翻车时看见的一模一样。

续传令牌:一次问一条 ​

偏偏 E820 一次只报一条。咱们想拿全图,就得一句一句地问。BIOS 记进度的方式很复古:不发下标,发的是令牌——每一轮出门,EBX 里揣着下一轮凭此续问的值,您原样递回去,它接着讲它的。什么时候讲完?出门的 EBX 归零,就是讲完了。您要是问完了还接着问,按约定的说法,清单可能从头再给您来一遍,也可能干脆把 CF 一拉了事,各家 BIOS 品性不一——咱们不赌,拿归零当唯一的收工信号。

令牌在 C++ 这边落在了 continuation 变量上,约束用的是 "+b"。这是 clobber 纪律的第二层:上一站的 "+a" 说的是“这个寄存器进去是参数、出来是结果”,一锤子的买卖。"+b" 多干了一件事:每次进 asm 的门,它担保令牌的值一定装在 EBX 里。调用回来了,EBX 里的新值又自动写回变量,顶替成下一轮的输入。至于两次调用之间的空当里这个变量待在哪个寄存器、会不会被暂时挪去栈上,编译器自己安排——约定要的只是“进门那一刻值必须在 EBX”,这一点它替咱们担保到家。当年笔者用汇编写这段,pusha、popa 手工保存了一整个寄存器现场。如今这摊活计全折进了约束字符串。

C++
int CollectMemoryMap(MemoryMap* map) {
    map->count            = 0;
    uint32_t continuation = 0;
    do {
        if (!query_one(continuation, &map->entries[map->count])) {
            return 1;
        }
        ++map->count;
        if (map->count >= kE820MaxEntries) {
            break;
        }
    } while (continuation != 0);
    return 0;
}

循环体咱们走一遍。头一轮令牌给的是零。咱们每问成一条 count 就加一,下一问的目的地 &map->entries[map->count] 落在下一格——BIOS 不挪 DI,咱们就在 C++ 这边把索引往前顶,其实是一个意思。kE820MaxEntries 是 32 条的上限护栏:真正的硬件上,有些机器的图谱就是几十条,超了宁可截断也不能越界写。截断不报错是个坦白的取舍,低内存起步的活够用了。真不够用了,等将来消费图谱的那几站,欠的债自然会显出来。最后那句 do...while(continuation != 0) 的意思是:令牌不归零就接着问,归了零就收工。

ES 的自救,和 pushw 的宽度 ​

该还债了。E820 的目的地写在哪,BIOS 认的是 ES:DI 这对老搭档。咱们的指针活在 DS 的世界里,可 GCC 的约束系统里,段寄存器是正儿八经的编外人员:没有约束字母、也没有 clobber 声明,您没法告诉编译器“帮我把 ES 设成什么”。咱们没法指望它,只好自救:进了 asm 块,pushw %%ds 把 DS 压了栈、popw %%es 弹进 ES,两条指令就把 DS 的值抄给了 ES。这一刻 DS 为什么可信是 0?直接保证来自 stage2 自己的桩:stage2 进门的时候重铺段寄存器,DS 又亲手归了一遍零,这一抄也把 ES 归了零。MBR 铺的那一遍只是背景,撑不起直接的保证。当年笔者写这段,DS 是不可信的、ES 得手工填段值。如今整个镜像和存档都住在 1MB 以下、指针即地址,当年那几行手工填段值的代码,折成了两条抄作业的指令。

咱们再看 w 后缀。-m16 落到 .code16gcc 的语境里,您写裸的 push %ds,汇编器会自作主张地把它升成 32 位编码——压栈压 4 个字节,还附送了一条警告。咱们压栈、递参数,全按 16 位的口径来。pushw 把宽度确定在了 2 字节,反汇编里就是干干净净的 1e 07。上一站讲 -m16 的时候提过:写在 asm 字符串里的指令,宽度是自己的责任。当年 call 和 ret 宽度不一致、SP 活蹦乱跳的事故,跟这里犯的是同一种病。16 位的世界里,宽度错了一点,栈就完蛋了。

最后一条问完的时候,全图已经一条条落进了 g_memory_map——那个存档的全局变量,下一节咱们专门给它定形——落进去的还是 BIOS 的原话,一堆没定形的字节。

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