服务器跑着跑着突然没有任何响应,管理口还通着,业务却全断了。接上串口或者显示器一看,屏幕上刷了一大段:
BUG: unable to handle kernel paging request at ffff9c1d23456789 IP: [<ffffffffa02b3c10>] demo_nic_xmit_frame+0x8c/0x150 [demo_nic]后面还跟着RIP、Call Trace、Code,整台机器处于半死不活的状态。第一次遇到这种场面的人,脑子里多半有两个方向打架:内存条坏了,还是驱动写崩了?这两个方向如果一开始就押错,后面会走很多弯路。我见过有人连夜把一整套内存全换成新条,问题照旧;也有人反复重启几十次,最后才确认是某网卡驱动的函数在特定条件下踩了空指针。这篇文章就围绕“unable to handle kernel paging request”这条经典内核Oops,讲清楚怎么在内存故障和驱动问题之间快速分流,以及每一步该做什么、做什么操作有什么目的。
1. 先弄懂这条报错的真实含义
不把报错本身的机制搞清楚,后面所有排查都是抓瞎。这一节先把内核Oops是怎么回事、日志里每行代表什么,逐条拆开讲明白。
1.1 内核Oops和用户态段错误的区别
每个进程都有自己的虚拟地址空间,当用户态程序访问了没有权限或没有映射的地址,CPU会触发page fault,操作系统把这个信号翻译成SIGSEGV,进程直接挂掉,系统本身不受影响。这是常见的“段错误”。
但内核不一样。内核运行在最高的特权级,它的代码访问无效地址时,同样会发生page fault,但这个fault发生在我“内核态”,没有哪个进程能兜住它。内核只能打印一串Oops信息,把当时CPU的寄存器、调用栈、指令码全dump出来,然后根据panic_on_oops的值决定是继续运行还是直接停机。这就是为什么我们看到的那一大段日志里,既有“BUG:”开头,又有大量的寄存器值和调用栈。
所谓“unable to handle kernel paging request”,本质上就是CPU在虚拟地址翻译(page walk)过程中,没有找到虚拟地址对应的物理页表项。翻译失败之后,内核的缺页异常处理函数发现这个地址既不在正常的用户空间映射里,也不属于内核自己建立的有效映射,于是认定这是一次非法的内核态内存访问,立刻打报告。所以这条日志的完整含义是:内核自己的代码,在访问一个“当前页表里根本不存在”的虚拟地址。
1.2 一条完整报错日志的逐段拆解
真实场景里的完整Oops长这样(字段做了简化,但结构是标准的):
BUG: unable to handle kernel paging request at ffff9c1d23456789 IP: [<ffffffffa02b3c10>] demo_nic_xmit_frame+0x8c/0x150 [demo_nic] PGD 1a20f067 P4D 1a20f067 PUD 1a2ef067 PMD 0 Oops: 0000 [#1] SMP Modules linked in: demo_nic xt_conntrack nf_conntrack ip_set iptable_filter ... CPU: 3 PID: 1234 Comm: kworker/3:2 Not tainted 4.18.0-xxx.el8.x86_64 Hardware name: Dell Inc. PowerEdge R640 /xxxx, BIOS 2.12.2 RIP: 0010:[<ffffffffa02b3c10>] demo_nic_xmit_frame+0x8c/0x150 [demo_nic] RSP: 0018:ffff9c1d4ab4f8d8 EFLAGS: 00010002 RAX: ffff9c1d12345678 RBX: ffff9c1d34567890 RCX: dead000000000122 RDX: 0000000000000000 RSI: ffff9c1d4ab4f930 RDI: ffff9c1d12345678 RBP: ffff9c1d4ab4f960 R08: 0000000000000000 R09: 0000000000000001 R10: 0000000000000000 R11: 0000000000000000 R12: ffff9c1d23456789 R13: ffff9c1d12345678 R14: ffff9c1d98765432 R15: ffff9c1d456789ab FS: 0000000000000000 GS: 0000000000000000 CR2: ffff9c1d23456789 Call Trace: [<ffffffffa02b3d5e>] demo_nic_start_xmit+0x6e/0xa0 [demo_nic] [<ffffffff8156c9b1>] dev_hard_start_xmit+0x121/0x2d0 [<ffffffff8159a229>] sch_direct_xmit+0x99/0x1c0 [<ffffffff8159a4c6>] __qdisc_run+0xf6/0x260 [<ffffffff8156cd52>] __dev_queue_xmit+0x192/0x6e0 [<ffffffff8158f4e5>] ip_finish_output2+0x2c5/0x460 ... Code: 48 8b 00 48 89 c1 48 8b 40 18 48 85 c0 74 05 48 89 45 c8 c3 0f 1f 44 00 00逐段看:
BUG:行里的地址是本条最重要的第一线索。这个地址保存在CR2寄存器里,就是导致page fault的虚拟地址。如果这个地址是0x0、0x8、0x10这种很小的值,几乎可以确定是空指针解引用,代码里有人把NULL当有效指针用了。IP或RIP是崩溃指令所在的地址和符号。格式是“函数名+偏移量/总长”。这里是demo_nic_xmit_frame+0x8c,意思是崩溃点在该函数入口向后偏移0x8c字节处。这个是判断驱动还是内存问题的核心依据。PGD/P4D/PUD/PMD这一行是页表遍历结果。PMD 0表示遍历到这一级时页表项为空,也就是这个虚拟地址根本没有建立映射。这行对咱们普通人来说只需要知道:CPU走到这一级发现是空表项,所以踩出了fault。Oops: 0000 [#1] SMP:0000是缺页错误码,bit0为0代表不是“页存在但权限不足”,而是“页根本不存在”;[#1]表示这是第1次Oops,如果一直复现,次数会往上加。Modules linked in这一行列出了当时加载的模块。排查驱动问题时,这个列表能告诉我们可能和哪些模块相关。CPU/PID/Comm是崩溃所在的CPU编号、进程号和进程名。如果Comm总是kworker或者某个驱动专属线程,嫌疑也会更集中。- 寄存器一大堆,主要是还原现场。RSP是栈指针,RAX RBX这些通用寄存器是计算时的临时值,
RCX: dead000000000122这种是内核里常见的毒化值,用来标记已释放对象。 Call Trace是调用栈,从里到外列出崩溃函数是被谁一路调进来的。通过这条链可以知道引发崩溃的上层入口是什么。比如上面的调用栈里,可以看到是网卡发送路径demo_nic_start_xmit一路从网络协议栈下来的,这就很可疑驱动网卡了。Code是崩溃指令附近16字节的机器码。配合objdump或者在线反汇编可以翻译成汇编,看到底是哪条指令在访问内存。这里48 8b 00就是mov (%rax),%rax,如果RAX为0或已经指向已经释放的地址,那这行指令就是导火索。
1.3 内核page walk失败的技术本质
CPU访问一个虚拟地址时,MMU会按照CR3寄存器指向的顶级页表,逐级往下查找PGD、P4D、PUD、PMD、PTE的映射。这个过程相当于在酒店里按门牌号找房间:先看楼层索引,再看房间分区表,最后看具体房间门牌。只要某一级索引说“查无此房”,CPU就会给出page fault。
内核态发生page fault,处理逻辑会看是不是内核合法建立的映射。如果fault address在内核的vmalloc区域、线性映射区域或者模块分配的区域里,但页表项却是空的,那基本只有两种可能:第一种是代码本身访问了一个不该访问的地址(空指针、野指针、释放后使用),属于软件逻辑错误;第二种是保存页表或内存管理数据结构的那块物理内存本身出了问题,比如内存颗粒翻转把页表项写坏了,导致原本有效的映射莫名其妙消失。
这里就引出了判断的核心逻辑:如果崩溃点RIP每次都固定在同一个模块、同一个函数,甚至偏移都差不多,那多半是代码逻辑问题;如果RIP满屏乱跑,这次在网络栈、下次在文件系统、再下次在调度器,那就要高度重视硬件了。
2. 内存故障和驱动BUG的分水岭
“是内存坏了还是驱动的问题”这个问题的本质,其实就是一场归类。所有排查动作,都是在往“硬件特征”或者“软件特征”这两个框里填证据。
2.1 两类故障的本质差异对比
先说内存坏了的表现。物理内存在发生bit翻转时,系统多半不会马上崩给你看。可能是某个进程算到一半发现校验不对,可能是内核在分配内存时拿到了一个损坏的页,也可能是某段页表被写坏。这类故障最大的特点是随机性和跨区域性:同一个故障不会总出现在同一段代码里,因为坏的是物理页,谁分配到坏页谁倒霉。
驱动的问题恰恰相反。驱动代码无论怎么写,它就是那段逻辑,不会今天对A路径错、明天对B路径错还在同一个函数里。驱动BUG的崩溃点通常集中在某个特定函数里,调用路径高度固定。我把这两类特征并排对比:
| 排查维度 | 内存/硬件故障 | 驱动BUG |
|---|---|---|
| RIP位置 | 不稳定,每次不同函数 | 稳定在同一模块的特定函数 |
| Call Trace | 每次都不一样 | 高度一致,几乎相同 |
| 触发条件 | 无明显规律,内存压力大时更频繁 | 固定操作或固定负载,如重启网卡、故障切换 |
| MCE/EDAC日志 | 通常能查到记录 | 基本没有 |
| memtest86+ | 会报错或揭露出不稳定的物理地址 | 能通过 |
| 升级/回退驱动 | 无效,该怎么崩还怎么崩 | 故障可能消失、转移或者固化 |
这个表格不是绝对真理,但在我处理过的几十个案例里,它能快速指出方向。绝大多数情况下,第一眼看到RIP和Call Trace,方向就能定了。
2.2 内存故障的四个预警信号
第一个信号是最直接的:dmesg或/var/log/messages里有Machine Check Exception、EDAC、mce相关的记录。服务器级的ECC内存在出现可纠正错误时,CPU会通过机器检查机制上报,内核里的mcelog进程会把日志写出来。只要是MCE里有内存故障记录,硬件嫌疑立刻拉高。
第二个信号是系统里“满屏报错”。用户进程莫名段错误、MySQL/数据库进程突然core dump、文件系统出现metadata校验失败、内核Oops的RIP每次都不一样,这些都属于跨模块的随机故障。当一个系统开始在多个毫不相关的子系统里随机报错时,内存损坏的概率远大于多块驱动同时出BUG的概率。
第三个信号和内存压力有关。一旦启用大量swap,或者跑内存密集型的压测任务,故障频率明显变高。这很好理解,内存压力大时系统会频繁分配和归还物理页,更容易碰到坏页。
第四个信号是故障形态比较“脏”。比如同样的数据读出来两次结果不一样,dd校验文件前后不一致,这种情况十有八九是物理内存或者缓存颗粒出问题了,纯驱动逻辑不会造成用户数据反复不一致。
2.3 驱动BUG的固定特征
驱动类故障最好认的一点,就是它能复现。无论是卸载重载驱动、网络链路up/down、存储链路故障切换还是硬件热拔插,只要是同一个动作必然触发或者大概率触发,就要优先怀疑驱动。
再看RIP和Call Trace。驱动BUG的最典型画面是:RIP里的符号永远是demo_nic_xxx、qla2xxx_xxx、mydrv_interrupt这类设备模块函数,偏移也经常固定;Call Trace从入口到崩溃点高度相似,只是上层调用者的偏移量偶尔有些许不同。
还有一个细节,如果崩溃日志里Modules linked in出现某个模块反复出现,且每次Oops都是在它所属的地址范围,这本质上等于软件在自我指认。这时候先别急着怪硬件,应该去查这个驱动自身的版本、已知BUG、固件兼容性。不要用“内存会不会连着两次坏在同一函数”来感动自己,这种概率比中彩票还低。
2.4 容易被忽略的第三类:存储/文件系统栈
在实际运维里,还有相当一部分“paging request”既不是内存坏也不是传统意义上的网卡驱动BUG,而是存储/RAID/文件系统相关驱动造成的内核态空指针。比如某RAID卡驱动在磁盘掉线、IO超时处理路径里访问了已经释放的SCSI命令结构,或者某文件系统模块在journal recovery时状态没判断就往下走。这类问题特征和驱动BUG完全一致,RIP固定、复现路径清晰,只是模块属于存储栈而已。
所以在判断时不要把“驱动”狭义理解成“网卡驱动”。只要是内核模块代码引起的,统称“模块/驱动问题”更准确。排查时同样要走模块版本、固件、参数、复现条件这条路。
3. 完整排查流程:从现场到结论
这一节是整篇文章的操作核心。我把它整理成四步:先抓现场、再做内核转储分析、再查硬件、最后驱动处置。顺序很重要,因为现场信息一次性丢失就永远没了。
3.1 第一步:完整抓取现场,先保住数据
问题刚发生后,操作要克制。我见过太多人上来就敲reboot,重启完了才想起来日志没保存,追悔莫及。正确顺序是:先通过带外管理卡(比如BMC/IPMI的SOL串口)或者服务器连接的真串口,把屏幕上的完整Oops日志复制下来,拍照也行。
然后立刻去翻持久化日志。大部分发行版会把内核消息写进/var/log/messages或/var/log/kern.log,崩溃片段可以直接搜:
grep -B 10 -A 40 "unable to handle kernel paging request" /var/log/messages如果配置了kdump,/var/crash/目录下通常已经生成了vmcore文件,这个文件是内核崩溃瞬间的完整内存镜像,是后面精确分析的根本。它的价值比屏幕截图高得多,不要在没有确认vmcore是否存在之前就覆盖或重启。
还要检查一下BMC的SEL日志:
ipmitool sel elist这能看到硬件层记录的ECC错误、电压异常等系统事件。有时候网卡或者其他PCIe设备也会产生系统事件,这同样重要。
3.2 第二步:用kdump和crash把崩溃点钉死
在已经生成vmcore的情况下,分析工具首选crash。命令很简洁,先把崩溃转储文件和对应的vmlinux(带符号的内核映像)关联起来:
crash /usr/lib/debug/lib/modules/$(uname -r)/vmlinux /var/crash/2024-xxxx/vmcore进入crash交互界面后,第一步执行:
crash> bt这个命令输出崩溃时的完整内核态调用栈。如果栈顶是某个模块函数,后面的分析就围绕它展开。接着用dis命令反汇编崩溃点附近代码:
crash> dis -l demo_nic_xmit_frame+0x8c-l参数会把指令对应到源码文件和行号(前提是安装了带debuginfo的模块)。这时能看到崩溃指令到底是mov (%rax),%rax还是mov 0x10(%rdi),%rdx,再结合寄存器值判断是哪个指针出了问题。
使用log命令可以拉出内核环形缓冲区里崩溃前的所有日志,包括有没有MCE、EDAC信息:
crash> log | grep -i -E "mce|edac|machine check"如果log里干干净净,没有硬件错误,而bt里又明确是某个驱动函数,那几乎可以排除内存颗粒故障。
没有vmcore也不代表没法分析。日志里Code: 48 8b 00 ...那一段机器码,可以手工算出来RIP相对偏移,再用objdump反汇编:
echo "48 8b 00 48 89 c1 48 8b 40 18" | xxd -r -p > code.bin objdump -D -b binary -mi386:x86-64 -M intel code.bin即使没有完整内存转储,至少能还原崩溃指令是什么,这已经足以回答“是空指针还是非法地址”这个基础问题。
3.3 第三步:硬件侧诊断三板斧
如果日志分析指向硬件,或者证据不充分,那就按固定套路三板斧走。
第一板斧是查MCE和EDAC状态。先看内核日志:
dmesg | grep -i -E "mce|edac|machine check|ECC"再查EDAC控制器导出的sysfs节点:
ls /sys/devices/system/edac/mc/ cat /sys/devices/system/edac/mc/mc0/ce_count cat /sys/devices/system/edac/mc/mc0/ue_countce_count是可纠正错误计数,ue_count是不可纠正错误计数。后者一旦大于0就要尽快安排停机换内存。前者如果持续增长,也要注意,说明某个颗粒正在劣化。
第二板斧是跑内存检测。最可靠的还是用memtest86+做启动级测试,做U盘启动盘,让它在服务器上跑至少三个完整轮次。这类工具能直接定位到具体的地址范围和颗粒序号。如果服务器没法轻易脱机,可以用memtester在系统内跑,也能测出相当一部分问题,只是准确性不如启动级测试。
第三板斧是物理层最小化。拔掉一半内存、只留单根,或者把内存条对调插槽,观察故障是否跟着某根条走。内存故障的规律往往是:把疑似坏条拿到另一个槽位,故障区域跟着走。操作时注意先看服务器手册确认支持的内存配置方式。
如果上述三步全过,基本可以排除内存问题,再看驱动。
3.4 第四步:驱动侧定位与处置
驱动排查的第一步不是升级,而是确认这个驱动模块是什么、版本多少、和哪个设备绑定:
lsmod | grep demo_nic modinfo demo_nic lspci -vvv | grep -i -A 10 "Ethernet" ethtool -i eth0ethtool -i能同时给出driver版本和firmware版本,这两者不匹配也容易出怪问题。确认版本后,第二步是收集这个驱动已知的BUG。不要小看这一步,很多厂商驱动在特定的固件版本与内核版本组合下,确实存在“某某操作必现空指针”的已知问题,往往在厂商的release note里写得明明白白。
复现路径一旦确认,处置手段就是三板斧:
- 升级驱动或固件:优先选厂商最新稳定版,不要选最激进的beta版;
- 回退驱动:如果升级前没这毛病,升级后才开始崩,那回退是最高优先级;
- 调整驱动参数:有些网卡驱动有
txqueuelen、rx-ring、int_mode等参数,存储驱动有can_queue、cmd_per_lun等参数,适当收紧可以绕开BUG路径。
临时定位时,还可以用模块黑名单禁掉有问题的设备驱动来验证:
echo "blacklist demo_nic" >> /etc/modprobe.d/blacklist.conf如果能禁掉且系统不再崩溃,那基本就是驱动证据链闭环。
4. 三个真实复盘案例
理论看多了容易头晕,我挑三个曾经处理过的案例复盘,分别对应内存坏、网卡驱动BUG、存储驱动BUG,让大家体会一下完整判断过程。
4.1 案例一:某数据库服务器内存颗粒损坏
症状是:服务器每周大约随机崩溃两三次,每次的RIP都不一样。数据库进程偶尔报错,应用层偶发校验和失败。有人先怀疑Oracle驱动,有人怀疑文件系统,一直没头绪。
排查时我第一件事就是看大日志,结果在崩溃前几小时能看到零星几条:
EDAC MC0: 1 CE on memory module DIMM_A2 MCE: CPU3: Machine Check Exception: 0 Bank 5: e800004000000009MCE和EDAC同时在报,硬件指向性已经很明确。memtest86+在第二轮跑到某个地址范围时稳定报错。替换对应内存条后,系统维持了半年没有再出现该症状。复盘结论:最开始几天大家都在追各条驱动,纯属浪费时间,日志里的MCE早就把答案写在了墙边。
这个案例最大的教训就是,排查顺序比排查技术更重要。先看硬件日志,再分析软件逻辑,能够省下大量无头苍蝇式的操作。
4.2 案例二:某Web服务器网卡驱动卸载重载必现
症状是:每次执行网络服务重启命令或手动ip link set dev eth0 down再up,系统有八成概率触发内核Oops,RIP每次几乎都落在同一个网卡驱动函数里。业务高峰时偶尔也会崩,但触发条件非常清晰。
日志分析时发现Call Trace前几条依然是那个网卡驱动自己的收发路径,并且Modules linked in列表里有该驱动。顺着厂商发布记录查,发现该版本驱动在链路状态切换时存在skb释放后重用的已知缺陷,对应修复补丁在新版本中已合入。升级驱动后,反复重启链路测试二十余次,故障消失。
复盘时值得补一句:这类问题如果一开始先用ethtool -i确认版本、厂商和固件配套,可能当天就能定位,不必等到第N次复现。
4.3 案例三:某RAID卡驱动高IO下崩溃
症状是:某个备份系统在集中备份时段频繁崩溃,Oops的RIP固定在某RAID卡驱动模块的IO完成路径里。硬盘本身没有物理坏道,MCE和EDAC都没有任何记录,memtest双轮通过。
崩溃Call Trace里能看到SCSI上层调用一路进入RAID驱动。进一步查驱动参数,发现该驱动在单队列深度过大时存在竞争条件,官方推荐将队列深度从256下调到128。调整后故障频率显著下降,后续更换新版固件后彻底解决。
这说明,不是所有驱动问题都能靠“升级”解决,有时靠参数调整先保业务稳定,再用固件/版本升级做根除,也是完全可行的策略。
5. 排查经验速查与日常防御
很多问题等到发生了才排查,其实已经晚了。平时做好防御,遇到问题时才能有条不紊。
5.1 崩溃症状快速分类表
先给一张方便打印贴在机房的速查表:
| 观察项 | 内存/硬件 | 驱动/模块BUG |
|---|---|---|
| RIP位置 | 飘忽不定 | 固定同一模块函数 |
| Call Trace | 每次不同 | 高度相似 |
| MCE/EDAC日志 | 常伴随出现 | 基本无 |
| memtest86+ | 有报错 | 完全通过 |
| 更换/升级驱动 | 无效 | 有效或转移 |
| 是否受内存压力影响 | 明显 | 不明显 |
| 是否受特定操作影响 | 不明显 | 固定操作复现 |
这张表不需要背,只需要记住一句口诀:先看RIP稳不稳,再看MCE有没有,三看复现操不操。
5.2 平时就要开好的内核参数
经历过一次“内核Oops后直接hang死,只能强制断电”的痛苦之后,我养成了给所有服务器加上这些内核参数的习惯:
# /etc/sysctl.conf kernel.panic = 10 kernel.panic_on_oops = 1 kernel.panic_on_io_nmi = 1 kernel.softlockup_panic = 1 kernel.nmi_watchdog = 1这些参数的核心思想是:一旦内核死亡,就干净利落地重启,不要一直半死状态拖到业务超时崩溃。kernel.panic=10表示重启前等待10秒,panic_on_oops=1让任何Oops都直接触发panic,softlockup_panic=1让软锁死也能自动重启。配合kdump,每次重启前都会在崩溃现场留下完整的vmcore。
开机引导参数里也要预留内存给kdump:
crashkernel=512M配置好之后需要执行:
grub2-mkconfig -o /boot/grub2/grub.cfg systemctl enable kdump.service systemctl start kdump.service我建议在交付每台机器时都检查一下/sys/kernel/kexec_crash_loaded是否为1,确保crash内核确实已加载。
5.3 日志与硬件的长效监控
EOS基础设施里建议把以下监控项纳入例巡:
- 定期查看
/var/log/messages中的EDAC和MCE记录,dmesg里的EDAC MC0: x CE、Machine check这类关键字打到告警里。 - 定期检查
/sys/devices/system/edac/mc/mc0/csrow*/ce_count和ue_count的增长速率。ue_count从0变成1就要马上规划停机。 - 查看BMC SAM/SEL事件,特别注意DIMM相关的“Correctable Error”和“Uncorrectable Error”。
- 固件、BIOS、驱动版本建立台账,在新版本发布后评估是否值得升级。不要盲目追最新,但也不要长时间停留在有已知严重BUG的版本上。
5.4 最后一个小技巧:先备份后动手
遇到unable to handle kernel paging request这类崩溃,最忌讳的就是急着换硬件。换内存之前,先花十分钟把/var/crash下的vmcore、/var/log/messages的报错段、BMC里的SEL事件全部备份出来。这些是后续分析判断的“案发现场原始照片”,比换下来的内存条值钱得多。如果后续发现是驱动问题而内存已经被退换掉,等于白白浪费了一周窗口期。
在我个人处理这种问题的经验里,最稳定有效的第一步永远不是动硬件,而是静下来看完日志再动手。RIP稳、MCE空、固定操作复现的,直接往驱动方向走;RIP乱、MCE有货、触发毫无规律的,果断按硬件方向拆机。这两条分支,能筛掉九成以上的无效排查。