본문으로 건너뛰기
Embedded C++ for Real Systems · 24/41

임베디드 에러 처리 패턴 — Result·errno·optional 비교

· Hawk · 4분 읽기

#한 줄 요약

“에러 처리는 세 가지 결정으로 정리됩니다.” 어디서 발견할지, 어떻게 전파할지, 어떻게 복구할지를 정합니다.

#어떤 문제를 푸는가

Part 3-05에서 도구를 소개했습니다. 이 글은 그것을 엮은 전체 시스템 이야기입니다.

대부분의 임베디드 프로젝트가 에러 처리에 일관성을 갖추지 못합니다.

  • 어떤 곳은 error code, 어떤 곳은 bool, 어떤 곳은 exception을 씁니다.
  • 전파 방식이 함수마다 다릅니다.
  • fatal과 recoverable 구분이 모호합니다.
  • 로깅, 알람, 무시 사이의 결정이 산발적입니다.

시스템 차원의 패턴이 필요합니다. 이 글은 네 가지 결정으로 정리합니다.

#결정 1 — Fatal vs Recoverable

모든 에러는 둘 중 하나로 분류됩니다.

Fatal은 시스템을 유지할 수 없는 경우입니다.

  • Heap corruption
  • Stack overflow
  • Hardware bus fault
  • Watchdog reset 임박

Recoverable은 처리 가능한 경우입니다.

  • Sensor reading invalid
  • Buffer full
  • Timeout
  • Invalid user input

각각 다른 반응 전략을 가집니다.

// Fatal — 즉시 복구 시도 또는 안전 모드
[[noreturn]] void fatal_error(const char* msg) {
__disable_irq();
log_to_persistent_storage(msg);
NVIC_SystemReset(); // 또는 안전 상태로 강제
}
// Recoverable — 호출자에 보고
tl::expected<Data, Error> read_sensor() {
if (!ready()) return tl::unexpected(Error::Timeout);
return parse_data();
}

#결정 2 — Error code 표준화

프로젝트 전체에서 통일된 에러 종류를 정의합니다.

// errors.h — 프로젝트 전체 공유
enum class Error : uint16_t {
// 0-99: 공통
Ok = 0,
InvalidParam,
NotInitialized,
NotImplemented,
// 100-199: 시간 관련
Timeout = 100,
NotReady,
// 200-299: 메모리
OutOfMemory = 200,
BufferOverflow,
AlignmentError,
// 300-399: I/O
IoError = 300,
DeviceBusy,
DeviceNotFound,
// 400-499: protocol
ProtocolError = 400,
ChecksumMismatch,
InvalidResponse,
// 500-599: domain-specific
SensorFault = 500,
CalibrationFailed,
};
constexpr const char* error_name(Error e) {
switch (e) {
case Error::Ok: return "Ok";
case Error::InvalidParam: return "InvalidParam";
// ...
default: return "Unknown";
}
}

숫자 범위로 분류해 두면 새 에러를 해당 범위 안에서 추가할 수 있습니다.

#결정 3 — Result type 통일

모든 함수가 같은 Result 타입을 반환하면 일관성이 생깁니다.

// 프로젝트 전체 표준
template<typename T>
using Result = tl::expected<T, Error>;
// 사용
Result<int> divide(int a, int b);
Result<Data> read_sensor();
Result<void> write_register(uint8_t addr, uint8_t value); // 반환값 없음
// void 특수
inline tl::unexpected<Error> err(Error e) { return tl::unexpected(e); }

함수 호출은 다음과 같이 이어집니다.

auto result = read_sensor()
.and_then([](Data d) -> Result<float> {
return process(d);
})
.or_else([](Error e) -> Result<float> {
if (e == Error::Timeout) return 0.0f; // 기본값
return err(e); // 다른 에러는 전파
});

#결정 4 — 에러 chain 전파

저수준 에러를 고수준 에러로 wrap해 디버깅 정보를 보존합니다.

// 옵션 1 — 단순 변환 (정보 손실)
Result<float> read_temperature() {
auto raw = read_register(0x10);
if (!raw) return err(Error::SensorFault); // 원본 에러 손실
return celsius(*raw);
}
// 옵션 2 — Context 추가
struct ErrorContext {
Error code;
const char* function;
int line;
Error cause; // 원인 에러
};
#define ERR_CTX(e, c) ErrorContext{(e), __func__, __LINE__, (c)}
Result<float> read_temperature() {
auto raw = read_register(0x10);
if (!raw) return tl::unexpected(ERR_CTX(Error::SensorFault, raw.error()));
return celsius(*raw);
}

옵션 2는 디버깅 정보가 풍부하지만 코드 크기가 늘어납니다. 트레이드오프입니다.

#패턴 — Try Macro

C++23 이전에는 반환 후 검사가 반복됩니다. 매크로로 단순화할 수 있습니다.

#define TRY(expr) \
({ \
auto _result = (expr); \
if (!_result) return tl::unexpected(_result.error()); \
std::move(*_result); \
})
// 사용
Result<float> process() {
auto raw = TRY(read_register(0x10));
auto celsius = TRY(convert(raw));
return celsius * 1.8f + 32.0f;
}

Rust의 ? operator와 유사한 형태입니다. GCC extension인 statement expression을 쓰므로 gnu++ 모드에서만 동작합니다.

C++23의 monadic chain(and_then)으로 표준적으로 대체할 수 있습니다.

#Logging 통합

에러 발생 시 위치, 내용, 시점을 함께 기록합니다. 로그가 디버깅의 1차 정보입니다.

// 매크로로 자동 file:line 정보
#define LOG_ERROR_RESULT(result) \
do { \
if (!(result)) { \
log_error("%s:%d: error %s in %s", \
__FILE__, __LINE__, \
error_name((result).error()), __func__); \
} \
} while (0)
auto r = risky_operation();
LOG_ERROR_RESULT(r);
if (!r) return err(r.error());

C++20의 std::source_location을 함수 매개변수의 default로 두면 위치가 자동으로 채워집니다.

template<typename T>
void log_if_error(const Result<T>& r,
std::source_location loc = std::source_location::current()) {
if (!r) {
log_error("%s:%d in %s: error %s",
loc.file_name(), loc.line(), loc.function_name(),
error_name(r.error()));
}
}

#패턴 — 우아한 복구

Result<Data> read_with_retry(int max_retries = 3) {
for (int i = 0; i < max_retries; ++i) {
auto result = read_sensor();
if (result) return result;
if (result.error() == Error::Timeout) {
// 잠시 대기 후 재시도
sleep_ms(10);
continue;
}
// 다른 에러는 즉시 반환
return result;
}
return err(Error::Timeout); // 최종 실패
}
Result<float> read_or_default() {
return read_temperature()
.or_else([](Error e) -> Result<float> {
if (e == Error::Timeout || e == Error::NotReady) {
return last_valid_temperature(); // fallback
}
return err(e);
});
}

복구 가능한 에러는 재시도나 fallback으로 처리하고, 진짜 에러는 그대로 전파합니다.

#State Machine + 에러

상태 머신에서는 에러를 상태 전이 트리거로 사용합니다.

enum class DeviceState { Idle, Initializing, Ready, Error, Recovering };
void device_loop() {
static DeviceState state = DeviceState::Idle;
switch (state) {
case DeviceState::Idle:
if (auto r = init(); r) state = DeviceState::Ready;
else state = DeviceState::Error;
break;
case DeviceState::Ready:
if (auto r = process(); !r) {
log_error("process failed");
state = DeviceState::Error;
}
break;
case DeviceState::Error:
attempt_recovery();
state = DeviceState::Recovering;
break;
case DeviceState::Recovering:
if (auto r = recovery_check(); r) state = DeviceState::Ready;
break;
}
}

자세한 state machine은 Part 4-06에서 다룹니다.

#Fatal error handling

fatal_error는 마지막 수단입니다. 호출 직전까지 최대한 정보를 보존합니다.

struct CrashInfo {
uint32_t magic; // 0xCAFEBABE
uint32_t reset_count;
Error last_error;
uint32_t stack_pointer;
uint32_t program_counter;
char message[64];
uint32_t crc;
};
static_assert(sizeof(CrashInfo) <= 128);
// 비휘발성 메모리 (battery-backed RAM, FRAM, ...)
__attribute__((section(".noinit")))
CrashInfo g_crash_info;
[[noreturn]] void fatal_error(const char* msg, Error code) {
__disable_irq();
g_crash_info.magic = 0xCAFEBABE;
g_crash_info.last_error = code;
g_crash_info.stack_pointer = __get_MSP();
// current PC 등...
strncpy(g_crash_info.message, msg, sizeof(g_crash_info.message) - 1);
g_crash_info.crc = calculate_crc(&g_crash_info);
NVIC_SystemReset();
}
// 부팅 시
void check_crash_info() {
if (g_crash_info.magic == 0xCAFEBABE &&
g_crash_info.crc == calculate_crc(&g_crash_info)) {
// 이전 crash 정보 있음
log_persistent("Recovered from crash: %s", g_crash_info.message);
}
}

reset 이후에도 g_crash_info가 보존되므로 반복 crash를 추적할 수 있습니다.

#인증 환경 — DO-178C, ISO 26262

인증 환경에서는 에러 처리에 추가 제약이 붙습니다.

  • 모든 에러 경로를 테스트해야 합니다.
  • recovery time을 명시하고 검증해야 합니다.
  • fail-safe behavior(예: power-off)를 정의해야 합니다.
  • 로깅이 critical 메모리에 안전하게 기록되어야 합니다.
// 인증 환경 표준 패턴
[[nodiscard]] Error perform_operation(Input* in, Output* out) {
// pre-condition
if (!in || !out) return Error::InvalidParam;
if (!is_initialized()) return Error::NotInitialized;
// operation
auto result = do_work(in, out);
// post-condition
if (result == Error::Ok && !validate_output(out)) {
return Error::InternalError;
}
return result;
}

pre/post-condition을 명시하고 모든 분기 코드를 커버해야 합니다.

#자주 보는 함정과 안티패턴

#1. Silent failure

bool foo(); // false 반환만 — 이유 모름

enum과 [[nodiscard]]를 함께 씁니다.

#2. 에러를 magic value로

int read(); // -1 반환 = 에러 (?)

-1이 valid 값일 수 있습니다. 명시적으로 optional이나 expected를 사용합니다.

#3. 예외와 error code 혼용

void foo() {
throw std::exception(); // -fno-exceptions에서 abort
}
ErrorCode bar();

프로젝트 전체에서 통일합니다. exception을 끄면 어디에서도 throw하지 않습니다.

#4. 에러 로그 너무 많음

if (auto r = read(); !r) {
log_error("read failed"); // 매 호출
}

timeout처럼 빈번한 에러는 logging level로 통제합니다.

#5. fatal_error가 너무 흔함

recoverable한 경우까지 fatal로 처리하면 시스템이 자주 reset됩니다. 분류를 신중히 합니다.

#6. try/catch + -fno-exceptions

try { foo(); } catch (...) {} // 컴파일 에러

예외를 끄면 try를 금지합니다.

#측정 — 에러 처리 패턴의 코드 영향

같은 함수를 다른 에러 처리 방식으로 비교합니다.

# bool 반환
bool f() { return true; }
크기: 8 B
# error code
ErrorCode f() { return ErrorCode::Ok; }
크기: 10 B
# optional<T>
std::optional<int> f() { return 42; }
크기: 16 B (return value + has_value flag)
# expected<T, E>
tl::expected<int, ErrorCode> f() { return 42; }
크기: 18 B
# 예외 (-fexceptions)
int f() { throw std::runtime_error("err"); }
크기: 312 B + unwind table 추가

예외가 압도적으로 크고, optional과 expected는 거의 동일합니다.

#정리

  • 에러 처리는 네 가지 결정으로 정리됩니다 — fatal과 recoverable 구분, error code 표준화, Result type 통일, chain 전파.
  • 프로젝트 전체에서 Result<T> = tl::expected<T, Error>로 통일합니다.
  • Try macro나 and_then으로 체인을 단순화합니다.
  • Logging은 source_location이나 매크로로 통합합니다.
  • Recovery 패턴은 retry, fallback, state machine 전이로 구성됩니다.
  • Fatal error는 비휘발성 메모리에 정보를 보존한 뒤 reset합니다.

#관련 항목

#다음 글

Part 3-07: std::expected (C++23) — 값 또는 에러를 표현하는 C++23 표준 Result 타입의 상세 사용법을 다룹니다.

Embedded C++ for Real Systems · 25 of 41

  1. 1Embedded C++ for Real Systems — 임베디드 모던 C++ 시리즈 소개
  2. 2임베디드 C++ vs C — 런타임·코드 크기·ABI 관점 비교
  3. 3임베디드 C++ 컴파일러 플래그 분석 — -fno-rtti·-fno-exceptions·-Os
  4. 4임베디드 C++ 런타임 요구사항 — libstdc++·newlib·crt0 분석
  5. 5C++ 코드 크기 분석 — 가상 함수·템플릿·예외 비용 추적
  6. 6C++ ABI 호환성 — Itanium ABI·name mangling·vtable 레이아웃
  7. 7C++ 스타트업 코드 분석 — .init_array·전역 생성자 호출 순서
  8. 8임베디드 C++ 링커 스크립트 — vtable·정적 객체 배치 추적
  9. 9임베디드 C++ 표준 선택 가이드 — C++11/14/17/20/23 트레이드오프
  10. 10임베디드 RAII 기초 — 리소스 안전성과 결정적 소멸 보장
  11. 11임베디드 RAII 실전 패턴 — Lock·Pin·DMA·Power 관리
  12. 12constexpr 기초와 임베디드 적용 — 컴파일 타임 계산 활용
  13. 13constexpr 고급 활용 — 룩업 테이블·CRC·해시 컴파일 타임 생성
  14. 14consteval과 constinit 분석 — C++20 컴파일 타임 강제 메커니즘
  15. 15임베디드 Templates 기초 — 타입 안전과 코드 재사용 분석
  16. 16Template 비용 분석 — 코드 폭증·인스턴스화·디버그 정보 측정
  17. 17CRTP 패턴 분석 — vtable 없는 정적 다형성
  18. 18Type Traits 임베디드 활용 — SFINAE·is_pod·컴파일 타임 검사
  19. 19C++20 Concepts 활용 — 템플릿 제약과 가독성 개선
  20. 20동적 할당 없는 임베디드 C++ — placement new·정적 객체·풀
  21. 21Custom Allocator 기초 — std::allocator 인터페이스 분석
  22. 22Pool Allocator 구현 — Fixed-Size Block과 O(1) 보장
  23. 23std::pmr 임베디드 활용 — Polymorphic Memory Resource 분석
  24. 24No-Exception C++ 설계 — 코드 크기·결정성 트레이드오프
  25. 25임베디드 에러 처리 패턴 — Result·errno·optional 비교
  26. 26std::expected 분석 — C++23 결과 타입과 에러 전파
  27. 27No-RTTI C++ 설계 — dynamic_cast 제거와 정적 타입 분기
  28. 28임베디드 스마트 포인터 선택 — unique·shared·custom 비교
  29. 29임베디드 C++ 소유권 모델 — single·shared·borrow 패턴
  30. 30Intrusive Containers 분석 — 동적 할당 없는 컨테이너 설계
  31. 31ETL 라이브러리 분석 — Embedded Template Library의 STL 대체
  32. 32임베디드 Lock-free 기초 — atomic·memory ordering·CAS
  33. 33Lock-free Container 구현 — SPSC Queue·Ring Buffer
  34. 34Type-safe Flags 패턴 — Enum Class·Strong Typedef·Tag
  35. 35임베디드 State Machine 패턴 — Variant·Visitor·Table-driven 비교
  36. 36Compile-time FSM 구현 — 템플릿으로 상태 전이 검증
  37. 37Singleton 대안 패턴 — Service Locator·Static Init·Phantom
  38. 38MMIO Register 추상화 — 타입 안전한 비트 필드 접근
  39. 39GPIO 추상화 패턴 — Template·Concept으로 보드 독립성
  40. 40Peripheral 추상화 — UART·SPI·I2C 공통 인터페이스 설계
  41. 41임베디드 HAL 설계 패턴 — Static·Dynamic·Hybrid 비교