4. 固件组成与格式
说实话,很多做固件的工程师,写了好几年代码,都没怎么碰过 .ld 文件。编译器一把梭,烧进去能跑就行。但一旦遇到芯片内存不够、需要精确控制变量存放位置、或者要做 bootloader 分区,你就绕不开它了。
链接脚本,说白了就是告诉链接器:你的代码和变量,应该放在硬件的哪个地址上。它是一张内存地图,也是一份段布局的施工图。今天我就带你把它彻底吃透。
4.1 链接脚本到底在干什么?
我们先看一个最简单的例子。假设你写了一个 main.c:
int global_var = 42; // 已初始化的全局变量
int uninit_var; // 未初始化的全局变量
const char msg[] = "Hello"; // 只读常量
void func(void) {
static int count = 0; // 静态局部变量
int local = 10; // 栈变量
}
编译之后,这些符号会被分配到不同的段(Section):
func()的指令 → .textmsg[]和字符串常量 → .rodataglobal_var = 42→ .datauninit_var和count→ .bsslocal→ 运行时栈,不占用固定段
链接脚本的任务,就是把这些段,按你指定的顺序和地址,拼接到最终的二进制文件里。
核心认知:链接脚本 = 内存布局 + 段映射 + 符号地址决议。没有它,编译器生成的 .o 文件只是一堆碎片。
4.2 内存布局:ROM 和 RAM 的分工
嵌入式系统里,ROM(Flash)和 RAM 的分工非常明确:
| 存储介质 | 存放内容 | 特点 |
|---|---|---|
| ROM / Flash | .text, .rodata, .data 的初始值 | 掉电不丢失,只读执行 |
| RAM | .data 运行时副本, .bss, 堆, 栈 | 掉电丢失,可读写 |
这里有个关键点:.data 段在 Flash 里存了一份初始值,但运行时必须搬到 RAM 里。为什么?因为全局变量是要被修改的,不能放在只读的 Flash 里。这个搬运过程,就是所谓的「重定位」或「初始化」。
我画了一张图,帮你把整个内存布局串起来:
我的习惯:在链接脚本里,我会把 .data 的 LMA(加载地址)明确指向 Flash,VMA(运行地址)指向 RAM。然后在启动代码里用 memcpy 完成搬运。很多新手直接让 LMA = VMA,结果变量值一上电就丢了。
4.3 段(Section)的定义与重定位
链接脚本里,段是用 SECTIONS 块来描述的。我直接拿一个实际项目里的脚本片段来拆解:
SECTIONS
{
/* 中断向量表,必须放在 Flash 起始地址 */
.vectors : ALIGN(4)
{
__vectors_start = .;
KEEP(*(.vectors))
__vectors_end = .;
} > FLASH
/* 代码段 */
.text : ALIGN(4)
{
*(.text) /* 所有 .text 段 */
*(.text.*) /* 所有 .text.xxx 子段 */
*(.glue_7)
*(.glue_7t)
} > FLASH
/* 只读数据段 */
.rodata : ALIGN(4)
{
*(.rodata)
*(.rodata.*)
} > FLASH
/* 已初始化数据段 - 加载在 Flash,运行在 RAM */
.data : ALIGN(4)
{
__data_start = .;
*(.data)
*(.data.*)
__data_end = .;
} > RAM AT> FLASH
/* 零初始化段 */
.bss : ALIGN(4)
{
__bss_start = .;
*(.bss)
*(.bss.*)
*(COMMON)
__bss_end = .;
} > RAM
}
这里有几个概念,我当年刚接触时也绕了很久:
- VMA(Virtual Memory Address):段在运行时的地址。比如
.data的 VMA 在 RAM。 - LMA(Load Memory Address):段在烧录时的地址。比如
.data的 LMA 在 Flash。 - ALIGN(n):强制地址对齐到 n 字节。ARM 通常要求 4 字节对齐,否则可能触发硬件异常。
- KEEP():告诉链接器,即使这个段没被引用,也不要优化掉。中断向量表必须加这个。
我曾经踩过的坑:有一次 .bss 段忘记清零,结果全局变量上电后是随机值。设备偶尔启动正常,偶尔死机。查了两天才发现是启动代码里漏了 __bss_start 到 __bss_end 的 memset。从那以后,我每次写链接脚本都会手动检查清零逻辑。
4.4 如何自定义链接脚本?
实际项目中,很少有直接用默认链接脚本的。我给你列几个最常见的自定义场景:
| 场景 | 做法 | 关键点 |
|---|---|---|
| Bootloader + App 分区 | 定义两个 .text 段,分别映射到不同 Flash 地址 |
用 ORIGIN 和 LENGTH 划分 Flash 区域 |
| 关键变量放在特定 RAM 地址 | 用 . = 0x20000000; 强制定位 |
注意不要覆盖其他段 |
| 将日志字符串放到外部 Flash | 单独定义 .log_text 段,映射到 QSPI 地址空间 |
需要修改链接器命令行,添加内存区域 |
| 保留一段 RAM 给 DMA 使用 | 在 .bss 之后预留 . = . + 0x1000; |
配合 NOLOAD 避免初始化 |
举个例子,假设你要把某个大数组强制放到 RAM 的 0x20001000 地址:
/* 在 C 代码中 */
__attribute__((section(".dma_buf")))
uint8_t dma_buffer[4096] __attribute__((aligned(32)));
/* 在链接脚本中 */
.dma_buf 0x20001000 : ALIGN(32)
{
*(.dma_buf)
} > RAM
这样,dma_buffer 的地址就固定了。DMA 控制器不需要计算偏移,直接硬编码地址就行。
我个人的建议:自定义链接脚本时,一定要在脚本开头加上 MEMORY 命令,明确声明芯片的 Flash 和 RAM 范围。这样链接器会在地址越界时报错,而不是静默溢出。比如:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
4.5 链接脚本的调试技巧
写链接脚本,最怕的就是地址算错了。我分享几个调试方法:
- 查看 .map 文件:编译时加
-Wl,-Map=output.map,链接器会输出每个符号的最终地址。这是最直接的调试手段。 - 使用
PROVIDE关键字:在脚本里定义符号,比如PROVIDE(__stack_top = 0x20020000);,然后在 C 代码里用extern引用。这样启动代码就能拿到栈顶地址。 - 检查段大小:用
size命令查看各段占用。如果.bss比预期大很多,可能是某个大数组没初始化。
一句话总结:链接脚本就是固件的「城市规划图」。哪里放代码,哪里放变量,哪里留给堆栈,全由它说了算。花半小时把 .ld 文件吃透,能省下后面数小时的调试时间。