两台设备之间要传一组数据,“把结构体 memcpy 过去”是最省事的写法,也是最容易埋雷的写法。
结构体直传的四个问题
// 看似无害的一帧数据
typedef struct {
uint32_t timestamp;
int16_t voltage;
int16_t current;
uint8_t status;
uint8_t reserved;
} sample_t;
void send_sample(int fd, const sample_t *s)
{
write(fd, s, sizeof(sample_t)); // 直接把结构体发出去
}
这段代码依赖了四个没有写在代码里的假设。
对齐与填充。编译器会在成员之间插入填充字节让每个成员落在自然边界上。上面的结构体实际布局是:timestamp(4) + voltage(2) + current(2) + status(1) + reserved(1) = 10 字节有效数据,恰好不需要尾部填充。但把 voltage 换成 int32_t 之后,status 之前会多出 2 字节空隙。不同的编译器、不同的 -fpack-struct 选项、甚至同一编译器的不同版本,都可能给出不同的填充方案。发送端和接收端只要有一侧不同,字段就错位。
字节序。uint32_t timestamp 在 Cortex-M 上以小端存储,某些架构是大端,双方对“第一个字节是高位还是低位”的理解相反。0x0000_0064 到了对面变成 0x6400_0000。
类型宽度。int 在 16 位 MCU 上是 2 字节,在 Cortex-M 上是 4 字节;long 在 Linux 64 位上是 8 字节。用 int、long、size_t 这类平台相关类型定义通信结构体,等于把平台假设写进了协议。
版本演进。中途给 sample_t 加一个 int32_t temperature 字段,所有未升级的接收端立刻解错。而固件的滚动升级必然存在版本共存期,这个问题无法回避。
前三个问题可以用 __attribute__((packed)) 部分缓解,但它带来新的代价:成员访问变成逐字节搬运(多出数倍指令),并且在 strict-alignment 架构上访问未对齐地址会直接触发硬件异常。Cortex-M3/M4 能容忍非对齐访问(部分场景除外),Cortex-M0 不能。用 packed 结构体做通信,等于用性能和可移植性换一点便利。
正确的做法:显式序列化
把“内存里的表示”和“线上的表示”分开,用显式的编码函数连接两者。这样每个字节的位置和含义都由你自己定义,编译器和平台都无法插手。
// 显式打包:每个字段的字节序和宽度都写死
size_t sample_encode(const sample_t *s, uint8_t *out, size_t maxlen)
{
if (maxlen < 10) return 0;
size_t n = 0;
out[n++] = (uint8_t)(s->timestamp >> 24); // 大端序(网络字节序),全程统一
out[n++] = (uint8_t)(s->timestamp >> 16);
out[n++] = (uint8_t)(s->timestamp >> 8);
out[n++] = (uint8_t)(s->timestamp);
out[n++] = (uint8_t)(s->voltage >> 8);
out[n++] = (uint8_t)(s->voltage);
out[n++] = (uint8_t)(s->current >> 8);
out[n++] = (uint8_t)(s->current);
out[n++] = s->status;
out[n++] = s->reserved;
return n;
}
size_t sample_decode(sample_t *s, const uint8_t *in, size_t len)
{
if (len < 10) return 0;
size_t n = 0;
s->timestamp = ((uint32_t)in[n] << 24) | ((uint32_t)in[n+1] << 16) |
((uint32_t)in[n+2] << 8) | (uint32_t)in[n+3];
n += 4;
s->voltage = (int16_t)(((uint16_t)in[n] << 8) | in[n+1]); n += 2;
s->current = (int16_t)(((uint16_t)in[n] << 8) | in[n+1]); n += 2;
s->status = in[n++];
s->reserved = in[n++];
return n;
}
手写编码枯燥但完全可控,适合字段固定、对体积和性能敏感的场景(比如 8 位/16 位 MCU 的无线报文)。字段多了维护成本高,这时应该上方案化的编码体系。
三种常见编码方案
| 方案 |
自描述 |
体积开销 |
MCU 资源 |
兼容性处理 |
适用 |
| 手写定长二进制 |
否 |
最小 |
极小 |
靠版本号,需自己兼容 |
资源极紧、字段固定 |
| TLV |
是(类型+长度) |
中等 |
很小 |
天然可跳过未知字段 |
配置项、控制命令 |
| CBOR |
是 |
小(半字节头部) |
ROM 10~20KB 级 |
良好 |
JSON 替代、上云数据 |
| nanopb(Protobuf) |
生成代码 |
小 |
ROM 数 KB ~ 十几 KB |
优秀,有明确演进规则 |
云平台对接、长期演进 |
选择依据是“数据要不要跨版本共存”和“接收端能否同时升级”。设备固件通常需要共存,所以至少要能处理“未知字段”;如果能接受自己管理版本号,手写或 TLV 就够了。
TLV:轻量且可扩展
TLV(Tag-Length-Value)的每个字段自带类型和长度,接收端遇到不认识的 tag 可以安全地跳过去,这正是版本兼容所需要的性质。
#define TLV_MAX_LEN 256
typedef enum {
TLV_TS = 0x01, // 时间戳,4 字节
TLV_VOLTAGE = 0x02, // 电压,2 字节
TLV_CURRENT = 0x03, // 电流,2 字节
TLV_STATUS = 0x04, // 状态字,1 字节
TLV_TEMP = 0x05 // 温度,2 字节(后加的字段)
} tlv_tag_t;
size_t tlv_put(uint8_t *buf, uint8_t tag, const void *val, uint16_t len)
{
buf[0] = tag;
buf[1] = (uint8_t)len;
memcpy(&buf[2], val, len);
return 2u + len;
}
// 解码:遇到不认识的 tag 跳过,而不是报错
int tlv_parse(const uint8_t *buf, size_t len, void (*emit)(uint8_t, const uint8_t *, uint16_t))
{
size_t p = 0;
while (p + 2 <= len) {
uint8_t tag = buf[p];
uint16_t vlen = buf[p + 1];
if (vlen > TLV_MAX_LEN) return -1; // 长度异常,可能是损坏数据
if (p + 2 + vlen > len) return -1; // 数据不完整
emit(tag, &buf[p + 2], vlen);
p += 2 + vlen;
}
return (p == len) ? 0 : -1;
}
vlen > TLV_MAX_LEN 这个判断不能省:少了它,一段错误数据就能让解析越过缓冲区边界。通信数据的长度字段永远要先校验再使用。
TLV 的取舍很明显:每个字段多出 2 字节开销,字段较短时相对开销偏大,换来的是“老固件遇到新字段不崩溃”的能力。新设备加了 TLV_TEMP,老接收端不认识这个 tag,直接跳过并正常处理其他字段——不需要任何额外代码。
nanopb:给长期演进的数据
Google 的 Protobuf 在小 MCU 上有成熟实现 nanopb,用 .proto 文件描述数据结构,工具生成编解码代码。
// sensor.proto
syntax = "proto2";
message Sample {
required uint32 timestamp = 1;
required int32 voltage = 2; // 单位 0.1V,不用浮点数
required int32 current = 3; // 单位 0.01A
optional int32 status = 4;
optional int32 temp = 5; // 后加入的字段,optional 保证向后兼容
}
设备端编码:
#include "pb_encode.h"
#include "sensor.pb.h"
int encode_sample(uint8_t *buf, size_t maxlen, size_t *outlen)
{
Sample msg = Sample_init_zero;
pb_ostream_t stream = pb_ostream_from_buffer(buf, maxlen);
msg.timestamp = rtc_get_unix();
msg.voltage = (int32_t)(g_sample.volt_v * 10.0f);
msg.current = (int32_t)(g_sample.curr_a * 100.0f);
msg.has_status = true;
msg.status = g_sample.status;
if (!pb_encode(&stream, Sample_fields, &msg)) return -1;
*outlen = stream.bytes_written;
return 0;
}
Protobuf 的兼容规则写得很清楚,照着遵守就能长期演进:
- 字段号只能新增,不能修改或重用(把一个字段改名没关系,改号会破坏兼容性)
- 新加字段一律用
optional 或 repeated,老版本的接收端会自动忽略不存在的 optional 字段
- 删除字段要把该号标记为
reserved,防止后来的人误用
- 不要把
optional 改成 required,否则老数据会解析失败
nanopb 编译后可以不需要动态内存(用静态缓冲区,即 PB_NO_ERRMSG 与静态分配配置),ROM 占用视消息复杂度从几 KB 到十几 KB 不等。字段很少、MCU 很紧的场景不必上它,但凡要跟云平台对接、且协议会长期演进,提前用上比后面返工划算。
数值的编码约定
浮点在线上传输是个坏主意:IEEE754 的跨平台一致性、精度随量级变化、MCU 上解码开销都不划算。约定一个定点刻度(scale factor)就能解决,而且双方理解一致。
| 量 |
传输形式 |
刻度 |
备注 |
| 电压 |
int32 |
0.1V 或 0.01V |
按精度需求定 |
| 电流 |
int32 |
0.01A |
要覆盖负向潮流 |
| 功率 |
int32 |
1W 或 0.1W |
注意量程溢出 |
| 温度 |
int16 |
0.1℃ |
-400 表示无效值 |
| 能量 |
uint64 |
0.01kWh |
累加量,注意回绕周期 |
| 时间戳 |
uint32/uint64 |
Unix 秒 |
统一 UTC |
刻度和单位必须写进协议文档并且同步更新到代码中——“这里传的是 0.1V 还是 mV”的歧义,是联调时最常见的扯皮点。用带单位的命名(volt_0p1v、temp_0p1c)能让歧义在代码评审时就被发现。
枚举要不要 predefined 值:直接传整数比传字符串省得多,但要在文档里预留“未知值”的处理约定(比如 0xFF 保留为无效)。接收端遇到未定义枚举值应当按无效处理并记日志,而不是当成某个默认值。
兼容性策略
数据结构一定会变,提前把“变更”这条路径设计好:
版本号放在帧头,且只能增不能改语义。解析时按版本分流,老版本的兼容代码攒够几年之后可以在一个明确的版本节点上砍掉,并提前通知。
保留“未知即跳过”的能力是这套策略的核心。做不到这点,任何一次字段增加都要全网同步升级,工程上不可行。
校验的位置
序列化和校验的顺序不能搞反:先完整序列化,再对序列化后的字节流做 CRC 或 MAC。对结构体做 CRC 会包含填充字节,而填充字节的内容是不确定的(可能是上一次的内存残留),发送端和接收端算出的 CRC 不一致。
size_t build_frame(uint8_t *buf, size_t maxlen, const sample_t *s)
{
size_t n = 0;
buf[n++] = FRAME_HEAD_0;
buf[n++] = FRAME_HEAD_1;
n += sample_encode(s, &buf[n], maxlen - n - 2); // 先序列化
uint16_t crc = crc16(&buf[2], n - 2); // 再对实际字节流校验
buf[n++] = (uint8_t)(crc >> 8);
buf[n++] = (uint8_t)(crc);
return n;
}