启动调试:exec 启动并跟踪进程

实现目标:启动进程并attach

思考:如何让进程刚启动就停止?

前面小节介绍了通过 exec.Command(prog, args...)来启动一个进程,也介绍了通过ptrace系统调用attach一个运行中的进程。读者是否有疑问,这样启动调试的方式能满足调试要求吗?

当尝试attach一个运行中的进程时,进程正在执行的指令可能早已经越过了我们关心的位置。比如,我们想调试追踪下golang程序在执行main.main之前的初始化步骤,但是通过先启动程序再attach的方式无疑太滞后了,main.main可能早已经开始执行,甚至程序都已经执行结束了。

考虑到这,不禁要思索在“启动进程”小节的实现方式有没有问题。我们如何让进程在启动之后立即停下来等待调试呢?如果做不到这点,就很难做到高效的调试。

内核:启动进程时内核做了什么?

启动一个指定的进程归根究底是fork+exec的组合:

cmd := exec.Command(prog, args...)
cmd.Run()
  • cmd.Run()首先通过 fork创建一个子进程;
  • 然后子进程再通过 execve函数加载目标程序、运行;

但是如果只是这样的话,程序会立即执行,可能根本不会给我们预留调试的机会,甚至我们都来不及attach到进程添加断点,程序就执行结束了。

我们需要在cmd对应的目标程序指令在开始执行之前就立即停下来!要做到这一点,就要依靠ptrace操作 PTRACE_TRACEME

内核:PTRACE_TRACEME到底做了什么?

先使用c语言写个程序来简单说明下这一过程,为什么不用go语言示例呢?因为go运行时和标准库做了太多工作,此处使用c语言示例能更用最简单的篇幅展示关键步骤。在这之后我们还要介绍内核对PTRACE_TRACEME操作和exec操作的处理流程,加深理解,让大家知其然知其所以然。OK,看下这里的示例。

#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

// see /usr/include/sys/user.sh `struct user_regs_struct`
define ORIG_EAX_FIELD = 11
define ORIG_EAX_ALIGN = 8 // 8 for x86_64, 4 for x86

int main()
{   pid_t child;
    long orig_eax;
    child = fork();
    if(child == 0) {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execl("/bin/ls", "~", NULL);
    }
    else {
        wait(NULL);
        orig_eax = ptrace(PTRACE_PEEKUSER, child, (void *)(ORIG_EAX_FIELD * ORIG_EAX_ALIGN), (void *)NULL);
        printf("The child made a system call %ld\n", orig_eax);
        ptrace(PTRACE_CONT, child, NULL, NULL);
    }
    return 0;
}

上述示例中,首先父进程执行一次fork,fork返回值为0表示当前是子进程,子进程中执行一次 ptrace(PTRACE_TRACEME,...) 操作,下面是ptrace的定义,代码中省略了无关部分。当ptrace request为PTRACE_TRACEME,内核将更新当前进程 task_struct* current 的标记位 current->ptrace = PT_PTRACED。而该标记位将直接影响exec族函数的执行行为,exec族函数执行时会检查该标记位并做出相应处理。

file: /kernel/ptrace.c

// ptrace系统调用实现
SYSCALL_DEFINE4(ptrace, long, request, long, pid, unsigned long, addr,
        unsigned long, data)
{
    ...

    if (request == PTRACE_TRACEME) {
        ret = ptrace_traceme();
        ...
        goto out;
    }
    ...

 out:
    return ret;
}

/**
 * ptrace_traceme是对ptrace(PTRACE_PTRACEME,...)的一个简易包装函数,
 * 它执行检查并设置进程标识位PT_PTRACED.
 */
static int ptrace_traceme(void)
{
    ...
    /* Are we already being traced? */
    if (!current->ptrace) {
        ...
        if (!ret && !(current->real_parent->flags & PF_EXITING)) {
            current->ptrace = PT_PTRACED;
            ...
        }
    }
    ...
    return ret;
}

内核:PTRACE_TRACEME对execve影响?

c语言库函数中,常见的exec族函数包括execl、execlp、execle、execv、execvp、execvpe,这些都是由系统调用execve实现的。

系统调用execve的代码执行路径大致包括:

-> sys_execve
 |-> do_execve
   |-> do_execveat_common

函数do_execveat_common的代码执行路径大致如下,其作用是将当前进程的代码段、数据段 (初始化&未初始化数据) 用新加载的程序替换掉,然后执行新程序。

-> retval = bprm_mm_init(bprm);
 |-> retval = prepare_binprm(bprm);
   |-> retval = copy_strings_kernel(1, &bprm->filename, bprm);
     |-> retval = copy_strings(bprm->envc, envp, bprm);
       |-> retval = exec_binprm(bprm);
         |-> retval = copy_strings(bprm->argc, argv, bprm);

这里我们重点关注一下上述过程中 exec_binprm(bprm),这里包含了执行新程序的逻辑。

file: fs/exec.c

#define PTRACE_EVENT_EXEC   4

static int exec_binprm(struct linux_binprm *bprm)
{
    pid_t old_pid, old_vpid;
    int ret;

    /* Need to fetch pid before load_binary changes it */
    old_pid = current->pid;
    rcu_read_lock();
    old_vpid = task_pid_nr_ns(current, task_active_pid_ns(current->parent));
    rcu_read_unlock();

    ret = search_binary_handler(bprm);
    if (ret >= 0) {
        audit_bprm(bprm);
        trace_sched_process_exec(current, old_pid, bprm);
        ptrace_event(PTRACE_EVENT_EXEC, old_vpid);
        proc_exec_connector(current);
    }

    return ret;
}

这里 exec_binprm(bprm)内部调用了 ptrace_event(PTRACE_EVENT_EXEC, message),后者将对进程current->ptrace状态进行检查,并执行处理。

有两种可能的处理路径:

  • 如果发现当前tracee上被tracer设置了标记 current->ptrace & PT_EVENT_FLAG(event),此时就会执行 ptrace_notify((event<<8)|SIGTRAP) 来给tracee发送一个信号SIGTRAP;
  • 或者发现event==PTRACE_EVENT_EXEC,并且进程 current->ptrace & (PT_TRACED|PT_SEIZED) == PT_TRACED,内核将给进程发送一个SIGTRAP信号;

file: include/linux/ptrace.h

#define PTRACE_EVENT_EXEC   4

#define PT_OPT_FLAG_SHIFT   3
#define PT_EVENT_FLAG(event)    (1 << (PT_OPT_FLAG_SHIFT + (event))) // 1<<7

/**
 * ptrace_event - possibly stop for a ptrace event notification
 * @event:  %PTRACE_EVENT_* value to report
 * @message:    value for %PTRACE_GETEVENTMSG to return
 *
 * Check whether @event is enabled and, if so, report @event and @message
 * to the ptrace parent.
 *
 * Called without locks.
 */
static inline void ptrace_event(int event, unsigned long message)
{
    if (unlikely(ptrace_event_enabled(current, event))) {
        current->ptrace_message = message;
        ptrace_notify((event << 8) | SIGTRAP);
    } else if (event == PTRACE_EVENT_EXEC) {
        /* legacy EXEC report via SIGTRAP */
        if ((current->ptrace & (PT_PTRACED|PT_SEIZED)) == PT_PTRACED)
            send_sig(SIGTRAP, current, 0);
    }
}

/**
 * ptrace_event_enabled - test whether a ptrace event is enabled
 * @task: ptracee of interest
 * @event: %PTRACE_EVENT_* to test
 *
 * Test whether @event is enabled for ptracee @task.
 *
 * Returns %true if @event is enabled, %false otherwise.
 */
static inline bool ptrace_event_enabled(struct task_struct *task, int event)
{
    return task->ptrace & PT_EVENT_FLAG(event);
}

在Linux下面,SIGTRAP信号处理函数将使得进程暂停执行,并向父进程通知自身的状态变化,然后父进程通过wait系统调用来获取子进程状态的变化信息。ptrace_event中这两个分支都可以实现对tracee通知SIGTRAP的目的。结合我们的上述示例,子进程执行ptrace(PTRACE_TRACEME, ...) 操作,然后再执行execve替换掉代码段和数据段内容,最终实际上是通过第2个分支 send_sig(SIGTRAP, current, 0) 来发送SIGTRAP信号给tracee。

我们先展开第1个分支来看看:

  • 第1个分支什么时候会命中?被调试进程已经存在了,tracer先ptrace attach,然后再调用 ptrace(PTRACE_SETOPTIONS,...) 来设置PTRACE_EVENT_EXEC,目的是跟踪进程后续exec的行为; ps:再比如后面将提到的,可以设置选项来跟踪进程中新创建的线程,这个选项就是PTRACE_O_TRACECLONE。
  • 第2个分支什么时候会命中?这是对经典ptraceme操作的处理,tracer主动启动被调试程序,先fork出子进程,然后子进程执行 ptrace(PTRACE_TRACEME, ...),目的是让被调试进程启动后立即被跟踪;

我们简单看下这里的处理过程,内核在执行处理时,这两个分支有点微妙的区别。这里涉及到内核对几个关键信号SIGCHLD(通知调试器tracee已暂停)、SIGTRAP(内核发送给tracee)的使用及处理,以及对任务调度(被调试程序暂停调度)、任务唤醒的处理(唤醒调试器)。我们有必要介绍下这里的细节,让大家理解这里的一些区别。

分支1:跟踪已运行进程中后续exec行为
|-> ptrace_notify
|   |-> ptrace_do_notify
|   |   |-> ptrace_stop
|   |   |   |   // 设置一个不可调度状态
|   |   |   |-> set_special_state(TASK_TRACED);
|   |   |   |   // 通知tracer
|   |   |   |-> do_notify_parent_cldstop(current, true, why)
|   |   |   |      kernel_siginfo, info.si_signo=SIGCHLD, info.si_code = CLD_STOPPED 
|   |   |   |      __group_send_sig_info(SIGCHLD, &info, parent);
|   |   |   |      __wake_up_parent(tsk, parent);
|   |   |   |   // 禁用抢占,不允许被调度
|   |   |   |-> freezable_schedule()

ptrace_do_notify -> ptrace_stop -> do_notify_parent_cldstop(),这里的tracee通知tracer(或者父进程)我已经停下来了,会发送信号 SIGCHLD 的方式来通知tracer,但是这里的SIGCHLD不一定会生成,比如tracer实现中故意屏蔽SIGCHLD信号。所以,内核还有更保险的一个做法,__wake_up_parent(task, parent),在ptrace link关系中,这里的tsk就是tracee,parent就是tracer。

分支2:被调试进程启动后立即被跟踪

分支2的情况下,要先send_sig发送信号SIGTRAP给tracee。

|-> send_sig(SIGTRAP, current, 0)
    |-> send_sig_info(sig, __si_special(priv), p) 
        |-> do_send_sig_info(sig, info, p, PIDTYPE_PID)
            |-> send_signal(sig, info, p, type)
                |-> __send_signal(sig, info, t, type, force)
                    |-> signalfd_notify(t, sig) // linux内核允许通过fd来收信号
                    |-> sigaddset(&pending->signal, sig)
                    |-> complete_signal(sig, t, type);

这里得先介绍一点关于信号的预备知识,Linux中信号分为同步信号和异步信号:

  • 同步信号是指令执行时就同步生成的,表示发生了严重事件,通常是不能耽搁处理的,也不建议捕获后自定义信号处理函数,比如SIGSEGV、SIGTRAP等,因为处理不当可能问题更大。
  • 异步信号,这个是程序执行期间由外部操作生成的,如Ctrl+C生成的SIGINT、SIGTERM、SIGQUIT等,这类信号的处理等到程序执行系统调用返回用户态前处理即可,是可以捕获并自定义信号处理函数的;

ps: 但是go运行时有捕获部分SIGSEGV将其转为panic进行处理。

Linux定义的同步信号主要有下面几个,其中就有我们关心的SIGTRAP:

#define SYNCHRONOUS_MASK                                     \
    (sigmask(SIGSEGV) | sigmask(SIGBUS) | sigmask(SIGILL) |  \
     sigmask(SIGTRAP) | sigmask(SIGFPE) | sigmask(SIGSYS))

信号发送实际上就是在进程task_struct的信号相关队列里进行记录,它是发送给这个进程的,而不是特定的某个线程。进程中的任何一个线程在执行系统调用返回用户态前都有机会取走接收到的信号并处理。内核中的信号处理逻辑,专门会检查当前进程有没有设置ptraced状态,有就会优先执行对应的调试相关的特殊处理逻辑,比如暂停tracee执行并通知唤醒tracer。

下面来看看进一步的细节,这个在命中断点时,处理流程也是近似的。当前tracee线程是在执行系统调用execve哦,执行期间发现有task_struct->ptraced=true标识,然后内核为其生成了一个SIGTRAP信号,当tracee线程从系统调用返回用户程序之前,会有一个契机执行一遍信号处理。其实,就是一个get_signal、handle_signal的过程。对于SIGTRAP比较特殊,它是同步信号,会被优先出队并进行处理,并且会停止当前tracee调度,并通知唤醒tracer。

arch_do_signal_or_restart(struct pt_regs *regs, bool has_signal)
|-> get_signal
|   |   // 同步信号优先出队进行处理,比如SIGTRAP
|   |-> signr = dequeue_synchronous_signal(&ksig->info);
|   |
|   |   // 如果是ptrace SIGTRAP,那么立即去执行处理,
|   |   // - 先暂停tracee执行,
|   |   // - 再通知tracer
|   |-> if (unlikely(current->ptrace) && signr != SIGKILL)
|   |     signr = ptrace_signal(signr, &ksig->info);
|   |     |-> ptrace_stop(signr, CLD_TRAPPED, 0, info);
|   |     |   |   // 设置一个不可调度状态
|   |     |   |-> set_special_state(TASK_TRACED);
|   |     |   |   // 通知tracer
|   |     |   |-> do_notify_parent_cldstop(current, true, why)
|   |     |   |      kernel_siginfo, info.si_signo=SIGCHLD, info.si_code = CLD_STOPPED 
|   |     |   |      __group_send_sig_info(SIGCHLD, &info, parent);
|   |     |   |      __wake_up_parent(tsk, parent);
|   |     |   |   // 禁用抢占,不允许被调度
|   |     |   |-> freezable_schedule()
|   |-> else 如果是其他普通信号,且sig_handler != IGN,则转handle_signal执行
|
|-> handle_signal(struct ksignal *ksig, struct pt_regs *regs)
|   |   // 设置signal handler执行需要的栈帧
|   |-> failed = setup_rt_frame(ksig, oldset, regs)
|   |   // 切换上下文,执行对应的signal handler
|   |-> fpu__clear_user_states(fpu) // enter signal handler
|   |   // 执行信号处理函数结束
|   |-> signal_setup_done(failed, ksig, stepping);

OK,大致就是这样一个流程,大家能消化的了最好,消化不了知道个大概也不影响我们继续本节内容。

tracer从wait4中被唤醒

那么tracer(或者父进程)wait4 的实现,是怎么实现的呢? 我们这里也进行了一个精简版的总结:

  1. 简单来说,就是tracer或者父进程将自己加入一个等待子进程状态改变的等待队列中,然后将自己设置为可中断等待状态“INTERRUPTIBLE”,意思就是可以被信号唤醒,如SIGCHLD信号。
  2. 然后tracer就调用一次进程调度,让出CPU去等待了,直到tracee因为PTRACE_TRACEME停下来,给tracer发信号通知SIGCHLD or __wake_up_parent,此时tracer被唤醒。
  3. tracer此时会将自己从可中断等待状态“INTERRUPTIBLE”切换为“RUNNING”状态,从等待tracee状态改变的等待队列中移除,然后等待被scheduler调度。
  4. 当tracer被scheduler调度到之后,它就可以继续执行后续处理了。

最终,tracer从syscall.Wait4系统调用阻塞状态中唤醒,从wait4返回,就可以继续执行后续的其他ptrace操作了。

|-> wait4
      |-> kernel_wait4
            |-> do_wait

下面来详细看看:

SYSCALL_DEFINE4(wait4, pid_t, upid, int __user *, stat_addr,
        int, options, struct rusage __user *, ru)
{
    struct rusage r;
    long err = kernel_wait4(upid, stat_addr, options, ru ? &r : NULL);

    if (err > 0) {
        if (ru && copy_to_user(ru, &r, sizeof(struct rusage)))
            return -EFAULT;
    }
    return err;
}

long kernel_wait4(pid_t upid, int __user *stat_addr, int options,
          struct rusage *ru)
{
    ...
    ret = do_wait(&wo);
    ...
}

static long do_wait(struct wait_opts *wo)
{
    ...

    // 将当前线程加入到进程共享的等待队列中 (注意这个current->signal是进程专属字段,所有线程共享)
    add_wait_queue(&current->signal->wait_chldexit, &wo->child_wait);   

    do {
        // 将当前线程设置为可中断等待状态
        set_current_state(TASK_INTERRUPTIBLE);
        ...
        // 执行一轮调度,当前线程让出CPU进入等待
        schedule();
    } while (1);

    // 等到被唤醒并重新调度
    __set_current_state(TASK_RUNNING);
    remove_wait_queue(&current->signal->wait_chldexit, &wo->child_wait);
    return retval;
}

什么时候被唤醒呢?当tracee状态发生变化时显示通过 do_notify_parent_cldstop 通知时:

static void do_notify_parent_cldstop(struct task_struct *tsk, bool for_ptracer, int why)
{
    ...
    // 这个其实是发送给ptracer所属进程的,进程中任意一个线程均可以处理:
    // - 如果是untraced线程,可以处理信号;
    // - 如果是traced线程,会进入signal-deliver-sigstop,暂停tracee执行,
    //   ptracer可以通过PTRACE_RESTART操作的同时inject signal给tracee(此时可以更换成别的信号);
    //
    // 这个信号不一定总是生成,不一定能够唤醒ptracer进程上wait4阻塞调用!
    __group_send_sig_info(SIGCHLD, &info, parent); 

    // 唤醒ptracer进程上wait4阻塞调用,这个函数是最靠谱的,它直接唤醒parent上等待tsk状态变化的所有线程
    __wake_up_parent(tsk, parent);
}

void __wake_up_parent(struct task_struct *p, struct task_struct *parent)
{
    // 唤醒ptracer所属进程上正在通过wait4等待当前tracee状态改变的所有线程
    // parent->signal时是进程专属字段,所有线程共享
    __wake_up_sync_key(&parent->signal->wait_chldexit,
               TASK_INTERRUPTIBLE, p);
}

OK,以上提了下阻塞和唤醒的内核中的交互式过程。

父进程也可通过 ptrace(PTRACE_COND, pid, ...) 操作来恢复子进程执行,使其继续执行execve加载的新程序。

Put it Together

现在,我们结合上述示例,再来回顾一下整个过程、理顺一下。

首先,父进程调用fork、子进程创建成功之后是处于就绪态的,是可以运行的。然后,子进程先执行 ptrace(PTRACE_TRACEME, ...)告诉内核“当前进程希望在后续execve执行新程序时停下来,等待父进程的ptrace操作,所以请通知我在合适的时候停下来”。子进程执行execve加载新程序,重新初始化进程执行所需要的代码段、数据段等等。

重新初始化完成之前内核会将进程状态调整为“UnInterruptible Wait”阻止其被调度、响应外部信号,完成之后,再将其调整为“Interruptible Wait”,即可以被信号唤醒,意味着如果有信号到达,则允许进程对信号进行处理。

接下来,如果该进程没有特殊的ptrace状态,子进程状态将被更新为可运行等待下次调度。当内核发现这个子进程ptrace标记位为PT_PTRACED时,则会执行这样的逻辑:内核给这个子进程发送了一个SIGTRAP信号,该信号将被追加到进程的pending信号队列中,当内核任务调度器调度到该进程,该进程执行系统调用返回用户态之前,发现其有pending信号到达,将执行SIGTRAP的信号处理逻辑,只不过SIGTRAP比较特殊,作为一种同步信号会在进程从内核态返回用户态时立即被处理。

SIGTRAP信号处理具体做什么呢?它会暂停目标进程的执行,并按条件发送SIGCHLD信号向父进程通知自己的状态变化,最后会有wake_up_parent做兜底唤醒父进程。注意,在PTRACE_TRACEME的场景下,子进程调用 ptrace(PTRACE_TRACEME, ...) 后,父进程需要通过系统调用wait等待tracee停下来并获取进程状态。tracer调用wait会将tracer状态变为 "Interruptible Wait",当前tracer会被加入tracee进程状态变化的等待队列里。直到前面讲的内核处理tracee的SIGTRAP信号后将其停下来,然后发送SIGCHLD信号或wake_up_parent将tracer唤醒。

ps: 发送SIGCHLD信号唤醒tracer的方式不可靠,因为tracer进程有可能屏蔽该信号,所以最后有__wake_up_parent(...)方法直接将tracer唤醒这种方式兜底。

此时,tracer被唤醒,wait就可以返回子进程tracee的当前状态。tracer发现子进程tracee已经停下来(并且是因为SIGTRAP停下来),就可以发起后续调试命令对应的ptrace操作,如读写内存数据。

代码实现

src详见:golang-debugger-lessons/3_process_startattach。

类似c语言fork+exec的方式,go标准库提供了一个ForkExec函数实现,以此可以用go重写上述c语言示例。但是,go标准库提供了另一种更简洁的方式。

我们首先通过 cmd := exec.Command(prog, args...) 获取一个cmd对象,在 cmd.Start() 启动进程前打开进程标记位 cmd.SysProcAttr.Ptrace=true,然后再 cmd.Start()启动进程,最后调用 Wait函数来等待子进程(因为SIGTRAP)停下来并获取子进程的状态。

在这之后,父进程便可以继续做些调试相关的工作了,如读写内存等。

这里的示例代码,是在以前示例代码基础上修改得来,修改后代码如下:

package main

import (
    "fmt"
    "os"
    "os/exec"
    "runtime"
    "strconv"
    "strings"
    "syscall"
    "time"
)

const (
    usage = "Usage: ./godbg exec <path/to/prog>"

    cmdExec   = "exec"
    cmdAttach = "attach"
)

func main() {
    runtime.LockOSThread()

    if len(os.Args) < 3 {
        fmt.Fprintf(os.Stderr, "%s\n\n", usage)
        os.Exit(1)
    }
    cmd := os.Args[1]

    switch cmd {
    case cmdExec:
        args := os.Args[2:]
        fmt.Printf("exec %s\n", strings.Join(args, ""))

        if len(args) != 1 {
            fmt.Println("参数错误")
            os.Exit(1)
        }

        // start process but don't wait it finished
        progCmd := exec.Command(args[0])
        progCmd.Stdin = os.Stdin
        progCmd.Stdout = os.Stdout
        progCmd.Stderr = os.Stderr
        progCmd.SysProcAttr = &syscall.SysProcAttr{
            Ptrace: true,   // this implies PTRACE_TRACEME
        }

        if err := progCmd.Start(); err != nil {
            fmt.Println(err)
            os.Exit(1)
        }

        // wait target process stopped
        var (
            status syscall.WaitStatus
            rusage syscall.Rusage
        )
        pid := progCmd.Process.Pid
        if _, err := syscall.Wait4(pid, &status, syscall.WALL, &rusage); err != nil {
            fmt.Println(err)
            os.Exit(1)
        }
        fmt.Printf("process %d stopped:%v\n", pid, status.Stopped())
    case cmdAttach:
        // ...

    default:
        fmt.Fprintf(os.Stderr, "%s unknown cmd\n\n", cmd)
        os.Exit(1)
    }
}

其实这里通过设置进程启动选项ptrace=true的方式,到了标准库代码后,启动时也是类似我们c中处理的方式,先fork一个子进,然后子进程设置ptraceme,然后再execve。

func forkAndExecInChild1(argv0 *byte, argv, envv []*byte, chroot, dir *byte, attr *ProcAttr, sys *SysProcAttr, pipe int) (pid uintptr, pidfd int32, err1 Errno, mapPipe [2]int, locked bool) {
    // set clone flags
    flags = sys.Cloneflags
    if sys.Cloneflags&CLONE_NEWUSER == 0 && sys.Unshareflags&CLONE_NEWUSER == 0 {
        flags |= CLONE_VFORK | CLONE_VM
    }
    ...

    // clone child process
    if clone3 != nil {
        pid, err1 = rawVforkSyscall(_SYS_clone3, uintptr(unsafe.Pointer(clone3)), unsafe.Sizeof(*clone3), 0)
    } else {
        pid, err1 = rawVforkSyscall(SYS_CLONE, flags, 0, uintptr(unsafe.Pointer(&pidfd)))
    }
    ...

    // Enable tracing if requested.
    // Do this right before exec so that we don't unnecessarily trace the runtime
    // setting up after the fork. See issue #21428.
    if sys.Ptrace {
        _, _, err1 = RawSyscall(SYS_PTRACE, uintptr(PTRACE_TRACEME), 0, 0)
        ...
    }

    // Time to exec.
    _, _, err1 = RawSyscall(SYS_EXECVE,
        uintptr(unsafe.Pointer(argv0)),
        uintptr(unsafe.Pointer(&argv[0])),
        uintptr(unsafe.Pointer(&envv[0])))
    ...

代码测试

下面我们针对调整后的代码进行测试:

$ cd golang-debugger/lessons/0_godbg/godbg && go install -v
$
$ godbg exec ls
exec ls
process 2479 stopped:true
godbg> exit
cmd  go.mod  go.sum  LICENSE  main.go  syms  target

首先,我们进入示例代码目录编译安装godbg,然后运行 godbg exec ls,意图对PATH中可执行程序 ls进行调试。

godbg将启动ls进程,并通过PTRACE_TRACEME让内核把ls进程停下,可以看到调试器输出 process 2479 stopped:true,表示被调试进程pid是2479已经停止执行了。

并且还启动了一个调试回话,终端命令提示符应变成了 godbg>,表示调试会话正在等待用户输入调试命令,我们除了 exit命令还没有实现其他的调试命令,我们输入 exit 退出调试会话。

NOTE:关于调试会话

这里的调试会话,允许用户输入调试命令,用户所有的输入都会转交给cobra生成的debugRootCmd处理,debugRootCmd下包含了很多的subcmd,比如breakpoint、list、continue、step等调试命令。

在写这篇文档时,我们还是基于cobra-prompt来管理调试会话命令及输入补全的,将上述debugRootCmd交给cobra-prompt管理后,当我们输入一些信息后,prompt就会处理我们的输入并交给debugRootCmd注册的同名命令进行处理。

如我们输入了exit,则会调用debugRootCmd中注册的exitCmd进行处理。exitCmd只是执行os.Exit(0)让进程退出,在退出之前内核会自动做些清理操作,如正在被其跟踪的tracee会被内核执行ptrace(PTRACE_COND,...)解除跟踪,让tracee恢复执行。

当我们退出调试会话时,会通过 ptrace(PTRACE_COND,...) 操作来恢复被调试进程继续执行,也就是ls正常执行列出目录下文件的命令,我们也看到了它输出了当前目录下的文件信息 cmd go.mod go.sum LICENSE main.go syms target

godbg exec <prog> 命令现在一切正常了!

NOTE: 示例中程序退出时,没有显示调用 ptrace(PTRACE_COND,...) 来恢复tracee的执行。其实tracer退出时,如果某个目标线程还处于被跟踪状态,内核会自动解除tracee的跟踪状态,还它自由。

如果tracee是我们主动启动的(不是attach的已运行中进程),那么在调试器退出时允许选择是否kill掉该进程。

再次思考下,如果我们exec执行的是一个go程序,应该如何处理呢?前1节有提到过,如果目标进程中已创建多个线程,我们可以枚举 /proc/<pid>/task 下的线程列表逐个attach。但是对于新建的线程呢?从主线程启动到陆续创建出其他的gc、sysmon、执行众多goroutines的线程是有一个过程的,这个过程中我们如何感知有新线程创建并自动attach呢?难道要写个定时器频繁遍历 /proc/<pid>/tasks

我们必须精确到线程创建之初就立即跟踪它,然后根据它执行时的情况决定在后面何处设置断点,这样才更符合调试人员习惯。

这就涉及到ptrace attach的具体选项 PTRACE_O_TRACECLONE 了,添加了这个选项后,内核会在clone创建新线程时给新线程发送必要的信号SIGTRAP,等新线程创建完成并参与调度从内核态返回用户态时,就会执行信号处理,自然就会恰到好处地停下来。

man 2 ptrace

PTRACE_SETOPTIONS (since Linux 2.4.6; see BUGS for caveats)
   **PTRACE_O_TRACECLONE**:
   Stop the tracee at the next clone(2) and automatically start tracing the newly cloned process, which will start with a SIGSTOP, or PTRACE_EVENT_STOP if PTRACE_SEIZE was used.

在上述代码基础上做下列修改就可以搞定新线程创建时自动跟踪了:

pid := progCmd.Process.Pid
if _, err := syscall.Wait4(pid, &status, syscall.WALL, &rusage); err != nil {
   fmt.Println(err)
   os.Exit(1)
}
syscall.PtraceSetOptions(pid, syscall.PTRACE_O_TRACECLONE)

此时go程序中创建新线程时,Linux会执行到如下处理逻辑:

/*
 *  Ok, this is the main fork-routine.
 *
 * It copies the process, and if successful kick-starts
 * it and waits for it to finish using the VM if required.
 *
 * args->exit_signal is expected to be checked for sanity by the caller.
 */
pid_t kernel_clone(struct kernel_clone_args *args)
{
    // 创建出新线程
    p = copy_process(NULL, trace, NUMA_NO_NODE, args);
    pid = get_task_pid(p, PIDTYPE_PID);

    if (clone_flags & CLONE_VFORK) {
        p->vfork_done = &vfork;
        init_completion(&vfork);
        get_task_struct(p);
    }

    // housekeeping并将其加入调度器待调度的任务队列中
    wake_up_new_task(p);

    /* forking complete and child started to run, tell ptracer */
    // 给tracee发送信号SIGTRAP
    if (unlikely(trace))
        ptrace_event_pid(trace, pid);

    if (clone_flags & CLONE_VFORK) {
        if (!wait_for_vfork_done(p, &vfork))
            ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
    }
}

static inline void ptrace_event_pid(int event, struct pid *pid)
{
    ptrace_event(event, message);
}

static inline void ptrace_event(int event, unsigned long message)
{
    if (unlikely(ptrace_event_enabled(current, event))) {
        current->ptrace_message = message;
        ptrace_notify((event << 8) | SIGTRAP);
    } else if (event == PTRACE_EVENT_EXEC) {
        ...
    }
}

首先会创建一个线程,然后完成一些housekeeping逻辑并将新线程放入调度器的任务队列中,等待被调度,然后通过ptrace_event_pid发送SIGTRAP给这个新线程。OK,一切准备就绪,之后就是kernel_clone系统调用执行结束返回时,系统调用结束时就是内核发起新一轮调度的一个契机,如果新线程被调度器调度到、新线程返回用户态开始执行之前,首先就是要处理这个SIGTRAP信号,自然就会停下来并通知ptracer。通知ptracer的这部分我们前面已经提过了,这里不再赘述。

ps: 各种类型任务的切换时机,联想下:

  • 中断服务程序切换,指令周期的结束CPU会检查有没有新的中断控制器发来的中断请求;
  • 线程切换,操作系统内核系统调用时钟中断、系统调用返回之前会检查是否需要执行当前任务,还是切换到另一个任务,此时还会检查有没有pending信号要处理;
  • 协程切换,在goroutine进行网络IO、或者涉及到goroutines之间执行同步操作的交互逻辑时,检查是否需要暂停当前goroutine并调度其他goroutine来执行;

重新思考ptrace limit

前一节我们思考过ptrace limit,即当ptrace link建立后,debugger要发送给tracee的所有ptrace requests都只能通过tracer对应的线程来发送。

ptrace link建立的方式有两种: 1、一种是ptrace attach去跟踪已经运行中的线程; 2、一种是主动启动构建好的程序,通过fork+exec的方式,子进程中主动调用ptrace traceme;

这两种方式都需要考虑这里的ptrace约束:所有后续ptrace请求都只能通过一个ptracer发送。那就要求我们设计一个公共的helper函数,比如 func execPtraceAction(func () error) error,我们可以内部维护一个chan,这个chan会接受调用方发送来的所有ptrace操作函数,然后我们启动一个goroutine,这个goroutine一开始就设置好 runtime.LockOSThread() ,然后它慢慢消费这个ptrace操作函数队列,取出一个执行一个,并设置好是否发生了error。

var (
    once sync.Once
    reqCh = make(chan func() error, 1)
    doneCh = make(chan error, 1)
)

func execPtrace(fn func() error) error {
    once.Do(func() {
        go func() {
            // ensure all ptrace requests goes via the same tracer (thread)
            runtime.LockOSThread()
            defer runtime.UnlockOSThread()

            // polling ptrace actions and run them
            for {
                select {
                case req := <-reqCh:
                    req.errCh <- req.fn()
                case <-p.doneCh:
                    break
                }
            }
        }()
    })

    // submit ptrace action
    req := ptraceRequest{
        fn:    fn,
        errCh: make(chan error),
    }
    reqCh <- req
    // wait ptrace action finished
    return <-req.errCh
}

比较特殊的是,通先启动子进程,然后子进程主动调用ptrace traceme这种方式,执行启动子进程逻辑的线程自动就成了ptracer,所以为了保证后续ptrace requests能和这个ptracer是同一个线程上执行的。我们也要将启动子进程逻辑放在上述函数中执行。比如:

var err = execPtrace(func() error) {
    // start process but don't wait it finished
    progCmd := exec.Command(args[0])
    progCmd.Stdin = os.Stdin
    progCmd.Stdout = os.Stdout
    progCmd.Stderr = os.Stderr
    progCmd.SysProcAttr = &syscall.SysProcAttr{
        Ptrace: true,   // this implies PTRACE_TRACEME
    }

    return progCmd.Start()
})

ps:wait4不受这个ptrace limit限制,前面分析过了,ptracer所属进程中任意线程都可以调用wait4且能被正常唤醒。

本节小结

本节深入探讨了调试器对多线程程序进行跟踪的机制,第1节介绍了调试器如何启动程序,第2节介绍了如何attach到运行中的进程,而本节则重点阐述了如何在程序启动时立即发起跟踪,以及如何实现对未来新创建线程的自动跟踪。

启动时立即跟踪的关键:通过 PTRACE_TRACEME 机制,我们可以在程序执行第一条指令之前就将其暂停,这为调试器提供了在程序初始化阶段设置断点的机会。内核通过发送 SIGTRAP 信号来暂停被跟踪进程,并通过 SIGCHLD 信号或直接唤醒机制通知调试器。

多线程自动跟踪的实现:对于多线程程序的调试,关键在于及时设置 PTRACE_O_TRACECLONE 选项。当tracee执行完 PTRACE_TRACEME 并通知tracer后,tracer应立即调用 syscall.PtraceSetOptions(traceePID, syscall.PTRACE_O_TRACECLONE) 来配置跟踪选项。这样,当tracee内部创建新线程时,内核会自动跟踪新线程并通知tracer。通过递归应用这一机制,我们甚至可以实现对"新线程继续创建新线程"的跟踪,从而确保单进程多线程调试的完整覆盖。

内核层面的信号处理:本节还深入分析了Linux内核中信号处理的细节,包括同步信号与异步信号的区别、SIGTRAP 信号的特殊处理机制,以及内核如何通过来实现进程暂停和通知唤醒机制。

通过本节的学习,读者不仅掌握了调试器开发中进程跟踪的核心技术,还深入理解了Linux操作系统在信号处理和进程管理方面的底层实现细节。现在我们已经掌握了如何跟踪程序,接下来就应该建立调试会话,调试会话中维护了一系列实用的调试命令,我们可以通过添加断点、执行到断点、打印变量、寄存器、线程切换、协程切换等等调试命令来自由调试。下一节我们将学习如何建立一个既实用便捷又可以灵活扩展调试命令的调试会话。

参考内容

results matching ""

    No results matching ""