第三章 链接脚本与内存布局
说实话,很多嵌入式开发者写了几年代码,对链接脚本还是一知半解。我刚开始做嵌入式那会儿也是这样——编译器帮我搞定了,我干嘛要管它?直到有一次,我的程序在SRAM里跑得好好的,移植到Flash就不行了,折腾了两天才发现是链接脚本里地址配错了。嗯,从那以后,我再也不敢小看这个.ld文件了。
链接脚本到底在干什么?
说白了,链接脚本就是一张地图。它告诉链接器:你的代码该放哪儿,数据该放哪儿,堆栈又该在哪儿。没有这张地图,编译器就只能按默认规则来,这在简单项目里还行,一旦涉及到Bootloader、应用程序分区、或者内存紧张的情况,你就得亲自上手了。
我个人习惯把链接脚本看作「内存的分配方案」。你想想看,MCU的内存空间就那么大,Flash和SRAM各占一块,怎么划分给代码段、数据段、BSS段、堆和栈,全由链接脚本说了算。
内存分区:代码段、数据段、BSS段、堆栈
先快速过一下这几个概念,后面写脚本时你会反复用到它们。
| 段名称 | 存储位置 | 内容 |
|---|---|---|
| .text(代码段) | Flash(只读) | 程序指令、常量字符串 |
| .rodata(只读数据段) | Flash(只读) | const修饰的全局变量 |
| .data(数据段) | Flash存储,运行时拷贝到SRAM | 已初始化的全局/静态变量 |
| .bss(BSS段) | SRAM(运行时清零) | 未初始化或初始化为0的全局/静态变量 |
| 堆(Heap) | SRAM | 动态分配(malloc) |
| 栈(Stack) | SRAM | 局部变量、函数调用 |
这里有个关键点:.data段为什么要在Flash和SRAM各存一份?因为MCU上电时SRAM里的数据是随机的,必须从Flash把初始值拷贝过去。这个拷贝动作,通常由启动文件(startup.s)里的代码完成。我在项目中遇到过有人忘了实现这个拷贝,结果全局变量全是乱的,查了一整天。
核心要点:BSS段在运行时必须清零,.data段必须从Flash拷贝到SRAM。这两个动作缺一不可,否则程序行为不可预测。
如何自定义链接脚本?
好,现在我们来动手写一个。以STM32为例,假设芯片有512KB Flash(起始0x08000000)和128KB SRAM(起始0x20000000)。
/* memory.ld - 自定义链接脚本示例 */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
/* 代码段:放在Flash */
.text :
{
*(.isr_vector) /* 中断向量表 */
*(.text*) /* 程序代码 */
*(.rodata*) /* 只读数据 */
_etext = .; /* 代码段结束地址 */
} > FLASH
/* 数据段:Flash存储初始值,运行时在SRAM */
.data : AT (ADDR(.text) + SIZEOF(.text))
{
_sdata = .; /* 数据段起始地址 */
*(.data*)
_edata = .; /* 数据段结束地址 */
} > SRAM
/* BSS段:运行时清零 */
.bss :
{
_sbss = .; /* BSS段起始地址 */
*(.bss*)
*(COMMON)
_ebss = .; /* BSS段结束地址 */
} > SRAM
/* 堆和栈 */
.heap (NOLOAD) :
{
. = ALIGN(8);
_heap_start = .;
. = . + 0x4000; /* 16KB堆 */
_heap_end = .;
} > SRAM
.stack (NOLOAD) :
{
. = ALIGN(8);
_stack_top = .;
. = . + 0x1000; /* 4KB栈 */
_stack_bottom = .;
} > SRAM
}
这段脚本里,我用了几个符号变量:_etext、_sdata、_edata、_sbss、_ebss。这些符号在启动文件里会被引用,用来实现数据拷贝和BSS清零。我曾经见过有人把符号名拼写错了,结果链接时报未定义错误,找了半天才发现是少了个下划线。
小技巧:链接脚本里的符号变量,在C代码里可以用extern声明后直接取地址。比如:extern uint32_t _sdata;,然后&_sdata就是数据段的起始地址。
避坑指南:我踩过的几个坑
写链接脚本时,有几个地方特别容易出问题。我一个个说。
- 地址对齐问题:ARM Cortex-M要求中断向量表必须4字节对齐,有些MCU甚至要求512字节对齐。我遇到过因为没对齐,上电后直接跑飞的情况。
- Flash和SRAM地址写反:这个错误很蠢,但我确实犯过。把代码段放到了SRAM里,结果程序下载进去一运行就死机——因为SRAM掉电不保存。
- 堆栈大小设置不合理:默认的堆栈大小往往偏大或偏小。我建议先设大一点(比如栈8KB、堆16KB),跑起来后用
__heap_limit和栈填充模式检查实际使用量,再调整。 - NOLOAD关键字忘记加:堆和栈不需要在镜像文件中占用空间,必须加上
(NOLOAD),否则生成的.bin文件会包含一堆无意义的零。
警告:千万不要在链接脚本里把不同段的地址范围重叠!比如代码段占用了0x08000000~0x08010000,数据段又映射到同一片Flash区域。链接器不会报错,但运行时数据会被代码覆盖,后果很严重。
实际项目中的应用
在我做的一个Bootloader项目中,需要把Flash分成三块:Bootloader区(64KB)、应用程序区(384KB)、参数存储区(64KB)。链接脚本就得分别写两个——一个给Bootloader用,一个给应用程序用。
Bootloader的链接脚本只包含Bootloader区的地址范围,应用程序的链接脚本则从0x08010000开始。这样两个固件互不干扰,各自独立编译、独立下载。
/* 应用程序的链接脚本片段 */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08010000, LENGTH = 384K
SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
你看,只是改了个起始地址,整个应用程序就乖乖地跑在Bootloader后面了。这就是链接脚本的威力——它决定了你的程序在内存里的「住址」。
总结一下
链接脚本不是那种天天要碰的东西,但一旦需要,它就是关键中的关键。我个人建议每个嵌入式开发者都亲手写一遍链接脚本,哪怕只是改改地址和大小。只有亲手踩过坑,才能真正理解内存布局是怎么回事。
下一章我们会讲启动文件的编写,到时候你会看到链接脚本里定义的符号是怎么被C代码和汇编代码使用的。嗯,那才是真正把整个流程串起来的时候。