ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ARM启动流程详解:data/bss段初始化、XIP与位置无关码

ARM启动流程详解:data/bss段初始化、XIP与位置无关码 打开一份编译好的 ARM 工程很多人习惯直接点“Download”烧录然后看 main 里的打印。但如果把窗口切到 map 文件、反汇编窗口或者是调试器刚停在 Reset_Handler 的那一帧你会看到启动流程里其实藏了一套前置动作先建栈、再清 bss、再搬 data、最后才调 main。这套动作在裸机、RT-Thread、uboot 甚至 Linux 底层启动里都存在只是表现形式不同。这次我们来看 ARM 启动流程第三弹集中聊三个高频词data/bss/text 段初始化、XIP、位置无关码。前两弹如果已经讲完了入口代码和中断向量那这次就是把“main 之前到底发生了什么”全部补齐。内容偏底层但只要你写过 STM32、RT-Thread或者动过 uboot/Linux 启动代码读完就能把启动过程在脑子里完整串起来。先说结论data 段和 bss 段必须由启动代码显式处理text 段决定代码放哪执行XIP 决定代码是不是直接死在 Flash 里跑位置无关码解决“代码被搬到别处还能不能跑”的问题。这四件事互相配合但经常被人混在一起讲。下面拆开逐层说明。1. 核心概念速览先把这次涉及的几个概念列成一张总表后面每个专题再展开。这张表建议收藏排查启动异常时先用它定位方向。概念本质典型存储位置启动阶段做什么text 段可执行指令、部分只读常量Flash 或 ROMXIP 场景下直接原地执行不需要初始化但需要保证跳转正确rodata 段只读数据如字符串常量一般和 text 放一起也可能独立分节不需要运行时修改无需初始化data 段已初始化全局变量非零初值初值存 Flash运行变量在 RAM启动时把初值从 Flash 拷到 RAMbss 段未初始化或零初始化的全局变量RAM启动时对整个区域清零栈函数调用临时数据RAM启动第一条指令前就要设置栈指针XIP代码直接在 Flash 上执行FlashCPU 取指不拷贝到 RAM省 RAM但对 Flash 读速度和总线有要求LMA/VMA加载地址 / 运行地址链接脚本中描述决定拷贝的源地址和目的地址位置无关码代码与数据使用相对寻址可在任意地址运行重定位前、bootrom、uboot 早期阶段使用这些概念在 MCU 和 MPU 场景下的侧重点不一样。MCU 上更多是“data/bss 初始化 XIP 默认开启”MPU 上更多是“重定位 位置无关码 MMU 页表切换”。但底层逻辑是同一套。2. 这套机制出现在哪些真实启动场景里想真正理解段初始化不能只背概念要能把它映射到实际工程中。下面列几个你大概率会碰到的场景。2.1 裸机工程STM32 标准库 / HAL 启动用 Keil 打开一个 STM32F103 工程启动文件是startup_stm32f10x_hd.s。复位中断Reset_Handler里做了这几件事设置主栈指针。调用SystemInit把时钟切到目标频率。搬运 data 段。清零 bss 段。调用__main最终进入 C 的main。如果你用 GCC 工具链同样的工作由startup_stm32f10x_hd.s 链接脚本中的符号共同完成。无论哪种工具链核心代码逻辑都一样你写的全局变量初值必须有人负责从 Flash 搬到 RAM。2.2 RT-Thread 启动初始化流程RT-Thread 的启动流程比裸机多了一层。它也有 Reset_Handler但entry函数不是直接到main而是先做硬件板级初始化再到rt_hw_board_init然后调度器启动、创建主线程。这里要注意RT-Thread 里同样依赖 C 运行时环境data/bss 初始化必须在 C 代码大量执行之前完成。如果 bss 没清零RT-Thread 内核对象链表、线程控制块里的标志位一开始就是脏数据跑起来的行为完全不可控。2.3 Uboot 启动流程与重定位Uboot 分为前期的start.S阶段和后期的relocate阶段。start.S早期代码要满足两个条件代码不知道自己最终被链接到哪个地址。代码要在低地址或只读介质上先跑一段。所以start.S大量使用位置无关写法比如adr、ldr pc, label配合重定位逻辑。到_relocate阶段才把 text/data 段整体搬到 DDR 高位地址然后跳转到绝对地址执行。如果你去读 uboot 手册和源码会发现它比 MCU 启动多了一个「重定位」动作这就是位置无关码 段搬移的完整应用。2.4 IMX6 等带 Boot ROM 的 SoCIMX6 这类 SoC 内部有 Boot ROM上电后先由 ROM 代码决定从 SD、NAND、QSPI 还是 USB 启动。ROM 会把用户代码的前面部分加载到内部 SRAM 或 DDR 里。这里会涉及 IVTImage Vector Table和 DCDDevice Configuration Data结构。IVT 告诉 Boot ROM你的代码入口在哪、DCD 地址在哪、代码要加载到哪个地址。后续交给用户代码后才逐步走完段初始化。这比 MCU 流程多了一层“外部 Boot ROM 预加载”的概念但段初始化的职责仍然落在用户代码上。从这些场景能看出段初始化不是编译器的附加操作而是启动代码必须承担的任务。你用的操作系统、Bootloader、裸机框架可能封装了它但出了问题排查时最终都要回到这层。3. 编译链接基础text/data/bss 段是怎么产生的3.1 编译器和链接器如何把代码分段C 代码经过编译后会生成多个 section。用 GCC 工具链时常见的是.text代码段。.rodata只读数据段。.data已初始化数据段。.bss零初始化数据段。每个 section 有**加载地址 LMALoad Memory Address和运行地址 VMAVirtual Memory Address**两个属性。LMA 表示“数据在烧录时存放在哪”VMA 表示“程序运行时要访问的地址”。对 MCU 来说LMA 一般在 FlashVMA 一般在 RAM。链接脚本的核心任务之一就是告诉链接器这些段分别放在哪些地址并导出一些符号供启动代码使用。3.2 一段典型 GNU LD 链接脚本下面是一个简化但结构完整的 ARM MCU 链接脚本片段使用 GNU LD 语法。建议对照你的 STM32/GD32 工程里的.ld文件阅读。ENTRY(Reset_Handler) MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K RAM (rwx) : ORIGIN 0x20000000, LENGTH 128K } SECTIONS { .text : { KEEP(*(.isr_vector)) *(.text*) *(.rodata*) _etext .; /* data 初值在 Flash 中的起始地址 */ } FLASH .data : { _sdata .; /* data 段 RAM 起始地址 */ *(.data*) _edata .; /* data 段 RAM 结束地址 */ } RAM AT FLASH .bss : { _sbss .; /* bss 段起始地址 */ *(.bss*) *(COMMON) _ebss .; /* bss 段结束地址 */ } RAM }这里重点看.data的写法 RAM AT FLASH。它的意思是运行地址在 RAM但烧录内容放在 Flash。链接器在生成 bin 文件时会把.data的初始值放在 Flash 的_etext位置。启动代码搬运时就是从这里把初值拷贝到 RAM 里的_sdata。3.3 用 map 文件验证段分布编译成功后Keil 会生成.map文件GCC 工具链则可以使用arm-none-eabi-objdump、arm-none-eabi-nm或打开.map文件查看。检查一个工程是否会把.data初值放在 Flash 末尾可以用这个命令arm-none-eabi-nm -n build/project.elf | grep -E _etext|_sdata|_edata|_sbss|_ebss输出会显示类似08007800 A _etext 20000000 A _sdata 20000234 A _edata 20000234 A _sbss 20000456 A _ebss如果_etext和_sdata地址差得很大说明 data 初值确实放在 Flash 中运行地址在 RAM启动拷贝必然存在。如果这两个地址相等说明该工程没有搬运 data 段或链接脚本把 data 段放在了 RAM 初始化的模式下具体要结合目标平台判断。4. data 段与 bss 段初始化的实现细节4.1 为什么全局变量不能直接使用 Flash 里的初值C 语言规定非零初始化的全局变量在main之前必须处于正确的初值状态。但 MCU 的 RAM 是易失性存储器每次上电内容不确定。所以初值必须保存在非易失介质Flash中启动时再由代码搬运。这是 data 段存在的原因。但把所有用户全局变量都搬运一遍会拖慢启动时间。于是编译器把“只需清零”的变量单独放到 bss 段启动时只需快速 memset不需要从 Flash 搬运逐字节初值。这就是 bss 段存在的原因。4.2 启动汇编代码中的 data/bss 初始化以 ARM Cortex-M 为例用汇编实现 data 搬运和 bss 清零可以直接在启动文件中看到Reset_Handler: ldr r0, _sdata ldr r1, _edata ldr r2, _etext copy_data: cmp r0, r1 bge zero_bss ldr r3, [r2], #4 str r3, [r0], #4 b copy_data zero_bss: ldr r0, _sbss ldr r1, _ebss mov r2, #0 zero_loop: cmp r0, r1 bge jump_to_main str r2, [r0], #4 b zero_loop jump_to_main: bl SystemInit bl main这段代码如果写成 C 函数放在启动早期执行通常要加__attribute__((used))或类似属性防止编译器在 LTO 时认为“没人调用”而裁剪掉。4.3 常见实现错误实际工程里data/bss 初始化最容易出问题的地方是初值源地址写错有些人把_sdata当成初值源地址这是错的。初值源地址应该是_etext也就是 Flash 中存放初值的位置。bss 未清零如果启动代码忘清 bss局部模块的全局标志位、信号量初始化前的初值就是随机值表现为“上电后行为不稳定”。拷贝长度超过 RAMdata 段过大导致拷贝越界覆盖栈区启动后函数调用直接跑飞。编译器版本差异Keil MDK 使用 Arm Compiler 5 时启动文件是传统 ARM 汇编切到 Arm Compiler 6 后如果不更新启动文件或汇编器语法会出现编译错误。这是很多老工程升级编译器时踩到的坑。4.4 在 C 语言中实现段初始化的替代方式有些框架喜欢在 C 里做这段逻辑例如 RT-Thread 早期 bsp 的某些启动代码或者用运行时库__libc_init_array前的__main帮做。GCC 工具链下也可以这样写extern unsigned int _etext; extern unsigned int _sdata; extern unsigned int _edata; extern unsigned int _sbss; extern unsigned int _ebss; void startup_memory_init(void) { unsigned int *src _etext; unsigned int *dst _sdata; while (dst _edata) { *dst *src; } dst _sbss; while (dst _ebss) { *dst 0; } }这段代码本质上和汇编启动文件做的是同一件事。区别在于C 版本要依赖栈、依赖调用约定所以必须在栈指针设置完之后才能调用。通常放在 Reset_Handler 之后的早期 C 阶段执行。5. XIP代码为什么不搬进 RAM 也能执行5.1 XIP 的核心思想XIPExecute In Place就是代码在 Flash 或 ROM 中直接执行。CPU 取指时通过总线直接读取 Flash 地址里的指令而不是先拷贝到 RAM。STM32 默认就是这种模式代码段.text放在0x08000000起始的 Flash 地址复位后 PC 直接在 Flash 里跑。XIP 的好处非常直接省 RAM代码量多大RAM 就省多大。一个 128KB 代码的工程如果全量搬到 RAM需要额外占 128KB RAM。启动快没有大批量代码拷贝过程上电很快就能执行到 main。掉电不丢代码在 Flash 里不容易因异常写操作损坏。代价也很明显Flash 读取速度通常低于 RAM尤其是高性能 MCU 跑到几百 MHz 时需要加 Cache 和预取缓冲。Flash 有擦写限制不能在 Flash 里像 RAM 一样频繁修改变量。如果 Flash 总线带宽不够CPU 取指会停顿。5.2 XIP 下哪些数据必须搬到 RAMXIP 说的是代码可以原地执行不代表所有数据都能原地访问。全局变量必须读写而 Flash 不能随意写所以 data/bss 必须放在 RAM这就是为什么链接脚本要把.data的 VMA 放在 RAM。只读数据.rodata可以留在 Flash。字符串常量、查表用的const数组理论上可以直接在 Flash 里读。但要注意部分编译器在优化情况下可能把小的const数据优化到指令的立即数里也可能把大const数组放到 Flash还有一些场景下.rodata被放在.data之后这个要看具体链接脚本和编译器策略。5.3 混合执行模式部分代码放 RAM部分 XIP有些场景不希望全部代码 XIP。比如对性能要求高的中断服务函数放在 RAM 里执行。Flash 上正在执行擦写操作时代码必须从 RAM 运行否则“取指 Flash 的同时擦写 Flash”会造成总线冲突。这时可以在 C 源码里给函数指定 section然后修改链接脚本让该函数运行在 RAM。GCC 中写法如下__attribute__((section(.ramfunc))) void flash_erase_callback(void) { // 这段函数会被放到 RAM 执行 }链接脚本中增加.ramfunc : { *(.ramfunc*) } RAM AT FLASH它的原理和 data 段搬运完全一样函数指令的初值放在 Flash启动时拷贝到 RAM然后调用位置落在 RAM。等于“代码段的 data 化”。5.4 XIP 不是所有 ARM 平台默认开启在带 MMU 的高性能 ARM 平台如 Cortex-A 系列上是否 XIP 往往不直接由链接脚本决定而是由内存映射和 boot 流程决定。有些平台把整个系统拷贝到 DDR 后才执行有些平台在 NOR Flash 上 XIP有些平台则把关键代码拷贝到 SRAM 再执行。所以理解 XIP 时要结合具体硬件总线而不是把它当成一个纯粹的软件开关。6. 位置无关码代码搬到任何地址都能跑的前提6.1 为什么需要位置无关码普通链接的代码是按绝对地址生成的。比如一个函数链接在0x08001000跳转和函数指针都会含有这个地址信息。如果代码被搬到另一个地址绝对地址就失效了。在下面这些场景代码编译时无法确定最终运行地址Boot ROM 中的一段引导代码可能被拷贝到不同片内 SRAM 位置。Uboot 早期汇编在 DDR 初始化完成前不知道自己最终的位置。共享库 / 动态加载模块加载地址由系统决定。RTOS 中的部分动态装载模块。位置无关码Position Independent CodePIC的核心就是让代码中所有地址引用都使用 PC 相对偏移或通过基址寄存器计算而不是写死绝对地址。6.2 ARM 汇编中的相对寻址与绝对寻址以 ARM 汇编为例下面两种跳转方式含义不同b label 相对跳转与当前位置无关 ldr pc, label 绝对跳转需要依赖链接地址b指令使用相对偏移代码搬走后偏移量不变所以位置无关。ldr pc, literal把 label 的绝对地址加载进 PC代码搬走后绝对地址不变仍然是旧地址就会跑飞。在 GNU 汇编中adr指令会尝试生成 PC 相对地址adr r0, msg 位置无关的伪指令生成 PC 相对偏移 ldr r1, msg 绝对地址加载如果需要在位置无关代码中获取某个符号的地址推荐用adr或者用adrl大多数汇编器支持。编译器也会根据优化参数来决定是否使用 PC 相对寻址。6.3 编译器层面的位置无关编译对于 C 代码GCC 可以使用以下编译选项-fPIC -fpie-fPIC常用于共享库-fpie用于可执行文件。两者都会让编译器尽量避免在代码段中直接使用绝对地址而是生成 PC 相对访问、GOT 或者重定位表项。MCU 场景中很多工程默认不开 PIC因为绝对寻址效率更高。但在下面这些场景必须显式配置编写 bootloader且 bootloader 的运行地址和链接地址不一致。编写一个可以搬到片内任意 SRAM 地址运行的裸机诊断程序。RTOS 中做动态模块加载。6.4 位置无关码的代价位置无关码不是免费的。编译出的代码通常更大因为需要额外的基址计算、PC 相对偏移修正运行效率也可能降低。所以不要在全工程盲目开启 PIC只在真正需要地址无关的阶段使用。常见的做法是早期启动阶段少量汇编 极小 C 文件开启 PIC主体代码仍按绝对地址链接。7. 段初始化与重定位的区别很多初学者把“data/bss 初始化”和“重定位”混为一谈。这里明确区分data/bss 初始化把全局变量的初值从 Flash 拷到 RAM以及清零 bss。对象是「数据段」。重定位把整个代码段甚至整个镜像从一个地址搬到另一个地址并修正所有绝对地址引用。对象是「整个程序」。Uboot 的重定位流程可以概括为早期start.S中代码以位置无关方式运行。检测当前运行地址和链接地址。如果不同把整个镜像搬到链接地址对应的 RAM 区域。用重定位表修正.rel.dyn等重定位项让绝对地址指向新位置。跳转到重定位后的 C 函数。而 MCU 裸机启动通常只做 data/bss 初始化不做整体重定位因为链接脚本已经把 text 放在 Flash XIPdata 放在 RAM没有大规模搬移代码的需要。理解这个区别后读 uboot 源码会顺畅很多。8. 实战验证方法反汇编、map 文件与调试器8.1 查看全镜像反汇编使用 ARM GCC 工具链时最直接的验证方式是对 elf 反汇编。命令如下arm-none-eabi-objdump -D build/project.elf project.dis然后在反汇编文件里查看Reset_Handler确认启动代码确实执行了 data 拷贝和 bss 清零。同时可以搜索_sdata、_etext、_sbss、_ebss这些符号的引用位置。8.2 查看段大小用size命令快速查看各个段的大小arm-none-eabi-size build/project.elf输出类似text data bss dec hex filename 12345 108 256 12709 31a5 build/project.elftext 表示 Flash 中的代码和只读数据。data 表示初值占据的 Flash 空间但运行时占用 RAM 同样大小。bss 表示仅占用 RAM不占 Flash。从这张表就能估算 Flash/RAM 消耗比例以及启动拷贝时长。如果 data 段很大说明全局变量初值多启动拷贝时间会变长。8.3 用调试器验证拷贝结果在调试器中给Reset_Handler的拷贝循环打断点或者在进入main前查内存窗口查看_sdata地址处的值是否等于 Flash 中_etext处的初值。查看_sbss到_ebss整个区间是否都是 0。查看栈顶地址是否正确。如果调试器支持表达式窗口可以直接写*(unsigned int*)0x20000000对比初值判断 data 拷贝是否生效。如果发现某些全局变量的地址落在_sbss和_ebss之间但调试器显示它不是 0说明 bss 清零逻辑没有执行或执行顺序有问题。8.4 查看 map 文件定位变量位置map 文件定位全局变量时很有用。GCC 生成的 map 文件里会有 memory map 和 cross reference。搜索你的全局变量名会看到它被放在哪个 section、哪段地址。如果它显示在.bss但初值非零说明源码里可能写了 0或者没有初始化如果显示在.data初值会从 Flash 拷贝。9. 资源占用与性能观察9.1 Flash 和 RAM 占用怎么观察在 MCU 工程里每次编译后终端或 IDE 编译输出都会显示 Program Size. 例如Program Size: Code12345 RO-data678 RW-data100 ZI-data256其中Code代码段。RO-data只读数据如字符串、const 数组。RW-data已初始化数据烧录占 Flash运行时占 RAM。ZI-data零初始化数据只占 RAM。在 Keil 中这个输出直接可见而且能看出启动时要搬多少 data。如果RW-data很大启动时间会变长。如果要降低 RAM 占用可以检查哪些大数组是全局变量且初值非零改成运行时填充或放 Flash。9.2 XIP 对执行速度的影响XIP 场景下CPU 访问 Flash 的速度往往比 RAM 慢。某些 MCU 内核频率远高于 Flash 读取速度需要使用预取缓冲区或 Cache。优化时重点看代码是否频繁调用大量短函数造成 Flash 预取失效率高。中断处理中是否有大量代码从 Flash 取指可以考虑把中断函数放到 RAM。是否对 Flash 进行擦写操作时代码仍从 Flash 取指。如果启动时间过长优先优化 data 段大小和 bss 清零循环而不是优化 Flash 速度。9.3 如何降低启动时间和 RAM 占用把不需要长期存在的临时缓冲放到 heap 或栈上。将大且只读的查表数据放到.rodata或单独 Flash 段。对性能不敏感的重型驱动保持 XIP。若 Flash 速度慢且允许把关键中断函数放 RAM但要注意 RAM 资源有限。10. 常见问题与排查方法实际调试中段初始化问题表现得千奇百怪但根因大多集中在几个点上。下面整理一张排查表建议保存。问题现象可能原因排查方式解决方案全局变量第一次访问不是设定初值data 未拷贝或拷贝源地址错误检查_etext、_sdata地址是否合理修正启动代码拷贝方向或链接脚本 LMA全局变量初值是随机值bss 未清零或清零不完整调试器查看_sbss到_ebss内容在调用 main 前完整清零 bss上电后程序直接跑飞栈指针未设置或 data 段拷贝越界覆盖栈查看 Reset_Handler 第一句是否设置 SP检查 map 文件修正启动文件或链接脚本空间分配代码烧到 Flash 后执行到某函数崩溃函数被链接到 RAM 但没拷贝反汇编查看函数地址比对 map确认.ramfunc段初值被搬运Uboot 搬到新地址后跳转崩溃位置无关码编译不完整存在绝对地址引用反汇编搜索绝对地址加载指令使用-fPIE -fpic编译早期代码修正重定位表Keil 从 AC5 切到 AC6 后启动文件报错旧汇编语法和 armclang 汇编器不兼容查看报错行确认是启动文件语法问题更新启动文件或使用兼容汇编语法STM32CubeMX 生成工程后找不到arm文件夹工程结构变化或中间文件生成位置不同检查编译输出路径、RTE 目录根据实际目录调整工程搜索路径调用 C 库函数前没有执行__main部分库函数依赖 C 运行时初始化检查启动流程是否进入__main/__libc_init_array完整走库初始化或避免使用依赖初始化库的函数使用 GCC 工具链时链接报 region overflowdata/bss 超出 RAM 或 text 超出 Flash查看.map文件各段大小裁剪代码、改大 Flash/RAM 分配或优化数据结构11. 最佳实践让启动代码成为可排查的代码启动代码是嵌入式工程里最容易被忽略、也最不该被忽略的文件。以下是我在多个工程中沉淀下来的几条实用建议。11.1 链接脚本先读清楚再动启动文件很多启动问题表面是启动文件写错实质是链接脚本里的段分布和启动文件假设不一致。改启动文件之前先把链接脚本里每个段、每个导出符号的位置看一遍。特别是 data 段拷贝的源地址必须以链接脚本里的_etext为准不要凭记忆硬编码 Flash 地址。11.2 启动代码加上断言或自检在启动阶段做两个轻量自检收益很高bss 清零后抽查几个关键地址是否为 0。data 拷贝完成后比较几个关键变量的地址是否符合预期。如果自检失败可以挂在 error trap 里方便调试器定位。这不会增加多少代码量但能大幅减少排查时间。11.3 统一管理启动文件与工具链版本Keil 工程升级编译器时要确认启动文件与汇编器语法兼容。切换 Arm Compiler 5 和 Arm Compiler 6 时启动文件可能要做调整不要只替换编译器。使用 GCC 时也要确认.ld文件与启动文件的符号命名一致避免_etext、_sdata这类符号在链接时找不到。11.4 保留最小可启动配置开发过程中建议先写一个最小工程只保留启动文件。链接脚本。一个直接给全局变量赋初值的 main。然后逐步添加业务模块。每次加模块后发现启动异常先检查 map 文件里新模块的段分布再判断是否是启动代码或链接脚本的问题。这套方法在定位“高地址访问异常”“栈溢出”“bss 变量被莫名修改”时非常有效。11.5 合规提醒启动代码里通常会引用芯片厂商 SDK 的库文件、启动文件。这部分代码的许可证需要按厂商规定使用尤其是商业项目。如果自己编写启动文件或链接脚本建议标注许可证。使用 Rust、C 等语言编写裸机程序时也要确认语言运行时初始化与启动代码的衔接不要漏掉__libc_init_array或对应的静态构造器执行步骤。12. 总结与下一步这第三弹把 ARM 启动流程中最容易混淆的三块内容讲透了data/bss 段初始化解决“全局变量在 main 之前为什么是真的”。XIP 解决“代码为什么不搬进 RAM 也能跑”。位置无关码解决“代码被搬到新地址后为什么还会炸”。三者底层都依赖链接脚本里的 LMA/VMA 关系只是应用场景不同。裸机工程重点在段初始化uboot 和动态加载场景重点在位置无关码MCU 高性能场景重点在 XIP 和 RAM 优化。掌握了这三块启动流程基本就没有死角了。下一步建议找一个你真在用的工程把启动文件和链接脚本完整读一遍再通过反汇编、map 文件核实一遍段分布。如果能跑到调试器里验证 data/bss 初始化结果这系列知识基本就内化了。之后再回头读 uboot 的重定位流程或者 RT-Thread 的启动初始化流程会发现自己能跟上的细节多了不少。
返回列表