Lua中如何实现类似gdb的断点调试--04优化钩子事件处理

在第一篇的01最小实现中,我们实现了一个断点调试的最小实现,在设置钩子函数时只加了line事件,显然这会对性能有很大的影响。而后来两篇02通用变量打印03通用变量修改及调用栈回溯则是提供了一些辅助的调试接口,并没有对钩子函数进行修改。

我们本篇将在钩子中引入call和return事件的处理,尝试对性能进行优化。

源码已经上传Github,欢迎watch/star😘。

实现分析

当前的实现因为只加了line事件,执行每一行代码都会执行钩子函数去查看是否有断点,这是没有必要的。我们可以在call事件时检查当前函数是否有断点,只有当有断点的时候才加入line事件。那我们什么时候去掉line事件呢?是不是遇到return事件就去掉呢?

考虑如下场景

考虑如下场景:假设f1调用f2,f2又调用f3。f1中有断点,f2没有断点,f3有断点。如果遇到return就去掉line事件,那么从f2返回到f1之后,就无法再停到f1后面的断点上了。

1
2
3
b             b
f1 --> f2 --> f3
<-- <--

正确的做法

所以正确的做法应该是:call事件时,根据被调函数是否有断点,决定是否加line事件;return事件时,则根据主调函数是否有断点,决定是否加line事件。那么return时如何获取到主调函数的信息呢?我们就需要在call的时候保存函数的相关信息,组成一个链表。call的时候在尾部增加一个节点,return的时候则去掉一个节点。

数据结构

首先,在status数据结构中增加3个成员,stackinfos相当于我们前面提到的链表(只不过这里以数组的形式实现),维护了调用栈中每个函数的信息,记录其中是否有断点,stackdepth记录了链表的长度,或者说栈的深度。funcinfos用于缓存一些函数调试信息,不用每次都调用debug.getinfo去获取。

1
2
3
status.stackinfos = {}  -- table for saving stack infos
status.stackdepth = 0 -- the depth of stack
status.funcinfos = {} -- table for caching func infos

钩子函数

我们本篇最主要的改动是钩子函数,除了line事件,我们还增加了call(或tail call)事件和return(或tail return)事件的处理。为了代码的简洁,用局部变量s来表示status。接下来我们分别来看这三个部分。

1
2
3
4
5
6
7
8
9
10
local function hook (event, line)
local s = status
if event == "call" or event == "tail call" then
-- 省略
elseif event == "return" or event == "tail return" then
-- 省略
elseif event == "line" then
-- 省略
end
end

call事件

如果是call事件(或tail call事件),那么先获取当前函数,查看是否在断点表中有断点。

如果有则在s.stackinfos表尾部插入一个元素,其中hasbreak字段为true指示该函数有断点。注意,这里我们对tail call进行了一个优化,直接覆盖上一层的节点,在递归尾调用时可以防止空间无限膨胀。(Lua5.1上因为没有tail call就无能为力了:)。然后重新设置钩子函数的事件,将call、return和line事件全加上了。

如果当前函数没有断点,同样在s.stackinfos表尾部插入一个节点,不过其中hasbreak字段为false指示该层没有断点。在设置的钩子事件中,则只保留了cr,将line事件移除了。这样就只有断点所在的函数内才会触发line事件,可以大幅提升性能。

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
local function hook (event, line)
local s = status
if event == "call" or event == "tail call" then
local func = debug.getinfo(2, "f").func
for _, v in pairs(s.bptable) do
-- 当前函数中有断点
if v.func == func then
if event == "call" then
s.stackdepth = s.stackdepth + 1
end
s.stackinfos[s.stackdepth] =
{func = func, hasbreak = true}
debug.sethook(hook, "crl") -- 添加"line"事件
return
end
end
-- 当前函数中没有断点
if event == "call" then
s.stackdepth = s.stackdepth + 1
end
s.stackinfos[s.stackdepth] = {func = func, hasbreak = false}
debug.sethook(hook, "cr") -- 移除"line"事件
elseif event == "return" or event == "tail return" then
-- 省略
end

return事件

接下来,我们来看return事件的处理。它首先删除s.stackinfos表尾部的节点,然后检查前一个节点的函数是否有断点,如果有则恢复line事件,否则移除line事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
local function hook (event, line)
local s = status
if event == "call" or event == "tail call" then
-- 省略
elseif event == "return" or event == "tail return" then
s.stackinfos[s.stackdepth] = nil
s.stackdepth = s.stackdepth - 1
-- 如果上一层的函数有断点
if s.stackdepth > 0 and s.stackinfos[s.stackdepth].hasbreak then
debug.sethook(hook, "crl") -- 恢复"line"事件
else
debug.sethook(hook, "cr") -- 移除"line"事件
end
elseif event == "line" then
-- 省略
end
end

line事件

最后一部分是line事件的处理,跟之前没有太大的变化。它遍历断点表,如果匹配到断点则打印提示信息,然后进入用户交互模式。不过这里也做了一个小优化,将debug.getinfo获取的函数信息缓存到了status.funcinfos中,下一次就可以直接从缓存中获取到该函数的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
local function hook (event, line)
local s = status
if event == "call" or event == "tail call" then
-- 省略
elseif event == "line" then
for _, v in pairs(s.bptable) do
if v.func == s.stackinfos[s.stackdepth].func
and v.line == line then
if not s.funcinfos[v.func] then
s.funcinfos[v.func] = debug.getinfo(2, "nS")
end
local info = s.funcinfos[v.func]
local prompt = string.format("%s (%s)%s %s:%d\n",
info.what, info.namewhat, info.name, info.short_src, line)
io.write(prompt)
debug.debug()
end
end
end
end

初始事件设置

hook函数已经修改好了,我们再调整一下setbreakpoint函数中第一次设置钩子时的行为。初始只设置call事件。

1
2
3
4
5
6
7
local function setbreakpoint(func, line)
-- 省略
if s.bpnum == 1 then -- 只有一个断点
debug.sethook(hook, "c") -- 设置call事件
end
return s.bpid
end

复杂度分析

我们假设代码执行的总行数为L,断点数N=n*b,其中n为有断点的函数个数,b为平均每个函数的断点数,断点所在函数平均行数为l,断点所在函数平均调用次数为c,总的函数调用次数C

那么优化前复杂度为O(L*N),优化后的复杂度为O(C*N+c*l*N)

一般情况下(C+c*I) << L,因为右边L代码执行总行数可以分成有断点的函数执行总行数+没有断点的函数执行总行数,而左边的c*I就是有断点的函数执行总行数,C为函数调用总次数。正常情况下函数调用总次数肯定是远远小于没有断点的函数执行的总行数的,有断点的函数执行总行数也是远远小于没有断点的函数执行总行数的。

测试断点是否正常

我们编写如下测试脚本,来测试下之前提到的那种场景:f1调用f2,f2又调用f3,f1中加了两个断点,在调用f2前后各有一个,f2没有断点,f3有断点。

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 ldb = require "luadebug"
local setbp = ldb.setbreakpoint
local rmbp = ldb.removebreakpoint
pv = ldb.printvarvalue
sv = ldb.setvarvalue
ptb = ldb.printtraceback

local function f3()
end

local function f2()
f3()
end

local function f1()
f2()
end

-- f3中加断点
local id1 = setbp(f3, 9)
-- f2不加断点

-- f1中在调用f2前后各加一个断点
local id2 = setbp(f1, 16)
local id3 = setbp(f1, 17)

f1()

rmbp(id1)
rmbp(id2)
rmbp(id3)

然后来运行测试脚本验证一下。首先停在了f1函数第16行(调用f2之前),然后cont继续执行,停在了函数f3的断点处,再次cont继续,函数停在了f1函数第17行(调用f2之后)。可见断点能正常工作

1
2
3
4
5
6
7
$ lua test.lua
Lua (local)f1 test.lua:16
lua_debug> cont
Lua (upvalue)f3 test.lua:9
lua_debug> cont
Lua (local)f1 test.lua:17
lua_debug> cont

测试tail call优化

我们再来测试下tail call的优化,编写如下测试脚本。我们定义了一个尾调用递归的函数foo,然后再其他函数上随便加了一个断点(为了设置hook)。然后我们foo函数一直递归调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
local ldb = require "luadebug"
local setbp = ldb.setbreakpoint
local rmbp = ldb.removebreakpoint
pv = ldb.printvarvalue
sv = ldb.setvarvalue
ptb = ldb.printtraceback

local function foo(n)
if n == 0 then
return 0
end
return foo(n-1)
end

local function bar()
end

-- add a break in bar
local id1 = setbp(bar, 16)

foo(100000000000)

rmbp(id1)

Lua5.1测试

1
2
$ lua5.1 test2.lua

用Lua5.1运行上面的测试脚本,内存占用一直在飙升,我只测试了一小会,就已经飙到8G了。

lua5.1-call

Lua5.3测试

1
$ lua5.3 test2.lua

用Lua5.3运行上面的测试脚本,因为有尾调用的优化,内存占用一直保持在720KB。

lua5.3-tail-call

细心的同学可能已经发现了,我们的hook函数中call事件和line都需要对整个断点表进行遍历,这其中其实是存在着一些冗余的。因为篇幅原因,我们放到下回分解。

-------------本文结束感谢您的阅读-------------

欢迎关注我的其它发布渠道