ARM64アセンブリ入門:基本から理解を深める

64ビットARMアーキテクチャの世界へようこそ!

現代のコンピューティング環境では、スマートフォン、タブレット、ラップトップ(Apple Silicon搭載Macなど)、さらにはサーバーやスーパーコンピューターに至るまで、ARMアーキテクチャが広く採用されています。特にその64ビット版であるARM64(AArch64とも呼ばれます)は、高いパフォーマンスと優れた電力効率により、その存在感を増しています。

普段、私たちがC言語、Python、Javaなどの高水準言語でプログラミングする際、CPUが直接理解できる「機械語」への翻訳はコンパイラやインタプリタが自動的に行ってくれます。しかし、ソフトウェアが実際にどのように動作しているのか、パフォーマンスを極限まで追求したい、あるいは低レイヤーのセキュリティについて学びたいといった場合、CPUの言語であるアセンブリ言語の知識が役立ちます。

このブログ記事では、ARM64アセンブリ言語の基本的な概念を解説し、その世界への第一歩を踏み出すお手伝いをします。低レイヤーの世界は一見難解に思えるかもしれませんが、基本を理解すれば、コンピューターの動作原理に対する理解が格段に深まるはずです 。

ARM64アーキテクチャの概要

ARM64は、Arm Holdings社によって設計されたARMアーキテクチャの64ビット版で、正式にはARMv8-Aアーキテクチャの一部として2011年に発表されました。それまでの32ビットアーキテクチャ(AArch32)との互換性を保ちつつ、64ビット処理に対応することで、より大きなメモリ空間(最大256TBの物理アドレス空間、48ビットの仮想アドレス空間)を扱えるようになり、パフォーマンスが大幅に向上しました。

主な特徴

  • RISC (Reduced Instruction Set Computer) ベース: シンプルで固定長の命令セットを持ち、パイプライン処理に適しています。これにより、高速な処理と低消費電力を両立しています。
  • 多数の汎用レジスタ: 31個の64ビット汎用レジスタ(X0~X30)を持ち、より多くのデータをCPU内部に保持できるため、メモリアクセスを減らし、処理を高速化できます。
  • 優れた電力効率: モバイルデバイスでの長年の経験から、電力効率を重視した設計がされています。これは、バッテリー寿命が重要なラップトップや、運用コスト削減が求められるデータセンターにおいても大きな利点となります。
  • スケーラビリティ: 低消費電力の組み込みシステムから高性能サーバーまで、幅広い用途に対応できるスケーラビリティを持っています。
  • セキュリティ機能: TrustZone(セキュアな実行環境を提供する技術)やポインタ認証(メモリ破壊攻撃への対策)など、ハードウェアレベルでのセキュリティ機能が組み込まれています。
  • SIMD (Single Instruction, Multiple Data) 命令: Neonと呼ばれる拡張命令セットにより、マルチメディア処理や科学技術計算などで必要とされるベクトル演算を効率的に実行できます。32個の128ビットレジスタ(V0~V31)を使用します。

これらの特徴により、ARM64はモバイル分野だけでなく、Appleが2020年からMacに採用した「Apple Silicon」や、Amazon Web Services (AWS) の「Graviton」プロセッサなど、デスクトップやサーバー市場でも急速にシェアを拡大しています。

アセンブリ言語の基本要素

レジスタ:CPU内の高速メモリ

レジスタは、CPU内部にある非常に高速な記憶領域です。メモリ(RAM)からデータを読み書きするよりもはるかに高速にアクセスできるため、計算途中の一時的な値や頻繁に使うデータを保持するために使われます。ARM64には主に以下のレジスタがあります。

レジスタの種類 名前(例) ビット幅 主な用途
汎用レジスタ X0X30 64ビット データの一時保存、演算、関数引数・戻り値の受け渡し
汎用レジスタ(32ビットアクセス) W0W30 32ビット X0X30の下位32ビットへのアクセス
スタックポインタ SP 64ビット 現在のスタックの頂上(末尾)を指すアドレス
ゼロレジスタ XZR / WZR 64/32ビット 常に0を返す。特定の命令でX31/W31として扱われる
プログラムカウンタ PC 64ビット 次に実行する命令のアドレスを保持(直接アクセスは不可)
リンクレジスタ LR (X30) 64ビット 関数呼び出し時に、呼び出し元に戻るためのアドレスを保持
浮動小数点/SIMDレジスタ V0V31 128ビット 浮動小数点演算、ベクトル演算(Neon命令)に使用(D0D31: 64ビット, S0S31: 32ビット, H0H31: 16ビット, B0B31: 8ビットとしてもアクセス可能)
プロセス状態レジスタ PSTATE 演算結果の状態(ゼロ、負、キャリー、オーバーフロー)を示すフラグ(NZCVフラグ)などを保持

汎用レジスタX0からX30は、様々な用途に使われます。特にX0からX7は関数の引数を渡すため、X0は関数の戻り値を返すために使われることが多いです(後述の呼び出し規約で詳しく説明します)。X30はリンクレジスタLRとしても機能します。

命令の基本構造

アセンブリ言語の各行は、通常、一つの命令を表します。基本的な構造は以下のようになります。

[ラベル:] ニーモニック [オペランド1 [, オペランド2 [, ...]]] [; コメント]
  • ラベル(オプション): 命令やデータが配置されるメモリアドレスに名前を付けます。分岐命令の飛び先などで使われます。例:loop_start:
  • ニーモニック: 実行される操作を表す短い英単語(記憶補助語)。CPUが実行する命令の種類を示します。例:MOV, ADD, LDR
  • オペランド(命令による): 命令が操作する対象(データやレジスタ、メモリアドレスなど)を指定します。数は命令によって異なります。例:X0, #10, [SP, #16]
  • コメント(オプション): セミコロン(;)またはスラッシュ2つ(//)以降はコメントとして扱われ、プログラムの説明などを記述します。

例:

start:          ; プログラム開始ラベル
    MOV X0, #10   ; レジスタX0に即値10をロードする (X0 = 10)
    MOV X1, #20   ; レジスタX1に即値20をロードする (X1 = 20)
    ADD X0, X0, X1 ; X0 = X0 + X1 (X0 = 10 + 20 = 30)

データ型とエンディアン

ARM64は以下のデータサイズを扱えます。

  • Byte: 8ビット
  • Halfword: 16ビット
  • Word: 32ビット
  • Doubleword: 64ビット

また、ARM64は通常リトルエンディアンで動作します。これは、複数バイトで構成されるデータをメモリに格納する際に、下位バイト(小さい桁の値)を小さいメモリアドレスに格納する方式です。(一部のARMプロセッサはビッグエンディアンもサポートしますが、AArch64ではリトルエンディアンが一般的です)。

よく使われるARM64命令

ARM64には多くの命令がありますが、ここでは基本的なデータ操作、算術演算、メモリ操作、分岐に関する代表的な命令を紹介します。

データ転送命令

  • MOV <Xd>, <Xn|imm>: レジスタ間、または即値(プログラム中に直接書かれた値、#で始まる)をレジスタに移動(コピー)します。
    MOV X0, X1      ; X0 = X1
    MOV W2, #100    ; W2 = 100 (32ビット即値)
    MOV X3, #0xFF   ; X3 = 255 (64ビット即値)

メモリアクセス命令

  • LDR <Xt>, [<Xn|SP>, #offset]: メモリからレジスタにデータをロード(読み込み)します。[]内はメモリアドレスを指定し、ベースレジスタ(XnまたはSP)にオフセットを加えたアドレスからデータを読み込みます。
    LDR X0, [X1]        ; X0 = メモリ[X1]の内容 (オフセット0)
    LDR W1, [SP, #16]   ; W1 = メモリ[SP + 16]の内容 (32ビット)
    LDR X2, [X3, #8]!   ; Pre-index: X3 = X3 + 8 してから X2 = メモリ[X3]
    LDR X4, [X5], #16   ; Post-index: X4 = メモリ[X5] してから X5 = X5 + 16
    !が付く形式(Pre-index)は、アドレス計算後にベースレジスタの値を更新します。[]の後にオフセットがある形式(Post-index)は、ロード後にベースレジスタを更新します。
  • STR <Xt>, [<Xn|SP>, #offset]: レジスタのデータをメモリにストア(書き込み)します。アドレッシングモードはLDRと同様です。
    STR X0, [X1]        ; メモリ[X1] = X0
    STR W1, [SP, #16]   ; メモリ[SP + 16] = W1 (32ビット)
    STR X2, [X3, #-8]!  ; Pre-index: X3 = X3 - 8 してから メモリ[X3] = X2
  • LDP <Xt1>, <Xt2>, [<Xn|SP>, #offset]: メモリの連続した領域から2つのレジスタに同時にロードします(Load Pair)。
  • STP <Xt1>, <Xt2>, [<Xn|SP>, #offset]: 2つのレジスタの値をメモリの連続した領域に同時にストアします(Store Pair)。スタックへのレジスタ退避などで効率的に利用されます。
    STP X29, X30, [SP, #-16]! ; SP = SP - 16 してから メモリ[SP] = X29, メモリ[SP+8] = X30
    LDP X29, X30, [SP], #16   ; メモリ[SP]をX29に, メモリ[SP+8]をX30にロード後、SP = SP + 16
    これは関数プロローグ・エピローグでフレームポインタ(X29)とリンクレジスタ(X30)をスタックに保存・復元する典型的な例です。

算術演算命令

  • ADD <Xd>, <Xn>, <Xm|imm>: 加算。Xd = Xn + Xm または Xd = Xn + imm
  • SUB <Xd>, <Xn>, <Xm|imm>: 減算。Xd = Xn - Xm または Xd = Xn - imm
  • MUL <Xd>, <Xn>, <Xm>: 乗算。Xd = Xn * Xm
  • SDIV <Xd>, <Xn>, <Xm>: 符号付き除算。Xd = Xn / Xm
  • UDIV <Xd>, <Xn>, <Xm>: 符号無し除算。Xd = Xn / Xm
ADD X0, X1, X2      ; X0 = X1 + X2
SUB W3, W4, #10     ; W3 = W4 - 10
MUL X5, X6, X7      ; X5 = X6 * X7

論理演算・シフト命令

  • AND <Xd>, <Xn>, <Xm|imm>: 論理積 (AND)
  • ORR <Xd>, <Xn>, <Xm|imm>: 論理和 (OR)
  • EOR <Xd>, <Xn>, <Xm|imm>: 排他的論理和 (XOR)
  • LSL <Xd>, <Xn>, <#shift>: 論理左シフト
  • LSR <Xd>, <Xn>, <#shift>: 論理右シフト
  • ASR <Xd>, <Xn>, <#shift>: 算術右シフト(符号ビットを維持)
AND X0, X0, #0xFF   ; X0の下位8ビット以外を0にする
ORR W1, W1, #0x10   ; W1のビット4を1にする
LSL X2, X3, #3      ; X2 = X3 を3ビット左シフト (X3 * 8)

比較命令と分岐命令

プログラムの流れを制御するために、比較命令と分岐命令を使います。

  • CMP <Xn>, <Xm|imm>: 2つの値を比較します(内部的にはXn - XmまたはXn - immを実行)。結果はPSTATEレジスタのフラグ(N, Z, C, V)に反映され、後続の条件分岐命令で使われます。
  • TST <Xn>, <Xm|imm>: ビット単位のテスト(内部的にはXn AND XmまたはXn AND immを実行)。結果がゼロかどうかなどをフラグに反映します。
  • B.<cond> <label>: 条件付き分岐。指定した条件(<cond>)が満たされる場合、<label>にジャンプします。
    条件コード (<cond>)説明フラグ
    EQ等しい (Equal)Z=1
    NE等しくない (Not Equal)Z=0
    CS / HSキャリーセット / 符号無しで以上 (Carry Set / Unsigned Higher or Same)C=1
    CC / LOキャリークリア / 符号無しでより小さい (Carry Clear / Unsigned Lower)C=0
    MI負 (Minus / Negative)N=1
    PL正またはゼロ (Plus / Positive or Zero)N=0
    VSオーバーフロー (Overflow Set)V=1
    VCオーバーフローなし (Overflow Clear)V=0
    HI符号無しでより大きい (Unsigned Higher)C=1 and Z=0
    LS符号無しで以下 (Unsigned Lower or Same)C=0 or Z=1
    GE符号付きで以上 (Signed Greater than or Equal)N=V
    LT符号付きでより小さい (Signed Less Than)N!=V
    GT符号付きでより大きい (Signed Greater Than)Z=0 and N=V
    LE符号付きで以下 (Signed Less than or Equal)Z=1 or N!=V
    AL常に (Always) – 無条件分岐Bと同じ
  • B <label>: 無条件分岐。常に<label>にジャンプします。
  • BL <label>: 分岐してリンク (Branch with Link)。<label>にジャンプする前に、次の命令のアドレスをリンクレジスタ(LR / X30)に保存します。関数呼び出しに使用されます。
  • RET [<Xn>]: サブルーチンからのリターン。通常、LRに保存されたアドレスにジャンプして戻ります。オプションで指定したレジスタのアドレスに戻ることも可能です。
    CMP W0, #10         ; W0 と 10 を比較
    B.EQ equal_label    ; もし W0 == 10 なら equal_label へジャンプ
    ; ... W0が10でない場合の処理 ...
    B end_label         ; 処理終了へジャンプ
equal_label:
    ; ... W0が10の場合の処理 ...
end_label:
    ; ... 処理終了 ...

    BL my_function      ; my_function を呼び出す(戻りアドレスはLRに保存)
    ; ... my_function から戻ってきた後の処理 ...

my_function:
    ; ... 関数の処理 ...
    RET                 ; LRのアドレス(呼び出し元の次の命令)へ戻る

関数呼び出し規約 (AAPCS64)

複数の関数が連携して動作するためには、引数の渡し方、戻り値の返し方、レジスタの使い分けなどについて共通のルールが必要です。ARM64では、Procedure Call Standard for the Arm 64-bit Architecture (AAPCS64) という標準規約が定められています。主要なルールは以下の通りです。

  • 引数渡し:
    • 最初の8個の整数・ポインタ引数は、レジスタX0からX7(またはW0からW7)を使って渡されます。
    • 最初の8個の浮動小数点・SIMD引数は、レジスタV0からV7(またはD0D7, S0S7など)を使って渡されます。
    • 9個目以降の引数や、サイズが大きい構造体などはスタックを使って渡されます。
  • 戻り値:
    • 整数・ポインタの戻り値はX0(またはW0)を使って返されます。
    • 浮動小数点・SIMDの戻り値はV0(またはD0, S0など)を使って返されます。
    • サイズが大きい構造体などは、呼び出し元が用意したメモリ領域へのポインタをX8で渡し、そこへ格納して返します。
  • レジスタの役割:
    • 呼び出し元保存 (Caller-Saved) / スクラッチレジスタ: X0X18 (LR=X30を除く), V0V7, V16V31。関数を呼び出す側は、これらのレジスタの値が必要なら呼び出し前に自分で退避・復元する必要があります。呼び出された関数はこれらのレジスタを自由に書き換えて構いません。X16(IP0)とX17(IP1)はプロシージャ内呼び出しの一時レジスタとしてリンカが使用することがあります。
    • 呼び出し先保存 (Callee-Saved) / 退避レジスタ: X19X29, V8V15の下位64ビット(D8D15)。呼び出された関数は、これらのレジスタを使用する場合、関数の最初で値をスタックなどに退避し、関数の最後で元の値に復元しなければなりません。X29はフレームポインタ(FP)として使われることが多いです。
    • SP, PC, PSTATEは特殊なレジスタです。LR(X30)はBL命令で自動的に更新されます。
  • スタック:
    • スタックはフルディスクセンディング (Full Descending) 方式で、スタックポインタ(SP)はスタックの一番上の有効なデータ(最後に積まれたデータ)を指し、スタックはアドレスの大きい方から小さい方へ伸長します。
    • スタックポインタ(SP)は常に16バイト境界にアラインされている必要があります。
    • 関数内でスタックを使用する場合、通常、関数の最初(プロローグ)でSPを減算して領域を確保し、関数の最後(エピローグ)でSPを加算して領域を解放します。

これらの規約に従うことで、アセンブリ言語で書かれた関数と、C/C++などの高水準言語でコンパイルされた関数が互いに呼び出しあえるようになります。

簡単な例:C言語関数とアセンブリ

簡単なC言語の関数が、どのようにARM64アセンブリにコンパイルされるか見てみましょう。(コンパイラの最適化レベルによって出力は異なります)

C言語のコード

long add_numbers(long a, long b) {
    long result = a + b;
    return result;
}

対応するARM64アセンブリ(概念的な例)

add_numbers:
    ; AAPCS64に基づき、引数 'a' は X0 に、'b' は X1 に渡される
    ADD X0, X0, X1  ; result = a + b. 結果を X0 に格納
    ; 戻り値は X0 に格納されているので、そのままリターン
    RET             ; LR(呼び出し元の次のアドレス)に戻る

この例では、C言語のadd_numbers関数がアセンブリのadd_numbersラベルに対応します。

  1. 呼び出し規約に従い、引数aがレジスタX0に、引数bがレジスタX1に入ってきます。
  2. ADD X0, X0, X1命令で、X0X1を加算し、結果をX0に格納します。C言語のresult = a + b;に相当します。
  3. 呼び出し規約では、戻り値はX0で返すことになっているため、加算結果が格納されたX0をそのままにしておきます。
  4. RET命令で、リンクレジスタLRに保存されている呼び出し元の次の命令のアドレスに処理を戻します。

実際のコンパイラは、スタックフレームの設定など、もう少し複雑なコードを生成することもありますが、基本的な処理の流れはこのようになります。

開発ツール

ARM64アセンブリ言語でプログラミングするには、いくつかのツールが必要です。

  • アセンブラ: アセンブリ言語のコード(.sファイル)を、機械が理解できるオブジェクトコード(.oファイル)に変換します。
    • GNU AS (gas): GCC (GNU Compiler Collection) に含まれるアセンブラ。Linux環境で広く使われています。
    • Clang/LLVM Assembler: LLVMツールチェインに含まれるアセンブラ。macOS (Xcode) や他の環境で標準的に使われています。GNU ASと互換性のある構文もサポートしています。
  • リンカ: 1つまたは複数のオブジェクトファイルとライブラリファイルを結合し、実行可能なファイルを作成します。
    • GNU ld: GCCに含まれるリンカ。
    • LLVM lld: LLVMのリンカ。
    • macOS Linker: Xcodeに含まれるリンカ。
  • デバッガ: プログラムをステップ実行したり、レジスタやメモリの内容を確認したりして、バグの原因を特定するのに役立ちます。
    • GDB (GNU Debugger): 高機能なコマンドラインデバッガ。
    • LLDB: LLVMプロジェクトのデバッガ。macOSの標準デバッガです。
  • 逆アセンブラ: 実行可能ファイルやオブジェクトファイルから、アセンブリ言語のコードを復元します。既存のプログラムの動作を解析するのに使われます。
    • objdump: GNU Binutilsに含まれるツール。objdump -d <file>で逆アセンブルできます。
    • Ghidra: NSAによって開発された高機能なソフトウェアリバースエンジニアリングツール(無償)。
    • IDA Pro: 商用の高機能逆アセンブラ・デバッガ。
  • テキストエディタ/IDE: コードを書くためのエディタ。VSCode, Vim, Emacs, Sublime Textなど、シンタックスハイライト機能があると便利です。

macOSで開発する場合、Xcode Command Line Tools をインストールすれば、Clang (アセンブラ、コンパイラ)、リンカ、LLDBなどが一通り揃います。Linuxでは、GCC (build-essentialパッケージなど) や LLVM をインストールすることで必要なツールが揃います。

# macOS でのコンパイルとリンクの例 (Clangを使用)
clang -c my_program.s -o my_program.o  # アセンブル
clang my_program.o -o my_program       # リンク
./my_program                           # 実行

# Linux でのコンパイルとリンクの例 (GCC/GNU ASを使用)
as my_program.s -o my_program.o      # アセンブル
ld my_program.o -o my_program          # リンク
./my_program                           # 実行

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

ARM64アセンブリについてさらに深く学ぶためのリソースをいくつか紹介します。

  • Arm Architecture Reference Manual (ARM ARM): Armv8-Aアーキテクチャの公式かつ最も詳細なドキュメントです。Arm Developerサイトから入手できます。(非常に詳細で網羅的ですが、初心者には少し難しいかもしれません)
    Arm Architecture Reference Manual Armv8, for Armv8-A architecture profile
  • Procedure Call Standard for the Arm 64-bit Architecture (AAPCS64): 関数呼び出し規約の詳細な仕様です。
    AAPCS64 Specification on GitHub
  • オンラインチュートリアルやブログ記事:
    • “ARM64アセンブリ 入門” や “AArch64 assembly tutorial” といったキーワードで検索すると、多くの解説記事が見つかります。
    • 特定のプラットフォーム(Linux, macOS/Apple Silicon)向けの記事も参考になります。例えば、Apple Silicon特有の点についてはAppleのドキュメントも役立ちます。
      Writing ARM64 Code for Apple Platforms
    • Jun Mizutani氏のウェブサイトには、LinuxにおけるARM64アセンブリプログラミングの詳細な解説があります。
      Linux で Arm64 アセンブリプログラミング
  • 書籍:
    • 『ARM 64-Bit Assembly Language』 (Larry D. Pyeatt, William Ughetta 著): Raspberry Piをターゲットとしていますが、ARM64アセンブリの基礎を学ぶのに役立ちます。
    • WikibooksにもARM64アセンブリ言語に関するページがあります。
      Wikibooks: ARM64アセンブリ言語
  • 実践: 自分で簡単なプログラム(Hello World、簡単な計算、ループなど)を書いて、アセンブル、リンク、デバッグしてみることが最も効果的な学習方法です。Cコンパイラが生成したアセンブリコード(gcc -S code.cclang -S code.c)を読んでみるのも理解を深めるのに役立ちます。

まとめ

この記事では、ARM64アーキテクチャの概要から、基本的なアセンブリ言語の要素(レジスタ、命令、アドレッシングモード)、代表的な命令、関数呼び出し規約、開発ツール、学習リソースまでを紹介しました。

アセンブリ言語の学習は、コンピュータが内部でどのように動作しているかを理解するための強力な手段です。最初は難しく感じるかもしれませんが、一つ一つの命令や概念を丁寧に学んでいけば、必ず理解が深まります。パフォーマンスチューニング、組み込みシステム開発、セキュリティ解析など、様々な分野でこの知識は役立つでしょう。

ぜひ、実際にコードを書いて動かしながら、ARM64アセンブリの世界を探求してみてください!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です