# 一次 CRC 字段“神秘消失”的排查 -> 指针强转的UB代价
嵌入式C设备,内部 flash 上有一个键值日志:每条记录 = 8 字节头(key/len/crc)+ 数据,CRC16 覆盖 key+len+data。
某次升级编译器后(其实是同事用的编译器版本不同), 发现固件写入记录后回读校验失败;
把 flash dump 出来发现:key、len、数据都对,CRC 字段始终是 0xFFFF(擦除值)。
背景
在嵌入式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写了,作者言)
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 | ❌ 被删 | ✅ 保留 |
经验
- “嵌入式中指针强转很常见”不代表安全:指向没有声明类型的原始内存(寄存器、DMA 缓冲、flash 映射区)的强转没问题;指向已声明类型的对象再换类型访问是 UB。
- “能跑”不能作为验证:这类 UB 只有在特定编译器/优化/LTO 组合下才被利用,要用全量构建看反汇编。
- 修法很便宜:
memcpy的常量长度会被内联成普通 load/store,代码量和性能与强转基本一致(实测差 2~3 条指令,而 flash 编程是几十微秒级,可忽略)。 - 嵌入式C中还是GCC10.3爽,怪不得网上都推荐用这个
- 多人开发时必须限制编译器同版本,不然就慢慢给不同编译器擦屁股吧
更多推荐
所有评论(0)