ARTICLE DETAIL

资讯详情

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

Linux内核调试手段全解:从printk到kgdb的实战指南

Linux内核调试手段全解:从printk到kgdb的实战指南 说实话内核调试比用户态调试恶心得多这一点只要你碰过一次内核崩溃就能切身感受到。上一章我们聊的主要是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_DEBUG7应该能看到但实际很多发行版会在启动脚本里把它调成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然后挂上debugfsmount -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函数返回-ENOMEMdmesg里只有模块自己打印的两行初始化信息根本看不出死在哪个步骤。这种情境最尴尬代码是厂商给的BSP源码是完整的但厂商的日志写得稀烂你想加printk又不想每次rebuild一遍ko。当时我的操作路线是这样第一步先开这个模块所有动态日志echo module spi_xxx p /sys/kernel/debug/dynamic_debug/control控制文件里确实出现了大量日志点但跑一遍流程输出的日志还是不多。我意识到这个模块大部分地方用的是普通printk而不是pr_debugdynamic 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_event0x40/0x1e0信息量非常大第一行告诉你是什么样的访问错误这里是对地址0x18的访问触发了空指针实际上这个地址是从结构体偏移算出来的你一看就知道是某个结构体成员的偏移。IP那行是当前指令指针的位置方括号里是函数符号后面跟了偏移。继续往下是RIP寄存器、出错时的代码段、以及一大段Call TraceCall Trace: [ffffffff812b3a90] drm_fb_helper_hotplug_event0x40/0x1e0 [ffffffff815c5c0a] intel_fb_async_flush0x1a/0x20 [ffffffff810a7fa5] async_run_entry_fn0x45/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里加参数crashkernel512M这个值看物理内存大小给。我的经验值是物理内存16G以下给512M16G以上给1G。项目里遇到过预留太小导致捕获内核起不来的情况这个参数在多个发行版上都有坑。第二步装上kdump相关工具。Debian/Ubuntu系apt install kdump-tools crashCentOS/RHEL系yum install kexec-tools crash第三步设置捕获内核的转储位置一般在/etc/kdump.conf里配置path字段指向一个有足够空间的目录。第四步启用并启动服务systemctl enable kdump-tools systemctl start kdump-tools配置完之后别急着上线先手动触发一次panic验证能不能抓vmcoreecho c /proc/sysrq-trigger注意这条命令执行后系统立刻崩溃。如果一切正常重启后会在你指定的目录看到vmcore文件然后用crash工具载入调试信息crash /usr/lib/debug/boot/vmlinux-$(uname -r) /var/crash/vmcorecrash交互界面里我用得最多的几条命令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体系里被严重低估的APItrace_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_eventsp表示在函数入口处插桩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: filename0xffff888012345678的输出。这里地址还是内核态的指针值要还原成文件路径还得用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_KGDBy CONFIG_KGDB_SERIAL_CONSOLEy CONFIG_KGDB_KDBy CONFIG_DEBUG_INFOy还需要在启动参数里指定kgdb使用的串口我一般用server模式进入等待断点状态kgdbocttyS0,115200 kgdbwait这里kgdbwait是让内核启动到一定阶段后停下来等待远端gdb连接。如果不想机器刚启动就等调试器可以去掉kgdbwait启动完全正常后再通过sysrq触发进入调试模式echo g /proc/sysrq-trigger这条命令让系统进入kgdb状态此时系统暂停等待外部调试器连接。系统在等待时屏幕会变成kdb提示符如果你看得懂kdb可以直接在本地敲help玩起来如果你要远程连就在宿主机上启动gdb连接。5.2 gdb连接内核的操作步骤与经验宿主机上的gdb要带内核符号最简单的方式是直接用编译出来的vmlinuxgdb vmlinuxgdb里连接目标机(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内存crashkernel512M按内存量调整手动触发崩溃echo c /proc/sysrq-trigger用于验证kdump慎用进入kgdb等待模式echo g /proc/sysrq-trigger触发后系统暂停gdb连接kgdbtarget remote /dev/ttyS0或kgdboe网络地址最后再总结一条个人经验内核调试工具不存在“万能药”每种手段都有它的适用场景和代价。我刚入行时总希望一个工具包打天下后来发现最有效的路径永远是先看日志日志不够就上动态追踪追踪不到就直接抓崩溃点开发阶段有条件就上kgdb。多实践几次你会自然形成一套自己的排查节奏。
返回列表