Hulo 语言开发分享 调试器是如何工作的? - V2EX
V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
请不要在回答技术问题时复制粘贴 AI 生成的内容
ansurfen
V2EX    程序员

Hulo 语言开发分享 调试器是如何工作的?

  •  
  •   ansurfen 43 天前 1327 次点击
    这是一个创建于 43 天前的主题,其中的信息可能已经有所发展或是发生改变。

    书接上回,在《 Hulo 编程语言开发 解释器》一文中,我们介绍了Hulo 编程语言的解释器。今天,让我们深入探讨编译流程中的第四个关键环节调试器。

    调试器是编程语言开发中不可或缺的工具,它允许开发者暂停程序执行、检查变量状态、单步执行代码等。而它的核心是断点机制,它允许程序在特定位置暂停执行,并查看环境情况。

    断点

    断点本质上就是一个位置标记

    type Breakpoint struct { File string // 文件名 Line int // 行号 Column int // 列号 Condition string // 条件表达式(可选) Enabled bool // 是否启用 } 

    调试器会收集用户指定要中断的位置,然后存储起来,待解释器走到那一步的时候暂停。

    从 AST 到行列号

    在解析器分析 AST 的时候,我们往往会为 AST 节点添加位置信息:

    type Node interface { Pos() token.Pos End() token.Pos } 

    每个 AST 节点都有两个关键方法:

    • Pos() - 返回节点在源代码中的开始位置
    • End() - 返回节点在源代码中的结束位置

    具体例子:

    1. 数字字面量 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)) // 开始位置 + 长度 } 
    1. 标识符 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)

      • 第 1 行长度:len("fn main() {") = 12,加上\n = 13
      • 第 2 行开始位置:13
      • 15 - 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) // ... } 

    调试循环可以理解为一个协程/线程,它在调试器启动的时候就会开始运行,与解释器的执行异步,这样双方就不会相互卡住。

    • 主线程:执行 Hulo 代码,遇到断点时发送信号
    • 调试协程:监听信号,处理调试命令,控制程序暂停/恢复

    当程序命中断点时,主线程向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接收到继续执行的命令。

    完整的执行流程:

    1. 主线程执行 → 命中断点 → 调用pause()卡住等待
    2. 调试协程 → 接收到暂停信号 → 等待用户命令
    3. 用户操作 → 发送继续命令 → 调试协程设置isPaused = false
    4. 主线程 → 检测到isPaused = false继续执行

    DAP 协议

    DAP (Debug Adapter Protocol) 是微软开发的一个标准化调试协议,它定义了调试器与 IDE 之间的通信规范。

    为什么需要 DAP ?

    想象一下,如果你写了一个调试器,但是只能在命令行使用,那多不方便。用户想要在 VS Code 、IntelliJ IDEA 等图形化编辑器中调试代码,怎么办呢?

    DAP 就是解决这个问题的。它就像是一个"翻译官",把 IDE 的调试命令翻译成调试器能理解的语言,再把调试器的反馈翻译成 IDE 能显示的信息。

    DAP 消息格式

    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"。

    DAP 事件

    调试器会向 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) } 

    实际工作流程:

    1. IDE 连接 - VS Code 连接到 Hulo 的 DAP 服务器/li>
    2. 用户操作 - 用户在 VS Code 中点击"设置断点"
    3. 发送命令 - VS Code 发送 JSON 命令到 DAP 服务器
    4. 调试器处理 - Hulo 调试器接收命令并设置断点
    5. 程序执行 - 程序运行到断点处暂停
    6. 发送事件 - 调试器发送"程序暂停"事件给 VS Code
    7. 界面更新 - VS Code 显示程序已暂停,用户可以查看变量

    这就是现代调试器的标准做法:用统一的协议让不同的工具能够互相配合工作。

    目前尚无回复
    关于     帮助文档     自助推广系统     博客     API     FAQ     Solana     5733 人在线   最高记录 6679       Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 27ms UTC 06:20 PVG 14:20 LAX 23:20 JFK 02:20
    Do have faith in what you're doing.
    ubao snddm index pchome yahoo rakuten mypaper meadowduck bidyahoo youbao zxmzxm asda bnvcg cvbfg dfscv mmhjk xxddc yybgb zznbn ccubao uaitu acv GXCV ET GDG YH FG BCVB FJFH CBRE CBC GDG ET54 WRWR RWER WREW WRWER RWER SDG EW SF DSFSF fbbs ubao fhd dfg ewr dg df ewwr ewwr et ruyut utut dfg fgd gdfgt etg dfgt dfgd ert4 gd fgg wr 235 wer3 we vsdf sdf gdf ert xcv sdf rwer hfd dfg cvb rwf afb dfh jgh bmn lgh rty gfds cxv xcv xcs vdas fdf fgd cv sdf tert sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf shasha9178 shasha9178 shasha9178 shasha9178 shasha9178 liflif2 liflif2 liflif2 liflif2 liflif2 liblib3 liblib3 liblib3 liblib3 liblib3 zhazha444 zhazha444 zhazha444 zhazha444 zhazha444 dende5 dende denden denden2 denden21 fenfen9 fenf619 fen619 fenfe9 fe619 sdf sdf sdf sdf sdf zhazh90 zhazh0 zhaa50 zha90 zh590 zho zhoz zhozh zhozho zhozho2 lislis lls95 lili95 lils5 liss9 sdf0ty987 sdft876 sdft9876 sdf09876 sd0t9876 sdf0ty98 sdf0976 sdf0ty986 sdf0ty96 sdf0t76 sdf0876 df0ty98 sf0t876 sd0ty76 sdy76 sdf76 sdf0t76 sdf0ty9 sdf0ty98 sdf0ty987 sdf0ty98 sdf6676 sdf876 sd876 sd876 sdf6 sdf6 sdf9876 sdf0t sdf06 sdf0ty9776 sdf0ty9776 sdf0ty76 sdf8876 sdf0t sd6 sdf06 s688876 sd688 sdf86