嵌入式启动代码与内存布局:data段、bss段、XIP及位置无关码深度解析
2026/9/8 3:52:36 网站建设 项目流程

做嵌入式开发,几乎人人都要过这么一关:第一次打开启动文件(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;

既然初值是 0,Flash 里就不需要为它存任何东西。启动代码只需要在 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。

  • LMA(Load Memory Address):数据初始值在 Flash 里的存储位置。可以理解成“货物上车前的存放仓库”。
  • VMA(Virtual Memory Address):段在运行时应该出现在 RAM 里的位置。可以理解成“货物送到家之后的位置”。

对 .text 段来说,LMA 和 VMA 通常是同一个地址——在 Flash 里,直接在 Flash 里跑。对 .data 段来说,LMA 在 Flash,VMA 在 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 在 RAM,LMA 在 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 为什么顺序有讲究

启动过程的顺序不是随意的:

  1. 关闭中断,避免在处理过程中被打断。
  2. 设置栈指针,因为后续调用 C 函数需要栈。
  3. 初始化时钟、外设(可选,取决于硬件设计)。
  4. 搬运 .data、清零 .bss。
  5. 调用 main 或进入系统初始化代码。

如果先调 main 再搬运 data,那么 main 里访问的任何全局变量都是垃圾值。如果先搬运 data 再设置栈,一旦拷贝循环里发生异常,连出错现场都无处保存。

很多初学者在这个阶段会犯一个非常隐蔽的错误:把“搬运 data”的代码放进 C 函数里,却忽略了这个 C 函数本身依赖 .data 段。这本质上是一个“鸡生蛋”问题,解决办法是让这层代码用汇编写,或者确保编译器不插入任何依赖 .data/.bss 的运行时初始化。

5. XIP:在 Flash 里直接跑代码

5.1 XIP 解决什么问题

XIP(Execute In Place,原地执行)是一种策略:CPU 直接从非易失性存储(NOR Flash、ROM)中取指执行,不把代码镜像搬到 RAM。

它解决的核心问题是 RAM 容量焦虑。RAM 通常比 Flash 贵、比 Flash 少。一颗 MCU 可能带 512KB Flash 但只有 64KB RAM,如果所有代码都搬进 RAM,程序根本放不下。XIP 模式下,.text 段放在 Flash,CPU 直接从 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 Code,PIC)存在的原因:代码在哪个地址运行,它就用哪个地址访问数据,而不是用编译时写死的地址。

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,-Map=output.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 无法启动的问题,你都会比大多数人更快地定位到根因。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询