第3章:链接脚本与内存布局——让芯片知道代码该放哪儿

说实话,我刚入行那会儿,觉得链接脚本就是个黑盒子。编译器帮我搞定了,我干嘛要管?直到有一次,我调试一个Bootloader,程序死活跑不起来,折腾了两天才发现——是链接脚本里RAM地址写错了。从那以后,我再也不敢跳过这章了。

链接脚本,说白了就是告诉编译器:你的代码、数据、变量,分别该放到芯片的哪个地址去。没有它,MCU上电后根本不知道从哪里开始执行。

3.1 链接脚本(.ld文件)到底在干什么?

你写好的C代码,经过编译、汇编,变成了一堆目标文件(.o)。这些文件里,代码、已初始化的全局变量、未初始化的全局变量,都混在一起。链接脚本的任务,就是把它们分门别类,放到指定的内存区域。

我习惯把链接脚本理解成一张「搬家清单」:

  • 代码段(.text) → 放到Flash里(只读,掉电不丢)
  • 数据段(.data) → 初始值放Flash,运行时搬到RAM
  • BSS段(.bss) → 直接占RAM,上电清零
  • 堆(Heap) → RAM里划一块,给malloc用
  • 栈(Stack) → RAM里划一块,给局部变量和函数调用用

核心原则:Flash放只读内容,RAM放读写内容。Bootloader的链接脚本尤其要小心——因为Bootloader本身就在Flash里,它要负责把应用程序的.data段从Flash搬到RAM。

3.2 一个典型的MCU链接脚本长什么样?

以STM32F103为例,我贴一个简化版的.ld文件。别怕,我会逐行解释。

/* 入口点 */
ENTRY(Reset_Handler)

/* 内存区域定义 */
MEMORY
{
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
  RAM   (xrw) : ORIGIN = 0x20000000, LENGTH = 128K
}

/* 段布局 */
SECTIONS
{
  /* 中断向量表 + 代码 */
  .isr_vector : ALIGN(4)
  {
    KEEP(*(.isr_vector))
  } > FLASH

  .text : ALIGN(4)
  {
    *(.text)
    *(.text*)
    *(.glue_7)
    *(.glue_7t)
  } > FLASH

  /* 只读数据 */
  .rodata : ALIGN(4)
  {
    *(.rodata)
    *(.rodata*)
  } > FLASH

  /* 已初始化数据——运行时搬到RAM */
  _sidata = .;
  .data : AT(_sidata)
  {
    _sdata = .;
    *(.data)
    *(.data*)
    _edata = .;
  } > RAM AT> FLASH

  /* BSS段——上电清零 */
  .bss : ALIGN(4)
  {
    _sbss = .;
    *(.bss)
    *(.bss*)
    *(COMMON)
    _ebss = .;
  } > RAM

  /* 堆 */
  .heap : ALIGN(8)
  {
    _sheap = .;
    . = . + _Min_Heap_Size;
    _eheap = .;
  } > RAM

  /* 栈 */
  .stack : ALIGN(8)
  {
    _sstack = .;
    . = . + _Min_Stack_Size;
    _estack = .;
  } > RAM
}

嗯,这里要注意几个关键点:

  • ENTRY(Reset_Handler):告诉链接器,程序入口是Reset_Handler函数。MCU上电后,从Flash的0x08000000取中断向量表,第一个向量就是栈顶指针,第二个就是Reset_Handler地址。
  • MEMORY块:定义了两块物理内存。Flash从0x08000000开始,512KB;RAM从0x20000000开始,128KB。
  • .data段的AT属性:这是最tricky的地方。.data段在运行时位于RAM,但它的初始值存储在Flash里。链接器用_sidata记录Flash里的起始地址,用_sdata和_edata记录RAM里的地址范围。启动代码需要把这些数据从Flash拷贝到RAM。

我曾经踩过的坑:有一次我改了链接脚本,忘了更新_sidata的地址,结果.data段拷贝时源地址指向了错误的位置。全局变量全变成了随机值。排查了整整一个下午才发现是这里的问题。所以,每次改链接脚本,一定要检查启动代码里的拷贝逻辑是否匹配。

3.3 代码段/数据段/BSS段——三兄弟的分工

这三兄弟,每个都有自己的脾气。我整理了一个表格,方便你对照:

段名 存放内容 存储位置 运行时位置 初始化方式
.text 函数代码、常量字符串 Flash Flash(原地执行) 无需初始化
.data 已初始化的全局/静态变量 Flash(存初始值) RAM 启动时从Flash拷贝到RAM
.bss 未初始化的全局/静态变量 无(只占RAM) RAM 启动时清零

为什么会这样设计?你想想看,Flash是只读的,但全局变量需要读写。所以.data段必须有个「影子」在Flash里存初始值,启动时再搬到RAM。而.bss段干脆不占Flash空间,上电直接清零就行。

我个人习惯在启动代码里这样处理:

/* 拷贝.data段 */
extern uint32_t _sidata, _sdata, _edata;
for (uint32_t *src = &_sidata, *dst = &_sdata; dst < &_edata; src++, dst++)
    *dst = *src;

/* 清零.bss段 */
extern uint32_t _sbss, _ebss;
for (uint32_t *p = &_sbss; p < &_ebss; p++)
    *p = 0;

这段代码必须在进入main()之前执行。如果漏了,你的全局变量初始值全是乱的。

3.4 栈与堆的设置——别让它们打架

栈和堆都放在RAM里,而且它们相向生长。栈从高地址往低地址长,堆从低地址往高地址长。如果设置不当,它们会撞在一起——那就是著名的「栈堆碰撞」,程序会莫名其妙地崩溃。

我一般这样设置:

  • 栈大小:根据函数调用深度和局部变量大小来定。对于Bootloader,我通常给1KB~4KB。如果用到RTOS,每个任务栈单独算。
  • 堆大小:如果不用malloc,直接给0。很多嵌入式项目其实不需要动态内存分配。用了malloc反而容易出内存碎片。

我的经验:在Bootloader里,我几乎不用堆。所有内存都静态分配。这样既简单又可靠。如果你非要用malloc,记得实现一个健壮的_sbrk函数,并且监控堆的使用情况。

链接脚本里,栈和堆的定义通常放在RAM的最后:

_Min_Heap_Size = 0x200;   /* 512字节 */
_Min_Stack_Size = 0x400;  /* 1KB */

.stack : ALIGN(8)
{
  _sstack = .;
  . = . + _Min_Stack_Size;
  _estack = .;
} > RAM

注意,栈顶指针_estack会被写入中断向量表的第一个位置。MCU上电时,硬件自动把_estack加载到SP寄存器。所以这个值必须指向有效的RAM地址。

3.5 知识结构图:链接脚本与内存布局全景

下面这张图,把整个流程串起来了。从C代码到最终的二进制文件,每一步都离不开链接脚本的指挥。

链接脚本与内存布局全景图 链接脚本与内存布局全景图 C源文件 (.c) 汇编文件 (.s) 编译 + 汇编 目标文件 (.o) 链接脚本 (.ld) 链接器 (ld) ELF / .axf 文件 最终内存布局 Flash .isr_vector .text / .rodata RAM .data / .bss .heap / .stack 拷贝

3.6 实战中的避坑指南

最后,分享几个我实战中遇到的坑,希望能帮你少走弯路:

  1. 中断向量表对齐:.isr_vector段必须4字节对齐,否则硬件取向量时会出错。我习惯在段定义前加ALIGN(4)。
  2. 栈大小别抠太狠:我曾经为了省RAM,把栈设成256字节。结果一个函数嵌套调用就溢出了。建议至少留512字节,调试阶段可以设大一点,稳定后再优化。
  3. 链接脚本里的符号别拼错:_sdata、_edata这些符号,启动代码里extern声明必须和链接脚本里完全一致。大小写、下划线都不能错。我见过有人把_sdata写成sdata,结果链接失败。
  4. 多段Flash布局:如果你的Bootloader和应用程序共用Flash,记得在链接脚本里给Bootloader分配独立的地址范围。比如Bootloader放0x08000000~0x0800FFFF,应用程序放0x08010000开始。

重要提醒:每次修改链接脚本后,一定要做三件事:1) 重新编译全部文件;2) 检查生成的.map文件,确认各段地址正确;3) 用调试器单步验证启动代码的拷贝和清零逻辑。这三步缺一不可。