暑假在家闲来无事,翻出一个闲置许久的 HC-05 蓝牙模块,正好拿来玩玩。稍微查了一下,它说白了就是一个无线串口桥接工具,用手机上的蓝牙调试软件连接后,实际通信效果和有线串口没啥区别,没什么需要额外学的。
想起之前学习上挖过的坑,计划学习数据解包/封包还没有填上,正好借机会学习一下。
我使用的是立创天空星STM32F407VGT6,将 HC-05 的 TXD , RXD 连接到单片机的 USART3 对应引脚,其逻辑电平3.3V,使用5V电压驱动。串口方案使用了DMA + 空闲中断 + 环形缓存区。
当EN/KEY引脚处于低电平或悬空状态时,HC-05处于默认模式(数据模式),手机使用蓝牙调试软件连接 HC-05 发送数据, HC-05 自动会转发数据到单片机。
当EN/KEY引脚被拉至高电平时,HC-05模块将进入AT指令模式。此模式允许用户通过串口向模块发送特定的“AT指令”,以配置模块的各种参数。
一个设计良好的数据包通常包含以下部分
组成部分 | 别称/俗称 | 功能与作用 |
---|---|---|
1. 包头 (Header) | 同步头, 帧头 | 一串固定的、独特的字节序列,用于标识一个数据包的开始。 |
2. 地址/ID (Address) | 源/目标地址 | 在多设备通信总线(如RS485)上,用来指定该数据包是发给谁的,或者来自谁。在简单的点对点通信中可以省略。 |
3. 命令/功能码 (Command) | 功能码, CMD | 数据包的核心,告诉接收方“要做什么事”,例如“设置LED”、“读取温度”、“返回心跳”。 |
4. 数据长度 (Length) | LEN | 明确指出有效载荷 (Payload) 的长度。这是实现变长数据包的关键,接收方根据此长度来确定需要读取多少字节的数据。 |
5. 有效载荷 (Payload) | 数据, Data | 数据包中真正承载变化信息的部分。例如,要设置LED的状态值,要发送的传感器读数等。如果某个命令不需要额外数据(如“查询”),则此部分可以为空。 |
6. 校验码 (Checksum) | CRC, 校验和 | 通过特定算法(累加和、CRC等)对数据包的关键内容进行计算得出的一个“指纹”。接收方用同样的算法计算一遍,如果结果与接收到的校验码一致,就说明数据很可能没问题。 |
7. 包尾 (Footer) | 结束符, Terminator | 一个固定的字节序列,用于标识数据包的结束。在有“数据长度”字段的协议中,包尾不是必需的,但有时会用作额外的校验层。在一些文本协议中,换行符\r\n 就扮演了包尾的角色。 |
为了练习,设计了一个简单情景,通过手机与蓝牙模块通信,控制开发版上6个LED。
自定义的数据包格式如下:
字段 (Field) | 字节数 (Bytes) | 值 (Value) | 描述 (Description) |
---|---|---|---|
包头 (Header) | 2 | 0xAA 0x55 (固定) | 标识一个数据帧的开始,接收方以此作为数据同步的标志。 |
命令码 (Command | 1 | 0x00 ~ 0xFF | 定义了该数据包的功能和意图。 |
数据长度 (Length) | 1 | 0x00 ~ 0xFF | 指示紧随其后的数据 (Data)字段的字节长度。如果数据字段为空,则此值为0。 |
数据 (Data) | N (由Length决定) | 任意值 | 数据包的有效载荷,即真正需要传输的信息。 |
校验和 (Checksum) | 1 | 0x00 ~ 0xFF | 从命令码到数据字段最后一个字节的所有字节的累加和,结果截断为最低8位。 |
A. 设置LED状态 (CMD: 0x01
)
1
代表点亮,0
代表熄灭。0b00000101
= 0x05
)。
AA 55 01 01 05 07
0x01 + 0x01 + 0x05 = 0x07
B. 查询LED状态 (CMD: 0x02
)
AA 55 02 00 02
0x02 + 0x00 = 0x02
C. 响应LED状态 (CMD: 0x82
)
0x02
查询命令的回复,上报当前LED的状态。
请求码 + 0x80
),这是一种常见的协议设计模式。0x01
命令的数据格式完全相同,表示当前LED的实际状态。AA 55 82 01 05 88
0x82 + 0x01 + 0x05 = 0x88
根据上面的设计,确定我们的数据包格式如下
| 包头1 (0xAA) | 包头2 (0x55) | 命令 (1字节) | 长度 (1字节) | 数据 (N字节) | 校验和 (1字节) |
按照直觉,我可能会写出这样的“面条代码”
这种代码的执行流程是线性的、过程化的,根据一系列条件判断来决定下一步操作,其坏处显而易见:层层嵌套,逻辑混乱,非常容易出错,而且极难扩展。
显然,这种“一条路走到黑”的面向过程写法,在处理带有上下文逻辑的流式数据时,已经力不从心。
那么,我们来分析一下这种混乱的根源是什么?
根本原因在于,我们的解析逻辑不仅取决于当前读到的字节,更依赖于一个隐藏的“上下文”,也就是“目前读到了数据包的哪个部分”。
在上面的代码里,试图用一个i++
和代码的嵌套层次来被人为地“记住”这个上下文,但效果显然不理想。
既然解析过程存在不同的“上下文”,为何不换一种思维模式呢?我们可以把这些上下文明确地定义出来,变成一个个清晰的“状态”。
比如:
等待包头1的状态
等待包头2的状态
等待命令字的状态
如果程序能在这些明确的“状态”之间进行切换,代码结构就会清晰许多了。
这正是接下来要介绍的核心思想——有限状态机(Finite State Machine, FSM)。
状态机是一种强大的设计模式,简单来说,状态机思想的核心,就是把一个复杂的逻辑过程,拆分成有限个、互不重叠的“状态”,并明确定义在什么条件下(事件),从一个状态切换到另一个状态。通过它,我们可以将上面那团乱麻似的if-else
,重构为结构清晰、易于维护的代码。它天然地适合用来处理像数据包解析这样,行为依赖于历史步骤的场景。
用一个枚举类型 enum
定义所有可能的状态。初始状态为 STATE_HEADER1
等待包头1 (0xAA)。
定义协议命令常量。
状态机核心在于 parse_byte(uint8_t byte)
函数。它的工作模式是逐字节驱动的
串口功能已将数据存入环形缓存区,并开始逐字节处理数据。
它每次只处理一个字节,然后根据 current_state
(当前状态)来决定下一步做什么。
switch-case
结构是实现状态机的绝佳方式,每个 case
就是一个独立的状态处理逻辑。
初始状态 (STATE_WAIT_HEADER_1
):
0xAA
。0xAA
,它就完成了第一个任务,并将状态切换到 STATE_WAIT_HEADER_2
。等待第二个包头 (STATE_WAIT_HEADER_2
):
0x55
。0x55
,说明包头匹配成功,状态切换到 STATE_WAIT_CMD
,准备接收命令。0x55
,说明这是一个错误的包(可能只是数据中恰好包含了0xAA
),会立刻重置状态机回到 STATE_WAIT_HEADER_1
。接收命令和长度 (STATE_WAIT_CMD
, STATE_WAIT_LEN
):
packet_cmd
, packet_len
),然后无条件地切换到下一个状态。packet_len
为 0,会直接跳过数据接收阶段,进入校验和状态 STATE_WAIT_CHECKSUM
。接收数据 (STATE_WAIT_DATA
):
packet_len
个字节的数据。data_index
变量在这里起到了计数器的作用。每接收一个字节,data_index
就加一。data_index
等于 packet_len
时,表示数据部分接收完毕,状态切换到 STATE_WAIT_CHECKSUM
。校验和验证 (STATE_WAIT_CHECKSUM
):
packet_cmd
、packet_len
和 packet_buffer
中的数据,重新计算一遍校验和。checksum
与接收到的最后一个字节 byte
进行比较。handle_packet()
函数处理这个完整、正确的数据包。STATE_WAIT_HEADER_1
,等待下一个数据包的到来。当状态机成功接收并校验完一个完整的数据包后(在 STATE_CHECKSUM
状态完成),它会调用 handle_packet()
函数来执行真正的业务逻辑。
对于更复杂的、安全性要求更高的场景,可以作以下改进:
增加超时机制
如果只接受到半个包(例如,只发了包头和命令),状态机会永远停在中间状态(如 STATE_WAIT_LEN
),无法自动复位。
增加一个超时定时器。在进入 STATE_WAIT_HEADER_2
时记录一下当前系统时间(例如 HAL_GetTick()
)。在 BT_Task
的循环中,检查当前时间与记录时间的差值。如果超过了一个预设的阈值,就强制将 parser_state
复位到 STATE_WAIT_HEADER_1
。
数据长度校验
如果错误地发送了一串超长的数据,超出缓存区发生溢出,导致数据丢失,程序出错。
在 STATE_WAIT_LEN
状态接收到 packet_len
后,立刻进行检查if (packet_len > BT_PROCESS_BUFFER_SIZE)
,若长度错误,重置状态机。
有机会填上了“数据包解包/封包”这个之前一直想学却没深入的坑。
这次学习让我豁然开朗。“面条式” if-else
嵌套写法,在处理流式数据时会变得逻辑混乱、难以维护。
状态机的实现不仅让代码变得易于理解和扩展,并且很自然地实现了解析逻辑与业务逻辑的解耦。
// 伪代码
void process_data(uint8_t* data, int len) {
int i = 0;
if (data[i] == 0xAA) {
i++;
if (data[i] == 0x55) {
i++;
uint8_t cmd = data[i];
i++;
uint8_t data_len = data[i];
i++;
// ...一堆嵌套的 if-else...
}
}
}
typedef enum {
STATE_HEADER1, // 等待包头1 (0xAA)
STATE_HEADER2, // 等待包头2 (0x55)
STATE_CMD, // 等待命令字
STATE_LEN, // 等待数据长度
STATE_DATA, // 接收数据
STATE_CHECKSUM // 等待校验和
} ParserState;
static ParserState current_state = STATE_HEADER1; // 初始状态
#define PACKET_HEADER_1 0xAA
#define PACKET_HEADER_2 0x55
#define CMD_SET_LED 0x01
#define CMD_QUERY_LED 0x02
#define CMD_RESPONSE_LED 0x82
/**
* @brief 蓝牙数据处理任务,调用状态机解析器
*/
void BT_Task()
{
uint8_t temp_buffer[BT_PROCESS_BUFFER_SIZE]; // 用于临时存储从环形缓冲区读取的数据
rt_size_t read_len;
// 从环形缓冲区中读取数据
read_len = rt_ringbuffer_get(&uart3_rx_ringbuffer, temp_buffer, BT_PROCESS_BUFFER_SIZE);
if (read_len > 0)
{
// 逐个字节地送入解析器
for (rt_size_t i = 0; i < read_len; i++)
{
parse_byte(temp_buffer[i]);
}
}
}
void parse_byte(uint8_t byte)
{
switch (current_state)
{
// 状态1: 等待 0xAA
case STATE_HEADER1:
if (byte == 0xAA) {
current_state = STATE_HEADER2; // 收到,进入下一状态
}
break;
// 状态2: 等待 0x55
case STATE_HEADER2:
if (byte == 0x55) {
current_state = STATE_CMD; // 收到,进入下一状态
} else {
current_state = STATE_HEADER1; // 错误,重置回初始状态
}
break;
// 状态3: 接收命令
case STATE_CMD:
packet.cmd = byte;
current_state = STATE_LEN;
break;
// 状态4: 接收长度
case STATE_WAIT_LEN:
packet_len = byte;
if (packet_len > 0)
{
data_index = 0;
parser_state = STATE_WAIT_DATA;
}
else // 如果数据长度为0
{
parser_state = STATE_WAIT_CHECKSUM;
}
break;
// 状态5: 接收数据
case STATE_WAIT_DATA:
packet_buffer[data_index++] = byte;
if (data_index >= packet_len)
{
parser_state = STATE_WAIT_CHECKSUM;
}
break;
// 状态6: 校验和验证
case STATE_WAIT_CHECKSUM:
{
uint8_t checksum = 0;
checksum += packet_cmd;
checksum += packet_len;
for (int i = 0; i < packet_len; i++)
{
checksum += packet_buffer[i];
}
if (checksum == byte) // 校验成功
{
// 一个完整的数据包接收并校验成功!
handle_packet();
}
else
{
// 校验失败,可以在此打印错误信息
my_printf(&huart1, "Checksum error!\n");
}
// 无论成功与否,都重置状态机,准备接收下一个包
parser_state = STATE_WAIT_HEADER_1;
break;
}
}
void handle_packet(void)
{
switch (packet_cmd)
{
case CMD_SET_LED:
{
uint8_t led_status_data = packet_buffer[0]; // 数据在packet_buffer的第一个字节
set_led_status(led_status_data); // 调用LED控制函数
// 可以在这里回显调试信息
my_printf(&huart1, "CMD_SET_LED received, status: 0x%02X\n", led_status_data);
break;
}
case CMD_QUERY_LED:
{
uint8_t current_led_status = get_led_status(); // 获取当前LED状态
send_response_packet(CMD_RESPONSE_LED, ¤t_led_status, 1); // 将状态打包回复
// 调试信息
my_printf(&huart1, "CMD_QUERY_LED received, responding with status: 0x%02X\n",current_led_status);
break;
}
default:
my_printf(&huart1, "Unknown CMD: 0x%02X\n", packet_cmd);
break;
}
}