XDebugger 源码导读:从 API 到 Java 调试器实现¶
官方 SDK 对 Run Configuration 和 Execution API 有相对完整的说明,但 XDebugger 的高层教程较少。实现调试器时,最可靠的阅读路径是:先看 platform/xdebugger-api 的抽象契约,再看一个真实语言调试器如何把运行时协议接到这些契约上。本章以 IntelliJ IDEA 开源 Java 调试器为主线做导读。核对日期:2026-06-18。
源码地图¶
| 层级 | 关键类 | 读源码时关注什么 |
|---|---|---|
| XDebugger API | XDebugProcess |
会话生命周期、step/resume/stop、断点处理器、console、状态消息 |
| XDebugger API | XBreakpointHandler |
IDE 断点如何注册到后端、如何移除 |
| XDebugger API | XStackFrame |
暂停后如何提供 source position、变量和 evaluator |
| XDebugger API | XValue / XValueContainer |
变量树如何展示、如何懒加载子节点 |
| Java debugger | JavaDebugProcess |
Java 调试会话如何实现 XDebugProcess |
| Java debugger | JavaLineBreakpointType |
Java 行断点如何判断可放置位置和行内变体 |
| Java debugger | JavaStackFrame |
Java 栈帧如何映射 source position、locals、this、evaluator |
不要把 Java 调试器实现当作可复制模板。它包含大量 JDI、字节码、lambda、匿名类、decompiler、memory view 和历史兼容逻辑。真正可复用的是它的分层方式:IDE 层只处理 XDebugger 模型,语言运行时细节放在后端适配层。
XDebugProcess:会话的中心协调者¶
XDebugProcess 是一个调试会话在 IDE 侧的入口。它不是调试协议客户端本身,而是把协议能力暴露给 IDE:
getBreakpointHandlers()返回本会话支持的断点处理器。getEditorsProvider()提供表达式编辑、断点条件、watch 等位置使用的编辑器支持。startStepOver()、startStepInto()、startStepOut()、resume()、runToPosition()把用户动作转给后端。stop()/stopAsync()负责停止调试并释放资源。createConsole()、createTabLayouter()可定制 Debug Tool Window 内容。getCurrentStateMessage()/getCurrentStateMessageFlow()用于展示当前状态,后者对 split debugger client 更友好。
实现自定义调试器时,不要在 action 里直接调用这些方法。IDE 用户动作应走 XDebugSession,再由 session 调用 XDebugProcess。源码注释里也明确说明:stop()、resume()、runToPosition() 等不应由插件直接调用,而应通过 XDebugSession。
最小实现思路:
class MyDebugProcess(
session: XDebugSession,
private val backend: MyDebugBackend
) : XDebugProcess(session) {
override fun getEditorsProvider(): XDebuggerEditorsProvider = MyEditorsProvider()
override fun getBreakpointHandlers(): Array<XBreakpointHandler<*>> =
arrayOf(MyLineBreakpointHandler(backend))
override fun startStepOver(context: XSuspendContext?) {
backend.stepOver(currentThreadId(context))
}
override fun resume(context: XSuspendContext?) {
backend.resume(currentThreadId(context))
}
override fun stop() {
backend.close()
}
}
关键点是:MyDebugProcess 只做协调,不直接解析协议包、不阻塞 EDT、不持有 Swing 组件状态。
JavaDebugProcess:Java 调试器怎么接入¶
JavaDebugProcess 继承 XDebugProcess。从源码结构看,它把几类职责集中在一起:
- 构造时绑定
XDebugSession和 JavaDebuggerSession。 - 创建默认断点处理器,包括行断点、异常断点、字段断点、方法断点、通配断点和集合断点。
- 监听 Java 调试器暂停事件,转换成 XDebugger 的
positionReached(...)或 breakpoint reached。 - 实现
startStepOver()、startStepInto()、startStepOut()、resume()、stop()。 - 定制 Debug Tool Window,例如 Threads、Memory View、Overhead Monitor 等 Java 专属内容。
这说明真实语言调试器通常不是只有一个 XDebugProcess。它还需要一个语言后端会话对象。对 Java 来说是 JDI/DebuggerSession;对 DAP 调试器来说可能是 DAP client;对 Python/JavaScript 可能是 socket、stdio 或 IDE 内置语言插件后端。
推荐结构:
XDebugSession
-> MyDebugProcess
-> MyRuntimeDebugSession
-> protocol client / VM API / remote adapter
当后端发出“暂停”事件时,流程应当是:
backend stopped event
-> build XSuspendContext
-> build current XStackFrame
-> session.positionReached(context)
如果暂停是由断点触发,还要把后端断点 ID 映射回对应的 XBreakpoint,让 IDE 能高亮断点、处理日志断点和条件断点。
XBreakpointHandler:断点同步边界¶
XBreakpointHandler<B extends XBreakpoint<?>> 的契约很小:注册断点和反注册断点。
registerBreakpoint(XBreakpoint)
unregisterBreakpoint(XBreakpoint, temporary)
它小是有意的。IDE 只知道用户在某个文件、行、条件或属性上创建了断点;调试后端才知道运行时是否能接受这个断点。插件应在 handler 内完成:
- 把
VirtualFile + line映射为后端 source path。 - 把 IDE 的 0-based line 转成后端要求的 line 基准。
- 传递条件、日志表达式、命中次数等属性。
- 保存
XBreakpoint -> backendBreakpointId映射。 - 后端返回 verified / rejected 时更新 UI 或 console 提示。
Java 调试器的 JavaDebugProcess 会返回多个默认 handler。这个设计值得借鉴:不要做一个“万能 handler”处理所有断点类型。行断点、异常断点、函数断点、字段断点、地址断点的生命周期和后端参数不同,拆开更容易测试。
JavaLineBreakpointType:断点可放置位置不是简单行号¶
JavaLineBreakpointType 是 Java 行相关断点的入口。源码里能看到几类复杂度:
- 创建
JavaLineBreakpointProperties。 - 判断
canPutAt(file, line, project),避免在空白、注释或无效位置放断点。 - 为同一行的 lambda、条件 return、普通行位置提供不同 breakpoint variant。
- 将断点属性和 source position 互相转换。
- 支持 decompiled class、inline position、run to cursor 等特殊情况。
对自定义语言来说,重点不是复刻 Java 的 lambda 逻辑,而是明确自己的语言语义:
| 语言特性 | 断点设计问题 |
|---|---|
| 多语句同一行 | 是否需要行内断点 variant |
| lambda / closure | 断点落在外层还是闭包体 |
| 模板语言 | 运行时位置映射到模板还是生成代码 |
| transpiled language | 是否有 source map |
| notebook / scratch / REPL | 文件是否有稳定路径 |
| 远程文件 | 是否需要 path mapping |
只要断点可能“看起来能创建但永远不会命中”,就应在 canPutAt、variant 或用户提示里解决,而不是把问题留到运行时。
JavaStackFrame:暂停后展示什么¶
XStackFrame 表示一层调用栈。API 文档中最关键的点是:
- 选中的 frame 会显示在 Debug Tool Window 的 Variables 面板。
- 实现调试器时,重写
XValueContainer.computeChildren()展示 locals、parameters 和 fields。 getEvaluator()用于条件断点、日志断点、Evaluate action 和 watches。getSourcePosition()返回当前执行位置。
JavaStackFrame 的实现展示了完整思路:
- 构造时从 Java frame descriptor 计算
XSourcePosition。 getEvaluator()返回 Java evaluator。computeChildren()在合适的调试上下文中构造变量列表。- 变量包含
this、静态字段、可见局部变量、catch 参数、decompiled local variables 等。 - 对变量构造、异常和 frame 失效做降级处理。
自定义调试器可以按这个模型拆:
runtime frame
-> source position mapper
-> stack frame presentation
-> scope list
-> XValue children
-> evaluator
如果后端变量请求是远程调用,computeChildren() 里不能同步等待。应快速返回,并在后台请求完成后调用 node API 填充子节点。变量数量大时要分页或分组,例如 locals、globals、this、static、closure。
XValue:变量节点必须轻量¶
XValue 表示 debugger tree 中的一个值。源码注释明确提醒:computePresentation() 从 EDT 调用,应快速返回。值节点可以:
- 设置展示图标、类型和值。
- 通过
computeChildren()懒加载子属性。 - 提供 evaluation expression。
- 提供 instance evaluator。
- 提供 value modifier 支持 set value。
常见错误是把远程 variables 请求、对象序列化或 getter 调用放在 presentation 阶段同步执行。这会卡住 Debug Tool Window。更稳的做法是:
computePresentation
-> 立即展示 name/type/preview/loading
-> 后台请求详细值
-> 完成后更新 node 或在 computeChildren 中加载
对大型对象,优先展示 preview,再由用户展开。不要默认递归展开对象图。
表达式求值与断点条件¶
XStackFrame.getEvaluator() 同时支撑多个功能:
- 条件断点。
- 日志断点。
- Evaluate Expression。
- Watches。
这意味着 evaluator 的上下文必须稳定:当前线程、当前 frame、当前语言、当前 source position、当前 classloader / module / scope 都要可确定。后端恢复后,旧 evaluator 应拒绝执行或返回“frame unavailable”之类的错误。
实现建议:
- 语法错误、运行时异常、超时、上下文无效要分开显示。
- 对表达式是否允许副作用有明确策略。
- 条件断点求值失败时不要让会话崩溃。
- 日志断点表达式应限制执行成本,避免每次命中都做昂贵计算。
源码导读顺序¶
建议按这个顺序读源码:
XDebugProcess:先理解 IDE 要你实现哪些动作。XBreakpointHandler:理解断点如何进入后端。XStackFrame:理解暂停后如何展示 frame、source position 和 evaluator。XValue/XValueContainer:理解变量树如何懒加载。JavaDebugProcess:看真实语言调试器如何把后端事件接到XDebugSession。JavaLineBreakpointType:看复杂语言如何设计断点可放置位置和 variant。JavaStackFrame:看变量、this、locals、source position、evaluator 的真实组合方式。
读 Java 源码时保持两个问题:
- 这段代码属于“XDebugger 通用模型”,还是“Java/JDI 特有历史复杂度”?
- 我的语言后端是否真的需要同样能力,还是可以用更小的协议适配层实现?
自定义语言调试器落地清单¶
plugin.xml声明com.intellij.modules.xdebugger和语言依赖。- Run Configuration 可保存 launch/attach 参数。
- ProgramRunner 能区分 Run 和 Debug。
XDebugProcess不阻塞 EDT,stop()幂等。- 每类断点有独立 handler 和清晰后端映射。
canPutAt或 variant 避免无效断点。- 后端暂停事件能构造
XSuspendContext和XStackFrame。 XStackFrame.getSourcePosition()有路径映射失败路径。- 变量树懒加载,支持取消或超时。
- evaluator 区分语法错误、运行时错误、超时和 frame 失效。
- Split Mode 下后端连接留在 backend,frontend 只展示 UI。