• 应用程序调试总结


    总结一下对应用程序出现segment fault时的基础和调试方法,知识来自debug hacks一书

    环境,x86 32位linux

    一.基础

    1.熟悉参数的传递方式。

      在进入被调用函数之前,程序会按照参数,返回地址,fp指针(帧指针),被调用函数的局部变量,的次序压栈。

      源码:

      #include <stdio.h>

      int fun(int a,char c)

      {

        printf("%d %c ",a,c);

        return a;

      }

      int main()

      {

        fun(1,'a');

        return 0;

      }

      使用gdb调试该程序:

      在函数名前加上*号,程序遇到断点时,会卡在函数汇编语言层次的开头。如果不加*,会停在函数的第一句话。
      函数在跳转之前会把需要传递的变量和返回地址压入栈,而剩余的变量由被调用函数压栈。所以此时,sp指针指向的是返回地址,另一个我们知道栈是向下增长的,所以sp+4就是压入的第2个参数(a),sp+8是压入的第1个参数(c),如下图。
      
    2.core文件的生成
      一般linux系统默认是不生成core文件的,可以通过ulimit -c查看。如果显示0,则调用ulimit -c unlimited 设置为没有上限,当然也可以设置一个具体的值,单位为blocks。
      注意:必须确保有权限在该目录下生成core文件,因为我们很多工作的时候是将本地文件挂载到linux服务器上或者虚拟机上,如果不是有权限的用户登录的话,是不会在该目录下生成core文件的,或者生成的core文件大小为0。
    3.gdb的常用命令
      可以查看我的上一篇总结。
    二.调试实践
    1.栈溢出
      源码:

    #include <stdio.h>
    int fun()
    {
    int a = 10;
    fun();
    printf("%d ",a);
    return 1;
    }

    int main(int argc,char **argv)
    {
    fun();
    return 0;
    }

      发生段错误,利用生成的core文件,查看sp的指针大小
         可以看到sp=0xbf45a000;
         再查看各个段的大小,使用i files命令,虽然看不出哪个段是stack,ps:不知道为何无法上传图片。那我就打字了。
      如下:

    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 ");
    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 ",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版本单步调试。

      

  • 相关阅读:
    175. 组合两个表
    101. 对称二叉树
    292. Nim游戏
    319. 灯泡开关
    155. 最小栈
    232. 用栈实现队列
    225. 用队列实现栈
    145. 二叉树的后序遍历
    144. 二叉树的前序遍历
    【leetcode】977. Squares of a Sorted Array
  • 原文地址:https://www.cnblogs.com/leo0000/p/5454908.html
Copyright © 2020-2023  润新知