4、参数传递的硬件基础:CPU寄存器、内存地址空间、MMU与缓存的影响
Bootloader 要把启动参数传给内核,这事听起来就是个软件协议。但你真去调一次,就会发现——卡住你的往往不是代码逻辑,而是硬件。
CPU 寄存器怎么用?内存地址空间怎么布局?MMU 开了之后地址全变了,缓存又把数据给“吞”了。这些坑,我一个都没少踩。今天咱们就把这四块硬骨头啃干净。
4.1 CPU 寄存器:参数传递的“手递手”
启动参数传递的第一站,就是 CPU 寄存器。Bootloader 和内核之间,最开始连内存都不可靠,只能靠寄存器传话。
4.1.1 通用寄存器怎么用?
以 ARM64 为例,我习惯这么分配:
| 寄存器 | 用途 | 说明 |
|---|---|---|
| x0 | Device Tree Blob 地址 | 指向内存中的 DTB 位置 |
| x1 | 内核参数结构体地址 | 早期内核用,现在多传 NULL |
| x2 | 保留 / 平台信息 | 某些架构用来传 CPU 数量 |
| x3 | 保留 / 电源管理信息 | 树莓派等平台会用到 |
我在调试某款车规芯片时,遇到过 Bootloader 把 DTB 地址放在 x1 里,内核却从 x0 读——结果系统直接挂死在解析设备树那一步。查了两天才发现是寄存器用错了。
4.1.2 特殊寄存器的作用
除了通用寄存器,还有几个特殊寄存器也参与参数传递:
- PC(程序计数器):Bootloader 跳转内核时,PC 必须指向内核入口点。偏一个字节都不行。
- SP(栈指针):内核启动初期需要栈。Bootloader 要保证 SP 指向一段可用的内存。
- SPSR(保存的程序状态寄存器):ARM 架构下,Bootloader 通过 SPSR 告诉内核当前异常级别。
嗯,这里要注意:SP 的值不能随便给。我见过有人直接把 Bootloader 的栈指针传给内核,结果内核一压栈就把 Bootloader 的数据给覆盖了——死机都找不到原因。
4.2 内存地址空间:参数放在哪?
寄存器只能传几个地址。真正的参数数据,比如 DTB、内核命令行,都得放在内存里。
4.2.1 地址空间布局
一个典型的 32 位系统,启动阶段的内存布局长这样:
0x0000_0000 - 0x0000_FFFF : Boot ROM / 安全区
0x0010_0000 - 0x0010_FFFF : Bootloader 第一阶段
0x0020_0000 - 0x0020_FFFF : 参数存储区(DTB、cmdline)
0x0100_0000 - 0x01FF_FFFF : 内核映像
0x0200_0000 - 0x0FFF_FFFF : 可用内存
为什么参数区要放在内核前面?因为内核解压时会把自己往后挪,如果参数区放在内核后面,很可能被解压过程覆盖掉。
经验之谈:我一般把参数区放在内核基址上方 2MB 的位置,留足余量。曾经有个项目为了省空间,把 DTB 紧贴内核放,结果内核版本升级后映像变大了,直接把 DTB 给吃了。
4.2.2 物理地址 vs 虚拟地址
Bootloader 阶段,MMU 通常是关闭的。这时候所有地址都是物理地址。但内核启动后第一件事就是开 MMU,地址空间瞬间“变脸”。
所以参数传递必须用物理地址。内核在开启 MMU 之前,会先把参数从物理地址拷贝到内核的虚拟地址空间里。
你想想看,如果 Bootloader 传了个虚拟地址给内核,内核一开 MMU 就去找那个地址——结果映射还没建立,直接触发缺页异常。这个坑我帮别人排查过,对方调了整整一周。
4.3 MMU:地址转换的“双刃剑”
MMU(内存管理单元)是参数传递中最容易出问题的硬件模块。它负责把虚拟地址翻译成物理地址,但启动阶段这东西开不开,差别很大。
4.3.1 MMU 关闭时的参数传递
Bootloader 阶段,MMU 通常是关的。好处是简单——所有地址都是物理地址,直来直去。坏处是内存访问没有保护,一个野指针就能把系统搞崩。
我习惯在 Bootloader 里保持 MMU 关闭,直到跳转内核前最后一刻。这样参数传递的路径最清晰,不容易出幺蛾子。
4.3.2 MMU 开启时的参数传递
有些 Bootloader(比如 UEFI)会在自己运行期间开启 MMU。这时候传参数就要小心了:
- Bootloader 看到的地址是虚拟地址
- 内核需要的是物理地址
- 必须做一次虚拟地址到物理地址的转换
4.3.3 页表的影响
MMU 开启时,页表决定了内存的映射关系。参数区所在的物理内存,必须在页表中有对应的映射。否则内核访问参数时,MMU 会报“translation fault”。
我曾经在移植 U-Boot 到新平台时,忘了给参数区建立页表映射。结果内核启动时读 DTB,直接卡死在“Unable to handle kernel paging request”。加上映射后,问题立刻消失。
4.4 缓存:看不见的数据“搬运工”
缓存(Cache)是另一个容易忽略的硬件因素。它本意是加速内存访问,但在参数传递时,它可能帮倒忙。
4.4.1 缓存一致性问题
Bootloader 把参数写入内存,然后跳转内核。内核去读同一块内存——但读到的可能是缓存里的旧数据。
为什么会这样?因为 Bootloader 写内存时,数据可能只进了缓存,还没写回物理内存。内核读的时候,如果缓存没有命中,就去物理内存读——读到的是空数据。
解决办法:在跳转内核前,必须执行缓存刷新操作。ARM 上可以用 DC CVAC 指令清数据缓存,x86 上可以用 WBINVD 指令。我习惯在跳转前加一段汇编,把参数区所在的缓存行全部刷回内存。
4.4.2 指令缓存 vs 数据缓存
缓存分两种:指令缓存(I-Cache)和数据缓存(D-Cache)。Bootloader 写参数用的是数据缓存,内核读参数可能用到指令缓存(如果参数区被映射成可执行区域)。
这种情况下,光刷数据缓存不够,还得刷指令缓存。否则内核读到的是指令缓存里的旧指令,而不是 Bootloader 刚写的新数据。
嗯,这个坑我踩过。当时在调试一个快速启动方案,Bootloader 把内核命令行写到内存,然后跳转内核。内核读命令行时总是读到乱码。查了半天,发现是 I-Cache 没刷。加上 IC IVAU 指令后,问题解决。
4.4.3 缓存行对齐
缓存操作是按“缓存行”来的。ARM 的缓存行通常是 64 字节,x86 是 64 字节,有些老架构是 32 字节。
如果参数区的起始地址没有对齐到缓存行边界,刷新缓存时可能会漏掉一部分数据,或者多刷了不该刷的区域。
我建议:参数区的起始地址强制 64 字节对齐。这样缓存操作最干净,不会出现“刷了半条缓存行”的尴尬情况。
4.5 知识体系总览
下面这张图把 CPU 寄存器、内存地址空间、MMU 和缓存的关系串起来了。你可以看到参数从 Bootloader 到内核的完整路径:
4.6 实战建议
说了这么多,总结几条我实际干活时用的原则:
- 寄存器分配写死:把寄存器用途写在代码注释里,团队所有人都遵守。我见过因为换人维护导致寄存器用法冲突的惨案。
- 参数区固定位置:在链接脚本里预留一块区域专门放参数,不挪作他用。这样不管内核怎么升级,参数区都不会被覆盖。
- 跳转前刷缓存:写一段汇编,在跳转内核前把 D-Cache 和 I-Cache 都刷一遍。别偷懒,这个步骤省不了。
- MMU 状态要明确:如果 Bootloader 开了 MMU,跳转前必须把参数地址转成物理地址。最好在文档里写明“跳转时 MMU 是开还是关”。
调试技巧:如果内核启动时参数解析失败,先别急着看代码。用 JTAG 或串口把 x0 寄存器的值打出来,看看它指向的内存区域是不是真的有数据。很多时候问题出在地址传错了,而不是参数格式错了。