1. 程式人生 > >gdb動態庫延遲斷點及線程/進程創建相關事件處理(上)

gdb動態庫延遲斷點及線程/進程創建相關事件處理(上)

tac nbsp desc end 問題 不難 共享 del 同學

一、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連接在一起,所以調試器可以動態獲得被調試任務的所有線程列表。

gdb動態庫延遲斷點及線程/進程創建相關事件處理(上)