调试器实现原理简介
我们在Linux系统上调试一个程序时,调试器是如何帮助我们完成程序调试的?
调试器可以分为两个部分:一部分是控制程序的运行,包括暂停/恢复程序、读/写程序的寄存器和内存、检查程序的状态等;另一部分是分析,处理程序运行时的数据和解析调试信息,包括程序正在执行的语句、断点应该设在什么地方、如何查看局部变量、全局变量和寄存器的值等。
如果使用过远程调试,那么一定知道gdb/gdbserver或lldb/lldb-server,我们把gdb/lldb称为client,gdbserver/lldb-server称为server。本地调试时,gdb 作为 client 同时兼任了 gdbserver 的功能,而 lldb 则会自动启动 lldb-server,统一了本地调试和远程调试的行为。
一、典型的远程调试结构
远程调试能够让调试器和程序运行在不同的目标机器上。client运行在本地,由于要分析和处理数据,需要较好的硬件资源;而server和被调试的程序运行在目标机器,只需要控制运行和访问数据,因此不需要太好的 硬件资源。
server的核心是一个stub, gdbserver/lldb-server通过系统调用(ptrace)来控制和访问程序;对于仿真器和OpenOCD,server通常是它们的一部分,仿真器本身能够完全控制被仿真的程序,而OpenOCD通过JTAG也能够控制硬件的行为。stub也可以内嵌到被调试的程序中,注册异常处理函数来让 stub 有机会获得程序的控制能力。
RSP(Remote 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协议
用于client和server的交互。定义了调试过程中可能出现的各种请求。简单列举一些:
三、常用命令的实现原理
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_file、DW_AT_decl_line)。DW_TAG_subprogram表示在该编译单元定义的某个函数。这里的DW_TAG_XXX和DW_AT_XXX大多可以望文生义,不做过多解释。
print命令也可以查看寄存器的值。调试器可以查看当前所有寄存器的值,但如果需要查看函数的上一帧或者上上一帧的寄存器的值呢?这属于函数帧的信息,但仍然通过如下命令查看:
readelf --debug-dump=frames a.out
红色标记指出ra 存放在 cfa-8的位置,s0存放在cfa-16的位置,其中cfa(Canonical Frame Address)就是帧地址,可以与上面的反汇编对比。注意,cfa 是刚进入函数,sp 还未修改时的值。调试器只能找出被保存在栈上的寄存器。
backtrace:
callee-saved寄存器通常包含了ra和fp寄存器。通过 fp 可以确定当前帧的帧地址,通过 ra 可以确定其调用函数,再根据函数帧信息解析出调用帧布局,因此通过这两个寄存和调试信息可以还原整个函 数调用栈。
source code location:
我们需要程序停止时看到的是当前执行的源码,而不是即将执行的指令。这些信息在调试信息中记录,使用
readelf --debug-dump=decodedline a.out
可以看到被解码后的行表,无论调试信息被如何编码,调试器最终都可以确定某条指令对应于哪个源码文件的哪一行(实际上行表里也记录了列信息,这对一行存在多条语句时有意义)。
以上是我们使用调试器常用命令的实现原理,从原理到实现,调试器必须仔细地判断程序的状态和数据,决定什么时候需要停止,什么时候需要恢复。程序绝不仅仅在我们指定的地方暂停,比如在执行”step over”时,就有可能自动插入和删除断点。
调试器所要完成的事情远不止这些,如gdb和lldb,还支持表达式估值、多线程调试、多进程调试、JIT和指令仿真等。如上step into 小节中提到“如果调试器能够知道将要执行的指令中是否包含函数调用,调试器也可以自由选择执行策略”就是通过仿真指令完成的。
参考:
https://sourceware.org/gdb/current/onlinedocs/gdb/Remote-Protocol.html#Remote-Protocol
以下是兆松团队的张忠海参加 OSDT 2022 所分享的关于调试器的详细内容。
OSDT Conf2022图文版:
--------END--------
兆松科技是一家专业做编译和仿真的初创公司,由前晶心科技研发副总王东华博士于 2019 年底创立。研发总监伍华林曾就职于 晶心科技,S3 Graphics,Imagination,拥有 10 余年 CPU/GPU 编译器研发经验。欢迎关注兆松科技公众号!
公众号