VS Code 都普遍走 DAP(Debug Adapter Protocol)的年代,Godot 坚持用自己的调试协议——不是审美问题,是历史包袱加需求错位:引擎 3.x 时代就有远程调试器,当时 DAP 还没成气候;且引擎需要的不只是"断点加变量",还有场景树检查、远程节点属性编辑、profiler、内存快照这些游戏引擎特有的通道。这篇拆协议结构和两个核心机制:断点怎么拦住字节码,变量怎么从栈帧里搬出来。
通道结构:一条 TCP,消息名分发
编辑器启动游戏实例(或附加到外部进程)时,运行端在启动参数里拿到调试器的 host:port,建立一条 TCP 长连接。这条连接是调试器和编辑器之间唯一的路,所有功能——断点、变量、profiler、内存面板、场景树检查——都在上面多路复用,靠消息名区分。
负载的编码走引擎自有的序列化,结构上是 [消息名, 数据数组]:消息名是字符串,数据数组是变长的。高频消息的设计原则是发差异不发全量——场景树增删只发变化的子树路径,profiler 只发每个 tick 的计数增量。这条通道不是为通用性设计的,是为"编辑器已经在用 Variant 表达一切"这个事实设计的,任何引擎类型都能直接塞进负载,没有跨语言 schema 的翻译层。
编辑器侧的接收端是 EditorDebuggerNode / ScriptEditorDebugger,消息按名字路由到对应面板;运行侧的发送端是 core/debugger/ 下的 EngineDebugger 和 RemoteDebugger。想给调试器加自定义通道(比如自己插件想往调试面板塞数据)有官方口子:EngineDebugger.register_message_capture,运行端 capture 消息,编辑器端插件注册同名校验器接收。
断点:行号表下发,指令边界检查
断点的实现朴素但高效。流程是:
- 你在编辑器点出行号左侧的红点,编辑器把
{脚本路径: [行号集合]}打包成消息发给运行端。 - 运行端的脚本调试器把断点表挂到对应
GDScriptFunction上。 - VM 执行字节码时,只在携带行号信息的指令边界检查当前行是否在断点集合里。纯算术语句不换行的,连续执行中间没有任何检查点。
这解释了调试器体验里的两个现象。第一,断点"打不准"——一行里多条语句(分号挤两行逻辑)时,VM 停在第一个边界,你以为的那条语句可能还没到。第二,断点检查的开销和代码里"逻辑行"的数量成正比,和语句密度无关:一个十万次迭代的循环只要在一个逻辑行内,断点检查只发生一次(进循环那行)。所以开着调试器跑游戏,断点表为空时性能损耗近似为零,损耗来自后面要讲的变量通道。
变量检查:从栈帧到 JSON 的搬运税
命中断点后,编辑器请求当前栈帧的局部变量。运行端做的事:遍历 VM 栈上的 Variant 槽位,按调试符号表还原变量名和值,然后序列化成传输格式发过去。这笔税有三层:
- 遍历栈帧:每帧 O(局部变量数),深度调用栈时是各帧之和。
- Variant → 传输格式:Vector3 这类小结构体便宜,Array/Dictionary 是深递归遍历。
- 编辑器侧还原:收到后再重建树形 UI。
编辑器调试面板上"每次步进都全量刷新变量列表"的体验,背后就是这个 O(栈 × 变量 × 序列化) 在跑。被观察的变量如果是大 Dictionary(比如缓存表),单次步进的刷新能明显卡一下——这不是调试器写得差,是协议"按需全量拉取"的固有成本。写代码时把大缓存挪出局部作用域(挂成员变量),调试体验会顺很多。
profiler 的同通道复用与节流
profiler 和断点共享通道但频率高几个量级:每帧每个域(process/physics/idle)都要上报时间。直接发会淹没通道,运行端的处理是缓冲加节流:profiler 数据先进一个环形缓冲,按固定间隔(而不是每帧)批量 flush 一次,消息里带时间戳,编辑器侧自己按时间轴展开。掉帧时缓冲满了就丢最旧的——profiler 数据宁可丢不可堵,堵了断点消息就延迟,调试手感全毁。
这个设计立场值得记住:调试通道里,控制类消息(断点、继续、单步)优先级高于数据类消息。4.x 的实现用同一 TCP 流没有真正优先级队列,靠的是数据类消息节流和丢弃策略,让控制消息实际上总能挤过去。如果哪天协议要重做成 DAP 兼容,这条优先级纪律是必须先保留的性质。