Lua调试工具使用及原理
前言
当我们在linux下使用c/c++开发时,可以通过gdb来调试我们编译后的elf文件。gdb支持了attch、单步运行(单行、单指令)、设置断点等非常实用的功能来辅助我们调试。当使用lua开发的时候,一般可能会使用print(打印到屏幕)或是输出日志等稍微简陋的调试方式,但如果日志输出不能满足我们需求时,比如我们需要类似断点、单步执行等更高级的调试功能,此时就必须借助第三方工具。
本文介绍了lua调试工具LuaPanda的使用,以及lua调试工具和gdb在实现上的一些区别。
gdb调试原理
先简单介绍一下gdb的原理。一般的我们将gdb这种调试进程称为tracer,被调试进程称为tracee。当进程被调试时(处于traced状态)时,每次收到任何除了SIGKILL以外的任何信号,都会暂停当前的执行,并且tracer进程可以通过waitpid来获取tracee的暂停原因。gdb使用ptrace系统调用来实现操作tracee进程
1. gdb附加到进程
当使用gdb附加到一个正在运行的进程(tracee)上时,gdb会执行类似下面的代码:
ptrace(PTRACE_ATTACH, pid, ...)
这里的pid是tracee的pid。系统调用执行后,os会给tracee进程发送一个SIGTRAP信号,然后tracee的执行将会暂停。最后gdb(tracer)可以通过系统调用waitpid来获取tracee的暂停原因,并且开始调试。
2. gdb单步执行
单步调试与上述attch类似,gdb通过下面的代码告诉tracee进程需要在运行完一个指令后暂停:
ptrace(PTRACE_SINGLESTEP, pid, ...)
当tracee执行完一个指令后,tracee也会因为收到os的SIGTRAP信号从而暂停执行。
3. gdb设置断点
设置断点稍微有点不同,首先gdb需要从调试程序的调试信息中根据行号(函数名)找到代码的内存地址,然后通过ptrace将tracee进程的代码替换成一个软中断指令:int 3。由于这个指令实际上会被编码为一个字节0xcc,因此可以很安全的与任何指令替换。
/* Look at the word at the address we're interested in */
unsigned addr = 0x8048096;
unsigned data = ptrace(PTRACE_PEEKTEXT, child_pid, (void*)addr, 0);
/* Write the trap instruction 'int 3' into the address */
unsigned data_with_trap = (data & 0xFFFFFF00) | 0xCC;
ptrace(PTRACE_POKETEXT, child_pid, (void*)addr, (void*)data_with_trap);
通过给ptrace指定PTRACE_PEEKTEXT、PTRACE_POKETEXT可以读写tracee进程的代码段的内存。最终当程序执行到int 3时,会触发一个软中断,os会给tracee进程发送SIGTRAP信号。当断点成功后,gdb会用相同的方法用原来的指令替换掉int 3,在这之后tracee就可以正常执行了。
LuaPanda使用介绍
LuaPanda是腾讯开源的一个lua调试工具,配合vscode可以做到类似gdb的调试功能。当开始调试时,vscode会监听本机(127.0.0.1)的8818端口。tracce进程(包含被调试的lua代码的进程)通过LuaPanda连上vscode:
require("LuaPanda").start("127.0.0.1",8818);
LuaPanda通过使用LuaSocket模块创建tcp连接以此来实现通信(对比gdb使用信号机制通信)。vscode会把用户的调式命令(如设置断点、continue、单步运行等)通过tcp发送给tracee进程。
Lua调试原理
lua调试与gdb不同,我们可以通过debug.sethook来为一个lua线程设置hook函数,在调用函数、离开函数、进入新行的时候lua会先执行这个hook函数。LuaPanda在连接上vscode后会注册一个hook函数:
debug.sethook(this.debug_hook, "lrc");
"lrc"字符串掩码决定了hook函数会在什么时候被调用:
- 'c': 每当 Lua 调用一个函数时,调用钩子;
- 'r': 每当 Lua 从一个函数内返回时,调用钩子;
- 'l': 每当 Lua 进入新的一行时,调用钩子。
1. LuaPanda设置断点
当我们设置一个断点时,vscode会把断点的信息,包括文件路径、行号发给tracee进程。tracee收到setBreakPoint命令时,表示需要注册一个断点。此时LuaPanda会将断点的信息存储在一个全局的lua Table中:breaks。
-- 处理 收到的消息
-- @dataStr 接收的消息json
function this.dataProcess( dataStr )
...
elseif dataTable.cmd == "setBreakPoint" then
this.printToVSCode("dataTable.cmd == setBreakPoint");
local bkPath = dataTable.info.path;
bkPath = this.genUnifiedPath(bkPath);
if autoPathMode then
-- 自动路径模式下,仅保留文件名
bkPath = this.getFilenameFromPath(bkPath);
end
this.printToVSCode("setBreakPoint path:"..tostring(bkPath));
breaks[bkPath] = dataTable.info.bks;
当lua虚拟机调用hook函数的时候,hook会遍历breaks,看一下当前行是否命中断点:
------------------------断点处理-------------------------
-- 参数info是当前堆栈信息
-- @info getInfo获取的当前调用信息
function this.isHitBreakpoint( info )
local curLine = tostring(info.currentline);
local breakpointPath = info.source;
local isPathHit = false;
if breaks[breakpointPath] then
isPathHit = true;
end
if isPathHit then
for k,v in ipairs(breaks[breakpointPath]) do
if tostring(v["line"]) == tostring(curLine) then
...
如果断点被命中则会发一个消息给vscode,并原地等待消息回包,以此来实现暂停执行tracee进程:
function this.real_hook_process(info)
...
local isHit = false;
if tostring(event) == "line" and jumpFlag == false then
if currentRunState == runState.RUN or currentRunState == runState.STEPOVER or currentRunState == runState.STEPIN or currentRunState == runState.STEPOUT then
--断点判断
isHit = this.isHitBreakpoint(info) or hitBP;
if isHit == true then
this.printToVSCode(" + HitBreakpoint true");
hitBP = false; --hitBP是断点硬性命中标记
--计数器清0
stepOverCounter = 0;
stepOutCounter = 0;
this.changeRunState(runState.HIT_BREAKPOINT);
--发消息并等待
this.SendMsgWithStack("stopOnBreakpoint");
2. LuaPanda单步执行
单步执行实现比较简单,当tracee收到stopOnStep命令时,表示vscode需要单步执行代码:执行到新的一行需要暂停,并且当有函数调用时应该跳过函数。LuaPanda在处理setBreakPoint命令时操作非常简单:将运行状态改为runState.STEPOVER然后结束:
-- 处理 收到的消息
-- @dataStr 接收的消息json
function this.dataProcess( dataStr )
...
elseif dataTable.cmd == "stopOnStep" then
this.changeRunState(runState.STEPOVER);
local msgTab = this.getMsgTable("stopOnStep", this.getCallbackId());
this.sendMsg(msgTab);
this.changeHookState(hookState.ALL_HOOK);
当lua虚拟机由于进入新行(event为"line")时执行hook函数时,会根据stepOverCounter计数器来决定这次是否要暂停执行。而stepOverCounter计数器会在调用函数的时候+1,离开函数的时候-1。因此当处于内部函数调用的时候,计数器的值会大于零,执行不会被暂停,从而实现跳过函数执行。
function this.real_hook_process(info)
...
if currentRunState == runState.STEPOVER then
-- line stepOverCounter!= 0 不作操作
-- line stepOverCounter == 0 停止
if event == "line" and stepOverCounter <= 0 and jumpFlag == false then
stepOverCounter = 0;
this.changeRunState(runState.STEPOVER_STOP)
this.SendMsgWithStack("stopOnStep");
elseif event == "return" or event == "tail return" then
--5.1中是tail return
if stepOverCounter ~= 0 then
stepOverCounter = stepOverCounter - 1;
end
elseif event == "call" then
stepOverCounter = stepOverCounter + 1;
end
lua hook实现
下面是LuaState结构中的与hook函数有关的字段:
/*
** 'per thread' state
*/
struct lua_State {
...
volatile lua_Hook hook;
l_signalT hookmask;
...
};
其中,hook字段表示对应的函数地址,hookmask是一个掩码,表示需要调用hook函数的事件。
lua虚拟机会在每次执行每一个字节码之前判断是否需要调用hook函数。lua虚拟机执行的主循环(luaV_execute函数中),每次通过vmfetch获取一个字节码指令时,都会先检查LuaState的hookmask字段,看是否有LUA_MASKLINE标记,若有则继续判断是否进入新行。
void luaV_execute (lua_State *L) {
...
/* main loop of interpreter */
for (;;) {
Instruction i;
StkId ra;
vmfetch();
...
vmfetch是一个宏,定义为:
/* fetch an instruction and prepare its execution */
#define vmfetch() { \
i = *(ci->u.l.savedpc++); \
if (L->hookmask & (LUA_MASKLINE | LUA_MASKCOUNT)) \
Protect(luaG_traceexec(L)); \
ra = RA(i); /* WARNING: any stack reallocation invalidates 'ra' */ \
lua_assert(base == ci->u.l.base); \
lua_assert(base <= L->top && L->top < L->stack + L->stacksize); \
}
最后在函数luaG_traceexec中判断是否执行新行:
void luaG_traceexec (lua_State *L) {
...
if (mask & LUA_MASKLINE) {
Proto *p = ci_func(ci)->p;
int npc = pcRel(ci->u.l.savedpc, p);
int newline = getfuncline(p, npc);
if (npc == 0 || /* call linehook when enter a new function, */
ci->u.l.savedpc <= L->oldpc || /* when jump back (loop), or when */
newline != getfuncline(p, pcRel(L->oldpc, p))) /* enter a new line */
luaD_hook(L, LUA_HOOKLINE, newline); /* call line hook */
}
从代码可以看到在lua中,通过debug.sethook注册hook函数是有性能损耗的:
- 每次执行字节码前都需要判断是否是新行;
- 每次执行新行前都需要调用一个lua的函数(hook函数)
而且LuaPanda的实现上看,断点命中判断是遍历breaks做字符串匹配,所以效率较低,不推荐在生产环境下使用LuaPanda调试(即使没有设置断点)。也不推荐在生产环境注册hook函数。
LuaPanda使用限制
由于LuaPanda是使用debug.sethook来实现调试功能的,并且由于每个luaState只能注册一个hook函数。因此如果在代码的其它地方中注册hook函数就会把LuaPanda的hook给覆盖。
因此在调试时不能运行luacov这类的工具,因为luacov内部也会通过debug.sethook来注册钩子函数。