一、宏与状态机:从混乱到秩序

状态机这东西,说白了就是让代码按规矩办事。我早年做嵌入式协议解析时,代码里全是 if-else 嵌套,改一个状态要翻半天。后来用宏重构,整个世界清静了。

宏在状态机里能干什么?三个核心场景:

  • 状态定义:用宏给状态起名字,别用裸数字
  • 事件驱动:用宏封装事件触发逻辑
  • 状态转换表:用宏生成查表代码

先看个反面教材:

// 别这么写
if (state == 0) { ... }
else if (state == 1) { ... }
else if (state == 2) { ... }

你想想看,三个月后你还能记得 0 是啥状态?我反正记不住。

核心原则:状态机宏的本质,是把「状态编号」变成「可读的名字」,把「转换逻辑」变成「表格数据」。

二、状态机宏定义:给状态起个好名字

2.1 基础状态宏

#define STATE_IDLE       0
#define STATE_HEADER     1
#define STATE_DATA       2
#define STATE_CHECKSUM   3
#define STATE_DONE       4

嗯,这里要注意:状态值最好从 0 开始连续递增。为什么?因为后面做状态转换表时,数组下标直接对应状态编号,省去查找开销。

2.2 状态行为宏

我个人习惯把「进入状态时的操作」也封装成宏:

#define ENTER_STATE(s)   do { current_state = (s); on_entry(s); } while(0)
#define EXIT_STATE(s)    do { on_exit(s); } while(0)

这个 do-while 结构是宏的经典写法,防止展开后出问题。我在项目中吃过亏,宏展开后 if 后面跟了多个语句,结果只有第一句被条件控制——从那以后,所有多语句宏我都套 do-while。

小技巧:状态宏的名字加个前缀,比如 STM_ 或 STATE_,避免和其他变量冲突。我习惯用 STM_,因为短。

三、事件驱动宏:让状态机活起来

状态机不能光有状态,还得有事件驱动它跳转。事件驱动宏就是干这个的。

3.1 事件定义

#define EVT_NONE        0
#define EVT_RX_BYTE     1
#define EVT_TIMEOUT     2
#define EVT_ERROR       3
#define EVT_COMPLETE    4

3.2 事件处理宏

#define HANDLE_EVENT(evt) \
    do { \
        event_t e = (evt); \
        if (e != EVT_NONE) { \
            process_event(e); \
        } \
    } while(0)

#define TRIGGER_TRANSITION(src, evt, dst) \
    ((src) == current_state && (evt) == current_event ? (dst) : -1)

TRIGGER_TRANSITION 这个宏,说白了就是查表用的。它检查当前状态和事件是否匹配,匹配就返回目标状态,不匹配返回 -1。我在做串口协议解析器时,就用这个宏配合状态转换表,代码量直接砍半。

注意:事件宏的参数不要有副作用。比如 HANDLE_EVENT(get_byte()),如果 get_byte 有内部状态变化,宏展开多次调用就会出问题。我曾经被这个坑过,调试了一下午才发现是宏展开导致函数调了两次。

四、状态转换表宏:用数据代替逻辑

这是状态机设计的精髓。把转换关系写成表格,代码就变成了查表,清晰又好维护。

4.1 转换表结构

typedef struct {
    uint8_t src_state;
    uint8_t event;
    uint8_t dst_state;
    void (*action)(void);
} trans_t;

4.2 表格定义宏

#define TRANS_ENTRY(src, evt, dst, act) \
    { .src_state = (src), .event = (evt), .dst_state = (dst), .action = (act) }

#define TRANS_TABLE_END \
    { .src_state = 0xFF, .event = 0xFF, .dst_state = 0xFF, .action = NULL }

用宏定义表格条目,比手写结构体初始化清晰多了。你看:

const trans_t protocol_trans[] = {
    TRANS_ENTRY(STATE_IDLE,     EVT_RX_BYTE,   STATE_HEADER,   on_rx_header),
    TRANS_ENTRY(STATE_HEADER,   EVT_RX_BYTE,   STATE_DATA,     on_rx_data),
    TRANS_ENTRY(STATE_DATA,     EVT_RX_BYTE,   STATE_CHECKSUM, on_rx_checksum),
    TRANS_ENTRY(STATE_CHECKSUM, EVT_RX_BYTE,   STATE_DONE,     on_complete),
    TRANS_ENTRY(STATE_ANY,      EVT_TIMEOUT,   STATE_IDLE,     on_timeout),
    TRANS_TABLE_END
};

STATE_ANY 是个特殊状态,表示「任何状态遇到超时都回到空闲」。这种通配符在状态机里很实用。

4.3 查表执行宏

#define EXEC_TRANSITION(table, state, evt) \
    do { \
        const trans_t *t = (table); \
        while (t->action != NULL) { \
            if ((t->src_state == (state) || t->src_state == STATE_ANY) \
                && t->event == (evt)) { \
                t->action(); \
                state = t->dst_state; \
                break; \
            } \
            t++; \
        } \
    } while(0)

这个宏遍历表格,找到匹配的条目就执行动作并更新状态。注意 STATE_ANY 的处理,它匹配任何状态。

为什么用宏而不是函数? 宏在编译时展开,没有函数调用开销。对于嵌入式系统,尤其是中断上下文里的状态机,这点性能差异可能决定系统能否跑在目标频率上。

五、实战:协议解析器

来个完整的例子。假设我们要解析一个简单协议:帧头(0xAA) + 长度(1字节) + 数据(n字节) + 校验(1字节)。

5.1 状态与事件定义

// 状态
#define STM_IDLE        0
#define STM_SYNC        1   // 等待同步头
#define STM_LENGTH      2   // 接收长度
#define STM_DATA        3   // 接收数据
#define STM_CHECK       4   // 接收校验
#define STM_DONE        5   // 完成

// 事件
#define EVT_BYTE        0
#define EVT_SYNC_OK     1
#define EVT_LEN_OK      2
#define EVT_DATA_DONE   3
#define EVT_CHECK_OK    4
#define EVT_ERROR       5

5.2 状态转换表

const trans_t parser_trans[] = {
    // 空闲态:收到0xAA进入同步
    TRANS_ENTRY(STM_IDLE,   EVT_SYNC_OK,    STM_SYNC,   reset_packet),
    // 同步态:收到长度字节
    TRANS_ENTRY(STM_SYNC,   EVT_BYTE,       STM_LENGTH, save_length),
    // 长度态:开始收数据
    TRANS_ENTRY(STM_LENGTH, EVT_LEN_OK,     STM_DATA,   init_data_buf),
    // 数据态:收完所有数据
    TRANS_ENTRY(STM_DATA,   EVT_DATA_DONE,  STM_CHECK,  finalize_data),
    // 校验态:校验通过
    TRANS_ENTRY(STM_CHECK,  EVT_CHECK_OK,   STM_DONE,   signal_complete),
    // 任何状态出错都回到空闲
    TRANS_ENTRY(STM_ANY,    EVT_ERROR,      STM_IDLE,   reset_packet),
    TRANS_TABLE_END
};

5.3 主循环

void parser_run(uint8_t byte) {
    uint8_t event = EVT_BYTE;
    
    // 检测同步头
    if (current_state == STM_IDLE && byte == 0xAA) {
        event = EVT_SYNC_OK;
    }
    
    // 检测长度完成
    if (current_state == STM_SYNC) {
        packet_len = byte;
        event = EVT_LEN_OK;
    }
    
    // 检测数据完成
    if (current_state == STM_DATA && data_idx >= packet_len) {
        event = EVT_DATA_DONE;
    }
    
    // 检测校验
    if (current_state == STM_CHECK) {
        event = (byte == calc_checksum()) ? EVT_CHECK_OK : EVT_ERROR;
    }
    
    // 执行转换
    EXEC_TRANSITION(parser_trans, current_state, event);
}

你看,主循环里没有 if-else 嵌套,全是事件判断 + 查表执行。加一个新状态?加一行表格就行。改一个转换?改表格条目就行。代码的可维护性直接拉满。

避坑指南:我曾经在状态机里忘了处理「非法事件」,结果系统进入未定义状态。后来我在查表宏里加了个 default 处理,遇到未匹配事件就触发错误状态。建议你也加上。

六、知识体系总览

下面这张图总结了宏与状态机的核心关系:

宏与状态机知识体系 状态定义宏 事件驱动宏 状态转换表宏 STATE_IDLE, STATE_HEADER... EVT_RX_BYTE, EVT_TIMEOUT... TRANS_ENTRY, TRANS_TABLE_END 实战:协议解析器 帧头检测 (0xAA) 长度/数据/校验 查表执行转换 核心:用宏定义状态/事件 → 用表格描述转换 → 用查表代替逻辑

这张图把三个核心模块串起来了。状态定义宏提供「名字」,事件驱动宏提供「触发」,状态转换表宏提供「规则」。三者结合,就是一套完整的协议解析框架。

总结一下:宏在状态机里的价值,不是炫技,而是让代码变得可读、可维护、可扩展。我做了十年嵌入式,见过太多被 if-else 堆死的协议栈。用宏 + 状态转换表,你也能写出优雅的解析器。

最后说一句:别把宏用得太花哨。宏的本质是文本替换,过度使用反而让代码难调试。我的原则是:宏只做「命名」和「查表」两件事,复杂的逻辑还是交给函数。


公众号:蓝海资料掘金营,微信deep3321