モジュールとBEAMファイルフォーマット

この記事は英語の原文を日本語に翻訳したものです。原文: https://blog.stenmans.org/theBeamBook/#CH-beam_modules

翻訳元: happi/theBeamBook 7998e22e78417dbe20e5136b9aee862a1ecaa404(コミット 7998e22

モジュール

Erlangにおけるモジュールとは、Erlangの関数を格納するファイルです。 関連する関数をまとめ、他のモジュールから利用するための単位を提供します。 Erlangのコードロードとは、コンパイル済みのErlangモジュールをBEAM仮想マシンに読み込む処理を指します。 この読み込みは、起動時に静的に行うことも、システム稼働中に動的に行うこともできます。

Erlangはホットコードローディングに対応しています。 システムを停止・再起動することなく、稼働中のままモジュールを更新できる機能です。 開発やデバッグの最中にはとても重宝します。 運用の仕方によっては、24時間365日稼働し続けるシステムを止めずにモジュールをアップグレードできるため、保守の場面でも役立ちます。

新しいコードを読み込んでも、それを実行中のプロセスが残っている間は旧バージョンがメモリ上に残り続けます。 そうしたプロセスがなくなった時点で、旧コードはシステムからパージされます。 ここで注意が必要なのは、最初のバージョンがパージされる前に3番目のバージョンを読み込んだ場合の挙動です。 デフォルトの動作では、最初のバージョンを参照している(スタック上に呼び出しを持つ)プロセスはすべて強制終了されます。

code:load_file(Module) 関数を使うと、モジュールを動的にシステムへ読み込めます。 新しいモジュールを読み込んだ後は、完全修飾呼び出し(Module:function の形の呼び出し。リモートコールとも呼ばれます)がすべて新バージョンへ向かうようになります。 ただし、リモートコールを含まないサーバーループは、そのまま旧コードを実行し続ける点に注意してください。

コードサーバーは、読み込み済みモジュールとそのコードの管理を担うBEAM仮想マシンの一部です。

Erlangの分散モデルとホットコードローディング機能を組み合わせると、分散システム内の複数ノードにまたがるコード更新も可能になります。 とはいえ、これは慎重な調整を要する複雑な作業です。

BEAMファイルフォーマット

BEAMファイルフォーマットに関する最も確実な情報源は、当然ながら beam_lib.erl のソースコードです(GitHub上のbeam_lib.erlを参照)。 また、Beamの主要開発者兼メンテナが書いた、読みやすいものの少し古くなった解説文書も存在します(erlang.seの記事を参照)。

BEAMファイルフォーマットは、Interchange File Format(EA IFF)をもとに、2箇所だけ小さな変更を加えたものです。 その変更点についてはこの後すぐに触れます。 IFFファイルは、ヘッダーに続けて複数の「チャンク」が並ぶ構造を取ります。 IFF仕様には主に画像や音楽を扱う標準チャンク型がいくつも定義されていますが、独自の名前を持つチャンクを指定することも許されており、BEAMが使っているのはこちらの仕組みです。

BEAMファイルは標準のIFFファイルと異なり、各チャンクが2バイト境界ではなく4バイト境界(32ビットワード)に揃えられます。 この標準IFFファイルではないことを示すため、IFFヘッダーには「FOR」ではなく「FOR1」というタグが付けられます。 これはIFF仕様が将来の拡張のために提案しているタグです。

BEAMはフォームタイプとして「BEAM」を使います。BEAMファイルのヘッダーは次のようなレイアウトを取ります。

BEAMHeader = <<
  IffHeader:4/unit:8 = "FOR1",
  Size:32/big,                  // ビッグエンディアン。残りのバイト数
  FormType:4/unit:8 = "BEAM"
>>

ヘッダーに続けて複数のチャンクが並びます。各チャンクのサイズは4の倍数に揃えられ、チャンクごとに固有のヘッダー(以下)を持ちます。

この境界揃えは、一部のプラットフォームで重要な意味を持ちます。 境界に揃っていないメモリへのバイトアクセスがハードウェア例外(LinuxではSIGBUSという名前)を引き起こすことがあるためです。 性能低下で済む場合もあれば、例外によってVMがクラッシュすることもあります。
BEAMChunk = <<
  ChunkName:4/unit:8,           // "Code", "Atom", "StrT", "LitT", ...
  ChunkSize:32/big,
  ChunkData:ChunkSize/unit:8,   // データフォーマットはChunkNameによって決まる
  Padding4:0..3/unit:8
>>

このファイルフォーマットでは、あらゆる領域の先頭にその領域のサイズを置くため、ディスクから読みながらそのままファイルを解析できます。 BEAMファイルの構造と内容を具体的に見るために、ファイルからすべてのチャンクを取り出す小さなプログラムを書いてみましょう。 プログラムをできるだけ単純で読みやすく保つため、読み込みながら解析するのではなく、ファイル全体をバイナリとしてメモリに読み込んでから各チャンクを解析する方式にします。 最初の一歩は、すべてのチャンクの一覧を取得することです。

-module(beamfile1).
-export([read/1]).

read(Filename) ->
   {ok, File} = file:read_file(Filename),
   <<"FOR1",
     Size:32/integer,
     "BEAM",
     Chunks/binary>> = File,
   {Size, read_chunks(Chunks, [])}.

read_chunks(<<N,A,M,E, Size:32/integer, Tail/binary>>, Acc) ->
   %% 各チャンクを4バイトの倍数に揃える
   ChunkLength = align_by_four(Size),
   <<Chunk:ChunkLength/binary, Rest/binary>> = Tail,
   read_chunks(Rest, [{[N,A,M,E], Size, Chunk}|Acc]);
read_chunks(<<>>, Acc) -> lists:reverse(Acc).

align_by_four(N) -> (4 * ((N+3) div 4)).

実行例は次のようになります。

> beamfile1:read("beamfile1.beam").
{1116, [{"AtU8",127,
 <<0,0,0,16,9,98,101,97,109,102,105,108,101,49,4,114,101,
 97,100,4,102,105,108,...>>}, {"Code",362,
 <<0,0,0,16,0,0,0,0,0,0,0,178,0,0,0,16,0,0,0,5,1,16,...>>},
 {"StrT",8,<<"FOR1BEAM">>},
 {"ImpT",88,<<0,0,0,7,0,0,0,3,0,0,0,4,0,0,0,1,0,0,0,8,...>>},
 {"ExpT",40,<<0,0,0,3,0,0,0,15,0,0,0,1,0,0,0,15,0,0,0,...>>}, {"Meta",29,
 <<131,108,0,0,0,1,104,2,100,0,16,101,110,97,98,108,101, 100,...>>},
 {"LocT",28,<<0,0,0,2,0,0,0,10,0,0,0,1,0,0,0,11,0,...>>}, {"Attr",40,
 <<131,108,0,0,0,1,104,2,100,0,3,118,115,110,108,0,...>>}, {"CInf",122,
 <<131,108,0,0,0,3,104,2,100,0,7,118,101,114,115,...>>}, {"Dbgi",49,
 <<131,104,3,100,0,13,100,101,98,117,103,95,105,110,...>>},
 {"Line",31,<<0,0,0,0,0,0,0,0,0,0,0,12,0,...>>},
 {"Type",80,<<0,0,0,1,0,0,0,4,31,255,0,0,...>>}]}

ここでBEAMが使用するチャンク名を確認できます。

アトムテーブルチャンク

Atom という名前のチャンクか AtU8 という名前のチャンクのいずれかが必須です。 このチャンクには、モジュールが参照するすべてのアトムが格納されます。 latin1 エンコーディングのソースファイルでは Atom という名前のチャンクが使われ、utf8 エンコーディングのモジュールでは AtU8 という名前のチャンクが使われます。 アトムチャンクのフォーマットは次のとおりです。

AtomChunk = <<
  ChunkName:4/unit:8 = "Atom",
  ChunkSize:32/big,
  NumberOfAtoms:32/big,
  [<<AtomLength:8, AtomName:AtomLength/unit:8>> || repeat NumberOfAtoms],
  Padding4:0..3/unit:8
>>

AtU8 チャンクのフォーマットは、チャンク名が AtU8 である点を除けば上記と同じです。

モジュール名は常にテーブルの最初のアトム(アトムインデックス0)として格納されます。

それでは、BEAMファイルリーダーにアトムチャンクのデコード処理を追加してみましょう。

-module(beamfile2).
-export([read/1]).

read(Filename) ->
   {ok, File} = file:read_file(Filename),
   <<"FOR1",
     Size:32/integer,
     "BEAM",
     Chunks/binary>> = File,
   {Size, parse_chunks(read_chunks(Chunks, []),[])}.

read_chunks(<<N,A,M,E, Size:32/integer, Tail/binary>>, Acc) ->
   %% 各チャンクを4バイトの倍数に揃える
   ChunkLength = align_by_four(Size),
   <<Chunk:ChunkLength/binary, Rest/binary>> = Tail,
   read_chunks(Rest, [{[N,A,M,E], Size, Chunk}|Acc]);
read_chunks(<<>>, Acc) -> lists:reverse(Acc).

parse_chunks([{"AtU8", _Size,
             <<_Numberofatoms:32/integer, Atoms/binary>>}
            | Rest], Acc) ->
   parse_chunks(Rest,[{atoms,parse_atoms(Atoms)}|Acc]);
parse_chunks([Chunk|Rest], Acc) -> %% まだ実装していないチャンク
   parse_chunks(Rest, [Chunk|Acc]);
parse_chunks([],Acc) -> Acc.

parse_atoms(<<Atomlength, Atom:Atomlength/binary, Rest/binary>>) when Atomlength > 0->
   [list_to_atom(binary_to_list(Atom)) | parse_atoms(Rest)];
parse_atoms(_Alignment) -> [].

align_by_four(N) -> (4 * ((N+3) div 4)).

エクスポートテーブルチャンク

ExpT(EXPort Tableの略)という名前のチャンクは必須で、どの関数がエクスポートされているかという情報を保持します。

エクスポートチャンクのフォーマットは次のとおりです。

ExportChunk = <<
  ChunkName:4/unit:8 = "ExpT",
  ChunkSize:32/big,
  ExportCount:32/big,
  [ << FunctionName:32/big,
       Arity:32/big,
       Label:32/big
    >> || repeat ExportCount ],
  Padding4:0..3/unit:8
>>

FunctionName はアトムテーブル内のインデックスです。

アトムを扱う節の後に次の節を追加することで、parse_chunk 関数を拡張できます。

parse_chunks([{"ExpT", _Size,
             <<_Numberofentries:32/integer, Exports/binary>>}
            | Rest], Acc) ->
   parse_chunks(Rest,[{exports,parse_exports(Exports)}|Acc]);

...

parse_exports(<<Function:32/integer,
               Arity:32/integer,
               Label:32/integer,
               Rest/binary>>) ->
   [{Function, Arity, Label} | parse_exports(Rest)];
parse_exports(<<>>) -> [].

インポートテーブルチャンク

ImpT(IMPort Tableの略)という名前のチャンクは必須で、どの関数がインポートされているかという情報を保持します。

このチャンクのフォーマットは次のとおりです。

ImportChunk = <<
  ChunkName:4/unit:8 = "ImpT",
  ChunkSize:32/big,
  ImportCount:32/big,
  [ << ModuleName:32/big,
       FunctionName:32/big,
       Arity:32/big
    >> || repeat ImportCount ],
  Padding4:0..3/unit:8
>>

ここで ModuleNameFunctionName はアトムテーブル内のインデックスです。

インポートテーブルを解析するコードは、エクスポートテーブルを解析するコードとよく似ていますが、完全に同じではありません。 どちらも32ビット整数3つ組ですが、その意味が異なります。完全なコードは章末を参照してください。

コードチャンク

Code という名前のチャンクはモジュールのBEAMコードを保持し、必須です。このチャンクのフォーマットは次のとおりです。

CodeChunk = <<
  ChunkName:4/unit:8 = "Code",
  ChunkSize:32/big,
  SubSize:32/big,
  InstructionSet:32/big,        % エミュレータ内のコードバージョンと一致していなければならない
  OpcodeMax:32/big,
  LabelCount:32/big,
  FunctionCount:32/big,
  Code:(ChunkSize-SubSize)/binary,  % 残り全部のデータ
  Padding4:0..3/unit:8
>>

SubSize フィールドには、コードが始まるまでのワード数が格納されます。これにより、古いローダーを壊すことなくコードチャンクへ新しい情報フィールドを追加できます。

InstructionSet フィールドは、そのファイルが使用する命令セットのバージョンを示します。命令に非互換な変更が加えられるたびに、このバージョン番号は増加します。

OpcodeMax フィールドは、コード内で使用されているオペコードのうち最大の番号を示します。新しい命令をシステムに追加する場合でも、ファイル内で使われている命令がローダーの知っている範囲に収まっていれば、古いローダーでも新しいファイルを読み込めます。

LabelCount フィールドにはラベルの数が格納されており、ローダーは一度の呼び出しで適切なサイズのラベルテーブルを事前確保できます。FunctionCount フィールドには関数の数が格納されており、同様に関数テーブルも効率よく事前確保できます。

Code フィールドには命令が連なって格納されており、各命令は次のフォーマットを持ちます。

Instruction = <<
  InstructionCode:8,
  [beam_asm:encode(Argument) || repeat Arity]
>>

ここで Arity はテーブルにハードコードされています。このテーブルは、エミュレータをソースからビルドする際に genop スクリプトが ops.tab から生成します。

beam_asm:encode が生成する符号化については、後述のコンパクトターム符号化の節で説明します。

次のコードをプログラムに追加すれば、コードチャンクを解析できます。

parse_chunks([{"Code", Size, <<SubSize:32/integer,Chunk/binary>>
              } | Rest], Acc) ->
   <<Info:SubSize/binary, Code/binary>> = Chunk,
   %% 8はChunkSizeとSubSizeを合わせたサイズ
   OpcodeSize = Size - SubSize - 8,
   <<OpCodes:OpcodeSize/binary, _Align/binary>> = Code,
   parse_chunks(Rest,[{code,parse_code_info(Info), OpCodes}
                      | Acc]);

...

parse_code_info(<<Instructionset:32/integer,
          OpcodeMax:32/integer,
          NumberOfLabels:32/integer,
          NumberOfFunctions:32/integer,
          Rest/binary>>) ->
   [{instructionset, Instructionset},
    {opcodemax, OpcodeMax},
    {numberoflabels, NumberOfLabels},
    {numberofFunctions, NumberOfFunctions} |
    case Rest of
     <<>> -> [];
     _ -> [{newinfo, Rest}]
    end].

BEAM命令のデコード方法は、まさに「BEAM Instructions」という名の章で学びます。

文字列テーブルチャンク

StrT という名前のチャンクは必須で、モジュール内のすべての定数文字列リテラルを1つの長い文字列として保持します。文字列リテラルが1つもない場合でも、チャンク自体は存在し、中身は空でサイズ0になります。

このチャンクのフォーマットは次のとおりです。

StringChunk = <<
  ChunkName:4/unit:8 = "StrT",
  ChunkSize:32/big,
  Data:ChunkSize/binary,
  Padding4:0..3/unit:8
>>

文字列チャンクは、バイト列をそのままバイナリに変換するだけで簡単に解析できます。

parse_chunks([{"StrT", _Size, <<Strings/binary>>} | Rest], Acc) ->
    parse_chunks(Rest,[{strings,binary_to_list(Strings)}|Acc]);

属性チャンク

Attr という名前のチャンクは省略可能ですが、一部のOTPツールは属性が存在することを前提にしています。たとえばリリースハンドラは "vsn" 属性の存在を前提にしています。ファイルからバージョン属性を取得するには beam_lib:version(Filename) を使います。この関数は、"vsn" 属性を持つ属性チャンクが存在することを前提としています。

このチャンクのフォーマットは次のとおりです。

AttributesChunk = <<
  ChunkName:4/unit:8 = "Attr",
  ChunkSize:32/big,
  Attributes:ChunkSize/binary,
  Padding4:0..3/unit:8
>>

属性チャンクは次のように解析できます。

parse_chunks([{"Attr", Size, Chunk} | Rest], Acc) ->
    <<Bin:Size/binary, _Pad/binary>> = Chunk,
    Attribs = binary_to_term(Bin),
    parse_chunks(Rest,[{attributes,Attribs}|Acc]);

コンパイル情報チャンク

CInf という名前のチャンクは省略可能ですが、一部のOTPツールはこの情報が存在することを前提にしています。

フォーマットは次のとおりです。

CompilationInfoChunk = <<
  ChunkName:4/unit:8 = "CInf",
  ChunkSize:32/big,
  Data:ChunkSize/binary,
  Padding4:0..3/unit:8
>>

コンパイル情報チャンクは次のように解析できます。

parse_chunks([{"CInf", Size, Chunk} | Rest], Acc) ->
    <<Bin:Size/binary, _Pad/binary>> = Chunk,
    CInfo = binary_to_term(Bin),
    parse_chunks(Rest,[{compile_info,CInfo}|Acc]);

ローカル関数テーブルチャンク

LocT という名前のチャンクは省略可能で、相互参照ツール向けに用意されています。

フォーマットはエクスポートテーブルと同じです。

LocalFunTableChunk = <<
  ChunkName:4/unit:8 = "LocT",
  ChunkSize:32/big,
  FunctionCount:32/big,
  [ << FunctionName:32/big,
       Arity:32/big,
       Label:32/big
    >> || repeat FunctionCount ],
  Padding4:0..3/unit:8
>>
ローカル関数テーブルを解析するコードは、エクスポートテーブルやインポートテーブルを解析するコードと基本的に同じであり、実際にはすべてのテーブルのエントリを同じ関数で解析できます。完全なコードは章末を参照してください。

リテラルテーブルチャンク

LitT という名前のチャンクは省略可能で、モジュールのソースにあるリテラル値のうち即値ではないものを、すべて圧縮した形で保持します。このチャンクのフォーマットは次のとおりです。

LiteralTableChunk = <<
  ChunkName:4/unit:8 = "LitT",
  ChunkSize:32/big,
  UncompressedSize:32/big,      % メモリを確保する際にサイズがわかると都合がよい
  CompressedLiterals:ChunkSize/binary,
  Padding4:0..3/unit:8
>>

CompressedLiterals は、展開後にちょうど UncompressedSize バイトになっている必要があります。テーブル内の各リテラルは、外部タームフォーマット(erlang:term_to_binary)で符号化されています。CompressedLiterals のフォーマットは次のとおりです。

CompressedLiterals = <<
  Count:32/big,
  [ <<Size:32/big, Literal:binary>>  || repeat Count ]
>>

テーブル全体は zlib:compress/1(deflateアルゴリズム)で圧縮されており、zlib:uncompress/1(inflateアルゴリズム)で展開できます。

このチャンクは次のように解析できます。

parse_chunks([{"LitT", _ChunkSize,
              <<_UnCompressedTableSize:32, Compressed/binary>>}
             | Rest], Acc) ->
    <<_NumLiterals:32,Table/binary>> = zlib:uncompress(Compressed),
    Literals = parse_literals(Table),
    parse_chunks(Rest,[{literals,Literals}|Acc]);

...

parse_literals(<<Size:32,Literal:Size/binary,Tail/binary>>) ->
    [binary_to_term(Literal) | parse_literals(Tail)];
parse_literals(<<>>) -> [].

抽象コードチャンク

Abst という名前のチャンクは省略可能で、抽象構文の形をしたコードを保持することがあります。コンパイラに debug_info フラグを渡すと、モジュールの抽象構文木がこのチャンクに格納されます。デバッガやXrefのようなOTPツールは、この抽象構文形式を必要とします。このチャンクのフォーマットは次のとおりです。

AbstractCodeChunk = <<
  ChunkName:4/unit:8 = "Abst",
  ChunkSize:32/big,
  AbstractCode:ChunkSize/binary,
  Padding4:0..3/unit:8
>>

このチャンクは次のように解析できます。

parse_chunks([{"Abst", _ChunkSize, <<>>} | Rest], Acc) ->
    parse_chunks(Rest,Acc);
parse_chunks([{"Abst", _ChunkSize, <<AbstractCode/binary>>} | Rest], Acc) ->
    parse_chunks(Rest,[{abstract_code,binary_to_term(AbstractCode)}|Acc]);

追加のBEAMチャンク

Erlang OTP 16以降、いくつかの新しいチャンクが追加されています。

  • タイプチャンク(Type:OTP 25で導入され、JIT最適化を助けるための簡略化された型情報を保持する。
  • 関数テーブルチャンク(FunT:無名関数(fun)のメタデータ。
  • メタデータチャンク(Meta:ドキュメントや注釈といった省略可能なメタデータを保持する。

これら新しいチャンクのフォーマットは次のとおりです。

タイプチャンク(Type

TypeChunk = <<
  ChunkName:4/unit:8 = "Type",
  ChunkSize:32/big,
  Version:32/big,
  Count:32/big,
  TypeData:(Count * 16)/binary,
  Padding4:0..3/unit:8
>>

タイプチャンクは、JITコンパイラが最適化に使う簡略化された型情報を保持します。各型エントリは固定16バイトのレコードです。このデータはErlangの外部タームフォーマットではないため、binary_to_term では復号できません。符号化の詳細は、コンパイラ内の beam_types.erl を参照してください。

解析コードは次のとおりです。

parse_chunks([{"Type", _Size, Chunk} | Rest], Acc) ->
    <<_Version:32/big, Count:32/big, TypeData/binary>> = Chunk,
    Types = parse_types(Count, TypeData, []),
    parse_chunks(Rest, [{type_info, Types}|Acc]);
  • UTF-8アトムチャンク(AtU8:Atomチャンクと同じフォーマットで、UTF-8エンコーディングが必要な場合に使う。
AtU8Chunk = <<
  ChunkName:4/unit:8 = "AtU8",
  ChunkSize:32/big,
  NumberOfAtoms:32/big,
  [<<AtomLength:8, AtomName:AtomLength/unit:8>> || repeat NumberOfAtoms],
  Padding4:0..3/unit:8
>>
  • 関数テーブル(FunT:ローカルな無名関数に関する情報。
FunTableChunk = <<
  ChunkName:4/unit:8 = "FunT",
  ChunkSize:32/big, FunCount:32/big, [<<FunctionName:32/big, Arity:32/big,
      Label:32/big
   >> || repeat FunCount],
  Padding4:0..3/unit:8
>>
  • メタデータチャンク(Meta:注釈やユーザー定義のプロパティといったメタデータを格納する。
MetaChunk = <<
  ChunkName:4/unit:8 = "Meta",
  ChunkSize:32/big,
  MetaInfo:ChunkSize/binary,
  Padding4:0..3/unit:8
>>

解析例は次のとおりです。

parse_chunks([{"Meta", Size, Chunk} | Rest], Acc) ->
    <<MetaInfo:Size/binary, _Pad/binary>> = Chunk,
    Meta = binary_to_term(MetaInfo),
    parse_chunks(Rest, [{meta, Meta}|Acc]);

暗号化

Erlangでは、BEAMファイル内のデバッグ情報を暗号化できます。この機能により、開発者はソースコードを非公開にしたまま、デバッガやXrefといったツールを利用し続けられます。

暗号化されたデバッグ情報を利用するには、コンパイラと beam_lib の両方に鍵を渡す必要があります。この鍵は文字列として指定し、大文字・小文字・数字・記号を含む32文字以上にすることが望ましいとされています。

現時点で唯一かつデフォルトの暗号アルゴリズムは des3_cbc で、Cipher Block Chainingモードのtriple DES(Data Encryption Standard)を意味します。鍵文字列は erlang:md5/1 によって撹拌され、des3_cbc で使う鍵が生成されます。

鍵の指定方法は2通りあります。

  1. コンパイラオプション:コンパイラオプション {debug_info_key,Key} と、beam_lib がデバッグ情報を復号する際に鍵を返す関数を登録するための crypto_key_fun/1 を使う。
  2. .erlang.crypt ファイル:関数が登録されていない場合、beam_lib はカレントディレクトリ、次にユーザーのホームディレクトリ、最後に filename:basedir(user_config, "erlang") の順に .erlang.crypt ファイルを探す。ファイルが見つかり鍵が含まれていれば、beam_lib は暗黙のうちに鍵を返す関数を作成して登録する。

.erlang.crypt ファイルには、{debug_info, Mode, Module, Key} という形のタプルのリストを記述します。Mode は暗号アルゴリズムの種類(現在は des3_cbc のみ許可)、Module はアトム(その場合 Key はそのモジュールにのみ使われる)か [](その場合 Key はすべてのモジュールに使われる)、Key は空でない鍵文字列です。

ModeModule の両方が一致する最初のタプルの鍵が使われます。暗号化されたデバッグ情報の安全性を保つには、一意な鍵を使い、それを安全に管理することが重要です。

圧縮

Erlangコンパイラに compressed フラグを渡すと、コンパイラは生成するBEAMファイルを圧縮するようになります。これによりファイルサイズが大幅に小さくなることがあり、ディスク容量が貴重な環境で有用です。

compressed フラグは、BEAMファイル内のErlangコードとリテラルデータを含む部分にzlib圧縮を適用します。コードの実行速度には影響しません。展開処理はコードの実行時ではなく、メモリへの読み込み時に行われるためです。

compressed フラグを使うには、次のように compile 関数のオプションとして渡します。

compile:file(Module, [compressed]).

コマンドラインコンパイラ erlc を使う場合は、+compressed オプションを渡せます。

erlc +compressed module.erl

compressed フラグはBEAMファイルのサイズを縮小できる一方で、コードの展開が必要になる分、モジュールの読み込みにかかる時間は増加します。つまりディスク容量と読み込み時間のトレードオフです。

コンパクトターム符号化

beam_asm:encode が使う符号化方式を見ていきます。BEAMファイルは、単純なタームを効率よく格納するためのコンパクトな符号化方式を採用しています。これはファイルサイズの削減に特化した方式で、タームのメモリ上のレイアウトとは大きく異なります。復号後、符号化された値はすべて、フルサイズのマシンワードまたはErlangタームへと展開されます。

beam_asm は、Erlangディストリビューションに含まれる compiler アプリケーション内のモジュールで、BEAMモジュールのバイナリ内容を組み立てるために使われます。

この符号化方式の主な目的はコンパクトさです。単純なタームをより少ないビット数に詰め込むことで効率よく符号化します。復号すると、これらの圧縮されたタームは標準的なマシンワードまたはErlangタームへと展開されます。

タグフォーマットは次のとおりです。ここには最近の変更(特にOTP 20とOTP 25のもの)も反映されています。

タグフォーマットの一覧。上位5ビットが使われない場合は空欄、下位3ビットがタグを表す。拡張タグ(111)ではさらに上位5ビットが種別を示す 図1: コンパクトターム符号化のタグフォーマット一覧

OTP 20以降、このタグフォーマットは変更され、Extended - Float はなくなりました。それ以降のタグ値はすべて1つずつ繰り下がっています。リストは2#10111、fpregは2#100111、アロケーションリストは2#110111、リテラルは2#1010111です。浮動小数点値は、現在ではBEAMファイルのリテラル領域に直接格納されるようになりました。

先頭バイトの最初の3ビットには、後続の値の型を示すタグが格納されます。この3ビットがすべて1(特殊な値7、beam_opcodes.hrl?tag_z)の場合は、さらに数ビットが使われます。

16未満の値は、ビット3を0にした上で、ビット4〜7にそのまま格納されます。

16未満の値のビットレイアウト。上位4ビットに値、ビット3は0、下位3ビットにタグを配置する 図2: 16未満の値のビットレイアウト

2048(16#800)未満の値では、ビット3が1にセットされます。これは継続バイトが1つ使われることを示し、値の上位3ビットはこのバイトのビット5〜7に延びます。

2048未満の値のビットレイアウト。継続バイトを使うことを示すビット3と4に01を置く 図3: 2048未満の値のビットレイアウト(継続バイト1つ)

さらに大きな値や負の値は、まずバイト列に変換されます。値が2〜8バイトに収まる場合、ビット3と4が1にセットされ、ビット5〜7には値のバイト数から2を引いたサイズが格納され、続けて値本体が置かれます。

2〜8バイトの値のビットレイアウト。ビット3と4に11を置き、上位3ビットにバイト数-2を格納する 図4: 2〜8バイトの値のビットレイアウト

値が8バイトを超える場合は、ビット3〜7がすべて1にセットされます。続けて、バイト数から9を引いた値をネストされた符号なしリテラル(beam_opcodes.hrl のマクロ ?tag_u)として符号化したものが置かれ、そのあとにデータ本体が続きます。

8バイトを超える値の符号化。1バイト目のビット3から7をすべて1にし、続けてネストされたリテラル(Size-9)、そのあとにデータ本体が続く 図5: 8バイトを超える値の符号化(ネストされたリテラルによるサイズ指定)

タグの種類

コンパクトターム形式を読み取る際、得られる整数は Tag の値によって解釈のされ方が変わります。

  • リテラルの場合、値はリテラルテーブル内のインデックスです。
  • アトムの場合、値はアトムインデックスから1を引いたものです。値が0の場合は NIL(空リスト)を意味します。
  • ラベルの場合、0は無効な値を意味します。
  • タグが文字の場合、値は符号なしのUnicodeコードポイントです。
  • タグExtended Listは、タームの組を含みます。Sizeを読み、Size要素のタプルを作り、そこにSize/2個の組を読み込みます。各組はValueLabelから成り、Valueは比較対象のターム、Labelは一致した場合のジャンプ先です。これはselect_val命令で使われます。
型ヒントの追加により、コンパイラとJITがより緊密に連携できるようになり、OTP 25以降ではランタイム性能がより最適化されています。

符号化の詳細については、compiler アプリケーションの beam_asm:encode/2 を参照してください。タグの値はこの節で示したものですが、beam_makeops によって生成される compiler/src/beam_opcodes.hrl でも確認できます。