ARM-驱动-10自定义通信协议

一、为什么需要自定义协议

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 0x02case 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默认),与网络大端序相反
相关推荐
A小辣椒8 小时前
TShark:Wireshark CLI 功能
linux
A小辣椒12 小时前
TShark:基础知识
linux
AlfredZhao14 小时前
OCI 明明分配了 200G 系统盘,为什么 df 只看到 30G?
linux·oci
AlfredZhao1 天前
vi 删除指定范围的行,不用再反复按 dd
linux·vi
用户9718356334661 天前
银河麒麟 KY10 申威(SW64) 安装 nginx-1.16.1-2.p01.ky10.sw_64.rpm 详细步骤
linux
猪脚踏浪2 天前
linux 拷贝文件或目录到指定的位置
linux
摇滚侠2 天前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
bush42 天前
嵌入式linux学习记录十四、术语
linux·嵌入式
载数而行5202 天前
Linux 11 动态监控指令top
linux
不会C语言的男孩2 天前
Linux 系统编程 · 第 8 章:进程基础
linux·c语言