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.funcbpt
、status.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
是設置斷點時的行號、第四個nline
是verifyfuncline()
進行修正後的行號。
該函數首先以src
和oline
為索引將斷點從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的情況,linedefined
和lastlinedefined
欄位的值都是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