|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +categories: [C] |
| 4 | +description: none |
| 5 | +keywords: C |
| 6 | +--- |
| 7 | +# GDB调试程序的核心技术-ptrace系统调用与使用示例 |
| 8 | +在程序出现bug的时候,最好的解决办法就是通过 GDB 调试程序,然后找到程序出现问题的地方。比如程序出现 段错误(内存地址不合法)时,就可以通过 GDB 找到程序哪里访问了不合法的内存地址而导致的。 |
| 9 | + |
| 10 | +本文不是介绍GDB不是使用方式,而是大概介绍 GDB 的实现原理,当然是 GDB 是一个庞大而复杂的项目,不可能只通过一篇文章就能解释清楚,所以本文主要是介绍 GDB 使用的核心的技术 - ptrace。 |
| 11 | + |
| 12 | +## ptrace系统调用 |
| 13 | +ptrace() 系统调用是 Linux 提供的一个调试进程的工具,ptrace() 系统调用非常强大,它提供非常多的调试方式让我们去调试某一个进程,下面是 ptrace() 系统调用的定义: |
| 14 | +``` |
| 15 | +long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data); |
| 16 | +``` |
| 17 | +下面解释一下 ptrace() 各个参数的作用: |
| 18 | +- request:指定调试的指令,指令的类型很多,如:PTRACE_TRACEME、PTRACE_PEEKUSER、PTRACE_CONT、PTRACE_GETREGS等等,下面会介绍不同指令的作用。 |
| 19 | +- pid:进程的ID(这个不用解释了)。 |
| 20 | +- addr:进程的某个地址空间,可以通过这个参数对进程的某个地址进行读或写操作。 |
| 21 | +- data:根据不同的指令,有不同的用途,下面会介绍。 |
| 22 | + |
| 23 | +## ptrace使用示例 |
| 24 | +下面通过一个简单例子来说明 ptrace() 系统调用的使用,这个例子主要介绍怎么使用 ptrace() 系统调用获取当前被调试(追踪)进程的各个寄存器的值,代码如下(ptrace.c): |
| 25 | +``` |
| 26 | +#include <sys/ptrace.h> |
| 27 | +#include <sys/types.h> |
| 28 | +#include <sys/wait.h> |
| 29 | +#include <unistd.h> |
| 30 | +#include <sys/user.h> |
| 31 | +#include <stdio.h> |
| 32 | +int main() |
| 33 | +{ pid_t child; |
| 34 | + struct user_regs_struct regs; |
| 35 | +
|
| 36 | + child = fork(); // 创建一个子进程 |
| 37 | + if(child == 0) { // 子进程 |
| 38 | + ptrace(PTRACE_TRACEME, 0, NULL, NULL); // 表示当前进程进入被追踪状态 |
| 39 | + execl("/bin/ls", "ls", NULL); // 执行 `/bin/ls` 程序 |
| 40 | + } |
| 41 | + else { // 父进程 |
| 42 | + wait(NULL); // 等待子进程发送一个 SIGCHLD 信号 |
| 43 | + ptrace(PTRACE_GETREGS, child, NULL, ®s); // 获取子进程的各个寄存器的值 |
| 44 | + printf("Register: rdi[%ld], rsi[%ld], rdx[%ld], rax[%ld], orig_rax[%ld]\n", |
| 45 | + regs.rdi, regs.rsi, regs.rdx,regs.rax, regs.orig_rax); // 打印寄存器的值 |
| 46 | + ptrace(PTRACE_CONT, child, NULL, NULL); // 继续运行子进程 |
| 47 | + sleep(1); |
| 48 | + } |
| 49 | + return 0; |
| 50 | +} |
| 51 | +``` |
| 52 | +通过命令 gcc ptrace.c -o ptrace 编译并运行上面的程序会输出如下结果: |
| 53 | +``` |
| 54 | +Register: rdi[0], rsi[0], rdx[0], rax[0], orig_rax[59] |
| 55 | +ptrace ptrace.c |
| 56 | +``` |
| 57 | +上面结果的第一行是由父进程输出的,主要是打印了子进程执行 /bin/ls 程序后各个寄存器的值。而第二行是由子进程输出的,主要是打印了执行 /bin/ls 程序后面输出的结果。 |
| 58 | + |
| 59 | +下面解释一下上面程序的执行流程: |
| 60 | +1. 主进程调用 fork() 系统调用创建一个子进程。 |
| 61 | +2. 的进程调用 ptrace(PTRACE_TRACEME,...) 把自己设置为被追踪状态,并且调用 execl() 执行 /bin/ls 程序。 |
| 62 | +3. 被设置为追踪(TRACE)状态的子进程执行 execl() 的程序后,会向父进程发送 SIGCHLD 信号,并且暂停自身的执行。 |
| 63 | +4. 父进程通过调用 wait() 接收子进程发送过来的信号,并且开始追踪子进程。 |
| 64 | +5. 父进程通过调用 ptrace(PTRACE_GETREGS, child, ...) 来获取到子进程各个寄存器的值,并且打印寄存器的值。 |
| 65 | +6. 父进程通过调用 ptrace(PTRACE_CONT, child, ...) 让子进程继续执行下去。 |
| 66 | + |
| 67 | +从上面的例子可以知道,通过向 ptrace() 函数的 request 参数传入不同的值时,就会有不同的效果。比如传入 PTRACE_TRACEME 就可以让进程进入被追踪状态,而转入 PTRACE_GETREGS 时,就可以获取被追踪的子进程各个寄存器的值等。 |
| 68 | + |
| 69 | +## ptrace实现原理 |
| 70 | +本文使用的 Linux 2.4.16 版本的内核。看懂本文需要的基础:进程调度,内存管理和信号处理等相关知识。 |
| 71 | + |
| 72 | +调用 ptrace() 系统函数时会触发调用内核的 sys_ptrace() 函数,由于不同的原因 CPU 架构有着不同的调试方式,所以 Linux 为每种不同的 CPU 架构实现了不同的 sys_ptrace() 函数,而本文主要介绍的是 X86 CPU 的调试方式,所以 sys_ptrace() 函数所在文件是 linux-2.4.16/arch/i386/kernel/ptrace.c。 |
| 73 | + |
| 74 | +sys_ptrace() 函数的主体是一个 switch 语句,会传入的 request 参数不同进行不同的操作,如下: |
| 75 | +``` |
| 76 | +asmlinkage int sys_ptrace(long request, long pid, long addr, long data) |
| 77 | +{ |
| 78 | + struct task_struct *child; |
| 79 | + struct user *dummy = NULL; |
| 80 | + int i, ret; |
| 81 | + ... |
| 82 | +
|
| 83 | + read_lock(&tasklist_lock); |
| 84 | + child = find_task_by_pid(pid); // 获取 pid 对应的进程 task_struct 对象 |
| 85 | + if (child) |
| 86 | + get_task_struct(child); |
| 87 | + read_unlock(&tasklist_lock); |
| 88 | + if (!child) |
| 89 | + goto out; |
| 90 | +
|
| 91 | + if (request == PTRACE_ATTACH) { |
| 92 | + ret = ptrace_attach(child); |
| 93 | + goto out_tsk; |
| 94 | + } |
| 95 | +
|
| 96 | + ... |
| 97 | + switch (request) { |
| 98 | + case PTRACE_PEEKTEXT: |
| 99 | + case PTRACE_PEEKDATA: |
| 100 | + ... |
| 101 | + case PTRACE_PEEKUSR: |
| 102 | + ... |
| 103 | + case PTRACE_POKETEXT: |
| 104 | + case PTRACE_POKEDATA: |
| 105 | + ... |
| 106 | + case PTRACE_POKEUSR: |
| 107 | + ... |
| 108 | + case PTRACE_SYSCALL: |
| 109 | + case PTRACE_CONT: |
| 110 | + ... |
| 111 | + case PTRACE_KILL: |
| 112 | + ... |
| 113 | + case PTRACE_SINGLESTEP: |
| 114 | + ... |
| 115 | + case PTRACE_DETACH: |
| 116 | + ... |
| 117 | + } |
| 118 | +out_tsk: |
| 119 | + free_task_struct(child); |
| 120 | +out: |
| 121 | + unlock_kernel(); |
| 122 | + return ret; |
| 123 | +} |
| 124 | +``` |
| 125 | +从上面的代码可以看出,sys_ptrace() 函数首先根据进程的 pid 获取到进程的 task_struct 对象。然后根据传入不同的 request 参数在 switch 语句中进行不同的操作。 |
| 126 | + |
| 127 | +ptrace() 支持的所有 request 操作定义在 linux-2.4.16/include/linux/ptrace.h 文件中,如下: |
| 128 | +``` |
| 129 | +#define PTRACE_TRACEME 0 |
| 130 | +#define PTRACE_PEEKTEXT 1 |
| 131 | +#define PTRACE_PEEKDATA 2 |
| 132 | +#define PTRACE_PEEKUSR 3 |
| 133 | +#define PTRACE_POKETEXT 4 |
| 134 | +#define PTRACE_POKEDATA 5 |
| 135 | +#define PTRACE_POKEUSR 6 |
| 136 | +#define PTRACE_CONT 7 |
| 137 | +#define PTRACE_KILL 8 |
| 138 | +#define PTRACE_SINGLESTEP 9 |
| 139 | +#define PTRACE_ATTACH 0x10 |
| 140 | +#define PTRACE_DETACH 0x11 |
| 141 | +#define PTRACE_SYSCALL 24 |
| 142 | +#define PTRACE_GETREGS 12 |
| 143 | +#define PTRACE_SETREGS 13 |
| 144 | +#define PTRACE_GETFPREGS 14 |
| 145 | +#define PTRACE_SETFPREGS 15 |
| 146 | +#define PTRACE_GETFPXREGS 18 |
| 147 | +#define PTRACE_SETFPXREGS 19 |
| 148 | +#define PTRACE_SETOPTIONS 21 |
| 149 | +``` |
| 150 | +由于 ptrace() 提供的操作比较多,所以本文只会挑选一些比较有代表性的操作进行解说,比如 PTRACE_TRACEME、PTRACE_SINGLESTEP、PTRACE_PEEKTEXT、PTRACE_PEEKDATA 和 PTRACE_CONT 等,而其他的操作,有兴趣的朋友可以自己去分析其实现原理。 |
| 151 | + |
| 152 | +进入被追踪模式(PTRACE_TRACEME操作) |
| 153 | + |
| 154 | +当要调试一个进程时,需要使进程进入被追踪模式,怎么使进程进入被追踪模式呢?有两个方法: |
| 155 | + |
| 156 | +被调试的进程调用 ptrace(PTRACE_TRACEME, ...) 来使自己进入被追踪模式。 |
| 157 | +调试进程(如GDB)调用 ptrace(PTRACE_ATTACH, pid, ...) 来使指定的进程进入追踪模式。 |
| 158 | +第一种方式是进程自己主动进入被追踪模式,而第二种是进程被动进入被追踪模式。 |
| 159 | + |
| 160 | +被调试的进程必须进入追踪模式才能进行调试,因为 Linux 会对被追踪的进程进行一些特殊的处理。下面我们主要介绍第一种进入追踪模式的实现,就是 PTRACE_TRACEME 操作过程,代码如下: |
| 161 | +``` |
| 162 | +asmlinkage int sys_ptrace(long request, long pid, long addr, long data) |
| 163 | +{ |
| 164 | + ... |
| 165 | + if (request == PTRACE_TRACEME) { |
| 166 | + if (current->ptrace & PT_PTRACED) |
| 167 | + goto out; |
| 168 | + current->ptrace |= PT_PTRACED; // 标志 PTRACE 状态 |
| 169 | + ret = 0; |
| 170 | + goto out; |
| 171 | + } |
| 172 | + ... |
| 173 | +} |
| 174 | +``` |
| 175 | +从上面的代码可以发现,ptrace() 对 PTRACE_TRACEME 的处理就是把当前进程标志为 PTRACE 状态。 |
| 176 | + |
| 177 | +当然事情不会这么简单,因为当一个进程被标记为 PTRACE 状态后,当调用 exec() 函数去执行一个外部程序时,将会暂停当前进程的运行,并且发送一个 SIGCHLD 给父进程。父进程接收到 SIGCHLD 信号后就可以对被调试的进程进行调试。 |
| 178 | + |
| 179 | +我们来看看 exec() 函数是怎样实现上述功能的,exec() 函数的执行过程为 sys_execve() -> do_execve() -> load_elf_binary(): |
| 180 | +``` |
| 181 | +static int load_elf_binary(struct linux_binprm * bprm, struct pt_regs * regs) |
| 182 | +{ |
| 183 | + ... |
| 184 | + if (current->ptrace & PT_PTRACED) |
| 185 | + send_sig(SIGTRAP, current, 0); |
| 186 | + ... |
| 187 | +} |
| 188 | +``` |
| 189 | +从上面代码可以看出,当进程被标记为 PTRACE 状态时,执行 exec() 函数后便会发送一个 SIGTRAP 的信号给当前进程。 |
| 190 | + |
| 191 | +我们再来看看,进程是怎么处理的 SIGTRAP 信号的。信号是通过 do_signal() 函数进行处理的,而对 SIGTRAP 信号的处理逻辑如下: |
| 192 | +``` |
| 193 | +int do_signal(struct pt_regs *regs, sigset_t *oldset) |
| 194 | +{ |
| 195 | + for (;;) { |
| 196 | + unsigned long signr; |
| 197 | +
|
| 198 | + spin_lock_irq(¤t->sigmask_lock); |
| 199 | + signr = dequeue_signal(¤t->blocked, &info); |
| 200 | + spin_unlock_irq(¤t->sigmask_lock); |
| 201 | +
|
| 202 | + // 如果进程被标记为 PTRACE 状态 |
| 203 | + if ((current->ptrace & PT_PTRACED) && signr != SIGKILL) { |
| 204 | + /* 让调试器运行 */ |
| 205 | + current->exit_code = signr; |
| 206 | + current->state = TASK_STOPPED; // 让自己进入停止运行状态 |
| 207 | + notify_parent(current, SIGCHLD); // 发送 SIGCHLD 信号给父进程 |
| 208 | + schedule(); // 让出CPU的执行权限 |
| 209 | + ... |
| 210 | + } |
| 211 | + } |
| 212 | +} |
| 213 | +``` |
| 214 | +上面的代码主要做了3件事: |
| 215 | +- 如果当前进程被标记为 PTRACE 状态,那么就使自己进入停止运行的状态。 |
| 216 | +- 发送 SIGCHLD 信号给父进程。 |
| 217 | +- 让出 CPU 的执行权限,使 CPU 执行其他进程。 |
| 218 | + |
| 219 | +执行以上过程后,追踪进程便进入了调试模式 |
| 220 | + |
| 221 | +当父进程(调试进程)接收到 SIGCHLD 信号后,表示被调试进程已经标记为被追踪状态并且停止运行,那么调试进程就可以开始进行调试了。 |
| 222 | + |
| 223 | +获取被调试进程的内存数据(PTRACE_PEEKTEXT / PTRACE_PEEKDATA) |
| 224 | + |
| 225 | +调试进程(如GDB)可以通过调用 ptrace(PTRACE_PEEKDATA, pid, addr, data) 立即获取被调试进程 addr 处虚拟内存地址的数据,但每次只能读取一个大小为 4字节的数据。 |
| 226 | + |
| 227 | +我们来看看 ptrace() 对 PTRACE_PEEKDATA 操作的处理过程,代码如下: |
| 228 | +``` |
| 229 | +asmlinkage int sys_ptrace(long request, long pid, long addr, long data) |
| 230 | +{ |
| 231 | + ... |
| 232 | + switch (request) { |
| 233 | + case PTRACE_PEEKTEXT: |
| 234 | + case PTRACE_PEEKDATA: { |
| 235 | + unsigned long tmp; |
| 236 | + int copied; |
| 237 | +
|
| 238 | + copied = access_process_vm(child, addr, &tmp, sizeof(tmp), 0); |
| 239 | + ret = -EIO; |
| 240 | + if (copied != sizeof(tmp)) |
| 241 | + break; |
| 242 | + ret = put_user(tmp, (unsigned long *)data); |
| 243 | + break; |
| 244 | + } |
| 245 | + ... |
| 246 | +} |
| 247 | +``` |
| 248 | +从上面代码可以看出,对 PTRACE_PEEKTEXT 和 PTRACE_PEEKDATA 的处理是相同的,主要是通过调用 access_process_vm() 函数来读取被调试进程 addr 处的虚拟内存地址的数据。 |
| 249 | + |
| 250 | +access_process_vm() 函数的实现主要涉及到 内存管理 相关的知识,可以参考我以前对内存管理分析的文章,这里主要大概说明一下 access_process_vm() 的原理。 |
| 251 | + |
| 252 | +我们知道每个进程都有个 mm_struct 的内存管理对象,而 mm_struct 对象有个表示虚拟内存与物理内存映射关系的页目录的指针 pgd。如下: |
| 253 | +``` |
| 254 | +struct mm_struct { |
| 255 | + ... |
| 256 | + pgd_t *pgd; /* 页目录指针 */ |
| 257 | + ... |
| 258 | +} |
| 259 | +``` |
| 260 | +而 access_process_vm() 函数就是通过进程的页目录来找到 addr 虚拟内存地址映射的物理内存地址,然后把此物理内存地址处的数据复制到 data 变量中。 |
| 261 | + |
| 262 | +access_process_vm() 函数的实现这里就不分析了,有兴趣的读者可以参考我之前对内存管理分析的文章自行进行分析。 |
| 263 | + |
| 264 | +## 单步调试模式(PTRACE_SINGLESTEP) |
| 265 | + |
| 266 | +单步调试是一个比较有趣的功能,当把被调试进程设置为单步调试模式后,被调试进程没执行一条CPU指令都会停止执行,并且向父进程(调试进程)发送一个 SIGCHLD 信号。 |
| 267 | + |
| 268 | +我们来看看 ptrace() 函数对 PTRACE_SINGLESTEP 操作的处理过程,代码如下: |
| 269 | +``` |
| 270 | +asmlinkage int sys_ptrace(long request, long pid, long addr, long data) |
| 271 | +{ |
| 272 | + ... |
| 273 | + switch (request) { |
| 274 | + case PTRACE_SINGLESTEP: { /* set the trap flag. */ |
| 275 | + long tmp; |
| 276 | + ... |
| 277 | + tmp = get_stack_long(child, EFL_OFFSET) | TRAP_FLAG; |
| 278 | + put_stack_long(child, EFL_OFFSET, tmp); |
| 279 | + child->exit_code = data; |
| 280 | + /* give it a chance to run. */ |
| 281 | + wake_up_process(child); |
| 282 | + ret = 0; |
| 283 | + break; |
| 284 | + } |
| 285 | + ... |
| 286 | +} |
| 287 | +``` |
| 288 | +要把被调试的进程设置为单步调试模式,英特尔的 X86 CPU 提供了一个硬件的机制,就是通过把 eflags 寄存器的 Trap Flag 设置为1即可。 |
| 289 | + |
| 290 | +当把 eflags 寄存器的 Trap Flag 设置为1后,CPU 每执行一条指令便会产生一个异常,然后会触发 Linux 的异常处理,Linux 便会发送一个 SIGTRAP 信号给被调试的进程。 |
| 291 | + |
| 292 | +从上图可知,eflags 寄存器的第8位就是单步调试模式的标志。 |
| 293 | + |
| 294 | +所以 ptrace() 函数的以下2行代码就是设置 eflags 进程的单步调试标志: |
| 295 | +``` |
| 296 | +tmp = get_stack_long(child, EFL_OFFSET) | TRAP_FLAG; |
| 297 | +put_stack_long(child, EFL_OFFSET, tmp); |
| 298 | +``` |
| 299 | +而 get_stack_long(proccess, offset) 函数用于获取进程栈 offset 处的值,而 EFL_OFFSET 偏移量就是 eflags 寄存器的值。 |
| 300 | + |
| 301 | +所以上面两行代码的意思就是: |
| 302 | +- 获取进程的 eflags 寄存器的值,并且设置 Trap Flag 标志。 |
| 303 | +- 把新的值设置到进程的 eflags 寄存器中。 |
| 304 | + |
| 305 | +设置完 eflags 寄存器的值后,就调用 wake_up_process() 函数把被调试的进程唤醒,让其进入运行状态。 |
| 306 | + |
| 307 | +处于单步调试模式时,被调试进程每执行一条指令都会触发一次 SIGTRAP 信号,而被调试进程处理 SIGTRAP 信号时会发送一个 SIGCHLD 信号给父进程(调试进程),并且让自己停止执行。 |
| 308 | + |
| 309 | +而父进程(调试进程)接收到 SIGCHLD 后面,就可以对被调试的进程进行各种操作,比如读取被调试进程内存的数据和寄存器的数据,或者通过调用 ptrace(PTRACE_CONT, child,...) 来让被调试进程进行运行等。 |
| 310 | + |
| 311 | +由于 ptrace() 的功能十分强大,所以本文只能抛砖引玉,没能对其所有功能进行分析。另外断点功能并不是通过 ptrace() 函数实现的,而是通过 int3 指令来实现的,在 Eli Bendersky 大神的文章有所介绍。而对于 ptrace() 的所有功能,只能读者自己慢慢看代码来体会了。 |
| 312 | + |
| 313 | + |
| 314 | + |
| 315 | + |
| 316 | + |
| 317 | + |
| 318 | + |
| 319 | + |
| 320 | + |
| 321 | + |
| 322 | + |
| 323 | + |
| 324 | + |
| 325 | + |
| 326 | + |
| 327 | + |
| 328 | + |
| 329 | + |
| 330 | + |
| 331 | + |
| 332 | + |
| 333 | + |
| 334 | + |
| 335 | + |
| 336 | + |
| 337 | + |
| 338 | + |
| 339 | + |
| 340 | + |
| 341 | + |
| 342 | + |
| 343 | + |
0 commit comments