开发内功修炼@张彦飞开发内功修炼@张彦飞

talk is cheap,
show me the code!

揭开 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配套源码

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 中可以查看到所有的系统调用号信息。将其转为系统调用名后输出即可。

整个程序是一个循环,每当目标程序有系统调用发生的时候,都会通知到当前程序。当前程序再将其正在执行的系统调用信息输出出来。这样就实现了对目标进程实时行为的动态跟踪。

图0.png

接下来我们分三个部分,从内核视角深入地探究一下底层工作原理。

二、attach 到目标进程

要想实现对目标程序的跟踪,首先第一步准备工作便是调用 ptrace 把自己 attach 到目标进程上。

int main(int argc, char *argv[]) {
    // 1.attach 到 pid 指定的目标进程上
    ptrace(PTRACE_ATTACH, pid, NULL, NULL)
    ...
}

图1.png

我们来看下这个所谓的 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)
        ...
    }
}

图2.png

在 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(&current->signal->wait_chldexit, &wo->child_wait);
...
}

当子进程退出时,内核会向父进程发送一个信号,父进程的信号处理程序会唤醒等待队列中的进程,使它们重新进入可运行状态,等待被调度器调度执行。

四、等待并读取目标进程系统调用

图3.png

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。

3.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;
}

这时,跟踪进程就知道目标进程有系统调用发生了。下一步就可以读取目标进程正在执行的系统调用信息了。

3.3 读取目标进程系统调用号

在 Linux 内核中,ORIG_RAX 寄存器用于保存进程在执行系统调用是的调用号。跟踪进程只需要访问下目标进程的 ORIG_RAX 寄存器就可以知道目标进程正在执行哪个系统调用了。

图4.png

具体执行方式是调用 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 命令跟踪其它进程的系统调用的整个过程可以同下面一张图来总结。

图5-summary.png

首先是 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 命令的时候还是要小心一点。更万万不可当成一个长期的线上监控工具来使用。

不过,strace 命令还是非常简单好用的,你说呢!?


我打造了一套更为完整的视频课程。总共包括了硬件原理、内存管理、进程管理、网络管理、文件系统、容器原理、性能观测、性能优化、调用原理、等几个大部分的内容。

其中有 5 个大部分已经更新完毕,下图中彩色部分为已更新内容,总时长 1900 多分钟。

剩下各个大部分还在持续更新中。

除了视频内容,星球里我也给大家开放各种电子 PDF、PPT 的下载。

目前我给大家仍然还开放 200 元的大额优惠券。加入后一年是 299 元。后期再续费目前是 6 折,也是 299。直接使用下方优惠券加入即可看上面的这些视频内容。

本原创文章未经允许不得转载 | 当前页面:开发内功修炼@张彦飞 » 揭开 strace 命令捕获系统调用的神秘面纱