Lua的debug hook功能探究與改造–上篇

來源:互聯網
上載者:User

 Lua5.1提供了較為完善的debug庫函數,其中的sethook可以讓使用者自己設定hook函數來監控程式的某些運行行為,這包括:調用 函數,從函數返回和將要運行新的一行代碼,每當這些事件(event)發生時hook函數都會被調用。讀者可以試試 monitor_v1.lua, 它是debug hook功能的一個簡單應用。require匯入該檔案後便得到一個名為monitor的擴充庫,裡面只有一個register函數, 原型為:monitor.register([thread,] func)。調用它後系統便能夠監控func函數在thread線程(如果沒有指定就是指 調用monitor.register函數時所在的當前線程)中的調用和返回事件:每當事件發生時就列印受監控函數的所有局部變數的名字和 值。請看這段程式:
require("monitor_v1")

function sum(x,y)
    y = 88
    return x + y
end

monitor.register(sum)

sum(4,5)
  

它運行後的輸出是:
sum->function: 003D9658 , `call' event
local variables are:
----------------------
x       4
y       5
(*temporary)    nil
(*temporary)    table: 003DA688

sum->function: 003D9658 , `return' event
local variables are:
----------------------
x       4
y       88
(*temporary)    92
(*temporary)    table: 003DA688
  

有關x、y的資訊很容易理解:它們是傳入的實參,在入口處分別為4和5,在出口處則為4和88(y的值在函數體內被更改)。但是 另外兩個名為`(*temporary)'的東西是什麼呢?為瞭解釋這個問題,先讓我們來多瞭解一點Lua虛擬機器的秘密。

同大多數語言一樣,Lua也用棧來實現各種程序呼叫。棧分為調用棧(call stack)和資料棧(data stack),虛擬機器會為每一個被 調用的函數在資料棧上分配一塊屬於它們自己的連續地區以便存放參數、自訂的局部變數、臨時變數和傳回值,這就是函數的 棧架構(stack frame)。調用棧的每一層儲存的是與該層對應的函數的執行資訊(包括函數的中繼資料、棧架構的起始和結束位置、 子函數調用的返回地址等),調用棧與資料棧的精確配合是程式正確啟動並執行基本前提。Lua5.1的官方實現用了一個容易讓人迷惑的 名字--寄存器(register)來指代棧架構上的儲存單元:從架構的基址(base)開始,依次是0號、1號、2號.....寄存器。由於 寄存器就是架構上的儲存單元,所以調用棧上不同層的函數的同一序號寄存器指代的並不是同一個棧單元,這點務必弄清。為了方便, 接下來的行文將用棧特指資料棧,用R(X)表示序號為X的寄存器或寄存器的內容,讀者根據上下文應該很容易判斷它的具體指代。 一個Lua函數可以有多條執行路徑,這導致一個函數在每次被調用時使用的棧架構尺寸有可能發生變化,但其中最大的棧架構的大小 完全能夠在編譯期確定,並且由於該值對將來程式的運行很有協助,所以每一個編譯後的Lua函數都包含了這條資訊,官方實現把它 稱為maxstacksize。在準備函數調用時虛擬機器會根據它的maxstacksize預先分配相應大小的棧架構,這樣既能保證這個棧架構 夠用(因為按最大需要來分配),又能免去之後動態調整之累。局部變數的資訊是通過debug.getlocal([thread,] level, Y) 取得的。它返回thread線程(如不指定便是當前線程)的調用棧上第level層函數的第Y個局部變數的名字和值。由於Y是從1開始 編號的,所以只要level合法且R(Y-1)位於相應函數的棧架構內,函數就一定返回R(Y-1)對應的名字和值。按照Lua5.1參考手冊 的解釋,當棧單元儲存的是一個具名局部變數(即函數參數和定義在函數體內的local變數)那它對應的名字便是該局部變數的名字, 否則它就是一個內部變數(即迴圈變數、臨時變數和C function的local變數),名字將以左括弧`('開頭。

回到我們寫的sum函數,它的2個參數需要佔用2個棧單元,再加上return語句返回的那個運算式需要佔一個單元,所以sum函數需要 的最大棧架構尺寸是3。請讀者注意,我們這樣估測虛擬機器指令的內部資訊只是為了說明例子中的問題,人工方法既不可靠也無必要。 如果真想一窺編譯後代碼的究竟,可以使用一個出色的工具--ChunkSpy,它能方便地反組譯碼Lua虛擬機器的位元組碼並以一種美觀的 方式顯示出來,目前已有針對Lua5.0和5.1的版本,首頁在 這裡。 當sum剛剛開始進入調用還未觸發hook函數時,它的棧架構的布局應該是這樣的(其中棧頂指的是棧的下一個可用單元):
     |   .    |             
     |   .    |  更上層函數的棧架構
     |   .    |      |
     |--------|<-----|
     | R(0)=4 |      |
 x-->|--------|      |
     | R(1)=5 |    sum棧架構
 y-->|--------|      |
     |R(2)=nil|      |
     |--------|<-----|
     |        |
     |--------|<---棧頂
  

R(0)和R(1)分別是具名局部變數x和y,R(2)僅是一個預配置單位(它們都被初始化為nil),所以用getlocal得到的名字就是 `(*temporary)'。現在又有問題了,按照上面的分析應該只有一個內部變數,可為什麼後面還會多出一個,而且值為table?其實 我們通過debug.sethook設定的hook函數是由虛擬機器以對應線程為key儲存在一張名為hooktable的表中,而hooktable則是以 一個light userdata(它的值就是ldblib.c 203行定義的static char變數KEY_HOOK的地址)為key儲存在registry表中。 要想找到hook函數需要用lua C API中的壓棧函數先把hooktable和線程對象壓棧(這會引起棧的增長),再用lua_rawget取得 hook函數,然後壓入合適的參數並用lua_call調用它。由於lua_rawget不會把表彈出棧,所以hooktable仍然留在sum函數的 棧架構頂部。需要提醒的是,hooktable是一個動態分配的對象,所以每次運行程式它輸出的地址都可能不同。進入到hook函數後 相應棧布局是:
     |      .       |      |       
     |      .       |   更上層函數的棧架構
     |      .       |      |
     |--------------|<-----|
     |    R(0)=4    |      |
 x-->|--------------|      |
     |    R(1)=5    |    sum棧架構
 y-->|--------------|      |
     |   R(2)=nil   |      |
     |--------------|      |
     |R(3)=hooktable|      |
     |--------------|<-----|
     |      .       |      |
     |      .       |  hook函數棧架構
     |      .       |      |
     |--------------|<-----|
     |              |
     |--------------|<--棧頂
 

函數return時所有傳回值(是的,Lua可以return多個傳回值)將依次儲存在棧架構上,並且棧頂也跟著調整指向到最後一個傳回值 所在單元的下一個位置。如果沒有傳回值,棧頂將被設為指向棧架構的R(0)位置(相當於清空棧架構)。我們的sum函數返回時R(1) 位置的y變數已被改為88,而R(3)則容納了傳回值92。注意由於每次調hook函數之前都要先到hooktable裡尋找,所以hooktable 總會遺留在被監控函數棧架構的頂部。此時的布局是:
     |      .       |      |       
     |      .       |   更上層函數的棧架構
     |      .       |      |
     |--------------|<-----|
     |    R(0)=4    |      |
 x-->|--------------|      |
     |   R(1)=88    |    sum棧架構
 y-->|--------------|      |
     |   R(2)=92    |      |
     |--------------|      |
     |R(3)=hooktable|      |
     |--------------|<-----|
     |      .       |      |
     |      .       |  hook函數棧架構
     |      .       |      |
     |--------------|<-----|
     |              |
     |--------------|<--棧頂
 

搞清楚了棧架構的概念後來看看這段代碼:
require("monitor_v1")

function null()
end

monitor.register(null)

null()
   

運行輸出是:
null->function: 003D9658 , `call' event
local variables are:
----------------------
(*temporary)    nil
(*temporary)    nil
(*temporary)    table: 003DBEE8

null->function: 003D9658 , `return' event
local variables are:
----------------------
(*temporary)    table: 003DBEE8
   

null是個空空如也的函數,它的執行不需要任何棧單元,maxstacksize等於0,所以call null時棧架構上應該只有一個對應 hooktable的`(*temporary)'變數。但是不知道出於什麼原因,Lua 5.1.1源碼的lparse.c檔案的346行有這麼一句:
      f->maxstacksize = 2;  /* registers 0/1 are always valid */
   

也就是說任何函數都會給它分配至少2單元大小的棧架構。這就不難解釋null的call事件發生時hooktable前的2個古怪的nil 變數了。又因為函數返回前棧頂已經調整了,所以return時的列印資訊只會有一個hooktable。

嗯,到目前為止,是不是覺得一切都很簡單?那讓我們來看看另外一個例子:
require("monitor_v1")

function inner(m)
    m = m + 7
    return m * 10
end

function foo(a,b)
    b = b + 10
    return inner(a + b)
end

monitor.register(foo)

foo(7,8)
   

它的運行結果是:
foo->function: 003D96F0 , `call' event
local variables are:
----------------------
a       7
b       8
(*temporary)    nil
(*temporary)    nil
(*temporary)    table: 003DA6B0
   

咦,這是怎麼回事?為什麼foo進入調用後就不再返回了?是不是程式出錯了?如果程式出錯那麼解譯器應該會異常退出並報告錯誤 資訊,所以從解譯器的表現來看foo運行應該一切正常。那麼現在就只剩下兩種可能:要麼是foo的返回事件沒有被捕獲,要麼 就是它根本沒有被觸發。實際上,問題的真正原因是後者,要搞清楚它先得來熟悉一下尾調用(tail call)的概念。如果某個 return語句的傳回值列表只有一項,並且這一項是一個函數調用的話,那麼該函數調用稱為一個尾調用,Lua虛擬機器 有一條專門的叫TAILCALL的指令對這種調用做最佳化處理。以5.1.1的實現來說,它的具體行為是:在準備尾調用時,虛擬機器先象 對待普通調用那樣從棧頂開始為被調函數分配一個新的架構,同時調用棧也隨之增長並初始化相應資料;接著虛擬機器會把棧頂的這些 被調函數的資訊(包括棧架構內容和調用棧中的內容)拷貝到調用者的對應位置上,然後資料棧和調用棧都收縮(shrink)一層,最後 再開始執行被尾調用者的函數體。很明顯,被尾調用的函數複用了調用者在資料棧和調用棧上的空間,這相當於用被調函數替換了 棧頂的調用者,而調用者則從調用鏈上徹底消失了,所以系統沒有辦法再觸發調用者的返回事件。下面我們看看實際的情況是否如此, 把上常式序改為:
require("monitor_v1")

function inner(m)
    m = m + 7
    return m * 10
end

function foo(a,b)
    b = b + 10
    return inner(a + b)
end
-- 對inner也進行監控
monitor.register(inner)
monitor.register(foo)

foo(7,8)
   

它的輸出是:
foo->function: 003D9668 , `call' event
local variables are:
----------------------
a       7
b       8
(*temporary)    nil
(*temporary)    nil
(*temporary)    table: 003DA688

inner->function: 003DA8B0 , `call' event
local variables are:
----------------------
m       25
(*temporary)    nil
(*temporary)    table: 003DA688

anonymous function: 003DA8B0 , `return' event
local variables are:
----------------------
m       32
(*temporary)    320
(*temporary)    table: 003DA688

foo->function: 003DA8B0 , `tail return' event
local variables are:
----------------------
m       32
(*temporary)    320
(*temporary)    table: 003DA688
   

由於尾調用的特殊性,所以被尾調用的函數在返回時觸發的事件也有點特殊:不是return而是tail return(奇怪的是,系統卻 沒有提供與之對應的tail call)。Lua還保證在一個函數的完整執行過程中call事件的總數和return事件加tail return事件 的總數是相等的,這姑且稱為事件恒等式吧。從上面的輸出資訊可以看出,最後一條我們收到的不是foo的return事件,而是為了 維持事件恒等式成立的inner函數的tail return事件(不要被它包含的那個`foo'名字所迷惑,你只要比較函數對象的值就知道 那時的函數是inner,至於Lua怎麼巧妙地取得foo這個名字作者將另行撰文分析),輸出的架構資訊也是inner函數的。其實只要 存在尾調用,那麼函數返回時的棧架構內容將取決於由該函數引發的尾調用鏈(被尾調用的函數還能尾調用其它函數)的末端函數。 讀者或許會問,還有沒有辦法在foo函數的出口處得到它本身的局部變數資訊呢?當然有!請注意,call事件引發的hook函數調用 是在剛剛為被調函數分配並初始化好棧架構之後進行的,也就是說如果調用為尾調用的話,那麼此時調用者函數的資訊還留在棧上, 只要我們把它儲存下來然後待真正返回時(可以利用事件恒等式判斷調用者是否返回)再輸出就可以了。具體的做法可以參考 monitor_v2.lua。現在只要將 樣本程式中的
require("monitor_v1")
   

改成
require("monitor_v2")
   

同時刪掉對inner的監控(免得資訊太多),就會得到正確的結果:
foo->function: 003D96F0 , `call' event
local variables are:
----------------------
a       7
b       8
(*temporary)    nil
(*temporary)    nil
(*temporary)    table: 003DA688

foo->function: 003D9658 , `tail return' event
local variables are:
----------------------
a       7
b       18
   

讀者一定發現了這次輸出的資訊少了傳回值部分,要解決這個問題需要更深入的挖掘,作者將在本文的下篇中給出詳細的解釋。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.