LoginSignup
1
0

[modernAVR]シングルCPUマイコンで協調マルチタスク

Posted at

実用性はともかく無いよりは出来たほうが良いじゃん?

  • この記事はMultix Zinnia Product SDK [*AVR] for Arduino IDEに含まれるTaskChanger の技術解説だ。他の環境でそのまま動くようなものではないことに注意されたい。
  • もっぱら AVR でインラインアセンブリを書く内容になっている。今どきそういうマシン語操作を詳説した文章も多くないだろうから、ノウハウサンプルとしてかなりの長文を割く。

とっかかり

Arduino APIのdelay()関数は内部でyield()関数がもし定義済あれば遅滞ループ中に繰り返し呼び出すよう設計されている。互換APIではそうなっていないものもあるが、仕組みが用意されているんだったら使ってみたくならないか?

一応その大元のAPIのそこがどうなっているか引用しておこう。ファイル名から判るが Wiring Framework が原典のようだ。delay()を呼ぶと1ミリ秒に1回yield()を呼ぶようになっていることが判る。

/* hook.c */
static void __empty () { /* Empty */ }
void yield (void) __attribute__ ((weak, alias("__empty")));
/* wiring.c */
void delay (unsigned long ms) {
  uint32_t start = micros();
  while (ms > 0) {
    yield();
    while ( ms > 0 && (micros() - start) >= 1000) {
      ms--;
      start += 1000;
    }
  }
}

yield()が宣言中にweak属性を付加しているのがミソで、これによって利用者がyield()関数を独自に同名のユーザー関数を書けば(エラーなく)置き換えてくれる。だから下記のような関数を自分のコード中に書けばdelay()が呼ばれ続けるあいだだけ、Lチカさせることができる。

void yield (void) {
  /* delayで待っているあいだは 100ms 間隔でLチカ */
  if ((millis() / 100) % 2 == 0)
    digitalWrite(LED_BUILTIN, LOW);
  else
    digitalWrite(LED_BUILTIN, HIGH);
}

この仕組みを発展させれば 協調マルチタスク を作れるんじゃないかと閃いたなら、アナタはきっとビギナーではない。
自分ならこういうスケッチコードが書けるようにしたかった。

void setup (void) { /* あれこれ初期化 */ }
void loop (void) {
  /* なにかの処理 */

  /* 割と長く待つことがある */
  delay(5000);

  /* また何かの処理 */
}
/* 上の delay中にはこれが実行されて勝手に Lチカし続けて欲しい */
void subtask (void) {
  while (true) {
    digitalWrite(LED_BUILTIN, TOGGLE);
    delay(100); /* <-- この delay でメインループの delay に実行権が戻る */
  }
  /* このサブタスク自体は勝手に無限ループ */
  /* もしこのループを抜けてサブタスクが終わったなら、もう呼ばれないようでいて欲しい */
}

delay()が呼ばれたら次の待機タスクに実行権を渡す。それを順繰りラウンドロビンして一巡したら最初のタスクに実行権が戻ってくるという仕組みを作れたなら、このスケッチは目論見通り動くのではないだろうか。

タスク切り替えフローを設計する

思いついたはいいものの、だいたいの人はそこでくじけるように思う。普通のシングルCPUマイコン用APIにそういう仕組は用意されてないし、自作するにしても普通のC/C++言語でとうてい記述できるものではないからだ。誰かが作ってくれないかなーと辺りを見回すに違いない。

一方、マシン語の知識とそれを書ける視点を持っているなら話は早い。ざっくりとこうすれば良さげと秒で発想する。

  1. yield() が呼ばれたら、現在のレジスタファイルとシステムレジスタを漏れなく全部スタックにPUSHする。
  2. そのスタックポインタを現在のタスク管理テーブルに記憶する。
  3. 次に実行権を渡すべきタスク情報を、タスク管理テーブルから探す。
  4. 対象タスクを見つけたら、そこにあるスタックポインタを復帰する。
  5. システムレジスタと全レジスタファイルをそのスタックからPOPして、yield() を終える。
  6. 何事もなく切替先のタスクが進行し、またyield()が呼ばれたら1から繰り返す。

まあ本当の思案のしどころはここからなんですがね。つらつらと思考展開を書き出してゆくと;

  1. 切替先のタスクは、初期化時点で個別かつ独自のスタック用メモリが用意されていなければならない。
  2. そのスタックは最初に空であってはならない。前述の yield() が切替完了前にシステムレジスタと全レジスタを POP するからだ。
  3. 更にタスクが終了したことが解らねばならず、タスク管理テーブルからタスクを削除するコードが実行されなければならない。これについてはスタックの先頭に削除コードへジャンプする番地が PUSH されていれば、スタックが空になる時にそこへ飛ぶはずではあると仮定する。そうでなければバグだ。
  4. スタックオーバーフロー対策はどうしよう?これは率直に無視するものと妥協するしかない。それができるメモリ管理機能がマイコンにないから、API設計段階でマルチタスク対応を盛り込まなかったのだと想像できるからだ。タスク実行に必要十分なメモリを使用者が用意し、その過不足が生じないよう注意してコーディングする他にない。
  5. スタック管理テーブルはどういうフォーマットが良いか。またどのくらいのタスク数に対応すればよいか。複雑な実装にはしたくないしそもそもメモリ量は限られているのだから、メインタスクを含め最大4個も使えればよいだろう。また8bit級マイコンではそのアドレス範囲の全部を SRAM領域に割り当てていることはなく、せいぜいその半分以下だ。だからスタックポインタもその全ビットを使っているわけではなく、必ず未使用ビットが余っているに違いない。実際 modernAVR系列の場合は 16bitアドレスのレジスタ中、SRAM番地は下位の 15bit以内に必ず納まる。故にMSBが立っていたらそれは SRAMひいてはスタック領域を指していないので「実行しなくて良いタスク」だと判定することにしよう。この想定に合わない MCUは対応外としてよい。

ここらでフローチャートを書いて検討するのが真面目というものだが、いきなりマシン語書き出してトライ&エラーで煮詰めてゆく流儀もある。

インラインアセンブリ構文

実際のコードはソースコードを見たほうがよいがyield()実装周辺を引用するとこうだ。想定している AVRアーキテクチャは tinyAVR-0 以降の新世代系列で、ATmega328P のような旧世代 AVRは対象とはしていない。(何箇所か修正すれば使えるが)

#include <avr/io.h>
#include <util/atomic.h>

struct TaskChanger_work_t {
  char* stack[4];
  uint8_t current;
};

namespace TaskChanger {
  volatile TaskChanger_work_t __worker = {0, (char*)-1, (char*)-1, (char*)-1, 0};

  __attribute__((naked,used)) void yield (void) {
    __asm__ __volatile__ ( R"#ASM#(
        PUSH    R1              ; enter function
        IN      R1, __SREG__    ; load SREG
        PUSH    R1              ; keep SREG
    ; 割込を禁止して残りの全レジスタを保存
        CLI                     ; change SREG
        PUSH    R0              ; keep all
        PUSH    R2              ;
        PUSH    R3              ;
(中略)
        PUSH    R29             ;
        PUSH    R30             ;
        PUSH    R31             ;
        IN      R24, __SP_L__   ; SPL 取得
        IN      R25, __SP_H__   ; SPH 取得
1:      LDI     YL, lo8(%0)     ; スタック管理先頭番地
        LDI     YH, hi8(%0)     ;
        LDD     XL, Y+%1        ; スタック管理指標
        CLR     XH              ;
        MOVW    ZL, YL          ; 作業ワードレジスタに管理先頭番地複製
        ADD     ZL, XL          ; オフセット加算
        ADC     ZL, XH          ;
        STD     Z+0, R24        ; SPL 保存
        STD     Z+1, R25        ; SPH 保存
2:      ADIW    XL, 2           ; 次の指標(線形探索)
        ANDI    XL, 7           ; 指標範囲制限(オフセット0,2,4,6のラウンドロビン)
        MOVW    ZL, YL          ; 作業ワードレジスタに管理先頭番地複製
        ADD     ZL, XL          ; オフセット加算
        ADC     ZH, XH          ;
        LDD     R25, Z+1        ; 保存されている SPH 取得
        SBRC    R25, 7          ; SPH の MSB をテスト
        RJMP    2b              ; MSB がセットならタスク無効なので次の指標をテスト
        STD     Y+%1, XL        ; 選択された指標を保存
        LDD     R24, Z+0        ; 管理に保存されている SPL 取得
        OUT     __SP_L__, R24   ; SPL 復帰
        OUT     __SP_H__, R25   ; SPH 復帰
    ; 復元したスタックから全レジスタを戻す
        POP     R31             ; restore all
        POP     R30             ;
        POP     R29             ;
(中略)
        POP     R3              ;
        POP     R2              ;
        POP     R0              ;
        POP     R1              ;
        OUT     __SREG__, R1    ; restore SREG
        POP     R1              ; restore work
        RET                     ; leave function
__TaskChanger_detach_task:
    ; 終了タスクをスタック管理テーブルから削除する
        LDI     R25, 0xFF       ; スタック未使用(MSB)をマーク
        RJMP    1b
    )#ASM#"
      ::"p" (_SFR_MEM_ADDR(TaskChanger::__worker.stack))
      , "n" (_SFR_MEM_ADDR(TaskChanger::__worker.current) - _SFR_MEM_ADDR(TaskChanger::__worker.stack))
      : "memory"
    );
  }
}

void yield (void) {
  TaskChanger::yield();
}

元のコードにはTIMEOUT_BLOCK機能対応なども含まれているが、それについては論じないので省いた。

最初にタスク管理テーブルの構造体を定義し、それをvolatile宣言付きで領域確保し、初期化する。これは4個分のスタックポインタと、1個の指標変数を備えている。4個のうち先頭のスタックポインタはメインタスク用である。これは必ず最初の yield() 実行で上書きされるから何が書かれてあっても関係はない。だが他はそうではないので、MSBがセットされた適当な値を「無効タスクマーク」として書き込んでおかなければならない。指標変数currentにはメインタスクを現す 0を書いておく。

yield()関数には特別な属性を付加している。nakedはこの関数全体がアセンブリで書かれることをコンパイラに教えるもので、これによって余分なコード(例えばreturn処理)をコードに付け加えさせない効果を持つ。usedはこの関数がたとえ何処からも呼ばれざるとも最適化処理で除去させないようにし、必ず出力ファイルに書き出させる。

__asm__ __volatile__(...)はインラインアセンブリ構文で、毎度こう書く約束だと覚えておこう。単にasm(...)と書いてもよいのだが C/C++言語コンパイラの挙動が少し変わるので、書き方は統一しておいたほうが良い。説明する上ではasmと略記する。

よくあるアセンブリコードは1行毎にダブルクォートで括って改行コードも埋め込む面倒な書き方をするが、ここでは今どきのコンパイラで使えるヒアドキュメント構文を使ってスッキリ書いた。それが使えないような古いAVR-GCCではそもそも、いま俎上にあげている新世代AVRに対応していないので下位互換性に配慮する必要はないからだ。

asm構文の引数

その中身の説明をする前にasm(...)構文の引数について。これは丸括弧で括っているが関数ではない。引数は:でグループ化された構造になっている。

asm(<アセンブリ>:<出力指定>:<入力指定>:<補助指定>)

どのグループも必要に応じて省略可能だが、<アセンブリ>部はダブルクォート文字列で書く。<出力指定>はひとつだけ指定できる。<入力指定>と<補助指定>は,で区切って複数列挙できる。ここでは<出力指定>は使わないので、こういう記述になっている。

      ::"p" (_SFR_MEM_ADDR(TaskChanger::__worker.stack))
      , "n" (_SFR_MEM_ADDR(TaskChanger::__worker.current) - _SFR_MEM_ADDR(TaskChanger::__worker.stack))
      : "memory"

"指示子"(指定値)がひとつの構文だ。指示子は指定値をコンパイラにどう解釈させるか(アセンブリコードに変換するか)を決める。"p"はポインタ定数の意味で指定値をポインタ(リンクプロセス時までは結果不明)であると解釈する。"n"は指定値を静的定数値(コンパイル中に結果は定まる)であると解釈する。ここではこれらが2文あり、<アセンブリ>部内では順番に%0%1という疑似オペランドで参照することができる。

この指示子関係の記述間違いが非常に厄介で、GCC/GASはエラーを吐けどもエラー内容は意味不明であるのが常、デバッグどうこう以前に何処の何が間違っているのかいくらも教えてくれないのだ。インラインアセンブリの習熟が極端に難しい一因である。対策はできるだけ小さな最小限の内容だけを書いた単体ファイルをコンパイルさせて見ることしかない。修練である。

(指定値)の部分は普通にC/C++の記述で、値をあれこれcastして渡す。アセンブラは最終的に指定値をレジスタあるいは定数としてしか見ないので、その定数が指すのが IOレジスタ番地なのか、SRAMメモリ番地なのか、はたまた関数コードアドレスか、静的定数なのかを考えながら渡す。_SFR_MEM_ADDR()マクロはそういう作業を助けるマクロのひとつで、指定値を"データ領域内のメモリ番地"とみなせと指示してくれる。今回渡すのは SRAM内のタスク管理テーブル先頭のメモリ番地と、そこから指標変数currentまでのオフセット値(結果として静的定数になる)だけだ。

最後の<補助指定>である"memory"は、このasm構文がメモリ変更操作を行っていることをコンパイラに教える。意味はただそれだけでどういう最適化を施すかはコンパイル任せにするという意思を示しているに過ぎない。

意外かも知れないが GCCはasm構文内のコードも最適化対象にして書換えてしまうことがある。なぜなら C/C++言語から変換されたアセンブリコードと、使用者が書いたアセンブリコードとを平等に区別しないからだ。GCCを通す以上この挙動を止めることは難しいので、せめてここはこういうことをやっている、こうして欲しいという"プログラマの意思"を教えてやるように万全を期すしかない。

レジスタ割り当て

レジスタ名 別名 説明
R31:R30 ZH:ZL Zポインタレジスタペア。__worker.stack 配列のインデックス。
R29:R28 YH:YL Yポインタレジスタペア。__worker.stack 配列の先頭アドレス。
R27:R26 XH:XL Xポインタレジスタペア。インデックスオフセットの増分に使う。
R24:R24 タスク用スタックポインタ値の保存に使う。
R1 一時的作業レジスタ。SREGの保存に使う。
SREG ステータスレジスタ。AVRではIOメモリ空間上にある。
SP SPH:SPL SPレジスタ。システムスタックポインタ。AVRではIOメモリ空間上にある。

XYZの三つの16bitレジスタペアは、間接メモリ参照ができるポインタレジスタだ。AVRではこの三つしかポインタに使えないのだが、それを全部活用する。

説明しよう

さて、アセンブリコードで最初にやることは、システムレジスタSREGの保存である。AVRでは困ったことにこれが普通の汎用レジスタではなくIOレジスタなので、直接それをスタックにPUSHすることが出来ない。まず汎用作業レジスタR1をひとつあけて、IOメモリ命令INで読み出してからスタックにPUSHする。それがすめばSREG中の全体割込フラグI(レジスタ内では MSB)をCLI命令でクリアして、割込禁止状態にできる。

        PUSH    R1              ; enter function
        IN      R1, __SREG__    ; load SREG
        PUSH    R1              ; keep SREG
    ; 割込を禁止して残りの全レジスタを保存
        CLI                     ; change SREG

たったここまででももう、4命令を使った。CPUが 1MHzシステムクロック駆動だったら 4usも掛かっている。この間に多重割込なんぞが入ってきたらどうなるのかと心配になるが、AVRはそうならないように出来ているのが面白い。スタックポインタがインクリメント/デクリメントされるとそこから最大4システムクロックのあいだの割込が自動的に全部保留されるのである。それはこのルーチンが CALLされる時からもう始まっているので、CLI命令が実行されるまで割込に邪魔されない時間的猶予がある。そもそもそういう設計でなければ、必ずワード単位で番地情報をスタック操作する CALL/RET/RETI のようなアトミック操作が分断され、スタックが壊されてしまう可能性があるからだろう。8bitデータバスCPUならではの事情である。

その次は残りの全レジスタ31個をスタックにPUSHする。当然31行もあるから中略。そうしておいてからスタックポインタペアSPを汎用レジスタにIN命令で読み出す。SREGもそうだがよく操作する特殊レジスタには__で括ったマクロシンボルが用意されているのでこれを使う。またSPは16bit幅だが、8bitCPUなので2回に分けて読まなければならない。なのでそのシンボルは__SP_L____SP_H__のふたつになる。ここではR25:R24レジスタペアを保存用に使う。

        PUSH    R0              ; keep all
        PUSH    R2              ;
        PUSH    R3              ;
(中略)
        PUSH    R29             ;
        PUSH    R30             ;
        PUSH    R31             ;
        IN      R24, __SP_L__   ; SPL 取得
        IN      R25, __SP_H__   ; SPH 取得

次にタスク管理テーブルの中身を読むための準備をする。

1:      LDI     YL, lo8(%0)     ; スタック管理先頭番地
        LDI     YH, hi8(%0)     ;

lo8()hi8()は avr-gcc 特有の擬似アセンブリ関数だ。これは 16bit「静的定数」引数の下位 8bit、あるいは上位 8bitを取り出す。16bit「レジスタ」引数の下位/上位を取り出すには%A0%B0と書くので注意したい。詳細はここに書かれているが、ざっくり翻訳すると;

修飾子 説明
lo8() リンク時定数の 1 バイト目、ビット 0 ~ 7
hi8() リンク時定数の 2 バイト目、ビット 8 ~ 15
hlo8() リンク時定数の 3 バイト目、ビット 16 ~ 23
hhi8() リンク時定数の 4 バイト目、ビット 24 ~ 31
hh8() hlo8と同じ(3バイト目)
pm_lo8() リンク時定数の 2 で割った 1 番目のバイト、ビット 1 ~ 8
pm_hi8() リンク時定数を 2 で割った 2 番目のバイト、ビット 9 ~ 16
pm_hh8() リンク時定数を 2 で割った 3 番目のバイト、ビット 17 ~ 24
pm() lo8(pm(main)) のように、プログラムメモリ(ワード)アドレスを取得するためのリンク時定数を 2 で割ったもの。
gs() lo8(gs(main)) のように、プログラムメモリ(ワード)アドレスを取得するために関数アドレスを 2 で割る。必要に応じてスタブ(トランポリン)を生成する。これはEICALLで使用されることが想定される 128KiB を超えるプログラムメモリを備えたデバイス上の関数のアドレスを計算するときに必要。

pm()gs()はプログラムメモリ空間(AVRxmアーキテクチャでは最大24bitワード)をポイントするために使う。それはワード表現メモリなので常にアドレスも偶数であるから、最下位1ビットをけちって「2で割る」操作が必要なのを助けてくれる。結果的に16bitデータレジスタ幅で 128KiBのフラッシュ空間内のどこにでも実行位置をジャンプできる。普通のノイマン型CPUではちょっとお目に掛かれない設計だ。

プログラムアドレスはリンク作業完了までどういう絶対値に定まるか分からないので、これらの擬似関数の使いこなしは必須。

Yポインタレジスタが初期化できたところで、そこからcurrentに入っている現在のタスク管理番号をXLレジスタにロードする。ペアになるXHレジスタはゼロクリアしたいので、CLR XHを実行する。これが現在のR25:R24レジスタペアを保存したいstackワード型配列内のバイト型インデックスだ。

        LDD     XL, Y+%1        ; スタック管理指標
        CLR     XH              ;

Xレジスタペアを作業用のZレジスタペアに複製する。MOVW命令はこれ一つで16bitレジスタペアを複製してくれる便利な命令だが、オペランドに下位レジスタ名だけを書くので、直感的には分かりづらい。(ペアになっているZHにもYHがコピーされる)

        MOVW    ZL, YL          ; 作業ワードレジスタに管理先頭番地複製

次はZレジスタペアにXレジスタペア(stackテーブル先頭)を足しこむ。するとそれがR25:R24レジスタペアを保存するべきstackワード型配列内のアドレスだ。1命令でレジスタペアをメモリに保存するような便利なオペコードはないので、二つのSTD Z+命令を使用する。

        ADD     ZL, XL          ; オフセット加算
        ADC     ZL, XH          ;
        STD     Z+0, R24        ; SPL 保存
        STD     Z+1, R25        ; SPH 保存

ここからがようやく本題だ。次に実行権を与えるべき登録タスクを、スタック管理テーブルの中から探す。Xレジスタペアに定数2(1ワード分のオフセット)を足して7で論理和をとった値が、次のタスクの管理テーブルアドレスだ。16bit定数加算はADIW、8bit定数ANDはANDI命令を使う。結果として Xレジスタペアは四通りの値しか持たない。

2:      ADIW    XL, 2           ; 次の指標(線形探索)
        ANDI    XL, 7           ; 指標範囲制限(オフセット0,2,4,6のラウンドロビン)

YレジスタペアをZレジスタペアに複製して、Xレジスタペア値を足しこむ。16bitアドレスレジスタペア同士の加算は1命令ではできないので、ADDADCの2命令を使う。

        MOVW    ZL, YL          ; 作業ワードレジスタに管理先頭番地複製
        ADD     ZL, XL          ; オフセット加算
        ADC     ZH, XH          ;

長い道のりを経て、Zレジスタペアには「次のタスクのスタック」が保存してあるアドレス値が格納されている。そのスタック値の上位バイトSPHの最上位 MSBは管理フラグなので、SPHR25に読み出してSBRC命令で第7ビット(MSB)をテストする。このテスト命令は結果が真なら直後の命令を実行、偽なら実行しないでスキップさせる。Z80などでは「条件付きジャンプ」が多用されるが、AVRではこの「条件付き次命令スキップ」がよく使われる。ともあれテストが真なら、直後の相対ジャンプRJMPが実行される。その飛び先は2bつまり上(back)方向にある一番近い2:ラベルまで PCカウンタが戻される。

        LDD     R25, Z+1        ; 保存されている SPH 取得
        SBRC    R25, 7          ; SPH の MSB をテスト
        RJMP    2b              ; MSB がセットならタスク無効なので次の指標をテスト

下(forward)方向へのジャンプは1fなどと書く。数値を使った仮ラベルは何度同じ番号を宣言しても良い。直近への指定番号を探す仕組みなので、複数のラベルを跨ぐには違う番号で宣言する。

テストが済むとそのスタックを持つタスクは実行可能だ。現在の指標を示しているXLcurrentメモリに保存し、ZポインタでSPLR24に読み込む。完成したSPLSPHを真の SPポインタに書き込む。

        STD     Y+%1, XL        ; 選択された指標を保存
        LDD     R24, Z+0        ; 管理に保存されている SPL 取得
        OUT     __SP_L__, R24   ; SPL 復帰
        OUT     __SP_H__, R25   ; SPH 復帰

お疲れ様でした。あとは保存してあるすべてのレジスタファイルを読み戻し、割り込みフラグをステータスレジスタに復帰してRETすれば、新たなタスク実行へと復帰する。前述したようにPUSH/POP直後の4システムクロックは無条件に割込禁止なので、正しく割込許可が再開されるのはRET命令実行完了後まで保留される。

        POP     R31             ; restore all
        POP     R30             ;
        POP     R29             ;
(中略)
        POP     R3              ;
        POP     R2              ;
        POP     R0              ;
        POP     R1              ;
        OUT     __SREG__, R1    ; restore SREG
        POP     R1              ; restore work
        RET                     ; leave function

最後に、小さなスニペットコードがある。ここには終了したタスクが最後に実行するコードが書かれていて、次の生きているタスクを探すよう1:ラベルへ実行処理を飛ばすようになっている。

__TaskChanger_detach_task:
    ; 終了タスクをスタック管理テーブルから削除する
        LDI     R25, 0xFF       ; スタック未使用(MSB)をマーク
        RJMP    1b

タスク登録

タスク登録をする関数は、タスク管理テーブルへの登録とともに、タスク別ローカルスタックの初期化もする。与えられる引数はすべてリンク時定数であるから、assertを使ってコンパイル時チェックした上で受け入れる。

そしてもちろん、作業は全体割込禁止状態でなければならない。ATOMIC_BLOCK(ATOMIC_RESTORESTATE){...}ブロックを使うとSREGフラグレジスタを自動で保存/復帰してくれるので便利だ。(#include <util/atomic.h>で使えるようになる)

  void attach_task (uint8_t __task_index, volatile char __local_stack[], size_t __local_stack_size, void (*__start_task)()) {
    assert(1 <= __task_index && __task_index <= 3);
    assert(64 <= __local_stack_size);
    ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {
      /* ローカルスタックを初期化する */
      /* スタック最上位は 終わったタスクを片付ける処理の番地(BigEndian) */
      uint16_t __temp;
      __asm__ __volatile__ ( R"#ASM#(
          LDI     %A0, lo8(pm(__TaskChanger_detach_task)) ;
          LDI     %B0, hi8(pm(__TaskChanger_detach_task)) ;
        )#ASM#"
        : "=r" (__temp)
      );
      __local_stack[__local_stack_size - 1] = __temp;
      __local_stack[__local_stack_size - 2] = __temp >> 8;
      /* 実行開始番地 */
      __local_stack[__local_stack_size - 3] = (uint16_t)__start_task;
      __local_stack[__local_stack_size - 4] = (uint16_t)__start_task >> 8;
      /* R1 の初期値 */
      __local_stack[__local_stack_size - 5] = 0; // R1 == __zero_reg__
      /* SREG の初期値 */
      __local_stack[__local_stack_size - 6] = 0x80; // local SREG == sei()
      /* SP を計算 */
      __worker.stack[__task_index] = (char*)__local_stack + __local_stack_size - (32 + 6);
    }
  }

コード中の小さなアセンブリは、前述したスニペットコード__TaskChanger_detach_task関数アドレスを変数__tempにロードするためのものだ。ここでpm()lo8()hi8()の擬似アセンブリ関数が活躍する。アセンブリ内で宣言したラベル(のアドレス)はexport(アセンブリ内では.GLOBAL擬似命令)宣言しない限り C/C++言語側からは全く見えないが、それだとグローバル公開になってしまうためローカル関数のままにしておきたい場合は都合が悪い。そこでこのようなスニペットコードを使って、通常の C/C++言語変数へ関数アドレス値をロードするのだ。

タスク別ローカルスタックの初期化はそのメモリ配列末尾から逆順に埋めてゆくが、RET命令で PCレジスタへ取り出される実行関数アドレスのバイト並びはビッグエンディアンでなければならない。C/C++言語のデータ格納順や 16bit以上の幅の I/Oレジスタファイルがリトルエンディアンなのに、これだけはビッグエンディアンなので失念しやすい。AVRの古くからある CPU設計の名残なので、そういうものだと覚えておこう。最後に全レジスタ分だけ減数したスタックポインタ値を初期値として管理テーブルに書き込んでいる。

__start_task関数アドレスのメモリ格納は C/C++言語が自動的にpm()相当の「2で割る」処理をするため、小細工する必要はない。素直にメモリに格納する。

制約

  • ここで書かれたyield()タスク切り替えは少なくとも 140システムクロックを消費する。これは CPUが 1MHz 動作の場合は 140usの、20MHz動作なら 7us の遅延に相当する。この間は他の割込実行は待たされる。遅延が許されないタイムクリティカルな処理があるなら、その間はタスク切換が発生しないように配慮しなければならない。(ATOMIC_BLOCKや、<TimeoutTimer.h>TIMEOUT_BLOCK などで必要処理をガードする)
  • 各タスクには登録時に個別の専用ローカルスタックメモリを必ず割り当てなければならない。これは該当タスク実行中に不足するようなことが絶対にあってはならない。スタックメモリ不足は スタック・オーバーフロー を発生せしめ、CPU動作を不正な状態に招く。
  • 異なるタスク間の変数共有はvolatile宣言を伴っていなければ保証されない。C/C++言語からはタスク切り替えの動向を把握できないからだ。面倒なら必要な変数は全部struct構造体の中に入れておくと良い。avr-gcc でのそれはvolatileと同じ効果を持つ。

まとめ

  • アセンブリ記述の解説は、やたら長くなって疲れるね。(;´д`)

関連リンク

Copyright and Contact

Twitter(X): @askn37
BlueSky Social: @multix.jp
GitHub: https://github.com/askn37/
Product: https://askn37.github.io/

Copyright (c) askn (K.Sato) multix.jp
Released under the MIT license
https://opensource.org/licenses/mit-license.php
https://www.oshwa.org/

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0