実用性はともかく無いよりは出来たほうが良いじゃん?
- この記事は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++言語でとうてい記述できるものではないからだ。誰かが作ってくれないかなーと辺りを見回すに違いない。
一方、マシン語の知識とそれを書ける視点を持っているなら話は早い。ざっくりとこうすれば良さげと秒で発想する。
-
yield()
が呼ばれたら、現在のレジスタファイルとシステムレジスタを漏れなく全部スタックにPUSH
する。 - そのスタックポインタを現在のタスク管理テーブルに記憶する。
- 次に実行権を渡すべきタスク情報を、タスク管理テーブルから探す。
- 対象タスクを見つけたら、そこにあるスタックポインタを復帰する。
- システムレジスタと全レジスタファイルをそのスタックから
POP
して、yield()
を終える。 - 何事もなく切替先のタスクが進行し、また
yield()
が呼ばれたら1から繰り返す。
まあ本当の思案のしどころはここからなんですがね。つらつらと思考展開を書き出してゆくと;
- 切替先のタスクは、初期化時点で個別かつ独自のスタック用メモリが用意されていなければならない。
- そのスタックは最初に空であってはならない。前述の
yield()
が切替完了前にシステムレジスタと全レジスタをPOP
するからだ。 - 更にタスクが終了したことが解らねばならず、タスク管理テーブルからタスクを削除するコードが実行されなければならない。これについてはスタックの先頭に削除コードへジャンプする番地が
PUSH
されていれば、スタックが空になる時にそこへ飛ぶはずではあると仮定する。そうでなければバグだ。 - スタックオーバーフロー対策はどうしよう?これは率直に無視するものと妥協するしかない。それができるメモリ管理機能がマイコンにないから、API設計段階でマルチタスク対応を盛り込まなかったのだと想像できるからだ。タスク実行に必要十分なメモリを使用者が用意し、その過不足が生じないよう注意してコーディングする他にない。
- スタック管理テーブルはどういうフォーマットが良いか。またどのくらいのタスク数に対応すればよいか。複雑な実装にはしたくないしそもそもメモリ量は限られているのだから、メインタスクを含め最大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メモリ空間上にある。 |
X
、Y
、Z
の三つの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命令ではできないので、ADD
とADC
の2命令を使う。
MOVW ZL, YL ; 作業ワードレジスタに管理先頭番地複製
ADD ZL, XL ; オフセット加算
ADC ZH, XH ;
長い道のりを経て、Z
レジスタペアには「次のタスクのスタック」が保存してあるアドレス値が格納されている。そのスタック値の上位バイトSPH
の最上位 MSBは管理フラグなので、SPH
をR25
に読み出して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
などと書く。数値を使った仮ラベルは何度同じ番号を宣言しても良い。直近への指定番号を探す仕組みなので、複数のラベルを跨ぐには違う番号で宣言する。
テストが済むとそのスタックを持つタスクは実行可能だ。現在の指標を示しているXL
をcurrent
メモリに保存し、Z
ポインタでSPL
をR24
に読み込む。完成したSPL
とSPH
を真の 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
と同じ効果を持つ。
まとめ
- アセンブリ記述の解説は、やたら長くなって疲れるね。(;´д`)
関連リンク
- TaskChanger ツールリファレンス
- TaskChanger ソースコード
- TackChanger.ino 実演スケッチ
- avr-gcc wiki
- AVR-LIBC(日本語訳)
- Wiring Framework
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/