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
$