임베디드 State Machine 패턴 — Variant·Visitor·Table-driven 비교
#한 줄 요약
“State machine은 세 단계로 발전합니다.” enum + switch는 간단하고,
std::variant는 type-safe하며,etl::fsm은 정식 구현입니다.
#어떤 문제를 푸는가
임베디드는 state machine으로 가득합니다.
- TCP 연결 — Listen, Established, Closed
- 자판기 — Idle, HasMoney, Dispensing
- 프로토콜 파서 — Init, Header, Body, Trailer
- 미디어 플레이어 — Stopped, Playing, Paused
순진한 구현은 flag 변수와 if 분기에 의존합니다.
bool is_idle = true;bool is_playing = false;bool is_paused = false;
void play() { if (is_idle) { is_idle = false; is_playing = true; } // ...복잡, 상태 invariant 깨지기 쉬움}상태 invariant가 깨지기 쉽고 전이도 모호합니다.
State machine 패턴은 세 단계로 점진적으로 개선합니다.
#미디어 플레이어 — 예시 FSM
이 글 전반에서 미디어 플레이어 state machine을 예시로 씁니다. 세 상태와 네 이벤트의 전이는 다음과 같습니다.
#단계 1 — Enum + Switch
가장 기본 형태입니다. 직관적이고 코드도 짧습니다.
enum class State { Stopped, Playing, Paused };
State current_state = State::Stopped;
void on_play() { switch (current_state) { case State::Stopped: start_playback(); current_state = State::Playing; break; case State::Paused: resume_playback(); current_state = State::Playing; break; case State::Playing: // 이미 재생 중 — 무시 break; }}
void on_pause() { if (current_state == State::Playing) { pause_playback(); current_state = State::Paused; }}
void on_stop() { if (current_state != State::Stopped) { stop_playback(); current_state = State::Stopped; }}장점은 다음과 같습니다.
- 가장 적은 코드로 구현할 수 있습니다.
- 디버깅이 쉽습니다.
- Cortex-M0+에서도 동작합니다.
단점은 다음과 같습니다.
- 전이 로직이 여러 함수에 흩어집니다.
- 새 state를 추가하면 모든 함수를 수정해야 합니다.
- invariant 강제가 어렵습니다.
#단계 2 — std::variant + std::visit
C++17부터 가능합니다. 각 state를 type으로 표현하므로 type system이 invariant를 강제합니다.
#include <variant>
struct Stopped {};struct Playing { uint32_t position; };struct Paused { uint32_t position; };
using State = std::variant<Stopped, Playing, Paused>;
State current_state = Stopped{};
State on_play(State s) { return std::visit([](auto&& state) -> State { using T = std::decay_t<decltype(state)>; if constexpr (std::is_same_v<T, Stopped>) { start_playback(); return Playing{0}; } else if constexpr (std::is_same_v<T, Paused>) { resume_playback(state.position); return Playing{state.position}; } else { return state; // 이미 Playing } }, s);}
State on_pause(State s) { return std::visit([](auto&& state) -> State { using T = std::decay_t<decltype(state)>; if constexpr (std::is_same_v<T, Playing>) { pause_playback(); return Paused{state.position}; } return state; }, s);}
current_state = on_play(current_state);current_state = on_pause(current_state);장점은 다음과 같습니다.
- 각 state가 자기 데이터를 가집니다. 예를 들어 Playing은 position을 보유합니다.
- 전이가 명시적이며 함수가 새 state를 반환합니다.
if constexpr패턴으로 variant의 모든 type을 처리하도록 강제할 수 있어 exhaustive check가 가능합니다.
단점은 다음과 같습니다.
std::visit + if constexpr조합이 약간 복잡합니다.- 큰 state machine에서는 함수가 길어집니다.
#임베디드 — TCP 연결 state
struct Listen { uint16_t port; };struct SynReceived { Conn conn; };struct Established { Conn conn; uint32_t seq; };struct FinWait { Conn conn; };struct Closed {};
using TcpState = std::variant<Listen, SynReceived, Established, FinWait, Closed>;
TcpState handle_syn(TcpState s, const Packet& p) { return std::visit([&p](auto&& state) -> TcpState { using T = std::decay_t<decltype(state)>; if constexpr (std::is_same_v<T, Listen>) { return SynReceived{accept(state.port, p)}; } return state; }, s);}
TcpState handle_ack(TcpState s, const Packet& p) { return std::visit([&p](auto&& state) -> TcpState { using T = std::decay_t<decltype(state)>; if constexpr (std::is_same_v<T, SynReceived>) { return Established{state.conn, p.seq}; } // ... 다른 state는 ack 무시 return state; }, s);}각 state에 필요한 데이터만 두고 다른 state에는 두지 않으므로 RAM이 절약됩니다.
#단계 3 — etl::fsm
Part 4-02에서 본 ETL FSM은 대규모 FSM을 형식화할 때 적합합니다.
#include <etl/fsm.h>
// Event 정의class PlayEvent : public etl::message<1> {};class PauseEvent : public etl::message<2> {};class StopEvent : public etl::message<3> {};
// State IDenum class StateId : etl::fsm_state_id_t { Stopped, Playing, Paused,};
// FSM 클래스class MediaPlayerFsm : public etl::fsm {public: MediaPlayerFsm() : fsm(0) {} void log_transition(const char* msg) { /* */ }};
// State 구현class StoppedState : public etl::fsm_state<MediaPlayerFsm, StoppedState, (etl::fsm_state_id_t)StateId::Stopped, PlayEvent> {public: etl::fsm_state_id_t on_event(const PlayEvent&) { get_fsm_context().log_transition("Stopped → Playing"); start_playback(); return (etl::fsm_state_id_t)StateId::Playing; }
etl::fsm_state_id_t on_event_unknown(const etl::imessage&) { return STATE_ID; }};
class PlayingState : public etl::fsm_state<MediaPlayerFsm, PlayingState, (etl::fsm_state_id_t)StateId::Playing, PauseEvent, StopEvent> {public: etl::fsm_state_id_t on_event(const PauseEvent&) { pause_playback(); return (etl::fsm_state_id_t)StateId::Paused; }
etl::fsm_state_id_t on_event(const StopEvent&) { stop_playback(); return (etl::fsm_state_id_t)StateId::Stopped; }
etl::fsm_state_id_t on_event_unknown(const etl::imessage&) { return STATE_ID; }};
// 사용StoppedState stopped;PlayingState playing;PausedState paused;
etl::ifsm_state* states[] = {&stopped, &playing, &paused};
MediaPlayerFsm fsm;fsm.set_states(states, etl::size(states));fsm.start();
fsm.receive(PlayEvent{});fsm.receive(PauseEvent{});fsm.receive(StopEvent{});장점은 다음과 같습니다.
- 상태와 전이가 명시적입니다.
- Type-safe event dispatch가 가능합니다.
- unknown event 처리도 명시적으로 작성합니다.
- FSM diagram에 직접 매핑됩니다.
단점은 다음과 같습니다.
- boilerplate가 많습니다.
- 작은 FSM에는 과합니다.
#패턴 비교
| 패턴 | 코드 크기 | 표현력 | 적합 |
|---|---|---|---|
| Enum + switch | 가장 작음 | 낮음 | 3-5 state |
| std::variant + visit | 중간 | 높음 | 5-15 state |
| etl::fsm | 큼 | 매우 높음 | 15+ state, 형식 검증 |
#Hierarchical State Machine (HSM)
복잡한 시스템에서는 상태가 계층화되기도 합니다.
etl::hsm이나 Boost.SML을 사용합니다. 대부분의 임베디드는 flat FSM으로 충분합니다.
#임베디드 — Compile-time FSM
C++23의 constexpr로 컴파일 타임에 검증할 수 있습니다.
// transition table — 컴파일 타임 데이터struct Transition { StateId from; EventId event; StateId to;};
constexpr Transition transitions[] = { {StateId::Stopped, EventId::Play, StateId::Playing}, {StateId::Playing, EventId::Pause, StateId::Paused}, {StateId::Playing, EventId::Stop, StateId::Stopped}, {StateId::Paused, EventId::Play, StateId::Playing}, {StateId::Paused, EventId::Stop, StateId::Stopped},};
constexpr StateId next_state(StateId current, EventId event) { for (const auto& t : transitions) { if (t.from == current && t.event == event) return t.to; } return current;}
// 런타임 또는 컴파일 타임constexpr StateId s1 = next_state(StateId::Stopped, EventId::Play);static_assert(s1 == StateId::Playing);
StateId state = StateId::Stopped;state = next_state(state, EventId::Play);상태 전이를 table로 만들어 데이터로 다루면 검증이 쉽고, 새 전이도 table에 한 줄만 추가하면 됩니다.
자세한 compile-time FSM은 Part 4-07에서 다룹니다.
#State machine + Logging
각 전이를 자동으로 로깅할 수 있습니다.
template<typename FsmType>void transition(FsmType& fsm, StateId from, StateId to, const char* event) { log_info("FSM transition: %s -> %s on %s", to_string(from), to_string(to), event); fsm.state = to;}production debugging에서 상태 흐름을 추적할 때 매우 유용합니다.
#자주 보는 함정과 안티패턴
#1. State invariant 깨짐
bool playing = false;bool paused = false;playing = true;paused = true; // 둘 다 true? — 불가능한 상태single enum이나 variant로 invariant를 강제합니다.
#2. 전이 로직이 흩어짐
void on_play() { state = ...; }void on_pause() { state = ...; }void on_other() { state = ...; }// 한곳에 모이지 않음FSM 패턴으로 전이를 중앙화합니다.
#3. Unhandled event
switch (state) { case A: break; case B: break; // C 처리 누락}default + assert를 추가하거나 exhaustive check를 강제합니다.
#4. State에 너무 많은 데이터
모든 state가 모든 데이터를 들고 있으면 RAM이 낭비됩니다. std::variant로 state별 데이터를 분리합니다.
#5. 동시 event 처리
ISR과 main이 동시에 receive하면 race가 발생합니다. mutex나 queue를 둡니다.
#6. Hardcoded transition
새 state를 추가할 때 모든 switch를 수정해야 하므로 transition table을 data-driven으로 만듭니다.
#측정 — 패턴별 코드 크기
같은 5-state FSM 기준입니다 (STM32F4, -Os).
| 패턴 | 코드 크기 | 비고 |
|---|---|---|
| Enum + switch | ~400 B | — |
| std::variant + visit | ~800 B | variant 인스턴스 |
| etl::fsm | ~1.6 KB | 5 state class + FSM base |
작은 FSM에는 enum이, 큰 FSM에는 etl::fsm이 적합합니다.
#정리
- State machine은 세 단계로 발전합니다 — enum+switch(작음), variant(type-safe), etl::fsm(formal).
- State invariant는 variant나 single enum으로 강제합니다.
- 전이를 한 곳에 모아 중앙화합니다.
- State별 데이터를 다룰 때는 variant가 최적입니다.
- Logging을 통합하면 production debugging이 가능합니다.
- Compile-time FSM은 검증과 안전을 함께 얻습니다.
#관련 항목
- Part 4-02: ETL 라이브러리 — etl::fsm
- Part 4-07: Compile-time FSM
- Part 3-08: No-RTTI 설계 — std::variant
- GoF 20: State
#다음 글
Part 4-07: Compile-time FSM — constexpr FSM으로 컴파일 타임에 전이를 검증합니다.
Embedded C++ for Real Systems · 35 of 41
- 1Embedded C++ for Real Systems — 임베디드 모던 C++ 시리즈 소개
- 2임베디드 C++ vs C — 런타임·코드 크기·ABI 관점 비교
- 3임베디드 C++ 컴파일러 플래그 분석 — -fno-rtti·-fno-exceptions·-Os
- 4임베디드 C++ 런타임 요구사항 — libstdc++·newlib·crt0 분석
- 5C++ 코드 크기 분석 — 가상 함수·템플릿·예외 비용 추적
- 6C++ ABI 호환성 — Itanium ABI·name mangling·vtable 레이아웃
- 7C++ 스타트업 코드 분석 — .init_array·전역 생성자 호출 순서
- 8임베디드 C++ 링커 스크립트 — vtable·정적 객체 배치 추적
- 9임베디드 C++ 표준 선택 가이드 — C++11/14/17/20/23 트레이드오프
- 10임베디드 RAII 기초 — 리소스 안전성과 결정적 소멸 보장
- 11임베디드 RAII 실전 패턴 — Lock·Pin·DMA·Power 관리
- 12constexpr 기초와 임베디드 적용 — 컴파일 타임 계산 활용
- 13constexpr 고급 활용 — 룩업 테이블·CRC·해시 컴파일 타임 생성
- 14consteval과 constinit 분석 — C++20 컴파일 타임 강제 메커니즘
- 15임베디드 Templates 기초 — 타입 안전과 코드 재사용 분석
- 16Template 비용 분석 — 코드 폭증·인스턴스화·디버그 정보 측정
- 17CRTP 패턴 분석 — vtable 없는 정적 다형성
- 18Type Traits 임베디드 활용 — SFINAE·is_pod·컴파일 타임 검사
- 19C++20 Concepts 활용 — 템플릿 제약과 가독성 개선
- 20동적 할당 없는 임베디드 C++ — placement new·정적 객체·풀
- 21Custom Allocator 기초 — std::allocator 인터페이스 분석
- 22Pool Allocator 구현 — Fixed-Size Block과 O(1) 보장
- 23std::pmr 임베디드 활용 — Polymorphic Memory Resource 분석
- 24No-Exception C++ 설계 — 코드 크기·결정성 트레이드오프
- 25임베디드 에러 처리 패턴 — Result·errno·optional 비교
- 26std::expected 분석 — C++23 결과 타입과 에러 전파
- 27No-RTTI C++ 설계 — dynamic_cast 제거와 정적 타입 분기
- 28임베디드 스마트 포인터 선택 — unique·shared·custom 비교
- 29임베디드 C++ 소유권 모델 — single·shared·borrow 패턴
- 30Intrusive Containers 분석 — 동적 할당 없는 컨테이너 설계
- 31ETL 라이브러리 분석 — Embedded Template Library의 STL 대체
- 32임베디드 Lock-free 기초 — atomic·memory ordering·CAS
- 33Lock-free Container 구현 — SPSC Queue·Ring Buffer
- 34Type-safe Flags 패턴 — Enum Class·Strong Typedef·Tag
- 35임베디드 State Machine 패턴 — Variant·Visitor·Table-driven 비교
- 36Compile-time FSM 구현 — 템플릿으로 상태 전이 검증
- 37Singleton 대안 패턴 — Service Locator·Static Init·Phantom
- 38MMIO Register 추상화 — 타입 안전한 비트 필드 접근
- 39GPIO 추상화 패턴 — Template·Concept으로 보드 독립성
- 40Peripheral 추상화 — UART·SPI·I2C 공통 인터페이스 설계
- 41임베디드 HAL 설계 패턴 — Static·Dynamic·Hybrid 비교
관련 글
ETL 라이브러리 분석 — Embedded Template Library의 STL 대체
Embedded Template Library — heap 없는 STL 대체, fsm, message router 포함.
GPIO 추상화 패턴 — Template·Concept으로 보드 독립성
GPIO pin = type — 컴파일 타임에 핀 설정 검증, runtime 비용 0.
MMIO Register 추상화 — 타입 안전한 비트 필드 접근
MMIO를 type-safe하게 — volatile, bit field, register wrapper class.
이 글을 참조하는 글 (5)
- Compile-time FSM 구현 — 템플릿으로 상태 전이 검증— Embedded C++ for Real Systems
- Type-safe Flags 패턴 — Enum Class·Strong Typedef·Tag— Embedded C++ for Real Systems
- ETL 라이브러리 분석 — Embedded Template Library의 STL 대체— Embedded C++ for Real Systems
- No-RTTI C++ 설계 — dynamic_cast 제거와 정적 타입 분기— Embedded C++ for Real Systems
- 임베디드 에러 처리 패턴 — Result·errno·optional 비교— Embedded C++ for Real Systems