C言語は1972年に開発が始まった。この1972年ごろのCコンパイラのソースコードがテープに残されていて、そこから読み出したファイルがインターネットに公開されている。本稿では、47年ほど前のこの状態のC言語を体感することを目的として、Cコンパイラを動かしてみることとする。
Apout の導入
C言語はDEC社の16bitミニコンピュータであるPDP-11で動いていたUNIXの高級言語として開発された。この最初期のCコンパイラは移植性(portability)がなく、PDP-11でしか動作しなかった。そのため、まずPDP-11シミュレータを準備する。
PDP-11 Unixのユーザモードエミュレータを行うアプリとしてApoutを導入する。
DoctorWkt/Apoutのソースをcloneしてきて、単にmakeすればよい。
しかしデフォルトではEMUV1
というマクロが無効化されているので、これを有効にしないと後述のunix v1が動作しないことに注意すること。
% git clone --depth=1 https://github.com/DoctorWkt/Apout.git
% cd Apout
% make APOUT_OPTIONS='-DEMU211 -DEMUV1 -DNATIVES -DRUN_V1_RAW'
以下、ここで作ったapout
のあるフォルダにパスが通っているものとする。
使用方法はapout
を引数なしで実行すれば使い方が表示されるので、すぐに了解できる。単にPDP-11の実行ファイルを後ろに続けるだけである
% apout
Usage: apout pdp11_binary
%
PDP-11 unix v1 の取得
下記からダウンロードできる。
これを適当なディレクトリに展開する。中身は単なる /
以下を tar ball にしただけである。なので配下のバイナリファイルはapout
を介して使用することとなる。
% apout bin/echo Hello
Hello
%
もし上のように実行して Apout not compiled to support 2nd Edition binaries
というエラーが表示された場合、Apout のビルドに失敗している。APOUT_OPTIONS
を再確認した上でmake clean
とmake
してapout
を作り直すこと。
last1120c 版Cコンパイラ
1972-1973年頃の歴史的Cコンパイラのソースコードは、ベル研究所にあるデニス・リッチーのページにおいてある。
しかし、ここには「鶏と卵」の問題がある。この歴史的コンパイラは、Cの原始的な方言で書かれている。現存するgccといったコンパイラではソースコードを解析できない(エラーとなる)。しかし、初期のコンパイラのバイナリも救出されたため、我々はこの歴史的Cコンパイラを実行できるようになったのである。
ただ、この問題を解決するのに簡便な方法がgithubに上がっているので、ここではその手法を使う。
last1120c 版Cコンパイラの生成
ここには mak というシェルスクリプトがあって、これを使えばビルドが可能だ。$APOUT_ROOT
の値を環境に応じて変更する必要がある。
+++ mak.orig 2000-01-10 18:49:01.000000000 +0900
--- mak 2019-12-15 00:08:20.745133838 +0900
@@ -4,7 +4,7 @@
# match the location where you unpacked
# the 2nd Edition UNIX binaries
#
-APOUT_ROOT=/usr/local/src/V1
+APOUT_ROOT=/path/to/V1
export APOUT_ROOT
#
cc="apout $APOUT_ROOT/bin/cc"
このように変更後に、シェルスクリプト mak を実行する。
% sh mak
%
実際には mak の実行において大量のエラーが出たが、これで実行ファイルが作られたので特に気にしないものとする。
last1120c 版Cコンパイラのインストール
cc
は、/usr/lib/c0
と/usr/lib/c1
を内部的に読んでいる仕組みになっている。c0
は構文解析で、c1
はコードジェネレータだ。いま mak の実行によって c0
, c1
, cc
が作られているが、このうち c0
, c1
を unix v1 の該当箇所に入れる。同様に cc
を bin
ディレクトリに入れても良い。
% extern APOUT_ROOT=/path/to/V1
% chmod +w $APOUT_ROOT/usr/lib
% mv $APOUT_ROOT/usr/lib/c0 $APOUT_ROOT/usr/lib/c0_orig
% mv $APOUT_ROOT/usr/lib/c1 $APOUT_ROOT/usr/lib/c1_orig
% cp -p c0 c1 $APOUT_ROOT/usr/lib
last1120c 版Cコンパイラの動作確認
簡単にいわゆる Hello, world で動作確認する。
main(argc, argv)
char argv[][]; {
extern printf;
printf("Hello, World.\n");
return(0);
}
ここで以下の点に注意すること。
- このCコンパイラはファイル名が8文字しか受け付けないため短いファイル名とすること。例えば、
hello1.c
はOKだが、hello12.c
は9文字のためエラーとなる。 - この時代のC言語には
#include
がなかったので、#include <stdio.h>
を書いてはならない。- かわりに
main
から参照している関数(やグローバル変数)の宣言を関数冒頭に記載する必要がある - ただリンカが勝手に解決してくれるのでextern宣言をしなくても良かったりする。
- かわりに
- この時代のC言語には
int main(int argc, char argv[][])
といった原型形式での書き方はなかった。非原型形式(いわゆるK&R スタイル)の関数定義/宣言で書く必要がある。- 引数型を書いていないのは
int
を意味する。 - このサンプルの場合は、引数を省略して書いても良い。
- 引数型を書いていないのは
- このCコンパイラは
return 0;
と書いてはならない。return
文の戻り値を括弧で囲まなければならない。
今作ったcc
でコンパイルをして、実行することができるはずだ。
% extern APOUT_ROOT=/path/to/V1
% apout ./cc hello.c
I
II
% apout ./a.out
Hello, World.
%
もしここで、下記のようなエラーが出た場合はAPOUT_ROOT
の設定が不正である可能性が高い。
-
ld: 認識できないオプション '-l' です
などホストのld
が動作してしまう場合→APOUT_ROOT
が指定できていない可能性がある -
Can't find /bin/ld
というエラーがでて異常終了 →APOUT_ROOT
に間違ったパスを指定している可能せいがある
補足: prestructc 版Cコンパイラ
構造体をCコンパイラ自身に導入する前のCコンパイラである prestructc というのもある。オリジナルには一部のソースが抜けているらしいが、今用意した unix 環境で動作するようにしたソースコードもあるので、それを利用できる。
なお、struct実装前と言われるため、まるで構造体が使えないように聞こえるが、あくまでlast1120cからprestructcの間に構造体を処理できるように実装してあって、Cコンパイラに構造体が導入されていないという意味である。
c00.cを比較するとstruct対応がなされていることが確認できよう。
@@ -41,11 +37,12 @@
exit(1);
}
tmpfil = argv[3];
+ xdflg++;
init("int", 0);
init("char", 1);
init("float", 2);
init("double", 3);
-/* init("long", 4); */
+ init("struct", 4);
init("auto", 5);
init("extern", 6);
init("static", 7);
これもlast1120cと同様にコンパイラをビルドできるが、last1120cとprestructcの差分を確認していない(この時代のstructの記述方法など)ので、詳細は述べない。
last1120c プログラミング実例: FizzBuzz
引数を一つ取り1からその数字までカウントして、FizzBuzzする。なお、現代のC言語ではエラーとなるもの、もしくは誤りとなるものについては、コメントで記載している。
main(argc, argv)
char argv[][]; { /* XXX char *argv[] のかわりにこう書く */
extern printf; /* XXX #include<stdio.h>の代わり */
auto num, cpos;
if (argc != 2) {
printf("Usage: fizbuz NUMBER\n");
return(1);
}
num = 0;
cpos = 0;
while (argv[1][cpos] != 0) {
num =* 10; /* XXX 代入演算子が逆 */
if (('0' <= argv[1][cpos]) & (argv[1][cpos] <= '9')) { /* XXX 論理演算子非対応 */
num =+ argv[1][cpos] - '0'; /* XXX 代入演算子が逆 */
} else {
error("Error: Wrong number specified\n");
return(1);
}
cpos++;
}
if (num <= 0) {
error("Error: wrong NUMBER specified\n");
return(1);
}
fizzbuzz(num);
return(0);
}
fizzbuzz(num)
{
extern printf; /* XXX #include<stdio.h>の代わり */
auto i;
i = 1;
while (i <= num) {
if (i % 15 == 0) {
printf("FizzBuzz\n");
} else if (i % 3 == 0) {
printf("Fizz\n");
} else if (i % 5 == 0) {
printf("Buzz\n");
} else {
printf("%d\n", i);
}
i++;
}
}
error(s, p1, p2) { /* XXX 可変長引数非対応 */
extern printf, fout, flush; /* XXX #include<stdio.h>の代わり */
int f;
flush();
f = fout;
fout = 1; /* XXX 出力先のファイルディスクリプタの番号を直接変更 */
printf(s, p1, p2);
fout = f;
}
for
文はまだない。while文・do-while文もしくはgoto
を組み合わせてループを作る必要がある。
&&
や||
といった論理演算子もまだない。かわりにビット演算子の&
や|
を使う。
基本的に型はint
しかないため、int
の場合は書かない。これは関数戻り値の型を略記するとint
になる、といった形などで現在も引きずっているC言語の仕様である(警告は出る)。main()
,fizzbuzz()
,error()
のいずれも関数戻り値の型を略記しているため、int
である。現代のC言語ではvoid
を使うことで戻さないことを明示するが、void
キーワードがまだ存在しないのでどうにもならない。
興味深いのは可変長引数を対応していないのだが、関数の引数チェックを行っていないため可変長引数のような使い方をしても問題なくコンパイルがとおる。もっともこの仕様は現代のCコンパイラも引き継いでいる(警告は出る)。このため、関数プロトタイプ宣言といったものも不要であり、ファイルをまたいだ関数呼び出しの場合もextern printf
と書くだけである。
おわりに
本稿では、1972年頃の歴史的Cコンパイラであるlast1120cの環境を準備し、実際にこれが解釈することができるコードを書いた。
C言語の引数をチェックしないことや、関数プロトタイプ宣言などが、最初期のC言語では気にしないでよく、それはそれでリーズナブルな形になっていることも体感できた。こういうものは、増築を繰り返した温泉旅館みたいに複雑になった末に発生したものであったということも理解できたので、もう少し広い心でCコンパイラを見ることができるだろうと思う。
参考文献
- Dennis Ritchie, "Primeval C: two very early compilers"
- Warren Toomey, "The Restoration of Early UNIX Artifacts", https://www.usenix.org/legacy/event/usenix09/tech/full_papers/toomey/toomey.pdf
- pavel-krivanek/legacy-cc
- 本の虫: デニス・リッチーによって書かれた最初のCコンパイラーがGitHubで公開, 2013-05-23
- Rui Ueyama, "低レイヤを知りたい人のためのCコンパイラ作成入門", 2019-09-18版, "1973年のCコンパイラ"