赞
踩
在开发过程中我们都会使用 gdb 进行调试程序,那么当我们使用 gdb 进行调试程序的时候,底层发生了什么?
今天通过分析 ptrace 系统调用来分析下 gdb 的底层实现原理。
ptrace 是操作系统提供的一个用于跟踪进程的系统调用。通过 ptrace 系统调用可以获取被跟踪进程的进程状态。
比如我们常用的获取可执行文件执行时都进行了哪些系统调用的 strace 命令和我们常使用的调试工具 gdb 等,他们都是通过使用 ptrace 进行实现的。
在使用 gdb 进行本地跟踪某个进程时,常使用方式有 2 种:
1、gdb 可执行文件,被跟踪进程从头开始执行,也即是使用 PTRACE_TRACEME 来使自己进入被跟踪模式。
2、gdb attach 进程 id,跟踪一个已经运行的进程,也即是使用 PTRACE_ATTACH 来使指定的进程进入被跟踪模式。
**
**
ptrace 系统调用如下
#include <sys/ptrace.h>
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);
ptrace 的各个参数如下:
request: 指定调试的指令,比如:PTRACE_PEEKDATA、PTRACE_SYSCALL、PTRACE_CONT、PTRACE_KILL、PTRACE_ATTACH 等等。
pid : 进程的 id。
addr:进程的某个地址空间,可以通过该参数对进程的某个地址进行读和写。
data:根据 request 的不同而变化,如果需要向目标进程中写入数据,data存放的是需要写入的数据;如果从目标进程中读数据,data将存放返回的数据。
下面通过一个例子来说明 ptrace 系统调用的使用方式。
该例子通过使用ptrace系统调用用来获取子进程执行一个可执行文件时都进行了哪些系统调用,返回系统调用的 id 号和返回值,类似于 strace 命令。
#include <stdio.h>
#include <sys/ptrace.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <sys/reg.h>
int main(int argc, char *argv[])
{
pid_t pid = fork();
if(pid < 0)
{
printf(“fork failed\n”);
exit(-1);
}else if(pid == 0){ // 子进程 //告诉内核本进程被父进程进行跟踪 ptrace(PTRACE_TRACEME, 0, NULL, NULL); execve("argv[1]", NULL, NULL); } else { // 父进程 int status; int flag = 1; long num; long ret; //父进程中则使用wait系统调用等待子进程的状态改变 wait(&status); if(WIFEXITED(status)) { return 0; } //获取子进程系统调用号 num = ptrace(PTRACE_PEEKUSER, pid, ORIG_RAX * 8, NULL); printf("system call num = %ld\n", num); //PTRACE_SYSCALL 会使得每次子进程进行系统调用或系统调用退出时被暂停,内核会给父进程发信号,父进程可以获取子进程的状态变化 ptrace(PTRACE_SYSCALL, pid, NULL, NULL); while(1){ //等待子进程发送 SIGCHLD 信号 wait(&status); if(WIFEXITED(status)) return 0; if(flag ){ //获取系统调用号 num = ; printf("system call num = %ld", num); flag = 0; }else { //获取系统调用返回值 ret = ptrace(PTREACE_PEEKUSER, pid, RAX * 8, NULL); printf("system call return = %ld\n", ret); flag = 1; } ptrace(PTRACE_SYSCALL, pid, NULL, NULL); } }
}
上面的程序流程如下:
主进程 fork 一个子进程。
子进程通过 ptrace(PTRACE_TRACEME, 0, NULL, NULL) 把自己设置为被跟踪状态,然后子进程调用 execve() 进行加载可执行文件,子进程在运行前向父进程发送一个 SIGCHLD 信号,并暂停本进程。
父进程收到信息号后,调用 ptrace(PTRACE_PEEKUSER, pid, ORIG_RAX * 8, NULL) 获取子进程的系统调用号。然后调用 ptrace(PTRACE_SYSCALL, pid, NULL, NULL) 来跟踪子进程系统调用,也即是子进程每次进行系统调用前和调用后都会暂停本进程并通知父进程,父进程即可获取子进程的系统调用 id 和系统调用的返回值。
父进程在被子进程每次进行系统调用前和调用后唤醒后,通过调用 ptrace(PTRACE_PEEKUSER, pid, xx, NULL) 获取寄存器中的值。
ptrace 函数最终会调用 sys_ptrace 内核函数,而 sys_ptrace 的实现就是通过 switch 根据 request 的不同而进行不同的处理。
asmlinkage int sys_ptrace(long request, long pid, long addr, long data)
{
…
if (request == PTRACE_TRACEME) { /* are we already being traced? */ if (current->flags & PF_PTRACED) return -EPERM; /* set the ptrace bit in the process flags. */ current->flags |= PF_PTRACED; return 0; } //通过id获取对应的进程 if (!(child = get_task(pid))) return -ESRCH; switch (request) { case PTRACE_PEEKTEXT: /* read word at location addr. */ case PTRACE_PEEKDATA: { ... } case PTRACE_PEEKUSR: { ... } /* when I and D space are separate, this will have to be fixed. */ case PTRACE_POKETEXT: /* write the word at location addr. */ case PTRACE_POKEDATA: ... case PTRACE_POKEUSR: /* write the word at location addr in the USER area */ ... case PTRACE_SYSCALL: /* continue and stop at next (return from) syscall */ case PTRACE_CONT: { /* restart after signal. */ ... } case PTRACE_KILL: { ... } case PTRACE_SINGLESTEP: { /* set the trap flag. */ ... } case PTRACE_DETACH: { /* detach a process that was attached. */ ... } default: return -EIO; }
}
在 sys_ptrace 中,首先通过进程 pid 来获取进程的 task_struct 内核结构。然后根据传入的参数 request 来对进程进行不同的操作。
使用 PTRACE_TRACEME 进入被跟踪模式
该该方式中 gdb 进程会 fork 一个子进程,然后子进程执行可执行文件进行运行,然后 gdb 进程对新运行的进程进行跟踪。
执行过程如下:
if(pid == 0){
//告诉内核本进程被父进程进行跟踪
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
//加载执行文件
execve(“argv[1]”, NULL, NULL);
}
子进程先设置状态为被跟踪状态,然后使用 execv 系列函数进行加载可执行文件进行运行。
ptrace 会调用 sys_ptrace 系统调用。
asmlinkage int sys_ptrace(long request, long pid, long addr, long data)
{
struct task_struct *child;
struct user * dummy;
int i;
dummy = NULL;
if (request == PTRACE_TRACEME) {
/* are we already being traced? */
if (current->flags & PF_PTRACED)
return -EPERM;
/* set the ptrace bit in the process flags. */
current->flags |= PF_PTRACED;
return 0;
}
...
}
当进程调用者使用 PTRACE_TRACEME 时,会把当前进程状态设置为被跟踪者 PF_PTRACED,然后退出。
接下来进程调用 execv 系列函数进行加载可执行文件进行运行,具体实现本文不再具体分析,可以查看《Linux 可执行文件程序载入和执行过程》。
execv 进行加载可执行文件时,会调用 load_elf_binary ,在该函数的最后会进行判断当前进程状态,若设置了 PF_PTRACED,则给当前进程发送一个 SIGTRAP 信号。
static int load_elf_binary(struct linux_binprm * bprm, struct pt_regs * regs)
{
…
if (current->flags & PF_PTRACED)
send_sig(SIGTRAP, current, 0);
return 0;
}
进程收到信号后, 会调用 notify_parent 通知父进程。
asmlinkage int do_signal(unsigned long oldmask, struct pt_regs * regs)
{
…
while ((signr = current->signal & mask)) {
...
if ((current->flags & PF_PTRACED) && signr != SIGKILL) {
current->exit_code = signr;
//设置本进程为暂停状态
current->state = TASK_STOPPED;
//通知父进程
notify_parent(current);
//调度其他进程
schedule();
...
}
}
...
}
void notify_parent(struct task_struct * tsk)
{
if (tsk->p_pptr == task[1])
tsk->exit_signal = SIGCHLD;
//向父进程发送 SIGCHLD 信号
send_sig(tsk->exit_signal, tsk->p_pptr, 1);
wake_up_interruptible(&tsk->p_pptr->wait_chldexit);
}
到此可知,在加载完可执行文件,子进程通知完父进程后就会被暂停了,这个是时候父进程被唤醒进行运行。
唤醒的父进程就可以通过 sys_ptrace 系统调用进行获取子进程的各种状态数据了,比如调用 ptrace(PTRACE_PEEKUSER, pid, ORIG_RAX * 8, NULL) 获取子进程的系统调用号等。
asmlinkage int sys_ptrace(long request, long pid, long addr, long data)
{
if (request == PTRACE_TRACEME) { ... } ... switch (request) { ... //跟踪系统调用 case PTRACE_SYSCALL: /* continue and stop at next (return from) syscall */ case PTRACE_CONT: { /* restart after signal. */ long tmp; if ((unsigned long) data > NSIG) return -EIO; if (request == PTRACE_SYSCALL) //给子进程设置跟踪系统调用位 child->flags |= PF_TRACESYS; else child->flags &= ~PF_TRACESYS; child->exit_code = data; child->state = TASK_RUNNING; //设置子进程运行状态 /* make sure the single step bit is not set. */ tmp = get_stack_long(child, sizeof(long)*EFL-MAGICNUMBER) & ~TRAP_FLAG; put_stack_long(child, sizeof(long)*EFL-MAGICNUMBER,tmp); return 0; } ... //跟踪单步执行 case PTRACE_SINGLESTEP: { /* set the trap flag. */ long tmp; if ((unsigned long) data > NSIG) return -EIO; child->flags &= ~PF_TRACESYS; //取消跟踪系统调用位 //设置单步跟踪位 tmp = get_stack_long(child, sizeof(long)*EFL-MAGICNUMBER) | TRAP_FLAG; put_stack_long(child, sizeof(long)*EFL-MAGICNUMBER,tmp); child->state = TASK_RUNNING; 设置子进程运行状态 child->exit_code = data; /* give it a chance to run. */ return 0; } ... default: return -EIO; }
}
比如父进程调用 sys_ptrace 跟踪子进程获取系统调用过程,则给子进程设置跟踪系统调用bit位,设置子进程为运行状态让子进程运行,当子进程每次调用系统调用前和系统调用后,都会调用 _syscall_trace 函数。
// arch/i386/kernel/entry.S
_system_call:
…
.align 4
1: call _syscall_trace //系统调用前调用_syscall_trace
movl ORIG_EAX(%esp),%eax //设置系统调用号
call _sys_call_table(,%eax,4) //系统调用
movl %eax,EAX(%esp) # save the return value
movl _current,%eax
movl errno(%eax),%edx
negl %edx
je 1f
movl %edx,EAX(%esp)
orl $(CF_MASK),EFLAGS(%esp) # set carry to indicate error
1: call _syscall_trace //系统调用后调用_syscall_trace
_syscall_trace 函数的实现如下:
asmlinkage void syscall_trace(void)
{
if ((current->flags & (PF_PTRACED|PF_TRACESYS))
!= (PF_PTRACED|PF_TRACESYS))
return;
current->exit_code = SIGTRAP;
current->state = TASK_STOPPED; //暂停本进程
notify_parent(current); //通知父进程
schedule();
if (current->exit_code)
current->signal |= (1 << (current->exit_code - 1));
current->exit_code = 0;
}
syscall_trace 函数作用暂停本进程,然后通知父进程,这个时候父进程就可以通过 sys_ptrace 系统调用从 ORIG_EAX(32位,64位为 ORIG_RAX)寄存器中获取系统调用号。
同理,当子进程调用完系统调用后,调用 syscall_trace 通知父进程,父进程通过 sys_ptrace 系统调用 从寄存器 EAX(32位,64位为 ORIG_RAX)中获取系统调用的返回值。
通过上述分析可知,对于单步执行的实现原理跟获取系统调用的方式是同样的。
//跟踪单步执行
case PTRACE_SINGLESTEP: { /* set the trap flag. */
long tmp;
if ((unsigned long) data > NSIG)
return -EIO;
child->flags &= ~PF_TRACESYS; //取消跟踪系统调用位
//设置单步跟踪位
tmp = get_stack_long(child, sizeof(long)*EFL-MAGICNUMBER) | TRAP_FLAG;
put_stack_long(child, sizeof(long)*EFL-MAGICNUMBER,tmp);
child->state = TASK_RUNNING; 设置子进程运行状态
child->exit_code = data;
/* give it a chance to run. */
return 0;
}
给子进程设置一个单步跟踪的 bit 位,这样cpu每次执行一个指令后便会产生一个异常,系统就会给被调试进程发送一个 SIGTRAP 信号,被调试进程的信号处理函数会发送一个 SIGCHLD 信号给调试进程(父进程),并且让自己停止。
调试进程收到 SIGCHLD 信号后,就可以对被调试进程进行各种操作,比如读取被调试进程的内存数据和各个寄存器中的值。
**
**
通过 ptrace 也可以跟踪调试一个已经存在运行的程序,其具体方式是通过
PTRACE_ATTACH 实现的。
asmlinkage int sys_ptrace(long request, long pid, long addr, long data)
{
…
if (request == PTRACE_ATTACH) {
...
//设置被调试状态
child->flags |= PF_PTRACED;
//设置和父进程的关系
if (child->p_pptr != current) {
REMOVE_LINKS(child);
child->p_pptr = current;
SET_LINKS(child);
}
//给子进程发送 SIGSTOP 信号
send_sig(SIGSTOP, child, 1);
return 0;
}
...
}
其实现原理就是给子进程设置一个被调试状态,给子进程发送一个 SIGSTOP 信号让子进程暂停,子进程在信号处理函数中通知父进程,然后父进程进行控制子进程获取所需信息。
ptrace 的功能十分强大,通过本文的分析我们可以了解到其实现的原理过程,本文只是对部分操作进行了分析,若有想了解更多的功能,可自行查看源码分析。
原文链接
Linux GDB的实现原理
欢迎关注公众号 Linux码农,获取更多干货
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。