ARM 链接脚本详解
ARM链接脚本深度解剖:从内存布局到启动初始化的全链路掌控 🧩
你有没有遇到过这样的情况?代码明明编译通过了,烧录进MCU后却像“死机”一样毫无反应——既不进
main
,也不跳中断。或者某天突然链接报错:“SRAM overflowed by 1KB”,可你只是加了个日志缓冲区而已…… 😤
这类问题,90%都出在 链接脚本 上。
在ARM嵌入式开发的世界里,链接脚本(Linker Script)就像一位幕后指挥家——它不直接参与演奏(写逻辑),但整个交响乐能否和谐奏响,全看它的调度是否精准。尤其是当你使用Cortex-M系列微控制器(如STM32、NXP LPC、GD32等)进行裸机或RTOS开发时,这份
.ld
文件几乎决定了你的程序“生死”。
今天,我们就来彻底拆解这个常被忽视却又至关重要的组件:GNU
ld
链接脚本在ARM平台下的工作原理、关键结构和实战技巧。不是泛泛而谈语法,而是带你走进
从编译输出到可执行镜像生成的全过程
,理解每一个符号背后的运行时意义。
准备好了吗?我们从一个最真实的工程痛点开始讲起👇
内存资源有限,如何让代码“各得其所”?
想象一下,你正在做一款基于STM32F407的工业网关设备:
- Flash:1MB(0x08000000 起始)
- SRAM:128KB(0x20000000 起始)
- 还有一个外部SPI Flash用于存储配置和日志
现在你要把Bootloader、应用固件、网络协议栈、加密模块全都塞进去。问题是:这些代码和数据该放在哪?哪些必须放Flash?哪些可以动态加载?堆栈又该设多大?
操作系统会帮你管理内存?抱歉,在很多实时控制场景中,我们跑的是 裸机系统 或者轻量级RTOS(比如FreeRTOS),没有虚拟内存,也没有页表机制。一切地址映射,都得靠你自己定义。
这时候,就需要一张“地图”告诉链接器:
“嘿,
.text放Flash开头,.data初始值也存在Flash里,但运行时要搬到SRAM;.bss不占Flash空间,只在SRAM分配并清零;中断向量表必须对齐到Flash起始位置……”
这张“地图”,就是链接脚本。
而它的两大支柱,是
MEMORY
和
SECTIONS
。
MEMORY:物理内存的建模语言 🔍
先问一个问题:为什么我们需要显式声明
MEMORY
?
因为链接器
并不知道目标芯片有多少RAM、Flash从哪里开始
。它只知道你要链接一堆
.o
文件。如果你不说清楚硬件资源,它可能会把你的重要函数链接到一片根本不存在的内存区域——结果就是:链接成功,烧录失败,调试器连不上。
所以第一步,我们必须用
MEMORY
指令为链接器构建一个“内存沙盘”。
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1M
SRAM (rwx): ORIGIN = 0x20000000, LENGTH = 128K
}
这段代码的意思是:
-
定义两个内存块:
FLASH和SRAM -
ORIGIN是起始地址(别忘了ARM Cortex-M复位后默认从0x08000000取向量表) -
LENGTH是大小,支持单位K/M -
(rx)表示该区域可读(r)、可执行(x);(rwx)还多了写权限
📌 小贴士 :这里的属性只是提示,并不会强制阻止你在SRAM中执行代码(除非你启用MPU)。但它能在你犯低级错误时给出警告,比如试图在只读Flash中修改变量。
更复杂的系统呢?比如STM32H7这种带DTCM/ITCM的高性能MCU?
完全可以扩展:
MEMORY
{
FLASH_BANK1 (rx) : ORIGIN = 0x08000000, LENGTH = 1024K
FLASH_BANK2 (rx) : ORIGIN = 0x08100000, LENGTH = 1024K
ITCM_RAM (rx) : ORIGIN = 0x00000000, LENGTH = 64K /* 零等待指令访问 */
DTCM_RAM (rwx): ORIGIN = 0x20000000, LENGTH = 128K /* 数据零等待 */
SRAM1 (rwx): ORIGIN = 0x24000000, LENGTH = 256K
}
看到没?你可以把不同类型的内存分开管理。甚至可以让某些关键函数运行在ITCM中,实现真正的确定性执行——这对电机控制、音频处理这类时间敏感任务至关重要 ⚡️
但注意:
MEMORY
本身只是“容器”。真正决定哪个段放进哪里的,是接下来的
SECTIONS
。
SECTIONS:段布局的终极控制器 🎛️
如果说
MEMORY
是画了一张地图,那
SECTIONS
就是在这张图上盖房子的人。
它的核心职责有三个:
- 把所有输入文件中的同名段合并成一个输出段;
- 指定每个输出段放在哪个内存区域;
- 在特定位置插入符号(symbols),供后续代码引用。
我们来看一个典型定义:
SECTIONS
{
.isr_vector :
{
KEEP(*(.isr_vector))
} > FLASH
这一小段其实藏着不少门道:
-
.isr_vector是输出段名; -
{}内列出要包含的输入段,这里是所有目标文件中的.isr_vector段; -
KEEP()是防止被“垃圾回收”掉——即使没有其他地方引用它,也要保留; -
> FLASH表示这个输出段应放置在前面定义的FLASH区域中。
💡 为什么需要
.isr_vector
?因为在ARM Cortex-M中,启动时CPU会自动从地址
0x08000000
读取主栈顶,然后跳转到复位向量。如果中断向量表没对齐到这里,板子根本启动不了!
继续往下看:
.text :
{
*(.text)
*(.text*)
*(.glue_7)
*(.glue_7t)
. = ALIGN(4);
} > FLASH
这里做了几件事:
-
合并所有
.text段(函数代码); -
加上
.text.*(比如.text.startup、.text.isr等编译器生成的特殊段); - 包含ARM特有胶水代码(用于Thumb状态切换);
-
最后用
. = ALIGN(4);确保当前地址4字节对齐——这是ARM架构的基本要求。
同样的模式出现在
.rodata
中:
.rodata :
{
*(.rodata)
*(.rodata*)
. = ALIGN(4);
} > FLASH
常量字符串、
const
变量都会归入此处,节省宝贵的RAM资源 ✅
那么问题来了:
.data
和
.bss
怎么办?它们可是要在RAM里运行的啊!
这就涉及到链接脚本中最容易让人困惑的概念之一: 加载地址 vs 运行地址 。
LMA 与 VMA:代码的“双重身份”之谜 🕵️♂️
让我们思考这样一个现实:
全局变量
int g_flag = 1;的初始值1必须保存在非易失性存储器中(否则断电就丢了),但它又必须在SRAM中运行(因为CPU只能读写RAM)。
怎么解决?答案是: 分离存储与执行地址 。
这就是
AT>
操作符的意义所在。
.data :
{
_sdata = .;
*(.data)
. = ALIGN(4);
_edata = .;
} > SRAM AT> FLASH
拆解一下:
-
> SRAM→ 运行地址(VMA, Virtual Memory Address):程序运行时,.data段位于SRAM; -
AT> FLASH→ 加载地址(LMA, Load Memory Address):该段的数据实际存放在Flash中; -
_sdata = .→ 当前.代表VMA位置,即SRAM中的起始地址; - 而链接器会自动将这部分内容复制到Flash中相应偏移处,作为初始镜像的一部分。
换句话说:当你生成
.bin
文件时,
.data
的原始数据是紧跟在
.text
后面的(在Flash里);但一旦程序启动,就需要有人把它搬移到SRAM对应的位置。
谁来做这件事?—— 启动代码(startup code)!
而它搬移的依据,正是链接脚本中定义的那些“魔法符号”。
符号定义:链接期与运行期的桥梁 🌉
看看这几个符号:
_sdata = .; /* 当前地址赋给_sdata */
_edata = .;
_sbss = .;
_ebss = .;
_stack_top = ORIGIN(SRAM) + LENGTH(SRAM);
它们看起来像是普通的赋值语句,但实际上,它们会被链接器导出到最终的符号表中,变成全局可见的标签。
这意味着你可以在C或汇编中这样使用它们:
extern uint32_t _sidata, _sdata, _edata;
虽然
_sidata
没有在脚本中显式定义,但链接器会根据
.data
段的 LMA 自动推导出来(也就是它在Flash里的地址)。
于是,典型的
.data
初始化流程就成了:
void copy_data_init(void)
{
uint32_t *src = &_sidata; // Flash中的源地址
uint32_t *dst = &_sdata; // SRAM中的目标地址
while (dst < &_edata) {
*dst++ = *src++;
}
}
是不是很像CRT(C Runtime)的初始化过程?没错,这就是你在调用
main()
之前,系统默默为你做的事。
同理,
.bss
段虽然不占用Flash空间,但需要清零:
void clear_bss(void)
{
uint32_t *dst = &_sbss;
while (dst < &_ebss) {
*dst++ = 0;
}
}
这些操作通常在
Reset_Handler
中完成:
Reset_Handler:
ldr sp, =_stack_top
bl copy_data_init
bl clear_bss
bl SystemInit
bl main
bx lr
看到了吗?整个C环境的建立,依赖的就是链接脚本提供的这些符号。少了任何一个,全局变量可能就是随机值,程序行为完全不可预测 ❌
实战案例:优化性能的关键一招 💡
讲个真实故事。
我曾参与一个高速采样项目,ADC以2Msps速率采集信号,DMA搬运到缓冲区,再由DSP算法实时滤波。但每次运行都出现周期性卡顿,分析发现是缓存未命中导致延迟波动。
后来我们做了什么?把核心滤波函数放到DTCM中!
.dtcm_filter_func :
{
KEEP(*(.dtcm_filter_func))
} > DTCM_RAM
并在C代码中标注:
__attribute__((section(".dtcm_filter_func")))
void fast_dsp_filter(int16_t *input, int16_t *output, size_t len)
{
// 数百行内联汇编+展开循环
}
效果立竿见影:函数执行时间稳定在±1 cycle以内,彻底消除抖动。
这背后的技术支撑,依然是链接脚本的精细控制能力。
类似的技巧还有很多:
- 把中断服务程序(ISR)放入ITCM,减少响应延迟;
- 将大数组放入外部QSPI Flash,启用XIP(eXecute In Place)直接执行;
- 使用双Bank Flash实现IAP(In-Application Programming)无缝升级;
而这一切的前提,是你能准确地在链接脚本中描述内存拓扑和段分布。
常见陷阱与调试策略 🛠️
❌ 场景一:程序无法启动
现象:下载后JTAG连不上,或者停在HardFault。
排查思路:
-
检查中断向量表位置
ld .isr_vector : { KEEP(*(.isr_vector)) } > FLASH
确保它确实是第一个段,且Flash起始地址正确(通常是0x08000000)。 -
验证
_stack_top是否合理
ld _stack_top = ORIGIN(SRAM) + LENGTH(SRAM);
如果SRAM是128KB,则栈顶应为0x20020000。若误写成0x20000000,会导致栈空间不足或覆盖其他数据。 -
查看.map文件确认符号地址
bash arm-none-eabi-objdump -t firmware.elf | grep _stack_top
❌ 场景二:RAM溢出
报错信息:
region `SRAM' overflowed by 2048 bytes
怎么办?
-
分析
.map文件找出大户
bash arm-none-eabi-size --format=SysV firmware.elf
或打开.map搜索.bss段贡献者。 -
优化大数组存储方式
- 改为动态分配(配合heap);
- 使用__attribute__((section(".my_zi_section")))移到特定ZI段;
- 对于调试日志缓冲区,考虑环形队列+外部存储。 -
启用链接时优化
编译选项加上:
makefile CFLAGS += -ffunction-sections -fdata-sections LDFLAGS += -Wl,--gc-sections
可有效剔除未使用的函数和变量。
✅ 推荐最佳实践清单
| 项目 | 建议 |
|---|---|
| 命名规范 | 所有自定义符号以下划线开头(避免与用户变量冲突) |
| 对齐处理 |
每个段结尾添加
. = ALIGN(4);
|
| 溢出防护 |
使用
ASSERT
主动检测
|
| ```ld | |
| ASSERT(_ebss <= ORIGIN(SRAM) + LENGTH(SRAM), “SRAM overflow!”) | |
| ``` | |
| 跨平台适配 | 提取公共参数为变量 |
| ```ld | |
| FLASH_ORIGIN = 0x08000000; | |
| FLASH_SIZE = 512K; | |
| MEMORY { FLASH (rx) : ORIGIN = FLASH_ORIGIN, LENGTH = FLASH_SIZE } | |
| ``` | |
| 调试支持 |
开发版保留
.debug_*
段,发布版移除以减小体积
|
高阶玩法:定制段与高级特性 🔥
你以为链接脚本只能处理标准段?太天真了!
你可以创建任意自定义段,实现各种巧妙设计:
📍 日志专用段(Log Section)
.log_buf ALIGN(4) :
{
KEEP(*(.log_buf))
} > SRAM
C代码中:
__attribute__((section(".log_buf"), used))
char g_log_buffer[1024];
// 外部工具可以直接读取此段内容用于诊断
📍 固件元信息段
.fw_meta : {
LONG(0x1A2B3C4D) /* Magic Number */
SHORT(__DATE_YEAR__) /* 编译年份 */
SHORT(__DATE_MON__)
*(.fw_version)
} > FLASH
可用于版本校验、安全启动等场景。
📍 多核共享内存段(适用于Cortex-M4/M0+双核架构)
.shmem (rwx) :
{
. = ALIGN(4);
_shmem_start = .;
*(.shmem)
. = ALIGN(4);
_shmem_end = .;
} > SHARED_SRAM
Core1和Core2通过该区域通信,配合邮箱或事件标志即可实现高效协同。
工程视角:链接脚本的生命周期管理 🔄
在一个成熟的嵌入式项目中,链接脚本不应是“一次性作品”。它应该具备:
- 可配置性 :同一套代码支持多种硬件配置(如512K Flash版 vs 1M Flash版);
- 可测试性 :能通过自动化脚本验证内存布局合理性;
- 可追溯性 :版本控制系统中清晰记录变更原因。
推荐做法:
- 模板化处理
创建
template.ld.in
,使用CMake变量替换:
ld
MEMORY
{
FLASH (rx) : ORIGIN = @FLASH_ORIGIN@, LENGTH = @FLASH_SIZE@
SRAM (rwx): ORIGIN = @SRAM_ORIGIN@, LENGTH = @SRAM_SIZE@
}
构建时自动生成具体脚本。
- 静态检查集成
写个Python脚本解析
.map
文件,检查:
- 栈空间是否小于最小阈值(如4KB);
-
.text
是否超过Flash容量的80%;
- 关键函数是否落在预期内存区域。
- 文档化维护
在README中说明:
- 每个自定义段的作用;
- 符号命名规则;
- 修改影响范围(例如调整SRAM布局需同步更新启动代码)。
结语:掌握链接脚本,才是真正掌控系统底层 🎯
当你第一次手动修改
.ld
文件并成功引导程序时,那种“我终于看懂了整个启动链条”的感觉,是无与伦比的。
链接脚本不只是一个配置文件。它是:
- 内存资源的宪法 :规定谁可以住在哪;
- 性能优化的杠杆 :通过合理布局撬动极致效率;
- 系统稳定的基石 :一个错误的符号就能让整个系统瘫痪;
- 工程师思维的体现 :从抽象模型到物理实现的完整闭环。
下次当你面对一个新的MCU型号时,不妨先静下心来读一遍它的链接脚本。你会发现,那里藏着通往系统灵魂的大门 🔑
而现在,你已经拿到了钥匙。
更多推荐



所有评论(0)