摘要:本文从「int g_count = 100; 这个 100 到底存在哪」出发,拆解 STM32 链接脚本(.ld 文件)的 MEMORY 与 SECTIONS 两大核心,讲清 .data/.bss 的 VMA 与 LMA、堆栈布局、Bootloader + APP 双区跳转,以及常见链接报错怎么排查。写给正在学单片机、准备做 IAP/OTA,或者想把嵌入式底层原理补扎实的人。

急着要答案的,先看这一句:

芯片里每一段代码、每一个变量最终落在哪个物理地址,由链接脚本(.ld 文件)决定。带初值的全局变量,运行时住在 RAM,但它的初值被另外存了一份在 Flash 里;上电后由启动文件执行一段「搬家 + 清零」的代码,把初值从 Flash 复制进 RAM。所以断电之后变量初值还在——不是 RAM 记住了,是 Flash 替你保管着。


这段代码,几乎每个学单片机的人都写过:
int g_count = 100;  

编译、烧录、上电,一路顺利。但有这么个问题,被问住的人不少:100 这个数,到底存在哪?

说存在 RAM?RAM 一断电就清空。可下次上电 g_count 还是 100,这个初值是从哪回来的?

说存在 Flash?可程序跑起来明明在改 g_count,Flash 又不能随便写。

这问题看着基础,能一次答利索的人真不多。不是笨,是没人系统讲过。

答案在一份叫 .ld 的文件里。芯片里每段代码、每个变量住 Flash 还是 RAM,初值怎么从 Flash「搬」进 RAM,全是它说了算。这份文件学校不讲、多数教程跳过,却是从「会点灯」到「懂原理」的那道分水岭。

这篇把它从头拆一遍。看到最后你会发现:上电那一瞬间,芯片里其实发生了一次有条不紊的「搬家」。

如果你读到这,心里冒出的是「这问题我也想过」,那这篇就是写给你的。学单片机时肯多想一步的人,往往就是能在这行走远的人。


一、程序是怎么「炼成」的

一份 C 代码变成能在 STM32 上跑的 bin/hex 文件,要经过 4 个阶段:


预处理 → 编译 → 汇编 → 链接
 (.i)           (.s)       (.o)        (.elf)

前三步,每个 .c 文件各自「单打独斗」,生成互相独立的目标文件(.o)。

链接(Link),就是把这些零散的 .o 文件、加上库文件,「拼装」成一个完整的、有确定地址的可执行文件的过程。

链接器要解决两个核心问题:

  1. 符号解析:A 文件调用了 B 文件的函数,链接器要把这个「引用」关系接上;
  2. 地址分配:决定每一段代码、每一个变量,最终落在芯片的哪个物理地址上。

而地址分配的规则,正是由链接脚本(.ld 文件)决定的。这就是我们今天的主角。

二、STM32 的「地盘」:Flash 和 SRAM

以常见的 STM32F103C8T6 为例,它的存储资源大致是:

存储区域

容量

用途

Flash(内部闪存)

64 KB

存放代码、常量、中断向量表

SRAM(内部 RAM)

20 KB

存放变量、堆栈

链接脚本里,这两块区域会这样声明:


MEMORY
{
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 64K
  RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
}

  • ORIGIN:区域起始地址
  • LENGTH:区域大小
  • rx:可读可执行(Flash 里放代码,不给写权限,防止误操作)
  • rwx:可读可写可执行(RAM)

💡 这也解释了为什么 STM32 的中断向量表必须从 0x08000000 开始——因为 CPU 上电复位后,硬件规定从这个地址取第一条指令。


三、代码「住」在哪个房间:常见的 Section

链接脚本第二部分是 SECTIONS,负责把编译产物的各个「段」分配到具体存储区域:


SECTIONS
{
  .isr_vector : { *(.isr_vector) } > FLASH   /* 中断向量表 */
  .text       : { *(.text) }       > FLASH   /* 代码 */
  .rodata     : { *(.rodata) }     > FLASH   /* const常量、字符串 */

  .data : {
    _sdata = .;
    *(.data)
    _edata = .;
  } > RAM AT> FLASH                          /* 已初始化的全局变量 */

  .bss : {
    _sbss = .;
    *(.bss)
    _ebss = .;
  } > RAM                                    /* 未初始化的全局变量 */
}

划重点,几个新手最容易懵的地方:

3.1 .data 段为什么有两个位置?

int g_count = 100; 这种带初值的全局变量,运行时必须在 RAM 里(因为要能被修改),但它的「初值 100」必须先保存在 Flash 里(因为断电 RAM 会清空)。

所以链接脚本用 > RAM AT> FLASH 表示:

  • 运行地址在 RAM(VMA,Virtual Memory Address)
  • 存储地址在 Flash(LMA,Load Memory Address)

启动文件(startup_stm32xxx.s)里那段「从 Flash 搬到 RAM」的代码,干的就是这件事:


// 伪代码,实际在汇编启动文件里
memcpy(&_sdata, &_sidata, &_edata - &_sdata);  // .data 从Flash拷贝到RAM
memset(&_sbss, 0, &_ebss - &_sbss);            // .bss 清零

3.2 .bss 段为什么不占 Flash 空间?

int g_arr[1000];(未初始化)反正上电都要清零,Flash 里没必要存一堆 0 占地方,只需在 RAM 里预留位置即可。

这也是为什么有时候你定义了一个很大的全局数组,却「不影响 .bin 文件大小」——它只占用了 RAM,没占用 Flash。

⚠️ 反过来说:如果这个数组带了初值(int g_arr[1000] = {1};),那它就进了 .data,初值会老老实实占掉 4KB 的 Flash。这是排查 Flash 突然变大的常见原因。


四、栈和堆:谁「住」在 RAM 的哪头

一般链接脚本会这样安排 RAM 的堆栈:


._user_heap_stack :
{
  . = ALIGN(8);
  PROVIDE ( end = . );
  . = . + _Min_Heap_Size;
  . = . + _Min_Stack_Size;
  . = ALIGN(8);
} > RAM

约定俗成的布局是:


低地址 ┌────────────┐
       │ .data/.bss │
       ├────────────┤
       │    堆(Heap)│ ↓ 向高地址增长
       │     ...    │
       │  栈(Stack) │ ↑ 向低地址增长
高地址 └────────────┘

堆栈相向增长,一旦中间的「缓冲区」被耗尽——比如递归太深、局部数组开太大——两者就会「打架」,这正是栈溢出(Stack Overflow)、HardFault 死机的经典元凶之一。

🛠️ 排查 HardFault 时,除了看 SCB 寄存器,第一反应也应该去看看:是不是哪个函数里定义了超大的局部数组?

五、进阶实战:Bootloader + APP 双区跳转

这是链接技术最有「技术含量」的实战场景。做 OTA 升级、IAP 方案时,通常把 Flash 切成两块:


0x08000000 ───────── Bootloader(比如16KB)
0x08004000 ───────── APP 应用程序

APP 工程的链接脚本要改哪里? 只需改一行——把 FLASH 的起始地址偏移:


MEMORY
{
  FLASH (rx) : ORIGIN = 0x08004000, LENGTH = 48K   /* 注意起始地址变了 */
  RAM   (rwx): ORIGIN = 0x20000000, LENGTH = 20K
}

同时 APP 代码里,需要在 main 最开始手动「重定位」中断向量表:


SCB->VTOR = 0x08004000;  // 告诉CPU:中断向量表挪到这里了

Bootloader 跳转到 APP 的核心代码:


typedef void (*pFunction)(void);

uint32_t appStack = *(volatile uint32_t*)0x08004000;        // APP的栈顶指针
uint32_t appEntry = *(volatile uint32_t*)(0x08004000 + 4);  // APP的复位入口

__set_MSP(appStack);                    // 设置主栈指针
pFunction jump = (pFunction)appEntry;
jump();                                  // 跳转执行APP

这里能看懂的前提,正是理解了 .isr_vector 段的结构——它的第一个字(4 字节)是栈顶地址,第二个字是复位中断服务函数地址,这是 Cortex-M 内核的硬性规定。

六、遇到链接报错怎么办?(速查表)

报错 / 现象

根因

排查方向

❌ region 'FLASH' overflowed by xxx bytes

代码/常量太大,超出 Flash 容量

检查是否关闭了优化(-O0 会让体积明显膨胀)、是否引入了不必要的大表/长字符串;确认带初值的大数组是不是误放进了 .data

❌ region 'RAM' overflowed

全局变量 + 堆栈总和超过 RAM 容量

重点排查大数组、printf 的缓冲与栈开销、_Min_Stack_Size 是否设得过大

❌ 运行跑飞、进 HardFault

.data/.bss 初始化没正常执行,或栈溢出

检查启动文件是否被裁剪、是否换了不匹配的链接脚本;确认 .ld 里的 _sidata/_sdata/_edata 符号与启动文件一致;检查栈是否溢出


七、高频问答(FAQ)

Q1:STM32 里为什么全局变量的初值断电后还在?

因为初值不在 RAM 里。带初值的全局变量(.data 段)运行时位于 RAM,但它的初值被链接脚本安排同时存放了一份在 Flash;上电后启动文件把这份初值 memcpy 进 RAM,所以每次上电都能「复原」。RAM 本身断电确实会丢数据。

Q2:.data 和 .bss 有什么区别?

.data 存已初始化的全局/静态变量,占 RAM 也占 Flash(初值要存);.bss 存未初始化(或初值为 0)的全局/静态变量,只占 RAM,不占 Flash,上电时由启动文件统一清零。两者都可通过 _sdata/_edata、_sbss/_ebss 这几个符号定位。

Q3:什么是链接脚本(.ld 文件)?

链接脚本是给链接器看的「地址分配说明书」,由 MEMORY(芯片有哪些存储区域、各自起始地址和大小)和 SECTIONS(每一段代码和数据放在哪个区域、以什么顺序排列)两部分组成。它回答两个问题:芯片有哪些房间,每段代码和数据该住哪一间。

Q4:VMA 和 LMA 有什么区别?

VMA(Virtual Memory Address)是运行地址,程序跑起来时该段所处的地址;LMA(Load Memory Address)是加载地址,该段在烧录文件里的存放地址。.data 段 VMA 在 RAM、LMA 在 Flash,两者不同,所以上电时需要启动代码做一次拷贝。.text 段两者相同。

Q5:Bootloader 跳转到 APP 时,为什么必须设置 SCB->VTOR?

因为 Cortex-M 的中断向量表默认固定在 0x08000000(复位后的起始地址)。APP 被烧到 0x08004000 后,它自己的中断向量表也搬到了那儿,必须把 SCB->VTOR 指向 APP 向量表基址,否则 APP 里一进中断就会跳到 Bootloader 的向量表,直接跑飞。

Q6:想系统学嵌入式,底层原理要学到什么程度?

至少要把「编译 → 链接 → 启动 → 运行」这条链路走通:能看懂链接脚本的 MEMORY/SECTIONS 两段、能读 Map 文件定位 Flash/RAM 占用、能说清 .data 的 VMA/LMA 差异、能独立完成一次 Bootloader + APP 双区跳转并讲清每一步为什么这么做。这几件事恰好是嵌入式岗位面试最容易被追问的点。

Q7:嵌入式培训应该先学什么?

顺序上先打牢 C 语言与计算机组成(内存模型、栈与堆、指针),再进 MCU 外设(GPIO/中断/定时器/串口),然后补底层机制(编译链接过程、启动文件、链接脚本、Map 文件),最后用 Bootloader/IAP、RTOS 这类项目把知识串起来。跳过底层直接堆项目,换个芯片就会回到原点。

八、核心知识点复盘(可以直接抄进笔记)

  1. 链接脚本(.ld)决定每个段落在芯片的哪个物理地址,包含 MEMORY 和 SECTIONS 两部分
  2. int g_count = 100; → 运行时在 RAM(VMA),初值在 Flash(LMA),上电由启动文件搬运。
  3. .bss 不占 Flash,只占 RAM;.data 两者都占。
  4. _sdata / _edata / _sidata、_sbss / _ebss 是启动代码与链接脚本之间的「接头暗号」。
  5. 中断向量表第一个字是栈顶地址,第二个字是复位入口——这是 Cortex-M 的硬性规定。
  6. SCB->VTOR 是 Bootloader 跳转 APP 的必要一步。
  7. 看 Map 文件能一眼定位 Flash/RAM 是被谁吃掉的。

九、金橙智能最较真的地方

链接脚本拆到底,就是回答两个问题:芯片有哪些房间(MEMORY),每段代码和数据该住哪一间(SECTIONS)。

看懂这两点,很多事就通了:Map 文件不再天书,Flash/RAM 溢出能秒定位,Bootloader 跳转心里有底。

但说句实在话,链接脚本这一页,多数工程师是被编译报错逼着才翻开的。被项目推着学,学到的是碎片;换个芯片、换个编译器,又回到原点。

这正是金橙智能做嵌入式培训最较真的地方。 链接脚本、启动文件、Map 文件这些底层机制,在金橙智能的课程里是必修基本功,每个学员都得能对着自己的项目讲清楚。

为什么这么较真?因为嵌入式岗位真正筛人的,是你有没有把原理吃透。调过几个库,没人记得住;能不能讲清它为什么这么工作,三句话就露底。

所以金橙智能带项目有一条硬规矩:每个项目做完,先过答辩,老师拿着你简历上写的每一条,逐条追问到你讲透为止。我们不做点灯速成,教的是能扛住追问、能独立排错的本事。

如果你正打算系统学嵌入式,或者已经在做、想把这些底层补扎实,欢迎来了解金橙智能。

代码能跑只是起点,讲得清原理才算入了门。希望下次编译报错时,你心里有的是底气,而不是玄学。

#STM32 #链接脚本 #嵌入式 #单片机 #Bootloader #IAP #启动文件 #嵌入式培训

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐