当前位置: 首页 > 科技观察

开发Linux调试器(一):准备环境

时间:2023-03-16 11:20:09 科技观察

写过比helloworld更复杂的程序的人应该都用过调试器(如果没有,先停下手头的工作,先学学吧)。但是,尽管这些工具被广泛使用,但没有多少资源可以告诉您它们如何工作以及如何开发,尤其是与编译器等其他工具链技术相比。以下是一些其他资源:http://eli.thegreenplace.net/2011/01/23/how-debuggers-work-part-1https://t-a-w.blogspot.co.uk/2007/03/how-to-code-debuggers.htmlhttps://www.codeproject.com/Articles/43682/Writing-a-basic-Windows-debuggerhttp://system.joekain.com/debugger/我们将支持以下功能:开始,暂停,继续执行在不同的地方设置断点内存地址源代码行函数入口读写寄存器和内存单步执行指令进入函数跳出函数跳过函数打印当前代码地址打印函数调用栈打印简单变量值在最后一部分,我将简要介绍如何将以下功能添加到您的调试器:远程调试共享库和动态库支持表达式计算多线程调试支持在这个项目中,我将重点关注C和C++,但也应该适用于语言将源代码编译成机器码并输出标准DWARE调试信息(如果你还不知道这些是什么,别担心,我们很快就会介绍)。此外,我只关心让程序正常工作,并且大部分时间都在工作,为了简单起见,避免了诸如强大的错误处理之类的事情。系列文章索引这些链接会随着后续文章的发布而逐渐生效。PrepareEnvironmentBreakpointRegistersandMemoryElvesanddwarvesSourceandSignalSourceLayerSteppingSourceLayerBreakpointCallStackStepAfterReadingVariablesLCTT注释:ELF-ExecutableandLinkableFormat;使用的调试数据格式参考WIKI)。准备环境在开始之前,我们首先需要搭建环境。在本文中,我将依赖两个工具:用于处理命令行输入的Linenoise和用于解析调试信息的libelfin。你也可以使用比较传统的libdwarf代替libelfin,但是界面不是那么友好,而且libelfin还提供了一个基本完整的DWARF表达式求值器,当你想读取一个变量的值时可以节省你很多时间。确保您使用的是libelfin我的fbreg分支,因为它为读取x86上的变量提供了额外的支持。一旦你在你的系统上安装了这些依赖项或使用你最喜欢的构建系统编译了这些依赖项,你就可以开始了。我在CMake文件中设置它们以与我的其余代码一起编译。启动一个可执行程序在我们实际调试任何程序之前,我们需要启动被调试的程序。我们将使用经典的fork/exec模式。intmain(intargc,char*argv[]){if(argc<2){std::cerr<<"Programnamenotspecified";return-1;}autoprog=argv[1];autopid=fork();if(pid==0){//we'reinthechildprocess//executedebugee}elseif(pid>=1){//we'reintheparentprocess//executedebugger}我们调用fork将我们的程序分成两个进程。如果我们在子进程中,fork返回0,如果我们在父进程中,则返回子进程的进程ID。如果我们在子进程中,我们用我们希望调试的程序替换正在执行的程序。ptrace(PTRACE_TRACEME,0,nullptr,nullptr);execl(prog.c_str(),prog.c_str(),nullptr);这里我们第一次遇到ptrace,在我们写调试器的时候会经常遇到。ptrace允许我们通过读取寄存器、内存、逐步调试等来观察和控制另一个进程的执行。API非常简单;您为这个简单的函数提供一个枚举值,指定您想要做什么,然后根据您提供的值可能会或可能不会使用一些参数。函数原型看起来很相似:longptrace(enum__ptrace_requestrequest,pid_tpid,void*addr,void*data);request是我们要对被跟踪进程执行的操作;pid是被跟踪进程的进程ID;addr是一个内存地址,用于指定被跟踪程序在某些调用中的地址;data是请求对应的资源。返回值通常是一些错误信息,所以你应该在你的实际代码中检查返回值;为了简洁起见,我在这里省略了它。您可以查看手册页以获取更多信息(关于ptrace)。上面代码中我们发送的请求PTRACE_TRACEME表示这个进程应该允许父进程跟踪它。所有其他参数都会被忽略,因为API设计不是很重要,哈哈。接下来,我们调用execl,它是众多exec函数格式之一。我们执行指定的程序,通过命令行参数传递其名称,并使用nullptr终止列表。如果需要,您还可以传递执行程序所需的其他参数。完成后,我们将结束子进程;它会一直执行直到我们结束它。添加调试循环现在我们已经启动了子进程,我们希望能够与其进行交互。为此,我们将创建一个调试器类,通过它循环侦听用户输入,并在父进程的主函数中启动它。elseif(pid>=1){//parentdebuggerdbg{prog,pid};dbg.run();}classdebugger{public:debugger(std::stringprog_name,pid_tpid):m_prog_name{std::move(prog_name)},m_pid{pid}{}voidrun();private:std::stringm_prog_name;pid_tm_pid;};在run函数中,需要等到子进程启动完毕,然后不断从linenoise获取输入,直到收到EOF(CTRL+D)。voiddebugger::run(){intwait_status;autooptions=0;waitpid(m_pid,&wait_status,options);char*line=nullptr;while((line=linenoise("minidbg>"))!=nullptr){handle_command(line);linenoiseHistoryAdd(line);linenoiseFree(line);}}被跟踪进程启动时,会向其发送SIGTRAP信号,即跟踪或断点中断。我们可以使用waitpid函数等待发送此信号。在我们知道可以调试进程后,我们会监听用户输入。linenoise函数本身使用一个窗口来显示和处理用户输入。这意味着我们不需要做太多工作就可以拥有一个支持历史和导航命令的命令行。当我们得到输入时,我们将命令发送到我们编写的小程序handle_command,然后我们将命令添加到linenoise历史记录并释放资源。以类似于gdb和lldb的格式处理命令的输入。要继续程序,用户需要键入continue或cont甚至只是c。如果他们想在某个地址设置断点,他们会键入break0xDEADBEEF,其中0xDEADBEEF是所需地址的十六进制表示形式。让我们添加对这些命令的支持。voiddebugger::handle_command(conststd::string&line){autoargs=split(line,'');autocommand=args[0];if(is_prefix(command,"continue")){continue_execution();}else{std::cerr<<"Unknowncommand\n";}}split和is_prefix是一对有用的小例程:std::vectorsplit(conststd::string&s,chardelimiter){std::vectorout{};std::stringstreamss{s};std::stringitem;while(std::getline(ss,item,delimiter)){out.push_back(item);}returnout;}boolis_prefix(conststd::string&s,conststd::string&of){if(s.size()>of.size())返回false;returnstd::equal(s.begin(),s.end(),of.begin());}我们将在调试器类中添加一个continue_execution函数。voiddebugger::continue_execution(){ptrace(PTRACE_CONT,m_pid,nullptr,nullptr);intwait_status;autooptions=0;waitpid(m_pid,&wait_status,options);}现在我们的continue_execution函数将使用ptrace告诉进程继续执行,并且然后使用waitpid等到收到信号。总结现在你应该编译一些C或C++程序并用你的调试器运行它,看看它是否在函数入口处停止并从调试器继续执行。在下一篇文章中,我们将学习如何让我们的调试器设置断点。如果您遇到任何问题,请在下面的评论框中告诉我!您可以在此处找到该项目的代码。