[IPSwitch]アセンブラを理解せよ

アセンブラに関する理解

これが全く足りていないので再度勉強することにする。実はいろいろコードを試したりしているのだが、理解が不十分すぎてコードを実装するのが全く捗っていない。

六年くらい前に一回触っただけのアセンブラに比べたらまだC言語の方が理解が深いので、それぞれを比べることでアセンブラの動きの理解を深める事を目的とした。

基本的なプログラム対する理解がないと以下の内容は全く意味がわかりません。

※関数の戻り値、変数の概念、値の代入、加算減算命令、配列への”理解”が必要です。

アセンブルはめんどくさい

ただ、C言語からアセンブラに変換するのはめんどくさい。普通は中間言語としてmain.oのようなファイルが生成されたりするのだが「コードを書いてそれをコンパイルしてその中間言語と比較して」とかやっていたら時間がいくらあっても足りない。

なんとかならないものかと考えていたらWebでCからアセンブラに変換できる神サイトがあったのでご紹介する。

Compiler Explorer

int main(void) {
    int x = 100;
    int y = 10;
    return x + y;
}

コードとしてはひどく初歩的な関数内で二つの変数の値をセットしてその和を返すプログラムを実装した。

それをARM64のアセンブラに直すと次のようなコードになる。

main:
        sub     sp, sp, #16
        mov     w0, 100
        str     w0, [sp, 12]
        mov     w0, 10
        str     w0, [sp, 8]
        ldr     w1, [sp, 12]
        ldr     w0, [sp, 8]
        add     w0, w1, w0
        add     sp, sp, 16
        ret

たった6行のコードが倍近い11行のコードになった。しかし、必要最低限の用語だけで記述されており、極めて合理化された言語であることがわかる。

最も、合理化しすぎて一般的には読みにくい言語になってしまったのだが、アセンブラに対して可読性うんぬんを言及するのはお門違いというものだろう。

ARM命令

さて、まずは命令について復習する。

この記事でもチラっと書いたが、アセンブラ自体は必要最低限の二十個ほどの命令の定義と使い方を覚えておけば、なんとなくコードの意味を理解することができる。

今回のこのコードもでてきているのはたったの六つの命令で構成されている。順に追って復習しよう。

#SUB

減算命令である。

SUB X0, X1, #10
// X0 = X1 - 10

#MOV

代入命令である。

MOV W0, 100
// W0 = 100

#STR

ストア命令である。ワーキングレジスタは命令を実行するたびにどんどん値が書き換えられるので、ワーキングレジスタの内容をメモリに値を避難させるときに使われる。

STR W0, [SP, 12]
// MEM[SP+12] = W0

SPはスタックポインタと呼ばれる値で、現在CPUが作業しているアドレスの値が入っている。

#LDR

ロード命令である。メモリの内容をワーキングレジスタに戻す場合に使われる。

LDR W1, [SP, 12]
// W1 = MEM[SP+12]

#ADD

加算命令である。

ADD W0, W1, W0
// W0 = W1 + W0

#RET

リターン命令である。

MOV X0, #100
RET
// return 100

引数を何も取らない場合、スタックポインタのアドレスまで戻る?

返り値がある場合は、X0(またはW0)のレジスタの値を返す?

恐らく「値はのちのちレジスタを参照しろ」という意味であり、値自体は返してはいない。

解説

ここまでARMの命令を学べばこれらの命令から元のコードを復元することが可能である。

int main(void) {
    int MEM[1]; //Memory
    int W0;
    W0 = 100;
    MEM[0] = W0;
    W0 = 10;
    MEM[1] = W0;
    W1 = MEM[0];
    W0 = MEM[1];
    W0 = W0 + W1; // W0 += W1
    return W0;
}

これだけ見るとなんとまあややこしいことをしているコードなんだという気がする。

しかし、最近のコンパイラは進んでいるのでこの程度のコードであれば更に最適化を行って次のようなアセンブラを返すことでしょう。

main:
        mov     w0, 110
        ret

場合によっては関数自体をなかったものにしてしまうこともあるかもしれません。何故ならこの関数は常に110という定数しか返さないからです。

コンパイルオプション-Oまたは-O3などをつけるとコードが最適化されます。実際にやってみたところ、想定通りのアセンブラが返ってきました。

関数呼び出しは基本的に時間のかかるコードなので、定数として扱ってしまうほうが速いです。高度なコンパイラはそれすらも配慮してコンパイルするというのだからびっくり仰天ですね。

レジスタとメモリ

めんどくさかったのですが、パワーポイントを使ってレジスタとメモリの動きを再現してみました。初心者が勘違いしやすいのですが、スタックポインタに入っているのは単なる値ではなくポインタ(メモリのアドレス)です。

何故アドレスが4バイト単位で区切れているのかというと、それは整数型(int)が32ビットなためです。

つまり、最大の数を用いたとしても32 / 8 = 4バイトあれば足りるわけです。

int main(void) {
    int x = 2147483647;
    return x;
}

つまり、こんなコードもxがちゃんと4バイト以内に収まるので、

main:
        sub     sp, sp, #16
        mov     w0, 2147483647
        str     w0, [sp, 12]
        ldr     w0, [sp, 12]
        add     sp, sp, 16
        ret

と書けるわけです。

シェアする