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·정적 분석은 출하 전 불량 검사 역할을 합니다.
목차
- 예외 비활성화 (-fno-exceptions)
- RTTI 비활성화 (-fno-rtti)
- STL과 할당 전략
- 완전한 임베디드 C++ 예제
- 자주 발생하는 에러와 해결법
- 최적화 팁
- 프로덕션 패턴
- 구현 체크리스트
- 정리
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로 “값이 있으면 성공, 없으면 실패”를 표현합니다. loadConfig는 path가 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로 종료될 수 있음.
원인: optional이 nullopt인데 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-exceptions | 10~30KB ROM 절약 | 언와인딩 테이블 제거 |
-fno-rtti | 5~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 | 런타임 비용 | 에러 정보 | 타입 안전 |
|---|---|---|---|---|
| 예외 | 큼 (언와인딩) | 실패 시 높음 | O | O |
std::optional | 작음 | if 검사만 | X (없음만) | O |
std::expected | 작음 | if 검사만 | O | O |
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-rtti | variant·visit·가상 함수·태그로 타입 분기 |
| STL | reserve·고정 버퍼·할당 최소화로 예외 경로 회피 |
| 생성자 | 팩토리 함수 또는 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]