ptrace系统调用的实现

最近遇到这样一个问题,机器跑着跑着画面冻结了,打开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_TRACEMEPTRACE_ATTACHPTRACE_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信号通知当前进程的parentreal_parent。最后调用freezable_schedule进行进程调度,将该进程停下来。

static void ptrace_stop(int exit_code, int why, int clear_code, siginfo_t *info)
	__releases(&current->sighand->siglock)
	__acquires(&current->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,主要是因为这部分和文章开头提到的问题不相干,所以就没有看这部分的源码,有兴趣的可以自己看一下。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章