Lua中如何實現類似gdb的斷點調試—09支援動態添加和刪除斷點

前面已經支援了幾種不同的方式添加斷點,但是必須事先在程式碼中添加斷點,在使用上不是那麼靈活方便。本文將支援動態增刪斷點,只需要開一開始引入調試庫即可,後續可以在調試過程中動態的添加和刪除斷點。事不宜遲,我們直接進入正題。

源碼已經上傳Github,歡迎watch/star😘。

本部落格已遷移至CatBro’s Blog,那是我自己搭建的個人部落格,歡迎關注。本文鏈接

實現分析

入口斷點

儘管我們目標是支援動態添加斷點,但還是需要一個入口,提供用戶添加初始的斷點。仍然像之前一樣,在用戶程式碼中顯式添加的確可以,但顯然不是我們想要效果。理想的效果就是用戶開始調試程式,就自動停在入口處,等待用戶輸入交互資訊,就像gdb那樣。

因為引入調試庫這個動作是肯定要做的,所以最方便的方式就是在引入這個庫的時候就直接停到入口斷點。我們可以在調試庫中實現一個init方法,在require這個調試庫之後調用init進入調試入口,類似下面這樣

require("luadebug").init()

用戶程式碼中只需要添加這樣一行,無需其他任何改動,後續就可以交互模式中動態添加斷點了。

支援動態添加斷點

要在交互模式中動態添加斷點,我們的介面函數如添加斷點函數、刪除斷點函數就需要在交互模式的作用域中可見,所以需要將公共介面函數放到_G_ENV中。但是放到這樣的全局表中,可能出現名字衝突的情況,需要支援通過參數自定義介面函數的名稱。

支援了動態斷點之後,原本在call事件中判斷函數是否有斷點並記錄在status.stackinfos中,然後在return事件中查詢該值的機制就失效了。因為隨時可以動態增刪斷點,所以在call和return事件都需要實時進行判斷,然後根據結果決定是否添加或刪除line事件。

另外為了方便添加斷點,擴展斷點添加函數以支援用”.”表示當前函數或當前包。

支援動態刪除斷點

要支援動態刪除斷點,需要添加一個斷點列印函數以查看當前的斷點情況。

鉤子函數

首先來看鉤子函數,因為需要支援動態增刪斷點,所以call和return事件需要相應修改。先看call事件改動,updatehookevent函數把之前根據函數資訊判斷是否有斷點,並調整line事件的邏輯給封裝起來了,因為現在在return事件中也需要進行這些操作。而status.stackinfos中則不再快取hasbreak,因為支援動態添加斷點後,需要實時判斷了。

local function hook (event, line)
    local s = status
    if event == "call" or event == "tail call" then
        -- level 2: hook, target func
        local sinfo = debug.getinfo(2, "nf")
        local finfo = updatehookevent(sinfo)
        if event == "call" then     -- for tail call, just overwrite
            s.stackdepth = s.stackdepth + 1
        end
        s.stackinfos[s.stackdepth] =
            {stackinfo = sinfo, funcinfo = finfo}
        -- 省略...
    end
end

然後來看return事件的改動。s.stackinfos中把當前函數出棧,這還是跟之前一樣。然後如果已經刪除了所有斷點,那麼將鉤子函數移除,並清空s.stackinfos快取。如果棧中還有函數,則調用updatehookevent函數,這裡的參數是即將返回的函數的資訊。

local function hook (event, line)
    -- 省略...
        elseif event == "return" or event == "tail return" then
        if s.stackdepth > 0 then
            s.stackinfos[s.stackdepth] = nil
            s.stackdepth = s.stackdepth - 1
        end
        if s.bpnum == 0 then
            debug.sethook()
            s.stackinfos = {}
            s.stackdepth = 0
        end
        if s.stackdepth > 0 then
            updatehookevent(s.stackinfos[s.stackdepth].stackinfo)
        end
    -- 省略...
end

我們來看下updatehookevent的實現:

function updatehookevent(stackinfo)
    local s = status
    local func = stackinfo.func
    local name = stackinfo.name
    local funcinfo = getfuncinfo(func)
    local hasbreak = false
    -- check unsolved srcbp
    solvesrcbp(funcinfo, func)

    if funcinfo.what ~= "C" then
        setsrcfunc(funcinfo, func)
    end

    if s.funcbpt[func] then
        hasbreak = true
    end
    if not hasbreak and s.namebpt[name] then
        local min = funcinfo.linedefined
        local max = funcinfo.lastlinedefined
        for k, _ in pairs(s.namebpt[name]) do
            if type(k) == "number" and ((k >= min and k <= max) or k == 0) then
                hasbreak = true
                break
            end
        end
    end
    -- found breakpoint in current function
    if hasbreak then
        debug.sethook(hook, "crl")  -- add "line" event
    else        -- no breakpoints found
        debug.sethook(hook, "cr")   -- remove "line" event
    end

    return funcinfo
end

大部分都是之前的call事件中乾的事情,首先檢查srcbpt中是否有當前包中未解析的斷點,然後判斷當前函數時候有斷點,有斷點打開line事件,沒有斷點移除line事件。

初始化函數

我們分成三個部分來看初始化函數,首先第一部分是將函數註冊到全局表_G

local function init(name_table)
    local s = status
    if not _G.luadebug_inited then
        _G.luadebug_inited = true
        if name_table and type(name_table) == "table" then
            if hasdupname(name_table) then
                return
            end
            _G[name_table[1]] = setbreakpoint
            _G[name_table[2]] = removebreakpoint
            _G[name_table[3]] = printvarvalue
            _G[name_table[4]] = setvarvalue
            _G[name_table[5]] = printtraceback
            _G[name_table[6]] = printbreakinfo
            _G[name_table[7]] = help
            hascustomnames = true
            customnames = name_table
        else
            if hasdupname(longnames) then
                return
            end
            if hasdupname(shortnames) then
                return
            end
            _G.setbreakpoint = setbreakpoint
            _G.removebreakpoint = removebreakpoint
            _G.printvarvalue = printvarvalue
            _G.setvarvalue = setvarvalue
            _G.printtraceback = printtraceback
            _G.printbreakinfo = printbreakinfo
            _G.help = help
            -- short names
            _G.b = setbreakpoint
            _G.d = removebreakpoint
            _G.p = printvarvalue
            _G.bt = printtraceback
            _G.s = setvarvalue
            _G.i = printbreakinfo
            _G.h = help
        end
    end
    -- 省略...
end

可選參數name_table用於指定自定義的函數名稱。我們添加了一個標記luadebug_inited表示是否已經初始化了全局表。如果還沒有則進行註冊,如果提供了自定義的函數名,則註冊自定義的,否則註冊默認的函數名。註冊前使用hasdupname函數檢查_G表中是否已經有了同名的成員,如果有則終止註冊,函數返回。

接著看函數第二部分,這部分在輸出一些提示資訊後debug.debug進入交互模式,這就是我們第一個入口斷點,可以在這裡添加一些初始的斷點。

local function init(name_table)
    -- 省略...
    io.write(string.format("luadebug %s start ...\n", version))
    if hascustomnames then
        io.write("input '" .. customnames[7] .. "()' for help info or '"
            .. customnames[7] .. "(1)' for verbose info\n")
    else
        io.write("input 'help()' for help info or 'help(1)' for verbose info\n")
    end

    local sinfo = debug.getinfo(2, "nfl")
    local func = sinfo.func
    local name = sinfo.name
    local finfo = getfuncinfo(func)
    local prompt = string.format("%s (%s)%s %s:%d\n",
        finfo.what, sinfo.namewhat, name, finfo.short_src, sinfo.currentline)
    io.write(prompt)
    debug.debug()
    -- 省略...
end

接下來看函數的第三部分,這部分可能不太好理解。我們的status.stackinfos是用於快取調用棧的函數資訊的,call事件時入棧,return事件時出棧,我們依賴這個快取的函數資訊來決定是否添加line事件。但是在sethook函數啟動鉤子之前已經在調用棧中的函數,我們是沒有快取的函數資訊的,也就造成即使我們在這些函數上添加了斷點,也沒有辦法真正斷到那裡。

解決辦法有兩個:一個是不使用快取,每次都debug.getinfo實時獲取調用棧中的函數資訊。這樣雖然簡單,但是性能有一定損失。第二個辦法就是我們在第一次調用sethook函數前,把缺失的調用棧函數資訊手動補上去。

原先我們是在添加第一個斷點時,debug.sethook啟動鉤子函數,因為我們有多個斷點添加函數,且存在潛嵌套調用的情況,所以如果在斷點設置函數中處理程式碼上會有重複,而且debug.getinfo在層數上時不確定的,所以我們決定在init函數中干這個事情。

local function init(name_table)
    -- 省略...
    if s.bpnum > 0 then
        if s.stackdepth == 0 then       -- set hook
            local max_depth = 2
            while ( true ) do
                if not debug.getinfo(max_depth, "f") then
                    max_depth = max_depth - 1
                    break
                end
                max_depth = max_depth + 1
            end
            -- init stackinfos
            for i=max_depth, 1, -1 do
                s.stackdepth = s.stackdepth + 1
                local sinfo = debug.getinfo(i, "nf")
                local func = sinfo.func
                local finfo = getfuncinfo(func)
                s.stackinfos[s.stackdepth] =
                    {stackinfo = sinfo, funcinfo = finfo}
            end
            -- add sethook
            s.stackdepth = s.stackdepth + 1
            s.stackinfos[s.stackdepth] =
                {stackinfo = {name = "sethook", func = debug.sethook},
                 funcinfo = getfuncinfo(debug.sethook)}
            debug.sethook(hook, "cr")
        end
    end
	-- 省略...
end

首先檢查是否添加了斷點,如果沒有斷點不需要添加鉤子函數。然後檢查當前s.stackdepth是否為0,這是考慮到init函數可能被多次調用的情況,只有第一次才需要手動補調用棧資訊。接下來的while循環是為了探測調用棧的深度,之所以不使用固定值,是考慮到調用init函數的也不一定就是最外層。然後從棧的最深處開始一層一層添加,最後再補上sethook函數本身。補充完status.stackinfos資訊後就可以調用debug.sethook設置鉤子函數了。

既然我們在init函數中sethook了,那麼之前設置斷點函數中的sethook就都可以去掉了。

斷點列印函數

斷點列印函數非常簡單,只是遍歷status.bptable表,列印斷點資訊,對應通過函數名字添加的斷點列印名字及行數,其餘斷點列印包名及行數。

local function printbreakinfo()
    local s = status
    for i=1,s.bpid do
        local bp = s.bptable[i]
        local prompt
        if bp then
            if bp.name then
                prompt = string.format("id: %d, name: %s, line: %d\n",
                    i, bp.name, bp.line)
            else
                prompt = string.format("id: %d, src: %s, line: %d\n",
                    i, bp.src, bp.line)
            end
            io.write(prompt)
        end
    end
end

其他

help幫助函數,以及擴展斷點添加函數支援用”.”表示當前函數或當前包,我就不專門講了。另外,既然我們的介面函數已經支援在交互模式中動態調用了,那麼也就不需要再導出了,模組只需要導出init函數即可。

return {
    init = init,
}

測試

我們編寫一個如下的Lua測試腳本

require("luadebug").init()
local lib = require "testlib"

local g = 1
local function faa ()
    g = 2
end

faa()
lib.foo()
lib.bar()
faa()

測試包還是跟之前一樣

local function foo ()
    local a = 1
end

local function bar()
    local a = 1
end

local a = 1

return {
    foo = foo,
    bar = bar,
}

入口腳本中斷點測試

首先測試僅在入口腳本中添加斷點

$ lua dynamictest.lua
luadebug 0.0.1 start ...
input 'help()' for help info or 'help(1)' for verbose info
main ()nil dynamictest.lua:1
lua_debug> 

我們添加兩個斷點,一個是當前包的第7行,及faa函數的最後一行,一個是當前函數即mainchunk的第9行

lua_debug> b(".:7")
lua_debug> b(".@9")
lua_debug> i()
id: 1, src: dynamictest.lua, line: 7, refname: nil
id: 2, src: dynamictest.lua, line: 9, refname: main
lua_debug>

我們繼續執行,首先停在了mainchunk的第9行,此時g的值為1,繼續執行,又停在了faa的第7行,此時g已經改為2

lua_debug> cont
main ()nil dynamictest.lua:9
lua_debug> p("g")
local	1
lua_debug> cont
Lua (local)faa dynamictest.lua:7
lua_debug> p("g")
upvalue	2
lua_debug> i()
id: 1, src: dynamictest.lua, line: 7, refname: faa
id: 2, src: dynamictest.lua, line: 9, refname: main
lua_debug>

此時我們刪除兩個斷點,再次繼續執行,程式不再停到faa上

lua_debug> d(1)
lua_debug> d(2)
lua_debug> i()
lua_debug> cont
$ 

其他包中斷點測試

接著測試下在testlib包中添加斷點,首先啟動調試,添加兩個斷點

$ lua dynamictest.lua
luadebug 0.0.1 start ...
input 'help()' for help info or 'help(1)' for verbose info
main ()nil dynamictest.lua:1
lua_debug> b("testlib:-9")
lua_debug> b("foo@")
lua_debug> i()
id: 1, src: /usr/local/share/lua/5.3/testlib.lua, line: -9, refname: nil
id: 2, name: foo, line: 0
lua_debug>

繼續執行,程式首先停在了testlib的mainchunk第9行,我們在這裡添加faa的斷點

lua_debug> cont
main ()nil /usr/local/share/lua/5.3/testlib.lua:9
lua_debug> i()
id: 1, src: /usr/local/share/lua/5.3/testlib.lua, line: 9, refname: main
id: 2, name: foo, line: 0
lua_debug> b("faa@")
lua_debug>

繼續執行,程式先停在faa函數,然後停在foo函數,最後聽到faa函數。

lua_debug> cont
Lua (local)faa dynamictest.lua:6
lua_debug> cont
Lua (field)foo /usr/local/share/lua/5.3/testlib.lua:2
lua_debug> cont
Lua (local)faa dynamictest.lua:6
lua_debug> cont

多次初始化測試

我們在testlib包開頭添加一行require("luadebug").init()。首先一樣停在了dynamictest.lua中的入口斷點處,我們添加兩個斷點。

$ lua dynamictest.lua
luadebug 0.0.1 start ...
input 'help()' for help info or 'help(1)' for verbose info
main ()nil dynamictest.lua:1
lua_debug> b("faa@")
lua_debug> b("foo@")
lua_debug> i()
id: 1, name: faa, line: 0
id: 2, name: foo, line: 0
lua_debug>

然後繼續執行,發現程式停到了testlib的入口斷點處,斷點情況正常

lua_debug> cont
luadebug 0.0.1 start ...
input 'help()' for help info or 'help(1)' for verbose info
main ()nil /usr/local/share/lua/5.3/testlib.lua:1
lua_debug> bt()
stack traceback:
	/usr/local/share/lua/5.3/testlib.lua:1: in main chunk
	[C]: in function 'require'
	dynamictest.lua:2: in main chunk
	[C]: in ?
lua_debug> i()
id: 1, name: faa, line: 0
id: 2, name: foo, line: 0
lua_debug>

我們繼續執行,停在了faa函數處,我們刪除斷點1,然後繼續執行

lua_debug> cont
Lua (local)faa dynamictest.lua:6
lua_debug> d(1)
lua_debug> i()
id: 2, name: foo, line: 0
lua_debug>

程式停在了foo函數處,再繼續因為faa函數處的斷點1已經刪除,所以程式直接結束。

lua_debug> cont
Lua (field)foo /usr/local/share/lua/5.3/testlib.lua:3
lua_debug> cont
$

僅在testlib包中初始化

我們刪除dynamictest.lua中的第一行,繼續測試,程式直接停在了testlib包的入口斷點,我們同樣添加兩個斷點。

lua dynamictest.lua
luadebug 0.0.1 start ...
input 'help()' for help info or 'help(1)' for verbose info
main ()nil /usr/local/share/lua/5.3/testlib.lua:1
lua_debug> b("faa@")
lua_debug> b("foo@")
lua_debug> i()
id: 1, name: faa, line: 0
id: 2, name: foo, line: 0
lua_debug>

繼續執行,程式停在了faa函數處,我們刪除斷點1,然後繼續執行

lua_debug> cont
Lua (local)faa dynamictest.lua:5
lua_debug> d(1)
lua_debug> i()
id: 2, name: foo, line: 0
lua_debug>

程式停在了foo函數處,再繼續因為斷點1已經刪除,所以不再停在faa函數處,程式直接結束。

lua_debug> cont
Lua (field)foo /usr/local/share/lua/5.3/testlib.lua:3
lua_debug> cont
$

自定義函數名稱及重名測試

我們在dynamictest.lua最前面添加一行:

d = 1

測試輸出如下,提示錯誤之後沒有進入交互模式。

$ lua dynamictest.lua
table `_G` already has element called "d" please specify custom names as the following example:
require("luadebug").init({"bb", "dd", "pp", "ss", "tt", "ii", "hh"})

我們再將第二行改為如下

require("luadebug").init({"bb", "dd", "pp", "ss", "tt", "ii", "hh"})

然後重新測試,可以看到函數名已經順利修改

lua dynamictest.lua
luadebug 0.0.1 start ...
input 'hh()' for help info or 'hh(1)' for verbose info
main ()nil dynamictest.lua:2
lua_debug> bb("faa@")
lua_debug> bb("foo@")
lua_debug> ii()
id: 1, name: faa, line: 0
id: 2, name: foo, line: 0
lua_debug>

繼續執行,一切正常。

lua_debug> cont
luadebug 0.0.1 start ...
input 'hh()' for help info or 'hh(1)' for verbose info
main ()nil /usr/local/share/lua/5.3/testlib.lua:1
lua_debug> cont
Lua (local)faa dynamictest.lua:7
lua_debug> dd(1)
lua_debug> cont
Lua (field)foo /usr/local/share/lua/5.3/testlib.lua:3
lua_debug> tt()
stack traceback:
	/usr/local/share/lua/5.3/testlib.lua:3: in function 'testlib.foo'
	dynamictest.lua:11: in main chunk
	[C]: in ?
lua_debug> cont
$