sykwer’s blog

力こそパワー

[組み込みOS - kozos] スイッチを押してからOSがコマンド応答するまでを追う

これは 自作OS Advent Calendar 2018 - Adventar 23日目の記事です

0. はじめに

@kozossakai さんが開発された kozos という, H8/3069Fの上で動く組み込みOSをベースにして, 色々改造した内容をアドベントカレンダー向けに書きたかったのですが, 時間がとれなかったので内容を急遽変更し, 「リセットスイッチを押してからOSがコマンドに応答して出力をコンソールに返すまで」の処理を順番に追っていくという記事にすることにしました. 論文とかではないので新規性は気にしません.

H8/3069F

ここで追っていくコードは, 12ステップで作る組込みOS自作入門 で完成したコードを自分の扱いやすいように少しいじったものになります. 以下のrepoでv0.1.0として公開されています.

github.com

1. H8/3069Fについて

この組み込みOSが動作するマイクロコンピュータである, H8/3069Fに関する簡単な前提知識を書いておきます.

  • H8/3069Fは, H8/300H CPUを核にした, シングルチップマイクロコンピュータ
  • 内部32ビット構成で, 16ビット × 16本の汎用レジスタを持つ 16MBのリニアなアドレス空間を扱うことができる
  • 今回用いる周辺機能は, ROM, RAM, Serial Communication Interface(シリアル通信を担う周辺デバイス. 以後SCI)
  • 512KBのフラッシュメモリ(ROM)と, 16KBのRAMが内蔵されている
  • SCIは3チャネル存在するが, 今回は1チャネルしか用いない

2. 大まかな流れ

まず, bootloaderの実行バイナリをあらかじめROMに焼き込んでおきます(この焼き込み回数に上限があるので, bootloaderはあまり変更ができません. 手順は README参照). OSのコードは, bootloaderのコードの中からダウンロードされます.

リセットスイッチを押すとまずbootloaderが起動し, bootloaderはホストマシンからOSの実行可能ファイルをシリアル経由でダウンロードしてRAM上に展開し, そしてbootloaderがOSを起動するという流れになります.

さっそく, リセットスイッチを押すとまず何が起こるのか..から始めたいところですが, メモリマッピングが分からないと処理の内容が分かりにくいと思うので, bootloaderのメモリマッピングを見てから, bootloaderの処理を見ていくことにします.

3. bootloaderのメモリマッピング

アドレス割り当てを知るには, bootloaderのリンカスクリプトを見ればいいのですが図にしておきます. 前章で説明したように, H8/3069Fには512KBのフラッシュROMと16KBのRAMが内蔵されており, 図のようにアドレスがマッピングされます.

(下の図には表記されていませんが, 0xffff20 ~ 0xffffe9 には内蔵I/Oレジスタマッピングされています)

memory mapping of bootloader

次に定義された各領域にどのようなデータが割り当てられるのかを知るために, セクションの定義を見てみます. セクションの定義は以下のように書かれています(長いのでソースのコメントで解説を書きます).

SECTIONS
{
     /* vectorsの領域には, vector.oというオブジェクトファイルのdata領域の */
     /* 情報が書き込まれます (このvectorとは割り込みベクタに当たります) */
    .vectors : {
        ../../bin/bootload/obj/vector.o(.data)
    } > vectors

    /* text領域(実行可能コード)はromに書き込まれます */
    .text : {
        _text_start = . ;
        *(.text)
        _etext = . ;
    } > rom

    /* read only data はromに書き込まれます */
    .rodata : {
        _rodata_start = . ;
        *(.strings)
        *(.rodata)
        *(.rodata.*)
        _erodata = . ;
    } > rom

    .softvec : {
       _softvec = .;
    } > softvec

    .buffer : {
        _buffer_start = . ;
    } > buffer

    /* data領域とbss領域においては, 変数の初期値はROM上に焼きこまれ, */
    /* プログラムがdata/bss領域の変数を見にいくときは, RAM上のアドレスを見にいくようになっています. */
    /* これは, 静的変数を実行中に書き換えられるようにするための処置であり,  */
    /* リンカスクリプトでは, 以下のように特別な記法がなされる */
    /* (後々, ROM上の初期値を, RAM上にコピーする処理が行われるので, このようなことが可能になります) */
    .data : {
        _data_start = . ;
        *(.data)
        _edata = . ;
    } > data AT> rom

    .bss : {
        _bss_start = . ;
        *(.bss)
        *(COMMON)
        _ebss = . ;
    } > data AT> rom

    . = ALIGN(4);
    _end = . ;

    .bootstack : {
      _bootstack = .;
    } > bootstack

    .intrstack : {
      _intrstack = .;
    } > intrstack
}

また, 後々プログラム中からアドレスを取得するために, 以下のようなラベルが然るべきアドレスに対して設定されています.

  • text_start, etext : text領域の開始と終了
  • rodata_start : read only dataの領域の開始と終了
  • softvec : ソフトウェア割り込みベクタ(実際の割り込みハンドラへのポインタ. RAM上に置かれるので実行時に変更可) のアドレス
  • buffer_start : OSの実行可能ファイルがダウンロードされるときのバッファ領域の開始
  • data_start, edata : data領域の開始と終了
  • bss_start, ebss : bss領域の開始と終了
  • bootstack : bootloaderの処理のスタックはこのアドレスから積まれる
  • intrstack : 割り込みハンドラの処理のスタックはこのアドレスから積まれる

bootloaderの処理で使用したスタックが, 後々割り込みハンドリングで使用されるスタックで上書きされてしまいますが, bootloaderのスタックを保存する必要はないので問題ありません.

4. Bootloader

それではリセットボタンを押してみましょう.

4.1. リセットボタンを押してからスタートアップまで

push reset button

リセットスイッチを押すと, まず割り込みベクタの0番目を見にいき, そこに格納されているアドレスにプログラムカウンタを書き換えて, 実行を開始します.

割り込みベクタの定義は bootload/vector.c に書かれています.

割り込みベクタとは, 周辺機器やシステムコールなどで割り込みが発生したときに, 要因に応じた場所の割り込みベクタ(下記のstartとかintr_serintr とか)を見て, そのアドレスに飛ぶというものでした. (つまり, 割り込みベクタ中の関数ポインタは, 割り込みハンドラのアドレスであると言い換えられる)

void (*vectors[])(void) = {
        start, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
        intr_syscall, intr_softerr, intr_softerr, intr_softerr, // trap interrupt vectors
        NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
        NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
        NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
        NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
        NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
        intr_serintr, intr_serintr, intr_serintr, intr_serintr, // SCI0 interrupt vectors
        intr_serintr, intr_serintr, intr_serintr, intr_serintr, // SCI1 interrupt vectors
        intr_serintr, intr_serintr, intr_serintr, intr_serintr, // SCI2 interrupt vectors
};

前章より, この配列は 0x000000 ~ 0x0000ff の領域に書き込まれているのでした. 割り込みベクタの0番目は start という関数へのポインタです. つまり, リセットスイッチを押すと, start という関数から処理が始まります.

startbootload/asm/startup.S に定義されています.

_start:
    mov.l  #_bootstack,sp
    jsr  @_main

スタックポインタに #_bootstack というアドレスを設定したのち, main という関数へのポインタにプログラムカウンタを書き換えています. #_bootstack は前章で見たリンカスクリプトで定義されていたラベルで, この先の処理では, このアドレスからスタックが積まれます. また, sper7エイリアスであり, er7 で表されるレジスタに現在のスタックポインタが保持されます.

4.2. main関数 - 初期化

mainbootload/main.c に定義されています(現在の説明でみる必要のないソースは省略 することにします).

static int init(void) {
    extern int erodata, data_start, edata, bss_start, ebss; // defined in linker script

    // グローバル変数の初期化(=> 下記 説明⑵)
    memcpy(&data_start, &erodata, (long) &edata - (long) &data_start);
    memset(&bss_start, 0, (long) &ebss - (long) &bss_start);

    // ソフトウェア割り込みベクタの初期化(=> 下記 説明⑶)
    softvec_init();

    // 今回使用するSerial Communication Interfaceの初期化(=> 下記 説明⑷)
    serial_init(SERIAL_DEFAULT_DEVICE);
}

int main(void) {
    /* 省略 */

   // 割り込みを禁止 (=> 下記 説明⑴)
    INTR_DISABLE;

    init();

    /* 省略 */
}

説明⑴

まず, ブートロード中に割り込みが入ると非常に困るので INTR_DISABLE マクロで割り込みを無効にします.

INTR_DISABLEbootload/interrupt.h で定義されています.

#define INTR_DISABLE asm volatile ("orc.b #0xc0, ccr")

INTR_DISABLEは, ccr というレジスタに対して or 演算で 0xc0 を足しこむインラインアセンブラであると分かります. ccr は1バイトのレジスタで, 最上位ビットを1にすることで割り込みが無効になります.

さて, init 関数の中では以下の説明⑵ ~ ⑷に解説される処理を行なっています. (なお, memcpy などのライブラリ関数は bootload/lib.c に定義されています)

説明⑵

ROM上のdata領域のデータを, RAM上のdata領域にコピーしています. これは, 前章で見たリンカスクリプトの解説のとおり, 「data領域とbss領域においては, 変数の初期値はROM上に焼きこまれ,プログラムがdata/bss領域の変数を見にいくときは, RAM上のアドレスを見にいくようになっている」ために必要な処理です.

また, RAM上のbss領域を初期化(ゼロクリア)しています. 初期値がゼロ値と決まっているので, memsetでゼロクリアしているだけです.

説明⑶

ソフトウェア割り込みベクタを初期化(ゼロクリア)しています (softvec_init). 現在はゼロクリアされた状態ですが, OS側から実行時に, 割り込みハンドリングの実際の処理を行う関数のポインタがここには設定されます.

説明⑷

今回使用するSCIのレジスタに初期値をセットしています(serial_init). serial_initの実際のコードとH8/3069Fの仕様書を読めば何をしているか理解できます.

4.3. main関数 - ホストPCからOS実行可能ファイルをダウンロード

さて, main 関数の続きを見ていきます.

int main(void) {
    static char buf[16]; // buffer for command from host PC
    static long size = -1;
    static unsigned char *loadbuf = NULL; // buffer for os binary image

    extern int buffer_start; // defined in linker script

    /* 省略 */

    puts("kzload (kozos boot loader) started \n");

    while (1) {
        puts("kzload> ");
        gets(buf);

        if (!strcmp(buf, "load")) {
            loadbuf = (char *) (&buffer_start);
            size = xmodem_recv(loadbuf);
            wait(); // 3*10^5 回のbusy waitをしているだけです

            if (size < 0) {
                puts("\nXMODEM receive error\n");
            } else {
                puts("\nXMODEM receive succeeded!\n");
            }
        }

        /* 省略 */
    }
    /* 省略 */
}

まず, puts() は内部で serial_send_byte() を繰り返し呼ぶことにより, SCIの特定のレジスタを操作して, シリアル経由でホストPCに文字を送信しています(送信可能かどうかの判定はbusy loopでSCIの特定のレジスタを見張っています).

シリアル経由で, ホストPCのコンソールにbootloaderの開始メッセージとプロンプトが表示されました.

bootloader started!

ここでコマンドを入力した上で enterを押して送信すると, gets()が繰り返しserial_recv_byte()を呼び出すことで, シリアル経由で送られてきた文字を, SCIの特定のレジスタから取得します(受信があるかどうかの判定はbusy loopでSCIの特定のレジスタを見張っています).

さて, 上記の main の中の処理は, コマンドとしてload を受け取った場合にあたります.

リンカスクリプトで定義された buffer_startが指す, buffer領域の始まりのアドレスを xmodem_recv() という関数に渡しています. この関数は, XMODEMというシリアル通信のプロトコルで, ホストPCからOSの実行形式ファイルを受け取り, buffer領域上に展開する処理をします.

XMODEMのプロトコルにより, OSの実行形式ファイルを受け取ってbuffer領域に格納する処理は bootload/xmodem.c に実装されているので, 興味のある人は読んでみてください.

ホストPCからはcuで接続し, lsxコマンドでOSのファイルを送信しました

ここまでで, 「OSの実行形式ファイルがbuffer領域上に展開された状態」が達成されました. あとは, 「buffer領域上の実行形式ファイルを, ベタバイナリとしてRAM上にロードして, OSのエントリポイントにプログラムカウンタを書き換えてジャンプする」を達成すればOSが起動できます!

4.4. main関数 - OS実行可能ファイルのロードとOS起動

さて, main の続きを見ていきます.

int main(void) {
    /* 省略 */
    while (1) {
        puts("kzload> ");
        gets(buf);

        if (!strcmp(buf, "run")) {
            entry_point = elf_load(loadbuf);

            if (!entry_point) {
                puts("run command error");
            } else {
                puts("start from entry point: ");
                putxval((unsigned long) entry_point, 0);
                puts("\n");

                f = (void (*)(void)) entry_point;
                f();
                // not returned here
            }
        } 
        /* 省略 */
    }
    /* 省略 */
}

上記の main の中の処理は, コマンドとしてrun を受け取った場合にあたります.

まず, buffer領域の先頭のアドレスをelf_load() に渡して呼び出しています.

先ほど load コマンドでbuffer領域に展開した実行形式ファイルは, メモリ上に展開するときのイメージそのままのバイナリではなく, ヘッダなどを含んだ特定のフォーマットで記述されています. elf_load はこのELFフォーマットで展開されている実行形式ファイルを解析して, ベタバイナリをRAM上に展開する役割を担います.

elf_loadは具体的に以下の処理をします.

  • ELF形式である実行可能ファイルが適切なフォーマットであるか, H8/300で動作するにあたり正しい定数がセットされているかをチェックする.
  • ELFファイルのセグメントの情報に基づいて, memcpy, memset によってロードを行う. これにより, OSのtext, data, bss領域などのベタバイナリのデータがRAM上に展開される.

さて, elf_load からは entry_point が返ってきます. これは, OSのエントリポイントのアドレスを指しています. OSのエントリポイントはOSのリンカスクリプト で定義されています.

ENTRY("_start")

定義によると start というラベルで表されるアドレスがOSのエントリポイントに指定されています.

ブートローダーの最後の仕事は, このentry_pointを関数ポインタとして呼び出すことです. これでOSのコードに処理は移ります.

startos/asm/startup.s で定義されています.

_start:
    mov.l  #_bootstack,sp
    jsr  @_main

ブートローダのときと同様に, スタックポインタを設定したのち, OSのmain関数にジャンプしています.

int main(void) {
    INTR_DISABLE;

    puts("kozos boot succeed!\n");

    /* 省略 */
}

main 関数ではまず割り込みを無効化した後に, puts 関数でOSの起動メッセージをシリアル経由でホストPCに送信しています.

success in booting os!

OSの起動メッセージを無事確認することができました🎉

5. OS

さて, OSに処理が移ったところで, OSがマルチスレッドで動作し, コマンドに応答する様子を追っていきたいところですが, 記事が思ったより長くなってしまったので, OSのパートは別記事にしたいと思います...