C 상태 머신 구현
상태 머신(Finite State Machine)은 임베디드 펌웨어에서 프로토콜 파싱, 모터 제어, UI 흐름 등을 구현하는 핵심 패턴이다. 상태와 이벤트의 조합으로 동작을 결정한다.
switch-case 방식
가장 직관적인 구현이다.
typedef enum { ST_IDLE, ST_CONNECTING, ST_CONNECTED, ST_ERROR } state_t;
typedef enum { EV_CONNECT, EV_CONNECTED, EV_DISCONNECT, EV_TIMEOUT } event_t;
static state_t state = ST_IDLE;
void fsm_handle(event_t ev) {
switch (state) {
case ST_IDLE:
if (ev == EV_CONNECT) {
start_connection();
state = ST_CONNECTING;
}
break;
case ST_CONNECTING:
if (ev == EV_CONNECTED) {
state = ST_CONNECTED;
} else if (ev == EV_TIMEOUT) {
log_error("connect timeout");
state = ST_ERROR;
}
break;
case ST_CONNECTED:
if (ev == EV_DISCONNECT) {
close_connection();
state = ST_IDLE;
}
break;
case ST_ERROR:
if (ev == EV_CONNECT) {
start_connection();
state = ST_CONNECTING;
}
break;
}
}
상태가 5개 이하일 때 적합하다. 그 이상이면 switch가 비대해지고 상태-이벤트 조합을 빠뜨리기 쉽다.
State Object 패턴
각 상태를 함수 포인터 구조체로 표현한다. GoF의 State 패턴을 C 구조체로 구현한 것이다. 상태별 enter/exit 라이프사이클, 커맨드 핸들러, 주기적 tick을 구조체 멤버로 가진다.
상태 인터페이스
typedef struct State State;
typedef State *StatePtr;
struct State {
void (*enter)(StatePtr *current, uint8_t state_from);
void (*exit)(StatePtr *current, uint8_t state_to);
void (*tick)(StatePtr *current, const void *context);
void (*on_connect)(StatePtr *current);
void (*on_disconnect)(StatePtr *current);
void (*on_stop)(StatePtr *current);
uint8_t id;
};
이중 포인터(StatePtr *current)가 핵심이다. 핸들러 안에서 *current = &other_state로 직접 상태를 전이한다. 전이 로직이 상태 내부에 캡슐화된다.
상태 전이 함수
static void (*state_changed_cb)(uint8_t from, uint8_t to);
void change_state(StatePtr *current, StatePtr next) {
// 1. 현재 상태 exit
if ((*current)->exit)
(*current)->exit(current, next->id);
// 2. 콜백 (로깅, BLE notify 등)
if (state_changed_cb)
state_changed_cb((*current)->id, next->id);
// 3. 포인터 교체
uint8_t from = (*current)->id;
*current = next;
// 4. 새 상태 enter
if ((*current)->enter)
(*current)->enter(current, from);
}
전이 순서가 보장된다: exit → 콜백 → 포인터 교체 → enter. 어떤 이벤트로 전이하든 exit/enter가 반드시 실행된다.
상태 구현 (파일 단위 분리)
각 상태를 별도 .c 파일로 분리한다. 상태가 자기 완결적이다.
idle_state.c:
static State idle_state;
static void enter(StatePtr *current, uint8_t from) {
led_off();
stop_timer();
}
static void on_connect(StatePtr *current) {
start_connection();
change_state(current, get_connecting_state());
}
void init_idle_state(void) {
idle_state.id = ST_IDLE;
idle_state.enter = enter;
idle_state.exit = NULL;
idle_state.tick = NULL;
idle_state.on_connect = on_connect;
idle_state.on_disconnect = NULL;
idle_state.on_stop = NULL;
}
StatePtr get_idle_state(void) { return &idle_state; }
void transition_to_idle(StatePtr *current) {
change_state(current, &idle_state);
}
connecting_state.c:
static State connecting_state;
static uint32_t timeout_count;
static void enter(StatePtr *current, uint8_t from) {
timeout_count = 0;
led_blink_start();
}
static void exit(StatePtr *current, uint8_t to) {
led_blink_stop();
}
static void tick(StatePtr *current, const void *context) {
timeout_count++;
if (timeout_count > CONNECT_TIMEOUT_TICKS) {
transition_to_error(current);
return;
}
if (is_connected()) {
transition_to_connected(current);
}
}
static void on_stop(StatePtr *current) {
transition_to_idle(current);
}
void init_connecting_state(void) {
connecting_state.id = ST_CONNECTING;
connecting_state.enter = enter;
connecting_state.exit = exit;
connecting_state.tick = tick;
connecting_state.on_connect = NULL;
connecting_state.on_disconnect = NULL;
connecting_state.on_stop = on_stop;
}
각 상태 파일에 transition_to_* 함수를 둔다. 다른 상태에서 transition_to_connecting(current)만 호출하면 된다.
메인 루프 연동
static StatePtr current_state;
void controller_init(void) {
init_idle_state();
init_connecting_state();
init_connected_state();
init_error_state();
current_state = get_idle_state();
}
void controller_task(void *param) {
while (1) {
// 커맨드 처리
command_t cmd;
if (xQueueReceive(cmd_queue, &cmd, 0) == pdTRUE) {
switch (cmd.type) {
case CMD_CONNECT:
if (current_state->on_connect)
current_state->on_connect(¤t_state);
break;
case CMD_DISCONNECT:
if (current_state->on_disconnect)
current_state->on_disconnect(¤t_state);
break;
case CMD_STOP:
if (current_state->on_stop)
current_state->on_stop(¤t_state);
break;
}
}
// tick 실행
if (current_state->tick)
current_state->tick(¤t_state, NULL);
vTaskDelay(pdMS_TO_TICKS(10));
}
}
커맨드 디스패치는 current_state->handler(¤t_state) 한 줄이다. 현재 상태가 무엇인지 메인 루프는 알 필요 없다.
왜 이 패턴이 좋은가
상태 추가 = 파일 추가. 기존 코드를 수정하지 않는다.
src/states/
├── state.h // State 구조체 정의
├── state.c // change_state()
├── idle_state.c
├── connecting_state.c
├── connected_state.c
└── error_state.c // 새 상태 추가 시 이 파일만 생성
- enter/exit 보장: 전이 경로에 관계없이 자원 초기화/정리가 항상 실행된다
- tick: 주기적 감시(타임아웃, 센서 체크)를 상태 내부에 캡슐화한다. polling 기반 임베디드에 적합하다
- NULL 핸들러: 해당 상태에서 처리하지 않는 이벤트는 NULL로 두면 자연스럽게 무시된다
- 파일 분리: 상태 간 커플링이
transition_to_*호출로만 연결된다. 컴파일 단위가 분리되어 빌드 시간도 줄어든다 - 상태별 로컬 데이터:
static변수로 해당 상태에서만 사용하는 데이터(타이머 카운트, 재시도 횟수)를 관리할 수 있다
이벤트 큐 기반 패턴
ISR에서 이벤트를 생성하고, 메인 루프에서 FSM이 처리하는 구조다. 어떤 FSM 방식과도 조합할 수 있다.
#define EVT_QUEUE_SIZE 16
static event_t evt_queue[EVT_QUEUE_SIZE];
static volatile uint32_t evt_head = 0;
static volatile uint32_t evt_tail = 0;
// ISR에서 호출
void fsm_post_event(event_t ev) {
uint32_t next = (evt_head + 1) & (EVT_QUEUE_SIZE - 1);
if (next == evt_tail) return; // 큐 꽉 참
evt_queue[evt_head] = ev;
evt_head = next;
}
// 메인 루프
void fsm_run(void) {
while (evt_head != evt_tail) {
event_t ev = evt_queue[evt_tail];
evt_tail = (evt_tail + 1) & (EVT_QUEUE_SIZE - 1);
fsm_handle(ev);
}
}
ISR 안에서 상태 전이를 직접 수행하지 않는다. ISR은 이벤트만 큐에 넣고, FSM 처리는 메인 컨텍스트에서 한다.
switch-case vs State Object
| switch-case | State Object | |
|---|---|---|
| 상태 추가 | switch에 case 추가 | 파일 추가 (기존 코드 수정 없음) |
| enter/exit/tick | 별도 구현 필요 | 구조체에 내장 |
| 상태별 복잡한 로직 | switch 비대화 | 파일 단위로 깔끔 |
| 상태별 로컬 데이터 | 불가 (전역 변수 필요) | static 변수로 캡슐화 |
| 적합 규모 | 상태 5개 이하 | 5개 이상 |
상태가 적고 로직이 단순하면 switch-case, 그 외에는 State Object를 사용한다.
메모
- State Object의 이중 포인터 패턴:
StatePtr *current를 핸들러에 넘기는 이유는 핸들러 안에서*current를 변경해야 호출자의 포인터가 업데이트되기 때문이다. 단일 포인터를 넘기면 로컬 복사본만 변경된다 switch-case에서default:없이-Wswitch로 컴파일하면, enum에 새 상태를 추가했을 때 처리 누락을 경고로 잡을 수 있다- State Object에서 디버깅 시 현재 상태를 확인하려면
id필드를 사용한다. 함수 포인터 주소만으로는 상태를 식별하기 어렵다 - 테이블 구동 방식(
transition_t table[STATE_COUNT][EVENT_COUNT])도 있다. 모든 상태-이벤트 조합을 2차원 배열에 명시해서 빠뜨린 조합이 없음을 보장한다. 안전 인증(ISO 26262 등)에서 조합 완전성을 증명해야 할 때 외에는 실용성이 낮다 - 가드 조건(guard condition)이 많아지면 상태를 더 세분화해야 한다는 신호다. 가드를 남용하면 상태 머신의 장점이 사라진다
- 계층형 상태 머신(HSM)은 공통 동작을 부모 상태로 올려 중복을 줄인다. QP/C 프레임워크가 대표적이다