はじめに 🚀
ARM(Advanced RISC Machines)アーキテクチャは、スマートフォン、タブレット、組み込みシステムなど、私たちの身の回りの多くのデバイスで採用されているプロセッサアーキテクチャです。特に32ビット版のARM(ARM32、ARMv7などとも呼ばれる)は、長年にわたりモバイルデバイスや組み込みシステムの中心として活躍してきました。
アセンブリ言語は、コンピュータが直接理解できる機械語に最も近い低水準言語です。特定のプロセッサアーキテクチャに固有の命令セットを使ってプログラムを記述します。なぜ今、ARMアセンブリを学ぶのでしょうか?
- ハードウェアの深い理解: プロセッサがどのように動作するのか、メモリやレジスタがどのように使われるのかを直接的に理解できます。
- パフォーマンス最適化: 特定の処理を極限まで高速化したい場合、アセンブリ言語による最適化が有効な場合があります。
- 組み込みシステム開発: リソースが限られた環境では、コードサイズや実行速度を細かく制御するためにアセンブリが使われることがあります。
- セキュリティ研究: マルウェア解析や脆弱性診断において、バイナリコードを読み解くためにアセンブリの知識は不可欠です。
このブログでは、ARM 32ビットアーキテクチャのアセンブリ言語の基本的な概念から、簡単なプログラムの作成までを解説していきます。さあ、ARMアセンブリの世界を探検しましょう!🗺️
ARMアーキテクチャの基本
ARMアーキテクチャは、RISC (Reduced Instruction Set Computer) 設計哲学に基づいています。これは、命令セットを比較的単純なものに限定し、各命令が高速に実行できるように設計されていることを意味します。
主な特徴は以下の通りです。
- ロード/ストアアーキテクチャ: 演算命令(加算、減算など)は、レジスタ内の値に対してのみ実行されます。メモリ上のデータに対して直接演算を行うことはできません。メモリとのデータのやり取りは、専用のロード(メモリからレジスタへ)命令とストア(レジスタからメモリへ)命令を使用します。これにより、命令の設計が単純化され、高速化が図られています。
- 32ビットアーキテクチャ: ARM 32ビット版では、レジスタのサイズやメモリアドレス空間の基本単位が32ビット(4バイト)です。データサイズとしては、バイト(8ビット)、ハーフワード(16ビット)、ワード(32ビット)が定義されています。
- 固定長命令 (ARM命令): 元々のARM命令セット(A32とも呼ばれる)は、全ての命令が32ビットの固定長です。これにより、命令のデコード(解読)が容易になります。(Thumb命令セットという16/32ビット混在の可変長命令セットも存在します。)
- 条件付き実行: 多くのARM命令は、特定の条件(直前の演算結果がゼロだった、など)が満たされた場合にのみ実行されるように指定できます。これにより、分岐命令の使用を減らし、パイプライン処理の効率を高めることができます。
- 豊富なレジスタ: ユーザーモードからアクセス可能な汎用レジスタが16個用意されており、メモリアクセスを減らす助けになります。
ARMアーキテクチャは、Acorn Computers社によって1980年代に開発され、その後ARM Holdings社(現Arm Ltd.)によってライセンス供与される形で広く普及しました。特にARMv7アーキテクチャは、多くのスマートフォンやタブレットで採用され、32ビットARMの代表的なバージョンとなりました。2011年には64ビット版のARMv8-Aが登場し、現在の主流となっていますが、32ビット環境も依然として多くの組み込みシステムなどで利用されています。
レジスタ 💾
レジスタは、CPU内部にある高速な記憶領域です。ARM 32ビットアーキテクチャ(ユーザーモード)では、主に以下の16個の32ビットレジスタにアクセスできます。
レジスタ名 | 別名 | 役割 | 詳細説明 |
---|---|---|---|
R0 – R3 |
a1 – a4 | 引数、戻り値、汎用 | 関数呼び出し規約 (AAPCS) では、最初の4つの引数を渡すために使用され、関数の戻り値は R0 に格納されます。それ以外の場面では汎用レジスタとして自由に利用できます。 |
R4 – R11 |
v1 – v8 (一部別名あり: R9=sb, R10=sl, R11=fp) | 汎用 (退避レジスタ) | 汎用的に使用できますが、関数呼び出しにおいて値を保持する必要がある場合は、呼び出し側(または呼び出された側で)スタックに退避させる必要があります。慣例的に R11 はフレームポインタ (FP) として使われることがあります。 |
R12 |
IP (Intra-Procedure call scratch register) | 汎用 (手続き内呼び出しスクラッチ) | リンカなどが一時的に使用することがありますが、通常は汎用レジスタとして利用できます。 |
R13 |
SP (Stack Pointer) | スタックポインタ | 現在のスタックの頂上(最後に積まれたデータの位置)を指すアドレスを保持します。スタックは、ローカル変数や関数呼び出し時のレジスタ退避などに使われるメモリ領域です。 |
R14 |
LR (Link Register) | リンクレジスタ | 関数(サブルーチン)呼び出し命令 (BL ) を実行すると、呼び出し元に戻るためのアドレス(BL 命令の次の命令のアドレス)がここに自動的に格納されます。関数からの復帰時にこのアドレスを PC にコピーすることで、元の場所から実行を再開します。 |
R15 |
PC (Program Counter) | プログラムカウンタ | 次に実行される命令のアドレスを保持しています。命令が実行されるたびに、次の命令のアドレスを指すように自動的に更新されます。分岐命令は、この PC に分岐先のアドレスを書き込むことでプログラムの流れを変えます。直接 PC に値を書き込むことも可能です。 |
これらのレジスタの他に、カレントプログラムステータスレジスタ (CPSR) があります。
CPSR (Current Program Status Register)
CPSRは、現在のプロセッサの状態を示すフラグを保持しています。主なフラグには以下のようなものがあります。
- N (Negative): 演算結果が負の場合にセット (1) されます。
- Z (Zero): 演算結果がゼロの場合にセット (1) されます。
- C (Carry): 加算で桁上げが発生した場合や、減算で桁借りが発生しなかった場合にセット (1) されます。
- V (Overflow): 符号付き演算でオーバーフローが発生した場合にセット (1) されます。
これらのフラグは、条件分岐命令などでプログラムの流れを制御するために参照されます。CPSRには他にも、現在のプロセッサモード(User, Supervisorなど)や命令セット(ARM/Thumb)を示すビットなどが含まれています。
プロセッサのモード(特権モードなど)によっては、さらに多くのレジスタ(バンクドレジスタ)にアクセスできますが、通常のアプリケーションプログラミングでは上記のレジスタを主に扱います。
基本的な命令セット 📜
ARM 32ビットアセンブリには多くの命令がありますが、ここでは特に基本的なものをいくつか紹介します。ARMアセンブリ命令は一般的に以下の形式を取ります。
label mnemonic operand1, operand2, ... ; comment
label
: 命令のアドレスを示すラベル(任意)。分岐命令の飛び先などに使われます。mnemonic
: 命令の種類を示すニーモニック(例:MOV
,ADD
)。operand
: 命令が操作する対象(レジスタ、即値、メモリアドレスなど)。通常、最初のオペランドが結果の格納先(デスティネーション)になります。comment
: セミコロン(;
) またはアットマーク(@
)以降はコメントとして扱われます(アセンブラによります)。
データ処理命令
レジスタ間の演算やデータ移動を行います。
-
MOV
(Move): レジスタ間、または即値(固定値)をレジスタに移動(コピー)します。MOV R0, R1 ; R0 = R1 MOV R2, #10 ; R2 = 10 (即値) MOV R3, #'A' ; R3 = 65 ('A'のASCIIコード)
※ 32ビットの即値を直接MOV命令でロードすることはできません。通常、複数命令に分割するか、
LDR
命令でメモリからロードします。 -
ADD
(Add): 2つのオペランド(レジスタまたは即値)を加算し、結果をレジスタに格納します。ADD R0, R1, R2 ; R0 = R1 + R2 ADD R3, R4, #1 ; R3 = R4 + 1
-
SUB
(Subtract): 第2オペランドを第1オペランドから減算し、結果をレジスタに格納します。SUB R0, R1, R2 ; R0 = R1 - R2 SUB R3, R4, #5 ; R3 = R4 - 5
-
CMP
(Compare): 2つのオペランドを比較(内部的に減算)し、結果は格納せず、CPSRのフラグ(N, Z, C, V)を更新します。条件分岐命令の直前によく使われます。CMP R0, R1 ; R0 - R1 の結果に基づきフラグを更新 CMP R2, #0 ; R2 が 0 かどうかを比較
-
AND
,ORR
(OR),EOR
(XOR),BIC
(Bit Clear): ビット単位の論理演算を行います。AND R0, R1, R2 ; R0 = R1 & R2 (ビットAND) ORR R3, R4, #0xFF; R3 = R4 | 0xFF (ビットOR) EOR R5, R6, R6 ; R5 = R6 ^ R6 (結果的に R5 = 0) BIC R7, R8, #0b101 ; R7 = R8 & (~0b101) (指定ビットをクリア)
オペランド2の柔軟性 (バレルシフタ)
多くのデータ処理命令では、第2オペランド(レジスタ)に対して演算前にシフトやローテートを行うことができます。これはバレルシフタと呼ばれるハードウェア機能によって実現されます。
MOV R0, R1, LSL #2 ; R0 = R1 << 2 (R1を2ビット左シフトした値をR0へ)
ADD R2, R3, R4, ASR #1 ; R2 = R3 + (R4 >> 1) (R4を1ビット算術右シフトした値をR3に加算)
これにより、乗算や除算(2のべき乗による)などを1命令で効率的に行うことができます。シフトの種類には論理シフト(LSL
, LSR
)、算術シフト(ASR
)、ローテート(ROR
)があります。
メモリアクセス命令
レジスタとメモリの間でデータを転送します。
-
LDR
(Load Register): メモリからデータを読み込み、レジスタに格納します。ワード(32ビット)、ハーフワード(16ビット、LDRH
)、バイト(8ビット、LDRB
)単位でロードできます。LDR R0, [R1] ; R1 が指すメモリアドレスからワードを読み込み R0 へ LDR R2, [R3, #4] ; R3 + 4 バイトのメモリアドレスからワードを読み込み R2 へ LDR R4, [R5, R6] ; R5 + R6 のメモリアドレスからワードを読み込み R4 へ (レジスタオフセット) LDR R7, =label ; labelのアドレス値を R7 にロード (疑似命令) LDRB R8, [R9] ; R9 が指すメモリアドレスからバイトを読み込み R8 へ (上位ビットは0で埋められる)
[ベースレジスタ, オフセット] という形式でアドレスを指定するのが基本です。オフセットは即値または他のレジスタで指定できます。
-
STR
(Store Register): レジスタの値をメモリに書き込みます。ワード、ハーフワード(STRH
)、バイト(STRB
)単位でストアできます。STR R0, [R1] ; R0 の値を R1 が指すメモリアドレスにワードとして書き込む STR R2, [R3, #8] ; R2 の値を R3 + 8 バイトのメモリアドレスにワードとして書き込む STRB R4, [R5] ; R4 の下位8ビットを R5 が指すメモリアドレスにバイトとして書き込む
-
LDM
/STM
(Load/Store Multiple): 複数のレジスタの内容を一度にメモリとの間でロード/ストアします。スタック操作などで効率的です。STMIA SP!, {R0-R3, LR} ; SPが指すアドレスから順にR0-R3, LRをストアし、SPを更新 (Increment After) LDMIA SP!, {R0-R3, PC} ; SPが指すアドレスから順にR0-R3, PCにロードし、SPを更新 (PCへのロードで関数から復帰)
IA (Increment After), IB (Increment Before), DA (Decrement After), DB (Decrement Before) などの接尾辞でアドレスの増減タイミングを指定します。
!
を付けるとベースレジスタ(この例ではSP)の値が更新されます。
分岐命令
プログラムの実行フローを変更します。
-
B
(Branch): 指定されたラベル(アドレス)に無条件でジャンプします。B loop_start ; loop_start ラベルへジャンプ
-
BL
(Branch with Link): 指定されたラベルにジャンプし、同時に戻りアドレス(BL
命令の次の命令のアドレス)をLR
レジスタに格納します。関数呼び出しに使用されます。BL my_function ; my_function を呼び出し、戻りアドレスを LR に保存
-
条件付き分岐:
B
命令に条件コードを付加することで、CPSRフラグの状態に基づいて分岐するかどうかを決定します。CMP R0, #0 ; R0 と 0 を比較 BEQ zero_label ; R0 が 0 なら (Zフラグ=1 なら) zero_label へ分岐 (Branch if Equal) BNE not_zero ; R0 が 0 でないなら (Zフラグ=0 なら) not_zero へ分岐 (Branch if Not Equal) BGT greater ; R0 が 0 より大きいなら (符号付き比較) greater へ分岐 (Branch if Greater Than) BLT less ; R0 が 0 より小さいなら (符号付き比較) less へ分岐 (Branch if Less Than)
他にも多くの条件コードがあります (例:
CS
/CC
: キャリーセット/クリア,MI
/PL
: マイナス/プラス,VS
/VC
: オーバーフローセット/クリアなど)。 -
BX
(Branch and Exchange): 指定されたレジスタ内のアドレスに分岐します。さらに、レジスタの下位ビットが1の場合はプロセッサの状態をThumbモードに切り替えます(0の場合はARMモード)。関数からの復帰 (BX LR
) によく使われます。BX LR ; LR に格納されたアドレスへ分岐 (通常、関数からの復帰) MOV R0, #target_addr BX R0 ; R0 の値のアドレスへ分岐
簡単なアセンブリプログラムの例 (Linux) 🐧
実際に簡単なプログラムを書いてみましょう。ここでは、Linux環境で標準出力に “Hello, ARM!\\n” と表示して終了するプログラムの例を示します。システムコール(OSの機能を呼び出す仕組み)を使用します。
; --- hello.s ---
.global _start ; _start ラベルを外部から見えるようにする (リンカ用)
.data ; データセクションの開始
message:
.asciz "Hello, ARM!\n" ; 表示する文字列 (NULL終端)
message_end:
len = message_end - message ; 文字列の長さを計算 (アセンブラが計算)
.text ; コードセクションの開始
_start: ; プログラムのエントリーポイント
; write システムコール (sys_write = 4)
MOV R7, #4 ; システムコール番号 4 (write) を R7 に設定
MOV R0, #1 ; ファイルディスクリプタ 1 (stdout) を R0 に設定
LDR R1, =message ; 表示する文字列のアドレスを R1 に設定
MOV R2, #len ; 文字列の長さを R2 に設定
SVC #0 ; システムコールを実行 (Supervisor Call)
; exit システムコール (sys_exit = 1)
MOV R7, #1 ; システムコール番号 1 (exit) を R7 に設定
MOV R0, #0 ; 終了コード 0 を R0 に設定
SVC #0 ; システムコールを実行
; --- ここまで ---
解説
- ディレクティブ (
.global
,.data
,.text
,.asciz
,.equ
など): これらはARM命令ではなく、アセンブラに対する指示です。.global _start
:_start
というラベルを、プログラムの開始点としてリンカに知らせます。.data
/.text
: それぞれデータセクション(初期化されたデータ)とテキストセクション(実行コード)の開始を示します。message: .asciz "..."
:message
というラベルの付いたメモリ位置に、指定されたNULL終端文字列を配置します。len = message_end - message
: アセンブラがアセンブル時にラベル間のアドレス差を計算し、len
というシンボルにその値を割り当てます。
- システムコール: OSの機能(ファイル書き込み、プロセス終了など)を呼び出すためのインターフェースです。Linux ARM (EABI) では、以下のようにレジスタを設定して
SVC #0
命令を実行します。R7
: システムコール番号。(sys_write
は 4,sys_exit
は 1)R0
,R1
,R2
, …: システムコールへの引数。
write
システムコールは、R0
にファイルディスクリプタ、R1
にバッファ(文字列)のアドレス、R2
に書き込むバイト数を指定します。exit
システムコールは、R0
に終了コードを指定します。 LDR R1, =message
: これは疑似命令で、アセンブラがmessage
ラベルのアドレス値をリテラルプール(コードの近くに配置される定数置き場)に配置し、そこからLDR
命令でR1
にロードするように展開してくれます。
アセンブルとリンク
このプログラムをアセンブル(機械語に変換)し、リンク(実行可能ファイルを作成)するには、GNU Binutilsに含まれるツールを使います(環境によってはクロスコンパイル用のツールチェーンが必要になる場合があります)。
# アセンブル (hello.s から オブジェクトファイル hello.o を生成)
as hello.s -o hello.o
# リンク (hello.o から 実行可能ファイル hello を生成)
ld hello.o -o hello
# 実行
./hello
実行すると、ターミナルに “Hello, ARM!” と表示されるはずです。🎉
ツールチェーン 🛠️
ARMアセンブリプログラミングを行うには、いくつかの開発ツールが必要です。これらをまとめてツールチェーンと呼びます。
-
アセンブラ (Assembler): アセンブリ言語のソースコード (
.s
ファイル) を、CPUが理解できる機械語のオブジェクトファイル (.o
ファイル) に変換します。- GNU Assembler (as): GNU Binutils に含まれる標準的なアセンブラ。多くのLinux環境で利用可能です。クロス開発用に
arm-linux-gnueabihf-as
のような名前の場合もあります。
- GNU Assembler (as): GNU Binutils に含まれる標準的なアセンブラ。多くのLinux環境で利用可能です。クロス開発用に
-
リンカ (Linker): 複数のオブジェクトファイルやライブラリファイルを結合し、最終的な実行可能ファイルを生成します。アドレスの解決やシンボルの結合などを行います。
- GNU Linker (ld): GNU Binutils に含まれる標準的なリンカ。クロス開発用に
arm-linux-gnueabihf-ld
のような名前の場合もあります。
- GNU Linker (ld): GNU Binutils に含まれる標準的なリンカ。クロス開発用に
-
コンパイラ (Compiler): C/C++などの高級言語をアセンブリ言語や機械語に変換します。アセンブリコードを生成する目的でも利用できます。
- GCC (GNU Compiler Collection): C/C++など多くの言語をサポートするコンパイラ。ARM向けのクロスコンパイラ (
arm-linux-gnueabihf-gcc
など) が広く使われています。GCCは内部でアセンブラやリンカを呼び出します。
- GCC (GNU Compiler Collection): C/C++など多くの言語をサポートするコンパイラ。ARM向けのクロスコンパイラ (
-
デバッガ (Debugger): プログラムの実行をステップごとに追いかけたり、レジスタやメモリの内容を確認したりして、バグの原因を特定するのに役立ちます。
- GDB (GNU Debugger): 高機能なデバッガ。ARM向けのクロスデバッグも可能です (
arm-linux-gnueabihf-gdb
やgdb-multiarch
など)。
- GDB (GNU Debugger): 高機能なデバッガ。ARM向けのクロスデバッグも可能です (
-
シミュレータ / エミュレータ: 開発用PC上でARM環境をシミュレートまたはエミュレートし、実機がなくてもプログラムの動作確認ができます。
- QEMU: 様々なアーキテクチャをエミュレートできるツール。ARMシステムのエミュレーションも可能です。
Arm社自身も Arm GNU Toolchain を提供しており、ベアメタル(OSなし)環境やLinux環境向けの統合・検証済みツールチェーンをダウンロードできます。
クロスコンパイル環境
PC(x86/x64アーキテクチャ)上でARM用のプログラムを開発する場合、クロスコンパイル環境が必要です。これは、開発を行うホストマシンとは異なるターゲットアーキテクチャ(この場合はARM)向けのコードを生成する環境のことです。ツール名に arm-linux-gnueabihf-
のような接頭辞が付いているものがクロス開発用のツールチェーンの一部です。
学習リソースと参考情報 📚
ARMアセンブリをさらに深く学ぶためのリソースをいくつか紹介します。
-
Arm Architecture Reference Manuals (ARMs):
Arm社が提供する公式のアーキテクチャ仕様書です。特定のARMバージョン(例: ARMv7-A and ARMv7-R)の詳細な情報(命令セット、レジスタ、メモリモデルなど)が記載されています。非常に詳細ですが、最も正確な情報源です。
-
オンラインチュートリアル:
- Azeria Labs – ARM Assembly Basics: ARMアセンブリの基礎からエクスプロイト開発までをカバーする詳細なチュートリアルシリーズです。(英語)
- Wikibooks – ARM32アセンブリ言語: 日本語で書かれたARM32アセンブリの入門的な解説です。
-
逆アセンブルツールの活用:
GCCなどのコンパイラに
-S
オプションを付けてC言語のコードをアセンブリに変換してみたり、GDBやIDA Pro、Ghidraなどの逆アセンブルツールを使って既存のバイナリがどのようなアセンブリコードになっているかを確認するのも非常に勉強になります。
これらのリソースを活用し、実際にコードを書いて動かしてみることで、理解が深まるでしょう。
おわりに ✨
この入門記事では、ARM 32ビットアセンブリの基本的な概念、レジスタ、主要な命令、簡単なプログラム例、そして開発ツールについて駆け足で見てきました。
アセンブリ言語の学習は、最初は少し難しく感じるかもしれませんが、コンピュータの動作原理を深く理解するための強力な手段です。特にARMは、身近なデバイスで広く使われているため、その知識は様々な分野で役立つ可能性があります。
ここで紹介したのはほんの入り口に過ぎません。条件実行、Thumb命令セット、浮動小数点演算、NEON (SIMD) 命令など、さらに多くのトピックがあります。ぜひ興味を持った部分を掘り下げてみてください。
Happy Hacking! 💻
コメント