펌웨어에서 C vs C++ 프로젝트 구성 비교
MCU 펌웨어의 주류 언어는 C다. C로 못 만드는 펌웨어는 없다. Linux 커널, Git, FreeRTOS 전부 C다. C++은 일부 기능을 골라 쓰면 C와 동일한 바이너리를 생성하면서 컴파일 타임 안전성을 높일 수 있지만, 그것이 C++을 써야 하는 이유가 되는 건 아니다. 이 글은 같은 펌웨어 작업을 C와 C++로 각각 구현했을 때 무엇이 달라지는지 비교하고, 각 선택의 근거를 정리한다.
같은 일, 다른 코드
매크로 상수 → constexpr
C:
#define BAUD_DIV(clk, baud) ((clk) / (16 * (baud)))
#define DIV_9600 BAUD_DIV(48000000, 9600) // 타입 없음, 디버거에서 안 보임
C++:
constexpr uint16_t baud_div(uint32_t clk, uint32_t baud) {
return clk / (16 * baud);
}
constexpr auto div_9600 = baud_div(48000000, 9600);
// 타입 있음, 디버거에서 보임, 컴파일 결과는 즉시값 하나 → 바이너리 동일
constexpr은 컴파일 타임에 완전히 평가된다. #define과 바이너리가 같으면서 타입 검사와 스코프가 적용된다.
수동 cleanup → RAII
C:
void update_sensor(void) {
xSemaphoreTake(mutex, portMAX_DELAY);
if (!sensor_ready()) {
xSemaphoreGive(mutex); // 빠뜨리면 데드락
return;
}
read_sensor();
xSemaphoreGive(mutex); // 여기도 빠뜨리면 데드락
}
return 경로가 3개면 Give()도 3번 써야 한다. 하나라도 빠지면 데드락.
C++:
class MutexGuard {
SemaphoreHandle_t m;
public:
MutexGuard(SemaphoreHandle_t mtx) : m(mtx) { xSemaphoreTake(m, portMAX_DELAY); }
~MutexGuard() { xSemaphoreGive(m); }
};
void update_sensor() {
MutexGuard lock(mutex);
if (!sensor_ready()) return; // 소멸자가 자동으로 Give()
read_sensor();
} // 여기서도 자동으로 Give()
컴파일러가 모든 스코프 종료 지점에 소멸자를 인라인한다. 생성되는 어셈블리는 C 버전과 동일하다.
void* 제네릭 → 템플릿
C:
// 링 버퍼: void*로 범용화 → 타입 실수를 컴파일러가 잡지 못함
typedef struct {
void *buf;
size_t elem_size;
size_t head, tail, capacity;
} ring_buf_t;
void ring_push(ring_buf_t *rb, const void *item) {
memcpy((char*)rb->buf + rb->head * rb->elem_size, item, rb->elem_size);
rb->head = (rb->head + 1) % rb->capacity;
}
C++:
template<typename T, size_t N>
class RingBuffer {
T buf[N];
size_t head = 0, tail = 0;
public:
void push(const T& item) {
buf[head] = item;
head = (head + 1) % N;
}
};
RingBuffer<int16_t, 64> adc_buf; // 타입·크기 고정, memcpy 없음
템플릿은 타입마다 별도 코드를 생성한다. void* 캐스팅이 사라지고, 컴파일러가 타입 불일치를 잡는다.
C enum → enum class
C:
enum pin_state { LOW, HIGH }; // LOW, HIGH가 전역 스코프에 노출
enum led_mode { OFF, ON }; // OFF == LOW == 0 → 의도치 않은 비교 통과
if (state == OFF) { /* 버그지만 컴파일됨 */ }
C++:
enum class PinState : uint8_t { Low, High };
enum class LedMode : uint8_t { Off, On };
if (state == LedMode::Off) { /* 타입이 다르면 컴파일 에러 */ }
바이너리는 동일하다. 이름 충돌과 타입 혼동이 컴파일 타임에 차단된다.
C++에서 피해야 하는 기능
위 예시들은 오버헤드가 없다. 아래 기능들은 flash, RAM, 또는 결정성(determinism)에 비용이 든다.
| 기능 | 비용 | 이유 |
|---|---|---|
RTTI (dynamic_cast, typeid) | flash +~10 KB | 모든 다형 클래스의 타입 정보 테이블 저장 |
| 예외 처리 | flash +~20 KB | unwind table, landing pad 코드. 예외 객체 생성 시 heap 할당 → 비결정적 |
| iostream | flash +50~150 KB | locale, formatting 인프라 전체가 링크됨 |
| virtual 함수 | 객체당 +4 bytes | vtable 포인터. 링커가 미사용 virtual 함수를 제거 불가. final로 완화 가능 |
| 전역 객체 생성자 | 초기화 순서 미정의 | 다른 파일의 전역 객체를 참조하면 정의되지 않은 동작 (static init fiasco) |
| STL 컨테이너 | heap 할당 | vector, string, map 등 기본 allocator가 new 사용 → fragmentation |
이 기능들을 끄는 컴파일 플래그:
-fno-exceptions -fno-rtti -fno-unwind-tables
이 세 플래그만으로 C++ 고유 오버헤드 대부분이 제거된다.
STL 컨테이너 대안: ETL
heap을 쓰는 std::vector 대신, 컴파일 타임에 크기가 고정되는 ETL (Embedded Template Library)을 사용할 수 있다.
#include <etl/vector.h>
#include <etl/string.h>
etl::vector<int, 10> values; // 최대 10개, 스택/정적 메모리
etl::string<32> name; // 최대 32자, heap 할당 없음
STL과 API가 호환되므로 코드 수정이 거의 없다.
C/C++ 혼합 빌드
벤더 HAL과 RTOS 커널은 C로 작성되어 있다. 실무에서는 C/C++ 혼합이 일반적이다.
C++ 컴파일러는 함수 이름을 변형(name mangling)한다. C 코드와 링크하려면 extern "C"로 원래 이름을 유지해야 한다.
#ifdef __cplusplus
extern "C" {
#endif
void HAL_GPIO_Init(GPIO_TypeDef *port, GPIO_InitTypeDef *cfg);
#ifdef __cplusplus
}
#endif
extern "C"가 필요한 곳:
- C++ 파일에서 정의하는 RTOS task 함수, ISR 핸들러
main()(링커가 C 심볼로 찾음)- 인터럽트 벡터 테이블 엔트리
벤더 SDK는 수정하지 않고 C로 컴파일, 애플리케이션 코드만 C++로 컴파일하는 분리 빌드가 일반적이다.
C만으로 충분한 이유
위 비교에서 C++ 쪽이 “더 좋아 보이지만”, C가 부족한 건 아니다. C의 모든 패턴은 규율로 대체 가능하다.
#define실수 → 코드 리뷰와 네이밍 규칙(MODULE_CONSTANT_NAME)으로 잡는다. 수십 년 동안 작동해온 방법이다- 수동 cleanup 누락 →
goto cleanup패턴으로 단일 해제 경로를 만든다. Linux 커널이 이 방식이다
void update_sensor(void) {
xSemaphoreTake(mutex, portMAX_DELAY);
if (!sensor_ready()) goto cleanup;
read_sensor();
cleanup:
xSemaphoreGive(mutex);
}
void*타입 실수 →_Static_assert와 매크로로 타입 크기를 검증한다. 완벽하진 않지만 실무에서 충분하다- enum 이름 충돌 →
MODULE_접두어 규칙으로 해결한다.PIN_STATE_LOW,LED_MODE_OFF
C의 장점은 투명성이다. 코드가 하는 일이 보이는 그대로다. 숨겨진 생성자, 소멸자, 템플릿 인스턴스화가 없다. 디스어셈블리를 볼 때 C 코드와 어셈블리가 거의 1:1로 대응된다. 디버깅할 때 이 투명성이 가치를 갖는다.
또한 현실적인 이유가 있다:
- 벤더 SDK, HAL, RTOS 커널이 전부 C다. C++로 감싸면 결국 두 언어를 모두 알아야 한다
- 임베디드 C 개발자 풀이 더 크다. 팀 채용이 더 쉽다
- 레거시 코드베이스가 C인 경우가 대부분이다. 기존 코드와의 일관성이 중요하다
C++이 실질적 이점을 주는 경우
반대로, C++의 컴파일 타임 안전성이 실질적 차이를 만드는 상황도 있다.
- 코드 규모가 클 때 — 파일 50개, 상태 머신 10개 이상이면 수동 cleanup 누락이 통계적으로 발생한다. RAII는 이 버그 클래스를 구조적으로 제거한다
- 여러 타입의 같은 자료구조 — ADC 버퍼, CAN 메시지 큐, 로그 링 버퍼를 각각
void*로 만들면 타입 실수 확률이 높다. 템플릿은 컴파일러가 잡아준다 - 팀 개발 — 코드 리뷰로 잡아야 할 실수를 컴파일 에러로 바꾼다. 리뷰어의 부담이 줄어든다
- 장기 유지보수 — 2년 후 다른 사람이 코드를 수정할 때,
enum class와namespace가 있으면 의도를 잘못 해석할 여지가 줄어든다
핵심은 C++의 이점이 “프로그래머가 규칙을 지키면 필요 없는 것”이라는 점이다. 규칙을 지킬 자신이 있으면 C로 충분하고, 규칙 위반을 컴파일러에 위임하고 싶으면 C++이 합리적이다.
정리
| C | C++ (임베디드 서브셋) | |
|---|---|---|
| 상수 | #define | constexpr (타입 안전, 바이너리 동일) |
| 리소스 관리 | 수동 acquire/release, goto cleanup | RAII (자동 해제, 바이너리 동일) |
| 제네릭 | void*, 매크로 | 템플릿 (타입 검사, 인라인 최적화) |
| 열거형 | enum + 접두어 규칙 | enum class (스코프, 타입 안전) |
| 네임스페이스 | module_ 접두어 관습 | namespace (컴파일러 강제) |
| 컨테이너 | 직접 구현 | ETL (고정 크기, STL 호환 API) |
| 투명성 | 코드 = 어셈블리 1:1 | 숨겨진 동작 가능 (생성자, 소멸자, 인스턴스화) |
| 생태계 | SDK/HAL/RTOS 네이티브 | extern "C" 래핑 필요 |
어떤 C++ 기능도 C로 대체할 수 있다. 차이는 실수를 누가 잡는가다 — 프로그래머(리뷰, 규칙)인가, 컴파일러(constexpr, RAII, enum class)인가. 프로젝트 규모, 팀 구성, 기존 코드베이스에 따라 판단한다.
메모
-fno-exceptions -fno-rtti를 적용하면 동일 로직의 C 코드와 바이트 단위로 동일한 바이너리가 나올 수 있다- 주요 임베디드 툴체인(ARM GCC 7+, IAR 8.30+, Keil AC6) 모두 C++17을 지원한다
- FreeRTOS, ThreadX 등 주요 RTOS는 공식 C++ API가 없다. C++ wrapper는 커뮤니티 프로젝트
- MISRA C++:2023이 “임베디드 C++ 서브셋” 접근을 공식 표준화했다 (예외/RTTI 금지, constexpr/RAII 권장)
- Linux 커널이 C를 고수하는 이유: 컴파일러 동작의 완전한 예측 가능성, ABI 안정성, 광범위한 아키텍처 지원. 2024년부터 Rust를 일부 드라이버에 도입하기 시작했지만, C++은 채택하지 않았다
goto cleanup은 안티패턴이 아니다: 단일 해제 지점을 만들어 모든 return 경로에서 리소스를 확실히 해제한다. Linux 커널 코딩 스타일에서 공식 권장하는 패턴이다