链接脚本入门:section、内存区域与裸机程序布局
更新时间:2026-09-24。本文是
languages/c/主题专家层的一篇。在用户态 Linux 上,内存布局由内核的 ELF 装载器和 libc 接管,你几乎感觉不到链接脚本的存在;但在裸机和嵌入式世界里,没有人帮你安排地址,"代码放在哪、变量在哪运行、栈从哪里长"全部由一份 C 链接脚本(linker script,传给ld的.ld文件)决定。本篇是 C 链接脚本 专题里决定镜像物理布局的一环,与 C 链接脚本的符号基础(重定位)互为表里。
本文要回答的问题:C 链接脚本决定了什么
MEMORY和SECTIONS两个块各自负责什么?定位计数器.怎么用?- 为什么
.data在 flash 里存一份、在 RAM 里又有一份?LMA 和 VMA 到底是什么? _start入口地址、栈顶地址、堆边界这些符号是从哪里冒出来的?- 裸机程序上电后"跑飞",为什么十有八九要回头查链接脚本?
- 链接脚本里的布局,最终怎样体现在
readelf -l看到的 program header 上?
一、链接脚本是什么:你一直在用,只是没见过
即使你从不写 .ld 文件,ld 每次链接也会使用一份内置默认脚本。可以用下面的命令把它打印出来:
ld --verbose | sed -n '/=====/,/=====/p' # 两个分隔行之间就是默认脚本用户态程序由内核按照 ELF program header 装载到固定的虚拟地址(通常 x86-64 从 0x400000 起),默认脚本已经够用。但交叉编译裸机程序时,目标芯片的 flash 可能从 0x08000000 开始、RAM 从 0x20000000 开始,复位向量还必须放在起始处——这时必须用 -T 给出自己的脚本:
arm-none-eabi-gcc -T linker.ld -nostdlib startup.o main.o -o firmware.elf链接脚本做三件事:描述存储区域(MEMORY)、描述输入段如何合并并放到何处(SECTIONS)、定义启动代码需要引用的地址符号(PROVIDE)。
理解一个前提:链接器只处理 section,不处理"变量"和"函数"。编译阶段结束后,g 和 main 已经退化成符号表里的名字加上 .data、.text 中的字节;链接脚本操作的单位就是这些段。所以脚本里你看不到任何 C 标识符的类型信息,只有"把哪些文件里的哪些段,按什么顺序、什么对齐,放到哪个地址"。另外建议每次链接都加 -Wl,-Map=firmware.map,生成的 map 文件会逐段列出 VMA、LMA、占用大小和边界符号,是排查布局问题最直接的书面证据。
二、MEMORY:先画一张芯片存储地图
MEMORY 给每块物理存储起名,并给出起始地址(ORIGIN)和长度(LENGTH)。以一款 Cortex-M 芯片为例:64KB flash 位于 0x08000000,20KB RAM 位于 0x20000000。
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
}括号里的 rx/rwx 是该区域的权限属性(读/写/执行),链接器会据此设置输出段的权限。注意:链接器不检查物理上能不能写,只检查放不放得下。若某个段超出 LENGTH,会报 region 'RAM' overflowed with ... bytes,报错信息会附带实际需要的字节数,这是少数它会主动拦住你的布局错误。
ORIGIN 和 LENGTH 不是凭感觉填的,必须来自芯片数据手册的地址映射表。常见的坑是把别名地址当成真实地址:很多 Cortex-M 芯片的 flash 同时映射在 0x00000000 和 0x08000000,Boot 引脚决定哪个生效,脚本里要写带完整 BOOT 配置的那个。现代芯片还可能有多块存储:主 flash、信息区(选项字节、出厂校准)、通用 SRAM、紧耦合内存 CCM、外部 SDRAM。它们在 MEMORY 里各占一行,CCM 不能执行代码、外部 SDRAM 上电后要先初始化控制器才能用——把段放进"此刻还不可用"的区域,是更隐蔽的一类布局错误。
三、SECTIONS、定位计数器与对齐
SECTIONS 命令把各 .o 里的输入段收集进输出段,再指派到 MEMORY 区域。下面是一份最小骨架:
SECTIONS
{
.text : {
KEEP(*(.vectors)) /* 中断向量表必须排最前 */
*(.text*) /* 所有目标文件的代码 */
*(.rodata*) /* 只读数据 */
} > FLASH
.data : {
_sdata = .;
*(.data*)
_edata = .;
} > RAM AT > FLASH /* VMA 在 RAM,LMA 在 FLASH */
.bss : {
_sbss = .;
*(.bss*) *(COMMON)
_ebss = .;
} > RAM
}其中的关键点:
- 定位计数器
.:代表"当前摆放地址",可以读取(_sdata = .;把当前地址存成符号),也可以强制推进(. = . + 4;)。 > RAM AT > FLASH:>后是运行地址 VMA(Virtual Memory Address),AT>后是装载地址 LMA(Load Memory Address)。只写> FLASH时两者相同。- 对齐:
. = ALIGN(8);把定位计数器向上对齐。ARM 取数要求地址对齐,flash 擦除按页(通常 2KB)进行,段边界不对齐会触发 HardFault 或擦掉半个段。 KEEP():垃圾回收(--gc-sections)时保留该段。中断向量表若不 KEEP,会被当作"没人引用"而丢掉。
输入段的写法是 文件名(段名),文件名和段名都支持通配符:*(.text*) 中 * 代表所有目标文件(含库成员),.text* 同时收进 .text、.text.startup、.text.main 等编译器生成的子段。段在脚本里出现的顺序就是镜像里的字节顺序,所以 .vectors 必须排在第一位——Cortex-M 复位时硬件不执行任何指令,它先从地址 0 取一个字装入栈指针,再取第二个字跳转到复位处理函数,这两个字必须正好是向量表的前两项。反过来,普通 C 函数即使排在最前也无济于事,因为硬件取到的"地址"会是一条机器指令。
--gc-sections 配合 -ffunction-sections -fdata-sections 能丢弃未引用的函数和变量,是缩小固件体积的常用手段;但链接器判断"未引用"靠的是重定位引用链,向量表、异常处理表这类靠硬件按地址访问、软件里没有显式引用的内容,必须用 KEEP() 从回收名单里排除。
常用 section 一览
| section | 内容 | 典型存放 | 属性 | 对应 C 代码 |
|---|---|---|---|---|
.vectors | 复位/中断向量表 | FLASH 起始 | rx | 汇编定义的函数指针数组 |
.text | 机器指令 | FLASH | rx | 所有函数 |
.rodata | 字符串、const 常量 | FLASH | r | "hello"、const int x=3; |
.data | 已初始化、可写全局量 | RAM 运行 / FLASH 存初值 | rw | int g = 5; |
.bss | 零初始化全局量 | RAM | rw | int g;、static int a[100]; |
COMMON | 未初始化临时定义 | RAM | rw | 旧式 int g;(无 static) |
| 堆/栈 | 动态内存与调用栈 | RAM 顶端向下 | rw | 链接脚本划界,C 代码使用 |
四、LMA/VMA:为什么 .data 要从 flash 搬到 RAM
下面这张图是整篇 C 链接脚本最核心的物理事实:同一份 .data 在存储世界里有两个地址。

为什么必须这么麻烦?因为 flash 只读、不能当变量用,而 RAM 掉电易失、初值必须有地方永久保存。于是链接器给 .data 两个地址:VMA 是代码里 g 实际访问的地址(在 RAM),LMA 是镜像文件中初值字节所在的位置(在 flash)。_sdata、_edata 标记 VMA 区间,_sidata = LOADADDR(.data); 取得 LMA 起点——这三个符号正是为启动代码准备的。.bss 更省:它在文件里不占字节(NOBITS),只需启动代码把 _sbss.._ebss 区间清零。
用一个具体数字感受这套机制。假设代码和只读数据一共 0x1a20 字节,那么 .data 的 LMA 就是 0x08001a20;而它的 VMA 从 RAM 起点 0x20000000 开始。程序里每次读写 g,机器码里的地址都是 0x20000000 一带;烧录文件中初值 0x05 0x00 0x00 0x00 却躺在 0x08001a20。启动拷贝循环做的事,就是把这两处地址的内容对齐起来。顺带一提,LMA/VMA 分离不限于 .data:把中断密集的热函数放进 RAM 执行(避开 flash 等待周期)时,常用名为 .ramfunc 或 .fastcode 的段,写法同样是 > RAM AT > FLASH,启动时一并拷贝——它拷贝的是代码而不是数据,原理完全相同。
五、启动时发生了什么:拷贝、清零、设栈、跳转

这张顺序图解释了"跑飞"的机理:C 代码以为 int g = 5; 所在地址已经有 5,但如果第 ② 步没做、或 _sidata 算错,g 就是 RAM 上电后的随机值;如果栈顶符号和 .bss 区间重叠,函数调用压栈会悄悄改写全局变量。这些故障在 C 源码层面完全看不出来——布局错只能在链接脚本层面修。
六、PROVIDE、入口点与栈地址
启动代码和链接脚本之间靠符号通信。常用写法:
PROVIDE(_stack_top = ORIGIN(RAM) + LENGTH(RAM)); /* 栈顶 = RAM 末端 */
ENTRY(Reset_Handler); /* ELF 入口点 */PROVIDE 的含义是"仅当没有任何目标文件定义这个符号时,才由脚本提供",所以汇编/C 代码可以在需要时覆盖它。栈顶放在 RAM 末端、向低地址生长,是绝大多数 ABI 的约定;Cortex-M 硬件还会把向量表第一个字自动装入 SP,所以 _stack_top 必须是栈顶地址本身而不是栈段起点,差一个方向就会从无效地址开始压栈。若不用 PROVIDE 而直接写 _stack_top = ...;,任何同名定义都会与它产生多重定义冲突;PROVIDE 给后续移植和覆盖留了余地。下表汇总了脚本侧常用命令与启动代码约定的符号:
| 命令/符号 | 作用 | 谁消费它 |
|---|---|---|
ENTRY(sym) | 设置 ELF 头的 e_entry | 调试器、装载器 |
PROVIDE(name = expr) | 弱定义一个地址符号 | 启动汇编、C 代码 |
LOADADDR(sec) | 取某输出段的 LMA | 计算 _sidata |
ADDR(sec) | 取某输出段的 VMA | 计算段边界 |
ALIGN(n) | 定位计数器向上对齐 | 段间对齐 |
. = 0x...; | 强制设置当前地址 | 固定外设、空洞 |
_stack_top | 初始栈指针(Cortex-M 放向量表第一项) | 复位硬件/启动汇编 |
_sdata/_edata/_sidata | .data 的 VMA 两端与 LMA 起点 | 拷贝循环 |
_sbss/_ebss | .bss 区间两端 | 清零循环 |
_end / __HeapLimit | 堆边界 | malloc 或自检 |
七、可运行 demo:最小裸机镜像
三份文件即可构成一个能在 QEMU qemu-system-arm -M lm3s6965evb 或真实 Cortex-M3 板上运行的最小镜像。
linker.ld 的关键部分(MEMORY 同第二节,SECTIONS 补充三个导出符号):
.data : {
_sdata = .;
*(.data*)
_edata = .;
} > RAM AT > FLASH
. = ALIGN(4);
_sidata = LOADADDR(.data);
.bss : {
_sbss = .;
*(.bss*) *(COMMON)
_ebss = .;
. = ALIGN(4);
} > RAM
PROVIDE(_stack_top = ORIGIN(RAM) + LENGTH(RAM));startup.c(也可用汇编,C 写法更易读)完成图 2 的三步:
extern uint32_t _sdata, _edata, _sidata, _sbss, _ebss, _stack_top;
void Reset_Handler(void) {
uint32_t *src = &_sidata, *dst = &_sdata;
while (dst < &_edata) *dst++ = *src++; /* ② .data: flash → ram */
uint32_t *b = &_sbss;
while (b < &_ebss) *b++ = 0; /* ③ .bss 清零 */
main(); /* ④ 跳转 */
for (;;) {}
}构建并检查产物:
arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -nostdlib -ffreestanding \
-T linker.ld startup.c main.c -o firmware.elf
arm-none-eabi-readelf -l firmware.elf # 装载视图
arm-none-eabi-readelf -S firmware.elf # 节视图
arm-none-eabi-nm firmware.elf | grep -E '_sdata|_sidata|_stack_top'没有 ARM 工具链时,在 Linux 用户态也能演示同一机制:写一个把 .text 放到自定义地址的脚本,用 gcc -T demo.ld -nostdlib -no-pie 链接,再用 readelf -l 观察。注意用户态实验只是观察布局,不要真的跳转到自定义入口——x86-64 的 _start 之后还要靠它设置 libc 运行环境,去掉 libc 后应自己发起 exit 系统调用收尾,否则进程会沿着栈上的垃圾地址继续取指。这恰好反证了裸机场景的纯粹:没有装载器、没有 libc,脚本写什么,世界就是什么。readelf 的完整选项见 readelf 速查。
八、readelf -l/-S:布局如何落到 ELF
链接脚本不直接生成二进制,它写进的是 ELF 的两类元数据,这一节给出解读方法。
Section Headers: Program Headers:
[ 1] .vectors PROGBITS AX ... Type Offset VirtAddr PhysAddr
[ 2] .text PROGBITS AX ... LOAD 0x010000 0x08000000 0x08000000
[ 5] .data PROGBITS WA ... LOAD 0x020000 0x20000000 0x08001a20
[ 6] .bss NOBITS WA ...readelf -S(节视图):VirtAddr 列是 VMA;.bss类型为NOBITS,Size 有值但文件里不占空间。readelf -l(装载视图):每个PT_LOAD行同时给出 VirtAddr(VMA)和 PhysAddr(LMA)。.data那一行两者不同——PhysAddr 指向 flash 中的初值,这就是AT>FLASH的直接产物。烧录工具按 PhysAddr 写 flash,启动代码按这个布局完成拷贝。- Entry point 一行显示
Entry point address: 0x08000xxx,即ENTRY(Reset_Handler)的结果。
一个验证习惯值得养成:链接后先用 nm 把脚本导出的符号按地址排一遍(arm-none-eabi-nm -n firmware.elf),确认 _sdata < _edata <= _sbss < _ebss < _stack_top 且互不相交,再做烧录。这些符号在 nm 输出里表现为绝对值地址,类型多为 A 或 T 之外的普通符号——它们没有任何字节实体,纯粹是链接器广播给软件的数字。
节/段两个视图的完整原理见 ELF 文件格式;重定位怎样把符号引用改成这些最终地址,见 重定位。
九、跑飞排查清单:链接布局错误速查
| 症状 | 根因 | 定位/修复 |
|---|---|---|
| 一复位就进 HardFault | 向量表没在 flash 起始处 | KEEP(*(.vectors)) 放 .text 首位 |
| 全局变量初值随机 | 没拷 .data,或 _sidata 不是 LOADADDR(.data) | 对照 readelf -l 的 PhysAddr |
| 字符串/常量读到乱码 | .rodata 误放入 RAM 区域 | 归入 FLASH,权限 r |
| 跑一会变量莫名变化 | 栈与 .bss/堆重叠 | _stack_top 取 RAM 末端,核对 _ebss |
region overflowed | 段超出区域长度 | 看溢出字节数,缩缓冲或换大芯片 |
| 偶发对齐异常 | 段边界未 4/8 字节对齐 | 每个输出段后 . = ALIGN(4); |
开 -Wl,--gc-sections 后死机 | 向量表/特殊段被回收 | 用 KEEP() 固定 |
| 中断永远进不来 | 向量表符号被优化或对齐错 | nm 确认向量表在首地址 |
这一篇在知识体系里的位置
- 上游:编译器怎样生成各个 section、链接器怎样解析符号与重定位,见 编译链接 ELF 与 符号重定位。
- L5 原理层:section 与 segment 的区别、装载器如何按 program header 映射,见 ELF 与二进制基础、ELF 文件格式、readelf。
- 相邻:用户态进程的虚拟地址布局对照见 内存布局与 ABI;脚本里
PROVIDE的弱定义语义,下一篇展开为 弱符号与别名。
总结
C 链接脚本是裸机系统的内存地图:MEMORY 圈出 FLASH/RAM,SECTIONS 借定位计数器、对齐和 VMA/LMA 把代码与数据摆到确定地址,PROVIDE 把栈顶和段边界符号交给启动代码,由它完成 .data 从 flash 到 RAM 的拷贝与 .bss 清零;这份布局最终落在 ELF 的 program header 上——跑飞时先看链接脚本和 readelf -l,而不是怀疑 C 代码。