⚙️

同じ処理、3つの命令セット——x86_64・ARM・z/Architecture のアセンブリを読み比べる

に公開
5
5
3

はじめに

仕事でアセンブリ言語を扱うことになりそうなので、素振りとして主要なアーキテクチャのアセンブリを読み比べてみました。

昔少しだけアセンブリを触ったことはあるものの、かなりブランクがあります。改めて現代の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, RBXRAX = RAX + RBX を意味し、ソースの片方が破壊されます。

AArch64(RISC)

  • 命令長が固定(すべて4バイト)。デコードが単純で、パイプラインが効率的です。
  • ロード/ストアアーキテクチャ。演算はレジスタ間でのみ行い、メモリアクセスは専用命令で行います。
  • 汎用レジスタが31本(X0〜X30)と豊富。レジスタに乗せて計算を回す思想です。
  • 3オペランド形式。ADD X0, X1, X2X0 = 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つだけ見ていたら「こういうものだ」で終わっていたと思います。比較することで「なぜそう設計されたのか」という問いが立つようになりました。

引き続きアセンブリは深掘りしていく予定なので、次回以降の記事で個別のアーキテクチャにもっと踏み込んでいきます。

5
3

Discussion

藤田望藤田望

x86_64

cmp     eax, 0          ; eax と 0 を比較(フラグレジスタに結果を書く)
je      .is_zero         ; Zero Flag が立っていたらジャンプ
test    eax, eax          ; eax と eax のand結果をフラグレジスタに結果を書く
je      .is_zero         ; Zero Flag が立っていたらジャンプ

のが良くないですか?

1
藤田望藤田望

x86_64

; rsi = 配列のアドレス, ecx = 要素数, eax = 合計(0で初期化済み)
.loop:
    add     eax, [rsi]       ; 現在の要素を加算
    add     rsi, 4           ; ポインタを4バイト進める
    dec     ecx              ; カウンタをデクリメント
    jnz     .loop            ; ゼロでなければループ

↑の

    dec     ecx              ; カウンタをデクリメント
    jnz     .loop            ; ゼロでなければループ

loop命令を使えばコードサイズは小さくなり、decの代わりにsubを使ってPartial Flag Stallを避ける戦略もあると思います。

.loop:
    add     eax, [rsi+4*rcx-4]       ; 現在の要素を加算
    loop .loop

と書けば2命令ですね。

decが自動的にフラグを更新するため、比較命令が不要です。これもCISCの利点の一つで、「副作用としてのフラグ更新」が命令数を減らしています。

CISC/RISCの違いではなくアーキテクチャに違いだと思います。

1
藤田望藤田望

x86_64:可変長

48 C7 C0 2A 00 00 00    ; mov rax, 42

x86-64の知識がある人であればeaxへの操作はraxの上位32bitがクリアされることを知ってるので

B8 2A 00 00 00    ; mov eax, 42

と書くと思います。
バイト数を減らしたければ命令数は増えますが

6A 2A    ; push 42
58       ; pop rax
31 C0    ; xor eax, eax
B0 2A    ; mov al, 42

というのもありだと思います。

1
藤田望藤田望
特徴 x86_64 AArch64 z/Architecture
フラグ/条件 多数のフラグビット 明示的に更新を選択 2ビット条件コード

AArch64もフラグビットは複数ありますが記載されてる内容が比較になっていない気がしますね。

1
YasuYasu

フィードバックありがとうございます。test eax, eaxmov eax, 42 のイディオム、loop 命令の存在、CISC/RISC二項対立の不正確さ、比較表の軸のズレなど、自分では気づけなかった点ばかりで大変勉強になりました。いただいた指摘をもとに記事を修正しました。引き続き精進します。

1
ログインするとコメントできます
5
3