アセンブラ
概要
命令・レジスタ・スタック・呼び出し規約を具体的なコードで理解する
アセンブラは、CPU が実行する命令列を人間が読める形で記述するための低水準言語です。高級言語と機械語のちょうど間にあり、コンパイラ と CPU をつなぐ位置にあります。
この章で重視すること
- アセンブラを「昔の人の言語」ではなく、現在の実行モデルを理解する窓として使う
- x86-64 を中心に、必要に応じて ARM64 も対比する
- レジスタ、スタック、呼び出し規約、条件分岐を具体的なコードで理解する
- C や Rust の関数が、どのような命令列に落ちるかを追う
目次
- アセンブラとは何か
- なぜ今でも学ぶ価値があるのか
- 機械語とアセンブリの関係
- レジスタ
- メモリアクセス
- フラグレジスタと比較
- 制御フロー
- スタックと関数呼び出し
- プロローグとエピローグ
- 呼び出し規約 ABI
- 具体的なコード例
- Linux システムコール例
- x86-64 と ARM64 の見比べ方
- SIMD 命令の入口
- 逆アセンブルとデバッグの見方
- 実際に組み立てて動かす最小サンプル
- C からアセンブリ関数を呼ぶサンプル
- コンパイラ出力をどう読むか
- 実務での使いどころ
- 参考文献
アセンブラとは何か
アセンブラは、CPU 命令をニーモニックで記述した低水準言語です。
たとえば
mov rax, 1
add rax, 2
は、
raxに 1 を入れるraxに 2 を足す
という命令です。
高級言語と違って、
- 変数の置き場所
- 一時値の扱い
- 分岐
- 関数呼び出し
がかなり露骨に見えます。
なぜ今でも学ぶ価値があるのか
普段は C、Rust、Go、Java などで十分に開発できます。それでもアセンブラを知る価値があるのは、次の問いに答えやすくなるからです。
- 関数呼び出しで何が保存されるのか
- 再帰でスタックがどう増えるのか
- 分岐予測ミスがなぜ痛いのか
- コンパイラの最適化が何をしているのか
- メモリ破壊がなぜ危険なのか
機械語とアセンブリの関係
CPU が本当に実行するのは機械語です。アセンブリは、その機械語を人間が扱いやすくした表記です。
コンパイラ の コード生成 は、まさにこの橋をどう渡るかを扱っています。
高級言語からこの形へ落ちる過程を先に見たいなら、コンパイラ の コード生成、レジスタ割付、関数呼び出しと ABI が直接つながります。
レジスタ
レジスタは、CPU の中にある非常に高速な記憶領域です。
x86-64 の代表例
raxrbxrcxrdxrsirdirsprbp
直感
- レジスタは「いま使う値の机の上」
- メモリは「少し遠い棚」
というイメージが役立ちます。
この 机の上 と 棚 の感覚を、CPU の実装側から見ると CPU の レジスタと ISA、キャッシュとメモリ階層 に対応します。
役割の例
rax: 戻り値や計算用rsp: スタックポインタrbp: フレームポインタとして使われることがあるrdi,rsi,rdx,rcx,r8,r9: 引数渡しに使われることが多い
メモリアクセス
レジスタだけでは足りないので、メモリから値を読み書きします。
mov rax, [rbp-8]
mov [rbp-16], rax
これは、
rbp-8にある値をraxに読むraxの値をrbp-16に書く
という意味です。
アドレッシング
アセンブラでは
- 即値
- レジスタ
- メモリアドレス
- ベース + オフセット
を明示的に書くことが多いです。
スケール付きアドレッシング
x86-64 では
mov rax, [rdi + rcx*8]
のように、base + index * scale の形がよく出ます。これは
rdi: 配列の先頭rcx: 添字8: 要素サイズ
という意味です。long 配列やポインタ配列を読むときに頻出します。
load と store の直感
高級言語では代入一つに見えても、低水準では
- メモリから読む
- レジスタで計算する
- メモリへ書く
という 3 段階に分かれることが多いです。性能を考えると、この「読む回数」「書く回数」がかなり効きます。
フラグレジスタと比較
比較命令は、多くの場合「真偽値をそのままレジスタへ入れる」より、まずフラグレジスタを更新します。
cmp rax, rbx
je equal
ここでは cmp が内部的に引き算に近い比較を行い、その結果をフラグへ反映します。je はそのフラグを見て分岐します。
代表的なフラグ
ZF: ゼロフラグ。結果が 0 なら立つSF: 符号フラグCF: キャリーフラグOF: オーバーフローフラグ
なぜ重要か
アセンブラを読むとき、cmp や test の直後に来るジャンプは「どのフラグを見ているのか」を意識すると理解しやすくなります。
cmp と test
cmp a, b:a - b相当の比較をしてフラグだけ更新test a, b: AND 的な判定をしてフラグだけ更新
null 判定やビット判定で test がよく出ます。
制御フロー
高級言語の if や while は、最終的には比較とジャンプになります。
cmp rax, rbx
jl smaller
これは「rax < rbx なら smaller へ飛ぶ」という意味です。
代表的なジャンプ
jmp: 無条件ジャンプje: 等しければ飛ぶjne: 等しくなければ飛ぶjl: 小さければ飛ぶjg: 大きければ飛ぶ
ラベルを使って流れを読む
アセンブラでは、if や while のような構文はなく、ラベルとジャンプで制御フローを作ります。したがって読むときは
- 比較している場所
- どのラベルへ飛ぶか
- どこでループに戻るか
を先に見つけるのがコツです。
ループの形の見分け方
- 末尾で先頭ラベルへ戻るなら
while/forっぽい - 先頭で条件を見ずに本体へ入ってから末尾で条件を見るなら
do whileっぽい
といった読み方ができます。
スタックと関数呼び出し
関数呼び出しでは、戻り先、ローカル変数、一時退避領域などを管理する必要があります。そのために使われるのがスタックです。
よく出る命令
pushpopcallret
call は戻り先を積んでジャンプし、ret は戻り先を取り出して戻ります。
call や ret が CPU の内部でどう扱われるか、分岐予測や return stack buffer の観点まで追うなら CPU の パイプラインと分岐予測 が補助になります。
スタックが伸びる方向
x86-64 では、多くの場合スタックは高アドレスから低アドレスへ伸びます。つまり push で rsp は小さくなり、pop で大きくなります。
再帰とスタック
再帰関数が深くなると、呼び出しごとに
- 戻り先
- 保存レジスタ
- ローカル変数
が積まれるため、スタック使用量が増えます。再帰の理解にはアセンブラのスタック像がかなり効きます。
プロローグとエピローグ
関数の先頭と末尾では、かなり定型的な命令列が出ます。
典型的な x86-64 の形
push rbp
mov rbp, rsp
sub rsp, 32
これは関数の プロローグ で、
- 古い
rbpを保存 - 新しいフレーム基準を作る
- ローカル変数用の領域を確保
しています。
末尾では
mov rsp, rbp
pop rbp
ret
のような エピローグ が出ます。
なぜ今は省略されることがあるのか
最適化が有効だと、フレームポインタを省略して rbp を汎用レジスタとして使うことがあります。すると逆アセンブルは少し読みにくくなりますが、レジスタ資源は増えます。
例: ローカル変数を使う関数
long f(long a) {
long x = a + 1;
long y = x * 2;
return y;
}
最適化前のイメージでは、x や y がスタック上のスロットへ一度落ちることがあります。最適化後はレジスタだけで終わることも多いです。
呼び出し規約 ABI
同じ CPU でも、「引数をどのレジスタに置くか」「どのレジスタは呼び出し側が保存するか」は約束が必要です。これが ABI の一部です。
System V AMD64 ABI の例
整数系の先頭引数はよく次で渡されます。
rdirsirdxrcxr8r9
戻り値は通常 rax に入ります。
caller save / callee save
- caller save: 呼び出し側が壊れて困るなら自分で保存する
- callee save: 呼び出された側が戻る前に元へ戻す
この違いは、コンパイラのレジスタ割付や関数呼び出しコストに効きます。
つまり ABI は、アセンブラの約束であると同時に、コンパイラの最終判断でもあります。そこは コンパイラ の 関数呼び出しと ABI と往復すると理解しやすいです。
FFI で壊れやすい場所
外部言語やライブラリとつなぐときは、
- 引数サイズ
- 構造体レイアウト
- アラインメント
- 戻り値の渡し方
がずれると壊れます。見た目の関数シグネチャが合っていても、ABI がずれていれば危険です。
具体的なコード例
例1: 足し算する関数
高級言語で
long add(long a, long b) {
return a + b;
}
と書いたときのイメージは、x86-64 では次のようになります。
add:
mov rax, rdi
add rax, rsi
ret
引数 a, b が rdi, rsi に入ってきて、戻り値を rax に置いて返しています。
例2: if 文
long max2(long a, long b) {
if (a > b) {
return a;
}
return b;
}
max2:
cmp rdi, rsi
jg .a_is_bigger
mov rax, rsi
ret
.a_is_bigger:
mov rax, rdi
ret
例3: while ループで総和
long sum_to_n(long n) {
long s = 0;
long i = 1;
while (i <= n) {
s += i;
i += 1;
}
return s;
}
sum_to_n:
mov rax, 0
mov rcx, 1
.loop:
cmp rcx, rdi
jg .done
add rax, rcx
add rcx, 1
jmp .loop
.done:
ret
ここでは
rax: 累積和rcx: ループ変数rdi: 引数n
という役割です。
例4: 配列の先頭から最大値を探す
; rdi = 配列先頭アドレス
; rsi = 要素数
; 戻り値 rax = 最大値
find_max:
mov rax, [rdi]
mov rcx, 1
.loop:
cmp rcx, rsi
jge .done
mov rdx, [rdi + rcx*8]
cmp rdx, rax
jle .next
mov rax, rdx
.next:
add rcx, 1
jmp .loop
.done:
ret
これで、ポインタ、スケール付きアドレッシング、比較、分岐が一度に見えます。
例5: 再帰の骨格
long fact(long n) {
if (n <= 1) return 1;
return n * fact(n - 1);
}
イメージ上は次のような流れになります。
fact:
cmp rdi, 1
jle .base
push rdi
sub rdi, 1
call fact
pop rcx
imul rax, rcx
ret
.base:
mov rax, 1
ret
実際の最適化後コードはもっと違うことがありますが、call 前後で何を保存するかを見るにはよい例です。
例6: switch の骨格
switch は単純な if-else 連鎖になることもあれば、値が密ならジャンプテーブルになることもあります。アセンブラを読むと、コンパイラがどちらを選んだかが見えます。
Linux システムコール例
ライブラリを経由せず、直接カーネルへ入る最小例もアセンブラ理解には有効です。
x86-64 Linux で標準出力へ書く例
global _start
section .data
msg db "hello, world", 10
len equ $ - msg
section .text
_start:
mov rax, 1
mov rdi, 1
mov rsi, msg
mov rdx, len
syscall
mov rax, 60
xor rdi, rdi
syscall
これは
write(1, msg, len)exit(0)
に対応します。
何が見えるか
- システムコール番号をレジスタへ入れる
- 引数も決まったレジスタへ入れる
syscallでカーネルへ入る
OS や CPU の話とここがつながります。
ユーザモードからカーネルモードへ
この例では、ライブラリやランタイムをほとんど介さず、CPU 命令で直接カーネル境界を越えています。syscall 一発が、OS と CPU の境界そのものです。
x86-64 と ARM64 の見比べ方
ISA が違うと見た目はかなり変わりますが、役割は似ています。
x86-64 の足し算
mov rax, rdi
add rax, rsi
ret
ARM64 の足し算
add x0, x0, x1
ret
ARM64 では
x0,x1, … が引数や戻り値に使われる- 命令形式が比較的そろっている
という違いがあります。
Intel 記法と AT&T 記法
x86 には資料によって 2 つの記法が出ます。
- Intel syntax:
mov rax, rbx - AT&T syntax:
movq %rbx, %rax
オペランド順や即値表記が違うので、最初は混乱しやすいです。Linux の objdump 出力などでは AT&T を見ることがあります。
SIMD 命令の入口
スカラー命令だけでなく、複数要素を一度に処理する SIMD 命令も重要です。
例
addps xmm0, xmm1
は、xmm0 と xmm1 に入った複数の浮動小数点値をまとめて加算します。
なぜ見る価値があるか
- コンパイラの自動ベクトル化が見える
- 行列計算や画像処理の速さの理由が分かる
CPUの SIMD 節と直接つながる
逆アセンブルとデバッグの見方
アセンブラを学ぶときは、自分で全部書くより、まず逆アセンブルを見る方が実務的です。
代表的な道具
objdumpotoollldbgdb- Compiler Explorer
読む順番
- 関数の入口を探す
- 引数レジスタを見る
- 比較とジャンプを見る
callの前後で保存しているものを見る- 末尾の
retへどう到達するかを見る
デバッグ時に効く観点
- スタックトレースの意味
- フレームポインタの有無
- 最適化で変数が消える理由
- インライン展開で見え方が変わる理由
lldb で最初にやること
macOS では lldb が標準的です。最初は次の流れで十分です。
- 実行ファイルを読み込む
mainで止める- レジスタを見る
- 1 命令ずつ進める
- スタックを見る
典型的には次のように使います。
lldb ./asm_hello_macos_arm64
(lldb) breakpoint set --name main
(lldb) run
(lldb) register read
(lldb) disassemble --frame
(lldb) ni
(lldb) register read x0 x29 x30 sp
ここで見るべきなのは、
x0に何が入っているかspがどう動くかblの前後でx30がどう使われるか
です。x30 は link register で、ARM64 では戻り先アドレスを保持します。
実際に組み立てて動かす最小サンプル
読むだけでなく、実際に 1 本動かすと理解が一気に進みます。ここでは macOS ARM64 でそのまま動く最小サンプルを使います。
サンプルファイル
何をしているか
このプログラムは、
- 文字列のアドレスを
x0に入れる - C ランタイムの
_putsを呼ぶ - 戻り値
0をw0に入れて終了する
という最小構成です。
ビルドと実行
clang Tech/ComputerScience/scripts/asm_hello_macos_arm64.s -o /tmp/asm_hello_macos_arm64
/tmp/asm_hello_macos_arm64
どこを見るとよいか
stp x29, x30, [sp, #-16]!フレームポインタと戻り先を保存するadrpとadd文字列リテラルのアドレスを組み立てるbl _puts関数呼び出しldp x29, x30, [sp], #16保存した値を戻す
最初は 1 命令ずつ暗記するより、「入口で保存」「引数を置く」「呼ぶ」「戻す」という流れで見る方が理解しやすいです。
C からアセンブリ関数を呼ぶサンプル
アセンブリ単体より、C とつないで 1 関数だけ自分で書く方が ABI の理解には向いています。
サンプルファイル
アセンブリ関数がしていること
この関数は long add2(long a, long b) に相当します。
- 第 1 引数
aはx0 - 第 2 引数
bはx1 - 戻り値は
x0
なので、実装はほぼ 1 行です。
_add2:
add x0, x0, x1
ret
ビルドと実行
clang \
Tech/ComputerScience/scripts/asm_add2_main.c \
Tech/ComputerScience/scripts/asm_add2_macos_arm64.s \
-o /tmp/asm_add2_demo
/tmp/asm_add2_demo
このサンプルで見えること
- 高級言語の関数シグネチャがレジスタ配置へ落ちる
- 戻り値もレジスタで返る
- 簡単な関数はスタックフレームすら不要
これは コンパイラ の ABI や コード生成 が、最終的に何を意味しているかを具体化する最短ルートです。
コンパイラ出力をどう読むか
アセンブラを学ぶ最大の実利の一つは、コンパイラが吐いたコードを読めるようになることです。
見るポイント
- どのレジスタが引数か
- ループがどう比較とジャンプに分解されたか
- ローカル変数がスタックかレジスタか
- 関数呼び出し前後で何を保存しているか
- 最適化でどの命令が消えたか
-O0 と -O2 の違い
-O0: 元コードの形に近く、読みやすいが冗長-O2: レジスタ利用や分岐整理が進み、短くなるが追いにくい
最初は -O0 で構造を見て、その後 -O2 で最適化の差を見るのが学びやすいです。
この読み方は、コンパイラ の 最適化 と コード生成、CPU の パイプライン や 性能の見方 と強くつながります。
何が消え、何が残るか
最適化後に見えやすい変化は次です。
- 不要な load/store が消える
- ループ変数がレジスタへ載る
- 定数計算が先に済む
- 関数呼び出しが inline 化される
- 条件分岐が簡略化される
これを見ると、コンパイラが「ソースをそのまま翻訳しているだけではない」ことがよく分かります。
実務での使いどころ
- クラッシュダンプやバックトレースの理解
- パフォーマンスボトルネックの解析
- FFI 境界の調査
- コンパイラ最適化結果の確認
- セキュリティ脆弱性の理解
セキュリティとの接点
バッファオーバーフロー、ROP、スタック破壊、シェルコードなどは、アセンブラとスタックの理解があるとかなり見通しが良くなります。
まとめ
アセンブラは、レジスタ、スタック、分岐、呼び出し規約を具体的に見るための入口です。高級言語のコードが最終的にどう落ちるかを追うことで、コンパイラや CPU の話が現実の動きとして理解しやすくなります。