Apple Siliconアセンブリ言語入門ガイド 第1回:基礎知識と環境構築
はじめに
Apple SiliconはAppleが独自に設計したARMアーキテクチャベースのプロセッサです。2020年のM1チップ以降、MacはIntelプロセッサからApple Siliconへと完全に移行しました。このガイドでは、Apple Silicon(ARM64アーキテクチャ)でのアセンブリ言語プログラミングを基礎から丁寧に学んでいきます。
アセンブリ言語は、プロセッサが直接理解できる機械語に最も近い低レベル言語です。学習することで、コンピュータの動作原理を深く理解でき、パフォーマンスが重要な場面や、OSの内部動作を知りたい場合に役立ちます。
Apple Siliconの特徴とアーキテクチャ
ARM64アーキテクチャとは
Apple SiliconはARM64(AArch64とも呼ばれる)命令セットアーキテクチャを採用しています。これは従来のIntel x86-64とは全く異なる設計思想に基づいています。
主な特徴:
- RISC(Reduced Instruction Set Computer)設計
- 命令セットがシンプルで規則性が高い
- 1つの命令で1つの処理を行う
- 命令の実行速度が均一で予測しやすい
- 豊富なレジスタ
- 31個の汎用64ビットレジスタ(X0-X30)
- 各レジスタの下位32ビットにW0-W30としてアクセス可能
- x86-64の16個と比べて作業領域が広い
- 固定長命令
- すべての命令が32ビット(4バイト)
- 命令のデコードが高速
- 分岐予測が効率的
- ロード/ストアアーキテクチャ
- 演算はレジスタ間でのみ実行
- メモリアクセスは専用のロード/ストア命令で行う
- x86のようにメモリを直接演算に使えない
Intel x86-64との違い
従来のIntel Macを使っていた方のために、主な違いを整理します:
| 特徴 | ARM64 (Apple Silicon) | x86-64 (Intel) |
|---|---|---|
| 設計思想 | RISC | CISC |
| 命令長 | 固定(32ビット) | 可変(1-15バイト) |
| レジスタ数 | 31個 | 16個 |
| メモリアクセス | ロード/ストア専用命令 | 多くの命令で直接可能 |
| 呼び出し規約 | 最大8個をレジスタ渡し | 最大6個をレジスタ渡し |
開発環境の準備
必要なツール
Apple Siliconでアセンブリプログラミングを始めるには、以下のツールが必要です:
- Xcode Command Line Tools - アセンブラ、リンカなどが含まれる
- テキストエディタ - VSCode、Vim、Emacsなど任意
- ターミナル - コマンド実行用
インストール手順
ターミナルを開いて以下のコマンドを実行します:
# Xcodeコマンドラインツールのインストール
xcode-select --install
ダイアログが表示されるので、指示に従ってインストールを完了させます。
インストール確認:
# アセンブラの確認
as --version
# 出力例: Apple clang version 15.0.0
# リンカの確認
ld -v
# 出力例: @(#)PROGRAM:ld PROJECT:ld-1053.12
# コンパイラの確認(C言語と混在させる場合に使用)
clang --version
開発ワークフローの基本
アセンブリプログラムの開発は以下の流れで行います:
- エディタでソースコード作成 - .s または .asm 拡張子で保存
- アセンブル - ソースコードをオブジェクトファイルに変換
- リンク - オブジェクトファイルを実行可能ファイルに変換
- 実行 - プログラムを動かして動作確認
- デバッグ - 必要に応じてlldbでデバッグ
レジスタの詳細
汎用レジスタ(64ビット:X0-X30)
ARM64には31個の汎用レジスタがあり、それぞれ特定の用途が推奨されています:
引数と戻り値用レジスタ
- X0-X7: 関数の引数(第1引数から第8引数)
- X0: 第1引数、かつ関数の戻り値
- X1: 第2引数
- X2-X7: 第3-8引数
- X8: 間接的な戻り値用(構造体を返す場合など)
一時レジスタ(Caller-saved)
- X9-X15: 関数呼び出しで保存不要
- 関数内で自由に使える
- 関数を呼ぶ側が保存する責任がある
プラットフォームレジスタ
- X16, X17: IPレジスタ(Intra-Procedure-call registers)
- システムコール番号の格納(X16)
- ダイナミックリンカが使用
- X18: プラットフォーム予約レジスタ
- 使用しないこと
保存レジスタ(Callee-saved)
- X19-X28: 関数呼び出し時に保存が必要
- 関数内で使う場合は、最初に保存して最後に復元
- 呼ばれる側が保存する責任がある
特殊用途レジスタ
- X29: FP(Frame Pointer)
- スタックフレームの基底アドレス
- デバッグに便利
- X30: LR(Link Register)
- 関数の戻りアドレスを保存
- BL命令実行時に自動設定される
- SP: スタックポインタ
- スタックの先頭を指す
- 独立したレジスタ(X31ではない)
32ビットレジスタ(W0-W30)
各64ビットレジスタの下位32ビットには、W0-W30としてアクセスできます:
mov x0, #0x123456789ABCDEF0 // 64ビット値を設定
mov w0, #42 // 下位32ビットのみ設定(上位32ビットはゼロクリア)
重要な注意点:
- Wレジスタへの書き込みは、対応するXレジスタの上位32ビットをゼロクリアします
- これは意図しないバグの原因になることがあるので注意
その他の重要なレジスタ
ゼロレジスタ
- XZR/WZR: 読み出すと常に0、書き込みは無視される
- 比較やゼロ初期化に便利
プログラムカウンタ
- PC: 現在実行中の命令のアドレス
- 直接アクセスできないが、相対アドレッシングで使用
ステータスレジスタ
- PSTATE: プロセッサの状態を保持
- 条件フラグ(N, Z, C, V)を含む
基本的な命令セット
データ移動命令
MOV命令 - レジスタへの値の設定
mov x0, #42 // x0 = 42(即値)
mov x1, x0 // x1 = x0(レジスタコピー)
mov w2, #100 // w2 = 100(32ビット)
MOV命令には制限があり、大きな即値は直接設定できません。16ビットの即値のみ使用可能です。
MOVZ, MOVK, MOVN - 大きな値の設定
// 64ビットの大きな値を設定する方法
movz x0, #0x1234, lsl #48 // x0 = 0x1234000000000000
movk x0, #0x5678, lsl #32 // x0 = 0x1234567800000000
movk x0, #0x9ABC, lsl #16 // x0 = 0x123456789ABC0000
movk x0, #0xDEF0 // x0 = 0x123456789ABCDEF0
// または
movz x1, #0xFFFF // x1 = 0x000000000000FFFF
movn x2, #0 // x2 = 0xFFFFFFFFFFFFFFFF(-1)
算術演算命令
加算と減算
add x0, x1, x2 // x0 = x1 + x2
add x0, x1, #100 // x0 = x1 + 100
sub x0, x1, x2 // x0 = x1 - x2
sub x0, x1, #50 // x0 = x1 - 50
// フラグを更新する加算・減算
adds x0, x1, x2 // x0 = x1 + x2、フラグ更新
subs x0, x1, x2 // x0 = x1 - x2、フラグ更新
乗算と除算
mul x0, x1, x2 // x0 = x1 * x2
sdiv x0, x1, x2 // x0 = x1 / x2(符号付き)
udiv x0, x1, x2 // x0 = x1 / x2(符号なし)
// 余りを求める場合は、除算後に計算が必要
udiv x3, x1, x2 // x3 = x1 / x2
msub x4, x3, x2, x1 // x4 = x1 - (x3 * x2) = 余り
論理演算命令
and x0, x1, x2 // x0 = x1 & x2(AND)
orr x0, x1, x2 // x0 = x1 | x2(OR)
eor x0, x1, x2 // x0 = x1 ^ x2(XOR)
mvn x0, x1 // x0 = ~x1(NOT)
// 即値との論理演算
and x0, x1, #0xFF // x0 = x1 & 0xFF
orr x0, x1, #0xF0 // x0 = x1 | 0xF0
シフト命令
lsl x0, x1, #4 // x0 = x1 << 4(論理左シフト)
lsr x0, x1, #4 // x0 = x1 >> 4(論理右シフト)
asr x0, x1, #4 // x0 = x1 >> 4(算術右シフト、符号保持)
ror x0, x1, #4 // x0 = x1を4ビット右回転
メモリアクセス命令
ロード命令(メモリからレジスタへ)
ldr x0, [x1] // x0 = *x1(64ビット)
ldr w0, [x1] // w0 = *x1(32ビット)
ldrh w0, [x1] // w0 = *x1(16ビット、符号なし拡張)
ldrb w0, [x1] // w0 = *x1(8ビット、符号なし拡張)
ldrsb x0, [x1] // x0 = *x1(8ビット、符号拡張)
ストア命令(レジスタからメモリへ)
str x0, [x1] // *x1 = x0(64ビット)
str w0, [x1] // *x1 = w0(32ビット)
strh w0, [x1] // *x1 = w0(16ビット)
strb w0, [x1] // *x1 = w0(8ビット)
オフセット付きアクセス
ldr x0, [x1, #8] // x0 = *(x1 + 8)
str x0, [x1, #16] // *(x1 + 16) = x0
ldr x0, [x1, x2] // x0 = *(x1 + x2)
プログラムの基本構造
アセンブリファイルの構成
Apple Siliconのアセンブリプログラムは以下の要素で構成されます:
// コメントは//または#で開始
// ディレクティブ - アセンブラへの指示
.global _main // シンボルをグローバルにする
.align 2 // 4バイト境界にアライン(2^2=4)
// テキストセクション - コードを記述
.text
_main: // ラベル定義
// ここに命令を記述
mov x0, #0
ret
// データセクション - 初期化済みデータ
.data
message:
.ascii "Hello\n" // 文字列データ
counter:
.quad 0 // 64ビット整数
// BSSセクション - 未初期化データ
.bss
buffer:
.skip 1024 // 1024バイト確保
エントリポイント
macOSでは、プログラムのエントリポイントは _main という名前である必要があります(アンダースコアに注意):
.global _main
.align 2
_main:
// プログラムのコード
mov x0, #0 // 終了コード
mov x16, #1 // exit システムコール
svc #0x80 // システムコール実行
システムコールの使い方
macOSでシステムコールを呼び出すには:
- X16にシステムコール番号を設定
- X0-X7に引数を設定
svc #0x80を実行
主なシステムコール番号:
- 1: exit - プログラム終了
- 3: read - ファイル読み込み
- 4: write - ファイル書き込み
// write(1, message, length)
mov x0, #1 // ファイルディスクリプタ(stdout)
adrp x1, message@PAGE // メッセージアドレス
add x1, x1, message@PAGEOFF
mov x2, #13 // 長さ
mov x16, #4 // writeシステムコール
svc #0x80
最初のプログラム:Hello World
それでは、実際に動くHello Worldプログラムを作成してみましょう。
ソースコード作成
hello.s という名前でファイルを作成し、以下のコードを記述します:
// hello.s - Apple Silicon用Hello Worldプログラム
// システムコールを使って"Hello, World!"を出力する
.global _main // エントリポイントをグローバルに
.align 2 // 命令は4バイト境界にアライン
_main:
// write(1, message, 14) システムコール
mov x0, #1 // 第1引数: ファイルディスクリプタ(1 = stdout)
adrp x1, message@PAGE // 第2引数: メッセージアドレス(ページ)
add x1, x1, message@PAGEOFF // メッセージアドレス(オフセット)
mov x2, #14 // 第3引数: 書き込むバイト数
mov x16, #4 // システムコール番号(4 = write)
svc #0x80 // システムコール実行
// exit(0) システムコール
mov x0, #0 // 終了コード(0 = 正常終了)
mov x16, #1 // システムコール番号(1 = exit)
svc #0x80 // システムコール実行
// データセクション
.data
message:
.ascii "Hello, World!\n" // 出力する文字列
プログラムの解説
ADRPとADD命令について
Apple Siliconでは、メモリアドレスを取得するのに2段階の処理が必要です:
adrp x1, message@PAGE // ページアドレス(4KBアライン)取得
add x1, x1, message@PAGEOFF // ページ内オフセットを加算
これは、ARM64がPIC(Position Independent Code)を前提としているためです。
ビルド手順
ターミナルで以下のコマンドを実行します:
# 1. アセンブル(.s → .o)
as -o hello.o hello.s
# 2. リンク(.o → 実行ファイル)
ld -o hello hello.o \
-lSystem \
-syslibroot `xcrun -sdk macosx --show-sdk-path` \
-e _main \
-arch arm64
# 3. 実行
./hello
出力:
Hello, World!
ビルドオプションの説明
-o hello.o: 出力ファイル名を指定-lSystem: Systemライブラリをリンク(システムコールに必要)-syslibroot: SDKのパスを指定-e _main: エントリポイントを指定-arch arm64: ターゲットアーキテクチャを指定
Makefileの作成
毎回長いコマンドを打つのは面倒なので、Makefileを作成しましょう:
# Makefile for Apple Silicon Assembly
AS = as
LD = ld
SDK = $(shell xcrun -sdk macosx --show-sdk-path)
ASFLAGS =
LDFLAGS = -lSystem -syslibroot $(SDK) -e _main -arch arm64
TARGET = hello
SRCS = hello.s
OBJS = $(SRCS:.s=.o)
all: $(TARGET)
$(TARGET): $(OBJS)
$(LD) -o $@ $^ $(LDFLAGS)
%.o: %.s
$(AS) $(ASFLAGS) -o $@ $<
clean:
rm -f $(OBJS) $(TARGET)
run: $(TARGET)
./$(TARGET)
.PHONY: all clean run
使い方:
make # ビルド
make run # ビルドして実行
make clean # クリーンアップ
実践例:数値の計算
例1:簡単な足し算
// add_numbers.s - 2つの数を足す
.global _main
.align 2
_main:
mov x0, #10 // x0 = 10
mov x1, #32 // x1 = 32
add x2, x0, x1 // x2 = 10 + 32 = 42
// 結果(42)を終了コードとして返す
mov x0, x2
mov x16, #1
svc #0x80
実行後、終了コードを確認:
./add_numbers
echo $? # 42が表示される
例2:複雑な計算式
計算式:result = (a + b) * c - d / e を実装
// calculate.s - 複雑な計算
.global _main
.align 2
_main:
// 変数の初期化
mov x0, #10 // a = 10
mov x1, #5 // b = 5
mov x2, #3 // c = 3
mov x3, #20 // d = 20
mov x4, #4 // e = 4
// (a + b) を計算
add x5, x0, x1 // x5 = 10 + 5 = 15
// (a + b) * c を計算
mul x6, x5, x2 // x6 = 15 * 3 = 45
// d / e を計算
udiv x7, x3, x4 // x7 = 20 / 4 = 5
// 最終結果: (a + b) * c - d / e
sub x8, x6, x7 // x8 = 45 - 5 = 40
// 結果を終了コードで返す
mov x0, x8
mov x16, #1
svc #0x80
デバッグ方法
lldbを使ったデバッグ
アセンブリプログラムもlldbでデバッグできます:
# デバッグシンボル付きでビルド
as -g -o hello.o hello.s
ld -o hello hello.o -lSystem -syslibroot `xcrun -sdk macosx --show-sdk-path` -e _main -arch arm64
# lldbでデバッグ開始
lldb hello
lldbの基本コマンド:
(lldb) breakpoint set --name _main # ブレークポイント設定
(lldb) run # 実行開始
(lldb) register read # 全レジスタ表示
(lldb) register read x0 # x0レジスタのみ表示
(lldb) memory read $sp # スタックメモリ表示
(lldb) stepi # 1命令実行
(lldb) continue # 続行
(lldb) quit # 終了
printfを使ったデバッグ
C言語のprintf関数を呼び出すこともできます:
// printf_debug.s - printfでデバッグ
.global _main
.align 2
_main:
// スタックフレーム設定
stp x29, x30, [sp, #-16]!
mov x29, sp
mov x0, #42
mov x1, x0 // 表示したい値
adrp x0, format@PAGE
add x0, x0, format@PAGEOFF
bl _printf // printfを呼び出し
// スタックフレーム復元
ldp x29, x30, [sp], #16
mov x0, #0
mov x16, #1
svc #0x80
.data
format:
.asciz "Value: %ld\n" // フォーマット文字列
ビルド時にCライブラリをリンク:
clang -o printf_debug printf_debug.s
./printf_debug
# 出力: Value: 42
まとめ
第1回では、Apple Siliconアセンブリ言語プログラミングの基礎を学びました:
学んだこと:
- ARM64アーキテクチャの特徴(RISC設計、豊富なレジスタ、固定長命令)
- 開発環境の準備(Xcode Command Line Toolsのインストール)
- レジスタの種類と役割(X0-X30の用途、W0-W30との関係)
- 基本的な命令セット(MOV、ADD、SUB、MUL、DIV、論理演算、シフト、メモリアクセス)
- プログラムの基本構造(ディレクティブ、セクション、エントリポイント)
- システムコールの使い方(write、exit)
- Hello Worldプログラムの作成とビルド
- 実践的な計算プログラム
- デバッグ方法(lldb、printf)
重要なポイント:
- Apple SiliconはARM64アーキテクチャで、Intel x86-64とは大きく異なる
- レジスタが豊富で、用途に応じた使い分けが重要
- メモリアクセスは専用のロード/ストア命令で行う
- システムコールはX16に番号を設定してsvc #0x80で呼び出す
- アドレス取得にはADRP+ADDの2段階処理が必要
次回予告: 第2回では、より詳しい命令セット、条件分岐、ループ処理について学びます。また、関数の作成方法や呼び出し規約についても詳しく解説します。
練習問題:
- 3つの数(a=15, b=7, c=3)の平均値を計算するプログラムを書いてみましょう
- 2つの数を交換するプログラムを書いてみましょう
- ビット演算を使って、ある数が2の累乗かどうかを判定するプログラムを書いてみましょう
これらの練習問題に取り組むことで、第1回で学んだ内容をより深く理解できます。次回もお楽しみに!
