返回博客
ROM 不够用?嵌入式 Flash 代码体积优化实战指南

ROM 不够用?嵌入式 Flash 代码体积优化实战指南

2026年4月22日嵌入式, 内存优化, C语言

引言

上一篇我们聊了 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

对比:

函数标准 newlibnano 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 移到 Flashnano newlib、-Os、gc-sections
编译器帮助有限(主要靠代码修改)很大(-Os/LTO/gc-sections)
风险栈溢出、堆碎片功能缺失、精度损失

两者的核心原则是相同的:先量化定位,再有针对性地优化。盲目优化既浪费时间又容易引入 Bug。