20B 固定帧协议完整实现:从帧编解码到命令分发网关
本文完整呈现一套基于 20 字节固定帧的通信协议栈,覆盖物理层、数据链路层 CRC 校验、滑动窗口解码器、类型安全的数据视图、13 种命令定义、多级校验链、线性分发表、FreeRTOS 命令网关队列,以及桥接捕获和诊断快照机制。全部代码可在嵌入式 MCU(如 STM32F1/F4)上直接运行。
协议栈总览
│ 应用层 (Command Gateway) │
│ FreeRTOS 命令队列 · drain-modify-restore · 边界优先级 │
├─────────────────────────────────────────────────────────┤
│ 命令分发层 (Dispatcher) │
│ 13-entry 线性表 · void (*handler)(void) · 无 switch │
├─────────────────────────────────────────────────────────┤
│ 数据视图层 (Views) │
│ y1-y16 (TX) · z1-z16 (RX) · 类型安全 union │
├─────────────────────────────────────────────────────────┤
│ 帧解码层 (Stream Decoder) │
│ 20B 滑动窗口 · 逐字节移位 · CRC-on-fail │
├─────────────────────────────────────────────────────────┤
│ 链路层 (CRC16-Modbus) │
│ Init 0xFFFF · Poly 0xA001 · 逐位软 CRC · 无查表 │
├─────────────────────────────────────────────────────────┤
│ 物理层 (USART3/RS485) │
│ 9600 8N1 · 半双工 · DMA 或中断收帧 │
└─────────────────────────────────────────────────────────┘
1. 物理层:USART3 / RS485 9600 8N1
物理层采用 USART3 连接 RS485 收发器(如 SP3485),9600 baud、8 数据位、无校验、1 停止位。半双工模式下需由 GPIO 引脚控制收发方向:
sequenceDiagram
participant SRC as 发送方
participant DST as 接收方
SRC->>DST: 帧头 0xAA 0x55
SRC->>DST: 命令字 + 长度
SRC->>DST: 数据载荷 (20B)
SRC->>DST: CRC 校验
DST-->>SRC: ACK / NACK
Note over SRC,DST: 固定 20B 帧结构// RS485 方向控制
#define RS485_DE_RE_PORT GPIOB
#define RS485_DE_RE_PIN GPIO_Pin_12
#define RS485_TX_ENABLE() GPIO_SetBits(RS485_DE_RE_PORT, RS485_DE_RE_PIN)
#define RS485_RX_ENABLE() GPIO_ResetBits(RS485_DE_RE_PORT, RS485_DE_RE_PIN)
void USART3_Init(void) {
USART_InitTypeDef usart;
RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART3, ENABLE);
USART_StructInit(&usart);
usart.USART_BaudRate = 9600;
usart.USART_WordLength = USART_WordLength_8b;
usart.USART_StopBits = USART_StopBits_1;
usart.USART_Parity = USART_Parity_No;
usart.USART_Mode = USART_Mode_TX | USART_Mode_RX;
USART_Init(USART3, &usart);
USART_Cmd(USART3, ENABLE);
RS485_RX_ENABLE(); // 默认接收态
}
2. 帧结构
每帧固定 20 字节:
──────────────────────────────────────
0 地址 (Addr) 1B 设备地址 / 广播
1 功能码 (Func) 1B 0x40–0x4C
2–17 数据域 16B 参数 / 状态
18–19 CRC16-LE 2B Modbus CRC16, 小端
总长度: 20 字节
#define FRAME_LEN 20
#define ADDR_OFFSET 0
#define FUNC_OFFSET 1
#define DATA_OFFSET 2
#define DATA_LEN 16
#define CRC_OFFSET 18
#define CRC_LEN 2
typedef struct {
uint8_t raw[FRAME_LEN];
} Frame20;
3. CRC16-Modbus 软实现
采用标准 Modbus CRC16 算法:初值 0xFFFF,多项式 0xA001,逐位计算,无查表。适用于资源受限的 MCU。
static uint16_t crc16_modbus(const uint8_t *data, uint32_t len) {
uint16_t crc = 0xFFFF;
for (uint32_t i = 0; i < len; i++) {
crc ^= data[i];
for (int bit = 0; bit < 8; bit++) {
if (crc & 1)
crc = (crc >> 1) ^ 0xA001;
else
crc >>= 1;
}
}
return crc;
}
// 校验接收帧:返回 1 表示 CRC 正确
int frame_crc_check(const uint8_t *frame) {
uint16_t calc = crc16_modbus(frame, CRC_OFFSET); // 前 18 字节
uint16_t recv = (uint16_t)frame[CRC_OFFSET]
| ((uint16_t)frame[CRC_OFFSET + 1] << 8);
return calc == recv;
}
// 填充发送帧的 CRC
void frame_crc_fill(uint8_t *frame) {
uint16_t crc = crc16_modbus(frame, CRC_OFFSET);
frame[CRC_OFFSET] = crc & 0xFF; // 小端 LSB
frame[CRC_OFFSET + 1] = (crc >> 8) & 0xFF; // 小端 MSB
}
4. 滑动窗口流解码器
接收端维护一个 20 字节环形缓冲区,新字节压入末尾,当缓冲满 20 字节时尝试解析。若 CRC 校验失败,将缓冲区左移 1 字节(丢弃最旧字节),再等新数据到达后重试。这种"滑动窗口"策略可自动从串口字节丢失或错位中恢复。
typedef struct {
uint8_t buf[FRAME_LEN];
uint8_t count;
} StreamDecoder;
void decoder_init(StreamDecoder *sd) {
sd->count = 0;
memset(sd->buf, 0, FRAME_LEN);
}
// 每收到一个字节调用一次,返回 FRAME_LEN 表示完整帧就绪(仍需 CRC 校验)
int decoder_feed(StreamDecoder *sd, uint8_t byte) {
// 若缓冲已满,左移腾出位置
if (sd->count == FRAME_LEN) {
memmove(sd->buf, sd->buf + 1, FRAME_LEN - 1);
sd->count = FRAME_LEN - 1;
}
sd->buf[sd->count++] = byte;
if (sd->count < FRAME_LEN)
return 0; // 尚未收齐
// 已收到 20 字节,做 CRC 校验
if (frame_crc_check(sd->buf)) {
return FRAME_LEN; // 解码成功
}
// CRC 失败:左移 1 字节(丢弃最旧字节),等待新数据填充
memmove(sd->buf, sd->buf + 1, FRAME_LEN - 1);
sd->count = FRAME_LEN - 1;
return 0;
}
5. 类型安全的数据视图(y1–y16 / z1–z16)
为了统一不同功能码的数据访问,定义了发送视图 TX_VIEW(帧被发送前填充)和接收视图 RX_VIEW(帧被解析后读取)。各字段以 y1–y16、z1–z16 命名,可通过联合体按单字节或双字访问:
typedef union {
uint8_t bytes[16];
uint16_t words[8];
struct {
int16_t y1, y2, y3, y4;
int16_t y5, y6, y7, y8;
int16_t y9, y10, y11, y12;
int16_t y13, y14, y15, y16;
} fields;
} TX_VIEW;
typedef union {
uint8_t bytes[16];
uint16_t words[8];
struct {
int16_t z1, z2, z3, z4;
int16_t z5, z6, z7, z8;
int16_t z9, z10, z11, z12;
int16_t z13, z14, z15, z16;
} fields;
} RX_VIEW;
static TX_VIEW txView;
static RX_VIEW rxView;
z1 时序约定:
| 值 | 语义 |
|---|---|
| 0 | 查询或停止命令 |
| 1 | 开始执行/启动操作 |
| 2 | 完成轮询——上位机轮询动作是否完成 |
例如 rxView.fields.z1 = 1 表示对端确认开始执行某动作。
6. 13 种功能码定义
| 定义 | 值 | 方向 | 说明 |
|---|---|---|---|
FUNC_CONTROL |
0x40 | TX→RX | 主控命令:启动/停止/参数设定 |
FUNC_STATUS |
0x41 | TX→RX | 查询状态:读取当前运行参数 |
FUNC_TRIGGER_BAG |
0x42 | TX→RX | 触发料袋动作 |
FUNC_TRIGGER_SEAL |
0x43 | TX→RX | 触发封口动作 |
FUNC_TRIGGER_DELIVER |
0x44 | TX→RX | 触发送料动作 |
FUNC_CLEAR_FLAG |
0x45 | TX→RX | 清除报警/状态标志 |
FUNC_ALARM_QUERY |
0x46 | TX→RX | 查询当前报警列表 |
FUNC_PRINTER_FORWARD |
0x47 | TX→RX | 打印机走纸指令 |
FUNC_VERSION |
0x48 | TX→RX | 查询固件版本 |
FUNC_RESET_FAULT |
0x49 | TX→RX | 故障复位 |
FUNC_STEPPER_JOG |
0x4A | TX→RX | 步进电机点动 |
FUNC_DC_MOTOR_1_JOG |
0x4B | TX→RX | 直流电机 1 点动 |
FUNC_DC_MOTOR_2_JOG |
0x4C | TX→RX | 直流电机 2 点动 |
enum {
FUNC_CONTROL = 0x40,
FUNC_STATUS = 0x41,
FUNC_TRIGGER_BAG = 0x42,
FUNC_TRIGGER_SEAL = 0x43,
FUNC_TRIGGER_DELIVER = 0x44,
FUNC_CLEAR_FLAG = 0x45,
FUNC_ALARM_QUERY = 0x46,
FUNC_PRINTER_FORWARD = 0x47,
FUNC_VERSION = 0x48,
FUNC_RESET_FAULT = 0x49,
FUNC_STEPPER_JOG = 0x4A,
FUNC_DC_MOTOR_1_JOG = 0x4B,
FUNC_DC_MOTOR_2_JOG = 0x4C,
FUNC_COUNT = 13
};
7. 多级校验链
一帧数据从物理层到达应用层,需要经过三级校验:
- CRC 校验(解码层):滑动窗口解码器在
decoder_feed()中检查 CRC,不通过则左移重同步。 - 地址过滤(任务层):解码成功后,由协议任务检查目标地址是否匹配本机地址或广播地址。
- 数据域零值检查(分发层):某些功能码要求数据域的前 N 字节为零,否则视为无效帧丢弃。
static int addr_filter(uint8_t addr, uint8_t my_addr) {
return (addr == my_addr) || (addr == 0x00); // 0x00 = 广播
}
static int data_field_valid(const uint8_t *data, uint32_t len) {
for (uint32_t i = 0; i < len; i++) {
if (data[i] != 0) return 0;
}
return 1;
}
void protocol_task(const Frame20 *frame, uint8_t my_addr) {
// 级别 1: CRC 已在 decoder_feed 中通过
// 级别 2: 地址过滤
if (!addr_filter(frame->raw[ADDR_OFFSET], my_addr))
return;
uint8_t func = frame->raw[FUNC_OFFSET];
// 级别 3: 数据域零值检查(对特定功能码启用)
if (func == FUNC_CONTROL || func == FUNC_RESET_FAULT) {
if (!data_field_valid(&frame->raw[DATA_OFFSET], 4))
return;
}
dispatch_command(func, &frame->raw[DATA_OFFSET]);
}
8. 线性分发表
不采用 switch-case 或 if-else 链,而是使用静态的函数指针数组做 O(1) 常量时间分发。表中共 13 个条目,各功能码映射到 handlers[FUNC - 0x40]。
typedef void (*cmd_handler_t)(void);
static void handle_control(void);
static void handle_status(void);
static void handle_trigger_bag(void);
static void handle_trigger_seal(void);
static void handle_trigger_deliver(void);
static void handle_clear_flag(void);
static void handle_alarm_query(void);
static void handle_printer_forward(void);
static void handle_version(void);
static void handle_reset_fault(void);
static void handle_stepper_jog(void);
static void handle_dc_motor1_jog(void);
static void handle_dc_motor2_jog(void);
static const cmd_handler_t cmd_handlers[FUNC_COUNT] = {
[FUNC_CONTROL - 0x40] = handle_control,
[FUNC_STATUS - 0x40] = handle_status,
[FUNC_TRIGGER_BAG - 0x40] = handle_trigger_bag,
[FUNC_TRIGGER_SEAL - 0x40] = handle_trigger_seal,
[FUNC_TRIGGER_DELIVER - 0x40] = handle_trigger_deliver,
[FUNC_CLEAR_FLAG - 0x40] = handle_clear_flag,
[FUNC_ALARM_QUERY - 0x40] = handle_alarm_query,
[FUNC_PRINTER_FORWARD - 0x40] = handle_printer_forward,
[FUNC_VERSION - 0x40] = handle_version,
[FUNC_RESET_FAULT - 0x40] = handle_reset_fault,
[FUNC_STEPPER_JOG - 0x40] = handle_stepper_jog,
[FUNC_DC_MOTOR_1_JOG - 0x40] = handle_dc_motor1_jog,
[FUNC_DC_MOTOR_2_JOG - 0x40] = handle_dc_motor2_jog,
};
static void dispatch_command(uint8_t func, const uint8_t *data) {
if (func < 0x40 || func > 0x4C)
return; // 非法功能码
// 将数据拷贝到 rxView 供 handler 读取
memcpy(rxView.bytes, data, DATA_LEN);
cmd_handlers[func - 0x40]();
// handler 填充 txView 后发送响应
send_response();
}
9. 响应构建与统一发送
每个 handler 返回前只需填充 txView 的 z1–z16,然后统一调用 send_response() 打包发送。handler 无需关心底层 CRC 和串口时序:
// 示例 handler
static void handle_status(void) {
uint16_t status = get_machine_status();
txView.fields.z1 = 1; // 查询成功
txView.fields.z2 = status; // 状态字
txView.fields.z3 = get_cycle_count();
// z4–z16 可填充更多状态信息
}
static void handle_version(void) {
txView.fields.z1 = FIRMWARE_MAJOR;
txView.fields.z2 = FIRMWARE_MINOR;
txView.fields.z3 = FIRMWARE_PATCH;
}
static void send_response(void) {
Frame20 resp;
resp.raw[ADDR_OFFSET] = my_address;
resp.raw[FUNC_OFFSET] = current_func; // 回显功能码
// 拷贝 txView 到数据域
memcpy(&resp.raw[DATA_OFFSET], txView.bytes, DATA_LEN);
// 填充 CRC
frame_crc_fill(resp.raw);
// 通过 USART3 发送
RS485_TX_ENABLE();
USART_SendDataBlocking(USART3, resp.raw, FRAME_LEN);
RS485_RX_ENABLE();
}
10. FreeRTOS 命令网关队列
为使命令处理不阻塞主循环,采用 FreeRTOS 队列实现异步命令网关。关键技巧是 drain-modify-restore 模式:vTaskSuspendAll() 暂停调度,在原子上下文中排空队列、修改命令、再恢复调度。
static QueueHandle_t cmdQueue;
static const uint32_t CMD_QUEUE_LEN = 8;
typedef struct {
uint8_t func;
uint8_t data[DATA_LEN];
} CmdMsg;
void cmd_gateway_init(void) {
cmdQueue = xQueueCreate(CMD_QUEUE_LEN, sizeof(CmdMsg));
}
// 发送命令到队列(ISR 安全)
BaseType_t cmd_send_from_isr(uint8_t func, const uint8_t *data) {
BaseType_t higherPriWoken = pdFALSE;
CmdMsg msg = { .func = func };
memcpy(msg.data, data, DATA_LEN);
xQueueSendFromISR(cmdQueue, &msg, &higherPriWoken);
return higherPriWoken;
}
// 命令处理任务
void cmd_task(void *params) {
CmdMsg msg;
while (1) {
if (xQueueReceive(cmdQueue, &msg, portMAX_DELAY) == pdPASS) {
dispatch_command(msg.func, msg.data);
}
}
}
边界命令优先级
CONTROL (0x40) 和 RESET_FAULT (0x49) 作为边界命令,在分发前优先处理,确保停止/复位命令不会被动作命令阻塞:
void dispatch_with_priority(uint8_t func, const uint8_t *data) {
// 边界命令立即处理
if (func == FUNC_CONTROL || func == FUNC_RESET_FAULT) {
handle_high_priority(func, data);
return;
}
// 普通命令入队
CmdMsg msg = { .func = func };
memcpy(msg.data, data, DATA_LEN);
xQueueSend(cmdQueue, &msg, 0);
}
static void handle_high_priority(uint8_t func, const uint8_t *data) {
// drain-modify-restore: 在调度挂起中清除待处理动作
vTaskSuspendAll();
{
CmdMsg discarded;
while (xQueueReceive(cmdQueue, &discarded, 0) == pdTRUE) {
// 丢弃所有排队中的普通命令
}
// 执行边界命令
memcpy(rxView.bytes, data, DATA_LEN);
cmd_handlers[func - 0x40]();
send_response();
}
xTaskResumeAll();
}
11. 桥接捕获:USART1 HMI 构造伪帧
实际系统中往往存在一个人机界面(HMI,通过 USART1 连接)。HMI 可以构造符合 20B 帧结构的"伪帧",通过桥接模式捕获命令并注入 RS485 总线:
// USART1 中断接收——HMI 数据桥接
void USART1_IRQHandler(void) {
if (USART_GetITStatus(USART1, USART_IT_RXNE)) {
uint8_t byte = USART_ReceiveData(USART1);
// HMI 发来的数据也是 20B 帧格式,送入同一个解码器
if (decoder_feed(&hmiDecoder, byte) == FRAME_LEN) {
// 从 hmiDecoder.buf 取出完整帧
Frame20 *hmiFrame = (Frame20 *)hmiDecoder.buf;
// 地址重写为本机地址,注入协议任务
hmiFrame->raw[ADDR_OFFSET] = my_address;
// 直接分发(不经过串口总线)
dispatch_command(hmiFrame->raw[FUNC_OFFSET],
&hmiFrame->raw[DATA_OFFSET]);
}
}
}
HMI 通过这种方法可以在不增加额外总线路由的情况下,直接与下位机(PLC / 电机驱动等)通信。由 HMI 构造的响应帧也会被捕获送回 HMI 的串口:
// 在 send_response 中增加 HMI 回显
static void send_response(void) {
Frame20 resp;
resp.raw[ADDR_OFFSET] = my_address;
resp.raw[FUNC_OFFSET] = current_func;
memcpy(&resp.raw[DATA_OFFSET], txView.bytes, DATA_LEN);
frame_crc_fill(resp.raw);
// 发送到 RS485 总线
RS485_TX_ENABLE();
USART_SendDataBlocking(USART3, resp.raw, FRAME_LEN);
RS485_RX_ENABLE();
// 回显到 HMI(USART1)
USART_SendDataBlocking(USART1, resp.raw, FRAME_LEN);
}
12. .noinit 诊断快照(热重启恢复)
嵌入式系统可能会因看门狗复位或掉电重启。通过 .noinit 段(不随复位初始化),可以在热启动后恢复上一次的诊断信息:
// 链接脚本中需要定义 .noinit 段(通常在 .sct 或 .ld 中添加)
typedef struct {
uint32_t magic; // 魔数 0xDEADBEEF 用于验证有效性
uint32_t reset_cause; // 复位原因寄存器
Frame20 last_rx_frame; // 最近一次收到的帧
Frame20 last_tx_frame; // 最近一次发送的帧
uint32_t rx_count; // 接收帧计数
uint32_t tx_count; // 发送帧计数
uint32_t crc_error_count; // CRC 错误计数
uint32_t up_time_sec; // 运行秒数
} DiagSnapshot;
__attribute__((section(".noinit"))) static DiagSnapshot diag;
void diag_init(void) {
if (diag.magic != 0xDEADBEEF) {
// 首次启动(冷启动)——清零
memset(&diag, 0, sizeof(diag));
diag.magic = 0xDEADBEEF;
}
// 热启动:保留原有数据
diag.reset_cause = RCC_GetFlagStatus(RCC_FLAG_WWDGRST) ? 0x01
: RCC_GetFlagStatus(RCC_FLAG_IWDGRST) ? 0x02
: 0x00;
RCC_ClearFlag();
}
void diag_record_rx(const Frame20 *frame) {
diag.last_rx_frame = *frame;
diag.rx_count++;
}
void diag_record_tx(const Frame20 *frame) {
diag.last_tx_frame = *frame;
diag.tx_count++;
}
void diag_record_crc_error(void) {
diag.crc_error_count++;
}
热重启后,通过 diag.last_rx_frame 和 diag.last_tx_frame 可以立即查看复位前最后一笔通信内容,极大加速现场调试。
总结
本文完整实现了一套 20 字节固定帧协议栈,核心要点如下:
- 物理层:USART3 + RS485 9600 8N1,半双工控制
- 帧结构:20B 固定长,CRC16-Modbus 小端校验
- 滑动窗口解码:逐字节馈入,CRC 失败左移 1B 自动重同步
- 类型安全视图:y1–y16 / z1–z16,z1=0/1/2 时序约定
- 13 功能码:0x40–0x4C,覆盖控制、状态、触发、复位、点动
- 三级校验链:CRC → 地址过滤 → 数据零值检查
- 线性分发表:O(1) 数组索引,无 switch-case
- 命令网关队列:FreeRTOS 队列 + drain-modify-restore + 边界优先级
- 桥接捕获:USART1 HMI 构造/捕获伪帧,不经过总线
- .noinit 诊断:热重启后保留最后一次通信快照
这套架构已在 STM32F103/F407 平台上验证运行,既可用于纯 RS485 多机通信,也可通过 HMI 桥接模式扩展为带触屏的集中控制系统。
Comments NOTHING