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函数是有性能损耗的:

  1. 每次执行字节码前都需要判断是否是新行;
  2. 每次执行新行前都需要调用一个lua的函数(hook函数)

而且LuaPanda的实现上看,断点命中判断是遍历breaks做字符串匹配,所以效率较低,不推荐在生产环境下使用LuaPanda调试(即使没有设置断点)。也不推荐在生产环境注册hook函数。

LuaPanda使用限制

由于LuaPanda是使用debug.sethook来实现调试功能的,并且由于每个luaState只能注册一个hook函数。因此如果在代码的其它地方中注册hook函数就会把LuaPanda的hook给覆盖。

因此在调试时不能运行luacov这类的工具,因为luacov内部也会通过debug.sethook来注册钩子函数。