임베디드 C++ vs C — 런타임·코드 크기·ABI 관점 비교
#한 줄 요약
“Modern C++는 추상화 비용을 거의 0으로 만든다.” 단, 그 zero가 성립하는 전제 조건들을 알아야 합니다.
#어떤 문제를 푸는가
새 임베디드 프로젝트의 언어를 정할 때 가장 먼저 부딪히는 질문입니다. “C로 갈까 C++로 갈까.”
C++를 택하면 다음 두려움이 따라옵니다.
- 코드 크기가 폭증할 것 같습니다
- 런타임 비용을 가늠하기 어렵습니다
- 디버깅이 더 까다로워질 것 같습니다
- 인증(MISRA, DO-178C)에서 막힐 것 같습니다
이 두려움 중 일부는 1990년대 C++의 잔영입니다. Modern C++(11/14/17/20)는 측정 가능한 zero-cost abstraction을 약속합니다. 다만 모든 기능이 그렇지는 않습니다. 어느 기능이 무료이고 어느 기능이 유료인지를 정확히 아는 것이 핵심입니다.
#C++가 C에 더하는 것
C++는 C의 superset에 가깝지만 동일하지는 않습니다. C++가 더하는 도구는 크게 네 가지입니다.
| 도구 | 컴파일 타임 비용 | 런타임 비용 |
|---|---|---|
| 클래스 (멤버 함수, public/private) | 작음 | 0 (call만) |
| 템플릿 | 큼 (bloat 가능) | 0 (인스턴스마다 코드) |
constexpr | 큼 | 0 또는 음수(런타임 코드 제거) |
| RAII (소멸자) | 작음 | 0 (생성자 인라인) |
| 가상 함수 | 작음 | vtable 간접 호출 1회 |
| 예외 처리 | 큼 | 있음 (대부분 환경에서 끔) |
RTTI (typeid, dynamic_cast) | 작음 | 있음 (끔) |
| 스마트 포인터 | 작음 | unique_ptr 0, shared_ptr 있음 |
iostream | 큼 | 큼 (printf 대비 ~50KB) |
핵심: C와 동등한 런타임 비용을 가지는 기능이 대부분입니다. 비용을 가지는 것은 예외, RTTI, iostream, 동적 다형성 정도입니다. 이들을 끄거나 사용하지 않으면 C와 같은 영역에서 출발합니다.
한눈에 보면 다음과 같이 정리됩니다.
#측정 — 단순 함수의 어셈블리
말로는 부족합니다. 어셈블리를 직접 봅니다.
같은 일을 하는 두 코드를 비교합니다. ARM Cortex-M4, -O2, GCC 13 환경입니다.
// C 버전int add_one(int x) { return x + 1;}// C++ 버전struct Counter { int value; Counter(int v) : value(v) {} int add_one() const { return value + 1; }};
int caller(int x) { Counter c(x); return c.add_one();}어셈블리 결과:
# C: add_one(int)add_one: adds r0, r0, #1 bx lr
# C++: caller(int)caller(int): adds r0, r0, #1 bx lr완전히 동일합니다. C++의 생성자 호출과 멤버 함수 호출이 컴파일러에 의해 사라졌습니다. 이것이 zero-cost abstraction의 가장 단순한 예입니다.
#측정 — Blink LED 비교
조금 더 현실적인 예를 봅니다. STM32 보드의 GPIO blink입니다.
// C 버전#define GPIOA_BSRR (*(volatile uint32_t*)0x40020018)
void blink_on(void) { GPIOA_BSRR = 1 << 5; }void blink_off(void) { GPIOA_BSRR = 1 << 21; }// C++ 버전 (RAII + 템플릿)template<uint32_t Address, uint8_t Pin>struct Gpio { static void on() { *reinterpret_cast<volatile uint32_t*>(Address + 0x18) = 1u << Pin; } static void off() { *reinterpret_cast<volatile uint32_t*>(Address + 0x18) = 1u << (Pin + 16); }};
using Led = Gpio<0x40020000, 5>;
void blink_on() { Led::on(); }void blink_off() { Led::off(); }-O2 어셈블리:
# C: blink_onblink_on: ldr r3, =0x40020018 movs r2, #32 str r2, [r3] bx lr
# C++: blink_onblink_on: ldr r3, =0x40020018 movs r2, #32 str r2, [r3] bx lr역시 동일합니다. C++의 템플릿은 컴파일 타임 추상화입니다. Address와 Pin이 상수로 박혀 어셈블리에서는 C 매크로와 같은 결과가 나옵니다.
장점은 타입 안전성입니다. Gpio<0x40020000, 5>와 Gpio<0x40020400, 12>는 다른 타입이라 서로 섞어 쓸 수 없습니다. C의 매크로로는 불가능한 보호입니다.
#비용이 진짜로 발생하는 영역
zero-cost가 아닌 영역도 있습니다. 이것들을 알고 끄는 것이 임베디드 C++의 절반입니다.
#1. 예외 처리
예외는 unwind table과 런타임 핸들러를 함께 데려옵니다.
// 예외 사용int divide(int a, int b) { if (b == 0) throw std::runtime_error("divide by zero"); return a / b;}이 함수 하나가 3-10KB의 런타임을 요구합니다. 임베디드는 보통 예외 자체를 끕니다.
CXXFLAGS += -fno-exceptions대안으로는 std::optional, std::expected (C++23), error code 반환이 있습니다. 자세한 내용은 Part 3-05: No-Exception 설계에서 다룹니다.
#2. RTTI
dynamic_cast와 typeid는 type info 테이블을 데려옵니다. 보통 수 KB 정도입니다.
CXXFLAGS += -fno-rtti대안으로는 tagged union, std::variant, visitor 패턴(CRTP 기반)이 있습니다.
#3. iostream
std::cout은 50KB 이상의 코드를 데려옵니다. 임베디드에서는 printf보다 무겁습니다.
대안으로는 printf(C 라이브러리), fmt(header-only, 작음), 직접 UART 출력 등이 있습니다.
#4. 가상 함수
vtable 간접 호출이 1회 발생합니다. 무료는 아니지만 대부분 무시할 수 있습니다. 인라인되지 않는다는 점이 단점입니다.
대안은 CRTP(static polymorphism, Part 2-08)입니다.
#5. std::function
내부적으로 heap 할당과 type erasure를 사용합니다. 임베디드에서는 함수 포인터나 FixedFunction(etl::delegate)을 씁니다.
#코드 크기 비교 — 실측
같은 기능을 C와 C++로 작성했을 때의 실제 크기 비교입니다. STM32F4, -Os -flto -fno-exceptions -fno-rtti 환경입니다.
| 항목 | C | C++ | 차이 |
|---|---|---|---|
| 빈 main | 240 B | 240 B | 0 |
| GPIO blink | 312 B | 312 B | 0 |
| UART driver | 1.2 KB | 1.2 KB | 0 |
| Ring buffer | 480 B | 488 B | +8 B |
| RTOS task (FreeRTOS) | 18 KB | 18 KB | 0 |
printf 사용 | +9 KB | +9 KB | 0 |
std::cout 사용 | — | +52 KB | +52 KB |
dynamic_cast 1회 | — | +3 KB | +3 KB |
적절히 끄면 C++가 C와 같거나 수 바이트 차이입니다. 기본을 모르고 쓰면 수십 KB의 bloat가 한 번에 들어옵니다.
#자주 보는 함정과 안티패턴
#1. -fno-exceptions 없이 시작
GCC 기본값은 예외 활성입니다. 임베디드 프로젝트는 처음부터 -fno-exceptions를 켜야 합니다. 늦게 끄면 기존 코드가 깨집니다.
#2. std::vector를 무심코 사용
vector는 heap 할당을 합니다. 자유 메모리가 없는 환경에서는 런타임 실패로 이어집니다. std::array, etl::vector(고정 크기)로 대체합니다.
#3. virtual을 모든 곳에 적용
“OOP니까 virtual”은 아닙니다. 진짜 런타임 다형성이 필요한 곳에만 씁니다. 그 외에는 CRTP를 씁니다.
#4. iostream 사용 후 크기 폭증에 놀람
<< 한 번에 50KB가 따라옵니다. printf 또는 fmt를 씁니다.
#5. std::shared_ptr로 모든 소유권 표현
shared_ptr은 atomic 카운터와 heap을 함께 끌고 옵니다. 임베디드는 unique_ptr이 기본이며 raw pointer와 명확한 소유자 지정도 괜찮습니다.
#6. C++ 표준 라이브러리 전부 사용 가능하다고 가정
<filesystem>, <thread>, <chrono>의 일부는 OS 의존입니다. bare-metal에서는 링크 실패로 이어집니다. ETL, EASTL 같은 임베디드 친화 라이브러리를 검토합니다.
#Modern C++가 가져온 변화
C++11 이전과 이후는 거의 다른 언어에 가깝습니다.
| 기능 | 추가 표준 | 임베디드 가치 |
|---|---|---|
constexpr | C++11 | 런타임 → 컴파일 타임 |
auto, range-for | C++11 | 가독성 |
nullptr | C++11 | NULL 대체, 타입 안전 |
enum class | C++11 | 네임스페이스 분리 |
unique_ptr | C++11 | RAII 소유권 |
static_assert | C++11 | 컴파일 타임 검증 |
noexcept | C++11 | 예외 없음 명시 |
constexpr 확장 | C++14/17/20 | 더 많은 컴파일 타임 |
std::array | C++11 | 고정 크기 컨테이너 |
| Variadic template | C++11 | 가변 인자 (printf 안전 대체) |
| Concepts | C++20 | 템플릿 제약, 에러 메시지 |
std::span | C++20 | 포인터 + 길이 안전 wrap |
std::expected | C++23 | 예외 없는 에러 처리 |
이 중 대부분이 zero-cost입니다. 템플릿 기반 + 컴파일 타임 기능들이 핵심입니다.
#C가 여전히 옳은 자리
C++가 만능은 아닙니다. C가 더 적합한 경우는 다음과 같습니다.
- 부트 코드 / 인터럽트 벡터 테이블: 매우 초기 단계라 C++ 런타임이 초기화되지 않았습니다
- 극소형 MCU: ATtiny 같은 4KB 미만 Flash 환경에서는 C++ 런타임 자체가 부담입니다
- 인증 제약: DO-178C Level A 같은 환경에서는 C++ 도구 인증 비용이 큽니다
- legacy 코드: 수십만 줄의 C 코드베이스에 C++ 일부만 섞으면 복잡성이 커집니다
- 팀 역량: C++ 숙련도가 낮은 팀에서는 느린 도입이 빠른 도입보다 안전합니다
특히 부트 코드는 거의 항상 C 또는 어셈블리입니다. __libc_init_array가 호출되기 전에는 static 생성자가 돌지 않았기 때문에 C++ 객체를 생성할 수 없습니다. 자세한 내용은 Part 1-06: 스타트업 코드에서 다룹니다.
#C와 C++의 공존 전략
대부분의 임베디드 프로젝트는 C와 C++가 섞여 있습니다.
- HAL/드라이버: C(벤더 제공, 검증된 코드)
- 부트/스타트업: C 또는 어셈블리
- 애플리케이션 로직: C++(RAII, 타입 안전, 추상화 활용)
- 외부 라이브러리 wrapper: C++(C API를 RAII로 감쌉니다)
C/C++ 혼합 시에는 name mangling과 ABI가 문제가 됩니다. extern "C" 블록과 헤더 분리 패턴이 표준 해법입니다. 자세한 내용은 Part 1-05: ABI 호환성에서 다룹니다.
#핵심 질문 — 언어가 아닌 사용법
C와 C++의 선택은 언어 자체의 우열 문제가 아닙니다. 어떤 기능을 어떻게 쓰는지가 결정합니다.
C++를 안전하게 쓰려면 시리즈 전체를 관통하는 네 질문을 각 결정마다 던집니다.
- 이 기능이 런타임 비용을 추가하는가? 어셈블리로 확인합니다
- 이 기능이 디버깅을 더 쉽게 만드는가? 타입 안전과 명확한 의도가 핵심입니다
- 이 기능이 코드 품질을 끌어올리는가? RAII, const, constexpr이 그 예입니다
- 바이너리 크기가 감당 가능한가? 측정과
-Os -flto로 확인합니다
C++의 강점인 추상화가 부담 없이 가능한지를 프로젝트마다 평가합니다.
#정리
- Modern C++의 대부분은 zero-cost abstraction이며 어셈블리는 C와 동일하게 나옵니다.
- 비용이 발생하는 영역은 예외, RTTI, iostream, 동적 할당,
std::function이며 알고 끄거나 대체합니다. - C는 부트 코드, 극소형 MCU, 인증 환경에서 여전히 적합합니다.
- 결정은 언어 선택이 아니라 사용법 선택입니다. 위 네 가지 핵심 질문으로 매 결정을 평가합니다.
#관련 항목
- Part 1-02: 컴파일러 플래그 가이드 —
-fno-exceptions,-fno-rtti,-Os설정 - Part 1-04: 코드 크기 분석 — bloat 측정과 통제
- Part 2-08: Static Polymorphism — virtual 없이 다형성
- Part 3-05: No-Exception 설계 — 예외 없이 에러 처리
- GoF 5: Singleton (avoid) — 임베디드의 DI 대안
#다음 글
Part 1-02: 컴파일러 플래그 가이드 — C++를 임베디드 모드로 만드는 정확한 플래그 조합과 그것이 어셈블리에 미치는 영향을 다룹니다.
Embedded C++ for Real Systems · 2 of 41
- 1Embedded C++ for Real Systems — 임베디드 모던 C++ 시리즈 소개
- 2임베디드 C++ vs C — 런타임·코드 크기·ABI 관점 비교
- 3임베디드 C++ 컴파일러 플래그 분석 — -fno-rtti·-fno-exceptions·-Os
- 4임베디드 C++ 런타임 요구사항 — libstdc++·newlib·crt0 분석
- 5C++ 코드 크기 분석 — 가상 함수·템플릿·예외 비용 추적
- 6C++ ABI 호환성 — Itanium ABI·name mangling·vtable 레이아웃
- 7C++ 스타트업 코드 분석 — .init_array·전역 생성자 호출 순서
- 8임베디드 C++ 링커 스크립트 — vtable·정적 객체 배치 추적
- 9임베디드 C++ 표준 선택 가이드 — C++11/14/17/20/23 트레이드오프
- 10임베디드 RAII 기초 — 리소스 안전성과 결정적 소멸 보장
- 11임베디드 RAII 실전 패턴 — Lock·Pin·DMA·Power 관리
- 12constexpr 기초와 임베디드 적용 — 컴파일 타임 계산 활용
- 13constexpr 고급 활용 — 룩업 테이블·CRC·해시 컴파일 타임 생성
- 14consteval과 constinit 분석 — C++20 컴파일 타임 강제 메커니즘
- 15임베디드 Templates 기초 — 타입 안전과 코드 재사용 분석
- 16Template 비용 분석 — 코드 폭증·인스턴스화·디버그 정보 측정
- 17CRTP 패턴 분석 — vtable 없는 정적 다형성
- 18Type Traits 임베디드 활용 — SFINAE·is_pod·컴파일 타임 검사
- 19C++20 Concepts 활용 — 템플릿 제약과 가독성 개선
- 20동적 할당 없는 임베디드 C++ — placement new·정적 객체·풀
- 21Custom Allocator 기초 — std::allocator 인터페이스 분석
- 22Pool Allocator 구현 — Fixed-Size Block과 O(1) 보장
- 23std::pmr 임베디드 활용 — Polymorphic Memory Resource 분석
- 24No-Exception C++ 설계 — 코드 크기·결정성 트레이드오프
- 25임베디드 에러 처리 패턴 — Result·errno·optional 비교
- 26std::expected 분석 — C++23 결과 타입과 에러 전파
- 27No-RTTI C++ 설계 — dynamic_cast 제거와 정적 타입 분기
- 28임베디드 스마트 포인터 선택 — unique·shared·custom 비교
- 29임베디드 C++ 소유권 모델 — single·shared·borrow 패턴
- 30Intrusive Containers 분석 — 동적 할당 없는 컨테이너 설계
- 31ETL 라이브러리 분석 — Embedded Template Library의 STL 대체
- 32임베디드 Lock-free 기초 — atomic·memory ordering·CAS
- 33Lock-free Container 구현 — SPSC Queue·Ring Buffer
- 34Type-safe Flags 패턴 — Enum Class·Strong Typedef·Tag
- 35임베디드 State Machine 패턴 — Variant·Visitor·Table-driven 비교
- 36Compile-time FSM 구현 — 템플릿으로 상태 전이 검증
- 37Singleton 대안 패턴 — Service Locator·Static Init·Phantom
- 38MMIO Register 추상화 — 타입 안전한 비트 필드 접근
- 39GPIO 추상화 패턴 — Template·Concept으로 보드 독립성
- 40Peripheral 추상화 — UART·SPI·I2C 공통 인터페이스 설계
- 41임베디드 HAL 설계 패턴 — Static·Dynamic·Hybrid 비교
관련 글
CRTP 패턴 분석 — vtable 없는 정적 다형성
Curiously Recurring Template Pattern — virtual 함수 없이 컴파일 타임 다형성. vtable 0, 간접 호출 0.
constexpr 기초와 임베디드 적용 — 컴파일 타임 계산 활용
컴파일 타임 계산 — 런타임 코드와 데이터를 컴파일러가 미리 만들어줍니다. -Os보다 강력한 zero-cost.
C++ ABI 호환성 — Itanium ABI·name mangling·vtable 레이아웃
C와 C++가 한 바이너리에서 살아남는 법 — name mangling, extern "C", calling convention, struct layout.