Cのポインタ/リンクリスト/スタックの使い方をリアルタイムカーネルのコードを例に説明

2026年10月17日土曜日

17. リアルタイムカーネル

t f B! P L

はじめに

リアルタイムカーネルの記事(→リンク)を見直していて、リアルタイムカーネルについては説明しているもののソースコードを掲載しているだけで、ソースコードの説明が足りないかもと思い始めました。
リアルタイムカーネルは C で記述していますが、構造体や構造体の配列、構造体のポインタを使ったリンクリストの構成、関数ポインタ、更にスタックの使い方など、組み込みプログラマを目指すエンジニアのステップアップに必要な知見をリアルタイムカーネルのコードを例に説明していきます。

ポインタの基礎知識

プログラムの中のコードやデータはメモリ(RAM/ROM)上に配置されます。配置されたメモリのアドレスを示すのがポインタです。

ポインタはアドレスを格納するので、32bitマイコンなら32bitのアドレス情報を格納するため4byteサイズです。また、ポインタが指し示す対象(変数、構造体、関数)の型も宣言します。

変数のポインタ

int型の変数を指し示すポインタ p の定義は
  int *p;
p にはアドレスが格納されます。32bitマイコンなら4byteサイズです。*p  はポインタが指し示す対象(この場合はint変数)の値を意味します。

次にアドレス演算子 & ですが
  int a;
で定義された変数 a がメモリ上に配置されたアドレスは  &a  になります。ポインタ p が変数 a を指し示すようにするには
  p = &a;
で *p は変数 a の値を参照することになります。さらに
  (*p)++;
は変数 a の値をインクリメントします。

( )が重要

( )がないと、p++ が優先実行されます。p++ はメモリ上に配置された次の int型データ のアドレスを p に設定することになります。
  int a;
  int b;
と定義されていれば、*p++ は変数 b の値を参照することになります。

配列とポインタ

配列 c を
        int c[10];
と定義すると、int型データ 10要素の配列をメモリ上に配置します。

配列名はアドレス

配列名はアドレスです。p もint型のポインタなので
  p = c;
と書けます。アドレス演算子 & を使う場合は
  p = &c[0];
と書いても結果は同じで、ポインタ p に配列 c の先頭アドレスが格納されます。
p に配列のアドレスを設定した場合は、p++ と記述すると
  p = &c[0];
  p++;
この時 *p は c[1] を参照することになります。

構造体のポインタ

下記のように構造体を定義すると
  struct S{
    int a;
    int b;
  } s1, s2, *p;
p は構造体S型のポインタになります。
配列と違い構造体名はアドレスではありません。ポインタ p に構造体 s1 のアドレスを設定するには
  p = &s1;
です。構造体 s1 の構造体メンバ a の値は
  s1.a
で参照しますが、s1 のアドレスを設定したポインタ p を使って参照する場合は
  (*p).a   または   p -> a
になります。

関数ポインタ

void型の関数ポインタ p の定義は以下になります。
  void (*p)(void);

関数名はアドレス

void型の関数
  void func(void);
において、関数名 func はアドレスです。なので
  p = func;
で関数ポインタ p に関数 func のアドレスを設定すると
  (*p)();
で関数 func を実行することができます。

リアルタイムカーネルのコード

上記ポインタの基礎を前提にリアルタイムカーネルのコードを確認していきます。

タスクコントロールブロックの構造体

最初にタスクコントロールブロックの構造体 TCB の宣言です。

struct TCB {
    unsigned char status;   // タスクの状態
    void  (*task)(void);    // タスクのエントリーアドレス
    unsigned char no;       // タスク定義番号
    unsigned char level;    // タスクの優先順位 大きい方が優先順位が高い
    struct  TCB *next;      // TCB リンク
};

タスクコントロールブロックはタスクを定義しています。各構造体メンバを確認していきます。

  • status はタスクの状態です。状態には、停止 (STOP)、実行 (RUN)、実行可能 (READY)、中断 (SUSPEND)の4つがあります
  • task は関数ポインタで、タスクのエントリーアドレスになります
  • no はタスク定義番号(ID)です
  • level はタスクの優先順位です
  • next は構造体TCB型のポインタです

実行中(RUN)のタスクは常に1つですが、実行中(RUN)のタスクの処理が完了した後、実行可能(READY)状態のタスクまたは、中断(SUSPEND)状態のタスクの中で最も優先順位が高いタスクに実行権を渡すことになります。そのため、中断(SUSPEND)または 実行可能(READY)の状態のタスクは優先順位の順でリンクリストの形で管理します。

タスクコントロールブロックのリンクリストを構成するためのポインタが next です。タスクコントロールブロックの構造体 TCB の宣言の中に構造体メンバとしてTCB型のポインタを宣言できてしまうのはちょっと面白いですね。

タスクコントロールブロックの構造体 TCB の配列

リアルタイムカーネルのコードでは、必要なタスクの数だけ構造体型の配列の形でタスクコントロールブロックを定義しています。

struct TCB tcb[TASK_MAX];             // タスクコントロールブロック 

このリアルタイムカーネルでは、タスク定義番号 no を配列の要素番号としています。なのでタスクに割り振る定義番号は「 0 から始まる整数」になります。

例えば定義番号 no のタスクの構造体メンバ level(優先順位)の参照は
  tcb[no].level;

になります。

リンクリストの構成

リンクリストで管理するタスクコントロールブロックですが、中断(SUSPEND)状態のリンクリストの先頭と、実行可能(READY)状態のリンクリストの先頭、実行中(RUN)状態のタスクコントロールブロックのアドレスは下記の構造体SCB型のデータ scb で管理しています。
struct SCB {
    struct TCB *run;        // RUN タスクのTCB アドレス
    struct TCB *ready;      // 最優先READY タスクの TCB アドレス 
    struct TCB *suspend;    // 最優先 SUSPEND タスクの TCB アドレス
};

中断(SUSPEND)と実行可能(READY)のリンクリストは、優先順位順に並べており、各リンクリストの先頭は、中断(SUSPEND)と実行可能(READY)状態の中で最も優先順位が高いタスクになっています。

タスクコントロールブロックはタスク定義番号を要素番号とする配列で定義しているので、定義番号 no のタスクが実行中(RUN)になった場合に scb に登録するには
  scb.run = &tcb[no];
と記述できます。

さて、このときscbに登録された実行中(RUN)タスクの構造体メンバ level の参照は下記のいずれが正しいでしょうか?

  1.  *(scb.run).level
  2.  scb.(*run).level
  3.  (*scb.run).level

1.  *(scb.run).level は、C言語の演算子の評価順が . > * なので *((scb.run).level)の意味になってしまうのでNG
2.   scb.(*run).level は scbの構造体メンバはポインタ run(アドレス)なので、scb.(*run) は意味不明です。

正解は 3.    (*scb.run).level   で   scb.run->level   の記述でもOKです。一般的には -> を使うことが多いようですが、リアルタイムカーネルのコードでは  (*scb.run).level を使っています。

scbで管理しているタスクの優先順位は
  (*scb.run).level
  (*scb.ready).level
  (*scb.suspend).level
で参照できます。

次にリアルタイムカーネルのコードで、実行可能(READY)タスクのリンクリストの管理をしている関数 link_ready() を確認してみましょう。

リアルタイムカーネルは、イベントの要求に応じてタスクを起動する場合、実行中(RUN)タスクよりも起動要求タスクの優先順位が高ければ、起動要求タスクに実行権を渡します。同時に実行中(RUN)タスクを 中断(SUSPEND) のリンクリストに繋げます。

逆に、実行中(RUN)タスクの優先順位が起動要求タスクよりも高ければ、起動要求タスクを 実行可能(READY) のリンクリストに繋げ、実行中(RUN)タスクの終了を待ちます。

関数 link_ready() には、実行可能(READY)リンクに繋げるタスクの定義番号を引数として渡します。関数 link_ready() は、リンクリストに繋げる際には優先順位順になるように繋ぎ換え処理を行います。

///////////////////////////////////////////////
//  TCBのレディリンクへの接続処理
//    タスク定義ナンバーで指定されるタスクの TCB ポインタを、タスク
//    の優先順位の順にリンクされた TCBのレディリンクにつなげる。
///////////////////////////////////////////////
 
void link_ready(unsigned char no)
{
    struct TCB *tcb1, *tcb2;
 
    // READY TASKが存在しない場合、タスク定義ナンバーで指定さ
    // れるタスクのTCBをレディリンクの先頭(スケジューラ コントロールブロック
    // のレディリンクポインタ)につなぐ
    if (scb.ready == NULL) {
        scb.ready = &tcb[no];
        tcb[no].next = NULL;
 
    // タスク定義ナンバーで指定されるタスクの優先順位が、レディリンクの
    // 先頭(スケジューラコントロールブロックのレディリンクポインタ) のタスクの
    // 優先順位よりも高い場合レディリンクの先頭につなぐ。
    }
    else if ((*scb.ready).level < tcb[no].level) {
 
        tcb[no].next = scb.ready;
        scb.ready = &tcb[no];
 
    // レディリンクに、タスク定義ナンバーで指定されるタスクの TCBが、タスクの
    // 優先順位の順に並ぶように挿入する。
    }
    else {
 
        tcb2 = scb.ready;
        do {
            tcb1 = tcb2;
            tcb2 = (*tcb1).next;
 
            // TCBのリンクポインタがNULL の場合、そのTCBがレディリンクの最終TCBである。
            // この場合、タスク定義ナンバーで指定されるタスクの TCBをレディリンクの最後に挿入する。
            if (tcb2 == NULL) {
                (*tcb1).next = &tcb[no];
                tcb[no].next = NULL;
                return;
            }
 
            // TCBのレディリンク間のタスクの優先順位をチェック
        } while ((*tcb2).level > tcb[no].level);
        (*tcb1).next = &tcb[no];
        tcb[no].next = tcb2;
    }
}

次の図は、実行可能(READY)リンクのリンクリストの繋ぎ換え処理のイメージです。

この図は、以下のコードの赤字の処理のケースです。

        do {
            tcb1 = tcb2;
            tcb2 = (*tcb1).next;
   ・・・・
            // TCBのレディリンク間のタスクの優先順位をチェック
        } while ((*tcb2).level > tcb[no].level);
        (*tcb1).next = &tcb[no];
        tcb[no].next = tcb2;

いかがでしょう。構造体のポインタを使ったリンクリストの管理、ポインタ処理が面白くなってきたでしょうか??

スタックの使い方

次にスタックの使い方について説明していきます。Cのコードを見ている限りスタックの使い方は見えませんが、関数の引数やローカル変数、関数を呼び出した時の戻りアドレスはスタック上に確保、退避されます。

また、割り込みが発生した場合も戻りアドレスやCPUの各レジスタはスタック上に退避します。リアルタイムカーネルでは、スタックの構造や使い方を利用してタスクの優先順位に応じた実行権の割り当て(タスク切り替え)を実現しています。

基本的なスタックの振る舞い

main() からタスク生成関数 task_create() をコールしたケースを例にスタックの動きを見てみましょう。次の x86 アセンブリコードとスタックの振る舞いの図を見てください。


関数 task_create() を呼ぶときには、3つの引数 優先順位 8、タスク番号 1、task_A のエントリーアドレスをスタックに積み、関数をコールします。このとき、更にリターンアドレスがスタックに積まれます。
関数を呼ぶと、スタック上にその関数がアクセスする引数やローカル変数などを含むスタックフレームを構成します。そのスタックフレーム内の引数やローカル変数へのアクセスにはベースポインタレジスタ bp が使われます。
関数 task_create では、最初にベースポインタレジスタ bp をスタックに積みます。このベースポインタレジスタ bp は、前の関数のスタックフレームのポインタです。その後、スタックポインタ sp → bp で関数 task_create のスタックフレームになります。
更に、関数にローカル変数がある場合は、スタック上にその領域が確保されます。

関数から戻る際にはベースポインタレジスタ bp とスタックポインタ sp を前の関数の状態に戻していきます。

int main(void)
{

    // タスクの優先順位 大きい方が優先順位が高い
    // 優先順位 D > A > C > B
    task_create(task_A, taskId_A, 8);

009B2E51  push        8  
009B2E53  push        1  
009B2E55  push        offset task_A (09B1249h)  
009B2E5A  call        task_create (09B1294h)  
009B2E5F  add         esp,0Ch  


///////////////////////////////////////////////
//  タスクの生成
//    タスクのエントリーアドレス、タスク定義番号、タスクの優先順位を
//    TCBに登録し、 タスクを定義する。
///////////////////////////////////////////////

void task_create(void(*task) (void), int no, int level)
{

009B2C00  push        ebp  
009B2C01  mov         ebp,esp  

	処理は省略

009B2C74  mov         esp,ebp  
009B2C76  pop         ebp  
009B2C77  ret

※sp,bpはx86のアセンブリコードでは esp,ebp です。

リアルタイムカーネルの機能の実現

リアルタイムカーネルでは、スタックを利用してタスクの優先順位に応じた実行権の割り当て(タスク切り替え)を実現しています。

具体的には、下記の関数 dispatch() で、実行権を割り当てるタスクのエントリーアドレスや、タスク終了時の再スケジューリング処理のアドレスをスタックに積むことでリアルタイムカーネルを機能させています。動作の詳細は、こちら →リンク

void dispatch(void (*task_entry)(void))
{  
    // reschedule()のアドレスを取得
    unsigned int addr = (unsigned int)reschedule;
     
    asm volatile (
 
      // ラベルのアドレスはByteアドレスのため1ビットシフトでプログラムアドレスに修正
      " ldi r24, pm_lo8(postprocess) \n"  // 戻りアドレスの下位バイトをr24にロード
      " push r24 \n"          // スタックにPUSH
      " ldi r24, pm_hi8(postprocess) \n"  // 戻りアドレスの上位バイトをr24にロード
      " push r24 \n"          // スタックにPUSH
 
      " ldi r24, lo8(%0) \n"  // reschedule()のアドレスの下位バイトをr24にロード
      " push r24 \n"          // スタックにPUSH
      " ldi r24, hi8(%0) \n"  // reschedule()のアドレスの上位バイトをr24にロード
      " push r24 \n"          // スタックにPUSH
 
      " mov r24, %A1 \n"      // task_entry()のアドレスの下位バイトをr24にロード
      " push r24 \n"          // スタックにPUSH
      " mov r24, %B1 \n"      // task_entry()のアドレスの上位バイトをr24にロード
      " push r24 \n"          // スタックにPUSH
 
      // 割り込みを許可してnext task起動      
      " reti \n"
 
      " postprocess: \n"
 
        :                 // 出力オペランドリスト
        : "i" (addr) , "r" (task_entry)    // 入力オペランドリスト(%An下位バイト %Bn上位バイト) 
        : "r24"           //クラバー:使用レジスタをコンパイラに明示
    );
}

RISCマイコンの現実

せっかくスタックの説明をしたのですが、最後にちょっと残念なお知らせです。
基本的なスタックの振る舞いをx86マイコンで説明しましたが、現在では基本的なというよりも古典的なと言った方が正しい表現です。
Arduino UNO R3でもR4でも、現在のマイコンの多くはRISCマイコンです。RISCマイコンの場合は、スタックフレームを構成するといった面倒なことはせず、引数やローカル変数は基本的にレジスタ渡しレジスタ割り当てになっています。

また関数も呼び出すのではなく、コードをインライン展開してしまうなど、コンパイラによる最適化を前提としています。Arduino UNO R3のマイコンは ATmega328Pですが、8bit RISCマイコンで、アセンブリコードも本当にRISCマイコンです。関数 task_create() はインライン展開されており、アセンブリコード上に関数名すらありません。
ただ、RISCマイコンでも、割り当てるレジスタが足りなくなるとスタックフレームを構成するので、スタックの振る舞いは知っていても損はないかと思います。
尚、dispatch()関数は、インライン展開などの最適化をされると想定通り動かなくなってしまうので volatile宣言 でコンパイラの最適化を禁止しています。

終わりに

ちょっと調べたら、Arduino UNO R3の市場シェアや需要は依然として大きいとのこと。
なら、Arduino UNO R3のリアルタイムカーネルのこのブログも少しは意味があるでしょうか。

Translate

このブログを検索

ページビュー

QooQ