第2章:链接脚本入门:GNU LD链接脚本语法,SECTIONS命令与内存区域定义
好,咱们进入正题。上一章聊了内存布局的宏观概念,这一章就得动真格的了——写链接脚本。说实话,我刚开始接触Bootloader开发时,最头疼的就是这个链接脚本。看着那些花括号、点号、星号,完全不知道在干什么。后来踩了无数坑才明白,这东西其实就是告诉链接器:「我的代码放哪,数据放哪,别给我乱塞」。
2.1 链接脚本到底是个啥?
说白了,链接脚本就是一张「地图」。编译器把每个C文件编译成目标文件(.o),这些目标文件里有很多段:代码段(.text)、数据段(.data)、BSS段(.bss)等等。链接器的任务就是把它们拼到一起,拼成最终的二进制文件。
但问题来了——拼到一起放哪?是放在0x08000000还是0x20000000?这得由你说了算。链接脚本就是用来干这个的。
我记得第一次写STM32的Bootloader时,没注意链接脚本里的FLASH起始地址,结果代码烧进去直接跑飞。嗯,从那以后我每次改链接脚本都会反复确认三遍地址对不对。
2.2 GNU LD脚本的基本结构
一个典型的GNU LD链接脚本,长这样:
OUTPUT_FORMAT("elf32-littlearm")
OUTPUT_ARCH(arm)
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 128K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 40K
}
SECTIONS
{
.text :
{
*(.vectors)
*(.text)
*(.text.*)
*(.rodata)
*(.rodata.*)
_etext = .;
} > FLASH
.data : AT (ADDR(.text) + SIZEOF(.text))
{
_sdata = .;
*(.data)
*(.data.*)
_edata = .;
} > RAM
.bss :
{
_sbss = .;
*(.bss)
*(.bss.*)
*(COMMON)
_ebss = .;
} > RAM
}
看着有点复杂?别急,咱们拆开来看。
2.3 MEMORY命令:定义内存区域
MEMORY命令用来声明你的芯片有哪些内存区域,以及它们的地址范围和属性。格式很简单:
MEMORY
{
名字 (属性) : ORIGIN = 起始地址, LENGTH = 长度
}
属性有几种:
- r:可读
- w:可写
- x:可执行
- !:取反,比如!rx表示不可读不可执行
我个人习惯把FLASH设为rx(可读可执行),RAM设为rwx(可读可写可执行)。为什么RAM要可执行?因为有时候Bootloader需要把代码拷贝到RAM里跑,比如做在线升级时。
2.4 SECTIONS命令:编排输出段
SECTIONS命令是链接脚本的核心。它告诉链接器:每个输入段应该放到哪个输出段,以及这个输出段放在哪个内存区域。
基本语法:
SECTIONS
{
段名 :
{
输入段列表
} > 内存区域
}
这里的「段名」就是输出段的名字,比如.text、.data、.bss。输入段列表用通配符匹配,比如:
*(.text):所有目标文件的.text段*(.text.*):所有目标文件的.text.xxx段(编译器有时会生成这种子段)*(.rodata):只读数据段
你想想看,为什么要有通配符?因为你的工程可能有几十个.c文件,每个都生成一个.text段。用通配符就能一把抓,不用一个一个写。
2.5 位置计数器「.」
在SECTIONS里,你会经常看到一个点号「.」。这个点号叫位置计数器,它表示当前地址。比如:
.text :
{
*(.vectors)
*(.text)
_etext = .;
} > FLASH
这里的_etext = .;意思是:把当前地址赋值给符号_etext。这个符号可以在C代码里用extern声明后直接使用,用来获取代码段的结束地址。
我在项目中经常用这个技巧来计算代码大小,或者确定数据段的加载地址。比如:
extern uint32_t _etext;
// 在代码里可以这样用
uint32_t code_size = (uint32_t)&_etext - 0x08000000;
2.6 加载地址 vs 运行地址
这是新手最容易搞混的地方。看这句:
.data : AT (ADDR(.text) + SIZEOF(.text))
AT (...)指定的是加载地址(LMA),而> RAM指定的是运行地址(VMA)。什么意思呢?
- 加载地址:数据在烧录文件里存放的位置(通常在FLASH)
- 运行地址:程序运行时数据实际所在的位置(通常在RAM)
为什么会这样?因为RAM掉电会丢数据,所以初始化数据必须存在FLASH里。上电后,Bootloader要把这些数据从FLASH拷贝到RAM里。这就是.data段需要两个地址的原因。
关键点: 如果你忘了写AT (...),链接器会默认加载地址等于运行地址。对于.data段来说,这意味着数据会被放在RAM里,但烧录文件里却没有它——上电后RAM里全是随机值,程序直接崩掉。我当年就因为这个bug调了整整两天。
2.7 常用符号定义
在链接脚本里定义符号,可以在C代码里直接引用。常用的有:
| 符号名 | 含义 | 典型用途 |
|---|---|---|
_etext |
代码段结束地址 | 计算代码大小 |
_sdata |
数据段起始地址 | 拷贝数据到RAM |
_edata |
数据段结束地址 | 判断拷贝是否完成 |
_sbss |
BSS段起始地址 | 清零BSS段 |
_ebss |
BSS段结束地址 | 判断清零范围 |
_stack_top |
栈顶地址 | 初始化栈指针 |
这些符号的命名没有强制规定,但业界基本都这么用。我个人习惯再加一个_flash_start和_ram_start,方便在代码里获取内存区域的基地址。
2.8 一个完整的Bootloader链接脚本示例
最后,给你看一个我实际项目中用过的链接脚本(简化版):
OUTPUT_FORMAT("elf32-littlearm")
OUTPUT_ARCH(arm)
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
}
_estack = ORIGIN(RAM) + LENGTH(RAM);
SECTIONS
{
.isr_vector :
{
*(.isr_vector)
} > FLASH
.text :
{
*(.text)
*(.text.*)
*(.glue_7)
*(.glue_7t)
*(.gnu.linkonce.t.*)
} > FLASH
.rodata :
{
*(.rodata)
*(.rodata.*)
*(.gnu.linkonce.r.*)
} > FLASH
.ARM.extab : { *(.ARM.extab*) } > FLASH
.ARM.exidx : { *(.ARM.exidx*) } > FLASH
_sidata = LOADADDR(.data);
.data : AT (_sidata)
{
_sdata = .;
*(.data)
*(.data.*)
*(.gnu.linkonce.d.*)
_edata = .;
} > RAM
.bss :
{
_sbss = .;
*(.bss)
*(.bss.*)
*(.gnu.linkonce.b.*)
*(COMMON)
_ebss = .;
} > RAM
._user_heap_stack :
{
. = ALIGN(8);
PROVIDE ( end = . );
PROVIDE ( _end = . );
. = . + _Min_Heap_Size;
. = . + _Min_Stack_Size;
. = ALIGN(8);
} > RAM
}
小技巧: 注意看_sidata = LOADADDR(.data);这一行。LOADADDR()函数可以获取某个段的加载地址。这样就不用手动计算AT的表达式了,代码更清晰,也不容易算错。
嗯,到这里链接脚本的基础语法就讲完了。你可能会觉得内容有点多,但别担心——写链接脚本就像骑自行车,刚开始觉得别扭,多写几次就顺手了。下一章我会讲怎么用这个链接脚本配合启动代码,把Bootloader真正跑起来。