Embedded/STM32

[STM32] UART DMA Circular 모드와 Ring Buffer 이용해 수신해 보기

변화의 물결1 2026. 2. 22. 01:52

 

 

안녕하세요.

 

 최근에는 개발자들도 AI의 도움을 받아 코드를 만들기 시작했습니다. 그러나 무작정 믿고 사용하다가는 에러가 발생하기 때문에 확인하고 또 확인해야 합니다.

 

 이번 코드도 AI의 도움을 받아 조금씩 수정하면서 테스트한 코드입니다.

 DMA와 Ring Buffer를 이렇게도 가능하겠구나 참고가 되었으면 합니다.

 GPS 데이터를 계속 받는다는 전제로 작업해 보았습니다. 간단한 방식이지만, 조금 안정적인 방식으로 수신할 수 있다는 것을 확인할 수 있지 않을까 합니다.

 

 GPS 데이터 파싱 부분은 또 다른 부분이라 여기서는 빠져 있습니다. 그리고 코드를 모듈파일로 분리하지 않고 한 파일에 볼 수 있게 main.c파일에 넣어서 작업해 보았습니다.

 

     

1. 테스트 환경

 STM32G431CBU MCU와 GPS는 NEO-7M를 사용했습니다.

 stm32의 uart1을 gps와 연결하고 uart2를 PC와 연결해서 데이터를 수신했습니다.

 

 

 

2. 버퍼(Ring Buffer)무엇인가?

 

 통신에서 데이터는 불규칙하게 들어오지만, 처리는 일정 시간이 걸립니다. 이때 데이터를 임시로 보관하는 장소가 '버퍼'입니다.  링 버퍼(Circular Buffer)는 이름처럼 시작과 끝이 연결된 고리 모양의 구조를 가집니다.

 

 - 동작 원리: 데이터를 넣는 위치(In)와 꺼내는 위치(Out)를 별도로 관리합니다.

 - 장점: 데이터가 끝까지 차면 다시 처음으로 돌아가기 때문에, 메모리를 효율적으로 재사용하며 데이터가 밀려도 시스템이 멈추지 않습니다.

 - DMA와의 연동 : 하드웨어(DMA)가 In을 관리하고, 소프트웨어(CPU)가 Out을 관리하면 서로 방해받지 않고 실시간 처리가 가능해집니다.

 

 

3. 기술이 필요한가?

 

 일반적인 Interrupt 방식의 수신은 데이터가 1바이트 들어올 때마다 CPU가 일을 멈추고 데이터를 처리해야 합니다. 하지만 고속 통신이나 GPS처럼 끊임없이 데이터가 들어오는 환경에서는 CPU에 부하가 걸리고, 결국 데이터를 놓치는 Overrun Error가 발생합니다.

 

이때 DMA(Direct Memory Access)를 사용하면 CPU의 간섭 없이 데이터를 메모리에 직접 저장할 수 있으며, Circular(순환) 모드를 통해 무한히 돌아가는 버퍼를 만들 수 있습니다.

 

 

4. 기본 동작 원리 (이해하기)

 

1) DMA Circular Mode: 설정된 버퍼(1024 바이트)를 다 채우면 멈추지 않고 다시 0번 인덱스로 돌아와 데이터를 덮어씁니다.

 

2) CNDTR 레지스터: DMA 하드웨어 내부의 카운터입니다. "앞으로 쓸 공간이 얼마나 남았나?"를 알려줍니다. 이 값을 역이용하면 "DMA가 현재 버퍼 어디를 쓰고 있는지(In 포인터)"를 실시간으로 알 수 있습니다.

 

3) Ring Buffer(큐): DMA가 쓰고 있는 지점(In)과 CPU가 읽어간 지점(Out)을 나누어 관리하여 데이터 충돌을 방지합니다.

 

 

5. 전체 소스 코드 구조

 

기본 생성 코드 이외에 추가해야 할 핵심 부분을 정의부와 구현부로 나누어 정리했습니다.

 

1) 구조체 전역 변수 정의

main.c 상단의 /* USER CODE BEGIN PTD */와 /* USER CODE BEGIN PV */ 섹션 등 구조체와 변수를 추가합니다.

 

/* USER CODE BEGIN Includes */
#include <stdio.h>
#include <string.h>
/* USER CODE END Includes */

/* USER CODE BEGIN PTD */
typedef struct
{
    volatile uint32_t out;   // CPU가 읽어간 위치
    uint32_t len;            // 버퍼의 전체 크기
    uint8_t *p_buf;          // 실제 데이터가 저장될 배열 주소
} qbuffer_t;
/* USER CODE END PTD */

/* USER CODE BEGIN PD */
#define UART1_RX_BUF_SIZE 1024
/* USER CODE END PD */

/* USER CODE BEGIN PV */
uint8_t uart1_rx_buf[UART1_RX_BUF_SIZE]; // DMA가 데이터를 채울 실제 공간
qbuffer_t qbuffer_uart1;                 // 링 버퍼 관리 객체
/* USER CODE END PV */

 

 

2) 핵심 제어 함수 (API)

 

/* USER CODE BEGIN 0 */ 섹션에 구현합니다. 이 함수들이 링 버퍼의 핵심 로직입니다.

 

/* 링 버퍼 초기화 */
void qbufferInit(qbuffer_t *pNode, uint8_t *pBuf, uint32_t length) {
    pNode->out = 0;
    pNode->len = length;
    pNode->p_buf = pBuf;
}

/* DMA가 현재 쓰고 있는 위치(In) 계산 */
static inline uint32_t qbufferGetDMAIn(qbuffer_t *pNode) {
    // CNDTR: DMA가 앞으로 전송해야 할 남은 데이터 양
    return (pNode->len - hdma_usart1_rx.Instance->CNDTR) % pNode->len;
}

/* 읽을 수 있는 데이터 양 계산 */
uint32_t qbufferAvailable(qbuffer_t *pNode) {
    uint32_t dma_in = qbufferGetDMAIn(pNode);
    if (dma_in >= pNode->out) return dma_in - pNode->out;
    return (pNode->len - pNode->out) + dma_in;
}

/* 버퍼에서 데이터 읽기 (Wrap-around 처리 포함) */
uint32_t qbufferRead(qbuffer_t *pNode, uint8_t *pData, uint32_t len) {
    uint32_t available = qbufferAvailable(pNode);
    if (available == 0) return 0;
    if (len > available) len = available;

    uint32_t first_copy_len = pNode->len - pNode->out;
    
    if (first_copy_len >= len) {
        memcpy(pData, &pNode->p_buf[pNode->out], len);
    } else {
        // 버퍼 끝을 넘어가는 경우 두 번 나누어 복사
        memcpy(pData, &pNode->p_buf[pNode->out], first_copy_len);
        memcpy(&pData[first_copy_len], &pNode->p_buf[0], len - first_copy_len);
    }

    pNode->out = (pNode->out + len) % pNode->len;
    return len;
}

 

 

6. 핵심 코드 설명

 

1) CNDTR활용

 

 가장 중요한 기술적 포인트입니다. 보통 DMA는 완료 인터럽트만 사용하지만, 우리는 CNDTR 레지스터를 실시간으로 읽어 DMA가 현재 버퍼의 몇 번째 인덱스를 쓰고 있는지(In 포인터)를 파악합니다. 이를 통해 인터럽트 없이도 데이터 수신 여부를 즉시 알 수 있습니다.

 

2) Wrap-around (복사 로직)

 

 링 버퍼는 데이터가 버퍼 끝(1023)에 도달하면 다시 0으로 돌아갑니다. memcpy를 한 번만 쓰면 끝부분에서 메모리 범위를 초과할 수 있기 때문에, 끝부분과 시작 부분을 두 번 나누어 복사하는 예외 처리가 필수입니다.

 

 

7. 실행 예외 복구 코드

 

1) 초기 구동 (Main 함수)

 

  /* USER CODE BEGIN 2 */
	UART1_DMA_Start();  //DMA 시작
	printf("UART DMA and Ring Buffer Test\r\n =======");
  /* USER CODE END 2 */

  /* Infinite loop */
  /* USER CODE BEGIN WHILE */
	while (1) {
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
		uint8_t data[128];

		uint32_t len = qbufferRead(&qbuffer_uart1, data, sizeof(data)-1);

		if (len > 0)
		{
			// 데이터 처리 (예: GPS 파싱 등)
			HAL_UART_Transmit(&huart2, data, len, HAL_MAX_DELAY);
		}
	}
  /* USER CODE END 3 */

  

 

2) 에러 복구 (Callback)

 UART 통신은 노이즈나 부팅 시 타이밍 문제로 Overrun Error가 발생할 수 있습니다. 이때 하드웨어 수신이 멈추는데, 이를 풀어주는 안전장치 부분입니다.

 

 테스트해 보니, 리셋하면 DMA가 수신준비가 되지 않은 시점에도 데이터가 들어오다 보니 에러가 발생해서 한 번씩 멈추는 현상이 있었습니다.

 

/* USER CODE BEGIN 4 */
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) {
    if (huart->Instance == USART1) {
        __HAL_UART_CLEAR_FLAG(huart, UART_CLEAR_OREF | UART_CLEAR_NEF);
        HAL_UART_DMAStop(huart);
        HAL_UART_Receive_DMA(huart, uart1_rx_buf, UART1_RX_BUF_SIZE);
    }
}
/* USER CODE END 4 */

 

 

위의 코드를 빌드하고 다운로드하면 아래와 같이 GPS 데이터를 수신하는 것을 볼 수 있습니다.

 

 

 

8. 마치며

 

 이 코드대로 구현하면 인터럽트 부하는 최소화하면서 데이터 유실을 최소화한 UART 수신 코드로 사용할 수 있을 것입니다. STM32를 활용한 통신 프로젝트에서 기본이 될 수 있으니 참고 정도로 보시면 됩니다.

 참고로, main 코드 부분에서 데이터를 확인하는 부분이 있는데 이 부분도 다른 방법으로 최적화하는 부분 등 개선할 부분도 있으니 도전해 보는 것도 하나의 재미가 되지 않을까 합니다.

 

 그리고 항상 다른 사람의 코드는 의심하고, 보완하면서 사용하시기 바랍니다.

  

감사합니다.

 

 

반응형