一、前言上篇文章我們聊了gdb的底層調試機制,明白了gdb是利用操作系統提供的系統信號來調試目標程序的。很多朋友私下留言了,看到能幫助到大家,我心里還是很開心的,其實這也是我繼續輸出文章的最大動力!后面我會繼續把自己在項目開發中的實戰經驗進行總結。
由于gdb的代碼相對復雜,沒有辦法從代碼層面仔細的分析調試細節,所以這次我們選擇一個小巧、開源的Lua腳本語言,深入到最底層的代碼中去探究一下代碼調試真正是怎么一回事。
不過請放心,雖然深入到代碼最底層,但是理解難度并不大,只要C語言掌握的沒問題,其他就都不是問題。另外,這篇文章重點不是介紹代碼,而是介紹實現一個調試器應該如何思考,解決問題的思路是什么。
通過閱讀這篇文章,能有什么收獲?
- 如果你使用過Lua語言,那么你能夠從源代碼級別了解到調試庫的代碼邏輯。
- 如果你對Lua不了解,可以從設計思想、實現架構上學習到一門編程語言是如何進行調試程序的。
二、Lua 語言簡介
1. Lua是什么鬼?
喜歡玩游戲的小伙伴可能會知道,Lua語言在游戲開發中使用的比較多。它是一個輕量、小巧的腳本語言,用標準C語言編寫,源碼開放。正因為這幾個原因,所以我才選擇它作為剖析對象。
如果對于Lua語言還是沒有感覺,Python語言總應該知道吧?廣告滿天飛,你就把Lua想象為類似Python一樣的腳本語言,只不過體積比Python要輕量的得多。
這里有1張圖可以了解下,2020年12月份的編程語言市場占有率。
在上圖中看不到Lua的身影,因為市場占有率太低了,大概是位于30幾名。但是再看看下面這張圖,從工資的角度再體會一下Lua的高貴:
遠遠的把C/C++、JAVA甩在了身后,是不是有點沖動想學一下Lua語言了?先別激動,學習任何東西,先要想明白可以用在什么地方。如果僅僅是從找工作的角度來,Lua可以不用考慮了,畢竟市場需求量比較小。
2. 為什么選擇Lua語言作為研究對象?
雖然Lua語言在招聘網站中處于小眾需求,但是這并不妨礙我們利用Lua來深入的學習、研究一門編程語言,Lua語言雖小,但是五臟俱全。就像我們如果想學習Linux內核的設計思想,你是愿意從最開始的版本(幾千行代碼)開始呢?還是愿意從當前最新的內核代碼(2780萬行代碼,66492個文件)開始呢?
看一下當前最新版的Lua代碼體積:
同樣的思路,如果我們想深入研究一門編程語言,選擇哪一種語言,對于我們的積極性和學習效率是非常重要的。每個人的職業生涯都很長,花一些時間沉下心來研究透一門語言,對于一個開發者來說,還是蠻有成就的,對于職業的發展是非常有好處的,你會有一覽眾山小的感覺!
再看一下Lua代碼量與Python代碼量的對比:
從功能上來說,Lua與Python之間是沒有可比性的,但是我們的目的不是學習一個編程工具,而是研究一門編程語言本身,因此選擇Lua腳本語言進行學習、研究,沒有錯!
言歸正傳。
三、Lua源代碼5.3.5
1. Lua程序是如何執行的?
Lua 是一門擴展式程序設計語言,被設計成支持通用過程式編程,并有相關數據描述設施。同時對面向對象編程、函數式編程和數據驅動式編程也提供了良好的支持。它作為一個強大、輕量的嵌入式腳本語言,可供任何需要的程序使用。
作為一門擴展式語言,Lua沒有"main"程序的概念:它只能嵌入一個宿主程序中工作,該宿主程序被稱為被嵌入程序或者簡稱宿主。宿主程序可以調用函數執行一小段Lua代碼,可以讀寫Lua變量,可以注冊C函數讓Lua代碼調用。依靠C函數,Lua可以共享相同的語法框架來定制編程語言,從而適用不同的領域。
也就是說,我們寫了一個test.lua程序,是沒有辦法直接運行它的。而實需要一個“宿主”程序,來加載test.lua文件。
宿主程序可以是一個最簡單的C程序,Lua官方提供了一個宿主程序。
我們也可以自己寫一個,如下:
// 引入Lua頭文件
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);
}
// 其他代碼
}
2. Lua語法
在語法層面,Lua涵蓋的內容還是比較全面的,它是一門動態類型語言,基本概念包括:八種基本數據類型,表是唯一的數據結構,環境與全局變量,元表及元方法,協程,閉包,錯誤處理,垃圾收集。具體的信息可以看一下Lua5.3參考手冊。
這篇文章主要從調試器這個角度進行分析,因此我不會在這里詳細的貼出很多代碼細節,而只是把與調試有關的代碼貼出來進行解釋。
我之前在學習Lua源碼時(5.3.5版本),在代碼文件中記錄了很多注釋,可以很好的幫助理解,主要是因為我的忘性比較好。
其實我更建議大家自己去下載源碼學習,經過自己的理解、加工,印象會更深刻。在之前的工作中,由于項目需要,我對源碼進行了一些優化,這部分代碼就不放出來了,添加注釋的源碼是完完全全的Lua5.3.5版本,大概是這個樣子:
如果有小伙伴需要加了注釋的源碼,請在公眾號(IOT物聯網小鎮)里留言給我。
四、Lua調試庫相關
我們可以停下來稍微想一下,對一個程序進行調試,需要考慮的問題有3點:
- 如何讓程序暫停執行?
- 如何獲取程序的內部信息?
- 如果修改程序的內部信息?
帶著這些問題,我們來逐個擊破。
1. 鉤子函數(Hook):讓程序暫停執行
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(),如下代碼:
voidluaD_hook (lua_State *L, intevent, 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);
}
}
只要進入了用戶設置的鉤子函數,那么我們就可以在這個函數中為所欲為了。
比如:獲取程序內部信息,讀取、修改變量的值,查看函數調用棧信息等等,這就是下面要講解的內容。
2. Lua調試庫是什么?
首先說一下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中,定義了調試庫支持的所有函數:
staticintdb_sethook(lua_State *L) {
lua_sethook(L1, func, mask, count);
}
static const luaL_Reg dblib[] = {
// 其他接口函數都刪掉了,只保留這一個來講解
{"sethook", db_sethook},
{NULL, NULL}
};
// 這個函數用來把調試庫中的函數注冊到全局變量表中
LUAMOD_API intluaopen_debug(lua_State *L) {
luaL_newlib(L, dblib);
return 1;
}
可以看到,調試庫的debgu.sethook()函數最終也是調用基礎API函數:lua_sethook()。
在后面的調試器開發講解中,我就是用debug庫來實現一個遠程調試器。
3. 獲取程序內部信息
在鉤子函數中,可以通過如下API函數還獲取程序內部的信息了:
int lua_getinfo (lua_State *L, const char *what, lua_Debug *ar);
在這個API函數中:
第二個參數用來告訴虛擬機我們想獲取程序的哪些信息
第三個參數用來存儲獲取到的信息
結構體lua_Debug比較重要,成員變量如下:
typedef structlua_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()來達到同樣的目的。
4. 修改程序內部信息
經過上面的講解,已經看到我們獲取程序信息都是通過Lua提供的API函數,或者是利用調試庫提供的接口函數來完成的。那么修改程序內部信息也同樣如此。Lua提供了下面這2個API函數來修改函數中的變量:
- 修改當前活動記錄總的局部變量的值:
const char *lua_setlocal (lua_State *L, const lua_Debug *ar, int n);
- 設置閉包上值的值(上值upvalue就是閉包使用了外層的那些變量)
const char *lua_setupvalue (lua_State *L, int funcindex, int n);
同樣的,也可以利用調試庫中的debug.setlocal和debug.setupvalue來完成同樣的功能。
5. 小結
到這里,我們就把Lua語言中與調試有關的機制和代碼都理解清楚了,剩下的問題就是如何利用它提供的這些接口,來編寫一個類似gdb一樣的調試器。
就好比:Lua已經把材料(米、面、菜、肉、佐料)擺在我們的面前了,剩下的就需要我們把這些材料做成一桌美味佳肴。## 五、Lua調試器開發
1. 與gdb調試模型做類比
上一篇文章說過,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協議,簡化了很多。
2. ldbserver如何實現
思考一個問題:被調試程序在執行時調用鉤子函數,在鉤子函數中我們可以做各種調試操作,但是在執行到鉤子函數的最后,是需要返回到被調試程序中的下一行指令碼繼續執行的,我們不能打斷被調試程序的執行序列。
但是,調試操作又需要通過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. ldb如何實現
這部分就比較簡單了,從功能上來說包括3部分內容:
- 與ldbserver之間建立TCP連接。
- 讀取調試人員輸入的指令,發送給ldbserver。
- 接收ldbserver發來的信息,顯示給調試人員。
可以在調試終端中手動輸入、顯示調試信息,也可以把ldb嵌入到一個可視化的編輯工具中,例如:
local functionprint_commands()
print("setb -- sets a breakpoin" )
print("step -- run one line, stepping into function")
print("next -- run one line, stepping over function")
print("goto -- goto line in a function" )
// 其他指令
end
六、調試指令舉例
1. break指令的實現
(1)設置鉤子函數
ldbserver通過調試庫的debug.sethook函數,設置了一個鉤子函數,調用參數是:
debug.sethook(my_hook, "lcr")
第二個參數"lcr"的含義是:
'c': 每當 Lua 調用一個函數時,調用鉤子。
'r': 每當 Lua 從一個函數內返回時,調用鉤子。
'l': 每當 Lua 進入新的一行時,調用鉤子。
也即是說:虛擬機進入一個函數、從一個函數返回、每執行一行代碼,都調用一次鉤子函數。注意:這里的一行指定是被調試程序中的一行Lua代碼,而不是二進制文件中的一行指令碼,一行Lua代碼可能被會編譯生成多行指令碼。
這里還有一點需要注意:鉤子函數雖然是定義在用戶代碼中,但是它是被虛擬機調用的,也就是說鉤子函數是處于主程序的執行序列中。
(2)設置斷點
ldb向ldbserver發送設置斷點的指令:setb test.lua 10,即:在test.lua文件的第10行設置一個斷點,ldbserver接收到指令后,在內存中記錄這個信息(文件名-行號)。
(3)捕獲斷點
虛擬機在調用鉤子函數時,傳入兩個參數(注意:鉤子函數是被虛擬機調用的,所以它是處于主程序的執行序列中),
local function my_hook(event, line)
在鉤子函數中,查找這個line是否被用戶設置為斷點,如果是那么就通過coroutine.resume讓主程序暫停,讓協程中的ldbserver執行。此時,ldbserver就可以在TCP網絡上繼續等待ldb發來的下一個調試指令。
2. next指令的實現
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參數來記錄進入、退出函數層數,然后在鉤子函數中判斷:是否需要暫停主程序,把執行的機會讓給協程。
3. goto指令的實現
在調試過程中,如果我們想跳過當前執行函數中的某幾行,可以發送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中。
-
代碼
+關注
關注
30文章
4744瀏覽量
68345 -
gdb
+關注
關注
0文章
60瀏覽量
13278 -
lua腳本
+關注
關注
0文章
21瀏覽量
7577
發布評論請先 登錄
相關推薦
評論