第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.0 / 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
フラグ |
意味 |
|---|---|
|
デバッグ情報を付与(シンボル名を読みやすくする) |
|
最適化を無効化(コンパイラの変換を最小限にする) |
|
実行ファイルではなくオブジェクトファイル( |
|
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 つのことを先に示します。
文字列オブジェクトはスタック上に組み立てられる
Python のように全てがヒープのオブジェクトになるのではなく、スタック上にサイズ、ポインタ、フラグをレイアウトした構造体として組まれます。エラー状態は戻り値のビットフラグで表される
print()の結果ワードに特定ビット(0x4000...)が立っているかをチェックして、エラーあり/なしを判定しています。Mojo の例外処理が機械語レベルでどう実装されるかの一例です。参照カウントのデクリメントはアトミック操作で行われる
lock xaddqによって、マルチスレッド下でも安全に参照数を操作します。シンプルなprint("Hello, Mojo")一行でも、Mojo のメモリ安全の仕組みが機械語に反映されています。
以降は、この 3 点を念頭に置きながら、実際の命令列を確認します。
hello_mojo_minimal.o:
(__TEXT,__text) section
_hello_mojo_minimal::main():
; def main():
0: 48 81 ec 28 01 00 00 subq $0x128, %rsp
; print("Hello, Mojo")
7: 48 8d 44 24 60 leaq 0x60(%rsp), %rax
c: 48 89 44 24 40 movq %rax, 0x40(%rsp)
11: 48 c7 44 24 60 0b 00 00 00 movq $0xb, 0x60(%rsp)
1a: 48 8d 44 24 58 leaq 0x58(%rsp), %rax
1f: 48 89 44 24 48 movq %rax, 0x48(%rsp)
24: 48 8d 05 00 00 00 00 leaq _static_string_359aaa639d270c60(%rip), %rax
2b: 48 89 44 24 70 movq %rax, 0x70(%rsp)
30: 48 89 44 24 78 movq %rax, 0x78(%rsp)
35: 48 89 44 24 58 movq %rax, 0x58(%rsp)
3a: 48 8d 44 24 68 leaq 0x68(%rsp), %rax
3f: 48 89 44 24 50 movq %rax, 0x50(%rsp)
44: 48 b8 00 00 00 00 00 00 00 20 movabsq $0x2000000000000000, %rax
4e: 48 89 44 24 68 movq %rax, 0x68(%rsp)
53: 90 nop
54: 90 nop
55: 48 8d 05 00 00 00 00 leaq _static_string_a8d4ace0dc8d360e(%rip), %rax
5c: 48 89 84 24 b8 00 00 00 movq %rax, 0xb8(%rsp)
64: 48 c7 84 24 c0 00 00 00 01 00 00 00 movq $0x1, 0xc0(%rsp)
70: 48 c7 84 24 c8 00 00 00 01 00 00 00 movq $0x1, 0xc8(%rsp)
7c: 48 89 84 24 80 00 00 00 movq %rax, 0x80(%rsp)
84: 48 89 84 24 88 00 00 00 movq %rax, 0x88(%rsp)
8c: 48 89 84 24 d0 00 00 00 movq %rax, 0xd0(%rsp)
94: 48 89 84 24 d8 00 00 00 movq %rax, 0xd8(%rsp)
9c: 48 c7 84 24 e0 00 00 00 01 00 00 00 movq $0x1, 0xe0(%rsp)
; print("Hello, Mojo")
a8: 90 nop
a9: 90 nop
aa: 48 8d 05 00 00 00 00 leaq _static_string_bbe01a6a523daf15(%rip), %rax
b1: 48 89 84 24 b8 00 00 00 movq %rax, 0xb8(%rsp)
b9: 48 c7 84 24 c0 00 00 00 01 00 00 00 movq $0x1, 0xc0(%rsp)
c5: 48 c7 84 24 c8 00 00 00 01 00 00 00 movq $0x1, 0xc8(%rsp)
d1: 48 8d 05 00 00 00 00 leaq _static_string_bbe01a6a523daf15(%rip), %rax
d8: 48 89 84 24 90 00 00 00 movq %rax, 0x90(%rsp)
e0: 48 8d 05 00 00 00 00 leaq _static_string_bbe01a6a523daf15(%rip), %rax
e7: 48 89 84 24 98 00 00 00 movq %rax, 0x98(%rsp)
ef: 48 8d 05 00 00 00 00 leaq _static_string_bbe01a6a523daf15(%rip), %rax
f6: 48 89 84 24 d0 00 00 00 movq %rax, 0xd0(%rsp)
fe: 48 8d 05 00 00 00 00 leaq _static_string_bbe01a6a523daf15(%rip), %rax
105: 48 89 84 24 e8 00 00 00 movq %rax, 0xe8(%rsp)
10d: 48 c7 84 24 f0 00 00 00 01 00 00 00 movq $0x1, 0xf0(%rsp)
; print("Hello, Mojo")
119: 48 89 e0 movq %rsp, %rax
11c: 48 c7 00 01 00 00 00 movq $0x1, "_hello_mojo_minimal::main()"(%rax)
123: 48 8d 35 00 00 00 00 leaq _static_string_a8d4ace0dc8d360e(%rip), %rsi
12a: 48 8d 0d 00 00 00 00 leaq _static_string_bbe01a6a523daf15(%rip), %rcx
131: 48 8d 7c 24 58 leaq 0x58(%rsp), %rdi
136: 41 b8 01 00 00 00 movl $0x1, %r8d
13c: 45 31 c9 xorl %r9d, %r9d
13f: 4c 89 c2 movq %r8, %rdx
142: e8 00 00 00 00 callq "_std::io::io::print[KGENParamList[::Writable & ::AnyType],*::Writable & ::AnyType,LITImmOrigin,::Origin[::Bool(False), $2],::Bool,LITOrigin[$4._mlir_value],::Origin[$4, $5],::Bool,LITOrigin[$7._mlir_value],::Origin[$7, $8]](*$0,sep:::StringSpan[$4, $5, $6],end:::StringSpan[$7, $8, $9],flush:::Bool,file:::FileDescriptor$),Ts.values`=[[typevalue<#kgen.instref<\"std::collections::string::string::String\">>, struct<(pointer<none>, scalar<index>, scalar<index>) memoryOnly>]],sep.mut`4=false,end.mut`7=false"
147: 48 b8 00 00 00 00 00 00 00 40 movabsq $0x4000000000000000, %rax
151: 48 23 44 24 68 andq 0x68(%rsp), %rax
156: 48 83 f8 00 cmpq $0x0, %rax
15a: 0f 84 3e 01 00 00 je 0x29e
160: 48 8b 44 24 48 movq 0x48(%rsp), %rax
; print("Hello, Mojo")
165: 48 8b 00 movq "_hello_mojo_minimal::main()"(%rax), %rax
168: 48 83 c0 f8 addq $-0x8, %rax
16c: 48 89 44 24 38 movq %rax, 0x38(%rsp)
171: 48 89 84 24 a0 00 00 00 movq %rax, 0xa0(%rsp)
179: 48 89 84 24 a8 00 00 00 movq %rax, 0xa8(%rsp)
181: 90 nop
182: 48 8b 4c 24 38 movq 0x38(%rsp), %rcx
187: 48 89 8c 24 b0 00 00 00 movq %rcx, 0xb0(%rsp)
18f: 48 89 8c 24 f8 00 00 00 movq %rcx, 0xf8(%rsp)
197: 48 c7 c0 ff ff ff ff movq $-0x1, %rax
19e: f0 lock
19f: 48 0f c1 01 xaddq %rax, "_hello_mojo_minimal::main()"(%rcx)
1a3: 48 89 84 24 00 01 00 00 movq %rax, 0x100(%rsp)
1ab: 48 89 44 24 30 movq %rax, 0x30(%rsp)
1b0: 48 8b 44 24 30 movq 0x30(%rsp), %rax
; print("Hello, Mojo")
1b5: 48 83 f8 01 cmpq $0x1, %rax
1b9: 0f 85 db 00 00 00 jne 0x29a
1bf: eb 00 jmp 0x1c1
1c1: 48 8b 7c 24 38 movq 0x38(%rsp), %rdi
; print("Hello, Mojo")
1c6: e8 00 00 00 00 callq "_std::memory::alloc::ThinAllocation::__init__(unsafe_owned_ptr:::Pointer[::Bool(True), MutUntrackedOrigin, $0, *?, ::AddressSpace(::SIMDLength(0))]),T=[typevalue<#kgen.instref<\"std::builtin::simd::SIMD,dtype=ui8,length=1\">>, scalar<ui8>]"
1cb: 48 89 44 24 28 movq %rax, 0x28(%rsp)
1d0: 48 8b 4c 24 50 movq 0x50(%rsp), %rcx
; print("Hello, Mojo")
1d5: 48 b8 00 00 00 00 00 00 00 80 movabsq $-0x8000000000000000, %rax
1df: 48 23 01 andq "_hello_mojo_minimal::main()"(%rcx), %rax
1e2: 48 83 f8 00 cmpq $0x0, %rax
1e6: 74 0c je 0x1f4
1e8: b8 17 00 00 00 movl $0x17, %eax
1ed: 48 89 44 24 20 movq %rax, 0x20(%rsp)
1f2: eb 4b jmp 0x23f
1f4: 48 8b 4c 24 50 movq 0x50(%rsp), %rcx
; print("Hello, Mojo")
1f9: 48 b8 00 00 00 00 00 00 00 40 movabsq $0x4000000000000000, %rax
203: 48 23 01 andq "_hello_mojo_minimal::main()"(%rcx), %rax
206: 48 83 f8 00 cmpq $0x0, %rax
20a: 0f 95 c0 setne %al
20d: 34 ff xorb $-0x1, %al
20f: a8 01 testb $0x1, %al
211: 75 02 jne 0x215
213: eb 0f jmp 0x224
215: 48 8b 44 24 40 movq 0x40(%rsp), %rax
; print("Hello, Mojo")
21a: 48 8b 00 movq "_hello_mojo_minimal::main()"(%rax), %rax
21d: 48 89 44 24 18 movq %rax, 0x18(%rsp)
222: eb 11 jmp 0x235
224: 48 8b 44 24 50 movq 0x50(%rsp), %rax
; print("Hello, Mojo")
229: 48 8b 00 movq "_hello_mojo_minimal::main()"(%rax), %rax
22c: 48 c1 e0 03 shlq $0x3, %rax
230: 48 89 44 24 18 movq %rax, 0x18(%rsp)
235: 48 8b 44 24 18 movq 0x18(%rsp), %rax
23a: 48 89 44 24 20 movq %rax, 0x20(%rsp)
23f: 48 8b 44 24 20 movq 0x20(%rsp), %rax
244: 48 89 44 24 10 movq %rax, 0x10(%rsp)
249: 48 8b 7c 24 28 movq 0x28(%rsp), %rdi
24e: 48 8b 74 24 10 movq 0x10(%rsp), %rsi
; print("Hello, Mojo")
253: 48 83 c6 08 addq $0x8, %rsi
257: 48 89 f0 movq %rsi, %rax
25a: 90 nop
25b: 48 89 b4 24 08 01 00 00 movq %rsi, 0x108(%rsp)
263: 90 nop
; print("Hello, Mojo")
264: 48 89 b4 24 10 01 00 00 movq %rsi, 0x110(%rsp)
26c: ba 01 00 00 00 movl $0x1, %edx
; print("Hello, Mojo")
271: 48 89 84 24 18 01 00 00 movq %rax, 0x118(%rsp)
279: 48 c7 84 24 20 01 00 00 01 00 00 00 movq $0x1, 0x120(%rsp)
285: e8 00 00 00 00 callq "_std::memory::alloc::ThinAllocation::unsafe_with_layout(::ThinAllocation[$0]$,::Layout[$0]),T=[typevalue<#kgen.instref<\"std::builtin::simd::SIMD,dtype=ui8,length=1\">>, scalar<ui8>]"
28a: 48 89 c7 movq %rax, %rdi
28d: 48 89 d6 movq %rdx, %rsi
290: 48 89 ca movq %rcx, %rdx
293: e8 00 00 00 00 callq "_std::memory::alloc::dealloc[::AnyType](::Allocation[$0]$),T=[typevalue<#kgen.instref<\"std::builtin::simd::SIMD,dtype=ui8,length=1\">>, scalar<ui8>]"
298: eb 02 jmp 0x29c
29a: eb 00 jmp 0x29c
29c: eb 02 jmp 0x2a0
29e: eb 00 jmp 0x2a0
; def main():
2a0: 48 81 c4 28 01 00 00 addq $0x128, %rsp
2a7: c3 retq
2a8: 0f 1f 84 00 00 00 00 00 nopl "_hello_mojo_minimal::main()"(%rax,%rax)
リスト5-2: hello_mojo_minimal.asm
ここに載せるニモニックは、リスト5-2 と同じく AT&T 記法(objdump の出力)です。
Mojo や LLVM のバージョンによって細部は変わり得ますが、上記のダンプを前提に読み進めてください。
5.4 関数プロローグ
スタックに 296 バイトを確保し、ローカル変数や一時データの置き場を作ります。
命令の詳細
0: subq $0x128, %rsp
5.4.1 スタックへの変数割り付けの仕組み
コンパイラに不慣れな読者向けに、「なぜ関数の先頭で一括してメモリを確保するのか」を補足します。
スタックとは何か
スタックは「後入れ先出し(LIFO)」のメモリ領域です。CPU には rsp(スタックポインタ)というレジスタがあり、「スタックの現在の先端」を指し示しています。x86-64 ではスタックはアドレスが減る方向に伸びます。先端に積み上げるほどアドレスが小さくなります。
subq $0x128, %rsp が何をするか
rsp の値を 0x128(= 296)だけ減算する
これにより、rsp の指す位置から 296 バイト分の空き領域が確保されます。
この領域が main() 関数のローカル変数と一時データの置き場(スタックフレーム)になります。
なぜ先頭でまとめて確保するか
「変数を使う直前に少しずつ確保する」のではなく、コンパイル時にすべての変数のサイズと配置が確定するため、まとめて一命令で確保できます。
subq 一つで 296 バイトをまとめて押さえるほうが、何度も rsp を動かすより効率的です。
確保した領域は %rsp + オフセット で参照します。
たとえば 0x18(%rsp) は「rsp から 24 バイト先のスロット」を指します。
各変数のオフセットはコンパイラが静的に決定しており、実行時に計算するコストはゼロです。
関数終了時の解放
関数が終わるとき(エピローグ)に addq $0x128, %rsp で rsp を元に戻します。
これだけでフレーム全体が「解放」されます。
実際にはメモリを消しているのではなく、単にポインタを戻しているだけです。
図5-1: main() 関数のスタックフレームレイアウト
subq $0x128, %rsp で rsp が 296 バイト下がり、0x00〜0x128 の範囲がフレームとして確保される。
各スロットのオフセットはコンパイル時に確定しており、実行時の計算コストはない。
5.5 文字列オブジェクトの構築
5.5.1 Python との違い:文字列はどこに置かれるか
Python では "Hello, Mojo" と書くと、ヒープ上にオブジェクトが確保され、変数はそのオブジェクトへの参照(ポインタ)を持ちます。
文字データ自体もヒープに置かれるため、関数をまたいでもオブジェクトは生き続けます。
Mojo ではこの文字列が スタック上の構造体として組み立てられます。
文字データそのものはバイナリの静的領域(.rodata)に置かれ、構造体はそこへのポインタを持ちます。
スタック上の構造体はフレームが終われば自動的に消えます。
5.5.2 構造体のフィールド構成
Mojo の String はスタック上に次のフィールドを並べた構造体として表現されます。
オフセット |
内容 |
役割 |
|---|---|---|
|
文字列データポインタ |
|
|
文字列長 |
|
|
タグ/フラグビット |
|
|
自己ポインタ 1 |
|
|
自己ポインタ 2 |
|
|
自己ポインタ 3 |
|
|
ポインタコピー |
|
1.0.0 では旧バージョンの「1 個の自己ポインタ+データポインタ+長さ+タグ+コピー 2 個」という 6 フィールド構成から、各実データフィールド(データポインタ・長さ・タグ)にそれぞれ専用の自己ポインタが対応する構成に変わっています。 フィールドごとに参照を個別に持たせる設計は、1.0 で強化された Interior Origins(第10章 値・所有権・ライフサイクル の 10.6 節で触れた、コンテナ要素への参照を個別に追跡する仕組み)と関係している可能性がありますが、標準ライブラリ内部の設計判断の詳細までは本書では立ち入りません。
5.5.3 静的文字列の参照方法
"Hello, Mojo" の実体はコンパイル時にバイナリの .rodata セクションへ埋め込まれます。
leaq _static_string_...(%rip), %rax は RIP 相対アドレッシング で、「現在の命令ポインタ(%rip)からのオフセット」でその位置を求めます。
アドレスは実行時に決まるため即値で書けませんが、leaq 一命令で効率よく取得できます。
5.5.4 なぜポインタが複数あるのか
同じ _static_string_359aaa639d270c60 へのポインタが 0x58、0x70、0x78 の 3 か所に書かれます。
Mojo の print() はジェネリック関数であり、内部で複数の引数スロット(本体とディスパッチ用コピー)を別々に受け取る設計になっているためです。
同じアドレスを指しますが、構造体のレイアウト上で異なるフィールドに格納されます。
命令の詳細
7: leaq 0x60(%rsp), %rax ; 文字列長スロットのアドレスを rax へ
c: movq %rax, 0x40(%rsp) ; 自己ポインタ 2 として保存
11: movq $0xb, 0x60(%rsp) ; 文字列長 = 11(0xb)をスタックへ書き込む
1a: leaq 0x58(%rsp), %rax ; 文字列データポインタ・スロットのアドレスを rax へ
1f: movq %rax, 0x48(%rsp) ; 自己ポインタ 1 として保存
24: leaq _static_string_359aaa639d270c60(%rip), %rax
2b: movq %rax, 0x70(%rsp) ; ポインタコピー(ディスパッチ用)
30: movq %rax, 0x78(%rsp) ; ポインタコピー(ディスパッチ用)
35: movq %rax, 0x58(%rsp) ; 文字列データポインタ("Hello, Mojo" の実体)
3a: leaq 0x68(%rsp), %rax ; タグ/フラグビット・スロットのアドレスを rax へ
3f: movq %rax, 0x50(%rsp) ; 自己ポインタ 3 として保存
44: movabsq $0x2000000000000000, %rax
4e: movq %rax, 0x68(%rsp) ; タグ/フラグビット
図5-2: 文字列オブジェクトの構築
左のスタックフレーム上に各フィールドが並び、0x58(%rsp) の文字列データポインタが右の静的文字列領域(.rodata)の "Hello, Mojo" を指しています。
0x48(%rsp) の自己ポインタは 0x58(%rsp) を指しています。
5.5.5 sep/end 文字列オブジェクトの並行構築
続く 0x53〜0x119 では、print() の隠れ引数である sep(区切り文字、デフォルトは空文字)と end(末尾文字、デフォルトは改行)の文字列オブジェクトも、本体文字列と同様の手順でスタック上(0xb8(%rsp) 以降)に構築されます。
1.0.0 のコンパイラはこの 3 つの文字列オブジェクト(本体・sep・end)をまとめて先に組み立ててから print() の呼び出しへ進む、という命令スケジューリングを行っています。
細部の命令列はここでは省略しますが、リスト5-2 の 0x53 以降で同じパターン(leaq でアドレス取得 → movq でスロットへ格納)が繰り返されているのが確認できます。
5.6 参照カウントの初期化
値として扱う型でも、ランタイム側で参照カウントを伴う経路が生成されることがあります。 ここでは refcount を 1 で初期化しています。
命令の詳細
119: movq %rsp, %rax
11c: movq $0x1, "_hello_mojo_minimal::main()"(%rax) ; スタック先頭に refcount = 1 をセット
コラム: スタック上のオブジェクトになぜ参照カウントが必要か
「スタック上の構造体は関数のエピローグで自動的に消えるのだから、参照カウントは不要では?」と思うかもしれません。
答えは「スタック上の構造体が所有している資源」と「構造体そのものが占めるスタック領域」を区別することで見えてきます。
String 構造体はスタックに置かれますが、その構造体が指す文字データは静的領域(.rodata)やヒープに置かれることがあります。
動的に生成した文字列であればヒープバッファを所有しており、構造体が消えるときにそのバッファも解放しなければなりません。
この「最後の所有者だけがバッファを解放する」判断を、参照カウントで行います。
今回の "Hello, Mojo" は静的文字列なので実際には解放不要ですが、それは実行時には判断できないため、同じ経路を通ります。
もうひとつの理由は正常パスとエラーパスの非対称性です。
正常パス:コンパイラはコピーが発生していないと証明できるため、参照カウントを確認せずエピローグの
addq $0x128, %rspだけで完了します。エラーパス:例外やアンワインドが起きると、コンパイラではなくランタイム(アンワインダ)が制御を引き継ぎます。「誰かがまだこのオブジェクトへの参照を持っているか」をコンパイラが静的に判断できないため、参照カウントを
lock xaddqでアトミックに確認してからデストラクタを呼ぶ必要があります。
要約すると、参照カウントは「スタック領域の解放」のためではなく、「オブジェクトが所有する資源のデストラクタを正しいタイミングで呼ぶ」ためにあります。
5.7 print() の呼び出し準備
5.7.1 ABI とは何か
ABI(Application Binary Interface) は「呼び出し元と呼び出し先が、どのレジスタとどの順番で引数をやり取りするか」を定めた取り決めです。
Python の場合、関数呼び出しの仕組みはインタープリタが管理するため、プログラマが意識することはありません。
しかし機械語レベルでは、callq 命令で別の関数へジャンプする前に、引数を決まったレジスタへセットしておく必要があります。
受け取り側の関数も「1 番目の引数は rdi に入っている」と決め打ちでコードを生成しているためです。
この取り決めが ABI です。
5.7.2 レジスタとは何か
レジスタは CPU 内部にある超高速な記憶場所です。
メモリ(RAM)へのアクセスに比べて数十〜数百倍速く読み書きできます。
rdi、rsi、rdx、rcx、r8、r9 は x86-64 CPU の汎用レジスタで、それぞれ 64 ビット(8 バイト)の値を保持します。
5.7.3 System V AMD64 ABI の引数渡し規則
x86-64 Linux/macOS では System V AMD64 ABI が使われます。 整数とポインタ型の引数は、先頭から順に次のレジスタへ格納します。
引数の順番 |
レジスタ |
備考 |
|---|---|---|
第 1 引数 |
|
最初の引数 |
第 2 引数 |
|
2 番目の引数 |
第 3 引数 |
|
3 番目の引数 |
第 4 引数 |
|
4 番目の引数 |
第 5 引数 |
|
5 番目の引数 |
第 6 引数 |
|
6 番目の引数 |
第 7 引数以降 |
スタック |
レジスタが足りなければスタックへ積む |
7 つ以上の引数がある場合はスタックに積みます。
今回の print() は 6 引数以内に収まるため、すべてレジスタで渡します。
5.7.4 print() の引数マッピング
Mojo の print("Hello, Mojo") が展開されると、print の内部シグネチャに応じた複数の引数がレジスタへセットされます。
レジスタ |
引数 |
内容 |
|---|---|---|
|
第 1 引数 |
文字列オブジェクトへのポインタ( |
|
第 2 引数 |
|
|
第 3 引数 |
要素数 = |
|
第 4 引数 |
|
|
第 5 引数 |
要素数コピー = |
|
第 6 引数 |
|
命令の詳細
123: leaq _static_string_a8d4ace0dc8d360e(%rip), %rsi ; rsi = sep 文字列
12a: leaq _static_string_bbe01a6a523daf15(%rip), %rcx ; rcx = end 文字列
131: leaq 0x58(%rsp), %rdi ; rdi = 文字列オブジェクトへのポインタ
136: movl $0x1, %r8d ; r8 = 1(要素数)
13c: xorl %r9d, %r9d ; r9 = 0
13f: movq %r8, %rdx ; rdx = r8 のコピー = 1
142: callq "_std::io::io::print[KGENParamList[::Writable & ::AnyType],*::Writable & ::AnyType,...](*$0,sep:::StringSpan[...],end:::StringSpan[...],flush:::Bool,file:::FileDescriptor$),..."
旧バージョンでは print() の第 1 型パラメータが Writable 単体でしたが、1.0.0 では Writable & ::AnyType(交差トレイト)に変わっています。
また引数の型も StringSlice から StringSpan にリネームされています(StringSlice → StringSpan は 1.0.0 での標準的な改名の一つです)。
アセンブリ上の書き込み順(rsi → rcx → rdi → …)は引数番号の順と一致しません。
コンパイラはレジスタの依存関係を考慮して最適な順序で並べるため、命令の並び順と引数の順番は必ずしも一致しません。
図5-3: System V AMD64 ABI による引数渡し
スタック上のデータ(文字列オブジェクト、sep、end)が、ABI の規則に従って rdi〜r9 の各レジスタへセットされ、callq print[..] で呼び出しへ渡る流れです。
5.8 エラー/例外チェック(Mojo のエラー処理)
print() の戻り値ワードにエラー状態を表すビットが埋め込まれています。
0x4000000000000000 というマスクで特定ビットを取り出し、ゼロならエラーなしとしてエピローグへ、ビットが立っていればエラーありとしてデストラクタ処理へ進みます。
命令の詳細
147: movabsq $0x4000000000000000, %rax
151: andq 0x68(%rsp), %rax ; 先ほど保存したフラグビットと AND
156: cmpq $0x0, %rax
15a: je 0x29e ; フラグが立っていなければ後処理なしで終了側へ
5.9 エラーありの場合:参照カウントのアトミックデクリメントと Layout ベースの解放
エラーが発生した場合、文字列オブジェクトの参照カウントを lock xaddq でアトミックにデクリメントします。
旧カウントが 1(自分が最後の所有者)だった場合のみ、解放処理へ進みます。
命令の詳細(参照カウントのデクリメント)
160: movq 0x48(%rsp), %rax ; 文字列オブジェクトへの自己ポインタ
165: movq "_hello_mojo_minimal::main()"(%rax), %rax
168: addq $-0x8, %rax ; ポインタを 8 戻す(実際の refcount の位置)
16c: movq %rax, 0x38(%rsp)
...
197: movq $-0x1, %rax
19e: lock
19f: xaddq %rax, "_hello_mojo_minimal::main()"(%rcx) ; アトミックに refcount -= 1(旧値を rax へ)
...
1b5: cmpq $0x1, %rax ; 旧 refcount が 1 だったか?
1b9: jne 0x29a ; 1 でなければ他に参照があるので解放不要へ
1bf: jmp 0x1c1 ; 1 なら解放処理へ
1c1: movq 0x38(%rsp), %rdi ; 解放対象のポインタを rdi へ
ここまでの構造は旧バージョンと同じです。 違いは、ここから先の解放そのものの実装にあります。
5.9.1 1.0.0 での解放:ThinAllocation と Layout
旧バージョンでは、解放は callq _KGEN_CompilerRT_AlignedFree という単一の呼び出しで完結していました。
1.0.0 では、この解放処理が Layout ベースのメモリ管理 API を経由するようになり、次の 3 段階に分かれています。
1c6: callq "_std::memory::alloc::ThinAllocation::__init__(unsafe_owned_ptr:...)"
1cb: movq %rax, 0x28(%rsp)
; --- Layout(サイズ・アライメント情報)の組み立て ---
1d0〜24e: 文字列の状態を示すフラグビットを調べ、対応するサイズを計算する
(詳細は後述)
285: callq "_std::memory::alloc::ThinAllocation::unsafe_with_layout(::ThinAllocation[$0]$,::Layout[$0])"
293: callq "_std::memory::alloc::dealloc[::AnyType](::Allocation[$0]$)"
Mojo 1.0.0 では Pointer/UnsafePointer の統合と合わせて、ヒープメモリの確保・解放 API が刷新されました。
ThinAllocation は「確保したポインタだけを保持する薄いハンドル」で、サイズやアライメントの情報(Layout)を持ちません。
解放するときに unsafe_with_layout() で Layout を後から結合し、サイズ情報の揃った Allocation に変換してから dealloc() に渡します。
「確保したときと違うサイズで解放してしまう」というミスを、この 2 段階の構造そのものでコンパイラが防げるようにするための設計です。
String 型の内部実装もこの新しい API を使うように書き換わったため、print("Hello, Mojo") という一行だけのコードでも、生成される機械語にこの解放シーケンスが現れるようになりました。
注釈
1d0〜24e の間で行われているフラグビットのチェックと shlq $0x3(8 倍)のようなシフト演算は、文字列がどのような内部表現(たとえば短い文字列をスタック上にそのまま格納する最適化が効いているかどうか)を取っているかに応じて、解放すべきバイト数を計算しているものと考えられます。
この計算の詳細は Mojo 標準ライブラリ内部の String 実装に依存するため、本書では「解放前に Layout を組み立てている」という大枠の流れを掴むところまでを目標とし、命令ごとの完全な意味の解明までは踏み込みません。
コラム: スタック上の文字列になぜ解放処理が必要か
「文字列オブジェクトはスタック上にあるのだから、エラー時もエピローグの addq $0x128, %rsp で自動的に消えるのでは?」という疑問が生じます。
ここで大切なのは、スタック上の構造体はあくまで「ヘッダ(管理情報)」であり、その構造体が所有するリソースは別の場所にあるという点です。
String 型は静的文字列(.rodata)だけでなく、動的に確保したヒープバッファも保持できるように設計されています。
たとえば String("hello") + String(" world") のように実行時に文字列を結合すると、結果はヒープに置かれます。
このとき、スタック上のヘッダが消えるだけではヒープバッファが解放されずリークになります。
ThinAllocation 経由の dealloc を呼ぶのは「ヒープ上のリソースを解放する」ためであり、スタックフレームを解放するためではありません。
今回の "Hello, Mojo" は .rodata の静的文字列なので実際には解放対象がありませんが、コンパイラはすべての String に対して同じクリーンアップ経路を生成します。
参照カウントのデクリメントが先に行われるのも重要です。
print() の内部でこの文字列への参照が増えていた場合(refcount > 1)、デクリメント後もまだ参照があるため、解放処理を呼んではいけません。
「自分が最後の所有者か(旧 refcount が 1 だったか)」を確認してからはじめて解放処理を呼ぶことで、二重解放を防ぎます。
5.10 ランディングパッドと合流
この節について
この節で扱うランディングパッドは、例外処理を実装するためのコンパイラ機構であり、Mojo 独自のものではありません。 C++ や Rust でも LLVM を経由する場合は同様のコードが生成されます。 Mojo の動作を理解するうえでは、この細部を深追いする必要はなく、「コンパイラが後片付け用の骨格を差し込む」という概念だけ掴めれば十分です。 アセンブリのしくみに興味がある方向けの補足として読んでください。
callq(解放に相当する呼び出し)の直後に、4 本の短い jmp が並んでいます。
リスト5-2 では次のとおりです。
298: eb 02 jmp 0x29c
29a: eb 00 jmp 0x29c
29c: eb 02 jmp 0x2a0
29e: eb 00 jmp 0x2a0
これらは ランディングパッド(landing pad)/例外処理用のスタブ としてコンパイラが差し込んだコードです。
Mojo は LLVM を基盤にしているため、C++ と同様に LLVM の例外処理機構(Windows では SEH、ELF 系では DWARF unwinding など、ターゲットに応じたアンワインド情報)に沿ったコードが生成されます。
callq で呼んだ関数が、例外、パニック、スタック展開時のクリーンアップを要する場合に、「制御がどのラベルへ戻るか」をアンワインダが解析できるよう、合流点までの骨格が置かれます。
構造としては次のように読めます。
298〜29a… 直前のcallqが正常終了したあとにフォールスルーしてくる経路。いずれも0x29cへジャンプする。29c〜29e… さらに追加の後処理なしで、関数エピローグの0x2a0(addq/retq)へジャンプする。
eb 02 と eb 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 の直後/近傍」と考えてかまいません。
いま引用している 298〜29e は、リスト5-2 では 293: callq(dealloc — 解放に相当する呼び出し)の直後から始まります。
別の callq(たとえば print 本体)のまわりにも、LLVM は同様の骨組みを付けることがあります。
293: callq … ← ここから戻ってきた直後が 298
298: jmp 0x29c
29a: jmp 0x29c
29c: jmp 0x2a0
29e: jmp 0x2a0
アンワインドが発生すると、ランタイム側(アンワインダ等)が「この callq に対応するランディングパッドはどこか」を DWARF 等の情報から調べ、適切な 298 付近へ制御を移すことがあります。
そのあと後片付け(参照カウントや解放など)を経て、0x2a0 のエピローグへ合流します。
5.10.6 なぜ jmp が複数並ぶのか
LLVM は正常に callq から戻ってきた場合とアンワインドで「着陸」した場合の入口の形をそろえつつ、最終的に同じエピローグへ合流できるように、短い jmp を並べます。
見かけ上は
パスA …
callqが成功して戻り、機械語としては298側から0x29c→0x2a0へ進むパスB … アンワインドで別エッジから合流点へ入り、同様に
0x2a0へ向かう
といった二系統の入口を、同じオフセットに収束させるために、eb 02 と eb 00 で 2 バイト単位のアライメントを保ちながら冗長なジャンプを挟む、というイディオムが現れます(詳細は命令列ごとに異なります)。
eb 02 は相対ジャンプで 2 バイト先へ飛び、eb 00 は次の命令へそのまま進む(相対 0)という形で、アライメントを保ちつつ二つの入口を作る LLVM のパターンとして説明されることがあります。
5.10.7 用語の整理
用語 |
意味 |
|---|---|
例外 |
実行中に発生し、通常の戻り値だけでは処理しきれないエラーなどの事象 |
スタックアンワインド |
エラー後に、内側のフレームから外側へ向かって順に巻き戻す処理 |
ランディングパッド |
各フレームまわりの 後片付けコードへの入口(LLVM が生成する例外処理の骨格の一部) |
print("Hello, Mojo") のように見える一行でも、内部でオブジェクトやランタイム呼び出しが伴うため、ソース上は何も書いていなくても LLVM がこのようなスタブを生成します。
Mojo の安全なメモリ管理や例外処理が、機械語側で自動的に組み込まれる一例です。
5.11 関数エピローグ
プロローグで「スタックフレームを確保して関数の準備を整えた」のに対し、エピローグは「フレームを解放して呼び出し元へ戻る」後始末です。 命令は 2 つだけです。
2a0: addq $0x128, %rsp ; スタック解放(プロローグの逆、0x128 = 296 バイト)
2a7: retq ; 呼び出し元へ戻る
2a8: nopl "_hello_mojo_minimal::main()"(%rax,%rax) ; アライメント用パディング(次の関数を 16 バイト境界へ)
5.11.1 プロローグとエピローグの対比
関数プロローグとエピローグで対になる命令を対比させて確認します。
命令 |
プロローグ(5.4 節) |
エピローグ(本節) |
|---|---|---|
スタック操作 |
|
|
効果 |
|
|
次の命令 |
変数の初期化と引数の準備へ |
|
メモリの実態 |
領域の内容は変えない( |
領域の内容は変えない( |
5.11.2 addq $0x128, %rsp が何をするか
プロローグの subq $0x128, %rsp が rsp を 296 減らしてフレームを「押さえた」のと正反対に、addq $0x128, %rsp は rsp を 296 増やして同じ領域を「手放します」。
実際にはメモリを消去したり返却したりするわけではなく、「この範囲はもう使っていない」とポインタを戻すだけです。
次の関数がスタックを使うときにはこの領域が上書きされます。
これが「スタックは高速」と言われる理由のひとつで、malloc/free のようなヒープ管理コストがゼロです。
5.11.3 retq が何をするか
retq は次の 2 つを一命令で行います。
スタックの先頭(現在の
rspが指す位置)から 戻りアドレスを取り出すその戻りアドレスへ ジャンプする(
rspは 8 バイト増える)
戻りアドレスは callq 命令が呼び出し元側で積んでおいたものです。
callq と retq はこのアドレスのプッシュ/ポップで対になっています。
5.11.4 retq の直後の nopl について
nopl は アライメント用の NOP(パディング) です。
目的は、次の関数の先頭アドレスを特定のバイト境界(多くの場合 16 バイト)に整えることにあります。
このダンプでは 0x2a8 の次のアドレスが 0x2b0 になり、ちょうど 16 バイト境界になります。
x86-64 では関数エントリを 16 バイト境界に整えると、命令フェッチや一部マイクロアーキテクチャ上で有利になることがあります。
バイト列 0f 1f 84 00 00 00 00 00 は マルチバイト NOP(Intel が推奨する形式の長い NOP)です。
単に 90(1 バイト NOP)を 8 つ並べるより、パイプラインへの影響が小さいという理由でコンパイラが選ぶことがあります。
必要なパディング量(この場合は 8 バイト)に応じて、コンパイラはより長いエンコーディングのマルチバイト NOP を選択します。
retq のあとに続く命令は実行されません。
制御はすでに呼び出し元へ戻っているため、nopl は動作の正しさではなく、直後に配置される次関数のためのレイアウトのために挿入されています。
5.11.5 まとめ(ランディングパッド列と nopl)
箇所 |
目的 |
|---|---|
|
LLVM 例外処理機構に沿ったランディングパッドの骨格(このケースでは実質的にエピローグへ合流するだけ) |
|
次の関数を 16 バイト境界に整えるためのパディング( |
5.12 全体の流れ(まとめ)
ここまで読んできた main() の各ステップを、実行フローとしてまとめます。
スタック確保から始まり、文字列オブジェクトの構築、参照カウント初期化、print() 呼び出し、エラーフラグチェックを経て、正常パス(左)とエラーパス(右)に分岐し、最後にスタックを解放して返る流れを確認してください。
図5-5: main() 関数の実行フロー
単純な print("Hello, Mojo") でも、参照カウントの初期化、アトミックな減算、条件付き解放まで機械語に落ちていることが、このダンプから読み取れます。
Mojo が メモリ安全 を機械側でどう支えているかの一端だと捉えるとよいでしょう。