第三章 链接脚本与内存布局

做嵌入式开发,特别是写 Bootloader,绕不开一个文件——链接脚本(.ld)。很多初学者觉得这东西是编译器的“黑魔法”,能跑就行。但我得说,不理解链接脚本,你根本控制不了代码放在哪、数据存在哪,更别提什么向量表重定位了。

说白了,链接脚本就是告诉链接器:你的芯片内存长什么样,代码该放哪,数据该放哪。它是一张地图。

核心观点:链接脚本定义了目标程序在物理内存中的最终布局。没有它,编译器生成的 .o 文件只是一堆“不知道住哪”的符号。

3.1 链接脚本到底在干什么?

我刚开始做 Bootloader 时,踩过一个坑。那时候用 STM32F103,写了个简单的跳转程序,结果死活跑不起来。调试了半天,发现是中断向量表没放到正确的位置。嗯,问题就出在链接脚本上。

链接脚本的主要工作有三件:

  • 合并段(Section):把多个 .o 文件里的 .text、.data、.bss 等段合并到一起。
  • 分配地址:告诉链接器每个段该放在哪个地址区间。
  • 解析符号:把函数名、全局变量名绑定到具体地址。

你想想看,如果没有链接脚本,编译器生成的 .o 文件里,地址都是相对地址(从 0 开始)。链接器拿到这些文件,根本不知道你的 Flash 起始地址是 0x08000000 还是 0x00000000。所以,链接脚本就是那个“翻译官”。

3.2 内存分区:Flash、RAM、堆和栈

一个典型的 MCU 内存布局,大致分这么几块:

区域 用途 特点
Flash(只读) 存放代码(.text)、只读数据(.rodata)、常量 掉电不丢失,但写入慢,有擦写寿命
RAM(读写) 存放全局变量(.data)、零初始化变量(.bss)、堆、栈 掉电丢失,速度快,容量小
堆(Heap) 动态内存分配(malloc/free) 需要手动管理,容易产生碎片
栈(Stack) 局部变量、函数调用、中断现场保存 自动管理,但溢出后果严重

我个人习惯,在 Bootloader 里尽量不用堆。为什么?因为 Bootloader 的使命就是“启动”和“升级”,越简单越可靠。堆管理一旦出问题,系统可能连主应用都跳不过去。

小技巧:在链接脚本里,我会把栈指针(SP)的初始值放在向量表的第一个位置。这样芯片上电后,硬件自动从 0x00000000 加载 SP,从 0x00000004 加载 PC。这个顺序是 ARM Cortex-M 的硬规定。

3.3 向量表重定位——Bootloader 的命门

向量表重定位,是 Bootloader 里最容易被忽视、也最容易出问题的地方。

什么是向量表?就是一张表,里面存着各种中断服务函数的入口地址。芯片复位后,硬件会从固定地址(通常是 Flash 起始地址)读取这张表。

但问题来了:如果你的 Bootloader 占用了 Flash 的前 32KB,主应用放在 32KB 之后。那主应用的中断向量表就不能放在 0x00000000 了,得挪到 0x00008000(假设 32KB 偏移)。

这个“挪”的动作,就叫向量表重定位。

我曾经踩过的坑:有一次做 OTA 升级,主应用烧录到 0x08010000 之后,但忘了在启动代码里设置 SCB->VTOR 寄存器。结果一触发中断,CPU 还是跑到 0x08000000 去找向量表,那里是 Bootloader 的代码,根本不是主应用的中断服务函数。系统直接 HardFault。

在 Cortex-M 系列上,重定位向量表只需要写一行代码:

/* 设置向量表偏移寄存器 */
SCB->VTOR = 0x08010000;  /* 主应用起始地址 */

嗯,就这么简单。但很多人就是忘了写。

3.4 代码段与数据段——.text、.data、.bss 的真相

链接脚本里最核心的就是段(Section)的定义。常见的段有:

  • .text:存放可执行代码。只读,放在 Flash。
  • .rodata:存放只读数据(如字符串常量)。也放在 Flash。
  • .data:存放已初始化的全局变量和静态变量。初始值在 Flash,运行时拷贝到 RAM。
  • .bss:存放未初始化或零初始化的全局变量。不占 Flash 空间,运行时在 RAM 中清零。

这里有个关键点:.data 段为什么需要拷贝?

因为全局变量在程序里是有初始值的,比如 int count = 100;。这个 100 必须存在 Flash 里(掉电不丢)。但运行时,变量在 RAM 里才能读写。所以启动代码必须把 .data 段从 Flash 拷贝到 RAM。

我见过一些新手写的链接脚本,忘了定义 .data 段的加载地址(LMA)和运行地址(VMA),结果程序跑起来,全局变量全是乱的。

一个典型的链接脚本片段长这样:

SECTIONS
{
    . = 0x08000000;  /* Flash 起始地址 */
    .text : {
        *(.isr_vector)   /* 中断向量表 */
        *(.text)         /* 代码 */
        *(.rodata)       /* 只读数据 */
    } > FLASH

    . = 0x20000000;  /* RAM 起始地址 */
    .data : {
        _sdata = .;
        *(.data)
        _edata = .;
    } > RAM AT> FLASH  /* 加载在 Flash,运行在 RAM */

    .bss : {
        _sbss = .;
        *(.bss)
        _ebss = .;
    } > RAM
}

注意看 > RAM AT> FLASH 这句。它告诉链接器:.data 段运行时在 RAM,但初始数据存在 Flash 里。启动代码需要把数据从 Flash 拷贝到 RAM。

3.5 一张图看懂内存布局

下面这张图,是我自己画的内存布局示意。它把 Flash 和 RAM 的分配关系讲得很清楚。

典型 MCU 内存布局图 典型 MCU 内存布局(Flash + RAM) Flash(只读) .isr_vector 中断向量表(0x08000000 起始) .text 可执行代码 Bootloader / 主应用 .rodata 只读数据(字符串、常量) .data 初始值 (加载地址 LMA) 剩余 Flash 空间 启动时拷贝 .data RAM(读写) .data(运行地址 VMA) 已初始化全局变量 .bss 零初始化全局变量 堆(Heap) 动态内存分配 (从低地址向高地址增长) 栈(Stack) 局部变量、函数调用 (从高地址向低地址增长) 说明:.data 段在 Flash 中保存初始值,启动时由启动代码拷贝到 RAM 中运行。 .bss 段不占用 Flash 空间,启动时在 RAM 中清零。

3.6 链接脚本中的符号与启动代码

链接脚本里定义的符号(如 _sdata、_edata、_sbss、_ebss),会被启动代码(startup_xxx.s)引用。启动代码用这些符号来知道:

  • .data 段从哪里开始(Flash 地址)、要拷贝到哪里(RAM 地址)、拷贝多大。
  • .bss 段从哪里开始、到哪里结束,需要清零。

一个典型的启动代码片段(汇编)是这样的:

/* 拷贝 .data 段 */
ldr r0, =_sdata      /* RAM 目标地址 */
ldr r1, =_edata      /* RAM 结束地址 */
ldr r2, =_sidata     /* Flash 源地址 */
bl  CopyData

/* 清零 .bss 段 */
ldr r0, =_sbss
ldr r1, =_ebss
bl  ZeroBss

这里 _sidata 是链接脚本自动生成的符号,表示 .data 段在 Flash 中的加载地址。

个人经验:我习惯在链接脚本里显式定义这些符号,而不是依赖编译器默认行为。这样移植到不同芯片时,只需要改链接脚本,启动代码基本不用动。

3.7 避坑指南——链接脚本常见错误

这些年我见过不少链接脚本相关的 bug,挑几个典型的说说:

  • Flash 和 RAM 地址写反:有人把 .text 段放到 RAM 地址,结果代码一运行就死。因为 RAM 掉电丢数据,而且很多 MCU 不允许从 RAM 执行代码(除非特意配置)。
  • 栈和堆重叠:堆从低地址向上增长,栈从高地址向下增长。如果链接脚本没给够空间,两者就会“撞车”。我曾经在调试一个协议栈时,发现 malloc 返回的指针和局部变量地址重叠了,查了两天才发现是堆栈空间分配不足。
  • 向量表对齐问题:Cortex-M 要求向量表必须 256 字节对齐(即地址的低 8 位为 0)。如果链接脚本把 .isr_vector 段放在非对齐地址,芯片上电后直接 HardFault。

重要提醒:修改链接脚本后,一定要 clean 重新编译。因为链接器会缓存之前的符号地址,不清除的话,你可能还在用旧的布局。我吃过这个亏,浪费了半天时间。

3.8 小结

链接脚本是 Bootloader 开发的基石。它决定了你的代码和数据在内存中的“住址”。理解 .text、.data、.bss 的分区逻辑,掌握向量表重定位的方法,你就能自由控制 Bootloader 和主应用的内存布局。