深刻LUA腳本語言,讓你完全明白調試原理


這是道哥的第008篇原創


1、前言

上篇文章咱們聊了gdb的底層調試機制,明白了gdb是利用操做系統提供的系統信號來調試目標程序的。不少朋友私下留言了,看到能幫助到你們,我內心仍是很開心的,其實這也是我繼續輸出文章的最大動力!後面我會繼續把本身在項目開發中的實戰經驗進行總結。html

因爲gdb的代碼相對複雜,沒有辦法從代碼層面仔細的分析調試細節,因此此次咱們選擇一個小巧、開源的Lua腳本語言,深刻到最底層的代碼中去探究一下代碼調試真正是怎麼一回事。編程

不過請放心,雖然深刻到代碼最底層,可是理解難度並不大,只要C語言掌握的沒問題,其餘就都不是問題。
另外,這篇文章重點不是介紹代碼,而是介紹實現一個調試器應該如何思考,解決問題的思路是什麼。網絡

經過閱讀這篇文章,能有什麼收穫?數據結構

  1. 若是你使用過Lua語言,那麼你可以從源代碼級別瞭解到調試庫的代碼邏輯。
  2. 若是你對Lua不瞭解,能夠從設計思想、實現架構上學習到一門編程語言是如何進行調試程序的。

2、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個文件)開始呢?socket

看一下當前最新版的Lua代碼體積:

一樣的思路,若是咱們想深刻研究一門編程語言,選擇哪種語言,對於咱們的積極性和學習效率是很是重要的。每一個人的職業生涯都很長,花一些時間沉下心來研究透一門語言,對於一個開發者來講,仍是蠻有成就的,對於職業的發展是很是有好處的,你會有一覽衆山小的感受!

再看一下Lua代碼量與Python代碼量的對比:

從功能上來講,Lua與Python之間是沒有可比性的,可是咱們的目的不是學習一個編程工具,而是研究一門編程語言自己,所以選擇Lua腳本語言進行學習、研究,沒有錯!

言歸正傳。

3、Lua源代碼5.3.5

1. 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);
    }
    // 其餘代碼
}

2. Lua語法

在語法層面,Lua涵蓋的內容仍是比較全面的,它是一門動態類型語言,基本概念包括:八種基本數據類型,表是惟一的數據結構,環境與全局變量,元表及元方法,協程,閉包,錯誤處理,垃圾收集。具體的信息能夠看一下Lua5.3參考手冊

這篇文章主要從調試器這個角度進行分析,所以我不會在這裏詳細的貼出不少代碼細節,而只是把與調試有關的代碼貼出來進行解釋。

我以前在學習Lua源碼時(5.3.5版本),在代碼文件中記錄了不少註釋,能夠很好的幫助理解,主要是由於個人忘性比較好。

其實我更建議你們本身去下載源碼學習,通過本身的理解、加工,印象會更深入。在以前的工做中,因爲項目須要,我對源碼進行了一些優化,這部分代碼就不放出來了,添加註釋的源碼是完徹底全的Lua5.3.5版本,大概是這個樣子:

若是有小夥伴須要加了註釋的源碼,請在公衆號(IOT物聯網小鎮)裏留言給我。

4、Lua調試庫相關

咱們能夠停下來稍微想一下,對一個程序進行調試,須要考慮的問題有3點:

  1. 如何讓程序暫停執行?
  2. 如何獲取程序的內部信息?
  3. 若是修改程序的內部信息?

帶着這些問題,咱們來逐個擊破。

1. 鉤子函數(Hook):讓程序暫停執行

Lua虛擬機(也可稱之爲解釋器)內部提供了一個接口:用戶能夠在應用程序中設置一個鉤子函數(Hook),虛擬機在執行指令碼的時候會檢查用戶是否設置了鉤子函數,若是設置了,就調用這個鉤子函數。本質上就是設置一個回調函數,由於都是用C語言來實現的,虛擬機中只要把這個鉤子函數的地址記住,而後在某些場合回調這個函數就能夠了。

那麼,虛擬機在哪些場合回調用戶設置的鉤子函數呢?


咱們在設置Hook函數的時候,能夠經過mask參數來設置回調策略,也就是告訴虛擬機:在何時來回調鉤子函數。mask參數能夠是下列選項的組合操做:

  1. LUA_MASKCALL:調用一個函數時,就調用一次鉤子函數。
  2. LUA_MASKRET:從一個函數中返回時,就調用一次鉤子函數。
  3. LUA_MASKLINE:執行一行指令時,就回調一次鉤子函數。
  4. 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);
  }
}

只要進入了用戶設置的鉤子函數,那麼咱們就能夠在這個函數中隨心所欲了。


好比:獲取程序內部信息,讀取、修改變量的值,查看函數調用棧信息等等,這就是下面要講解的內容。

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中,定義了調試庫支持的全部函數:

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庫來實現一個遠程調試器。

3. 獲取程序內部信息

在鉤子函數中,能夠經過以下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;
  1. source:建立這個函數的代碼塊的名字。 若是 source 以 '@' 打頭, 指這個函數定義在一個文件中,而 '@' 以後的部分就是文件名。
  2. linedefined: 函數定義開始處的行號。
  3. lastlinedefined: 函數定義結束處的行號。
  4. 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函數來修改函數中的變量:

  1. 修改當前活動記錄總的局部變量的值:

const char *lua_setlocal (lua_State *L, const lua_Debug *ar, int n);

  1. 設置閉包上值的值(上值upvalue就是閉包使用了外層的那些變量)

const char *lua_setupvalue (lua_State *L, int funcindex, int n);

一樣的,也能夠利用調試庫中的debug.setlocal和debug.setupvalue來完成一樣的功能。

5. 小結

到這裏,咱們就把Lua語言中與調試有關的機制和代碼都理解清楚了,剩下的問題就是如何利用它提供的這些接口,來編寫一個相似gdb同樣的調試器。
就比如:Lua已經把材料(米、面、菜、肉、佐料)擺在咱們的面前了,剩下的就須要咱們把這些材料作成一桌美味佳餚。

5、Lua調試器開發

1. 與gdb調試模型作類比

上一篇文章說過,gdb調試模型有兩種:本地調試和遠程調試

本地調試

遠程調試

那麼,咱們也能夠按照這個思路來實現兩種調試模型,只要把其中的gdb替換成ldb,gdbserver替換成ldbserver便可。

本地調試

遠程調試

這兩種調試模型本質是同樣的,只是調試程序和被調試程序是否運行在同一臺電腦上而已。


若是是遠程調試,ldbserver調用接口函數對被調試程序進行控制,而後把結果經過TCP網絡傳遞給ldb,ldbserver就至關於一個傳話筒

至於選擇實現哪種調試模型?這個要根據實際場景的需求來決定。
我在這裏實現的是遠程調試,由於被調試程序是須要運行在ARM板子(下位機)中的,可是調試器是須要運行在PC電腦上(上位機)的,經過遠程調試,只須要把ldbserver和被調試程序放到下位機中運行,ldb嵌入到上位機的集成開發環境(IDE)中運行就能夠了。

另外,遠程調試模型一樣也能夠所有運行在同一臺PC電腦中,這個時候ldb與ldbserver之間就是在本機中進行TCP網絡鏈接。

這裏有2個內容須要補充一下:

  1. TCP連接能夠直接利用第三方庫luasocket。
  2. ldb與ldbserver之間的通信協議能夠參照gdb與gdbserver之間的協議,也能夠自定義。我借鑑了HTTP協議,簡化了不少。

2. ldbserver如何實現

思考一個問題:被調試程序在執行時調用鉤子函數,在鉤子函數中咱們能夠作各類調試操做,可是在執行到鉤子函數的最後,是須要返回到被調試程序中的下一行指令碼繼續執行的,咱們不能打斷被調試程序的執行序列。

可是,調試操做又須要經過TCP鏈接與上位機進行通訊協議的交互,好比:設置斷點、查看變量的值、查看函數信息等等。因此,被調試程序的執行與調試器ldbserver的執行是2個併發的執行序列,能夠理解爲2個線程在併發執行。咱們須要在這2個執行序列之間進行協調,好比:

  1. ldbserver在等待用戶輸入指令時(running),被調試程序應該處於暫停狀態(pending)。
  2. ldbserver接收到用戶指令後(eg: run),本身應該暫停執行(pending),讓被調試程序繼續執行(running)。

上圖中,兩條紅色箭頭表示兩個執行序列。這兩個執行序列並非同時在執行的,而是交替執行,以下圖所示:

那麼怎麼樣才能讓這2個執行序列交替執行呢?

若是是在C語言中,咱們能夠經過信號量、互斥鎖等各類方法實現,但這是在Lua語言中,應該利用什麼機制來實現這個功能?

柳暗花明又一村!


Lua中提供了協程機制
下面這段話是從參考手冊中摘抄過來:

  1. Lua 支持協程,也叫協同式多線程。一個協程在 Lua 中表明瞭一段獨立的執行線程。然而,與多線程系統中的線程的區別在於, 協程僅在顯式調用一個讓出(yield)函數時才掛起當前的執行。
  2. 調用函數coroutine.create可建立一個協程。
  3. 調用coroutine.resume函數執行一個協程。
  4. 經過調用coroutine.yield使協程暫停執行,讓出執行權。

咱們可讓ldbserver運行在一個協程中,被調試程序運行在主程序中。
當虛擬機執行一條被調試程序的指令碼以後,調用鉤子函數,在鉤子函數中經過coroutine.resume讓協程運行,主程序中止。前面說到,ldbserver運行在運行在一個協程中,此時就能夠在ldbserver中利用阻塞函數(例如:TCP 中的receive),接收用戶的調試指令。

假設用戶發送來全速執行指令(run),ldbserver就調用coroutine.yield讓本身掛起,此時被調試程序所在的主程序就能夠繼續執行了。

進行到這裏,基本上大功告成!剩下的就是一些代碼細節問題了。

3. ldb如何實現

這部分就比較簡單了,從功能上來講包括3部份內容:

  1. 與ldbserver之間創建TCP鏈接。
  2. 讀取調試人員輸入的指令,發送給ldbserver。
  3. 接收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

6、調試指令舉例

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

(若是你能跟着思路看到這裏,我內心時很是很是的感激,能容忍我這麼嘮叨這麼久。到這裏我想表達的內容也差很少結束了,後面兩個模塊若是有興趣的話能夠稍微瞭解一下,不是重點。)

7、其餘重要的模塊

這部分先空着,若是有小夥伴想要詳細瞭解的話,請在公衆號(IOT物聯網小鎮)中留言給我,單獨整理成文檔。
比較重要的內容包括:

  1. 標準庫的加載過程
  2. 函數調用棧
  3. 同時調試多個程序
  4. 如何處理中斷信號
  5. 如何處理中斷信號嵌套問題
  6. 如何添加本身的庫
  7. 如何同時調試多個程序
  8. 其餘指令的實現機制:查看、修改變量,查看函數調用棧,多個被調試程序的切換等等。

8、調試操做步驟

關於實際操做步驟,用文檔表達起來比較費勁,所有是黑乎乎的終端窗口。計劃錄一個60分鐘左右的視頻,把上面提到的內容都操做演示一遍,這樣效果會更好一下。有興趣的話能夠在B站搜一下個人ID(道哥分享)。
內容主要包括:

  1. 在Linux平臺下:編譯和調試步驟。
  2. Windows平臺下:編譯和調試步驟。
  3. 簡單的圖形調試界面,就是把ldb嵌入到IDE中。

【原創聲明】

> 做者:道哥(公衆號: IOT物聯網小鎮)
> 知乎:道哥
> B站:道哥分享
> 掘金:道哥分享
> CSDN:道哥分享

若是以爲文章不錯,請轉發、分享給您的朋友。


我會把十多年嵌入式開發中的項目實戰經驗進行總結、分享,相信不會讓你失望的!

長按下圖二維碼關注,每篇文章都有乾貨。


轉載:歡迎轉載,但未經做者贊成,必須保留此段聲明,必須在文章中給出原文鏈接。




推薦閱讀

[1] 原來gdb的底層調試原理這麼簡單
[2] 生產者和消費者模式中的雙緩衝技術
[3] C_C++_靜態庫_動態庫的製做和使用
[4] 利用C可變參數和宏定義來實現本身的日誌系統
[5] C與C++混合編程
[6] 拿來即用:用C+JS結構來處理JSON數據
[7] 拿來即用:分享一個檢查內存泄漏的小工具

相關文章
相關標籤/搜索