嵌入式高手都在偷偷用的“第29条”:用链接器的 KEEP 指令锁死中断向量表,不让一个条目“失踪”

该文章同步至OneChan

程序在调试器中跑得好好的,一到脱机运行就随机死机。查了半天,发现某个中断向量表条目变成了空指针——不是你没写,是链接器自作主张把它“优化”掉了。

这是资深工程师压箱底的编程技巧系列第二十九篇。前面我们学会了用 .noinit 保留复位后不丢失的变量,用 section 把热函数搬到 RAM 里高速执行。今天这一招,战场从编译器转移到链接器,守护的对象是嵌入式系统最敏感的一块数据——中断向量表

它就是链接脚本中一个看似简单、却能在关键时刻防止系统崩溃的指令:

KEEP(*(.section_name))

在开启了链接时垃圾回收(--gc-sections)的项目中,这条指令是保护中断向量表、启动代码、甚至固件版本信息不被意外删除的最后一道锁。


一、这东西到底是干什么用的?

简单说:KEEP 是链接脚本中的一个指令,它告诉链接器:“无论有没有人引用这个段里的符号,你都必须把它保留在最终的可执行文件中。”

先理解背景。为了减小固件体积,绝大多数嵌入式项目都会在编译和链接时开启以下选项:

  • 编译时:-ffunction-sections-fdata-sections,让每个函数、每个全局变量都放入自己独立的段。
  • 链接时:--gc-sections,链接器扫描所有段,如果某个段中的符号没有被任何其他段引用,就把它从最终镜像中删除。

这套组合拳可以非常激进地砍掉死代码,通常能节省 10%–30% 的 Flash 占用。但它有一个副作用:某些“看似未被引用”的关键段也可能被误删。 中断向量表就是重灾区。

中断向量表通常是启动文件中的一个汇编数组,里面填满了 Default_HandlerSysTick_HandlerDMA1_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. KEEPSORT 结合控制顺序

链接脚本还支持 SORT_BY_NAMESORT_BY_ALIGNMENT 等命令,可以与 KEEP 一起使用,在保留段的同时控制它们在内存中的排列顺序。这在需要按特定顺序遍历的插件列表中非常有用。


四、留两个问题给你思考

现在请你停下来,思考这两个链接器层面的问题:

  1. 如果在链接脚本里同时使用了 KEEP(*(.isr_vector))--gc-sections,但某个中断处理函数在源码中没有加 used 属性,编译时就被优化掉了。链接器还能找回它吗?KEEP 在此时还有作用吗?
  2. 链接脚本中的 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:KEEPEXCLUDE_FILE 的优先级?

EXCLUDE_FILE 用于从指定文件中排除某些段的输入,它作用于 KEEP 之前。链接器在解析输入文件时,首先应用 EXCLUDE_FILE 过滤掉不需要的段,剩下的段再接受 KEEP 的保护。所以 KEEP 不能挽回已被 EXCLUDE_FILE 排除的段。如果你需要保留某个被排除文件中的段,就必须修改或移除 EXCLUDE_FILE 指令。KEEP 只能保护那些已经进入链接器视野的段不被垃圾回收,而不是夺回已被过滤掉的段。


好了,第 29 招我们就彻底吃透了。下次当你开启 --gc-sections 享受小体积快感时,别忘了用 KEEP 把中断向量表和关键处理器锁死,别让链接器好心干了坏事。

如果今天的内容让你对链接脚本有了更强的掌控感,欢迎转发和点赞。下一篇我们继续挖:链接脚本中用 ASSERT 在链接时校验地址范围与对齐。 咱们不见不散!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

OneChan嵌入式

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值