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(),才去拷贝。这种低级错误,嗯,犯过一次就再也不会了。
所以启动代码的执行顺序一般是:
- 设置栈指针
- 初始化 .bss 段(清零)
- 拷贝 .data 段(从 Flash 到 RAM)
- 调用
main()
7.4 一个常见的坑:字节对齐
你可能会想:直接用 memcpy 不就行了?但很多嵌入式平台对非对齐访问有惩罚,甚至直接触发异常。
比如 ARM Cortex-M 系列,如果源地址或目标地址不是 4 字节对齐,用 LDR/STR 指令一次读 4 字节就会出问题。
我曾经在一个项目里,链接脚本把 .data 段的起始地址设成了奇数偏移,结果拷贝出来的数据全是乱的。排查了半天,才发现是对齐问题。
所以,我建议:
- 链接脚本里保证 .data 段在 RAM 中的起始地址4 字节对齐
- 拷贝时按
uint32_t为单位,或者用专门的memcpy实现(内部处理了对齐)
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
- 中间的情况,看你的启动时间预算
7.7 流程图:.data 搬运的完整过程
下面这张图展示了从链接脚本到启动代码的完整流程。我特意把关键符号和地址标出来了,方便你对照理解。
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 段了。不过那是另一个话题了。
公众号:蓝海资料掘金营,微信deep3321