书接上回,在《 Hulo 编程语言开发 解释器》一文中,我们介绍了Hulo 编程语言的解释器。今天,让我们深入探讨编译流程中的第四个关键环节调试器。
调试器是编程语言开发中不可或缺的工具,它允许开发者暂停程序执行、检查变量状态、单步执行代码等。而它的核心是断点机制,它允许程序在特定位置暂停执行,并查看环境情况。
断点本质上就是一个位置标记:
type Breakpoint struct { File string // 文件名 Line int // 行号 Column int // 列号 Condition string // 条件表达式(可选) Enabled bool // 是否启用 }
调试器会收集用户指定要中断的位置,然后存储起来,待解释器走到那一步的时候暂停。
在解析器分析 AST 的时候,我们往往会为 AST 节点添加位置信息:
type Node interface { Pos() token.Pos End() token.Pos }
每个 AST 节点都有两个关键方法:
Pos()
- 返回节点在源代码中的开始位置End()
- 返回节点在源代码中的结束位置具体例子:
10
:type NumericLiteral struct { ValuePos token.Pos // 数字开始的位置 Value string // "10" } func (x *NumericLiteral) Pos() token.Pos { return x.ValuePos // 返回数字开始位置 } func (x *NumericLiteral) End() token.Pos { return token.Pos(int(x.ValuePos) + len(x.Value)) // 开始位置 + 长度 }
x
:type Ident struct { NamePos token.Pos // 标识符开始位置 Name string // "x" } func (x *Ident) Pos() token.Pos { return x.NamePos // 返回标识符开始位置 } func (x *Ident) End() token.Pos { return token.Pos(int(x.NamePos) + len(x.Name)) // 开始位置 + 长度 }
位置转换过程:
实际上,计算行列号最简单的方法就是字符串分割:
func (d *Debugger) getLineFromPos(pos token.Pos) int { // 获取文件内容 content := d.getFileContent() // 将内容按行分割 lines := strings.Split(content, "\n") // 计算 pos 在第几行 currentPos := 0 for i, line := range lines { lineLength := len(line) + 1 // +1 是因为分割符 \n if currentPos <= int(pos) && int(pos) < currentPos + lineLength { return i + 1 // 返回行号(从 1 开始) } currentPos += lineLength } return 1 // 默认返回第 1 行 } func (d *Debugger) getColumnFromPos(pos token.Pos) int { // 获取文件内容 content := d.getFileContent() // 将内容按行分割 lines := strings.Split(content, "\n") // 计算 pos 在第几列 currentPos := 0 for _, line := range lines { lineLength := len(line) + 1 if currentPos <= int(pos) && int(pos) < currentPos + lineLength { // 计算在当前行中的偏移 return int(pos) - currentPos + 1 // 返回列号(从 1 开始) } currentPos += lineLength } return 1 // 默认返回第 1 列 }
实际例子:
假设我们有代码:
fn main() { // 第 1 行 let x = 10 // 第 2 行 }
文件内容:"fn main() {\n let x = 10\n}"
let
关键字:token.Pos(15)
len("fn main() {") = 12
,加上\n
= 1315 - 13 + 1 = 3
,所以let
在第 2 行第 3 列x
标识符:token.Pos(19)
19 - 13 + 1 = 7
,所以x
在第 2 行第 7 列10
数字:token.Pos(23)
23 - 13 + 1 = 11
,所以10
在第 2 行第 11 列Ps. 实际的代码和介绍的肯定不一样,不会写成这样。只是这样计算更直观,方便讲解。
有了断点、位置转换和环境管理,现在我们可以实现完整的断点机制:
解释器在每个语句执行前都要调用断点检查:
func (interp *Interpreter) Eval(node ast.Node) ast.Node { // 关键:每个节点执行前检查断点 if interp.shouldBreak(node) { // 程序暂停,等待调试器命令 interp.pause() } // 正常执行逻辑... switch node := node.(type) { case *ast.Literal: return interp.evalLiteral(node) case *ast.BinaryExpr: return interp.evalBinaryExpr(node) // ... } }
当命中断点时,程序需要暂停等待调试器命令:
func (d *Debugger) pause() { d.isPaused = true // 发送暂停信号到调试循环 d.pauseChan <- struct{}{} // 关键:主线程在这里等待恢复信号 for d.isPaused { // 阻塞等待,直到调试器发送恢复命令 time.Sleep(10 * time.Millisecond) // 避免 CPU 空转 } }
这里我们使用 pauseChan
变量作为暂停信号管道。当命中断点时,向管道发送信号,这个信号会在调试循环中接收并等待命令。
func (d *Debugger) debugLoop() { for { select { case <-d.ctx.Done(): return // 调试器关闭信号 case <-d.pauseChan: // 程序暂停了,开始等待用户命令 d.waitForResume() case cmd := <-d.commandChan: d.handleCommand(cmd) // 调试命令 } } } func main() { // ... go d.debugLoop() interp.Eval(node) // ... }
调试循环可以理解为一个协程/线程,它在调试器启动的时候就会开始运行,与解释器的执行异步,这样双方就不会相互卡住。
当程序命中断点时,主线程向pauseChan
发送信号,调试协程的 select 语句检测到这个信号,立即调用waitForResume()
开始等待用户命令。
waitForResume 的阻塞机制:
func (d *Debugger) waitForResume() { for d.isPaused { select { case cmd := <-d.resumeChan: d.handleCommand(cmd) if cmd.Type == CmdContinue { d.isPaused = false break // 退出等待,主线程可以继续 } } } }
waitForResume()
会一直阻塞在select
语句上,直到从resumeChan
接收到继续执行的命令。
完整的执行流程:
pause()
→ 卡住等待isPaused = false
isPaused = false
→ 继续执行DAP (Debug Adapter Protocol) 是微软开发的一个标准化调试协议,它定义了调试器与 IDE 之间的通信规范。
想象一下,如果你写了一个调试器,但是只能在命令行使用,那多不方便。用户想要在 VS Code 、IntelliJ IDEA 等图形化编辑器中调试代码,怎么办呢?
DAP 就是解决这个问题的。它就像是一个"翻译官",把 IDE 的调试命令翻译成调试器能理解的语言,再把调试器的反馈翻译成 IDE 能显示的信息。
DAP 使用 JSON 格式进行通信,就像两个人用同一种语言交流:
{ "type": "request", "seq": 1, "command": "setBreakpoints", "arguments": { "source": { "path": "/path/to/file.hl" }, "breakpoints": [ { "line": 10, "condition": "x > 5" } ] } }
这个 JSON 消息的意思是:"请在文件/path/to/file.hl
的第 10 行设置一个断点,条件是x > 5
"。
调试器会向 IDE 发送各种事件,告诉 IDE 发生了什么:
{ "type": "event", "seq": 2, "event": "stopped", "body": { "reason": "breakpoint", "threadId": 1, "allThreadsStopped": true } }
这个 JSON 消息的意思是:"程序暂停了,原因是命中了断点"。
有了 DAP 协议,我们就可以在 VS Code 等编辑器中以图形化的方式控制我们的调试器。其实就是通过网络的方式向调试循环发送命令,本着简单原则我们再次改造下上文介绍的伪代码部分:
func main() { // 启动 DAP 服务器,监听来自 IDE 的连接 go d.startDAPServer() // 启动调试循环,处理调试命令 go d.debugLoop() // 开始执行程序 interp.Eval(node) }
实际工作流程:
这就是现代调试器的标准做法:用统一的协议让不同的工具能够互相配合工作。