Linux 应用程序调试技术的研究
彭闻宇,陈朔鹰
(北京理工大学计算机学院,北京市 海淀区 100081)
[摘要] 本文介绍了C/C++函数调用机制,由此引入堆栈回朔(Stack Backtraces)方法,
通过从用户堆栈中提取出执行程序的函数调用序列,迅速定位程序异常发生的位置。同时,
论文结合Linux 信号机制,剖析了Linux 内核的核心转储(Core Dump)机制,当程序异常
终止时,核心转储机制会自动将程序运行的上下文和现场信息转储到文件中,然后交由GDB
进行分析。最后,本文通过比较上述两种方法的优劣,提出并实现了一种轻量级的Linux
应用程序调试模型——Crash Trace。该模型借鉴了堆栈回朔的基本思想,并采纳了核心转
储的基本原理,为桌面Linux 和嵌入式Linux 系统的应用程序调试提供了新的解决方案。
[关键词] Linux 调试技术,堆栈回朔, 核心转储, Crash Trace
中图分类号:TP319 文献标识码: A
Linux Application Debugging Techniques Investigation
Peng Wenyu, Chen Shuoying
(Dept. of Computer Science and Engineering, Beijing Institute of Technology, Beijing Haidian 100081)
[Abstract] After a program has crashed, it is vital to reconstruct why the failure occurred, or what
actions led to the error. In this paper, it firstly introduces two different debugging techniques for
Linux application: Stack Backtraces and Core Dump. Based on C/C++ function call mechanism,
Stack Backtraces construct the application's call trace from stack to reproduce its execution path
and find out what went wrong and where. Compared to Stack Backtraces, Core Dump acts as an
inline approach in Linux kernel and provides a powerful ability to collect information that
represents the final snapshot of execution state when the program crashed. Subsequently the thesis
proposes a lightweight debugging module--Crash Trace, which mainly considers the shortcoming
of the above solutions and intergrates their strongpoint. In additon, it explores the implementation
of Crash Trace.
[Key word] Linux Debug Techniques, Stack Backtraces, Core Dump, Crash Trace
一、 引言
本文立足于Linux 应用程序调试技术的研究,首先介绍了C/C++函数调用机制,并由此
引入基于glibc 库的堆栈回朔(Stack Backtraces)调试方法。然后,结合Linux 的信号机
制,分析了Linux 内置的核心转储(Core Dump)调试技术。最后,通过比较前两种调试方
法的优劣,取长补短,提出并实现了一种轻量级的Linux 应用程序的调试模型——Crash
Trace。
二、 堆栈回朔调试技术
2.1 函数调用(Function Calls)
在应用程序执行过程中,如果发生函数调用,系统首先会将函数调用的参数压入用户堆
栈,然后压入函数调用的返回地址,最后执行被调用的函数,并在用户堆栈上保存被调用函
数的局部变量。其中,一次函数调用对应的堆栈空间被称作是一个堆栈帧。当程序中嵌套多
层函数调用时,系统将重复上述压栈过程,从而形成一个堆栈帧序列。不难想象,通过遍历
上述堆栈帧序列,可以得到一条完整的函数调用链(函数调用序列),该方法被称之为堆栈回
朔。
- 2 -
2.2 堆栈回朔方法的基本原理
众所周知,当Linux 应用程序在执行过程中导致硬件或操作系统产生严重错误时,程序
将被异常终止,即crash。此时,要查明程序crash 的原因,最直接的方法就是打印出程序
的函数调用链,通过复现程序的执行序列,定位出异常发生的具体位置,从而找出程序中隐
藏的bugs。
那么,如何利用堆栈构造应用程序的函数调用链呢?由于堆栈的操作对硬件平台有很强
的依赖性,所以C 语言程序是不能直接访问堆栈的,需要借助于汇编代码来实现。在i386
平台下,可以通过下面的汇编指令访问ebp 和esp 寄存器。
register void *ebp __asm__ ("ebp");
register void *esp __asm__ ("esp");
其中,ebp 存放上一个堆栈帧的地址,esp 存放当前堆栈的位置。由图1 可知,通过(*ebp+4)
可以取得当前函数调用的返回地址。因此,根据ebp 的值进行回朔,可以得到程序在整个执
行过程中的函数调用地址序列,从而构建函数调用链。需要注意的是,由ebp 回朔得到的函
数调用链只是一个地址序列,为了方便调试,需要进一步把函数入口地址转换成对应的函数
名称。对于不同格式的二进制文件,glibc 库提供了相应的库函数来完成从函数地址到函数
名称的转换。
对Linux 应用程序而言,因为有glibc 库的支持,所以构造程序的函数调用链相对容易。
在glibc 库提供的关于堆栈回朔的一系列库函数中,其核心函数是 backtrace()。它负责遍
历从程序入口点到当前调用点的所有堆栈帧,然后生成函数调用的地址序列。为了完成函数
地址和函数名称的转换,函数backtrace_symbols()负责将backtrace()生成的地址序列转
换成一系列字符串列表,在每个字符串列表中包括了函数名称,当前指令在函数中的偏移量
和函数的返回地址。由于backtrace_symbols()需要动态申请空间以保存字符串列表,如果
应用程序crash 时破坏了系统内存,可能导致backtrace_symbols()结果错误。为此,glibc
库还提供了一个更安全的地址转换函数:backtrace_symbols_fd()。该函数将生成的字符串
直接输出到外部文件,而不再需要申请新的内存空间。
三、 核心转储调试技术
3.1 Linux 信号机制
信号机制是Linux 进程间通信的一种重要方式,Linux 信号一方面用于正常的进程间通
信和同步,如任务控制(SIGINT, SIGTSTP,SIGKILL, SIGCONT,……);另一方面,它还负责
监控系统异常及中断。当应用程序运行异常时,Linux 内核将产生错误信号并通知当前进程。
当前进程在接收到该错误信号后,可以有三种不同的处理方式。
1) 忽略该信号。
2) 捕捉该信号并执行对应的信号处理函数(signal handler)。
3) 执行该信号的缺省操作(如 SIGTERM, 其缺省操作是终止进程)。
其中,信号SIGKILL 和SIGSTOP 是不能被忽略或捕捉的,而无缺省操作的信号在默认
情况下会被忽略。如果应用程序主动去捕捉错误信号,一般意味着应用程序将执行自定义的
信号处理函数以取代缺省操作。
当Linux 应用程序在执行时发生严重错误,一般会导致程序crash。其中,Linux 专门
提供了一类crash 信号(见表1),在程序接收到此类信号时,缺省操作是将crash 的现场
信息记录到core 文件,然后终止进程。
Signal Description
SIGSEGV Invalid memory reference.
- 3 -
SIGBUS Access to an undefined portion of a memory object.
SIGFPE Arithmetic operation error, like divide by zero.
SIGILL Illegal instruction, like execute garbage or a privileged
instruction
SIGSYS Bad system call.
SIGXCPU CPU time limit exceeded.
SIGXFSZ File size limit exceeded.
3.2 核心转储方法的基本原理
如3.1 所述,Linux 提供了信号机制监控应用程序运行异常,其缺省操作是将程序执行
现场保存到core 文件中并终止程序。所谓核心转储,即Linux 从监测crash 信号到创建core
文件,最后终止程序的全过程。其中,core 文件保存了当前进程发生crash 时CPU 寄存器
及状态信息,并转储了进程堆栈和内存数据。
Linux 产生core 文件的具体过程如下:
首先,应用程序在执行过程中导致内核或硬件发生严重错误,例如:访问非法内存地址,
执行非法指令,总线错误等,内核即产生相应的crash 信号。这时,Linux 将执行缺省的信
号处理函数do_coredump()。
然后,do_coredump()函数会读取当前进程的程序加载器,判断进程是否满足coredump
条件(如:当前进程的dumpable 属性是否打开,进程允许的core 文件的大小是否大于程序
加载器中限定的最小值,……),如果条件满足,则在当前目录创建core 文件(文件名为
core.
)。在检查core 文件状态之后,do_coredump()调用进程程序加载器的core_dump()
函数。以ELF 格式的应用程序为例,core_dump()函数负责记录当前进程的现场信息并按照
ELF 文件格式保存在core 文件中。
最后,生成的core 文件包括ELF 文件头, 程序段描述符表和其它数据段。coredump 操
作依次转储了处理器状态, 进程状态, 任务结构映象和寄存器映象,最后转储进程在存储空
间中各个存储页的映象。生成的core 文件可以通过程序调试器(如:gdb)进行解析。通过
gdb 解析,不仅可以确定程序终止时的状态,还可以配合程序源文件对程序进行源程序级的
调试。
四、 Crash Trace 调试技术
4.1 Crash Trace 的介绍
综上所述,第一种方法的优点在于不需要依赖于调试器,直接打印出程序的Stack
backtraces。第二种方法的优点则是现场信息完整,同时可以结合调试器对程序进行源程序
级的调试。其缺点是:第一种方法需要在程序中添加额外的代码来处理程序的crash 信号,
同时依赖libc 库的支持。第二种方法虽然通过Linux 内核处理程序的crash 信号,但生成
的core 文件太大,而且必须在调试器的帮助下才能过对其进行分析。同时,上述两种方法
都需要特殊的gcc 编译选项的支持。
通过比较前面两种调试方法,本文提出了一种介于它们之间的轻量级的调试方法——
Crash Trace。Crash Trace 的基本原则是:第一,应用程序代码改动尽量小(甚至是零改动),
其中应用程序代码包括源程序和Makefile 文件。第二,记录的现场信息必须能够指出:发
生crash 的进程名称,crash 发生的时间,crash 信号和程序crash 时正在执行的函数名称。
第三,降低记录文件与系统其它模块或调试工具的耦合度。
4.2 Crash Trace 的设计与实现
表1: crash 信号
- 4 -
Crash Trace 沿用了核心转储的基本原理,即在内核中完成信号的捕获和处理,从
而实现了应用程序源代码的零改动。所谓Makefile 的零改动,即不需要特殊的gcc 编
译选项的支持。Crash Trace 通过使用独立的符号表文件,实现了应用程序的二进制代
码与源文件符号信息的分离。同时,在获取程序的Stack backtraces 时,不再使用相
关的libc 库,而是直接使用堆栈内容与相关应用程序的符号表进行模糊匹配来实现。
尽管Crash Trace 与核心转储在基本原理上保持一致,但它的输出文件与core 文
件却不尽相同。首先,它需要控制core 文件的大小,所以不会转储进程的存储页。其
次,因为不依赖于调试器,它选择以文本格式输出,而没有遵循ELF 格式。最后,需要
对现场信息进行简单的分析,其主要工作是把函数地址转化为对应的函数名称,同时通
过用户堆栈和相关符号表文件生成程序的函数调用链。
总的来说,与前面的方法相比,Crash Trace 具有以下特点:
①. 不依赖调试器,直接以文本格式输出程序现场信息到记录文件。
②. 不依赖gcc 编译选项,同时将程序的符号表信息从程序中分离。
③. 只记录必要的现场信息,控制输出文件的大小。
④. 可以对现场信息做简单的分析,并输出分析结果到记录文件。
具体的说,Crash Trace 的实现分为两个部分: crash 信号的捕捉和crash 现场的
处理。为了在内核中完成信号的捕获和处理,一方面需要将Crash Trace 实现为可加载
的内核模块,另一方面在Linux 内核中加入crash trace 的stub。这样,当内核在捕捉
到crash 信号时,如果Crash Trace 模块已经被加载,那么当产生crash 信号时,内核
会执行Crash Trace 模块的处理函数。否则,内核将执行缺省的核心转储操作。在crash
现场处理的过程中,因为Crash Trace 模块工作在内核空间,所以可以很容易的获得现
场寄存器信息和处理器状态,同时对进程任务结构中的信息进行筛选,记录其中的关键
信息。不仅如此,为了能够获得程序的Stack backtraces,还需要转储进程的内存映射
表和用户堆栈。在现场处理的最后阶段,需要结合相关符号表文件以及之前转储的内存
映射表和用户堆栈信息完成函数地址与函数名称的转换并生成程序的函数调用链。
五、 总结
本文分析了当前常用的两种Linux 应用程序调试方法:堆栈回朔(Stack Backtraces)
和核心转储(Core Dump),详细阐明了它们实现机制,并比较了各自的优劣。
本文作者的创新点是:借鉴上述两种方法的原理和优点,提出并实现了一种轻量级的调
试方法:Crash Trace。该方法在设计上遵循与应用程序、编译器和调试器松散耦合的原则,
实现上遵循了Linux 内核的信号机制和函数调用机制,在实际应用取得了较为理想的效果。
因为该方法在获取函数调用序列时使用模糊匹配而没有结合系统的硬件特性,所以在分析结
果中存在不必要的冗余信息,需要在今后的工作中进一步改进。
参考文献:
[1] Maurice Bach, The Design of the UNIX Operating System, Prentice Hall, Upper Saddle
River, NJ, 1986, ISBN 0-13-201757-1.
[2] Daniel Bovet & Marco Cesati, Understanding the Linux Kernel, O'Reilly & Associates,
Inc., Sebastopol, CA, January 2001, ISBN 0-596-00002-2.
[3] B. Thangaraju, Linux Signals for the Application Programmer, Linux Journal, March
2003.
[4] 李俊平.基于liunx 实时平台的研究[J].微计算机信息,2005,6-2:21-22