【日記】30日でできる! OS自作入門

book.mynavi.jp

筆者はそうしなくていいと書いていますが、律儀な性格なので1日1Chapter進めていきたいと思っています。 (4日目)一度で理解できる内容ではなく、2週したいので予定変更。

0日目 - 2018/03/31

本はWindowで開発するために書かれているので、macで開発できるよう環境を整える。

三等兵さんという方がmac向けの開発環境を整えてくれているのでそれを利用する。

github.com

こちらのQiita記事によるとエミュレータに問題があるらしく、そのままでは途中でつまずくとのことだったので記事の筆者がMakefileを修正したこちらのレポジトリをもとにセットアップする。(本家は2012年以来コミットがないから今のところフォークしてない。)READMEの通りにやってうまくいった。

環境:

  • macOS10.13.3 (High Sierra)
  • Homebrew 1.5.13

14日目につまづきポイントがあるらしいのでメモ。

参考

しかし三等兵さんのこのブログ記事、文章がすごい面白い。笑

1日目 - 2018/04/01

バイナリエディタ0xEDをダウンロードする。バイナリエディタを使うのは初めて。 本の通りに打ち込んでmake runすると無事Hello, Worldが表示された。

書いたものを手元のUSBにインストールしようとしたけどさすがにmacでそれはできないっぽい(batファイルを対応するシェルスクリプトに書き換えればいける?)。ちなみにmacでUSBをフォーマット(削除)する方法はここ

ここに沿っていけばとくに詰まることなく最後までできる。最後のアセンブリ言語のあたり本当に自分で書いたものが動いている実感がない...笑

ポイント

2日目 - 2018/04/02

今日は手を動かすことはほとんどなく、解説を読むのがほとんどだった。主にアセンブリ言語の読み方でその中でレジスタの紹介とかメモリのリテラルとかがでてきた。3章「アセンブリでブートセクタだけ作り、残りのディスクイメージはツールでつくる」の部分はまだしっくりきていない。またMakefileの入門もあり、Makefileについてはいつか時間を割いて習得しておきたい。

解説中心だったけどもCPUやレジスタ、メモリとの関係など大事なことが多かったのであとで見返したいChapterだった。

ポイント

3日目 - 2018/04/03

いきなり多いし難しい。2日目に導入したIPL(Initial Program Loader: 初期プログラムローダ)を改良し、ディスクから両面10シリンダ分読み出す処理の実装。ディスクの物理的な構造とそれを読み込んでいくイメージがついた。

次にOSの本体を記述し、ディスクイメージに保存。その後ブートセクタから実行する処理。よくわからない。 その後16ビットモードでしか動作しないBIOSの処理を行ったのち、32ビットモードに切り替えて(32ビットでのみ動く)C言語で記述していく。

一度で飲み込みきれないのでこれは2週目が必要な雰囲気だし、2週する以上は1週目はもっとスピードをあげて駆け抜けたい感じ。うーん。

ポイント

4日目 - 2018/04/04

  • Chapter 4 ~ 6

というわけで一日一チャプターの予定を変更。

よくある386、486というのなんとなく聞いたことがある程度だったけどもよく考えたらx86系でインテルの系列だった。C言語でforループを回してVRAMに値を書き込んでいって描画する。描画の仕組みを初めて知ってなるほどという感じ。はじめはアセンブリ言語の関数をCで呼んでいたがポインタを使って書き直し。今までで一番詳しくてわかりやすいポインタの説明だった。a[1]が*(a+1)のシンタックスシュガーというのにはびっくり。

Chapter5および6の内容はエグい。というかほとんど理解できなかった。フォントの定義や文字の表示はいいのだけど、セグメンテーションがさっぱり。やりたいことはマウスを動かすことで、そのためにGDT(global descriptor table)とIDT(interrupt descriptor table)というのを初期化しないといけない、という流れ。セグメンテーションはメモリの競合を防ぐための仕組みで、IDTは割り込みとハンドラのマップ、という理解でとりあえず大丈夫かな...?

ポイント

  • VRAMへの書き込み
  • ポインタ
  • フォントの設定と文字の表示
  • セグメンテーション
  • GDTとIDT
  • ヘッダファイル
  • 割り込み処理とPICの設定

5日目 - 2018/04/05

  • Chapter 7 ~ 9

どうやら鬼門は超えたらしい。

割り込み処理は次々とくるのですぐに抜け出さないといけない。ということで基本的には処理をバッファに積んで処理を返す。キーボード用のバッファ、マウス用のバッファでというように別々に作成しFIFOかつリングバッファで構築。

プロテクトモードはセグメントレジスタの解釈が16の倍数ではなく、GDTを使うようにするモード。つまり仮想アドレスを導入するということかな。よくわからないけどアプリケーションからセグメントを直接操作できないようにするモードらしい。 またOS開発の際にはメモリのレイアウトを設計する必要があって、これを最初に作っておくと順調に開発できるそう。初期設定は最低限のことだけをして早く割り込みを受け付けられるようにしないとハードウェアに割り込みが溜まって不具合を起こすらしい。

次に必要なのはメモリの管理で、空き領域を構造体で表現してリスト形式で管理した。ブロックのメモリが空いているかどうかをフラグで表現して並べてもいいのだけれど、配列/リストで管理したほうが省スペースで処理も一括でできるから速い。また配列よりもリストで管理したほうが挿入削除が高速にできていい。とのこと。その他初めて知ったこととして、CPUにもキャッシュがついており、わざわざメモリを読みにいかなくてもいい場合があること、プログラムの9割以上はループで消費されていることなどがあった。

ポイント

  • 割り込み処理とバッファ
  • プロテクトモード
  • メモリ管理
  • CPUキャッシュ

6日目 - 2018/04/06

  • Chapter 10 ~ 12

最初は画面描画について。ウィンドウが重なった時にどう効率よく表示するかの話。まずいとすごい時間がかかったりチラつきが発生したりするのでうまく必要な範囲だけを更新する。この辺りは正直デバイスの機能なので一番理解したいUNIXカーネル的なところとは関係ないんだけど、実際にOSを作ろうと思ったら一番成果が見やすいところだなーとも思う。

最後はタイマーの話。CPUは200MHzとかで動いているわけだけど、PITという補助的なデバイスから指定した時間おきに(100Hzとか)割り込みが入るように設定してOS側でカウンタ制御して時間を測る。また割り込みハンドラに処理を積んでおくことで「指定時間後に処理」するタイマを設定することもできる。

読んでいて改めてちゃんと動作するOSを作ってみたいなという気になった。

ポイント

  • 重ね合わせの描画処理とチラつきの解消
  • タイマの設定

7日目 - 2018/04/07

  • Chapter 13 ~ 15(途中まで)

昨日に引き続きタイマーの改善。デバイスごとに設定していたタイマ用のFIFOバッファを一つにまとめた。さらにforループごとにアップするカウンタを置いてベンチマークをとるようにする。さらにFIFOをLinkedListに変更して配列要素をずらす処理をカット、配列後端に番兵をおくことで空配列や最終要素への追加などをスッキリさせた。そういえば同時に読んでいるUNIX V6の本でブロックデバイスのバッファに使うbfreelist要素も番兵の役割をしているなと思った。

14日目は画面を高解像度にする処理。ビデオカードがなんであるかがよくわからなかったけど、高解像度かどうかを調べて対応していたらそれに応じて画面のモードを変更する。またキー入力と文字とを対応させて画面に文字を入力、さらにはカーソル描画も追加してラインエディタの完成。さらにさらにマウス入力を認識してウィンドウの移動描画。すごい。

15日目はマルチタスクマルチタスクは「分身の術」という表現がしっくりきた。コンテキストスイッチの頻度は30Hz~100Hzらしい。この頻度とスイッチにかかるクロック数でCPU時間の何パーセントをスイッチにかけているか計算できる。そしてこのスイッチにかかる時間というのは概ねレジスタからメモリへの書き出しとその逆の読み込みにかかる時間といっていい。

15日目の続きはまた明日。

ポイント

8日目 - 2018/04/08

  • Chapter 15(続き) ~ 18

昨日の続き。taskAとBで交互にタスクスイッチを読んで0.02秒ごとに切り替える。またカウンタを表示してタスクが切り替わっていることを確かめる。さらにタスクから明示的にスイッチを呼び出すのはかっこ悪いのでタイムアウト時に切り替えが起こるようにする。

次にタスクを構造体として定義して管理する。現状はタスクAはHLTするだけの一方Bはひたすらカウントアップしているのに50:50でCPU時間を使っているため効率が悪い。タスクAはスリープするようにし、割り込み発生時にwakeupするよう修正。

次に優先度の仕組みを導入し、優先度に応じてCPU時間が割り当てられるように修正。タスクAはほとんど何もしない一方割り込み時は切り替えなしで動いて欲しい(マウス操作とか)種類のタスク。こういうタスクが同じ優先度になったら先にCPUをとったほうが思う存分使えてしまうので、優先度レベルを導入して同じレベル内で切り替えが起こるようにする。最後にHLTするだけのidleタスクを番兵として優先度最低でおくことで、タスクAは他にタスクがあるかどうかに関わらずスリープできるようにする。

残りはコンソールを作る作業。入力の切り替え、文字・プロンプトの表示、記号(shift)・大文字小文字(CapsLock)の入力、改行・スクロール処理、最後に特定の文字列("mem","cls","dir")で改行された際に対応するデータを表示する、コマンドをいくつか作成し終了。たった1,2日でかなりシェルっぽいものができていてすごい。

ポイント

  • タスクを定義する構造体(Unixのプロセス構造体?)
  • スリープとwakeup
  • 優先度と優先レベル
  • コンソールの作成とコマンドの実行(.exeの実行はまだ)
  • ディスク上のファイル情報の位置取得

さて、6日目で「実際に動くOSを作ってみたい」と思って思いついたのは「xv6をRaspberry piに移植する」というもの。本当にできるかどうかわかんないけど調べたものをメモしておく。

すごい面白そうだけどできるかどうか不安。

9日目 - 2018/04/09

  • Chapter 19 ~ 21

今日もガツガツ進む。ついにアプリケーションをファイルに作成し実行する処理。はじめにdirコマンド(cat in Unix)でディスクからファイルの内容を読み出す手順に慣れる。その際FATというUnixでいうスーパーブロックかな?の情報から読む方法も学ぶ。ファイルの中身を読めるようになったらそれを表示するのではなく実行することでアプリケーション実行処理の完了となる。

Chapter20ではAPI(システムコール)の作成をする。最初にアプリから1文字表示用の関数を呼び出し、OS内であらかじめ番地を割り振っておいた呼び出しに対応する関数を呼び出す方式をとる。その際アプリとOSではセグメントが異なるので通常のCALL命令ではなくfar-CALLというセグメントを切り替える呼び出し(およびそれに対応するfar-RET)が必要。しかしこの方式だとOSを書き直すたびに関数のアドレスが変わってしまい、アプリケーションをコンパイルし直さないといけない。これを解決するのは呼び出し関数をあらかじめ登録しておく方法で、それは割り込みによって実現される。ここでなんでシステムコールがトラップによって実装されているのかの理由が与えられたわけだ。引数で呼び出すAPIを指定できるようにすることで一つのIRQ登録で全てのAPIコールに対応できるようにする。Chapter21ではさらにアセンブリAPI呼び出し関数を用意して、C関数でコールできるようにしC言語でアプリを書く。

21日目のメインの内容はOSの保護で、アプリがOSが管理するメモリにアクセスできないようにする。そのためにアプリ用のセグメントを用意してOSセグメントと区別できるようにする。本来はこのOS-アプリ間のセグメント切り替えはOSの仕事なのだが、今時の(?)CPUはOS保護機能が付いておりこの切り替えを代わりにやってくれるらしい便利。その際保護例外が発生した時にCPUから割り込みが送られるのでそれに対応する処理を追加してあげる。最後にセグメント定義でアプリ用かOS用かの値を設定してあげることで、アプリがレジスタにOS用セグメントを代入することを防いだ。

システムコールやOS保護などの今時必須な機能の実装はとても興味深かった。Unixと共通する部分が多くあった気がする。

ポイント

  • ディスクからファイルの中身をFAT情報を元に読み出す処理
  • APIの作成と割り込みによる呼び出しのハンドリング
  • OSの保護と例外処理

10日目 - 2018/04/10

  • Chapter 22 ~ 24

C言語でアプリをガンガン書いていく段階。はじめに昨日やったOS保護の仕組みのおかげで「アプリがOSを呼び出すにはAPIを使うしかない」ことを確認、また例外処理をリッチにすることでバグを踏んだ際に必要な情報が残るようにする。強制処理機能を追加することでアプリを開発する際OSから必要なフィードバックか返ってくるようにする。

本題はC言語を使ったアプリの作成。OSにやってもらわないとけない作業はAPIとして定義していく。ウィンドウ、文字列、点の描画、線の描画、ウィンドウの削除、キーの入力を次々にAPIとして追加していく。その過程でデータ領域の確保だったりメモリ割り当ての関数を定義したりもする。

24日目にはウィンドウをWindowsのように操作できるよう改造してよりOSらしい、使い慣れたUIへと進化させていった。最後にタイマようのAPIを作成してアプリから利用できるようにした。

ポイント

  • 例外処理と強制終了
  • データ領域の確保とメモリ割り当てAPI
  • APIの作成と呼び出し

11日目 - 2018/04/11

  • Chapter 25 ~ 27

OSとしての機能が充実してきたのであとはアプリを作り込んでいくだけのよう。アプリを2つ同時に出すためにコンソールを増やす改造をしたり、ウィンドウを高速化するために書き込み命令が4の倍数になるようにしてバルクで処理させるなどの最適化をする。またコンソールを好きに増やしたり消したり、アプリを新しいコンソールで実行したりコンソールから切り離して実行したりする処理を追加する。

これまではOSのセグメントとアプリのセグメントという分け方しかしていなかったため、あるアプリから他のアプリのデータセグメントを操作したりといったことが可能であったが、タスクごとにLDT(Local Descriptor Table)を割り当てることで自分のセグメント以外への書き込みを防ぎ、アプリを保護する仕組みを追加する。

最後にAPIやその他の関数をライブラリとしてまとめることで不要な関数をアプリから取り除く。またライブラリやその背後にある構造化プログラミングの概念にも触れる。

ポイント

  • 描画処理の高速化
  • LDTとアプリの保護
  • ライブラリ

12日目 - 2018/04/12

  • Chapter 28 ~ 31

Chapter28でははじめにスタックを4Kバイト以上割り当てられるように修正。そしてメインであるファイルの読み出しAPIを作成。最後に日本語フォントを用意、全角に合わせて描画の修正も行って日本語表示ができるようにする。

残りはひたすらアプリを作る。途中で標準関数を整備したり、ブート時にセクタを一気に読み出す高速化を行う。

これで1週目は終わりだけども、最後に書いてあるように「テキストエディタの実装」「コンパイラアセンブラ、リンカの移植」「 HariboteOS内でアプリを作成」そして最後の「OSのセルフホスト」と夢が広がった。次、2週目。

ポイント

  • ファイルの読み出し
  • 標準関数
  • セクタをバッチで読む処理

13日目 - 2018/04/13

  • Chapter 0 ~ 2

さて2週目。はじめにやっていたのはディスクイメージを直接バイナリで書いて作ることで、それは手間がかかるので次はアセンブラで書く。最初はDB命令しか使わないのでやっていることはバイナリでやったことと大きくは変わらない(Chapter1, 2を通してバイナリの内容は変わらないけど書き方としてという意味で)。Chapter2で本格的にアセンブラの命令を使う。いくつかの命令を動かすためにORG 0x7c00でメモリ上の位置を指定したり、より簡潔に書くためにレジスタの説明と使い方、メモリから値を読む方法を学んでいく。文字列表示のためにBIOSプログラムの呼び出し命令INTについての説明もある。

次にこれまでフロッピーディスク1440KB分全てアセンブラで書いていたものを、ブートに必要な1セクタ512バイト分だけ書くようにし(これをIPLと呼ぶ)、残り(今はほとんど0が並ぶのみ)をディスクイメージ管理ツールでがっちゃんこするように修正。

最後にMakefileに軽く入門して終了。

2週目ということもあり最初に読んだときよりだいぶ理解が進んでいい感じ。

14日目 - 2018/04/14

  • Chapter 3 ~ 4

2週目、だいぶ調子がいい。昨日はブートセクタにhello, worldの表示だけをさせていたが、ブートストラップらしく次のセクタを読み込む処理を入れる。やることはシリンダ、セクタ、ヘッド、(デバイス)それから読み出し先のメモリ番地を指定してBIOSを呼び出すだけ。読み出し先はメモリマップをもとに自由に決めていい。

ディスク読み出しをしたらブートの仕事はOSのエントリポイントにジャンプするだけ。OS本体を別のファイルに保存してコンパイルすると、そのファイルがイメージ内のどこに書き込まれるかをバイナリエディタで確認できるので、それがメモリ上のどこに展開されるかを計算しブートセクタからジャンプしてあげるようにすればブートは完了。

CPUははじめ16ビットモードで動いている。16ビットモードだと32ビットモード用のレジスタや命令が使えない。一方C言語コンパイルすると基本的に32ビットモードで動くような機械語に翻訳されてしまう。つまりC言語で開発を進めるにはCPUを32ビットモードに切り替える必要がある。 ここで一つ困ったことにBIOSは16ビットモードのCPUでしか動作しない。よってBIOSを必要とする処理(キーボードの状態取得など)はCPUを32ビットモードに切り替える前に完了しておかなければいけない。ここではBIOS関連の処理としてハードウェアの情報をメモリ上に読んでおく。

切り替えが完了したらあとはCで書いていける。アセンブラでしかかけない部分(32ビットモード切り替え前)をasmhead.nasに書き、それ以外をbootpack.cに書いていく。そしてこの二つはコンパイル時にcat asmhead.bin bootpack.hrb > haribote.sysというように単純に連結される。そのためasmhead.nasの最後はbootpack:というラベルで終わっている。切り替え後にアセンブラでしか書けないものはnaskfunc.nasに書いてobjファイルに静的にリンクする。 この辺りそれぞれのツールの役割の理解がまだ曖昧な感じ。

Cで始めにすることはメモリへの書き込み。これは代入命令(MOVとかDBとか)をCでどう書くか、という話。最初はMOVするだけの関数をアセンブラで書いてCから呼ぶ。しかし実はこれを簡潔に、Cだけで書けるようにするのがポインタという機能である。p番地にaという値を書きたいときに*p = a;だけでいい。なるほど便利。さらに驚きなのはp[0]という表現は*pの糖衣構文だし、p[i]*(p+i)と等価である(同じ機械語)。これはもう配列に対する見方が全然変わってしまう。

ここまでくればあとはポインタといくつかのアセンブラ関数でVRAMがマッピングされたメモリ番地に適当なカラーを代入するだけで画面に模様や絵を書くことができる。

15日目 - 2018/04/15

  • Chapter 5 ~ 6

この本で一番難しい部分、GDT/IDTに差し掛かる。どちらもCPUへの設定が書いてある表であり、メモリ上に展開されている。GDTはセグメントに関する情報で、これは物理メモリを切り分けたブロックに関する情報である。このセグメントのおかげでメモリに管理属性(アクセス制限やモード)をつけたり、セグメントを使う側が物理アドレスを知らなくていい状態(仮想アドレス)を実現できる。このGDTを表現したらあとはCPUの特定のレジスタにこの表の場所とサイズを書き込めば準備は完了。IDTは割り込みに関する表であり、割り込み時にどの関数を呼び出すかが書いてある。CPUへ設定する方法はGDTと同じ。メモリ上のどこにGDT/IDTを置くかは自由に決めていい。GDTのセグメント番号1にはメモリの全域、2にはbootpack領域が割り当てられる。 セグメンテーションは「メモリ(例えば4GB)の分割」という考え方で、ページングは「タスク毎にメモリ全域4GBが割り当たっているとする」という考え方。 ちなみにCPUがシステムモードで動いているか、ユーザモードで動いているかは実行しているコードがあるセグメントがシステム用かユーザ用かで判断している。

IDTの設定が完了したので次はPICを初期化して割り込みを有効にする。IMR(各割り込みの有効フラグ)、ICW(配線情報、マスタスレーブ接続情報)、ICW2(IRQと割り込み番号の対応) の各レジスタを設定する。次にICW2に設定した割り込み番号に対応するハンドラを書く。注意として割り込みハンドラの場合普通の関数と違いRET命令では返れないので割り込み専用のIRETDで返るアセンブラ関数でC関数をラップしてあげる。最後にこのアセンブラ関数をIDTに登録すれば割り込み時に呼び出されるようになる。

このほかC言語での構造体の書き方((*a).ba->bが等しいことがわかればよさそう)や、コード分割とヘッダファイルの整備でコードを綺麗にする方法も学ぶ。

16日目 - 2018/04/16

  • Chapter 7 ~ 8

今日はアルゴリズム中心の内容。FIFOをリングバッファで実装する。この利点は取り出しの際に配列要素を前にずらす処理をしなくてもいいところにある。マウスに関する設定は細々としたものが多いが、それが済んだらキーボードと同じ処理で入力を受け取れるようにする。最後に入力に合わせてマウスが動くようにすれば完成。やはりマウスが動くというのはかなり気持ちがいいものである。

Chapter8の残りはChapter3でやった32ビットモードへの切り替えの際のアセンブラの解説。ここはちょっと難しいので手順だけを箇条書きしておく。

  1. 割り込みを禁止にしてキーボードの設定を変更。メモリを1MB以上使えるようにする。
  2. 適当なGDTを作成し、CR0レジスタのフラグを変更。「ページングなしのプロテクトモード」で動くようにする。プロテクトモードはセグメントを使うためGDTの設定が必要。パイプラインをフラッシュするためにジャンプ命令を入れる。
  3. ブートセクタ、読み出した残りのディスクデータ、そしてOS本体をあらかじめ設計しておいたメモリマップに合わせてmemcpyする。
  4. OSファイルのヘッダーを解析して必要な位置にデータをコピーする。

最後のポイントとしてブートの際はハードウェアが割り込みを溜め込まないようにできるだけ早く割り込みを受け付けられるようにするべきであるとのこと。

17日目 - 2018/04/17

  • Chapter 9 ~ 10

気がついたら30日も半分を折り返していた。Chapter9の内容はメモリ管理。はじめにメモリの容量を調べる。「書き込み→上書き→読み出し」で期待した値と比べてあっていたら次のメモリをチェックする、という方法をとる。32ビットモードに移行しているのでBIOSに聞くことができない、というのが主な理由だがなぜこれでうまくいくのか、なんでこんな回りくどい方法なのかよくわからない。この際キャッシュメモリが邪魔になることがあるので無効にしておく。(このチェックをCで書くとコンパイラの最適化により期待した機械語が得られないのでアセンブラで書く。)

メモリ管理は管理マネージャが空き情報構造体を配列で持つ形で行う。配列はフラグで管理する方法と比べて場所を取らないし処理が速いが、取得の際の要素を前にずらす処理や解放の際の左右のブロックと結合する処理が必要になってやや複雑さが増す。メモリは1バイト単位でも管理することができるが、それだとフラグメントがたくさんできてしまい取れる連続した領域が小さくなってしまうため、4KB単位で管理することにする。

Chapter10の大部分は重ね合わせと移動の描画だけどもあまり興味がないので割愛。

18日目 - 2018/04/18

  • Chapter 11 ~ 12

はじめにウィンドウの表示。差分更新などでチラツキがなくなるよう描画処理を最適化する。

今回の目玉はタイマーの設置。タイマのハードウェア設定(割り込み間隔など)をして有効化すれば一定時間の経過を割り込みとして受け取ることができる。ハンドラ内で変数をインクリメントすればカウンタの実装になるし、タイマ構造体を定義して一定時間後に構造体のfifoに(構造体に設定した)データをputするようにハンドラを更新すればタイムアウト機能を実装することができる。これでカーソルの点滅などの機能が実装できる。さらに割り込み処理が最短で済むように最適化して今日は終わり。

19日目 - 2018/04/19

  • Chapter 13 ~ 14

昨日に続きタイマ。カウンタでベンチマークを取れるようにし、性能の改善をしていく。はじめに割り込みの種類毎にあるFIFOを一つにまとめる。それぞれの入力(キーボードなら文字毎)で書き込まれるデータを定義してしまえば割り込みの種類とその値を特定することができる。これによりそれぞれのFIFOを見なくてもいいので高速化される。次にタイマの配列をリストで管理することでずらしを無くして改善、さらに番兵を置いて分岐をシンプルにしてさらに改善。

明日のタスク管理に備えて残りは画面の高解像度化と文字の出力、マウスを使ったウィンドウの移動をやって終わり。

頭では理解している(つもり)だから書き写すだけだとちょっと淡々としていていまいち身についてない感じがする。画面やマウスの試行錯誤だったりパフォーマンスの改善は実際に自分で考えて実験してみる経験をしないと本当の意味で学べなさそう。。

20日目 - 2018/04/20

  • Chapter 15

今日(昨日)は天気が良すぎたので公園で寝そべっていたら1章しか進めなかった。

15日目はマルチタスクの導入。切り替え間隔は100HzとかCPU時間の1%以下とかにするのが基本らしい。マルチタスク機能はCPUにサポートされており、具体的にはGDTにTSS(Task Status Segment、タスク状態セグメント)を登録すればいい。TSSの中身は主にレジスタで、タスクスイッチの際のレジスタの退避先になる。大事なのはEIP(Extend Instruction Pointer)レジスタで、次に実行が始まった時に開始する命令の番地を書き込む先になる。(ちなみにJMP命令はEIPへの代入命令であり次の実行番地を更新する処理である。)

タスクスイッチの際、通常のJMP命令ではセグメントを跨いでジャンプすることができないため、EIPとCS(Code Segment)レジスタを同時に切り替えるfar-JMP命令を使わないといけない。CPU側ではfar-JMP命令の際、受け取ったCSがTSSだった場合(タスクスイッチの時は切り替え先TSSの先頭番地)にタスクスイッチと判断して通常とは違う(たぶんレジスタの切り替えとかも行う)JMP命令を実行する。またこのとき受け取ったEIPの値は無視される。

タスクスイッチの際TRレジスタという、現在実行しているタスクを格納しておくレジスタの更新も行う。TSS設定の際にエントリポイントとなる関数の番地を取得してEIPに代入したり、切り替え先用のスタックを確保してESPに代入する方法も押さえておく。

タスクBからタスクAに戻ったり、0.02秒毎にタスクを切り替えたりする。またスタックを使ってタスクBに引数を渡す方法も学ぶ。これはC言語の仕様である、引数がESP+4に置かれることを利用している。ちなみにESPの位置には関数の戻り先がpushされており、ここに適切な値をいれておけばタスクのエントリポイントでreturnしたときに思い通りの場所に帰ってこれるようになる。

最後にタイマ割り込みハンドラ内でタスクスイッチを勝手に行うことでよりOSらしいマルチタスクを実現する。このときタイマのセットや割り込みの処理はハンドラ内で行うのでfifoを使わなくても「この割り込みはタスクスイッチだな」と判断することができる。

実はまとめは次の日にやったほうが定着度がよさそうだな...と感じつつおわり。

21日目 - 2018/04/21

  • Chapter 16 ~ 18

昨日の残りも含めて3つ分。20日目に感じたことを鑑みて今日の内容については復習を兼ねて明日書こうと思う。


マルチタスクの続き。3つ以上のタスクを手軽に動かせるようにタスク配列を管理するTASKCHL構造体を導入。セグメント番号のことをセレクタというらしいので覚えておいたほうがよさそう。task_initを改造してTASKCTL構造体の初期化。ここが面白いのだけれどもtask_initにより、この関数の呼び出し元も0番目のタスクとして管理されることになるので自身を表すタスク構造体が戻り値として返ることになる。

タスクAは割り込みの処理がメインで、それ以外の時間はhltしているだけのためCPU時間を使うのが勿体無い。sleepを導入してhltの間タスクBを動かせるようにする。sleep関数内では呼び出したタスク自身がsleepに入るときに切り替え先のタスクにfarJMPするのがポイント。割り込みハンドラ内でFIFOに値をputした時にsleepタスクを起こす処理も忘れずに追加する。

次に優先度の導入。優先度の値がそのままタイマにセットする時間になる(例えば優先度2なら0.02秒ごとに切り替わる)ので、優先度に応じてCPU時間が割当たるようになる。ここで問題なのは優先度が同じ場合どちらが実行されるかは運次第になってしまうため、マウス操作と音楽再生のように優先度が高いものがぶつかったときにユーザビリティが下がってしまう。これを解決するためにタスクレベルというのを導入して、同じレベル内でのみタスクスイッチをかけるように変更する。よってオブジェクトの関連としてはTASKCTL構造体が複数のタスクレベルを管理し、それぞれのタスクレベルが複数のタスクを保持するという形になる。最後に一番下の優先度にhltするだけのアイドルタスクを番兵としておくことで処理をシンプルにしてマルチタスクは完成。

残りの時間はコンソールを作る作業。コンソールができればなんでもできる気がするしワクワクする。といってもやることは淡々としており、表示の切り替え、文字の入力、FIFOを使ったシグナル(みたいなもの)の送信、Enterの対応、コマンドの作成と進んで行く。mem,cls,dirのコマンドを作成して昨日は終了。

22日目 - 2018/04/22

  • Chapter 19 ~ 20

最初はtypeコマンド(Unixのcat)の作成。ファイルの中身がディスク上のどこに存在するかはFILEINFO構造体のクラスタ番号から計算できるのでそこから読み出す。このままだとセクタ毎にデータを読んでしまい最悪他のファイルを読み出し始めてしまうので、FAT(file allocation table)情報を利用して続きの内容がどのセクタに収められているかを調べてたどるようにする。FATには総クラスタ個数の要素が並んでいて、それぞれの要素には次の内容が入っているクラスタ番号が書かれたリレー方式となっている。FAT自体はディスクのフォーマット(FAT形式のディスクならセクタ番号××にFAT情報がある、みたいな)。多分。

章の最後に読み出した内容をset_segmentでセグメント化、その先頭番地にfarjmpすることでアプリを実行する処理を実装する。Unixでいうexecの処理。

アプリの実行ができたのでついにAPIの実装に入る。やりたいことは実行したアプリケーションからcons_putchar関数を呼び出せるようにし、アプリからコンソールに文字を出力する。ここでAPI関数へのアセンブラ命令CALLを使うが、JMP命令との違いはスタックにレジスタをPUSHするかどうかのみである。これによりCALL先でRETした際に呼び出し元に帰ってこれるようになる。実際にはアプリケーションとコンソールのセグメントが異なるのでただのCALLではなくセグメントを跨ぐfarCALLを使う。同様にCALL先から戻るときもfarRETであるRETFを使ってセグメントレジスタの更新も行う。実際にはAPI利用側からC関数を直接呼ぶのではなく、アセンブラのラッパー関数を呼ぶようにする。これはC言語の仕様として引数はスタックを使って受け渡しをするため、アセンブラからC関数を呼び出すときも引数をレジスタからスタックに移しておく必要があるためである。最後にアプリケーションの終了に対応するため(アプリ終了後にコンソールに帰ってこれるように)コンソールはアプリをfarJMPではなくfarCALLで呼び出し、アプリも終了後にRETFで呼び出し元に帰るようにする。

このままだとOSを書きなおすたびにAPIであるアセンブラ関数のメモリ番地を調べてアプリを書き直さなければならず、相当不便である。そこで「OSに関数を登録しておく仕組み」である割り込みアセンブラ関数を登録する。これによって固定の割り込み番号でINTを呼び出せば登録したアセンブラ関数が呼び出されるようになる。アプリを書き換えなくていい。すごい。ちなみにINTで呼ばれた関数から呼び出し元に帰るときはRETFではなくIRETDを使わなければいけないのと、割り込み処理で呼び出し元のレジスタが書き換わると不便なので最初にPUSHAD、最後にPOPADを呼んでレジスタの内容をスタックに退避するようアセンブラ関数も修正。

今はAPI関数毎に割り込みを定義しているがそれだとIDTの数が足りなくなってしまうので、どのAPIを呼ぶかを引数にして一つのINT命令ですべてのAPIが呼べるように修正する。対応するアセンブラ関数ではPUSHADによりレジスタを退避しているのでC関数では全てのレジスタの中身を受け取ることになる。APIの種類に応じて別個のC関数を引数が入っているはずのレジスタを引数に呼び出せばよい。

23日目 - 2018/04/23

  • Chapter 21 ~ 22

まずはC言語からAPIを使えるように引数をスタックからレジスタに移してINT 0x40をするアセンブラ関数を定義。

今日の本題はOS保護の話。セグメントレジスタの退避や更新をアセンブラで書く部分が難しい。はじめにアプリに専用のデータセグメントを設定し、それ以外のセグメント(例えばOS)にアクセスできないようにする。OSとアプリの間でセグメントを行ったり来たりするので頭が混乱するところだが、流れは 1) アプリのスタック上にある関数への引数をOSのスタックにコピー 2) セグメントをOSに切り替える 3) システムで動作する関数のコール 4) アプリ用レジスタの復元 という感じ。一般保護例外用の割り込みハンドラを定義して例外をキャッチできるようにする。

今の設定だとアプリがセグメントレジスタを勝手にいじれてしまうので、セグメントのアクセス権を設定してこれを防ぐ。この設定をするとOSがアプリをfarCALLで起動することができなくなってしまうので(理由は謎らしい)アプリからCALLされた状態になるようスタックを操作し、RETFによりアプリに返るふりをして起動する。これによりアプリ終了時にRETFで返ることはできなくなるので終了APIを用意する。スタックの切り替えはCPUが勝手にやってくれるようになるのでAPIのコールや割り込み処理は簡潔になる。割り込み命令はアプリのコードセグメントが設定されていると使えなくなってしまうので、API割り込みだけはアプリから呼べるようIDTに設定する際にフラグを立てる。このCPUの保護機能によりアプリが使える命令の多くは制限がかかり、アプリからOSを呼び出す方法は実質INT 0x40APIコール割り込みに限られる。

いくつかの例外割り込みハンドラを設定して問題が起こった時に原因を表示できるようにする。さらにアプリの強制終了をbootpack側で実装。これはアプリ起動はコンソールのFIFOに入力を入れても反応しないため。最後に実行ファイル.hrbのフォーマットの解説と、アプリ起動時にフォーマットを読んでデータをデータセグメントにコピーする処理を追加。 いくつかのAPIを定義してウィンドウを表示できるようになったら今日は終わり。

24日目 - 2018/04/24

  • Chapter 23 ~ 24

今日はひたすらAPIとそれを使ったアプリを作る。新しいことはそんなにないので割愛。

ウィンドウの移動とタスクの切り替えがあり割と大掛かりな変更なのに加えてあまりエキサイティングな作業とは言えないけど、ウィンドウ操作が洗練されるとぐっとOSっぽく見えるなという印象。

25日目 - 2018/04/25

  • Chapter 25 ~ 26

今日はひたすらOSらしい動作の追求。コンソールをいくらでも出せるようにしたり画面移動を高速化したり新しいコンソールでアプリを起動と、コンソールなしでアプリを起動を追加したり。新しい内容が出てくるわけではないので簡潔におわり。

26日目 - 2018/04/26

  • Chapter 27 ~ 28

今の状態ではアプリはOSのセグメントにアクセスすることはできないが、他のアプリのセグメントにアクセスすることは可能でありこれを防がなくてはいけない。GDTと同様にLDT(Local Descriptor Table)という仕組みを利用する。これまではアプリのセグメントもGDTに登録していたがこれをLDTに登録するとタスクスイッチの際にLDTRレジスタが更新され他のLDTセグメントへのアクセスができなくなる。

次に今ではAPIは全てa_nask.nasファイルに記述されているためAPIの一部しか使わない場合でも全てインクルードされている。APIをそれぞれのファイルに分割し必要なものだけリンクすることでアプリサイズを小さくする。しかしこれだとどのアプリがどのAPIを利用するかをいちいち調べてビルドしなければいけないため不便。そこで登場するのがライブラリである。ライブラリはオブジェクトファイルをまとめたものであり、ビルド時にこれ一つ指定すれば必要な関数だけがリンクされるという便利ツールである。合わせてAPIのヘッダファイルも作ることでMakefileおよびC言語アプリの先頭がかなりすっきりする。

Chapter28のはじめにC言語の仕様で呼び出される__alloca関数を定義してスタックの動的拡張に対応する。これによりこれまでmallocで回避していたような巨大なローカル変数を扱える。__allocaの実装の際ESPを変更するためRETでは返れなくなるので注意。スタック上にある呼び出し元へ直接JMPすることで解決する。

ファイル取得機能はすでにtypeコマンドを作っているのでそんなに難しくはない。また日本語表示とシフトJISEUCの実装があるけどもそんなに興味もないので割愛。

27日目 - 2018/04/27

  • Chapter 29 ~ 31

最終日、と言ってもやることはアプリを作ることなのでOSには直接関係ないから終わり。とてもいい本だった。特にChapter31に書かれている以下の部分は響いた。金言としてこれからのプログラマ人生におけるモットーにするまである。

...最初からOSを作ろうと思わないこと、これはかなり重要です。それに加えて、気に入らないところはあとで直せばいいや、と思うことです。なんならあとで全部作り直したっていいんですよ。最初から完璧にしようと思うと、本当にまったく前に進めなくなります。

明日からは実際に何か動くものを作っていこうと思う。楽しみ。