ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux内核Unable to handle page fault定位:vmalloc释放后访问与竞态条件分析

Linux内核Unable to handle page fault定位:vmalloc释放后访问与竞态条件分析 1. 问题现场还原与初步判断1.1 从一行内核报错说起那是一个再普通不过的下午测试同学在群里甩了一张截图上面赫然写着Unable to handle kernel paging request at virtual address ffff800012345678紧接着下面还有一行更让人心里一紧的Unable to handle page fault for address: ffff800012345678做过内核开发的人看到这两行字血压基本都会往上走一点。因为它不像普通的空指针解引用那样直白Unable to handle page fault意味着 CPU 在访问某个虚拟地址时触发了缺页异常而内核的缺页处理程序在尝试修复这个异常时发现这个地址既不在合法的用户空间映射里也不在当前进程的内核页表映射范围内最终只能选择把进程干掉然后打印出这一堆让人头大的寄存器信息和调用栈。我当时的第一个反应是这大概率不是用户态程序的问题而是内核态某段代码访问了一个已经失效或者压根没建立映射的虚拟地址。但具体是哪个模块、哪一行代码、什么条件下触发的光看这一行报错是远远不够的。1.2 为什么这类问题特别难缠Unable to handle page fault这类问题之所以让人头疼核心原因有三点。第一它的触发条件往往很隐蔽。可能是某个驱动在初始化时申请了一块vmalloc内存结果在释放之后又被另一段代码访问了也可能是某个内核线程在特定的内存压力下才走到那条异常路径。换句话说它不一定每次都能复现有时候跑一整天没事有时候一压测就崩。第二它的现场信息虽然多但解读门槛高。内核在崩溃时会打印一大堆寄存器状态、页表项、调用栈但这些信息是给懂行的人看的。如果你不熟悉 ARM64 或者 x86 的页表结构不熟悉vmalloc区域的地址分布那这些信息在你眼里就是一串十六进制数字。第三它涉及的知识面很广。要定位这类问题你得同时具备内存管理、页表映射、驱动模型、内核调试工具链这几块的知识。缺了任何一块排查过程都会变得像盲人摸象。1.3 本文的定位与适用读者这篇文章不是一篇教科书式的内存管理原理讲解也不是一份泛泛而谈的调试指南。它是我在实际项目中遇到的一次真实问题的完整定位记录从看到报错到最终找到根因中间踩过的坑、用过的工具、走过的弯路我都会尽量还原出来。如果你正在被类似的内核崩溃问题困扰或者你是一名驱动开发工程师、内核爱好者、嵌入式系统开发者希望提升自己定位内核问题的能力那这篇文章应该能给你一些直接的参考。我会尽量把每一步的操作意图和背后的原理都讲清楚让你不仅知道怎么做还知道为什么这么做。2. 核心概念快速梳理page fault、vmalloc 与页表2.1 page fault 到底是什么page fault中文通常叫缺页异常是 CPU 在访问虚拟内存时触发的一种异常。它的本质是CPU 通过 MMU 把虚拟地址翻译成物理地址时发现当前页表里没有这个虚拟地址对应的有效映射或者映射存在但权限不匹配于是硬件就抛出一个异常让操作系统来决定怎么处理。这里有一个很多人容易混淆的点page fault 不一定是错误。在正常的虚拟内存管理里page fault 是非常常见的。比如你malloc了一块内存但操作系统并没有立刻给你分配物理页而是等到你真正读写的时候才通过 page fault 来按需分配。这种叫“合法缺页”内核的缺页处理程序会默默地帮你把物理页补上程序完全感知不到。但Unable to handle page fault就不一样了。它意味着内核的缺页处理程序在尝试处理这个异常时发现这个地址既不是合法的用户空间地址也不是内核已知的合法映射区域换句话说内核也不知道这个地址该映射到哪里。这时候内核只能选择终止当前进程并打印出详细的错误信息。2.2 vmalloc 区域的特殊性在 Linux 内核里内存分配主要有几套机制kmalloc、vmalloc、kmem_cache、alloc_pages等等。其中vmalloc是比较特殊的一个。kmalloc分配的内存在物理上是连续的虚拟地址上也是连续的它直接映射在内核的线性映射区也叫直接映射区。而vmalloc分配的内存在虚拟地址上是连续的但物理地址不一定连续。它需要内核专门建立页表映射把那些物理上分散的页拼凑成一段虚拟地址连续的区间。这个区别带来一个很重要的后果vmalloc区域的地址不能通过简单的偏移计算得到物理地址必须走完整的页表翻译流程。而且vmalloc区域的页表是在分配时才建立的释放时会被拆除。如果某段代码在vmalloc内存释放之后还去访问对应的虚拟地址就会触发Unable to handle page fault。另外vmalloc区域的地址范围在不同架构上是不一样的。以 ARM64 为例vmalloc区域通常位于0xffff800000000000到0xffffffffffffffff之间的某个子区间具体范围取决于内核配置。这也是为什么在报错信息里看到ffff8000开头的地址时我会第一时间怀疑是不是和vmalloc有关。2.3 页表在地址翻译中的角色页表是 CPU 用来做虚拟地址到物理地址翻译的数据结构。在 ARM64 上页表通常有四级PGD、PUD、PMD、PTE。每一级页表项里都记录了下一级页表的物理地址以及一些权限和属性位。最后一级 PTE 里记录了最终的物理页帧号。当 CPU 访问一个虚拟地址时MMU 会依次查这四级页表找到对应的 PTE然后取出物理页帧号加上页内偏移得到物理地址。如果中间任何一级页表项无效或者权限不匹配就会触发 page fault。在内核崩溃时内核会打印出出错的虚拟地址以及相关的页表项信息。如果你能读懂这些页表项就能判断出这个地址到底有没有建立映射、映射到了哪里、权限是什么。这对于定位问题非常关键。3. 信息收集从崩溃日志里挖出关键线索3.1 完整日志的获取方式很多人看到崩溃日志第一反应是看调用栈。但调用栈只是冰山一角真正有价值的信息往往藏在那些看起来像天书的寄存器 dump 和页表信息里。要获取完整的崩溃日志有几个途径。如果系统配置了kdump那崩溃时会产生一个vmcore文件可以用crash工具去分析。如果没有kdump那就只能靠串口控制台或者pstore来抓取。我这次的环境是嵌入式 ARM64 平台没有配置kdump所以只能通过串口把完整的启动日志和崩溃日志都抓下来。这里有一个实操心得串口日志一定要设置足够大的缓冲区并且要确保日志级别足够高。有时候默认的日志级别会把一些关键信息过滤掉导致你拿到的日志是不完整的。我一般会在启动参数里加上loglevel8和ignore_loglevel确保所有级别的日志都能打印出来。3.2 关键字段解读拿到完整日志之后我一般会按下面的顺序去读。首先是出错地址。日志里会有一行类似Unable to handle kernel paging request at virtual address ffff800012345678这个地址就是触发异常的虚拟地址。记住这个地址后面所有的分析都围绕它展开。然后是 ESR 寄存器。ESR 是 Exception Syndrome Register它记录了触发异常的具体原因。比如在 ARM64 上ESR 的 bit[31:26] 是 EC 字段表示异常类别。如果 EC 的值是0x25表示这是数据中止异常如果是0x21表示指令中止异常。bit[24:11] 是 ISS 字段里面包含了具体的错误码比如是翻译错误还是权限错误。接着是 FAR 寄存器。FAR 是 Fault Address Register它记录了触发异常的虚拟地址。这个地址应该和日志里打印的地址一致可以用来交叉验证。再往下是页表项信息。内核会打印出出错地址对应的各级页表项比如 PGD、PUD、PMD、PTE 的值。这些值能告诉你这个地址有没有建立映射映射到了哪里。最后是调用栈。调用栈能告诉你崩溃发生时内核正在执行哪段代码这是定位问题的直接线索。3.3 我实际拿到的日志片段为了让你有更直观的感受我把当时日志里最关键的一段贴出来地址做了脱敏处理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 FSC 0x06: level 2 translation fault Data abort info: ISV 0, ISS 0x00000006 CM 0, WnR 0 swapper pgtable: 4k pages, 48-bit VAs, pgdp00000000a1b2c000 [ffff800012345678] pgd00000000a1b2d000, p4d00000000a1b2d000, pud00000000a1b2e000, pmd0000000000000000 Internal error: Oops: 96000006 [#1] SMP这段日志里有几个信息非常关键。ESR 0x96000006其中 EC 字段是0x25表示数据中止。FSC 字段是0x06表示 level 2 translation fault。翻译一下就是在查第二级页表PMD的时候发现页表项无效导致翻译失败。pgd00000000a1b2d000, p4d00000000a1b2d000, pud00000000a1b2e000, pmd0000000000000000这一行说明 PGD、P4D、PUD 都是有效的但 PMD 是 0也就是空的。这意味着这个地址在 PUD 这一级还有映射但到了 PMD 这一级就断了。这基本上就坐实了我的猜测这个地址属于某个vmalloc区域但对应的页表映射已经被拆除了或者压根就没建立完整。4. 定位思路与工具链选择4.1 先判断地址属性拿到出错地址之后第一步是判断这个地址属于哪个区域。内核的虚拟地址空间是有明确划分的不同区域有不同的用途。在 ARM64 上常见的区域包括用户空间0x0000000000000000到0x0000ffffffffffff内核线性映射区0xffff000000000000到0xffff7fffffffffffvmalloc 区0xffff800000000000到0xffffffffffffffff的某个子区间模块区通常在 vmalloc 区附近我拿到的出错地址是ffff800012345678落在0xffff8000开头的范围里这基本上可以确定是 vmalloc 区域。这个判断很重要因为它直接决定了后续的排查方向。4.2 工具链准备定位这类问题光靠看日志是不够的还需要一些工具来辅助。我这次用到的工具主要有这几个。crash工具如果有 vmcore 的话可以用它来分析内核内存、页表、调用栈。可惜这次没有 vmcore所以只能靠日志。gdb配合vmlinux可以用来反汇编内核代码查看某个函数的汇编实现确认编译器有没有做什么优化导致意外的内存访问。objdump和readelf用来查看内核镜像的段布局和符号表帮助确认某个地址属于哪个段。pahole用来查看内核结构体的布局有时候结构体成员偏移不对也会导致访问到非法地址。另外如果条件允许我会在代码里加一些dump_stack()或者printk来打印关键变量的值。但这次问题是在压力测试下才复现的加打印可能会改变时序所以我没有一开始就这么做。4.3 定位策略从调用栈反推在没有 vmcore 的情况下调用栈是最直接的线索。我当时的调用栈大概是这样的函数名做了简化Call trace: some_driver_write0x1a4/0x2c0 vfs_write0x168/0x2a0 ksys_write0x74/0xc0 __arm64_sys_write0x24/0x40 el0_svc_common0x98/0x120 el0_svc_handler0x20/0x40 el0_svc0x8/0xc调用栈显示崩溃发生在some_driver_write这个函数里。这是一个字符设备驱动的写操作。这就把范围缩小到了这个驱动的 write 路径上。接下来我要做的就是找到some_driver_write的源码看看它在偏移0x1a4附近做了什么操作特别是涉及内存访问的操作。5. 深入分析从调用栈到根因5.1 反汇编定位具体指令有了调用栈里的偏移0x1a4/0x2c0我就可以用objdump或者gdb来反汇编some_driver_write这个函数看看偏移0x1a4处到底是哪条指令。操作命令大概是这样aarch64-linux-gnu-objdump -d vmlinux --start-address0xffff800010000000 --stop-address0xffff800010000200当然实际使用时需要先通过nm或者readelf找到some_driver_write的起始地址然后加上偏移。反汇编出来之后我看到偏移0x1a4处是一条ldr指令也就是从内存里加载数据。ldr x0, [x1, #0x18]这条指令的意思是把x1 0x18这个地址里的值加载到x0寄存器。而x1里存的根据前面的指令推断是一个结构体指针。也就是说驱动在访问某个结构体的成员偏移是0x18。5.2 结构体成员偏移核对接下来我要确认的是这个结构体是什么类型偏移0x18对应的是哪个成员。我用pahole查看了相关结构体的布局。假设这个结构体叫struct my_dev那pahole的输出大概是这样struct my_dev { struct device *dev; /* 0x00 */ struct cdev cdev; /* 0x08 */ void *buffer; /* 0x18 */ size_t buffer_size; /* 0x20 */ ... };偏移0x18对应的是buffer成员。也就是说驱动在 write 路径里访问了my_dev-buffer这个指针所指向的内存。到这里问题的轮廓就清晰了my_dev-buffer指向的是一块vmalloc分配的内存但在某个时刻这块内存被释放了而驱动还在继续访问它。5.3 释放路径的排查接下来要回答的问题是这块vmalloc内存是在哪里被释放的为什么释放之后还有代码在访问它。我在驱动代码里搜索vfree的调用找到了释放buffer的地方。释放逻辑大概是在设备的 release 回调里static int my_dev_release(struct inode *inode, struct file *filp) { struct my_dev *dev filp-private_data; if (dev-buffer) { vfree(dev-buffer); dev-buffer NULL; } return 0; }看起来释放之后把buffer置成了NULL这应该没问题才对。但问题是在多线程或者多进程的场景下一个线程正在 write 路径里访问buffer另一个线程调用了 release 把buffer释放了这时候就会出现 use-after-free。而且更隐蔽的是即使buffer被置成了NULL正在执行的 write 路径可能已经把buffer的值加载到了寄存器里后续访问用的是寄存器里的旧值而不是重新从内存里读。这就是典型的竞态条件。5.4 竞态条件的确认为了确认这个猜测我仔细检查了驱动的并发控制逻辑。发现这个驱动在 write 路径和 release 路径之间没有加锁也没有引用计数。这意味着进程 A 打开设备调用 write进入some_driver_write。进程 A 在 write 路径里读取了dev-buffer准备往里面写数据。此时进程 B 关闭了同一个设备文件触发了 release调用vfree(dev-buffer)。进程 A 继续访问已经释放的buffer触发 page fault。这个时序在压力测试下很容易复现因为压力测试会频繁地打开、写入、关闭设备。6. 修复方案与验证6.1 加锁还是引用计数确认了根因之后修复方案就有方向了。核心目标是确保在 write 路径访问buffer期间buffer不会被释放。有两种常见的做法。第一种是加互斥锁。在 write 和 release 路径里都加同一把锁保证两者不会并发执行。这种做法简单直接但缺点是如果 write 操作耗时较长release 会被阻塞影响关闭设备的响应速度。第二种是引用计数。在打开设备时增加引用计数在关闭时减少引用计数只有当引用计数降到零时才真正释放buffer。这种做法更灵活但实现起来稍微复杂一些需要仔细处理计数的增减时机。我这次选择了加锁的方案因为驱动的 write 操作本身不涉及长时间的阻塞加锁带来的影响可以接受。而且加锁的实现更简单不容易引入新的 bug。6.2 具体代码修改修改后的 release 路径大概是这样static int my_dev_release(struct inode *inode, struct file *filp) { struct my_dev *dev filp-private_data; mutex_lock(dev-lock); if (dev-buffer) { vfree(dev-buffer); dev-buffer NULL; } mutex_unlock(dev-lock); return 0; }write 路径里也在访问buffer之前加锁static ssize_t my_dev_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { struct my_dev *dev filp-private_data; ssize_t ret; mutex_lock(dev-lock); if (!dev-buffer) { mutex_unlock(dev-lock); return -ENODEV; } ret copy_from_user(dev-buffer, buf, count); mutex_unlock(dev-lock); return ret ? -EFAULT : count; }这样就能保证 write 和 release 不会并发执行buffer在 write 期间不会被释放。6.3 验证方式修改之后我用同样的压力测试跑了一遍。之前大概跑几个小时就会崩一次修改之后连续跑了 48 小时没有再出现Unable to handle page fault。为了进一步确认我还在代码里加了一些断言和日志确保buffer在访问时确实是非空的。另外我还用KASANKernel Address Sanitizer跑了一遍。KASAN能检测出 use-after-free 和越界访问如果修改不彻底KASAN会报出来。实测下来开启KASAN之后跑压力测试也没有再报错这让我对修复方案更有信心了。7. 常见问题与排查技巧实录7.1 常见问题速查表问题现象可能原因排查方向Unable to handle page fault地址在 vmalloc 区vmalloc 内存被释放后仍被访问检查 vfree 调用路径和并发控制地址在模块区模块卸载后代码仍被执行检查模块引用计数地址在用户空间内核错误地访问了用户空间地址检查 copy_from_user/copy_to_user 使用ESR 显示 permission fault页表权限不匹配检查页表项的权限位设置ESR 显示 translation fault页表项无效检查地址是否已建立映射7.2 几个实用的排查技巧第一个技巧是善用dump_stack()。如果你怀疑某段代码有问题但又不知道是谁调用的可以在那段代码里加一个dump_stack()把调用栈打印出来。这比单步调试快得多。第二个技巧是关注地址的对齐。vmalloc分配的地址通常是页对齐的如果你看到的出错地址不是页对齐的那可能是访问越界了而不是整个页被释放。第三个技巧是用crash工具的vtop命令。如果你有 vmcore可以用vtop把虚拟地址翻译成物理地址看看对应的物理页是否存在。如果物理页不存在说明映射已经被拆除了。第四个技巧是检查编译优化。有时候编译器会把多次内存访问优化成一次或者把变量缓存在寄存器里导致你以为每次都在读内存实际上读的是旧值。这种情况下加volatile或者内存屏障可能会有帮助。7.3 我踩过的坑第一个坑是只看调用栈忽略了页表信息。一开始我看到调用栈指向驱动就直接去查驱动代码了没有仔细看页表信息。后来才发现页表信息里明确显示了 PMD 是空的这直接指向了 vmalloc 映射被拆除。如果早点看页表信息可以少走一些弯路。第二个坑是低估了竞态的复杂性。我一开始觉得 release 里已经把buffer置成NULL了应该不会有问题。但实际上多线程场景下置NULL和访问之间是有时间窗口的而且编译器优化可能让访问用的是旧值。这个教训让我在后续的驱动开发中对并发控制更加谨慎了。第三个坑是压力测试的复现条件。这个问题在普通测试下很难复现只有在高并发、频繁打开关闭设备的场景下才会触发。所以如果你遇到类似问题一定要想办法构造高并发的测试场景否则可能跑很久都复现不了。8. 从这次定位中沉淀下来的经验8.1 内核问题定位的通用思路这次定位过程让我总结出了一套相对通用的思路适用于大多数内核崩溃问题。第一步是收集完整信息。不要只看调用栈寄存器、页表、内存映射信息都要看。这些信息在崩溃日志里都有只是很多人会忽略。第二步是判断地址属性。出错地址落在哪个区域直接决定了排查方向。用户空间、线性映射区、vmalloc 区、模块区每个区域的问题模式都不一样。第三步是结合调用栈定位代码。调用栈告诉你崩溃时在执行什么代码结合反汇编和源码可以定位到具体的指令和变量。第四步是分析并发和生命周期。很多内核问题都和对象的生命周期有关特别是释放后访问、竞态条件这类问题。要仔细检查锁、引用计数、内存屏障这些机制。第五步是验证修复。修改之后要用压力测试和动态检测工具如 KASAN来验证确保问题真的解决了而不是被掩盖了。8.2 对驱动开发的几点建议第一任何共享资源都要有明确的保护机制。锁、引用计数、RCU选一种适合场景的不要裸奔。第二释放资源之后要把指针置空但不要以为置空就万事大吉了。置空只是防止后续的访问对于正在进行的访问还是需要锁来保护。第三压力测试是发现并发问题的利器。普通的功能测试很难触发竞态只有高并发、高频操作才能把问题暴露出来。第四善用内核提供的调试工具。KASAN、lockdep、kmemleak这些工具能在问题发生之前就给你警告比事后定位要轻松得多。8.3 后续可以扩展的方向这次问题虽然解决了但还有一些可以深入的地方。比如我后来想如果当时有 vmcore用crash分析会不会更快还有如果驱动改用kvmalloc而不是vmalloc会不会有不同的表现这些问题我没有继续深究但如果你遇到了类似问题可以沿着这些方向继续探索。另外内核的页表管理还有很多细节值得研究比如大页映射、页表项的原子更新、TLB 刷新时机等等。这些知识在定位更复杂的内存问题时会有很大帮助。最后再分享一个小技巧如果你在定位内核问题时感到无从下手不妨先把出错地址、ESR、调用栈这三个信息整理出来然后在内核源码里搜索相关的错误打印位置。很多时候打印错误的那段代码本身就会告诉你它检查了什么条件、什么情况下会报这个错。顺着这个线索往下查往往能事半功倍。
返回列表