最近遇到这样一个问题,机器跑着跑着画面冻结了,打开top
看到Xorg
的cpu占用率100%。想用gdb
挂上去看一下,结果gdb
一直卡着挂不上去。后来又换用perf
分析,结果发现进程99%的时间花在了一个ioctl
调用。这个ioctl
操作的是nvidia显卡,进程实际上是卡在了nvidia的驱动中。
我对gdb
挂不上去这件事感到很好奇,之前除了因为进程已经被另一个gdb调试而导致gdb挂不上去之外,还没有遇到过这种情况,所以看了内核源码分析了一下。
先说结论,为什么此时gdb
挂不上去呢?简单的来说就是:调试器(tracer)是基于ptrace
实现的,使用ptrace
连接(attach)被调试进程(tracee)时,会向tracee发送一个SIGSTOP
信号,让tracee停下来,后续的调试工作要等tracee停下来才能继续进行。而tracee能够停下来又要依赖对信号的响应,但是进程只有在从内核空间返回用户空间时,才会检查是否有待处理的信号,并进行响应。而如果进程一直卡在内核空间的话,就无法返回用户空间,所以就无法响应信号,导致进程无法停止。这样的话,tracer就只能一直等着tracee,无法进行调试。
上面是概要的结论,下面对ptrace
系统调用的基本流程分析一下,由于ptrace
的实现细节非常多,所以此处只是其大致框架。我使用的内核版本是4.16,系统架构为64位x86。为了与文档和代码中的术语保持一致,不再使用“调试”这个词,而是使用“跟踪”(trace)。(“跟踪”实际上是手段,“调试”是目的)
ptrace操作的环境
除了PTRACE_TRACEME
、PTRACE_ATTACH
和PTRACE_SEIZE
这些用于本身就用于建立ptrace
操作环境的请求之外,其他请求在执行之前都会检查ptrace
环境是否已经建立。
SYSCALL_DEFINE4(ptrace, long, request, long, pid, unsigned long, addr,
unsigned long, data)
{
......
ret = ptrace_check_attach(child, request == PTRACE_KILL ||
request == PTRACE_INTERRUPT);
if (ret < 0)
goto out_put_task_struct;
......
}
ptrace_check_attach
函数就是用来检查ptrace
环境的。ptrace
环境有两部分:一部分是要求tracer和tracee有跟踪关系,另一部分是要求tracee处于被跟踪的停止状态。后一部分在请求为PTRACE_KILL
或者PTRACE_INTERRUPT
时不需要保证。
static int ptrace_check_attach(struct task_struct *child, bool ignore_state)
{
int ret = -ESRCH;
/*
* We take the read lock around doing both checks to close a
* possible race where someone else was tracing our child and
* detached between these two checks. After this locked check,
* we are sure that this is our traced child and that can only
* be changed by us so it's not changing right after this.
*/
read_lock(&tasklist_lock);
if (child->ptrace && child->parent == current) {
WARN_ON(child->state == __TASK_TRACED);
/*
* child->sighand can't be NULL, release_task()
* does ptrace_unlink() before __exit_signal().
*/
if (ignore_state || ptrace_freeze_traced(child))
ret = 0;
}
read_unlock(&tasklist_lock);
if (!ret && !ignore_state) {
if (!wait_task_inactive(child, __TASK_TRACED)) {
/*
* This can only happen if may_ptrace_stop() fails and
* ptrace_stop() changes ->state back to TASK_RUNNING,
* so we should not worry about leaking __TASK_TRACED.
*/
WARN_ON(child->state == __TASK_TRACED);
ret = -ESRCH;
}
}
return ret;
}
上面的child->ptrace && child->parent == current
用来保证tracer和tracee的跟踪关系,而ptrace_freeze_traced
函数用来测试tracee是否处于期望的状态,即tracee的状态有__TASK_TRACED
标志位,且当前没有未处理的SIGKILL
信号。
static bool ptrace_freeze_traced(struct task_struct *task)
{
bool ret = false;
/* Lockless, nobody but us can set this flag */
if (task->jobctl & JOBCTL_LISTENING)
return ret;
spin_lock_irq(&task->sighand->siglock);
if (task_is_traced(task) && !__fatal_signal_pending(task)) {
task->state = __TASK_TRACED;
ret = true;
}
spin_unlock_irq(&task->sighand->siglock);
return ret;
}
下面来分析如何建立ptrace环境。
tracer和tracee的跟踪状态
进程tracer首先要成为进程tracee的跟踪进程,即要满足:tracee->ptrace && tracee->parent == tracer
。如何做到呢?有两种方式:一种情况是tracer是tracee的父进程,进程tracee主动调用ptrace(PTRACE_TRACEME, 0, 0, 0)
,另外一种是进程tracer调用ptrace(PTRACE_ATTACH, tracee->pid, 0, 0)
。我们来看一下这两种方式的流程。
ptrace(PTRACE_TRACEME, 0, 0, 0)
在ptrace
系统调用中,当请求为PTRACE_TRACEME
时,会直接调用ptrace_traceme
。
SYSCALL_DEFINE4(ptrace, long, request, long, pid, unsigned long, addr,
unsigned long, data)
{
......
if (request == PTRACE_TRACEME) {
ret = ptrace_traceme();
......
}
......
}
在ptrace_traceme
函数中,首先要检查现在没有其他进程跟踪当前进程,然后进行权限检查。都没有问题的话,将当前进程ptrace
字段置为PT_PTRACED
,然后调用ptrace_link
,而ptrace_link
最终调用了__ptrace_link
,最终将父进程设为了跟踪进程。
static int ptrace_traceme(void)
{
int ret = -EPERM;
write_lock_irq(&tasklist_lock);
/* Are we already being traced? */
if (!current->ptrace) {
ret = security_ptrace_traceme(current->parent);
/*
* Check PF_EXITING to ensure ->real_parent has not passed
* exit_ptrace(). Otherwise we don't report the error but
* pretend ->real_parent untraces us right after return.
*/
if (!ret && !(current->real_parent->flags & PF_EXITING)) {
current->ptrace = PT_PTRACED;
ptrace_link(current, current->real_parent);
}
}
write_unlock_irq(&tasklist_lock);
return ret;
}
__ptrace_link
首先将tracee放到tracer的ptraced
列表中,然后将tracee的parent
字段置为tracer。(请注意区分parent
字段和real_parent
字段)。
void __ptrace_link(struct task_struct *child, struct task_struct *new_parent,
const struct cred *ptracer_cred)
{
BUG_ON(!list_empty(&child->ptrace_entry));
list_add(&child->ptrace_entry, &new_parent->ptraced);
child->parent = new_parent;
child->ptracer_cred = get_cred(ptracer_cred);
}
ptrace(PTRACE_ATTACH, tracee->pid, 0, 0)
tracer跟踪tracee的另一种方式是使用PTRACE_ATTACH
(或PTRACE_SEIZE
)请求。与处理PTRACE_TRACEME
请求的ptrace_traceme
函数类似,处理PTRACE_ATTACH
请求时会调用ptrace_attach
函数。ptrace_attach
代码行数较ptrace_traceme
长不少,因为要做大量的合法性检查:tracer和tracee可能是同一个进程,tracee可能是个内核线程,也可能是其他用户的进程,等等有各种意外情况。但如果没问题的话,最终也是调用了ptrace_link
将tracer设置为tracee的跟踪进程,在此不再赘述。有一个不一样的地方是,处理PTRACE_ATTACH
请求时会向tracee发送一个SIGSTOP
信号。
至此,tracer就成为了tracee的跟踪进程,但此时tracer还不能立即对tracee使用ptrace
请求,还有另一个前提条件:tracee已经停止运行,即state字段中__TASK_TRACED
被置位。
使tracee停止运行
内核何时对tracee的state
字段的__TASK_TRACED
标志位进行置位呢?是在tracee处理除SIGKILL
信号以外的任何信号时做的。
进程在从内核空间返回用户空间时会检查是否有挂起信号,如果有的话,调用do_signal
进行处理。
static void exit_to_usermode_loop(struct pt_regs *regs, u32 cached_flags)
{
......
/* deal with pending signal delivery */
if (cached_flags & _TIF_SIGPENDING)
do_signal(regs);
......
}
do_signal
需要调用get_signal
来填充一个ksignal结构体。
void do_signal(struct pt_regs *regs)
{
......
if (get_signal(&ksig)) {
/* Whee! Actually deliver the signal. */
handle_signal(&ksig, regs);
return;
}
......
}
在get_signal
过程中,会检查当前进程是否处于被ptrace
跟踪的状态,如果是的话,且当前信号不是SIGKILL
,则会调用ptrace_signal
。
int get_signal(struct ksignal *ksig)
{
......
if (unlikely(current->ptrace) && signr != SIGKILL) {
signr = ptrace_signal(signr, &ksig->info);
if (!signr)
continue;
}
......
}
ptrace_signal
调用ptrace_stop
来使当前进程停下来。
static int ptrace_signal(int signr, siginfo_t *info)
{
......
ptrace_stop(signr, CLD_TRAPPED, 0, info);
......
}
ptrace_stop
首先将进程状态设置为TASK_TRACED
,这样的话,下次进行进程调度时就不会调度该进程了。设置完进程状态后,会发送SIGCHLD
信号通知当前进程的parent
和real_parent
。最后调用freezable_schedule
进行进程调度,将该进程停下来。
static void ptrace_stop(int exit_code, int why, int clear_code, siginfo_t *info)
__releases(¤t->sighand->siglock)
__acquires(¤t->sighand->siglock)
{
......
set_current_state(TASK_TRACED);
......
do_notify_parent_cldstop(current, true, why);
if (gstop_done && ptrace_reparented(current))
do_notify_parent_cldstop(current, false, why);
......
freezable_schedule();
......
}
为了能让tracee停止运行,信号是必不可少的,无论是PTRACE_ATTACH
请求、breakpoint或者是watchpoint,都依赖于给tracee发送一个信号,同时tracee还要有机会去检查当前有哪些挂起的信号。就像文章一开始提到的问题,进程一直卡在内核空间,没有机会在返回用户空间时检查当前挂起的信号,也就无法停止运行了。
至此ptrace
请求的前置条件就完全满足了,可以使用其他ptrace
请求了。
其他的ptrace
请求无非是读写tracee的task_struct
、内存和寄存器了,似乎没什么太特别的地方,也就不再分析了。
结束语
以上就是ptrace系统调用实现的大致原理。不过有一部分没有提到,就是tracer与tracee如何detach,主要是因为这部分和文章开头提到的问题不相干,所以就没有看这部分的源码,有兴趣的可以自己看一下。