
1. 从一次线上告警说起这个 page fault 到底卡在哪凌晨两点多监控面板上突然跳出一台机器的异常——业务进程没挂但内核日志里开始疯狂刷屏核心报错就一行unable to handle page fault。紧接着跟着一串寄存器快照和调用栈进程直接进入 D 状态怎么都拉不起来。这种场景做过内核调试的人应该都不陌生它不像普通的空指针崩溃那样干脆利落而是带着一种我知道出事了但我不知道该找谁的模糊感。unable to handle page fault这个报错本质上是内核在缺页异常处理流程里走到了一个它无法自洽的分支。CPU 访问一个虚拟地址MMU 查页表发现映射不存在或者权限不对于是触发 page fault内核的缺页处理函数do_page_fault接手尝试去补这个映射。如果补不上或者补的过程中发现这个地址压根就不该被访问内核就会抛出这句经典的报错然后通常伴随Oops或者直接 panic。这篇文章我想聊的不是教科书上 page fault 的定义而是我实际遇到的一次定位过程。涉及的关键词包括unable to handle page fault、page fault、vmalloc、内核、页表这几个。我会把整个排查链路拆开讲从日志怎么读、寄存器怎么用、到 vmalloc 区域为什么容易出这类问题、页表项怎么手工验证最后给出一套可复现的排查步骤和避坑清单。适合有一定内核基础、正在做驱动开发或者系统稳定性排查的读者纯应用层的同学也能从中理解内核缺页处理的基本逻辑。先说结论方向这次问题的根因是vmalloc 分配的虚拟地址在页表同步环节出现了窗口期竞争导致 CPU 在页表尚未完全建立时访问了该地址。听起来有点绕往下看就清楚了。2. 先搞懂 page fault 的两种面孔用户态和内核态2.1 用户态缺页正常流程别慌用户态进程访问一个还没映射的页比如第一次读写 malloc 出来的内存触发 page fault 是完全正常的。内核的缺页处理会分配物理页、建立页表映射然后重新执行那条指令进程毫无感知。这种叫minor fault页在内存里但没映射或者major fault页在磁盘上需要换入。用户态缺页是内存懒加载机制的核心属于设计内的行为。判断依据很简单日志里如果出现的是do_user_addr_fault或者调用栈里能看到handle_mm_fault并且进程还能继续跑那基本是正常缺页不用管。2.2 内核态缺页这才是要命的内核态访问非法地址触发 page fault情况就完全不一样了。内核代码运行在特权级它访问的地址要么是直接映射区linear mapping、要么是 vmalloc 区、要么是 per-cpu 区、要么是模块加载区。这些区域的页表在内核初始化或者模块加载时就应该建立好了。如果内核在运行期访问这些地址还触发缺页说明要么这个地址根本就是野指针压根没分配过要么地址分配了但页表没同步到位要么页表被错误地修改或回收了。unable to handle page fault就是内核缺页处理函数在尝试修复失败后给出的最终判决。它后面通常会跟一段BUG: unable to handle page fault for address: ffff...这个地址是定位问题的第一把钥匙。注意内核态缺页不一定是 bug比如copy_from_user/copy_to_user访问用户地址时触发缺页是正常的内核有专门的fixup机制处理。但如果报错地址落在内核地址空间x86_64 上通常是0xffff8000_00000000以上那就要认真对待了。3. 读懂 Oops 日志寄存器快照里的破案线索3.1 关键字段逐个拆一次典型的报错日志长这样我做了脱敏和简化BUG: unable to handle page fault for address: ffffc9000a3b4000 #PF: supervisor write access in kernel mode #PF: error_code(0x0002) - not-present page PGD 0 P4D 0 Oops: 0002 [#1] SMP PTI CPU: 3 PID: 1287 Comm: kworker/3:1 Tainted: G OE RIP: 0010:my_driver_write0x4a/0x120 [my_driver] RSP: 0018:ffffb3c8c0a4fc00 EFLAGS: 00010246 RAX: ffffc9000a3b4000 RBX: ffff9d8a4c3e0000 RCX: 0000000000000000 RDX: 0000000000000000 RSI: ffffc9000a3b4000 RDI: ffff9d8a4c3e0000 CR2: ffffc9000a3b4000 CR3: 0000000104a3c002这里几个字段必须看懂for address: ffffc9000a3b4000出事的虚拟地址。ffffc900开头是 x86_64 的 vmalloc 区域典型前缀这一点非常关键。supervisor write access内核态写访问说明是内核代码在写这个地址。error_code(0x0002)错误码的 bit 1 置位表示写操作bit 0 为 0 表示页不存在not-present。所以是写一个不存在的页。CR2存放触发缺页的线性地址和上面的 address 一致互相印证。RIP出错指令地址my_driver_write0x4a直接指向我的驱动代码。RAX/RSI都等于出事的地址说明这个地址是从某个变量加载进来的。3.2 错误码的二进制含义error_code是页错误处理的核心判据每一位都有含义我整理成表格方便对照bit含义本次取值解读0P页是否存在0页不存在不是权限问题1W/R0 读 1 写1写操作触发2U/S0 内核 1 用户0内核态访问3RSVD保留位被置位0页表项本身合法4I/D取指错误0不是指令预取导致bit 0 为 0 意味着页表项里 present 位是 0也就是这个虚拟地址压根没有建立映射。结合地址落在 vmalloc 区第一反应就是这个 vmalloc 地址的页表没建好或者被提前释放了。实操心得看到error_code先看 bit 0。如果是 1present那多半是权限问题比如写只读页排查方向是页表权限位如果是 0那就是映射缺失排查方向是分配和释放的生命周期。这个判断能帮你省掉一半的弯路。4. vmalloc 为什么是这类问题的重灾区4.1 vmalloc 和 kmalloc 的本质区别要理解为什么 vmalloc 地址容易出unable to handle page fault得先搞清楚它和 kmalloc 的差异。kmalloc分配的是物理连续内存它从直接映射区linear mapping拿地址。直接映射区的特点是虚拟地址和物理地址只差一个固定偏移整个区域的页表在内核启动时就一次性建好了之后永远不会变。所以你拿到 kmalloc 返回的地址页表一定是就绪的访问它不可能因为页表没建好而缺页。vmalloc完全相反。它分配的是虚拟地址连续、物理地址可以不连续的内存。内核维护了一棵 vmalloc 虚拟地址空间的红黑树每次vmalloc调用时从 vmalloc 区域找一段空闲的虚拟地址范围逐页分配物理页可能来自不同位置逐页修改页表把虚拟地址映射到物理页刷新 TLB。关键就在第 3 步。页表修改不是原子的尤其当分配范围跨越多个页表页时中间存在一个部分映射已建立、部分还没建立的窗口期。如果在这个窗口期有另一个 CPU 访问了尚未映射的那部分地址就会触发unable to handle page fault。4.2 vmalloc 的地址特征x86_64 上 vmalloc 区域的地址范围大致是ffffc90000000000到ffffe8ffffffffff具体边界随内核配置变化。所以看到ffffc900开头的地址基本可以锁定是 vmalloc 家族包括vmalloc、vzalloc、vmalloc_user、module_alloc模块加载也用 vmalloc 区、ioremap等。这次我的驱动里用vmalloc分配了一块 DMA 缓冲区地址正是ffffc9000a3b4000。日志里RIP指向my_driver_write说明是驱动在写这块缓冲区时出的事。4.3 竞争窗口是怎么形成的我的驱动逻辑简化后是这样static void *buf; static int my_probe(struct platform_device *pdev) { buf vmalloc(BUF_SIZE); if (!buf) return -ENOMEM; /* 启动一个工作队列去填充数据 */ queue_work(my_wq, fill_work); return 0; } static void fill_work(struct work_struct *work) { /* 这里写 buf */ memset(buf, 0, BUF_SIZE); }看起来没问题对吧vmalloc返回后 buf 就是有效的。但问题出在另一个路径中断处理函数里也会写 buf而中断可能在任何时刻到来包括vmalloc内部页表还没建完的时候。具体来说vmalloc内部会调用__vmalloc_node_range它先分配虚拟地址范围然后调用__vmalloc_area_node逐页alloc_pages并map_kernel_range_noflush建立映射。如果在这期间中断触发中断处理函数拿到buf指针此时全局变量已经赋值但页表可能只建了一部分去写还没映射的那一页就炸了。这是一个典型的发布-使用顺序问题。全局指针的赋值发生在页表完全建立之前导致其他执行路径能提前看到这个指针。5. 手工验证页表用工具把真相挖出来5.1 用 crash 工具查看页表项光看日志还不够得实际验证出事的地址在页表里到底是什么状态。内核崩溃转储vmcore配合crash工具是标配。crash /usr/lib/debug/vmlinux-$(uname -r) vmcore crash vtop ffffc9000a3b4000 VIRTUAL PHYSICAL ffffc9000a3b4000 (not mapped) PAGE DIRECTORY: ffff9d8a4c3e0000 PGD: ffff9d8a4c3e0000 104a3c063 P4D: ffff9d8a4c3e0060 104a3d063 PUD: ffff9d8a4c3e1000 104a3e063 PMD: ffff9d8a4c3e2000 0vtop的输出直接告诉你走到 PMD 这一级值是 0也就是 PMD 表项为空映射根本没建立到 PTE 级别。这就实锤了页表未建立的判断。5.2 用 /proc/kcore 和 gdb 辅助如果没有 vmcore活着的系统上可以用/proc/kcore配合 gdb 看页表但需要 root 权限且内核开了CONFIG_PROC_KCORE。不过这种方式对 vmalloc 区的解析比较麻烦因为需要手动走四级页表。更实用的办法是写一个内核模块在模块里调用follow_page或者直接读init_mm的页表#include linux/mm.h #include linux/sched.h static void dump_pte(unsigned long addr) { pgd_t *pgd; p4d_t *p4d; pud_t *pud; pmd_t *pmd; pte_t *pte; pgd pgd_offset_k(addr); if (pgd_none(*pgd) || pgd_bad(*pgd)) { pr_info(PGD not present\n); return; } p4d p4d_offset(pgd, addr); pud pud_offset(p4d, addr); if (pud_none(*pud)) { pr_info(PUD not present\n); return; } pmd pmd_offset(pud, addr); if (pmd_none(*pmd)) { pr_info(PMD not present\n); return; } pte pte_offset_kernel(pmd, addr); if (pte_none(*pte)) { pr_info(PTE not present\n); return; } pr_info(PTE %llx, PFN %llx\n, (u64)pte_val(*pte), (u64)pte_pfn(*pte)); }这个模块加载后在出问题前后各调一次dump_pte就能看到页表从不存在到存在的变化过程从而确认竞争窗口。注意pgd_offset_k用于内核地址空间用户地址要用pgd_offset配合当前 mm。另外不同内核版本的页表层级宏名可能不同5 级页表下多一层 P4D写代码前先确认CONFIG_PGTABLE_LEVELS。5.3 页表项的标志位解读拿到 PTE 值后标志位也要会看。一个典型的 vmalloc 页表项PTE 8000000104a3f163拆开看bit 0 (P)1页存在bit 1 (RW)1可写bit 2 (US)0内核页bit 63 (NX)1不可执行bit 12 以上物理页帧号。如果 PTE 是 0那就是没映射如果 PTE 有值但 bit 0 是 0那是映射被标记为不存在比如被pte_clear了但没刷新 TLB。这两种情况排查方向不同。6. 定位与修复从竞争窗口到发布顺序6.1 复现问题把窗口放大竞争类问题最难的是复现。我的做法是人为放大窗口在vmalloc之后、页表完全建立之前插入一个延时同时用另一个线程高频访问 buf。buf vmalloc(BUF_SIZE); /* 调试用放大竞争窗口 */ mdelay(100); /* 正常逻辑继续 */配合CONFIG_DEBUG_VM和CONFIG_DEBUG_PAGEALLOC打开问题几乎必现。这一步的目的是确认根因不是修复。6.2 修复方案一调整发布顺序最直接的修复是先完成所有初始化再发布指针。用smp_wmb()或者干脆用锁保护static DEFINE_MUTEX(buf_lock); static void *buf; static int my_probe(struct platform_device *pdev) { void *tmp vmalloc(BUF_SIZE); if (!tmp) return -ENOMEM; memset(tmp, 0, BUF_SIZE); /* 先触碰所有页确保页表建立 */ mutex_lock(buf_lock); buf tmp; mutex_unlock(buf_lock); return 0; }关键点是memset(tmp, 0, BUF_SIZE)这一步。它会逐页写一遍强制所有页表项建立完毕。之后再把指针发布到全局变量其他路径看到 buf 时页表一定就绪。6.3 修复方案二用 kmalloc 替代如果缓冲区不大比如小于几 MB直接用kmalloc或kzalloc更省心。kmalloc 走直接映射区页表启动时就建好了不存在这个竞争窗口。代价是物理内存必须连续大块分配可能失败。buf kzalloc(BUF_SIZE, GFP_KERNEL);对于 DMA 场景如果设备支持 scatter-gather用dma_alloc_coherent或者dma_map_sg是更规范的做法它们内部会处理好页表和 cache 一致性。6.4 修复方案三加内存屏障和状态标志如果确实需要 vmalloc 的大块内存又不想在 probe 里阻塞太久可以用状态标志配合内存屏障static atomic_t buf_ready ATOMIC_INIT(0); static void *buf; static int my_probe(struct platform_device *pdev) { buf vmalloc(BUF_SIZE); if (!buf) return -ENOMEM; /* 建立所有页表映射 */ for (i 0; i BUF_SIZE; i PAGE_SIZE) WRITE_ONCE(((char *)buf)[i], 0); smp_wmb(); atomic_set(buf_ready, 1); return 0; } static void irq_handler(void) { if (!atomic_read(buf_ready)) return; smp_rmb(); /* 安全访问 buf */ }atomic_set配合smp_wmb保证当其他 CPU 看到buf_ready为 1 时页表建立的所有写操作都已经完成。6.5 验证修复修复后重新压测同时用dump_pte模块在中断里打印页表状态确认不再出现 PMD/PTE 为空的情况。连续跑 24 小时无复现基本可以收工。7. 常见问题速查与避坑清单7.1 排查速查表现象可能原因排查手段地址 ffffc900 开头error_code bit00vmalloc 页表未建立vtop 看 PMD/PTE地址 ffff9d8a 开头error_code bit00直接映射区野指针检查指针来源和生命周期error_code bit01写操作写只读页检查页表权限位和 COWRIP 在 copy_from_user用户地址非法检查用户指针范围只在多核高负载下复现竞争窗口加锁或调整发布顺序模块加载后立即崩溃模块重定位问题检查 module_alloc 区域7.2 避坑清单别在中断上下文里做 vmallocvmalloc 可能睡眠中断里调用直接 panic。用vmalloc的原子版本vmalloc_atomic也要谨慎它不保证页表立即可用。vmalloc 返回后立即触碰所有页这是最省事的预热手段能提前暴露页表问题。全局指针发布要配内存屏障单核时代的老代码经常忽略这点多核上就是随机崩溃。TLB 刷新不能省修改页表后必须flush_tlb_*否则其他 CPU 可能用旧 TLB 项访问到错误物理页。CONFIG_DEBUG_VM 是好朋友打开后内核会对页表操作做大量校验很多隐藏问题会提前暴露。别迷信地址有效指针非空不代表页表就绪vmalloc 场景下这两件事是分离的。7.3 几个容易误判的场景有一种情况是unable to handle page fault出现在copy_to_user里地址是用户地址。这通常不是内核 bug而是用户传了个坏指针。内核的fixup机制会处理但如果fixup也失败就会报这个错。排查时先看地址范围用户地址x86_64 上低于0x0000800000000000和内核地址的处理路径完全不同。还有一种是在模块卸载后访问模块数据。模块卸载时module_alloc的 vmalloc 区域被释放页表被清除但如果有残留的定时器或工作队列还在跑就会访问到已释放的地址。这种问题的特征是崩溃地址落在模块加载区且RIP指向已卸载模块的代码段。8. 我在这类问题上的几点个人体会做内核稳定性排查这些年unable to handle page fault是我见过最诚实的报错之一——它不会骗你地址、错误码、寄存器都摆在那里问题是你得会读。我的经验是遇到这类问题先别急着改代码花十分钟把日志里的每个字段都翻译一遍往往答案就浮出来了。vmalloc 相关的缺页问题九成以上和发布顺序或生命周期有关。要么是指针发布太早页表还没建好要么是内存释放太早页表已经清了但还有人用。前者靠内存屏障和预热解决后者靠引用计数和 RCU 保护。真正需要怀疑硬件或内核本身 bug 的情况极少。最后分享一个习惯我会在驱动里保留一个dump_pte的调试开关通过 debugfs 暴露出来。线上出问题时运维同学一条命令就能把关键地址的页表状态打出来比事后分析 vmcore 快得多。这个习惯帮我省过好几次通宵。