4. 链接脚本与内存布局:.lds 详解与段配置
链接脚本,说白了就是告诉链接器:你的代码该放哪儿,数据该放哪儿。很多做应用层开发的同事第一次看到 .lds 文件时,会觉得这玩意儿跟天书似的。但我跟你说,搞 Bootloader 如果不懂链接脚本,那就像开车不看仪表盘——能跑,但迟早要出事。
我记得刚入行那会儿,接手一个 STM32 的 Bootloader 项目。代码写好了,编译通过,下载运行——直接死机。查了两天,最后发现是链接脚本里 RAM 起始地址写错了。从那以后,我养成了一个习惯:拿到一个新平台,第一件事就是先把 .lds 文件从头到尾读一遍。
4.1 链接脚本的核心语法
GNU LD 的链接脚本,语法其实不复杂。核心就几个关键字:ENTRY、MEMORY、SECTIONS。咱们一个一个说。
4.1.1 ENTRY:程序入口
这个最简单。告诉链接器,程序的第一条指令在哪儿。对于 Bootloader 来说,入口通常是复位向量:
ENTRY(Reset_Handler)
嗯,这里要注意:ENTRY 只是给链接器一个提示。真正决定 CPU 从哪里开始执行的,是芯片硬件规定的启动地址。比如 Cortex-M 系列,上电后从 0x00000000 取栈指针,从 0x00000004 取复位向量。所以 ENTRY 更多是给调试器和反汇编工具看的。
4.1.2 MEMORY:定义内存区域
这个就关键了。你得告诉链接器,芯片上有哪些内存区域,它们的起始地址和大小是多少。
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
属性 rx 表示可读可执行,rwx 表示可读可写可执行。我见过有人把 FLASH 写成 rwx,虽然也能编译通过,但总觉得心里不踏实。
4.1.3 SECTIONS:段布局
这才是重头戏。SECTIONS 命令定义了各个段(.text、.data、.bss 等)如何放置到 MEMORY 定义的内存区域中。
SECTIONS
{
. = ORIGIN(FLASH);
.text :
{
KEEP(*(.isr_vector))
*(.text*)
*(.rodata*)
. = ALIGN(4);
_etext = .;
} > FLASH
.data : AT(_etext)
{
_sdata = .;
*(.data*)
. = ALIGN(4);
_edata = .;
} > RAM
.bss :
{
_sbss = .;
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
}
这段代码,我闭着眼睛都能写出来。但每个符号的含义,你得搞清楚:
.:当前位置计数器。你可以手动赋值,比如. = 0x20000000。KEEP:告诉链接器,这个段即使没人引用也要保留。中断向量表就是典型的例子。ALIGN(4):4 字节对齐。ARM 架构下,大部分指令和数据都需要对齐,否则可能触发异常。_etext、_sdata等:这些是链接器导出的符号,C 代码里可以用extern声明后直接使用。
4.2 代码段、数据段、BSS 段的布局
咱们把这三个段拆开来看。每个段都有它的脾气。
4.2.1 .text:代码段
存放程序指令和只读常量。在 Bootloader 里,这部分通常放在 FLASH 中。为什么?因为 FLASH 是非易失性的,掉电不丢失。
我个人习惯把中断向量表放在最前面,紧跟着是复位处理函数,然后是其他代码。这样布局清晰,调试的时候也容易定位问题。
4.2.2 .data:已初始化数据段
这个段有点特殊。它存储的是有初始值的全局变量和静态变量。比如:
int g_counter = 100; // 这个变量在 .data 段
static int s_flag = 1; // 这个也在 .data 段
问题是:这些初始值在编译时就已经确定了,它们被存放在 FLASH 中。但变量本身需要在 RAM 中才能读写。所以链接脚本做了个巧妙的设计:
- 加载地址(LMA):在 FLASH 中,紧跟在 .text 后面。
- 运行地址(VMA):在 RAM 中。
启动代码必须负责把 .data 段从 FLASH 拷贝到 RAM。这就是为什么你会在汇编启动文件里看到一段循环拷贝的代码。
// 启动代码中的 .data 段拷贝
extern uint32_t _sdata, _edata, _etext;
uint32_t *src = &_etext;
uint32_t *dst = &_sdata;
while (dst < &_edata) {
*dst++ = *src++;
}
嗯,这里有个坑:如果你忘了拷贝 .data 段,或者拷贝的地址算错了,那所有带初始值的全局变量都是垃圾值。我调试过的一个项目,串口打印乱码,查了半天发现是 .data 段没拷贝——波特率配置变量是 0。
4.2.3 .bss:未初始化数据段
存放未初始化或初始化为 0 的全局变量和静态变量。比如:
int g_buffer[1024]; // 在 .bss 段
static int s_count; // 也在 .bss 段
.bss 段不占用 FLASH 空间,只在 RAM 中占位置。启动代码需要把它清零:
extern uint32_t _sbss, _ebss;
uint32_t *dst = &_sbss;
while (dst < &_ebss) {
*dst++ = 0;
}
4.3 栈与堆的配置
栈和堆,这两个概念搞嵌入式的都不陌生。但在链接脚本里,它们是怎么配置的?
4.3.1 栈(Stack)
栈用于函数调用、局部变量、中断现场保存。Cortex-M 使用满递减栈,栈指针 SP 指向栈顶。
在链接脚本中,栈通常不直接定义,而是在汇编启动文件中设置:
; 汇编启动文件中的栈定义
Stack_Size EQU 0x00001000
AREA STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem SPACE Stack_Size
__initial_sp
但链接脚本需要确保栈所在的 RAM 区域足够大,并且不与 .data 和 .bss 段冲突。
我个人习惯把栈放在 RAM 的最高地址。为什么?因为栈是向下生长的,放在最高处可以最大化可用空间。而且如果栈溢出,它会先覆盖到未使用的区域,而不是直接踩到全局变量。
__builtin_frame_address 调试才发现。建议 Bootloader 的栈至少 1KB,如果用到文件系统或网络协议栈,4KB 起步。
4.3.2 堆(Heap)
堆用于动态内存分配(malloc/free)。在 Bootloader 中,我建议尽量少用堆。原因很简单:
- 堆管理有开销,影响实时性。
- 碎片问题难以预测。
- 如果 malloc 失败,错误处理很麻烦。
如果非要用,可以在链接脚本中定义堆的起始和结束地址:
_heap_start = .;
_heap_end = ORIGIN(RAM) + LENGTH(RAM);
然后在 C 代码中实现 _sbrk 函数:
extern uint32_t _heap_start, _heap_end;
void *_sbrk(int incr) {
static uint32_t *heap_ptr = NULL;
if (heap_ptr == NULL) heap_ptr = &_heap_start;
uint32_t *prev = heap_ptr;
heap_ptr += incr;
if (heap_ptr > &_heap_end) return (void*)-1;
return prev;
}
4.4 完整的内存布局图
说了这么多,咱们用一张图来总结一下典型的 Bootloader 内存布局:
4.5 实战:自定义链接脚本的注意事项
最后,分享几个我在实际项目中踩过的坑:
- 对齐问题:ARM 架构要求某些段必须对齐到 4 字节或 8 字节边界。忘了写
ALIGN,可能触发硬件异常。 - 符号冲突:不同目标文件中的同名段会被合并。如果你有两个
.text段,链接器会报错。 - 内存重叠:检查
MEMORY定义是否有重叠区域。我见过有人把 FLASH 和 RAM 的地址写反了,编译通过,运行直接 HardFault。 - 栈大小估算:Bootloader 的栈大小可以通过分析函数调用深度来估算。保守起见,先设大一点,然后通过
__stack_chk_guard或填充模式来检测实际使用量。
链接脚本这东西,说难不难,说简单也不简单。关键是要理解它背后的逻辑:代码放哪儿,数据放哪儿,启动时怎么初始化。搞清楚了这些,你就能随心所欲地控制 Bootloader 的内存布局了。