从 0 搭建一个通用的协议类嵌入式软件框架
关键词:嵌入式、协议框架、事件驱动、状态机、超时控制、周期调度、模块化、FreeRTOS
一、这套框架到底是干什么的
很多人一说到"协议开发",第一反应往往是:
- 一帧数据怎么拆包
- 校验怎么做
- 命令字怎么分发
- 响应怎么返回
这些当然重要。
但项目一旦真正复杂起来,你很快就会发现:协议解析本身,并不是最难的部分。
真正难的,往往是下面三类控制逻辑如何统一、准确、可维护地组织起来:
1. 周期控制
例如:
- 每 100ms 轮询一次设备状态
- 每 1s 发送一次心跳
- 每 500ms 做一次链路检测
- 定期同步参数或状态
2. 超时控制
例如:
- 发出请求后等待 ACK 超时
- 等待完整响应超时
- 握手阶段等待对端确认超时
- 重发窗口超时
3. 外部事件控制
例如:
- 收到一帧报文
- 通信链路变化
- 上层命令下发
- 配置参数变化
- 外设状态变化
在很多项目里,协议虽然已经从业务里剥离出来了,代码表面上看也像是"结构清晰",但只要周期、超时、异步事件这三类触发源开始混合驱动,代码就会逐渐变成下面这样:
- 多个状态里反复写时间比较
- 周期逻辑散落在不同任务中
- 超时判断嵌在状态机里越来越重
- 收包、回调、业务接口都可能直接改状态
- 逻辑入口不统一,调试非常痛苦
所以,这套框架真正要解决的问题,不是"怎么把一帧报文解析出来",而是:
如何把协议运行过程中的周期、超时、外部输入统一组织起来,让协议代码长期可维护、可扩展、可调试。
换句话说,这不是一个单纯的"报文解析模板",而是一套协议运行框架。
它要解决的是:
- 协议逻辑应该在什么时机执行
- 等待多久算失败
- 不同来源的输入如何统一进入协议层
- 状态应该在哪里修改,谁有权修改
- 后续增加新命令、新状态、新异常流程时,代码还能不能稳住
可以把本文的核心观点先写在前面:
协议类框架的关键,不只是把协议做成状态机,而是把驱动协议运行的周期、超时、事件统一管理起来。
二、这套框架适合哪些项目
这套框架不是给所有嵌入式项目准备的,它更适合下面这类场景。
2.1 适合的项目类型
如果你的项目有下面这些特点,就很适合用这套思路:
- 有明显的通信协议交互
- 同时存在周期动作、超时动作、异步动作
- 协议流程不是"一次收包一次处理"那么简单
- 后续还会不断增加命令、状态、异常恢复逻辑
- 希望代码后面还能扩展,而不是先跑起来再说
典型例子包括:
- 串口协议
- RS485 / Modbus 通信
- CAN / LIN 协议处理
- AT 指令交互
- 电源、充电、储能类控制协议
- 网关和外设之间的轮询式通信
- 蓝牙、网口、UART 混合接入的设备管理系统
2.2 特别适合哪种"难项目"
它尤其适合这种项目:
- 要周期轮询
- 要等待对端响应
- 超时后还要重发
- 收到不同类型报文要走不同处理路径
- 链路异常时要恢复
- 配置变化时要主动同步
这类项目最怕的就是:
- 时间判断到处散落
- 回调里直接改状态
- 周期、超时、事件三套机制各写各的
- 后面一扩展,整个代码就绞在一起
2.3 不太适合的情况
如果你的项目只是:
- 一个简单的定时采样任务
- 一个轻量的状态切换流程
- 没有复杂超时处理
- 没有明显的异步协议交互
那就没必要一上来就上完整框架。
此时一个普通任务、一个简单状态机、几个清晰接口,往往已经够用了。
所以要先有一个基本判断:
这套框架是为了应对"协议交互复杂、时序控制复杂、后期还会持续扩展"的项目。
三、协议类软件真正难在哪里
3.1 协议项目不是单纯的"解析报文"
对于简单协议来说,可能收一帧、解析、执行、回包就结束了。
但对于真实项目,协议处理通常是一个持续运行的系统行为,而不是一次性动作。
例如一个典型流程可能是:
- 周期性发送查询命令
- 等待设备返回数据
- 如果超时则重发
- 如果收到异常码则切换处理路径
- 如果链路断开则进入恢复状态
- 如果参数发生变化则主动同步新配置
这意味着协议系统是一个典型的多触发源系统。
它不是只处理"当前状态是什么",还要处理:
- 何时执行
- 等多久算失败
- 收到什么外部事件后切换行为
3.2 协议开发中的三类核心触发源
协议类系统可以抽象为三类驱动源:
1)周期触发
用于固定频率执行的动作,例如:
- 每 100ms 轮询一次设备状态
- 每 1s 发送一次心跳
- 每 500ms 检查一次链路质量
特点是:
- 固定节拍
- 可重复执行
- 一般和系统 tick 有关
2)超时触发
用于等待某个动作结果的时限控制,例如:
- 发出请求后等待 ACK
- 握手阶段等待对端确认
- 等待某个阶段完成
特点是:
- 一次性触发
- 往往与当前状态或当前命令绑定
- 和错误恢复机制强相关
3)事件触发
用于响应外部输入或内部变化,例如:
- 收到报文
- 通信中断
- 参数更新
- 用户按键
- 上位机命令下发
特点是:
- 异步
- 不确定发生时间
- 来源多样
3.3 这三者为什么难统一
因为它们虽然都能触发状态变化,但本质并不相同:
| 类型 | 触发方式 | 是否重复 | 是否绑定状态 | 典型用途 |
|---|---|---|---|---|
| 周期 | 固定节拍 | 是 | 不一定 | 心跳、轮询、同步 |
| 超时 | 截止时间 | 否 | 是 | 等待响应、等待阶段完成 |
| 事件 | 异步输入 | 不一定 | 不一定 | 收包、链路变化、命令输入 |
很多项目的痛点就在于:
- 周期靠一个定时任务处理
- 超时靠状态机里手写 tick 比较
- 事件靠回调或者全局标志
- 三套机制互相并列,最后互相缠绕
结果就是:
- 逻辑入口分散
- 状态和时间强耦合
- 代码越来越难维护
- 协议扩展时风险很高
四、为什么很多人用了状态机,项目后面还是会乱
这里必须先把一个误区讲清楚:
4.1 状态机当然重要
状态机非常适合描述协议流程,例如:
- 空闲
- 发送请求
- 等待响应
- 校验结果
- 处理异常
- 超时重发
- 结束或复位
所以,状态机本身没有问题。
问题在于很多项目里,状态机被迫承担了太多不该由它承担的职责。
4.2 传统状态机为什么常见
状态机适合协议开发,这是毫无疑问的。
因为协议天然具有"阶段性"。
状态机的优势在于:
- 能清晰描述协议流程
- 便于实现阶段切换
- 易于剥离协议逻辑和业务逻辑
- 有利于维护握手、会话、命令序列
所以这里不是否定状态机,而是强调:
状态机适合描述协议流程,但不适合独立承担完整的时序调度职责。
4.3 传统写法常见长这样
c
switch (state)
{
case STATE_IDLE:
if (period_elapsed(100))
{
send_request();
start_tick = get_tick();
state = STATE_WAIT_ACK;
}
break;
case STATE_WAIT_ACK:
if (ack_received)
{
state = STATE_PROCESS;
}
else if ((get_tick() - start_tick) > 50)
{
retry_count++;
if (retry_count < 3)
{
send_request();
start_tick = get_tick();
}
else
{
state = STATE_ERROR;
}
}
break;
case STATE_PROCESS:
process_data();
state = STATE_IDLE;
break;
}
刚开始看,这样写似乎挺自然。
但项目一复杂,问题就会越来越明显:
- 一个状态里同时塞了周期判断、超时判断、事件判断
- 每个协议模块都在自己维护时间戳
- 周期逻辑散落在多个地方
- 超时逻辑和状态强耦合
- 接收回调、业务接口、任务循环都可能改状态
- 调试时很难看出当前动作到底是谁触发的
4.4 状态机的问题,不是"不能用",而是"一个人干了三个人的活"
状态机本来更适合负责:
- 当前状态是什么
- 收到某个输入后该转到哪里
- 进入状态后要触发什么动作
但很多项目里,状态机还被迫负责:
- 周期调度
- 超时计时
- 异步输入入口管理
- 重试窗口维护
- 链路异常恢复时序
当这些逻辑都塞进状态机后,状态机就不再是"流程描述器",而变成了"全能控制器"。
这也是为什么很多人会觉得:
- 一开始状态机写得很顺
- 后来状态越来越多
- 时间控制越来越乱
- 最后虽然还是状态机,但已经很难维护了
4.5 所以这套框架和状态机是什么关系
一定要明确:
这套框架不是替代状态机,而是给状态机减负。
框架负责统一管理:
- 周期
- 超时
- 异步输入事件
状态机负责专注处理:
- 当前状态
- 当前事件
- 应该执行什么动作
- 切到哪个新状态
这才是更合理的职责划分。
五、这套框架的核心思想是什么
核心思想可以概括成一句话:
把"周期、超时、外部输入"统一抽象为事件,由协议执行上下文按统一入口处理。
换句话说:
- 周期不是散落在各处的
if (tick)判断 - 超时不是每个状态都自己维护一份计时器
- 外部输入也不是到处回调乱飞
而是统一变成:
- "给协议模块投递一个事件"
- "协议模块在固定入口处理这个事件"
- "状态机只处理事件,不直接处理复杂调度细节"
为什么这招有效?
因为协议系统本质上是一个"多触发源系统"。
它会被很多东西驱动:
- 周期到了
- 某个等待超时了
- 收到一帧报文了
- 链路状态变了
- 用户发命令了
- 参数更新了
如果这些入口是分散的,那么:
- 一部分逻辑在主循环
- 一部分逻辑在任务里
- 一部分逻辑在回调里
- 一部分逻辑在状态机里
后面几乎一定会乱。
但如果统一成事件,整个系统就会清楚很多:
- 谁触发的,不重要
- 最终都变成事件
- 协议模块只认事件
- 状态机只处理事件
六、整体架构应该长什么样
下面给出一套适用于协议类软件的总体结构:
底层驱动或中断
接收适配层
事件中心
周期调度器
超时管理器
业务命令入口
协议执行上下文
协议状态机
业务处理层
发送接口
这个架构里有几个关键点:
- 所有输入统一进入事件中心
- 事件中心不做复杂业务,只做归一化和投递
- 协议执行上下文负责串行处理本协议相关的所有事件
- 状态机只处理事件与状态迁移
- 业务层负责具体动作,不直接参与调度细节
你可以把它理解成五层:
第一层:输入来源
包括:
- 底层接收
- 周期到点
- 超时到点
- 上层业务命令
第二层:统一事件入口
这些输入不直接改状态,而是先进入事件中心。
第三层:协议执行上下文
每个协议模块都有自己的上下文、自己的状态、自己的重试计数、自己的缓冲区。
第四层:状态机处理
状态机不再关心"事件从哪里来",只关心"当前状态下收到这个事件该怎么办"。
第五层:业务与发送
状态机如果需要发报文,就走发送接口;如果需要处理业务,就调用业务层。
七、框架里的几个核心角色
7.1 周期调度器
周期调度器负责产生"周期到达"事件,而不是直接执行业务。
例如:
- 每 100ms 产生一次轮询事件
- 每 1s 产生一次心跳事件
它的职责是:
- 管理节拍
- 统一触发时间点
- 避免每个模块自己维护周期计数器
7.2 超时管理器
超时管理器负责把"等待时间结束"转化为超时事件。
例如:
- 发送请求后注册一个 50ms 超时
- 如果 50ms 内未收到 ACK,则投递等待超时事件
- 如果先收到 ACK,则取消这个超时
它的职责是:
- 超时注册
- 超时取消
- 超时事件投递
- 避免每个状态都自己算时间差
7.3 事件中心
事件中心负责统一组织所有输入,典型职责包括:
- 事件定义
- 事件封装
- 事件投递
- 事件队列管理
- 可选的事件日志记录
7.4 协议执行上下文
每个协议模块应该拥有自己的上下文,而不是把状态散落在全局变量里。
上下文中通常会放:
- 当前状态
- 重试次数
- 会话号或序列号
- 链路状态
- 缓冲区
- 当前等待对象
这样可以做到:
- 状态集中
- 计数集中
- 易于扩展多协议实例
- 易于日志打印和问题定位
八、先别写业务,先把工程骨架搭出来
很多新人容易犯的错误是:
一打开工程就开始写接收、解析、发送。
先别这样。
更稳的做法是:先把骨架搭好,再往骨架里填协议内容。
8.1 推荐目录结构
text
project/
├── app/
│ └── app_main.c
├── bsp/
│ ├── bsp_uart.c
│ ├── bsp_can.c
│ └── bsp_timer.c
├── osal/
│ ├── osal_task.c
│ ├── osal_queue.c
│ ├── osal_time.c
│ └── osal_lock.c
├── framework/
│ ├── fw_event.c
│ ├── fw_event.h
│ ├── fw_timer.c
│ ├── fw_timer.h
│ ├── fw_runtime.c
│ └── fw_runtime.h
├── protocol/
│ ├── proto_common.h
│ ├── proto_demo.c
│ ├── proto_demo_sm.c
│ └── proto_demo_codec.c
├── service/
│ ├── svc_demo.c
│ └── svc_demo.h
└── include/
└── project_config.h
8.2 每一层到底干什么
bsp/
负责最底层硬件访问,例如:
- 串口发送接收
- CAN 收发
- GPIO 读写
- 定时器读数
osal/
负责把操作系统能力抽象出来,例如:
- 创建任务
- 队列收发
- 获取系统 tick
- 互斥锁
framework/
负责统一运行语义:
- 事件中心
- 定时器管理
- 运行组织
- 协议执行入口
protocol/
负责真正的协议逻辑:
- 状态机
- 编解码
- 协议上下文
- 重试计数
- 会话状态
service/
负责业务动作,例如:
- 更新设备参数
- 写入缓存
- 通知上层
- 触发某个业务流程
九、第一步:先定义统一事件
这是整个框架最关键的一步。
9.1 为什么一定要先定义事件
因为你以后所有触发源,最终都应该变成一种统一输入:
- 周期到了 -> 一个事件
- 超时到了 -> 一个事件
- 收到报文 -> 一个事件
- 链路断开 -> 一个事件
- 用户命令 -> 一个事件
这样协议模块只需要记住一件事:
我收到的都是事件。
9.2 先定义事件枚举
在 framework/fw_event.h 中可以先写:
c
/* 事件编号枚举
* 作用:
* 1. 告诉协议层"现在发生了什么"
* 2. 让周期、超时、收包、业务命令都能用统一方式表达
* 3. 后续状态机只需要判断事件编号,不需要关心事件来自哪里
*/
typedef enum
{
FW_EVT_NONE = 0, /* 空事件,表示当前没有有效事件 */
FW_EVT_TICK_100MS, /* 100ms 周期事件,常用于轮询、查询、状态刷新 */
FW_EVT_TICK_1S, /* 1s 周期事件,常用于心跳、慢速检测、统计 */
FW_EVT_FRAME_RX, /* 收到一帧数据 */
FW_EVT_LINK_UP, /* 链路恢复正常,例如串口恢复、总线恢复 */
FW_EVT_LINK_DOWN, /* 链路异常或掉线 */
FW_EVT_WAIT_ACK_TIMEOUT, /* 等待 ACK 超时 */
FW_EVT_WAIT_RSP_TIMEOUT, /* 等待完整响应超时 */
FW_EVT_CMD_START, /* 上层下发"开始"命令 */
FW_EVT_CMD_STOP, /* 上层下发"停止"命令 */
FW_EVT_PARAM_CHANGED, /* 某个配置参数发生变化,需要同步 */
FW_EVT_MAX /* 枚举结束标记,一般用于边界检查 */
} fw_event_id_t;
9.3 再定义统一事件结构体
c
/* 统一事件结构体
* 所有输入最终都建议封装成这个结构,再交给协议层处理
*/
typedef struct
{
fw_event_id_t id; /* 事件编号:说明这是什么事件 */
uint16_t source; /* 事件来源:是谁发来的,例如接收层、定时器、业务层 */
uint16_t target; /* 事件目标:发给哪个协议模块或哪个实例 */
uint16_t size; /* 附带数据长度,单位通常是字节 */
void *payload; /* 附带数据指针,例如收到的报文、命令参数 */
uint32_t timestamp; /* 事件产生时的时间戳,方便调试时分析先后顺序 */
} fw_event_t;
先不要纠结字段多不多。
你只要记住这个结构的意义:
id表示这是什么事件payload表示附带的数据timestamp方便调试和分析时序source/target方便以后做多模块扩展
对于刚入门的人,可以把这个结构简单理解成一句话:
事件结构体 = 事件名字 + 事件附带的数据 + 事件发生的时间。
十、第二步:给协议模块建立上下文
很多新手的代码容易这样写:
- 当前状态是一个全局变量
- 重试次数是一个全局变量
- 上次发送时间又是一个全局变量
- 收到的数据缓冲区还是一个全局变量
结果就是:代码一多,谁都能改状态,调试会非常痛苦。
10.1 正确做法:每个协议模块都有自己的上下文
c
/* 协议状态枚举
* 作用:表示协议当前运行到了哪一步
*/
typedef enum
{
PROTO_IDLE = 0, /* 空闲状态:当前没有在执行一轮交互 */
PROTO_SEND_REQ, /* 发送请求状态:准备发命令或刚发完命令 */
PROTO_WAIT_ACK, /* 等待 ACK 状态:已经发出请求,正在等对方确认 */
PROTO_PROCESS, /* 数据处理状态:已经收到需要的数据,准备处理 */
PROTO_ERROR, /* 错误状态:超时过多、链路异常、数据异常等 */
} proto_state_t;
/* 协议上下文结构体
* 作用:把这个协议实例运行时需要的所有关键信息集中放在一起
* 好处:避免状态、计数、缓冲区散落在全局变量里
*/
typedef struct
{
proto_state_t state; /* 当前状态:协议正在执行哪个阶段 */
uint8_t retry_count; /* 重试次数:超时后已经重发了几次 */
uint8_t link_ok; /* 链路状态:1 表示链路正常,0 表示链路异常 */
uint32_t sequence; /* 序列号/会话号:用于标记当前这一轮交互 */
uint8_t rx_buf[128]; /* 接收缓冲区:用于保存收到的原始数据 */
uint16_t rx_len; /* 当前接收数据长度:rx_buf 里现在有多少字节有效数据 */
} proto_context_t;
10.2 上下文里建议放什么
如果你是刚开始接触框架,可以把"上下文"理解成:
这个协议模块的小背包。
协议运行过程中需要随身带着的东西,都尽量放到这个背包里,而不是丢得到处都是。
对于一个普通协议模块,建议先放这些:
- 当前状态
- 重试次数
- 链路状态
- 会话号或序列号
- 接收缓冲区
- 当前等待对象
后面协议复杂了,再逐步扩展。
10.3 为什么一定要有上下文
因为上下文意味着:
- 状态集中存放
- 运行信息集中存放
- 同一种协议可以做多实例
- 日志打印时可以直接看到完整运行现场
十一、第三步:给协议建立统一处理入口
当事件已经统一、上下文已经建立后,下一步就是给协议定义一个统一入口。
c
/* 协议统一分发入口
* ctx:协议上下文,里面保存当前状态、重试次数、缓冲区等运行信息
* evt:当前收到的事件,可能来自周期、超时、收包、业务命令
*
* 以后不管谁触发协议动作,最终都应该尽量走到这里来
*/
void proto_dispatch_event(proto_context_t *ctx, const fw_event_t *evt);
它的意义非常大:
- 不管输入来自周期、超时还是收包
- 不管事件来自中断、任务还是上层命令
- 最终都走这一个入口
也就是说,你的协议模块以后只认一件事:
收到一个事件,然后结合当前上下文做处理。
十二、第四步:状态机只处理"状态 + 事件"
现在才轮到状态机真正出场。
12.1 推荐的事件驱动状态机写法
c
/* 事件驱动状态机示例
* 这段代码的重点不是函数名,而是处理思路:
* "当前状态 + 当前事件" -> "执行动作 + 切换状态"
*/
void proto_dispatch_event(proto_context_t *ctx, const fw_event_t *evt)
{
/* 第一步:先判断当前处于哪个状态 */
switch (ctx->state)
{
case PROTO_IDLE: /* 当前空闲,说明可以决定要不要开启一轮新的协议交互 */
if (evt->id == FW_EVT_TICK_100MS) /* 如果收到了 100ms 周期事件 */
{
proto_send_query(); /* 发送查询命令 */
/* 启动一个 50ms 的 ACK 超时
* 意思是:如果 50ms 内还没收到 ACK,就自动产生一个超时事件
*/
fw_start_timeout(ctx, FW_EVT_WAIT_ACK_TIMEOUT, 50);
/* 命令已经发出,下一步就进入"等待 ACK"状态 */
ctx->state = PROTO_WAIT_ACK;
}
break;
case PROTO_WAIT_ACK: /* 当前正在等待 ACK */
if (evt->id == FW_EVT_FRAME_RX) /* 如果收到了一帧数据 */
{
/* 判断这帧数据是不是我们正在等的 ACK */
if (proto_is_ack(evt->payload))
{
/* 已经等到了 ACK,就把之前注册的超时取消掉
* 否则后面时间到了还会错误地产生超时事件
*/
fw_cancel_timeout(ctx, FW_EVT_WAIT_ACK_TIMEOUT);
/* ACK 收到后,转入数据处理阶段 */
ctx->state = PROTO_PROCESS;
}
}
else if (evt->id == FW_EVT_WAIT_ACK_TIMEOUT) /* 如果等 ACK 超时了 */
{
/* 先判断是否还有重试次数 */
if (ctx->retry_count < 3)
{
ctx->retry_count++; /* 重试计数加 1 */
proto_send_query(); /* 重新发送请求 */
/* 重新启动下一轮等待 ACK 的超时 */
fw_start_timeout(ctx, FW_EVT_WAIT_ACK_TIMEOUT, 50);
}
else
{
/* 超过最大重试次数,进入错误状态 */
ctx->state = PROTO_ERROR;
}
}
else if (evt->id == FW_EVT_LINK_DOWN) /* 如果链路突然断开 */
{
/* 链路出问题了,这一轮等待已经没意义了,先取消超时 */
fw_cancel_timeout(ctx, FW_EVT_WAIT_ACK_TIMEOUT);
/* 进入错误处理状态 */
ctx->state = PROTO_ERROR;
}
break;
case PROTO_PROCESS: /* 当前处于数据处理状态 */
proto_process_data(); /* 真正处理收到的数据 */
ctx->retry_count = 0; /* 一轮流程成功结束后,把重试计数清零 */
ctx->state = PROTO_IDLE; /* 处理完回到空闲,等待下一轮 */
break;
case PROTO_ERROR: /* 当前进入错误状态 */
proto_recover(); /* 执行恢复动作,例如清理现场、通知上层、复位局部状态 */
ctx->retry_count = 0; /* 错误处理完成后,重试计数清零 */
ctx->state = PROTO_IDLE; /* 回到空闲,后续再决定是否开启新一轮流程 */
break;
}
}
12.2 这段代码怎么读
如果你是小白,建议按下面顺序看这段状态机代码:
- 先看
switch (ctx->state),意思是"先看当前在什么状态"。 - 再看
evt->id,意思是"再看现在收到了什么事件"。 - 然后看这个分支里做了什么动作,例如发送、取消超时、加重试次数。
- 最后看
ctx->state = ...,意思是"下一步要切到哪个状态"。
你把每个分支都按这四步去读,就不会乱。
12.2 这段代码为什么比传统写法更稳
因为现在:
- 周期、超时、收包都被统一成了事件
- 状态机只关心当前事件,不关心事件从哪来
- 时间控制从状态机里剥离出去了
- 状态机终于只做它最擅长的事
十三、第五步:把周期逻辑统一出来
很多人最开始都会在状态机里自己数时间。
不推荐。
更推荐的做法是:
- 周期调度器统一维护节拍
- 到点后投递周期事件
- 协议任务消费周期事件
13.1 周期项可以这样定义
c
/* 周期调度项
* 每一个结构体,表示"一个需要按固定周期触发的任务"
*/
typedef struct
{
uint32_t period_ms; /* 周期时间,单位 ms,例如 100 表示每 100ms 触发一次 */
uint32_t last_tick; /* 上一次触发时的系统 tick,用于判断这次是否到点 */
fw_event_id_t event_id; /* 到点后要投递的事件编号 */
} periodic_item_t;
13.2 调度逻辑可以很简单
如果你是第一次见这种代码,可以这样理解:
period_ms是"闹钟间隔"last_tick是"上次响铃时间"now是"现在几点了"- 当"现在时间 - 上次响铃时间 >= 闹钟间隔"时,就说明该响了
响了以后不直接做业务,而是先发一个事件,这就是框架统一入口的关键。
c
/* 周期扫描函数
* item:要检查的周期项
* now :当前系统 tick
*/
void fw_periodic_scan(periodic_item_t *item, uint32_t now)
{
/* 判断当前时间距离上一次触发,是否已经达到设定周期 */
if ((now - item->last_tick) >= item->period_ms)
{
item->last_tick = now; /* 记录这一次触发时间,作为下次判断基准 */
/* 周期到了,不直接做业务,而是投递一个周期事件
* 这样协议层就能继续用统一事件入口处理
*/
fw_post_event(item->event_id, NULL, 0);
}
}
它的核心思想不是"复杂",而是"统一"。
统一之后:
- 节拍更好调整
- 周期行为更容易追踪
- 不需要每个协议模块自己维护周期计数器
十四、第六步:把超时逻辑从状态机里抽出来
这一步对框架质量影响非常大。
很多项目里,超时逻辑长这样:
- 进状态时记一个开始 tick
- 每次跑状态机都比较一次时间差
- 收到响应时再手动清理
项目一大,这种写法非常容易乱。
14.1 推荐做法:统一的超时管理器
可以先定义一个简单超时项:
c
/* 超时项结构体
* 每一个结构体,表示"一个正在等待中的超时任务"
*/
typedef struct
{
uint8_t active; /* 是否激活:1 表示这个超时正在生效,0 表示未使用 */
uint32_t deadline_tick; /* 截止时间:系统 tick 到了这个时间点就算超时 */
fw_event_id_t event_id; /* 超时后要投递的事件编号 */
void *owner; /* 属于谁:一般指向某个协议上下文,方便区分多个实例 */
} timeout_item_t;
14.2 超时管理器至少要做三件事
你可以把超时项理解成一张"等待清单":
- 谁在等
- 等什么
- 等到什么时候
- 超时了要通知谁
这样一来,超时就不再是某个状态里的临时变量,而是一个可以被管理、被打印、被追踪的对象。
- 启动一个超时
- 取消一个超时
- 到期后投递一个超时事件
也就是说,状态机以后只需要做:
- 发请求后:注册超时
- 收到 ACK:取消超时
- 收到超时事件:执行重试或转错误状态
这样超时就变成了一个显式资源,而不是藏在状态分支里的零散代码。
十五、第七步:事件中心怎么落地
事件中心不用做得很重。
它只需要把各种输入统一封装成事件,并送到协议执行上下文即可。
15.1 事件中心至少要提供这些能力
- 事件定义
- 事件入队
- 事件出队
- 可选的事件日志打印
15.2 一个简单的接口示例
c
/* 事件投递接口
* id :事件编号,说明要投递什么事件
* payload :附带数据指针,没有附带数据时可以传 NULL
* size :附带数据长度
* 返回值 :通常 0 表示成功,非 0 表示失败
*/
int fw_post_event(fw_event_id_t id, void *payload, uint16_t size);
/* 事件获取接口
* evt :输出参数,用来接收取到的事件内容
* timeout_ms :等待时间,单位 ms;如果队列里暂时没事件,可以等待一会儿
* 返回值 :通常 0 表示成功拿到事件,非 0 表示超时或失败
*/
int fw_get_event(fw_event_t *evt, uint32_t timeout_ms);
如果你用的是 FreeRTOS,底层完全可以直接映射到:
xQueueCreatexQueueSendxQueueReceive
重点不在于重新发明队列,而在于建立统一事件语义。
十六、第八步:协议任务怎么组织
在实际工程里,协议通常需要一个串行执行上下文。
这个上下文的意义是:
- 同一个协议实例的状态变化尽量放在同一执行路径里
- 避免多个地方同时改状态
- 便于日志和调试
16.1 一个最小协议任务骨架
c
/* 协议任务示例
* 这个任务的职责很单纯:
* 1. 等事件
* 2. 收到事件后交给协议分发入口处理
*/
static void protocol_task(void *argument)
{
fw_event_t evt; /* 用来暂存"当前取到的事件" */
proto_context_t ctx; /* 协议上下文,保存本协议实例的运行状态 */
(void)argument; /* 如果当前示例没用到传参,可以先显式忽略,避免编译告警 */
proto_context_init(&ctx);/* 先初始化上下文,例如状态置空闲、计数清零、缓冲区清空 */
for (;;)
{
/* 从事件中心获取一个事件
* 100 表示最多等 100ms
* 等到了就返回 0,拿不到就继续下一轮
*/
if (fw_get_event(&evt, 100) == 0)
{
/* 所有事件最终都从这里进入协议层 */
proto_dispatch_event(&ctx, &evt);
}
}
}
16.2 这段任务代码每一步是什么意思
fw_event_t evt;:准备一个变量,用来接收从事件队列里取出来的事件。proto_context_t ctx;:准备一个协议上下文,保存这个协议实例自己的状态和数据。proto_context_init(&ctx);:先把上下文初始化好,避免里面是脏数据。for (;;):进入永久循环,因为协议任务通常需要一直运行。fw_get_event(&evt, 100):尝试从事件中心拿一个事件,没有事件就最多等 100ms。proto_dispatch_event(&ctx, &evt);:拿到事件后,统一交给协议处理入口。
你可以把这个任务理解成一个"值班员":
平时等消息,来消息就按统一流程处理。
16.2 为什么这个结构好用
因为它满足了协议框架最重要的一点:
所有输入最终都进入同一个处理入口,所有状态修改最终都发生在同一个上下文里。
这会让你的协议行为非常清晰。
十七、第九步:系统初始化顺序怎么安排
很多人写框架时,不是逻辑错,而是初始化顺序混乱。
更推荐的初始化顺序是:
- 初始化底层驱动
- 初始化 OS 抽象层
- 初始化事件中心
- 初始化超时管理器
- 初始化周期调度器
- 初始化协议上下文或协议实例
- 创建协议任务
- 启动系统调度
17.1 一个简化示例
很多新手不是代码写错,而是顺序写反了。
比如:
- 事件队列还没创建,就开始发事件
- 协议上下文还没初始化,就开始跑协议任务
- 调度器已经启动了,但协议依赖的对象还没准备好
所以初始化顺序不是"形式问题",而是系统能不能稳定运行的问题。
c
/* 系统初始化示例
* 顺序很重要:先把基础能力准备好,再启动协议任务,最后启动调度器
*/
int main(void)
{
bsp_init(); /* 初始化底层硬件,例如串口、CAN、GPIO、时钟等 */
osal_init(); /* 初始化操作系统抽象层,例如任务、队列、锁、时间接口 */
fw_event_init(); /* 初始化事件中心,例如创建事件队列 */
fw_timer_init(); /* 初始化超时管理器 */
fw_periodic_init(); /* 初始化周期调度器 */
proto_system_init(); /* 初始化协议系统,例如创建协议实例、清空上下文 */
fw_create_protocol_task();/* 创建协议任务,让协议有自己的执行上下文 */
osal_start_scheduler(); /* 启动操作系统调度器,任务从这里开始真正运行 */
return 0; /* 一般正常情况下不会运行到这里 */
}
这里不要求你函数名和示例完全一样,关键是顺序要清楚。
十八、在 FreeRTOS 下该怎么落地
在 FreeRTOS 项目中,这套框架不应该做成一个"重新发明 RTOS 的新操作系统"。
更合理的做法是:
- 底层继续使用 FreeRTOS 原语
- 上层定义协议框架自己的运行语义
18.1 不建议做的事情
不建议写成下面这种"重复造轮子"的框架:
- 自己重新定义任务优先级体系
- 自己模拟调度器
- 封装一大层 RTOS API 只是简单转发
- 框架控制逻辑比业务还复杂
18.2 更推荐的做法
把框架做成下面这些角色:
-
运行组织层
- 协议任务怎么创建
- 协议实例怎么启动
- 初始化顺序怎么统一
-
事件层
- 统一事件类型
- 统一事件投递和获取
-
时间层
- 周期调度
- 超时管理
-
协议层
- 状态机
- 编解码
- 协议上下文
18.3 推荐的语义映射
| 框架语义 | FreeRTOS 实现建议 |
|---|---|
| 协议任务 | xTaskCreate |
| 事件队列 | xQueueCreate |
| ISR 唤醒任务 | xTaskNotifyFromISR 或 queue |
| 周期调度 | vTaskDelayUntil 或 tick 扫描 |
| 超时检测 | tick 比较 + timeout 表 |
| 互斥资源 | mutex / semaphore |
也就是说:
保留框架语义,底层直接映射到 RTOS 原语。
十九、这种方案比传统写法到底好在哪里
19.1 周期控制更统一
传统写法中,周期逻辑可能散落在:
- 主循环
- 某个任务
- 某个状态分支
而统一框架下:
- 周期全部由周期调度器产生
- 协议模块只接收周期事件
- 节拍更容易统一和调整
19.2 超时控制更清晰
传统写法中,超时常见问题是:
- 时间戳分散
- 各个状态自己管理
- 超时取消容易漏掉
统一方案下:
- 超时作为显式资源管理
- 启动、取消、到期都可追踪
- 每个超时都有明确事件号
- 更适合打印日志与定位问题
19.3 事件入口更明确
传统项目容易出现:
- ISR 直接改全局变量
- 回调里直接切状态
- 上层函数直接操作协议状态
统一方案下:
- 所有输入统一变成事件
- 协议状态只能在协议上下文中改变
- 更有利于保证状态一致性
19.4 调试和日志更友好
如果事件都统一化,就可以方便记录:
- 什么时候投递了什么事件
- 当前状态是什么
- 为什么进入超时
- 为什么触发重试
- 为什么链路进入异常
对协议调试来说,这一点非常重要。
二十、新手最容易踩的坑
20.1 还没统一事件,就先开始写业务
结果是后面输入来源越来越多,逻辑入口越来越乱。
20.2 还是习惯在回调里直接改状态
这样做短期看起来方便,长期一定会出问题。
20.3 上下文放得不集中
状态、计数、缓冲区散落在多个全局变量中,后面很难维护。
20.4 状态机里继续自己做周期和超时
这等于框架搭了,但核心问题没有真正解决。
20.5 框架一开始做太重
不要一上来做成"大而全平台"。
先把:
- 统一事件
- 统一入口
- 周期抽离
- 超时抽离
这四件事做好,就已经非常有价值了。
二十一、从 0 落地时,推荐这样推进
如果你准备在自己的项目里引入这套思路,我建议按下面顺序做,而不是一开始就上一个很大的框架。
第一步:先统一事件定义
把各种触发源整理成统一事件类型。
第二步:给协议模块建立统一处理入口
先让所有输入都走同一个入口。
第三步:把超时从状态机里抽出来
把原来散落在状态里的时间比较逐步抽成统一接口。
第四步:把周期逻辑统一出来
不要让每个状态机自己数时间,改成周期调度器投递事件。
第五步:最后再考虑任务组织方式
当事件和时间控制都统一后,再决定是否需要更轻量的运行组织层来管理协议实例和运行监控。
这样做更稳,也更适合渐进式重构。
二十二、结语
很多人以为"把协议做成状态机"就已经完成了架构设计。
实际上,状态机只是协议流程描述的一部分。
真正决定代码是否能长期维护的,是下面这三件事能否统一组织:
- 周期
- 超时
- 事件
如果这三类控制逻辑仍然分散在各个任务、各个状态、各个回调中,那么即使协议层已经剥离出来,系统仍然会逐渐变得难懂、难调、难扩展。
因此,这套框架的价值,并不在于重新发明 RTOS,而在于为协议类软件建立一种统一的控制语义:
- 周期由统一调度器产生
- 超时由统一管理器检测
- 事件由统一入口分发
- 状态机专注于状态迁移和协议逻辑
最后把本文的核心观点再总结成一句话:
协议类框架的关键,不只是把协议做成状态机,而是把驱动协议运行的周期、超时、事件统一管理起来。