链接脚本深入:处理孤儿 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 文件中。程序依然能跑。
但依赖链接器自动安排,不能适应所有情况:
- 源码可能引用 section 相关的符号。 比如
.datasection 就需要把起始符号(_sdata)和结束符号(_edata)引出,供启动代码拷贝数据使用。这种情况就必须显式安排。 - 对运行地址有要求。 比如向量表所在的 section,芯片可能有特殊的地址要求。
- 链接器自动安排的行为可能随版本变化。 一旦升级工具链,原本"恰好能跑"的东西可能突然出问题。
所以为了保证所有 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)
} > RAMALIGN(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