第5章 最小サンプル main のアセンブリを読む

5.1 この章で学ぶこと

  • リスト5-2(hello_mojo_minimal.asm)の main が、スタック上で何をしているか

  • 文字列オブジェクトの組み立て、print 呼び出し、エラー/例外まわりの分岐、参照カウントと解放の流れ

第1章 Mojo とは何か・位置づけ では、ソース(リスト1-1)を示し 「見た目の違い」 に触れました。 この章では、そのコンパイル結果(リスト5-2)を手がかりに、hello_mojo_minimal::main() の機械語を追います。

注釈

本章で示す逆アセンブルは、Mojo 1.0.0b1 / x86-64 / 本書のビルド条件(--no-optimization など)における観測例です。 コンパイラのバージョン、ターゲット、最適化レベルが変われば、生成される機械語も変わり得ます。 ここでの目的は「この条件で Mojo が何を生成したか」を読み解くことであり、言語仕様そのものの定義ではありません。

5.2 アセンブリ出力の取得方法

本章で読むアセンブリ(リスト5-2)は、次の 2 ステップで生成します。

ステップ 1:オブジェクトファイルを生成する

mojo build -g --no-optimization --debug-info-language=C \
  --emit object --target-triple x86_64-apple-macosx15.0 \
  hello_mojo_minimal.mojo

フラグ

意味

-g

デバッグ情報を付与(シンボル名を読みやすくする)

--no-optimization

最適化を無効化(コンパイラの変換を最小限にする)

--emit object

実行ファイルではなくオブジェクトファイル(.o)を出力

--target-triple x86_64-apple-macosx15.0

x86-64 向けにクロスコンパイル(Apple Silicon Mac でも x86 の機械語を出力できる)

ステップ 2:逆アセンブルする

llvm-objdump を使い、main() 関数のみを抽出して出力します。

llvm-objdump --macho -S -C --disassembler-color=off \
  --dis-symname "_hello_mojo_minimal::main()" \
  hello_mojo_minimal.o

-S でソース行を混在、-C でシンボルを demangle します。出力は AT&T 記法です。

x86-64 向けにクロスコンパイルする理由

本書の環境は Apple Silicon(ARM64)の Mac です。ARM64 ネイティブでコンパイルすると AArch64 の命令が出力されます。解説のターゲットである x86-64 の命令列を得るために、--target-triple で x86-64 を明示しています。実際に動かすバイナリを作るときは、このフラグは不要です。

5.3 この章の3つのポイント

アセンブリの細部に入る前に、この章で確認したい 3 つのことを先に示します。

  1. 文字列オブジェクトはスタック上に組み立てられる
    Python のように全てがヒープのオブジェクトになるのではなく、スタック上にサイズ、ポインタ、フラグをレイアウトした構造体として組まれます。

  2. エラー状態は戻り値のビットフラグで表される
    print() の結果ワードに特定ビット(0x4000...)が立っているかをチェックして、エラーあり/なしを判定しています。Mojo の例外処理が機械語レベルでどう実装されるかの一例です。

  3. 参照カウントのデクリメントはアトミック操作で行われる
    lock xaddq によって、マルチスレッド下でも安全に参照数を操作します。シンプルな print("Hello, Mojo") 一行でも、Mojo のメモリ安全の仕組みが機械語に反映されています。

以降は、この 3 点を念頭に置きながら、実際の命令列を確認します。

src/part1/ch01/hello_mojo_minimal.o:
(__TEXT,__text) section
_hello_mojo_minimal::main():
; def main():
       0:	48 81 ec 88 00 00 00	subq	$0x88, %rsp
;     print("Hello, Mojo")
       7:	48 c7 44 24 28 0b 00 00 00	movq	$0xb, 0x28(%rsp)
      10:	48 8d 44 24 20	leaq	0x20(%rsp), %rax
      15:	48 89 44 24 18	movq	%rax, 0x18(%rsp)
      1a:	48 8d 05 00 00 00 00	leaq	_static_string_359aaa639d270c60(%rip), %rax
      21:	48 89 44 24 38	movq	%rax, 0x38(%rsp)
      26:	48 8d 05 00 00 00 00	leaq	_static_string_359aaa639d270c60(%rip), %rax
      2d:	48 89 44 24 40	movq	%rax, 0x40(%rsp)
      32:	48 8d 05 00 00 00 00	leaq	_static_string_359aaa639d270c60(%rip), %rax
      39:	48 89 44 24 20	movq	%rax, 0x20(%rsp)
      3e:	48 b8 00 00 00 00 00 00 00 20	movabsq	$0x2000000000000000, %rax
      48:	48 89 44 24 30	movq	%rax, 0x30(%rsp)
      4d:	48 89 e0	movq	%rsp, %rax
      50:	48 c7 00 01 00 00 00	movq	$0x1, "_hello_mojo_minimal::main()"(%rax)
      57:	48 8d 35 00 00 00 00	leaq	_static_string_a8d4ace0dc8d360e(%rip), %rsi
      5e:	48 8d 0d 00 00 00 00	leaq	_static_string_bbe01a6a523daf15(%rip), %rcx
      65:	48 8d 7c 24 20	leaq	0x20(%rsp), %rdi
      6a:	41 b8 01 00 00 00	movl	$0x1, %r8d
      70:	45 31 c9	xorl	%r9d, %r9d
      73:	4c 89 c2	movq	%r8, %rdx
      76:	e8 00 00 00 00	callq	"_std::io::io::print[KGENParamList[::Writable],*::Writable,LITImmutOrigin,::Origin[::Bool(False), $2]](*$0,sep:::StringSlice[::Bool(False), StaticConstantOrigin, *?],end:::StringSlice[::Bool(False), StaticConstantOrigin, *?],flush:::Bool,file:::FileDescriptor$),Ts.values`=[[typevalue<#kgen.instref<\"std::collections::string::string::String\">>, struct<(pointer<none>, index, index) memoryOnly>]]"
      7b:	48 b8 00 00 00 00 00 00 00 40	movabsq	$0x4000000000000000, %rax
      85:	48 23 44 24 30	andq	0x30(%rsp), %rax
      8a:	48 83 f8 00	cmpq	$0x0, %rax
      8e:	74 73	je	0x103
      90:	48 8b 44 24 18	movq	0x18(%rsp), %rax
;     print("Hello, Mojo")
      95:	48 8b 00	movq	"_hello_mojo_minimal::main()"(%rax), %rax
      98:	90	nop
      99:	48 89 44 24 68	movq	%rax, 0x68(%rsp)
      9e:	48 83 c0 f8	addq	$-0x8, %rax
      a2:	48 89 44 24 10	movq	%rax, 0x10(%rsp)
      a7:	48 89 44 24 48	movq	%rax, 0x48(%rsp)
;     print("Hello, Mojo")
      ac:	48 89 44 24 50	movq	%rax, 0x50(%rsp)
      b1:	90	nop
      b2:	48 8b 4c 24 10	movq	0x10(%rsp), %rcx
      b7:	48 89 4c 24 58	movq	%rcx, 0x58(%rsp)
      bc:	48 89 4c 24 60	movq	%rcx, 0x60(%rsp)
      c1:	48 c7 c0 ff ff ff ff	movq	$-0x1, %rax
      c8:	f0	lock
      c9:	48 0f c1 01	xaddq	%rax, "_hello_mojo_minimal::main()"(%rcx)
      cd:	48 89 44 24 70	movq	%rax, 0x70(%rsp)
      d2:	48 89 44 24 08	movq	%rax, 0x8(%rsp)
      d7:	48 8b 44 24 08	movq	0x8(%rsp), %rax
;     print("Hello, Mojo")
      dc:	48 83 f8 01	cmpq	$0x1, %rax
      e0:	75 1d	jne	0xff
      e2:	eb 00	jmp	0xe4
      e4:	48 8b 7c 24 10	movq	0x10(%rsp), %rdi
;     print("Hello, Mojo")
      e9:	90	nop
      ea:	48 89 7c 24 78	movq	%rdi, 0x78(%rsp)
      ef:	90	nop
      f0:	48 89 bc 24 80 00 00 00	movq	%rdi, 0x80(%rsp)
      f8:	e8 00 00 00 00	callq	_KGEN_CompilerRT_AlignedFree
;     print("Hello, Mojo")
      fd:	eb 02	jmp	0x101
      ff:	eb 00	jmp	0x101
     101:	eb 02	jmp	0x105
     103:	eb 00	jmp	0x105
; def main():
     105:	48 81 c4 88 00 00 00	addq	$0x88, %rsp
     10c:	c3	retq
     10d:	0f 1f 00	nopl	"_hello_mojo_minimal::main()"(%rax)

リスト5-2: hello_mojo_minimal.asm

ここに載せるニモニックは、リスト5-2 と同じく AT&T 記法(objdump の出力)です。 Mojo や LLVM のバージョンによって細部は変わり得ますが、上記のダンプを前提に読み進めてください。

5.4 関数プロローグ

スタックに 136 バイトを確保し、ローカル変数や一時データの置き場を作ります。

命令の詳細

0: subq $0x88, %rsp

5.4.1 スタックへの変数割り付けの仕組み

コンパイラに不慣れな読者向けに、「なぜ関数の先頭で一括してメモリを確保するのか」を補足します。

スタックとは何か

スタックは「後入れ先出し(LIFO)」のメモリ領域です。CPU には rsp(スタックポインタ)というレジスタがあり、「スタックの現在の先端」を指し示しています。x86-64 ではスタックはアドレスが減る方向に伸びます。先端に積み上げるほどアドレスが小さくなります。

subq $0x88, %rsp が何をするか

rsp の値を 0x88(= 136)だけ減算する

これにより、rsp の指す位置から 136 バイト分の空き領域が確保されます。 この領域が main() 関数のローカル変数と一時データの置き場(スタックフレーム)になります。

なぜ先頭でまとめて確保するか

「変数を使う直前に少しずつ確保する」のではなく、コンパイル時にすべての変数のサイズと配置が確定するため、まとめて一命令で確保できますsubq 一つで 136 バイトをまとめて押さえるほうが、何度も rsp を動かすより効率的です。

確保した領域は %rsp + オフセット で参照します。 たとえば 0x18(%rsp) は「rsp から 24 バイト先のスロット」を指します。 各変数のオフセットはコンパイラが静的に決定しており、実行時に計算するコストはゼロです。

関数終了時の解放

関数が終わるとき(エピローグ)に addq $0x88, %rsprsp を元に戻します。 これだけでフレーム全体が「解放」されます。 実際にはメモリを消しているのではなく、単にポインタを戻しているだけです。

main() 関数のスタックフレームレイアウト

図5-1: main() 関数のスタックフレームレイアウト

subq $0x88, %rsp で rsp が 136 バイト下がり、0x00〜0x88 の範囲がフレームとして確保される。 各スロットのオフセットはコンパイル時に確定しており、実行時の計算コストはない。

5.5 文字列オブジェクトの構築

5.5.1 Python との違い:文字列はどこに置かれるか

Python では "Hello, Mojo" と書くと、ヒープ上にオブジェクトが確保され、変数はそのオブジェクトへの参照(ポインタ)を持ちます。 文字データ自体もヒープに置かれるため、関数をまたいでもオブジェクトは生き続けます。

Mojo ではこの文字列が スタック上の構造体として組み立てられます。 文字データそのものはバイナリの静的領域(.rodata)に置かれ、構造体はそこへのポインタを持ちます。 スタック上の構造体はフレームが終われば自動的に消えます。

5.5.2 構造体のフィールド構成

Mojo の String はスタック上に次のフィールドを並べた構造体として表現されます。

表5-1: 構造体のフィールド構成

オフセット

内容

役割

0x18(%rsp)

自己ポインタ

構造体先頭(0x20(%rsp))を指します。エラー処理時に構造体の位置を特定するために使われます

0x20(%rsp)

文字列データポインタ

.rodata 上の "Hello, Mojo" の先頭を指します(_static_string_...

0x28(%rsp)

文字列長

0xb(= 11)。"Hello, Mojo" のバイト数です

0x30(%rsp)

タグ/フラグビット

0x2000000000000000。参照カウント管理用のフラグです(実装依存)

0x38(%rsp)

ポインタコピー 1

print() への引数渡し用のポインタ複製です

0x40(%rsp)

ポインタコピー 2

同上(複数の引数スロットに対応します)

5.5.3 静的文字列の参照方法

"Hello, Mojo" の実体はコンパイル時にバイナリの .rodata セクションへ埋め込まれます。 leaq _static_string_...(%rip), %raxRIP 相対アドレッシング で、「現在の命令ポインタ(%rip)からのオフセット」でその位置を求めます。 アドレスは実行時に決まるため即値で書けませんが、leaq 一命令で効率よく取得できます。

5.5.4 なぜポインタが複数あるのか

同じ _static_string_... へのポインタが 0x200x380x40 の 3 か所に書かれます。 Mojo の print() はジェネリック関数であり、内部で複数の引数スロット(本体とディスパッチ用コピー)を別々に受け取る設計になっているためです。 同じアドレスを指しますが、構造体のレイアウト上で異なるフィールドに格納されます。

命令の詳細

7:  movq $0xb, 0x28(%rsp)    ; 文字列長 = 11(0xb)をスタックへ書き込む

10: leaq 0x20(%rsp), %rax    ; 構造体先頭アドレスを rax へ
15: movq %rax, 0x18(%rsp)    ; 自己ポインタとして保存

1a: leaq _static_string_359aaa639d270c60(%rip), %rax
21: movq %rax, 0x38(%rsp)    ; ポインタコピー 1(ディスパッチ用)

26: leaq _static_string_359aaa639d270c60(%rip), %rax
2d: movq %rax, 0x40(%rsp)    ; ポインタコピー 2(ディスパッチ用)

32: leaq _static_string_359aaa639d270c60(%rip), %rax
39: movq %rax, 0x20(%rsp)    ; 文字列データポインタ("Hello, Mojo" の実体)

3e: movabsq $0x2000000000000000, %rax
48: movq %rax, 0x30(%rsp)    ; タグ/フラグビット
文字列オブジェクトの構築

図5-2: 文字列オブジェクトの構築

左のスタックフレーム上に各フィールドが並び、0x20(%rsp) の文字列データポインタが右の静的文字列領域(.rodata)の "Hello, Mojo" を指しています。 0x18(%rsp) の自己ポインタは構造体先頭(0x20(%rsp))を指しています。

5.6 参照カウントの初期化

値として扱う型でも、ランタイム側で参照カウントを伴う経路が生成されることがあります。 ここでは refcount を 1 で初期化しています。

命令の詳細

4d: movq %rsp, %rax
50: movq $0x1, "_hello_mojo_minimal::main()"(%rax)  ; スタック先頭に refcount = 1 をセット

コラム: スタック上のオブジェクトになぜ参照カウントが必要か

「スタック上の構造体は関数のエピローグで自動的に消えるのだから、参照カウントは不要では?」と思うかもしれません。

答えは「スタック上の構造体が所有している資源」と「構造体そのものが占めるスタック領域」を区別することで見えてきます。

String 構造体はスタックに置かれますが、その構造体が指す文字データは静的領域(.rodata)やヒープに置かれることがあります。 動的に生成した文字列であればヒープバッファを所有しており、構造体が消えるときにそのバッファも解放しなければなりません。 この「最後の所有者だけがバッファを解放する」判断を、参照カウントで行います。 今回の "Hello, Mojo" は静的文字列なので実際には解放不要ですが、それは実行時には判断できないため、同じ経路を通ります。

もうひとつの理由は正常パスとエラーパスの非対称性です。

  • 正常パス:コンパイラはコピーが発生していないと証明できるため、参照カウントを確認せずエピローグの addq $0x88, %rsp だけで完了します。

  • エラーパス:例外やアンワインドが起きると、コンパイラではなくランタイム(アンワインダ)が制御を引き継ぎます。「誰かがまだこのオブジェクトへの参照を持っているか」をコンパイラが静的に判断できないため、参照カウントを lock xaddq でアトミックに確認してからデストラクタを呼ぶ必要があります。

要約すると、参照カウントは「スタック領域の解放」のためではなく、「オブジェクトが所有する資源のデストラクタを正しいタイミングで呼ぶ」ためにあります。

5.7 print() の呼び出し準備

5.7.1 ABI とは何か

ABI(Application Binary Interface) は「呼び出し元と呼び出し先が、どのレジスタとどの順番で引数をやり取りするか」を定めた取り決めです。

Python の場合、関数呼び出しの仕組みはインタープリタが管理するため、プログラマが意識することはありません。 しかし機械語レベルでは、callq 命令で別の関数へジャンプする前に、引数を決まったレジスタへセットしておく必要があります。 受け取り側の関数も「1 番目の引数は rdi に入っている」と決め打ちでコードを生成しているためです。 この取り決めが ABI です。

5.7.2 レジスタとは何か

レジスタは CPU 内部にある超高速な記憶場所です。 メモリ(RAM)へのアクセスに比べて数十〜数百倍速く読み書きできます。 rdirsirdxrcxr8r9 は x86-64 CPU の汎用レジスタで、それぞれ 64 ビット(8 バイト)の値を保持します。

5.7.3 System V AMD64 ABI の引数渡し規則

x86-64 Linux/macOS では System V AMD64 ABI が使われます。 整数とポインタ型の引数は、先頭から順に次のレジスタへ格納します。

表5-2: System V AMD64 ABI の引数渡し規則

引数の順番

レジスタ

備考

第 1 引数

rdi

最初の引数

第 2 引数

rsi

2 番目の引数

第 3 引数

rdx

3 番目の引数

第 4 引数

rcx

4 番目の引数

第 5 引数

r8

5 番目の引数

第 6 引数

r9

6 番目の引数

第 7 引数以降

スタック

レジスタが足りなければスタックへ積む

7 つ以上の引数がある場合はスタックに積みます。 今回の print() は 6 引数以内に収まるため、すべてレジスタで渡します。

5.7.4 print() の引数マッピング

Mojo の print("Hello, Mojo") が展開されると、print の内部シグネチャに応じた複数の引数がレジスタへセットされます。

表5-3: print() の引数マッピング

レジスタ

引数

内容

rdi

第 1 引数

文字列オブジェクトへのポインタ(0x20(%rsp))。印字する値の本体

rsi

第 2 引数

sep 文字列(区切り文字、デフォルトは空文字)

rdx

第 3 引数

要素数 = 1

rcx

第 4 引数

end 文字列(末尾文字、デフォルトは改行)

r8

第 5 引数

要素数コピー = 1

r9

第 6 引数

0(フラグ等)

命令の詳細

57: leaq _static_string_a8d4ace0dc8d360e(%rip), %rsi  ; rsi = sep 文字列
5e: leaq _static_string_bbe01a6a523daf15(%rip), %rcx  ; rcx = end 文字列
65: leaq 0x20(%rsp), %rdi    ; rdi = 文字列オブジェクトへのポインタ
6a: movl $0x1, %r8d          ; r8 = 1(要素数)
70: xorl %r9d, %r9d          ; r9 = 0
73: movq %r8, %rdx           ; rdx = r8 のコピー = 1
76: callq "_std::io::io::print[KGENParamList[::Writable],*::Writable,...]"

アセンブリ上の書き込み順(rsircxrdi → …)は引数番号の順と一致しません。 コンパイラはレジスタの依存関係を考慮して最適な順序で並べるため、命令の並び順と引数の順番は必ずしも一致しません。

System V AMD64 ABI による引数渡し

図5-3: System V AMD64 ABI による引数渡し

スタック上のデータ(文字列オブジェクト、sep、end)が、ABI の規則に従って rdir9 の各レジスタへセットされ、callq print[..] で呼び出しへ渡る流れです。

5.8 エラー/例外チェック(Mojo のエラー処理)

print() の戻り値ワードにエラー状態を表すビットが埋め込まれています。 0x4000000000000000 というマスクで特定ビットを取り出し、ゼロならエラーなしとしてエピローグへ、ビットが立っていればエラーありとしてデストラクタ処理へ進みます。

命令の詳細

7b: movabsq $0x4000000000000000, %rax
85: andq 0x30(%rsp), %rax    ; 先ほど保存したフラグビットと AND
8a: cmpq $0x0, %rax
8e: je 0x103                 ; フラグが立っていなければ後処理なしで終了側へ
callq print(...)戻り値ワードを受け取る(エラービット埋め込み)movabsq $0x4000000000000000, %rax(マスク値ロード)andq 0x30(%rsp), %rax(フラグビットと AND)ビットが立っているか?(cmpq $0x0, %rax)エピローグへ(addq $0x88, %rsp → retq)5.9 節へ — 参照カウントをデクリメント → デストラクタ ゼロ(エラーなし) je 0x103非ゼロ(エラーあり) フォールスルー

5.9 エラーありの場合:参照カウントのアトミックデクリメント

エラーが発生した場合、文字列オブジェクトの参照カウントを lock xaddq でアトミックにデクリメントします。 旧カウントが 1(自分が最後の所有者)だった場合のみ、デストラクタ呼び出しへ進みます。

命令の詳細

90: movq 0x18(%rsp), %rax    ; 文字列オブジェクトへのポインタ
95: movq "_hello_mojo_minimal::main()"(%rax), %rax
98: nop
99: movq %rax, 0x68(%rsp)
9e: addq $-0x8, %rax         ; ポインタを 8 戻す(実際の refcount の位置)
...
b2: movq 0x10(%rsp), %rcx   ; rcx = refcount へのポインタ
...
c1: movq $-0x1, %rax
c8: lock
c9: xaddq %rax, "_hello_mojo_minimal::main()"(%rcx)  ; アトミックに refcount -= 1(旧値を rax へ)
...
dc: cmpq $0x1, %rax          ; 旧 refcount が 1 だったか?
e0: jne 0xff                 ; 1 でなければ他に参照があるので解放不要へ
e2: jmp 0xe4                 ; 1 なら解放処理へ

e4: movq 0x10(%rsp), %rdi    ; 解放対象のポインタを rdi へ
...
f8: callq _KGEN_CompilerRT_AlignedFree

コラム: スタック上の文字列になぜデストラクタが必要か

「文字列オブジェクトはスタック上にあるのだから、エラー時もエピローグの addq $0x88, %rsp で自動的に消えるのでは?」という疑問が生じます。

ここで大切なのは、スタック上の構造体はあくまで「ヘッダ(管理情報)」であり、その構造体が所有するリソースは別の場所にあるという点です。

String 型は静的文字列(.rodata)だけでなく、動的に確保したヒープバッファも保持できるように設計されています。 たとえば String("hello") + String(" world") のように実行時に文字列を結合すると、結果はヒープに置かれます。 このとき、スタック上のヘッダが消えるだけではヒープバッファが解放されずリークになります

デストラクタ(_KGEN_CompilerRT_AlignedFree)を呼ぶのは「ヒープ上のリソースを解放する」ためであり、スタックフレームを解放するためではありません。 今回の "Hello, Mojo".rodata の静的文字列なので実際には解放対象がありませんが、コンパイラはすべての String に対して同じクリーンアップ経路を生成します。

参照カウントのデクリメントが先に行われるのも重要です。 print() の内部でこの文字列への参照が増えていた場合(refcount > 1)、デクリメント後もまだ参照があるため、デストラクタを呼んではいけません。 「自分が最後の所有者か(旧 refcount が 1 だったか)」を確認してからはじめてデストラクタを呼ぶことで、二重解放を防ぎます。

5.10 ランディングパッドと合流

この節について

この節で扱うランディングパッドは、例外処理を実装するためのコンパイラ機構であり、Mojo 独自のものではありません。 C++ や Rust でも LLVM を経由する場合は同様のコードが生成されます。 Mojo の動作を理解するうえでは、この細部を深追いする必要はなく、「コンパイラが後片付け用の骨格を差し込む」という概念だけ掴めれば十分です。 アセンブリのしくみに興味がある方向けの補足として読んでください。

callq(デストラクタ/解放に相当する呼び出し)の直後に、4 本の短い jmp が並んでいます。 リスト5-2 では次のとおりです。

fd: eb 02    jmp 0x101
ff: eb 00    jmp 0x101
101: eb 02   jmp 0x105
103: eb 00   jmp 0x105

これらは ランディングパッド(landing pad)/例外処理用のスタブ としてコンパイラが差し込んだコードです。

Mojo は LLVM を基盤にしているため、C++ と同様に LLVM の例外処理機構(Windows では SEH、ELF 系では DWARF unwinding など、ターゲットに応じたアンワインド情報)に沿ったコードが生成されます。 callq で呼んだ関数が、例外、パニック、スタック展開時のクリーンアップを要する場合に、「制御がどのラベルへ戻るか」をアンワインダが解析できるよう、合流点までの骨格が置かれます。

構造としては次のように読めます。

  • fdff … 直前の callq が正常終了したあとにフォールスルーしてくる経路。いずれも 0x101 へジャンプする。

  • 101103 … さらに追加の後処理なしで、関数エピローグの 0x105addqretq)へジャンプする。

eb 02eb 00 がペアで現れるのは、2 バイト境界へのアライメント調整と、unwinder がどちらのエッジから来ても同じオフセットの合流点へ到達できるようにするための、いわば冗長なジャンプ列です。

これらはリンク時に特別なシンボル解決で「意味が変わる」というより、コンパイル時に LLVM が生成した例外処理とクリーンアップパスの骨組みです。 この最小サンプルでは実質的には何もせずエピローグへ流れるだけの経路になっており、リスト上は短い jmp の並びに見えます。

ランディングパッドを素朴に理解する

5.10.1 例外とは

実行中に、確保に失敗する、前提が崩れるなど、呼び出し元がその場で処理しきれない事態が起きることがあります。

main()
  └→ print()
       └→ (内部でメモリ確保やランタイム呼び出し)
            └→ 「続行できない」← ここでエラー

このとき、どの関数から順に片付けをしてから実行を止めるかを決める必要があります。

5.10.2 呼び出しの積み重ねとスタック

関数は入れ子になって呼ばれます。

main() が print() を呼ぶ
  print() が(内部で)さらに別の関数を呼ぶ
    … いちばん深いところでエラー発生

スタックで表すと、いま実行中のフレームが上に積まれます。

関数のコールスタックとアンワインド方向

図5-4: 関数のコールスタックとアンワインド方向

深い側の関数(deep function)でエラーが発生すると、print()main() の順に巻き戻しながら各フレームの後片付けを行う。 右矢印がアンワインド方向を示す。

エラーが起きたあと、この積み重ねを外側(呼び出し元)へ向かって順に巻き戻す処理が スタックアンワインド(stack unwinding) です。

5.10.3 なぜ巻き戻しが必要か

各フレームは、オブジェクトやバッファなど解放が必要な状態を持っていることがあります。 エラーで途中打ち切りにするとリークや不整合が残るため、各段で「後片付け」を挟んでから外へ戻る必要があります。

5.10.4 ランディングパッドの役割

アンワインド時、コンパイラは「この呼び出しに対応する片付けの入口はここだ」という情報を機械語に埋め込みます。 その入口に相当するのが ランディングパッド(landing pad) です。 通常の成功経路では通らず、例外やランタイムが「ここへ一旦着陸して片付けろ」と指示したときに使われる、後片付けコードへの玄関だと捉えるとよいでしょう。

5.10.5 リスト5-2 で見る位置づけ

概念の説明では「任意の callq の直後/近傍」と考えてかまいません。 いま引用している fd103 は、リスト5-2 では f8: callq(デストラクタ/解放に相当する呼び出し)の直後から始まります。 別の callq(たとえば print 本体)のまわりにも、LLVM は同様の骨組みを付けることがあります。

f8: callq …   ← ここから戻ってきた直後が fd
fd: jmp 0x101
ff: jmp 0x101
101: jmp 0x105
103: jmp 0x105

アンワインドが発生すると、ランタイム側(アンワインダ等)が「この callq に対応するランディングパッドはどこか」を DWARF 等の情報から調べ、適切な fd 付近へ制御を移すことがあります。 そのあと後片付け(参照カウントや解放など)を経て、0x105 のエピローグへ合流します。

5.10.6 なぜ jmp が複数並ぶのか

LLVM は正常に callq から戻ってきた場合とアンワインドで「着陸」した場合の入口の形をそろえつつ、最終的に同じエピローグへ合流できるように、短い jmp を並べます。 見かけ上は

  • パスAcallq が成功して戻り、機械語としては fd 側から 0x1010x105 へ進む

  • パスB … アンワインドで別エッジから合流点へ入り、同様に 0x105 へ向かう

といった二系統の入口を、同じオフセットに収束させるために、eb 02eb 00 で 2 バイト単位のアライメントを保ちながら冗長なジャンプを挟む、というイディオムが現れます(詳細は命令列ごとに異なります)。

eb 02 は相対ジャンプで 2 バイト先へ飛び、eb 00 は次の命令へそのまま進む(相対 0)という形で、アライメントを保ちつつ二つの入口を作る LLVM のパターンとして説明されることがあります。

5.10.7 用語の整理

用語

意味

例外

実行中に発生し、通常の戻り値だけでは処理しきれないエラーなどの事象

スタックアンワインド

エラー後に、内側のフレームから外側へ向かって順に巻き戻す処理

ランディングパッド

各フレームまわりの 後片付けコードへの入口(LLVM が生成する例外処理の骨格の一部)

print("Hello, Mojo") のように見える一行でも、内部でオブジェクトやランタイム呼び出しが伴うため、ソース上は何も書いていなくても LLVM がこのようなスタブを生成します。 Mojo の安全なメモリ管理や例外処理が、機械語側で自動的に組み込まれる一例です。

5.11 関数エピローグ

プロローグで「スタックフレームを確保して関数の準備を整えた」のに対し、エピローグは「フレームを解放して呼び出し元へ戻る」後始末です。 命令は 2 つだけです。

105: addq $0x88, %rsp        ; スタック解放(プロローグの逆、0x88 = 136 バイト)
10c: retq                    ; 呼び出し元へ戻る
10d: nopl "_hello_mojo_minimal::main()"(%rax)  ; アライメント用パディング(次の関数を 16 バイト境界へ)

5.11.1 プロローグとエピローグの対比

関数プロローグとエピローグで対になる命令を対比させて確認します。

表5-4: プロローグとエピローグの対比

命令

プロローグ(5.4 節)

エピローグ(本節)

スタック操作

subq $0x88, %rsp

addq $0x88, %rsp

効果

rsp を 136 バイト減らしてフレームを確保

rsp を 136 バイト増やしてフレームを解放

次の命令

変数の初期化と引数の準備へ

retq で呼び出し元へ戻る

メモリの実態

領域の内容は変えない(rsp を動かすだけ)

領域の内容は変えない(rsp を元に戻すだけ)

5.11.2 addq $0x88, %rsp が何をするか

プロローグの subq $0x88, %rsprsp を 136 減らしてフレームを「押さえた」のと正反対に、addq $0x88, %rsprsp を 136 増やして同じ領域を「手放します」。 実際にはメモリを消去したり返却したりするわけではなく、「この範囲はもう使っていない」とポインタを戻すだけです。

次の関数がスタックを使うときにはこの領域が上書きされます。 これが「スタックは高速」と言われる理由のひとつで、malloc/free のようなヒープ管理コストがゼロです。

5.11.3 retq が何をするか

retq は次の 2 つを一命令で行います。

  1. スタックの先頭(現在の rsp が指す位置)から 戻りアドレスを取り出す

  2. その戻りアドレスへ ジャンプするrsp は 8 バイト増える)

戻りアドレスは callq 命令が呼び出し元側で積んでおいたものです。 callqretq はこのアドレスのプッシュ/ポップで対になっています。

5.11.4 retq の直後の nopl について

noplアライメント用の NOP(パディング) です。 目的は、次の関数の先頭アドレスを特定のバイト境界(多くの場合 16 バイト)に整えることにあります。

このダンプでは 0x10d の次のアドレスが 0x110 になり、ちょうど 16 バイト境界になります。 x86-64 では関数エントリを 16 バイト境界に整えると、命令フェッチや一部マイクロアーキテクチャ上で有利になることがあります。

バイト列 0f 1f 00マルチバイト NOP(Intel が推奨する形式の長い NOP)です。 単に 90(1 バイト NOP)を 3 つ並べるより、パイプラインへの影響が小さいという理由でコンパイラが選ぶことがあります。

retq のあとに続く命令は実行されません。 制御はすでに呼び出し元へ戻っているため、nopl は動作の正しさではなく、直後に配置される次関数のためのレイアウトのために挿入されています。

5.11.5 まとめ(ランディングパッド列と nopl

箇所

目的

fd103 の 4 つの jmp

LLVM 例外処理機構に沿ったランディングパッドの骨格(このケースでは実質的にエピローグへ合流するだけ)

10dnopl

次の関数を 16 バイト境界に整えるためのパディング(retq 後は未実行)

5.12 全体の流れ(まとめ)

ここまで読んできた main() の各ステップを、実行フローとしてまとめます。 スタック確保から始まり、文字列オブジェクトの構築、参照カウント初期化、print() 呼び出し、エラーフラグチェックを経て、正常パス(左)とエラーパス(右)に分岐し、最後にスタックを解放して返る流れを確認してください。

main() 関数の実行フロー

図5-5: main() 関数の実行フロー

単純な print("Hello, Mojo") でも、参照カウントの初期化、アトミックな減算、条件付き解放まで機械語に落ちていることが、このダンプから読み取れます。 Mojo が メモリ安全 を機械側でどう支えているかの一端だと捉えるとよいでしょう。