前言
在嵌入式系统C语言开发调试过程中,常会遇到各类异常情况。一般可按需添加打印信息,以便观察程序执行流或变量值是否异常。然而,打印操作会占用CPU时间,而且代码中添加过多打印信息时会显得很凌乱。此外,即使出错打印已非常详尽,但仍难以完全预防和处理段违例(Segment Violation)等错误。在没有外部调试器(如gdb server)可用或无法现场调试的情况下,若程序能在突发崩溃时自动输出函数的调用堆栈信息(即堆栈回溯),那么对于排错将会非常有用。
本文主要介绍嵌入式系统C语言编程中,发生异常时的堆栈回溯方法。文中涉及的代码运行环境如下:
本文假定读者已具备函数调用栈、信号处理等方面的知识。相关性文章也可参见:
一 原理
通常,在多级函数调用过程中,处理器会将调用函数指令的下一条地址压入堆栈。通过分析当前栈帧,找到上层函数在堆栈中的栈帧地址,再分析上层函数的栈帧,进而找到再上层函数的栈帧地址……如此回溯直至最顶层函数。这就组成一条函数执行的路径轨迹(调用顺序)。
以Intel x86架构为例,由于帧基指针(BP)所指向的内存中存储上一层函数调用时的BP值,而在每层函数调用中都能通过当前BP值向栈底方向偏移得到返回地址。如此递归,可逐层向上找到最顶层函数。
在GDB里,使用bt命令可获取函数调用栈。若要通过代码获取当前函数调用栈,可借助glibc库提供的backtrace系列函数。由于不同处理器堆栈布局不同,堆栈回溯由编译器内建函数__buildin_frame_address和__buildin_return_address实现,涉及工具glibc和gcc。若编译器不支持该功能,也可自行实现,其步骤如下(以Intel x86架构为例):
1) 获得当前函数的BP;
2) 通过BP偏移获得主调函数的IP(返回地址);
3) 通过当前BP指向的内容,获得主调函数BP地址;
4) 循环执行以上步骤直至到达栈底。
glibc2.1及以上版本提供backtrace等GNU扩展函数以获取当前线程的函数调用堆栈,其原型声明在头文件<execinfo.h>内。
int backtrace(void **buffer, int size); |
该函数获取当前线程的调用堆栈,并以指针(实为返回地址)列表形式存入参数buffer缓冲区中。参数size指定buffer中可容纳的void*元素数目。该函数返回是实际获取的元素数,且不超过size大小。若返回值小于size,则buffer中保存完整的堆栈信息;若返回值等于size,则堆栈信息可能已被删减(最早的那些栈帧返回地址被丢弃)。
char ** backtrace_symbols(void *const *buffer, int size); |
该函数将backtrace函数获取的信息转换为一个字符串数组。参数buffer应指向backtrace函数获取的地址数组,参数size为该数组中的元素个数(backtrace函数返回值)。
该函数返回一个指向字符串数组的指针,数组元素个数与buffer数组相同(即为size)。每个字符串包含一个对应buffer数组元素的可打印描述信息,如函数名、偏移地址和实际的返回地址(16进制)。
该函数的返回值指向函数内部通过malloc所申请的动态内存,因此调用者必须使用free函数来释放该内存。若不能为字符串申请足够的内存,则该函数返回NULL。
目前,只有在使用ELF二进制格式的程序和库的系统中才能获取函数名和偏移地址。在其他系统中,仅能获取16进制的返回地址。此外,可能需要向链接器传递额外的标志,以支持函数名功能(如在使用GNU ld的系统中,需要传递-rdynamic选项来通知链接器将所有符号添加到动态符号表中)。
void backtrace_symbols_fd(void *const *buffer, int size, int fd); |
该函数与backtrace_symbols函数功能相同,但不向调用者返回字符串数组,而是将结果写入文件描述符为fd的文件中,每条信息字符串对应一行。该函数不会为字符串存储申请动态内存,因此适用于堆内存可能被破坏的情况(此时buffer也应为静态或自动存储空间)。
举例如下:
1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <unistd.h> 4 #include <execinfo.h> 5 6 static void StackTrace(void){ 7 void *pvTraceBuf[10]; 8 int dwTraceSize = backtrace(pvTraceBuf, 10); 9 backtrace_symbols_fd(pvTraceBuf, dwTraceSize, STDOUT_FILENO); 10 } 11 12 void FuncC(void){ StackTrace(); } 13 static void FuncB(void){ FuncC(); } 14 void FuncA(void){ FuncB(); } 15 int main(void){ 16 FuncA(); 17 return 0; 18 }
编译运行结果如下:
1 [wangxiaoyuan_@localhost test1]$ gcc -Wall -rdynamic -o StackTrace StackTrace.c 2 [wangxiaoyuan_@localhost test1]$ ./StackTrace 3 ./StackTrace[0x80485f9] 4 ./StackTrace(FuncC+0xb)[0x8048623] 5 ./StackTrace[0x8048630] 6 ./StackTrace(FuncA+0xb)[0x804863d] 7 ./StackTrace(main+0x16)[0x8048655] 8 /lib/libc.so.6(__libc_start_main+0xdc)[0x552e9c] 9 ./StackTrace[0x8048521]
当若干主调函数中的某个以错误的参数调用给定函数时,通过在该函数内检查参数并调用StackTrace()函数,即可方便地定位出错的主调函数。
使用backtrace系列函数获取堆栈回溯信息时,需要注意以下几点:
1) 某些编译器优化可能对获取有效的调用堆栈造成干扰。
若忽略帧基指针(-fomit-frame-pointer),回溯时将无法正确解析堆栈内容。优化级别非0时(如-O2)可能改变函数调用关系;尾调用(Tail-call)优化会替换栈帧内容,这些也会影响回溯结果。
2) 内联函数和宏定义没有栈帧结构。
3) 静态函数名无法被内部解析,因其无法被动态链接访问。此时可使用外部工具addr2line解析。
4) 若内存垃圾导致堆栈自身被破坏,则无法进行回溯。
若自行实现堆栈回溯功能,可调用dladdr()函数来解析返回地址所对应的文件名和函数名等信息。
#include <dlfcn.h> int dladdr(void *addr, Dl_info *info); |
该函数出错时(共享库libdl.so目标文件段中不存在该地址)返回0,成功时返回非0值。
Dl_info结构定义如下:
1 typedef struct{ 2 const char *dli_fname; /* Filename of defining object */ 3 void *dli_fbase; /* Load address of that object */ 4 const char *dli_sname; /* Name of nearest lower symbol */ 5 void *dli_saddr; /* Exact value of nearest symbol */ 6 }Dl_info;
使用dladdr()函数时,需加上-rdynamic编译选项和-ldl链接选项。
更进一步,可将堆栈回溯置于信号处理程序中。这样,当程序突然崩溃时,当前进程接收到内核发送的信号后,在信号处理程序中自动输出进程的执行信息、当前寄存器内容及函数调用关系等。
通常使用sigaction()函数检查或修改与指定信号相关联的处理动作(或同时执行这两种操作):
#include <signal.h> int sigaction( int signo, const struct sigaction *restrict act, struct sigaction *restrict oact); |
该函数成功时返回0,否则返回-1并设置errno值。参数signo为待检测或修改其具体动作的信号编号。若act指针非空,则修改其动作;若oact指针非空,则系统经由oact指针返回该信号的上个动作。sigaction结构的sa_flags字段指定对信号进行处理的各个选项。当设置为SA_SIGINFO标志时,表示信号附带的信息可传递到信号处理函数中。此时,应按下列方式调用信号处理程序:
void handler(int signo, siginfo_t *info, void *context); |
siginfo_t结构包含信号产生原因的有关信息,需针对不同信号选取有意义的属性。其中,si_signo(信号编号)、si_errno(errno值)和si_code(信号产生原因)定义针对所有信号。其余属性只有部分信息对特定信号有用。例如,si_addr指示触发故障的内存地址(尽管该地址可能并不准确),仅对SIGILL、SIGFPE、SIGSEGV和SIGBUS 信号有意义。si_errno字段包含错误编号,对应于引发信号产生的条件,并由实现定义(Linux中通常不使用该属性)。
信号处理程序的context参数是无类型指针,可被强制转换为ucontext_t结构,用于标识信号产生时的进程上下文(如CPU寄存器)。该结构定义在头文件<ucontext.h>内,且包含mcontext_t类型的uc_mcontext字段(该字段保存特定于机器的寄存器上下文)。
注意,即使指定信号处理函数,若不设置SA_SIGINFO标志,信号处理函数同样不能得到信号传递过来的附加信息(info和context),在信号处理函数中访问这些信息都将导致段错误。
二 实现
本节将实现基于信号处理的用户态进程堆栈回溯功能。该实现假定未忽略帧基指针。
注意,若只需向上回溯一层函数,如查看某函数被哪些函数直接调用,则可对其进行简单封装。假定被调函数名为FuncTraced,可将其声明和定义中的名称改为FuncTraced1,然后封装名为FuncTraced的宏。该宏内部输出定位信息并调用FuncTraced1()函数,如:
1 extern void FuncTraced1(void); 2 #define FuncTraced() do{ 3 printf("[%s<%d>]Call FuncTraced! ", __FILE__, __LINE__); 4 FuncTraced1(); 5 }while(0)
示例中原FuncTraced()函数无返回值,若有则封装方式略有不同。
2.1 数据定义
定义如下宏:
1 #ifndef __i386 2 #warning "Possibly Non-x86 Platform!" 3 #endif 4 5 #if defined(REG_RIP) 6 #define REG_IP REG_RIP //指令指针(保存返回地址) 7 #define REG_BP REG_RBP //帧基指针 8 #define REG_FMT "%016lx" 9 #elif defined(REG_EIP) 10 #define REG_IP REG_EIP 11 #define REG_BP REG_EBP 12 #define REG_FMT "%08x" 13 #else 14 #warning "Neither REG_RIP nor REG_EIP is defined!" 15 #define REG_FMT "%08x" 16 #endif 17 18 #define BTR_FILE_LEN 512 //保存堆栈回溯结果的文件路径最大长度 19 #ifndef BTR_FILE //保存堆栈回溯结果的基本文件名 20 #define BTR_FILE "btr" 21 #endif 22 #ifndef BTR_FILE_PATH //保存堆栈回溯结果的文件路径(默认为当前路径) 23 #define BTR_FILE_PATH "." //"..//var//tmp" 24 #endif 25 26 #ifndef MAX_BTR_LEVEL //函数回溯的最大层数 27 #define MAX_BTR_LEVEL 20 28 #endif 29 30 //用户调用SHOW_STACK宏可触发堆栈回溯 31 #ifndef BTR_SIG //触发堆栈回溯的用户信号 32 #define BTR_SIG SIGUSR1 33 #endif 34 #define SHOW_STACK() do{raise(BTR_SIG);}while(0)
其中,REG_IP、REG_BP分别为x86处理器的指令指针和帧基指针寄存器编号,REG_FMT宏指定寄存器内容的输出格式。BTR_FILE等文件相关的宏指定保存堆栈回溯结果时文件路径和名称。当程序运行于嵌入式单板时,当前路径可能没有写入权限,此时用户可自定义BTR_FILE_PATH宏。
定义如下全局变量:
1 static FILE *gpStraceFd = NULL; //输出文件描述符(置为stderr时输出到终端,否则将输出存入文件) 2 typedef VOID (*SignalHandleFunc)(INT32S dwSignal); 3 static SignalHandleFunc gfpCustSigHandler = NULL; //用户自定义的信号处理函数指针
2.2 函数接口
首先定义一组私有函数。这些内部使用的函数已尽可能保证参数安全性,故省去参数校验处理。
SpecifyStraceOutput()函数指定堆栈回溯结果的输出方式:
1 /****************************************************************************** 2 * 函数名称: SpecifyStraceOutput 3 * 功能说明: 指定回溯结果输出方式 4 ******************************************************************************/ 5 static FILE *SpecifyStraceOutput(VOID) 6 { 7 #ifdef __BTR_TO_FILE 8 time_t tTime; 9 CHAR szFileName[BTR_FILE_LEN]; 10 szFileName[0] = '