Implementing State Machines in C: From Core Concepts to Practical Programming

For friends engaged in embedded or C language development, have you often encountered this headache: when handling key presses, serial protocols, or device state transitions, you end up writing a bunch of nested if-else or switch-case statements, with logic tangled like a maze, making subsequent code modifications and bug tracking a nightmare?

In fact, there is a super practical programming approach to solve this “multi-state, multi-event” problem — the state machine. Its core concept is particularly simple: let the program be in only one “state” at any given time, such as the device’s “idle state” or “receiving data state”, and then trigger state transitions through external events (like receiving data, key presses, or timeouts), with each state only handling its own responsibilities.

Implementing a state machine in C not only makes it lightweight and efficient but also clarifies chaotic logic, making the code more readable and maintainable, whether for protocol parsing, key handling, or device control. Next, I will guide you step by step from core concepts to practical steps to master C language state machine programming.

A state machine (Finite State Machine, FSM) is a programming paradigm that manages program behavior through “states”, with the core idea being: the program is in only one “state” at any given time, triggering state transitions by receiving “events” and executing corresponding “actions” for each state. Implementing a state machine in C is lightweight and efficient, widely used in embedded systems, protocol parsing, UI interactions, and more.

1. Core Concepts (Clarifying 3 Key Elements)

Element Definition Example (e.g., Serial Data Parsing)
State The behavior mode of the program (stable state), with fixed response logic to different events within the same state Idle state, waiting for header state, receiving data state, checking CRC state
Event External inputs or internal signals that trigger state changes (e.g., data received, timeout, key pressed) Received 0xAA (header), received data byte, timeout
Action Logic executed when entering a state, exiting a state, or processing events within a state (optional) Initializing buffer, storing data, checking CRC

Core Principles: Unique states, event-driven, predictable transitions

2. Classification of State Machines (Choosing the Right Scenario)

1. Moore Machine

  • Characteristics: Output (action) depends only on the current state, independent of triggering events.
  • Applicable Scenarios: Behavior determined by the state itself (e.g., device operating modes: standby, running, sleep).
  • Example: After entering the “running state”, regardless of which event is triggered, the “start motor” action is executed.

2. Mealy Machine

  • Characteristics: Output (action) depends on the current state + triggering event.
  • Applicable Scenarios: Different events require different logic to be executed in the same state (e.g., protocol parsing, key handling).
  • Example: In the “idle state”, receiving a “header event” transitions to the “receiving data state”, while receiving “other data events” discards the data.

3. Simple Type (Single State Variable)

  • Characteristics: Uses a single variable to store the current state, with<span>switch-case</span> or function pointer jumps.
  • Applicable Scenarios: Few states (<10), simple transition logic (e.g., short/long key presses, LED blinking control).

3. Steps to Implement State Machines in C (General Process)

Step 1: Define State and Event Enumerations (Clear Classification)

Use<span>enum</span> to manage states and events uniformly, avoiding magic numbers and improving readability.

// 1. Define state enumeration (expand based on business scenario)
typedef enum {
    STATE_IDLE,        // Idle state (initial state)
    STATE_WAIT_HEADER, // Waiting for header state
    STATE_RECV_DATA,   // Receiving data state
    STATE_CHECK_CRC,   // Checking CRC state
    STATE_ERROR        // Error state
} StateType;

// 2. Define event enumeration (signals that trigger state transitions)
typedef enum {
    EVENT_NONE,        // No event
    EVENT_RECV_BYTE,   // Received a byte
    EVENT_TIMEOUT,     // Timeout event
    EVENT_CRC_OK,      // CRC check passed
    EVENT_CRC_ERR      // CRC check failed
} EventType;

Step 2: Define State Machine Context (Store Global Information)

Encapsulate the state machine’s “current state”, “temporary data”, “configuration parameters”, etc., in a structure to avoid the proliferation of global variables and support multiple state machine instances.

#define MAX_DATA_LEN 32

typedef struct {
    StateType current_state; // Current state
    uint8_t data_buf[MAX_DATA_LEN]; // Data buffer
    uint16_t data_len;       // Length of received data
    uint8_t target_header;   // Target header (e.g., 0xAA)
    uint32_t timeout_cnt;    // Timeout counter
} FsmContext;

Step 3: Implement State Handling Functions (Core Logic)

Each state corresponds to a function, with parameters being “context” and “current event”, and the return value being the “next state” (recommended to use function pointers for flexibility and easy expansion).

3.1 Declare State Handling Function Type

// State handling function: input (context, event), output (next state)
typedef StateType (*StateHandler)(FsmContext* ctx, EventType event);

3.2 Implement Specific State Functions

// 1. Idle state handling function
StateType handle_idle(FsmContext* ctx, EventType event) {
    switch (event) {
        case EVENT_RECV_BYTE:
            // Received byte, check if it is the header
            if (ctx->data_buf[0] == ctx->target_header) {
                ctx->data_len = 1; // Record header length
                return STATE_WAIT_HEADER; // Transition to "waiting for header state"
            } else {
                return STATE_IDLE; // Not a header, remain in idle state
            }
        case EVENT_TIMEOUT:
            // Idle state timeout, no action, remain in state
            return STATE_IDLE;
        default:
            return STATE_IDLE;
    }
}

// 2. Waiting for header state handling function (assuming header occupies 2 bytes, here receiving the second byte)
StateType handle_wait_header(FsmContext* ctx, EventType event) {
    switch (event) {
        case EVENT_RECV_BYTE:
            ctx->data_len++;
            if (ctx->data_len == 2) { // Header reception complete
                return STATE_RECV_DATA; // Transition to "receiving data state"
            }
            return STATE_WAIT_HEADER; // Not complete, remain in state
        case EVENT_TIMEOUT:
            // Timeout, reset buffer, return to idle state
            ctx->data_len = 0;
            return STATE_IDLE;
        default:
            return STATE_WAIT_HEADER;
    }
}

// 3. Receiving data state handling function (assuming data length is parsed from header, simplified to fixed 8 bytes)
StateType handle_recv_data(FsmContext* ctx, EventType event) {
    switch (event) {
        case EVENT_RECV_BYTE:
            ctx->data_buf[ctx->data_len++] = /* received byte */;
            if (ctx->data_len == 2 + 8) { // Header + data total 10 bytes
                return STATE_CHECK_CRC; // Transition to "checking state"
            }
            return STATE_RECV_DATA;
        case EVENT_TIMEOUT:
            ctx->data_len = 0;
            return STATE_IDLE;
        default:
            return STATE_RECV_DATA;
    }
}

// 4. Checking CRC state handling function
StateType handle_check_crc(FsmContext* ctx, EventType event) {
    switch (event) {
        case EVENT_CRC_OK:
            // CRC passed, process data (e.g., report, parse)
            process_data(ctx->data_buf, ctx->data_len);
            ctx->data_len = 0;
            return STATE_IDLE;
        case EVENT_CRC_ERR:
            // CRC failed, enter error state
            return STATE_ERROR;
        default:
            return STATE_CHECK_CRC;
    }
}

// 5. Error state handling function
StateType handle_error(FsmContext* ctx, EventType event) {
    switch (event) {
        case EVENT_NONE:
            // Error state resets after delay
            if (/* delay ends */) {
                ctx->data_len = 0;
                return STATE_IDLE;
            }
            return STATE_ERROR;
        default:
            return STATE_ERROR;
    }
}

Step 4: Create State Transition Table (Optional, Recommended)

Use an array to store the mapping of “state → handling function” to replace nested<span>switch-case</span>, making the code clearer and easier to maintain (especially when there are many states).

// State transition table: state → corresponding handling function
const StateHandler state_table[] = {
    [STATE_IDLE] = handle_idle,
    [STATE_WAIT_HEADER] = handle_wait_header,
    [STATE_RECV_DATA] = handle_recv_data,
    [STATE_CHECK_CRC] = handle_check_crc,
    [STATE_ERROR] = handle_error
};

// Check that the state enumeration matches the length of the transition table (compile-time check)
_Static_assert(sizeof(state_table)/sizeof(StateHandler) == STATE_ERROR + 1, 
               "State table size mismatch with StateType");

Step 5: State Machine Scheduler (Drive State Machine Operation)

Provide “initialization” and “event dispatch” interfaces, allowing external calls to trigger state machine operation.

// 1. Initialize state machine
void fsm_init(FsmContext* ctx, uint8_t target_header) {
    if (ctx == NULL) return;
    ctx->current_state = STATE_IDLE; // Initial state is idle state
    ctx->data_len = 0;               // Reset buffer
    ctx->target_header = target_header; // Set target header
    ctx->timeout_cnt = 0;            // Reset timeout counter
}

// 2. Dispatch events (core scheduling function)
void fsm_dispatch(FsmContext* ctx, EventType event) {
    if (ctx == NULL || event >= EVENT_CRC_ERR + 1) return;
    
    // 1. Get the handling function for the current state
    StateHandler current_handler = state_table[ctx->current_state];
    if (current_handler == NULL) return;
    
    // 2. Execute handling function, get next state
    StateType next_state = current_handler(ctx, event);
    
    // 3. State transition (optional: add hooks for state switch actions, such as exiting current state actions, entering next state actions)
    if (next_state != ctx->current_state) {
        // Exit action for current state (e.g., log, release resources)
        exit_state_action(ctx->current_state);
        // Update current state
        ctx->current_state = next_state;
        // Enter action for next state (e.g., initialize parameters, start timer)
        enter_state_action(next_state);
    }
}

// Optional: State switch hook functions (uniformly handle state enter/exit actions)
void enter_state_action(StateType state) {
    switch (state) {
        case STATE_RECV_DATA:
            // Enter receiving data state, start timeout timer
            start_timeout_timer();
            break;
        // Other state enter actions...
        default: break;
    }
}

void exit_state_action(StateType state) {
    switch (state) {
        case STATE_RECV_DATA:
            // Exit receiving data state, stop timeout timer
            stop_timeout_timer();
            break;
        // Other state exit actions...
        default: break;
    }
}

Step 6: External Calls (Trigger Events)

In the main program or interrupt service function, send events through<span>fsm_dispatch</span> to drive the state machine operation.

// Example: Trigger "received byte" event in serial receive interrupt
void uart_rx_isr(void) {
    FsmContext fsm_ctx; // Global or static variable (ensure lifecycle)
    uint8_t recv_byte = uart_read_byte(); // Read serial byte
    
    fsm_ctx.data_buf[fsm_ctx.data_len] = recv_byte; // Store in buffer
    fsm_dispatch(&fsm_ctx, EVENT_RECV_BYTE); // Dispatch "received byte" event
}

// Example: Trigger "timeout" event in timer interrupt
void timer_isr(void) {
    FsmContext fsm_ctx;
    fsm_ctx.timeout_cnt++;
    if (fsm_ctx.timeout_cnt >= 1000) { // Timeout threshold (e.g., 1ms timer, 1000=1s)
        fsm_ctx.timeout_cnt = 0;
        fsm_dispatch(&fsm_ctx, EVENT_TIMEOUT); // Dispatch "timeout" event
    }
}

// Main function initialization
int main(void) {
    FsmContext fsm_ctx;
    fsm_init(&fsm_ctx, 0xAA); // Initialize state machine, target header is 0xAA
    
    while (1) {
        // Main loop can handle non-interrupt triggered events (e.g., CRC check results)
        if (/* CRC check completed */) {
            if (/* Check passed */) {
                fsm_dispatch(&fsm_ctx, EVENT_CRC_OK);
            } else {
                fsm_dispatch(&fsm_ctx, EVENT_CRC_ERR);
            }
        }
    }
}

4. Advanced Optimization Techniques

1. Avoid Global Variables, Support Multiple State Machine Instances

Pass<span>FsmContext</span> as a parameter to all state functions and the scheduler instead of using global variables, allowing multiple independent state machines to run simultaneously (e.g., parsing protocols for two serial ports separately).

2. Simplify State Transitions: Use “State → Event → Next State” Table

For complex state machines (many states, complex transition logic), define a two-dimensional transition table that directly maps “current state + event → next state”, reducing<span>switch-case</span>:

// Transition table: [current state][event] = next state
const StateType transition_table[][EVENT_CRC_ERR + 1] = {
    [STATE_IDLE][EVENT_RECV_BYTE] = STATE_WAIT_HEADER,
    [STATE_IDLE][EVENT_TIMEOUT] = STATE_IDLE,
    [STATE_WAIT_HEADER][EVENT_RECV_BYTE] = STATE_RECV_DATA,
    [STATE_WAIT_HEADER][EVENT_TIMEOUT] = STATE_IDLE,
    // ... other state transitions
};

3. Decouple Actions from States

Encapsulate “enter state actions”, “exit state actions”, and “event handling actions” as separate functions, with state handling functions only responsible for state transitions, improving code reusability:

// Event handling action: common logic when receiving a byte
void action_recv_byte(FsmContext* ctx, uint8_t byte) {
    ctx->data_buf[ctx->data_len++] = byte;
}

// Call action in state handling function
StateType handle_recv_data(FsmContext* ctx, EventType event) {
    if (event == EVENT_RECV_BYTE) {
        action_recv_byte(ctx, /* received byte */); // Call action
        if (ctx->data_len == 10) return STATE_CHECK_CRC;
    }
    // ...
}

4. Compile-Time Optimizations (Embedded Scenarios)

  • Use<span>static inline</span> to decorate state handling functions, reducing function call overhead;
  • Use<span>__attribute__((packed))</span> (GCC) to compress space for state and event enumerations;
  • Disable unnecessary logs to reduce code size.

5. Applicable Scenarios and Considerations

Applicable Scenarios

  • Embedded devices: key handling, LED control, serial/Can protocol parsing, sensor data collection;
  • General programming: state transition logic (e.g., order status, user login process), finite state task scheduling.

Considerations

  1. Avoid State Explosion: Too many states can complicate code; prioritize splitting state machines (e.g., divide into “protocol parsing state machine” and “device control state machine”);
  2. Handle Boundary Events: Such as buffer overflow, timeout, abnormal data, to avoid state machine deadlock;
  3. Initialization and Reset: Ensure the state machine has a clear initial state and can reset to a safe state after exceptions;
  4. Thread Safety: When used in multi-threading/interruption, lock<span>FsmContext</span> (e.g., disable interrupts, mutex) to avoid concurrent access conflicts.

Conclusion

The core of the C language state machine is “state abstraction + event-driven + controllable transitions“, by defining states/events with enumerations, encapsulating context with structures, and implementing state logic with function pointers, you can write efficient and maintainable code. Beginners are advised to start with simple<span>switch-case</span> implementations and gradually transition to the advanced scheme of “state transition table + function pointers”, choosing between Moore and Mealy state machines based on actual scenarios.

Leave a Comment