C 상태 머신 구현

CEmbedded

상태 머신(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(&current_state);
                break;
            case CMD_DISCONNECT:
                if (current_state->on_disconnect)
                    current_state->on_disconnect(&current_state);
                break;
            case CMD_STOP:
                if (current_state->on_stop)
                    current_state->on_stop(&current_state);
                break;
            }
        }

        // tick 실행
        if (current_state->tick)
            current_state->tick(&current_state, NULL);

        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

커맨드 디스패치는 current_state->handler(&current_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-caseState 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 프레임워크가 대표적이다