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占了。

重定位怎么做?很简单,两步走:

  1. 修改链接脚本:把应用程序的.isr_vector段放到新的地址,比如0x08010000。
  2. 修改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文件,互不冲突。

最后说一句,链接脚本这东西,看着复杂,其实核心就三件事:告诉编译器内存有多大、代码放哪儿、数据放哪儿。搞懂了这三件事,你就能自己定制内存布局了。我建议你拿到一个新芯片,先花半小时把链接脚本研究透——这半小时,能帮你省下后面几天的调试时间。