ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

使用QEMU搭建Linux内核开发调试环境:从编译到GDB源码级调试

2026/8/13 3:58:39 拓冰建站 浏览量
使用QEMU搭建Linux内核开发调试环境:从编译到GDB源码级调试

在 Linux 内核开发与调试的征途中,你是否曾因缺少真实的硬件环境而苦恼?或者,你是否想在不重启物理机的情况下,快速验证一个内核模块的改动?又或者,你是否对操作系统底层如何与硬件交互充满好奇,却苦于没有安全的实验平台?如果你有以上任一困惑,那么 QEMU 将是你的得力助手。本文将从 Linux 内核开发者的视角出发,系统性地讲解如何将 QEMU 打造为一个强大、灵活的内核开发与调试沙箱。我们将从核心概念入手,逐步搭建环境,编写并运行一个最简单的内核模块,最终深入到使用 GDB 进行源码级单步调试。无论你是刚接触内核的新手,还是希望优化现有工作流的资深开发者,都能从中获得一套可直接复用的完整方案。

1. 背景与核心概念:为什么内核开发者需要 QEMU?

在深入实操之前,我们有必要厘清 QEMU 是什么,以及它为何能成为内核开发的“神器”。

1.1 QEMU 是什么?

QEMU 是一个开源的、通用的机器模拟器和虚拟化器。简单来说,它可以模拟一整套计算机硬件(如 CPU、内存、磁盘、网卡等),并在此虚拟硬件上运行未经修改的操作系统。对于内核开发者而言,我们主要利用其系统模拟(System Emulation)功能。这意味着 QEMU 可以模拟一个完整的、从加电启动开始的计算机系统,这正是运行一个完整 Linux 内核所需要的环境。

与 VMware、VirtualBox 等基于宿主操作系统内核功能的“虚拟化”软件不同,QEMU 的纯模拟模式不依赖宿主机的特定 CPU 虚拟化扩展(如 Intel VT-x 或 AMD-V),它通过二进制翻译来模拟指令执行。这带来了无与伦比的灵活性:你可以在 x86 主机上运行为 ARM、RISC-V 或 MIPS 编译的内核,反之亦然。这种跨架构模拟能力,对于进行嵌入式 Linux 或新硬件架构适配的内核开发至关重要。

1.2 QEMU 对内核开发的核心价值

  1. 安全与隔离:内核开发涉及系统底层,错误的代码可能导致系统崩溃、数据丢失。在 QEMU 虚拟机中测试,相当于在一个“沙箱”中操作,无论内核如何崩溃,宿主机的系统都安然无恙。
  2. 快速迭代:无需准备多台物理机,也无需反复重启宿主机。修改内核代码后,只需重新编译内核镜像,然后在 QEMU 中启动新的虚拟机即可,整个过程可能只需几十秒。
  3. 精确的调试控制:QEMU 内置了对 GDB 调试协议的支持。这意味着你可以像调试用户态程序一样,使用 GDB 对正在运行的内核进行源码级单步调试、设置断点、查看变量和内存。这是物理机上极难实现的。
  4. 灵活的硬件配置:你可以轻松地为虚拟机配置特定的内存大小、CPU 核心数、磁盘映像、网络拓扑,甚至模拟特定的硬件设备(如自定义的 PCI 设备),这对于驱动开发非常有用。
  5. 可复现的环境:通过保存一个磁盘镜像和启动命令,你可以精确复现一个开发环境,方便团队共享和问题追溯。

1.3 核心组件关系梳理

在后续的实践中,我们会涉及几个核心组件,它们的关系如下:

  • 宿主系统(Host):你实际使用的物理机,运行着如 Ubuntu、Fedora 等发行版。
  • QEMU:运行在宿主系统上的模拟器软件。
  • 客户机系统(Guest):在 QEMU 中运行的虚拟计算机。
  • Linux 内核镜像(bzImage/vmlinux):被加载到客户机内存中并运行的核心程序。
  • 根文件系统(Rootfs):包含/bin,/sbin,/lib等目录的文件系统映像,为客户机内核提供用户空间环境(如init进程、shell)。
  • GDB:运行在宿主机上的调试器,通过 QEMU 提供的调试接口连接到客户机中运行的内核。

理解了这些概念,我们就可以开始动手搭建环境了。

2. 环境准备与版本说明

我们的目标是在一个 Linux 宿主机上,使用 QEMU 启动一个同样为 Linux 的客户机,并在其中运行我们开发的内核。以下是本次实践的环境概览。

宿主机环境:

  • 操作系统:Ubuntu 22.04 LTS (x86_64)。其他主流发行版如 Fedora、CentOS 步骤类似。
  • QEMU 版本:本文使用qemu-system-x86_64版本 6.2.0。安装命令会获取你发行版仓库中的稳定版本。
  • 内核源码:我们将使用 Linux 内核的稳定版本linux-5.15作为示例。你可以从 kernel.org 下载。
  • 构建工具链gcc,make,flex,bison等。
  • 调试器gdb
  • 根文件系统构建工具busybox。我们用它来制作一个极简的根文件系统。

版本兼容性说明: 不同版本的 QEMU 和 Linux 内核在特性支持上可能有细微差别。本文选择的5.15是一个长期支持(LTS)版本,与主流 QEMU 版本兼容性良好。如果你的项目要求特定版本,请替换对应的版本号,核心操作流程是通用的。

3. 搭建基础开发环境

3.1 安装 QEMU 及必要工具

首先,在 Ubuntu 宿主机上安装 QEMU 和编译内核所需的工具。

# 更新软件包列表 sudo apt update # 安装 QEMU 系统模拟器 (x86_64架构) sudo apt install qemu-system-x86 qemu-system-gui -y # 安装内核编译依赖 sudo apt install build-essential libncurses-dev libssl-dev bc libelf-dev flex bison -y # 安装调试工具 GDB sudo apt install gdb -y # 安装用于制作根文件系统的 busybox 和 文件系统工具 sudo apt install busybox-static genext2fs -y

安装完成后,可以验证 QEMU 是否安装成功:

qemu-system-x86_64 --version

命令应输出类似QEMU emulator version 6.2.0的信息。

3.2 获取并配置 Linux 内核源码

我们下载并解压内核源码,并进行最基础的配置。

# 1. 下载 Linux 5.15 内核源码 (你也可以使用其他版本,此处以5.15.148为例) wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.15.148.tar.xz # 或者使用国内镜像,如清华镜像:https://mirrors.tuna.tsinghua.edu.cn/kernel/v5.x/linux-5.15.148.tar.xz # 2. 解压源码 tar -xvf linux-5.15.148.tar.xz cd linux-5.15.148 # 3. 生成默认配置 # 为 x86_64 架构生成一个默认的配置。这会创建 .config 文件。 make x86_64_defconfig # 4. (关键)启用内核调试信息 # 我们需要在内核中嵌入调试符号,否则 GDB 无法进行源码级调试。 # 执行 menuconfig 图形化配置界面 make menuconfig

make menuconfig界面中,使用键盘方向键导航,确保以下选项被启用(标有[*]<*>):

  • Kernel hacking->Compile-time checks and compiler options->Compile the kernel with debug info(CONFIG_DEBUG_INFO)
  • 同样在Kernel hacking下,确保KGDB: kernel debugger是启用的。

保存并退出(通常按右方向键选择<Save>,然后<Exit>)。

3.3 制作简易根文件系统 (Rootfs)

内核启动后需要挂载一个根文件系统,里面至少需要有一个init程序。我们使用busybox来制作一个极简的initramfs(内存文件系统)。

# 1. 回到工作目录,创建一个用于构建 rootfs 的文件夹 cd .. mkdir rootfs cd rootfs # 2. 创建基本的文件系统目录结构 mkdir -p bin sbin etc proc sys usr/bin usr/sbin dev home lib lib64 # 3. 将 busybox 安装到当前目录 # busybox 是一个集成了上百个常用命令的单一可执行文件。 busybox --install -s . # 4. 创建 init 脚本(这是内核启动后运行的第一个用户空间进程) cat > init << EOF #!/bin/sh # 挂载虚拟文件系统 mount -t proc none /proc mount -t sysfs none /sys mount -t devtmpfs none /dev # 打印欢迎信息 echo "=========================================" echo " Hello from QEMU Linux Kernel Developer!" echo "=========================================" # 启动一个交互式 shell exec /bin/sh EOF # 5. 赋予 init 脚本执行权限 chmod +x init # 6. 将当前目录打包成 initramfs 镜像 # find . | cpio -o -H newc | gzip > ../initramfs.cpio.gz # 更推荐使用以下命令,避免包含上层目录路径 find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../initramfs.cpio.gz cd ..

现在,我们得到了一个关键的文件:initramfs.cpio.gz。它是一个被压缩的、包含完整目录结构和busybox工具的内存文件系统镜像。

4. 编译内核并首次启动

4.1 编译 Linux 内核

回到内核源码目录,开始编译。这个过程可能需要一些时间,取决于你的 CPU 性能。

cd linux-5.15.148 # 使用多线程编译以加快速度,`-j$(nproc)` 表示使用与CPU核心数相同的线程数。 make -j$(nproc)

编译成功后,在arch/x86/boot/目录下会生成压缩的内核镜像bzImage,在源码根目录下会生成带有完整调试符号的内核文件vmlinux

  • bzImage: 用于引导启动的压缩内核镜像。
  • vmlinux: 原始的、未压缩的、包含所有符号的内核可执行文件,是 GDB 调试所必需的。

4.2 使用 QEMU 启动自定义内核

现在,使用 QEMU 启动我们刚刚编译的内核和制作的根文件系统。

# 在 linux-5.15.148 目录的同级目录下执行 qemu-system-x86_64 \ -kernel ./linux-5.15.148/arch/x86/boot/bzImage \ -initrd ./initramfs.cpio.gz \ -append "console=ttyS0 nokaslr" \ -nographic \ -s

参数解析:

  • -kernel <path>: 指定要启动的内核镜像 (bzImage) 路径。
  • -initrd <path>: 指定初始内存磁盘 (initramfs) 路径。
  • -append “…”: 传递给内核的命令行参数。
    • console=ttyS0: 将控制台重定向到串口 0,这样-nographic模式下我们才能看到输出。
    • nokaslr:至关重要!禁用内核地址空间布局随机化。如果不禁用,GDB 设置的断点地址会因为每次启动的随机偏移而失效。
  • -nographic: 不使用图形化窗口,所有输出输入通过当前终端进行。非常适合在服务器或 SSH 会话中使用。
  • -s:调试关键参数!这是-gdb tcp::1234的简写,表示在 TCP 的 1234 端口开启一个 GDB 调试服务器,等待 GDB 连接。

执行命令后,QEMU 会启动虚拟机,并输出一系列内核启动日志,最后会看到我们init脚本打印的欢迎信息,并进入busybox提供的shshell。你可以在这里执行一些简单的命令,如ls,ps

要退出 QEMU,可以按Ctrl+A,然后松开再按X

5. 使用 GDB 进行内核源码级调试

这是 QEMU 为内核开发者提供的“杀手级”功能。我们将演示如何连接 GDB,设置断点,并单步执行内核代码。

5.1 启动 QEMU 并等待 GDB 连接

首先,以前台方式启动 QEMU,并让它暂停在启动的最开始,等待调试器连接。

qemu-system-x86_64 \ -kernel ./linux-5.15.148/arch/x86/boot/bzImage \ -initrd ./initramfs.cpio.gz \ -append "console=ttyS0 nokaslr" \ -nographic \ -S \ -s

注意,这里多了一个-S参数。它告诉 QEMU 在启动 CPU 前先冻结(暂停),直到 GDB 发送continue命令。现在 QEMU 窗口会卡住,没有任何输出。

5.2 在另一个终端中启动 GDB

打开一个新的终端窗口,切换到内核源码目录。

cd linux-5.15.148 gdb vmlinux

这会启动 GDB 并加载带有完整符号的vmlinux文件。

在 GDB 界面中,执行以下命令:

(gdb) target remote localhost:1234

这条命令让 GDB 连接到 QEMU 在localhost:1234端口开放的调试服务。连接成功后,GDB 会显示类似Remote debugging using localhost:1234的信息,并可能显示当前暂停的地址(如0x000000000000fff0,这是复位向量地址)。

5.3 设置断点并调试

现在,我们可以像调试普通程序一样调试内核了。例如,我们想在start_kernel函数(这是内核 C 语言代码的入口点)处设置一个断点。

(gdb) break start_kernel Breakpoint 1 at 0xffffffff82a3c7e0: file init/main.c, line 928. (gdb) continue Continuing.

continue(或c) 命令让被 QEMU 冻结的 CPU 开始执行。内核开始启动,并在执行到start_kernel()函数时自动暂停。

此时,GDB 会显示命中断点,并打印出源码上下文:

Breakpoint 1, start_kernel () at init/main.c:928 928 {

现在,你可以使用一系列 GDB 命令进行调试:

  • list(l): 查看当前断点附近的源码。
  • next(n): 单步执行(不进入函数内部)。
  • step(s): 单步执行(进入函数内部)。
  • print <variable>(p): 打印变量的值。
  • backtrace(bt): 查看函数调用栈。
  • info registers: 查看寄存器状态。

例如,执行几步next后,你可以看到内核初始化的早期流程。

5.4 一个完整的调试会话示例

假设我们想研究printk函数的内部实现。我们可以在vprintk_func函数(printk的核心函数之一)设置断点。

# 在 GDB 中 (gdb) break vprintk_func Breakpoint 2 at 0xffffffff8110b2c0: file kernel/printk/printk.c, line 2176. (gdb) continue Continuing.

然后,在 QEMU 的虚拟终端里(如果你之前用-nographic启动了带 shell 的系统),执行一个会触发printk的命令,比如echo “test debug”。GDB 会立刻捕获到这个断点。

Breakpoint 2, vprintk_func (fmt=0xffffffff82c1fe8c “\002Linux version %s (%s) (%s) %s\\n”, args=args@entry=0xffffc9000008fd18) at kernel/printk/printk.c:2176 2176 { (gdb) list 2171 } 2172 EXPORT_SYMBOL(vprintk); 2173 #endif 2174 2175 __printf(1, 0) int vprintk_func(const char *fmt, va_list args) 2176 { 2177 return vprintk_default(fmt, args); 2178 } 2179 2180 /**

通过这种方式,你可以深入到内核的任何角落进行观察和分析,这对于理解内核运行机制或定位疑难 Bug 具有不可估量的价值。

6. 进阶:开发与调试一个简单的内核模块

仅仅运行和调试现有内核还不够,我们更需要测试自己编写的代码。下面我们创建一个最简单的内核模块,并在 QEMU 环境中进行加载、卸载和调试。

6.1 编写一个简单的内核模块

在工作目录下创建文件hello_kernel.c

// hello_kernel.c #include <linux/init.h> #include <linux/module.h> #include <linux/kernel.h> MODULE_LICENSE("GPL"); MODULE_AUTHOR("CSDN Developer"); MODULE_DESCRIPTION("A simple hello world kernel module for QEMU debug"); static int __init hello_init(void) { printk(KERN_INFO "Hello, Kernel World! Module loaded.\n"); return 0; // 返回 0 表示初始化成功 } static void __exit hello_exit(void) { printk(KERN_INFO "Goodbye, Kernel World! Module unloaded.\n"); } module_init(hello_init); module_exit(hello_exit);

同时,创建对应的Makefile

# Makefile obj-m += hello_kernel.o KERNEL_DIR ?= /lib/modules/$(shell uname -r)/build # 注意:这里我们需要指向我们**自己编译**的内核源码目录,而不是宿主机的! # 假设我们的源码在 ../linux-5.15.148 CUSTOM_KERNEL_DIR ?= $(PWD)/../linux-5.15.148 all: # 使用我们编译内核时生成的构建配置和头文件 make -C $(CUSTOM_KERNEL_DIR) M=$(PWD) modules clean: make -C $(CUSTOM_KERNEL_DIR) M=$(PWD) clean

6.2 编译内核模块

在包含hello_kernel.cMakefile的目录下执行:

make

如果一切顺利,会生成hello_kernel.ko文件,这就是我们的内核模块。

6.3 将模块放入根文件系统并测试

我们需要让运行在 QEMU 里的内核能够访问到这个.ko文件。

  1. 将模块复制到 rootfs 目录
    cp hello_kernel.ko ./rootfs/
  2. 重新打包 initramfs
    cd rootfs find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../initramfs.cpio.gz cd ..
  3. 启动 QEMU(不带-S,正常启动)
    qemu-system-x86_64 \ -kernel ./linux-5.15.148/arch/x86/boot/bzImage \ -initrd ./initramfs.cpio.gz \ -append "console=ttyS0 nokaslr" \ -nographic
  4. 在 QEMU shell 中操作模块
    # 进入 QEMU 的 shell 后 $ ls / # 应该能看到 hello_kernel.ko 文件 $ insmod /hello_kernel.ko # 加载模块 $ dmesg | tail -5 # 查看内核日志,应看到 “Hello, Kernel World! Module loaded.” $ rmmod hello_kernel # 卸载模块 $ dmesg | tail -5 # 应看到 “Goodbye, Kernel World! Module unloaded.”

6.4 调试自定义内核模块

如果想调试hello_init函数,步骤与调试内核本身类似:

  1. 确保编译模块时开启了调试信息(上面的Makefile通过使用自定义内核目录已经隐含了这一点,因为我们的内核是带CONFIG_DEBUG_INFO编译的)。
  2. 在 GDB 中,你需要加载模块的符号。这需要知道模块被加载到内核地址空间后的基址。
    • 在 QEMU shell 中,执行cat /sys/module/hello_kernel/sections/.text可以获取模块的.text段地址。
    • 在 GDB 中,使用add-symbol-file hello_kernel.ko <text_addr>命令加载符号。
  3. 然后就可以像之前一样,对hello_init等函数设置断点进行调试了。

7. 常见问题与排查思路

在使用 QEMU 进行内核开发时,你可能会遇到以下典型问题。

问题现象可能原因排查思路与解决方案
QEMU 启动失败,报错Could not open ‘xxx.iso’启动参数中指定的镜像文件路径错误或文件不存在。1. 使用绝对路径或检查相对路径是否正确。
2. 确保文件已下载完整,使用ls -lh确认文件大小正常。
内核启动后卡住,无输出或报Kernel panic1. 内核配置不支持虚拟硬件。
2. 根文件系统 (initrd) 损坏或init程序有问题。
3. 内核命令行参数错误。
1. 确保使用make x86_64_defconfig这类通用配置。
2. 检查initramfs制作流程,确保init脚本有执行权限且语法正确。
3. 检查-append参数,特别是console=的设置是否正确。尝试去掉nokaslr看是否与此有关。
GDB 连接失败Connection refused1. QEMU 未启动或已退出。
2.-s参数未指定或端口被占用。
1. 确认 QEMU 进程正在运行 (`ps aux
GDB 能连接但断点不生效1. 内核地址空间布局随机化 (KASLR) 未禁用。
2. 断点设置在错误的地址(模块未加载符号)。
1.必须在内核命令行中加入nokaslr参数。
2. 对于内核本身,确保加载的是带调试信息的vmlinux文件。对于模块,需要先获取加载地址并用add-symbol-file加载符号。
模块insmod失败,报错Invalid module format模块编译所用的内核版本/配置与当前运行的内核不匹配。确保模块的MakefileKERNEL_DIRCUSTOM_KERNEL_DIR指向的是你正在 QEMU 中运行的那个内核的源码目录,并且已经make modules_prepare过(我们直接make编译内核已包含此步骤)。
QEMU 占用 CPU 过高QEMU 默认会尽力模拟,占用所有可用的宿主 CPU。使用-smp <n>限制客户机 CPU 核心数,如-smp 2。使用-cpu host参数可能利用宿主 CPU 特性提升性能,但会降低可移植性。
网络无法使用默认的-nographic启动未添加网络设备。添加网络参数,例如-netdev user,id=net0 -device e1000,netdev=net0可创建一个用户模式的虚拟网络。更复杂的网络需要桥接或 TAP 设备配置。

8. 最佳实践与工程建议

将 QEMU 集成到日常内核开发工作流中,可以遵循以下建议以提升效率。

  1. 脚本化一切:将冗长的 QEMU 启动命令、GDB 连接命令、根文件系统制作流程都写成 Shell 脚本或 Makefile 目标。例如,创建run-qemu.sh,debug-kernel.sh,build-rootfs.sh

    # run-qemu.sh 示例 #!/bin/bash KERNEL_IMAGE="./linux-5.15.148/arch/x86/boot/bzImage" INITRD_IMAGE="./initramfs.cpio.gz" qemu-system-x86_64 -kernel $KERNEL_IMAGE -initrd $INITRD_IMAGE \ -append "console=ttyS0 nokaslr quiet" -nographic -s -S
  2. 使用版本控制管理根文件系统:将rootfs目录及其构建脚本纳入 Git 管理。可以创建不同的分支或目录来管理包含不同测试工具(如perf,strace)的文件系统。

  3. 探索 QEMU 的丰富参数

    • -m 512M: 指定客户机内存为 512MB。
    • -smp 4: 指定客户机为 4 核 CPU。
    • -drive file=disk.img,format=raw: 使用一个持久化的磁盘镜像,而不是临时的initramfs
    • -net nic,model=virtio -net user,hostfwd=tcp::2222-:22: 配置网络并设置端口转发,便于从宿主机 SSH 到客户机。
  4. 结合自动化测试框架:对于内核或驱动的大规模测试,可以将 QEMU 启动、内核加载、测试用例执行、结果收集的过程自动化,集成到 CI/CD 流水线中。

  5. 性能考量:纯软件模拟(TCG)模式较慢。如果宿主机和客户机架构相同(如都是 x86_64),可以启用 KVM 加速(需要宿主 CPU 和内核支持),参数为-enable-kvm,这将获得接近物理机的性能。

  6. 调试复杂问题

    • 内核崩溃 (Kernel Panic):QEMU 的-d参数可以输出详细的 CPU 状态、设备日志。结合crash工具分析内核转储。
    • 死锁与并发问题:QEMU 的确定性执行在某些情况下有助于复现并发 Bug。可以结合内核的lockdep,KCSAN等调试工具。
  7. 跨架构开发:这是 QEMU 的强项。要编译 ARM64 内核,你需要安装交叉编译工具链(如gcc-aarch64-linux-gnu),并使用make ARCH=arm64 defconfig配置,使用qemu-system-aarch64来启动。制作根文件系统也需要对应架构的busybox

从理解虚拟化与模拟的基本概念,到亲手编译内核、制作根文件系统,再到使用 GDB 进行源码级单步调试,最后完成一个内核模块的完整开发测试循环,我们走完了一个 Linux 内核开发者使用 QEMU 的标准工作流。这套环境的价值在于其极致的可控性和可重复性,它让你能安全、专注地探索操作系统最核心的领域。当你下次面对一个晦涩的内核 Oops 信息或想验证一个驱动设计时,不妨首先启动 QEMU,在这个数字沙箱中大胆实验。