UARTなどの通信プログラムを作っていると、「受信したデータをすぐには処理できない」という場面があります。
たとえばUARTから次々とデータが届いているのに、メイン処理ではセンサーの読み取りやOLEDの更新など、別の処理を行っている場合です。
そこでよく使われるのがリングバッファ(Ring Buffer)です。
リングバッファは、固定サイズの配列を円形につながっているように扱い、データを効率よく一時保存する仕組みです。
組み込み開発では、UART受信、センサーデータ、ログ、通信パケットなど、連続して入ってくるデータを一時的に保存するためによく使われます。
この記事では、リングバッファの基本から、Head・Tail、空・満杯の判定、C/C++での実装、さらにUARTや割り込みと組み合わせる方法まで順番に解説します。
リングバッファとは?
リングバッファとは、固定サイズの配列を円形につながっているように扱うデータ構造です。
「循環バッファ」「サーキュラーバッファ(Circular Buffer)」と呼ばれることもあります。
たとえば8バイトの配列を用意したとします。
uint8_t buffer[8];
普通の配列なら、インデックスは0~7です。
0 → 1 → 2 → 3 → 4 → 5 → 6 → 7
リングバッファでは、最後の7まで到達したら、次は0へ戻ります。
0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 ↑ ↓ └───────────────────────────────┘
実際に配列が円形になっているわけではありません。
インデックスを最後まで進めたら0へ戻すことで、同じ配列を繰り返し利用します。
なぜリングバッファを使うの?
UARTから次のようなデータが連続して届く場合を考えてみましょう。
A → B → C → D → E → F → G → ...
CPUがUARTだけを処理しているのであれば、その場ですぐ処理することもできます。
しかし実際の組み込みプログラムでは、
- センサーを読み取る
- OLEDを更新する
- Wi-Fi通信をする
- モーターを制御する
- 他のタスクを実行する
など、さまざまな処理を同時に行っています。
そこで、受信したデータをいったんリングバッファへ保存します。
UART ↓ データ受信 ↓ リングバッファへ保存 ↓ メイン処理 ↓ 必要なときに取り出す
つまりリングバッファは、データを受け取る側と処理する側の速度差を一時的に吸収する場所として利用できます。
HeadとTailとは?
リングバッファでは一般的に、現在の位置を管理するためにHeadとTailという2つのインデックスを使います。
| 名前 | 役割 |
|---|---|
| Head | 次にデータを書き込む位置 |
| Tail | 次にデータを読み出す位置 |
たとえば、
buffer
[ A ][ B ][ C ][ ][ ][ ][ ][ ]
0 1 2 3 4 5 6 7
↑
Head
↑
Tail
という状態なら、次のデータはインデックス3へ書き込みます。
読み出す場合は、Tailが指しているインデックス0のAから取り出します。
データを書き込むとHeadが進む
リングバッファへデータを書き込むときは、Headが指している位置へデータを保存します。
buffer[head] = data;
そしてHeadを1つ進めます。
head++;
ただし、配列の最後まで到達したら0へ戻す必要があります。
最後まで行ったら0へ戻す
たとえばバッファサイズが8なら、次のように書くことができます。
head = (head + 1) % 8;
%は剰余演算子です。
Headが7だった場合、
(7 + 1) % 8 = 8 % 8 = 0
となるため、次の位置は0になります。
6 → 7 → 0 → 1 → 2 → ...
これによって、配列を円形のように利用できます。
データを読み出すとTailが進む
データを読み出す場合はTailを使います。
data = buffer[tail];
読み出したらTailを1つ進めます。
tail = (tail + 1) % BUFFER_SIZE;
このように、
書き込み → Headが進む 読み出し → Tailが進む
という2つの位置を管理するのがリングバッファの基本です。
リングバッファが空かどうかを判定する
リングバッファでは、「データが入っているか」を判定する必要があります。
代表的な実装方法の1つでは、
head == tail
なら空と判断します。
Head ↓ [ ][ ][ ][ ][ ][ ][ ][ ] ↑ Tail Head == Tail ↓ データなし
では満杯はどうやって判定する?
ここで問題があります。
すべての領域を使ってHeadがTailまで一周すると、
head == tail
になってしまいます。
すると「空」と「満杯」を区別できません。
そこで、よく使われる実装方法の1つが1要素分を空けておく方法です。
1要素空ける方式
次のHead位置がTailと同じになる場合を「満杯」とします。
next = (head + 1) % BUFFER_SIZE;
if (next == tail) {
// バッファ満杯
}
つまり、
空 head == tail 満杯 (head + 1) % BUFFER_SIZE == tail
と判断できます。
この方式では、配列サイズが8でも実際に保存できるデータは最大7個です。
ほかにもデータ数を別変数で管理する方法や、満杯状態を表すフラグを持つ方法があります。リングバッファには複数の設計方法があることも覚えておきましょう。
リングバッファをC/C++で実装してみよう
ここまでの内容を実際のコードにしてみます。
#include <stdint.h>
const uint8_t BUFFER_SIZE = 8;
uint8_t buffer[BUFFER_SIZE];
uint8_t head = 0;
uint8_t tail = 0;
まず、8バイトの配列とHead・Tailを用意します。
データを書き込む関数
bool bufferPush(uint8_t data)
{
uint8_t next = (head + 1) % BUFFER_SIZE;
if (next == tail) {
return false;
}
buffer[head] = data;
head = next;
return true;
}
処理の流れは次の通りです。
- 次のHead位置を計算する
- Tailと同じなら満杯
- 空きがあればデータを書き込む
- Headを次へ進める
成功した場合はtrue、満杯で書き込めなかった場合はfalseを返しています。
データを読み出す関数
bool bufferPop(uint8_t &data)
{
if (head == tail) {
return false;
}
data = buffer[tail];
tail = (tail + 1) % BUFFER_SIZE;
return true;
}
まずhead == tailを確認します。
同じならデータがないためfalseを返します。
データがある場合はTailの位置から読み出し、Tailを1つ進めます。
C言語の場合はポインタを使える
先ほどのuint8_t &dataはC++の参照です。
C言語で同じ処理を書く場合は、ポインタを使えます。
bool bufferPop(uint8_t *data)
{
if (head == tail) {
return false;
}
*data = buffer[tail];
tail = (tail + 1) % BUFFER_SIZE;
return true;
}
呼び出す側では、
uint8_t data;
if (bufferPop(&data)) {
// dataを使用
}
とします。
ここで、以前学習したポインタが実際のデータ構造の実装にも登場します。
ポインタについては「ポインタとは?アドレス・&・*の意味を初心者向けに解説」も参照してください。
UART受信でリングバッファを使う理由
リングバッファの代表的な用途がUART受信です。
UARTではCPUの都合に関係なくデータが届く可能性があります。
UARTからデータ到着
↓
受信
↓
すぐ処理できる?
↓ ↓
YES NO
↓
一時保存が必要
処理が間に合わずUARTの受信領域があふれると、データを失う可能性があります。
そこで、受信したデータを短時間でリングバッファへ移し、本格的な処理は後で行う設計がよく使われます。
UART割り込みとリングバッファ
低レベルなUARTドライバでは、受信割り込みとリングバッファを組み合わせることがあります。
UARTハードウェア
↓
データ受信
↓
割り込み
↓
受信データを取得
↓
リングバッファへ保存
↓
割り込み終了
↓
メイン処理で取り出す
割り込み処理では、受信データを保存するところまでにしておきます。
文字列解析やコマンド処理など時間のかかる処理は、通常の処理側で行います。
割り込みについては「割り込みとは?マイコンがイベントをすぐ処理できる仕組みを初心者向けに解説」も確認してください。
ArduinoではSerial自体に受信バッファがある
ここで注意したいのが、ArduinoのSerialを使う場合です。
一般的なArduino環境では、UARTドライバやSerial実装の内部ですでに受信バッファが使われています。
そのため、通常のArduinoプログラムでUARTを使うだけなら、自分でUART割り込み処理を書いてリングバッファへ格納する必要はありません。
if (Serial.available() > 0) {
uint8_t data = Serial.read();
}
Serial.available()でデータが残っていることを確認し、Serial.read()で取り出せるのは、内部で受信データが保持されているためです。
自分でリングバッファを実装すると、こうしたライブラリ内部の仕組みを理解する練習にもなります。
volatileは必要?
割り込み処理と通常処理の両方からHeadやTailへアクセスする設計では、volatileが必要になる場合があります。
volatile uint8_t head = 0;
volatile uint8_t tail = 0;
これは、通常のコードだけでは予測できないタイミングで値が変更される可能性があることをコンパイラへ伝えるためです。
ただし、volatileを付ければ割り込みや複数タスクからのアクセスがすべて安全になるわけではありません。
どちらがHead・Tailを更新するのか、操作がアトミックか、割り込み禁止やクリティカルセクションが必要かなどは、CPUと実装方法に応じて検討する必要があります。
詳しくは「constとvolatileとは?組み込み開発での意味と使い方を初心者向けに解説」も参照してください。
1つの書き込み側と1つの読み出し側
リングバッファは、1つの処理がデータを書き込み、別の1つの処理が読み出す構成でよく利用されます。
Producer(生産者)
↓
データ
↓
リングバッファ
↓
データ
↓
Consumer(消費者)
たとえばUARTなら、
UART受信処理 ↓ Producer リングバッファ ↓ メイン処理 ↓ Consumer
という関係になります。
このような構造はProducer-Consumer(生産者・消費者)モデルとも呼ばれます。
バッファが満杯になったらどうする?
リングバッファを設計するときに必ず決めておきたいのが、満杯になったときの動作です。
代表的には次の方法があります。
- 新しいデータを捨てる
- 古いデータを上書きする
- エラーとして記録する
- 送信側を停止できるならフロー制御する
どの方法が正しいかは用途によって異なります。
たとえばログなら古いデータを上書きする設計も考えられますが、通信コマンドなら1バイト失っただけでパケット全体を正しく解釈できなくなる可能性があります。
バッファサイズはどう決める?
バッファを大きくすれば、多くのデータを一時保存できます。
しかし組み込み機器ではRAM容量に制限があります。
小さすぎる ↓ すぐ満杯になる 大きすぎる ↓ RAMを多く消費する
そのため、
- データの到着速度
- 最大で何バイト連続して届くか
- 読み出し側が処理できない最長時間
- 使用できるRAM容量
などを考えてサイズを決めます。
2のべき乗サイズが使われることも多い
リングバッファでは、8、16、32、64、128、256など、2のべき乗をサイズとして使う実装もよくあります。
サイズが2のべき乗なら、条件が合えば剰余演算の代わりにビットマスクを利用できます。
head = (head + 1) & (BUFFER_SIZE - 1);
たとえばBUFFER_SIZEが8なら、
head = (head + 1) & 0x07;
とすることで、インデックスを0~7へ循環させられます。
ただし、この方法はBUFFER_SIZEが2のべき乗であることが前提です。まずは%を使った実装を理解してからで十分です。
ビット演算については「ビット演算とは?AND・OR・XOR・NOT・シフト演算を初心者向けに解説」も参照してください。
複数バイトのデータも保存できる
リングバッファへ保存するのは1バイトのデータだけとは限りません。
構造体を配列にすれば、センサーデータなども保存できます。
struct SensorData {
uint32_t time;
float temperature;
};
SensorData buffer[16];
これなら、
時刻 + 温度
時刻 + 温度
時刻 + 温度
↓
リングバッファへ順番に保存
という使い方もできます。
構造体については「構造体とは?C/C++のstructとメンバ・ポインタの使い方を初心者向けに解説」で詳しく解説しています。
リングバッファのメリット
- 固定サイズのメモリを繰り返し利用できる
- データを前へ詰め直す必要がない
- 書き込み・読み出しを効率よく行える
- 動的メモリ確保を使わずに実装できる
- 連続して到着するデータを一時保存できる
- UARTやセンサーなど組み込み用途と相性がよい
リングバッファの注意点
- 空と満杯の判定方法を決める必要がある
- 満杯時の動作を決める必要がある
- バッファサイズが小さいとデータを失う可能性がある
- 割り込みや複数タスクからアクセスする場合は同期を考える必要がある
- HeadとTailの管理を間違えるとデータが壊れる
KUMITATE-C3でリングバッファを試してみよう
KUMITATE-C3でも、まずはSerialを使ってリングバッファの動きを確認できます。
const uint8_t BUFFER_SIZE = 8;
uint8_t buffer[BUFFER_SIZE];
uint8_t head = 0;
uint8_t tail = 0;
bool bufferPush(uint8_t data)
{
uint8_t next = (head + 1) % BUFFER_SIZE;
if (next == tail) {
return false;
}
buffer[head] = data;
head = next;
return true;
}
bool bufferPop(uint8_t &data)
{
if (head == tail) {
return false;
}
data = buffer[tail];
tail = (tail + 1) % BUFFER_SIZE;
return true;
}
void setup()
{
Serial.begin(115200);
}
void loop()
{
while (Serial.available() > 0) {
uint8_t data = Serial.read();
if (!bufferPush(data)) {
Serial.println("Buffer Full");
}
}
uint8_t data;
if (bufferPop(data)) {
Serial.print("Read: ");
Serial.write(data);
Serial.println();
}
}
シリアルモニターから文字を送ると、受信したデータが一度リングバッファへ入り、その後bufferPop()で取り出されます。
ArduinoのSerial内部にも受信バッファがあるため、このサンプルは「UART受信を実用上改善する」というより、リングバッファそのものの動きを理解する教材として考えてください。
これまでの記事がリングバッファでつながる
リングバッファは、これまで学習してきた内容を実際の組み込みプログラムへつなげる良い題材です。
データ型 ↓ 配列 ↓ Head / Tail ↓ ビット演算・剰余 ↓ リングバッファ ↓ UART ↓ 割り込み ↓ volatile ↓ メイン処理
さらに構造体を使えば、1バイトだけでなく複数の情報をまとめたデータをリングバッファへ保存できます。
こうした仕組みを理解すると、UARTやBluetoothなどの通信ライブラリ、センサードライバ、RTOSなどの内部コードも少しずつ読めるようになります。
まとめ
リングバッファは、固定サイズの配列を円形のように繰り返し利用するデータ構造です。
- リングバッファは固定サイズの配列を繰り返し利用する
- Headは次に書き込む位置を管理する
- Tailは次に読み出す位置を管理する
- 配列の最後まで進んだら先頭へ戻る
- HeadとTailを使って空・満杯を判定できる
- 1要素空ける方式では実際の保存容量は配列サイズより1つ少なくなる
- UARTなど連続して届くデータの一時保存によく使われる
- 割り込みではデータをリングバッファへ保存し、重い処理を後回しにする設計ができる
- volatileだけでは排他制御やアトミック性は保証されない
- 構造体を使えばセンサーデータなども保存できる
リングバッファのコード自体はそれほど長くありません。しかし、その中には配列・インデックス・データ型・ビット演算・割り込み・volatile・UARTなど、組み込み開発の基本が数多く含まれています。
そのため、自分で一度リングバッファを実装して動かしてみることは、組み込みソフトウェアを理解するうえで非常に良い練習になります。

