一文带你看透 GDB 的 实现原理 -- ptrace真香


GDB本身能够attach到一个运行的进程,实时获取运行中进程的内存数据,增加断点,查看当前运行状态下函数变量值,甚至直接修改函数的变量。

这个机制本身就很有趣,也很实用,接下来探索一下GDB核心功能的详细实现。

GDB基本的调试功能都是通过一个系统调用ptrace来实现的。

ps: 限于本人能力有限,对底层CPU 执行的正确逻辑没法做到万无一失,欢迎大家批评指正,相互学习讨论。

Ptrace 的使用

ptrace 主要被用做进程追踪,追踪进程的什么内容呢?这里有很多可选的配置,比如进程内存的值、进程寄存器的值,进程接收到的信号,指定进程以何种方式运行等等;

接口声明如下:

#include <sys/ptrace.h>

long ptrace(enum __ptrace_request request, pid_t pid,
            void *addr, void *data);

调用ptrace 追踪进程时(gdb attach -p $pid),被追踪进程会发生如下事情:

  • 追踪进程会变为被追踪进程 的父进程

    Baron+ 215677 154756  0 11:57 pts/1    S      0:00  |   \_ gdb attach -p 215063           
    Baron+ 218064 215677  7 11:59 pts/1    S+     0:00  |       \_ /home/Baron/write_test
    
  • 进程状态会进入 TASK_TRACED ,表示当前进程正在被追踪,此时进程会暂停下来,等待追踪进程的操作。这个状态有点像TASK_STOPPED,都是让进程暂停下来等待被唤醒或者操作。只是TASK_TRACED状体的进程 不接受SIGCONT信号,只接受ptrace指定的PTRACE_DETACHPTRACE_CONT 请求从而唤醒进程执行操作。

  • 发送给被追踪进程的信号会被转发给父进程,除了SIGKILL,子进程则会被阻塞。

  • 父进程收到信号之后可以对子进程进行修改,来让子进程继续运行。

接下来描述一下ptrace接口的参数含义:

  • request 作为ptrace的核心配置,提供非常多的进程追踪能力

    • PTRACE_TRACEMEPTRACE_ATTACH 都是和进程建立追踪关系

      • PTRACE_TRACEME表示被追踪进程调用,让父进程来追踪自己。通常是gdb调试新进程时使用。
      • PTRACE_ATTACH父进程attach到正在运行的子进程上,这种追踪方式会检查权限,普通用户无法追踪root用户下的进程
    • PTRACE_PEEKTEXTPTRACE_PEEKDATAPTRACE_PEEKUSERPTRACE_GETREGS等表示读取子进程内存,寄存器等内容

    • PTRACE_POKETEXT,PTRACE_POKEDATA,PTRACE_POKEUSR等表示修改子进程的内存,寄存器的内容

    • PTRACE_CONTPTRACE_SYSCALL, PTRACE_SINGLESTEP表示被控制进程以何种方式追踪

      • PTRACE_CONT表示重新启动被追踪进程
      • PTRACE_SYSCALL每次进入或者退出系统调用时都会触发一次SIGTRAP(Trace/breakpoint trap),strace的追踪系统调用就是通过该配置进行追踪的,进入时获取参数,退出时获取系统调用返回值。
      • PTRACE_SINGLESTEP 每执行完一次指令之后会触发一次sigtrap,支持获取当前进程的内存/寄存器状态。gdb的next指令通过该选项实现。
    • PTRACE_DETACH, PTRACE_KILL解除父子进程之间的追踪关系

      如果父进程在在子进程前结束,则会自动解除追踪关系。

  • pid表示 要跟踪的进程pid

  • addr表示进程的内存地址

  • data 根据前面设置的requet选项而变化,比如要开始追踪时则设置request= PTRACE_CONT,同时将data设置为对应signal数字(SIGTRAP – 5)。

GDB 的基本实现原理

gdb调试的基本架构如下

  • 本地调试 通过本地gdb 命令行或者mi图形接口进行调试
  • 远端调试 就是在当前设备通过远端的gdb server对远端设备的目标程序进行调试

两者共同点是 底层都通过ptrace系统调用进行调试。

在这里插入图片描述

ptrace的基本使用我们已经看了一遍,如果想要了解更加详细的信息,可以通过man 2 ptrace进一步了解。

接下来通过ptrace来简单看一下gdb的实现原理:

  • 当我们使用gdb设置断点的时候,gdb会将断点处的指令修改为INT 3(x86开始支持的专门用作调试的CPU指令,使得cpu终端到调试器),同时将断点信息以及修改前的指令保存起来。
  • 当被调试的子进程执行到断点处时 触发INT 3中断,从而产生SIGTRAP信号。
  • 因为此时父进程已经和调试进程建立追踪关系,ptrace会将子进程的SIGTRAP信号发送给父进程,此时父进程先和已有的断点信息进行对比,比如确认INT 3指令的位置,来确认当前信号是否因为断点产生。
  • 如果是,则会等待用户输入指令,进行下一步处理,如果不是,则不予理会,继续执行后续代码。

通过以上原理可以看出,gdb会修改子进程的代码(将设置断点处的子进程指令修改为INT 3),那就涉及到修改子进程内存的情况了。这里是通过ptracePTRACE_POKEDATA选项进行修改。

Example1 通过ptrace 修改 被追踪进程的内存数据

通过ptrace 修改 被追踪进程的内存数据

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#include <sys/ptrace.h>

void check(long ret, char *str) {
    if (ret == -1) {
        printf("execute %s failed with %ld !!!\n", str, ret);
    }
    printf("Execute %s success! \n", str);
}


char str[] = "Ptrace is testing";

int main() {
    pid_t pid = fork();

    union{
      char cdata[8];
      u_int64_t data; 
    }u = {"CHANGE T"};

    switch (pid)
    {
    case 0: // 子进程先休眠2秒
        sleep(2); 
        printf("Child's data is %s\n", str);
        break;

    case -1:
        printf("Fork failed ");
        exit(1) ;
        break;

    default: // 父进程先修改子进程内存中的值,但是父进程内存中的数据不变
        check(ptrace(PTRACE_ATTACH, pid ,0 ,0),"PT_ATTACH"); // 链接到子进程
        check(ptrace(PTRACE_POKEDATA, pid ,str ,u.data),"PT_WRITE_D"); // 修改子进程内存中str的内容
        check(ptrace(PTRACE_CONT, pid ,0 ,0),"PT_CONTINUE"); // 子进程继续运行
        printf("Parent's data is %s\n", str);
        wait(NULL);
        break;
    }

    return 0;
}

执行结果如下,可以看到父进程已经将子进程内存中的str数据前8个字节做了更改,但是父进程内存中的数据还是没有变化。

$ ./ptrace_change 
Execute PT_ATTACH success! 
Execute PT_WRITE_D success! 
Execute PT_CONTINUE success! 
Parent's data is Ptrace is testing
Child's data is CHANGE Ts testing
Example2 通过ptrace 对被追踪进程进行单步调试

通过ptrace 对被追踪进程进行单步调试,以下代码是在32位系统上调试的,所以寄存器的表示还是eip,而x86_64的系统下寄存器都已经变更为rip了。

总体的逻辑如下:

  • 追踪给定的进程pid, 通过PTRACE_ATTACH作为父进程与 给定进程建立追踪关系
  • 获取被追踪进程的 CPU存放的下一个指令的存放地址 — EIP,CPU 存放当前主线程的栈顶指针偏移地址 — ESP
  • 通过ptrace的PTRACE_SINGLESTEP选项不断得将EIP和ESP指针向下移动,每执行一条指令,寄存器指针移动一次,直到两个寄存器指针到达栈尾,结束调试

当然打印并不只打印寄存器的地址,像GDB每一次单步追踪会等待用户的输入,这个时候可以查看或者修改esp和eip当前状态下的进程内存中的数据。

看ptrace测试 代码之前先简单描述一下ESP和EIP寄存器的关系:

进程开始运行的时候,左侧CPU的ESP寄存器指向主线程的函数栈顶(函数的执行是不断得压栈和弹栈的)
右侧的EIP寄存器则保存CPU执行的下一条汇编指令(后文有一个简单的测试程序的全指令截图,可以看看)
在这里插入图片描述
当开始运行的时候,一个函数语句可能需要多条汇编指令来完成,所以EIP改变多次,ESP才会发生一次改变。
在这里插入图片描述
在这里插入图片描述
通过n次的指令执行程序主体代码, 运行完成的标记就是ESP指向函数栈底,EIP指令指针也指向函数栈底。
在这里插入图片描述

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <wait.h>
#include <sys/ptrace.h>
#include <sys/reg.h>
#include <sys/user.h>
#include <sys/signal.h>

#define M_OFFSETOF(STRUCT, ELEMENT) \
	(unsigned int) &((STRUCT *)NULL)->ELEMENT;

#define D_LINUXNONUSRCONTEXT 0x40000000 // 32位系统下内核态部分的结束地址
  																			//(32位系统虚拟进程空间内核地址占用1个G)

int main (int argc, char *argv[]) 

{

int Tpid, stat, res;
int signo;
int ip, sp;
int ipoffs, spoffs;
int initialSP = -1;
int initialIP = -1;
struct user u_area;
struct user_regs_struct regs;


/*
** 传入指定进程的PID 
*/
	if (argv[1] == NULL) {
		printf("Need pid of traced process\n");
		printf("Usage: pt  pid  \n");
		exit(1);
	}
	Tpid = strtoul(argv[1], NULL, 10);
	printf("Tracing pid %d \n",Tpid );
/*
** 获取EIP 偏移地址 -- 保存CPU 下一个指令的寄存器地址
** 获取ESP 偏移地址 -- 保存CPU 函数栈顶指针的偏移地址
*/
	ipoffs = M_OFFSETOF(struct user, regs.eip);
	spoffs = M_OFFSETOF(struct user, regs.esp);
/*
** 通过Ptrace 将输入PID所代表的进程作为当前进程的子进程,并建立追踪关系。
** 此时会目标子进程发送一个SIGSTOP的信号,调用waitpid来感知子进程的状态变化。
*/
	printf("Attaching to process %d\n",Tpid);
	if ((ptrace(PTRACE_ATTACH, Tpid, 0, 0)) != 0) {;
		printf("Attach result %d\n",res);
	}
	res = waitpid(Tpid, &stat, WUNTRACED);
	if ((res != Tpid) || !(WIFSTOPPED(stat)) ) {
		printf("Unexpected wait result res %d stat %x\n",res,stat);
		exit(1);
	}
	printf("Wait result stat %x pid %d\n",stat, res);
	stat = 0;
	signo = 0;
/*
** 完成子进程(输入的PID 进程)的状态切换,并且与当前追踪进程建立了父子关系
*/
	while (1) {
/*
** 通过ptrace的PTRACE_SINGLESTEP进行单步调试,调试过程会向子进程发送SIGTRAP信号
** 通过wait系统调用进行捕获
*/ 
		if ((res = ptrace(PTRACE_SINGLESTEP, Tpid, 0, signo)) < 0) {
			perror("Ptrace singlestep error");
			exit(1);
		}
		res = wait(&stat);
/*
** 捕获到SIGTRAP信号之后,将信号置0,准备开启下一个单步调试。
** 如果发现子进程接受到的信号是SIGHUP和SIGINT(子进程接受到了暂停信号
** 那么就停止单步调试,父进程退出。
*/
		if ((signo = WSTOPSIG(stat)) == SIGTRAP) {
			signo = 0;
		}
		if ((signo == SIGHUP) || (signo == SIGINT)) {
			ptrace(PTRACE_CONT, Tpid, 0, signo);
			printf("Child took a SIGHUP or SIGINT. We are done\n");
			break;
		}
/*
** 单步调试之后,两个寄存器的地址会发生变化,所以需要重新获取以下
*/
		ip = ptrace(PTRACE_PEEKUSER, Tpid, ipoffs, 0);
		sp = ptrace(PTRACE_PEEKUSER, Tpid, spoffs, 0);
/*
** 通过 ldd 查看输入的PID进程的内存分布如下
**     libc.so.6 => /lib/i686/libc.so.6 (0x40030000)
**     /lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000)
** 这里跳过内核态的地址
*/
		if (ip & D_LINUXNONUSRCONTEXT) {
			continue;
		} 
		if (initialIP == -1) {
			initialIP = ip;
			initialSP = sp;
			printf("---- Starting LOOP IP %x SP %x ---- \n",
						initialIP, initialSP);
		} else { // 直到运行到ESP指针和EIP指针的结尾,完成单步追踪
			if ((ip == initialIP) && (sp == initialSP)) {
				ptrace(PTRACE_CONT, Tpid, 0, signo);
				printf("----- LOOP COMPLETE -----\n");
				break;
			}
		}
		printf("Stat %x IP %x SP %x  Last signal %d\n",stat, ip, sp,
							signo);
	}
	printf("Debugging complete\n");

	sleep(5);
	return(0);
}

测试代码如下:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>


int main() {

  int *a[10] = {0};
  int i = 0;
  int j = 0;
  
  while(i < 1000) {
    a[i] = (int *)malloc(sizeof(int)*10);
    if(a[i] == NULL){
      printf("malloc failed\n");
      exit(1);
    }else {
      printf("malloc address is %x\n",(unsigned int)a[i]);
    }
    
    for(;j < 10; ++j){
      a[i][j] = j;
    }

    i++;
    sleep(1);
  }

  for(i =0;i < 1000 ;++i) {
    free (a[i]);
  }

  return 0;

}

测试代码对应的CPU指令如下
perf top -p pid

先运行测试代码,再编译运行ptrace追踪代码./test_ptrace $pid,可以看到ptrace追踪代码如下输出:
其中IP和SP指向的地址可看到 SP指针不会每次追踪都发生变化,而指令寄存器地址IP每次都发生变化,因为每次执行的指令都不一样,这和我们描述ptrace单步调试代码逻辑时的ESP和EIP寄存器关系图逻辑一样的。

因为还不是linux手艺人,还没法深入浅出linux系统,所以这里只能通过自己的猜测和工具来 弥补体系结构这块知识的缺失了。

Tracing pid 314201 
Attaching to process 314201
Wait result stat 137f pid 314201
---- Starting LOOP IP a88e0840 SP b60b6418 ----
Stat 57f IP a88e0840 SP b60b6418  Last signal 0
Stat 57f IP a88e0846 SP b60b6418  Last signal 0
Stat 57f IP a88e0848 SP b60b6418  Last signal 0
Stat 57f IP a88e06f4 SP b60b6420  Last signal 0
Stat 57f IP a88e06f6 SP b60b6420  Last signal 0
Stat 57f IP a88e06f8 SP b60b6420  Last signal 0
Stat 57f IP a88e0720 SP b60b6420  Last signal 0
Stat 57f IP a88e0727 SP b60b65d8  Last signal 0
Stat 57f IP a88e0729 SP b60b65d8  Last signal 0
Stat 57f IP a88e072a SP b60b65e0  Last signal 0
......
Stat 57f IP a88e06ef SP b60b6420  Last signal 0
Stat 57f IP a88e0830 SP b60b6418  Last signal 0
Stat 57f IP a88e0837 SP b60b6418  Last signal 0
Stat 57f IP a88e0839 SP b60b6418  Last signal 0
Stat 57f IP a88e083e SP b60b6418  Last signal 0
----- LOOP COMPLETE -----
Debugging complete

Ptrace的实现

这里不可能将每一个ptrace的选项的实现都讲明白,只能在主线的调试流程上看看当 attach获取被追踪进程内存数据单步调试 这一些功能的背后内核做了什么。

使用frtrace 抓取SyS_ptrace函数的执行逻辑,关于ftrace的使用可以参考关于 Rocksdb 性能分析 需要知道的一些“小技巧“ – perf_context的“内功” ,systemtap、perf、 ftrace的颜值

这个抓取主要是通过执行gdb的一些调试命令来让ptrace的不同选项得到运行,抓取attach,breadpoint,r,n等基本gdb指令的结果如下(主体的处理逻辑还是比较长的,这里仅仅贴一部分逻辑):

# tracer: function_graph
#
# CPU  TASK/PID         DURATION                  FUNCTION CALLS
# |     |    |           |   |                     |   |   |   |
   3)  <...>-46083   |               |  SyS_ptrace() {                           # 系统调用入口
   3)  <...>-46083   |               |    ptrace_get_task_struct() {             # 获取进程的task_struc
   3)  <...>-46083   |               |      find_task_by_vpid() {
   3)  <...>-46083   |               |        find_task_by_pid_ns() {
   3)  <...>-46083   |   0.523 us    |          find_pid_ns();
   3)  <...>-46083   |   1.178 us    |        }
   3)  <...>-46083   |   1.858 us    |      }
   3)  <...>-46083   |   2.387 us    |    }
   3)  <...>-46083   |               |    ptrace_attach() {                     # attach 入口
   3)  <...>-46083   |               |      mutex_lock_interruptible() {
   3)  <...>-46083   |   0.037 us    |        _cond_resched();
   3)  <...>-46083   |   0.707 us    |      }
   3)  <...>-46083   |   0.087 us    |      _raw_spin_lock();
   3)  <...>-46083   |               |      __ptrace_may_access() {
   3)  <...>-46083   |   0.105 us    |        get_dumpable();
   3)  <...>-46083   |               |        security_ptrace_access_check() {
   3)  <...>-46083   |               |          yama_ptrace_access_check() {
   3)  <...>-46083   |   0.068 us    |            cap_ptrace_access_check();
   3)  <...>-46083   |   0.584 us    |          }
   3)  <...>-46083   |   0.043 us    |          cap_ptrace_access_check();
   3)  <...>-46083   |   1.404 us    |        }
   3)  <...>-46083   |   2.947 us    |      }
......

ps: 后文涉及到的ptrace源码是 linux-3.10.1.0.1版本

PTRACE_TRACEME

通过gdb 调试一个新的进程会进入PTRACE_TRACEME选项,gdb ./new_process

ptrace系统调用入口如下:

确认能够建立连接之后通过_ptrace_link将当前进程new_process和gdb追踪进程建立父子关系

PTRACE_ATTACH

通过gdb attach到一个正在运行的进程上时会进入这个逻辑,gdb attach -p pid

在后续会通过signal_wake_up_state函数唤醒处于stopped状态的进程

PTRACE_CONT

使得因正在被调试而暂停,或者断掉的进程恢复运行,gdb的n,r,c等命令让进程重新运行都是通过该选项实现的

进入到arch_ptrace之后,通过ptrace_reuqest --> ptrace_resume对该选项进行处理

PTRACE_SINGLESTEP

将进程的标志寄存器设置为单步模式,让被调试进程继续运行。当执行完一条指令之后,会触发INT中断,并发信号给控制进程,等待下一次的执行。

PTRACE_PEEKDATA

读取虚拟进程内存中的数据,像gdb的p 打印变量 就是该选项的功能,与选项PTRACE_PEEKTEXT一样,只不过读取的是不同的地址空间的数据。TEXT是代码段的数据,程序执行代码中的一段数据,DATA段存储已经初始化的静态数据和全局变量数据。

PTRACE_POKEDATA

修改被追踪进程指定内存地址中的数据,通过设置access_process_vm函数最后一个参数来表示是写入内存中的数据还是从内存中读数据。

PTRACE_GETREGS

获取被追踪进程 指定寄存器中的数据

而对应的PTRACE_SETREG即修改用户进程寄存器内容,通过__get_user函数将data中的数据写入到regs数组之中。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值