octahedron

LemとSKKとCommon Lispでたたかうプログラマのブログ

アセンブリ言語に入門したときのメモ

あらまし

 なんとなくアセンブリ言語に入門したのでそのとき詰まったこと等を記します。入門の成果である以下のコードを参照したりします。

my first (ARMv6) assembly program - gist.github.com

 まずはじめに、基礎知識を学ぶにあたって以下の本をまず通して読みました。

 アーキテクチャとして普段つかっているマシンのx86ではなくARMを選んだのは、x86こわいのでまずARMで慣れようという理由からです。あと手持ちのラズパイを活用したかったというのも。この本を譲ってもらっていて読みがかったというのも。
 この本では、いきなり単品のアセンブラファイルを書かずに、まずはgccインラインアセンブラ機能を利用してプログラムの一部をアセンブリ言語で書いていくのを薦めています。標準出力とかABIとかGNU/Linuxシステムコールとかの説明で煩雑になるし、覚えることがたくさんあるからのようです。

 しかし本に書いてあることはここには書きません。本でカバーされてない詰まったポイントとか追加情報とかを記していくつもりです。具体的には…

といったかんじのことを書きます。

Cコードなしのアセンブリコードの記述ルール

 本を一通り読んでインラインアセンブラを試したあとはCコード内ではなく直にアセンブリ言語を書いてみたくなりますが、それはどのような形をしていればよいのでしょうか。
 ぼくは最初、gcc -sの吐くアセンブラコードを参考にして(削って)つくろうとしましたが、かなりたくさんのアセンブラへの指示が書かれていてわからなかったので諦めました(どうもエントリポイントもgcc経由と直接asで異なるようでした。C言語の制約ぽい)。

 というわけでいろいろ調べたところ、ARMでは以下の形が最小のアセンブリコードのようでした。

        .text
        .global _start

_start:
        mov     r0, #0  // return 0;
        mov     r7, #1  // 1: sys_exit()
        svc     #0      // call sys_exit()

 .textELFの.textセクション、つまり機械語の命令列を置くセクションを意味します。これを書くことで以降の行の内容はオブジェクトファイルやメモリの.textに置かれます。セクションによって「プログラムとして実行可能」だったり「書き込み許可」など可能な操作が異なるようです。
 つぎに.global _startですが、これはアセンブル結果に含まれるシンボルを外部からも見れるようにする疑似命令です。モジュール外への関数のエクスポートのようなものと思っておけばよさそうです。ちなみに_startはラベル(後述)です。
 _start:は、アセンブルしたときのこの位置(アドレス)に名前をつけておくためのラベルです。前行の.global疑似命令で公開されているのでリンカでリンクする他のコードからも_startを参照することができます。GNU/Linuxにおいてはこれがプログラムのエントリポイントであるようです(がソース不明)。

 最後の三行はGNU/Linuxシステムコールexitを叩いてプログラムを終了しています。このやりかたはアーキテクチャABI毎に異なっており、man syscallに記載されています。ぼくのラズパイはarm/eabiなのでその方法に従っています。すなわち、

  1. どのシステムコール番号のシステムコールを呼ぶかはr7レジスタの数値で指定する
  2. システムコールへの引数はr0r5レジスタを用いる
  3. システムコールを呼ぶには上の準備をしたあとにsvc #0を実行する

ということです。システムコールがどの番号なのかはアーキテクチャによって異なるようで、これはヘッダファイルを調べるとよいようです。以下のようにgrepすると定義が見つかります(定義方法もアーキテクチャによって異なるので注意)。ちなみに命令のオペランドに、即値としての数値を記述する場合は、数値の前に#を付けるのがGNU asの記法です。

pi@raspberrypi:~ $ grep '__NR_exit' -R /usr/include/
# ココ! 「1」!!!
/usr/include/arm-linux-gnueabihf/asm/unistd.h:#define __NR_exit                 (__NR_SYSCALL_BASE+  1)
/usr/include/arm-linux-gnueabihf/asm/unistd.h:#define __NR_exit_group                   (__NR_SYSCALL_BASE+248)
/usr/include/arm-linux-gnueabihf/bits/syscall.h:#define SYS_exit __NR_exit
/usr/include/arm-linux-gnueabihf/bits/syscall.h:#define SYS_exit_group __NR_exit_group
/usr/include/arm-linux-gnueabihf/bits/syscall.h:#define SYS_exit __NR_exit
/usr/include/arm-linux-gnueabihf/bits/syscall.h:#define SYS_exit_group __NR_exit_group
/usr/include/asm-generic/unistd.h:#define __NR_exit 93
/usr/include/asm-generic/unistd.h:__SYSCALL(__NR_exit, sys_exit)
/usr/include/asm-generic/unistd.h:#define __NR_exit_group 94
/usr/include/asm-generic/unistd.h:__SYSCALL(__NR_exit_group, sys_exit_group)

GNU asのディレクティブや記法のこと

 つぎは文字列の表示がしたいです。

 で、表示をするための処理のうちシステムコールwriteを呼べばよさそうなので、あとはメモリにデータを置くのみです。ldr命令とかでレジスタ-メモリ間はデータを移動できるのですが、メモリに直接データをおくにはどうするか。それはアセンブラやリンカに、起動時にデータを置くよう指示をします。これに用いるのが疑似命令(ディレクティブ)です。ここまでで.text.globalが登場しています。ディレクティブの一覧は以下のマニュアルを参照します。

Using as - Assembler Directives

 ここでは.dataセクション(読み書き可能なデータを置くセクション)を用意して、そこに文字列を置き、それをsys_writeで表示してみます。プログラムはこんな感じです。

.data
hello:
        .byte   0x68  // h
        .byte   0x6f  // o
        .byte   0x67  // g
        .byte   0x65  // e
        .byte   0x0a  // newline
hello_len = . - hello

.text
.global _start

_start:
        ldr     r0, =hello
        mov     r1, #42
        strb    r1, [r0, #1]

        mov     r0, #1          // file discripter
        ldr     r1, =hello
        mov     r2, #hello_len  // character count
        mov     r7, #4          // sys_write
        svc     #0

        mov     r0, #0
        mov     r7, #1
        svc     #0              // sys_exit

 そういえば、コメントは上の本(のインラインアセンブラ章)には#が使えるとあったのですが、アセンブリコードにはつかえなさそうでした。//がコメントとして使えるようなので利用しています。
 .data領域にhello:というラベルをつくり、それ以降に文字を1バイトずつ置いています。hello_len = . - helloはシンボルhello_lenに、記述位置.からhello:の位置を減算した数値(つまり文字列のバイト数)を設定(代入?)しています。より親しみやすい記法で文字列を書ける.stringというディレクティブもありますが、今回はメモリに置いてるっぽさを重視して使いませんでした。
 _start:すぐ後の三行で、hello:のアドレスをldr命令でレジスタにロードしています。ラベル名に=がついていますが、これは=以降にラベル等の評価後数値になるシンボルがきたときそれを即値として記述できる、というGNU asの記法のようです("ARM Opcodes (Using as)"参照)。

 ちなみについでにstrb命令でメモリの書き換えも実験しています。第2オペランド[r0, #1]はメモリアドレスの記法です。「r0レジスタのアドレスに1足したアドレス」という意味です。このへんはARMの命令セットチートシートがさくっと見れてよかったです。

アセンブリコードの実行方法

 上記のhogeを出力するコード (hoge.s) を実行してみます。

まずはラズパイ上で

 GNU asの使いかたがよくわからなかったのですが、以下のようにすれば(ちょっと長いけど)アセンブルとリンクができます。

# アセンブル
$ as hoge.s -o hoge.o
# リンク
$ ld hoge.o -o hoge
# 実行
$ ./hoge
h*oge

 GNU asにas hoge.sとするとa.outができたりするのですが、リンカがエラーを吐くので上のようにしました。

Ubuntu (x86)上で

 ラズパイだとスペックが低いためGNU Emacsやlemが重いので、x86上のUbuntuでもエミュレーションとかで動かせないかを試してみました。

 QEMUユーザーモードエミュレーションを使います。QEMUにはマシン全体をシミュレーションするモードとGNU/Linuxのユーザー環境をエミュレーションするモードがあるようで、こちらを利用すれば、HDDイメージにUbuntuをインストールしておく手間を省いて開発できます。

 利用するには、まずQEMUとARM向けのgccをインストールします。

$ sudo apt install qemu qemu-user-static gcc-arm-linux-gnueabi

 インストールが終わったら、ARM用のas/ldを用いてリンクまで行い、qemu上で実行します。

# アセンブル・リンク
$ arm-linux-gnueabi-as hoge.s -o hoge.o && arm-linux-gnueabi-ld hoge.o -o hoge
# 実行
$ qemu-arm-static -L /usr/arm-linux-gnueabi ./hoge
h*ge

まとめ

 アセンブラたのしい!!

 マシン語は広大だわ…
 なにをつくろうかしら…