用 70 行代码给你自己写一个 strace
基本上任何使用了一段時間 Linux 的人,最后都會知道并愛上 strace 命令。strace 是系統調用跟蹤器,它跟蹤程序執行的進入內核以與外面的世界交互的調用。如果你還不熟悉這個令人驚奇的多才多藝的工具,我建議你看一下我的朋友和合作伙伴 Greg Price 的出色的博客 blog post 中關于這一主題的內容,然后再回到這里。
我們都愛 strace,但你是否曾經好奇它是如何工作的呢?它是如何把它自己注入到內核和用戶空間程序之間的呢?這篇博客將用大約 70 行 C 代碼走查一個小小的 strace 實現。它的功能不會像真的那樣好,但在這個過程中,你將了解關于它使用的核心接口所需了解的大部分內容。
在 Linux(還可能在其它一些 UNIX)上 strace 使用了被稱為 [ptrace](http://linux.die.net/man/2/ptrace) 的有點神秘的接口,進程追蹤接口。ptrace 允許一個進程監視另一個進程的狀態,并深入調查(或甚至是控制)它的內部狀態。
ptrace 是一個復雜的系統調用,它接收一個神奇的 “request” 首參數,然后依賴于它的值執行完全不同的事情。它通常的原型看起來像這樣:
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);然而,由于不同的 request 值使用剩余的從 0 個到 3 個參數,glibc 中它的原型為可變參數函數,允許一個開發者只列出給定調用所需要的參數個數。
為了使一個進程跟蹤另一個,它附到那個進程上,并臨時變為那個進程的父進程。當一個進程被 ptraced,跟蹤器可以請求它的子進程隨時在各種事件發生時停下來,比如子進程執行了一個系統調用。當這發生時,內核將以 SIGTRAP 停止子進程。由于此時跟蹤器是子進程的父進程,這樣它就可以使用標準的 UNIX waitpid 系統調用觀察到這一點。
我們的小型 strace 將只支持 strace 的 strace COMMAND 形式(對照 strace -p),并且我們將只打印系統調用號和返回值 - 不解碼名字或參數或任何其它事情。因此一次簡單的運行可能看起來像下面這樣:
$ ./ministrace ls … syscall(6) = 0 syscall(54) = 0 syscall(54) = 0 syscall(5) = 3 syscall(221) = 1 syscall(220) = 272 syscall(220) = 0 syscall(6) = 0 syscall(197) = 0 syscall(192) = -1219706880盡管不是世界上最有用的東西,但它展示了核心的跟蹤工具。因此,讓我們來看下代碼:
#include <sys/ptrace.h> #include <sys/reg.h> #include <sys/wait.h> #include <sys/types.h> #include <unistd.h> #include <stdlib.h> #include <stdio.h> #include <errno.h> #include <string.h>我們從必要的頭文件開始。sys/ptrace.h 定義了 ptrace 和 __ptrace_request 常量,我們還將需要 sys/reg.h 幫忙解碼系統調用。更多相關的內容在后面。其它的你應該都認得出來。
int do_child(int argc, char **argv); int do_trace(pid_t child);int main(int argc, char **argv) {if (argc < 2) {fprintf(stderr, "Usage: %s prog args\n", argv[0]);exit(1);}pid_t child = fork();if (child == 0) {return do_child(argc-1, argv+1);} else {return do_trace(child);} }我們將從入口點開始。我們檢查我們被傳入了一個命令,然后我們通過 fork() 創建兩個進程 - 一個用于執行被跟蹤的程序,而另一個跟蹤它。
int do_child(int argc, char **argv) {char *args [argc+1];memcpy(args, argv, argc * sizeof(char*));args[argc] = NULL;子進程從一些瑣碎的參數整理開始,這是由于 execvp 想要一個由 NULL 終止的參數數組。
ptrace(PTRACE_TRACEME);kill(getpid(), SIGSTOP);return execvp(args[0], args); }接下來,我們僅執行提供的參數列表,但首先,我們需要啟動跟蹤進程,以使父進程可以開始在非常早期就開始跟蹤新執行的程序。
如果子進程知道它想要被跟蹤,它可以執行 PTRACE_TRACEME ptrace 請求,這將啟動追蹤。此外,這意味著下一個發送給這個進程的信號將停止它并通知它的父進程(通過 wait),這樣父進程就知道要開始跟蹤了。因此,在執行了一個 TRACEME 之后,我們 SIGSTOP 我們自己,以使父進程可以通過 exec 調用繼續我們的執行。
(你可能已經注意到了,strace COMMAND 輸出總是以一個 execve 調用開始。現在你應該已經理解為什么了 —— 實際上,我們打算在 kill 返回后立即開始跟蹤,因此我們看到了啟動新程序的 execve 調用。)
int wait_for_syscall(pid_t child);int do_trace(pid_t child) {int status, syscall, retval;waitpid(child, &status, 0);與此同時,在父進程中,我們聲明了稍后需要的函數的原型,并開始跟蹤。我們立即開始 waitpid 在子進程上,一旦子進程給自己發送了上面的SIGSTOP,它將返回,并準備好被跟蹤。
ptrace(PTRACE_SETOPTIONS, child, 0, PTRACE_O_TRACESYSGOOD);我前面提到 ptrace 基本上把子進程上的所有事件都轉為 SIGTRAP。這很不方便,因為它意味著當你看到子進程由于 SIGTRAP 而停止時,沒有很好的辦法來知道它是由于它可能停止的多種原因中的哪種而停止的。
PTRACE SETOPTIONS 允許我們設置許多選項來控制我們要如何跟蹤子進程。這里我們使用它來設置 PTRACE_O_TRACESYSGOOD,這意味著當子進程由于系統調用相關的原因停止時,我們實際上會看到它以信號號SIGTRAP | 0x80 停止,這樣我們可以簡單地從其它停止中區分出系統調用導致地停止。由于(出于這個 demo 的目的),我們只關注系統調用,這還是非常方便的。
while(1) {if (wait_for_syscall(child) != 0) break;現在我們進入跟蹤循環。wait_for_syscall,在下面定義,將運行子進程直到進入或退出一個系統調用。如果它返回非 0,則子進程已經退出,我們終止循環。
syscall = ptrace(PTRACE_PEEKUSER, child, sizeof(long)*ORIG_EAX);fprintf(stderr, "syscall(%d) = ", syscall);否則,盡管,我們知道子進程進入了一個系統調用,這樣我們需要解碼系統調用號(以及潛在的參數,如果這是一個不那么簡單的例子)。PTRACE_PEEKUSER ptrace 請求從子進程的 “user area” 讀取一個字的數據,這是一個邏輯區域,它持有它所有的寄存器和其它的內部非內存狀態。在 i386 上,系統調用號位于 %eax。出于各種各樣的技術原因,然而,內核在此時已經破壞了子進程的 %eax,但它在一個不同的偏移量處保存了原始值,ORIG_EAX,這來自于 sys/regs.h。
if (wait_for_syscall(child) != 0) break;一旦我們有了系統調用號,我們再次 wait_for_syscall,這應該會讓我們停止在系統調用返回處。
retval = ptrace(PTRACE_PEEKUSER, child, sizeof(long)*EAX);fprintf(stderr, "%d\n", retval);i386 上的返回值也是在 %eax 中傳遞的,因此這次我們可以直接讀取它,并打印返回值,然后返回到循環的頂部并等待下一次系統調用。
}return 0; }一旦子進程退出,我們也返回。
int wait_for_syscall(pid_t child) {int status;while (1) {ptrace(PTRACE_SYSCALL, child, 0, 0);wait_for_syscall 是一個簡單的輔助函數。我們使用 PTRACE_SYSCALL 來繼續子進程,這允許一個停止的子進程繼續執行直到下一次進入或退出一個系統調用。
waitpid(child, &status, 0);然后我們 waitpid 等待有趣的事情發生在子進程身上。
if (WIFSTOPPED(status) && WSTOPSIG(status) & 0x80)return 0;由于我們上面設置的 PTRACE_O_SYSGOOD ,我們可以通過檢查被停止的子進程是否由一個最高位設置了的信號停止的來探測一個系統調用停止。如果是這樣,我們就返回。
if (WIFEXITED(status))return 1;} }如果子進程退出,我們就完成了;否則,它是因為我們不關心的原因而停止的(例如,execve),因此我們循環再次啟動它,直到它遇到系統調用。
這就是它的全部。如果你想下載并試用,你可以在 github 上找到我剛剛發布的版本。
讓它更有用
雖然它可以工作,但我認為以前的版本并不是特別有用。你不得不手動解碼系統調用號,且你無法獲得任何系統調用參數。
把代碼都包含在這篇博客中可能有點長,但我已經把一個稍微更實用的版本發布到了相同的 github 倉庫的?master 。它包含一個 Python 腳本來掃描 Linux 源碼以提取系統調用號及參數個數和類型,且它知道如何解碼字符串參數,以使你可以看到文件名及 read 和 write 的數據。
讀取參數很容易 —— 在 i386 上,它們在寄存器中傳遞,因此,對于每一個參數,只是另一次 PTRACE_GETUSER。也許最有趣的片段就是 read_string 函數了,它用于從子進程中讀取一個 NULL 結尾的字符串。(當然,以 NULL 結尾是不正確的 —— 真正的 strace 知道 read() 和 write() 的 count 參數,比如。但這已經足夠做一個 demo 了。)
char *read_string(pid_t child, unsigned long addr) {read_string 接收一個要讀取的子進程的進程 ID,及它打算讀取的字符串的地址作為參數:
char *val = malloc(4096);int allocated = 4096, read;unsigned long tmp;我們需要一些變量。一個拷入字符串的緩沖區,我們已經拷貝的數據及分配的數據的計數器,及一個臨時變量用于讀取內存。
while (1) {if (read + sizeof tmp > allocated) {allocated *= 2;val = realloc(val, allocated);}我們在必要時增加緩沖。我們一次一個字地讀取數據。
tmp = ptrace(PTRACE_PEEKDATA, child, addr + read);if(errno != 0) {val[read] = 0;break;}PTRACE_PEEKDATA 返回子進程在指定偏移量處的數據工作。因為它使用返回值,所以我們需要檢查 errno 來判斷它是否失敗。如果它失敗了(可能由于子進程傳遞了一個無效的指針),我們僅返回我們截止目前已經獲得的字符串,確保在最后添加我們自己的 NULL。
memcpy(val + read, &tmp, sizeof tmp);if (memchr(&tmp, 0, sizeof tmp) != NULL)break;read += sizeof tmp;然后,將我們讀到的數據附加起來就很簡單了,如果我們發現一個終止 NULL 就跳出循環,否則循環讀取另一個字。
}return val; }【原文】Write yourself an strace in 70 lines of code
總結
以上是生活随笔為你收集整理的用 70 行代码给你自己写一个 strace的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: strace 哇,好多系统调用
- 下一篇: 基于 FFmpeg 的播放器 demo