アセンブラ

概要

命令・レジスタ・スタック・呼び出し規約を具体的なコードで理解する

アセンブラは、CPU が実行する命令列を人間が読める形で記述するための低水準言語です。高級言語と機械語のちょうど間にあり、コンパイラCPU をつなぐ位置にあります。

要点
アセンブラを学ぶと、関数呼び出し、スタック、レジスタ、条件分岐、メモリアクセスが具体的に見えるようになります。高級言語のコードが最終的にどう落ちるかを知ると、コンパイラや CPU の話が一気に現実的になります。

この章で重視すること

  • アセンブラを「昔の人の言語」ではなく、現在の実行モデルを理解する窓として使う
  • x86-64 を中心に、必要に応じて ARM64 も対比する
  • レジスタ、スタック、呼び出し規約、条件分岐を具体的なコードで理解する
  • C や Rust の関数が、どのような命令列に落ちるかを追う

目次

  1. アセンブラとは何か
  2. なぜ今でも学ぶ価値があるのか
  3. 機械語とアセンブリの関係
  4. レジスタ
  5. メモリアクセス
  6. フラグレジスタと比較
  7. 制御フロー
  8. スタックと関数呼び出し
  9. プロローグとエピローグ
  10. 呼び出し規約 ABI
  11. 具体的なコード例
  12. Linux システムコール例
  13. x86-64 と ARM64 の見比べ方
  14. SIMD 命令の入口
  15. 逆アセンブルとデバッグの見方
  16. 実際に組み立てて動かす最小サンプル
  17. C からアセンブリ関数を呼ぶサンプル
  18. コンパイラ出力をどう読むか
  19. 実務での使いどころ
  20. 参考文献

アセンブラとは何か

アセンブラは、CPU 命令をニーモニックで記述した低水準言語です。

たとえば

mov rax, 1
add rax, 2

は、

  • rax に 1 を入れる
  • rax に 2 を足す

という命令です。

高級言語と違って、

  • 変数の置き場所
  • 一時値の扱い
  • 分岐
  • 関数呼び出し

がかなり露骨に見えます。

なぜ今でも学ぶ価値があるのか

普段は C、Rust、Go、Java などで十分に開発できます。それでもアセンブラを知る価値があるのは、次の問いに答えやすくなるからです。

  • 関数呼び出しで何が保存されるのか
  • 再帰でスタックがどう増えるのか
  • 分岐予測ミスがなぜ痛いのか
  • コンパイラの最適化が何をしているのか
  • メモリ破壊がなぜ危険なのか

機械語とアセンブリの関係

CPU が本当に実行するのは機械語です。アセンブリは、その機械語を人間が扱いやすくした表記です。

flowchart LR High["高級言語"] Asm["アセンブリ"] Bin["機械語"] Cpu["CPU 実行"] High --> Asm --> Bin --> Cpu

コンパイラコード生成 は、まさにこの橋をどう渡るかを扱っています。

高級言語からこの形へ落ちる過程を先に見たいなら、コンパイラコード生成レジスタ割付関数呼び出しと ABI が直接つながります。

レジスタ

レジスタは、CPU の中にある非常に高速な記憶領域です。

x86-64 の代表例

  • rax
  • rbx
  • rcx
  • rdx
  • rsi
  • rdi
  • rsp
  • rbp

直感

  • レジスタは「いま使う値の机の上」
  • メモリは「少し遠い棚」

というイメージが役立ちます。

この 机の上 の感覚を、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: オーバーフローフラグ

なぜ重要か

アセンブラを読むとき、cmptest の直後に来るジャンプは「どのフラグを見ているのか」を意識すると理解しやすくなります。

cmptest

  • cmp a, b: a - b 相当の比較をしてフラグだけ更新
  • test a, b: AND 的な判定をしてフラグだけ更新

null 判定やビット判定で test がよく出ます。

制御フロー

高級言語の ifwhile は、最終的には比較とジャンプになります。

cmp rax, rbx
jl smaller

これは「rax < rbx なら smaller へ飛ぶ」という意味です。

代表的なジャンプ

  • jmp: 無条件ジャンプ
  • je: 等しければ飛ぶ
  • jne: 等しくなければ飛ぶ
  • jl: 小さければ飛ぶ
  • jg: 大きければ飛ぶ

ラベルを使って流れを読む

アセンブラでは、ifwhile のような構文はなく、ラベルとジャンプで制御フローを作ります。したがって読むときは

  1. 比較している場所
  2. どのラベルへ飛ぶか
  3. どこでループに戻るか

を先に見つけるのがコツです。

ループの形の見分け方

  • 末尾で先頭ラベルへ戻るなら while / for っぽい
  • 先頭で条件を見ずに本体へ入ってから末尾で条件を見るなら do while っぽい

といった読み方ができます。

スタックと関数呼び出し

関数呼び出しでは、戻り先、ローカル変数、一時退避領域などを管理する必要があります。そのために使われるのがスタックです。

flowchart TD Top["高アドレス側"] Ret["return address"] Saved["saved registers"] Local["local variables"] Spill["spill slots"] Bottom["低アドレス側"] Top --> Ret --> Saved --> Local --> Spill --> Bottom

よく出る命令

  • push
  • pop
  • call
  • ret

call は戻り先を積んでジャンプし、ret は戻り先を取り出して戻ります。

callret が CPU の内部でどう扱われるか、分岐予測や return stack buffer の観点まで追うなら CPUパイプラインと分岐予測 が補助になります。

スタックが伸びる方向

x86-64 では、多くの場合スタックは高アドレスから低アドレスへ伸びます。つまり pushrsp は小さくなり、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;
}

最適化前のイメージでは、xy がスタック上のスロットへ一度落ちることがあります。最適化後はレジスタだけで終わることも多いです。

呼び出し規約 ABI

同じ CPU でも、「引数をどのレジスタに置くか」「どのレジスタは呼び出し側が保存するか」は約束が必要です。これが ABI の一部です。

System V AMD64 ABI の例

整数系の先頭引数はよく次で渡されます。

  • rdi
  • rsi
  • rdx
  • rcx
  • r8
  • r9

戻り値は通常 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, brdi, 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 でカーネルへ入る

OSCPU の話とここがつながります。

ユーザモードからカーネルモードへ

この例では、ライブラリやランタイムをほとんど介さず、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

は、xmm0xmm1 に入った複数の浮動小数点値をまとめて加算します。

なぜ見る価値があるか

  • コンパイラの自動ベクトル化が見える
  • 行列計算や画像処理の速さの理由が分かる
  • CPU の SIMD 節と直接つながる

逆アセンブルとデバッグの見方

アセンブラを学ぶときは、自分で全部書くより、まず逆アセンブルを見る方が実務的です。

代表的な道具

  • objdump
  • otool
  • lldb
  • gdb
  • Compiler Explorer

読む順番

  1. 関数の入口を探す
  2. 引数レジスタを見る
  3. 比較とジャンプを見る
  4. call の前後で保存しているものを見る
  5. 末尾の ret へどう到達するかを見る

デバッグ時に効く観点

  • スタックトレースの意味
  • フレームポインタの有無
  • 最適化で変数が消える理由
  • インライン展開で見え方が変わる理由

lldb で最初にやること

macOS では lldb が標準的です。最初は次の流れで十分です。

  1. 実行ファイルを読み込む
  2. main で止める
  3. レジスタを見る
  4. 1 命令ずつ進める
  5. スタックを見る

典型的には次のように使います。

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 を呼ぶ
  • 戻り値 0w0 に入れて終了する

という最小構成です。

flowchart LR Start["main 開始"] Frame["スタックフレーム作成"] Arg["x0 に文字列アドレス"] Call["puts を呼ぶ"] Ret0["w0 に 0"] End["main から return"] Start --> Frame --> Arg --> Call --> Ret0 --> End

ビルドと実行

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]! フレームポインタと戻り先を保存する
  • adrpadd 文字列リテラルのアドレスを組み立てる
  • bl _puts 関数呼び出し
  • ldp x29, x30, [sp], #16 保存した値を戻す

最初は 1 命令ずつ暗記するより、「入口で保存」「引数を置く」「呼ぶ」「戻す」という流れで見る方が理解しやすいです。

C からアセンブリ関数を呼ぶサンプル

アセンブリ単体より、C とつないで 1 関数だけ自分で書く方が ABI の理解には向いています。

サンプルファイル

アセンブリ関数がしていること

この関数は long add2(long a, long b) に相当します。

  • 第 1 引数 ax0
  • 第 2 引数 bx1
  • 戻り値は 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 の話が現実の動きとして理解しやすくなります。