大家好,我是飞哥!
在性能观测领域,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 进程执行下面三步操作
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 命令的时候还是要小心一点。更万万不可当成一个长期的线上监控工具来使用。