31段の壁 — PIC で「RTOS もどき」を作ると、普通の CPU と何が違うのか

  • pic
  • アセンブリ
  • rtos
  • 組み込み
  • 電子工作

はじめに

PIC18 で、最小の協調スケジューラを書きました。タスクが切り替わり、止めたところから何事もなく続きを走り出す。動いてしまえば当たり前に見えますが、その裏で何が保存され、何が戻されているのかを、レジスタ操作の段まで降りて確かめたかったからです。

PIC を選んだのは、戻り番地の扱いが普通の CPU と決定的に違うからです。サブルーチンの戻り番地は、PIC では CPU 内部の専用「ハードウェアリターンスタック」に積まれます。しかも段数に上限があり、PIC18 で 31 段。普通の CPU ならスタックポインタ 1 個の差し替えで済む文脈切り替えが、PIC では済みません。では、タスクの続きをどこに置くのか。この記事では、そこだけをできるだけ小さな実装で追います。

確かめ方は、実機もデータシートの丸写しもなしに、自分で書いた協調スケジューラを PIC の実機モデル(gpsim)の上で動かす、というものです。ツールはすべて OSS、手元でも apt install だけで再現できます。ただし本格的な RTOS ではありません。プリエンプションもセマフォもない協調型のラウンドロビンで、狙いは機能の網羅ではなく、リターンスタックという制約が文脈切り替えに何を強いるかを、最小の実装で見ることにあります。

RTOS の肝は「文脈切り替え」

複数のタスクを同時に動いて見せる要が、文脈切り替え(コンテキストスイッチ)です。あるタスクを止め、別のタスクへ CPU を渡す。あとで戻すと、止まっていたタスクは中断に気づかず続きから走ります。これを成り立たせるため、タスクが止まる瞬間の状態一式を退避し、戻すときに書き戻します。

典型的な CPU なら、この状態一式はレジスタ群とスタックポインタで足ります(割り込み状態などは別途あるにせよ)。スタックはメモリ上にあるので、タスクごとに別の領域を割り当て、スタックポインタを差し替えれば、呼び出し履歴ごと切り替わります。

PIC は違います。ここから少し勝手が変わります。戻り番地が通常の RAM ではなく、CPU 内部の専用スタックに積まれるからです。CALL で積み、RETURN で戻る。メモリ上のソフトウェアスタックのように、ポインタ 1 個で丸ごと付け替えられません。段数の上限もあり、PIC18 は 31 段、PIC16 系だと 8 段や 16 段です。

そのため PIC では、切り替えのたびに次の操作が要ります。走行中タスクの戻り番地をハードスタックから取り出して保存領域へ退避し、次タスクの戻り番地を積み直す。タスクの戻り番地をどこへ退避するか。書き始めてすぐ、ここを決めないと先へ進めないと分かりました。

なぜ PIC18 と gpsim なのか

使ったツールは次の二つです。

  • アセンブラ: gpasm(gputils)。PIC 用の OSS アセンブラです。
  • シミュレータ: gpsim。PIC12/16/18 を命令単位で実行でき、CLI でスクリプト実行できます。GUI も実機も要りません。
sudo apt install gputils gpsim

C(XC8 や SDCC)ではなくアセンブラにしたのは、今回の主役が、レジスタを直接いじる文脈切り替えそのものだからです。TOSSTKPTR の操作を C の下に隠すと、いちばん見たい部分が隠れてしまう。今回はそこだけを見たいので、アセンブラで書きました。

PIC の中で PIC18 を選んだ理由は、ハードスタックのてっぺん(Top Of Stack)を TOSU:TOSH:TOSL で読み書きでき、STKPTR も見えるからです。つまり、戻り番地の取り出しと積み直しがソフトから書けます。

一方、PIC16(mid-range)はハードスタックの中身にソフトから触れる手段がありません。TOS を読むレジスタも、任意に積み降ろしする命令もない。今回の「戻り番地を退避する」やり方は、PIC16 では書けません。これは「8 ビット PIC で RTOS が難しい」一因ですが、唯一の理由ではありません(RAM の量や C の呼び出し規約、割り込みの扱いも絡みます)。ここでは「PIC18 ならスタックのてっぺんに手が届くから書ける」とだけ押さえます。対象デバイスは、gpsim が対応する PIC18F452 にしました。

文脈切り替えを書く

各タスクに「制御ブロック(TCB)」を一つ持たせ、退避するコンテキストは戻り番地・W レジスタ・STATUS の最小限に絞りました。中心はこの os_yield です。タスクが区切りで rcall os_yield を呼ぶと、TOS にはそのタスクへの戻り番地が載っています。これを TCB へ退避し、次タスクの番地を積み直して return します。

os_yield:
        ; 1. 走行中タスクの戻り番地をハードスタックから取り出す
        movff   TOSL, tmpL
        movff   TOSH, tmpH
        movff   TOSU, tmpU
        pop                      ; ハードスタックを 1 段降ろす (中身は退避済み)
        movwf   tmpW, A          ; W を退避
        movff   STATUS, tmpS     ; STATUS を退避

        ; 2. いまのタスクの TCB へ書き込む
        rcall   point_fsr0       ; FSR0 を現タスクの TCB 先頭へ
        movff   tmpL, POSTINC0
        movff   tmpH, POSTINC0
        movff   tmpU, POSTINC0
        movff   tmpW, POSTINC0
        movff   tmpS, POSTINC0

        ; 3. 次のタスクへ (ラウンドロビン)
        incf    cur, F, A
        movlw   NTASK
        cpfslt  cur, A           ; cur < NTASK なら次を飛ばす
        clrf    cur, A           ; cur >= NTASK のとき 0 へ巻き戻し

        ; 4. 次タスクのコンテキストを TCB から読み出す
        rcall   point_fsr0
        movff   POSTINC0, tmpL
        movff   POSTINC0, tmpH
        movff   POSTINC0, tmpU
        movff   POSTINC0, tmpW
        movff   POSTINC0, tmpS

        ; 5. 戻り番地をハードスタックへ積み直す
        push                     ; 1 段確保 (TOS は直後に上書きする)
        movf    tmpU, W, A
        movwf   TOSU, A
        movf    tmpH, W, A
        movwf   TOSH, A
        movf    tmpL, W, A
        movwf   TOSL, A

        ; 6. STATUS / W を復元して次タスクへ戻る
        movff   tmpS, STATUS
        movff   tmpW, WREG
        return                   ; 次タスクの “続き” へ

冒頭の pop と末尾の push で、ハードスタックの増減は±0です。途中の rcall point_fsr0 も呼び出し元に戻るため、タスクが何個あってもハードスタックは深く積み上がりません。各タスクの戻り番地は、ハードスタックではなく TCB の中にあります。ここが今回の要点です。

タスク本体は単純です。task0 は自分のカウンタを +1 して明け渡し、task1 は +2 します。

task0:
        incf    COUNT0, F, A     ; COUNT0 += 1
        rcall   os_yield
        bra     task0

task1:
        movlw   2
        addwf   COUNT1, F, A     ; COUNT1 += 2
        rcall   os_yield
        bra     task1

動かす

gpsim をスクリプトで走らせ、一定サイクルごとに COUNT0 / COUNT1(各タスクの出力)と STKPTR(ハードスタックの段数)を確認します。

cycle COUNT0(task0:+1) COUNT1(task1:+2) STKPTR
300 2 4 0
1500 10 20 1
4000 28 54 0

確かめたいことは二つです。

一つは、COUNT1 が常に COUNT0 のおよそ 2 倍になっていること。task0 が +1、task1 が +2 なので、二つが交互に、それぞれ中断した続きから正しく走り続けている証拠です(チェックポイントの当たり所で 1 回ぶんずれることはあります)。

もう一つが本題で、STKPTR が 0 か 1 から増えていきません。タスクは無限ループで rcall os_yield を繰り返しているのに、31 段のハードスタックは埋まっていきません。戻り番地を TCB へ退避する仕掛けが、ねらいどおり働いているからです。

わざとあふれさせる — 31 段の壁

次に、戻り番地を退避しない場合を見ます。正しく動く例だけだと、退避が何を防いでいるのか見えにくいからです。明け渡しも return もせず、ひたすら深く rcall を繰り返すタスクを書いて、同じように観察します。

; sink: 自分自身を rcall し続ける。return には決して到達しない。
sink:
        incf    depth, F, A      ; 「また 1 段潜った」と記録
        rcall   sink             ; さらに深く
        return                   ; 到達しない (=戻り番地が溢れる原因そのもの)

depth はコードが数えた潜行段数、STKPTR は実際に積めた段数です。

cycle depth(信じている段数) STKPTR 何が起きているか
40 12 0x0d depth+1 で一緒に増えていく
110 36 0x9f 31 段で頭打ち。あふれ検出ビットが立つ
600 199 0x9f depth は増え続け、実スタックは凍りついたまま

STKPTR は最初、depth より 1 だけ大きい値で増えていきます。ところが 31 に達すると止まり、値が 0x9f になります。STKFUL(スタック満杯ビット = 0x80)が立ち、下位 5 ビットが 31(0x1f)で頭打ちになった状態です(0x9f = 0x80 | 0x1f)。

ここで見たいのは、STKPTRdepth が別々の数字になっていくところです。STKPTR は 31 で止まったまま、depth は 36、199 と増え続けます。コードは 199 段潜ったつもりでも、保存できた戻り番地は 31 段ぶんだけ。あふれた分の戻り番地はどこにも記録されず、破棄されています。この sink は、もう正しい戻り先を持ちません。ハードスタックの浅い PIC で起こしやすい失敗の、いちばん素朴な形です。

make runSTKPTR が 0〜1 のまま)と make run-overflow(31 で頭打ち)を並べると、文脈切り替えのたびに戻り番地を TCB へ退避する手間が何のためだったか、一目で分かります。

詰まったところ

きれいに動いたように書きましたが、実装はすんなりとはいきませんでした。詰まった点を三つ挙げます。

MOVFF で TOS に書けない。 PIC18 の MOVFF は、行き先に TOSU/TOSH/TOSL(や PCL)を取れません。それを知らずに movff tmpU, TOSU と書いて、アセンブラに Error[181] The destination cannot be the TOSU で弾かれました。そこで、W を経由する movf / movwf の組に直しました(コードの手順 5)。

W と STATUS を戻す順番。 退避した W を movf tmpW, W で書き戻すと、movf が Z フラグを更新し、戻したばかりの STATUS が汚れます。フラグに触らない MOVFF で W と STATUS を戻して回避しました(手順 6)。ここを雑にやると、たまに分岐がおかしくなる、追いにくいバグになります。

スタックあふれの自動停止が使えない。 当初は gpsim の break so(スタックオーバーフローで停止)を当てにしていたのですが、今回の PIC18F452 では "Stack breaks not available" と言われて使えませんでした。仕方なく STKPTR を直接サンプリングしたところ、段数が 31 で頭打ちになる様子は表のとおり観測でき、かえって分かりやすかったかもしれません。

この種の食い違いは、頭の中で「たぶん動く」と判断せず、シミュレータの実レジスタで一つずつ裏を取ると早く気づけます。私の場合、今回いちばん効いたのは、STKPTR という 1 バイトを覗き続けたことでした。

このモデルが省いていること

最後に、このモデルで見ていない部分も書いておきます。これは仕組みを見るための最小実装で、本格的な RTOS でも実機の完全な再現でもありません。とくに次を割り切っています。

  • 協調型のみ。タスクは自分から os_yield したときだけ切り替わります。タイマ割り込みで強制的に切り替えるプリエンプションは入れていません。セマフォもタスク間通信もまだです。
  • 退避するコンテキストは戻り番地・W・STATUS だけ。一般の文脈切り替えとしては不完全で、たとえばタスクが FSR0 や乗算結果レジスタ、BSR をまたいで使うと壊れます(切り替えルーチン自身がそれらを使うため)。今回のタスクはそれらを使わない前提で書いています。
  • os_yield はトップレベルで呼ぶ前提です。深いサブルーチンの途中で明け渡すと、下の階層の戻り番地を TCB に保存していないため破綻します。今回はハードスタックが最上位 1 段だけという単純さが、STKPTR の動きをいちばんきれいに見せてくれました。
  • ここで見ているのは、あくまで gpsim というシミュレータの上で、今回の命令列を実行した挙動です。実シリコンの STKFUL や、STVR(あふれでリセットするかどうかの設定)の細かい振る舞いは、データシートで確かめてください。今回はあふれを観察したいので STVR = OFF(あふれてもリセットしない)にしてあります。STVR = ON だと、31 段目を積んでスタックが満杯になる、その CALLPUSHSTKFUL が立つと同時に、同じバグがチップのリセットへ化けます。

それでも、PIC のリターンスタックとは何か、文脈切り替えで戻り番地をどこへ退避するのか、退避しないと何が起きるのか。この三つは、最小モデルでもはっきり見えてきます。

おわりに

普通の CPU ならスタックポインタの差し替えで終わる文脈切り替えが、PIC では戻り番地をハードスタックから取り出し、TCB へ退避し、積み直す手順になりました。退避しなければ STKPTR は 31 で頭打ちになり、戻り先が失われます。効果も失敗も、ハードスタックが浅く、普通の CPU のように丸ごと付け替えられないという一点から出てきます。最初は地味な違いに見えますが、文脈切り替えを書くと、ここが急に効いてきます。

書いたコードと、この記事の数字を出した gpsim スクリプトは、まとめてリポジトリに置いてあります(apt install gputils gpsim のあと、pic18/make runmake run-overflow を実行すると、上の二つの表を再現できます)。

次は、ここにプリエンプション(タイマ割り込みでの強制切り替え)を加えます。割り込みが絡むと、ハードスタックの話はもう一段ややこしく、そして面白くなるはずです。

この記事は Zenn に初出したものを加筆・補足したものです ── Zenn の元記事を見る