# 一次 CRC 字段“神秘消失”的排查 -> 指针强转的UB代价

现象

嵌入式C设备,内部 flash 上有一个键值日志:每条记录 = 8 字节头(key/len/crc)+ 数据,CRC16 覆盖 key+len+data。

某次升级编译器后(其实是同事用的编译器版本不同), 发现固件写入记录后回读校验失败;

把 flash dump 出来发现:key、len、数据都对,CRC 字段始终是 0xFFFF(擦除值)。

背景

在嵌入式C中,使用指针强转赋值是很常见的做法(写起来太方便通畅了,作者言)

于是在写字段时自然而然使用指针强转

写函数原本是这样:

c 复制代码
uint8_t buf[8 + MAX_LEN];
entry_hdr_t *eh = (entry_hdr_t *)buf;   // ← 问题在这里
memset(buf, 0xFF, sizeof buf);
eh->key = key;
eh->len = len;
memcpy(buf + 8, data, len);

memcpy(tmp, eh, 6);
memcpy(tmp + 6, data, len);
eh->crc = crc16(tmp, 6 + len);          // ← 被整行删掉

flash_write(offs, buf, esize);

根因

C 的有效类型规则(6.5p7):对象的存储值只能通过兼容类型 或字符类型 访问。字符类型访问任何对象是允许的,反过来不成立 ------用 entry_hdr_t* 写一个声明为 uint8_t[] 的对象是未定义行为。

程序一旦有 UB,编译器就可以假设:结构体写和字节读是两个不相干的对象 。于是 eh->crc 的存储对后面 flash_write(offs, buf, ...) 的读取"不可见",被死存储消除(DSE)删除;存储没了,CRC 计算也成了死代码。

为什么 key/len 没被删?因为 memcpy(tmp, eh, 6) 是一次按字符类型的读,编译器必须保留它们;而 crc 没有任何"存活的字符读者",只剩那个不可见的 flash_write。

反汇编佐证

旧写法(GCC 14.2,全量 LTO)------从 memset 到 flash_write 之间,没有 CRC 计算,也没有写入 crc 字段:

复制代码
80065aa: movs  r2, #130
80065ae: add.w r0, sp, #142
80065b2: bl    memset            ; memset(buf+6, 0xFF, 130)
80065be: str.w r8, [sp, #136]    ; key
80065c2: strh.w r4, [sp, #140]   ; len
80065c6: bl    memcpy            ; data
80065ca: ldmia.w r7, {r0, r1}    ; 构造 tmp
80065d0: str   r0, [sp, #0]
80065d6: add.w r0, sp, #6
80065dc: bl    memcpy
80065e0: add.w r2, r4, #11
80065e4: bic.w r2, r2, #3
80065ec: bl    flash_write        ; ← 直接写,没有 crc

行表佐证 (addr2line):memcpy(tmp, ...) 与 flash_write 都有地址,唯独 eh->crc = crc16(...) 这一行没有生成任何地址:

复制代码
0x8006e16 -> 156   memcpy(tmp, eh, 6);
0x8006e22 -> 157   memcpy(tmp + 6, data, len);
0x8006e34 -> 160   return flash_write(...);
(158 行:eh->crc = crc16(...)  →  无指令)

修复后(同一编译器、同一全量构建)------CRC 计算和写回都恢复:

复制代码
80065e0: bl    crc16             ; CRC 计算
80065e4: strh.w r0, [sp, #6]     ; crc 写回
80065e8: ldrd  r0, r1, [sp]
80065ee: stmia.w r5, {r0, r1}    ; 头 → 输出缓冲
80065fc: bl    memcpy            ; data
800660a: bl    memset            ; padding 0xFF
8006614: bl    flash_write

修复

去掉"结构体指针写字节数组":头在局部结构体里构造,再通过 memcpy 拷贝进输出缓冲(字符访问,标准允许的方向):

(手写memcpy好繁琐啊,还好现在都是用AI写了,作者言)

c 复制代码
static int entry_write(uint32_t offs, uint32_t key, const void *data, uint32_t len) {
  entry_hdr_t hdr;
  uint8_t buf[8 + MAX_LEN];
  uint8_t tmp[6 + MAX_LEN];

  memset(buf, 0xFF, sizeof buf);
  hdr.key = key;
  hdr.len = (uint16_t)len;

  memcpy(tmp, &hdr, 6);
  memcpy(tmp + 6, data, len);
  hdr.crc = crc16(tmp, 6 + len);

  memcpy(buf, &hdr, sizeof hdr);   /* struct → 字节:允许方向 */
  memcpy(buf + sizeof hdr, data, len);

  return (flash_write(offs, buf, esize) == OK) ? 0 : -1;
}

验证(全量构建 A/B)

编译器与构建 旧写法 新写法
GCC 10.3 全量 LTO ✅ 保留 ✅ 保留
GCC 14.2 全量 LTO ❌ 被删(复现) ✅ 保留
GCC 15.3 全量 LTO ❌ 被删 ✅ 保留

经验

  1. "嵌入式中指针强转很常见"不代表安全:指向没有声明类型的原始内存 (寄存器、DMA 缓冲、flash 映射区)的强转没问题;指向已声明类型的对象再换类型访问是 UB。
  2. "能跑"不能作为验证:这类 UB 只有在特定编译器/优化/LTO 组合下才被利用,要用全量构建看反汇编。
  3. 修法很便宜:memcpy 的常量长度会被内联成普通 load/store,代码量和性能与强转基本一致(实测差 2~3 条指令,而 flash 编程是几十微秒级,可忽略)。
  4. 嵌入式C中还是GCC10.3爽,怪不得网上都推荐用这个
  5. 多人开发时必须限制编译器同版本,不然就慢慢给不同编译器擦屁股吧