切换序列,一步都不能错
铺垫都齐了,现在咱们把那四步一口气摆出来。咱们把整个序列安在了 pm_switch.cpp 里,做成了切换序列专用的汇编特权件:
extern "C" [[noreturn]] void EnterProtectedMode() {
asm volatile(
"cli\n"
"lgdt kGdtr\n"
"movl %%cr0, %%eax\n"
"orb $1, %%al\n"
"movl %%eax, %%cr0\n"
"ljmp $0x08, $PmEntry\n"
:
:
: "ax", "memory");
__builtin_unreachable();
}stage2 的尾部,在 VESA 的三行输出打完之后,打的最后一行字是 [stage2] leaving real mode,紧接着就调它了。咱们一步一步看,每步都不能错了位。
cli,咱们关的就是中断。这一关要关到什么时候呢?要关到新世界里段寄存器全部刷新完为止。咱们手里没有中断描述符表,一旦有异步中断闯了进来、又找不着门,等着咱们的直接就是三重故障、机器复位的下场。咱们从这句开始算,到下一节的五段刷新完,中间一秒钟都开不得的。
lgdt kGdtr,把上一节立的表告诉 CPU。这里有个非常值得回望的点:lgdt 在实模式里执行,它算操作数地址的方式,还是段左移四位拼偏移的那一套。也就是说 kGdtr 的地址要算得对,DS 的值就得保持为零才行。而当年第一遍,笔者就是在这儿崩的:DS 还带着脏值,lgdt 从错误的内存里读来六个字节的垃圾,后面一访问段就翻了车。这一遍的序列里,您找不到清 DS 的那两行——因为 stage2 从入口起 DS 就是零,这是咱们在立栈那回写下的约定,还有守在 layout 里的断言。当年的一个教训,如今长在了代码里,成了常设的法律,咱们路过它的时候连停都不用停。
接着咱们拨开关。movl 把 CR0 的值读进 EAX,orb $1, %%al 只把最低的一位置一,然后再写回去了。咱们拨的这个 bit 就是 PE(Protection Enable)。为什么这么小心地只动一位,不干脆 orl $1, %%eax 呢?在这里两者确实等价,可咱们看 CR0 里住着好些保留位,读-改-写、只碰自己认得的位数,是咱们碰控制寄存器的终身习惯——今天随手多置一位没事,明天换了个寄存器,这个习惯就是救命的那根绳了。
置完了 PE,机器名义上已经是保护模式了,可您注意,CPU 此刻用的还是实模式留下的 CS,译码走的还是 16 位。咱们要把名义变成事实,路就只有一条了:ljmp $0x08, $PmEntry。远跳带着的新选择子是 0x08,它逼着 CPU 去刚装载的 GDT 里查自己、把 CS 换成那个 32 位代码段,译码也同时切到了 32 位。Intel SDM(Intel 软件开发者手册)Vol.3A 的 §9.9.2 把这一步写成了硬性要求:进了保护模式,头一件事必须是这么一条远跳,把实模式的 CS 冲掉。要是置了 PE 却不跳,会发生什么?CS 里揣着的还是实模式留下的缓存描述符,机器名义上进了保护模式,事实上咱们还站在门槛上。眼下咱们手里这几条指令碰巧还能译对,错位要一直憋到执行流走进 -m32 世界的代码:那边整片都是 32 位的编码,而 CS 换不过去,译码宽度就对不上了,三重故障随之而来。所以置 PE 和远跳,是一对不容拆散的动作了。
所以咱们在序列末尾跟的是 __builtin_unreachable():远跳是真控制流的离开,执行流不会再回到这个函数了,函数也就标了 [[noreturn]]。序列之后意味着什么?意味着没有之后了。
远跳的身上还藏着一条硬约束,是咱们这回真编真跑才发现的。16 位世界里 GAS 生成的远跳编码是 ea off16 seg16,目标的偏移只有 16 位,所以 PmEntry 的链接地址必须落在 64KB 以内,这是咱们要记的头一条。另一条跟编码无关、跟咱们脚下的世界有关:整个 stage2 的镜像都得住在 1MB 低区,这是实模式的寻址和 BIOS 的加载路径本来就圈定的地界。咱们眼下的镜像是 0x7E00 起、12 个扇区,尾巴收在 0x9600 的地方,两条边界离咱们都还远。可这样的安稳不能靠眼下没事撑着,咱们在 layout 里给它配了断言,要求镜像的偏移加长度必须小于 0x100000。哪天 stage2 涨扇区涨过了界,构建当场就把它拦下了,而不是让它在跑起来的机器上,用三重故障跟咱们打招呼。
咱们把这几步连起来看,它们走的都是单行道:cli 之后不许有中断,lgdt 之前 DS 必须是干净的,置了 PE 之后必须紧跟远跳,远跳之后必须刷新的是段寄存器。每一步都以下一步为前提的,谁站错了位,机器就用复位回答咱们。