3、链接脚本与内存映射:链接脚本(.ld)文件解析、代码段/数据段/BSS段布局、中断向量表重定位
好,咱们今天聊聊链接脚本。说实话,很多做MCU开发的朋友,写了几年代码都没碰过.ld文件。我以前也是这样,直到有一次项目死活跑不起来,查了三天才发现是内存布局出了问题。嗯,从那以后,我养成了一个习惯——每次建新工程,第一件事就是打开链接脚本看一眼。
3.1 链接脚本(.ld)文件解析
链接脚本,说白了就是告诉编译器:你的代码放哪儿,数据放哪儿,堆栈放哪儿。它就像一张城市地图,标注了每块内存区域的起始地址和大小。
我拿一个典型的STM32链接脚本举例,你一看就明白了:
/* 内存区域定义 */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
/* 段布局定义 */
SECTIONS
{
/* 中断向量表,必须放在最前面 */
.isr_vector :
{
. = ALIGN(4);
KEEP(*(.isr_vector))
. = ALIGN(4);
} > FLASH
/* 代码段 */
.text :
{
. = ALIGN(4);
*(.text)
*(.text*)
. = ALIGN(4);
} > FLASH
/* 只读数据段 */
.rodata :
{
. = ALIGN(4);
*(.rodata)
*(.rodata*)
. = ALIGN(4);
} > FLASH
/* 初始化数据段(运行时在RAM,初始值在Flash) */
.data : AT (ADDR(.rodata) + SIZEOF(.rodata))
{
. = ALIGN(4);
_sdata = .;
*(.data)
*(.data*)
. = ALIGN(4);
_edata = .;
} > RAM
/* BSS段(未初始化数据) */
.bss :
{
. = ALIGN(4);
_sbss = .;
*(.bss)
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
}
你看,MEMORY块定义了两块区域:Flash和RAM。Flash从0x08000000开始,512KB大小;RAM从0x20000000开始,128KB大小。这个地址是芯片厂商定死的,不能乱改。
关键点:链接脚本里的地址必须和芯片数据手册完全一致。我曾经见过有人把Flash起始地址写成了0x08001000,结果程序下载进去直接跑飞——因为中断向量表没放在正确的位置上。
3.2 代码段/数据段/BSS段布局
咱们把上面脚本里的几个段拆开讲讲。每个段都有它的脾气。
代码段(.text)
代码段放的是你的程序指令。它被放在Flash里,因为MCU上电后直接从Flash取指令执行。我个人习惯在.text段里加一个KEEP宏,防止链接器把没调用的函数优化掉——尤其是中断服务函数,你想想看,中断函数没有显式调用,但链接器不知道啊,它觉得这函数没用就给删了,结果中断一触发就死机。
只读数据段(.rodata)
常量、字符串字面量这些,都放在.rodata段。它也待在Flash里。为什么?因为Flash是只读的,天然适合放只读数据。我记得有个同事把一个大数组定义成const,但没放在.rodata段,结果RAM爆了——嗯,这就是没搞清楚段布局的后果。
数据段(.data)
这个段有点特殊。它有两个家:初始值在Flash里,运行时在RAM里。为什么这么折腾?因为全局变量和静态变量需要初始化,但RAM掉电就丢数据,所以初始值必须存在Flash里。上电后,启动代码会把.data段从Flash拷贝到RAM。
你看脚本里的写法:.data : AT (ADDR(.rodata) + SIZEOF(.rodata))。这个AT指定了加载地址(在Flash里),而> RAM指定了运行地址(在RAM里)。启动代码里需要用到_sdata和_edata这两个符号,它们标记了拷贝的起始和结束位置。
BSS段(.bss)
BSS段放未初始化的全局变量和静态变量。它只在RAM里占空间,不需要在Flash里存初始值——因为初始值就是0。启动代码会把BSS段全部清零。
小技巧:如果你发现程序编译后RAM占用比预期大很多,检查一下是不是有变量不小心定义成了全局的,而且没初始化。这些变量都会进BSS段,白白占用RAM。我一般用局部变量代替全局变量,除非真的需要跨函数共享数据。
3.3 中断向量表重定位
中断向量表,就是一张地址表,记录了每个中断对应的处理函数地址。MCU发生中断时,硬件会自动从这张表里找到处理函数并跳转过去。
默认情况下,中断向量表放在Flash的起始地址(0x08000000)。但有些场景下,我们需要把它挪个地方。比如做Bootloader时,应用程序的中断向量表就不能放在0x08000000了——那个位置已经被Bootloader占了。
重定位怎么做?很简单,两步走:
- 修改链接脚本:把应用程序的.isr_vector段放到新的地址,比如0x08010000。
- 修改SCB->VTOR寄存器:在应用程序启动代码里,把中断向量表偏移寄存器指向新地址。
代码示例:
/* 在应用程序的启动代码中 */
#define APP_VECTOR_TABLE_ADDR 0x08010000
void SystemInit(void)
{
/* 重定位中断向量表 */
SCB->VTOR = APP_VECTOR_TABLE_ADDR;
/* 其他初始化... */
}
警告:修改VTOR寄存器必须在使能全局中断之前完成。否则,如果在重定位之前发生了中断,MCU会去原来的地址找中断处理函数——而那个地址已经被Bootloader占用了,结果就是程序跑飞。我曾经在这个坑里摔过一次,调试了一整天才发现是VTOR设置时机不对。
还有一个细节要注意:中断向量表必须4字节对齐。因为每个向量占4个字节(32位地址),如果起始地址没对齐,硬件取向量时会出错。链接脚本里的. = ALIGN(4);就是干这个用的。
3.4 实战中的内存布局考量
在实际项目中,我一般会这样规划内存:
| 区域 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Bootloader | 0x08000000 | 32KB | 启动代码、固件升级 |
| 应用程序 | 0x08008000 | 448KB | 用户程序代码 |
| 参数存储 | 0x0807E000 | 8KB | 校准参数、配置信息 |
| RAM | 0x20000000 | 128KB | 运行时数据 |
你想想看,这样规划的好处是什么?Bootloader和应用程序完全隔离,互不干扰。升级固件时,Bootloader把新程序写到应用程序区,然后跳转过去就行。参数存储放在Flash末尾,即使更新固件也不会被覆盖。
嗯,这里要注意:Bootloader和应用程序的链接脚本必须各自独立配置。Bootloader的Flash起始地址是0x08000000,应用程序的起始地址是0x08008000。两个工程各用各的.ld文件,互不冲突。
最后说一句,链接脚本这东西,看着复杂,其实核心就三件事:告诉编译器内存有多大、代码放哪儿、数据放哪儿。搞懂了这三件事,你就能自己定制内存布局了。我建议你拿到一个新芯片,先花半小时把链接脚本研究透——这半小时,能帮你省下后面几天的调试时间。