xHCI経由のUSBキーボード入力とBoot Protocol
この記事ではUSBキーボードからの入力を扱います。フレームバッファへのテキスト描画とpanic表示の記事までで画面出力の土台は整いましたが、キーボード入力はPS/2コントローラ経由で実装していたところ、対象実機での動作確認の結果、PS/2からの入力は機能しないことが確認されました。この記事では、USBキーボードをxHCI(Extensible Host Controller Interface)経由でHID Boot Protocolとして扱う実装に切り替えます。
PS/2が実機で機能しなかった経緯
PIT割り込みとPS/2キーボードの記事で触れたとおり、対象実機(MousePro CR-I1U01)には物理的なPS/2ポートがありません。それでもPS/2コントローラの実装から着手したのは、多くのAMI系ファームウェアが持つ「USB Legacy Support」機能により、SMM(System Management Mode)経由でUSBキーボードの入力を8042コントローラ相当に見せかけるエミュレーションが働くことを期待したためです。
実機での確認の結果、このエミュレーションはキー入力を拾えませんでした。USBキーボードの入力を確実に扱うには、USBホストコントローラを直接初期化してデバイスと通信する経路が必要になります。
xHCIとは
USB 3.0以降の世代で使われるホストコントローラの標準的なレジスタ仕様がxHCIです。USB 1.1(UHCI/OHCI)・USB 2.0(EHCI)は世代ごとに別々の規格が使われていましたが、xHCIはこれらを統合し、USB 1.1〜3.xのデバイスを単一のレジスタ仕様・単一のドライバで扱えるようにしています。統合されている分、初期化に必要な手順はPS/2の8042コントローラと比べて桁違いに多くなります。今回実装した範囲は、ハブを介さず直接ポートに挿されたキーボード1台をHID Boot Protocolで扱うことに絞り、マウスや複数デバイス、汎用的なHIDレポート記述子の解析などは対象外としています。
PCIコンフィグレーション空間からコントローラを見つける
xHCIコントローラはPCIデバイスとして存在するため、まずPCIバスをスキャンして見つける必要があります。x86ではCONFIG_ADDRESS(I/Oポート0xCF8)とCONFIG_DATA(0xCFC)という2つのポートを使い、bus・device・function番号を指定してコンフィグレーション空間の内容を読み書きします。
// arch/pci.zig
fn addressDword(addr: Address, offset: u8) u32 {
return (1 << 31) // enable bit
| (@as(u32, addr.bus) << 16)
| (@as(u32, addr.device) << 11)
| (@as(u32, addr.function) << 8)
| (offset & 0xFC);
}
pub fn readConfig32(addr: Address, offset: u8) u32 {
io.outl(config_address, addressDword(addr, offset));
return io.inl(config_data);
}bus 0上の全device・functionを走査し、クラスコード(Serial Bus Controller)・サブクラス(USB)・プログラミングインターフェース(xHCI)が一致するデバイスを探します。実機・QEMUのいずれもxHCIコントローラはbus 0に直接載っているため、PCI-to-PCIブリッジを跨いだ探索は今回実装していません。
見つけたデバイスからは、レジスタ空間の物理アドレスが入ったBAR(Base Address Register)を読み取ります。ファームウェアが起動時にBARへアドレスを割り当て済みのため、OS側ではその値を読むだけで済みます。xHCIのBARは64bitアドレスを2つのBARレジスタに分けて格納する方式が使われており、下位32bitと上位32bitを組み合わせて1つの物理アドレスに復元します。
MMIOレジスタ空間のマッピング
BARで得た物理アドレスは、GOPフレームバッファのBARと同様にUEFIのメモリマップには含まれないMMIO領域です。ページングの記事で構築した恒等マッピングはメモリマップに載っている範囲までしかカバーしないため、フレームバッファのときと同じ要領で、xHCIのBARについても明示的な恒等マッピングを追加しています。
// kernel/paging.zig
pub fn mapMMIO(base: usize, size: usize) void {
identityMapRange(kernel_pml4, base, size);
}リング構造とTRB
xHCIは、コマンドやデータ転送の要求を「TRB(Transfer Request Block)」と呼ばれる16バイト固定長のデータ構造で表現し、これをリング状のバッファに並べてやり取りします。リングにはソフトウェアがTRBを積んで渡す向き(コマンドリング、各エンドポイントの転送リング)と、コントローラが処理結果を積んで返す向き(イベントリング)の2種類があります。
リングは固定長のバッファですが、各TRBの先頭ビットである「Cycle bit」を使うことで終わりなく使い回せます。送り手と受け手はそれぞれリングの現在位置を追跡しており、リングの末尾に置かれた特別なTRB(Link TRB)を通過するたびに、双方が独立してCycle bitの意味(0/1のどちらが「有効」を表すか)を反転させます。この反転が送り手・受け手の間でずれないことが、リングを正しく使い回すための前提になります。
// drivers/xhci.zig
fn push(self: *ProducerRing, parameter: u64, status: u32, control_without_cycle: u32) void {
self.trbs[self.enqueue_index] = Trb{
.parameter = parameter,
.status = status,
.control = control_without_cycle | self.cycle,
};
self.enqueue_index += 1;
if (self.enqueue_index == slots) {
self.enqueue_index = 0;
self.cycle ^= 1;
}
}コントローラの初期化
xHCIのレジスタ空間は、Capability Registers・Operational Registers・Runtime Registers・Doorbell Registersの4つの領域に分かれています。先頭のCapability Registersから、Operational Registersまでのオフセットや、対応ポート数、コンテキスト構造のサイズなどのハードウェア構成を読み取ります。
初期化はおおむね次の順序で進みます。
- ファームウェアがすでにコントローラを動作させていた場合は一旦停止する
USBCMDレジスタにHCRSTビットを立ててコントローラをリセットし、USBSTSのCNR(Controller Not Ready)ビットが下りるまで待つ- Device Context Base Address Array(DCBAA)・コマンドリング・イベントリングをそれぞれ確保し、対応するレジスタにベースアドレスを設定する
USBCMDのRun/Stopビットを立ててコントローラを稼働状態にする
このプロジェクトにはI/O APICやACPIテーブルの解析がまだ無いため、MSI/MSI-XやレガシーPCI割り込みのルーティングは今回のスコープ外としました。かわりに、イベントリングをメインループから定期的にポーリングする方式を採っています。CPUに割り込みで通知を送るか、ソフトウェア側から定期的に問い合わせるかの違いはありますが、コントローラ自身がイベントをリングに積む処理そのものは、どちらの方式でも変わりません。
デバイスの列挙とアドレス設定
コントローラの起動後、ポートレジスタ(PORTSC)を走査してデバイスが接続されているポートを見つけ、そのポートをリセットします。リセットが完了すると、ポートの速度(Low/Full/High Speed等)が読み取れるようになります。
続いてコマンドリングを介して次の2つのコマンドを発行します。
- Enable Slot Command:デバイスに割り当てるSlot ID(内部管理用の番号)をコントローラから受け取ります
- Address Device Command:Slot Contextとデフォルトコントロールエンドポイント(EP0)の情報を積んだInput Contextを渡し、デバイスにUSBアドレスを設定させます。実際のSET_ADDRESSリクエストの送信はコントローラが内部で行うため、ソフトウェア側から明示的に送る必要はありません
Slot ContextやEndpoint Contextは、いずれも32バイトないし64バイト単位の固定レイアウトを持つ構造体で、対応するビットフィールドに値を書き込んでいきます。
// drivers/xhci.zig
fn writeEndpointContext(base: usize, ep_type: u32, max_packet_size: u16, interval: u8, tr_dequeue_phys: u64, avg_trb_len: u16) void {
mmio32(base + 0).* = @as(u32, interval) << 16;
mmio32(base + 4).* = (3 << 1) | (ep_type << 3) | (@as(u32, max_packet_size) << 16); // CErr=3
mmio64(base + 8).* = tr_dequeue_phys | 1; // DCS=1
mmio32(base + 16).* = avg_trb_len;
}ディスクリプタの取得とHIDインターフェースの特定
アドレス設定が終わったEP0に対して、コントロール転送でディスクリプタを取得します。コントロール転送はSetup・Data・Statusの3段階からなり、それぞれ専用のTRB(Setup Stage TRB・Data Stage TRB・Status Stage TRB)をEP0の転送リングに積んでから、ドアベルレジスタで通知します。
最初のリクエストは、デバイスディスクリプタのうち先頭8バイトだけを要求するGET_DESCRIPTORです。コントロールエンドポイントの実際の最大パケットサイズ(bMaxPacketSize0)を知る前に安全に送れるリクエスト長が8バイトに限られるためで、この値を読んだ後に必要であればEvaluate Context Commandで補正します。
続いてコンフィグレーションディスクリプタを取得し、その中からInterface Descriptor(bInterfaceClassが3=HID)とEndpoint Descriptor(方向がIN、種別が割り込み転送)を自前でパースして探します。見つかったインターフェース番号・エンドポイントアドレス・最大パケットサイズをもとに、SET_CONFIGURATIONとSET_PROTOCOL(Boot Protocolを指定するクラス固有リクエスト)を送信します。
Boot Protocolへの切り替えと割り込み転送
Boot Protocolのキーボードは、8バイト固定フォーマットのレポート(モディファイアキー1バイト・予約1バイト・同時押しキーコード6バイト)を返します。Configure Endpoint Commandで割り込みINエンドポイントの転送リングを設定し、あらかじめ複数のNormal TRBをリングに積んでおくことで、キー入力のたびにコントローラが順番にレポートを書き込んでイベントを発生させる状態を作ります。
// drivers/xhci.zig
fn handleReport(buffer_index: usize) void {
const report: [*]volatile u8 = @ptrFromInt(@intFromPtr(report_buffers) + buffer_index * 8);
var new_keys: [6]u8 = undefined;
for (0..6) |i| new_keys[i] = report[2 + i];
for (new_keys) |code| {
if (code < 4) continue; // 0 = キーなし、1-3 = エラー状態
var already_down = false;
for (last_keys) |prev| {
if (prev == code) { already_down = true; break; }
}
if (!already_down) {
if (usageToAscii(code)) |ascii| pushKey(ascii);
}
}
last_keys = new_keys;
}Boot Protocolのレポートは「現在押されているキーの一覧」を毎回まるごと返す形式で、押した瞬間・離した瞬間だけを知らせる形式ではありません。そのため、直前のレポートと比較して新しく現れたキーコードだけを「押された」と判定しています。イベントを処理したリングのスロットは、その場で新しいNormal TRBを積み直して再利用し、ドアベルを再度鳴らすことで、リングが尽きることなく次のレポートを待ち続けられるようにしています。
つまずいた点:Setup Stage TRBの転送長
最初にコントロール転送を実装した際、デバイスディスクリプタの先頭8バイトを取得するGET_DESCRIPTORは成功する一方、256バイトを要求するコンフィグレーションディスクリプタの取得だけがタイムアウトする、という現象が起きました。
コマンドの発行自体は正常に進んでおり(Enable Slot・Address Deviceの完了イベントは受け取れている)、EP0のTransfer Dequeue Pointerを読むと、コントローラ側は3つのTRB(Setup・Data・Status)を実際に消費し終えていました。にもかかわらず完了イベントが一向に届かない、という状態です。
原因は、Setup Stage TRBの転送長フィールドに、リクエストのwLength(要求するデータの長さ)をそのまま渡していたことでした。Setup Stage TRBのこのフィールドは、実際にはリクエストの内容によらず常に8(Setupパケット自体のサイズ)でなければなりません。8バイトを要求する最初のリクエストではこの値がたまたま一致していたため問題が表面化せず、256バイトを要求する2件目のリクエストで初めて不一致が生じていました。
// drivers/xhci.zig
ep0_ring.push(
setupPacket(bm_request_type, b_request, w_value, w_index, length),
8, // Setup Stage TRBの転送長は常に8(Setupパケット自体のサイズ)で、wLengthではない
(TrbType.setup_stage << 10) | (1 << 6) | (trt << 16),
);固定値の8に直したところ、2件目以降のコントロール転送も正常に完了するようになりました。
QEMUでの確認
QEMUにはqemu-xhciというxHCIコントローラのデバイスモデルと、usb-kbdというUSBキーボードのデバイスモデルが用意されており、これらを使ってQEMU+OVMF上で先行動作確認を行いました。
キー入力を送ったところ、コンソールに文字が表示されることを確認できました。同じキーを繰り返し押した場合も、直前のレポートとの比較により重複せず1文字ずつ反映されます。また、割り込み転送のリングが持つスロット数(255個)を超える量のキー入力を連続して送っても表示が乱れたり止まったりしないことから、リングの折り返し(Cycle bitの反転を含む再利用処理)が想定どおりに機能していると判断できました。
今後
USBキーボードからの入力経路が整ったことで、次はコンソール入出力の抽象化とコマンドパーサを備えたTUIシェルに取り組みます。今回のBoot Protocol実装はキーボード1台・ハブなしという前提に絞っているため、マウスや複数デバイスへの対応、汎用的なHIDレポート記述子の解析は必要になった時点で改めて検討します。
参考リンク
- OSDev Wiki: PCI —— コンフィグレーション空間へのアクセス方法とレジスタレイアウトがまとまっています
- OSDev Wiki: Universal Serial Bus —— USBの転送方式やディスクリプタ構造の概要です
- xHCI Specification(Intel) —— レジスタ・TRB・コンテキストのレイアウトの一次情報源です
- USB HID Usage Tables —— キーボードのUsage IDとキーの対応関係の一次情報源です