歡迎您光臨本站 註冊首頁

為 gdb 增加書籤功能

←手機掃碼閱讀     火星人 @ 2014-03-12 , reply:0
  

GDB 是 Linux 中常用的調試工具。在日常工作中,調試程序主要靠設置斷點和單步執行。斷點位置的選擇往往帶有猜測的性質。有時斷點設置過於靠後,錯誤代碼已經跳過;有時單步操作過快以至跳過了錯誤發生點。發生了這些情況之後程序員只能重新運行被調試程序,從頭開始。很多時間都在這個過程中浪費了。為此我們非常希望 gdb 能有某種反向調試功能,讓調試程序向前回退。書籤功能提供了回退調試功能。用戶在程序的某處設置書籤,在後續的調試過程中就能夠隨時回到書籤點繼續調試。通過本文,希望讀者能夠對 gdb 內部有一定的了解,從而開發出更多有趣的功能。
GDB 及書籤功能簡介

GDB 是 GNU 開源組織發布的一個強大的 UNIX 下的程序調試工具。gdb 可以讓您查看程序的內部結構、列印變數值、設置斷點,以及單步調試源代碼。

使用 GDB 調試的基本方法就是設置斷點,然後使用單步的命令來跟蹤程序的執行。下面是一個假想的例子,我們可以看到一種令人沮喪的情況和書籤能帶來的方便。

假設我們有一個操作資料庫的程序,該程序要求用戶輸入用戶名,密碼和一些其它的必要信息。然後連接資料庫,查詢一個表之後,將結果返回並列印。

當該程序運行結束時,程序員發現查詢結果完全沒有顯示。此時他使用 gdb 調試程序希望能找到問題所在。

下面的代碼片斷大致展示了這個資料庫查詢程序。


程序 1. 讀取資料庫的例子程序
               
Line 1:  main()
Line 2: {
Line 3:   s = getConnectionInfo();       //獲得用戶輸入的信息,包括user,password等
Line 4:   cid = connect(s);
Line 5:   sql = buildSql(s);             //利用s構建SQL語句,並釋放s的空間
Line 6:   result = exec(cid, sql);
Line 7:   free(sql);  cid = NULL;
Line 8:   printResult(result);
Line 9: }                          
      


假設這個程序有錯誤,運行后沒有任何結果列印出來。此時程序員迷惑不解,第一反應就是想看看運行到Line8時變數result是否為空。因此他將斷點設置在Line8。當程序在Line8停下時,他查看了result的內容,很不幸,該變數值為空。此時,他又很想查看 變數sql的內容。但是sql已經被釋放了。他別無選擇,只能退出gdb,重頭再來。令人沮喪的是getConnectionInfo()這個函數每次都需要大量的人機交互,每次重新調試都要重複這些比較費時的工作。在這種情況下如果有書籤功能,他的工作就會愉快多了。

書籤允許用戶在程序的某個地方設置一個返回標記,在後續的調試過程中可以隨時跳回書籤點重新調試。如果有了書籤,上面那個程序員就可以在 Line4 處設置書籤,當斷點設置不當時,他有機會跳過 getConnectionInfo 那些複雜的交互過程,從而提高工作的效率.

GDB從6.5開始提供了一個新功能:checkpoint。允許用戶設置檢查點,並在後續的調試過程中回到檢查點。但是我們發現 checkpoint 無法在同一點連續設置。結果就是用戶只能返回一次,而無法隨時任意多次的返回檢查點。

我們在 GDB5.3 的基礎上嘗試著實現了書籤功能,在 RedHat9.0 系統中測驗成功,下面的章節將詳細介紹具體的實現細節和原理

書籤的基本實現原理

書籤的基本想法就是當用戶需要設置書籤時,保存目標進程當時的運行現場,在需要返回書籤時再恢復現場。

有兩類現場信息需要保存。第一類是進程自身運行的硬體和軟體上下文,包括寄存器,堆棧,內存以及一些內核數據。凡是在後續調試過程中可能改變的東西,都應該保存下來;另一類是GDB內部的一些數據結構,這些數據結構用來描述被調試進程的一些狀態,比如stop_pc,表示當前正在執行的指令的地址。

為了保存第一類信息,有兩種方法:一種是遍歷內存,將所有需要保存的數據都保存到磁碟文件中;另一種是使用 fork 系統調用,生成一個和被調試進程完全相同的新進程,fork() 將父進程的一切都完全拷貝一份;我們選擇了第二種方式,因為其可移植性更強。每一個不同的硬體平台和操作系統中,進程需要保存的內容都不相同,而 fork 則是標準的系統調用。

第二類信息是 GDB 內部需要維護的數據結構,這類信息的保存比較直觀,直接用相應的全局變數來保存就可以了。

下圖更直觀地描述了書籤的實現原理。


圖 1. 書籤的原理圖
 

在 LineN 設置書籤的過程如下:讓被調試進程調用 fork() 創建一個新進程。並讓該進程立即進入一個無限循環。調試繼續進行。當到達 line M 時,用戶想回到 Line N。返回的過程如下:調用 GDB 的 attach 命令綁定到新進程,此時新進程的上下文還保存著 Line N 處的狀態,從這裡向下執行就相當於返回到了 Line N。最後將最初的被調試進程銷毀。

我們在 GDB5.3 版本上增加了兩個新的命令:setmark 和 gotomark。”setmark”用來設置書籤,”gotomark”則將被調試進程恢復到書籤點,從那裡開始繼續調試。

如何為GDB添加新的命令

GDB 是一個命令行工具,它基本的工作模式類似 Shell。接收用戶輸入的命令然後執行相應的處理函數。GDB 中 CLI(command line interface) 子系統負責用戶界面的工作,它顯示提示符,接收用戶輸入,分析用戶輸入並調用相應的處理函數。

CLI 子系統的設計非常完善,它為用戶添加新命令提供了幾個專門函數。add_com() 就是最基本的一個。它有四個入口參數,第一個參數是命令的名字,類型為字元串;第二個參數表明該命令的類型:第三個參數是該命令的處理函數,第四個參數是關於該命令的幫助說明。


add_com 函數原型
               
struct cmd_list_element *add_com (char *name,
                       enum command_class class,
                       void (*fun) (char *, int),
                       char *doc)                  
      


下面的代碼顯示了如何添加新的 gdb 命令。
               
_initialize_mark (void)
{
  struct cmd_list_element *c;
  c = add_com("markset",class_breakpoint,set_mark,"create a mark");
  c = add_com(“gotomark",class_breakpoint,goto_mark,"go back to mark");
}
 


在GDB中,所有以_initailize_開頭的函數都會被initialize_all_file()調用。在GDB的Makefile中有一個規則查找所有的源文件中以_initialize開頭的函數,並加入到initialize_all_file()中。(對應的Makefile規則為init.c,如果感興趣,可以直接查看Makefile)

_initialize_mark函數調用add_com()為gdb添加新的命令.setmark,對應的處理函數為set_mark().其中class_breakpoint是一個枚舉變數,表示命令setmark屬於斷點類的命令.當鍵入help breakpoint后,就能看到命令setmark,以及對它的說明,即add_com()的第四個參數”create a mark”. Gotomark的添加方法同上。

setmark的實現

設置書籤主要步驟如下:

保存被調試進程的當前寄存器
fork新進程,保存新進程的PID
保存相關的GDB內部數據結構
首先將被調試進程的寄存器保存到本地磁碟文件mark.regs中:


                struct user_regs_struct regs;
  targetPid = PIDGET(inferior_ptid);
  fd = open("./mark.regs ",O_CREAT|O_RDWR,S_IRWXU);
  ptrace_readreg(targetPid,&regs);
 


inferior_ptid是GDB內部最重要的一個全局變數.它保存了當前GDB正在調試的目標進程的進程ID。在GDB術語中inferior就代表被調試的進程。Ptrace_read()是對PTRACE系統調用的封裝函數,主要功能就是將目標進程的寄存器內容讀出,保存在regs變數中.regs變數的類型為struct user_regs_struct.在linux/user.h中定義.(關於ptrace_read可以參考ref 2)

接下來讓被調試進程調用fork(),生成新進程.如果讓被調試進程直接調用fork(),新的進程將立即不受控制的執行下去直至結束,而我們希望新進程能夠暫停,以便gotomark的時候被attach。因此我們對fork進行了封裝:


                //loop_there_fork.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int loop_there_fork(void)
{
  int pid;
  pid = fork();
  if(pid <0) {perror("fork error"); exit(-1);}
  if(pid==0)  { //child
   for(;;)
   {
        sleep(1000);  // loop here forever until attached
   }
  }
  else 
    return pid;
  }
 


這個函數的主要功能就是返回新進程的ID,並且讓新進程無限循環等待。這也前面是為什麼需要保存被調試進程的寄存器內容的原因:調用gotomark時,新進程的指令計數器已經不在當前書籤點上,而在loop_there_fork()的某處,因此在gotomark時必須恢復寄存器,包括指令寄存器,這樣新進程就能完全恢復到書籤點了。

讓被調試進程調用loop_there_fork()的方法有很多種,早期的方法直接將二進位代碼插入到目標進程中,現在有一種更簡單的辦法,即共享庫注射(參考ref 2)。將loop_there_fork()編譯成一個共享庫:


                gcc –o shared –fpic –o loopfork.so loop_there_fork.c
 


下面的代碼將loopfork.so注入被調試進程.


                strcpy(soName,”./loopfork.so”);
funptr = find_function_in_inferior(“_dl_open”);
funaddr = funptr-> address;
reg.eax = (unsigned long)soName;
reg.ecx=0x0;
reg.edx= RTLD_LAZY;
ptrace_writereg(targetPid,reg);
ptrace_call(targetPid, funaddr);
 


函數find_function_in_inferiro()在目標進程中查找符號”_dl_open”的地址,返回值為struct value類型的指針。其中的address域存放著符號解析出來的地址值。 _dl_open()函數是dlopen()的實際實現。dlopen()的實現在libdl中,但是大多數程序並不會鏈接libdl,因此一般的被調試程序的地址空間中都找不到dlopen(),但_dl_open()的實現在Libc中,絕大多數程序都會鏈接Libc因此一般都能找到_dl_open()。利用該函數,就可以將loopfork.so裝載到被調試進程的地址空間中。

這裡沒有使用GDB內部的函數hand_function_call(),而直接調用ptrace_call來完成函數調用工作.原因是_dl_open函數被聲明為通過寄存器傳遞參數,而hand_function_call()則使用stack來傳遞參數.Ptrace_call(pid, addr)也是對Ptrace的封裝,將目標進程的EIP設置為ADDR,即實現了函數調用的功能。

再次調用find_function_in_inferiror()就能找到loop_there_fork()的地址,調用ptrace_call()就可以調用該函數了,將新進程的PID保存在org_pid變數中。


                funptr = find_function_in_inferiro(“loop_there_fork”);
funaddr = funptr->address;
org_pid = ptrace_call(targetPid,funaddr);
 


新的進程生成之後還需要保存一些GDB內部使用的數據結構:

step_frame_address:

該值保存了當單步命令執行時的堆棧frame的指針。當執行step over等命令時,需要依靠這個值。而該值在後續調試過程中會被更改。因此必須保存下來。

step_sp:

step_sp保存了當前的堆棧指針。保存的理由同上.

stop_pc:

stop_pc表示當前目標進程正在執行的PC值。該值沒有顯示的保存,因為我們已經將目標進程當前的所有寄存器值都保存在log文件中了,在恢復的時候,只需要讀取該文件中的eip的值就可以了。當用戶使用next等命令時,GDB依靠stop_pc來決定目標進程當前執行的位置,因此該值必須恢復。

保存GDB內部數據結構的代碼如下


                  org_pid = newPid;
  saved_step_frame_address = step_frame_address;
  saved_step_sp = step_sp;
 


gotomark的實現

gotomark的基本工作如下:

Attach到setmark時fork的新進程
恢復進程在設置書籤時的寄存器內容
恢復必要的GDB內部數據結構
自動調用set_mark()重新設置該書籤
首先attach到setmark時fork的新進程,該進程ID已經保存在全局變數org_pid中。直接調用gdb函數attach_command()完成attach工作。


                  sprintf(att_str,"%d",org_pid);
  attach_command(att_str,0);
 


attach_command是GDB處理attach命令的處理函數,函數原型為:


                void attach_command (char *args, int from_tty)
 


第一個參數是目標進程ID,該函數將目前正在調試的進程結束。然後掛載到新的進程上。從此新進程就成為當前被調試進程。新進程此時還在loop_there_fork函數中運行,必須將PC指針恢復為前面setmark時的值。當時的寄存器值都保存在了文件mark.regs中。下面的代碼讀出寄存器值並恢復到當前進程空間:


                  targetPid = PIDGET(inferior_ptid);
  fd = open("./mark.regs",O_RDWR,S_IRWXU);
  count = read(fd, (char*)&regs,sizeof(struct user_regs_struct));
  if(count==-1) perror("read log failed\n");
  ptrace_writereg(targetPid, &regs);
 


現在被調試進程完全恢復到了setmark時的狀態。

進程狀態恢復之後,就應該將GDB內部的一些數據結構恢復,代碼如下:


                  registers_changed();
  stop_pc = regs.eip;
  memcpy(current_frame,&poi_frame,sizeof(struct frame_info));
  step_frame_address = saved_step_frame_address;
  step_sp = saved_step_sp;
 


registers_changed()告訴GDB清空寄存器緩衝。為了提高效率,gdb使用host上的內存變數作為target中寄存器的cache。因為我們修改了目標進程的寄存器值,因此必須通知gdb清空cache。stop_pc,step_frame_address,step_sp以及current_frame都恢復為設置書籤時保存的原始值。這樣GDB內部的數據結構恢復也完成了。

最後再次調用set_mark()在當前點繼續設置書籤,以便下次再使用gotomark命令。

使用書籤的例子

這裡給出一個具體使用bookmark的例子。程序源碼如下


                //test.c
#include <stdio.h>
int globalData=0;
int main(void)
{
  int stackData = 0;
  stackData=globalData = 10;
  printf(“stackData is %d, globalData is %d\n”,stackData, globalData);
  stackData=globalData = 11;
  printf(“stackData is %d, globalData is %d\n”,stackData, globalData);
  return 0;
}
 


編譯


                 >gcc –g –o t1 test.c
 


使用gdb進行調試:


                >gdb t1
(gdb) l
1       //test.c
2       #include <stdio.h>
3       int globalData=0;
4       int main(void)
5       {
6         int stackData = 0;
7         stackData=globalData = 10;
8         printf("stackData is %d, globalData is %d\n",stackData,globalData);
9         stackData=globalData = 11;
10        printf("stackData is %d, globalData is %d\n",stackData, globalData);
(gdb) b 8
Breakpoint 1 at 0x8048350: file test.c, line 8.
(gdb) r
Starting program: /home/lm/t1
 
Breakpoint 1, main () at abc.c:8
8         printf("stackData is %d, globalData is %d\n",stackData,globalData);
(gdb)setmark
(gdb)n
stackData is 10, globalData is 10
9         stackData=globalData = 11;
(gdb) n
10        printf("stackData is %d, globalData is %d\n",stackData, globalData);
(gdb)
stackData is 11, globalData is 11
11        return 0;
(gdb)gotomark
 program is being debugged already.  Kill it? (y or n)y
(gdb)n
stackData is 10, globalData is 10
9         stackData=globalData = 11;

 


上面的例子中,書籤設置在代碼行第8行;在執行了兩次next命令之後調用gotomark。該命令首先提示用戶是否結束當前進程,選擇y。再運行next命令,程序已經回到了第8行,而且變數stackData和globalData都恢復了原值。表明堆棧和數據段的信息都恢復到原來的狀態。

結論

經過簡單測試,書籤功能在RedHat9.0版本上工作良好。但是還存在不少問題有待解決:

代碼中很多部分的編程風格同GDB不同,也直接使用了一些於操作系統緊密耦合的系統調用,因此存在可移植性的問題.
另外,這種fork新進程方式對某些特殊的程序也許不適用,因為被調試進程實際上已經是一個新進程,有些依賴於自身進程ID的程序將不能調試.
fork()不能複製進程的所有狀態,文件指針就是其中之一。目前的書籤實現中沒有保存已打開文件的文件指針,因此文件操作還不能完全恢復到書籤點的狀態.
書籤功能是對gdb的一個小小的改進。筆者的日常工作多數為維護性項目。書籤功能減少了調試時間,提高了工作效率。

因為本人的水平有限,文中難免會有錯誤的描述和概念,希望能通過本文拋磚引玉,也希望能給大家一定的幫助。

(責任編輯:A6)



[火星人 ] 為 gdb 增加書籤功能已經有538次圍觀

http://coctec.com/docs/linux/show-post-67523.html