プログラミング言語の作り方

javascript/C言語/アセンブラを用い、 字句解析、構文解析、インタプリタ、コンパイラのプログラムをスクラッチから作りながら、 「プログラミング言語の作り方」を解説する。

最低限のアセンブラ(x86-64)を、手で書き、動かしながら理解する

コンパイラを作る前に最低限のアセンブラを理解しておこう。

ページメニュー

アセンブラとは

アセンブラ(アセンブリ言語)は、バイナリではなく、単なるテキストファイルだ。

cpuが扱うマシン語はバイナリなので、人間が直接扱うのは不便だ。

それら、1つ1つに対応付けた、テキスト文字列をアセンブラでは、命令と呼ぶ。

アセンブラなんか、勉強したくない?

コンパイラをつくるためには、アセンブラを勉強する必要がある。

しかし、「アセンブラなんか、勉強したくない!!」と、敬遠されてしまうことが多い。

その理由は、「CPUが異なったら、別のアセンブラを勉強しないといけない」、とか、 「アセンブラで書くと、マシン依存になる、OS依存になる」とか言われるからだ。

そこで、アセンブラ自体の解説の前に、まずは、アセンブラを覚えてみようかな、と思えるような話をしたい。

異なるCPUメーカー間で、バイナリは動作するか?

同じOSだが、手元に異なるCPUメーカーのLinux環境がある。

片方で作成した実行可能ファイル(バイナリ)を、別のマシンへコピーしても動作するのだろうか?

このページで作成する、アセンブラで記述した、hello worldのサンプルの実行ファイルexecを、scpでコピーして実行してみたが、ちゃんと動作した。

//windowPC上のlinux(AMD Ryzen)で実行
local-pc $ ./exec
hello world
//windowPC上のlinux(AMD Ryzen) → サーバー上のlinux(Intel Xeon)へコピー
local-pc $ scp exec server:
local-pc $ ssh server
//サーバー上のlinux(Intel Xeon)で、同じバイナリを実行
server $ ./exec                                                                                                                                       [~]
hello world

アセンブラは1種類覚えればよい

最近の一般PCに搭載されている64bit CPUを対象に考えよう。

実は、Intel系(Coreなんちゃら、pentinum,celeron,atomなど)やAMD系(Ryzenなんちゃら)であっても、 「x86-64の1種類のアセンブラ」で、実害なく動作する。

スマホや小型デバイス、最近だとapple M1も?、などに搭載されている、arm系cpuは、x86-64とは、別のアセンブラarm64だ。

arm64は、ここでは扱わないが、arm64に興味があれば検索してほしい。

なんだ、まずは、PC用に、x86-64の1種類を勉強するだけでいいんだ、と気が楽になったはずだ。

OSごとの流儀の違いはC言語でも同じ

同じx86-64のアセンブラであっても、確かに、OSごとに流儀が異なり、特に、OSの機能を直接呼び出す際などは異なるし、 OSが異なれば、x86-64を包んでいる、フォーマットが異なるので、同じバイナリのままでは動作しない。

しかし、これらはC言語で記述したって、WindowsとLinuxでは、異なるコードを書かないといけないのと、同じことだ。 アセンブラが悪いわけじゃない。

「プログラミング言語を作り方」を勉強する話としては、単一OS上で動くものだけを考えよう。

覚えるアセンブラ命令はごくわずか

1種類のアセンブラx86_64といっても、その中には膨大な命令が存在する。

極限まで、スピードをもとめて、アセンブラ命令を駆使し、手書きでチューニングするのであれば、多くの命令を覚えないといけないだろう。

しかし、ふつうのコンパイラを作る用途であれば、ごく一部の限られた、本当にわずかな命令だけを、知っていればよいのだ。

asコマンド

実際にアセンブラを記述する前に、関連するコマンドを紹介しておく。

アセンブラ(テキストファイル)を、機械語(バイナリ。オブジェクトファイル)に変換するには、linuxでは、asコマンドを使う。

asコマンドの後に、アセンブラのファイル名(source.s)、その後、出力ファイル名を「-o」オプションで、「-o source.o」と指定するだけだ。

もし、asコマンドがインストールされいなければ、gccなどの開発環境や、binutilsパッケージをインストールすれば入ると思うので、検索してほしい。

$ as source.s -o source.o 

ldコマンド

オブジェクトファイルはバイナリファイルだが、実行可能ファイルではない。

(分割コンパイルされた複数の)オブジェクトファイルを、1つの実行可能ファイルに結合するため、ldコマンドを用いる。

ldコマンドの後に、オブジェクトファイルを並べて、「-o」オプションの後に、実行可能ファイルのファイル名を指定するだけだ。

cの関数を使う場合、「-lc --dynamic-linker /lib64/ld-linux-x86-64.so.2」を追加で指定する必要がある。

これらは何なのかの解説は、アセンブラの話からは脱線してしまうので、ここでは行わない。

#単一オブジェクトファイルの場合
$ ld source.o -o exec
#複数のオブジェクトファイルを1つに結合する場合
$ ld source.o source2.o source.3.o -o exec
# Cの関数を使う場合
$ ld source.o -o exec -lc --dynamic-linker /lib64/ld-linux-x86-64.so.2

アセンブラでhello worldを書く

さて、アセンブラの実際のコードを書いていこう。

まずは、hello worldだ。Cで書くと以下のようになる。

void main(void){
    printf("hello world\n");
    exit(0);
}

アセンブラからも、Cの関数のprintfとexitを呼び出そう。

$ cat start.s
    #intel表記を使う
    .intel_syntax noprefix

    #文字列の場所にhelloとラベルを付ける
    hello: .string "hello world\n"

    #エントリポイント
    .global _start
  _start:

    #--- C言語のprintf関数を呼び出す ---
    #第一引数として使われるrdiレジスタに、helloラベルが示す、アドレスそのものをセットする
    lea rdi,[hello]
    #準備ができたので、printf関数を呼び出す
    call printf

    #--- C言語のexit関数を呼び出す ---
    #第一引数として使われるrdiレジスタに、0をセットする
    mov rdi, 0
    #準備ができたので、exit関数を呼び出す
    call exit

アセンブラのhello worldを実行

解説の前に、まずは、as/ldコマンドで、execを作り、実行してみよう。 ちゃんと、hello worldと表示された。

$ as start.s -o start.o
$ ld start.o -o exec -lc --dynamic-linker /lib64/ld-linux-x86-64.so.2
$ ./exec
hello world

intel表記

アセンブラのコードについて、一つ一つ見ていこう。

まずは、最初の部分だ。 これを指定することで、intel表記を使うことができる。

     #intel表記を使う
     .intel_syntax noprefix

asコマンド(GNU as)のデフォルトの表記方法だと、一例だが、以下のようになる。

     #GNU as標準の表記方法

     #数値の前に、$が必要。
     mov (@$@)60, %rax
     #レジスタの前に%が必要
     mov $60, (@%@)rax
     #「数値、レジスタ」の順
     mov (@$60, %rax@)

単なる好みや慣れの問題なので、好きな方を使ってほしい。

これ以降では、私の好みである、$や%がなく、右から左へ代入する代入文と同じの順序である「レジスタ,数値」となっている、intel表記を使って解説する。

エントリポイント

次は、エントリポイントの部分だ。

     #エントリポイント
     .global _start
     _start:

エントリポイントは、「ここから実行してください」と指定する部分だ。

デフォルトで「_start:」使うことになっている。

「:」の左側をラベルと呼び、その場所を、別の場所から指定できる。

このラベルは、デフォルトでは、1つのオブジェクトファイルを超えて外部に公開されない。

_startから開始するためには、「.global」を指定し、 「_start」を、公開扱いにする必要がある。

文字列リテラル

文字列リテラル"hello world\n"を、示すラベルとして、helloを定義した。

.stringで始めると、文字列の最後にnullをセットしてくれるので、Cに渡す文字列を作る際に、便利だ。

    #文字列の場所にhelloとラベルを付ける
    (@hello: .string "hello world\n"@)

なお、アセンブラ上では、命令の最中に、このようなデータを置くことはできない。

正確には、データと命令の区別がないため、データを命令だと思って実行してしまうので、エラーになる。

_startの前か、exit後のような、処理が絶対に来ない場所に配置する必要がある。

Cのprinf関数を呼び出す

アセンブラでは、printf(第一引数)という記述はできない。

第一引数は、rdiレジスタを使うと決まっているので、rdiにセットする。

    #第一引数として使われるrdiレジスタに、helloラベルが示す、アドレスそのものをセットする
    (@lea@) rdi,[hello]

printfの第一引数であるrdiレジスタに、helloをセットする際、lea命令が使われている。

逆に、exit関数の第一引数であるrdiレジスタに、0をセットする際、mov命令が使われている。

     #第一引数として使われるrdiレジスタに、0をセットする
     (@mov@) rdi, 0

この違いはなんだろうか?

movはラベルを指定すると、ラベルの先にある中身をコピーする。

leaは、アドレスの先の中身ではなく、アドレスそのものをコピーする。

この説明を聞いただけだとイメージしにくいだろうが、 以下のC言語での記述を見れば、movとleaの違いが、分かるだろう。

    //movとleaの違いをイメージしやすい例

    //"hello world\n"の先頭アドレス自体を、ポインタhelloにコピーする(lea)
    char *hello = "hello world\n";

    //a[0]に、b[0]が指すアドレスの先の値'h'をコピーする(mov)
    char a[20];
    a[0] = b[0]; //'h'
    a[1] = b[1]; //'e'
    a[2] = b[2]; //'l'
    …

    //printfにはポインタを渡すが、bの中には、アドレス自体が入っている
    printf(b);

    //cに値0をコピーする(mov)
    int c = 0;

    //int型変数cのアドレスを、dポインタに代入(lea)
    int *d = &c;

    //dが指すアドレスの先に、10を代入する(mov)
    *d = 10;

    //exitには整数を渡す。cの中には、値(0)が入っている。
    exit(c);

最後に、printf関数をcallしている。 このタイミングで、rdiが参照されて第一引数として処理される。

    #準備ができたので、printf関数を呼び出す
    (@call printf@)

Cのexit関数を呼び出す

movを使っている違いはあるが、printfと同じだ。

     #--- C言語のexit関数を呼び出す ---
     #第一引数として使われるrdiレジスタに、0をセットする
     (@mov@) rdi, 0
     #準備ができたので、システムコールを呼び出す
     (@call exit@)

printfで整数の表示

スタックの必要性

push/pop

実数のリテラル

xmmレジスタ

printfで実数の表示

xmmレジスタのaddsd/subsd/mulsd/divsd

xmmレジスタのpush/pop代替手段


このページの目次へ戻るサイトの最上位へ戻る