javascript/C言語/アセンブラを用い、 字句解析、構文解析、インタプリタ、コンパイラのプログラムをスクラッチから作りながら、 「プログラミング言語の作り方」を解説する。
コンパイラを作る前に最低限のアセンブラを理解しておこう。
アセンブラ(アセンブリ言語)は、バイナリではなく、単なるテキストファイルだ。
cpuが扱うマシン語はバイナリなので、人間が直接扱うのは不便だ。
それら、1つ1つに対応付けた、テキスト文字列をアセンブラでは、命令と呼ぶ。
コンパイラをつくるためには、アセンブラを勉強する必要がある。
しかし、「アセンブラなんか、勉強したくない!!」と、敬遠されてしまうことが多い。
その理由は、「CPUが異なったら、別のアセンブラを勉強しないといけない」、とか、 「アセンブラで書くと、マシン依存になる、OS依存になる」とか言われるからだ。
そこで、アセンブラ自体の解説の前に、まずは、アセンブラを覚えてみようかな、と思えるような話をしたい。
同じ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
最近の一般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種類を勉強するだけでいいんだ、と気が楽になったはずだ。
同じx86-64のアセンブラであっても、確かに、OSごとに流儀が異なり、特に、OSの機能を直接呼び出す際などは異なるし、 OSが異なれば、x86-64を包んでいる、フォーマットが異なるので、同じバイナリのままでは動作しない。
しかし、これらはC言語で記述したって、WindowsとLinuxでは、異なるコードを書かないといけないのと、同じことだ。 アセンブラが悪いわけじゃない。
「プログラミング言語を作り方」を勉強する話としては、単一OS上で動くものだけを考えよう。
1種類のアセンブラx86_64といっても、その中には膨大な命令が存在する。
極限まで、スピードをもとめて、アセンブラ命令を駆使し、手書きでチューニングするのであれば、多くの命令を覚えないといけないだろう。
しかし、ふつうのコンパイラを作る用途であれば、ごく一部の限られた、本当にわずかな命令だけを、知っていればよいのだ。
実際にアセンブラを記述する前に、関連するコマンドを紹介しておく。
アセンブラ(テキストファイル)を、機械語(バイナリ。オブジェクトファイル)に変換するには、linuxでは、asコマンドを使う。
asコマンドの後に、アセンブラのファイル名(source.s)、その後、出力ファイル名を「-o」オプションで、「-o source.o」と指定するだけだ。
もし、asコマンドがインストールされいなければ、gccなどの開発環境や、binutilsパッケージをインストールすれば入ると思うので、検索してほしい。
$ as source.s -o source.o
オブジェクトファイルはバイナリファイルだが、実行可能ファイルではない。
(分割コンパイルされた複数の)オブジェクトファイルを、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だ。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
解説の前に、まずは、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_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後のような、処理が絶対に来ない場所に配置する必要がある。
アセンブラでは、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@)
movを使っている違いはあるが、printfと同じだ。
#--- C言語のexit関数を呼び出す --- #第一引数として使われるrdiレジスタに、0をセットする (@mov@) rdi, 0 #準備ができたので、システムコールを呼び出す (@call exit@)