同じ処理、3つの命令セット——x86_64・ARM・z/Architecture のアセンブリを読み比べる
はじめに
仕事でアセンブリ言語を扱うことになりそうなので、素振りとして主要なアーキテクチャのアセンブリを読み比べてみました。
昔少しだけアセンブリを触ったことはあるものの、かなりブランクがあります。改めて現代の3大アーキテクチャ——x86_64(Intel/AMD)、AArch64(ARM)、z/Architecture(IBM メインフレーム)で同じ処理を書き、設計思想の違いを整理してみます。
なぜこの3つかというと、x86_64 はサーバーとデスクトップの標準、AArch64 はモバイルと Apple Silicon で急速に広がり、z/Architecture は金融・公共の基幹系で数十年動き続けていて、現代のコンピューティングを支える3本柱と言えるからです。
設計思想の違い:CISC・RISC・そしてメインフレーム
まず大枠を整理してみます。
x86_64(CISC寄り)
- 命令長が可変(1〜15バイト)。複雑なことを1命令でできる反面、デコーダの実装が複雑です。
- メモリオペランドを直接演算命令に取れます。
ADD RAX, [RBX+8]のように、ロードと加算を1命令で表現できます。 - レジスタは16本の汎用レジスタ(RAX, RBX, RCX, ...R15)。歴史的に少なめです。
- 2オペランド形式が基本。
ADD RAX, RBXはRAX = RAX + RBXを意味し、ソースの片方が破壊されます。
AArch64(RISC)
- 命令長が固定(すべて4バイト)。デコードが単純で、パイプラインが効率的です。
- ロード/ストアアーキテクチャ。演算はレジスタ間でのみ行い、メモリアクセスは専用命令で行います。
- 汎用レジスタが31本(X0〜X30)と豊富。レジスタに乗せて計算を回す思想です。
- 3オペランド形式。
ADD X0, X1, X2はX0 = X1 + X2で、ソースが破壊されません。
z/Architecture(メインフレーム)
- 命令長は2・4・6バイトの3種類。CISCとRISCの中間的な性格を持っています。
- 汎用レジスタは16本(R0〜R15)。ただしアクセスレジスタ、制御レジスタなど専用レジスタが豊富です。
- ストレージ(メモリ)参照は base+displacement+index の形式。
L R1,8(R2,R3)のように3要素で指定します。 - 2オペランドと3オペランドの両方があります。世代を経て命令が追加されてきた歴史が見えます。
- ビッグエンディアン。x86_64のリトルエンディアン、ARMの選択可能とは異なります。
実例1:2つの整数の加算
最も基本的な処理で比較してみます。メモリ上の2つの整数を読み、加算し、結果をメモリに書き戻すだけの処理です。
x86_64
; a + b → result
; a, b, result はメモリ上のラベル
mov eax, [a] ; メモリから a をロード
add eax, [b] ; メモリから b を読みつつ加算(CISC的)
mov [result], eax ; 結果をストア
3命令です。面白いのは add eax, [b] で、加算命令の中でメモリ読み出しが発生しています。命令数は少なく済みますが、1命令あたりの実行サイクルが長くなる場合があるようです。
AArch64
// a + b → result
ldr w0, [x2] // メモリから a をロード
ldr w1, [x3] // メモリから b をロード
add w0, w0, w1 // レジスタ間で加算
str w0, [x4] // 結果をストア
4命令になりました。add はレジスタ同士でしか演算できないため、メモリからのロードが別命令になっています。命令数は増えますが、各命令が単純なのでパイプラインに載せやすく、命令レベル並列性が高くなります。
z/Architecture
* a + b → result
L R1,A メモリから a をロード
A R1,B メモリから b を読みつつ加算
ST R1,RESULT 結果をストア
3命令で、構造的にはx86_64と似ています。A(Add)命令がメモリオペランドを取れる点はCISC的です。ただし、命令のニモニックが極めて短いのが特徴的です。L(Load)、A(Add)、ST(Store)——1〜2文字の略語が基本で、1960年代のSystem/360から続く伝統です。
実例2:条件分岐
「値が0なら分岐する」という処理を比較してみます。ここが一番アーキテクチャごとの個性が出て面白かったところです。
x86_64
test eax, eax ; eax AND eax の結果でフラグレジスタを更新(値が0ならZFが立つ)
je .is_zero ; Zero Flag が立っていたらジャンプ
x86_64 では、test eax, eax(ビットANDの結果を捨ててフラグだけ更新する)でゼロ判定し、条件ジャンプ(je)がフラグを読みます。cmp eax, 0 でも同じ結果ですが、test の方が即値を持たない分エンコードが短く、コンパイラもこちらを出力します。比較と分岐が暗黙的にフラグレジスタ(RFLAGS)を介して結合する設計です。
AArch64
cbz w0, .is_zero // w0 が 0 なら分岐(Compare and Branch if Zero)
1命令で済みます。ARMv8 では比較と分岐を合成した命令(cbz, cbnz)が追加されています。フラグレジスタを経由しないため、他の命令とのフラグ競合が起きません。もちろん従来型の cmp + b.eq も使えます。
z/Architecture
LTR R1,R1 R1 自身をテストし条件コードを設定
BZ IS_ZERO 条件コード0(結果がゼロ)なら分岐
z/Architecture では「条件コード」(CC: Condition Code)という2ビットの状態を使います。x86のフラグレジスタが複数のフラグ(ZF, CF, SF, OF...)を持つのに対し、CCは0〜3の4状態しか取りません。代わりに、分岐命令のマスクで「どの状態のとき分岐するか」を柔軟に指定できます。BZ は「CC=0のとき分岐」の意味です。
実例3:ループ(配列の合計)
int型配列の要素を合計するループで、もう少し実践的な違いを見てみます。
x86_64
; ecx = 要素数, rsi = 配列のアドレス, eax = 合計(0で初期化済み)
.loop:
add eax, [rsi+4*rcx-4] ; インデックスアドレッシングで現在の要素を加算
loop .loop ; ecxをデクリメントし、ゼロでなければループ
loop 命令は「ecxをデクリメントし、ゼロでなければジャンプ」を1命令で行います。x86にはこうしたループ専用命令が用意されています。ただし現代のCPUでは loop のマイクロアーキテクチャ実装が遅く、実際にコンパイラが出力するのは dec ecx / jnz(あるいはPartial Flag Stallを避けるために sub ecx, 1 / jnz)の組み合わせです。命令セットの設計と実行性能が一致しないところも、長い歴史を持つx86の面白さです。
AArch64
// x1 = 配列のアドレス, w2 = 要素数, w0 = 合計(0で初期化済み)
.loop:
ldr w3, [x1], #4 // ロード後にアドレスを+4(ポストインクリメント)
add w0, w0, w3 // 合計に加算
subs w2, w2, #1 // カウンタをデクリメント(フラグ更新あり)
b.ne .loop // ゼロでなければループ
ldr w3, [x1], #4 のポストインクリメントアドレッシングが特徴的です。ロードとアドレス更新を1命令で実行します。また subs(末尾のsがフラグ更新を明示する)と sub を使い分けることで、フラグ更新の有無をプログラマが明示的に制御します。
z/Architecture
* R2 = 配列のアドレス, R3 = 要素数, R1 = 合計(0で初期化済み)
LOOP A R1,0(R2) 現在の要素を加算
LA R2,4(R2) アドレスを4バイト進める
BCT R3,LOOP カウンタをデクリメントし、0でなければ分岐
3命令です。BCT(Branch on Count)はカウンタのデクリメントと条件分岐を1命令で行います。ループ処理に最適化された命令が用意されているのはメインフレームらしい設計だと感じました。LA(Load Address)はアドレス計算専用命令で、実際のメモリアクセスは発生しません——ポインタ演算に使う定番パターンです。
命令エンコーディングの違い
ここからはもう少し深い層を覗いてみます。同じ「レジスタへの即値ロード」でも、バイナリレベルの構造がまったく異なっていました。
x86_64:可変長
B8 2A 00 00 00 ; mov eax, 42
5バイト。x86-64では32ビットレジスタへの書き込みで上位32ビットが自動的にゼロクリアされるため、mov rax, 42(REXプレフィックス付きで7バイト)と書く必要はありません。さらにバイト数を削りたければ push 42 / pop rax(3バイト)や xor eax, eax / mov al, 42(4バイト)といったテクニックもあり、同じ意味の命令に複数のエンコーディングが存在するのがx86の特徴です。命令長が可変のため、命令の先頭を見つけるには先頭から順にデコードするしかありません。
AArch64:固定4バイト
D2800540 ; mov x0, #42
常に4バイト。命令境界が自明で、任意のアドレスから(4バイトアラインされていれば)デコードを開始できます。
z/Architecture:2/4/6バイトの3種
A7 18 00 2A ; LHI R1,42(Load Halfword Immediate)
4バイト。命令の先頭2ビットで長さが決まります(00→2バイト、01/10→4バイト、11→6バイト)。x86_64ほど複雑ではなく、AArch64ほど固定でもない、実用的な中間を取っています。
アドレッシングモードの比較
メモリアクセスの方法にも、設計思想の違いが見えてきます。
| x86_64 | AArch64 | z/Architecture | |
|---|---|---|---|
| 基本形 | [base + index*scale + disp] |
[base, #offset] |
disp(index, base) |
| スケール | 1, 2, 4, 8 | なし(シフト指定は可能) | なし |
| ディスプレースメント | 8bit / 32bit | 9bit〜12bit(命令依存) | 12bit / 20bit |
| ポストインクリメント | なし | あり | なし |
| プリインデックス | なし | あり | なし |
x86_64 の [base + index*scale + disp] は配列アクセスに極めて強力で、[rsi + rax*4 + 8] のような表現が1オペランドで可能です。
AArch64 はポスト/プリインクリメントでポインタ走査を効率化する方向に振っています。
z/Architecture の disp(index, base) は、構造体のフィールドアクセスを意識した設計です。ベースレジスタでオブジェクトの先頭を指し、ディスプレースメントでフィールドオフセットを指定するパターンが基本形になっています。
呼び出し規約の違い
関数呼び出しの規約を調べてみると、ここにもアーキテクチャごとの違いがありました。
x86_64(System V AMD64 ABI)
- 引数: RDI, RSI, RDX, RCX, R8, R9 の6レジスタ、それ以降はスタック
- 戻り値: RAX
- caller-saved: RAX, RCX, RDX, RSI, RDI, R8-R11
- callee-saved: RBX, RBP, R12-R15
AArch64(AAPCS64)
- 引数: X0〜X7 の8レジスタ
- 戻り値: X0
- callee-saved: X19〜X28
- リンクレジスタ X30 に戻りアドレスが入る(スタックに
CALLのような暗黙のpushはない)
z/Architecture(Linux s390x ABI)
- 引数: R2〜R6
- 戻り値: R2
- callee-saved: R6〜R13
- R14 にリターンアドレス、R15 にスタックポインタ
-
BASR R14,Rxで呼び出し(R14に戻りアドレスを保存しつつRxのアドレスへ分岐)
ARMとz/Architectureはどちらもリンクレジスタ方式で、x86_64 がスタックに戻りアドレスを暗黙にpushする CALL 命令を使うのとは対照的でした。リンクレジスタ方式だとリーフ関数(他の関数を呼ばない関数)でスタック操作を省略できるという点は、並べてみて初めて意識しました。
手元で3アーキテクチャを試す
読むだけでなく実際にアセンブル・実行してみると理解が段違いに変わりました。Apple Silicon Mac であれば、Docker だけで3つとも試せます。
AArch64(ネイティブ)
Mac 上でそのまま動きます。add.s を書いてアセンブル・実行するまでの最小手順です。
# Docker は不要、ネイティブで動く
cat << 'EOF' > add.s
.global _main
.align 2
_main:
mov w0, #30
mov w1, #12
add w0, w0, w1 // w0 = 42
// w0 がそのまま終了コードになる
mov x16, #1 // exit syscall
svc #0x80
EOF
as -o add.o add.s && ld -o add add.o -l System -syslibroot `xcrun -sdk macosx --show-sdk-path`
./add; echo $? # => 42
x86_64(Docker + Rosetta)
docker run --rm -it --platform linux/amd64 ubuntu:24.04 bash
コンテナ内で:
apt update && apt install -y nasm gcc
cat << 'EOF' > add.asm
section .text
global _start
_start:
mov eax, 30
add eax, 12 ; eax = 42
mov edi, eax ; 終了コードに設定
mov eax, 60 ; exit syscall
syscall
EOF
nasm -f elf64 add.asm && ld -o add add.o
./add; echo $? # => 42
z/Architecture (s390x)(Docker + QEMU)
docker run --rm -it --platform linux/s390x ubuntu:24.04 bash
QEMU のソフトウェアエミュレーションで動くため遅いですが、素振りには十分です。コンテナ内で:
apt update && apt install -y gcc binutils
cat << 'EOF' > add.c
// まずは C から生成されるアセンブリを見る
int main() {
int a = 30, b = 12;
return a + b;
}
EOF
gcc -S -O0 add.c -o add.s # アセンブリ出力を確認
cat add.s # z/Architecture のアセンブリが見える
gcc -o add add.c
./add; echo $? # => 42
s390x ではインラインアセンブリや gcc -S の出力を読むところから始めるのが現実的です。HLASM(メインフレーム用アセンブラ)はこの環境では使えませんが、GAS(GNU Assembler)構文での z/Architecture アセンブリは十分に学べます。
Compiler Explorer(ブラウザで試す)
Docker を起動するまでもない場合は、Compiler Explorer が便利です。C のコードを入力すると、x86_64 / AArch64 / s390x の3つのコンパイル結果をブラウザ上で並べて比較できます。-O0 と -O2 を切り替えて、最適化の効果を見るのにも使えます。
まとめ
同じ「整数を足す」「ループを回す」という処理でも、3つのアーキテクチャはそれぞれ異なるアプローチを取っています。
| 特徴 | x86_64 | AArch64 | z/Architecture |
|---|---|---|---|
| 設計思想 | 命令を複雑にして命令数を減らす | 命令を単純にしてパイプライン効率を上げる | 大規模データ処理に最適化した命令を持つ |
| 命令長 | 可変(1〜15B) | 固定(4B) | 3種(2/4/6B) |
| フラグ構造 | RFLAGS(ZF, CF, SF, OFなど多数) | NZCV(4ビット) | CC(2ビット、0~3の4状態) |
| フラグ更新 | 多くの命令が暗黙的に更新 |
s 接尾辞で明示的に選択 |
演算命令が暗黙的に更新 |
| レジスタ数 | 16本 | 31本 | 16本 |
| エンディアン | リトル | 選択可能(通常リトル) | ビッグ |
今回は素振りとして3つのアーキテクチャを並べてみましたが、1つだけ見ていたら「こういうものだ」で終わっていたと思います。比較することで「なぜそう設計されたのか」という問いが立つようになりました。
引き続きアセンブリは深掘りしていく予定なので、次回以降の記事で個別のアーキテクチャにもっと踏み込んでいきます。
Discussion
のが良くないですか?
↑の
は
loop命令を使えばコードサイズは小さくなり、decの代わりにsubを使ってPartial Flag Stallを避ける戦略もあると思います。と書けば2命令ですね。
CISC/RISCの違いではなくアーキテクチャに違いだと思います。
x86-64の知識がある人であればeaxへの操作はraxの上位32bitがクリアされることを知ってるので
と書くと思います。
バイト数を減らしたければ命令数は増えますが
というのもありだと思います。
AArch64もフラグビットは複数ありますが記載されてる内容が比較になっていない気がしますね。
フィードバックありがとうございます。
test eax, eaxやmov eax, 42のイディオム、loop命令の存在、CISC/RISC二項対立の不正確さ、比較表の軸のズレなど、自分では気づけなかった点ばかりで大変勉強になりました。いただいた指摘をもとに記事を修正しました。引き続き精進します。