☰
从零搭建QEMU+ARM64内核调试环境:让panic可控可分析
2026/9/27 13:58:35 网站建设 项目流程

1. 为什么我要从零搭一套能随时 panic 的内核调试环境

搞内核开发的人大概都有过这种体验:改了两行代码,编译半小时,烧到板子上,串口一片死寂,连个 panic 信息都没打出来。你盯着那块开发板,怀疑是驱动写错了、时钟配错了、还是压根没启动起来。这种"黑盒调试"的痛苦,是逼着我搭一套本地可复现调试环境的直接原因。

这套环境的核心诉求就一个:让内核想崩就崩,而且崩了我能立刻看到现场。听起来有点反直觉——别人都在追求稳定,为什么我要追求"随时能 panic"?因为内核调试的本质不是让系统跑起来,而是让系统在出问题时把话说清楚。一个能稳定复现 panic、能挂上 gdb 单步、能随时查看寄存器和调用栈的环境,比一块"看起来能跑但一出问题就失联"的板子有价值得多。

具体来说,这套环境要满足几个硬指标。第一,可复现:同样的代码、同样的操作,每次都能触发同样的崩溃点,不能是偶发的玄学问题。第二,可观测:panic 发生时,完整的调用栈、寄存器状态、内存映射都要能拿到,最好还能在崩溃前打断点。第三,可回滚:改坏了内核,几分钟就能换回上一个能启动的版本,不用重新烧写整个系统。第四,低成本:不需要额外的硬件,一台普通的开发机就能跑起来。

基于这几个诉求,我最终选定的方案是QEMU 模拟 ARM64 + 自编译 Linux 内核 + gdb 远程调试。这套组合的好处在于:QEMU 把硬件抽象掉了,你不需要关心具体的开发板型号、外设驱动、启动流程差异;自编译内核让你能随意加 printk、加断点、改 panic 行为;gdb 则提供了源码级的调试能力,比串口打印高效太多。

这篇文章是"16 集内核课"的第一集,我会把从零搭建这套环境的完整过程讲清楚,包括工具选型背后的逻辑、每一步操作的意图、以及我在实际搭建中踩过的坑。不管你是刚接触内核的新手,还是想换一套更顺手的调试环境的老手,这套流程都能直接抄作业。

提示:本文所有操作基于 x86_64 主机 + ARM64 目标架构,主机系统以 Ubuntu 22.04/24.04 为例。如果你用其他发行版,包管理命令需要相应调整,但核心逻辑完全一致。

2. 工具链选型:为什么是 QEMU + 自编译内核 + gdb 这套组合

2.1 QEMU 在调试场景里到底扮演什么角色

很多人对 QEMU 的印象停留在"虚拟机",觉得它就是个跑系统的模拟器。但在内核调试场景里,QEMU 的价值远不止于此。它本质上是一个全系统模拟器,能模拟 CPU 指令集、内存控制器、中断控制器、串口、定时器等一整套硬件。这意味着你可以在完全没有物理开发板的情况下,跑起一个完整的内核。

更关键的是,QEMU 提供了两个对调试极其友好的能力。第一个是-s -S参数:-s会在 1234 端口开启 gdb server,-S会让 CPU 在启动时暂停,等待 gdb 连接。这两个参数组合起来,你就能在内核执行第一条指令之前就挂上调试器,实现真正的"从零开始调试"。第二个是-d系列参数,可以 dump 出 CPU 状态、内存访问、中断触发等信息,配合-D输出到文件,用于事后分析。

我选 QEMU 而不是其他模拟器(比如 Renode、Bochs),主要考虑三点。一是架构支持全:ARM64、RISC-V、x86_64 都能模拟,换架构不用换工具。二是社区活跃:内核社区大量使用 QEMU 做 CI 测试,遇到问题容易找到答案。三是与 gdb 集成成熟:QEMU 的 gdb stub 实现稳定,断点、单步、内存读写都很可靠。

2.2 为什么不用现成的发行版内核,非要自己编译

这是新手最容易偷懒的地方:直接拿 Ubuntu 的 ARM64 内核镜像塞进 QEMU 不就行了?能跑,但调试体验很差。原因有几个。

第一,发行版内核默认关闭了大量调试选项。比如CONFIG_DEBUG_INFO(生成调试符号)、CONFIG_GDB_SCRIPTS(提供 gdb 辅助脚本)、CONFIG_KGDB(内核内置调试器)这些,发行版为了性能和体积通常都不开。没有调试符号,gdb 里看到的全是地址,没法对应到源码行。

第二,发行版内核的 panic 行为被定制过。很多发行版配置了panic_timeout,panic 后自动重启,你还没来得及看现场,系统就重启了。自编译内核可以设成 panic 后死等,方便你慢慢分析。

第三,你需要能改代码。调试过程中经常要加 printk、改断言、甚至故意制造 panic 来验证调试链路。发行版内核你改不了,自编译内核想怎么改就怎么改。

第四,编译过程本身就是学习。从make defconfig到make menuconfig再到make -j,你会直观感受到内核的配置体系和构建流程,这对理解内核结构帮助很大。

2.3 gdb 版本选择和远程调试的连接逻辑

gdb 这边有个坑:版本要够新。老版本 gdb 对 ARM64 的支持不完善,加载内核符号时可能报错或者解析错误。我实测下来,gdb 13.2 及以上版本对 ARM64 内核调试支持比较稳。Ubuntu 24.04 自带的 gdb 版本通常够用,如果太老就自己编译一个。

远程调试的连接逻辑是这样的:QEMU 启动时通过-s在本地 1234 端口监听,gdb 通过target remote :1234连上去。连上之后,gdb 就能读写目标机的内存、寄存器,设置断点,单步执行。这里要注意,gdb 加载的符号文件必须是和 QEMU 里跑的内核完全一致的那份 vmlinux,否则地址对不上,断点会设到错误的位置。

还有一个细节:内核开启了 KASLR(地址空间随机化)的话,每次启动内核加载地址都不一样,gdb 里的符号地址就对不上了。调试环境里建议在启动参数里加nokaslr关掉它,保证地址固定。

工具版本要求作用选型理由
QEMU6.0+模拟 ARM64 硬件gdb stub 稳定,架构支持全
Linux 内核5.15+被调试对象长期支持版,文档全
gdb13.2+源码级调试ARM64 支持完善
交叉编译工具链gcc-aarch64-linux-gnu编译 ARM64 内核Ubuntu 官方源直接装

3. 环境搭建实操:从装包到第一次成功启动

3.1 主机依赖安装与交叉编译工具链配置

第一步是把主机上的依赖装齐。Ubuntu 下一条命令搞定:

sudo apt update sudo apt install -y build-essential git flex bison libssl-dev libelf-dev \ qemu-system-arm gcc-aarch64-linux-gnu gdb-multiarch \ bc rsync cpio

这里解释几个容易忽略的包。flex和bison是内核构建时的词法/语法分析工具,缺了会在编译早期报错。libssl-dev和libelf-dev是内核签名和 ELF 解析需要的。bc是个计算器工具,内核构建脚本用它做算术运算,看着不起眼但缺了会中断编译。cpio用于生成 initramfs。

gdb-multiarch这个包很关键。普通 gdb 可能只支持主机架构,调试 ARM64 目标需要多架构版本。装好之后用gdb-multiarch命令启动,而不是gdb。

交叉编译工具链装好后验证一下:

aarch64-linux-gnu-gcc --version

能打印出版本信息就说明工具链就绪。这里有个经验:工具链版本和内核版本要匹配。太新的工具链编译老内核可能报 warning 甚至 error,太老的编译新内核可能不支持某些特性。我一般用 Ubuntu 官方源的版本,兼容性最稳。

3.2 内核源码获取与最小化配置

源码我建议用内核官方的长期支持版(LTS),比如 6.1 或 6.6。下载和解压:

wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.6.tar.xz tar -xf linux-6.6.tar.xz cd linux-6.6

接下来是配置。直接用defconfig生成 ARM64 默认配置:

make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- defconfig

但默认配置不够用,要手动开几个关键选项。用menuconfig进去改:

make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- menuconfig

需要确认开启的选项:

  • Kernel hacking→Compile-time checks and compiler options→Compile the kernel with debug info(对应CONFIG_DEBUG_INFO)
  • Kernel hacking→Generic Kernel Debugging Instruments→KGDB: kernel debugger(对应CONFIG_KGDB)
  • Kernel hacking→Tracers下的相关选项按需开
  • Device Drivers→Character devices→Serial drivers→ARM AMBA PL010 serial port support(QEMU 的串口)

还有一个隐藏坑:CONFIG_DEBUG_INFO开了之后,默认可能用DEBUG_INFO_REDUCED模式,符号信息不全。要确保CONFIG_DEBUG_INFO_REDUCED是关闭的,CONFIG_DEBUG_INFO_DWARF4或DWARF5开启,这样 gdb 才能拿到完整的类型和行号信息。

配置改完保存退出,配置文件在.config。建议把它备份一份,下次重编译直接复用。

3.3 编译内核并生成可调试的 vmlinux

编译命令:

make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- -j$(nproc)

-j$(nproc)用满所有 CPU 核心,能显著加快编译。第一次编译大概 10 到 30 分钟,取决于机器性能。

编译产物里,最关键的是vmlinux。这是带调试符号的未压缩内核 ELF 文件,gdb 加载的就是它。另外arch/arm64/boot/Image是压缩后的内核镜像,QEMU 启动时加载的是这个。两者必须来自同一次编译,否则符号对不上。

编译完检查一下 vmlinux 有没有调试符号:

file vmlinux

输出里应该包含with debug_info, not stripped。如果显示stripped,说明调试符号没生成,回去检查CONFIG_DEBUG_INFO配置。

注意:编译过程中如果报No rule to make target 'debian/canonical-certs.pem'之类的错误,是因为内核配置里引用了发行版的证书路径。解决办法是在 menuconfig 里把CONFIG_SYSTEM_TRUSTED_KEYS和CONFIG_SYSTEM_REVOCATION_KEYS清空。

3.4 用 QEMU 启动内核并挂上 gdb

先准备一个最简单的根文件系统。没有根文件系统内核会 panic(这其实正好符合我们"随时能 panic"的主题),但为了能进 shell 做更多测试,还是准备一个。最省事的办法是用 busybox 做一个 initramfs,或者直接下载一个现成的 rootfs 镜像。

启动 QEMU 的命令:

qemu-system-aarch64 \ -M virt \ -cpu cortex-a57 \ -smp 2 \ -m 1024 \ -kernel arch/arm64/boot/Image \ -append "console=ttyAMA0 nokaslr" \ -nographic \ -s -S

逐个参数解释。-M virt指定 QEMU 的虚拟开发板,这是 ARM64 最通用的虚拟平台。-cpu cortex-a57指定 CPU 型号,cortex-a57 是 ARMv8 的经典核心,兼容性好。-smp 2给两个 CPU 核心,方便调试多核场景。-m 1024给 1GB 内存。-kernel指定内核镜像。-append传内核启动参数,console=ttyAMA0指定串口控制台,nokaslr关闭地址随机化。-nographic表示不用图形界面,串口输出直接打到终端。-s -S就是前面说的 gdb server 加启动暂停。

启动后 QEMU 会停住,等待 gdb 连接。另开一个终端:

gdb-multiarch vmlinux

进入 gdb 后:

(gdb) target remote :1234 (gdb) b start_kernel (gdb) c

target remote :1234连上 QEMU。b start_kernel在内核入口函数下断点。c继续执行。如果一切正常,gdb 会停在start_kernel,这时候你就可以单步、看变量、看调用栈了。

4. 让内核"想崩就崩":panic 触发与现场捕获的几种手法

4.1 主动触发 panic 的三种方式及适用场景

调试环境搭好后,第一件事是验证"panic 可控"。我常用三种方式主动触发 panic,各有适用场景。

第一种是内核命令行参数。启动时加panic=1或者oops=panic,前者让内核在 panic 后 1 秒重启,后者把 oops 也升级成 panic。这种方式适合测试 panic 后的重启流程。

第二种是通过 sysrq 触发。系统跑起来后,往/proc/sysrq-trigger写c:

echo c > /proc/sysrq-trigger

这会立即触发一个 crash,内核打印调用栈后 panic。这种方式的好处是不用改代码,随时能触发,适合验证调试链路是否通畅。

第三种是代码里主动调用panic()或BUG()。在你想调试的位置插入:

panic("debug: intentional panic at %s:%d\n", __FILE__, __LINE__);

或者用BUG()触发 oops。这种方式最灵活,能精确控制崩溃点,适合调试特定代码路径。

我一般先用 sysrq 验证链路,确认 gdb 能捕获到 panic 现场,再在具体代码里插 panic 做精细调试。

4.2 panic 现场里到底该看什么

panic 发生时,内核会打印一大堆信息。新手容易被淹没,其实重点看这几块。

调用栈(Call trace)是第一位的。它告诉你崩溃时函数是怎么一层层调下来的,直接指向问题代码路径。栈里的地址配合 gdb 的list命令能定位到源码行。

寄存器状态在 ARM64 上会打印 x0 到 x30 以及 sp、pc 等。pc 是崩溃时的指令地址,x0 到 x7 通常是函数参数。看这些能判断是空指针解引用、非法地址访问还是断言失败。

PC 值对应的符号最关键。panic 信息里通常会有一行pc : some_function+0x1c/0x40,这个some_function就是崩溃函数,+0x1c是偏移。在 gdb 里用info line *地址能精确定位到行。

内存映射和页表信息在地址访问异常时很有用,能看出访问的地址是否在合法映射范围内。

我习惯在 panic 后先看调用栈定位大致范围,再用 gdb 连上去看寄存器和内存,最后回到源码分析根因。这个顺序比一上来就啃寄存器高效。

4.3 用 gdb 在 panic 前打断点,把问题扼杀在崩溃之前

比"崩溃后分析"更高阶的是"崩溃前拦截"。既然 gdb 能下断点,那就在可能出问题的地方提前断住,看状态对不对。

比如怀疑某个指针是空的,可以在解引用之前下断:

(gdb) b suspicious_function (gdb) c

断住之后:

(gdb) p pointer_variable (gdb) p *pointer_variable

如果发现是 NULL,那就找到根因了,不用等它崩溃。

对于 panic 函数本身,也可以下断:

(gdb) b panic

这样任何 panic 调用都会先断在 gdb 里,你能看到完整的调用栈和参数,比看串口打印的信息全得多。这是我最推荐的调试手法——把 gdb 断点下在 panic 入口,让每次崩溃都变成一次可控的暂停。

4.4 硬件断点与软件断点的选择

gdb 在 QEMU 上支持硬件断点和软件断点。软件断点通过替换指令实现,数量不限但会修改内存;硬件断点用 CPU 的调试寄存器,数量有限(通常 4 到 8 个)但不改内存。

在内核调试里,只读代码段建议用硬件断点,因为软件断点改不了只读内存。用hbreak命令下硬件断点:

(gdb) hbreak start_kernel

普通break是软件断点。如果下断点时报Cannot insert breakpoint,多半是内存只读,换成hbreak就行。

5. 搭建过程中最容易翻车的几个点

5.1 内核和 gdb 符号对不上:地址错位的排查

这是最常见的坑。现象是 gdb 里下断点,跑起来断在了莫名其妙的地方,或者list显示的源码和实际执行的对不上。

根因通常是三个。一是vmlinux 和 QEMU 加载的 Image 不是同一次编译的。解决办法是每次重编译后,确保 QEMU 用的 Image 和 gdb 用的 vmlinux 来自同一个源码目录。二是KASLR 没关。启动参数里必须加nokaslr。三是gdb 加载了错误的符号文件,比如加载了 stripped 版本或者别的架构的 vmlinux。

排查方法:在 gdb 里执行info files,看加载的符号文件路径对不对。再执行p &start_kernel,看打印的地址和 panic 信息里的地址是否一致。不一致就是符号问题。

5.2 QEMU 启动卡住不动:串口和控制台配置的坑

QEMU 启动后终端一片空白,什么都不打印,这种情况我遇到过好几次。

第一个原因是串口设备没配对。ARM64 的 virt 平台默认串口是ttyAMA0,如果内核配置里没开对应的串口驱动,或者启动参数里 console 写错了,就看不到输出。检查CONFIG_SERIAL_AMBA_PL011是否开启,启动参数是否是console=ttyAMA0。

第二个原因是**-nographic和图形输出冲突**。如果同时用了-nographic又没正确配置串口,输出可能被吞掉。确保只用-nographic,不要加-serial之类的额外参数。

第三个原因是内核压根没启动起来。这时候用-S让 QEMU 暂停,gdb 连上去单步,看卡在哪条指令。常见的是 CPU 型号不匹配或者内存布局问题。

5.3 编译报错合集:证书、工具链、依赖缺失

编译内核时的报错五花八门,我整理几个高频的。

No rule to make target 'debian/canonical-certs.pem':前面提过,清空CONFIG_SYSTEM_TRUSTED_KEYS。

flex: command not found或bison: command not found:装flex和bison。

fatal error: openssl/opensslv.h: No such file:装libssl-dev。

error: unrecognized command line option '-mgeneral-regs-only':工具链太老,升级gcc-aarch64-linux-gnu。

BTF: .tmp_vmlinux.btf: pahole (pahole) is not available:装dwarves包,或者关掉CONFIG_DEBUG_INFO_BTF。

这些报错看着吓人,其实都是依赖问题,装包或者改配置就能解决。关键是看报错信息里的关键词,对症下药。

5.4 gdb 连接超时和端口占用

target remote :1234报连接超时,通常是 QEMU 没启动或者-s参数没加。确认 QEMU 进程在跑,且启动命令里有-s。

端口被占用的话,换个端口。QEMU 用-gdb tcp::1235指定端口,gdb 里target remote :1235对应连上。

还有一种情况是 QEMU 启动了但没加-S,内核已经跑飞了,gdb 连上去时系统已经 panic 或者卡死。调试场景建议始终加-S,让 CPU 先暂停。

6. 把这套环境用起来:几个真实调试场景的复盘

6.1 定位一个空指针解引用

有次我写了个字符设备驱动,加载模块后一读设备就 panic。串口打印的调用栈指向mydev_read函数。gdb 连上去,在mydev_read下断点,重新触发读操作,断住后看参数:

(gdb) p dev $1 = (struct mydev *) 0x0

果然是空指针。回头看代码,发现open函数里忘了给file->private_data赋值,read里直接拿它当设备结构体用了。这种问题用 printk 也能查,但 gdb 一眼就能看到指针值,效率高得多。

6.2 追踪一个偶发的内存越界

偶发问题最难查,因为复现不稳定。我的做法是在可疑的内存操作前后下断点,配合 watchpoint 监控特定内存地址。

(gdb) watch *(int *)0xffff800012345678

watchpoint 在内存被写时触发,能抓到是谁改的。QEMU 对 watchpoint 支持不错,虽然会拖慢执行速度,但定位偶发问题很有效。

6.3 用 gdb 脚本自动化重复调试

每次调试都手动敲一堆 gdb 命令很烦。可以把常用命令写成脚本,gdb 启动时用-x加载:

# debug.gdb target remote :1234 b start_kernel b panic c

启动:

gdb-multiarch vmlinux -x debug.gdb

这样每次调试自动连上、自动下断点、自动继续,省去重复劳动。对于需要反复复现的场景,这个技巧能省不少时间。

6.4 保存和恢复调试现场

调试到一半要关机,下次想接着调怎么办?gdb 可以 dump 内存和寄存器状态:

(gdb) dump binary memory memdump.bin 0xffff000000000000 0xffff000010000000

不过更实用的做法是把复现步骤和断点配置记录下来,下次重新启动环境后按记录操作。内核调试环境本身是可重建的,与其保存现场,不如保存"如何到达现场"的脚本。

7. 关于这套环境的一些个人体会

搭这套环境前后花了我大概两天时间,其中大部分时间耗在编译和排查配置问题上。但搭好之后,后面每次调试内核都省了大量时间,这笔投入非常值。

有几个体会想分享给准备动手的朋友。第一,第一次编译内核一定要有耐心,报错是正常的,逐个解决就行,解决完一次以后就顺了。第二,配置文件要备份,.config和编译好的vmlinux都留着,换机器或者重装系统时能直接复用。第三,gdb 脚本要积累,把常用的断点、打印命令存成脚本,调试效率会越来越高。第四,不要怕 panic,这套环境的意义就是让 panic 变成可控的、可分析的,而不是可怕的。

这套环境搭好之后,下一集我会讲怎么在这个环境里调试一个真实的驱动模块,包括模块加载、符号解析、以及模块和内核交互时的调试技巧。如果你在搭建过程中遇到本文没覆盖的问题,大概率是环境差异导致的,按报错信息逐个排查基本都能解决。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询