启动&Attach进程

实现目标:启动进程并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语言写个程序来简单说明下这一过程,在这之后我们还要看些内核代码,加深对PTRACE_TRACEME操作以及进程启动过程的理解,这些代码是c语言实现的,这个简短的示例使用c语言实现也是为了让读者提前联想一下c语言的语法。

#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <linux/user.h>   /* For constants ORIG_EAX etc */
int main()
{   pid_t child;
    long orig_eax;
    child = fork();
    if(child == 0) {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execl("/bin/ls", "ls", NULL);
    }
    else {
        wait(NULL);
        orig_eax = ptrace(PTRACE_PEEKUSER, child, 4 * ORIG_EAX, gg);
        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

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

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),后者将对进程ptrace状态进行检查,一旦发现进程ptrace标记位设置了PT_PTRACED,内核将给进程发送一个SIGTRAP信号,由此转入SIGTRAP的信号处理逻辑。

file: include/linux/ptrace.h

/**
 * 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);
    }
}

在Linux下面,SIGTRAP信号将使得进程暂停执行,并向父进程通知自身的状态变化,父进程通过wait系统调用来获取子进程状态的变化信息。

父进程也可通过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信号处理具体做什么呢?它会暂停目标进程的执行,并向父进程通知自己的状态变化,此时父进程通过系统调用wait就可以获取到子进程状态变化的情况。一旦父进程tracer发现子进程tracee已经停下来,就可以发起后续的ptrace操作,如读写内存数据。

代码实现

src详见:golang-debugger-lessons/1.2_cmd_exec+attach

类似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: go run main.go 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,
        }

        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)
    }
}

代码测试

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

$ 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进程停下(通过SIGTRAP),可以看到调试器输出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退出时,如果其trace的tracee还在,内核会自动解除tracee的跟踪状态。

如果tracee如果是我们显示启动的(不是attach的),那么在调试器退出时应该kill掉该进程(或者允许选择kill进程或让其继续执行),而不应该默认让其继续执行。

再次思考下,如果我们exec执行的是一个go程序,应该如何处理呢?因为go程序天然是多线程程序,从其主线程启动到陆续创建出其他的gc、sysmon、执行众多goroutines的线程是有一个过程的,那么这个过程中我们是很难人为去感知的,调试器如何对这个过程中创建的诸多线程自动发起ptrace attach呢?

没有什么好办法,调试器作为一个普通用户态程序,只能请求操作系统提供的服务代为处理,这就涉及到ptrace attach的具体选项PTRACE_O_TRACECLONE了,添加了这个选项内核会在clone创建新线程时给新线程发送必要的信号,等新线程调度时自然会停下来。

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.

本节小结

本节实现了一个完整的“启动、跟踪”的实现原理、代码解释、示例演示。本节用到了start+attach或exec+attach的表述,这样做只是为了让章节内容组织上突出层层递进的关系。

严格来说,我们应该用trace代替attach的表述。因为attach会让读者误以为技术上时通过tracer 主动 ptrace(PTRACE_ATTACH,)实现的。其实是 tracee 主动ptrace(PTRACE_TRACEME,)实现的。另外对于多线程调试,如果希望新创建出来的线程自动被trace,也不是tracer主动发起的,而是提前给tracee设置TRACECLONE选项实现的。

参考内容

results matching ""

    No results matching ""