Firmware & RTOS/FreeRTOS & Real-time Scheduling

FreeRTOS 메시지 큐(Queue) 완벽 가이드: 태스크 간 데이터 통신 및 예제 (CMSIS-RTOS v2)

임베디드 친구 2025. 1. 13. 08:37
반응형

임베디드 시스템의 펌웨어 구조가 복잡해지고 처리해야 할 데이터 연산량이 늘어나면서 멀티태스킹 환경 구축은 필수적인 선택이 되었습니다. 하드웨어 자원을 쪼개어 여러 개의 태스크(Task)가 동시에 구동될 때, 개발자가 마주하는 가장 까다로운 과제 중 하나는 바로 태스크 간 동기화와 "어떻게 데이터를 깨지지 않고 안전하게 주고받을 것인가"에 대한 문제입니다.

흔히 사용하는 전역 변수나 공유 배열을 통해 데이터를 넘겨주려고 하면, 컨텍스트 스위칭이 일어나는 찰나의 타이밍에 데이터가 오염되는 레이스 컨디션(Race Condition) 현상이 발생하기 쉽습니다. 이를 안정적으로 해결하기 위해 FreeRTOS는 커널이 자체적으로 보호하는 큐(Queue)라는 강력한 IPC(Inter-Process Communication) 인터페이스를 제공합니다. 이번 글에서는 글로벌 표준 인터페이스인 CMSIS-RTOS v2 API를 기준으로 메시지 큐의 동작 메커니즘을 이해하고, 실무에서 자주 쓰이는 센서 데이터 수집 및 처리 시스템을 예제로 구현해 보겠습니다.

핵심 요약 3줄

  • 스레드 세이프 통신: 메시지 큐는 커널 레벨에서 임계 영역을 자동으로 보호하여 레이스 컨디션 없이 태스크 간 데이터를 안전하게 전송합니다.
  • FIFO 구조의 완충 지대: 선입선출 방식으로 데이터 순서를 보장하며, 데이터를 생성하는 태스크와 소비하는 태스크 간의 처리 속도 차이를 완충하는 버퍼 역할을 수행합니다.
  • 유연한 블로킹 제어: osMessageQueue API의 타임아웃 설정을 활용하면 데이터 유무에 따라 태스크를 Blocked 상태로 자동 전환하여 CPU 소모를 최소화합니다.

1. 메시지 큐(Message Queue)의 개념과 도입 배경

메시지 큐는 데이터 항목을 순서대로 저장하고 관리하는 커널 내 보호 자료 구조로, 기본적으로 FIFO(First In, First Out, 선입선출) 방식으로 동작합니다. 즉, 먼저 입력된 데이터가 가장 먼저 빠져나가는 구조를 가집니다.

임베디드 소프트웨어 아키텍처 설계 시 메시지 큐가 필수적으로 요구되는 상황은 크게 세 가지로 요약할 수 있습니다.

적용 상황 세부 동작 및 필요성
태스크 간 데이터 전송 데이터를 주기적으로 갱신하는 생성자(Producer) 태스크와 이를 가공하거나 디스플레이에 표출하는 소비자(Consumer) 태스크 간의 데이터 격리 통신.
인터럽트(ISR) 후처리 연산 타이머나 UART 수신 등 급박한 처리가 필요한 ISR 내부에서 원시 데이터를 큐에 빠르게 밀어 넣고, 무거운 필터링이나 프로토콜 분석은 일반 태스크 레벨로 이관하여 처리할 때.
속도 차이 제어 (Buffering) 데이터가 입력되는 물리적 속도와 MCU가 가공하여 외부 플래시나 네트워크로 내보내는 소모 속도가 일시적으로 불일치할 때, 누락을 방지하는 메모리 완충 지대 역할.

2. 핵심 API 사용법 (CMSIS-RTOS v2)

CMSIS-RTOS v2 규격에서는 메시지 큐를 선언하고 제어하기 위해 직관적인 구조를 가진 함수 인터페이스를 제공합니다. 가장 핵심이 되는 두 가지 함수는 다음과 같습니다.

2.1 osMessageQueuePut(): 큐에 데이터 삽입

데이터를 생성하는 태스크가 큐의 맨 뒤에 새로운 데이터를 밀어 넣을 때 호출합니다.

C
 
osStatus_t osMessageQueuePut(osMessageQueueId_t mq_id, const void *msg_ptr, uint8_t msg_prio, uint32_t timeout);
  • mq_id: osMessageQueueNew 함수를 통해 메모리에 정상 생성된 메시지 큐의 식별 핸들 ID입니다.
  • msg_ptr: 큐에 복사하여 집어넣을 소스 데이터 변수의 실제 주소 포인터입니다.
  • msg_prio: 메시지 자체의 우선순위를 지정할 수 있으나, 일반적인 구조에서는 기본값인 0을 대입하여 순차 처리를 진행합니다.
  • timeout: 만약 큐가 가득 차서 데이터를 더 넣을 공간이 없을 때, 공간이 생길 때까지 현재 태스크가 Blocked 상태로 대기할 시간을 시스템 틱 단위로 지정합니다. osWaitForever 설정 시 자리가 날 때까지 무한 대기하며, 0을 넣으면 즉시 에러 반환 코드를 뱉고 넘어갑니다.

2.2 osMessageQueueGet(): 큐에서 데이터 추출

데이터를 소비하는 태스크가 큐의 맨 앞에서 대기 중인 데이터를 읽어올 때 호출합니다.

C
 
osStatus_t osMessageQueueGet(osMessageQueueId_t mq_id, void *msg_ptr, uint8_t *msg_prio, uint32_t timeout);
  • msg_ptr: 큐에서 꺼내온 데이터를 복사하여 저장할 로컬 버퍼 변수의 주소 포인터입니다.
  • timeout: 큐에 가져올 데이터가 하나도 없을 때, 데이터가 들어올 때까지 대기할 시간입니다. 이 인자에 osWaitForever를 지정하면 평소에는 CPU 자원을 0%로 유지하며 완벽한 대기(Blocked) 상태로 머물다가, 다른 태스크가 데이터를 넣어주는 순간 스케줄러에 의해 깹니다.

3. 실전 예제: 센서 데이터 교환 시스템 구현

이해를 돕기 위해 하드웨어 센서에서 값을 읽어 큐에 넣는 생성자 태스크(SensorTask)와 이를 전달받아 후처리 로직을 처리하는 소비자 태스크(ProcessTask)의 실무 구조 코드를 살펴보겠습니다.

Step 1: 큐 선언 및 생성

C
 
#include "cmsis_os2.h"

// 큐가 담을 수 있는 최대 아이템 개수 및 핸들 정의
#define QUEUE_SIZE 5
osMessageQueueId_t messageQueue;

int main(void) {
    // 하드웨어 및 커널 초기화 진행 후 메인 진입 영역에서 생성
    osKernelInitialize();
    
    // 32비트 정수형(uint32_t) 데이터 5개를 담을 수 있는 메시지 큐 할당
    messageQueue = osMessageQueueNew(QUEUE_SIZE, sizeof(uint32_t), NULL);
    
    // 태스크 생성 코드 생략 (SensorTask, ProcessTask 등록 필요)
    osKernelStart();
    while (1);
}

Step 2: 데이터를 보내는 태스크 (Producer)

C
 
void SensorTask(void *argument) {
    uint32_t sensorData = 0;
    while (1) {
        sensorData = ReadSensor(); // 가상 주변장치 데이터 수집 함수 호출
        
        // 큐에 데이터 삽입 (버퍼가 꽉 찼다면 대기 없이 즉시 반환하여 다음 루프 실행)
        osStatus_t status = osMessageQueuePut(messageQueue, &sensorData, 0, 0);
        
        if (status == osOK) {
            // 송신 성공 시 처리할 로그나 로직 구현 영역
        } else {
            // 큐 오버플로우 발생 시 예외 처리 영역
        }
        
        osDelay(1000); // 1초 주기로 센서 샘플링 수행
    }
}

Step 3: 데이터를 처리하는 태스크 (Consumer)

C
 
void ProcessTask(void *argument) {
    uint32_t receivedData;
    while (1) {
        // 큐에 새로운 데이터가 들어올 때까지 무한 대기하며 CPU 자원을 반환함
        osStatus_t status = osMessageQueueGet(messageQueue, &receivedData, NULL, osWaitForever);
        
        if (status == osOK) {
            // 수신 완료된 데이터를 기반으로 무거운 연산이나 직렬 포트 출력 수행
            printf("Received Sensor Value: %lu\n", receivedData);
        }
    }
}

4. 개발을 위한 실무 팁

  • 대형 구조체 전송 시 포인터 주소 복사법 활용: FreeRTOS의 메시지 큐는 기본적으로 데이터의 실제 '값' 자체를 내부 버퍼에 통째로 Deep Copy하는 방식을 취합니다. 4바이트나 8바이트 변수라면 문제가 없으나, 수십 수백 바이트 크기의 센서 원시 배열 구조체를 통째로 넣으면 복사 연산 오버헤드와 큐 전용 RAM 낭비가 심각해집니다. 이럴 때는 동적 할당이나 정적 버퍼의 '포인터 주소(4바이트 값)'만 주고받도록 큐 사이즈(sizeof(struct MyData*))를 설계하는 것이 런타임 성능 최적화의 핵심입니다.
  • 컴파일 타임 정적 할당 고려: 메모리 단편화(Fragmentation)에 매우 민감한 의료, 방산 제품군 프로젝트에서는 osMessageQueueNew를 통한 런타임 힙 메모리 할당이 금지될 수 있습니다. CMSIS-RTOS v2에서는 osMessageQueueAttr_t 구조체를 통해 사용자가 정의한 정적 글로벌 배열 영역(mq_mem 및 mq_size)을 큐의 물리적 버퍼로 강제 매핑할 수 있는 정적 생성 기능을 지원하므로 적극 활용하시기 바랍니다.

5. 흔히 하는 실수

  • 인터럽트(ISR) 내에서 일반 API 호출: 타이머 인터럽트나 UART 인터럽트 콜백 함수 내부에서 외부 태스크로 데이터를 넘기려고 일반 osMessageQueuePut 함수를 무심코 호출하는 실수를 자주 범합니다. 인터럽트 컨텍스트에서는 절대로 블로킹 대기가 허용되지 않으므로, 일반 함수를 쓰면 커널 내부 제어 스택이 무너지며 시스템 하드 fault 가 발생합니다. 반드시 내부적으로 FreeRTOS 네이티브의 FromISR 계열로 분기 처리되는 적합한 API 환경인지 확인하거나 타임아웃 인자를 0으로 제약해야 합니다.
  • 값 복사 대상 변수의 라이프 사이클 오해: 포인터 방식으로 큐를 운영할 때 초보 개발자가 가장 많이 저지르는 논리적 결함입니다. 생성자 태스크 내의 지역 변수로 선언된 구조체 주소를 큐에 넣고 태스크 루프가 돌아가 버리면, 소비자 태스크가 해당 주소를 읽어 내용물에 접근하는 시점에는 이미 그 지역 변수가 스택 메모리에서 소멸하여 엉뚱한 쓰레기 데이터나 다른 태스크의 메모리 영역을 침범하는 치명적인 포인터 오류가 발생합니다. 포인터 전송 방식을 쓸 때는 메모리의 생명 주기를 글로벌 영역이나 동적 힙 영역으로 엄격하게 격리 설계해야 합니다.

6. 결론

FreeRTOS의 메시지 큐 인터페이스는 단순한 데이터 저장소의 개념을 넘어, 멀티태스킹 환경에서 독립된 각 태스크가 서로 간섭하지 않고 독립성을 유지하며 유기적으로 협력할 수 있도록 결합도를 낮춰주는 소프트웨어 아키텍처의 핵심 컴포넌트입니다.

CMSIS-RTOS v2 추상화 계층을 활용하면 하드웨어 칩셋이나 하부 커널을 통째로 변경하는 상황이 오더라도 osMessageQueue 기반의 통신 소스코드를 그대로 가져갈 수 있어 프로젝트의 자산 가치가 대폭 향상됩니다. 오늘 정리해 드린 값 복사 메커니즘과 타임아웃 예외 처리 가이드를 바탕으로, 여러분이 진행 중인 프로젝트에 한층 더 견고하고 안전한 스레드 세이프 데이터 파이프라인을 구축해 보시기 바랍니다. 구현 도중 데이터 누락 현상이나 동기화 시점 꼬임 등 해결하기 어려운 기술적 난관이 있다면 언제든 댓글로 질문을 남겨주세요.

반응형