使用 QEMU 和 GDB 搭建 Linux 内核调试环境

1. 引言

Linux 内核是一个庞大而复杂的软件系统。对其进行开发、分析或故障排查通常需要强大的调试工具。直接在物理硬件上调试内核可能既困难又不方便,需要专门的硬件调试器(如 JTAG)或复杂的设置。QEMU 作为一个功能强大的开源机器模拟器和虚拟器,结合 GNU 调试器 (GDB),为内核调试提供了一个灵活、可控且成本低廉的环境。

本报告旨在详细阐述如何利用 QEMU 的系统仿真能力和 GDB 的远程调试功能,搭建一个用于 Linux 内核调试的完整环境。我们将涵盖从获取内核源代码、配置编译内核以包含调试信息、创建最小化的根文件系统 (initramfs)、启动 QEMU 虚拟机并连接 GDB,到使用 GDB 进行基本和高级内核调试操作的全过程。通过这种方式,开发者可以在不依赖特定硬件的情况下,深入探索内核的内部工作机制、跟踪代码执行、检查内存和寄存器状态,从而有效地定位和解决问题。

2. 理解 QEMU 与 GDB 的集成

QEMU 的核心能力之一是系统仿真,它能够虚拟化一个完整的计算机系统,包括 CPU、内存和各种外设。这使得我们可以在宿主机上运行一个客户操作系统(Guest OS),如同它运行在真实的硬件上一样。对于内核调试而言,QEMU 提供了一个关键特性:GDB Stub(或称为 gdbserver)。

GDB Stub 是 QEMU 内置的一个小型服务器,它实现了 GDB 远程串行协议 (RSP)。当启用 GDB Stub 时,QEMU 会监听一个指定的端口(通常是 TCP 端口)或 Unix 套接字,等待来自 GDB 客户端的连接。一旦 GDB 连接成功,它就可以通过 RSP 协议向 QEMU 发送命令,以控制虚拟机的执行、检查虚拟 CPU 的寄存器状态、读写客户机内存,以及设置断点和观察点。

这种集成方式模拟了使用 JTAG 等低级调试工具调试物理硬件的过程,但完全在软件层面实现。开发者可以在宿主机上运行 GDB,连接到在 QEMU 中运行的客户内核,就像调试本地应用程序一样方便,但目标却是整个操作系统内核。QEMU 的 GDB Stub 支持调试系统仿真模式下的客户代码,并且能够处理多核 CPU 的情况,将每个 CPU 核心暴露给 GDB 作为可调试的线程。

3. 获取 Linux 内核源代码

进行内核调试的第一步是获取目标内核版本的源代码。官方的 Linux 内核源代码主要由 Linux Kernel Organization 维护,并通过其网站 kernel.org 分发。

3.1 官方代码仓库

kernel.org 是 Linux 内核源代码的主要分发点,托管着所有版本的内核源码仓库。这些仓库使用 Git 进行版本控制,Git 是由 Linus Torvalds 设计的分布式版本控制系统,用于保证源代码的完整性和追踪变更。除了 kernel.org,GitHub 上的 torvalds/linux 仓库也是一个官方镜像,包含了主线内核的开发历史。

3.2 使用 Git 克隆源代码

获取源代码最推荐的方式是使用 Git 克隆官方仓库。这允许你轻松地在不同版本之间切换,并访问完整的提交历史。

  • 克隆仓库:
    • 主线内核 (Linus Torvalds' tree): 这是最新的开发版本,包含所有最新的特性和变更。 Bash

      git clone git://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git
      # 或者使用 HTTPS
      # git clone https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git
      
    • 稳定版/长期支持版 (Stable/Longterm): 这些版本基于主线版本,但只包含错误修复和重要的驱动更新,通常更适合生产环境或需要稳定性的调试。 Bash

      git clone git://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git
      # 或者使用 HTTPS
      # git clone https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git
      

3.3 检出特定版本

为了保证调试的可复现性,通常需要针对一个特定的、带有标签 (tag) 的内核版本进行工作。

  • 列出可用标签: git tag -l 会显示所有可用的版本标签。
  • 检出特定标签: 使用 git checkout <tag_name> 命令切换到指定的版本。例如,要检出 v6.6.16 版本: Bash

    cd linux # 进入克隆的仓库目录
    git checkout v6.6.16
    

虽然可以从 kernel.org 下载特定版本的 tar 压缩包,但对于调试和开发而言,克隆完整的 Git 仓库通常是更好的选择。Git 仓库包含了完整的代码演进历史,使得开发者可以方便地使用 git checkout 在不同版本间切换,或者使用 git bisect 等工具来定位引入问题的提交。相比之下,管理多个解压后的 tar 包目录会繁琐得多。因此,尽管初始克隆可能需要较长时间和较多磁盘空间,但其提供的版本控制和历史追溯能力对于深入的内核调试工作来说是非常有价值的。

4. 配置和编译内核

获取源代码后,需要对其进行配置和编译,以生成包含调试信息的内核镜像。

4.1 建立基础配置

内核的编译选项存储在源代码树根目录下的 .config 文件中。有多种方法可以创建初始的 .config 文件:

  1. 复制当前系统配置: 如果宿主机运行的是 Linux,可以将其配置作为基础。配置文件通常位于 /boot/config-$(uname -r)/proc/config.gz(压缩格式)。将其复制到内核源码树根目录并重命名为 .config。 Bash

    # 示例:
    cp /boot/config-$(uname -r).config
    # 或者,如果配置是压缩的:
    # zcat /proc/config.gz >.config
    
  2. 使用默认配置:
    • make defconfig: 为当前体系结构生成一个合理的默认配置。
    • make tinyconfig: 生成一个非常小的、仅包含最基本功能的配置。
    • make <target>_defconfig: 为特定的目标板或体系结构生成默认配置。
  3. 更新现有配置: 如果已经有一个 .config 文件(例如从旧版本复制而来),运行 make oldconfig 会提示用户为新内核版本中增加的选项选择配置,同时保留原有配置。

4.2 必要的调试配置选项

为了使内核能够被 GDB 有效调试,必须在配置中启用几个关键选项。可以使用 make menuconfig(一个基于 ncurses 的图形化配置界面)或 make xconfig/make gconfig(基于 Qt/GTK)来修改配置,也可以直接编辑 .config 文件或使用脚本工具 ./scripts/config

以下是核心的调试相关选项:

Kconfig 选项目的menuconfig 中的大致位置 (可能因版本而异)说明与依赖
CONFIG_DEBUG_INFO启用调试信息生成 (DWARF 格式),使用编译器的 -g 选项。这是 GDB 能够关联代码与源文件的基础。Kernel hacking -> Compile-time checks and compiler options -> Compile the kernel with debug info必须启用。较新内核可能基于其他调试选项自动启用此项。
CONFIG_GDB_SCRIPTS启用内核提供的 GDB 辅助脚本 (如 lx-symbols, lx-dmesg),极大方便内核调试。Kernel hacking -> Compile-time checks and compiler options -> Provide GDB scripts for kernel debugging强烈推荐。可能需要在 GDB 中配置 auto-load safe-path
CONFIG_FRAME_POINTER(如果架构支持) 让编译器维护栈帧指针。有助于 GDB 生成更可靠的栈回溯信息,尤其是在栈可能损坏时。Kernel hacking -> Compile-time checks and compiler options -> Compile the kernel with frame pointers推荐启用(如果可用)。可能对性能有轻微影响。
CONFIG_KGDB启用内核内置的调试器核心 (kgdb)。Kernel hacking -> KGDB: kernel debugger对于 QEMU GDB Stub 不是必需的,但启用后可用于其他调试场景(如串口调试)。
CONFIG_DEBUG_KERNEL启用通用的内核调试特性。Kernel hacking通常是其他调试选项的依赖项。
CONFIG_DEBUG_INFO_SPLIT将内核和模块的调试信息分离到单独的 .dwo 文件中。显著减小部署到目标机的镜像和模块大小。Kernel hacking -> Compile-time checks and compiler options -> Generate dwarf split debug info可选。需要 GCC 4.7 或更高版本。简化部署,但 GDB 主机需要同时访问 vmlinux.dwo 文件。

可以使用 ./scripts/config 命令行工具来启用这些选项,例如:

Bash

./scripts/config --enable DEBUG_INFO
./scripts/config --enable GDB_SCRIPTS
# 如果架构支持且需要,启用 FRAME_POINTER
#./scripts/config --enable FRAME_POINTER
make oldconfig # 确保所有依赖和新选项得到处理

在选择是否启用 CONFIG_DEBUG_INFO_SPLIT 时,需要权衡利弊。分离的调试信息 (.dwo 文件) 可以显著减小最终部署到 QEMU 虚拟机中的内核镜像 (bzImage) 和内核模块 (.ko) 的体积,这有助于加快虚拟机的启动速度并减少内存占用。然而,这也意味着在宿主机上运行 GDB 时,除了 vmlinux 文件外,还需要确保 GDB 能够找到对应的 .dwo 文件。对于简单的调试设置,将调试信息直接编译进 vmlinux (CONFIG_DEBUG_INFO=yCONFIG_DEBUG_INFO_SPLIT=n) 更为直接。但在资源受限或部署复杂的场景下,分离调试信息可能是更好的选择。

4.3 内核编译

配置完成后,开始编译内核。使用 make 命令,并利用 -j 参数并行编译以加快速度。$(nproc) 会自动检测并使用所有可用的 CPU 核心。

Bash

make -j$(nproc)

编译完成后,关注以下关键输出文件:

  • vmlinux: 位于源码树根目录。这是未压缩的、包含调试符号的内核可执行文件。GDB 需要加载这个文件来理解内核代码结构和符号信息。
  • arch/<arch>/boot/bzImage (x86) 或类似文件: 位于特定架构的 boot 目录下。这是压缩后的内核镜像,QEMU 将使用这个文件来启动虚拟机。<arch> 取决于目标体系结构,例如 x86arm64 等。

4.4 内核模块处理 (简述)

如果内核配置中某些驱动或功能被编译为模块 (CONFIG_XXX=m),编译过程也会生成相应的 .ko 文件。如果 CONFIG_DEBUG_INFO 被启用,这些模块文件同样会包含调试信息。在调试时,当客户内核加载了某个模块后,需要在 GDB 中使用特定命令(如 lx-symbols)来加载该模块的符号信息,才能对其进行调试。

5. 准备最小化根文件系统 (Initramfs)

为了启动 Linux 内核,即使是在 QEMU 中,也需要一个根文件系统。对于调试环境,一个包含基本工具的最小化内存文件系统 (initramfs) 通常就足够了。

5.1 Initramfs 的原理

Initramfs (Initial RAM File System) 是一个临时的、基于内存的文件系统,由引导加载程序(或 QEMU 本身)在内核启动早期加载到内存中。它的主要目的是提供一个初始的用户空间环境,包含必要的驱动程序(例如磁盘或网络驱动)和工具,以便内核能够挂载真正的、持久化的根文件系统。在我们的调试场景中,这个 initramfs 本身就可以作为最终的根文件系统,提供一个基本的 shell 环境。Initramfs 通常打包成一个 cpio 归档文件,并可能使用 gzip 等工具进行压缩。

5.2 使用 BusyBox

BusyBox 是创建 initramfs 的常用工具。它将许多标准的 Unix/Linux 命令行工具(如 ls, sh, mount, insmod 等)集成到一个单一的小型可执行文件中,非常适合资源受限的环境。

  • 获取 BusyBox:
    • 克隆源码: git clone git://busybox.net/busybox.git
    • 下载预编译的静态二进制文件 (如果可用且满足需求)

5.3 配置和编译 BusyBox

与内核类似,BusyBox 也需要配置。

  1. 进入 BusyBox 源码目录。
  2. 运行 make menuconfig
  3. 关键配置: 启用静态链接。导航到 Settings ---> 并选中 Build static binary (no shared libraries)
    • 强烈建议为 initramfs 中的 BusyBox 启用静态链接。这样做可以避免繁琐地去查找、复制所有依赖的共享库(如 libc.so, libm.so 等)到 initramfs 中,极大地简化了 initramfs 的创建过程,并减少了因缺少库而导致的运行时错误。虽然静态链接会稍微增加 BusyBox 二进制文件的大小,但这通常比处理动态链接库的复杂性要划算得多。
  4. 保存配置并退出。
  5. 编译 BusyBox: make -j$(nproc)

5.4 创建 Initramfs 目录结构

  1. 创建一个目录用于存放 initramfs 的内容,例如 mkdir initramfs_root
  2. 将编译好的 BusyBox 安装到这个目录结构中。make install 默认安装到源码目录下的 _install 子目录,使用 CONFIG_PREFIX 可以指定安装路径: Bash

    make CONFIG_PREFIX=./initramfs_root install
    
    这会在 initramfs_root 下创建必要的子目录(如 bin, sbin, usr/bin 等),并用指向主 busybox 二进制文件的符号链接填充它们。
  3. 创建必要的设备节点(如果内核配置了 CONFIG_DEVTMPFS_MOUNT 并将在 /init 脚本中挂载 devtmpfs,则此步骤可能不是必需的,但包含它们也无害): Bash

    # 进入 initramfs 根目录
    cd initramfs_root
    mkdir dev
    sudo mknod -m 660 dev/console c 5 1 # 控制台设备
    sudo mknod -m 660 dev/null c 1 3    # 空设备
    cd..
    
    (改编自)
  4. 创建挂载点目录: Bash

    mkdir initramfs_root/{proc,sys,tmp} # dev 目录已在上面创建
    

5.5 /init 脚本

内核启动后,会查找并执行 initramfs 根目录下的 /init 文件作为第一个用户空间进程 (PID 1)。我们需要创建一个简单的 /init 脚本。

  1. initramfs_root 目录下创建名为 init 的文件。

  2. 写入以下内容:

    Bash

    #!/bin/busybox sh
    # 挂载必要的伪文件系统
    mount -t proc proc /proc
    mount -t sysfs sysfs /sys
    # 如果内核支持 CONFIG_DEVTMPFS_MOUNT,可以使用 devtmpfs 自动管理 /dev
    mount -t devtmpfs devtmpfs /dev
    
    echo "======================================"
    echo " Welcome to Minimal Debug Environment"
    echo "======================================"
    
    # 执行一个交互式 shell,替换当前进程
    exec /bin/sh
    

    (综合自)

  3. 赋予脚本执行权限:

    Bash

    chmod +x initramfs_root/init
    

    值得注意的是,BusyBox 在作为 PID 1(即 /init 是指向 busybox 的符号链接)运行时,其行为可能与预期不同。它可能会进入一种“init 模式”,期望读取配置文件(如 /etc/inittab)来启动服务,而不是直接提供一个交互式 shell。在 /init 脚本的最后使用 exec /bin/sh 是一个常用的技巧。exec 命令会用 /bin/sh 进程替换掉当前的 /init 脚本进程,使得 shell 自身成为 PID 1,并在挂载完必要的文件系统后立即提供交互式环境。另一种方法是在内核命令行上传递 init=/bin/sh 参数,但这会完全绕过我们自定义的 /init 脚本。对于调试环境,在 /init 中先做少量设置(如挂载 /proc/sys)再 exec sh 通常是更清晰的做法。

5.6 打包 Initramfs

最后,将 initramfs_root 目录的内容打包成一个压缩的 cpio 归档文件。

  1. 进入 initramfs_root 目录。
  2. 执行打包和压缩命令: Bash

    cd initramfs_root
    find. | cpio -o -H newc | gzip -9 >../initramfs.cpio.gz
    cd..
    
    (命令改编自)
    • find.: 列出当前目录下的所有文件和目录。
    • cpio -o: 创建归档文件。
    • -H newc: 指定使用可移植的 SVR4 cpio 格式 (无 CRC)。
    • gzip -9: 使用最高级别压缩归档文件。

现在,我们有了编译好的内核 (bzImage)、包含调试符号的内核文件 (vmlinux) 以及一个最小化的根文件系统 (initramfs.cpio.gz)。

6. 在 QEMU 中启动内核

准备好内核和 initramfs 后,就可以使用 QEMU 启动虚拟机并启用 GDB 服务器了。

6.1 QEMU 基本命令结构

QEMU 的命令行结构通常是 qemu-system-<arch> [options],其中 <arch> 是目标体系结构(如 x86_64, aarch64 等)。选项可以分为几类,包括机器类型、CPU 配置、加速器、设备、后端、接口和启动选项。

6.2 指定内核和 Initramfs

  • -kernel <path/to/bzImage>: 指定要加载和启动的压缩内核镜像文件。
  • -initrd <path/to/initramfs.cpio.gz>: 指定用作初始 RAM 磁盘的 initramfs 文件。

6.3 内核命令行参数 (-append)

使用 -append "..." 选项可以将参数传递给内核命令行,这些参数会影响内核的启动行为。对于调试环境,以下参数尤为重要:

  • console=ttyS0: 将内核的控制台输出重定向到 QEMU 的第一个虚拟串口。这允许我们在宿主机上通过 QEMU 的监控界面或重定向的终端看到内核的启动信息和 shell 输出。
  • nokaslr: 禁用内核地址空间布局随机化 (Kernel Address Space Layout Randomization)。KASLR 是一项安全特性,它在每次启动时将内核代码和数据加载到内存中的随机位置。然而,这对于调试来说是个障碍,因为它使得 GDB 加载的 vmlinux 文件中的符号地址与实际运行的内核地址不匹配,导致断点无法准确设置或符号无法解析。nokaslr 参数强制内核加载到可预测的、编译时确定的地址,从而使 GDB 能够正确工作。
  • root=/dev/ram0 (可选): 有时需要明确告知内核 initramfs 就是最终的根文件系统。
  • init=/bin/sh (可选): 如前所述,可以直接启动 shell 而不是执行 /init 脚本。

6.4 启用 GDB 服务器

QEMU 提供了多种选项来启用和配置其内置的 GDB Stub:

选项等效 -gdb 形式 (若适用)目的默认值 (若适用)示例
-s-gdb tcp::1234启用 GDB Stub,监听 TCP 端口 1234。qemu-system-x86_64 -s...
-S(与 -gdb-s 结合使用)启动时冻结 CPU,等待 GDB 连接并发出 continue 命令。用于调试早期启动代码。qemu-system-x86_64 -s -S...
-gdb tcp::<port>N/A启用 GDB Stub,监听指定的 TCP 端口。qemu-system-x86_64 -gdb tcp::9000 -S...
-gdb unix:<path>N/A启用 GDB Stub,监听指定的 Unix 域套接字。qemu-system-x86_64 -chardev socket,path=/tmp/gdb-sock,server=on,wait=off,id=gdb0 -gdb chardev:gdb0 -S... (需要先定义 chardev) 或更简单的 qemu-system-x86_64 -gdb unix:/tmp/gdb-sock -S...
-gdb <dev>N/A将 GDB Stub 连接到预定义的字符设备 (chardev)。(见上一个 Unix 套接字示例)

虽然 -s 是最常用的快捷方式,但使用 -gdb unix:/path/to/socket 在某些场景下更具优势。例如,在运行自动化测试或同时启动多个 QEMU 实例时,Unix 套接字可以避免 TCP 端口冲突。此外,在本地主机上,Unix 套接字通信通常比 TCP 具有更低的开销,并且不暴露网络端口,安全性稍好。

6.5 示例 QEMU 启动命令

综合以上选项,一个典型的用于启动 x86_64 内核进行调试的 QEMU 命令如下:

Bash

qemu-system-x86_64 \
    -kernel path/to/your/linux/arch/x86/boot/bzImage \
    -initrd path/to/your/initramfs.cpio.gz \
    -append "console=ttyS0 nokaslr" \
    -s -S \
    -m 2G \ # 分配 2GB 内存给虚拟机 (示例)
    -nographic # 可选:不在图形窗口中显示,仅使用串口控制台

(综合自)

执行此命令后,QEMU 将启动,加载内核和 initramfs,但由于 -S 选项,它会立即暂停执行,并在后台静默等待来自 GDB 的连接。-nographic 选项意味着 QEMU 不会打开图形窗口;所有的交互(包括内核启动信息和之后的 shell)都将通过配置的 console=ttyS0 重定向到 QEMU 的标准输出/输入或指定的串口后端。

7. 连接 GDB 和基本调试操作

QEMU 虚拟机启动并等待连接后,就可以启动 GDB 并开始调试了。

7.1 启动 GDB 并加载内核符号

  1. 在宿主机上打开一个新的终端。
  2. 切换到包含编译好的 vmlinux 文件的内核源码/构建目录。
  3. 启动 GDB,并将 vmlinux 文件作为参数传递给它。vmlinux 文件包含了未压缩的内核代码以及我们在编译时加入的调试符号。 Bash

    cd path/to/your/linux/
    gdb vmlinux
    

7.2 连接到 QEMU

在 GDB 提示符下,使用 target remote 命令连接到 QEMU 正在监听的 GDB Stub。

  • 如果 QEMU 使用 -s-gdb tcp::1234 启动: 代码段

    (gdb) target remote localhost:1234
    
  • 如果 QEMU 使用了自定义 TCP 端口 (例如 9000): 代码段

    (gdb) target remote localhost:9000
    
  • 如果 QEMU 使用了 Unix 套接字 (例如 /tmp/gdb-socket): 代码段

    (gdb) target remote /tmp/gdb-socket
    

GDB 会尝试连接到 QEMU。连接成功后,GDB 会显示类似 Remote debugging using localhost:1234 的消息,并可能显示当前停止执行的位置(如果使用了 -S 选项)。

另外,target extended-remote 命令提供了类似的功能,但它在被调试程序退出或 GDB 与目标断开连接后,仍会保持与远程 stub 的连接,这在某些自动化或脚本化场景中可能有用。

7.3 初始状态和恢复执行

如果 QEMU 使用 -S 选项启动,连接 GDB 后,虚拟机的 CPU 处于暂停状态。GDB 通常会显示当前停止的指令地址,这可能是处理器的复位向量或引导加载代码的入口点。

要开始或恢复虚拟机的执行,使用 continue 命令(或其缩写 c)。

代码段

(gdb) c
Continuing.

虚拟机会开始执行,直到遇到断点、发生异常或被 GDB 手动中断(例如通过 Ctrl+C)。

7.4 设置断点

断点是让程序在特定位置暂停执行的标记。

  • 软件断点 (breakb): 这是最常用的断点类型。可以在函数名、源文件行号或内存地址处设置断点。它们通常通过在目标地址插入特定的中断指令(如 x86 上的 int3)来实现。

    代码段

    (gdb) b start_kernel # 在 start_kernel 函数入口设置断点
    (gdb) b drivers/net/ethernet/intel/e1000/e1000_main.c:1234 # 在指定文件和行号设置断点
    (gdb) b *0xffffffff81000000 # 在指定内存地址设置断点
    
  • 硬件断点 (hbreak): 这种断点利用 CPU 的硬件调试寄存器来实现,数量有限(通常只有几个)。硬件断点对于调试非常早期的启动代码(此时内核的异常处理机制尚未完全建立,无法处理软件断点指令)或在 ROM/Flash 中设置断点是必需的。

    代码段

    (gdb) hbreak start_kernel # 使用硬件断点在 start_kernel 处中断
    

    理解 hbreak 的必要性对于调试内核启动过程至关重要。在内核初始化的极早期阶段,例如执行 start_kernel 函数时,处理中断和异常的软件机制(如中断描述符表 IDT)还没有完全设置好。这意味着 CPU 无法正确响应由 break 命令插入的软件中断指令 (int3)。硬件断点则直接利用 CPU 硬件特性来暂停执行,绕过了对这些早期软件设施的依赖,因此成为调试内核引导阶段不可或缺的工具。

  • 在模块代码中设置断点: 如果要调试内核模块中的函数,可以使用 b <module_name>:<function_name> 的形式,例如 b my_driver:my_ioctl。如果模块尚未加载,GDB 会询问是否将此断点设为“待定”(pending)。选择 y 后,GDB 会在将来加载包含该符号的共享库(即内核模块)时自动激活此断点。当然,前提是需要先使用 lx-symbols 命令加载模块的符号信息。

7.5 检查状态

当虚拟机暂停时,可以使用 GDB 命令检查其状态。

命令 (缩写)目的示例
info registers (i r)显示所有 CPU 寄存器的值。(gdb) i r
backtrace (bt)显示当前的函数调用栈。(gdb) bt
x/<N><F><S> <addr>检查内存内容。N: 数量, F: 格式 (i-指令, x-十六进制, s-字符串, d-十进制, c-字符, g-巨字(64位)), S: 大小 (b-byte, h-halfword, w-word, g-giant)。(gdb) x/20i $pc (反汇编当前指令后的 20 条指令) <br> (gdb) x/16gx 0xffff888010000000 (显示 16 个 64 位十六进制值) <br> (gdb) x/s my_string_pointer (显示字符串)
print <expr> (p)计算并打印表达式的值(变量、寄存器等)。可带格式 /x, /t, /d 等。(gdb) p my_variable <br> (gdb) p/x $rax (以十六进制打印 rax 寄存器) <br> (gdb) p *my_struct_pointer (打印结构体内容)
ptype <var_or_type>打印变量或类型的定义(对结构体特别有用)。(gdb) ptype struct task_struct

7.6 控制执行

命令 (缩写)目的
continue (c)恢复执行,直到下一个断点或事件。
step (s)单步执行一行源代码,如果此行包含函数调用,则进入该函数内部。
next (n)单步执行一行源代码,如果此行包含函数调用,则执行完该函数再停止(不进入函数内部)。
stepi (si)单步执行一条机器指令。
nexti (ni)单步执行一条机器指令,遇到函数调用指令时执行完整个调用再停止。
finish继续执行,直到当前函数返回。
until <location>继续执行,直到达到指定的位置(行号或地址)。

需要注意 QEMU 的默认单步执行行为。为了确保 GDB 能够可靠地执行单步命令并停在预期的下一条指令,QEMU 的 GDB Stub 在处理单步命令(s, n, si, ni)时,默认会临时屏蔽中断和定时器。这样做是为了防止在单步执行过程中,CPU 因为响应中断而意外跳转到中断处理程序,导致 GDB 无法准确跟踪执行流程。然而,这种默认行为也可能屏蔽与中断相关的 bug。如果需要调试中断处理程序本身,或者观察单步执行期间的中断行为,可以通过 GDB 的 maintenance packet 命令与 QEMU 的 GDB Stub 交互,查询和修改单步执行时的中断屏蔽设置。例如,maintenance packet qqemu.sstep 可以查询当前设置,而 maintenance packet Qqemu.sstep=<hex_mask> 可以修改设置。

7.7 物理内存与虚拟内存访问

默认情况下,当你在 GDB 中使用 xp 命令访问内存时,GDB 会通过 QEMU 访问客户机的 虚拟内存。这意味着地址会经过客户机当前活动的内存管理单元 (MMU) 的转换。

QEMU 提供了一个强大的扩展功能,允许 GDB 切换到直接访问客户机的 物理内存,绕过 MMU。这对于调试页表设置、内存管理代码或直接与硬件交互的驱动程序非常有用。

  • 检查当前内存访问模式: 代码段

    (gdb) maintenance packet qqemu.PhyMemMode
    
    QEMU 会返回 0 (表示虚拟内存模式) 或 1 (表示物理内存模式)。
  • 切换到物理内存模式: 代码段

    (gdb) maintenance packet Qqemu.PhyMemMode:1
    
  • 切换回虚拟内存模式: 代码段

    (gdb) maintenance packet Qqemu.PhyMemMode:0
    

这种在虚拟内存视图和物理内存视图之间切换的能力是基于模拟器的内核调试的一个显著优势。它使得开发者能够直接检查和修改底层内存状态,例如查看页表条目或访问设备映射的物理内存区域,而无需依赖客户内核自身可能存在问题的内存映射机制,这对于解决复杂的内存管理或驱动问题非常有价值。

8. 利用 Linux 内核 GDB 脚本

为了进一步简化内核调试,Linux 内核源码树中包含了一系列 GDB Python 脚本,它们提供了许多内核特定的辅助命令和函数。

8.1 启用和加载脚本

  1. 内核配置: 确保在编译内核时已启用 CONFIG_GDB_SCRIPTS=y 选项。
  2. 自动加载: 当使用 gdb vmlinux 启动 GDB 时,GDB 通常会自动检测并加载位于 vmlinux 文件相同目录下的 vmlinux-gdb.py 脚本。
  3. 安全路径配置: 出于安全考虑,GDB 可能默认禁止自动加载来自不信任目录的脚本。如果 GDB 启动时报告拒绝加载 vmlinux-gdb.py,你需要在你的 ~/.gdbinit 文件中添加一行,将内核构建目录声明为安全路径。
    # ~/.gdbinit 文件内容示例
    add-auto-load-safe-path /path/to/your/linux-build/
    

8.2 加载模块符号 (lx-symbols)

当客户内核加载或卸载模块时,GDB 不会自动更新符号信息。lx-symbols 命令用于手动扫描当前加载的内核模块,并将它们的符号加载到 GDB 中。

  • 用法: 代码段

    (gdb) lx-symbols
    loading vmlinux
    scanning for modules in /path/to/your/linux-build
    loading @0xffffffffc0a0b000: /path/to/your/linux-build/drivers/net/ethernet/intel/e1000/e1000.ko
    

...

```

  • 调试树外模块 (Out-of-Tree Modules): 如果你正在调试的模块不是内核主源码树的一部分(例如,你自己开发的驱动程序),你需要告诉 lx-symbols 在哪里查找模块的构建目录(包含 .ko 文件和可能的 .o 文件): 代码段

    (gdb) lx-symbols /path/to/external/module/build/dir
    

8.3 检查内核日志 (lx-dmesg)

这个命令可以直接在 GDB 内部打印出客户内核的环形日志缓冲区 (ring buffer) 的内容,相当于在客户机内部执行 dmesg 命令。这对于在不切换到客户机控制台的情况下查看内核消息、错误或调试打印非常方便。

  • 用法: 代码段

    (gdb) lx-dmesg
    [    0.000000] Linux version 6.6.16...
    [    0.123456] Booting processor 0...
    [    1.567890] e1000: eth0 NIC Link is Up 1000 Mbps Full Duplex...
    

...

```

8.4 使用便捷函数和变量

这些脚本还定义了一些特殊的 GDB 函数和变量,用于方便地访问内核内部的数据结构。

脚本/函数目的示例
$lx_current()获取当前被调试 CPU 上正在执行的任务 (task) 的 task_struct 结构体指针。(gdb) p $lx_current().pid <br> (gdb) p $lx_current().comm
$lx_per_cpu("<var>", [cpu])访问指定的 per-CPU 变量。如果省略 cpu 号,则访问当前 CPU 的变量。(gdb) p $lx_per_cpu("runqueues", 0).nr_running <br> (gdb) p $lx_per_cpu("cpu_number")
$lx_task_by_pid(<pid>)根据进程 ID (PID) 查找对应的 task_struct(gdb) p $lx_task_by_pid(1234)->state
$lx_module_by_name("<name>")根据模块名称查找模块信息。(gdb) p $lx_module_by_name("e1000")
$lx_module_by_addr(<addr>)根据内存地址查找其所属的模块信息。(gdb) p $lx_module_by_addr(0xffffffffc0a0b123)
lx_show_regs([regs])打印 struct pt_regs 结构体的内容。(gdb) lx_show_regs $lx_current().thread.regs
lx_list_check(<head>, [field])检查内核链表 (struct list_head) 的完整性。(gdb) lx_list_check &my_list_head

这些 lx-* 辅助脚本和函数极大地提升了使用 GDB 调试 Linux 内核的体验。它们将 GDB 从一个通用的调试器转变为一个具备内核感知能力的工具,抽象了访问核心内核数据结构(如当前任务、per-CPU 数据)的复杂性。开发者不再需要手动根据内核内部实现去遍历指针和内存,而是可以直接访问具有语义的内核对象,从而显著加快了调试流程。

9. 高级主题与结论

9.1 多核调试

QEMU 能够模拟多核/多 CPU 系统。当连接 GDB 时,QEMU 会将这些 CPU 暴露给 GDB,通常是作为一个“inferior”(下级进程)下的多个“threads”(线程)。

  • info threads: 列出 GDB 当前可识别的所有 CPU(线程)。
  • thread <id>: 切换 GDB 的当前焦点到指定的 CPU(线程)。
  • set schedule-multiple on: 建议在 GDB 中设置此选项。这样,当执行 continue 命令时,GDB 会恢复所有 CPU 的执行,而不仅仅是当前聚焦的 CPU。这对于保持多核系统状态的一致性很重要。

9.2 进一步探索

本报告涵盖了搭建环境和基本调试操作。更深入的调试可能涉及:

  • 内核模块调试: 更详细地跟踪模块加载、卸载过程,调试模块与内核核心的交互。
  • 观察点 (Watchpoints): 使用 watch <expression> 命令,在某个变量或内存位置的值发生改变时暂停执行。
  • GDB 脚本化: 利用 GDB 的 Python API 编写更复杂的脚本,实现自动化调试任务、数据结构分析等。
  • 用户空间调试: 同时调试在 QEMU 客户机中运行的用户空间应用程序(这通常需要不同的设置,例如在客户机内部运行 gdbserver)。

9.3 结论

通过结合 QEMU 的系统仿真能力和 GDB 强大的调试功能,可以搭建一个功能完善且灵活的 Linux 内核调试环境。该环境不依赖于特定的物理硬件,允许开发者方便地获取内核源代码、配置编译包含调试信息的内核、创建必要的最小化根文件系统,并在受控的环境中启动和调试内核。

利用 QEMU 的 GDB Stub,开发者可以实现对内核执行流程的完全控制,包括设置断点、单步执行、检查寄存器和内存(包括物理内存和虚拟内存)。更重要的是,通过启用并利用内核提供的 GDB 辅助脚本,可以将 GDB 转变为一个内核感知的调试工具,极大地简化了对内核内部数据结构和状态的访问。

虽然搭建过程涉及多个步骤,但一旦环境建立,它将为内核开发、驱动程序调试、内核漏洞分析以及深入理解 Linux 内核工作原理提供一个极其强大的平台。鼓励使用者多加练习,并进一步探索 GDB 和内核内部的更多高级特性。

10. 引用的链接

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值