
做嵌入式开发几乎人人都要过这么一关第一次打开启动文件startup_xxx.s看到里面有几十行汇编——设置栈指针、调用 SystemInit、把一段数据从 Flash 复制到 RAM、再把一片区域清零。注释写得很简单Copy data、Clear bss。看起来顺理成章但真到自己改链接脚本、换芯片型号、或者排查“全局变量初值为什么不对”的时候很多人会卡住data 段为什么要拷贝bss 段为什么不直接放进 Flash代码既然能在 Flash 里跑为什么启动代码还要折腾这些如果再碰上 XIP、位置无关码、加载地址、运行地址这些概念很容易越查越乱。这篇文章把这三个概念放在同一条线索里讲透。核心判断只有一句话启动代码做的事本质上是让程序的内存布局从“链接脚本里的图纸”变成“芯片里的现实”。data/bss 初始化、XIP、位置无关码都是从这一件事里长出来的不同分支。读完你会得到三个明确收益能看懂启动汇编里每一句 LDR/STR 在搬什么能写出属于自己的 data/bss 初始化代码能用反汇编验证程序到底是不是位置无关的。1. 这篇文章真正要解决的问题很多嵌入式教程讲启动流程习惯按“步骤”来罗列第一步关看门狗第二步设时钟第三步初始化栈第四步拷贝 data第五步清 bss。这种讲法能让人背下来却不能让人理解。真正的问题是为什么要做这些不做会怎样举个例子。你在 C 文件里写了一个全局变量int g_count 100;这句话在 PC 上运行时毫无存在感程序开始执行时 g_count 就已经是 100。但在裸机 ARM 工程里这个“理所当然”需要启动代码用几十条汇编指令去成全。因为 g_count 的初始值 100 被编译器放进了 Flash而 g_count 这个变量本身被分配在 RAM。RAM 上电后内容是随机的CPU 不会自动把“Flash 里的 100”填到 RAM 里 g_count 的位置。如果你不拷贝g_count 就是一块随机内存代码里读出来可能是个垃圾值。这就是 data 段初始化存在的意义。而 XIP 和位置无关码则是在回答另外两个问题既然代码不从 Flash 拷到 RAM那它怎么直接在 Flash 里跑搬运代码在搬运自身的时候它怎么保证自己还能正确执行理解了这三个问题的关系你再看启动文件里的一堆汇编就不再是背指令而是看一场精心安排的“搬家过程”。本文将按照“段是什么 → 链接脚本如何分配 → 启动代码如何执行搬运 → XIP 如何改变策略 → 位置无关码如何保证前期代码可信”的顺序展开最后用反汇编帮助你验证理解。2. 三个“段”的本质编译产物里的三类家当2.1 .text 段只读的程序本体.text 段里放的是编译后的机器指令也就是程序本体。它的特点是只读、执行、体积通常最大。因为只读.text 段天然适合放在 Flash 或 ROM 这类非易失性存储里。只要 CPU 能直接从 Flash 取指.text 段就不需要搬进 RAM。2.2 .data 段带初始值的“活数据”.data 段放的是已经初始化且初值不为 0 的全局变量和静态变量。比如int g_count 100; static char g_name[] csdn;这类变量必须待在 RAM 里因为程序运行时随时可能修改它。但它的初始值又来自 Flash。这就产生了一个矛盾变量在 RAM初始值在 Flash中间缺一条搬运路径。2.3 .bss 段只需要“清零”的变量.bss 段放的是未初始化、或显式初始化为 0 的全局变量和静态变量int g_buffer[1024]; static int g_flag 0;既然初值是 0Flash 里就不需要为它存任何东西。启动代码只需要在 RAM 里找到这一段把它整体清零。.bss 的名字来源于历史术语“Block Started by Symbol”现在你只需要记住.bss 是“最爱省 Flash 空间的段”因为它不占镜像体积只占 RAM 体积。2.4 容易被忽略的 .rodata 段还有一个和 .text 关系密切的段叫 .rodata存的是 const 修饰的只读数据和字符串字面量const int kMaxSize 1024; const char *kMsg hello arm;. rodata 和 .text 一样只读很多链接脚本干脆把它和 .text 放在同一个 Flash 区域。理解这一点你就不会奇怪为什么“常量数组”不需要初始化——它从一开始就在 Flash 里躺着。为了帮助你记忆把三类段的特性对比如下段名内容存放位置启动时动作.text机器指令、只读常量Flash或RAM若是XIP则不需要搬运.data带非零初值的变量Flash作为源RAM作为目标从Flash拷贝到RAM.bss零初值/未初始化变量只在RAM按大小清零.rodataconst常量、字符串Flash无需搬运3. 链接脚本启动代码的“施工图纸”3.1 什么是链接脚本链接脚本Linker Script告诉链接器哪个段放在哪个地址哪个段从哪个地址开始。你的程序会长成什么样在链接脚本里就已经“定稿”。链接脚本用 GNU LD 语法写成后缀通常是 .ld 或 .lds。在大部分 MCU 工程里它决定三个关键问题Flash 的起始地址和大小RAM 的起始地址和大小.text、.data、.bss 分别被放在哪3.2 加载地址LMA与运行地址VMA链接脚本里最容易让人困惑的一对概念是 LMA 和 VMA。LMALoad Memory Address数据初始值在 Flash 里的存储位置。可以理解成“货物上车前的存放仓库”。VMAVirtual Memory Address段在运行时应该出现在 RAM 里的位置。可以理解成“货物送到家之后的位置”。对 .text 段来说LMA 和 VMA 通常是同一个地址——在 Flash 里直接在 Flash 里跑。对 .data 段来说LMA 在 FlashVMA 在 RAM两者不一致。正是这个“不一致”让启动代码必须做一次搬运。再换个比方链接脚本是装修图纸.data 段是“需要从仓库运到客厅的家具”。家具的仓库位置是 LMA客厅摆放位置是 VMA。启动代码就是那个搬运工。3.3 一个最小链接脚本示例下面是一个通用的 ARM Cortex-M 风格链接脚本示意地址均为示意实际工程请按芯片存储映射修改MEMORY { FLASH (rx) : ORIGIN 0x00000000, LENGTH 512K RAM (rwx): ORIGIN 0x20000000, LENGTH 128K } SECTIONS { .text : { KEEP(*(.isr_vector)) *(.text*) *(.rodata*) __data_load LOADADDR(.data); } FLASH .data : { __data_start .; *(.data*) __data_end .; } RAM AT FLASH .bss : { __bss_start .; *(.bss*) *(COMMON) __bss_end .; } RAM }这段脚本的关键逻辑.text被强制放在 FLASH 起始位置中断向量表通过KEEP(*(.isr_vector))保证不被链接器当作无用段丢掉。.data前面的 RAM AT FLASH表示 VMA 在 RAMLMA 在 Flash。__data_load取自LOADADDR(.data)它告诉启动代码“数据源在 Flash 的哪个地址”。__data_start、__data_end、__bss_start、__bss_end这四个符号启动代码会直接引用它们。如果你写错了脚本或者忘了AT FLASH链接器可能把 .data 的初始值直接放在 RAM 地址。上电后 RAM 是随机内容你的“100”就根本不在 Flash 里启动自然失败。4. 启动时的 data 初始化与 bss 清零4.1 为什么要拷贝而不是直接访问有人会问既然 .data 在 Flash 里有初始值那我直接读 Flash 不行吗不行。因为 .data 是变量程序运行时会改写它。如果不把它搬到 RAM每次改写都会去擦写 Flash这是一种“不可能的慢”而且 Flash 有写入次数限制。所以嵌入式世界的固定操作是上电后把 .data 从 Flash 搬到 RAM把 .bss 清零让内存处于 C 语言期望的状态。4.2 启动汇编从 Flash 搬到 RAM下面是一段常见的启动汇编示意逻辑上等价于很多 MCU 的 startup 文件; 复制 .data 段从 Flash(__data_load) 拷贝到 RAM(__data_start) LDR R0, __data_load LDR R1, __data_start LDR R2, __data_end copy_loop: CMP R1, R2 BGE clear_bss LDR R3, [R0], #4 STR R3, [R1], #4 B copy_loop ; 清零 .bss 段 clear_bss: LDR R0, __bss_start LDR R1, __bss_end MOV R2, #0 zero_loop: CMP R0, R1 BGE done STR R2, [R0], #4 B zero_loop done: ; 此时内存布局已就绪可以调 main BL main这段代码的逻辑LDR R0, __data_load加载的是链接脚本里导出的符号值它表示数据的源地址。LDR R3, [R0], #4是“先读取地址 R0 处的数据然后 R0 自增 4”等价于 C 语言的指针后移。STR R3, [R1], #4把读到的数据写到目标地址R1 同样自增。bss 清零用的是同一思路唯一的区别是源地址不存在只写 0。这段代码必须放在调用任何“普通 C 函数”之前。因为 C 函数内部可能有自己的全局变量、静态变量如果 .bss 还没清零、.data 还没搬运这些变量就是脏数据程序的行为完全不可预知。4.3 为什么顺序有讲究启动过程的顺序不是随意的关闭中断避免在处理过程中被打断。设置栈指针因为后续调用 C 函数需要栈。初始化时钟、外设可选取决于硬件设计。搬运 .data、清零 .bss。调用 main 或进入系统初始化代码。如果先调 main 再搬运 data那么 main 里访问的任何全局变量都是垃圾值。如果先搬运 data 再设置栈一旦拷贝循环里发生异常连出错现场都无处保存。很多初学者在这个阶段会犯一个非常隐蔽的错误把“搬运 data”的代码放进 C 函数里却忽略了这个 C 函数本身依赖 .data 段。这本质上是一个“鸡生蛋”问题解决办法是让这层代码用汇编写或者确保编译器不插入任何依赖 .data/.bss 的运行时初始化。5. XIP在 Flash 里直接跑代码5.1 XIP 解决什么问题XIPExecute In Place原地执行是一种策略CPU 直接从非易失性存储NOR Flash、ROM中取指执行不把代码镜像搬到 RAM。它解决的核心问题是 RAM 容量焦虑。RAM 通常比 Flash 贵、比 Flash 少。一颗 MCU 可能带 512KB Flash 但只有 64KB RAM如果所有代码都搬进 RAM程序根本放不下。XIP 模式下.text 段放在 FlashCPU 直接从 Flash 取指RAM 只需要分配给 .data、.bss 和堆栈。5.2 什么能 XIP什么不能不是所有非易失性存储都能 XIP。关键取决于存储介质是否支持“随机读取”。NOR Flash 支持 XIP可以按字节随机读取等同于 ROM 接口。NAND Flash 不支持 XIP它按页/块访问CPU 无法直接取指执行。EEPROM 通常也不用于 XIP速度太慢且地址空间接入方式特殊。所以 MCU 内部的 Flash 几乎都是 NOR 架构支持 XIP。这也是为什么大多数单片机固件能“直接在 Flash 里跑”。而带外部 SDRAM 的高性能嵌入式系统经常把代码先搬到 SDRAM 再执行是为了速度把代码放在 Flash 里直接执行是为了省内存开销是执行速度可能变慢。5.3 XIP 模式下还需要做 data/bss 初始化吗需要。XIP 只解决了“.text 段可以不搬运”但没有解决“.data 需要在 RAM 里可写”的问题。在 XIP 模式下启动阶段的 .data 拷贝流程不变.bss 清零也不变唯一的区别是.text 段不需要被复制因为 CPU 已经在 Flash 里取指了。这一点最容易被误解。很多人以为“既然代码在 Flash 里跑那全局变量也应该在 Flash 里”。错了变量必须写在 RAM 里才能被赋值和修改。从另一个角度理解XIP 不是“什么都不用搬”而是“程序本体的那部分不用搬带初始值的变量照旧搬”。启动代码的职责没有减少只是把搬家的规模缩小了。6. 位置无关码启动代码的“隐身术”6.1 绝对地址引用为什么会在启动早期崩溃启动代码有个特殊问题它自己可能不在“最终运行地址”上执行。拿 U-Boot 这类程序举例。它的链接地址可能指向 RAM 高端地址但芯片上电复位后CPU 从 Flash 或 ROM 的起始地址取第一条指令。此时 Flash 里的程序是“按照 RAM 地址编译好的”但实际还在 Flash 里跑。如果代码里有一条指令访问绝对地址LDR R1, some_variable链接器会把这个引用翻译成链接地址指定的地址。但那个地址对应的 RAM 内容现在是空的读出来的就是垃圾值。如果你此时还试图往里写更可能直接触发硬件异常。这就是位置无关码Position Independent CodePIC存在的原因代码在哪个地址运行它就用哪个地址访问数据而不是用编译时写死的地址。6.2 位置无关码的机制编译成位置无关码后代码不再直接引用“绝对地址”而是通过“当前 PC 固定偏移”来访问数据。看一个最简单的 ARM 汇编对比; 非位置无关加载变量的绝对地址 LDR R1, g_flag STR R0, [R1] ; 位置无关基于当前PC计算变量地址 ADR R1, g_flag STR R0, [R1]LDR R1, g_flag是伪指令链接器会为 g_flag 准备一个绝对地址放在文字池literal pool里。程序不管在哪个地址运行都会去访问那个写死的 RAM 地址。ADR R1, g_flag是相对指令它会被汇编成类似“当前 PC 值 一个常量偏移”的形式。程序被加载到 A 地址它就根据 A 地址算被加载到 B 地址它就根据 B 地址算。这样就实现了“跑到哪里就认哪里”。不过需要说明一点在裸机工程里标准 C 编译器默认不会生成 PIC 代码除非启用特定编译选项。因此启动阶段通常不是靠编译器来解决而是靠“人为设计”来规避问题启动代码尽量只用相对跳转指令BL/B 天然是相对的。需要访问数据时尽量用与 PC 无关的寄存器间接寻址。把那些无法做成位置无关的复杂操作推迟到代码已经拷贝到最终地址之后再执行。这也是为什么早期启动代码里很少出现大段 C 代码因为 C 编译器生成的数据引用往往是绝对地址。6.3 位置无关码与重定位的关系位置无关码和“重定位”relocation是一对容易混淆的概念。位置无关码的思路是“从一开始就别用绝对地址”代码放到哪都能跑不需要改任何地址。重定位的思路是“先按绝对地址链接启动后自己把自己搬到那个地址再跳过去”搬运之后所有引用天然正确。两类方案在现实中的典型应用U-Boot 前级启动代码Flash 空间紧张采用位置无关码思想保证复位后在 Flash 开头执行的代码不依赖 RAM 地址。带 MMU 的 Linux 内核使用重定位 MMU 映射把虚拟地址映射到物理地址比位置无关码更灵活。MCU 上的 Bootloader很多直接把程序从 Flash 搬到 RAM再跳转执行用重定位思路避开 PIC 的复杂性。针对你的工程如果 Flash 够大、RAM 够用最简单的策略不是“做 PIC”而是“先搬到 RAM 再跳转”。位置无关码真正必须的场景是那些“搬不动只能原地跑”的阶段。7. 三种加载策略对比为了看清楚整体我们把“段初始化 XIP 位置无关码”放到一张表里对比。维度RAM 加载模式XIP 模式位置无关码模式.text 是否拷贝拷贝到 RAM不拷贝直接 Flash 执行不依赖加载位置可 Flash 可 RAM.data 是否拷贝必须拷贝必须拷贝必须拷贝.bss 是否清零必须清零必须清零必须清零启动早期代码约束较低较低必须避免绝对地址引用RAM 占用大代码数据都在 RAM小数据才占 RAM由运行位置决定执行速度快RAM 取指受 Flash 等待周期影响受实际存储介质影响典型场景Bootloader、MCU 小型固件MCU 内部 Flash 运行U-Boot 早期阶段、部分 ROM 代码这张表的要点是无论哪种模式 .data 搬运和 .bss 清零都跑不掉。变化只集中在 .text 段和“启动早期代码如何访问自己的数据”。8. 用反汇编验证你的启动代码8.1 查看编译产物编译完成后先用 readelf 或 nm 查看符号的地址分配确认链接脚本是否生效。$ arm-none-eabi-nm build/demo.elf | grep -E __data_start|__data_end|__bss_start|__bss_end|__data_load如果输出的地址没有按预期落在 RAM 或 Flash 区间说明链接脚本的段放置有问题。8.2 反汇编启动代码反汇编是验证启动逻辑最直观的手段$ arm-none-eabi-objdump -d -S build/demo.elf在反汇编里找到 Reset_Handler 或你的启动标签逐行核对LDR/STR 的源地址是否指向 Flash 区域。目标地址是否落在 RAM 区域。循环条件是否在正确的位置退出。8.3 判断代码是否位置无关判断一段代码是不是位置无关最直接的方法是看它访问全局数据时使用的是“literal pool 中保存的绝对地址”还是“基于 PC 的偏移”。反汇编中如果出现大量这种模式8000: ldr r1, [pc, #96] ; 从文字池读取一个绝对地址 8004: str r0, [r1]说明代码依赖绝对地址不是位置无关的。如果看到的是类似 ADR、ADD R1, PC, #offset 的指令说明编译器或汇编器在生成相对访问代码具有位置无关特征。一个更可靠的验证手段是把同一条代码链接到两个不同地址分别反汇编比较数据访问段。如果指令序列基本一致只是 PC 相对偏移变化说明位置无关如果出现大量不同的绝对地址常量说明是普通代码。这个验证方法在排查“代码搬到另一个地址后崩溃”时非常实用。9. 常见问题与排查思路问题现象可能原因排查方式解决方案全局变量初值是随机值.bss 未清零或清零的范围不对用 nm/readelf 查 __bss_start/__bss_end修正启动代码中的清零范围带初值的全局变量读出来不对.data 段没有从 Flash 拷贝或源地址错误查 __data_load 实际指向的 Flash 地址检查链接脚本 AT FLASH 是否生效代码在 Flash 运行很慢XIP 下 Flash 等待周期或缓存未配置查看数据手册中 Flash 配置寄存器配置等待周期必要时启用 Cache加了一个新函数后启动崩溃启动阶段新增了绝对地址数据引用反汇编查看新增代码的地址访问方式延迟该操作到重定位后或改为 PC 相对寻址某个 const 数组读出来是 0xFF.rodata 被放进了 RAM 或链接脚本忽略该段查 map 文件确认 .rodata 归属在链接脚本中显式放置 .rodata 到 Flash从 Bootloader 跳转到 App 失败App 的链接地址和实际烧录地址不一致比较 App 的 VMA 和 Bootloader 跳转地址用链接脚本重新配置 App 起始地址排查这类问题有一个很实用的顺序先看 .map 文件确认“符号被链接到哪”再反汇编确认“启动代码怎么访问它”最后用调试器在拷贝循环前打断点看源地址和目标地址各自的内容。能定位到是“链接阶段错”还是“执行阶段错”问题就解决了一半。10. 最佳实践与工程建议10.1 把链接脚本当成“核心资产”管理链接脚本决定整个镜像的布局一旦写错症状千奇百怪。建议把它纳入版本管理并在注释里写明这份脚本面向的芯片型号、Flash/RAM 大小、段放置策略。多人协作时修改链接脚本必须经过评审不要随手改动。10.2 生成并保留 .map 文件在编译命令中加入-Wl,-Mapoutput.map或工程配置里的“Generate map file”选项。排查符号地址问题时.map 文件比任何猜测都可靠。建议发布版本时把 .map 一起归档方便回溯。10.3 不要把复杂 C 逻辑放进启动早期如果你发现启动代码里需要判断字符串、检查哈希、做复杂计算请先问一句这些代码在“搬 mové 之前”跑还是在“搬之后”跑启动早期运行得越久位置无关的约束就越容易出问题。更稳妥的做法是启动早期只做必要的最小初始化复杂逻辑放到 .data/.bss 就绪之后再执行。10.4 使用调试器验证内存布局主流 IDE 都支持启动时停在第一条指令。强烈建议在 Reset_Handler 入口设置断点逐个查看关键符号地址栈顶地址是否正确。.data 源地址内容是否等于初值。.bss 目标区域是否为随机值搬运前。搬运后全局变量是否变成预期值。这一套验证流程跑通之后你才算真正掌握了启动过程而不是“反正能跑就行”。10.5 警惕优化器对启动代码的影响个别编译器在高优化等级下可能删除看起来“无用”的写入操作。启动代码中的 bss 清零尤其容易被优化掉因为它“没有任何读取者”。稳妥的做法是启动阶段使用较低优化等级或把关键清零函数标注为不被优化。企业项目的发布构建通常会对 startup 相关文件单独设置优化等级。11. 总结一次把内存布局这件事想清楚这一篇真正讲清楚的事情是启动流程背后的“内存布局观”。data/bss 初始化不是一段可有可无的仪式性汇编而是程序能不能在 C 语言的世界里正确运行的前提XIP 不是玄学而是一种“省 RAM、牺牲一定执行速度”的存储策略位置无关码也不难它只是启动早期那段不知道自己会出现在哪里的代码被迫具备的“随遇而安”能力。回顾整个过程链接脚本画了一张图纸启动代码按图施工XIP 决定哪里不用搬位置无关码保证“还没到新家之前”代码也能正常生活。三者相互咬合共同构成了嵌入式系统启动的基础框架。下次再遇到程序上电后行为异常希望你先去看 .map 文件再打开反汇编沿着 Reset_Handler 走一遍而不是盲目怀疑编译器或硬件。把启动阶段的内存布局彻底搞懂之后无论是移植 RT-Thread、分析 U-Boot、还是调试 App 无法启动的问题你都会比大多数人更快地定位到根因。