【OS自作】littleosbook をやってみる #4

OS 自作シリーズの第四回。前回の記事は以下。

t-keita.hatenadiary.jp

littleosbook は以下。今回は6章から。

littleosbook.github.io

6 Interrupts and Input

この章では、ユーザからのキーボードの入力を受け付けるために interrupts(割り込み)をサポートする。割り込みは、ハードウェアの状態が変わったことを CPU に伝える場合だけでなく CPU 自身が発生させる場合もある。とにかく、CPU にとって想定していなかったことは割り込みとして表現されると思ったらよさそう。

6.1 Interrupts Handlers

割り込みは Interrupt Descriptor Table(IDT)によって管理される。IDT は割り込みの種類ごとにハンドラを管理している。割り込みハンドラの種類は3種類あるが、今回は trap handler を使う。おそらく、IDT に具体的な設定値を埋めて CPU に伝えるのがカーネルの役割。前回のメモリ管理の GDT と同じようなものっぽい。

6.2 Creating an Entry in the IDT

IDT に登録するエントリを定義してゆく。エントリは8バイトで構成され、フォーマットは以下の通りである。

f:id:t-keita:20211103233429p:plain

画像は Intel のマニュアル からの引用。

以下のように、IDT にハンドラの情報を登録する処理を実装した。(interrupt_handler_0 などの割り込みハンドラや load_idt 関数はこの後の手順で作るものである。)愚直に 0-255 番目のハンドラを登録したが、拾いたい割り込みだけ正しく設定出来ていればおそらく問題はなさそう。

struct idt_entry
{
    unsigned short offset_low;
    unsigned short segment;
    unsigned char zeros;
    unsigned char flags; // layout: P_DPL_0_D_110
    unsigned short offset_high;
} __attribute__((packed));

struct idt idt_info;
struct idt_entry idt_table[256];

void load_idt_table()
{
    unsigned int handler_address;

    handler_address = (unsigned int)interrupt_handler_0;
    idt_table[0].offset_low = handler_address & 0xFFFF;
    idt_table[0].offset_high = handler_address >> 16 & 0xFFFF;
    idt_table[0].segment = 0x0008;
    idt_table[0].zeros = 0x00;
    idt_table[0].flags = 0x8E; // = 0b10001110
    (省略)
    idt_info.address = (unsigned int)idt_table;
    idt_info.size = sizeof(struct idt_entry) * 256;
    load_idt(idt_info);   
}

6.3 Handling an Interrupt

IDT に割り込みハンドラを登録しておくと、その割り込みが発生したタイミングで CPU がハンドラを実行する。カーネルとしては、ハンドラが実行された事実によってのみ割り込みが発生したことを知ることができるとのこと。

CPU が割り込みハンドラを実行するとき、以下のような情報がスタックに push されるらしい。(あえてチュートリアルに載っている順序とは逆にしている。x86 ではスタックは番地が小さい方へ伸びる。この表記の方が、アドレスが昇順になるし、push された内容が上に積まれていくとイメージできるので個人的には分かりやすい。)

    [esp]      error code?
    [esp + 4]  eip
    [esp + 8]  cs
    [esp + 12] eflags

ハンドラの処理の最後に iret 命令を実行する。これを実行するときにはスタックが上記の状態である必要があるので、余計なものを push していたときは pop すべきらしい。この知識を使った実装は下でまとめて行う。

6.4 Creating a Generic Interrupt Handler

nasm のマクロを使ってハンドラとして登録するためのラベルとその処理を作ってゆく。まずはエラーコードが存在しない場合のために以下のようなマクロを定義する。チュートリアルでは1行目の 1%1 になっていたが、ここでは引数の個数を書くのが構文上正しいので、以下のように書くのが正しい。

    %macro no_error_code_interrupt_handler 1
    global interrupt_handler_%1
    interrupt_handler_%1:
        push    dword 0                     ; push 0 as error code
        push    dword %1                    ; push the interrupt number
        jmp     common_interrupt_handler    ; jump to the common handler
    %endmacro

ここでエラーコードとして 0 と interrupt number をスタックに push している。この挙動の意図を説明すると、6.3 章の通り、ハンドラが実行されるタイミングではスタックにエラー情報が積まれている。今回はエラーコードが存在しないケースなので、初期状態ではスタックが以下のようになっている。

    [esp]      eip
    [esp + 4]  cs
    [esp + 8]  eflags

これに対して、上で述べた2つの値を push するとスタックは以下の状態となる。

    [esp]       interrupt number
    [esp + 4]   0 (error code)
    [esp + 8]   eip
    [esp + 12]  cs
    [esp + 16]  eflags

一方で、エラーコードが存在する場合は以下のようなマクロを定義する。

    %macro error_code_interrupt_handler 1
    global interrupt_handler_%1
    interrupt_handler_%1:
        push    dword %1                    ; push the interrupt number
        jmp     common_interrupt_handler    ; jump to the common handler
    %endmacro

今回はハンドラが呼び出されるタイミングでエラーコードがすでに存在している。具体的には、スタックは以下のようになっている。

    [esp]       error code
    [esp + 4]   eip
    [esp + 8]   cs
    [esp + 12]  eflags

これに対して interrupt number を push するのでスタックは以下のようになる。

    [esp]       interrupt number
    [esp + 4]   error code
    [esp + 8]   eip
    [esp + 12]  cs
    [esp + 16]  eflags

ここで重要なポイントは、エラーコードが存在する場合も存在しない場合も、スタックの初期状態から esp レジスタの値が8バイト分だけ小さくなっているということである。これが、後で esp の値を元に戻すために esp の値を8だけ足す理由である。

次に C 言語で定義される interrupt_handler 関数を呼び出す部分を実装する。以下のように実装した。

   common_interrupt_handler:
        ; save the registers
        push    ebp
        push    edi
        push    esi
        push    edx
        push    ecx
        push    ebx
        push    eax
        ; call the C function
        call    interrupt_handler
        ; restore the registers
        pop   eax
        pop   ebx
        pop   ecx
        pop   edx
        pop   esi
        pop   edi
        pop   ebp
        ; restore the esp
        add   esp, 8
        ; return to the code that got interrupted
        iret

構造体は以下の通り。

struct cpu_state
{
    unsigned int eax;
    unsigned int ebx;
    unsigned int ecx;
    unsigned int edx;
    unsigned int esi;
    unsigned int edi;
    unsigned int ebp;
 } __attribute__((packed));

struct stack_state
{
    unsigned int error_code;
    unsigned int eip;
    unsigned int cs;
    unsigned int eflags;
} __attribute__((packed));

6.5 Loading the IDT

ここはチュートリアルの通りに load_idt を実装した。C 言語からアクセスするためのラッパーも作った。

6.6 Programmable Interrupt Controller (PIC)

PIC は、ハードウェアからのシグナルを割り込みにマッピングする集積回路である。PIC のデフォルトの設定では、キーボードからの入力シグナルを interrupt number が1である割り込みにマッピングする。しかしこの割り込みは CPU が発生させる割り込みと重複しているので他の番地の割り込みにマッピングし直したい。

もう少し PIC について理解を深めるために以下の osdev のページを見てみた。

wiki.osdev.org

PIC はハードウェアからの信号を interrupt requests(IRQ)という形で受信し、それを割り込みとして CPU に送信する。ハンドラによって処理が完了したことは PIC に伝える必要がある。そうしない限りは PIC は次の割り込みリクエストを送ってこないとのこと。

以下、実装したものを抜粋して紹介する。

まず、ハンドラの処理が完了したことを伝える pic_acknowledge 関数はチュートリアルの通りに実装した。interrupt number をリマップするための PIC の設定方法は SigOPS website [35] を参照とのことだったがこのリンクが切れている。ツラい。そこでこのチュートリアルをやったと思われる人の Github リポジトリを覗いてみた。

だいたいこの通りに設定した。以下のような感じ。

void init_idt()
{
    outb(PIC_1_COMMAND, PIC_ICW1_INIT + PIC_ICW1_ICW4); // starts the initialization sequence (in cascade mode)
    outb(PIC_2_COMMAND, PIC_ICW1_INIT + PIC_ICW1_ICW4);
    outb(PIC_1_DATA, PIC1_START_INTERRUPT); // ICW2: Master PIC vector offset
    outb(PIC_2_DATA, PIC2_START_INTERRUPT); // ICW2: Slave PIC vector offset
    outb(PIC_1_DATA, 4);                    // ICW3: tell Master PIC that there is a slave PIC at IRQ2 (0000 0100)
    outb(PIC_2_DATA, 2);                    // ICW3: tell Slave PIC its cascade identity (0000 0010)
    outb(PIC_1_DATA, 0x01);                 // ICW4: 8086/88 (MCS-80/85) mode
    outb(PIC_2_DATA, 0x01);                 // ICW4: 8086/88 (MCS-80/85) mode
    // Setup Interrupt Mask Register (IMR)
    outb(PIC_1_DATA, 0xFD); // 1111 1101 - Enable IRQ 1 only (keyboard).
    outb(PIC_2_DATA, 0xFF);

    asm("sti"); // Enable interrupts.
}

詳しいところまでは調べていないが、シリアルポートに設定値を送って PIC が発生させる割り込みの interrupt number をずらしている。最後の asm("sti");アセンブリ命令であり、これを呼び出さないと割り込みが有効化されないらしい。

6.7 Reading Input from the Keyboard

キーボードからの信号を読み取るには、チュートリアルにある通りにシリアルポートから char を読み取ればよい。ただし、この信号は scan code であり、これを ascii にマッピングする必要がある。ちゃんと頑張るようなところでもないと思ったので、適当に以下のアンサーにあるコードを移植してみた。

最後に、割り込みハンドラは以下のように実装してみた。

void interrupt_handler(struct cpu_state cpu, unsigned int interrupt, struct stack_state stack)
{
    // unused parameters
    cpu = cpu;
    stack = stack;

    if (interrupt == 0x21)
    {
        // scan code from the keyboard
        char scancode = inb(KBD_DATA_PORT);
        char input_char = kbd_US[(unsigned int)scancode];
        if (input_char != 0)
        {
            write_len(&input_char, 1);
        }
        pic_acknowledge(interrupt);
    }
}

キーボードから送られる信号が PIC によって 0x21 番の割り込みにマッピングされるので、0x21 のときに画面に文字を表示するようにしている。なお、文字に対応しないキーの信号はここでは無視している。たとえば Ctrl キーを押しても画面上は何も変化が起こらない。

以上を実装したものを Bochs で起動して動作確認してみた。このとき仮想キーボードの設定などは必要なかった。

f:id:t-keita:20211111010932p:plain:w600

緑色の文字の hello world はキーボードから入力したものである。同じようにしてコンソールっぽいものは作れそうな気がする。めでたし。

所感

割り込み処理はコールバックとして CPU に登録する仕組みになっている。また PIC がハードウェアと CPU をつなぐ役割を果たしている。今回はこんなイメージが具体的に湧くようになったのが成果。今この瞬間ブログを書いているときも、CPU は PIC を経由して割り込みを認識し、キーボードからの信号を文字に変換する処理を行っていると思うと感慨深い。

今回は PIC の正しい設定方法を探すのに苦労した。結果的には同じチュートリアルをやったっぽい人のリポジトリを覗くという元も子もない感じになったが、調べるのに時間使いすぎるのもアレなのでこんな感じでよしとしよう。