更现代的内核调试工具
作者:Tom Jones

恐慌(Panic)是奇妙的词!
它简要描述了极其复杂的情绪事件。我们可以说“士兵慌乱了”,于是便能想象一场战斗的局势。我们可以用它来表达小小的疏忽带来的分量,也可以解释当我们踏上飞机却怀疑自己是否关掉了烤箱时,脑子里在想什么。
肯定是关了吧。
“恐慌(Panic)”也许是我最喜欢在书封面上看到的词:《Panic! Unix 系统崩溃转储分析》(Panic! Unix System Crash Dump Analysis)——哇!我一定得有一本。所有使用此类标题的作者肯定写了一本好书,即使它只是一本陈旧的 Sun OS/Solaris 技术手册。
优秀的书名比技术专著更经得起时间考验,而最先过时的内容往往是现有系统的厂商文档。到了 2024 年,我发现非常难找到关于操作系统调试方法的最新资料。看看已出版的作品,你会以为我们在 2004 年就已经完善了操作系统。我从未使用过 SunOS 或 Solaris,但《Panic!》给了我一直想要的崩溃分析入门。
我承认,我一直不理解为什么人们如此渴望核心转储——我会想,核心转储能比堆栈跟踪给我们更多什么呢?但从《Panic!》中,我迈出了崩溃分析的第一步,踏上在 FreeBSD 上尝试最前沿内核调试工具的旅程。下面展示我学到的东西。别担心,不需要等着柠檬浸湿的纸巾了。(译者注:此处指不必无聊地等待和无意义的步骤,即本文切中主旨,简单高效)
获取内核转储
我得坦白,我知道你不会尝试内核转储分析,除非确有必要。当你遇到卡住的机器时,那条路通向的就是 printf 调试的生活。
获取能用的核心转储并不难——你需要把系统设置成接收崩溃转储(参见 dumpon(8)),然后进入调试器。通常,系统会主动陷入 Panic,帮助你进入调试器。FreeBSD 提供了一项功能,即使在没有出现问题时也可以让内核陷入 Panic。在测试系统上将 debug.kdb.panic sysctl 设置为 1,即可进入调试提示符:
如果你在桌面或云中的重要工作机上运行了这个命令,可能会遇到麻烦(如果是桌面,这篇文章可能已经消失了)。我建议在虚拟机或至少不会引发大麻烦的设备上学习内核调试。
此外,FreeBSD 虚拟机镜像默认配置为在启动时运行 savecore,并保存崩溃转储文件。
在设置 debug.kdb.panic 后,你将进入 ddb(4) 提示符。ddb 是一款功能齐全的实时系统调试器——它是很好的分析工具,但今天我们用不上它。
在 ddb 中,可以使用 dump 命令导出运行中的内核。
Panic 状态下系统无法使用,因此需要重启以继续操作。
当虚拟机重启时,savecore 会显示一条信息,提示提取并保存了核心文件。
核心文件会放置在目录 /var/crash 下,还有一些其他文件。
本次测试的核心文件是 vmcore.0,并附有相应的 info.0 和 core.txt.0 文件。info 文件是主机和转储的摘要,core.txt 是转储文件的摘要、消息缓冲区中的未读部分、Panic 字符串和堆栈跟踪(如果存在的话)。
bounds 文件告诉转储程序下一个核心转储将命名为 vmcore.1,当前机器上的 bounds 是:
最后,vmcore.last 是指向最近的核心转储文件的链接,万一你经历了有趣的一周,记不清最近的崩溃记录。
符号
分析核心转储的第二个必要内容是内核符号。正式发布版的内核符号可以通过 kernel-dbg 包获取,安装到目录 /usr/lib/debug/,或者可以从内核构建目录中提取出来。
使用 gdb 查看核心转储(入门)
首先,我们用 kgdb 快速查看核心转储,为后续对比 lldb 崩溃转储调试的发展程度提供基准。
kgdb 启动时会显示许可证(已省略),然后打印内核信息缓冲区的最后部分,这是 kgdb 提供的很有用的功能。消息缓冲区的最后部分告诉我们 Panic 信息、其他信息和堆栈跟踪。
使用 kgdb 的 bt(backtrace)命令,我们可以获取堆栈跟踪,并通过 frames 命令在堆栈中移动,查看 Panic 发生时的具体情况。
回顾一下,我列出了导致 Panic 的回溯路径,识别了大约在 frame #11 处调用的 panic,并要求 kgdb 跳转到 frame #12(导致 Panic 的具体代码),然后列出该处的代码。进一步调查可以帮助我们找出在此崩溃转储中导致 Panic 的原因。
这些是内核调试的基本步骤,查看当时的情况并分析崩溃转储,以找出变量持有的值。要在内核上下文中发挥作用,lldb 需要能够完成这些任务。
lldb
在过去的十年里,FreeBSD 一直在向更加自由许可的 llvm/clang 工具链迈进。调试支持一度缺失,但在 2024 年,使用 lldb 调试 FreeBSD 内核成为可能。
lldb 可以将 FreeBSD 内核转储导入为核心文件,并在堆栈帧之间移动。
lldb 是由苹果开发的。我还记得他们将默认调试器从 gdb 切换为 lldb 时,我受到强烈的文化冲击。过去从晦涩难懂、没有文档的 GNU 世界中摸索出来的所有调试命令都不见了,取而代之的是一些奇怪的命令。
实际上,lldb 并非专为命令行界面设计,而是为了通过 API 被软件驱动。这体现在许多命令的冗长上。幸运的是,lldb 的命令行现在支持更多 gdb 风格的命令,许多命令界面更为一致。基本命令如打印操作现已兼容,但其他选项有差异,有些更好,有些则差很多。
使用 lldb 探索
无需特殊配置,lldb 即可分析内核转储。加载崩溃转储的方式与 kgdb 相同,只是参数位置略有不同:
在我们的示例中,命令如下:
这比 kgdb 的启动过程更简洁,这很好,但缺少了一些崩溃转储的关键上下文。究竟是什么导致了此次转储呢?
kgdb 并没有什么神奇的魔力(否则它可能会有个“修复”(fix)命令来搭配“断点”(break)命令)。它所做的只是查找转储中的已知符号,并在启动时将其打印出来。
我们可以自己做这些操作。
首先,内核中的 Panic 信息存储在字符串 panicstr 中,并由 vpanic 设置(位于 kern/kern_shutdown.c)。我们可以通过 lldb 从转储中轻松提取这一信息:
这可能足够让人开始调试。我也喜欢通过 lldb 的 bt 命令获取堆栈跟踪:
我们还可以选择感兴趣的 frame 来进一步查看:
获取内核缓冲区
打印内容并在堆栈中移动是内核崩溃转储调试所需的大部分操作。gdb 的启动消息很不错,会展示内核消息缓冲区的最后部分,就像直接从本地控制台输出的一样。
lldb 目前还没有类似的启动命令。不过,《Panic!》为我们提供了一些提示,告诉我们如何自己提取这些信息。《Panic!》使用了名为 msgbuf 的宏,通过 struct msgbuf 打印内核消息缓冲区。
通过查阅 FreeBSD 的源代码,我们可以找到类似的代码来完成此操作:
内核中存在全局可见的结构体 msgbuf,用于实现内核的消息缓冲区。lldb 可以显示缓冲区的起始部分。字段 msg_wseq 和 msg_rseq 告诉我们写入和读取的位置。
读取消息缓冲区中未读部分非常简单:
输出的格式不太友好,控制字符直接打印出来了,不过我们能读取内核消息缓冲区的内容。输出被截断,无法获取完整的回溯信息。试试其他命令:
我们遇到了打印限制。尽管我尝试了各种办法,还是无法说服 lldb 继续读取。是时候使用更高级的工具了。
一些来自 Lua 的帮助
lldb 还提供了用于控制的脚本接口,这也是为什么许多命令的输入非常冗长。目前,lldb 支持 C++ 和 Python 脚本,且试验性支持 Lua。FreeBSD 的基本系统中自带 Lua,而 2024 年的 FreeBSD 版本的 lldb 默认支持 Lua。
可以通过以下方式简单试验:
提示符 >>> 表明我们已进入 Lua 解释器。
在《Panic!》一书中,我们了解到 SunOS/Solaris 调试器 adb 有方便易懂的宏,用于查找和打印消息缓冲区:
有了这个示例,使用 Lua 实现类似的机制应该没问题。
lldb 的 Lua 接口是通过 swig 绑定生成的。swig 是使用 C++ 格式来描述库间接口的工具。Python 和 Lua 绑定是用相同的方式生成的。如果对 API 和如何使用有疑问,可通过查看 lldb 项目提供的 Python API 文档找到答案。这种做法很笨拙,但可行。
很快我就厌倦了在解释器中运行命令,考虑到命令的长度,有时操作起来很麻烦。lldb 可以在解释器运行后从文件中加载 Lua 脚本。在全新的会话中:
假设文件 hello.lua 内容为:
lldb 的 Lua 环境提供了变量 lldb,包含了访问目标、调试器、帧、进程和线程的成员。这些对象映射到 Python API 中描述的对象。
我并不是很喜欢 lldb 的 API,编写起来比较繁琐,如果选择的函数和变量布局有问题,理解起来也比较困难。
积累一些经验后,就能更好地理解它的需求。
让我通过示例来说明如何使用 lldb 的 Lua 绑定从崩溃转储中打印消息缓冲区。
通过 lldb 的 Lua 变量,我们能访问转储镜像中的文件。我最初开始分析核心转储时,一大障碍是理解如何在内存中定位信息。可以从各种内核全局变量入手,大多数子系统都有可以用于构建的内容。
正如我们之前所看到的,msgbufp 是内核消息缓冲区的全局实例。在 lldb Lua 中可以通过以下方式访问它:
这会返回一个 SBValue 实例,代表核心转储内存中的这个结构体实例。我们可以使用 GetChildMemberWithName 方法和成员名(例如 msg_rseq)来访问结构体的子成员。
对象 lldb.process 允许我们从内核转储中读取内存。正确组合引用、地址和值来完成想要的操作有时可能需要一些调整。
使用这些方法,我们可以组装指向消息缓冲区起始位置的指针,从核心转储中读取它,并使用 Lua 打印出来。我将所有内容放入了名为 msgbuf.lua 的脚本中:
在 lldb 会话中运行这个脚本,我们得到以下输出:
Lua 贴心地为我们展开了缓冲区中的控制字符,从消息缓冲区输出格式良好的内容。
更好的调试功能
在内核调试方面,lldb 还是新手,且在 Lua 环境中仍有许多功能不可用,但现有的功能足以使其成为有用的工具。gdb 资深用户可能会对这些示例的价值存疑,毕竟 lldb 的语法复杂许多,可能看起来更像是为了改变而改变。
lldb 与其内置的 Lua 的重要价值在于它随 FreeBSD 发行版镜像一同分发。lldb Lua 采用自由许可协议,与 FreeBSD 兼容,从 2024 年初开始,CURRENT 构建版本中默认启用了它。这让内核开发人员和问题排查人员可以使用 lldb Lua 编写脚本,并提供给用户分析。
kgdb 很早就支持 gdb 脚本,但这种脚本语言并不友好。而 Lua 虽然有些奇怪,但在许多环境中都很常见,并且是 FreeBSD 引导加载程序的一部分。我编写了一个工具,能从崩溃的内核镜像中提取 TCP 日志文件——主要的难题在于如何获取内存。只要获取了数据,创建并写入文件就是轻而易举的事。
内核转储包含所有信息,其中可能包含敏感数据。合理的脚本语言使开发人员可以提供脚本,从内核镜像中提取进一步的调试信息,无需移动大型核心转储文件,也无需担心让陌生人接触可能的敏感信息。
Tom Jones 是 FreeBSD 提交者,对保持网络栈的高速运行充满兴趣。
最后更新于