☰
Linux内核启动早期内存映射:idmap与swapper_pg_dir深度解析
2026/10/14 22:16:20 网站建设 项目流程

1. 这块“内存映射”到底在干什么

先从最直接的印象说起。一提到“内存映射”,很多人脑子里立刻跳出mmap、page table、缺页中断这些东西,觉得这是一套非常庞大、非常难啃的模块。但“idmap & swapper”这个标题点到的,其实是Linux内核启动早期、MMU(内存管理单元)刚开启前后那一段极其特殊的“过渡时代”。简单说,就是内核还没完全接管硬件、连页表都只能用临时方案撑着的阶段,我们要先把“地址翻译”这件事的基础搭起来。

搞懂这个东西,实战价值在哪?如果你写过驱动、调过启动流程、或者被“内核启动早期访问某个物理地址就死机”这种问题折磨过,你会明白:很多莫名其妙的问题,根源不在驱动逻辑,而在启动阶段的地址映射配置错了。而idmap和swapper_pg_dir正是这段时期最核心的两个主角——一个负责让MMU开启瞬间代码还能继续跑,另一个负责建立内核主页面表的最初骨架,后面所有的vmsplit、vmalloc区域、线性映射,统统从它派生出来。

这篇文章适合两类人:一是正在学操作系统、想从源码层面理解启动流程的开发者;二是做嵌入式、内核移植、或者调试启动阶段问题的工程师。我会把这套流程拆开讲清楚:为什么需要idmap、swapper和init_task是什么关系、MMU打开前后发生了什么、VMLINUX的链接脚本在这一步里扮演什么角色,以及实操中最容易踩的坑。

先说一个全篇的总纲:内核刚解压完、还没有进入C语言的世界之前,以及进入start_kernel之后非常早期的一小段时间里,系统处在一个“物理地址和虚拟地址概念开始分家”的临界点。idmap负责在分家瞬间保命,swapper负责铺开未来的虚拟地址格局。

2. 为什么非要“恒等映射”才能活过启动初期

2.1 代码还在跑,MMU却要开了,这是个死结

很多人第一次接触idmap(identity mapping)时会有个疑惑:既然内核编译链接的时候把地址放在了高地址(比如典型的PAGE_OFFSET之上),那为什么启动早期偏偏要把物理地址和虚拟地址设成相等?

这个问题的本质在于:开启MMU这个动作本身,是有“瞬时性”的。当你把SCTLR(系统控制寄存器)的M位置1的那一刹那,CPU的行为立刻改变——之前CPU认为“我取到的地址就是物理地址”,之后CPU认为“我取到的地址需要查页表翻译”。可这时候,PC寄存器里装着的还是刚才那条指令的地址。如果这个地址在页表里找不到对应项,或者找到了却不是恒等映射,CPU的下一条指令就会立刻翻译失败,触发异常甚至直接挂死。

可以把它类比成换房子的过程:你人还在老房子里,但已经请搬家公司在新地址把家具摆好了。开门进新家的那一刻,你必须保证钥匙能打开新家的大门,否则人就卡在楼道里了。idmap就是在“开门瞬间”保证“老房子地址”“新房子地址”都能通到同一个物理位置的临时通道。

现实中的ARM64、RISC-V、x86都遵循同一逻辑,只是因为架构差异实现位置不同。ARM64在head.S里创建了一份临时的、覆盖内核“当前所在物理地址”的映射;RISC-V同样在setup_vm早期搞了恒等映射;x86则通过__START_KERNEL_map和LOAD_OFFSET来早期过渡。虽然形态各异,但本质都逃不开这个“瞬间保命”的需求。

2.2 僵尸阶段的三个危险窗口

如果你仔细阅读启动代码,会发现“危险”不止出现在开启MMU那一刻。实际上有至少三个窗口期,每一个都可能让系统无声无息地死掉:

  • 窗口一:解压阶段。内核镜像可能位于内存中任意物理地址(取决于加载器把Image放在哪里),此时没有MMU或MMU处于关闭状态,所有访问都是裸物理寻址。这阶段逻辑简单,危险相对低。
  • 窗口二:开启MMU瞬间。上面说的核心窗口,idmap覆盖的范围必须确保包含当前正在执行的指令地址、栈地址、以及接下来马上要引用的数据地址。
  • 窗口三:切换到正式内核页表之前。idmap建立了临时映射后,系统并不会马上跳到高地址运行,而是先做一系列检查、配置,最后才“跳到虚拟地址版本的内核继续跑”。在这个跳转没有完成之前,临时映射依然是唯一可用的翻译路径,一旦覆盖范围算小了,直接翻车。

我自己当年调一个ARM平台时,就遇见过内核启动到开启MMU之后就完全没了打印的情况,最后定位到是idmap覆盖的物理范围不包含UART的寄存器地址——打印函数在尝试访问串口时翻译失败,不是逻辑错,是映射表没覆盖到位。这种问题不看汇编、不查映射表,你用示波器都测不出来。

2.3 一个值讲到烂也要讲:物理地址 → 虚拟地址的偏移

要理解idmap为什么是“临时方案”,先弄明白它和最终布局的关系。

内核编译链接时,默认的链接地址是虚拟地址(例如ARM64下通常以PAGE_OFFSET为基准)。实际运行时,内核被加载到某个物理地址。两者之间的差值,就叫“偏移量”(在ARM64中常说的KASLR偏移或PHYS_OFFSET)。

公式很简单:

虚拟地址 = 物理地址 + PAGE_OFFSET - PHYS_OFFSET

在idmap阶段,我们故意打破这个公式,让虚拟地址 = 物理地址。这样的好处是:在没有复杂地址计算的情况下,开启MMU后CPU还能按原地址继续执行。等到正式运行时,页表里同时保留两条映射路径:一条是idmap的“直通路径”,一条是标准内核线性映射路径。这样即使代码跳转到高地址,也依然能正常工作。

idmap的映射范围不需要很大,通常只要覆盖内核镜像本身的大小就够了(加上对齐冗余)。这一点在查看head.S中的__idmap_text区域时能看得很清楚——专门有一段代码被标记为idmap区域,链接脚本会把它集中放在一起,这样只需映射一个连续区域,而不需要映射整个内核。

3. swapper_pg_dir:内核页表的“第一块砖”

3.1 到底谁是swapper

可能会有人把swapper误认为是一个“进程切换器”或者“内存交换器”,其实在Linux的语境里,swapper是一个独特的存在:它是内核的第0号进程,也就是init_task。它是系统里所有进程的祖先,PID为0,永远存在,优先级最低,几乎不做“正经事”——当CPU没有其他任务可跑时,它会被调度出来,执行cpu_idle之类的空转逻辑。

那么这个“第0号进程”和内存映射有什么关系?答案藏在init_task的mm字段里。普通进程有自己独立的mm_struct(内存描述符),但内核线程没有“用户空间”的概念,它们的mm是空的。swapper作为内核线程的鼻祖,它的mm自然也是空。可内核需要访问物理内存、管理页表,这个职责实际上被抽象成了init_mm——一个特殊的mm_struct,它不描述任何用户态地址空间,只负责描述内核自身的页表。

init_mm.pgd(页全局目录指针)指向的正是swapper_pg_dir。

换句话说:swapper_pg_dir这个名字的由来,正是因为它是0号进程swapper的页表。它是整个内核页表的根,所有内核地址空间的映射安排,最后都要挂到这颗树上。

3.2 初始页表的规模和覆盖范围

不同架构对swapper_pg_dir的大小定义不同:

  • 在x86_64上,swapper_pg_dir通常是4KB页(一个页目录的大小),配合PGD/PMD/PUD等层级。
  • 在ARM64上,swapper_pg_dir定义为一个连续的页表区域,大小取决于页粒度(4KB/16KB/64KB)和虚拟地址位数。
  • RISC-V则根据sv39/sv48配置,大小也不同。

但共同点是:它只负责内核地址空间,不负责用户态地址。所以它的PUD/PMD中,凡是属于用户空间的条目在启动早期要么是空的,要么被设置成不可访问。

这也是为什么后来创建用户进程时,每新建一个进程都要创建一份新的页表,并把内核空间那部分给继承映射过去(或者用vma方式共享内核映射)。swapper_pg_dir是模板,不是最终成品。

从另一个角度理解:你在用户态看/proc/xxx/maps,看到的那些[heap]、[stack]、[vdso]条目,全都跟你进程自己的页表相关;而内核地址空间的固定映射、vmalloc区、线性映射区,物理页表源头都能追到swapper_pg_dir。

3.3 从head.S到start_kernel:谁在什么时候用它

这里按ARM64的典型启动流程,把swapper_pg_dir的使用过程捋一遍。先别管汇编细节,看大方向:

  1. 内核在head.S入口处,此时MMU还是关闭的(或者处于固件设置的某种状态)。
  2. 早期代码立刻初始化一个临时页表(部分架构就是直接用swapper_pg_dir的一部分作为早期页表,部分架构先建一个更小的temporary page table)。
  3. 把idmap和内核线性映射的早期条目写入页表。
  4. 设置TTBR0_EL1(或对应架构的页表基地址寄存器),指向swapper_pg_dir。
  5. 开启MMU。
  6. 跳转到虚拟地址空间继续执行。
  7. 进入start_kernel,setup_arch调用paging_init,对swapper_pg_dir做正式初始化,补齐所有区域。
  8. 从此,swapper_pg_dir成为系统内核页表的基石。

有一个常见误区是认为swapper_pg_dir是paging_init的时候才第一次被使用。其实很多架构里,head.S阶段就已经把它当作页表基地址来用了。你去看汇编代码,里面会有类似adrp指令加载swapper_pg_dir的地址,然后写入TTBR。所以它从“出生”那一刻开始就承担起了实际功能,而不是一个象征性的空壳。

4. 实操视角:搭建一个最小可复现的实验环境

4.1 实验目标与前置条件

理论讲再多,不如亲手验证一遍。下面这个实验不需要完整的发行版内核源码修改,也不用QEMU里跑全套图形界面,只需要一部能联网的Linux机器,装好交叉编译工具链(如果是ARM64目标,用aarch64-linux-gnu-gcc)和QEMU模拟器。

实验目标是:观察head.S中idmap的建立流程、swapper_pg_dir的初始化过程,并通过修改页表内容制造一个可预测的启动崩溃。这样你能真实感受到“页表配错会死成什么样”。

前置条件列表:

  • Linux主机(x86即可)
  • 交叉编译工具链:aarch64-linux-gnu-gcc
  • QEMU:qemu-system-aarch64
  • Linux内核源码(选一个你熟悉的版本,建议5.10以上)

4.2 步骤一:构建一个可启动的最小内核

首先获取内核源码,然后用默认的virt配置(QEMU arm64的通用配置)编译:

export ARCH=arm64 export CROSS_COMPILE=aarch64-linux-gnu- make defconfig make -j$(nproc) Image

这一步会生成arch/arm64/boot/Image。这个Image是一个未经压缩的内核镜像,可以直接被QEMU加载。

如果你之前没有做过ARM64内核编译,defconfig是个非常稳妥的选择——它的默认配置支持QEMU virt平台,串口输出也配好了。编译时间通常几分钟到十几分钟,取决于机器性能。

4.3 步骤二:用QEMU跑起来观察启动日志

qemu-system-aarch64 \ -machine virt \ -cpu cortex-a57 \ -m 2G \ -kernel Image \ -nographic \ -append "console=ttyAMA0"

启动后如果你能看到内核日志一路走下去,最后停在“Kernel panic - not syncing: VFS: Unable to mount root fs”,说明内核镜像本身已经能正常启动到后期,前期页表配置没有任何问题。

这一步很有意义:它证明了一个默认内核在QEMU环境下,idmap和swapper_pg_dir的初始化是成功的。接下来我们才谈得上人为制造故障。

4.4 步骤三:在启动早期插入一个故意的页表错误

这一步是加深理解的速成办法。在head.S的__create_page_tables(ARM64)或对应架构的早期建表代码里,故意修改一行:

比如把创建idmap时用来计算映射大小的adrp x0, _text改成adrp x0, _stext或者改PTE的属性位,把本该设置的可读可执行改成只读。

加入一行临时打印(或直接通过刷UART寄存器输出一个点),观察日志停在哪一步:

// 在start_kernel早期或者汇编里,刻意把一个有效页表项清零

制造故障的思路不在于“让系统崩溃”,而在于通过崩溃位置反推页表层级关系。比如你把PMD条目清掉一个,等系统访问到对应地址时,会发生translation fault,你会发现PC的值指向的内核虚拟地址,而这个虚拟地址没有对应的物理映射——至此,“虚拟地址必须经过页表翻译才能访问物理内存”这个抽象概念就变得极其具体了。

我强烈建议胆子大的朋友做这个实验。因为看十遍文档都不如亲手把一个PTE改成错误值,看着PC卡在某个地址、日志里出现一段异常向量来得深刻。

5. 链接脚本里的玄机:为什么idmap代码必须集中放

5.1 VMLINUX.lds.S对启动段落的布局要求

前面提到idmap要求映射范围尽量小。这个“尽量小”不是自然发生的,而是必须通过链接脚本的强制布局来实现,否则编译器随性把idmap相关函数散落在整个内核镜像里,那你要恒等映射的区域就等于整个内核,浪费页表条目不说,也失去“小范围保命映射”的意义。

在ARM64的arch/arm64/kernel/vmlinux.lds.S中,有专门的一段:

.text..idmap.text : { _idmap_text_start = .; *(.idmap.text) _idmap_text_end = .; }

所有显式标记为.idmap.text的section会被归拢到同一段连续区域里,链接脚本还导出_idmap_text_start和_idmap_text_end这两个符号,方便建表代码计算这段区域的起始、结束地址,进而算出需要映射的物理范围。

与此类似,.text..entry.text、.text..hyp.text等段也有类似布局需求。这也解释了为什么很多启动关键函数会带__HEAD、__INIT、__idmap_text这类section标记——它们不只是风格问题,而是实打实地影响链接后的二进制布局和页表映射范围计算。

5.2 为什么有些函数必须放在idmap段里

什么函数需要被“恒等映射”?原则很简单:凡是在MMU开启瞬间或紧接着的一小段时间里要被执行的代码,都必须保证在idmap覆盖范围内。

典型的有:

  • __enable_mmu(负责真正打开MMU的那个函数)
  • __turn_mmu_on(MMU开启后的第一个入口标签)
  • __primary_switch(切换到主内核页表并跳转到虚拟地址版本的代码)
  • 一些架构的__secondary_switch(多核启动时,从核唤醒也要走类似路径)

这些代码的特点是:它们在“物理地址世界”和“虚拟地址世界”之间横跳。如果你把__enable_mmu放到普通文本段,它被链接到高地址,那开启MMU后如果没有高地址映射,它直接无法执行。

比较反直觉的一个点是,idmap段代码不依赖任何全局变量——因为它映射后,数据访问也走同一套翻译。你在idmap函数里访问一个全局标量,这个标量如果没在idmap映射范围内,一样会挂。所以这些函数通常被写成“纯逻辑、无依赖”的形式,或者只通过寄存器传递参数。

5.3 链接脚本、符号导出、启动代码三者的配合

用一张逻辑关系图(虽然这里不能画图,但文字说清楚)来描述:

  • 链接脚本定义段的物理布局。
  • 符号(_idmap_text_start/end)把边界告诉建表代码。
  • 建表代码(__create_page_tables)用这些边界算出映射的物理页框范围。
  • 启动代码切换到新页表时,用同样的边界校验本次映射是否建立成功。

三者任何一环脱节,都会导致启动失败。比如链接脚本改了段名,但建表代码里引用的符号没同步更新,那计算出的范围就是错的,最典型的症状是“某些配置下能启动、某些配置下不能启动”——因为启动时内核加载到的物理地址刚好不同,页面错位概率就不一样。

我在实际调试过程中,遇过一个厂商内核,为了优化启动时间,把idmap段移动到了另一个位置,但忘了更新符号引用,结果QEMU下怎么跑都正常——因为QEMU的加载地址和物理布局固定;上真机就每隔几台挂一台,找了一周才发现是各平台物理内存起始地址不同导致的。这类问题最坑人,因为它不具备“必现性”。

6. 深度对比:不同架构下的“第一步”差异

6.1 ARM64:分阶段建表,TTBR0/TTBR1阴阳两隔

ARM64的虚拟地址空间被分隔成两个区域:低地址(TTBR0管理)和高地址(TTBR1管理)。内核默认使用高地址区域(PAGE_OFFSET以上),用户态使用低地址区域。

启动早期的临时页表,实际上会同时包含两种映射:

  • 低地址的idmap(恒等映射)
  • 高地址的内核线性映射

ARM64的head.S非常讲究这一点:__create_page_tables会先建立idmap,再建立内核映射,并在最后把TTBR0/EL1设置成idmap页表的基地址。

有一个容易被忽略的细节:ARM64的MMU开启依赖MAIR(Memory Attribute Indirection Register)预先配置好内存属性(如cacheable、device等)。所以建表和开MMU之间还夹着一个MAIR的初始化。如果MAIR没配好,映射关系正确也可能出现“跑得起来但极慢”“访问外设异常”等诡异问题——这就像在idmap框架下面还藏着一层属性配置,很多新手会漏掉。

6.2 RISC-V:SV39/SV48的简洁与直白

RISC-V的启动早期路径较为简洁,同样要建临时页表、映射内核、开启MMU,但它有一个独特之处:RISC-V的SATP(Supervisor Address Translation and Protection Register)切换更简单,没有ARM64那种TTBR0/TTBR1双根的设计,而是由一个根指针决定整个地址空间。

这意味着RISC-V早期要么把所有映射放进一棵页表树,要么临时切换SATP的根。实际内核代码里徘徊在setup_vm_final和各阶段临时页表之间,不过逻辑上依然是idmap先行、最终页表跟进。理解ARM64之后再去看RISC-V,会轻松很多。

6.3 x86_64:有历史包袱的经典路径

x86的启动流程最复杂,因为它要兼容16位、32位、64位等多阶段引导,并且有PAE、NX位、SMEP/SMAP等一大堆特性要考虑。但在“启动早期建立恒等映射”这个逻辑上,x86反而最明显:从实际的物理地址到虚拟地址的过渡,通过__START_KERNEL_map和__phys_addr这样的宏来回切换。

而且x86还有一个独特的“低端映射”概念:内核会把前1MB或者更小的物理内存映射到固定虚拟地址,用来兼容历史硬件访问。这种设计背景很老,但一直保留了下来。对比来看,ARM64和RISC-V因为架构年轻,反而没有这么多历史兼容包袱,流程设计得更干净。

6.4 差异总结:求同存异的一张表

对比维度ARM64RISC-Vx86_64
页表寄存器TTBR0/TTBR1SATPCR3
早期映射实现head.S中create_page_tablessetup_vm阶段早期页表startup_64及arch/x86等
idmap必要性必要必要必要
内核虚拟地址位置高地址(TTBR1)一个统一空间高地址(内核映射区)
复杂主要来源双根页表、KASLR简单直接历史兼容、多阶段引导

这张表告诉我们:架构虽有差异,但“必须活着跨过MMU开启这一关”是共性需求。把ARM64摸透,对理解其他架构有很强的迁移价值。

7. 常见故障与排查技巧实录

7.1 故障一:开启MMU后直接没有任何输出,也没有panic

这是最让人抓狂的一种:日志在“Starting kernel ...”之后就消失得无影无踪,像内核根本没被加载一样。

排查思路:

  1. 先用-d in_asm或者QEMU的trace功能,确认CPU是否真的执行到了MMU开启指令。
  2. 加上-d mmu观察TLB fill、翻译错误等事件。
  3. 如果确认执行到了__enable_mmu但之后就断了,高度怀疑idmap覆盖范围不够或PTE属性错误。

我遇到过最典型的场景是:idmap覆盖了.text区域,却漏掉了BSS区域。某条指令引用了BSS段的全局变量,翻译失败,系统直接卡死,连异常向量都没来得及进(因为异常向量表本身也在映射范围之外)。解决方案是:在核对映射范围时,除了看代码段,也看数据段和BSS段,特别是那些被__idmap_text函数引用的外部变量。

7.2 故障二:多核启动时,从核卡死在secondary_entry

ARM64多核启动时,主核会把从核唤醒,从核也会经历一遍页表切换。从核的启动地址和上下文与主核不尽相同,如果idmap的建立逻辑没有覆盖从核的入口区域,从核会被唤醒后立刻死掉,主核却一切正常。

排查方法很直接:在从核暂存寄存器写入一个特殊值,然后主核循环等待;如果等待超时,说明从核卡住了。再通过JTAG/调试器读出从核PC,就能知道它到底卡在哪条指令。

这类问题的根源,绝大多数是厂商的休眠/唤醒流程与标准内核临时页表逻辑冲突,或者早期唤醒代码没有正确执行到__secondary_switched入口。

7.3 故障三:换了内存大小或者加载地址,系统就起不来

这是最隐蔽的一类。默认配置下,KASLR或者固定的物理加载地址会让idmap刚好覆盖所有需要映射的区域。但当你把内存变大、或者把Image加载到特别高的物理地址,原本的计算方式就可能越界。

比如ARM64里如果物理地址超过一定范围,早期__create_page_tables中用来计算页表索引的移位可能算错,导致映射写进错误的层级。

这种问题最好的预防方案是:不要手动假设物理地址范围,而是依赖链接脚本和启动时传入的物理地址参数动态计算。要排查的话,先在__create_page_tables里加上映射表的dump逻辑(把页表项逐条打印出来),对比“应该映射哪些地址”和“实际映射了哪些地址”,差异一目了然。

7.4 必收的操作心得:把“一次启动”变成“可观测实验”

做这类底层调试,最忌讳的是“瞎调碰运气”。我建议从一开始就建立三个习惯:

  1. 每次修改只动一处,并记录修改前后的启动日志差异。
  2. 用QEMU做正交实验:同样的改动,分别在不同内存大小、不同CPU类型、不同加载地址下跑,看是否都稳定。
  3. 学会读异常向量表:一旦系统翻译失败,CPU会跳到异常向量表,会把PC和异常类型告诉你。不要只盯着panic日志的最后几行,前几行的ESR(Exception Syndrome Register)值才是关键信息。

ESR的值怎么解读?简单说它有EC(Exception Class)字段,比如0x20表示来自lower EL的翻译错误,0x25表示来自当前EL的翻译错误。看到EC是translation fault,再配合IPA/PC输出,就能定位到是哪一级页表缺项。

8. 从这段启动代码你能带走的深层收获

说回标题里的“第一步”三个字,它其实比很多人想象得更准确:idmap和swapper不仅是一段孤立的启动逻辑,它们是整座虚拟内存大厦的奠基石。

首先,理解idmap,你就会对“虚拟地址翻译”有本能层面的认识。很多人学了很久内存管理,张口能背页表层级,但遇到实际地址转换问题还是懵,就是因为脑子里没有一个“CPU视角”的直观画面。当你亲手看到CPU在MMU开启前后的行为差异,这个画面就永远烙在脑海里了。

其次,理解swapper,你就会对Linux的进程模型有更立体的认识。很多人在教科书上看到“0号进程是swapper”,但不知道它到底干过什么。现在你知道了:它虽不执行普通任务,但它名下挂着init_mm,而init_mm的pgd就是整个系统内核页表的根。没有这个根,系统里其他所有进程的内核空间映射都无从谈起。

第三,这段启动流程也是理解KASLR(内核地址空间布局随机化)的最佳入口。很多人看KASLR觉得只是一堆随机偏移,但搞懂启动页表后你会发现,KASLR的本质就是在idmap到最终页表切换时,往内核虚拟地址里插入一段随机偏移,并确保这段偏移下所有映射依然自洽。

最后分享一点个人感受:凡是对启动阶段页表有一定掌握的人,之后看内存管理模块会轻松非常多。因为你看的不是“某个函数实现”,而是“整套地址翻译体系的前世今生”。就好比学交通,你如果先弄明白第一条路是怎么修出来的,之后所有立交桥的设计都能看出逻辑来。

如果你正在调试启动崩溃问题,我的建议永远是:先把日志里异常类型、PC值、页表寄存器状态完整抓出来,再动手改代码。过早猜测和改动只会让问题藏得更深。带上断点、带上QEMU,一步一步看CPU眼里看到的内存世界,很多百思不解的问题往往就在某一次“哦,原来CPU是这么理解这段地址的”中迎刃而解。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询