十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

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

ARM启动流程详解:从data段初始化到XIP与位置无关码 之前写过 ARM 启动流程大家基本都把“复位后从哪里进入 main”这条链路搞清楚了。但很多同学真正开始做裸机程序、看 U-Boot 启动代码、或者自己写链接脚本时还是会卡在三个词上data 段初始化、XIP、位置无关码。这篇文章就专门把这几个概念串起来讲透用一份最小 Cortex-M 裸机工程带你从链接脚本 - 启动汇编 - 段搬运 - XIP 原地执行 - 位置无关码全部走一遍。不管你用的是 STM32、GD32、NXP 还是其它 Cortex-M 芯片理解这套流程后再回头看 Keil 的分散加载、ARM GCC 的启动文件、U-Boot 的start.S都会轻松很多。文章内容有点长建议先收藏再跟着动手实验。1. 先看懂 ARM 从复位到 main 的启动链路1.1 Cortex-M 的硬件自动初始化在讲data/bss/text段之前需要先把“复位后发生了什么”这件事理清楚。以 Cortex-M3/M4 为例芯片复位后内核并不是去执行什么“bootloader”而是按照 ARM 设计好的固定行为启动从向量表起始地址读取栈指针初始值加载到SP。从向量表偏移0x04处读取复位向量加载到PC。从Reset_Handler开始取指执行。也就是说硬件只帮你做了两件事设定栈指针、跳转到复位入口。剩下的事全部交给软件。此后执行顺序通常是Reset_Handler - 系统时钟初始化SystemInit可选但通常需要 - C 运行环境初始化data/bss 段处理、堆栈建立、C 库初始化 - main()这也是很多初学者疑惑的地方为什么我不写main之前的代码程序也能跑因为你使用的集成开发环境或框架已经在启动文件里帮你完成了这些动作。比如 Keil MDK 的startup_stm32f10x_hd.s中Reset_Handler最终会调用__main由 ARM C 库完成分散加载而main只是 C 库最后准备好之后才跳转进去的用户入口。1.2 为什么有人能看到全局变量“怪值”有个非常典型的现象int flag 0x55; int count; // 期望为 0下载程序后在调试器里看到flag的值是随机的count也不是 0而是某个残留值。这个问题十有八九不是代码逻辑的问题而是启动时没有正确初始化 data 段和 bss 段。flag属于.data段编译时初值 0x55 被存放在 Flash 里程序启动后需要从 Flash 拷贝到 RAM 中。count属于.bss段启动时必须由软件清零否则它可能残留上一次运行的数据或者上电后的随机值。如果启动过程没有执行“拷贝”和“清零”全局变量的初始值就是错的程序后续行为不可预测。1.3 ARMCC 与 GCC 两条启动路径的差异同样一件事不同工具链的自动化程度不同工具链启动路径谁负责 data/bss 初始化Keil MDK ARMCC/armclang启动文件调用__mainARM C 库的__scatterload自动完成GCC arm-none-eabi启动文件需要自己写copy_data/zero_bss或调用平台库常见裸机方案是自己写或借用__libc_init_arrayIAR启动文件调用__iar_program_startIAR 运行时库自动完成理解这一点非常重要。你用 Keil 新建工程时几乎不用关心段初始化但一旦切到 GCC 裸机工程或者阅读 U-Boot、RT-Thread 的启动代码就必须自己处理。2. text/data/bss/rodata 段的本质与存储规划2.1 四种段的角色一个典型的 ARM 程序由若干段组成最常见的是四个段名内容属性正常运行地址.text代码指令、内联函数只读、可执行可放 Flash可直接 XIP.rodataconst 常量、字符串只读可放 Flash.data已初始化全局变量、静态变量可读可写RAM初值镜像在 Flash.bss未初始化或零初始化变量可读可写RAM运行前清零即可.text和.rodata是“只读”的因此可以放在掉电不丢的 Flash 里不需要在启动阶段搬移。.data里的变量有非零初值这个初值只能先存放在 Flash 中程序启动后再拷贝到 RAM。任务重、依赖多。.bss不需要在 Flash 保存初值因为初值全是 0所以 Flash 里完全不用给它留空间启动时在 RAM 里开辟一段区域并清零即可。2.2 LMA 和 VMA为什么 data 段要搬两次段有两个地址概念很多人容易混淆LMALoad Memory Address这个段在 Flash/镜像文件里存放的地址。VMAVirtual Memory Address这个段运行时实际所在的地址。对于.textLMA 和 VMA 通常相同都在 Flash 中所以代码可以直接从 Flash 取指执行。对于.dataLMA 在 Flash 中VMA 在 RAM 中。启动阶段要做的“data 段初始化”本质就是一段memcpy把数据从 LMA 复制到 VMA。对于.bssLMA 根本没有地址因为它无需在镜像中占用空间只需要在 RAM 里有一块区域启动时清零即可。连线脚本里经常看到这样的写法.data : { _sdata .; *(.data*) _edata .; } RAM AT FLASH其中 RAM指定 VMAAT FLASH指定 LMA。这就是data段“既要待在 Flash又要在 RAM 运行”的直观表达。2.3 链接脚本决定段的去留链接脚本是段初始化的“总设计图”。芯片有多少 Flash、多少 RAM、栈顶在哪、data 段从哪个 Flash 地址复制到哪个 RAM 地址、bss 段从哪里开始清零全由链接脚本决定。下面我们会用一份具体链接脚本做实战这里先不展开。3. 用完整示例手工初始化 data 和 bss 段3.1 项目结构与源码为了把原理讲清楚我这里不使用 Keil 的自动分散加载而是用GCC 裸机方式手工完成段初始化。项目文件很少bare-arm-start/ ├── startup.s # 启动汇编 ├── main.c # 用户代码包含测试变量 ├── stm32f103.ld # 链接脚本这里的芯片地址我按常见 Cortex-M3 例子来写Flash 起始0x08000000RAM 起始0x20000000。如果你用的是其它芯片替换成你自己的地址即可重点是看流程。3.2 链接脚本精讲新建stm32f103.ld文件ENTRY(Reset_Handler) MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K RAM (rwx) : ORIGIN 0x20000000, LENGTH 128K } _estack ORIGIN(RAM) LENGTH(RAM); SECTIONS { .isr_vector : { . ALIGN(4); KEEP(*(.isr_vector)) . ALIGN(4); } FLASH .text : { . ALIGN(4); *(.text*) *(.rodata*) . ALIGN(4); _etext .; } FLASH .data : { . ALIGN(4); _sdata .; *(.data*) . ALIGN(4); _edata .; } RAM AT FLASH _sidata LOADADDR(.data); .bss : { . ALIGN(4); _sbss .; *(.bss*) *(COMMON) . ALIGN(4); _ebss .; } RAM }几个关键点_estack ORIGIN(RAM) LENGTH(RAM);把栈顶定义在 RAM 末尾。Cortex-M 的栈是向下生长的所以初始 SP 指向 RAM 的最高地址。.isr_vector向量表必须放在 Flash 起始地址并用KEEP防止被垃圾回收。_etext .;标记.text段结束位置。要注意这个符号是.data初值镜像在 Flash 中的起始地址的一个常用别称但更严格的写法是下面单独用LOADADDR。链接脚本末尾有一个关键定义_sidata LOADADDR(.data);LOADADDR(.data)表示的是.data段在 Flash 中的加载地址。启动汇编里复制 data 段时源地址用的就是_sidata。3.3 启动汇编精讲新建startup.s.syntax unified .cpu cortex-m4 .thumb .section .isr_vector, a .word _estack .word Reset_Handler .word NMI_Handler .word HardFault_Handler .section .text .thumb_func .global NMI_Handler NMI_Handler: b . .thumb_func .global HardFault_Handler HardFault_Handler: b . .thumb_func .global Reset_Handler Reset_Handler: /* 复制 .data把 Flash 中的初值镜像搬到 RAM 运行地址 */ ldr r0, _sdata ldr r1, _edata ldr r2, _sidata copy_data: cmp r0, r1 bhs zero_bss ldr r3, [r2], #4 str r3, [r0], #4 b copy_data zero_bss: ldr r0, _sbss ldr r1, _ebss movs r2, #0 bss_loop: cmp r0, r1 bhs call_main str r2, [r0], #4 b bss_loop call_main: bl SystemInit bl main b . .ltorg这里解释一下ldr r0, _sdata、ldr r1, _edata、ldr r2, _sidata是汇编伪指令。_sdata、_edata、_sidata都是链接脚本定义出来的符号链接器会把它们的值填入指令对应的字面量池中。copy_data循环r0指向 RAM 中的 data 段起点r1指向 RAM 中的 data 段终点r2指向 Flash 中的 data 初值镜像起点。每次用后置递增[r2], #4、[r0], #4拷贝一个字4 字节。比较r0和r1时使用bhs无符号大于等于因为 RAM 地址本身是无符号数使用无符号比较逻辑更严谨。zero_bss循环从_sbss到_ebss逐个字清零。全部完成后再调用SystemInit最后进入main。.ltorg用于立即把当前文字池literal pool放在这个位置确保ldr r0, _sdata这类伪指令使用的常量在可用范围内。需要特别说明的是这份启动代码本身用的是“绝对地址”因为它要配合链接脚本固定地址工作。它和后面讲的位置无关码并不冲突因为这份启动代码的链接地址和运行地址是同一个 Flash 地址。3.4 编译、链接与验证新建main.cint init_var 0x55; int zero_var; const char msg[] ARM startup; void SystemInit(void) { /* 外设时钟、Flash 预取等初始化本文略 */ } int main(void) { volatile unsigned int i; volatile unsigned int check (unsigned int)init_var; (void)check; for (i 0; i 100U; i) { /* 简单空转验证可运行 */ } while (1) { } }编译链接命令arm-none-eabi-as -mcpucortex-m4 -mthumb startup.s -o startup.o arm-none-eabi-gcc -mcpucortex-m4 -mthumb -O2 -ffreestanding -c main.c -o main.o arm-none-eabi-ld -T stm32f103.ld startup.o main.o -o app.elf arm-none-eabi-objdump -h app.elfobjdump -h的输出中重点看.data的 VMA 和 LMA。正常情况下你会看到Idx Name Size VMA LMA File off Algn 0 .isr_vector 00000010 08000000 08000000 00010000 2**2 1 .text 000000cc 08000010 08000010 00010010 2**2 2 .data 0000000c 20000000 080000dc 000100dc 2**2 3 .bss 00000004 2000000c 2000000c 000100e8 2**2.data的 VMA 是0x20000000RAM 地址LMA 是0x080000dcFlash 地址。这说明它的初值确实作为一个镜像存在 Flash 中而运行时要被搬到 RAM 里。还可以用 nm 查看符号值arm-none-eabi-nm -n app.elf | grep -E (sdata|edata|sidata|sbss|ebss)预期能查到类似20000000 _sdata 2000000c _edata 080000dc _sidata 2000000c _sbss 20000010 _ebss到这里data 段拷贝和 bss 段清零的完整链路就跑通了。4. XIP代码为什么能在 Flash 里原地运行4.1 XIP 是什么XIP 全称 Execute In Place意思是“就地执行”。对于 MCU 来说最常见的解释是代码指令直接在 Flash 里被 CPU 读取执行不需要先拷到 RAM 里再跑。Flash 虽然属于非易失存储器但只要总线接口支持CPU 完全可以像读 RAM 一样去 Flash 里取指令。于是.text段的 VMA 和 LMA 可以保持一致链接脚本也就不需要为代码段做重定位。很多入门教材说“程序从 Flash 加载到 RAM 才运行”这句话在传统 PC 场景下是对的但在现代 MCU 上并不完全正确。Cortex-M 系列普遍采用 XIP 策略默认代码就在 Flash 里执行。4.2 XIP 和段初始化如何协作XIP 解决的是“代码在哪运行”的问题段初始化解决的是“数据放在哪、怎么搬运”的问题。由于 XIP.text和.rodata可以留在 Flash由于 RAM 掉电会丢而全局变量必须能被修改所以.data和.bss必须放到 RAM。于是最终存储规划就变成了Flash向量表、.text代码、.rodata常量、.data初值镜像。RAM.data运行副本、.bss零初始化区、堆、栈。启动汇编做的事情就是把 Flash 里的.data初值镜像搬到 RAM再把 RAM 中的.bss区域清零。之后代码继续在 Flash 里 XIP 执行CPU 访问全局变量时则会去 RAM。4.3 XIP 的性能与适用边界XIP 最大的优点是省 RAM加载速度快掉电不丢代码。但也有局限性Flash 随机读取速度通常比 RAM 慢尤其是在高主频下。如果代码所在的 Flash 总线带宽有限CPU 取指可能遇到瓶颈。部分 Flash 不支持字节或半字随机访问或者读访问有较大延迟。为了缓解这个问题很多 MCU 在内部增加了 Flash 预取缓冲、指令 Cache 或 ART 加速器。比如 STM32F1 系列的 Flash 预取机制、STM32F7 的 I-Cache/D-Cache都能明显改善 XIP 性能。这也带来一个工程角度的问题如果你的代码对性能要求极高可以考虑把热点函数或整个程序拷到 RAM 中执行常见做法是在链接脚本里给某个函数单独分配一个 RAM 执行段。但大部分业务代码XIP 就足够了没有必要为了“看着高级”把所有代码搬到 RAM。5. 位置无关码让代码“地址变了也能跑”5.1 位置无关码到底解决什么问题位置无关码的英文是 Position Independent Code常缩写为 PIC中文里也翻译成位置无关代码、地址无关代码。一个程序在编译链接时链接器会为每个符号、每个函数分配一个绝对地址。如果这个地址就是运行时地址程序自然能正常工作。但有些场景下代码运行时所在的地址和链接时的地址不一致这时如果还使用绝对地址访问就会访问到错误位置。典型的场景包括Bootloader 被引导 ROM 读到 SRAM 的任意地址后执行但链接脚本按固定地址链接。某个补丁、驱动、模块被动态加载到不同内存地址。代码要从加载地址整体搬到另一个地址后继续运行搬运后相对位置不变但绝对地址全变了。位置无关码的价值就在这里它让代码不依赖“链接时写死的绝对地址”而是通过相对 PC 的偏移来访问指令和数据因此加载到哪个地址都能运行。5.2 ARM 指令层面看相对寻址ARM 处理器天然有一部分指令是位置无关的B、BL跳转指令跳转目标用相对当前 PC 的偏移表示与代码所在绝对地址无关。ADR伪指令汇编器会尝试把标签地址用“PC 偏移”的方式生成指令。LDR Rd, [PC, #offset]访问字面量池时从当前 PC 附近取数据这条指令本身是位置无关的但取出来的值可能是一个绝对地址。反过来下面这些写法通常不是位置无关的LDR Rd, symbol会把symbol的绝对地址放到字面量池中。MOVW Rd, #immMOVT Rd, #imm直接构造 32 位绝对地址。LDR PC, function把函数绝对地址加载到 PC。典型对比l1: adr r0, data1 位置无关r0 data1 的实际运行地址 ldr r1, [r0] ldr r2, data1 位置有关r2 链接时 data1 的绝对地址 ldr r3, [r2]当代码被搬到其它地址运行时adr计算出的r0仍然指向正确位置而ldr r2, data1拿到的地址还是旧地址访问很可能越界或读到错误数据。5.3 一个典型反例绝对地址的“死穴”假设你有这样一段代码它被设计成可以搬移到 SRAM 任意地址执行.section .text .global my_func my_func: ldr r0, pattern 注意这里链接器会把 pattern 的绝对地址写入字面量池 ldr r1, [r0] bx lr pattern: .word 0x11223344假如链接地址是0x08002000运行时这段代码被加载到了0x20000000那么ldr r0, pattern仍然会去访问0x08002000附近的地址但这个地址的内容已经不是原来的pattern了甚至可能是完全不可访问的内存。改成 PC 相对寻址就能解决.section .text .global my_func my_func: adr r0, pattern PC 相对寻址代码搬到哪都能找到 pattern ldr r1, [r0] bx lr pattern: .word 0x11223344当然了adr有范围限制编译器如果发现偏移超过指令能表达的范围会报错。对于大范围的符号访问需要更复杂的手段比如借助全局偏移表GOT或者运行时先计算出基址。这也是为什么很多启动代码在非常早期的阶段会先用一小段汇编计算“当前运行地址”和“链接地址”的差值再用这个差值修正后续的绝对地址访问。U-Boot 里的start.S就大量体现了这个思想。5.4 C 语言编译选项与 U-Boot 的工程实践如果你用 C 语言开发GCC 也提供了编译选项-fPIC生成位置无关代码常用于共享库。-fpie搭配-pie生成位置无关可执行文件常用于可执行程序。但要注意-fPIC并不是“用了就万事大吉”。它通常依赖 GOT、动态重定位表还需要额外的运行时支持。在裸机 MCU 上很多人还是选择在启动汇编里手工保证早期代码的位置无关性等重定位完成后再跳转到绝对地址的 C 代码继续执行。以 U-Boot 为例如果配置了位置无关相关宏start.S中大概会有这样的逻辑_start: adr r0, _start 得到当前运行时地址 ldr r1, _start 得到链接时地址 subs r2, r0, r1 计算偏移 ...计算出偏移值后relocate_code会利用这个偏移把整个 U-Boot 镜像搬到最终 RAM 地址并修正各种绝对引用最后再跳转到新地址运行。整个过程的核心就是“重定位”。理解位置无关码不只是为了刷面试题而是当你在看 bootloader、引导代码、RTOS 启动代码时能够真正看懂为什么要这样写。6. 常见启动异常排查清单6.1 高频异常速查表问题现象常见原因排查思路全局变量初始值是随机值.data段没有被拷贝检查启动文件是否执行copy_data检查链接脚本符号_sdata/_edata/_sidata是否一致全局变量不是 0.bss段没有被清零检查启动文件是否有zero_bss代码程序在启动阶段 HardFault栈指针不对或复制源地址指向非法 Flash检查_estack是否等于 RAM 末尾检查_sidata是否指向可读 Flash从 RAM 启动后全局变量错乱链接地址和运行地址不一致绝对地址失效使用位置无关码或在跳转前完成重定位使用memcpy复现malloc异常启动阶段过早调用 C 库函数避免在__main或main之前调用依赖堆栈/堆的库函数Keil 工程提示启动文件与编译器不兼容ARMCC5/ARMCC6 对启动文件符号要求不同优先使用对应工具链版本的官方启动文件6.2 定位段初始化问题的三个手段第一看链接脚本符号。启动文件的复制逻辑依赖于链接脚本中的_sdata、_edata、_sidata、_sbss、_ebss。如果这些名称拼写不一致启动代码很容易复制到错误区域。第二看 map 文件。链接完成后从 map 文件里能明确看到每个段的 VMA、LMA、大小。如果data段 LMA 和 VMA 相同说明链接脚本没有正确划分加载域和执行域启动程序自然搬了个寂寞。第三在调试器里检查。在Reset_Handler开始处、main入口处分别打断点查看 RAM 中全局变量对应的地址。如果进入main时初值还未就绪基本可以确定是启动代码没有执行或执行顺序不对。7. 工程最佳实践与进阶路线7.1 启动代码工程建议启动阶段尽量简单。启动代码不必追求“一行代码解决一切”它应该只做必要初始化。复杂逻辑放 C 代码里更稳妥。统一链接脚本符号命名。整个团队最好统一一套_sdata/_edata/_sbss/_ebss/_sidata规范命名避免每个人自创符号。保证段 4 字节对齐。数据复制通常按字操作如果段大小不是 4 的倍数复制可能会越界。链接脚本里用ALIGN(4)是一种好习惯。不要随意覆盖__main。如果你用 Keil ARMCC__main是 C 库提供的入口它负责分散加载。除非你很明确自己在做什么否则不要去重定义或修改它。确认栈大小。栈顶位置和栈大小决定了函数调用深度和中断嵌套能力。链接脚本给出栈顶后要结合 map 文件确认全局变量没有和栈区域重叠。不要在高风险场景下直接改 Flash 地址或 RAM 地址。如果你在调试非标环境记得先在测试板上验证并保留原始备份。7.2 从 MCU 到复杂 SoC 的进阶方向MCU 的启动流程相对简单因为 Cortex-M 的向量表机制已经做得很固定。但学习不能停留在点灯层面。如果你对 ARM 启动流程有兴趣下一步可以按这个方向继续深入熟练阅读 map 文件和反汇编能用objdump分析.data的 LMA 与 VMA。把 RT-Thread、Zephyr 或 FreeRTOS 的启动流程完整读一遍重点关注它们在哪里完成段初始化和系统堆初始化。研究 U-Boot 启动流程理解为什么 bootloader 要为真正的主程序准备 DDR、MMU、中断向量和重定位环境。进一步学习 Cortex-A 系列 SoC 的启动流程例如 i.MX6 的 IVT 启动流程、Uboot 的 SPL 阶段。那套流程比 MCU 复杂得多但底层思想仍然离不开“段初始化 重定位 位置无关码”。如果之前还没系统看过 map 文件建议现在就打开一个已有工程arm-none-eabi-objdump -h app.elf也好Keil 的 Map 视图也好先找到.data的 LMA/VMA再对照启动文件里的复制代码。看懂这一步再复杂的启动流程都可以一层层拆开。这篇文章讲完了希望下次再看到“启动流程”“XIP”“位置无关码”这些词时你能直接联想到“Flash 中的镜像要在 RAM 里安家而地址变了还不慌”的那段启动代码。动手把你手头最小的裸机工程从启动文件到链接脚本都过一遍比看十篇文章都管用。
返回列表