リングバッファの実装

割り込みで受け取ったデータを、タイミングの合わないメインループへ安全に渡す必要があります。この受け渡しでよく使われるのがリングバッファです。固定長配列を循環利用する素朴な仕組みですが、割り込み安全性やDMA連携まで含めると設計上の論点が増えます。

UART受信のように、いつ到着するか予測しにくいデータを取りこぼさず受けるには、速い割り込み側と遅い処理側の間に緩衝材を挟む発想が必要です。

この記事では、実装の基本から、SPSCの理屈やDMA連携までを順に解説します。以降、書き込み側を割り込み側(ISR)、読み出し側をメインループと呼びます。

リングバッファが必要になる場面

処理のタイミングが異なる相手同士でデータを渡すには、順序を保ったまま一時的にためておけるFIFOの仕組みが必要です。リングバッファはその役割を担い、書き込む側と読み出す側を切り離します。片方が忙しくても、もう片方はバッファを介して自分のペースで動けるわけです。

割り込みで受けたデータをメインループに渡す

UART受信の割り込みで1バイトずつ受け取り、メインループのパーサが順番に取り出していきます。割り込みの中ではバッファへ書くだけにとどめ、コマンド解析や表示更新のような重い処理はメインループに回すのが基本です。ISRを短く保つほど、他の割り込みを取りこぼす危険が減ります。

センサやUARTの連続入力を一時保管する

解析処理が一瞬遅れてデータがたまっても、リングバッファがその分を吸収します。たとえばポーリングの間隔が長くなると、ハードウェアのFIFOがあふれてデータを失うことがあります。しかし、割り込みでバッファへ移しておけば、多少のバーストにも耐えられます。到着と処理のタイミングを切り離せるので、一時的な処理遅延によるフレームの取りこぼしを抑えやすくなります。

基本構造|head/tailインデックスとサイズ管理

リングバッファは、固定長の配列と、書き込み位置を指すhead、読み出し位置を指すtailの2つのインデックスで組み立てます。headは書くたびに、tailは読むたびに進み、末尾まで来たら先頭へ折り返す仕組みです。この折り返しがあるおかげで、限られたメモリを輪のように使い回せます。

満杯/空の判定のために1エントリを空ける

headとtailが同じ場合は空(empty)、headの次がtailに届いたら満杯(full)、と決めるのが典型的なやり方です。この方式だと満杯と空の両方でheadとtailが一致してしまうため、判定用に1エントリを常に空けておき、実効容量を配列サイズより1つ少なくします。フラグ変数を別に持たずに区別できるので、割り込みと共有する場面でも扱いが軽く済みます。

サイズを2のべき乗にしてビットマスクで剰余を置換する

サイズを2のべき乗にしておくと、折り返しの剰余計算をindex & (size - 1)というビット演算で置き換えられます。除算は重い処理です。Cortex-M0やM0+には除算命令自体がないため、コンパイラがソフトウェアライブラリ経由で除算を処理します。ビットANDの場合、こうした環境でも1命令で済み、割り込み内の短い処理にも向きます。

割り込み安全な実装のための考え方

割り込み安全 | SPSCならロック不要

割り込み安全 | SPSCならロック不要

割り込みとメインループで同じバッファを共有する以上、競合状態への備えが避けられません。headやtailをISRとメインループで共有する場合は、コンパイラの最適化だけでなく、メモリアクセス順序や原子性にも注意が必要です。単純な構成ではvolatileが使われることもありますが、volatileを付けるだけで同期が保証されるわけではありません。volatileは最適化抑止であり、(1)複数バイト更新の原子性、(2)メモリアクセス順序、(3)割り込みとの競合回避については別途設計が必要です。

単一プロデューサ・単一コンシューマ(SPSC)の場合はロックフリーで書ける

headの書き込みをISRだけ、tailの書き込みをメインループだけに限れば、各インデックスの書き手が1つずつになります。シングルコアのCortex-M3/M4/M7では、コンパイラの並べ替え防止(volatileの適切な使用またはコンパイラバリア)が必要です。これと、必要に応じたデータメモリバリア(__DMB())を組み合わせることで、ロックフリー(lock-free)で実装できます。こうしたSPSCのロックフリーなキューは、既存の並行処理研究でも広く扱われている定番のアプローチです。

ただしCPUやコンパイラによるメモリアクセス順序を考慮する必要がある環境もあります。その場合はデータを書き込んだ後にheadを更新する順序が保証されるよう配慮が必要です。

割り込み優先度が異なる場合のcritical sectionの入れ方

異なる優先度の複数の割り込みから書き込まれると、SPSCの前提が崩れてしまいます。Cortex-M3/M4/M7などではBASEPRIやPRIMASKを使って割り込みを一時的にマスクし、その区間だけ更新を保護する構成が一般的です。全割り込みを止めるPRIMASKに対し、BASEPRIは設定した優先度値以上(同等または低優先度)の割り込みをマスクできるため、より高い優先度の割り込みを生かしたまま保護できます。なおArm Cortex-M系では「数値が小さいほど高優先度」です。BASEPRIは、設定した優先度値以上(数値が同等または大きい=同等または低優先度)の割り込みをマスクし、より高優先度(数値が小さい)割り込みは通します(実装の優先度ビット幅に注意)。抑える区間は必要最小限にとどめるのが原則です。

DMA循環モードと組み合わせる実装パターン

高頻度で長さの読めないデータを受けるときは、CPUの割り込みに頼らず、DMAでバッファへ直接書き込む構成もよく使われます。1バイトごとの割り込みが減る分CPUの負荷を下げられ、高速な通信でも受信処理の余裕を確保しやすくなります。

マイコンのDMA循環モードとUART IDLE割り込みの併用

マイコンによっては、DMAの循環モードを有効にできます。この場合、DMAがバッファへの書き込み位置を循環し、ソフトウェア側が処理済み位置を管理することで、リングバッファに近い構成を作れます。IDLEライン検出をフレーム終端の目安として利用し、可変長のデータを1回のまとまりとして扱うのが一般的です。MCUベンダーのSDKやHAL(Hardware Abstraction Layer:ハードウェア抽象化層)によっては、IDLE検出(または受信タイムアウト)を使って可変長DMA受信を支援するAPIが提供されることがあります(提供有無・名称はベンダー依存)。

処理遅延とDMA転送単位からバッファサイズを決める

処理側の取り出しが多少遅れても受信データを保持できるよう、最大フレーム長だけではなく、処理の最大遅延時間も加味してバッファサイズを決めます。目安としては、最大フレーム長に最悪遅延分(受信レート×遅延時間)を加えた値を見積もります。ケースにより必要量は変わるため、あくまで出発点として扱います。半分転送完了(Half Transfer)と転送完了(Transfer Complete)を活用することもあります。その場合は、転送サイズに合わせた扱いやすいサイズ(2の倍数など)に設定するのが基本です。あふれたときに古いデータを上書きするか、新しいデータを捨てるかは、用途に応じて先に決めておく必要があります。

RTOS環境での抽象APIとの使い分け

RTOSが用意する既製のAPIを使うか、自作のリングバッファを採用するかは重要な選択です。必要な性能や利用形態、移植しやすさなどを考慮して決定します。既製APIは同期やブロック処理などを自前で実装する手間を減らせるメリットがあります。しかし、前述のあふれ時の動作を細かくカスタマイズしにくい点がデメリットです。

RTOS付属のストリームバッファ/メッセージバッファを使う場面

ここで扱うのは、書き込み側と読み出し側が1つずつというSPSCを前提とした選択肢です。代表的なRTOSは、バイト列を扱うストリームバッファと、可変長メッセージを扱うメッセージバッファを公式に用意しています。タスクとISRの間で、ストリームバッファはバイト列を、メッセージバッファはメッセージ単位の可変長データを渡す用途に適した機能です。実装の手間を減らしつつSPSCを前提としたデータ受け渡しに利用できます。RTOSに載せる前提の場合は、まず使用するRTOSが提供するストリーム/メッセージ用バッファで要件を満たせるかを確認します。

自作リングバッファを選ぶ場面

ベアメタルで動かす場合やリソースの制約が厳しい場合、あるいは既製APIの仕様を細かく変えたい場合は自作が向きます。基本的にはhead・tailと配列を中心に構成できるため実装自体はシンプルです。あふれ時の扱いや静的確保など、用途にあわせて挙動を決められます。テストコードとあわせても管理しやすい規模です。

テストとデバッグの観点

リングバッファは通常の使い方では問題なく動作するため、境界での不具合が本番になってから表に出やすくなります。特に巻き戻りをまたぐ処理は、通常のテストでは再現しにくく、狙って作り込まないと露呈しません。

あふれ・取りこぼしを統計カウンタで可視化する

あふれた回数や占有量の最大値を数えておき、量産の前に負荷試験で確かめます。占有量の最大値(ハイウォーターマーク)を記録しておくと、バッファの使用状況がよくわかります。サイズを増やすべきか、あるいは掃き出しを速めるべきかを決める判断材料になります。

境界値テスト|満杯・空・巻き戻し点で挙動を確認する

実効容量を超えて1つ多く詰めようとしたときや、空の状態から取り出したとき、headとtailが巻き戻る瞬間といった、これら3つの挙動は単体テストで重点的に確認すべきポイントです。巻き戻りをまたいだコピーは間違いが起きやすい難所なので、意図してこの条件を作って確かめます。割り込み処理とデバッグの基礎、DMA転送の実務とあわせて読むと、実装から検証までひと続きで見通せます。

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