私が40日でCコンパイラを書いたように

3年半前にCコンパイラーの実装に取り​​組んでいたGoogleプログラマー、上山ルイの日記を翻訳することをお勧めします(ただし、昨年12月に公開されました)。
この日記には実用的な利点はなく、チュートリアルではありませんが、私はそれを読むことに非常に興味がありました。あなたもこの物語が好きになることを願っています:)

40日で8ccと呼ばれるCコンパイラを作成しました。 これは当時私が書いた日記です。 コードとその履歴はGitHubで表示できます。

8日目


コンパイラを書いています。 約1000行のコードを記述した後、動作を開始しました。 すでに機能するいくつかの例を次に示します。

int a = 1; a + 2; // => 3 int a = 61; int *b = &a; *b; // => 61 

配列はポインターに正しく変換されるため、以下のコードも機能します。 関数呼び出しもサポートされています。

 char *c = "ab" + 1; printf("%c", *c); // => b 

これを実装するのは難しくありませんでした。 私はこれを二度目にしています。 配列とポインターをより適切に処理する方法を学びました。

15日目


コンパイラーの実装には長い道のりがあり、驚くほどうまく機能しています。 自明ではないプログラム、たとえばこれ は、8人の女王の解決課題であり 、コンパイルされて起動されます。

もちろん、彼には多くの機能がありません。 これらのサンプルプログラムは、使用しないように選択されています。
実装は非常に簡単です。 レジスタの割り当てさえありません。
ソースプログラムをスタックマシンのコードにコンパイルし、スタックマシンはシステムスタックをスタックとして使用します。 各操作にはメモリアクセスが必要です。 しかし、今のところ、それは私に合っています。

最初、コンパイラは約20行に収まり、コンパイラが実行できたのは、標準入力から整数値を読み取り、整数を返すことをすぐに終了するプログラムを実行することだけでした。

現在、約2000行が含まれています。 gitを見ると、次のように開発されているようです。

17日目


構造の実装に成功しました。 構造は、複数の機械語を占有できるオブジェクトです。 実装はプリミティブ型よりも難しいですが、思ったより簡単でした。

正常に機能するようです。 構造を含む構造を定義できます。 構造体へのポインターを定義し、それを逆参照できます。 配列および構造の配列を含む構造も機能します。 理論的にはコードが機能するはずであることはすでに知っていましたが、そのような困難なケースであっても、実際に機能するときは満足していました。

ただし、このコードが正しく機能している理由を完全に理解しているとは思いません。 再帰的な性質のため、少し不思議な感じがします。

構造体を関数に渡すことはできません。 x86の呼び出し規約では、構造体がスタックにコピーされ、その構造体へのポインターが関数に渡されます。 しかし、x86-64では、構造をいくつかのデータに分割し、それらをレジスタに渡す必要があります。 難しいので、今のところ延期します。 構造体を値で渡すことは、構造体にポインターを渡すことよりも必要性が低くなります。

18日目


関連付けを実装する方が簡単だったのは、 これは、すべてのフィールドが同じオフセットを持つ構造の単なる変形です。 「->」演算子も実装されています。 そのように簡単。

浮動小数点サポートの編成は困難でした。 intとfloat間の暗黙的な型変換は機能しているように見えますが、浮動小数点数を関数に渡すことはできません。 私のコンパイラでは、すべての関数パラメーターが最初にスタックにプッシュされ、次にx86-64呼び出し規約で指定された順序でレジスターに書き込まれます。 しかし、このプロセスには明らかにバグがあります。 メモリアクセスエラー(SIGSEGV)を返します。 コンパイラーはアセンブラーを読み取り用に最適化しないため、アセンブラーの出力を考慮するとデバッグが困難です。 私は一日でそれを終えることができると思ったが、私は間違っていた。

19日目


x86-64呼び出し規約に従って、スタックフレームを16バイトに揃える必要があることを忘れていたため、時間を無駄にしました。 複数の浮動小数点数を渡すと、printf()がSEGVでクラッシュすることがわかりました。 これを再現できる条件を見つけようとしました。 スタックフレームの位置が重要であることが判明したため、ABI x86-64の要件について考えるようになりました。

私はこれをまったく気にしませんでしたので、スタックフレームは8バイトだけアライメントされましたが、print()は整数のみを受け入れるまで文句を言いませんでした。 この問題は、CALL命令を呼び出す前にスタックフレームを調整することで簡単に修正できます。 ただし、コードを記述する前に仕様を注意深く読んでいない限り、このような問題を回避することはできません。

20日目


コンパイラコードのインデントを2から4に変更しました。2つの空白インデントは、Googleでの作業で使用されるため、2つの使用に慣れています。 しかし、何らかの理由で、4スペースのインデントは「美しいオープンソースプログラム」により適していると思います。

さらに重要な変更があります。 テストをシェルスクリプトからCに書き直しました。この変更の前に、GCCによってコンパイルされ、シェルスクリプトによって実行されたmain()に関連付けられたコンパイラーによってコンパイルされた各テスト関数。 遅いので テストごとに多くのプロセスを生成しました。 プロジェクトを始めたとき、私には選択肢がありませんでした。 私のコンパイラには多くの機能がありませんでした。 たとえば、比較演算子がないため、結果を期待値と比較できませんでした。 これで、テストコードをコンパイルできるほど強力になりました。 そこで、それらをより速くするために書き直しました。

また、longやdoubleなどの大きな型も実装しました。 これらの機能をすぐに実装できたので、コードを書くのは楽しかったです。

21日目


私は1日でCプリプロセッサの実装をほぼ完了しました。 これは実際には、コンパイラーを作成するための以前の試みからの移植です。

Cプリプロセッサの実装は簡単な作業ではありません。

これは、仕様で定義されているC標準の一部です。 しかし、仕様は自己実装に役立つには少なすぎます。 この仕様には、拡張された形式のいくつかのマクロが含まれていますが、アルゴリズム自体についてはほとんど説明していません。 彼女は彼の期待される行動の詳細さえ説明していないと思います。 一般に、それは指定不足です。

私の知る限り、 このブログのPDFは、C言語プリプロセッサの実装方法に関する唯一かつ最良のリソースです。このドキュメントで説明されているアルゴリズム(Dave Prosserアルゴリズム)は、マクロの無限の拡張を回避しながら、できるだけ多くのトークンを展開しようとします。 各トークンには独自の展開履歴があるため、同じマクロによってトークンが複数回展開されることはありません。

Cプリプロセッサ自体は独立した言語です。 多くの機能があり、ベテランのCプログラマーだけがそれをよく理解しています。

22日目


コンパイラにシステムヘッダーを読み取らせようとしたため、#includeを理解できるようになりました。 試している間に、多くのエラーが発生しました。 これにより、私のプリプロセッサにはまだ多くの機能が欠けていることが明らかになりました。たとえば、#ifでしか使用できない演算子です。 これ以外にも多くのバグがあります。 見つけたらすぐに修正しました。

システムヘッダーファイルは大きく、混乱を招きます。 これらには、enumやtypedefなど、コンパイラからの多くの関数が必要です。 私はそれらを一つずつ実装しましたが、時々角を切りました。 stdio.hを読み込もうとしています。 私はそれがどれくらいかかるかわからない。

コンパイラは現在4000行で構成されています。 小さなLCCコンパイラには12,000行が含まれています。 それをガイドとして使用すると、私のコンパイラはまもなく本物のCコンパイラのように動作できるようになると思います。

今日500行のコードを書いたことに驚きました。 私は12時間ストリームで作業できますが、非効率になる可能性があります。 気づかないうちに疲れてしまいます。 いずれにせよ、私は自由時間の多い人であることを認めなければなりません。

24日目


何を修正したのか覚えていませんが、stdio.hは接続できます。 これは、ヘッダーファイルで定義されている関数の種類が正しく処理されるようになったため、非常にクールです。

コンパイラの実装に使用するスキームは、言語の小さなサブセット用のコンパイラを作成し、それを実際のC言語に開発することを意味します。最近まで、私は完全に理解していない関数を実装しようとしませんでした。 必要なだけコードを記述し、残りはそのままにしておくことができます。 楽しかった。

ヘッダーシステムなどの外部のものは、多くの問題を引き起こしています。 「実際の」Cコンパイラに期待されるすべての機能を実装する必要がありますが、stdio.hを読むために多くの汚いハッキングを行いました。 たとえば、「const」トークンのすべての出現を無視するハックを実装しました。 それは私を混乱させます。

最初から正しい方法でそれをしないのはなぜでしょうか。 これは面白くないと思います。 多すぎる。 たとえば、Cで型を宣言する構文は、理由もなく混乱しすぎており、実装するのはまったく面白くないです。

それにもかかわらず、避けられないことがいくつかあります。 最初から最後まですべての機能を実現するために、おそらく考えを変える必要があります。 目標に近づくにつれて、面白いと感じることができます。 時には、目標を達成するために必要以上のコードを書く必要があります。

25日目


私は2日間、定義と宣言の構文を実装することに成功しましたが、成功しませんでした。 なぜこれを終了できないのですか? 私は1日の仕事でポインターと構造を作成しました。 私はこれを過小評価していたように感じます。 たぶん計画が必要ですか?

26日目


このような困難な状況では、1か月の進捗を確認するために、コンパイラがたった1つのファイルに含まれているという事実を覚えておく必要があります。 彼は単にscanf()で整数を読み取り、printf()で整数を印刷しました。 実際、私は1か月で非常に深刻な進歩を遂げました。 はい、できると思います。

28日目


宣言と定義用のパーサーの作成が終了しました。 私が失敗した理由は、最初からあまりにも多くの詳細を書き込もうとしたためだと思うので、すべてを正しく理解してから実際のコードに変換することを確認するために擬似コードを書きました。

私は15年近くCを書いてきましたが、今日になってようやくCの型構文が理解できたと感じました。動作するコードを書けなかったのは驚くことではありません。 これは、私がそれを正しく理解しなかったためです。

書いたばかりのコードは複雑すぎて壊れやすいので、私も理解することはできません。 Cの作成者であるデニスリッチーが、彼がやっていることの結果を理解していたとは思わない。 彼は構文を思いつき、予想よりも複雑なコードを書き、最終的にはANSI委員会によって標準化されたと思います。 すべてを正しく行う必要があるため、標準化された言語を実装することは困難です。 独自のおもちゃの言語を書く方が簡単です。

29日目


さらに多くの演算子を実装し、コードをクリーンアップしました。

今日、初めて、私のコンパイラが私のファイルの1つをコンパイルすることができました。 GCCを使用してコンパイルされた他のファイルにリンクすると、機能していることがわかりました。 また、結果のコンパイラも動作するようです。 ターゲットが近づいているようです。

30日目


今日、私はスイッチケースを実装し、続行し、中断し、後藤に行きました。 gotoのテストケースを作成すると、テストコードはすぐには読めないスパゲッティコードに変わりました。 それは私を笑わせた。 gotoがなぜ有害と考えられるのかを確認しました。

31日目


varargsに実装された関数、つまりva_start、va_arg、va_end。 これらはあまり使用されませんが、printfなどの関数をコンパイルするために必要でした。

Cの可変引数の仕様は、よく考えられていません。 スタックを介して関数にすべての引数を渡すと、va_startを非常に簡単に実装できますが、最新のプロセッサと最新の呼び出し規約では、関数を呼び出すオーバーヘッドを減らすために引数がレジスタに渡されます。 したがって、仕様は現実に対応していません。

大まかに言えば、AMDによって標準化されたx86-64のABIでは、va_startの次の呼び出しに備えて、すべてのレジスタをスタックにコピーするために、可変数の引数を持つ関数が必要です。 彼らには他に選択肢はなかったと理解していますが、それでもやはり厄介に見えます。

他のコンパイラが可変数の引数を持つ関数をどのように処理するのか疑問に思いました。 TCCヘッダーを調べましたが、ABI x86-64と互換性がないようです。 varargsのデータ構造が異なる場合、va_listを渡す関数(vprintfなど)は互換性がなくなります。 または私は間違っていますか? [そして、私は本当に間違っています-それらは互換性があります。] Clangも見ましたが、紛らわしいようです。 私はそれを読みませんでした。 他のコンパイラーから多くのコードを読みすぎると、自分の実装の面白さを台無しにする可能性があります。

32日目


小さな問題を修正し、文字列リテラルのエスケープシーケンスを追加した後(まだ「\ 0」などはありませんでした)、別のファイルをコンパイルすることが判明しました。 私は自信を持って進歩を感じています。

6つ以上のパラメーターを持つ関数のサポートを実装しようとしましたが、1日で完了できませんでした。 x86-64では、最初の6つの整数パラメーターはレジスターを介して渡され、残りはスタックを介して渡されます。 現在、レジスタを介した転送のみがサポートされています。 スタックを通過させることは実装するのが難しいことではありませんが、デバッグするには時間がかかりすぎます。 私のコンパイラには、6つ以上のパラメータを持つ関数はないので、今のところ実装を延期すると思います。

33日目


今日、さらに3つのファイルがコンパイルされました。 11のうち6つです。ただし、コードの行を数えると、合計の約10%になります。 残りのファイルは、コンパイラコアのコードが含まれているため、はるかに大きくなります。

さらに悪いことに、複合リテラルや指定された初期化子など、カーネルファイルで比較的新しいC機能を使用します。 それらは自己コンパイルを非常に複雑にします。 私はそれらを使うべきではありませんでしたが、普通の古いCでコードを書き直すことは生産的ではないので、コンパイラでそれらをサポートしたいと思います。 時間がかかりますが。

34日目


デバッグツールに関するいくつかの注意。 コンパイラは多くのステップで構成される複雑なコードであるため、デバッグのために何らかの方法でコンパイラを調べる方法が必要です。 私のコンパイラも例外ではありません。 便利だと思ういくつかの機能を実装しました。

まず、字句解析プログラムは読み取り位置を記憶し、予期しない理由で中断された場合、この位置を返します。 これにより、コンパイラが正しい入力を受け入れない場合にバグを簡単に見つけることができます。

内部抽象構文ツリーを印刷するためのコマンドラインオプションがあります。 パーサーにエラーがある場合、構文ツリーを確認したいと思います。

コードジェネレーターは、抽象構文ツリーを走査するときにアセンブラーコードのフラグメントを生成するため、再帰を広く使用できます。 そのため、アセンブラー出力の各行に対してミニスタ​​ックトレースの印刷を実装することができました。 何かおかしいことに気づいたら、その出力を見ることでコード生成プログラムをたどることができます。

ほとんどの内部データ構造には、文字列に変換するための関数があります。 これは、デバッグにprintfを使用するときに便利です。

新しい関数を作成するときは、常にユニットテストを作成します。 実装したとしても、テストを実行するためにコードをコンパイルしたままにします。 テストは短時間で実行されるように記述されているため、何度でも実行できます。

36日目


複合リテラルを実装し、構造体と配列の初期化子を書き直しました。 以前の実装は気に入らなかった。 これで、イニシャライザが改善されました。 私は最初から美しいコードを書かなければなりませんでしたが、動作するコードを書くことでしか理解できなかったため、書き換えは避けられませんでした。

自己コンパイルには不十分な唯一の機能は、構造の割り当てだと思います。 実装時に多くのデバッグなしですべてが意図したとおりに機能することを願っています。

37日目


トークナイザーを含むファイルはコンパイルされますが、何らかの理由で結果の第2世代コンパイラーが正しいアセンブラーコードを生成しません。 ただし、第一世代のコンパイラーによって生成されたコードはすべてのテストに合格します。 そのような陰湿なバグ。

第2世代はデバッグ情報をサポートしていないコンパイラーでコンパイルされているため、デバッグにprintfを使用する以外に選択肢はないと思います。 疑わしい場所にprintfを追加しました。 第2世代のコンパイル時にPrintfデバッグメッセージが表示されたので、少し驚きました。 第2世代を使用する場合にのみデバッグメッセージを出力したいので、第2世代が作成されたばかりのときに出力が機能するとは思っていませんでした 。

映画「Beginning」を思い出させます。 このバグを再現するには、さらに深くする必要があります。 これは、自己コンパイルコンパイラのデバッグの楽しい部分です。

38日目


字句解析プログラムが自己コンパイルされた場合に、第2世代で発生した問題を修正しました。 -1> 0が時々trueを返すバグを引き起こしました(符号拡張を忘れていました)。 構造の配置(構造レイアウト)には別のバグがあります。 残っているファイルは3つだけです。

39日目


コードジェネレーターもコンパイルできるようになりました。 残り2つのファイル。 私は楽観的ではないかもしれませんが、作業はほぼ完了しています。 予期しない落とし穴がまだ残っている可能性があります。

このプロジェクトの初期段階で書いたコードの質の低さに起因する多くの問題を修正しました。 それは私を退屈させた。

自己コンパイルの機会はすべてあると信じていましたが、そうではありません。 プレフィックスのインクリメント/デクリメント演算子もありません。 C99の一部の機能については、コンパイラー部分を書き直してコンパイルしやすくしました。 自己コンパイルの可能性にそれほど早く到達することを期待していなかったため、必要なだけ多くの新しいC機能を使用しました。

40日目


やった!私のコンパイラは完全にコンパイルできるようになりました!

約40日かかりました。これは、自己コンパイルCコンパイラを書くための非常に短い時間です。私のアプローチは、最初に非常に限られたCのサブセット用の小さなコンパイラを作成し、それを実際のCコンパイラに変換して非常にうまく機能させることだと思います。いずれにせよ、今日はとても幸せです。

自分のコードを見て、自分で作成したことを知っていても、入力で自分自身を受け入れてアセンブラーに変換できるため、少し魔法のように感じます。

まだ多くのバグと未実現の機能があります。おそらくそれらを終了してから、出力コードの改善に取り組みます。

ソースコードはこちらです。。読む価値があるかどうかはわかりませんが、単純な5000行のコンパイラーが処理できるCコードのサンプルとして見るのは面白いかもしれません。

41日目


大きなマイルストーンに到達したため、彼は体系的な開発に戻りました。コードを変更して、あたかも第三者であるかのように読み込もうとした結果、コードの品質に満足しました。盆栽の剪定を連想させます。

セルフコンパイルを再開するテストを追加して、第2世代と第3世代のファイルで結果が同じであることを確認しました。私の記憶が正しければ、GCCにも同様のテストがあります。

42日目


コンパイラのソースコードに書き込まれていないにもかかわらず、バイナリ形式の実行可能ファイルを介してのみ次世代に送信できる情報がいくつかあります。

たとえば、私のコンパイラは、「\ n」(バックスラッシュと文字「n」のシーケンス)を文字列リテラル「\ n」(この場合は改行文字)に解釈します。考えてみると、「\ n」の実際のASCIIコードに関する情報がないため、これは少しおかしいかもしれません。文字コードに関する情報はソースコードにはありませんが、コンパイラをコンパイルするコンパイラから送信されます。私のコンパイラの改行は、GCCにまでさかのぼることができます。
コンパイラは、文字コードよりもはるかに多くの情報を伝えることができます。

この驚くべき物語は、ケン・トンプソンがチューリング賞の受賞に関する講演で発表したものです。Kenが以前のバージョンのUnixコンパイラに追加した情報により、ユーザーログインプログラムはいくつかの特別なパスワードを受け入れることができたため、Kenは任意のUnixアカウントにログインできました。また、コンパイラーに独自のコンパイルを認識させ、入力プログラムのハックを子コンパイラーに渡すことで、このバックドア(ソースコードにない)が世代から世代へと渡されるようにしました。ソースコードを処理するコンパイラが感染しているため、コンパイラのソースコードのすべての行を慎重に調べて再コンパイルしても、削除できませんでした。これは素晴らしい話ですね。

43日目


演算子の優位性(演算子優先順位パーサー)を使用する代わりに、再帰降下の方法で演算子のパーサーの実装を書き直しました。演算子の優先順位を使用してパーサーを選択する理由は、単純さと速度にあると思いますが、演算子のCステートメントで使用される文法は、この方法で処理するには混乱しすぎています(たとえば、配列インデックスまたはさまざまな種類の単項演算子)。大きな関数は多くの小さな関数に分割されるため、コードは以前よりも読みやすくなりました。パーサーにエラーチェックを追加するのも簡単になりました。

パーサー作成テクニックは、プログラマーとしての私の最も役立つスキルの1つです。彼は何度も助けてくれました。

しかし、再帰言語を使用して文法のパーサーを記述するためにC言語仕様を読んだとき、いくつかの派生語が再帰的に残されていることがわかりました。私はしばらく考えて、教科書をもう一度開いて、文法を正しく再帰するように書き直す方法を思い出さなければなりませんでした。左再帰の排除は構文解析の基本的なトピックであり、これは入門書で説明されています。しかし、私は長い間この技術を使用していなかったので、そのような基本的なことを思い出すことができませんでした。

44日目


入力データは、文字列→トークンシーケンス→マクロ置換後のトークンシーケンス→抽象構文ツリー→x86-64アセンブラーのように変換されます。おそらく、やり過ぎで混乱している最後の移行。暗黙的な型変換や、アセンブリコードの生成中のラベル名の展開など、さまざまな種類の操作を実行します。理論的には、おそらくASDとアセンブラーの間の中間言語を定義する必要があります。

私は完全にそれを理解していると感じずに、この主題について再びドラゴンブックを読みました。この本はあまりにも抽象的なので、すぐに私のケースに適用することはできません。

私のコンパイラは、コンパイル時にGCCの2倍遅くなります。これは思ったほど悪くはありません。私のコンパイラはひどく芸術的なアセンブラーを生成しますが、そのようなコードは一桁も遅くありません。

45日目


gcovが私のコードでどのように機能するのかと思っていました。彼は、単体テストに失敗したコードの多くのブランチを見つけました。これらのコードブランチのテストを追加することで、いくつかのバグを発見しました。コードカバレッジツールは非常に便利です。

46日目


コンパイラーをどうするかについてのアイデアが不足しているように感じます。新しいことを学ぶ必要はなかったので、開発の現状に到達することは難しくありませんでしたが、この点以外のことは難しいようです。

コードジェネレーターから暗黙的な型変換を引き出しました。それらは現在、ASDで明確に表されています。これにより、内部で何が起こっているのかを簡単に理解できます。また、さまざまな場所で偶然の改善を行いました。作業はほぼ完了したと思いましたが、実際には多くの未実現の機能とエラーがありました。

Dragon Bookからさらにいくつかのページを読んだ後、コンパイラーの最適化をよりよく理解し始めました。もう少し良く理解できれば、コードを書き始めることができます。

52日目


謎のバグを3日間探しました。スタックポインターを切り上げて16バイトの境界に合わせると、第2世代のコンパイラーは正しい入力に対してエラーメッセージを表示します。これらの種類のエラーは、通常の致命的なエラーよりもデバッグが困難です。

私の最初の推測は:スタックスタックポインターの不整列です。しかし、これはそうではありません。スタックポインターが16バイト境界に既に正しく配置されているためです。アセンブラーの出力にバグが見つかりませんでした。私は半分に分割することにしました。

コンパイラーで各ファイルをコンパイルし、残りをGCCでコンパイルして、問題を再現できる関数を決定しようとしました。しかし、関数にはエラーが含まれていなかったようです。これは、エラーメッセージを表示する関数ではありません。1つのファイルにさえありません。理論は、1つの関数が他の関数でエラーを引き起こす不正なデータを作成するというものです。

長時間の偶然のデバッグの後、私は最終的に理由を見つけました:コンパイラはゼロで構造体フィールドを初期化しません。C仕様では、構造体、値で初期化されていないフィールドを初期化するとき、コンパイラは自動的にゼロで埋める必要があります。私はコードを書いたときに仕様を知っていました(したがって、私のコードはこの動作に依存します)が、それを実装するのを忘れていました。その結果、一部の構造体フィールドはゼロではなくゴミで初期化されました。ガベージデータはスタックの現在の位置に応じて変化するため、スタックポインターを変更するとプログラムの動作がランダムに変化します。

その結果、3日間のデバッグ後、1行のみが修正されました。このようなデバッグを簡素化する手段が欲しいのです。

53日目


別の謎のバグが修正されました。一部のファイルをGCCでコンパイルし、残りを私のコンパイラでコンパイルすると、結果のコンパイラは有効な入力で構文エラーを報告します。このような問題は、ABIの互換性に関連する必要があります。私の最初の仮定は、問題は構造のマークアップまたは引数が関数に渡される方法にあるかもしれないが、アセンブラーの出力はそれらに対して正しいようだということでした。

もう一度、検索を特定のファイルに絞り込み、半分に共有しました。問題を引き起こしている関数を見つけるために、あるファイルから別のファイルに関数を順番に転送しました。この関数は小さくなかったので、私はそれを分離し、コードを別のファイルに転送しました。最後に、コードを数行取得しました。 GCCとコンパイラを使用してそれらをコンパイルし、比較しました。

唯一の違いはこれです。コンパイラはレジスタのすべてのビットをチェックして、ブール値trueが含まれているかどうかを判断しますが、GCCは下位8ビットのみをチェックします。したがって、例えば、レジ​​スターの値が512(= 2 9または0x100)の場合、コンパイラーはそれをtrueと見なしますが、GCCは別の方法でそれを考慮します。 GCCは実際には、最下位8ビットにゼロを含む非常に大きな数を返しますが、これはfalseと解釈されます。

この非互換性のため、GCCを使用してコンパイルされた関数を使用して終了条件をチェックするループ(ループ自体はコンパイラーによってコンパイルされます)は、最初の反復ですぐに停止します。その結果、プリプロセッサマクロは定義されていません。また、一部のトークンは未定義のままであったため、パーサーは一部の入力データで構文エラーを報告しました。その理由は、エラーが報告された場所からはほど遠いものでしたが、最終的にはそれを見つけました。

x86-64 ABI仕様には、下位8ビットのみが論理戻り値にとって重要であるという簡単なメモがあります。私はこれを読みましたが、初めてこれが何を意味するのか理解できなかったので、そのような兆候が存在したことすら覚えていませんでした。しかし今、私にとっては非常に明確です。私は複雑な感情を持っています-新しいことを学びましたが、それほど時間を費やすことなくそれらを学ぶことができました。

55日目


ビットフィールドを実装しました。

ビットフィールドを使用して、いくつかの変数を小さな領域にパックできますが、コンパイラーは、それらのタイプに応じて、それらの間にスペースを作成する必要があります。GCCの結論に関する簡単な調査により、次のルールが明らかになりました(もちろん、それらが正しいことを保証するものではありません)。


CPUにはメモリ内の個々のビットにアクセスするための命令がないため、ビットフィールドにアクセスするには複数の機械命令が必要です。ビットフィールドを含むメモリをレジスタに読み込む必要があります。次に、&マスクを使用してビットフィールドを読み取ります。メモリに書き込むときは、最初にビットフィールドを含むメモリを読み取り、ビットマスク&、ビット単位の「または」を適用して新しい値を取得し、次に値をメモリに書き戻す必要があります。

56日目


計算されたgoto(計算されたgoto)を実装しました。通常のgotoは1つのラベルしか参照できませんが、計算されたgotoはポインターを取り、そのアドレスに制御を渡すことができます。これはC標準にはなく、GCCの拡張機能として存在します。非常に大きなスイッチケースを使用して仮想マシンを作成したことがある場合は、この機能をご存知でしょう。この関数は、大規模なスイッチケーススイッチを変換テーブルと計算されたgotoで置き換えるためによく使用されます。

計算されたgotoは、単一の間接ジャンプ命令にコンパイルされます。これは、おそらくアセンブラーの観点から理解しやすいでしょう。私のコンパイラはアセンブラを生成するため、実装は非常に簡単でした。

57日目


コンパイラで使用したかったため、C11 _Genericを実装しました。しかし、実装後、GCC 4.6.1は_Genericをサポートしていないことに気付きました。この関数を使用すると、GCCがコンパイラをコンパイルできないため、使用できません。

また、GCCの拡張機能であるtypeof()関数も実装しました。これらの関数は両方とも現時点では使用されていませんが、これらを実装するのに必要なコードはほとんどないため、これは正常です。

58日目


C99から有向グラフを追加しました。ダイグラフは、一部の文字を使用できない特定の環境では珍しい機能です。たとえば、「<:」は「[」のエイリアスとして定義されます。ダイグラフは、トークン化中に通常の文字に変換できるため、無用ですが無害です。

C89には明らかに有害な3文字があります。トライグラフは、3文字の文字シーケンスであり、ソースコードのどこにある場合でも1文字に変換されます。たとえば、printf( "huh ??!")は「huh ??!」ではなく、「huh |」と出力しますが、これは「??!」だからです。これは「|」の3文字表記です。これは非常に紛らわしいです。トリグラフをサポートする予定はありません。

62日目


TCCをコンパイルしようとしています。しかし、これまでのところ、未実現の機能とエラーのために私は成功にはほど遠い。

TCCは、サイズが20K〜30K行のコードの範囲にある小さなCコンパイラです。 x86-64以外のアーキテクチャのサポートを削除した場合、行数は約10K〜20Kになる可能性があります。このような小さなコンパイラーが非常に多くの機能をサポートしているのは驚きです。TCCの作成者であるFabrice Bellarは天才です。

TCCのソースコードを数回読んでみましたが、全体像を理解できません。コンパイラは複雑なプログラムであるため、通常は管理しやすい小さな部分に分割する必要がありますが、TCCソースコードはモノリシックコンパイラのように感じられます。こんなにすばらしいコードを書くことはできません。模倣したいかどうかはわかりませんが、そのようなコードを書くことができる人が世界中にいるという事実は本当に感銘を受けました。

73日目


TCCのコンパイルに失敗しました。もちろん、これが難しいのは、合格すれば、コンパイラーがTCCと同じ機能を持つことを意味するからです。他のコンパイラのコンパイルはデバッグに役立ちますが、その性質上、言語仕様のすべての詳細を細かく選択しています。

Source: https://habr.com/ru/post/J274083/


All Articles