펌웨어에서 C vs C++ 프로젝트 구성 비교

EmbeddedC++

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 KBunwind table, landing pad 코드. 예외 객체 생성 시 heap 할당 → 비결정적
iostreamflash +50~150 KBlocale, formatting 인프라 전체가 링크됨
virtual 함수객체당 +4 bytesvtable 포인터. 링커가 미사용 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 classnamespace가 있으면 의도를 잘못 해석할 여지가 줄어든다

핵심은 C++의 이점이 “프로그래머가 규칙을 지키면 필요 없는 것”이라는 점이다. 규칙을 지킬 자신이 있으면 C로 충분하고, 규칙 위반을 컴파일러에 위임하고 싶으면 C++이 합리적이다.

정리

CC++ (임베디드 서브셋)
상수#defineconstexpr (타입 안전, 바이너리 동일)
리소스 관리수동 acquire/release, goto cleanupRAII (자동 해제, 바이너리 동일)
제네릭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 커널 코딩 스타일에서 공식 권장하는 패턴이다