OS自作入門 -Advent36-

NO IMAGE

Step11 タスク間通信を実装する

これまでのstepでアプリケーション・プログラムをスレッドとして並行動作させ、メモリ管理を行なってきた。今回はこのスレッド間での情報のやり取りを実現するためにタスク間通信を実装していく。

タスク間通信

組込みOSでは、高速性やリアルタイム性の観点から、デバイス・ドライバ、ファイル・システム、ネットワーク通信などもアプリケーションとして実装しスレッドで動作させるということが多くあるらしい。

例えばUNIX系OSなどの汎用OSの場合、シリアルへの文字出力などはカーネル内部にデバイス・ドライバを内蔵しており、デバイス・ファイルへの書き込みを行えばそのデバイスに対して文字が出力されるようになっている。なるほど。確かに/dev/hogehoge宛に送ることがたまにあるな。

しかし、組込みOSの場合、デバイスを管理するためのスレッドを作成し、そのスレッドに対してタスク間通信で出力依頼をするようになっていることが多い。

システム・タスク

汎用OSのファイルシステムの場合、OSに対してファイル・オープンのシステム・コールを発行し、ファイル操作もシステム・コールで行い読み書きを行う。しかし、OSのカーネル内部にファイルシステムを実装するとカーネルのサイズが肥大化する。そのため組込みOSの場合はカーネルに内蔵するようなことはしない。

ファイルシステム解析用のスレッドや対象のファイルへ読み書きをするためのスレッドを用意してそれに対して依頼するようなやり方を採用する。また、前回のstepで実装しなかった動的なメモリ管理用のスレッドを作成して、必要であればメモリ割り当てを依頼する。というように作成することもできる。

このようなシステム内で必要な基本サービスを行うような管理スレッドは、 システム・タスク と呼ばれる。一方でユーザーが作成するアプリケーション・プログラムは ユーザー・タスク と呼ばれる。

関数の再入と排他

今作成しているKOZOSは優先度を持つプリエンティブなOSである。そのためスレッドの実行中に他のスレッドが割り込んでくる可能性がある。

もし現在実行中のスレッドがある関数を実行しその内部でstaticなメモリを取り扱っていたりすると、処理の途中で他のスレッドにより再度同じ関数が呼ばれstaticメモリ内の内容が書き換えられてしまう場合、最初に実行していたスレッドが再開すると意図したものと違う処理が行われる可能性がある。

このように、あるスレッド間で関数を呼び合ってしまうことを 関数の再入 と呼ぶ。

再入問題の回避

関数の再入はプログラミング上避けては通れない問題であり、いくつかの回避策がある。

  1. 仮想メモリを実装し、プロセス化する
    そもそもの問題はメモリ資源を共有資源として取り扱っているからであり、仮想メモリ を利用して回避しようというもの。
    仮想メモリとはタスクごとに独立した仮想的なメモリ空間を割り当てるものであり、その仮想的なメモリ空間嬢のアドレスを 仮想アドレス と呼ぶ。

    プログラムが動作するメモリ空間と物理メモリ上の記憶領域とは別に考えるというもので、仮想メモリによりアドレス空間が独立しているタスクを プロセス と呼ぶ。スレッドはアドレス空間が共通であり他のスレッドの変数にも触れるが、プロセスではそのようなことは発生しない。

    なるほど。ここでスレッドとプロセスの違いが語られるのか。スレッドはCPUの割り当て単位でメモリ空間は共有されるが、プロセスは仮想メモリを利用してプログラムが動作するメモリを分離しているためメモリ空間は共有されない。と。

    メモリ・アクセスはMMU(Memroy Management Unit)と呼ばれる専用のハードウェアが必要らしく、これが仮想メモリ=>実際のメモリアドレス(物理アドレス)にアドレス変換を行なってくれるらしい。が、H8にはそんな高機能なCPUは載っていないので今回の組込みOSでは実現できない。とのこと。

  2. サービスをOS内部に実装し、システム・コールで呼び出すようにする
    この場合、対象のサービスを使いたい場合は、システム・コールを使ってOSに依頼するということになる。
    KOZOSの場合はカーネル内部の処理に対しては CCR のビットが建てられるため割込み禁止となる。という性質を利用したもの。

    だが、なんでもかんでもカーネルに入れるとかやってらんないし、肥大化するし、かなり悪手っぽい。

  3. 関数をリエントラントな構造にする
    関数内での共有されてしまうような static な変数を static 定義をやめてスタック上に確保するという手段も取れる。

    このように関数が再入されても問題が発生しない構造になっていることを リエントラント または再入可能と呼ぶ。
    ただし、これは関数の内部で他の関数を呼んでいる場合に注意が必要となる。その先の全ての関数はリエントラント可能な関数でないといけない。

  4. 関数内に排他処理を入れる
    共用の変数を使っていたりするかしょを割込み禁止にする。という手法もある。
    interrupt.h には割込み禁止/許可のための INTR_ENABLE, INTR_DISABLE というマクロがあるので、これを利用して以下のように書くと割込みを禁止することができる。

    void log(char *message)
    {
      ....
    
      INTR_DISABLE;
      something_use_static_var();
      INTR_ENABLE;
    }

    こういった処理のことを 排他 と呼ぶ。排他にはいくつかやり方がある。 ロックセマフォミューテックス を利用する。などである。

    ロック

    void log(char *message)
    {
      ....
    
      volatile static int locked = 0;
      while(locked) {
        sleep(1);
      }
      locked = 1;
      something_use_static_var();
      locked = 0;
    }

    イメージはこんな感じで変数に状態をおいてそれを参照して処理を止める。が、この処理だと whilelocked = 1 の間に隙間があるのでここで割り込まれる可能性がある。なのでCPUによってはこれらの処理をアトミックに行えるようにしているものもある。また while によるビジーループもシステムが固まったり、なかなか調整が難しいものである。

    セマフォ
    なのでセマフォという機能をOS側で提供する。

    void log(char *message)
    {
      ....
    
      kz_getsem(ID);
      something_use_static_var();
      kz_relsem(ID);
    }

    kz_getsem がセマフォの獲得、 kz_relsem がセマフォの解放。セマフォは上で示したロックをOS側で行うような仕組みです。セマフォは獲得と開放をカウンタで管理し、複数のスレッドが再入する場合のスレッド数の上限を管理することができる。上限が1のセマフォは バイナリ・セマフォ と呼ぶ。軽く調べたらバイナリ・セマフォは0/1でのみロックを管理するような仕組みっぽいな。通常のセマフォは値をインクリメント・デクリメントしていくつのスレッドからロックをかけられているのかを制御しているっぽい。ミューテックス はバイナリ・セマフォに相当するものとのこと。

    割込みを禁止している区間の処理は当然割込みができないので、割込みが発生しても遅延する。なので内部実装に依存した形でシステムの応答性に影響が出てしまうという問題がある。他にもネットワーク越しだったりすると受信割込みを禁止してしまったりすると連続的に送られてくるデータがコントローラのバッファが溢れて受信できなくなってしまったりするため、早いうちに割込み受信に対しては解放してあげないといけない。

  5. 再入しない設計にする
    プログラム自体に工夫をして再入が発生しないようにする。という手段もある。
    優先度を同じにすればプリエンプションな動作は行われないことを利用したり、書き込みと読み込みのスレッドを作成し書き込みスレッドの優先度を高く設定するという手もある。

    が、これは複雑になりそうだ。というかかなり広範囲のシステム全容を知っておかないといけなくなる。

  6. サービスをスレッド化する
    再入の根本的な問題は 一つの資源を複数のスレッドで触ること からきている。なので各種資源を管理するためのスレッドを作成し、他のスレッドはそのスレッドに対してお願いして資源を利用するという構成にする。なので一つの資源を操作するスレッドは一つにする。という考え方。

    ここで問題となるのが タスク間通信 である。管理スレッドに対してなんらかの方法で操作を依頼する必要があり、OS側でそのようなサービスを持つようにする。

KOZOSのメッセージ通信

ということでタスク間通信を実装する。これを メッセージ と呼ぶことにする。メッセージの送信側はメッセージ送信用のシステム・コールを呼び出しメッセージIDを指定する。受信側はメッセージIDを指定してメッセージ受信用システム・コールを呼び出すことでデータを受け取るようにする。さらに送信側が繰り返し送信を行なった場合には、メッセージはキューに蓄えることにする。これを メッセージ・ボックス と呼ぶことにする。

以下のような仕様のシステム・コールになる

int kz_send(kz_msgbox_id_t id, int size, char *p);
kz_thread_id_t kz_recv(kz_msgbox_id_t id, int *sizep, char **pp);

メッセージ通信の実装

ということで実装。以下のように書き換えていく。

  • test11_1.c, test11_2.c(新規)
  • defines.h(修正)
  • syscall.h, syscall.c(修正)
  • kozos.h, kozos.c(修正)
  • main.c(修正)
  • Makefile(修正)

メッセージIDの定義

defines.hにメッセージIDを定義。

typedef enum {
  MSGBOX_ID_MSGBOX1 = 0,
  MSGBOX_ID_MSGBOX2,
  MSGBOX_ID_NUM
} kz_msgbox_id_t;

システム・コールの追加

syscall.hにシステム・コールを追加。

システム・コール番号 KZ_SYSCALL_TYPE_SENDKZ_SYSCALL_TYPE_RECV を追加し、構造体を追加。

syscall.cにシステムコールの呼び出しを実装。
kozos.hにプロトタイプ宣言を追加。

メッセージの送受信処理

kozos.cにメッセージ通信のための構造体を追加。

typedef struct _kz_msgbuf {
  struct _kz_msgbuf *next;
  kz_thread *sender;
  struct {
    int size;
    char *p;
  } param;
} kz_msgbuf;

typedef struct _kz_msgbox {
  kz_thread *receiver;
  kz_msgbuf *head;
  kz_msgbuf *tail;
  long dummy[1];
} kz_msgbox;

kz_msgbufはメッセージ一個一個を管理するための構造体(メッセージバッファーと呼ぶ)。メッセージ・ボックスはキューなので *next を持ってリンク構造にできるようにする。また実行したスレッドがわかるように *sender も保持する。
kz_msgboxはメッセージ・ボックスの管理用の構造体でメッセージIDごとに作成する。スレッドがメッセージの受信待ちをする場合にはメッセージが到着したらそのスレッドをウェイクアップするために *receiver を保持している。

構造体のサイズは2の階乗にしないと、リンク時に「undefined reference to __mulsi3」というエラーになってしまうことがある。

あ、ここで説明が入るのか。気づかなかった。出たけど直し方わからなくて他のstepでとてもつまづいた記憶がある。

kozos.cにメッセージ周りの実装。

sendmsg()recvmsg()を実装。sendmsg()はメッセージ・バッファ用のメモリを確保し、各種パラメータの保存、さらにリンクリストを操作し、キューの終端に接続されるようにしておく。recvmsg()はメッセージ・ボックスからメッセージを取り出し、メッセージ・ボックスからメッセージを受信するスレッドを取得し、戻り値の設定をしておく。

kozos.cにシステム・コールの処理を追加。

thread_send(), thread_recv()を実装。thread_send()はメッセージ・ボックスをidを用いて取得、sendmsg()を実行しメッセージを送信。待ち状態のスレッドがすでにいる可能性があるのでレディーキューにつなぎ直しておく。次にrecvmsg()を呼び受信処理を行い、mboxp->receiverが示すスレッドに対してputcurrent()を行うことでレディーキューに接続する。これで受信待ちをしているスレッドがレディー状態となり動作するようになる。thread_recvでは、メッセージ・ボックスを取得し、受信待ちのスレッドを設定して、メッセージがあれば処理を行いなければ待機状態となる。

kozos.cに呼び出し処理と初期化を追加。

memset(msgboxes, 0, sizeof(msgboxes));
case KZ_SYSCALL_TYPE_SEND:
  p->un.send.ret = thread_send(p->un.send.id, p->un.send.size, p->un.send.p);
  break;
case KZ_SYSCALL_TYPE_RECV:
  p->un.recv.ret = thread_recv(p->un.recv.id, p->un.recv.sizep, p->un.recv.pp);
  break;

サンプルプログラム

test11_1.ctest11_2.cを作成。

main.cMakefileに追加。

プログラムの実行

いつも通りビルドして、転送して、run。

kzload> run
starting from entry point: ffc020
kozos boot succeed!
test11_1 started.
test11_1 recv in.  # MSGBOX1に対してrecv、メッセージはないのでブロック
test11_2 started.  # ブロックされたので別なスレッドが動きだす
test11_2 send in.  # MSGBOX1にメッセージをsendする
test11_1 recv out. # 受信
static memory      # メッセージのpを表示
test11_1 recv in.  # 再度recv、再度ブロック
test11_2 send out.
test11_2 send in.  # 再度送信。
test11_1 recv out. # test11_1が受信してレディー状態となり表示する
allocated memory
test11_1 send in.
test11_1 send out.
test11_1 send in.
test11_1 send out.
test11_1 exit.     # test11_1の方が優先度が高いのでsendしても処理はtest11_1のまま
test11_1 EXIT.     # 末尾まで行って終了する
test11_2 send out. # test11_1が終わったのでtest11_2が動作を開始する
test11_2 recv in.
test11_2 recv out.
static memory
test11_2 recv in.
test11_2 recv out.
allocated memory   # 送信された順番通りに、「static」「allocated」のメッセージを受け取る
test11_2 exit.
test11_2 EXIT.

こんな感じ。いつも書いている実装と違って結構破壊的だったりポインタ渡してガリガリ中を書き換えたり、大きめのスコープの変数を書き換えたりして脳内のメモリを結構食うなー。組み込みって大変だ。さて、次で最後だ。これまでの集大成+割込みの管理機能の実装してフィナーレへ!