PICの再マッピングとタイマー・キーボードの割り込み処理
これまでのPhaseで、CPU例外(vector 0〜31)はIDTを構築してCPU例外を捕捉するの記事からずっと処理できていましたが、ハードウェア割り込み(IRQ)は一度も扱っていませんでした。理由は同記事で触れた通り、レガシーPICが初期状態でIRQ0〜7をCPU例外と同じvector 8〜15にマッピングしており、これを解消しないまま割り込みを有効化するのは危険だったためです。今回はこのPICの再マッピングを行い、タイマー(PIT)とPS/2キーボードをIRQ経由で受け取れるようにします。
PICの再マッピング
8259 PIC(Programmable Interrupt Controller)に対して、ICW(Initialization Command Word)と呼ばれる一連のコマンドを送ることで、IRQをどのvectorに割り当てるかを変更できます。
// arch/pic.zig
pub fn remap(master_offset: u8, slave_offset: u8) void {
io.outb(master_command, 0x11);
io.outb(slave_command, 0x11);
io.outb(master_data, master_offset);
io.outb(slave_data, slave_offset);
io.outb(master_data, 0x04);
io.outb(slave_data, 0x02);
io.outb(master_data, 0x01);
io.outb(slave_data, 0x01);
io.outb(master_data, 0xFF);
io.outb(slave_data, 0xFF);
}CPU例外がvector 0〜31を占有しているため、IRQ0〜7をvector 32〜39へ、IRQ8〜15をvector 40〜47へ移動させます。再マッピング直後はすべてのIRQをマスクした状態にしておき、実際に使うものだけを個別に有効化する設計にしました。UEFIから引き継いだ時点でのマスク状態を信用せず、明示的に確定させておく狙いです。
割り込み処理を終えたら、PICに対して「処理が終わった」ことを通知するEOI(End Of Interrupt)を送る必要があります。これを送らないと、そのIRQ以降の優先度の割り込みがPICから配送されなくなります。
// arch/pic.zig
pub fn sendEoi(irq: u8) void {
if (irq >= 8) io.outb(slave_command, 0x20);
io.outb(master_command, 0x20);
}IDTにIRQディスパッチを追加する
CPU例外のハンドリングは致命的なものとして扱い、そのまま停止する設計でした。IRQはそうはいきません。タイマーやキーボードの割り込みを処理したあとは、割り込まれていた元の処理へ戻る必要があります。つまりCPU例外用のスタブとは異なり、汎用レジスタをすべて退避・復帰したうえでiretqする実装が必要です。
// arch/idt.zig
export fn isrIrqCommonStub() callconv(.naked) noreturn {
asm volatile (
\\ push %%rax
\\ push %%rbx
\\ push %%rcx
\\ push %%rdx
\\ push %%rsi
\\ push %%rdi
\\ push %%rbp
\\ push %%r8
\\ push %%r9
\\ push %%r10
\\ push %%r11
\\ push %%r12
\\ push %%r13
\\ push %%r14
\\ push %%r15
\\ mov 120(%%rsp), %%rcx
\\ sub $0x20, %%rsp
\\ call irqDispatch
\\ add $0x20, %%rsp
\\ pop %%r15
\\ pop %%r14
\\ pop %%r13
\\ pop %%r12
\\ pop %%r11
\\ pop %%r10
\\ pop %%r9
\\ pop %%r8
\\ pop %%rbp
\\ pop %%rdi
\\ pop %%rsi
\\ pop %%rdx
\\ pop %%rcx
\\ pop %%rbx
\\ pop %%rax
\\ add $8, %%rsp
\\ iretq
);
}pushしたIRQ番号は、15個のレジスタを退避した分だけスタックの深いところ(120バイト先)に残っているので、callの直前にそこから読み出して引数レジスタ(rcx)に入れています。ここで16バイト境界へのスタック整列をand命令で計算し直していないのは意図的です。Intel SDMは「割り込みエントリ時点でRSPは16バイト境界に揃っている」ことを保証しており、IRQ番号1個+レジスタ15個=16個分のpush(128バイト、16の倍数)を行った時点でも整列は崩れません。前回のGDT/IDTの実装で使っていた、実行時にandで強制的に切り詰めるやり方より単純に済んでいます。
呼び出されたirqDispatchは、登録されているハンドラを呼び、EOIを送るだけの薄いディスパッチです。
// arch/idt.zig
export fn irqDispatch(irq: u64) callconv(.c) void {
if (irq < 16) {
if (irq_handlers[irq]) |handler| handler();
}
pic.sendEoi(@intCast(irq));
}PIT:タイマー割り込み
PIT(8253/8254)はチャンネル0をIRQ0に配線するのが伝統的な構成です。入力クロック(1.193182MHz)を望みの割り込み頻度で割った値を「分周比」としてチャンネルに設定します。
// arch/pit.zig
pub fn init(frequency_hz: u32) void {
const divisor: u32 = input_frequency / frequency_hz;
io.outb(command_port, 0x36);
io.outb(channel0_data, @truncate(divisor));
io.outb(channel0_data, @truncate(divisor >> 8));
}今回は100Hz(10ミリ秒間隔)に設定し、ハンドラは単純にカウンタを1増やすだけにしています。実際にこのカウンタを使った処理(スリープやスケジューリングなど)は、後のPhaseで必要になったタイミングで実装する予定です。
PS/2キーボード:ポーリングから割り込み駆動へ
キーボードからのスキャンコードは、IRQ1が発生するたびにコントローラのデータポートから読み出し、小さなリングバッファに溜めておく実装にしました。
// arch/ps2.zig
pub fn onIrq1() void {
const code = io.inb(data_port);
const next = (write_index + 1) % buffer.len;
if (next != read_index) {
buffer[write_index] = code;
write_index = next;
}
}データポートの読み出しは、バッファに空きがあるかどうかに関わらず必ず行っています。これはコントローラ側の「送信済みでまだ読まれていない」状態を解除するために必要な操作で、読まなければ次のキー入力がいつまでも配送されません。バッファが満杯の場合は、読んだ値をその場で捨てています。
スキャンコードそのものをキーの意味(アルファベットや記号)に変換する処理はまだ実装していません。何が押されたかを判別する必要が出てくるのはPhase 11のTUIシェルからなので、それまでは生のスキャンコードをバッファに溜めておくだけにとどめています。
なぜ物理PS/2ポートの無い実機でPS/2から着手するのか
対象実機(MousePro CR-I1U01)には物理的なPS/2ポートがありません。USBキーボードしか接続できない機体で、あえてPS/2コントローラ(8042)を実装するのは一見遠回りに見えます。
USBキーボードの本格的な入力対応には、USBコントローラ(xHCI)の初期化、デバイス列挙、HIDレポートの解析が必要で、実装規模がPS/2コントローラとは桁違いに大きくなります。一方、多くのAMI系ファームウェアは「USB Legacy Support」という機能を持ち、SMM(System Management Mode)経由でUSBキーボードの入力を裏側で拾い、OSから見ると従来の8042コントローラが存在するかのように見せかけるエミュレーションを提供します。物理ポートが無くても、このエミュレーションだけがそのまま生き残っている機体は珍しくありません。
今回実装したコードは、このエミュレーションが機能する前提で書かれています。実機での動作確認はまだ行っておらず、動かなければUSB HID対応を別Phaseとして切り出す方針です。
割り込みを有効化する
PICの再マッピング、ハンドラの登録、必要なIRQだけのマスク解除まで終えたところで、ようやくPhase 5から保留にしていたstiを呼び出します。
// kernel/kernel.zig
arch.pic.remap(32, 40);
arch.idt.setIrqHandler(0, arch.pit.onTick);
arch.idt.setIrqHandler(1, arch.ps2.onIrq1);
arch.pic.setMask(0, false);
arch.pic.setMask(1, false);
arch.pit.init(100);
arch.interrupts.enable();QEMUでの確認
テキスト出力がまだ無いため、実機での動作確認用に別途用意していた「数字キーで画面の背景色を切り替える」一時的なデバッグコードを流用して確認しました。もともとはPS/2コントローラを直接ポーリングする実装でしたが、今回IRQ1経由のreadScanCode()に差し替えたため、実際にこのPhaseで実装した割り込み経路を通った結果として色が変わることになります。
QEMU側からキー入力を送ったところ、期待通りに背景色が切り替わりました。タイマー割り込みは100Hzで常時発生し続けているため、この間ずっとIRQ0が繰り返し配送・処理されていたことになりますが、レジスタの退避・復帰に問題があれば早い段階で状態が壊れて画面が乱れるか、panicが発生していたはずです。数秒間動作させ続けても表示が安定したままだったことから、isrIrqCommonStubのレジスタ退避・復帰とiretqによるリターンが正しく機能していると判断しました。
今後
今後はフレームバッファへのテキスト描画に取り組みます。ここまでのPhaseで色の塗りつぶしだけに頼ってきた動作確認の手段が、ようやく文字による表示に置き換わる見込みです。
参考リンク
- OSDev Wiki: 8259 PIC —— PICの再マッピング手順とICWの意味がまとまっています
- OSDev Wiki: Programmable Interval Timer —— PITのモードや分周比の計算方法を扱っています
- OSDev Wiki: PS/2 Keyboard —— スキャンコードセットや8042コントローラの詳細です
- Intel 64 and IA-32 Architectures Software Developer's Manual —— 割り込みエントリ時のスタック整列保証を含む、割り込み処理全般の一次情報源です(Volume 3A, Chapter 6)