処理を「止めない」ための工夫 - パイプライン処理による高速化と、関数呼び出しを支えるスタックの役割

組み込み機器のCPUは、限られたクロック周波数の中でセンサ入力や制御演算を時間内に処理し切る必要があります。

それは、命令を一つずつ順番に実行するだけでは間に合わない場面も多いためです。

本記事では、処理効率を引き上げるパイプライン処理と、関数呼び出しの基盤となるスタックの2つを取り上げます。どちらも実際の設計判断に関わるため、動作の概要だけでも押さえておく価値があるはずです。

なぜCPUには「高速化の仕組み」が必要なのか

CPUは、命令サイクルの逐次実行だけでは、処理能力に限界があります。組み込み機器の多くはクロック周波数が数十MHzから数百MHz程度に制限されており、その中でセンサ入力や制御演算を一定周期内に処理し切らなければなりません。

仮にモータ制御で10kHzの制御周期が求められる場合、処理1回当たりの猶予は1÷10,000=100マイクロ秒です。これは意外とシビアな値で、1命令ずつ愚直に順番で処理するだけでは、周期内に収まらないケースも珍しくありません。

こうした制約に対応するために、CPUには逐次実行を補う形で処理効率を引き上げるような仕組みが備わっています。

パイプライン処理 - 命令の「流れ作業」で効率を上げる

パイプライン処理とは、命令サイクルの各ステップを独立したユニットに分け、複数の命令を流れ作業のように並行処理する仕組みです。逐次実行では使用されなかったハードウェア資源を常に稼働させることで、CPUの処理効率を大きく引き上げられます。

この仕組みが組み込みソフトの処理時間に影響する部分なので、基本的な考え方から順に説明します。

パイプライン処理の基本的な考え方

フェッチ・デコード・エクセキュートの3ステップは、それぞれ独立したハードウェアユニットが担当しています。逐次実行方式では、一つの命令がエクセキュートまで完了してから次の命令のフェッチに移るため、その間はフェッチユニットもデコードユニットも待機状態です。つまりハードウェアの大半は待機している時間が長く、ここに改善の余地があります。

パイプライン処理では、フェッチユニットが命令Aを取り出してデコードユニットに渡した瞬間、すぐに命令Bのフェッチを開始可能です。

デコードユニットも命令Aの解読が終われば命令Bに取りかかり、その間にフェッチユニットは命令Cを取り出している、という具合に各ユニットが休みなく稼働し続けます。この並行動作によって、1クロック当たりに1命令が完了する状態へと近づいていく仕組みです。

また、組み込みマイコンで広く採用されているArm Cortex-M3も、この3ステップのパイプラインを実装しています。仮にパイプラインが途切れなければ、1サイクルごとに演算結果が出力されるような設計です。ただし実際には、命令の内容次第でこの流れが止まる場面も出てきます。

パイプラインが「詰まる」とき – ハザードとその対策

パイプラインは、命令間の依存関係によって処理の流れが滞ることがあります。この現象がパイプラインハザードです。代表的なものにデータハザードと制御ハザードがあります。

データハザードは、前の命令の演算結果を直後の命令で必要とする場面で起こります。加算命令の結果をレジスタR1に格納し、次の命令でR1を参照するようなケースでは、加算結果の格納が完了するまで後続の命令が進めません。

パイプラインはここで一時停止しますが、この待機状態をストールと呼びます。見落としがちですが、連続する命令のレジスタ依存はコード上では意外と頻繁に発生します。

制御ハザードは、条件分岐命令をきっかけに起こるものです。分岐の成否は演算結果が出るまで確定しないため、パイプラインは暫定的に次の命令をフェッチし続けます。しかし、分岐が成立すればその内容を破棄して、再び分岐先から命令を取り直すことが必要です。

なおCortex-M3ではこのロスを抑えるために、分岐先を事前に計算しておく分岐投機の機能が搭載されています。どちらのハザードも、処理時間の見積もりを狂わせる要因になり得るため、設計段階で意識しておきましょう。また、条件分岐などがなるべく発生しないようなコードを記述すると、処理時間の見積もりをしやすくなります。

スタック – 関数呼び出しと復帰を支える仕組み

スタックは、関数の呼び出しや割り込み処理の際に、レジスタの値やプログラムカウンタを一時的に退避・復元するための記憶領域です。

組み込みソフトの動作を裏側で支えているスタックについて、その構造から解説します。

スタックとは何か – 後入れ先出し(LIFO)の記憶領域

スタックは、RAM上に確保されるLIFO(Last-In, First-Out:後入れ先出し)方式のデータ領域です。新しいデータを上に積み上げるようにPUSH命令で書き込み、取り出すときはPOP命令で最後に積まれたデータから順に取り出します。

このデータの退避先の位置を管理しているのが、スタックポインタ(SP)と呼ばれる専用レジスタです。PUSHを実行するとSPのアドレスが進み、POPを実行すると戻ります。32ビットCPUでは1回のPUSHでSPが4バイト分移動する動きが一般的です。

一見地味に見えますが、プログラマがアドレスを直接指定しなくてもデータの退避と復元が正確に行える仕組みであり、次に解説する関数呼び出しの動作を理解する上での前提となる知識になります。

関数呼び出し時にスタックで何が起きるか

プログラムが関数を呼び出した瞬間、CPUはまず戻り先アドレス(現在のプログラムカウンタの値)をスタックにPUSHします。続いて呼び出し元で使っていたレジスタの内容を退避。さらに、呼び出された関数内のローカル変数がスタック上に確保され、SPはその分だけ進みます。

関数の処理が終わると、この流れが逆順に巻き戻ります。ローカル変数の領域が解放され、レジスタが復元され、戻り先アドレスがプログラムカウンタに戻ることで、呼び出し元の次の命令から再開される仕組みです。呼び出しと復帰が一対で成り立っている点が、LIFOの構造とうまく整合しています。

また、関数の中からさらに別の関数を呼ぶネスト構造になると、このPUSH/POPが多重に積み重なります。3段、4段とネストが深くなるほどスタックの消費は増えていく仕様です。そのため、組み込みのように数百バイトしかスタック領域を確保できない環境では、呼び出し階層の深さが設計上の制約として影響します。

CPUの動作を知ることが組み込みソフトの品質を支える

パイプラインの動作特性を理解していれば、ハザードによるストールを考慮した処理時間の見積もりが可能になります。分岐の多いコードで実測値が理論値から乖離した場合にも、原因の当たりが付けられるどうかはこの知識の有無により変わってくる部分です。

スタックについては、オーバーフローの防止に直結します。組み込み環境ではスタック領域が数百バイトから数KBに限られるケースも珍しくありません。

そうした環境でネストの深い関数呼び出しや大きな配列のローカル変数宣言が重なると、スタックが確保された領域を超えてあふれ出し、プログラムの異常動作を引き起こす可能性があります。加えて、スタックオーバーフローは特定の呼び出しパスでしか再現しないことが多く、通常のテストではすり抜けやすいといった厄介さを持っています。

どちらも、不具合が起きてから調べていては遅い領域です。設計段階での見積もりと制約の把握が、品質を支える起点になります。

実際には、全てを正しく見積もって設計をすることは現実的ではないことも多いです。メモリや処理速度にはある程度の余裕を持ってハードウェアを決定できると良いでしょう。

組み込みソフトの世界 トップへ戻る