授業 情報工学実験II極めて小規模な(とはいえ十分に汎用的な)C言語サブセットのコンパイラをLLVMコンパイラ基盤とC言語を用いて実現することで
本実験で作成するのはC言語の小さなサブセット(以下,Pico-C)のコンパイラである。
中間言語としてLLVMコンパイラ基盤の中間言語(LLVM-IR)を用いる。この実験の主要な対象はLLVM-IRを出力するまでの工程(フロントエンド(の一部))である。LLVM-IRを実際のハードウェアの目的言語に翻訳する工程(バックエンド)はLLVMに対応したC言語コンパイラであるclang(クラン)をはじめとする既存のLLVMのツールチェーンを使用する。
実験1では実際に様々なツールをシェル上でパイプして実行しながらこのコンパイルの流れ作業を理解する。コンパイラフロントエンドpicocのソースコードの本体はファイルpicoc.cで,実験2(LLVM-IR)が済むと理解できるようになる。実験3ではこのファイルにコードを追加・変更しフロントエンドの機能を強化する。
字句解析器(scan.c)やハッシュ表(hashmap.c)のソースコードは別のファイルに分離してあり、今回の実験ではこの内容を理解・変更する必要は一切ない。
ソースコードはすべてC99(ISO9899:1999, JIS X3010:2003) / C11(ISO9899:2011)に準拠したC言語の基本的な機能と標準ライブラリの関数だけを用い,理解し易さを重視して平易に記述されている。
実験にはWindow11のインストールされた各自のラップトップ(ノート)PCを用いる。事前に以下の環境を準備・確認しておくこと。
本実験資料は入学時に大学で指定された仕様を満たすPC環境、つまりamd64/x86_64アーキテクチャ(Intel/AMDの64bit CPU)のWindows 11 PCを前提にしている。Windows 10は2025年10月14日にサポートが終了するので本実験でもサポートしない。
自力でサポート外のaarch64/arm64アーキテクチャの環境を用いる人のための非公式の手引きはこちら
どうしてもローカルのPCにUbuntu 24.04 LTS以降の環境を用意できない人は、クラウドにUbuntuサーバーを用意してSSHでリモート接続する方法もある。例:ConoHa VPS
Linuxでシェル(bash)のコマンド操作ができない人は,実験開始までに1年次春学期の「情報処理演習」の教科書の下記の部分を復習し,よく理解しておくこと。
参考までに,2024年度の情報処理演習(午後クラス)の資料はこちら。
aptコマンドを用いて以下のようにパッケージでインストールできる。vim, emacs, nano, microなど)がある人はUbuntuにインストールしてそれを使い続ければよい。エディタを使い慣れない人(というのが情報工学科の3年次にいるのか疑問だが)には最近マイクロソフトが公開したEditがある。機能は最小限で誰でも直感的に使える。PowerShellでとすれば簡単にインストールできる(近い将来Windows11に標準でインストールされる予定)。WSLのUbuntuから起動するにはコマンド
を実行する。
Editより高機能なエディタを求める人には、マイクロソフトのVisual Studio Code (以下,VSCode)がある(公式解説)。VSCodeはStoreからインストールできる。あるいは,PowerShellでwingetコマンドを用いるとより簡単に導入できる。
上記のVSCodeはWindows版だが,WSLと連携する機能があるのでUbuntuからも簡単に起動できる。ただしそのためには拡張機能のインストールが必要。WSLが既に有効になっているとVSCodeを起動したときに導入を促す通知が表示されるのでそれに従えばよい。あるいは,PowerShellで以下のコマンドを実行するとより簡単確実に導入できる。
もしよくわからないときは上記の公式解説を参照。
VSCodeと拡張機能が正しくインストールされていれば,WSLのUbuntuからコマンド
でVSCodeが起動する。
[追記] 2025-10-16にv0.208.4でWindowsに正式対応したばかりのZedも動作が非常に高速かつ高機能でおすすめできる。Linux版をWSLのUbuntuにインストールするのではなく,Windows版をダウンロードしてインストールすること(WSLからの起動にも標準で対応している)。
WSLのUbuntuから起動するにはコマンド
を実行する。初回起動時にVimモードを選べば,普段Vimを使っている人にも使いやすい。また,clangdがインストールされていればそれを自動的に使うので,C言語のソースコードの編集が便利になる。
Ubuntuで~/.profileに以下の設定を追記しておくと実験の作業が格段に効率化できる。詳しくはこの資料の最後の付録2,3を参照。
設定は
を実行して即時有効にするか,あるいは次回WSL2のUbuntu起動時から有効になる。
gitコマンドを用いてダウンロードする。
カレントディレクトリにjikkenBというディレクトリがつくられる。このディレクトリ名は自由に変更してよい。実験の作業はすべてこのディレクトリに移動しておこなう。
シェルのパイプを用いてソースコードが実行ファイルにコンパイルされる各段階を確認する。また,中間言語の意義,クロスコンパイルと最適化の効果について理解する。
Pico-Cコンパイラpicocの構築
フィボナッチ数列を求めるPico-Cソースコード(C言語のサブセット)
中間言語(LLVM-IR)への翻訳
さらにLLVMのバックエンドllcを用いてx86_64のアセンブリ言語に翻訳
llc --versionで表示される様々なターゲットに対応している。
例:
さらにx86_64のアセンブラ(x86_64-linux-gnu-asあるいは短くas)を用いてx86_64のオブジェクトファイルfib.oを生成
さらにリンカー(x86_64-linux-gnu-ldあるいは短くld)を用いてランタイムルーチンやライブラリを結合して最終的にx86_64の実行可能ファイル(バイナリ)fibを生成。手動でリンカーを呼び出すのは面倒なのでclangコンパイラを経由して間接的にldを呼び出す。
-staticはライブラリを静的にリンクして単一バイナリーを生成するリンカーオプション(エミュレータで実行する時など単一バイナリーのほうが扱いが易しいため)。
オプション-vを付けるとclangが内部でリンカーを呼び出す様子が確認できる。
x86_64マシン上でネイティブ実行
最適化器(オプティマイザ)optを用いて中間言語を最適化する段階を挿入する。example/fib.pcは計算が速すぎて効果が分かりずらいので,代わりに2,147,483,647回ループするexample/loop.pcで試す。
これにopt -S -O2をパイプに挿入するとLLVM-IRのコードが最適化され実行ファイルの実行速度が著しく高速化される。
-O2は中程度の最適化を有効にするオプション。-Sはビットコードではなく人間に読めるテキスト形式でLLVM-IRを出力する指定。逆に最適化を無効化するには-O0を指定すればよい。
実際,
の出力をみるとループが消え,2147483647を出力するだけのコードに最適化されていることが確認できる。
clangはLLVMコンパイラ基盤を用いて作られたコンパイラなので,実はLLVM-IRのコードも受け付ける。Pico-Cのコードを実行可能ファイルにコンパイルするには,単にpicocとclangをパイプして以下のようにするだけでよい。
以下の課題1の作業ではこの方法を用いるとよい。
-は入力を標準入力ストリームから受け付けることを、-x irはそれがLLVM-IRのコードであることを示している。-Wno-override-moduleはターゲット組の上書き警告の出力抑制(警告表示が気にならなければ付けなくてもOK)。最適化オプション-O2もここで与えられる。
あるいはバックエンドに中間言語LLVM-IRのインタプリタlliを用いて直ちに解釈実行することもできる(実行可能ファイルは生成されない)。実験2ではこの方法が便利である。
ここまではx86_64アーキテクチャのマシン上でx86_64向けのバイナリを生成してきた(いわゆるネイティブコンパイル)が,ターゲットを指定して他のアーキテクチャのマシン向けのバイナリを生成することもできる(クロスコンパイル)。
64bit ARM(aarch64)用にクロスコンパイル
-target aarch64-linux-gnuで64bit ARMアーキテクチャのLinux OSマシン用にコンパイルするよう指定している。
aarch64用の実行可能ファイルであることを確認
エミュレータqemu-aarch64を用いてx86_64マシン上でaarch64用の実行可能ファイルをエミュレーション実行
Linuxには実行可能ファイルのフォーマットを認識してqemu-aarch64等を呼び出すbinfmt_miscという仕組みがあるので,設定次第で単に以下でも実行できるが,混同・誤解を避けるために上記のように明示的にqemu-aarch64コマンドを用いることをすすめる。
ソフトウェアでエミュレートするので,当然ながらネイティブ実行よりも実行が遅い。fib.aarch64はライブラリを静的にリンクした単一バイナリなので,このファイルをaarch64マシン(例:Raspberry Pi 3以降や,ARM CPUのMacやWindows PC)上のLinuxに移してネイティブ実行すればもっと高速になる(該当のマシンを持っている人はどのくらい速くなるか試してみるとよい)。
ターゲットをwasm32-wasiにしてWebAssemblyを出力することもできる.
WebAssemblyの実行環境であるwasmerをダウンロードし
これを用いて実行する。
次にwebブラウザでの実行を確認する。
でwebサーバを起動し,webブラウザでlocalhost:8000/にアクセスするとfib.wasmの実行結果が表示される。
コードexample/bench.pcをpicocでLLVM-IRにコンパイルし,さらに
clangでx86_64用にコンパイルした実行可能ファイルをx86_64マシン上で実行した場合clangでaarch64用にクロスコンパイルした実行可能ファイルをqemu-aarch64を用いて実行した場合clangでwasm32用にクロスコンパイルした実行可能ファイルをwasmerを用いて実行した場合で,実行可能ファイルの実行に要する時間を計測し比較せよ。それぞれ最適化を有効にして(-O2)生成した実行可能ファイルと無効にして(-O0)生成したものの両方についておこなえ。
レポートには実験環境(ハードウェア/ソフトウェアとも)と実際におこなった作業・手順を客観的,正確,かつなるべく簡潔に記述し、読者が実験を再試可能なようにせよ。また、結果は比較しやすいように表とグラフにまとめ,比較の結果について考察を付せ。
実行時間の計測にはtime(/usr/bin/time)コマンドを用いるのが簡単である。実時間(経過時間、あるいは壁時間とも呼ばれる)とユーザCPU時間を計測せよ.以下は一例(詳しくはman 1 timeでマニュアルを参照):
先頭のバックスラッシュ(\)はbashの内部コマンド版のtimeコマンドと区別するため。bashの内部コマンドのtimeでも機能は少ないが同様な計測が可能。
考察のヒント:実時間がユーザCPU時間より長くなることはあるか?逆に実時間のほうが短くなることはあるか?その場合、何故か?
実行毎に計測値は異なるはずである。10回試行した中の最良(最小)の値を採れ。
計測で得られた生データのすべてをレポートに記載する必要はないが,生データはすべて記録し、必要に応じて後で確認できるように整理した上で保存、提出せよ。
また,ターミナルでの作業は正確に記録しておくことが客観的で正確なレポートを作成するのに重要である。実験がうまくいかないときに作業を客観的に振り返り原因を探ったり,他の人や教員・TAのアドバイスを求めるとき等にも有用である。asciinemaを用いるとターミナルでの作業の正確な記録を簡単に残すことができる。asciinemaの記録ファイルは提出を求める。
asciinemaの簡単な使い方asciinema 3.0のダウンロード
収録
カレントディレクトリにファイルa.castが作られ作業が収録される。作業の無い経過時間(アイドリング)は1秒以内に切り詰められる(-i 1)。収録を終了するにはctrl-d。
既存のa.castに追記で収録するには--appendをつける。
複数のファイルに分けて収録し、後述のascinema catで後から連結してもよい。
再生
で記録a.castが(5倍速で)再生される。スペースキーで再生を一時停止・再開。再生中止はctrl-c。
URLを指定してサーバにアップロードされている記録(後述)も再生できる。
連結
でpart1.cast,part2.cast,part3.castをこの順に連結してひとつのファイルone.castにする。
asciinema.orgへのアップロード(公開)
でa.castをasciinema.orgへアップロードするとURLを知っている人なら誰でも再生できるようになる(1週間で消去される)。後でアクセスするには表示されるURL(例:https://asciinema.org/a/xxxyyyzzz)が必要。
現代の多くのコンパイラで用いられるLLVM中間言語(LLVM-IR)の基本を理解し,簡単なC言語のコードを手作業でLLVM-IRに翻訳できるようになる。
これはC言語の
に相当する。ret命令は関数から呼び出し元に復帰する命令である。i32はret命令の引数123の型が32ビットの符号付き整数であることを示している。@mainの前のi32も同様で、@main関数の返り値の型を表している。
LLVM-IRではグローバルな識別子には@をつけ、ローカルな識別子には%をつける。
LLVM-IRのコードを実行確認するときは実験1で学んだインタープリタlliを用いるのが簡便である。ファイルa.llにLLVM-IRのコードが保存されているとすると
のようにして解釈実行できる。実験1でみたようにlliは標準入力を受け付けるので,以下のようにすればいちいちファイルに保存しなくて済む。
この資料の事前準備をしておいた人は以下のようにすればコピペする手間すらいらない。
OSに123が戻されたことは以下のように確認できる。
LLVM-IRでは式は書けないので分解して計算したい順序通り並べる必要がある(これがLLVM-IRと高級言語との一番の大きな違いである)。よって、例えばC言語の
に相当するLLVM-IRコードは
ではなく
のようになる。
コンパイラの授業で出てきた4つ組(3アドレスコード) と同じだと思えばよい。
%t.1, %t.2は計算の途中の値に付けられた一時的な名前(仮想レジスタを表すローカル識別子)である。変数とは異なり値の変更はできない。上のコードを
のようにするのは間違いである(%tの値を再設定して変更しているから)。必ず他と重複しない新しい名前を用いる必要がある。%で始まっていればどんな名前でもよいが、以下では原則%t.に接尾辞として数字を付加して新しい名前をつくりだすことにする。
add (+), mul (*)以外にもsub (-), sdiv (/), srem (%)などの命令が用意されている。
LLVM-IRはC言語の関数を呼び出すことが可能である。C言語の標準ライブラリにあるprintf()関数を呼び出して標準出力ストリームに整数1234567を出力するコードは以下のようになる。
これはC言語のコード
にほぼ相当する。
LLVM-IRのコードの1行目は外部の関数printf()のプロトタイプ(引数や返り値の型等)を宣言している。printf()は可変引数なので第2引数以降は...になっている。2行目ではフォーマット文字列"%d\n"を用意している。6行目のcall命令は関数を呼び出す命令である。
ローカル変数を(スタックに)割り当てるには alloca命令を用いる。
は32ビット整数型のローカル変数をスタックに割り当てており、C言語の変数宣言
に相当する。%t.1は変数xへのポインタ(変数xが割り当てられている最初のアドレス)を表す。割り当てた変数への読み書きはこのポインタ(アドレス)を経由して行なわれる。
LLVM-IRコードの可読性を高めるために、以降では allocaの値の一時名には宣言したい変数の名前の最初に%を補ったものを使う約束にする(また,後のコンパイラ作成も簡単になる)。
変数に値を格納するには store命令を用いる。以下の
はC言語の
に相当する。
逆に、変数に格納されている値を読み出すには load命令を用いる。上記の変数xの値を読み出すには
とする。%t.1が読み出した値を表している。
alloca, load, storeを用いたまとまった例:C言語の
に相当するLLVM-IRのコードは以下のようになる。
以下のコードでは,scanf()を呼び出して標準入力ストリームから入力した整数値を変数xに格納している。
これはC言語のコード
にほぼ相当する。
scanf()も標準入力からデータを読み取るので,LLVM-IRのコードをコピペしてctrl-dしたあとで123を入力する。
もちろん一旦ファイル(例:a.ll)に保存しておいて引数としてlliに与えてもよい。
LLVM-IRと高級言語のもうひとつの大きな違いは、前者には後者のような構造化された制御構造が用意されていないことである。代わりにC言語のgoto文に相当する br命令(分岐命令)を用いて任意の制御構造を実現する。
C言語のコード
をフローチャートで表せば
これはフローチャートの矢印に相当するgoto文と矢印の指すボックスに付けられたラベル(L1〜L3)を用いて以下のように表せる。
元のコードにあったwhile文はなくなっている。またif文はgoto文と組み合わされた
という限定された形でのみ出現する。これを以下ではif-goto文と呼ぶ。
goto文
は
に翻訳される。一方、if-goto文
は式の値をa (aは仮想レジスタや整数)とすると
のように翻訳される。ここでicmp命令はa != 0なら(1ビット整数の)1を,そうでなければ0を返す。次の行のbr命令はこの%t.1の値をみて,1(真)なら%<ラベル1>へ0(偽)なら%<ラベル2>へと条件分岐する。
icmp命令にはne (!=)の他にもeq (==),slt (<), sgt (>), sle (<=), sge (>=)などが指定できる。よって上記はeqを用いて
でもよい(ラベルの順序が交換されているのに注意)。
このようにして最終的にコード全体は以下のように翻訳される。
上記のような制御フローグラフ(CFG)を用いるとLLVM-IRのコードの流れが分かりやすく可視化できる。CFGはLLVMのoptコマンドとGraphvizのdotコマンドを用いれば簡単に描ける。例えばファイルexample/prime.pcのmain関数の内容をCFGで描き画像ファイルprime.pdfとして出力するには以下のようにする。
シェルスクリプトを用意したので例えば
のようにするだけでよい。
画像の形式はpdfの他にpng、gif, jpg,svgなど様々な画像形式が指定できる。
この資料の付録3の設定をしておけば
のようにしてWSLのUbuntuから簡単にWindowsの既定アプリを開いて画像を確認できる。また,Windows Terminalのバージョン1.22以降ではimg2sixelコマンドを用いてターミナル上にpng,gif,jpg等の画像を描画できる。
フローチャートから忠実に実現した(すなわち矢印をgoto文で置き換えた)C言語のコードは
ようなコードの集まりになるはずである。よって,これから逐語訳で変換したLLVM-IRのコードも,CFGが示すように
ようなコード(基本ブロック)の集まりになる。
各命令の構文に間違いがないはずなのに自分の書いたLLVM-IRのコードが構文エラーになるようなときは,goto文/if-goto文を用いたC言語のコードに戻って忠実にフローチャートを実現しているか確認し,正しく基本ブロックになっているかチェックするとよい。
icmp命令の返す真偽値(0,1)は1ビットの整数である。真偽値a(aは仮想レジスタや0,1)を32ビット整数にキャスト(型変換)するにはゼロ拡張命令zextをつかって
のようにする。%t.1が結果として得られるi32型の値としての0,1を表している。
picoc.cではicmp命令で計算した不等号<,>,<=,>=の値を32ビット整数に統一するのにこれを用いている。
符号拡張命令sextを使うと真(1)は-1に変換されてしまうので注意。2の補数では1ビット整数の1は-1を意味するからである。
以下のC言語のコードdiv.cをLLVM-IRのコードに手作業で翻訳せよ。LLVM-IRのコードはコンパイル・実行して網羅的なテストを実施しdiv.cの正しい翻訳になっていることを確認せよ。
翻訳は以下の手順にしたがい段階をおって作業し,この各段階を含めレポートにまとめよ。
div.cをフローチャートを用いて表せ。矢印の指すブロックにはラベル(L1, L2…等)を記せ。(1) この課題は次回の実験3(コンパイラ作成・拡張)のための準備である。やみくもに翻訳するのではなく,機械(人間コンパイラ)になったつもりで系統的に(順序よく)作業するのが重要である。つまり最初にフローチャートを正しくつくれば後は頭をひねらずに機械的な逐語訳で進めるのが理想である。フローチャートとgoto文のコードの対応が不明瞭であったり,goto文のコードとLLVM-IRのコードの対応が不明瞭な答案は評価が低くなるおそれがある。
(2) テストは単に適当な例でやみくもに多くのケースを試せばよいというものではない。あらゆる場合をできるだけ広範にカバーするように各テストケースの選択は明確な意図をもってなされるべきである。少なくとも典型的な例と境界的な例(極端な例)を含むべきである。実施した各テストケースがどのような意図で選ばれたのかをレポートでは明確にせよ。
div.c
C言語の文法ではラベルの直後には必ず文が必要(空や宣言は不可)。どうしても空にしたいときや宣言で始めたいときは空の式文(
;だけからなる文)を使うかブロック{ … }にすればよい。もっとも、この課題ではそのような必要はないはずだが。
コンパイラフロントエンドのコード(picoc.c)を理解し,新たにif文をサポートする機能を実現する。さらに余力のある人は配列をサポートする機能も実現する。
Pico-Cは標準C言語の(ほぼ)サブセットである。扱うデータ型はint型だけで,他の整数型や浮動小数型はない。また,配列,構造体,ポインタ等の派生型もない。よって文字列もない。ローカル変数が宣言できるが,初期値は代入で設定する。代入は式文としてしか出現できない。制御構造はwhile文だけで,if文等はない。演算子も9個しかない(後述)。関数はmain()しかなく他の関数を呼び出す機能もない。このため,scanf()/printf()関数を呼び出す代わりに,標準入力ストリームから10進整数を読み込むscan文と標準出力ストリームに10進整数を書き出すprint文が追加されている。以上の文法をBNFと構文図式でまとめると以下の通りである。
Program:
Block:
Decl:
Statement:
Assign_st:
Scan_st:
Print_st:
While_st:
Expr:
Additive:
Term:
Factor:
構文図式はすべてこのサイトを用いて生成した。
標準C言語と同じく整数0が偽値falseを表し、0以外の整数(典型的には1)が真値trueを表す。式を構成する演算子は+, -, *, /, %,<, >, <=, >=の9個ですべて左結合の二項演算子である。このうち<, >, <=, >=は成り立てば1をそうでなければ0を返す。演算子の結合順位は標準C言語と同じである。
工夫次第でより複雑な条件式を表せる。以下c,dを値0か1を取る式とすると,条件cの否定「cでない」(!c)はc < 1と表せる.「cかつd」はc * dと表せる.よって,「cまたはd」はド=モルガンの法則を用いて((c < 1) * (d < 1)) < 1と表せる。あるいはより簡単にc + d > 0でもよい。
(不)等号==, !=は用意されていないが,a == bは(a <= b) * (b <= a)で表せる。よってa != bは((a <= b) * (b <= a)) < 1で表せる。あるいは
(a < b) + (b < a) > 0でもよい。
変数の宣言では初期化はできないので
は
とする必要がある。
代入は式文としての出現に制限しているので,例えば入れ子になった代入式
は記述できない。代わりに2文に分けて
のように書く必要がある。データ型はint型だけなので代入式の左辺は変数名を表す識別子しか出現しない。
while文は標準Cのそれと同じである。条件式の値が0でない間,反復する。反復される文が1つしかないときは,それを囲むブラケット{ ... }は不要である。
if文は用意されていないが,高々1回だけ反復するwhile文で模倣できる。具体的には
は
で実現できる。
も
のようにすればよい。短絡論理演算(&&, ||)はif文で
c = a && b
c = a || b
のように実現できるので上記のようにwhile文になおせる。
ディレクトリexampleにはPico-Cで書かれたコードのサンプルがいくつかある。
prime.pcとbinary.pcあたりをみるとPico-Cのだいたいの感じがつかめる。
ソースコードpicoc.c
picoc.cの概要ファイルpicoc.cはおよそ300行のC言語のコードで、5つのグローバル変数と16個の関数定義が含まれている。
各グローバル変数の役割は以下の通りである。
グローバル変数tokenは次に処理すべき字句の識別番号を常に保持している。またその字句の名前(文字列)はnameというグローバル変数に保持されている。字句解析器(scan.c)を呼び出してこれらを更新するにはget_next_token()という関数を呼び出す。
vsuffixやlsuffixは重複のない仮想レジスタ名やラベル名を生成するための添え字として使用される。使用する毎にカウントアップし,重複を避ける。hmapは宣言された変数の名前をコンパイル作業の間、いつでも参照できるよう保存しておくためのハッシュ表で,宣言のし忘れや重複宣言を検出するのに用いられる。
16個の関数のうち、12個は上述の12個の構文図式と1対1に対応しており、各図式にしたがって字句を読み進めながらLLVM-IRの命令列を(printf()関数を用いて)出力する。構文の解析が終了した時点でコードの生成も同時に完了している。このように構文解析とコード生成を同時におこなうコンパイラは1パス方式と呼ばれる。抽象構文木を生成しないので仕組みが簡単であり、単純な言語のコンパイラでしばしば用いられる。また、構文図式にそって各構文に対応する関数を(再帰)呼び出ししながら解析を進める方法は再帰下降型構文解析と呼ばれる。
残りの4つの関数はmain()と下請け関数である。
式の構文図式に対応する4つの関数
にnum(), lval()を加えた6つの関数は、呼び出されるときに文字列を格納するためのバッファ(char型の配列)を実引数として渡される(正確にはその先頭要素へのポインタを渡される)。復帰(リターン)の際にはこのバッファに式の計算結果を表す"%t.1"のような仮想レジスタ名かあるいは十進整数を表す"123"のような文字列を書き戻す。
このように結果を格納するためのバッファを呼び出し側で用意しバッファへのポインタを引数として渡すのはC言語の常套句である(例:scanf())。
変数の宣言(図式Decl)は関数decl()で処理される。まず,hget(hmap, name)を呼び出して変数が既に登録されていないかチェックし,登録済みの場合は重複宣言のエラーにしている。登録されていない場合はhinsert(hmap, x, row)を呼び出してハッシュ表に登録し,
でalloca命令を出力する。
変数名を表す文字列はdecl()関数から抜けた後も随時参照されるのでヒープに動的に割り付ける必要がある。
割り付けられた記憶域は,最後にhdestroy()を呼んでハッシュ表を解放する際にあわせて解放される(ので自分でfreeしないこと)。
左辺値(変数)の参照に関する処理は下請けの関数lval(char *x)として共通化されており,関数factor()やassign_st()から呼び出される。lval()は次に出現する字句をハッシュ表から検索し宣言済みの変数名であることを確認する(なければエラーにする)。確認した変数名の最初に'%'を補った文字列を、引数として与えられたバッファに書き込む(このような文字列の加工にはsprintf()が便利)。
約束により変数名の前に'%'を付けるとその変数を指すポインタ(アドレス)を意味するので、これをstore/load命令に渡せば変数を読み書きできる。代入文(図式Assign_st)を処理するassign_st()ではこれを用いてstore命令を出力している。
因子(図式Factor)を処理するfactor()関数ではこれを用いてload命令を出力している。
printf()の書式文字列の中で'%'そのものを表すには%を重ね書きして%%のようにすることに注意。
while文(図式While_st)に対応する関数while_st()では実験2(LLVM-IR)のwhile文の実現方法にしたがって、3つのブロックのためのラベルを用意する。
冒頭ブロック(while_entry)では,まず条件式の値eを計算するコードを出力する。この値はi32(32ビットの整数)である。次にこれが0かどうかによってwhile_endかwhile_bodyに条件分岐するコード
を出力している。
while_bodyの末尾では反復のために
で冒頭のブロックに無条件に戻る命令を出力している。
標準C言語と同じ(※)if文をサポートするようpicoc.cを拡張せよ。
(※)標準C言語のif文と同じとは
else以下は省略可能{…}も省略可能の要件を満足することを意味する。
以下の手順にしたがい作業しレポートにまとめよ。
picoc.cに系統的にコードを追加する(下記参照)。example/kadai3ディレクトリにあるbinary_kadai3.pcとprime_kadai3.pcが正しくコンパイル・実行できるか最終確認する。(1) やみくもに試行錯誤でコードを追加変更するのではなく,ステップ1でつくった構文図式に添って系統的にコーディングすること。そうすれば40行程度の簡単な追記作業で済む。
(2) テストは網羅的におこなうこと。ブラケットの有無,elseの有無,if文が入れ子になった例も忘れずに。特に,宙ぶらりんのelseが正しく解決されていることも確認せよ。
(3) レポートではソースコード全体を無駄に漫然と貼り付けるのではなく,ステップ2でコードを追加・変更した箇所(つまりオリジナルからの差分)が明確になるようにせよ。以下のコマンドの結果を利用するのが最も簡単である。
このコマンド出力では追記行が+で,削除行が-で明示される。さらに追加行は緑で,削除行は赤で色分けされる。もしも差分が思った通りに認識されないときは他の差分アルゴリズム(例:histogram)も試すとよい。
(4)課題文に「picoc.cに…コードを追加する」と明記されているにもかかわらずpicoc.c以外のファイルをつくってそれに追加しようとしてコンパイルに苦労している人が例年何人かいる。課題文通りpicoc.cに追加すれば,コンパイルは
とするだけで済む。
参考:キーワードifとelseの識別番号はscan.hの中で以下のようにマクロ定義されている。
注意:これは課題1〜3に取り組んだ人だけが提出できる余力と意欲のある人向けのオプション課題である.課題1〜3にやり残しがあると採点されない。また,再提出レポートでの追加提出はできない。
課題3に加え、int型の要素の(1次元)配列をサポートするようにpicoc.cを拡張せよ。example/arrayにあるサンプルコードが正しくコンパイル・実行できるか確認せよ。
配列をサポートしたPicoc-Cの構文は以下のようになる(変更のある箇所のみ記載)。
Decl:
Assign_st:
Factor:
Pico-Cはポインタをサポートしないので,式の中に配列名aが出現するときは必ずブラケットを伴ってa[...]という形で出現する。
配列の宣言もalloca命令でおこなえる。例えばint型の要素3つの配列aを宣言するには
とすればよい。%aは確保された配列を指すポインタである。
(参考)LLVMはバージョン15でopaque pointer (ポインタの指す先のオブジェクトの型情報を持たないポインタ,C言語でいえば
void *のようなもの)がデフォルトになったので,「配列全体の先頭を指すポインタ」と「配列の先頭要素を指すポインタ」は区別されない。アドレスとしては両者は同一だからである。
alloca命令が返す配列の先頭を指すポインタから各要素を指すポインタを得るにLLVM-IRのgetelementptr (GEP)命令を用いる。GEP命令は配列や構造体等を指すポインタ(アドレス)からその要素やメンバを指すポインタ(アドレス)を算出するための汎用的な命令である。
The Often Misunderstood GEP Instruction
LLVM's getelementptr, by example
例えば&a[2](= a + 2)を得るにはgetelementptr命令を用いて
のようにする。最初のi32がポインタの指す先のオブジェクト(要素)の型を,最後の2が要素2つ分ずらすことを指定している部分である。GEP命令の返すポインタ(アドレス)をload命令やstore命令に渡せば,通常の変数と同じように配列の要素の読み書きができる。
例えば
は以下のように実現できる。
%aと%t.1は同じアドレスなのでGEP命令
は冗長だが,&a[0]を&a[1]や&a[2]と統一的に扱うために敢えて入れた。
図式Assign_st,Factorの左辺値(変数)を参照する部分は下請けの関数lval()に共通化されているので,関数assign_st()とfactor()に手を加えるよりlval()に手を加えるほうが作業が簡単に済む。lval()とdecl()への追加・変更は合わせてもわずか20行程度である。
変数DEBUGをセットしてpicocをコンパイルしなおすと標準エラー出力ストリームに構文解析木を出力するようになる。
もとに戻すには
CFLAGSでDEBUGを定義する代わりにpetitc.cの中で
のコメントを外して有効化してもよい。
これでコンパイルの過程を(標準エラー出力ストリームに)ツリー表示するようになるのでデバッグに役立つ。以下はfib.pcを処理したときの出力の冒頭部分。どのように関数が呼ばれてどの関数でどの字句を処理したのかが分かるのでデバッグに役立つ。
実行例:
assert(式)マクロを挿入すると式が成り立たないときに実行を強制終了し、その行番号を表示できる。
例:
は、tokenの値が'+'でなかった場合に強制終了し、行番号を表示する。
assert()マクロを用いるには
を加える。コード完成後、assert.hを読み込むより前で
しておけば、assert()を無効化できる(無害なvoid式で置換される)ので、手作業でassert()式をわざわざ取り除く必要はない。
別ページを用意した。
WSLのファイルやディレクトリもexplorer.exeで開けるが,パスの仕様がWindowsとLinuxでは異なるのでやや不便である.~/.profileに下記の設定
を追記しておくと
のように深い階層にあるファイルやディレクトリもLinuxのパス指定の方法で簡単に開けるようになる.
~/.profileに以下の設定
を追記しておくとコマンドの出力をクリップボードにコピーしてWindowsアプリで用いたり,逆にクリップボードの内容をコマンドにパイプして利用したりできるようになる。
(別の方法) 上記の方法が上手く動作しない環境では、win32yank.exeというWindowsアプリ(リンク先のwin32yank-x64.zipをダウンロードして解凍すると得られる)をCドライブ直下
C:\に置き,~/.profileに以下の設定を書いておくのでもよい。
pbcopy/pbpaste/pbrunの使用例例:(Webブラウザ上で)クリップボードにコピーしたLLVM中間言語のコードをhello.llというファイルに保存。
例:(Webブラウザ上で)クリップボードにコピーしたLLVM中間言語のコードをインタプリタlliで実行。
例:(Webブラウザ上で)クリップボードにコピーしたC言語のコードをclangでコンパイルして実行し,実行結果をクリップボードにコピー(してWord/LaTeXなどで利用)。
例:(Webブラウザ上で)クリップボードにコピーしたPico-Cのコードをpicocとclangでコンパイル。
例:example/fib.pcをpicocでコンパイルし,その出力したLLVM中間言語をクリップボードにコピー(してワードやLaTeXで利用)。
例:(Webブラウザ等から)クリップボードにコピーしたコマンド行をターミナルで実行
例:最後に実行したコマンド行をクリップボードにコピー
例:最後に実行したclangで始まるコマンド行をクリップボードにコピー
クリップボードの内容のペースト/実行はセキュリティ上の潜在的なリスクが伴うので,
pbrunではいったん内容を表示しy/nで確認を求めるようにしている。