前言
當我們在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來注冊鈎子函數。