gdb动态库延迟断点及线程/进程创建相关事件处理(上)

gdb动态库延迟断点及线程/进程创建相关事件处理(上)  

2012-04-25 22:57:29|  分类: gdb源代码分析 |字号 订阅

一、gdb对共享库符号的支持
当使用gdb调试一些动态链接生成的可执行文件时,我们可能会有意或者无意的在一些不存在的函数上打断点,此时gdb并不是提示错误,而是提示是否在之后加载的动态库中添加该断点,也就是pending断点,下面是一个典型的提示:
(gdb) b noexisting
Function "noexisting" not defined.
Make breakpoint pending on future shared library load? (y or [n])
此时我们可以想一下,对于动态加载的共享库文件,它可能是在可执行文件中已经确定好的,例如大家通过ldd可以看到一个可执行文件静态依赖的共享库文件;还有一种是通过dlopen在代码中主动随机打开的一个so文件。
无论是哪种情况,有一点是相同而确定的,那就是在设置断点的时候,gdb是不知道这个符号的位置。这一点其实也不难,难点是gdb怎么知道一个so文件被加载(包括代码中通过dlopen打开的),并且这个被加载的so文件中包含了pending的断点符号?这里有一个实实在在的限制: gdb必须第一时间知道所有so文件的加载,在该so文件中任何一个函数都没有开始执行的时候就提前打上断点,否则就可能错误唯一的一次执行机会,例如一些so文件中init节的函数。

大家可以先思考一下gdb将如何实现这个功能,此时建议先理一下思路,不要还没有理解问题本身就开始继续看下面内容。

二、gdb第一时间感知动态库加载方法
1、什么样的模式和思路
这个问题我其实是想了一下,觉得应该有比较巧妙的方法,只是我不知道。但是看了一下gdb的实现,发现此处的方法并没有巧妙之处,但是可以实实在在解决问题。这可能就是做工程和做科学的区别,很多时候,我们要让一个工程联动流畅的运行,中间可以使用协议、妥协、适配、模式等各种方法,最终把一个产品实现,这就是我们的目的。当一个产品实现之后,大家就可以在这个基础上进行优化,扩展,兼容等各种操作,这就是工程。也就是说,实现的方法可能很朴素,但是只要能很好的解决问题,它就是一个好的工程。例如,java,它效率可能没有C++高,但是它便于跨平台、C++虽然没有C那么底层、但是它可以更好的支持大规模项目协作开发,这些都是一些应用和场景决定的一些实现。
这里说gdb对SO文件加载的第一时间感知并不是自己独立完成的,而是需要动态链接器的支持,甚至是dl库本身的支持,gdb本身可能的确没有自己完成这个功能的能力(不太确定,但是当前的Linux实现是依赖了动态链接库本身),它需要动态库操作本身的支持。这一点对于WIndows系统同样适用,windows系统下对于调试器来说,它可以通过WaitForDebugEvent来获得被调试任务的一些事件,而动态链接库的加载就在这个通知范围内(通知类型为LOAD_DLL_DEBUG_EVENT,可参考gdb-6.0\gdb\win32-nat.c get_child_debug_event)。
2、linux下实现
①、动态链接库本身支持
_dl_debug_state动态库和调试器约定好的一个接口,这个接口事实上是一个空函数,定义于glibc-2.7\elf\dl-debug.c:
/* This function exists solely to have a breakpoint set on it by the
   debugger.  The debugger is supposed to find this function's address by
   examining the r_brk member of struct r_debug, but GDB 4.15 in fact looks
   for this particular symbol name in the PT_INTERP file
.  */
void
_dl_debug_state (void)
{
}
上面的注释已经说明了这个函数的用处,可能有些同学看这个代码的时候没有在意这个空函数,更不要说注释了。它的意思就是说,这个函数单独放在这里就是为了给调试器一个下断点的机会,调试器可以在这个约定好的地方设置断点,在该函数断点命中之后,调试器可以通过搜索_r_debug符号来找到被调试任务主动反映的一些状态。大家可以在glibc中搜索一下对这个_dl_debug_state函数的调用。在调用这个函数之前,C库都会重新的给_r_debug结构赋值。例如glibc-2.7\elf\dl-load.c  _dl_map_object_from_fd
  struct r_debug *r = _dl_debug_initialize (0, nsid);
……
      /* Notify the debugger we have added some objects.  We need to
     call _dl_debug_initialize in a static program in case dynamic
     linking has not been used before.  */
      r->r_state = RT_ADD;
      _dl_debug_state ();
而函数就是通过
struct r_debug *
internal_function
_dl_debug_initialize (ElfW(Addr) ldbase, Lmid_t ns)
{
  struct r_debug *r;

  if (ns == LM_ID_BASE)
    r = & _r_debug; 也就是这个函数返回的就是这个全局的_r_debug变量
  else
    r = &GL(dl_ns)[ns]._ns_debug;
……
  return r;
}
这种模式在NPTL库中也存在,该库中定义的__nptl_create_event和__nptl_death_event就是为了让调试器方便的打断点,但是当前的gdb并没有使用这个接口功能,这是后话,具体怎么实现本文最后再描述一下。
②、gdb对该接口的使用
gdb-6.0\gdb\solib-svr4.c
该文件中包含了一些符号信息,其中包含了和外部符号联动的协约式接口
static char *solib_break_names[] =
{
  "r_debug_state",
  "_r_debug_state",
  " _dl_debug_state", 这个就是之前和动态库约定好的_dl_debug_state 接口,在该文件初始化的开始就会给该函数打断点
  "rtld_db_dlactivity",
  "_rtld_debug_state",
……
  NULL
};
在gdb-6.0\gdb\solib-legacy.c  legacy_svr4_fetch_link_map_offsets函数中,其中设置了r_debug、link_map结构之间的一些相对关系及结构信息(实不相瞒,这些结构具体细节有待详细分析,我也没有完全分析完整,只是看个大概)。
然后在文件初始化函数中会调用gdb-6.0\gdb\solib-svr4.c:enable_break (void)
      /* Now try to set a breakpoint in the dynamic linker.  */
      for (bkpt_namep = solib_break_names; *bkpt_namep != NULL; bkpt_namep++) 这个数组中就包含了我们之前说的那个_r_debug_state函数
    {
      sym_addr = bfd_lookup_symbol (tmp_bfd, *bkpt_namep);
      if (sym_addr != 0)
        break;
    }
      /* We're done with the temporary bfd.  */
      bfd_close (tmp_bfd);

      if (sym_addr != 0)
    {
      create_solib_event_breakpoint (load_addr + sym_addr); 这个函数实现非常简单,只是简单转发给create_internal_breakpoint (address, bp_shlib_event)函数,注意其中的类型bp_shlib_event,后面将会用到
      return 1;
    }
③、当_r_debug_state命中时
明显地,当使能动态so断点之后,系统并不会在加载一个文件之后就让程序停下来,虽然gdb在其中设置了断点。所以gdb要能够识别这个断点类型并自己默默的消化掉这个断点,然后读取新加载(卸载时删除)文件中的符号表,并判断pending断点是否存在其中,如果存在则使能断点。
gdb-6.0\gdb\breakpoint.c:bpstat_what (bpstat bs)

#define shl BPSTAT_WHAT_CHECK_SHLIBS 这个类型将会决定调试器对新接收事件的处理方式,这里就是BPSTAT_WHAT_CHECK_SHLIBS

 static const enum bpstat_what_main_action
    table[(int) class_last][(int) BPSTAT_WHAT_LAST] =
  {
/*shlib */
    {shl, shl, shl, shl, shl, shl, shl, shl, ts, shl, shlr},

    case bp_shlib_event:
      bs_class = shlib_event;
……
      current_action = table[(int) bs_class][(int) current_action];

调试器对之上类型判断的调用位置
gdb-6.0\gdb\infrun.c:handle_inferior_event (struct execution_control_state *ecs)
    what = bpstat_what (stop_bpstat);
    switch (what.main_action)
      {
      case BPSTAT_WHAT_CHECK_SHLIBS:
      case BPSTAT_WHAT_CHECK_SHLIBS_RESUME_FROM_HOOK:
#ifdef SOLIB_ADD
    {
……
      SOLIB_ADD (NULL, 0, NULL, auto_solib_add);
……
}  }
其中的SOLIB_ADD--->>>solib_add--->>>update_solib_list--->>>TARGET_SO_CURRENT_SOS--->>>svr4_current_sos
其中的svr4_current_sos函数将会遍历被调试任务中所有的so文件链表,对于被调试任务来说,它的所有so文件通过link_map的指针域连接在一起,下面是glibc中结构glibc-2.7\include\link.h
struct link_map
  {
    /* These first few members are part of the protocol with the debugger.
       This is the same format used in SVR4.  */

    ElfW(Addr) l_addr;        /* Base address shared object is loaded at.  */
    char *l_name;        /* Absolute file name object was found in.  */
    ElfW(Dyn) *l_ld;        /* Dynamic section of the shared object.  */
    struct link_map *l_next, *l_prev; /* Chain of loaded objects.  */
……
}
所以当gdb知道了动态链接库对应的link_map实例,它就可以通过该链表遍历被调试任务的所有link_map,由于每个link_map都和一个加载的so文件对应,所以可以知道被调试任务所有已经加载的动态库。
④、读取符号后使能断点
前面的步骤之后,gdb就可以得到了so文件加载的事件消息,然后读入被调试任务中所有的so文件的符号信息。前面的行为也说明了要忽略此次断点,继续运行。
在handle_inferior_event--->>>keep_going--->>insert_breakpoints函数中完成对所有断点的使能,如果新加载的so文件中包含了之前的一个pending断点,对于insert_breakpoints函数的调用将会使这个断点生效。
3、说明
这里只是描述了一个大致的思路,里面的有些细节可能比较模糊,而且不一定完全准确,但是大致的流程和思路是没有问题的,这一点我还是能够保证的。
三、进程/线程/系统调用相关事件处理
新的内核中添加了一些自动调试子进程、枚举系统调用之类的功能,这些功能对动态链接库要求不多,转而依赖内核实现。可以通过
set follow-fork-mode parent/child 来设置调试跟踪模式,这样对子进程的调试比较方便,因为Unix中进程创建时 fork+exec模式,所以fork之后的代码如果出问题,当前的调试是不好使的。
还有就是一些catch命令来跟踪系统调用等
(gdb) show version
GNU gdb (GDB) Fedora (7.0-3.fc12)
Copyright (C) 2009 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "i686-redhat-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
(gdb) help catch
Set catchpoints to catch events.

List of catch subcommands:

catch assert -- Catch failed Ada assertions
catch catch -- Catch an exception
catch exception -- Catch Ada exceptions
catch exec -- Catch calls to exec
catch fork -- Catch calls to fork
catch syscall -- Catch system calls by their names and/or numbers 这些功能不清楚是什么版本开始支持的,上面显示我用的是gdb7.0
catch throw -- Catch an exception
catch vfork -- Catch calls to vfork

这些很多都需要内核支持,所以看一下内核实现。
1、进程、线程创建及删除
这些主要是通过 ptrace的PTRACE_SETOPTIONS选项来实现的(该文件中还有ptrace_getsiginfo借口,说明子进程信号信息也是容易被父进程获得和修改的)
linux-2.6.21\kernel\ptrace.c
static int ptrace_setoptions(struct task_struct *child, long data)
{
    child->ptrace &= ~PT_TRACE_MASK;

    if (data & PTRACE_O_TRACESYSGOOD)
        child->ptrace |= PT_TRACESYSGOOD;

    if (data & PTRACE_O_TRACEFORK)
        child->ptrace |= PT_TRACE_FORK;

    if (data & PTRACE_O_TRACEVFORK)
        child->ptrace |= PT_TRACE_VFORK;

    if (data & PTRACE_O_TRACECLONE)
        child->ptrace |= PT_TRACE_CLONE;

    if (data & PTRACE_O_TRACEEXEC)
        child->ptrace |= PT_TRACE_EXEC;

    if (data & PTRACE_O_TRACEVFORKDONE)
        child->ptrace |= PT_TRACE_VFORK_DONE;

    if (data & PTRACE_O_TRACEEXIT)
        child->ptrace |= PT_TRACE_EXIT;

    return (data & ~PTRACE_O_MASK) ? -EINVAL : 0;
}
在进程fork时:long do_fork(unsigned long clone_flags,……)
    if (unlikely(current->ptrace)) {
        trace = fork_traceflag (clone_flags);
        if (trace)
            clone_flags |= CLONE_PTRACE;
    }
……
        if (unlikely (trace)) {
            current->ptrace_message = nr;
            ptrace_notify ( (trace << 8) | SIGTRAP);
        }
由于这篇文章粘贴的代码已经很多了,所以就不再粘贴fork_traceflag和ptrace_notify的实现了,但是大家通过这个名字应该就可以知道这些信息是发送给了父进程。大家注意一下ptrace_notify中返回值,低8bits为SIGTRAP信号,而高8bits为trace类型,这些类型可以为PT_TRACE_VFORK、PT_TRACE_CLONE、PTRACE_EVENT_FORK类型,大家可以在gdb中搜索一下对这些事件的处理位置。其它处理,例如PT_TRACE_EXEC、PT_TRACE_EXIT实现和该实现类似,这里省略,大家搜索一下内核这些关键字即可。
2、系统调用枚举
这个主要是在汇编代码中设置跟踪点:
linux-2.6.21\arch\i386\kernel\entry.S
syscall_trace_entry:
    movl $-ENOSYS,PT_EAX(%esp)
    movl %esp, %eax
    xorl %edx,%edx
    call do_syscall_trace
  系统调用前调用do_syscall_trace,其中edx参数清零,表示是进入系统调用
    cmpl $0, %eax
    jne resume_userspace        # ret != 0 -> running under PTRACE_SYSEMU,
                    # so must skip actual syscall
    movl PT_ORIG_EAX(%esp), %eax
    cmpl $(nr_syscalls), %eax
    jnae syscall_call
    jmp syscall_exit
END(syscall_trace_entry)

    # perform syscall exit tracing
    ALIGN
syscall_exit_work:
    testb $(_TIF_SYSCALL_TRACE|_TIF_SYSCALL_AUDIT|_TIF_SINGLESTEP), %cl
    jz work_pending
    TRACE_IRQS_ON
    ENABLE_INTERRUPTS(CLBR_ANY)    # could let do_syscall_trace() call
                    # schedule() instead
    movl %esp, %eax
    movl $1, %edx
    call do_syscall_trace
系统调用退出执行do_syscall_trace,参数为1,表示是推出系统调用
    jmp resume_userspace
END(syscall_exit_work)

通知调试器代码
linux-2.6.21\arch\i386\kernel\ptrace.c
__attribute__((regparm(3)))
int do_syscall_trace(struct pt_regs *regs, int entryexit)
{
    int is_sysemu = test_thread_flag(TIF_SYSCALL_EMU);
    /*
     * With TIF_SYSCALL_EMU set we want to ignore TIF_SINGLESTEP for syscall
     * interception
     */
    int is_singlestep = !is_sysemu && test_thread_flag(TIF_SINGLESTEP);
    int ret = 0;
……
    /* the 0x80 provides a way for the tracing parent to distinguish
       between a syscall stop and SIGTRAP delivery */
    /* Note that the debugger could change the result of test_thread_flag!*/
    ptrace_notify(SIGTRAP | ((current->ptrace & PT_TRACESYSGOOD) ? 0x80:0));
}
可以看到, 这里并没有区分是系统调用进入还是退出,我想可能是需要调试器自己记录是什么,并且进入和退出不能同时跟踪,PTRACE_CONT之后两者都失效
linux-2.6.21\arch\i386\kernel\ptrace.c
long arch_ptrace(struct task_struct *child, long request, long addr, long data)
    case PTRACE_SYSEMU: /* continue and stop at next syscall, which will not be executed */
    case PTRACE_SYSCALL:    /* continue and stop at next (return from) syscall */
    case PTRACE_CONT:    /* restart after signal. */
        ret = -EIO;
        if (!valid_signal(data))
            break;
        if (request == PTRACE_SYSEMU) {
            set_tsk_thread_flag(child, TIF_SYSCALL_EMU);
            clear_tsk_thread_flag(child, TIF_SYSCALL_TRACE);
        } else if (request == PTRACE_SYSCALL) {
            set_tsk_thread_flag(child, TIF_SYSCALL_TRACE);
            clear_tsk_thread_flag(child, TIF_SYSCALL_EMU);
        } else {
            clear_tsk_thread_flag(child, TIF_SYSCALL_EMU);
            clear_tsk_thread_flag(child, TIF_SYSCALL_TRACE
);
        }
这么看来,watch还是比较耗费CPU的,如果系统调用比较多的话。
四、和NPTL库比较
1、线程创建、删除
glibc-2.7\nptl\events.c
void
__nptl_create_event (void)
{
}
hidden_def (__nptl_create_event)

void
__nptl_death_event (void)
{
}
hidden_def (__nptl_death_event)
它们被C库创建和删除线程时调用,调试器同样可以设置此处为断点。
2、glibc-2.7\nptl\allocatestack.c

/* List of queued stack frames.  */
static LIST_HEAD (stack_cache);

/* List of the stacks in use.  */
static LIST_HEAD (stack_used);
struct pthread
{
……
  /* This descriptor's link on the `stack_used' or `__stack_user' list.  */
  list_t list;
……
}

所有的线程通过list连接在一起,所以调试器可以动态获得被调试任务的所有线程列表

http://tsecer.blog.163.com/blog/static/1501817201232592041576/


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值