一文带你看透 GDB 的 实现原理 -- ptrace真香
文章目錄
- Ptrace 的使用
- GDB 的基本實現原理
- Example1 通過ptrace 修改 被追蹤進程的內存數據
- Example2 通過ptrace 對被追蹤進程進行單步調試
- Ptrace的實現
- PTRACE_TRACEME
- PTRACE_ATTACH
- PTRACE_CONT
- PTRACE_SINGLESTEP
- PTRACE_PEEKDATA
- PTRACE_POKEDATA
- PTRACE_GETREGS
GDB本身能夠attach到一個運行的進程,實時獲取運行中進程的內存數據,增加斷點,查看當前運行狀態下函數變量值,甚至直接修改函數的變量。
這個機制本身就很有趣,也很實用,接下來探索一下GDB核心功能的詳細實現。
GDB基本的調試功能都是通過一個系統調用ptrace來實現的。
ps: 限于本人能力有限,對底層CPU 執行的正確邏輯沒法做到萬無一失,歡迎大家批評指正,相互學習討論。
Ptrace 的使用
ptrace 主要被用做進程追蹤,追蹤進程的什么內容呢?這里有很多可選的配置,比如進程內存的值、進程寄存器的值,進程接收到的信號,指定進程以何種方式運行等等;
接口聲明如下:
#include <sys/ptrace.h>long ptrace(enum __ptrace_request request, pid_t pid,void *addr, void *data);
調用ptrace 追蹤進程時(gdb attach -p $pid),被追蹤進程會發生如下事情:
-
追蹤進程會變為被追蹤進程 的父進程
Baron+ 215677 154756 0 11:57 pts/1 S 0:00 | \_ gdb attach -p 215063 Baron+ 218064 215677 7 11:59 pts/1 S+ 0:00 | \_ /home/Baron/write_test -
進程狀態會進入
TASK_TRACED,表示當前進程正在被追蹤,此時進程會暫停下來,等待追蹤進程的操作。這個狀態有點像TASK_STOPPED,都是讓進程暫停下來等待被喚醒或者操作。只是TASK_TRACED狀體的進程 不接受SIGCONT信號,只接受ptrace指定的PTRACE_DETACH和PTRACE_CONT請求從而喚醒進程執行操作。 -
發送給被追蹤進程的信號會被轉發給父進程,除了
SIGKILL,子進程則會被阻塞。 -
父進程收到信號之后可以對子進程進行修改,來讓子進程繼續運行。
接下來描述一下ptrace接口的參數含義:
-
request作為ptrace的核心配置,提供非常多的進程追蹤能力-
PTRACE_TRACEME和PTRACE_ATTACH都是和進程建立追蹤關系PTRACE_TRACEME表示被追蹤進程調用,讓父進程來追蹤自己。通常是gdb調試新進程時使用。PTRACE_ATTACH父進程attach到正在運行的子進程上,這種追蹤方式會檢查權限,普通用戶無法追蹤root用戶下的進程
-
PTRACE_PEEKTEXT、PTRACE_PEEKDATA、PTRACE_PEEKUSER、PTRACE_GETREGS等表示讀取子進程內存,寄存器等內容 -
PTRACE_POKETEXT,PTRACE_POKEDATA,PTRACE_POKEUSR等表示修改子進程的內存,寄存器的內容 -
PTRACE_CONT,PTRACE_SYSCALL,PTRACE_SINGLESTEP表示被控制進程以何種方式追蹤PTRACE_CONT表示重新啟動被追蹤進程PTRACE_SYSCALL每次進入或者退出系統調用時都會觸發一次SIGTRAP(Trace/breakpoint trap),strace的追蹤系統調用就是通過該配置進行追蹤的,進入時獲取參數,退出時獲取系統調用返回值。PTRACE_SINGLESTEP每執行完一次指令之后會觸發一次sigtrap,支持獲取當前進程的內存/寄存器狀態。gdb的next指令通過該選項實現。
-
PTRACE_DETACH, PTRACE_KILL解除父子進程之間的追蹤關系如果父進程在在子進程前結束,則會自動解除追蹤關系。
-
-
pid表示 要跟蹤的進程pid -
addr表示進程的內存地址 -
data根據前面設置的requet選項而變化,比如要開始追蹤時則設置request= PTRACE_CONT,同時將data設置為對應signal數字(SIGTRAP – 5)。
GDB 的基本實現原理
gdb調試的基本架構如下
- 本地調試 通過本地gdb 命令行或者mi圖形接口進行調試
- 遠端調試 就是在當前設備通過遠端的gdb server對遠端設備的目標程序進行調試
兩者共同點是 底層都通過ptrace系統調用進行調試。
ptrace的基本使用我們已經看了一遍,如果想要了解更加詳細的信息,可以通過man 2 ptrace進一步了解。
接下來通過ptrace來簡單看一下gdb的實現原理:
- 當我們使用gdb設置斷點的時候,gdb會將斷點處的指令修改為
INT 3(x86開始支持的專門用作調試的CPU指令,使得cpu終端到調試器),同時將斷點信息以及修改前的指令保存起來。 - 當被調試的子進程執行到斷點處時 觸發INT 3中斷,從而產生
SIGTRAP信號。 - 因為此時父進程已經和調試進程建立追蹤關系,ptrace會將子進程的
SIGTRAP信號發送給父進程,此時父進程先和已有的斷點信息進行對比,比如確認INT 3指令的位置,來確認當前信號是否因為斷點產生。 - 如果是,則會等待用戶輸入指令,進行下一步處理,如果不是,則不予理會,繼續執行后續代碼。
通過以上原理可以看出,gdb會修改子進程的代碼(將設置斷點處的子進程指令修改為INT 3),那就涉及到修改子進程內存的情況了。這里是通過ptrace的PTRACE_POKEDATA選項進行修改。
Example1 通過ptrace 修改 被追蹤進程的內存數據
通過ptrace 修改 被追蹤進程的內存數據
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>#include <sys/ptrace.h>void check(long ret, char *str) {if (ret == -1) {printf("execute %s failed with %ld !!!\n", str, ret);}printf("Execute %s success! \n", str);
}char str[] = "Ptrace is testing";int main() {pid_t pid = fork();union{char cdata[8];u_int64_t data; }u = {"CHANGE T"};switch (pid){case 0: // 子進程先休眠2秒sleep(2); printf("Child's data is %s\n", str);break;case -1:printf("Fork failed ");exit(1) ;break;default: // 父進程先修改子進程內存中的值,但是父進程內存中的數據不變check(ptrace(PTRACE_ATTACH, pid ,0 ,0),"PT_ATTACH"); // 鏈接到子進程check(ptrace(PTRACE_POKEDATA, pid ,str ,u.data),"PT_WRITE_D"); // 修改子進程內存中str的內容check(ptrace(PTRACE_CONT, pid ,0 ,0),"PT_CONTINUE"); // 子進程繼續運行printf("Parent's data is %s\n", str);wait(NULL);break;}return 0;
}
執行結果如下,可以看到父進程已經將子進程內存中的str數據前8個字節做了更改,但是父進程內存中的數據還是沒有變化。
$ ./ptrace_change
Execute PT_ATTACH success!
Execute PT_WRITE_D success!
Execute PT_CONTINUE success!
Parent's data is Ptrace is testing
Child's data is CHANGE Ts testing
Example2 通過ptrace 對被追蹤進程進行單步調試
通過ptrace 對被追蹤進程進行單步調試,以下代碼是在32位系統上調試的,所以寄存器的表示還是eip,而x86_64的系統下寄存器都已經變更為rip了。
總體的邏輯如下:
- 追蹤給定的進程pid, 通過
PTRACE_ATTACH作為父進程與 給定進程建立追蹤關系 - 獲取被追蹤進程的 CPU存放的下一個指令的存放地址 — EIP,CPU 存放當前主線程的棧頂指針偏移地址 — ESP
- 通過ptrace的
PTRACE_SINGLESTEP選項不斷得將EIP和ESP指針向下移動,每執行一條指令,寄存器指針移動一次,直到兩個寄存器指針到達棧尾,結束調試
當然打印并不只打印寄存器的地址,像GDB每一次單步追蹤會等待用戶的輸入,這個時候可以查看或者修改esp和eip當前狀態下的進程內存中的數據。
看ptrace測試 代碼之前先簡單描述一下ESP和EIP寄存器的關系:
進程開始運行的時候,左側CPU的ESP寄存器指向主線程的函數棧頂(函數的執行是不斷得壓棧和彈棧的)
右側的EIP寄存器則保存CPU執行的下一條匯編指令(后文有一個簡單的測試程序的全指令截圖,可以看看)
當開始運行的時候,一個函數語句可能需要多條匯編指令來完成,所以EIP改變多次,ESP才會發生一次改變。
通過n次的指令執行程序主體代碼, 運行完成的標記就是ESP指向函數棧底,EIP指令指針也指向函數棧底。
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <wait.h>
#include <sys/ptrace.h>
#include <sys/reg.h>
#include <sys/user.h>
#include <sys/signal.h>#define M_OFFSETOF(STRUCT, ELEMENT) \(unsigned int) &((STRUCT *)NULL)->ELEMENT;#define D_LINUXNONUSRCONTEXT 0x40000000 // 32位系統下內核態部分的結束地址//(32位系統虛擬進程空間內核地址占用1個G)int main (int argc, char *argv[]) {int Tpid, stat, res;
int signo;
int ip, sp;
int ipoffs, spoffs;
int initialSP = -1;
int initialIP = -1;
struct user u_area;
struct user_regs_struct regs;/*
** 傳入指定進程的PID
*/if (argv[1] == NULL) {printf("Need pid of traced process\n");printf("Usage: pt pid \n");exit(1);}Tpid = strtoul(argv[1], NULL, 10);printf("Tracing pid %d \n",Tpid );
/*
** 獲取EIP 偏移地址 -- 保存CPU 下一個指令的寄存器地址
** 獲取ESP 偏移地址 -- 保存CPU 函數棧頂指針的偏移地址
*/ipoffs = M_OFFSETOF(struct user, regs.eip);spoffs = M_OFFSETOF(struct user, regs.esp);
/*
** 通過Ptrace 將輸入PID所代表的進程作為當前進程的子進程,并建立追蹤關系。
** 此時會目標子進程發送一個SIGSTOP的信號,調用waitpid來感知子進程的狀態變化。
*/printf("Attaching to process %d\n",Tpid);if ((ptrace(PTRACE_ATTACH, Tpid, 0, 0)) != 0) {;printf("Attach result %d\n",res);}res = waitpid(Tpid, &stat, WUNTRACED);if ((res != Tpid) || !(WIFSTOPPED(stat)) ) {printf("Unexpected wait result res %d stat %x\n",res,stat);exit(1);}printf("Wait result stat %x pid %d\n",stat, res);stat = 0;signo = 0;
/*
** 完成子進程(輸入的PID 進程)的狀態切換,并且與當前追蹤進程建立了父子關系
*/while (1) {
/*
** 通過ptrace的PTRACE_SINGLESTEP進行單步調試,調試過程會向子進程發送SIGTRAP信號
** 通過wait系統調用進行捕獲
*/ if ((res = ptrace(PTRACE_SINGLESTEP, Tpid, 0, signo)) < 0) {perror("Ptrace singlestep error");exit(1);}res = wait(&stat);
/*
** 捕獲到SIGTRAP信號之后,將信號置0,準備開啟下一個單步調試。
** 如果發現子進程接受到的信號是SIGHUP和SIGINT(子進程接受到了暫停信號
** 那么就停止單步調試,父進程退出。
*/if ((signo = WSTOPSIG(stat)) == SIGTRAP) {signo = 0;}if ((signo == SIGHUP) || (signo == SIGINT)) {ptrace(PTRACE_CONT, Tpid, 0, signo);printf("Child took a SIGHUP or SIGINT. We are done\n");break;}
/*
** 單步調試之后,兩個寄存器的地址會發生變化,所以需要重新獲取以下
*/ip = ptrace(PTRACE_PEEKUSER, Tpid, ipoffs, 0);sp = ptrace(PTRACE_PEEKUSER, Tpid, spoffs, 0);
/*
** 通過 ldd 查看輸入的PID進程的內存分布如下
** libc.so.6 => /lib/i686/libc.so.6 (0x40030000)
** /lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000)
** 這里跳過內核態的地址
*/if (ip & D_LINUXNONUSRCONTEXT) {continue;} if (initialIP == -1) {initialIP = ip;initialSP = sp;printf("---- Starting LOOP IP %x SP %x ---- \n",initialIP, initialSP);} else { // 直到運行到ESP指針和EIP指針的結尾,完成單步追蹤if ((ip == initialIP) && (sp == initialSP)) {ptrace(PTRACE_CONT, Tpid, 0, signo);printf("----- LOOP COMPLETE -----\n");break;}}printf("Stat %x IP %x SP %x Last signal %d\n",stat, ip, sp,signo);}printf("Debugging complete\n");sleep(5);return(0);
}
測試代碼如下:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>int main() {int *a[10] = {0};int i = 0;int j = 0;while(i < 1000) {a[i] = (int *)malloc(sizeof(int)*10);if(a[i] == NULL){printf("malloc failed\n");exit(1);}else {printf("malloc address is %x\n",(unsigned int)a[i]);}for(;j < 10; ++j){a[i][j] = j;}i++;sleep(1);}for(i =0;i < 1000 ;++i) {free (a[i]);}return 0;}
測試代碼對應的CPU指令如下
perf top -p pid
先運行測試代碼,再編譯運行ptrace追蹤代碼./test_ptrace $pid,可以看到ptrace追蹤代碼如下輸出:
其中IP和SP指向的地址可看到 SP指針不會每次追蹤都發生變化,而指令寄存器地址IP每次都發生變化,因為每次執行的指令都不一樣,這和我們描述ptrace單步調試代碼邏輯時的ESP和EIP寄存器關系圖邏輯一樣的。
因為還不是linux手藝人,還沒法深入淺出linux系統,所以這里只能通過自己的猜測和工具來 彌補體系結構這塊知識的缺失了。
Tracing pid 314201
Attaching to process 314201
Wait result stat 137f pid 314201
---- Starting LOOP IP a88e0840 SP b60b6418 ----
Stat 57f IP a88e0840 SP b60b6418 Last signal 0
Stat 57f IP a88e0846 SP b60b6418 Last signal 0
Stat 57f IP a88e0848 SP b60b6418 Last signal 0
Stat 57f IP a88e06f4 SP b60b6420 Last signal 0
Stat 57f IP a88e06f6 SP b60b6420 Last signal 0
Stat 57f IP a88e06f8 SP b60b6420 Last signal 0
Stat 57f IP a88e0720 SP b60b6420 Last signal 0
Stat 57f IP a88e0727 SP b60b65d8 Last signal 0
Stat 57f IP a88e0729 SP b60b65d8 Last signal 0
Stat 57f IP a88e072a SP b60b65e0 Last signal 0
......
Stat 57f IP a88e06ef SP b60b6420 Last signal 0
Stat 57f IP a88e0830 SP b60b6418 Last signal 0
Stat 57f IP a88e0837 SP b60b6418 Last signal 0
Stat 57f IP a88e0839 SP b60b6418 Last signal 0
Stat 57f IP a88e083e SP b60b6418 Last signal 0
----- LOOP COMPLETE -----
Debugging complete
Ptrace的實現
這里不可能將每一個ptrace的選項的實現都講明白,只能在主線的調試流程上看看當 attach,獲取被追蹤進程內存數據,單步調試 這一些功能的背后內核做了什么。
使用frtrace 抓取SyS_ptrace函數的執行邏輯,關于ftrace的使用可以參考關于 Rocksdb 性能分析 需要知道的一些“小技巧“ – perf_context的“內功” ,systemtap、perf、 ftrace的顏值
這個抓取主要是通過執行gdb的一些調試命令來讓ptrace的不同選項得到運行,抓取attach,breadpoint,r,n等基本gdb指令的結果如下(主體的處理邏輯還是比較長的,這里僅僅貼一部分邏輯):
# tracer: function_graph
#
# CPU TASK/PID DURATION FUNCTION CALLS
# | | | | | | | | |3) <...>-46083 | | SyS_ptrace() { # 系統調用入口3) <...>-46083 | | ptrace_get_task_struct() { # 獲取進程的task_struc3) <...>-46083 | | find_task_by_vpid() {3) <...>-46083 | | find_task_by_pid_ns() {3) <...>-46083 | 0.523 us | find_pid_ns();3) <...>-46083 | 1.178 us | }3) <...>-46083 | 1.858 us | }3) <...>-46083 | 2.387 us | }3) <...>-46083 | | ptrace_attach() { # attach 入口3) <...>-46083 | | mutex_lock_interruptible() {3) <...>-46083 | 0.037 us | _cond_resched();3) <...>-46083 | 0.707 us | }3) <...>-46083 | 0.087 us | _raw_spin_lock();3) <...>-46083 | | __ptrace_may_access() {3) <...>-46083 | 0.105 us | get_dumpable();3) <...>-46083 | | security_ptrace_access_check() {3) <...>-46083 | | yama_ptrace_access_check() {3) <...>-46083 | 0.068 us | cap_ptrace_access_check();3) <...>-46083 | 0.584 us | }3) <...>-46083 | 0.043 us | cap_ptrace_access_check();3) <...>-46083 | 1.404 us | }3) <...>-46083 | 2.947 us | }
......
ps: 后文涉及到的ptrace源碼是 linux-3.10.1.0.1版本
PTRACE_TRACEME
通過gdb 調試一個新的進程會進入PTRACE_TRACEME選項,gdb ./new_process
ptrace系統調用入口如下:
確認能夠建立連接之后通過_ptrace_link將當前進程new_process和gdb追蹤進程建立父子關系
PTRACE_ATTACH
通過gdb attach到一個正在運行的進程上時會進入這個邏輯,gdb attach -p pid
在后續會通過signal_wake_up_state函數喚醒處于stopped狀態的進程
PTRACE_CONT
使得因正在被調試而暫停,或者斷掉的進程恢復運行,gdb的n,r,c等命令讓進程重新運行都是通過該選項實現的
進入到arch_ptrace之后,通過ptrace_reuqest --> ptrace_resume對該選項進行處理
PTRACE_SINGLESTEP
將進程的標志寄存器設置為單步模式,讓被調試進程繼續運行。當執行完一條指令之后,會觸發INT中斷,并發信號給控制進程,等待下一次的執行。
PTRACE_PEEKDATA
讀取虛擬進程內存中的數據,像gdb的p 打印變量 就是該選項的功能,與選項PTRACE_PEEKTEXT一樣,只不過讀取的是不同的地址空間的數據。TEXT是代碼段的數據,程序執行代碼中的一段數據,DATA段存儲已經初始化的靜態數據和全局變量數據。
PTRACE_POKEDATA
修改被追蹤進程指定內存地址中的數據,通過設置access_process_vm函數最后一個參數來表示是寫入內存中的數據還是從內存中讀數據。
PTRACE_GETREGS
獲取被追蹤進程 指定寄存器中的數據
而對應的PTRACE_SETREG即修改用戶進程寄存器內容,通過__get_user函數將data中的數據寫入到regs數組之中。
總結
以上是生活随笔為你收集整理的一文带你看透 GDB 的 实现原理 -- ptrace真香的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 最近播出了哪些好看的电影?有谁知道吗?
- 下一篇: 喋血复仇怎么刷钱?