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()
都會碰到斷點,函數名稱分別為foo
和bar
。
所以通過函數名稱添加的斷點並不是確定的,函數名稱和函數之間並不是一一映射的關係,而可能是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
$ # 第三次調用,不再碰到斷點