3. 链接脚本与内存布局:.lds 详解与段放置
链接脚本,说白了就是告诉链接器:你的代码该往哪里放。很多做应用层开发的同事第一次看到 .lds 文件,会觉得像天书。我刚开始接触时也一样,觉得这东西不就是个地址映射吗?直到有一次,我写的 Bootloader 在 QSPI Flash 上死活跑不起来,才发现是 .lds 里一个段地址配错了。
嗯,今天我们就把它彻底讲透。
3.1 链接脚本的核心语法
一个典型的 .lds 文件,其实就三大部分:内存区域定义、段布局、符号导出。我们拆开看。
3.1.1 MEMORY 命令
先告诉链接器,芯片里有哪些物理内存区域,它们的起始地址和大小是多少。
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
这里 rx 表示读+执行,rwx 表示读+写+执行。我个人习惯把 Bootloader 的 Flash 区域单独划一小块出来,比如 64KB,剩下的留给应用。这样万一 Bootloader 要升级,不会误伤应用区。
3.1.2 SECTIONS 命令
这是链接脚本的正文。它定义了每个段(.text、.data、.bss 等)该放到哪个内存区域,以及段之间的顺序。
SECTIONS
{
. = ORIGIN(FLASH);
.text :
{
*(.isr_vector) /* 中断向量表 */
*(.text*) /* 代码段 */
*(.rodata*) /* 只读数据 */
_etext = .; /* 代码段结束地址 */
} > FLASH
.data : AT(_etext)
{
_sdata = .;
*(.data*)
_edata = .;
} > RAM
.bss :
{
_sbss = .;
*(.bss*)
_ebss = .;
} > RAM
}
这里有个关键点:.data 段用了 AT(_etext)。什么意思?就是说 .data 段运行时在 RAM 里,但它的初始值存储在 Flash 里,紧跟在代码段后面。Bootloader 启动时,需要把这段数据从 Flash 拷贝到 RAM。
3.2 代码段 / 数据段 / BSS 段的放置策略
你想想看,Bootloader 的代码段为什么一定要放在 Flash 开头?因为芯片上电后,PC 指针会从复位向量指向的地址开始执行。如果复位向量不在 Flash 起始位置,CPU 就找不到入口。
| 段名 | 存放位置 | 内容 | 初始化方式 |
|---|---|---|---|
| .isr_vector | Flash 起始 | 中断向量表 | 硬件自动读取 |
| .text | Flash | 可执行指令 + 常量 | 无需初始化 |
| .rodata | Flash | 只读数据(字符串等) | 无需初始化 |
| .data | RAM(初始值在 Flash) | 已初始化的全局/静态变量 | 启动时从 Flash 拷贝 |
| .bss | RAM | 未初始化的全局/静态变量 | 启动时清零 |
| 堆 / 栈 | RAM 末尾 | 动态内存 / 函数调用栈 | 设置 SP 指针 + 堆初始化 |
我在项目中遇到过一个问题:某个 Bootloader 在跳转到应用前,忘了把 .bss 段清零。结果应用的全局变量里残留了 Bootloader 的旧数据,导致通信协议解析出错。排查了两天才找到原因。所以,.bss 清零是启动流程里绝对不能省的一步。
3.3 栈与堆的初始化
栈和堆的初始化,是 Bootloader 进入 C 环境前的最后一步。说白了,就是给 SP 寄存器赋值,然后告诉 malloc 函数堆从哪里开始、有多大。
3.3.1 栈指针设置
在链接脚本里,我们通常会定义栈顶地址:
_estack = ORIGIN(RAM) + LENGTH(RAM); /* 栈顶在 RAM 末尾 */
然后在启动汇编里:
ldr sp, =_estack
为什么栈要放在 RAM 末尾?因为栈是向下生长的。如果放在 RAM 开头,栈溢出时会直接踩到 .data 和 .bss 段,后果不堪设想。
3.3.2 堆初始化
堆的初始化相对简单。在链接脚本里定义堆的起始和结束地址:
_heap_start = _ebss + 0x100; /* BSS 段后面留 256 字节 */
_heap_end = _estack - 0x400; /* 栈底往下留 1KB 给栈 */
然后在 C 代码里,用 __heap_start 和 __heap_end 这两个符号初始化堆管理器。如果你用的是 newlib,需要实现 _sbrk 函数:
extern char _heap_start;
extern char _heap_end;
void *_sbrk(int incr)
{
static char *heap_ptr = &_heap_start;
char *prev = heap_ptr;
if (heap_ptr + incr > &_heap_end) {
return (void *)-1; /* 堆溢出 */
}
heap_ptr += incr;
return prev;
}
这里要注意:堆和栈是相向生长的。堆向上,栈向下。如果中间没有足够的空隙,它们就会撞在一起。我一般会在堆顶和栈底之间留至少 2KB 的 guard zone。
3.4 链接脚本与内存布局的完整关系图
下面这张图,把整个内存布局串起来了。你可以对照着看,每个段在 Flash 和 RAM 里是怎么摆放的。
3.5 实战:一个完整的 Bootloader .lds 示例
最后,我给你一个我实际项目里用过的链接脚本模板。它包含了 Bootloader 特有的分区设计:
/* Bootloader 专用链接脚本 */
MEMORY
{
BOOT_FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 32K
APP_FLASH (rx) : ORIGIN = 0x08008000, LENGTH = 480K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
. = ORIGIN(BOOT_FLASH);
.text :
{
KEEP(*(.isr_vector))
*(.text*)
*(.rodata*)
. = ALIGN(4);
_etext = .;
} > BOOT_FLASH
.data : AT(_etext)
{
_sdata = .;
*(.data*)
. = ALIGN(4);
_edata = .;
} > RAM
.bss :
{
_sbss = .;
*(.bss*)
. = ALIGN(4);
_ebss = .;
} > RAM
/* 堆定义 */
.heap (NOLOAD) :
{
. = ALIGN(8);
_heap_start = .;
. += 4K; /* 4KB 堆 */
_heap_end = .;
} > RAM
/* 栈定义 */
.stack (NOLOAD) :
{
. = ALIGN(8);
. += 2K; /* 2KB 栈 */
_stack_end = .;
} > RAM
_estack = ORIGIN(RAM) + LENGTH(RAM);
/* 安全检查:堆不能超过栈底 */
ASSERT(_heap_end <= _estack - 2K, "Heap overflow into stack guard zone!")
}
这个脚本里,我把 Bootloader 限制在 32KB 的 Flash 空间内,剩下的 480KB 留给应用。堆和栈之间留了 2KB 的 guard zone,并且在链接时就用 ASSERT 做了静态检查。这样,编译阶段就能发现内存布局问题,而不是等到运行时崩溃。