大家好,我是杂烩君。
在嵌入式系统开发中,自定义通信协议是板间通信的核心基础设施。本文介绍如何设计一个高度实用且具备良好扩展性的自定义协议。
1. 简易自定义协议设计
1.1 协议设计原则
字节序一致性:跨平台通信必须明确字节序(本实现采用小端序)
固定宽度类型:使用uint8_t、uint16_t等固定宽度类型,确保跨平台一致性
静态内存分配:嵌入式环境避免使用动态内存,防止内存碎片
流式解析支持:实际通信中数据是流式到达的,需要状态机处理粘包/断包
完善的错误处理:统一的错误码体系,便于问题定位
1.2 ITLV字段定义
| 字段 | 含义 | 典型长度 | 说明 |
|---|---|---|---|
| I | ID/Index | 1~2 字节 | 数据标识符,区分不同类型的数据 |
| T | Type | 1 字节 | 数据类型(uint8、int32、string等) |
| L | Length | 1~4 字节 | Value字段的长度 |
| V | Value | N 字节 | 实际数据负载(Payload) |
其中,I、T、L是固定长度字段,在制定协议前需要评估:项目数据种类有多少(决定I的位宽)、单条数据最大长度是多少(决定L的位宽)。预留扩展空间可保证协议的通用性。一般I设置为1~2字节,T设置为1字节,L设置为1~4字节。
只用ITLV四个字段够吗? 这取决于应用场景:
场景一:物联网端云通信。基于MQTT/TCP的通信中,只用ITLV四个字段是可行的。因为TCP协议本身提供校验和重传机制,保证了传输可靠性;平台SDK在传输用户数据时会自动添加消息边界标识。开发者只需关注ITLV这一层即可。
场景二:嵌入式板间通信。串口、CAN等通信方式没有TCP的可靠性保障,电磁干扰可能导致数据出错。此时必须增加:
包头(Header):用于帧同步和边界识别
校验字段(CRC):用于检测数据错误
根据实际需求还可扩展:分包传输需要包序号、多板通信需要目标地址等。
1.3 协议帧格式
我们一起设计一个自定义协议,用于嵌入式板间通信。帧格式:
| 字段 | 长度 | 说明 |
|---|---|---|
| Head | 2 字节 | 固定为 0x55, 0xAA |
| ID | 1 字节 | 协议标识符 |
| Type | 1 字节 | 数据类型 |
| Length | 1 字节 | Payload长度(最大255字节) |
| Value/Payload | N 字节 | 实际数据 |
| CRC16 | 2 字节 | CRC16-X25校验(小端序) |
1.4 流式解析与一次性解析
根据数据来源的不同,协议库提供两种解析方式:
| 对比维度 | 一次性解析 | 流式解析 |
|---|---|---|
| 输入数据 | 完整帧 | 逐字节 |
| 状态管理 | 无状态 | 状态机驱动 |
| 缓冲区 | 依赖调用者提供 | 内部缓冲 |
| 粘包/断包 | 不支持 | 自动处理 |
| 适用场景 | UDP、文件读取 | 串口、TCP流 |
一次性解析:已经拿到完整的数据帧时使用。比如从文件中读取的协议数据。优点是调用简单、无状态,缺点是必须确保输入数据完整。
流式解析:当数据是"一点一点"到达时使用。比如串口中断每次只收到1个字节。状态机会"记住"已收到的内容,自动处理粘包(多帧粘连)和断包(一帧分多次到达)。
1.5 数据结构设计
1.5.1 跨平台打包属性
跨编译器的结构体打包属性定义,确保结构体按1字节对齐,消除填充字节。
#if defined(__GNUC__) || defined(__clang__)
#define PACKED_STRUCT __attribute__((packed))
#elif defined(_MSC_VER)
#define PACKED_STRUCT
#pragma pack(push, 1)
#else
#define PACKED_STRUCT
#warning"Unknown compiler, packed attribute may not work correctly"
#endif
1.5.2 类型定义(使用固定宽度类型)
TLV数据类型定义使用固定宽度类型。使用固定宽度uint8_t确保跨平台一致性,避免使用enum,因为enum的大小是编译器相关的。
typedefuint8_ttlv_type_t;
#define TLV_TYPE_UINT8 ((tlv_type_t)0x00) // 无符号8位整数
#define TLV_TYPE_INT8 ((tlv_type_t)0x01) // 有符号8位整数
#define TLV_TYPE_UINT16 ((tlv_type_t)0x02) // 无符号16位整数
#define TLV_TYPE_INT16 ((tlv_type_t)0x03) // 有符号16位整数
#define TLV_TYPE_UINT32 ((tlv_type_t)0x04) // 无符号32位整数
#define TLV_TYPE_INT32 ((tlv_type_t)0x05) // 有符号32位整数
#define TLV_TYPE_STRING ((tlv_type_t)0x06) // 字符串类型
#define TLV_TYPE_FLOAT ((tlv_type_t)0x07) // 浮点类型
#define TLV_TYPE_BYTES ((tlv_type_t)0x08) // 字节数组
1.5.3 协议数据结构
typedefstruct
{
protocol_id_t id; // 协议ID
tlv_type_t type; // 数据类型
uint8_t length; // 数据长度
uint8_t payload[PROTOCOL_VALUE_MAX_LEN]; // 负载数据
} protocol_data_t;
用于组包和解包的数据容器,业务层可以通过 payload 访问原始字节数据。
1.5.4 错误码定义
typedefenum
{
PROTO_OK = 0, // 操作成功
PROTO_ERR_NULL_PTR = -1, // 空指针错误
PROTO_ERR_BUF_TOO_SMALL = -2, // 缓冲区太小
PROTO_ERR_INVALID_HEAD = -3, // 无效的包头
PROTO_ERR_CRC_MISMATCH = -4, // CRC校验失败
PROTO_ERR_INVALID_ID = -5, // 无效的协议ID
PROTO_ERR_PAYLOAD_SIZE = -6, // 负载大小错误
PROTO_ERR_IN_PROGRESS = -7, // 解析进行中
PROTO_ERR_INVALID_LEN = -8, // 无效的数据长度
} protocol_err_e;
1.5.5 流式解析器定义
流式解析的核心是状态机。每收到一个字节,状态机根据当前状态决定下一步动作:
当遇到错误数据自动回到IDLE重新开始,这就实现了噪声过滤。
typedefenum
{
PARSE_STATE_IDLE = 0, // 空闲状态
PARSE_STATE_HEAD1, // 等待包头第一字节
PARSE_STATE_HEAD2, // 等待包头第二字节
PARSE_STATE_ID, // 接收ID
PARSE_STATE_TYPE, // 接收Type
PARSE_STATE_LENGTH, // 接收Length
PARSE_STATE_PAYLOAD, // 接收Payload
PARSE_STATE_CRC_LOW, // 接收CRC低字节
PARSE_STATE_CRC_HIGH, // 接收CRC高字节
} parse_state_e;
typedefstruct
{
parse_state_e state; // 当前解析状态
uint8_t buffer[PROTOCOL_MAX_LEN]; // 接收缓冲区
uint16_t index; // 当前接收索引
uint8_t payload_len; // 期望的负载长度
} protocol_parser_t;
1.6 CRC16校验
CRC(循环冗余校验)用于检测数据传输错误。本协议采用CRC16-X25算法,使用查表法提高效率。
校验范围:从包头开始到Payload结束(不含CRC本身)
1.7 API接口设计
协议库提供3类API:组包、一次性解包、流式解析。
1.7.1 组包
/**
* @brief 协议数据组包
* @param buf 输出缓冲区指针
* @param buf_size 缓冲区大小
* @param data 协议数据结构
* @param out_len 实际输出长度(输出)
* @return PROTO_OK: 成功, 其他: 错误码
*/
protocol_err_e protocol_pack(uint8_t *buf,
size_t buf_size,
constprotocol_data_t *data,
size_t *out_len);
1.7.2 一次性解包
/**
* @brief 一次性解包
* @param buf 输入缓冲区指针
* @param len 数据长度
* @param data 协议数据结构(输出)
* @return PROTO_OK: 成功, 其他: 错误码
*/
protocol_err_e protocol_unpack(constuint8_t *buf,
size_t len,
protocol_data_t *data);
1.7.3 流式解析
/**
* @brief 初始化解析器
* @param parser 解析器实例指针
* @return PROTO_OK: 成功, 其他: 错误码
*/
protocol_err_e protocol_parser_init(protocol_parser_t *parser);
/**
* @brief 重置解析器状态
* @param parser 解析器实例指针
*/
void protocol_parser_reset(protocol_parser_t *parser);
/**
* @brief 流式解析 - 逐字节输入
* @param parser 解析器实例指针
* @param byte 输入字节
* @return PROTO_OK: 帧完成, PROTO_ERR_IN_PROGRESS: 解析中, 其他: 错误码
*/
protocol_err_e protocol_parse_byte(protocol_parser_t *parser, uint8_t byte);
/**
* @brief 从解析器提取帧数据
* @param parser 解析器实例指针
* @param data 协议数据结构(输出)
* @return PROTO_OK: 成功, 其他: 错误码
*/
protocol_err_e protocol_parser_get_frame(constprotocol_parser_t *parser,
protocol_data_t *data);
2. ITLV组包、解包测试
本文demo可在公众号聊天对话框输入关键字:简易嵌入式自定义协议设计思路,即可获取。
下面通过两个典型场景演示协议的使用。
2.1 业务数据定义
首先定义业务层的数据结构。注意使用# pragma pack确保结构体按1字节对齐:
// 业务协议ID定义
#define CMD_ID_LED_CTRL (protocol_id_t)0x01
#define CMD_ID_DATE_TIME (protocol_id_t)0x02
#pragma pack(push, 1)
typedefstruct
{
uint8_t led_id; // LED编号
uint8_t on_off; // 0=关闭, 1=打开
} led_ctrl_t;
typedefstruct
{
uint16_t year;
uint8_t month;
uint8_t day;
uint8_t hour;
uint8_t minute;
uint8_t second;
uint8_t reserved;
} datetime_t;
#pragma pack(pop)
2.2 一次性解析测试
核心代码:
// 准备数据
led_ctrl_t led_cmd = { .led_id = 1, .on_off = 1 };
tx_data.id = CMD_ID_LED_CTRL;
tx_data.type = TLV_TYPE_BYTES;
tx_data.length = sizeof(led_cmd);
memcpy(tx_data.payload, &led_cmd, sizeof(led_cmd));
// 组包
protocol_pack(tx_buf, sizeof(tx_buf), &tx_data, &frame_len);
printf("Pack: ID=0x%02X, LED%d=%s, len=%zun",
tx_data.id, led_cmd.led_id,
led_cmd.on_off ? "ON" : "OFF", frame_len);
printf("Pack: ");
protocol_print_hex(tx_buf, frame_len);
// 解包
ret = protocol_unpack(tx_buf, frame_len, &rx_data);
if (ret == PROTO_OK)
{
led_ctrl_t *rx_led = (led_ctrl_t*)rx_data.payload;
printf("Unpack: ID=0x%02X, LED%d=%snn",
rx_data.id, rx_led->led_id,
rx_led->on_off ? "ON" : "OFF");
}
运行结果:
2.3 流式解析测试
模拟数据逐字节到达,状态机累积处理。
核心代码:
protocol_parser_t parser;
protocol_parser_init(&parser);
// 模拟逐字节接收
for (size_t i = 0; i < frame_len; i++)
{
protocol_err_e ret = protocol_parse_byte(&parser, tx_buf[i]);
if (ret == PROTO_OK)
{
// 帧接收完成
protocol_parser_get_frame(&parser, &rx_data);
led_ctrl_t *rx_led = (led_ctrl_t*)rx_data.payload;
printf("Unpack: ID=0x%02X, LED%d=%s (parsed at byte %zu)nn",
rx_data.id, rx_led->led_id,
rx_led->on_off ? "ON" : "OFF", i + 1);
break;
}
}
运行结果:
本文demo可在公众号聊天对话框输入关键字:简易嵌入式自定义协议设计思路,即可获取。
3. 局限性与优化方向
本ITLV协议是个最小实现,轻量简洁,可以适用于短距离、低误码率的嵌入式板间通信。如果要应用于其它更广泛的场景,会有一些局限性。
下面讨论一些局限性及可优化点:
3.1 字段容量限制
| 字段 | 当前设计 | 局限性 | 优化方向 |
|---|---|---|---|
| ID | 1字节 (0~255) | 最多256种数据类型 | 扩展为2字节,支持65536种 |
| Length | 1字节 (0~255) | 单帧最大255字节 | 扩展为2字节,或引入分包机制 |
| Type | 1字节 | 当前仅做标记,未强制校验 | 可用于自动类型转换(大小端等) |
对于数据种类<256、单条数据<255字节的系统,当前设计完全够用。超出范围时需扩展字段位宽。
3.2 可靠性机制不够完善
当前协议:
可优化方向:
3.3 状态机健壮性
当前状态机无超时机制。潜在问题:
3. 总结
本文介绍的ITLV协议具有以下特点:
| 特性 | 说明 |
|---|---|
| 简洁高效 | 最小帧7字节,开销合理 |
| 静态内存 | 无动态分配,适合嵌入式 |
| 流式解析 | 状态机自动处理粘包/断包 |
| CRC校验 | 保证数据完整性 |
| 跨平台 | 固定宽度类型、打包属性 |
适用场景:短距离、低误码率的嵌入式板间通信(串口、SPI、I2C等)。
不适用场景:高可靠性要求、大数据传输、多设备组网、安全敏感的场合。
我是杂烩君,专注嵌入式软件开发,分享实用的技术干货。
378