学ARM嵌入式开发,前面几篇把启动流程、编译链接的基本逻辑说得差不多了,今天聊一个让无数人栽过跟头的点:重定位。我最早接触这个概念的时候,觉得不就是把代码从一个地方搬到另一个地方嘛,有啥好讲的。直到自己搓板子,遇到一个诡异现象——程序烧进Flash,单步跑的时候全局变量死活读不对,一会儿是0x00一会儿是0xFF,查了半天发现是数据段压根没搬到RAM里。那种感觉,就像你明明把一本书放进了书架,却在床头柜上翻来翻去找。
这篇东西就是冲着把重定位讲透去的,适合已经跟着系列文章走过来的朋友,也适合那种链接脚本看过几遍但还是不清楚LMA、VMA、__data_start__这些符号到底怎么用的读者。我会从原理讲到实操,从链接脚本讲到汇编搬运代码,再把我实际调试中踩过的坑整理出来。不能说看完就成专家,但至少再碰到跟重定位相关的问题,你不会一脸懵。
1. 重定位到底在解决什么问题
1.1 从一次奇怪的变量值说起
先描述一下我遇到的那个bug。项目是一个基于ARM Cortex-M内核的小控制器,程序放在片内Flash里,正常情况下上电之后从Flash启动,代码在Flash里直接执行。我在代码里定义了一个全局变量:
uint32_t g_flag = 0x5A5A5A5A;main函数第一行就想读这个值,结果读到的是0x00000000。我当时第一反应是编译器优化出问题了,关了优化还是一样。后来又怀疑是不是Flash烧录没烧对,用调试器读Flash地址,数据明明在。最后无意中发现,如果我把它改成在main里赋值,不依赖初始化,程序就正常了。
这个问题本质上就是重定位没做,或者说得更准确:g_flag在链接时被安排到了RAM的某个地址,但RAM里这个地址在启动阶段并没有被写入0x5A5A5A5A。因为代码里带初始值的数据——也就是.data段——烧录时放在Flash里,上电后如果没有人把它从Flash拷贝到RAM里,你在RAM地址上读到的自然就是上电瞬间的随机值,通常是0。
这个“没有人把它拷贝到RAM里”的活儿,就是重定位干的。
1.2 加载地址与运行地址:两个容易混淆的概念
要理解重定位,绕不开两个术语:加载地址(Load Memory Address,LMA)和运行地址(Runtime Memory Address,VMA)。前者是数据或者代码在存储介质里的物理位置,比如Flash里的某个偏移;后者是它执行或者被访问时应该待在的位置,通常是RAM。
拿生活里的事类比。你收到一个快递箱子,箱子里是家具零件,零件出厂时按照“在客厅某个位置安装好”的设计打好了包装。箱子被送到时,放在门口——这个门口就是加载地址。但你要真正使用家具,得把它们搬到客厅、卧室,按说明书组装好。组装好之后的位置,才是它们正常工作的运行地址。
飞机客舱送餐车那种行进过程不算,但嵌入式场景特别典型:程序烧在Flash里,Flash的读取速度慢、而且很多情况下不能被直接修改,所以运行时要把需要频繁读写的部分搬到RAM。搬之前,它在Flash上的地址就是LMA;搬之后,它在RAM里的地址就是VMA。
编译器生成的代码,只会按照VMA来访问全局变量和函数。也就是说,它脑子里只有“家具应该在客厅”这一幅画面,并不知道快递还堆在门口。如果你的启动代码不照“说明书”把它们从门口搬到客厅,那等到main里想去客厅找家具时,翻开柜子,当然是空的。
1.3 哪些场景必须做重定位
不是所有程序都需要重定位。如果你的代码量特别小,只用了栈,没有任何带初始值的全局变量,也基本上不用BSS段,那么从Flash直接执行可能没什么问题。但从实际产品开发的角度看,重定位几乎是必经之路,我自己归纳了一下,至少四种场景躲不掉。
第一种,RAM运行速度远高于Flash。尤其是一些MCU内部Flash带等待周期,CPU主频跑上去之后,Flash读取会成为瓶颈。把热点代码和数据搬到RAM里跑,性能提升非常明显。
第二种,程序存储在只读介质上,根本不可执行。比如很多方案是用SPI NOR Flash、NAND Flash或者SD卡存放固件,CPU不支持从这些介质直接取指,就必须在启动阶段把整个镜像搬运到RAM里再跳过去执行。
第三种,程序的某部分需要频繁修改。比如运行过程中要更新配置参数、维护日志缓冲区,这些数据天然必须放在RAM里,而上电初值又在Flash里,不做重定位就会丢失初值。
第四种,bootloader与APP的升级场景。bootloader跑在Flash初始区域,APP可能需要覆盖整个Flash区域。为了保证升级过程中能完整擦写,干脆先把APP从Flash搬到RAM里执行。这种重定向不只是搬数据段,而是整个运行时代码段都搬到RAM,复杂得多,但原理同根同源。
可以说,重定位不是“要不要做”的问题,而是“你什么时候意识到必须做”的问题。越早弄明白,后面踩坑越少。
2. 链接脚本配置:重定位的“地图”
2.1 链接脚本里的三类关键区域
重定位说起来简单,就是memcpy加memset,但做之前你得先有个“地图”:Flash上的数据位置、RAM里的目标位置、需要搬多长。这张地图就是链接脚本,或者ARM术语里的分散加载文件(scatter file)。
拿GNU工具链来说,一个典型的链接脚本里,SECTIONS块是核心。下面是我给一个Cortex-M项目写的简化版:
ENTRY(_start) MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K } SECTIONS { .text : { *(.text.start) *(.text*) *(.rodata*) __data_load_start = .; } > FLASH .data : { __data_start = .; *(.data*) __data_end = .; } > RAM AT> FLASH .bss : { __bss_start = .; *(.bss*) *(COMMON) __bss_end = .; } > RAM }先解释一下这段脚本干了什么。
.text段包括启动代码、普通代码、只读常量,直接放在Flash里,从0x08000000开始。.data段比较有意思,它有两个地址:运行地址在RAM,加载地址在Flash。> RAM AT> FLASH这个语法,前面的> RAM指定VMA,后面的AT> FLASH指定LMA。链接器得到的结果是:.data段的内容会被安排在Flash里(毕竟烧录时只能往Flash写),但符号的地址值却按RAM算。
在.data段末尾,我放了一个__data_load_start = .;。注意,这个符号的赋值是在.text段里面完成的,此时.正指向Flash区域。所以__data_load_start表示的是.data段在Flash里的起始地址,也就是要拷贝的源地址。而__data_start、__data_end是在RAM区域里定义的,分别表示目标地址和结束地址。
这三个符号,就是做那段搬运代码的全部信息。
2.2 scatter文件与链接脚本的对应关系
如果你用的是ARM自家的编译器(比如ARMCC或者Arm Compiler 6),入口文件是分散加载文件,后缀通常是.sct。它做的事情和GNU链接脚本完全一样,只是语法变了。我同样写了一个等价版本:
LOAD_ROM 0x08000000 0x00080000 { EXEC_REGION 0x08000000 0x00080000 { * (+RO) init.o (+RW) } RAM_REGION 0x20000000 0x00010000 { * (+RW, +ZI) } }LOAD_ROM是加载域,前半部分定义了整个镜像在Flash里的起点和大小。EXEC_REGION是执行域,就是说代码在Flash里执行,+RO放只读段。初始化那个目标文件——我给它起名叫init.o——它的数据段特殊处理,放在Flash里执行域,因为重定位代码自己用的变量一般很少,而且绝对不能让重定位代码去访问尚未重定位的数据。剩下的+RW(可读可写数据)和+ZI(零初始化数据)全部放到RAM执行域。
这套写法和GNU ld的思路一一对应,只是名字不同。当你看到project.map里出现了__data_load_start这类符号时,不要慌,那多半是ARM Compiler在底层自动生成的等价物,不代表你的工程有问题。
2.3 链接器符号:重定位代码的“路标”
链接脚本里的符号不是普通的C变量,它们是“地址常量”。如果你在C语言里声明成:
extern unsigned int __data_start;然后用__data_start去赋值,取到的并不是这个符号的内容,而是一个链接期确定下来、直接嵌入到指令里的数字值。换句话说,它更像#define而不像变量。你不需要为它分配内存,也不能往它里面写东西。很多新手在这里犯错,觉得声明一个extern变量就要去定义它,其实完全不需要。
我自己调试时习惯把这一组符号用在汇编里,因为汇编里对这些地址常量的使用非常直观:
ldr r0, =__data_load_start ldr r1, =__data_start ldr r2, =__data_endldr r0, =symbol是ARM汇编里常用的伪指令,编译器会把符号的地址值作为立即数加载到寄存器。这串代码最终的结果就是:r0拿到Flash上的源地址,r1拿到RAM里的目标地址,r2拿到搬运结束地址。有了这三个数,循环拷贝就顺理成章。
强调一个细节:链接器符号的赋值位置很关键。放在Flash区域里的符号记录的是加载地址,放在RAM区域里的符号记录的是运行地址。如果你在.text段里定义了一个__data_start = .;,那它就成了Flash地址,后面再拿它当目标地址搬运,数据就会写进Flash——那就芭比Q了。
3. 重定位代码实现:搬移数据段、清零BSS、跳转main
3.1 汇编实现逐行拆解
链接脚本把地图画好之后,接下来的事就是按图施工。我见过很多人直接在C里写一个memcpy完成拷贝,但在典型启动过程中,C运行环境还没建立,你在main之前调用任何C库函数都可能踩坑。所以最稳妥的方式,是用汇编在启动代码里把这个活干了。
下面是我常用的一段Cortex-M启动代码片段,去掉了具体芯片外设相关的部分,保留最核心的搬移逻辑:
.syntax unified .cpu cortex-m4 .thumb .section .text.start .global _start _start: /* 关中断,避免搬运过程中被异常打断 */ cpsid i /* 重定位.data段:从Flash拷贝到RAM */ ldr r0, =__data_load_start ldr r1, =__data_start ldr r2, =__data_end copy_loop: cmp r1, r2 bhs copy_done ldr r3, [r0], #4 str r3, [r1], #4 b copy_loop copy_done: /* 清零.bss段 */ ldr r0, =__bss_start ldr r1, =__bss_end mov r2, #0 zero_loop: cmp r0, r1 bhs zero_done str r2, [r0], #4 b zero_loop zero_done: /* 设置栈指针 */ ldr sp, =__stack_top /* 进入C的世界 */ bl main逐段说。
cpsid i关中断这一步容易被忽略。搬运过程如果来一个中断,中断服务程序访问的恰好是一个还没搬到位的全局变量,就会读到错误值。大多数芯片复位后默认中断是关的,但芯片厂商的启动文件可能提前开了,最好还是显式关一次。
拷贝循环用的是ldr r3, [r0], #4这种后变址寻址,先把r0指向的4字节读到r3,然后r0自动加4。str r3, [r1], #4同理。一次搬4字节,字节数如果不是4的倍数,基本上是因为结构体里有奇数字节的数据,链接器通常会自动补齐对齐,所以不用太担心。
清零BSS段就是循环写0。BSS段的特点是只占运行空间、不占Flash存储空间,链接器不会为它生成加载数据。它存在的意义就是给那些没初值的全局变量、静态变量腾出RAM,上电后必须清零,否则变量初始值完全是随机的。
搬完数据、清完BSS,再设置栈指针。栈顶地址也是链接脚本里安排好的,通常在RAM的最高端往下。最后bl main跳进C的世界。
你可能发现了,这段代码从头到尾没有用任何绝对跳转,也没有访问任何有初值的全局变量。原因下面细说。
3.2 为什么重定位代码本身必须是位置无关的
这是整个重定位里最微妙的一个点。
编译器在编译一个普通C函数时,访问全局变量用的是绝对地址,这个绝对地址就是链接脚本里安排的VMA。假如重定位代码里写了:
void copy_data(void) { g_source_ready = 1; /* 假设这是个全局变量 */ ... }编译器会把这个g_source_ready的地址固化成一个立即数,比如0x20000048。问题是,重定位代码执行的那个时刻,.data段还躺在Flash里,RAM里0x20000048这个地址的内容很有可能还是上电的随机值。这下完了,你连“源地址是多少”“目标地址在哪”这种基础信息都不可靠了。
所以启动代码必须保证在重定位完成之前,自己只用寄存器操作,不碰任何有初值的RAM变量。链接器为启动代码单独开一个段(.text.start),并且把它排在.text段最前面,也有这方面的考虑。
那C库函数能不能调?尽量不要。memcpy的代码本身倒不一定会访问全局数据,但它一旦被调用,编译器可能生成基于库内部状态的东西,而且你也没法保证库函数的栈空间足够。老老实实寄存器循环,简单可控。
有时候你会在一些启动文件里看到用adr指令而不是ldr伪指令来获取地址,这更严格的位置无关写法。adr生成的是相对于当前PC的地址,不受重定位影响;而ldr r0, =symbol是把链接器分配好的绝对地址塞进指令里。如果整个代码段本身也要搬移,那么用ldr加载其他段的符号可能会有问题。但在我们的场景里,搬移代码本身留在Flash运行,搬移完成后跳转到RAM里的main之前,没必要把启动代码也一并搬到RAM。所以只要启动代码自己不访问需要重定位的数据,ldr r0, =__data_load_start这种写法是可以正常工作的。
3.3 从重定位到main:别忘了栈与中断向量表
完成搬移后直接跳转main还不够。至少有两个东西需要确认:栈是否就绪,向量表是否指向正确的地址。
栈的问题好理解,C语言函数调用一定要有栈,我习惯在跳转main之前把sp设好。有些人会分两步:先设一个临时栈跑初始化,等真正进入main之前再切到正式栈。这个看需求,没有绝对标准。
向量表的问题藏得深。Cortex-M的向量表默认放在0x00000000附近,也就是Flash的起始位置。如果重定位之后,你把代码全部搬到了RAM里,但中断发生的时候处理器继续去Flash找向量表,而Flash顶端的数据并没有被搬移更新,那或者跳到错误地址,或者从一个非对齐的向量值上异常。解决方法是重定位完成后,配置向量表偏移寄存器(VTOR),把向量表指向RAM里的新位置,并且把尚未就绪的中断全部关掉。
同理,如果使用了操作系统或者复杂的中断嵌套,还得保证RAM里的向量表在搬移过程中不被中断保护机制干扰。这一步不少人会忘,但偏偏是产品稳定性的大坑。
4. 常见问题与排查技巧实录
4.1 我踩过的几个坑
先说三个印象最深的。
第一个坑:拷贝源地址__data_load_start指向Flash,但我在链接脚本里把符号定义错了位置。当时写脚本时脑子一抽:
.data : { __data_start = .; *(.data*) __data_end = .; __data_load_start = .; } > RAM AT> FLASH我把__data_load_start放到了.data段里面。这下坏了,符号算出来的地址是RAM里的地址,但那块RAM还没有数据。拷贝的时候等于从一个随机RAM地址往另一个RAM地址拷,结果全局变量全乱套。后来我总结:源地址符号必须放在加载域里,目标地址符号放在运行域里,两者不要放在同一个块内。
第二个坑:BSS清零的时候把所有段都清了一遍,结果连栈区域也被清了。栈是链接脚本分配的高端RAM,不属于BSS段,但我在链接脚本里把__bss_end定义到了RAM的最高地址,清零循环一路往上扫,直接把栈空间全抹成0。后来跳转main一压栈就爆,程序各种跑飞。教训是不要把BSS段范围扩大了,清零长度宁小勿大。
第三个坑:从Flash拷贝到RAM用的是32位字读取,但如果数据段首地址没有按4字节对齐,ldr会触发对齐异常。有些MCU容忍非对齐访问,但Cortex-M0基本会hardfault。所以链接脚本里最好用. = ALIGN(4);把每个段的起始地址对齐到4字节,保险。
4.2 常见问题排查速查表
平时帮人看代码,这些问题翻来覆去也就那么几种,我整理成一张表,建议收藏。
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 全局变量有初值但读出来是0或随机值 | .data段没有搬移,或者搬移源地址错误 | 查map文件中.data段的LMA和VMA,确认启动代码是否执行了拷贝循环 |
| 搬移后程序跑飞 | 目标RAM地址与链接脚本不一致,或栈被清零 | 单步看拷贝后的RAM内容;检查链接脚本BSS范围 |
| 有全局变量时程序无法启动 | 启动代码太早访问带初值变量 | 确认重定位完成之前,不调用任何依赖RAM变量的函数 |
| 中断响应异常 | 向量表没有重定位,VTOR未配置 | 检查重定位后是否更新VTOR,并确认中断在重定位期间关闭 |
| 烧录后第一次运行正常,复位后异常 | 数据段重复搬移,破坏了原数据或意外覆盖其他区域 | 确认启动代码只在冷启动时一次性搬移;如果支持软复位,需保护搬移结果的RAM区域不被重复执行覆盖 |
第一条最常见。你看程序在调试器里正常跑,真正上电复位反而不行,十有八九就是启动代码没跑或者跑的顺序不对。调试器通常会帮你初始化一堆东西,掩盖了问题。
4.3 验证重定位是否生效的几种方法
说完了排查,讲讲怎么主动验证。
最简单的方法,在main函数入口处放一个带初值的全局变量,然后把它打印到串口。如果打印的是你期望的初值,说明.data段搬移成功。但这只能说明结果,不能说明过程。
更好的方法是看map文件。我用的GNU工具链,编译完之后打开.map文件,搜索.data段,会看到类似这样的记录:
.data 0x20000000 0x100 load address 0x080010000x08001000是Flash上的加载地址,0x20000000是RAM里的运行地址。如果这里出现了load address和运行地址不一致,说明链接脚本里AT> FLASH生效了;如果load address就等于0x20000000,那说明链接脚本写错了,数据段根本没有放到Flash里。
还可以用调试器。把程序停在启动代码的copy_loop处,单步几次,观察RAM地址0x20000000上的值是否在逐渐变成Flash里的数据。注意调试器本身可能会干扰Flash读取时序,最好用硬件断点而不是软件断点。
另外一个进阶做法:在拷贝前后各打一个硬编码的魔数到某个寄存器或者GPIO,用示波器或者逻辑分析仪看时间差,能验证搬运是否耗时过久。尤其是在大容量数据的场合,一个低效逐字节搬运可能让启动时间从几毫秒拉到几十毫秒,对启动时序敏感的产品是致命的。
我个人做项目时,习惯把重定位相关的逻辑集中在一个文件里,命名为类似x_startup.s,并用宏把目标RAM基地址、预期拷贝长度做成可配置项。这样换芯片平台时,只改链接脚本和几个宏,不需要动汇编代码。还有一个经验是,尽量在拷贝循环里加入CRC或者魔数校验,尤其用在bootloader升级的场景。如果发现Flash里数据不完整,宁可不跳转也不会跑一个残缺的系统。
重定位这个东西,说穿了就是“启动时代码要相信自己已经被正确放置”的那一步。它不复杂,但涉及链接脚本、汇编、内存布局三个层面的配合,任何一个环节出错,表现都极其隐蔽。特别是当你从开发板过渡到自研板,或者从内部Flash换到外部SDRAM方案的时候,重定位的细节会直接决定项目能不能稳定跑起来。希望这篇总结能帮你把这块拼图补齐,至少在下一次遇到变量值千奇百怪时,你能快人一步想到,是不是身体还没搬进新家就急着干活了。