ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Linux内核unable to handle page fault排查:从vmalloc映射异常到根因定位

Linux内核unable to handle page fault排查:从vmalloc映射异常到根因定位 1. 从一次深夜告警说起unable to handle page fault 到底在说什么凌晨两点测试环境的机器突然失联串口日志里刷出最后一屏信息定格在一行刺眼的报错上unable to handle page fault for address: ffff...。这种场景做内核开发或者系统运维的人多少都遇到过它不像普通的应用崩溃那样给你一个清晰的堆栈而是直接把整个系统拖进泥潭——轻则进程被 kill重则整机 panic 重启。很多人第一次看到这个报错会懵因为page fault本身是 CPU 的一种正常异常机制缺页了补上就行为什么会出现unable to handle这种处理不了的情况要理解这个问题得先回到页表和虚拟内存的基本模型。现代操作系统给每个进程一套独立的虚拟地址空间CPU 访问的地址是虚拟地址真正落到物理内存需要经过页表翻译。当 CPU 访问一个虚拟地址硬件 MMU 查页表发现映射不存在、权限不对、或者页不在内存里就会触发 page fault 异常交给内核的缺页处理程序去补救。绝大多数情况下内核能搞定分配物理页、建立映射、从磁盘换入数据然后重新执行那条指令。但有一类 page fault内核翻遍页表也找不到合理的处理路径只能打印unable to handle page fault然后走 oops 流程。这就是我们今天要拆解的核心。这篇文章面向的是有一定 Linux 内核基础、正在做驱动开发、内核调试或者系统稳定性排查的读者。我会把这次问题定位的完整链路摊开讲从日志怎么读、现场怎么保、根因怎么一步步缩小到最终定位到 vmalloc 区域映射异常。中间会穿插大量实操细节和我自己踩过的坑尤其是那些文档里不会写、只有真机调试才会遇到的判断技巧。关键词里的 page fault、vmalloc、内核、页表会贯穿全文因为这几个概念就是解开这类问题的钥匙。需要提前说明的是内核问题定位没有银弹不同架构、不同内核版本、不同配置下表现可能完全不同。我下面讲的方法论和排查顺序是基于常见实践总结出来的通用路径具体到你的环境还需要结合实际情况调整。但思路是通的先稳住现场再从日志里榨取信息然后用假设驱动的方式逐层验证。2. 读懂 oops 日志page fault 报错里藏着哪些关键线索2.1 一行报错背后的完整信息结构很多人拿到unable to handle page fault就只盯着这一行看其实真正有价值的信息在它前后。一个典型的 page fault oops 日志长这样BUG: unable to handle page fault for address: ffffffffc0a1b000 #PF: supervisor write access in kernel mode #PF: error_code(0x0003) - not-present page PGD 0 P4D 0 Oops: 0003 [#1] SMP PTI CPU: 3 PID: 1287 Comm: my_driver Tainted: G OE 5.10.0-xxx #1 RIP: 0010:my_write0x4a/0x100 [my_module] ... Call Trace: my_ioctl0x30/0x80 [my_module] __x64_sys_ioctl0x8e/0xc0 do_syscall_640x33/0x80 entry_SYSCALL_64_after_hwframe0x44/0xa9这里每一行都是线索。for address: ffffffffc0a1b000告诉你出错的虚拟地址这个地址落在ffffffffc0000000起始的区间熟悉内核地址布局的人一眼就能认出这是vmalloc 区域在 x86_64 上vmalloc 通常从ffffc90000000000附近开始而模块加载区在ffffffffc0000000附近具体取决于内核配置。supervisor write access说明是内核态写操作触发的error_code(0x0003)是页错误错误码二进制0b011表示页不存在bit 0 0且是写操作bit 1 1且发生在内核态bit 2 0。PGD 0 P4D 0这行极其关键。它表示在页表遍历的顶层对应的 PGD 项就是空的。换句话说这个地址压根没有建立任何页表映射不是权限问题不是页被换出而是映射根本不存在。这就把问题范围大大缩小了要么是代码访问了一个从未映射的地址要么是映射被提前释放了要么是映射建立时就算错了地址。2.2 错误码是缩小范围的第一把刀页错误错误码error code是硬件压栈的每一位都有明确含义我把它整理成一张表排查时对着看能快速排除一大半可能性位含义为 0为 1bit 0页是否存在页不存在页级保护违规bit 1访问类型读操作写操作bit 2特权级内核态用户态bit 3保留位无保留位被置位bit 4指令取指数据访问指令取指拿我们这次的0x0003来说bit01、bit11、bit20翻译过来就是内核态写一个不存在的页。如果是0x0002bit00那就是读一个不存在的页如果是0x0007那就是用户态写不存在的页方向就完全不同了。我见过有人把错误码看反结果在权限检查上绕了半天其实压根是映射缺失。提示错误码的解读一定要结合PGD/P4D/PUD/PMD这几行的输出。如果顶层就是 0说明连页表都没建直接往地址是否有效、映射是否建立方向查如果顶层有值但中间层是 0那可能是映射被部分释放或者页表项被错误清零。2.3 RIP 和 Call Trace 告诉你谁在访问RIP: my_write0x4a/0x100 [my_module]直接指出出错指令在哪个函数、偏移多少。0x4a这个偏移很有用配合objdump -d反汇编模块能精确定位到是哪条指令在访问内存。Call Trace 则给出调用链帮你还原执行路径。这次是my_ioctl调到my_write说明是用户态通过 ioctl 触发的。我个人的习惯是拿到 oops 先把这几样东西抄下来出错地址、错误码、RIP 函数偏移、调用链顶层三个函数。这四样凑齐基本就能开始做假设了。剩下的CPU、PID、Comm、内核版本、Tainted标志也都是辅助信息比如Tainted: G OE里的O表示加载了外部模块E表示之前已经出过 oops这些能帮你判断环境是否干净。3. 现场保护与复现别让第二次崩溃毁掉证据3.1 为什么第一现场比什么都重要内核 oops 之后系统可能还能跑也可能直接 panic。如果配置了panic_on_oops1那机器会立刻重启日志如果没落到持久化存储就全没了。我吃过这个亏第一次崩溃没保存日志重启后想复现结果因为内存布局变化问题死活出不来。所以定位这类问题的第一条铁律是——先保现场再谈分析。保现场具体要做几件事。第一确认串口或者 netconsole 是否把完整日志输出到了另一台机器这是最可靠的因为本地磁盘 IO 在崩溃时可能不可用。第二如果系统还活着立刻dmesg /var/log/oops_$(date %s).log把环形缓冲区导出。第三记录当前的内核版本、模块版本、加载参数uname -a、cat /proc/cmdline、lsmod都存一份。第四如果条件允许把/proc/kcore或者 crash dump 抓下来后续用 crash 工具分析页表会方便很多。3.2 用 kdump 抓住崩溃瞬间的内存镜像kdump 是内核提供的崩溃转储机制原理是预留一块内存给第二个内核capture kernel主内核崩溃时由它启动并把主内核的内存镜像保存下来。配置 kdump 的步骤大致是# 1. 确认内核支持 kdump grep CONFIG_KEXEC /boot/config-$(uname -r) # 2. 预留 crashkernel 内存在 grub 里加参数 # crashkernel256M 小内存机器 # crashkernel512M 常规 # 3. 安装并启用 kdump 服务 systemctl enable kdump systemctl start kdump # 4. 触发一次测试谨慎会重启 echo c /proc/sysrq-trigger崩溃后镜像默认在/var/crash/下用crash vmlinux vmcore打开就能交互式地查页表、查符号、查每个 CPU 的寄存器。这次问题里我就是靠 crash 里的vtop命令确认了那个出错地址在 vmalloc 区域确实没有对应的页表项。3.3 复现策略控制变量比盲目重试有效复现不是简单地重复操作而是要控制变量。我一般会列一个变量清单触发操作、并发数、内存压力、模块加载顺序、内核参数。然后一次只动一个变量。这次的问题最终定位到是模块加载时 vmalloc 分配失败但代码没检查返回值导致后续访问了空指针偏移出来的地址。复现的关键变量就是内存压力——在系统内存紧张时加载模块vmalloc更容易失败问题就稳定出现。注意复现时一定要开slub_debug或者page_poison之类的调试选项虽然会拖慢系统但能让很多隐蔽的内存问题提前暴露。生产环境当然不能这么干但定位阶段值得。4. 顺着 vmalloc 往下挖映射为什么没建立起来4.1 vmalloc 和 kmalloc 的本质区别要理解这次问题必须先搞清楚 vmalloc 和 kmalloc 的区别因为很多 page fault 就出在混用或者误解上。kmalloc 分配的是物理连续的内存虚拟地址和物理地址之间是线性映射内核直接映射区分配出来的地址在ffff8880...这一带访问它几乎不会缺页因为映射早就建好了。vmalloc 则不同它分配的是虚拟地址连续、物理地址可以不连续的内存每次分配都要动态建立页表映射地址落在ffffc900...这一带。这个区别带来一个关键后果vmalloc 的映射是懒建立、可回收的。分配时建立释放时拆除。如果代码在 vmalloc 返回的指针上做了越界访问访问到了分配区域之外的地址那个地址很可能没有映射于是 page fault。如果代码在 vmalloc 失败返回 NULL后没检查就用了那访问的就是接近 0 的地址加上偏移同样会 fault。这次的问题属于后者。4.2 从出错地址反推它属于哪个区域内核虚拟地址空间是分区的不同区域有不同的映射策略。在 x86_64 上大致是这样地址范围区域映射方式ffff888000000000 起直接映射区线性映射物理内存ffffc90000000000 起vmalloc 区动态建立页表ffffffff80000000 起内核代码/模块区模块加载时映射ffff... 高地址各种特殊区域视配置而定拿到出错地址第一件事就是判断它落在哪个区。这次地址是ffffffffc0a1b000落在模块区但PGD 0说明这个模块地址的映射没建立。模块区的映射是在模块加载时由module_alloc建立的如果模块加载过程中某一步失败映射可能只建了一半。顺着这个思路我去查了模块加载的返回值处理果然发现了问题。4.3 一个容易被忽略的细节vmalloc 失败后的返回值vmalloc失败返回 NULL这是常识。但问题在于很多代码拿到 NULL 之后不是立刻返回错误而是继续往下走比如buf vmalloc(size); /* 忘记检查 buf 是否为 NULL */ memset(buf, 0, size); /* 这里就 fault 了 */memset(NULL, 0, size)访问的地址是 0 加上偏移在内核态访问低地址必然触发 page fault而且错误码通常是页不存在。但这次出错地址不是 0而是模块区的高地址说明不是简单的 NULL 解引用而是结构体指针偏移。比如dev-buf里buf是 NULL但代码访问的是dev-buf-field偏移出来就是一个非零的高地址。这种问题更隐蔽因为地址看起来像那么回事不仔细看会以为是正常的模块地址。我在 crash 里用struct命令打印了那个设备结构体确认buf字段确实是 NULL而field的偏移正好是0xa1b000减去模块基址。到这里根因就锁定了vmalloc 分配失败返回值未检查后续通过结构体偏移访问了非法地址。5. 定位这类问题的通用排查链路5.1 从日志到假设三步缩小范围我把这类 page fault 的排查总结成三步。第一步读错误码和页表输出判断是映射不存在还是权限不对。映射不存在就往地址有效性、分配失败、提前释放方向查权限不对就往写只读页、用户态访问内核页方向查。第二步看 RIP 和调用链确定是哪个函数、哪条指令在访问。第三步结合出错地址所属区域判断这个地址应该是什么和实际是什么做对比。这三步做完基本能形成一个或几个假设。比如这次假设就是vmalloc 返回 NULL 未检查。然后就是验证在代码里加日志、用 crash 查内存、或者直接代码审查。验证的过程往往比形成假设更快因为方向对了证据很快就能找到。5.2 用 crash 工具做页表和内存的交叉验证crash 是分析 vmcore 的利器几个命令特别有用# 查看出错地址的页表翻译 crash vtop ffffffffc0a1b000 # 查看某个结构体的内容 crash struct my_device ffff888012345678 # 查看符号地址 crash sym my_write # 反汇编函数 crash dis my_writevtop会告诉你这个虚拟地址对应的物理地址如果显示no mapping就坐实了映射不存在。struct能帮你确认结构体字段的值这次就是靠它确认了 NULL 指针。dis配合 RIP 偏移能精确看到是哪条指令在访问内存比如mov %rax, 0x18(%rbx)这种一看就知道是在写结构体某个字段。5.3 代码审查时重点看什么定位到函数之后代码审查要有的放矢。我一般重点看这几处分配函数的返回值有没有检查错误路径有没有正确释放资源指针在使用前有没有可能为 NULL数组访问有没有边界检查结构体字段的偏移计算有没有溢出。这次的问题就在第一点vmalloc返回值没检查。补充一句kzalloc、kmalloc、alloc_pages这些分配函数同样要检查返回值尤其是可能在大内存压力下失败的场景。提示静态分析工具如 sparse、smatch、coverity能提前发现一部分未检查返回值的问题建议在 CI 里跑起来。但工具不是万能的像结构体偏移这种间接访问工具往往看不出来还是得靠人。6. 修复方案与验证不只是加一个 if6.1 最小修复与防御性编程最直接的修复是在vmalloc之后加返回值检查buf vmalloc(size); if (!buf) { dev_err(dev, failed to allocate buffer, size%zu\n, size); return -ENOMEM; }但仅仅这样还不够。要想想为什么vmalloc会失败——是 size 太大是内存碎片还是调用频率太高导致泄漏这次排查发现模块在每次 ioctl 时都 vmalloc 一块不小的内存但错误路径下没有 vfree长时间运行后 vmalloc 区域被耗尽。所以真正的修复是两处加返回值检查以及修复错误路径的资源释放。buf vmalloc(size); if (!buf) return -ENOMEM; ret do_something(buf); if (ret) { vfree(buf); /* 错误路径必须释放 */ return ret; }6.2 验证修复压力测试比功能测试更重要修复之后怎么验证功能测试只能证明正常路径没问题证明不了错误路径被正确处理。我一般会做两件事。第一用 fault injection 强制让vmalloc失败看代码是否优雅返回而不是崩溃。Linux 内核有fail_page_alloc和should_fail机制可以配合使用# 开启 fault injection echo 1 /sys/kernel/debug/fail_page_alloc/times echo 100 /sys/kernel/debug/fail_page_alloc/probability第二做长时间压力测试反复触发分配释放观察 vmalloc 区域的使用量是否稳定。cat /proc/vmallocinfo | wc -l能看当前有多少 vmalloc 分配如果数量持续增长说明有泄漏。6.3 加监控让下次问题更早暴露修复完不是终点还得让系统具备下次出问题能更早发现的能力。我在模块里加了统计计数记录 vmalloc 成功和失败的次数通过 debugfs 暴露出来。这样一旦失败率上升监控就能告警不用等到 page fault 崩溃。另外/proc/vmallocinfo本身就能看到每个 vmalloc 分配的调用者定期采集这个信息能提前发现异常增长。监控项采集方式告警阈值建议vmalloc 失败次数模块内计数 debugfs连续 5 分钟 0vmalloc 区域使用量/proc/vmallocinfo 行数环比增长 50%模块错误日志dmesg 关键字过滤出现即告警7. 几个容易踩的坑和我的实操心得7.1 别把 page fault 和 panic 混为一谈page fault 本身不是错误是机制。只有处理不了的 page fault 才是问题。我见过新手一看到 page fault 就慌其实用户态程序天天在触发 page fault比如第一次访问 malloc 的内存只是内核默默处理了。真正要关注的是unable to handle这个前缀以及它后面跟的 oops。区分清楚这一点排查时心态会稳很多。7.2 地址对齐和偏移计算是重灾区这次问题里结构体偏移0xa1b000看起来很大一开始我以为是别的区域差点查错方向。后来用pahole工具看了结构体布局才发现那个字段确实在很靠后的位置。所以遇到大偏移不要想当然用工具确认结构体布局。pahole能显示每个字段的偏移和大小对分析这类问题帮助很大。pahole -C my_device my_module.ko7.3 内核版本和配置差异会让同样的代码表现不同同一个模块在 5.10 上崩在 6.1 上可能不崩因为 vmalloc 的实现、内存布局、甚至结构体对齐都可能变了。所以定位问题时一定要记录清楚内核版本和关键配置CONFIG_VMAP_STACK、CONFIG_RANDOMIZE_BASE等。我习惯在模块加载时打印一行版本和配置摘要方便回溯。7.4 串口日志的可靠性高于一切最后再强调一次现场保护。图形界面、SSH 在崩溃时都可能断只有串口和 netconsole 最可靠。如果做内核开发强烈建议常备一根串口线或者配好 netconsole。这次能快速定位很大程度上是因为串口把完整的 oops 日志输出到了另一台机器一个字都没丢。# netconsole 配置示例在目标机执行 modprobe netconsole netconsole6666192.168.1.10/eth0,6666192.168.1.20/00:11:22:33:44:55这套流程走下来从看到unable to handle page fault到定位到 vmalloc 返回值未检查前后花了大概半天。核心不是某个工具多厉害而是排查顺序对了先保现场再读日志用错误码和页表输出缩小范围然后用假设驱动的方式逐层验证。希望这次的经验能给遇到类似问题的朋友一点参考少走点弯路。
返回列表