3. 链接脚本深度解析:链接脚本的作用、内存布局定义、段(Section)的划分、符号表的生成
说实话,很多做嵌入式开发的朋友,写了几年 C 代码,却从来没仔细看过链接脚本。我刚开始带项目时也是这样,直到有一次移植 Bootloader 到一颗新芯片上,程序死活跑不起来。折腾了两天,最后发现是链接脚本里 RAM 地址写错了。嗯,从那以后,我再也不敢把链接脚本当「黑盒子」了。
链接脚本,说白了就是告诉编译器:你的代码该放哪儿,数据该放哪儿,堆栈该放哪儿。它决定了最终生成的二进制文件长什么样。今天我们就把它彻底拆开看看。
3.1 链接脚本到底在干什么?
你写好的 C 代码,经过编译变成一堆 .o 目标文件。这些文件里都是零散的代码段、数据段。链接脚本的任务,就是把这些碎片拼成一个完整的、能在芯片上运行的镜像。
我习惯把链接脚本比作「搬家公司的装箱清单」:
- 哪些东西要搬 —— 代码段、数据段、BSS 段
- 搬到哪个房间 —— Flash 地址、RAM 地址
- 怎么摆放 —— 段的顺序、对齐方式
- 贴什么标签 —— 符号地址、入口点
核心要点:链接脚本不产生任何代码,它只负责「编排」。但编排错了,程序一定跑不起来。
3.2 内存布局定义 —— 你得先知道芯片长什么样
写链接脚本的第一步,是定义目标芯片的内存布局。不同芯片的 Flash 和 RAM 地址范围完全不同。比如 STM32F103 和 S32K144,它们的地址映射天差地别。
我一般会在链接脚本开头用 MEMORY 命令来声明:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
这里每个参数都有讲究:
| 参数 | 含义 | 我的经验 |
|---|---|---|
ORIGIN |
起始地址 | 必须和芯片手册一致,差一个字都不行 |
LENGTH |
可用大小 | 别写满,留点余量给调试 |
rx |
读+执行权限 | Flash 一般只读执行,不能写 |
rwx |
读+写+执行 | RAM 可以读写,也能跑代码 |
注意:我曾经遇到过芯片有多个 RAM 区域(比如 DTCM 和 SRAM),它们的地址不连续。这时候需要分别定义,并在段分配时指定放到哪个区域。否则链接器会报错,或者把数据放到你意想不到的地方。
3.3 段(Section)的划分 —— 代码和数据怎么分家
段是链接脚本的核心概念。每个段都有名字、属性和内容。最常见的几个段:
- .text —— 代码段,放函数指令
- .rodata —— 只读数据段,放字符串常量、const 变量
- .data —— 已初始化全局变量
- .bss —— 未初始化全局变量(启动时清零)
- .stack —— 栈空间
- .heap —— 堆空间
来看看一个典型的段分配写法:
SECTIONS
{
. = ORIGIN(FLASH);
.text :
{
*(.isr_vector) /* 中断向量表必须放最前面 */
*(.text*) /* 所有代码 */
*(.rodata*) /* 只读数据 */
. = ALIGN(4);
} > FLASH
.data : AT(ADDR(.text) + SIZEOF(.text))
{
_sdata = .;
*(.data*)
_edata = .;
} > RAM
.bss :
{
_sbss = .;
*(.bss*)
_ebss = .;
} > RAM
}
这里有个关键点:.data 段运行时在 RAM,但初始值存在 Flash。所以用了 AT() 指定加载地址。启动代码需要把数据从 Flash 拷贝到 RAM。这个拷贝逻辑,我一般写在汇编启动文件里。
小技巧:用 . = ALIGN(4) 保证段对齐到 4 字节。ARM Cortex-M 要求中断向量表必须 256 字节对齐,否则硬件会跑飞。我踩过这个坑,真的会跑飞。
3.4 符号表的生成 —— 链接器给你留的「路标」
链接脚本里可以定义符号。这些符号在 C 代码里用 extern 声明就能访问。它们本质上是地址标签,不是变量。
常用的符号有:
_sdata、_edata—— 数据段起始和结束地址_sbss、_ebss—— BSS 段起始和结束地址_estack—— 栈顶地址(通常就是 RAM 末尾)_sheap、_eheap—— 堆的边界
举个例子,启动代码里清零 BSS 段:
extern uint32_t _sbss;
extern uint32_t _ebss;
void reset_handler(void)
{
uint32_t *p = &_sbss;
while (p < &_ebss) {
*p++ = 0;
}
/* 然后跳转到 main */
}
你看,链接脚本里定义的符号,在 C 里用 extern 就能拿到地址。但要注意:&_sbss 取的是符号的值,不是符号的地址。因为符号本身就是地址。
关键理解:链接脚本符号 = 地址常量。你不能修改它,只能读取它。我见过新手写 _sbss = 0x20000000; 想改地址,结果编译报错。
3.5 一张图看懂链接脚本全流程
下面这张 SVG 图,把链接脚本的核心逻辑串起来了。从输入文件到最终镜像,每一步都离不开链接脚本的编排。
3.6 实战中常见的坑
做 Bootloader 移植时,链接脚本最容易出问题的几个地方:
- 中断向量表偏移 —— Bootloader 和 App 的中断向量表不能冲突。我一般用
VECT_TAB_OFFSET宏配合链接脚本的.isr_vector位置来控制。 - RAM 分区冲突 —— Bootloader 和 App 共用 RAM 时,必须明确划分各自区域。我曾经没划清楚,App 启动时把 Bootloader 的全局变量覆盖了,导致跳转失败。
- 对齐问题 —— ARM 架构要求某些段必须对齐到 4 字节或 8 字节。不对齐的话,访问未对齐地址会触发硬件异常。
- 符号未定义 —— 链接脚本里定义的符号,C 代码里用
extern声明时类型要写对。我习惯用uint32_t,因为地址就是 32 位无符号整数。
重要提醒:修改链接脚本后,一定要 make clean 再重新编译。增量编译有时不会重新链接,导致你改了半天,烧进去的还是旧版本。我因为这个浪费过一整个下午。
3.7 小结
链接脚本不是那种天天要改的东西,但一旦需要改,往往就是项目最关键的阶段。理解它的三个核心要素 —— 内存布局、段划分、符号生成 —— 你就能掌控整个固件的「骨架」。
下次遇到程序跑不起来,别急着怀疑代码逻辑。先打开 .map 文件,看看各个段是不是在你预期的地方。很多时候,问题就出在链接脚本里。