WSL2で「30日でできる!OS自作入門」に取り組む
環境
WSL2
Ubuntu 22.04.4 LTS
CDドライブはないので手元やオンラインにあるものだけで頑張る
あんま関係ないけど
VSCode
Zsh
参考サイト
これの中のリポジトリも参考にさせていただきました
低レイヤに関する筆者のレベル
電子工作を趣味にしてて、PIC(マイコン)のアセンブリでフルスクラッチで色々(SDカードライブラリなど)書いたことがある。
CやC++の低レイヤの言語も割と出来て、C++は競プロ用のデバッグツールを作った経験がある。
ので、アセンブリが何かとか、コンパイルされたときにどんな感じにアセンブリになってメモリはどう使われるかとか、Makefileが何かとか、そういう基礎的なことはもともと知ってた。
(あと論理回路の仕組みは回路レベルで知ってるけど、それは今回関係ないと思う?)
ただ、所詮PICのアセンブリで、パソコンのCPUの命令セットは触ったことがないのと、言語は知っててもOSの仕組みはほとんど知らないって感じです!
それでも、僕が分からなかったところを書いていく感じなので、説明が端折られていたらごめんなさいmm
リポジトリ
1日目
sec. 1
大分前にやったのでこの投稿日時は全然違うのと、アプリのインストールなどで端折ってる手順があるかもしれない…。バイナリエディタのリンクは切れてた?気がするので窓の荘のリンクを使う。他にもいろいろ試してみたがこれが一番使いやすかった。
プロセッサエミュレータQEMU(キューエミュ(ム))をインストール。
sudo apt install qemu-kvm
この中のqemu-system-i386
を使ってエミュレートする。
qemu-system-i386 -drive format=raw,if=floppy,file=helloos.img
普通にコマンドにファイル名を渡しても動くが、本と同じ環境を確実に再現するためにこの指定の仕方をする。まず、i386はiはインテルで、386は初めて32ビットを扱えるようになったCPU(インテルx86系列)(参考)。format
は指定しないとワーニングが出る。if
(インターフェース)はディスクイメージを入れたことにするドライブ。この時点まででCPUの指定はなかったかもなのでなぜインテルx86系列なのかという話があるが、後々本が32ビットの話をしているのでこれでいいんじゃないかと。-cpu
でCPUの型番が指定でき、公式ドキュメントによれば指定しなかった場合x86系列の全ての拡張が使えるようになるみたいだが、それは脆弱性があるのでやめた方がいいとのこと。でもこの本では恐らくどんなx86系でも動く命令しか使わないかなと思うのと、あまり本と違うことをして動かなかったら困るので、CPUの指定は一旦しないでおく。エミュレータはときどき失敗したように見えるが、ウィンドウの拡大とかリフレッシュさせると直ると思う。
とはいえqemu-system-i386
じゃなくて他のCPUのエミュレータでも動くみたい。その互換性に関しては後で調べたい。
ディスクへ書き込みをしましょう、みたいなのがあるが、これはやってない。
sec.3
CDが読めずnask
が使えないのでnasm
を使う。
sudo apt install nasm
NASMとはNetwide Assemblerの略でインテルx86を対象としたアセンブラ(Wiki)。アセンブリにディレクティブを加えることでCPUタイプを指定できる(公式ドキュメント)。こちらのCPUタイプも一旦指定しないでおく。デフォルトでは全ての拡張命令が使える。リポジトリのファイルとしてはhelloos2.nasm
で、以下でコンパイル、実行。RESB
命令はワーニングが出るのでTIMES
に置き換えてあるのと、他にも変えてるとこがあるがそれは参考文献を参照。
nasm helloos2.nasm -o helloos2.img
qemu-system-i386 -drive format=raw,if=floppy,file=helloos2.img
なお、VSCodeの拡張機能としてはNASM Language Supportと、CoolSpy3's Assembly Formatterを使っている。
レジスタの初期化にわざわざAX
を使っているのは、レジスタによっては定数初期化の命令がないからみたいだ。試しにAX
を0
とかにして保存すると、拡張機能に怒られると思う。
2日目
sec. 2
nasm helloos3.nasm -o helloos3.img
qemu-system-i386 -drive format=raw,if=floppy,file=helloos3.img
CD読み取れんのでブートセクタ以降のコードがどうアセンブリになってるかが分からん。と思ったけど、逆アセンブルしてもアセンブリにならんのでそもそも命令じゃないっぽい。まあ後でわかると信じよう。
ORG
疑似命令を若干勘違いしていたのだが、この疑似命令により0x7c00
に配置されるのではなく、0x7c00
に配置されるのは決まっているからそれをアセンブラに教えるための命令だとわかった。このおかげで$
による相対位置で指定ができる。
sec. 4
make helloos4
何やってるかはMakefile
参照。dd
コマンドはブロック単位でファイルをコピーするコマンド(参照)で、一つ目のdd
はただのコピー、二つ目はファイルサイズを1474560にしている。なお、空いた部分は0フィルされる(スパースファイル)。これを参考にstrace
してlseek
関数について調べればなぜ0フィルされるかわかると思う(参考)。ただ、lseek
関数ではファイルサイズが変更できないとのことだが、変更できてしまっている(stat
やdiff
を使って調べた)。これはバグなのか?もしバグだったら以下のように置き換えるといい。
# 2つ目のddはこうも書ける
dd if=/dev/zero of=$@ bs=1 count=1 seek=1474559
3日目
sec. 1-4
cd harib00*
make run
4つのフォルダはMakefile共通である。
sec. 5
sudo apt install mtools
cd harib00e
make run
Makefileが変更有、haribote.nasmが新規追加。
Makefileのmformat
はMS-DOSフォーマット(参考1、参考2)を行うものらしい。オプションは-f 1440
がファイルシステムのサイズを"1440K, double-sided, 18 sectors per track, 80 cylinders"(man mformat
参照) にする、-B
がブートセクタの指定、-C
がMS-DOSファイルシステムの載ったディスクイメージファイルを作る、-i $@
がフォーマットするファイルである。mcopy
の方は最後に::
がついているが、これはディスクイメージ内MS-DOSファイルシステム内のルートのパスだと思う。理由はman mtools
上で::ファイル名
みたいな例があるのと、mdir
やmmd
を使って調べてみたから。他のサイトだとmformat
の方でも::
をつけているが、mformat
の方はなくても動くので省略可能と判断した。mcopy
の方で省略するとバグる。ここら辺の説明がマニュアルで乏しいのはなぜなのか😭。意味としては、Linuxのファイルシステムと、フロッピーディスクのファイルシステム(MS-DOS)が違うから、mtoolsを使って読み書きしないといけないということだろう。
sec. 6
cd harib00f
make run
ipl.nasmとharibote.nasmに変更有。
sec. 7
cd harib00g
make run
haribote.nasmに変更有。真っ黒な画面が出てくると思う。なんか最初調子悪かったが、ALの他のモードを試したりしていたら直った。
sec. 8
cd harib00h
make run
haribote.nasmに変更有。
sec. 9
cd harib00i
make run
ipl.nasm以外は変更有。
gccのコマンドはこの記事の中のリポジトリを参考にした。-nostdlib
はシステムライブラリのリンクをしない、-m32
は32ビット環境用のコードを生成する、-fno-pie
はPIE(Position Independent Executable)にしないというオプションである。-T
はリンカスクリプトの指定で、このページを参考にしているみたい。このスクリプトの仕様は今度調べることにしよう。
sec. 10
cd harib00j
make run
nasmfunc.nasm追加、Makefile、bootpack.cに変更有。nasm
の-f elf32
はLinux向けi386のコードを生成するオプションである。どう違うのかわからないが、Windows向けにしても動いた。MacOS向けは動かない。ここに関しても後で調べたい。
4日目
sec. 1
cd harib01a
make run
bootpack.cとnasmfunc.nasmに変更有。
sec. 6
cd harib01f
make run
bootpack.cとnasmfunc.nasmに変更有。
sec. 7-8
cd harib01*
make run
bootpack.cに変更有。
5日目
sec. 4
以降コマンドは省略する。いつも通りmake run
でよい。また、今回からフォーマッタclang-format
を導入したのと、僕好みの書き方にちょいちょい変えてある。あと、unsigned char
とchar
が混在していたが、文字('a'
等)や文字列(const char *
)を表す場合を除きunsigned char
に統一した。なお、clang-format
はVSCodeの拡張機能があるのと、ルートの.clang-formatというファイルが設定ファイルである。ちなみにランゲージサーバーはclangd
が僕のおすすめ(VSCodeの拡張機能にあるので入れてみよう)。
(ちょいちょい思うのだが時代のせい(そもそも言語自体やmake等が高機能でなかったか、ノウハウが蓄積されてなかった)なのか何なのかわからないが、可読性や書き方などこの本のコードの書き方には思うことがある。ただ、いちいち修正してたら時間がかかるので気になったところだけ変えることにする。)
sec. 5
hankakuフォルダ追加、bootpack.c、Makefileに変更有。
本ではhankakuの配列のオブジェクトをつくってリンクしているが、ここではhankaku.cというCファイルを作ってリンクさせている。
sec. 7
libフォルダ追加、bootpack.c、Makefileに変更有。
lib/my_spintf.cはこちらを参考にさせてもらった。
sec. 9
bootpack.c、nasmfunc.nasmに変更有。
なお、勘違いしてしまうがこの時点では画面上の変化はない。
6日目
sec. 3
ただソースファイルを分割しただけ。cフォルダの中身とMakefileのみ変更有。
sec. 6
bootpack.c、bootpack.h、dsctbl.c、int.c、nasmfunc.nasm、Makefileに変更有。
学んだこと
今度からコミットログ貼るようにしようかな。
C言語自体は元から知ってるし説明することがなくなってきた。だったら学んだことを書こうかな。
割り込みはPIC(Programable interrupt Controller)という別チップで監視されてる。割り込みがCPUに通知されるとIN,OUTの信号線でどの割り込みかを通知する。PICはマスタとスレーブの二つを使って15個の割り込みを監視する。それぞれの割り込みで呼び出される関数の番地はIDT(Interrupt Descriptor table)に登録しておく。IDTはメモリに配置し先頭アドレスを専用のレジスタに登録しておけばよい。そしてIDTの設定のためにはセグメントの設定が必要。これはなぜかというとIDTの一つ一つのデータは関数の番地(正確にはオフセット、32bit)、セグメント番号(16bit)、割り込みを表すトークン?(16bit)で構成されているから(8バイト)。セグメント番号もGDT(Global (Segment) Descriptor Table)にIDTと同じように登録する。GDTの一つのデータはセグメントの大きさ(20bit)、先頭アドレス(32bit)、セグメントの属性(書き込み・実行禁止、システム専用など、12bit)で構成され、8バイト。セグメントの数は8192個で、GDTのメモリ上のサイズは8*8192=64kB。セグメントの大きさを表すビットは20ビットしかないので、1MB以上の場合は属性のGフラグを立て、ページの数として解釈させる。その場合×4kBのサイズになる。よく言うSegmentation Faultとか、ページとかってこれのこと言ってるんだろうな。なるほどねぇ。
IDTの一つ一つのデータはGATEとプログラム上ではなっていたが、これは後で説明があるのか。それともただの割り込みのGATEという意味だろうか。GATEの構造体の詳しい説明がなかったが後で説明があるのか読み落としたのか。
メモリ容量の確認は今回は書き込みを行って本当に覚えているかでチェックした。その際にキャッシュがバッファにならないようにオフにした。キャッシュの管理ってOSがやってるんじゃなくてハードウェア由来なのか。
割り込み時のレジスタの内容はスタックに記録する。そのとき、PUSHADといった命令があるが、これのクロック数を調べたい。あと、これは全てのレジスタを記録できているのか?
7日目
sec. 1
コミットログ
ちなみにこの後編集していたりするのでフォルダのその後のログを確認すると良い。
なお、これのついでにmy_sprintf.cをリッチにしておいた。
sec. 5
コミットログ
今後言わないが、今後のコミットもこの後編集している可能性がある。
sec. 7
学んだこと
マウスもキーボードも複数バイトのデータを送ってくるのでFIFOにためて処理する。なお、それぞれのbyteで毎回割り込みが発生する。マウスは3byteそれぞれで押しているボタンの情報、x、yの移動量を送る。押しているボタンに関しては押したときと離したときに割り込みを発生させているようだ。送るデータには特に離したという情報はつけず、今押しているボタンの情報を送る(割り込みの発生した回数を表示させて調べた)。キーボードは2byteでキーを押しているか離したかの情報とキーコードを送る(p142)。押している間ずっと割り込みが発生するみたい(割り込みの発生した回数を表示させて調べた)。確かに何も押してないのに押したままと勘違いするみたいな誤動作を考えるとこれがいいかもだ。と思ったら、キーによって違うようだ。Ctrlキーは押したときと離したときにしか反応しない。キーコードは確かにCtrlは2byteだが、他のキーは同じByteを重ねるときもある。そうか、これで長押ししてるということを示してるのかな。また、長押しフェーズに入るまで割り込みは発生しないから、長押しの時間とかもハードウェア側で処理しているっぽい。
マウスやキーボードで1byteずつ割り込みが発生しているのは最新のCPUでもそうなのか?
長押しの時間とかキーのリピート速度って確かOS側で設定可能だった気がするがそれはBIOSの設定か何かか?
確か普通はメモリはリンクリストで管理してるんじゃなかったっけ。
でもその場合順番に並べて管理するみたいなのは難しいな。そこはどうしてるんだろ。
free[]の4000個で十分らしいが本当だろうか。最近のOSはどうしてるんだろう。
11日目
sec. 2
コミットログ
Window外サポートって些細だけど興味深いな。シートで管理してるからWindow外を無限遠まで考えなくて良いという。
sec. 3
sec. 4
sec. 6
sec. 7
sec. 8
コミットログ
変更部分についてmapを更新してから書き込む。mapの計算量は変わらないが、mapを作り替えなくて良い場面(各シートのスライドがなく、不透明が透明になったりしていないとき。一枚のシートにバックグラウンドを書いてそのあと文字を書いたりしている場合など)はシート一枚分の書き込み量になる。mapを作り替える場面でも追加の書き込み量は書き換えなくて良いシート(変更があったシートより上のシート)が分かるので少ない。
学んだこと
やったこととしてはWindowを表示させてみたのと、mapなどを使い一回当たりの画面更新量を減らすかつレイヤが重なっている部分の再描画回数を減らしちらつきを抑える工夫。基本的に前回のグラフィックの続き。
計算量解析的に速度が怪しい。実際のPCもこうなっているのか確認。
13日目
sec. 2
sec. 3
harib10c
のコミットログ harib10d
のコミットログ
一つ目はフォルダ名がharib10f
となっているが間違い。
恐らくエミュレータのせいだと思うが、カウントのたびに画面更新をしないと実行速度が遅くなるという問題が発生した。毎ループで割り込みの禁止・許可をしているから、その周期があまりに短すぎることが問題ではないかと疑い、空forループを入れることで実行速度を回復させることができた。
sec. 4
コミットログ バグ修正
本ほど早くならなかった。まあforループで1万も回しているから当たり前である。
sec. 5
sec. 6
学んだこと
今までのアルゴリズムの改善を行った。マウスやキーボード、タイマのFIFOを一つに統一した。その際、それぞれのコードをシフトすることでそれぞれのデータを表していて、そのためにFIFOを1データ4byteに拡張した。タイマはリンクリストに変更し、ずらし処理をなくした。(あと番兵を使ってコードを短くした。)最近リファクタリングばかりだな。
STIやCLI命令はCPU上で割り込みを禁止して、PICは監視を続けるイメージ。割り込みがスタックした場合、STI命令が実行された瞬間に割り込みされるものと予想しているが違うのか。でなければQEMUが割り込み禁止・許可の周期でPITのカウントが遅くなる理由(QEMU上のPITはクロック数のカウントで動いてるという前提だが)が分からない。
15日目
sec. 2
sec. 4
sec. 5
コミットログ
例の問題が発生するので空for文を追加している。
sec. 6
sec. 7
学んだこと
JMP命令で飛んだ先のセグメントが実行可能セグメントではなくTSS(Task Status Segment)だった場合、CPUは全レジスタを今行っているタスクのTSSに書き込む。また、JMP先のTSSセグメントが保存しているレジスタの情報を全てロードし、(拡張)命令ポインタ(EIP)もその保存された値になる。TR(Task Register)というレジスタがあり、今行っているTSSを覚えるレジスタだそうだ。これはタスクスイッチング時に自動的に変更されるようで、マルチタスクの初期化時にだけ明示的にセットしていた。スタックも新しく用意してESPに入れる。自動で定期的なタスクスイッチングをするためには、定期的に割り込んで、割り込みハンドラの中でJMP命令を行う(次タスクスイッチングをして戻ってくる場所は割り込みハンドラ内のその次の命令である)。なお、ここで使うJMP命令はfar-JMPといって、CS(コードセグメントレジスタ)とEIPの両方に書き込む命令である。それと、今更気づいたがこのOSやC上ではアドレスが32bitで表される。なんかおかしいとずっと思っていたがそういうことか。
セグメントレジスタはコード用(CS)とスタック用(SS)とデータ用(DS)があるらしい(他にもあるがおまけらしい)。p134の記述によると「C言語では『DS(データセグメント)もES(エクストラセグメント)もSS(スタックセグメント)も同じセグメントを指している』という思い込み」があって、だからSSをDSとESに代入するそうだ。セグメントが違うデータにはアクセスしない前提なのか。
また、もしそうならSS=DS=ESが保たれているC言語の途中で呼び出された割り込み処理内でわざわざ代入する必要はないと思うがどうなのか。まあこれに関してはタスクスイッチング中などは異なる可能性あると思う。
と思ったが、どうやらセグメントの意味を完全に誤解していた。セグメントは4GBにアクセスするための手段だと思っていたが、32bitモードになった今セグメントはその手段ではなく、プログラムのアドレスを0からにリセットして使えるようにするためのものらしい。なんじゃそりゃ。だったらDS=ES=SSも納得できるな。(もちろんCSは一致していない。)
今回の場合、CS=2<<3、DS=ES=SS=1<<3で、どちらのタスク(マルチタスクの)もこれらのセグメントを使っている。1<<3は4GB全体を示すセグメント、2<<3はbootpack.sysがあるセグメントである。ここら辺の整理をしなければ。読み飛ばした8日目の32bitモードへの道の記述の中にヒントがあると思われる。
タスクスイッチング中のJMP命令によるレジスタの保存のクロック数が知りたい。
TSSの登録の際、limitを103としていたが、これはなぜなのか。104byteあるのにどういう意味があるのか。
ちなみにJMP命令の正体はEIPへのMOV命令らしい。ここら辺も整理したり、使えるレジスタについても学びなおしたい。