应用程序调试总结
总结一下对应用程序出现segment fault时的基础和调试方法,知识来自debug hacks一书
环境,x86 32位linux
一.基础
1.熟悉参数的传递方式。
在进入被调用函数之前,程序会按照参数,返回地址,fp指针(帧指针),被调用函数的局部变量,的次序压栈。
源码:
#include <stdio.h>
int fun(int a,char c)
{
printf("%d\n%c\n",a,c);
return a;
}
int main()
{
fun(1,'a');
return 0;
}
使用gdb调试该程序:
#include <stdio.h>
int fun()
{
int a = 10;
fun();
printf("%d\n",a);
return 1;
}
int main(int argc,char **argv)
{
fun();
return 0;
}
Local core dump file:
`/root/core', file type elf32-i386.
0x0084e000 - 0x0084e000 is load1
0x009a1000 - 0x009a1000 is load2
0x009a2000 - 0x009a4000 is load3
0x009a4000 - 0x009a5000 is load4
0x009a5000 - 0x009a8000 is load5
0x00d68000 - 0x00d69000 is load6
0x00d87000 - 0x00d87000 is load7
0x00da2000 - 0x00da3000 is load8
0x00da3000 - 0x00da4000 is load9
0x08048000 - 0x08048000 is load10
0x08049000 - 0x0804a000 is load11
0x0804a000 - 0x0804b000 is load12
0xb775e000 - 0xb775f000 is load13
0xb776d000 - 0xb776f000 is load14
0xbf45a000 - 0xbfe5a000 is load15
可以看出0xbf45a000 属于段15,明显已经位于了这个段的末尾,因为sp自减时并不检查sp是否超过了范围,当访问时才会知道这个地址是否合法,所以可以确定是栈溢出。
很多大型的程序,当程序抛出段错误的信号时,会有处理程序接收这个信号,但是这个时候栈上已经没有空间了,是不可能让这个处理函数正常结束的,所以需要提前为这个函数申请好栈空间,确保能把当时的情形保留下来,可以使用sigaltstack函数在堆上申请备用栈。具体的用法请man一下
2.返回地址被修改
返回地址被修改的情况很多,根据之前的栈空间压栈顺序,如果被调用函数的局部数组越界就可以将返回地址覆盖,导致段错误的发生,这是一种。重点是我们要怎么知道发生了返回地址被修改,而且此时的局部变量也可能是不正确的,很难调试。一般来讲如果发生返回地址被修改,bt中的信息会是这样的。
我们知道正常情况下,应该是显示函数名称而不是问号,(如果修改之后的地址还是指向某个函数的话,那就只能一步步查看下去,是否存在这么一个调用顺序)。此时是可以确定返回地址被修改了的。
具体将一个如果是数组越界导致的返回地址被修改的情形。
源码:
#include <stdio.h>
#include <string.h>
char names[] = "book cat dog building vagetable curry";
void fun()
{
char buf[5];
strcpy(buf,names);
}
int main(int argc,char **argv)
{
fun();
return 0;
}
调试过程:首先查看当前运行在哪句话上。
可以看出当前运行到了ret这句话,也就是返回,那么看下sp中的值是多少。
这步有些多余,就是堆栈信息中的下一帧地址。
因堆栈信息目前怀疑的是返回地址被修改,所以查看esp中的内容,先用字符串的形式查看里面的内容
比较明显可以看出现在堆栈中的信息就是book cat dog building vagetable curry 显然是一个字符串,搜索这个字符串被引用的地方,可以发现就在源代码的第8行,复制字符串时超出了数组的长度。
3.利用监视点检测非法内存访问
这个我在linux系统中无法复现出,因为越界之后的地址值是非法的,模拟出这个情况比较困难。所以这边就语言描述下。
源程序:
int data[2]= {1,2};
int calc(void)
{
return -7;
}
int main()
{
int index = calc();
data[index] = 0x0a;
data[index+1] = 0x08;
printf("ssssss\n");
return 0;
}
错误发生在printf那句话中。通过查看堆栈找到main函数中的返回地址,而在这个返回地址之前的语句可能就导致了这个段错误,然后查看到之前的语句中有一句call跟踪该语句,最终会跳转到一个指针中的地址,而实际上这个指针中的地址就是0x08,也就是被程序中的语句所修改了,那么重点就在怎么确定是这句话导致的错误。
既然知道了这个指针所指向的地址,那么就可以在这个地址值出设置监视点,当这个地址处的值被修改时gdb就会停住,运行时会发现就是printf的前一句话,也就是找到了原因所在。
4.双重释放指针导致的bug
这种错误我觉得可以设置监视点或者断点的方式,利用gdb的脚本,打印出free时的堆栈信息,然后查看哪个地址有被多重释放。
另一种方法是利用env MALLOC_CHECK_=1 ./a.out 来运行程序,但有的情况下不指定环境变量,在双重释放指针时也会打印出堆栈信息,反而加了环境变量没有打印出堆栈信息。但个人觉得这只是说明原因是双重释放,还是坚持前一种方法,找到释放的两个位置,只保留一个释放点。
5.死锁
当造成死锁时,先使用ps命令查看下线程状态,如果状态是S的话,就有可能说明是死锁了。
这个时候再使用gdb attch上去,查看各个线程的堆栈,看卡在哪一个线程中。
然后再利用gdb设置断点和脚本,打印出同一把锁被操作的过程。下面看个例子
源码:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int cnt = 0;
void cnt_reset(void)
{
pthread_mutex_lock(&mutex);
cnt = 0;
pthread_mutex_unlock(&mutex);
}
void *th(void *p)
{
while(1){
pthread_mutex_lock(&mutex);
if(cnt > 2)
cnt_reset();
else
cnt++;
pthread_mutex_unlock(&mutex);
printf("%d\n",cnt);
sleep(1);
}
}
int main()
{
pthread_t id;
pthread_create(&id,0,th,0);
pthread_join(id,0);
return 0;
}
运行结果:
[root@ubuntu: deadlock]./a.out
1
2
3
发现程序不跑了,根据程序接下来应该打印出0。
[root@ubuntu: deadlock]ps -x | grep a.out
Warning: bad ps syntax, perhaps a bogus '-'? See http://procps.sf.net/faq.html
26418 pts/9 Sl+ 0:00 ./a.out
可以看出程序现在处于睡眠状态,那么使用gdb attch上去,查看是哪一个线程在睡眠或者说导致了死锁。
可以看出主线程是处在睡眠中,在等待子线程的结束,而子线程睡眠在了等待锁的释放上,那么现在问题就在于为什么锁是在哪一步或者哪个线程先拿到了,而导致当前线程拿不到锁。
使用gdb重新调试程序,并且在加锁和释放锁的位置设置断点,打印出堆栈,可以发现前面一直都是加锁解锁对应的,而在最后一对打印中两个操作都是加锁
根据这个堆栈信息可以知道th函数先加了一次锁,然后th函数本身调用了cnt_reset函数,该函数再一次加锁导致了死锁。
所以现在就找到原因了。
这是一个较为简洁的例子,我在工作中遇到过一次较为麻烦的问题,如下:多线程之间对于一个数据结构的访问,需要首先拿到保护该结构的锁,问题出在了当某一个线程拿到锁之后还没有释放锁,该线程就被杀死了,而此时其他线程就再也无法获取到该锁,导致所有线程堵死。同样通过上述方式可以找到原因。
6.死循环
这个情况我自己模仿书上的例子,创建了一个类似的例子
源码:
#include <stdio.h>
int fun(char *p,int len)
{
while(len > 0){
int version = *(int *)p;
int msgtype = *(int *)(p+sizeof(int));
int length = *(int *)(p+sizeof(int)+sizeof(int));
/*do something*/
len = len - length;
p = p + length;
}
}
int main()
{
char p[100];
int len = 0;
int version = 1;
int type = 10;
int length = 0;
memset(p,0,100);
memcpy(p,&version ,4);
memcpy(&p[4],&type,4);
memcpy(&p[8],&length,4);
length = 10;
memcpy(&p[12],&version ,4);
memcpy(&p[16],&type,4);
memcpy(&p[20],&length,4);
fun(p,30);
return 0;
}
fun函数是用来解析消息的一个函数。有些类似于tcp,是基于流的方式来解析数据包。
但是现在在运行时发生了死循环。即执行程序之后就不会退出。
gdb attach上该进程之后,发现是在fun函数里面,那么查看源码知道fun就只有一个循环。那么现在使用debug版本的可执行程序,单步调试该程序。
可以发现,消息体的长度一直为0,这个问题导致了,一直在解析同一个消息。那么问题就确定了,发送的消息长度有问题,所以在函数中解析到长度字段时,应该比较长度字段至少大于多少。
三。总结
首先要熟练运用gdb中的各种工具,包括查看寄存器,堆栈,断点,监视点和脚本等。
一般来讲调试过程是,收集信息,包括现象和dump信息。分析dump信息,复现bug,修复bug。
栈溢出:结合sp和程序map信息。
返回地址被修改:堆栈异常基本属于返回地址被修改,将sp中的内容打印出来,以各种方式打印,字符型或者十六进制等等。可能会发现比较眼熟的结果打印,比如明显是一个字符串,这时候对错误的定位就很容易了。
非法内存访问:某个跳转地址是存放在一个指针中的,这个指针中的值被修改了,也就导致了后续的跳转出现了非法。这个时候可以在这个指针上设置监视点,打印访问该监视点时的堆栈。
双重释放:还是利用监视点或者断点,确定哪两次释放。
死锁:同上,确定哪两步拿锁冲突。
死循环:确定当前死循环位置,最好使用debug版本单步调试。