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來注冊鈎子函數。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM