본문으로 건너뛰기
Embedded C++ for Real Systems · 19/41

동적 할당 없는 임베디드 C++ — placement new·정적 객체·풀

· Hawk · 4분 읽기

#한 줄 요약

“임베디드의 기본은 정적 할당입니다.” std::vector 대신 std::arrayetl::vector를 쓰고 heap 자체를 사용하지 않습니다.

#어떤 문제를 푸는가

임베디드에서 동적 할당은 세 가지 위험을 안고 갑니다.

  1. Heap fragmentation — 짧고 긴 alloc이 반복되면 큰 블록을 얻지 못합니다.
  2. 비결정성malloc의 시간이 예측 불가합니다. real-time 보장이 깨집니다.
  3. 메모리 부족 — 언제 부족해질지 알 수 없고 graceful fail이 어렵습니다.
// 위험
std::vector<Order> orders;
for (auto& order : input) {
orders.push_back(order); // heap alloc, 크기마다 reallocate
if (orders.capacity() > 1000) {
// 시점 불명, 메모리 부족 가능
}
}

대부분의 인증 환경(MISRA, AUTOSAR, DO-178C)이 동적 할당을 금지하거나 심하게 제한합니다. 허용한다 해도 시스템 초기화 시점에만 허용합니다.

이 글은 heap 없이 C++를 쓰는 패턴을 정리합니다.

#정적 컨테이너 표준 라이브러리

C++ 표준에서 <array>가 유일한 stack-only 컨테이너입니다. 나머지는 대부분 heap을 사용합니다.

#include <array>
std::array<int, 16> buffer; // stack, 64 byte
std::array<float, 256> sin_table; // 1024 byte
std::array<Order, 8> recent_orders; // sizeof(Order)*8
buffer[0] = 1;
auto size = buffer.size(); // 16, constexpr
auto begin = buffer.begin(); // iterator
// range-for
for (auto& x : buffer) { x = 0; }
// algorithm
std::fill(buffer.begin(), buffer.end(), 0);
std::sort(sin_table.begin(), sin_table.end());

std::array는 컴파일 타임 크기를 갖고 런타임에 크기를 바꿀 수 없습니다. 이것이 zero-cost의 비결입니다.

#ETL — heap 없는 STL 대체

Embedded Template Library (ETL)는 임베디드 친화 STL입니다. 모든 컨테이너가 고정 크기로 동작합니다.

#include <etl/vector.h>
#include <etl/string.h>
#include <etl/map.h>
etl::vector<int, 16> v; // 최대 16개
v.push_back(1);
v.push_back(2);
if (v.full()) { /* */ }
etl::string<32> s = "hello"; // 최대 32 char
s += " world";
etl::map<int, Order, 8> orders; // 최대 8 entry
orders.insert({1, Order{}});

핵심을 정리하면 이렇습니다.

  • 컨테이너 크기가 type의 일부이며 compile-time에 결정됩니다.
  • push_back 등이 실패할 수 있으므로 full()로 체크합니다.
  • internal storage가 stack 또는 .bss에 자리잡습니다.
  • <algorithm>, <iterator>와 모두 호환됩니다.

ETL은 MIT license에 header-only이며, 임베디드 표준 라이브러리에 가깝게 쓰입니다.

#std::pmr (C++17) — polymorphic allocator

표준 컨테이너에 사용자 정의 allocator를 붙이면 heap 없이도 표준 STL을 사용할 수 있습니다.

#include <memory_resource>
// stack buffer 위에 monotonic allocator
std::array<std::byte, 4096> buffer;
std::pmr::monotonic_buffer_resource pool(buffer.data(), buffer.size());
std::pmr::vector<int> v(&pool);
v.reserve(100);
for (int i = 0; i < 100; ++i) v.push_back(i);
// 모든 메모리는 stack buffer에서

자세한 내용은 Part 3-04: std::pmr 활용.

#Stack vs static vs heap

위치수명크기임베디드 적합
Stack함수 scope작음 (KB)단명 객체
Static (.bss/.data)프로그램 전체큼 (수십 KB)영구 객체
Heap동적매우 큼회피
void func() {
int local_arr[100]; // stack — 함수 종료시 회수
static int static_arr[100]; // .bss — 영구
auto* heap = new int[100]; // heap — 위험!
delete[] heap; // 명시 해제 필요
}

임베디드에서는 stack과 static을 우선합니다.

#임베디드 패턴 1 — fixed-size object pool

Part 3-03: Pool Allocator 미리보기.

template<typename T, size_t N>
class ObjectPool {
alignas(T) std::byte storage_[sizeof(T) * N];
std::bitset<N> in_use_;
public:
T* allocate() {
for (size_t i = 0; i < N; ++i) {
if (!in_use_[i]) {
in_use_.set(i);
return reinterpret_cast<T*>(&storage_[sizeof(T) * i]);
}
}
return nullptr;
}
void deallocate(T* p) {
if (!p) return;
size_t i = (reinterpret_cast<std::byte*>(p) - storage_) / sizeof(T);
if (i < N) in_use_.reset(i);
}
template<typename... Args>
T* construct(Args&&... args) {
T* p = allocate();
if (p) new (p) T(std::forward<Args>(args)...);
return p;
}
void destroy(T* p) {
if (p) {
p->~T();
deallocate(p);
}
}
};
ObjectPool<Order, 32> order_pool;
auto* o = order_pool.construct(/* args */);
// 사용
order_pool.destroy(o);

고정 크기 N개를 heap 없이 결정적 시간에 처리합니다.

#패턴 2 — placement new

이미 할당된 메모리 위에 객체만 생성합니다. new의 생성 부분만 쓰는 셈입니다.

alignas(Order) std::byte buffer[sizeof(Order)];
Order* o = new (buffer) Order(/* args */); // placement new
o->process();
o->~Order(); // 명시 destructor 호출

heap을 전혀 쓰지 않고 기존 메모리 위에 객체를 construct합니다.

#패턴 3 — std::optional (C++17)

객체의 유무를 heap 없이 표현합니다.

std::optional<Order> current_order; // sizeof(Order) + bool, on stack
void start() {
current_order = Order{/* args */}; // emplace
}
void process() {
if (current_order) {
current_order->execute();
}
}
void end() {
current_order.reset();
}

pointer나 heap 없이 “있을 수도, 없을 수도”를 표현합니다. 모든 메모리가 stack 또는 static에 자리잡습니다.

#패턴 4 — Static factory

생성 시점에 한 번만 할당하고 그 뒤로는 공유합니다.

class Logger {
public:
static Logger& instance() {
static Logger inst; // C++11 — thread-safe, 최초 호출 시 1번 생성
return inst;
}
private:
Logger() = default;
};
// 사용
Logger::instance().log("hello");

Construct-On-First-Use 패턴입니다. .bss에 자리만 잡아두고 최초 호출 시점에 생성합니다.

#std::vector 대신

// BAD — 동적
std::vector<Order> orders;
orders.push_back(o);
// GOOD — 정적
etl::vector<Order, 32> orders;
if (!orders.full()) orders.push_back(o);
// GOOD — pmr (메모리 풀에서)
std::pmr::vector<Order> orders(&order_pool);
// GOOD — std::array + 직접 size
std::array<Order, 32> orders;
size_t order_count = 0;
if (order_count < orders.size()) orders[order_count++] = o;

#std::string 대신

// BAD — 동적
std::string s = "hello";
// GOOD — 고정 크기
etl::string<32> s = "hello";
// GOOD — string_view (참조만)
std::string_view sv = "hello"; // const char* + size_t
// GOOD — std::array<char, N>
std::array<char, 32> s = {'h', 'e', 'l', 'l', 'o', '\0'};

string_view는 데이터를 소유하지 않고 기존 문자열을 가리키기만 합니다. 함수 매개변수에 가장 적합합니다.

#std::function 대신

// BAD — heap 가능
std::function<void(int)> callback;
callback = [data](int x) { /* */ }; // capture가 크면 heap
// GOOD — etl::delegate (fixed-size)
etl::delegate<void(int)> cb;
cb = etl::delegate<void(int)>::create<&MyClass::method>(my_obj);
// GOOD — 함수 포인터
void (*cb)(int) = my_function;
// GOOD — std::function with capture-less lambda
std::function<void(int)> cb = [](int x) { /* */ }; // capture 없으면 inline storage

etl::delegate은 고정 크기 internal storage를 사용하므로 heap을 쓰지 않습니다.

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

#1. std::vector 무심코 사용

표준 라이브러리의 대부분의 컨테이너는 heap을 사용합니다. 명시적으로 검사한 뒤에 써야 합니다.

#2. std::string concatenation

std::string s = "a";
s += "b"; // heap reallocate 가능

고정 크기 buffer와 snprintf 조합으로 대체합니다.

#3. static array에 큰 객체

static std::array<HugeStruct, 1000> arr; // 큰 .bss

.bss 크기가 폭증하고 RAM이 부족해집니다.

#4. Pool 고갈

auto* o = pool.allocate();
if (!o) { /* ? */ } // nullptr 처리 누락

항상 null 체크를 합니다.

#5. Destructor 누락

Order* o = new (buffer) Order;
return; // ~Order() 호출 안 함 — leak

placement new에는 명시적으로 destructor를 호출하거나 RAII로 감쌉니다.

#6. Recursive structure에 정적 할당

struct Node {
std::array<Node, 4> children; // 무한 재귀 — 컴파일 에러
};

pointer나 index를 활용해 풉니다.

#측정 — heap 사용 추적

malloc 호출 횟수와 크기를 런타임에 추적합니다.

extern "C" {
static size_t total_alloc = 0;
static size_t total_free = 0;
static size_t alloc_count = 0;
void* __wrap_malloc(size_t n) {
total_alloc += n;
alloc_count++;
return __real_malloc(n);
}
void __wrap_free(void* p) {
// 크기 추적 어려움 — newlib에 기록 필요
total_free++;
__real_free(p);
}
}

링커 옵션 -Wl,--wrap=malloc,--wrap=free로 모든 malloc 호출을 가로채면 예상치 못한 호출을 발견할 수 있습니다.

#Code size 비교

같은 기능을 동적과 정적 방식으로 비교합니다.

// V1 — std::vector (heap)
std::vector<int> v;
for (int i = 0; i < 100; ++i) v.push_back(i);
// V2 — etl::vector (static)
etl::vector<int, 128> v;
for (int i = 0; i < 100; ++i) v.push_back(i);
// V3 — std::array
std::array<int, 128> v;
for (int i = 0; i < 100; ++i) v[i] = i;

STM32F4 (newlib-nano, -Os):

V1 (std::vector): +6.2 KB (heap, vector, growth logic)
V2 (etl::vector): +0.4 KB
V3 (std::array): +0.2 KB

동적과 정적의 차이가 수 KB에 이릅니다. 임베디드에서는 결정적인 차이입니다.

#정리

  • 임베디드는 정적 할당이 원칙이며 heap은 회피하거나 제한적으로 사용합니다.
  • std::array가 유일한 표준 stack 컨테이너입니다.
  • ETL이 heap 없는 STL 대체이며 모든 컨테이너가 고정 크기입니다.
  • std::pmr(C++17)로 표준 컨테이너에 custom allocator를 붙여 pool을 활용합니다.
  • placement new로 명시적 위치에 객체를 생성합니다.
  • std::function 대신 etl::delegate나 capture-less lambda를 씁니다.

#관련 항목

#다음 글

Part 3-02: Custom Allocator 기초 — STL allocator interface를 구현해 제어된 메모리 위에서 표준 컨테이너를 사용합니다.

Embedded C++ for Real Systems · 20 of 41

  1. 1Embedded C++ for Real Systems — 임베디드 모던 C++ 시리즈 소개
  2. 2임베디드 C++ vs C — 런타임·코드 크기·ABI 관점 비교
  3. 3임베디드 C++ 컴파일러 플래그 분석 — -fno-rtti·-fno-exceptions·-Os
  4. 4임베디드 C++ 런타임 요구사항 — libstdc++·newlib·crt0 분석
  5. 5C++ 코드 크기 분석 — 가상 함수·템플릿·예외 비용 추적
  6. 6C++ ABI 호환성 — Itanium ABI·name mangling·vtable 레이아웃
  7. 7C++ 스타트업 코드 분석 — .init_array·전역 생성자 호출 순서
  8. 8임베디드 C++ 링커 스크립트 — vtable·정적 객체 배치 추적
  9. 9임베디드 C++ 표준 선택 가이드 — C++11/14/17/20/23 트레이드오프
  10. 10임베디드 RAII 기초 — 리소스 안전성과 결정적 소멸 보장
  11. 11임베디드 RAII 실전 패턴 — Lock·Pin·DMA·Power 관리
  12. 12constexpr 기초와 임베디드 적용 — 컴파일 타임 계산 활용
  13. 13constexpr 고급 활용 — 룩업 테이블·CRC·해시 컴파일 타임 생성
  14. 14consteval과 constinit 분석 — C++20 컴파일 타임 강제 메커니즘
  15. 15임베디드 Templates 기초 — 타입 안전과 코드 재사용 분석
  16. 16Template 비용 분석 — 코드 폭증·인스턴스화·디버그 정보 측정
  17. 17CRTP 패턴 분석 — vtable 없는 정적 다형성
  18. 18Type Traits 임베디드 활용 — SFINAE·is_pod·컴파일 타임 검사
  19. 19C++20 Concepts 활용 — 템플릿 제약과 가독성 개선
  20. 20동적 할당 없는 임베디드 C++ — placement new·정적 객체·풀
  21. 21Custom Allocator 기초 — std::allocator 인터페이스 분석
  22. 22Pool Allocator 구현 — Fixed-Size Block과 O(1) 보장
  23. 23std::pmr 임베디드 활용 — Polymorphic Memory Resource 분석
  24. 24No-Exception C++ 설계 — 코드 크기·결정성 트레이드오프
  25. 25임베디드 에러 처리 패턴 — Result·errno·optional 비교
  26. 26std::expected 분석 — C++23 결과 타입과 에러 전파
  27. 27No-RTTI C++ 설계 — dynamic_cast 제거와 정적 타입 분기
  28. 28임베디드 스마트 포인터 선택 — unique·shared·custom 비교
  29. 29임베디드 C++ 소유권 모델 — single·shared·borrow 패턴
  30. 30Intrusive Containers 분석 — 동적 할당 없는 컨테이너 설계
  31. 31ETL 라이브러리 분석 — Embedded Template Library의 STL 대체
  32. 32임베디드 Lock-free 기초 — atomic·memory ordering·CAS
  33. 33Lock-free Container 구현 — SPSC Queue·Ring Buffer
  34. 34Type-safe Flags 패턴 — Enum Class·Strong Typedef·Tag
  35. 35임베디드 State Machine 패턴 — Variant·Visitor·Table-driven 비교
  36. 36Compile-time FSM 구현 — 템플릿으로 상태 전이 검증
  37. 37Singleton 대안 패턴 — Service Locator·Static Init·Phantom
  38. 38MMIO Register 추상화 — 타입 안전한 비트 필드 접근
  39. 39GPIO 추상화 패턴 — Template·Concept으로 보드 독립성
  40. 40Peripheral 추상화 — UART·SPI·I2C 공통 인터페이스 설계
  41. 41임베디드 HAL 설계 패턴 — Static·Dynamic·Hybrid 비교