借り初めのひみつきち

仮ブログです。

今月の myos

このシリーズ、ほぼ月1になっていたのでタイトルを変更します。

SIMD と Lazy FPU save / restore

CPU の高速化はいくつかの歴史を経て、近年の CPU は「ひとつの命令で多数のデータを処理する」命令をサポートしています。 これを一般に SIMD (Single Instruction, Multiple Data) と言います。 画像処理などは大量のデータに対して同じ手順を繰り返す操作が多いため SIMD 命令が効果的です。

初期の x86 プロセッサには SIMD 命令がありませんでしたが、現代の x86 プロセッサには SSE という拡張機能SIMD に対応しています。 SSE は専用のレジスタを使うので、コンテキストスイッチの際に正しく退避処理を行わないと他のスレッドとレジスタの内容が混ざってしまいます。 SSE は長い x86 の歴史の中では比較的後期に新設された機能であり、通常の処理において大抵の場合 SSE は必須ではありません。

このような経緯のため SSE など x86SIMD 機能はデフォルトで無効になっていて、 OS 側で機能を有効にしないとアプリ側から自由に使えないようになっています。*1

また、大量のデータを扱う性質上、 SIMDレジスタは汎用レジスタに比べて容量も多く退避のコストが上昇します。 基本的な処理ではほとんど使わない上に汎用レジスタより容量の多い SSE レジスタコンテキストスイッチのたびに退避処理を行うと性能低下の恐れがあります。

そこで昔の人は考えました。

コンテキストスイッチのたびに一旦 SIMD ユニットを無効化し、実際に SIMD 命令を実行しようとしたら例外でトラップし、例外が発生するまでレジスタの退避復元処理を遅延させることで SIMD 命令を使わないスレッドにおけるコンテキストスイッチのオーバーヘッドを減らそう。 いわゆる Lazy FPU context switch や Lazy FPU save/restore などと呼ばれる高速化技法です。

しかし、時が経つとこの高速化技法はあまり役に立たなくなってきました。

  • SSE が普及し、昔は汎用命令で処理していた何気ない処理(2つのまとまった 64bit 値をコピーする場合など)にも SSE を使うコンパイラが出てきた。
  • マルチコアが普及してコンテキストスイッチの際に同じ CPU に復元されるとは限らなくなり、レジスタの退避・復元を遅延する効果が薄れた。
  • Lazy FPU save/restore と投機実行を併用したセキュリティ攻撃が発見された。
  • SIMD レジスタの退避・復元処理の速度ペナルティは Intel も認識しており、 AVX 以降では負荷を軽減する新命令が追加された。

現代では Lazy FPU save/restore は使わない方が良いとされています。

ここまでが SIMD を取り巻く歴史となります。

myos では SSE レジスタの退避処理による割り込みやコンテキストスイッチの速度低下への懸念から、当初カーネルのコア部分では SSE を非使用とし、 UI やアプリで必要になったら順次解放していくという方向で開発を進めていました。 しかし、現在のビルド環境ではカーネルのコア部分と UI 部分が同じバイナリに含まれているために ABI を切り替えることが難しく、完全に禁止するか、完全に解放するかという2択の状況が続いていました。

いずれは解放する予定だったし Lazy FPU save/restore のような仕組みを新しく考えるのも面倒になってきたので、試しに全てのコンテキストスイッチで SSE レジスタの退避と復帰処理を実装してみました。

方法は簡単で、元々コンテキストを保存する領域は SSE のレジスタを保存できるように準備していたので、コンテキストスイッチレジスタを切り替える部分に FXSAVEFXRSTOR を追加します。また、新しいスレッドを作成する部分で FPU や SSE の初期値を設定するコードも追加しました。

    fninit
    pxor xmm0, xmm0
    pxor xmm1, xmm1
    pxor xmm2, xmm2
    pxor xmm3, xmm3
    pxor xmm4, xmm4
    pxor xmm5, xmm5
    pxor xmm6, xmm6
    pxor xmm7, xmm7
    pxor xmm8, xmm8
    pxor xmm9, xmm9
    pxor xmm10, xmm10
    pxor xmm11, xmm11
    pxor xmm12, xmm12
    pxor xmm13, xmm13
    pxor xmm14, xmm14
    pxor xmm15, xmm15

そして画像処理で SSE の最適化を有効になるように調整すると、バイナリサイズは肥大化してしまうものの、実機では速度向上が見られました。 懸念していたほどの速度低下も起きず、 Lazy FPU save/restore のような複雑な仕組みも不要になってシンプルになり、画像処理も速くなったのでこれでいくことに決まりました。

これによって機種によってはゲームベンチのFPSが300を超えるようになりました。速いっていいですね。

Universal Serial Bus

myos は一言で言うと moe を Rust で再実装した OS です。 開発言語が違うので単純な比較はできませんが、主要な機能の再実装は完了していると思います。 最後に残った機能は USB でした。

というわけで実装しました。

f:id:neriring16:20210913160425p:plain

USB のクラスドライバは USB バスと通信して結果を変換して他のところに転送するだけのようなものが多く、単純にそのまま実装すると通信用のスレッド数がものすごく増えることになります。 同じコントローラーに対する USB コマンドは同時実行できないので排他処理が必要であること、ドライバの実行時間の大半は待ち時間であることなどを考慮すると、各ドライバがそれぞれスレッドを利用するのはあまり好ましくないので、それぞれのドライバは Rust の async 機能で並列化しています。

また、それぞれのクラスドライバは moe の時代から進化しています。

HID は moe の時代にはキーボードとマウスの最低限の機能だけをサポートした boot プロトコルしか対応していませんでした。 myos では boot プロトコルを廃止してレポートプロトコルの方を使うようになったので boot プロトコルを提供していないトラックパッドや一体型のキーボードなどもだいたい使えるようになりました。 本来の HID 規格はキーボードとマウスだけでなく人間と機器の間でやりとりをするあらゆるデバイスを取り込もうとした超巨大な規格で膨大な種類の Usage が定義されていて全て対応しようとするととても大変ですが、現在の HID ドライバは若干手抜きをしていてキーボードとマウスの入力以外の機能は考慮していません。

USB 2.0 ハブは moe の時代から対応していて起動時に刺さっているとうまくデバイスを認識できない不具合がありましたが修正されました。 USB 3.0 ハブは未対応だったので対応中ですがまだうまく動いていません。

XInput は moe の時代にはとりあえず値が読み出せるだけで使い道がありませんでした。 myos ではゲーム API からアクセスできるようになったので市販の XInput ゲームパッドを接続してゲームを操作することができます。 DirectInput 形式への対応は未定です。

Curiosity

以上により、現在のバージョンをもって moe から myos への移行は完全に完了したと思っています。

いつまでも名前が5月だと締まらないので、 これからは新バージョンのコードネーム「Curiosity」として次のマイルストーンに向けて開発していこうと思います。

*1:ただし、 UEFI から起動した場合は x64 UEFI の用件に SSE が含まれているので有効の状態で起動すると思います

今週の MYOS 🧚‍♀️

MYOS でも簡単なゲームを動かせそうになってきたので準備をします。

多くのゲームは画面内をキャラクターが動き回りますが、キャラクターを描画する時は背景との重ね合わせ処理が必要になりますし、キャラクターが移動するときはそれに加えて元いた場所の背景を復元しないと画面がぐちゃぐちゃになります。それらを自力で管理するのは結構大変です。

レトロゲームフレームワーク 🧚‍♀️

古き良きレトロゲーム機は CPU もメモリも貧弱でしたが、スプライト 🧚‍♀️ という機能を持っていました。 これは背景とは別に小さい画像オブジェクトを重ねることができる機能で、先述のめんどくさい重ね合わせ処理をハードウェアで処理してくれます。 この機能のため、レトロゲーム機は貧弱なCPU能力と少ないメモリでもキャラクターが画面中を動き回るゲームを実現することができました。

というわけで、 MYOS にも似たような機能を実装します。

f:id:neriring16:20210821155043p:plain

ベンチマーク用のサンプルアプリを起動するとウィンドウの中をボールが動き回ります。

ちなみに現在のゲームAPIハローワールドがこんな感じのコードになります。

#![no_main]
#![no_std]

use megoslib::game::v1::prelude::*;

#[no_mangle]
fn _start() {
    let presenter = GameWindow::new("hello", Size::new(128, 64));
    presenter
        .screen()
        .draw_string(Point::new(0, 3), 0, b"Hello, world!");
    loop {
        presenter.sync();
    }
}

f:id:neriring16:20210818025101p:plain

これに少し加えて以下のようにすると、文字がスクロールします。簡単ですね。 *1

#![no_main]
#![no_std]

use megoslib::game::v1::prelude::*;

#[no_mangle]
fn _start() {
    let presenter = GameWindow::new("hello", Size::new(128, 64));
    presenter
        .screen()
        .draw_string(Point::new(0, 3), b"Hello, world!");
    loop {
        presenter.sync();

        presenter.screen().control_mut().scroll_x -= 1;
        presenter.set_needs_display();
    }
}

ゲーム API を有効化すると、 wasm のアプリと OS の間で複数回のデータやり取りで描画が遅くなるのを防ぐため、ゲーム画面描画専用の固定メモリマップが割り当てられます。

まだ仕様は完全に確定していませんが、よくあるレトロゲーム機の画面仕様と似たような仕様となっており、解像度や色数に同じような制限があるものの、プログラムサイズ数KB〜数10KBの規模でレトロゲームが簡単に実装できることを目標にしています。 将来はもう少し制限を緩くした version 2 以降も実装できたらいいなという願いを込めて、現在実装しているゲーム API には v1 というプレフィックスをつけています。

ゲームウィンドウ高速化

ゲーム API を実装する過程で気付きがありました。

以前検証した単純な計算ループのベンチでは qemu より実機の方が1桁くらい速かったですが、スプライトのデモに FPS 表示を付けてみると、なんと qemu より実機の方が遅いです。 そして、ウィンドウを画面端に移動して描画部分を極限まで小さくすると FPS 表示が跳ね上がることを確認できます。

スプライトはウィンドウの状態に関係なく内部バッファに対して描画されますが、内部バッファからウィンドウへの描画は実際の画面の表示領域に依存します。 画面端にウィンドウを表示すると、クリッピングによってウィンドウの描画範囲が狭くなり、描画するビットマップの面積も小さくなって転送量が減ります。 画面端にウィンドウを移動してFPSが跳ね上がるということは、スプライト自体の描画よりもウィンドウの描画に時間がかかっていることになります。

MYOS もその先祖の MOE も、半透明のウィンドウどうしの重ね合わせ描画をサポートするために、すべてのウィンドウ描画はいったんバックバッファで合成した後で実際の画面に転送します。 MOE の時代のウィンドウは透過ウィンドウと不透明ウィンドウの2種類のウィンドウが存在し、不透明ウィンドウは高速化のために重ね合わせ処理の一部を省略して描画していました。 MYOS では基本的に全てのウィンドウに影がつくため、影の合成のために不透明ウィンドウという概念が消滅しました。*2 つまり、全てのウィンドウはその内容が不透明であったとしても毎回後ろのウィンドウを描画した後アルファブレンド合成していたのです。

それは遅くなるわけです。

ウィンドウの形が角丸が基本になって矩形ではなくなってしまったり影を描画しなくてはいけない関係上、今後も完全な不透明ウィンドウは MYOS には基本的に存在しないでしょう。 一方、ウィンドウの一部分を再描画する場合、内容が不透明であれば自分より背面のウィンドウは描画しなくて良いはずです。

というわけで、一部の条件を満たしたウィンドウの部分再描画でアルファブレンドしないようにしたところ、 qemuFPS が倍以上に跳ね上がりました。 今まで半分以上ウィンドウ再描画の時間だったということになります。

一方、実機の速度向上は限定的でした。

実機でもウィンドウを端に寄せて描画範囲を狭くすると FPS が跳ね上がるので描画が遅いことがわかりますが、メインメモリ内で完了するアルファブレンド処理はさほど負荷ではなく、実際に画面に描画する VRAM 転送する処理が一番遅いということなのでしょう。

エミュレーターは一般に実機より遅いと思われがちですが、 VRAM アクセスは特別で、エミュレーターの中からは遅い VRAM に直接アクセスできなかったり表示時の転送がいい感じに効率化されているので実機より速くなることも多いです。

*1:前半ではスプライトの話をしたいたのに、実はこのハローワールドはスプライト機能を一切使っていないことに気づいてしまいました。要望があればそのうちもう少し詳しいサンプルプログラムを作って解説するかもしれません。

*2:実際には一番後ろにあるデスクトップに相当するウィンドウはそれ以上後ろに何もないので不透明ウィンドウが完全に消滅したわけではないですが

今週の MYOS

wasm-strip

MYOS のアプリケーションは WebAssembly 形式に対応していて、現在は Rust で開発する前提でライブラリの整備を進めています。 しかしどうやら Rust で WebAssembly を生成すると Release ビルドにも関わらずバイナリにデバッグ情報のようなものが付与されているようです。 現在のアプリは数KB程度のサンプルが多いですが、実際の内容の半分以上はデバッグ情報になっていることも多いです。 このデバッグセクションは仕様がよくわからず、自作 wasm ランタイムにとっては完全にゴミ情報なので削除してサイズ節約したいです。

そこで、デバッグ情報を削除できるユーティリティーを作りました。 内容は簡単で、 wasm ファイルを読み込んでセクションを順番にパースして通常のセクションはそのままデータとして残し、 Custom セクションは実行に直接関係ないデータなので削除し、最後に残ったデータだけを結合して出力します。 wasm ファイルの構造は複数のセクションが並んでいる構造で、各セクションの中身は直接パースせずただのバイナリとして扱う場合は結構簡単に加工できます。

なお、 Custom セクションのうち、 name セクションだけはデバッグ情報ほどサイズが大きくなく残した方が有益と判断したので、現在の MYOS の Makefile では name セクションを残すオプションを指定しています。

実際のファイル内容にはおそらくリンクのために冗長な LEB128 エンコードで表現されているデータも含まれているので、その辺をパースして圧縮すればもう少しファイルを小さくできそうですが、想定される作業量の割には削減できるデータ量がそこまでないので見送りになりました。

name セクション対応

前の節で触れたように、 WebAssembly には name というカスタムセクションが存在します。 WebAssembly は全ての関数はインデックスで扱い、 export されていない関数は番号でしか判別できません。 多くの場合は高級言語コンパイルして実行するので、関数名を番号で表示されてもどの関数のことかわかりません。

しかし、 name セクションがある場合は export していなくてもシンボルの名前がわかるようになります。 ということで、実行エラーが発生した時に関数名も表示できるように対応しました。

f:id:neriring16:20210724030921p:plain

実際のバイナリ上の関数名はほとんどの場合マングルされるのでソース上の関数名と比べるとよくわからない呪文が付属したよくわからない長い名前になってしまいますが、番号だけ表示されるよりは若干わかりやすくなった気がします。

その他の細かい改良

そのほかにも WebAssembly の細かい調整をしてパフォーマンスが向上した気がしますが、ベンチに使っている qemu が時々何の前触れもなくいきなり実行速度が2〜3倍になったり戻ったりするので実際の速度がよくわからなくなってしまいました・・・

今週の MYOS

今週というタイトルなのに最後の更新何週間前だったっけ?という最近の MYOS 界隈のトピックです。

スケジューラキュー

一般にマルチタスク OS のスケジューラには実行待機中のスレッドを管理するためのキューがあります。 このキューは全てのプロセッサコアから同時に読み書きする可能性があるため非常に高度な排他制御が必要になります。

MYOS の開発初期に最も悩んだのもこのスケジューラキューの実装で、最終的に一旦自前実装を諦めて既存のわりと定評のあるらしい外部ライブラリを使うことにしました。 数ヶ月前はこれで安定して動いていたと思うのですが、最近また動作が怪しくなってきました。 しばらく動作しているとキューからデータを引き出す部分で止まって無限ループしているようです。

ロックフリーアルゴリズムというのはアトミック操作の組み合わせでデータを保証します。 当然書き込みもいくつかのアトミック操作の組み合わせを行いますが、稀にこれからデータを書き込む印をつけたまま実際の書き込みが終わる前にプリエンプションが実行され、スケジューラーが次に実行するスレッドを決めるためにキューを読み取る際に書き込み完了マークがつくまで待ち続ける、しかしそのスレッドはすでにプリエンプションされているので書き込みは永遠に完了しないという無限ループを発見しました。

キューの出し入れを割り込み禁止にすることで、キューに関するほとんどの問題は解決しました。

クリティカルな操作をするときは割り込みを禁止しましょう。

以前より安定してきたので自作のキューも使い始めてますが、やはりまだ時々止まってしまう現象を確認していて詳細不明です。

フォントレンダラと直線アルゴリズム

MYOS は現在 Hershey という簡易的なベクタフォントに対応しています。 このフォントは頂点のリストだけが提供されるので頂点の間に直線を描画するだけで簡単に利用できます。

ベクタフォントの常として、ある程度のサイズを下回るサイズで描画しようとするとあまり綺麗に描画できなくなります。 そこで今までの実装はサイズが倍のバッファに拡大描画した後で縮小して画面に描画していました。 しかし、フォントによってはあまり綺麗に描画できていませんでした。

これまで直線の描画はブレゼンハムと呼ばれる有名なアルゴリズムを利用していました。 ブレゼンハムのアルゴリズムは X が1増加するときに Y が 1 変化するかたまは変化しないのどちらかを判別して描画していくアルゴリズムで、高速に描画できる代わりに座標の変化が整数の近似値で丸められるので射線がガタガタに描画されます。

元々ガタガタの線を拡大縮小しても綺麗に描画できるはずがありません。そこで座標を小数で扱って射線も滑らかに描画できるアルゴリズムに変更しました。

以前より描画結果が良好になりました。

角丸ウィンドウ

MYOS は、その先祖となった moe の時代から角丸の矩形を描画する機能を持っていました。 そして、マウスカーソルのように矩形ではない図形を重ね合わせる機能も元々持っていました。 角丸ウィンドウを実現できそうな気がしますが、ひとつ問題がありました。影です。

MYOS のウィンドウの影は内部的にはウィンドウの矩形の周りに当たり判定のない半透明の枠線として描画する方法で実現していました。 処理が単純で高速ですが、この方法では矩形以外の影を描画できません。

そこでウィンドウ本体と別のビットマップを用意し、ウィンドウ本体のビットマップの透明度を基準にぼかしをかけて影を生成するように変更しました。

影の形が自由になったのでウィンドウの形を矩形に限定しなくても描画できるようになりました。

f:id:neriring16:20210711155418p:plain

ウィンドウの自由度が上がったので、角丸ついでにタイトルバーのデザインも少し変更してみました。

いかがでしたか?

これからも MYOS の活躍にご期待ください。

今週の MYOS

さいきんの MYOS 界隈のトピックです。

WASM 高速化

以前の大規模改修で数倍の高速化に成功した WASM ランタイムですが、まだいくつか課題と高速化の余地があります。

まず、実行時エラーの処理を改善しました。

以前のエラー処理は命令実行時に現在の命令を覚えておいて実際にエラーが発生した場合にエラーコードを投げて呼び出し元が覚えておいた命令と合成してエラー表示していました。 しかし、プログラムの実行時間の大半はエラーを起こさない命令を実行しているので、エラーに関わらず毎回命令を保存する処理は無駄が多く速度低下の原因のひとつにもなっていました。 そもそも呼び出し元がエラーの発生した場所を特定する仕組みだったので実際にはあまりうまく動かないケースがあり、速度低下のこともあって高速化の際にこの機能を無効にしてエラー発生場所が完全にわからない状態になっていました。

新しいエラー処理では、命令の実行時にエラーが発生した際にエラーコードと一緒に現在実行中の命令に関する情報も一緒に呼び出し元に返すことで、正常系の速度低下を引き起こさずに正確なエラー情報を通知できるようになりました。

残っている問題は、エラー時に表示される命令は実行時の命令なので WASM のバイトコードではなく事前検証で変換された中間言語になっていて、一部の命令は元のバイトコードと違う命令になっているので元のバイトコードに戻したいと考えています。

f:id:neriring16:20210526112517p:plain

また、配列アクセスを合理化しました。

Rust は配列アクセスで必ず境界チェックが行われるため、 C 言語で同等のプログラムを書いた場合に比べると遅くなることがあります。*1

WebAssembly インタプリタはほぼ全てのバイトコード命令がなんらかの配列にアクセスします。 一方、 WebAssembly のバイトコードは基本的に事前検証しないと実行できないため、ほとんどの配列インデックスは事前検証で安全性を担保することができます。*2

Rust は unsafe の命令で境界チェックを行わない配列アクセスができます。 unsafe 命令は基本的に使う側が安全性を確認することが必要ですが、実行時にしか安全性を検証できない一部の配列インデックスをのぞいて、事前検証で安全性を確認しているほとんどの配列アクセスは直接アクセスで高速化できます。

これらの改修で改修前よりベンチが 40% 程度高速化できたようです。

rustdoc

MYOS もだいぶ大きくなってきました。 カーネル内部の各モージュール、共通ライブラリ、サンプルアプリなど・・・ どこにどんなクラスがあってどんな使い方をすればいいのか見通しが悪くなってきました。

幸い、 rust には rustdoc という自動的にドキュメントを生成する機能があるのでそれを利用します。

良い子は cargo doc というコマンドでドキュメントを作ることができますが、 MYOS はレポジトリの構造が若干複雑なので色々オプションを指定してあげる必要があります。

ローカルで何度か調整してドキュメントを作ることができるようになったら、次はサーバーにアップロードしてドキュメントを共有できるようにします。 しかし、ドキュメントを生成してサーバーにアップロードする作業は手作業のままだと効率が悪いし最新のアップデートに追従できなくなります。

Github には Github Action というプッシュした場合などに自動的にスクリプトを実行する機能があるので設定します。

.github/workflows/ に以下のような YAML を設定します。*3

name: Rustdoc

on:
  push:
    branches: [ "test/doc" ]

env:
  CARGO_TERM_COLOR: always

jobs:
  rustdoc:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2

    - name: Install Rust toolchain
      uses: actions-rs/toolchain@v1
      with:
        toolchain: nightly
        profile: minimal
        override: true
        components: rustfmt, rust-src

    - name: Build Documentation
      run: |
        cd system
        cargo doc --no-deps --target x86_64-unknown-none.json -p kernel -p megstd -p bootprot -p megosabi

    - name: Deploy Docs
      uses: peaceiris/actions-gh-pages@v3
      with:
        github_token: ${{ secrets.GITHUB_TOKEN }}
        publish_branch: gh-pages
        publish_dir: ./system/target/x86_64-unknown-none/doc
        force_orphan: true

すると、 gh-pages というブランチに自動的にデプロイされて Github Pages の機能で HTML 公開されます。

meg-os.github.io

現在はドキュメントプッシュ用のブランチと開発のブランチが異なるので最新に追従しているわけではありませんが、 いずれブランチをもう少し整理して、マージするたびにドキュメントも自動で更新できるように考えています。

*1:コンパイル時に静的検証される場合と実行時にチェックされる場合があります

*2:WASMの仕様上、分岐命令はブロックのネストで表現されているので事前検証なしの実装は困難です。また、スタックの妥当性検証を事前検証しておくことで実行時のスタックチェックを省略できるなど事前検証した方がメリットがあります

*3:他のプロジェクトに転用する場合はこれをそのままコピペしてもうまく動かないので各自調整してください

GPD MicroPC の内蔵キーボードを使う方法

エンジニア向けに人気らしい GPD MicroPC ですが、実はいくつかの OS で内臓キーボードが使えない問題があります。 いくつかの OS には自作 OS も含まれます。

※ リビジョンによってハードウェア詳細が異なる個体があるようなので、この記事が全ての個体に当てはまる保証はできません。

結局、内蔵キーボードは何の規格なの?

答え: PS/2 です。

出た当時は色々言われていましたが、 windows を起動してデバイスマネージャを眺めてみると「PS/2 キーボード」というデバイスが存在します。 そして、 UEFI コンソール上でポート I/O を行うコマンドでテストするとポート 0x60 (PS/2 データポート) からキーコードっぽい値が取れます。

実際に自作 OS で試してみると、 PS/2 コントローラーらしいデバイスが存在していていくつかのコマンドにはちゃんと応答を返しています。

しかし、キーボードをいくら押し込んでもキーのデータは取得できません。

UEFI で直接 PS/2 ポートを叩くプログラムを作ってみるとそれっぽいデータが返ってきますが、 途中で ExitBootServices を呼び出すとそれ以降はやはりデータが返ってきません。

一方、 USB スタックを実装して調べてみると謎の HID デバイスは存在するものの、キーボードらしき応答は返ってきません。

一体何が起こってるんでしょうか?

Linux の事情

インターネットを検索してみると Linux をインストールした人の記事をいくつか見つけることができます。

Linux もバージョンによっては内蔵キーボードがうまく扱えないことがわかります。 一方で、バッテリ管理を有効にするとキーボードが使えるらしいという報告もあります。

バッテリ管理ということは ACPI が怪しいですね。

ACPI、DSDT、AML / ASL

ACPI には AML というバイトコードエンコードされた DSDT というコンピューターの全てのデバイス構成を管理するための巨大なデータベースがあります。

流石にそのまま見てもよくわからないので ASL というテキスト形式に変換して GPD MicroPC の DSDT の一部を見てみましょう。

Device (PS2K)
{
    Name (_HID, EisaId ("PNP0303") /* IBM Enhanced Keyboard (101/102-key, PS/2 Mouse) */)  // _HID: Hardware ID
    Name (_UID, Zero)  // _UID: Unique ID
    Name (LDN, 0x06)
    Name (_CID, EisaId ("PNP030B"))  // _CID: Compatible ID

...

Scope (_SB.PCI0)
{
    Device (PS2K)
    {
        Name (_HID, "MSFT0001")  // _HID: Hardware ID
        Name (_CID, EisaId ("PNP0303") /* IBM Enhanced Keyboard (101/102-key, PS/2 Mouse) */)  // _CID: Compatible ID

PS2K というデバイスを見つけることができ、これは PS/2 接続のキーボードということがわかります。

PS2K デバイスの ASL をさらに眺めていると、以下のように我々のよく知っている PS/2 キーボードのリソース情報が記述されています。

IO (Decode16,
    0x0060,             // Range Minimum
    0x0060,             // Range Maximum
    0x00,               // Alignment
    0x01,               // Length
    )
IO (Decode16,
    0x0064,             // Range Minimum
    0x0064,             // Range Maximum
    0x00,               // Alignment
    0x01,               // Length
    )
IRQNoFlags ()
    {1}

PS/2 キーボードの存在は確からしいことがわかりました。 問題は、なぜ ExitBootServices を呼び出すと、キーボードが反応なくなるのでしょうか?

ACPI Embedded Controller

ACPI には Embedded Controller (EC) という、 ACPI に関するコマンドやイベントのやりとりを行うコントローラーがあります。 EC は PS/2 コントローラーによく似ていて、一部の機種では PS/2 コントローラーと兼用している例もあるようです。

f:id:neriring16:20210523145757p:plain

コントローラーチップの謎

分解記事を見るとコントローラーチップらしき型番 (IT8987E) を見つけることができるのでそれを元に検索すると、 キーボードマトリックスPS/2 キーボードとして使えるコントローラーと Embedded Controller を内蔵しているブロック図を見つけることができます。 しかし、他に手がかりになりそうな情報を見つけることはできませんでした。

あえてこのようなチップを内蔵しているということは、おそらく PS/2 接続キーボードなんでしょう。

チップ本体の情報は見つかりませんが、似ている別の型番のチップでは EC のコマンドでキーボードなどの有効化を制御することができます。

つまり、 EC に何らかのコマンドを送ると内蔵キーボードの ON/OFF が制御できるのではないか、ということが予想できます。

全ての材料は揃った

ここまでの情報を整理すると、 ACPI を有効化する過程で EC になんらかのコマンドを送ればキーボードが使えるようになると予想できます。

あとは AML/ASL からそれらしい情報を探してくるだけです。

ここが一番大変ですけどね

おぼろげながら浮かんできた 0x11 という数値

ここまでは比較的早い段階で辿り着くことができましたが、 AML/ASL はしらみつぶしに探すには巨大すぎるし、名前が4文字制限でよく分からない略語がいくつも出てきて何をやってるかさっぱり分からなくなるので、進捗はほとんどありませんでした。

そして、当初の目的も忘れてきたある日ふと ASL を眺めていたところ、 EmbeddedControl に KBCD という怪しいフィールドを見つけました。

OperationRegion (ECF2, EmbeddedControl, Zero, 0xFF)
Field (ECF2, ByteAcc, Lock, Preserve)
{
    XXX0,   8, 
    XXX1,   8, 
    XXX2,   8, 
    Offset (0x11), 
    KBCD,   8, 

いかにもキーボード関係ありそうな怪しい名前のこのフィールドを ASL から検索してみると、バッテリー関連のメソッドで 0 を書き込んでいました。 なお、別のところでは 3 を書き込むケースもありました。

というわけで、実機で試してみました。

f:id:neriring16:20210523142033j:plain

キーボードが動きました。

ヤッタネ

実装の仕方

EC は PS/2 コントローラーによく似ています。 GPD MicroPC の場合はポート番号 0x62 がデータレジスタ、ポート番号 0x66 がコマンド・ステータスレジスタとなり、レジスタにデータを書き込む際はステータスレジスタIBF フラグの値を調べます。

f:id:neriring16:20210523145933p:plain

EC は 256 バイトの空間を持っていて、書き込む際は 0x81 Write Embedded Controller (WR_EC) をコマンドレジスタに書き込んだあと、アドレスと書き込むデータをデータレジスタに書き込みます。

f:id:neriring16:20210523150013p:plain

WR_EC は以下のようなコードになります。

unsafe fn wr_ec(addr: u8, data: u8) {
    Self::ec_wait_for_ibf();
    asm!("out 0x66, al", in("al") 0x81u8);
    Self::ec_wait_for_ibf();
    asm!("out 0x62, al", in("al") addr);
    Self::ec_wait_for_ibf();
    asm!("out 0x62, al", in("al") data);
}

unsafe fn ec_wait_for_ibf() {
    let mut al: u8;
    loop {
        asm!("in al, 0x66", out("al") al);
        if (al & 0x02) == 0 {
            break;
        }
        asm!("pause");
    }
}

件の問題では EmbeddedControl 0x11 に 0 を書き込みたいので、

Self::wr_ec(0x11, 0x00);

のように呼び出せばいいです。

しかし、このままだと GPD MicroPC 以外では何が起きるかわかりません。

EC が 62/66 にない機種ではステータス待ちでおそらくハングアップしますし、 EC があっても内容は機種依存なのでデバイスに予期せぬダメージを与えてしまうかもしれません。 というわけで、 SMBIOS で機種判別をして「GPD MicroPC」の時だけ処理するようにします。*1

let device = System::current_device();
match device.manufacturer_name() {
    Some("GPD") => {
        match device.model_name() {
            Some("MicroPC") => {
                Self::wr_ec(0x11, 0x00);
            }
            _ => (),
        }
    }
    _ => (),
}

これで GPD MicroPC の時だけキーボード有効化の EC コマンドを書き込みするようにできました。

本来は ACPI をちゃんと実装すればバッテリ関連のメソッドで自動的に実行されます。 現在の実装はあくまでワークアラウンドです。 そもそもキーボードが勝手に無効化されてしまうのはファームウェアのバグだと思うのでいずれ修正されるかもしれません。

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

自分の所有しているデバイスでキーボードがちゃんと扱えないのは悲しいものです。 意外と簡単な修正でキーボードが使えることがわかって嬉しいですね。

*1:実は多くの中華端末では SMBIOS の内容が適当すぎて機種が判別できません。手元にある他の GPD も判別できませんでした。 MicroPC だけはちゃんと判別できてよかったです

今週の MYOS 🎉

今週、というには間が空いてしまった最近の MYOS/TOE 界隈のトピックです。

MAYSTORM

直訳すると5月の風になります。

MYOS が最初のコミットから1年経ったので、1周年を記念して新しい名前を付けて仮プロジェクトから正式なプロジェクトに昇格しました🎉

github.com

ページング

TOEMMU のない CPU で動作するのが目標なのでページングを現状利用していないし今後も利用予定はありません。

一方 MYOS は x64 CPU で動作するのでページングを完全に OFF にすることができず、ブートローダーが設定した Identity Mapping (物理アドレスと仮想アドレスが一致する割り当て方法) 領域をそのまま使って、カーネルとしてはページングを見て見ぬ振りしていました。

今まではこのやり方でも特に大きな問題は起きていませんでしたが、 MMIOバイスの扱いで問題が発生しました。 ブートローダーは MMIOバイスの存在を知らないので、あらかじめページを割り当てておくことができません。 というわけで、カーネル起動後にデバイスドライバの指示でページ割り当てる機能を実装しました。

そもそも、実は APIC や HPET も MMIOバイスなのでページ割り当てが必要でしたが、これまでは本来カーネルが設定するべきページ設定をブートローダーの中で固定アドレスで勝手に設定していました。 今回の変更でブートローダーの固定配置を削除してカーネル側で ACPI の情報に基づいて動的に設定するように修正しました。

また、今まではカーネル以外は Identity Mapping 前提で物理メモリに配置して動いていましたが、メモリマネージャにページマネージャが仲介して仮想アドレスを返すようにしたので物理アドレスを直接扱わないようになりました。 カーネルの配置はブートローダーがカーネル専用の領域をマッピングしていたので今まで通りです。

一連の変更によって SMP 初期化以降のカーネルは Identity Mapping 領域にアクセスせずにページマネージャが割り当てた新しいページテーブルで動くようになりました。 ただし、残っている Identity Mapping 領域をそのまま切り離してしまうと haribote-os アプリが 32bit のために 4GB 以下のアドレスにしか配置できず、メモリアクセスできなくて動作しなくなってしまいます。この部分はまだ調整中です。

SMP のページング

SMP 起動後のページングはページテーブル操作中に他のプロセッサがページテーブルの書き換えを行うと競合して予期せぬ動作をする可能性があるため排他制御が必要になります。 また、ページテーブル変更があったことを他のプロセッサは知らずにそのまま実行を続けてしまうので、ページテーブル操作が終わったら TLB フラッシュを全てのプロセッサに通知するためにプロセッサ間通信する必要があります。

もしも単純にそのままページテーブルをロックしてしまうと、あるプロセッサでページテーブル変更後の通知の応答を待ってる間に他のプロセッサでページテーブル操作のロック待ちが発生した場合、お互いを待ち続けてデッドロックする可能性があります。

これに対する解決策として、ページ操作専用のスレッドを作って全てのページ操作を非同期にする改修に着手しています。 なお、現在は SMP 起動後にページング操作をするシチュエーションが存在しないのでこの機能は未完成です。

カーネルの ELF 対応

PC でよく使われるメジャーな実行ファイル形式は大きく分けると PE と ELF の2種類存在します。 UEFI は PE を採用していますが、現代的なほとんどの OS は ELF を採用しています。

MYOS は当初 UEFI アプリとして開発を始めたため PE を採用し、ブートローダーとカーネルの分離後もそのまま PE を使っていました。 しかし、 PE だと制限も多く扱いづらくなってきたので ELF への移行を検討していました。

まずは試しに単純に ELF をサポートしているターゲットに変えてビルドしなおしたところ、今まで見たことないエラーが出てきました。

その中のひとつに SSE が無効化されていてビルトインライブラリがビルドできないというエラーがありました。

現在 MYOS では SSE を無効にしています。 SSE は汎用レジスタとは別のレジスタセットを使用するので割り込みやコンテキストスイッチのコストが上昇しますが、 SSE は x86 アーキテクチャに後から追加された機能なので使わなくても大半のプログラムは記述可能です。

アプリケーションレベルでは SSE の機能を利用できた方が便利なこともあると思うので、将来アプリケーションで利用することがあれば SSE に対応する予定があります。 一方、カーネルでは利用するメリットよりも全てのスレッドがコンテキストスイッチのたびに毎回 SSE レジスタの退避処理をするコスト上昇の方がデメリットだと考えています。 そのため、カーネル内で不用意に SSE にアクセスする命令を実行しないようにコンパイラオプションで SSE を無効にしています。

Rust では OS ごとにターゲットが存在していて、 PE 形式から ELF 形式に移行するということは今まで使っていた UEFI のターゲット (x86_64-unknown-uefi) をやめて ELF をサポートするターゲットに変更することになります。 しかし、 ELF に対応するために一番無難そうだった Linux ターゲット (x86_64-unknown-linux-gnu) のビルトインライブラリの中には先述のように SSE を利用するものが含まれていてこのままではビルドできませんでした。

そこで他に使えそうなターゲットを探していたところ、 x86_64-unknown-none というターゲット設定のテンプレートを見つけたので、これをカスタマイズして無事に ELF に移行することができました🎉

PE から ELF に移行すると ABI も変更されますが、あらかじめ ABI が違うことはわかっていて色々準備していたので大きな混乱もなく無事に移行できました。

ちなみに TOE は最初から linux ターゲットで ELF 形式を採用していましたが、ロードサイズ制限緩和のためにビルドスクリプトで独自形式に変換していました。 MYOS では TOE ほどメモリ要求が厳しくないのでこのまま ELF のまま利用する予定です。