3、链接脚本深度解析:链接脚本的作用、MEMORY与SECTIONS命令、如何自定义链接脚本实现双区备份
说实话,很多做嵌入式开发的朋友,写了几年C代码,却从来没仔细看过链接脚本。我刚开始带项目时也是这样——编译器帮我搞定了,我干嘛要管?直到有一次,我负责的Bootloader死活跑不起来,调试了整整两天,最后发现是Flash地址映射错了。嗯,从那以后,我再也不敢跳过链接脚本了。
今天咱们就把这块硬骨头啃下来。你想想看,链接脚本其实就是告诉链接器:你的代码该放哪儿,数据该放哪儿,哪些段要保留,哪些可以丢掉。说白了,它就是一张内存布局的施工图。
3.1 链接脚本到底在干什么?
一个嵌入式工程从源码到可执行文件,要经过四个阶段:预处理、编译、汇编、链接。前三个阶段咱们都比较熟,但链接这一步,很多人把它当黑盒。
链接器干的事,我总结为三条:
- 地址分配:给每个段(.text、.data、.bss等)分配具体的运行地址
- 符号解析:把函数名、全局变量这些符号,绑定到具体的地址上
- 段合并:把多个目标文件中的同类型段,合并成一个完整的段
没有链接脚本,链接器就用默认规则。但默认规则往往不适合嵌入式场景——尤其是咱们要做双区备份的Bootloader,必须精确控制每一块Flash的用途。
核心观点:链接脚本不是编译器的附属品,它是嵌入式工程师的"地契"——你声明了这块地归谁,编译器才敢往上盖楼。
3.2 MEMORY命令:定义你的硬件家底
MEMORY命令用来描述目标芯片的存储资源。我习惯把它叫做"盘家底"——你得告诉链接器,你手里有哪些Flash、哪些RAM,它们的起始地址和大小是多少。
基本语法长这样:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
每个存储区域可以指定属性:
| 属性 | 含义 | 典型用途 |
|---|---|---|
| r | 可读 | 所有存储区域都该有 |
| w | 可写 | RAM必须,Flash一般不加 |
| x | 可执行 | 存放代码的区域必须加 |
| ! | 取反 | 比如"!x"表示不可执行 |
我在项目中遇到过一个问题:某次把代码段属性写成了"rw",结果芯片上电后程序直接跑飞。后来查了半天才发现,Flash区域没加"x"属性,链接器认为这块区域不可执行,生成的向量表地址全是错的。你说冤不冤?
个人习惯:我一般会在MEMORY里把Flash拆成多个区域——比如BOOT、APP1、APP2、PARAM。这样后面写SECTIONS时,每个段该放哪儿一目了然,不容易出错。
3.3 SECTIONS命令:给代码和数据安排座位
MEMORY定义好了房间,SECTIONS就是安排每个段住哪个房间、住哪个位置。这是链接脚本里最核心、也最灵活的部分。
一个典型的SECTIONS结构:
SECTIONS
{
.text :
{
*(.isr_vector) /* 中断向量表,必须放在最前面 */
*(.text*) /* 所有代码 */
*(.rodata*) /* 只读数据 */
_etext = .; /* 代码结束地址 */
} > FLASH
.data : AT(_etext)
{
_sdata = .;
*(.data*)
_edata = .;
} > RAM
.bss :
{
_sbss = .;
*(.bss*)
_ebss = .;
} > RAM
}
这里有个关键点:.data段用了AT(_etext)。什么意思?就是说这个段运行时在RAM里,但它的初始值存在Flash里,紧跟在代码段后面。启动代码要做的,就是把这段数据从Flash拷贝到RAM。
我刚开始学的时候,一直搞不懂为什么.data要写两个地址。后来调试一个RAM不够用的项目,才真正理解了——运行时地址和加载地址可以不同,这是嵌入式系统的精髓之一。
注意:_etext、_sdata、_edata这些符号,不是C语言定义的变量,而是链接器生成的地址符号。你在启动代码里要用extern声明它们,然后取地址使用。我曾经见过有人直接拿这些符号当变量用,结果读出来的值全是错的。
3.4 双区备份的链接脚本设计
好了,前面都是基础。现在咱们来点实战的——双区备份Bootloader的链接脚本该怎么写?
双区备份的思路很简单:Flash里放两份应用程序,一份是当前运行的,一份是备份的。升级时,把新固件写到备份区,校验通过后切换启动。这样即使升级过程中断电,系统还能从旧区启动,不会变砖。
先画个内存布局图:
对应这个布局,链接脚本的MEMORY部分要这样写:
/* 双区备份Bootloader内存布局 */
MEMORY
{
BOOT (rx) : ORIGIN = 0x08000000, LENGTH = 32K
APP_A (rx) : ORIGIN = 0x08008000, LENGTH = 128K
APP_B (rx) : ORIGIN = 0x08028000, LENGTH = 128K
PARAM (rw) : ORIGIN = 0x08048000, LENGTH = 32K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
然后,Bootloader自己的代码要链接到BOOT区域:
SECTIONS
{
.text :
{
KEEP(*(.isr_vector))
*(.text*)
*(.rodata*)
. = ALIGN(4);
_etext = .;
} > BOOT
/* 其他段省略... */
}
那应用程序的链接脚本呢?注意,应用程序不知道自己将来会跑在A区还是B区。所以它的链接脚本要设计成可配置的:
/* app.ld - 应用程序链接脚本 */
/* 编译时通过 -DAPP_BASE=0x08008000 或 -DAPP_BASE=0x08028000 指定 */
MEMORY
{
FLASH (rx) : ORIGIN = APP_BASE, LENGTH = 128K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.text :
{
KEEP(*(.isr_vector))
*(.text*)
*(.rodata*)
. = ALIGN(4);
_etext = .;
} > FLASH
/* 注意:.data的加载地址紧跟在代码后面 */
.data : AT(_etext)
{
_sdata = .;
*(.data*)
_edata = .;
} > RAM
.bss :
{
_sbss = .;
*(.bss*)
_ebss = .;
} > RAM
}
避坑指南:应用程序的中断向量表必须放在它所在区域的起始地址。Bootloader跳转到应用程序前,要先把SCB->VTOR寄存器改成APP_BASE。我见过有人忘了改VTOR,结果中断一触发就跑回Bootloader的向量表,整个系统乱套了。
3.5 双区切换的关键:链接脚本与启动代码的配合
链接脚本写好了,但要让双区备份真正跑起来,还需要启动代码的配合。这里我总结了一个检查清单:
- Bootloader启动:读取参数区,判断应该启动A区还是B区
- 设置向量表:把SCB->VTOR指向目标应用区的起始地址
- 跳转:获取应用区的栈顶指针和复位向量,执行跳转
- 应用程序启动:执行自己的启动代码,完成.data和.bss的初始化
跳转代码的核心部分,我习惯用内联汇编写:
/* 跳转到应用程序 */
void jump_to_app(uint32_t app_base)
{
uint32_t stack_top = *(volatile uint32_t *)app_base;
uint32_t reset_handler = *(volatile uint32_t *)(app_base + 4);
/* 设置主栈指针 */
__set_MSP(stack_top);
/* 设置中断向量表偏移 */
SCB->VTOR = app_base;
/* 跳转到复位向量 */
void (*app_reset)(void) = (void (*)(void))reset_handler;
app_reset();
}
曾经踩过的坑:跳转前一定要关掉全局中断,跳转完成后再由应用程序重新初始化中断系统。我有一次没关中断就跳了,结果应用程序的启动代码还没跑完,一个定时器中断就来了,VTOR还没更新,直接飞到了错误的地方。从那以后,我每次跳转前都老老实实关中断。
3.6 链接脚本调试技巧
链接脚本出了问题,不像C代码那么好调试。我分享几个实用技巧:
| 技巧 | 方法 | 适用场景 |
|---|---|---|
| 查看map文件 | 编译时加-Wl,-Map=output.map | 检查各段的实际地址和大小 |
| 查看符号表 | 使用nm或objdump工具 | 确认某个符号的地址是否正确 |
| 反汇编验证 | objdump -d查看跳转指令 | 检查函数调用是否使用了正确的地址 |
| 打印链接器符号 | 在代码中打印_etext等符号的值 | 验证加载地址和运行地址的关系 |
我个人最常用的是map文件。每次修改链接脚本后,我都会先看map文件,确认每个段的地址和大小符合预期。尤其是做双区备份时,要确保APP_A和APP_B的代码段没有越界——128K就是128K,多一个字节都不行。
一句话总结:链接脚本是嵌入式工程师的"地契",MEMORY定义了你有什么地,SECTIONS决定了每块地种什么庄稼。双区备份的核心,就是通过精心设计的链接脚本,让两份应用程序在Flash里和平共处,互不干扰。