DAP调试适配协议

DAP调试适配协议

什么是 DAP 协议

DAP 即调试适配协议( Debug Adapter Protocol ),顾名思义,它是用来对多种调试器进行抽象统一的适配层,将原有 IDE 和调试工具直接交互的模式更改为和 DAP 进行交互。该模式可以让 IDE 集成多种调试器变得更简单,且灵活性更好。

IDE 中的调试功能有许多小功能组成,包括单步执行、断点、查看变量值等,常规的实现方式是在每个 IDE 中去实现这些逻辑,且因为调试工具的接口不同,还需要为每个调试工具做一些适配工作,这将导致大量且重复的工作,如下图所示:
在这里插入图片描述

调试适配器协议背后的想法是标准化一个抽象协议,用于开发工具如何与具体调试器通信。这个思想和 LSP(Language Server Protocol)和 BSP(Build Server Protocol)类似,都是通过协议去统一相同功能在不同工具之间的差异性。其所处位置如下图所示,其中左边为不同的开发工具,右边为不能同的调试器,不同于开发工具和调试器直接交互的方式,DAP 将这些交互统一了起来,让开发工具和调试工具都面向 DAP 编程。

在这里插入图片描述

上图中的交互是通过协议进行,所以不会像通过 API 的方式存在语言限制,可以更好的适应调试器的集成。

如何工作

以下部分解释了开发工具(例如 IDE 或编辑器)和调试适配器之间的交互,这不仅有助于在调试适配器中实现调试适配器协议,而且有助于在开发工具(有时也称为“主机”或“客户端”)中托管协议。

调试会话

开发工具有两种基础的方式和调试器进行交互,分别是:

【单会话模式】

在这种模式下,开发工具启动一个调试适配器作为一个单独的进程并且通过标准的std接口进行通信。在调试会话的结束时调试适配器就终止,对于当前的调试会话,开发工具往往需要实现多个调试适配。

【多会话模式】

在这种模式下,开发工具不会启动调试适配器,而是假定它已经在运行并且会在特定端口上侦听连接尝试,对于每个调试会话,开发工具在特定端口上启动一个新的通信会话并在会话结束时断开连接。

在与调试适配器建立连接后,开发工具和调试适配器之间通过基础协议进行通信

基础协议

基础协议由两部分组成,包括头和内容(类似于 HTTP),头部和内容部分通过“rn”进行分割:

【协议头】

协议头部分由字段组成, 每个头字段由一个键和一个值组成,用‘:’(一个冒号和一个空格)分隔, 每个头字段都以“rn“结尾。由于最后一个协议头字段和整个协议头本身都以 rn 终止,并且由于协议头是强制性的,所以消息的内容部分总是在(并唯一标识)两个 rn 序列之前。当前只支持一个协议头字段:

头字段名 值类型 描述
Content-Length 数字 这个字段是必须的,用来记录内容字段的长度,单位是字节。

协议头部分使用的是“ASCII”编码。

【内容部分】

内容部分包含了实际要传输的数据,这些数据用 JSON 格式来描述请求、响应和事件。内容部分用的是 utf-8 编码

使用方法

下一步

在调试过程中,开发人员经常会使用到下一步操作,在 DAP 中其协议为:

Content-Length: 119rn
rn
{
    "seq": 153,
    "type": "request",
    "command": "next",
    "arguments": {
        "threadId": 3
    }
}

类型是“请求”,命令是下一步,参数可以携带多个,这里用的线程Id。

初始化

DAP 协议目前定义了多个功能并且数量还在增长中,但是,该协议仍处于其第一个版本,因为以完全向后兼容的方式支持新功能是一个明确的设计目标。在没有版本号的情况下实现这一点需要每个新功能都有一个相应的标志,让开发工具知道调试适配器是否支持该功能,没有标志总是意味着不支持该功能。单个功能及其相应标志在调试适配器协议中称为“能力”, 所有功能标志的开放式集合称为 DAP 的“能力集”。

启动调试会话时,开发工具向适配器发送初始化请求,以便在开发工具和调试适配器之间交换能力集。

开发工具能力集在初始化请求的 InitializeRequestArguments 结构中提供,通常以 supports 前缀开头。 从工具传递到调试适配器的信息有:

  • 调试工具的名字
  • 格式化的文件路径
  • 行和列值基于 0 还是 1
  • 开发工具使用的语言环境。 调试适配器应返回符合此语言环境的错误消息。

调试适配器通过 Capabilities 类型在 InitializeResponse 中返回支持的功能,对于不受支持的功能,没有必要返回显式 false。

启动和附加

在完成初始化工作后,那么就可以发送一些请求开始调试任务,这里提供两种调试类型的请求:

  • 启动请求

    调试适配器在调试模式下启动程序,然后开始与其通信。由于调试适配器负责被调试对象,它应该为最终用户提供与被调试对象交互的选项,基本上有三个选项可以如何启动调试器,调试适配器可以通过 RunInTerminal 请求使用其中的两个选项。

    • debug console
    • integrated terminal
    • external terminal
  • 附加请求

    调试适配器连接到一个已经运行的程序。 最终用户负责启动和终止程序。

由于这两个请求的参数高度依赖于特定的调试器和调试适配器实现,因此调试适配器协议没有为这些请求指定任何参数。相反,开发工具应该从其他地方(例如,由某些插件或扩展机制提供)获取有关调试器特定参数的信息,并在此基础上构建 UI 和验证机制。

配置断点和异常行为

当开发工具实现了调试界面,那此时可以实现断点的管理和异常的配置,这些配置信息必须在程序启动之前传递到调试器中。

即便开发工具不知道什么时候去传递这些配置信息,调试器也可以发送一个初始化时间到开发工具,表明其已准备好接收配置请求。通过这种方式可以让调试适配器不用去创建缓存来保存配置信息。

在响应初始化事件时,开发工具用以下这些请求来发送配置信息:

  • setBreakpoints 单个源文件中所有断点的一个请求,
  • setFunctionBreakpoints 设置函数断点,
  • setExceptionBreakpoints 设置异常断点,
  • configurationDoneRequest 标志配置序列的结束。

setBreakpoints 请求注册单个源存在的所有断点(因此它不是增量的),在调试适配器中这些语义的一个简单实现是清除源的所有先前断点,然后设置请求中指定的断点。 setBreakpointssetFunctionBreakpoints 应返回“实际”断点,如果断点无法在请求的位置设置或被调试器移动,则通用调试器会动态更新 UI

以下序列图总结了假设的 gdb 调试适配器的请求和事件序列:

在这里插入图片描述

访问状态

每当程序停止时,调试适配器都会发送一个停止事件,并带有适当的原因和线程 ID。

当开发工具收到该停止事件后,首先请求线程,然后是停止事件中提到的线程的堆栈跟踪(堆栈帧列表),如果用户随后进入堆栈框架,则开发工具首先请求堆栈框架的作用域,然后是作用域的变量。 如果变量本身是结构化的,则开发工具通过附加变量请求来请求其属性。 整个请求如下:

Threads
   StackTrace
      Scopes
         Variables
            ...
               Variables

可以通过 setVariable 请求修改变量的值。

支持线程

每当通用调试器接收到停止或线程事件时,开发工具都会请求该时间点下所有的线程。线程事件是可选的,即使在未处于停止状态调试适配器也可以发送它们以强制开发工具动态更新线程 UI。 如果调试适配器决定不发出 Thread 事件,则开发工具中的线程 UI 只会在收到停止事件时更新。

成功启动或附加后,开发工具使用线程请求来获得当前现有线程的基线(主线程),然后开始侦听线程事件以检测新线程或终止线程。 即使调试适配器不支持多线程,它也必须实现线程请求并返回单个(虚拟)线程。 线程 id 必须用于所有引用线程的请求中,例如 堆栈跟踪、暂停、继续、下一步、步入和步出。

调试会话结束

当开发工具结束调试会话时,根据最初会话是“启动”还是“附加”,事件序列略有不同:

  • 启动

如果调试适配器支持终止请求,开发工具使用它来优雅地终止被调试者,即它让被调试者有机会在终止前清理所有内容。 如果被调试对象没有终止但继续运行(或遇到断点),调试会话将继续,但如果开发工具再次尝试终止被调试对象,它将使用断开连接请求无条件地结束调试会话。 断开连接请求应强制终止调试对象(和任何子进程)。

  • 附加

如果调试对象最初已“附加”,则开发工具会发出断开连接请求,这应该将调试器与被调试者分离,但将允许它继续。

在调试适配器想要结束调试会话的所有情况下,必须触发终止事件。

如果调试对象已经结束(并且调试适配器能够检测到这一点),则可以发出可选的退出事件以将退出代码返回给开发工具。

下图总结了 gdb 的假设调试适配器的请求和事件序列:

在这里插入图片描述

参考文档

debug-adapter-protocol

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>