
这个系列已经写到第4篇了前面几篇把寄存器、时钟、串口和最简单的点灯捋了一遍一切感觉良好。直到我在某开发板上写了一个带全局变量的程序下载到Nor Flash里运行出现了我完全无法理解的现场程序能启动LED也能闪但只要逻辑里用到全局变量行为就变得乱七八糟甚至直接跑飞进异常。那段折磨了我两天的排查过程最后指向的核心概念就是重定位。重定位一句话可以说明白让程序在自己的链接地址上运行。但这句话背后牵扯出来的东西很不少——搬运代码、位置无关、BSS清零、栈指针、异常向量表每一环都能单独写一篇踩坑记录。这篇学习总结按我当时的理解顺序来写从为什么要搬到手写搬运代码再到搬完之后还有哪些坑希望能帮你少走我走过的弯路。1. 跑飞的不是程序是地址重定位要解决的三个地址问题1.1 当时的怪异现场点灯正常全局变量错乱先说最开始的现象。程序链接时我指定了一个很高的内存地址作为程序入口但开发板复位之后CPU实际是从Flash的低地址开始取指令的。结果就是程序在前面几百条汇编里跑得还挺好因为这些代码大多是相对跳转和寄存器操作不依赖某个绝对地址。但一旦执行到需要读写全局变量的指令时CPU会按照编译器生成的绝对地址去访问内存。那一刻访问的实际上是一块什么都没有的高地址区域里面可能是随机值或者全0于是计数变量越加越乱字符串、标志位全部失效看起来就像是程序精神分裂。这个现象的关键点在于代码里的全局变量、字符串常量、函数指针在编译链接阶段就已经被分配好了绝对地址。程序不搬到那个地址去运行所有这类访问全是错的。1.2 链接地址、加载地址、运行地址三个地址一台戏把问题拆开其实就是三个地址在打架链接地址Link Address链接脚本里规定的程序应该运行的内存基址编译器生成的代码里所有绝对地址引用都基于这个基址计算。加载地址Load Address程序实际被放置的位置比如Nor Flash的起始地址、SD卡里的某个偏移、或者SRAM的低地址。运行地址Running AddressCPU当前实际执行到的地址也就是PC的值。正常情况下这三个地址应该一致。但裸机启动的初始阶段它们几乎不可能一致。程序被烧录在Flash里加载地址是Flash的地址链接地址却是SDRAM的高地址CPU复位后从Flash开始执行运行地址又是Flash的地址。三址分离就是所有怪异问题的根源。打个不太严谨但好理解的比方你约了一位快递员把包裹送到你家的地址A但包裹实际上被放在地址B的驿站而你还站在地址C的路口干等。代码就是那个快递员他手里拿着的取货单上写的是地址A但包裹一直在驿站B你在路口C永远等不到货。重定位要做的就是把驿站B的包裹亲手搬到地址A然后人也走到地址A去拆包。1.3 Nor Flash 与 SDRAM 的差异决定了必须搬有人会问既然程序能在Flash里执行为什么不干脆链接到Flash的地址程序原地运行不就不需要搬了这是个好问题答案取决于存储介质。有些平台确实支持在Flash里原地执行XIPExecute in Place比如Nor Flash可以按字节读CPU能直接从里面取指令。但问题是Nor Flash通常容量小、速度慢而且一般开发板的Nor只有几MB放个完整应用很勉强SDRAM容量大、速度快是程序真正该常驻的地方。另外在Nand Flash这类不支持随机取指的介质上CPU连指令都没法直接取启动阶段只能先靠片内SRAM运行一小段代码把程序搬进SDRAM再继续执行。所以重定位不是你选择要不要做而是从Flash/SRAM启动、把代码放在SDRAM里运行这个模型下的必然步骤把程序内容从加载地址找到链接地址并且确保跳动到链接地址后一切正常。这里先放一张我在笔记里反复对照的表格三个地址的典型场景地址类型典型位置程序真正访问时依赖什么链接地址SDRAM 0x30000000编译器生成的所有绝对引用加载地址Nor Flash 0x00000000烧录器/启动代码放置位置运行地址开始时也在Flash低地址PC寄存器当前值理解了这三个地址的错位再看第二点就容易很多究竟哪些指令在搬家之前还能安全执行哪些指令一旦运行在错误地址就直接翻车。2. 位置无关与位置有关反汇编里看穿指令的本质2.1 一条 ldr 伪指令引发的血案学习重定位绕不开两种指令ldr伪指令和adr指令。我在相当长一段时间里都把两者混为一谈直到被程序跑飞教育了一顿才去深挖。ldr r0, _start看起来很普通但它不是一条真实存在的ARM指令而是汇编器提供的一条伪指令。它背后的实现方式是汇编器在代码的某个位置通常是文字池literal pool放一个4字节数据这个数据就是_start的链接地址然后真正的指令变成一条基于PC的加载指令把这个4字节从文字池加载到寄存器里。也就是说你的源文件里写了一个把_start地址赋给r0的逻辑编译器最终生成的却是从某个位置取一个预先存放的值。这个值在链接时被固化成了_start的链接地址。程序在执行这条指令时它取出来的地址永远是链接地址和你当前实际运行在哪没有任何关系。这带来的效果是只要指令流里出现ldr rN, 某符号这段代码就已经是位置有关的了。如果它在Flash的低地址执行取到的是高地址0x30000000如果它在SDRAM里执行取到的还是0x30000000。同一个符号取到的值恒定不变。2.2 adr 与 ldr 的本质差异一个是量距离一个是取绝对值和ldr r0, _start相对的是adr r0, _start。adr是ARM里的一条伪指令但它汇编出来的是一系列相对于PC的加减指令。它计算的是当前PC离_start的相对偏移然后PC 偏移得到_start在当前运行空间中的实际地址。举例说明。设_start的链接地址是 0x30000000程序此时实际运行在 0x00000000ldr r0, _start执行后r0 0x30000000adr r0, _start执行后r0 ≈ 0x00000000实际运行时所在的低地址位置如果把这段代码和它所在的数据一起搬到SDRAMadr计算的偏移不变r0 的值反而会变成0x30000000附近。这说明adr是位置无关的它在哪儿运行就算出哪儿的地址。这个区别怎么记我的记忆方法ldr r0, _start是这个符号被我固定在内存里的绝对门牌号adr r0, _start是我现在站的位置到这个符号要拐几个弯。前者拿到的是写死的绝对门牌后者拿到的是根据你自己位置算出来的相对距离。提示adr使用的相对偏移范围有限PC附近大约±4KB以内效果最好。如果代码很大要小心地址距离超出范围汇编器会报错或者直接生成不可靠结果。2.3 用 objdump 验证位置无关代码文字说得再多不如实际看看反汇编。当时我用的命令是arm-linux-objdump -D 你的程序.elf dump.txt反汇编文件里你能清楚看到两类指令的形态差异。比如30000000: e59f0014 ldr r0, [pc, #20] ; 从文字池取值 30000004: e28f0008 add r0, pc, #8 ; add指令基于PC偏移计算第一条ldr就是从当前位置往后偏移20字节的文字池里把一个写死的值取出来第二条add则是直接在当前PC上做加减。同一份代码把加载地址从Flash换成SDRAM第一条指令取出来的值不变第二条指令算出来的值却会跟着PC走。这就是位置无关和位置有关最直观的区别。重定位代码的设计原则由此而来在程序完成搬家、PC还没有跳到SDRAM之前启动代码里绝对不能用位置有关的绝对地址访问来获取代码段内的数据和函数入口。需要用到真实地址的地方要么用相对跳转要么用adr临时计算。另外启动代码里经常看到一句话请确保前面这几条指令是位置无关的。这句话的含金量就在这一节一旦你在搬移代码里用了ldr pc, XX的方式跳转CPU就会立刻切到链接地址执行如果那边的代码还没搬完结果就是跳进空地。3. 手写重定位汇编从Flash搬到SDRAM的完整流程3.1 链接脚本改一处整个地址空间全变重定位先要确定目标地址。这个目标地址一般在链接脚本linker script里设定。学习阶段我用的链接脚本长这样SECTIONS { . 0x30000000; .text : { *(.text) } .rodata : { *(.rodata) } .data : { *(.data) } __bss_start .; .bss : { *(.bss) *(.COMMON) } __bss_end .; }脚本里的. 0x30000000;就是设定整个程序的链接基址后面的所有段都从这开始排布。这里的地址必须和实际SDRAM的物理地址对应上否则程序即使被搬过去也会因为MMU/映射关系不对继续跑飞。链接脚本还会顺带生成两个符号__bss_start和__bss_end用于在汇编里访问BSS段的边界。BSS段专门存放未初始化的全局变量和静态变量C标准要求它们在程序启动时值为0后续我们清BSS就要用到这两个边界。3.2 搬运代码逐行拆解源地址、目标地址、终止条件我最初照抄了一段搬运代码结果发现连目标地址都搞错了折腾了很久。后来自己一步步拆才明白每一行在做什么。一个典型的搬运代码如下 检查是否已经在链接地址运行 adr r0, _start r0 当前真实运行地址 ldr r1, _start r1 链接地址 cmp r0, r1 beq skip_copy 相等说明已经在SDRAM目标地址跳过 准备搬运 ldr r1, __bss_start r1 搬运目标终点链接地址中的BSS起点 ldr r2, __bss_start ldr r0, _start r0 目标起始地址实际上用adr取当前地址等等上面这段我故意先写了个有问题的版本因为这是我最初犯的错。正确的思路应该是 若已经运行在链接地址直接进入清BSS流程 adr r0, _start r0 当前代码实际所在地址加载地址 ldr r1, _start r1 目标起始地址链接地址 cmp r0, r1 beq skip_copy 计算要拷贝的字节数 ldr r2, __bss_start r2 链接地址中BSS起点 sub r2, r2, r1 r2 代码段 只读段 数据段总大小 copy_loop: ldr r3, [r0], #4 从源地址取4字节r0 4 str r3, [r1], #4 写入目标地址r1 4 subs r2, r2, #4 长度减4 bne copy_loop 不为0继续循环 skip_copy:这里最需要理清的是三个指针r0源地址起始时通过adr r0, _start获取当前真实运行地址这样无论程序在Flash还是SRAM都能指到正确的代码起点。r1目标地址起始时通过ldr r1, _start获取链接地址。注意这里用ldr反而是正确的因为我们恰恰需要那个写死的绝对地址。r2拷贝字节数由链接脚本里的__bss_start减去_start得到这是代码段只读段数据段的体积之和。这个值在编译链接时就已经确定和程序在哪运行无关。注意搬运时用subs r2, r2, #4递减字节数而不是拿目标地址和某个固定地址比较。因为源地址和目标地址可能相差很大但拷贝长度是固定的用地址比较来作为循环条件很容易因为指针偏移没有同步而陷入死循环。我当时踩过一个特别隐蔽的坑ldr r3, [r0], #4和str r3, [r1], #4后面的递增符号#4不能丢一旦丢了每次循环都在同一个地址读写程序看起来搬运了很多次实际啥也没搬。这种死循环还特别难发现因为LED或者串口都不一定报错。3.3 清BSS与栈指针搬完不能直接跳main程序搬完之后CPU其实还停在Flash的低地址区域执行。这个时候不能急着跳回SDRAM里跑C代码还有两件事必须做清BSS、设栈。清BSS的原因是搬移的目标区域是SDRAM上电后里面是随机数据。而C语言默认未初始化的全局变量和静态变量为0很多编译器和运行库的启动代码都不做这一保证如果程序里某个全局标志位本来就指望初始值是0不清BSS就极大概率踩雷。skip_copy: ldr r0, __bss_start ldr r1, __bss_end mov r2, #0 clear_loop: cmp r0, r1 bhs clear_done str r2, [r0], #4 b clear_loop clear_done: 设置栈指针然后才能进入C环境 ldr sp, stack_top这里cmp r0, r1用到了BSS段的上下界r0从起点开始r1指向终点。循环把每4字节写成0。bhs无符号大于等于判断的是无符号比较避免出现负数比较的麻烦。栈指针必须在进入C函数之前设置好这一点怎么强调都不过分。C编译器生成的函数第一条指令往往就是压栈操作如果你的sp还是复位后的初始值任何一次函数调用都可能往错误的内存写数据。栈顶地址一般是SDRAM的最高地址比如0x34000000处。具体的栈大小可以根据程序需求调整常见做法是在链接脚本里定义一个符号. ALIGN(4); stack_top . 0x10000; /* 预留64KB栈空间 */3.4 从Flash切到SDRAM为什么不是b/bl而是ldr pc搬运和清BSS都在Flash/SRAM里执行完了最后一步是跳到SDRAM中的main函数。这一步初学者包括我最常犯的错误是顺手写成bl main结果程序在SDRAM里跑得乱七八糟根本无法解释为什么。原因在指令本身的跳转机制b和bl是相对跳转指令机器码里存放的是当前指令地址到目标地址的偏移量。这个偏移量在链接时是按照链接地址计算的。如果程序在Flash中运行时执行bl mainCPU的PC是Flash低地址加上链接器计算好的偏移这个偏移是基于SDRAM链接地址算出来的结果会跳到Flash低地址 偏移而不是SDRAM里的main。举个例子main的链接地址是0x30000010当前bl main这条指令的链接地址是0x30000000那么偏移是0x10。可运行地址是0x00000000执行bl时目标变成0x00000010也就是Flash里第16字节处根本不是main。正确做法是切换到SDRAM时用绝对跳转ldr pc, mainldr pc, main会把main的链接地址直接加载到PCCPU从此在SDRAM地址运行之后再调用任意C函数所有相对跳转都在SDRAM内部进行偏移关系就正确了。提示如果你看到某份代码在搬运后用bl main也能正常工作大概率是它把链接基址恰好选到了和Flash映射的地址一致或者踩了别的巧不属于通用写法。通用、稳妥且好理解的方式就是ldr pc, main。4. 重定位之后的三个后续坑向量表、栈与重复搬移4.1 中断向量表要不要跟着程序搬家代码本身搬到SDRAM了但CPU发生中断时硬件会自动从异常向量地址通常是0x00000000取第一条指令也就是说异常向量表必须在0地址处有效。这就出现一个很容易被忽略的问题如果你的向量表随着代码段被搬到了SDRAM而0地址处的SRAM里还留着旧代码或什么都没留中断一旦发生CPU就会从0地址取到完全无关的数据直接进错分支。常见解法是向量表本身保持在0地址的SRAM里但向量表中对应的处理函数地址在代码搬移后要能指向SDRAM中的真正handler。以最常见的向量表写法为例_start: b reset 0x00 复位 ldr pc, undefined_handler 0x04 未定义指令 ldr pc, swi_handler 0x08 软中断 ldr pc, prefetch_handler ldr pc, abort_handler ldr pc, irq_handler ldr pc, fiq_handler这里第一条b reset是位置无关的相对跳转没问题。但后面的ldr pc, handler是位置有关的绝对跳转它把handler的SDRAM地址直接写入PC这正是重定位完成后中断能正确进入SDRAM handler的关键。如果向量表在低地址SRAM里程序搬到SDRAM后表中的ldr pc, handler一如既往地加载SDRAM地址中断就能正常工作。所以结论是不是所有代码都要求搬到SDRAM才正确中断向量表只要在0地址可读且它的跳转目标是绝对地址就不一定要跟着搬。关键是要保证0地址处的数据有效并且在重定位前后都指向正确的handler。如果你把向量表也链接到SDRAM并准备搬过去那必须借助对应的存储器重映射机制让0地址在MMU映射下指向SDRAM中的表项否则中断会从物理0地址取到旧值。4.2 栈指针的位置和大小C语言的第一道安全线搬完代码、清完BSSsp设置的地方还有一个魔鬼细节异常模式下的sp和普通模式的sp不是同一个物理寄存器。ARM处理器在FIQ、IRQ、SVC、ABT、UND等异常模式下各自有独立的sp寄存器。如果只给SVC模式设置了sp就调用函数并开了中断一旦IRQ发生处理器切换到IRQ模式IRQ的sp还是未知值中断处理函数第一句压栈就会写飞。正确做法是进入C代码前把需要用到的每个异常模式都设置好独立的栈 切换到IRQ模式设置sp mrs r0, cpsr bic r0, r0, #0x1f orr r0, r0, #0x12 msr cpsr_c, r0 ldr sp, irq_stack_top 切换回SVC模式设置sp mrs r0, cpsr bic r0, r0, #0x1f orr r0, r0, #0x13 msr cpsr_c, r0 ldr sp, svc_stack_top栈的方向是向低地址生长的所以栈底要放在内存高地址。多个栈共存时注意不要让各自的栈区重叠。我在某个项目里就吃过亏IRQ栈设得离SVC栈太近中断一多栈直接压进了对方的地盘最后程序在随机时刻崩溃。合理的做法是给每个模式预分配一块足够大的独立区域并且在链接脚本里把空间预留出来。4.3 重复执行重定位代码会怎样运行地址判断如何避免二次搬运很多人写完重定位代码后从来没想过一个问题这段代码在程序启动时执行一次但如果程序被异常复位、或者从某种低功耗状态唤醒又从头执行了会不会再搬一次再搬一次本身不会把代码搬坏因为源和目标的内容一样重复赋值覆盖写相同的数据是幂等的。真正要命的是如果第二次进入时源地址的计算采用了错误的方式。比如你用adr r0, _start获取源地址此时程序已经运行在SDRAM中_start的实际地址就是0x30000000目标地址也是0x30000000cmp r0, r1会相等于是跳过了搬运。所以那个判断语句其实是关键的保险。但这个判断不能只靠看起来相等来保证。如果你的启动代码在重定位前先执行了一堆位置有关的跳转那可能还没走到判断就已经跑飞了。所以启动代码的前几十条指令必须全部是位置无关的或者至少那些绝对引用的目标已经在当前空间里存在。另一个实际问题是搬移的长度如果用的是链接地址间的差值那么在程序已经运行在链接地址时这个长度同样是可用的——但因为你已经跳过搬运长度有没有都无所谓。这个判断逻辑的严谨性直接影响程序在后期的可靠性我对它的理解是重定位代码的入口必须天然支持原地运行和异地运行两种状态并能正确区分。5. 验证与调试如何确定重定位真的成功了5.1 最直接的验证打印两个地址学了串口之后验证重定位是否成功有一个非常朴素的办法在搬运之前用adr取一次_start的地址在搬运完成后再取一次把两个值通过串口打印出来。如果串口输出两个地址显示不同并且搬运后的地址落在SDRAM区域内再配合程序功能正常基本能说明重定位生效了。我当时打印的内容大致是// 伪代码示意 volatile unsigned int *addr (unsigned int *)0x30000000; printf(link addr: 0x%08x\n, (unsigned int)main); printf(run addr: 0x%08x\n, get_running_addr());两次打印的地址差异能直观地让你感受到同一份代码链接地址和运行地址确实不同。5.2 改变加载基址做对照实验更严格一点可以做一个对照实验把链接脚本里的基址从0x30000000改成0x30040000重新编译后再次烧录运行。如果程序仍然能正常运行说明重定位代码对目标地址的适配是正确的如果运行后全局变量、函数跳转全部错乱说明搬运代码里可能有硬编码地址或长度计算错误。这个方法在排查为什么全局变量全错时尤其好用。只要你把链接基址一改问题复现得极其稳定定位思路就清晰了。反之如果改了链接地址程序依然活蹦乱跳那说明重定位确实起到了作用——程序已经被搬到新的链接址上跑了。5.3 调试中常用的三板斧与后续扩展方向调试重定位问题我的三板斧是第一在搬运目标地址预先填一个特定模式比如0xDEADBEEF搬运完成后检查目标区域的第一个word是否变成了代码段第一条指令这个检查能确定搬运有没有真的发生以及有没有搬错位置。第二在搬移代码的清BSS、设栈、跳转main这些关键节点分别通过点亮不同的LED或输出不同字符来标记执行路径。哪一步没执行到灯和串口会非常直白地告诉你。用led亮到第几盏来标记代码进度在裸机调试里又土又靠得住。第三如果有调试器或者仿真器直接在搬运完成、main入口处打断点查看PC是否等于main的链接地址同时查看sp是否已经指向预设的栈顶。这个验证最准确比任何打印都可信。学到这里重定位这个坎就算正式迈过去了。它也是后续很多内容的地基如果你想在裸机上跑简单的RTOS任务栈、任务切换本质上就是各种地址搬运的组合如果你想了解Linux内核里的自解压过程思路和这套启动代码如出一辙。理解了重定位你基本就理解了程序真正在哪里运行这件事的底层逻辑。最后再分享一个我实际调试中的体会面对重定位相关问题别急着改代码先在纸上把三个地址标出来——加载地址在哪、链接地址在哪、现在PC在哪——绝大多数问题一画图就现形了。希望这篇总结对你也有同样的效果。