很多嵌入式开发者在学 ARM 启动流程时,能看懂“上电后从 Reset_Handler 开始执行”,也能点灯跑 main,但一旦涉及“全局变量为什么需要初始化”“代码是不是一定要拷到 RAM 里跑”“位置无关码到底是干嘛的”这些问题,就很容易卡住。
这一篇专门把 ARM 启动流程里最容易绕晕的三件事一次性讲透:
- data/text/bss 段的定义和初始化逻辑;
- XIP 为什么能让程序直接在 Flash 里运行;
- 位置无关码(PIC)在启动阶段的意义。
内容会从链接脚本、汇编启动代码、反汇编三个角度展开,既有原理也有可以直接改用的代码。适合正在学 ARM 启动流程、调试 bare-metal 程序、或者准备系统看 U-Boot/RT-Thread 启动代码的读者。
1. 为什么 ARM 启动流程绕不开 data/bss/text 段
1.1 一个看似简单的问题:上电后 C 语言为什么不能直接跑
很多初学者第一次接触单片机时,会认为“上电之后 main 函数就自动开始执行了”。但从 CPU 的角度看,main 只是一个普通的函数符号,它没有魔法。
芯片上电后,CPU 首先要做的事情是:
- 从复位向量指定的地址取第一条指令;
- 初始化栈指针(ARM Cortex-M 从向量表偏移 0 处加载初始 SP);
- 跳到启动代码执行;
- 启动代码完成必要的寄存器、时钟、内存初始化;
- 准备好 C 语言运行环境;
- 最后才调用 main。
所谓的“准备 C 语言运行环境”,核心工作就是把编译产物中的各个段放到正确的位置,并给它们赋正确的初值。这项工作如果不做,C 语言里的全局变量可能就是随机值,跳转到 main 后程序行为完全不可预期。
1.2 启动流程到底在干什么
启动流程可以通俗地理解为“搬家”:把程序从存储介质中取出来,放到适合运行的地址上,并完成环境初始化。
这里涉及两类存储:
- 非易失性存储(Flash、ROM):掉电不丢,适合长期保存代码和只读数据;
- 易失性存储(SRAM、SDRAM、DDR):掉电丢失,但是读写速度快,且是全局变量、栈、堆的驻地。
问题是,编译出的二进制不能直接全部放在 RAM 里运行,因为 RAM 上电后是空的,没有代码;也不能让所有变量都放在 Flash 里,因为 Flash 不能频繁改写。
于是编译器通过“分段”的方式,把具有不同属性的内容分开存放,启动代码再按需把它们搬运到正确的位置。
2. 先把三个段彻底分清
2.1 text 段
text 段通常存放:
- 程序指令;
- 只读常量(常量字符串、const 全局变量);
- 部分只读的初始化数据。
text 段的属性是“只读、可执行”。它在整个程序生命周期里不需要被修改,因此可以直接放在 Flash/ROM 中,不需要搬运。
如果 MCU 支持 XIP(Execute In Place),text 段直接在 Flash 里就被 CPU 取指执行,这也是目前绝大多数 Cortex-M 单片机的运行方式。
如果芯片从 NAND Flash 启动,或者外部存储不支持直接取指,则需要把 text 段整体拷贝到 RAM 中再跳转执行,这时候 text 段的“搬运”也是必要步骤。
2.2 data 段
data 段存放“有初值且非零的全局变量和静态变量”。例如:
int counter = 0x5A; static int init_flag = 1;这些变量在程序运行期间可读可写,必须放在 RAM 中。但它们的初值在编译时已经确定,不能丢失,所以初值必须存放在 Flash 中。
于是 data 段就有了两个地址:
- 加载地址(LMA,Load Memory Address):在 Flash 中的位置,存放初值;
- 运行地址(VMA,Virtual Memory Address):在 RAM 中的位置,程序通过该地址访问变量。
启动代码要做的事情就是:把 data 段从 Flash 的加载地址,逐个字节拷贝到 RAM 的运行地址。
2.3 bss 段
bss 段存放“没有初值或初值为 0 的全局变量和静态变量”。例如:
char buffer[1024]; static int count;C 语言标准规定,未显式初始化的全局变量和静态变量初值为 0。因此 bss 段的处理方式非常高效:它在 Flash 中不占任何空间,只需要在启动阶段把 RAM 中对应区域清零即可。
注意,bss 段不占 Flash 空间,是指它没有加载地址;但链接后的可执行文件里,bss 段的符号信息仍然会占用调试信息空间,这是另一个概念。
2.4 Flash 与 RAM 的分工
综合来看,一个典型程序的内存布局应该是这样的:
| 段 | 内容 | 存放位置(初始) | 运行位置 | 启动阶段要做什么 |
|---|---|---|---|---|
| text | 指令、只读常量 | Flash | Flash 或 RAM | XIP 时不用动;非 XIP 时拷贝到 RAM |
| data | 已初始化全局/静态变量 | Flash(保存初值) | RAM | 从 Flash 拷贝到 RAM |
| bss | 未初始化全局/静态变量 | 不占用 | RAM | 清零 |
| heap | 动态分配内存 | 无 | RAM | 设置堆边界 |
| stack | 函数调用栈 | 无 | RAM | 设置栈顶指针 |
正是因为 text、data、bss 三者的“装载方式”不同,链接脚本里才会出现各种地址符号,启动代码也会出现一个个循环搬运指令。
3. 链接脚本:LMA 和 VMA 是理解启动的钥匙
3.1 三个关键地址
阅读链接脚本之前,先记住三个概念:
- LMA(Load Memory Address):段的内容被“加载”到的地址。对于 data 段来说,就是初值存在 Flash 的哪个位置。
- VMA(Virtual Memory Address):段在运行时被访问的地址。对于 data 段来说,就是变量在 RAM 里的地址。
- 符号(Symbol):链接脚本定义的
_sdata、_edata、_etext等,本质是地址常量,供汇编代码读取。
很多初学者只关注 VMA,忽略了 LMA,这就导致 data 段拷贝代码写错:从错误的源地址拷贝、或者拷贝长度不对,最终全局变量初值混乱。
3.2 一份简化链接脚本
下面以 ARM Cortex-M4 为例,给出一个最小 GNU LD 链接脚本。
/* 文件路径:link.ld */ ENTRY(Reset_Handler) MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K } SECTIONS { .text : { KEEP(*(.isr_vector)) *(.text*) *(.rodata*) . = ALIGN(4); _etext = .; } > FLASH .data : { _sdata = .; *(.data*) . = ALIGN(4); _edata = .; } > RAM AT> FLASH .bss (NOLOAD) : { _sbss = .; *(.bss*) *(COMMON) . = ALIGN(4); _ebss = .; } > RAM _estack = ORIGIN(RAM) + LENGTH(RAM); }这段脚本的信息量很大,挑重点解释:
.text段放在 FLASH 中,_etext记录 text 段结束地址,这个地址正好可以作为 data 段初值的拷贝源地址。.data段的 VMA 在 RAM,但通过AT> FLASH指定 LMA 在 Flash,表示“运行时在 RAM,初值保存在 Flash”。.bss段标记为NOLOAD,表示不需要在 Flash 中占据加载空间,只占用 RAM 空间。_estack指向 RAM 末地址,在支持满减栈的 ARM 上通常作为初始 SP。
如果你在 data 段输出语句后面加上:
_sidata = LOADADDR(.data);也可以直接用_sidata表达 data 段的加载地址。相比_etext,这种方式更语义化,但两者在常见连续布局下效果一样。
3.3 链接脚本里为什么要有 AT>
这是理解 data 段初始化的关键。
如果只写:
.data : { _sdata = .; *(.data*) _edata = .; } > RAM那么链接器会认为 data 段的加载地址和运行地址一样,也在 RAM 中。然而 RAM 上电后是空的,程序运行时找不到 data 段初值,全局变量初始值自然不对。
而通过AT> FLASH,链接器会为 data 段的 LMA 单独分配 Flash 空间,同时保留 VMA 在 RAM。启动代码中用_etext或_sidata获取 LMA,循环拷贝到_sdata到_edata之间,整个搬运链路才完整。
4. 动手写启动代码:搬运 data,清零 bss
4.1 启动汇编的完整结构
有了链接脚本,接下来需要写启动汇编代码。它要做的事情包括:
- 设置栈指针(Cortex-M 上由硬件加载,也可在代码里显式再设置一次);
- 搬运 data 段;
- 清零 bss 段;
- 调用 main。
下面是基于 ARM Cortex-M4 的示例。
@ 文件路径:startup.s .syntax unified .cpu cortex-m4 .thumb .section .isr_vector, "a", %progbits .word _estack .word Reset_Handler .section .text .thumb_func .global Reset_Handler Reset_Handler: @ 1. 拷贝 data 段 @ r0 = 目标地址(RAM 中的 data 段起始) @ r1 = data 段结束地址 @ r2 = 源地址(Flash 中的 data 段初值) ldr r0, =_sdata ldr r1, =_edata ldr r2, =_etext copy_data: cmp r0, r1 bge data_done ldr r3, [r2], #4 str r3, [r0], #4 b copy_data data_done: @ 2. bss 段清零 @ r0 = bss 段起始 @ r1 = bss 段结束 ldr r0, =_sbss ldr r1, =_ebss movs r2, #0 zero_bss: cmp r0, r1 bge bss_done str r2, [r0], #4 b zero_bss bss_done: @ 3. 跳转 main bl main loop_forever: b loop_forever这个例子省略了系统时钟初始化和 C++ 全局构造器调用,目的是突出“段初始化”这条主线。
4.2 逐行解释
ldr r0, =_sdata是一条伪指令,汇编器会从文字池(literal pool)中加载_sdata这个绝对地址到 r0。在平时运行阶段,这段代码已经运行在正确的链接地址上,所以没有问题。
copy_data循环逻辑:
cmp r0, r1比较当前目标地址是否到达结束地址;bge data_done如果已经拷贝完则跳出;ldr r3, [r2], #4从源地址读取 4 字节,r2 自增 4;str r3, [r0], #4写入目标地址,r0 自增 4。
这里用后变址模式写起来很简洁。实际工程中还要考虑非 4 字节对齐,以及 data 段长度不是 4 的倍数的情况;本文为了主线清晰,默认你已经在链接脚本中做了ALIGN(4)。
bss 清零循环与 data 拷贝类似,只是统一写入 0,源地址不再递增。
bl main会把返回地址保存到 LR 并跳转到 main。如果 main 返回,会进入死循环loop_forever,这是裸机程序的常规兜底处理。
4.3 运行验证
可以配合一个非常简单的 main 来验证:
/* 文件路径:main.c */ volatile int data_var = 0x12345678; volatile int bss_var; int main(void) { /* 在这里打断点观察 data_var 和 bss_var 的值 */ while (1) { data_var++; } }调试步骤:
- 全速运行到
main入口断点; - 查看
data_var,应该等于 0x12345678; - 查看
bss_var,应该等于 0; - 如果 data_var 是随机值,说明 data 段搬运有问题;
- 如果 bss_var 不是 0,说明 bss 清零循环没执行。
这是最常见的“启动代码是否工作”验证手段。
5. XIP:程序直接在 Flash 里运行的条件与限制
5.1 什么是 XIP
XIP 是 Execute In Place 的缩写,中文经常叫“原地执行”。指的是 CPU 直接从非易失性存储介质中取指令执行,而不必将指令拷贝到 RAM。
Cortex-M 单片机内部 Flash 就是典型的 XIP 介质。CPU 通过内部总线直接访问 Flash 地址,例如0x08000000处的指令,CPU 可以直接取指。由于 Flash 的读取速度通常慢于 CPU 主频,芯片内部一般有 Flash 预取缓冲区和 Cache 来弥补性能差距。
5.2 NOR Flash 能 XIP,NAND 不行
不是所有 Flash 都支持 XIP,这取决于存储器的接口特性:
- NOR Flash 支持按字节随机读取,具备完整的地址总线,可以被映射到 CPU 地址空间,因此支持 XIP。
- NAND Flash 按页读写,存在坏块管理,CPU 不能像访问内存一样直接访问,因此不能直接 XIP,必须先把代码搬运到 RAM。
这也是为什么很多 SoC 从 NAND 启动时,需要一个小的 BootROM 或 SPL 先把 U-Boot 加载到 SRAM/DDR 中再运行。
5.3 XIP 与 data/bss 段初始化是两回事
一个常见误区是:既然程序在 Flash 里 XIP 运行,是不是就不用初始化 data/bss 段了?
不是。XIP 解决的是“指令从哪取”的问题,data/bss 段初始化解决的是“变量初值从哪来”的问题。
即使在 XIP 模式下:
- text 段不用搬运,因为 CPU 直接读 Flash;
- rodata 只读,也可以放在 Flash 中;
- data 段仍然要搬运,因为变量的值可能被修改,Flash 不能作为普通 RAM 频繁写入;
- bss 段仍然要清零,因为 RAM 上电后的数据是不确定的。
所以 XIP 只是省掉了 text 段搬运,并没有省掉 data/bss 初始化。
5.4 XIP 下如何修改全局变量
如果你在 XIP 环境下把代码和只读数据放在 Flash 中,然后对指向 Flash 地址的指针进行写操作,轻则写入无效,重则触发总线错误或 Flash 控制器异常。
工程上的处理方式很明确:
- 普通全局变量、栈、堆全部放在 RAM;
- 常量、字符串、函数指令放在 Flash;
- 必须在运行期修改的配置参数,单独划分一块可擦写的 Flash 区域或存到外部 EEPROM 中,而不是直接对代码段进行写操作。
基于这个问题,在链接脚本中一定要给 data 段设置AT> FLASH,确保变量初值存在于 Flash、而变量本体位于 RAM,否则 XIP 模式下程序一修改全局变量就会出现问题。
6. 位置无关码:启动代码的“保命符”
6.1 什么是位置无关码
位置无关码(Position Independent Code,PIC)指的是代码不依赖绝对地址,无论被加载到内存的哪个位置,都能正确执行。
对应的概念是位置相关代码(Position Dependent Code),代码在编译链接时假设自己运行在固定的链接地址上,一旦实际运行地址和链接地址不一致,访问全局变量、跳转函数都会出错。
ARM 汇编中,最简单的对比:
@ 位置无关的取地址方式 adr r0, message @ 位置相关的取地址方式 ldr r0, =messageadr会生成一条基于 PC 相对偏移的伪指令,目标是当前 PC 加上一个固定偏移;不管代码被搬到哪个地址,只要相对布局不变,取到的地址就是正确的。
ldr r0, =message会从文字池中加载 message 的绝对链接地址。如果代码没有运行在链接地址上,这个值就是错的。
6.2 为什么启动代码必须位置无关
启动阶段非常特殊:代码可能先运行在 ROM 或内部 SRAM 中,链接地址却是最终的 DDR 地址。
例如:
- 某些 SoC 上电后从内部 BootROM 执行,BootROM 代码由芯片厂家固化;
- U-Boot SPL 先被加载到内部 SRAM,运行地址与最终 DDR 运行地址不同;
- 程序在 Flash 附近启动,但要先初始化 DDR 控制器,才能把完整程序加载到 DDR。
在这些场景中,启动早期代码运行的实际地址与链接脚本写死的地址并不相同。如果启动代码里使用了绝对地址跳转或绝对地址取数据,就很容易跳飞或取到错误值。
所以 ARM 启动代码通常遵循一个原则:重定位完成之前,所有代码必须位置无关。
也就是说,启动代码要尽量避免直接访问绝对地址,而是通过 PC 相对寻址、寄存器偏移等方式计算真实地址,完成全局偏移量计算和重定位后,才跳转到链接地址对应的 C 环境中去。
6.3 一个最简单的位置无关示例
看一个典型的“计算运行地址与链接地址差值”的启动片段:
_start: @ 记录当前实际运行地址 adr r0, _start @ 记录链接地址(通过链接器符号) ldr r1, =_start @ 计算偏移:实际地址 - 链接地址 sub r2, r0, r1 @ 之后可以通过该偏移修正所有绝对地址访问 add r3, r3, r2这段代码本身是位置无关的,因为adr读取的是运行时地址,ldr r1, =_start读取的是链接地址,两者相减得到的就是“代码被搬移了多少”。
U-Boot 早期启动代码中就能看到类似逻辑:先计算重定位偏移,然后把整个镜像拷贝到 RAM 的高地址,最后跳转到重定位后的地址继续运行。
6.4 位置无关代码的代价
位置无关并不是免费的。它带来的典型开销包括:
- 每次访问全局数据可能要多一次偏移计算;
- 编译器为全局变量访问生成额外的地址计算指令;
- 使用
-fPIC编译 C 代码时,可能引入 GOT(Global Offset Table)和额外的重定位信息; - 完整支持动态重定位的 PIC 在裸机上并不简单。
因此在嵌入式启动中,最稳妥的做法不是让所有 C 代码都使用-fPIC,而是:
- 启动早期用少量手写汇编完成段初始化与重定位;
- 等代码运行在正确链接地址上之后,再跳转进入普通 C 程序;
- 普通 C 程序按链接地址编译,不再依赖位置无关。
这一点也是很多开发者看 U-Boot 源码时觉得“start.S 很绕”的原因:不是所有代码都要位置无关,只有启动早期那一段需要。
7. 常见问题与排查思路
7.1 高频问题表
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 全局变量初值全为 0 或随机值 | data 段没有搬运 | 检查启动代码和链接脚本 LMA 符号 |
| 某些全局变量值正确,某些不正确 | data 段搬运长度不对,或源地址用了错误符号 | 检查_sdata、_edata、_etext三个符号值 |
| 未初始化全局变量不是 0 | bss 清零循环未执行或寻址错误 | 检查_sbss、_ebss和清零循环 |
| 程序在 Flash 里可以跑,拷到 RAM 后跑飞 | 代码位置相关,跳转或取数使用了绝对地址 | 重定位前使用位置无关汇编 |
| main 能进入,但调用函数时崩溃 | 栈指针未正确初始化 | 检查_estack和向量表第一个 word |
链接报错 undefined symbolReset_Handler | 启动汇编符号未导出或链接脚本 ENTRY 拼写不一致 | 检查.global Reset_Handler与ENTRY(Reset_Handler) |
| 使用 ARM Compiler 5 时变量初值随机 | scatter 文件没有正确设置 RW 与 ZI 区域 | 检查分散加载文件中执行域与加载域的映射 |
7.2 典型问题拆解:全局变量初值不对
假设链接脚本是:
.data : { _sdata = .; *(.data*) _edata = .; } > RAM而没有AT> FLASH,那么链接器认为 data 段的 LMA 等于 VMA,也就是也在 RAM 中。程序运行时,RAM 对应区域并没有初值,所以全局变量自然是随机值。
排查方式:
- 在启动代码的拷贝循环处打断点;
- 查看 r2 寄存器是否为 Flash 中的一个地址;
- 如果 r2 指向 RAM,说明链接脚本少了
AT> FLASH; - 如果 r2 正确,继续检查
_edata - _sdata是否等于 data 段大小。
7.3 典型问题拆解:跳转后跑飞
比较隐蔽的一种情况:代码在 Flash 里正常跑,But 你手动把整个镜像拷贝到 RAM 后运行,结果跑飞。
此时优先怀疑位置相关问题:
- 函数之间的
bl指令通常是相对跳转,拷贝后仍然有效; - 但通过函数指针调用、
ldr pc, =address、访问全局变量等操作,如果使用绝对地址则会失败; - 需要检查反汇编中是否存在从文字池加载绝对地址并跳转的指令;
- 还需要考虑中断向量表:如果从 RAM 启动,向量表也要搬到 RAM 并通过 VTOR 重新定位。
8. 最佳实践与工程建议
8.1 启动代码工程建议
在实际工程中,可以从这几个方面避免大多数启动问题。
第一,使用成熟启动框架而非完全手写。如果使用 STM32 标准库、STM32CubeMX、RT-Thread 等,启动文件已经很完善,建议先理解再修改,不要一上来就重写。
第二,链接脚本中的段符号命名要一致。_sdata、_edata、_sbss、_ebss、_etext这些符号不是编译器自动生成的,而是链接脚本自己定义的。汇编里用哪个名字,链接脚本就要提供哪个名字。
第三,拷贝和清零尽量按 4 字节或更长粒度进行。Cortex-M4 等平台支持ldm/stm批量传输,可以提升搬运效率。在启动阶段时钟频率不是最高、Flash/RAM 速度有限时,一个词一个词搬运也通常可以接受,但工程化封装最好做对齐判断。
第四,启动代码不要依赖 volatile 全局变量通信。汇编启动代码和 C 代码之间,尽量通过寄存器或链接脚本符号传递信息,避免在段初始化完成之前访问需要特殊初始化的变量。
第五,保持启动汇编最小化。启动阶段适合做的是设置栈、拷贝 data、清零 bss、关闭看门狗、设置时钟、调用 main。不要把复杂业务逻辑写进启动汇编。
8.2 和 RT-Thread、U-Boot 启动流程对照
如果你接下来去读 RT-Thread 的 BSP 启动文件,会发现核心逻辑与本文非常接近:
- 汇编入口负责设置栈、段初始化;
- 再调用 C 环境的
entry; - 最后进入系统初始化与调度器;
- 不同 BSP 的差异主要体现在时钟、内存控制器、外设引脚配置上。
而 U-Boot 启动会更复杂一些:
- 前期启动代码运行在 ROM/SRAM 中,位置无关;
- 计算出当前运行地址与链接地址的偏移;
- 将自身重定位到 RAM;
- 建立 C 运行环境,包括栈和 BSS;
- 才进入 board_init_f 等 C 阶段。
RT-Thread 和 U-Boot 的源码都比较适合用来验证本文讲到的概念,尤其是“重定位前位置无关”这一点。
8.3 下一步学什么
理解了 data/bss/text 段初始化、XIP、位置无关码之后,接下来可以按这个顺序深入:
- 读懂你所使用芯片的启动文件,对照链接脚本确认每个符号来源;
- 使用
arm-none-eabi-nm、arm-none-eabi-readelf查看目标文件的段分布; - 使用反汇编工具观察启动代码中的
adr、ldr r0, =、bl指令差异; - 阅读 Cortex-M 内核手册中关于向量表、VTOR、栈初始化的章节;
- 动手修改链接脚本,比如把 text 段拷到 RAM 运行,观察 XIP 和非 XIP 的差别;
- 再回头阅读 U-Boot 的
start.S和散列加载文件(scatter file),把这套知识用到大型 SoC 启动场景中。
如果你的调试器支持地址断点和表达式窗口,建议在 data 搬运前后分别查看 RAM 内容变化,这是最直观的理解方式。纸上谈兵再多,不如自己在板上把_sdata、_edata、_etext三个地址打印出来看一次,很多困惑会立刻消失。