[組み込みOS - kozos] スイッチを押してからOSがコマンド応答するまでを追う
これは 自作OS Advent Calendar 2018 - Adventar 23日目の記事です
0. はじめに
@kozossakai さんが開発された kozos という, H8/3069Fの上で動く組み込みOSをベースにして, 色々改造した内容をアドベントカレンダー向けに書きたかったのですが, 時間がとれなかったので内容を急遽変更し, 「リセットスイッチを押してからOSがコマンドに応答して出力をコンソールに返すまで」の処理を順番に追っていくという記事にすることにしました. 論文とかではないので新規性は気にしません.
ここで追っていくコードは, 12ステップで作る組込みOS自作入門 で完成したコードを自分の扱いやすいように少しいじったものになります. 以下のrepoでv0.1.0として公開されています.
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レジスタがマッピングされています)
次に定義された各領域にどのようなデータが割り当てられるのかを知るために, セクションの定義を見てみます. セクションの定義は以下のように書かれています(長いのでソースのコメントで解説を書きます).
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. リセットボタンを押してからスタートアップまで
リセットスイッチを押すと, まず割り込みベクタの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
という関数から処理が始まります.
start
は bootload/asm/startup.S に定義されています.
_start: mov.l #_bootstack,sp jsr @_main
スタックポインタに #_bootstack
というアドレスを設定したのち, main
という関数へのポインタにプログラムカウンタを書き換えています. #_bootstack
は前章で見たリンカスクリプトで定義されていたラベルで, この先の処理では, このアドレスからスタックが積まれます. また, sp
は er7
のエイリアスであり, er7
で表されるレジスタに現在のスタックポインタが保持されます.
4.2. main関数 - 初期化
main
は bootload/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_DISABLE
は bootload/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の開始メッセージとプロンプトが表示されました.
ここでコマンドを入力した上で 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 に実装されているので, 興味のある人は読んでみてください.
ここまでで, 「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のコードに処理は移ります.
start
は os/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に送信しています.
OSの起動メッセージを無事確認することができました🎉
5. OS
さて, OSに処理が移ったところで, OSがマルチスレッドで動作し, コマンドに応答する様子を追っていきたいところですが, 記事が思ったより長くなってしまったので, OSのパートは別記事にしたいと思います...