erlang虛擬機代碼運行原理

erlang是開源的,很是多人都研究過源碼。但是。從erlang代碼到c代碼。這是個不小的跨度。而且代碼也比較複雜。

因此這裏,我利用一些時間,整理下erlang代碼的運行過程。從erlang代碼編譯過程,到代碼運行過程作解說。而後重點講下虛擬機運行代碼的原理。將本篇文章。獻給所有喜歡erlang的人。html


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 BEAM
beam文件是不可閱讀的,僅僅是給VM識別,內容包括了代碼。原子,導入導出函數,屬性,編譯信息等數據塊。

4.  erlang運行時代碼
運行時代碼是指模塊載入到VM後的代碼,erlang對開發人員暴露了底層的接口。

當模塊載入後,在erlang shell下經過下面方式可以獲取模塊的運行時代碼。就會生成test.dis文件c++

erts_debug:df(test).

這裏。細心的同窗會發現,經過對照erlang彙編碼和運行時代碼,發現指令代碼是不全然相同的。一方面。erlang會對指令進一步作優化。另外。erlang使用了兩種指令集,有限指令集和擴展指令集,在beam文件使用了有限指令集。而後在載入到VM時展開爲擴展指令集。

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


erlang代碼從編譯到運行過程

前面介紹了erlang代碼編譯的過程。現在再來講明erlang代碼從編譯到運行的完整過程。

文章erlang版本號以R16B02做說明。shell


這裏。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)。

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)的指令長度不是固定的,可以在指令中帶多個操做數。這樣,基於寄存器可以下降指令數量,下降入棧出棧操做,從而下降了指令派發的次數和內存訪問的次數,相比開銷少了很是多。併發

但是,假設利用寄存器作數據交換,就要經常保存和恢復寄存器的結果。這就致使基於寄存器的虛擬機在實現上要比基於棧的複雜,代碼編譯也要複雜得多app


erlang進程

erlang進程是在代碼運行過程當中動態建立和銷燬。每個進程都有本身私有的棧和堆。erlang進程是erlang虛擬機進行資源分配和調度的基本單位。erlang代碼的運行要經過erlang進程來實現。
1> spawn(fun() -> m:loop() end).
<0.34.0>
也許有人會問,啓動erlang節點時沒有使用不論什麼進程。這是爲何? 實際上。啓動erlang節點的代碼是運行在shell進程。相同受到erlang虛擬機調度。咱們看到的是由shell進程運行後返回的結果。

爲了實現多進程併發。erlang虛擬機實現了進程掛起和調度機制。進程運行代碼時會消耗調度次數(Reductions),當調度次數爲0時就會掛起這個進程,而後從調度隊列中取出第一個進程運行。

假設進程在等待新消息時也會被掛起,直到這個進程接收到新消息後。就又一次加到調度隊列。ide



進程的棧和堆

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_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, e.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]

文獻資料:
[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

參考:
http://blog.csdn.net/mycwq/article/details/45653897
相關文章
相關標籤/搜索