前面已经支持了几种不同的方式添加断点,但是必须事先在代码中添加断点,在使用上不是那么灵活方便。本文将支持动态增删断点,只需要开一开始引入调试库即可,后续可以在调试过程中动态的添加和删除断点。事不宜迟,我们直接进入正题。
源码已经上传Github ,欢迎watch/star😘。
实现分析 入口断点 尽管我们目标是支持动态添加断点,但还是需要一个入口,提供用户添加初始的断点。仍然像之前一样,在用户代码中显式添加的确可以,但显然不是我们想要效果。理想的效果就是用户开始调试程序,就自动停在入口处,等待用户输入交互信息,就像gdb那样。
因为引入调试库这个动作是肯定要做的,所以最方便的方式就是在引入这个库的时候就直接停到入口断点。我们可以在调试库中实现一个init方法,在require这个调试库之后调用init进入调试入口,类似下面这样
1 require ("luadebug" ).init()
用户代码中只需要添加这样一行,无需其他任何改动,后续就可以交互模式中动态添加断点了。
支持动态添加断点 要在交互模式中动态添加断点,我们的接口函数如添加断点函数、删除断点函数就需要在交互模式的作用域中可见,所以需要将公共接口函数放到_G
或_ENV
中。但是放到这样的全局表中,可能出现名字冲突的情况,需要支持通过参数自定义接口函数的名称。
支持了动态断点之后,原本在call事件中判断函数是否有断点并记录在status.stackinfos中,然后在return事件中查询该值的机制就失效了。因为随时可以动态增删断点,所以在call和return事件都需要实时进行判断,然后根据结果决定是否添加或删除line事件。
另外为了方便添加断点,扩展断点添加函数以支持用”.”表示当前函数或当前包。
支持动态删除断点 要支持动态删除断点,需要添加一个断点打印函数以查看当前的断点情况。
钩子函数 首先来看钩子函数,因为需要支持动态增删断点,所以call和return事件需要相应修改。先看call事件改动,updatehookevent函数把之前根据函数信息判断是否有断点,并调整line事件的逻辑给封装起来了,因为现在在return事件中也需要进行这些操作。而status.stackinfos中则不再缓存hasbreak,因为支持动态添加断点后,需要实时判断了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 local function hook (event, line) local s = status if event == "call" or event == "tail call" then local sinfo = debug .getinfo (2 , "nf" ) local finfo = updatehookevent(sinfo) if event == "call" then s.stackdepth = s.stackdepth + 1 end s.stackinfos[s.stackdepth] = {stackinfo = sinfo, funcinfo = finfo} end end
然后来看return事件的改动。s.stackinfos中把当前函数出栈,这还是跟之前一样。然后如果已经删除了所有断点,那么将钩子函数移除,并清空s.stackinfos缓存。如果栈中还有函数,则调用updatehookevent函数,这里的参数是即将返回的函数的信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 function updatehookevent (stackinfo) local s = status 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 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 if hasbreak then debug .sethook (hook, "crl" ) else debug .sethook (hook, "cr" ) end return funcinfo end
大部分都是之前的call事件中干的事情,首先检查srcbpt中是否有当前包中未解析的断点,然后判断当前函数时候有断点,有断点打开line事件,没有断点移除line事件。
##初始化函数
我们分成三个部分来看初始化函数,首先第一部分是将函数注册到全局表_G
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 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 _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进入交互模式,这就是我们第一个入口断点,可以在这里添加一些初始的断点。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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函数中干这个事情。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 local function init (name_table) if s.bpnum > 0 then if s.stackdepth == 0 then 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 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 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表,打印断点信息,对应通过函数名字添加的断点打印名字及行数,其余断点打印包名及行数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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函数即可。
1 2 3 return { init = init, }
测试 我们编写一个如下的Lua测试脚本
1 2 3 4 5 6 7 8 9 10 11 12 require ("luadebug" ).init()local lib = require "testlib" local g = 1 local function faa () g = 2 end faa() lib.foo() lib.bar() faa()
测试包还是跟之前一样
1 2 3 4 5 6 7 8 9 10 11 12 13 14 local function foo () local a = 1 end local function bar () local a = 1 end local a = 1 return { foo = foo, bar = bar, }
入口脚本中断点测试 首先测试仅在入口脚本中添加断点
1 2 3 4 5 $ 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行
1 2 3 4 5 6 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
1 2 3 4 5 6 7 8 9 10 11 12 lua_debug> cont main ()nil dynamictest.lua:9 lua_debug> p("g" ) local 1lua_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上
1 2 3 4 5 lua_debug> d(1) lua_debug> d(2) lua_debug> i() lua_debug> cont $
其他包中断点测试 接着测试下在testlib包中添加断点,首先启动调试,添加两个断点
1 2 3 4 5 6 7 8 9 10 $ 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的断点
1 2 3 4 5 6 7 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函数。
1 2 3 4 5 6 7 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中的入口断点处,我们添加两个断点。
1 2 3 4 5 6 7 8 9 10 $ 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的入口断点处,断点情况正常
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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,然后继续执行
1 2 3 4 5 6 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已经删除,所以程序直接结束。
1 2 3 4 lua_debug> cont Lua (field)foo /usr/local /share/lua/5.3/testlib.lua:3 lua_debug> cont $
仅在testlib包中初始化 我们删除dynamictest.lua中的第一行,继续测试,程序直接停在了testlib包的入口断点,我们同样添加两个断点。
1 2 3 4 5 6 7 8 9 10 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,然后继续执行
1 2 3 4 5 6 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函数处,程序直接结束。
1 2 3 4 lua_debug> cont Lua (field)foo /usr/local /share/lua/5.3/testlib.lua:3 lua_debug> cont $
自定义函数名称及重名测试 我们在dynamictest.lua最前面添加一行:
测试输出如下,提示错误之后没有进入交互模式。
1 2 3 $ 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" })
我们再将第二行改为如下
1 require ("luadebug" ).init({"bb" , "dd" , "pp" , "ss" , "tt" , "ii" , "hh" })
然后重新测试,可以看到函数名已经顺利修改
1 2 3 4 5 6 7 8 9 10 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>
继续执行,一切正常。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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 $