M1 Mac でAssembly を動かす【働学併進#013】

昨日に引き続き、予定していた記事の準備に手間取ってしまったため、私が息抜きで見ていた動画とその内容をM1 Mac に拡張した内容を紹介する。

ソースコード

先に元となるC言語のソースコードを紹介する。
行っていることは単純でで、255より小さいフィボナッチ数列 fibonacci number を出力するループを無限ループで実行している。

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アーキテクチャ用のアセンブラ言語を書く必要がある。(それはこの記事の後半で紹介している)

因みに、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巻しか出てこなかったのが悔やまれる。(まぁプログラマーだから妥当な復活時期なのだが)

明日こそは、有名映画の英語を解説する記事を投稿する。

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