低レイヤを知りたい人のためのCコンパイラ作成入門 Rui Ueyama <ruiu@cs.stanford.edu>

はじめに

このオンラインブックは執筆中です。完成版ではありません。フィードバックフォーム

この本には一冊の本に盛り込むにはやや欲張りな内容を詰め込みました。本書では、C言語で書かれたソースコードをアセンブリ言語に変換するプログラム、つまりCコンパイラを作成します。コンパイラそのものもCを使って開発します。当面の目標はセルフホスト、すなわち自作コンパイラでそれ自身のソースコードをコンパイルできるようにすることです。

この本では、コンパイラの説明の難易度が急に上がりすぎないように、様々なトピックを本書全体を通じて次第に掘り下げていくという形で説明することにしました。その理由は次のとおりです。

コンパイラは、構文解析、中間パス、コード生成といった複数のステージに概念的に分割することができます。よくある教科書的アプローチでは、それぞれのトピックについて章を立てて解説を行うことになりますが、そのようなアプローチの本は話が途中で狭く深くなりすぎて、読者がついていけなくなりがちです。

また、ステージごとに作っていく開発手法では、全てのステージが完成するまでコンパイラを動かしてみることができないので、全体が動き始めるまで自分の理解やコードが決定的に間違っていても気づくことができないという欠点があります。そもそも次のステージの入力としてなにが期待されているのか、自分で作ってみるまでよくわからないので、その前のステージで何を出力すればよいのかもよくわからないのです。完成するまで何のコードもまったくコンパイルできないのでモチベーションを保つのが困難という問題もあります。

本書ではその罠を避けるために別のアプローチをとることにしました。この本の最初のほうで、読者はごく単純な言語仕様の「独自言語」を実装することになります。その言語はあまりにも単純なので、それを実装する時点では、コンパイラの作り方について詳しく知っている必要はありません。その後、読者は本書を通じて「独自言語」に機能を追加していって、最終的にそれをCと一致するものに育て上げることになります。

そのようなインクリメンタルな開発手法では、細かいコミットを刻みつつ、ステップ・バイ・ステップでコンパイラを作っていくことになります。この開発手法では、どのコミットにおいてもコンパイラはある意味常に「完成形」です。ある段階ではただの電卓レベルのことしかできないかもしれないし、ある段階では相当限定されたCのサブセットかもしれないし、ある段階ではほぼCと言える言語かもしれない、というようになります。ポイントは、どの段階でも、その時点の完成度に合わせたリーズナブルな仕様の言語を目指すという点です。開発中に一部の機能だけを突出してC言語ぽくすることは行いません。

データ構造やアルゴリズム、コンピュータサイエンス的な知識についても、開発段階に応じて順次解説していきます。

インクリメンタルな開発では、本書を読んでいるどの時点においても、読者はそこまでのレベルにおいてのリーズナブルな言語の作り方の知識をまんべんなく持っている、ということが達成されることになります。これはコンパイラ作成の一部のトピックだけ極端に偏って詳しい状態よりずっとよい状態です。そして、本書を読み終わるころには、すべてのトピックについてまんべんなく知識が得られていることでしょう。

また、この本は、大きなプログラムを1から書くにはどうすればよいのかということを説明している本でもあります。大きなプログラムを作るスキルというのは、データ構造やアルゴリズムを学ぶのとはまた違った一種独特のスキルなのですが、そういったものを解説している本はあまりないように思います。また、仮に解説してもらっても、実際に体験してみなければ、開発手法の良し悪しというものはよくわからないものです。本書は、自作言語をC言語に育てていくプロセスが、一つの良い開発手法の実体験になるようにデザインされています。

筆者の目論見が成功していれば、この本を読むことで、読者はコンパイラ作成のテクニックやCPU命令セットの知識だけではなく、大きなプログラムを小さなステップにわけて少しづつ作っていく方法や、ソフトウェアテストの手法、バージョン管理の手法、そしてコンパイラ作成のような野心的なプロジェクトに取り組むときの心構えすら学ぶことができるはずです。

この本の想定読者は普通のCプログラマです。Cの言語仕様を熟知しているスーパーCプログラマである必要はありません。ポインタや配列が理解できており、他人の書いた小規模なCプログラムを、少なくとも時間をかければ読める、というレベルであれば十分です。

この本の執筆にあたって、言語仕様やCPUの仕様については、単に仕様を説明するだけではなく、なぜそのようなデザインが選ばれたのかについての解説をできる限り行うようにしました。また、読者の興味を引くようなコンパイラやCPU、コンピュータ業界やその歴史についてのコラムを散りばめて、楽しく読み進められるように心がけました。

コンパイラ作成は大変楽しい作業です。最初のころはバカバカしいくらい単純なことしかできなかった自作言語が、開発を続けていくとたちまちのうちに自分でも驚くくらいC言語っぽく成長していって、まるで魔法のようにうまく動くようになります。実際に開発をしてみると、その時点でうまくコンパイルできるとは思えない大きめのテストコードがエラーなしにコンパイルできて、完全に正しく動くことに驚くことがよくあります。そういうコードはコンパイル結果のアセンブリを見ても自分ではすぐには理解できません。時折、自作のコンパイラが作者である自分を超える知性を持っているように感じることすらあります。コンパイラは仕組みがわかっていても、どことなく、なぜここまでうまく動くのか不思議な感じがするプログラムです。きっとあなたもその魅力に夢中になることでしょう。

さて、前置きはこれくらいにして、さっそく筆者と一緒にコンパイラ開発の世界に飛び込んでみましょう！

なぜC言語なのか 数多くあるプログラミング言語の中で、この本ではなぜCを選んだのでしょうか？ あるいはなぜ自作言語ではないのでしょうか？ この点については、絶対にCでなければならない理由はないのですが、ネイティブコードを出力するコンパイラの制作手法を学ぶために何らかの言語を選ばなければいけないとしたら、Cはさほど多くないリーズナブルな選択肢のうちの一つだと思います。 インタプリタ方式の言語では低レイヤについてあまり学ぶことができません。一方でCでは普通はアセンブリにコンパイルするので、コンパイラを作ることで、Cそのものと同時に、CPUの命令セットやプログラムの動く仕組みなどを学ぶことができます。 Cは広く使われているので、コンパイラがきちんと動くようになったら、ネットからダウンロードしてきた第三者のソースコードをコンパイルして遊ぶことができます。例えばミニUnixのxv6をビルドして遊ぶことができるでしょう。コンパイラの完成度が十分に高ければ、Linuxカーネルすらコンパイルすることが可能なはずです。こういった楽しみ方はマイナーな言語や自作言語ではできません。 Cのようなネイティブな機械語にコンパイルされる静的型付け言語で、Cと少なくとも同じくらい広く使われているものとして、C++があります。しかしC++は、言語仕様があまりにも巨大で、気軽に自作コンパイラを作るのは不可能で、現実的に選択肢に入りません。 オリジナルの言語をデザインして実装するのは、言語のデザインセンスを磨くという意味ではよいのですが、落とし穴もあります。実装が面倒なところは、言語仕様でそれを避けることにより実装しないで済ませてしまうことができるのです。言語仕様が標準として与えられているCのような言語ではそうはいきません。その縛りは学習という意味ではわりとよいものだと思います。

本書の表記法

関数や式、コマンドなどは本文中で main や foo=3 、 make のように等幅フォントで表示します。

複数行に渡るコードは、次のように等幅フォントを使って枠の中に表示します。

枠で囲まれたコードがユーザがそのまま入力することを想定しているシェルコマンドの場合、 $ から始まる行はプロンプトを表しています。その行の $ 以降をシェルに入力してください（ $ そのものは入力しないようにしてください）。 $ 以外の行は、入力したコマンドからの出力を表しています。例えば下のブロックは、ユーザが make という文字列を入力してエンターを押した場合の実行例です。 make コマンドからの出力は make: Nothing to be done for `all'. です。

$ make make: Nothing to be done for `all'.

本書の想定する開発環境

本書ではIntelやAMDなどのいわゆる普通のPCで動く64ビットのLinux環境を想定しています。読者がお使いのディストリビューションに合わせてgccやmakeといった開発ツールをあらかじめインストールしておいてください。Ubuntuであれば以下のコマンドを実行することで、本書で使用しているコマンドをインストールできます。

$ sudo apt update $ sudo apt install -y gcc make git binutils libc6-dev

macOSはLinuxとアセンブリのソースレベルでかなり互換性がありますが、完全互換ではありません（具体的には「スタティックリンク」という機能がサポートされていません）。この本の内容に従ってmacOS対応のCコンパイラを作成するのは不可能ではないものの、実際に試してみると、細かな点でいろいろな非互換性に悩まされることになるでしょう。Cコンパイラ作成のテクニックと、macOSとLinuxの差異を同時に学ぶというのは、お勧めできることではありません。何かがうまく動かない場合、どちらの理解が間違っているのかよくわからなくなってしまうからです。

したがって本書ではmacOSは対象外とします。macOSでは何らかの仮想環境を使ってLinux環境を用意するようにしてください。Linuxの仮想環境を用意するのが初めてだという読者は、Dockerを使って開発環境を作成する方法を付録3にまとめておいたので参考にしてください。

WindowsはLinuxとはアセンブリのソースレベルで互換性がありません。ただし、Windows 10ではLinuxを1つのアプリケーションのようにWindows上で動作させることが可能で、それを使うことでWindows上で開発を進めていくことができます。Windows Subsystem for Linux（WSL）というアプリケーションがそのLinux互換環境です。本書の内容をWindowsで実践するときは、WSLをインストールして、その中で開発を進めるようにしてください。

クロスコンパイラ コンパイラが動作するマシンのことを「ホスト」、コンパイラが出力したコードが動作するマシンのことを「ターゲット」といいます。本書ではどちらも64ビットのLinux環境ですが、ホストとターゲットは必ずしも同じである必要はありません。 ホストとターゲットが異なるコンパイラのことをクロスコンパイラといいます。たとえばRaspberry Piの実行ファイルを生成するWindowsで動くコンパイラはクロスコンパイラです。クロスコンパイラは、ターゲットのマシンがコンパイラを動かすには貧弱だったり特殊だったりするときによく使われます。

著者について

植山 類（@rui314）。高速なリンカlldのオリジナル作者かつ現メンテナで、lldはAndroid（バージョンQ以降）やFreeBSD（12以降）、Nintendo Switch、ChromeやFirefoxなど、多くのOSやプロジェクトにおいて、実行ファイルを作成する標準リンカとして採用されています（したがって筆者が書いたツールが作成したバイナリが、読者の手元のコンピュータに入っている可能性は高い）。コンパクトなCコンパイラ8ccの作者でもあります。ソフトウェアに関するエッセイは主にnoteに書いています。

コンパイラをコンパイルするコンパイラ CコンパイラがCで書かれているといった自己参照的な状況は珍しくありません。C以外でも、数多くの言語実装がその言語自体を使って書かれています。 すでに言語Xの実装がある場合、その言語自身を使って新たなXコンパイラを作ることに論理的な矛盾はありません。もしセルフホストをしようと思ったら、単に既存のコンパイラで開発を進めていって、自作のものが完成したらスイッチすればよいだけです。この本で我々が行おうとしているのはまさにその方法です。 しかし既存のコンパイラがない場合はどうすればよいのでしょうか？ そのときには別の言語を使って書くしかありません。セルフホストするつもりでX言語の最初のコンパイラを書くときには、Xと異なる既存のY言語を使って書き、コンパイラの完成度が高まったところで、コンパイラ自身をY言語からX言語に書き直す必要があります。 現代の複雑なプログラミング言語のコンパイラも、その言語の実装をコンパイルするために使った別のコンパイラ、というように系譜をさかのぼっていくと、最終的に、コンピュータの黎明期に誰かが機械語で直接書いた単純なアセンブラにたどりつくはずです。現存するすべての言語実装のある意味の究極の祖先にあたるそのアセンブラが、単一なのか複数あったのかはわかりませんが、現在のコンパイラがごく少数の祖先から出発しているのは間違いないでしょう。コンパイラ以外の実行ファイルも普通はコンパイラが生成したファイルですから、現存するほぼすべての実行ファイルは、その原始のアセンブラの間接的な子孫にあたるわけです。これは生命の起源のような面白い話ですね。

機械語とアセンブラ

この章では、コンピュータを構成するコンポーネントと、我々が作成するCコンパイラからどのようなコードを出力すればよいのかということについて、大雑把なイメージをつかむことを目標とします。具体的なCPUの命令などについてはまだ深入りはしません。まずは概念を把握することが重要です。

CPUとメモリ

コンピュータを構成するコンポーネントは、大きくCPUとメモリにわけることができます。メモリはデータを保持できるデバイスで、CPUは、そのメモリを読み書きしながら何らかの処理を行なっていくデバイスです。

概念的に、CPUにとってはメモリはランダムアクセス可能な巨大なバイトの配列のように見えます。CPUがメモリにアクセスするときは、メモリの何バイト目にアクセスしたいのかという情報を数値で指定するわけですが、その数値のことを「アドレス」といいます。例えば「アドレス16から8バイトのデータを読む」というのは、バイトの配列のように見えているメモリの16バイト目から8バイト分のデータを読む、という意味です。同じことを「16番地から8バイトのデータを読む」ということもあります。

CPUが実行するプログラムと、そのプログラムが読み書きするデータは、どちらもメモリに入っています。CPUは「現在実行中の命令のアドレス」をCPU内部に保持していて、そのアドレスから命令を読み出して、そこに書かれていることを行い、そして次の命令を読み出して実行する、ということを行なっています。その現在実行中の命令のアドレスのことを「プログラムカウンタ」（PC）や「インストラクションポインタ」（IP）といいます。CPUが実行するプログラムの形式そのもののことを「機械語」(machine code)といいます。

プログラムカウンタは必ずしも直線的に次の命令だけに進んでいくわけではありません。CPUの「分岐命令」（branch instruction）という種類の命令を使うと、プログラムカウンタを、次の命令以外の任意のアドレスに設定することができます。この機能によってif文やループなどが実現されています。プログラムカウンタを次の命令以外の場所に設定することを「ジャンプする」あるいは「分岐する」といいます。

CPUはプログラムカウンタのほかにも、少数のデータ保存領域を持っています。例えばIntelやAMDのプロセッサには、64ビット整数が保持できる領域が16個あります。この領域のことを「レジスタ」（register）と呼びます。メモリはCPUから見て外部の装置で、それを読み書きするには多少の時間がかかりますが、レジスタはCPU内部に存在していて、遅延なしにアクセスすることができます。

多くの機械語は、2つのレジスタの値を使って何らかの演算を行なって、その結果をレジスタに書き戻すというフォーマットになっています。したがってプログラムの実行というものは、CPUがメモリからレジスタにデータを読み込んできて、レジスタとレジスタの間でなんらかの演算を行い、その結果をメモリに書き戻す、ということで実行が進んでいくことになります。

特定の機械語の命令を総称として「命令セットアーキテクチャ」（instruction set architecture, ISA）あるいは「命令セット」といいます。命令セットは一種類というわけではなく、CPUごとに好きにデザインしてかまいません。とはいえ、機械語レベルの互換性がないと同じプログラムを動かせないので、命令セットのバリエーションはそれほど多くありません。PCでは、Intelやその互換チップメーカーであるAMDの、x86-64と呼ばれる命令セットが使われています。x86-64は主要な命令セットの1つですが、x86-64だけが市場を独占しているというわけではありません。例えばiPhoneやAndroidではARMという命令セットが使われてます。

x86-64命令セットの名称 x86-64は、AMD64やIntel 64、x64などと呼ばれることもあります。同一の命令セットにこのように複数の名前がついているのには歴史的背景があります。 x86命令セットは1978年にIntelが作ったものですが、それを64ビットに拡張したのはAMDです。64ビットプロセッサが必要になりつつあった2000年頃、IntelはItaniumというまったく新しい命令セットに全社を挙げて取り組んでいて、それと競合することになる64ビット版x86にはあえて取り組んでいませんでした。その隙を突いてAMDが64ビット版x86の仕様を策定して公開しました。それがx86-64です。そのあとAMDはブランディング戦略の都合上か、x86-64をAMD64と改名しました。 その後Itaniumの失敗が明白になり、Intelは64ビット版x86を作ることしか選択肢がなくなってしまったのですが、そのころにはAMD64の実際のチップがそれなりに数が出ていたので、それと似て非なる拡張命令セットをいまさら策定するのも難しく、IntelもAMD互換の命令セットを採用することになりました。Microsoftからも互換性維持のプレッシャーがあったと言われています。そのときにIntelは、AMD64とほぼ全く同じ命令セットにIA-32eという名前をつけて採用しています。64ではなくIA-32e (Intel Architecture 32 extensions) という名前をつけたことには、64ビットCPUの本丸はあくまでItaniumであるという、失敗した命令セットに対する未練が透けて見えるようです。そのあとIntelはItaniumを完全に見捨てる方針を取るようになり、IA-32eはIntel 64という普通の名前に改名されました。Microsoftは長すぎる名前を嫌ってか、x86-64のことをx64と呼んでいます。 上記のような理由で、x86-64はたくさんの異なる名前を持っているのです。 オープンソースプロジェクトでは、特定の会社の名前が入っていないx86-64という名称が好まれることが多いようです。本書でもx86-64という名称を一貫して使っています。

アセンブラとは

機械語はCPUが直接読んでいくものですから、CPUの都合だけが考慮されていて、人間にとっての扱いやすさというものは考慮されていません。こういった機械語をバイナリエディタで書いていくのは、不可能というわけではないものの、とても辛い作業です。そこで発明されたのがアセンブラです。アセンブリは機械語にほぼそのまま1対1で対応するような言語なのですが、機械語よりもはるかに人間にとって読みやすいものになっています。

仮想マシンやインタープリタではなくネイティブなバイナリを出力するコンパイラの場合、通常、アセンブリを出力することが目標になります。機械語を直接出力しているように見えるコンパイラも、よくある構成では、アセンブリを出力したあとにバックグラウンドでアセンブラを起動しています。本書で作るCコンパイラもアセンブリを出力します。

アセンブリのコードを機械語に変換するのは「コンパイルする」ということもありますが、入力がアセンブリであることを強調して特別に「アセンブルする」ということもあります。

読者の方々はアセンブリをいままでにどこかで見たことがあるかもしれません。もしアセンブリを見たことがなければ、今が見てみるよい機会です。 objdump コマンドを使って、適当な実行ファイルを逆アセンブルして、そのファイルの中に入っている機械語をアセンブリとして表示してみましょう。以下は ls コマンドを逆アセンブルしてみた結果です。

$ objdump -d -M intel /bin/ls /bin/ls: file format elf64-x86-64 Disassembly of section .init: 0000000000003d58 <_init@@Base>: 3d58: 48 83 ec 08 sub rsp,0x8 3d5c: 48 8b 05 7d b9 21 00 mov rax,QWORD PTR [rip+0x21b97d] 3d63: 48 85 c0 test rax,rax 3d66: 74 02 je 366a <_init@@Base+0x12> 3d68: ff d0 call rax 3d6a: 48 83 c4 08 add rsp,0x8 3d6e: c3 ret ...

筆者の環境では ls コマンドには2万個ほどの機械語命令が含まれているので、逆アセンブルした結果も2万行近い長大なものになります。ここでは最初のごく一部だけを掲載しました。

アセンブリでは、基本的に機械語1個につき1行という構成になっています。例として次の行に着目してみましょう。

3d58: 48 83 ec 08 sub rsp,0x8

この行の意味は何でしょうか？ 3d58というのは、機械語が入っているメモリのアドレスです。つまり、 ls コマンドが実行されるとき、この行の命令はメモリの0x3d58番地に置かれるようになっていて、プログラムカウンタが0x3d58のときにこの命令が実行されることになります。その次に続いている4つの16進数の数値は実際の機械語です。CPUはこのデータを読んで、それを命令として実行します。 sub rsp,0x8 というのは、その機械語命令に対応するアセンブリです。CPUの命令セットについては章を分けて説明しますが、この命令は、RSPというレジスタから8を引く（subtract = 引く）という命令です。

Cとそれに対応するアセンブラ

簡単な例

Cコンパイラがどのような出力を生成しているのかというイメージを掴むために、Cコードとそれに対応するアセンブリコードを比較してみましょう。最も簡単な例として次のCプログラムを考えてみます。

このプログラムが書かれているファイルを test1.c とすると、次のようにしてコンパイルして、 main が実際に42を返していることを確認することができます。

Cでは main 関数が返した値はプログラム全体としての終了コードになります。プログラムの終了コードは画面に表示されることはありませんが、暗黙のうちにシェルの $? という変数にセットされているので、コマンド終了直後に $? を echo で表示することで、そのコマンドの終了コードを見ることができます。ここでは正しく42が返されていることがわかります。

さて、このCプログラムに対応するアセンブリプログラムは次の通りです。

このアセンブリでは、グローバルなラベル main が定義されていて、ラベルのあとに main 関数のコードが続いています。ここでは42という値を、RAXというレジスタにセットし、 main からリターンしています。整数を入れられるレジスタはRAXを含めて合計で16個あるのですが、関数からリターンしたときにRAXに入っている値が関数の返り値という約束になっているので、ここでは値をRAXにセットしています。

このアセンブリプログラムを実際にアセンブルして動かしてみましょう。アセンブリファイルの拡張子は .s なので、上のアセンブリコードを test2.s に記述して、次のコマンドを実行してみてください。

$ cc -o test2 test2.s $ ./test2 $ echo $? 42

Cのときと同じように42が終了コードになりました。

大雑把にいうと、Cコンパイラは、 test1.c のようなCコードを読み込んだ時に、 test2.s のようなアセンブリを出力するプログラムということになります。

関数呼び出しを含む例

もう少し複雑な例として、関数呼び出しのあるコードがどのようなアセンブリに変換されるのかを見てみましょう。

関数呼び出しは単なるジャンプとは異なり、呼び出した関数が終了した後に、元々実行していた場所に戻ってこなければいけません。元々実行していたアドレスのことを「リターンアドレス」といいます。仮に関数呼び出しが1段しかなければ、リターンアドレスはCPUの適当なレジスタに保存しておけばよいのですが、関数呼び出しはいくらでも深くできるので、リターンアドレスはメモリに保存する必要があります。実際にはリターンアドレスはメモリ上のスタックに保存されます。

スタックは、スタックの一番上のアドレスを保持する1つの変数のみを使って実装することができます。そのスタックトップを保持している記憶領域のことを「スタックポインタ」といいます。x86-64は、関数を使ったプログラミングをサポートするために、スタックポインタ専用のレジスタと、そのレジスタを利用する命令をサポートしています。スタックにデータを積むことを「プッシュ」、スタックに積まれたデータを取り出すことを「ポップ」といいます。

さて、関数呼び出しの実例を見てみましょう。次のCコードを考えてみてください。

このCコードに対応するアセンブリは次のようになります。

1行目はアセンブリの文法を指定する命令です。2行目の .globl から始まる行は、 plus と main という2つの関数がファイルスコープではなくプログラム全体から見える関数だということをアセンブリに指示しています。これはさしあたり無視してかまいません。

まず main に着目してみてください。Cでは main から plus を引数つきで呼び出しています。アセンブラにおいては、第一引数はRDIレジスタ、第二引数はRSIレジスタに入れるという約束になっているので、 main の最初の2行でそのとおりに値をセットしています。

call というのは関数を呼び出す命令です。具体的に call は次のことを行います。

call の次の命令（この場合 ret ）のアドレスをスタックにプッシュ

の次の命令（この場合 ）のアドレスをスタックにプッシュ call の引数として与えられたアドレスにジャンプ

したがって call 命令が実行されると、CPUは plus 関数を実行し始めることになります。

plus 関数に着目してください。 plus 関数には3つの命令があります。

add は足し算を行う命令です。この場合には、RSIレジスタとRDIレジスタを足した結果がRSIレジスタに書き込まれます。x86-64の整数演算命令は通常2つのレジスタしか受け取らないので、第1引数のレジスタの値を上書きする形で結果が保存されることになります。

関数からの返り値はRAXに入れるということになってました。したがって足し算の結果はRAXに入れておきたいので、RSIからRAXに値をコピーする必要があります。ここでは mov 命令を使ってそれを行なっています。 mov はmoveの省略形ですが、実際にはデータを移動するわけではなく単にコピーする命令です。

plus 関数の最後では、 ret を呼んで関数からリターンしています。具体的に ret は次のことを行います。

スタックからアドレスを1つポップ

そのアドレスにジャンプ

つまり ret は、 call が行なったことを元に戻して、呼び出し元の関数の実行を再開する命令です。このように call と ret は対になる命令として定義されています。

plus からリターンしたところにあるのは main の ret 命令です。元のCコードでは plus の返り値をそのまま main から返すということになっていました。ここでは plus の返り値がRAXに入った状態になっているので、そのまま main からリターンすることで、それをそのまま main からの返り値にすることができます。

本章のまとめ

本章ではコンピュータが内部でどのように動いているのかということと、Cコンパイラが何をすればよいのかということについて、概要を説明しました。アセンブリや機械語を見ると、Cとはかけ離れた、ごちゃっとしたデータの塊のように見えますが、実際は意外とCの構造を素直に反映していると思った読者も多いのではないでしょうか。

まだ本書では具体的な機械語についてほとんど説明していないので、 objdump で表示されたアセンブリコードの個別の命令の意味はわからないと思いますが、1つ1つの命令は大したことをしていないということが想像できると思います。本章の段階ではそういう感覚が掴めるだけで十分です。

本章のポイントを箇条書きで下にまとめます。

CPUはメモリを読み書きすることでプログラムの実行を進めていく

CPUが実行するプログラムと、そのプログラムが扱うデータは、どちらもメモリに入っていて、CPUはメモリから順に機械語命令を読み、その命令を実行する

CPUにはレジスタという小さな記憶領域があり、多くの機械語はレジスタ間での操作として定義されている

アセンブリは機械語を人間にとって読みやすくした言語で、Cコンパイラは普通はアセンブリを出力する

Cの関数はアセンブリでも関数になる

関数呼び出しはスタックを使って実装されている

電卓レベルの言語の作成

この章では、Cコンパイラ作成の最初のステップとして、四則演算やそのほかの算術演算子をサポートして、次のような式をコンパイルできるようにします。

30 + (4 - 2) * -5

これは他愛もない目標のようですが、実は結構難しい目標です。数式には、カッコの中の式が優先されるとか、掛け算が足し算より優先されるといった構造があって、それを何らかの方法で理解しなければ正しく計算を行うことはできません。しかし、入力として与えられる数式はただのフラットな文字の列であって、構造化されたデータではありません。式を正しく評価するためには、文字の並びを解析して、そこに隠れた構造をうまく導き出す必要があります。

こういった構文解析の問題は、何の前提知識もなしに解こうとすると相当大変です。実際、こういった問題は昔は難しい問題だと考えられていて、特に1950年代から1970年代にかけて精力的に研究が行われて、いろいろなアルゴリズムが開発されてきました。その成果のおかげで、今では構文解析は、やり方さえ分かっていればさほど難しい問題ではなくなっています。

この章では、構文解析の最も一般的なアルゴリズムの一つである「再帰下降構文解析法」（recursive descent parsing）を説明します。GCCやClangなど、みなさんが日常的に使っているC/C++コンパイラも、再帰下降構文解析法を使っています。

コンパイラに限らず、何らかの構造のあるテキストを読むというニーズは、プログラミングをしているとよくでてきます。この章で学ぶテクニックはそういった問題にもそのまま使うことができます。この章で学ぶ構文解析の手法は、大げさではなく一生物のテクニックといってよいでしょう。この章を読んでアルゴリズムを理解して、自分のプログラマとしての道具箱に構文解析の技を入れておきましょう。

ステップ1：整数1個をコンパイルする言語の作成

最もシンプルなC言語のサブセットを考えてみてください。読者の皆さんはどういう言語を想像するでしょうか？ main 関数しかない言語でしょうか。あるいは式1つだけからなる言語でしょうか。突き詰めて考えると、整数1つだけからなる言語というものが、考えうる限り最も簡単なサブセットだといってよいと思います。

このステップではまずその最も簡単な言語を実装することにしましょう。

このステップで作成するプログラムは、1個の数を入力から読んで、その数をプログラムの終了コードとして終了するアセンブリを出力するコンパイラです。つまり入力は単に 42 のような文字列で、それを読むと次のようなアセンブリを出力するコンパイラを作成します。

.intel_syntax noprefix というのは、複数あるアセンブリの書き方のなかで、本書で使っているIntel記法という記法を選ぶためのアセンブラコマンドです。今回作成するコンパイラでは必ず冒頭にこの行をお約束として入れるようにしてください。それ以外の行は、前章で説明した通りです。

読者はここで、「こんなプログラムはコンパイラとは言えない」と思うかもしれません。筆者も正直そう思います。しかし、このプログラムは、数値1つからなる言語を入力として受け付けて、その数値に対応したコードを出力するというもので、それは定義から言うと立派なコンパイラです。このような簡単なプログラムも、改造していくとすぐにかなり難しいことができるようになるので、まずはこのステップを完了してみましょう。

実はこのステップは、開発全体の手順からみてみるととても重要です。このステップで作るものをスケルトンとして使って今後開発を進めていくからです。このステップでは、コンパイラ本体の作成に加えて、ビルドファイル（Makefile）、自動テストの作成、gitリポジトリのセットアップも行います。それらの作業について1つ1つ見ていきましょう。

なお、本書で作るCコンパイラは9ccという名前です。ccというのはC compilerの略称です。9という数字に特に意味はないのですが、筆者の以前につくったCコンパイラが8ccという名前なので、それの次の作品ということで9ccという名前にしました。もちろんみなさんは好きな名前をつけてもらってかまいません。ただし、事前に名前を考えすぎてコンパイラ作成が始められないということはないようにしましょう。GitHubのリポジトリも含め、名前は後から変えられるので、適当な名前で始めて問題ありません。

Intel記法とAT&T記法 本書で使用しているIntel記法の他に、AT&T記法というアセンブラの記法もUnixを中心に広く使われています。gccやobjdumpはデフォルトではAT&T記法でアセンブリを出力します。 AT&T記法では結果レジスタが第2引数に来ます。したがって2引数の命令では引数を逆順に書くことになります。レジスタ名には % プレフィックスをつけて %rax というように書きます。数値には $ プレフィックスをつけて $42 というように記述します。 また、メモリを参照する場合、 [] の代わりに () を使って、独特の記法で式を記述します。以下にいくつか対比のために例を示します。 mov rbp, rsp // Intel mov %rsp, %rbp // AT&T mov rax, 8 // Intel mov $8, %rax // AT&T mov [rbp + rcx * 4 - 8], rax // Intel mov %rax, -8(rbp, rcx, 4) // AT&T 今回作るコンパイラでは読みやすさを考慮してIntel記法を使うことにしました。Intelの命令セットマニュアルではIntel記法が使われているので、マニュアルの記述をそのままコードに書けるという利点もあります。表現力はAT&T記法もIntel記法も同じです。どちらの記法を使っても、生成される機械語命令列は同一です。

コンパイラ本体の作成

コンパイラには通常はファイルとして入力を与えますが、ここではファイルをオープンして読むのが面倒なので、コマンドの第1引数に直接コードを与えることにします。第1引数を数値として読み込んで、定型文のアセンブリの中に埋め込むCプログラムは、次のように簡単に書くことができます。

9cc という空のディレクトリを作って、その中に 9cc.c というファイルを上記の内容で作成します。そのあと下のように9ccを実行して動作を確認してみましょう。

$ cc -o 9cc 9cc.c $ ./9cc 123 > tmp.s

1行目で 9cc.c をコンパイルして 9cc という実行ファイルを作成しています。2行目では 123 という入力を9ccに渡してアセンブリを生成し、それを tmp.s というファイルに書き込んでいます。 tmp.s の内容を確認してみましょう。

$ cat tmp.s .intel_syntax noprefix .globl main main: mov rax, 123 ret

見ての通りうまく生成されていますね。こうしてできたアセンブリファイルをアセンブラに渡すと実行ファイルを作成することができます。

Unixにおいては cc （あるいは gcc ）は、CやC++だけではなく多くの言語のフロントエンドということになっていて、与えられたファイルの拡張子で言語を判定してコンパイラやアセンブラを起動するということになっています。したがってここでは9ccをコンパイルしたときと同じように、 .s という拡張子のアセンブラファイルを cc に渡すと、アセンブルをすることができます。以下はアセンブルを行い、生成された実行ファイルを実行してみた例です。

$ cc -o tmp tmp.s $ ./tmp $ echo $? 123

シェルでは直前のコードの終了コードが $? という変数でアクセスできるのでした。上の例では、9ccに与えた引数と同じ123という数字が表示されています。つまりうまく動いているということです。0〜255の範囲の123以外の数を与えてみて（Unixのプロセス終了コードは0〜255ということになっています）、実際に9ccがうまく動くことを確認してみてください。

自動テストの作成

趣味のプログラミングでテストを書いたことがない読者も多いと思いますが、本書ではコンパイラを拡張するたびに、新しいコードをテストするコードを書くことにします。テストを書くのは最初は面倒に感じるかもしれませんが、すぐにテストのありがたみがわかるようになるはずです。テストコードを書かなかった場合、結局は同じようなテストを手で毎回実行して動作確認をするしかないわけですが、手でやるほうがずっと面倒です。

テストを書くのが面倒だという印象の多くの部分は、テストフレームワークが大げさであったり、テストの思想が時に教条的であるところからきていると思います。例えばJUnitのようなテストのフレームワークはいろいろな便利な機能を持っていますが、導入するのにも使い方を覚えるのにも手間がかかります。したがってこの章ではそういったテストフレームワークを導入することはしません。その代わりに、手書きのとても簡単な「テストフレームワーク」をシェルスクリプトで書いて、それを使ってテストを書くことにします。

以下にテスト用のシェルスクリプト test.sh を示します。シェル関数 assert は、入力の値と、期待される出力の値という2つの引数を受け取って、実際に9ccの結果をアセンブルし、実際の結果を期待されている値と比較するということを行います。シェルスクリプトでは、 assert 関数を定義した後に、それを使って0と42がどちらも正しくコンパイルできることを確認しています。

上記の内容で test.sh を作成し、 chmod a+x test.sh を実行して実行可能にしてください。実際に test.sh を走らせてみましょう。何もエラーが起きなければ、以下のように test.sh は最後に OK を表示して終了します。

$ ./test.sh 0 => 0 42 => 42 OK

もしエラーが起きれば、 test.sh は OK を表示しません。その代わりに test.sh は、失敗したテストで想定されていた値と実際の値を以下のように表示します。

$ ./test.sh 0 => 0 42 expected, but got 123

テストスクリプトをデバグしたいときは、bashに -x というオプションを与えてスクリプトを実行してください。 -x オプションをつけると、bashは以下のように実行のトレースを表示します。

$ bash -x test.sh + assert 0 0 + expected=0 + input=0 + cc -o 9cc 9cc.c + ./9cc 0 + cc -o tmp tmp.s + ./tmp + actual=0 + '[' 0 '!=' 0 ']' + assert 42 42 + expected=42 + input=42 + cc -o 9cc 9cc.c + ./9cc 42 + cc -o tmp tmp.s + ./tmp + actual=42 + '[' 42 '!=' 42 ']' + echo OK OK

我々が本書を通して使う「テストフレームワーク」は、単なる上記のようなシェルスクリプトです。このスクリプトはJUnitなどの本格的なテストフレームワークとくらべて簡単すぎるように見えるかもしれませんが、このシェルスクリプトの簡単さは、9cc自身の簡単さとバランスが取れているので、これくらい簡単なほうが望ましいのです。自動テストというものは、要は自分の書いたコードを一発で動かして結果を機械的に比較できればよいだけなので、難しく考えすぎず、まずはテストを行うことが大切なのです。

makeによるビルド

本書を通して読者のみなさんは9ccを何百回、あるいは何千回もビルドすることになるでしょう。9ccの実行ファイルを作成して、その後にテストスクリプトを走らせる作業は毎回同じなので、ツールに任せると便利です。こうした用途で標準的に使われているのが make コマンドです。

makeは、実行されるとカレントディレクトリの Makefile という名前のファイルを読み込んで、そこに書かれているコマンドを実行します。 Makefile は、コロンで終わるルールと、そのルールのためのコマンドの列という構成になっています。次の Makefile はこのステップで実行したいコマンドを自動化するためのものです。

上記のファイルを、 9cc.c があるのと同じディレクトリに Makefile というファイル名で作成してください。そうすると、 make を実行するだけで9ccが作成され、 make test を実行するとテストを実行する、ということができるようになります。makeはファイルの依存関係を理解できるので、 9cc.c を変更した後、 make test を実行する前に、 make を実行する必要はありません。9ccという実行ファイルが9cc.cより古い場合に限り、makeは、テストを実行するより前に9ccをビルドしてくれます。

make clean というのはテンポラリなファイルを消すルールです。テンポラリファイルは手で rm してもよいのですが、消したくないファイルを誤って消してしまうと面倒なので、こういったユーティリティ的なものも Makefile に書くことにしています。

なお、 Makefile を記述する際の注意点ですが、 Makefile のインデントはタブ文字でなければいけません。スペース4個や8個ではエラーになります。これは単に使い勝手の悪い文法なだけなのですが、makeは1970年代に開発された古いツールで、伝統的にこうなってしまっています。

cc には必ず -static というオプションを渡すようにしてください。このオプションはダイナミックリンクという章で説明します。このオプションの意味について今は特に考える必要はありません。

gitによるバージョン管理

本書ではバージョン管理システムとしてgitを使います。本書を通してコンパイラをステップ・バイ・ステップで作っていくわけですが、そのステップごとに、gitのコミットを作って、コミットメッセージを書くようにしてください。コミットメッセージは日本語で構わないので、実際に何を変更したのかを1行サマリーとしてまとめるようにしてください。1行以上の詳細な説明を書きたいときは、最初の行の次に1行空行を開けて、そのあとに説明を書くようにします。

gitでバージョン管理を行うのはみなさんが手で生成したファイルだけです。9ccを動かした結果として生成されるファイルなどは、同じコマンドを実行すればもう一度生成できるので、バージョン管理対象には入れる必要はありません。むしろ、こういったファイルを入れてしまうとコミットごとの変更点が不必要に長くなるので、バージョン管理から外して、リポジトリに入れないようにする必要があります。

gitでは .gitignore というファイルに、バージョン管理から外すファイルのパターンを書くことができます。 9cc.c があるのと同じディレクトリに、以下の内容で .gitignore を作成して、テンポラリファイルやエディタのバックアップファイルなどをgitが無視するように設定しておきましょう。

*~ *.o tmp* a.out 9cc

gitを使うのが初めてという人は、gitに名前とメールアドレスを教えておきましょう。ここでgitに教えた名前とメールアドレスがコミットログに記録されます。下は筆者の名前とメールアドレスを設定する例です。読者の皆さんは自分の名前とメールアドレスを設定してください。

$ git config --global user.name "Rui Ueyama" $ git config --global user.email "ruiu@cs.stanford.edu"

gitでコミットを作るためには、まず変更があったファイルを git add で追加する必要があります。今回は初回のコミットなので、まず git init でgitリポジトリを作成し、その後に、ここまでで作成したすべてのファイルを git add で追加します。

$ git init Initialized empty Git repository in /home/ruiu/9cc $ git add 9cc.c test.sh Makefile .gitignore

そのあと git commit でコミットします。

$ git commit -m "整数1つをコンパイルするコンパイラを作成"

-m オプションでコミットメッセージを指定します。 -m オプションがない場合、 git はエディタを起動します。コミットがうまくいったことは以下のように git log -p を実行すると確認することができます。

$ git log -p commit 0942e68a98a048503eadfee46add3b8b9c7ae8b1 (HEAD -> master) Author: Rui Ueyama <ruiu@cs.stanford.edu> Date: Sat Aug 4 23:12:31 2018 +0000 整数1つをコンパイルするコンパイラを作成 diff --git a/9cc.c b/9cc.c new file mode 100644 index 0000000..e6e4599 --- /dev/null +++ b/9cc.c @@ -0,0 +1,16 @@ +#include <stdio.h> +#include <stdlib.h> + +int main(int argc, char **argv) { + if (argc != 2) { ...

最後に、ここまでで作成したgitリポジトリをGitHubにアップロードしておきましょう。特にGitHubにアップロードする積極的な理由はないのですが、アップロードしない理由もないですし、GitHubはコードのバックアップとしても役に立ちます。GitHubにアップロードするためには、新規のリポジトリを作って（この例では rui314 というユーザを使って 9cc というリポジトリを作成しました）、次のコマンドでそのリポジトリをリモートリポジトリとして追加します。

$ git remote add origin git@github.com:rui314/9cc.git

その後、 git push を実行すると、手元のリポジトリの内容がGitHubにプッシュされます。 git push を実行した後、GitHubをブラウザで開いて、自分のソースコードがアップロードされていることを確認してみてください。

これで第1ステップのコンパイラの作成は完了です。このステップのコンパイラは、コンパイラと呼ぶには簡単すぎるようなプログラムですが、コンパイラに必要な要素をすべて含んだ立派なプログラムです。これから我々はこのコンパイラをひたすら機能拡張していって、まだ信じられないかもしれませんが、立派なCコンパイラに育て上げることになります。まずは最初のステップが完成したことを味わってください。

「よいデザイン」のコンパイラをお手本にするリスク コンパイラの世界にはGCCやLLVMといった定評のあるオープンソースのプログラムが存在します。そういったプログラムは、優れたクオリティのコードを効率的に出力することのできる「よいデザイン」に基づいて作られていると考えられています。自作コンパイラを作るときに、そういったコンパイラの内部構成をお手本にしようというのは 自然な考え方でしょう。 そういった既存の大規模なコンパイラをお手本に自作コンパイラを作ろうとすると、まずLLVMのような汎用的な中間言語をデザインして、その中間言語を扱うフロントエンドとバックエンドを作成することになります。また、コンパイラの最適化をよく勉強している人であれば、SSAといった中間形式やSSAを前提にした最適化パスなども加えようとするでしょう。いろいろなベストプラクティスを組み合わせた「僕の考えた最強のコンパイラ」を構想してみるのは楽しいものです。 しかし、大規模コンパイラと同じような「よいデザイン」の構想を先に完成させて、それから実装に取り掛かるという開発スタイルは、実際にはほとんどうまくいきません。 大規模なコンパイラは、最初から抽象化されたよいデザインに基づいて作られたわけではありません。最初はアドホックなところがたくさんあった状態から、だんだん進化を遂げていまの形になっているだけです。完成形だけをみてそれを真似するというのは

コンピュータの記憶階層と経済性 プログラムを書いていると、ストレージについて、あらゆるところにサイズと速度のトレードオフがあることに気がつくと思います。レジスタは数百バイトしかないストレージですが、ディレイなしでCPUの内部からアクセスできます。DRAMから構成されるメインメモリでは、CPUからのアクセスに100クロック以上かかりますが、数GiBというサイズを確保することができます。レジスタとDRAMの間にはCPUのチップ内に実装されたキャッシュがあり、L1、L2、L3という階層ごとに、小さくて速いメモリから、比較的大きくて遅いメモリまでが用意されています。 このようなストレージの速度と容量のトレードオフはCPUやメインメモリに限りません。SSDはDRAMより大容量で1000倍くらい遅いストレージです。HDDはSSDより大きくてより遅いストレージです。DropboxやGoogle Driveのようなインターネットのストレージは、HDDよりもずっと大きくなることができて、そしてアクセスにはさらに時間がかかります。 なぜストレージには、速いものは容量が小さくて、遅いものは容量が大きいという一般的な法則があるのでしょうか？ 一つの理由は、ストレージの種類によっては、容量と速度がトレードオフの関係にあることです。例えばレジスタは増やせば増やすほど良さそうですが、レジスタを増やすと回路規模が増大して、他の機能につかえるシリコンが減ってしまします。また、レジスタの数を増やした分だけ命令セットのレジスタを指定するビットの幅も増やさなければならないので、命令が長くなり、命令キャッシュの利用効率が悪くなります。レジスタを増やすことによる速度向上はある程度以上ではほとんど効果がなくなるので、多ければよいというものでもありません。 SSDやHDDのような外部ストレージについては、実際に、速くて容量が大きいストレージや、遅くて容量が小さいストレージというものは存在します。ただし、速くて大容量のストレージが登場すると、それより劣るテクノロジは市場から駆逐されてしまいますし、同様に遅くて小容量のストレージは作る意味がないので、市場に出回っているテクノロジには、小容量で速いものと、大容量で遅いものしか存在していないのです。コンピュータの博物館にいくと、コアメモリや水銀遅延菅といった、現在主流のメモリ技術と比べると小容量で低速なメモリの実物を見ることができます。 最も速くて最も大容量の不揮発性のストレージがあれば、それが究極の単一のメモリ技術になり得ますが、残念ながらそういったデバイスは今のところ存在しません。すべての評価基準において最高の性能特性を持つメモリ技術が開発されない限り、デコボコの性能特性を持ったストレージシステムをうまく階層化してコンピュータを構成していくのは、技術の選択としては自然な成り行きなのです。

ステップ2：加減算のできるコンパイラの作成

このステップでは、前のステップで作成したコンパイラを拡張して、 42 といった値だけではなく、 2+11 や 5+20-4 のような加減算を含む式を受け取れるようにします。

5+20-4 のような式は、コンパイルするときに計算して、その結果の数（この場合 21 ）をアセンブリに埋め込むこともできますが、それだとコンパイラではなくインタープリタのようになってしまうので、加減算を実行時に行うアセンブリを出力する必要があります。加算と減算を行うアセンブリ命令は add と sub です。 add は、2つのレジスタを受け取って、その内容を加算し、結果を第1引数のレジスタに書き込みます。 sub は add と同じですが、減算を行います。これらの命令を使うと、 5+20-4 は次のようにコンパイルすることができます。

上記のアセンブリでは、 mov でRAXに5をセットし、そのあとRAXに20を足して、そして4を引いています。 ret が実行される時点でのRAXの値は 5+20-4 すなわち21になるはずです。実行して確認してみましょう。上記のファイルを tmp.s に保存してアセンブルし、実行してみます。

$ cc -o tmp tmp.s $ ./tmp $ echo $? 21

上記のように正しく21が表示されました。

さて、このアセンブリファイルはどのように作成すればいいのでしょうか？ この加減算のある式を「言語」として考えてみると、この言語は次のように定義することができます。

最初に数字が1つある

そのあとに0個以上の「項」が続いている

項というのは、 + の後に数字が来ているものか、 - の後に数字が来ているものである

この定義を素直にCのコードに落としてみると、次のようなプログラムになります。

ちょっと長いプログラムになっていますが、前半部分と ret の行は以前と同じです。中間に項を読み込むためのコードが足されています。今回は数字1つを読むだけのプログラムではないので、数字を読み込んだあとに、どこまで読み込んだのかがわからないといけません。 atoi では読み込んだ文字の文字数は返してくれないので、 atoi では次の項をどこから読めばよいのかわからなくなってしまいます。したがってここでは、C標準ライブラリの strtol 関数を使いました。

strtol は数値を読み込んだ後、第2引数のポインタをアップデートして、読み込んだ最後の文字の次の文字を指すように値を更新します。したがって、数値を1つ読み込んだ後、もしその次の文字が + や - ならば、 p はその文字を指しているはずです。上のプログラムではその事実を利用して、 while ループの中で次々と項を読んで、1つ項を読むたびにアセンブリを1行出力するということを行なっています。

さて、さっそくこの改造版コンパイラを実行してみましょう。 9cc.c ファイルを更新したら、 make を実行するだけで新しい9ccファイルを作ることができるのでした。実行例を以下に示します。

$ make $ ./9cc '5+20-4' .intel_syntax noprefix .globl main main: mov rax, 5 add rax, 20 sub rax, 4 ret

どうやらうまくアセンブリが出力されているようですね。この新しい機能をテストするために、 test.sh に次のようにテストを1行追加しておきましょう。

assert 21 "5+20-4"

ここまでできたら、ここまでの変更点をgitにコミットしておきましょう。そのためには以下のコマンドを実行します。

$ git add test.sh 9cc.c $ git commit

git commit を実行するとエディタが起動するので「足し算と引き算を追加」と書いて保存し、エディタを終了します。 git log -p コマンドを使ってコミットが期待した通りに行われていることを確認してみてください。最後に git push を実行してGitHubにコミットをプッシュしたら、このステップは完了です！

ステップ3：トークナイザを導入

前のステップで作成したコンパイラには1つ欠点があります。もし入力に空白文字が含まれていたら、その時点でエラーになってしまうのです。例えば以下のように 5 - 3 という空白の入った文字列を与えると、 + あるいは - を読もうとしているところで空白文字を見つけることになり、コンパイルに失敗してしまいます。

$ ./9cc '5 - 3' > tmp.s 予期しない文字です: ' '

この問題を解決する方法はいくつかあります。1つの自明な方法は、 + や - を読もうとする前に空白文字を読み飛ばすことです。このやり方には特に問題があるというわけはないのですが、このステップでは別の方法で問題を解決することにします。その方法というのは、式を読む前に入力を単語に分割してしまうという方法です。

日本語や英語と同じように、算数の式やプログラミング言語も、単語の列から成り立っていると考えることができます。例えば 5+20-4 は 5 、 + 、 20 、 - 、 4 という5つの単語でできていると考えることができます。この「単語」のことを「トークン」（token）といいます。トークンの間にある空白文字というのは、トークンを区切るために存在しているだけで、単語を構成する一部分ではありません。したがって、文字列をトークン列に分割するときに空白文字を取り除くのは自然なことでしょう。文字列をトークン列に分割することを「トークナイズする」といいます。

文字列をトークン列に分けることには他のメリットもあります。式をトークンに分けるときにそのトークンを分類して型をつけることができるのです。例えば + や - は、見ての通りの + や - といった記号ですし、一方で 123 という文字列は123という数値を意味しています。トークナイズするときに、入力を単なる文字列に分割するだけではなく、その1つ1つのトークンを解釈することで、トークン列を消費するときに考えなければならないことが減るのです。

現在の加減算ができる式の文法の場合、トークンの型は、 + 、 - 、数値の3つです。さらにコンパイラの実装の都合上、トークン列の終わりを表す特殊な型を1つ定義しておくとプログラムが簡潔になります（文字列が '\0' で終わっているのと同じです）。トークンはポインタで繋いだ連結リストになるようにして、任意の長さの入力を扱えるようにしてみましょう。

やや長くなりますが、トークナイザを導入して改良したバージョンのコンパイラを下に掲載します。

150行程度のあまり短いとはいえないコードですが、あまりトリッキーことは行なっていないので、上から読んでいけば読めるはずです。

上のコードで使われているプログラミングテクニックをいくつか説明しておきましょう。

パーサが読み込むトークン列は、グローバル変数 token で表現することにしました。パーサは、連結リストになっている token をたどっていくことで入力を読み進めていきます。このようなグローバル変数を使うプログラミングスタイルは、きれいなスタイルには見えないかもしれません。しかし実際には、ここで行なっているように、入力トークン列を標準入力のようなストリームとして扱うほうがパーサのコードが読みやすくなることが多いようです。従ってここではそのようなスタイルを採用しました。

で表現することにしました。パーサは、連結リストになっている をたどっていくことで入力を読み進めていきます。このようなグローバル変数を使うプログラミングスタイルは、きれいなスタイルには見えないかもしれません。しかし実際には、ここで行なっているように、入力トークン列を標準入力のようなストリームとして扱うほうがパーサのコードが読みやすくなることが多いようです。従ってここではそのようなスタイルを採用しました。 token を直接触るコードは consume や expect といった関数にわけて、それ以外の関数では token を直接触らないようにしました。

を直接触るコードは や といった関数にわけて、それ以外の関数では を直接触らないようにしました。 tokenize 関数では連結リストを構築しています。連結リストを構築するときは、ダミーの head 要素を作ってそこに新しい要素を繋げていって、最後に head->next を返すようにするとコードが簡単になります。このような方法では head 要素に割り当てられたメモリはほとんど無駄になりますが、ローカル変数をアロケートするコストはほぼゼロなので、特に気にする必要はありません。

関数では連結リストを構築しています。連結リストを構築するときは、ダミーの 要素を作ってそこに新しい要素を繋げていって、最後に を返すようにするとコードが簡単になります。このような方法では 要素に割り当てられたメモリはほとんど無駄になりますが、ローカル変数をアロケートするコストはほぼゼロなので、特に気にする必要はありません。 calloc は malloc と同じようにメモリを割り当てる関数です。 malloc とは異なり、 calloc は割り当てられたメモリをゼロクリアします。ここでは要素をゼロクリアする手間を省くために calloc を使うことにしました。

この改良版では空白文字がスキップできるようになったはずなので、次のようなテストを1行 test.sh に追加しておきましょう。

assert 41 " 12 + 34 - 5 "

Unixのプロセスの終了コードは0〜255の数字ということになっているので、テストを書く際には、式全体の結果が0〜255に収まるようにしてください。

テストファイルをgitレポジトリに追加すれば、このステップは完了です。

ステップ4：エラーメッセージを改良

ここまでに作ったコンパイラでは、入力が文法的に間違っていた場合、どこかにエラーがあったことくらいしかわかりません。その問題をこのステップで改良してみましょう。具体的には下のような直感的にわかるエラーメッセージを表示できるようにします。

$ ./9cc "1+3++" > tmp.s 1+3++ ^ 数ではありません $ ./9cc "1 + foo + 5" > tmp.s 1 + foo + 5 ^ トークナイズできません

このようなエラーメッセージを表示するためには、エラーが起きたときに、それが入力の何バイト目なのかを知ることができる必要があります。そのために、プログラムの文字列全体を user_input という変数に保存することにして、その文字列の途中を指すポインタを受け取るエラー表示関数を新たに定義することにしましょう。そのコードを以下に示します。

error_at が受け取るポインタは、入力全体を表す文字列の途中を指しているポインタです。そのポインタと、入力の先頭を指しているポインタとの差を取ると、エラーのある場所が入力の何バイト目かわかるので、その場所を目立つように ^ でマークすることができます。

argv[1] を user_input に保存するようにして、 error("数ではありません") といったコードを error_at(token->str, "数ではありません") といったコードにアップデートすれば、このステップは完了です。

実用レベルのコンパイラであれば、入力にエラーがあるときの振る舞いについてもテストを書くべきですが、今のところエラーメッセージはデバグを助けるために出力しているだけなので、この段階では特にテストは書かなくて構いません。

ソースコードのフォーマッタ 日本語でも句読点など正書法のレベルで誤りの多い文章が読むに耐えないのと同じように、ソースコードも、インデントがおかしかったり空白の有無などが一貫していなかったりすると、ソースコードの中身以前のレベルできれいなコードとは言えません。コードのフォーマッティングといったいわばどうでもいい部分では、機械的に一定のルールを適用して、気が散らずに読めるコードを書くように気をつけてください。 複数人で開発するときにはどういったフォーマットにするか相談して決めなければいけませんが、この本では一人で開発しているので、ある程度メジャーなフォーマットのなかから自分で好きなフォーマットを選んで構いません。 最近開発された言語では、どういうフォーマットを選ぶかという、好みはわかれるけど本質的ではない議論の必要性そのものをなくすために、言語公式のフォーマッタを提供しているものがあります。たとえばGo言語ではgofmtというコマンドがあり、それを使うとソースコードをきれいに整形してくれます。gofmtはフォーマットのスタイルを選ぶためのオプションがなく、いわば唯一の「Go公式のフォーマット」にしか整形することができません。あえて選択肢を与えないことにより、フォーマットをどうするかという問題をGoは完全に解決しているわけです。 CやC++ではclang-formatというフォーマッタがありますが、本書では特にこういったツールを使うことを推奨したいわけではありません。フォーマットのおかしなコードを書いて後から整形するのではなく、最初から一貫した見た目のコードを書くように気をつけてみてください。

文法の記述方法と再帰下降構文解析

さて、次は乗除算や優先順位のカッコ、すなわち * 、 / 、 () を言語に追加したいのですが、それをするためには1つ大きな技術的チャレンジがあります。掛け算や割り算は式の中で最初に計算しなければいけないというルールがあるからです。例えば 1+2*3 という式は 1+(2*3) というように解釈しなければいけないのであって、 (1+2)*3 というように解釈してはいけません。こういった、どの演算子が最初に「くっつく」のかというルールを「演算子の優先順位」（operator precedence）といいます。

演算子の優先順位はどのように処理すればよいのでしょうか？ ここまで作ってきたコンパイラでは、先頭からトークン列を読んでアセンブリを出力していくだけなので、素直にそのまま拡張して * と / を追加すると、 1+2*3 を (1+2)*3 としてコンパイルすることになってしまいます。

既存のコンパイラは当然、演算子の優先順位をうまく扱えています。コンパイラの構文解析は非常に強力で、どのような複雑なコードでも、文法にそっている限りは正しく解釈することができます。このコンパイラの振る舞いには人間を超える知的な能力すら感じることがありますが、実際には、コンピュータには人間のような文章読解能力はないので、構文解析はなんらかの機械的メカニズムのみによって行われているはずです。具体的にはどういう仕組みで動いているのでしょうか？

この章では、コーディングは一休みにして、構文解析のテクニックについて学んでいきましょう。この章では、構文解析のテクニックについて次の順番で説明をします。

パーサの出力のデータ構造を知ることで、最終的なゴールをまず把握する 文法規則を定義するルールを学ぶ 文法規則を定義するルールをもとに、パーサを書くテクニックを学ぶ

木構造による文法構造の表現

プログラミング言語のパーサの実装においては、入力はフラットなトークンの列で、出力は入れ子構造を表す木にするのが普通です。本書で作成するコンパイラもその構成に従っています。

C言語では if や while といった文法的な要素を入れ子にすることができます。こういったものを木構造で表すというのは自然な表現方法といってよいでしょう。

数式には、カッコの中を先に計算するとか、乗除算を加減算より先に計算するといった構造があります。こういった構造は、一見、木には見えないかもしれませんが、実際には木を使うと大変シンプルに式の構造を表すことができます。たとえば 1*(2+3) という式は、次の木により表現されていると考えることができます。

1*(2+3) を表す木

木の末端から順に計算していくというルールを採用した場合、上記の木は、1に2+3をかける、という式を表していることになります。つまり、上記の木では、 1*(2+3) の具体的な計算順序が木の形そのもので表現されていることになります。

別の例を示します。下の木は 7-3-3 を表す木です。

7-3-3 を表す木

上記の木においては、「引き算は左から順に計算しなければいけない」というルールの適用結果が、木の形として明示的に表されています。つまり、上記の木は (7-3)-3 = 1 という式を表しているのであって、 7-(3-3) = 7 という式を表しているわけではありません。もし後者の式であったなら、それを表す木は左ではなく右に深くなるようになります。

左から計算しなければならない演算子のことを「左結合」の演算子、右から計算しなければならない演算子のことを「右結合」の演算子といいます。Cでは、代入の = を除いて、ほとんどの演算子は左結合として定義されています。

木構造においては、木を深くすることでいくらでも長い式を表すことができます。次の木は、 1*2+3*4*5 を表す木です。

1*2+3*4*5 を表す木

上記のような木のことを「構文木」（こうぶんぎ、syntax tree）といいます。特に、グループ化のためのカッコなどの冗長な要素を木の中に残さずになるべくコンパクトに表現した構文木のことを「抽象構文木」（abstract syntax tree、AST）といいます。上記の構文木は、どれも抽象構文木ということができます。

抽象構文木はコンパイラの内部表現なので実装の都合で適当に定義してかまいません。とはいえ、足し算や掛け算のような算術演算子は、左辺と右辺の2つに対する演算として定義されているので、どのコンパイラでも2分木にするのが自然でしょう。一方、関数本体の式など、順番に実行されるだけで何個にでもなりうるものは、すべての子要素をフラットに持つ木で表すのが自然でしょう。

構文解析におけるゴールは抽象構文木を構築することです。コンパイラは、まず構文解析を行って入力のトークン列を抽象構文木に変換し、その構文木を次はアセンブリに変換することになります。

生成規則による文法の定義

さて、次はプログラミング言語の構文の記述方法について学んでいきましょう。プログラミング言語の構文の大部分は「生成規則」（production rule）というものを使って定義されています。生成規則は文法を再帰的に定義するルールです。

自然言語について少し考えてみましょう。日本語において文法は入れ子構造になっています。例えば「花がきれいだ」という文の「花」という名詞を「赤い花」という名詞句に置き換えても正しい文になりますし、「赤い」というのを「少し赤い」というようにさらに展開してもやはり正しい文になっています。「少し赤い花がきれいだと私は思った」というように別の文章の中に入れることもできます。

こういった文法を、「『文』とは『主語』と『述語』からなる」とか「『名詞句』は『名詞』か、あるいは『形容詞』の後に『名詞句』が続くものからなる」といったようなルールとして定義されているものと考えてみましょう。そうすると「文」を出発点にして、ルールに従って展開していくことで、定義された文法における妥当な文というものを無数に作り出すことができます。

あるいは逆に、すでに存在している文について、それにマッチする展開手順を考えることで、その文字列がどのような構造を持っているのかどうかを考えることもできます。

元々上記のようなアイデアは自然言語のために考案されたのですが、コンピュータで扱うデータとの親和性がとても高いため、生成規則はプログラミング言語を始めとしてコンピュータの様々なところで利用されています。

チョムスキーの生成文法 生成文法というアイデアを考え付いたのは、ノーム・チョムスキーという言語学者です。彼のアイデアは言語学やコンピュータサイエンスに非常に大きな影響を与えました。 チョムスキーの仮説によると、ヒトが言葉を話せる理由は、ヒトには生まれつき、生成規則を獲得するための専用の回路が脳に存在しているからだとされています。人間には再帰的な言語のルールの獲得能力があるために、言語を話せるようになるというわけです。ヒト以外の動物には言語獲得能力はありませんが、彼は、それは生成規則を獲得するための回路がヒト以外の動物の脳に存在しないためだと考えました。チョムスキーの主張は、仮説が発表されてから60年近くがたったいまでも立証も反証もされていませんが、現在でもかなり説得力があるものと考えられています。

BNFによる生成規則の記述

生成規則をコンパクトかつわかりやすく記述するための一つの記法として、BNF（Backus–Naur form）と、それを拡張したEBNF（Extended BNF）というものがあります。この本では、Cの文法をEBNFを使って説明していきます。この節では、まずBNFを説明し、その後にEBNFの拡張部分を説明します。

BNFでは、一つ一つの生成規則を A = α₁α₂⋯ という形式で表します。これは記号 A を α₁α₂⋯ に展開できるという意味です。 α₁α₂⋯ は0個以上の記号の列で、それ以上展開できない記号と、さらに展開される（いずれかの生成規則で左辺に来ている）記号の両方を含むことができます。

それ以上展開できない記号を「終端記号」（terminal symbol）、どれかの生成規則の左辺に来ていて展開できる記号を「非終端記号」（nonterminal symbol）といいます。このような生成規則で定義される文法のことを一般に「文脈自由文法」（context free grammar）といいます。

非終端記号は複数の生成規則にマッチしてかまいません。例えば A = α₁ と A = α₂ の両方の規則があった場合、 A は α₁ か α₂ のどちらに展開してもよい、という意味になります。

生成規則の右辺は、空でもかまいません。そのようなルールでは、左辺の記号は長さ0の記号列に（つまり無に）展開されることになります。ただし、表示上、右辺を省略すると意味がわかりづらくなるので、そのような場合には何もないことを表す記号としてε（イプシロン）を右辺に書いておくというのが普通のBNFのルールです。本書でもそのルールを採用しています。

文字列はダブルクオートでくくって "foo" のように書きます。文字列は常に終端記号です。

上記が基本的なBNFのルールです。EBNFでは、BNFのルールに加えて、以下の記号を使って複雑なルールを簡潔に書き下すことができます。

書き方 意味 A* A の0回以上の繰り返し A? A またはε A | B A または B ( ... ) グループ化

例えば A = ("fizz" | "buzz")* では、 A は、 "fizz" または "buzz" が0回以上繰り返された文字列、すなわち、

""

"fizz"

"buzz"

"fizzfizz"

"fizzbuzz"

"buzzfizz"

"buzzbuzz"

"fizzfizzfizz"

"fizzfizzbuzz"

⋯⋯

のいずれかに展開することができます。

BNFとEBNF Extendedではない普通のBNFには、 * 、 ? 、 | 、 ( ... ) といった簡潔な記法が存在していませんが、BNFで生成可能な文とEBNFで生成可能な文は同じです。なぜなら、以下のように書き換えることで、EBNFをBNFに変換することができるからです。 EBNF 対応するBNF A = α* A = αA と A = ε A = α? A = α と A = ε A = α | β A = α と A = β A = α (β₁β₂⋯) γ A = α B γ と B = β₁β₂⋯ 例えば A = αA と A = ε という生成規則を使って A から ααα という文を生成するときには、 A → αA → ααA → αααA → ααα という展開の順序になります。 このように、 * や ? といった記法は単なるショートカットにすぎませんが、とはいえ短い書き方の方がわかりやすくて望ましいので、短い記法を使える場合は普通はその記法を使って簡潔に記述することが普通です。

単純な生成規則

EBNFを使った文法の記述の例として、次の生成規則を考えてみてください。

num は別途どこかで数値を表す記号として定義されているものとします。この文法においては、 expr は、まず num が1つあって、その後に0個以上の「 + と num 、あるいは - と num 」があるものということになります。この規則は、実は加減算の式の文法を表しています。

exprから出発して展開していくと、任意の加減算の文字列、例えば 1 や 10+5 や 42-30+2 のような文字列を作り出すことができます。以下の展開結果を確認してみてください。

このような展開の手順を、矢印を使って展開順ごとに表すだけではなく、木構造で表すこともできます。上の式の構文木を以下に示します。

1 の構文木

10+5 の構文木

42-30+2 の構文木

木構造で表すことによって、どの非終端記号がどの記号に展開されているのかがわかりやすくなりました。

上の図のような、入力に含まれるすべてのトークンを含んだ、文法に完全に一対一でマッチしている構文木は、「具象構文木」（concrete syntax tree）と呼ばれることもあります。この用語は、抽象構文木と対比させたいときによく使われます。

なお、上記の具象構文木では、加減算を左から計算するというルールが木の形では表現されていません。そのようなルールは、ここで説明する文法では、EBNFを使って表現するのではなく、言語仕様書の中に文章で但し書きとして「加減算は左から先に計算します」と書いておくことになります。パーサではEBNFと但し書きの両方を考慮に入れて、式を表すトークン列を読み込んで、式の評価順を適切に表現している抽象構文木を構築することになります。

従って、上記の文法では、EBNFが表す具象構文木とパーサの出力となる抽象構文木の形が、おおまかにしか一致しません。抽象構文木と具象構文木がなるべく同じ構造になるように文法を定義することも可能ですが、そうなると文法が冗長になって、パーサをどう書けばよいのかわかりづらくなってしまいます。上記のような文法は、形式的な文法の記述の厳密さと、自然言語による補足のわかりやすさのバランスが取れた、扱いやすい文法の表現方法です。

生成規則による演算子の優先順位の表現

生成規則は文法を表現するための大変強力なツールです。演算子の優先順位も、文法を工夫すると、生成規則の中で表すことができます。その文法を以下に示します。

以前のルールでは expr が直接 num に展開されていたのですが、今回は expr は mul を経由して num に展開されるルールになりました。 mul というのが乗除算の生成規則で、加減算を行う expr は、 mul をいわば一つの部品として使っています。この文法では乗除算が先にくっつくというルールが構文木の中で自然と表現されることになります。具体的にいくつか例を見てみましょう。

1*2+3 の構文木

1+2*3 の構文木

1*2+3*4*5 の構文木

上の木構造では、足し算より掛け算が常に木の末端方向に現れるようになっています。実際のところ、 mul から expr に戻るルールがないので、掛け算の下に足し算がある木は作りようがないのですが、そうはいってもこのような単純なルールで優先順位が木構造としてうまく表現できるのはかなり不思議に感じます。読者の皆さんも実際に生成規則と構文木を付き合わせて、構文木が正しいことを確認してみてください。

再帰を含む生成規則

生成文法では再帰的な文法も普通に書くことができます。下は、優先順位のカッコを四則演算に追加した文法の生成規則です。

上記の文法を以前の文法と比べてみると、今まで num が許されていたところに、 primary 、すなわち num あるいは "(" expr ")" が来てよいことになっています。つまりこの新しい文法では、丸カッコでくくられた式というものは、いままでの単一の数と同じ「くっつき具合」で扱われることになります。一つ例を見てみましょう。

次の木は 1*2 の構文木です。

1*2 の構文木

次の木は 1*(2+3) の構文木です。

1*(2+3) の構文木

2つの木を比べてみると、 mul の右の枝の primary の展開結果だけが異なることがわかります。展開結果の末端に現れる primary というのは、1つの数字に展開してもよいし、カッコでくくられた任意の式に展開してもよい、というルールが、木構造の中にきちんと反映されています。このように簡単な生成規則でカッコの優先順位も扱えるというのは少し感動的ではないでしょうか。

再帰下降構文解析

C言語の生成規則が与えられれば、それをどんどん展開していくことで、生成規則の観点からみて正しい任意のCプログラムを機械的に生成することができます。しかし9ccにおいて我々が行いたいことは、むしろ逆のことです。外部から文字列としてCプログラムが与えられていて、展開すると入力の文字列になる展開手順、すなわち入力と同じ文字列になる構文木の構造を知りたいのです。

実はある種の生成規則については、規則が与えられれば、その規則から生成される文にマッチする構文木を求めるコードを機械的に書いていくことができます。ここで説明する「再帰下降構文解析法」はそういったテクニックの一つです。

例として四則演算の文法を考えてみましょう。四則演算の文法を再掲します。

再帰下降構文解析法でパーサを書くときの基本的な戦略は、これらの非終端記号一つ一つをそのまま関数一つ一つにマップするというものです。したがってパーサは expr 、 mul 、 primary という3つの関数を持つことになります。それぞれの関数は、その名前のとおりのトークン列をパースします。

具体的にコードで考えてみましょう。パーサに渡される入力はトークンの列です。パーサからは抽象構文木を作って返したいので、抽象構文木のノードの型を定義しておきましょう。ノードの型を以下に示します。

lhs と rhs いうのはそれぞれleft-hand sideとright-hand side、すなわち左辺と右辺という意味です。

新しいノードを作成する関数も定義しておきます。この文法における四則演算では、左辺と右辺を受け取る2項演算子と、数値の2種類があるので、その2種類に合わせて関数を2つ用意します。

さて、これらの関数とデータ型を使ってパーサを書いていきましょう。 + や - は左結合の演算子ということになっています。左結合の演算子をパーズする関数は、パターンとして次のように書きます。

consume というのは以前のステップで定義した関数で、入力ストリームの次のトークンが引数とマッチするときに、入力を1トークン読み進めて真を返す関数です。

expr 関数をよく読んでみてください。 expr = mul ("+" mul | "-" mul)* という生成規則が、そのまま関数呼び出しとループにマップされていることがわかると思います。上記の expr 関数から返される抽象構文木では、演算子は左結合、つまり返されるノードの左側の枝のほうが深くなるようになっています。

expr 関数が使っている mul 関数も定義してみましょう。 * や / も左結合の演算子なので、同じパターンで記述することができます。その関数を下に示します。

上記のコードの関数呼び出し関係は、 mul = primary ("*" primary | "/" primary)* という生成規則にそのまま対応しています。

最後に primary 関数を定義してみましょう。 primary が読み込むのは左結合の演算子ではないので、上記のパターンのコードにはなりませんが、 primary = "(" expr ")" | num という生成規則をそのまま関数呼び出しに対応させることで、 primary 関数は以下のように記述することができます。

さて、これで全ての関数が揃ったわけですが、これで本当にトークン列をパースできるのでしょうか？ 一見よくわからないかもしれませんが、この関数群を使うときちんとトークン列をパースできます。例として 1+2*3 という式を考えてみましょう。

最初に呼ばれるのは expr です。式というのは全体として expr であると決めつけて（この場合、実際にそうなわけですが）入力を読み始めるわけです。そうすると、 expr → mul → primary というように関数呼び出しが行われて、 1 というトークンが読み込まれ、 expr には、返り値として1を表す構文木が返ってきます。

次に、 expr の中の consume('+') という式が真になるので、 + というトークンが消費され、 mul が再度呼び出されます。この段階での入力の残りは 2*3 です。

mul からは前回と同様に primary が呼び出されて、 2 というトークンが読み込まれますが、今回は mul はすぐにはリターンしません。 mul の中の consume('*') という式が真になるので、 mul は再度 primary を呼び出して、 3 というトークンを読み込みます。結果として mul からは 2*3 を表す構文木が返ることになります。

リターンした先の expr では、1を表す構文木と 2*3 を表す構文木が組み合わされて、 1+2*3 を表す構文木が構築され、それが expr の返り値になります。つまり正しく 1+2*3 がパースできたというわけです。

関数の呼び出し関係とそれぞれの関数が読み込むトークンを図に示すと次のようになります。下の図では、 1+2*3 全体に対応した expr の層がありますが、これが入力全体を読み込む expr の呼び出しを表しています。 expr の上に2つの mul がありますが、それらは 1 と 2*3 を読み込む別の mul の呼び出しを表しています。

1+2*3 をパースしている時の関数の呼び出し関係

もう少し複雑な例を下に示します。下の図は、 1*2+(3+4) をパースしているときの関数の呼び出し関係を表しています。

1*2+(3+4) をパースしている時の関数の呼び出し関係

再帰に慣れていないプログラマの場合、上のような再帰的な関数はわかりづらく感じるかもしれません。正直、再帰には非常に慣れているはずの筆者ですら、こういったコードが動くのは一種のマジックのように感じます。再帰的なコードは、仕組みがわかっていてもどこか不思議な感じがするのですが、それはおそらくそういうものなのでしょう。何度もよく頭の中でコードをトレースしてみて、きちんとコードが動作することを確認してみてください。

上記のような1つの生成規則を1つの関数にマップするという構文解析の手法を「再帰下降構文解析」といいます。上記のパーサではトークンを1つだけ先読みして、どの関数を呼び出すか、あるいはリターンするか、ということを決めていましたが、そのようにトークンを1つだけ先読みする再帰下降パーサのことをLL(1)パーサといいます。また、LL(1)パーサが書ける文法のことをLL(1)文法といいます。

スタックマシン

前章ではトークン列を抽象構文木に変換するアルゴリズムについて説明しました。演算子の優先順位を考慮した文法を選ぶことによって、 * や / が、 + や - に比べて、常に枝の先の方に来ている抽象構文木を作ることができるようになったわけですが、この木をどのようにアセンブリに変換すればよいのでしょうか？ この章ではその方法を説明します。

まずは、なぜ加減算と同じ方法ではアセンブリに変換できないのかを考えてみましょう。加減算のできるコンパイラでは、RAXを結果のレジスタとして、そこに加算や減算を行っていました。つまりコンパイルされたプログラムでは中間的な計算結果を1つだけ保持していました。

しかし、乗除算が含まれる場合は中間的な計算結果が1つだけになるとは限りません。例として2*3+4*5を考えてみてください。足し算を行うためには両辺が計算済みでなければいけないので、足し算の前に2*3と、4*5を計算する必要があります。つまりこの場合は途中の計算結果を2つ保持できなければ全体の計算ができないのです。

こういったものの計算が簡単に行えるのが「スタックマシン」というコンピュータです。ここではいったんパーサの作った抽象構文木から離れて、スタックマシンについて学んでみましょう。

スタックマシンの概念

スタックマシンは、スタックをデータ保存領域として持っているコンピュータのことです。したがってスタックマシンでは「スタックにプッシュする」と「スタックからポップする」という2つの操作が基本操作になります。プッシュでは、スタックの一番上に新しい要素が積まれます。ポップでは、スタックの一番上から要素が取り除かれます。

スタックマシンにおける演算命令は、スタックトップの要素に作用します。例えばスタックマシンの ADD 命令は、スタックトップから2つ要素をポップしてきて、それらを加算し、その結果をスタックにプッシュします（x86-64命令との混同を避けるために、仮想スタックマシンの命令はすべて大文字で表記することにします）。別の言い方をすると、 ADD は、スタックトップの2つの要素を、それらを足した結果の1つの要素で置き換える命令です。

SUB 、 MUL 、 DIV 命令は、 ADD と同じように、スタックトップの2つの要素を、それらを減算、乗算、除算した1つの要素で置き換える命令ということになります。

PUSH 命令は引数の要素をスタックトップに積むものとします。ここでは使用しませんが、スタックトップから要素を1つ取り除いて捨てる POP という命令も考えることができます。

さて、これらの命令を使って、2*3+4*5を計算することを考えてみましょう。上のように定義したスタックマシンを使うと、次のようなコードで2*3+4*5を計算することができるはずです。

// 2*3を計算 PUSH 2 PUSH 3 MUL // 4*5を計算 PUSH 4 PUSH 5 MUL // 2*3 + 4*5を計算 ADD

このコードについて少し詳しくみていきましょう。スタックにはあらかじめ何らかの値が入っているものとします。ここではその値は重要ではないので、「⋯」で表示します。スタックは図において上から下に伸びるものとします。

最初の2つの PUSH が2と3をスタックにプッシュするので、その直後の MUL が実行される時点ではスタックの状態は次のようになっています。

⋯ 2 3

MUL はスタックトップの2つの値、すなわち3と2を取り除いて、それを掛けた結果、つまり6をスタックにプッシュします。したがって MUL の実行後にはスタックの状態は次のようになります。

⋯ 6

次に PUSH が4と5をプッシュするので、2番目の MUL が実行される直前にはスタックは次のようになっているはずです。

⋯ 6 4 5

ここで MUL を実行すると、5と4が取り除かれて、それを掛けた結果の20に置き換えられます。したがって MUL の実行後には次のようになります。

⋯ 6 20

2*3と4*5の計算結果がうまくスタックに入っていることに着目してください。この状態で ADD を実行すると、20+6が計算され、その結果がスタックにプッシュされるので、最終的にスタックは次の状態になるはずです。

⋯ 26

スタックマシンの計算結果はスタックトップに残っている値ということにすると、26は2*3+4*5の結果ですから、きちんとその式が計算できたことになるわけです。

スタックマシンではこの式に限らず、複数の途中結果を持つどのような式でも計算することができます。スタックマシンを使うと、どのような部分式も、それを実行した結果として1つの要素をスタックに結果として残すという約束を守っている限り、上記の方法でうまくコンパイルできるのです。

CISCとRISC x86-64は、1978年に発売された8086から漸進的に発展してきた命令セットで、典型的な「CISC」（シスク）と呼ばれるスタイルのプロセッサです。CISCプロセッサの特徴は、機械語の演算がレジスタだけではなくメモリアドレスを取ることが可能であるということ、機械語命令の長さが可変長であること、アセンブリプログラマにとって便利な複雑な操作を1命令で行う命令を多く備えていること、などがあります。 CISCに対して1980年代に発明されたのが「RISC」（リスク）です。RISCプロセッサの特徴は、演算は必ずレジスタ間でのみ行い、メモリに対する操作はレジスタへのロードとレジスタからのストアだけであること、機械語命令の長さがどの命令でも同じことであること、アセンブリプログラマにとって便利な複合命令を持っておらず、コンパイラが生成する簡単な命令のみを備えていること、などがあります。 x86-64はCISCの数少ない生き残りの一つで、x86-64以外の主要なプロセッサはほぼ全てRISCをベースにしています。具体的にはARM、PowerPC、SPARC、MIPS、RISC-V（リスク・ファイブ）などはすべてRISCプロセッサです。 RISCには、x86-64のようなメモリとレジスタ間の演算はありません。レジスタのエイリアスもありません。特定の整数レジスタが特定の命令で特別な使われ方をする、といったルールもありません。そういう命令セットが主流になっている現代の目から見ると、x86-64の命令セットは古めかしいものに見えます。 RISCプロセッサはその単純なデザインゆえに高速化しやすく、プロセッサ業界を席捲しました。ではなぜx86-64は生き残りに成功したのでしょうか？ そこには既存のソフトウェア資産を活かせる高速なx86プロセッサを求める市場の巨大なニーズと、それに応えようとしたIntelやIntel互換チップメーカーの技術革新がありました。Intelは、CPUの命令デコーダでx86命令を内部的にある種のRISC命令に変換して、x86を内部的にRISCプロセッサ化しました。それによりRISCが高速化に成功したのと同じテクニックをx86に適用することが可能になったのです。

スタックマシンへのコンパイル

この節では、抽象構文木をスタックマシンのコードに変換する方法について説明します。それができるようになれば、四則演算からなる式をパースして抽象構文木を組み立て、それをx86-64命令を使ったスタックマシンにコンパイルして実行することができるようになります。つまり四則演算のできるコンパイラが書けるようになるというわけです。

スタックマシンでは、部分式を計算すると、それが何であれその結果の1つの値がスタックトップに残るということになっていました。例えば下のような木を考えてください。

加算を表す抽象構文木

A や B というのは部分木を抽象化して表したもので、実際にはなんらかの型のノードを意味しています。しかしその具体的な型や木の形は、この木全体をコンパイルするときには重要ではありません。この木をコンパイルするときは次のようにすればよいのです。

左の部分木をコンパイルする 右の部分木をコンパイルする スタックの2つの値を、それらを加算した結果で置き換えるコードを出力

1のコードを実行した後には、その具体的なコードが何であれ、左の部分木の結果を表す1つの値がスタックトップに置かれているはずです。同様に、2のコードを実行した後には、右の部分木の結果を表す1つの値がスタックトップに置かれているはずです。したがって、木全体の値を計算するためには、その2つの値を、その合計値で置き換えればよいというわけです。

このように、抽象構文木をスタックマシンにコンパイルするときは、再帰的に考えて、木を下りながらどんどんアセンブリを出力していくことになります。再帰の考え方に慣れていない読者にとってはやや難しく思えるかもしれませんが、木のような自己相似形のデータ構造を扱う時には再帰は定番のテクニックです。

以下の例で具体的に考えてみましょう。

加算と乗算を表す抽象構文木

コード生成を行う関数は木のルートのノードを受け取ります。

上記の手順に従うと、その関数がまず行うのは左の部分木をコンパイルすることです。つまり数値の2をコンパイルすることになります。2を計算した結果はそのまま2なので、その部分木のコンパイル結果は PUSH 2 です。

次にコード生成関数は右の部分木をコンパイルしようとします。そうすると再帰的に部分木の左側をコンパイルすることになり、結果として PUSH 3 が出力されます。次は部分木の右側をコンパイルすることになり、 PUSH 4 が出力されます。

そのあとコード生成関数は再帰呼び出しを元に戻りながら、部分木の演算子の型に合わせたコードを出力していきます。最初に出力されるのは、スタックトップの2つの要素を、それらを掛けたもので置き換えるコードです。その次にスタックトップの2つの要素を、それらを足したもので置き換えるコードが出力されます。結果として下のアセンブリが出力されることになります。

PUSH 2 PUSH 3 PUSH 4 MUL ADD

このような手法を使うと、抽象構文木を機械的にアセンブリに落としていけるのです。

x86-64におけるスタックマシンの実現方法

ここまでは仮想的なスタックマシンの話でした。実際のx86-64はスタックマシンではなくレジスタマシンです。x86-64の演算は通常2つのレジスタ間に対して定義されており、スタックトップの2つの値に対して動作するように定義されているわけではありません。したがって、スタックマシンのテクニックをx86-64で使うためには、レジスタマシンでスタックマシンをある意味でエミュレートする必要があります。

レジスタマシンでスタックマシンをエミュレートするのは比較的簡単です。スタックマシンで1命令になっているものを複数の命令を使って実装すればよいのです。

そのための具体的な手法を説明しましょう。

まずスタックの先頭の要素を指すレジスタを1つ用意しておきます。そのレジスタのことをスタックポインタといいます。スタックトップの2つの値をポップしてきたいのであれば、スタックポインタの指す要素を2つ取り出して、スタックポインタを取り出した要素のぶんだけ変更しておきます。同じように、プッシュするときは、スタックポインタの値を変更しつつそれが指しているメモリ領域に書き込めばよいというわけです。

x86-64のRSPレジスタはスタックポインタとして使うことを念頭に置いて設計されています。x86-64の push や pop といった命令は、暗黙のうちにRSPをスタックポインタとして使って、その値を変更しつつ、RSPが指しているメモリにアクセスする命令です。したがって、x86-64命令セットをスタックマシンのように使うときは、RSPをスタックポインタとして使うのが素直です。では早速、 1+2 という式を、x86-64をスタックマシンと見立ててコンパイルしてみましょう。以下にx86-64のアセンブリを示します。

// 左辺と右辺をプッシュ push 1 push 2 // 左辺と右辺をRAXとRDIにポップして足す pop rdi pop rax add rax, rdi // 足した結果をスタックにプッシュ push rax

x86-64には「RSPが指している2つの要素を足す」という命令はないので、いったんレジスタにロードして加算を行い、その結果をスタックにプッシュし直す必要があります。上記の add 命令で行っているのはそういう操作です。

同様に 2*3+4*5 をx86-64で実装してみると次のようになります。

// 2*3を計算して結果をスタックにプッシュ push 2 push 3 pop rdi pop rax mul rax, rdi push rax // 4*5を計算して結果をスタックにプッシュ push 4 push 5 pop rdi pop rax mul rax, rdi push rax // スタックトップの2つの値を足す // つまり2*3+4*5を計算する pop rdi pop rax add rax, rdi push rax

このように、x86-64のスタック操作命令を使うと、x86-64であっても、かなりスタックマシンに近いコードを動かすことができます。

次の gen 関数はこの手法をそのままCの関数で実装したものです。

特にパースやコード生成において重要なポイントではないのですが、トリッキーな仕様の idiv 命令が上のコードでは使われているので、それについて説明しておきましょう。

idiv は符号あり除算を行う命令です。x86-64の idiv が素直な仕様になっていれば、上のコードでは本来 idiv rax, rdi のように書きたかったところですが、そのような2つのレジスタをとる除算命令はx86-64には存在しません。その代わりに、 idiv は暗黙のうちにRDXとRAXを取って、それを合わせたものを128ビット整数とみなして、それを引数のレジスタの64ビットの値で割り、商をRAXに、余りをRDXにセットする、という仕様になっています。 cqo 命令を使うと、RAXに入っている64ビットの値を128ビットに伸ばしてRDXとRAXにセットすることができるので、上記のコードでは idiv を呼ぶ前に cqo を呼んでいます。

さて、これでスタックマシンの説明は終わりです。ここまで読み進めたことによって、読者のみなさんは複雑な構文解析と、その構文解析の結果得られた抽象構文木をマシンコードに落とすことができるようになったはずです。その知識を活用するために、コンパイラ作成の作業に戻ってみましょう！

掛け算や割り算のサイズ 一般にn桁の数を2つ掛けると、結果を表すためには2n桁必要になります。例えば3桁の10進数を掛けたときの最大の値を考えてみると、999×999=998001というように6桁の数になります。乗算をハードウェアで普通に実装すると、実際に引数のレジスタの2倍の桁数の結果が生成されます。x86-64ではその計算結果を無駄にすることなく、上位桁をRDXに、下位桁をRAXに保存することにしています。 割り算を普通に実装すると、割られる数は割る数の2倍の桁数が必要です。上位桁を0で埋めて計算を始めても構いませんが、x86-64ではRDXを使って上位桁の値を指定できるようになっています。また、割り算を行うと必然的に余りも同時に計算できてしまうのですが、それはRDXに保存されるという仕様になっています。 多くのRISCプロセッサは上記のx86-64のような機能は実装していません。RISCでは、掛け算では下位桁の結果だけがレジスタに保存され、割り算では上位桁は暗黙のうちに0で初期化される、といった仕様になっていることが多いようです。 そのような命令セットとx86-64を比較してみると、せっかく計算した値を無駄にしないx86-64の仕様の方が優れているように思えますが、実際には特にそういうわけでもありません。例えば掛け算においてアセンブリレベルでは常に2倍幅の結果が計算されているとはいっても、Cやそのほかの言語では結果の上位桁にアクセスする方法が特に規定されていないので、使いようがないというのが実際のところです。 また、多くのRISCプロセッサでは掛け算の上位桁と下位桁を計算する命令を別々に持っていて、2つの命令を使って2倍幅の結果を計算することが可能になっています。それらの命令は、連続して実行されるとき、ハードウェアが特別にそのパターンを認識して、内部的に1つの命令として実行するという最適化が行われていることがよくあります。したがって現代のプロセッサにおいては、RISCで大きな桁の数字を扱いたい時も、x86-64に比べて特に不利ということはありません。 x86-64の命令セットが現在の形になっている理由は歴史的事情によるところが多いのですが、掛け算や割り算もその一つの例です。

最適化コンパイラ この章で筆者が説明に使ったx86-64のアセンブリはかなり非効率的に見えるかもしれません。例えばスタックに数値を push してそれを pop する命令は、直接レジスタにその値を mov する命令で書けば1命令で済むはずです。読者の中には、そういったアセンブリから冗長さを取り除いて最適化したいという気持ちが湧き上がってきている人もいることでしょう。しかし、その誘惑には負けないようにしてください。一番最初のコード生成では、コンパイラの実装の容易さを優先して冗長なコードを出力するのは、望ましいことなのです。 9ccには必要ならば後から最適化パスを付け足すことができます。生成されたアセンブリを再度スキャンして、特定のパターンで現れている命令列を別の命令列で置き換えることは難しくありません。例えば「 push 直後の pop は mov に置き換える」とか「連続している add が、即値（そくち）を同じレジスタに足している場合、その即値を合計した値を足す1つの add に置き換える」といったルールを作って、それを機械的に適用すれば、冗長なコードを、意味を変えることなくより効率的なコードに置き換えることができます。 コード生成と最適化を混ぜてしまうとコンパイラが複雑になってしまいます。最初から難しいコードになってしまうと、後から最適化パスを足すのはむしろ困難です。Donald Knuthが言っていたように「早すぎる最適化は全ての悪の元凶」なのです。読者の皆さんが作成するコンパイラでも、実装の簡単さだけを考慮するようにしてください。出力に含まれる明白な冗長さは後から取り除けるので心配する必要はありません。

ステップ5：四則演算のできる言語の作成

この章では、前章までに作ってきたコンパイラを変更して、優先順位のカッコを含む四則演算の式を扱えるように拡張します。必要なパーツは揃っているので、新たに書くコードはほんのわずかです。コンパイラの main 関数を変更して、新しく作成したパーサとコードジェネレータを使うようにしてみてください。下のようなコードになるはずです。

この段階まで進んだことで、加減乗除と優先順位のカッコからなる式が正しくコンパイルできるようになっているはずです。いくつかテストを追加しておきましょう。

assert 47 '5+6*7' assert 15 '5*(9-6)' assert 4 '(3+5)/2'

なお、ここまでは説明の都合上、一気に * 、 / 、 () を実装しているような話の流れになっていますが、実際には一気に実装することは避ける方がよいでしょう。元々、加減算ができる機能があったわけですから、まずはその機能を壊さずに、抽象構文木とそれを使ったコードジェネレータを導入するようにしてみてください。そのときには新たな機能を足すわけではないので、新しいテストは必要ありません。その後に、 * 、 / 、 () を、テスト込みで実装していってください。

9ccにおけるメモリ管理 読者は本書をここまで読んだところで、このコンパイラにおけるメモリ管理がどうなっているのか不思議に思っているかもしれません。ここまでに出てきたコードでは、（mallocの亜種の）callocは使っていますが、freeは呼んでいません。つまりアロケートしたメモリは解放されません。これは、いくらなんでも手抜きではないでしょうか？ 実際にはこの「メモリ管理を行わないことをメモリ管理ポリシーとする」という設計は、いろいろなトレードオフを考慮した上で、筆者が意図的に選択したデザインです。 このデザインの利点として、メモリを解放しないことによって、まるでガベージコレクタがある言語のようにコードを書けるという点があります。これにより、メモリ管理を行うコードを書かなくてよくなるだけではなく、手動メモリ管理にまつわる不可解なバグを根本から断つことができます。 一方、freeをしないことによって発生する問題というのは、普通のPCのようなコンピュータで動かすことを考えると、実質的にあまり存在しません。コンパイラは1つのCファイルを読み込んでアセンブリを出力するだけの短命なプログラムです。プログラム終了時に確保されているメモリはOSによってすべて自動的に解放されます。したがって、トータルでどれくらいメモリを割り当てるかということだけが問題になるわけですが、筆者の実測ではかなり大きなCファイルをコンパイルしたときでもメモリ使用量は100MiB程度にすぎません。したがってfreeしないというのは現実的に有効な戦略なのです。例えばD言語のコンパイラDMDも、同じ考えから、mallocだけを行いfreeはしないというポリシーを採用しています。

ステップ6：単項プラスと単項マイナス

引き算を行う - 演算子は、 5-3 のように2つの項の間に書くことだけではなく、 -3 のように単独の項の前に書くことができます。同様に + 演算子も左辺を省略して +3 のように書くことができます。このような1つの項だけを取る演算子のことを「単項演算子」（unary operator）といいます。それに対して、2つの項をとる演算子は「2項演算子」（binary operator）といいます。

Cには + と - 以外に、ポインタを取得する & やポインタをデリファレンスする * などの単項演算子が存在しますが、このステップでは + と - だけを実装することにします。

単項 + と単項 - は、2項の + や - と同じ記号ですが、定義は異なっています。2項の - は左辺から右辺を引く演算として定義されていますが、単項 - にはそもそも左辺がないので、2項 - の定義はそのままでは意味をなしません。Cでは単項 - は右辺の正負を反転する演算として定義されています。単項 + は右辺をそのまま返す演算子です。これは特になくても構わない演算子なのですが、単項 - が存在するついでに存在しています。

+ や - は、単項と2項という、似て異なる定義の同名の演算子が複数存在していると考えるのが適切です。単項か2項かというのは文脈で見分けることになります。単項 + / - を含んだ新しい文法は次のようになります。

上記の新しい文法では unary という新しい非終端記号が増えていて、 mul が primary ではなく unary を使うようになっています。 X? は、オプショナルな、すなわち X が0回か1回出現する要素を表すEBNFの構文です。 unary = ("+" | "-")? primary というルールでは、 unary という非終端記号は、 + か - が1つあってもなくてもよくて、そのあとに primary が続いているものを表しています。

-3 や -(3+5) 、 -3*+5 などの式がこの新たな文法にマッチしていることを確認してみてください。以下に -3*+5 の構文木を示します。

-3*+5の構文木

この新しい文法に従うようにパーサを変更してみましょう。例によって、文法をそのまま関数呼び出しにマップすることでパーサの変更は完了するはずです。 unary をパースする関数を下に示します。

ここではパースの段階で +x を x に、 -x を 0-x に置き換えてしまうことにしました。したがってこのステップではコード生成器の変更は必要ありません。

テストを数個書いて、単項 + / - を追加するコードと一緒にチェックインすれば、このステップは完了です。テストを書く時には、テスト結果を0〜255の範囲に収めるようにしましょう。 -10+20 のような式は、単項 - を使いつつも全体の値は正の数になっているので、こういったテストを使ってください。

単項プラスと文法の良し悪し 単項 + 演算子はオリジナルのCコンパイラには存在しておらず、1989年にANSI（アメリカ国家規格協会）でCが標準化されたときに、公式に言語に追加されました。単項 - がある以上、単項 + もあったほうが対称性が高くてその意味でよいのは確かですが、実際のところは単項 + は特に使い道がありません。 一方で単項 + を文法に追加したことによる副作用もあります。Cに慣れていない人が += 演算子を誤って i =+ 3 のように書いてしまったとしましょう。単項 + がなければこれは単なる不正な式ですが、単項 + があるためにこれは i = +3 と書いたのと同じように解釈されて、 i に3を代入する正当な代入式としてコンパイラに黙って受け付けられてしまいます。 ANSIのC言語標準化委員会は、上記の問題を理解した上で単項 + を言語に追加するという判断を下したわけですが、読者の皆さんはどう思いますか？ あなたがそのときC標準化委員会に属していたら、賛成しますか？ 反対しますか？

ステップ7: 比較演算子

この節では、 < 、 <= 、 > 、 >= 、 == 、 != を実装します。これらの比較演算子は特殊な意味を持っているように見えますが、実際には + や - などと同じように、2つの整数を受け取って1つの整数を返す普通の2項演算子です。 + が両辺を足した結果を返すように、例えば == は両辺が同じ場合は1を、違う場合は0を返します。

トークナイザの変更

今までに扱ってきた記号トークンは長さがどれも1文字で、コードでもそれを前提にしてきましたが、 == などの比較演算子を扱うためにはコードを一般化する必要があります。文字列の長さをトークンに保存できるように、 len というメンバを Token 構造体に保存することにしましょう。新しい構造体の型を下に示します。

この変更に伴って、 consume や expect といった関数にも変更を加えて、それらの関数が文字ではなく文字列を取るように改良する必要があります。変更を加えた例を以下に示します。

複数の文字からなる記号をトークナイズする場合、長いトークンから先にトークナイズする必要があります。たとえば残りの文字列が > から始まっている場合、まず strncmp(p, ">=", 2) のように >= である可能性から先にチェックしないで、 > から始まっている可能性をチェックしてしまうと、 >= が > と = という2つのトークンとして誤ってトークナイズされてしまいます。

新しい文法

比較演算子のサポートをパーサに追加するために、比較演算子を加えた文法がどのようになるのかを考えてみましょう。今までに出てきた演算子を優先順位の低い順から高い順に書くと次のようになります。

== != < <= > >= + - * / 単項 + 単項 - ()

優先順位は生成文法で表現可能で、優先順位の異なる演算子は別の非終端記号にマップされるのでした。 expr や mul と同様に文法を考えてみると、比較演算子を加えた新しい文法は以下のようになります。

equality は == と != を、 relational は < 、 <= 、 > 、 >= を表しています。これらの非終端記号は、左結合の演算子をパースするパターンを使ってそのまま関数にマップすることができます。

なお、上記の文法では、式全体が equality であるということを表すために、 expr と equality を分離しました。 expr の右辺に equality の右辺を直接書いてもよかったのですが、おそらく上記の文法の方が見やすいと思います。

単純で冗長なコードと、高度で簡潔なコード 再帰下降構文解析では生成規則にほぼそのまま対応したコードを書くことになるので、同じような規則をパースする関数は、同じような見た目になります。ここまでに書いた relational 、 equality 、 add 、 mul も、同じような見た目の関数になっているはずです。 そういった関数に共通するパターンを、CのマクロやC++のテンプレート、高階関数やコード生成などのメタプログラミングのテクニックを使ってうまく抽象化できないかと考えることは、おそらく自然な発想でしょう。実際、そのようなことを行うことは可能です。しかし本書では、あえてそういったことを行なっていません。その理由は以下の通りです。 単純なコードは、やや冗長であっても理解するのは簡単です。似たような関数に同じような変更を後で加えることになったとしても、実際のところそれは大した手間ではありません。一方、高度に抽象化されたコードは、その抽象化メカニズムをまず理解し、それをどう使っているのかを次に理解する必要があるので、難解になりがちです。例えば、メタプログラミングを使って再帰下降構文解析の関数を生成する関数を書くところから本書の解説を始めていたら、この本はもっと難しい本になってしまっていたでしょう。 技巧を凝らした簡潔なコードを書くことを常に目指す必要はありません。そういうことを目指していると、それ以上難しくできないところまでコードを難しくしてしまいがちです。 コードを書いている本人はそのコードのエキスパートになるので、エキスパート目線から見た簡潔で無駄のないコードを良いコードだと感じがちですが、大半のコードの読者は筆者と同じ感覚は共有しておらず、そもそもそこまで習熟する必要性もないので、コードの筆者としての自分の感覚はある程度疑ってかかる必要があります。「もっといい書き方がありそうな単純なコード」を必要に応じてあえて書くというのは、理解しやすくメンテナンスしやすいプログラムを作るための一つの重要なテクニックです。

アセンブリコードの生成

x86-64では、比較はcmp命令を使って行います。スタックから2つの整数をポップして比較を行い、同一の場合に1、そうでなければ0をRAXにセットするコードは次のようになります。

このコードは、短いアセンブリながらやや盛りだくさんなので、ステップバイステップでコードを見ていきましょう。

最初の2行では値をスタックからポップしています。3行目では、それらのポップしてきた値を比較（compare）しています。比較結果はどこにいくのでしょうか？ x86-64では、比較命令の結果は特別な「フラグレジスタ」というものにセットされます。フラグレジスタは整数演算や比較演算命令が実行されるたびに更新されるレジスタで、結果が0かどうかといったビットや、桁あふれが発生したかどうかというビット、結果が0未満かどうかといったビットなどを持っています。

フラグレジスタは通常の整数レジスタではないので、RAXに比較結果をセットしたい場合、フラグレジスタの特定のビットをRAXにコピーしてくる必要があります。それを行うのが sete 命令です。 sete 命令は、直前の cmp 命令で調べた2つのレジスタの値が同じだった場合に、指定されたレジスタ（ここではAL）に1をセットします。それ以外の場合は0をセットします。

ALというのは本書のここまでに登場していない新しいレジスタ名ですが、実はALはRAXの下位8ビットを指す別名レジスタにすぎません。従って sete がALに値をセットすると、自動的にRAXも更新されることになります。ただし、RAXをAL経由で更新するときに上位56ビットは元の値のままになるので、RAX全体を0か1にセットしたい場合、上位56ビットはゼロクリアする必要があります。それを行うのが movzb 命令です。 sete 命令が直接RAXに書き込めればよいのですが、 sete は8ビットレジスタしか引数に取れない仕様になっているので、比較命令では、このように2つの命令を使ってRAXに値をセットすることになります。

sete の代わりに別の命令を使うことで、その他の比較演算子を実装することができます。 < では setl 、 <= では setle 、 != では setne を使うようにしてください。

> と >= はコードジェネレータでサポートする必要はありません。パーサで両辺を入れ替えて < や <= として読み換えるようにしてください。

フラグレジスタとハードウェア 値の比較結果が、普通の整数レジスタと異なる特別なレジスタに暗黙のうちに保存されるというこれはx86-64の仕様は、最初はわかりにくく感じるかもしれません。実際、RISCプロセッサでは、フラグレジスタを持つのを嫌って、値の比較結果を普通のレジスタにセットするという命令セットを持っているものがあります。たとえばRISC-Vはそのような命令セットです。 しかし、ハードウェアを実装する立場からすると、素朴な実装であれば、フラグレジスタを作るのはとても簡単です。つまり、整数演算を行うときに、その結果の配線を分岐して別のロジックにつないで、そちらで結果がゼロかどうか（すべての線が0かどうか）とか、結果がマイナスかどうか（最上位ビットの線が1かどうか）などを見て、その結果をフラグレジスタの各ビットにセットしてしまえばよいのです。フラグレジスタを持つCPUはまさにそのように実装されていて、整数演算を行うたびにフラグレジスタもついでに更新されることになります。 そのような仕組みでは、 cmp だけではなく add や sub などでもフラグレジスタは更新されます。実際、 cmp の実体は、フラグレジスタだけを更新する特殊な sub 命令ということになっています。 sub rax, rdi というようにして、その後にフラグレジスタを見ればRAXとRDIの大小関係がわかるのですが、それだとRAXが更新されてしまうので、整数レジスタへの書き込みを行わない sub として cmp が用意されています。 ソフトウェアの場合、「ついでに何かを計算する」ということをすると必ず余分な時間がかかってしまいますが、ハードウェアでは、線を分岐して余分にトランジスタを使うこと自体に時間的ペナルティは発生しないので、フラグレジスタを毎回更新するコストは、素朴なハードウェア実装の場合には存在しないのです。

分割コンパイルとリンク

この段階までは、Cファイルとテストのシェルスクリプトがそれぞれ1つだけというファイル構成で開発を進めてきました。この構成に問題があるというわけではないのですが、だんだんソースが長くなってきているので、このあたりで複数のCファイルに分割して見通しをよくすることにしましょう。このステップでは、9cc.cという1つのファイルを、以下の5つのファイルに分割します。

9cc.h : ヘッダファイル

: ヘッダファイル main.c : main 関数

: 関数 parse.c : パーサ

: パーサ codegen.c : コードジェネレータ

: コードジェネレータ container.c : ベクタ、マップ、およびそのテストコード

main 関数は小さいので他のCファイルに入れてもよかったのですが、意味的に parse.c と codegen.c のどちらにも属さないので、別のファイルに分けることにします。

この章では分割コンパイルの概念とその意義について説明を行い、その後に具体的な手順について説明をします。

分割コンパイルとは

分割コンパイルとその必要性

分割コンパイルとは、1つのプログラムを複数のソースファイルに分割して書いて、別々にコンパイルすることです。分割コンパイルでは、コンパイラはプログラム全体ではなく、プログラムの断片を読んで、それに対応した断片を出力することになります。単体では実行不可能なプログラムの断片の入ったファイルのことを「オブジェクトファイル」（拡張子は .o ）といいます。分割コンパイルでは、最後にオブジェクトファイルをつなぎ合わせて1つのファイルを作ることになります。オブジェクトファイルをまとめて1つの実行ファイルにするプログラムのことを「リンカ」といいます。

なぜ分割コンパイルをする必要があるのかを理解しておきましょう。実は、技術的にはソースを分割しなければならない必然性というものはありません。コンパイラにソースコードを一度に全部渡せば、コンパイラはリンカの助けなしに完全な実行ファイルを出力することが論理的には可能です。

ただしそのようなやり方の場合、コンパイラは、プログラムが使っているコードを本当にすべて知っている必要があります。例えば printf などの標準ライブラリの関数は、普通は標準ライブラリの作者がCで書いた関数なわけですが、リンクのステップを省くためには、そういった関数のソースコードも毎回コンパイラの入力に与える必要が出てきてしまいます。何度も同じ関数をコンパイルするのは、多くの場合、単なる時間の無駄です。したがって標準ライブラリは普通はコンパイル済みのオブジェクトファイル形式で配布されていて、手元で毎回コンパイルし直さなくてよくなっています。つまり、1つのソースコードからなるプログラムでも、標準ライブラリを使っている限り、実は分割コンパイルを利用しているのです。

分割コンパイルを行わないと、1行変更しただけでもコード全体をコンパイルし直すことになります。数万行の長さのコードではコンパイルは数十秒はかかります。大きなプロジェクトではソースコードは1000万行以上あったりするので、それを1つの単位としてコンパイルすると1日では終わらないでしょう。メモリも100GiBといった単位で必要になります。そういったビルド手順は非現実的です。

また、単純に、1つのファイルにすべての関数や変数をまとめて書くと人間にとって管理が難しいという問題もあります。

上記のような理由で分割コンパイルが必要とされているのです。

リンカの歴史 複数の断片的な機械語ルーチンをつなぎ合わせて1つのプログラムにまとめるというリンカの機能は、コンピュータの黎明期から必要とされていました。1947年にJohn Mauchly（最初のデジタルコンピュータ、ENIACのプロジェクトリーダー）は、テープから読み込んだサブプログラムをリロケートして1つのプログラムにまとめるプログラムについて記述しています 。 最初期のコンピュータにおいても、汎用的なサブルーチンは1回だけ書いていろいろなプログラムから使いたかったわけですが、そうなると、プログラムの断片を結合して実行可能なプログラムにするリンカというものが必要になるわけです。1947年というのは、アセンブラがまだ使われておらず、機械語で直接コードを書いていた時代なので、実はプログラマにとってリンカというのはアセンブラよりも先に作りたくなるプログラムなのです。

ヘッダファイルの必要性とその内容

分割コンパイルでは、コンパイラはプログラムの一部分のコードだけを見ることになりますが、コンパイラはプログラムのどのような小さな断片でもコンパイルできるというわけではありません。例えば次のコードを考えてみてください。

上記のコードでは、構造体Fooの型を知っていればこのコードに対応するアセンブリを出力することができますが、そうでなければこの関数をコンパイルすることはできません。

分割コンパイルする場合、個々のCファイルをコンパイルできるだけの十分な情報を、それぞれのファイルにいれておく必要があります。とはいっても、別のファイルに書かれているコードを全部書いてしまうとそもそも分割コンパイルではなくなってしまうので、ある程度情報は取捨選択する必要があります。

一つの例として、別のCファイルに入っている関数を呼び出すコードを出力するためにどのような情報をいれる必要があるのかを考えてみましょう。コンパイラは以下の情報を必要とします。

そもそもある識別子が関数の名前であるという情報が必要です。

コンパイラが出力する関数呼び出しのコードは、引数をある決められた順番でレジスタにセットして、 call 命令を使って別の関数の先頭にジャンプします。引数の型によっては整数を浮動小数点数に変換するといったことも行います。引数の型や個数が間違っている場合はエラーメッセージを表示する必要もあります。したがって関数の引数の個数や個々の引数の型が必要です。

命令を使って別の関数の先頭にジャンプします。引数の型によっては整数を浮動小数点数に変換するといったことも行います。引数の型や個数が間違っている場合はエラーメッセージを表示する必要もあります。したがって関数の引数の個数や個々の引数の型が必要です。 呼び出した関数の先で何が行われていても、呼び出し元にとってはそのうち単にリターンしてくるだけなので、呼び出し先の関数のコードは、呼び出し元の関数をコンパイルするときには必要ありません。

call で飛ぶ先のアドレスは分割コンパイル時にはわかりませんが、アセンブラはとりあえずアドレス0にジャンプするような call 命令を出力しておいて、オブジェクトファイル内に「オブジェクトファイルのXバイト目をYという名前の関数のアドレスで修正する」という情報を残しておくことができます。リンカはその情報を見て、実行ファイルのレイアウトを決めた後、プログラム断片をバイナリパッチングして、ジャンプ先のアドレスを修正します（この操作を「リロケートする」といいます）。したがって、分割コンパイルするためには関数の名前は必要ですが、関数のアドレスは不要です。

上記の要件をまとめてみると、関数本体の { ... } を省いたものさえあれば、その関数を呼び出すのに十分な情報はあるということになります。そのような関数本体を省いたものを関数の「宣言」（declaration）といいます。宣言は型と名前をコンパイラに教えているだけで、関数のコードは含まれていません。例えば、以下は strncmp の宣言です。

コンパイラは上記の1行を見ることにより、 strncmp の存在とその型を知ることができるというわけです。宣言に対して関数のコードを含むものを「定義」（definition）といいます。

関数宣言には、宣言を表すキーワード extern をつけて、

のように書いても構いませんが、関数の場合、関数本体が省略されていることで宣言と定義を区別できるので、 extern はつけなくてもかまいません。

なお、引数は型さえわかればよいので、宣言では名前は省略可能ですが、人間にとってわかりやすくするために宣言でも名前を書いておくことが一般的です。

別の例として構造体の型を考えてみましょう。同じ構造体を使っているCファイルが2つ以上ある場合、それぞれのCファイルに同じ構造体の宣言を書いておく必要があります。1つのCファイルでしか使われていない構造体であれば、特に他のCファイルはその存在について知る必要はありません。

Cでは、このように他のCファイルをコンパイルするときに必要になる宣言をまとめて、ヘッダファイル（拡張子は .h ）というものに書くことになっています。 foo.h に宣言を書いておいて、それを必要とする別のCファイルに #include "foo.h" のように書いておくと、 #include の行が foo.h ファイルの内容に置き換えられることになります。

typedef などもコンパイラに型情報を教えるために使われます。こういったものも、複数のCファイルで使われている場合、ヘッダファイルに書いておく必要があります。

コンパイラは宣言を読み込んだときには特に何のアセンブリも出力しません。宣言というものは、別のファイルに含まれている関数や変数を使うために必要な情報であって、それ自体は関数や変数を定義するものではないからです。

ここまでの分割コンパイルの話を踏まえると、「 printf を使うときは #include <stdio.h> をおまじないとして書いておきます」といった話が、実際には何をしているのかがわかると思います。C標準ライブラリはリンカに暗黙のうちに渡されるので、リンカは printf の関数呼び出しが含まれたオブジェクトファイルをリンクして実行ファイルを作成することができます。一方で、コンパイラは printf についてはデフォルトでは特に知識を持っていません。 printf は組み込み関数ではなく、標準ライブラリのヘッダファイルが自動的に読み込まれるといった仕様も存在しないので、起動した直後はコンパイラは printf については何も知らない状態です。この状態から、C標準ライブラリについてくるヘッダファイルをインクルードすることで、 printf の存在とその型をコンパイラは知ることができ、 printf の関数呼び出しをコンパイルできるようになるのです。

ワンパスコンパイラと前方宣言 Cでは、1つのファイルに全部の関数をまとめて書くときでも、宣言が必要になることがあります。Cの言語仕様では、コンパイラがファイル全体を読み込むことをせずに、関数1つ1つを先頭から順にコンパイルしていけるようになっています。したがって、どの関数も、その関数がファイル中で出現するところまでに書かれた情報だけでコンパイルできるようになっていなければいけません。従って、ファイルの後ろで定義されている関数を使いたい場合、事前にその関数の宣言を書いておく必要があります。そういった宣言のことを「前方宣言」（forward declaration）といいます。 関数をファイルに書く順番を工夫することで、ほとんどの前方宣言は書かずに済ませることができますが、相互再帰している関数を書きたい場合には、前方宣言は必須です。 ファイル全体を読み込まずにコンパイルすることを許すというC言語の仕様は、メインメモリが非常に小さかった時代には意味がありましたが、今となっては時代遅れな仕様と言わざるをえないでしょう。コンパイラがもう少し賢ければ、同じファイルに書いてある定義については宣言を書かずに済ませることができるはずです。とはいえこの動作は言語仕様の一部ということになっているので、覚えておく必要があります。

リンクエラー

オブジェクトファイルを最後にまとめてリンカに渡すときには、全体としてプログラムを構成するのに足る情報が過不足なく含まれていなければいけません。

もしプログラムに関数 foo の宣言だけが含まれていて定義がない場合、個々のCファイルは、 foo を呼び出すコードも含めて普通にコンパイルすることができます。しかし、最後にリンカが完全なプログラムを作成しようとしたとき、 foo のアドレスで修正するべき箇所が、 foo がないために修正しようがないので、エラーになってしまいます。

リンク時のエラーのことをリンクエラーといいます。

複数のオブジェクトファイルに同じ関数や変数が含まれている場合もリンクエラーになります。リンカとしては、重複がある場合どちらを選べばよいかよくわからないからです。このような重複エラーは、ヘッダファイルに間違えて定義を書いてしまったときによく発生します。ヘッダファイルは複数のCファイルにインクルードされるので、ヘッダファイルに定義がある場合、複数のCファイルに重複して定義が書かれているのと同じ状態になるからです。このようなエラーを解消するためには、ヘッダファイルに宣言だけを書くようにして、実体はどれか1つのCファイルに移してください。

重複した定義とリンクエラー 重複した定義があるときに、どれか一つを選んで残りの定義を無視するというリンカの動作もありえます。そのようなリンカでは重複定義をしてもエラーにはなりません。 実際のオブジェクトファイルでも、定義ごとに重複を許すかどうかを選ぶことが可能になっていて、インライン関数やC++のテンプレートの展開結果などは重複を許す形でオブジェクトファイルに含められます。オブジェクトファイルのフォーマットやリンカの動作というのは意外に複雑で、例外が多いのですが、しかしそういった動作はあくまで例外です。デフォルトでは、重複した定義はエラーになることが普通です。

グローバル変数の宣言と定義

我々のコンパイラにはまだグローバル変数がないので、グローバル変数に対応するアセンブリの例というのはまだ出ていませんが、グローバル変数というものはアセンブリレベルでは関数とほとんど同じです。したがって関数と同様に、グローバル変数にも定義と宣言の区別があります。変数の本体が複数のCファイルに重複して存在している場合、通常それはリンクエラーになります。

グローバル変数はデフォルトでは実行禁止メモリ領域に割り付けられるので、そこにジャンプするとプログラムがセグメンテーションフォールトでクラッシュすることになりますが、本質的にはそれ以外、データとコードの違いは存在しません。実行時に関数をデータとしてグローバル変数のように読むこともできますし、実行を許可するようにメモリの属性を変更してデータにジャンプすれば、データをコードとして実行することもできます。

関数とグローバル変数がどちらも本質的にはメモリ上に存在するデータにすぎないことを、実際のコードで確認してみましょう。以下のコードでは、 main という識別子がグローバル変数として定義されています。 main の内容はx86-64の機械語です。

上記のCコードを foo.c というファイルに保存してコンパイルして、 objdump を使って内容を確認してみましょう。 objdump のデフォルトではグローバル変数の内容は16進で表示されるだけですが、 -D オプションを渡すと、データをコードとして無理やり逆アセンブルすることができます。

$ cc -c foo.c $ objdump -D -M intel foo.o Disassembly of section .data: 0000000000000000 <main>: 0: 48 c7 c0 2a 00 00 00 mov rax,0x2a 7: c3 ret

データが実行禁止領域にマップされるというデフォルトの動作は、コンパイル時に -Wl,--omagic というオプションを渡すことで変更できます。このオプションを使って実行ファイルを生成してみましょう。

$ cc -static -Wl,--omagic -o foo foo.o

関数も変数もアセンブリにおいてはただのラベルになっていて、同じ名前空間に属しているので、リンカは複数のオブジェクトファイルをまとめるときに、どれが関数でどれがデータなのかは気にしません。したがって main がCレベルでデータとして定義されていても、 main が関数であるのと同じようにリンクは成功します。

生成されたファイルを実行してみましょう。

$ ./foo $ echo $? 42

上記のように、42という値が正しく返ってきています。 main というグローバル変数の内容がコードとして実行されたというわけです。

Cの文法では、グローバル変数の場合、 extern をつけると宣言になります。以下はint型のグローバル変数 foo の宣言です。

foo を含んだプログラムを書く場合、上記の行をヘッダファイルに書いておくことになります。そして、どれか1つのCファイルで foo を定義することになります。以下は foo の定義です。

なお、Cにおいては初期化式の与えられていないグローバル変数は0で初期化されるということになっているので、そのような変数は、0や {0, 0, ...} 、 "\0\0\0\0..." などで初期化されているのと意味的に同じです。

int foo = 3 のように初期化式を書く場合は、定義だけに初期化式を書いてください。宣言は変数の型だけをコンパイラに教えるためのものなので、具体的な初期化式は必要ありません。コンパイラはグローバル変数の宣言を見たときに特にアセンブリを出力するわけではないので、その中身がどのように初期化されているかというのはその場では必要ないのです。

初期化式が省略されている場合、グローバル変数の宣言と定義は extern の有無だけなので見た目が似てしまいますが、宣言と定義は異なるものです。ここでそれをきっちり把握しておいてください。

Intel CPUのF00Fバグ 1997年以前のIntel Pentiumプロセッサには、 F0 0F C7 C8 という4バイトの命令を実行するとCPUが完全にハングしてしまうという重大なバグがありました。 この4バイトの命令に対応するアセンブリ命令は正式には存在しませんが、あえてアセンブリとして書き下すと、 lock cmpxchg8b eax という命令になります。 0F C7 C8 は cmpxchg8b eax という命令で、これは、8バイトの値をレジスタとメモリの間でアトミックに（マルチコアでも他のコアに途中の状態が観測不可能な形で）交換するという命令です。 F0 というのは lock プレフィックスと呼ばれる付加的な情報で、直後の命令をアトミックにする効果を持っています。しかし、元々 cmpxchg8b はアトミックなので、 lock cmpxchg8b eax は冗長で不正な命令の書き方になっています。従って、このようなアセンブリ命令は文法的に存在しないことになっていて、 F0 0F C7 C8 というバイト列が普通のプログラムに出現することはなく、Intelはプロセッサの大量生産前にこのバグに気づくことができませんでした。 main関数をデータとして書くというハックを使うと、F00Fバグを再現するコードはCで次の1行で書くことができます。 char main[] = "\0xf0\0x0f\0xc7\0xc8" ; 現代のx86ではこの関数は無害ですが、1997年当時のPentiumでは、この1行のプログラムによってシステム全体を誰でも簡単にハングアップさせることができました。 個人で完全に占有しているPCならF00Fバグは大した問題ではないのですが、今で言うクラウドのような使い方をしている場合、このバグは致命的です。しかし、当初はF00Fバグは修正不可能でCPUの回収交換しかないかと思われたものの、その後OSカーネルの例外ハンドラレベルでのトリッキーな方法でバグを回避する手法が生み出されて、Intelにとっては幸いなことに製品交換は避けることができました。

ステップ8: ファイル分割とMakefileの変更

ファイルの分割

この章の最初に示した構成でファイルを分割してみてください。 9cc.h というのはヘッダファイルです。プログラムの構成によっては1つの .c ファイルごとに1つの .h ファイルを用意することもありますが、余分な宣言があっても特に害をなすことはないので、ここではそこまで細かな依存関係の管理をする必要はありません。 9cc.h というファイルを一つ用意して、すべてのCファイルで #include "9cc.h" というようにインクルードしてください。

Makefileの変更

さて、プログラムを複数のファイルに変更したところで、 Makefile も更新しておきましょう。下の Makefile は、カレントディレクトリに置かれているすべての.cファイルをコンパイル＆リンクして、9ccという実行ファイルを作成するためのものです。プロジェクトのヘッダファイルとしては、9cc.hという一つのファイルだけが存在して、そのヘッダファイルをすべての.cファイルでインクルードしているものと仮定しています。

Makefile のインデントはタブ文字でなければいけないことに注意してください。

makeは高機能なツールで、必ずしも使いこなす必要はないのですが、上の Makefile くらいは読めるようになっているといろいろな場面で役に立ちます。そこで、この節では上の Makefile の説明を行います。

Makefile では、コロンで区切られた行と、タブでインデントされた0行以上のコマンドの行が、1つのルールを構成します。コロンの前の名前のことを「ターゲット」といいます。コロンの後ろの0個以上のファイル名のことを依存ファイルといいます。

make foo と実行すると、 make は foo というファイルを作成しようとします。指定されたターゲットのファイルがすでに存在する場合、依存ファイルよりもターゲットファイルのほうが古い場合に限り、 make はターゲットのルールを再実行します。これにより、ソースコードが変更されたときにだけバイナリを再生成するといった動作が実現されています。

.PHONY というのはダミーのターゲットを表すための特別な名前です。 make test や make clean は、 test や clean といったファイルを作成するために実行するわけではないのですが、普通は make にはそのことがわからないので、 test や clean といった名前のファイルが偶然存在している場合、 make test や make clean は何も行わなくなってしまいます。こういったダミーのターゲットは、 .PHONY で指定することにより、本当にそういう名前のファイルを作りたいわけではなく、指定されたターゲットのファイルが存在しているかどうかに関わらずルールのコマンドを実行するべきということを make に伝えることができます。

CFLAGS や SRCS 、 OBJS は変数です。

CFLAGS はmakeの組み込みルールによって認識される変数で、Cコンパイラに渡すコマンドラインオプションを書いておきます。ここでは以下のフラグを渡しています。

-std=c11 : Cの最新規格であるC11で書かれたソースコードということを伝える

: Cの最新規格であるC11で書かれたソースコードということを伝える -g : デバグ情報を出力する

: デバグ情報を出力する -static : スタティックリンクする

SRCS の右辺で使われている wildcard というのはmakeが提供している関数で、関数の引数にマッチするファイル名に展開されます。 $(wildcard *.c) は、現在のところ main.c parse.c codegen.c container.c に展開されるというわけです。

OBJS の右辺では変数の置換ルールを使っていて、それによりSRCの中の.cを.oに置き換えた値を生成しています。 SRCS は main.c parse.c codegen.c container.c なので、 OBJS は main.o parse.o codegen.o container.o になります。

これらを踏まえた上で、 make 9cc と実行したときに何が起こるのかをトレースしてみましょう。makeは引数として指定されたターゲットを生成しようとするので、 9cc ファイルを作ることがコマンドの最終的な目標になります（引数がない場合は最初のルールが選ばれるので、この場合は9ccは指定しなくてもよい）。makeはそのために依存関係をたどっていって、欠けている、あるいは古くなっているファイルをビルドしようとします。

9cc の依存ファイルは、カレントディレクトリにある .c ファイルに対応する .o ファイルです。もし前回makeを実行したときの .o ファイルが残っていて、それが対応する .c ファイルより新しいタイムスタンプであるときは、makeはわざわざ同じコマンドを再実行したりはしません。 .o ファイルが存在しないか、 .c ファイルのほうが新しい場合にのみ、コンパイラを実行して .o ファイルを生成します。

$(OBJS): 9cc.h というルールは、すべての .o ファイルが 9cc.h に依存していることを表しています。したがって 9cc.h を変更した場合、すべての .o ファイルが再コンパイルされることになります。

static キーワードの様々な意味 Cの static キーワードは、主に次の2つの用途で使われます。 ローカル変数に static をつけて、関数を抜けた後でも値が保存されるようにする グローバル変数や関数に static をつけて、その変数や関数のスコープをファイルスコープにする この2つの用途には共通性は特にないにもかかわらず同じキーワードを使っているので、Cを学習するときに混乱するポイントの一つになってしまっています。理想的には、用途1は persistent 、用途2は private などの別のキーワードを使うべきだったのでしょう。もっと理想を言えば、用途2に関しては private をデフォルトにして、グローバルなスコープの変数や関数に public と付ける方が良かったのかもしれません。 Cがキーワードの使い回しをしている理由は、過去に書かれたコード資産との互換性です。 private などの新たなキーワードを言語に追加すると、そのキーワードを変数や関数の名前として使っている既存のプログラムがコンパイルできなくなってしまいます。Cはそれを嫌って、キーワードを増やす代わりに、既存のキーワードを異なるコンテキストで使い回しすることにしたのです。 1970年代のある段階で、 static キーワードを使い回しするのではなく新たなキーワードを増やす決断をしていれば、大した量のコードを変更しなくて済んだのでしょうが、自分だったらどうするかと考えてみるとなかなか難しい問題です。

関数とローカル変数

この章では、関数とローカル変数を実装します。また、簡単な制御構造も実装します。この章が終わると次のようなコードをコンパイルできるようになります。

上記のコードはCとはまだギャップがありますが、それでもかなりCに近づいてきたと言えるのではないでしょうか。

ステップ9：1文字のローカル変数

前章までで、四則演算ができる言語のコンパイラを作ることができました。この節では、その言語に機能を追加して、変数を使えるようにします。具体的には次のように変数を含む複数の文をコンパイルできるようになることが目標です。

a = 3; b = 5 * 6 - 8; a + b / 2;

一番最後の式の結果をプログラム全体の計算結果とすることにします。これの言語は、四則演算だけの言語に比べると、かなり「本物の言語」のような雰囲気が出てきていると言えるのではないでしょうか？

この章では、まず変数をどのように実装すればよいのかについて説明を行い、その後、インクリメンタルに変数を実装していくことにします。

スタック上の変数領域

Cにおける変数はメモリ上に存在します。変数はメモリのアドレスに名前をつけたものと言ってもよいでしょう。メモリアドレスに名前をつけることにで、「メモリの0x6080番地にアクセスする」というように表現するのではなく、「変数 a にアクセスする」というように表現することができるようになります。

ただし、関数のローカル変数は、関数呼び出しごとに別々に存在しなければいけません。実装の都合だけを考えると、例えば「関数 f のローカル変数 a は0x6080番地に置く」というようにアドレスを決め打ちにしてしまうのが簡単そうですが、それだと f を再帰的に呼び出した場合にうまく動きません。ローカル変数を関数呼び出しごとに別々に持たせるために、Cではローカル変数はスタックに置くことになっています。

スタックの内容を具体的な例を挙げて考えてみましょう。ローカル変数 a と b を持つ関数 f があり、別の何らかの関数が f を呼び出したとします。関数呼び出しの call 命令はリターンアドレスをスタックに積むので、 f が呼ばれた時点のスタックトップは、そのリターンアドレスが入っていることになります。それ以外にも、元々スタックには何らかの値が入っているものとします。ここでは具体的な値は重要ではないので「⋯⋯」で表すことにします。図にすると次のようになります。

⋯⋯ リターンアドレス ← RSP ここでは「← RSP」という表記で、現在のRSPレジスタの値がこのアドレスを指しているということを表すことにします。 a と b のサイズはそれぞれ8バイトとします。 スタックは下に向かって成長します。この状態から a と b の領域を確保するためには、変数2個分、つまり合計で16バイトRSPを押し下げる必要があります。それを行うと次のようになります。 ⋯⋯ リターンアドレス a b ← RSP

上記のようなレイアウトにすると、RSP+8の値を使うと a に、RSPの値を使うと b に、それぞれアクセスできるということになります。このように関数呼び出しごとに確保されるメモリ領域のことを「関数フレーム」や「アクティベーションレコード」といいます。

RSPを何バイト分変更するかとか、そのようにして確保した領域に変数をどういった順番で置くかといったことは、他の関数から見えるものではないので、コンパイラの実装の都合で適当に決めて構いません。

基本的にローカル変数というのは、このような単純なものとして実装されています。

ただし、この方法は一つ欠点があるので、実際の実装にはもう一つレジスタを使うことになります。我々のコンパイラでは（そしてそのほかのコンパイラでも）、関数を実行している間にRSPが変更されることがあることを思い出してください。9ccは式の途中の計算結果をRSPを使ったスタックにプッシュ／ポップしているので、RSPの値は頻繁に変更されます。したがって、 a や b にはRSPからの固定のオフセットでアクセスすることができません。

これを解決するための一般的なやり方では、RSPとは別に、現在の関数フレームの開始位置を常に指しているレジスタを用意します。そのようなレジスタを「ベースレジスタ」、そこに入っている値のことを「ベースポインタ」と呼びます。x86-64では慣習としてRBPレジスタをベースレジスタとして使用します。

関数実行中にはベースポインタは変化してはいけません（それこそがベースポインタを用意する理由です）。関数から別の関数を呼び出して、戻ってきたら別の値になっていた、というのではダメですから、関数呼び出しごとに元のベースポインタを保存しておいて、リターンする前に書き戻す必要があります。

ベースポインタを使った関数呼び出しにおけるスタックの状態を示したのが以下の図です。ローカル変数 x と y を持った関数 g が f を呼び出すものとしましょう。 g の実行中、スタックは次のようになっています。

⋯⋯ gのリターンアドレス gの呼び出し時点のRBP ← RBP x y ← RSP ここから f を呼び出すと次の状態になります。 ⋯⋯ gのリターンアドレス gの呼び出し時点のRBP x y fのリターンアドレス fの呼び出し時点のRBP ← RBP a b ← RSP

このようにすると、 a にはRBP-8、 b にはRBP-16というアドレスで常にアクセスすることができます。具体的にこのようなスタックの状態を作るアセンブリを考えてみると、それぞれの関数の冒頭に、以下のようなアセンブリをコンパイラが出力すればよいということになります。

このようなコンパイラが関数の先頭に出力する定型の命令のことを「プロローグ」（prologue）といいます。なお、16というのは、実際には関数ごとに変数の個数やサイズに合わせた値にする必要があります。

RSPがリターンアドレスを指している状態から上のコードを実行すると、期待している通りの関数フレームができあがることを確認してみましょう。1命令ごとのスタックの状態を以下に示します。

f を call で呼び出した直後のスタック ⋯⋯ gのリターンアドレス gの呼び出し時点のRBP ← RBP x y fのリターンアドレス ← RSP push rbp を実行した後のスタック ⋯⋯ gのリターンアドレス gの呼び出し時点のRBP ← RBP x y fのリターンアドレス fの呼び出し時点のRBP ← RSP mov rbp, rsp を実行した後のスタック ⋯⋯ gのリターンアドレス gの呼び出し時点のRBP x y fのリターンアドレス fの呼び出し時点のRBP ← RSP, RBP sub rsp, 16 を実行した後のスタック ⋯⋯ gのリターンアドレス gの呼び出し時点のRBP x y fのリターンアドレス fの呼び出し時点のRBP ← RBP a b ← RSP

関数からリターンするときには、RBPに元の値を書き戻して、RSPがリターンアドレスを指している状態にして、 ret 命令を呼びます（ ret 命令はスタックからアドレスをポップして、そこにジャンプする命令です）。それを行うコードは以下のように簡潔に書くことができます。

このようなコンパイラが関数の末尾に出力する定型の命令のことを「エピローグ」（epilogue）といいます。

エピローグを実行しているときのスタックの状態を以下に示します。RSPが指しているアドレスより下のスタック領域は、もはや無効なデータとみなしてよいので、図では省略しました。

mov rsp, rbp を実行する前のスタック ⋯⋯ gのリターンアドレス gの呼び出し時点のRBP x y fのリターンアドレス fの呼び出し時点のRBP ← RBP a b ← RSP mov rsp, rbp を実行した後のスタック ⋯⋯ gのリターンアドレス gの呼び出し時点のRBP x y fのリターンアドレス fの呼び出し時点のRBP ← RSP, RBP pop rbp を実行した後のスタック ⋯⋯ gのリターンアドレス gの呼び出し時点のRBP ← RBP x y fのリターンアドレス ← RSP ret を実行した後のスタック ⋯⋯ gのリターンアドレス gの呼び出し時点のRBP ← RBP x y ← RSP

このように、エピローグを実行することにより、呼び出し元の関数 g のスタックの状態がリストアされます。 call 命令は、 call 命令自体の次の命令のアドレスをスタックに積みます。エピローグの ret はそのアドレスをポップしてそこにジャンプするので、 call の次の命令から関数 g の実行が再開されることになります。こういった動作は、我々が知っている関数の動作と完全に一致しています。

このようにして関数呼び出しと関数のローカル変数というものは実現されているのです。

スタックの伸びる方向 x86-64のスタックは、上記で説明したようにアドレスの大きい方から小さい方に成長します。逆方向、つまりスタックが上に向かって伸びる方が自然な感じがしますが、なぜスタックは下に伸びるように設計されているのでしょうか？ 実はスタックが下に成長する技術的な必然性はありません。実際のCPUやABIでは、スタックの開始地点を上位アドレスにして下に成長するようにするものが主流ですが、極めてマイナーとはいえスタックが逆方向に成長するアーキテクチャもあります。例えば8051マイクロコントローラや、PA-RISCのABI 、Multics などではスタックは上位アドレス方向に成長します。 とはいえ、スタックが下方向に成長するという設計は、とりたてて不自然なものというわけでもありません。 電源投入直後、まっさらの状態からCPUがプログラムの実行を始めるにあたって、実行を開始するアドレスというのは、普通はCPUの仕様で決まっています。よくある設計では、CPUはアドレス0のような下位アドレスから実行を始めることになっています。そうすると普通はプログラムのコードはまとめて下位アドレスに置くことになります。スタックが成長してプログラムのコードと被ることがないように、その2つをなるべく離して配置すると、スタックを上位アドレスに置いて、アドレス空間の中央方向に向かって成長するように設計することになります。このようにすると、スタックは下に成長することになります。 もちろん上記のCPUとは違った設計をまた考えることができて、そうするとスタックを上に伸ばすほうが自然な配置になります。これは正直どちらでもよい問題で、単に業界の一般的な認識としてマシンスタックは下に成長するということになっている、というのが実際のところです。

トークナイザの変更

変数をどのように実装すればよいのかがわかったところで、早速実装してみましょう。ただし任意の個数の変数をサポートするのは急に難しくなりすぎるので、このステップにおける変数は小文字1文字に限定することにして、変数 a はRBP-8、変数 b はRBP-16、変数 c はRBP-24、というように、すべての変数が常に存在するものとします。アルファベットは26文字あるので、関数を呼び出すときに26×8すなわち208バイト分RSPを押し下げることにすると、すべての1文字変数の領域を確保できることになります。

では早速実装してみましょう。まずはトークナイザに手を加えて、今までの文法要素の他に、一文字の変数をトークナイズできるようにします。そのためには新たなトークンの型を追加する必要があります。変数名は str メンバーから読むことができるので、特に Token 型に新たにメンバーを足す必要はありません。結果として、トークンの型は次のようになります。

トークナイザに変更を加えて、アルファベットの小文字ならば、 TK_IDENT 型のトークンを作成するようにしてください。次のような if 文をトークナイザに加えれば良いはずです。

パーサの変更

再帰下降構文解析では文法さえわかれば機械的に関数呼び出しにマップできるのでした。したがって、パーサに加えるべき変更を考えるためには、変数名（識別子）を加えた新たな文法がどうなっているのかを考えてみる必要があります。

識別子を ident としましょう。これは num と同じように終端記号です。変数というのは数値が使えるところではどこでも使えるので、 num だったところを num | ident というようにすると、数値と同じ場所で変数が使える文法になります。

それに加えて、文法に代入式を足す必要があります。変数は代入できないと仕方がないので、 a=1 のような式を許す文法にしたいというわけです。ここではCにあわせて、 a=b=1 のように書ける文法にしておきましょう。

さらに、セミコロン区切りで複数の文（ステートメント）を書けるようにしたいので、結果として新しい文法は以下のようになります。

まずは 42; や a=b=2; a+b; のようなプログラムがこの文法に合致していることを確認してみてください。そのあと、ここまでで作成したパーサに手を入れて、上記の文法をパースできるようにしてください。この段階では a+1=5 のような式もパースできてしまいますが、それは正しい動作です。そのような意味的に不正な式の排除は次のパスで行います。パーサを改造することについては、特にトリッキーなところはなく、いままでと同じように文法要素をそのまま関数呼び出しにマップしていけばできるはずです。

セミコロン区切りで複数の式をかけるようにしたので、パースの結果として複数のノードをどこかに保存する必要があります。いまのところは次のグローバルな配列を用意して、そこにパース結果のノードを順にストアするようにしてください。最後のノードはNULLで埋めておくと、どこが末尾かわかるようになります。新規に追加するコードの一部を以下に示します。

抽象構文木では新たに「ローカル変数を表すノード」を表現できるようになる必要があります。そのために、ローカル変数の新しい型と、ノードの新しいメンバーを追加しましょう。例えば次のようになるはずです。このデータ構造では、パーサは識別子トークンに対して ND_LVAR 型のノードを作成して返すことになります。

offset というのは、ローカル変数のベースポインタからのオフセットを表すメンバーです。今のところ、変数 a はRBP-8、 b はRBP-16⋯⋯というように、ローカル変数は名前で決まる固定の位置にあるので、オフセットは構文解析の段階で決めることができます。以下に識別子を読み込んで ND_LVAR 型のノードを返すコードを示します。

ASCIIコード ASCIIコードでは、0〜127までの数に対して文字が割り当てられています。ASCIIコードにおける文字の割り当ての表を下に示します。 0 NUL SOH STX ETX EOT ENQ ACK BEL 8 BS HT NL VT NP CR SO SI 16 DLE DC1 DC2 DC3 DC4 NAK SYN ETB 24 CAN EM SUB ESC FS GS RS US 32 sp ! " # $ % & ' 40 ( ) * + , - . / 48 0 1 2 3 4 5 6 7 56 8 9 : ; < = > ? 64 @ A B C D E F G 72 H I J K L M N O 80 P Q R S T U V W 88 X Y Z [ \ ] ^ _ 96 ` a b c d e f g 104 h i j k l m n o 112 p q r s t u v w 120 x y z { | } ~ DEL 0〜31にあるのは制御文字です。現在ではNUL文字や改行文字などを除いて、このような制御文字を使う機会はほとんどなく、ほとんどの制御文字は文字コードの一等地を無駄に占めているだけの存在になってしまっていますが、ASCIIコードが策定された1963年当時には、これらの制御文字は実際によく使われていました。ASCII標準の策定時には、アルファベットの小文字を入れる代わりにさらに多くの制御文字を入れようという提案すらありました 。 48〜58には数字、65〜90には大文字、97〜122には小文字が割り当てられています。これらの文字が連続したコードに割り当てられていることに注目してください。つまり0123456789やabcdefg...は文字コード上で連続しています。順番が定義されている文字をこのように連続した位置に置くのは当然のことに思えますが、EBCDICといった当時はメジャーだった文字コードでは、パンチカードの影響でアルファベットはコード上で連続していませんでした。 Cでは文字は整数型の単なる小さな値で、文字に対応するコードを数値として書くのと意味は変わりません。つまりASCIIを前提にすると、例えば 'a' は97、 '0' は48と等価です。上記のコードでは文字から a を数値として引いている式がありましたが、そのようにすると、与えられた文字がaから何文字離れているかを計算することができます。これはASCIIコード上でアルファベットが連続して並べられているからこそできる技なのです。

左辺値と右辺値

代入式はそれ以外の二項演算子とは違って、左辺の値を特別に扱う必要があるので、それについてここで説明しておきましょう。

代入式の左辺はどのような式でも許されているというわけではありません。例えば 1=2 というように1を2にすることはできません。 a=2 のような代入は許されていますが、 (a+1)=2 のような文は不正です。9ccにはまだポインタや構造体は存在していませんが、もし存在しているとしたら、 *p=2 のようなポインタの指している先への代入や、 a.b=2 のような構造体のメンバへの代入は、正当なものとして許さなければいけません。このような正当な式と不正な式の区別はどのようにつければよいのでしょうか？

その問いには単純な答えがあります。Cにおいて代入式の左辺にくることができるのは、基本的にメモリのアドレスを指定する式だけです。

変数というのはメモリに存在していてアドレスを持っているので、変数は代入の左辺に書くことができます。同様に、 *p のようなポインタ参照も、 p の値がアドレスだという話なので、これも左辺に書くことができます。 a.b のような構造体のメンバアクセスも、メモリ上に存在する構造体 a の開始位置から b というメンバのオフセット分進んだメモリアドレスを指しているので、左辺に書くことができます。

一方で、 a+1 のような式の結果は、変数ではないので、メモリのアドレスを指定する式としては使えないということになっています。こういったテンポラリな値は、実際にレジスタだけに存在していてメモリ上にないかもしれないですし、メモリ上に存在していたとしても、既知の変数からの固定のオフセットでアクセスすることは普通はできません。こうした理由から、例えば &(a+1) のように書いても、 a+1 の結果のアドレスを取得することは許されておらず、コンパイルエラーになります。こういった式は代入文の左辺に書くことはできません。

左辺に書くことができる値のことを左辺値（さへんち、left value）、そうではない値のことを右辺値（うへんち、right value）といいます。左辺値と右辺値はそれぞれlvalue、rvalueということもあります。現在の我々の言語では、変数のみが左辺値で、それ以外の値はすべて右辺値です。

変数のコード生成を行う際は左辺値を起点に考えることができます。代入の左辺として変数が現れている場合は、左辺の値として変数のアドレスを計算するようにして、そのアドレスに対して右辺の評価結果をストアします。これにより代入式を実装することができます。それ以外のコンテキストで変数が現れている場合は、同じように変数のアドレスを計算したあとに、そのアドレスから値をロードすることにより、左辺値を右辺値に変換します。これにより変数の値を取得することができます。

任意のアドレスから値をロードする方法

ここまでのコード生成ではスタックトップのメモリにしかアクセスしていませんでしたが、ローカル変数ではスタック上の任意の位置にアクセスする必要があります。ここではメモリアクセスの方法について説明します。

CPUはスタックトップだけではなくメモリの任意のアドレスから値をロードしたりストアすることができます。

メモリから値をロードするときは、 mov dst, [src] という構文を使います。この命令は「srcレジスタの値をアドレスとみなしてそこから値をロードしdstに保存する」という意味です。例えば mov rdi, [rax] ならば、RAXに入っているアドレスから値をロードしてRDIにセットするということになります。

ストアするときは、 mov [dst], src という構文を使います。この命令は「dstレジスタの値をアドレスとみなして、srcレジスタの値をそこにストアする」という意味です。例えば mov [rdi], rax ならば、RAXの値を、RDIに入っているアドレスにストアするということになります。

push や pop は暗黙のうちにRSPをアドレスとみなしてメモリアクセスをする命令なので、実はこれらは普通のメモリアクセス命令を使って複数の命令で書き直すことができます。つまり、例えば pop rax は

mov rax, [rsp] add rsp, 8

という2つの命令と同じですし、 push rax は

sub rsp, 8 mov [rsp], rax

という2つの命令と同じです。

コードジェネレータの変更

ここまでの知識を使って、変数を含む式を扱えるようにコードジェネレータに変更を加えてみましょう。今回の変更では式を左辺値として評価するという関数を追加することになります。下のコードにおける gen_lval という関数はそれを行なっています。 gen_lval は、与えられたノードが変数を指しているときに、その変数のアドレスを計算して、それをスタックにプッシュします。それ以外の場合にはエラーを表示します。これにより (a+1)=2 のような式が排除されることになります。

変数を右辺値として使う場合は、まず左辺値として評価したあと、スタックトップにある計算結果をアドレスとみなして、そのアドレスから値をロードします。コードを下に示します。

メイン関数の変更

さて、すべてのパーツが揃ったところで main 関数も変更して、コンパイラを実際に動かしてみましょう。

ステップ10：複数文字のローカル変数

以前の章では変数名を1文字に決め打ちにして、aからzまでの26個のローカル変数が常に存在しているものとして扱うことにしていました。この節では、1文字より長い名前を持つ識別子をサポートして、次のようなコードをコンパイルできるようにします。

変数は定義なしに使えるものとします。したがってパーサでは、識別子一つ一つについて今までに見たことがあるかどうかを判定して、新たなものであれば自動的にスタック領域に変数を割り付ける必要があります。

まずはトークナイザを変更して、複数の文字からなる識別子を TK_IDENT 型のトークンとして読み込むようにしてください。

変数は連結リストで表すことにします。 LVar という構造体で一つの変数を表すことにして、先頭の要素を locals というポインタで持つことにしましょう。コードで表すと次のようになります。

パーサにおいては、 TK_IDENT 型のトークンが出現した場合、その識別子が今までに出現したことがあるかどうかを確認します。 locals をたどって変数名を見ていくことで、既存の変数かどうかはわかります。変数が以前に出現していた場合、その変数の offset をそのまま使います。新たな変数の場合、新しい LVar を作って、新たなオフセットをセットして、そのオフセットを使います。

変数を名前で探す関数を以下に示します。

パーサでは次のようなコードを追加すればよいはずです。

機械語命令の出現頻度 9ccが出力したアセンブリを見てみると、 mov や push といったデータ移動命令が多くて、 add や mul のような「本当の計算」を行う命令が比較的少ないことに気がつくと思います。その一つの理由は、9ccが最適化を行なっておらず、無駄なデータ移動命令を出力しているからなのですが、実は最適化コンパイラでも一番多く出力されるのはデータ移動命令です。筆者の環境で /bin に入っているすべての実行ファイルを逆アセンブルして、命令数をカウントした結果のグラフを以下に示します。 命令の出現頻度 ご覧のように mov 命令だけで全命令の実に3割を占めています。コンピュータというものはデータ処理機械ですが、データ処理で最も頻繁に行われるのはデータの移動なのです。「データを適切な場所に移動させる」ということがデータ処理の本質の一つだと考えてみれば、この mov 命令の多さは順当な結果のような気もしますが、意外に思った読者も多いのではないでしょうか。

ステップ11：return文

この章では return 文を追加して、次のようなコードをコンパイルできるようにします。

return 文はプログラムの途中に書いても構わないということにします。通常のCと同様に、プログラムの実行は最初の return で打ち切られて関数からリターンすることになります。例えば以下のプログラムは最初の return の値、すなわち5を返します。

この機能を実装するために、まずは return を追加した文法がどうなるのかを考えてみましょう。いままではステートメントはただの式ということになっていましたが、新たな文法では return <式>; というものを許すことになります。したがって新たな文法は次のようになります。

これを実装するためには、トークナイザ、パーサ、コードジェネレータのすべてに少しづつ手を加える必要があります。

まずはトークナイザで return というトークンを認識できるようにして、それを TK_RETURN という型のトークンで表すようにしましょう。 return や while 、 int のように、文法上特別な意味を持つトークン（キーワードといいます）は限られた個数しか存在しないので、このようにトークンごとに別の型を持たせるようにしたほうが簡単です。

次のトークンが return かどうかは、トークナイザの残りの入力文字列が return から始まっているかどうかだけを調べれば良さそうですが、それだと returnx のようなトークンが、誤って return と x としてトークナイズされてしまうことになります。したがってここでは、入力の先頭が return であることに加えて、その次の文字がトークンを構成する文字ではないことを確認する必要があります。

与えられた文字がトークンを構成する文字、すなわち英数字かアンダースコアかどうかを判定する関数を下に示します。

この関数を使って、 tokenize に次のコードを加えると、 return を TK_RETURN としてトークナイズできるようになります。

次にパーサに手を加えて、TK_RETURNを含むトークン列をパースできるようにしましょう。そのためには、まず return 文を表すノードの型 ND_RETURN を追加します。次に、ステートメントを読み込む関数を変更して、 return 文を構文解析できるようにします。例によって、文法をそのまま関数呼び出しにマップすることで構文解析ができます。新しい stmt 関数を以下に示します。

ND_RETURN 型のノードはここでしか生成しないので、ここでは新たに関数を作るのではなく、その場で malloc して値をセットすることにしました。

最後にコードジェネレータを変更して、 ND_RETURN 型のノードに対して適切なアセンブリコードを出力するようにします。新しい gen 関数の一部分を以下に示します。

上記のコードの gen(node->lhs) という関数呼び出しで、 return の返り値になっている式のコードが出力されます。そのコードはスタックトップに1つの値を残すはずです。 gen(node->lhs) の後に続くアセンブリでは、その値をスタックからポップしてRAXにセットし、関数からリターンしています。

前章までに実装した機能では、関数の最後に必ず1つの ret 命令を出力していました。この章で説明した方法で return 文を実装すると、 return 文ごとにさらに余分な ret 命令が出力されることになります。それらの命令はまとめることも可能ですが、ここでは簡単に実装するために、 ret 命令を複数出力してもかまわないということにしました。こういう細かいところは今の時点では気にしても仕方がないので、実装のシンプルさを優先することが大切です。難しいコードを書けるというのは役立つスキルですが、そもそもコードを難しくしすぎないというのは、時にはさらに役立つスキルなのです。

文法の階層 入力が何らかの規則に合致しているのかどうかを判定するために「正規表現」というものがよく使われますが、ある程度より複雑な文法は、正規表現では表現することはできません。たとえば、文字列の中でカッコの釣り合いが取れているか判定する正規表現は、原理的に書くことができません。 文脈自由文法（BNFで表現できる文法）は正規表現よりも強力で、たとえばカッコの釣り合いが取れている文字列だけを表すことができます（BNFで書くと S → SS | "(" S ")" | ε ）。しかし、正規表現と同様に文脈自由文法にも限界があり、文脈自由文法では普通のプログラミング言語に出てくる複雑なルールを表現することはできません。たとえば「変数は使う前に宣言しなければいけない」というルールはCの文法の一部ですが、こういったルールは文脈自由文法で表現することはできません。 C言語のコンパイラを書けば、コンパイラにバグがない限り、「コンパイラが受け付けた入力は正しいCプログラムで、受け付けなかった入力は不正なCプログラム」と言うことができます。つまり、普通のコンピュータの能力があれば、「Cの文法に一致しているかどうか」という問題は判定可能であり、コンパイラというものは全体として文脈自由文法より強力な文法判定機だということができます。このように、文法に一致しているかどうかを常にYES/NOで判定できる文法のことをDecidableといいます。 Decidableではない文法を考えることもできます。たとえば、「コンピュータプログラムが入力として与えられてそれを実行したとき、そのプログラムが最終的に exit 関数を実行して終了するか、あるいは無限に実行を続けるかどうか」という問題は、プログラムを実際に実行せずにYES/NOを判定することは一般に不可能ということが証明されています（なお、メモリが無限に存在する仮想的なコンピュータで実行するものとします）。つまり、プログラムが停止するかどうかという問いには、プログラムが停止するときにはYESと答えることができますが、停止しない場合は無限に実行を続けるだけになってしまうのでNOと答えることはできません。このように、判定機がYES/NOを返すだけではなく、判定機の実行が終わらないことがありえる文法のカテゴリのことを、Turing-recognizableといいます。 つまりここには、正規表現 < 文脈自由文法 < Decidable < Turing-recognizable、という文法の階層が存在しています。こういった文法の階層は、コンピュータサイエンスの一部として広く研究されています。有名な未解決問題のP≟NPも、文法の階層に関する問題です。

1973年のCコンパイラ

ここまで我々はインクリメンタルにコンパイラを作ってきました。この開発プロセスはある意味でCの歴史をそのままなぞっていると言うことができます。

現在のCを見てみると意味のよくわからない部分や不必要に複雑な部分が散見されますが、そういったものは歴史を抜きにして理解することはできません。現在のCの不可解なところも、初期のCのコードを読んで、初期のCの形とその後の言語とコンパイラの発展の様子をみてみると、いろいろ腑に落ちるところがあります。

CはUnixのための言語として1972年に開発が始まりました。1972年か1973年当時の、つまりCの歴史の中での極めて初期のソースコードがテープに残されていて、そこから読み出したファイルがインターネットに公開されています。当時のCコンパイラのコードを少し覗いてみましょう。以下に示すのは、 printf フォーマットでメッセージを受け取って、それをコンパイルのエラーメッセージとして表示する関数です。

どことなく奇妙な、CのようなCではないような言語に見えます。当時のCはこういう言語でした。このコードを読んでまず気がつくのは、我々が作ってきたコンパイラの初期の段階と同じように、関数の返り値や引数に型がないことです。ここではsは文字列へのポインタ、 p1 や p2 は整数のはずなのですが、当時のマシンではすべてが同じ大きさだったので、このように変数は型なしになっています。

2行目には、 error が参照しているグローバル変数と関数の宣言が書かれています。当時のCコンパイラにはヘッダファイルもCプリプロセッサもなかったので、このようにしてプログラマはコンパイラに変数や関数の存在を教えてやる必要がありました。

現在の我々のコンパイラと同じように、関数は名前が存在するかどうかがチェックされるだけで、引数の型や個数が一致しているかどうかはチェックされません。想定している個数の引数をスタックに積んだあとに、おもむろに関数本体にジャンプすれば関数呼び出しが成功するので、それでよしとしていたのでしょう。

fout というのは出力先のファイルディスクリプタの番号を持っているグローバル変数です。この頃にはまだ fprintf が存在しておらず、標準出力ではなく標準エラー出力に文字列を書き出すためには、グローバル変数経由で出力先をスイッチする必要がありました。

error の中では printf が2回呼ばれています。2回目のprintfではフォーマット文字列に加えて2つの値が渡されています。では、1つの値だけを取るようなエラーメッセージを表示するときにはどうしていたのでしょうか？

実はこの error 関数は、単に無理やり少ない引数で読んでも正しく動作します。関数の引数チェックがこの時点では存在しなかったことを思い出してください。 s 、 p1 、 p2 といった引数は単にスタックポインタから1、2、3番目のワードを指しているだけで、実際に p2 に相当する値が渡されているかどうかはコンパイラは気にしません。 printf は、第一引数の文字列に含まれる %d や %s の個数ぶんだけ余分な引数にアクセスするので、 %d をひとつだけ含むメッセージの場合、 p2 はまったくアクセスされません。したがって引数の個数が一致していなくても問題ないのです。

このように初期のCコンパイラには、現時点での9ccと類似した点がたくさんあります。

もう1つコードの例を見てみましょう。下のコードは、渡された文字列を静的に確保された領域にコピーして、その領域の先頭を指すポインタを返す関数です。つまりこれは静的な領域を使う strdup のような関数です。

この当時は int *p という形式の宣言の構文が考案されていませんでした。そのかわりにポインタ型は int p[] というように宣言します。関数の引数リストと関数本体との間に変数定義のようなものが入っていますが、これは s をポインタ型として宣言するためのものです。

この初期のCコンパイラには特筆するべきことが他にもあります。

この時点では構造体は存在していませんでした。

&& や || といった演算子もまだありません。この頃は & や | が if などの条件式の中でだけ論理演算子になるという文脈依存の動作になっていました。

や といった演算子もまだありません。この頃は や が などの条件式の中でだけ論理演算子になるという文脈依存の動作になっていました。 += といった演算子は =+ というように書いていました。この文法には、 i に-1を代入するつもりで、空白を入れずに i=-1 と書いてしまうと、 i =- 1 と見なされて i がデクリメントされるという意図しない動作になってしまう問題がありました。

といった演算子は というように書いていました。この文法には、 に-1を代入するつもりで、空白を入れずに と書いてしまうと、 と見なされて がデクリメントされるという意図しない動作になってしまう問題がありました。 整数型はcharとintだけで、shortやlongは存在しませんでした。「関数ポインタの配列」といった型を宣言する構文は存在せず、複雑な型を記述することができませんでした。

上記のほかにも70年代初期のCにはいろいろな機能が欠けていました。とはいえ、このCコンパイラは、上のソースコードからわかるようにCで書かれていました。構造体すらない時代にすでにCはセルフホストしていたのです。

古いソースコードを見ると、Cの一部のわかりにくい文法がなぜ現在の形になってしまったのかを推測することもできます。 extern か auto か int か char の後ろに必ず変数名が来る、という文法なら変数定義のパースは簡単です。ポインタを表す [] も単に変数名の直後に来るだけならパースするのは簡単です。ただし、この文法を、この初期のコンパイラで見えている方向性に沿って発展させていくと、現在の不必要に複雑な形になってしまうのもわかるような気がします。

さて、1973年前後にUnixとCの共同開発者のDennis Ritchieが行っていたのは、まさにインクリメンタルな開発でした。彼は、Cそのものを発展させるのと平行して、Cを使ってそのコンパイラを書いていたのです。現在のCは、言語の機能追加を続ける中で特別なポイントに達した何らかの完成形というわけではなく、単にDennis Ritchieがある時点で、これで言語の機能は十分、と思ったところで言語として完成ということになっただけです。

我々のコンパイラでも最初から完成形を追い求めることはしませんでした。Cの完成形は特別な意味があるものではないので、それを特別に追い求めることにそこまで意味はないでしょう。どの時点でもリーズナブルな機能のセットを持った言語として開発を続けていって、最終的にCにする、というのは、原始のCコンパイラがそうしていた由緒正しい開発手法なのです。自信を持って開発を進めていきましょう！

Rob Pikeのプログラミングの5つのルール 9ccはRob Pikeのプログラミングに対する考え方の影響を受けています。Rob Pikeは、Cの作者Dennis Ritchieの元同僚で、Go言語の作者であり、Unixの作者Ken Thompsonと一緒にUnicodeのUTF-8を開発した人物です。 Rob Pikeの「プログラミングの5つのルール」（Rob Pike's 5 Rules of Programming）を引用します。 プログラムのどの部分が時間を使うのか予測することはできない。ボトルネックは驚くような場所で生じるので、それがどこにあるかわかるまで、むやみにボトルネックの場所を予測してパフォーマンスハックを加えたりしないこと。 計測せよ。計測する前に最適化しようとしてはいけない。また、計測したとしても、コードの極端に遅い場所以外は最適化しようとしないこと。 凝ったアルゴリズムはnが小さい時に遅く、nは通常小さい。凝ったアルゴリズムは定数部分が大きい。nが通常大きいということを知っているのでない限り、凝らないようにすること。（仮にnが大きいとしても、まずはルール2を適用せよ。） 凝ったアルゴリズムは簡単なものよりバグりやすく実装するのも難しい。シンプルなアルゴリズムとデータ構造を使うべき。 データこそが重要。正しいデータ構造を選んで、データをうまく表すことができれば、アルゴリズムはほぼ常に自明になる。アルゴリズムではなくデータ構造こそがプログラミングの中心となる存在である。

ステップ12: 制御構文を足す

これ以降の章は執筆中です。ここまでの章は丁寧に書いたつもりですが、ここからの章は正直まだ公開するレベルには達していないと思います。ただし、ここまで読み進めてきた人ならば自分で必要なことを補完して読めないこともないでしょうし、どのような手順で進めるのがよいのか道標が欲しい人もいるでしょうから、そういう意味で公開しておきます。

この節では if 、 if ... else 、 while 、 for といった制御構造を言語に追加します。これらの制御構造は一見複雑そうに見えますが、アセンブリに素直にそのままコンパイルする場合、実装は比較的簡単です。

アセンブリにはCの制御構造に対応するものが存在しないので、Cの制御構造は、アセンブリでは分岐命令とラベルで表現されます。これはある意味、制御構造を goto を使って書き直すのと同じです。人間が制御構造を手で goto 文に書き直せるように、制御構造は、パターンにしたがってコード生成を行うだけで無理なく実装することができます。

制御構文には他にも do ... while 、 goto 、 continue 、 break など様々な構文が存在しますが、それらはこの時点ではまだ実装する必要はありません。

if 、 while 、 for を加えた新たな文法を以下に示します。

expr? ";" を読み取る時には、1トークン先読みして、次のトークンが ; ならば expr は存在しないということにして、そうでなければ expr を読む、というようにすればよいです。

if (A) B は次のようなアセンブリにコンパイルします。

Aをコンパイルしたコード // スタックトップに結果が入っているはず pop rax cmp rax, 0 je .LendXXX Bをコンパイルしたコード .LendXXX:

つまり if (A) B は、

と同じように展開されるというわけです。 XXX は通し番号などにして、全てのラベルがユニークになるようにしてください。

if (A) B else C は次のようなアセンブリにコンパイルします。

Aをコンパイルしたコード // スタックトップに結果が入っているはず pop rax cmp rax, 0 je .LelseXXX Bをコンパイルしたコード jmp .LendXXX .LelseXXX Cをコンパイルしたコード .LendXXX

つまり if (A) B else C は次のように展開されます。

if 文を読むときは、1トークン先読みをして else があるかどうかをチェックして、 else があるときは if ... else 、ないときは else のない if としてコンパイルします。

while (A) B は次のようにコンパイルします。

.LbeginXXX: Aをコンパイルしたコード pop rax cmp rax, 0 je .LendXXX Bをコンパイルしたコード jmp .LbeginXXX .LendXXX:

つまり while (A) B は次のようなコードと同じように展開されます。

for (A; B; C) D は次のようにコンパイルします。

Aをコンパイルしたコード .LbeginXXX: Bをコンパイルしたコード pop rax cmp rax, 0 je .LendXXX Dをコンパイルしたコード Cをコンパイルしたコード jmp .LbeginXXX .LendXXX:

for (A; B; C) D に対応するCコードを以下に示します。

なお、 .L から始まるラベルはアセンブラによって特別に認識される名前で、自動的にファイルスコープになります。ファイルスコープのラベルは、同じファイルの中から参照することはできますが、別のファイルから参照することはできません。したがって、 if や for のためにコンパイラが作り出したラベルを .L から始めるようにしておくと、他のファイルに含まれているラベルと衝突する心配がいらなくなります。

ccで小さいループをコンパイルしてそのアセンブリを参考にして作ってください。

コンパイラによる実行時エラーの検出 Cでプログラムを書くと、配列の終端を超えてデータを書き込んでしまったり、ポインタのバグで無関係のデータ構造を壊してしまったりすることがよくあります。こういったバグはセキュリティホールにもなるので、コンパイラの助けを借りることで、実行時にバグを積極的に検出していこうという発想があります。 例えばGCCに -fstack-protector というオプションを渡すと、コンパイルされた関数が、プロローグで「カナリー」（canary）といわれるポインタサイズのランダムな整数を関数フレームに出力して、エピローグでカナリーの値が変わっていないことを確認するようになります。このようにすると、配列のバッファオーバーフローでスタックの内容が知らないうちに上書きされてしまっている場合、カナリーの値もほぼ間違いなく変わっているはずなので、関数リターン時にエラーを検出できるというわけです。エラーを検出した場合、プログラムは普通は即座に終了します。 LLVMにはTSan（ThreadSanitizer）というものがあって、適切にロックを確保せずに複数のスレッドが共有データ構造にアクセスしているのを実行時に検出するコードを出力することができます。また、LLVMのUBSan（UndefinedBehaviorSanitizer）というものでは、Cの未定義動作をうっかり踏んでしまっていないかどうかを実行時に検出するコードを出力することができます。例えば符号あり整数のオーバーフローはCでは未定義動作なので、符号あり整数のオーバーフローが起きるとUBSanはエラーを報告します。 TSanなどはプログラムの動作速度が数倍遅くなってしまうので、常用するプログラムのコンパイルオプションに加えるのは無理がありますが、実行時のコストが比較的低いスタックカナリーのような機能は、環境によってはデフォルトでオンになっていることがあります。 このようなコンパイラの助けを借りた動的エラー検出というのは、近年盛んに研究されていて、メモリ安全ではないCやC++といった言語を使ってそれなりにセキュアなプログラムを書くことに大きく貢献しています。

ステップ13: ブロック

このステップでは { ... } の間に複数のステートメントを書くことのできる「ブロック」（block）をサポートします。ブロックは正式には「複文」（compound statement）と呼ばれますが、長い単語なので、往々にして単にブロックと呼ばれています。

ブロックは、複数のステートメントをまとめて1つのステートメントにする効果があります。上記のステップで実装した if や while は、条件式が成立したときに実行されるステートメントを1つしか許していませんでしたが、このステップでブロックを実装することにより、Cと同じように、そこに {} でくくった複数の文を書けるようになります。

関数本体も実はブロックです。文法上、関数本体は必ずブロックでなければならないことになっています。関数の定義の { ... } は、実は if や while の後に書く { ... } と構文的には同じなのです。

ブロックを追加した文法を以下に示します。

この文法では、 stmt が "{" で始まっている場合、 "}" が出現するまで0個以上の stmt がでてきてよいことになります。 stmt* "}" をパースするためには、 "}" が出現するまで while 文で繰り返し stmt を呼んで、その結果をベクタとして返すようにしてください。

ブロックを実装するためには、ブロックを表すノードの型 ND_BLOCK を追加してください。ノードを表す構造体 Node には、ブロックに含まれる式を持つベクタを追加する必要があります。コードジェネレータでは、ノードの型が ND_BLOCK だった場合に、そのノードに含まれるステートメントのコードを順番に生成するようにしてください。なお、1つ1つのステートメントは1つの値をスタックに残すので、それを毎回ポップするのを忘れないようにしましょう。

ステップ14: 関数の呼び出しに対応する

このステップでは foo() のような引数なしの関数呼び出しを認識できるようにして、これを call foo にコンパイルするということを目標にします。

関数呼び出しを加えた新たな文法を下に示します。

ident を読んだあと1つトークンを先読みしてみることで、その ident が変数名なのか関数名なのかを見分けることができます。

テストでは int foo() { printf("OK

"); } のような内容のCファイルを用意しておいて、それを cc -c でオブジェクトファイルにコンパイルして、自分のコンパイラの出力とリンクします。そうすると全体としてきちんとリンクできて、自分の呼び出したい関数がきちんと呼ばれていることも確認できるはずです。

それが動いたら、次は foo(3, 4) のような関数呼び出しを書けるようにしてください。引数の個数や型のチェックはいりません。単に引数を順番に評価すると、スタック上に関数に渡すべき引数ができあがるので、それをx86-64のABIで規定されている順番でレジスタにコピーして、関数をcallします。6つより多い引数はサポートしなくてかまいません。

テストでは上と同じように、 int foo(int x, int y) { printf("%d

", x + y); } のような関数を適当に用意しておいて、それをリンクすれば動作確認できるはずです。

x86-64の関数呼び出しのABIは（上のようなやり方をしている限りは）簡単ですが、注意点が一つあります。関数呼び出しをする前にRSPが16の倍数になっていなければいけません。 push や pop はRSPを8バイト単位で変更するので、 call 命令を発行するときに必ずしもRSPが16の倍数になっているとは限りません。この約束が守られていない場合、RSPが16の倍数になっていることを前提にしている関数が、半分の確率で落ちる謎の現象に悩まされることにmなります。関数を呼ぶ前にRSPを調整するようにして、RSPを16の倍数になるように調整するようにしましょう。

ステップ15: 関数の定義に対応する

ここまでが終わったら次は関数定義をできるようにします。とはいえCの関数定義は構文解析が面倒なのでいきなり全部を実装したりはしません。現在のところ我々の言語にはint型しか存在しないので、 int foo(int x, int y) { ... } という構文ではなく 型名を省略した foo(x, y) { ... } という構文を実装します。

呼び出された側では x や y といった名前で引数にアクセスできる必要があるわけですが、レジスタで渡された値にそのまま名前でアクセスすることは現状できません。ではどうするかというと、 x や y といったローカル変数が存在するものとしてコンパイルして、関数のプロローグの中で、レジスタの値をそのローカル変数のためのスタック上の領域に書き出してください。そうすれば、その後は特に引数とローカル変数を区別することなく扱えるはずです。

今までは暗黙のうちに全体が main() { ... } で囲まれているのと同じ動作になっていましたが、それは廃止して、全部のコードを何らかの関数の中に書くようにします。そうするとトップレベルをパースしているときは、まずトークンを読むとそれは必ず関数名のはずで、その後に続くのは引数リストのはずで、そのあとは関数本体が続いているはず、となるので、簡単に読めます。

このステップが終わるとフィボナッチ数列を再帰で計算しつつ表示したりできるようになるのでグッと面白くなるはずです。

バイナリレベルのインターフェイス

C言語の仕様はソースコードレベルの仕様を規定しています。例えば言語仕様では、どのような書き方をすると関数を定義できるのかとか、どのファイルをインクルードすればどの関数が宣言されるのか、といったことが決められています。一方で、標準に準拠するように書いたソースコードがどのような機械語に変換されるのかといったことは、言語仕様では規定されていません。C言語の標準は特定の命令セットを念頭に置いて決められているわけではないので、これは当然のことといえるでしょう。

したがって、機械語レベルの仕様というものは一見きちんと決める必要がなさそうに思えますが、実際にはプラットフォームごとにある程度の仕様が決まっています。その仕様のことをABI（Application Binary Interface）と言います。

本書でここまでに説明した関数の呼び出し方では、引数は特定の順番でレジスタに置かれるということになっていました。また、返り値はRAXにセットされるという約束になっていました。こういった関数の呼び出し方のルールのことを「関数呼び出し規約」（function calling convention）といいます。関数呼び出し規約はABIの一部です。

C言語のABIには、引数や返り値の渡し方の他に次のようなものも含まれています。

関数呼び出しで変更されるレジスタとされないレジスタ（RBPなどはリターンの前に元の値に戻されますが、いくつかのレジスタについては元の値に戻さなくてよいことになっています）

int や long などの型のサイズ

や などの型のサイズ 構造体のレイアウトのルール（構造体のメンバがメモリ上で実際にどのような配置になるのかというルール）

ビットフィールドのレイアウトのルール（例えば最下位ビットからビットフィールドを並べるのか、最上位ビットから並べるのか、など）

ABIはソフトウェアレベルのいわばただのお約束にすぎないので、本書で説明しているのとは異なるものを考えることは可能ですが、ABI互換性のないコードはお互い呼び出して使うことができないので、基本的にはCPUベンダやOSベンダがプラットフォーム標準のABIを定義しています。x86-64では、UnixやmacOSで使われているSystem V ABIいうものと、Windowsで使われているMicrosoft ABIの2つが広く使われています。なお、この2つの呼び出し規約は必然性があって別れているわけではなく、単に別々の人たちが別々に規約を策定しただけです。

本書ではここまでに、自作のコンパイラから、別のコンパイラでコンパイルした関数を呼び出すといったことを行ってきました。そういうことが可能だったのは我々のCコンパイラと別のコンパイラのABIが同じだったからです。

コンピュータにおける整数の表現

このあたりで、コンピュータでどのように整数、特に負の整数が表現されているのかを理解しておきましょう。この章では、符号なしの数の表現方法と、「2の補数表現」（two's complement）による符号ありの数の表現方法を説明します。

本書では2進のビットパターンは、0bプレフィックスをつけて、見やすくするために4桁ごとにアンダースコアで区切って、0b0001_1010のように表すことにします。0bプレフィックスは実際、コンパイラ独自拡張として多くのCコンパイラでそのまま使うことができます（ただしアンダースコアを含めることは普通できません）。

符号なし整数

符号なし整数（unsigned integer）の表現は通常の2進数と同じです。10進数の数が、下の桁から順に1の桁、10の桁、100の桁、1000の桁、⋯⋯（すなわち100の桁、101の桁、102の桁、103の桁、⋯⋯）を表しているのと同じように、2進数の数は、下の桁から1の桁、2の桁、4の桁、8の桁、⋯⋯（すなわち20の桁、21の桁、22の桁、23の桁、⋯⋯）を表しています。

例えば0b1110というビットパターンが表している符号なし整数の値は、1になっているビットの位置を見てみればわかります。この場合、2桁目、3桁目、4桁目、すなわち2の桁、4の桁、8の桁が1になっているので、0b1110は2 + 4 + 8 = 14を表しています。いくつかの例の図を以下に示します。

符号なし整数に1を足していくと、次のグラフで示すように値が循環します。これは4ビット整数の例です。

演算結果が桁あふれして、無限にビットがあるときとは異なる結果になることを「オーバーフローする」といいます。例えば8ビット整数では1+3はオーバーフローしませんが、200+100や20-30はオーバーフローして、それぞれ44と246になります。数学的にいうと28 = 256で割った余りと同じになります。

オーバーフローが引き起こした面白いバグ 数値のオーバーフローは時に思わぬバグを引き起こすことがあります。ここではゲーム「Civilization」のファーストバージョンにあったバグを紹介します。 Civilizationは文明間で戦う戦略シミュレーションゲームで、チンギスハンやエリザベス女王のようなプレイヤーを選んで、世界制覇か宇宙開発競争での勝利を目指すというゲームです。 初代Civilizationにあったバグは、非暴力主義のガンジーが突然核攻撃してくるというものでした。原因は文明が民主主義を採用すると攻撃性が2下がるというロジックでした。初代Civilizationではガンジーの攻撃性は全プレイヤー中で最小の1なのですが、ゲームが進んでインド文明が民主主義を採用すると、攻撃性がマイナス2されてオーバーフローで255になり、ガンジーがゲーム中で突如、極度に攻撃的なプレイヤーになってしまっていました。そして、そのころには大抵、科学技術の面で各文明が核兵器を持っているレベルまでゲームが進行しているので、結果的にガンジーがあるターンで突然核戦争を仕掛けてくるという行動が引き起こされていました。この「核ガンジー」はむしろ面白いということで、それ以降のCivilizationシリーズでは定番化したのですが、初代ではこれは意図しないバグだったのです。

符号あり整数

符号あり整数（signed integer）では、最上位ビット（most significant bit）を特別に扱う「2の補数表現」（two's complement）というものが使われます。2の補数表現におけるn桁の整数では、n桁目以外は符号なしの場合と同じ数を表していますが、最上位のn桁目だけは、2n-1ではなく-2n-1を表すというルールになっています。

具体的に4桁の2進数で考えてみると、それぞれの桁と、その桁が表している数は、次の表の通りになります。

4 3 2 1 符号なしの場合 8 4 2 1 符号ありの場合 -8 4 2 1

符号なしと同様、あるビットパターンが表している符号ありの値は、1になっているビットの位置を見ればわかります。たとえば0b1110を4桁の符号あり整数と見なすと、2桁目、3桁目、4桁目、すなわち2の桁、4の桁、-8の桁が1になっているので、0b1110は2 + 4 + (-8) = -2を表していることになります。いくつかの例の図を以下に示します。

このルールにおいては、最上位ビットがオンになっていない限り、符号あり整数が表している数は、それを符号なし整数として解釈した数と同じです。4ビット整数の場合、0〜7は、符号ありでもなしでも同じビットパターンになります。一方、4ビット目がオンの場合、そのビットパターンは-8〜-1（0b1000〜0b1111）のいずれかの数を表していることになります。最上位ビットがオンの場合には負数になるので、最上位ビットを「符号ビット」（sign bit）ということもあります。

符号あり整数に1を足していくと、次のグラフで示すように値が循環します。これは4ビット整数の例です。

上記のルールを理解してみると、プログラミングをしていると一般的に見かける、符号あり整数の様々な一見奇妙な振る舞いの説明がつくようになります。

符号あり整数に1を足していくと、オーバーフローしたところで大きな数から極端に小さな数になってしまうのは、読者の皆さんも経験したことがあるでしょう。これは2の補数表現を考えてみると、具体的に何が起きているのか理解できます。たとえば8ビットの符号あり整数では、最大の数は0b0111_1111すなわち127です。これに1を足すと0b1000_0000になり、2の補数表現においては-128ということになります。これは絶対値が最大の負の数です。

単項 - のテストで main から例えば-3をリターンした場合、プログラム全体の終了コードは253になったはずです。これは、 main がRAXに-3すなわち0b1111_​1111_​1111_​1111_​1111_​1111_​1111_​1101をセットしたのに対して、それを受け取る側ではRAXの下位8ビットだけが意味のある値と考えていて、それを符号なし整数として扱うので、0b1111_1101すなわち253が返り値であるかのような結果になったというわけです。

このように、あるビットパターンがどういう数を表しているかというのは、読む側の想定で変わってきます。例えば紙の本の文字というのが結局のところインクのシミであって、それを文章だと思って読む人間がいるからこそ意味が生じるように、コンピュータのメモリ上にあるものもオンとオフのビットの列に過ぎず、それ自体に本来の意味というものがあるわけではありません。ある数値を受け渡しするためには、値をセットする側とそれを読む側で解釈の方法が一致している必要があります。

なお、2の補数表現では、表現可能な負の数は、表現可能な正の数より1つ多くなっています。例えば8ビット整数では、-128は表現可能ですが、+128は表現可能な範囲からぎりぎり外れています。このように正負の範囲がアンバランスになるのは、仕組み上、仕方がありません。nビットで表せるパターンは2n通り、つまり常に偶数通りありますが、0のために1つのビットパターンを割り当てると、奇数個のパターンが残るので、正の数と負の数のどちらかが多くなってしまうのです。

符号拡張

コンピュータでは、数値のビットの幅を広げるという操作がよく出てきます。たとえば8ビットの数値をメモリから読んで64ビットレジスタにセットする場合、8ビットの値を64ビットに伸ばす必要があります。

符号なし整数を扱っている場合、値を拡張するのは簡単で、単に上位ビットを0で埋めるだけで構いません。たとえば4ビットの値0b1110 = 14を8ビットに拡張すると0b0000_1110 = 14になります。

一方、符号あり整数を扱っている場合、上位ビットを0で埋めてしまうと数が変わってしまいます。例えば4ビットの値0b1110 = -2を8ビットに拡張したつもりで、0b0000_1110としてしまうと、14になってしまいます。これはそもそも符号ビットが立っていないので負の数ですらありません。

符号あり整数を拡張する場合は、符号ビットが1ならば新たな上位ビットをすべて1で、符号ビットが0ならば新たな上位ビットをすべて0で埋める必要があります。この操作は「符号拡張」（sign extension）と呼ばれます。たとえば4ビットの値0b1110 = -2を8ビットに符号拡張すると、0b1111_1110 = -2となり、うまくビット幅が伸ばせていることがわかります。

符号なし整数では、数値の左側に無限に0が続いていて、拡張するときにはそれを取り出していると考えることができます。

同様に、符号あり整数では、数値の左側に符号ビットと同じ値が無限に続いていて、拡張するときにはそれを取り出していると考えることができます。

このように、ある数値をよりビット幅の広いところにフィットさせようとしている場合、自分がいま扱っている値が符号なしなのか符号ありなのかを意識しておく必要があります。

符号拡張の不要な負数の表現 2の補数表現はコンピュータで広く使われている符号あり整数の表現方法ですが、正負の整数をビットのパターンにマップする方法を考えてみると、それだけが唯一のやり方というわけではありません。たとえばマイナス2進数というものを考えてみると、下の桁から(-2)0、(-2)1、(-2)2、⋯⋯を表していることになります。各桁が表す数について、4ビットの場合の比較表を以下に示します。 4 3 2 1 符号なし 8 4 2 1 2の補数 -8 4 2 1 マイナス2進数 -8 4 -2 1 4ビットのマイナス2進数では、次のように、-10〜5の計16個の整数を表すことができます。 5 0b0101 4 0b0100 3 0b0111 2 0b0110 1 0b0001 0 0b0000 -1 0b0011 -2 0b0010 -3 0b1101 -4 0b1100 -5 0b1111 -6 0b1110 -7 0b1001 -8 0b1000 -9 0b1011 -10 0b1010 マイナス2進数は、見るからに桁上がりなどの処理が大変で、表現可能な範囲の中央付近に0がこないという欠点がありますが、一方で符号ビットがいらないという面白い特徴があります。したがって、ある桁のマイナス2進数をより大きい桁数に拡張するときは、上位ビットは常に0で埋めて構いません。 このように、コンピュータ上での整数の表現は、2の補数表現に限らず様々な方法が考えられます。2の補数表現はその中で、ハードウェアで最も扱いやすい表現として、現存するほぼすべてのコンピュータで使われています。

符号の反転

2の補数表現の詳細は、コンパイラ作成のために必ずしも必要な知識というわけではないのですが、いくつか2の補数表現に関する技を覚えておくと、ちょっとしたときにいろいろ便利です。ここでは数値の正負を反転する簡単な方法を説明します。

2の補数表現では、「全てのビットを反転して1を足す」という操作をすると、数値の正負が反転します。たとえば8ビット符号あり整数で、3から-3のビットパターンを求める手順は次のようになります。

数値を2進数で表す。3の場合、0b0000_0011。 全ビットを反転する。この場合、0b1111_1100になる。 1を足す。この場合、0b1111_1101になる。これが-3のビットパターン。

上記の方法を覚えておくと、負数のビットパターンを簡単に求めることができます。

また、符号ビットが立っているビットパターンを、同じ操作を行うことによって正の数にすることで、そのビットパターンが表している数値を簡単に求められることがあります。例えば0b1111_1101が何を表しているのか、単純に足し算で求めるのは面倒ですが、ビットを反転して1を足すと0b0000_0011になり、3の逆符号すなわち-3を表していたということが簡単にわかります。

上記のトリックが動く理由は割と単純です。ここまでに、2の補数表現の演算を数学的にきちんと定義をしていないので、やや曖昧な説明になりますが、アイデアは次の通りです。

全ビットを反転するというのは、-1すなわち全ビットが1のビットパターンから引くのと同じです。たとえば0b0011_0011というビットパターンは、次のようにして反転することができます。

1111 1111 - 0011 0011 = 1100 1100

つまり数値nを表すビットパターンを反転するのは、-1 - nを計算することと同じです。それに1を足すと、(-1 - n) + 1 = -nを計算しているということになり、nに対して-nを求めることができたということになるわけです。

リテラルの数の基数 Cの標準規格では数を8進、10進、16進のいずれかで書くことができます。普通に123のように数を書くと10進数、0x8040のように先頭に0xをつけて書くと16進数、0737のように先頭に0をつけて書くと8進数になります。 Cで8進数で数を書く機能なんて使ったことないよ、と思う読者も多いかもしれませんが、この文法においては単なる0も8進数表記ということになるので、どのCプログラマも実は8進数をとても頻繁に書いています。これはちょっとしたトリビアですが、よく考えてみると深いような深くないような理由があります。 そもそも0というのは数の記法としてやや特殊です。普通、1のような数は、10の桁や100の桁が0だからといって01や001のように書いたりしないわけですが、そのルールを0にそのまま適用すると空文字列になってしまいます。0を書きたい時に何も書かないというのでは実用上困るので、その場合は特別なルールとして0と書くことにしているわけですが、そうするとCの文法ではやや特殊なカテゴリということになってしまうわけです。

ポインタと文字列リテラル

ここまでの章で、それなりに意味のある計算ができる言語が出来上がりつつありますが、我々の言語ではまだ Hello world を表示することすらできません。そろそろ文字列を追加して、意味のあるメッセージをプログラムから出力できるようにしたいところです。

Cの文字列リテラルは、 char 型とグローバル変数、配列と密接に関係があります。例として以下の関数を考えてみてください。

上記のコードは、下のコードと同じようにコンパイルすることになります。ただし msg というのは他の識別子とは被らないユニークな識別子とします。

我々のコンパイラは、文字列リテラルをサポートするための機能がまだいくつか欠けています。文字列リテラルをサポートして、 printf などでメッセージを表示できるように、この章では次の機能を順に実装していきましょう。

単項 & と単項 * ポインタ 配列 グローバル変数 char型 文字列リテラル

また、上記の機能をテストするために必要な機能などもこの章で追加していきます。

ステップ16: 単項 & と単項 *

このステップでは、ポインタを実装する最初のステップとして、アドレスを返す単項 & と、アドレスを参照する単項 * を実装します。

これらの演算子は本来はポインタ型の値を返したり、ポインタ型の値を取ったりする演算子ですが、我々のコンパイラにはまだ整数以外の型がないので、ポインタ型は整数型で代用することにします。すなわち、 &x は変数 x のアドレスを単なる整数として返します。また、 *x は、 x の値をアドレスとみなして、そのアドレスから値を読んでくるという演算ということになります。

そのような演算子を実装すると、次のようなコードが動くようになります。

また、ローカル変数がメモリ上で連続して割り当てられているということを利用して、スタック上の変数にポインタ経由で間接的に無理やりアクセスすることもできます。以下のコードでは、スタック上の変数 y の8バイト上に変数 x があることを前提にしています。

このようなポインタ型と整数型を区別しない実装では、例えば *4 という式はアドレス4から値を読み出す式ということになってしまいますが、それはとりあえずよいということにしましょう。

実装は比較的簡単です。単項 & と単項 * を追加した文法を以下に示します。この文法に従ってパーサに変更を加えて、単項 & と単項 * をそれぞれ ND_ADDR と ND_DEREF という型のノードとして読み込むようにしてください。

コードジェネレータに加える変更はごくわずかです。変更点を以下に示します。

ステップ17: 暗黙の変数定義を廃止して、intというキーワードを導入する

いままでは変数や関数の返り値はすべて暗黙のうちにintということになっていました。したがってわざわざ int x; というように変数をその型名と一緒に定義することはせず、新しい識別子はすべて新しい変数名だとみなしていました。今後はそのような仮定を置くことはできなくなります。そこで、まずその点を改造します。以下の機能を実装してください。

新しい識別子を変数名とみなすのはやめて、定義されていない変数が現れたらエラーにしてください。

int x; という形で変数を定義するようにしてください。 int x = 3; といった初期化式などはサポートする必要はありません。同様に int x, y; といったものも必要ありません。なるべく単純なものだけを実装します。

という形で変数を定義するようにしてください。 といった初期化式などはサポートする必要はありません。同様に といったものも必要ありません。なるべく単純なものだけを実装します。 関数はいままで foo(x, y) といった形で書いていましたが、これを int foo(int x, int y) といった形になるように改造します。現状、トップレベルは関数定義しかないはずなので、パーザはまず int を読み、そのあとは必ず関数名のはずなのでそれを読み、次に int <引数の名前> という列を読む、ということになります。これ以上難しい構文には対応する必要はないですし、「将来の拡張にそなえて」といった念のためになにかする必要もありません。単純に"int (<int の繰り返しからなる引数リスト>)"を読むために十分なだけのコードを書いてください。

ステップ18: ポインタ型を導入する

ポインタを表す型を定義する

このステップでは、いままでは型名に int しか許していなかったのを、 int のあとに * が0個以上続く、というものを型名として許すことにします。すなわち int *x や int ***x といった定義を構文解析できるようにします。

「intへのポインタ」といった型はコンパイラの中で無論扱える必要があります。たとえば変数 x がintへのポインタだとしたら、コンパイラは式 *x はint型だとわからなければいけないわけです。「intへのポインタへのポインタへのポインタ」というように型はいくらでも複雑にできるので、これは固定のサイズの型だけで表すことはできません。

ではどうするかというと、ポインタを使います。今まで変数に対してマップを通して紐付けられている情報は、スタック上のベースポインタ（RBP）からのオフセットだけでした。これに変更を加えて、変数の型を持てるようにしてください。変数の型というのは、大雑把にいうと、次のような構造体になるはずです。

ここで ty はint型か「～へのポインタ」型かという2つの値のどちらかを持つことができます。 ptr_to は ty が「～へのポインタ」型であるときのみに意味のあるメンバーで、そのときには、「～」が指すTypeオブジェクトへのポインタを入れておきます。たとえば「intへのポインタ」なら、その型を表すデータ構造は内部的に次のようになるわけです。

intへのポインタを表すデータ構造

「intへのポインタへのポインタ」なら次のようになります。

intへのポインタのポインタを表すデータ構造

このようにすればコンパイラ内部でいくらでも難しい型を表すことができるというわけです。

ポインタが指している値に代入する

代入式の左辺が単純な変数名ではない式、たとえば *p=3 のような式はどのようにコンパイルすればよいのでしょうか？ こういった式も、左辺が単純な変数のときと基本的な概念は変わりません。この場合には、 p のアドレスが生成されるように、 *p を左辺値としてコンパイルすればよいのです。

*p=3 を表す構文木をコンパイルするときは、再帰的にツリーを下りながらコード生成していくわけですが、まず最初に呼ばれるのは *p を左辺値としてコンパイルするためのコードジェネレータです。

そのコードジェネレータでは与えられた構文木の型に応じて分岐することになります。単純な変数では前述のとおりその変数のアドレスを出力するコードを出力することになるわけですが、ここではデリファレンス演算子が与えられているので、違った動作をする必要があります。デリファレンス演算子が与えられている場合、その中の構文木を「右辺値」としてコンパイルしてください。そうすると、それは何らかのアドレスを計算するコードにコンパイルされるはずです（そうでなければその結果をデリファレンスすることはできません）。そしてそのアドレスをそのままスタックに残しておけばよいというわけです。

この段階までが完成したら、次のような文をコンパイルできるようになるはずです。

ステップ19: ポインタの加算と減算を実装する

このステップでは、ポインタ型の値 p に対して p+1 や p-5 のような式を書けるようにします。これはただの整数の加算と同じように見えますが、実際には結構異なる演算です。 p+1 は、 p が持っているアドレスに1を足す、という意味ではなくて、 p の次の要素を指すポインタにする、という意味なので、ポインタが指しているデータ型の幅を p に足してやらなければいけないわけです。たとえば p がintを指している場合、我々のABIでは、 p+1 はアドレスのバイト数としては4を足すことになります。一方で p がintへのポインタへのポインタである場合、 p+1 は8を足すことになります。

したがってポインタの加減算では、型のサイズを知る方法が必要になりますが、現状ではintなら4、ポインタなら8なので、そのように決め打ちでコードを書いてください。

この段階ではまだ連続してメモリをアロケートする方法がないので（我々のコンパイラにはまだ配列がない）、テストを書くのはちょっと大変です。ここは単に外部のコンパイラの助けを借りて、そちらのほうでmallocすることにして、自分のコンパイラの出力ではそのヘルパー関数を使ってテストを書くようにしてみてください。例えばこんな感じでテストできるでしょう。

intやlongのサイズ x86-64 System V ABIのような、intが32ビット、longとポインタが64ビットのデータモデルのことを、LP64といいます。これはlongとpointerが64ビットという意味です。同じx86-64上のABIでも、WindowsはLLP64、すなわちintやlongは32ビットで、long longとポインタが64ビットというデータモデルを採用しています。 LP64とLLP64はlongのサイズが異なっているのでABI互換性がありません。たとえばlongのメンバを含む構造体を作って、構造体全体をそのままファイルに書き出して、読み込むときはファイル上のデータをその構造体に直接キャストして扱っているといった場合、UnixとWindowsでファイルを相互に渡して読むことはできません。 Cの仕様では、intは「そのマシンで自然な整数のサイズ」（A "plain" int object has the natural size suggested by the architecture of the execution environmen）と定められています。そう言われると64ビットマシンではintを64ビットにしなければいけない感じがしますが、何が自然かというのは主観的な問題ですし、64ビットマシンでも普通は32ビット演算は自然に扱えるので、64ビットマシンでもintを32ビットにするというのはあながち間違ってはいません。それに現実的に考えると、intを64ビットにすると次のような問題が生じます。 intに64ビットもの大きさが必要なケースは少ないので、intを64ビットにすると単にメモリが無駄になる

shortが16ビット、intとlongが64ビットということにすると、32ビット整数を表す型がなくなってしまう 上記のような理由で、現存するほとんどの64ビットマシンではintは32ビットになっています。とはいえintが64ビットのILP64も存在はします。たとえば昔のCrayのスーパーコンピュータはILP64だったそうです。

ステップ20: sizeof演算子

sizeof は、見た目は関数のようですが、文法的には単項の演算子です。Cではほとんどの演算子は記号ですが、文法的には演算子を記号にしなければいけない理由は特になく、実際に sizeof はその例外になっています。

sizeof 演算子の動作をちょっと復習してみましょう。 sizeof は引数の式の型がメモリ上で何バイトなのかを返す演算子です。例えば我々のABIでは、 sizeof(x) は、 x が int ならば4、 x がポインタなら8を返します。 sizeof の引数には任意の式を書くことができて、例えば sizeof(x+3) は、 x+3 という式の型が全体としてintならば4、ポインタならば8を返すことになります。

我々のコンパイラにはまだ配列はありませんが、 sizeof(x) は、 x が配列ならば x 全体のサイズをバイト数として返すことになります。例えば x が int x[10] というように定義されている場合、 sizeof(x) は40を返します。 x が int x[5][10] というように定義されている場合、 sizeof(x) は200、 sizeof(x[0]) は40、 sizeof(x[0][0]) は4になります。

sizeof 演算子の引数は、型を知るために書かれているだけで、実際に実行される式ではありません。例えば sizeof(x[3]) という式を書いても、 x[3] へのアクセスは実際には発生しません。 x[3] という式の型が全体として何であるかはコンパイル時にわかるので、 sizeof(x[3]) という式は、コンパイル時にその型のサイズに置き換えられることになります。したがって x[3] といった sizeof に与えられた具体的な式は実行時には存在しなくなっています。

sizeof の動作を下に示します。

さて、この sizeof 演算子を実装してみましょう。 sizeof 演算子を実装するためには、トークナイザとパーサの両方に手を入れることになります。

まず、トークナイザに変更を加えて、 sizeof というキーワードを TK_SIZEOF という型のトークンとして認識するようにしてください。

次にパーサに変更を加えて、 sizeo