关于人生,有一句著名的话,说人生就是一场修行。又说,修行就是走一条路,一条很长的路。
如果套用这个说法,我选择的一条修行之路就是软件调试,进一步说就是穷究软件调试的方方面面,什么是软件调试,怎么用软件调试的方法和理论来调试软件。软件有很多种,针对不同的软件,“软件调试”和“调试软件”又有哪些相同和不同。
时代在变化,软件的形势也在变化,研究了很多年的Windows平台调试之后,这些年也花了大把大把的时间在Linux上。
众所周知,Linux平台上的调试工具积贫积弱,没法与Windows平台相比。以内核调试为例,KDB、KGDB等方式都有很多不足。
对于这样的情况,我们没有权力抱怨,因为在自由软件的世界里,这是很正常的事。
写到这里,不由得想起缔造GNU世界的Richard Stallman先生,也是GDB调试器的作者,在他亲自撰写的GDB教程封面上引用的一句话:“如果它不工作,那么别焦虑。如果所有东西都工作了,那么你就没有工作了。”
是啊,在自由软件的世界里,已经有的都是别人奉献出来的,还缺少的,你如果不满意,那么你可以自己动手做啊。
因为很多原因,我花了很多时间在一个新的调试器上,我精心规划它的每个功能,精心设计它的每段代码,有些代码写了一遍之后,再写一遍。
我把这个新的调试器,取名叫Nano Debugger,简称NDB。
开发一个新的调试器需要大量的时间。自从把《软件调试》第2卷交稿后,我不仅把我的大多数业余时间都用在NDB上,而且还花了不少的正常工作时间,年轻的格蠹科技也投了不小的人力物力在这个工具上。
天道酬勤,每一份付出都有收获,在我和格蠹小伙伴们的努力下,NDB一天天成长。
加入一个个新的功能,去除一个个瑕疵。
如果把NDB当作一个孩子,那么我给它设定的目标有两个:一是有深度,具有最底层的控制能力,能观察到系统的最底层信息;二是有高度,能呈现软件世界的高层语义,让调试者可以快速理解问题现场。
对于这两个目标,说来容易,实现起来,真是困难重重。
要实现底层控制,那么必须要有硬件支撑,为此,格蠹科技专门打造了一个调试套件,取名为GDK7。
GDK是Gedu Debug Kit的缩写,那么为什么叫GDK7呢?因为GDK7的CPU是Intel 的。在Intel的CPU设计团队里,有个以调试技术见长的印度人,它的名字以K开头,字母很多,不好读,因为字母的个数是7个,于是很多人就叫他K7。
再说第二个目标,要想呈现高层语义,就必须依赖调试符号。可是调试符号种类繁多,格式多变,受源代码、编译器等多种因素影响,要想搞定这个拦路虎也非一日之功。
下面举个具体的例子。栈是CPU的贴身行囊,里面记录着CPU的行动轨迹。把这个轨迹呈现出来就可以看到CPU从那里来,即将往那里去。这个功能一般称为栈回溯,是调试器的最重要功能之一。在GDB中,bt(backtrace)命令用于此,在WinDBG中,k命令用于此。
在NDB中,既可以用k命令,也可以用bt命令来激发栈回溯。比如下图便是把Linux内核中断下来后,经常看到的一个栈回溯结果,显示的是著名的idle线程的执行经过。
仔细观察上面的栈回溯,它非常详细的记录了从内核初始化到功成名就,进入idle循环休息的旅程。
lk!intel_idle+0x87
lk!cpuidle_enter_state+0x75
lk!cpuidle_enter+0x2e
lk!call_cpuidle+0x23
lk!do_idle+0x1f6
lk!cpu_startup_entry+0x1d
lk!rest_init+0xae
lk!arch_call_rest_init+0xe
lk!start_kernel+0x56c
lk!x86_64_start_reservations+0x24
lk!x86_64_start_kernel+0x74
lk+0x10d4
虽然上述结果已经比较完整,足以让很多人感觉惊奇。但是对于我来说,它还不够完美,有个不足,那就是最底下的那个栈帧没有显示出函数名称,只能用模块基地址+偏移的方法显示为lk+0x10d4,如果显示出来,它应该是x86_64_start_kernel函数的父函数。
我发现这个不足很久了,知道它是个不足,是个有待完善的地方,但是一直没有时间去深究它。
最近的天气非常好,大多都是晴天,阳光充足,气温舒适。很多树木的叶子都变成美丽的彩色,可谓层林尽染,秋高气爽。这样的好天气,真的想在周末出去走走,爬爬山。但是为了NDB,我还是决定坐下来debug,只能在房间里享受秋日的美好阳光。
周六的早晨,吃过早饭,我坐下来,准备打一场歼灭战,为x86_64_start_kernel寻找父函数,找不到不罢休,“不解决不收兵吃饭”(^-^)。
何处下手呢?
搜源代码。
Linux内核是开源的,有很多便利之处,一定要充分利用。
缺少的函数是x86_64_start_kernel的父函数,因此先搜一下x86_64_start_kernel。
今天的Linux内核源代码总量可谓“浩然”,压缩包110MB,解压后接近1GB大关。
存放Linux源代码的机械硬盘“哗哗的”响了足足几分钟后,搜索结果逐渐展现在我面前。
先说最后两个结果,都是以某种方式产生的栈回溯结果,但都是把x86_64_start_kernel认定为是最后一个栈帧,直接没有显示任何关于父函数的信息,还不如NDB。
第1和第2个结果分别是原型声明和实现。
asmlinkage __visible void __init x86_64_start_kernel(char * real_mode_data)
特别说明一下这个原型中的asmlinkage,它意味着,这个函数是可以被汇编代码所调用的,也就是它是C函数与汇编函数的交界。
第二个结果是在head.S中,发现了x86_64_start_kernel,这显然是最有价值的信息。因为head_64.S中.S代表它里面是汇编代码,head之名是Linux内核惯用的代表“内核之头”的意思。
展开第2个搜索结果,内容如下:
看这部分代码,显然不是汇编指令,而是写给汇编器的指示符(directive)。
线索断了么?
没有,在上图.quad指示符上面为这个变量定义了一个符号initial_code,在这个文件里搜一下initial_code,就柳暗花明又一村了。
这下看到的是汇编指令了,著名的AT&T格式,比较难度,但是习惯了也还好。
因为这一段代码特别重要,所以特别再以文字形式摘录如下:
pushq $.Lafter_lret # put return address on stack for unwinder
xorl %ebp, %ebp # clear frame pointer
movq initial_code(%rip), %rax
pushq $__KERNEL_CS # set correct cs
pushq %rax # target address in negative space
lretq
.Lafter_lret:
END(secondary_startup_64)
上面几句汇编指令的含义大致为:
把标号.Lafter_lret的地址压入栈,后面的注释还特别说,这是给栈展开使用的,栈展开是栈回溯的另一种说法。
把ebp寄存器清零
把initial_code指针的值赋给rax
把__KERNEL_CS的值压栈
把rax寄存器的值压栈
执行函数返回指令
最有趣的是最后一条指令,字面上是从函数返回的ret指令,其实这里是用来调用函数。因为ret指令的操作是从栈上弹出一个值,赋给程序指针,所以它也是可以用来调用函数的,很多黑客喜欢用这样的写法,通常称为“倒车”。
如此看来,上述这个代码片段就是调用x86_64_start_kernel的地方。它叫什么名字呢?根据END语句中的信息,它叫secondary_startup_64。向上翻一下,也可以看到这个汇编函数的入口:
ENTRY(secondary_startup_64)
经过上面一番搜索和分析,算是找到了x86_64_start_kernel的父函数名称,叫secondary_startup_64,接下来的问题是为什么NDB没有找到这个函数呢?
尝试在NDB中使用x命令来显示所有以startup_64结束的符号,结果让人惊喜,明明有啊。
x lk!*startup_64
ffffffff`aa0001f0 lk!__startup_64 (int64, boot_params*)
ffffffff`aa000000 lk!startup_64
ffffffff`aa000030 lk!secondary_startup_64
是位置信息对不上么?
因为Linux内核有地址随机化功能,每次启动时内核函数都可能改变地址。但是以固定的一次启动来计算:
x86_64_start_kernel的返回地址为ffffffff`aa0000d4
lk!secondary_startup_64的起始地址为ffffffff`aa000030
在调试器下找到secondary_startup_64符号,观察它的size信息,是0xa4
把ffffffff`aa000030+0xa4,刚好等于 ffffffff`aa0000d4,这意味着栈回溯中找到的返回地址刚好是secondary_startup_64的末尾,这与汇编代码中标号在汇编函数的最后也是一致的。
那么是什么原因让NDB没能显示出secondary_startup_64呢?
一边跟踪代码,一边思考,想到的另一个原因可能是符号类型的问题,对于这个汇编语言的函数,GCC没有为其产生DWARF格式的符号,只产生了简单的ELF格式符号,ELF符号也有如下几种类型:
//ELF SymType.
#define SYMTYPE_NOTYPE 0
#define SYMTYPE_OBJECT 1
#define SYMTYPE_FUNC 2
#define SYMTYPE_SECTION 3
#define SYMTYPE_FILE 4
#define SYMTYPE_LOPROC 13
#define SYMTYPE_HIPROC 15
上面截图中的m_info字段的低四位用来记录SymType,仔细观察上面截图中的m_info值,它的低四位都是0,代表SYMTYPE_NOTYPE,没有类型。
在NDB的代码中,栈回溯时只寻找函数类型的符号,因此找不到secondary_startup_64这个符号。
问题的根源终于找到了,是GCC的问题么?为什么不给汇编函数一个函数身份?汇编写的函数难道就不是函数么?谁写的代码,我真想找他问个明白。
可是即使GCC的同行承认这是个bug,也不可能立刻修正啊,即使修正了,很多用户也不会立刻更新到没有问题的GCC啊,即使更新了,还有很多老版本GCC编译出来的文件啊。
于是,更切实的方法还是修改自己的代码,在栈回溯时接受没有函数身份的符号。说改就改,编译执行,问题好了,一个完美的栈回溯终于呈现在了面前。
用了几个小时的时间,一路搜索、调试、分析、追踪,终于把悬挂很久的一个老大难问题解决了,站起身,窗外阳光灿烂,天高云淡,秋色盎然。
人生是一场修炼,在这场修炼中,不要觉得目标很遥远,千里之行始于一步,每前进一步,就距离目标更近一些。
***********************************************************
正心诚意,格物致知,以人文情怀审视软件,以软件技术改变人生
欢迎关注格友公众号