2. 嵌入式启动流程基础
链接脚本,说白了就是告诉链接器:你的代码该放哪儿,数据该放哪儿。很多嵌入式开发者写了好几年代码,却从来没碰过 .ld 文件。嗯,这其实是个大坑。我记得刚入行那会儿,就因为链接地址配错了,整块板子死活跑不起来,折腾了两天才发现是 .text 段跑到 RAM 里去了。
今天我们就来彻底搞懂 GNU LD 链接脚本。我会带着你从最简单的脚本开始,一步步深入到 SECTIONS 命令的每个细节。
2.1 链接脚本的基本结构
一个典型的链接脚本长这样:
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.text :
{
*(.isr_vector)
*(.text)
*(.text.*)
*(.rodata)
_etext = .;
} > FLASH
.data :
{
_sdata = .;
*(.data)
_edata = .;
} > RAM AT> FLASH
.bss :
{
_sbss = .;
*(.bss)
*(COMMON)
_ebss = .;
} > RAM
}
别被这堆符号吓到。我们拆开来看,其实就三个核心部分:ENTRY、MEMORY、SECTIONS。
| 命令 | 作用 | 我常用的坑 |
|---|---|---|
| ENTRY | 指定程序入口点 | 忘了写,默认是 _start,但你的启动文件可能叫 Reset_Handler |
| MEMORY | 定义可用内存区域 | ORIGIN 和 LENGTH 写错,链接器直接报错 |
| SECTIONS | 控制各段的放置位置 | 这是最复杂的,也是我们今天的主菜 |
2.2 MEMORY 命令:定义你的硬件资源
MEMORY 命令说白了就是告诉链接器:你的芯片上有哪些内存,分别在哪,多大。我个人习惯把 MEMORY 写在 SECTIONS 前面,这样后面引用起来一目了然。
语法很简单:
MEMORY
{
名字 (属性) : ORIGIN = 起始地址, LENGTH = 长度
}
属性有四种:
- r:只读
- w:可写
- x:可执行
- !:取反,比如 !x 表示不可执行
2.3 SECTIONS 命令:核心中的核心
SECTIONS 命令才是链接脚本的灵魂。它决定了每个段放在哪个内存区域,以及段之间的顺序。我曾经在一个项目里,因为 .bss 段放在了 .data 前面,导致全局变量初始化全乱了,查了两天才发现是链接脚本顺序问题。
2.3.1 段定义的基本语法
SECTIONS
{
.段名 :
{
内容
} > 内存区域
}
每个段定义包含三部分:
- 段名:比如 .text、.data、.bss,也可以自定义
- 内容:用
*()语法指定哪些输入段放进来 - 放置位置:
> 内存区域指定放哪个 MEMORY 里
2.3.2 通配符与输入段匹配
*(.text) 这个写法,星号代表所有目标文件。说白了就是:把所有目标文件里的 .text 段都收集过来。
更精细的写法:
*(.text):所有 .text 段*(.text.*):所有 .text.xxx 子段startup.o(.text):只取 startup.o 的 .text 段*(EXCLUDE_FILE(*startup.o) .text):排除 startup.o
*(.text .text.*) 漏掉了 .rodata,结果只读常量全丢了。后来我习惯写成 *(.text) *(.text.*) *(.rodata),一个不落。
2.4 位置计数器与符号赋值
链接脚本里有个特殊符号 .,叫位置计数器。它代表当前地址。你可以用它来记录段的边界:
.text :
{
_stext = .; // 记录 .text 起始地址
*(.text)
_etext = .; // 记录 .text 结束地址
} > FLASH
这些符号在 C 代码里可以这样用:
extern uint32_t _stext;
extern uint32_t _etext;
// 注意:取地址才是真正的值
uint32_t text_size = (uint32_t)&_etext - (uint32_t)&_stext;
& 取地址。很多人直接拿变量值用,结果拿到的是地址本身,这个坑我踩过不止一次。
2.5 加载地址与运行地址:AT 命令
这是嵌入式链接脚本最精妙的地方。想想看:代码在 Flash 里执行没问题,但 .data 段需要初始化值,这些值也得存在 Flash 里。可运行时 .data 必须在 RAM 里。怎么办?
答案就是 加载地址(LMA) 和 运行地址(VMA) 分离:
.data :
{
_sdata = .;
*(.data)
_edata = .;
} > RAM AT> FLASH // VMA 在 RAM,LMA 在 Flash
> RAM 指定运行地址(VMA),AT> FLASH 指定加载地址(LMA)。启动代码需要把数据从 Flash 拷贝到 RAM:
extern uint32_t _sdata, _edata, _sidata;
// _sidata 是 Flash 里的加载地址,需要自己在链接脚本里定义
uint32_t *src = &_sidata;
uint32_t *dst = &_sdata;
while (dst < &_edata) {
*dst++ = *src++;
}
2.6 BSS 段的清零处理
.bss 段存放未初始化的全局变量。运行时必须清零。链接脚本里记录边界:
.bss :
{
_sbss = .;
*(.bss)
*(COMMON)
_ebss = .;
} > RAM
启动代码里清零:
extern uint32_t _sbss, _ebss;
uint32_t *p = &_sbss;
while (p < &_ebss) {
*p++ = 0;
}
2.7 完整示例:一个实际项目的链接脚本
下面是我在一个 STM32F4 项目里用过的脚本,加了详细注释:
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1024K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
CCM (rw) : ORIGIN = 0x10000000, LENGTH = 64K // 内核紧耦合内存
}
SECTIONS
{
// 中断向量表必须放在最前面
.isr_vector :
{
KEEP(*(.isr_vector))
} > FLASH
// 代码段
.text :
{
*(.text)
*(.text.*)
*(.glue_7)
*(.glue_7t)
*(.rodata)
*(.rodata.*)
_etext = .;
} > FLASH
// 初始化数据:运行时在 RAM,初始值在 Flash
.data : ALIGN(4)
{
_sdata = .;
*(.data)
*(.data.*)
_edata = .;
} > RAM AT> FLASH
// 记录 Flash 里的加载地址
_sidata = LOADADDR(.data);
// BSS 段
.bss : ALIGN(4)
{
_sbss = .;
__bss_start__ = _sbss;
*(.bss)
*(.bss.*)
*(COMMON)
_ebss = .;
__bss_end__ = _ebss;
} > RAM
// 把 CCM 内存也映射一下,给 DMA 用
.ccmram (NOLOAD) :
{
*(.ccmram)
} > CCM
}
2.8 知识结构图
下面这张图总结了链接脚本的核心逻辑:
2.9 常见陷阱与调试技巧
最后分享几个我实战中踩过的坑:
- 段顺序问题:中断向量表必须放在 .text 最前面,否则芯片上电后 PC 指针会飞到奇怪的地方
- ALIGN 对齐:ARM 要求代码 4 字节对齐,
.text : ALIGN(4)可以保证 - KEEP 关键字:
KEEP(*(.isr_vector))防止链接器优化掉中断向量表 - NOLOAD 段:像 CCM 这种不需要初始化的内存,加
(NOLOAD)避免生成初始化数据
arm-none-eabi-objdump -h 你的.elf 查看各段的 VMA 和 LMA。我曾经靠这个命令,五分钟就定位到了 .data 段加载地址配错的问题。
链接脚本这东西,说白了就是个地址映射表。你给它输入段,它给你输出地址。掌握了 SECTIONS 命令,你就能精确控制每一行代码、每一个变量在内存里的位置。下次遇到启动代码跑飞、全局变量初始化失败这类问题,先检查链接脚本——八成是它的问题。