1. 代码压缩入门:为什么需要代码压缩?

做嵌入式开发的朋友,十有八九都遇到过这种情况:辛辛苦苦写完功能,编译一过,发现 Flash 快满了。或者跑起来总觉得卡顿,查了半天发现是代码体积太大,把缓存挤爆了。

我刚开始做单片机项目时也犯过这毛病。那时候觉得反正 Flash 有 64KB,够用。结果加了个协议栈,再加个 GUI 库,直接超了 20%。最后只能硬着头皮砍功能、手写汇编——那叫一个痛苦。

说白了,代码压缩就是让你的程序在更小的存储空间里跑得更快。这不是锦上添花,而是嵌入式开发的必修课。

核心观点: 代码压缩不是“优化完再考虑”的事,而是从设计第一天就该刻在骨子里的意识。

1.1 代码膨胀的代价

代码膨胀到底有多贵?我列几个真实账目给你看。

资源类型 膨胀后果 典型代价
Flash 容量 超出芯片规格,被迫换更大容量型号 每增加 64KB,BOM 成本上升 $0.3~$1.5
RAM 占用 全局变量/栈空间被挤占,动态分配频繁 堆碎片导致随机崩溃,调试成本翻倍
指令缓存 代码段过大,I-Cache 命中率下降 执行效率降低 10%~40%
编译/链接时间 冗余代码导致链接器处理更多符号 每次修改等待时间增加 30 秒~2 分钟
维护成本 重复代码、死代码、魔数散落各处 改一处漏三处,线上 Bug 率上升

你看,代码膨胀不只是“多用几 KB”的问题。它会从成本、性能、可靠性三个维度同时拖垮你的项目。

我曾经踩过的坑: 有个量产产品用了 STM32F103C8T6(64KB Flash),固件编译后 62KB。当时觉得还剩 2KB 没问题。结果客户要求加一个 OTA 升级功能,Flash 直接爆了。最后只能换 F103RBT6(128KB),单价贵了 $0.8,一年 10 万片就是 8 万美金——就因为当初没做代码压缩。

1.2 代码膨胀的根源

为什么会膨胀?我总结了几条最常见的“凶手”。

  • 滥用库函数: 调用一个 printf,可能拉进来 5KB 的格式化代码。你只是打印个整数,却背上了整个 stdio 的重量。
  • 模板/泛型展开: C++ 模板每个类型实例化都会生成一份完整代码。你写了一个 List<int> 和一个 List<float>,编译器就给你生成两份几乎一样的代码。
  • 内联过度: 把大函数加 inline,每次调用都复制一份。代码体积直接翻倍。
  • 死代码残留: 注释掉的函数、未调用的中断处理、调试用的打印语句——它们都躺在 Flash 里吃空间。
  • 对齐填充: 结构体成员没按自然边界对齐,编译器插入大量 padding 字节。一个 10 字节的结构体,可能实际占用 16 甚至 20 字节。
一个小实验: 你可以在当前项目里搜一下 printf、sprintf、std::vector 这些关键词。然后注释掉它们,重新编译看看 Flash 减少了多少。我试过,最多一次减了 12KB。

1.3 代码压缩的核心收益

压缩代码不是让你写晦涩难懂的汇编。恰恰相反,好的压缩能让代码更清晰、更高效。

  1. 降低成本: 用更便宜的芯片完成同样功能。每片省 $0.5,10 万片就是 5 万美金。
  2. 提升性能: 代码越小,I-Cache 命中率越高。同样的 CPU 频率,实际吞吐量可能提升 20%。
  3. 减少 Bug: 冗余代码是 Bug 的温床。删掉死代码、合并重复逻辑,出问题的概率自然下降。
  4. 加快启动: 代码段越小,从 Flash 加载到 RAM 的时间越短。有些设备要求上电 100ms 内响应,压缩代码就是关键。

1.4 代码压缩的层次

代码压缩不是一招鲜。我习惯把它分成四个层次,从浅到深。

代码压缩四层模型 第1层:编译器优化 开启 -Os / -Oz 优化选项,链接时垃圾回收(--gc-sections) 第2层:代码重构 消除重复代码、合并相似函数、减少全局变量、使用查表代替计算 第3层:数据结构优化 位域、联合体、紧凑结构体、对齐控制、内存池 第4层:汇编级/链接级优化 手写关键路径汇编、自定义链接脚本、函数重排 ⚠ 层次越深,优化效果越明显,但维护成本和风险也越高。建议从第1层开始,逐层推进。

我个人习惯是:先做第1层和第2层。这两个层次改动小、风险低,通常能砍掉 20%~40% 的代码体积。如果还不够,再考虑第3层和第4层。

我的经验: 80% 的项目在第1层和第2层就能解决问题。只有那些对成本极度敏感(比如消费电子、IoT 模组)或者对实时性要求极高的场景,才需要深入到第3层和第4层。

1.5 一个简单的对比实验

光说不练假把式。我们看一段真实代码。

// 优化前:滥用 printf
void debug_print(int val) {
    printf("Current value: %d\n", val);
}

// 优化后:用自定义轻量打印
void debug_print(int val) {
    uart_send_str("CV:");
    uart_send_hex(val);
    uart_send_char('\n');
}

第一段代码用了 printf,链接器会把整个格式化输出模块拉进来。第二段只用了三个自定义函数,体积小得多。

我在一个 STM32G030 项目上实测过:printf 版本占 18.2KB,轻量版本只占 6.8KB。就改了一行调用,省了 11.4KB——相当于芯片容量从 32KB 降到了 16KB。

注意: 不要盲目禁用所有库函数。有些库函数(比如 memcpy、memset)是经过高度优化的,比自己写的快得多。压缩代码的前提是“不影响功能正确性和性能指标”。

1.6 什么时候该做代码压缩?

不是所有项目都需要极致压缩。我一般按这个标准判断:

  • Flash 占用率 > 80%: 必须做。否则后续加功能、修 Bug 都会很被动。
  • Flash 占用率 60%~80%: 建议做。提前压缩,给未来留余量。
  • Flash 占用率 < 60%: 可以不做。但要注意,如果项目生命周期长(比如 5 年以上),还是建议养成压缩习惯。