调试器开发:架构设计和技术方案选择

这里的技术方案,暂时先主要聚焦在如下几个点。

整体架构设计

调试器应该具备良好的扩展性设计,以支持在不同应用场景中的应用,如在命令行中调试,与不同的IDE VsCode、Goland进行集成,支持远程调试部署在服务器、Kubernetes集群、Container容器中的程序。这也要求调试器架构必须实现“frontend”和“backend”分离式架构。

比如,我们可能在一台macOS机器上调试运行在Linux机器上的进程,此时frontend可以是运行在darwin/amd64机器上的VsCode或者命令行调试器,而backend可以是运行在linux/amd64 or linux/arm64机器上的调试器服务。

最基础的三层架构

一个最基础的调试器,通常至少需要包含以下三层:

debugger-arch-1

  • UI层 (UI layer):负责与用户交互,接收用户输入、展示调试信息(如变量、堆栈等)。将UI层单独分离,可以让用户交互逻辑与核心调试逻辑解耦,便于后续更换或支持不同的用户界面(如命令行、GUI、IDE插件等)。
  • 符号层 (Symbolic Layer):负责解析和管理符号信息(如变量名、函数名、源码位置与内存地址的映射等),是调试器的核心桥梁,连接用户操作与底层调试逻辑。分离符号层有助于支持多种编程语言和不同的调试信息格式。
  • 目标层 (Target Layer):直接与被调试程序(target)交互,负责进程控制、数据读写、断点设置、单步执行、内存和寄存器访问等。目标层的独立,使得调试器可以更容易适配不同的操作系统和硬件架构。

这三层架构虽然是调试器的“基本盘”,但在实际工程和复杂应用场景下,远远不够。比如,如何支持前后端分离、远程调试、服务化、分布式调试等高级需求?还需要引入更多的架构层次和机制来满足这些扩展性和灵活性要求。

ps:第6章指令级调试器开发,我们将采用这里的最基础的三层架构方式,以简化调试器示例godbg的开发工作量。

前后端分离式架构

最基础的三层架构虽然是调试器的“基本盘”,但在实际工程和复杂应用场景下,远远不够。比如,如何支持前后端分离、远程调试、服务化、分布式调试等高级需求?还需要引入更多的架构层次和机制来满足这些扩展性和灵活性要求。

下面对设计进行进一步调整,引入Service Layer,以实现调试器的前后端分离架构,如下所示:

debugger-arch-2

我们将调试器架构进一步划分,分为frontend、backend。

  • frontend聚焦于与用户的交互逻辑,完成调试动作的触发、结果的展示;
  • backend聚焦于目标进程、平台特性相关的底层实现,接收frontend的调试命令,并返回对应的结果,以在frontend进行展示;
  • frontend和backend之间的桥梁就是新引入的服务层,frontend、backend之间可以通过RPC进行通信。

通过引入服务层(Service Layer),调试器的前后端可以实现解耦,前端(frontend)专注于用户交互和命令管理,后端(backend)专注于与被调试进程的底层交互。服务层则负责命令的转发、数据的序列化与反序列化、状态同步等工作。这样一来,调试器不仅可以支持本地调试,还可以很容易地扩展为远程调试、分布式调试等场景。例如,前端可以是命令行工具、IDE插件,甚至是Web界面,而后端则可以部署在本地或远程服务器上,二者通过RPC(如gRPC、JSON-RPC等)进行通信。

这种架构设计极大提升了调试器的灵活性和可扩展性。无论是支持多种用户界面,还是适配不同的操作系统和硬件平台,亦或是实现多用户协作调试、云端调试等高级功能,都变得更加容易。同时,前后端分离也有助于团队协作开发,前端和后端可以并行开发、独立演进。

总之,采用分层、前后端分离的架构,是现代调试器实现的主流趋势,也是满足复杂应用场景和未来扩展需求的坚实基础。

ps:第9章符号级调试器开发,我们将采用这里的前后端分离式架构,以展示现代调试器是如何解决真实调试场景中的挑战的。

调试命令管理

对于一个命令行调试器,涉及到多种启动调试的命令,在调试会话中也需要多种多样的调试命令,这些调试命令驱动着一个高效的调试过程,直到我们定位到问题源头。比如启动调试就可能多种方式,godbg <exec|attach|core|trace> ...,在调试会话中也涉及到大量调试命令,如 break, condition, continue, next, step, stepin, stepout, finish, bt, args, loals 等等,如何对这些调试命令进行有效地管理和扩展是一个挑战。

spf13/cobra

go标准库支持flags,方便对命令行选项进行解析,但是和我们想要的能力比起来,还是差点意思。所以社区里也成长起一些非常优秀的命令行开发支持项目距,比如 spf13/cobra,它是一个基于golang的开源的命令行程序开发框架,它具有如下特点:

  • 支持快速添加cmd;
  • 支持为指定cmd添加subcmd;
  • 支持帮助信息汇总展示;
  • 支持POSIX风格的参数解析;
  • 支持常见数据类型的参数解析;
  • 支持为cmd指定必要参数;
  • 支持生成shell自动补全脚本;
  • 等等;

可以说,cobra是一个非常优秀的命令行程序开发框架,在诸多大型开源项目中得以应用,如kubernetes、hugo、github-cli gh,等等。在我的个人项目中,也有不少是采用了cobra来对命令、子命令进行管理。

命令分组

使用cobra对调试命令进行管理,将给我们带来很大的便利。对于godbg exec <proc>godbg attach <pid>类似的命令及选项管理,cobra绰绰有余,使用默认的设置就可以提供很好的支持。

调试器除了上述“启动调试”相关的命令以外,也有很多“调试会话”中使用的调试命令,如断点相关的,调用栈相关的,查看源码、变量、寄存器等相关的。为了方便调试会话中中查看调试命令的帮助信息,对这些调试命令进行必要的分组是非常有必要的 (调试人员如果不能借助分组快速找到急需的调试命令,就会打断需要高度集中注意力的调试活动)。

比如:

  • break、condition、clear、toggle、on,这几个与增删激活断点以及命中后处理强相关,可以将它们归类到分组“[breakpoint]”;
  • print、display、args、locals、funcs、types、list,这几个与查看变量、参数、函数、类型、源码强相关,可以将它们归类到分组“[show]”;
  • backtrace、frame,这几个与查看调用栈、切换调用栈强相关,可以将它们归类到分组“[frames]”;
  • restart、continue、stepin、stepout、finish,这几个与运行强相关,可以将它们归类到分组“[run]”。
  • ...
  • 其他调试命令及分组;

cobra为每个命令提供了一个属性cobra.Command.Annotations,它是一个map类型,可以为每个命令添加一些kv属性信息,然后基于此可以对其进行一些分组等自定义的操作:

breakCmd.Annotation["group"] = "breakpoint"
clearCmd.Annotation["group"] = "breakpoint"
printCmd.Annotation["group"] = "show"
frameCmd.Annotation["group"] = "frames"

上面我们对几个命令根据功能进行了分组,假如我们用debugRootCmd表示最顶层的命令,那么我们可以自定义debugRootCmd的Use方法,方法内部我们遍历所有的子命令,并根据它们的属性Annotation["group"]进行分组后,再显示帮助信息。

查看帮助信息时将得到如下分组后的展示样式(而非默认列表样式),更便利、更有条理:

[breakpoint]
break : break <locspec>,添加断点
clear : clear <n>,清除断点

[show]
print : print <variable>,显示变量信息

[frames]
frame : frame <n>,选择对应的栈帧

综上不管是调试器启动时的命令,还是调试会话中需要交互式键入的调试命令,都可以安心地使用cobra来完成,cobra能很好地满足我们的开发需求。

其他易用性方案

需求分析阶段列出了一些要支持的调试操作,每个操作基本都需要一个或几个调试命令来支持,而每个调试命令又包含不同的选项。考虑到我们最终交付的是一个命令行调试器,命令行调试器尽管有它的优点,但是缺点也显而易见。

对使用者而言,要记住这么多调试命令、调试选项,还要正确输入它们以及它们的取值,会是一个巨大挑战。为了方便使用者,我们需要考虑一些非常必要的易用性方案设计。

ps: 读者可能有疑问,既然命令行调试器用起来不方便,那为什么不提供一个GUI界面呢? 考虑到在不同软硬件平台的可移植性、操作的一致性、go技术栈以及最终实现的工作量,我们更倾向于提供一个命令行版本的调试器。实际上,当我们掌握了命令行调试器之后,攀登过那陡峭的学习曲线这时候,你也会获得巨大的收益,你可以以一致的调试界面、调试命令、调试习惯在不同软硬件平台上进行调试。

输入补全:启动命令选项

在调试过程中,我们很可能会遗忘命令名和选项名,或者需要高频输入它们,或者很容易输入错误,对于特定类型的选项、参数的值,也可能比较难输入,比如输入一个源文件的位置。此时,就会中断调试会话,这是一个很低效的过程。试想下,我们不得不执行help命令查看帮助信息,帮助信息将污染我们的调试会话,使得我们注意力被分散。所以作为一个调试器产品的设计者、开发者,应该对“查看帮助”信息的需求进行进一步挖掘。

用户是需要查看帮助信息,但是并不一定是通过help的形式,我们可以在他输入命令的同时就给予辅助输入的提示信息,自动补全就是不错的方法。

自动补全大家并不陌生,我们在shell里面使用的很多命令有自动补全的功能,包括 spf13/cobra 开发的命令行应用程序本身也支持生成shell的自动完成脚本(导入即可实现自动补全功能)。

$ godbg completion bash > ~/.bash_godbg
$ source ~/.bash_godbg

然后我们可以执行命令并通过TAB来触发自动补全,如 godbg att<TAB> 会被自动补全为 godbg attach

输入补全:会话命令选项

这里借助spf13/cobra只可以解决启动调试时的命令和选项的自动补全,但是还解决不了调试器进程启动后调试会话内的自动补全。

  • go-prompt是一个不错的自动补全的库,它能够在程序运行期间根据用户输入自动给出自动补全的候选列表,并且支持多种选项设置,如候选列表的颜色、选中列表项的颜色等等。可以说,go-prompt是一个非常不错的选择。但是它的命令管理不如spf13/cobra方便,实际上它也可以和cobra结合使用。

  • cobra-prompt 就是来解决这个问题的,它将go-prompt和spf13/cobra进行了一个比较好的集成,既能利用cobra的命令管理,也能发挥go-prompt的自动补全优势。cobra-prompt的实现原理很简单,将go-prompt获得的用户输入适当处理后,转给cobra debugRootCmd进行处理就可以。

  • liner 本书第6章提供的指令级调试器实现,最初采用了cobra-prompt进行开发,但是最终使用了liner进行代替,因为cobra-prompt的自动补全功能会经常干扰调试会话信息的连贯性,不一定真的有实质性的帮助,所以最后我们替换为了liner代替。最后,使用liner读取用户键入的调试命令,并通过cobra的命令管理来执行调试动作。简言之,我们仍然具备自动补全能力,只是放弃了go-prompt似的自动补全方式。

ps: 这里我们只对调试会话内的命令进行了输入自动补全,对于命令选项,则没有进行支持。调试器使用者可以通过 help <command> 查看相应选项及帮助信息。

输入补全:其他输入信息

输入补全,我们借助spf13/cobra生成的completion脚本可以解决启动调试相关命令选项的输入补全,通过liner可以解决调试会话中命令的输入补全,但是这就够了吗?

对于后续还需要输入的参数值,比如 break main.<funcName> ,此时我们想在main中某个函数处添加断点,但是我们此时希望借助TAB来自动补全输入函数名。spf13/cobra是做不到这点的,如果希望支持类似能力,就还需要进一步探索,这点也可以借助liner来完成。

理论上,我们为每个调试命令设置一个专用的completer(类似spf13/cobra那样),每个completer可以负责自动补全该调试命令的命令名、选项名,以及对应的值。比如 break 命令对应的completer就可以自动提取不同package里定义的funcs,然后根据用户输入进行过滤,并进行补全。

ps:这部分内容权当一种“愿景”了,我们不一定真的实现,但是这么做可能会让调试活动更加简单高效,读者也可以思考下有没有更好的方式。

其他可扩展性支持

除了前后端分离和分层架构,调试器在设计和实现时还应考虑以下可扩展性支持:

  • 多语言支持:调试器不仅要支持Go语言,还应具备扩展到其他语言(如C/C++、Rust等)的能力。这要求符号层和目标层的实现要有良好的抽象,便于适配不同语言的调试信息格式(如DWARF、PDB等)和运行时特性。
  • 插件机制:通过插件机制,调试器可以灵活地扩展新功能。例如,可以为不同的调试命令、表达式求值器、UI组件等提供插件接口,用户或第三方开发者可根据需要动态加载和卸载插件,增强调试器的功能。
  • 脚本化与自动化:支持脚本语言(如Python、Lua等)集成,允许用户编写脚本自动化调试流程、批量设置断点、批量分析变量等。这对于复杂调试场景和大规模问题定位非常有帮助。
  • 远程与分布式调试:除了本地调试,还应支持远程调试和分布式系统的调试。调试器需要具备跨网络通信、认证授权、数据加密等能力,能够安全高效地调试云端、容器、Kubernetes集群等环境中的进程。
  • 多用户协作调试:在某些场景下,多个开发者可能需要协同调试同一个进程。调试器可以设计为支持多用户会话、权限管理、调试状态同步等协作特性,提升团队效率。
  • 可配置性与个性化:调试器应允许用户自定义命令别名、快捷键、UI主题、命令分组等,满足不同用户的使用习惯和偏好。
  • 与其他开发工具集成:调试器应易于与IDE、CI/CD系统、性能分析工具等集成。例如,提供API、命令行接口、事件通知等机制,方便与外部工具协作。
  • 健壮性与可测试性:为保证调试器的健壮性和正确性,应支持单元测试、集成测试、回归测试等自动化测试机制,并具备良好的错误处理和日志记录能力,便于问题定位和维护。

通过上述多维度的可扩展性设计,调试器能够具备持续演进和扩展的能力,更好地适应不断变化的调试场景。在进行到后续章节、介绍完必要前置知识时,我们会继续对这些内容进行展开。

本节小结

本节我们围绕调试器的架构设计与技术方案选择进行了系统梳理。首先介绍了调试器的基础三层架构(UI层、符号层、目标层),并进一步探讨了前后端分离式架构如何提升调试器的灵活性和可扩展性。随后,我们分析了调试命令的管理方式,以及命令行和调试会话中的自动补全技术选型与实现思路。最后,列举了其他多维度的可扩展性设计要点。

通过本节内容,读者可以对现代调试器的整体架构、关键技术选型及未来可扩展方向有一个全面的认识。接下来,我们将以具体的实现为例,逐步带领大家开发一个具备基础调试能力的指令级调试器,深入理解调试器各层的实际落地方式与工程细节。

results matching ""

    No results matching ""