1.lua语言的调试支持
分析emmylua调试器原理之前首先介绍一下lua自身对调试的支持,便于后续理解。下面的lua源码和接口均基于lua5.3,和其它版本的lua可能会略有偏差。
1.1 设置Hook
我们可以通过**void lua_sethook (lua_State *L, lua_Hook f, int mask, int count)
**给lua的指定线程设置回调函数,当满足指定条件时,设置的回调函数就会被自动调用,参数中的mask
即条件包含下面几种:
- LUA_MASKCALL:某个函数调用时触发。
- LUA_MASKRET:某个函数返回时触发。
- LUA_MASKLINE:执行新的一行代码时触发。
- LUA_MASKCOUNT:执行指定数量指令后触发。
lua_sethook的实现如下,可以看到其实就是在对lua_State的字段进行一些操作,把我们传入的hook函数和触发时机都设置到了lua_State的对应字段上。
LUA_API void lua_sethook (lua_State *L, lua_Hook func, int mask, int count) {
if (func == NULL || mask == 0) { /* turn off hooks? */
mask = 0;
func = NULL;
}
if (isLua(L->ci))
L->oldpc = L->ci->u.l.savedpc;
L->hook = func;
L->basehookcount = count;
resethookcount(L);
L->hookmask = cast_byte(mask);
}
1.2 Hook触发
通过lua_sethook我们可以指定在某些条件下,例如每执行一行新代码时(LUA_MASKLINE)调用我们设置的回调,但是这个回调是如何被触发调用的呢。在lua5.3的实现中,有个**luaV_execute
**函数,作用是逐条解析和执行lua编译后的字节码。
void luaV_execute (lua_State *L) {
...
/* main loop of interpreter */
for (;;) {
Instruction i;
StkId ra;
vmfetch(); // 尝试调用hook
vmdispatch (GET_OPCODE(i)) {
...
}
...
}
vmdispatch的作用就是根据当前的字节码的opcode跳转到对应操作的具体执行逻辑,而在执行具体的字节码之前会调用一个**vmfetch
函数,vmfetch中会判断如果hookmask有LUA_MASKLINE或LUA_MASKCOUNT标记则会调用luaG_traceexec
**判断是否需要触发hook函数。
#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
**的主要作用就是根据设置hook触发条件来判断当前是否可以触发hook调用,例如如果设置了LUA_MASKCOUNT会判断当前调用次数是否达到目标,如果满足条件则会触发hook函数调用。
void luaG_traceexec (lua_State *L) {
CallInfo *ci = L->ci;
lu_byte mask = L->hookmask;
int counthook = (--L->hookcount == 0 && (mask & LUA_MASKCOUNT));
if (counthook)
resethookcount(L); /* reset count */
else if (!(mask & LUA_MASKLINE))
return; /* no line hook and count != 0; nothing to be done */
if (ci->callstatus & CIST_HOOKYIELD) { /* called hook last time? */
ci->callstatus &= ~CIST_HOOKYIELD; /* erase mark */
return; /* do not call hook again (VM yielded, so it did not move) */
}
if (counthook)
luaD_hook(L, LUA_HOOKCOUNT, -1); /* call count hook */
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 */
}
L->oldpc = ci->u.l.savedpc;
if (L->status == LUA_YIELD) { /* did hook yield? */
if (counthook)
L->hookcount = 1; /* undo decrement to zero */
ci->u.l.savedpc--; /* undo increment (resume will increment it again) */
ci->callstatus |= CIST_HOOKYIELD; /* mark that it yielded */
ci->func = L->top - 1; /* protect stack below results */
luaD_throw(L, LUA_YIELD);
}
}
1.3 获取调试信息
现在我们已经知道了如何在lua运行中插入hook函数,从而在运行一行新的代码的时候执行我们自己的函数,那么我们如何在hook函数中获取到我们想要的信息呢?接下来就介绍lua提供的调试相关的数据接口和接口。
1.3.1 lua_Debug结构体
typedef struct lua_Debug {
int event;
const char *name; /* (n) */
const char *namewhat; /* (n) */
const char *what; /* (S) */
const char *source; /* (S) */
int currentline; /* (l) */
int linedefined; /* (S) */
int lastlinedefined; /* (S) */
unsigned char nups; /* (u) number of upvalues */
unsigned char nparams; /* (u) number of parameters */
char isvararg; /* (u) */
char istailcall; /* (t) */
char short_src[LUA_IDSIZE]; /* (S) */
/* private part */
other fields
} lua_Debug;
在lua的源码实现中有一个用于辅助调试的结构体lua_Debug,其中存储一个函数或者一个记录A(activation record)的信息。
关键字段的具体含义如下:
- source:创建这个函数的chunk名称
- currentline:给定函数当前正在运行的代码行号
- name:给定函数的合理(reasonable)的名字。因为函数在lua中是第一类值,可以是个匿名函数,可以被保存到变量或者表中,不一定会有一个定义的名字。
- nups:函数的上值数量
每个字段后面的(n)类似的注释可以理解为是对这个字段的分类,在通过int lua_getinfo (lua_State *L, const char *what, lua_Debug *ar)
获取lua_Debug的时候可以通过what字段指定分类,从而获取到填充了对应分类字段数据的lua_Debug。
1.3.2 lua_Hook定义
我们可以通过**void lua_sethook (lua_State *L, lua_Hook f, int mask, int count)
给指定线程设置一个lua_Hook类型的回调,lua_Hook类型的定义如下:typedef void (*lua_Hook) (lua_State *L, lua_Debug *ar)
**,可以看到lua_Hook还接收一个lua_Debug的指针ar,这个ar中就保存了当前函数调用的信息以便我们在lua_Hook中获取和使用。
值得一提的是,在我们实现的lua_Hook中可以调用lua的C API来执行一些lua代码,意味着lua虚拟机仍然会正常运行,即luaV_execute
还是会调用,但是lua在运行一个hook函数的时候,禁用了其它hook的调用,此时无法调用新的hook函数。
另外,lua_Hook函数不支持延续(continuations),不能用lua_yieldk等方式挂起,只能通过lua_yield类似方式挂起。
2.emmylua调试器原理
emmylua调试器的仓库地址:https://github.com/EmmyLua/EmmyLuaDebugger,下面开始分析emmylua是如何实现断点,单步步进,监视变量等功能的。
2.1 调试框架
emmylua的调试框架可以认为是一个cs模式,ide侧的插件是client,负责收集用户的输入(断点信息,监视的变量,step in/step over等)然后发送给debugger请求对应的数据。而lua虚拟机侧是server,负责接收来自ide的请求,然后从lua虚拟机中获取相关数据,或者暂停lua虚拟机的执行等,然后把数据返回给ide展示。上述仓库中的代码就是lua虚拟机侧执行的代码,主要负责判断断点是否命中,获取监视变量的结果,修改指定变量的值,处理用于的step in/step over等命令。emmylua插件侧的代码在Emmylua的其他仓库中可以找到,这里不再列举。
使用这种结构有个好处就是多个不同的ide可以共用一套debugger代码,例如emmylua的vscode插件和rider插件是两套逻辑,因为这两个ide的前端表现的接口有很大的区别,但是它们使用的后端debugger逻辑是相同的,节省了开发成本。下面提到的ide操作均为vscode下,与rider的可能略有区别,但是debugger的逻辑是相同的,不影响对debugger的理解。
2.2 调试器连接
emmylua的插件端和debugger之间可以使用tcp来进行通信,首先需要我们在lua虚拟机侧加载和开启tcpListen。
EMMY_CORE_EXPORT int luaopen_emmy_core(struct lua_State* L) {
EmmyFacade::Get().SetWorkMode(WorkMode::EmmyCore);
if (!install_emmy_debugger(L))
return false;
luaL_newlibtable(L, lib);
luaL_setfuncs(L, lib, 0);
// _G.emmy_core
lua_pushglobaltable(L);
lua_pushstring(L, "emmy_core");
lua_pushvalue(L, -3);
lua_rawset(L, -3);
lua_pop(L, 1);
return 1;
}
我们在项目启动lua虚拟机之后,通过require("emmy_core")
加载编译好的emmy_core.dll,加载dll的同时lua会自动调用luaopen_emmy_core函数,这个函数的作用主要是把emmy_core设置为全局变量,同时给这个全局变量增加了下面几个函数:
static const luaL_Reg lib[] = {
{"tcpListen", tcpListen},
{"tcpConnect", tcpConnect},
{"pipeListen", pipeListen},
{"pipeConnect", pipeConnect},
{"waitIDE", waitIDE},
{"breakHere", breakHere},
{"stop", stop},
{"tcpSharedListen", tcpSharedListen},
{"registerTypeName", registerTypeName},
{nullptr, nullptr}
};
require emmy_core之后在lua中执行emmy_core.tcpListen(),传入对应的ip和端口号就开启一个tcp监听了。
之后我们需要设置vscode里的调试配置,注意这里的端口号需要和我们vscode里调试器的配置端口号保持一致,vscode中默认的端口号使用的是9966。设置好了之后点击开始调试即可连接到emmylua debugger开始debug了。
{
"type": "emmylua_new",
"request": "launch",
"name": "EmmyLua New Debug",
"host": "localhost",
"port": 9966, // 需要和tcpListen中的端口号一致
"ext": [
".lua",
".lua.txt",
".lua.bytes"
],
"ideConnectDebugger": true
},
2.3 设置Hook
在开启tcpListen的同时,emmylua会通过SetReadyHook
来给当前线程设置Hook
void EmmyFacade::SetReadyHook(lua_State *L) {
lua_sethook(L, ReadyLuaHook, LUA_MASKCALL | LUA_MASKLINE | LUA_MASKRET, 0);
}
这里设置的Hook函数ReadyLuaHook
还不会开始处理断点相关的调试逻辑,而是进一步设置Hook。
void EmmyFacade::ReadyLuaHook(lua_State *L, lua_Debug *ar) {
if (!Get().readyHook) {
return;
}
Get().readyHook = false;
auto states = FindAllCoroutine(L); // 获取虚拟机中所有协程
for (auto state: states) {
lua_sethook(state, HookLua, LUA_MASKCALL | LUA_MASKLINE | LUA_MASKRET, 0);
}
lua_sethook(L, HookLua, LUA_MASKCALL | LUA_MASKLINE | LUA_MASKRET, 0);
auto debugger = Get().GetDebugger(L);
if (debugger) {
debugger->Attach();
}
Get().Hook(L, ar);
}
这段代码主要逻辑就是找到当前lua虚拟机中所有的lua_State,然后把给它们都设置上Hooklua
的Hook回调。这么做是为了避免因为有代码运行在没有设置hook函数的协程中,导致无法调试相关代码。
注意这里只需要把当前的所有lua_State都设置上hook,那么之后即使有新创建的协程,对应的hook也会被自动设置上,这是因为lua在创建新的lua_State的时候会把创建者的hook信息复制给新创建的lua_State,对应代码在lua_newthread
中
LUA_API lua_State *lua_newthread (lua_State *L) {
...
// 把当前lua_State中的hook信息复制给新创建的lua_State
L1->hookmask = L->hookmask;
L1->basehookcount = L->basehookcount;
L1->hook = L->hook;
resethookcount(L1);
...
}
2.4 断点命中
2.4.1 断点匹配
在设置了hook之后,lua虚拟机每次执行代码都会回调我们设置的hook函数HookLua
。HookLua
主要逻辑就是找到当前lua_State对应的Debugger,然后调用Debugger:Hook
,断点匹配相关逻辑就在这个函数中。
void Debugger::Hook(lua_Debug *ar, lua_State *L) {
...
auto bp = FindBreakPoint(ar);
if (bp && ProcessBreakPoint(bp)) {
HandleBreak();
return;
}
...
}
FindBreakPoint
的代码如下,首先获取到当前执行的代码行号,然后检查缓存的所有断点的代码行号集合lineSet
中是否有当前行号,如果没有则断点未命中直接返回。这一步是一个粗略的剪枝,可以过滤大部分情况。
如果lineSet
中有当前行号,则进一步判断当前执行的文件名是否和断点中的文件名匹配。首先通过lua_getinfo
获取到当前执行代码的文件名,接着通过FindBreakPoint
的重载函数检查是否有断点的文件名和行号和当前执行的代码文件名和行号匹配。
std::shared_ptr<BreakPoint> Debugger::FindBreakPoint(lua_Debug *ar) {
if (!currentL) {
return nullptr;
}
auto L = currentL;
const int cl = getDebugCurrentLine(ar);
auto lineSet = manager->GetLineSet();
if (cl >= 0 && lineSet.find(cl) != lineSet.end()) {
lua_getinfo(L, "S", ar);
const auto chunkname = GetFile(ar);
return FindBreakPoint(chunkname, cl);
}
return nullptr;
}
FindBreakPoint
的重载函数主要逻辑就是遍历所有的断点,逐个检查文件名和行号是否和当前执行的代码行号和文件名匹配,如果匹配则返回对应的断点。FuzzyMatchFileName
主要逻辑就是检查文件是否匹配,从lua_getinfo
中获取到的文件名可能是require/dofile等方式传入的,会有aaa/./bbb等非绝对路径的情况,而断点中保存的文件名是由前端插件记录的,是一个绝对路径,因此需要处理一下文件名匹配和后缀匹配,具体逻辑这里不展示了。
std::shared_ptr<BreakPoint> Debugger::FindBreakPoint(const std::string &chunkname, int line) {
std::shared_ptr<BreakPoint> breakedpoint = nullptr;
int maxMatchProcess = 0;
auto breakpoints = manager->GetBreakpoints();
for (const auto bp: breakpoints) {
if (bp->line == line) {
// fuzz match: bp(x/a/b/c), file(a/b/c)
int matchProcess = FuzzyMatchFileName(chunkname, bp->file);
if (matchProcess > 0 && matchProcess > maxMatchProcess) {
maxMatchProcess = matchProcess;
breakedpoint = bp;
}
}
}
return breakedpoint;
}
2.4.2 断点条件判断
Debugger::Hook
的源码中可以看到,当断点的所在文件和行号均和当前执行的情况匹配会返回一个bp的数据结构,还需要ProcessBreakPoint
的返回值为true才能进入break的逻辑。ProcessBreakPoint
的作用就是检查断点的条件是否匹配,以支持条件断点,次数断点,命中时输出log等功能,具体逻辑如下:
bool Debugger::ProcessBreakPoint(std::shared_ptr<BreakPoint> bp) {
if (!bp->condition.empty()) {
auto ctx = std::make_shared<EvalContext>();
ctx->expr = bp->condition;
ctx->depth = 1;
bool suc = DoEval(ctx);
return suc && ctx->result->valueType == LUA_TBOOLEAN && ctx->result->value == "true";
}
if (!bp->logMessage.empty()) {
DoLogMessage(bp);
return false;
}
if (!bp->hitCondition.empty()) {
bp->hitCount++;
return DoHitCondition(bp);
}
return true;
}
可以看到ProcessBreakPoint
的逻辑主要有3个部分,如果断点配置了条件,则验证对应的条件是否满足,具体验证逻辑就是通过DoEval
计算给定条件表达式的值是否为true,DoEval
的实现后续会介绍,目前可以认为就是对一个表达式求值。如果配置了logMessage则输出对应信息到控制台,并返回断点未命中。如果配置了命中次数则检查命中次数是否达标。
当断点通过ProcessBreakPoint
的检查后才会被认为是真正命中,接下来就需要在ide中展示命中的断点和相关的堆栈信息,这些逻辑是在HandleBreak
的断点处理函数中进行处理的。
2.5 断点信息
断点命中之后会调用到OnBreak()
中,这个函数一个主要作用是通过GetStacks
获取当前的调用栈和局部变量等信息,用于显示在ide中,另一个作用就是通过tcp连接通知ide当前命中的断点。
bool EmmyFacade::OnBreak(std::shared_ptr<Debugger> debugger) {
if (!debugger) {
return false;
}
std::vector<Stack> stacks;
_emmyDebuggerManager.SetHitDebugger(debugger);
// 获取局部变量和栈信息
debugger->GetStacks(stacks);
auto obj = nlohmann::json::object();
obj["cmd"] = static_cast<int>(MessageCMD::BreakNotify);
obj["stacks"] = JsonProtocol::SerializeArray(stacks);
// 通知ide断点命中
transporter->Send(int(MessageCMD::BreakNotify), obj);
return true;
}
GetStacks
的函数声明为bool Debugger::GetStacks(std::vector<Stack> &stacks)
,调用之后可以得到当前运行的函数的堆栈信息和对应的所有局部变量,用于显示在ide中,具体代码如下
bool Debugger::GetStacks(std::vector<Stack> &stacks) {
if (!currentL) {
return false;
}
auto prevCurrentL = currentL;
auto L = currentL;
int totalLevel = 0;
while (true) {
int level = 0;
while (true) {
lua_Debug ar{};
// 获取栈信息
if (!lua_getstack(L, level, &ar)) {
break;
}
// 获取栈上的其它信息
if (!lua_getinfo(L, "nSlu", &ar)) {
continue;
}
// C++ 17 only return T&
stacks.emplace_back();
auto &stack = stacks.back();
stack.file = GetFile(&ar);
stack.functionName = getDebugName(&ar) == nullptr ? "" : getDebugName(&ar);
stack.level = totalLevel++;
stack.line = getDebugCurrentLine(&ar);
// get variables
{
// 获取局部变量
for (int i = 1;; i++) {
const char *name = lua_getlocal(L, &ar, i);
if (name == nullptr) {
break;
}
if (name[0] == '(') {
lua_pop(L, 1);
continue;
}
// add local variable
auto var = stack.variableArena->Alloc();
var->name = name;
SetVariableArena(stack.variableArena.get());
GetVariable(L, var, -1, 1);
ClearVariableArenaRef();
lua_pop(L, 1);
stack.localVariables.push_back(var);
}
// 获取上值信息
if (lua_getinfo(L, "f", &ar)) {
const int fIdx = lua_gettop(L);
for (int i = 1;; i++) {
const char *name = lua_getupvalue(L, fIdx, i);
if (!name) {
break;
}
// add up variable
auto var = stack.variableArena->Alloc();
var->name = name;
SetVariableArena(stack.variableArena.get());
GetVariable(L, var, -1, 1);
ClearVariableArenaRef();
lua_pop(L, 1);
stack.upvalueVariables.push_back(var);
}
// pop function
lua_pop(L, 1);
}
}
level++;
}
// TODO
lua_State *PL = manager->extension.QueryParentThread(L);
if (PL != nullptr) {
L = PL;
} else {
break;
}
}
SetCurrentState(prevCurrentL);
return false;
}
主要逻辑是由两个while(true)循环包裹起来的,里层的while循环就是在从底向上获取栈的信息,外层的while循环是在从子线程到父线程遍历。
具体来看里层的循环逻辑,首先是通过lua_getstack
获取当前level的栈信息,当level为0时即获取当前在调用的函数栈,level为1则是调用level 0的函数的栈,以此类推,直到遍历到栈顶。获取到栈信息后继续通过lua_getinfo
来获取当前的行号,文件名等其它信息。
下面的一个for (int i = 1;; i++)
循环是在逐个获取当前栈中的局部变量,通过const char *lua_getlocal (lua_State *L, const lua_Debug *ar, int n)
可以把第n个局部变量push到栈上,并返回它的名字字符串。接着通过自己封装的GetVariable
来获取这个变量的可表示形式,例如如果变量是一个字符串则获取它的值,如果是一个table则获取它所有的键值对,关于GetVariable
的具体实现后续再展开。
接着通过一个for (int i = 1;; i++)
循环来遍历获取当前函数的所有上值,获取到之后也是通过GetVariable
来获取每个上值的表示。
2.6 断点操作
当命中一个断点后,程序会被暂停,调试器等待用户输入进行下一步的行为。常用的操作有Step Over,Step In,Step Out,Stop,添加监视表达式等,下面逐一来看这些操作是如何实现的。
2.6.1 暂停程序
当命中断点之后,emmylua会调用到EnterDebugMode()
,表示自己进入调试模式,代码如下:
void Debugger::EnterDebugMode() {
std::unique_lock<std::mutex> lock(runMtx);
blocking = true;
while (true) {
std::unique_lock<std::mutex> lockEval(evalMtx);
if (evalQueue.empty() && blocking) {
lockEval.unlock();
cvRun.wait(lock);
lockEval.lock();
}
if (!evalQueue.empty()) {
const auto evalContext = evalQueue.front();
evalQueue.pop();
lockEval.unlock();
const bool skip = skipHook;
skipHook = true;
evalContext->success = DoEval(evalContext);
skipHook = skip;
EmmyFacade::Get().OnEvalResult(evalContext);
continue;
}
break;
}
ClearCache();
}
可以看到当blocking为true时,这是一个死循环。若evalQueue不为空,则会逐个求evalQueue中的值直到为空。若evalQueue为空则会调用cvRun.wait(lock)
,cvRun的类型是condition_variable
,因此这里就是在等待cvRun
满足条件。查阅代码可以发现cvRun
满足条件的情况只有两种,一种是推出调试模式,即用户手动选择stop等情况,另一种则是由于需要求取某个变量的值,向evalQueue中push了一个变量。
void Debugger::ExitDebugMode() {
blocking = false;
cvRun.notify_all();
}
bool Debugger::Eval(std::shared_ptr<EvalContext> evalContext, bool force) {
if (force)
return DoEval(evalContext);
if (!blocking) {
return false;
}
// 加锁
{
std::unique_lock<std::mutex> lock(evalMtx);
evalQueue.push(evalContext);
}
cvRun.notify_all();
return true;
}
从1.2中我们知道lua虚拟机会先调用hook函数,然后才执行对应的字节码,而emmylua的hook函数中是一个循环,所以lua虚拟机相当于被暂停了,无法执行后续指令。
2.6.2 Step Over
在ide中选择执行了相应的操作之后,ide就会发送指定到debugger,debugger这边主要通过DoAction
来分发执行:
void Debugger::DoAction(DebugAction action) {
// 锁加到这里
std::lock_guard<std::mutex> lock(hookStateMtx);
switch (action) {
case DebugAction::Break:
SetHookState(manager->stateBreak);
break;
case DebugAction::Continue:
SetHookState(manager->stateContinue);
break;
case DebugAction::StepOver:
SetHookState(manager->stateStepOver);
break;
case DebugAction::StepIn:
SetHookState(manager->stateStepIn);
break;
case DebugAction::Stop:
SetHookState(manager->stateStop);
break;
case DebugAction::StepOut:
SetHookState(manager->stateStepOut);
break;
default:
break;
}
}
void Debugger::SetHookState(std::shared_ptr<HookState> newState) {
if (!currentL) {
return;
}
auto L = currentL;
hookState = nullptr;
if (newState->Start(shared_from_this(), L)) {
hookState = newState;
}
}
可以看到这里不管执行哪个命令,实际上都是在调用对应命令的State中实现的Start
方法,然后记录当前的hookState为对应状态。
下面查看Step Over的具体实现:
bool HookStateStepOver::Start(std::shared_ptr<Debugger> debugger, lua_State* current)
{
if (!StackLevelBasedState::Start(debugger, current))
return false;
lua_Debug ar{};
lua_getstack(current, 0, &ar);
lua_getinfo(current, "nSl", &ar);
file = getDebugSource(&ar);
line = getDebugCurrentLine(&ar);
debugger->ExitDebugMode();
return true;
}
可以看到这里就是记录了当前的文件名和行号,然后退出调试模式,使得lua虚拟机能够继续向前执行。当lua虚拟机执行一条命令之后就又会进入emmylua的Hook函数:
void Debugger::Hook(lua_Debug *ar, lua_State *L) {
...
auto bp = FindBreakPoint(ar);
if (bp && ProcessBreakPoint(bp)) {
HandleBreak();
return;
}
// 加锁
std::shared_ptr<HookState> state = nullptr;
{
std::lock_guard<std::mutex> lock(hookStateMtx);
state = hookState;
}
if (state) {
state->ProcessHook(shared_from_this(), currentL, ar);
}
}