【組み込み】リングバッファとは?UART受信でよく使う理由と実装時のハマりどころ

  • URLをコピーしました!

今回は、組み込み開発でよく使われるリングバッファについて解説します。

UART受信では、1文字ずつ非同期にデータが届きます。
そのため、受信したデータをいったん貯めておき、メイン処理側で順番に取り出せる仕組みが必要です。そこでよく使われるのがリングバッファです。

リングバッファ自体はシンプルな仕組みですが、実装を急ぐと取りこぼし満杯判定のミス割り込みとメイン処理の競合といった不具合が起きやすい部分でもあります。
この記事では、リングバッファの基本から、UART受信で使われる理由、実装時に注意したいポイントまでをC言語の例とあわせて紹介します。

目次

リングバッファとは

リングバッファは、固定長の配列を輪っかのように再利用するバッファです。
データを書き込む位置を head、読み出す位置を tail で管理し、末尾まで行ったら先頭に戻ります。

たとえば、8バイトの配列を使う場合は次のようなイメージです。

  • 受信割り込みで1バイト届いたら head 側へ書き込む
  • メインループで必要になったら tail 側から読み出す
  • headtail が配列末尾に達したら0に戻る

この方法なら、毎回データを前詰めする必要がなく、少ない負荷で連続受信に対応できます。

UART受信でリングバッファがよく使われる理由

UARTは、こちらの都合に関係なくデータが届きます。
メインループが別の処理をしている間にも、受信割り込みは発生します。

もし受信のたびにその場で重い解析処理まで実行すると、次の受信に間に合わなくなる可能性があります。
そこで実務では、次のように役割を分けることがよくあります。

  • 割り込み側: 受信レジスタから1バイト読み、リングバッファへ格納する
  • メイン処理側: バッファから取り出してコマンド解析やフレーム組み立てを行う

この構成にすると、割り込み処理を短く保ちやすく、UART受信の取りこぼしを減らせます。

リングバッファの基本実装

簡単な例を示します。

#include <stdint.h>
#include <stdbool.h>

#define BUF_SIZE 128

typedef struct {
    uint8_t data[BUF_SIZE];
    volatile uint16_t head;
    volatile uint16_t tail;
} RingBuffer;

static RingBuffer rx_buf;

書き込み処理は次のようにできます。

bool rb_push(RingBuffer *rb, uint8_t value)
{
    uint16_t next = (rb->head + 1) % BUF_SIZE;

    if (next == rb->tail) {
        return false; // 満杯
    }

    rb->data[rb->head] = value;
    rb->head = next;
    return true;
}

読み出し処理は以下の通りです。

bool rb_pop(RingBuffer *rb, uint8_t *value)
{
    if (rb->head == rb->tail) {
        return false; // 空
    }

    *value = rb->data[rb->tail];
    rb->tail = (rb->tail + 1) % BUF_SIZE;
    return true;
}

UART受信割り込みでは、受信データを入れるだけにします。

void uart_rx_isr(void)
{
    uint8_t ch = UART_DR;
    rb_push(&rx_buf, ch);
}

実装時のハマりどころ

満杯と空の判定を混同しやすい

リングバッファでは head == tail を空とする実装が多いです。
その場合、満杯を区別するために常に1要素分を空ける必要があります。

つまり、配列が128バイトあっても、実際に安全に使えるのは127バイトです。
この前提を忘れると、「満杯なのに空と判定される」不具合につながります。

オーバーフロー時の方針を決めていない

受信が処理速度を上回ると、いつかは満杯になります。
このときの扱いを曖昧にすると、現場で原因追跡が難しくなります。

主な方針は次の3つです。

  • 新しいデータを捨てる
  • 古いデータを上書きする
  • エラーフラグを立てて上位へ通知する

UARTコマンド受信のように順序が重要な場合は、上書きよりも破棄+エラー通知のほうが扱いやすいことが多いです。

割り込みとメイン処理の競合

head は割り込み側、tail はメイン側だけが更新する設計にすると、競合を減らしやすくなります。
ただし、バッファ残量の計算や複数バイト一括読み出しなどを行うと、途中で割り込みが入って値が変わることがあります。

そのため、以下の点を意識すると安全です。

  • 共有変数は volatile を検討する
  • 複雑な状態計算はクリティカルセクションで保護する
  • 割り込み側では重い処理をしない

volatile を付ければ排他制御が不要になるわけではありません。
あくまで「最適化で消されないようにする」ためのもので、整合性保証とは別です。

改行終端のメッセージ処理で詰まりやすい

UARTでは、\n\r\n で1コマンドを区切る実装がよくあります。
このとき、リングバッファから1文字ずつ取り出してパースする形にすると柔軟ですが、行長制限を決めていないとアプリ側のバッファがあふれます。

実務では次のような防御が有効です。

  • 1行の最大長を決める
  • 改行が来ないまま上限を超えたら破棄する
  • タイムアウトを設ける

リングバッファは万能ではなく、その先のパーサ設計まで含めて考える必要があります。

% 演算を避けたいケースもある

小さなマイコンでは、剰余演算のコストが気になることがあります。
その場合、バッファサイズを2のべき乗にして、ビットマスクで折り返す実装もよく使われます。

next = (rb->head + 1) & (BUF_SIZE - 1);

ただし、この方法は BUF_SIZE が 2, 4, 8, 16, 128 のような2のべき乗であることが前提です。
可読性とのバランスを見て選ぶのがよいでしょう。

まとめ

リングバッファは、UART受信のような非同期データ処理で非常に使いやすい仕組みです。
割り込みでは最小限の受信処理だけを行い、後段でゆっくり解析できるのが大きな利点です。

一方で、実装時には次の点でつまずきやすくなります。

  • 空と満杯の判定方法
  • オーバーフロー時の扱い
  • 割り込みとメイン処理の競合
  • 上位の文字列処理やフレーム解析とのつなぎ方

リングバッファ自体は小さな部品ですが、UARTまわりの安定性を左右する重要な土台です。
「とりあえず動く実装」で終わらせず、満杯時の振る舞いや共有データの扱いまで含めて設計しておくと、あとで困りにくくなります。

よかったらシェアしてね!
  • URLをコピーしました!

この記事を書いた人

エンジニア。20代。組み込みエンジニアとして働き始めるも、働き方や業務内容に限界を感じ、 AI,Web3エンジニアを目指して勉強中。 エンジニアとして思うことや、学んだことを発信します。

目次