第3章:链接脚本与内存布局——让芯片知道代码该放哪儿
说实话,我刚入行那会儿,觉得链接脚本就是个黑盒子。编译器帮我搞定了,我干嘛要管?直到有一次,我调试一个Bootloader,程序死活跑不起来,折腾了两天才发现——是链接脚本里RAM地址写错了。从那以后,我再也不敢跳过这章了。
链接脚本,说白了就是告诉编译器:你的代码、数据、变量,分别该放到芯片的哪个地址去。没有它,MCU上电后根本不知道从哪里开始执行。
3.1 链接脚本(.ld文件)到底在干什么?
你写好的C代码,经过编译、汇编,变成了一堆目标文件(.o)。这些文件里,代码、已初始化的全局变量、未初始化的全局变量,都混在一起。链接脚本的任务,就是把它们分门别类,放到指定的内存区域。
我习惯把链接脚本理解成一张「搬家清单」:
- 代码段(.text) → 放到Flash里(只读,掉电不丢)
- 数据段(.data) → 初始值放Flash,运行时搬到RAM
- BSS段(.bss) → 直接占RAM,上电清零
- 堆(Heap) → RAM里划一块,给malloc用
- 栈(Stack) → RAM里划一块,给局部变量和函数调用用
核心原则:Flash放只读内容,RAM放读写内容。Bootloader的链接脚本尤其要小心——因为Bootloader本身就在Flash里,它要负责把应用程序的.data段从Flash搬到RAM。
3.2 一个典型的MCU链接脚本长什么样?
以STM32F103为例,我贴一个简化版的.ld文件。别怕,我会逐行解释。
/* 入口点 */
ENTRY(Reset_Handler)
/* 内存区域定义 */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 128K
}
/* 段布局 */
SECTIONS
{
/* 中断向量表 + 代码 */
.isr_vector : ALIGN(4)
{
KEEP(*(.isr_vector))
} > FLASH
.text : ALIGN(4)
{
*(.text)
*(.text*)
*(.glue_7)
*(.glue_7t)
} > FLASH
/* 只读数据 */
.rodata : ALIGN(4)
{
*(.rodata)
*(.rodata*)
} > FLASH
/* 已初始化数据——运行时搬到RAM */
_sidata = .;
.data : AT(_sidata)
{
_sdata = .;
*(.data)
*(.data*)
_edata = .;
} > RAM AT> FLASH
/* BSS段——上电清零 */
.bss : ALIGN(4)
{
_sbss = .;
*(.bss)
*(.bss*)
*(COMMON)
_ebss = .;
} > RAM
/* 堆 */
.heap : ALIGN(8)
{
_sheap = .;
. = . + _Min_Heap_Size;
_eheap = .;
} > RAM
/* 栈 */
.stack : ALIGN(8)
{
_sstack = .;
. = . + _Min_Stack_Size;
_estack = .;
} > RAM
}
嗯,这里要注意几个关键点:
- ENTRY(Reset_Handler):告诉链接器,程序入口是Reset_Handler函数。MCU上电后,从Flash的0x08000000取中断向量表,第一个向量就是栈顶指针,第二个就是Reset_Handler地址。
- MEMORY块:定义了两块物理内存。Flash从0x08000000开始,512KB;RAM从0x20000000开始,128KB。
- .data段的AT属性:这是最tricky的地方。.data段在运行时位于RAM,但它的初始值存储在Flash里。链接器用
_sidata记录Flash里的起始地址,用_sdata和_edata记录RAM里的地址范围。启动代码需要把这些数据从Flash拷贝到RAM。
我曾经踩过的坑:有一次我改了链接脚本,忘了更新_sidata的地址,结果.data段拷贝时源地址指向了错误的位置。全局变量全变成了随机值。排查了整整一个下午才发现是这里的问题。所以,每次改链接脚本,一定要检查启动代码里的拷贝逻辑是否匹配。
3.3 代码段/数据段/BSS段——三兄弟的分工
这三兄弟,每个都有自己的脾气。我整理了一个表格,方便你对照:
| 段名 | 存放内容 | 存储位置 | 运行时位置 | 初始化方式 |
|---|---|---|---|---|
| .text | 函数代码、常量字符串 | Flash | Flash(原地执行) | 无需初始化 |
| .data | 已初始化的全局/静态变量 | Flash(存初始值) | RAM | 启动时从Flash拷贝到RAM |
| .bss | 未初始化的全局/静态变量 | 无(只占RAM) | RAM | 启动时清零 |
为什么会这样设计?你想想看,Flash是只读的,但全局变量需要读写。所以.data段必须有个「影子」在Flash里存初始值,启动时再搬到RAM。而.bss段干脆不占Flash空间,上电直接清零就行。
我个人习惯在启动代码里这样处理:
/* 拷贝.data段 */
extern uint32_t _sidata, _sdata, _edata;
for (uint32_t *src = &_sidata, *dst = &_sdata; dst < &_edata; src++, dst++)
*dst = *src;
/* 清零.bss段 */
extern uint32_t _sbss, _ebss;
for (uint32_t *p = &_sbss; p < &_ebss; p++)
*p = 0;
这段代码必须在进入main()之前执行。如果漏了,你的全局变量初始值全是乱的。
3.4 栈与堆的设置——别让它们打架
栈和堆都放在RAM里,而且它们相向生长。栈从高地址往低地址长,堆从低地址往高地址长。如果设置不当,它们会撞在一起——那就是著名的「栈堆碰撞」,程序会莫名其妙地崩溃。
我一般这样设置:
- 栈大小:根据函数调用深度和局部变量大小来定。对于Bootloader,我通常给1KB~4KB。如果用到RTOS,每个任务栈单独算。
- 堆大小:如果不用malloc,直接给0。很多嵌入式项目其实不需要动态内存分配。用了malloc反而容易出内存碎片。
我的经验:在Bootloader里,我几乎不用堆。所有内存都静态分配。这样既简单又可靠。如果你非要用malloc,记得实现一个健壮的_sbrk函数,并且监控堆的使用情况。
链接脚本里,栈和堆的定义通常放在RAM的最后:
_Min_Heap_Size = 0x200; /* 512字节 */
_Min_Stack_Size = 0x400; /* 1KB */
.stack : ALIGN(8)
{
_sstack = .;
. = . + _Min_Stack_Size;
_estack = .;
} > RAM
注意,栈顶指针_estack会被写入中断向量表的第一个位置。MCU上电时,硬件自动把_estack加载到SP寄存器。所以这个值必须指向有效的RAM地址。
3.5 知识结构图:链接脚本与内存布局全景
下面这张图,把整个流程串起来了。从C代码到最终的二进制文件,每一步都离不开链接脚本的指挥。
3.6 实战中的避坑指南
最后,分享几个我实战中遇到的坑,希望能帮你少走弯路:
- 中断向量表对齐:.isr_vector段必须4字节对齐,否则硬件取向量时会出错。我习惯在段定义前加
ALIGN(4)。 - 栈大小别抠太狠:我曾经为了省RAM,把栈设成256字节。结果一个函数嵌套调用就溢出了。建议至少留512字节,调试阶段可以设大一点,稳定后再优化。
- 链接脚本里的符号别拼错:
_sdata、_edata这些符号,启动代码里extern声明必须和链接脚本里完全一致。大小写、下划线都不能错。我见过有人把_sdata写成sdata,结果链接失败。 - 多段Flash布局:如果你的Bootloader和应用程序共用Flash,记得在链接脚本里给Bootloader分配独立的地址范围。比如Bootloader放0x08000000~0x0800FFFF,应用程序放0x08010000开始。
重要提醒:每次修改链接脚本后,一定要做三件事:1) 重新编译全部文件;2) 检查生成的.map文件,确认各段地址正确;3) 用调试器单步验证启动代码的拷贝和清零逻辑。这三步缺一不可。