揭開 Strace 命令捕獲系統調用的神秘面紗
在性能觀測領域,strace 命令是一個雖然很古老,但很常用的命令。使用它我們可以非常方便地觀察某個進程正在執行什么系統調用。
這個命令的使用方式也很簡單,想觀察哪個進程,直接將其 pid 作為參數傳給 strace 命令即可。
# strace -p {pid}
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0@k\0\0\0\0\0\0"..., 832) = 832
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\260A\2\0\0\0\0\0"..., 832) = 832
write(1, "anycast6 dev_snmp6\t if_inet6\ti"..., 137anycast6 dev_snmp6 if_inet6 ip6_mr_vif ip_mr_vif mcfilter nf_conntrack ptype rt6_stats sockstat tcp6 unix
) = 137
......
然而我們都知道,正常來講操作系統中的各個進程之間是互相隔離的。那么 strace 命令是如何做到能獲取其他進程執行的系統調用信息的呢,我們今天就來揭開這個謎底。
一、手工實現一個 strace
要想理解清楚 strace 命令原理,我想最有效的辦法是我們自己親手寫一個簡單程序來模擬 strace 的工作過程。
為了方便大家理解,我這里只把這個程序的核心邏輯列出來。完整的程序源碼請大家查看strace配套源碼 https://github.com/yanfeizhang/coder-kung-fu/blob/main/tests/cpu/test11/main.c
int main(int argc, char *argv[]) {
// 1.attach 到 pid 指定的目標進程上
ptrace(PTRACE_ATTACH, pid, NULL, NULL)
while (1) {
// 2.等待目標進程的 PTRACE_SYSCALL
// 2.1 指定要捕獲目標進程的 PTRACE_SYSCALL
ptrace(PTRACE_SYSCALL, pid, NULL, NULL)
// 2.2 當目標進程有 SYSCALL 發生時醒來處理
waitpid(pid, &status, 0)
// 3.讀取并解析系統調用
// 3.1 讀取目標進程正在執行的系統調用號
syscall_number = ptrace(PTRACE_PEEKUSER, pid, 8 * ORIG_RAX, NULL); 、
// 3.2 將系統調用號轉為系統調用名稱
switch (syscall_number) {
case 5: syscall_name = "read"; break;
case 6: syscall_name = "write"; break;
case 10: syscall_name = "open"; break;
case 11: syscall_name = "close"; break;
......
default: syscall_name = "unknown"; break;
}
// 3.3 打印系統調用名稱
printf("Syscall: %s (number: %ld)\n", syscall_name, syscall_number);
}
}
通過上面二十多行核心代碼,我們就實現了一個模擬 strace 命令跟蹤系統調用功能的簡易程序。在這個程序中,主要是通過三塊邏輯來實現:
第一,attach 到目標進程。在 C 標準庫 中有一個 ptrace 函數 , 該函數是一個系統調用方法。通過指定它的第一個參數為 PTRACE_ATTACH pid,這樣就可以建立當前程序和目標進程的跟蹤關系了。要注意的是,這一步必須得有 root 權限才可以。
第二,將自己注冊為目標進程的 syscall 調試器。這次還是使用 ptrace 函數。但第一個參數設為 PTRACE_SYSCALL,這樣就在告訴內核要將自己注冊為目標進程的 syscall 調試器。每當目標進程發生系統調用的時候,都會通知當前程序。
第三,讀取目標進程系統調用名。這里涉及到一個基礎知識,Linux 內核在幫用戶進程執行系統調用的時候,會將系統調用號保存到 ORIG_RAX 寄存器中。
ptrace 函數第一個參數設為 PTRACE_PEEKUSER,這是在告訴內核幫忙讀取目標進程的用戶區域的數據。第三個參數指定要讀取目標進程的 ORIG_RAX 寄存器中保存的系統調用號。在 /usr/include/x86_64-linux-gnu/asm/unistd_64.h 中可以查看到所有的系統調用號信息。將其轉為系統調用名后輸出即可。
整個程序是一個循環,每當目標程序有系統調用發生的時候,都會通知到當前程序。當前程序再將其正在執行的系統調用信息輸出出來。這樣就實現了對目標進程實時行為的動態跟蹤。
接下來我們分三個部分,從內核視角深入地探究一下底層工作原理。
二、attach 到目標進程
要想實現對目標程序的跟蹤,首先第一步準備工作便是調用 ptrace 把自己 attach 到目標進程上。
int main(int argc, char *argv[]) {
// 1.attach 到 pid 指定的目標進程上
ptrace(PTRACE_ATTACH, pid, NULL, NULL)
...
}
我們來看下這個所謂的 attach ,在 Linux 內部究竟是干了點啥。找來 ptrace 系統調用的源碼。
//file:kernel/ptrace.c
SYSCALL_DEFINE4(ptrace, long, request, long, pid, unsigned long, addr, ...)
{
// 1. 根據 pid 查找目標進程內核對象
struct task_struct *child;
child = find_get_task_by_vpid(pid);
......
// 2. 執行 ptrace_attach
if (request == PTRACE_ATTACH || request == PTRACE_SEIZE) {
ret = ptrace_attach(child, request, addr, data);
...
}
......
}
在 ptrace 系統調用源碼中,第一步比較簡單,根據參數中的 pid 查找目標進程在內核中的 task_struct 內核對象。第二步操作中的 ptrace_attach,是 attach 到目標進程的核心函數。
//file:kernel/ptrace.c
static int ptrace_attach(struct task_struct *task, long request, ...)
{
...
// 1.權限檢查
if (unlikely(task->flags & PF_KTHREAD))
goto out;
...
// 2.狀態設置
ptrace_link(task, current);
...
}
在 ptrace_attach 中先要進行一些權限檢查,例如內核線程是不允許被 attach 的。接著調用 ptrace_link 來修改當前進程,以及要跟蹤的目標進程的內核對象相關字段。ptrace_link 的主要實現是 __ptrace_link。
//file:kernel/ptrace.c
void __ptrace_link(struct task_struct *child, struct task_struct *new_parent, ...)
{
list_add(&child->ptrace_entry, &new_parent->ptraced);
child->parent = new_parent;
...
}
在 ptrace_link 函數中,先是通過 list_add 函數,將目標進程(child)的 ptrace_entry 被插入到當前進程(new_parent)的 ptraced 鏈表的頭部。這樣當前進程(new_parent) 就可以通過 ptraced 鏈表來跟蹤和管理所有在跟蹤的進程。
接著調用 child->parent = new_parent 把當前進程設置成了目標進程(child)的 parent。目的是使當前進程能夠通過調用 waitpid 獲取到目標進程的 SIGTRAP 信號。
這樣就完成了到目標進程的 attach,當前進程通過 ptraced 來管理目標進程,目標進程也可以發出 SIGTRAP 信號來和當前進程進行消息傳遞。
三、捕獲目標進程 SYSCALL
3.1 設置等待目標進程 SYSCALL
在完成當前進程和目標進程的 attach 關聯后。接著下一步操作是告訴 Linux 內核,要等待和捕獲目標進程的系統調用。
int main(int argc, char *argv[]) {
// 1.attach 到 pid 指定的目標進程上
...
while (1) {
// 2.等待目標進程的 PTRACE_SYSCALL
// 2.1 指定要捕獲目標進程的 PTRACE_SYSCALL
ptrace(PTRACE_SYSCALL, pid, NULL, NULL)
// 2.2 當目標進程有 SYSCALL 發生時醒來處理
waitpid(pid, &status, 0)
...
}
}
在 ptrace 系統調用中,由于這次傳入的第一個參數是 PTRACE_SYSCALL。所以其執行的核心函數提煉后如下所示:
//file:kernel/ptrace.c
SYSCALL_DEFINE4(ptrace, long, request, long, pid, unsigned long, addr,
unsigned long, data)
{
struct task_struct *child;
child = find_get_task_by_vpid(pid);
......
ret = arch_ptrace(child, request, addr, data)
......
}
首先還是先根據 pid 獲取目標進程的 task_struct 內核對象。接著執行 arch_ptrace 來為目標進程內核對象添加一個標記 SYSCALL_TRACE。具體設置是在 ptrace_resume 函數中執行的( arch_ptrace -> ptrace_request -> ptrace_resume )。我們直接來看 ptrace_resume 源碼。
//file:kernel/ptrace.c
static int ptrace_resume(struct task_struct *child, long request,
unsigned long data)
{
if (request == PTRACE_SYSCALL)
set_task_syscall_work(child, SYSCALL_TRACE);
...
}
set_task_syscall_work 函數就是在給目標進程的設置了一個 SYSCALL_TRACE 標記位。
//file:include/linux/thread_info.h
#define set_task_syscall_work(t, fl) \
set_bit(SYSCALL_WORK_BIT_##fl, &task_thread_info(t)->syscall_work)
這樣后面當該進程再執行系統調用的時候,通過判斷該標記就能發現有進程在跟蹤它了。
3.2 等待目標進程信號發生
當前進程在設置完要對目標進程的 SYSCALL 進行觀察后,接著就調用 waitpid 進入了睡眠狀態。
int main(int argc, char *argv[]) {
// 1.attach 到 pid 指定的目標進程上
...
while (1) {
// 2.等待目標進程的 PTRACE_SYSCALL
// 2.1 指定要捕獲目標進程的 PTRACE_SYSCALL
ptrace(PTRACE_SYSCALL, pid, NULL, NULL)
// 2.2 當目標進程有 SYSCALL 發生時醒來處理
waitpid(pid, &status, 0)
...
}
}
waitpid 也是一個系統調用。
//file:kernel/exit.c
SYSCALL_DEFINE3(waitpid, pid_t, pid, int __user *, stat_addr, int, options)
{
return kernel_wait4(pid, stat_addr, options, NULL);
}
在這個系統調用中,依次調用 kernel_wait4、do_wait 等內核函數,最后在 add_wait_queue 函數中,將當前進程加入到等待隊列中,更新進程狀態為 TASK_INTERRUPTIBLE(可中斷睡眠狀態),等待子進程信號。
// file:kernel/exit.c
static long do_wait(struct wait_opts *wo)
{
...
init_waitqueue_func_entry(&wo->child_wait, child_wait_callback);
wo->child_wait.private = current;
add_wait_queue(¤t->signal->wait_chldexit, &wo->child_wait);
...
}
當子進程退出時,內核會向父進程發送一個信號,父進程的信號處理程序會喚醒等待隊列中的進程,使它們重新進入可運行狀態,等待被調度器調度執行。
四、等待并讀取目標進程系統調用
4.1 目標進程系統調用發生
當目標進程系統調用發生的時候,會檢查是否有被設置 SYSCALL_TRACE 標記位。如果有,那就說明有進程正在跟蹤它。具體的檢測是在 syscall_trace_enter 內核函數中做的
//file:arch/m68k/kernel/entry.S
ENTRY(system_call)
...
jbsr syscall_trace_enter
//file:arch/m68k/kernel/ptrace.c
asmlinkage int syscall_trace_enter(void)
{
int ret = 0;
if (test_thread_flag(TIF_SYSCALL_TRACE))
ret = ptrace_report_syscall_entry(task_pt_regs(current));
return ret;
}
如果有 SYSCALL_TRACE 標志,那就會設置一下退出碼,發出 SIGTRAP 信號,喚醒正在追蹤它的進程,最后暫停當前程序的運行。具體的內核函數調用過程是經過 ptrace_report_syscall_entry -> ptrace_report_syscall -> ptrace_notify -> ptrace_do_notify 這么一條長的調用鏈后,最終在 ptrace_stop 內核函數中執行的。我們直接來看這個最關鍵的 ptrace_stop 函數。
//file:kernel/signal.c
static int ptrace_stop(int exit_code, int why, unsigned long message,
kernel_siginfo_t *info)
__releases(¤t->sighand->siglock)
__acquires(¤t->sighand->siglock)
{
......
// 1.
set_special_state(TASK_TRACED);
// 2.設置當前進程的 exit_code
current->ptrace_message = message;
current->last_siginfo = info;
current->exit_code = exit_code;
// 3.
if (current->ptrace)
do_notify_parent_cldstop(current, true, why);
if (gstop_done && (!current->ptrace || ptrace_reparented(current)))
do_notify_parent_cldstop(current, false, why);
cgroup_enter_frozen();
schedule();
......
}
在這個函數中做了這么幾件事情。
第一,調用 set_special_state(TASK_TRACED)
將當前進程(被跟蹤進程)的狀態設置為 TASK_TRACED,表示進程已被 ptrace 停止。該狀態意味著進程將不會在下次調度時被調度執行,因為它現在處于被跟蹤狀態。
第二,設置自己的退出碼,到struct task_struct 的成員 exit_code 上。
第三,調用 do_notify_parent_cldstop()-->__wake_up_parent()喚醒跟蹤進程(strace)
第四,調用 schedule 掛起自己讓出 CPU。
4.2 跟蹤進程 waitpid 返回
接下來跟蹤進程收到信號,會被內核喚醒,并中 waitpid 函數中返回。
//file:kernel/exit.c
SYSCALL_DEFINE3(waitpid, pid_t, pid, int __user *, stat_addr, int, options)
{
return kernel_wait4(pid, stat_addr, options, NULL);
}
long kernel_wait4(pid_t upid, int __user *stat_addr, int options,
struct rusage *ru)
{
...
ret = do_wait(&wo);
put_pid(pid);
//將進程狀態保存到用戶傳入的地址中
if (ret > 0 && stat_addr && put_user(wo.wo_stat, stat_addr))
ret = -EFAULT;
return ret;
}
這時,跟蹤進程就知道目標進程有系統調用發生了。下一步就可以讀取目標進程正在執行的系統調用信息了。
4.3 讀取目標進程系統調用號
在 Linux 內核中,ORIG_RAX 寄存器用于保存進程在執行系統調用是的調用號。跟蹤進程只需要訪問下目標進程的 ORIG_RAX 寄存器就可以知道目標進程正在執行哪個系統調用了。
圖片
具體執行方式是調用 ptrace 系統調用。第一個參數設置為 PTRACE_PEEKUSER 表示要讀取目標進程的一些數據。第三個參數指定為 8*ORIG_RAX 表示要讀取 ORIG_RAX 寄存器。8*ORIG_RAX 計算出 ORIG_RAX 在用戶空間的偏移地址。
int main(int argc, char *argv[]) {
// 1.attach 到 pid 指定的目標進程上
...
while (1) {
// 2.等待目標進程的 PTRACE_SYSCALL
...
// 3.讀取并解析系統調用
// 3.1 讀取目標進程正在執行的系統調用號
syscall_number = ptrace(PTRACE_PEEKUSER, pid, 8 * ORIG_RAX, NULL); 、
// 3.2 將系統調用號轉為系統調用名稱
switch (syscall_number) {
case 5: syscall_name = "read"; break;
case 6: syscall_name = "write"; break;
case 10: syscall_name = "open"; break;
case 11: syscall_name = "close"; break;
......
default: syscall_name = "unknown"; break;
}
// 3.3 打印系統調用名稱
printf("Syscall: %s (number: %ld)\n", syscall_name, syscall_number);
}
}
內核執行 ptrace 系統調用的時候,會執行到 arch_ptrace 函數。
//file:arch/x86/kernel/ptrace.c
long arch_ptrace(struct task_struct *child, long request,
unsigned long addr, unsigned long data)
{
unsigned long __user *datap = (unsigned long __user *)data;
...
switch (request) {
...
case PTRACE_PEEKUSR: {
tmp = 0; /* Default return condition */
if (addr < sizeof(struct user_regs_struct))
tmp = getreg(child, addr);
else if (addr >= offsetof(struct user, u_debugreg[0]) &&
addr <= offsetof(struct user, u_debugreg[7])) {
addr -= offsetof(struct user, u_debugreg[0]);
tmp = ptrace_get_debugreg(child, addr / sizeof(data));
}
ret = put_user(tmp, datap);
break;
}
}
}
在 arch_ptrace 判斷是 PTRACE_PEEKUSER 參數,會在計算一下目標進程數據地址 addr,然后將其讀取出來設置到跟蹤進程的用戶空間中。這樣,就讀取到系統調用號了。
接下來在 /usr/include/x86_64-linux-gnu/asm/unistd_64.h 中可以查看到所有的系統調用號信息。將其轉為系統調用名后輸出即可。
五、總結
strace 命令跟蹤其它進程的系統調用的整個過程可以同下面一張圖來總結。
圖片
首先是 strace 進程執行下面三步操作:
- 調用 ptrace(ATTACH, ...) 設置關聯跟蹤進程和目標進程
- 再調用 ptrace(SYSCALL, ...) 設置要要跟蹤目標進程的系統調用
- 接著就調用 waitpid 去等待子進程的信號了,而先暫停執行了
再接下來當目標進程有系統調用發生時,
- 檢查當前進程是否被設置了 SYSCALL_TRACE 標記
- 如果有,那么設置一下當前進程的狀態,也暫停執行了
- 通過信號機制喚醒跟蹤進程
跟蹤進程收到信號后會繼續執行:
- 讀取目標進程 ORIG_RAX 寄存器,其中保存著目標進程的系統調用號
- 將系統調用號轉換成系統調用名輸出
再接下來再調用 wait_pid,讓目標進程繼續運行。整體進入一個不斷獲取,不斷打印的循環中。
從以上的執行過程可以看出。strace 命令執行的過程中,會讓目標進程執行到系統調用時暫停運行,從而導致比較頻繁的上下文切換,會增加目標進程 的運行時間。所以,如果是在生產環境中,使用 strace 命令的時候還是要小心一點。更萬萬不可當成一個長期的線上監控工具來使用。
不過,strace 命令還是非常簡單好用的,你說呢!?