一、为什么需要自定义协议
TCP 是流式传输 ,没有"消息边界"的概念。发送方发了3个包,接收方可能一次收到1个,也可能一次收到3个拼在一起,这就是经典的粘包问题。
发送方发送:
[帧1: 7字节] [帧2: 7字节] [帧3: 8字节]
接收方可能收到:
情况A:[帧1+帧2+帧3 共22字节] ← 粘包
情况B:[帧1前3字节] + [帧1后4字节+帧2] ← 半包
情况C:正好每次一帧 ← 理想情况,实际很少
解决方案:设计自定义协议,用帧头 + 长度 + 帧尾标记每帧的边界,接收方按协议解析,无论怎么粘包都能正确还原。
二、自定义协议格式
2.1 帧结构
┌────────┬────────┬──────────────────────┬────────┬────────┐
│ 帧头 │ 长度 │ 数据(n字节) │ 校验 │ 帧尾 │
│ 0xAA │ 1字节 │ 标识(1) + 值(n-1) │ 1字节 │ 0xBB │
└────────┴────────┴──────────────────────┴────────┴────────┘
byte0 byte1 byte2 ~ byte2+n-1 byte2+n byte2+n+1
| 字段 | 大小 | 说明 |
|---|---|---|
| 帧头 | 1字节 | 固定值 0xAA,标记一帧的开始 |
| 长度 | 1字节 | 仅包含数据字段的长度(不含帧头、长度本身、校验、帧尾) |
| 数据 | n字节 | 第1字节为标识,后续为值(温度/湿度为2字节 short) |
| 校验 | 1字节 | 对数据字段所有字节求和,取低8位 |
| 帧尾 | 1字节 | 固定值 0xBB,标记一帧的结束 |
2.2 数据标识定义
| 标识值 | 含义 | 数据字段内容 | 数据字段总长度 |
|---|---|---|---|
0x01 |
温度帧 | 标识(1) + temp值(2) | 3字节 |
0x02 |
湿度帧 | 标识(1) + humi值(2) | 3字节 |
0x03 |
温湿度组合帧 | 标识(1) + temp(2) + humi(2) | 5字节 |
2.3 数值编码规则
⚠️ 温度和湿度的实际值 = 原始数据 ÷ 10
例如:温度 22.2℃ 存储为
222(即0x00DE),读取时222 / 10 = 22(整数部分)
三、三种帧的完整字节示例
温度帧(标识 0x01)
0xAA 0x03 0x01 0x16 0x00 0xXX 0xBB
│ │ │ │ │ │
帧头 长度=3 标识 低字节 高字节 校验 帧尾
数据字段 = [0x01, 0x16, 0x00](长度=3)
值 = 0x0016 = 22 → 22 / 10 = 2.2℃(实际表示 22,不是2.2,/10取整=2)
图片中示例:0xAA 0x02 0x01 22 22 0xBB
→ 长度字段=0x02(数据2字节:标识+值各1字节,此处为简化示意)
湿度帧(标识 0x02)
0xAA 0x03 0x02 0x37 0x00 0xXX 0xBB
│ │ │ │ │ │
帧头 长度=3 标识 低字节 高字节 校验 帧尾
图片中示例:0xAA 0x02 0x02 55 55 0xBB
→ 湿度帧
温湿度组合帧(标识 0x03)
0xAA 0x05 0x03 temp_L temp_H humi_L humi_H 0xXX 0xBB
│ │ │ ├──────────┤ ├──────────┤ │ │
帧头 长度=5 标识 temp(2字节) humi(2字节) 校验 帧尾
图片中示例:0xAA 0x03 0x03 22 55 77 0xBB
→ temp+humi 组合帧
帧尾位置计算公式
帧尾地址 = buf[i + len + 3]
其中:
i = 帧头所在下标
len = buf[i+1](长度字段值)
+1 = 帧头本身占1字节
+1 = 长度字段本身占1字节
+len = 数据字段 len 字节
+1 = 校验字段占1字节
合计偏移 = 1 + 1 + len + 1 - 1 = len + 2(从 i 开始,偏移 len+2 即为帧尾)
代码中:buf[buf[i+1] + 3](i=0时)即 buf[len+3]
对应图片中的表达式:
c
if( *p == 0xAA && *(p + (*(p+1) + 3)) == 0xBB)
// 帧头判断 帧尾判断(偏移量 = len + 3)
四、校验和计算
c
// 计算校验和:对数据字段中所有值字节求和,取低8位
unsigned char checksum(const unsigned char *value_bytes, int len)
{
unsigned char sum = 0;
for (int i = 0; i < len; i++)
{
sum += value_bytes[i];
}
return sum; //取低8位
}
关键点:
- 校验范围是数据字段(从标识字节开始,含标识,不含帧头/长度/校验/帧尾)
unsigned char自然溢出即取低8位,无需额外操作- 发送方打包时计算并填入,接收方解包时重新计算并比对
温度帧校验示例:
数据字段 = [0x01, 0x16, 0x00]
校验 = (0x01 + 0x16 + 0x00) & 0xFF = 0x17
五、客户端(cli.c)代码详解
5.1 数据结构
c
typedef struct data
{
float temp;
float humi;
}data_t;
typedef struct package
{
unsigned char frame_head; // 帧头 0xAA
unsigned char len; // 数据长度
unsigned short data; // 数据值(2字节,温度或湿度)
unsigned char sum; // 校验
unsigned char frame_tail; // 帧尾 0xBB
}package_t;
typedef struct package_both
{
unsigned char frame_head; // 帧头 0xAA
unsigned char len; // 数据长度
unsigned short temp; // 温度值
unsigned short humi; // 湿度值
unsigned char sum; // 校验
unsigned char frame_tail; // 帧尾 0xBB
}package_both_t;
⚠️ 注意 :直接用结构体打包发送存在字节对齐风险,实际项目中应加
__attribute__((packed))禁止填充,或手动逐字节填充unsigned char数组(send_temp函数的做法更安全)。
5.2 send_temp 函数(手动逐字节打包)
c
void send_temp(int sockfd, float temp)
{
unsigned short value = (unsigned short)(temp * 10); // 四舍五入取整
unsigned char frame[7];
frame[0] = 0xAA; // 帧头
frame[1] = 3; // 数据长度(标识1 + 值2 = 3字节)
frame[2] = 0x01; // 标识:温度
frame[3] = value & 0xff; // 温度值低字节(小端序)
frame[4] = value >> 8; // 温度值高字节
frame[5] = calc_checksum(&frame[2], 3); // 校验(从标识字节开始,共3字节)
frame[6] = 0xBB; // 帧尾
send(sockfd, frame, sizeof(frame), 0);
printf("发送温度帧: %.1f℃\n", temp);
}
字节布局(frame 数组):
frame[0] = 0xAA 帧头
frame[1] = 0x03 长度(数据3字节)
frame[2] = 0x01 标识(温度)
frame[3] = value低字节 温度值小端低字节
frame[4] = value高字节 温度值小端高字节
frame[5] = checksum 校验(对frame[2..4]求和取低8位)
frame[6] = 0xBB 帧尾
为什么校验从 frame[2] 开始?
因为校验范围是数据字段,数据字段从 frame[2](标识字节)开始,长度为 frame[1](=3)。
5.3 send_data 函数(结构体打包,仅实现了 0x01)
c
void send_data(int sockfd, data_t data, int flag)
{
package_t pack_data;
switch(flag)
{
case 0x01:
pack_data.frame_head = 0xAA;
pack_data.frame_tail = 0xBB;
pack_data.data = data.temp * 10;
//
send(sockfd, &pack_data, sizeof(pack_data), 0);
break;
case 0x02:
break;
case 0x03:
break;
default:
break;
}
}
注意:
case 0x02和case 0x03为空,为待实现内容(湿度帧和组合帧)。
5.4 get_temp 模拟函数
c
float get_temp(void)
{
return rand() % 1000 * 10.0;
}
5.5 main 函数发送流程
c
while (1)
{
send_temp(sockfd, temp_val); // 发送温度帧
usleep(30 * 1000); // 间隔 30ms
// send_humi 等已注释掉,待实现
usleep(50 * 1000);
usleep(50 * 1000);
usleep(30 * 1000);
usleep(30 * 1000);
temp_val += get_temp();
humi_val += 0.2;
if (temp_val > 25.0) temp_val = 20.0;
if (humi_val > 70.0) humi_val = 50.0;
}
图片中描述的完整发送序列(待实现):
temp(3s) → humi(5s) → temp → temp → humi → temp → temp+humi 组合帧
六、服务器端(ser.c)代码详解
6.1 数据结构
c
typedef struct
{
short temp;
short humi;
}data_t;
typedef union
{
short temp;
short humi;
data_t data;
}package_t;
package_t deal_data;
为什么用 union?
union 中所有成员共用同一块内存:
- 收到 0x01 温度帧 → 用 deal_data.temp 读取2字节
- 收到 0x02 湿度帧 → 用 deal_data.humi 读取2字节
- 收到 0x03 组合帧 → 用 deal_data.data.temp + deal_data.data.humi 读取4字节
节省内存,不需要对每种帧单独定义变量。
6.2 parse_package 函数
c
void parse_package(unsigned char * data, int len)
{
printf("**********\t");
for(int j = 0; j < len; j++)
printf("0x%02x\t", data[j]);
printf("**********\n");
memcpy(&deal_data, &data[1], len - 1); // 跳过标识字节,拷贝值字节
switch(data[0]) // data[0] 是标识字节
{
case 0x01: // 温度帧
printf("temp = %d\n", deal_data.temp / 10);
break;
case 0x02: // 湿度帧
printf("humi = %d\n", deal_data.humi / 10);
break;
case 0x03: // 组合帧
printf("temp = %d humi = %d\n",
deal_data.data.temp / 10,
deal_data.data.humi / 10);
break;
default:
break;
}
}
parse_package 接收的参数说明:
调用方式:parse_package(&buf[i + 2], buf[i + 1])
&buf[i + 2] = 数据字段起始地址(跳过帧头和长度字段)
buf[i + 1] = 数据字段长度(即长度字段的值)
函数内部:
data[0] = 标识字节(0x01 / 0x02 / 0x03)
&data[1] = 值字节起始
len - 1 = 值字节的字节数
memcpy 目标 = deal_data(union,按需用不同成员访问)
6.3 核心解析循环(拆包逻辑,重点!)
c
unsigned char buf[1024];
int len_data = 0;
int i = 0;
while(1)
{
usleep((rand() % 200) * 1000);
i = 0;
int len_r = recv(conn_fd, &buf[len_data], sizeof(buf) - len_data, 0);
if(0 == len_r)
break;
len_data += len_r;
// 打印当前缓冲区所有字节(调试用)
for(int j = 0; j < len_data; j++)
printf("0x%02x\t", buf[j]);
printf("\n");
while(len_data - i >= 7) // 最短帧长7字节才尝试解析
{
if(!(buf[i] == 0xAA && buf[buf[i+1] + 3] == 0xBB)) // 帧头帧尾验证
i++;
else
{
if(!(buf[i + buf[i + 1] + 2] == checksum(&buf[i + 2], buf[i + 1]))) // 校验和验证
i++;
else
{
parse_package(&buf[i + 2], buf[i + 1]); // 解析数据字段
int len_rm = i + buf[i + 1] + 4; // 计算本帧总长度
memcpy(buf, &buf[i + buf[i + 1] + 4], len_data - len_rm); // 移除已解析的帧
len_data -= len_rm;
i = 0;
}
}
if(i > 8) // 连续8字节找不到合法帧头,丢弃脏数据
{
int len_rm = i + 1;
memcpy(buf, &buf[i + 1], len_data - len_rm);
len_data -= len_rm;
i = 0;
}
}
}
七、拆包算法完整流程图
recv 接收数据,追加到 buf 尾部
↓
缓冲区剩余字节 >= 7 ?
↓ 是
buf[i] == 0xAA ? 且 buf[i + len + 3] == 0xBB ?
↓ 否 ↓ 是
i++ 校验和匹配?
↓ 否 ↓ 是
i++ parse_package 解析
↓
从 buf 中删除该帧
len_data -= 帧总长
i = 0,继续循环
↓
i > 8 ?
↓ 是
丢弃 buf[0..i],i=0
关键下标计算(以 i 为帧头位置)
buf[i] 帧头 0xAA
buf[i+1] 长度字段(设为 L)
buf[i+2] 数据字段起始(标识字节)
buf[i+2 .. i+2+L-1] 数据字段(共L字节)
buf[i+2+L] 校验字段
buf[i+2+L+1] 帧尾 0xBB = buf[i + L + 3]
帧总长度 = 1(帧头) + 1(长度) + L(数据) + 1(校验) + 1(帧尾) = L + 4
删除后:len_data -= (i + L + 4)
帧尾位置验证代码:
c
buf[buf[i+1] + 3] // 当 i=0 时 等价于 buf[L+3]
展开:buf[i + buf[i+1] + 3],即从帧头 i 出发,偏移 L+3,正好是帧尾位置。
校验字段位置验证代码:
c
buf[i + buf[i + 1] + 2] // 偏移 L+2,正好是校验字节
checksum(&buf[i + 2], buf[i + 1]) // 从数据字段起始,算 L 个字节
八、服务器解析示例(逐字节追踪)
以接收温度帧为例,假设 buf 中数据为:
buf = [0xAA, 0x03, 0x01, 0xDE, 0x00, 0xDF, 0xBB]
下标 [0] [1] [2] [3] [4] [5] [6]
解析步骤:
1. len_data = 7,i = 0
2. buf[0] == 0xAA ✓
3. buf[i+1] = 0x03(L=3)
4. 帧尾检查:buf[0 + 3 + 3] = buf[6] = 0xBB ✓
5. 校验检查:
checksum(&buf[2], 3) = buf[2]+buf[3]+buf[4] = 0x01+0xDE+0x00 = 0xDF
buf[0 + 3 + 2] = buf[5] = 0xDF ✓
6. 调用 parse_package(&buf[2], 3)
data[0] = 0x01 → case 0x01
memcpy(&deal_data, &data[1], 2)
→ deal_data.temp = 0x00DE = 222
→ printf("temp = %d\n", 222 / 10) → 输出 temp = 22
7. len_rm = 0 + 3 + 4 = 7
8. 删除已解析帧:len_data = 7 - 7 = 0
九、网络通信基础部分
9.1 服务器 socket 初始化流程
c
// 1. 创建 TCP socket
int listfd = socket(AF_INET, SOCK_STREAM, 0);
// 2. 绑定地址和端口
struct sockaddr_in ser;
bzero(&ser, sizeof(ser));
ser.sin_family = AF_INET;
ser.sin_port = htons(50000); // 监听端口 50000
ser.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡
bind(listfd, (SA)&ser, sizeof(ser));
// 3. 监听(最大等待队列3)
listen(listfd, 3);
// 4. 接受连接
int conn_fd = accept(listfd, (SA)&cli, &len);
9.2 客户端 socket 连接流程
c
// 1. 创建 TCP socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 2. 设置服务器地址
struct sockaddr_in ser;
bzero(&ser, sizeof(ser));
ser.sin_family = AF_INET;
ser.sin_port = htons(50000);
ser.sin_addr.s_addr = inet_addr("127.0.0.1"); // 连接本机(测试用)
// 3. 连接服务器
connect(sockfd, (SA)&ser, sizeof(ser));
9.3 typedef struct sockaddr *(SA) 用法说明
c
typedef struct sockaddr *(SA);
// 将 (SA) 定义为强制类型转换的简写
// 等价于 (struct sockaddr *)
// bind / connect / accept 第2个参数要求 struct sockaddr *
// 而实际用的是 struct sockaddr_in *,需要强转
bind(listfd, (SA)&ser, sizeof(ser));
// 等价于
bind(listfd, (struct sockaddr *)&ser, sizeof(ser));
十、协议设计总结与待完善部分
10.1 已实现功能
| 功能 | 客户端 | 服务器 |
|---|---|---|
| TCP 连接建立 | ✅ | ✅ |
| 温度帧发送(0x01) | ✅ send_temp() | ✅ parse_package() |
| 湿度帧发送(0x02) | ❌ 已注释 | ✅ 解析框架已有 |
| 组合帧发送(0x03) | ❌ 已注释 | ✅ 解析框架已有 |
| 粘包处理 | --- | ✅ 滑动窗口解析 |
| 脏数据丢弃 | --- | ✅ i>8 时丢弃 |
| 校验和验证 | ✅ | ✅ |
10.2 待完善部分(客户端)
c
// 1. 实现 send_humi(湿度帧,标识 0x02)
void send_humi(int sockfd, float humi)
{
// 参考 send_temp,把 frame[2] 改为 0x02,其余相同结构
}
// 2. 实现 send_both(组合帧,标识 0x03)
// frame 需要 8 字节:帧头+长度+标识+temp低+temp高+humi低+humi高+校验+帧尾 = 9字节
// frame[1] = 5(数据长度:标识1+temp2+humi2)
// 3. send_data 函数补全 case 0x02 和 case 0x03
10.3 协议关键设计点回顾
① 帧头 0xAA + 帧尾 0xBB:快速定位帧边界
② 长度字段:精确确定数据区域,解决变长帧问题
③ 校验和:检测数据传输错误
④ 帧尾二次确认:长度字段被污染时的保护
⑤ 数值 ×10 存储:用整型传输浮点,避免浮点精度问题
⑥ 小端序:值字节低字节在前(frame[3]=低字节,frame[4]=高字节)
十一、关键概念速查
| 概念 | 说明 |
|---|---|
| 粘包 | TCP 流式传输,多帧数据可能合并接收 |
| 帧头/帧尾 | 自定义协议中标记帧边界的固定字节 |
| 长度字段 | 指示数据区长度,服务器据此确定帧结束位置 |
| 校验和 | 数据字段所有字节之和取低8位,用于验证数据完整性 |
memcpy 移除帧 |
将缓冲区中已解析部分删除,剩余数据前移 |
htons |
主机字节序转网络字节序(大端),用于端口号 |
inet_addr |
将点分十进制IP字符串转换为网络字节序整数 |
INADDR_ANY |
绑定所有网卡接口 |
usleep |
微秒级睡眠,1000微秒=1毫秒 |
| 小端序 | 低字节存低地址(x86/ARM默认),与网络大端序相反 |