ARM 启动流程第三弹,这次把链接脚本、启动文件里的段拷贝、XIP 和位置无关码四件事一次性讲透。前两弹讲完复位向量、栈初始化和时钟/存储器初始化之后,C 环境能不能真正跑起来,关键就看 text、data、bss 这三大段有没有被正确安排。看过不少做裸机、RTOS 移植、bootloader、IAP OTA 的读者卡在这里:GCC 的 .ld 脚本和 Keil 的 scatter file 该怎么读,startup_xxx.s 里那些搬数据的汇编循环到底在干什么,为什么同一个固件有时候放在 Flash 里就能跑,有时候又要先搬到 RAM,为什么 u-boot 和 Linux 早期代码换个地址照样运行。这篇文章就围绕这三个问题展开。
先给结论:ARM Cortex-M 启动流程中,text 段是只读代码,通常留在 Flash 里通过 XIP 原地执行;data 段是已初始化的全局变量,初值必须从 Flash 拷贝到 RAM;bss 段是零初始化和未初始化全局变量,启动时必须清零。位置无关码则是另一种思路,它让整个二进制文件不依赖链接时写死的绝对地址,加载到哪就在哪跑。
文章会先从内存布局和链接脚本讲起,再深入到启动文件里的拷贝代码,然后用 Cortex-M 常见工程做例子说明 XIP 和位置无关码,最后给出一份常见问题排查清单和工程实践建议。读者看完之后,至少能读懂大多数 ARM 裸机工程的启动代码,并能在出现全局变量初值不对、程序运行随机崩溃这类问题时,快速定位到段初始化的方向去排查。
1. 核心概念速览
很多问题之所以难排查,不是因为汇编难,而是因为“段”这个词在不同工具链下说法不一样。先把核心概念统一一下。
| 概念 | 所属区域 | 是否只读 | 谁负责初始化 | 说明 |
|---|---|---|---|---|
| text 段 | Flash / ROM | 是 | 链接器安排,启动时无需处理 | 存放可执行指令,XIP 时直接在 Flash 取指 |
| rodata 段 | Flash / ROM | 是 | 链接器安排 | 存放 const 变量、字符串字面量、switch 跳转表 |
| data 段 | RAM,初值在 Flash | 否 | 启动文件拷贝 | 存放非零初始化的全局变量和静态变量 |
| bss 段 | RAM | 否 | 启动文件清零 | 存放零初始化和未初始化全局变量 |
| 堆 | RAM | 否 | 链接器留空间,库初始化 | malloc 使用 |
| 栈 | RAM | 否 | 启动前由 Reset_Handler 设置 | 局部变量、函数调用保存现场 |
再对比一下常用工具链中这些段的叫法和控制文件:
| 工具链 | 链接控制文件 | 段拷贝关键符号 |
|---|---|---|
| GCC / GNU ld | .ld 链接脚本 | _sdata、_edata、_sidata、_sbss、_ebss |
| Keil MDK(armcc / armclang) | scatter file(分散加载文件) | Image$$RW_IRAM1$$Base、Image$$RW_IRAM1$$Length |
| IAR EWARM | .icf 链接配置文件 | segment_init相关操作,IDE 自动生成启动代码 |
这里的核心矛盾只有一个:初值必须放在掉电不丢的 Flash 里,但变量本身必须待在可读写的 RAM 里。启动代码的任务就是把初值从 Flash 搬运到 RAM 的正确位置,把不需要初值的内存清零。
2. ARM 启动时为什么需要段初始化
C 语言运行环境不是天然存在的。编译出的二进制只是一堆指令和数据,CPU 上电后不会自动知道“哪个全局变量应该等于 1”。在 main 函数被调用之前,运行环境必须满足三个条件:
第一,栈指针有效。Cortex-M 复位后从向量表首个地址加载主栈指针 MSP,然后才能调用任何函数,因为函数调用依赖压栈和弹栈。
第二,所有非零初始化的全局变量已经拿到初值。假设代码里写了一个uint32_t g_flag = 0xA5A5A5A5;,这个变量的实际存储位置在 RAM 的 data 段,但 0xA5A5A5A5 这个值在编译后被放在 Flash 的某处。启动代码必须把它复制过去,否则 g_flag 可能是 0。
第三,所有零初始化和未初始化的全局变量已经被清零。C 标准规定未初始化全局变量的初值为 0,这个动作靠启动代码循环写零完成,链接器不会自动做,malloc 也不会管这一块。
这三点没有做全,最典型的症状是全局变量初始值偶发不对、程序执行结果与预期完全不符、断点调试时变量监视窗口出现奇怪数值。尤其是优先级高的中断服务函数如果被提前触发,bss 段还没来得及清零,整个程序的状态就不可信了。
还有一个容易被忽略的点:向量表本身也是一种“数据段”。Cortex-M 内核要求向量表按 512 字节对齐或按 VTOR 要求对齐,表中第 0 项是初始 SP,第 1 项是 Reset_Handler。链接脚本里必须用 KEEP 把.isr_vector固定在 Flash 起始地址,并在链接后通过 map 文件确认 0x08000000 处的数据是否为预期向量表。
3. ARM 程序的完整内存布局:text、rodata、data、bss 是怎么落位的
以一个典型 Cortex-M 芯片为例,Flash 从 0x08000000 开始,RAM 从 0x20000000 开始。编译出的目标文件并不关心绝对地址,链接器才负责把所有段安放到具体地址。
Flash 侧通常依次排列:
| 段 | 在 Flash 中的位置 | 内容 |
|---|---|---|
| .isr_vector | 起始地址 | 向量表 |
| .text | 向量表之后 | 代码 |
| .rodata | 紧跟代码之后 | 只读常量 |
| .data 初值 | Flash 最后一段 RO 数据之后 | 变量初值区域,也就是 LMA 区域 |
RAM 侧通常依次排列:
| 段 | 在 RAM 中的位置 | 内容 |
|---|---|---|
| .data | RAM 起始区 | 已初始化全局变量 |
| .bss | data 之后 | 零初始化全局变量 |
| 堆 | bss 之后 | malloc 区域 |
| 栈 | RAM 末尾 | 向下生长的栈 |
这里面最关键的是 data 段同时出现在两侧:运行时位于 RAM,但初值位于 Flash。链接脚本中使用AT > FLASH或 scatter file 中的LOAD =语法来表达这种“加载地址”和“运行地址”不一致。
看一个简单的 GNU ld 链接脚本示例,这个结构在 STM32 裸机工程中很常见:
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) *(.text*) *(.rodata) *(.rodata*) . = ALIGN(4); _etext = .; } > FLASH .data : { . = ALIGN(4); _sdata = .; *(.data) *(.data*) . = ALIGN(4); _edata = .; } > RAM AT > FLASH .bss : { . = ALIGN(4); _sbss = .; *(.bss) *(.bss*) *(COMMON) . = ALIGN(4); _ebss = .; } > RAM }注意.data段的> RAM AT > FLASH写法。前半段表示运行地址在 RAM,后半段表示加载地址在 Flash。链接器会为此生成一组符号,常见命名是_sdata、_edata和_sidata,其中_sidata就是 data 段初值在 Flash 中的起始地址,也就是LOADADDR(.data)。如果启动文件中拷贝源地址写错,最常见就是把_sidata写成了_etext,这两个值在大多数布局中确实接近,但某些链接脚本里如果插入了其他段,就会偏移。
如果使用 Keil MDK 的 scatter file,等价的描述是:
LR_IROM1 0x08000000 0x00080000 { ER_IROM1 0x08000000 0x00080000 { *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00020000 { .ANY (+RW +ZI) } }scatter file 把地址规划分成 Load Region 和 Execution Region。上面的LR_IROM1是加载域,ER_IROM1是执行域,RW_IRAM1是数据执行域。armlink 会生成Image$$RW_IRAM1$$Base、Image$$RW_IRAM1$$Limit、Image$$RW_IRAM1$$ZI$$Base这些符号,启动文件中的拷贝代码直接引用它们。
4. 链接脚本与启动文件的分工
链接脚本负责“安排地址”,启动文件负责“执行搬运”。很多人只看其一,问题就出在两者对不上。
先看 startup 文件中的 part,典型汇编逻辑如下:
Reset_Handler: ldr r0, =_estack msr msp, r0 ldr r0, =_sdata ldr r1, =_edata ldr r2, =_sidata CopyData: cmp r0, r1 beq CopyDataDone ldr r3, [r2], #4 str r3, [r0], #4 b CopyData CopyDataDone: ldr r0, =_sbss ldr r1, =_ebss movs r2, #0 ZeroBss: cmp r0, r1 beq ZeroBssDone str r2, [r0], #4 b ZeroBss ZeroBssDone: bl main b .这段汇编做的事情非常直接:
第一段循环把_sidata指向的 Flash 区域逐个 word 拷贝到_sdata指向的 RAM 区域,直到地址等于_edata。拷贝的字节数由链接器计算的_edata - _sdata决定,编译器不会在源码层面写死。
第二段循环从_sbss到_ebss逐个 word 清零。
写完这两个循环后,才调用 main。这也是为什么要格外重视链接脚本:如果链接脚本把某个段漏了,或者> RAM AT > FLASH写反,启动文件里的循环照样会执行,但拷贝来源和目标地址就是错的,程序跑起来就是另外一副样子。
如果用 C 语言来表达启动文件的工作,大概是:
extern uint32_t _sdata; extern uint32_t _edata; extern uint32_t _sidata; extern uint32_t _sbss; extern uint32_t _ebss; void startup_init(void) { uint32_t *src = &_sidata; uint32_t *dst = &_sdata; while (dst < &_edata) { *dst++ = *src++; } dst = &_sbss; while (dst < &_ebss) { *dst++ = 0; } }实际启动文件比这多做了很多事,比如开 FPU、配置 VTOR、调用 SystemInit、设置堆地址等,但段初始化的骨架就是上面这两层循环。
5. XIP:为什么代码可以放在 Flash 里原地执行
XIP 全称 Execute In Place,直接翻译就是“原地执行”。Cortex-M 的片上 Flash 被映射到统一地址空间,CPU 可以像读内存一样从 Flash 取指,所以代码不需要搬到 RAM 就能跑。
为什么 text 段能 XIP?因为 Flash 是只读的,而代码指令本身就是只读的,不存在写入需求。CPU 取指只是发起读请求,Flash 控制器通过数据总线返回指令内容,这个过程对 CPU 来说是透明的。
为什么 data 段不能 XIP?因为 data 段存放的是变量,运行时要被反复读写。Flash 虽然支持单字节读,但写入需要按页或按扇区擦除,写一次寿命有限,速度也慢得多,根本不适合当作普通 RAM 使用。所以 data 段必须放在真正的 RAM 里,只能把“初值”放在 Flash 里,由启动代码搬运。
XIP 带来的最大好处是省 RAM。一个几十 KB 的固件如果全部搬到 RAM 跑,RAM 容量很快就烧完了。对于 MCU 这类 RAM 通常只有几十到几百 KB 的芯片,XIP 几乎是必然选择。
但 XIP 也不是没有代价。Flash 的读取速度低于 SRAM,高主频下还需要配置等待周期。如果系统时钟不断提高,Flash 等待周期没配好,程序就会执行异常。从 Flash 取指时,每次跳转、每个函数调用都要走 Flash 总线,对时间敏感的中断处理可能会有明显延迟。
所以很多工程会把一部分关键函数放到 RAM 里执行,典型对象是 Flash 擦写驱动、中断服务函数、实时性要求极高的算法循环。在 GCC 环境下,可以通过 section 属性让函数进入 data 段:
__attribute__((section(".data"))) void flash_program_worker(void) { // 该函数会被启动代码当作 data 段的一部分拷贝到 RAM }在 Keil 环境下,使用__attribute__((section("RAMCODE")))或 MDK 提供的__ramfunc关键字。这些“放进 RAM 段的函数”同样由启动文件的段拷贝循环搬运,所以要注意:一旦放进去的函数体积过大,RAM 占用会上升,搬移时间也会变长。
6. 位置无关码:为什么同一份二进制换地址还能跑
位置无关码 PIC(Position Independent Code)是解决“加载地址不确定”问题的手段。嵌入式里最常见的场景是启动加载器,比如 u-boot 的 SPL、各种 bootloader、IAP 机制,它们不知道自己会被烧录到哪段 Flash,甚至不知道外部 RAM 控制器初始化后内存映射到哪个地址,如果代码里写满了绝对地址,搬个位置就废了。
普通代码访问全局变量的方式很直接:
ldr r0, =g_count这条指令把g_count的绝对地址加载到 r0,如果链接时规定这个变量运行在 0x20000000,那么二进制里就硬编码了 0x20000000。如果代码后来被加载到 0x20010000 执行,这个地址就失效了。
位置无关码的思路是:让代码在运行时根据当前 PC 值计算目标地址,而不是直接使用编译期绝对地址。对于跳转指令,ARM 的 BL、B 本身就是相对跳转,天然位置无关。问题出在数据访问和间接跳转。
一个典型的位置无关实现是借助 GOT 表(Global Offset Table)。编译器把所有需要重定位的地址集中在 GOT 中,代码通过 PC 相对寻址先算出 GOT 表项地址,再间接读取真正地址。加载器只需在运行时修好 GOT 表中的数值,代码本身无需修改。这个机制在 Linux 动态库里最常见,裸机 bootloader 里也会用到简化版。
需要在 ARM 工具链中开启位置无关码时,GCC 一般使用:
arm-none-eabi-gcc -fPIC -fPIE -c startup.c但裸机工程中仅仅加编译选项往往不够,链接脚本还要配合修改,确保 GOT 等段被正确放置并在启动时完成重定位。很多人遇到的问题是:编译开了-fPIC,但链接脚本还是普通裸机布局,结果启动时 GOT 表没被初始化,程序一跑就 HardFault。更稳妥的判断是:如果只是想让一个 bootloader 能在不同 Flash 偏移下运行,优先审视链接脚本中的 Region 起始地址,而不是一上来就上-fPIC。位置无关码适合真正需要“任意地址加载”的场景,比如 ROM 启动程序、动态装载器等;如果加载地址只是需要在编译前确定,直接改链接脚本的 ORIGIN 更可控。
在启动流程中,位置无关码和段初始化的关系非常紧密。位置无关码只是让代码“敢在任何地址执行”,但全局变量的 data/bss 初始化照样需要有人做。如果整份固件被搬到了 RAM 的某个地址,启动代码必须知道 LMA 和 VMA 之间的差值,也就是重定位偏移量:
extern uint32_t _sdata_load; extern uint32_t _sdata; extern uint32_t _edata; uint32_t reloc_offset = (uint32_t)&_sdata - (uint32_t)&_sdata_load;这段代码表达的语义是:如果 data 段的加载地址和运行地址之间存在固定偏移,那么整个镜像大概率也是基于同一个偏移加载的。搬运镜像时只需要增加这个偏移,就能得到当前运行地址。很多 bootloader 的重定位逻辑就是在做这件事。
7. 段初始化的常见失败模式与排查方法
段初始化一旦出错,现象往往都是“随机”的。下面把我在大量问题帖和实际调试中最常见的几类列出来。
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 全局变量的初值偶尔不对 | data 段拷贝源地址或目的地址错误 | 查看 map 文件确认_sdata、_sidata、_edata地址;检查链接脚本是否有 OTHER 段插入 |
| 未初始化全局变量直接是随机值 | bss 清零循环没执行或跳过了 | 检查_sbss、_ebss符号是否定义;检查启动文件中是否早退 |
| 程序从一个地址拷贝后跑飞 | LMA/VMA 设置相反或重定位偏移计算错误 | 检查AT > FLASH;打印重定位偏移量确认是否等于预期 |
| 中断全部跳到同一个入口 | 向量表偏移未设置或 VTOR 配置错误 | 检查 SystemInit 后是否设置 SCB->VTOR;检查向量表是否 512 字节对齐 |
| 程序放到 RAM 后全局变量地址错乱 | 链接脚本仍然把 data 定位到 Flash | 确认> RAM AT > FLASH;确认 scatter file 中 RW 区域正确 |
| XIP 下提高主频后执行异常 | Flash 等待周期未同步调整 | 配置 FLASH_ACR 等待周期,与系统时钟匹配 |
| Keil 旧工程迁移后 link 报错 | AC5 scatter 语法在 AC6 下不兼容 | 更新分散加载文件语法,或用 MDK 提供的迁移工具 |
编译开-fPIC后运行仍失败 | GOT 表未初始化或链接脚本未正确处理.got段 | 增加.got、.got.plt段定义;启动代码补充 GOT 重定位 |
逐个展开讲一下排查思路。
data 段拷贝错误是出现频率最高的问题。现象是全局变量初始值像是“有值但不对”,比如某个配置变量应该在启动时变成 0x55AA,实际却变成 0xFFFFFFFF 或相邻变量值。这种问题最直接的做法是读二进制 map 文件,找到这三个符号的实际地址:
arm-none-eabi-nm firmware.elf | grep -E '_sdata|_edata|_sidata'然后看 Flash 区域的映射,确认_sidata是否真的等于.data加载地址。有些工程师在链接脚本里额外插入了校验和、固件版本号等段,这些段会改变_etext和_sidata的相对位置,启动文件一旦复用旧的_etext作为拷贝源就会读错区域。
bss 清零问题相对隐蔽,因为很多启动文件在跳转 main 之前会执行多个初始化函数,比如SystemInit、__libc_init_array。如果其中某个函数提前使用了未清零的全局变量,bss 清零就变得形同虚设。调试时可以用硬件断点把_ebss地址的写入操作断下来,看看清零动作发生在哪个时刻,再确认其是否在任何用户代码执行之前完成。
向量表偏移是另一个容易忽略的坑。很多项目的 App 固件从0x08010000开始运行,但启动代码没有执行这条语句:
SCB->VTOR = 0x08010000;结果中断来了,CPU 还是跑到旧向量表去取中断入口。溢出、写保护、HardFault 全部指向错误的中断服务函数。检查向量表是否按 512 字节对齐也是一个硬性条件。
8. 最佳实践:让段初始化变成可验证的工程步骤
这一节的内容偏工程化,每一步都值得在项目里落地。
第一,每个工程维护一份自己的链接脚本和分散加载文件,不要永远依赖 IDE 自动生成文件。IDE 自动生成能满足默认情况,但工程一旦加入 bootloader 地址偏移、RAM 功能搬移、OTA 双备份,这些文件就必须被手动精细控制。用 git 管理它们,并写清楚每次修改理由。
第二,启动文件中段拷贝完成之后,增加一个可选的 CRC 校验步骤。做法是链接脚本里把 data 段初值区域标记为一个整体,启动代码在拷贝前对 Flash 源区域计算 CRC,拷贝后对 RAM 目标区域计算 CRC,二者一致才进入 main。这个流程在 bootloader 场景里尤其重要,能有效拦截“拷贝一半被复位”的固件。
第三,利用 map 文件做自动化检查。在持续集成或编译脚本中加入对 map 文件的解析,确认以下条件:
# 伪脚本逻辑:解析 map 文件中的内存占用 # RW + ZI 总大小必须小于 RAM 总容量 # RO 总大小必须小于 Flash 总容量 # _sidata 与 .data load region 起始地址一致如果使用 Keil 工具链,可以在编译后手动打开.map文件查看 Execution Region RW_IRAM1 的 Size 和 Load Region 信息;使用 GCC 工具链则可以用arm-none-eabi-size查看各段总和:
arm-none-eabi-size firmware.elfsize输出通常分为 text、data、bss 三列。text 代表 Flash 中的代码和常量,data 代表有初值的全局变量,bss 代表零初始化变量。实际 RAM 占用为 data + bss 之和,加上堆和栈预留。
第四,函数放 RAM 要控制数量。__ramfunc或 section 属性将函数放入 data 段时,这个函数会被启动文件完整拷贝到 RAM。优点是可执行关键代码不受 Flash 等待周期影响,缺点是 RAM 占用上升、启动时间变长。使用前先确认锁在 RAM 里的函数是否真的处于性能关键路径,不要整个驱动都往里扔。
第五,bootloader 和 App 的 Flash 地址偏移一定要在链接脚本和代码里同时体现。常规做法是定义统一的宏或在 ld 脚本中使用 SYMBOL 设置:
APP_START_ADDR = 0x08010000;然后在启动代码中把该地址写入 SCB->VTOR,同时确保向量表本身在链接后被放置到该起始地址。这样 bootloader 跳转到 App 时才不会出现中断向量错乱。
第六,尽量使用链接器符号来引用段边界,不要手工计算地址。GNU ld 中会自动导出_sdata、_edata等符号,Keil scatter file 中也会生成Image$$RW_IRAM1$$Base等符号。手工计算地址一旦链接脚本调整就全部失效,这是很多隐蔽 bug 的来源。
第七,涉及固件安全和 IAP 功能时,必须确认 Flash 写入任务被安排到 RAM 执行,并确认边界条件。不要在 Flash 擦写过程中断电或复位,否则固件会被写废;批量生产时也要考虑在固件中加入版本检查和回滚方案。
第八,针对 AC5 迁移 AC6 的工程,段初始化的逻辑可能有变化。AC5 时代某些启动文件中使用分散加载符号的方式在 AC6 下依然适用,但 scatter file 语法要求更严格。迁移后优先对比 map 文件中 data 段拷贝源地址是否与 AC5 版本一致,而不是只看编译是否通过。
9. 直接能参考的验证步骤
如果你正在调试一个 ARM 裸机或 RTOS 工程,下面的验证顺序可以直接照做。
第一步,编译后先看 map 文件和size输出,确认 text/data/bss 三个段的规模和地址。这一步能发现最基础的链接配置错误。
第二步,在 Reset_Handler 的段拷贝入口处设置断点,单步执行一个循环周期,检查 r0、r1、r2 三个寄存器的初值。r0 应该等于 data 运行起始地址,r1 应该等于 data 运行结束地址,r2 应该等于 data 初值在 Flash 的起始地址。这三个值和 map 文件对齐,拷贝就成功了一半。
第三步,在进入 main 函数前设置断点,打开内存观察窗口,检查 data 段中的关键变量是否已经是预期初值,bss 段中的变量是否全部为 0。
第四步,运行到中断触发,检查向量表偏移设置和中断服务函数是否实际执行到。此时如果出现 HardFault,优先在 HardFault_Handler 中读取堆栈帧,分析压栈 PC 的位置,判断是段访问异常还是 Flash wait state 配置问题。
第五步,如果程序是从 bootloader 跳转过来的,需要在 App 启动早期打印或保存一个“段初始化完成”的标志,确认跳转地址、栈指针、链接地址三者一致。常见坑是 bootloader 里设置的 SP 被带到 App 里,而 App 的栈空间布局变了,导致压栈把 Flash 区域写穿。
第六步,做 XIP 性能测试时,不要把“能否运行”当作唯一标准。用示波器或性能计数器测量关键中断响应时间,对比代码放在 Flash 和 RAM 两种情况。如果 Flash wait state 没有配好,即使运行不崩,实时性也可能已经不合格。
第七步,如果想验证位置无关码,可以做一个简单的实验:连续编译两个链接脚本,分别把代码放在0x08000000和0x08010000,在两个版本中调用同一个 bootloader 跳转指令,观察是否都能正确执行。如果两个版本都能运行且全局变量正确,说明代码和链接脚本的重定位设计都没有大问题。
10. 总结
ARM 启动流程中的段初始化不是“配好一次就不用管”的事情,只要固件涉及 bootloader、OTA、XIP、RAM 执行、位置无关码,就会频繁和 text、data、bss、LMA、VMA、GOT 打交道。值得最先深入理解的是 data 段的“双地址”模型:初值在 Flash,运行在 RAM,启动文件负责搬运;bss 段则是纯 RAM 区域,启动文件负责清零。XIP 节省 RAM,但要处理 Flash 等待周期;位置无关码解决加载地址不确定问题,但要看 GOT 表和链接脚本是否配合。建议读者下次遇到“全局变量初始值不对”这类问题,先打开 map 文件核对_sdata、_sidata、_edata,再检查启动文件拷贝循环,最后确认 VTOR 和 Flash wait state 配置。把这一段逻辑跑通,ARM 程序从复位到 main 的全链路就基本没有盲区了。