
1. 从一次线上崩溃说起这个报错到底在说什么unable to handle page fault这行字只要你在 Linux 内核这条路上待过一段时间迟早会在dmesg、串口日志或者 panic 截屏里撞见它。它不像NULL pointer dereference那样直白也不像general protection fault那样干脆它更像一个半路翻车的信号CPU 在访问某个虚拟地址时触发了缺页异常内核的缺页处理程序跑过去一看发现这个地址既不是合法的用户态地址也不是自己管理的合法内核映射于是只能摊手报错紧接着往往就是Oops或者直接Kernel panic。我这次遇到的问题场景是一个跑在 ARM64 平台上的嵌入式设备内核版本是 5.10 的长期支持分支驱动里用了不少vmalloc系列接口来分配大块非连续内存。设备平时跑得好好的压力测试一上来或者连续运行十几个小时之后就会随机性地崩一次日志里赫然就是Unable to handle kernel paging request at virtual address ffff...后面跟着Unable to handle page fault。这种问题最恶心的地方在于它不可稳定复现堆栈每次还不太一样有时候挂在驱动自己的回调里有时候挂在内存回收路径上甚至有一次挂在了memcpy里。这篇文章我想把整个定位过程完整地摊开讲一遍。核心关键词就是unable to handle page fault、page fault、vmalloc、内核、页表这几个。我会从缺页异常的基本机制讲起然后一步步说明我是怎么从一堆看似无关的崩溃点里锁定到vmalloc区域页表同步这个根因的。适合谁看如果你正在做 Linux 内核驱动开发、嵌入式系统调试或者你手头正好有一个类似的随机崩溃查不下去那这篇内容应该能给你一条可复制的排查路径。哪怕你只是刚接触内核我也会尽量用生活化的类比把页表、缺页这些概念讲清楚让你不至于被术语劝退。先说结论方向免得你看到一半还不知道我在往哪走绝大多数unable to handle page fault的内核态崩溃本质都是CPU 拿着一个虚拟地址去查页表结果页表里没有对应的有效映射或者映射的权限不对。而vmalloc之所以容易出这类问题是因为它的映射是动态建立、动态拆除的一旦同步没做好或者生命周期管理有漏洞就会出现代码以为这块内存还在页表却已经把它划掉了的尴尬局面。2. 缺页异常与页表先把地基打牢2.1 page fault 到底是怎么被触发的要理解这个报错得先搞清楚 CPU 访问内存的流程。现代处理器都开了 MMU内存管理单元程序里用的都是虚拟地址真正落到物理内存上要经过页表翻译。页表是一棵多级树以 ARM64 为例通常是 4 级或者 5 级页表每一级负责切分地址的不同位段。CPU 每次访问内存硬件会先拿虚拟地址去查 TLB快表TLB 没命中就去查页表查到物理地址再访问。那什么时候会触发 page fault 呢简单说就是翻译失败或者权限不符。具体分几种情况这个虚拟地址压根没有建立映射页表项是空的映射建立了但权限不允许当前操作比如你往一个只读页里写页被换出去了用户态常见需要从磁盘换回来访问的地址超出了当前进程或内核允许的范围。用户态的缺页是家常便饭内核的缺页处理程序会按需分配物理页、建立映射然后让指令重新执行一遍用户完全无感。但内核态的缺页就敏感多了因为内核代码通常运行在特权级它访问的地址理论上都应该是已经建立好映射的。一旦内核态触发缺页处理程序会先判断这个地址是不是属于用户空间——如果是可能是在处理用户传进来的指针那还有救如果不是那就说明内核自己访问了一个非法地址直接判定为 bug打印unable to handle page fault然后 Oops。提示区分用户态缺页和内核态缺页是排查的第一步。日志里如果出现Unable to handle kernel paging request说明是内核态在访问如果只是page fault且发生在用户进程上下文那多半是正常的按需调页不用紧张。2.2 内核地址空间里vmalloc 站在哪个位置内核的虚拟地址空间是划分区域的以 ARM64 为例大致有这么几块直接映射区linear mapping也叫 lowmem、vmalloc 区、模块区、fixmap 区等等。直接映射区是把物理内存线性地映射过来虚拟地址和物理地址之间只差一个固定偏移访问效率最高kmalloc分配的内存基本都落在这里。而vmalloc区就不一样了。它专门用来分配物理上不连续、但虚拟地址上连续的内存。为什么需要这个因为随着系统运行物理内存会碎片化你想申请一大块连续的物理内存可能申请不到但虚拟地址空间是充裕的于是内核就允许你把一堆零散的物理页拼凑起来在 vmalloc 区给它们安排一段连续的虚拟地址。代价是每次访问都要走完整的页表翻译TLB 命中率低性能比直接映射差所以vmalloc一般只用于大块分配或者对连续性有要求但性能不敏感的场合。关键点来了vmalloc 区的页表映射是动态建立和拆除的。你调用vmalloc的时候内核会分配物理页然后修改页表把这些物理页映射到 vmalloc 区的一段虚拟地址上调用vfree的时候又要反过来把页表项清掉释放物理页。这个建立映射和拆除映射的过程就是页表同步的核心也是 bug 最容易藏身的地方。2.3 页表同步为什么这么容易出错页表同步听起来简单无非就是改几个内存里的表项但实际做起来要考虑一堆事。首先是多级页表的中间层也要分配vmalloc 区那么大不可能一开始就把所有中间页表都建好得按需分配。其次是 TLB 和 cache 的一致性你改了页表其他 CPU 核上的 TLB 可能还缓存着旧的翻译结果必须发 IPI核间中断去刷新。再者是并发多个 CPU 可能同时操作页表得有锁保护。我用一个生活化的类比来解释页表就像一本通讯录TLB 是你手机里的常用联系人快捷拨号。你改了通讯录里某个人的号码改了页表但手机快捷拨号里还是旧号码TLB 没刷新那你拨出去还是打给旧号码。所以改完通讯录必须同步更新快捷拨号这个同步动作如果漏了或者顺序错了就会出问题。在内核里vmalloc建立映射时会调用类似vmap的机制逐级填充页表最后设置叶子页表项然后刷新 TLB。vfree拆除映射时先清叶子页表项刷新 TLB再释放中间页表和物理页。如果这个顺序被打乱或者中间某一步被并发操作干扰就可能出现页表项已经清了但还有代码在访问这段地址的情况于是 page fault 就来了。3. 问题现场还原日志里藏着哪些线索3.1 崩溃日志的完整解读我先把当时最典型的一次崩溃日志贴出来做了脱敏处理地址和符号名有改动Unable to handle kernel paging request at virtual address ffff800012345678 Mem abort info: ESR 0x96000006 EC 0x25: DABT (current EL), IL 32 bits SET 0, FnV 0 EA 0, S1PTW 0 Data abort info: ISV 0, ISS 0x00000006 CM 0, WnR 0 swapper pgtable: 4k pages, 48-bit VAs, pgdp00000000abcdef00 [ffff800012345678] pgd0000000000000000, p4d0000000000000000 Internal error: Oops: 96000006 [#1] SMP CPU: 2 PID: 0 Comm: swapper/2 Tainted: G O ... PC is at my_driver_read0x8c/0x1a0 LR is at my_driver_read0x64/0x1a0 ... Call trace: my_driver_read0x8c/0x1a0 kthread_worker_fn0x... kthread0x... ret_from_fork0x...这段日志信息量其实很大我逐条拆给你看。第一行Unable to handle kernel paging request at virtual address ffff800012345678告诉我们崩溃的虚拟地址是ffff800012345678。这个地址落在内核空间的高半区从数值范围看很像是 vmalloc 区或者模块区的地址ARM64 上 vmalloc 区通常在ffff8000...这个段附近具体取决于内核配置。ESR 0x96000006是异常综合征寄存器EC 0x25表示 Data Abort也就是数据访问异常不是取指异常。ISS 0x00000006里的0x6对应的是 translation fault level 2意思是页表翻译在第 2 级就失败了说明中间某一级页表项是空的或者无效的。最关键的是这一行[ffff800012345678] pgd0000000000000000, p4d0000000000000000。pgd 和 p4d 都是 0意味着从最高级页表开始就没有有效映射。这基本坐实了这个地址在页表里根本没有建立映射不是权限问题是压根没映射。再看调用栈崩溃发生在my_driver_read这个函数里偏移0x8c。这个函数是我们驱动里一个工作线程的回调负责从一块vmalloc分配出来的缓冲区里读数据。PID: 0 Comm: swapper/2说明是在内核线程上下文不是用户进程触发的。3.2 从日志能推断出什么综合这些信息我当时的初步判断是my_driver_read访问了一块本该由vmalloc映射的缓冲区但访问的时候这块缓冲区的页表映射已经不存在了。可能的原因有几个方向缓冲区已经被vfree释放了但工作线程还在用它use-after-free缓冲区还在但页表映射被意外清掉了比如内存回收、页表操作 bug缓冲区地址本身算错了访问到了 vmalloc 区里一段从未映射过的地址。这三个方向对应完全不同的排查路径得用排除法一个个验证。我当时的做法是先在my_driver_read里加了地址打印把缓冲区的起始地址、大小、当前访问偏移都打出来同时记录vmalloc和vfree的调用时机看看能不能抓到访问发生在释放之后的证据。3.3 复现策略把随机变成必然这种随机崩溃靠等是等不来的必须主动构造压力。我做了几件事第一把工作线程的调度频率调高从原来的每秒几次改成每秒几百次让访问和释放的竞争窗口变大。第二在内存分配路径上人为制造压力用vmalloc反复申请释放大块内存逼着内核频繁操作页表。第三开了CONFIG_DEBUG_VM、CONFIG_DEBUG_PAGEALLOC这些调试选项让内核在页表操作上更敏感有问题尽早暴露。改完之后崩溃从十几个小时一次变成了几分钟一次虽然还是随机但至少能在一次调试会话里复现好几回这就有了分析的基础。4. 抽丝剥茧定位到 vmalloc 页表同步4.1 第一轮排查排除 use-after-free最先怀疑的就是 use-after-free因为这是最直观的解释。我在vfree的地方加了打印记录释放的地址和时间戳在my_driver_read里也加打印记录访问的地址和时间戳。跑了几轮之后发现一个有意思的现象崩溃时的访问地址对应的vfree调用确实发生过但时间戳显示释放发生在访问之前很久而且释放之后这块地址并没有被重新vmalloc出去。这就矛盾了。如果地址已经释放那访问它应该触发 page fault这符合现象但问题是我们的代码逻辑里工作线程在缓冲区释放前应该已经被停掉了理论上不该再访问。我去查了线程停止的逻辑发现用的是kthread_stop它会等线程函数返回。但问题在于my_driver_read里有一段代码在等一个信号量如果信号量一直不来线程会卡在那里而释放路径没有等这个信号量就直接vfree了。这是一个典型的生命周期管理漏洞释放方没有确认使用方已经完全退出就回收了资源。但奇怪的是我修复了这个同步问题之后崩溃频率确实下降了却没有完全消失。这说明 use-after-free 只是问题的一部分还有别的东西在作祟。4.2 第二轮排查页表项为什么会凭空消失既然排除了单纯的 use-after-free那就要看第二种可能缓冲区还在但页表映射没了。这个方向更隐蔽因为从代码逻辑上看缓冲区是有效的访问它天经地义但页表却不认账。我用了内核提供的vmalloc_to_page和follow_page这类接口在访问前先手动走一遍页表看看能不能查到对应的物理页。结果发现在崩溃发生前的一小段时间里页表查询是成功的但崩溃那一刻查询返回空。这就说明页表项是在运行过程中被清掉的不是一开始就没有。谁会在运行过程中清掉 vmalloc 区的页表项我梳理了几个可能的来源vfree路径但我们前面已经确认释放和访问的时序对不上内存热插拔或者内存回收这个平台没开相关特性页表操作本身的 bug比如多级页表中间层被误释放其他模块的vunmap操作误伤了这块地址。为了缩小范围我打开了内核的页表操作 tracepoint把vmap、vunmap、vmalloc、vfree这些事件的地址范围都记录下来然后和崩溃地址做比对。跑了几轮之后抓到了一个可疑的vunmap调用它的地址范围和我们崩溃的缓冲区有重叠。4.3 根因浮现地址范围计算错误导致的误伤顺着这个vunmap调用查下去发现是另一个驱动模块在做自己的内存管理时计算要释放的地址范围出了错。它本意是释放自己申请的一块 vmalloc 内存但由于一个整数溢出问题计算出的结束地址比实际大了很多结果vunmap的时候把相邻的、属于我们驱动的 vmalloc 区域的页表项也给清掉了。这就是问题的根因一个模块的页表操作越界误伤了另一个模块的 vmalloc 映射。因为 vmalloc 区的地址是全局共享的各个模块的分配在虚拟地址空间上是相邻的一旦某个模块的释放范围算错就很容易波及邻居。而 page fault 的表现又是随机的取决于被误伤的地址什么时候被访问所以定位起来特别费劲。我用一个类比来说明vmalloc 区就像一栋公寓楼每个模块租了几间房。正常情况下各回各家但如果有个租户退租时把门牌号算错了把隔壁的房间也给清空了那隔壁住户回来开门就发现房子没了。unable to handle page fault就是那个开门发现房子没了的瞬间。5. 修复方案与验证怎么让页表不再被误伤5.1 修复整数溢出从源头堵住越界根因找到之后修复其实不复杂。那个模块计算地址范围时用的是类似这样的代码unsigned int size; void *start; void *end start size; // size 是 unsigned intstart 是 64 位指针问题出在start size这个表达式上。start是 64 位指针size是 32 位无符号整数相加时size会被提升为 64 位本身没问题。但如果size在别处被赋了一个接近UINT_MAX的值或者经过了某种回绕计算那start size就可能越过预期范围。更隐蔽的是如果代码里还有减法或者取反操作32 位的回绕会直接导致结果错得离谱。修复方式是把所有涉及地址计算的变量都改成size_t或者unsigned long并且在计算前后加上范围校验size_t size; void *start; void *end; if (size MAX_ALLOWED_SIZE || check_add_overflow(start, size, end)) { pr_err(invalid range: start%p size%zu\n, start, size); return -EINVAL; }内核提供了check_add_overflow这类宏专门用来做带溢出检查的加法比自己手写判断靠谱得多。这个改动虽小但直接堵住了越界释放的源头。5.2 加固 vmalloc 使用规范几条硬性纪律修完这个 bug 之后我顺手把项目里所有用vmalloc的地方都过了一遍总结了几条纪律后来也写进了团队的编码规范地址计算一律用size_t禁止用int或unsigned int存大小和偏移释放前必须校验范围vfree的地址必须是vmalloc返回的原始地址不能是偏移过的指针生命周期用引用计数管理谁用谁加引用释放前确认引用归零跨模块共享的 vmalloc 内存要有明确的归属不能两个模块各自算各自的释放范围。这几条看起来都是常识但实际项目里踩坑往往就踩在以为不会出错的地方。尤其是地址计算32 位和 64 位混用是重灾区编译器不会报错运行时才暴露。5.3 验证压力测试跑够 72 小时修复之后怎么验证我没有只跑一遍就收工而是设计了一套组合测试第一把之前能触发崩溃的压力测试原样跑连续跑 72 小时观察是否还有unable to handle page fault。第二专门针对 vmalloc 做疲劳测试反复申请释放不同大小的内存覆盖各种边界值0、1、页大小减一、页大小、页大小加一、几 MB 等。第三打开CONFIG_DEBUG_VM和CONFIG_DEBUG_PAGEALLOC让内核在页表异常时更早报错。72 小时跑下来没有再出现崩溃dmesg里也没有任何页表相关的警告。为了进一步确认我还用vmallocinfo接口定期 dump vmalloc 区的使用情况确认没有地址范围重叠或者泄漏。到这里这个问题算是彻底闭环了。6. 常见问题速查与避坑心得6.1 page fault 排查速查表下面这张表是我这些年排查类似问题时整理的遇到unable to handle page fault可以按这个顺序过一遍现象可能原因排查手段地址在 vmalloc 区pgd 为空映射未建立或已被清除查 vfree/vunmap 调用记录比对地址范围地址在直接映射区权限错误访问了只读页或越界查 ESR 的 WnR 位确认读写方向崩溃在 memcpy/memset源或目的地址非法打印两个地址确认哪个越界随机崩溃堆栈每次不同内存被踩或页表被误改开 DEBUG_PAGEALLOC用 KASAN只在压力下崩溃并发竞争或生命周期问题加锁、引用计数、延长测试时间地址看起来像用户态指针内核误用了用户指针检查 copy_from_user 的使用这张表不是万能的但能帮你快速缩小范围避免一上来就漫无目的地翻代码。6.2 几个容易踩的坑坑一只看崩溃点不看地址归属。很多人看到崩溃在my_driver_read就死磕这个函数但其实崩溃点只是受害者真正的凶手在别处。一定要先分析崩溃地址属于哪个区域是直接映射、vmalloc 还是模块区这决定了排查方向。坑二忽略 ESR 寄存器的信息。ESR里的EC和ISS字段能告诉你异常类型和页表翻译失败的级别这些信息比堆栈还重要。比如translation fault level 2说明中间页表有问题而不是叶子页表权限问题。坑三调试选项开太少。生产内核为了性能往往关掉了各种调试选项但排查阶段一定要开CONFIG_DEBUG_VM、CONFIG_DEBUG_PAGEALLOC、CONFIG_KASAN这些它们能让隐藏的问题提前暴露省下大量猜测时间。坑四以为修了同步问题就万事大吉。我这次就是先修了 use-after-free崩溃频率降了但没消失差点以为问题解决了。实际上底层还有页表误伤的问题。所以修复后一定要跑够时间别被频率下降迷惑。6.3 我个人的几条经验第一vmalloc 相关的 bug十有八九和生命周期或地址计算有关。性能问题反而少见因为用 vmalloc 的地方本来就不追求极致性能。所以排查时优先看这两块。第二地址范围重叠是 vmalloc 区的隐形杀手。因为虚拟地址空间是全局共享的模块之间没有硬隔离一个模块的越界操作很容易伤到另一个。建议在开发阶段就用工具定期检查 vmalloc 区的使用情况别等崩溃了才查。第三日志要打全但别打太多。崩溃现场的日志越完整越好但平时运行时的日志要克制否则关键信息会被淹没。我的做法是平时只打错误级别一旦检测到异常就动态提高日志级别把上下文都抓下来。第四复现不了的问题先想办法让它变得容易复现。加压力、开调试、缩短竞争窗口这些都是把偶发变成必现的手段。能稳定复现的问题定位难度会下降一个数量级。最后再分享一个小技巧如果你怀疑是页表被误改可以在关键路径上用follow_page手动走一遍页表把每一步的页表项值打出来。虽然有点侵入性但能直观地看到页表在什么时候、被谁改动了。我这次就是靠这个手段才把怀疑范围从整个内核缩小到某个模块的 vunmap 调用。这个问题的排查前后花了我大概一周时间其中大部分时间是在和随机性作斗争。回过头看如果一开始就把地址归属分析和页表 tracepoint 用上可能两三天就能定位。所以我现在养成了一个习惯遇到内核态 page fault第一件事不是看堆栈而是看地址、看 ESR、看页表。这三样东西看明白了方向基本就对了。