C 링 버퍼 구현
링 버퍼(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 % SIZE를 index & (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 값이 나온다