上篇文章咱们聊了gdb的底层调试机制,明白了gdb是利用操做系统提供的系统信号来调试目标程序的。不少朋友私下留言了,看到能帮助到你们,我内心仍是很开心的,其实这也是我继续输出文章的最大动力!后面我会继续把本身在项目开发中的实战经验进行总结。html
因为gdb的代码相对复杂,没有办法从代码层面仔细的分析调试细节,因此此次咱们选择一个小巧、开源的Lua脚本语言,深刻到最底层的代码中去探究一下代码调试真正是怎么一回事。编程
不过请放心,虽然深刻到代码最底层,可是理解难度并不大,只要C语言掌握的没问题,其余就都不是问题。
另外,这篇文章重点不是介绍代码,而是介绍实现一个调试器应该如何思考,解决问题的思路是什么。网络
经过阅读这篇文章,能有什么收获?数据结构
喜欢玩游戏的小伙伴可能会知道,Lua语言在游戏开发中使用的比较多。它是一个轻量、小巧的脚本语言,用标准C语言编写,源码开放。正由于这几个缘由,因此我才选择它做为剖析对象。多线程
若是对于Lua语言仍是没有感受,Python语言总应该知道吧?广告满天飞,你就把Lua想象为相似Python同样的脚本语言,只不过体积比Python要轻量的得多。闭包
这里有1张图能够了解下,2020年12月份的编程语言市场占有率。架构
在上图中看不到Lua的身影,由于市场占有率过低了,大概是位于30几名。可是再看看下面这张图,从工资的角度再体会一下Lua的高贵:并发
远远的把C/C++、JAVA甩在了身后,是否是有点冲动想学一下Lua语言了?先别激动,学习任何东西,先要想明白能够用在什么地方。若是仅仅是从找工做的角度来,Lua能够不用考虑了,毕竟市场需求量比较小。框架
虽然Lua语言在招聘网站中处于小众需求,可是这并不妨碍咱们利用Lua来深刻的学习、研究一门编程语言,Lua语言虽小,可是五脏俱全。就像咱们若是想学习Linux内核的设计思想,你是愿意从最开始的版本(几千行代码)开始呢?仍是愿意从当前最新的内核代码(2780万行代码,66492个文件)开始呢?socket
看一下当前最新版的Lua代码体积:
一样的思路,若是咱们想深刻研究一门编程语言,选择哪种语言,对于咱们的积极性和学习效率是很是重要的。每一个人的职业生涯都很长,花一些时间沉下心来研究透一门语言,对于一个开发者来讲,仍是蛮有成就的,对于职业的发展是很是有好处的,你会有一览众山小的感受!
再看一下Lua代码量与Python代码量的对比:
从功能上来讲,Lua与Python之间是没有可比性的,可是咱们的目的不是学习一个编程工具,而是研究一门编程语言自己,所以选择Lua脚本语言进行学习、研究,没有错!
言归正传。
Lua 是一门扩展式程序设计语言,被设计成支持通用过程式编程,并有相关数据描述设施。同时对面向对象编程、函数式编程和数据驱动式编程也提供了良好的支持。它做为一个强大、轻量的嵌入式脚本语言,可供任何须要的程序使用。
做为一门扩展式语言,Lua没有"main"程序的概念:它只能嵌入一个宿主程序中工做,该宿主程序被称为被嵌入程序或者简称宿主。宿主程序能够调用函数执行一小段Lua代码,能够读写Lua变量,能够注册C函数让Lua代码调用。依靠C函数,Lua能够共享相同的语法框架来定制编程语言,从而适用不一样的领域。
也就是说,咱们写了一个test.lua程序,是没有办法直接运行它的。而实须要一个“宿主”程序,来加载test.lua文件。
宿主程序能够是一个最简单的C程序,Lua官方提供了一个宿主程序。
咱们也能够本身写一个,以下:
// 引入Lua头文件 #include <lua.h> #include <lualib.h> #include <lauxlib.h> int main(int argc, char *argv[]) { // 建立一个Lua虚拟机 lua_State *L = luaL_newstate(); // 打开LUA中的标准库 luaL_openlibs(L); // 加载 test.lua 程序 if (luaL_loadfile(L, "test.lua") || lua_pcall(L, 0, 0, 0)) { printf("Error: %s \n", lua_tostring(g_lua_handle.L, -1)); lua_close(g_lua_handle.L); } // 其余代码 }
在语法层面,Lua涵盖的内容仍是比较全面的,它是一门动态类型语言,基本概念包括:八种基本数据类型,表是惟一的数据结构,环境与全局变量,元表及元方法,协程,闭包,错误处理,垃圾收集。具体的信息能够看一下Lua5.3参考手册。
这篇文章主要从调试器这个角度进行分析,所以我不会在这里详细的贴出不少代码细节,而只是把与调试有关的代码贴出来进行解释。
我以前在学习Lua源码时(5.3.5版本),在代码文件中记录了不少注释,能够很好的帮助理解,主要是由于个人忘性比较好。
其实我更建议你们本身去下载源码学习,通过本身的理解、加工,印象会更深入。在以前的工做中,因为项目须要,我对源码进行了一些优化,这部分代码就不放出来了,添加注释的源码是完彻底全的Lua5.3.5版本,大概是这个样子:
若是有小伙伴须要加了注释的源码,请在公众号(IOT物联网小镇)里留言给我。
咱们能够停下来稍微想一下,对一个程序进行调试,须要考虑的问题有3点:
- 如何让程序暂停执行?
- 如何获取程序的内部信息?
- 若是修改程序的内部信息?
带着这些问题,咱们来逐个击破。
Lua虚拟机(也可称之为解释器)内部提供了一个接口:用户能够在应用程序中设置一个钩子函数(Hook),虚拟机在执行指令码的时候会检查用户是否设置了钩子函数,若是设置了,就调用这个钩子函数。本质上就是设置一个回调函数,由于都是用C语言来实现的,虚拟机中只要把这个钩子函数的地址记住,而后在某些场合回调这个函数就能够了。
那么,虚拟机在哪些场合回调用户设置的钩子函数呢?
咱们在设置Hook函数的时候,能够经过mask参数来设置回调策略,也就是告诉虚拟机:在何时来回调钩子函数。mask参数能够是下列选项的组合操做:
- LUA_MASKCALL:调用一个函数时,就调用一次钩子函数。
- LUA_MASKRET:从一个函数中返回时,就调用一次钩子函数。
- LUA_MASKLINE:执行一行指令时,就回调一次钩子函数。
- LUA_MASKCOUNT:执行指定数量的指令时,就回调一次钩子函数。
设置钩子函数的基础API原型以下:
void lua_sethook (lua_State *L, lua_Hook f, int mask, int count);
第二个参数f须要指向咱们本身定义的钩子函数,这个钩子函数原型为:
typedef void (*lua_Hook) (lua_State *L, lua_Debug *ar);
咱们也能够经过下面即将介绍的调试库中的函数来设置钩子函数,效果是同样的,由于调试库函数的内部也是调用基础函数。
debug.sethook ([thread,] hook, mask [, count])
再来看一下虚拟机中的相关代码。
当执行完上一条指令,获取下一条指令以后,调用函数luaG_traceexec(lua_State *L):
void luaG_traceexec (lua_State *L) { // 获取mask掩码 lu_byte mask = L->hookmask; int counthook = (--L->hookcount == 0 && (mask & LUA_MASKCOUNT)); if (counthook) resethookcount(L); else if (!(mask & LUA_MASKLINE)) return; if (counthook) luaD_hook(L, LUA_HOOKCOUNT, -1); // 按指令次数调用钩子函数 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 || ci->u.l.savedpc <= L->oldpc || newline != getfuncline(p, pcRel(L->oldpc, p))) luaD_hook(L, LUA_HOOKLINE, newline); // 按行调用钩子函数 } }
能够看到,当mask掩码中包含了LUA_MASKLINE时,就调用函数luaD_hook(),以下代码:
void luaD_hook (lua_State *L, int event, int line) { lua_Hook hook = L->hook; if (hook && L->allowhook) { // 为钩子函数准备参数,其中包括了各类调试信息 lua_Debug ar; ar.event = event; ar.currentline = line; ar.i_ci = ci; // 调用钩子函数 (*hook)(L, &ar); } }
只要进入了用户设置的钩子函数,那么咱们就能够在这个函数中随心所欲了。
好比:获取程序内部信息,读取、修改变量的值,查看函数调用栈信息等等,这就是下面要讲解的内容。
首先说一下Lua中的标准库。
所谓的标准库就是Lua为开发者提供的一些有用的函数,能够提升开发效率,固然咱们能够选择不使用标准库,或者只使用部分标准库,这是能够裁剪的。
这里咱们只介绍一下基础库、操做系统库和调试库这3个家伙。
基础库
基础库提供了Lua核心函数,若是你不将这个库包含在你的程序中,就须要当心检查程序是否须要本身提供其中一些特性的实现,这个库通常都是须要使用的。
操做系统库
这个库提供与操做系统进行交互的功能,例如提供了函数:
os.date
os.time
os.execute
os.exit
os.getenv
调试库
先看一下库中提供的几个重要的函数:
debug.gethook
debug.sethook
debug.getinfo
debug.getlocal
debug.setlocal
debug.setupvalue
debug.traceback
debug.getregistry
上面已经说到,Lua给用户提供了设置钩子的API函数lua_sethook,用户能够直接调用这个函数,此时传入的钩子函数的定义格式须要知足要求。
为了简化用户编程,Lua还提供了调试库来帮助用户下降编程难度。调试库其实也就是把基础API函数进行封装了一下,咱们以设置钩子函数debug.sethook为例:
文件ldblib.c中,定义了调试库支持的全部函数:
static int db_sethook (lua_State *L) { lua_sethook(L1, func, mask, count); } static const luaL_Reg dblib[] = { // 其余接口函数都删掉了,只保留这一个来说解 {"sethook", db_sethook}, {NULL, NULL} }; // 这个函数用来把调试库中的函数注册到全局变量表中 LUAMOD_API int luaopen_debug (lua_State *L) { luaL_newlib(L, dblib); return 1; }
能够看到,调试库的debgu.sethook()函数最终也是调用基础API函数:lua_sethook()。
在后面的调试器开发讲解中,我就是用debug库来实现一个远程调试器。
在钩子函数中,能够经过以下API函数还获取程序内部的信息了:
int lua_getinfo (lua_State *L, const char *what, lua_Debug *ar);
在这个API函数中:
第二个参数用来告诉虚拟机咱们想获取程序的哪些信息
第三个参数用来存储获取到的信息
结构体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) 上值的数量 */ unsigned char nparams; /* (u) 参数的数量 */ char isvararg; /* (u) */ char istailcall; /* (t) */ char short_src[LUA_IDSIZE]; /* (S) */ /* 私有部分 */ 其它域 } lua_Debug;
- source:建立这个函数的代码块的名字。 若是 source 以 '@' 打头, 指这个函数定义在一个文件中,而 '@' 以后的部分就是文件名。
- linedefined: 函数定义开始处的行号。
- lastlinedefined: 函数定义结束处的行号。
- currentline: 给定函数正在执行的那一行。
其余字段能够在参考手册中查询。
例如:若是想知道函数 f 是在哪一行定义的, 你可使用下列代码:
lua_Debug ar; lua_getglobal(L, "f"); /* 取得全局变量 'f' */ lua_getinfo(L, ">S", &ar); printf("%d\n", ar.linedefined);
一样的,也能够调用调试库debug.getinfo()来达到一样的目的。
通过上面的讲解,已经看到咱们获取程序信息都是经过Lua提供的API函数,或者是利用调试库提供的接口函数来完成的。那么修改程序内部信息也一样如此。
Lua提供了下面这2个API函数来修改函数中的变量:
const char *lua_setlocal (lua_State *L, const lua_Debug *ar, int n);
const char *lua_setupvalue (lua_State *L, int funcindex, int n);
一样的,也能够利用调试库中的debug.setlocal和debug.setupvalue来完成一样的功能。
上一篇文章说过,gdb调试模型有两种:本地调试和远程调试。
本地调试
远程调试
那么,咱们也能够按照这个思路来实现两种调试模型,只要把其中的gdb替换成ldb,gdbserver替换成ldbserver便可。
本地调试
远程调试
这两种调试模型本质是同样的,只是调试程序和被调试程序是否运行在同一台电脑上而已。
若是是远程调试,ldbserver调用接口函数对被调试程序进行控制,而后把结果经过TCP网络传递给ldb,ldbserver就至关于一个传话筒。
至于选择实现哪种调试模型?这个要根据实际场景的需求来决定。
我在这里实现的是远程调试,由于被调试程序是须要运行在ARM板子(下位机)中的,可是调试器是须要运行在PC电脑上(上位机)的,经过远程调试,只须要把ldbserver和被调试程序放到下位机中运行,ldb嵌入到上位机的集成开发环境(IDE)中运行就能够了。
另外,远程调试模型一样也能够所有运行在同一台PC电脑中,这个时候ldb与ldbserver之间就是在本机中进行TCP网络链接。
这里有2个内容须要补充一下:
- TCP连接能够直接利用第三方库luasocket。
- ldb与ldbserver之间的通信协议能够参照gdb与gdbserver之间的协议,也能够自定义。我借鉴了HTTP协议,简化了不少。
思考一个问题:被调试程序在执行时调用钩子函数,在钩子函数中咱们能够作各类调试操做,可是在执行到钩子函数的最后,是须要返回到被调试程序中的下一行指令码继续执行的,咱们不能打断被调试程序的执行序列。
可是,调试操做又须要经过TCP链接与上位机进行通讯协议的交互,好比:设置断点、查看变量的值、查看函数信息等等。因此,被调试程序的执行与调试器ldbserver的执行是2个并发的执行序列,能够理解为2个线程在并发执行。咱们须要在这2个执行序列之间进行协调,好比:
- ldbserver在等待用户输入指令时(running),被调试程序应该处于暂停状态(pending)。
- ldbserver接收到用户指令后(eg: run),本身应该暂停执行(pending),让被调试程序继续执行(running)。
上图中,两条红色箭头表示两个执行序列。这两个执行序列并非同时在执行的,而是交替执行,以下图所示:
那么怎么样才能让这2个执行序列交替执行呢?
若是是在C语言中,咱们能够经过信号量、互斥锁等各类方法实现,但这是在Lua语言中,应该利用什么机制来实现这个功能?
柳暗花明又一村!
Lua中提供了协程机制!
下面这段话是从参考手册中摘抄过来:
- Lua 支持协程,也叫协同式多线程。一个协程在 Lua 中表明了一段独立的执行线程。然而,与多线程系统中的线程的区别在于, 协程仅在显式调用一个让出(yield)函数时才挂起当前的执行。
- 调用函数coroutine.create可建立一个协程。
- 调用coroutine.resume函数执行一个协程。
- 经过调用coroutine.yield使协程暂停执行,让出执行权。
咱们可让ldbserver运行在一个协程中,被调试程序运行在主程序中。
当虚拟机执行一条被调试程序的指令码以后,调用钩子函数,在钩子函数中经过coroutine.resume让协程运行,主程序中止。前面说到,ldbserver运行在运行在一个协程中,此时就能够在ldbserver中利用阻塞函数(例如:TCP 中的receive),接收用户的调试指令。
假设用户发送来全速执行指令(run),ldbserver就调用coroutine.yield让本身挂起,此时被调试程序所在的主程序就能够继续执行了。
进行到这里,基本上大功告成!剩下的就是一些代码细节问题了。
这部分就比较简单了,从功能上来讲包括3部份内容:
- 与ldbserver之间创建TCP链接。
- 读取调试人员输入的指令,发送给ldbserver。
- 接收ldbserver发来的信息,显示给调试人员。
能够在调试终端中手动输入、显示调试信息,也能够把ldb嵌入到一个可视化的编辑工具中,例如:
local function print_commands() print("setb <file> <line> -- sets a breakpoin") print("step -- run one line, stepping into function") print("next -- run one line, stepping over function") print("goto <line> -- goto line in a function") // 其余指令 end
ldbserver经过调试库的debug.sethook函数,设置了一个钩子函数,调用参数是:
debug.sethook(my_hook, "lcr")
第二个参数"lcr"的含义是:
'c': 每当 Lua 调用一个函数时,调用钩子。
'r': 每当 Lua 从一个函数内返回时,调用钩子。
'l': 每当 Lua 进入新的一行时,调用钩子。
也便是说:虚拟机进入一个函数、从一个函数返回、每执行一行代码,都调用一次钩子函数。注意:这里的一行指定是被调试程序中的一行Lua代码,而不是二进制文件中的一行指令码,一行Lua代码可能被会编译生成多行指令码。
这里还有一点须要注意:钩子函数虽然是定义在用户代码中,可是它是被虚拟机调用的,也就是说钩子函数是处于主程序的执行序列中。
ldb向ldbserver发送设置断点的指令:setb test.lua 10,即:在test.lua文件的第10行设置一个断点,ldbserver接收到指令后,在内存中记录这个信息(文件名-行号)。
虚拟机在调用钩子函数时,传入两个参数(注意:钩子函数是被虚拟机调用的,因此它是处于主程序的执行序列中),
local function my_hook(event, line)
在钩子函数中,查找这个line是否被用户设置为断点,若是是那么就经过coroutine.resume让主程序暂停,让协程中的ldbserver执行。此时,ldbserver就能够在TCP网络上继续等待ldb发来的下一个调试指令。
next指令与step指令相似,区别在于当下一条指令是一个函数调用时:
step指令: 进入到函数内部。
next指令: 不进入函数内部,而是直接把这个函数执行完。
next指令的实现主要依赖于钩子函数的第一个参数event,上面在设置钩子函数的时候,告诉虚拟机在3种条件下调用钩子函数,从新贴一下:
'c': 每当 Lua 调用一个函数时,调用钩子
'r': 每当 Lua 从一个函数内返回时,调用钩子
'l': 每当 Lua 进入新的一行时,调用钩子
在进入钩子函数以后,event参数会告诉咱们:为何会调用钩子函数。代码以下:
function my_hook(event, line) if event == "call" then // 进入了一个函数 func_level = func_level + 1 elseif event == "return" then // 从一个函数返回 func_level = func_level - 1 else // 执行完一行代码 end
因此就能够利用event参数来记录进入、退出函数层数,而后在钩子函数中判断:是否须要暂停主程序,把执行的机会让给协程。
在调试过程当中,若是咱们想跳过当前执行函数中的某几行,能够发送goto指令,被调试程序就从当前中止的位置直接跳转到goto指令中设置的那行代码。
目前goto指令有一个限制:
由于Lua虚拟机中的全部代码都是以函数为单位的,经过函数调用栈把全部的代码串接在一块儿,所以只能goto到当前函数内的指定行。
这部分功能Lua源码中并无提供,须要扩展调试库的功能。核心步骤就是:强制把虚拟机中的PC指针设置为指定的那行Lua代码所对应的第一个指令码。
ar->i_ci->u.l.savedpc = cl->p->code + 须要跨过的指令码
ar变量就是调试库为咱们准备的:
const lua_Debug *ar
(若是你能跟着思路看到这里,我内心时很是很是的感激,能容忍我这么唠叨这么久。到这里我想表达的内容也差很少结束了,后面两个模块若是有兴趣的话能够稍微了解一下,不是重点。)
这部分先空着,若是有小伙伴想要详细了解的话,请在公众号(IOT物联网小镇)中留言给我,单独整理成文档。
比较重要的内容包括:
- 标准库的加载过程
- 函数调用栈
- 同时调试多个程序
- 如何处理中断信号
- 如何处理中断信号嵌套问题
- 如何添加本身的库
- 如何同时调试多个程序
- 其余指令的实现机制:查看、修改变量,查看函数调用栈,多个被调试程序的切换等等。
关于实际操做步骤,用文档表达起来比较费劲,所有是黑乎乎的终端窗口。计划录一个60分钟左右的视频,把上面提到的内容都操做演示一遍,这样效果会更好一下。有兴趣的话能够在B站搜一下个人ID(道哥分享)。
内容主要包括:
- 在Linux平台下:编译和调试步骤。
- Windows平台下:编译和调试步骤。
- 简单的图形调试界面,就是把ldb嵌入到IDE中。
若是以为文章不错,请转发、分享给您的朋友。
我会把十多年嵌入式开发中的项目实战经验进行总结、分享,相信不会让你失望的!
推荐阅读
[1] 原来gdb的底层调试原理这么简单
[2] 生产者和消费者模式中的双缓冲技术
[3] C_C++_静态库_动态库的制做和使用
[4] 利用C可变参数和宏定义来实现本身的日志系统
[5] C与C++混合编程
[6] 拿来即用:用C+JS结构来处理JSON数据
[7] 拿来即用:分享一个检查内存泄漏的小工具