스마트 팩토리, 웨어러블 디바이스, 홈 오토메이션 등 자율적으로 데이터를 수집하고 통신해야 하는 최신 IoT 기기 개발 환경에서 멀티태스킹 제어는 필수적인 요소로 자리 잡았습니다. 과거의 단순한 무한 루프(Super Loop) 구조로는 센서 값을 읽는 와중에 UART 통신 패킷을 수신하거나 OLED 화면을 실시간으로 갱신할 때 발생하는 지연 시간(Latency) 문제를 해결하기 어렵습니다. 특정 하드웨어 동작이 끝날 때까지 CPU가 아무것도 하지 못하고 대기하는 블로킹 현상이 발생하기 때문입니다.
이러한 타이밍 병목을 해결하고 시스템의 가용성을 극대화하기 위해 펌웨어 설계자들은 STM32와 같은 ARM Cortex-M 기반 MCU 환경에 실시간 운영체제(RTOS)를 탑재합니다. 이번 글에서는 글로벌 하드웨어 추상화 표준인 CMSIS-RTOS v2 API를 기준으로 BME280 온습도 센서 데이터를 안전하게 수집하고, 메시지 큐(Message Queue)와 뮤텍스(Mutex)를 활용하여 OLED 출력 및 UART 전송 간의 자원 경합을 방지하는 실전 멀티태스크 애플리케이션 구현 방법을 다루어 보겠습니다.
핵심 요약 3줄
- 기능별 태스크 분리: 시스템의 연산 지연을 방지하기 위해 센서 수집, OLED 디스플레이 표출, UART 통신 루틴을 각각 독립적인 실행 스레드로 쪼개어 스케줄러의 제어를 받도록 설계합니다.
- 메시지 큐 데이터 전송: 전역 변수 참조로 인해 발생할 수 있는 데이터 오염을 방지하기 위해, 커널이 관리하는 스레드 세이프(Thread-safe)한 메시지 큐 구조로 센서 데이터를 소비 태스크에 안전하게 전달합니다.
- 뮤텍스 자원 보호: 다중 태스크가 동일한 I2C 또는 SPI 버스 컨트롤러에 접근하여 하드웨어 제어권이 충돌하는 현상을 뮤텍스 자원 잠금 메커니즘으로 원천 차단합니다.
1. 실전 프로젝트 설계: 다중 태스크 및 자원 토폴로지
효율적이고 견고한 IoT 시스템 구축을 위해 전체 펌웨어 기능을 3개의 독립적인 태스크로 분리하고, 커널 오브젝트들을 동원하여 자원을 안전하게 관리하는 아키텍처를 수립해야 합니다.
| 할당 태스크 명칭 | 할당 우선순위 (Priority) | 주된 역할 및 하드웨어 자원 접근 제어 방식 |
| Sensor Task | osPriorityHigh (높음) | BME280 센서 레지스터를 1초 주기(osDelay)로 폴링하여 원시 데이터를 파싱한 뒤, 커널 메시지 큐에 밀어 넣습니다. |
| Display Task | osPriorityNormal (보통) | 메시지 큐에 새로운 데이터 팩이 들어올 때까지 Blocked 상태로 대기하다가, 신호 수신 시 뮤텍스를 획득하여 I2C OLED에 수치를 갱신합니다. |
| Comm Task | osPriorityNormal (보통) | 디스플레이 태스크와 동등한 순위에서 큐 데이터를 관찰하며, 수집된 온도와 습도 텍스트 스트링을 PC의 시리얼 터미널로 전송(HAL_UART_Transmit)합니다. |
중요 설계 체크포인트: 하나의 메시지 큐를 두 개 이상의 태스크가 동시에 Get(수신)하도록 단순 매핑하면, 스케줄러 슬롯을 먼저 잡은 한쪽 태스크만 데이터를 가져가고 다른 한쪽은 데이터 굶주림(Starvation) 상태에 빠집니다. 따라서 본 실무 가이드라인에서는 하나의 수집 구조체 원본 데이터를 두 개의 전용 큐(displayQueue, commQueue)로 각각 복사하여 브로드캐스트하는 안전한 다중 복사 아키텍처를 기준으로 코드를 전개합니다.
2. 핵심 소스코드 구현 (CMSIS-RTOS v2)
STM32CubeMX에서 FreeRTOS 미들웨어를 CMSIS_V2 인터페이스 옵션으로 활성화했을 때 작성되는 표준 코드 구조입니다.
2.1 커널 오브젝트 정의 및 초기화 영역
#include "cmsis_os2.h"
#include "bme280.h"
#include "ssd1306.h"
#include <stdio.h>
// 태스크 간 교환될 센서 데이터 데이터 타입 구조체 정의
typedef struct {
float temperature;
float humidity;
} SensorData_t;
// CMSIS-RTOS v2 전용 커널 오브젝트 ID 핸들 변수
osMessageQueueId_t displayQueueHandle;
osMessageQueueId_t commQueueHandle;
osMutexId_t i2cBusMutexHandle;
void MX_RTOS_Init(void) {
// 1. 메시지 큐 생성: 최대 5개의 센서 데이터 구조체 인스턴스를 보관할 수 있는 공간 확보
displayQueueHandle = osMessageQueueNew(5, sizeof(SensorData_t), NULL);
commQueueHandle = osMessageQueueNew(5, sizeof(SensorData_t), NULL);
// 2. 뮤텍스 생성: I2C 버스 제어권을 독점하기 위한 상호 배제 객체 생성 (기본 속성 적용)
i2cBusMutexHandle = osMutexNew(NULL);
}
2.2 센서 데이터 수집 태스크 (생성자 스레드)
void StartSensorTask(void *argument) {
SensorData_t rawData;
for (;;) {
// 드라이버 API를 통한 물리 센서 레지스터 스캔
rawData.temperature = BME280_ReadTemperature();
rawData.humidity = BME280_ReadHumidity();
// 소비 태스크들이 각각 독립적으로 소비할 수 있도록 두 개 전용 큐에 데이터 삽입
// 파라미터: 큐 ID, 데이터 주소, 우선순위(0), 타임아웃(0: 비차단 모드 즉시 복사)
osMessageQueuePut(displayQueueHandle, &rawData, 0, 0);
osMessageQueuePut(commQueueHandle, &rawData, 0, 0);
// 1000 시스템 틱 동안 블로킹되어 타 태스크에 CPU 자원 양보 (1초 주기 연산)
osDelay(1000);
}
}
2.3 OLED 디스플레이 제어 및 상호 배제 태스크
void StartDisplayTask(void *argument) {
SensorData_t receivedData;
char displayBuffer[32];
for (;;) {
// 전용 큐에 센서 데이터가 쌓일 때까지 제한 없이 대기 상태를 유지함
if (osMessageQueueGet(displayQueueHandle, &receivedData, NULL, osWaitForever) == osOK) {
// 다중 태스크 환경에서 I2C 링커 내부 레지스터가 꼬이지 않도록 뮤텍스 락 획득
if (osMutexAcquire(i2cBusMutexHandle, osWaitForever) == osOK) {
sprintf(displayBuffer, "Temp: %.1f C", receivedData.temperature);
SSD1306_GotoXY(0, 0);
SSD1306_Puts(displayBuffer, &Font_11x18, 1);
SSD1306_UpdateScreen();
// I2C 버스 제어 시퀀스가 안전하게 종료되었으므로 소유권 반환
osMutexRelease(i2cBusMutexHandle);
}
}
}
}
3. 개발을 위한 실무 팁
- 정적 할당 기능의 우선 고려: osMessageQueueNew 함수나 osMutexNew 함수를 사용하면 내부적으로 커널 동적 힙 영역(heap_4.c 등)에서 구조체 메모리를 잘라옵니다. 장시간 구동해야 하는 IoT 양산 제품군에서는 미세한 메모리 파편화로 인해 구동 후 수개월 뒤 시스템이 멈추는 리스크가 있으므로, 빌드 단계에서 고정 배열 영역을 할당하는 osMessageQueueNewStatic 계열 API를 도입하여 메모리 예측 가능성을 확보하는 편이 좋습니다.
- 비차단 큐 삽입 옵션의 활용: 데이터 생성 속도가 소비 속도보다 순간적으로 빨라질 수 있는 센서 인터럽트 핸들러(ISR) 영역이나 고속 루프 내부에서는 osMessageQueuePut 호출 시 타임아웃 인자를 반드시 0으로 지정해 주어야 합니다. 큐가 가득 찼다고 해서 태스크가 대기 상태로 들어가게 되면 인터럽트 서비스 루틴이 멈추거나 센서 샘플링 주기가 깨져 오작동의 도미노 현상이 일어납니다.
4. 흔히 하는 실수
- 문자열 포매팅 함수 사용 시 스택 오버플로우: 본문 코드에서 사용한 sprintf 함수나 printf 계열 함수는 내부 임시 버퍼 소모량이 상당하여 일반적인 가벼운 연산 함수보다 수배 이상의 런타임 스택(Stack) 공간을 강제 점유합니다. STM32CubeMX 기본 설정인 128워드(512바이트) 스택 크기 상태에서 sprintf를 남발하면 높은 확률로 스택 바운더리를 침범하여 시스템이 HardFault_Handler로 진입해 멈춰버립니다. 이러한 포매팅 함수를 사용하는 태스크는 초기 설계 시 할당 스택을 최소 256워드(1024바이트) 이상으로 여유 있게 증설해 두어야 안전합니다.
- 뮤텍스 미반환으로 인한 시스템 교착 상태(Deadlock): 디스플레이 태스크 내부에서 osMutexAcquire를 정상 수행하여 버스 소유권을 획득한 뒤, 아래쪽 UI 조건 연산 분기문 내부에서 예외가 발생하여 osMutexRelease 코드를 건너뛰고 루프를 빠져나가는 실수를 간혹 범하게 됩니다. 뮤텍스를 소유한 태스크가 자원을 놓아주지 않으면, 동일한 뮤텍스를 획득해야 하는 통신 태스크나 타 제어 태스크들이 무한 대기 상태에 빠져 시스템 전체가 먹통이 되는 교착 상태를 유발하므로 모든 실행 분기점 출구에 릴리즈 코드가 포함되었는지 확인해야 합니다.
5. 결론
STM32 MCU 디바이스 환경에서 FreeRTOS와 CMSIS-RTOS v2 규격을 결합한 멀티태스킹 아키텍처는 가독성 높은 소스코드 제어와 견고한 타이밍 설계를 구현하기 위한 표준 프레임워크입니다.
기능 단위별로 독립적인 스레드를 구축하고 이들 사이의 연산 시퀀스를 커널이 보증하는 메시지 큐와 상호 배제 뮤텍스로 묶어 관리하면, 하드웨어 타이밍이 불규칙하게 요동치는 IoT 통신 환경에서도 무결한 데이터 처리를 보장할 수 있습니다. 오늘 공유해 드린 멀티 큐 분할 매핑 아키텍처와 스택 공간 마진 확보 팁을 참고하여 유연하고 안정적인 임베디드 펌웨어를 완성해 보시기 바랍니다. STM32 포팅 단계에서 큐 핸들이 NULL을 반환하는 메모리 고갈 문제를 겪고 계시거나 주변 장치 드라이버 인터페이스가 꼬이는 현상이 발생했다면 언제든 댓글로 구체적인 현상을 공유해 주세요.
'Firmware & RTOS > FreeRTOS & Real-time Scheduling' 카테고리의 다른 글
| CMSIS-RTOS v1 vs v2 차이점 완벽 정리: FreeRTOS 마이그레이션 가이드 (0) | 2025.01.18 |
|---|---|
| FreeRTOS 디버깅 가이드: 스택 오버플로우 및 메모리 부족 해결 방법 (0) | 2025.01.18 |
| FreeRTOS 이벤트 그룹(Event Group) 완벽 가이드: 다중 태스크 동기화 (CMSIS-RTOS v2) (0) | 2025.01.17 |
| FreeRTOS 저전력 최적화 가이드: Idle Task와 Tickless Idle 모드 활용법 (0) | 2025.01.16 |
| FreeRTOS 소프트웨어 타이머 완벽 가이드: osTimerNew 사용법 및 예제 (CMSIS-RTOS v2) (0) | 2025.01.15 |