上篇文章咱們聊了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] 拿來即用:分享一個檢查內存泄漏的小工具