1. 为什么我不建议在发行版内核上直接碰运气
1.1 发行版内核的三大硬伤
很多同学排查内核问题时,第一反应是打开/boot/目录,看着vmlinuz-6.x.x-generic发呆。这个文件是压缩过的内核镜像,可以直接启动,但里面不包含完整的调试符号。你把它拖进 GDB,得到的结果通常是一堆No symbol table is loaded的报错。
发行版内核还有两个比缺少符号更麻烦的问题。第一,发行版会在上游 Linux 代码基础上打大量补丁,安全修复、驱动背板、调度器改动都有,你面对的实际代码和 kernel.org 上看到的源码可能差了十万八千里。第二,发行版为了兼容各种硬件,会开启大量模块化配置,而调试场景恰恰需要精简配置、关闭地址随机化、保留帧指针这些"偏门"选项。这两点叠加在一起,导致你想通过发行版自带的 vmlinuz 复现一个内核崩溃现场时,几乎等于在饭店后厨里做菜——锅碗瓢盆都在,但你根本不知道哪口锅对应哪道菜。
我在早期调试内核 panic 时也走过弯路。当时一个驱动在特定负载下偶发崩溃,我反复加 printk、重新编译模块、重启宿主机,折腾了整整两天,最后发现崩溃点和驱动本身毫无关系,问题出在一个被发行版补丁改过的内存分配路径上。那一刻我就下了决心:要么不用内核,要用就自己编译。
1.2 自己编译换来的是完全可控
自己从源码编译 Linux 6.1 内核,换来的是三个关键控制权:源码、配置、符号。
源码层面,你拿到的是干净的、未经第三方修补的 LTS 版本,任何一行代码都能在源码树上精确对应。配置层面,你可以主动关闭 KASLR(内核地址空间布局随机化)、开启 DEBUG_INFO、保留帧指针,这些选项直接决定 GDB 能不能在下断点的瞬间准确命中。符号层面,编译产物vmlinux是一份完整的 ELF 镜像,函数名、变量名、行号信息一应俱全,GDB 可以直接加载。
打个不恰当的比方:调试内核有点像修一台老式机械表。你在表面上看齿轮转动(printk 日志),永远只能猜内部哪根发条出了问题。而自己编译内核配合 QEMU 和 GDB,相当于把表壳彻底打开,还能用指针顶住某个齿轮,让它转到你想看的位置停住,一格一格往下走。这种掌控感,是任何日志分析工具都给不了的。
1.3 这套环境到底适合谁
三种人最适合这套 QEMU + GDB 调试环境:
第一种是刚接触内核源码的初学者。你可以在start_kernel下断点,用next一条一条看内核从无到有的启动过程,比任何源码分析文章都直观。第二种是驱动开发者。模块崩溃、死锁、内存越界,在虚拟机里随便折腾,崩了重启镜像就是,不会波及宿主机。第三种是做内核安全、模糊测试、性能分析的研究者。这类工作经常需要修改内核代码、加 hook、反复启动,没有一套可控环境,效率会低到怀疑人生。
考虑到 Linux 6.1 是长期维护版本,社区资料多、发行版覆盖广,选它作为学习基准是最稳的。接下来的步骤我就以 6.1.68 小版本为例,小版本号可以换成任意 6.1.y。
2. 宿主机准备:工具链、源码与最低硬件门槛
2.1 依赖包清单
在 Ubuntu/Debian 系宿主机上,我建议一条命令装齐全部依赖:
sudo apt update sudo apt install -y \ build-essential \ flex \ bison \ libncurses-dev \ libssl-dev \ bc \ libelf-dev \ dwarves \ qemu-system-x86 \ gdb逐条说下作用,免得你装完还是一头雾水。
build-essential提供 gcc、make 等基础编译工具。flex和bison是内核构建脚本里生成解析器时依赖的经典工具,没有它们会在早期阶段直接报错。libncurses-dev对应make menuconfig的图形菜单界面。libssl-dev用于内核配置阶段生成证书相关的工具。bc是一个计算器程序,内核的编译脚本会调用它做数值运算。libelf-dev是处理 ELF 文件所需的开发库,编译模块和生成符号表时要用。dwarves这个包提供 pahole 工具,内核启用某些 BTF 调试选项时需要它。qemu-system-x86是模拟器本体,gdb是远程调试器。
如果你用的是 Arch Linux,把build-essential换成base-devel,其余包名基本一致。Fedora 系则用dnf install gcc make flex bison ncurses-devel openssl-devel bc elfutils-libelf-devel qemu-system-x86 gdb。
2.2 内核源码版本选择与校验
下载源码我习惯放到一个专用工作目录,比如~/kernel-lab:
mkdir -p ~/kernel-lab && cd ~/kernel-lab wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.1.68.tar.xz sha256sum linux-6.1.68.tar.xz tar xf linux-6.1.68.tar.xzsha256sum这一步很多人会跳过,但我不建议省。内核源码被投毒的事件在开源社区不是没发生过,下载完顺手校验一下哈希值,和官网 kernel.org 上公布的 SHA256 核对,成本几秒钟,换来的是一份心安。官网在每份源码包旁边都会列出对应哈希,照着比对就行。
解压完成后,cd linux-6.1.68进入源码树。如果你希望后续调试某个具体版本,比如 6.1.55,只需把 URL 里的版本号替换掉,6.1 大版本内的编译配置和调试流程完全一致。
2.3 磁盘、内存和编译时间预估
内核编译对硬件的要求没有想象中那么夸张,但也不能太寒酸。
磁盘方面,完整编译 6.1 内核、保留中间.o文件,大约需要 15GB 左右空间,加上源码解压、根文件系统和 QEMU 镜像,我给的建议是留出 20GB。内存方面,如果你执行make -j$(nproc)全核并行编译,8GB 内存跑 8 个线程基本够用,但偶尔链接阶段会吃紧。个人经验是 16GB 内存比较舒服。编译时间取决于 CPU 核心数,8 核机器首次全量编译大约 15 到 25 分钟,4 核机器可能要接近 40 分钟。耐心点,这个时间值得花。
提示:如果你的
/tmp分区比较小,一定把源码放到空间充足的目录,因为内核编译过程中会有大量临时文件生成,/tmp不够会直接编译失败。
3. 内核配置:决定调试体验的其实是这几个开关
3.1 从 defconfig 到 menuconfig
进入源码目录后,第一步生成一个基础配置:
cd ~/kernel-lab/linux-6.1.68 make defconfigdefconfig会根据当前架构生成一份默认配置,对 x86_64 来说是一份"能用但不精简"的全功能基础配置。它包含了大量驱动和功能模块,并不是所有选项都是调试需要的,但先跑一遍它能保证后续菜单配置在一个合理基线上进行。
然后打开图形化配置界面:
make menuconfigmenuconfig 是基于 ncurses 的终端菜单,上下键移动,回车进入子菜单,按/可以搜索配置项名称,按?查看当前选项的帮助信息。初次进入菜单的同学可能会迷路,我建议直接用/搜索,比一层层翻菜单高效得多。
3.2 翻译一下这几个调试选项
下面这几个选项是整套调试环境的核心,我逐个解释它们的作用和设置理由。
| 配置项 | 推荐值 | 用途 |
|---|---|---|
CONFIG_DEBUG_INFO | y | 生成 DWARF 调试信息,GDB 才能看到函数名、变量名和源码行号 |
CONFIG_DEBUG_INFO_DWARF4 | y | 使用 DWARF4 格式,兼容性和信息完整度都比较均衡 |
CONFIG_GDB_SCRIPTS | y | 生成内核提供的 GDB 辅助脚本,对应lx-*系列命令 |
CONFIG_KALLSYMS | y | 保留内核符号表,崩溃日志和调试时能显示函数名 |
CONFIG_KALLSYMS_ALL | y | 保留全部符号,包括很多静态函数,调试时会顺手很多 |
CONFIG_RANDOMIZE_BASE | n | 关闭内核地址随机化,GDB 才能按静态地址准确下断点 |
CONFIG_FRAME_POINTER | y | 启用帧指针,bt回溯调用栈的结果才靠谱 |
CONFIG_DEBUG_KERNEL | y | 打开内核调试总开关 |
CONFIG_DEBUG_INFO_REDUCED | n | 不要缩减调试信息,否则局部变量信息会被砍掉 |
最容易被忽略的是CONFIG_RANDOMIZE_BASE。KASLR 是内核的一项安全机制,每次启动时把内核映射到不同地址。这个机制在真实生产环境里非常有价值,但在调试环境里它是断点命中最大的敌人。你按 vmlinux 的静态地址在start_kernel下断点,实际运行地址却每次随机偏移,GDB 只会一头雾水。所以在调试内核时关闭 KASLR 是常规操作,这也是为什么后面 QEMU 启动参数里还要额外加一个nokaslr。
CONFIG_FRAME_POINTER也很关键。没有帧指针时,GDB 想通过栈回溯找到调用者,只能依赖 DWARF 的调用帧信息,成功率并不高。打开帧指针后,每个函数入口都会保存调用者的栈基址,bt命令能给出清晰完整的调用链,这在排查 panic 时几乎是救命级别的。
在 menuconfig 里搜索并修改这些选项时,可以用/输入DEBUG_INFO定位,回车进入后按Y选择,按N取消。改完后按左右方向键选Save保存到.config,再按两下Exit退出。
3.3 编译并检查产物
配置完成后,启动编译:
make -j$(nproc) 2>&1 | tee build.logtee build.log会把编译日志同时写进文件,后续排查编译错误时可以直接grep -i error build.log。编译过程中如果报错,最常遇到的几个我已经在第六章单独整理。
编译结束后,确认两个产物的存在,它们的用途完全不同:
ls -lh vmlinux ls -lh arch/x86/boot/bzImagevmlinux是未压缩的 ELF 内核镜像,包含完整调试符号,给 GDB 用。arch/x86/boot/bzImage是压缩过的启动镜像,QEMU 启动内核时用这个。很多人刚接触时会混淆:拿 vmlinux 去给 QEMU 当-kernel参数,或者拿 bzImage 给 GDB 加载符号,都会失败。记住一个朴素的原则:QEMU 需要的是"能启动的瘦身版",GDB 需要的是"带符号的完整版",两者各司其职。
4. QEMU 与根文件系统:让编译出来的内核真正跑起来
4.1 为什么选择 initramfs 方案
内核镜像编译好了,还需要一个"外壳环境"让它跑起来。QEMU 提供了完整的虚拟机硬件模拟,而虚拟机里的内核需要一个根文件系统来挂载/、启动第一个进程。
构建根文件系统的方案有三种,我做了个对比:
| 方案 | 优点 | 缺点 |
|---|---|---|
| initramfs + busybox | 制作快、启动快、体积小,适合调试 | 需要自己组装目录结构和 init 脚本 |
| buildroot | 功能全面、可定制构建完整系统 | 首次配置和下载时间长,对新手不友好 |
| 磁盘镜像 + 发行版 rootfs | 最接近真实环境,包管理可用 | 体积大、启动慢,调试时频繁重启成本高 |
针对"快速启动 + 反复重启 + 内核调试"这个目标,initramfs 是性价比最高的选择。busybox 是一个把上百个常用命令打包进一个二进制的工具,静态编译后不需要任何动态库就能独立运行,正好用来撑起一个极简的 Linux 用户空间。
4.2 用 busybox 制作极简根文件系统
首先下载并编译 busybox:
cd ~/kernel-lab wget https://busybox.net/downloads/busybox-1.36.1.tar.bz2 tar xf busybox-1.36.1.tar.bz2 cd busybox-1.36.1 make defconfig make menuconfig在 menuconfig 里定位到Settings→Build static binary (no shared libs),按Y开启。这一步非常关键:如果 busybox 编译成动态链接版本,放进 initramfs 后因为没有 glibc 动态库,第一个进程根本起不来,你会看到No working init found,然后内核直接 panic。
保存配置并编译安装:
make -j$(nproc) make installmake install会把 busybox 和它的一大堆符号链接装到当前目录下的_install目录里。接下来我们手工组装根文件系统:
cd _install mkdir -p proc sys dev etc然后在_install下创建init脚本,它是内核启动后执行的第一个用户空间程序:
cat > init << 'EOF' #!/bin/sh mount -t proc proc /proc mount -t sysfs sysfs /sys mount -t devtmpfs devtmpfs /dev echo "=== kernel debug environment up ===" exec /bin/sh EOF chmod +x init这段脚本的作用是挂载 proc、sysfs、devtmpfs 三个关键虚拟文件系统,然后启动一个交互 shell。没有这些挂载,你在 shell 里查看进程列表、内核信息时会出现一片空白;没有 devtmpfs,设备节点都不存在,基本没法正常使用系统。
最后打包成 initramfs:
find . -print0 | cpio --null -ov --format=newc --owner=root:root 2>cpio.log | gzip > ../initramfs.cpio.gz这里注意两个细节:--owner=root:root是为了让打包后的文件属主统一成 root,避免权限问题;2>cpio.log是 cpio 把文件清单打印到标准错误输出的常规操作,直接忽略会让终端刷屏到看不清。
4.3 QEMU 启动参数逐个拆解
一切准备就绪,启动虚拟机:
qemu-system-x86_64 \ -kernel ~/kernel-lab/linux-6.1.68/arch/x86/boot/bzImage \ -initrd ~/kernel-lab/initramfs.cpio.gz \ -append "console=ttyS0 nokaslr panic=-1" \ -nographic \ -m 1024M \ -s \ -S参数逐个拆开说:
-kernel指定内核启动镜像,用编译产出的 bzImage。-initrd指定 initramfs 压缩包。-append是传给内核的启动参数:console=ttyS0把内核日志输出到串口,配合-nographic能在纯终端下看到启动日志;nokaslr和前面内核配置里关闭 KASLR 呼应,双保险;panic=-1表示内核 panic 后立即重启而不停在黑屏,调试时崩溃了能快速回到起点。-nographic让 QEMU 不使用图形窗口,改用串口作为控制台,这在远程开发和服务器环境里非常实用。-m 1024M分配 1GB 内存给虚拟机。-s是-gdb tcp::1234的简写,相当于在 1234 端口打开 GDB 远程调试服务。-S让虚拟机启动后先暂停 CPU,等待 GDB 连接,这是调试的关键开关。
执行这条命令后,你看到的画面应该是黑屏、CPU 暂停状态。这不是卡死了,是 QEMU 在等 GDB 过来"接管"。如果你不想连 GDB 只想先看看内核能不能启动,把-S去掉重新跑一遍即可。
5. GDB 连接与第一次在 start_kernel 下断点
5.1 从 gdb 启动到连接目标
保持 QEMU 运行窗口不动,另开一个终端,进入内核源码目录启动 GDB:
cd ~/kernel-lab/linux-6.1.68 gdb vmlinux进入 GDB 提示符后,依次执行:
(gdb) target remote :1234 (gdb) hbreak start_kernel (gdb) continuetarget remote :1234建立与 QEMU 的远程连接。连接成功后,GDB 会报告Remote debugging using :1234,同时显示出 QEMU 暂停时 CPU 所在的地址。然后我们用硬件断点在start_kernel下断点,continue让内核继续执行。
这时你会看到 QEMU 那边的黑屏瞬间开始滚动启动日志,然后在某个位置停住。GDB 这边显示:
Breakpoint 1, start_kernel () at init/main.c:...这一刻值得纪念。你亲手编译的内核、跑在 QEMU 虚拟机里、被 GDB 精准截停在 C 语言入口函数。之后用next、step、list、info regs就可以像调试普通用户态程序一样,一行一行看着内核启动。
5.2 lx-symbols 与内核辅助命令
直接在 vmlinux 上调试,能看到的符号只限于内核自身。如果我们后续要调试内核模块,模块加载时有独立的地址空间和符号表,刚加载时 GDB 并不知道。内核源码树里自带的 GDB 脚本就是解决这个问题的。
加载辅助脚本:
(gdb) source ~/kernel-lab/linux-6.1.68/scripts/gdb/linux (gdb) lx-symbols这里有个小坑:source后面跟的是scripts/gdb/linux这个目录,不是某个.py文件。GDB 加载该目录后会识别并加载目录下的vmlinux-gdb.pyPython 脚本,进而注册lx-*系列命令。lx-symbols会自动重读模块加载状态,为后续插入的模块符号做映射。
常用辅助命令我整理在下表:
| 命令 | 作用 |
|---|---|
lx-dmesg | 查看内核环形缓冲区里的日志 |
lx-lsmod | 列出当前加载的内核模块 |
lx-ps | 列出内核进程列表 |
lx-current | 查看当前正在执行的进程 |
lx-cmdline | 查看内核启动参数 |
lx-iomem | 查看 IO 内存映射 |
lx-dmesg是我最常用的。内核日志输出到了 QEMU 的串口,但串口日志滚动很快,有时候想回头翻某条信息很费劲。在 GDB 里用lx-dmesg直接拉出完整内核日志,配合grep,排查效率高得多。
5.3 一次简洁的调试实战
光会断在start_kernel还不够,我以一个常见场景为例,展示完整调试流程。
目标:观察进程创建过程,断在copy_process函数。在 GDB 里执行:
(gdb) hbreak copy_process (gdb) continue如果内核一直没触发进程创建,可以切到 QEMU 的串口终端,随便执行一条命令触发 fork。busybox 的 shell 每执行一条外部命令就会 fork 一次。这时 GDB 会迅速命中,函数调用栈可以用bt查看:
(gdb) bt栈帧会从copy_process一路向上延伸到do_fork、kernel_clone、syscall等调度和系统调用路径。通过对比栈帧,你能直观理解用户态 fork 是如何穿过系统调用进入内核态的,这种"亲手解剖"的体验远比读源码来得深刻。
查看寄存器状态用info registers,查看某内核变量的值用p 变量名。如果变量是一个结构体指针,直接p *ptr就能展开成员,DWARF 调试信息里完整保留了类型定义,体验和调试用户态程序几乎没有差别。
5.4 为什么我坚持用 hbreak 而不是 break
这里分享一个实战经验:调试内核早期启动代码时,我强烈建议使用hbreak硬件断点,而不是默认的break软件断点。
软件断点的实现原理是在目标地址写入一条断点指令,程序执行到该地址时触发异常。问题在于:内核启动早期,某些内存页要么还没建立映射,要么处于只读状态,软件断点写入指令可能失败,甚至导致 undefined behavior。硬件断点则利用 CPU 的调试寄存器实现,不需要改写内存内容,只要 CPU 执行到对应地址就会触发,可靠得多。
经历过一次"明明下了断点却永远不命中"的困惑后,我在调试内核时几乎只用hbreak。代价是硬件断点数量有限(x86 通常最多 4 个),但内核调试场景下同时跟踪的点很少,完全够用。等你调试的是用户态普通程序,再切回软件断点也不迟。
6. 常见问题排查记录
6.1 QEMU 黑屏或没有输出
QEMU 启动后完全黑屏,先别慌。第一步检查-nographic和-append "console=ttyS0"是否同时存在。串口控制和内核日志必须匹配,只加-nographic而不指定console=ttyS0,内核日志会继续走 VGA 输出,而 VGA 输出又被-nographic屏蔽了,结果就是黑屏。反过来也是同理。
第二步检查 initramfs 里的init脚本是否可执行。chmod +x init漏掉的话,内核找不到可用的 init,表现为日志走到Run /init as init process附近就 panic。
第三步检查 busybox 是否静态编译。动态链接的 busybox 在 initramfs 里没有 libc 支持,启动就会报can't load library或者No working init found。
6.2 GDB 连接失败
target remote :1234时报Connection refused,最常见的原因是 QEMU 侧没加-s,或者 QEMU 进程已经退出。启动 QEMU 的命令里没有-s,1234 端口自然没有服务在监听。
端口被占用也会导致连接异常。排查命令:
ss -ltnp | grep 1234如果确认有别的进程占用了 1234 端口,要么杀掉那个进程,要么给 QEMU 换个端口,比如用-gdb tcp::1235指定其他端口,GDB 侧相应地连target remote :1235。
还有一类情况出现频率不低:QEMU 跑在物理机上,GDB 跑在容器里,或者跨主机通过 SSH 端口转发连接,这时候网络路径上的防火墙策略可能会拦掉 TCP 1234 端口。建议先在同一台宿主机上完成基本的连接测试,再考虑跨环境。
6.3 断点不生效或地址不对
如果你下了break start_kernel后继续执行,内核一路跑完没有任何停顿,优先检查两件事:KASLR 是否关闭、断点类型是否正确。
KASLR 相关:内核编译配置里CONFIG_RANDOMIZE_BASE是否设成了n,QEMU 启动参数里是否加了nokaslr。这两个位置任何一个漏掉,内核实际加载地址都会随机偏移,GDB 按静态地址下的断点自然全落空。
断点类型相关:确认用的是hbreak而不是break。另外,如果在早期启动代码下发软件断点,断点指令可能被写入只读页,或写到尚未映射的地址上,表现为不触发或触发后状态异常。
如果断点地址差那么一点点,还可以用info files查看 GDB 认为的内核加载地址,和 QEMU 串口日志里打印的Kernel Offset对比,偏移量一目了然。
6.4 内核 panic 后没有自动重启
我在启动参数里加了panic=-1,却仍然看到 panic 后系统停住不动。原因是panic=-1要求内核能正确进入重启路径,但有些 panic 点是注册了 panic 回调的,比如某些驱动会在 panic 时锁住系统。
遇到这种情况,最省事的办法不是调内核参数,而是在 GDB 里从panic函数往回看调用栈。在 QEMU 串口广播 panic 日志的瞬间,GDB 端及时interrupt中断执行,然后bt看调用链,往往能直接抓到触发 panic 的那条路径,这比反复重启找日志高效得多。调试内核和平时写应用不一样,崩溃本身就是最宝贵的调试现场,别急着把现场毁掉重启。
关于内核调试环境,我还有一句个人体会:这套 QEMU + GDB 的组合,价值远不止"能下断点"这一点。它真正改变了我对内核的理解方式——内核不再是一个黑盒,而是一个可以在任意指令处暂停、可以查看每个变量、可以逐行追溯逻辑的系统。如果你也正在内核源码里挣扎,建议花半天时间搭好这套环境,回报远大于投入。