该文章同步至OneChan
程序在调试器中跑得好好的,一到脱机运行就随机死机。查了半天,发现某个中断向量表条目变成了空指针——不是你没写,是链接器自作主张把它“优化”掉了。
这是资深工程师压箱底的编程技巧系列第二十九篇。前面我们学会了用 .noinit 保留复位后不丢失的变量,用 section 把热函数搬到 RAM 里高速执行。今天这一招,战场从编译器转移到链接器,守护的对象是嵌入式系统最敏感的一块数据——中断向量表。
它就是链接脚本中一个看似简单、却能在关键时刻防止系统崩溃的指令:
KEEP(*(.section_name))。
在开启了链接时垃圾回收(--gc-sections)的项目中,这条指令是保护中断向量表、启动代码、甚至固件版本信息不被意外删除的最后一道锁。
一、这东西到底是干什么用的?
简单说:KEEP 是链接脚本中的一个指令,它告诉链接器:“无论有没有人引用这个段里的符号,你都必须把它保留在最终的可执行文件中。”
先理解背景。为了减小固件体积,绝大多数嵌入式项目都会在编译和链接时开启以下选项:
- 编译时:
-ffunction-sections和-fdata-sections,让每个函数、每个全局变量都放入自己独立的段。 - 链接时:
--gc-sections,链接器扫描所有段,如果某个段中的符号没有被任何其他段引用,就把它从最终镜像中删除。
这套组合拳可以非常激进地砍掉死代码,通常能节省 10%–30% 的 Flash 占用。但它有一个副作用:某些“看似未被引用”的关键段也可能被误删。 中断向量表就是重灾区。
中断向量表通常是启动文件中的一个汇编数组,里面填满了 Default_Handler、SysTick_Handler、DMA1_IRQHandler 等函数名。这些函数可能被声明为 __attribute__((weak)),并且除了出现在向量表中之外,没有任何 C 代码直接调用它们。链接器扫描时发现:这些函数没被调用 → 它们所在的段没有引用 → 删除!结果向量表中对应位置变成了垃圾地址,中断一触发系统就飞。
KEEP 指令就是用来对抗这种“误删”的。 你在链接脚本中明确写出要保留的段名,链接器就会无条件保留它们,无论有没有显式引用。
二、上硬菜,直接看怎么用
Step 1:在链接脚本中保留中断向量表段
一个典型的 ARM Cortex-M 启动文件中,中断向量表通常放在一个叫 .isr_vector 的段里。在链接脚本的 SECTIONS 块中,可以这样写:
SECTIONS
{
.isr_vector : {
KEEP(*(.isr_vector))
KEEP(*(.isr_vector.*))
} >FLASH
.text : {
*(.text)
*(.text.*)
} >FLASH
/* ... 其他段 ... */
}
KEEP(*(.isr_vector)) 告诉链接器:所有以 .isr_vector 为名的输入段,必须保留。这就保证了向量表本身不会被删除。
Step 2:保留默认中断处理函数所在的段
光保留向量表还不够。如果向量表里引用的 Default_Handler 函数因为没有被调用而被删掉了,向量表条目就会变成悬空地址。所以还必须保留这些处理函数所在的段。通常它们放在 .text.Default_Handler 这样的段中(因为 -ffunction-sections 会为每个函数单独建段)。
你可以在链接脚本中这样写:
.text : {
KEEP(*(.text.Default_Handler))
KEEP(*(.text.Reset_Handler))
KEEP(*(.text.NMI_Handler))
KEEP(*(.text.HardFault_Handler))
/* ... 其他中断处理函数 ... */
*(.text)
*(.text.*)
} >FLASH
这样一来,即使没有任何 C 代码直接调用 Default_Handler,链接器也会保留它,向量表中的地址就安全了。
Step 3:与 __attribute__((used)) 组成双保险
我们第 16 招学过,used 属性在编译阶段防止编译器删除未被引用的函数。但编译器只能保证函数留在 .o 文件中,链接器的 --gc-sections 仍然可能删除它。双保险的做法是:源码级加 used,链接脚本级加 KEEP。
// 源码中
__attribute__((weak, used)) void Default_Handler(void) {
while(1);
}
/* 链接脚本中 */
.text : {
KEEP(*(.text.Default_Handler))
...
} >FLASH
这样,编译器不会在生成 .o 时删掉它,链接器也不会在链接时删掉它,万无一失。
三、举一反三,KEEP 的这些保护对象你知道吗?
1. 保留 .init_array 和 .fini_array 构造函数表
我们第 14 招用了 constructor 属性实现模块自初始化。这些构造函数被放在 .init_array 段中,由启动代码遍历调用。如果启动代码的遍历逻辑不是通过显式的符号引用(而是通过起止符号),--gc-sections 可能会误删 .init_array 中的条目。 可以在链接脚本中保护:
.init_array : {
__init_array_start = .;
KEEP(*(.init_array))
KEEP(*(.init_array.*))
__init_array_end = .;
} >FLASH
2. 保留固件版本和校验信息
像第 16 招中放在 .firmware_info 段的常量数据,如果没有任何代码直接引用它的符号,也可能被链接器丢弃。用 KEEP 锁死:
.firmware_info : {
KEEP(*(.firmware_info))
} >FLASH
3. 保护自定义的“插件式”段
第 75 招(未来篇章)会讲如何利用 __start_xxx / __stop_xxx 遍历自定义段来构建插件式初始化列表。那个模式下,每个插件放在自己的段中,通过起止符号遍历,所有插件段都必须用 KEEP 保留,否则遍历时会漏掉。
4. KEEP 与 SORT 结合控制顺序
链接脚本还支持 SORT_BY_NAME、SORT_BY_ALIGNMENT 等命令,可以与 KEEP 一起使用,在保留段的同时控制它们在内存中的排列顺序。这在需要按特定顺序遍历的插件列表中非常有用。
四、留两个问题给你思考
现在请你停下来,思考这两个链接器层面的问题:
- 如果在链接脚本里同时使用了
KEEP(*(.isr_vector))和--gc-sections,但某个中断处理函数在源码中没有加used属性,编译时就被优化掉了。链接器还能找回它吗?KEEP在此时还有作用吗? - 链接脚本中的
KEEP指令和EXCLUDE_FILE指令如果同时作用于同一个段,谁优先级更高?能不能用KEEP反悔一个已经被EXCLUDE_FILE排除的文件中的段?
想清楚这两个问题,你就能在链接器层面游刃有余地保护关键符号,而不被工具链的“自动优化”反噬。
五、总结与思考题回答
核心总结:
KEEP(*(.section))是链接脚本命令,告诉链接器无条件保留指定段,对抗--gc-sections的误删。- 主要保护对象:中断向量表、默认中断处理函数、
.init_array构造函数表、固件版本等特殊数据段。 - 最佳实践:与源码级
__attribute__((used))形成双保险;同时确保启动代码不依赖被删除的段。 - 本质:链接器是自动化工具,
KEEP是你对它说“这部分不许动”的直接指令。
思考题回答
问题1:编译器已删除函数,KEEP 还有用吗?
没有用。 KEEP 只能保护链接器看得到的段。如果编译器在生成 .o 文件时已经把函数优化掉了(比如一个 static 函数从未被调用,且没有 used 属性),链接器就根本看不到这个段,KEEP 也就无从保留。因此,对于中断向量表这类关键函数,源码中必须加 __attribute__((used)) 先防止编译器删除,再用 KEEP 防止链接器删除。两者缺一不可。
问题2:KEEP 与 EXCLUDE_FILE 的优先级?
EXCLUDE_FILE 用于从指定文件中排除某些段的输入,它作用于 KEEP 之前。链接器在解析输入文件时,首先应用 EXCLUDE_FILE 过滤掉不需要的段,剩下的段再接受 KEEP 的保护。所以 KEEP 不能挽回已被 EXCLUDE_FILE 排除的段。如果你需要保留某个被排除文件中的段,就必须修改或移除 EXCLUDE_FILE 指令。KEEP 只能保护那些已经进入链接器视野的段不被垃圾回收,而不是夺回已被过滤掉的段。
好了,第 29 招我们就彻底吃透了。下次当你开启 --gc-sections 享受小体积快感时,别忘了用 KEEP 把中断向量表和关键处理器锁死,别让链接器好心干了坏事。
如果今天的内容让你对链接脚本有了更强的掌控感,欢迎转发和点赞。下一篇我们继续挖:链接脚本中用 ASSERT 在链接时校验地址范围与对齐。 咱们不见不散!

558

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



