4. 存储介质
链接脚本,说白了就是告诉链接器:你的芯片内存长什么样,代码该放哪儿,数据该放哪儿。很多做应用层开发的同事觉得这东西可有可无,但做固件的人都知道——链接脚本写不对,轻则程序跑飞,重则直接变砖。
我记得刚入行那会儿,接手一个老项目,发现启动后全局变量全是乱的。查了两天,最后发现是链接脚本里 .bss 段的起始地址和 .data 段重叠了。嗯,从那以后我写链接脚本都会反复核对地址范围。
4.1 内存布局定义:告诉链接器你的硬件长什么样
每个芯片都有不同的存储资源。常见的包括:
- Flash(或 ROM):存放代码和只读数据,掉电不丢失
- SRAM:存放可读写数据,掉电丢失
- ITCM / DTCM:紧耦合内存,访问速度极快
- DDR:大容量外部内存,常用于 Linux 或复杂 RTOS
在链接脚本里,我们用 MEMORY 命令来定义这些区域。来看一个典型的 Cortex-M 芯片定义:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
ITCM (rwx) : ORIGIN = 0x00000000, LENGTH = 16K
DTCM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
}
每个区域都有三个属性:
- ORIGIN:起始地址,必须和芯片手册一致
- LENGTH:大小,单位可以是 K、M
- 访问权限:r(读)、w(写)、x(执行)
4.2 段(Section)分配:把代码和数据安排得明明白白
段是链接脚本的核心概念。每个段都有名字、类型和存放位置。常见的标准段包括:
| 段名 | 内容 | 存放位置 | 特点 |
|---|---|---|---|
| .text | 可执行代码、常量 | Flash | 只读、可执行 |
| .rodata | 只读数据(字符串、const 变量) | Flash | 只读 |
| .data | 已初始化的全局/静态变量 | SRAM(初始值在 Flash) | 读写、需搬运 |
| .bss | 未初始化的全局/静态变量 | SRAM | 读写、需清零 |
| .stack | 栈空间 | SRAM | 运行时动态使用 |
| .heap | 堆空间(malloc 用) | SRAM | 运行时动态分配 |
段分配用 SECTIONS 命令来实现。下面是一个典型的分配脚本:
SECTIONS
{
/* 代码段:放在 Flash 开头 */
.text :
{
*(.isr_vector) /* 中断向量表,必须放在最前面 */
*(.text*) /* 所有 .text 子段 */
*(.rodata*) /* 只读数据 */
_etext = .; /* 标记代码段结束地址 */
} > FLASH
/* 已初始化数据段:运行时在 SRAM,初始值在 Flash */
.data : AT(_etext)
{
_sdata = .; /* 数据段起始地址 */
*(.data*)
_edata = .; /* 数据段结束地址 */
} > SRAM
/* BSS 段:运行时在 SRAM,需要清零 */
.bss :
{
_sbss = .;
*(.bss*)
*(COMMON)
_ebss = .;
} > SRAM
}
关键点:.data 段用了 AT(_etext) 语法。这表示段运行时在 SRAM,但初始值存放在 Flash 中紧接 .text 段之后的位置。启动代码需要把这段数据从 Flash 搬运到 SRAM。
4.3 符号定位:让代码能找到它该找的东西
符号定位是链接脚本里最容易被忽视但又极其重要的部分。说白了,就是给某些关键地址起个名字,让 C 代码能直接引用。
常见的符号定位场景:
- 段边界标记:如
_sdata、_ebss,用于启动代码搬运和清零 - 栈顶地址:如
_estack,用于设置 MSP(主栈指针) - 堆边界:如
_heap_start、_heap_end,用于 malloc 实现 - 自定义锚点:如某个特殊功能区的基地址
来看一个完整的符号定义示例:
/* 在链接脚本中定义符号 */
_estack = ORIGIN(SRAM) + LENGTH(SRAM); /* 栈顶在 SRAM 末尾 */
_Min_Heap_Size = 0x200; /* 最小堆大小 512 字节 */
_Min_Stack_Size = 0x400; /* 最小栈大小 1KB */
SECTIONS
{
/* ... 段定义 ... */
/* 在 C 代码中引用这些符号 */
/* extern uint32_t _estack; */
/* extern uint32_t _sdata; */
/* extern uint32_t _ebss; */
}
我个人习惯:在链接脚本里把所有关键符号集中放在文件开头,用注释说明每个符号的用途。这样后续维护时,不用翻遍整个文件去找某个符号定义在哪里。
4.4 链接脚本的完整结构图
下面这张图展示了链接脚本从内存定义到符号定位的完整流程:
4.5 实战中的避坑指南
写链接脚本这么多年,我总结了几条血泪教训:
- 地址对齐很重要:ARM Cortex-M 要求中断向量表必须 256 字节对齐。我曾经因为没对齐,导致中断响应异常,查了整整一天。
- 段顺序不能乱:.isr_vector 必须放在 .text 段的最前面,否则芯片上电后找不到中断入口。
- 符号名不要冲突:链接脚本里定义的符号和 C 代码中的变量名不要重复,否则链接器会报多重定义错误。
- 注意 LENGTH 的单位:K 是 1024,M 是 1048576,别搞混了。
我曾经遇到过一个特别隐蔽的问题:某次在 .data 段搬运时,启动代码里用的搬运长度是 _edata - _sdata,但链接脚本里这两个符号定义错了顺序,导致搬运长度变成负数。程序跑起来后,全局变量全是乱的。从那以后,我每次写完链接脚本都会用 objdump -t 检查符号地址。
4.6 多分区场景下的链接脚本
在实际项目中,我们经常需要把固件分成多个分区。比如:
- Bootloader 分区:放在 Flash 起始地址,负责固件升级和启动校验
- App 分区:放在 Bootloader 之后,存放主应用程序
- 参数分区:存放设备配置、校准数据等
- 备份分区:OTA 升级时存放新固件
每个分区都需要独立的链接脚本,或者通过条件编译来切换。来看一个 Bootloader 的链接脚本片段:
MEMORY
{
/* Bootloader 占用 Flash 前 32KB */
BOOT_FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 32K
/* App 从 0x08008000 开始 */
APP_FLASH (rx) : ORIGIN = 0x08008000, LENGTH = 480K
/* 参数区在 Flash 末尾 */
PARAM_FLASH (r) : ORIGIN = 0x0807E000, LENGTH = 8K
SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.text :
{
*(.isr_vector)
*(.text*)
*(.rodata*)
_etext = .;
} > BOOT_FLASH
/* ... 其他段定义 ... */
}
我建议:在多分区项目中,为每个分区单独维护一个链接脚本模板。这样修改某个分区的内存布局时,不会影响到其他分区。同时,在 Makefile 或 CMakeLists.txt 中通过变量来指定使用哪个脚本。