Linux应用程序调试技术
嵌入式Linux系统应用开发之Linux应用程序调试技术
《嵌入式Linux应用完全开发手册》第4篇第27章总结归纳
本章目标
- 掌握使用strace工具跟踪系统调用和信号的方法
- 掌握各类内存测试工具,比如memwatch
- 掌握使用库函数backtrace和backtrace_symbols来定位段错误
使用strace工具跟踪系统调用和信号
strace介绍及移植
strace介绍
strace是一个很有用的诊断、学习、调试工具。使用时无需重新编译程序,这也使得可以用来跟踪没有源代码的程序。系统调用和信号是发生在用户空间和内核空间边界处的事件,检查这些边界事件有助于隔离错误、检查完整性、跟踪程序。
使用strace工具来执行程序时,它会记录程序执行过程中调用的系统调用、接收到的信号。通过查看记录结果,可以知道程序打开了哪些文件、是否打开成功、对文件进行了哪些读写操作、映射了哪些内存、向系统申请了多少内存等。
strace移植
略
使用strace来调试程序
strace的用法
直接运行strace可以看到它的用法,如下所示:
1 | strace |
上面的“[command [arg …]]”表示要执行的程序及其参数;前面是各种选项。下面是几个常用的选项。
-f:除了跟踪当前进程外,还跟踪其子进程。
-o file:将输出信息写到文件file中,而不是显示到标准错误输出(stderr)。
-p pid:绑定到一个由pid对应的正在运行的进程。此参数常用来调试后台进程。
-t:打印各个系统调用被调用时的绝对时间,想观察程序的各部分的执行时间可以使用这个选项。
-tt:与选项t相似,打印时间精度为μs。
-r:与选项t相似,打印时间为相对时间
strace输出结果分析
使用strace的最简单的例子为:
1 | strace cat /dev/null |
它的输出结果如下,其中的省略号表示还有其他系统调用
1 | execve("/bin/cat",["cat","/dev/null"],[/*6 vars */]) = 0 |
第1行表示通过系统调用execve来建立一个进程,他就是“cat /dev/null”对应的进程。在控制台中执行各种命令,比如“ls”、“cd”时,都是通过系统调用execv来建立它们进程的。通过strace,可以看到程序运行的细节。
第2-7行打开动态链接库,如果cat程序是静态链接的,这几个步骤将不需要。
第9-10行才是“cat /dev/null”命令的真正处理过程,首先打开“/dev/null”文件,然后读取它的内容。
在strace的输出过程中,每一行对应一个系统调用:左起是系统调用的名字,紧接着是被包含在括号中的参数,最后是它的返回值,比如上面输出结果的第7行。
系统调用出错时(返回值通常是-1),在返回值的后面会打印错误记号及其注释,比如:
1 | open("/foo/bar",O_RDONLY) = -1 ENOENT (No such file or directory) |
接收信号时会将信号记号及其注释打印出来,比如执行以下命令使用strace在后台开启一个sleep进程,然后向这个进程发送SIGINT信号。
1 | strace -o sleep.log sleep 100 & |
在sleep.log文件中可以发现如下字样,表示接收到SIGINT信号,它是“Interrupt”信号。
1 | --- SIGINT (Interrupt) @ 0 (0) --- |
对系统调用的参数,有多种打印格式,往往让人一目了然。下面是一些常见的格式:
1 | open("xyzzy",O_WRONLY | O_APPEND | O_CREATE, 0666) = 3 |
第1行的系统调用open有3个参数,第一个为文件名,使用字符串格式表示;第二个为flag标志,它由3个按位相或的宏组成;第三个为mode参数,它使用八进制表示。
第2行系统调用lstat的第二个参数的数据类型为“struct stat”,它被展开了。lstat的第一个参数是输入参数,第二个参数是输出参数。如果系统调用失败,相应的输出参数不会被展开,比如第3行的第二个参数。
当参数是字符串指针时,这些字符串将被打印出来。默认只打印字符串的前32字节,其余字节使用省略号表示,这个省略号紧跟在双引号包含起来的被打印字符之后,比如第4行的第二个参数。
对于比较简单的指针或者数组,它们的内容被中括号包含起来,其中的各个元素使用逗号来分离,比如:
1 | getgroups(32,[100,0]) = 2 |
最后,位的集合(bit-sets)也是使用中括号包含起来的,其中的元素以空格分离,比如:
1 | sigprocmask(SIG_BLOCK,[CHLD TTOU],[ ]) = 0 |
上面代码的第二个参数是两个信号SIGCHLD和SIGTTOU的集合。有时候集合的元素很多,只打印出不使用的元素比较直观,这时可以加上前缀“`”,比如下面语句中第二个参数表示信号的全集:
1 | sigprocmask(SIG_UNBLOCK,`[],NULL) = 0 |
调试程序
在前面移植基于X的GUI程序时,就多次使用strace工具来跟踪程序,根据其中的出错信息建立一些必需的目录,复制字库到特定的目录等。当不了解一个程序依赖于哪些目录和文件时,可以使用strace工具来跟踪它。
下面举几个例子来说明如何使用strace来调试程序。
- 使用strace来定位gtkboard的警告信息。
通过以下命令启动gtkboard。然后查看gtkboard.log文件,发现如下字样:1
strace -o gtkboard.log gtkboard &
而交叉编译工具链中lib目录下刚好有gconv目录,把它复制到开发板根文件系统的/usr/lib目录后重新启动gtkboard,这些警告信息消失。1
2
3open("/usr/lib/gconv/gconv-modules.cache",O_RDONLY) = -1 ENOENT (No such file or directory)
open("/usr/lib/gconv/gconv-modules",O_RDONLY) = -1 ENOENT (No such file or directory)
write(2,"\n(gtkboard:855):GLib-WARNING **"...,82) = 82 - 使用strace来测量程序的执行时间
如果发现某个程序突然执行得很慢,通常需要找出其中哪部分代码执行得时间过长。使用strace工具可以轻松达到这个目的。
执行以下命令,可以发现第2行和第3行的时间相差2s左右,符合“sleep2”意图。1
2
3
4
5strace -r sleep 2
... ...
01 0.002608 rt_sigprocmask(SIG_SETMASK, [], NULL, 8) = 0
02 0.002392 nanosleep({2,0}, {2,0}) = 0
03 2.005517 exit_group(0) = ?
内存调试工具
使用memwatch进行内存调试
memwatch介绍
段错误与内存错误是C语言编程中经常碰到的问题,段错误的调试与解决在下节描述。所以内存错误是指使用动态分配的内存时出现的各种错误,比如内存泄漏(malloc分配的内存没有使用free释放掉)和缓冲区溢出(例如对越界存储动态分配的内存)是一些常见的问题。
memwatch是一个开放源代码的C语言内存错误检测工具。它可以跟踪程序中的内存泄露和错误,支持ANSI C,提供结果日志记录,能检测双重释放(double free)、错误释放(erroneous free)、没有释放的内存(unfreed memory)、溢出和下溢等。
memwatch并不是一个可以单独运行的程序,它提供一套实现动态内存管理、检测的代码,用它们来代替C库中的相关函数。它有两个文件:memwatch.h和memwatch.c。前者将malloc、free等库函数重新定义为在memwatch.c中实现的相应库函数,部分代码如下:
1 |
memwatch调试实例
要使用memwatch,需要完成以下3点:
- 在代码中加入头文件memwatch.h
- 程序的代码与memwatch.c一起编译、链接
- 使用gcc编译器进行编译时要定义宏MEMWATCH、MEMWATCH_STDIO,即在编译程序时增加“-DMEMWATCH -DMEMWATCH_STDIO”标志
测试代码memtest.c代码如下:
1 |
|
其中第13行的错误为缓冲区溢出;第15行修改ptr2变量后的值,将导致第17、18行释放ptr1对应的内存两次(double-free),而原来的ptr2对应的内存没有被释放。
下面使用memwatch查看是否能检查出这些错误。
- 源文件memtest.c在第3行中包含了头文件memwatch.h
- Makefile内容如下:基于该Makefile生成的可执行文件memtest,将它复制到根文件系统下运行,会生成一个记录文件memwatch.log,其内容如下图:
1
2
3
4
5
6
7
8
9
10CC=arm-linux-gcc
CFLAGS=-DMEMWATCH -DMEMWATCH_STDIO
memtest: memtest.o memwatch.o
$(CC) -o $@ $^
%.o:%.c
$(CC) $(CFLAGS) -c -o $@ $^
clean:
rm -f memtest *.o
第9行表示发生了缓冲区上溢,“memtest.c(17)”并不是表示在memtest.c是第17行发生了上溢,他表示这个错误是当程序执行到memtest.c的第17行“free(ptr2)”时才检测到的;“512 bytes alloc’d at <1> memtest.c(10)”表示这个出错的缓冲区大小为512字节,是在memtest.c的第10行分配的。根据这些信息查看代码,可以容易的发现是memtest.c的第13行代码导致这个错误。
第10行表示发生了双重释放(double-free)的错误,其中的“memtest.c(18)”表示这个错误是当程序执行到memtest.c的第18行“free(ptr1)”时才检测到的;“0x1a1fc was freed from memtest.c(17)”表示首地址为0x1a1fc的内存在memtest.c的第17行已经被释放过了。
第14行表示有一块内存没被释放(unfreed memory),“memtest.c(11),512 bytes at 0x1a42c”表示这块内存是在memtest.c的第11行分配,大小为512字节,首地址为0x1a42c。
第16-20行是一些统计信息:第17行表示了分配了两次内存,第18行表示程序结束时能够使用的最大动态内存,第19行表示总共分配的动态内存,第20行表示未释放的内存。
其他内存工具介绍:mtrace、dmalloc、yamd
略
段错误的调试方法
使用库函数backtrace和backtrace_symbols定位段错误
访问没有权限或是不存在的内存时,会产生段错误(Segmentation fault),它很常见,比如访问空字符串等都会引起这类错误。
使用库函数backtrace和backtrace_symbols来进行栈回溯,可以知道发生错误时函数的调用关系,这不依赖于其他工具,这两个函数的原型如下:
1 | int backtrace(void **buffer,int size); |
C语言中,A函数调用B函数时,会在栈中保存一个地址,当B函数执行完毕后,程序返回这个地址继续执行;而这个地址处于A函数中,可以根据B函数的返回地址反向找到它的调用者A。根据栈中的返回地址向上回溯栈,一级一级的找到各个调用函数,就可以得到完整的调用关系。
backtrace函数就是利用这个原理得到这些调用关系的,它分析栈的内容,找到各级调用的返回地址,将它们保存在字符串数组buffer中,最大数目由参数size决定,它的返回值表示所确定的返回地址的数目。如果返回值小于或等于参数size,表示栈中所有的内容都被分析了;如果栈很大。要想回溯所有内容,就需要增大字符串数组buffer、增大参数size。
backtrace_symbols函数将这些返回地址转换为描述性的字符串,返回地址保存在字符串数组buffer中,参数size表示它们的数目。这些描述性的字符串的格式为:
1 | 程序名(函数名+偏移)[返回地址] |
其中的“函数名”是根据返回地址找找到的函数名称,偏移是这个返回地址与这个函数的首地址之间的偏移。如果找不到函数名称,则小括号中的“(函数名+偏移)”不打印。
backtrace_symbols函数将这些描述性字符保存在一个字符串数组中,作为它的返回值。需要注意,使用完毕后这个字符串数组需要释放掉,但是它的元素(即各个字符串)不需要也不能释放。
综上所述,库函数backtrace和backtrace_symbols通常一起使用,实例代码如下:
1 | void DebugBacktrace(void) |
这两个函数能显示正确的结果是基于一下假设的。
- 编译程序时,gcc的优化选项是0。
- 内联(inline)函数没有栈。
- 尾调用的优化使得一个“stack frame”替换另一个“stack frame”
连接程序时,使用“-rdynamic”选项,这使得程序包含更多的符号(symbol)。静态(static)函数的符号没有导出来,所以使用这些函数进行回溯时无法找到静态函数的符号
段错误调试实例
当程序发生段错误时,内核会向程序发送SIGSEGV信号,这个信号的默认处理行为是使程序退出。可以修改信号处理函数,在程序退出之前就使用库函数backtrace和backtrace_symbols打印出函数的调用关系,这有助于找到出错的代码及出错原因。
这需要修改代码,只要将SIGSEGV信号的处理函数设为DebugBacktrace函数即可。代码如下:
1 |
|
第1-27行的代码定义了A、B、C共3个函数,A调用B,B调用C。在函数C中,第24、25行的代码有漏洞,如果参数c不是一个可用的地址值,则第25行赋值语句导致段错误。在函数B中,故意使第19行调用函数C时传入一个非法地址。
第30行是信号处理函数,它通过库函数backtrace和backtrace_symbols获得并打印函数的调用关系后,退出程序(第44行)。这个函数是本实例的重点,读者可以在自己的应用程序代码中加入这个函数,然后使用第52行的函数将它设为SIGSEGV信号的处理函数。
segfault.c的Makefile如下,可以看到,编译时选项为“-g -O0”,连接时的选项为“-rdynamic”。
1 | CC=arm-linux-gcc |
执行make命令生成可执行程序segfault,运行后可以看到如下信息:
1 | segfault |
标号0-5行表示从下到上的函数调用关系:标号5的__libc_start_main函数调用第4行的main函数,main函数调用标号3的A函数,A函数调用标号2的B函数。B调用C时出错,这导致内核发出SIGSEGV信号,这时正常的流程被打断,信号处理函数DebugBacktrace被强行调用,标号1、0的行表示处理信号时的函数调用关系。
从这几个标号可以看出,当main函数调用A、A调用B时,出现段错误。这些调用关系可以帮助开发人员缩小定位错误的范围,在很复杂的程序中尤其如此。
从上面的输出信息“2: segfault (B + 0x28)[0x893c]”中可以知道,B函数调用的某个函数的返回地址为0x893c,这个地址前面的语句就是调用下一级函数。
单从这些调用关系还是不能直接看出是在函数C中出错,这时要用到segfault程序的反汇编代码。使用下面的指令进行反汇编:
1 | arm-linux-objdump -D segfault > segfault.dis |
反汇编文件segfault.dis的部分内容如下:
1 | 00008914<B>: |
可以看到,返回地址0x893c前面的代码调用函数C,所以可以确定是在函数C出错。