4、程序分段与地址映射:代码段、数据段、BSS段在Flash和RAM中的分布、如何通过链接脚本控制地址。

好,咱们接着聊。上一章我们把启动流程捋了一遍,这一章要深入一个非常核心的话题——程序分段与地址映射。

说白了,就是你的代码编译完之后,那些.text.data.bss段到底该放哪儿?谁在Flash里,谁在RAM里?怎么通过链接脚本精确控制它们的地址?

我个人习惯把链接脚本比作「一张地图」。没有这张地图,你的固件就像无头苍蝇,跑飞了都不知道。

4.1 程序段的本质:为什么需要分段?

你想想看,MCU的存储空间是分层的。Flash是非易失的,掉电不丢,但速度慢;RAM是易失的,速度快,但掉电就没了。

所以,一个聪明的做法是:

  • 代码段(.text):只读,放Flash里,省RAM。
  • 只读数据段(.rodata):常量、字符串,也放Flash里。
  • 数据段(.data):有初始值的全局变量。运行时必须在RAM里,但初始值得从Flash拷过去。
  • BSS段(.bss):未初始化的全局变量。运行时在RAM里,启动时清零。

嗯,这里要注意:.data段在Flash里存的是「初始值的镜像」,上电后由启动代码拷贝到RAM里。这个拷贝过程,就是我们上一章说的__main或者Reset_Handler里干的事。

核心概念:程序在Flash中是「静态分布」,在RAM中是「运行时分布」。链接脚本就是定义这个映射关系的规则书。

4.2 典型的内存分布图

我画一个典型的ARM Cortex-M的分布图,你感受一下:

Flash (0x08000000 起始)
+------------------+
| .text (代码)      |  <- 入口地址
| .rodata (常量)    |
| .data (初始值镜像) |  <- 启动时拷贝到RAM
+------------------+

RAM (0x20000000 起始)
+------------------+
| .data (运行时)    |  <- 从Flash拷贝过来
| .bss (未初始化)   |  <- 启动时清零
| heap (堆)         |
| stack (栈)        |
+------------------+

为什么会这样?因为CPU只能从Flash取指令,但读写变量必须用RAM。所以代码段老老实实呆在Flash里,而数据段必须「搬家」到RAM里。

我在项目中遇到过一个问题:有个同事把一个大数组定义成const,以为能省RAM。结果编译器把它放到了.rodata段,还是在Flash里。读取速度慢不说,还占用了宝贵的Flash空间。嗯,这就是对段分布理解不够深。

4.3 链接脚本:地址的「总设计师」

链接脚本(.ld文件)就是干这个的。它告诉链接器:每个段放在哪个地址,按什么顺序排列。

我以STM32的GCC链接脚本为例,给你拆解一下:

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

/* 定义段布局 */
SECTIONS
{
    /* 代码段放在Flash起始 */
    .text :
    {
        *(.isr_vector)    /* 中断向量表 */
        *(.text)          /* 代码 */
        *(.rodata)        /* 只读数据 */
        _etext = .;       /* 标记代码段结束 */
    } > FLASH

    /* 数据段:初始值镜像放在Flash */
    .data : AT (_etext)
    {
        _sdata = .;       /* RAM中数据段起始 */
        *(.data)          /* 数据 */
        _edata = .;       /* RAM中数据段结束 */
    } > RAM AT> FLASH

    /* BSS段:直接放在RAM */
    .bss :
    {
        _sbss = .;        /* BSS起始 */
        *(.bss)           /* 未初始化数据 */
        _ebss = .;        /* BSS结束 */
    } > RAM
}

注意看.data : AT (_etext)这一行。它表示:.data段运行时在RAM里,但它的加载地址(LMA)在_etext之后,也就是Flash里。启动代码就是根据_sdata_edata_etext这三个符号来完成拷贝的。

避坑指南:我曾经在移植一个Bootloader时,忘记在链接脚本里定义_sdata_etext符号,结果启动代码找不到数据段的源地址,全局变量全是乱的。排查了整整一天才发现是链接脚本少写了两个符号定义。

4.4 自定义段:Bootloader与APP的「隔离墙」

在Bootloader和APP跳转的场景下,链接脚本的地址控制就更加关键了。

比如,Bootloader放在Flash的0x08000000~0x0800FFFF,APP放在0x08010000之后。那么APP的链接脚本就要这样写:

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08010000, LENGTH = 448K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS
{
    .text :
    {
        *(.isr_vector)    /* 中断向量表偏移到0x08010000 */
        *(.text)
        *(.rodata)
        _etext = .;
    } > FLASH

    /* 其他段不变 */
    ...
}

同时,APP的中断向量表偏移也要设置:

SCB->VTOR = 0x08010000;

嗯,这里要注意:如果你不改VTOR,中断来了还是会跳到Bootloader的向量表,APP就永远跑不起来。

警告:链接脚本中的ORIGINLENGTH必须与硬件实际地址一致。我曾经见过有人把Flash的ORIGIN写成了0x08000000,但APP实际烧录在0x08010000,结果一运行就HardFault。这种错误非常隐蔽,因为编译不报错,只有运行才崩溃。

4.5 实战技巧:如何验证地址映射是否正确?

我建议你每次写完链接脚本,都做两件事:

  1. 查看map文件:编译生成的.map文件里,有每个段的起始地址和大小。比如:.text 0x08010000 0x4000,说明代码段从0x08010000开始,大小16KB。
  2. 用调试器检查:在启动代码里打断点,查看_sdata_etext_sbss_ebss的值是否与预期一致。

我记得有一次,我写了一个很复杂的链接脚本,把部分代码放到了RAM里(为了加速)。结果map文件显示.text段在Flash和RAM里都有。我一开始以为错了,后来才明白这是「代码重定位」——把关键函数拷贝到RAM里执行。嗯,这个技巧后面章节会细讲。

4.6 总结一下

程序分段和地址映射,说白了就是「什么数据该放哪里」。链接脚本就是你的控制工具。

  • 代码段:放Flash,只读。
  • 数据段:初始值在Flash,运行时在RAM。
  • BSS段:运行时在RAM,启动时清零。
  • 链接脚本:通过MEMORYSECTIONS精确控制每个段的地址。

下一章,我们会讲「跳转前的状态清理」,看看在跳转之前,哪些寄存器必须恢复,哪些可以不管。敬请期待。