ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

QEMU+GDB调试Linux内核:从编译到断点实战指南

QEMU+GDB调试Linux内核:从编译到断点实战指南 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-labmkdir -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_INFOy生成 DWARF 调试信息GDB 才能看到函数名、变量名和源码行号CONFIG_DEBUG_INFO_DWARF4y使用 DWARF4 格式兼容性和信息完整度都比较均衡CONFIG_GDB_SCRIPTSy生成内核提供的 GDB 辅助脚本对应lx-*系列命令CONFIG_KALLSYMSy保留内核符号表崩溃日志和调试时能显示函数名CONFIG_KALLSYMS_ALLy保留全部符号包括很多静态函数调试时会顺手很多CONFIG_RANDOMIZE_BASEn关闭内核地址随机化GDB 才能按静态地址准确下断点CONFIG_FRAME_POINTERy启用帧指针bt回溯调用栈的结果才靠谱CONFIG_DEBUG_KERNELy打开内核调试总开关CONFIG_DEBUG_INFO_REDUCEDn不要缩减调试信息否则局部变量信息会被砍掉最容易被忽略的是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) 21 | 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 制作极简根文件系统首先下载并编译 busyboxcd ~/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设备节点都不存在基本没法正常使用系统。最后打包成 initramfsfind . -print0 | cpio --null -ov --formatnewc --ownerroot:root 2cpio.log | gzip ../initramfs.cpio.gz这里注意两个细节--ownerroot:root是为了让打包后的文件属主统一成 root避免权限问题2cpio.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 consolettyS0 nokaslr panic-1 \ -nographic \ -m 1024M \ -s \ -S参数逐个拆开说-kernel指定内核启动镜像用编译产出的 bzImage。-initrd指定 initramfs 压缩包。-append是传给内核的启动参数consolettyS0把内核日志输出到串口配合-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 运行窗口不动另开一个终端进入内核源码目录启动 GDBcd ~/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 consolettyS0是否同时存在。串口控制和内核日志必须匹配只加-nographic而不指定consolettyS0内核日志会继续走 VGA 输出而 VGA 输出又被-nographic屏蔽了结果就是黑屏。反过来也是同理。第二步检查 initramfs 里的init脚本是否可执行。chmod x init漏掉的话内核找不到可用的 init表现为日志走到Run /init as init process附近就 panic。第三步检查 busybox 是否静态编译。动态链接的 busybox 在 initramfs 里没有 libc 支持启动就会报cant load library或者No working init found。6.2 GDB 连接失败target remote :1234时报Connection refused最常见的原因是 QEMU 侧没加-s或者 QEMU 进程已经退出。启动 QEMU 的命令里没有-s1234 端口自然没有服务在监听。端口被占用也会导致连接异常。排查命令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是否设成了nQEMU 启动参数里是否加了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 的组合价值远不止能下断点这一点。它真正改变了我对内核的理解方式——内核不再是一个黑盒而是一个可以在任意指令处暂停、可以查看每个变量、可以逐行追溯逻辑的系统。如果你也正在内核源码里挣扎建议花半天时间搭好这套环境回报远大于投入。
返回列表