본문으로 건너뛰기
Practical RTOS Internals · 45/53

C++ in RTOS — RAII·std::thread·ETL·Coroutine

· Hawk · 10분 읽기

#한 줄 요약

“RTOS C++ = C API + RAII + 제한된 STL입니다.” — heap과 exception을 피하고 scope-based 자원 관리만으로도 안전성을 크게 높일 수 있습니다.

#어떤 문제를 푸는가

FreeRTOS, Zephyr, ThreadX 같은 RTOS의 공개 API는 C 함수입니다. mutex를 잡으면 반드시 풀고, queue handle을 만들면 반드시 해제해야 합니다. 짝을 빠뜨리면 deadlock 또는 자원 leak이 발생합니다.

C에서는 이 짝맞춤을 개발자가 매번 손으로 한다는 점이 가장 큰 위험입니다. 한 함수에 return path가 다섯 개라면 unlock도 다섯 곳에 적어야 하고, 새 path를 추가할 때 하나만 빠뜨려도 조용히 자원이 새기 시작합니다.

C++가 RTOS에 들어오는 첫 번째 가치는 RAII입니다. MutexGuard 같은 객체 하나만 도입해도, 함수가 어떻게 끝나든 소멸자가 unlock을 보장합니다. 두 번째 가치는 type-safe template입니다. xQueueCreate가 void pointer로 다루던 메시지가 StaticQueue<Cmd, 16>처럼 type을 보존한 채 안전하게 다뤄집니다.

다만 RTOS는 heap 사용 제한, 결정성 요구, code size 제약이라는 환경 안에서 동작합니다. 표준 C++가 가진 모든 기능을 그대로 쓸 수는 없고, 어떤 것을 쓰고 어떤 것을 피할지에 대한 판단이 필요합니다. 이번 편은 그 경계선을 정리합니다.

#RAII MutexGuard — 가장 작은 출발점

class MutexGuard {
public:
explicit MutexGuard(SemaphoreHandle_t mtx,
TickType_t timeout = portMAX_DELAY)
: mtx_(mtx),
locked_(xSemaphoreTake(mtx, timeout) == pdTRUE) {}
~MutexGuard() {
if (locked_) {
xSemaphoreGive(mtx_);
}
}
bool locked() const noexcept { return locked_; }
MutexGuard(const MutexGuard&) = delete;
MutexGuard& operator=(const MutexGuard&) = delete;
private:
SemaphoreHandle_t mtx_;
bool locked_;
};

-fno-exceptions 환경에서도 안전합니다. RAII는 exception unwinding에만 의존하는 메커니즘이 아니라 scope exit 시 소멸자 호출이 본질이기 때문입니다. return으로 빠져 나가든, break로 빠져 나가든, 마지막 }에 도달하든 소멸자는 호출됩니다.

void handle_command(void) {
MutexGuard lock(state_mtx_, pdMS_TO_TICKS(10));
if (!lock.locked()) {
log_timeout();
return; /* 자동 give 없음 — locked_ == false */
}
if (state_ == State::Idle) {
return; /* 자동 give */
}
process_state(state_);
/* 자동 give */
}

복사를 = delete로 막은 점이 중요합니다. 복사가 허용되면 같은 mutex가 두 번 give되어 카운트가 깨집니다.

#ScopedIRQDisable — Critical Section RAII

ISR과 데이터를 공유하는 짧은 critical section도 같은 패턴으로 묶습니다.

class ScopedIRQDisable {
public:
ScopedIRQDisable() noexcept : primask_(__get_PRIMASK()) {
__disable_irq();
}
~ScopedIRQDisable() noexcept {
__set_PRIMASK(primask_);
}
ScopedIRQDisable(const ScopedIRQDisable&) = delete;
ScopedIRQDisable& operator=(const ScopedIRQDisable&) = delete;
private:
uint32_t primask_;
};
void update_shared(void) {
ScopedIRQDisable irq_off;
counter_++;
if (counter_ > kMax) {
counter_ = 0;
flag_ = true;
}
/* 자동 enable */
}

진입 시점의 PRIMASK를 저장했다가 복원하므로 이미 disabled인 nested context에서도 안전합니다. 자세한 RAII 일반론은 Embedded C++ 2-01에서 다룹니다.

#std::lock_guard와 호환되는 Mutex Wrapper

RAII guard를 직접 만들지 않고 표준 std::lock_guard를 그대로 쓰는 방법이 있습니다. 직접 만들어야 할 것은 BasicLockable 컨셉을 만족하는 mutex 클래스뿐입니다.

class Mutex {
public:
Mutex() : mtx_(xSemaphoreCreateMutex()) {
configASSERT(mtx_ != nullptr);
}
~Mutex() {
vSemaphoreDelete(mtx_);
}
void lock() { xSemaphoreTake(mtx_, portMAX_DELAY); }
bool try_lock() { return xSemaphoreTake(mtx_, 0) == pdTRUE; }
void unlock() { xSemaphoreGive(mtx_); }
Mutex(const Mutex&) = delete;
Mutex& operator=(const Mutex&) = delete;
private:
SemaphoreHandle_t mtx_;
};
/* 사용 — STL guard를 그대로 활용 */
Mutex state_mtx;
void task(void) {
std::lock_guard<Mutex> lock(state_mtx);
do_work();
}

이 wrapper의 진짜 가치는 코드가 표준 C++ 관용구로 표현된다는 점입니다. 새 팀원이 와도 std::lock_guard라는 익숙한 RAII 도구를 그대로 읽으면 됩니다. 내부가 FreeRTOS인지 Zephyr인지는 별로 중요하지 않게 됩니다.

#std::thread vs xTaskCreate — 결정성의 차이

std::thread는 표준 C++ thread API이지만, 임베디드 RTOS에서 그대로 쓰기에는 잘 맞지 않습니다. 이유 셋입니다.

첫째, std::thread의 구현은 보통 pthread 위에 얹혀 있습니다. RTOS에 pthread layer를 추가해야 동작하고, 그 layer 자체가 heap을 쓰고 control block 크기가 커지는 경향이 있습니다.

둘째, stack 크기와 priority를 생성 시점에 명시적으로 지정할 수 없습니다. 표준 std::thread의 생성자는 entry function과 인자만 받습니다. priority가 모두 같고 stack 크기를 컴파일러 default에 맡기는 형태가 됩니다. 임베디드에서는 priority와 stack 크기가 곧 시스템 설계인데 이것을 잃게 됩니다.

셋째, std::thread 객체가 RAII로 자기 thread를 join하거나 detach하려고 합니다. 임베디드 task는 보통 영원히 도는 무한 루프인데 std::thread의 소멸자가 호출되면 std::terminate가 호출됩니다.

결정적인 시스템에서는 xTaskCreate 또는 k_thread_create를 명시적으로 호출하는 편이 정직합니다.

class TaskBase {
public:
TaskBase(const char *name, void (*entry)(void*), void *arg,
configSTACK_DEPTH_TYPE stack_words, UBaseType_t prio) {
BaseType_t r = xTaskCreate(entry, name, stack_words, arg, prio, &handle_);
configASSERT(r == pdPASS);
}
~TaskBase() {
if (handle_ != nullptr) {
vTaskDelete(handle_);
}
}
TaskHandle_t handle() const { return handle_; }
TaskBase(const TaskBase&) = delete;
TaskBase& operator=(const TaskBase&) = delete;
private:
TaskHandle_t handle_ = nullptr;
};

std::thread 인터페이스를 강제로 흉내내기보다 RTOS API의 진짜 모양을 C++에 노출하는 wrapper가 사용성과 결정성을 모두 살립니다.

#Static Queue Template — Type Safety + No Heap

xQueueCreate는 void pointer 기반이라 송신과 수신에서 타입을 직접 맞춰야 합니다. template으로 감싸면 컴파일러가 검사해 줍니다.

template <typename T, size_t N>
class StaticQueue {
public:
StaticQueue() {
handle_ = xQueueCreateStatic(N, sizeof(T), storage_, &buf_);
configASSERT(handle_ != nullptr);
}
bool push(const T& v, TickType_t timeout = portMAX_DELAY) {
return xQueueSend(handle_, &v, timeout) == pdTRUE;
}
bool pop(T& v, TickType_t timeout = portMAX_DELAY) {
return xQueueReceive(handle_, &v, timeout) == pdTRUE;
}
StaticQueue(const StaticQueue&) = delete;
StaticQueue& operator=(const StaticQueue&) = delete;
private:
StaticQueue_t buf_;
uint8_t storage_[N * sizeof(T)] __attribute__((aligned(alignof(T))));
QueueHandle_t handle_;
};
struct Command { uint16_t op; uint16_t arg; };
StaticQueue<Command, 16> cmd_q;
void producer(void) {
cmd_q.push(Command{0x01, 0x42});
}
void consumer(void) {
Command c;
if (cmd_q.pop(c, pdMS_TO_TICKS(100))) {
handle(c);
}
}

heap이 전혀 쓰이지 않습니다. storage가 클래스 멤버이고 정렬도 type에 맞춰 자동으로 잡힙니다.

#ETL — Embedded Template Library

std::vector, std::string, std::map은 거의 모든 RTOS 환경에서 heap을 동적으로 사용합니다. 그 결과 fragmentation이 누적되고 WCET 분석이 깨집니다.

ETL(Embedded Template Library, MIT license)은 STL과 인터페이스가 비슷하지만 모두 fixed-capacity, no heap, no exception인 컨테이너 모음입니다.

#include <etl/vector.h>
#include <etl/queue.h>
#include <etl/string.h>
#include <etl/map.h>
etl::vector<int, 100> v; /* 최대 100, 내부 storage */
etl::queue<Command, 16> q;
etl::string<32> s = "hello";
etl::map<uint8_t, Sensor*, 8> sensors; /* key 최대 8개 */
v.push_back(42);
if (v.size() >= v.capacity()) {
/* heap 확장 없음, 호출자가 결정 */
}

API가 STL과 매우 닮아 있어 기존 C++ 코드의 사고방식을 그대로 가져올 수 있습니다. 결정적으로, 동작은 전부 stack 또는 static입니다. 자세한 ETL 활용은 Embedded C++ 4-02에서 다룹니다.

#컴파일러 플래그 — RTTI와 Exception

Terminal window
arm-none-eabi-g++ -std=c++20 -O2 \
-fno-rtti \
-fno-exceptions \
-fno-threadsafe-statics

세 플래그가 RTOS C++의 표준 조합입니다.

-fno-rttidynamic_casttypeid를 제거합니다. virtual class마다 따라붙던 RTTI 메타데이터가 사라져 코드 크기 ~10% 절약결정성 개선을 얻습니다.

-fno-exceptions는 throw/try/catch를 제거합니다. exception unwinding table이 사라져 추가 1020% 코드 절약WCET 분석 가능성을 얻습니다. 단, 표준 라이브러리 일부 함수가 exception throw로 실패를 보고하므로 (std::vector::at, std::stoi) 그런 API는 피하거나 대체합니다.

-fno-threadsafe-statics는 함수 내 static 객체 초기화의 thread-safe wrapper(__cxa_guard_acquire)를 제거합니다. RTOS task가 한 함수의 첫 호출에서 경쟁할 가능성이 없거나 직접 초기화 시점을 통제한다면 안전합니다.

#std::atomic — Cortex-M에서의 동작

#include <atomic>
std::atomic<int> counter{0};
void isr_handler(void) {
counter.fetch_add(1, std::memory_order_relaxed);
}
void task(void) {
int v = counter.load(std::memory_order_acquire);
process(v);
}

Cortex-M3 이상은 LDREX/STREX 명령으로 lock-free atomic을 hardware로 지원합니다. C++ 표준 std::atomic<T>T가 word 크기(32-bit)이면 lock-free입니다.

std::atomic<int64_t>처럼 word를 넘는 type은 32-bit 시스템에서 lock-based가 됩니다. ARMv7-M은 LDREXD/STREXD로 64-bit lock-free를 지원하지만, 컴파일러가 자동으로 이 명령을 emit하는지는 옵션에 달려 있습니다. is_lock_free()를 컴파일 타임에 확인합니다.

#C++20 Coroutine — RTOS 위의 Async

C++20 coroutine은 stackless 비동기 단위입니다. RTOS task 위에서 여러 async 흐름을 표현할 때 유용합니다.

#include <coroutine>
struct Task {
struct promise_type {
Task get_return_object() { return {}; }
std::suspend_never initial_suspend() noexcept { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() {}
};
};
struct Delay {
TickType_t ticks;
bool await_ready() const noexcept { return false; }
void await_suspend(std::coroutine_handle<> h) const {
schedule_resume_after(h, ticks); /* RTOS timer로 resume */
}
void await_resume() const noexcept {}
};
Task blink_task(GPIO_TypeDef *port, uint16_t pin) {
while (true) {
port->BSRR = pin;
co_await Delay{pdMS_TO_TICKS(500)};
port->BSRR = (uint32_t)pin << 16;
co_await Delay{pdMS_TO_TICKS(500)};
}
}

coroutine frame은 컴파일러가 생성한 작은 구조체이며 한 task의 stack과는 별도로 promise type이 지정한 allocator에서 할당됩니다. RTOS에서는 pool allocator를 promise에 묶어 heap fragmentation을 피하는 패턴이 일반적입니다.

핵심은 한 task에서 여러 coroutine을 cooperative하게 돌릴 수 있다는 점입니다. 한 task 안의 여러 상태 머신을 별도 sub-task로 만들지 않아도 됩니다.

#Virtual Function의 비용

virtual function 호출은 vtable lookup → indirect call로 평범한 함수 호출보다 약간 비쌉니다.

Cortex-M4 @ 168 MHz, hot cache

호출 종류Cycle
direct call2
virtual call5 ~ 7
cold cache30+ (vtable miss)

ISR 진입 직후 호출되는 hot path라면 concrete type을 직접 호출하거나 static polymorphism(CRTP) 으로 대체하는 편이 결정성에 좋습니다. CRTP 패턴은 Embedded C++ 2-08에서 자세히 다룹니다.

template <typename Derived>
class SensorBase {
public:
void sample() {
static_cast<Derived*>(this)->read_impl(); /* compile-time bind */
}
};
class Imu : public SensorBase<Imu> {
public:
void read_impl() { /* MMIO read */ }
};

vtable이 사라지므로 직접 call로 inlining되고 RTTI 메타데이터도 필요 없습니다.

#자주 보는 함정과 안티패턴

경고 — heap-backed STL을 RTOS에서 그대로 사용

std::vector<Cmd> queue; /* heap, fragmentation */
queue.push_back(c);

장시간 동작 후 fragmentation으로 malloc 실패가 발생할 수 있습니다. etl::vector<Cmd, N> 또는 StaticQueue<Cmd, N>로 대체합니다.

경고 — 소멸자에서 예외

~UartGuard() {
if (deinit() < 0) throw std::runtime_error("...");
}

소멸자에서 예외를 던지면 stack unwinding 중 std::terminate가 호출됩니다. -fno-exceptions에서도 abort로 이어지므로 소멸자는 항상 noexcept이고 실패는 조용히 처리하거나 로깅합니다.

경고 — Static initialization order fiasco

sensors.cpp
Sensor g_sensor;
/* logger.cpp */
extern Sensor g_sensor;
Logger g_logger(g_sensor); /* g_sensor 초기화 전일 수 있음 */

translation unit 사이의 전역 객체 초기화 순서는 보장되지 않습니다. construct-on-first-use idiom을 사용합니다.

Sensor& sensor() {
static Sensor s; /* 첫 호출 시 1회 초기화 */
return s;
}

-fno-threadsafe-statics를 쓰는 경우 첫 호출이 단일 task에서만 일어남을 설계자가 보장해야 합니다.

경고 — ISR에서 heap allocation

void TIM2_IRQHandler(void) {
auto evt = std::make_unique<Event>(...); /* malloc in ISR */
queue.push(std::move(evt));
}

malloc이 spinlock을 잡는 구현이라면 ISR 안에서 hang할 수 있고, 그렇지 않더라도 WCET 분석이 깨집니다. ISR이 쓰는 객체는 static 또는 pool에서 미리 확보합니다.

경고 — 거대한 template 인스턴스화

StaticQueue<HugeStruct, 16384> q;

같은 template이 여러 type에 대해 인스턴스화되면 code bloat가 누적됩니다. 공통 로직은 non-template base class로 빼고 template은 얇은 wrapper로 두는 패턴이 안전합니다.

#RAII Overhead 측정

같은 mutex critical section을 C 수동 코드와 C++ RAII로 비교합니다(ARM Cortex-M4, -O2, FreeRTOS).

# C 수동
shared:
push {r4, lr}
bl xSemaphoreTake
ldr r3, [counter]
adds r3, r3, #1
str r3, [counter]
bl xSemaphoreGive
pop {r4, pc}
# 24 bytes
# C++ RAII (MutexGuard)
shared:
push {r4, lr}
bl xSemaphoreTake
ldr r3, [counter]
adds r3, r3, #1
str r3, [counter]
bl xSemaphoreGive
pop {r4, pc}
# 24 bytes — 동일

생성자와 소멸자가 모두 inlining되어 overhead가 0입니다. 전형적인 zero-cost abstraction입니다.

#MISRA C++ / AUTOSAR C++14 — 안전 표준

MISRA C++ 2008 / 2023

  • exception 사용 제한
  • dynamic dispatch 제한
  • template metaprogramming 제한

AUTOSAR C++14 Coding Guidelines

  • 현대 C++ 일부 허용 (constexpr, auto, lambda)
  • 자동차 safety-critical에 적합

JSF C++ (Lockheed Martin F-35) — 가장 보수적, F-35 비행 소프트웨어용.

이런 표준은 ETL과 잘 어울립니다. heap, exception, dynamic dispatch가 모두 제거된 상태에서 RAII와 template으로만 안전성을 표현하므로 분석 가능성과 결정성을 동시에 얻습니다. 자세한 소유권 모델은 Embedded C++ 3-10에서 다룹니다.

#정리

  • RTOS C++의 출발점은 RAII로 C API의 짝맞춤을 자동화하는 것이며, MutexGuardScopedIRQDisable이 가장 작은 시작점입니다.
  • 표준 std::lock_guard를 그대로 쓰려면 BasicLockable 컨셉만 만족하는 Mutex wrapper를 만들면 됩니다.
  • std::thread는 pthread layer, stack/priority 표현 부족, 소멸자 동작 차이 때문에 임베디드 RTOS에서 그대로 쓰기에 부적합합니다. xTaskCreate를 명시적으로 호출하는 thin wrapper가 정직합니다.
  • StaticQueue<T, N> 같은 template은 type safety와 no-heap을 동시에 제공합니다.
  • 표준 STL container는 heap을 쓰므로 ETL의 fixed-capacity container로 대체합니다.
  • RTOS 빌드의 표준 컴파일러 옵션은 -fno-rtti -fno-exceptions -fno-threadsafe-statics입니다.
  • std::atomic은 word 크기 type에 대해 Cortex-M3+에서 lock-free이며, ISR과 task 사이 카운터에 자연스럽게 쓰입니다.
  • C++20 coroutine은 한 task 안의 여러 async 흐름을 stackless로 표현하는 도구로 활용 가치가 큽니다.
  • virtual function은 hot path에서 측정 가능한 비용이 있으며, CRTP 같은 static polymorphism으로 대체 가능합니다.
  • 소멸자 예외, static initialization order, ISR 안 heap allocation이 가장 자주 보는 함정입니다.

다음 part는 Part 5에서 RTOS porting과 시스템 통합 사례를 다룹니다.

#관련 항목

Practical RTOS Internals · 46 of 53

  1. 1Practical RTOS Internals — 실시간 커널 내부 분석 시리즈 소개
  2. 2RTOS가 필요한 이유 — 일반 OS와의 결정적 차이
  3. 3Task와 Thread 개념 — TCB·상태 머신·생명 주기 분석
  4. 4실시간 스케줄링 알고리즘 비교 — RR·Priority·EDF·RMS
  5. 5Preemption과 Cooperation — 강제 전환 vs 자발 양보
  6. 6인터럽트와 RTOS — ISR Context·Deferred Processing·FromISR API
  7. 7동기화 기초 분석 — Critical Section·Mutual Exclusion·Race Condition
  8. 8Semaphore 개념 분해 — Counting·Binary·P/V 연산
  9. 9Mutex 개념 분해 — Ownership·Recursive·Priority Inheritance
  10. 10큐와 메시지 패싱 — Producer-Consumer·Ring Buffer·전달 의미
  11. 11실시간성 분석 — Latency·Jitter·Deadline·WCET·RMA
  12. 12Ready List 자료구조 분석 — Linked List·Bitmap·O(1) Scheduler
  13. 13Blocked List 자료구조 — Timeout 정렬·Delta List·Two-List Scheme
  14. 14Scheduler 알고리즘 구현 추적 — Next-Task Selection 로직
  15. 15Context Switch 원리 분석 — 레지스터 저장·복원·Stack Frame
  16. 16ARM Cortex-M Context Switch — PendSV·MSP/PSP 어셈블리 추적
  17. 17ARM Cortex-A Context Switch — Mode 전환·SVC·Banked Registers
  18. 18RISC-V Context Switch 분석 — ECALL·mret·CSR
  19. 19RTOS Tick과 타이머 — SysTick·Generic Timer·configTICK_RATE_HZ
  20. 20Tickless 모드 구현 — Idle Tick Suppression·Sleep·Wake 보정
  21. 21Scheduler Latency 측정 기법 — GPIO Toggle·DWT·ftrace·cyclictest
  22. 22RTOS Tracing과 Observability — Tracealyzer·SystemView·ITM/ETM
  23. 23Critical Section 구현 비교 — IRQ Disable·BASEPRI·Spinlock
  24. 24Semaphore 내부 구현 추적 — Counter·Wait List·ISR-Safe Variant
  25. 25Mutex 내부 구현 추적 — Owner·Recursion Count·ISR 금지
  26. 26Priority Inversion 문제 — Mars Pathfinder 사례·Bounded vs Unbounded
  27. 27Priority Inheritance 구현 — Inherit·Disinherit·Chain
  28. 28Priority Ceiling Protocol — Immediate vs Original 비교
  29. 29Queue 내부 구현 추적 — Ring Buffer·2 Wait Lists·Atomic Send/Receive
  30. 30Event Group 분석 — Bit Flag·AND/OR Wait·Sync Barrier
  31. 31ISR-Safe API 설계 — FromISR 패턴·Higher Priority Wake·Deferred Work
  32. 32Deadlock 분석 — 4 조건·Wait-for Graph·Lock Ordering·Timeout
  33. 33Stream Buffer와 Message Buffer — FreeRTOS 10의 Lock-Free SPSC
  34. 34실시간 메모리 요구사항 — Determinism·Fragmentation·WCET
  35. 35FreeRTOS Heap_1~5 분석 — 5종 Allocator의 구조와 트레이드오프
  36. 36TLSF Allocator 분석 — Two-Level Segregated Fit O(1)
  37. 37Static Allocation — 컴파일 타임으로 동적 위험 제거하기
  38. 38Memory Pool — Fixed-Size Block Allocator의 단순함과 강력함
  39. 39Stack Overflow 탐지 — Canary·MPU·Watermark 3중 방어
  40. 40SMP RTOS 설계 — Ready List·Affinity·IPI·Load Balancing
  41. 41SMP Spinlock 구현 — LDREX/STREX·Ticket Lock·MCS·WFE/SEV
  42. 42Software Timer 분석 — Daemon Task·자료구조·ISR-Safe API
  43. 43RTOS System Call — SVC·ECALL·User/Kernel 분리·FreeRTOS-MPU
  44. 44TrustZone과 TF-M — Secure/Non-Secure·NSC Veneer·PSA
  45. 45AMP와 OpenAMP — Heterogeneous SoC·RPMsg·remoteproc
  46. 46C++ in RTOS — RAII·std::thread·ETL·Coroutine
  47. 47FreeRTOS 소스 분석 — tasks.c·queue.c·port.c 추적
  48. 48Zephyr 커널 분석 — k_thread·k_sem·Driver Model
  49. 49RT-Thread 분석 — Object 모델·Components·Smart·Studio
  50. 50RTOS 포팅 가이드 — 새 아키텍처에 옮기는 절차
  51. 51RTOS 선택 가이드 — Footprint·License·Certification·Ecosystem
  52. 52Apache NuttX 분석 — POSIX·PX4·NASA Ingenuity
  53. 53PREEMPT_RT Linux — Mainline 6.12·Xenomai 4·EVL