☰
Linux内核调试手段全解:从printk到kgdb的实战指南
2026/10/8 11:57:14 网站建设 项目流程

说实话,内核调试比用户态调试恶心得多,这一点只要你碰过一次内核崩溃就能切身感受到。上一章我们聊的主要是strace、ltrace、perf这类偏用户态的排查手段,这一篇是“第3章 Linux内核调试手段之二”,我们把视角彻底切入内核腹地,讲一讲真正用来定位内核问题的那几套工具:日志与动态调试、崩溃转储分析、动态追踪探针、以及kgdb远程调试。这几样东西平时不显山不露水,但内核一崩、驱动一挂、性能一掉,能不能快速稳住场面,靠的就是它们。这篇文章适合正在做内核开发、驱动移植、内核裁剪,或者经常被生产环境内核问题折磨的工程师;如果你是刚入门内核的新人,我建议先把用户态调试那套玩熟,再来看这里,否则前面几段你会觉得像天书。

1. 调试手段全景:先搞清楚能用什么,别上来就打印

1.1 为什么内核调试比用户态难这么多

用户态程序崩了,哪怕没有core dump,系统也照样活着,你可以用gdb挂上去,设断点,看变量,甚至调试完了还能让进程继续跑。但内核不一样:内核是整个系统的心脏,它崩了,就是全系统歇菜。更麻烦的是,内核根本没有“用户态进程”那种上下文概念,它的运行是被中断、系统调用、异步事件驱动的,你没法随随便便按个暂停键说“让我看看现在是什么状态”。

另一个让人头疼的特点是,内核代码里随便一个空指针解引用,造成的后果可能是不可恢复的panic,不像用户态顶多段错误。而且,在单机上调试内核有天然劣势:你的调试器得跑在你要调试的内核之外,可内核对单机来说就是全世界。所以内核调试的经验法则里约定俗成有一条——尽量别在生产环境上直接调试,要么靠日志和转储事后分析,要么准备两台机器做远程调试。

还有一个容易被忽视的坑:内核对时间的敏感度极高。你在一个自旋锁或者中断上下文里printk打印几十万个字符,看起来只是在“打印日志”,实际上已经让系统时序面目全非,原本复现稳定的bug可能就此消失,或者反而多出一堆莫名其妙的新问题。这就是为什么很多内核调试手段的核心设计目标,是“尽量少打扰系统”。

1.2 内核调试手段的分类与选型思路

从实际使用的角度,我习惯把内核调试手段分成四类:

  • 日志观察类:printk、dynamic debug、trace_printk、dmesg。这类手段成本最低,适合日常开发、驱动移植、系统启动过程定位。
  • 崩溃转储类:Oops、panic、kdump + crash。内核已经崩了的情况下,这是唯一能把案发现场完整保留下来并事后分析的手段。
  • 动态追踪类:ftrace、kprobes、uprobes、perf、eBPF。适合在不重新编译内核也不重启系统的情况下,动态观察函数调用、参数和性能热点。
  • 交互调试类:kgdb、kdb。适合在开发阶段单步跟踪内核代码,逻辑像用户态gdb,但环境搭建最讲究。

选型的逻辑,说穿了一句话:能用日志解决的事,不要上重型工具;系统已经崩了,才考虑转储;开发阶段必须看执行路径,再上kgdb。我在实际项目里的决策顺序通常是:先看dmesg有没有线索,没有就开dynamic debug或ftrace,再不行就用kprobes看某个函数的入口参数,最后才考虑kgdb这种大动干戈的方案。下面把这四类手段逐个拆开讲。

2. 日志与动态调试:把printk用到极致

2.1 printk的级别与console_loglevel,别再被“看不到日志”坑了

printk是内核开发者的第一个朋友,也是第一个敌人。很多新手在驱动里写了printk之后发现终端上啥也没有,第一反应是代码没执行,其实八成是日志级别的问题。

printk一共有8个级别,从0到7,数字越小优先级越高:

级别宏定义含义
0KERN_EMERG系统不可用
1KERN_ALERT必须立即处理
2KERN_CRIT严重错误
3KERN_ERR错误
4KERN_WARNING警告
5KERN_NOTICE正常但重要
6KERN_INFO信息
7KERN_DEBUG调试信息

控制台输出哪一级,由/proc/sys/kernel/printk决定,这个文件里有4个数字。第一个数字是当前控制台日志级别,意思是:只有数字小于这个值的消息才会打到串口或终端上。绝大多数系统默认值是7,理论上KERN_DEBUG(7)应该能看到,但实际很多发行版会在启动脚本里把它调成3或者4,于是KERN_INFO以下的消息全被终端过滤掉了,可日志本身并没丢,去dmesg里看照样在。

所以排查问题的第一步永远是:dmesg -T,带时间戳去看完整内核环形缓冲区。我见过太多同事花半天找驱动不起作用的原因,最后发现printk的信息一直在dmesg里躺着,只是在屏幕上不显示而已。

如果你非要让某个级别的日志实时显示在串口终端,可以临时改:

echo "4 4 1 7" > /proc/sys/kernel/printk

这里第二个数字4,意思是新printk消息的默认级别是4,如果不指定级别,消息就用这个值。第三个数字4是最低控制台级别,第四个7是默认控制台级别。一般调试期间我习惯设成7 7 1 7,看所有信息,调完再改回去。

2.2 dynamic debug:编译一次,日志开关任意控制

printk写多了有个致命问题:改一次日志就要重新编译一次内核模块,生产环境根本没法这么折腾。dynamic debug就是为了解决这个问题设计的,它的核心思路是:代码里预留一堆动态日志点,编译期放进内核,运行期再根据条件决定哪些日志打印出来。

使用之前先确认内核开没开CONFIG_DYNAMIC_DEBUG,然后挂上debugfs:

mount -t debugfs none /sys/kernel/debug

动态调试的控制文件在/sys/kernel/debug/dynamic_debug/control,查看当前所有可控制的日志点:

cat /sys/kernel/debug/dynamic_debug/control

每行是一个日志点,格式类似:

drivers/net/xxx.c:513 [xxx]print_hello =p "Hello %d\n"

注意看,日志点除了文件名行号,还带了个模块名。开启某个模块的全部动态日志:

echo "module xxx +p" > /sys/kernel/debug/dynamic_debug/control

开启某个文件的日志:

echo "file drivers/net/xxx.c +p" > /sys/kernel/debug/dynamic_debug/control

关闭则把+p换成-p。还有更精细的写法,比如只开某个函数:

echo "func foo +p" > /sys/kernel/debug/dynamic_debug/control

这里有一点必须提醒:+p和-p都是追加到现有标志上的,你得先想清楚自己需要哪些组合。比如=p表示只打印,=fl表示带文件名和行号,=flm再加上模块名。我最常用的组合是:

echo "file xxx.c +flm" > /sys/kernel/debug/dynamic_debug/control

这样每条日志前面自动带上文件、行号和模块名,对照源码找路径非常方便。

2.3 实战案例:驱动probe失败,如何用动态调试快速定位

去年我调一块SPI外设驱动,模块insmod正常,但probe函数返回-ENOMEM,dmesg里只有模块自己打印的两行初始化信息,根本看不出死在哪个步骤。这种情境最尴尬:代码是厂商给的BSP,源码是完整的,但厂商的日志写得稀烂,你想加printk又不想每次rebuild一遍ko。

当时我的操作路线是这样:

第一步,先开这个模块所有动态日志:

echo "module spi_xxx +p" > /sys/kernel/debug/dynamic_debug/control

控制文件里确实出现了大量日志点,但跑一遍流程,输出的日志还是不多。我意识到这个模块大部分地方用的是普通printk而不是pr_debug,dynamic debug管不着。

第二步,退一步用ftrace看probe函数的调用链,确认是不是走到了厂商源码里某一步就异常返回。ftrace的具体用法下一章会讲,这里先记住一个思路:内核函数执行路径不是黑盒,ftrace能让你说出“你看到了哪个函数被执行了”。

第三步,最终锁定问题:probe函数里有一个devm_kzalloc的申请参数用的是sensor_height * sensor_width * 2,而sensor_height读出来是0。不报错、不崩溃,就因为参数读出0直接返回忙。传统printk得改代码重新编译,动态调试虽然这次没直接帮上忙,但让我确定了问题方向。所以debug手段不要单打独斗,动态调试和ftrace经常要配合着用。

3. 崩溃现场还原:Oops、kdump、crash三板斧

3.1 读懂Oops的输出:从panic到Call Trace

内核崩的时候,最崩溃的是你。但越是这种时候越要冷静,因为内核在死之前其实打印了一大堆信息给你——这就是Oops输出。它能保住命,前提是你读得懂。

典型的Oops开头长这样:

BUG: unable to handle kernel NULL pointer dereference at 0000000000000018 IP: [<ffffffff812b3a90>] drm_fb_helper_hotplug_event+0x40/0x1e0

信息量非常大:第一行告诉你是什么样的访问错误,这里是对地址0x18的访问触发了空指针,实际上这个地址是从结构体偏移算出来的,你一看就知道是某个结构体成员的偏移。IP那行是当前指令指针的位置,方括号里是函数符号,后面跟了偏移。

继续往下是RIP寄存器、出错时的代码段、以及一大段Call Trace:

Call Trace: [<ffffffff812b3a90>] drm_fb_helper_hotplug_event+0x40/0x1e0 [<ffffffff815c5c0a>] intel_fb_async_flush+0x1a/0x20 [<ffffffff810a7fa5>] async_run_entry_fn+0x45/0x140

这段Call Trace是定位问题的核心线索。看到它一定要顺着最外层往上找,通常会理出一条完整的执行路径。比如上面这个例子,底层是async_run_entry_fn,说明这个崩溃发生在异步工作队列里,那么问题很可能不是某个直接调用者,而是异步任务本身的数据生命周期没管好。

还有一个细节很多人忽略:Oops里经常会带“Code”字段,后面跟一串十六进制指令。如果你手头有带符号的内核,可以手工把这串指令反汇编出来,有时能看到崩溃指令附近的操作数。

我把阅读流程总结成固定套路:先看错误类型(空指针、缺页、对齐等),再看IP/函数,再看Call Trace找问题入口,最后看寄存器值和Code判断具体哪条指令炸的。这套流程建议用文本记录模板写下来,遇到问题照着过一遍,不会漏信息。

3.2 kdump配置与vmcore分析实战

Oops输出的信息有限,而且系统可能随时二次崩溃,真正的完整现场要靠kdump保留。kdump的原理很简单:给系统预留一块内存,装一个“捕获内核”,主内核崩掉之后,捕获内核接管并落盘完整的vmcore文件。

配置步骤我按自己的实际经验整理:

第一步,给主内核预留内存。先在/etc/default/grub的GRUB_CMDLINE_LINUX里加参数:

crashkernel=512M

这个值看物理内存大小给。我的经验值是物理内存16G以下给512M,16G以上给1G。项目里遇到过预留太小导致捕获内核起不来的情况,这个参数在多个发行版上都有坑。

第二步,装上kdump相关工具。Debian/Ubuntu系:

apt install kdump-tools crash

CentOS/RHEL系:

yum install kexec-tools crash

第三步,设置捕获内核的转储位置,一般在/etc/kdump.conf里配置path字段,指向一个有足够空间的目录。

第四步,启用并启动服务:

systemctl enable kdump-tools systemctl start kdump-tools

配置完之后别急着上线,先手动触发一次panic验证能不能抓vmcore:

echo c > /proc/sysrq-trigger

注意,这条命令执行后系统立刻崩溃。如果一切正常,重启后会在你指定的目录看到vmcore文件,然后用crash工具载入调试信息:

crash /usr/lib/debug/boot/vmlinux-$(uname -r) /var/crash/vmcore

crash交互界面里我用得最多的几条命令:bt看进程调用栈,log看崩溃前完整内核日志,ps看当时系统中各进程状态,dis反汇编崩溃点附近代码。看崩溃时刻哪个进程在跑,往往是揭开问题大门的钥匙。

这里有个必须强调的细节:crash分析用的vmlinux必须和崩溃内核完全同版本、同配置,甚至最好同一份编译产物。否则符号表对不上,bt出来的栈可能完全是乱的。建议开发阶段就在内核源码目录里make vmlinux,保留一份带全符号的调试版本。

4. ftrace与kprobes:不给源码也能探针追踪

4.1 ftrace:函数级调用跟踪入门与实操

ftrace可能是被低估最严重的内核调试工具,它能在几乎不影响系统性能的前提下,记录内核函数执行路径。上手使用只需要挂上tracefs,默认路径在/sys/kernel/debug/tracing(老版本)或/sys/kernel/tracing(新版本)。

最基础的function tracer用法:

echo function > current_tracer echo > trace # 清空旧记录 # 触发想跟踪的操作 cat trace

默认会跟踪所有内核函数,输出会爆炸。实际使用必须加过滤,最简单的是按函数名前缀过滤:

echo 'xxx_*' > set_ftrace_filter echo function > current_tracer

另一个更实用的跟踪器是function_graph,它能把函数调用关系画成树状缩进,看执行路径一目了然:

echo function_graph > current_tracer

比如怀疑某个驱动加载顺序有问题,可以过滤该驱动模块名的函数前缀,然后反复加载卸载模块,观察trace输出。这种场景比printk的优势是:你不需要在代码里加任何东西,内核已经在编译期埋好了调用点。

还有一个在ftrace体系里被严重低估的API:trace_printk。它比printk强在哪里?printk是直接往内核log缓冲区写,即使你设了级别过滤,在高频路径上频繁调用还是会明显拖慢系统。trace_printk则把字符串写到trace环形缓冲区里,由ftrace基础设施统一管理,对执行路径的干扰小得多。用法和printk一模一样:

trace_printk("state=%d\n", state);

加完之后用cat trace就能看到。开发阶段用它,性能好、日志干净,也不用担心console刷屏。不过要注意,trace_printk的缓冲区和ftrace共享,默认缓冲区大小有限,生产环境建议echo 8192 > buffer_size_kb这种级别才够用。

4.2 kprobes:动态插桩跟踪一个内核函数的参数与返回

kprobes解决的是另一类问题:我怀疑某个内核函数被执行了,但我既不想重新编译,也不想重启系统,只想临时偷看一眼它被调用时的参数和返回值。这对线上故障定位特别重要。

kprobes最简单的使用方式是通过tracefs下的kprobe_events接口。它的语法看起来有点唬人,但核心就几点:先定义事件格式,再打开事件,再观察输出。

先看当前已定义的事件:

cat /sys/kernel/tracing/kprobe_events

定义一个新的kprobe事件,比如跟踪do_sys_open函数的第一个参数(文件名指针):

echo 'p:myprobe do_sys_open filename=$arg1' > /sys/kernel/tracing/kprobe_events

p表示在函数入口处插桩,myprobe是事件名,do_sys_open是目标函数,后面filename=$arg1是把第一个参数取出来,命名为filename。64位x86下函数参数按rdi、rsi、rdx的顺序传,$arg1对应用户态文件路径的指针。

接下来使能这个事件:

echo 1 > /sys/kernel/tracing/events/kprobes/myprobe/enable

然后触发一个文件打开操作,再去trace里看:

ls /tmp/testfile cat /sys/kernel/tracing/trace

你会看到形如ls-12345 [001] ... myprobe: filename=0xffff888012345678的输出。这里地址还是内核态的指针值,要还原成文件路径还得用post_processing之类的复杂玩法,或者直接换成tracefs的-s选项。实际排障中,你不一定需要完整路径,只要看到指针值和调用频率,就能判定这个函数有没有被频繁调用、参数是否异常。

kprobes还有个常用变体是r标志,插桩到函数返回处取返回值:

echo 'r:myret do_sys_open ret=$retval' >> /sys/kernel/tracing/kprobe_events

注意r事件只能定义新的事件后用同样的cli机制使能。很多朋友问我,kprobes和ftrace function_graph有什么本质区别?我的理解是:ftrace记录的是“这条路径上走了哪些函数”,是宏观执行流;而kprobes是微观探针,能在某个具体函数上拿到参数、返回值甚至内存内容,而且插桩位置可以是任意函数,不必提前编译支持。两者结合使用,定位诡异问题会快得多。

5. kgdb远程调试:像调试用户态程序一样调试内核

5.1 kgdb环境准备与启动参数

到了kgdb这个层级,意味着你准备用gdb那套断点、单步、查看变量的交互式玩法直接对付内核。kgdb的调试模式分两种:一种是用kdb内置命令控制台,不需要外部gdb;另一种是真正让外部gdb通过串口或网口连接上来,这才是“像调试用户态程序一样调试内核”的正确姿势。

准备环境时,先确保内核编译打开了这些配置:

CONFIG_KGDB=y CONFIG_KGDB_SERIAL_CONSOLE=y CONFIG_KGDB_KDB=y CONFIG_DEBUG_INFO=y

还需要在启动参数里指定kgdb使用的串口,我一般用server模式,进入等待断点状态:

kgdboc=ttyS0,115200 kgdbwait

这里kgdbwait是让内核启动到一定阶段后停下来等待远端gdb连接。如果不想机器刚启动就等调试器,可以去掉kgdbwait,启动完全正常后再通过sysrq触发进入调试模式:

echo g > /proc/sysrq-trigger

这条命令让系统进入kgdb状态,此时系统暂停,等待外部调试器连接。系统在等待时,屏幕会变成kdb提示符,如果你看得懂kdb,可以直接在本地敲help玩起来;如果你要远程连,就在宿主机上启动gdb连接。

5.2 gdb连接内核的操作步骤与经验

宿主机上的gdb要带内核符号,最简单的方式是直接用编译出来的vmlinux:

gdb vmlinux

gdb里连接目标机:

(gdb) target remote /dev/ttyS0

如果用的是kgdboe(以太网),则是:

(gdb) target remote 192.168.1.22:6443

连接成功后,gdb会提示kgdb的版本信息。之后的操作基本就是用户态那套流程了:

(gdb) break panic (gdb) continue

目标机继续运行,等触发断点后再切回gdb,这时候你可以看调用栈、打印变量、甚至修改内存。内核调试有一个好消息和一个坏消息:好消息是vmlinux里带符号,list、bt这些命令都能用;坏消息是内核执行环境很敏感,单步跟踪自旋锁临界区、中断处理函数这种地方,极可能让系统完全卡死,因为你在单步期间,lockup watchdog可能已经触发重启了。

我踩过的坑里,有一个特别典型:目标机用串口连接kgdb,速率设115200,但内核日志开关还开着,结果控制台打印的大量printk把串口占满了,gdb的交互报文被挤掉,连接一断一断的。解决办法是进入调试前先临时调低console loglevel,把串口让给kgdb干活:

echo "1 1 1 7" > /proc/sys/kernel/printk

然后才echo g > /proc/sysrq-trigger。这一招实操中非常好用。

另外提醒一点:kgdb调试内核时,如果你要调试某个模块里的函数,光载入vmlinux不够。模块通常是编译成ko的,有不同的加载地址,你得先在目标机上查到模块的加载基址,然后在gdb里:

(gdb) add-symbol-file /path/to/foo.ko 0xffffffffc0000000

这个地址从目标机dmesg里可以找到,不找对地址,断点到模块函数上完全无效。

6. 实战问题排查与避坑技巧实录

6.1 调试过程中我踩过的坑

第一坑:dynamic debug开不起来。很多内核发行版根本没开CONFIG_DYNAMIC_DEBUG,你echo命令一点反应没有,控制文件也不存在。检查方法很简单:grep DYNAMIC_DEBUG /boot/config-$(uname -r)。没开就老老实实改用ftrace或者用module_param做自制开关。

第二坑:kdump装好了但是触发panic不落盘。排查顺序先看cat /sys/kernel/kexec_crash_loaded,为0说明捕获内核没加载,多半是crashkernel内存分配失败;再看systemctl status kdump-tools,多半是预留内存和当前运行内核有冲突。还有一个隐蔽原因:某些驱动占用了crashkernel保留区域,导致kexec加载失败。

第三坑:ftrace过滤条件意外匹配了过多函数。比如你用echo 'dev_*' > set_ftrace_filter想过滤dev开头的函数,实际上内核里dev前缀的函数有几千个,跟踪输出瞬间把trace缓冲区打爆。我的经验是先查available_filter_functions里有多少匹配项,再用精确函数名过滤,缓冲区太小就echo 65536 > buffer_size_kb,但别贪多,缓冲区大了读取输出会变慢。

第四坑:kprobes事件名重复导致定义失败。如果你在事件名上用了系统里已有的名字,或者写入时不注意用了>覆盖了之前的定义,再触发时会发现事件没有使能。解决的标准化操作是先clear再写:

echo > /sys/kernel/tracing/kprobe_events

然后单独写入一组探针定义。写完后顺手cat kprobe_events确认你定义的三个字段都在。

6.2 常用调试命令与内核参数速查表

把高频命令整理成一张表,存下来当工作手册用:

目的命令/参数备注
查看完整内核日志dmesg -T时间戳直观
临时放开控制台日志echo "7 7 1 7" > /proc/sys/kernel/printk只对当前运行有效
挂载debugfsmount -t debugfs none /sys/kernel/debug很多工具依赖
查看动态调试点cat /sys/kernel/debug/dynamic_debug/control确认模块是否支持
开启模块动态日志echo "module xxx +p" > .../dynamic_debug/control记得支持与否先查
查看ftrace当前模式cat /sys/kernel/tracing/current_tracernop是未启用
配置crashkernel内存crashkernel=512M按内存量调整
手动触发崩溃echo c > /proc/sysrq-trigger用于验证kdump,慎用
进入kgdb等待模式echo g > /proc/sysrq-trigger触发后系统暂停
gdb连接kgdbtarget remote /dev/ttyS0或kgdboe网络地址

最后再总结一条个人经验:内核调试工具不存在“万能药”,每种手段都有它的适用场景和代价。我刚入行时总希望一个工具包打天下,后来发现最有效的路径永远是:先看日志,日志不够就上动态追踪,追踪不到就直接抓崩溃点,开发阶段有条件就上kgdb。多实践几次,你会自然形成一套自己的排查节奏。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询