文章目录
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_DETACH
和PTRACE_CONT
请求从而唤醒进程执行操作。 -
发送给被追踪进程的信号会被转发给父进程,除了
SIGKILL
,子进程则会被阻塞。 -
父进程收到信号之后可以对子进程进行修改,来让子进程继续运行。
接下来描述一下ptrace接口的参数含义:
-
request
作为ptrace的核心配置,提供非常多的进程追踪能力-
PTRACE_TRACEME
和PTRACE_ATTACH
都是和进程建立追踪关系PTRACE_TRACEME
表示被追踪进程调用,让父进程来追踪自己。通常是gdb调试新进程时使用。PTRACE_ATTACH
父进程attach到正在运行的子进程上,这种追踪方式会检查权限,普通用户无法追踪root用户下的进程
-
PTRACE_PEEKTEXT
、PTRACE_PEEKDATA
、PTRACE_PEEKUSER
、PTRACE_GETREGS
等表示读取子进程内存,寄存器等内容 -
PTRACE_POKETEXT
,PTRACE_POKEDATA
,PTRACE_POKEUSR
等表示修改子进程的内存,寄存器的内容 -
PTRACE_CONT
,PTRACE_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),那就涉及到修改子进程内存的情况了。这里是通过ptrace
的PTRACE_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数组之中。