3、链接脚本与内存布局:链接脚本(.ld)的作用、内存分区(Flash、RAM、堆栈)、代码段与数据段的重定位
好,咱们今天聊点硬核的。链接脚本,很多人觉得它就是个配置文件,写一次就扔那不管了。说实话,我早年也这么想。直到有一次,我做一个STM32的项目,Bootloader和App死活跳转不过去,查了三天,最后发现是链接脚本里Flash的起始地址写错了。嗯,从那以后,我再也不敢小看这个.ld文件了。
3.1 链接脚本到底在干什么?
说白了,链接脚本就是告诉编译器:你的代码该放哪,数据该放哪,堆栈又该放哪。它是一张内存地图。没有这张地图,你的程序跑起来就是无头苍蝇。
我个人习惯,拿到一个新芯片,第一件事就是打开它的链接脚本,看看Flash和RAM的分配。这比看数据手册还直观。你想想看,芯片的物理地址空间,在链接脚本里是一目了然的。
核心作用:
- 定义内存的起始地址和大小(Flash、RAM)
- 指定各个段(.text, .data, .bss)的存放位置
- 控制代码和数据的重定位过程
- 为堆栈预留空间
我见过不少新手,直接在代码里硬编码地址,然后问我为什么跑飞了。其实,你只要把链接脚本搞明白,这些地址问题都能迎刃而解。
3.2 内存分区:Flash、RAM、堆栈
一个典型的嵌入式系统,内存就三大块:Flash、RAM,还有我们常说的堆和栈。它们各司其职,谁也离不开谁。
3.2.1 Flash(只读存储器)
Flash里放的是啥?你的代码(.text段)、只读数据(.rodata段),还有那些初始化常量。掉电不丢失,这是它的特点。我在项目中遇到过,有人把大数组定义成const,结果编译出来的固件体积暴涨。为什么?因为const变量默认放Flash,Flash不够用了。所以,大数组该放RAM就放RAM,别省那点事。
3.2.2 RAM(随机存取存储器)
RAM是程序运行时的舞台。全局变量、静态变量、堆、栈,全挤在这里。RAM通常分两块:一块是上电清零的(.bss段),一块是上电需要从Flash拷贝初始值的(.data段)。
这里有个坑,我曾经踩过:.data段的初始化,是启动代码(Reset_Handler)里做的。如果你的Bootloader跳转到App时,没有重新初始化.data段,那App里的全局变量初始值全是乱的。嗯,这个问题我修了整整一个通宵。
3.2.3 堆与栈
栈(Stack):局部变量、函数调用、中断现场,全压在这里。栈的大小,我建议至少给1KB,复杂系统给4KB以上。太小了,递归一深就溢出,系统直接死给你看。
堆(Heap):malloc/free用的地方。嵌入式里,我其实不太推荐用动态内存分配。为什么?碎片化问题太头疼了。我见过一个产品,跑了一个月后突然死机,查到最后是堆碎片导致malloc失败。从那以后,我基本都用静态分配。
我的经验: 栈的大小,在链接脚本里用STACK_SIZE定义。堆的大小,用HEAP_SIZE定义。别偷懒,这两个值一定要根据你的实际需求来调。我曾经见过一个项目,栈只给了256字节,结果一进中断嵌套就崩。调大一点,世界就安静了。
3.3 代码段与数据段的重定位
重定位,这个词听起来高大上,其实说白了就是:把代码和数据搬到它们该去的地方。Bootloader和App之间,最核心的就是这个重定位过程。
3.3.1 代码段(.text)的重定位
代码段通常放在Flash里。但Bootloader和App的代码段,起始地址不一样。比如Bootloader从0x08000000开始,App从0x08020000开始。这个偏移量,就是在链接脚本里定义的。
/* 典型的链接脚本片段 */
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
/* App的起始地址偏移 */
_app_start = 0x08020000;
SECTIONS
{
.text :
{
*(.isr_vector) /* 中断向量表 */
*(.text*) /* 代码 */
*(.rodata*) /* 只读数据 */
_etext = .;
} > FLASH
.data : AT (_etext)
{
_sdata = .;
*(.data*)
_edata = .;
} > RAM
}
你看,.data段后面有个AT (_etext),这表示.data段的初始值存放在Flash里(紧跟在代码段后面),但运行时它要搬到RAM里。这个搬运工作,就是启动代码干的活。
3.3.2 数据段(.data和.bss)的重定位
.data段:上电后,从Flash拷贝到RAM。.bss段:上电后,在RAM里清零。这两个操作,缺一不可。
我曾经犯过一个低级错误:在Bootloader里跳转到App之前,忘了把全局中断关掉。结果App的.data段还没拷贝完,一个中断进来,直接访问了未初始化的全局变量。嗯,那画面太美我不敢看。
避坑指南: 如果你在做Bootloader跳转App,一定要确保:
- App的中断向量表地址已经重新设置(SCB->VTOR)
- App的.data段和.bss段已经正确初始化
- 跳转前关闭所有外设中断,跳转后由App重新初始化
我曾经因为忘了关SysTick中断,跳转后App直接卡死在HardFault里。查了两天,最后发现是SysTick还在跑,但中断向量表已经变了。教训深刻啊。
3.4 实战:一个典型的链接脚本分析
咱们来看一个实际项目中我用过的链接脚本。这个脚本是为一个Cortex-M4芯片写的,Bootloader占64KB,App占448KB。
/* Bootloader链接脚本 */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 32K
}
SECTIONS
{
.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
._user_heap_stack :
{
. = ALIGN(8);
PROVIDE ( _end = . );
PROVIDE ( _heap_start = . );
. = . + _heap_size;
. = . + _stack_size;
. = ALIGN(8);
} > RAM
}
注意看最后那个._user_heap_stack段。它没有实际内容,只是用来预留堆和栈的空间。_heap_size和_stack_size这两个符号,你可以在链接脚本开头定义,也可以在编译选项里传进来。我个人习惯在链接脚本里直接定义,这样一目了然。
小技巧: 链接脚本里的符号(比如_etext、_sdata),可以在C代码里用extern声明后直接使用。比如:
extern uint32_t _etext;
extern uint32_t _sdata;
extern uint32_t _edata;
void data_init(void)
{
uint32_t *src = &_etext;
uint32_t *dst = &_sdata;
while (dst < &_edata) {
*dst++ = *src++;
}
}
这样,你就能在C代码里精确控制数据段的拷贝了。我经常用这个方法来调试内存布局问题。
3.5 总结一下
链接脚本,说白了就是你的程序在芯片里的「房产证」。Flash是永久产权,RAM是临时租赁。代码段住Flash,数据段住RAM,但数据段的初始值得从Flash搬过来。堆和栈,就是你在RAM里给自己留的活动空间。
我做了这么多年嵌入式,最大的体会就是:内存布局搞不清楚,Bug就永远搞不清楚。每次遇到莫名其妙的问题,先看链接脚本,再看启动代码,往往能找到答案。嗯,今天就聊到这,下一章咱们讲讲中断向量表的重定向,那又是另一个精彩的故事了。