通用协议类嵌入式软件框架

从 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) 判断
  • 超时不是每个状态都自己维护一份计时器
  • 外部输入也不是到处回调乱飞

而是统一变成:

  • "给协议模块投递一个事件"
  • "协议模块在固定入口处理这个事件"
  • "状态机只处理事件,不直接处理复杂调度细节"

为什么这招有效?

因为协议系统本质上是一个"多触发源系统"。

它会被很多东西驱动:

  • 周期到了
  • 某个等待超时了
  • 收到一帧报文了
  • 链路状态变了
  • 用户发命令了
  • 参数更新了

如果这些入口是分散的,那么:

  • 一部分逻辑在主循环
  • 一部分逻辑在任务里
  • 一部分逻辑在回调里
  • 一部分逻辑在状态机里

后面几乎一定会乱。

但如果统一成事件,整个系统就会清楚很多:

  • 谁触发的,不重要
  • 最终都变成事件
  • 协议模块只认事件
  • 状态机只处理事件

六、整体架构应该长什么样

下面给出一套适用于协议类软件的总体结构:
底层驱动或中断
接收适配层
事件中心
周期调度器
超时管理器
业务命令入口
协议执行上下文
协议状态机
业务处理层
发送接口

这个架构里有几个关键点:

  1. 所有输入统一进入事件中心
  2. 事件中心不做复杂业务,只做归一化和投递
  3. 协议执行上下文负责串行处理本协议相关的所有事件
  4. 状态机只处理事件与状态迁移
  5. 业务层负责具体动作,不直接参与调度细节

你可以把它理解成五层:

第一层:输入来源

包括:

  • 底层接收
  • 周期到点
  • 超时到点
  • 上层业务命令

第二层:统一事件入口

这些输入不直接改状态,而是先进入事件中心

第三层:协议执行上下文

每个协议模块都有自己的上下文、自己的状态、自己的重试计数、自己的缓冲区。

第四层:状态机处理

状态机不再关心"事件从哪里来",只关心"当前状态下收到这个事件该怎么办"。

第五层:业务与发送

状态机如果需要发报文,就走发送接口;如果需要处理业务,就调用业务层。


七、框架里的几个核心角色

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 这段代码怎么读

如果你是小白,建议按下面顺序看这段状态机代码:

  1. 先看 switch (ctx->state),意思是"先看当前在什么状态"。
  2. 再看 evt->id,意思是"再看现在收到了什么事件"。
  3. 然后看这个分支里做了什么动作,例如发送、取消超时、加重试次数。
  4. 最后看 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,底层完全可以直接映射到:

  • xQueueCreate
  • xQueueSend
  • xQueueReceive

重点不在于重新发明队列,而在于建立统一事件语义。


十六、第八步:协议任务怎么组织

在实际工程里,协议通常需要一个串行执行上下文。

这个上下文的意义是:

  • 同一个协议实例的状态变化尽量放在同一执行路径里
  • 避免多个地方同时改状态
  • 便于日志和调试

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 为什么这个结构好用

因为它满足了协议框架最重要的一点:

所有输入最终都进入同一个处理入口,所有状态修改最终都发生在同一个上下文里。

这会让你的协议行为非常清晰。


十七、第九步:系统初始化顺序怎么安排

很多人写框架时,不是逻辑错,而是初始化顺序混乱。

更推荐的初始化顺序是:

  1. 初始化底层驱动
  2. 初始化 OS 抽象层
  3. 初始化事件中心
  4. 初始化超时管理器
  5. 初始化周期调度器
  6. 初始化协议上下文或协议实例
  7. 创建协议任务
  8. 启动系统调度

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 更推荐的做法

把框架做成下面这些角色:

  1. 运行组织层

    • 协议任务怎么创建
    • 协议实例怎么启动
    • 初始化顺序怎么统一
  2. 事件层

    • 统一事件类型
    • 统一事件投递和获取
  3. 时间层

    • 周期调度
    • 超时管理
  4. 协议层

    • 状态机
    • 编解码
    • 协议上下文

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,而在于为协议类软件建立一种统一的控制语义:

  • 周期由统一调度器产生
  • 超时由统一管理器检测
  • 事件由统一入口分发
  • 状态机专注于状态迁移和协议逻辑

最后把本文的核心观点再总结成一句话:

协议类框架的关键,不只是把协议做成状态机,而是把驱动协议运行的周期、超时、事件统一管理起来。