Linux应用程序调试技术

嵌入式Linux系统应用开发之Linux应用程序调试技术

《嵌入式Linux应用完全开发手册》第4篇第27章总结归纳

本章目标

  1. 掌握使用strace工具跟踪系统调用和信号的方法
  2. 掌握各类内存测试工具,比如memwatch
  3. 掌握使用库函数backtrace和backtrace_symbols来定位段错误

使用strace工具跟踪系统调用和信号

strace介绍及移植

strace介绍

strace是一个很有用的诊断、学习、调试工具。使用时无需重新编译程序,这也使得可以用来跟踪没有源代码的程序。系统调用和信号是发生在用户空间和内核空间边界处的事件,检查这些边界事件有助于隔离错误、检查完整性、跟踪程序。
使用strace工具来执行程序时,它会记录程序执行过程中调用的系统调用、接收到的信号。通过查看记录结果,可以知道程序打开了哪些文件、是否打开成功、对文件进行了哪些读写操作、映射了哪些内存、向系统申请了多少内存等。

strace移植

使用strace来调试程序

strace的用法

直接运行strace可以看到它的用法,如下所示:

1
2
3
4
5
6
strace
usage:strace [-dffhiqrttTvVxx] [-a column] [-e expr] ... [-o file]
[-p pid] ... [-s strsize] [-u username] [-E val=val] ...
[command [arg ...]]
or: strace -c [-e expr] ... [-O overhead] [-S sortby] [-E var=val] ...
[command [arg ...]]

上面的“[command [arg …]]”表示要执行的程序及其参数;前面是各种选项。下面是几个常用的选项。
-f:除了跟踪当前进程外,还跟踪其子进程。
-o file:将输出信息写到文件file中,而不是显示到标准错误输出(stderr)。
-p pid:绑定到一个由pid对应的正在运行的进程。此参数常用来调试后台进程。
-t:打印各个系统调用被调用时的绝对时间,想观察程序的各部分的执行时间可以使用这个选项。
-tt:与选项t相似,打印时间精度为μs。
-r:与选项t相似,打印时间为相对时间

strace输出结果分析

使用strace的最简单的例子为:

1
strace cat /dev/null

它的输出结果如下,其中的省略号表示还有其他系统调用

1
2
3
4
5
6
7
8
9
10
11
12
execve("/bin/cat",["cat","/dev/null"],[/*6 vars */]) = 0
... ...
open("/lib/librcypto.so.1",O_RDONLY) = 3
... ...
open("/lib/libm.so.6",O_RDONLY) = 3
... ...
open("/lib/libc.so.6",O_RDONLY) = 3
... ...
open("/dev/null",O_RDONLY | O_LARGEFILE) = 3
read(3,"",8192) = 0
close(3) = 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
2
strace -o sleep.log sleep 100 &
kill -INT 868 //假设sleep的进程号为868,可以使用ps命令查看

在sleep.log文件中可以发现如下字样,表示接收到SIGINT信号,它是“Interrupt”信号。

1
2
--- SIGINT (Interrupt) @ 0 (0) ---
+++ killed by SIGINT +++

对系统调用的参数,有多种打印格式,往往让人一目了然。下面是一些常见的格式:

1
2
3
4
open("xyzzy",O_WRONLY | O_APPEND | O_CREATE, 0666) = 3
lstat("/dev/null",{st_mode = S_IFCHR | 0666,st_rdev = makede(1,3),...}) = 0
lstat("/foo/bar",0xb004) = -1 ENOENT (No such file or directory)
read(3,"root::0:0:System Administrator:/"...,1024) = 422

第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来调试程序。

  1. 使用strace来定位gtkboard的警告信息。
    通过以下命令启动gtkboard。
    1
    strace -o gtkboard.log gtkboard &   
    然后查看gtkboard.log文件,发现如下字样:
    1
    2
    3
    open("/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
    而交叉编译工具链中lib目录下刚好有gconv目录,把它复制到开发板根文件系统的/usr/lib目录后重新启动gtkboard,这些警告信息消失。
  2. 使用strace来测量程序的执行时间
    如果发现某个程序突然执行得很慢,通常需要找出其中哪部分代码执行得时间过长。使用strace工具可以轻松达到这个目的。
    执行以下命令,可以发现第2行和第3行的时间相差2s左右,符合“sleep2”意图。
    1
    2
    3
    4
    5
    strace -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
2
3
4
5
#define malloc(n)       mwMalloc(n,__FILE__,__LINE__)
#define strdup(n) mwStrdup(p,__FILE__,__LINE__)
#define realloc(p,n) mwRealloc(p,n,__FILE__,__LINE__)
#define calloc(n,m) mwCalloc(n,m,__FILE__,__LINE__)
#define free(n) mwFree(p,__FILE__,__LINE__)

memwatch调试实例

要使用memwatch,需要完成以下3点:

  1. 在代码中加入头文件memwatch.h
  2. 程序的代码与memwatch.c一起编译、链接
  3. 使用gcc编译器进行编译时要定义宏MEMWATCH、MEMWATCH_STDIO,即在编译程序时增加“-DMEMWATCH -DMEMWATCH_STDIO”标志

测试代码memtest.c代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdlib.h>
#include <stdio.h>
#include "memwatch.h"

int main(void)
{
char *ptr1;
char *ptr2;

ptr1 = malloc(512);
ptr2 = malloc(512);

ptr1[512] = 'A';
ptr2 = ptr1;

free(ptr2);
free(ptr1);

return 0;
}

其中第13行的错误为缓冲区溢出;第15行修改ptr2变量后的值,将导致第17、18行释放ptr1对应的内存两次(double-free),而原来的ptr2对应的内存没有被释放。
下面使用memwatch查看是否能检查出这些错误。

  1. 源文件memtest.c在第3行中包含了头文件memwatch.h
  2. Makefile内容如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    CC=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
    基于该Makefile生成的可执行文件memtest,将它复制到根文件系统下运行,会生成一个记录文件memwatch.log,其内容如下图:
    img not found
    第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
2
int backtrace(void **buffer,int size);
char **backtrace_symbols(void *const 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
2
3
4
5
6
7
8
9
10
11
12
13
14
void DebugBacktrace(void)
{
#define SIZE 100
void *array[SIZE];
int size,i;
char **strings;

size = backtrace(array,SIZE);
fprintf(stderr,"\nBacktrace (%d deep):\n",size);
strings = backtrace_symbols(array,size);
for(i = 0;i < size; i++)
fprintf(stderr,"%d: %s\n",i,strings[i]);
free(strings);
}

这两个函数能显示正确的结果是基于一下假设的。

  1. 编译程序时,gcc的优化选项是0。
  2. 内联(inline)函数没有栈。
  3. 尾调用的优化使得一个“stack frame”替换另一个“stack frame”

连接程序时,使用“-rdynamic”选项,这使得程序包含更多的符号(symbol)。静态(static)函数的符号没有导出来,所以使用这些函数进行回溯时无法找到静态函数的符号

段错误调试实例

当程序发生段错误时,内核会向程序发送SIGSEGV信号,这个信号的默认处理行为是使程序退出。可以修改信号处理函数,在程序退出之前就使用库函数backtrace和backtrace_symbols打印出函数的调用关系,这有助于找到出错的代码及出错原因。
这需要修改代码,只要将SIGSEGV信号的处理函数设为DebugBacktrace函数即可。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <stdio.h>
#include <signal.h>
#include <execinfo.h>

void A(int a);
void B(int b);
void C(int c);
void DebugBacktrace(void);

void A(int a)
{
printf("%d: A call B\n",a);
B(2);
}

void B(int b)
{
printf("%d: B call C\n",b);
C(3);//这个函数调用将导致段错误
}

void C(int c)
{
char *p = (char *)c;
*p = 'A';//如果参数c不是一个可用的地址值,则这条语句导致段错误
printf("%d: function C\n",c);
}

//SIGSEGV信号的处理函数,回溯栈,打印函数的调用关系
void DebugBacktrace(void)
{
#define SIZE 100
void *array[SIZE];
int size,i;
char **strings;

fprintf(stderr,"\nSegmentation fault\n");
size = backtrace(array,SIZE);
fprintf(stderr,"Backtrace (%d deep):\n",size);
strings = backtrace_symbols(array,size);
for(i = 0; i < size; i++)
fprintf(stderr, "%d: %s\n",i,strings[i]);
free(strings);
exit(-1);
}

int main(int argc,char **argv)
{
char a;

//设置SIGSEGV信号的处理函数
signal(SIGSEGV,DebugBacktrace);

A(1);
C(&a);

return 0;
}

第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
2
3
4
5
6
7
8
9
10
11
12
CC=arm-linux-gcc
CFLAGS=-g -O0
LDFLAGS=-rdynamic

segfault: segfault.o
$(CC) $(LDFLAGS) -o $@ $^

%.o: %.c
$(CC) $(CFLAGS) -c -o $@ $^

clean:
rm -rf segfault *.o

执行make命令生成可执行程序segfault,运行后可以看到如下信息:

1
2
3
4
5
6
7
8
9
10
11
12
segfault
1: A call B
2: B call C

Segmentation fault
Backtrace (6 deep):
0: segfault(DebugBacktrace+0x30) [0x89b4]
1: /lib/libc.so.6 [0x4004bd10]
2: segfault(B+0x28) [0x893c]
3: segfault(A+0x28) [0x890c]
4: segfault(main+0x2c) [0x8a84]
5: /lib/libc.so.6(__libc_start_main+0xe4) [0x40034f14]

标号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
2
3
4
00008914<B>:
... ...
8938: eb000001 b1 8944<C>
893c: e89da808 ldmia sp, {r3,fp,sp,pc}

可以看到,返回地址0x893c前面的代码调用函数C,所以可以确定是在函数C出错。