很多嵌入式开发者在学 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 支持 XIPExecute In Placetext 段直接在 Flash 里就被 CPU 取指执行这也是目前绝大多数 Cortex-M 单片机的运行方式。如果芯片从 NAND Flash 启动或者外部存储不支持直接取指则需要把 text 段整体拷贝到 RAM 中再跳转执行这时候 text 段的“搬运”也是必要步骤。2.2 data 段data 段存放“有初值且非零的全局变量和静态变量”。例如int counter 0x5A; static int init_flag 1;这些变量在程序运行期间可读可写必须放在 RAM 中。但它们的初值在编译时已经确定不能丢失所以初值必须存放在 Flash 中。于是 data 段就有了两个地址加载地址LMALoad Memory Address在 Flash 中的位置存放初值运行地址VMAVirtual 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指令、只读常量FlashFlash 或 RAMXIP 时不用动非 XIP 时拷贝到 RAMdata已初始化全局/静态变量Flash保存初值RAM从 Flash 拷贝到 RAMbss未初始化全局/静态变量不占用RAM清零heap动态分配内存无RAM设置堆边界stack函数调用栈无RAM设置栈顶指针正是因为 text、data、bss 三者的“装载方式”不同链接脚本里才会出现各种地址符号启动代码也会出现一个个循环搬运指令。3. 链接脚本LMA 和 VMA 是理解启动的钥匙3.1 三个关键地址阅读链接脚本之前先记住三个概念LMALoad Memory Address段的内容被“加载”到的地址。对于 data 段来说就是初值存在 Flash 的哪个位置。VMAVirtual 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清零 bss4.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 自增 4str 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 什么是 XIPXIP 是 Execute In Place 的缩写中文经常叫“原地执行”。指的是 CPU 直接从非易失性存储介质中取指令执行而不必将指令拷贝到 RAM。Cortex-M 单片机内部 Flash 就是典型的 XIP 介质。CPU 通过内部总线直接访问 Flash 地址例如0x08000000处的指令CPU 可以直接取指。由于 Flash 的读取速度通常慢于 CPU 主频芯片内部一般有 Flash 预取缓冲区和 Cache 来弥补性能差距。5.2 NOR Flash 能 XIPNAND 不行不是所有 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 直接读 Flashrodata 只读也可以放在 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 CodePIC指的是代码不依赖绝对地址无论被加载到内存的哪个位置都能正确执行。对应的概念是位置相关代码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 代码时可能引入 GOTGlobal Offset Table和额外的重定位信息完整支持动态重定位的 PIC 在裸机上并不简单。因此在嵌入式启动中最稳妥的做法不是让所有 C 代码都使用-fPIC而是启动早期用少量手写汇编完成段初始化与重定位等代码运行在正确链接地址上之后再跳转进入普通 C 程序普通 C 程序按链接地址编译不再依赖位置无关。这一点也是很多开发者看 U-Boot 源码时觉得“start.S 很绕”的原因不是所有代码都要位置无关只有启动早期那一段需要。7. 常见问题与排查思路7.1 高频问题表问题现象常见原因解决思路全局变量初值全为 0 或随机值data 段没有搬运检查启动代码和链接脚本 LMA 符号某些全局变量值正确某些不正确data 段搬运长度不对或源地址用了错误符号检查_sdata、_edata、_etext三个符号值未初始化全局变量不是 0bss 清零循环未执行或寻址错误检查_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三个地址打印出来看一次很多困惑会立刻消失。