第三章 链接脚本与内存布局
做嵌入式开发,特别是写 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 的分配关系讲得很清楚。
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 和主应用的内存布局。