1. 程式人生 > 實用技巧 >erlang虛擬機器程式碼執行原理

erlang虛擬機器程式碼執行原理

erlang 是開源的,很多人都研究過原始碼。但是,從erlang程式碼到c程式碼,這是個不小的跨度,而且程式碼也比較複雜。所以這裡,我利用一些時間,整理下 erlang程式碼的執行過程,從erlang程式碼編譯過程,到程式碼執行過程做講解,然後重點講下虛擬機器執行程式碼的原理。將本篇文章,獻給所有喜歡erlang的人。

erlang程式碼編譯過程
erlang對開發者是友好的,從erlang程式檔案編譯成能被erlang虛擬機器識別的beam檔案,在這個編譯過程還對開發者暴露中間程式碼。藉助這個中間程式碼,我們就可以逐步探究erlang程式碼的執行過程。

這是erlnag的編譯過程,當然,最開始和大多數編譯器一樣,首先會將程式檔案轉換成語法樹,但這個轉換對我們來說閱讀的意義不大,所以歸結於以上3個過程。

  1. erlang核心程式碼
    確切的叫法是Core Erlang,使用了類似Haskell 的語法,而且每個變數都用“Let” 宣告。在erlang shell通過以下方式可以獲取模組的Core Erlang程式碼,將會生成test.core檔案
    c(test, to_core).
    實際上core檔案可以直接編譯成beam檔案,如下:
    c(test, from_core).

  2. erlang彙編碼
    這 個是erlang程式碼編譯成beam前的彙編程式碼,雖然在erlang打包成beam,以及載入到VM時會進一步優化,但彙編碼實際上可以看成 erlang程式碼到c程式碼的紐帶。但理解彙編碼而不是很容易,這裡要知道erlang VM的設計基於暫存器,其中有兩類重要的暫存器,傳遞引數的x暫存器,和在函式內用作本地變數的y暫存器。在erlang shell通過以下方式可以獲取模組的彙編程式碼,將會生成test.S檔案
    c(test, to_asm). 或是 c(test, ‘S‘).
    當然,S檔案也支援編譯成beam檔案,如下:
    c(test, from_asm).

  3. erlang BEAM
    beam檔案是不可閱讀的,只是給VM識別,內容包括了程式碼,原子,匯入匯出函式,屬性,編譯資訊等資料塊。

  4. erlang執行時程式碼
    執行時程式碼是指模組載入到VM後的程式碼,erlang對開發者暴露了底層的介面。當模組載入後,在erlang shell下通過以下方式可以獲取模組的執行時程式碼,就會生成test.dis檔案
    erts_debug:df(test).

這裡,細心的同學會發現,通過對比erlang彙編碼和執行時程式碼,發現指令程式碼是不完全相同的。一方面,erlang會對指令進一步做優化;另 外,erlang使用了兩種指令集,有限指令集和擴充套件指令集,在beam檔案使用了有限指令集,然後在載入到VM時展開為擴充套件指令集。有論文說是為了減少 Beam的大小,這點我沒有做過實質性的探究,我只是覺得有限指令集比較短,更容易閱讀被人理解。關於有限指令集和擴充套件指令集的差別,我在文章最後的拓展閱讀做了討論。

erlang程式碼從編譯到執行過程

前面介紹了erlang程式碼編譯的過程,現在再來說明erlang程式碼從編譯到執行的完整過程。文章erlang版本以R16B02作說明。

這裡,erlang程式碼先被編譯成beam,然後載入到VM中,最後再被模擬器所識別和呼叫。
其中,beam檔案的載入過程會將beam的位元組碼形式的資料轉成Threaded code和資料。前面也提到,beam檔案的位元組碼資料包含有程式碼塊,這裡是將指令展開,轉成Threaded code(線索化程式碼),每條指令包含了opcode(操作碼)和operands(運算元),另外還對operands做修正,比如呼叫外部函式,這裡會找到這個外部函式的匯出地址,這樣每次程式碼執行的時候就不用再去函式表查詢到這個函式,就可以直接執行程式碼。

Beam 的載入邏輯是在 beam_load.c 完成的,指令集的轉換在beam_opcodes.c做了對映,而beam_opcodes.c檔案是在編譯Erlang原始碼過程有Perl指令碼 beam_makeops根據ops.tab生成的。所有有限指令集可以在genop.tab找到。

File 	Path
beam_makeops	erts/emulator/utils/
ops.tab		erts/emulator/beam/
beam_opcodes.c	erts/emulator/<machine>/opt/smp/
beam_load.c	erts/emulator/beam/
genop.tab	lib/compiler/src/

erlang 虛擬機器執行程式碼的原理
這裡先簡單說明下erlang虛擬機器、程序、堆疊,暫存器,然後側重從指令排程,程式碼線索化說明虛擬機器程式碼執行原理。
erlang虛擬機器概述
通常我們說的eralng虛擬機器,是指BEAM虛擬機器模擬器和erlang執行時系統(ERTS),BEAM模擬器是執行Erlang程式經編譯後產出的位元組碼的地方。
erlang虛擬機器最早的版本是Joe Armstrong編寫的,基於棧,叫JAM(Joe‘s Abstract Machine),很類似WAM(Warren‘s Abstract Machine)。後來改成基於暫存器的虛擬機器,也就是現在的BEAM(Bogdan‘s Abstract Machine),執行效率有了較大幅度提升,這在Joe的erlang VM演變論文有說到。

基於棧和基於暫存器的虛擬機器有什麼區別?

基於棧(stack-based)的虛擬機器的指令長度是固定的,執行多個運算元計算時,會先將運算元做壓入棧,由運算指令取出並計算。而基於暫存器(register-based)的指令長度不是固定的,可以在指令中帶多個運算元。這樣,基於暫存器可以減少指令數量,減少入棧出棧操作,從而減少了指令派發的次數和記憶體訪問的次數,相比開銷少了很多。但是,如果利用暫存器做資料交換,就要經常儲存和恢復暫存器的結果,這就導致基於暫存器的虛擬機器在實現上要比基於棧的複雜,程式碼編譯也要複雜得多

erlang程序
erlang程序是在程式碼執行過程中動態建立和銷燬,每個程序都有自己私有的棧和堆。erlang程序是erlang虛擬機器進行資源分配和排程的基本單位,erlang程式碼的執行要通過erlang程序來實現。
1> spawn(fun() -> m:loop() end).
<0.34.0>
或許有人會問,啟動erlang節點時沒有使用任何程序,這是為什麼?實際上,啟動erlang節點的程式碼是執行在shell程序,同樣受到erlang虛擬機器排程,我們看到的是由shell程序執行後返回的結果。
為 了實現多程序併發,erlang虛擬機器實現了程序掛起和排程機制。程序執行程式碼時會消耗排程次數(Reductions),當排程次數為0時就會掛起這個 程序,然後從排程佇列中取出第一個程序執行。如果程序在等待新訊息時也會被掛起,直到這個程序接收到新訊息後,就重新加到排程佇列。

程序的棧和堆
erlang程序在執行程式碼的過程中,棧主要用來存放呼叫幀的本地變數和返回地址,堆則是用來存放執行過程建立的資料。在實現上,棧和堆是在同一個記憶體區域的。如下圖:

堆 棧的記憶體空間是先申請一塊較大的記憶體後一點一點使用,不夠再重新申請一大塊,這樣避免頻繁申請釋放記憶體造成開銷。以上,在已分配好的記憶體區域內,堆從最低 的地址向上增長,而棧從最高的地址向下增長。中間堆頂和棧頂的空白區域,表示了程序堆疊還未使用到的空間,使用記憶體時就向裡收縮,不夠時就執行gc。這 樣,記憶體溢位檢查就只要比較棧頂和堆頂就好。
堆用於儲存複雜的資料結構,如元組,列表或大整數。棧被用來儲存簡單的資料,還有指向堆中複雜資料的資料指標。棧有指標指向堆,但不會有指標從堆到棧。

暫存器
前 面也提到,對於基於棧的虛擬機器,運算元在使用前都會被壓到棧,計算時取出。也就是先將本地變數的值壓入棧,然後在計算時從棧取出賦值給本地變數。所以,這 裡有很大開銷在本地變數和棧之間的交換上(出入棧)。為此,基於暫存器的虛擬機器使用臨時變數來儲存這個本地變數,這個臨時變數也就是暫存器。而且,這個寄 存器變數通常都被優化成CPU的暫存器變數,這樣,虛擬機器訪問暫存器變數甚至都不用訪問記憶體,極大的提高了系統的執行速度。
/* * X register zero; also called r(0) */ register Eterm x0 REG_x0 = NIL;
register修飾符的作用是暗示編譯器,某個變數將被頻繁使 用,儘可能將其儲存在CPU的暫存器中,以加快其儲存速度。隨著編譯程式設計技術的進步,在決定那些變數應該被存到暫存器中時,現在的編譯器能比程式設計師做 出更好的決定,往往會忽略register修飾符。但是就erlang虛擬機器對暫存器變數的使用程度,應該是可以利用到CPU暫存器的好處。

erlang有哪些暫存器?
引數暫存器(R0-R1024) R0是最快的,是獨立的暫存器變數,其他以reg[N]訪問。R0還用來儲存函式返回值
指令暫存器(IP) 引用當前正在執行的指令,可以通過I[N]取到上下文指令。
返回地址暫存器 (CP,原意Continuation Pointer) 記錄當前函式呼叫的返回地址,在執行完當前函式後返回上一個函式中斷處執行後面的程式碼。
棧暫存器(EP) 指向棧的棧頂,以E[N]陣列形式訪問棧幀資料
堆暫存器 (heap top)指向堆的堆頂,以HTOP[N]陣列形式訪問堆資料
臨時暫存器(tmp_arg1和tmp_arg2)用於指令實現需要臨時變數的場合(儘可能重用臨時變數,同時利用CPU暫存器優化)
浮點暫存器(FR0-FR15)

其他暫存器:
‘Live‘ 表示當前需要的暫存器數量,很多指令取這個值來判斷是否要執行GC申請新的空間
‘FCALLS‘ 表示當前程序剩餘的排程次數(Reductions)

若不考慮多排程器,暫存器是所有程序共享的。當虛擬機器排程執行某個程序的時候,暫存器就歸這個程序使用。當程序被調出的時候,暫存器就給其他程序使用。(程序切換儲存程序上下文時,只需要儲存指令暫存器IP和當前函式資訊,效率很高)

指令排程
erlang 指令排程實現是一個巨大的switch結構,每一個case語句都對應一個指令操作碼(opcode),這樣就可以實現指令的分發和執行。但 是,switch排程方式實現簡單,但效率比較低下。所以,erlang虛擬機器使用了goto語法,避免過多的使用switch造成效能損耗;同 時,erlang還使用跳轉表,在一些高階編譯器下(如GCC),利用label-goto語法,效率比較高(針對跳轉表的概念,我之前也有文章說明,見這裡)。正因為這點,虛擬機器排程時解釋指令的代價不容忽視,基於暫存器的虛擬機器指令少,就要比基於棧高效。
while(1){ opcode = *vPC++; switch(opcode){ case i_call_fun: .. break; case call_bif_e: .. break; //and many more.. } };
位元組碼在虛擬機器中執行,執行過程類似CPU執行指令過程,分為取指,解碼,執行3個過程。通常情況下,每個操作碼對應一段處理函式,然後通過一個無限迴圈加一個switch的方式進行分派。

erlang程序建立時必須指定執行函式,程序建立後就會執行這個函式。從這個函式開始一直到結束,程序都會被erlang虛擬機器排程。
start()-> spawn(fun() -> fun1(1) end). %% 建立程序,執行 fun1/1 fun1(A) -> A1 = A + 1, B = trunc(A1), %% 執行 trunc/1 {ok, A1+B}.
以上,程序在執行函式( trunc/1)呼叫前,會將當前的本地變數和返回地址指標CP寫入棧。然後,在執行完這個函式(trunc/1)後再從棧取出CP指令和本地變數,根據CP指標返回呼叫處,繼續執行後面的程式碼。

這樣,每次函式執行結束時,erlang從棧頂檢查並取得CP指標(如果函式內過於簡單,沒有其他函式呼叫,就直接讀取 (Process* c_p)->cp),然後將CP指標的值賦給指令暫存器IP,同時刪除CP棧幀(根據需要還要回收Live借用的棧空間),繼續排程執行。
備註:這裡講到的棧幀刪除操作,如CP指標,本地變數資料,刪除時只要將棧頂指標向高位移動N個位置,沒有GC操作,代價極小。另外,這裡也顯露出一個問題,如果非尾遞迴函式呼叫,erlang需要反覆將本地變數和CP指標入棧,容易觸發GC和記憶體複製,引發記憶體抖動。

另外,在暫存器方面,函式呼叫時,erlang虛擬機器會將傳參寫到引數暫存器x(N),然後更新返回地址暫存器CP,在函式呼叫返回時,會將返回值寫到x(0)暫存器。

Threaded Code(線索化程式碼)
前 面提到switch指令派發方式,每次處理完一條指令後,都要回到迴圈的開始,處理下一條指令。但是,每次switch操作,都可能是一次線性搜尋(現代 編譯器能對switch語句進行優化, 以消除這種線性搜尋開銷,但也是隻限於特定條件,如case的數量和值的跨度範圍等)。如果是少量的switch case,完全可以接受,但是對於虛擬機器來說,有著成百上千的switch case,而且執行頻繁非常高,執行一條指令就需要一次線性搜尋,確定比較耗效能。如果能直接跳轉到執行程式碼位置,就可以省去線性搜尋的過程了。於是在字 節碼的分派方式上,做了新的改進,這項技術叫作 Context Threading(上下文線索化技術,Thread目前都沒有合適的中文翻譯,我這裡意譯為線索化,表示其中的線索關係)。

這裡取了Context Threading論文的例子,說明上下文線索化技術(Context Threading)
1.首先,程式碼會被編譯成位元組碼

2.如果是switch派發指令,效率低下

3.如果是線索化程式碼(Threaded Code),就直接跳轉(goto),無需多次switch

4.從位元組碼到最終執行程式碼的過程。

左 邊是編譯生成的位元組碼,中間就是位元組碼載入後生成的線索化程式碼,右邊是對應的虛擬機器實現程式碼。虛擬機器執行時,vpc指向了iload_1指令,在執行 iload_1指令操作後根據goto *vpc++ 跳轉到下一條指令地址,繼續執行,如此反覆。這個過程就好像穿針引線,每執行完一條指令,就直接跳轉到下一條指令的地址,而不再是Switch Loop那樣,每執行一條指令都要做一次switch。(這裡,vPC是指虛擬PC指令,在erlang中是IP指標)

拓展閱讀
BIF(內建函式)
BIF是erlang的內建函式,由C程式碼實現,用以實現在erlang層面實現效率不高或無法實現的功能。大多數BIF函式屬於erlang模組,也有其他模組的BIF函式,ets或lists,os等
1> erlang:now().
{1433,217791,771000}
2> lists:member(1,[1,2,3]).
true

這裡重點解釋下,BIF程式碼如何被執行的。
erlang原始碼編譯時生成bif函式表資訊,見 erts\emulator<machine>\erl_bif_table.c
Export* bif_export[BIF_SIZE]; BifEntry bif_table[] = { {am_erlang, am_abs, 1, abs_1, abs_1}, {am_erlang, am_adler32, 1, adler32_1, wrap_adler32_1}, {am_erlang, am_adler32, 2, adler32_2, wrap_adler32_2}, {am_erlang, am_adler32_combine, 3, adler32_combine_3, wrap_adler32_combine_3}, {am_erlang, am_apply, 3, apply_3, wrap_apply_3}, {am_erlang, am_atom_to_list, 1, atom_to_list_1, wrap_atom_to_list_1},
typedef struct bif_entry { Eterm module; Eterm name; int arity; BifFunction f; // bif函式 BifFunction traced; // 函式呼叫跟蹤函式 } BifEntry;
erlang BEAM模擬器啟動時會初始化bif函式表,
init_emulator: { em_call_error_handler = OpCode(call_error_handler); em_apply_bif = OpCode(apply_bif); beam_apply[0] = (BeamInstr) OpCode(i_apply); beam_apply[1] = (BeamInstr) OpCode(normal_exit); beam_exit[0] = (BeamInstr) OpCode(error_action_code); beam_continue_exit[0] = (BeamInstr) OpCode(continue_exit); beam_return_to_trace[0] = (BeamInstr) OpCode(i_return_to_trace); beam_return_trace[0] = (BeamInstr) OpCode(return_trace); beam_exception_trace[0] = (BeamInstr) OpCode(return_trace); /* UGLY/ beam_return_time_trace[0] = (BeamInstr) OpCode(i_return_time_trace); /* Enter all BIFs into the export table./ for (i = 0; i < BIF_SIZE; i++) { ep = erts_export_put(bif_table[i].module, //模組名 bif_table[i].name, bif_table[i].arity); bif_export[i] = ep; ep->code[3] = (BeamInstr) OpCode(apply_bif); ep->code[4] = (BeamInstr) bif_table[i].f; // BIF函式 /XXX: set func info for bifs */ ep->fake_op_func_info_for_hipe[0] = (BeamInstr) BeamOp(op_i_func_info_IaaI); }

下面寫個簡單的例子說明,

bif函式編譯後,opcode都是 call_bif_e,運算元是函式匯出表地址,下面分析下這個opcode的實現:
/* * 以下擷取 bif 處理過程/ OpCase(call_bif_e): { Eterm (bf)(Process, Eterm, BeamInstr*) = GET_BIF_ADDRESS(Arg(0)); // 根據引數獲取bif實際執行函式 Eterm result; BeamInstr *next; PRE_BIF_SWAPOUT(c_p); c_p->fcalls = FCALLS - 1; if (FCALLS <= 0) { save_calls(c_p, (Export *) Arg(0)); } PreFetch(1, next); ASSERT(!ERTS_PROC_IS_EXITING(c_p)); reg[0] = r(0); result = (*bf)(c_p, reg, I); // 執行bif函式 ASSERT(!ERTS_PROC_IS_EXITING(c_p) || is_non_value(result)); ERTS_VERIFY_UNUSED_TEMP_ALLOC(c_p); ERTS_HOLE_CHECK(c_p); ERTS_SMP_REQ_PROC_MAIN_LOCK(c_p); PROCESS_MAIN_CHK_LOCKS(c_p); if (c_p->mbuf || MSO(c_p).overhead >= BIN_VHEAP_SZ(c_p)) { Uint arity = ((Export *)Arg(0))->code[2]; result = erts_gc_after_bif_call(c_p, result, reg, arity); E = c_p->stop; } HTOP = HEAP_TOP(c_p); FCALLS = c_p->fcalls; if (is_value(result)) { r(0) = result; CHECK_TERM(r(0)); NextPF(1, next); } else if (c_p->freason == TRAP) { SET_CP(c_p, I+2); SET_I(c_p->i); SWAPIN; r(0) = reg[0]; Dispatch(); }
上面涉及到一個巨集,就是取得bif函式地址。
#define GET_BIF_ADDRESS(p) ((BifFunction) (((Export *) p)->code[4]))
根據前面提到的,((Export *) p)->code[4] 就是 bif_table表的中BIF函式的地址。

擴充套件指令集
BEAM檔案使用的是有限指令集(limited instruction set),這些指令集會在beam檔案被載入時,展開為擴充套件指令集(extended instruction set)。
get_list -> get_list_rrx
get_list ->get_list_rry
call_bif -> call_bif_e

擴充套件指令集和有限指令集的差別是,擴充套件指令集還描述了運算元型別。

Type	Description
t	An arbitrary term, e.g. {ok,[]}
I	An integer literal, e.g. 137
x	A register, r.g. R1
y	A stack slot
c	An immediate term, i.e. atom/small int/nil
a	An atom, e.g. ‘ok‘
f	A code label
s	Either a literal, a register or a stack slot
d	Either a register or a stack slot
r	A register R0
P	A unsigned integer literal
j	An optional code label
e	A reference to an export table entry
l	A floating-point register

以 call_bif_e 為例, e表示了運算元為函式匯出表地址,所以 call_bif_e 可以這樣取到bif程式碼地址
((Export *) Arg(0))->code[4]

文獻資料:
[1] The Erlang BEAM Virtual Machine Specification Bogumil Hausman
[2] Virtual Machine Showdown: Stack Versus Registers Yunhe Shi, David Gregg, Andrew Beatty
[3] Context Threading: A flexible and efficient dispatch technique for virtual machine interpreters
[4] A Peek Inside the Erlang Compiler James Hague

轉自:

參考http://blog.csdn.net/mycwq/article/details/45653897