4、链接器与链接脚本入门:理解链接器的作用、链接脚本语法、段(section)的概念和内存布局定义
在MCU嵌入式开发中,我们编写的C代码经过编译后生成目标文件(.o),但这些目标文件中的代码和数据还没有被赋予最终的内存地址。链接器(Linker)的核心任务就是将多个目标文件与库文件组合,按照链接脚本(Linker Script)的规则,将各个段(Section)放置到目标MCU的Flash和RAM中,生成最终可执行的二进制镜像。对于TinyML应用而言,模型权重、激活缓冲区、中间计算结果等大块数据的内存布局直接决定了推理能否成功运行。理解链接器与链接脚本,是分析map文件、定位内存热点(如RAM溢出、Flash占用过高)的前提。
上图展示了链接器在嵌入式构建流程中的核心位置:左侧是编译产生的多个目标文件与静态库,右侧是链接器依据链接脚本生成的ELF文件、map文件以及可烧录的二进制文件。链接脚本是链接器的“施工图纸”,它定义了目标MCU的存储器资源(Flash起始地址与大小、RAM起始地址与大小)以及各段如何放置到这些存储器区域中。对于TinyML开发者,理解这张图意味着能够回答一个关键问题:我的模型权重被放到了Flash还是RAM?我的中间变量缓冲区占用了哪块RAM区域?
4.1 链接器的作用与段(Section)的概念
链接器的主要职责可以概括为三个动作:符号解析、重定位和段合并。符号解析是指将目标文件中对函数和全局变量的引用与它们的定义关联起来;重定位是指为每个符号分配最终的内存地址,并修改代码中的地址引用;段合并则是将不同目标文件中属性相同的段(如所有.o文件中的.text段)合并为一个大的段,并统一放置到内存中。
在MCU的编译模型中,常见的段包括:
- .text:存放可执行代码(函数指令),通常位于Flash,只读。
- .rodata:存放只读常量,如字符串字面量、const修饰的全局变量、TinyML模型权重数组(如果声明为const)。位于Flash。
- .data:存放已初始化且非零的全局变量和静态变量。这些变量在程序启动时需要从Flash复制初值到RAM,因此同时占用Flash(存初值)和RAM(运行时的值)。
- .bss:存放未初始化或零初始化的全局变量和静态变量。程序启动时由启动代码将RAM中对应区域清零,只占用RAM,不占用Flash。
- .heap:动态内存分配区域(malloc/free),大小由链接脚本或启动文件定义。
- .stack:函数调用栈区域,存放局部变量、函数调用返回地址等,大小固定。
对于TinyML应用,一个典型的场景是:模型权重数组如果定义为const float weights[] = {...},则会被放入.rodata段,仅占用Flash;如果定义为非const的全局数组,则会被放入.data段,同时占用Flash(存初值)和RAM(运行时的副本),这会导致RAM占用翻倍。另一个常见场景是TensorFlow Lite Micro中的Tensor Arena缓冲区,通常定义为全局数组,被放入.bss段,只占用RAM。
核心原理:链接器并不关心你的代码逻辑是否正确,它只关心如何将各个段放到指定的内存区域。链接脚本中的MEMORY命令定义了可用的内存区域,SECTIONS命令定义了每个段放置到哪个区域、按什么顺序排列、如何对齐。map文件就是链接器执行完这些规则后输出的“账本”,记录了每个段、每个符号的最终地址和大小。
4.2 链接脚本语法精讲:MEMORY与SECTIONS
链接脚本(通常以.ld为后缀)是链接器的控制文件。一个最简的MCU链接脚本包含两个核心命令:MEMORY和SECTIONS。
MEMORY命令用于描述目标芯片的存储器资源。其语法为:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
这里定义了Flash从地址0x08000000开始,大小为512KB,属性为可读可执行(rx);RAM从0x20000000开始,大小为128KB,属性为可读可写可执行(rwx)。对于TinyML场景,如果模型较大,需要确认Flash容量是否足够存放权重;如果Tensor Arena较大,需要确认RAM容量是否足够。
SECTIONS命令用于定义输出文件中的段如何映射到输入文件中的段,以及放置到哪个内存区域。其基本语法为:
SECTIONS
{
.text :
{
*(.text) /* 所有输入文件的.text段 */
*(.rodata) /* 所有输入文件的.rodata段 */
_etext = .; /* 定义符号,标记text段结束地址 */
} > FLASH
.data :
{
_sdata = .; /* data段起始地址 */
*(.data)
_edata = .; /* data段结束地址 */
} > RAM AT> FLASH /* 运行时在RAM,加载时在Flash */
.bss :
{
_sbss = .;
*(.bss)
_ebss = .;
} > RAM
}
上述脚本中,> FLASH表示该段放置在Flash区域;> RAM AT> FLASH表示该段运行时地址在RAM,但加载地址(LMA)在Flash,启动代码需要将数据从Flash复制到RAM。_etext、_sdata等符号是链接器生成的地址标记,供启动文件(startup_*.s)使用,用于完成数据复制和BSS清零。
| 语法元素 | 含义 | TinyML相关说明 |
|---|---|---|
ORIGIN |
内存区域的起始地址 | 需与芯片数据手册一致,错误会导致链接失败或运行异常 |
LENGTH |
内存区域的大小 | 决定模型权重和Tensor Arena的最大可用空间 |
*(.text) |
通配符,匹配所有输入文件中的.text段 | 多个目标文件的代码段会按顺序合并 |
AT> FLASH |
指定加载地址(LMA)与运行地址(VMA)不同 | 用于.data段,初值存储在Flash,运行时复制到RAM |
_sdata等符号 |
链接器生成的地址标记 | 启动代码依赖这些符号完成初始化 |
4.3 判断方法:如何通过链接脚本和map文件定位内存热点
在TinyML开发中,我们通常不需要从零编写链接脚本,而是基于芯片厂商提供的模板进行修改。但理解脚本内容有助于我们分析map文件。以下是利用链接脚本和map文件定位内存热点的操作流程:
- 查看链接脚本中的MEMORY定义:确认Flash和RAM的起始地址与大小。如果芯片型号是STM32F411CEU6,Flash为512KB,RAM为128KB,而你的模型权重为300KB,那么Flash空间可能不足。
- 编译生成map文件:在编译命令中添加
-Wl,-Map=output.map选项(GCC工具链),或在IDE中勾选“Generate map file”。 - 分析map文件中的“Memory Configuration”部分:该部分列出了每个内存区域的名称、起始地址、大小和属性。
- 分析“Linker script and memory map”部分:该部分按地址顺序列出了所有输出段,包括每个段的起始地址、结束地址、大小、占用内存区域以及包含的输入文件。重点关注
.text、.rodata、.data、.bss段的大小。 - 查找大符号:在map文件中搜索占用空间大的符号。例如,搜索
tensor_arena或model_weights,查看它们被分配到了哪个段、起始地址和大小。 - 计算内存使用率:将各段大小相加,与MEMORY中的LENGTH对比,判断Flash和RAM的余量。
经验提醒:在map文件中,.data段会显示两个地址:VMA(运行地址,在RAM中)和LMA(加载地址,在Flash中)。如果发现.data段过大,说明有大量非const的全局变量,可以考虑将它们改为const以节省RAM。
4.4 案例拆解:一个TinyML模型的内存布局分析
假设我们使用STM32F411(Flash 512KB,RAM 128KB)运行一个语音关键词识别模型,模型权重为200KB,Tensor Arena为64KB。编译后生成的map文件片段如下:
Memory Configuration
Name Origin Length Attributes
FLASH 0x08000000 0x00080000 xr
RAM 0x20000000 0x00020000 xrw
Linker script and memory map
.text 0x08000000 0x1a4f8
.text 0x08000000 0x1a4f8 ./src/main.o
...
.rodata 0x0801a4f8 0x32000
.rodata 0x0801a4f8 0x32000 ./src/model.o
...
.data 0x20000000 0x1200
.data 0x20000000 0x1200 ./src/main.o
...
.bss 0x20001200 0x10000
.bss 0x20001200 0x10000 ./src/tflite.o
...
分析过程:
- Flash占用:
.text段约105KB(0x1a4f8字节),.rodata段约200KB(0x32000字节),.data段初值约4.5KB(0x1200字节)。Flash总占用约309.5KB,剩余约202.5KB,满足需求。 - RAM占用:
.data段运行时占用4.5KB,.bss段占用64KB(0x10000字节,即Tensor Arena)。RAM总占用约68.5KB,剩余约59.5KB。如果后续需要增加Tensor Arena大小以容纳更大的模型中间结果,当前剩余空间是足够的。 - 热点定位:
.rodata段是Flash占用的主要来源,对应模型权重。如果希望减小Flash占用,可以考虑模型量化(从float32转为int8),将权重从200KB降至50KB。.bss段是RAM占用的主要来源,对应Tensor Arena。如果RAM紧张,可以尝试减小Tensor Arena或优化内存复用策略。
进一步,我们可以在map文件中搜索tensor_arena符号,确认其具体地址和大小:
.bss.tensor_arena
0x20001200 0x10000 ./src/tflite.o
这确认了Tensor Arena位于RAM起始地址0x20001200处,大小为64KB。如果后续调试中发现内存越界,可以通过查看该地址附近的变量来定位问题。
风险提示:不要随意修改链接脚本中的内存区域大小或起始地址,除非你完全理解芯片的存储器映射。错误的修改可能导致程序无法启动或硬件故障。例如,将RAM的ORIGIN改为0x20010000(超出实际RAM范围),链接器不会报错,但运行时数据会被写入不存在的地址,导致不可预知的行为。
4.5 常见误区与总结复盘
在链接脚本和内存布局方面,TinyML开发者常犯以下错误:
- 误区一:忽略
.data段的Flash占用。很多人只关注.rodata段,却忘了.data段的初值也存储在Flash中。如果一个全局数组很大且非const,它既占用RAM又占用Flash。 - 误区二:混淆VMA与LMA。在map文件中,
.data段显示两个地址,如果误将LMA当作运行地址,会导致调试时查看变量值出错。 - 误区三:堆栈大小设置不当。链接脚本中通常定义了
_Min_Heap_Size和_Min_Stack_Size。TinyML推理过程中如果使用递归或深层函数调用,栈溢出会导致HardFault。如果使用malloc动态分配Tensor Arena,堆大小不足会导致分配失败。 - 误区四:使用默认链接脚本不检查。芯片厂商提供的默认链接脚本通常适用于通用场景,但可能没有为TinyML优化。例如,默认脚本可能将
.rodata放在.text之后,如果Flash的某些区域有特殊要求(如OTP),需要手动调整。
总结复盘:链接器与链接脚本是嵌入式开发的“底层规则”,它们决定了代码和数据的最终归宿。对于TinyML应用,掌握以下要点即可有效分析内存热点:
- 明确
.text、.rodata、.data、.bss各段的内存归属和大小。 - 学会阅读map文件中的Memory Configuration和Linker script and memory map部分。
- 通过搜索大符号(如模型权重、Tensor Arena)快速定位内存占用大户。
- 在修改链接脚本时,始终以芯片数据手册为准,并保留原始脚本备份。