C 링 버퍼 구현

CEmbedded

링 버퍼(circular buffer)는 고정 크기 배열을 원형으로 사용하는 FIFO 자료구조다. UART 수신, 센서 데이터 버퍼링, ISR→태스크 데이터 전달에 사용한다.

기본 구조

#define RING_SIZE 64  // 반드시 2의 거듭제곱

typedef struct {
    uint8_t buf[RING_SIZE];
    volatile uint32_t head;  // 쓰기 위치
    volatile uint32_t tail;  // 읽기 위치
} ring_t;

void ring_init(ring_t *r) {
    r->head = 0;
    r->tail = 0;
}

2의 거듭제곱 크기: index % SIZEindex & (SIZE - 1)로 대체할 수 있다. 나눗셈 연산이 없는 MCU에서 성능 차이가 크다.

Full/Empty 판별

head와 tail이 같으면 비어 있는 건지 꽉 찬 건지 구별할 수 없다. 세 가지 방법이 있다.

방법 1: 슬롯 하나 낭비 (가장 일반적)

bool ring_is_empty(ring_t *r) {
    return r->head == r->tail;
}

bool ring_is_full(ring_t *r) {
    return ((r->head + 1) & (RING_SIZE - 1)) == r->tail;
}

bool ring_push(ring_t *r, uint8_t data) {
    if (ring_is_full(r)) return false;
    r->buf[r->head] = data;
    r->head = (r->head + 1) & (RING_SIZE - 1);
    return true;
}

bool ring_pop(ring_t *r, uint8_t *data) {
    if (ring_is_empty(r)) return false;
    *data = r->buf[r->tail];
    r->tail = (r->tail + 1) & (RING_SIZE - 1);
    return true;
}

실제 용량은 RING_SIZE - 1이다. SPSC(Single Producer, Single Consumer) 환경에서 lock 없이 안전하다. Producer는 head만, Consumer는 tail만 수정하기 때문이다.

방법 2: count 변수 사용

typedef struct {
    uint8_t buf[RING_SIZE];
    uint32_t head;
    uint32_t tail;
    volatile uint32_t count;
} ring_counted_t;

bool ring_push(ring_counted_t *r, uint8_t data) {
    if (r->count >= RING_SIZE) return false;
    r->buf[r->head] = data;
    r->head = (r->head + 1) & (RING_SIZE - 1);
    r->count++;
    return true;
}

모든 슬롯을 사용할 수 있지만, count를 읽고 쓰는 양쪽에서 접근하므로 ISR 환경에서는 atomic 연산이나 인터럽트 비활성화가 필요하다.

방법 3: 인덱스를 감싸지 않기

typedef struct {
    uint8_t buf[RING_SIZE];
    volatile uint32_t head;  // 0부터 계속 증가
    volatile uint32_t tail;  // 0부터 계속 증가
} ring_unwrap_t;

bool ring_is_empty(ring_unwrap_t *r) {
    return r->head == r->tail;
}

bool ring_is_full(ring_unwrap_t *r) {
    return (r->head - r->tail) == RING_SIZE;
}

void ring_push(ring_unwrap_t *r, uint8_t data) {
    r->buf[r->head & (RING_SIZE - 1)] = data;
    r->head++;
}

head와 tail을 감싸지 않고 계속 증가시킨다. 실제 배열 접근 시에만 마스킹한다. uint32_t가 오버플로우해도 뺄셈 결과는 정확하다 (unsigned 산술). 슬롯 낭비도 없고 lock-free다.

UART ISR 수신 패턴

static ring_t uart_rx;

// ISR: 수신 인터럽트마다 호출
void UART_IRQHandler(void) {
    if (UART->STATUS & RX_READY) {
        uint8_t byte = UART->RXD;
        ring_push(&uart_rx, byte);  // 꽉 차면 드롭
    }
}

// 메인 루프: 데이터 소비
void process_uart(void) {
    uint8_t byte;
    while (ring_pop(&uart_rx, &byte)) {
        handle_byte(byte);
    }
}

슬롯 낭비 방식(방법 1)을 사용하면 ISR(producer)과 메인 루프(consumer) 사이에 lock이 필요 없다.

DMA + 링 버퍼

DMA를 circular 모드로 설정하면 하드웨어가 자동으로 버퍼를 순환한다. 소프트웨어는 DMA의 현재 위치만 추적하면 된다.

#define DMA_BUF_SIZE 256
static uint8_t dma_buf[DMA_BUF_SIZE];
static uint32_t read_pos = 0;

// DMA가 자동으로 dma_buf에 쓴다
// DMA 카운터로 현재 쓰기 위치를 계산

uint32_t dma_available(void) {
    uint32_t write_pos = DMA_BUF_SIZE - DMA_GET_COUNTER();
    return (write_pos - read_pos) & (DMA_BUF_SIZE - 1);
}

void dma_read(uint8_t *dst, uint32_t len) {
    for (uint32_t i = 0; i < len; i++) {
        dst[i] = dma_buf[read_pos & (DMA_BUF_SIZE - 1)];
        read_pos++;
    }
}

CPU는 DMA 전송에 관여하지 않으므로 인터럽트 빈도가 줄어든다. 고속 UART(1Mbps 이상)에서 바이트 단위 인터럽트 대신 DMA를 사용하면 CPU 부하가 크게 낮아진다.

메모

  • volatile은 컴파일러가 변수 접근을 최적화하지 못하게 한다. ISR과 메인 코드가 공유하는 변수에 반드시 붙인다
  • 슬롯 낭비 방식에서 크기를 64로 선언하면 실제 용량은 63이다. 64개가 필요하면 128로 선언한다
  • 링 버퍼가 꽉 찼을 때의 정책(드롭 vs 덮어쓰기)을 명확히 정해야 한다. 센서 최신값은 덮어쓰기, 통신 데이터는 드롭 후 에러 처리
  • 멀티 코어에서는 volatile만으로 부족하다. memory barrier(__DMB() 등)가 필요하다. 단일 코어 MCU에서는 volatile이면 충분하다
  • DMA 버퍼는 캐시 라인에 정렬하거나 non-cacheable 영역에 배치해야 한다. 캐시된 영역에서 DMA 데이터를 읽으면 stale 값이 나온다