最近遇到這樣一個問題,機器跑着跑着畫面凍結了,打開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,主要是因爲這部分和文章開頭提到的問題不相干,所以就沒有看這部分的源碼,有興趣的可以自己看一下。