5. 最小サンプル main のアセンブリを読む
5.1. この章で学ぶこと
リスト-2(
hello_mojo_minimal.asm)のmainが、スタック上で何をしているか文字列オブジェクトの組み立て、
print呼び出し、エラー/例外まわりの分岐、参照カウントと解放の流れ
1 章 では、ソース(リスト-1)を示し 「見た目の違い」 に触れました。
この章では、そのコンパイル結果(リスト-2)を手がかりに、hello_mojo_minimal::main() の機械語を追います。
5.2. この章の3つのポイント
アセンブリの細部に入る前に、この章で確認したい 3 つのことを先に示します。
文字列オブジェクトはスタック上に組み立てられる
Python のように全てがヒープのオブジェクトになるのではなく、スタック上にサイズ・ポインタ・フラグをレイアウトした構造体として組まれます。エラー状態は戻り値のビットフラグで表される
print()の結果ワードに特定ビット(0x4000...)が立っているかをチェックして、エラーあり/なしを判定しています。Mojo の例外処理が機械語レベルでどう実装されるかの一例です。参照カウントのデクリメントはアトミック操作で行われる
lock xaddqによって、マルチスレッド下でも安全に参照数を操作します。シンプルなprint("Hello, Mojo")一行でも、Mojo のメモリ安全の仕組みが機械語に反映されています。
以降は、この 3 点を念頭に置きながら、実際の命令列を確認します。
hello_mojo_minimal.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <hello_mojo_minimal::main()>:
; def main():
0: 48 81 ec 88 00 00 00 subq $136, %rsp
; print("Hello, Mojo")
7: 48 c7 44 24 28 0b 00 00 00 movq $11, 40(%rsp)
10: 48 8d 44 24 20 leaq 32(%rsp), %rax
15: 48 89 44 24 18 movq %rax, 24(%rsp)
1a: 48 8d 05 00 00 00 00 leaq (%rip), %rax # 0x21 <hello_mojo_minimal::main()+0x21>
21: 48 89 44 24 38 movq %rax, 56(%rsp)
26: 48 8d 05 00 00 00 00 leaq (%rip), %rax # 0x2d <hello_mojo_minimal::main()+0x2d>
2d: 48 89 44 24 40 movq %rax, 64(%rsp)
32: 48 8d 05 00 00 00 00 leaq (%rip), %rax # 0x39 <hello_mojo_minimal::main()+0x39>
39: 48 89 44 24 20 movq %rax, 32(%rsp)
3e: 48 b8 00 00 00 00 00 00 00 20 movabsq $2305843009213693952, %rax # imm = 0x2000000000000000
48: 48 89 44 24 30 movq %rax, 48(%rsp)
4d: 48 89 e0 movq %rsp, %rax
50: 48 c7 00 01 00 00 00 movq $1, (%rax)
57: 48 8d 35 00 00 00 00 leaq (%rip), %rsi # 0x5e <hello_mojo_minimal::main()+0x5e>
5e: 48 8d 0d 00 00 00 00 leaq (%rip), %rcx # 0x65 <hello_mojo_minimal::main()+0x65>
65: 48 8d 7c 24 20 leaq 32(%rsp), %rdi
6a: 41 b8 01 00 00 00 movl $1, %r8d
70: 45 31 c9 xorl %r9d, %r9d
73: 4c 89 c2 movq %r8, %rdx
76: e8 00 00 00 00 callq 0x7b <hello_mojo_minimal::main()+0x7b>
7b: 48 b8 00 00 00 00 00 00 00 40 movabsq $4611686018427387904, %rax # imm = 0x4000000000000000
85: 48 23 44 24 30 andq 48(%rsp), %rax
8a: 48 83 f8 00 cmpq $0, %rax
8e: 74 73 je 0x103 <hello_mojo_minimal::main()+0x103>
90: 48 8b 44 24 18 movq 24(%rsp), %rax
; print("Hello, Mojo")
95: 48 8b 00 movq (%rax), %rax
98: 90 nop
99: 48 89 44 24 68 movq %rax, 104(%rsp)
9e: 48 83 c0 f8 addq $-8, %rax
a2: 48 89 44 24 10 movq %rax, 16(%rsp)
a7: 48 89 44 24 48 movq %rax, 72(%rsp)
; print("Hello, Mojo")
ac: 48 89 44 24 50 movq %rax, 80(%rsp)
b1: 90 nop
b2: 48 8b 4c 24 10 movq 16(%rsp), %rcx
b7: 48 89 4c 24 58 movq %rcx, 88(%rsp)
bc: 48 89 4c 24 60 movq %rcx, 96(%rsp)
c1: 48 c7 c0 ff ff ff ff movq $-1, %rax
c8: f0 lock
c9: 48 0f c1 01 xaddq %rax, (%rcx)
cd: 48 89 44 24 70 movq %rax, 112(%rsp)
d2: 48 89 44 24 08 movq %rax, 8(%rsp)
d7: 48 8b 44 24 08 movq 8(%rsp), %rax
; print("Hello, Mojo")
dc: 48 83 f8 01 cmpq $1, %rax
e0: 75 1d jne 0xff <hello_mojo_minimal::main()+0xff>
e2: eb 00 jmp 0xe4 <hello_mojo_minimal::main()+0xe4>
e4: 48 8b 7c 24 10 movq 16(%rsp), %rdi
; print("Hello, Mojo")
e9: 90 nop
ea: 48 89 7c 24 78 movq %rdi, 120(%rsp)
ef: 90 nop
f0: 48 89 bc 24 80 00 00 00 movq %rdi, 128(%rsp)
f8: e8 00 00 00 00 callq 0xfd <hello_mojo_minimal::main()+0xfd>
; print("Hello, Mojo")
fd: eb 02 jmp 0x101 <hello_mojo_minimal::main()+0x101>
ff: eb 00 jmp 0x101 <hello_mojo_minimal::main()+0x101>
101: eb 02 jmp 0x105 <hello_mojo_minimal::main()+0x105>
103: eb 00 jmp 0x105 <hello_mojo_minimal::main()+0x105>
; def main():
105: 48 81 c4 88 00 00 00 addq $136, %rsp
10c: c3 retq
10d: 0f 1f 00 nopl (%rax)
リスト-2: hello_mojo_minimal.asm
ここに載せるニモニックは、リスト-2 と同じく AT&T 記法(objdump の出力)です。
Mojo や LLVM のバージョンによって細部は変わり得ますが、上記のダンプを前提に読み進めてください。
5.3. 関数プロローグ
スタックに 136 バイトを確保し、ローカル変数や一時データの置き場を作ります。
注釈
命令の詳細
0: subq $136, %rsp
5.4. 文字列オブジェクトの構築
スタック上に、文字列長・データポインタ・タグビットを並べた構造体を組み立てます。
leaq (%rip), %rax はリンク前のプレースホルダーで、リンク時に実際のアドレスが埋まります。
タグビット(0x2000000000000000)は参照カウントまわりのフラグとして使われます(ビット割り当ては実装依存)。
注釈
命令の詳細
7: movq $11, 40(%rsp) ; 文字列長 "Hello, Mojo" = 11 文字
1a: leaq (%rip), %rax
21: movq %rax, 56(%rsp) ; 関数ポインタ的なもの① をスタックへ
26: leaq (%rip), %rax
2d: movq %rax, 64(%rsp) ; 関数ポインタ的なもの② をスタックへ
32: leaq (%rip), %rax
39: movq %rax, 32(%rsp) ; 文字列データへのポインタ("Hello, Mojo")
3e: movabsq $0x2000000000000000, %rax
48: movq %rax, 48(%rsp) ; タグ/フラグビット(後で参照される)
5.5. 参照カウントの初期化
値として扱う型でも、ランタイム側で 参照カウント を伴う経路が生成されることがあります。ここでは refcount を 1 で初期化しています。
注釈
命令の詳細
4d: movq %rsp, %rax
50: movq $1, (%rax) ; スタック先頭に refcount = 1 をセット
5.6. print() の呼び出し準備
x86-64 Linux の System V AMD64 ABI に従い、整数・ポインタ引数を rdi, rsi, rdx, rcx, r8, r9 の順に並べて print 本体を呼び出します。
注釈
命令の詳細
57: leaq (%rip), %rsi ; 引数: 文字列データポインタ("Hello, Mojo")
5e: leaq (%rip), %rcx ; 引数: メタデータ(vtable 等に相当し得るポインタ)
65: leaq 32(%rsp), %rdi ; 引数: 文字列オブジェクト本体へのポインタ
6a: movl $1, %r8d ; 引数: 要素数 = 1
70: xorl %r9d, %r9d ; 引数: 0(フラグ等)
73: movq %r8, %rdx ; rdx = 1
76: callq 0x7b ; print 本体の呼び出し(相対オフセットはリンク前の見え方)
5.7. エラー/例外チェック(Mojo のエラー処理)
print() の戻り値ワードに エラー状態を表すビット が埋め込まれています。
0x4000000000000000 というマスクで特定ビットを取り出し、ゼロならエラーなしとしてエピローグへ、ビットが立っていればエラーありとしてデストラクタ処理へ進みます。
注釈
命令の詳細
7b: movabsq $0x4000000000000000, %rax
85: andq 48(%rsp), %rax ; 先ほど保存したフラグビットと AND
8a: cmpq $0, %rax
8e: je 0x103 ; フラグが立っていなければ後処理なしで終了側へ
5.8. エラーありの場合:参照カウントのアトミックデクリメント
エラーが発生した場合、文字列オブジェクトの参照カウントを lock xaddq でアトミックにデクリメントします。
旧カウントが 1(自分が最後の所有者)だった場合のみ、デストラクタ呼び出しへ進みます。
注釈
命令の詳細
90: movq 24(%rsp), %rax ; 文字列オブジェクトへのポインタ
9e: addq $-8, %rax ; ポインタを 8 戻す(実際の refcount の位置)
c1: movq $-1, %rax
c8: lock
c9: xaddq %rax, (%rcx) ; アトミックに refcount -= 1(旧値を rax へ)
dc: cmpq $1, %rax ; 旧 refcount が 1 だったか?
e0: jne 0xff ; 1 でなければ他に参照があるので解放不要へ
e2: jmp 0xe4 ; 1 なら解放処理へ
e4: movq 16(%rsp), %rdi ; 解放対象のポインタを rdi へ
f8: callq 0xfd ; デストラクタ/free に相当する呼び出し
5.9. ランディングパッドと合流
callq(デストラクタ/解放に相当する呼び出し)の直後に、4 本の短い jmp が並んでいます。リスト-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 で呼んだ関数が、例外・パニック・スタック展開時のクリーンアップを要する場合に、「制御がどのラベルへ戻るか」をアンワインダが解析できるよう、合流点までの骨格が置かれます。
構造としては次のように読めます。
fd〜ff… 直前のcallqが 正常終了したあとにフォールスルーしてくる経路。いずれも0x101へジャンプする。101〜103… さらに 追加の後処理なしで、関数エピローグの0x105(addq/retq)へジャンプする。
eb 02 と eb 00 がペアで現れるのは、2 バイト境界へのアライメント調整と、unwinder がどちらのエッジから来ても 同じオフセットの合流点へ到達できるようにするための、いわば冗長なジャンプ列です。
これらはリンク時に特別なシンボル解決で「意味が変わる」というより、コンパイル時に LLVM が生成した例外処理・クリーンアップパスの骨組みです。この最小サンプルでは実質的には 何もせずエピローグへ流れるだけ の経路になっており、リスト上は短い jmp の並びに見えます。
ランディングパッドを素朴に理解する
5.9.1. 例外とは
実行中に、確保に失敗する・前提が崩れるなど、呼び出し元がその場で処理しきれない事態が起きることがあります。
main()
└→ print()
└→ (内部でメモリ確保やランタイム呼び出し)
└→ 「続行できない」← ここでエラー
このとき、どの関数から順に片付けをしてから実行を止めるかを決める必要があります。
5.9.2. 呼び出しの積み重ねとスタック
関数は入れ子になって呼ばれます。
main() が print() を呼ぶ
print() が(内部で)さらに別の関数を呼ぶ
… いちばん深いところでエラー発生
スタックで表すと、いま実行中のフレームが上に積まれます。
+-------------+ ← スタックの上
| 深い側の関数 | ← ここでエラー
+-------------+
| … |
+-------------+
| print() |
+-------------+
| main() |
+-------------+ ← スタックの下
エラーが起きたあと、この積み重ねを 外側(呼び出し元)へ向かって順に巻き戻す 処理が スタックアンワインド(stack unwinding) です。
5.9.3. なぜ巻き戻しが必要か
各フレームは、オブジェクトやバッファなど 解放が必要な状態 を持っていることがあります。エラーで途中打ち切りにすると リークや不整合 が残るため、各段で「後片付け」を挟んでから 外へ戻る必要があります。
5.9.4. ランディングパッドの役割
アンワインド時、コンパイラは 「この呼び出しに対応する片付けの入口はここだ」 という情報を機械語に埋め込みます。その入口に相当するのが ランディングパッド(landing pad) です。通常の成功経路では通らず、例外やランタイムが「ここへ一旦着陸して片付けろ」と指示したとき に使われる、後片付けコードへの玄関 だと捉えるとよいでしょう。
5.9.5. リスト-2 で見る位置づけ
概念の説明では「任意の callq の直後/近傍」と考えてかまいません。いま引用している fd〜103 は、リスト-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.9.6. なぜ jmp が複数並ぶのか
LLVM は 正常に callq から戻ってきた場合 と アンワインドで「着陸」した場合 の 入口の形をそろえつつ、最終的に同じエピローグへ合流できるように、短い jmp を並べます。見かけ上は
パスA …
callqが成功して戻り、機械語としてはfd側から0x101→0x105へ進むパスB … アンワインドで 別エッジから 合流点へ入り、同様に
0x105へ向かう
といった 二系統の入口を、同じオフセットに収束させる ために、eb 02 と eb 00 で 2 バイト単位のアライメントを保ちながら 冗長なジャンプを挟む、というイディオムが現れます(詳細は命令列ごとに異なります)。
eb 02 は相対ジャンプで 2 バイト先 へ飛び、eb 00 は 次の命令へそのまま進む(相対 0)という形で、アライメントを保ちつつ二つの入口を作る LLVM のパターンとして説明されることがあります。
5.9.7. 用語の整理
用語 |
意味 |
|---|---|
例外 |
実行中に発生し、通常の戻り値だけでは処理しきれないエラーなどの事象 |
スタックアンワインド |
エラー後に、内側のフレームから外側へ向かって順に巻き戻す処理 |
ランディングパッド |
各フレームまわりの 後片付けコードへの入口(LLVM が生成する例外処理の骨格の一部) |
print("Hello, Mojo") のように見える一行でも、内部でオブジェクトやランタイム呼び出しが伴うため、ソース上は何も書いていなくても LLVM がこのようなスタブを生成します。Mojo の 安全なメモリ管理や例外処理が、機械語側で自動的に組み込まれる 一例です。
5.10. 関数エピローグ
105: addq $136, %rsp ; スタック解放(プロローグの逆)
10c: retq ; 呼び出し元へ戻る
10d: 0f 1f 00 ; nopl (%rax) — 下記のとおりパディング
5.10.1. retq の直後の nopl について
nopl は アライメント用の NOP(パディング) です。目的は 次の関数の先頭アドレスを、特定のバイト境界(多くの場合 16 バイト)に整えることにあります。
このダンプでは 0x10d の次のアドレスが 0x110 になり、ちょうど 16 バイト境界になります。x86-64 では関数エントリを 16 バイト境界に整えると、命令フェッチや一部マイクロアーキテクチャ上で有利になることがあります。
バイト列 0f 1f 00 は マルチバイト NOP(Intel が推奨する形式の長い NOP)です。単に 90(1 バイト NOP)を 3 つ並べるより、パイプラインへの影響が小さいという理由でコンパイラが選ぶことがあります。
retq のあとに続く命令は 実行されません。制御はすでに呼び出し元へ戻っているため、nopl は動作の正しさではなく 直後に配置される次関数のためのレイアウトのために挿入されています。
5.10.2. まとめ(ランディングパッド列と nopl)
箇所 |
目的 |
|---|---|
|
LLVM 例外処理機構に沿ったランディングパッドの骨格(このケースでは実質的にエピローグへ合流するだけ) |
|
次の関数を 16 バイト境界に整えるためのパディング( |
5.11. 全体の流れ(まとめ)
スタック確保
↓
"Hello, Mojo" 文字列オブジェクトをスタック上に構築
↓
print() 呼び出し
↓
エラーフラグチェック
├─ エラーなし → そのままエピローグ
└─ エラーあり → 文字列オブジェクトの参照カウントをアトミックにデクリメント
└─ 旧カウント == 1 → デストラクタ呼び出し → エピローグ
スタック解放・return
単純な print("Hello, Mojo") でも、参照カウントの初期化、アトミックな減算、条件付き解放まで機械語に落ちていることが、このダンプから読み取れます。Mojo が メモリ安全 を機械側でどう支えているかの一端だと捉えるとよいでしょう。
5.12. 出典・参照
Quickstart(最小サンプルの出典)