 |
 |
リアルタイムOS |
 |
|
KL5C16030開発セットを使ったリアルタイムOS |
| |
|
大石 伸彰 |
 |
|
リアルタイムOSを作る方法 |
 |
 |
リアルタイムOSという言葉は知っていても、それを実際にうまく使う方法など、特に初めての人には難しい話です。マルチタスクOSにタスクがひとつしかないプログラムを書いてしまった人もいるとか…。単に仕様書を読んで使うだけじゃなく、自分の手で作ってみることがリアルタイムOSの理解の早道のようです。あ、いや、別にイエローソフト殿の営業妨害をしようというのではなくて、一度理解してしまえば市販品を使用するにしてもきっと効率の良いプログラムが書けるに違いありません、ということですね。
そこで、ここでリアルタイムOSを作る方法を書いてしまいましょう。って一度しか書いたことのないヤツが言うのもおこがましいですが、いくつかの注意点を交えながら、参考になる情報を提示しようというわけなのです。 |
 |
|
アセンブラは必須 |
 |
 |
私自身組み込み用コンパイラを使ったのは初めてなのですが、アセンブラでなくコンパイラを選択する理由は、普通の場合なんなのでしょう?私が思うのは、やはり記述性の高さ。その分冗長になることは覚悟しないといけませんが、複雑な処理を平易に書ける魅力は捨てがたいものがあります。
ところで、Cコンパイラを「アセンブラがなくてもソフトが書ける道具」だと思ってませんか?間違いではありませんが、Cがあればアセンブラを忘れられるというわけではないですよね。それでも、例えばYCシリーズを使う限りはスタートアップルーチンにちょこちょこっと初期化プロセスを書いておけばあとは直接アセンブラの世話になることはありません。ですが、リアルタイムOSを書こうと思うなら、それなりのアセンブラの知識とCPUの動作の理解が必要になります。
もう少し具体的に述べましょう。普段Cでプログラムを組みコンパイルしているときには、そのプログラムが使用するスタックについてあまり気にすることはないと思います。もちろんメモリが足らなくてスタック溢れという事態を避けるべく設計をするのでしょうが、関数の呼び出しやパラメータ受け渡しの際のスタックの使われ方にまで気を配る必要はありません。しかしマルチタスク構成のOSになると「タスクひとつにスタックひとつ」というのが基本になり、複数のタスクがあればそれを切り替える処理が発生しますので、スタックをOSが管理しなければならなくなるのです。スタックを明示的に変更するCの命令というのはありませんから、自分でどうにかして記述するしかありません。 |
 |
|
コンテキストの切り替え |
 |
 |
コンテキストとは、タスクが使っている変数のことです。あくまでアセンブラレベルで考えますから、通常は「タスクが使用しているレジスタ」を意味します。普通タスクがどのレジスタを使うかなんてわかりませんから、全てのレジスタを保存することになります。
しかしタスクをCでのみ記述するとなると、話は変わってきます。YCシリーズの特徴として、割り込み関数宣言がありますが、この"interruput void"で宣言された関数は自動的にいくつかのレジスタを保存するようにコンパイルされます。考え方を変えると、割り込み関数で保存されるレジスタ以外は普通の関数でも使われていないわけですから、保存対象から外して構わないことになります。当然ながらregister宣言された変数で使われるレジスタも対象外になってしまうわけですが、割り込み関数内でも保存されませんし、"Your own risk"であるのには違いないですよね。
コンテキストを決めましたので、その切り替えの雛型を書いてみましょう。
PUSH AF
PUSH HL
PUSH BC
PUSH DE
PUSH YIY
PUSH IX
SP⇒今のタスクのスタックポインタ保存領域
〜次のタスクを探す処理〜
次のタスクのスタックポインタ保存領域⇒SP
POP IX
POP YIY
POP DE
POP BC
POP HL
POP AF
見てのとおり、コンテキストの保存先はスタックです。タスクが持つスタックに保存するわけです。そのスタックポインタをタスクが個別にもつ特定の領域に保存すれば、スタックもコンテキストも他のタスクから保護されるわけです。新しいタスクが決まったら、保存していたスタックポインタを復帰して、さらにコンテキストも復帰させます。コンテキストの切り替え=タスクの切り替えのことを「ディスパッチ」といいますが、ディスパッチを決まったルーチンで処理する限り決まった形式でコンテキストも保存され、また決まった形で復帰するようになります。
なお、例ではコンテキストの保存と復帰の方法をアセンブラで具体的に、その間の処理を日本語で抽象的に書いてますが、その抽象的な部分をCで書き、具体的なアセンブラの所をインラインアセンブラで書くというようにすれば、ソースの書き方で悩むことはないですよね。
|
 |
|
タスクコントロールブロック |
 |
 |
では「タスクが個別にもつ特定の領域」というのはどうすればいいのでしょうか?それが「タスクコントロールブロック(TCB)」と呼ばれる構造体です。タスク固有の情報を集める変数だと思えばいいでしょう。TCBにはさまざまな情報が入るのですが、実際には必要になったものから入れていけば十分です。なにも仕様書に書いてあるもの全てをいきなり入れてしまう必要はありません。ここまでで言えば、スタック保存用の領域でしょうか。
さて、先ほど「次のタスクを探す処理」というのがありました。今走っているタスクが何らかの理由で止まらないといけなくなった時、一定の条件を満たすタスクを探し出してそちらにディスパッチするわけですが、その条件のひとつに「優先度」というのがあります。というかITRONの場合は優先度が全てで、ディスパッチ時点において走行可能で最も優先度の高いタスクにディスパッチされることになっています。優先度の最も高いタスクを探すために、「キュー」と呼ばれるリンク構造を使用します。
キューとは、「次のキューを含む構造体の格納アドレスを指すポインタ」のことです。次の図を見てください。
|
| |
|
 |
| |
|
キューが次のキューを指してますので、同じ方法でさらに次のキューをたどることができます。最後のキューは、先頭を指すようにしておきます。このキューの根元にあたる部分をOSカーネルで持ち、ずらずらつながるキューをTCBに入れれば、一連のキューについてどれでも参照することができるようになります。どこをとっても構造が同じなので、任意の場所にキューを割り込ませたり任意のキューを削除したりという操作を関数で定義できます。実際には、キューの最後尾に対する操作が多いので、逆にたどることのできるバックリンクと併用します(双方向リンクと呼びます)。
|
| |
|
 |
| |
|
さて、先ほどの「次のタスクを探す処理」の話に戻りましょう。このようにして作った、すぐにでも実行できるタスクのTCBをつないだキューのことを「レディキュー」といいます。タスクをつなぐキューには、優先度の数だけ用意して優先度毎にTCBをつなぐものと、優先度順にTCBをひとつのキューにつなぐ方法が考えられるのですが、たくさんのタスクがぶら下がることが予想されるレディキューは高速にサーチできる「優先度の数だけキューがある」方法がいいと思います。同じ優先度を複数のタスクに与えることができるので、もちろん複数のタスクがひとつのキューにぶら下がる可能性があるわけですが、TCBをキューにつなげる時は必ず最後尾につなぐことになってますので、ある優先度の中のタスクの実行順というのはFIFOになります。
なお、レディキューだけでなくいろいろな場面でキューを使いますので、それぞれで定義する構造体のキューの構造を統一(必ず構造体の先頭に配置するとか)しておくと、キューの操作も統一することができます。 |
 |
|
コンパイル結果を把握する |
 |
 |
Cにおけるスタックの使われ方はご存知ですか?プログラマが特別なことをしない限り、
1. 関数呼び出し(=サブルーチンコール)の際の戻り先保管
2. 関数のパラメータ
3. auto変数
ということになります。このうち、関数の中で使われるのは3.のauto変数だけです。auto変数は関数の先頭で確保され、関数の出口で開放されます。YC160を例にして見てみましょう。
まず関数の入り口ではこうなっています。
PUSH IX
LD IX,SP
LD HL,-8
ADD HL,SP
LD SP,HL
これは
unsigned int sum,size,i;
unsigned char *pkt;
と宣言された関数の先頭なのですが、スタックポインタ(SP)を変数4つ分(8バイト)前へずらしています。元のIXレジスタを保管した上で、その時のSPをIXに代入しているのは、関数中で
LD (IX-4),HL
のようにしてauto変数にアクセスするためです(この命令はKC160で拡張されたものです)。この時のスタックは次のようになっています。
paramというのはこの関数の呼び出しのためのパラメータです。パラメータがスタックに積まれた後、戻り先(旧PC)が積まれ、IXを保存し、auto変数のための領域が確保されているのがわかると思います。
次に関数の出口を見てみましょう。
LD SP,IX
POP IX
RET
SPを元に戻して(auto変数の開放)、IXを復帰し、この関数が呼ばれたところへ戻ります。 |
 |
|
タスクの起動 |
 |
 |
ここまで、コンパイル結果を見てきたのにはもちろん理由があります。いざタスクが起動されるというとき、つまりそのタスクに「飛ぶ」わけですが、具体的にどうしたらいいのでしょう?JP命令で飛びましょうか?
ここは、「ディスパッチというのはタスクなどにとって割り込みと同等である」と考えると楽かもしれません。コンテキストというものを定義してそれをディスパッチのたびに退避・復帰させていることやその選定基準が割り込みと同じだったことに通じるのですが、ディスパッチが起こるとそれまで走行していたタスクの状態を保存するというのは割り込み時に必要なレジスタを保存するのと何ら変わりないわけです。
割り込みは「レジスタ退避→割り込み処理→レジスタ復帰」の一連の流れがありますが、これを前後二つに分けて捉えてみましょう。つまり「レジスタ退避→何らかの処理」と「何らかの処理→レジスタ復帰」です。レジスタの退避と復帰をコンテキストの退避と復帰に読み替え、「何らかの処理」をタスクの交代処理と考えれば、そのままディスパッチの形式をとっていることがわかるはずです。また割り込みは「命令なきサブルーチンコール」であり、サブルーチンコールの実態は「戻り先退避+コール先へジャンプ」と「戻り先へジャンプ」の組み合わせなのですから、あらかじめスタックに戻り先を仕込んでおけば、CPUは元に戻るつもりで、新たな場所へ飛んでくれるわけです。
と長々書きましたが、アセンブラでスタック操作などの「汚いプログラム」をやっていた経験のある人ならすぐ気づくんじゃないかと思います。汚いといってもテクニックのひとつで、戻り先をすっ飛ばしたい時や場合によって戻る先を変えたい時に使ったりします。
それで結局どういうことかというと、「新規に起動されるタスクもスタックの構造をあらかじめディスパッチされたようにしておけば、特別な起動ルーチンはいらないんじゃないか」ということなんです。ディスパッチのコンテキスト復帰の部分で、いくつスタックから取り出されるかが数えてあれば、ダミーのスタックを取り出してRET命令で目的地へジャンプしてくれるように作れるはずなのです(特定のレジスタを狙って値を入れることもできます)。中身はどうでもいいので実際にPUSH命令を並べなくても数の合うスタックポインタになっていればいいのです。この部分はタスクの生成部で処理するようにしておきます。 |
 |
|
割り込みについて |
 |
 |
先ほど"interrupt void()"に触れましたが、YCシリーズにはこの便利な「割り込み関数」の定義があります。しかし、ここではそれをあきらめなければなりません。というのは、interrupt void宣言では関数からの復帰にRETI命令を使うのですが、Cのソースではそれまでにディスパッチ部の呼び出しを記述するため都合が悪いのです。RETI実行により割り込み処理モードから抜ける(というのはCPU内部の話ですが)のに、その前にディスパッチしてしまうと割り込み状態のまま他の部分へ飛んでいってしまうからなのです。割り込み状態が解けないと、次の割り込みが受けられなかったり割り込みコントローラがある一定レベル以下の割り込みを禁止したりしてしまいます。割り込み処理が終わったら必ずRETIを実行しないといけません。
KC160の場合割り込みモードか否かは割り込みコントローラ任せなので、コントローラの特定アドレスに任意のデータを書き込むとRETI実行と同様に動作するようになっているので対処も楽なのですが、その他のCPUではそういうわけにはいかないかもしれません。例えばZ80では、
LD HL,next
PUSH HL
RETI
next:
のようにしてどこへも飛んでいかないようにしながらRETIを実行して割り込み処理モードを終わらせ、続きの処理に移るなどの工夫がいります。いちいちこの処理を書いておくのは面倒だしアプリケーションを書く者にこれを教えておくのも煩雑なので、この処理を含むret_int()というAPIを定義しておいて、割り込み処理の最後に呼び出すように決めておく手もよく使われるようです。
割り込みに関してもうひとつ注意点があります。それは、「割り込み処理中にはディスパッチしてはいけない」ということです。理由はさきと同じ、「割り込み中に起こる制限を解除しないまま他の処理に移ってしまい、割り込み処理中ではないにも関わらず特定の割り込みを受けられなくなったりする」からです。これを防ぐために、割り込み処理中に呼ばれるAPIで起こるディスパッチは無視するようにして、割り込み処理ルーチンの最後にディスパッチ部を呼び出すようにしておきます。多重割り込みを許可している場合は、一番最初の割り込み処理から抜けるときに呼び出されるディスパッチ部のみ有効になるようにします。
問題は割り込み中をどうやって判別するかにあります。比較的高機能なCPUだと内部レジスタに割り込み受付中などを示すフラグを持つものがあるのでそれを読み出すようにすればいいのですが、低機能なものでは別の方法で実現しないといけません。といっても大層なものではなくて、割り込み処理ルーチンの最初に割り込みフラグを"+1"、最後に"-1"するようにしておいて、ゼロになるときは割り込み処理から全て抜けるときと判断するようにすればいいのです。Z80ではある方法で内部の割り込みフラグを読み出すことができることになっていますが実は動作が不完全なので使用できません。KC160でもあがかずにあっさりこの方法を採用しました。
ちなみに、TRONCHIPではある優先順位の低い割り込みにディスパッチ部を割り当て、ソフト割り込みによってディスパッチ処理に移らせるように作れます。もし割り込み処理中だとソフト割り込みが予約され、割り込み処理が全て終わってからディスパッチ部が自動的に呼び出されるようになります。これを遅延割り込みといい、TRONCHIPの特徴のひとつになっています。
ここまで書いて来たことは、実はあちこちの文献から拾った知識を集めたものです。実際に作った人などは「プログラムテクニックといっても、双方向リンクくらいしかない」とも言ったりします。結構泥臭い、Cを使うがゆえにこそこそいじらないといけないようなことがほとんどのような気がします。それらが解決し、リアルタイムOSの要件を満たせるようになれば、あとのAPIについては仕様書をそのままコーディングするだけとも言えます。さて、あなたにも作れそうな気がしてきましたでしょうか? |
|