揭开 strace 命令捕获系统调用的神秘面纱

文摘   2024-12-14 11:12   陕西  

大家好,我是飞哥!

在性能观测领域,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, NULLNULL)

 while (1) {
  // 2.等待目标进程的 PTRACE_SYSCALL
  // 2.1 指定要捕获目标进程的 PTRACE_SYSCALL
  ptrace(PTRACE_SYSCALL, pid, NULLNULL)
  // 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, NULLNULL)
 ...
}

我们来看下这个所谓的 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, NULLNULL)
  // 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, NULLNULL)
  // 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(&current->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(&current->sighand->siglock)
 __acquires(&current->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 进程执行下面三步操作

  • 1.1 调用 ptrace(ATTACH, ...) 设置关联跟踪进程和目标进程
  • 1.2 再调用 ptrace(SYSCALL, ...) 设置要要跟踪目标进程的系统调用
  • 1.3 接着就调用 waitpid 去等待子进程的信号了,而先暂停执行了

再接下来当目标进程有系统调用发生时,

  • 2.1 检查当前进程是否被设置了 SYSCALL_TRACE 标记
  • 2.2 如果有,那么设置一下当前进程的状态,也暂停执行了
  • 2.3 通过信号机制唤醒跟踪进程

跟踪进程收到信号后会继续执行

  • 1.4 读取目标进程 ORIG_RAX 寄存器,其中保存着目标进程的系统调用号
  • 1.5 将系统调用号转换成系统调用名输出

再接下来再调用 wait_pid,让目标进程继续运行。整体进入一个不断获取,不断打印的循环中。

从以上的执行过程可以看出。strace 命令执行的过程中,会让目标进程执行到系统调用时暂停运行,从而导致比较频繁的上下文切换,会增加目标进程 的运行时间。所以,如果是在生产环境中,使用 strace 命令的时候还是要小心一点。更万万不可当成一个长期的线上监控工具来使用。



Linux内核之旅
Linux内核之旅
 最新文章