ARM启动流程核心解析:段初始化、XIP与位置无关码

ARM启动流程核心解析:段初始化、XIP与位置无关码 做嵌入式开发的都知道ARM启动流程里最容易出问题的地方常常不在某个外设驱动而在 Reset_Handler 之前那段谁都不敢细看的汇编和链接脚本。data段为什么没被搬过去bss段为什么不清零程序为什么可以在 Flash 里直接执行这些问题一旦出现往往不是改一行代码能解决的。下面从段初始化、XIP、位置无关码三个角度把 ARM 启动流程的核心逻辑拆开讲。不论你是刚接触 Cortex-M 的新手还是在移植 Uboot 或 RTOS 的工程师理解了这三个概念后续排查问题会顺利很多。1. text、data、bss 三大段先分清再谈启动流程1.1 编译产物为什么不是一整块二进制很多初学者会把编译生成的 bin 或 hex 文件理解成“一段连续的程序代码”但实际上它内部会被拆成几个不同属性的区域。C 源文件经过编译、汇编、链接之后至少会分出四类内容text 段存放可执行指令也就是函数本体。rodata 段存放只读常量比如字符串字面量、const 数组、查表用的表格。data 段存放非零初始值的全局变量和静态变量。bss 段存放未初始化或初始化为 0 的全局变量和静态变量。有的工具链会把 rodata 并到 text 里有的会独立出来。纠结名字没有意义你需要关心的是哪些区域只读哪些区域可写哪些内容在启动时必须被复制或清零。为什么这事重要因为 CPU 访问 Flash 和访问 RAM 的代价完全不同而且不是所有存储介质都支持随机读。启动流程的本质是先把各种段放到它们该待的位置再跳进 main。1.2 各段在内存里的最终位置链接脚本决定每个段最终放在哪个地址。一个常见布局如下text 段放在 Flash地址从 0x08000000 开始。rodata 段放在 text 后面。data 段的运行地址放在 RAM但初始值存放在 Flash。bss 段只占 RAM 空间不占用 Flash 空间。我在实际项目里最喜欢用一张内存分布表来帮助理解段名存放内容初始值是否存在于 Flash运行位置启动时需执行的操作text函数代码、部分常量存在Flash通常原地执行一般不需要搬运rodata字符串、const 数组存在Flash原地读取一般不需要搬运data非零初始化的全局变量存在RAM必须从 Flash 复制到 RAMbss零初始化和未初始化变量不存在RAM必须清零为什么 data 段要同时出现在两个位置因为变量在运行期会被修改而 Flash 不能像 RAM 一样随意写。所以编译器把“初始值”放在 Flash 里启动阶段再把这份初始值复制到 RAM 中一块固定的可写区域。之后程序访问 RAM 里的那部分空间CPU 才能正常读写。bss 段则反过来它根本不需要初始值只要把 RAM 对应区域清零即可。正因为这个特点bss 段不会增加固件镜像的体积这也是很多经验老到的工程师会故意把大数组放到未初始化区的原因之一。2. Reset_Handler 到底做了哪些初始化2.1 向量表和复位入口ARM Cortex-M 处理器上电后会从向量表读取初始栈顶指针和复位中断处理函数地址。向量表通常放在链接脚本指定的起始地址很多 MCU 会把内部 Flash 映射到 0x08000000 一带。向量表第一个字是栈顶地址第二个字是 Reset_Handler 地址。硬件复位后处理器加载这两个值把 PC 跳到 Reset_Handler 开始执行。在进入 Reset_Handler 时RAM 里的内容是随机的外设也没初始化。因此启动代码通常第一件事是关总中断然后设置栈指针。栈如果不设置后面调用任何函数都会跑飞。所以你会发现几乎所有启动汇编里都有类似这样一行ldr sp, _stack_top之后再调 SystemInit 或时钟配置函数把 PLL、Flash 等待周期这些基础环境准备好。这里要注意不是每个工程都必须在启动阶段配置 PLL但如果有它一般会早于 data/bss 初始化。2.2 data 段复制和 bss 清零data 段复制的核心逻辑不复杂知道源地址、目标地址、长度然后把数据从 Flash 拷到 RAM。一个常见的汇编写法是ldr r0, __etext ldr r1, __data_start__ ldr r2, __data_end__ copy_loop: cmp r1, r2 bge copy_done ldr r3, [r0], #4 str r3, [r1], #4 b copy_loop copy_done:这里的__etext表示 Flash 中代码段结束之后的位置也就是 data 初始值存放的源地址__data_start__是 RAM 中 data 段的起始地址__data_end__是结束地址。符号名称在不同工具链里可能不一样有的叫_sdata、_edata有的叫__load_start__data需要以当前工程 map 文件里实际生成的符号为准。bss 清零的代码类似只是没有源地址ldr r0, __bss_start__ ldr r1, __bss_end__ mov r2, #0 zero_loop: cmp r0, r1 bge zero_done str r2, [r0], #4 b zero_loop zero_done:这两段代码执行完后全局变量才有确定的初始状态。再往下启动代码会做一些 C 语言运行时初始化比如调用__libc_init_array处理 C 全局构造函数最后才跳到 main。注意如果是带 C 构造函数的工程还多一个遍历 init_array 的过程。漏掉这一步全局对象的构造函数不会执行后续状态会非常奇怪。3. XIP 模式下谁能留在 Flash 原地执行3.1 XIP 的硬件条件XIP 全称 Execute in Place意思是“就地执行”。CPU 不需要先把指令搬到 RAM直接从 Flash 或 ROM 里取指运行。但不是所有 Flash 都支持 XIP。Nor Flash 支持随机字节读取可以直接映射到地址空间Nand Flash 按页读写无法直接映射SD 卡和 eMMC 是块设备也做不到让 CPU 在任意地址取指令。所以你把程序放在 Nor Flash 上上电就能跑放在 Nand 上则必须先启动一段较小代码把主程序读到 RAM 再执行。对于大多数 Cortex-M MCU内部 Flash 被映射到固定地址天生支持 XIP。这也是为什么很多工程不需要把 text 段搬到 RAM也能正常运行。3.2 只读数据可以留在 Flashrodata 里的字符串、const 数组、字库、查表数据在运行期不会修改天然适合留在 Flash。直接从 Flash 读取并不会产生副作用还能省下 RAM 空间。我见过不少项目为了“性能优化”把所有 .rodata 也搬到 RAM结果 RAM 紧张到堆栈溢出。如果某个常量表访问频率不是特别高把它留在 Flash 是更稳妥的做法。3.3 可写数据必须搬到 RAMdata 段和 bss 段则完全不同。变量在运行期会被赋值、累加、修改而 Flash 的写操作需要擦除、按页编程速度很慢且不适合频繁执行。更关键的是很多 Flash 控制器并不允许对代码地址区域直接进行普通写操作强行写可能造成总线错误。所以启动流程里总是把 data 段从 Flash 复制到 RAM把 bss 段清零。这样之后的 C 代码访问普通全局变量实际上是在访问 RAM。XIP 的边界判断就一句话只读内容可以留 Flash可写变量必须放 RAM可执行代码可以留 Flash也可以按需搬到 RAM 追求速度。4. 位置无关码启动阶段为什么离不开它4.1 位置无关码是什么位置无关码Position Independent CodePIC是一种不依赖绝对地址的代码实现方式。程序被加载到任意地址都能正确运行。实现位置无关的基础是使用 PC 相对寻址。比如 ARM 指令里的LDR Rd, [PC, #offset]或者汇编指令ADR、ADRL它们访问的不是一个固定绝对地址而是基于当前 PC 的偏移量。只要访问目标与 PC 位于同一可执行区域即使代码整体被搬移到另一个地址偏移关系依然成立。普通 C 代码编译时编译器默认使用绝对地址跳转和访问。如果你把一个链接地址在 0x08000000 的程序直接搬到 0x20000000 执行函数调用和全局变量访问都会指向原来的地址结果就是跑飞。所以在 bootloader、低层启动代码、某些安全固件里编译指令里通常会开启位置无关选项。比如 GCC 的-fpic或-fpie配合汇编实现才能保证早期代码可重定位。4.2 启动阶段和链接地址不一致的场景MCU 从内部 Flash 启动时链接地址和实际运行地址通常是一致的所以位置无关码的问题不太明显。但下面这些场景就必须考虑从外部 Nor Flash 启动Boot 阶段在低地址执行主程序链接到 DDR 地址。Boot ROM 先执行一段掩膜代码再把用户代码搬运到 RAM。Uboot 早期还在 Flash/ROM 中运行尚未完成 DDR 初始化。某个 RTOS 的启动代码先放在可执行地址随后把自己重定位到最终链接地址。这些场景里前期代码的运行地址和链接地址不一致。如果全部使用绝对地址跳转就会出问题。我记得调试一块带外部 Nor 的板子时复位后 PC 已经在 Nor 映射地址上运行但代码链接地址写到了 DDR。程序一进入 main 前的初始化调用就 HardFault后来才发现是早期跳转指令没做位置无关处理。4.3 如何验证代码是否位置无关编译后看反汇编文件是最直接的。如果看到大量BL指令的目标地址与当前 PC 距离很远并且跳转目标是一个固定绝对地址说明这段代码不是位置无关的。也可以用运行时验证。在启动代码里故意把一个函数或变量放在与实际加载地址不同的链接地址上然后看程序还能不能正确访问。如果跳转正常说明编译器生成了 PC 相对寻址代码具备位置无关能力。另一个更实际的办法是看链接脚本和启动汇编。如果启动汇编里大量使用ADR而不是LDR Rd, label通常说明作者考虑了位置无关问题。5. 启动之后出问题按这条链路排查5.1 变量值不对先看 data/bss 初始化如果你进入 main 后发现某个全局变量初值不是代码里写定的值不要急着改逻辑。先按顺序检查打开 map 文件找 data 段和 bss 段的起止地址。在启动汇编的 copy 循环和 zero 循环处打断点。看复制前后 RAM 里对应地址的数据变化。检查链接脚本是否给 data 段指定了AT FLASH。确认堆栈区没有和 data/bss 重叠。我一般会在启动阶段故意定义两个全局变量一个初始化为非零值一个初始化为 0。若非零变量读到错误值问题大概率在 data 复制若 0 变量读到随机值先怀疑 bss 清零没执行或堆栈覆盖。5.2 HardFault 和跳转异常怎么查HardFault 出现后不要凭感觉改代码。先看这几项看当前栈里保存的 PC判断卡在哪个函数。把 PC 地址和 map 文件对比确认运行地址是否等于链接地址。查看访问的地址是否存在。是 Flash、RAM还是外设地址该地址是否超出芯片映射范围确认是否在 start 阶段就开了中断中断服务函数访问的变量是否已经初始化。如果卡在进入 main 之前重点检查 SystemInit 或时钟初始化里有没有访问还没上电的外设地址。启动早期总中断没有关闭也会导致 data/bss 还没准备好时收到中断中断函数里一旦访问全局变量结果不可控。5.3 程序能跑但 Flash 写不进去这种情况大多和段布局无关而是写地址落在 Flash 之外的映射区或者写入粒度不对。Nor Flash 通常按字或页编程如果程序直接写单个字节会失败。遇到这类问题先看目标地址是否在 Flash 映射范围内再看 Flash 控制器驱动的编程函数是否按页处理。6. 链接脚本才是很多启动异常的根源启动代码本身写得很短反而链接脚本一旦出错问题非常隐蔽。因为它编译不报错烧录也不报错直到运行时才表现出异常。6.1 data 段漏写 AT FLASH一个常见错误是链接脚本里 data 段只写了 RAM没写AT FLASH。这样链接器会认为 data 初始值应该放在 RAM 地址连续区域。最终的 hex 文件里data 初始值会被安排在 RAM 地址上。烧录器把文件烧进 Flash 后初始值并不会出现在 Flash 中的预期位置。系统上电启动代码从 Flash 的某个地址复制 data 到 RAM读到的却是其他代码或常量变量初值自然不对。正确写法里data 段的 LMA加载地址和 VMA运行地址必须分开.data : { _sdata .; *(.data*) _edata .; } RAM AT FLASH如果你在工程里删除过加载区域设置建议立刻检查一下 data 段这行。6.2 对齐和符号名不一致ARM 上很多指令和数据结构要求 4 字节或 8 字节对齐。链接脚本里经常用. ALIGN(4)来保证段边界对齐。如果某个段起止地址没有对齐copy 或 zero 循环按 32 位操作时可能把相邻内存也写到或漏写到。不同编译器的启动符号差异也很大。GCC 常用__data_start__、__bss_start__ARMCC 5 常用Image$$RW_IRAM1$$Base到了 ARMCC 6、Clang 环境下部分启动文件和分散加载脚本可能需要重写。跨编译器移植时最稳妥的办法是打开 map 文件和反汇编确认启动汇编里引用的符号都真实存在于当前工程的链接输出里。6.3 固件镜像体积异常如果你发现编译出来的 bin 文件比预期大很多很可能是某些只读数据被误放到了 data 段或者是大数组没有用const修饰。另一个常见情况是 data 段初始值被重复放置。链接脚本里如果同时存在多个输出段且都分配了 Flash 地址bin 文件会包含重复内容。遇到体积异常先看 map 文件里 Flash 占用分布。7. 从 MCU 启动扩展到 RTOS 和带 MMU 的 SoC7.1 RTOS 里的启动链路以 RT-Thread 为例启动链路同样分几步硬件复位、向量表跳转、系统时钟初始化、data/bss 初始化、进入rtthread_startup最后创建 init 线程和主线程。很多人查 RTOS 启动问题一上来就切到线程调度器其实没有意义。更合理的顺序是在 Reset_Handler 处看硬件初始化是否完成。在进入 main 前看 data/bss 是否正常。在rt_hw_board_init处看板级外设是否准备好。再去看线程创建和调度器启动。RTOS 里很多全局对象比如线程控制块、信号量、互斥量都是全局变量。如果 data/bss 初始化有问题这些对象一启动就是脏数据表现出来的现象可能是在某个系统调用里卡死。真正的病灶却在启动阶段。7.2 带 MMU 的 SoC 和 Uboot 启动Cortex-A 这类带 MMU 的处理器启动流程会更长。比如 IMX6 这类 SoCBoot ROM 会先读取 IVT 启动头决定把代码搬运到哪里执行。之后还要初始化 DDR、建立页表、配置 MMU最后才跳到主程序链接地址。Uboot 在起始阶段通常使用位置无关码先在低地址或者 ROM 映射地址执行等 DDR 就绪后再把整个镜像重新定位到链接地址最后跳转。这套流程里你不能只看 data/bss还要区分“加载地址”“链接地址”“执行地址”三个概念。加载地址镜像实际存放在哪里。链接地址编译时假设代码运行在哪个地址。执行地址当前 PC 正在执行的地址。这三个概念只要有一个对不上系统就会在跳转时崩掉。排查带 MMU 的平台时先通过串口打印 PC 值和 map 文件里的链接地址做对比比开调试器猜更快。7.3 不同平台的共通思路虽然 MCU 和 SoC 的启动细节差异很大但内核逻辑一致建立栈、执行段初始化、配置基础时钟、设置中断环境、跳主程序。换平台后只要按照“向量表、链接脚本、启动汇编、串口日志”这条链路去找就能把大部分问题框定在一个小范围内。8. 上手一个新平台时我建议这样验证8.1 用最小工程跑通启动链路拿到一块新板子不要急着把所有驱动都加载进去。先做一个最小工程只保留时钟初始化。只保留一个串口输出。定义一个非零初始化的全局变量。定义一个零初始化的全局变量。在进入 main 前后各打印一次。如果非零变量和零变量打印都正确说明 data/bss 初始化正常启动链路基本可靠。之后再把外设驱动、RTOS、文件系统逐层加回去。8.2 从 map 文件和反汇编入手遇到启动问题时我最常打开的是三类文件.map文件看各段起止地址和占用的内存。.lst文件或编译器反汇编输出看 Reset_Handler 到 main 的调用链。串口日志标记执行到哪个阶段。下面是一个典型的判断流程上电后没有任何输出 - 看电源和时钟是否正常 - 看 PC 是否进入 Reset_Handler - 看启动文件是否被编译链接 - 看 vector table 是否放在正确地址 有输出但变量错误 - 看 data/bss 地址和长度符号 - 检查启动汇编 copy/zero 是否执行 - 检查 RAM 区是否被编译器和启动代码保持同一布局 进入 main 后跳转异常 - 比较链接地址和实际地址 - 检查是否使用位置无关码 - 检查重定位和 LMA 设置8.3 编译器切换后额外跑一轮启动测试从 ARMCC 5 切到 ARMCC 6或者从旧 GCC 切到新 GCC即使语法兼容启动文件和链接脚本也不一定兼容。新版编译器可能默认使用不同的启动库也可能会调整段布局。切换后不要只跑业务用例必须单独跑一遍启动测试确认 data 复制、bss 清零、main 入口和中断向量表都正常。另外网上找来的启动文件不一定适用于当前工程。不同芯片的 Flash 起始地址、RAM 大小、外设基址差异很大。跨平台移植时请先对比向量表、链接脚本内存区域和启动汇编里的地址定义。ARM 启动流程研究到最后其实就变成一件事确认哪些数据在什么阶段被放到了什么地址。把 data/bss/text 段的初始化、XIP 边界和位置无关码这三个点想清楚再回头看启动汇编和链接脚本很多原本神秘的问题都会变得很直接。个人更建议先把单次启动跑通再去研究复杂优化。如果遇到特别诡异的错误先看 map 文件、反汇编和串口日志大多数启动问题终究是某一个段没按预期摆放或复制。