第4章:链接脚本与内存布局

大家好,我是你们的嵌入式系统讲师。今天我们来聊聊BSP开发中一个绕不开的话题——链接脚本和内存布局。说实话,我刚入行那会儿,觉得这玩意儿就是个黑盒子,反正编译器帮我搞定了。直到有一次,我调试一个诡异的系统崩溃问题,折腾了整整三天,最后发现是栈溢出把关键数据给踩了……嗯,从那以后,我再也不敢轻视链接脚本了。

4.1 链接脚本(.lds)语法入门

链接脚本,说白了就是告诉链接器:你的代码该放哪儿,数据该放哪儿。它是个文本文件,后缀通常是.lds或.ld。

一个最简单的链接脚本长这样:

SECTIONS
{
    . = 0x80000000;
    .text : { *(.text) }
    .data : { *(.data) }
    .bss  : { *(.bss) }
}

这里.代表当前地址计数器。. = 0x80000000意思是从这个地址开始放东西。.text、.data、.bss就是我们要安排的三个主要段。

我个人习惯把链接脚本分成三部分:

  • 内存区域定义(MEMORY命令)
  • 段布局(SECTIONS命令)
  • 符号导出(PROVIDE等)

来看一个更完整的例子:

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 1M
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS
{
    .text :
    {
        *(.vectors)
        *(.text)
        *(.rodata)
        _etext = .;
    } > FLASH

    .data : AT (ADDR(.text) + SIZEOF(.text))
    {
        _sdata = .;
        *(.data)
        _edata = .;
    } > RAM

    .bss :
    {
        _sbss = .;
        *(.bss)
        _ebss = .;
    } > RAM
}

注意看.data段后面有个AT表达式。这表示数据段在运行时放在RAM里,但它的初始值存储在FLASH里。启动代码需要把这些初始值从FLASH拷贝到RAM。我在项目中遇到过有人忘了这个拷贝步骤,结果全局变量全是乱的,排查了好久才发现。

4.2 代码段、数据段、BSS段布局

这三个段是嵌入式程序的核心。我们来逐个拆解:

段名 内容 存储位置 特点
.text 代码、常量、向量表 通常放在FLASH 只读,可共享
.data 已初始化的全局/静态变量 运行时在RAM,初始值在FLASH 可读写,需拷贝
.bss 未初始化的全局/静态变量 RAM 可读写,需清零

你想想看,为什么要把.text放在FLASH里?因为FLASH是非易失的,掉电不丢数据。而.data和.bss必须放在RAM里,因为程序运行时要修改它们。

这里有个坑:.bss段在启动时必须清零。我曾经接手过一个项目,启动代码里忘了清零.bss,结果每次上电,未初始化的全局变量都是随机值,导致程序行为完全不可预测。嗯,这种bug最难查。

重要提示:启动代码中必须完成两件事:

  1. 将.data段从FLASH拷贝到RAM
  2. 将.bss段清零

这两个步骤通常在Reset_Handler中完成,在跳转到main()之前。

4.3 栈与堆的配置

栈和堆是程序运行时的两个重要内存区域。栈用于函数调用、局部变量;堆用于动态内存分配(malloc/free)。

在链接脚本中,我们通常这样定义栈和堆:

_stack_size = 0x4000;   /* 16KB 栈 */
_heap_size  = 0x2000;   /* 8KB  堆 */

SECTIONS
{
    /* ... 其他段 ... */

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

    .heap (NOLOAD) :
    {
        . = ALIGN(8);
        _sheap = .;
        . = . + _heap_size;
        . = ALIGN(8);
        _eheap = .;
    } > RAM
}

注意NOLOAD关键字,它告诉链接器这个段不需要加载初始值。栈和堆的内容在运行时才产生,不需要在FLASH里存一份。

我个人习惯把栈放在RAM的最高地址,因为栈是向下生长的。这样栈溢出时,会先碰到其他段,更容易被发现。举个例子:

_estack = ORIGIN(RAM) + LENGTH(RAM);  /* 栈顶在RAM末尾 */
.stack :
{
    . = ALIGN(8);
    . = . + _stack_size;
    . = ALIGN(8);
    _sstack = .;
} > RAM

这里_estack是栈顶指针,_sstack是栈底。启动代码中,需要把_estack赋给SP寄存器。

避坑指南:我曾经把栈大小设得太小,只有1KB。结果函数嵌套调用一多,栈就溢出了,把旁边的全局变量给覆盖了。程序跑着跑着就莫名其妙地崩溃。后来我用__builtin_frame_address和栈填充模式来检测,才定位到问题。建议至少给栈留4KB以上,具体看你的函数调用深度。

4.4 DDR初始化

DDR初始化是BSP开发中的重头戏。DDR不像SRAM那样上电就能用,它需要一套复杂的初始化序列。

典型的DDR初始化步骤包括:

  1. 配置DDR控制器:设置时序参数、数据宽度、行列地址等
  2. 上电序列:CKE拉高、等待稳定时间
  3. 初始化命令:发送NOP、预充电、刷新等命令
  4. 模式寄存器配置:设置CAS延迟、突发长度等
  5. ZQ校准:阻抗匹配校准
  6. 训练:读写训练,确保信号质量

来看一段简化的DDR初始化代码(以某款Cortex-A处理器为例):

void ddr_init(void)
{
    /* 1. 使能DDR时钟 */
    DDR_CLK_EN |= (1 << 0);

    /* 2. 配置控制器时序 */
    DDR_TIMING = 0x12345678;  /* 具体值看DDR颗粒手册 */

    /* 3. 发送初始化序列 */
    DDR_CMD = CMD_NOP;
    delay_us(200);
    DDR_CMD = CMD_PRECHARGE_ALL;
    delay_us(20);
    DDR_CMD = CMD_AUTO_REFRESH;
    delay_us(10);
    DDR_CMD = CMD_AUTO_REFRESH;
    delay_us(10);

    /* 4. 配置模式寄存器 */
    DDR_MR0 = 0x1234;  /* CAS=5, BL=8 */
    DDR_MR1 = 0x5678;  /* 驱动强度等 */

    /* 5. ZQ校准 */
    DDR_ZQCL = 1;
    while(DDR_ZQCL & 1);

    /* 6. 读写训练 */
    ddr_training();

    /* 7. 使能DDR */
    DDR_ENABLE = 1;
}

这里每个步骤的延时和命令序列,都必须严格按照DDR颗粒的数据手册来。不同厂商、不同型号的DDR,参数可能完全不同。

个人经验:我在调试DDR时,最喜欢用"写-读-比较"的方法。先往某个地址写一个固定模式(比如0xA5A5A5A5),再读回来比较。如果读出来不对,说明初始化有问题或者硬件连接有误。我曾经用这个方法,发现了一块PCB上DDR数据线虚焊的问题。

DDR初始化完成后,整个内存系统才算就绪。这时候,链接脚本中定义的RAM区域才能真正使用。

知识体系总览

下面这张图,是我自己总结的链接脚本与内存布局的核心逻辑:

链接脚本与内存布局核心逻辑 链接脚本 (.lds) 链接器处理 可执行文件 .text 代码段 .data 数据段 .bss 未初始化段 FLASH (非易失) RAM (运行时) RAM (需清零) 栈 (Stack) + 堆 (Heap) 配置 DDR初始化 → 内存就绪

这张图展示了从链接脚本到最终内存就绪的完整流程。说白了,链接脚本就是一张"地图",告诉链接器怎么安排你的程序。而DDR初始化,则是让这张地图上的"土地"变得可用。

好了,这一章的内容就到这里。记住,链接脚本和内存布局是BSP开发的基石。搞懂了它,你就能更好地理解程序在硬件上是如何运行的。

可可精品资料库