4、串口数据收发实战:二进制数据收发、文本数据收发、数据分包与粘包处理
好,咱们直接进入正题。串口数据收发,说白了就是让两个设备通过一根线“聊天”。但怎么聊、聊什么格式、聊着聊着会不会“串词”,这里面的门道可不少。我这些年调过的串口设备,少说也有上百种了,踩过的坑能写满一本笔记本。今天就把这些实战经验掰开揉碎了讲给你听。
4.1 二进制数据收发:最原始的“机器语言”
二进制数据,就是0和1的原始序列。为什么用二进制?因为效率高、解析快。你想想看,嵌入式设备里跑的都是裸机或RTOS,CPU资源金贵得很,解析文本字符串太费劲了。
我在项目中遇到过最典型的场景:给一个电机驱动器发控制指令。它只认二进制协议,比如0x01表示正转,0x02表示反转,0x03停止。就这么简单粗暴。
核心要点:二进制收发时,一定要明确“帧结构”。我习惯这样定义:
- 帧头:固定字节,比如0xAA 0x55,用于同步
- 长度域:1-2字节,表示数据域长度
- 数据域:实际要传输的二进制数据
- 校验域:CRC或累加和,保证数据完整性
- 帧尾:固定字节,比如0x0D 0x0A
来看一个实际的发送代码片段:
// 二进制数据发送示例
uint8_t tx_buffer[10];
tx_buffer[0] = 0xAA; // 帧头1
tx_buffer[1] = 0x55; // 帧头2
tx_buffer[2] = 0x04; // 数据长度
tx_buffer[3] = 0x01; // 命令:正转
tx_buffer[4] = 0x00; // 参数高字节
tx_buffer[5] = 0x64; // 参数低字节(速度100)
tx_buffer[6] = 0x00; // 保留
// 计算累加和校验
uint8_t checksum = 0;
for(int i=0; i<7; i++) checksum += tx_buffer[i];
tx_buffer[7] = checksum;
tx_buffer[8] = 0x0D; // 帧尾
tx_buffer[9] = 0x0A;
// 发送
write(serial_fd, tx_buffer, 10);
我的小技巧:接收二进制数据时,别用read()一次读完。我习惯用状态机逐字节解析,这样能有效处理数据不完整的情况。
4.2 文本数据收发:人类可读的“通用语言”
文本数据,就是ASCII字符串。比如AT指令、JSON、CSV这些。优点是调试方便,拿个串口助手就能看懂。缺点是效率低、解析开销大。
我记得有一次调试一个GPS模块,它输出的就是NMEA 0183格式的文本数据。每条语句以$开头,以\r\n结尾,中间用逗号分隔字段。这种格式看着舒服,但解析起来要小心。
文本收发的关键点:
- 行结束符:常见的有\r\n(Windows)、\n(Linux)、\r(老式Mac)
- 编码问题:中文环境下,GB2312和UTF-8混用会出乱码
- 缓冲区溢出:文本数据可能很长,一定要限制最大长度
// 文本数据接收示例(按行读取)
char line_buffer[256];
int index = 0;
char ch;
while(read(serial_fd, &ch, 1) > 0) {
if(ch == '\n') {
line_buffer[index] = '\0'; // 字符串结束
// 处理这一行数据
process_line(line_buffer);
index = 0; // 重置缓冲区
} else if(ch != '\r') {
// 忽略回车符,只存有效字符
if(index < 255) {
line_buffer[index++] = ch;
} else {
// 缓冲区满了,丢弃这一行
index = 0;
}
}
}
曾经踩过的坑:有一次我接收的文本数据里混了二进制0x00,导致strlen()提前结束。从那以后,我处理文本数据时都会先检查数据里有没有非打印字符。
4.3 数据分包与粘包处理:串口通信的“老大难”
为什么会粘包?说白了就是发送方连续发了多包数据,接收方一次read()读到了好几包的内容。分包呢?就是一包数据被拆成了多次read()才能读完。
这两种情况在实际项目中太常见了。我刚开始做串口开发时,就被粘包问题折磨了好几天。后来总结了一套通用的处理思路:
4.3.1 粘包处理策略
| 策略 | 原理 | 适用场景 |
|---|---|---|
| 固定长度 | 每包数据长度固定 | 传感器数据、状态上报 |
| 长度前缀 | 包头带数据长度 | 通用协议、文件传输 |
| 特殊分隔符 | 用特定字符标记包边界 | 文本协议、AT指令 |
| 超时判断 | 接收间隔超过阈值算一包结束 | 不定长数据、调试场景 |
我个人最推荐的是“长度前缀”法。为什么呢?因为它最可靠。你想想看,固定长度不够灵活,分隔符可能和数据内容冲突,超时判断又不够精确。
4.3.2 实战:一个健壮的接收缓冲区
嗯,这里要重点讲。我设计了一个环形缓冲区+状态机的接收方案,用了好多年,基本没出过问题。
// 接收状态机
typedef enum {
WAIT_HEADER1,
WAIT_HEADER2,
WAIT_LENGTH,
WAIT_DATA,
WAIT_CHECKSUM
} rx_state_t;
rx_state_t state = WAIT_HEADER1;
uint8_t rx_buffer[1024];
uint16_t rx_index = 0;
uint16_t expected_length = 0;
void process_rx_byte(uint8_t byte) {
switch(state) {
case WAIT_HEADER1:
if(byte == 0xAA) state = WAIT_HEADER2;
break;
case WAIT_HEADER2:
if(byte == 0x55) {
state = WAIT_LENGTH;
rx_index = 0;
} else {
state = WAIT_HEADER1; // 重新同步
}
break;
case WAIT_LENGTH:
expected_length = byte;
state = WAIT_DATA;
break;
case WAIT_DATA:
rx_buffer[rx_index++] = byte;
if(rx_index >= expected_length) {
state = WAIT_CHECKSUM;
}
break;
case WAIT_CHECKSUM:
// 校验通过,处理完整数据包
if(verify_checksum(rx_buffer, expected_length, byte)) {
process_packet(rx_buffer, expected_length);
}
state = WAIT_HEADER1; // 准备接收下一包
break;
}
}
关键点:这个状态机的好处是,即使中间丢了一个字节,也能自动重新同步。我曾在一次项目中,串口线被干扰得厉害,数据包几乎一半都是错的,但靠这个状态机硬是扛住了,没丢一条关键指令。
4.3.3 分包处理:别让read()坑了你
分包的本质是:read()返回的数据可能不完整。比如你期望收到10个字节,但read()只返回了3个。怎么办?
我的做法是:永远不要假设一次read()能读完一包数据。正确的姿势是:
- 用非阻塞或超时模式打开串口
- 每次read()后,把数据追加到接收缓冲区
- 在缓冲区里用状态机解析完整的数据包
- 解析完一包后,把已处理的数据从缓冲区移除
// 主循环中的接收处理
uint8_t temp_buf[64];
int len;
while(1) {
len = read(serial_fd, temp_buf, sizeof(temp_buf));
if(len > 0) {
// 把新数据追加到环形缓冲区
ring_buffer_write(&rx_ring, temp_buf, len);
// 尝试从缓冲区解析数据包
while(ring_buffer_available(&rx_ring) > 0) {
uint8_t byte;
ring_buffer_read(&rx_ring, &byte, 1);
process_rx_byte(byte);
}
}
// 其他任务...
}
我的经验:环形缓冲区的大小至少要是最大数据包长度的2倍。我曾经图省事设了个256字节的缓冲区,结果对方一次发了300字节的数据,直接溢出丢包。从那以后,我都是按最大包长的4倍来设缓冲区。
4.4 实战中的避坑指南
最后,分享几个我这些年总结出来的血泪教训:
- 波特率要匹配:别以为9600和9600就一定一样。有些芯片的晶振不准,实际波特率会有偏差。我建议用逻辑分析仪实测一下。
- 流控别乱开:硬件流控(RTS/CTS)在短距离通信时反而容易出问题。我一般默认关掉,除非线缆超过5米。
- 校验不能省:哪怕只是简单的累加和,也能挡住90%的传输错误。别信“串口不会出错”这种鬼话。
- 超时要设对:接收超时设太短容易误判分包,设太长又影响响应速度。我一般设10ms,够用。
好了,这一章的内容就到这里。二进制和文本数据收发是基本功,粘包分包处理是进阶技能。把这些吃透了,串口通信这块你就基本毕业了。下一章咱们聊聊更高级的话题——串口DMA传输和中断优化。