4. 链接脚本与内存布局
大家好,我是你们的嵌入式系统讲师。今天我们来聊聊BSP开发中一个绕不开的话题——链接脚本和内存布局。说实话,我刚入行那会儿,觉得这玩意儿就是个黑盒子,反正编译器帮我搞定了。直到有一次,我调试一个诡异的系统崩溃问题,折腾了整整三天,最后发现是栈溢出把关键数据给踩了……嗯,从那以后,我再也不敢轻视链接脚本了。
4.1 链接脚本(.lds)语法入门
链接脚本,说白了就是告诉链接器:你的代码该放哪儿,数据该放哪儿。它是个文本文件,后缀通常是.lds或.ld。
一个最简单的链接脚本长这样:
SECTIONS
{
. = 0x80000000;
.text : { *(.text) }
.data : { *(.data) }
.bss : { *(.bss) }
}
这里.代表当前地址计数器。. = 0x80000000意思是从这个地址开始放东西。.text、.data、.bss就是我们要安排的三个主要段。
我个人习惯把链接脚本分成三部分:
- 内存区域定义(MEMORY命令)
- 段布局(SECTIONS命令)
- 符号导出(PROVIDE等)
来看一个更完整的例子:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1M
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.text :
{
*(.vectors)
*(.text)
*(.rodata)
_etext = .;
} > FLASH
.data : AT (ADDR(.text) + SIZEOF(.text))
{
_sdata = .;
*(.data)
_edata = .;
} > RAM
.bss :
{
_sbss = .;
*(.bss)
_ebss = .;
} > RAM
}
注意看.data段后面有个AT表达式。这表示数据段在运行时放在RAM里,但它的初始值存储在FLASH里。启动代码需要把这些初始值从FLASH拷贝到RAM。我在项目中遇到过有人忘了这个拷贝步骤,结果全局变量全是乱的,排查了好久才发现。
4.2 代码段、数据段、BSS段布局
这三个段是嵌入式程序的核心。我们来逐个拆解:
| 段名 | 内容 | 存储位置 | 特点 |
|---|---|---|---|
| .text | 代码、常量、向量表 | 通常放在FLASH | 只读,可共享 |
| .data | 已初始化的全局/静态变量 | 运行时在RAM,初始值在FLASH | 可读写,需拷贝 |
| .bss | 未初始化的全局/静态变量 | RAM | 可读写,需清零 |
你想想看,为什么要把.text放在FLASH里?因为FLASH是非易失的,掉电不丢数据。而.data和.bss必须放在RAM里,因为程序运行时要修改它们。
这里有个坑:.bss段在启动时必须清零。我曾经接手过一个项目,启动代码里忘了清零.bss,结果每次上电,未初始化的全局变量都是随机值,导致程序行为完全不可预测。嗯,这种bug最难查。
重要提示:启动代码中必须完成两件事:
- 将
.data段从FLASH拷贝到RAM - 将
.bss段清零
这两个步骤通常在Reset_Handler中完成,在跳转到main()之前。
4.3 栈与堆的配置
栈和堆是程序运行时的两个重要内存区域。栈用于函数调用、局部变量;堆用于动态内存分配(malloc/free)。
在链接脚本中,我们通常这样定义栈和堆:
_stack_size = 0x4000; /* 16KB 栈 */
_heap_size = 0x2000; /* 8KB 堆 */
SECTIONS
{
/* ... 其他段 ... */
.stack (NOLOAD) :
{
. = ALIGN(8);
_sstack = .;
. = . + _stack_size;
. = ALIGN(8);
_estack = .;
} > RAM
.heap (NOLOAD) :
{
. = ALIGN(8);
_sheap = .;
. = . + _heap_size;
. = ALIGN(8);
_eheap = .;
} > RAM
}
注意NOLOAD关键字,它告诉链接器这个段不需要加载初始值。栈和堆的内容在运行时才产生,不需要在FLASH里存一份。
我个人习惯把栈放在RAM的最高地址,因为栈是向下生长的。这样栈溢出时,会先碰到其他段,更容易被发现。举个例子:
_estack = ORIGIN(RAM) + LENGTH(RAM); /* 栈顶在RAM末尾 */
.stack :
{
. = ALIGN(8);
. = . + _stack_size;
. = ALIGN(8);
_sstack = .;
} > RAM
这里_estack是栈顶指针,_sstack是栈底。启动代码中,需要把_estack赋给SP寄存器。
避坑指南:我曾经把栈大小设得太小,只有1KB。结果函数嵌套调用一多,栈就溢出了,把旁边的全局变量给覆盖了。程序跑着跑着就莫名其妙地崩溃。后来我用__builtin_frame_address和栈填充模式来检测,才定位到问题。建议至少给栈留4KB以上,具体看你的函数调用深度。
4.4 DDR初始化
DDR初始化是BSP开发中的重头戏。DDR不像SRAM那样上电就能用,它需要一套复杂的初始化序列。
典型的DDR初始化步骤包括:
- 配置DDR控制器:设置时序参数、数据宽度、行列地址等
- 上电序列:CKE拉高、等待稳定时间
- 初始化命令:发送NOP、预充电、刷新等命令
- 模式寄存器配置:设置CAS延迟、突发长度等
- ZQ校准:阻抗匹配校准
- 训练:读写训练,确保信号质量
来看一段简化的DDR初始化代码(以某款Cortex-A处理器为例):
void ddr_init(void)
{
/* 1. 使能DDR时钟 */
DDR_CLK_EN |= (1 << 0);
/* 2. 配置控制器时序 */
DDR_TIMING = 0x12345678; /* 具体值看DDR颗粒手册 */
/* 3. 发送初始化序列 */
DDR_CMD = CMD_NOP;
delay_us(200);
DDR_CMD = CMD_PRECHARGE_ALL;
delay_us(20);
DDR_CMD = CMD_AUTO_REFRESH;
delay_us(10);
DDR_CMD = CMD_AUTO_REFRESH;
delay_us(10);
/* 4. 配置模式寄存器 */
DDR_MR0 = 0x1234; /* CAS=5, BL=8 */
DDR_MR1 = 0x5678; /* 驱动强度等 */
/* 5. ZQ校准 */
DDR_ZQCL = 1;
while(DDR_ZQCL & 1);
/* 6. 读写训练 */
ddr_training();
/* 7. 使能DDR */
DDR_ENABLE = 1;
}
这里每个步骤的延时和命令序列,都必须严格按照DDR颗粒的数据手册来。不同厂商、不同型号的DDR,参数可能完全不同。
个人经验:我在调试DDR时,最喜欢用"写-读-比较"的方法。先往某个地址写一个固定模式(比如0xA5A5A5A5),再读回来比较。如果读出来不对,说明初始化有问题或者硬件连接有误。我曾经用这个方法,发现了一块PCB上DDR数据线虚焊的问题。
DDR初始化完成后,整个内存系统才算就绪。这时候,链接脚本中定义的RAM区域才能真正使用。
知识体系总览
下面这张图,是我自己总结的链接脚本与内存布局的核心逻辑:
这张图展示了从链接脚本到最终内存就绪的完整流程。说白了,链接脚本就是一张"地图",告诉链接器怎么安排你的程序。而DDR初始化,则是让这张地图上的"土地"变得可用。
好了,这一章的内容就到这里。记住,链接脚本和内存布局是BSP开发的基石。搞懂了它,你就能更好地理解程序在硬件上是如何运行的。