RTOSベースの設計におけるスタックオーバーフローの検出(パート2)
著者: Jean J. Labrosse氏、RTOSエキスパート
スタックオーバーフローを検出するには多くの方法があります。ハードウェアを用いるやり方もあれば、ソフトウェアですべてを実行する方法もあります。以下で説明するように、オーバーフローを検出する機能は、ハードウェアに持たせるほうが、はるかに望ましいやり方と言えます。というのも、ハードウェアはスタックオーバーフローの発生を即座に検出でき、無効なアクセスへの書込みをブロックするため、事実上スタックオーバーフローを回避できるからです。
ハードウェアによるスタックオーバーフローの検出では、例外ハンドラをトリガするのが一般的です。例外ハンドラは、通常、現在のPC(プログラムカウンタ)と、場合によっては他のCPUレジスタを現在のタスクのスタックに保存します。
もちろん、例外はスタック外のデータにアクセスしようとした場合に発生するので、ハンドラはオーバーフローしたスタックのベースを超えるRAMがあるものとして、アプリケーションの一部の変数または別のスタックを上書きします。
スタックオーバーフローの状態をどう処理するかは、通常、アプリケーション開発者が決定する必要があります。例外ハンドラによって、組込みシステムを既知の安全な状態に置いてCPUをリセットするべきか、それとも何もしないでおくべきでしょうか?
CPUをリセットすると決めた場合は、オーバーフローが発生した事実とオーバーフローの原因となったタスクを記録する方法を考え、リセット時にユーザに通知できるようにしておく必要があります。
手法1:スタックポインタリミットレジスタを使用
一部の(非常に少数ですが)プロセッサは、シンプルながら極めて高性能なスタックオーバーフローを検出するレジスタ(Stack Pointer Limit register以下SP_Limit)を備えています。この機能は、ARMv8-M CPUアーキテクチャを基盤とするプロセッサで利用できます。CPUのスタックポインタがこのレジスタに設定された値を下回る(スタックの積まれ方によっては上回る)と、例外が生成されます。この動作を図3に示します。
図3 – スタックポインタリミットレジスタを使用してスタックオーバーフローを検出
(1) タスクが動作すると、RTOSのコンテキストスイッチコードによりSP_Limitレジスタがロードされます。
(2) SP_Limitが指示する場所は、スタックのベースアドレスの最下位または、問題となるスタック上で例外ハンドラが余裕を持って例外を処理するのに十分なレジスタを保存できる場所ですが、望ましいのは後者です。
(3) スタックが増加していき、SPレジスタがSP_Limitを下回ると、例外が生成されます。SPレジスタを変更しようとしても拒否されるため、アプリケーションコードは有効なスタック内に保持されます。
因みに、µC/OS-IIIおよびCs/OS3は、もともとスタックポインタリミットレジスタを有するCPUをサポートするよう設計されていました。しかし、当時この機能をサポートしていたプロセッサは、インフィニオン社の80C166/167しかありませんでした。
タスクごとにSP_Limitにロードする固有の値があり、この値はタスク制御ブロック(TCB)に置かれます。この機能への対応を予定していなかったRTOSは、アップグレードの必要がありますが、多くの場合、これはRTOS開発者の仕事です。
CPUのスタックオーバーフローを検出するハードウェアが使用するSP_Limitレジスタの値は、RTOSがコンテキストスイッチを実行するたびに変更する必要があります。変更手順は、必ず次の順序で行ってください。
1) SP_Limitを0に設定します。これにより、SPレジスタの値がSP_Limitを下回らないようにできます。なお、ここでは、スタックは高位メモリから低位メモリに向けて積まれると仮定していますが、スタックの積まれ方が逆向きであっても考え方は同じです。
2) SPをロードします。
3) 新しいタスクのSP_Limit値を、そのTCBから取得します。SP_Limitレジスタをこの値に設定します。
手法2:MPUを使用
今日のプロセッサの多くは、メモリ保護ユニット(MPU)を備えています。MPUは通常、アドレスバスをモニタし、記述したコードがメモリの特定の場所またはI/Oポートにアクセスできるかどうかを調べます。MPUは比較的簡単に使用できるデバイスですが、セットアップはやや複雑です。
しかし、スタックオーフローの検出だけが目的であれば、MPUは大量の初期化コードを使うことなく効果的に使用することができます。MPUがすでにチップにある場合は、追加コストなしに使用できるので、これを使わない手はありません。以降の考察では、「このスタック領域外に書込みをしようとするとMPUが例外を生成する」というMPU領域をセットアップします。
もちろん、別の場所にRAMがある場合は、別のMPU領域をセットアップし、そのRAMへのアクセスを許可します。説明上、ここでは、通常8領域のMPUを備えたARMv7MアーキテクチャのCortex-Mを使用していると仮定します。
この場合、全スタックを含むように1つのMPU領域を配置できます。ARMv7Mでは、スタックサイズは2のべき乗(最小32バイト)でなければならず、同じ2のべき乗のベースアドレスに揃っている必要があります。言い換えると、タスクは、サイズが32、64、128、256、512、…バイトで、それぞれ、0x??????E0、0x??????C0、0x??????80、0x??????00、0x?????E00、…に揃っていなければなりません。
ARMv8-Mアーキテクチャを基盤とするプロセッサでは、この制限はなくなり、1つのMPU領域のサイズ粒度は32バイトで、サイズは32バイトの倍数であればよく、領域は32バイト境界に揃えます。また、標準的なARMv8M CPUは、16領域のMPUを備えているため、制御の幅がより広くなります。
スタックをセットアップする方法の1つは、すべてのスタックをまとめて連続的なメモリに配置することです。この場合、図4に示すように、RAMのベースアドレスからスタックが始まります。
具体的な方法については本稿では触れませんが、IARリンカを使うと簡単にできます。
図4 – タスクのスタックを連続的に配置
RTOSのコンテキストはタスク間で切り替わるため、コンテキストは、図5に示すように1個のMPUの「保護ウィンドウ」を移動します。別の言い方をすると、タスクの切り替えが発生すると、RTOSはMPUを再プログラムして、領域の1つを新しいタスクのスタック(赤枠)を囲む形で配置します。
ARMv7M(およびARMv8M)では、最後の領域(8領域MPUでは領域#7)を使用するのが望ましいでしょう。それは、この領域が他の領域を無効にする(MPUのそれらの領域を使用中とみなして)ためです。
この方法の制約の1つとして、バッファをタスクのスタックに割り当て、そのバッファへのポインタを別のタスクに渡す操作ができないことが挙げられます。この操作を行うと、残りのタスクは自身の領域外のメモリにアクセスしなくてはならなくなるためです。
ただし、いずれにせよ、バッファをタスクのスタックに割り当てることは良策ではありません。そのため、MPU違反の罰を課されることは、まだましです。
図5 コンテキスト切り替え時にMPU領域を移動
手法3:ハードウェアベースのレッドゾーン
MPUを使用するもう一つの方法は、実行中のタスクのスタック領域外にMPU領域を配置することです。そのMPU領域内に書込みが発生した場合、MPUは例外をトリガします。
なお、この場合、MPUは前述の方法とは異なる構成となり、コードが領域外に書込みを行った場合に例外は生成されず、コードが領域内に書込みを行った場合に違反が発生します(図6参照)。
図6 – MPU領域を使用してスタック領域外への書込みを検出
この方法は、大きな配列やデータ構造をタスクスタックに割り当てられるように極めて大きな領域を設定していない限り、前述の方法に比べて信頼性が低くなります。
別の問題点として、タスクに対してアクセス可能でなければならないメモリとレッドゾーンを重ならないように配置する必要性も生じてきます。
他方、最大の利点は、タスクのスタックサイズを2のべき乗となるように設定する必要がないことです。ほとんどの場合、128バイトから256バイトのレッドゾーンで十分です。
手法4:ソフトウェアベースのレッドゾーン
MPUがない場合やMPUを使用したくない場合は、ソフトウェアベースのレッドゾーンの使用を検討してください。ただし、使用しているRTOSがこの方法をサポートしている必要があります。そうでない場合は、自分で実装する必要があります。
ここでは、図7に示すように、レッドゾーンがスタック領域内のスタックの最下位に配置されています。
レッドゾーンのサイズは、必要なリスクやオーバーヘッドによって異なり、スタックごとにレッドゾーン用に確保したいRAMのサイズによっても違ってきます。タスクの初期化時、RTOSは、レッドゾーンに0xABCDEF01(これ以外のいずれでもほとんど可)といった既知のパターンを配置します。
コンテキストの切り替え時、RTOSはレッドゾーン内で変更された場所がないかをチェックし、もしあった場合は補正アクションをとる関数を呼び出します。
図7 – コンテキストの切り替え時にRTOSがレッドゾーンをチェック
想像されるように、これは事後チェックです。つまり、損失はすでに発生しており、防ぐことはできなかったということです。
タスクスタックの使用可能領域として512バイト必要で、レッドゾーンが全部で128バイトの場合、640バイトを割り当てる必要があり、そのため、割り当てられたRAMは80%の効率しかありません。
レッドゾーンを小さくした場合、この方法を用いてオーバーフローを検出するには、ローカル変数や大きな配列またはデータ構造を関数の入口で初期化することが非常に重要です。
ソフトウェアレッドゾーンは、どのCPUアーキテクチャにも移植可能であるため便利です。ただし、RAMだけでなく、コンテキストの切り替え時に貴重なCPUサイクルも消費してしまう可能性があるのが難点です。
手法5:コンテキスト切り替え時にSPレジスタをチェック
FreeRTOSで用いられるのが、コンテキストの切り替え時にスタックポインタの値を調べる手法です。値がスタックのベースアドレス未満だった場合、RTOSは、タスクがスタック外に何かを書き込んだことを把握できます。
この手法が、すべての手法の中で最も好ましくない方法であることは間違いありません。それは、スタックポインタがスタック外を指し示したタイミングでコードがプリエンプションされなければならないからです。これでは遅すぎて、莫大な損失が生じる恐れがあります。
しかし、ソフトウェアベースのスタックリミットを使用し、スタックのベースアドレスではなく、リミットに対してチェックを行えば、タスクによる損失が生じる前にスタックポインタを取得できる可能性が大きくなります。
さらに詳しい情報
パート1の 「RTOSベースの設計におけるスタックオーバーフロー」 、またはオンデマンドのウェビナー Tips and hints for better debugging your RTOS-based application (RTOSベースアプリケーションのより効率的なデバッグのためのヒントとコツ)を参照してください。
著者について
本稿は、RTOSを使用したアプリケーション開発をテーマとしたシリーズの一部です。
Jean Labrosse(ジーン・ラブロス)氏は、Micriumの創設者であり、広く普及しているuC/OS-IIおよびuC/OS-IIIカーネルの作成者です。組込みソフトウェアのuC/ラインの発展のために積極的に取り組んでいます。
組込みシステム市場での豊富な経験を有し、市場を知り尽くしているLabrosse氏は、Weston Embedded Solutionsの主席アドバイザおよびコンサルタントとして、現行のRTOS製品の将来的な方向性の策定に尽力しています。Weston Embedded Solutionsは、Micriumのコードベースから生まれた信頼性の高いCesium RTOSファミリー製品のサポートと開発を専門としています。