第3章 链接脚本(Linker Script)深度解析:ROM与RAM的地址映射
链接脚本,说白了就是告诉编译器「你的代码该放哪儿,数据该放哪儿」。很多做MCU开发的朋友,一开始都觉得这东西可有可无——反正IDE都帮我生成了。但等你真正做Bootloader移植的时候,就会发现:不懂链接脚本,你连代码都跑不起来。
我个人习惯把链接脚本比作「MCU的内存地图」。你想想看,芯片就那么点ROM和RAM,哪个段该放哪里,哪个地址是保留区,哪个地址是外设寄存器,全得靠这张地图来规划。
3.1 ROM与RAM的地址映射:芯片的「内存地图」
先看一个典型的MCU内存映射。以STM32F103为例,它的地址空间长这样:
| 地址范围 | 区域 | 用途 |
|---|---|---|
| 0x0000 0000 - 0x0000 FFFF | 别名区/系统存储器 | BootROM、中断向量表映射 |
| 0x0800 0000 - 0x0801 FFFF | 主Flash(ROM) | 代码段(.text)、只读数据(.rodata) |
| 0x2000 0000 - 0x2000 FFFF | SRAM | 数据段(.data)、BSS段(.bss)、堆栈 |
| 0x4000 0000 - 0x4002 3FFF | 外设寄存器 | GPIO、USART、TIM等 |
嗯,这里要注意:ROM和RAM的地址映射,是由芯片硬件设计决定的,不是你想改就能改。但链接脚本可以决定:你的代码段从Flash的哪个偏移地址开始放,数据段从SRAM的哪个地址开始放。
核心概念:链接脚本的本质,就是把编译器生成的「逻辑段」(.text、.data、.bss)映射到芯片的「物理地址空间」(ROM、RAM)。
3.2 .text / .data / .bss 段的布局:代码和数据怎么「住」进芯片
这三个段,是嵌入式开发里最基础的三个段。我刚开始做Bootloader时,就栽在.data段的初始化上——代码能跑,但全局变量全是乱的。
先看它们各自是干什么的:
| 段名 | 存储位置 | 运行位置 | 内容 |
|---|---|---|---|
| .text | ROM(Flash) | ROM(Flash) | 可执行指令、常量字符串 |
| .data | ROM(Flash) | RAM | 已初始化的全局/静态变量 |
| .bss | 无(仅占位) | RAM | 未初始化的全局/静态变量 |
为什么.data段要放在ROM里,却要搬到RAM里运行?
因为Flash只能读,不能写。你定义了一个全局变量 int count = 10;,这个「10」必须存在Flash里,但程序运行时count的值可能会变,所以必须搬到RAM里。启动代码(startup_xx.s)会负责在main()之前把.data从Flash拷贝到RAM。
至于.bss段,它不需要在Flash里占空间——反正初始值都是0,启动代码直接清零RAM区域就行。
我的经验:做Bootloader时,我习惯把.data段和.bss段的起始地址、长度定义成全局符号,方便在启动代码里引用。比如:_sdata、_edata、_sbss、_ebss。这样写汇编启动代码时,直接ldr这些符号地址就行,不用硬编码。
3.3 链接脚本语法:MEMORY和SECTIONS命令
链接脚本的核心就两个命令:MEMORY 和 SECTIONS。一个定义「有什么内存」,一个定义「怎么放段」。
3.3.1 MEMORY命令:声明芯片的内存资源
语法很简单:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 128K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
}
这里定义了:
- FLASH:起始地址0x08000000,大小128KB,属性可读可执行(rx)
- RAM:起始地址0x20000000,大小64KB,属性可读可写可执行(rwx)
属性里的r/w/x分别代表读、写、执行。Bootloader的代码段一般放在rx区域,数据段放在rwx区域。
我曾经踩过的坑:有一次我把RAM的属性写成了(rw),漏了x。结果代码里有个函数指针跳转到RAM执行,直接HardFault。查了半天才发现是链接脚本里RAM少了个x属性。所以做Bootloader时,如果要在RAM里执行代码(比如Flash擦写时的临时代码),记得加上x。
3.3.2 SECTIONS命令:把段「放」到内存里
这是链接脚本最核心的部分。一个典型的SECTIONS配置长这样:
SECTIONS
{
. = ORIGIN(FLASH);
.text :
{
KEEP(*(.isr_vector)) /* 中断向量表,必须保留 */
*(.text*) /* 所有.text段 */
*(.rodata*) /* 只读数据 */
_etext = .; /* .text结束地址 */
} > FLASH
. = ORIGIN(RAM);
.data : AT(_etext)
{
_sdata = .; /* .data起始地址 */
*(.data*) /* 所有.data段 */
_edata = .; /* .data结束地址 */
} > RAM AT> FLASH
.bss :
{
_sbss = .; /* .bss起始地址 */
*(.bss*) /* 所有.bss段 */
_ebss = .; /* .bss结束地址 */
} > RAM
}
这里有几个关键点:
KEEP():告诉链接器,这个段即使没人引用也要保留。中断向量表必须用KEEP,否则优化时会被删掉。AT():指定加载地址(LMA)。.data段的运行地址(VMA)在RAM,但加载地址(LMA)在Flash的_etext处。启动代码就是根据这个差值来拷贝数据的。> FLASH和> RAM:指定段放在哪个内存区域。
VMA vs LMA:VMA(Virtual Memory Address)是程序运行时的地址,LMA(Load Memory Address)是程序烧录时的地址。对于.text段,VMA和LMA相同;对于.data段,VMA在RAM,LMA在Flash。
3.4 自定义段(.boot_header)的实现:给Bootloader加个「身份证」
做Bootloader时,经常需要在固件开头放一个自定义头部,里面包含:固件版本、校验和、长度、入口地址等信息。这个头部不能随便放,必须放在Flash的最前面,而且格式要固定。
怎么做?用自定义段。
首先,在C代码里定义一个结构体,并指定它放在自定义段里:
// boot_header.h
typedef struct {
uint32_t magic; // 魔数,用于识别
uint32_t version; // 固件版本
uint32_t length; // 固件总长度
uint32_t checksum; // 校验和
uint32_t entry_point; // 入口地址
uint32_t reserved[3]; // 保留
} boot_header_t;
// 放在自定义段 .boot_header 中
__attribute__((section(".boot_header")))
const boot_header_t g_boot_header = {
.magic = 0xBOOTLOAD,
.version = 0x0100,
.length = 0,
.checksum = 0,
.entry_point= 0x08000000,
.reserved = {0, 0, 0}
};
然后,在链接脚本里定义这个段,并强制放在Flash的最开头:
SECTIONS
{
. = ORIGIN(FLASH);
.boot_header :
{
KEEP(*(.boot_header))
. = ALIGN(4);
} > FLASH
.text :
{
KEEP(*(.isr_vector))
*(.text*)
*(.rodata*)
_etext = .;
} > FLASH
/* ... 其余段 ... */
}
这样编译出来的固件,前32个字节就是boot_header。上位机烧录工具可以直接读取这个头部,验证魔数和校验和,确认固件是否合法。
我建议:自定义段的起始地址最好4字节对齐,因为MCU的Flash通常按字(4字节)访问。另外,记得用KEEP()防止链接器优化掉这个段——毕竟它没有被任何代码显式引用。
3.5 本章知识体系:链接脚本全景图
下面这张图,把链接脚本的核心逻辑串起来了:
3.6 避坑指南:链接脚本常见问题
做Bootloader移植时,链接脚本最容易出问题的几个地方:
- 中断向量表偏移:如果Bootloader占用了Flash前段空间,应用程序的中断向量表就不能放在0x08000000了。需要在链接脚本里把应用程序的Flash起始地址偏移,同时修改SCB->VTOR寄存器。
- .data段拷贝不对齐:启动代码拷贝.data时,如果源地址或目标地址没有4字节对齐,在Cortex-M3/M4上会触发异常。我建议在链接脚本里用
. = ALIGN(4);保证对齐。 - 符号引用错误:启动代码里引用的
_sdata、_etext等符号,必须在链接脚本里正确定义。少定义一个,链接就报错。 - 自定义段被优化:记得用
KEEP(),否则-O2优化下,链接器可能认为这个段是「死代码」直接删掉。
我曾经遇到的一个诡异问题:Bootloader能正常跳转到APP,但APP里的全局变量全是乱的。查了两天,最后发现是链接脚本里.data段的LMA地址写错了——AT()指定的地址和实际Flash里存放的位置差了4个字节。启动代码拷贝时,源地址偏移了,拷到RAM里的数据全是错的。
嗯,链接脚本这东西,说难不难,说简单也不简单。关键是要理解它背后的逻辑:段是逻辑的,地址是物理的,链接脚本就是连接这两者的桥梁。做Bootloader移植时,花半小时把链接脚本吃透,能省下后面几天的调试时间。