書接上回,在《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()- 返回節點在源代碼中的結束位置
具體例子:
-
數字字面量
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)- 第1行長度:
len("fn main() {") = 12,加上\n= 13 - 第2行開始位置:13
15 - 13 + 1 = 3,所以let在第2行第3列
- 第1行長度:
-
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接收到繼續執行的命令。
完整的執行流程:
- 主線程執行 → 命中斷點 → 調用
pause()→ 卡住等待 - 調試協程 → 接收到暫停信號 → 等待用户命令
- 用户操作 → 發送繼續命令 → 調試協程設置
isPaused = false - 主線程 → 檢測到
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)
}
實際工作流程:
- IDE連接 - VS Code連接到Hulo的DAP服務器
- 用户操作 - 用户在VS Code中點擊"設置斷點"
- 發送命令 - VS Code發送JSON命令到DAP服務器
- 調試器處理 - Hulo調試器接收命令並設置斷點
- 程序執行 - 程序運行到斷點處暫停
- 發送事件 - 調試器發送"程序暫停"事件給VS Code
- 界面更新 - VS Code顯示程序已暫停,用户可以查看變量
這就是現代調試器的標準做法:用統一的協議讓不同的工具能夠互相配合工作。