资讯动态

内核启动早期内存映射:idmap与swapper_pg_dir的交接机制

发布时间:2026/10/15 11:03:51 来源:尧图企业网站定制
1. 为什么“内存映射的第一步”是让物理地址等于它自己学内存管理的人通常从进程地址空间开始学都会背“每个进程有独立的虚拟地址空间”。但很少有人认真想过一个更基础的问题进程有页表内核自己呢内核的地址翻译从一开始是怎么建立的标题里的 idmap 和 swapper一个负责在 MMU 开启的瞬间给内核“保命”一个负责为整个系统撑起后续所有地址翻译的“总地图”。这篇东西就把这两张页表的来历、内容和交接过程完整讲清楚适合对内核启动早期流程有好奇心、但没仔细读过启动代码的读者。1.1 MMU 开启不是“后续生效”而是立刻生效很多人对页表的理解停留在“设置好了之后访问内存就会自动走虚拟地址”。但在启动早期这个“之后”是不存在的——MMU 开启的瞬间翻译机制立刻接管所有取指和访存。我举个例子你就明白了。CPU 是流水线执行的它可能已经预取了好几条指令。当你执行“写系统寄存器、开启 MMU”这条指令时流水线后面已经预取的指令全部要按新的翻译规则去解释。如果当前 PC 所在的地址在新规则下找不到对应的页表项CPU 在取下一条指令时就直接触发异常整个内核还没开始跑就没。这里的关键在于开启 MMU 之前CPU 访问的一直是物理地址开启之后PC 里的数字还是原来那个数字但它的含义变了——不再代表物理地址而是要拿这个数字去查页表。数字没变规则变了这就是“立刻生效”的含义。所以内核必须提前准备一个映射让“当前 PC 正在执行的这一段物理地址”在虚拟地址空间里查得到而且查出来的结果还得恰好等于它自己。这种映射就叫恒等映射也就是标题里的 idmap。注意恒等映射不是说“不需要页表”而是“虚拟地址等于物理地址”的特殊映射。它同样要写页表项只是把物理地址原封不动地填进页表项里让翻译前后的地址数字保持一致。1.2 物理地址世界留不住内核你可能会想既然这么麻烦那干脆不开 MMU一直用物理地址跑不行吗答案是不行而且内核从编译阶段就注定了必须走虚拟地址。内核镜像在链接的时候所有符号地址都是高位的虚拟地址。函数指针、全局变量、字符串常量这些地址在编译期就写死在 ELF 文件里了。比如一个函数在链接脚本里的地址可能是0xffff800010000000这种高位值但内核镜像实际被加载到了物理内存的某个低地址位置。你不开 MMUCPU 拿0xffff800010000000去访存物理内存里根本没有这个地址直接崩。开了 MMU反而可以把这个虚拟地址映射到镜像实际所在的物理地址C 代码才能正常寻址。而且现代内核一旦进入 C 语言阶段几乎每一行代码都依赖虚拟地址访问设备树要虚拟地址写串口控制台要虚拟地址遍历内存 bank 信息也要虚拟地址。早期可以用一段纯汇编在物理地址世界做最基础的事情但只要想跳进 C 代码就必须先把页表准备好把内核要用的区域全部映射好。所以 idmap 不是锦上添花的优化是底线工程。1.3 两张页表的分工雏形搞懂了“为什么需要映射”再看标题里的两个主角就清晰了idmap在 MMU 开启前后保证 CPU 当前执行位置“物理等于虚拟”完成惊险一跳。swapper准确说是 swapper_pg_dir内核地址空间的全局页表负责内核在虚拟地址世界里的长期生存撑起线性映射区、vmalloc 区、内核镜像区等等。两者的关系可以类比成“临时栈桥”和“城市主干道”。栈桥只在施工期间使用铺好主干道之后就会被弃用主干道才是日后所有车辆内核代码、模块、驱动真正要走的路。理解了这层关系后面所有的代码和流程都顺了。2. idmap一张只为自己服务的临时页表2.1 idmap 到底映射了哪些范围idmap 的映射范围不同架构、不同版本的实现不完全一样但思路高度一致只覆盖当前马上就要用的那几块区域绝不铺张。最基本的范围是内核镜像本身也就是从镜像起始符号到结束符号这一大段。因为开启 MMU 后紧接着要执行的是内核镜像里的初始化代码这一大段必须映射。其次可能包含设备树DTB所在的区域因为早期代码要读取设备树来发现内存布局、串口信息还可能包含 initrd 所在的区域如果内核要在早期解压 rootfs 的话。关键在于“只映射够用的”。为什么不全 RAM 映射因为早期页表页是静态预留的数量极其有限。内存在启动早期还没有被内存管理器接管不能动态分配页表页所以所有页表内存都是编译期预留的一块固定空间。你想映射 4GB 内存在 4KB 页、二级页表的情况下需要百万级别的页表项这根本不现实。就算用大页映射页表页的数量也远撑不起整个物理内存。而且早期根本没有必要提前映射全部内存等内存管理器初始化完成之后线性映射区会在 swapper_pg_dir 的基础上系统性地建立起来。2.2 早期页表是“手工焊”出来的现代内核的 idmap 构建过程集中在启动汇编和一段非常早期的 C 代码里。它的特点是没有依赖任何现成的内存分配接口纯粹是把预留在 BSS 段的页表内存拿过来一个条目一个条目地手工填写。拿一个典型的 64 位架构举例构建过程大致是/* 示意代码早期手工构建 idmap 页表项 */ static void early_set_pte(pmd_t *pmd, phys_addr_t phys, pgprot_t prot) { /* 用物理地址构造页表项并附带访问权限和缓存属性 */ *pmd __pmd(phys | pgprot_val(prot)); }这里有个非常容易忽略的细节填页表项用的“物理地址”必须转换成页表项要求的位段格式并且要正确设置属性位。很多早期启动崩溃都出在这一步——地址对了属性错了。实际执行时汇编代码会先计算内核镜像的物理起始地址与虚拟地址之间的偏移然后把虚拟地址范围对应的各级页表条目填充到位。idmap 的页表基地址会被写进页表基地址寄存器。此时 CPU 还不知道什么是虚拟地址所以所有页表指针在访问时本身也要做物理地址访问。这种“页表自己也活在物理地址世界”的状态正是早期汇编代码特别绕的原因——你不能靠 MMU 翻译去访问页表因为 MMU 还没开。2.3 属性位写错会怎样这是我见过最多、最隐蔽的早期启动问题。页表项不止包含物理地址还包含一系列属性位有效位、访问权限位内核态/用户态可读可写、内存类型普通内存还是设备内存、缓存属性cacheable 还是 non-cacheable等。只要有一个关键属性不对开启 MMU 后的行为就非常诡异。我举几个典型情况页表项没有置有效位开 MMU 后第一条指令取指就翻译失败直接异常日志都打不出来。映射了代码段但忘了映射数据段代码能跑几步一旦访问全局变量就挂。设备内存被映射成 cacheable串口控制器这类外设寄存器的访问会被缓存机制搅乱表现是“偶尔能输出偶尔死循环”。CPU 缓存与页表属性不匹配现象更隐蔽可能是“跑一段时间随机 crash”因为缓存行回写和页表权限在硬件层面对不上。所以在阅读早期启动代码时关注点不要只放在“映射了哪些地址”上还要逐个核对属性位。调试这种问题时最常见的工具就是模拟器加早期串口打印。串口控制台本身就是映射在外设地址上的它的映射属性必须要是 device 类型否则打印本身就会出问题。3. swapper_pg_dir内核地址空间的“总根页表”3.1 swapper 不是 swap 的亲戚先说一个最常见的误会swapper 和 swap 分区没关系。swap 是交换空间swapper 是内核里 idle 进程的传统名字也叫 pid 0 进程。它没有普通的进程地址空间它用的就是内核自己的地址空间也就是全局页表。所以swapper_pg_dir的意思不是“用于 swap 的页表”而是“idle 进程swapper使用的全局内核页表”。这个命名确实容易误导我第一次看的时候也以为跟交换空间有关。实际上init_mm这个全局内存描述符的pgd字段就指向swapper_pg_dir。任何内核线程、任何进程在内核态工作时通过地址空间的切换最终都能访问到内核地址区域而这些内核地址区域的翻译都由 swapper_pg_dir 决定。可以说它是整个系统的“地基页表”。3.2 一张页表如何覆盖整个内核虚拟地址空间swapper_pg_dir 负责的区域是内核虚拟地址空间中属于内核的那一大半。以内核常见的 64 位地址空间布局为例可以简化成下面这样区域用途建立时机线性映射区把大部分物理内存直接映射到内核虚拟地址空间方便内核按地址访问任意物理页内存初始化时逐步建立内核镜像区映射内核代码、数据段、init 段启动早期建立vmalloc 区给 vmalloc、ioremap 等接口使用的非连续映射区域运行时动态建立模块区加载内核模块的代码和数据运行时动态建立fixmap 区固定映射区用于早期临时映射、中断描述符等启动早期建立向量表/异常入口CPU 异常向量所在区域启动早期建立这些区域不是同一时间建立的但它们的顶层目录项在 swapper_pg_dir 初始化的时候就已经预留好了位置。也就是说swapper_pg_dir 像一个总目录先给各个区域划分好“楼层”至于每个楼层内部怎么装修则是各个子系统在后面慢慢填充。这种设计很像城市总规划先确定每个功能区在哪条街具体每栋楼什么时候盖由各个部门自行推进。没有总规划后面所有区都乱套但有了总规划也不意味着所有楼在第一天就得盖好。3.3 顶层预填、底层按需页表不是一口气建完的具体到页表结构swapper_pg_dir 本身只提供最顶层的 PGD 条目。每个 PGD 条目指向下一级页表PUDPUD 指向 PMDPMD 再指向 PTE最后 PTE 指向物理页面。为了节省内存内核采用“惰性填充”策略先只把顶层条目填好或者只填当前用得到的下层条目等某个区域真正被访问时再在缺页异常或专门初始化流程中补建下层页表。有一个例子特别能说明问题线性映射区本身覆盖的物理内存范围极大但内核不会在启动的一瞬间把所有物理内存的页表全部建立。它会在内存管理子系统初始化时按内存节点node和内存区zone批量建立映射用 2MB 甚至 1GB 的大页来减少页表层级。这种大页的使用正是“顶层预填、底层按需”的必然结果——如果没有大页一张线性映射页表会吃掉巨量页表内存。所以 swapper_pg_dir 的完整状态是在整个系统启动过程中逐步逼近最终形态的。读早期启动代码时看到某个顶层目录项还是空的不要以为写错了它可能只是还没到建立的时机。4. idmap 到 swapper 的交接一次不能回头的换轨4.1 标准交接序列有两张页表还不够关键在于什么时机、以什么顺序从 idmap 换到 swapper_pg_dir。顺序一旦错了系统必挂。最典型的执行序列大致是在 idmap 区域中开启 MMU。此时 CPU 仍然在“物理地址等于虚拟地址”的区域里取指安全。执行一个长跳转把 PC 跳到内核镜像的虚拟地址。这个虚拟地址已经在 idmap或 swapper_pg_dir 中提前建立好的内核镜像映射里存在。进入虚拟地址世界后把页表基地址寄存器从 idmap 换成 swapper_pg_dir。做必要的 TLB 失效和缓存维护操作。这里面第 2、3 步的顺序是决定性的。如果你先换页表基地址、再跳转而跳转目标地址在新页表里恰好还没有建立那跳转的一瞬间就是一条不归路。反过来如果先跳转、再换页表跳转目标必须在旧页表idmap里也存在。实际实现中通常会让 idmap 把内核镜像的预期虚拟地址也一并映射这样第 2 步的时候无论 PC 落在哪个地址都能被旧页表覆盖。4.2 交接时最容易翻车的三个边界第一是跳转指令不能跨映射边界。长跳转指令本身的存放位置、目标地址所在页面都必须确保两个页面都映射了否则指令预取或目标取指会落空。第二是“新页表里必须能看到你的下一脚”。页表基地址寄存器一旦切换后续所有访存都走新页表。如果切换后的第一条指令地址在新页表中没有对应映射系统直接卡死在取指阶段连异常处理都进不去因为你根本不知道异常向量表在哪个地址。第三是缓存与 TLB 的一致性。页表属性如果前后不一致TLB 里可能残留旧的翻译结果导致切换后访问到错误的物理地址。正确的做法是切换后主动做 TLB 失效并在需要时执行数据/指令缓存维护命令。很多“换了页表就死机但代码看起来完全正确”的案例都是在这一步吃了亏。提示如果你的目标平台支持多种页大小交接时还要注意页表条目里的页面大小属性要和实际物理页面布局一致。用 4KB 页表项映射了 2MB 的物理区域MMU 的行为在多数架构上是未定义的。4.3 为什么 idmap 不能一直用下去既然 idmap 已经能把内核镜像映射好为什么还要换到 swapper_pg_dir因为 idmap 的角色太“自我”了——它只映射了启动必需的那一小块区域根本覆盖不了整个内核虚拟地址空间。想象一下如果一直用 idmap内核想访问线性映射区时没有页表项想用 vmalloc 时也没有连模块加载都做不了。而且 idmap 的地址范围通常贴近物理内存的低地址和内核编译期的高位虚拟地址布局完全不搭。内核要管理整个系统的内存就必须拥有一个覆盖面完整的全局页表这只能是 swapper_pg_dir。所以 idmap 的宿命就是“用完全身而退”。它在开启 MMU、完成地址空间切换之后基本就不再参与日常的地址翻译。有些实现会保留它用于一些特殊场景比如 CPU 热插拔早期代码、休眠唤醒的早期汇编但它已经不再是主角。5. 关于“内存映射第一步”的常见误区和排错经验5.1 三个高频误解我接触到的朋友里对 idmap 和 swapper 的理解偏差集中在三处误解正解idmap 映射的是整个内存idmap 只映射启动必需的最小区块通常就是内核镜像和设备树swapper 是 swap 的缩写swapper 是 idle 进程的名字swapper_pg_dir 是全局内核页表页表必须一次性建完顶层先建、底层按需填充是内核页表的一贯设计这三个误解不澄清后面读启动代码时很容易把自己绕进去。尤其是第一个看到 idmap 代码里只有几百行映射逻辑就觉得“这不完整”其实它本来就该不完整。5.2 早期启动挂死时的排查思路早期启动崩溃最大的问题是没有日志。串口驱动可能还没初始化打印函数本身依赖的映射可能还没建立。我的排查思路一般是这样先确认崩溃点是不是发生在 MMU 开启前后。如果是优先怀疑页表映射范围覆盖不足当前 PC 所在的地址、要访问的数据地址、栈地址这三样必须都在映射里。其次怀疑属性位尤其是设备内存的 cacheable 属性和页表项的有效位。再其次怀疑页表基地址切换顺序把“跳转”和“切换基地址”的先后关系理一遍。如果是在模拟器里调试可以在关键位置放置特殊的寄存器写操作比如往一个调试寄存器写固定值通过观察写入值判断执行到了哪里。也可以在 MMU 开启之前把串口控制器的物理地址映射到 idmap 里这样 MMU 开启后第一时间就能输出打印信息定位效率会高很多。启动汇编里最常见的“正常但不正常”的例子是这样的映射建立了属性看起来也对但就是开启 MMU 后死机。最后发现是页表基地址寄存器中的地址没有按架构要求的对齐方式对齐或者页表内存本身没有被正确清零里面残留垃圾数据。这个问题我在不同架构上都见过排查方法就是把页表内存的初始状态打印出来对着看。5.3 我的几点实操体会反复看启动代码之后我有一个很深的体会内存映射的“第一步”之所以难不是难在页表本身而是难在“你正站在物理地址世界却要为虚拟地址世界做准备”。idmap 和 swapper_pg_dir 正是在两个世界之间搭桥的机制。每次排查早期启动问题我都会强迫自己先画一张当前时刻的“地址视图”哪些地址还是物理语义哪些已经变成虚拟语义当前 PC 在哪一边访问的数据又要求哪一边。只要这张视图画对了问题就解决了一大半。另外一个很实用的习惯是在阅读早期启动代码时把 idmap 和 swapper_pg_dir 的构建代码分别标注出来观察它们各自映射了哪些区域、在什么时点被写入页表基地址寄存器。对照着看整个启动早期的内存脉络会特别清晰。以后再遇到“内核启动就挂”“跳转后跑飞”之类的问题你会比别人更快锁定方向。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑