深入LUA腳本語言,讓你徹底明白調試原理

  • 2020 年 12 月 18 日
  • 筆記

這是道哥的第008篇原創

一、前言

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

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

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

通過閱讀這篇文章,能有什麼收穫?

  1. 如果你使用過Lua語言,那麼你能夠從源代碼級別了解到調試庫的代碼邏輯。
  2. 如果你對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頭文件
#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物聯網小鎮)里留言給我。

四、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已經把材料(米、面、菜、肉、佐料)擺在我們的面前了,剩下的就需要我們把這些材料做成一桌美味佳肴。

五、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

六、調試指令舉例

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物聯網小鎮)中留言給我,單獨整理成文檔。
比較重要的內容包括:

  1. 標準庫的加載過程
  2. 函數調用棧
  3. 同時調試多個程序
  4. 如何處理中斷信號
  5. 如何處理中斷信號嵌套問題
  6. 如何添加自己的庫
  7. 如何同時調試多個程序
  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] 拿來即用:分享一個檢查內存泄漏的小工具