5、函数指针表实战:简单通信协议解析器、状态转移表设计、代码实现
好,咱们今天来点真家伙。
前面几章讲了状态机的基本概念、状态转移图、还有经典的 switch-case 实现。说实话,那些都是基本功,但真正到了项目里,尤其是通信协议解析这种场景,switch-case 写起来会让人想砸键盘。为什么?因为协议状态一多,case 分支铺天盖地,维护起来简直是噩梦。
我个人习惯,遇到这种场景,直接上函数指针表。说白了,就是把状态和动作的对应关系,硬编码成一张表格。代码看起来像查字典,清晰、高效、好扩展。
5.1 为什么通信协议解析器适合用状态机?
你想想看,一个通信协议,比如最简单的串口数据帧,它的解析过程本质上就是状态跳转:
- 等待帧头 → 收到帧头 → 接收长度 → 接收数据 → 校验 → 处理完成
- 任何一步出错,都要回到等待帧头状态
这不就是典型的状态机吗?
我在项目中遇到过好几次,有人用一堆 if-else 嵌套来解析协议,结果代码又臭又长,加一个新命令得改半天。后来我全部改成函数指针表,加协议命令只需要加一行表项,改一个处理函数,清爽得很。
5.2 状态转移表设计
我们先设计一个简单的通信协议。假设帧格式如下:
| 字节 | 含义 | 说明 |
|---|---|---|
| 0 | 帧头 | 固定 0xAA |
| 1 | 长度 | 数据域字节数(1~64) |
| 2 ~ N+1 | 数据 | 实际数据 |
| N+2 | 校验 | 前面所有字节的异或和 |
对应的状态机有 5 个状态:
- IDLE:等待帧头
- HEADER:已收到帧头,等待长度
- LENGTH:已收到长度,等待数据
- DATA:正在接收数据
- CHECK:数据收完,等待校验
状态转移表长什么样?我习惯用二维数组,行是当前状态,列是触发事件(比如收到一个字节)。表格里存的是下一个状态和要执行的动作函数指针。
5.3 代码实现:函数指针表版
好,直接上代码。这是我最喜欢的一种实现方式,没有之一。
// 状态枚举
typedef enum {
ST_IDLE = 0,
ST_HEADER,
ST_LENGTH,
ST_DATA,
ST_CHECK,
ST_MAX
} state_t;
// 事件枚举(其实就是收到的字节值,但为了清晰,我们定义几个特殊事件)
typedef enum {
EVT_HEADER = 0xAA,
EVT_ANY = 0xFF // 通配符,表示任意字节
} event_t;
// 动作函数指针类型
typedef void (*action_t)(uint8_t byte);
// 状态转移表项
typedef struct {
state_t next_state;
action_t action;
} trans_t;
// 动作函数声明
static void act_idle_header(uint8_t byte);
static void act_header_length(uint8_t byte);
static void act_length_data(uint8_t byte);
static void act_data_store(uint8_t byte);
static void act_data_check(uint8_t byte);
static void act_check_done(uint8_t byte);
static void act_error(uint8_t byte);
// 状态转移表:行=当前状态,列=事件(这里简化,只列出关键事件)
// 实际项目中,可以用更精细的事件分类
static const trans_t state_table[ST_MAX][2] = {
// ST_IDLE
[ST_IDLE] = {
[0] = { .next_state = ST_HEADER, .action = act_idle_header }, // 收到0xAA
[1] = { .next_state = ST_IDLE, .action = NULL } // 其他字节,忽略
},
// ST_HEADER
[ST_HEADER] = {
[0] = { .next_state = ST_LENGTH, .action = act_header_length }, // 收到长度
[1] = { .next_state = ST_IDLE, .action = act_error } // 非法,回IDLE
},
// ST_LENGTH
[ST_LENGTH] = {
[0] = { .next_state = ST_DATA, .action = act_length_data }, // 长度有效,准备收数据
[1] = { .next_state = ST_IDLE, .action = act_error } // 长度无效
},
// ST_DATA
[ST_DATA] = {
[0] = { .next_state = ST_DATA, .action = act_data_store }, // 还在收数据
[1] = { .next_state = ST_CHECK, .action = act_data_check } // 数据收完,转校验
},
// ST_CHECK
[ST_CHECK] = {
[0] = { .next_state = ST_IDLE, .action = act_check_done }, // 校验通过,处理完成
[1] = { .next_state = ST_IDLE, .action = act_error } // 校验失败
}
};
// 当前状态
static state_t current_state = ST_IDLE;
// 协议解析主函数:每收到一个字节,调用一次
void protocol_parse(uint8_t byte) {
trans_t trans;
// 根据当前状态和事件查表
// 这里简化了事件判断,实际项目中需要更精细的匹配
if (byte == 0xAA) {
trans = state_table[current_state][0];
} else {
trans = state_table[current_state][1];
}
// 执行动作(如果有)
if (trans.action != NULL) {
trans.action(byte);
}
// 状态跳转
current_state = trans.next_state;
}
5.4 动作函数实现示例
动作函数负责具体的业务逻辑。比如:
static uint8_t rx_buffer[64];
static uint8_t rx_len;
static uint8_t rx_index;
static void act_idle_header(uint8_t byte) {
// 收到帧头,准备接收
rx_index = 0;
// 可以在这里清空缓冲区
}
static void act_header_length(uint8_t byte) {
rx_len = byte;
if (rx_len > 64) {
// 长度非法,触发错误
current_state = ST_IDLE;
}
}
static void act_data_store(uint8_t byte) {
if (rx_index < rx_len) {
rx_buffer[rx_index++] = byte;
}
}
static void act_data_check(uint8_t byte) {
// 这里 byte 是校验值
uint8_t calc = 0;
for (int i = 0; i < rx_len; i++) {
calc ^= rx_buffer[i];
}
if (calc == byte) {
// 校验通过,处理数据
process_packet(rx_buffer, rx_len);
} else {
// 校验失败,错误处理
}
}
你看,每个动作函数只做一件事,职责单一。想加一个新协议命令?加一个状态、加一行表项、写一个动作函数,完事。不需要动原来的任何代码。
5.5 函数指针表的优势
我总结一下,为什么我偏爱这种写法:
- 可读性强:状态转移表一目了然,比 switch-case 好读一百倍
- 可维护性高:加状态、加事件,只需要改表,不需要改逻辑
- 执行效率高:查表是 O(1) 的,比 if-else 链快得多
- 容易代码生成:表格可以用脚本自动生成,从 Excel 或者 UML 图直接转成 C 代码
5.6 知识体系图
下面这张图,把本章的核心逻辑串起来了。你看一眼,应该就能明白函数指针表状态机的全貌。
这张图你看懂了吗?说白了,整个流程就是一个闭环:输入字节 → 查状态转移表 → 执行动作函数 → 更新状态 → 等待下一个字节。干净、利落、没有废话。
5.7 写在最后
函数指针表这种写法,我用了快十年了。从早期的串口协议解析,到后来的 CAN 总线、网络协议栈,只要是状态驱动的场景,我第一反应就是它。
你可能会问:那有没有更高级的?比如用状态机代码生成工具?有,但那是后话。先把函数指针表玩熟,这是基本功。就像练武先扎马步,马步不稳,学再多花架子也没用。
嗯,今天就到这里。代码你可以直接拿去用,但建议你自己动手敲一遍。只有亲手写过,才知道哪里容易踩坑。
公众号:蓝海资料掘金营,微信deep3321