檢視原始碼 beam_makeops 腳本

此文件描述 beam_makeops 腳本。

簡介

beam_makeops Perl 腳本在編譯時和運行時系統都會使用。它會讀取一些輸入檔案(所有檔案的副檔名都是 .tab),並產生 Erlang 編譯器和運行時系統用來載入和執行 BEAM 指令的原始碼檔案。

基本上,這些 .tab 檔案定義了:

  • 外部通用 BEAM 指令。這些指令是編譯器和運行時系統都知道的。通用指令在各版本之間是穩定的。新的通用指令可以在主要版本中加入,其編號會高於先前的指令。OTP 20 版本有 159 個外部通用指令。

  • 內部通用指令。這些指令只有運行時系統知道,並且可以隨時變更,不會有相容性問題。它們是由轉換規則(稍後描述)產生的。

  • 將一個或多個通用指令轉換為其他通用指令的規則。轉換規則允許組合、拆分和移除指令,以及重新排列運算元。由於轉換規則的存在,運行時可以有許多只有運行時系統知道的內部通用指令。

  • 特定 BEAM 指令。特定指令是運行時系統實際執行的指令。它們可以隨時變更,不會有相容性問題。載入器會將通用指令轉換為特定指令。一般來說,對於每個通用指令,都存在一組特定指令。OTP 20 版本有 389 個特定指令。

  • 傳統 BEAM 直譯器的特定指令實作。對於 OTP 24 中引入的 BeamAsm JIT,指令的實作是在 C++ 中編寫的 emitter 函式中定義的。

通用指令具有類型化的運算元。以下是 move/2 的一些運算元範例:

{move,{atom,id},{x,5}}.
{move,{x,3},{x,0}}.
{move,{x,2},{y,1}}.

當這些指令被載入時,載入器會將它們重寫為特定指令

move_cx id 5
move_xx 3 0
move_xy 2 1

每個通用指令都對應一組特定的指令。特定指令實例可以處理的類型編碼在指令名稱中。例如,move_xy 的第一個運算元是一個 X 暫存器編號,第二個運算元是一個 Y 暫存器編號。move_cx 的第一個運算元是一個帶標籤的 Erlang 項,第二個運算元是一個 X 暫存器編號。

範例:move 指令

我們將以 move 指令為例,快速瀏覽一下 beam_makeops 的主要功能。

compiler 應用程式中,在 genop.tab 檔案中,有以下這行:

64: move/2

這是外部通用 BEAM 指令的定義。最重要的是,它指定了運算碼為 64。它還定義了它有兩個運算元。BEAM 組譯器在建立 .beam 檔案時會使用此運算碼。編譯器實際上不需要元數,但它會在組譯 BEAM 程式碼時使用它作為內部健全性檢查。

讓我們看看 erts/emulator/beam/emu 中的 ops.tab,其中定義了特定的 move 指令。以下是其中一些:

move x x
move x y
move c x

每個特定指令的定義方式是在指令名稱後跟著每個運算元的類型。運算元類型是單個字母。例如,x 代表 X 暫存器,y 代表 Y 暫存器,而 c 代表「常數」(帶標籤的項,例如整數、原子或字面值)。

現在讓我們看看 move 指令的實作。在 erts/emulator/beam/emu 目錄中有許多檔案包含指令的實作。move 指令定義在 instrs.tab 中。它看起來像這樣:

move(Src, Dst) {
    $Dst = $Src;
}

指令的實作基本上遵循 C 語法,除了函式頭中的變數沒有任何類型。$ 在識別符號之前表示巨集展開。因此,$Src 會展開為取得指令來源運算元的程式碼,而 $Dst 則會展開為取得目標暫存器的程式碼。

我們將依次檢視每個特定指令的程式碼。為了使程式碼更容易理解,讓我們先看看指令 {move,{atom,id},{x,5}} 的記憶體佈局:

     +--------------------+--------------------+
I -> |                 40 |       &&lb_move_cx |
     +--------------------+--------------------+
     |                        Tagged atom 'id' |
     +--------------------+--------------------+

此範例以及文件中所有其他範例都假設為 64 位元架構,並且假設指向 C 程式碼的指標適合 32 位元。

BEAM 虛擬機器中的 I 是指令指標。當 BEAM 執行指令時,I 指向指令的第一個字。

&&lb_move_cx 是實作 move_cx 的 C 程式碼位址。它儲存在字的低 32 位元中。在高 32 位元中,是指向 X 暫存器的位元組偏移量;暫存器編號 5 已乘以字大小 8。

下一個字中儲存了帶標籤的原子 id

有了這些背景知識,我們可以看看在 beam_hot.h 中為 move_cx 產生的程式碼:

OpCase(move_cx):
{
  BeamInstr next_pf = BeamCodeAddr(I[2]);
  xb(BeamExtraData(I[0])) = I[1];
  I += 2;
  ASSERT(VALID_INSTR(next_pf));
  GotoPF(next_pf);
}

我們將逐行檢視。

  • OpCase(move_cx): 定義了指令的標籤。OpCase() 巨集在 beam_emu.c 中定義。它會將此行展開為 lb_move_cx:

  • BeamInstr next_pf = BeamCodeAddr(I[2]); 擷取要執行的下一個指令的程式碼指標。BeamCodeAddr() 巨集會從指令字的低 32 位元中提取指標。

  • xb(BeamExtraData(I[0])) = I[1];$Dst = $Src 的展開。BeamExtraData() 是一個巨集,它會從指令字中提取高 32 位元。在此範例中,它會傳回 40,這是 X 暫存器 5 的位元組偏移量。xb() 巨集會將位元組指標轉換為 Eterm 指標並取值。= 右側的 I[1] 擷取一個 Erlang 項(在此範例中是原子 id)。

  • I += 2 將指令指標推進到下一個指令。

  • 在偵錯編譯的模擬器中,ASSERT(VALID_INSTR(next_pf)); 會確保 next_pf 是一個有效的指令(也就是說,它指向 beam_emu.c 中的 process_main() 函式內)。

  • GotoPF(next_pf); 將控制權轉移到下一個指令。

現在讓我們看看 move_xx 的實作:

OpCase(move_xx):
{
  Eterm tmp_packed1 = BeamExtraData(I[0]);
  BeamInstr next_pf = BeamCodeAddr(I[1]);
  xb((tmp_packed1>>BEAM_TIGHT_SHIFT)) = xb(tmp_packed1&BEAM_TIGHT_MASK);
  I += 1;
  ASSERT(VALID_INSTR(next_pf));
  GotoPF(next_pf);
}

我們將檢視與 move_cx 相比是新增或變更的行。

  • Eterm tmp_packed1 = BeamExtraData(I[0]); 擷取封裝在指令字高 32 位元中的兩個 X 暫存器編號。

  • BeamInstr next_pf = BeamCodeAddr(I[1]); 預先擷取下一個指令的位址。請注意,由於兩個 X 暫存器運算元都適合放入指令字中,因此下一個指令就在緊接的下一個字中。

  • xb((tmp_packed1>>BEAM_TIGHT_SHIFT)) = xb(tmp_packed1&BEAM_TIGHT_MASK); 將來源複製到目標。(對於 64 位元架構,BEAM_TIGHT_SHIFT 是 16,而 BEAM_TIGHT_MASK0xFFFF。)

  • I += 1; 將指令指標推進到下一個指令。

move_xymove_xx 幾乎相同。唯一的區別是使用 yb() 巨集而非 xb() 來引用目標暫存器

OpCase(move_xy):
{
  Eterm tmp_packed1 = BeamExtraData(I[0]);
  BeamInstr next_pf = BeamCodeAddr(I[1]);
  yb((tmp_packed1>>BEAM_TIGHT_SHIFT)) = xb(tmp_packed1&BEAM_TIGHT_MASK);
  I += 1;
  ASSERT(VALID_INSTR(next_pf));
  GotoPF(next_pf);
}

轉換規則

接下來,讓我們看看如何使用轉換規則進行一些最佳化。對於像 move/2 這樣的簡單指令,指令派送的負擔可能很大。一個簡單的最佳化方法是將常見的指令序列組合成單一指令。其中一個常見的序列是多個將 X 暫存器移動到 Y 暫存器的 move 指令。

使用以下規則,我們可以將兩個 move 指令組合成一個 move2 指令:

move X1=x Y1=y | move X2=x Y2=y => move2 X1 Y1 X2 Y2

箭頭 (=>) 左側是一個模式。如果模式匹配,則匹配的指令將被替換為右側的指令。模式中的變數必須以大寫字母開頭,就像在 Erlang 中一樣。模式變數後面可以跟著 = 和一個或多個類型字母,以限制匹配為這些類型之一。在左側綁定的變數可以在右側使用。

我們還需要定義一個特定的指令和一個實作:

# In ops.tab
move2 x y x y

// In instrs.tab
move2(S1, D1, S2, D2) {
    Eterm V1, V2;
    V1 = $S1;
    V2 = $S2;
    $D1 = V1;
    $D2 = V2;
}

當載入器找到匹配並替換了匹配的指令時,它會將新的指令與轉換規則進行匹配。因此,我們可以將 move3/6 指令的規則定義如下:

move2 X1=x Y1=y X2=x Y2=y | move X3=x Y3=y =>
      move3 X1 Y1 X2 Y2 X3 Y3

(為了提高可讀性,較長的轉換行可以在 |=> 運算子之後中斷。)

也可以這樣定義:

move X1=x Y1=y | move X2=x Y2=y | move X3=x Y3=y =>
     move3 X1 Y1 X2 Y2 X3 Y3

但在這種情況下,它必須在 move2/4 的規則之前定義,因為會應用第一個匹配的規則。

必須小心不要建立無限迴圈。例如,如果我們因為某些原因想要反轉 move 指令的運算元順序,我們絕不能這樣做:

move Src Dst => move Dst Src

載入器會永遠交換運算元。為了避免迴圈,我們必須重新命名指令。例如:

move Src Dst => assign Dst Src

這總結了 beam_makeops 功能的快速導覽。

直譯器指令載入的簡短概述

為了讓您對本文檔的其餘部分有所了解,以下簡要概述指令的載入方式。

  • 載入器會從 BEAM 程式碼中一次讀取並解碼一個指令,並建立一個泛型指令。許多轉換規則必須查看多個指令,因此載入器會將多個泛型指令保留在一個連結串列中。

  • 載入器會嘗試對連結串列中的泛型指令套用轉換規則。如果規則符合,則會移除符合的指令,並以從轉換的右側建構的新泛型指令取代。

  • 如果轉換規則符合,則載入器會再次套用轉換規則。

  • 如果沒有轉換規則符合,則載入器會開始將第一個泛型指令重寫為特定指令。

  • 首先,載入器會搜尋所有運算元的類型與泛型指令的類型相符的特定運算。將會選取第一個符合的指令。beam_makeops 已對特定指令進行排序,以便具有更特定運算元的指令排在具有較少特定運算元的指令之前。例如,move_nxmove_cx 更為特定。如果第一個運算元是 [] (NIL),則將會選取 move_nx

  • 給定所選取特定指令的運算碼,載入器會查詢該指令的 C 程式碼指標,並將其儲存在正在載入的模組的程式碼區域中。

  • 載入器會將每個運算元轉換為機器字組,並將其儲存在程式碼區域中。所選取特定指令的運算元類型會引導轉換。例如,如果類型是 e,則運算元的值是外部函式陣列的索引,並將轉換為要呼叫的函式的匯出項目的指標。如果類型是 x,則 X 暫存器的數字將乘以字組大小,以產生位元組偏移。

  • 載入器會執行封裝引擎,將多個運算元封裝到單個字組中。封裝引擎由一個小型程式控制,該程式是一個字串,其中每個字元都是一個指令。例如,封裝 move_xy 的運算元的程式碼是 "22#" (在 64 位元機器上)。該程式會將兩個暫存器的位元組偏移與 C 程式碼的指標一起封裝到同一個字組中。

BeamAsm 指令載入簡短概述

  • 在選取特定指令之前的初始步驟與直譯器所描述的相同。特定指令的選取較為簡單,因為在 BeamAsm 中,大多數泛型指令只有單個對應的特定指令。

  • 載入器會呼叫所選取特定指令的發射器函式。發射器函式會將指令轉換為機器碼。

執行 beam_makeops

beam_makeops 位於 $ERL_TOP/erts/emulator/utils 中。選項以連字號 (-) 開頭。選項後面是輸入檔的名稱。按照慣例,所有輸入檔都具有 .tab 擴展名,但 beam_makeops 不會強制執行此操作。

-outdir 選項

選項 -outdir Directory 指定產生檔案的輸出目錄。預設為目前的工作目錄。

為編譯器執行 beam_makeops

提供選項 -compiler 以產生編譯器的輸出檔。下列檔案將會寫入輸出目錄

  • beam_opcodes.erl - 主要由 beam_asmbeam_diasm 使用。

  • beam_opcode.hrl - 由 beam_asm 使用。它包含用於編碼指令運算元的標籤定義。

輸入檔應僅包含 BEAM_FORMAT_NUMBER 和外部泛型指令的定義。(其他所有內容都會被忽略。)

為模擬器執行 beam_makeops

提供選項 -emulator 以產生模擬器的輸出檔。下列輸出檔將會在輸出目錄中產生。

  • beam_opcodes.c - 定義載入器 (beam_load.c) 使用的靜態資料,提供有關泛型和特定指令的資訊,以及所有轉換規則的 C 程式碼。

  • beam_opcodes.h - 各種前置處理器定義,主要由 beam_load.c 使用,但也由 beam_{hot,warm,cold}.h 使用。

對於傳統的 BEAM 直譯器,也會產生下列檔案

  • beam_hot.hbeam_warm.hbeam_cold.h - 指令的實作。包含在 beam_emu.c 中的 process_main() 函式內。

對於 BeamAsm,也會產生下列檔案

  • beamasm_emit.h - 呼叫發射器函式的膠合程式碼。

  • beamasm_protos.h - 所有發射器函式的原型。

可以提供下列選項

  • wordsize 32|64 - 定義字組大小。預設值為 32。

  • code-model Model - 提供給 GCC 的 -mcmodel 選項的程式碼模型。預設值為 unknown。如果程式碼模型是 small (且字組大小為 64 位元),則 beam_makeops 會將運算元封裝到指令字組的較高 32 位元中。

  • DSymbol=0|1 - 定義符號的值。符號可用於 %if%unless 指示詞中。

.tab 檔案的語法

註解

任何以 # 開頭的行都是註解,並會被忽略。

具有 // 的行也是註解。建議僅在定義指令實作的檔案中使用此樣式的註解。

長的轉換行可以在 => 運算子之後和 | 運算子之後中斷。自 OTP 25 起,這是中斷轉換行的唯一方法。在讀取舊原始碼時,您可能會看到 \ 用於此目的,但我們已移除它,因為它只會與 =>| 一起出現。

變數定義

變數定義會將變數繫結至 Perl 變數。僅當 beam_makeops 同時更新以使用該變數時,才新增新的定義才有意義。變數定義看起來像這樣

name=value[;]

其中,namebeam_makeops 中 Perl 變數的名稱,而 value 是要給予該變數的值。該行可以選擇性地以 ; 結尾 (以避免在 Emacs 中搞亂 C 縮排模式)。

以下是已定義變數的說明。

BEAM_FORMAT_NUMBER

genop.tab 具有下列定義

BEAM_FORMAT_NUMBER=0

它定義指令集的版本 (將會包含在 BEAM 程式碼中的程式碼標頭中)。理論上,可以提升版本,並變更所有指令。實際上,我們會在執行階段系統中至少支援兩個版本的指令集至少兩個版本,因此實際上可能永遠不會發生。

GC_REGEXP

macros.tab 中,有一個 GC_REGEXP 的定義。它將在後面的章節中說明。

FORBIDDEN_TYPES

asm/ops.tab 中,有一個指示詞可禁止特定指令中的某些類型

FORBIDDEN_TYPES=hQ

特別是對於 BeamAsm,所有內建類型可能沒有意義,因此 FORBIDDEN_TYPES 可以強制執行不應使用某些類型。

特定指令將在後面的章節中說明。

指示詞

有一些指示詞可以根據特定指令的使用頻率對其進行分類

  • %hot - 實作將會放置在 beam_hot.h 中。經常執行的指令。

  • %warm - 實作將會放置在 beam_warm.h 中。二進位語法指令。

  • %cold - 實作將會放置在 beam_cold.h 中。追蹤指令和不常用的指令。

預設值為 %hot。這些指示詞將會套用至後續的特定指令宣告。以下範例

%cold
is_number f? xy
%hot

條件編譯指示詞

如果條件為 true,則 %if 指示詞會包含一系列程式碼行。例如

%if ARCH_64
i_bs_get_integer_32 x f? x
%endif

特定指令 i_bs_get_integer_32 將僅在 64 位元機器上定義。

可以使用 %unless 而不是 %if 來反轉條件

%unless NO_FPE_SIGNALS
fcheckerror p => i_fcheckerror
i_fcheckerror
fclearerror
%endif

也可以新增 %else 子句

%if ARCH_64
BS_SAFE_MUL(A, B, Fail, Dst) {
    Uint64 res = ($A) * ($B);
    if (res / $B != $A) {
        $Fail;
    }
    $Dst = res;
}
%else
BS_SAFE_MUL(A, B, Fail, Dst) {
    Uint64 res = (Uint64)($A) * (Uint64)($B);
    if ((res >> (8*sizeof(Uint))) != 0) {
        $Fail;
    }
    $Dst = res;
}
%endif

在指示詞中定義的符號

始終會定義下列符號。

  • ARCH_64 - 對於 64 位元機器,為 1,否則為 0。
  • ARCH_32 - 對於 32 位元機器,為 1,否則為 0。

目前,用於建置模擬器的 Makefile 會透過在 beam_makeops 的命令列上使用 -D 選項來定義下列符號。

  • USE_VM_PROBES - 如果編譯執行階段系統以使用 VM 探測器 (支援 dtrace 或 systemtap),則為 1,否則為 0。

定義外部泛型指令

編譯器和執行階段系統都知道外部泛型 BEAM 指令。它們在版本之間保持穩定。新的主要版本可能會新增更多外部泛型指令,但不得變更先前定義指令的語意。

外部泛型指令的語法如下

opcode: [-]name/arity

opcode 是大於或等於 1 的整數。

name 是以小寫字母開頭的識別碼。arity 是一個表示運算元數量的整數。

name 可以選擇性地以 - 為前綴,以表示它已過時。不允許編譯器產生使用過時指令的 BEAM 檔案,並且載入器會拒絕載入使用過時指令的 BEAM 檔案。

僅在 lib/compiler/src 中的 genop.tab 檔案中定義外部泛型指令才有意義,因為編譯器必須知道它們才能使用它們。

新的指令必須加在檔案的結尾,並使用比之前指令更高的編號。

定義內部泛型指令

內部泛型指令僅為執行時期系統所知,且可隨時變更而不會有相容性問題。

有兩種方法可以定義內部泛型指令

  • 當定義特定指令時隱含地定義。這是目前最常見的方式。每當建立一個特定的指令時,beam_makeops 會自動建立一個內部泛型指令(如果它之前不存在)。

  • 明確地定義。只有當泛型指令用於轉換,但沒有任何對應的特定指令時,才需要這樣做。

內部泛型指令的語法如下

名稱/元數

name 是以小寫字母開頭的識別碼。arity 是一個表示運算元數量的整數。

關於一般的泛型指令

每個泛型指令都有一個運算碼。運算碼是一個整數,大於或等於 1。對於外部泛型指令,它必須在 genop.tab 中明確給定,而內部泛型指令則由 beam_makeops 自動編號。

泛型指令的識別是其名稱與元數的組合。這意味著允許定義兩個具有相同名稱但元數不同的不同泛型指令。例如

move_window/5
move_window/6

泛型指令的每個運算元都帶有其類型標記。一個泛型指令可以有以下其中一種類型

  • x - X 暫存器。

  • y - Y 暫存器。

  • l - 浮點數暫存器編號。

  • i - 帶標籤的字面整數。

  • a - 帶標籤的字面原子。

  • n - NIL([],空列表)。

  • q - 不適合一個字組的字面值,也就是儲存在堆積上的物件,例如列表或元組。支援任何堆積物件類型,即使是沒有實際字面值的類型,例如外部參照。

  • f - 非零失敗標籤。

  • p - 零失敗標籤。

  • u - 適合機器字組的未標記整數。它用於許多不同的目的,例如 test_heap/2 中的活動暫存器數量、作為 call_ext/2 的匯出參照,以及二進制語法指令的旗標運算元。當泛型指令轉換為特定指令時,特定操作中運算元的類型將告訴載入器如何處理該運算元。

  • o - 溢位。如果 u 運算元的值不適合機器字組,則該運算元的類型將更改為 o(沒有相關聯的值)。目前僅在載入器的守護條件函數 binary_too_big() 中內部使用。

  • v - 元數值。僅在載入器內部使用。

定義特定指令

特定指令僅為執行時期系統所知,並且是實際執行的指令。它們可以隨時變更而不會導致相容性問題。

如果特定指令所屬的指令系列有多個成員,則特定指令最多可以有 6 個運算元。如果一個系列中只有一個特定指令,則運算元的數量不受限制。

特定指令的定義首先給出其名稱,然後給出每個運算元的類型。例如

 move x y

在內部,例如在產生的程式碼和 BEAM 反組譯器的輸出中,指令 move x y 將被稱為 move_xy

特定指令的名稱是以小寫字母開頭的識別符號。類型是小寫或大寫字母。

具有給定名稱的所有特定指令必須具有相同數量的運算元。也就是說,以下是允許的

 move x x
 move x y x y

以下是與泛型指令類型或多或少直接對應的類型字母。

  • x - X 暫存器。將作為 X 暫存器陣列基準的 X 暫存器位元組偏移量載入。(可以與其他運算元打包。)

  • y - Y 暫存器。將作為堆疊框架基準的 Y 暫存器位元組偏移量載入。(可以與其他運算元打包。)

  • r - X 暫存器 0。一個隱含的運算元,不會儲存在載入的程式碼中。(在 BeamAsm 中未使用。)

  • l - 浮點數暫存器編號。(可以與其他運算元打包。)

  • a - 帶標籤的原子。

  • n - NIL 或空列表。(不會儲存在載入的程式碼中。)

  • q - 帶標籤的 CONS 或 BOXED 指標。也就是說,像列表或元組這樣的術語。支援任何堆積物件類型,即使是沒有實際字面值的類型,例如外部參照。

  • f - 失敗標籤(非零)。分支或呼叫指令的目標。

  • p - 0 失敗標籤,表示如果指令失敗,則應引發例外狀況。(不會儲存在載入的程式碼中。)

  • c - 任何字面術語;也就是說,像是 SMALL 的立即字面值,以及指向字面值的 CONS 或 BOXED 指標。(可以用於泛型指令中運算元的類型為 ianq 的地方。)

以下類型在執行時期對運算元執行類型測試;因此,就執行時期而言,它們通常比先前描述的類型更昂貴。然而,需要這些運算元類型,以避免特定指令數量和 process_main() 的整體程式碼大小出現組合爆炸。

  • s - 帶標籤的來源:X 暫存器、Y 暫存器或字面術語。標籤將在執行時期進行測試,以從 X 暫存器、Y 暫存器檢索值,或簡單地將該值用作帶標籤的 Erlang 術語。(實作說明:X 暫存器被標記為 pid,而 Y 暫存器被標記為埠。因此,字面術語不得包含埠或 pid。)

  • S - 帶標籤的來源暫存器(X 或 Y)。標籤將在執行時期進行測試,以從 X 暫存器或 Y 暫存器檢索值。比 s 稍微便宜一些。

  • d - 帶標籤的目的地暫存器(X 或 Y)。標籤將在執行時期進行測試,以設定指向目的地暫存器的指標。如果指令執行垃圾收集,則必須使用 $REFRESH_GEN_DEST() 巨集來重新整理指標,然後再儲存到其中(稍後章節將有更多詳細資訊)。

  • j - 失敗標籤(fp 的組合)。如果分支目標為 0,則如果指令失敗將引發例外狀況,否則控制權將轉移到目標位址。

以下類型都應用於具有 u 類型的運算元。

  • t - 適合 12 位元(0-4096)的未標記整數。它可以與一個字組中的其他運算元打包。最常作為指令(例如 test_heap)中的活動暫存器數量。

  • I - 適合 32 位元的未標記整數。在 64 位元系統上,它可以與一個字組中的其他運算元打包。

  • W - 未標記的整數或指標。無法與其他運算元打包。

  • e - 指向匯出項目的指標。由呼叫其他模組的呼叫指令使用,例如 call_ext

  • L - 標籤。僅由 label/1 指令使用。

  • b - 指向 BIF 的指標。在 BIF 指令(例如 call_bif)中使用。

  • F - 指向 fun 項目的指標。在 make_fun2 和類似指令中使用。

  • A - 帶標籤的元數值。用於測試元組元數的指令。

  • P - 元組中的位元組偏移量。

  • Q - 堆疊中的位元組偏移量。用於更新框架指標暫存器。可以與其他運算元打包。

  • * - 此運算元必須是最後一個運算元。它表示後續有多個可變數量的運算元。當指令具有可變數量的運算元時,對於 BeamAsm 而言,必須使用它;請參閱處理可變數量的運算元。它可以作為直譯器的文件,但對程式碼產生沒有影響。

當載入器將泛型指令轉換為特定指令時,它將選擇最適合該類型的特定指令。請考慮以下兩個指令

move c x
move n x

c 運算元可以編碼任何字面值,包括 NIL。n 運算元僅適用於 NIL。如果我們有泛型指令 {move,nil,{x,1}},則載入器會將其轉換為 move_nx 1,因為 move n x 更具體。move_nx 可能會稍微更快或更小(取決於架構),因為 [] 不會明確地儲存為運算元。

特定指令的語法糖

可以為每個運算元指定多個類型字母。這是一個範例

move cxy xy

這是以下指令的語法糖

move c x
move c y
move x x
move x y
move y x
move y y

請注意 move c xymove c d 之間的差異。請注意,move c xy 等效於以下兩個定義

move c x
move c y

另一方面,move c d 是一個單一指令。在執行時期,將測試 d 運算元以查看它是否引用 X 暫存器或 Y 暫存器,並將設定指向該暫存器的指標。

「?」類型修飾符

可以在運算元的末尾加上字元 ?,以表示運算元不會在每次執行指令時都使用。例如

allocate_heap t I t?
is_eq_exact f? x xy

allocate_heap 中,最後一個運算元是活動暫存器的數量。只有在堆積空間不足且必須執行垃圾收集時才會使用它。

is_eq_exact 中,只有在兩個暫存器運算元不相等時才會使用失敗位址(第一個運算元)。

知道運算元並非總是使用,可以改善某些指令的打包方式。

對於 allocate_heap 指令,若沒有 ?,封裝方式會如下:

     +--------------------+--------------------+
I -> |       Stack needed | &&lb_allocate_heap +
     +--------------------+--------------------+
     |        Heap needed | Live registers     +
     +--------------------+--------------------+

「Stack needed(所需堆疊)」和「Heap needed(所需堆積)」總是會被使用,但它們位於不同的字詞中。因此,在執行階段,allocate_heap 指令必須從記憶體中讀取這兩個字詞,即使它不一定會使用「Live registers(活動暫存器)」。

有了 ?,運算元將會以這種方式封裝:

     +--------------------+--------------------+
I -> |     Live registers | &&lb_allocate_heap +
     +--------------------+--------------------+
     |        Heap needed |       Stack needed +
     +--------------------+--------------------+

現在,「Stack needed」和「Heap needed」位於同一個字詞中。

定義轉換規則

轉換規則用於將泛型指令改寫為其他泛型指令。轉換規則會重複應用,直到沒有規則匹配為止。屆時,結果指令序列中的第一個指令將會被轉換為特定指令,並加入到正在載入的模組程式碼中。接著,剩餘指令的轉換規則會以相同方式執行。

規則的識別方式是其右指針箭頭:=>。箭頭左側是一個或多個指令模式,以 | 分隔。箭頭右側是零個或多個指令,以 | 分隔。如果 BEAM 程式碼中的指令符合左側的指令模式,它們將會被右側的指令取代(如果右側沒有指令,則會被移除)。

定義指令模式

我們將從查看箭頭左側的模式開始。

指令的模式由其名稱組成,後跟每個運算元的模式。運算元模式之間以空格分隔。

最簡單的模式是變數。就像在 Erlang 中一樣,變數必須以大寫字母開頭。與 Erlang 不同的是,變數 **不能** 重複。

在左側綁定的變數可以在右側使用。例如,此規則會將所有 move 指令改寫為運算元交換的 assign 指令:

move Src Dst => assign Dst Src

如果我們只想比對特定類型的運算元,可以使用類型約束。類型約束由一個或多個小寫字母組成,每個字母指定一個類型。例如:

is_integer Fail an => jump Fail

如果第二個運算元是原子或 NIL(空列表),則第二個運算元模式 an 會比對成功。在比對成功的情況下,is_integer/2 指令將會被 jump/1 指令取代。

運算元模式可以同時綁定變數和約束類型,方法是在變數後面加上 = 和約束。例如:

is_eq_exact Fail=f R=xy C=q => i_is_eq_exact_literal Fail R C

在這裡,is_eq_exact 指令會被僅比較常值的特定指令取代,但前提是第一個運算元是暫存器,而第二個運算元是常值。

移除指令

模式左側的指令可以使用轉換右側的 _ 符號移除。例如,可以像這樣移除沒有任何實際行號資訊的 line 指令:

line n => _

(在 OTP 25 之前,這會改用將右側留空的方式實現。)

進一步約束模式

除了指定類型字母之外,還可以指定類型的實際值。例如:

move C=c x==1 => move_x1 C

在這裡,move 的第二個運算元被約束為 X 暫存器 1。

在指定原子約束時,原子會以 C 原始程式碼中的方式編寫。也就是說,它需要 am_ 前綴,並且必須在 atom.names 中列出。例如,可以像這樣移除多餘的 is_boolean 指令:

is_boolean Fail=f a==am_true => _
is_boolean Fail=f a==am_false => _

有多種約束可用於測試呼叫是否為 BIF 或函式。

u$is_bif 約束將測試給定的運算元是否參照 BIF。例如:

call_ext u Bif=u$is_bif => call_bif Bif
call_ext u Func         => i_call_ext Func

call_ext 指令可以用來呼叫以 Erlang 編寫的函式以及 BIF(或更正確地說,SNIF)。如果運算元參照 BIF(也就是說,如果它在 bif.tab 檔案中列出),則 u$is_bif 約束會比對成功。請注意,u$is_bif 應該只應用於已知包含 BEAM 檔案中匯入表格區塊索引的運算元(此類運算元在對應的特定指令中具有 be 類型)。如果應用於其他 u 運算元,則最多會傳回無意義的結果。

如果運算元不參照 BIF(未在 bif.tab 中列出),則 u$is_not_bif 約束會比對成功。例如:

move S X0=x==0 | line Loc | call_ext_last Ar Func=u$is_not_bif D =>
     move S X0 | call_ext_last Ar Func D

如果給定的運算元參照特定的 BIF,則 u$bif:Module:Name/Arity 約束會測試成功。請注意,Module:Name/Arity **必須** 是在 bif.tab 中定義的現有 BIF,否則將會發生編譯錯誤。當呼叫特定 BIF 應以指令取代時,它會很有用,如本範例所示:

gc_bif2 Fail Live u$bif:erlang:splus/2 S1 S2 Dst =>
     gen_plus Fail Live S1 S2 Dst

在此,對 GC BIF '+'/2 的呼叫將會被指令 gen_plus/5 取代。請注意,必須使用 C 原始程式碼中使用的相同名稱來表示 BIF,在本例中為 splus。它在 bit.tab 中的定義如下:

ubif erlang:'+'/2 splus_2

如果給定的運算元是特定函式,則 u$func:Module:Name/Arity 將會測試成功。以下是一個範例:

bif1 Fail u$func:erlang:is_constant/1 Src Dst => too_old_compiler

is_constant/1 在很久以前曾經是 BIF。此轉換會使用 too_old_compiler 指令取代呼叫,該指令在載入器中經過特殊處理,以產生比遺失保護 BIF 的預設錯誤更友善的錯誤訊息。

模式中允許的類型約束

以下是轉換規則左側允許的所有類型字母。

  • u - 符合機器字組的未加標籤整數。

  • x - X 暫存器。

  • y - Y 暫存器。

  • l - 浮點數暫存器編號。

  • i - 帶標籤的字面整數。

  • a - 帶標籤的字面原子。

  • n - NIL([],空列表)。

  • q - 不符合字組的常值,例如列表或元組。

  • f - 非零失敗標籤。

  • p - 零失敗標籤。

  • j - 任何標籤。等同於 fp

  • c - 任何常值項。等同於 ainq

  • s - X 暫存器、Y 暫存器或任何常值項。等同於 xyc

  • d - X 或 Y 暫存器。等同於 xy。(在模式中,d 將會比對來源和目標暫存器。在特定指令中作為運算元時,它只能用於目標暫存器。)

  • o - 溢位。不符合機器字組的未加標籤整數。

述詞

如果到目前為止所描述的約束不足,則可以在 C 中實作其他約束,並在轉換左側呼叫它們作為保護函式。如果保護函式傳回非零值,則會繼續比對規則,否則比對會失敗。此類保護函式在下文中稱為 *述詞*。

最常用的保護約束是 equal()。它可以用來像這樣移除多餘的 move 指令:

move R1 R2 | equal(R1, R2) => _

或像這樣移除多餘的 is_eq_exact 指令:

is_eq_exact Lbl Src1 Src2 | equal(Src1, Src2) => _

在撰寫本文時,所有述詞都定義在多個目錄中名為 predicates.tab 的檔案中。在 $ERL_TOP/erts/emulator/beam 中直接的 predicates.tab 中,包含傳統模擬器和 JIT 實作所使用的述詞。只有模擬器使用的述詞可以在 emu/predicates.tab 中找到。

關於述詞實作的簡短說明

詳細描述述詞的實作方式超出了本文檔的範圍,因為它需要了解內部載入器資料結構的知識,但以下是對名為 literal_is_map() 的簡單述詞實作的快速瀏覽。

以下是第一個使用範例:

ismap Fail Lit=q | literal_is_map(Lit) =>

如果 Lit 運算元是常值,則會呼叫 literal_is_map() 述詞以判斷它是否為地圖常值。如果是,則不需要該指令且可以將其移除。

literal_is_map() 的實作方式如下(位於 emu/predicates.tab 中):

pred.literal_is_map(Lit) {
    Eterm term;

    ASSERT(Lit.type == TAG_q);
    term = beamfile_get_literal(&S->beam, Lit.val);
    return is_map(term);
}

pred. 前綴會告訴 **beam_makeops** 此函式是述詞。如果沒有此前綴,它會被解讀為指令的實作(在 **定義實作** 中描述)。

述詞函式具有稱為 S 的魔術變數,它是指向狀態結構的指標。在範例中,beamfile_get_literal(&S->beam, Lit.val); 用於擷取常值的實際項。

在撰寫本文時,**beam_makeops** 產生的展開 C 程式碼如下:

static int literal_is_map(LoaderState* S, BeamOpArg Lit) {
  Eterm term;

  ASSERT(Lit.type == TAG_q);
  term = S->literals[Lit.val].term;
  return is_map(term);;
}

處理具有可變數量運算元的指令

某些指令(例如 select_val/3)基本上具有可變數量的運算元。此類指令在 BEAM 組譯碼中,最後一個運算元為 {list,[...]}。例如:

{select_val,{x,0},
            {f,1},
            {list,[{atom,b},{f,4},{atom,a},{f,5}]}}.

載入器會將 {list,[...]} 運算元轉換為值為列表中元素數量的 u 運算元,後跟列表中的每個元素。上述指令會被翻譯為以下泛型指令:

{select_val,{x,0},{f,1},{u,4},{atom,b},{f,4},{atom,a},{f,5}}

若要比對可變數量的引數,我們需要使用特殊的運算元類型 *,如下所示:

select_val Src=aiq Fail=f Size=u List=* =>
    i_const_select_val Src Fail Size List

此轉換會將具有常數來源運算元的 select_val/3 指令重新命名為 i_const_select_val/3

在右側建構新指令

右側最常見的運算元是在比對左側模式時綁定的變數。例如:

trim N Remaining => i_trim N

運算元也可以是類型字母,以建構該類型的運算元。每個類型都有一個預設值。例如,類型 x 的預設值為 1023,這是最高的 X 暫存器。這使得右側的 x 成為暫時 X 暫存器的方便捷徑。例如:

is_number Fail Literal=q => move Literal x | is_number Fail x

如果 is_number/2 的第二個運算元是字面值,它會被移至 X 暫存器 1023。然後 is_number/2 會測試儲存在 X 暫存器 1023 中的值是否為數字。

當運算元很少不是暫存器時,這種轉換很有用。在 is_number/2 的情況下,除非編譯器最佳化被停用,否則第二個運算元永遠是暫存器。

如果預設值不適用,可以在類型字母後面加上 = 和一個值。大多數類型採用整數值。原子 (atom) 的值寫法與 C 原始碼中相同。例如,原子 false 寫成 am_false。原子必須列在 atom.names 中。

以下範例顯示如何指定值

bs_put_utf32 Fail=j Flags=u Src=s =>
    i_bs_validate_unicode Fail Src |
    bs_put_integer Fail i=32 u=1 Flags Src

右側的類型字母

以下是所有允許在轉換規則右側建構指令的運算元中使用的類型。

  • u - 建構一個未標記的整數。預設值為 0。

  • x - X 暫存器。預設值為 1023。這使得 x 方便用作臨時 X 暫存器。

  • y - Y 暫存器。預設值為 0。

  • l - 浮點暫存器編號。預設值為 0。

  • i - 已標記的字面整數。預設值為 0。

  • a - 已標記的原子 (atom)。預設值為空原子 (am_Empty)。

  • p - 零失敗標籤。

  • n - NIL([],空列表)。

右側的函式呼叫

無法使用此處描述的規則語言描述的轉換,可以實作為 C 語言中的產生器函式,並從轉換的右側呼叫。轉換的左側將執行匹配並將運算元繫結到變數。然後,這些變數可以傳遞到右側的產生器函式。例如

bif2 Fail=j u$bif:erlang:element/2 Index=s Tuple=xy Dst=d =>
    element(Jump, Index, Tuple, Dst)

此轉換規則會匹配對 BIF element/2 的呼叫。運算元將被捕獲,並呼叫產生器函式 element()

element() 產生器將根據 Index 產生兩個指令之一。如果 Index 是介於 1 到最大元組大小範圍內的整數,則會產生指令 i_fast_element/2,否則會產生指令 i_element/4。相應的特定指令為

i_fast_element xy j? I d
i_element xy j? s d

由於元組已經是一個未標記的整數,因此 i_fast_element/2 指令速度更快。它也知道索引至少為 1,因此不必對其進行測試。i_element/4 指令必須從暫存器中提取索引,測試它是否為整數,並取消標記該整數。

在撰寫本文時,所有產生器函式都定義在多個目錄中名為 generators.tab 的檔案中(與 predicates.tab 檔案位於相同的目錄中)。

詳細描述如何編寫產生器函式不在本文檔的範圍之內,但這裡是 element() 的實作

gen.element(Fail, Index, Tuple, Dst) {
    BeamOp* op;

    $NewBeamOp(S, op);

    if (Index.type == TAG_i && Index.val > 0 &&
        Index.val <= ERTS_MAX_TUPLE_SIZE &&
        (Tuple.type == TAG_x || Tuple.type == TAG_y)) {
        $BeamOpNameArity(op, i_fast_element, 4);
        op->a[0] = Tuple;
        op->a[1] = Fail;
        op->a[2].type = TAG_u;
        op->a[2].val = Index.val;
        op->a[3] = Dst;
    } else {
        $BeamOpNameArity(op, i_element, 4);
        op->a[0] = Tuple;
        op->a[1] = Fail;
        op->a[2] = Index;
        op->a[3] = Dst;
    }

    return op;
}

gen. 前綴告訴 beam_makeops 此函式是一個產生器。如果沒有此前綴,它將被解釋為指令的實作(在 **定義實作** 中描述)。

產生器函式有一個名為 S 的魔術變數,它是指向狀態結構的指標。在範例中,S 用於調用 NewBeamOp 巨集。

定義實作

對於傳統的 BEAM 解譯器,指令的實際實作也定義在由 beam_makeops 處理的 .tab 檔案中。有關如何為 BeamAsm 完成程式碼產生的簡要介紹,請參閱BeamAsm 的程式碼產生

由於實際原因,指令定義儲存在多個檔案中,撰寫本文時在以下檔案中(在 beam/emu 目錄中)

bif_instrs.tab
arith_instrs.tab
bs_instrs.tab
float_instrs.tab
instrs.tab
map_instrs.tab
msg_instrs.tab
select_instrs.tab
trace_instrs.tab

還有一個檔案只包含巨集定義

macros.tab

每個檔案的語法都類似於 C 程式碼。實際上,大多數內容 *是* C 程式碼,其中散佈著巨集調用。

為了讓 Emacs 自動縮排程式碼,每個檔案都以以下行開始

// -*- c -*-

為了避免弄亂縮排,所有註解都寫成 C++ 風格的註解 (//) 而不是 #。請注意,註解必須從行的開頭開始。

指令定義檔案的重點是巨集定義。我們之前已經看過這個巨集定義

move(Src, Dst) {
    $Dst = $Src;
}

巨集定義必須從行的開頭開始(不允許有空格),左大括號必須在同一行,右大括號必須在行的開頭。建議巨集主體正確縮排。

按照慣例,標頭中的巨集引數都以大寫字母開頭。在主體中,可以透過在巨集引數前面加上 $ 來擴展巨集引數。

名稱和引數計數與特定指令族匹配的巨集定義,會被假設為該指令的實作。

巨集也可以從另一個巨集中呼叫。例如,move_deallocate_return/2 透過調用 $deallocate_return() 作為巨集來避免重複程式碼

move_deallocate_return(Src, Deallocate) {
    x(0) = $Src;
    $deallocate_return($Deallocate);
}

這是 deallocate_return/1 的定義

deallocate_return(Deallocate) {
    //| -no_next
    int words_to_pop = $Deallocate;
    SET_I((BeamInstr *) cp_val(*E));
    E = ADD_BYTE_OFFSET(E, words_to_pop);
    CHECK_TERM(x(0));
    DispatchReturn;
}

move_deallocate_return 的展開程式碼會像這樣

OpCase(move_deallocate_return_cQ):
{
  x(0) = I[1];
  do {
    int words_to_pop = Qb(BeamExtraData(I[0]));
    SET_I((BeamInstr *) cp_val(*E));
    E = ADD_BYTE_OFFSET(E, words_to_pop);
    CHECK_TERM(x(0));
    DispatchReturn;
  } while (0);
}

展開巨集時,除非 beam_makeops 可以清楚地看到不需要包裝器,否則 beam_makeops 會將展開包裝在 do/while 包裝器中。在這種情況下,需要包裝器。

請注意,巨集的引數不能是複雜的表達式,因為引數是根據 , 分割的。例如,以下程式碼將無法運作,因為 beam_makeops 會將表達式分割成兩個引數

$deallocate_return(get_deallocation(y, $Deallocate));

程式碼產生指令

在巨集定義中,// 註解通常不會被特殊處理。它們將與主體中的其他程式碼一起複製到具有產生程式碼的檔案中。

但是,有一個例外。在巨集定義中,以空白字元開頭,後接 //| 的行會被特殊處理。該行的其餘部分會假設包含用於控制程式碼產生的指令。

目前,有兩個程式碼產生指令被識別

  • -no_prefetch
  • -no_next
-no_prefetch 指令

為了瞭解 -no_prefetch 的作用,讓我們先看看預設的程式碼產生。以下是為 move_cx 產生的程式碼

OpCase(move_cx):
{
  BeamInstr next_pf = BeamCodeAddr(I[2]);
  xb(BeamExtraData(I[0])) = I[1];
  I += 2;
  ASSERT(VALID_INSTR(next_pf));
  GotoPF(next_pf);
}

請注意,完成的第一件事是提取下一個指令的位址。原因在於它通常可以提高效能。

為了示範,我們可以將 -no_prefetch 指令新增到 move/2 指令

move(Src, Dst) {
    //| -no_prefetch
    $Dst = $Src;
}

我們可以看到不再執行預提取

OpCase(move_cx):
{
  xb(BeamExtraData(I[0])) = I[1];
  I += 2;
  ASSERT(VALID_INSTR(*I));
  Goto(*I);
}

在實際情況中,我們何時會想要關閉預提取?

在並非總是執行下一個指令的指令中。例如

is_atom(Fail, Src) {
    if (is_not_atom($Src)) {
        $FAIL($Fail);
    }
}

// From macros.tab
FAIL(Fail) {
    //| -no_prefetch
    $SET_I_REL($Fail);
    Goto(*I);
}

is_atom/2 可以執行下一個指令(如果第二個運算元是原子 (atom)),或分支到失敗標籤。

產生的程式碼如下所示

OpCase(is_atom_fx):
{
  if (is_not_atom(xb(I[1]))) {
    ASSERT(VALID_INSTR(*(I + (fb(BeamExtraData(I[0]))) + 0)));
    I += fb(BeamExtraData(I[0])) + 0;;
    Goto(*I);;
  }
  I += 2;
  ASSERT(VALID_INSTR(*I));
  Goto(*I);
}
-no_next 指令

接下來,我們將看看何時可以使用 -no_next 指令。以下是 jump/1 指令

jump(Fail) {
    $JUMP($Fail);
}

// From macros.tab
JUMP(Fail) {
    //| -no_next
    $SET_I_REL($Fail);
    Goto(*I);
}

產生的程式碼如下所示

OpCase(jump_f):
{
  ASSERT(VALID_INSTR(*(I + (fb(BeamExtraData(I[0]))) + 0)));
  I += fb(BeamExtraData(I[0])) + 0;;
  Goto(*I);;
}

如果我們移除 -no_next 指令,程式碼會如下所示

OpCase(jump_f):
{
  BeamInstr next_pf = BeamCodeAddr(I[1]);
  ASSERT(VALID_INSTR(*(I + (fb(BeamExtraData(I[0]))) + 0)));
  I += fb(BeamExtraData(I[0])) + 0;;
  Goto(*I);;
  I += 1;
  ASSERT(VALID_INSTR(next_pf));
  GotoPF(next_pf);
}

最後,C 編譯器可能會將此程式碼最佳化為與第一個版本相同的原生程式碼,但第一個版本肯定更容易讓人們閱讀。

macros.tab 檔案中的巨集

檔案 macros.tab 包含許多有用的巨集。實作新指令時,最好瀏覽 macros.tab,看看是否可以使用任何現有的巨集,而不是重新發明輪子。

我們將在這裡描述一些最有用的巨集。

GC_REGEXP 定義

以下行定義了一個正規表達式,它會識別對執行垃圾收集的函式的呼叫

 GC_REGEXP=erts_garbage_collect|erts_gc|GcBifFunction;

目的是讓 beam_makeops 驗證執行垃圾收集且具有 d 運算元的指令,是否使用 $REFRESH_GEN_DEST() 巨集。

如果您需要定義一個執行垃圾收集的新函式,則應給它加上前綴 erts_gc_。如果不可能,您應該更新正規表達式,使其與您的新函式匹配。

FAIL(Fail)

分支到 $Fail。將抑制預提取 (-no_prefetch)。典型用法

is_nonempty_list(Fail, Src) {
    if (is_not_list($Src)) {
        $FAIL($Fail);
    }
}
JUMP(Fail)

分支到 $Fail。抑制下一個指令的調度產生 (-no_next)。典型用法

jump(Fail) {
    $JUMP($Fail);
}
GC_TEST(NeedStack, NeedHeap, Live)

$GC_TEST(NeedStack, NeedHeap, Live) 測試是否提供給定量的堆疊空間和堆積空間。如果沒有,它將執行垃圾收集。典型用法

test_heap(Nh, Live) {
    $GC_TEST(0, $Nh, $Live);
}
AH(NeedStack, NeedHeap, Live)

AH(NeedStack, NeedHeap, Live) 配置堆疊框架,並且可選擇配置額外的堆積空間。

預先定義的巨集和變數

beam_makeops 定義了幾個內建巨集和預先繫結的變數。

NEXT_INSTRUCTION 預先繫結的變數

NEXT_INSTRUCTION 是一個預先繫結的變數,在所有指令中都可用。它會展開為下一個指令的位址。

這是一個範例

i_call(CallDest) {
    //| -no_next
    $SAVE_CONTINUATION_POINTER($NEXT_INSTRUCTION);
    $DISPATCH_REL($CallDest);
}

呼叫函式時,返回位址會先儲存在 E[0] 中(使用 $SAVE_CONTINUATION_POINTER() 巨集),然後將控制權轉移給被呼叫者。以下是產生的程式碼

OpCase(i_call_f):
{
    ASSERT(VALID_INSTR(*(I+2)));
    *E = (BeamInstr) (I+2);;

    /* ... dispatch code intentionally left out ... */
}

我們可以看到 $NEXT_INSTRUCTION 已展開為 I+2。這是合理的,因為 i_call_f/1 指令的大小為兩個字。

IP_ADJUSTMENT 預先繫結的變數

$IP_ADJUSTMENT 通常為 0。在少數組合指令中(如下所述),它可能不為零。它在 macros.tab 中這樣使用

SET_I_REL(Offset) {
    ASSERT(VALID_INSTR(*(I + ($Offset) + $IP_ADJUSTMENT)));
    I += $Offset + $IP_ADJUSTMENT;
}

避免直接使用 IP_ADJUSTMENT。使用 SET_I_REL() 或調用該巨集的巨集之一,例如在 macros.tab 中定義的 FAIL()JUMP()

預先定義的巨集函式

IF() 巨集

$IF(Expr, IfTrue, IfFalse) 會評估 Expr,其必須為有效的 Perl 表達式(對於簡單的數值表達式,其語法與 C 相同)。如果 Expr 的評估結果為 0,則整個 IF() 表達式將被替換為 IfFalse,否則將被替換為 IfTrue

請參閱 OPERAND_POSITION() 的描述以取得範例。

OPERAND_POSITION() 巨集

$OPERAND_POSITION(Expr) 會傳回 Expr 的位置,如果 Expr 是一個未封裝的運算元。第一個運算元的位置為 1。

否則傳回 0。

這個巨集可以用於共享程式碼,例如:

FAIL(Fail) {
    //| -no_prefetch
    $IF($OPERAND_POSITION($Fail) == 1 && $IP_ADJUSTMENT == 0,
        goto common_jump,
        $DO_JUMP($Fail));
}

DO_JUMP(Fail) {
    $SET_I_REL($Fail);
    Goto(*I));
}

// In beam_emu.c:
common_jump:
   I += I[1];
   Goto(*I));

$REFRESH_GEN_DEST() 巨集

當特定指令具有 d 運算元時,在指令執行的早期階段,會初始化一個指標以指向相關的 X 或 Y 暫存器。

如果在結果儲存之前發生垃圾回收,堆疊將會移動,如果 d 運算元參考的是 Y 暫存器,則該指標將不再有效。(Y 暫存器儲存在堆疊上。)

在這些情況下,必須呼叫 $REFRESH_GEN_DEST() 以再次設定指標。beam_makeops 會注意到是否有呼叫執行垃圾回收的函式,且未呼叫 $REFRESH_GEN_DEST()

以下是一個完整的範例。new_map 指令的定義如下:

new_map d t I

其實現方式如下:

new_map(Dst, Live, N) {
    Eterm res;

    HEAVY_SWAPOUT;
    res = erts_gc_new_map(c_p, reg, $Live, $N, $NEXT_INSTRUCTION);
    HEAVY_SWAPIN;
    $REFRESH_GEN_DEST();
    $Dst = res;
    $NEXT($NEXT_INSTRUCTION+$N);
}

如果我們忘記了 $REFRESH_GEN_DEST(),則會出現類似以下的訊息:

pointer to destination register is invalid after GC -- use $REFRESH_GEN_DEST()
... from the body of new_map at beam/map_instrs.tab(30)

可變數量的運算元

以下是一個範例,說明如何處理解譯器中具有可變數量運算元的指令。以下是 emu/ops.tab 中的指令定義:

put_tuple2 xy I *

對於解譯器,* 是可選的,因為它不會以任何方式影響程式碼產生。然而,建議包含它,以清楚地向人類讀者表明存在可變數量的運算元。

使用 $NEXT_INSTRUCTION 巨集來取得指向第一個可變運算元的指標。

以下是實作方式:

put_tuple2(Dst, Arity) {
Eterm* hp = HTOP;
Eterm arity = $Arity;
Eterm* dst_ptr = &($Dst);

//| -no_next
ASSERT(arity != 0);
*hp++ = make_arityval(arity);

/*
 * The $NEXT_INSTRUCTION macro points just beyond the fixed
 * operands. In this case it points to the descriptor of
 * the first element to be put into the tuple.
 */
I = $NEXT_INSTRUCTION;
do {
    Eterm term = *I++;
    switch (loader_tag(term)) {
    case LOADER_X_REG:
    *hp++ = x(loader_x_reg_index(term));
    break;
    case LOADER_Y_REG:
    *hp++ = y(loader_y_reg_index(term));
    break;
    default:
    *hp++ = term;
    break;
    }
} while (--arity != 0);
*dst_ptr = make_tuple(HTOP);
HTOP = hp;
ASSERT(VALID_INSTR(* (Eterm *)I));
Goto(*I);
}

組合指令

問題:對於頻繁執行的指令,我們希望使用「快速」運算元類型,例如 xy,而不是 sS。為了避免程式碼大小爆炸,我們希望在指令之間共享大部分的實作。以下是 i_increment/5 的特定指令:

i_increment r W t d
i_increment x W t d
i_increment y W t d

i_increment 指令的實作方式如下:

i_increment(Source, IncrementVal, Live, Dst) {
    Eterm increment_reg_source = $Source;
    Eterm increment_val = $IncrementVal;
    Uint live;
    Eterm result;

    if (ERTS_LIKELY(is_small(increment_reg_val))) {
        Sint i = signed_val(increment_reg_val) + increment_val;
        if (ERTS_LIKELY(IS_SSMALL(i))) {
            $Dst = make_small(i);
            $NEXT0();
        }
    }
    live = $Live;
    HEAVY_SWAPOUT;
    reg[live] = increment_reg_val;
    reg[live+1] = make_small(increment_val);
    result = erts_gc_mixed_plus(c_p, reg, live);
    HEAVY_SWAPIN;
    ERTS_HOLE_CHECK(c_p);
    if (ERTS_LIKELY(is_value(result))) {
        $REFRESH_GEN_DEST();
        $Dst = result;
        $NEXT0();
    }
    ASSERT(c_p->freason != BADMATCH || is_value(c_p->fvalue));
    goto find_func_info;
}

程式碼會有三個幾乎相同的副本。考慮到程式碼的大小,這可能代價太高。

為了避免程式碼的三個副本,我們可以使用一個特定的指令:

i_increment S W t d

(與上面的相同實作方式將會有效。)

這會減少程式碼大小,但速度較慢,因為 S 表示會有額外的程式碼來測試運算元是否參考 X 暫存器或 Y 暫存器。

解決方案:我們可以使用「組合指令」。組合指令是從指令片段組合而成的。程式碼的大部分可以共享。

在這裡,我們將展示如何將 i_increment 實作為組合指令。我們將首先展示每個個別的片段,然後展示如何將它們連接在一起。首先,我們需要一個變數來儲存從暫存器中提取的值:

increment.head() {
    Eterm increment_reg_val;
}

名稱 increment 是該片段所屬群組的名稱。請注意,它不需要與指令名稱相同。群組名稱後面接著 . 和片段的名稱。名稱 head 是預先定義的。其中的程式碼將放置在區塊的開頭,以便群組中的所有片段都可以存取它。

接下來,我們定義從第一個運算元中提取暫存器值的片段:

increment.fetch(Src) {
    increment_reg_val = $Src;
}

我們將此片段稱為 fetch。此片段將重複三次,每個第一個運算元的值(rxy)各一次。

接下來,我們定義程式碼的主要部分,執行實際的遞增操作:

increment.execute(IncrementVal, Live, Dst) {
    Eterm increment_val = $IncrementVal;
    Uint live;
    Eterm result;

    if (ERTS_LIKELY(is_small(increment_reg_val))) {
        Sint i = signed_val(increment_reg_val) + increment_val;
        if (ERTS_LIKELY(IS_SSMALL(i))) {
            $Dst = make_small(i);
            $NEXT0();
        }
    }
    live = $Live;
    HEAVY_SWAPOUT;
    reg[live] = increment_reg_val;
    reg[live+1] = make_small(increment_val);
    result = erts_gc_mixed_plus(c_p, reg, live);
    HEAVY_SWAPIN;
    ERTS_HOLE_CHECK(c_p);
    if (ERTS_LIKELY(is_value(result))) {
        $REFRESH_GEN_DEST();
        $Dst = result;
        $NEXT0();
    }
    ASSERT(c_p->freason != BADMATCH || is_value(c_p->fvalue));
    goto find_func_info;
}

我們將此片段稱為 execute。它將處理其餘三個運算元(W t d)。此片段只會有一個副本。

現在我們已經定義了這些片段,我們需要通知 beam_makeops 它們應該如何連接:

i_increment := increment.fetch.execute;

:= 的左邊是要由片段實作的特定指令名稱,在本例中為 i_increment:= 的右邊是具有片段的群組名稱,後面接著 .。然後,按照它們應該執行的順序列出群組中的片段名稱。請注意,head 片段未列出。

該行以 ; 結尾(以避免 Emacs 中的縮排混亂)。

(請注意,實際上,:= 行通常放在片段之前。)

產生的程式碼如下所示

{
  Eterm increment_reg_val;
  OpCase(i_increment_rWtd):
  {
    increment_reg_val = r(0);
  }
  goto increment__execute;

  OpCase(i_increment_xWtd):
  {
    increment_reg_val = xb(BeamExtraData(I[0]));
  }
  goto increment__execute;

  OpCase(i_increment_yWtd):
  {
    increment_reg_val = yb(BeamExtraData(I[0]));
  }
  goto increment__execute;

  increment__execute:
  {
    // Here follows the code from increment.execute()
    .
    .
    .
}
關於組合指令的一些注意事項:

不同的運算元必須位於指令的開頭。最後一個片段中的所有運算元在特定指令的所有變體中都必須具有相同的運算元。

例如,以下特定指令不能實作為組合指令:

i_times j? t x x d
i_times j? t x y d
i_times j? t s s d

我們必須更改運算元的順序,以便將兩個不同的運算元放在最前面:

i_times x x j? t d
i_times x y j? t d
i_times s s j? t d

然後我們可以定義:

i_times := times.fetch.execute;

times.head {
    Eterm op1, op2;
}

times.fetch(Src1, Src2) {
    op1 = $Src1;
    op2 = $Src2;
}

times.execute(Fail, Live, Dst) {
    // Multiply op1 and op2.
    .
    .
    .
}

多個指令可以共享一個群組。例如,以下指令具有不同的名稱,但最終它們都會建立一個二進位檔。最後兩個運算元對於它們都是通用的:

i_bs_init_fail       xy j? t? x
i_bs_init_fail_heap s I j? t? x
i_bs_init                W t? x
i_bs_init_heap         W I t? x

指令的定義如下(為了清晰起見,增加了額外的空格):

i_bs_init_fail_heap := bs_init . fail_heap . verify . execute;
i_bs_init_fail      := bs_init . fail      . verify . execute;
i_bs_init           := bs_init .           .  plain . execute;
i_bs_init_heap      := bs_init .               heap . execute;

請注意,前兩個指令有三個片段,而其他兩個只有兩個片段。以下是片段:

bs_init_bits.head() {
    Eterm num_bits_term;
    Uint num_bits;
    Uint alloc;
}

bs_init_bits.plain(NumBits) {
    num_bits = $NumBits;
    alloc = 0;
}

bs_init_bits.heap(NumBits, Alloc) {
    num_bits = $NumBits;
    alloc = $Alloc;
}

bs_init_bits.fail(NumBitsTerm) {
    num_bits_term = $NumBitsTerm;
    alloc = 0;
}

bs_init_bits.fail_heap(NumBitsTerm, Alloc) {
    num_bits_term = $NumBitsTerm;
    alloc = $Alloc;
}

bs_init_bits.verify(Fail) {
    // Verify the num_bits_term, fail using $FAIL
    // if there is a problem.
.
.
.
}

bs_init_bits.execute(Live, Dst) {
   // Long complicated code to a create a binary.
   .
   .
   .
}

這些指令的完整定義可以在 bs_instrs.tab 中找到。產生的程式碼可以在 beam_warm.h 中找到。

BeamAsm 的程式碼產生

對於 BeamAsm 執行時系統,每個指令的實作都由以 C++ 編寫的發射器函式定義,這些函式會發射每個指令的組譯碼。每個特定指令系列都有一個發射器函式。

move 指令為例。在 beam/asm/ops.tab 中,move 有一個特定指令,其定義如下:

move s d

其實作位於 beam/asm/instr_common.cpp 中:

void BeamModuleAssembler::emit_move(const ArgVal &Src, const ArgVal &Dst) {
    mov_arg(Dst, Src);
}

mov_arg() 輔助函式將處理來源和目標運算元的所有組合。例如,指令 {move,{x,1},{y,1}} 將會翻譯成這樣:

mov rdi, qword [rbx+8]
mov qword [rsp+8], rdi

{move,{integer,42},{x,0}} 將會翻譯成這樣:

mov qword [rbx], 687

可以定義多個特定指令,但仍然只有一個發射器函式。例如:

fload S l
fload q l

透過這樣定義 fload,來源運算元必須是 X 暫存器、Y 暫存器或常值。否則,載入將會中止。如果指令改為定義如下:

fload s l

嘗試載入無效指令,例如 {fload,{atom,clearly_bad},{fr,0}},將會導致當機(在載入時或執行指令時)。

無論系列中有多少個特定指令,只允許有一個 emit_fload() 函式。

void BeamModuleAssembler::emit_fload(const ArgVal &Src, const ArgVal &Dst) {
    .
    .
    .
}

處理可變數量的運算元

以下是一個範例,說明如何處理具有可變數量運算元的指令。其中一個指令是 select_val/3。以下是一個在 BEAM 程式碼中看起來的範例:

{select_val,{x,0},
            {f,1},
            {list,[{atom,b},{f,4},{atom,a},{f,5}]}}.

載入器會將 {list,[...]} 運算元轉換為 u 運算元,其值為清單中的元素數量,然後是清單中的每個元素。上面的指令將會翻譯成以下指令:

{select_val,{x,0},{f,1},{u,4},{atom,b},{f,4},{atom,a},{f,5}}

該指令的特定指令定義看起來會像這樣:

select_val s f I *

最後一個運算元的 * 將確保可變運算元以 ArgValSpan 傳遞(在 C++20 之後將會是 std::span)。以下是發射器函式:

void BeamModuleAssembler::emit_select_val(const ArgVal &Src,
                                          const ArgVal &Fail,
                                          const ArgVal &Size,
                                          const Span<ArgVal> &args) {
    ASSERT(Size.getValue() == args.size());
       .
       .
       .
}