借り初めのひみつきち

仮ブログです。移転先募集中

妖精さんとなかよくなるほうほう

年初まで x64 や WebAssembly で Rust を色々いじってましたが、少しお休みして x86 でコードを書いてみようと思います。

動かす環境はベアメタル ʕ•ᴥ•ʔ になりますが、 Rust はベアメタルの適切なターゲットがありません。 CPU さえ合ってれば OS はどうでも良さそうだったのでメジャーなターゲットでいくつか試してみました。 しかし、どうやらターゲットごとに適切なリンカーが必要になるようです。

リンカーに困ったら LLVM

ということで、 LLVM をインストールして LLD を読み込むように指定しました。

具体的には .cargo/config に

[target.i586-unknown-linux-gnu]
linker = "/opt/homebrew/opt/llvm/bin/ld.lld"

みたいな記述を加えます。 これ、環境依存ですよね・・・

いかがだったでしょうか?

ということで、 ELF 形式のバイナリ出力できるようになったのでローダーを作るための準備をします。

Program Header と Section Header

ELF には Program Header と Section Header というよく似た2種類のヘッダーがあります。何が違うでしょうか?

ELF というのは Executable and Linking Format の略で、アセンブルコンパイルの出力である .o ファイルも、それらをリンクした実行ファイルも両方サポートしちゃう規格だぜということになります。

Section Header は主に .o ファイルの状態で使うヘッダーで、セクションの役割ごとに細かく分かれています。 Program Header はリンクされた実行可能ファイルだけに存在するヘッダーで、 Section Header に比べると実行時にメモリに読み込むための最低限の情報だけにまとまっています。 つまり、通常の ELF ローダーは Program Header だけ見れば OK ということになります。

Section Header では別のセクションに分かれていても、アドレスがすぐ近くにあって属性が同じ場合 Program Header では同じセグメントにまとまっていることがよくあります。

Program Header には、セグメントの属性、ファイル上の位置とサイズ、メモリ上のアドレスとサイズ、などの情報があるので、それらをパースして実行に必要なアドレスにセグメントの内容をコピーしていきます。

リロケーションがなければこれだけでロードできます。簡単ですね。

GOT - 神ではない

ELF には GOT というテーブルがあります。これは PIC/PIE というリロケーショナルなコードの実行に必要な ELF 特有のデータ構造となります。

アーキテクチャによっては GOT がないとまともに動かせない場合もあったりした気がしますが、 x86 で配置固定のコードを書く場合は全く必要ありません。 むしろ GOT へアクセスするために無駄な機械語が生成されてメモリ消費的にも実行速度的にも無駄です。

ということで、 GOT 使わないコードに書き換えましょう。

.cargo/config に

[build]
rustflags = ["-C", "relocation-model=static"]

のような記述を追加すると配置が完全に固定されたオブジェクトになる代わりに GOT を一切使わなくなります。

配置アドレス決まってるしこれでいいのです。

おめーのせきねぇです?・ヮ・

このようにして ELF さんと仲良くなりましたが、そもそも ELF って割といろんなメタデータが含まれていてバイナリが大きくなります。 デバッグには便利なメタデータですが、実行時にはセクションの配置情報とコード・データセグメントの内容以外必要ありません。 今回のターゲットは割とメモリをタイトにしたいので無駄なメタデータは削減したいです。

ということでバイナリを変換します。

objcopy でバイナリに変換した場合はメモリ上に展開したイメージそのまま出力されて ELF の状態から失われる情報量が多すぎます。 それに実はセグメントの隙間のパディングとか bss もそのままバイナリに出力されるので効率もあまりよくないです。 一方、 strip した場合は ELF として汎用的な情報が多く残っているのでもうちょっと削減したいです。

折半案として独自のツールを作ってセグメントの配置情報と内容だけ別ファイルに抜き出して独自のバイナリ形式にすることにします。 何年か前に似たようなことやったな・・・

ロードに必要な情報をまとめてみましょう。

セグメントの属性

全てのセグメントが読み書き実行可能でその他のメタデータなどがない場合は不要ですが、今回は念の為最低限の情報を残します。

ファイル上のセグメントデータの位置

ファイル上のどこからセグメントの内容を読み込めばいいのか判断するために必要ですが、 最初のセグメントがヘッダの直後につづき、2つ目以降のセグメントは以前のセグメントデータの直後に続いてるというルールがある場合は不要です。

ファイル上のセグメントデータのサイズ

実際に何バイトコピーすればいいのかわからないので必要な情報です。

メモリ上のアドレス

メモリ上のどこに配置すればいいのかわからないので必要な情報です。

メモリ上のサイズ

bss のように初期値 0 のデータを含む場合はファイル上のセグメントサイズとメモリ上のセグメントサイズが異なるので必要になりますが、 全てのセグメントが連続していてファイルヘッダーで bss のサイズがわかる場合、この情報は不要です。

アライン情報など

仮想メモリを使ってストレージ上にスワップする場合、リロケーションをする場合などにセグメントのアライン情報が必要になる場合があります。 現状必要ないし、今後も必要になる可能性が低いのでカットします。

自分の足を撃ち抜く方法

以上のように必要な情報だけを抜き出した単純なヘッダに変換したバイナリができました。 いよいよローダーを作ります。

世の中には ブートローダーをほぼ Rust だけで作ってしまう強者 もいるようです。 しかし、低レベルすぎる操作は高級言語だと逆に使いづらい面もあるかと思ったので、ローダー部分はアセンブリで書くことにしました。

    movzx edx, byte [ebp + N_SECS]
    lea ebx, [ebp + OFF_SECHDR]
    mov esi, edx
    shl esi, 4
    add esi, ebx
.loop:
    mov al, [ebx]
    and al, 0x07
    jz .no_load
    mov ecx, [ebx + S_FILESZ]
    jecxz .no_load
    mov edi, [ebx + S_VADDR]
    rep movsb
.no_load:
    add ebx, SIZE_SECHDR
    dec edx
    jnz .loop

結構シンプルですね。 出来上がったバイナリを自作 PC エミュレーターで動かしてみます。

f:id:neriring16:20210119232112p:plain

これで x86 のベアメタルで Rust が使えるようになりました💪

ぬるぽ警察24時

myos では Null Pointer Exception は発生しません。

理由は2つあって、言語に Rust を採用しているというのと、 Null Pointer Exception が発生するようにページングを設定していないからです。

事件編

Rust には Null Pointer Exception によく似た別のエラーがあります。

それは、 Option<T>None に対して unwrap() することで発生する panic です。

Rust では unwrap() はお行儀の悪い方法なので基本的に他の手段を検討するべきです。 しかし、言語仕様上変数や戻り値を Option にしないといけないことがしばしばあり、本来 None が返ってくることはないのでハンドリングが面倒で unwrap() を使ってしまいます。*1

そして、想定外の現象が起きた時に、本来起こり得ないはずの panic が発生します。

最近それが発生しました。 WebAssembly で特定の条件を満たすと current_thread の current_personality を取得する処理で発生します。

WebAssembly のシステムコール呼び出しをするとき、システムコール関数はランタイムのインスタンスを持っていないので current_thread の current_personality から取得する必要があります。 システムコールを呼び出すのは WebAssembly の内部だけなので current_personality (Option<Box<dyn Personality>>) が設定されているはずです。

しかし、現実には稀に None を返して unwrap() に失敗しました。

解決編

さて、 current_thread を知っているのは誰でしょうか?もちろんスケジューラです。

ただし、ちょっと注意が必要です。

myos のスケジューラーは SMP に対応していて、それぞれのコアが別々のスレッドを実行しています。 つまり、コアごとに実行中のスレッドを管理する必要があります。

現在実行中のスレッドを調べるには、現在実行中の CPU コアの ID を取得する必要があります。 そして、コア ID からコア個別のデータを探して現在実行中のスレッドを特定する必要があります。

以前の myos ではこの処理で割り込みを禁止していませんでした。 それによってどんなことが起きるでしょうか?

現在実行中の CPU コアを特定してコア個別のデータから現在実行中のスレッドを調べる一連の処理の途中でたまたま割り込みが発生した場合、コンテキストスイッチが発生してスレッドキューに戻されることがあります。 そしてスレッドキューに実行待ちのスレッドが多かったりたまたま別のコアがコンテキストスイッチをした場合、最初に実行していたコアとは別のコアでスレッドが復帰する可能性があります。

これらの条件が重なった時、現在実行中のコアとは別のコアのデータを読み出し、現在実行中のスレッドを誤判別するという現象が発生します。

色々な偶然が重ならないと発生しないので確率は低いですが、割り込みやコンテキストスイッチは一秒間に何回も実行しているのでいつでも発生する可能性があります。

今までこのバグが発覚しなかったのは、そもそも現在実行中のスレッドを取得する処理がそこまで頻繁に実行されなかったためです。 WebAssembly から頻繁に API 呼び出しをして画面を書き換えるテストアプリの実行中にやっと発見されました。

教訓

そもそも現在実行中のスレッドを取得するだけの処理で割り込み禁止したり実行中のコアを調べるのは少々複雑すぎな気がしませんか?

毎回割り込み禁止にするのは良くないので、スタックポインタから逆算できたり通常変更しないレジスタから取得できるようにした方がいい気もします。

*1:似たような事例として Result の unwrap もあります

myos の描画アーキテクチャ

myos のウィンドウ描画アーキテクチャについて解説します。

執筆時点での情報なのでバージョンによっては詳細が異なる場合があります。 なお、ここにあるのはカーネルの構造なので、アプリケーションレイヤーではラッピングしたオブジェクトなど詳細は異なります。

クラス名やメソッド名が違いますが myos の基礎となった moe も類似した構造になっています。

Bitmap

Bitmap は画像だったりバッファだったり画面そのものだったり myos が色々な場面で利用しているグラフィックスオブジェクトで、サイズやピクセルの配列などの情報を持っています。 Bitmap に対する描画命令もたくさんあります。

また、ウィンドウバッファの内容場合、枠線やタイトルバーを除外したビットマップビューという特殊な状態の Bitmap を扱うことがあります。

WindowManager

WindowManager はウィンドウ制御に関するマネージャクラスで、以下のようなメンバーを持っています。(一部抜粋)

main_screen

実際のスクリーンに相当するビットマップオブジェクトです。 ブートローダーから渡された EfiGraphicsOutputProtocol の値を使って初期化しています。

    main_screen: &'static Bitmap,

off_screen

実際のスクリーンと同じサイズのビットマップでオフスクリーン描画に使います。 myos ではアルファブレンディング処理のために一旦全てのウィンドウを重ねて描画する必要があるため(現在はほぼ全てのウィンドウが影付きのため)、いったんこのバッファで合成して描画します。

    off_screen: Box<Bitmap>,

実装上のポリシーとして、現状このバッファは複数のスレッドから同時に描画する可能性があり、一時的に画面が乱れることがあります。(半透明のウィンドウが濃く描画されるなど) この解決方法はいくつか考えられますが、実装コストや速度低下の見返りに画面が一時的に乱れる現象を許容しています。

sem_redraw

sem_redraw にシグナルを送信するとウィンドウマネージャがアクティブになります。 主にマウスカーソルを移動した際の再描画に使われます。

    sem_redraw: Semaphore,

root window

他の全てのウィンドウの最背面に位置するウィンドウでいわゆるデスクトップです。 ウィンドウヒエラルキーのルートになります。

    root: Option<WindowHandle>,

Rust の言語仕様上現行は Option で定義されています。

pointer window

マウスポインターの描画に使われるウィンドウです。他の全てのウィンドウよりも最前面に表示されます。

    pointer: Option<WindowHandle>,

Rust の言語仕様上現行は Option で定義されています。

ウィンドウヒエラルキーとウィンドウレベル

画面に表示されているすべてのウィンドウはウィンドウヒエラルキーという階層を持っていて、ウィンドウの表示・非表示はウィンドウヒエラルキーへの追加・削除と同義になります。 デスクトップウィンドウが再背面、マウスポインターが最前面で、それ以外のウィンドウはその中間にあります。

また、全てのウィンドウはウィンドウレベル (WindowLevel) を持っていて、レベルの高いウィンドウの方が前面に表示されます。 ウィンドウレベルが同じ場合、後から表示された(ウィンドウヒエラルキーに追加された)ウィンドウの方が前面に表示されます。

一般のウィンドウは WindowLevel::NORMALWindowLevel::FLOATING のどちらかを選択することができます。システムが管理している特殊ウィンドウではそれ以外のレベル (WindowLevel::ROOTWindowLevel::POINTER など) もあります。

WindowHandle

WindowHandle はウィンドウのハンドルで、どのウィンドウに対する操作なのかをウィンドウマネージャと他のプログラムの間で情報交換するために使います。 WindowHandle の実体はただの整数値です。

RawWindow

RawWindow は実際のウィンドウ管理に使われているオブジェクトです。 アプリケーションから直接アクセスする方法はありませんが、 WindowHandle が有効なウィンドウを指している場合にウィンドウマネージャ内部で RawWindow に変換して処理します。

ウィンドウの内容は bitmap というメンバーに描画され、その他ウィンドウの位置やサイズなどに関する情報も持っています。

ウィンドウの描画は3ステップあります。

フレームの描画

システムが必要と判断したときに RawWindow.draw_frame() で枠線やタイトルバーを描画します。

ウィンドウ内容の描画

アプリが WindowHandle.draw() などを呼び出すと RawWindow.bitmap から枠線やタイトルバーを除外したビットマップのビューを返却されるのでその中でウィンドウ内容を描画することができます。

画面への反映

WindowHandle.set_needs_display() を呼び出してイベントループが WindowMessage::Draw を受け取った場合、 WindowHandle.set_needs_display() を呼び出した後に WindowHandle.refresh_if_needed() を呼び出した場合、 WindowHandle.draw() を呼び出した場合、 ウィンドウ本体を移動した場合、 ウィンドウの上にあるウィンドウの状態が変わってシステムで再描画が必要と判断された場合などに画面に描画します。

最終的に RawWindow.draw_to_screen() の中で RawWindow.draw_into() を呼び出して RawWindow.bitmap の内容をオフスクリーンバッファに合成し、最後にメインスクリーンに転送します。

つまり通常の描画は RawWindow.bitmapoff_screenmain_screen というトリプルバッファになり、さらにアプリ側で個別にバッファを設けている場合はクワドロプルバッファになります。

このように myos の描画は複数のバッファを経由するため、直接描画する場合に比べて若干ラグがあります。

Rust の Null Pointer Optimization

古き良き C 言語では NULL ポインターがよく使われましたが、 NULL の発明者は10億ドルを超える莫大な経済損失を引き起こしたとのちに後悔しました。 モダンな言語は NULL に対する安全性を担保する仕組みを持っています。

Rust では Null 安全性のために Option<T> という型をよく使います。

Option<T>

Option<T> は、 NULL のような値を使わずに値が存在する場合と存在しない場合を明確に区別するために使います。 なぜこれで Null 安全性を確保できるかというと、 Option<T>T は明確に別の型になっているので、値が Null かどうか検証されてない状況でコードが動いてしまう危険な状況を回避できます。

Option<T> の実体は以下のような単なる enum です。

pub enum Option<T> {
    None,
    Some(T),
}

Option<usize> の実際のメモリ配置を考えてみると、 NoneSome を判別するための usize 値、 Some(v) の場合の値 v の2つの usize 値が必要です。

すべての Option<T> でこのようなメモリ配置を採用するととても嵩張ってしまいますが、実は多くの場合この配置を使いません。 それが Null Pointer Optimization と呼ばれている最適化です。

Null Pointer Optimization

Rust では有効な参照、 NonNull<T>、あるいは NonZeroUsize のような型は絶対に Null あるいは 0 の値を取りません。 これらの値を Option<T> でラッピングした場合、実際のメモリ上では Some(v) の場合は元の値そのまま、 None の場合は値 0 を使います。 NoneSome の判別は値が 0 かどうかで判別できますし、 Some(v) だった場合は元の値をそのままアンラップで取り出せます。

内部でこのような最適化を行うことで、 Option<T> は実際のメモリ上の値は T と同じ領域しか使わないように節約しながら Null 安全性を担保することができます。

なお、この最適化は実際には Option に限定されるわけではなくいくつかの条件を満たした他の enum でも同様の最適化が行われるようです。

という事までは以前から知っていました。

Option<char> の場合

Option<char> の場合を考えてみます。

char 型は任意の Unicode コードポイントリテラルなので 0 (U+00000) になり得ます。 一方、現行の UnicodeU+10FFFF までが有効なコードポイントです。

つまり、 char 型は 0x0000_0000 〜 0x0010_FFFF までの範囲を取ることができ、 0x0011_0000 〜 0xFFFF_FFFF の範囲の値にはなることができません。

実際に Rust で Option<char> を使ったコードをビルドして出力バイナリをみていたところ、 1114112 という値と比較する処理が見つかりました。 1114112 というのは 0x0011_0000 のことを指しています。

つまり、 Option<char> では char 型として不正な値の 0x0011_0000 を None の代わりに使うことで Null Pointer Optimization と同じような最適化をしていることがわかりました。

いかがだったでしょうか?

Rust でよく使われる Option<T> には追加のリソースをほとんど使わずに Null 安全性を担保するための高度が仕組みがあることがわかりましたが、 Option<char> にも同様の少しトリッキーな最適化がされていることもわかりました。

Rust 自作 OS 日記/Part 9 my new os...

2020 年を振り返ってみると、色々と検討した結果もう Rust を勉強するしかないなー ということになって春ごろから Rust の勉強を開始、そして myos の開発が始まりました。

さて、およそ半年開発を続けてきた「myos」ですが、

「私の OS」ってなんやねーん

という指摘があちこちから飛んでくるかと思ってましたが、そんなことはありませんでした( ˘ω˘ )

第一部、完。

すでに何度か触れているのでお気づきの方もいらっしゃるかと思いますが myos は moe の後継で、 最初のマイルストーンは C で記述された moe の機能を Rust で書き直すことでした。

まだ moe で実現されていた全ての機能を完全に置き換えることはできていませんが、当初のマイルストーンに到達し、 現在はアプリケーションの動作環境を整える段階になったと考えています。

UEFI / ACPI

UEFI は 2010 年代から使われ始めたファームウェア規格で、 ACPI はハードウェアの構成情報や電力制御に関するインターフェース規格です。 どちらも近年の PC では標準的に使われている規格です。 PC 以外の世界ではほとんど使われてませんが。

Rust で UEFI に対応するためには uefi-rs というライブラリを使うのが簡単です。

github.com

本来 UEFI で提供されている機能の一部がうまく扱えなかったりするのですが、今のところ myos の起動に支障はとくにないのでそのまま使っています。

また、同じところが提供している ACPI ライブラリも組み込んで使っていますが、こちらは不足している機能が多く moe の時に対応できていた機能の一部がまだ対応できていないので置き換えを検討しています。

マルチタスキング

myos では、タイマー割り込みによるオーソドックスなプリエンプティブマルチタスキング、 SMP や SMT によるハードウェアマルチスレッディング、 async + await による協調型マルチタスキングなど複数のマルチタスキングをサポートしています。

moe の SMP 対応は後から追加されたので実はオプションでした。 また、はじめて SMP 対応のコードを書いてみたので、コアごとに分離するべき情報がまとまってなかったり、色々中途半端なところがありました。

myos では最初から SMP 対応前提でスケジューラーなどが設計されました。 SMT (HTT) の割り当てはシステムの負荷が一定以上になるまでスケジューリングしないようになっています。 なお、 SMP 起動部分は古き良きリアルモードで起動する moe 時代のコードをほぼ踏襲することになりました。

ウィンドウシステム

moe では一時期ウィンドウシステムを実装していたものの GUI は不確定要素が大きくなるため、一時期悩んでいた不安定要素の原因になっているのではないかということで一旦削除されました。結果として犯人は別のところにいたことがわかりましたが、今更復活するのもめんどくさいのでなかったことにされてしまいました。

myos のウィンドウシステムは moe のコードを参考に開発されたのでアルファブレンドに対応しています。 moe の時代にあったカラーキーモードは myos ではアルファブレンド前提で完全に削除されました。 また、 moe の時代に実現予定だった機能としてウィンドウに影をつけることができるようになりました。

影をつけた代償として全てのウィンドウが半透過扱いになり、現状 SSE に対応していない事もあって描画は遅くなってしまった気がします。

メモリ管理

現状の myos はカーネルの配置以外にページングを使っていません。 厳密にはロングモードではページングを無効化できないので、物理アドレスと仮想アドレスが一致する Identity mapped という状態でページングを利用しています。

メモリ保護の観点では、開発言語として Rust を採用していること、アプリケーションの実行環境として Sandbox を想定していることなどから、ページングで保護する必要性があまりないのが現状です。

ページングの他の使い道としては、メモリ空間をアプリごとに分離して断片化に強くなるメリットがあります。 こちらはいずれ必要になってくるかもしれません。

なお、現状 4GB 以上の物理メモリが存在しても割り当てません。 これに関連して、メモリが 4GB 以上ある機種で 4GB 以上のメモリが割り当てられてうまく起動できない問題が一時期発生していました。

メモリアロケーターは Slab アロケーターを採用していますが、よくメモリが不足するようになってきたのでパラメータ調整中です。

ファイルシステム

起動時に initrd.img というファイルを読み込んで RAM ディスクとしてマウントしています。

ファイルシステム周りは将来非同期 I/O に対応するため仮実装の場所が多いです。

はりぼて OS エミュレーション

はりぼて OS の API の一部をエミュレーションで対応し、はりぼて OS のアプリがある程度動作します。 もともと initrd に対応してた時に、もしかして結構簡単に実装できるのではということで試験的に実装したものです。

はりぼて OS の 32bit アプリケーションを動かすためだけにカーネルに一部特殊なコードを実装しています。 将来は、エミュレーターやバイナリトランスレーションを導入してカーネル内部の特別扱いを辞めたいと考えています。

WebAssembly ランタイム

標準のアプリケーションバイナリ形式として WebAssembly を採用し、現在はインタプリタで実装しています。 やはり時々動作が遅いと感じることがあるので将来は JIT 対応したいです。

もともと moe でやりたかったのがこの WebAssembly 対応でしたが、諸般の事情で思うように実装が進まず、 myos で初めて実装する事ができました。

ここまでの機能が現状およそ2万行のコードで実現されています。 そのうち WebAssembly に関するコードが 1/4 程度あります。

本題

さて、 moe の後継ということは myos も megos ということになります。

megos はもともと独自の仮想機械を搭載していましたが、当時は色々と未成熟であまりうまく受け入れられませんでした。 一方、 WebAssembly は共通の仮想機械で将来性があります。 また、 WebAssembly で動くということは、ランタイムを移植すれば Web ブラウザでも同じアプリが実行できるということになります。

そんなわけで、世の中がもう少し落ち着いたら myos も megos としてリリースしたいと思っています。

f:id:neriring16:20201229143638p:plain

myos の益々の発展をお祈りします。

BASIC-DOS

さいきん変な OS が発表されたっぽいです。

basicdos.com

2020 年になって DOS !!!

ぼくはこういうソフトを見つけた時に、普通の人とはちょっと視点で見てしまいます。

このソフト、ぼくの自作エミュレータで動くのかな?

バイナリないナリ

ということで、まずは公式サイトからダウンロードしてみましょう。

残念ながら、バイナリイメージを見つけることができませんでした。

ソースを GITHUB.COM からダウンロードして中身をのぞいてみます。

2020 年なのに開発環境も DOS !!!

そもそも、ブラウザで動かす前提なので通常のディスクイメージは提供してないようです。

幸いにして COM 形式のバイナリは git に含まれているようなので、そこからディスクイメージを錬成します。

とりあえず作った最初のイメージはブートエラーでした。

起動できるまで

IPL のソースを見てみます。

DOS の基本ブートシーケンスは IPL が IO.SYS と MSDOS.SYS を探して読み込もうとします。 PC-DOS の場合は IBMBIO.COM と IBMDOS.COM という名前になる以外は基本的に同じです。 この OS ではそれに加えて CONFIG.SYS も読み込もうとするようです。いくつか CONFIG.SYS のサンプルがあったのでそれも組み込むようにしました。

また、当初 2HD でイメージを作りましたが BPB の内容が 160KB だったので 160KB で作り直しました。 IPL にうまくパッチを当てれば 2HD でも起動できそうですがそちらは試していません。

なんとか起動してコマンドプロンプトまで進みました。

古の開発環境再来

なんとか起動しましたが、キー入力の応答が悪く、カーソルの描画も変な感じでした。

コンソールドライバのソースを調査して原因の予想がついたので自作エミュレータ側を改修する必要がありますが、 なんと開発機をリプレースしたときに開発環境がなくなってしまっていたのでした。

ということで、開発環境を再構築しようとしましたが、必要なツールのひとつが darwin-arm64 なんか知らん!とエラーになってしまいました・・・。

これのためだけに x86 環境整えるのもなんだかめんどくさいなったので、古い Mac 君に頑張ってもらうことにしました。 久々に触った Intel Mac はとても重みを感じます。

修正

f:id:neriring16:20201226161804p:plain

カーソルの描画がおかしい問題は、おそらくエミュレータ側の座標計算が間違ってるだろうということですぐに解決しました。

問題はキー入力です。

通常、自作エミュレーターのキー入力は以下のようなフローで動きます。

ブラウザでキーを押す
 ↓
ホストOSのキーイベントが発生
 ↓
JS のキーイベント発生
 ↓
PS/2 ドライバがスキャンコードに変換して FIFO に書き込み、 IRQ 発火
 ↓
BIOS が PS/2 ポート(60)から FIFO の値を読み出してキーバッファに書き込む
 ↓
キーボード BIOS がキーバッファからキーデータを読み込む

一方、この OS のキーボードドライバを入れるとこうなります。

ブラウザでキーを押す
 ↓
ホストOSのキーイベントが発生
 ↓
JS のキーイベント発生
 ↓
PS/2 ドライバがスキャンコードに変換して FIFO に書き込み、 IRQ 発火
 ↓
OS のドライバが PS/2 ポートの FIFO の値を盗み見してホットキーの処理をし、 
BIOS の IRQ ハンドラを呼び出す
 ↓
BIOS が PS/2 ポート(60)から FIFO の値を読み出す→この時 FIFO がすでに空
 ↓
キーボード BIOS がキーバッファからキーデータを読み込む→空

OS のドライバが先に FIFO の値を読み込んでしまうので、その後 BIOS がキーデータを読み出す時には FIFO が空になってしまいます。

これを対策するために PS/2 のキーデータを FIFO ではなく上書き方式にすると、キー入力自体はできるようになりました。

しかし、IRQ の発火回数とデータの読み取り回数が一致しなくなるので、短期間に連続でデータが来た時のデータを保証することができなくなって取りこぼしや重複する問題が発生します。 この変更によって今まで動いていたソフトにも影響が出てしまう可能性があるので対策を考えています。

突然の UNDEFINED INSTRUCTION

とりあえず起動して操作できるようになったので、いくつか付属のアプリの動きを検証してみます。

f:id:neriring16:20201226163821p:plain

_人人人人人人人人人人人人人人人人_ > 突然の UNDEFINED INSTRUCTION <  ̄YYYYYYYYYYYYYYYY^ ̄

エミュレーターのバグの可能性もあるので色々調査したところ、普通に直接呼び出していました。

f:id:neriring16:20201226164024p:plain

FFBF:04D0 をリニアアドレスにすると 0x1000C0 になり、さらに A20 をマスクすると 0x000C0 になります。 INT 0x30 の割り込みベクタと一致します。

CALL 5 って CP/M 互換システムコールで使われているのですが、なんでこんな変なアドレスなんでしょうか? よくわかりませんでした。

いかがでしたか

謎 OS を自作エミュレーターに対応させるのは楽しいですが、時には謎挙動に頭を悩ませることもあります。

それと開発環境はちゃんと残しておきましょう。

Rustの基本戦略

さいきん Rust の基本戦略をやっと完全に理解しました。

  1. オブジェクトはスタックに割り当てます。(スタックポインタを減算するだけ)
  2. 所有権とライフタイムによってオブジェクトの生存期間が関数の生存期間を超えないように制御します。
  3. 関数を抜けたらスタックのオブジェクトを破棄します。(スタックポインタを元に戻すだけ)

2の所有権やライフタイムはコンパイル時にチェックされるので実行時は1と3のスタックポインタ操作だけになり、この仕組みだけを使ったオブジェクトは安全で高速に割り当てと解放が可能です。これが Rust の最も基本となるオブジェクト割り当て方針です。

この仕組みでオブジェクトの割り当てが不可能な状況が2つ存在します。オブジェクトのサイズがコンパイル時にわからないケースと、オブジェクトの生存期間が関数の生存期間を超える(かもしれない)ケースです。

オブジェクトのサイズがわからないとスタックポインタの操作が難しくなるのでスタックに確保できず、 BoxVec などの専用のラッパークラスを経由してヒープに割り当てる必要があります。 *1

関数の生存期間を超えるライフタイムのオブジェクトは、ライフタイムを調整して静的またはヒープに割り当てるケースと、関数の戻り値にライフタイムを指定して調整するケースがあります。

静的に割り当てたオブジェクトは初期化メソッドが const fn であるなどの制約がありますが、プログラムの実行中は常に生存している最も寿命が長いオブジェクトになります。また静的オブジェクトの参照を表す特殊なライフタイムが 'static になります。

ヒープに割り当てる場合は一番制約が少ないですが、先述のように専用のラッパークラスを利用し、さらにアロケーターによる複雑なアルゴリズムでメモリの割り当てと解放を行うので最も遅くなるオブジェクトでもあります。 ヒープに割り当てたオブジェクトは所有権とライフタイムによって寿命管理され、誰からも参照されなくなったとコンパイラが判断した時に開放されますが、コンパイラの判断が難しいケースではさらに RcArc などでラッピングして実行時に所有権を管理します。 *2

最後の関数の戻り値にライフタイムを指定するケースはオブジェクトがどうやって割り当てられているのかよくわからないのでもう少し調査が必要です。予想としては、呼び出す側の関数のスタックに場所だけ割りてておいて呼び出される側に渡してるんでしょうか?

以上のように、所有権とライフタイムをコンパイラが管理することで安全性を担保しつつ、可能な限りオブジェクトをスタックに割り当てることで高速に実行できるのが Rust の特徴ということがわかります。

そして、なぜコンパイル時に配列のサイズが確定できないとコンパイラが猛烈に怒り出すのかもこれでわかるかと思います。

Rust なんもわからん。

*1: Vec のようにサイズが無限に拡大するオブジェクトは無理でも、サイズが初期パラメータで半固定になる配列なら可能な気もしますが・・・

*2:他の言語はたいていヒープに確保するオブジェクトの利用がメインで、所有権に相当する概念はデフォルトで実行時に管理します