在性能观测领域,strace 命令是一个虽然很古老,但很常用的命令。使用它我们可以非常方便地观察某个进程正在执行什么系统调用。
这个命令的使用方式也很简单,想观察哪个进程,直接将其 pid 作为参数传给 strace 命令即可。
然而我们都知道,正常来讲操作系统中的各个进程之间是互相隔离的。那么 strace 命令是如何做到能获取其他进程执行的系统调用信息的呢,我们今天就来揭开这个谜底。
一、手工实现一个 strace
要想理解清楚 strace 命令原理,我想最有效的办法是我们自己亲手写一个简单程序来模拟 strace 的工作过程。
为了方便大家理解,我这里只把这个程序的核心逻辑列出来。完整的程序源码请大家查看strace配套源码 https://github.com/yanfeizhang/coder-kung-fu/blob/main/tests/cpu/test11/main.c
通过上面二十多行核心代码,我们就实现了一个模拟 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 到目标进程上。
我们来看下这个所谓的 attach ,在 Linux 内部究竟是干了点啥。找来 ptrace 系统调用的源码。
在 ptrace 系统调用源码中,第一步比较简单,根据参数中的 pid 查找目标进程在内核中的 task_struct 内核对象。第二步操作中的 ptrace_attach,是 attach 到目标进程的核心函数。
在 ptrace_attach 中先要进行一些权限检查,例如内核线程是不允许被 attach 的。接着调用 ptrace_link 来修改当前进程,以及要跟踪的目标进程的内核对象相关字段。ptrace_link 的主要实现是 __ptrace_link。
在 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 内核,要等待和捕获目标进程的系统调用。
在 ptrace 系统调用中,由于这次传入的第一个参数是 PTRACE_SYSCALL。所以其执行的核心函数提炼后如下所示:
首先还是先根据 pid 获取目标进程的 task_struct 内核对象。接着执行 arch_ptrace 来为目标进程内核对象添加一个标记 SYSCALL_TRACE。具体设置是在 ptrace_resume 函数中执行的( arch_ptrace -> ptrace_request -> ptrace_resume )。我们直接来看 ptrace_resume 源码。
set_task_syscall_work 函数就是在给目标进程的设置了一个 SYSCALL_TRACE 标记位。
这样后面当该进程再执行系统调用的时候,通过判断该标记就能发现有进程在跟踪它了。
3.2 等待目标进程信号发生
当前进程在设置完要对目标进程的 SYSCALL 进行观察后,接着就调用 waitpid 进入了睡眠状态。
waitpid 也是一个系统调用。
在这个系统调用中,依次调用 kernel_wait4、do_wait 等内核函数,最后在 add_wait_queue 函数中,将当前进程加入到等待队列中,更新进程状态为 TASK_INTERRUPTIBLE(可中断睡眠状态),等待子进程信号。
当子进程退出时,内核会向父进程发送一个信号,父进程的信号处理程序会唤醒等待队列中的进程,使它们重新进入可运行状态,等待被调度器调度执行。
四、等待并读取目标进程系统调用
4.1 目标进程系统调用发生
当目标进程系统调用发生的时候,会检查是否有被设置 SYSCALL_TRACE 标记位。如果有,那就说明有进程正在跟踪它。具体的检测是在 syscall_trace_enter 内核函数中做的
如果有 SYSCALL_TRACE 标志,那就会设置一下退出码,发出 SIGTRAP 信号,唤醒正在追踪它的进程,最后暂停当前程序的运行。具体的内核函数调用过程是经过 ptrace_report_syscall_entry -> ptrace_report_syscall -> ptrace_notify -> ptrace_do_notify 这么一条长的调用链后,最终在 ptrace_stop 内核函数中执行的。我们直接来看这个最关键的 ptrace_stop 函数。
在这个函数中做了这么几件事情。
第一,调用 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 函数中返回。
这时,跟踪进程就知道目标进程有系统调用发生了。下一步就可以读取目标进程正在执行的系统调用信息了。
4.3 读取目标进程系统调用号
在 Linux 内核中,ORIG_RAX 寄存器用于保存进程在执行系统调用是的调用号。跟踪进程只需要访问下目标进程的 ORIG_RAX 寄存器就可以知道目标进程正在执行哪个系统调用了。
图片
具体执行方式是调用 ptrace 系统调用。第一个参数设置为 PTRACE_PEEKUSER 表示要读取目标进程的一些数据。第三个参数指定为 8*ORIG_RAX 表示要读取 ORIG_RAX 寄存器。8*ORIG_RAX 计算出 ORIG_RAX 在用户空间的偏移地址。
内核执行 ptrace 系统调用的时候,会执行到 arch_ptrace 函数。
在 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 命令还是非常简单好用的,你说呢!?