先週のポストで、プライベートの学習がうまく進められていないことを書いた。不思議なもので、言語化してアウトプットしてみたら気が楽になったのか、ここ一週間はだいぶ調子がよくなってきている。
で、先月来少しずつ読み進めている The Elements of Computing Systems にそって、アセンブラを実装したので、この記事ではそれを紹介しようと思う。
TL;DR
レポジトリを公開している。
sato11/the-hack-assembler (https://github.com/sato11/the-hack-assembler)
利用するテキスト
The Elements of Computing Systems は Nand2Tetris という愛称でも親しまれている、コンピューター・サイエンスの著名な教科書である。
基礎回路の設計から初めて、論理演算、CPU設計、機械語、アセンブリ、コンパイラそしてOSと、演繹的な手順でコンピューターを実装する過程が演習として読者に与えられる。
コンピュータシステムにおけるアセンブラの位置付け
アセンブラを実装するのは上記テキストの第6章になる。これに先立つ第5章までにハードウェアの論理回路の実装は終えられており、ソフトウェア層の実装の嚆矢として与えられる課題がアセンブラということになる。
アセンブリ言語で記述されたコードをCPUに理解可能な機械語に変換するアセンブラこそ、ソフトウェアとハードウェアの架け橋となるソフトウェアだという位置付けが与えられている。
なぜ Go を選んだか
テキストでは API 仕様書だけが与えられ、実装についてはどの言語で行ってもいいと指示されている。つまり C で実装してもいいし、 Ruby で実装してもよかったわけである。
アセンブラの実装、と書くと高いハードルに感じられても、本質的にはテキスト処理プログラムにすぎない。手慣れた Ruby で実装してもよかったのだが、特に実装スピードを重視したい訳でもないし、手癖で実装しても勉強になるだろうかという思いがあった。
他方で Go については、以前より文法を学んで簡単なプログラムを作ったりしていたが、
- まとまりのあるパッケージを作成したことがない
- 文字列やストリームの処理をやったことがない
という未熟さを、アセンブラのテキスト処理の実装を通して克服できそうな期待があり、これを選択した。
実装
API の外形的な振る舞いは仕様としてテキストに与えられているので、それをひとつずつ実装していけばそれでよかった。API の命名、入力、出力の型が明確に仕様化されているため、命名に呻吟する必要はないし、 Go らしいテーブル駆動テストで進めやすく、非常にやりやすい。一部入力チェックでエラーが起こりうるようなところだけエラーを返すような設計に変更したが、 API ごとにテスト駆動でガシガシ進められるのは快感であった。
またモジュールレベルで十分にテストができたおかげで、それらモジュールを呼び出すメインパッケージのコーディングもだいぶ楽に進められた。逆にメインパッケージのテストの粒度やテスト項目に何を定義すべきかで迷ってしまうくらいであった。ほとんどの入出力がモジュールレベルでテスト済みのとき、呼び出し元でもう一度統合テストのようなものを書くべきなのか? とか、もし統合テストを書くのであれば、テーブル駆動テストではなく、もっとそれに適したやり方があるのではないか? などの迷いである。
とはいえ、単体の API をテスト駆動で開発するサイクルをいくつも繰り返す形で、わかりやすい進捗を残しながら開発できたため、プライベートの開発にしてはずいぶん効率的に進められた満足感はある。
動作
レポジトリの README に書いた通りにはなるが、テキストに沿って次のような動作確認をしている。
- テキストが提供するアセンブリコードを自作アセンブラに入力し、出力を保存する
- テキスト付属のアセンブラに同じアセンブリコードを入力し、出力を比較する
例えば、 testdata/Add.asm
に配置した足し算プログラムを機械語に変換するには
the-hack-assembler < testdata/Add.asm
とファイル内容を標準入力として与えることを想定している。
反省点
不器用なストリーム処理
仕様上、入力されたストリームを二回舐める必要があるのだが、 Ruby の IO#rewind
に相当する API が Go にはなさそうで、仕方なくストリームを先頭から読み直すためだけに入力を文字列として内部に保持してしまっている。
具体的には、入力されたストリームを ioutil.ReadAll()
で全行スキャンした上で、それを文字列として内部に保持しつつ、 parser.Reset()
が呼び出されるごとに新しい io.Reader
として定義し直すことで、複数回読み取ることができるようにしている。
しかし例えば数万行のアセンブラを変換するとなったときに、せっかくストリームで受け取れる入力をわざわざ文字列に変換して、プログラムが終了するまでずっとメモリにのせておくのは効率が悪いなあと思っている。
コードレビューを受けたかった
上記の件のみならず、基本的なグッドプラクティスもよく知ることができないままに Go を書いてしまっている。メンターとして頼れる知人がいないため、独自実装で進め切ってしまったが、できればコードレビューを受けながら進めたいところだった。
終わりに
以上、アセンブリ言語のコードを機械語に翻訳するプログラムを Go で作成した話を書かせてもらった。
学部生向けのテキストにのっとって実装しているわけだが、そもそもアセンブラってなんだっけ? というレベルから改めて知識を整理した上で、実装まで行うことでその知識を血肉化するという意味で、非常に有意義な勉強ができたと思っている。
情報系専攻の学生は学部時代にこんなことをやっているんだなあ、というのがわかるのもよい。もっとも、学ぶ意欲においては並の学部生を圧倒しているはずと自信を持って、無意味なルサンチマンは持たないようにする。
Go についていうと、最初の10行を書く上ではゆっくりと手探りしながら着手したが、通常のテスト駆動開発のサイクルに軌道を乗せてからあとは、非常に楽に進められた。標準パッケージの知識が少ないため、例えば文字列の簡単な操作にしても逐一ドキュメントを見ながらの開発になったわけだが、そのせいで劇的に作業が遅くなったとは思わない。テストを書くために RSpec のような独自 DSL を学ぶ必要がないのも魅力的だし、とてもいい印象だけ得られた。
強いていえば、上述した IO#rewind
の件のように、 Ruby の API はやっぱり開発体験優位に設計されているのだなあというのは感じた。しかしまあ、これは別言語が別 API を備えているというだけの当たり前の話で、特に優劣の判断材料とするには及ばない。
今回学んだアセンブラ以後の章ではバーチャルマシンとコンパイラ、そして OS を作成することになっている。それらも同じように Go で開発してみたいなと思っている。