使用用户模式 Linux 调试内核模块
当你在用户空间编写程序时,你的程序可能发生的最糟糕的事情就是核心转储。你的程序做了一些非常错误的事情,所以操作系统决定以核心文件的形式将它的所有内存和状态信息返回给你。核心文件随后可以用于调试你的程序并修复问题。
当你在内核中编程时,没有操作系统会介入并安全地停止你的代码运行,并告诉你你遇到了问题。Linux 内核对它自己的代码相当友好。有时,如果你做了一些相对良性的错误(这些崩溃通常被称为 oopses),它可以从崩溃中恢复。但是,没有任何东西可以阻止你的代码覆盖或访问内核地址空间中任何位置的内存地址。此外,如果你的模块挂起,内核也会挂起(从技术上讲,是你当前的内核线程挂起,但结果通常是相同的)。
对于天真的人来说,这些问题可能听起来是良性的,但它们是严重的问题。如果内核崩溃,你很少确切地知道是什么原因导致的崩溃。典型的解决方案是到处放置 printks,并希望你在消息丢失到重启之前偶然发现问题。所有这一切都假设你没有损坏你的文件系统。我曾经因为一次糟糕的定时崩溃(以及由于一个错误初始化的指针覆盖了 ext2 的一些内部结构)而丢失了整个文件系统。
你在内核编程时学到的第一件事就是将你所有的代码都放在 NFS 上。文件在另一台机器上仍然是安全的。但是,这并不能为你节省每次崩溃时运行 e2fsck 的时间。另外,你仍然可能丢失你的文件系统,即使你的源代码在另一台机器上是安全的。
因此,考虑到所有这些问题,进入内核编程领域的人数如此之少也就不足为奇了。现在,这一切都可能改变。
早在大型机时代,当分时机器成为常态时,虚拟机的概念就诞生了。虚拟机是一个完全由你支配的封装计算机。虚拟机上的程序无法真正访问物理硬件。所有硬件访问都由机器或模拟器控制。
VMware (www.vmware.com) 有一个非常强大的虚拟机,允许你在 Windows NT、2000、XP 或 Linux 下运行任何基于 x86 的操作系统。SoftPC(一个 8086 模拟器,允许你运行 Windows 和 DOS 程序)自 1988 年以来已在基于 Motorola 68k 的计算机(即 Macintosh)上可用。
真正的虚拟机有时对于学习者的预算来说太昂贵了。(VMware Workstation for Linux 从他们的网站上售价 299 美元。)值得庆幸的是,现在对于那些只想运行 Linux 的人来说,有一个免费的替代方案:用户模式 Linux (UML)。
用户模式 Linux (user-mode-linux.sourceforge.net) 不是一个完整的虚拟机。它不模拟不同的硬件,也不让你能够运行其他操作系统。但是,它 确实 允许你在用户空间中运行内核。这在开发方面为你带来了几个好处:主机文件系统免受损坏,虚拟文件系统是可撤销的(这使其免受损坏),你可以在一台机器上运行多台机器(这对于测试机器间通信非常有用,即网络消息,而无需使用多台机器),并且在调试器中运行内核非常容易。
运行 UML 很简单。你可以下载二进制包之一(内核二进制文件,加上一些工具),或者你可以下载内核补丁。你还需要下载一个文件系统。我建议先玩一下二进制文件,然后构建一个自定义内核以满足你的需求。《HOWTO》涵盖了所有这些主题以及更多内容。
UML 的一个有用的好处是写时复制文件。这些文件允许你修改虚拟文件系统,而无需修改基本文件系统。对文件系统的所有写入或修改都存储在这些文件中,通常以扩展名 .cow 结尾。
因此,当你在工作时,并且你使文件系统崩溃,你所要做的就是删除 .cow 文件(它将被重新创建),并且你的损坏的文件系统将恢复到其原始版本。(也有工具可以将 .cow 文件中的更改合并回原始文件系统,如果你想保留你的更改。)
一旦你启动并运行了 UML,就该开始玩了。我编写了一个非常简单的内核模块用于测试。它使用四个设备,/dev/gentest[0-3]。该模块对每个设备的处理方式略有不同。设备 1 是一个接收器(就像 /dev/null)。设备 2 存储一个字符串,供以后检索。你可以从设备 3 读取模块的状态,而设备 0 可以是其他三个设备中的任何一个,具体取决于它的配置方式。(你可以使用 ioctl 调用更改配置。)内核模块可从 www.frascone.com/kHacking/gentest-0.1.tar.gz 获取。
那么,让我们制造一个错误——一个讨厌的错误。假设当有人打开设备 4 (cat /dev/gentest4) 时,模块在一个讨厌的循环中挂起:for(;;) i++; (见清单 1)。死锁或挂起是编写程序时常见的错误。它们有时很难找到。通常程序员只使用 printks 来定位错误:printk("Got here!\n");。这种类型的调试是有效的,但是你仍然会在找到问题之前多次挂起系统。通过不断的 fscks,它可能会变得很糟糕。但是,使用 UML,你只需添加 printks 并每次重启到一个新的文件系统来测试它。
UML 将帮助我们使用 printks 找到该错误,但这并没有给我们带来超过几次重启的麻烦。现在让我们制造我们的第一个真正讨厌的错误。假设当有人从设备 5 读取时(即,cat /dev/gentest5);模块开始覆盖所有内存:memset(0, 0, 0xffffffff); (见清单 2)。覆盖内存是 C 程序中常见的错误。在内核中,它尤其令人讨厌,有时会导致立即重启,使你无法看到任何生成的 printks。这些错误仍然可以使用 printk 隔离,但这是一个非常耗时的过程。
从我目前所介绍的内容来看,UML 是一个很棒的调试工具。你可以使用它来在调试模块时保持文件系统的安全。但还有更多:GDB。
正如大多数经验丰富的内核程序员所知,已经有一种使用 GDB 和串行线调试内核的方法。但是,以我的经验来看,它真的不太好用。内核中的 GDB shim 有时会挂起,你需要两台机器才能使其工作。我通过将虚拟机的串行端口重定向到一个文件,成功地调试了在 VMware 中运行的内核,但进展缓慢,因为 GDB 代码的内核部分有时仍然会挂起。
UML 使所有这些都成为过去。使用 UML,你可以在 GDB 下运行整个虚拟机,在内核运行时甚至在崩溃后附加到内核。在 GDB 下运行 UML 最简单的方法是在你的运行行中添加命令行标志 debug。然后 UML 将在一个 xterm 中为你生成 GDB 并停止内核。对于大多数用途,只需键入 c 以允许内核继续启动(见图 1)。
要调试模块,你首先必须加载模块,然后告诉 GDB 符号文件在哪里,然后设置你需要的所有断点。
所以,首先要做的是加载模块。源代码中包含一个名为 loadModule 的简单 shell 脚本,它加载模块并在设备尚不存在时创建设备。
模块加载完成后,在 GDB 窗口中按 Ctrl-C 暂停内核,并查看 module_list 指针。最后加载的模块应该在列表的头部。你可以使用一个简单的 printf 命令来获取模块的地址。加载符号文件时你需要它(见图 2)。
现在,使用命令 add-symbol-file MODULE_PATH ADDRESS 加载符号文件。使用的文件名是主机系统上的文件名,不是 虚拟机上的文件名。在回答“y”以确认“你确定你知道你在做什么吗?”问题后,符号文件将被加载。你可以通过再次重新检查 module_list 指针来检查它是否已正确加载。请注意,现在 init 和 cleanup 指针具有与其地址关联的适当函数名称(见图 3)。
现在模块已加载,你可以设置你想要的任何断点。我将在 open 处设置一个断点,然后尝试 cat 其中一个设备(见图 4)。
现在,让我们运行我们的两个测试,看看在使用 GDB 时,这些错误有多难找到。在第一个测试中,系统仍然挂起。但是,现在我们可以按调试器中的 Ctrl-C,看看它挂在哪里。
在挂起测试中(见图 5),很明显当前的停止点在 for 循环内部。如果我们真的想玩得开心,我们可以打印出 i 的值来看看它包含什么。
现在,内存覆盖有点困难。不是因为它是一个崩溃,而是因为我使用了 memset。memset,在 GNU libc 中,最终会将内联汇编插入到你的代码中,所以看起来你的错误在 string.h 中,而不是在你的模块中。但是,它仍然让你知道错误发生在哪个函数中,并且你仍然知道它是在 memset 内部(见图 6)。
此外,你仍然可以检查当前函数 (gRead) 中的任何局部变量或任何全局变量,以帮助你找到问题。
虽然 UML 可能不允许你调试设备驱动程序(因为 UML 无法访问机器上的物理硬件),但它在调试内核模块方面是一个非常有价值的辅助工具。它允许你像编写和调试其他 C 程序一样轻松地编写和调试内核模块,而无需担心崩溃、死锁和数据丢失。它是任何内核黑客工具箱的有用补充。
