
引言
上一篇我们聊了 RAM 不够用怎么办。这篇来看另一个常见问题——Flash(ROM)不够用。
嵌入式 MCU 的 Flash 存储空间同样有限。nRF52832 有 512KB Flash,但 SoftDevice 蓝牙协议栈就占了 152KB,Bootloader 占 32KB,留给应用只有约 320KB。STM32F103C8T6 只有 64KB Flash,更是寸土寸金。
当你看到这个错误时:
region `FLASH' overflowed by 15360 bytes
或者需要为 OTA 双区升级腾出空间时,就得认真优化 Flash 占用了。
Flash 优化和 RAM 优化的思路不同。RAM 主要关注变量和缓冲区,Flash 主要关注代码体积和常量数据。本文系统梳理 Flash 的使用去向和优化手段。
Flash 里都装了什么
嵌入式 MCU 的 Flash 存储了程序运行所需的一切"只读"内容:
Flash 地址空间
┌────────────────────┐ ← Flash 起始(0x00000000)
│ SoftDevice / 协议栈 │ ← BLE/Wi-Fi 协议栈(厂商预编译二进制)
├────────────────────┤
│ Bootloader │ ← OTA 引导程序
├────────────────────┤
│ 应用程序 │
│ ├─ .isr_vector │ ← 中断向量表
│ ├─ .text │ ← 机器码(所有函数编译后的指令)
│ ├─ .rodata │ ← 只读数据(const 变量、字符串常量)
│ └─ .data(初始值) │ ← 全局变量的初始值(启动时拷贝到 RAM)
├────────────────────┤
│ 用户数据区 │ ← 配置存储、日志、文件系统(可选)
└────────────────────┘ ← Flash 末尾
| 段 | 内容 | 典型占比 |
|---|---|---|
| .text | 编译后的机器码 | 60%-80% |
| .rodata | 字符串常量、const 数组、查找表 | 10%-30% |
| .data 初始值 | 已初始化全局变量的初始值副本 | 1%-5% |
| 向量表 | 中断向量表 | < 1KB |
代码(.text)是 Flash 占用的绝对大头。优化 Flash,核心就是减小代码体积。
用工具精确定位
arm-none-eabi-size:快速查看总体占用
$ arm-none-eabi-size firmware.elf
text data bss dec hex filename
245780 1024 8456 255260 3E51C firmware.elf
Flash 占用 = text + data = 245780 + 1024 = 246,804 字节(241KB)。
nm --size-sort:按大小排序所有符号,找出最大的函数和常量
$ arm-none-eabi-nm --size-sort -r firmware.elf | head -20
00008000 r font_data_16px # 32KB 字体数据
00004000 r sin_cos_table # 16KB 三角函数表
00002800 T ble_gattc_handler # 10KB BLE GATT 处理
00001a00 T display_draw_bitmap # 6.5KB 位图绘制
00001200 T json_parse # 4.6KB JSON 解析
00001000 r error_strings # 4KB 错误描述字符串
00000e00 T http_request_handler # 3.5KB HTTP 处理
...
一目了然:字体数据 32KB、三角函数表 16KB、BLE 处理函数 10KB——这些是优化的重点目标。
map 文件:查看每个 .o 文件的贡献
编译时加 -Wl,-Map=firmware.map,在 map 文件中搜索各模块的占用:
# 统计每个 .o 文件的 .text 段大小
$ grep "\.text\." firmware.map | awk '{print $3, $4}' | sort -rn | head -10
0x2800 ble_service.o
0x1a00 display.o
0x1200 json_parser.o
0x1000 sensor_algo.o
0x0e00 ota_manager.o
...
编译器优化选项
编译器选项是最容易实施、效果也最明显的优化手段——改几个编译参数就能省几 KB 甚至几十 KB。
优化级别对比
| 级别 | 目标 | Flash 大小 | 适用场景 |
|---|---|---|---|
-O0 | 不优化 | 最大(基准) | 仅调试 |
-O1 | 基本优化 | 基准的 60%-70% | 平衡 |
-O2 | 性能优化 | 基准的 55%-65% | 性能敏感 |
-Os | 体积优化 | 基准的 50%-60% | Flash 紧张(推荐) |
-Oz | 极限体积 | 基准的 45%-55% | GCC 12+ / Clang |
-Os 是嵌入式的默认选择。它在 -O2 的基础上关闭了会增大代码体积的优化(如循环展开、函数内联过度展开)。
一个实际项目从 -O0 切到 -Os 的效果:
-O0: text = 312,456 bytes
-Os: text = 178,320 bytes (减少 43%)
LTO:链接时优化
LTO(Link-Time Optimization)让编译器在链接阶段看到所有源文件,进行跨文件的全局优化:
CFLAGS += -flto
LDFLAGS += -flto
LTO 能做到的优化:
- 跨文件内联:小函数跨
.c文件内联,减少调用开销和代码 - 跨文件死代码消除:如果一个函数只被一处调用且可以内联,原始函数被删除
- 全局常量传播:跨文件识别并传播常量值
典型收益:在 -Os 基础上再减少 5%-15%。
-Os: text = 178,320 bytes
-Os + LTO: text = 158,200 bytes (再减少 11%)
注意:LTO 会显著增加编译时间,且调试信息可能不准确。建议只在 Release 构建中启用。
剔除死代码
这是性价比最高的优化之一:
# 编译选项:每个函数和数据放独立段
CFLAGS += -ffunction-sections -fdata-sections
# 链接选项:丢弃未引用的段
LDFLAGS += -Wl,--gc-sections
原理:默认情况下,同一个 .c 文件的所有函数都放在一个 .text 段中。即使只用了其中一个函数,整个文件的代码都会被链接进来。加了 -ffunction-sections 后,每个函数独立成段,链接器可以精确剔除未调用的函数。
无 gc-sections: text = 178,320 bytes
有 gc-sections: text = 162,400 bytes (减少 9%)
可以用 -Wl,--print-gc-sections 查看被剔除了哪些函数:
$ arm-none-eabi-gcc ... -Wl,--print-gc-sections 2>&1 | head
removing unused section '.text.unused_debug_func' in file 'debug.o'
removing unused section '.text.test_selfcheck' in file 'selftest.o'
removing unused section '.rodata.test_patterns' in file 'selftest.o'
Thumb 指令集
ARM Cortex-M 默认使用 Thumb/Thumb-2 指令集(16/32 位混合编码),比 ARM 32 位指令集体积小约 30%。确保编译选项中有:
CFLAGS += -mthumb
Cortex-M 系列(M0/M3/M4/M7)只支持 Thumb,所以通常不需要额外配置。但如果项目从 Cortex-A(支持 ARM 和 Thumb 双模式)迁移过来,要确认没有遗漏。
代码层面的瘦身
编译器能做的有限,代码本身的写法对体积影响很大。
避免内联过度
inline 关键字让函数在调用点展开,省了调用开销但增加了代码体积——每个调用点都复制一份函数体:
// 如果这个函数被调用 50 次,内联后代码膨胀 50 倍
static inline void update_display_pixel(int x, int y, uint8_t color)
{
// 20 行实现...
}
// 改为普通函数,50 次调用共享一份代码
static void update_display_pixel(int x, int y, uint8_t color)
{
// 20 行实现...
}
原则:只对 1-3 行的小函数使用 inline。较大的函数让编译器自行决定(-Os 下编译器会非常保守地内联)。
还可以用 __attribute__((noinline)) 强制禁止内联:
__attribute__((noinline))
void complex_calculation(void) { ... }
减少宏展开膨胀
函数式宏每次使用都是文本展开,容易导致代码膨胀:
// 宏:每次使用都展开为完整代码
#define LOG(fmt, ...) do { \
char _buf[128]; \
snprintf(_buf, sizeof(_buf), fmt, ##__VA_ARGS__); \
uart_send(_buf, strlen(_buf)); \
} while(0)
// 在 100 个地方使用 LOG(...),snprintf + uart_send 的代码被复制了 100 次
改为函数调用:
// 函数:100 次调用共享一份实现
void log_printf(const char *fmt, ...)
{
char buf[128];
va_list args;
va_start(args, fmt);
vsnprintf(buf, sizeof(buf), fmt, args);
va_end(args);
uart_send(buf, strlen(buf));
}
合并重复逻辑
Review 代码时留意相似的代码块,提取为公共函数:
// 优化前:三个函数各有 80% 相似的初始化代码
void init_sensor_accel(void) {
i2c_write(ADDR, 0x20, 0x57);
i2c_write(ADDR, 0x21, 0x00);
i2c_write(ADDR, 0x22, 0x44);
i2c_write(ADDR, 0x23, 0x08); // 加速度特有
}
void init_sensor_gyro(void) {
i2c_write(ADDR, 0x20, 0x57);
i2c_write(ADDR, 0x21, 0x00);
i2c_write(ADDR, 0x22, 0x44);
i2c_write(ADDR, 0x23, 0x30); // 陀螺仪特有
}
// 优化后:公共逻辑提取
static void init_sensor_common(void) {
i2c_write(ADDR, 0x20, 0x57);
i2c_write(ADDR, 0x21, 0x00);
i2c_write(ADDR, 0x22, 0x44);
}
void init_sensor_accel(void) {
init_sensor_common();
i2c_write(ADDR, 0x23, 0x08);
}
void init_sensor_gyro(void) {
init_sensor_common();
i2c_write(ADDR, 0x23, 0x30);
}
用数据驱动替代大量 switch-case
大的 switch-case 编译后可能生成很大的跳转表或比较链:
// 优化前:每个命令一个 case,代码膨胀
void handle_command(uint8_t cmd)
{
switch (cmd) {
case CMD_READ_TEMP: read_temperature(); break;
case CMD_READ_HUMI: read_humidity(); break;
case CMD_READ_PRESS: read_pressure(); break;
case CMD_SET_MODE: set_mode(); break;
case CMD_CALIBRATE: calibrate(); break;
// ... 50 个 case
}
}
// 优化后:函数指针表(数据驱动)
typedef void (*cmd_handler_t)(void);
static const cmd_handler_t cmd_table[] = {
[CMD_READ_TEMP] = read_temperature,
[CMD_READ_HUMI] = read_humidity,
[CMD_READ_PRESS] = read_pressure,
[CMD_SET_MODE] = set_mode,
[CMD_CALIBRATE] = calibrate,
// ...
};
void handle_command(uint8_t cmd)
{
if (cmd < ARRAY_SIZE(cmd_table) && cmd_table[cmd]) {
cmd_table[cmd]();
}
}
函数指针表存在 Flash(.rodata),通常比 switch-case 编译后的跳转代码更紧凑。
精简日志字符串
字符串常量放在 .rodata 段,直接占 Flash。详细的日志在调试时很有用,但发布版应该精简:
// 调试版:详细日志
#if DEBUG
#define LOG_ERROR(msg) log_print("[ERROR] %s:%d %s\n", __FILE__, __LINE__, msg)
#else
// 发布版:只输出错误码,不输出文件名和行号
#define LOG_ERROR(msg) log_print("E:%d\n", __LINE__)
#endif
更激进的做法:发布版完全移除日志:
#if DEBUG
#define LOG(fmt, ...) log_printf(fmt, ##__VA_ARGS__)
#else
#define LOG(fmt, ...) ((void)0) // 编译为空
#endif
__FILE__ 宏特别需要注意——它会把完整的文件路径嵌入到 .rodata 中。如果有 100 个文件都使用了 __FILE__,路径字符串可能占用好几 KB。
库的选择与裁剪
第三方库和标准库是 Flash 占用的隐形大户。
nano newlib vs 标准 newlib
GCC ARM 工具链默认链接 newlib 标准库。标准 newlib 的 printf 实现支持浮点格式化,体积较大。nano 版做了大幅裁剪:
# 使用 nano newlib
LDFLAGS += --specs=nano.specs
对比:
| 函数 | 标准 newlib | nano newlib |
|---|---|---|
printf 系列 | ~50KB | ~8KB |
malloc/free | ~10KB | ~2KB |
math 函数 | 完整精度 | 单精度优化 |
仅切换到 nano.specs 就能省 30-50KB。
printf 的代价
即使用了 nano newlib,printf 家族仍然是个大函数。如果只需要输出整数和字符串,可以自己写轻量版本:
// 轻量 itoa:只需要几十字节代码
static int simple_itoa(int val, char *buf)
{
int i = 0, neg = 0;
if (val < 0) { neg = 1; val = -val; }
do {
buf[i++] = '0' + val % 10;
val /= 10;
} while (val);
if (neg) buf[i++] = '-';
// 反转
for (int j = 0; j < i / 2; j++) {
char t = buf[j]; buf[j] = buf[i-1-j]; buf[i-1-j] = t;
}
buf[i] = '\0';
return i;
}
// 轻量日志:不引入 printf,几百字节搞定
void log_int(const char *label, int value)
{
char buf[12];
uart_send_str(label);
simple_itoa(value, buf);
uart_send_str(buf);
uart_send_str("\r\n");
}
如果你发现项目中只有一处用了 printf 的浮点格式化(%f),那这一处就可能引入了 20KB+ 的浮点 printf 代码。考虑手动格式化浮点数:
// 避免 printf("%.1f", 3.7) → 引入浮点 printf 支持
// 改为:
int temp_int = (int)(temperature * 10);
log_printf("%d.%d", temp_int / 10, temp_int % 10); // 只用整数 printf
第三方库的按需裁剪
很多第三方库功能全面但体积庞大。常见优化方式:
cJSON:完整 cJSON 约 15-20KB。如果只需要解析(不需要生成 JSON),可以只编译解析部分,或者用更轻量的 jsmn(< 2KB)。
mbedTLS:完整加密库 100KB+。通过配置头文件只启用需要的算法:
// mbedtls_config.h —— 只启用 AES 和 SHA256
#define MBEDTLS_AES_C
#define MBEDTLS_SHA256_C
// 注释掉不需要的:RSA、ECC、X.509、SSL...
FreeRTOS:通过 FreeRTOSConfig.h 裁剪不需要的功能:
#define configUSE_MUTEXES 1
#define configUSE_COUNTING_SEMAPHORES 0 // 不需要计数信号量
#define configUSE_TRACE_FACILITY 0 // 不需要跟踪功能
#define configUSE_STATS_FORMATTING_FUNCTIONS 0 // 不需要统计格式化
常量数据优化
.rodata 段的常量数据有时占比不小,尤其是带显示功能的产品。
字体数据压缩
一个 16px 的中文字体库(GB2312)可以占 200KB+。优化手段:
- 只提取需要的字符:如果 UI 只用到 100 个汉字,只生成这 100 个字的字模,从 200KB 降到几 KB
- 减小字号和位深:16px → 12px,抗锯齿(4bit)→ 黑白(1bit)
- 运行时解压:字模数据用 RLE 或 LZ4 压缩存储,显示时解压
图片资源压缩
嵌入式常将图片编码为 C 数组存储在 Flash 中:
// 原始 128x128 RGB565 图片:32KB
const uint16_t logo_128x128[16384] = { ... };
// 优化:使用 RLE 压缩,如果图片有大面积色块可压缩到 3-5KB
const uint8_t logo_compressed[] = { /* RLE 编码数据 */ };
// 显示时解压到 RAM 帧缓冲
或者降低分辨率和色深:
// 128x128 RGB565: 32KB
// 64x64 RGB565: 8KB (缩小到 1/4)
// 128x128 单色1bit: 2KB (去掉颜色)
查找表精简
大型查找表可以通过降低精度或缩小范围来减小:
// 优化前:360 度正弦表,float 精度,1440 字节
const float sin_table[360] = { ... };
// 优化后:90 度正弦表(利用对称性),int16 精度,180 字节
const int16_t sin_table_q15[90] = { ... }; // Q15 定点数
int16_t fast_sin(int angle) {
angle = angle % 360;
if (angle < 0) angle += 360;
if (angle < 90) return sin_table_q15[angle];
if (angle < 180) return sin_table_q15[180 - angle];
if (angle < 270) return -sin_table_q15[angle - 180];
return -sin_table_q15[360 - angle];
}
从 1440 字节压到 180 字节,精度对大多数嵌入式场景足够。
字符串去重
编译器通常会自动合并相同的字符串常量(-fmerge-constants,默认开启)。但格式字符串中的细微差异会阻止合并:
// 这三个字符串各占一份 Flash 空间
log_print("Sensor init OK\n");
log_print("Display init OK\n");
log_print("BLE init OK\n");
// 优化:抽取公共部分
void log_module_ok(const char *module) {
log_print(module);
log_print(" init OK\n"); // 只存一份
}
log_module_ok("Sensor");
log_module_ok("Display");
log_module_ok("BLE");
链接脚本与 Flash 分区
典型 Flash 分区
一个 512KB Flash 的 BLE 产品典型分区:
地址 大小 用途
0x00000000 152KB SoftDevice(BLE 协议栈)
0x00026000 32KB Bootloader
0x0002E000 296KB Application(应用程序)
0x00076000 24KB Bootloader Settings + Bond Storage
0x0007C000 8KB MBR Parameters
留给应用的只有 296KB。如果要支持 OTA 双区升级(Bank 0 + Bank 1),应用空间还要对半分——每个 Bank 只有 148KB。
OTA 预留空间
OTA 双区升级要求 Flash 能同时存放当前固件和新固件:
┌──────────────┐
│ SoftDevice │ 固定不变
├──────────────┤
│ Bootloader │ 固定不变
├──────────────┤
│ App Bank 0 │ 当前运行的固件
├──────────────┤
│ App Bank 1 │ OTA 接收的新固件
├──────────────┤
│ Settings │ 启动配置
└──────────────┘
如果应用编译后 200KB,两个 Bank 就需要 400KB,可能放不下。这时候优化代码体积就不只是"省空间",而是决定了 OTA 方案是否可行。
替代方案:单区升级 + 外部 Flash。新固件先存到外部 SPI Flash,验证通过后擦除内部 Flash 写入。省空间但风险更高(写入过程中断电会变砖)。
实战案例:从 Flash 溢出到优化达标
问题背景
某 BLE 可穿戴产品,nRF52832(512KB Flash),分区后应用区 296KB。链接器报错:
region `FLASH' overflowed by 15360 bytes (15KB)
当前 Flash 占用:311KB,需要压到 296KB 以内。
第一步:用工具分析占用分布
$ arm-none-eabi-size firmware.elf
text data bss dec hex
317440 1024 8456 326920 4FC88
# text + data = 318,464 bytes (311KB),超了 15KB
$ arm-none-eabi-nm --size-sort -r firmware.elf | head -10
00008000 r chinese_font_16px # 32KB!中文字体
00002000 r icon_bitmaps # 8KB 图标
00001800 T mbedtls_aes_encrypt # 6KB AES(完整实现)
00001400 T ble_gattc_evt_handler # 5KB
00001200 T printf # 4.6KB printf
00001000 r error_msg_strings # 4KB 错误信息
00000c00 T json_parse_object # 3KB
...
第二步:逐项优化
1. 切到 nano newlib(省 ~35KB)
LDFLAGS += --specs=nano.specs
printf 从 4.6KB 降到 1.2KB,加上其他标准库函数的缩减,总共省了约 35KB。
2. 精简中文字体(32KB → 4KB)
产品 UI 只用到约 80 个汉字。从完整字库切换到只包含这 80 个字的精简字库。
3. 启用 gc-sections(省 ~8KB)
CFLAGS += -ffunction-sections -fdata-sections
LDFLAGS += -Wl,--gc-sections
剔除了调试模块、自检模块等未使用的函数。
4. 移除 printf 浮点支持(省 ~3KB)
找到唯一一处用了 %f 的地方,改为整数格式化。
5. 日志字符串精简(省 ~3KB)
发布版移除 __FILE__ 路径,日志改为错误码形式。
第三步:验证
$ arm-none-eabi-size firmware.elf
text data bss dec hex
265200 960 8456 274616 43048
# Flash 占用: 265,200 + 960 = 266,160 bytes (260KB)
# 应用区: 296KB
# 剩余: 36KB 余量
从超出 15KB 到富余 36KB,总共优化了约 51KB。
总结
优化检查清单
| 优化项 | 方法 | 预期收益 |
|---|---|---|
| 定位大户 | nm --size-sort + map 文件 | 找到优化方向 |
| -Os 优化级别 | 编译选项 | 比 -O0 减少 40%+ |
| nano newlib | --specs=nano.specs | 减少 30-50KB |
| gc-sections | 编译+链接选项 | 减少 5%-15% |
| LTO | -flto | 再减少 5%-15% |
| 精简字体 | 只提取使用的字符 | 几 KB 到几十 KB |
| 图片压缩 | RLE/降分辨率/降色深 | 视图片数量而定 |
| 移除浮点 printf | 手动格式化浮点 | 减少 15-20KB |
| 精简日志 | 移除 __FILE__、缩短字符串 | 几 KB |
| 裁剪第三方库 | 配置宏禁用不需要的模块 | 几 KB 到几十 KB |
| 合并重复代码 | 提取公共函数 | 视代码质量而定 |
| 控制内联 | 大函数不用 inline | 调用多处时有效 |
| 查找表精简 | 利用对称性、降精度 | 几百字节到几 KB |
与 RAM 优化的对比
| 维度 | RAM 优化 | Flash 优化 |
|---|---|---|
| 核心目标 | 减少变量和缓冲区 | 减少代码和常量 |
| 主要工具 | size (.bss/.data)、栈水位 | nm --size-sort、map 文件 |
| 最高效手段 | 缩小 buffer、const 移到 Flash | nano newlib、-Os、gc-sections |
| 编译器帮助 | 有限(主要靠代码修改) | 很大(-Os/LTO/gc-sections) |
| 风险 | 栈溢出、堆碎片 | 功能缺失、精度损失 |
两者的核心原则是相同的:先量化定位,再有针对性地优化。盲目优化既浪费时间又容易引入 Bug。