3. 链接脚本深度解析:语法、内存布局与符号绑定
说实话,很多做MCU开发的朋友,写了几年C代码,却从来没碰过链接脚本。我刚开始也是这样——直到有一次,我的Bootloader死活跳不到应用程序,折腾了两天,最后发现是链接脚本里一个段地址配错了。嗯,从那以后,我再也不敢跳过链接脚本了。
链接脚本,说白了就是告诉链接器:你的代码该放哪,数据该放哪,每个符号的最终地址是什么。没有它,编译器生成的.elf文件就是一盘散沙。
3.1 链接脚本的基本语法
GNU LD的链接脚本,扩展名通常是.ld或.lds。它的语法其实不复杂,核心就几个关键字。
| 关键字 | 作用 | 我常用的场景 |
|---|---|---|
ENTRY |
指定程序入口点 | Bootloader的复位向量 |
MEMORY |
定义目标芯片的物理内存区域 | Flash、SRAM、ITCM、DTCM |
SECTIONS |
描述如何将输入段合并到输出段 | .text、.data、.bss的布局 |
PROVIDE |
定义符号,如果用户未定义则使用默认值 | 堆栈顶地址、_sidata等 |
一个最简单的链接脚本骨架长这样:
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.text : { *(.text*) } > FLASH
.data : { *(.data*) } > SRAM AT> FLASH
.bss : { *(.bss*) } > SRAM
}
你看,是不是很直观?MEMORY定义了两块区域,SECTIONS把代码段放Flash,数据段放SRAM。但实际项目里,远没这么简单。
3.2 内存区域定义:别让你的芯片“爆仓”
定义内存区域时,我见过最典型的错误就是地址写错。有一次同事把STM32F103的Flash起始地址写成了0x08001000,结果程序死活跑不起来——因为中断向量表没在正确的位置。
定义内存区域时,有四个属性要搞清楚:
- r:可读。几乎所有区域都有。
- w:可写。RAM必须有,Flash一般没有(除非你要做IAP)。
- x:可执行。代码段所在的区域必须有。
- !:取反。比如
!x表示不可执行。
我个人习惯把内存区域定义得细一些。比如一个带TCM的MCU,我会这样写:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1M
ITCM (rwx) : ORIGIN = 0x00000000, LENGTH = 16K
DTCM (rw) : ORIGIN = 0x20000000, LENGTH = 16K
SRAM1 (rwx) : ORIGIN = 0x20004000, LENGTH = 112K
SRAM2 (rw) : ORIGIN = 0x20020000, LENGTH = 64K
}
为什么要分这么细?因为不同的内存有不同的访问速度。中断服务函数放ITCM,全局变量放DTCM,普通代码放Flash——性能能差好几倍。
3.3 段的分配与布局:代码到底放哪?
段(Section)是链接脚本的核心。每个段都有两个地址:加载地址(LMA)和运行地址(VMA)。
- VMA:程序运行时,这个段在内存中的位置。
- LMA:这个段在烧录文件(如.hex)中的存放位置。
对于只读的.text段,VMA和LMA通常是同一个地址——都在Flash里。但对于.data段,情况就不同了:它运行时在SRAM(VMA),但初始值却存在Flash里(LMA)。
这就是为什么链接脚本里会有> SRAM AT> FLASH这种写法。我当年第一次看到这个,愣了半天才反应过来。
.data段从Flash拷贝到SRAM,并把.bss段清零。这个工作通常在Reset_Handler里完成。
一个完整的段布局示例:
SECTIONS
{
/* 中断向量表,必须放在Flash起始地址 */
.isr_vector : { *(.isr_vector) } > FLASH
/* 代码段 */
.text : { *(.text*) *(.rodata*) } > FLASH
/* 初始化数据,运行时在SRAM,初始值在Flash */
.data :
{
_sdata = .; /* 数据段起始地址 */
*(.data*)
_edata = .; /* 数据段结束地址 */
} > SRAM AT> FLASH
_sidata = LOADADDR(.data); /* 数据段在Flash中的起始地址 */
/* 未初始化数据,运行时清零 */
.bss :
{
_sbss = .; /* BSS段起始地址 */
*(.bss*)
_ebss = .; /* BSS段结束地址 */
} > SRAM
/* 堆和栈 */
._user_heap_stack :
{
. = ALIGN(8);
PROVIDE ( _end = . );
. = . + _Min_Heap_Size;
. = . + _Min_Stack_Size;
. = ALIGN(8);
} > SRAM
}
注意看那些_sdata、_edata、_sidata——它们就是符号,启动代码里要用它们来拷贝数据。
3.4 符号表与地址绑定:链接器怎么知道变量在哪?
链接脚本里定义的符号,本质上就是给某个地址起个名字。比如_sdata = .,就是把当前地址赋值给_sdata这个符号。
这些符号在C代码里怎么用?用extern声明一下就行:
extern uint32_t _sdata;
extern uint32_t _edata;
extern uint32_t _sidata;
void data_init(void)
{
uint32_t *src = &_sidata;
uint32_t *dst = &_sdata;
while (dst < &_edata) {
*dst++ = *src++;
}
}
这里有个坑:&_sdata取的是符号的地址,而不是符号的值。因为_sdata本身就是一个地址值,所以&_sdata就是它的地址——嗯,有点绕,但用多了就习惯了。
PROVIDE来定义符号。这样如果C代码里没定义,就用默认值;如果定义了,就覆盖链接脚本的值。比如堆栈大小,我经常在C代码里重新定义。
3.5 知识结构总览
下面这张图,是我梳理的链接脚本核心知识体系。你看一眼,心里就有谱了。
3.6 实战:Bootloader的链接脚本要点
做Bootloader时,链接脚本有几个地方要特别小心。我曾经在一个项目里,因为忘了改Flash偏移地址,导致Bootloader和应用程序的中断向量表冲突——两个都指向同一个位置,结果一触发中断就死机。
ORIGIN改成对应的地址。
一个典型的Bootloader链接脚本片段:
/* Bootloader占用前64KB Flash */
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K
/* 应用程序从0x08010000开始 */
/* FLASH (rx) : ORIGIN = 0x08010000, LENGTH = 448K */
另外,Bootloader里通常需要定义一些特殊符号,比如应用程序的起始地址:
_app_start = 0x08010000;
然后在C代码里,跳转时就用这个地址:
extern uint32_t _app_start;
void jump_to_app(void)
{
uint32_t app_sp = *(uint32_t *)_app_start;
uint32_t app_pc = *(uint32_t *)(_app_start + 4);
__set_MSP(app_sp);
((void (*)(void))app_pc)();
}
_flash_start、_sram_start、_stack_top。这样C代码里直接引用,清晰又安全。别在代码里硬编码地址——改一次链接脚本就够了,不用到处找魔数。
嗯,链接脚本这东西,说难不难,说简单也不简单。关键是要理解它背后的逻辑:你写的每一行C代码,最终都要通过链接脚本映射到芯片的物理地址上。搞懂了这一点,你就掌握了MCU开发的底层密码。