一根地址线的老故事
栈立好了,第二件事咱们来开 A20。A20 这门眼下是关着的,咱们的日子也照过:stage2 自己的代码、栈、存档,咱们把它们全放在了 1MB 以下,A20 开了也罢锁了也罢,咱们一步都摸不着它。可咱们等会儿问回来的内存图谱会明明白白告诉您:机器的主存从 1MB 起,头一个地址 0x100000 的 bit 20 正好是 1。A20 这根线要是锁着的,硬件会把地址里的 bit 20 强行摁成 0。0x100000 就地折成了 0x000000,一发命中了中断向量表。而且这门摁的只是 bit 20 一位,偶数兆的地址 bit 20 本来就是 0,纹丝不动地躺着。奇数兆被整体拉低了一兆,0x300000 折到了 0x200000,还悬在了 1MB 之上。可主存偏偏从 0x100000 起,它是头一个奇数兆的起点:内核眼下定下的加载点 0x20000 只是中转站,它最终的居所在 1MB 起的主存里,到时候往奇数兆写的数据,会落进低一兆的地方,悄无声息地串了门。所以这门迟早要开,而开它的三样工具全是实模式时代的遗产,过了这个村,就没这个店了。
回卷:一个被惯出来的短处
这段故事咱们得从 8086 讲起。它有 20 根地址线,A0 到 A19 能编 1MB 的址。可它的段式寻址偏偏能算出比 1MB 更大的数:段左移四位加偏移,最大能到的就是 0xFFFF0 + 0xFFFF,比 1MB 还多出了差不多 64KB。多出来的部分怎么办?没有第二十根线,bit 20 以及更高的位直接从地址总线上掉了下去,硬件视而不见。咱们拿实打实的数算一遍:F800:8000,段 0xF800 往左挪四位就成了 0xF8000,偏移 0x8000 也加上了,得的就是正好 1MB 的 0x100000。它的 bit 20 是 1,可总线上偏偏没有这根线,于是地址绕回了 0x00000。这就是回卷的来历。
这段故事咱们配了一段动画:锁着的门把 0x100000 折回 0,开着的门放它直达主存。您看的时候,只看 bit 20 那根线的下场就行:
按理说这是硬件的短处,咱们躲着走就是了。偏偏当年有一批程序员拿它当特性用:数据放在低地址,故意用高段的地址回卷着访问。这招在 8086 的年代无伤大雅,机器真的会绕。可 80286 来了,地址线加到 24 根,能编 16MB 的址,0x100000 不再绕了,那批程序到了新机器上集体翻车。
门,搭在键盘控制器上
IBM 的 AT 机型要兼容这批老程序,给出的方案堪称硬件史上的名场面:既然 8086 的回卷来自“bit 20 不存在”,那就在 80286 上人为地让 bit 20“可以不存在”。他们在主板上加了一个门,默认是关着的,把第 21 根地址线 A20 摁在地上。要访问 1MB 以上的内存就得把门打开。这个门装在哪?您猜——装在 8042 键盘控制器的一根空闲引脚上。键盘控制器和地址线,八竿子打不着的两样东西,可在那个芯片引脚金贵的年代,空闲引脚的浪费就是罪过。后来的 486 把这套逻辑收进了 CPU 自己,留出了一根 A20M 引脚在外头控制。“开机后开 A20”这件杂活倒是留了下来,一留就留了四十多年,如今的 bootloader 每天还得伺候它。
三条开门的路
开门的路子,社区里流传的有三条,咱们挨个看。
头一条的路数最正宗:直接跟 8042 键盘控制器打交道,往 0x64 端口发了一串命令,禁了键盘、读输出口、改 bit 1、写回去、再开键盘。每一步之间还得轮询等它把手头的事忙完。能用倒是能用,可您数数这一长串端口舞步,少等一步都可能招来一个不该有的键盘中断。
再一条走的是快路,走芯片组的后门:PS/2 之后的很多机器把 A20 接在端口 0x92 的 bit 1 上,in、or、out 三条指令就完事了。听着当然痛快,可它偏偏没有准话:有些机器根本没实现,咱们写了也是白写。更吓人的是,少数机型上这个端口干着别的事,比如把屏幕清了。它的 bit 0 还接着快速复位,您手一抖,机器当场就重启了。
最省事的一条,请 BIOS 代劳:九十年代以降的 BIOS 在 INT 15h 上挂了个服务,AX=0x2401,一句话的事。它边上还有查状态的 AX=0x2402、查支持的 AX=0x2403,兄弟三个凑成了一桌。模拟器——包括咱们的 QEMU——通常一开机就把门替您开好了,所以咱们这一步多半是例行公事:再开一次不收费,咱们照样把序列走通。
OSDev 的 A20 页给的建议是全家桶:测 A20 开了没,没开的请 BIOS 出面再测一道。不行的换键盘控制器再测一道。最后的招数是连 0x92 一起试了再测一道,全不行了才认栽。对着兼容机的时代,这么谨慎是对的——三条路各有覆盖不到的机型,所以连锁下去才稳。可笔者给这个项目拍板的时候,目标机器从头定下的就是 QEMU,配的固件是 SeaBIOS,它的 0x2401 年年兑现,当年第一遍也是请 BIOS 开的。给一台跑着 SeaBIOS 的机器配三级兜底,那三段代码咱们永远跑不到、也验不了,白占镜像里的地盘。
咱们的一路
bool EnableA20AddressLine() {
static constexpr unsigned short kEnableA20Magic = 0x2401;
unsigned short register_ax = kEnableA20Magic;
unsigned char enable_a20_ok;
asm volatile(
"int $0x15\n"
"setnc %1"
: "+a"(register_ax), "=q"(enable_a20_ok)
:
: "memory");
return enable_a20_ok != 0;
}咱们逐件认。AX 进去的是功能号 0x2401。"+a" 读写约束是上一站的老朋友,BIOS 用完了可能把 EAX 弄脏,得告诉编译器:出来的是新值。setnc 是新面孔——它解决一个很实际的问题:BIOS 把成败写在进位标志 CF 上,可 C++ 的代码没有哪行能直接读标志位。setnc、setc 这类按条件置位的指令就是干这个的,条件一成立就把 1 写进一个普通的操作数。setnc 的意思是“CF 为 0 则置 1”,正好是 2401 的成功语义。"=q" 约束说的是:这个字节操作数随便挑一个字节寄存器装。等会儿问内存的 E820 那边,咱们还会见到它的镜像 setc——同一种按条件置位的指令,只不过盯的是失败,盯 CF 拉起来的那一下。
失败的路呢?咱们在 Stage2Main 里只安排了一句:
if (!EnableA20AddressLine()) {
fail("A20");
}fail 是 stage2 自己的错误通道:把 A20 FAILED 打到 debugcon,然后靠 hlt 永远地睡下去。这是 MBR 立过的老传统——boot 的世界里没有重试、没有退路,出了事就把遗言喊响亮,停下来等咱们提着终端赶到。门要是真开不了,咱们看见的是清清楚楚一行 A20 FAILED,而不是机器无声飞走。
门开了。可还没有人告诉过咱们:机器到底有多少内存、哪些能用、哪些碰不得。