7、数据段(.data)的搬运:从Flash到RAM的拷贝过程

好,咱们接着聊。上一章我们把 .bss 段清零了,全局变量有了安身之所。但还有个问题——那些已经初始化了的全局变量,比如 int count = 100;,它们的初始值 100 存在哪儿?

答案你可能猜到了:存在 Flash 里。但程序运行时,这些变量必须在 RAM 里才能读写。所以,启动代码必须干一件事——把 .data 段从 Flash 搬到 RAM

说白了,这就是一次内存拷贝。但这里面的门道,比你想象的多。

7.1 为什么非要搬?不能直接在 Flash 里用吗?

你想想看,Flash 是只读的,你没法写。而全局变量是可读可写的。所以必须搬到 RAM 里。

那有人会问:能不能让编译器直接把初始值写到 RAM 里?不行。因为 RAM 是掉电丢失的,每次上电都得重新初始化。所以 Flash 里必须存一份“原始数据”,启动时再拷贝到 RAM。

嗯,这里要注意:不是所有全局变量都需要搬。只有那些显式初始化了的全局变量才在 .data 段。没初始化的(或者初始化为 0 的)在 .bss 段,我们上一章已经清零了。

7.2 搬运的“三要素”:源地址、目标地址、长度

任何一次内存拷贝,都需要三个信息:

  • 源地址:.data 段在 Flash 里的起始位置
  • 目标地址:.data 段在 RAM 里的起始位置
  • 长度:.data 段的大小(字节数)

这些信息从哪里来?链接器。链接器在生成可执行文件时,会计算出这些地址,并暴露给启动代码。

我个人习惯用链接脚本里的符号来获取这些地址。比如:

/* 链接脚本中的典型定义 */
__data_load = LOADADDR(.data);   /* Flash 中的源地址 */
__data_start = ADDR(.data);      /* RAM 中的目标地址 */
__data_end = ADDR(.data) + SIZEOF(.data);  /* 结束地址 */

然后在 C 代码或汇编里,直接用这些符号:

extern uint32_t __data_load;
extern uint32_t __data_start;
extern uint32_t __data_end;

void data_copy(void) {
    uint32_t *src = &__data_load;
    uint32_t *dst = &__data_start;
    uint32_t len = &__data_end - &__data_start;

    for (uint32_t i = 0; i < len; i++) {
        dst[i] = src[i];
    }
}

你看,就这么简单。但实际工程里,我一般用 memcpy 或者汇编来加速。

7.3 搬运的时机:必须在 main 之前

这个拷贝必须在 main() 函数执行之前完成。为什么?因为 main() 里可能立即就要用到这些全局变量。

我记得有一次调试,发现某个全局变量在 main() 入口处值是乱的。查了半天,发现是启动代码里 .data 拷贝的顺序搞错了——先调了 main(),才去拷贝。这种低级错误,嗯,犯过一次就再也不会了。

所以启动代码的执行顺序一般是:

  1. 设置栈指针
  2. 初始化 .bss 段(清零)
  3. 拷贝 .data 段(从 Flash 到 RAM)
  4. 调用 main()

7.4 一个常见的坑:字节对齐

你可能会想:直接用 memcpy 不就行了?但很多嵌入式平台对非对齐访问有惩罚,甚至直接触发异常。

比如 ARM Cortex-M 系列,如果源地址或目标地址不是 4 字节对齐,用 LDR/STR 指令一次读 4 字节就会出问题。

我曾经在一个项目里,链接脚本把 .data 段的起始地址设成了奇数偏移,结果拷贝出来的数据全是乱的。排查了半天,才发现是对齐问题

所以,我建议:

  • 链接脚本里保证 .data 段在 RAM 中的起始地址4 字节对齐
  • 拷贝时按 uint32_t 为单位,或者用专门的 memcpy 实现(内部处理了对齐)
警告:不要假设 Flash 和 RAM 的地址都是自然对齐的。尤其是 Flash 的地址,有些芯片的 Flash 起始地址不是 4 字节对齐的(比如某些 Cortex-M0 芯片)。一定要检查链接脚本。

7.5 搬运的“幕后黑手”:链接脚本

没有链接脚本的配合,启动代码根本不知道往哪儿搬。所以,理解 .data 搬运,必须看懂链接脚本里的相关部分。

一个典型的链接脚本片段:

SECTIONS
{
    /* Flash 中的代码和只读数据 */
    .text : { *(.text) } > FLASH

    /* Flash 中的 .data 初始值(加载视图) */
    .data : AT(ADDR(.text) + SIZEOF(.text))
    {
        __data_start = .;
        *(.data)
        __data_end = .;
    } > RAM

    /* .bss 段 */
    .bss : { *(.bss) } > RAM
}

注意看 .data : AT(...) 这个语法。它表示:运行时 .data 段在 RAM 里,但它的初始数据存储在 Flash 里。这个 AT 后面的地址就是 Flash 里的位置。

链接器会计算出 __data_load(Flash 地址)和 __data_start(RAM 地址),启动代码就靠这两个符号来搬。

7.6 性能优化:用 DMA 或者快速拷贝

如果 .data 段很大(比如几百 KB),用 CPU 一个个字拷贝会浪费启动时间。尤其在一些实时性要求高的场景,比如汽车电子、工业控制,启动时间有严格限制。

我见过一个项目,.data 段有 512KB,用 CPU 拷贝花了 200ms。后来改成用 DMA,只用了 30ms。嗯,差距很明显。

但要注意:DMA 初始化本身也需要时间,而且 DMA 控制器可能还没配置好。所以一般建议:

  • 如果 .data 段小于 1KB,用 CPU 拷贝就够了
  • 如果大于 10KB,考虑用 DMA
  • 中间的情况,看你的启动时间预算
小技巧:有些芯片支持 Flash 预取或 Cache,可以加速 Flash 读取。如果开启了 Cache,CPU 拷贝的速度可能接近 DMA。我在 STM32H7 上试过,开启 Cache 后,memcpy 速度提升了 3 倍。

7.7 流程图:.data 搬运的完整过程

下面这张图展示了从链接脚本到启动代码的完整流程。我特意把关键符号和地址标出来了,方便你对照理解。

.data 段搬运流程 链接脚本 定义 .data 的加载/运行地址 链接器生成符号 __data_load, __data_start, __data_end 启动代码 读取符号,执行拷贝 Flash 存储 .data 初始值(只读) 地址:__data_load 源数据 RAM 运行区 .data 段(可读写) 地址:__data_start ~ __data_end 目标数据 拷贝过程 逐字/逐字节复制 可用 CPU 或 DMA 长度 = __data_end - __data_start 拷贝方向:Flash → RAM 关键点:链接脚本提供地址信息,启动代码执行拷贝, 必须在 main() 之前完成,且注意对齐和性能优化

7.8 避坑指南:我踩过的几个坑

做嵌入式这么多年,.data 搬运这块我踩过不少坑。分享几个典型的:

  • 坑一:链接脚本里忘了写 AT。如果不写 AT,链接器会认为 .data 段也在 Flash 里,启动代码就找不到正确的源地址。结果就是全局变量全是乱的。
  • 坑二:拷贝长度算错了。我见过有人用 sizeof(__data_start) 来算长度,结果只拷贝了 4 个字节。正确做法是用 __data_end - __data_start
  • 坑三:中断没关。如果在拷贝过程中来了中断,中断服务程序里又访问了还没拷贝完的全局变量,那数据就乱套了。所以拷贝前最好关中断,拷贝完再开。
  • 坑四:用了 C 库的 memcpy,但 C 库还没初始化。有些 C 库的 memcpy 依赖全局变量,而这些变量本身也在 .data 段里。这就成了“先有鸡还是先有蛋”的问题。解决办法:要么自己写一个简单的拷贝函数,要么确保 C 库的 .data 段先被拷贝。
核心要点:
  • .data 搬运的本质:从 Flash 拷贝初始值到 RAM
  • 三要素:源地址(__data_load)、目标地址(__data_start)、长度(__data_end - __data_start)
  • 必须在 main() 之前完成
  • 注意对齐、性能、中断安全
  • 链接脚本是幕后关键

好了,.data 搬运就讲到这里。下一章我们聊聊堆栈初始化——嗯,不对,按课程安排,我们该讲 .rodata 段了。不过那是另一个话题了。

个人建议:如果你刚开始接触启动代码,建议先在一个简单的开发板上把 .data 搬运的汇编代码手写一遍。别怕麻烦,写一遍比看十遍都管用。我当年就是在 STM32F103 上,一行一行汇编敲出来的,从那以后对启动代码的理解就再也没模糊过。

公众号:蓝海资料掘金营,微信deep3321