TaterLi 个人博客

[内核学习]内核调试环境

调试内核三件事:能断下来看现场,能不打断看流动,能事后验尸.对应三套工具:QEMU 加 gdb,KGSGB 加串口,ftrace 加 kallsyms.这篇每套都实际跑一遍,顺带说说板上这套配置偏素的内核,哪些能用哪些用不了,别拿着网上的教程命令硬敲.

这篇回答几件事:

先说最强的那套:QEMU 加 gdb.qemu-system-aarch64 带 -s 参数就内置了一个 gdb server,监听 1234 端口;-S 让 CPU 一上电就停住.这两个参数和内核没有任何关系,纯硬件级,内核哪怕一个字符都不用改,这是它比 KGDB 优越的根本点.

实测流程:启动时加 nokaslr(重要,后面说),gdb-multiarch 载入带符号的 vmlinux,target remote :1234 连上,第一个现场就有惊喜:

(gdb) info registers x0 x1 x2 x3 pc
x0=0x0 x1=0x0 x2=0x0 x3=0x0 pc=0x40000000
=> 0x40000000: ldr x0, 0x40000018
   0x40000004: mov x1, xzr

PC 停在 0x40000000,但这段代码不是内核!对照 head.S 你会发现内核第一条指令是 bl,不是 ldr 加一堆 mov xzr.这是 QEMU 自己内建的 bootloader 存根:它把 DTB 地址装进 x0,x1 到 x3 清零,然后跳去真正的内核入口.也就是说教科书里"bootloader 交班"那一段,在 QEMU 里被具象成了十几条看得见的指令,单步两下就看见 x0 被填上了 DTB 物理地址.这种具象是文字给不了的.

往下断 start_kernel,注意必须用 hbreak(硬件断点):MMU 还没开,内核正文没有有效的虚拟映射,软件断点要往代码里写 0xE420... 写不进去,硬件断点走调试单元,不看映射.断上之后世界瞬间美好:

(gdb) continue
Thread 1 hit Breakpoint 1, start_kernel () at init/main.c:872
#0  start_kernel () at init/main.c:872
#1  __primary_switched () at arch/arm64/kernel/head.S:246
(gdb) p init_task.comm
$1 = "swapper"

源码行号(这份 vmlinux 带了 DWARF),穿汇编边界的调用栈(从 C 的 start_kernel 直接看到 head.S 的 __primary_switched,上一篇讲的启动流程在栈上现形),还能直接读全局变量.init_task 叫 "swapper",就是那个 idle 祖宗.

说说 nokaslr.调试启动期不加它,断点会全部扑空:内核被 KASLR 随机平移,vmlinux 里的符号地址和实际执行地址差一个未知偏移.gdb 也不是完全没办法(可以拿 __primary_switched 落地后的值算 kimage_voffset),但调启动加 nokaslr 是基本礼仪,省下的脑子留给真正的 bug.还有个小知识:gdb 里 p &primary_entry 得到的是 0xffff8000... 开头的虚拟地址,而 MMU 开启前 CPU 实际在 0x4008xxxx 物理地址跑,符号对不上不是 gdb 坏了,是两个世界还没接轨.开 MMU 之后,虚拟地址生效,符号"啪"一下全部对上,这个瞬间亲自单步过一次,对分页的理解比看十篇文章都深.

第二套,KGDB/KDB,内核自己的调试后端.配置上要开 CONFIG_KGDB,启动参数 kgdboc=ttyS0,115200 指定共享串口,另一台机器 gdb 通过串口连进来;KDB 是它的前台键盘版.板上这套内核没编 KGDB,查 /proc/config.gz 里 CONFIG_KGDB 一行没有,如实说,想用得重编.它的适用场景和 QEMU 互补:真机上的硬件相关 bug,网卡时序,电源管理,QEMU 模拟不出来的那部分,才轮到 KGDB 上场.代价也不小,串口线,双机,断在 KGDB 里时系统完全冻结,时间相关的问题全变形.

第三套,不打断看流动,主角 ftrace.板上有个遗憾:CONFIG_FUNCTION_TRACER 没编,available_tracers 里只有 nop,函数级跟踪用不了,我编的 QEMU defconfig 也没开这项(下次自编记得把它点亮,菜单里在 Kernel hacking 下).但 tracepoint 是好的,这是静态埋点的跟踪,调度器,中断,内存管理全埋好了,等用户开闸.实测调度切换:

# cd /sys/kernel/tracing
# echo 1 > events/sched/sched_switch/enable
# head trace
 <idle>-0  [000] d.... 237.681348: sched_switch: prev_comm=swapper/0 prev_pid=0
               prev_state=R ==> next_comm=bash next_pid=569 next_prio=120
 bash-554   [002] d.... 237.681684: sched_switch: prev_comm=bash prev_pid=554
               prev_state=S ==> next_comm=swapper/2 next_pid=0

三百毫秒抓了 76 次切换,连 idle 线程的"苏醒"都看得清清楚楚.这套输出后面讲调度器时会大量使用,先混个脸熟.

最后验尸三件套.内核炸了留下一堆十六进制,要变回人类可读,靠三张表.第一张 /proc/kallsyms,运行时在线查.注意它对普通用户是打码的,实测 root 查 __arm64_sys_ioctl 得到 ffff80008026f6e0,su 到 nobody 查同一个符号得到一排 0 — kptr_restrict 在起作用,内核地址是敏感信息,随便漏给容器和普通进程就是攻击面.第二张 System.map,链接期生成,上一篇验证过它和板上 kallsyms 一字不差.第三张是 addr2line,把地址翻译回源文件行号,但它需要 vmlinux 里有调试信息.实测两个档位:板上的 vmlinux 没编 DWARF,addr2line 只能吐出函数名,行号是问号;QEMU 那份编了 CONFIG_DEBUG_INFO,vmlinux 从 23MB 胀到 159MB,同一个工具直接吐出 init/main.c:871.所以构建篇那条建议再强调一遍:认真对待的项目,保存一份带调试信息的 vmlinux,是给未来验尸的自己买保险.

最后留几个容易忽略的细节:

退出移动版