跳转至

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。从源码结构看,它把几类职责集中在一起:

  1. 构造时绑定 XDebugSession 和 Java DebuggerSession
  2. 创建默认断点处理器,包括行断点、异常断点、字段断点、方法断点、通配断点和集合断点。
  3. 监听 Java 调试器暂停事件,转换成 XDebugger 的 positionReached(...) 或 breakpoint reached。
  4. 实现 startStepOver()startStepInto()startStepOut()resume()stop()
  5. 定制 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 的实现展示了完整思路:

  1. 构造时从 Java frame descriptor 计算 XSourcePosition
  2. getEvaluator() 返回 Java evaluator。
  3. computeChildren() 在合适的调试上下文中构造变量列表。
  4. 变量包含 this、静态字段、可见局部变量、catch 参数、decompiled local variables 等。
  5. 对变量构造、异常和 frame 失效做降级处理。

自定义调试器可以按这个模型拆:

runtime frame
  -> source position mapper
  -> stack frame presentation
  -> scope list
  -> XValue children
  -> evaluator

如果后端变量请求是远程调用,computeChildren() 里不能同步等待。应快速返回,并在后台请求完成后调用 node API 填充子节点。变量数量大时要分页或分组,例如 localsglobalsthisstaticclosure

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”之类的错误。

实现建议:

  • 语法错误、运行时异常、超时、上下文无效要分开显示。
  • 对表达式是否允许副作用有明确策略。
  • 条件断点求值失败时不要让会话崩溃。
  • 日志断点表达式应限制执行成本,避免每次命中都做昂贵计算。

源码导读顺序

建议按这个顺序读源码:

  1. XDebugProcess:先理解 IDE 要你实现哪些动作。
  2. XBreakpointHandler:理解断点如何进入后端。
  3. XStackFrame:理解暂停后如何展示 frame、source position 和 evaluator。
  4. XValue / XValueContainer:理解变量树如何懒加载。
  5. JavaDebugProcess:看真实语言调试器如何把后端事件接到 XDebugSession
  6. JavaLineBreakpointType:看复杂语言如何设计断点可放置位置和 variant。
  7. 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 避免无效断点。
  • 后端暂停事件能构造 XSuspendContextXStackFrame
  • XStackFrame.getSourcePosition() 有路径映射失败路径。
  • 变量树懒加载,支持取消或超时。
  • evaluator 区分语法错误、运行时错误、超时和 frame 失效。
  • Split Mode 下后端连接留在 backend,frontend 只展示 UI。

参考来源