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

在前一篇中我們支援了通過函數名稱來添加斷點,我們同時也提到了在Lua中一個函數的名稱的並不是確定的。準確的說,Lua中的函數並沒有名稱,所謂名稱其實是保存這個函數值的變數的名稱。

於是通過函數名稱添加斷點就造成了一定的不確定性,因為函數被調用時並不一定是以這個名字被調用的。另外,多個不同的函數也可能以相同的名字進行調用。

所以為了解決這個問題,本篇我們將繼續擴展斷點的設置介面,支援通過包名來添加斷點。因為包名相對更具確定性,配合行號可以進行精確定位。

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

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

實現分析

為何選用包名的方式

其實一開始的想法並不是通過包名來添加斷點,而是通過源文件名。但是源文件名同樣存在重複的可能,我們只能考慮使用類似後綴匹配這種模糊的方式進行查找。這樣的話,每個call事件中都需要對通過源文件名設置的斷點表進行遍歷。

使用絕對路徑雖然可以解決重複的問題,但是使用上不方便,在添加斷點的時候也不一定知道源文件的具體位置,況且short_src也不一定是絕對路徑。

所以出於性能及易用性的考慮,採用了通過包名來設置斷點的方式,這樣就可以利用Lua本身的包名搜索機制,使用搜索包路徑的方法package.searchpackage()將包名轉成路徑。而這個路徑就是debug.getinfo獲取到的調試資訊中的short_src

下面是一個例子:

package.searchpath( "foo.bar.baz", package.path )
--> (e.g.) "/usr/share/lua/5.3/foo/bar/baz.lua"

斷點的保存解析和查找

跟上一篇中通過函數名稱添加的斷點不同的是,通過包名添加的斷點都是確定的,所以最終都可以將其轉換為函數的斷點。但是只有當執行過該函數之後,我們才能獲取到該函數的相關資訊,進行斷點的轉換,所以在此之前我們需要將它們臨時存放在一個表中,跟之前的status.funcbptstatus.namebpt類似,我們把這個表定義為status.srcbpt

當執行到該函數之後,我們就可以將status.srcbpt中對應的斷點轉移到status.funcbpt表中,後續就可以通過普通函數斷點的方式進行處理了。

為了提升性能,我們可以將之前執行過的函數的資訊保存下來,後續再(通過包名方式)在相同的函數中添加斷點時,就可以快速地確定其所在函數,直接在添加斷點的時候就轉換為普通函數斷點了,而不需要推遲到後面進行查找比對轉換了。之前我們已經有了一個保存函數資訊的表status.funcinfos,但是那個表是以函數作為鍵,而這裡我們需要通過源文件路徑進行查找,所以需要新的數據結構。

解決斷點歧義

通過包名添加斷點存在著一種歧義的情況:當斷點落在函數定義的範圍之內時,它是表示對mainchunk中的函數聲明添加斷點,還是表示在該子函數執行時添加斷點。(本質上是因為mainchunk的有效行和子函數有效行存在重疊)

所以為了優雅地解決這個歧義問題,我們使用行號的正負表示是在mainchunk中還是在子函數中添加斷點。如果行號是正數,表示子函數,跟前幾篇中的情況保持一致;如果行號是負數,則表示在mainchunk中添加斷點。

好了,解決了最關鍵的幾個問題,我們就可以開始著手寫程式碼了

添加斷點

同樣依照慣例,我們先修改設置斷點函數。它的改動較大,因為函數名稱和包名稱都是通過字元串參數指定,所以需要一個區分手段,我們這裡採用了一個跟在名稱後面的特殊字元來進行區分。如果後面跟的是@,表示是函數名稱,如果後面跟的是:,表示是包名。

先來看通過包名添加斷點的情況,首先查找標記符號:,如果找到則前面部分表示包名,後面部分表示行號。切分之後,檢查包名是否為空,再檢查行號是否合法。如果沒有指定行號,那麼默認設置為-1,也就是mainchunk的第一行。接下來調用package.searchpath()將包名轉化為路徑,如果找到了指定的包,再通過setsrcbp()函數來設置斷點。setsrcbp()我們稍後介紹。

local function setbreakpoint(where, line)
    -- 省略
    if type(where) == "function" then
        return setfuncbp(where, line)
    else            -- "string"
        local i = string.find(where, ":")
        if i then   -- package name
            local packname = string.sub(where, 1, i-1)
            local line = string.sub(where, i+1)
            if packname == "" then
                io.write("no package name specified!\n")
                return nil
            end
            if line ~= "" then
                line = tonumber(line)
                if not line then
                    io.write("no valid line number specified!\n")
                    return nil
                end
            else
                line = -1
            end
            local path, err = package.searchpath(packname, package.path)
            if not path then
                io.write(err)
                return nil
            end
            return setsrcbp(path, line)
        else
end

通過函數名稱添加斷點的情況也是類似,不過省了路徑轉換的步驟。首先查找標記符號@,然後切分函數名和行號,檢查函數名是否為空,檢查行號是否合法,都ok之後再交給setnamebp()進行後面的工作。setnamebp()函數我們在上一篇已經介紹過了。

local function setbreakpoint(where, line)
    -- 省略
        else
            local i = string.find(where, "@")
            if i then   -- function name
                local funcname = string.sub(where, 1, i-1)
                local line = string.sub(where, i+1)
                if funcname == "" then
                    io.write("no function name specified!\n")
                    return nil
                end
                if line ~= "" then
                    line = tonumber(line)
                    if not line then
                        io.write("no valid line number specified!\n")
                        return nil
                    end
                else
                    line = nil
                end
                return setnamebp(funcname, line)
            end
        end
    end
end

下面我們來看setsrcbp()函數的實現,這個函數跟setnamebp()大體上類似,開頭部分稍有不同。首先會通過lookforfunc()查看斷點是否位於已知函數中,如果是的話返回該函數,然後直接調用setfuncbp()函數作為函數斷點處理。lookforfunc()我們稍晚一點再介紹。後面的流程跟setnamebp()並無二致就不再贅述。

local function setsrcbp(src, line)
    local s = status

    -- 檢查斷點是否位於已知函數中
    local func = lookforfunc(src, line)
    if func then
        return setfuncbp(func, line)
    end

    local srcbp = s.srcbpt[src]
    -- 檢查相同的斷點是否已經設置
    if srcbp and srcbp[line] then
        return srcbp[line]
    end
    -- 省略
end

鉤子函數

鉤子函數的改動都是在call事件中。首先,在獲取到當前函數及其資訊之後,調用solvesrcbp()處理status.srcbpt表中還未轉換的斷點,如果發現有位於當前函數中的斷點,那麼就進行相應的斷點轉換。接下來,如果當前函數不是C函數,就調用setsrcfunc()函數保存函數資訊。

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
        -- 處理通過包名添加的還未轉換的斷點
        solvesrcbp(funcinfo, func)

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

        if s.funcbpt[func] then
            local id = s.funcbpt[func]
            if s.bptable[id] and not s.bptable[id].src then
                s.bptable[id].src = funcinfo.short_src
            end
            hasbreak = true
        end
        -- 省略
end

斷點轉換

接下來看下solvesrcbp()函數的實現,如果當前源文件中存在未轉換的斷點,那麼遍歷這些斷點,調用verifyfuncline()判斷斷點是否在當前函數中,如果是的話就調用modsrcbp()函數進行實際的轉換操作。

local function solvesrcbp (info, func)
    local s = status
    local srcbp = s.srcbpt[info.short_src]
    if srcbp then
        for k, v in pairs(srcbp) do
            if k ~= "num" then
                line = verifyfuncline(info, k)
                if line then
                    modsrcbp(info.short_src, func, k, line)
                end
            end
        end
    end
end

modsrcbp()函數的實現如下,它有4個參數第一個src是源文件路徑、第二個func是函數、第三個oline是設置斷點時的行號、第四個nlineverifyfuncline()進行修正後的行號。

該函數首先以srcoline為索引將斷點從status.srcbpt表中移除,然後設置到status.funcbpt表中。如果同一個斷點已經設置過了,那麼將新添加的斷點刪除,然後返回舊的斷點id。

local function modsrcbp(src, func, oline, nline)
    local s = status
    local srcbp = s.srcbpt[src]
    local id = srcbp[oline]

    -- 從srcbpt中移除
    srcbp.num = srcbp.num - 1
    srcbp[oline] = nil
    if srcbp.num == 0 then
        srcbp = nil
    end

    -- 設置funcbpt
    local funcbp = s.funcbpt[func]
    -- 檢查是否已經設置了相同的斷點
    if funcbp and funcbp[nline] then
        s.bptable[id] = nil      -- 如果已經設置了,刪除新加的斷點
        s.bpnum = s.bpnum - 1
        assert(s.bpnum > 0)
        return funcbp[nline]     -- 返回舊的斷點id
    end

    -- 省略
end

如果還未添加過這個斷點,那麼就在status.funcbpt中添加該斷點,然後將斷點所在的函數和修正後的行號更新到s.bptable表中。

local function modsrcbp(src, func, oline, nline)
    -- 省略
    if not funcbp then            -- 該函數的第一個斷點
        s.funcbpt[func] = {}
        funcbp = s.funcbpt[func]
        funcbp.num = 0
    end
    funcbp.num = funcbp.num + 1
    funcbp[nline] = id

    -- 更新bptable中欄位
    s.bptable[id].func = func
    s.bptable[id].line = nline

    return id
end

行號擴展

因為我們對行號進行了擴展了,使用負數來表示mainchunk中的斷點,所以verifyfuncline()也需要進行相應的擴展。對於行號是負數的情況,如果不是mainchunk函數,直接返回nil,否則對行號取反還原為正常的行號。

local function verifyfuncline (info, line)
    if not line then
        return info.sortedlines[1]
    end
    if line < 0 then
        if info.what ~= "main" then
            return nil
        end
        line = -line
    end
    if line < info.linedefined or line > info.lastlinedefined then
        return nil
    end
    for _, v in ipairs(info.sortedlines) do
        if v >= line then
            return v
        end
    end
    assert(false)   -- impossible to reach here
end

這裡還有一個問題,通過debug.getinfo()函數獲取到的函數資訊中,對於mainchunk的情況,linedefinedlastlinedefined欄位的值都是0,於是我們的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)
            -- mainchunk需要特殊處理以使`verifyfuncline`能夠正常工作
            if info.what == "main" then
                info.linedefined = 1
                info.lastlinedefined = info.sortedlines[#info.sortedlines]
            end
        end
        s.funcinfos[func] = info
    end
    return info
end

對於mainchunk函數進行特殊處理,將linedefined設置為1,將lastlinedefined設置為最後一個有效行的行號。

快取及查找源文件的函數

我們在鉤子函數中調用setsrcfunc()保存函數資訊,將函數與源文件關聯。在setsrcbp()中調用lookforfunc()通過源文件和行號查找對應的函數。先來看setsrcfunc()函數實現:

local function setsrcfunc (info, func)
    local s = status
    local srcfunc = s.srcfuncmap[info.short_src]
    if not srcfunc then
        srcfunc = {}
        s.srcfuncmap[info.short_src] = srcfunc
    end
    if not srcfunc[func] then
        srcfunc[func] = info
    end
end

其中status.srcfuncmap就是我們新增的數據結構,它是一個以源文件路徑為鍵的表,其值也是一個表,保存位於該源文件中的函數資訊,以函數為鍵,以函數資訊為值。

再來看lookforfunc()函數的實現:

local function lookforfunc (src, line)
    assert(line)
    local srcfunc = status.srcfuncmap[src]
    if srcfunc then
        for func, info in pairs(srcfunc) do
            if info.what == "main" then
                if line < 0 then
                    return func
                end
            elseif line >= info.linedefined
                and line <= info.lastlinedefined then
                return func
            end
        end
    end
    return nil
end

lookforfunc()函數中首先判斷該源文件是否有快取的函數資訊,如果有則進行遍歷。如果行號是負數,則只要找到mainchunk就可以返回了。否則,需要判斷斷點的行號是否在函數定義的範圍內。找到了,就返回斷點所在函數;沒有找到返回nil

測試

首先,編寫一個用於測試的包testlib.lua,實現了兩個簡單的函數foo和bar。

local function foo ()
    local a = 1
end

local function bar()
    local a = 1
end

local a = 1

return {
    foo = foo,
    bar = bar,
}

測試通過函數名和包名添加斷點

我們通過函數名添加了兩個斷點,其中一個省略行號,默認為函數第一個有效行。又通過包名添加了兩個有效斷點id3和id4,id5雖然能添加成功,但是並不落在有效函數範圍內。id6和id7都是參數錯誤的情況,添加斷點失敗。

設置完斷點,先分別調用foo和bar函數一次,預期都在foo函數第2行第3行會碰到斷點,然後在bar函數的第6行第7行碰到斷點。接著分別刪除foo和bar函數中的1個斷點,再分別調用foo和bar函數一次,預期在foo函數的第3行和bar函數的第7行碰到斷點。最後刪除foo和bar函數中的另一個斷點,再分別調用foo和bar函數一次,預期不再碰到斷點。

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

local id1 = setbp("foo@")           -- foo 2
local id2 = setbp("foo@3")          -- foo 3

local id3 = setbp("testlib:5")      -- bar 6
local id4 = setbp("testlib:7")      -- bar 7
local id5 = setbp("testlib:100")    -- invalid line
local id6 = setbp(":5")
assert(not id6)
local id7 = setbp("testlib:aa")
assert(not id7)

lib.foo(1)              -- break twice
lib.bar(1)              -- break twice

rmbp(id1)
rmbp(id3)

lib.foo(2)              -- break once
lib.bar(2)              -- break once

rmbp(id2)
rmbp(id4)

lib.foo(3)              -- not break
lib.bar(3)              -- not break

運行測試腳本,分別在foo函數和bar函數中碰到兩個斷點。

$ lua setbpbysrc.lua
no package name specified!
no valid line number specified!
Lua (field)foo /usr/local/share/lua/5.3/testlib.lua:2
lua_debug> cont
Lua (field)foo /usr/local/share/lua/5.3/testlib.lua:3
lua_debug> cont
Lua (field)bar /usr/local/share/lua/5.3/testlib.lua:6
lua_debug> cont
Lua (field)bar /usr/local/share/lua/5.3/testlib.lua:7
lua_debug>

繼續執行,分別在foo函數和bar函數中碰到一個斷點。

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

繼續執行,沒有再碰到斷點。

lua_debug> cont
$

測試行號擴展

編寫如下測試腳本,在mainchunk中添加了4個斷點,其中不指定行號時默認是mainchunk的第一個有效行。當require該包時,預期在mainchunk的第3行、第7行、第9行和第13行分別碰到斷點。

接著在子函數foo中添加兩個斷點,調用foo函數,預期在foo函數第2行和第3行碰到斷點。然後其中一個斷點,再調用foo函數,預期在foo函數第3行碰到斷點。最後刪除剩餘一個斷點,再調用foo函數,預期不再碰到斷點。

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

local id1 = setbp("testlib:")       -- main 3
local id2 = setbp("testlib:-5")     -- main 7
local id3 = setbp("testlib:-9")     -- main 9
local id4 = setbp("testlib:-13")    -- main 13

local lib = require "testlib"       -- break 4 times

local id5 = setbp("testlib:2")      -- foo 2
local id6 = setbp("testlib:3")      -- foo 3

lib.foo()   -- break 2 times

rmbp(id5)

lib.foo()   -- break 1 time

rmbp(id6)

lib.foo()   -- not break

運行測試腳本,首先碰到了mainchunk中的4個斷點

lua mainchunk.lua
main ()nil /usr/local/share/lua/5.3/testlib.lua:3
lua_debug> cont
main ()nil /usr/local/share/lua/5.3/testlib.lua:7
lua_debug> cont
main ()nil /usr/local/share/lua/5.3/testlib.lua:9
lua_debug> cont
main ()nil /usr/local/share/lua/5.3/testlib.lua:13
lua_debug>

繼續執行,碰到foo函數中兩個斷點

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

再繼續執行,碰到foo函數中一個斷點

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

再cont就結束了。

lua_debug> cont
$

測試斷點重複及快取源文件的函數

編寫如下測試腳本,首先我們通過函數添加一個斷點,然後再通過包名添加一個斷點。雖然這兩個實際是同一個斷點,但是剛開始的時候因為還沒有快取資訊,所以第二個斷點也會添加成功。接著調用 foo函數,預期在foo函數第2行碰到斷點。在鉤子函數call事件中會處理未轉換的斷點2,因為已經存在同樣的斷點了,所以會將斷點2刪除。

接下來我們刪除斷點1之後,就沒有斷點了,再次調用foo函數,預期不會碰到斷點。

然後我們再通過包名添加一個斷點,這裡因為已經有了對應的函數資訊,所以預期會直接轉換成對應的函數斷點。當我們再對函數添加同樣的斷點的時候預期返回之前的斷點號。

調用foo函數,預期在foo函數第3行碰到斷點。刪除斷點3,預期不再碰到斷點。

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

local id1 = setbp(lib.foo)

local id2 = setbp("testlib:1")  -- foo 2

lib.foo()   -- break once

rmbp(id1)

lib.foo()   -- not break
print("not break")

local id3 = setbp("testlib:3")  -- foo 3
assert(id3 == 3)
local id4 = setbp(lib.foo, 3)
assert(id3 == id4)

lib.foo()   -- break once

rmbp(id3)

lib.foo()   -- not break

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

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