嵌入式串口通信协议设计与解析------从入门到精通完全教程
教程定位:面向零基础初学者的系统性教程,从串口通信的基本概念讲起,逐步深入到通信协议设计(简化版与专业版),再到协议解析的查表法实现,最终完成一个完整可用的嵌入式串口通信框架。
学习目标:读完本教程后,你将能够独立设计串口通信协议、编写协议封包与解析代码、理解并应用CRC校验、掌握查表法(表驱动法)的协议解析技术。
目录
- [第一篇 串口通信基础](#第一篇 串口通信基础)
- [第1章 认识串口通信](#第1章 认识串口通信)
- [第2章 UART硬件原理详解](#第2章 UART硬件原理详解)
- [第3章 串口通信核心参数](#第3章 串口通信核心参数)
- [第4章 串口编程入门](#第4章 串口编程入门)
- [第二篇 通信协议设计理论](#第二篇 通信协议设计理论)
- [第5章 为什么需要通信协议](#第5章 为什么需要通信协议)
- [第6章 协议帧的基本概念](#第6章 协议帧的基本概念)
- [第7章 帧头与帧尾设计](#第7章 帧头与帧尾设计)
- [第8章 命令ID设计思想](#第8章 命令ID设计思想)
- [第9章 数据长度与数据字段](#第9章 数据长度与数据字段)
- [第10章 校验码设计](#第10章 校验码设计)
- [第11章 大端与小端](#第11章 大端与小端)
- [第三篇 简化版协议设计](#第三篇 简化版协议设计)
- [第12章 简化版协议格式详解](#第12章 简化版协议格式详解)
- [第13章 简化版协议举例说明](#第13章 简化版协议举例说明)
- [第14章 简化版协议封包代码实现](#第14章 简化版协议封包代码实现)
- [第15章 简化版协议解析代码实现](#第15章 简化版协议解析代码实现)
- [第16章 简化版协议完整工程示例](#第16章 简化版协议完整工程示例)
- [第四篇 专业版协议设计](#第四篇 专业版协议设计)
- [第17章 专业版协议格式详解](#第17章 专业版协议格式详解)
- [第18章 配置位结构详解](#第18章 配置位结构详解)
- [第19章 CRC循环冗余校验完全解析](#第19章 CRC循环冗余校验完全解析)
- [第20章 源地址与目的地址](#第20章 源地址与目的地址)
- [第21章 序列号与应答机制](#第21章 序列号与应答机制)
- [第22章 专业版协议封包代码实现](#第22章 专业版协议封包代码实现)
- [第23章 专业版协议解析代码实现](#第23章 专业版协议解析代码实现)
- [第五篇 协议解析------查表法](#第五篇 协议解析——查表法)
- [第24章 查表法基本概念](#第24章 查表法基本概念)
- [第25章 函数指针详解](#第25章 函数指针详解)
- [第26章 结构体与表驱动设计](#第26章 结构体与表驱动设计)
- [第27章 协议字段映射表](#第27章 协议字段映射表)
- [第28章 功能ID与命令ID处理表](#第28章 功能ID与命令ID处理表)
- [第29章 完整数据帧解析流程](#第29章 完整数据帧解析流程)
- [第六篇 完整实战项目](#第六篇 完整实战项目)
- [第30章 项目架构设计](#第30章 项目架构设计)
- [第31章 完整代码实现](#第31章 完整代码实现)
- [第32章 测试与调试方法](#第32章 测试与调试方法)
- [第33章 常见问题与优化建议](#第33章 常见问题与优化建议)
- 附录
- [附录A ASCII码表](#附录A ASCII码表)
- [附录B 常用CRC多项式参考](#附录B 常用CRC多项式参考)
- [附录C 术语对照表](#附录C 术语对照表)
第一篇 串口通信基础
第1章 认识串口通信
1.1 什么是串口通信
在嵌入式开发的世界里,设备与设备之间需要交换信息。就像人与人之间交流需要语言一样,设备之间交流也需要一套"语言"和"规则",这套规则就是通信协议。而串口通信,就是最基础、最常用的一种设备间通信方式。
所谓"串口",顾名思义,就是数据一位一位地串行传输。想象你有一条很窄的走廊,一次只能让一个人通过,数据也是这样------一个比特接一个比特地排队通过通信线路。与之相对的是"并口"通信,它像一条宽阔的多车道公路,多个比特可以同时并行传输。
串行通信(Serial Communication)的核心特征是:数据在一条信号线上按时间顺序逐位传输。这种方式的优点是接线少、成本低、适合远距离传输;缺点是速度相对较慢(相比并行通信)。
1.2 串行通信 vs 并行通信
为了让你更直观地理解,我们来做一个对比:
| 对比项目 | 串行通信 | 并行通信 |
|---|---|---|
| 数据传输方式 | 一位一位串行传输 | 多位同时并行传输 |
| 信号线数量 | 少(通常1~3根) | 多(8根、16根、32根等) |
| 传输距离 | 远(可达数十米甚至更远) | 近(通常不超过几米) |
| 传输速度 | 相对较慢 | 相对较快 |
| 成本 | 低 | 高 |
| 抗干扰能力 | 强 | 式(线间串扰) |
| 应用场景 | 嵌入式、工业控制、通信 | 计算机内部总线、打印机等 |
举个例子:你要传输一个字节 0x55(二进制 01010101)。
- 并行通信(假设8位并行):需要8根数据线,8个比特同时在8根线上传输,一个时钟周期就能传完。
- 串行通信:只需要1根数据线,8个比特排队,一个接一个传输,需要8个时钟周期。
虽然串行看起来慢,但在实际工程中,由于串行可以做到很高的时钟频率(因为线间干扰小),现代高速接口如USB、SATA、PCIe本质上都是串行通信。
1.3 串行通信的分类
串行通信可以按不同维度进行分类:
按数据传输方向分类:
-
单工通信(Simplex):数据只能单向传输。就像广播电台,只能收听不能回应。例如:温度传感器单向发送数据给主机。
-
半双工通信(Half-Duplex):数据可以双向传输,但不能同时进行。就像对讲机,一方说话时另一方只能听,说完才能切换。例如:RS-485通信。
-
全双工通信(Full-Duplex):数据可以同时双向传输。就像打电话,双方可以同时说话和听话。例如:UART通信、RS-232通信。
按时钟同步方式分类:
-
异步通信(Asynchronous):发送方和接收方没有共享的时钟信号,双方各自使用自己的时钟,通过约定的波特率来同步。UART就是典型的异步通信。
-
同步通信(Synchronous):发送方和接收方共享一个时钟信号,数据传输与时钟同步。SPI、I2C就是典型的同步通信。
按数据格式分类:
- 面向字符的协议:以字符(通常是ASCII字符)为基本单位进行传输。
- 面向比特的协议:以比特为基本单位,不局限于字符边界。
1.4 常见串行通信接口
在嵌入式开发中,你会遇到以下几种常见的串行通信接口:
1. UART(通用异步收发器)
UART是最基础的串行通信接口,几乎所有的微控制器都内置了UART外设。它使用两根信号线:
- TX(Transmit):发送数据线
- RX(Receive):接收数据线
UART是全双工异步通信,不需要时钟线,靠双方约定的波特率来同步。
2. SPI(串行外设接口)
SPI是一种高速同步串行通信接口,使用四根线:
- SCLK:时钟信号
- MOSI:主设备输出,从设备输入
- MISO:主设备输入,从设备输出
- CS/SS:片选信号
SPI速度很快,常用于连接Flash存储器、LCD屏幕、传感器等。
3. I2C(集成电路间总线)
I2C是一种两线制同步串行通信接口:
- SCL:时钟线
- SDA:数据线
I2C支持多设备挂载在同一条总线上,通过地址区分不同设备。常用于连接EEPROM、温度传感器、OLED屏幕等。
4. RS-232
RS-232是一种基于UART的物理层标准,使用±3V±15V的电平信号(而MCU的UART通常是03.3V的TTL电平)。RS-232常用于计算机与外设的通信,如老式串口鼠标、串口调试等。
5. RS-485
RS-485是一种差分信号的串行通信标准,使用两根线(A、B)传输差分信号。它的抗干扰能力很强,传输距离远(可达1200米),支持多点通信(总线上可挂载多个设备)。广泛应用于工业控制领域。
6. CAN(控制器局域网)
CAN是一种用于汽车和工业自动化的串行通信协议,具有高可靠性和错误检测能力。
1.5 本教程聚焦的UART串口
本教程主要聚焦于基于UART的串口通信,这是嵌入式开发中最基础也最常用的通信方式。后续讲的协议设计、协议解析,都是建立在UART通信的基础之上的。
UART通信的基本工作流程:
- 发送方将并行数据转为串行数据,按设定的波特率逐位发送到TX线。
- 接收方按相同的波特率在RX线上逐位接收数据,并将串行数据转为并行数据。
- 双方通过起始位、数据位、校验位、停止位来同步和校验每一帧数据。
1.6 本章小结
本章介绍了串口通信的基本概念:
- 串口通信是数据逐位串行传输的通信方式
- 串行通信有单工、半双工、全双工三种模式
- 串行通信有异步和同步两种方式
- 常见的串行接口包括UART、SPI、I2C、RS-232、RS-485、CAN等
- 本教程聚焦于UART串口通信
第2章 UART硬件原理详解
2.1 UART是什么
UART的全称是 Universal Asynchronous Receiver/Transmitter,即"通用异步收发器"。它是集成电路(通常是微控制器内部的一个外设模块),负责将并行数据转换为串行数据发送出去,以及将接收到的串行数据转换为并行数据。
需要特别说明的是:UART本身不是一种通信协议,而是一种硬件控制器/外设。它实现的是物理层的串行数据收发功能。而我们通常说的"串口通信协议",是建立在UART之上的更高层规则。
在大多数微控制器中,UART外设的名称可能略有不同:
- STM32中称为 USART(通用同步/异步收发器,比UART多了同步功能)
- Arduino中称为 Serial
- ESP32中称为 UART
2.2 UART的硬件连接
最基本的UART通信只需要三根线:
设备A 设备B
┌──────────┐ ┌──────────┐
│ TX ├────────────┤ RX │
│ │ │ │
│ RX ├────────────┤ TX │
│ │ │ │
│ GND ├────────────┤ GND │
└──────────┘ └──────────┘
- TX(发送线):设备A的TX连接到设备B的RX
- RX(接收线):设备A的RX连接到设备B的TX
- GND(地线):两个设备共地
注意TX和RX是交叉连接的:A的TX接B的RX,A的RX接B的TX。这就像打电话,你的嘴巴(TX)对着对方的耳朵(RX),对方的嘴巴(TX)对着你的耳朵(RX)。
为什么需要GND?
UART是单端信号通信,意味着信号是相对于地(GND)的电压。两个设备必须共地,才能正确识别对方发送的高低电平。没有共同的参考地,接收方无法判断信号是高电平还是低电平。
2.3 UART数据帧格式
UART的每一帧数据由以下几个部分组成:
空闲 起始位 数据位 校验位 停止位 空闲
____ ___ ___ ___ ___ ___ _____ ___ ___
|_________X___D0_D1_D2_D3_D4_D5_D6_D7_P___|
一帧UART数据
-
空闲状态(Idle):当没有数据传输时,TX线保持高电平(逻辑1)。
-
起始位(Start Bit):数据传输开始时,TX线从高电平拉低到低电平(逻辑0),持续一个比特周期。接收方检测到这个下降沿,就知道数据要来了。
-
数据位(Data Bits):紧跟起始位之后,是5~9位数据(通常使用8位)。先发送最低位(LSB),最后发送最高位(MSB)。
-
校验位(Parity Bit):可选,用于简单的错误检测。
- 无校验(None):没有校验位
- 奇校验(Odd):数据位+校验位中1的个数为奇数
- 偶校验(Even):数据位+校验位中1的个数为偶数
-
停止位(Stop Bit):数据传输结束,TX线回到高电平(逻辑1),持续1、1.5或2个比特周期。停止位标志着一帧数据的结束。
2.4 UART通信的时序详解
让我们用一个具体的例子来理解UART的时序。假设发送一个字节 0x55(二进制 01010101),配置为8位数据、无校验、1位停止位。
在UART中,数据是**先发最低位(LSB First)**的,所以 01010101 的发送顺序是:1, 0, 1, 0, 1, 0, 1, 0(从最低位D0到最高位D7)。
完整的时序如下:
空闲 起始 D0 D1 D2 D3 D4 D5 D6 D7 停止 空闲
___ _ _ _ _ _ _ _ _ ___
|______|1| 0| 1| 0| 1| 0| 1| 0|_____|
↑ 起始位(0) ↑ 停止位(1)
每个比特的持续时间由波特率决定。例如,波特率为9600时,每个比特的持续时间约为104.17微秒(1/9600秒)。
2.5 波特率详解
波特率(Baud Rate)是串口通信中最重要的参数之一。它表示每秒传输的比特数(bits per second, bps)。
常见的波特率有:9600、19200、38400、57600、115200、230400、460800、921600等。
以9600波特率为例:
- 每秒传输9600个比特
- 每个比特的持续时间 = 1/9600 ≈ 104.17微秒
- 传输一个字节(8位数据+1位起始+1位停止=10位)需要 10/9600 ≈ 1.04毫秒
- 每秒大约可以传输 9600/10 = 960个字节
波特率误差的影响:
发送方和接收方的波特率必须一致(或有极小的误差)。如果两者波特率不匹配,会导致接收方在错误的时间点采样数据,从而产生误码。
UART异步通信允许的波特率误差通常在±2.5%~±3%以内(取决于具体的UART硬件设计)。超出这个范围,通信就会出现错误。
为什么会有波特率误差?
因为波特率是由MCU的时钟分频得到的,而时钟源(晶振)的频率可能不是波特率的整数倍。例如,STM32使用72MHz时钟时,要得到115200的波特率,分频系数 = 72000000/(16×115200) = 39.0625,取整后为39,实际波特率 = 72000000/(16×39) = 115384.6,误差约为0.16%,这在允许范围内。
2.6 TTL电平与RS-232电平
MCU的UART输出的是TTL电平:
- 逻辑0:0V
- 逻辑1:3.3V(或5V,取决于MCU供电)
而RS-232使用的是负逻辑电平:
- 逻辑0(Space):+3V ~ +15V(通常为+12V)
- 逻辑1(Mark):-3V ~ -15V(通常为-12V)
由于电平不兼容,MCU的UART不能直接连接RS-232接口,需要通过电平转换芯片(如MAX232、CH340等)进行转换。
2.7 本章小结
本章详细讲解了UART的硬件原理:
- UART是通用异步收发器,负责串/并转换
- UART通信需要TX、RX、GND三根线,TX和RX交叉连接
- UART数据帧由起始位、数据位、校验位、停止位组成
- 波特率决定每秒传输的比特数,收发双方必须匹配
- TTL电平与RS-232电平不同,需要电平转换
第3章 串口通信核心参数
3.1 串口通信的五大参数
在进行串口通信之前,通信双方必须约定好以下五个参数,任何一个不匹配都会导致通信失败:
- 波特率(Baud Rate):通信速度,如9600、115200等
- 数据位(Data Bits):每帧数据的位数,通常为8位
- 校验位(Parity):无校验(None)、奇校验(Odd)、偶校验(Even)
- 停止位(Stop Bits):1位、1.5位、2位
- 流控制(Flow Control):无流控(None)、硬件流控(RTS/CTS)、软件流控(XON/XOFF)
通常的默认配置是:9600, 8, N, 1 (波特率9600,8位数据,无校验,1位停止位),或者 115200, 8, N, 1。
3.2 波特率的选择
选择波特率时需要考虑以下因素:
通信距离:
- 距离越远,波特率应该越低,因为长距离传输时信号衰减和干扰更严重
- 9600波特率下,使用普通线缆可以可靠传输约15米(TTL电平)或更远(RS-485)
- 115200波特率下,TTL电平通信距离建议不超过1米
通信稳定性:
- 波特率越高,对时钟精度和信号质量的要求越高
- 在嘈杂的工业环境中,建议使用较低的波特率以提高抗干扰能力
数据量需求:
- 如果需要传输大量数据(如图像数据),应使用较高的波特率
- 如果只传输少量控制命令,9600或115200即可满足需求
常见波特率推荐:
- 调试打印、简单控制命令:9600或115200
- 传感器数据采集:38400或115200
- 大数据量传输(如固件升级):460800或921600
3.3 数据位
数据位通常设置为8位,因为一个字节正好是8位。虽然UART支持5、6、7、8、9位数据,但绝大多数应用都使用8位。
某些特殊场景会使用7位数据,比如传输纯ASCII字符时(ASCII码为7位),可以省一位来提高效率。但这种用法在现代应用中已经很少见了。
3.4 校验位
校验位是UART硬件层面的简单错误检测机制:
无校验(None):最常用,不添加校验位。错误检测交由上层协议(如我们的和校验或CRC)来处理。
奇校验(Odd) :数据位加上校验位中,1的总数为奇数。例如数据 0x55(01010101)有4个1,为偶数,所以校验位设为1,使总1的个数为5(奇数)。
偶校验(Even) :数据位加上校验位中,1的总数为偶数。例如数据 0x55(01010101)有4个1,为偶数,所以校验位设为0,使总1的个数保持为4(偶数)。
在实际工程中,大多数情况使用无校验,因为UART的奇偶校验只能检测单比特错误,检测能力有限。更可靠的错误检测由应用层的校验码(和校验、CRC等)来完成,这将在后面的章节详细讲解。
3.5 停止位
停止位标志着一帧数据的结束,可以是1位、1.5位或2位:
- 1位停止位:最常用,适合大多数应用
- 2位停止位:在一些老式设备或要求更高可靠性的场景中使用,提供更多的帧间隔时间
- 1.5位停止位:较少使用,通常只在某些特殊设备中
3.6 流控制
流控制用于解决发送方发送速度过快导致接收方来不及处理的问题:
无流控(None):不使用流控制。适用于通信数据量小、双方处理速度足够快的场景。大多数嵌入式应用使用无流控。
硬件流控(RTS/CTS):使用RTS(请求发送)和CTS(清除发送)两根额外的信号线来控制数据流。发送方在发送前拉高RTS,等待接收方的CTS信号确认后才开始发送。这种方式响应速度快,但需要额外的两根线。
软件流控(XON/XOFF):通过在数据流中嵌入特殊字符(XON=0x11,XOFF=0x13)来控制数据流。接收方发送XOFF让发送方暂停,发送XON让发送方继续。这种方式不需要额外信号线,但会引入延迟,且数据中不能包含XON/XOFF字符。
3.7 本章小结
本章介绍了串口通信的五大核心参数:
- 波特率:决定通信速度,需根据距离、稳定性、数据量选择
- 数据位:通常为8位
- 校验位:通常为无校验,由上层协议负责错误检测
- 停止位:通常为1位
- 流控制:通常为无流控
第4章 串口编程入门
4.1 嵌入式串口编程概述
在嵌入式开发中,串口编程通常涉及以下几个层次:
┌─────────────────────────────────────┐
│ 应用层(业务逻辑) │
├─────────────────────────────────────┤
│ 协议层(封包/解析) │ ← 本教程重点
├─────────────────────────────────────┤
│ 驱动层(UART寄存器操作/HAL库) │
├─────────────────────────────────────┤
│ 硬件层(UART外设/引脚) │
└─────────────────────────────────────┘
本教程的重点在协议层,即如何设计通信协议、如何封包和解析。但要理解协议层的实现,你需要对驱动层有基本的了解。
4.2 串口初始化
在大多数MCU上,使用串口通信前需要进行初始化。以下以STM32 HAL库为例:
c
// STM32 HAL库串口初始化示例
UART_HandleTypeDef huart1;
void MX_USART1_UART_Init(void)
{
huart1.Instance = USART1;
huart1.Init.BaudRate = 115200; // 波特率
huart1.Init.WordLength = UART_WORDLENGTH_8B; // 8位数据
huart1.Init.StopBits = UART_STOPBITS_1; // 1位停止位
huart1.Init.Parity = UART_PARITY_NONE; // 无校验
huart1.Init.Mode = UART_MODE_TX_RX; // 收发模式
huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; // 无流控
huart1.Init.OverSampling = UART_OVERSAMPLING_16;
HAL_UART_Init(&huart1);
}
这段代码的含义:
- 选择USART1外设
- 设置波特率为115200
- 设置8位数据长度
- 设置1位停止位
- 设置无校验
- 设置为收发模式(既能发送也能接收)
- 设置无硬件流控
- 设置16倍过采样
- 调用HAL_UART_Init完成初始化
4.3 串口发送数据
阻塞式发送(最简单但效率低):
c
// 发送一个字节
void uart_send_byte(uint8_t data)
{
HAL_UART_Transmit(&huart1, &data, 1, HAL_MAX_DELAY);
}
// 发送多个字节
void uart_send_buffer(uint8_t *data, uint16_t len)
{
HAL_UART_Transmit(&huart1, data, len, HAL_MAX_DELAY);
}
阻塞式发送的特点是:发送函数会一直等待,直到所有数据发送完毕才返回。这种方式简单直观,但在等待期间CPU无法做其他事情。
中断式发送(更高效):
c
// 中断式发送
void uart_send_it(uint8_t *data, uint16_t len)
{
HAL_UART_Transmit_IT(&huart1, data, len);
}
// 发送完成回调函数
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1) {
// 发送完成后的处理
}
}
中断式发送在启动发送后立即返回,CPU可以做其他事情。当数据发送完成后,会触发中断,调用回调函数。
DMA发送(最高效):
c
// DMA发送
void uart_send_dma(uint8_t *data, uint16_t len)
{
HAL_UART_Transmit_DMA(&huart1, data, len);
}
DMA发送由DMA控制器直接搬运数据,全程不需要CPU参与,是最高效的发送方式。适合大数据量传输。
4.4 串口接收数据
阻塞式接收:
c
uint8_t rx_data;
HAL_UART_Receive(&huart1, &rx_data, 1, HAL_MAX_DELAY);
中断式接收:
c
uint8_t rx_data;
HAL_UART_Receive_IT(&huart1, &rx_data, 1);
// 接收完成回调函数
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1) {
// 处理接收到的数据 rx_data
// 然后重新开启接收
HAL_UART_Receive_IT(&huart1, &rx_data, 1);
}
}
空闲中断接收(推荐用于不定长数据接收):
这是实际工程中最常用的接收方式。利用UART的空闲中断(IDLE),可以接收不定长度的数据帧:
c
uint8_t rx_buf[256];
uint16_t rx_len = 0;
void uart_start_idle_receive(void)
{
HAL_UARTEx_ReceiveToIdle_IT(&huart1, rx_buf, sizeof(rx_buf));
}
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart->Instance == USART1) {
rx_len = Size; // 实际接收到的数据长度
// 处理接收到的数据
// ...
// 重新开启接收
HAL_UARTEx_ReceiveToIdle_IT(&huart1, rx_buf, sizeof(rx_buf));
}
}
4.5 串口环形缓冲区
在接收不定长数据时,环形缓冲区(Ring Buffer / Circular Buffer)是一种非常实用的数据结构。它可以解决接收速度与处理速度不匹配的问题。
c
#define RING_BUF_SIZE 256
typedef struct {
uint8_t buffer[RING_BUF_SIZE];
uint16_t head; // 写入位置
uint16_t tail; // 读取位置
} RingBuffer;
void ring_buf_init(RingBuffer *rb)
{
rb->head = 0;
rb->tail = 0;
}
// 写入一个字节
void ring_buf_write(RingBuffer *rb, uint8_t data)
{
uint16_t next = (rb->head + 1) % RING_BUF_SIZE;
if (next != rb->tail) { // 缓冲区未满
rb->buffer[rb->head] = data;
rb->head = next;
}
}
// 读取一个字节
uint8_t ring_buf_read(RingBuffer *rb, uint8_t *data)
{
if (rb->head == rb->tail) { // 缓冲区为空
return 0; // 读取失败
}
*data = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) % RING_BUF_SIZE;
return 1; // 读取成功
}
// 获取缓冲区中数据量
uint16_t ring_buf_available(RingBuffer *rb)
{
return (rb->head - rb->tail + RING_BUF_SIZE) % RING_BUF_SIZE;
}
环形缓冲区的工作原理:
head指向下一个写入位置tail指向下一个读取位置- 当
head == tail时,缓冲区为空 - 当
(head + 1) % SIZE == tail时,缓冲区满 - 数据在环形数组中循环写入和读取
4.6 本章小结
本章介绍了串口编程的基础知识:
- 串口编程分为硬件层、驱动层、协议层、应用层
- 串口初始化需要配置波特率、数据位、校验位、停止位等
- 发送和接收有阻塞式、中断式、DMA三种方式
- 空闲中断适合接收不定长数据
- 环形缓冲区可以解决接收与处理速度不匹配的问题
第二篇 通信协议设计理论
第5章 为什么需要通信协议
5.1 裸数据传输的问题
假设你直接通过串口发送原始数据,不使用任何协议。比如设备A要向设备B发送一个温度值25℃。
最简单的做法:直接发送数字25,即发送一个字节 0x19(十进制25)。
看似没问题,但考虑以下场景:
问题1:数据边界不明确
如果设备A连续发送三个温度值:25、18、30,串口上实际传输的是:0x19 0x12 0x1E。接收方收到这三个字节后,怎么知道第一个温度是25而不是后两个字节组成的值?怎么知道边界在哪里?
问题2:数据损坏无法检测
如果传输过程中某个字节受到了电磁干扰,比如 0x19 变成了 0x18,接收方拿到的是24℃而不是25℃,完全不知道数据出错了。
问题3:命令含义不明确
设备A要发的不只是温度,还有湿度、气压等。接收方收到一个字节后,怎么知道这个字节代表的是温度还是湿度?
问题4:多设备冲突
如果总线上挂了多个设备,设备A发送的数据,哪个设备应该接收并处理?
问题5:数据丢失无法发现
如果某个字节在传输过程中完全丢失了,接收方根本不知道少了一个字节,数据解析就会全部错位。
5.2 协议解决什么问题
通信协议就是为了解决上述问题而设计的一套规则。一个好的通信协议应该能够:
- 界定数据边界:明确告诉接收方,一帧数据从哪里开始,到哪里结束
- 标识数据类型:通过命令ID等功能字段,让接收方知道这帧数据是干什么的
- 检测数据错误:通过校验码发现传输过程中的数据损坏
- 支持多设备:通过地址字段实现多设备通信
- 保证可靠性:通过应答机制、重传机制等保证数据可靠到达
5.3 协议的层次结构
通信协议通常分为多层,每层负责不同的功能:
┌─────────────────────────┐
│ 应用层 │ 业务逻辑,命令定义
├─────────────────────────┤
│ 协议层 │ 帧格式、封包、解析、校验
├─────────────────────────┤
│ 链路层 │ 帧定界、错误检测、流量控制
├─────────────────────────┤
│ 物理层 │ 电信号传输、波特率、电平标准
└─────────────────────────┘
本教程主要关注协议层的设计,即如何定义帧格式、如何封包和解析。
5.4 常见通信协议参考
在设计自己的协议之前,了解一下业界成熟的协议设计思路是很有帮助的:
Modbus协议:工业控制领域最常用的协议。采用主从模式,支持RTU(二进制)和ASCII两种模式。RTU模式使用3.5个字符时间的间隔来分隔帧。
CAN协议:汽车电子领域标准协议。使用 arbitration field(仲裁域)实现多主竞争,CRC校验保证可靠性。
LoRaWAN协议:物联网通信协议。使用MIC(消息完整性校验)防止篡改,支持AES加密。
HTTP协议:虽然是网络协议,但其设计思想(请求-响应模式、头部+正文的结构)也值得参考。
这些成熟协议的设计思路会启发我们:
- Modbus的帧定界思想 → 我们用帧头帧尾定界
- CAN的CRC校验 → 我们用CRC校验
- LoRaWAN的MIC → 我们用校验码
5.5 本章小结
本章解释了为什么需要通信协议:
- 裸数据传输存在边界不明确、错误无法检测、命令含义不明等问题
- 协议通过帧头帧尾界定边界,通过命令ID标识数据类型,通过校验码检测错误
- 了解了Modbus、CAN等成熟协议的设计思路
第6章 协议帧的基本概念
6.1 什么是协议帧
协议帧(Frame)是通信协议中数据传输的基本单位。就像写信有信封、收件人地址、正文、落款一样,协议帧也有固定的结构,包含不同用途的字段。
一个典型的协议帧结构如下:
┌──────┬──────┬──────────┬──────┬──────┬──────┐
│ 帧头 │ 命令 │ 数据长度 │ 数据 │ 校验 │ 帧尾 │
│ │ ID │ │ │ 码 │ │
└──────┴──────┴──────────┴──────┴──────┴──────┘
每个字段都有特定的含义和作用,下面我们逐一讲解。
6.2 帧头
帧头是协议帧的起始标志,通常使用一个或多个固定的特殊字节。
帧头的作用:
- 帧定界:接收方通过检测帧头来判断一帧数据的开始
- 同步:让接收方与发送方同步,准备接收后续数据
- 调试辅助:在用示波器或逻辑分析仪调试时,帧头是很好的定位参考
常见的帧头选择:
0x55 0xAA:两个字节,最常见的选择0xAA 0x55:与上面顺序相反0x5A 0xA5:另一种常见选择- 单字节
0x55或0xAA:简单协议
6.3 命令ID
命令ID(Command ID)是标识这帧数据用途的字段。它告诉接收方:"这帧数据是要你做什么"。
例如:
0x01:打开LED0x02:设置参数0x03:读取传感器数据0x04:系统重启
命令ID的设计原则:
- 每个命令ID对应一个唯一的操作
- 预留一些ID以备将来扩展
- 命令ID的分类要有规律,便于管理和查找
6.4 数据长度
数据长度字段表示"数据"部分的字节数。它的作用:
- 让接收方知道数据部分有多少字节,便于分配缓冲区
- 支持变长数据传输
- 辅助帧定界(结合帧尾和校验码)
数据长度字段可以是1字节(最大255)或2字节(最大65535),取决于你的应用需要传输多大的数据。
6.5 数据
数据部分是协议帧的核心,携带实际的有效信息(payload)。数据的内容和含义由命令ID决定。
例如:
- 命令ID为"设置参数"时,数据部分是参数值
- 命令ID为"读取传感器"时,数据部分可能是传感器类型编号
- 命令ID为"打开LED"时,可能不需要数据(数据长度为0)
6.6 校验码
校验码是用于检测数据传输错误的字段。发送方根据帧中的数据计算校验码,附加到帧中;接收方收到后重新计算校验码,与收到的校验码比较,如果不一致则说明数据出错。
常见的校验方法:
- 和校验:将各字段数据求和,取低8位。简单快速,但检测能力有限
- CRC校验:循环冗余校验。检测能力强,是工业标准
- 异或校验:将各字段数据进行异或运算。比和校验更简单
6.7 帧尾
帧尾是协议帧的结束标志,通常使用一个或多个固定的特殊字节。
帧尾的作用:
- 帧定界:标识一帧数据的结束
- 完整性检查:如果接收到的帧没有正确的帧尾,说明帧不完整或出错
常见的帧尾选择:
0xFF:单字节0x0D 0x0A:即回车换行(\r\n),便于文本调试0x55 0xAA:与帧头相同(不推荐,容易混淆)
6.8 完整的协议帧示例
以一个简单的协议帧为例:
帧头 命令ID 数据长度 数据 校验码 帧尾
0x55 0x02 0x02 0x0A 0x0B 0x19 0xFF
这个帧的含义:
- 帧头
0x55:标识帧开始 - 命令ID
0x02:设置参数命令 - 数据长度
0x02:有2个字节的数据 - 数据
0x0A 0x0B:两个参数值 - 校验码
0x19:和校验值(0x02+0x02+0x0A+0x0B = 0x19) - 帧尾
0xFF:标识帧结束
6.9 本章小结
本章介绍了协议帧的基本结构:
- 帧头:标识帧开始,用于帧定界和同步
- 命令ID:标识数据用途
- 数据长度:表示数据部分的字节数
- 数据:携带有效信息
- 校验码:检测传输错误
- 帧尾:标识帧结束
第7章 帧头与帧尾设计
7.1 为什么很多协议的帧头都喜欢用0x55、0xAA
这是一个非常好的问题,也是原博客文章中重点讨论的内容。让我们深入理解其中的原因。
原因一:调试定位
将 0x55 转为二进制:01010101
将 0xAA 转为二进制:10101010
你会发现这两个值的特点是:每个比特都在0和1之间交替翻转。这种01交替的规律在示波器上表现为一个非常标准的方波信号。
当你用示波器调试串口信号时,如果数据流中有一段标准的方波,你就能很容易地定位到帧头的位置。相比之下,如果帧头是 0x00(全0)或 0xFF(全1),在示波器上就是一条直线,很难与其他数据区分。
原因二:自动波特率识别
有些产品支持自动波特率识别功能。其原理是监测信号线上每个比特周期的跳变,通过测量对方发送的连续跳变间隔来自动校准波特率。
0x55(01010101)和 0xAA(10101010)具有非常标准的上下沿跳变模式,每个比特都有跳变,非常适合用于自动波特率识别。
具体原理:
- 假设检测到3次跳变时间点为 t1、t2、t3
- 两个跳变之间的间隔就是一个比特周期
- 实测波特率 = 1 / ((t3 - t1) / 2)
例如,如果 t1=100μs, t2=204μs, t3=308μs(约104μs间隔):
- 波特率 = 1 / ((308 - 100) / 2) = 1 / 104μs ≈ 9615 ≈ 9600
原因三:防止数据中的误匹配
单字节的帧头容易在数据中偶然出现,导致误判。使用两个字节 0x55 0xAA 作为帧头,数据中同时出现这两个字节连续的概率大大降低,提高了帧定界的可靠性。
7.2 帧头设计的一般原则
- 使用多字节帧头:单字节帧头容易在数据中误匹配,建议使用2字节或更多字节的帧头
- 选择不常用的值 :避免选择
0x00和0xFF,因为它们太常见了 - 考虑可观测性:选择在示波器上容易识别的值
- 避免与数据冲突:如果可能,使用转义机制(类似PPP协议的转义)
7.3 转义机制(高级)
在某些高可靠性要求的场景中,数据部分可能包含与帧头或帧尾相同的字节,导致误判。解决这个问题的方法是转义机制。
转义机制的核心思想是:定义一个特殊的转义字节(如 0x7D),当数据中出现帧头、帧尾或转义字节本身时,在其前面插入转义字节,并将原字节做一定变换。
例如,PPP协议的转义规则:
0x7E(帧标志)在数据中出现时,替换为0x7D 0x5E0x7D(转义字节)在数据中出现时,替换为0x7D 0x5D- 接收方做反向还原
这种机制保证了帧头和帧尾的唯一性,但增加了协议的复杂度和处理开销。在简化版协议中通常不使用。
7.4 帧尾设计
帧尾的设计原则与帧头类似:
- 使用固定特殊字节
- 与帧头不同,便于区分
- 可以使用多字节提高可靠性
在实际应用中,帧尾的作用主要是辅助帧定界和完整性检查。有些协议甚至不使用帧尾,而是通过数据长度字段来确定帧的结束位置。
7.5 本章小结
本章深入讲解了帧头和帧尾的设计:
0x55、0xAA作为帧头的原因:调试定位和自动波特率识别- 帧头设计应使用多字节、不常用的值
- 转义机制可以防止数据误匹配
- 帧尾辅助帧定界和完整性检查
第8章 命令ID设计思想
8.1 命令ID的作用
命令ID是协议帧中最重要的字段之一,它决定了这帧数据的"意图"。通过命令ID,接收方知道应该执行什么操作。
用一个生活中的例子来理解:命令ID就像你给快递员的指令编号。编号01表示"送达签收",编号02表示"退货取件",编号03表示"更换地址"。快递员看到编号就知道该做什么。
8.2 命令ID的使用方式
假设现在有两个设备:一个发送端(设备A)和一个接收端(设备B)。
设备A向设备B发送几帧数据,其中命令ID字段分别是 0x01、0x02、0x03:
- 命令ID
0x01:接收端解析后执行"点亮LED1" - 命令ID
0x02:接收端解析后执行"点亮LED2" - 命令ID
0x03:接收端解析后执行"点亮LED3"
简而言之,不同的命令ID对应不同的处理动作。
8.3 命令ID的分类设计
在复杂系统中,命令可能很多。为了便于管理,可以将命令ID进行分类:
按功能模块分类:
| 命令ID范围 | 功能模块 | 说明 |
|---|---|---|
| 0x01~0x0F | 系统命令 | 重启、版本查询、配置等 |
| 0x10~0x1F | GPIO控制 | LED、继电器、按键等 |
| 0x20~0x2F | 传感器 | 温湿度、气压、光照等 |
| 0x30~0x3F | 通信配置 | WiFi、蓝牙、LoRa等 |
| 0x40~0x4F | 数据传输 | 文件传输、固件升级等 |
| 0x50~0xFF | 预留扩展 | 未来功能扩展 |
按操作类型分类:
| 命令ID高位 | 操作类型 | 说明 |
|---|---|---|
| 0x?0 | 读操作 | 读取数据或状态 |
| 0x?1 | 写操作 | 设置参数或配置 |
| 0x?2 | 控制操作 | 开关、启停等 |
| 0x?3 | 响应/应答 | 对请求的响应 |
| 0x?F | 错误/异常 | 错误报告 |
这种分类方式使得命令ID有规律可循,便于维护和扩展。例如,命令ID 0x21 表示"写传感器参数",0x20 表示"读传感器数据"。
8.4 专业版的命令ID设计------功能ID + 命令ID
在专业版协议中,原博客文章使用了功能ID + 命令ID的双字段设计。这种设计提供了更细粒度的命令分类:
- 功能ID(Function ID):标识大的功能模块,如系统管理、传感器、通信等
- 命令ID(Command ID):在功能模块内的具体命令,如读、写、控制等
例如:
- 功能ID
0x01(系统管理)+ 命令ID0x01(重启)→ 系统重启 - 功能ID
0x01(系统管理)+ 命令ID0x02(查询版本)→ 查询固件版本 - 功能ID
0x02(传感器)+ 命令ID0x01(读取)→ 读取传感器数据 - 功能ID
0x02(传感器)+ 命令ID0x02(配置)→ 配置传感器参数
这种设计的好处是:
- 命令空间更大(功能ID × 命令ID = 256 × 256 = 65536种命令)
- 分类更清晰,便于查找和管理
- 扩展性好,新增功能模块只需添加新的功能ID
8.5 命令ID与查表法的结合
在协议解析时,命令ID是触发处理函数的依据。通过查表法,可以将命令ID与对应的处理函数建立映射关系,实现命令的灵活分发。这部分将在第五篇详细讲解。
8.6 本章小结
本章讲解了命令ID的设计思想:
- 命令ID标识帧数据的意图
- 可以按功能模块或操作类型进行分类设计
- 专业版使用功能ID + 命令ID的双字段设计
- 命令ID是查表法协议解析的核心依据
第9章 数据长度与数据字段
9.1 数据长度字段的作用
数据长度字段表示"数据"部分的字节数。它在协议帧中扮演着关键角色:
作用1:支持变长数据
不同的命令需要携带的数据量不同。比如"打开LED"命令不需要数据(长度为0),而"传输文件"命令可能需要携带数百字节的数据(长度为N)。数据长度字段使得协议能够灵活支持不同长度的数据。
作用2:辅助帧定界
接收方通过数据长度可以知道数据部分在哪里结束,从而确定校验码和帧尾的位置。这在没有帧尾的协议中尤为重要。
作用3:缓冲区分配
接收方在解析到数据长度后,可以预先分配足够大的缓冲区来存储数据,避免缓冲区溢出。
9.2 数据长度字段的宽度的选择
1字节(uint8_t)数据长度:
- 最大数据长度:255字节
- 适合大多数嵌入式应用
- 优点:节省帧空间
- 缺点:无法传输超过255字节的数据
2字节(uint16_t)数据长度:
- 最大数据长度:65535字节
- 适合需要传输较大数据的应用(如固件升级、文件传输)
- 优点:支持大数据量
- 缺点:多占1字节
在简化版协议中,原博客文章使用了 uint16_t(2字节)作为数据长度,这是一个兼顾灵活性和实用性的选择。
9.3 数据字段的设计
数据字段(Payload)是协议帧中携带有效信息的部分。数据的内容和格式由命令ID决定。
固定格式数据:
对于参数配置类的命令,数据通常是固定格式的。例如:
命令ID: 0x02 (设置参数)
数据长度: 5
数据: [参数1] [参数2] [参数3] [参数4] [参数5]
0x01 0x02 0x03 0x04 0x05
每个参数占1个字节,5个参数共5字节。接收方按照固定格式解析每个参数。
变长格式数据:
对于数据传输类的命令,数据长度是可变的。例如传输一个字符串:
命令ID: 0x10 (发送字符串)
数据长度: 12
数据: "Hello World!" (ASCII码)
混合格式数据:
有些命令的数据可能包含多种类型的信息:
命令ID: 0x20 (传感器数据包)
数据长度: 8
数据: [温度(2字节)] [湿度(2字节)] [气压(2字节)] [光照(2字节)]
0x00 0x19 0x00 0x32 0x01 0x84 0x02 0x58
9.4 多字节数据的字节序问题
当数据字段中包含多字节数据(如uint16_t、uint32_t)时,就需要考虑字节序(大小端)的问题。这在原博客文章中也有提及,我们将在第11章详细讲解。
9.5 数据字段的实际应用举例
假设现在有两个设备,设备1和设备2。设备1需要通过设备2去控制或配置设备2的功能参数(假定配置5个参数,分别配置为1、2、3、4、5)。
那么在这帧数据中:
- "数据长度"字段就等于5
- "数据字段"就是5个字节,分别是1、2、3、4、5
完整的数据帧(简化版协议):
帧头 命令ID 数据长度(2字节) 数据 校验码 帧尾
0x55 0x02 0x05 0x00 0x01 0x02 0x03 0x04 0x05 ?? 0xFF
9.6 本章小结
本章讲解了数据长度和数据字段的设计:
- 数据长度字段支持变长数据、辅助帧定界、缓冲区分配
- 数据长度可以选择1字节或2字节
- 数据字段的内容和格式由命令ID决定
- 数据可以是固定格式、变长格式或混合格式
第10章 校验码设计
10.1 什么是校验码
校验码是一种验算机制,用于确保双方数据通信的完整性和正确性。
打个比方:你去银行转账,转账金额是1000元。银行柜员会让你再确认一遍金额,这个"确认"的过程就是一种校验。在通信中,发送方对数据进行计算得到校验码,接收方对收到的数据进行同样的计算,如果两次计算结果一致,就认为数据完整且正确。
10.2 为什么需要校验码
在串口通信中,数据通过电信号传输。在传输过程中,可能受到各种干扰:
- 电磁干扰(EMI):工业环境中的电机、变频器等会产生强电磁干扰
- 信号反射:阻抗不匹配导致信号反射,造成数据错误
- 时钟偏差:收发双方波特率不完全一致导致采样错误
- 硬件故障:线缆接触不良、驱动电路故障等
这些因素都可能导致数据在传输过程中发生错误(某个比特从0变成1,或从1变成0)。校验码的作用就是检测出这些错误,避免使用错误的数据。
10.3 常见的校验方法
10.3.1 和校验
和校验是最简单的校验方法。将需要校验的所有字节求和,取结果的低8位(或低16位)作为校验码。
计算方法:
假设我们的协议帧格式是"帧头 + 命令ID + 数据长度 + 数据 + 校验码 + 帧尾",校验范围是"命令ID + 数据长度 + 数据"。
校验码 = (命令ID + 数据长度低字节 + 数据长度高字节 + 数据[0] + 数据[1] + ... + 数据[n-1]) & 0xFF
代码实现:
c
uint8_t calculate_checksum(uint8_t cmd, uint16_t len, const uint8_t *data)
{
uint8_t sum = cmd;
sum += (len & 0xFF); // 添加数据长度低字节
sum += ((len >> 8) & 0xFF); // 添加数据长度高字节
for (uint16_t i = 0; i < len; i++) {
sum += data[i]; // 添加数据内容
}
return sum; // 自动截断为8位
}
举例:
命令ID = 0x02,数据长度 = 2,数据 = {0x0A, 0x0B}
校验码 = (0x02 + 0x02 + 0x00 + 0x0A + 0x0B) & 0xFF = 0x19
和校验的优缺点:
- 优点:计算简单,速度快,代码量小
- 缺点:检测能力有限,无法检测字节顺序交换、多个错误相互抵消等情况
例如,数据 {0x01, 0x02} 和 {0x02, 0x01} 的和校验结果相同(都是0x03),无法区分。
10.3.2 异或校验
异或校验将所有需要校验的字节进行异或(XOR)运算,结果作为校验码。
计算方法:
校验码 = 命令ID ^ 数据长度低字节 ^ 数据长度高字节 ^ 数据[0] ^ 数据[1] ^ ... ^ 数据[n-1]
代码实现:
c
uint8_t calculate_xor_checksum(uint8_t cmd, uint16_t len, const uint8_t *data)
{
uint8_t xor_val = cmd;
xor_val ^= (len & 0xFF);
xor_val ^= ((len >> 8) & 0xFF);
for (uint16_t i = 0; i < len; i++) {
xor_val ^= data[i];
}
return xor_val;
}
异或校验的优缺点:
- 优点:同样简单快速
- 缺点:检测能力有限,两个相同字节的错误会相互抵消
10.3.3 CRC循环冗余校验
CRC是一种更强大的校验方法,通过多项式除法运算来检测错误。CRC能够检测出:
- 所有单比特错误
- 所有双比特错误
- 所有奇数个比特错误
- 所有长度小于等于CRC位数的突发错误
- 大部分长度大于CRC位数的突发错误
CRC是工业标准,广泛应用于各种通信协议中。CRC的计算比和校验复杂,但检测能力远强于和校验。我们将在第19章详细讲解CRC。
10.4 校验范围的选择
校验码应该校验哪些字段?通常有以下几种选择:
方案一:只校验关键数据
校验范围 = 命令ID + 数据长度 + 数据
帧头和帧尾不参与校验,因为它们是固定值,校验它们没有意义。
方案二:校验除校验码外的所有字段
校验范围 = 帧头 + 命令ID + 数据长度 + 数据 + 帧尾
方案三:校验整个帧
校验范围 = 整个帧(包括帧头帧尾,但不包括校验码本身)
在简化版协议中,原博客文章选择了方案一:校验范围是"命令ID + 数据长度 + 数据"。这是一个合理的选择,既保证了关键数据的正确性,又避免了不必要的计算。
10.5 接收端的校验流程
接收端收到一帧数据后的校验流程:
- 解析出命令ID、数据长度、数据
- 用相同的校验算法重新计算校验码
- 将计算结果与帧中的校验码字段比较
- 如果一致,说明数据正确,执行相应处理
- 如果不一致,说明数据出错,丢弃这帧数据
c
bool verify_checksum(uint8_t cmd, uint16_t len, const uint8_t *data, uint8_t received_checksum)
{
uint8_t calculated = calculate_checksum(cmd, len, data);
return (calculated == received_checksum);
}
10.6 本章小结
本章详细讲解了校验码的设计:
- 校验码用于检测传输过程中的数据错误
- 和校验简单快速但检测能力有限
- 异或校验与和校验类似
- CRC校验检测能力强,是工业标准
- 校验范围通常选择关键数据字段
- 接收端通过重新计算并比较来验证数据正确性
第11章 大端与小端
11.1 什么是大小端
大小端(Endianness)是指多字节数据在内存中的存储顺序。这是原博客文章中补充说明的一个重要概念。
计算机中最小的可寻址存储单元是字节(8位)。但有些数据类型需要多个字节来存储,比如 uint16_t(2字节)、uint32_t(4字节)。这就产生了一个问题:多字节数据的各个字节在内存中应该怎么排列?
小端模式:
- 低字节存放在低位地址
- 高字节存放在高位地址
大端模式:
- 低字节存放在高位地址
- 高字节存放在低位地址
11.2 用代码例子理解大小端
原博客文章中举了一个很好的例子:
c
uint32_t num = 0x12345678;
其中:
0x12是最高字节0x78是最低字节uint32_t占4个字节
假设变量 num 存放的地址是 0x00 到 0x03:
小端模式下:
| 地址 | 内容 |
|---|---|
| 0x00 | 0x78(最低字节) |
| 0x01 | 0x56 |
| 0x02 | 0x34 |
| 0x03 | 0x12(最高字节) |
大端模式下:
| 地址 | 内容 |
|---|---|
| 0x00 | 0x12(最高字节) |
| 0x01 | 0x34 |
| 0x02 | 0x56 |
| 0x03 | 0x78(最低字节) |
11.3 大小端在串口通信中的影响
在串口通信中,多字节数据(如uint16_t的数据长度字段、uint16_t的CRC校验码)需要逐字节传输。传输的顺序(先传高字节还是先传低字节)就是大小端的问题。
在简化版协议中,数据长度字段是 uint16_t(2字节),采用小端模式传输:
- 先传输低字节
- 后传输高字节
例如,数据长度为 2(0x0002):
- 小端传输顺序:
0x02(低字节),0x00(高字节) - 大端传输顺序:
0x00(高字节),0x02(低字节)
11.4 如何用代码验证MCU的大小端
c
void check_endianness(void)
{
uint32_t num = 0x12345678;
uint8_t *p = (uint8_t*)#
if (p[0] == 0x78) {
printf("小端模式\n");
} else if (p[0] == 0x12) {
printf("大端模式\n");
}
}
对于大多数Cortex-M系列MCU(如STM32),默认采用小端模式。但也可以通过配置SCB->CCR寄存器来切换。
11.5 协议中统一大小端的重要性
在协议设计中,必须明确规定多字节字段的大小端,否则发送方和接收方的理解不一致,就会导致解析错误。
原博客文章中的简化版协议采用了小端模式,这在封包和解析代码中都有体现:
封包时(小端):
c
buffer[idx++] = frame->len & 0xFF; // 先放低字节
buffer[idx++] = (frame->len >> 8) & 0xFF; // 再放高字节
解析时(小端):
c
uint16_t len = buffer[idx] | (buffer[idx+1] << 8); // 低字节在前
11.6 网络字节序
在网络通信中,通常使用大端模式,称为"网络字节序"(Network Byte Order)。TCP/IP协议规定使用大端模式。而在嵌入式系统中,由于大多数MCU使用小端模式,串口通信协议通常也使用小端模式。
如果需要在不同大小端的系统间通信,可以使用以下函数进行转换:
htons():主机字节序转网络字节序(16位)ntohs():网络字节序转主机字节序(16位)htonl():主机字节序转网络字节序(32位)ntohl():网络字节序转主机字节序(32位)
11.7 本章小结
本章详细讲解了大端与小端的概念:
- 小端:低字节存低地址,高字节存高地址
- 大端:低字节存高地址,高字节存低地址
- 大多数Cortex-M MCU默认小端模式
- 协议设计必须明确规定大小端
- 简化版协议采用小端模式
第三篇 简化版协议设计
第12章 简化版协议格式详解
12.1 协议格式总览
原博客文章中设计的简化版协议帧格式如下:
┌───────┬────────┬───────────────┬────────┬────────┬───────┐
│ 帧头 │ 命令ID │ 数据长度 │ 数据 │ 校验码 │ 帧尾 │
│ 1字节 │ 1字节 │ 2字节(小端) │ N字节 │ 1字节 │ 1字节 │
└───────┴────────┴───────────────┴────────┴────────┴───────┘
各字段详细说明:
| 字段 | 大小 | 说明 |
|---|---|---|
| 帧头 | 1字节 | 固定值 0x55,标识帧开始 |
| 命令ID | 1字节 | 标识命令类型,范围 0x00~0xFF |
| 数据长度 | 2字节 | 数据部分的字节数,小端模式(先低字节后高字节) |
| 数据 | N字节 | 有效数据内容,N = 数据长度,可以为0 |
| 校验码 | 1字节 | 和校验,校验范围:命令ID + 数据长度 + 数据 |
| 帧尾 | 1字节 | 固定值 0xFF,标识帧结束 |
12.2 帧的最小长度
当数据长度为0时(无数据),帧的最小长度为:
- 帧头(1) + 命令ID(1) + 数据长度(2) + 校验码(1) + 帧尾(1) = 6字节
12.3 帧的最大长度
数据长度为2字节(uint16_t),最大值为65535。但实际应用中,受限于缓冲区大小和通信效率,通常不会传输这么大的数据。建议将单帧数据限制在256字节以内。
12.4 协议格式的优缺点分析
优点:
- 结构简单,容易理解和实现
- 适合初学者学习和使用
- 帧开销小(固定开销只有5字节:帧头+命令ID+数据长度+校验码+帧尾)
- 和校验计算简单,适合资源有限的MCU
缺点:
- 帧头只有1字节,容易在数据中误匹配
- 和校验检测能力有限
- 没有源地址和目的地址,不支持多设备通信
- 没有序列号,不支持应答确认和重传
12.5 适用场景
简化版协议适合以下场景:
- 学生做小项目开发
- 两个设备之间的简单点对点通信
- 学习和理解协议设计的基本概念
- 原型验证和快速开发
12.6 本章小结
本章详细介绍了简化版协议的帧格式:
- 帧头(0x55) + 命令ID + 数据长度(2字节小端) + 数据 + 校验码 + 帧尾(0xFF)
- 最小帧长6字节,无数据时数据长度为0
- 适合学生项目和小型应用
- 优点是简单易用,缺点是可靠性和扩展性有限
第13章 简化版协议举例说明
13.1 示例一:打开LED灯(无应答)
需求描述: 让单片机一去控制单片机二打开LED灯,无需应答。双方约定 0x01 为打开LED的指令,且无需其他参数配置。
分析:
- 命令ID = 0x01(打开LED)
- 不需要参数,所以数据长度 = 0
- 不需要数据字段
- 无需应答,即单片机一发送后不需要单片机二回复
完整数据帧:
帧头 命令ID 数据长度(小端) 校验码 帧尾
0x55 0x01 0x00 0x00 0x01 0xFF
校验码计算:
校验码 = (命令ID + 数据长度低字节 + 数据长度高字节) & 0xFF
校验码 = (0x01 + 0x00 + 0x00) & 0xFF = 0x01
逐字节解释:
| 字节位置 | 值 | 含义 |
|---|---|---|
| 第1字节 | 0x55 | 帧头,标识帧开始 |
| 第2字节 | 0x01 | 命令ID,表示"打开LED" |
| 第3字节 | 0x00 | 数据长度低字节 |
| 第4字节 | 0x00 | 数据长度高字节(数据长度=0x0000=0) |
| 第5字节 | 0x01 | 校验码(0x01+0x00+0x00=0x01) |
| 第6字节 | 0xFF | 帧尾,标识帧结束 |
通信流程:
单片机1 ────[0x55 0x01 0x00 0x00 0x01 0xFF]────> 单片机2
↓
打开LED
(不回复)
13.2 示例二:设置参数(需应答)
需求描述: 让单片机一设置单片机二的两个参数为 0x0A 和 0x0B,需要应答。双方约定 0x02 为设置参数的指令。
分析:
- 命令ID = 0x02(设置参数)
- 需要配置两个参数:0x0A 和 0x0B
- 数据长度 = 2
- 数据 = {0x0A, 0x0B}
- 需要应答
单片机一发送的完整数据帧:
帧头 命令ID 数据长度(小端) 数据 校验码 帧尾
0x55 0x02 0x02 0x00 0x0A 0x0B 0x19 0xFF
校验码计算:
校验码 = (0x02 + 0x02 + 0x00 + 0x0A + 0x0B) & 0xFF = 0x19
逐字节解释:
| 字节位置 | 值 | 含义 |
|---|---|---|
| 第1字节 | 0x55 | 帧头 |
| 第2字节 | 0x02 | 命令ID,表示"设置参数" |
| 第3字节 | 0x02 | 数据长度低字节 |
| 第4字节 | 0x00 | 数据长度高字节(数据长度=0x0002=2) |
| 第5字节 | 0x0A | 数据1:第一个参数值 |
| 第6字节 | 0x0B | 数据2:第二个参数值 |
| 第7字节 | 0x19 | 校验码(0x02+0x02+0x00+0x0A+0x0B=0x19) |
| 第8字节 | 0xFF | 帧尾 |
单片机二应答的数据帧:
单片机二接收到单片机一发来的数据后,进行参数配置并立即应答:
帧头 命令ID 数据长度(小端) 数据 校验码 帧尾
0x55 0x02 0x01 0x00 0x01 0x04 0xFF
应答数据解释:
- 命令ID
0x02:与请求命令ID相同,表示这是对设置参数命令的应答 - 数据长度
1:应答数据为1个字节 - 数据
0x01:应答状态码,0x01表示设置成功(可以约定0x00表示失败) - 校验码 = (0x02 + 0x01 + 0x00 + 0x01) & 0xFF = 0x04
通信流程:
单片机1 ────[0x55 0x02 0x02 0x00 0x0A 0x0B 0x19 0xFF]────> 单片机2
↓
设置参数=0x0A, 0x0B
↓
单片机1 <────[0x55 0x02 0x01 0x00 0x01 0x04 0xFF]───────── 单片机2
↓
收到应答,设置成功
13.3 更多示例练习
示例三:读取温度数据
需求:单片机一请求单片机二读取温度传感器数据。约定命令ID 0x03 为读取温度。
请求帧(无数据):
0x55 0x03 0x00 0x00 0x03 0xFF
校验码 = (0x03 + 0x00 + 0x00) & 0xFF = 0x03
应答帧(假设温度为25.5℃,用uint16_t存储,值为255,即0x00FF):
0x55 0x03 0x02 0x00 0xFF 0x00 0x05 0xFF
校验码 = (0x03 + 0x02 + 0x00 + 0xFF + 0x00) & 0xFF = 0x04
等等,让我重新计算:0x03 + 0x02 + 0x00 + 0xFF + 0x00 = 0x104,取低8位 = 0x04
示例四:系统重启
需求:单片机一命令单片机二重启。约定命令ID 0x04 为系统重启。
请求帧:
0x55 0x04 0x00 0x00 0x04 0xFF
校验码 = (0x04 + 0x00 + 0x00) & 0xFF = 0x04
示例五:发送字符串
需求:单片机一向单片机二发送字符串"OK"(ASCII码 0x4F 0x4B)。约定命令ID 0x05 为发送字符串。
数据帧:
0x55 0x05 0x02 0x00 0x4F 0x4B 0x5A 0xFF
校验码 = (0x05 + 0x02 + 0x00 + 0x4F + 0x4B) & 0xFF = 0x5A
13.4 本章小结
本章通过多个示例演示了简化版协议的使用:
- 示例一:打开LED(无数据,无应答)
- 示例二:设置参数(有数据,有应答)
- 示例三:读取温度(请求-响应模式)
- 示例四:系统重启(无数据命令)
- 示例五:发送字符串(变长数据)
第14章 简化版协议封包代码实现
14.1 封包的概念
封包(Pack)是指将结构化的数据(命令ID、数据长度、数据等)按照协议格式组装成字节序列的过程。就像打包快递一样,把不同的物品(字段)按顺序放进箱子里(缓冲区),封好口(帧尾),贴上标签(帧头)。
14.2 第一步:定义协议帧结构体
首先,我们定义一个结构体来描述协议帧的各个字段:
c
typedef struct
{
uint8_t head; // 帧头
uint8_t cmd; // 命令ID
uint16_t len; // 数据长度(小端模式)
uint8_t *data; // 数据内容
uint8_t checksum; // 校验码(和校验)
uint8_t tail; // 帧尾
} SimpleProtocolFrame;
结构体各字段说明:
head:帧头,固定为0x55cmd:命令ID,1字节,标识命令类型len:数据长度,2字节(uint16_t),小端模式data:数据指针,指向数据内容的起始地址checksum:校验码,1字节,和校验tail:帧尾,固定为0xFF
为什么要用结构体?
使用结构体可以将协议帧的所有相关信息组织在一起,便于管理和传递。通过结构体指针,可以方便地在函数之间传递帧数据。
14.3 第二步:计算校验和
校验和的计算函数对"命令ID + 数据长度 + 数据内容"进行求和:
c
uint8_t calculate_checksum(uint8_t cmd, uint16_t len, const uint8_t *data)
{
uint8_t sum = cmd;
sum += (len & 0xFF); // 添加数据长度(小端模式:先低字节后高字节)
sum += ((len >> 8) & 0xFF);
for (uint16_t i = 0; i < len; i++) // 添加数据内容
{
sum += data[i];
}
return sum;
}
代码逐行解析:
uint8_t sum = cmd;:初始化校验和为命令ID的值sum += (len & 0xFF);:加上数据长度的低字节。len & 0xFF提取len的最低8位sum += ((len >> 8) & 0xFF);:加上数据长度的高字节。len >> 8将len右移8位,& 0xFF提取最低8位for循环:遍历数据数组的每个字节,累加到sumreturn sum;:返回校验和。由于sum是uint8_t类型,自动截断为8位
为什么用 uint8_t 而不是 uint16_t?
因为我们只需要8位的校验码。当累加结果超过255时,uint8_t 会自动溢出回绕,相当于取模256,这正是我们想要的效果。
14.4 第三步:协议封包
封包函数将结构体中的字段按协议格式写入发送缓冲区:
c
// 参数:frame - 协议帧结构体 buffer - 发送缓冲区
// 返回:封包后的数据长度
uint16_t simple_protocol_pack(const SimpleProtocolFrame *frame, uint8_t *buffer)
{
uint16_t idx = 0;
buffer[idx++] = frame->head;
buffer[idx++] = frame->cmd;
buffer[idx++] = frame->len & 0xFF; // 低字节
buffer[idx++] = (frame->len >> 8) & 0xFF; // 高字节
if (frame->len > 0 && frame->data != NULL) {
memcpy(&buffer[idx], frame->data, frame->len);
idx += frame->len;
}
buffer[idx++] = frame->checksum;
buffer[idx++] = frame->tail;
return idx;
}
代码逐行解析:
uint16_t idx = 0;:缓冲区写入索引,从0开始buffer[idx++] = frame->head;:写入帧头,然后idx自增buffer[idx++] = frame->cmd;:写入命令IDbuffer[idx++] = frame->len & 0xFF;:写入数据长度的低字节(小端模式,低字节在前)buffer[idx++] = (frame->len >> 8) & 0xFF;:写入数据长度的高字节if (frame->len > 0 && frame->data != NULL):检查是否有数据需要写入memcpy(&buffer[idx], frame->data, frame->len);:将数据内容复制到缓冲区idx += frame->len;:更新索引,跳过已写入的数据buffer[idx++] = frame->checksum;:写入校验码buffer[idx++] = frame->tail;:写入帧尾return idx;:返回封包后的总字节数
小端模式的体现:
注意第4、5行,数据长度是 uint16_t 类型(2字节),但在写入缓冲区时被拆分成两个字节:
- 先写低字节:
frame->len & 0xFF - 后写高字节:
(frame->len >> 8) & 0xFF
这就是小端模式在代码中的体现:低字节先传输。
14.5 第四步:协议发送
发送函数是面向应用的封装,它接收命令ID和数据,自动完成结构体填充、校验计算、封包和发送:
c
void send_protocol_frame(uint8_t cmd, const uint8_t *data, uint16_t data_len)
{
SimpleProtocolFrame frame;
uint8_t buffer[256]; // 发送缓冲区
// 填充帧结构
frame.head = 0x55;
frame.cmd = cmd;
frame.len = data_len;
frame.data = (uint8_t*)data; // 去除const限定
frame.checksum = calculate_checksum(cmd, data_len, data);
frame.tail = 0xFF;
// 封包
uint16_t frame_len = simple_protocol_pack(&frame, buffer);
// 发送
uart_send_buffer(buffer, frame_len);
}
代码解析:
- 创建一个
SimpleProtocolFrame结构体变量 - 分配一个256字节的发送缓冲区
- 填充结构体的各个字段:
- 帧头固定为
0x55 - 命令ID使用传入的
cmd参数 - 数据长度使用传入的
data_len参数 - 数据指针指向传入的
data - 调用
calculate_checksum计算校验码 - 帧尾固定为
0xFF
- 帧头固定为
- 调用
simple_protocol_pack将结构体封包到缓冲区 - 调用
uart_send_buffer发送缓冲区中的数据
为什么需要去除const限定?
因为结构体中 data 字段定义为 uint8_t *(非const),而函数参数是 const uint8_t *。使用强制类型转换 (uint8_t*) 去除const限定,使类型匹配。在更好的设计中,可以将结构体的 data 字段也定义为 const uint8_t *。
14.6 第五步:应用示例
c
// 示例1:发送无数据帧(打开LED)
send_protocol_frame(0x01, NULL, 0);
// 示例2:发送带参数帧
uint8_t params[] = {0x0A, 0x0B};
send_protocol_frame(0x02, params, sizeof(params));
示例1解析:
- 命令ID = 0x01(打开LED)
- 数据 = NULL(无数据)
- 数据长度 = 0
- 发出的字节序列:
0x55 0x01 0x00 0x00 0x01 0xFF
示例2解析:
- 命令ID = 0x02(设置参数)
- 数据 = {0x0A, 0x0B}
- 数据长度 = 2(sizeof(params) = 2)
- 发出的字节序列:
0x55 0x02 0x02 0x00 0x0A 0x0B 0x19 0xFF
14.7 封包流程总结图
应用层调用 send_protocol_frame(cmd, data, len)
│
▼
┌─────────────────────┐
│ 填充 SimpleProtocolFrame │
│ head = 0x55 │
│ cmd = cmd │
│ len = len │
│ data = data │
│ checksum = calc(...) │
│ tail = 0xFF │
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ simple_protocol_pack │
│ 按顺序写入buffer: │
│ [head][cmd][len_lo] │
│ [len_hi][data...] │
│ [checksum][tail] │
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ uart_send_buffer() │
│ 通过UART硬件发送 │
└─────────────────────┘
14.8 本章小结
本章详细讲解了简化版协议的封包代码实现:
- 定义协议帧结构体
SimpleProtocolFrame - 实现校验和计算函数
calculate_checksum - 实现封包函数
simple_protocol_pack - 实现发送函数
send_protocol_frame - 通过应用示例演示了使用方法
第15章 简化版协议解析代码实现
15.1 解析的概念
解析(Parse/Unpack)是封包的逆过程。封包是将结构化数据组装成字节序列,解析则是从接收到的字节序列中提取出结构化数据。
15.2 接收状态机
在接收不定长数据时,最可靠的方法是使用状态机。状态机通过跟踪当前接收状态,逐步解析协议帧。
状态机的状态定义:
┌──────────┐
│ WAIT_HEAD │ ← 等待帧头
└─────┬────┘
│ 收到0x55
▼
┌──────────┐
│ WAIT_CMD │ ← 等待命令ID
└─────┬────┘
│ 收到1字节
▼
┌────────────┐
│ WAIT_LEN_LO │ ← 等待数据长度低字节
└─────┬──────┘
│ 收到1字节
▼
┌────────────┐
│ WAIT_LEN_HI │ ← 等待数据长度高字节
└─────┬──────┘
│ 收到1字节
▼
┌──────────┐
┌───→│ RECV_DATA │ ← 接收数据
│ └─────┬────┘
│ │ 数据接收完毕
│ ▼
│ ┌────────────┐
│ │WAIT_CHECKSUM│ ← 等待校验码
│ └─────┬──────┘
│ │ 收到1字节
│ ▼
│ ┌──────────┐
│ │ WAIT_TAIL │ ← 等待帧尾
│ └─────┬────┘
│ │ 收到0xFF
│ ▼
│ ┌──────────────┐
│ │ FRAME_COMPLETE│ ← 帧完成,校验处理
│ └─────┬────────┘
│ │
└──────────┘ (回到等待帧头)
15.3 状态机代码实现
c
#include <string.h>
#include <stdbool.h>
// 协议常量
#define PROTOCOL_HEAD 0x55
#define PROTOCOL_TAIL 0xFF
#define MAX_DATA_LEN 255
// 接收状态
typedef enum {
STATE_WAIT_HEAD, // 等待帧头
STATE_WAIT_CMD, // 等待命令ID
STATE_WAIT_LEN_LO, // 等待数据长度低字节
STATE_WAIT_LEN_HI, // 等待数据长度高字节
STATE_RECV_DATA, // 接收数据
STATE_WAIT_CHECKSUM, // 等待校验码
STATE_WAIT_TAIL // 等待帧尾
} ParseState;
// 接收缓冲与状态
typedef struct {
ParseState state; // 当前状态
uint8_t cmd; // 接收到的命令ID
uint16_t data_len; // 接收到的数据长度
uint16_t data_index; // 当前数据接收索引
uint8_t data[MAX_DATA_LEN];// 数据缓冲区
uint8_t checksum; // 接收到的校验码
bool frame_ready; // 帧是否完成
} ProtocolParser;
// 初始化解析器
void parser_init(ProtocolParser *parser)
{
parser->state = STATE_WAIT_HEAD;
parser->data_index = 0;
parser->data_len = 0;
parser->frame_ready = false;
}
// 喂入一个字节,驱动状态机
void parser_feed_byte(ProtocolParser *parser, uint8_t byte)
{
switch (parser->state) {
case STATE_WAIT_HEAD:
if (byte == PROTOCOL_HEAD) {
parser->state = STATE_WAIT_CMD;
}
break;
case STATE_WAIT_CMD:
parser->cmd = byte;
parser->state = STATE_WAIT_LEN_LO;
break;
case STATE_WAIT_LEN_LO:
parser->data_len = byte; // 低字节
parser->state = STATE_WAIT_LEN_HI;
break;
case STATE_WAIT_LEN_HI:
parser->data_len |= ((uint16_t)byte << 8); // 高字节
parser->data_index = 0;
if (parser->data_len == 0) {
parser->state = STATE_WAIT_CHECKSUM;
} else if (parser->data_len <= MAX_DATA_LEN) {
parser->state = STATE_RECV_DATA;
} else {
// 数据长度超限,重置
parser->state = STATE_WAIT_HEAD;
}
break;
case STATE_RECV_DATA:
parser->data[parser->data_index++] = byte;
if (parser->data_index >= parser->data_len) {
parser->state = STATE_WAIT_CHECKSUM;
}
break;
case STATE_WAIT_CHECKSUM:
parser->checksum = byte;
parser->state = STATE_WAIT_TAIL;
break;
case STATE_WAIT_TAIL:
if (byte == PROTOCOL_TAIL) {
// 收到帧尾,验证校验码
uint8_t calc = calculate_checksum(
parser->cmd,
parser->data_len,
parser->data
);
if (calc == parser->checksum) {
parser->frame_ready = true;
}
}
// 无论校验是否通过,都回到等待帧头
parser->state = STATE_WAIT_HEAD;
break;
default:
parser->state = STATE_WAIT_HEAD;
break;
}
}
15.4 状态机代码详解
状态1:STATE_WAIT_HEAD(等待帧头)
解析器初始状态。逐个接收字节,当收到 0x55 时切换到等待命令ID状态。如果收到其他字节,保持在当前状态。
状态2:STATE_WAIT_CMD(等待命令ID)
收到命令ID字节,保存到 parser->cmd,切换到等待数据长度低字节。
状态3:STATE_WAIT_LEN_LO(等待数据长度低字节)
收到数据长度的低字节,保存到 parser->data_len 的低8位。
状态4:STATE_WAIT_LEN_HI(等待数据长度高字节)
收到数据长度的高字节,组合成完整的16位数据长度。然后判断:
- 如果数据长度为0,直接跳到等待校验码
- 如果数据长度在合理范围内,切换到接收数据状态
- 如果数据长度超限,重置到等待帧头
状态5:STATE_RECV_DATA(接收数据)
逐个接收数据字节,保存到 parser->data 数组中。当接收完所有数据后(data_index >= data_len),切换到等待校验码。
状态6:STATE_WAIT_CHECKSUM(等待校验码)
收到校验码字节,保存到 parser->checksum。
状态7:STATE_WAIT_TAIL(等待帧尾)
收到帧尾字节 0xFF 后,验证校验码。如果校验通过,设置 frame_ready 标志。无论结果如何,都回到等待帧头状态。
15.5 解析器的使用方法
c
ProtocolParser parser;
void main(void)
{
parser_init(&parser);
// 在串口接收中断中喂入数据
// ...
}
// 串口接收中断回调
void on_uart_rx_byte(uint8_t byte)
{
parser_feed_byte(&parser, byte);
if (parser.frame_ready) {
// 帧解析完成,处理数据
handle_protocol_frame(parser.cmd, parser.data, parser.data_len);
parser.frame_ready = false;
}
}
// 帧处理函数
void handle_protocol_frame(uint8_t cmd, uint8_t *data, uint16_t len)
{
switch (cmd) {
case 0x01:
// 打开LED
HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET);
break;
case 0x02:
// 设置参数
if (len >= 2) {
param1 = data[0];
param2 = data[1];
}
// 发送应答
uint8_t ack[] = {0x01};
send_protocol_frame(0x02, ack, 1);
break;
case 0x03:
// 读取温度
uint8_t temp_data[2];
temp_data[0] = temperature & 0xFF;
temp_data[1] = (temperature >> 8) & 0xFF;
send_protocol_frame(0x03, temp_data, 2);
break;
default:
// 未知命令
break;
}
}
15.6 状态机的优势
使用状态机解析协议有以下优势:
- 实时性:每收到一个字节就处理一次,不需要等待整帧数据到达
- 鲁棒性:即使中间丢字节或出现干扰,状态机会自动重置,不会永久卡死
- 内存效率:不需要大缓冲区存储原始数据,只需要存储解析结果
- 可扩展性:增加新字段只需添加新状态
15.7 本章小结
本章详细讲解了简化版协议的解析代码实现:
- 使用状态机逐字节解析协议帧
- 定义了7个状态:等待帧头、命令ID、数据长度、数据、校验码、帧尾
- 通过校验码验证数据完整性
- 在串口接收中断中喂入数据,驱动状态机
第16章 简化版协议完整工程示例
16.1 完整代码文件
将前面各部分的代码整合成一个完整的工程文件:
c
/*============================================================
* 文件名: simple_protocol.h
* 描述: 简化版串口通信协议 - 头文件
*============================================================*/
#ifndef __SIMPLE_PROTOCOL_H
#define __SIMPLE_PROTOCOL_H
#include <stdint.h>
#include <stdbool.h>
#include <string.h>
/* 协议常量定义 */
#define PROTOCOL_HEAD 0x55
#define PROTOCOL_TAIL 0xFF
#define MAX_DATA_LEN 255
#define TX_BUFFER_SIZE 512
/* 命令ID定义 */
#define CMD_LED_ON 0x01
#define CMD_SET_PARAM 0x02
#define CMD_READ_TEMP 0x03
#define CMD_REBOOT 0x04
#define CMD_SEND_STRING 0x05
/* 协议帧结构体 */
typedef struct
{
uint8_t head; // 帧头
uint8_t cmd; // 命令ID
uint16_t len; // 数据长度(小端模式)
uint8_t *data; // 数据内容
uint8_t checksum; // 校验码(和校验)
uint8_t tail; // 帧尾
} SimpleProtocolFrame;
/* 接收状态枚举 */
typedef enum {
STATE_WAIT_HEAD,
STATE_WAIT_CMD,
STATE_WAIT_LEN_LO,
STATE_WAIT_LEN_HI,
STATE_RECV_DATA,
STATE_WAIT_CHECKSUM,
STATE_WAIT_TAIL
} ParseState;
/* 协议解析器结构体 */
typedef struct {
ParseState state;
uint8_t cmd;
uint16_t data_len;
uint16_t data_index;
uint8_t data[MAX_DATA_LEN];
uint8_t checksum;
bool frame_ready;
} ProtocolParser;
/* 函数声明 */
uint8_t calculate_checksum(uint8_t cmd, uint16_t len, const uint8_t *data);
uint16_t simple_protocol_pack(const SimpleProtocolFrame *frame, uint8_t *buffer);
void send_protocol_frame(uint8_t cmd, const uint8_t *data, uint16_t data_len);
void parser_init(ProtocolParser *parser);
void parser_feed_byte(ProtocolParser *parser, uint8_t byte);
/* 硬件相关函数(需要用户实现) */
void uart_send_buffer(uint8_t *data, uint16_t len);
#endif /* __SIMPLE_PROTOCOL_H */
c
/*============================================================
* 文件名: simple_protocol.c
* 描述: 简化版串口通信协议 - 源文件
*============================================================*/
#include "simple_protocol.h"
/* 计算校验和 */
uint8_t calculate_checksum(uint8_t cmd, uint16_t len, const uint8_t *data)
{
uint8_t sum = cmd;
sum += (len & 0xFF);
sum += ((len >> 8) & 0xFF);
for (uint16_t i = 0; i < len; i++) {
sum += data[i];
}
return sum;
}
/* 协议封包 */
uint16_t simple_protocol_pack(const SimpleProtocolFrame *frame, uint8_t *buffer)
{
uint16_t idx = 0;
buffer[idx++] = frame->head;
buffer[idx++] = frame->cmd;
buffer[idx++] = frame->len & 0xFF;
buffer[idx++] = (frame->len >> 8) & 0xFF;
if (frame->len > 0 && frame->data != NULL) {
memcpy(&buffer[idx], frame->data, frame->len);
idx += frame->len;
}
buffer[idx++] = frame->checksum;
buffer[idx++] = frame->tail;
return idx;
}
/* 协议发送 */
void send_protocol_frame(uint8_t cmd, const uint8_t *data, uint16_t data_len)
{
SimpleProtocolFrame frame;
static uint8_t buffer[TX_BUFFER_SIZE];
frame.head = PROTOCOL_HEAD;
frame.cmd = cmd;
frame.len = data_len;
frame.data = (uint8_t*)data;
frame.checksum = calculate_checksum(cmd, data_len, data);
frame.tail = PROTOCOL_TAIL;
uint16_t frame_len = simple_protocol_pack(&frame, buffer);
uart_send_buffer(buffer, frame_len);
}
/* 初始化解析器 */
void parser_init(ProtocolParser *parser)
{
parser->state = STATE_WAIT_HEAD;
parser->data_index = 0;
parser->data_len = 0;
parser->frame_ready = false;
}
/* 喂入字节驱动状态机 */
void parser_feed_byte(ProtocolParser *parser, uint8_t byte)
{
switch (parser->state) {
case STATE_WAIT_HEAD:
if (byte == PROTOCOL_HEAD) {
parser->state = STATE_WAIT_CMD;
}
break;
case STATE_WAIT_CMD:
parser->cmd = byte;
parser->state = STATE_WAIT_LEN_LO;
break;
case STATE_WAIT_LEN_LO:
parser->data_len = byte;
parser->state = STATE_WAIT_LEN_HI;
break;
case STATE_WAIT_LEN_HI:
parser->data_len |= ((uint16_t)byte << 8);
parser->data_index = 0;
if (parser->data_len == 0) {
parser->state = STATE_WAIT_CHECKSUM;
} else if (parser->data_len <= MAX_DATA_LEN) {
parser->state = STATE_RECV_DATA;
} else {
parser->state = STATE_WAIT_HEAD;
}
break;
case STATE_RECV_DATA:
parser->data[parser->data_index++] = byte;
if (parser->data_index >= parser->data_len) {
parser->state = STATE_WAIT_CHECKSUM;
}
break;
case STATE_WAIT_CHECKSUM:
parser->checksum = byte;
parser->state = STATE_WAIT_TAIL;
break;
case STATE_WAIT_TAIL:
if (byte == PROTOCOL_TAIL) {
uint8_t calc = calculate_checksum(
parser->cmd,
parser->data_len,
parser->data
);
if (calc == parser->checksum) {
parser->frame_ready = true;
}
}
parser->state = STATE_WAIT_HEAD;
break;
default:
parser->state = STATE_WAIT_HEAD;
break;
}
}
16.2 使用示例
c
/*============================================================
* 文件名: main.c
* 描述: 简化版协议使用示例
*============================================================*/
#include "simple_protocol.h"
/* 全局变量 */
ProtocolParser g_parser;
uint8_t param1 = 0;
uint8_t param2 = 0;
uint16_t temperature = 0;
/* UART发送函数(需根据具体平台实现) */
void uart_send_buffer(uint8_t *data, uint16_t len)
{
// STM32 HAL库示例
// HAL_UART_Transmit(&huart1, data, len, HAL_MAX_DELAY);
// 简单模拟:逐字节发送
for (uint16_t i = 0; i < len; i++) {
// 等待发送缓冲区为空
// while(!(USART1->SR & USART_SR_TXE));
// USART1->DR = data[i];
}
}
/* 帧处理函数 */
void handle_protocol_frame(uint8_t cmd, uint8_t *data, uint16_t len)
{
switch (cmd) {
case CMD_LED_ON:
// 打开LED
// HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET);
break;
case CMD_SET_PARAM:
if (len >= 2) {
param1 = data[0];
param2 = data[1];
}
// 发送应答
uint8_t ack = 0x01; // 成功
send_protocol_frame(CMD_SET_PARAM, &ack, 1);
break;
case CMD_READ_TEMP:
// 返回温度数据
uint8_t temp_data[2];
temp_data[0] = temperature & 0xFF;
temp_data[1] = (temperature >> 8) & 0xFF;
send_protocol_frame(CMD_READ_TEMP, temp_data, 2);
break;
case CMD_REBOOT:
// 系统重启
// NVIC_SystemReset();
break;
default:
break;
}
}
/* 串口接收中断回调 */
void on_uart_rx_byte(uint8_t byte)
{
parser_feed_byte(&g_parser, byte);
if (g_parser.frame_ready) {
handle_protocol_frame(g_parser.cmd, g_parser.data, g_parser.data_len);
g_parser.frame_ready = false;
}
}
int main(void)
{
// 初始化
parser_init(&g_parser);
// 初始化硬件
// HAL_Init();
// SystemClock_Config();
// MX_GPIO_Init();
// MX_USART1_UART_Init();
// 启动串口接收中断
// HAL_UART_Receive_IT(&huart1, &rx_byte, 1);
while (1) {
// 主循环
// 可以在这里做其他事情
// 串口接收由中断驱动
}
}
16.3 测试用例
以下是几个测试用例,可以用串口调试助手发送这些十六进制数据来测试:
| 测试项 | 发送数据 | 预期行为 |
|---|---|---|
| 打开LED | 55 01 00 00 01 FF | LED点亮 |
| 设置参数 | 55 02 02 00 0A 0B 19 FF | 参数被设置,返回应答 55 02 01 00 01 04 FF |
| 读取温度 | 55 03 00 00 03 FF | 返回温度数据 |
| 错误校验码 | 55 01 00 00 02 FF | 帧被丢弃(校验失败) |
| 错误帧尾 | 55 01 00 00 01 FE | 帧被丢弃(帧尾错误) |
16.4 本章小结
本章将前面各部分整合成了一个完整的工程:
- 头文件定义了协议常量、结构体和函数声明
- 源文件实现了校验、封包、解析功能
- 使用示例展示了如何在实际项目中使用
- 测试用例可用于验证功能正确性
第四篇 专业版协议设计
第17章 专业版协议格式详解
17.1 为什么需要专业版协议
简化版协议虽然简单易用,但在企业级产品开发中存在以下不足:
- 可靠性不足:单字节帧头容易误匹配,和校验检测能力有限
- 不支持多设备:没有地址字段,无法实现多设备组网
- 没有应答机制:没有序列号,无法实现请求-应答-重传机制
- 安全性不足:没有加密和防篡改机制
- 扩展性不足:没有版本号和配置位,难以适配不同协议版本
专业版协议针对这些问题进行了全面升级,适合做企业级产品开发使用。
17.2 专业版协议格式
原博客文章中设计的专业版协议帧格式如下:
┌───────┬───────┬────────┬──────────┬────────┬───────┬───────┬───────┬───────┬──────────┬───────┬──────────┬───────┐
│起始位 │配置位 │序列号 │ 总长度 │头校验 │源地址 │目的地址│功能ID │命令ID │ 数据长度 │ 数据 │ 尾校验 │结束位 │
│2字节 │2字节 │2字节 │ 2字节 │2字节 │1字节 │1字节 │1字节 │1字节 │ 2字节 │N字节 │ 2字节 │2字节 │
└───────┴───────┴────────┴──────────┴────────┴───────┴───────┴───────┴───────┴──────────┴───────┴──────────┴───────┘
各字段详细说明:
| 字段 | 大小 | 说明 |
|---|---|---|
| 起始位 | 2字节 | 帧起始标志(如0x55AA) |
| 配置位 | 2字节 | 协议版本、加密标志、应答标志等配置信息 |
| 序列号 | 2字节 | 帧序号,用于应答和重传机制 |
| 总长度 | 2字节 | 整个帧的总字节数 |
| 头校验 | 2字节 | 对帧头部分的CRC校验 |
| 源地址 | 1字节 | 发送方地址 |
| 目的地址 | 1字节 | 接收方地址 |
| 功能ID | 1字节 | 功能模块标识 |
| 命令ID | 1字节 | 具体命令标识 |
| 数据长度 | 2字节 | 数据部分的字节数 |
| 数据 | N字节 | 有效数据内容 |
| 尾校验 | 2字节 | 对整个帧(除尾校验外)的CRC校验 |
| 结束位 | 2字节 | 帧结束标志(如0xFF00) |
17.3 与简化版的对比
| 对比项 | 简化版 | 专业版 |
|---|---|---|
| 帧头 | 1字节(0x55) | 2字节(0x55AA) |
| 帧尾 | 1字节(0xFF) | 2字节(0xFF00) |
| 校验方式 | 和校验(1字节) | CRC-16(2字节) |
| 校验次数 | 1次 | 2次(头校验+尾校验) |
| 地址 | 无 | 源地址+目的地址 |
| 序列号 | 无 | 有 |
| 配置位 | 无 | 有 |
| 命令分类 | 单命令ID | 功能ID+命令ID |
| 总长度 | 无 | 有 |
| 帧开销 | 5字节 | 20字节 |
| 可靠性 | 低 | 高 |
| 适用场景 | 学生项目 | 企业产品 |
17.4 双重校验的意义
专业版协议使用了头校验 和尾校验两次CRC校验:
头校验:
- 校验范围:起始位 + 配置位 + 序列号 + 总长度
- 作用:在解析帧头信息之前,先验证帧头的完整性。如果头校验失败,可以直接丢弃整个帧,避免浪费资源解析后续数据。
尾校验:
- 校验范围:整个帧除尾校验外的所有字节
- 作用:验证整个帧数据的完整性。即使帧头校验通过了,帧体数据仍可能在传输中出错,尾校验确保整体数据的正确性。
这种双重校验机制在效率和安全之间取得了平衡:头校验让接收方尽早判断帧的有效性,尾校验确保最终数据的完整性。
17.5 本章小结
本章介绍了专业版协议的整体格式:
- 专业版协议包含13个字段,支持地址、序列号、配置位、双重CRC校验
- 相比简化版,专业版在可靠性、安全性、扩展性方面大幅提升
- 双重校验(头校验+尾校验)兼顾效率和安全
- 适合企业级产品开发
第18章 配置位结构详解
18.1 配置位的作用
配置位(Config Field)是专业版协议中的一个2字节(16位)字段,用于携带协议级别的配置信息。每一位或几位代表一个配置选项,接收方通过解析配置位来了解这帧数据的属性。
配置位的结构设计如下(2字节 = 16位):
Bit: 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
|Ver|Ver|Ack|Rsv|Enc|Enc|Rsv|Rsv|Rsv|Rsv|Rsv|Rsv|Rsv|Rsv|Rsv|Rsv|
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
18.2 配置位各字段说明
Bit 15-14:协议版本(Version)
- 2位,可表示4个版本(0~3)
- 用于协议升级时的版本兼容
- 版本0:初始版本
- 版本1:修订版本
- 版本2:扩展版本
- 版本3:预留
Bit 13:应答标志(Ack)
- 1位
- 0:不需要应答
- 1:需要应答
- 接收方收到此标志为1的帧后,必须回复应答帧
Bit 12:预留(Reserved)
- 1位,保留给未来使用,默认为0
Bit 11-10:加密标志(Encryption)
- 2位,可表示4种加密方式
- 00:不加密
- 01:AES-128加密
- 10:DES加密
- 11:自定义加密
Bit 9-0:预留(Reserved)
- 10位,保留给未来扩展
- 可以用于定义压缩标志、优先级、重传标志等
18.3 配置位的使用示例
示例1:无加密、无需应答、版本0
配置位 = 0x0000(所有位都为0)
示例2:需要应答、版本0
配置位 = 0x2000(Bit 13 = 1)
二进制:0010 0000 0000 0000
示例3:AES加密、需要应答、版本1
配置位 = 0x6800
二进制:0110 1000 0000 0000
- Bit 15-14 = 01(版本1)
- Bit 13 = 1(需要应答)
- Bit 11-10 = 01(AES-128加密)
18.4 配置位的代码定义
c
/* 配置位掩码定义 */
#define CONFIG_VERSION_MASK 0xC000 // Bit 15-14
#define CONFIG_VERSION_SHIFT 14
#define CONFIG_ACK_MASK 0x2000 // Bit 13
#define CONFIG_ACK_SHIFT 13
#define CONFIG_ENCRYPT_MASK 0x0C00 // Bit 11-10
#define CONFIG_ENCRYPT_SHIFT 10
/* 版本号定义 */
#define VERSION_0 0
#define VERSION_1 1
#define VERSION_2 2
#define VERSION_3 3
/* 加密方式定义 */
#define ENCRYPT_NONE 0
#define ENCRYPT_AES128 1
#define ENCRYPT_DES 2
#define ENCRYPT_CUSTOM 3
/* 配置位操作宏 */
#define GET_VERSION(config) ((config & CONFIG_VERSION_MASK) >> CONFIG_VERSION_SHIFT)
#define GET_ACK_FLAG(config) ((config & CONFIG_ACK_MASK) >> CONFIG_ACK_SHIFT)
#define GET_ENCRYPT(config) ((config & CONFIG_ENCRYPT_MASK) >> CONFIG_ENCRYPT_SHIFT)
#define SET_VERSION(config, v) (config |= ((v << CONFIG_VERSION_SHIFT) & CONFIG_VERSION_MASK))
#define SET_ACK_FLAG(config) (config |= CONFIG_ACK_MASK)
#define SET_ENCRYPT(config, e) (config |= ((e << CONFIG_ENCRYPT_SHIFT) & CONFIG_ENCRYPT_MASK))
18.5 本章小结
本章详细讲解了配置位的结构:
- 配置位是2字节字段,用于携带协议级配置信息
- 包含协议版本、应答标志、加密标志等
- 通过位操作宏可以方便地读取和设置配置位
- 预留位为未来扩展提供了空间
第19章 CRC循环冗余校验完全解析
19.1 CRC是什么
CRC(Cyclic Redundancy Check,循环冗余校验)是一种通过位移和异或实现的复杂数学运算。它比和校验强大得多,能够检测出多种类型的传输错误。
CRC的核心思想是将数据看作一个大的二进制数,用一个预定的"多项式"(Generator Polynomial)去除这个数,得到的余数就是CRC校验码。
19.2 CRC的数学原理
多项式表示法:
CRC使用多项式来表示二进制数。例如,多项式 x^8 + x^2 + x + 1 对应的二进制数为 100000111,即 0x107。
CRC计算过程:
- 在数据后面附加n个0(n为CRC的位数,如CRC-8附加8个0)
- 用附加0后的数据除以生成多项式(模2除法,即异或运算)
- 余数就是CRC校验码
模2除法:
模2除法与普通除法类似,但不借位,用异或代替减法:
被除数:101100101 000 (数据后面附加3个0)
除数: 1011 (生成多项式)
101000
--------
1011 | 101100101000
1011
----
0100
0000
----
1001
1011
----
0100 ← 余数(3位)
余数 0100 就是CRC校验码。
19.3 为什么是"CRC-8(多项式0x07)"和"CRC-16(多项式0x1021)"
原博客文章中提到了这个问题。答案是:这些多项式是由国际标准组织定义的,也经过相关数学验证,可以提供最优的错误检测能力。
CRC-8(多项式0x07):
- 多项式:x^8 + x^2 + x + 1
- 二进制:100000111 = 0x107
- 但CRC-8使用时,多项式的最高位(x^8)是隐含的,所以实际使用的值为 0x07
- 常用于1-Wire总线、Dallas芯片等
CRC-16(多项式0x1021):
- 多项式:x^16 + x^12 + x^5 + 1
- 二进制:10001000000100001 = 0x11021
- 同样,最高位隐含,实际使用值为 0x1021
- 也称为CRC-CCITT,常用于X.25、HDLC、蓝牙等协议
19.4 CRC-8代码实现
原博客文章中给出的CRC-8实现:
c
uint8_t crc8(const uint8_t *data, uint16_t len) {
uint8_t crc = 0x00; // 初始值
while(len--) {
crc ^= *data++;
for(uint8_t i=0; i<8; i++)
crc = (crc & 0x80) ? (crc << 1) ^ 0x07 : (crc << 1);
}
return crc;
}
逐行解析:
-
uint8_t crc = 0x00;:初始化CRC寄存器为0x00。有些CRC变体使用0xFF作为初始值。 -
while(len--):遍历每个数据字节。 -
crc ^= *data++;:将当前数据字节异或到CRC寄存器。这一步相当于把数据"加载"到CRC寄存器中。 -
for(uint8_t i=0; i<8; i++):对每个比特处理一次,共8次。 -
crc = (crc & 0x80) ? (crc << 1) ^ 0x07 : (crc << 1);- 检查CRC寄存器的最高位是否为1(
crc & 0x80) - 如果为1:左移1位,然后异或多项式0x07
- 如果为0:仅左移1位
- 这就是模2除法中"够除就异或"的操作
- 检查CRC寄存器的最高位是否为1(
-
return crc;:返回最终的CRC值。
手动计算示例:
计算单字节数据 0x01 的CRC-8:
初始:crc = 0x00
第一步:crc ^= 0x01 → crc = 0x01
比特0:crc & 0x80 = 0 → crc = 0x01 << 1 = 0x02
比特1:crc & 0x80 = 0 → crc = 0x02 << 1 = 0x04
比特2:crc & 0x80 = 0 → crc = 0x04 << 1 = 0x08
比特3:crc & 0x80 = 0 → crc = 0x08 << 1 = 0x10
比特4:crc & 0x80 = 0 → crc = 0x10 << 1 = 0x20
比特5:crc & 0x80 = 0 → crc = 0x20 << 1 = 0x40
比特6:crc & 0x80 = 0 → crc = 0x40 << 1 = 0x80
比特7:crc & 0x80 = 1 → crc = (0x80 << 1) ^ 0x07 = 0x00 ^ 0x07 = 0x07
结果:CRC-8(0x01) = 0x07
19.5 CRC-16代码实现
原博客文章中给出的CRC-16实现:
c
uint16_t crc16(const uint8_t *data, uint16_t len) {
uint16_t crc = 0xFFFF; // 初始值
while(len--) {
crc ^= (uint16_t)(*data++) << 8;
for(uint8_t i=0; i<8; i++)
crc = (crc & 0x8000) ? (crc << 1) ^ 0x1021 : (crc << 1);
}
return crc;
}
逐行解析:
-
uint16_t crc = 0xFFFF;:初始化CRC寄存器为0xFFFF。CRC-16-CCITT通常使用0xFFFF作为初始值。 -
crc ^= (uint16_t)(*data++) << 8;:将数据字节左移8位后异或到CRC寄存器的高字节。这与CRC-8不同,CRC-8是直接异或到低字节。 -
crc = (crc & 0x8000) ? (crc << 1) ^ 0x1021 : (crc << 1);- 检查最高位(Bit 15)是否为1
- 如果为1:左移1位,异或多项式0x1021
- 如果为0:仅左移1位
CRC-16 vs CRC-8 的区别:
| 对比项 | CRC-8 | CRC-16 |
|---|---|---|
| 校验码长度 | 1字节(8位) | 2字节(16位) |
| 多项式 | 0x07 | 0x1021 |
| 初始值 | 0x00 | 0xFFFF |
| 检测能力 | 较强 | 更强 |
| 计算开销 | 较小 | 较大 |
| 适用场景 | 小数据量 | 大数据量 |
19.6 CRC查表法优化
上面的CRC实现是逐位计算的,每次处理一个比特需要8次循环。对于大数据量,这种方法较慢。可以通过预计算CRC表(查找表)来大幅加速。
CRC-16查表法:
c
/* CRC-16 查找表(256个条目) */
static const uint16_t crc16_table[256] = {
0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50A5, 0x60C6, 0x70E7,
0x8108, 0x9129, 0xA14A, 0xB16B, 0xC18C, 0xD1AD, 0xE1CE, 0xF1EF,
0x1231, 0x0210, 0x3273, 0x2252, 0x52B5, 0x4294, 0x72F7, 0x62D6,
0x9339, 0x8318, 0xB37B, 0xA35A, 0xD3BD, 0xC39C, 0xF3FF, 0xE3DE,
0x2462, 0x3443, 0x0420, 0x1401, 0x64E6, 0x74C7, 0x44A4, 0x5485,
0xA56A, 0xB54B, 0x8528, 0x9509, 0xE5EE, 0xF5CF, 0xC5AC, 0xD58D,
0x3653, 0x2672, 0x1611, 0x0630, 0x76D7, 0x66F6, 0x5695, 0x46B4,
0xB75B, 0xA77A, 0x9719, 0x8738, 0xF7DF, 0xE7FE, 0xD79D, 0xC7BC,
0x48C4, 0x58E5, 0x6886, 0x78A7, 0x0840, 0x1861, 0x2802, 0x3823,
0xC9CC, 0xD9ED, 0xE98E, 0xF9AF, 0x8948, 0x9969, 0xA90A, 0xB92B,
0x5AF5, 0x4AD4, 0x7AB7, 0x6A96, 0x1A71, 0x0A50, 0x3A33, 0x2A12,
0xDBFD, 0xCBDC, 0xFBBF, 0xEB9E, 0x9B79, 0x8B58, 0xBB3B, 0xAB1A,
0x6CA6, 0x7C87, 0x4CE4, 0x5CC5, 0x2C22, 0x3C03, 0x0C60, 0x1C41,
0xEDAE, 0xFD8F, 0xCDEC, 0xDDCD, 0xAD2A, 0xBD0B, 0x8D68, 0x9D49,
0x7E97, 0x6EB6, 0x5ED5, 0x4EF4, 0x3E13, 0x2E32, 0x1E51, 0x0E70,
0xFF9F, 0xEFBE, 0xDFDD, 0xCFFC, 0xBF1B, 0xAF3A, 0x9F59, 0x8F78,
0x9188, 0x81A9, 0xB1CA, 0xA1EB, 0xD10C, 0xC12D, 0xF14E, 0xE16F,
0x1080, 0x00A1, 0x30C2, 0x20E3, 0x5004, 0x4025, 0x7046, 0x6067,
0x83B9, 0x9398, 0xA3FB, 0xB3DA, 0xC33D, 0xD31C, 0xE37F, 0xF35E,
0x02B1, 0x1290, 0x22F3, 0x32D2, 0x4235, 0x5214, 0x6277, 0x7256,
0xB5EA, 0xA5CB, 0x95A8, 0x8589, 0xF56E, 0xE54F, 0xD52C, 0xC50D,
0x34E2, 0x24C3, 0x14A0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405,
0xA7DB, 0xB7FA, 0x8799, 0x97B8, 0xE75F, 0xF77E, 0xC71D, 0xD73C,
0x26D3, 0x36F2, 0x0691, 0x16B0, 0x6657, 0x7676, 0x4615, 0x5634,
0xD94C, 0xC96D, 0xF90E, 0xE92F, 0x99C8, 0x89E9, 0xB98A, 0xA9AB,
0x5844, 0x4865, 0x7806, 0x6827, 0x18C0, 0x08E1, 0x3882, 0x28A3,
0xCB7D, 0xDB5C, 0xEB3F, 0xFB1E, 0x8BF9, 0x9BD8, 0xABBB, 0xBB9A,
0x4A75, 0x5A54, 0x6A37, 0x7A16, 0x0AF1, 0x1AD0, 0x2AB3, 0x3A92,
0xFD2E, 0xED0F, 0xDD6C, 0xCD4D, 0xBDAA, 0xAD8B, 0x9DE8, 0x8DC9,
0x7C26, 0x6C07, 0x5C64, 0x4C45, 0x3CA2, 0x2C83, 0x1CE0, 0x0CC1,
0xEF1F, 0xFF3E, 0xCF5D, 0xDF7C, 0xAF9B, 0xBFBA, 0x8FD9, 0x9FF8,
0x6E17, 0x7E36, 0x4E55, 0x5E74, 0x2E93, 0x3EB2, 0x0ED1, 0x1EF0
};
uint16_t crc16_table_lookup(const uint8_t *data, uint16_t len)
{
uint16_t crc = 0xFFFF;
while (len--) {
crc = (crc << 8) ^ crc16_table[((crc >> 8) ^ *data++) & 0xFF];
}
return crc;
}
查表法的原理是预先计算出所有256种可能的CRC值(对于每个可能的字节值),然后在实际计算时直接查表,每次处理一个完整的字节而不是一个比特,速度提升8倍。
19.7 CRC的初始值和最终异或值
不同的CRC标准可能使用不同的初始值和最终异或值:
| CRC标准 | 多项式 | 初始值 | 最终异或 | 结果异或 |
|---|---|---|---|---|
| CRC-8/CCITT | 0x07 | 0x00 | 0x00 | false |
| CRC-8/Dallas | 0x31 | 0x00 | 0x00 | true |
| CRC-16/CCITT | 0x1021 | 0xFFFF | 0x0000 | false |
| CRC-16/Modbus | 0x8005 | 0xFFFF | 0x0000 | true |
在本教程的协议中,我们使用CRC-8(多项式0x07,初始值0x00)和CRC-16(多项式0x1021,初始值0xFFFF)。
19.8 本章小结
本章深入讲解了CRC循环冗余校验:
- CRC通过多项式除法(模2运算)计算校验码
- 0x07和0x1021是国际标准定义的最优多项式
- CRC-8用于头校验,CRC-16用于尾校验
- 查表法可以将计算速度提升8倍
- 不同的CRC标准使用不同的初始值和多项式
第20章 源地址与目的地址
20.1 为什么要有源地址和目的地址
原博客文章中解释了这个问题:
因为在实际产品开发中,并不是单纯的设备A发送给设备B,然后设备B就去直接处理了,因为一个产品里面会涉及到多条通信线路,而其他设备无法直接与设备A通信,但它可以与设备B通信,所以设备B就起到了数据转发的作用,相应的就引入了源地址和目的地址去辨别处理。
让我们用一个具体场景来理解:
┌──────────┐
│ 设备A │ (地址: 0x01)
│ 主控制器 │
└────┬─────┘
│
┌────┴─────┐
│ 设备B │ (地址: 0x02)
│ 中继器 │
└────┬─────┘
│
┌─────────┼─────────┐
│ │ │
┌───┴──┐ ┌───┴──┐ ┌───┴──┐
│设备C │ │设备D │ │设备E │
│0x03 │ │0x04 │ │0x05 │
└──────┘ └──────┘ └──────┘
设备A(主控制器)想向设备D(地址0x04)发送数据,但设备A只能直接与设备B通信。设备B可以与设备C、D、E通信。
通信流程:
- 设备A构造数据帧:源地址=0x01(A),目的地址=0x04(D)
- 设备A将帧发送给设备B
- 设备B收到帧后,检查目的地址=0x04,不是自己的地址(0x02)
- 设备B作为中继,将帧转发给设备D
- 设备D收到帧后,检查目的地址=0x04,是自己的地址,处理数据
- 设备D回复应答帧:源地址=0x04(D),目的地址=0x01(A)
- 设备B将应答转发给设备A
如果没有源地址和目的地址,设备B收到帧后无法知道这帧数据是给自己的还是需要转发的,也无法知道应该转发给谁。
20.2 地址分配原则
在多设备网络中,地址的分配需要遵循以下原则:
- 唯一性:每个设备的地址必须唯一
- 固定性:地址在产品生命周期内不应改变(除非通过配置命令修改)
- 可扩展性:地址空间要足够大,支持未来扩展
- 有序性:地址分配要有规律,便于管理
常见的地址分配方案:
| 地址范围 | 用途 |
|---|---|
| 0x00 | 广播地址(所有设备都处理) |
| 0x01 | 主控制器/网关 |
| 0x02~0x7F | 从设备(最多127个) |
| 0x80~0xFE | 预留扩展 |
| 0xFF | 无效/未分配地址 |
20.3 广播地址
地址 0x00 通常作为广播地址。当目的地址为 0x00 时,所有设备都应该接收并处理这帧数据。
广播的应用场景:
- 主控制器同步时间到所有从设备
- 主控制器广播紧急停止命令
- 系统升级时广播升级通知
20.4 地址在代码中的处理
c
/* 地址定义 */
#define ADDR_BROADCAST 0x00
#define ADDR_MASTER 0x01
#define MY_ADDR 0x02 // 本设备地址
/* 检查是否是发给本设备的帧 */
bool is_for_me(uint8_t dst_addr)
{
return (dst_addr == MY_ADDR || dst_addr == ADDR_BROADCAST);
}
/* 检查是否需要转发 */
bool need_forward(uint8_t dst_addr)
{
return (dst_addr != MY_ADDR && dst_addr != ADDR_BROADCAST);
}
20.5 本章小结
本章讲解了源地址和目的地址的设计:
- 地址用于多设备组网中的数据路由和转发
- 设备B可以作为中继,转发设备A发给设备D的数据
- 广播地址(0x00)让所有设备都处理数据
- 地址分配应遵循唯一性、固定性、可扩展性原则
第21章 序列号与应答机制
21.1 序列号的作用
序列号(Sequence Number)是专业版协议中的2字节字段,用于实现可靠的请求-应答机制。
序列号的主要作用:
-
关联请求与应答:发送方发送请求时附带序列号,接收方在应答中使用相同的序列号,发送方通过序列号匹配请求和应答。
-
检测丢帧:如果发送方发送了序列号为1的请求,但没有收到序列号为1的应答,说明帧丢失了。
-
避免重复处理:如果接收方收到了两个相同序列号的请求,说明是重复帧,可以只处理一次。
-
实现重传:发送方在超时未收到应答时,使用相同序列号重传请求。
21.2 应答机制的工作流程
发送方 接收方
│ │
│ 帧(seq=1, cmd=read_temp) │
│ ──────────────────────────────────→ │
│ │ 处理请求
│ 应答帧(seq=1, data=25℃) │
│ ←────────────────────────────────── │
│ │
│ 确认收到,处理应答 │
│ │
│ 帧(seq=2, cmd=read_humi) │
│ ──────────────────────────────────→ │
│ │ (帧丢失)
│ │
│ 超时未收到seq=2的应答 │
│ │
│ 重传 帧(seq=2, cmd=read_humi) │
│ ──────────────────────────────────→ │
│ │ 处理请求
│ 应答帧(seq=2, data=60%) │
│ ←────────────────────────────────── │
│ │
21.3 序列号的生成与管理
c
/* 序列号管理 */
static uint16_t g_seq_num = 0;
/* 获取下一个序列号 */
uint16_t get_next_seq_num(void)
{
g_seq_num++;
if (g_seq_num == 0) { // 溢出回绕
g_seq_num = 1;
}
return g_seq_num;
}
/* 待应答请求的管理 */
typedef struct {
uint16_t seq_num;
uint8_t cmd;
uint32_t send_time;
uint8_t retry_count;
uint8_t data[MAX_DATA_LEN];
uint16_t data_len;
bool active; // 是否在等待应答
} PendingRequest;
#define MAX_PENDING 4
PendingRequest g_pending[MAX_PENDING];
/* 发送请求并等待应答 */
int send_request(uint8_t dst_addr, uint8_t func_id, uint8_t cmd_id,
uint8_t *data, uint16_t len, bool need_ack)
{
uint16_t seq = get_next_seq_num();
// 发送帧
send_pro_frame(dst_addr, func_id, cmd_id, data, len, seq, need_ack);
if (need_ack) {
// 记录待应答请求
for (int i = 0; i < MAX_PENDING; i++) {
if (!g_pending[i].active) {
g_pending[i].seq_num = seq;
g_pending[i].cmd = cmd_id;
g_pending[i].send_time = get_tick_ms();
g_pending[i].retry_count = 0;
g_pending[i].active = true;
if (len > 0 && data != NULL) {
memcpy(g_pending[i].data, data, len);
}
g_pending[i].data_len = len;
break;
}
}
}
return seq;
}
/* 处理收到的应答 */
void handle_ack(uint16_t seq, uint8_t *data, uint16_t len)
{
for (int i = 0; i < MAX_PENDING; i++) {
if (g_pending[i].active && g_pending[i].seq_num == seq) {
// 找到匹配的请求,处理应答数据
g_pending[i].active = false;
// ... 处理应答数据 ...
break;
}
}
}
/* 超时重传检查(在主循环中调用) */
void check_timeout(void)
{
uint32_t now = get_tick_ms();
for (int i = 0; i < MAX_PENDING; i++) {
if (g_pending[i].active) {
if (now - g_pending[i].send_time > ACK_TIMEOUT_MS) {
if (g_pending[i].retry_count < MAX_RETRY) {
// 重传
g_pending[i].retry_count++;
g_pending[i].send_time = now;
send_pro_frame(/* ... */);
} else {
// 超过最大重传次数,放弃
g_pending[i].active = false;
// 通知上层请求失败
}
}
}
}
}
21.4 本章小结
本章讲解了序列号与应答机制:
- 序列号用于关联请求与应答、检测丢帧、避免重复处理
- 应答机制包括请求-应答-超时重传流程
- 需要管理待应答请求队列
- 超时重传保证通信可靠性
第22章 专业版协议封包代码实现
22.1 协议帧结构体定义
c
#pragma pack(1)
typedef struct {
uint16_t start_flag; // 起始位
uint16_t config; // 配置位
uint16_t seq_num; // 序列号
uint16_t total_len; // 总长度
uint16_t header_crc; // 头校验
uint8_t src_addr; // 源地址
uint8_t dst_addr; // 目的地址
uint8_t func_id; // 功能类型ID
uint8_t cmd_id; // 命令ID
uint16_t data_len; // 数据长度
uint8_t* data; // 数据指针
uint16_t tail_crc; // 尾校验
} ProtocolFrame;
#pragma pack()
解析说明:
#pragma pack(1):强制编译器按1字节对齐。
#pragma pack():恢复编译器默认的对齐方式。
为什么需要1字节对齐?因为在发送端是标准地一个字节一个字节组合帧发送的,而在接收端用结构体去解析,考虑存在字节对齐填充问题,所以需要设置为1字节对齐。
如果不设置1字节对齐,编译器可能会在结构体成员之间插入填充字节,导致结构体的大小大于各成员大小之和,从而在内存映射时产生错位。
字节对齐问题详解:
假设不使用 #pragma pack(1),在默认4字节对齐的情况下:
c
struct {
uint16_t a; // 偏移0,大小2
uint8_t b; // 偏移2,大小1
// 1字节填充
uint16_t c; // 偏移4,大小2
} // 总大小:6字节(而不是5字节)
编译器为了访问效率,会在 b 后面填充1个字节,使 c 对齐到4字节边界。这在普通编程中没有问题,但在协议解析中会导致数据错位。
22.2 头部封包
c
/* 专业版协议头部(不含数据和尾校验)的固定部分大小 */
#define PRO_HEADER_SIZE 12 // 起始位(2)+配置位(2)+序列号(2)+总长度(2)+头校验(2)+源地址(1)+目的地址(1) = 12
/* 专业版协议封包 */
uint16_t pro_protocol_pack(uint8_t *buffer,
uint16_t config, uint16_t seq_num,
uint8_t src_addr, uint8_t dst_addr,
uint8_t func_id, uint8_t cmd_id,
const uint8_t *data, uint16_t data_len)
{
uint16_t idx = 0;
uint16_t total_len;
// 计算总长度:固定头部 + 源/目的地址 + 功能/命令ID + 数据长度字段 + 数据 + 尾校验 + 结束位
total_len = 2 + 2 + 2 + 2 + 2 + 1 + 1 + 1 + 1 + 2 + data_len + 2 + 2;
// 即:起始(2)+配置(2)+序列号(2)+总长度(2)+头校验(2)+源地址(1)+目的地址(1)+功能ID(1)+命令ID(1)+数据长度(2)+数据(N)+尾校验(2)+结束位(2)
// 写入起始位
buffer[idx++] = 0x55;
buffer[idx++] = 0xAA;
// 写入配置位(小端)
buffer[idx++] = config & 0xFF;
buffer[idx++] = (config >> 8) & 0xFF;
// 写入序列号(小端)
buffer[idx++] = seq_num & 0xFF;
buffer[idx++] = (seq_num >> 8) & 0xFF;
// 写入总长度(小端)
buffer[idx++] = total_len & 0xFF;
buffer[idx++] = (total_len >> 8) & 0xFF;
// 计算头校验(CRC-8,校验范围:起始位+配置位+序列号+总长度)
uint8_t header_crc = crc8(buffer, idx);
buffer[idx++] = header_crc;
// 写入源地址
buffer[idx++] = src_addr;
// 写入目的地址
buffer[idx++] = dst_addr;
// 写入功能ID
buffer[idx++] = func_id;
// 写入命令ID
buffer[idx++] = cmd_id;
// 写入数据长度(小端)
buffer[idx++] = data_len & 0xFF;
buffer[idx++] = (data_len >> 8) & 0xFF;
// 写入数据
if (data_len > 0 && data != NULL) {
memcpy(&buffer[idx], data, data_len);
idx += data_len;
}
// 计算尾校验(CRC-16,校验范围:从起始位到数据结束)
uint16_t tail_crc = crc16(buffer, idx);
buffer[idx++] = tail_crc & 0xFF; // CRC低字节
buffer[idx++] = (tail_crc >> 8) & 0xFF; // CRC高字节
// 写入结束位
buffer[idx++] = 0xFF;
buffer[idx++] = 0x00;
return idx;
}
22.3 发送函数
c
void send_pro_frame(uint8_t dst_addr, uint8_t func_id, uint8_t cmd_id,
const uint8_t *data, uint16_t data_len,
uint16_t seq_num, bool need_ack)
{
uint16_t config = 0;
if (need_ack) {
config |= CONFIG_ACK_MASK;
}
uint8_t buffer[512];
uint16_t frame_len = pro_protocol_pack(
buffer, config, seq_num,
MY_ADDR, dst_addr,
func_id, cmd_id,
data, data_len
);
uart_send_buffer(buffer, frame_len);
}
22.4 应用示例
c
// 示例1:发送系统重启命令(需要应答)
uint16_t seq = get_next_seq_num();
send_pro_frame(0x02, 0x01, 0x01, NULL, 0, seq, true);
// 示例2:读取传感器数据(需要应答)
uint8_t sensor_type = 0x01; // 温度传感器
uint16_t seq = get_next_seq_num();
send_pro_frame(0x02, 0x02, 0x01, &sensor_type, 1, seq, true);
// 示例3:广播时间同步(无需应答)
uint8_t time_data[6] = {25, 7, 8, 21, 30, 0}; // 年月日时分秒
send_pro_frame(ADDR_BROADCAST, 0x01, 0x03, time_data, 6, 0, false);
22.5 本章小结
本章实现了专业版协议的封包代码:
- 定义了
ProtocolFrame结构体,使用#pragma pack(1)确保字节对齐 - 实现了封包函数,包括头部CRC-8和尾部CRC-16双重校验
- 实现了发送函数,支持配置位、序列号、地址等字段
- 通过应用示例展示了使用方法
第23章 专业版协议解析代码实现
23.1 解析状态机
专业版协议的解析状态机比简化版更复杂,因为帧头更长(2字节),且有更多的字段需要解析。
c
typedef enum {
PRO_STATE_WAIT_HEAD_1, // 等待起始位第1字节(0x55)
PRO_STATE_WAIT_HEAD_2, // 等待起始位第2字节(0xAA)
PRO_STATE_RECV_CONFIG, // 接收配置位(2字节)
PRO_STATE_RECV_SEQ, // 接收序列号(2字节)
PRO_STATE_RECV_LEN, // 接收总长度(2字节)
PRO_STATE_RECV_HEAD_CRC, // 接收头校验(1字节)
PRO_STATE_RECV_BODY, // 接收帧体(到结束位前)
PRO_STATE_VERIFY // 校验验证
} ProParseState;
typedef struct {
ProParseState state;
uint8_t recv_buf[512]; // 接收缓冲区
uint16_t recv_idx; // 接收索引
uint16_t total_len; // 帧总长度
bool header_verified; // 头校验是否通过
} ProParser;
23.2 解析实现
c
void pro_parser_init(ProParser *parser)
{
parser->state = PRO_STATE_WAIT_HEAD_1;
parser->recv_idx = 0;
parser->header_verified = false;
}
void pro_parser_feed(ProParser *parser, uint8_t byte)
{
parser->recv_buf[parser->recv_idx] = byte;
switch (parser->state) {
case PRO_STATE_WAIT_HEAD_1:
if (byte == 0x55) {
parser->recv_idx = 1;
parser->state = PRO_STATE_WAIT_HEAD_2;
}
break;
case PRO_STATE_WAIT_HEAD_2:
if (byte == 0xAA) {
parser->recv_idx = 2;
parser->state = PRO_STATE_RECV_CONFIG;
} else {
parser->state = PRO_STATE_WAIT_HEAD_1;
}
break;
case PRO_STATE_RECV_CONFIG:
parser->recv_idx++;
if (parser->recv_idx >= 6) { // 起始(2)+配置(2)+序列号(2)
parser->state = PRO_STATE_RECV_LEN;
}
break;
case PRO_STATE_RECV_SEQ:
// 已在RECV_CONFIG中处理
break;
case PRO_STATE_RECV_LEN:
parser->recv_idx++;
if (parser->recv_idx >= 8) { // +总长度(2)
// 解析总长度
parser->total_len = parser->recv_buf[6] |
(parser->recv_buf[7] << 8);
parser->state = PRO_STATE_RECV_HEAD_CRC;
}
break;
case PRO_STATE_RECV_HEAD_CRC:
parser->recv_idx++;
// 验证头校验
uint8_t calc_hcrc = crc8(parser->recv_buf, 8);
if (calc_hcrc == byte) {
parser->header_verified = true;
parser->state = PRO_STATE_RECV_BODY;
} else {
parser->state = PRO_STATE_WAIT_HEAD_1;
parser->recv_idx = 0;
}
break;
case PRO_STATE_RECV_BODY:
parser->recv_idx++;
if (parser->recv_idx >= parser->total_len) {
parser->state = PRO_STATE_VERIFY;
// 继续执行验证
} else {
break;
}
// fall through to verify
case PRO_STATE_VERIFY:
// 验证尾校验
uint16_t calc_tcrc = crc16(parser->recv_buf, parser->total_len - 4);
uint16_t recv_tcrc = parser->recv_buf[parser->total_len - 4] |
(parser->recv_buf[parser->total_len - 3] << 8);
// 验证结束位
uint8_t end1 = parser->recv_buf[parser->total_len - 2];
uint8_t end2 = parser->recv_buf[parser->total_len - 1];
if (calc_tcrc == recv_tcrc && end1 == 0xFF && end2 == 0x00) {
// 帧校验通过,处理数据
handle_pro_frame(parser->recv_buf, parser->total_len);
}
parser->state = PRO_STATE_WAIT_HEAD_1;
parser->recv_idx = 0;
break;
default:
parser->state = PRO_STATE_WAIT_HEAD_1;
parser->recv_idx = 0;
break;
}
}
23.3 帧处理函数
c
void handle_pro_frame(uint8_t *frame, uint16_t len)
{
// 解析各字段
uint16_t config = frame[2] | (frame[3] << 8);
uint16_t seq_num = frame[4] | (frame[5] << 8);
uint8_t src_addr = frame[9];
uint8_t dst_addr = frame[10];
uint8_t func_id = frame[11];
uint8_t cmd_id = frame[12];
uint16_t data_len = frame[13] | (frame[14] << 8);
uint8_t *data = &frame[15];
// 检查目的地址
if (dst_addr != MY_ADDR && dst_addr != ADDR_BROADCAST) {
// 不是发给本设备的,检查是否需要转发
if (need_forward(dst_addr)) {
forward_frame(frame, len);
}
return;
}
// 检查是否需要应答
bool need_ack = (config & CONFIG_ACK_MASK) ? true : false;
// 根据功能ID和命令ID处理
process_command(func_id, cmd_id, data, data_len, src_addr, seq_num);
// 如果需要应答,发送应答帧
if (need_ack) {
uint8_t ack_data = 0x01; // 成功
send_pro_frame(src_addr, func_id, cmd_id, &ack_data, 1, seq_num, false);
}
}
23.4 本章小结
本章实现了专业版协议的解析代码:
- 使用状态机逐字节解析,支持2字节帧头
- 头校验先验证帧头完整性,通过后再接收帧体
- 尾校验验证整个帧的完整性
- 帧处理函数解析各字段并执行相应操作
- 支持地址检查、转发和应答机制
第五篇 协议解析------查表法
第24章 查表法基本概念
24.1 什么是查表法
查表法(Table-Driven Method),也叫表驱动法,是通过建立协议表结构,将解析到的字段数据代入表中进行查找,如果表中有该字段并且与之匹配,那么我们就对该数据进行处理,反之不处理。
这是原博客文章(第三篇)的核心内容。让我们深入理解这个概念。
生活中的类比:
想象你是一个快递分拣员,面前有一个表格:
| 编号 | 目的地 | 处理方式 |
|---|---|---|
| 001 | 北京仓库 | 转给北京分部 |
| 002 | 上海仓库 | 转给上海分部 |
| 003 | 广州仓库 | 转给广州分部 |
当一件快递到来时,你查看快递上的编号,然后在表格中查找:
- 如果编号是001,转给北京分部
- 如果编号是002,转给上海分部
- 如果编号不在表格中,拒绝处理
这就是查表法的核心思想:用数据表代替条件判断逻辑。
24.2 传统的switch-case方式
在引入查表法之前,协议解析通常使用switch-case或if-else:
c
void process_command(uint8_t func_id, uint8_t cmd_id, uint8_t *data, uint16_t len)
{
if (func_id == 0x01) {
if (cmd_id == 0x01) {
handle_system_reboot(data, len);
} else if (cmd_id == 0x02) {
handle_get_version(data, len);
} else if (cmd_id == 0x03) {
handle_time_sync(data, len);
} else {
// 未知命令
}
} else if (func_id == 0x02) {
if (cmd_id == 0x01) {
handle_sensor_read(data, len);
} else if (cmd_id == 0x02) {
handle_sensor_config(data, len);
} else {
// 未知命令
}
} else {
// 未知功能
}
}
switch-case方式的缺点:
- 代码冗长:每增加一个命令,就需要添加一个case分支
- 可维护性差:所有命令处理逻辑都集中在一个函数中
- 扩展困难:添加新命令需要修改解析函数
- 查找效率低:最坏情况下需要遍历所有case
- 代码重复:每个case的结构相似但重复编写
24.3 查表法的优势
查表法将命令ID与处理函数的映射关系存储在一张表中:
c
// 表结构:{功能ID, 命令ID, 处理函数, 说明}
static CommandMap cmd_table[] = {
{0x01, 0x01, handle_system_reboot, "系统重启命令"},
{0x01, 0x02, handle_get_version, "获取版本命令"},
{0x01, 0x03, handle_time_sync, "时间同步命令"},
{0x02, 0x01, handle_sensor_read, "传感器读取命令"},
{0x02, 0x02, handle_sensor_config, "传感器配置命令"}
};
解析时只需遍历表查找匹配项:
c
void process_command(uint8_t func_id, uint8_t cmd_id, uint8_t *data, uint16_t len)
{
for (int i = 0; i < sizeof(cmd_table)/sizeof(CommandMap); i++) {
if (cmd_table[i].func_id == func_id &&
cmd_table[i].cmd_id == cmd_id) {
cmd_table[i].handler(data, len);
return;
}
}
// 未找到匹配的命令
default_handler(data, len);
}
查表法的优势:
- 代码简洁:解析逻辑只有几行,不随命令数量增加而变长
- 可维护性好:添加新命令只需在表中添加一行
- 扩展性强:不需要修改解析函数,只修改表
- 查找效率:线性查找,O(n)复杂度,可以优化为哈希查找O(1)
- 代码复用:表结构统一,避免重复代码
- 可读性好:表本身就是命令的文档
24.4 查表法的设计思路
查表法的设计分为以下步骤:
- 确定查表的依据:用什么字段作为查找关键字?(功能ID + 命令ID)
- 设计表的结构:表的每个元素包含哪些信息?(关键字 + 处理函数 + 说明)
- 建立表:填充表的内容
- 实现查找逻辑:遍历表,匹配关键字
- 调用处理函数:匹配成功后调用对应的处理函数
24.5 本章小结
本章介绍了查表法的基本概念:
- 查表法是用数据表代替条件判断的设计方法
- 相比switch-case,查表法代码简洁、可维护、易扩展
- 设计步骤:确定依据 → 设计结构 → 建表 → 查找 → 调用
第25章 函数指针详解
25.1 什么是指针
在讲解函数指针之前,先回顾一下普通指针的概念。
指针是一个变量,它存储的是另一个变量的内存地址。
c
int a = 10;
int *p = &a; // p存储a的地址
printf("%d\n", *p); // 通过指针访问a的值,输出10
25.2 什么是函数指针
函数指针是一个指向函数的指针。就像普通指针指向变量一样,函数指针指向函数的入口地址。
程序编译后,函数的代码被加载到内存中,每个函数都有一个入口地址。函数指针就是存储这个入口地址的变量。
c
// 函数定义
void my_function(uint8_t *data, uint16_t len) {
// 函数体
}
// 函数指针定义
void (*func_ptr)(uint8_t*, uint16_t) = my_function;
// 通过函数指针调用函数
func_ptr(some_data, some_len); // 等同于 my_function(some_data, some_len);
25.3 函数指针的语法
函数指针的定义语法比较复杂,让我们逐步拆解:
void (*CommandHandler)(uint8_t*, uint16_t);
│ │ │ │
│ │ │ └── 参数列表
│ │ └── 指针变量名
│ └── * 表示指针
└── 返回类型
拆解说明:
void:函数的返回类型,表示无返回值(*CommandHandler):CommandHandler是一个指针变量,*表示它是指针类型(uint8_t*, uint16_t):函数的参数列表,表示接受一个uint8_t指针和一个uint16_t参数
容易混淆的语法:
c
// 这是函数指针(返回void的函数的指针)
void (*func_ptr)(uint8_t*, uint16_t);
// 这是返回void*的函数声明(不是指针)
void *func_ptr(uint8_t*, uint16_t);
区别在于括号的位置:(*func_ptr) 是指针,*func_ptr() 是函数。
25.4 使用typedef简化函数指针
原博客文章中提到了使用typedef为函数指针起别名:
c
typedef void (*CommandHandler)(uint8_t*, uint16_t);
typedef的作用:
typedef 用于给类型起别名。在这里,它的作用是:
CommandHandler == void (*)(uint8_t*, uint16_t)
也就是说,CommandHandler 成为了"接受 uint8_t* 和 uint16_t 参数、返回 void 的函数指针"类型的别名。
使用typedef前:
c
// 定义函数指针变量
void (*handler1)(uint8_t*, uint16_t);
void (*handler2)(uint8_t*, uint16_t);
// 在结构体中使用
struct {
void (*handler)(uint8_t*, uint16_t); // 冗长的声明
};
使用typedef后:
c
typedef void (*CommandHandler)(uint8_t*, uint16_t);
// 定义函数指针变量
CommandHandler handler1;
CommandHandler handler2;
// 在结构体中使用
struct {
CommandHandler handler; // 简洁明了
};
typedef让函数指针的使用变得简洁清晰,特别是在结构体定义和函数参数中。
25.5 函数指针的使用示例
c
#include <stdio.h>
/* 定义函数指针类型 */
typedef void (*CommandHandler)(uint8_t*, uint16_t);
/* 定义几个处理函数 */
void handle_led_on(uint8_t *data, uint16_t len)
{
printf("LED打开\n");
}
void handle_led_off(uint8_t *data, uint16_t len)
{
printf("LED关闭\n");
}
void handle_read_temp(uint8_t *data, uint16_t len)
{
printf("读取温度\n");
}
int main()
{
/* 定义函数指针变量并赋值 */
CommandHandler handler;
/* 指向不同的函数 */
handler = handle_led_on;
handler(NULL, 0); // 输出: LED打开
handler = handle_led_off;
handler(NULL, 0); // 输出: LED关闭
handler = handle_read_temp;
handler(NULL, 0); // 输出: 读取温度
return 0;
}
25.6 函数指针在查表法中的作用
函数指针是查表法的核心。在命令处理表中,每个条目存储一个函数指针,指向该命令对应的处理函数。当查找到匹配的命令时,通过函数指针调用处理函数。
c
// 表中的每个条目包含函数指针
typedef struct {
uint8_t func_id;
uint8_t cmd_id;
CommandHandler handler; // 函数指针
char desc[32];
} CommandMap;
// 通过函数指针调用处理函数
cmd_table[i].handler(data, len);
这种设计实现了数据与逻辑的分离:命令表是数据,处理函数是逻辑,通过函数指针将两者关联起来。
25.7 本章小结
本章详细讲解了函数指针:
- 函数指针是指向函数入口地址的指针
- 语法:
void (*func_ptr)(uint8_t*, uint16_t) - typedef可以简化函数指针的定义
- 函数指针是查表法的核心,实现数据与逻辑的分离
- 通过函数指针可以在运行时动态调用不同的函数
第26章 结构体与表驱动设计
26.1 为什么需要结构体
原博客文章中解释了为什么用结构体来建表:
首先看表的框架可以看出它是一个数组的模样,有很多元素,但与普通数组不同的是它每个元素都可以存放多个不同的数据类型变量或者函数,那么结构体刚好就可以满足元素的特征。
普通数组只能存储同一种类型的数据:
c
uint8_t array[10]; // 只能存uint8_t
uint16_t array2[10]; // 只能存uint16_t
但我们的命令表需要存储多种类型的数据:功能ID(uint8_t)、命令ID(uint8_t)、处理函数(函数指针)、说明文字(char\[\])。结构体正好可以满足这个需求。
26.2 定义结构体
原博客文章中给出的结构体定义:
c
typedef struct {
uint8_t func_id; // 功能ID
uint8_t cmd_id; // 命令ID
CommandHandler handler; // 处理函数
char desc[32]; // 功能说明
} CommandList;
各字段说明:
func_id:功能ID,1字节,标识功能模块cmd_id:命令ID,1字节,标识具体命令handler:处理函数,函数指针类型CommandHandlerdesc[32]:功能说明,32字节的字符数组,用于存储命令的文字描述
为什么要包含 desc 字段?
desc 字段虽然不参与功能逻辑,但它在调试和维护时非常有用:
- 调试时可以打印命令说明,便于定位问题
- 代码审查时可以快速理解每个命令的用途
- 相当于代码的自文档化
26.3 建表
原博客文章中给出了建表的示例:
c
static CommandList cmd_table[] = {
{0x01, 0x01, handle_system_reboot, "系统重启命令"},
{0x01, 0x02, handle_sensor_read, "传感器读取命令"}
};
代码解析:
static:表示这个表只在当前文件内可见,避免命名冲突CommandList cmd_table[]:定义一个CommandList类型的数组,数组名是cmd_table{}内的每一行是一个CommandList结构体的初始化- 每行包含4个值,对应结构体的4个字段:func_id, cmd_id, handler, desc
表的内存布局:
cmd_table[0]: func_id=0x01, cmd_id=0x01, handler=&handle_system_reboot, desc="系统重启命令"
cmd_table[1]: func_id=0x01, cmd_id=0x02, handler=&handle_sensor_read, desc="传感器读取命令"
每个元素在内存中连续排列,可以通过数组索引访问。
26.4 扩展表
添加新命令只需在表中添加一行:
c
static CommandList cmd_table[] = {
{0x01, 0x01, handle_system_reboot, "系统重启命令"},
{0x01, 0x02, handle_get_version, "获取版本命令"},
{0x01, 0x03, handle_time_sync, "时间同步命令"},
{0x02, 0x01, handle_sensor_read, "传感器读取命令"},
{0x02, 0x02, handle_sensor_config, "传感器配置命令"},
{0x02, 0x03, handle_sensor_calib, "传感器校准命令"},
{0x03, 0x01, handle_led_control, "LED控制命令"},
{0x03, 0x02, handle_relay_control, "继电器控制命令"},
{0x04, 0x01, handle_wifi_config, "WiFi配置命令"},
{0x04, 0x02, handle_wifi_status, "WiFi状态查询命令"}
};
这就是查表法的核心优势:添加新命令不需要修改解析逻辑,只需在表中添加一行。
26.5 表的大小计算
在遍历表时,需要知道表中有多少个元素。常用的计算方法:
c
#define CMD_TABLE_SIZE (sizeof(cmd_table) / sizeof(CommandList))
sizeof(cmd_table) 返回整个数组的字节数,sizeof(CommandList) 返回单个元素的字节数,两者相除就是元素个数。
26.6 本章小结
本章讲解了结构体与表驱动设计:
- 结构体可以存储多种类型的数据,适合作为表的元素类型
- 命令处理表的结构体包含:功能ID、命令ID、处理函数、说明
- 建表就是定义并初始化结构体数组
- 添加新命令只需在表中添加一行,无需修改解析逻辑
- 使用sizeof可以计算表的元素个数
第27章 协议字段映射表
27.1 字段映射表的概念
原博客文章(第三篇)不仅讲了命令处理表,还介绍了字段级别的查表法。在解析数据帧时,也可以通过查表方式去处理各字段数据。
这种设计的思路是:为协议帧的每个字段建立一个映射表,表中记录每个字段的偏移量、大小、名称和处理函数。解析时遍历表,对每个字段调用对应的处理函数。
27.2 定义字段处理函数
原博客文章中定义了12个字段处理函数,对应协议帧的12个字段:
c
static void parse_header(void* field); // 起始位字段
static void parse_config(void* field); // 配置位字段
static void parse_sequence(void* field); // 序列号字段
static void parse_length(void* field); // 帧总长度字段
static void verify_header(void* field); // 头校验字段
static void parse_src_address(void* field); // 源地址字段
static void parse_dest_address(void* field); // 目标地址字段
static void parse_func_id(void* field); // 功能类型ID字段
static void parse_cmd_id(void* field); // 命令ID字段
static void parse_data_len(void* field); // 数据长度字段
static void parse_data_ptr(void* field); // 数据内容字段
static void verify_tail(void* field); // 尾校验字段
每个处理函数接收一个 void* 参数,指向字段数据的地址。处理函数内部根据字段的类型和含义进行相应的处理。
27.3 字段处理函数的实现示例
c
static void parse_header(void* field)
{
uint16_t *header = (uint16_t*)field;
if (*header != 0xAA55) { // 注意大小端,可能需要调整
printf("错误:帧头不匹配\n");
} else {
printf("帧头匹配: 0x%04X\n", *header);
}
}
static void parse_config(void* field)
{
uint16_t *config = (uint16_t*)field;
uint8_t version = GET_VERSION(*config);
bool need_ack = GET_ACK_FLAG(*config);
uint8_t encrypt = GET_ENCRYPT(*config);
printf("配置位: 版本=%d, 应答=%d, 加密=%d\n",
version, need_ack, encrypt);
}
static void parse_sequence(void* field)
{
uint16_t *seq = (uint16_t*)field;
printf("序列号: %d\n", *seq);
}
static void parse_src_address(void* field)
{
uint8_t *addr = (uint8_t*)field;
printf("源地址: 0x%02X\n", *addr);
}
static void parse_dest_address(void* field)
{
uint8_t *addr = (uint8_t*)field;
printf("目的地址: 0x%02X\n", *addr);
if (*addr != MY_ADDR && *addr != ADDR_BROADCAST) {
printf("警告:目的地址不匹配\n");
}
}
static void parse_func_id(void* field)
{
uint8_t *func_id = (uint8_t*)field;
printf("功能ID: 0x%02X\n", *func_id);
}
static void parse_cmd_id(void* field)
{
uint8_t *cmd_id = (uint8_t*)field;
printf("命令ID: 0x%02X\n", *cmd_id);
}
static void parse_data_len(void* field)
{
uint16_t *len = (uint16_t*)field;
printf("数据长度: %d\n", *len);
}
static void verify_header(void* field)
{
uint16_t *crc = (uint16_t*)field;
printf("头校验: 0x%04X\n", *crc);
// 实际应用中这里会验证CRC
}
static void verify_tail(void* field)
{
uint16_t *crc = (uint16_t*)field;
printf("尾校验: 0x%04X\n", *crc);
// 实际应用中这里会验证CRC
}
static void parse_length(void* field)
{
uint16_t *len = (uint16_t*)field;
printf("帧总长度: %d\n", *len);
}
static void parse_data_ptr(void* field)
{
uint8_t **data = (uint8_t**)field;
printf("数据指针: %p\n", (void*)*data);
}
27.4 定义字段映射结构体
原博客文章中定义了一个 FieldMap 结构体来描述字段的映射信息:
c
typedef struct {
uint16_t offset; // 字段偏移量
uint8_t size; // 字段大小
char name[16]; // 解释说明
void (*parser)(void*); // 处理函数
} FieldMap;
各字段说明:
offset:字段在结构体中的偏移量(字节),通过offsetof宏获取size:字段的大小(字节)name:字段名称,用于显示和调试parser:字段处理函数的函数指针
27.5 使用offsetof宏
offsetof 是C标准库 <stddef.h> 中定义的宏,用于获取结构体成员的偏移量:
c
offsetof(ProtocolFrame, start_flag) // 返回start_flag在结构体中的偏移量
例如,在 ProtocolFrame 结构体中:
start_flag的偏移量是0(第一个成员)config的偏移量是2(在start_flag之后)seq_num的偏移量是4- 依此类推...
27.6 建立字段映射表
原博客文章中给出了完整的字段映射表:
c
static FieldMap protocol_map[] = {
{offsetof(ProtocolFrame, start_flag), sizeof(uint16_t), "起始位", parse_header},
{offsetof(ProtocolFrame, config), sizeof(uint16_t), "配置位", parse_config},
{offsetof(ProtocolFrame, seq_num), sizeof(uint16_t), "序列号", parse_sequence},
{offsetof(ProtocolFrame, total_len), sizeof(uint16_t), "总长度", parse_length},
{offsetof(ProtocolFrame, header_crc), sizeof(uint16_t), "头校验", verify_header},
{offsetof(ProtocolFrame, src_addr), sizeof(uint8_t), "源地址", parse_src_address},
{offsetof(ProtocolFrame, dst_addr), sizeof(uint8_t), "目的地址", parse_dest_address},
{offsetof(ProtocolFrame, func_id), sizeof(uint8_t), "功能ID", parse_func_id},
{offsetof(ProtocolFrame, cmd_id), sizeof(uint8_t), "命令ID", parse_cmd_id},
{offsetof(ProtocolFrame, data_len), sizeof(uint16_t), "数据长度", parse_data_len},
{offsetof(ProtocolFrame, data), sizeof(uint8_t*), "数据指针", parse_data_ptr},
{offsetof(ProtocolFrame, tail_crc), sizeof(uint16_t), "尾校验", verify_tail}
};
这张表的作用:
当接收到一帧数据后,将原始数据强制转换为 ProtocolFrame 结构体,然后遍历这张表,对每个字段调用对应的处理函数。
27.7 字段映射表的优势
- 解耦:字段解析逻辑与帧结构定义分离,修改字段处理不需要改解析代码
- 可扩展:添加新字段只需在表中添加一行
- 统一处理:所有字段的解析方式一致,代码风格统一
- 调试友好:可以通过遍历表打印所有字段信息
- 可配置:可以动态启用/禁用某些字段的解析
27.8 本章小结
本章详细讲解了协议字段映射表:
- 为每个字段定义处理函数
- 使用
FieldMap结构体描述字段的偏移量、大小、名称和处理函数 - 使用
offsetof宏获取字段偏移量 - 字段映射表实现了解析逻辑与帧结构的解耦
第28章 功能ID与命令ID处理表
28.1 命令处理表的设计
在字段级别的查表法之外,还需要一个命令级别的处理表,用于根据功能ID和命令ID分发到具体的处理函数。
原博客文章中给出了完整的设计:
28.2 定义函数指针类型
c
typedef void (*CommandHandler)(uint8_t*, uint16_t);
这定义了一个函数指针类型 CommandHandler,它指向的函数接受两个参数:
uint8_t*:数据指针uint16_t:数据长度
28.3 定义命令处理结构体
c
typedef struct {
uint8_t func_id;
uint8_t cmd_id;
CommandHandler handler;
char desc[32];
} CommandMap;
这个结构体与第26章的 CommandList 类似,但原博客文章在第三篇中将其命名为 CommandMap,更准确地表达了"映射"的含义。
28.4 建立命令处理表
c
static CommandMap cmd_table[] = {
{0x01, 0x01, handle_system_reboot, "系统重启命令"},
{0x02, 0x01, handle_sensor_read, "传感器读取命令"}
};
28.5 定义处理函数
c
/* 系统重启处理函数 */
static void handle_system_reboot(uint8_t* data, uint16_t len)
{
printf("执行系统重启...\n");
// 实际代码中这里会触发系统重启
// NVIC_SystemReset();
}
/* 传感器读取处理函数 */
static void handle_sensor_read(uint8_t* data, uint16_t len)
{
printf("执行传感器读取...\n");
// 实际代码中这里会读取传感器数据并返回
uint8_t temp_data[2] = {0x19, 0x00}; // 25℃
// send_pro_frame(...);
}
/* 默认处理函数(未知命令) */
static void default_handler(uint8_t* data, uint16_t len)
{
printf("未知命令\n");
}
28.6 命令查找与分发
c
void dispatch_command(uint8_t func_id, uint8_t cmd_id, uint8_t* data, uint16_t len)
{
uint16_t table_size = sizeof(cmd_table) / sizeof(CommandMap);
for (uint16_t i = 0; i < table_size; i++) {
if (cmd_table[i].func_id == func_id &&
cmd_table[i].cmd_id == cmd_id) {
// 找到匹配的命令,调用处理函数
printf("执行命令: %s\n", cmd_table[i].desc);
cmd_table[i].handler(data, len);
return;
}
}
// 未找到匹配的命令,调用默认处理函数
printf("未找到命令: func_id=0x%02X, cmd_id=0x%02X\n", func_id, cmd_id);
default_handler(data, len);
}
查找过程详解:
- 计算表的大小(元素个数)
- 遍历表中的每个元素
- 比较元素的功能ID和命令ID与传入的值是否匹配
- 如果匹配,调用对应的处理函数并返回
- 如果遍历完整个表都没有匹配,调用默认处理函数
28.7 查找效率分析
当前使用的是线性查找(Linear Search),时间复杂度为O(n),其中n是表的大小。
对于小型系统(命令数量不超过几十个),线性查找的性能完全足够。
如果命令数量很大(上百个),可以考虑以下优化:
优化1:排序+二分查找
将表按功能ID和命令ID排序,然后使用二分查找,时间复杂度降为O(log n)。
优化2:哈希表
使用哈希表将(功能ID, 命令ID)映射到处理函数,时间复杂度为O(1)。
优化3:二维数组直接索引
如果功能ID和命令ID的范围不大(如都是0~15),可以用二维数组直接索引:
c
CommandHandler handler_table[16][16] = {
// cmd_id: 0 1 2 ...
/* func 0 */ {NULL, NULL, NULL, ...},
/* func 1 */ {NULL, reboot, version, ...},
/* func 2 */ {NULL, read_sensor, config_sensor, ...},
...
};
void dispatch_command(uint8_t func_id, uint8_t cmd_id, uint8_t* data, uint16_t len)
{
if (func_id < 16 && cmd_id < 16 && handler_table[func_id][cmd_id] != NULL) {
handler_table[func_id][cmd_id](data, len);
} else {
default_handler(data, len);
}
}
这种方式的时间复杂度为O(1),但牺牲了灵活性(需要固定大小的表)。
28.8 本章小结
本章详细讲解了命令处理表的设计:
- 定义
CommandHandler函数指针类型 - 定义
CommandMap结构体,包含功能ID、命令ID、处理函数和说明 - 建立命令处理表,将命令与处理函数关联
- 实现命令查找与分发逻辑
- 分析了查找效率及优化方案
第29章 完整数据帧解析流程
29.1 解析流程概述
原博客文章(第三篇)给出了完整的数据帧解析流程,分为4个步骤:
step1:将接收到的数据代入解析函数step2:强制转换为结构体协议帧类型step3:对每个字段进行查表处理step4:如果各字段查表处理通过,则执行最终的命令表处理
29.2 解析流程图
接收到的原始字节流
│
▼
┌───────────────────┐
│ Step1: 代入解析函数 │ parse_frame(raw_data)
└───────┬───────────┘
│
▼
┌───────────────────┐
│ Step2: 强制类型转换 │ ProtocolFrame *frame = (ProtocolFrame*)raw_data
└───────┬───────────┘
│
▼
┌───────────────────┐
│ Step3: 字段查表处理 │ 遍历protocol_map[],对每个字段调用parser
│ - 检查帧头 │
│ - 解析配置位 │
│ - 检查地址 │
│ - 验证头校验 │
│ - 验证尾校验 │
│ ... │
└───────┬───────────┘
│ 所有字段验证通过
▼
┌───────────────────┐
│ Step4: 命令表处理 │ 查找cmd_table[],调用对应处理函数
│ - 匹配功能ID+命令ID │
│ - 调用处理函数 │
└───────────────────┘
29.3 解析函数代码
原博客文章中给出的解析函数:
c
void parse_frame(uint8_t *raw_data)
{
ProtocolFrame *frame = (ProtocolFrame*)raw_data;
// 字段表驱动解析
for(size_t i=0; i<sizeof(protocol_map)/sizeof(FieldMap); i++)
{
uint8_t *field_ptr = raw_data + protocol_map[i].offset;
printf("解析字段: %s\n", protocol_map[i].name);
protocol_map[i].parser(field_ptr);
}
// 执行最终处理
if(flag == true)
parse_data(frame->data, frame->data_len, frame->func_id, frame->cmd_id);
}
代码逐行解析:
-
ProtocolFrame *frame = (ProtocolFrame*)raw_data;- 将接收到的原始字节数据强制转换为
ProtocolFrame结构体指针 - 这样可以通过结构体成员名直接访问各个字段
- 前提是使用了
#pragma pack(1)确保字节对齐
- 将接收到的原始字节数据强制转换为
-
for(size_t i=0; i<sizeof(protocol_map)/sizeof(FieldMap); i++)- 遍历字段映射表中的每个条目
sizeof(protocol_map)/sizeof(FieldMap)计算表中的条目数量
-
uint8_t *field_ptr = raw_data + protocol_map[i].offset;- 计算当前字段在原始数据中的地址
raw_data是原始数据的起始地址protocol_map[i].offset是字段的偏移量- 两者相加得到字段数据的地址
-
printf("解析字段: %s\n", protocol_map[i].name);- 打印当前正在解析的字段名称,用于调试
-
protocol_map[i].parser(field_ptr);- 调用字段处理函数,传入字段数据的地址
parser是函数指针,指向当前字段对应的处理函数
-
if(flag == true)- 检查所有字段验证是否通过
flag是一个全局标志,在各字段处理函数中设置- 如果任何字段验证失败,flag会被设为false
-
parse_data(frame->data, frame->data_len, frame->func_id, frame->cmd_id);- 调用命令处理函数,传入数据、数据长度、功能ID和命令ID
- 这是最终的命令分发
29.4 parse_data函数
c
void parse_data(uint8_t* data, uint16_t data_len, uint8_t func_id, uint8_t cmd_id)
{
uint16_t table_size = sizeof(cmd_table) / sizeof(CommandMap);
for (uint16_t i = 0; i < table_size; i++) {
if (cmd_table[i].func_id == func_id &&
cmd_table[i].cmd_id == cmd_id) {
printf("执行命令: %s\n", cmd_table[i].desc);
cmd_table[i].handler(data, data_len);
return;
}
}
printf("未找到匹配的命令\n");
default_handler(data, data_len);
}
29.5 强制类型转换的安全性
ProtocolFrame *frame = (ProtocolFrame*)raw_data; 这种将原始字节流直接强制转换为结构体指针的做法需要特别注意安全性:
前提条件:
- 结构体使用了
#pragma pack(1)确保1字节对齐,没有填充字节 - 原始数据的字节顺序与结构体成员的定义顺序一致
- 原始数据的长度不小于结构体的大小
潜在风险:
- 如果原始数据不完整,访问结构体成员可能越界
- 指针类型的成员(如
uint8_t* data)在强制转换后,其值是原始数据中的字节值,而不是有效的指针地址 - 在不同的CPU架构上,字节序可能不同
改进建议:
对于指针类型的字段(如 data),不能直接通过强制转换获取,应该单独处理:
c
// 不安全:frame->data 可能不是有效指针
// uint8_t *data = frame->data;
// 安全:根据偏移量计算数据地址
uint8_t *data = raw_data + offsetof(ProtocolFrame, data_len) + sizeof(uint16_t);
uint16_t data_len = frame->data_len;
29.6 完整解析流程的改进版本
c
typedef struct {
bool header_valid;
bool config_valid;
bool address_valid;
bool header_crc_valid;
bool tail_crc_valid;
bool all_valid;
} FrameValidation;
static FrameValidation g_validation;
void parse_frame(uint8_t *raw_data, uint16_t raw_len)
{
// 安全检查:数据长度
if (raw_len < sizeof(ProtocolFrame) - sizeof(uint8_t*) - sizeof(uint16_t)) {
printf("数据长度不足\n");
return;
}
// 重置验证状态
memset(&g_validation, 0, sizeof(g_validation));
// 强制转换
ProtocolFrame *frame = (ProtocolFrame*)raw_data;
// Step3: 字段查表处理
printf("===== 开始字段解析 =====\n");
for (size_t i = 0; i < sizeof(protocol_map)/sizeof(FieldMap); i++) {
uint8_t *field_ptr = raw_data + protocol_map[i].offset;
printf("解析字段: %s (偏移:%d, 大小:%d)\n",
protocol_map[i].name,
protocol_map[i].offset,
protocol_map[i].size);
protocol_map[i].parser(field_ptr);
}
// 汇总验证结果
g_validation.all_valid = g_validation.header_valid &&
g_validation.config_valid &&
g_validation.address_valid &&
g_validation.header_crc_valid &&
g_validation.tail_crc_valid;
// Step4: 命令处理
if (g_validation.all_valid) {
printf("===== 字段验证通过,执行命令 =====\n");
// 计算数据的实际地址(不能用frame->data,因为它是指针类型)
uint8_t *data_ptr = raw_data + 15; // 数据在帧中的偏移量
uint16_t data_len = frame->data_len;
parse_data(data_ptr, data_len, frame->func_id, frame->cmd_id);
} else {
printf("===== 字段验证失败,丢弃帧 =====\n");
}
}
29.7 本章小结
本章详细讲解了完整的数据帧解析流程:
- Step1:将原始数据代入解析函数
- Step2:强制转换为结构体指针
- Step3:遍历字段映射表,对每个字段调用处理函数
- Step4:所有字段验证通过后,查找命令处理表并调用处理函数
- 注意强制类型转换的安全性,特别是指针类型字段
第六篇 完整实战项目
第30章 项目架构设计
30.1 项目概述
本章将前面所有章节的知识整合,设计一个完整的串口通信协议框架。这个框架包含:
- 简化版协议的封包与解析(适合快速开发)
- 专业版协议的封包与解析(适合企业级应用)
- 查表法的协议解析(表驱动设计)
- 完整的CRC校验实现
- 状态机驱动的接收解析
- 命令处理表
30.2 文件结构设计
project/
├── protocol/
│ ├── protocol.h // 协议公共定义(常量、类型、宏)
│ ├── protocol_crc.h // CRC校验头文件
│ ├── protocol_crc.c // CRC校验实现
│ ├── simple_protocol.h // 简化版协议头文件
│ ├── simple_protocol.c // 简化版协议实现
│ ├── pro_protocol.h // 专业版协议头文件
│ ├── pro_protocol.c // 专业版协议实现
│ ├── table_parser.h // 查表法解析头文件
│ └── table_parser.c // 查表法解析实现
├── app/
│ ├── command_handler.h // 命令处理函数头文件
│ ├── command_handler.c // 命令处理函数实现
│ └── main.c // 主程序
└── hal/
├── uart.h // UART驱动头文件
└── uart.c // UART驱动实现
30.3 分层架构
┌─────────────────────────────────────────┐
│ 应用层 │
│ command_handler.c / main.c │
│ - 命令处理函数 │
│ - 业务逻辑 │
├─────────────────────────────────────────┤
│ 协议解析层 │
│ table_parser.c │
│ - 字段映射表 │
│ - 命令处理表 │
│ - parse_frame() │
├─────────────────────────────────────────┤
│ 协议封包/解析层 │
│ simple_protocol.c / pro_protocol.c │
│ - 封包函数 │
│ - 状态机解析 │
│ - 校验计算 │
├─────────────────────────────────────────┤
│ CRC校验层 │
│ protocol_crc.c │
│ - crc8() / crc16() │
│ - 查表法优化 │
├─────────────────────────────────────────┤
│ 硬件驱动层 │
│ uart.c │
│ - uart_send_buffer() │
│ - uart_receive_byte() │
└─────────────────────────────────────────┘
30.4 设计原则
- 模块化:每个功能模块独立,通过头文件暴露接口
- 可配置:通过宏定义选择使用简化版或专业版协议
- 可扩展:添加新命令只需修改命令处理表
- 可移植:硬件相关代码隔离在hal层
30.5 本章小结
本章设计了项目的架构:
- 采用分层架构:应用层 → 协议解析层 → 封包/解析层 → CRC层 → 硬件层
- 文件结构清晰,功能模块独立
- 遵循模块化、可配置、可扩展、可移植的设计原则
第31章 完整代码实现
31.1 protocol.h - 协议公共定义
c
#ifndef __PROTOCOL_H
#define __PROTOCOL_H
#include <stdint.h>
#include <stdbool.h>
#include <string.h>
#include <stddef.h>
/* ==================== 协议常量 ==================== */
/* 简化版协议 */
#define SIMPLE_HEAD 0x55
#define SIMPLE_TAIL 0xFF
#define SIMPLE_MAX_DATA 255
/* 专业版协议 */
#define PRO_HEAD_1 0x55
#define PRO_HEAD_2 0xAA
#define PRO_TAIL_1 0xFF
#define PRO_TAIL_2 0x00
/* 地址定义 */
#define ADDR_BROADCAST 0x00
#define ADDR_MASTER 0x01
#define ADDR_DEFAULT 0x02
/* 配置位掩码 */
#define CONFIG_VER_MASK 0xC000
#define CONFIG_VER_SHIFT 14
#define CONFIG_ACK_MASK 0x2000
#define CONFIG_ACK_SHIFT 13
#define CONFIG_ENC_MASK 0x0C00
#define CONFIG_ENC_SHIFT 10
/* 配置位操作宏 */
#define GET_VERSION(c) (((c) & CONFIG_VER_MASK) >> CONFIG_VER_SHIFT)
#define GET_ACK_FLAG(c) (((c) & CONFIG_ACK_MASK) >> CONFIG_ACK_SHIFT)
#define GET_ENCRYPT(c) (((c) & CONFIG_ENC_MASK) >> CONFIG_ENC_SHIFT)
/* 功能ID定义 */
#define FUNC_SYSTEM 0x01
#define FUNC_SENSOR 0x02
#define FUNC_GPIO 0x03
#define FUNC_COMM 0x04
/* 命令ID定义 - 系统功能 */
#define CMD_REBOOT 0x01
#define CMD_GET_VERSION 0x02
#define CMD_TIME_SYNC 0x03
/* 命令ID定义 - 传感器功能 */
#define CMD_SENSOR_READ 0x01
#define CMD_SENSOR_CONFIG 0x02
#define CMD_SENSOR_CALIB 0x03
/* 命令ID定义 - GPIO功能 */
#define CMD_LED_CONTROL 0x01
#define CMD_RELAY_CONTROL 0x02
/* 缓冲区大小 */
#define TX_BUF_SIZE 512
#define RX_BUF_SIZE 512
/* 超时与重传 */
#define ACK_TIMEOUT_MS 1000
#define MAX_RETRY 3
/* ==================== 类型定义 ==================== */
/* 简化版协议帧 */
typedef struct {
uint8_t head;
uint8_t cmd;
uint16_t len;
uint8_t *data;
uint8_t checksum;
uint8_t tail;
} SimpleProtocolFrame;
/* 专业版协议帧 */
#pragma pack(1)
typedef struct {
uint16_t start_flag;
uint16_t config;
uint16_t seq_num;
uint16_t total_len;
uint16_t header_crc;
uint8_t src_addr;
uint8_t dst_addr;
uint8_t func_id;
uint8_t cmd_id;
uint16_t data_len;
// data 在此处(变长,不放在结构体中)
// tail_crc (2字节)
// end_flag (2字节)
} ProtocolFrameHeader;
#pragma pack()
/* 函数指针类型 */
typedef void (*CommandHandler)(uint8_t*, uint16_t);
typedef void (*FieldParser)(void*);
/* 命令映射表结构体 */
typedef struct {
uint8_t func_id;
uint8_t cmd_id;
CommandHandler handler;
char desc[32];
} CommandMap;
/* 字段映射表结构体 */
typedef struct {
uint16_t offset;
uint8_t size;
char name[16];
FieldParser parser;
} FieldMap;
#endif /* __PROTOCOL_H */
31.2 protocol_crc.c - CRC校验实现
c
#include "protocol.h"
/* CRC-8 计算(多项式0x07) */
uint8_t crc8(const uint8_t *data, uint16_t len)
{
uint8_t crc = 0x00;
while (len--) {
crc ^= *data++;
for (uint8_t i = 0; i < 8; i++) {
crc = (crc & 0x80) ? (crc << 1) ^ 0x07 : (crc << 1);
}
}
return crc;
}
/* CRC-16 计算(多项式0x1021) */
uint16_t crc16(const uint8_t *data, uint16_t len)
{
uint16_t crc = 0xFFFF;
while (len--) {
crc ^= (uint16_t)(*data++) << 8;
for (uint8_t i = 0; i < 8; i++) {
crc = (crc & 0x8000) ? (crc << 1) ^ 0x1021 : (crc << 1);
}
}
return crc;
}
/* 和校验 */
uint8_t sum_checksum(uint8_t cmd, uint16_t len, const uint8_t *data)
{
uint8_t sum = cmd;
sum += (len & 0xFF);
sum += ((len >> 8) & 0xFF);
for (uint16_t i = 0; i < len; i++) {
sum += data[i];
}
return sum;
}
31.3 table_parser.c - 查表法解析实现
c
#include "protocol.h"
/* ==================== 字段处理函数 ==================== */
static bool g_frame_valid = false;
static uint8_t g_my_addr = ADDR_DEFAULT;
static void parse_header(void* field)
{
uint8_t *p = (uint8_t*)field;
if (p[0] == PRO_HEAD_1 && p[1] == PRO_HEAD_2) {
g_frame_valid = true;
} else {
g_frame_valid = false;
}
}
static void parse_config(void* field)
{
uint16_t *config = (uint16_t*)field;
// 可以在这里检查协议版本等
}
static void parse_sequence(void* field)
{
uint16_t *seq = (uint16_t*)field;
// 可以在这里记录序列号用于应答
}
static void parse_length(void* field)
{
uint16_t *len = (uint16_t*)field;
// 可以在这里检查长度是否合理
}
static void verify_header_crc(void* field)
{
// 头校验验证(需要在调用前计算)
}
static void parse_src_address(void* field)
{
uint8_t *addr = (uint8_t*)field;
}
static void parse_dest_address(void* field)
{
uint8_t *addr = (uint8_t*)field;
if (*addr != g_my_addr && *addr != ADDR_BROADCAST) {
g_frame_valid = false;
}
}
static void parse_func_id(void* field)
{
uint8_t *id = (uint8_t*)field;
}
static void parse_cmd_id(void* field)
{
uint8_t *id = (uint8_t*)field;
}
static void parse_data_len(void* field)
{
uint16_t *len = (uint16_t*)field;
}
static void parse_data_ptr(void* field)
{
// 数据指针字段,特殊处理
}
static void verify_tail(void* field)
{
// 尾校验验证
}
/* ==================== 字段映射表 ==================== */
static FieldMap protocol_map[] = {
{0, 2, "起始位", parse_header},
{2, 2, "配置位", parse_config},
{4, 2, "序列号", parse_sequence},
{6, 2, "总长度", parse_length},
{8, 2, "头校验", verify_header_crc},
{10, 1, "源地址", parse_src_address},
{11, 1, "目的地址", parse_dest_address},
{12, 1, "功能ID", parse_func_id},
{13, 1, "命令ID", parse_cmd_id},
{14, 2, "数据长度", parse_data_len},
{16, 4, "数据指针", parse_data_ptr},
{0, 2, "尾校验", verify_tail} // 偏移量需要动态计算
};
/* ==================== 命令处理表 ==================== */
/* 前向声明处理函数 */
static void handle_system_reboot(uint8_t* data, uint16_t len);
static void handle_get_version(uint8_t* data, uint16_t len);
static void handle_sensor_read(uint8_t* data, uint16_t len);
static void handle_led_control(uint8_t* data, uint16_t len);
static void default_handler(uint8_t* data, uint16_t len);
static CommandMap cmd_table[] = {
{FUNC_SYSTEM, CMD_REBOOT, handle_system_reboot, "系统重启"},
{FUNC_SYSTEM, CMD_GET_VERSION, handle_get_version, "获取版本"},
{FUNC_SENSOR, CMD_SENSOR_READ, handle_sensor_read, "传感器读取"},
{FUNC_GPIO, CMD_LED_CONTROL, handle_led_control, "LED控制"}
};
#define CMD_TABLE_SIZE (sizeof(cmd_table) / sizeof(CommandMap))
/* ==================== 命令分发 ==================== */
void dispatch_command(uint8_t func_id, uint8_t cmd_id,
uint8_t* data, uint16_t len)
{
for (uint16_t i = 0; i < CMD_TABLE_SIZE; i++) {
if (cmd_table[i].func_id == func_id &&
cmd_table[i].cmd_id == cmd_id) {
cmd_table[i].handler(data, len);
return;
}
}
default_handler(data, len);
}
/* ==================== 完整解析流程 ==================== */
void parse_frame(uint8_t *raw_data, uint16_t raw_len)
{
if (raw_len < 18) { // 最小帧长度
return;
}
g_frame_valid = true;
ProtocolFrameHeader *header = (ProtocolFrameHeader*)raw_data;
// 字段查表处理
for (size_t i = 0; i < sizeof(protocol_map)/sizeof(FieldMap) - 1; i++) {
if (protocol_map[i].offset + protocol_map[i].size <= raw_len) {
uint8_t *field_ptr = raw_data + protocol_map[i].offset;
protocol_map[i].parser(field_ptr);
}
}
if (!g_frame_valid) {
return;
}
// 提取数据
uint16_t data_len = header->data_len;
uint8_t *data_ptr = raw_data + 16; // 数据在帧中的偏移量
// 命令分发
dispatch_command(header->func_id, header->cmd_id,
data_ptr, data_len);
}
/* ==================== 命令处理函数 ==================== */
static void handle_system_reboot(uint8_t* data, uint16_t len)
{
// NVIC_SystemReset();
}
static void handle_get_version(uint8_t* data, uint16_t len)
{
uint8_t version[] = {1, 0, 0}; // v1.0.0
// send response...
}
static void handle_sensor_read(uint8_t* data, uint16_t len)
{
// 读取传感器并返回数据
}
static void handle_led_control(uint8_t* data, uint16_t len)
{
if (len >= 1) {
if (data[0] == 1) {
// LED ON
} else {
// LED OFF
}
}
}
static void default_handler(uint8_t* data, uint16_t len)
{
// 未知命令处理
}
31.4 本章小结
本章给出了完整项目的核心代码:
protocol.h定义了所有公共常量、类型和宏protocol_crc.c实现了CRC-8、CRC-16和和校验table_parser.c实现了字段映射表、命令处理表和完整解析流程- 代码结构清晰,便于理解和维护
第32章 测试与调试方法
32.1 串口调试工具
在开发和调试串口通信协议时,以下工具非常有用:
1. 串口调试助手
如SSCOM、XCOM、PuTTY等。可以发送和接收十六进制数据,便于手动构造和验证协议帧。
2. 逻辑分析仪
如Saleae Logic、DSLogic等。可以抓取串口波形,分析时序问题。
3. 示波器
用于观察信号质量,检查电平幅度、上升/下降时间等。
4. 虚拟串口软件
如VSPD(Virtual Serial Port Driver)。可以在没有物理串口的情况下,创建虚拟串口对进行测试。
32.2 测试策略
单元测试:
对每个函数进行独立测试:
c
// 测试CRC-8
void test_crc8(void)
{
uint8_t data[] = {0x01};
uint8_t result = crc8(data, 1);
assert(result == 0x07); // 已知结果
uint8_t data2[] = {0x55, 0xAA};
uint8_t result2 = crc8(data2, 2);
printf("CRC8(0x55, 0xAA) = 0x%02X\n", result2);
}
// 测试和校验
void test_checksum(void)
{
uint8_t data[] = {0x0A, 0x0B};
uint8_t result = sum_checksum(0x02, 2, data);
assert(result == 0x19); // 0x02+0x02+0x00+0x0A+0x0B = 0x19
}
// 测试封包
void test_pack(void)
{
uint8_t params[] = {0x0A, 0x0B};
// 发送后用串口调试助手检查发出的字节
send_protocol_frame(0x02, params, 2);
// 预期输出: 55 02 02 00 0A 0B 19 FF
}
集成测试:
将封包和解析组合测试:
c
// 测试封包-解析回路
void test_loopback(void)
{
uint8_t params[] = {0x0A, 0x0B};
uint8_t buffer[256];
// 封包
SimpleProtocolFrame frame = {
.head = 0x55,
.cmd = 0x02,
.len = 2,
.data = params,
.checksum = sum_checksum(0x02, 2, params),
.tail = 0xFF
};
uint16_t len = simple_protocol_pack(&frame, buffer);
// 模拟接收
ProtocolParser parser;
parser_init(&parser);
for (uint16_t i = 0; i < len; i++) {
parser_feed_byte(&parser, buffer[i]);
}
// 验证
assert(parser.frame_ready == true);
assert(parser.cmd == 0x02);
assert(parser.data_len == 2);
assert(parser.data[0] == 0x0A);
assert(parser.data[1] == 0x0B);
}
32.3 常见调试技巧
1. 打印调试信息
在关键位置添加调试打印:
c
#ifdef DEBUG
#define DBG_PRINT(fmt, ...) printf(fmt, ##__VA_ARGS__)
#else
#define DBG_PRINT(fmt, ...)
#endif
void parser_feed_byte(ProtocolParser *parser, uint8_t byte)
{
DBG_PRINT("RX: 0x%02X, state: %d\n", byte, parser->state);
// ...
}
2. 十六进制转储
将接收到的数据以十六进制格式打印,便于检查:
c
void hex_dump(const char *prefix, uint8_t *data, uint16_t len)
{
printf("%s [%d bytes]: ", prefix, len);
for (uint16_t i = 0; i < len; i++) {
printf("%02X ", data[i]);
}
printf("\n");
}
3. 统计信息
记录通信统计信息,便于分析问题:
c
typedef struct {
uint32_t tx_count; // 发送帧数
uint32_t rx_count; // 接收帧数
uint32_t crc_error; // CRC错误次数
uint32_t frame_error; // 帧格式错误次数
uint32_t unknown_cmd; // 未知命令次数
} CommStats;
CommStats g_stats;
void print_stats(void)
{
printf("TX: %lu, RX: %lu, CRC_Err: %lu, Frame_Err: %lu, Unk_Cmd: %lu\n",
g_stats.tx_count, g_stats.rx_count,
g_stats.crc_error, g_stats.frame_error, g_stats.unknown_cmd);
}
32.4 本章小结
本章介绍了测试与调试方法:
- 串口调试工具:调试助手、逻辑分析仪、示波器、虚拟串口
- 测试策略:单元测试和集成测试
- 调试技巧:打印调试信息、十六进制转储、统计信息
第33章 常见问题与优化建议
33.1 常见问题
问题1:接收数据错位
症状:接收到的数据帧字段错位,解析结果不正确。
原因:可能是波特率不匹配、字节对齐问题、或帧同步丢失。
解决:
- 检查收发双方的波特率是否一致
- 确保结构体使用了
#pragma pack(1) - 使用状态机解析,避免帧同步丢失
问题2:偶发校验失败
症状:大部分时间通信正常,偶尔出现校验失败。
原因:电磁干扰、信号质量差、波特率误差累积。
解决:
- 降低波特率
- 增加硬件滤波
- 使用CRC代替和校验
- 检查线缆质量和长度
问题3:数据丢失
症状:发送的数据在接收端不完整。
原因:接收缓冲区溢出、中断处理太慢、CPU被其他任务占用。
解决:
- 增大接收缓冲区
- 使用DMA接收
- 提高中断优先级
- 使用环形缓冲区
问题4:帧粘连
症状:两帧数据粘连在一起,无法正确分离。
原因:两帧数据之间没有足够的间隔,状态机没有正确处理帧边界。
解决:
- 确保状态机在帧结束后正确重置
- 在帧之间增加间隔时间
- 使用帧长度字段辅助帧定界
问题5:函数指针调用崩溃
症状:通过函数指针调用处理函数时程序崩溃。
原因:函数指针为NULL、函数签名不匹配、或指针指向了无效地址。
解决:
- 调用前检查函数指针是否为NULL
- 确保函数签名与函数指针类型一致
- 使用静态分析工具检查代码
33.2 优化建议
优化1:使用DMA提高通信效率
c
// 使用DMA接收不定长数据
void uart_start_dma_rx(void)
{
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, RX_BUF_SIZE);
__HAL_DMA_DISABLE_IT(huart1.hdmarx, DMA_IT_HT); // 禁用半传输中断
}
DMA可以在不占用CPU的情况下自动搬运数据,大幅提高通信效率。
优化2:使用CRC查表法加速
如第19章所述,使用查表法可以将CRC计算速度提高8倍。对于大数据量的CRC计算,这是非常显著的优化。
优化3:命令处理表排序
将命令处理表按使用频率排序,最常用的命令放在表的前面,减少平均查找次数。
优化4:使用回调机制
将命令处理改为回调机制,避免同步处理耗时操作:
c
typedef struct {
uint8_t cmd_id;
void (*callback)(uint8_t*, uint16_t);
} AsyncCallback;
// 在主循环中处理命令,而不是在中断中
void process_pending_commands(void)
{
for (int i = 0; i < pending_count; i++) {
pending_commands[i].callback(pending_commands[i].data,
pending_commands[i].len);
}
pending_count = 0;
}
优化5:协议版本兼容
通过配置位中的版本号字段,实现协议的向前兼容:
c
void handle_frame(ProtocolFrameHeader *header)
{
uint8_t version = GET_VERSION(header->config);
switch (version) {
case 0:
handle_v0_frame(header);
break;
case 1:
handle_v1_frame(header); // 新版本可能有额外字段
break;
default:
// 未知版本,使用最新版本处理
handle_v1_frame(header);
break;
}
}
33.3 安全性建议
- 输入验证:对所有接收到的数据进行边界检查,防止缓冲区溢出
- 长度限制:设置最大帧长度,拒绝超长帧
- 频率限制:限制命令处理频率,防止DoS攻击
- 认证机制:在专业版协议中可以添加认证字段
- 加密传输:使用配置位中的加密标志实现数据加密
33.4 本章小结
本章介绍了常见问题和优化建议:
- 常见问题:数据错位、校验失败、数据丢失、帧粘连、函数指针崩溃
- 优化建议:DMA接收、CRC查表、表排序、回调机制、版本兼容
- 安全性建议:输入验证、长度限制、频率限制、认证加密
附录
附录A ASCII码表
| 十六进制 | 字符 | 十六进制 | 字符 | 十六进制 | 字符 |
|---|---|---|---|---|---|
| 0x00 | NUL | 0x30 | 0 | 0x60 | ` |
| 0x01 | SOH | 0x31 | 1 | 0x41 | A |
| 0x02 | STX | 0x32 | 2 | 0x42 | B |
| 0x03 | ETX | 0x33 | 3 | 0x43 | C |
| 0x0A | LF | 0x34 | 4 | 0x44 | D |
| 0x0D | CR | 0x35 | 5 | 0x45 | E |
| 0x11 | XON | 0x36 | 6 | 0x46 | F |
| 0x13 | XOFF | 0x37 | 7 | 0x47 | G |
| 0x1B | ESC | 0x38 | 8 | 0x48 | H |
| 0x20 | SP | 0x39 | 9 | 0x49 | I |
| 0x21 | ! | 0x3A | : | 0x4A | J |
| 0x22 | " | 0x3B | ; | 0x4B | K |
| 0x30 | 0 | 0x3C | < | 0x4C | L |
| 0x41 | A | 0x3D | = | 0x4D | M |
| 0x55 | U | 0x3E | > | 0x4E | N |
| 0x48 | H | 0x3F | ? | 0x4F | O |
| 0x50 | P | 0x51 | Q | 0x52 | R |
| 0x53 | S | 0x54 | T | 0x55 | U |
| 0x56 | V | 0x57 | W | 0x58 | X |
| 0x59 | Y | 0x5A | Z | 0x7E | ~ |
(完整ASCII码表共128个字符,此处仅列出常用部分)
附录B 常用CRC多项式参考
| CRC名称 | 多项式 | 初始值 | 最终异或 | 结果异或 | 典型应用 |
|---|---|---|---|---|---|
| CRC-8 | 0x07 | 0x00 | 0x00 | 否 | 1-Wire, Dallas |
| CRC-8/SMBUS | 0x07 | 0x00 | 0x00 | 否 | SMBus |
| CRC-8/Dallas | 0x31 | 0x00 | 0x00 | 是 | DS18B20 |
| CRC-8/CCITT | 0x8D | 0x00 | 0x00 | 否 | 1-Wire |
| CRC-16/CCITT | 0x1021 | 0xFFFF | 0x0000 | 否 | X.25, HDLC, 蓝牙 |
| CRC-16/Modbus | 0x8005 | 0xFFFF | 0x0000 | 是 | Modbus |
| CRC-16/XMODEM | 0x1021 | 0x0000 | 0x0000 | 否 | XMODEM |
| CRC-16/KERMIT | 0x1021 | 0x0000 | 0x0000 | 是 | Kermit |
| CRC-32 | 0x04C11DB7 | 0xFFFFFFFF | 0xFFFFFFFF | 是 | 以太网, ZIP |
附录C 术语对照表
| 术语 | 英文 | 说明 |
|---|---|---|
| 串口通信 | Serial Communication | 数据逐位串行传输的通信方式 |
| 通用异步收发器 | UART | 实现异步串行通信的硬件模块 |
| 波特率 | Baud Rate | 每秒传输的比特数 |
| 协议帧 | Frame | 通信协议中数据传输的基本单位 |
| 帧头 | Header/Start Flag | 标识帧开始的固定字节 |
| 帧尾 | Tail/End Flag | 标识帧结束的固定字节 |
| 命令ID | Command ID | 标识命令类型的字段 |
| 功能ID | Function ID | 标识功能模块的字段 |
| 有效载荷 | Payload | 帧中携带的实际数据 |
| 校验码 | Checksum/CRC | 用于检测传输错误的字段 |
| 循环冗余校验 | CRC | 基于多项式除法的校验方法 |
| 和校验 | Sum Checksum | 对数据求和的简单校验方法 |
| 源地址 | Source Address | 发送方的地址 |
| 目的地址 | Destination Address | 接收方的地址 |
| 序列号 | Sequence Number | 标识帧序号的字段 |
| 小端模式 | Little Endian | 低字节存低地址 |
| 大端模式 | Big Endian | 低字节存高地址 |
| 查表法 | Table-Driven Method | 用数据表代替条件判断的设计方法 |
| 函数指针 | Function Pointer | 指向函数入口地址的指针 |
| 状态机 | State Machine | 通过状态转换驱动数据处理的方法 |
| 封包 | Pack | 将结构化数据组装成字节序列 |
| 解析 | Parse/Unpack | 从字节序列中提取结构化数据 |
| 字节对齐 | Byte Alignment | 结构体成员在内存中的对齐方式 |
| 配置位 | Config Field | 携带协议级配置信息的字段 |
| 广播地址 | Broadcast Address | 发送给所有设备的地址 |
| 应答 | Acknowledgment (ACK) | 对收到数据的确认回复 |
| 重传 | Retransmission | 超时未收到应答时重新发送 |
| 环形缓冲区 | Ring Buffer | 循环使用的缓冲区数据结构 |
| 直接内存访问 | DMA | 不需CPU参与的内存搬运机制 |
| 空闲中断 | IDLE Interrupt | UART检测到总线空闲时触发的中断 |
| 转义机制 | Escape Mechanism | 防止数据中误匹配帧头帧尾的机制 |
| 表驱动法 | Table-Driven Approach | 同查表法 |
教程结语
恭喜你读完了这篇教程!从串口通信的基础概念,到协议帧的设计,到简化版和专业版两种协议的实现,再到查表法的协议解析,你已经掌握了嵌入式串口通信协议的完整知识体系。
回顾一下本教程的核心知识点:
- 串口通信基础:UART是异步串行通信,需要约定波特率等参数
- 协议设计:帧头+命令ID+数据长度+数据+校验码+帧尾的帧结构
- 帧头选择:0x55和0xAA因为调试定位和自动波特率识别的优势而被广泛使用
- 校验机制:和校验简单快速,CRC校验强大可靠
- 大小端:协议中必须统一字节序,简化版使用小端模式
- 简化版协议:适合学生项目,结构简单易用
- 专业版协议:适合企业产品,支持地址、序列号、CRC双重校验
- 查表法:用数据表代替switch-case,代码简洁、易维护、易扩展
- 函数指针:是查表法的核心,实现数据与逻辑的分离
- 状态机:用于逐字节解析协议帧,保证实时性和鲁棒性
希望这篇教程能帮助你在嵌入式串口通信的学习之路上走得更远。如果你在实践中遇到问题,欢迎反复查阅本教程的相关章节。
参考来源:
本教程基于CSDN博主 Dustinthewine 的系列文章整理扩写,仅供学习交流使用。