Lua中如何實現類似gdb的斷點調試—07支援通過函數名稱添加斷點

我們之前已經支援了通過函數來添加斷點,並且已經支援了行號的檢查和自動修正。但是通過函數來添加斷點有一些限制,如果在當前的位置無法訪問目標函數,那我們就無法對其添加斷點。

於是,本篇我們將擴展斷點設置的介面,支援通過函數名稱添加斷點,以突破這個限制。

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

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

實現分析

由於Lua是動態類型語言,變數可以是任何值。而函數在Lua語言中又是第一類值,與其他值一樣使用,可以被存放在變數中、作為參數或返回值傳遞。所以一個函數的名字是不確定的,它可能是任意名字,取決於函數調用時候的變數的名稱。

通過下面這個簡單的例子,就可以看出來

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

local function foo()
end

setbp(foo, 6)

local bar = foo

foo()
bar()

我們在foo函數中添加了一個斷點,將foo函數賦值給局部變數bar,然後分別用foo和bar調用函數。運行這個腳本結果如下:

$ lua namenotstable.lua
Lua (local)foo namenotstable.lua:6
lua_debug> cont
Lua (local)bar namenotstable.lua:6
lua_debug> cont

調用foo()bar()都會碰到斷點,函數名稱分別為foobar

所以通過函數名稱添加的斷點並不是確定的,函數名稱和函數之間並不是一一映射的關係,而可能是m對n的關係。就算已經匹配到了一個與斷點設置的函數名稱一致的函數,我們也不能簡單地將函數名稱斷點轉換成相應的函數斷點,而是仍然需要維護函數名稱斷點。

因此,我們需要增加一個維護函數名稱斷點的數據結構—-新的斷點表status.namebpt。類似之前在05優化斷點資訊數據結構中添加的status.funcbpt表,只是表的鍵由之前的函數變成了函數名稱。status.namebpt表的值同樣是一個表,該表的鍵是斷點行號,值為斷點id。同樣地,為了快速獲取斷點個數,我們在表中額外加了一個特殊的num欄位保存該函數名稱中的斷點個數。

通過下面的例子來直觀地看一下,假設我們的bptable表中添加了兩個斷點如下(name欄位用來保存函數名稱):

bptable[1] = {name = "foo", line = 10}
bptable[2] = {name = "foo", line = 20}

對應的在namebpt表中的操作如下:

namebpt["foo"] = {}          -- 構造表
namebpt["foo"][10] = 1	     -- 函數名foo,行號10,斷點id為1
namebpt["foo"].num = 1       -- 該函數第一個斷點
namebpt["foo"][20] = 2       -- 函數名foo,行號20,斷點id為2
namebpt["foo"].num = namebpt["foo"].num + 1	-- 斷點個數+1

OK,分析完了,接下來開始修改相應的程式碼實現。

添加斷點

按照慣例,我們先修改設置斷點函數。因為支援了通過函數名稱設置斷點,第一個參數需要支援string類型。為了簡潔及程式碼重用,我們將之前通過函數設置斷點的操作封裝成了setfuncbp函數,另外將通過函數名稱設置斷點的操作封裝成了setnamebp函數。

local function setbreakpoint(where, line)
    if (type(where) ~= "function" and type(where) ~= "string")
        or ( line and type(line) ~= "number") then
        io.write("invalid parameter\n")
        return nil
    end

    if type(where) == "function" then
        return setfuncbp(where, line)
    else            -- "string"
        return setnamebp(where, line)
    end
end

接下來,來看下setnamebp函數的實現:

local function setnamebp(name, line)
    local s = status
    local namebp = s.namebpt[name]
    if not line then                    -- 如果沒有指定行號
        line = 0                        -- 用一個特殊值0來表示第一個有效行
    end
    -- 是否已經添加了相同的斷點
    if namebp and namebp[line] then
        return namebp[line]
    end

    s.bpid = s.bpid + 1
    s.bpnum = s.bpnum + 1
    s.bptable[s.bpid] = {name = name, line = line}

    if not namebp then                  -- 該函數名稱的第一個斷點
        s.namebpt[name] = {}
        namebp = s.namebpt[name]
        namebp.num = 0
    end
    namebp.num = namebp.num + 1
    namebp[line] = s.bpid

    if s.bpnum == 1 then                -- 第一個全局斷點
        debug.sethook(hook, "c")        -- 設置鉤子函數的"call"事件
    end
    return s.bpid                       --> 返回斷點id
end

因為我們支援不指定行號,但我們並不確定函數的第一個有效行是什麼。為了方便地記錄斷點,又不至於與實際的斷點行衝突,我們用了一個特殊值0來表示這種情況。

後續的邏輯與setfuncbp函數基本一致,如果已經添加了相同的斷點,則返回之前的斷點id。然後分別在bptable表和namebp表中添加斷點。這裡不再贅述。

刪除斷點

刪除斷點函數的改動不大。主要是要區分刪除的是哪類斷點,這個可以通過s.bptable表中id所對應的斷點資訊來判斷。如果有func則說明是通過函數添加的斷點,否則則是通過函數名稱添加的斷點。根據情況刪除s.funcbpt或者s.namebpt表中的斷點,最後刪除s.bptable表中的斷點。

local function removebreakpoint(id)
    local s = status
    if s.bptable[id] == nil then
        return
    end
    local func = s.bptable[id].func
    local name = s.bptable[id].name
    local line = s.bptable[id].line

    local dstbp = nil
    if func then
        dstbp = s.funcbpt[func]
    else
        dstbp = s.namebpt[name]
    end
    if dstbp and dstbp[line] then
        dstbp.num = dstbp.num - 1
        dstbp[line] = nil
        if dstbp.num == 0 then
            dstbp = nil
        end
    end

    s.bptable[id] = nil
    s.bpnum = s.bpnum - 1
    if s.bpnum == 0 then
        debug.sethook()                 -- 移除鉤子
    end
end

獲取函數資訊

正如前面提到過的,因為函數名稱資訊是不確定的,所以我們修改了getfuncinfo函數實現,不再快取函數名稱資訊,而只快取確定的函數資訊。

local function getfuncinfo (func)
    local s = status
    local info = s.funcinfos[func]
    if not info then
        info = debug.getinfo(func, "SL")
        if (info.activelines) then
            info.sortedlines = {}
            for k, _ in pairs(info.activelines) do
               table.insert(info.sortedlines, k)
            end
            table.sort(info.sortedlines)
        end
        s.funcinfos[func] = info
    end
    return info
end

鉤子函數

鉤子函數的改動主要是在call事件。函數名稱每次都根據調用棧實時獲取。首先在函數斷點表s.funcbpt中查找當前函數是否有斷點,如果沒有則再去函數名稱斷點表s.namebpt中查找。需要檢查斷點行號是否在當前函數的定義範圍之內,只有當行號在範圍之內才認為匹配。如果沒有指定行號的話(默認為第一個有效行),則總是認為匹配。另外,在調用棧資訊表中,分別將確定的函數資訊funcinfo和調用棧相關資訊stackinfo分別保存,以供return事件和line事件時使用。

local function hook (event, line)
    local s = status
    if event == "call" or event == "tail call" then
        local stackinfo = debug.getinfo(2, "nf")
        local func = stackinfo.func
        local name = stackinfo.name
        local funcinfo = getfuncinfo(func)
        local hasbreak = false
        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 k ~= "num" and ((k >= min and k <= max) or k == 0) then
                    hasbreak = true
                    break
                end
            end
        end
        if event == "call" then     -- for tail call, just overwrite
            s.stackdepth = s.stackdepth + 1
        end
        s.stackinfos[s.stackdepth] =
            {stackinfo = stackinfo, funcinfo = funcinfo, hasbreak = hasbreak}
        -- 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 temporarily
        end
    elseif event == "return" or event == "tail return" then
            -- 省略
end

line事件也需要做相應的修改

local function hook (event, line)
    -- 省略
    elseif event == "line" then
        local sinfo = s.stackinfos[s.stackdepth].stackinfo
        local finfo = s.stackinfos[s.stackdepth].funcinfo
        local func = sinfo.func
        local name = sinfo.name
        local funcbp = s.funcbpt[func]
        local namebp = s.namebpt[name]
        if (funcbp and funcbp[line]) or (namebp and namebp[line])
            or (namebp and namebp[0] and line == finfo.sortedlines[1]) then
            local prompt = string.format("%s (%s)%s %s:%d\n",
                finfo.what, sinfo.namewhat, name, finfo.short_src, line)
            io.write(prompt)
            debug.debug()
        end
    end
end

在判斷當前行是否有斷點時,除了查看funcbpt表,還需要查看namebpt表,對於函數名稱斷點沒有指定行號的情況,判斷當前行是不是第一個有效行。列印提示資訊時,則從stackinfos表中保存的資訊中獲取。

測試

程式碼修改好了,我們來測試下通過函數名稱添加斷點的功能。編寫如下測試腳本:

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

local function foo()
    local a = 0
end

local function bar()
    local a = 0
end

local function pee()
    local a = 0
end

local id1 = setbp(foo)
local id2 = setbp(foo, 7)

local id3 = setbp("bar")
local id4 = setbp("bar", 11)
local id5 = setbp("bar", 100)

local id6 = setbp(pee)
local id7 = setbp("pee", 15)

foo()
bar()
pee()

rmbp(id1)
rmbp(id3)
rmbp(id6)

foo()
bar()
pee()

rmbp(id2)
rmbp(id4)
rmbp(id7)

foo()
bar()
pee()

我們添加了三個函數,其中foo函數以函數作為參數添加斷點,bar函數以函數名稱作為參數添加斷點,pee函數分別用函數和函數名添加了一個斷點。添加完斷點,先分別調用一次,預期每個函數都會碰到兩個斷點。接著三個函數各刪除一個斷點,再各調用一次,預期每個函數都會碰到一個斷點。最後三個函數再各刪除一個斷點,再各調用一次,預期不碰到斷點。

運行測試腳本,結果符合預期。

$ lua test.lua
Lua (local)foo test.lua:6
lua_debug> cont
Lua (local)foo test.lua:7
lua_debug> cont
Lua (local)bar test.lua:10
lua_debug> cont
Lua (local)bar test.lua:11
lua_debug> cont
Lua (local)pee test.lua:14
lua_debug> cont
Lua (local)pee test.lua:15		# 第一次調用,每個函數碰到兩個斷點
lua_debug> cont					
Lua (local)foo test.lua:7
lua_debug> cont
Lua (local)bar test.lua:11
lua_debug> cont
Lua (local)pee test.lua:15		# 第二次調用,每個函數碰到一個斷點
lua_debug> cont
$						# 第三次調用,不再碰到斷點