Skip to content

搬进自己的栈 ​

地图确定下来了,咱们按图搬家的头一件是栈。MBR 交出来的那个,顶在 0x7C00 的地方一路向下长——严格说,它是 MBR 替自己立的栈,stage2 只是借住的客人。借住一天两天倒是可以,长住就不行了。您看,0x7C00 这个栈顶是 MBR 的遗产,图上没有它的户口,咱们不知道它往下能长多深。您再看图上,0x6000 到 0x6400 那块宅基地已经圈给了下一站的 VESA 缓冲区,咱们要是从 0x7C00 往下用栈用掉 6KB,就踩进了 0x6400 顶上分给 VESA 的缓冲区里。

所以咱们把栈搬进图上给它圈好的家:

C++
struct BootStack {
    unsigned short segments;
    unsigned short top;
};
inline constexpr BootStack kStage2Stack{.segments = 0x0000, .top = 0x7000};

咱们让新栈从 0x7000 向下长。为什么不贴着 MBR 挑个 0x7B00?这是笔者当年亲历的一回:第一遍里踩雷的是 MBR 自己的栈——栈贴着 MBR 往下长,没几层就压到了住在 0x7B00 的 DAP 参数包,BIOS 中断服务的 push 和咱们自己的 push 一来一回,把它踩了。那是 MBR 挑栈的旧案。您可能替 MBR 捏一把汗:它自己的栈也从 0x7C00 往下长,就不怕够到 VESA 的那块宅基地?倒是不怕——那块地要到下一站 stage2 请 BIOS 填 VBE 结构的时候才有住户,那时候 MBR 早就交棒退场了。咱们 stage2 要挑的栈是另一份家业,上一节那 36KB 的债也另有来历,咱们可别把它们混成一件事。

咱们挑的 0x7000,离 0x7C00 的旧栈顶隔着 3KB,向下到 VESA 缓冲区的顶 0x6400,手里也还有 3KB 的预算。借住 0x7C00 的日子,往下 6KB 才碰到 VESA 的顶。搬进自己的家,咱们的预算反而砍了一半。

预算砍半了,边界就得有人替咱们看管。layout.hpp 里的断言链干的就是这个:栈的落点对不对、有没有压着 VESA 缓冲区的顶,咱们要是画错了一格,构建当场就跟咱们翻脸。等咱们真把机器跑起来,手里的栈往下实际探多深,断言就看不住了。栈的深度咱们眼下没有仪器量,凭的只是起居的调用链本身就短。哪一站往后把调用链加深了,这 3KB 的预算就得重新审一遍。

桩,重新登场 ​

这一搬家就得动入口桩了。上一站的桩拢共三条指令,正事只有 call 一件——那是 MBR 拿 512 字节的苦换来的福利:段和栈全铺好了,stage2 落地就有家可回。现在 stage2 要自己换栈了,桩就得重新干活了。咱们看 stage2.cpp 里它的新模样:

C++
asm(".section .text.boot, \"ax\"\n"
    ".global stage2_start\n"
    "stage2_start:\n"
    "   cli\n"
    "   xor %ax, %ax\n"
    "   mov %ax, %ds\n"
    "   mov %ax, %es\n"
    "   mov %ax, %ss\n"
    "   movw $0x7000, %sp\n"  // Can we trust the new envs? better not :)
    "   cld\n"
    "   call Stage2Main\n"  // OK, call and enter
    "0: hlt\n"              // And that is always the hlt
    "   jmp 0b\n"
    ".text\n");

往下是几行老面孔:xor %ax, %ax 之后 DS、ES、SS 挨个归了零。上一站的 MBR 明明铺过一遍,这里为什么再铺一遍?因为桩是 stage2 自己的保险:上家嘴上说“都归零了”,落到咱们地界,重铺一遍不过三条指令,比赌上家的口头承诺便宜。

重铺的理由,源码里那句注释替咱们写了,态度直白:Can we trust the new envs? better not 😃。

真正的搬家动作是两条:mov %ax, %ss 和 movw $0x7000, %sp。SS 换到的是段 0,还是那个平的世界。SP 指到新的栈顶。等 C++ 进了门,Stage2Main 的头一句话就是拿新栈交作业:PutString("[stage2] stack ok\n")——这行字咱们进门的时候预告过,就是那串起居序列里 stage2 自己开口的头一条,它报告的事就一件:新栈上第一个 C++ 函数,活了下来。要说新栈的头一批住户,其实还不是 Stage2Main 自己:call Stage2Main 一执行,返回地址就已经压上了新栈。等控制流进了函数,序言里那几条保存现场的 push 才跟着压上来,咱们数它第二批。

换栈的那个窗口 ​

咱们专门拿一段来说 cli 打头,把最坏的情况摆出来:换栈是两条指令,得一起换的是 SS 和 SP。假如换到一半的节骨眼上,硬件中断来了,CPU 会立刻扔下咱们去跑处理程序。您别以为中断很稀罕,那个每秒滴答约 18 次的时钟就常来敲门。处理程序的头一件事多半是压栈保存现场,可这时候 SS 和 SP 已经一新一旧了,push 会把返回地址写到一个拼凑出来的地址上。栈上活着的数据被盖掉一个,等中断返回了,函数一层层地退出去,退到被盖掉的 ret,机器就飞了。案发地点离肇事指令隔着一次完整的中断往返,查起来可是要命的。

不过这个窗口要在什么条件下才真成立,咱们得讲清楚:要么机器没有硬件保底,要么两条指令的次序写反了、SP 动在 SS 前面。咱们脚下这块 x86 两条都不占,因为 Intel 早给这对指令留了后手:往 SS 里写了数之后,CPU 会自动屏蔽中断一拍——刚好够紧跟着的 mov %sp 也执行完。设计芯片的人知道这对指令天生成对,直接在硬件里把这个窗口上了锁。所以严格说,mov %ss 加 mov %sp 的这对组合,就算不 cli 也是原子的。您可能觉得开头那个 cli 多余了。它确实不是给这一拍准备的,更像是咱们给自己立的纪律:换栈是大事,咱们关着门办。办完之后咱们也不 sti,整个 stage2 都在关中断的状态下过日子。反正 BIOS 走的是软件中断,才不理会什么中断允许标志,咱们的起居活计一件都不耽误。

换栈窗口的四拍

顶层 asm 里手抄的数 ​

$0x7000 这个数您看着可能会问:上一节不是刚把栈的落点收进 BootStack,说好单一来源吗,桩里怎么又手抄了一份?收拢的边界就落在这里。"i" 约束能把 kStage2Spot.offset 这样的常量表达式递进函数体内的 asm,可入口桩是顶层的 asm() 字符串——站在所有函数之外,压根没有可以递的操作数槽。顶层 asm 的世界里,编译器只负责把字符串原样地交给汇编器,一个 C++ 的名字都进不去。

所以 $0x7000 注定手抄,咱们能做的是给它立一个响亮的锚点:它必须等于 layout.hpp 里的 kStage2Stack.top。您哪天动布局、给栈挪家,记得把两处一起改了。断言链管不到 asm 字符串里头的立即数,手抄的数和图上的数对不对得上,咱们只能靠人眼盯着。上一站 MBR 桩里手抄的 $0x7c00,享受的是同一待遇。

桩的尾巴还是老三样,咱们都眼熟。cld 咱们也认识,它清的是方向标志。这个标志要是被 BIOS 或者更早的代码弄脏了,后面的串指令会朝着反方向走,内存就写错边了。咱们多花一个字节,把反方向走的岔路提前堵上。call Stage2Main 开的是门,0: hlt 是配 jmp 0b 的,图的是睡得安稳。还有一件事值得咱们提一句:桩里没有 ljmp。上一站 MBR 交棒的那道远跳,自己就把 CS 定在了 0——换 CS,远跳是咱们手里最直接的手法,它顺手把归一化的活也干了。stage2 落地时 CS 已经是 0,桩里再跳一遍就属于浪费了。

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