STM32 链接脚本入门:从 Sections 到可执行文件的地址规划
前言
在 MCU 裸机开发中,绝大多数时候我们都在和 C 语言、寄存器、外设驱动打交道,很少有人会主动去碰链接脚本(Linker Script)。毕竟,厂商提供的默认链接文件通常就能跑,Keil、STM32CubeMX 帮你生成好一切,main 函数写完就能点灯。
但当你开始接触 XIP(就地执行)、自定义内存布局、将关键代码或数据放到特定地址、做 OTA 升级,或者需要优化 Flash 占用时,你就会发现:链接脚本不再是可选的附加知识,而是必须掌握的基本功。
本文从一个 STM32F103 工程出发,带你从目标文件的 sections 开始,一步步搞清楚:
- 一个 ELF 文件里到底有哪些 sections,它们是怎么来的;
- 链接器是怎么把一堆
.o和.a整合成一个可执行文件的; - 为什么 MCU 开发必须关注地址规划,而 Linux 应用却几乎不用管;
- 如何手写一个面向裸机程序的 GNU ld 链接脚本;
- 运行地址、加载地址、
.data拷贝、.bss清零、中断向量表这些概念如何串在一起。
完整视频流程:
【MCU 链接脚本写法 (一)】 https://www.bilibili.com/vide...
一、从一个工程说起:Sections 是链接的原料
1.1 编译的产物是很多目标文件
一个代码工程在编译过程中,会生成一堆目标文件(.o)和静态库文件(.a)。每一个目标文件里,几乎都包含了若干 sections:
| Section | 用途 |
|---|---|
.text | 存放代码(指令) |
.data | 存放已初始化的全局变量或静态变量 |
.bss | 存放未初始化的全局变量或静态变量 |
.rodata | 存放常量(如字符串字面量) |
除此之外还有很多其他的 sections,比如调试信息、异常/中断相关的 section 等,后面会逐一提到。
1.2 链接器做的事情,本质上是"重新洗牌"
一般情况下,链接器会把每个目标文件里独立的 .text、.data、.bss、.rodata 整合到一起,形成一个统一的 .text、.data、.bss、.rodata,最后生成一个整合好的可执行文件(ELF)。
这种"自动整合"的方式,对大部分情况是 OK 的。特别是 Windows、Linux 应用程序——由于有 MMU,程序跑在虚拟地址上,你基本不用担心链接器是怎么安排地址的,操作系统和加载器帮你兜了底。
1.3 但 MCU 不一样
MCU 是裸机环境,没有 MMU,没有操作系统帮你分配内存。RAM 和 Flash 都是有限的物理资源,程序跑在哪个地址、数据放在哪里,全靠链接脚本说得算。
所以:MCU 程序如何安排地址,是一个必须认真对待的问题。
当然,对于一般的 MCU 开发,芯片厂商会提供默认的链接文件,开发者确实可以不关注链接问题。但当涉及到 XIP、特殊内存区域、IAP/OTA、性能优化时,几乎都绕不开修改链接脚本。了解链接脚本,是底层开发的必备能力。
二、目标文件里到底有什么:用 readelf 看真相
为了讲清楚后面的内容,我们基于之前视频构建的 STM32F103 工程,先看看编译生成的目标文件里到底有哪些 sections。
2.1 先看 main.o 里有什么
用 readelf 工具查看 main.o:
readelf -S main.o可以看到有 .text section,同时还有很多 .text 前缀的 sections。为什么会这样?
2.2 关键编译选项:-ffunction-sections
这是因为编译时带了 -ffunction-sections 标志位。这个标志位的作用是:把每一个函数单独放到一个独立的 section 里,而不是把所有函数的代码都塞进同一个 .text。
举个例子,假设你有三个函数 func_a、func_b、func_c,正常情况下它们会合并在同一个 .text 里。开启 -ffunction-sections 后,它们会分别变成:
.text.func_a.text.func_b.text.func_c
这样做的意义是什么??
配合链接时的 --gc-sections(Garbage Collection,垃圾回收)标志位,链接器就能把没有被用到的函数代码段直接丢弃,从而减小最终程序的体积。
2.3 做个实验验证一下
我们把 -ffunction-sections 和 --gc-sections 去掉,重新编译,看看结果:
- Flash 占用空间变大了;
- 生成的可执行文件里不再有
.text前缀的 sections,所有函数又合并回了同一个.text。
MCU 对程序体积非常敏感,所以这两个标志位在生产环境中应当保留。
2.4 .data 和 .bss 在哪?
回过头看,main.o 里好像没有 .data 和 .bss?那是因为当前代码里还没有定义全局/静态变量。
我们在 main.c 里添加:
int g_init_val = 100; // 已初始化的全局变量 -> .data
int g_uninit_val; // 未初始化的全局变量 -> .bss重新编译后再看,main.o 里就出现了 .data section 和 .bss section。
2.5 .rodata 又是什么?
readelf 的输出里还有一个 .rodata section。回头看 main.c,里面串口输出的字符串常量,就存放在这里。
2.6 剩下的那些 sections
除了上面这些,还有一些调试相关的 sections(如 .debug_info、.debug_line 等),它们供调试器使用,程序运行时完全不需要,暂时不关注。
三、链接脚本的基本框架:SECTIONS 命令
了解了目标文件的 sections 之后,就可以开始编写链接脚本了。
3.1 几个基本概念
- Input Sections:目标文件里的 sections,就是链接脚本的"输入"。
- Output Sections:链接脚本整合后,生成的可执行文件里的 sections,就是"输出"。
一般的逻辑就是:把分散在各个 .o 文件里的 .text 和 .text 开头的 input sections,统一放到 output 的 .text section 里。
3.2 一个最简单的写法
SECTIONS
{
.text :
{
*(.text*)
}
.rodata :
{
*(.rodata*)
}
.data :
{
*(.data*)
}
.bss :
{
*(.bss*)
}
}关键语法解释:
SECTIONS命令:所有 section 的描述都必须放在这个命令里面,它是链接脚本的"主体"。*(通配符):表示所有目标文件。*(.text*):匹配所有目标文件里的.text以及所有.text前缀的 sections(比如.text.func_a)。因为*能匹配空字符串,所以也覆盖了纯.text的情况,可以只写这一条。- 外层的
.text就是 output section 的名字。
其它 sections 照葫芦画瓢,写法完全一致。
四、运行地址 vs 加载地址:理解 MCU 的内存模型
这是链接脚本里最核心、也最容易让人困惑的概念。
4.1 每个 section 其实有两个地址
| 地址类型 | 别名 | 含义 |
|---|---|---|
| 运行地址 | VMA(Virtual Memory Address) | 程序运行时,section 实际所在的地址 |
| 加载地址 | LMA(Load Memory Address) | section 在存储介质上的存放位置 |
注意:运行地址在很多文档里也叫"虚拟地址(VMA)"。在 MCU 裸机环境下没有真正的虚拟地址概念,这里的"虚拟"是 ELF 规范里的历史术语,实际就是"程序认为自己在哪运行"的地址。
4.2 默认情况下两者一致
MCU 代码一般存在 Nor Flash 中,运行时也直接从 Nor Flash 取指执行。因为 Nor Flash 支持 XIP(就地执行),所以 .text 的加载地址和运行地址是一致的。
4.3 什么时候不一致?——以 .data 为例
.data 里存放的是已初始化的全局/静态变量,这些变量在程序运行过程中随时可能改变值。
但是,Flash 是不能随便写入的。所以:
- 编译时:链接器把初始值(比如
g_init_val = 100里的100)存在 Flash 上 → 这是加载地址; - 运行时:启动代码必须把 Flash 里的初始值拷贝到 RAM → 这是运行地址。
这就导致了 .data 的加载地址 ≠ 运行地址。
4.4 在链接脚本中指定
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
}FLASH:可执行、可读、不可写(rx)RAM:可读、可写、可执行(rwx)
定义好内存布局后,就可以在 SECTIONS 里指定每个 section 放到哪块内存:
SECTIONS
{
.text :
{
*(.text*)
} > FLASH
/* 默认情况下加载地址 = 运行地址,无需额外指定 */
.rodata :
{
*(.rodata*)
} > FLASH
.data :
{
*(.data*)
} > RAM AT > FLASH
/* 运行地址在 RAM,加载地址在 FLASH */
.bss :
{
*(.bss*)
} > RAM
}语法要点:
> FLASH:指定运行地址(VMA)在 FLASH。> RAM AT > FLASH:指定运行地址在 RAM,同时显式指定加载地址(LMA)在 FLASH。.data是唯一需要同时指定两者的常见 section,因为它要从 Flash "搬家" 到 RAM。
五、深入理解 .bss:为什么不需要加载地址
.bss 存放的是未初始化的全局/静态变量。
5.1 为什么单独划分?
关键在于:未初始化的变量,在 Flash 中不需要分配空间去存储具体的值。
想一想,int g_uninit_val; 的初始值是什么?C 标准规定必须是 0。既然所有 .bss 变量的初始值都是 0,那完全没必要在 Flash 里存一堆 0 浪费空间。
正确做法是:
- 只在链接阶段记录
.bss总共需要多大空间; - 程序启动时,由启动代码在 RAM 中分配这段空间,并全部清零。
所以 .bss 不需要从 Flash 加载,只需要指定运行地址即可。
5.2 NOLOAD 属性
一般我们还会在 .bss 上加一个 NOLOAD:
.bss (NOLOAD) :
{
*(.bss*)
} > RAMNOLOAD 的作用是移除 PT_LOAD 属性。PT_LOAD 表示这段数据需要被加载到内存。在裸机程序中,加上 NOLOAD 主要是为了防止调试器把 .bss 当成需要加载的数据,错误地用 0 填充或映射,导致仿真调试异常。
六、启动代码与链接脚本的配合:符号约定
MCU 的启动流程需要建立 C 语言运行环境。这要求程序在 main 函数之前,就必须完成:
.data拷贝:把初始值从 Flash 搬到 RAM;.bss清零:把.bss对应的 RAM 区域全部清零。
这部分工作由启动文件(startup_*.s)完成,但启动文件需要链接脚本提供相关地址。
以 STM32CubeMX 默认生成的工程为例,启动文件里会引用一些符号,比如:
_sidata:.data在 Flash 中的起始地址(加载地址)_sdata:.data在 RAM 中的起始地址(运行地址)_edata:.data在 RAM 中的结束地址_sbss:.bss起始地址_ebss:.bss结束地址
这些符号必须由链接脚本提供。方法是在对应 section 里用 . 获取当前地址:
.data :
{
_sdata = .; /* .data 运行地址起始 */
*(.data*)
_edata = .; /* .data 运行地址结束 */
} > RAM AT > FLASH
_bss_start = .; /* .bss 起始地址 */
.bss (NOLOAD) :
{
*(.bss*)
} > RAM
_bss_end = .; /* .bss 结束地址 */对于 .data 的加载地址(Flash 端起始位置),用 LOADADDR() 命令获取:
/* 在 .data 之前定义 */
_data_load_addr = LOADADDR(.data);这样,启动文件就能通过这些符号,正确地完成数据搬运和清零工作。
七、收尾工作:ENTRY、栈指针、中断向量表
主干写完之后,还有三个关键点需要处理。
7.1 ENTRY:指定程序入口
ENTRY(Reset_Handler)把入口点设为复位中断函数。
⚠️ 一个重要提醒:
ENTRY命令的行为只是把入口地址写进 ELF 文件头,供调试器、加载器这类软件工具使用,它并不影响 MCU 启动时进入复位中断函数。换句话说:就算没有这条
ENTRY命令,MCU 也能正常启动并进入复位中断函数。 MCU 的上电/复位行为由 ARM 架构决定——只要正确提供了向量表,就能顺利启动。
7.2 指定栈的起始位置
C 程序的函数调用依赖栈,所以需要指定栈的起始位置。
Cortex-M 架构有一个特点:将中断向量表的第一个 32 位字作为主栈指针(MSP)的初始值。
这个位置通常在启动文件里定义,是一个符号(常见命名为 _estack)。我们需要在链接脚本里为它赋值:
_estack = ORIGIN(RAM) + LENGTH(RAM); /* 栈顶 = RAM 的末端 */因为 Cortex-M 的栈是满递减(Full Descending)的,从高地址向低地址增长,所以栈的起始位置放在 RAM 的最末端。
7.3 中断向量表必须放在 Flash 最开始
为什么?因为 MCU 上电后,硬件会从 Flash 地址 0x08000000 开始读取两个东西:
- 第一个字:初始栈指针(MSP);
- 第二个字:复位异常向量的入口地址。
如果向量表不在最前面,MCU 就找不到复位函数,启动直接失败。
启动文件里通常把向量表放在 .isr_vector section:
.text :
{
KEEP(*(.isr_vector))
*(.text*)
*(.text*)
} > FLASH要点:
.isr_vector必须放在最前面,这样它的地址就是 Flash 的起始地址;- 这里用了
KEEP命令:--gc-sections会回收没有被引用的 sections,而中断向量表里的函数(如各种 IRQ Handler)是由硬件机制调用的,不会被普通代码引用,如果不加KEEP,它们会被误删。
八、编译验证
链接脚本写完后,在 app/CMakeLists.txt 里指定新的链接脚本:
set(LINKER_SCRIPT ${CMAKE_SOURCE_DIR}/STM32F103C8Tx_FLASH.ld)删掉 build 产物,重新编译。
遇到一个典型错误:链接脚本报格式错误。原因是 output section 后面的冒号前面必须有空格:
/* 错误写法 */
.text: {
/* 正确写法 */
.text :
{修正后重新编译,成功。
打开 map 文件,查看内存布局,与链接脚本中指定的布局完全一致。下载到开发板运行,一切正常。
九、一个重要的反思
对于这个简单的程序,使用这个链接脚本运行是没问题的。但事实上,在实际开发中,这么草率地编写链接脚本是不推荐的。
回想一下最开始我们用 readelf 查看目标文件时,除了 .text、.data、.bss、.rodata,还有很多其它的 sections。而在我们写的链接脚本里,大部分都没有被显式安排。
比如:
.ARM.exidx/.ARM.extab(ARM 异常索引表、异常展开表)—— C++ 异常和栈回溯相关;.init_array/.fini_array—— C++ 全局构造函数/析构函数;.eh_frame—— 异常处理帧信息;.comment、.note.*—— 编译器版本等元信息;.debug_*—— 调试信息。
这些 sections 没有被显式处理,链接器虽然会按默认规则放置,但这种"放任不管"的做法在生产环境中可能会带来隐患。
下一节,我们将继续探究这些"漏网之鱼"该怎么处理,并进一步完善这个链接脚本,让它达到工程可用的标准。
附录:关键知识点速查表
| 概念 | 一句话解释 |
|---|---|
| Section | ELF 文件中按用途划分的数据块 |
| Input Section | 目标文件里的 section,链接脚本的输入 |
| Output Section | 链接脚本整合后生成的 section,最终 ELF 的输出 |
| VMA(运行地址) | 程序运行时 section 所在的地址 |
| LMA(加载地址) | section 在存储介质上的存放地址 |
-ffunction-sections | 将每个函数放到独立的 .text.xxx section |
--gc-sections | 链接时回收未被引用的 sections |
KEEP() | 防止 --gc-sections 删除指定的 section |
> RAM AT > FLASH | 运行地址在 RAM,加载地址在 FLASH |
LOADADDR() | 获取某个 section 的加载地址 |
NOLOAD | 移除 PT_LOAD 属性,数据不会被加载器处理 |
ENTRY() | 设置 ELF 入口点,仅影响调试/加载工具 |
MEMORY | 描述目标设备的内存布局 |
SECTIONS | 描述如何把 input sections 映射到 output sections |
下一篇预告:《完善链接脚本:处理那些你忽略了的 sections》