20B 固定帧协议完整实现:从帧编解码到命令分发网关

Babel36acl 嵌入式实战 无~ 12 次阅读 预计阅读时间: 20 分钟 发布于 1 天前 最后更新于 1 小时前 4370 字


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;
}
设计要点:若连续多次 CRC 失败,buffer 会反复左移直到找到正确的帧边界。这种算法不依赖帧头标记(如 0xAA、0x55),完全由 CRC 的校验能力保证正确同步。

5. 类型安全的数据视图(y1–y16 / z1–z16)

为了统一不同功能码的数据访问,定义了发送视图 TX_VIEW(帧被发送前填充)和接收视图 RX_VIEW(帧被解析后读取)。各字段以 y1–y16z1–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. 多级校验链

一帧数据从物理层到达应用层,需要经过三级校验:

  1. CRC 校验(解码层):滑动窗口解码器在 decoder_feed() 中检查 CRC,不通过则左移重同步。
  2. 地址过滤(任务层):解码成功后,由协议任务检查目标地址是否匹配本机地址或广播地址。
  3. 数据域零值检查(分发层):某些功能码要求数据域的前 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_framediag.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 桥接模式扩展为带触屏的集中控制系统。

此作者没有提供个人介绍。
最后更新于 2026-05-30