1. 程式人生 > >erlang虛擬機代碼運行原理

erlang虛擬機代碼運行原理

包括 增長 4.0 integer ren ret des now() pat

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代碼的紐帶。但理解匯編碼而不是非常easy,這裏要知道erlang VM的設計基於寄存器。當中有兩類重要的寄存器,傳遞參數的x寄存器。和在函數內用作本地變量的y寄存器。在erlang shell通過下面方式能夠獲取模塊的匯編代碼,將會生成test.S文件c(test, to_asm). 或是 c(test, ‘S‘).當然,S文件也支持編譯成beam文件,例如以下:c(test, from_asm).

3. erlang BEAMbeam文件是不可閱讀的,僅僅是給VM識別,內容包括了代碼。原子,導入導出函數,屬性,編譯信息等數據塊。
4. erlang運行時代碼運行時代碼是指模塊載入到VM後的代碼,erlang對開發人員暴露了底層的接口。

當模塊載入後,在erlang shell下通過下面方式能夠獲取模塊的運行時代碼。就會生成test.dis文件erts_debug:df(test).
這裏。細心的同學會發現,通過對照erlang匯編碼和運行時代碼,發現指令代碼是不全然相同的。一方面。erlang會對指令進一步做優化。另外。erlang使用了兩種指令集,有限指令集和擴展指令集,在beam文件使用了有限指令集。然後在載入到VM時展開為擴展指令集。

有論文說是為了降低Beam的大小,這點我沒有做過實質性的探究,我僅僅是認為有限指令集比較短,更easy閱讀被人理解。關於有限指令集和擴展指令集的區別。我在文章最後的拓展閱讀做了討論。

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_makeopserts/emulator/utils/
ops.taberts/emulator/beam/
beam_opcodes.certs/emulator/<machine>/opt/smp/
beam_load.certs/emulator/beam/
genop.tablib/compiler/src/


erlang 虛擬機運行代碼的原理

這裏先簡單說明下erlang虛擬機、進程、堆棧。寄存器,然後側重從指令調度。代碼線索化說明虛擬機代碼運行原理。

erlang虛擬機概述

通常我們說的eralng虛擬機。是指BEAM虛擬機模擬器和erlang運行時系統(ERTS)。

ERTS是erlang VM最底層的應用,負責和操作系統交互,管理I/O,實現erlang進程和BIF函數。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指針入棧。easy觸發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_rrxget_list ->get_list_rrycall_bif -> call_bif_e
擴展指令集和有限指令集的區別是,擴展指令集還描寫敘述了操作數類型。
TypeDescription
tAn arbitrary term, e.g. {ok,[]}
IAn integer literal, e.g. 137
xA register, e.g. R1
yA stack slot
cAn immediate term, i.e. atom/small int/nil
aAn atom, e.g. ‘ok‘
fA code label
sEither a literal, a register or a stack slot
dEither a register or a stack slot
rA register R0
PA unsigned integer literal
jAn optional code label
eA reference to an export table entry
lA 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

erlang虛擬機代碼運行原理