为求一层栈,追踪八万里

关于人生,有一句著名的话,说人生就是一场修行。又说,修行就是走一条路,一条很长的路。

如果套用这个说法,我选择的一条修行之路就是软件调试,进一步说就是穷究软件调试的方方面面,什么是软件调试,怎么用软件调试的方法和理论来调试软件。软件有很多种,针对不同的软件,“软件调试”和“调试软件”又有哪些相同和不同。

时代在变化,软件的形势也在变化,研究了很多年的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编译出来的文件啊。

于是,更切实的方法还是修改自己的代码,在栈回溯时接受没有函数身份的符号。说改就改,编译执行,问题好了,一个完美的栈回溯终于呈现在了面前。

用了几个小时的时间,一路搜索、调试、分析、追踪,终于把悬挂很久的一个老大难问题解决了,站起身,窗外阳光灿烂,天高云淡,秋色盎然。

人生是一场修炼,在这场修炼中,不要觉得目标很遥远,千里之行始于一步,每前进一步,就距离目标更近一些。

***********************************************************

正心诚意,格物致知,以人文情怀审视软件,以软件技术改变人生

欢迎关注格友公众号

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值