编写自己的 Linux 调试器






4.57/5 (17投票s)
Linux 的基本调试器
引言
本文类似于 https://codeproject.org.cn/Articles/189711/Write-your-own-Debugger-to-handle-Breakpoints 。 我们讨论 Linux 中 Windows 等效的 API:ptrace
,并在过程中编写我们自己的调试器(跟踪器)来调试一个示例被调试程序(被跟踪程序)。 在使用 gdb 时我差点心脏病发作,我希望向读者介绍调试器的内部结构,希望能帮助他们编写一个易于使用的命令行调试器。
背景
读者需要具备 Linux 的基本知识:尤其是信号及其处理。调试器依靠信号来获取来自被调试程序的通知(例如:SIGTRAP
)。调试器始终通过 wait
函数等待来自被跟踪程序的某些信号。
附加的代码在 Ubuntu 14.04, 64bit (Linux 3.16.0-55-generic) 上进行了测试
断点
断点允许用户在被调试程序的流程中设置中断。 用户可以这样做来评估执行到该点的特定条件。
调试器在被调试的可执行程序的进程空间中的特定地址(断点所需的位置)添加指令:int 3
(操作码:0xcc)。 遇到此指令后
-
EIP 将移动到中断服务例程(在本例中为
int 3
)。 -
服务例程将保存 CPU 寄存器(所有中断服务例程都必须这样做),并向附加的调试器发出信号:调用了
:ptrace(PTRACE_ATTACH,pid....)
的进程。
Using the Code
阅读本文时,必须始终参考附加的代码。
断点(操作码:0xcc)通过代码引入到被调试程序中
//calling break point
static unsigned char c[]={0xcc,0xc3,0x12,0x34,0x45};
static void (*pfunc)()=(void (*)())c;
static int i=mprotect((unsigned long int)c&0xfffffffffffff000,sizeof(c),
PROT_EXEC | PROT_READ | PROT_WRITE);
pfunc();
我们利用 mprotect
(相当于 Windows 中的 virtualprotect
)来提供对该内存的执行权限。
所有商业调试器都将通过使用 ptrace(PTRACE_POKEDATA,...)
,(相当于 Windows 中的 WriteProcessMemory
)来注入断点(不使用代码),它们显然会在更改指令之前保存指令,并恢复指令以进行正确的执行。
与 Windows(使用.pdb文件)不同,g++ 编译器将调试符号作为可执行文件的一部分发布。addr2line
工具可用于通过提供函数的地址来提取行和函数详细信息(除非可执行文件已被剥离)。 Linux 中的函数地址是绝对的(它们不像它们加载的共享对象那样受到 ASLR 的影响:https://en.wikipedia.org/wiki/Address_space_layout_randomization),因此 addr2line
不需要 通过它来查询可执行文件基本地址的 processID
。
int PrintFileAndLine
(const char *debugSymbol,unw_word_t addr) //call this function once per stack frame
{
char buffer[STR_MAX]={};
sprintf (buffer, "/usr/bin/addr2line
-C -e %s -f -i %lx", debugSymbol,addr); //I probably copied this from somewhere
FILE* f = popen (buffer, "r"); //open process
fgets (buffer, sizeof(buffer), f);printf("function:%s",buffer);
fgets (buffer, sizeof(buffer), f);printf("file/line:%s******\n",buffer);
pclose(f);
}
上面的代码片段向我们展示了如何通过正在运行的可执行文件的函数地址来提取行和函数名称。
获取调用堆栈
对于 Linux,不存在与 Windows StackWalk64
等效的函数,尽管我们有第三方的堆栈展开库(我们将使用它)。 当需要堆栈行走时,调试器必须一次一个字节地行走堆栈(属于 wait
返回的 threadID
)。 由于堆栈仅维护函数的返回地址,因此它必须检查前面的字节以确保进行了调用。 采取其他步骤以确保被调用的函数确实是一个函数而不是标签,这可以通过查看帧指针被推送到堆栈中的明显迹象来完成。
下面的函数使用库来展开堆栈(apt-get install libunwind-setjmp0-dev
),您可能想阅读:http://www.nongnu.org/libunwind/docs.html
void Getbacktrace(int thetid) {
unw_cursor_t cursor;
unw_word_t ip;
unw_addr_space_t as;
struct UPT_info *ui=NULL;
as = unw_create_addr_space(&_UPT_accessors,0);
ui = _UPT_create(thetid);
int rc = unw_init_remote(&cursor, as, ui);
while (unw_step(&cursor) > 0) { //walk the stack one frame at a time
unw_word_t offset, pc;
unw_get_reg(&cursor, UNW_REG_IP, &pc);
char buffer[STR_MAX]={};
if (0==unw_get_proc_name(&cursor, buffer,
sizeof(buffer), &offset)) //get mangled function names
printf("%s\n", buffer);
PrintFileAndLine(DEBUGGEE,pc); //use addr2line
}
_UPT_destroy(ui);unw_destroy_addr_space(as);
}
如前所述,调试器将使用 wait
,在 while
循环中旋转(代码是不言自明的),并且当被调试程序退出时将退出(对于读者来说是一个练习,因为缺少退出部分)
ptrace(PTRACE_ATTACH,pid,NULL);printf("error %u\n",errno); //lets attach the process
while(1)
{
pid_t tid=wait(&status);
if(WIFSTOPPED(status))
{
.....
ptrace(PTRACE_GETSIGINFO,pid,NULL,&siginfo);
ptrace(PTRACE_CONT, pid, NULL, siginfo.si_signo);
}
}
像 GDB 一样,您可能想吞下 SIGTRAP (=5)
,也就是说,不要将此信号传播回被调试程序以由其处理程序处理。
关注点
除了编写一个简单的调试器之外,我们可以利用这个新发现的知识来编写高级的性能分析工具。 ptrace
是实现调试器的关键,但也可以用于篡改、挂钩远程进程中的函数调用(在 mprotect
的帮助下,稍后可能会有更多介绍)。