🍒

dockerで行う12ステップで作る組込みOS自作入門

2024/01/21に公開

はじめに

冬休みに12ステップで作る 組込みOS自作入門を完走したをkozosを完走しました。
そのときの備忘録になります。

12STEPの各内容は以下のようになっています。

  • 第1部 ブート・ローダーの作成
    • 1stステップ 開発環境の作成
    • 2ndステップ シリアル通信
    • 3rdステップ 静的変数の読み書き
    • 4thステップ シリアル経由でファイルを転送する
    • 5thステップ ELFフォーマットの展開
    • 6thステップ もう一度,Hello World
  • 第2部 OSの作成
    • 7thステップ 割込み処理を実装する
    • 8thステップ スレッドを実装する
    • 9thステップ 優先度スケジューリング
    • 10thステップ OSのメモリ管理
    • 11thステップ タスク間通信を実装する
    • 12thステップ 外部割込みを実装する

1STEP、1commit単位でまとめて進めていきました。レポジトリは以下にあります。

https://github.com/sat0ken/kozos

環境構築

  • マイコンボード

秋月電子でH8/3069Fのボードが必要です。
新卒の時に買ってずっと積んでいました

https://akizukidenshi.com/catalog/g/gK-01271/

  • コンパイル環境

最初はdockerで環境を構築しようとしたのですが上手くgccがbuildできなかったので、サポートサイトで配布されているCentOSの仮想マシンからdockerのimageを作成しました。
手順としてはサポートサイトからダウンロードしたovaファイルを解凍して、ファイルシステムをマウントします。

マウントしたディレクトリをtarで圧縮したら、docker importをすることで仮想マシンからdockerの開発環境ができました。
やり方は以下のURLが参考になりました。

https://andygreen.phd/2022/01/26/converting-vm-images-to-docker-containers/
https://www.vinnie.work/blog/2021-03-19-virtualmachine-to-docker

環境ができたらソースのあるカレントディレクトリとシリアルポートをマウントしてdockerコンテナを起動します。

$ docker run -it --name makeos --device=/dev/ttyUSB0:/dev/ttyUSB0 -v $(pwd):/work makeos-centos:0.1 /bin/bash

開発はホスト側で行い、ビルドと書き込み時だけコンテナ内でmakeコマンドを実行してブートローダとOSイメージを生成します。

docker imageは以下にpushしました。
https://hub.docker.com/r/sat0ken/makeos-centos

第1部

全12STEPのうちSTEP1~6ではブートローダを作成します。
kozosはブートローダがOSを起動する仕組みになっているからです。

シリアル通信ができるようになったら、STEP4でXMODEMプロトコルを実装してファイルを転送できるようにします。
STEP7以降でbuildしたOSの動作イメージを転送してブートローダから起動させるためです。

ubuntuを使っているのでシリアルポートを開くときは screen コマンドを使っていました。
本では screen コマンドによるやり方が書いてなかったので以下の記事でファイル転送しました。

https://jp7fkf.hatenablog.jp/entry/2016/07/15/233530

ブートローダでload状態にしておいてから、screen コマンドで接続している端末で、Ctrl+a → :exec !! /usr/bin/sx <ファイルパス> を入力してEnterするとファイルが転送されます。

kzload (kozos boot loader) started.
kzload> load
Sending ./defines.h, 1 blocks: Give your local XMODEM receive command now.
Bytes Sent:    256   BPS:912

Transfer complete

XMODEM recieve succeeded.
kzload> dmup
unknown.
kzload> dump
size: 100
2369666e64656620  5f444546494e4553
5f485f494e434c55  4445445f0a236465
66696e65205f4445  46494e45535f485f
494e434c55444544  5f0a0a2364656669
6e65204e554c4c20  2828766f6964202a
2930290a23646566  696e652053455249
414c5f4445464155  4c545f4445564943
4520310a0a747970  6564656620756e73
69676e6564206368  617220202075696e
74383b0a74797065  64656620756e7369
676e65642073686f  7274202075696e74
31363b0a74797065  64656620756e7369
676e6564206c6f6e  6720202075696e74
33323b0a0a23656e  6469660a1a1a1a1a
1a1a1a1a1a1a1a1a  1a1a1a1a1a1a1a1a
1a1a1a1a1a1a1a1a  1a1a1a1a1a1a1a1a

kzload>

以後はこの方法でビルドしたOSイメージを転送してブートローダからrunコマンドで起動します。
以下はSTEP6での動作です

kzload> load
Sending ./kozos, 11 blocks: Give your local XMODEM receive command now.
Bytes Sent:   1536   BPS:919

Transfer complete

XMODEM receive succeeded.
kzload> run
starting from entry point: ffc020
Hello World!
> echo aaa
 aaa
>

ブートローダを作るSTEP6までもとても面白いのですが、実際のOSという部分はSTEP7以降になるので説明を割愛させて頂きますm(--)m

第2部 STEP7

STEP7からの第2部では実際のOSを作成していきます。
STEP7で実装するのは割り込み処理です。

STEP6まではシリアル受信をループで見張っていて入力されたコマンドに応答していましたが、シリアル受信割り込みを利用するように変更します。
H8マイコンはベクタ割り込み方式を採用しているので、ブートローダに割り込みハンドラ、割り込みベクタの機能を実装します。

ブートローダの実装追加が済んだら、OS側に実装追加をします。
OS側にシリアル受信割り込み時に呼ばれる関数を実装します。

実際にSTEP7で追加した内容は以下のcommitと本をご覧ください。

STEP7のcommit

実装を追加して正常に起動すると、以下のようにシリアル入力に対して応答が割り込み処理によって返ってきます。

kzload> load
Sending ./kozos, 14 blocks: Give your local XMODEM receive command now.
Bytes Sent:   1920   BPS:920

Transfer complete

XMODEM recieve succeeded.
kzload> run
start elf_load()
starting from entrypoint: ffc020
set entry point
kozos boot succeed!
> echo test
 test
>

※割り込みのイメージは以下のページがわかりやすかったです。
http://www.kumikomi.net/archives/2009/11/post_23.php?page=5

STEP8

STEP8で実装するのはスレッドです。
OSが実行するスレッド処理もSTEP7で追加した割り込み処理の延長線上にあります。

割り込み処理をするときは実行していたプログラムカウンタやレジスタの値を保存してから、処理を行い処理が終われば、
元々実行していたプログラムの続きが再開されます。

この仕組みはスレッドを切り替えるときのコンテキストの保存で行うことと同じです。

STEP8ではタスクコントロールブロックというタスク情報を格納する構造体を用意し、その構造体をリンクリストで持ちます。
リンクリストに格納されたタスクをOSが順次実行していきます。

STEP8のcommit

実装を追加して正常に起動すると、スレッドが起動しつつ、シリアルで応答が返ってきます。

kzload> load
Sending ./kozos, 24 blocks: Give your local XMODEM receive command now.
Bytes Sent:   3200   BPS:782

Transfer complete

XMODEM recieve succeeded.
kzload> run
start elf_load()
starting from entrypoint: ffc020
set entry point
kozos boot succeed!
start EXIT.             ← 初期スレッドが終了
test08_1 started.       ← サンプルプログラムのスレッドが起動
> echo test             ← echoコマンドを実行
 test
> exit
command EXIT.           ← exitコマンドでスレッドが終了
system error!           ← スケジューリングするスレッドがないのでエラーとなる

※タスクコントロールブロック
https://contentsviewer.work/Master/Arduino/ArduinOS/TaskTCB

STEP9

STEP9で実装するのは優先度スケジューリングです。
STEP8で実装したスレッド管理はキューにタスクと詰めていってFIFOによる仕組みなので、最優先のタスクが来ても前の処理が終わらない限り実行されません。
STEP8までの実装のスレッドを開始するシステムコールに優先度を追加して、優先度に応じてタスクを実行するようにします。

STEP9のcommit

実装を追加して正常に起動すると、以下のようになります。

kzload> run
start elf_load()
starting from entrypoint: ffc020
set entry point
kozos boot succeed!
test09_1 started.
test09_1 sleep in.              ← test09_1がスリープする
test09_2 started.
test09_2 sleep in.              ← test09_2がスリープする
test09_3 started.
test09_3 wakeup in (test09_1).  ← test09_3がスリープしていたtest09_1を起動する
test09_1 sleep out.
test09_1 change priority to 3.  ← test09_1が優先度を1から3に変更する
test09_3 wakeup out.
test09_3 wakeup in (test09_2).  ← test09_3がスリープしていたtest09_1を起動する
test09_2 sleep out.
test09_2 change priority to 3.  ← test09_2が優先度を1から3に変更する
test09_1 change priority out.
test09_1 wait in.               ← test09_1がCPUを離す
test09_3 wakeup out.
test09_3 wait in.               ← test09_3がCPUを離す
test09_2 change priority out.
test09_2 wait in.               ← test09_2がCPUを離す
test09_1 wait out.
test09_1 trap in.               ← test09_1がtrapを発行して強制終了が開始する
test09_1 DOWN.
test09_1 EXIT.
test09_3 wait out.
test09_3 exit in.               ← test09_3がexitにより終了する
test09_3 EXIT.
test09_2 wait out.
test09_2 exit.                  ← test09_2が終了する
test09_2 EXIT.

test09_1~3の3つのスレッドが初期スレッドから起動してから各スレッドの優先度が変更されるので、優先度に応じて実行されるタスクが変化していきます。
↑の出力結果から読み取るのは難しいと思いますので、ぜひ実際に試してみてください。

STEP10

STEP10で実装するのはメモリ管理です。
C言語にはmalloc, freeというメモリを確保、解放する関数がありますが、それをOSの機能として提供します。

メモリブロック構造体を定義し、リンクリスト形式で持つことでメモリプールがどれだけ使用されているかOSの動作で管理できるようにします。

typedef struct _kzmem_block {
    struct _kzmem_block *next;
    int size;
} kzmem_block;

typedef struct _kzmem_pool {
    int size;
    int num;
    kzmem_block *free;
} kzmem_pool;

static kzmem_pool pool[] = {
        {16, 8, NULL},
        {32, 8, NULL},
        {64, 4, NULL},
};

メモリを確保するOS関数では、要求されたサイズが収まる領域を解放済みのリストから検索して、空いている領域の先頭アドレスを返します。

void *kzmem_alloc(int size)
{
    kzmem_block *mp;
    kzmem_pool *p;
    int i;

    for (i = 0; i < MEMORY_AREA_NUM; i++) {
        p = &pool[i];
        if (size <= p->size - sizeof(kzmem_block)) {
            if (p->free == NULL) {
                kz_sysdown();
                return NULL;
            }
            mp = p->free;
            p->free = p->free->next;
            mp->next = NULL;
            return mp + 1;
        }
    }
    kz_sysdown();
    return NULL;
}

解放する場合は領域のアドレスを、解放済みリンクリストに戻します。

void kzmem_free(void *mem)
{
    kzmem_block *mp;
    kzmem_pool *p;
    int i;

    mp = ((kzmem_block *)mem -1);

    for (i = 0; i < MEMORY_AREA_NUM; i++) {
        p = &pool[i];
        if (mp->size == p->size) {
            mp->next = p->free;
            p->free = mp;
            return;
        }
    }
    kz_sysdown();
}

STEP10のcommit

STEP10の実行結果です。aとbを4~56のサイズで埋めて出力しています。
詳しい説明は割愛しますが、必要なサイズに応じてメモリの確保・解放・再利用が行われています。

kzload> run
start elf_load()
starting from entrypoint: ffc020
set entry point
kozos boot succeed!
test10_1 started.
00ffd1b0 aaa
00ffd1c0 bbb
00ffd1c0 aaaaaaa
00ffd1b0 bbbbbbb
00ffd230 aaaaaaaaaaa
00ffd250 bbbbbbbbbbb
00ffd250 aaaaaaaaaaaaaaa
00ffd230 bbbbbbbbbbbbbbb
00ffd230 aaaaaaaaaaaaaaaaaaa
00ffd250 bbbbbbbbbbbbbbbbbbb
00ffd250 aaaaaaaaaaaaaaaaaaaaaaa
00ffd230 bbbbbbbbbbbbbbbbbbbbbbb
00ffd330 aaaaaaaaaaaaaaaaaaaaaaaaaaa
00ffd370 bbbbbbbbbbbbbbbbbbbbbbbbbbb
00ffd370 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
00ffd330 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
00ffd330 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
00ffd370 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
00ffd370 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
00ffd330 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
00ffd330 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
00ffd370 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
00ffd370 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
00ffd330 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
00ffd330 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
00ffd370 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
00ffd370 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
00ffd330 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
test10_1 exit.
test10_1 EXIT.

STEP11

STEP11ではタスク間通信を実装します。

なぜタスク間通信が必要になるのかということについて、本では関数の再入が説明されています。
ログ出力用に以下のようなlog_outputという関数があったとします。

void log_output(char *message)
{
    static char buf[256];
    time_t t;

    time(&t);
    strcpy(buf, ctime(&t));
    strcpy(buf + strlen(buf), message);

    puts(buf);
}

これを複数スレッドから呼ぶ時に問題が発生します。
スレッドAからlog_outputを呼ぶ時に優先度が高いスレッドBが割り込まれた場合、スレッドA用に確保されたbufが上書きされてしまいます。
このようなあるスレッドが特定の関数を実行している時に割り込みによって別スレッドが動作し、その別スレッドが実行しようとしていた関数を実行してしまうことが関数の歳入です。

この問題を解決するためにはいくつかの方法がありますが、ログ出力をサービスとしてスレッド化します。
ログを出力したい場合はサービスに依頼をして出力してもらいます。出力したいメッセージをサービスに伝達するためにタスク間通信が必要となります。

STEP11の実装ではスレッド間でメッセージをやり取りするための構造体や送受信のシステムコールを追加しています。

STEP11のcommit

プログラムを実行すると2つのスレッド間でメッセージをやり取りしていることがわかります。

kzload> run
start elf_load()
starting from entrypoint: ffc020
set entry point
kozos boot succeed!
test11_1 started.
test11_1_recv in.   ← test11_1が受信待ちに入る
test11_2 started.
test11_2_send in.   ← test11_2がtest11_1に送信
test11_1_recv out.  ← test11_1が受信
static memory
test11_1_recv in.   ← test11_1が受信待ちに入る
test11_2_send out.
test11_2_send in.   ← test11_2がtest11_1に送信
test11_1_recv out.  ← test11_1が受信
allocated memory
test11_1_send in.   ← test11_1がtest11_2に送信
test11_1_send out.
test11_1_send in.   ← test11_1がtest11_2に送信
test11_1_send out.
test11_1 exit.
test11_1 EXIT.
test11_2_send out.
test11_2 recv in.   ← test11_2が受信
test11_2 recv out.
static memory
test11_2 recv in.   ← test11_2が受信
test11_2 recv out.
allocated memory
test11_2 exit.
test11_2 EXIT.

STEP12

最後となるSTEP12では、これまでのまとめとしてシリアルからの割り込み処理とコマンド応答スレッドを作成します。
筆者はコンピュータの3大要素と言われているCPU、メモリ、IOの3つが管理できていれば、それはOSである主張してよいと言われてます。

CPU時間の管理としてはSTEP8でスレッドを作成し、メモリ管理はSTEP10で実装しました。
IO管理としてスレッドとタスク間通信が実装できているのですでに実現できていますが、STEP12でさらに割り込み処理の管理を実装追加します。

STEP12のcommit

プログラムを起動するとこれまでと同じように文字が返ってくるだけですが、実は以下の図のように割り込みとスレッド、タスク間通信を利用した
なかなか本格的な構成になっています。

kzload> run
start elf_load()
starting from entrypoint: ffc020
set entry point
kozos boot succeed!
command> echo aaa
 aaa
command>

おわりに

というわけでARM全盛の時代にH8というオワコンマイコン上にOSを作るということを行いました。

正直みかん本のほうが秋月でマイコンボードやUSBシリアル変換ケーブルなどを買う必要なく、PCのみでスタートできるのでお手軽だと思います。
だだ目の前にあるボード上で自作したOSが動作するというのはエモさがあります。

STEP1でちゃんとブートローダが文字を出力したときは謎の感動がありました。

OSを作ってみたことでブートローダや割り込みの仕組み、ポインタなどぼんやりとしていた理解が明瞭になったきた気もします。まだまだわからないことだらけですが。
また上手いことdockerを使って環境構築をすれば古のマイコンのプログラムもビルドできることがわかりました。

自作でしか味わえないこともあると思うので引き続きやっていきたいと思います。

Discussion