Stories

Detail Return Return

Hulo 語言開發分享 —— 調試器是如何工作的? - Stories Detail

書接上回,在《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))  // 開始位置 + 長度
    }
  2. 標識符 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顯示程序已暫停,用户可以查看變量

這就是現代調試器的標準做法:用統一的協議讓不同的工具能夠互相配合工作。

user avatar soroqer Avatar xinggandemuer_b5u1v2 Avatar didiaodekaishuiping Avatar zhuifengdekaomianbao Avatar benpaodekaixinguo Avatar fengdudeyema Avatar guishangguandao Avatar gmicloud Avatar yejianfeixue Avatar tyltr Avatar lixingning Avatar liberhome Avatar
Favorites 19 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.