皆さん こんにちは。
今回は3つ目のソースコードに入れ替えて動作確認してみます。
第3部1章のタイトルは「プログラムを切り替えるディスパッチャ」です。
今回は実行コンテキストの作成とディスパッチの部分を細かく見ていきます。
この記事は開発環境を構築することを前提にしています。
開発環境を構築したい方は ゼロから作るOS 環境構築編 をご覧になってください。
この記事はインターフェース誌2023年7月号の「ゼロから作るOS」を参考にしています。
まずは書籍の本章を読んで予習しておくことをお薦めします。
簡易ディスパッチャ
私がこのたび組み込みOSについて詳しく調べようと思ったのは、ディスパッチ(タスク切り替え)のしくみを知りたかったからで、今回の章はその簡易ディスパッチャが主役です。
この章以降は本格的なディスパッチャが使われるようになっていて、簡易ディスパッチャは本章だけに使われています。
私の想像ですが本章はディスパッチについて集中的に学べるように、それ以外については最低限の機能で構成されているのだと思います。
そのせいか一般的にはOS側でディスパッチしますが、簡易ディスパッチャではアプリケーション側でディスパッチするようになっています。
ソースコードを入れ替える
これから前回製作したソースを削除するので、必要ならバックアップをとっておいてください。
Project Explorerでpico_tryknlのツリー下のフォルダを全て選択し、右クリックして Delete を選択して削除します。

次にソースファイルをIDEにコピーします。
C:\DevTools\src\IF2307TK\part_3\sect_1 下にある以下のフォルダを全て選択して、IDEの Project Explorer の pico_tryknl にドラッグ&ドロップします。
(書籍 第3部1章のソースファイルになります)
次のウィンドウが出るので、そのままOKを押してコピーします。

コピーしたフォルダを展開し、フォルダ下にもファイルがコピーされていれば成功です。

ソースコードを編集する
この記事を執筆する時点で、実行コンテキストのスタックサイズが大きすぎるという話題が出ました。
元ソースの場合、スタックのサイズが4096バイトです。
これでは少し大きいので1024バイトに変更する話が出ています。
こちらでは main.c のスタックのコードを以下の通りに変更して対応しました。
/* タスクのスタック */
#define STACK_SIZE 1024 // 1024バイト
UW stack_1[STACK_SIZE/sizeof(UW)]; // 4 * 256 バイト
UW stack_2[STACK_SIZE/sizeof(UW)]; // 4 * 256 バイト
これは UW型(4バイト)の配列を256要素分用意して 1024バイト分のスタック領域を確保するというものです。
以下の書き方でもわかりやすくて良いと思います。
/* タスクのスタック */
UW stack_1[256]; // 4 * 256 バイト
UW stack_2[256]; // 4 * 256 バイト
コードを編集後 Project – Build Project でビルドしてエラーがないことを確認しておきます。
マルチタスクとディスパッチ
マルチタスクとディスパッチについて簡単に触れておきます。
プログラムを実行するためにはメモリーやレジスタなどのリソースが必要です。
コアがひとつのマイコンの場合、マルチタスクを実現するにはこれらのリソースを時分割で使うことになります。
あたり前ですが複数のタスクは同時に実行できません。
組み込みOSでタスクを生成する場合、その数だけスタック領域(RAMの一部)が必要になります。
第3部1章の簡易ディスパッチャのソースコードではタスクを2つ生成するので、スタックも2つ用意しています。
大元のプログラム用にもスタックがあるので、スタックの数はタスクの数+1になります。
スタックは静的に確保したり、動的に確保することができますが Try Kernel では静的確保する仕様になっています。
スタック領域を変数で宣言しておき、コンパイルした時点で領域が確定するのが静的な確保です。
malloc()等を使ってプログラム実行時にヒープ領域からメモリーを借りて来るのが動的な確保です。
以下にスタックのイメージ図を示します。
RAMの先頭から stack_1 までに少し隙間がありますが、これはたまたま stack_1の宣言前に他の変数を使っているためです。

スタックのサイズはタスクによってさまざまですが、今回は同じです。
次にタスクひとつ分のスタックの構成図(stack_1)を以下に示します。

この図のStackFrameという部分はソースコードの kernel\context.c にあるStackFrame構造体(64バイト)を表しています。
タスクコンテキストのレジスタ16個を保存しておく領域になります。
レジスタひとつのサイズは4バイトですから 16 * 4 = 64 バイトということになります。
自タスクが実行される時に ここから対応するレジスタに値を取り出してタスクを実行し、他のタスクに切り替わる時に ここにレジスタの値を保存します。
そうすることで各タスク毎にレジスタの値を保持することができます。
レジスタからの値の取り出しとレジスタへの値の保存は割り込み処理で行われるように設計されています。
StackFrame部分の構成図を以下に示します。これは全てのタスク共通になります。
スタックを使う際にspはアドレスの高い方から低い方に動くため、StackFrameはスタックの底に配置されています。
色違いの後半の8つのレジスタは割り込み処理時に自動的に sp に push(退避) / pop(復帰) されます。
このStackFrameへのポインタはmain.cで定義されているctx_tbl[]の配列に保存します。

それではソースコードを見ていきましょう。
タスクコンテキストの作成
タスクコンテキストは make_context() で作成されます。
main.c の main()関数で以下のように呼び出しています。
/* タスクの初期化 */
ctx_tbl[0] = make_context(stack_1, sizeof(stack_1), task_1);
ctx_tbl[1] = make_context(stack_2, sizeof(stack_2), task_2);
関数本体は kernel\context.c で以下のように記述されています。
/* 初期実行コンテキストの作成 */
void *make_context( UW *sp, UINT ssize, void (*fp)())
{
StackFrame *sfp;
/* スタック上の実行コンテクスト情報へのポインタをsfpに設定 */
sfp = (StackFrame*)((UW)sp + ssize); // (1)
sfp--; // (2)
/* 実行コンテキスト情報の初期化 */
sfp->xpsr = 0x01000000; // (3)
sfp->pc = (UW)fp & ~0x00000001UL; // (4)
return (void*)sfp; // (5)
}
(1)スタックの最上位アドレスを sfp に代入しています。
(2)sfp = sfp – 64バイト としています。
これでsfpにはStackFrame構造体へのアドレスが入ります。
(3)sfp->xpsr に 0x01000000 を代入しています。
xpsr というのは APSR, IPSR, EPSR これら3つのプログラムステータスレジスタを合わせたものになります。
1が立っているビットは EPSR の T ビットで、Thumb状態を表すために 1 にする必要があります。
(cortex-m0+ には ARM命令がないため 1 固定にする必要があるのだと思います)
(4)pc(プログラムカウンタ)にタスクのアドレス fp(task_1, task_2)を代入します。
& ~0x00000001UL; の部分は D0ビットを 0 にマスクして pc に代入しています。
(Thumb命令ではジャンプ時のアドレスにD0ビットが立つことがありますが pc への設定は不要です)
(5) (2)で求められたStackFrame構造体のアドレスを返します。
タスク1のこの値は ctx_tbl[0]に、ダスク2のこの値は ctx_tbl[1]に保存されます。
これらの値はディスパッチする時に使います。
ディスパッチ
タスク切り替えを行う時に呼び出します。
/* ディスパッチャの呼出し */
#define SCB_ICSR 0xE000ED04 // 割込み制御ステートレジスタのアドレス
#define ICSR_PENDSVSET (1<<28) // PendSV set-pending ビット
static inline void dispatch( void )
{
out_w(SCB_ICSR, ICSR_PENDSVSET); // PendSV例外を発生 (1)
}
(1)ICSRレジスタのビット28に 1 を書くことで Pend SV例外が発生します。
これにより割り込みが有効であればベクターテーブルに書かれている dispatch_entry のアドレスにプログラムはジャンプします。
dispatch_entry はkernel\dispatch.S にアセンブラで書かれています。
私の方でコメントを追加したコードを以下に載せておきますので参考になさってください。
dispatch_entry:
/* ① 実行中のタスクの実行コンテキスト情報をスタックに退避 */
// ここに来る直前に r0-3, r12, LR, PC, xPSRはspに自動的に退避する(マイコンのハードウェアがspに退避してくれる)
// ここでは r4-11 を退避する必要がある
// r8-11はpushが使えないのでr0-3に代入してpushしている
push {r4-r7}
mov r0, r8
mov r1, r9
mov r2, r10
mov r3, r11
push {r0-r3}
/* ② 現在実行中のタスクの確認 */
// cur_taskから現在実行中のタスクのID番号をr1に読み込む
// 値が0の場合はアプリケーションが実行されてから最初のディスパッチであり
// 現在実行中のタスクはないので③の処理を飛ばし disp_010 へジャンプする
ldr r0, =cur_task // r0 = &cur_task;
ldr r1, [r0] // r1 = *r0;
cmp r1, #0 // if (r1 == 0)
beq disp_010 // cur_taskのID番号 = 0 ならば disp_010へ
/* ③ SPレジスタの値をctx_tblに格納 */
// spの値をctx_tblに保存する
// この値は①の処理で保存された実行コンテキスト情報へのポインタ
ldr r0, =ctx_tbl // r0 = &ctx_tbl;
sub r1, #1 // r1 = r1 - 1;
lsl r1, r1, #2 // r1 = r1 << 2; (lotate shift left)
mov r2, sp // r2 = sp;
str r2, [r0, r1] // *(uint32_t *)(r0 + r1) = r2;
disp_010:
/* ④ 実行中のタスクの変更 */
// next_taskの値をcur_taskに代入する
ldr r0, =next_task
ldr r1, [r0] // next_taskの値を r1 に代入して
ldr r0, =cur_task
str r1, [r0] // r1 の値を cur_taskに代入する
/* ⑤ スタックの切り替え */
ldr r0, =ctx_tbl // r0 = &ctx_tbl;
sub r1, #1
lsl r1, r1, #2
ldr r2, [r0, r1] // オフセットr1は 0,4,8,c,... ctx_tbl[0,1,2,3]の値 を r2 に代入
mov sp, r2 // r2 を sp に代入
// ctx_tbl[0] , [1] (コンテキストブロックへのポインタ)が切り替わって sp に入る
/* ⑥ スタック上のコンテキス情報の復元 */
// r8-11 を復帰し、r4-7を復帰する
pop {r0-r3}
mov r11, r3
mov r10, r2
mov r9, r1
mov r8, r0
pop {r4-r7}
bx lr
// この後 r0-3, r12, LR, PC, xPSRはspから自動的に復帰する(マイコンのハードウェアがspから復帰してくれる)
プログラムを実行してみる
main()関数の始めにタスクコンテキストを2つ作成します。
その後、dispatch()を実行します。
そして task_1()を実行します。
task_1()の dispatch()を実行後、task_2()が実行されます。
タスク1がLEDを点灯させて0.5秒後にdispatch()しタスク2が起動します。
タスク2はLEDを消灯させて0.5秒後にdispatch()しタスク1が起動します。
これを繰り返すのでLチカするしくみです。
タスクが切り替わる場所は、最初だけ task_1(), task_2()の先頭です。
これはmake_context()で pc に関数のアドレスを設定しておいたからです。
2週目からは、dispatch()の次あたりになります。
これはまさしく、pc(プログラムが動いていた場所)を含む実行環境が復元されていることを示しています。
今回は簡易ディスパッチャで、次以降が正式なディスパッチャになるようですが基本的な動作は同じです。
この簡易ディスパッチャを繰り返しデバッグ動作させることでタスク切り替えについての理解が深まると思います。
いかがでしたか?皆さんはディスパッチを理解できましたか?
お疲れさまでした。
この記事の続きは こちら です。