V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
• 请不要在回答技术问题时复制粘贴 AI 生成的内容
ansurfen
V2EX  ›  程序员

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

  •  
  •   ansurfen · 1 天前 · 585 次点击

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

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

    目前尚无回复
    关于   ·   帮助文档   ·   自助推广系统   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   4172 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 28ms · UTC 01:01 · PVG 09:01 · LAX 18:01 · JFK 21:01
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.