2. 编译与链接优化:GCC编译优化选项与链接脚本定制

嵌入式系统的存储空间,说白了就是寸土寸金。Flash和RAM就那么点,代码稍微膨胀一点,可能就塞不下了。这一章我们聊聊怎么从编译和链接阶段就把空间省出来,而不是等出了问题再回头找原因。

我个人习惯,在项目初期就会把编译优化和链接脚本的规划定好。因为后期再改,牵一发动全身,很麻烦。

2.1 GCC编译优化选项:-O0到-Os的选择

GCC的-O选项,控制的是编译器怎么去“折腾”你的代码。不同的级别,对代码大小和执行速度的影响天差地别。

优化级别 核心行为 代码大小 执行速度 调试难度
-O0 不做任何优化,忠实翻译源码 最大 最慢 最容易
-O1 基础优化,减少分支和冗余 中等 较快 中等
-O2 激进优化,包含循环展开等 较大(可能膨胀) 最快 较难
-Os 以代码大小为首要目标 最小 较快 中等

关键结论:对于存储受限的嵌入式系统,-Os 通常是首选。它会在不影响太多性能的前提下,极力压缩代码体积。

我在项目中遇到过一个问题:用-O2编译的固件,Flash刚好够用。但加了一个小功能后,编译就报错了。后来换成-Os,不仅新功能加进去了,还多出了十几KB的空间。你想想看,这就是优化选项的威力。

2.1.1 各选项的避坑指南

  • -O0:调试阶段用。但要注意,有些芯片的RAM极小,-O0生成的代码可能直接导致栈溢出。我曾经在STM32F0上吃过这个亏,程序一跑就死,最后发现是-O0编译的代码太大,把栈给挤爆了。
  • -O2:性能优先。但循环展开和函数内联会让代码体积暴增。如果你的Flash余量不多,慎用。
  • -Os:省空间神器。但它会牺牲一部分性能。如果你的算法对时间要求极严,比如电机控制中的PWM周期计算,用-Os可能会导致时序偏差。这时候需要局部用__attribute__((optimize("O2")))单独优化关键函数。

我的建议:全局用-Os,对性能敏感的模块用-O2。这样既保住了空间,又保住了速度。

2.2 链接脚本定制:内存布局的艺术

编译优化是“省空间”,链接脚本则是“管空间”。链接脚本(.ld文件)决定了你的代码、数据、堆栈分别放在哪里。

很多新手直接拿芯片厂商提供的默认链接脚本就用,结果发现RAM不够用。其实,默认脚本往往为了通用性,做了很多冗余的保留。定制一下,能挤出不少空间。

2.2.1 一个典型的链接脚本结构

/* 假设芯片有 64KB Flash, 20KB RAM */
MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 64K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
}

SECTIONS
{
    .text : {
        *(.text*)        /* 所有代码 */
        *(.rodata*)      /* 只读数据 */
    } > FLASH

    .data : {
        _sdata = .;
        *(.data*)        /* 初始化数据 */
        _edata = .;
    } > RAM AT> FLASH    /* 运行时在RAM,初始值在Flash */

    .bss : {
        _sbss = .;
        *(.bss*)         /* 未初始化数据 */
        _ebss = .;
    } > RAM

    .heap : {
        _heap_start = .;
        . = . + 2K;      /* 预留2KB堆 */
        _heap_end = .;
    } > RAM

    .stack : {
        _stack_start = .;
        . = . + 1K;      /* 预留1KB栈 */
        _stack_end = .;
    } > RAM
}

嗯,这里要注意:.data段在Flash里存着初始值,但运行时必须搬到RAM里。这个搬运工作,通常由启动文件中的copy_data函数完成。如果你把LENGTH算错了,或者AT> FLASH写漏了,程序上电后数据全是乱的。

2.2.2 内存布局优化的几个实战技巧

  • 把常量塞进Flash:默认情况下,const变量可能被放到RAM里。在链接脚本中,确保.rodata*被放在Flash段中。这样能省下宝贵的RAM。
  • 精确计算堆栈大小:不要无脑预留4KB、8KB。我建议先用--print-memory-usage查看实际占用,然后根据最大调用深度和局部变量大小,精确计算栈需求。我曾经把一个项目的栈从4KB减到512字节,省出来的RAM刚好够放一个音频缓冲区。
  • 自定义段:有些数据不需要频繁访问,比如启动时的配置表。可以把它放到Flash里,用__attribute__((section(".my_config")))指定。然后在链接脚本里单独定义这个段。

警告:修改链接脚本后,一定要检查链接器映射文件(.map文件)。它详细列出了每个段、每个符号的地址和大小。如果发现某个段跑到了不该去的地方,赶紧回头查脚本。

2.3 知识体系:编译与链接优化全景图

下面这张图,把编译优化和链接优化的核心逻辑串起来了。你可以把它当作一个检查清单。

编译与链接优化全景图 编译与链接优化全景图 编译优化选项 -O0 (调试) -O1 (基础) -O2 (性能) -Os (大小) 链接脚本定制 MEMORY 定义 SECTIONS 布局 输出:最小化固件体积 + 精确内存布局 实战要点 1. 全局用 -Os,关键函数用 -O2 局部优化 2. 链接脚本中精确计算堆栈,不要无脑预留 3. 把 .rodata 段强制放到 Flash 中 4. 每次修改后检查 .map 文件,确认段地址

2.4 实战案例:一个Flash溢出的修复过程

我记得有一次,一个同事开发的物联网终端,固件编译后大小是62KB,芯片Flash是64KB。看起来刚好够,但加了一个OTA升级功能后,编译直接报错:section .text will not fit in region FLASH。

我们当时做了三步优化:

  1. 编译选项从-O2改为-Os:固件体积从62KB降到了48KB。这一步省了14KB。
  2. 检查链接脚本:发现默认脚本把.rodata放到了RAM里。我们把它改到Flash段,又省了3KB的RAM。虽然RAM不是当前瓶颈,但为后续功能预留了空间。
  3. 精确调整栈大小:通过分析.map文件和调用栈深度,把栈从2KB减到512字节。省出来的1.5KB RAM,刚好用来放OTA的临时缓冲区。

最终,固件体积降到了45KB,RAM占用也降了4.5KB。OTA功能顺利加入。你想想看,如果当时不懂这些优化手段,可能就得换更大Flash的芯片,成本和时间都浪费了。

一个小技巧:在Makefile中,可以加一行$(CC) $(CFLAGS) -Wl,-Map=$(TARGET).map,每次编译都生成.map文件。养成看.map文件的习惯,能帮你发现很多内存布局上的问题。