Lua中如何實現類似gdb的斷點調試–01最小實現

說到Lua代碼調試,最常用的方法應該就是加一堆print進行打印。print大法雖好,但其缺點也是顯而易見的。比如效率低下,需要修改原有函數內部代碼,在每個需要的地方添加print語句,運行一次只能獲取一次信息,下次換個地方又得重新添加print語句。而且有時候,事先並不知道該去哪打印、或者打印什麼內容,需要通過運行中獲取的信息才能確定。

當print大法無法滿足我們的需求時,就需要類似斷點調試這樣更高級的調試功能。本文將從零開始編寫一個Lua調試器,實現類似gdb的斷點調試功能。

本文代碼已開源至Github,歡迎watch/star😘。

本博客已遷移至CatBro’s Blog,那裡是我自己搭建的個人博客,歡迎關注。

定義模塊及接口

首先,我們來定義模塊及接口,創建一個名為luadebug.lua的模塊,該模塊是基於標準庫中的debug庫。為了實現最基本的斷點調試功能,我們的模塊提供了兩個接口setbreakpointremovebreakpoint,分別用於設置斷點和刪除斷點。斷點信息通過一個函數和一個行號指定,返回斷點的id。後續可以通過這個id來刪除相應斷點。

#!/usr/bin/env lua

local debug = require "debug"

-- 省略...

local function setbreakpoint(func, line)
    -- 省略...
end

local function removebreakpoint(id)
    -- 省略...
end

return {
    setbreakpoint = setbreakpoint,
    removebreakpoint = removebreakpoint,
}

維護狀態的數據結構

接着,來定義維護狀態的數據結構,表status維護了所有斷點相關信息,其中的bpnum元素表示當前總共有多少斷點,bpid表示當前的斷點id,這個值是不斷遞增的,bptable則是保存所有斷點信息的表。表bptable的鍵是斷點的id,值也是一個表,保存了斷點的所在的函數和行號。

-- 省略...

-- 記錄斷點狀態
local status = {}
status.bpnum = 0        -- 當前總斷點數
status.bpid = 0         -- 當前斷點id
status.bptable = {}     -- 保存斷點信息的表

-- 省略...

設置斷點接口

接下來來定義我們的setbreakpoint接口。設置斷點時,首先檢查參數有效性,再更新斷點id和斷點數,然後將參數中傳入的函數func和行號line保存到表bptable中下一個斷點id的位置。如果只有一個斷點(從無到有),那麼還需要調用debug.sethook設置鉤子。這是實現斷點調試的核心函數之一,它使得我們有機會停在斷點處。因為是最小實現,簡單起見這裡只設置了line事件。

-- 設置斷點
local function setbreakpoint(func, line)
    if type(func) ~= "function" or type(line) ~= "number" then
        return nil                      --> nil表示無效斷點
    end
    status.bpid = status.bpid + 1
    status.bpnum = status.bpnum + 1
    status.bptable[status.bpid] = {func = func, line = line}
    if status.bpnum == 1 then           -- 第一個斷點
        debug.sethook(linehook, "l")    -- 設置鉤子
    end
    return status.bpid                  --> 返回斷點id
end

鉤子函數

在鉤子函數中,通過debug.getinfo獲取到閉包信息,注意這裡的層次為2,因為debug.getinfo()函數本身的層次是0,鉤子函數層次是1,斷點所在的函數層次即為2。然後遍歷斷點表,與獲取的閉包信息進行比較,如果函數和行號都匹配,說明命中斷點。我們打印一行提示信息,然後調用debug.debug()進入交互調試模式,debug.debug是實現斷點調試的另一個核心函數,它使得我們可以在斷點處輸入任意代碼執行。交互調試模式一直持續,直到用戶輸入cont為止。

-- 鉤子函數
local function linehook (event, line)
    local info = debug.getinfo(2, "nfS")
    for _, v in pairs(status.bptable) do
        if v.func == info.func and v.line == line then
            local prompt = string.format("(%s)%s %s:%d\n",
                info.namewhat, info.name, info.short_src, line)
            io.write(prompt)
            debug.debug()
        end
    end
end

刪除斷點接口

刪除斷點比較簡單,首先檢查id參數是否有效,如果無效直接返回,如果有效則將斷點表中相應id位置的值置為nil即可,然後更新斷點數,如果已經沒有斷點了,則清除鉤子。

-- 刪除斷點
local function removebreakpoint(id)
    if status.bptable[id] == nil then
        return
    end
    status.bptable[id] = nil
    status.bpnum = status.bpnum - 1
    if status.bpnum == 0 then
        debug.sethook()                 -- 清除鉤子
    end
end

至此我們的模塊就編寫好了,下面對這個模塊進行測試。

測試腳本

我們編寫一個如下的測試腳本test.lua,定義了兩個函數foo和bar,然後分別在兩個函數中設置了一個斷點(注意:注釋和空行不是有效的斷點行),然後多次調用函數並先後刪除斷點:

local ldb = require "luadebug"
local setbp = ldb.setbreakpoint
local rmbp = ldb.removebreakpoint

g = 1

local u = 2
local function foo (n)
    local a = 3
    a = a + 1
    u = u + 1
    g = g + 1
end

local function bar (n)
    n = n + 1
end

local id1 = setbp(foo, 11)      -- 設置斷點1
local id2 = setbp(bar, 16)      -- 設置斷點2

foo(10)
bar(10)

rmbp(id1)                       -- 刪除斷點1

foo(20)
bar(20)

rmbp(id2)                       -- 刪除斷點2

foo(30)
bar(30)

測試驗證

然後我們運行測試腳本,可以看到程序停在了foo函數的斷點1處。

$ lua test.lua
(local)foo test.lua:11
lua_debug> 

我們可以在這裡打印調用棧信息

$ lua test.lua
(local)foo test.lua:11
lua_debug> print(debug.traceback())
stack traceback:
	(debug command):1: in main chunk
	[C]: in function 'debug.debug'
	./luadebug.lua:20: in hook '?'
	test.lua:11: in local 'foo'
	test.lua:22: in main chunk
	[C]: in ?
lua_debug> 

可以看到foo函數在第4層(第1層是執行我們調試命令的main chunk,第2層是debug.debug函數,第3層是hook函數)。我們打印foo函數中第一個局部變量(即固定參數n)的值

lua_debug> print(debug.getlocal(4, 1))
n	10
lua_debug>

然後打印第二個局部變量(即a)的值

lua_debug> print(debug.getlocal(4, 2))
a	4
lua_debug>

然後我們輸入cont繼續代碼的執行,碰到了bar函數的斷點2

lua_debug> cont
(local)bar test.lua:16
lua_debug>

我們打印bar函數的參數n的值,可以看到也是10

lua_debug> print(debug.getlocal(4, 1))
n	10
lua_debug>

然後我們輸入cont繼續執行代碼,因為斷點1已經被移除,所以再次停在了bar函數的斷點2處

lua_debug> cont
(local)bar test.lua:16
lua_debug>

我們再來打印下參數n的值,此時參數n的值是20

lua_debug> print(debug.getlocal(4, 1))
n	20
lua_debug>

我們再次輸入cont,因為斷點2也被移除了,所以第三次調用foo函數和bar函數就沒有再碰到斷點,程序運行結束

lua_debug> cont
$

這樣一個最簡單的Lua斷點調試器就完成了。雖然還比較簡陋,但是已經能夠應付一些簡單的調試了。🎉