弄清除GDB的底层原理,首先需要了解一下ptrace这个系统调用:
ptrace 是一个用于在 Unix 和 Unix-like 操作系统上进行进程调试的系统调用。它允许一个进程(通常是调试器)监视和控制另一个进程(通常是被调试的程序)。通过 ptrace,调试器可以读取和写入被调试进程的内存、寄存器,以及拦截和处理信号。
ptrace 的基本用法可以概括为以下几步:
- 启动被调试进程:调试器启动被调试进程,并让它进入一个暂停状态,等待调试器的进一步操作。
- 附加到现有进程:调试器可以附加到一个已经在运行的进程上。
- 读取和写入内存和寄存器:调试器可以访问被调试进程的内存和寄存器。
- 控制进程执行:调试器可以单步执行被调试进程,或者继续其执行直到下一个断点或信号。
- 处理信号:调试器可以拦截被调试进程接收到的信号,并决定如何处理这些信号。
ptrace的方法声明如下:
arduino
long int ptrace (enum __ptrace_request __request, ...) __THROW;
第一个参数为枚举,可以控制不同的功能,具体的操作符包括:
● PTRACE_TRACEME:使一个进程可以被其父进程跟踪。
● PTRACE_PEEKTEXT / PTRACE_PEEKDATA:读取被调试进程的内存。
● PTRACE_POKETEXT / PTRACE_POKEDATA:写入被调试进程的内存。
● PTRACE_GETREGS / PTRACE_SETREGS:读取和设置被调试进程的寄存器。
● PTRACE_CONT:继续执行被调试进程。
● PTRACE_SINGLESTEP:单步执行被调试进程。
话不多说,我们直接上手实践:
我们先写一个调试代码debugger_learn.c
arduino
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/user.h>
void run_target(char *command);
void run_debugger(pid_t child_pid);
int main(int argc, char *argv[])
{
pid_t child_pid;
if (argc < 2)
{
printf("Usage: %s <command>\n", argv[0]);
return -1;
}
child_pid = fork();
if (child_pid < 0)
{
perror("fork");
}
else if (child_pid == 0)
{
run_target(argv[1]);
}
else
{
run_debugger(child_pid);
}
return 0;
}
void run_target(char *command)
{
printf("command = %s\n", command);
if (ptrace(PTRACE_TRACEME) < 0)
{
perror("ptrace");
return;
}
execl(command, NULL);
}
void run_debugger(pid_t child_pid)
{
int wait_status;
int count = 0;
struct user_regs_struct regs;
wait(&wait_status);
while (WIFSTOPPED(wait_status))
{
ptrace(PTRACE_GETREGS, child_pid, 0, ®s);
unsigned instr = ptrace(PTRACE_PEEKTEXT, child_pid, regs.rip, 0);
printf("icounter = %u. IP = 0x%08x. instr = 0x%08x\n",
count, regs.rip, instr);
if (ptrace(PTRACE_SINGLESTEP, child_pid, 0, 0) < 0)
{
perror("ptrace");
return;
}
count++;
wait(&wait_status);
}
printf("count = %d\n", count);
}
这段代码主要是开启一个子进程,子进程先是等待调试器,连接成功或,通过execl执行传入的命令;父进程则先等待子进程暂停,并开启单步调试,这里每次执行指令都会停下来计数,最后统计指令执行的个数。
我们可以先写一个小的子命令去测试一下 single_print.c
arduino
#include <stdio.h>
int main() {
printf("Hello World!\n");
return 0;
}
这里我们简单打印一句Hello World,将上述两个文件分别编译生成两个对应的可执行文件 然后执行:
总共执行了9万多个指令。
意料之外,情理之中。
虽然只有一句简单的printf,但其实这个函数是库函数,用到了libc.so里的内容,加载共享库等操作其实非常复杂,有这么多指令调用也很正常。
但我们怎么验证我们的单步调试代码是正常的呢?
这里我们需要简化代码,简化到最底层的指令代码,直接上汇编代码:
single_print_asm.s
ini
section .text
; The _start symbol must be declared for the linker (ld)
global _start
_start:
; Prepare arguments for the sys_write system call:
; - eax: system call number (sys_write)
; - ebx: file descriptor (stdout)
; - ecx: pointer to string
; - edx: string length
mov edx, len
mov ecx, msg
mov ebx, 1
mov eax, 4
; Execute the sys_write system call
int 0x80
; Execute sys_exit
mov eax, 1
int 0x80
section .data
msg db 'Hello, world from asm single', 0xa
len equ $ - msg
简单解释一下这段汇编代码,只有7个指令,使用系统调用(syscall)将一条消息输出到标准输出(stdout),然后退出。
以下是对每一部分代码的解释:
.text 段
.text段通常包含可执行代码。在这里,定义了程序的入口点和执行的指令。
● section .text:声明一个代码段。
● global _start:声明一个全局符号_start,这是程序的入口点。
_start 标签
_start是程序的入口点,在程序启动时,控制权会被转移到这里。
准备和执行sys_write系统调用
Linux系统调用sys_write用于向文件描述符写入数据。
● mov edx, len:将字符串的长度存入edx寄存器。
● mov ecx, msg:将字符串的地址存入ecx寄存器。
● mov ebx, 1:将文件描述符1(stdout,标准输出)存入ebx寄存器。
● mov eax, 4:将系统调用号4(sys_write)存入eax寄存器。
● int 0x80:触发中断0x80,执行系统调用。此时,寄存器eax、ebx、ecx和edx中的值会被传递给内核,内核根据eax中的值确定执行的系统调用(这里是sys_write),并使用ebx、ecx和edx中的值作为参数。
执行sys_exit系统调用
sys_exit系统调用用于退出程序。
● mov eax, 1:将系统调用号1(sys_exit)存入eax寄存器。
● int 0x80:触发中断0x80,执行系统调用。此时,内核会根据eax中的值确定执行sys_exit系统调用,从而终止程序。
.data 段
.data段通常包含已初始化的数据。
● section .data:声明一个数据段。
● msg db 'Hello, world from asm single', 0xa:定义一个字符串,内容为"Hello, world from asm single",并以换行符(0x0a)结尾。db是"定义字节"(define byte)的缩写,用于定义一个字节数组。
● len equ $ - msg
:计算字符串的长度。$
表示当前位置的地址,$ - msg
表示从当前位置到字符串起始位置的字节数,即字符串的长度。equ是"等同于"(equate)的缩写,用于定义常量。
将single_print_asm.s编译成可执行文件:
arduino
nasm -f elf64 single_print_asm.s -o single_print_asm.o
gcc -nostartfiles -nostdlib -static single_print_asm.o -o single_print_asm
最后成功验证单步调试成功,总共执行了7个指令