大目熊 · 1 天前 · 广东

MCU 链接脚本“升级”

链接脚本深入:处理孤儿 Section 与对齐问题

一、从"能跑"到"严谨"

在上一期中,我们完成了一个最简单的链接脚本,只安排了程序最核心的几个 section:

  • .text
  • .rodata
  • .data
  • .bss
  • .isr_vector(芯片要求的中断向量表)

实测表明,使用该脚本,程序确实能够正常跑起来。

但一个真实的项目工程,除了上述必要的 section 之外,还包含许多其他的 section——比如调试相关的 .debug_* sections、.comment、.ARM.attributes 等等。这些 section 没有在链接脚本中显式安排,被称为孤儿 section(Orphan Section)。

完整视频流程:
【MCU 链接脚本 (二)】 https://www.bilibili.com/vide...


二、什么是孤儿 Section?

根据 GNU LD 官方手册的说明,孤儿 section 最终会被链接器根据一定规则自动安置:

  • 要么归入同名的 output section
  • 要么由链接器创建一个合适的 output section 来存放

也就是说,即使不做任何处理,孤儿 section 最终也会出现在 ELF 文件中。程序依然能跑。

但依赖链接器自动安排,不能适应所有情况:

  1. 源码可能引用 section 相关的符号。 比如 .data section 就需要把起始符号(_sdata)和结束符号(_edata)引出,供启动代码拷贝数据使用。这种情况就必须显式安排。
  2. 对运行地址有要求。 比如向量表所在的 section,芯片可能有特殊的地址要求。
  3. 链接器自动安排的行为可能随版本变化。 一旦升级工具链,原本"恰好能跑"的东西可能突然出问题。

所以为了保证所有 section 都能被妥善处理,我们需要先搞清楚:程序中到底有哪些 section 没有被处理?

难道要逐个查看每个目标文件和静态库的 section 清单吗?显然不需要。


三、让链接器主动"报料"

我们可以在链接标志位中添加孤儿 section 处理的警告选项:

-Wl,--warn-orphan

这样在链接阶段,凡是没有在链接脚本中显式安排的 input section,链接器都会发出警告,并明确告知该 section 最终被放置到了哪个 output section。

编译后可以看到大量警告输出,每个目标文件或静态库中没有被安排的 section 都会列出来。哪些孤儿 section 没处理,一目了然。


四、Section 的两大分类

要处理这些孤儿 section,我们需要先建立一个基本认知。从用途上,section 可以分为两大类:

第一类:程序运行时需要的

包括 .text、.rodata、.data、.bss 等。

它们的共同特征是带有 SHF_ALLOC(A 标志)。使用 readelf -S 工具读取 ELF 文件信息时,最后一列 flags 中带有 A 的,就是 ALLOC 的意思。

这些 section 在链接脚本中需要分配具体的内存区域(Flash 或 RAM),并且会被烧录到 Flash 中。程序执行期间会直接访问这些 section。

第二类:程序运行时不需要的

包括调试信息(.debug_*)、ARM 属性描述(.ARM.attributes)、.comment 等。

它们不带 SHF_ALLOC 标志。程序运行时永远不会访问,仅由调试器、链接器、readelf 等工具使用。这类 section 不需要分配运行时地址,也不应被烧录到目标设备。

分类总结

类型特征典型 section处理方式
第一类带 SHF_ALLOC(A 标志).text、.rodata、.data、.bss分配内存区域
第二类不带 SHF_ALLOC.debug_*、.ARM.attributes、.comment、.symtab 等显式列出,不分配内存

基于这个分类,处理 section 的原则很清晰:第一类的要分配内存区域,第二类的则不需要分配。


五、逐个处理孤儿 Section

下面按照从第二类到第一类的顺序,逐个处理。

1. .ARM.attributes —— ARM ABI 属性

.ARM.attributes 是 ARM ABI 规定的标准 section,包含了:

  • CPU 架构
  • 浮点 ABI(硬浮点 / 软浮点)
  • 指令集版本

链接器、调试器和运行时库都会读取这些信息。

程序运行时不需要,属于第二类。 在链接脚本中显式列出来即可,不需要分配任何内存区域:

.ARM.attributes 0 : { *(.ARM.attributes) }

2. .debug_* —— 调试信息

所有 .debug_* 开头的 sections 都是调试器使用的,程序运行时完全不需要。

同样在链接脚本中显式列出,不分配内存区域:

.debug_aranges  0 : { *(.debug_aranges) }
.debug_info     0 : { *(.debug_info) }
.debug_abbrev   0 : { *(.debug_abbrev) }
.debug_line     0 : { *(.debug_line) }
.debug_frame    0 : { *(.debug_frame) }
.debug_str      0 : { *(.debug_str) }
.debug_loc      0 : { *(.debug_loc) }
.debug_ranges   0 : { *(.debug_ranges) }
注意: 调试相关的 section 比较多,要一个个显式写出来,而不是用通配符一次性囊括(如 *(.debug*))。这样可以精确控制哪些调试信息被保留、哪些可以丢弃。

3. .comment —— 编译器版本信息

.comment section 包含编译器版本字符串,是给开发者查看的,程序运行时不使用。处理方式相同:

.comment 0 : { *(.comment) }

4. .symtab、.strtab、.shstrtab —— 符号与字符串表

这些 section 由链接器从目标文件中合并生成,供调试器、readelf 等工具使用:

  • .symtab —— 符号表
  • .strtab —— 保存符号名对应的字符串
  • .shstrtab —— section 名字的字符串表

这些 section 都不是程序运行时需要的。处理方式同样是显式列出、不分配内存区域:

.symtab        0 : { *(.symtab) }
.strtab        0 : { *(.strtab) }
.shstrtab      0 : { *(.shstrtab) }

5. .ARM.exidx 与 .ARM.extab —— 异常处理展开表

这两个是 ALLOC 标志的 section,属于第一类。

它们是 ARM 架构用于 unwind 机制进行栈回溯的元数据,主要用于 MCU 崩溃时追踪问题。

为什么裸机项目也需要关注它?

虽然 MCU 裸机程序一般不会使用 unwind 机制进行栈回溯(因为太"重"了),即使引入 C++,一般也会禁用异常和 RTTI,同样不依赖它。

但是——"用不到"不代表"不会生成"。

  • 编译器可能默认开启 -funwind-tables 标志
  • 链接器(如 LLD)可能自动合成该 section
  • C/C++ 标准库内部也可能引用该 section 的起止符号

因此最稳妥的做法是在链接脚本中显式安排该 section 并导出起止符号,避免链接失败。

.ARM.exidx 与 .ARM.extab 的关系

.ARM.exidx 需要搭配 .ARM.extab 一起使用——一个是索引表,另一个存放具体的展开字节码。只安排其中一个,会导致回溯功能不完整。

.ARM.extab :
{
    *(.ARM.extab* .gnu.linkonce.armextab.*)
} > RAM

.ARM.exidx :
{
    __exidx_start = .;
    *(.ARM.exidx* .gnu.linkonce.armexidx.*)
    __exidx_end = .;
} > RAM

实际项目中的栈回溯方案

实际 MCU 裸机项目中,崩溃时的问题回溯通常依赖异常栈帧解析和栈扫描(stack walk),而不是 unwind 方式。如果对 MCU HardFault 回溯有兴趣,可以查看相关专题。


六、验证:所有警告消除

现在,所有产生警告的 section 都已显式安排完成。

删除编译产物,重新编译——没有任何警告。

查看生成的 map 文件,可以确认每个 section 的布局与链接脚本中的描述完全一致。


七、对齐问题:让确定性更强

为什么要关注对齐?

对于 32 位 MCU,数据访问最好保证 4 字节对齐。虽然部分芯片支持非对齐或半字访问,但非对齐访问通常会带来额外的总线周期,降低执行效率。

所以,.text、.data、.bss 等核心 section 的运行地址和加载地址都应保证 4 字节对齐。

查看 map 文件,目前这些 section 已经是 4 字节对齐的了。那是不是就没问题呢?

官方手册怎么说?

查阅官方手册关于 section 对齐的描述:

output section 的运行地址会适应该 section 的对齐要求,而且不会低于包含的任何一个 input section 的对齐要求。

section 的对齐要求通过 ALIGN 指定。目前没有显式指定,所以链接器会把 input section 中最严格的对齐当作 output section 的对齐要求。

使用 readelf 工具查看 input section,最后一列就是对齐值。可以看到,最严格的对齐要求就是 4,所以最终 output section 按 4 字节对齐。

问题所在

但如果后面某个目标文件的对齐属性发生变化——比如所有 input section 都是 1 字节对齐的——那这里的 output section 就无法保证 4 字节对齐了。

依赖"恰好对齐"是不可靠的。 链接脚本应该描述"你想要什么",而不是"现在恰好是什么"。

解决方案:显式声明对齐

最稳妥的做法是在每个关键 section 定义中,使用 ALIGN 显式声明对齐要求:

.text :
{
    . = ALIGN(4);
    *(.text*)
} > FLASH

.data :
{
    . = ALIGN(4);
    *(.data*)
} > RAM AT > FLASH

.bss :
{
    . = ALIGN(4);
    *(.bss*)
    *(COMMON)
} > RAM

ALIGN(4) 会将 output section 的起始地址向上对齐到 4 字节边界。即使所有 input section 的对齐要求都小于 4,链接器也会强制 output section 起始地址对齐到 4 字节。

这是确定性的、可预期的行为。

加载地址的对齐

加载地址默认和运行地址一致,所以只要运行地址对齐,加载地址就是对齐的。

如果使用 AT > 指定内存区域,该加载地址同样会按照 section 的对齐要求进行调整。


八、与 CubeMX 默认脚本的对比

现在我们的链接脚本已经从"能跑"升级到了"严谨"。所有 section 都显式安排,对齐行为有明确约束。

最后对比一下 CubeMX 默认生成的链接脚本,看看有什么区别:

1. 堆空间

CubeMX 工程会分配堆空间:

._user_heap_stack :
{
    . = ALIGN(8);
    PROVIDE ( end = . );
    PROVIDE ( _end = . );
    . = . + _Min_Heap_Size;
    . = . + _Min_Stack_Size;
    . = ALIGN(8);
} >RAM

但 MCU 裸机工程应该尽量避免使用 malloc。动态内存在中断上下文、实时性要求高的场景下不可控,而且容易产生碎片。如果确实需要动态内存,建议用对象池(memory pool)或静态分配代替。所以我们的脚本中没有堆。

2. .preinit_array、.init_array、.fini_array

这三个是 C/C++ 全局构造函数和析构函数的函数指针表,由 __libc_init_array() 在 main 函数之前遍历调用(.fini_array 则通过 atexit() 注册、程序退出时反向调用)。

  • 如果项目需要 C++ 代码,就需要这些 section
  • 纯 C 项目如果使用了 __attribute__((constructor)) 也需要

我们的工程目前是纯 C 裸机,暂时不需要,但保留这些可以防止以后引入 C++ 时链接失败。

3. 线程局部存储(TLS)

.tdata 和 .tbss 是线程局部存储的 sections:

  • .tdata —— 存放每个线程独享的、带初始值的全局变量
  • .tbss —— 存放未初始化的版本

裸机单线程环境下完全不需要。但如果使用了 crt 库,crt 内部引用这些 section 相关的符号,所以就需要添加这些 section 和符号。

这里用 PROVIDE 定义相关符号:

PROVIDE(__tdata_start = .);

PROVIDE 是弱符号定义。比起直接定义的方式,PROVIDE 只有在源码没有定义该符号时,链接脚本才会定义它;如果源码中已有定义,则优先使用源码版本。所以这些符号也有可能在源码中定义。

从这里也可以看出,CubeMX 默认工程的链接脚本是兼容 crt 库的。crt 库和 MCU 的启动流程有关,不清楚的话可以了解一下相关机制。


九、总结

链接脚本该涉及的内容基本就这些了。当然还有很多写法是为了兼容旧规范、旧架构,具体情况根据需要再去调整。

工程实践上最重要的一条:打开"孤儿 section"警告,才能尽早发现问题。

把链接脚本写严谨,本质上就是把一些莫名其妙的问题提前消灭掉。搞清楚、写明白,后面换芯片、换工具链、加功能,心里都有底。


附录:关键链接器标志参考

标志作用
--warn-orphan对未显式安排的 section 发出警告
--orphan-handling=error将孤儿 section 视为错误,直接终止链接
-Map=output.map生成 map 文件,查看 section 布局

附录:常用 readelf 命令

# 查看所有 section 的详细信息(含 flags 和对齐)
readelf -S output.elf

# 查看 section 到段的映射
readelf -l output.elf

# 查看符号表
readelf -s output.elf
推荐阅读
关注数
3
文章数
8
业余开发者
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息