跳到主要内容

调试器实现原理简介

· 阅读需 32 分钟

我们在Linux系统上调试一个程序时,调试器是如何帮助我们完成程序调试的?

调试器可以分为两个部分:一部分是控制程序的运行,包括暂停/恢复程序、读/写程序的寄存器和内存、检查程序的状态等;另一部分是分析,处理程序运行时的数据和解析调试信息,包括程序正在执行的语句、断点应该设在什么地方、如何查看局部变量、全局变量和寄存器的值等。

如果使用过远程调试,那么一定知道gdb/gdbserverlldb/lldb-server,我们把gdb/lldb称为clientgdbserver/lldb-server称为server本地调试时,gdb 作为 client 同时兼任了 gdbserver 的功能,而 lldb 则会自动启动 lldb-server,统一了本地调试和远程调试的行为。

一、典型的远程调试结构

远程调试能够让调试器和程序运行在不同的目标机器上。client运行在本地,由于要分析和处理数据,需要较好的硬件资源;而server和被调试的程序运行在目标机器,只需要控制运行和访问数据,因此不需要太好的硬件资源。

图片

server的核心是一个stub, gdbserver/lldb-server通过系统调用(ptrace)来控制和访问程序;对于仿真器和OpenOCDserver通常是它们的一部分,仿真器本身能够完全控制被仿真的程序,而OpenOCD通过JTAG也能够控制硬件的行为。stub也可以内嵌到被调试的程序中,注册异常处理函数来让 stub 有机会获得程序的控制能力。

RSPRemote Serial Protocol)是GDB定义的远程调试协议,client使用这个协议向server发出请求,server负责完成协议定义的功能。因此server还需要具备数据传送的能力,至于是通过串口还是网络,依赖于具体实现。

二、server需要完成的功能

为了能够调试程序,server至少需要实现以下功能:

1.停止正在运行的程序

  • 硬件断点:有些处理器支持硬件断点,通常是一些硬件寄存器,有数量限制。

  • 软件断点:一般也会提供陷阱指令作为软件断点,server将指定地址的指令替换为陷阱指令,当程序运行该指令时就能停下来。也可以是任何能够使CPU暂停的方法,比如除零异常,只要server能够正确识别这种情况。

  • 内存断点:有些硬件也支持内存断点,当指定内存地址被访问时自动停止。


2.异常处理函数

如果是内嵌在程序的stub,则它必须是一个异常处理函数,当命中断点或发生异常时被自动调用。而其他情况通常是一个无限循环,所有调试器感兴趣的事件都会在这里被捕获,以下是lldb-server的例子:

Status MainLoop::Run() {  m_terminate_request = false;
Status error; RunImpl impl(*this);
// run until termination or until we run out of things to listen to while (!m_terminate_request && (!m_read_fds.empty() || !m_signals.empty())) {
error = impl.Poll(); if (error.Fail()) return error;
impl.ProcessEvents(); } return Status();}


3.实现RSP协议

用于clientserver的交互。定义了调试过程中可能出现的各种请求。简单列举一些:

图片

三、常用命令的实现原理

server需要实现对程序的访问和控制,它接收的大多是地址或指令相关的请求。与用户直接交互的是client,它让我们能够进行源码级别的调试,例如把一行源代码行数转换为对应指令的地址。这里只介绍我们在调试器上最常使用命令。

  • continue

最简单的动作,只需要恢复CPU执行而无需做其他事情,这通常只需要向server发送一个“c”包。

  • step instruction

依赖于硬件和server的支持。一些硬件能够执行一条指令后停下,如果硬件不支持或者server不支持直接使用该功能,就需要用到上面提到的软件断点。server需要做的就是插入断点-恢复运行-还原指令

  • step into:

这里只考虑调试源代码的情况。通常一行源码对应多条指令,调试器可以逐条指令执行,直到离开当前函数范围进入到另一函数后,使程序停止,命令完成;或者直到执行完所有对应指令都没有离开当前函数,则在下一条即将执行的指令处停止,命令完成。如果调试器能够知道将要执行的指令中是否包含函数调用,调试器也可以自由选择执行策略。

调试信息里保存了所有函数的地址范围,当编译时加”-g”选项,编译器会生成调试信息,使用

readelf --debug-dump=frames a.out

可以查看函数帧信息,下面是一个实例,左边是调试信息,右边是对应的反汇编代码:

图片

  • step over

这里只考虑调试源代码的情况。通常一行源码对应多条指令,调试器可以逐条指令执行,如果离开当前函数范围,则在返回地址处插入断点并恢复运行,命中断点后继续逐条指令执行,直到完成所有对应指令,在下一条即将执行的指令处停止,命令完成;或者直到执行完所有对应指令都没有离开当前函数,则在下一条即将执行的指令处停止,命令完成。

这里进入函数后选择插入断点并恢复执行是因为如果函数很大,或者函数中还有函数调用,一直逐条指令执行的效率会非常低,因为执行每一条指令都需要与调试器交互。

  • print:

当使用print命令来查看变量时,调试器需要知道这个变量的位置以及如何解释这块内存区域。这些也放在了调试信息里,使用以下命令可以查看:

readelf --debug-dump=info a.out


图片

Dwarf有自己的编码方式,例如被标记的区域,DW_TAG_formal_paramter表示函数参数,每个条目记录了参数名称(DW_AT_name)、参数位置(DW_AT_location)、参数类型(DW_AT_type)、所在源码位置(DW_AT_decl_fileDW_AT_decl_line)。DW_TAG_subprogram表示在该编译单元定义的某个函数。这里的DW_TAG_XXXDW_AT_XXX大多可以望文生义,不做过多解释。

print命令也可以查看寄存器的值。调试器可以查看当前所有寄存器的值,但如果需要查看函数的上一帧或者上上一帧的寄存器的值呢?这属于函数帧的信息,但仍然通过如下命令看:

readelf --debug-dump=frames a.out


图片

红色标记指出ra 存放在 cfa-8的位置,s0存放在cfa-16的位置,其中cfaCanonical Frame Address)就是帧地址,可以与上面的反汇编对比。注意,cfa 是刚进入函数,sp 还未修改时的值。调试器只能找出被保存在栈上的寄存器。

  • backtrace:

callee-saved寄存器通常包含了rafp寄存器。通过 fp 可以确定当前帧的帧地址,通过 ra 可以确定其调用函数,再根据函数帧信息解析出调用帧布局,因此通过这两个寄存和调试信息可以还原整个函数调用栈。

图片

  • source code location:

我们需要程序停止时看到的是当前执行的源码,而不是即将执行的指令。这些信息在调试信息中记录,使用

readelf --debug-dump=decodedline a.out

可以看到被解码后的行表,无论调试信息被如何编码,调试器最终都可以确定某条指令对应于哪个源码文件的哪一行(实际上行表里也记录了列信息,这对一行存在多条语句时有意义)。

图片

以上是我们使用调试器常用命令的实现原理,从原理到实现,调试器必须仔细地判断程序的状态和数据,决定什么时候需要停止,什么时候需要恢复。程序绝不仅仅在我们指定的地方暂停,比如在执行”step over”时,就有可能自动插入和删除断点。

调试器所要完成的事情远不止这些,如gdblldb,还支持表达式估值、多线程调试、多进程调试、JIT和指令仿真等。如上step into 小节中提到“如果调试器能够知道将要执行的指令中是否包含函数调用,调试器也可以自由选择执行策略”就是通过仿真指令完成的。

参考:

https://sourceware.org/gdb/current/onlinedocs/gdb/Remote-Protocol.html#Remote-Protocol

https://dwarfstd.org/



以下是兆松团队的张忠海参加 OSDT 2022 所分享的关于调试器的详细内容。

OSDT Conf2022图文版

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

--------END--------


兆松科技是一家专业做编译和仿真的初创公司,由前晶心科技研发副总王东华博士于 2019 年底创立。研发总监伍华林曾就职于晶心科技,S3 Graphics,Imagination,拥有 10 余年 CPU/GPU 编译器研发经验。欢迎关注兆松科技公众号!