首页
/ Linux 内核 Bug 排查实战:从 Oops 栈转储到精准定位缺陷代码行

Linux 内核 Bug 排查实战:从 Oops 栈转储到精准定位缺陷代码行

2026-09-06 18:34:10作者:冯爽妲Honey

导读

内核 Bug 报告几乎都会附带一份像下图这样足以让人望而生畏的栈转储(stack dump),其中包含的 "Oops"、寄存器现场与函数调用链,其实足以帮助我们定位到缺陷发生的确切源码文件与行号。本文以 Linux 内核源码树中的 bug-hunting.rst 为骨架,结合 kernel/panic.c 等内核实现与 scripts/ 下的调试工具链,系统讲解如何阅读 Oops 信息中的 "Modules linked in"、"Tainted" 与 "Call Trace",如何用 gdbobjdumpscripts/decode_stacktrace.shscripts/decodecode 等把十六进制地址还原为 文件:行号,最后给出如何用 get_maintainer.pl 找到正确的上报对象并按社区规范提交修复补丁。读完本文,你将具备从一份原始内核崩溃日志出发、独立完成"定位缺陷 → 复现验证 → 上报/修复"全流程的能力。

一、读懂内核 Bug 报告:从一行 WARNING 到 Oops

内核 Bug 报告通常以一段栈转储开场。下面是文档开头给出的一份典型样本(原文为旧式 32 位 x86 格式,但结构至今未变):

------------[ cut here ]------------
WARNING: CPU: 1 PID: 28102 at kernel/module.c:1108 module_put+0x57/0x70
Modules linked in: dvb_usb_gp8psk(-) dvb_usb dvb_core nvidia_drm(PO) nvidia_modeset(PO) snd_hda_codec_hdmi snd_hda_intel snd_hda_codec snd_hwdep snd_hda_core snd_pcm snd_timer snd soundcore nvidia(PO) [last unloaded: rc_core]
CPU: 1 PID: 28102 Comm: rmmod Tainted: P        WC O 4.8.4-build.1 #1
Hardware name: MSI MS-7309/MS-7309, BIOS V1.12 02/23/2009
 00000000 c12ba080 ...
Call Trace:
 [<c12ba080>] ? dump_stack+0x44/0x64
 [<c103ed6a>] ? __warn+0xfa/0x120
 [<c109e8a7>] ? module_put+0x57/0x70
 [<f80ca4d0>] ? gp8psk_fe_set_frontend+0x460/0x460 [dvb_usb_gp8psk]
 ...
---[ end trace 6ebc60ef3981792f ]---

这类栈转储已经足以让我们锁定内核源码中 Bug 发生的行。根据严重程度,报告里还可能出现 Oops 字样,例如:

BUG: unable to handle kernel NULL pointer dereference at   (null)
IP: [<c06969d4>] iret_exc+0x7d0/0xa59
*pdpt = 000000002258a001 *pde = 0000000000000000
Oops: 0002 [#1] PREEMPT SMP
...

无论打印的是 "Oops" 还是其他形式的栈转储,定位"肇事的那一行"都是识别和处理 Bug 的前提。后文我们统一用 "Oops" 指代所有需要分析的栈转储。

文档特别强调:若内核以 CONFIG_DEBUG_INFO 编译,可以用 scripts/decode_stacktrace.sh 大幅提升栈转储的可读性。该脚本的用法与原理我们会在第五节详细展开。

从内核实现看 WARNING 从何而来

上述第一行 WARNING: CPU: 1 PID: 28102 at kernel/module.c:1108 正是内核 WARN 机制的输出。在 kernel/panic.c__warn() 中可以看到对应打印逻辑:

if (file) {
	pr_warn("WARNING: %s:%d at %pS, CPU#%d: %s/%d\n",
		file, line, caller,
		raw_smp_processor_id(), current->comm, current->pid);
}

随后 __warn() 依次执行 print_modules()(打印 "Modules linked in" 列表)、dump_stack()(在无 pt_regs 时打印原始栈回溯)、print_oops_end_marker()(输出 ---[ end trace ... ]---),并通过 add_taint(taint, LOCKDEP_STILL_OK) 为内核打上 W(warning)污染标记——这些细节正好解释了栈转储每一行的来历。

二、如何解读 "Modules linked in" 与 Tainted 标记

在 WARNING 首行之下,紧跟的是 Modules linked in 一行,它列出崩溃时已加载的所有内核模块。解读规则如下:

  • 处于被加载或被卸载过程中的模块会被打上括号标注;
  • 污染(tainted)标记的含义见 tainted-kernels.rst
  • 正在被加载的模块用 + 标注;
  • 正在被卸载的模块用 - 标注。

以上面的样例为例:dvb_usb_gp8psk(-) 表示该模块正处于卸载阶段,这与调用链尾部 SyS_delete_module(卸载模块系统调用)吻合;nvidia_drm(PO)(PO) 标记则表示 NVIDIA 专有驱动已加载(P)且是外部构建(out-of-tree,O)——这两点会污染内核。

污染标记对 Bug 排查的意义:开发者收到来自污染内核的 Bug 报告时,往往无法确定真正原因是否是导致污染的那个事件,因此这类报告常被忽略。文档建议尽量在未污染的内核上复现问题。同时注意:即使卸载了导致污染的模块,内核的污染状态也不会自动清除,因此内核会在打印 WARNING/Oops/Panic 时主动输出 Tainted 字段。

Tainted 字段位于以 CPU: 开头的那一行中、PID 与 Comm 之后,例如 Tainted: P WC O。其中:

  • P:加载过专有(proprietary)模块;
  • W:内核此前发出过 warning;
  • C:加载过 staging 驱动;
  • O:加载过外部构建(out-of-tree)模块。

运行时可执行 cat /proc/sys/kernel/tainted 查看污染数值(0 表示未污染,非零值是一个位图)。官方提供了 tools/debugging/kernel-chktaint 脚本帮助解码。如果你想手工快速解码位图,可使用文档给出的循环命令按位检查:

for i in $(seq 20); do echo $(($i-1)) $(($(cat /proc/sys/kernel/tainted)>>($i-1)&1));done

完整的 20 个污染位含义对照(节选,完整版见 tainted-kernels.rst):

日志字符 数值 内核被污染的原因
0 G/P 1 加载了专有模块
1 _/F 2 模块被强制加载(insmod -f
3 _/R 8 模块被强制卸载(rmmod -f
4 _/M 16 处理器上报 Machine Check Exception
7 _/D 128 内核最近死过(发生过 Oops 或 BUG)
9 _/W 512 内核发出过 warning
12 _/O 4096 加载过外部构建模块
13 _/E 8192 在内核支持模块签名时加载了未签名模块
14 _/L 16384 系统发生过 soft lockup
15 _/K 32768 内核被 live patch 过
18 _/N 262144 运行过内核内测试(如 KUnit)

(表中 _ 表示空格,便于排版阅读。)

三、Oops 消息在哪里:日志、控制台与崩溃现场取证

正常情况下,Oops 文本由 klogd 从内核缓冲区读出并交给 syslogd 写入系统日志,典型位置是 /var/log/messages(取决于 /etc/syslog.conf)。在使用 systemd 的系统上,消息可能由 journald 守护进程存储,可通过 journalctl 命令读取。

klogd 已死掉,仍可直接从内核缓冲区取数:

dmesg > file

或者使用 cat /proc/kmsg > file——但注意 /proc/kmsg 是一个"永不结束的文件",必须手动中断(Ctrl-C / Break)才能停止传输。

如果机器崩溃到无法执行任何命令、磁盘也不可用,文档给出三种取证方案:

  1. 人工抄录屏幕文本:重启后凭记忆键入,或用数码相机拍照留存。若消息滚动过快超出屏幕,可以尝试更高分辨率启动(如 vga=791),以便容纳更多文本。注意:这依赖 vesafb,对 early oops 无效。
  2. 串口控制台 + 双机互联:参见 serial-console.rst。用 null modem 线把崩溃机接到第二台机器,用 minicom 之类的终端程序捕获全部输出。
  3. Kdump 内核转储:参见 kdump.rst。崩溃后用 gdb 宏从旧内存中提取内核环形缓冲区,gdb 宏位于 gdbmacros.txt

四、定位 Bug 发生的精确位置:gdb 方法

上报 Bug 最有效的方式是直接给出"内核源文件的哪一行"。文档给出两种定位方法:gdb(通常更简单,但需要内核带调试信息)与 objdump

4.1 开启内核调试信息(CONFIG_DEBUG_INFO)

gdb 法在内核以 CONFIG_DEBUG_INFO 编译时效果最佳。可借助内核自带的配置脚本开启:

./scripts/config -d COMPILE_TEST -e DEBUG_KERNEL -e DEBUG_INFO

-d 表示 disable,-e 表示 enable。)

4.2 从 Oops 中摘取 EIP,用 gdb 反查源码行

若 Oops 直接给出了 EIP 的绝对地址,例如:

EIP:    0060:[<c021e50e>]    Not tainted VLI

在有调试信息的内核上,可直接在 gdb 中列出该地址对应的源码:

gdb vmlinux
(gdb) l *0xc021e50e

4.3 用"函数+偏移"定位(无需绝对地址)

很多 Oops 只给出函数符号加偏移,例如:

EIP is at vt_ioctl+0xda8/0x1482

+0xda8 表示缺陷位于函数内部偏移 0xda8 字节处,/0x1482 表示函数总长度为 0x1482 字节。此时需要先以 CONFIG_DEBUG_INFO 重新编译内核,再让 gdb 解析符号并列出源码:

./scripts/config -d COMPILE_TEST -e DEBUG_KERNEL -e DEBUG_INFO
make vmlinux
gdb vmlinux
(gdb) l *vt_ioctl+0xda8

gdb 的输出会把符号翻译为真实的文件与行号。在文档的示例中,vt_ioctl+0xda8 被解析为 drivers/tty/vt/vt_ioctl.c:293,并直接列出函数源码上下文:

288	{
289		struct vc_data *vc = NULL;
290		int ret = 0;
291
292		console_lock();
293		if (VT_BUSY(vc_num))
294			ret = -EBUSY;
...

如果希望更精细地控制偏移计算,还可以先打印函数符号地址再手动相加:

(gdb) p vt_ioctl
$1 = {int (struct tty_struct *, unsigned int, unsigned long)} 0xae0 <vt_ioctl>
(gdb) l *0xae0+0xda8

4.4 三种 gdb 定位场景汇总

  1. 单个目标文件(如 drivers/tty/vt/vt_ioctl.c),无需重新链接整个内核,直接对编译产物使用 gdb:

    make drivers/tty/
    gdb drivers/tty/vt/vt_ioctl.o
    (gdb) l *vt_ioctl+0xda8
    
  2. 可加载模块中的缺陷。若调用链形如:

    Call Trace:
     [<ffffffff8802c8e9>] :jbd:log_wait_commit+0xa3/0xf5
     [<ffffffff810482d9>] autoremove_wake_function+0x0/0x2e
     [<ffffffff8802770b>] :jbd:journal_stop+0x1be/0x1ee
     ...
    

    这强烈暗示问题出在 :jbd: 模块中。将模块对象加载进 gdb 即可查看对应代码:

    gdb fs/jbd/jbd.ko
    (gdb) l *log_wait_commit+0xa3
    
  3. 调用链中的任意一帧都可同理展开。例如对文档开头示例中的:

    [<f80bc9ca>] ? dvb_usb_adapter_frontend_exit+0x3a/0x70 [dvb_usb]
    

    可以用该驱动对应的对象文件查看其调用点:

    gdb drivers/media/usb/dvb-usb/dvb-usb.o
    (gdb) l *dvb_usb_adapter_frontend_exit+0x3a
    

提示:栈转储中的地址是函数返回地址(即 call 指令的下一条指令),因此 gdb l *... 定位到的行可能是 call 之后的行。需要更精确对齐到 call 指令本身时,可借助第五节介绍的 decode_stacktrace.shscripts/faddr2line 的自动修正逻辑。

五、用内核自带脚本解码栈转储(推荐路线)

5.1 decode_stacktrace.sh:把地址批量翻译为文件:行号

文档明确提示:只要内核开了 CONFIG_DEBUG_INFO,就能用 scripts/decode_stacktrace.sh 增强栈转储质量。该脚本会扫描输入中的两类行:

  • [<地址>] 形式的调用帧;
  • 函数名+0x偏移/0x总长 形式的符号行;
  • 以及 Code: 机器码行(转交给 scripts/decodecode)。

其基本用法(脚本 usage() 输出的三种形式):

# 形式一:按发行版 release 自动查找 vmlinux 与模块
$0 [-R] -r <release>
# 形式二:显式指定 vmlinux、源码根路径与模块路径
$0 [-R] [<vmlinux> [<base_path>|auto [<modules_path>]]]

典型调用(把保存在 oops.txt 里的崩溃日志通过管道送入脚本):

cat oops.txt | scripts/decode_stacktrace.sh vmlinux auto /lib/modules/$(uname -r)

脚本内部原理(阅读 decode_stacktrace.shparse_symbol() 可以印证):

  1. 解析 函数名+0x偏移/0x总长,把偏移值转成十六进制;
  2. nm vmlinux(或对应 .ko 文件)查出符号的基地址,二者相加得到精确地址;
  3. 关键细节:栈转储给出的是返回地址,脚本在 decode_retaddr 为假(默认)且偏移非零时会 expr=$((expr-1)) 回退一字节,使结果指向 call 指令本身(见 decode_stacktrace.sh 注释 "The stack trace shows the return address...");
  4. addr2line -i -e <objfile> <address> 反查 文件:行号-i 用于内联函数展开,多行结果会被压到同一行;
  5. 若符号是 Rust 符号(_R 开头),还会尝试用 c++filt/llvm-cxxfilt demangle;
  6. 对模块,脚本会按模块名在 /lib/modules/<release>/usr/lib/debug/lib/modules/<release> 等目录寻找带 .debug_line 段的 .ko* 文件(见 find_module()),找不到调试信息时会给出 "rebuild with DEBUG_KERNEL and DEBUG_INFO" 的警告。

脚本输出会保留原日志的缩进与对齐,仅把 [...] 中的裸地址替换为 函数名 (文件:行号) 形式的可读文本,因此可直接贴进 Bug 报告。附带说明:若系统装有 debuginfod-find,脚本还支持按 vmlinux 的 Build ID 通过 debuginfod 在线拉取调试信息(见 debuginfod_get_vmlinux())。

5.2 scripts/faddr2line:单点精确翻译

若只想翻译单个 函数+偏移(例如 WARNING 首行 module_put+0x57/0x70),可优先使用 scripts/faddr2line

scripts/faddr2line vmlinux 'module_put+0x57/0x70'
scripts/faddr2line vmlinux 'vt_ioctl+0xda8/0x1482'

其用法为 faddr2line [--list] <object file> <func+offset> ...,可一次翻译多个符号,原理同样是 nm 找基地址 + addr2line 反查行号,是最轻量的定位工具。

六、objdump 与无源码场景下的汇编级还原

6.1 objdump 反汇编目标文件

若不便使用 gdb,可用 objdump 对照崩溃输出的十六进制偏移查找对应代码。带调试符号时反汇编会同时显示汇编与 C 源码;调试符号可在内核 hacking(Kernel hacking)配置菜单中开启。示例:

objdump -r -S -l --disassemble net/ipv4/tcp.o

注意:必须在内核源码树顶层目录执行此命令,objdump 才能正确解析到你的 C 源文件。

6.2 没有源码、只有机器码时的"手工反汇编"

即使拿不到任何源码与符号,仍可对 Oops 中的 Code: 行做汇编级还原。Dave Miller 演示的方法如下。假设崩溃输出形如:

EIP is at     +0x14/0x4c0
 ...
Code: 44 24 04 e8 6f 05 00 00 e9 e8 fe ff ff 8d 76 00 8d bc 27 00 00
 00 00 55 57  56 53 81 ec bc 00 00 00 8b ac 24 d0 00 00 00 8b 5d 08
 <8b> 83 3c 01 00 00 89 44  24 14 8b 45 28 85 c0 89 44 24 18 0f 85

Code: 后面的字节填进一个汇编源文件:

        .text
        .globl foo
foo:
        .byte  .... /* bytes from Code: part of OOPS dump */

gcc -c -o foo.o foo.s 编译,再用 objdump --disassemble foo.o 反汇编。输出即可还原出崩溃指令,如文档示例将一段崩溃字节还原为 ip_queue_xmit 的开头序列:

ip_queue_xmit:
    push       %ebp
    push       %edi
    push       %esi
    push       %ebx
    sub        $0xbc, %esp
    mov        0xd0(%esp), %ebp        ! %ebp = arg0 (skb)
    mov        0x8(%ebp), %ebx         ! %ebx = skb->sk
    mov        0x13c(%ebx), %eax       ! %eax = inet_sk(sk)->opt

其中 <> 尖括号括起的字节正是触发异常的指令。需要说明的是:这个例子属于老式非模块化内核的旧格式,对于现代内核,Code: 行还包含确定指令指针位置的上下文信息,<...> 标记的字节就是崩溃发生时即将执行/正在执行的指令。

注意:对较新格式的 Oops(含 RIP 与完整符号)而言,手工逐字节还原通常已无必要。文档也指出:scripts/decodecode 可以根据 CPU 架构自动完成上述大部分工作。现代内核 BUG/Oops 文本中的 Code: 行还可能带有符号解析后缀(如模块括号标注、build id 等),解读时可结合 decode_stacktrace.sh 一并处理。

七、上报 Bug:让补丁与报告到达正确的人

找到 Bug 位置后,有两种选择:自己修复,或上报上游。上报时最关键的是找对 Bug tracker 或维护该代码的邮件列表,这可以借助 get_maintainer.pl 完成。

例如,若在 gspca 的 sonixj.c 中发现 Bug(该文件真实存在于 drivers/media/usb/gspca/sonixj.c):

./scripts/get_maintainer.pl --bug -f drivers/media/usb/gspca/sonixj.c

文档展示的输出大致包含:

Hans Verkuil <hverkuil@kernel.org> (odd fixer:GSPCA USB WEBCAM DRIVER,...)
Mauro Carvalho Chehab <mchehab@kernel.org> (maintainer:MEDIA INPUT INFRASTRUCTURE (V4L/DVB),...)
...
linux-media@vger.kernel.org (open list:GSPCA USB WEBCAM DRIVER)
linux-kernel@vger.kernel.org (open list)

脚本会指出以下几类对象:

  • 最后改动过该源码的开发者(在 git 树内执行时有效,例如示例中的 Tejun、Bhaktipriya,尽管他们其实与该文件开发无关);
  • 驱动维护者(示例中的 Hans Verkuil);
  • 子系统维护者(示例中的 Mauro Carvalho Chehab);
  • 驱动/子系统邮件列表linux-media@vger.kernel.org)与内核总邮件列表(linux-kernel@vger.kernel.org);
  • 该驱动/子系统的 Bug 上报 URI(上述示例中没有)。

社区约定(文档明示):

  • 若输出末尾含 Bug 上报 URI,优先使用 URI 而非邮件;
  • 否则发往该代码开发所用的邮件列表(如 linux-media ML),并抄送驱动维护者;
  • 如果完全不确定发给谁、get_maintainer.pl 也没给出有用信息,就发往 linux-kernel@vger.kernel.org

补充说明:get_maintainer.pl 的输出基于 MAINTAINERS 条目、git 提交史与代码署名等多源统计,字段含义包括该人对文件的责任角色、最近提交签名占比(commit_signer)等。仓库根目录的 MAINTAINERS 是它最核心的数据来源,感兴趣的读者可以直接阅读该文件了解各子系统与驱动的维护者列表结构。

八、修复 Bug 并提交补丁:走向上游

如果你懂编程,社区欢迎你不仅报告 Bug,更直接给出解决方案。当修复完成,请务必按内核社区规范提交。动手前请先阅读:

遵循社区约定能显著提高补丁被接受的几率——正如文档结尾所言:感谢你为让 Linux 尽可能稳定所付出的努力。

九、附录:老式 klogd 的 Oops 符号化机制(历史背景)

早期内核调试还依赖 klogd 对保护故障消息做符号化翻译,理解其机制有助于读懂旧日志。要点如下:

  • 要获得完整的地址解析支持,至少需要 sysklogd 1.3-pl3 及以上版本;
  • 保护故障发生时,klogd 会自动把内核日志中的关键地址翻译成符号形式,再交给它的上报机制转发;故障消息可从消息文件中直接裁剪并转发给内核开发者;
  • klogd 做两类地址解析:
    • 静态翻译(static translation):基于 System.map 文件,klogd 在守护进程初始化时必须能找到该文件(查找规则见 klogd 手册);
    • 动态翻译(dynamic translation):针对内核可加载模块。模块内存来自内核动态内存池,起始地址与函数位置都不固定,klogd 借助内核提供的系统调用查询"已加载模块及其内存位置",构建符号表用于解析发生在模块内的保护故障;
  • 动态模块环境变化时需要通知 klogd 刷新符号信息:klogd 提供命令行选项可向正在运行的守护进程发信号,详见其手册页。sysklogd 发行版附带一个针对 modules-2.0.0 的补丁,能在模块加载/卸载时自动通知 klogd,实现近乎无缝的模块内故障调试;
  • 以下是 klogd 处理过的模块内保护故障的经典日志片段(旧式格式),注意 [oops:_oops+16/3868] 这类已符号化的 EIP 表达,以及 Call Trace 中 [oops:_oops_ioctl+48/80] 的模块内帧格式:
Aug 29 09:51:01 blizard kernel: Unable to handle kernel paging request at virtual address f15e97cc
Aug 29 09:51:01 blizard kernel: current->tss.cr3 = 0062d000, %cr3 = 0062d000
Aug 29 09:51:01 blizard kernel: *pde = 00000000
Aug 29 09:51:01 blizard kernel: Oops: 0002
Aug 29 09:51:01 blizard kernel: CPU:    0
Aug 29 09:51:01 blizard kernel: EIP:    0010:[oops:_oops+16/3868]
Aug 29 09:51:01 blizard kernel: EFLAGS: 00010212
Aug 29 09:51:01 blizard kernel: eax: 315e97cc   ebx: 003a6f80   ecx: 001be77b   edx: 00237c0c
Aug 29 09:51:01 blizard kernel: esi: 00000000   edi: bffffdb3   ebp: 00589f90   esp: 00589f8c
Aug 29 09:51:01 blizard kernel: Process oops_test (pid: 3374, process nr: 21, stackpage=00589000)
Aug 29 09:51:01 blizard kernel: Stack: 315e97cc 00589f98 0100b0b4 bffffed4 0012e38e ...
Aug 29 09:51:01 blizard kernel: Call Trace: [oops:_oops_ioctl+48/80] [_sys_ioctl+254/272] [_system_call+82/128]
Aug 29 09:51:01 blizard kernel: Code: c7 00 05 00 00 00 eb 08 90 90 90 90 90 90 90 90 89 ec 5d c3

在现代内核上,上述职责已主要由内核自身的打印、kallsyms 符号表以及 scripts/decode_stacktrace.sh 等工具承担,但"先静态地址、后模块动态符号"的解析思路依然是理解一切 Oops 的基础。

十、快速查阅清单

建议的工作流总结:复现崩溃并采集 Oops → 查看 TaintedModules linked in 确认环境可信度 → 用 decode_stacktrace.sh(或 faddr2line)把关键帧翻译成 文件:行号 → 打开源码核对上下文、结合寄存器与 Code: 行确认根因 → 修复后先用 get_maintainer.pl 找到正确的维护者与列表,再按 submitting-patches.rst 提交补丁。

登录后查看全文
热门项目推荐
相关项目推荐