找回密码
立即注册
搜索
发回帖 发新帖

6588

积分

0

好友

836

主题
发表于 10 小时前 | 查看: 4| 回复: 0

两台设备之间要传一组数据,“把结构体 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;
}



上一篇:PID参数整定与离散化实现:位置式增量式怎么选、抗饱和与微分滤波
下一篇:嵌入式故障录波黑匣子设计:环形缓冲、预触发与Flash落盘方案
您需要登录后才可以回帖 登录 | 立即注册

关闭

站长推荐上一条 /1 下一条

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-28 13:48 , Processed in 0.077971 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表