x64アセンブリ入門の世界へようこそ! 💻

低レイヤー技術

コンピュータの心臓部に触れる旅を始めましょう

1. アセンブリ言語とは? なぜ学ぶの? 🤔

アセンブリ言語は、コンピュータのCPU(中央処理装置)が直接理解できる機械語に非常に近い、低水準プログラミング言語です。C言語やPythonのような高水準言語は人間にとって理解しやすいように設計されていますが、アセンブリ言語はCPUの命令セットに直接対応するニーモニック(命令の記号)を使って記述されます。

「なぜ今さらアセンブリ言語を?」と思うかもしれません。確かに、現代のアプリケーション開発で直接アセンブリ言語を書く機会は多くありません。しかし、アセンブリ言語を理解することには、以下のような大きなメリットがあります。

  • コンピュータの動作原理の理解: CPUがどのように命令を実行し、メモリやレジスタを操作するのか、その根本的な仕組みを深く理解できます。
  • パフォーマンスの最適化: 高水準言語のコードがコンパイラによってどのようにアセンブリコードに変換されるかを知ることで、より効率的なコードを書くヒントが得られます。特定の処理を極限まで高速化したい場合、アセンブリ言語で記述することもあります。
  • デバッグ能力の向上: コンパイラが生成したコードや、ソースコードがないプログラムの動作を解析する際に、アセンブリ言語の知識は強力な武器となります。特に、原因不明のバグやクラッシュを追跡する際に役立ちます。
  • 低レイヤー領域への入り口: OSカーネル、デバイスドライバ、組み込みシステム、リバースエンジニアリング、セキュリティ分野など、ハードウェアに近い領域で活躍するためには、アセンブリ言語の知識が不可欠です。

この入門記事では、現代のPCで広く使われている x64アーキテクチャ (AMD64 や Intel 64 とも呼ばれます) のアセンブリ言語に焦点を当てて解説していきます。x64は、従来の32ビット(x86)アーキテクチャを64ビットに拡張したものです。

⚠️ 注意: アセンブリ言語はCPUアーキテクチャに強く依存します。この記事で扱うx64アセンブリは、ARMアーキテクチャ(スマートフォンや近年のMacなど)のアセンブリ言語とは異なります。

2. 構文:Intel記法 vs AT&T記法

x86/x64アセンブリ言語には、主に二つの構文(記法)が存在します。「Intel記法」と「AT&T記法」です。これらは見た目やオペランド(命令の対象となるデータ)の順序が異なります。

特徴Intel記法 (NASM, MASMなど)AT&T記法 (GASなど)
オペランドの順序命令 宛先, 送信元 (例: mov rax, 1)命令 送信元, 宛先 (例: movq $1, %rax)
レジスタ名の接頭辞なし (例: rax)% (例: %rax)
即値(定数)の接頭辞なし (例: 1)$ (例: $1)
メモリ参照の表記[ベースレジスタ + インデックスレジスタ * スケール + ディスプレイスメント] (例: [rbx + rcx*4 + 10])ディスプレイスメント(ベースレジスタ, インデックスレジスタ, スケール) (例: 10(%rbx, %rcx, 4))
命令サフィックス (サイズ指定)PTR演算子などで明示することが多い (例: mov DWORD PTR [rbx], 1)命令名の末尾に付くことが多い (例: movl $1, (%rbx) -> 4バイト)
コメント; 以降# 以降

Intel記法は、Intelの公式マニュアルで使われており、Windows環境での開発でよく見られます。一方、AT&T記法は、Unix/Linux環境で標準的に使われるGNUアセンブラ(GAS)のデフォルトです。

どちらの記法が良いかは好みの問題や開発環境によりますが、この記事では主にIntel記法を使用します。Intel記法の方がオペランドの順序(宛先が先)が直感的だと感じる人が多いようです。

💡 GASでも .intel_syntax noprefix ディレクティブを使うことでIntel記法で記述できますし、GDBなどのデバッガでも表示形式を切り替えられます。

3. CPUの心臓部:レジスタ 🧠

CPU内部には、データを一時的に高速に読み書きするための記憶領域があります。これがレジスタです。メモリ(RAM)に比べて容量は非常に小さいですが、アクセス速度は桁違いに高速です。アセンブリプログラミングでは、このレジスタを直接操作して計算やデータ転送を行います。

x64アーキテクチャには、主に以下の種類のレジスタがあります。

3.1 汎用レジスタ (General-Purpose Registers: GPRs)

計算やメモリアドレスの保持など、様々な用途に使えるレジスタです。x64では16個の64ビット汎用レジスタが利用可能です。

64ビット (Quadword)下位32ビット (Doubleword)下位16ビット (Word)下位8ビット (Byte)主な用途・慣習 (x64)呼び出し規約での扱い (System V ABI / Microsoft x64)
RAXEAXAXAL (AHも一部存在)アキュムレータ、関数の戻り値揮発性 / 揮発性
RBXEBXBXBL (BHも一部存在)ベースレジスタ不揮発性 / 不揮発性
RCXECXCXCL (CHも一部存在)カウンタ、関数引数4 (Microsoft)揮発性 (引数4) / 揮発性 (引数1)
RDXEDXDXDL (DHも一部存在)データレジスタ、関数引数3 (System V), 関数引数2 (Microsoft)揮発性 (引数3) / 揮発性 (引数2)
RSIESISISILソースインデックス、関数引数2 (System V)揮発性 (引数2) / 不揮発性
RDIEDIDIDILデスティネーションインデックス、関数引数1 (System V)揮発性 (引数1) / 不揮発性
RBPEBPBPBPLベースポインタ(スタックフレーム)不揮発性 / 不揮発性
RSPESPSPSPLスタックポインタ不揮発性 / 不揮発性
R8R8DR8WR8B汎用、関数引数5 (System V), 関数引数3 (Microsoft)揮発性 (引数5) / 揮発性 (引数3)
R9R9DR9WR9B汎用、関数引数6 (System V), 関数引数4 (Microsoft)揮発性 (引数6) / 揮発性 (引数4)
R10R10DR10WR10B汎用揮発性 / 揮発性
R11R11DR11WR11B汎用揮発性 / 揮発性
R12R12DR12WR12B汎用不揮発性 / 不揮発性
R13R13DR13WR13B汎用不揮発性 / 不揮発性
R14R14DR14WR14B汎用不揮発性 / 不揮発性
R15R15DR15WR15B汎用不揮発性 / 不揮発性
  • 赤色: x86から存在するレジスタの64ビット拡張版。
  • 青色: x64で新たに追加されたレジスタ。
  • 下位ビットへのアクセス: 例えば、RAXレジスタの下位32ビットはEAX、下位16ビットはAX、下位8ビットはALとしてアクセスできます。R8~R15も同様にR8D (32bit), R8W (16bit), R8B (8bit) のようにアクセス可能です。
  • 重要: 32ビットレジスタ(例: EAX)に書き込みを行うと、対応する64ビットレジスタ(例: RAX)の上位32ビットは自動的に0で埋められます(ゼロ拡張)。しかし、16ビットや8ビットレジスタへの書き込みでは、上位ビットは変更されません。
  • 呼び出し規約 (Calling Convention): 関数を呼び出す際のルールです。引数をどのレジスタに入れるか、どのレジスタの値は関数呼び出し後も保持されているべきか(不揮発性/Callee-saved)、どのレジスタの値は破壊されてもよいか(揮発性/Caller-saved)などが定められています。OSによって規約が異なります(LinuxなどはSystem V ABI、WindowsはMicrosoft x64 ABI)。

3.2 命令ポインタレジスタ (Instruction Pointer Register)

  • RIP (64-bit) / EIP (32-bit) / IP (16-bit): 次に実行される命令が格納されているメモリアドレスを指します。このレジスタは直接書き換えることは通常できず、ジャンプ命令やコール命令によって間接的に変更されます。

3.3 フラグレジスタ (Flags Register)

  • RFLAGS (64-bit) / EFLAGS (32-bit) / FLAGS (16-bit): 演算結果の状態(ゼロか、負か、桁あふれしたかなど)や、CPUの動作モードを制御するためのフラグ(ビット)が集まったレジスタです。条件分岐命令などは、このフラグレジスタの状態を見て動作を決定します。
  • 代表的なフラグ:
    • ZF (Zero Flag): 演算結果がゼロのとき1になる。
    • SF (Sign Flag): 演算結果が負のとき1になる(最上位ビットが1)。
    • CF (Carry Flag): 符号なし演算で桁あふれ(キャリー)またはボローが発生したとき1になる。
    • OF (Overflow Flag): 符号付き演算でオーバーフローが発生したとき1になる。

3.4 浮動小数点/SIMDレジスタ

整数だけでなく、浮動小数点数(float, double)や、SIMD(Single Instruction, Multiple Data)と呼ばれる並列演算に使用されるレジスタもあります。

  • XMM0 ~ XMM15: 128ビットのレジスタ。SSE (Streaming SIMD Extensions) 命令で使用。浮動小数点演算や整数ベクタ演算に使われる。関数呼び出し規約では、浮動小数点数の引数や戻り値に使われることが多い。
  • YMM0 ~ YMM15: 256ビットのレジスタ。AVX (Advanced Vector Extensions) 命令で使用。XMMレジスタの下位128ビット部分を共有する。
  • ZMM0 ~ ZMM31: 512ビットのレジスタ。AVX-512命令で使用。YMMレジスタの下位256ビット部分を共有する。(比較的新しいCPUでサポート)

入門段階では主に汎用レジスタ、命令ポインタ、フラグレジスタの理解が重要です。

4. 基本的な命令(ニーモニック)🛠️

アセンブリ言語の命令はニーモニックと呼ばれる短い英単語や略語で表現されます。ここでは、x64アセンブリで最も基本的で頻繁に使われる命令をいくつか紹介します。

ニーモニック説明例 (Intel記法)
MOVデータの移動(コピー)。レジスタ間、レジスタとメモリ間、即値からレジスタ/メモリへの移動。mov rax, rbx ; RBXの内容をRAXにコピー
mov rcx, 10 ; RCXに即値10を代入
mov byte ptr [rdi], al ; RDIが指すメモリ位置にALの内容(1バイト)を書き込む
mov rdx, qword ptr [rsi] ; RSIが指すメモリ位置から8バイト読み込みRDXへ
LEAアドレスのロード (Load Effective Address)。メモリ参照の計算結果(アドレスそのもの)をレジスタにロードする。実際のメモリアクセスは行わない。lea rax, [rbx + 8] ; RBX+8のアドレスをRAXに格納 (mov rax, [rbx+8] はメモリの内容を読む)
ADD加算。第一オペランドに第二オペランドを加算し、結果を第一オペランドに格納する。add rax, rbx ; RAX = RAX + RBX
add rcx, 5 ; RCX = RCX + 5
SUB減算。第一オペランドから第二オペランドを減算し、結果を第一オペランドに格納する。sub rax, rbx ; RAX = RAX – RBX
sub byte ptr [rdi], 1 ; RDIが指すメモリ位置の1バイトデータから1を引く
INCインクリメント。オペランドを1増やす。inc rcx ; RCX = RCX + 1
DECデクリメント。オペランドを1減らす。dec rax ; RAX = RAX – 1
MUL / IMUL乗算。MULは符号なし、IMULは符号付き。オペランドの組み合わせで動作が異なるが、基本はRAX(またはAL/AX/EAX)と指定オペランドを掛け、結果をRAXとRDX(またはAXとDXなど)に格納する。IMULには2オペランド、3オペランド形式もある。mul rbx ; RAX * RBX (符号なし64bit*64bit) の結果を RDX:RAX (128bit) に格納
imul rcx, rdx, 10 ; RCX = RDX * 10 (符号付き)
DIV / IDIV除算。DIVは符号なし、IDIVは符号付き。RDX:RAX (128bit) を指定オペランド (64bit) で割り、商をRAX、剰余をRDXに格納する。xor rdx, rdx ; RDXを0にする (除算の準備)
div rbx ; RDX:RAX / RBX の商をRAX、剰余をRDXに (符号なし)
AND, OR, XORビット単位の論理演算(AND, OR, XOR)。and rax, 0ffh ; RAXの下位8ビット以外を0にする
or rbx, 1 ; RBXの最下位ビットを1にする
xor rcx, rcx ; RCXを0にする(よく使われる高速なゼロクリア)
NOTビット単位の論理否定(NOT)。not rax ; RAXの全ビットを反転
SHL/SAL, SHR/SARビットシフト。SHL/SALは左シフト、SHRは論理右シフト(0で埋める)、SARは算術右シフト(符号ビットで埋める)。shl rax, 3 ; RAXを3ビット左シフト
sar rbx, 1 ; RBXを1ビット算術右シフト
CMP比較 (Compare)。第一オペランドと第二オペランドを引き算するが、結果は格納せず、フラグレジスタ(ZF, SF, CF, OFなど)のみを更新する。条件分岐命令の直前で使われる。cmp rax, rbx ; RAX – RBX を計算しフラグを更新
TESTビット単位のAND演算を行い、結果は格納せず、フラグレジスタ(ZF, SF, PF)のみを更新する。特定のビットが立っているか、あるいはオペランドがゼロかを調べるのに使う。test rcx, rcx ; RCXがゼロかどうか調べる(結果がゼロならZF=1)
test al, 1 ; ALの最下位ビットが1かどうか調べる(結果がゼロならZF=1)
JMP無条件ジャンプ。指定したラベル(アドレス)に実行を移す。jmp target_label
JE/JZ, JNE/JNZ, JG/JNLE, JL/JNGE, JGE/JNL, JLE/JNG, etc.条件付きジャンプ。CMPTESTの結果(フラグレジスタの状態)に基づいて、指定したラベルにジャンプするかどうかを決定する。多数の種類がある(例: JE=Jump if Equal, JNE=Jump if Not Equal, JG=Jump if Greater, JL=Jump if Less)。cmp rax, 0
je is_zero_label ; RAXが0ならis_zero_labelへジャンプ
CALL関数(サブルーチン)呼び出し。戻りアドレス(CALL命令の次の命令のアドレス)をスタックにプッシュし、指定したラベル(関数の開始アドレス)にジャンプする。call myFunction
RET関数(サブルーチン)からの復帰。スタックから戻りアドレスをポップし、そのアドレスにジャンプする。ret
PUSHスタックにデータをプッシュ(格納)する。RSPの値を減らして、そのアドレスにデータを書き込む。push rax ; RAXの内容(8バイト)をスタックにプッシュ
push 123 ; 即値123をスタックにプッシュ
POPスタックからデータをポップ(取り出し)。現在のRSPが指すアドレスからデータを読み込み、RSPの値を増やす。pop rbx ; スタックから8バイトポップしてRBXに格納
NOP何もしない (No Operation)。デバッグやアラインメント調整などに使われる。nop
SYSCALL / INT 0x80オペレーティングシステム(OS)の機能を呼び出す(システムコール)。具体的な呼び出し方はOSによって異なる(Linux x64ではSYSCALL、古いLinux 32bitではINT 0x80、Windowsでは通常ライブラリ関数経由)。syscall ; (Linux x64での例)

5. Hello, World! を書いてみよう 👋

理論ばかりでは面白くないので、実際に簡単なプログラムを書いてみましょう。ここでは、Linux環境で “Hello, World!” という文字列を画面(標準出力)に表示して終了するプログラムを、NASMアセンブラ(Intel記法)を使って作成します。

Linuxで画面に文字を表示するには、`write` システムコールを使います。また、プログラムを正常終了させるには `exit` システムコールを使います。

Linux x86-64 のシステムコール呼び出し規約 (System V ABI):

  • システムコール番号を `RAX` レジスタに入れる。
  • 引数を順番に `RDI`, `RSI`, `RDX`, `R10`, `R8`, `R9` レジスタに入れる。
  • `SYSCALL` 命令を実行する。
  • 戻り値は `RAX` レジスタに入る。

主要なシステムコール番号 (Linux x86-64):

  • `write` : 1
  • `exit` : 60

以下が “Hello, World!” プログラムのコード (`hello.asm`) です。


section .data
    message db "Hello, World!", 10  ; 表示する文字列。10は改行コード(LF)
    msglen equ $ - message         ; 文字列の長さを計算 ($は現在のアドレス)

section .text
    global _start               ; エントリーポイント(_start)を公開

_start:
    ; write(1, message, msglen) を呼び出す
    mov rax, 1                  ; システムコール番号 1 (write) をRAXへ
    mov rdi, 1                  ; 第1引数: ファイルディスクリプタ 1 (標準出力) をRDIへ
    mov rsi, message            ; 第2引数: 表示する文字列のアドレスをRSIへ
    mov rdx, msglen             ; 第3引数: 文字列の長さをRDXへ
    syscall                     ; システムコール実行!

    ; exit(0) を呼び出す
    mov rax, 60                 ; システムコール番号 60 (exit) をRAXへ
    xor rdi, rdi                ; 第1引数: 終了コード 0 をRDIへ (xor rdi, rdi は mov rdi, 0 より短い)
    syscall                     ; システムコール実行!
      

このコードをアセンブル・リンクして実行する手順は以下のようになります。


# 1. NASMでアセンブルしてオブジェクトファイル(hello.o)を生成
nasm -f elf64 hello.asm -o hello.o

# 2. リンカ(ld)でオブジェクトファイルをリンクして実行可能ファイル(hello)を生成
ld hello.o -o hello

# 3. 実行!
./hello
      

成功すれば、ターミナルに “Hello, World!” と表示されるはずです!🎉

⚠️ Windowsの場合: Windowsでコンソールに文字を表示するには、`write`システムコールではなく、`kernel32.dll`に含まれる`WriteFile`や`WriteConsoleA`といったWin32 API関数を呼び出すのが一般的です。API呼び出しはシステムコールよりも複雑な手順(スタックの準備、呼び出し規約の遵守など)が必要になります。

Windows用の簡単な例 (NASM + MinGW GCCでリンクする場合):


extern ExitProcess
extern GetStdHandle
extern WriteFile

section .data
    message db "Hello, Windows!", 13, 10 ; 文字列 (CR+LF)
    msglen equ $ - message

section .text
    global main

main:
    sub rsp, 40          ; スタック確保 (シャドウスペース + アライメント)

    ; hStdOut = GetStdHandle(STD_OUTPUT_HANDLE = -11)
    mov rcx, -11         ; 第1引数: STD_OUTPUT_HANDLE
    call GetStdHandle
    mov rbx, rax         ; ハンドルをRBXに保存

    ; WriteFile(hStdOut, message, msglen, &bytesWritten, NULL)
    mov rcx, rbx         ; 第1引数: ハンドル
    lea rdx, [message]   ; 第2引数: バッファ
    mov r8d, msglen      ; 第3引数: 書き込むバイト数 (DWORD)
    lea r9, [rsp+32]     ; 第4引数: 書き込まれたバイト数を受け取る変数のアドレス
    mov qword [rsp+32], 0 ; 第5引数: lpOverlapped (NULL)
                         ; Win64呼び出し規約では、5番目以降の引数はスタック経由
                         ; WriteFileの第5引数はスタックの[rsp+32]に置く
    call WriteFile

    ; ExitProcess(0)
    xor ecx, ecx         ; 第1引数: 終了コード 0
    call ExitProcess
    ; ここには戻らない

    ; add rsp, 40 ; 本来はスタックを戻すがExitProcessで不要
    ; ret         ; mainからはretしない
        

アセンブルとリンク (MinGW GCC使用):


nasm -f win64 hello_win.asm -o hello_win.obj
gcc hello_win.obj -o hello_win.exe -lkernel32 -nostdlib
        

6. 学習リソースと次のステップ 🚀

x64アセンブリの世界は奥深く、広大です。この入門記事で触れたのは、ほんの入り口にすぎません。さらに深く学ぶためのリソースや、次に進むべきステップをいくつか紹介します。

6.1 公式ドキュメント

6.2 書籍

  • 低レベルプログラミング (著: Igor Zhirkov、訳: 株式会社クイープ): アセンブリ言語だけでなく、OS、コンパイラ、ハードウェアなど、低レイヤー全般を扱っており、アセンブリ言語がコンピュータシステム全体の中でどのように位置づけられるかを理解するのに役立ちます。
  • Assembly Language Step-by-Step: Programming with Linux (著: Jeff Duntemann): Linux環境でのx86/x64アセンブリプログラミングを丁寧に解説しています(内容は少し古い可能性があります)。
  • (その他、特定のOSや目的に特化した書籍も多数あります)

6.3 オンラインリソース・チュートリアル

  • アセンブラ入門 (Webサイト): 検索すると日本語でも多くの入門サイトや記事が見つかります。例:「x64 アセンブリ チュートリアル」「NASM 入門」
  • Qiita, Zennなどの技術ブログ: 日本語で書かれた実践的な記事や解説が多くあります。
  • Stack Overflow: 具体的な疑問点を質問したり、過去のQ&Aを検索したりするのに役立ちます。
  • YouTubeなどの動画チュートリアル: 視覚的に学習したい場合に有効です。

6.4 次のステップ

  1. より多くの命令を学ぶ: 算術演算、論理演算、ビット操作、条件分岐、ループなどを自由に扱えるように、様々な命令とその使い方を学びましょう。
  2. アドレッシングモードを理解する: メモリへのアクセス方法(直接、間接、インデックス付きなど)を深く理解しましょう。LEA命令との違いも重要です。
  3. スタック操作をマスターする: 関数の引数渡し、ローカル変数の確保、レジスタの退避など、スタックの役割と操作方法をしっかり身につけましょう。PUSH, POP, RSP, RBP の動きを追うことが鍵です。
  4. 関数呼び出し規約を理解する: 異なるOS(Linux vs Windows)や、C言語など他の言語との連携のために、呼び出し規約の知識は必須です。
  5. デバッガを使ってみる: GDB (Linux) や WinDbg/x64dbg (Windows) などのデバッガを使って、アセンブリコードを一行ずつ実行したり、レジスタやメモリの内容を確認したりする練習をしましょう。プログラムの動作を理解する上で非常に強力なツールです。
  6. 簡単なアルゴリズムを実装してみる: 配列の合計計算、文字列のコピー、簡単なソートなど、基本的なアルゴリズムをアセンブリ言語で書いてみましょう。
  7. C言語コードの逆アセンブル: 簡単なC言語プログラムをコンパイルし、その結果生成されたアセンブリコードを読んでみましょう(例: gcc -S -masm=intel mycode.c)。高水準言語がどのようにアセンブリに変換されるかを知る良い方法です。

アセンブリ言語の学習は、最初は難しく感じるかもしれませんが、コンピュータの動作原理を理解する上で非常に rewarding な体験です。焦らず、一つ一つの概念を確実に理解しながら進めていきましょう。頑張ってください!💪

参考情報

コメント

タイトルとURLをコピーしました