C++ 제약된 환경에서의 C++: Exception과 RTTI 없이 안전한 코드 짜기 [#42-1]

C++ 제약된 환경에서의 C++: Exception과 RTTI 없이 안전한 코드 짜기 [#42-1]

이 글의 핵심

C++ 제약된 환경에서의 C++: Exception과 RTTI 없이 안전한 코드 짜기 [#42-1]에 대한 실전 가이드입니다.

들어가며: “예외는 켤 수 없어요”

ROM·RAM이 제한된 환경

C++이 가장 강하게 쓰이는 저수준 제어 분야(임베디드, 드라이버, 일부 게임 콘솔)에서는 예외(Exception)RTTI비활성화(-fno-exceptions, -fno-rtti)하는 경우가 많습니다. 예외는 코드 크기·런타임 오버헤드가 부담되고, RTTI는 타입 정보 테이블이 ROM을 차지하기 때문입니다.

이 모드에서도 안전하고 유지보수 가능한 코드를 쓰려면: 오류는 반환값·에러 코드·std::expected 등으로 전달하고, 다형성은 가상 함수 대신 variant·템플릿·정적 인터페이스로 대체할 수 있습니다.

이 글에서 다루는 것:

  • -fno-exceptions: 예외 대신 반환값·expected·optional·abort 정책
  • -fno-rtti: RTTI(Runtime Type Information—실행 중에 타입 정보를 조회하는 기능으로, dynamic_cast·typeid가 여기 해당함)를 끄고, dynamic_cast·typeid 대신 variant·visit·태그 조합
  • STL 사용: 예외를 쓰는 컨테이너·연산 대체·커스텀 allocator

문제 시나리오: 예외·RTTI를 끄지 않을 수 없는 순간

시나리오 1: 64KB 플래시 MCU에서 빌드 크기 초과

문제: STM32F0 시리즈(64KB 플래시)에 C++ 프로젝트를 올리려 했는데, 예외를 활성화한 채로 빌드하니 이미지 크기가 80KB를 넘어 플래시에 들어가지 않습니다. 예외 처리 코드(언와인딩 테이블, typeinfo)가 20KB 이상을 차지합니다.

해결: -fno-exceptions -fno-rtti로 빌드하고, std::optional·에러 코드로 오류를 전달하면 이미지가 55KB로 줄어듭니다.

시나리오 2: 센서 드라이버에서 생성자 실패 처리

문제: I2C 센서 드라이버 생성 시, 버스 초기화가 실패하면(예: 슬레이브 응답 없음) 생성자에서 어떻게 알려야 할까요? 생성자는 반환값이 없고, 예외는 비활성화되어 있습니다.

해결: 팩토리 함수std::optional<Sensor> 또는 std::expected<Sensor, ErrorCode>를 반환하고, 생성자 본문에서는 단순 대입만 하거나 두 단계 초기화(생성 후 init() 호출)를 사용합니다.

시나리오 3: 디바이스 플러그인에서 타입 분기

문제: USB 호스트 스택에서 Keyboard, Mouse, Storage 등 여러 디바이스 타입을 하나의 Device*로 관리합니다. dynamic_cast로 타입을 확인하려 했는데, -fno-rtti 빌드에서는 dynamic_cast가 비활성화됩니다.

해결: std::variant<Keyboard, Mouse, Storage>로 타입 집합을 명시하고, std::visit로 타입별 처리를 exhaustive하게 구현합니다. 또는 enum kind() 멤버를 두고 switch로 분기합니다.

시나리오 4: vector::push_back에서 메모리 부족

문제: 표준 std::vector::push_back은 재할당 실패 시 std::bad_alloc 예외를 던집니다. -fno-exceptions 환경에서는 std::terminate로 가거나 구현체별로 abort합니다. 예측 불가능한 종료를 피하고 싶습니다.

해결: reserve()로 미리 용량을 확보해 재할당을 피하고, 고정 크기 버퍼를 사용하거나 커스텀 allocator로 실패 시 nullptr를 반환하는 정책을 적용합니다.

시나리오 5: 실시간 루프에서 예외 지연

문제: 1ms 주기 제어 루프에서, 내부 라이브러리 호출이 예외를 던지면 스택 언와인딩으로 수 ms 지연이 발생합니다. 데드라인을 놓치면 위험합니다.

해결: 예외를 완전히 비활성화하고, 모든 실패 경로를 반환값·에러 코드로 처리하면 최악 실행 시간(WCET)을 예측할 수 있습니다.

시나리오 6: 플러그인 ABI에서 RTTI 의존성

문제: DLL/SO 플러그인을 로드할 때, 호스트와 플러그인이 서로 다른 컴파일러·버전으로 빌드되면 RTTI 타입 정보가 호환되지 않아 dynamic_cast가 실패합니다.

해결: RTTI를 끄고, kind() 같은 enum·인터페이스 ID로 타입을 식별하고, 가상 함수만으로 통신합니다.

개념을 잡는 비유

빌드·검사·배포 파이프라인은 공장 검수 라인과 비슷합니다. 같은 입력이면 같은 산출물이 나오게 고정하고, Sanitizer·정적 분석은 출하 전 불량 검사 역할을 합니다.


목차

  1. 예외 비활성화 (-fno-exceptions)
  2. RTTI 비활성화 (-fno-rtti)
  3. STL과 할당 전략
  4. 완전한 임베디드 C++ 예제
  5. 자주 발생하는 에러와 해결법
  6. 최적화 팁
  7. 프로덕션 패턴
  8. 구현 체크리스트
  9. 정리

1. 예외 비활성화 (-fno-exceptions)

오류를 반환값으로 전달

-fno-exceptions를 쓰면 throw·try/catch를 사용할 수 없습니다. 대신 bool 반환 + out 파라미터, std::optional, std::expected(C++23) 등으로 “성공/실패 + 값”을 전달합니다.

flowchart TD
    subgraph with_exceptions["예외 사용 시"]
        A1[함수 호출] --> A2{실패?}
        A2 -->|예| A3[throw]
        A3 --> A4[스택 언와인딩]
        A4 --> A5[catch]
        A2 -->|아니오| A6[값 반환]
    end

    subgraph no_exceptions["-fno-exceptions 시"]
        B1[함수 호출] --> B2{실패?}
        B2 -->|예| B3[optional/expected 반환]
        B3 --> B4[호출부에서 if 검사]
        B2 -->|아니오| B5[값 반환]
    end

optional로 “값 있음/없음” 표현

-fno-exceptions 환경에서는 throw를 쓰지 않으므로, 실패 시 std::optional로 “값이 있으면 성공, 없으면 실패”를 표현합니다. loadConfigpath가 null이거나 parse가 실패하면 std::nullopt를 반환하고, 성공하면 Config를 담은 optional을 반환합니다.

#include <optional>

struct Config {
    int baud_rate;
    int timeout_ms;
};

// 예외 없이: 실패 시 nullopt, 성공 시 Config
std::optional<Config> loadConfig(const char* path) {
    if (!path) return std::nullopt;
    Config c;
    if (!parse(path, c)) return std::nullopt;
    return c;
}

// 호출부
void useConfig() {
    if (auto opt = loadConfig("/etc/device.conf")) {
        applyConfig(*opt);
    } else {
        useDefaultConfig();
    }
}

expected로 “값 또는 에러 정보” 표현 (C++23)

std::optional은 “값이 없음”만 표현합니다. 왜 실패했는지 에러 코드나 메시지를 함께 전달하려면 std::expected를 사용합니다.

#include <expected>
#include <cstdint>

enum class SensorError : uint8_t {
    Ok = 0,
    BusError,
    NoResponse,
    InvalidId,
};

struct SensorReading {
    float temperature;
    float humidity;
};

// 성공 시 SensorReading, 실패 시 SensorError
std::expected<SensorReading, SensorError> readSensor(uint8_t id) {
    if (id >= 8) return std::unexpected(SensorError::InvalidId);
    if (!i2c_probe(id)) return std::unexpected(SensorError::NoResponse);

    SensorReading r;
    if (!i2c_read(id, &r, sizeof(r))) return std::unexpected(SensorError::BusError);
    return r;
}

void pollSensors() {
    auto result = readSensor(0);
    if (result) {
        float temp = result->temperature;
        // ...
    } else {
        switch (result.error()) {
            case SensorError::NoResponse: log("센서 응답 없음"); break;
            case SensorError::BusError:   log("I2C 버스 오류"); break;
            default: break;
        }
    }
}

생성자 실패: 팩토리 함수와 두 단계 초기화

생성자에서는 반환값이 없으므로 팩토리 함수optional<T> 또는 expected<T, E>를 반환하게 하고, 생성자 본문에서는 단순 대입만 하거나, 두 단계 초기화(생성 후 init() 호출)를 씁니다.

#include <optional>
#include <cstdint>

class UartDriver {
    volatile uint32_t* reg_base_;
    bool initialized_ = false;

public:
    // 생성자: 멤버만 초기화, 실패 처리 없음
    UartDriver() : reg_base_(nullptr), initialized_(false) {}

    // 두 단계 초기화: init()에서 실패 반환
    bool init(uint32_t base_addr) {
        if (initialized_) return true;
        reg_base_ = reinterpret_cast<volatile uint32_t*>(base_addr);
        if (!reg_base_) return false;
        // 하드웨어 초기화...
        initialized_ = true;
        return true;
    }

    void send(uint8_t byte) {
        if (!initialized_) return;
        // 레지스터 쓰기...
    }
};

// 팩토리 함수: optional 반환
std::optional<UartDriver> createUart(uint32_t base_addr) {
    UartDriver uart;
    if (!uart.init(base_addr)) return std::nullopt;
    return uart;
}

void main_loop() {
    auto uart_opt = createUart(0x40001000);
    if (!uart_opt) {
        // 초기화 실패 처리
        return;
    }
    UartDriver& uart = *uart_opt;
    uart.send(0x55);
}

bool + out 파라미터 (레거시 호환)

C 인터페이스나 레거시 코드와 연동할 때는 bool 반환 + out 파라미터 패턴이 여전히 유효합니다.

// 성공 시 true + out에 값, 실패 시 false
bool parseBaudRate(const char* str, int* out_baud) {
    if (!str || !out_baud) return false;
    int val = 0;
    while (*str >= '0' && *str <= '9') {
        val = val * 10 + (*str - '0');
        ++str;
    }
    if (*str != '\0' || val <= 0) return false;
    *out_baud = val;
    return true;
}

2. RTTI 비활성화 (-fno-rtti)

타입 분기 대체

-fno-rtti에서는 dynamic_cast, typeid를 쓸 수 없습니다. 다형성이 필요하면 가상 함수로 “할 수 있는 일”을 인터페이스에 두고, “구체 타입”이 필요할 때는 enum 태그 + variant 조합을 씁니다.

flowchart LR
    subgraph rtti["RTTI 사용 시"]
        R1[dynamic_cast] --> R2[typeinfo 테이블]
        R2 --> R3[ROM 사용]
    end

    subgraph no_rtti["-fno-rtti 시"]
        N1[variant + visit] --> N2[컴파일 타임 분기]
        N3[enum kind] --> N4[switch]
        N2 --> N5[ROM 절약]
        N4 --> N5
    end

std::variant + std::visit로 타입 안전 분기

std::variant + std::visit로 “이 타입들 중 하나”를 타입 안전하게 담고, visit에서 타입별 처리하면 RTTI 없이 같은 효과를 낼 수 있습니다.

#include <variant>
#include <cstdint>

struct LedState {
    uint8_t brightness;
};

struct ButtonState {
    bool pressed;
};

struct SensorState {
    float value;
};

// 디바이스 상태: 이 중 정확히 하나
using DeviceState = std::variant<LedState, ButtonState, SensorState>;

// overloaded 헬퍼: 여러 람다를 하나의 방문자로
template<class... Ts>
struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

void handleState(const DeviceState& state) {
    std::visit(overloaded{
         {
            setLedBrightness(s.brightness);
        },
         {
            if (s.pressed) onButtonPress();
        },
         {
            processSensorValue(s.value);
        }
    }, state);
}

enum kind()로 파생 타입 식별

가상 함수는 RTTI 없이도 동작합니다. dynamic_cast만 못 쓰므로, “파생 타입인지 확인”이 필요하면 enum kind() 같은 멤버를 두거나 variant로 타입 집합을 명시적으로 관리합니다.

enum class DeviceKind { Led, Button, Sensor };

struct IDevice {
    virtual ~IDevice() = default;
    virtual DeviceKind kind() const = 0;
    virtual void update() = 0;
};

struct LedDevice : IDevice {
    DeviceKind kind() const override { return DeviceKind::Led; }
    void update() override { /* LED 제어 */ }
};

struct ButtonDevice : IDevice {
    DeviceKind kind() const override { return DeviceKind::Button; }
    void update() override { /* 버튼 폴링 */ }
};

void processDevice(IDevice* dev) {
    switch (dev->kind()) {
        case DeviceKind::Led:
            static_cast<LedDevice*>(dev)->setBrightness(128);
            break;
        case DeviceKind::Button:
            // ...
            break;
        case DeviceKind::Sensor:
            // ...
            break;
    }
}

3. STL과 할당 전략

예외를 쓰는 연산 피하기

vector·string 등은 할당 실패 시 표준에서는 std::bad_alloc을 던지도록 되어 있습니다. -fno-exceptions 빌드에서는 std::terminate로 가거나 구현체별 동작이 있으므로, 가능한 reserve로 재할당을 줄이고, 작은 버퍼 로컬로 쓰는 식으로 할당 횟수를 줄입니다.

#include <array>
#include <cstdint>

// 고정 크기 버퍼: 할당 없음, 예외 경로 없음
constexpr size_t MAX_PACKET = 256;
using PacketBuffer = std::array<uint8_t, MAX_PACKET>;

void processPacket() {
    PacketBuffer buf{};  // 스택 할당만
    size_t len = uartReceive(buf.data(), buf.size());
    parsePacket(buf.data(), len);
}

// vector 사용 시: reserve로 재할당 최소화
#include <vector>

void collectSamples(std::vector<int>& out, size_t expected_count) {
    out.clear();
    out.reserve(expected_count);  // 한 번에 확보
    for (size_t i = 0; i < expected_count; ++i) {
        out.push_back(readAdc());  // 재할당 없음
    }
}

커스텀 allocator 개요

커스텀 allocator에서 allocate가 실패 시 nullptr 반환 또는 abort하는 정책을 쓰는 경우, 표준 요구사항과 충돌할 수 있어 구현체 문서를 확인해야 합니다. 고정 크기 풀을 쓰면 예외 경로를 피할 수 있습니다.

#include <cstddef>
#include <new>

// 단순 풀 allocator: 고정 크기 블록, 실패 시 nullptr
template<size_t PoolSize>
class PoolAllocator {
    alignas(std::max_align_t) uint8_t pool_[PoolSize];
    bool used_ = false;

public:
    void* allocate(size_t n) {
        if (used_ || n > PoolSize) return nullptr;
        used_ = true;
        return pool_;
    }

    void deallocate(void*, size_t) {
        used_ = false;
    }
};

4. 완전한 임베디드 C++ 예제

예제 1: I2C 센서 드라이버 (optional + 두 단계 초기화)

// g++ -std=c++17 -fno-exceptions -fno-rtti -O2 -o sensor_driver sensor_driver.cpp
#include <optional>
#include <cstdint>
#include <cstring>

// 하드웨어 추상화 (실제로는 HAL 호출)
extern "C" bool i2c_init(uint8_t bus);
extern "C" bool i2c_write(uint8_t bus, uint8_t addr, const void* data, size_t len);
extern "C" bool i2c_read(uint8_t bus, uint8_t addr, void* data, size_t len);

enum class SensorError : uint8_t {
    Ok, BusNotReady, NoAck, InvalidParam
};

struct SensorConfig {
    uint8_t bus;
    uint8_t addr;
    uint16_t sample_interval_ms;
};

class TemperatureSensor {
    uint8_t bus_;
    uint8_t addr_;
    bool initialized_ = false;

public:
    TemperatureSensor() : bus_(0), addr_(0) {}

    bool init(const SensorConfig& cfg) {
        if (initialized_) return true;
        if (!i2c_init(cfg.bus)) return false;
        bus_ = cfg.bus;
        addr_ = cfg.addr;
        initialized_ = true;
        return true;
    }

    std::optional<float> read() {
        if (!initialized_) return std::nullopt;
        uint8_t raw[2];
        if (!i2c_read(bus_, addr_, raw, 2)) return std::nullopt;
        int16_t val = (raw[0] << 8) | raw[1];
        return val / 256.0f;
    }
};

std::optional<TemperatureSensor> createSensor(const SensorConfig& cfg) {
    TemperatureSensor s;
    if (!s.init(cfg)) return std::nullopt;
    return s;
}

int main() {
    SensorConfig cfg{0, 0x48, 100};
    auto sensor_opt = createSensor(cfg);
    if (!sensor_opt) return -1;

    auto temp = sensor_opt->read();
    if (temp) {
        // *temp 사용
    }
    return 0;
}

예제 2: 이벤트 디스패처 (variant + visit)

// g++ -std=c++17 -fno-exceptions -fno-rtti -O2 -o event_dispatch event_dispatch.cpp
#include <variant>
#include <cstdint>

struct TimerEvent { uint32_t id; };
struct GpioEvent { uint8_t pin; bool level; };
struct UartEvent { uint8_t data; };

using SystemEvent = std::variant<TimerEvent, GpioEvent, UartEvent>;

template<class... Ts>
struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

void dispatchEvent(const SystemEvent& e) {
    std::visit(overloaded{
         {
            // 타이머 t.id 처리
        },
         {
            if (g.level) onGpioRising(g.pin);
        },
         {
            uartRxHandler(u.data);
        }
    }, e);
}

예제 3: 고정 버퍼 기반 로그 (할당 없음)

#include <array>
#include <cstdint>
#include <cstdio>

constexpr size_t LOG_BUF_SIZE = 128;

class EmbeddedLogger {
    std::array<char, LOG_BUF_SIZE> buf_;
    size_t pos_ = 0;

public:
    void log(const char* fmt, ...) {
        if (pos_ >= buf_.size() - 1) return;  // 버퍼 풀
        int n = 0;
        // va_list로 vsnprintf 호출 (생략)
        pos_ += n;
    }

    void flush() {
        buf_[pos_] = '\0';
        uartSend(buf_.data());
        pos_ = 0;
    }
};

예제 4: 프로토콜 파서 (expected + 고정 버퍼)

// Modbus RTU 스타일 단순 파서 — 할당 없음, 예외 없음
#include <expected>
#include <array>
#include <cstdint>

enum class ParseError { Ok, TooShort, BadCrc, InvalidFunction };

struct ModbusFrame {
    uint8_t addr;
    uint8_t func;
    std::array<uint8_t, 252> data;
    uint8_t data_len;
};

std::expected<ModbusFrame, ParseError> parseModbus(
    const uint8_t* buf, size_t len)
{
    if (len < 4) return std::unexpected(ParseError::TooShort);

    ModbusFrame f;
    f.addr = buf[0];
    f.func = buf[1];
    f.data_len = static_cast<uint8_t>(len - 4);
    for (size_t i = 0; i < f.data_len && i < 252; ++i)
        f.data[i] = buf[2 + i];

    // CRC 검사 (생략)
    return f;
}

void processModbus(const uint8_t* buf, size_t len) {
    auto result = parseModbus(buf, len);
    if (!result) {
        if (result.error() == ParseError::BadCrc) logError("CRC");
        return;
    }
    ModbusFrame& f = *result;
    handleRequest(f.addr, f.func, f.data.data(), f.data_len);
}

예제 5: 디바이스 매니저 (variant + 태그 조합)

// 여러 디바이스 타입을 하나의 컨테이너로 관리
#include <variant>
#include <array>
#include <cstdint>

template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

struct Led { uint8_t pin; bool on; };
struct Relay { uint8_t channel; bool state; };
struct Pwm { uint8_t pin; uint8_t duty; };

using Device = std::variant<Led, Relay, Pwm>;
constexpr size_t MAX_DEVICES = 8;

class DeviceManager {
    std::array<Device, MAX_DEVICES> devices_;
    size_t count_ = 0;

public:
    bool addLed(uint8_t pin) {
        if (count_ >= MAX_DEVICES) return false;
        devices_[count_++] = Led{pin, false};
        return true;
    }

    bool addRelay(uint8_t ch) {
        if (count_ >= MAX_DEVICES) return false;
        devices_[count_++] = Relay{ch, false};
        return true;
    }

    void setAll(bool on) {
        for (size_t i = 0; i < count_; ++i) {
            std::visit(overloaded{
                [on](Led& l)   { l.on = on; setGpio(l.pin, on); },
                [on](Relay& r) { r.state = on; setRelay(r.channel, on); },
                       { /* PWM은 무시 */ }
            }, devices_[i]);
        }
    }
};

5. 자주 발생하는 에러와 해결법

문제 1: optional에 값 없는데 value() 호출

증상: std::bad_optional_access 예외 또는 정의되지 않은 동작(UB). -fno-exceptions에서는 std::terminate로 종료될 수 있음.

원인: optionalnullopt인데 value() 또는 *opt로 접근.

// ❌ 잘못된 코드
std::optional<int> opt = std::nullopt;
int x = opt.value();  // 위험!

// ✅ 해결: 항상 has_value() 또는 if 검사
std::optional<int> opt = getValue();
if (opt) {
    int x = *opt;  // 안전
} else {
    // 실패 처리
}

// 또는 value_or로 기본값
int x = opt.value_or(0);

문제 2: -fno-exceptions인데 STL이 예외를 던짐

증상: vector::push_back 재할당 실패 시 std::terminate 호출.

원인: 표준 라이브러리가 std::bad_alloc을 던지도록 구현됨. 예외 비활성화 시 terminate로 대체됨.

// ❌ 위험: reserve 없이 반복 push_back
std::vector<int> vec;
for (int i = 0; i < 10000; ++i) {
    vec.push_back(i);  // 재할당 시 terminate 가능
}

// ✅ 해결: reserve로 재할당 방지
std::vector<int> vec;
vec.reserve(10000);
for (int i = 0; i < 10000; ++i) {
    vec.push_back(i);
}

// ✅ 또는 고정 크기 array 사용
std::array<int, 10000> arr;

문제 3: dynamic_cast를 -fno-rtti에서 사용

증상: 컴파일 에러 또는 링크 에러.

원인: -fno-rtti 플래그로 빌드 시 dynamic_cast, typeid 사용 불가.

// ❌ 잘못된 코드
IDevice* dev = getDevice();
LedDevice* led = dynamic_cast<LedDevice*>(dev);  // -fno-rtti에서 에러

// ✅ 해결 1: kind() enum 사용
if (dev->kind() == DeviceKind::Led) {
    LedDevice* led = static_cast<LedDevice*>(dev);
}

// ✅ 해결 2: variant 사용
std::variant<LedDevice, ButtonDevice> dev = getDeviceVariant();
std::visit(overloaded{
     { /* ... */ },
     { /* ... */ }
}, dev);

문제 4: 생성자에서 실패 처리 시도

증상: 생성자는 반환값이 없어 실패를 호출자에게 전달할 수 없음.

원인: 생성자 설계 오류.

// ❌ 잘못된 코드
class Sensor {
public:
    Sensor(uint8_t id) {
        if (!initHardware(id)) {
            // ??? 반환할 방법 없음
        }
    }
};

// ✅ 해결: 팩토리 함수 또는 두 단계 초기화
std::optional<Sensor> createSensor(uint8_t id) {
    Sensor s;
    if (!s.init(id)) return std::nullopt;
    return s;
}

문제 5: expected/optional 반환값 무시

증상: 실패를 검사하지 않고 값만 사용해 런타임 오류.

원인: [[nodiscard]] 없이 반환값을 무시함.

// ❌ 위험
readConfig(path);  // 반환값 무시

// ✅ 해결: [[nodiscard]]로 강제
[[nodiscard]] std::optional<Config> readConfig(const char* path);

// 호출부에서 반드시 검사
if (auto cfg = readConfig(path)) {
    use(*cfg);
}

6. 최적화 팁

ROM/RAM 절약

기법효과비고
-fno-exceptions10~30KB ROM 절약언와인딩 테이블 제거
-fno-rtti5~15KB ROM 절약typeinfo 제거
std::variant vs 가상 상속variant가 보통 작음최대 타입 크기만 사용
std::optional vs 포인터optional이 명시적포인터는 nullptr 오버헤드 없음
reserve()재할당·fragment 감소vector 사용 시 필수

실행 시간 예측 가능성

  • 예외 경로 제거: 최악 실행 시간(WCET) 분석이 쉬워짐
  • 고정 할당: std::array, 풀 allocator로 힙 할당 제거
  • 인라인: 작은 함수는 inline 또는 __attribute__((always_inline))로 호출 오버헤드 제거

빌드 플래그 예시

# ARM GCC 임베디드 타겟
arm-none-eabi-g++ -std=c++17 \
  -fno-exceptions -fno-rtti \
  -ffunction-sections -fdata-sections \
  -Wl,--gc-sections \
  -Os \
  -o firmware.elf main.cpp
  • -ffunction-sections -fdata-sections: 미사용 코드 제거(링크 시 --gc-sections와 함께)
  • -Os: 크기 최적화

에러 처리 방식별 비교

방식ROM런타임 비용에러 정보타입 안전
예외큼 (언와인딩)실패 시 높음OO
std::optional작음if 검사만X (없음만)O
std::expected작음if 검사만OO
bool + out작음최소제한적X
에러 코드 (enum)최소최소O

7. 프로덕션 패턴

패턴 1: Result 타입 (expected 호환)

C++23 미지원 환경에서는 std::variant<T, E>로 Result 타입을 구현합니다.

template<typename T, typename E>
using Result = std::variant<T, E>;

template<typename E>
struct Err { E value; };

template<typename T, typename E>
bool isOk(const Result<T, E>& r) {
    return std::holds_alternative<T>(r);
}

template<typename T, typename E>
T& unwrap(Result<T, E>& r) {
    return std::get<T>(r);
}

패턴 2: 에러 코드 체인

여러 단계에서 실패할 수 있는 초기화를 체인으로 처리합니다.

bool initSystem() {
    if (!initClock()) return false;
    if (!initGpio()) return false;
    if (!initUart()) return false;
    if (!initSensors()) return false;
    return true;
}

패턴 3: 상태 머신 (variant)

유한 상태를 variant로 표현하면 타입 안전하고 exhaustive 처리됩니다.

struct Idle {};
struct Running { uint32_t start_tick; };
struct Error { uint8_t code; };

using State = std::variant<Idle, Running, Error>;

State transition(const State& s, Event e) {
    return std::visit(overloaded{
        [e](const Idle&) -> State {
            if (e == Event::Start) return Running{getTick()};
            return Idle{};
        },
        [e](const Running& r) -> State {
            if (e == Event::Stop) return Idle{};
            if (e == Event::Fault) return Error{1};
            return Running{r.start_tick};
        },
         -> State { return Idle{}; }
    }, s);
}

패턴 4: 스택 전용 컨테이너

힙 할당을 피하기 위해 고정 용량 스택 컨테이너를 사용합니다.

template<typename T, size_t N>
class StaticVector {
    std::array<T, N> data_;
    size_t size_ = 0;

public:
    bool push_back(const T& v) {
        if (size_ >= N) return false;
        data_[size_++] = v;
        return true;
    }
    size_t size() const { return size_; }
    T& operator { return data_[i]; }
};

8. 구현 체크리스트

빌드 설정

  • -fno-exceptions 플래그 추가
  • -fno-rtti 플래그 추가
  • -Os 또는 -O2 최적화 레벨 확인
  • -ffunction-sections -fdata-sections + --gc-sections (크기 최소화)

코드 규칙

  • throw, try, catch 사용 금지
  • dynamic_cast, typeid 사용 금지
  • 생성자 실패 시 팩토리 함수 또는 init() 사용
  • vector/string 사용 시 reserve() 호출
  • optional/expected 반환 시 [[nodiscard]] 적용

테스트

  • 실패 경로 단위 테스트 (nullopt, error 반환)
  • 메모리 사용량 프로파일링
  • WCET 분석 (실시간 시스템인 경우)

9. 정리

항목요약
-fno-exceptions반환값·optional·expected·두 단계 초기화로 오류 전달
-fno-rttivariant·visit·가상 함수·태그로 타입 분기
STLreserve·고정 버퍼·할당 최소화로 예외 경로 회피
생성자팩토리 함수 또는 init()으로 실패 처리
프로덕션Result 타입·에러 체인·상태 머신·StaticVector

42-1로 제약된 환경에서도 현대 C++ 스타일로 안전한 코드를 짜는 기초를 다뤘습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ 직접적인 하드웨어 제어: volatile, 메모리 맵 I/O, 인터럽트 서비스 루틴 [#42-2]
  • C++23 핵심 기능 완벽 가이드 | std::expected·mdspan
  • C++ volatile 완벽 가이드 | MMIO·ISR·메모리 맵 레지스터·atomic과의 차이 [실전]

실전 체크리스트

실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.

코드 작성 전

  • 이 기법이 현재 문제를 해결하는 최선의 방법인가?
  • 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
  • 성능 요구사항을 만족하는가?

코드 작성 중

  • 컴파일러 경고를 모두 해결했는가?
  • 엣지 케이스를 고려했는가?
  • 에러 처리가 적절한가?

코드 리뷰 시

  • 코드의 의도가 명확한가?
  • 테스트 케이스가 충분한가?
  • 문서화가 되어 있는가?

이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.


이 글에서 다루는 키워드 (관련 검색어)

임베디드 C++, 예외 RTTI 비활성화, -fno-exceptions, -fno-rtti, std::optional, std::expected, std::variant 등으로 검색하시면 이 글이 도움이 됩니다.

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. 임베디드·시스템 프로그래밍에서 예외와 RTTI를 끄고, 오류 처리·다형성을 대체 방식으로 구현하는 방법을 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. std::expected는 C++17에서 쓸 수 있나요?

A. std::expected는 C++23에서 표준화되었습니다. C++17/20에서는 std::variant<T, E> 또는 tl::expected 같은 서드파티 라이브러리를 사용할 수 있습니다.

Q. 예외를 끄면 STL을 아예 못 쓰나요?

A. 아닙니다. vector, string, map 등은 -fno-exceptions에서도 사용 가능합니다. 다만 할당 실패 시 std::terminate로 갈 수 있으므로, reserve()로 재할당을 피하거나 고정 버퍼를 사용하는 것이 안전합니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

한 줄 요약: 예외·RTTI 없이 optional·에러 코드로 제약 환경에서 안전한 C++을 쓸 수 있습니다. 다음으로 volatile·메모리 맵 I/O(#42-2)를 읽어보면 좋습니다.

다음 글: [실전 도메인 #42-2] 직접적인 하드웨어 제어: volatile, 메모리 맵 I/O, 인터럽트 서비스 루틴(ISR)

이전 글: [안정성 확보 #41-3] Fuzz Testing: 예상치 못한 입력값으로 프로그램의 견고함 테스트하기


관련 글

  • C++ 직접적인 하드웨어 제어: volatile, 메모리 맵 I/O, 인터럽트 서비스 루틴 [#42-2]
  • C++ noexcept 완벽 가이드 | 예외 계약·이동 최적화·프로덕션 패턴 [#42-1]
  • C++ 리눅스 시스템 프로그래밍 | 시스템 콜 호출과 커널 인터페이스 이해 [#42-3]
  • C++ volatile 완벽 가이드 | MMIO·ISR·메모리 맵 레지스터·atomic과의 차이 [실전]
  • C++ 정적 분석 도구 통합: Clang-Tidy와 Cppcheck로 코드 퀄리티 강제하기 [#41-1]