M1 Mac でAssembly を動かす

息抜きで見ていた動画の内容をM1 Mac に適用した内容を紹介します。

ソースコード

以下にあげるのが、255より小さいフィボナッチ数列 fibonacci number を無限ループで表示するC言語のソースコードです。
※わざわざ無限ループを入れてるのは、ただそれに対応するアセンブリを紹介するためです。

C言語

#include <stdio.h>

int main(void) {
    int x, y, z;

    while (1) {
        x = 0;
        y = 1;

        do {
            printf("%d\n", x);
            z = x + y;
            x = y;
            y = z;
        } while (x < 255);
    }
}

機械語の実行部分のみを表示するコマンド

% gcc -o fib fib.c
% otool -tv fib

上記のコマンドで表示されるのは、あくまでもコンパイルされたプログラムの実行部分だけなので、この出力結果の機械語だけで同じ処理を再現することはできません。

Cのプログラムからアセンブリを出力する

% gcc -S fib.c

アセンブリを実行する

% as -o fib.o fib.s
% gcc -o fib fib.o
% ./fib
  1. オブジェクトファイルを作り

  2. ①とリンクして実行ファイルを作り

  3. 実行

私の環境では、ldのコマンドを使うと以下のようなエラーが出たので、色々設定するよりもこっちのコマンドを使うことにしました。

% ld -o fib fib.o
Undefined symbols for architecture arm64:
  "_printf", referenced from:
      _main in fib.o
ld: symbol(s) not found for architecture arm64

C言語とx86_64の機械語を比較する

この記事の冒頭で紹介した動画で説明されているアセンブラ言語 Assembly はx86(32bit)アーキテクチャとx86_64(64bit)アーキテクチャ用です。
それに対し、M1 Mac はarmアーキテクチャであるため、armアーキテクチャ用のアセンブラ言語を書く必要があります。
この記事の後半でarmアーキテクチャ用のアセンブリを解説しています。

因みに、x86アーキテクチャとx86_64アーキテクチャには書き方が微妙に違う2つの構文があります。
「違う構文」といっても流儀が違うという意味で、意味は同じです。
レジスタ名に%をつけるAT&T構文と、%をつけないIntel構文です。
この動画では%rbpや%rspなどと、レジスターに%をつけているのでAT&T構文を使っていることがわかります。

全体のx86の機械語

fib:
(TEXT, text) section
_main:
0000000100000f20        pushq   %rbp
0000000100000f21        movq    %rsp, %rbp
0000000100000f24        subq    $0x20, %rsp
0000000100000f28        movl    $0x0, -0x4(%rbp)
0000000100000f2f        movl    $0x0, -0x8(%rbp)
0000000100000f36        movl    $0x1, -0xc(%rbp)
0000000100000f3d        leaq    0x56(%rip), %rdi
0000000100000f44        movl    -0x8(%rbp), %esi
0000000100000f47        movb    $0x0, %al
0000000100000f49        callq   0x100000f78
0000000100000f4e        movl    -0x8(%rbp), %esi
0000000100000f51        addl    -0xc(%rbp), %esi
0000000100000f54        movl    %esi, -0x10(%rbp)
0000000100000f57        movl    -0xc(%rbp), %esi
0000000100000f5a        movl    %esi, -0x8(%rbp)
0000000100000f5d        movl    -0x10(%rbp), %esi
0000000100000f60        movl    %esi, -0xc(%rbp)
0000000100000f63        movl    %eax, -0x14(%rbp)
0000000100000f66        cmpl    $0xff, -0x8(%rbp)
0000000100000f6d        jl      0x100000f3d
0000000100000f73        jmp     0x100000f2f

setup

pushq   %rbp
movq    %rsp %rbp
subq    $0x20, %rsp
movl    $0x0, -0x4(%rbp)

初めの数行は、ただのプログラムを実行するための準備であり、C言語で書いたソースコードに対応するところはないのだそう。

x = 0; y = 0;

movl    $0x0, -0x8(%rbp)
movl    $0x1, -0xc(%rbp) 

movl(move long) は第一引数(1行目は0、2行目は1)を第二引数に渡されたレジスターに格納するコマンド。
rbp はスタックのベースポインターであり、そこからのオフセット量が0x8や0xc($$12_{10}$$)と記されている。

つまり、メモリ上にあるスタックのベースポインターから8バイトオフセットされた場所にxの初期値値(0)をおき、そこから4バイトオフセットされた場所にyの初期値(1)を置いたということになる。

printf

leaq    0x56(%rip), %rdi
movl    -0x8(%rbp), %esi
movb    $0x0, %al
callq   0x100000f78
  1. leaq 0x56(%rip), %rdi: \nをセット

  2. movl -0x8(%rbp), %esi: xをセット

  3. なんかして(動画でもそう言ってた)

  4. callq 0x100000f78: 呼び出し。0x100000f78はちょうど添付した機械語の行の下にある

z = x + y;

movl    -0x8(%rbp), %esi
addl    -0xc(%rbp), %esi
movl    %esi, -0x10(%rbp)
  1. rbpから8バイトオフセットされたメモリの値(xの値)をesiレジスターに格納

  2. rbpから12バイトオフセットされたメモリの値(yの値)をesiレジスターに足す

  3. その結果を、rbpから16バイトオフセットされたメモリ(z)に格納

x = y; y = x;

movl    -0xc(%rbp), %esi
movl    %esi, -0x8(%rbp)
movl    -0x10(%rbp), %esi
movl    %esi, -0xc(%rbp)
  1. yの値を一度esiレジスターに格納してから、xへコピー

  2. zの値を一度esiレジスターに格納してから、yへコピー

movl    %eax, -0x14(%rbp)

この文は、デバッガーがデバッグ用にprintfの結果を格納しているだけのようだ。YouTube のコメントありがとう。

while (x < 255)

cmpl    $0xff, -0x8(%rbp)
jl      0x100000f3d
  1. cmpl(compare long): 第一引数(255)と第二引数(xの値)を比較する

  2. jl(jump if less than): 比較結果が小さかったら、指定されたアドレス(printfの挙動が書かれた場所)にジャンプする

while(1)

jmp     0x100000f2f

jmp: この場所を通ったときは必ず指定された場所(x = 0の場所)にジャンプする

arm のAssemblyと比較する

全体像

fib:
(__TEXT,__text) section
_main:
0000000100003f30        sub     sp, sp, #0x30
0000000100003f34        stp     x29, x30, [sp, #0x20]
0000000100003f38        add     x29, sp, #0x20
0000000100003f3c        stur    wzr, [x29, #-0x4]
0000000100003f40        b       0x100003f44
0000000100003f44        stur    wzr, [x29, #-0x8]
0000000100003f48        mov     w8, #0x1
0000000100003f4c        stur    w8, [x29, #-0xc]
0000000100003f50        b       0x100003f54
0000000100003f54        ldur    w9, [x29, #-0x8]
0000000100003f58        mov     x8, x9
0000000100003f5c        mov     x9, sp
0000000100003f60        str     x8, [x9]
0000000100003f64        adrp    x0, 0 ; 0x100003000
0000000100003f68        add     x0, x0, #0xfb4 ; literal pool for: "%d\n"
0000000100003f6c        bl      0x100003fa8 ; symbol stub for: _printf
0000000100003f70        ldur    w8, [x29, #-0x8]
0000000100003f74        ldur    w9, [x29, #-0xc]
0000000100003f78        add     w8, w8, w9
0000000100003f7c        str     w8, [sp, #0x10]
0000000100003f80        ldur    w8, [x29, #-0xc]
0000000100003f84        stur    w8, [x29, #-0x8]
0000000100003f88        ldr     w8, [sp, #0x10]
0000000100003f8c        stur    w8, [x29, #-0xc]
0000000100003f90        b       0x100003f94
0000000100003f94        ldur    w8, [x29, #-0x8]
0000000100003f98        subs    w8, w8, #0xff
0000000100003f9c        b.lt    0x100003f54
0000000100003fa0        b       0x100003fa4
0000000100003fa4        b       0x100003f44

一見無駄なb(ジャンプ)のコマンドが所々に入っているように見えるが、C言語で書かれたプログラム1行(もしくは同じようなことを行うプログラム数行)ごとの区切りとなっていることが後々わかる。

setup

sub     sp, sp, #0x30
stp     x29, x30, [sp, #0x20]
add     x29, sp, #0x20
stur    wzr, [x29, #-0x4]
b       0x100003f44

基本的におこなっていることはプログラムを実行するためのセットアップなので、これから出てくる命令やレジスタだけを説明する。

  1. sp:スタックポインターを表すレジスター。sub:引き算を行う命令
    $$sp = sp - 48_{10}$$という意味

  2. x29レジスターに、スタックポインターから32バイトだけ後ろにオフセットされた(足し算された)場所をコピー

    • stp: レジスターからメモリにコピーする(ストアする)命令

  3. x29レジスターに登録されている場所から4バイトだけ前にオフセットされたメモリに0をコピーしている

    • wzr: 常に0を表すレジスター(0レジスター)

  4. b: 指定された場所(次の行の処理)にジャンプしている

x = 0; y = 1;

stur    wzr, [x29, #-0x8]
mov     w8, #0x1
stur    w8, [x29, #-0xc]
b       0x100003f54
  1. スタックベースから8バイト分オフセットされたメモリ(xの場所)に0をコピー

    • 先ほどのセットアップの処理で、x29はスタックポインタの役割を果たすようになっている

    • stur: store unscaled register: 第一引数のレジスタの内容を第二引数のレジスタに保存する

  2. mov: 第一引数のレジスターに、第二引数のレジスターの内容をコピーする命令

    • yに代入する値を保持している

  3. スタックベースから12バイト分オフセットされたメモリ(yの場所)に1をコピー

printf("%d\n", x);

ldur    w9, [x29, #-0x8]
mov     x8, x9
mov     x9, sp
str     x8, [x9]
adrp    x0, 0 ; 0x100003000
add     x0, x0, #0xfb4 ; literal pool for: "%d\n"
bl      0x100003fa8 ; symbol stub for: _printf

xの値や表示するリテラルの値をレジスターに登録して、printfの命令たちがある場所へジャンプしている。結果的に、レジスターたちは以下の内容のように更新されているが以降の処理にはあまり影響を及ぼしていない。

  • w9: xの値の場所

  • x8, x9: ベースとなるスタックポインター

  • x0: "%d\n"のリテラル値が保存されている場所

z = x + y;

0000000100003f70        ldur    w8, [x29, #-0x8]
0000000100003f74        ldur    w9, [x29, #-0xc]
0000000100003f78        add     w8, w8, w9
0000000100003f7c        str     w8, [sp, #0x10]
  1. xを保持しているメモリの値をw8レジスターにロードする

  2. yを保持しているメモリの値をw9レジスターにロードする

  3. w8レジスターに、w8とw9を足した値を格納する

  4. スタックポインターから16バイトオフセットされたメモリ(zの場所)に、w8レジスターの値(x + y)を格納している

x = y; y = z;

ldur    w8, [x29, #-0xc]
stur    w8, [x29, #-0x8]
ldr     w8, [sp, #0x10]
stur    w8, [x29, #-0xc]
b       0x100003f94
  • yの値を一度w8レジスターに格納してから、xへコピー

  • zの値を一度w8レジスターに格納してから、yへコピー

while (x < 255);

ldur    w8, [x29, #-0x8]
subs    w8, w8, #0xff
b.lt    0x100003f54

xの値をw8レジスターにコピーして、その値が255より小さければ指定された場所(printfの処理が始まる場所)にジャンプする。

while(1)

b       0x100003f44

この命令を実行することになるたび必ずx = 0にジャンプする。


因みに、某漫画ではこれを16進数で暗記している化け物が登場する。

Dr. Stone (Z=205 宇宙を作る0と1)

私の最も好きなキャラクターで、彼の最初の登場は23巻。最終巻の4巻しか出てこなかったのが悔やまれる。(まぁプログラマーだから妥当な復活時期なのだが)

この記事が気に入ったらサポートをしてみませんか?