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

前回に続き、OS 自作のチュートリアル littleosbook の続きをやっていく。前回の内容は以下。

t-keita.hatenadiary.jp

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

littleosbook.github.io

追記:本家の littleosbook は不備が多いがメンテナンスされてない。以下はフォークされたリポジトリであり、かなりの不備が修正されている。絶対こっち見るほうがオススメ。

ordoflammae.github.io

4 Output

この章では、カーネルとハードウェアをつなぐドライバを作る。前半は、コンソール上にテキストを表示する framebuffer のドライバを作る。後半は、シリアルポートのドライバを作る。Bochs ではシリアルポートからの出力をファイルに保存できるので、OS 開発のロギングのために使用するとのこと。

4.1 Interacting with the Hardware

ハードウェアとやりとりするには memory-mapped I/O と I/O ports の2通りある。memory-mapped I/O では特定のアドレスのメモリに書き込むだけでハードウェアの挙動が更新される。I/O ports を用いた場合は out 命令や in 命令を使ってハードウェアとやりとりする。ソケット通信のような感じ。

4.2 The Framebuffer

framebuffer は、メモリの内容をスクリーンの描画するデバイスである。

4.2.1 Writing Text

framebuffer は memory-mapped I/O であり、0x000B8000 番地から始まるメモリ領域に値を書き込むとコンソールにテキストを書き込めるらしい。

たとえば、A という文字を緑色の文字で灰色の背景色で表示したかったら以下のように mov すればよいとのこと。

mov [0x000B8000], 0x4128

しかし、error: operation size not specified のようなエラーが出てアセンブリコンパイルが通らない。調べたところ、mov 命令するときにサイズの指定が 必要らしい。今回は2バイトなので word を指定してみる。

mov word [0x000B8000], 0x4128

コンパイラは通るようになったが、( という文字が赤色の背景に青文字で表示されてしまう。記載されているメモリのバイトと色などの対応がおかしいと思われる。いろいろ調べた結果、代わりに以下のページの Text buffer のレイアウトに従うと意図通りに A が表示されるようになった。

en.wikipedia.org

チュートリアルに記載されているものと Wikipedia のものが全然違う。この違いがどこから来ているのかは不明。とりあえず自分の環境では Wikipedia のものを参考にするのが正しそう。ちょっと遊んでみると、Hello と表示したかったら以下のようにアセンブリコードを書けばよい。

        mov word [0x000B8000], 0x4248 ; H
        mov word [0x000B8002], 0x4265 ; e
        mov word [0x000B8004], 0x426C ; l
        mov word [0x000B8006], 0x426C ; l
        mov word [0x000B8008], 0x426F ; o

各 mov 命令が2バイトの値を代入しているので、次の文字を表示するための番地も2ずつ増やしている。なぜかチュートリアルでは次のセルに行くには16増やさないといけないと書いている。なぜなんだ。実行結果は以下のようになる。先頭から Hello の文字列を表示できた。

f:id:t-keita:20211018232645p:plain:w500

ここからは、以上の動作を C プログラムとして書いておくことで便利にしようというもの。上記で述べた不整合があるので、それを考慮して直した C コードは以下。動作確認のため kmain 関数内で Hello を描画するようにしてみた。

#define LIGHTGREEN 0x0A
#define BLACK 0x00

char *fb = (char *)0x000B8000;
void fb_write_cell(unsigned int i, char c, unsigned char fg, unsigned char bg)
{
    fb[i] = c;
    fb[i + 1] = ((bg & 0x0F) << 4) | (fg & 0x0F);
}

void kmain()
{
    fb_write_cell(0, 'H', LIGHTGREEN, BLACK);
    fb_write_cell(2, 'e', LIGHTGREEN, BLACK);
    fb_write_cell(4, 'l', LIGHTGREEN, BLACK);
    fb_write_cell(6, 'l', LIGHTGREEN, BLACK);
    fb_write_cell(8, 'o', LIGHTGREEN, BLACK);
}

アセンブリからは以下のように呼び出せる。チュートリアルでは external 命令を使うと書いているが extern を使うのが正しいっぽい。

    extern kmain
    call kmain

ここまでバグ修正しながら頑張ってきたが、よく見ると同様の修正のプルリクエストが上がっていた。やっぱ自分の環境の問題じゃないのかなぁ。

github.com

4.2.2 Moving the Cursor

ここからはカーソルを動かす話。I/O ports を使ってカーソルの位置をハードウェアに送信する。カーソルの位置は2バイトで指定されるので、上位1バイトと下位1バイトを分けて送信する必要がある。上位であるか下位であるかは 0x3D4 ポートに 14 or 15 を送信を送信することで表明し、実際のデータは 0x3D5 ポートに送信する。

手順通りに進めれば C プログラムからカーソルの位置を指定できるようになった。ただし、途中で undefined reference to outb みたいなエラーメッセージが出て make run できなかった。以下の issue を参考に Makefile を修正すると直った。ld コマンドのためのオブジェクトが足りていなかったみたい。

github.com

4.2.3 The Driver

fb_write_cell 関数で1文字ずつスクリーンに文字を表示させるのは面倒なので、文字列をまとめて表示する write 関数を作るのがよいとのこと。

その通りに自分で C コードを書いてみたがどうも上手くいかない。具体的には、C コードの中で文字列リテラルを使うとコンパイルは通るが、Bochs を起動すると Error 13: Invalid or unsupported executable format のようなエラーが出る。困った。

いろいろ調べてみると同じ問題に遭遇している人がいた。

stackoverflow.com

問題としては、loader.s の section .text: というセグメント定義のコロンが不要らしい。正しくは section .text で text セグメントを定義できる。このエラーが、文字列リテラルを格納する rodata セグメントに影響を与えていたらしく、文字列リテラルを使ったときだけエラーが出るようになるとのこと。よく見ると issue としてこの問題を報告しているものもあった。はやく本家が直してくれ、無駄に時間が溶ける...。

結果として、以下の write 関数を実装してみた。表示の開始位置(cursor)は今のところ適当にしている。表示する文字数は関数の引数から外し、常に引数の文字列すべてを表示するようにしている。

unsigned int cursor = 0;
void write(char *buf)
{
    for (unsigned int i = 0; buf[i] != '\0'; i++)
    {
        fb_write_cell(cursor + i * 2, buf[i], LIGHTGREEN, BLACK);
    }
}

4.3 The Serial Ports

serial ports はハードウェア同士を接続するためのインターフェースである。serial ports の制御は I/O ports を介して行われる。つまり、I/O ports の特定のポートにデータをいろいろ送りつければ、serial ports を使えるということだと思われる。このチュートリアルの記述だけではシリアルポートの意味がよく分からなかったので、以下の OSDev Wiki のページを見てみた。

wiki.osdev.org

Bochs などのエミュレータがシリアルポートの出力をホストマシンに標準出力やファイルで伝達できることから、OS 開発においてシリアルポートがよく使われるとのこと。COM1, COM2,... などの COM ports というのは serial ports のことを指していると思ったらよさそう。COM1 を使うとして、COM1 の 0x3F8 ポートにオフセットを指定すると、各種データを設定するためのポートになる。たとえば、オフセットとして +3 を指定すると「Line Control Register」であり、このポートに設定値を送りつけると通信の設定ができる。

4.3.1 Configuring the Serial Port

2つのハードウェアを通信するには通信の設定を揃える必要があり、そのための設定値を送る必要がある。設定の例として bit rate, parity bit, data units の3つが挙げられている。これらの値をこれから設定してゆく。

4.3.2 Configuring the Line

ここではデータ送信の速度を示す bit rate を設定する。serial port ごとに line command port というのがあって、これは COM1 のようなベースとなるポートに +3 すれば求まる。速度の設定は 115200 Hz を割る値を 16 bits で指定し、data port(これはベースとなるポートと同じ)に送ればよい。ただし data port には 8 bits ずつしか送れないので、前半と後半の1バイトずつに分けて送る。

4.3.3 Configuring the Buffers

serial port を介した通信のバッファについての設定。以下のように 0xC7 を設定してみた。

void serial_configure_FIFO(unsigned short com)
{
    outb(SERIAL_FIFO_COMMAND_PORT(com), 0xC7);
}

4.3.4 Configuring the Modem

モデムの設定。これは何を設定しているのかよく分からんが、以下のようなコードを書けばよいことは分かった。

void serial_configure_modem(unsigned short com)
{
    outb(SERIAL_MODEM_COMMAND_PORT(com), 0x03);
}

4.3.5 Writing Data to the Serial Port

いよいよデータ本体を serial port に書き込む処理を実装する。チュートリアルのとおりに outb 関数を実装すれば1文字送信できるようになる。一応 write っぽさを出すために以下のように関数を定義してみた。

void serial_write_char(unsigned int com, unsigned char c)
{
    outb(com, c);
}

また、serial port の通信の準備をまとめて行うための関数を以下のように定義してみた。

void serial_initialize(unsigned short com)
{
    serial_configure_baud_rate(com, 0x03);
    serial_configure_FIFO(com);
    serial_configure_line(com);
    serial_configure_modem(com);
}

ここまでの関数を組み合わせて実際に文字を送信する処理は 4.3.7 章でやってみた。

4.3.6 Configuring Bochs

serial port とホスト側のファイルを関連付けるための設定を追加する。

4.3.7 The Driver

以上のようなシリアルポートへの書き込みを、文字列を引数とする write 関数として作っておくのがよいとのこと。ってことで以下のように logging 関数を作ってみた。serial port との通信は準備をしてからデータを送る流れになる。

void logging(char *buf)
{
    for (unsigned int i = 0; buf[i] != '\0'; i++)
    {
        // preparation
        serial_initialize(SERIAL_COM1_BASE);
        while (serial_is_transmit_fifo_empty(SERIAL_COM1_BASE) == 0)
            ;

        // send data
        serial_write_char(SERIAL_COM1_BASE, buf[i]);
    }
}

動作確認をするために以下のように logging 関数を呼び出してみる。

logging("os development is fun!\n");

するとログファイルに内容が書き込まれていることを確認できた。

$ cat com1.out 
os development is fun!

これで今後のデバッグがやりやすくなった。めでたし。

所感

今回はトラブルシューティングにかなりの時間が溶けた。本家チュートリアルケアレスミスが原因であるものが多いというのが悲しいところ。GitHub にはそれを指摘する issue やプルリクエストが溜まっているので、これらを取り込むだけでチュートリアルとしてのクオリティがかなり上がると思われる。メンテナンス頑張ってほしい。次回はメモリ管理について。楽しそうなのですぐやろう。