仓库源文

.. Kenneth Lee 版权所有 2022

:Authors: Kenneth Lee :Version: 0.1 :Date: 2022-06-12 :Status: Draft

用Qemu调试Linux内核的技巧总结


介绍

本文总结一下用Qemu调试Linux内核的技巧。不是那种简单启动qemu gdbstub的技巧,而是 为了使能指令集,修改ABI级别的修改的时候,发现从指令,ABI定义,到内核使能时的错 误的技巧。这些技巧不少需要修改Qemu的代码。我这里给出来的都是经过实践应用的方法 ,主要是给做相似工作的人提供一个开发方向,说明这个方向是可行的。这也是这个总结 放在“架构设计”专栏中的原因。

打点和日志

Qemu可以用-d产生各种各样的事件跟踪,产生日志以后,用脚本可以发现各种Pattern,进而发现问题 在哪里。

用得最多的首先是-d exec,nochain。这是最基本跟踪整个执行过程的方法。对很多人来说 这没有什么用,因为它只会产生这样的记录:::

Trace 0: 0x7f0f34112b80 [0000000000000000/ffffffff80001166/00209001/ff000000] Trace 0: 0x7f0f34112cc0 [0000000000000000/ffffffff80a0083a/00209001/ff000000] Trace 0: 0x7f0f341132c0 [0000000000000000/ffffffff8000a71c/00209001/ff000000] Trace 0: 0x7f0f34113580 [0000000000000000/ffffffff80a00878/00209001/ff000000] Trace 0: 0x7f0f341136c0 [0000000000000000/ffffffff80a03504/00209001/ff000000] Trace 0: 0x7f0f34113980 [0000000000000000/ffffffff80a00880/00209001/ff000000] Trace 0: 0x7f0f34113ac0 [0000000000000000/ffffffff80a09d82/00209001/ff000000] Trace 0: 0x7f0f34113dc0 [0000000000000000/ffffffff800a7660/00209001/ff000000] Trace 0: 0x7f0f34114300 [0000000000000000/ffffffff800a3564/00209001/ff000000] Trace 0: 0x7f0f34114800 [0000000000000000/ffffffff80058fb4/00209001/ff000000] Trace 0: 0x7f0f34114b80 [0000000000000000/ffffffff80065ae6/00209001/ff000000]

每个记录是一个qemu的TB,表示一段连续的代码的执行。

但在我这里这些记录是这样的:::

Trace 0: 0x7f0f34112b80 [0000000000000000/ffffffff80001166/00209001/ff000000] Trace 0: 0x7f0f34112cc0 [0000000000000000/ffffffff80a0083a/00209001/ff000000] start_kernel Trace 0: 0x7f0f341132c0 [0000000000000000/ffffffff8000a71c/00209001/ff000000] set_task_stack_end_magic Trace 0: 0x7f0f34113580 [0000000000000000/ffffffff80a00878/00209001/ff000000] start_kernel Trace 0: 0x7f0f341136c0 [0000000000000000/ffffffff80a03504/00209001/ff000000] smp_setup_processor_id Trace 0: 0x7f0f34113980 [0000000000000000/ffffffff80a00880/00209001/ff000000] start_kernel Trace 0: 0x7f0f34113ac0 [0000000000000000/ffffffff80a09d82/00209001/ff000000] cgroup_init_early Trace 0: 0x7f0f34113dc0 [0000000000000000/ffffffff800a7660/00209001/ff000000] init_cgroup_root Trace 0: 0x7f0f34114300 [0000000000000000/ffffffff800a3564/00209001/ff000000] init_cgroup_housekeeping Trace 0: 0x7f0f34114800 [0000000000000000/ffffffff80058fb4/00209001/ff000000] mutex_init Trace 0: 0x7f0f34114b80 [0000000000000000/ffffffff80065ae6/00209001/ff000000] raw_spin_lock_init

这个功能默认是qemu-user里面解释elf文件找到的符号,要用于内核,需要进行一个移植 ,但这就是一个上午的工作量。可以通过一个选项指定vmlinux来指定格式。如果你懒得干 这种事,也可以用脚本基于vmlinux的符号表配合日志文件生成,那个效率没有在qemu中做 高。

这样整个运行过程序列我们都找到了,这个日志数据量到达用户态前,不过一两个G,完全 可以接受,我们还可以-dfilter来过滤掉部分不关心的地址,进一步缩小范围。比如我一 般会把范围设定在_start到_end之间,这样至少把固件排除在外。

如果需要具体看到汇编,可以加上-d in_asm,但要注意,-d exec才是执行序列,-d in_asm只是翻译过程,要跟踪运行序列还是要看Trace记录,再用这条记录去查找具体代码 ,这样才是执行过程。我会用脚本把Trace记录全部替换成汇编记录,这样会好看很多,但 这样平白加大了范围,只在使能前期,数据量比较小的时候合适,后期其实没有必要。

上面之跟踪过程,没有跟踪CPU的状态变化,加上-d cpu可以每个Trace前打印CPU的状态, 但这个跟踪会大幅提高日志文件的大小。一般到确定某个范围需要跟踪的时候再开。

其他的日志就根据需要来加就是了,比如前期使能MMU的时候我们会写Page Walk过程的日 志,调试中断的时候我们会加中断使能和关闭的日志。调试切换的时候我们单独跟踪中断 时和中断返回时的所有寄存器的值。配合执行过程,可以看到更多的内容。

但更有用的日志是配合编译器和内核的另一种日志:我们会增加一个调试指令,或者如果 你的指令空间足够大,可以留一个bit来做调试标记,遇到这条指令的时候,在Qemu中打一 个点,这样,就可以在跟踪的时候随时跟踪执行到什么地方了。

我二十多年前刚入行的时候,调试操作系统功能都是用所谓的“80口”,就是总线上留一个 端口,写这个地址就会把数字显示在一个两位的发光二极管数字上。这个相当于这种功能 的进化。你可以随便打点,一路跟踪程序运行到什么地方了。配合前面的-d exec,很容易 定位内核在什么地方跑飞了。

这个功能可以进化,你可以让入参寄存器指向一个字符串,然后你就可以用这样的代码来 打出复杂的日志来,比如这样:::

qemu_log(char *str) { asm volatile { my_debug_insn } }

当然上面只是个例子,这些写其实不那么安全,因为gcc不一定保留str的寄存器到你的嵌 入式汇编上下文中。但原理就是这么个原理,你把这个实现放汇编里面去就肯定没有这个 问题了。或者你用register变量来表达输入也行。这里重点告诉你这个技巧可以用到什么 程度。

这个调试指令可以玩出很多花样来,比如不同的指令版本可以输出cpu状态,输出特定地址 的内容,请求qemu启动某个变量的变化过程跟踪等等。如何怎么用具体看遇到什么问题。

断点

另一个有用的功能是断点。断点可以通过在qemu中调用stop_vm()触发,触发后整个Guest 会停下来,这有几个作用:

  1. 至少不会产生日志了,否则OS空转也会产生日志,几分钟之内就能产生几百G的日志。
  2. 这时可以通过monitor去读内存,读CPU状态,看Guest的状态。
  3. 这时还可以用gdb连到qemu上,进行所有gdb可以干的事情。

我会给qemu增加这几种断点:

  1. 遇到某个地址断点
  2. 经过某个地址以后N条指令以后断点
  3. 数据断点
  4. 遇到某个指令触发断点
  5. 在Monitor中动态增加断点
  6. 靠gdb设置断点

这些功能通常需要根据开发进展一点点加,因为有些功能初期不一定有的,比如增加指令 的早期,gdb不一定能立即工作,这个阶段增加qemu的断点就容易得多。

BUG(), WARN()这些函数,都可以换成debug指令,这样进入异常情形都可以跟踪起来。

状态跟踪

内核什么时候在关中断状态,什么时候是在prempt状态,在什么线程,是否在调度,这些 状态我们不一定立即知道,这些全都可以通过调试指令写给qemu,qemu做-d exec的跟踪就 可以看到这些状态,这对于死锁的跟踪很有效,比如这样的:::

Trace-XXX 0: 0x7f0f34147580 [0000000000000000/ffffffff80802e80/00209001/ff000000] lock_is_held_type Trace-XXX 0: 0x7f0f34147580 [0000000000000000/ffffffff80802e80/00209001/ff000000] lock_is_held_type Trace-XX*X 0: 0x7f0f34147c00 [0000000000000000/ffffffff80802eb8/00209001/ff000000] lock_is_held_type Trace-XX 0: 0x7f0f346bfd40 [0000000000000000/ffffffff800756b6/00209001/ff000000] rcu_read_lock_sched_held Trace-XX 0: 0x7f0f346c0440 [0000000000000000/ffffffff80063704/00209001/ff000000] lock_acquire Trace-XX 0: 0x7f0f3411f180 [0000000000000000/ffffffff80063530/00209001/ff000000] lock_acquire Trace-XX 0: 0x7f0f3411f540 [0000000000000000/ffffffff80061014/00209001/ff000000] lock_acquire Trace-XX 0: 0x7f0f3412d400 [0000000000000000/ffffffff800600c0/00209001/ff000000] mark_lock.part.0 Trace-XX 0: 0x7f0f34139480 [0000000000000000/ffffffff8006137a/00209001/ff000000] lock_acquire

Trace后面随时可以根据后面的掩码状态判断当前CPU和OS的运行状态。

调用栈上埋跟踪信息

遇到断点以后,经常会发现某个锁的状态不对,但之前的锁已经用过了,如果我们每次用 锁的时候都打印,这就会有无数打印,而且对不上哪个实例。更简单的方法,是在堆栈中 埋上这个信息,比如下面这个代码:::

outter_layer_function() { lock(a); inner_layer_function(); }

我们的断点在层层深入的inner_layer_function()中,我们现在想支持lock(a)是谁,那可 以这样写:::

outter_layer_function() { volatile struct debug_var { int magic=0x1010101; void * data = a; }; lock(a); inner_layer_function(); }

这样就可以回溯堆栈,通过magic找到a的内容。这种技巧可以用在各种动态调试上。

总结

先记录一下想得起来的,其他后面再说。