简介:一份围绕 ARM 架构启动流程的 doc 文档,面向嵌入式初学者和从事底层移植的工程师,梳理 Bootloader 从上电到调用主程序之间必须完成的初始化工作。内容按异常向量表、存储器系统、堆栈、特殊端口与设备、应用程序运行环境五个环节展开:既说明 ARM 要求向量表固定在 0x00 起始的 8×4 字节空间、每条表项只能容纳一条跳转指令的缘由,也讨论存储器类型、接口时序、总线宽度配置与地址重映射的取舍,并给出切换 CPSR、逐模式设置 SP 的汇编片段及注意事项。压缩包内只有 1 个 doc 文件,约 37KB,篇幅紧凑却保留大量代码示例,如向量表跳转定义、IRQ 与 FIQ 堆栈初始化、从 ROM 向 RAM 搬移数据后跳转 C_Entry 等关键片段。已有 268 人学习,适合用来理清启动代码的执行顺序、比对自家平台的初始化差异,也可作为移植与调试时的对照笔记。
1. 上电之后、main 之前:这几百行汇编到底在替谁完成初始化
手上一块 ARM 开发板,上电后串口没有任何输出,调试器连上去发现 PC 在 0x00 附近反复跳转——这种场面做过裸机 bring-up 的人都见过。问题多半不在应用代码,而在 bootloader 开头那几百行汇编:异常向量表没摆对位置、存储器控制器还停在上电默认值、栈指针指向一段根本没初始化的 RAM。这份 ARM 系统初始化过程资料把启动链拆成六段:异常向量表、存储器系统、堆栈、特殊外设与运行环境、处理器模式切换、跳到主程序,属于能对着芯片手册一步步落地的骨架。它适合刚接手裸机启动的工程师,也适合写了很多年 C、却一直没搞清 main 之前发生了什么的开发者。下面按可复现的顺序拆开讲,顺带标注几个只有真正上板才会暴露的坑。
2. 异常向量表与启动文件:8 条跳转指令怎么排、链接地址怎么定
启动代码的第一件事不是配置时钟,也不是点灯,而是让处理器在发生任何异常时都能找到该去的地方。ARM 的异常响应是硬件行为:一旦有异常被触发,内核直接把 PC 强制设为该异常对应的向量地址,同时把当前 CPSR 存进对应模式的 SPSR,把返回地址存进 LR。这个过程中软件没有任何干预机会,所以向量表必须在异常发生之前就已经就位,而且位置不能错。很多启动失败案例最后追下去,都是向量表被链接到了错误的地址,处理器跳到 0x00 执行时那里根本没有有效指令,取指直接进入未定义状态。
2.1 为什么向量表必须是 8×4 字节、还必须从 0x00 起
ARM 架构规定异常向量表占据从 0x00 开始的连续 8 个字,依次是复位、未定义指令、软件中断、指令预取中止、数据中止、保留、外部中断 IRQ、快速中断 FIQ。每个入口只有 4 字节,正好放一条 ARM 指令,所以入口处一般是一条跳转指令,把执行流引到存储器其他位置的真正处理函数。8 个入口的顺序不能调换,这是硬件写死的映射关系。
优先级也是固定的:复位最高,其次是数据中止、FIQ、IRQ、预取中止、SWI、未定义指令。FIQ 排在向量表最后一位(0x1C)不是随意安排,FIQ 处理函数可以直接放在向量表之后连续存放,省掉一次跳转,这是 ARM 为快速中断特意留的设计余量。真正做产品时,如果 FIQ 对延迟极其敏感,可以把 FIQ 处理代码紧贴 0x1C 放置,让向量表最后一条指令直接落进处理逻辑。
需要澄清一个常见误解:向量表不一定非在 0x00。部分 ARM 核支持高向量地址 0xFFFF0000,通过协处理器 CP15 的控制寄存器 V 位切换。但绝大多数裸机项目用不到这个特性,且切换后所有异常入口整体搬迁,调试时容易搞混,我一般不动它。
还有一个必须记住的边界:向量表里的B指令是 PC 相对跳转,ARM 状态下立即数 24 位、符号扩展后左移 2 位,跳转范围是 ±32MB。启动代码放在 Flash 起始、处理函数也在附近时完全够用;一旦程序规模大到处理函数被链接到 32MB 之外,这条B就会静默跳到错误地址,编译器不会报错,只会在运行时出错。
2.2 一份能直接抄的向量表与复位入口
下面这段用 GNU 汇编语法写,.section .vectors单独成段是为了让链接脚本能把它精确固定在 0x00。如果用的是另一套汇编器(AREA / ENTRY 那种写法),指令序列完全一致,只是段声明和符号导出语法不同。
.syntax unified .arm .section .vectors, "ax" .global _start _start: b reset_handler @ 0x00 复位 b undef_handler @ 0x04 未定义指令异常 b swi_handler @ 0x08 软中断 b prefetch_handler @ 0x0C 取指中止 b data_handler @ 0x10 数据中止 b . @ 0x14 保留入口,原地死循环 b irq_handler @ 0x18 普通外部中断 b fiq_handler @ 0x1C 快速中断 .text reset_handler: @ 第一步:屏蔽 IRQ/FIQ,并把模式切到 SVC mrs r0, cpsr orr r0, r0, #0xC0 @ I=1、F=1,先关中断再谈别的 bic r0, r0, #0x1F @ 清模式位 M[4:0] orr r0, r0, #0x13 @ 0b10011 -> SVC 模式 msr cpsr_c, r0 @ 第二步:关看门狗(寄存器地址按具体芯片手册替换) ldr r1, =WDT_CTRL_REG mov r2, #0 str r2, [r1] @ 后续:存储器初始化 -> 栈初始化 -> 段搬移 -> 跳 C b memory_init代码里有三个地方值得单独说明。b .这条看起来莫名其妙,实际是保留入口的标准写法——分支到自己的地址,形成原地死循环,比留空(可能被填成 0x00000000,等价于跳回复位)更安全。msr cpsr_c, r0里的_c表示只写控制位域(低 8 位),不会顺手把条件标志位改掉,这是启动阶段必须注意的细节。orr r0, r0, #0xC0把 IRQ 和 FIQ 屏蔽位同时置 1,原因是此时栈还没建立,任何一个中断进来都会把返回地址写进一个不存在的栈里,后果不可预测。
如果跳转目标确实超出 ±32MB,把b handler换成ldr pc, =handler。代价是每个入口占两个字,向量表从 32 字节膨胀到 64 字节,并且需要字面量池配合;换来的是全 32 位地址空间任意跳转。
2.3 链接脚本与入口地址:把向量表固定在 0x00
向量表写对了只完成一半,还得保证它被链接到 0x00。链接脚本要显式把.vectors段放到 ROM 起始,并且用KEEP防止它被垃圾回收删掉——这个坑很隐蔽,开了--gc-sections之后,如果没有任何代码显式引用_start,整个向量段会被判定为无用直接删掉,生成的镜像开头就是别的代码。
MEMORY { ROM (rx) : ORIGIN = 0x00000000, LENGTH = 1M RAM (rwx) : ORIGIN = 0x40000000, LENGTH = 64K } SECTIONS { .vectors : { KEEP(*(.vectors)) } > ROM .text : { *(.text*) *(.rodata*) } > ROM .data : { __data_start = .; *(.data*) __data_end = .; } > RAM AT > ROM __data_load = LOADADDR(.data); .bss : { __bss_start = .; *(.bss*) *(COMMON) __bss_end = .; } > RAM __stack_top = ORIGIN(RAM) + LENGTH(RAM); }> RAM AT > ROM这行的意思是:.data段的运行地址在 RAM,但加载地址(镜像里实际存放的位置)在 ROM,启动代码要把这段从 ROM 搬到 RAM。LOADADDR(.data)取出加载地址赋给__data_load,汇编里的搬移循环就靠这个符号定位源地址。__stack_top直接算到 RAM 末端,省掉在汇编里硬编码地址。
验证方法很简单:编译完用arm-none-eabi-objdump -h firmware.elf看段分布,.vectors的 VMA 必须是 0x00000000;再用objdump -d反汇编前 32 字节,应该能看到 8 条b指令依次排列。这一步花两分钟,能省掉后面几小时的盲目调试。
3. 存储器系统初始化:类型、时序、总线宽度与地址重映射
原始资料里把存储器控制器写成“MMU”,这里必须纠正一下:MMU 是内存管理单元,负责虚拟地址到物理地址的转换和访问权限检查;存储器控制器是总线上的一个外设,负责产生 SRAM、Flash、DRAM 的读写时序。两者完全不是一回事,启动阶段通常先配存储器控制器让 RAM 可用,MMU 要到跑操作系统时才启用。搞混这两个概念,会导致在错误的寄存器上反复试探。
3.1 存储器类型与端口的对应关系
系统里通常同时挂几类存储器,控制器对它们的管理方式差别很大。SRAM 和 NOR Flash 属于静态存储器,接口简单,地址线和数据线直接对应,读写只取决于访问时间,可以共用同一套片选和总线时序模型。DRAM 是动态存储器,需要周期性刷新防止电容漏电丢数据,地址线还做了行、列复用(RAS/CAS 分时送出),所以必须占用独立的控制器通道和一套专门的刷新逻辑。
| 存储器类型 | 接口特征 | 是否需要刷新 | 初始化关键项 |
|---|---|---|---|
| SRAM | 地址数据线直连,异步 | 否 | 总线宽度、等待周期 |
| NOR Flash | 类 SRAM,读快写慢 | 否 | 等待周期、写保护 |
| SDRAM | 行列为复用,同步 | 是 | CAS 延迟、刷新计数、Bank 配置 |
配置前先画一张存储器映射表:哪块器件接哪个片选、起始地址多少、容量多大、数据宽度几位。这张表决定了后面所有寄存器参数的取值,也决定了链接脚本里 ROM 和 RAM 的 ORIGIN。我习惯把它写在启动文件顶部的注释里,几个月后回头改板子时,比翻原理图快得多。
3.2 时序与总线宽度:两个最容易设错的参数
时序配置的核心是把器件手册给的纳秒数换算成总线时钟周期数。静态存储器的等待周期按这个式子算:
等待周期 = ceil(器件访问时间 / 总线时钟周期) - 1举个具体例子,总线跑 100MHz,一个周期 10ns。挂一颗 70ns 的 NOR Flash,ceil(70/10) - 1 = 6,配 6 个等待周期。挂一颗 12ns 的 SRAM,ceil(12/10) - 1 = 1,配 1 个等待周期。计算时向上取整是必须的,向下取整会让实际时序比器件能力更紧,常温下可能跑得通,一到高低温或者批次差异就开始随机出错,这类问题极难定位。
SDRAM 不按等待周期算,它由 CAS 延迟、tRCD、tRP 这几个以周期为单位的参数描述,直接照手册的时钟频率档位查表填写。刷新计数器单独算:
刷新计数值 = 刷新周期 / 行数 / 总线时钟周期典型值是 64ms 内刷完 4096 行,单行间隔 15.6µs,总线周期 10ns 时计数值约 1560。
总线宽度是另一个高发错误点。控制器支持 8/16/32 位,但硬件连接方式会反过来约束软件。16 位器件接在数据总线的低 16 位上,处理器的 A1 要接到器件的 A0,因为处理器按字节编址、按半字访问时地址最低位恒为 0。如果硬件工程师按 32 位习惯把 A0 直连过去,软件按字节读写就会整体错位一个字节,读回来的数据看着像乱码,其实是隔壁字节。
@ 存储器控制器配置示意,寄存器名与位域按具体芯片手册替换 ldr r0, =MEM_CTRL_BASE @ BANK0:16 位宽、6 个等待周期、使能片选 ldr r1, =0x00000006 @ 位域含义:宽度编码 | 等待周期数 str r1, [r0, #BANK0_TIMING] @ BANK1:32 位宽、1 个等待周期 ldr r1, =0x00000011 str r1, [r0, #BANK1_TIMING] @ SDRAM 刷新计数 ldr r1, =1560 str r1, [r0, #REFRESH_CNT] @ 全部配完再统一使能,避免半配置状态被访问 ldr r1, =0x00000001 str r1, [r0, #CTRL_ENABLE]这段的顺序有讲究:所有 bank 的时序和宽度先配好,最后一步才打开控制器使能位。反过来的话,使能瞬间控制器就用默认参数去访问器件,某些 DRAM 在这种状态下会直接锁死,只能断电重来。至于为什么每条配置都写成常量再str,而不是用位操作逐位设——启动早期可读性优先,参数都摆在一起,对照手册核对时不容易漏。
保守起步是个好习惯:第一次点亮时把等待周期配到明显偏大(比如算出来是 3 就先填 6),确认能稳定读写之后再逐级往下压,每压一级跑一遍全片读写测试。这样即使出问题,也知道是哪一步引入的。
3.3 地址重映射:切换瞬间的执行流必须连续
地址重映射是用软件改变某块存储器物理地址映射关系的功能,很多面向中高端应用的控制器都带。做这件事的动机有两个:一是异常频繁时从 Flash 取指太慢,把 RAM 映射到 0x00 后中断响应明显变快;二是上电时 0x00 被片内 Boot ROM 占据,用户程序在别处跑起来之后,需要把 RAM 换到 0x00 才能接管异常向量。
重映射最容易出错的地方在切换那一瞬间。写重映射寄存器之后,0x00 处的物理内容立刻就变了,如果 PC 正好还在旧映射区域的顺序执行路径上,下一条取指就会落到一个完全不同的地方。正确做法是先把向量表复制到目标 RAM 的起始位置,关中断,写重映射寄存器,然后立刻用绝对跳转(bx或ldr pc, =label)跳到 RAM 中已经准备好的下一条指令,绝对不能指望一条b顺序往下走。
@ 1) 把向量表复制到 RAM 起始 ldr r0, =RAM_BASE ldr r1, =__vectors_start ldr r2, =__vectors_end copy_vectors: cmp r1, r2 ldrcc r3, [r1], #4 strcc r3, [r0], #4 bcc copy_vectors @ 2) 关中断,防止切换窗口里来异常 mrs r4, cpsr orr r4, r4, #0xC0 msr cpsr_c, r4 @ 3) 写重映射寄存器 ldr r5, =REMAP_REG mov r6, #1 str r6, [r5] @ 4) 绝对跳转,进入新映射下的执行流 ldr r7, =after_remap bx r7 after_remap: @ 这里的 0x00 已经是 RAM,可以重新安装向量表第 3 步和第 4 步之间不能插入任何函数调用或栈操作,因为栈指针此刻可能还指向旧映射区域。这段代码通常手写并逐条对照反汇编确认,编译器优化有时会把bx前后的指令重排,用汇编写能完全控制顺序。
对于不支持重映射的处理器,等效方案是代码搬移加跳转:把整个运行镜像从 Flash 复制到 RAM,然后跳到 RAM 里的入口。搬移时要注意方向,源地址低于目标地址时从高地址往低地址复制,避免还没读的数据被覆盖。
4. 七种模式的堆栈与运行环境:汇编到 C 的最后一段路
堆栈初始化看起来是最机械的一步,实际上是最容易埋雷的一步。原因在于 ARM 的七种运行模式各有独立的物理寄存器组,而编译器生成的 C 代码默认假设自己有一个可用的栈。栈没建好之前调用任何函数、甚至触发一次中断,都会把数据写到随机地址上,现象是程序跑着跑着莫名复位,用调试器看又一切正常。
4.1 为什么每个模式都要独立 SP
ARM 有七种模式:用户、系统、管理(SVC)、中止(ABT)、未定义(UND)、中断(IRQ)、快速中断(FIQ)。寄存器 R0 到 R12 在所有模式间共享,但 R13(SP)和 R14(LR)是分组的,一共有六组物理寄存器:用户模式和系统模式共用同一组,其余五种异常模式各有一组独立的 SP 和 LR。
这个设计的意义在于异常响应可以做到零开销上下文切换。中断发生时,处理器自动切到 IRQ 模式,SP 自动变成 IRQ 专属的那一个,处理函数可以直接压栈保存现场,完全不用管被打断的任务用的是哪个栈。如果所有模式共用一个栈,中断处理就得先把用户栈指针保存到某个全局变量里再换栈,多出来的指令在实时性要求高的场景里是不能接受的。
代价是软件必须为每个模式单独初始化 SP。漏掉任何一个,该模式一旦被触发就是灾难:IRQ 没设栈,第一个外部中断进来就把返回地址写到 0 地址去;UND 没设栈,执行一条协处理器指令就挂死。
4.2 汇编实现与用户模式为什么必须放最后
初始化的方法是修改 CPSR 的模式位切换当前模式,设完 SP 再切下一个。模式位编码是固定的:用户 0x10、FIQ 0x11、IRQ 0x12、SVC 0x13、中止 0x17、未定义 0x1B、系统 0x1F。
@ 入口前提:已经处于 SVC 模式且中断已关 mrs r0, cpsr bic r0, r0, #0x1F @ r0 保存"清干净模式位"的 CPSR 模板 orr r1, r0, #0x12 @ IRQ msr cpsr_c, r1 ldr sp, =__irq_stack_top orr r1, r0, #0x11 @ FIQ msr cpsr_c, r1 ldr sp, =__fiq_stack_top orr r1, r0, #0x17 @ ABT msr cpsr_c, r1 ldr sp, =__abt_stack_top orr r1, r0, #0x1B @ UND msr cpsr_c, r1 ldr sp, =__und_stack_top orr r1, r0, #0x1F @ SYS(与 USR 共用寄存器组) msr cpsr_c, r1 ldr sp, =__sys_stack_top orr r1, r0, #0x13 @ 回到 SVC,本模式栈最后确认一次 msr cpsr_c, r1 ldr sp, =__svc_stack_top这段代码的关键是 r0 这个模板:先把模式位清掉但保留其他控制位(中断屏蔽、Thumb 状态等),后面每次只用orr加上目标模式编码,避免反复读 CPSR 导致状态位被意外改动。ldr sp, =符号展开成从字面量池取地址,链接脚本里给每个符号预留了对应的栈区。
用户模式必须放在最后,因为从 SVC 切到用户模式后,处理器就失去了直接切回特权模式的能力,只能通过软中断陷入。一旦提前切进去,后面的.data搬移、.bss清零、外设寄存器访问全部做不了。常见做法是把设置用户栈放到启动流程的倒数第二步,设完直接进 C 入口,应用代码以用户身份跑起来,需要特权操作时走 SWI。
这里有个设计取舍值得提一句:也可以完全不进用户模式,让 C 代码一直跑在 SVC 下,等应用需要时再主动切。裸机项目里这种做法很普遍,好处是调试方便、不用写 SWI 分发;坏处是应用代码一旦写错地址就会直接踩坏系统区,没有任何隔离。
4.3 .data 搬移、.bss 清零与跳到 C 入口
栈建好之后还差一步才能调 C 函数:全局变量的初值。C 语言规定初始化为非零值的全局变量存放在.data段,镜像烧在 Flash 里,运行时必须在 RAM 里有一份可写副本;未初始化或初始化为零的变量在.bss段,不占镜像空间,但必须清零,否则拿到的是上电后的随机值。
@ 从 ROM 把 .data 段搬到 RAM ldr r0, =__data_start ldr r1, =__data_load ldr r2, =__data_end 1: cmp r0, r2 ldrcc r3, [r1], #4 strcc r3, [r0], #4 bcc 1b @ .bss 段清零 ldr r0, =__bss_start ldr r1, =__bss_end mov r2, #0 2: cmp r0, r1 strcc r2, [r0], #4 bcc 2b @ 交接给 C ldr r0, =c_entry bx r0三个符号__data_start、__data_load、__data_end全部由链接脚本产生,汇编里不写死任何地址——这一点在换板子、改存储器容量时能省掉大量返工。循环用ldrcc/strcc/bcc的条件执行,把比较和搬运合在一条流水线里,比写beq分支紧凑,也不影响可读性。
最后一步用bx而不是b。两者都跳到目标地址,区别在于bx会根据目标地址的最低位决定切换到 ARM 还是 Thumb 状态:bit0 为 1 表示目标函数是 Thumb 代码。C 编译器生成的函数符号在链接时会带上这个位,用bx能自动适配;用b跳进 Thumb 函数,处理器仍按 ARM 状态译码,指令边界错位,跑出来的行为完全不可预测。这是从汇编进 C 最隐蔽的一类翻车。
跳转之前还有两个前置条件要满足:C 编译器遵循的调用约定要求 SP 是 8 字节对齐,栈顶符号必须按 8 字节取整;.data、.bss必须已经处理完毕,因为 C 入口函数一开始就可能访问全局变量,而main之外的构造函数、库初始化同样依赖这些段。
5. 避坑与排查:启动就跑飞时的五条现场记录
启动代码的特点是失败现象高度相似——不亮灯、串口无输出、调试器连不上,但成因可能分布在完全不同的环节。下面五条是实际调试中反复遇到的,每条按现象、原因、解决三段写,照着顺序排查能覆盖大部分情况。
5.1 向量表与链接地址相关的两条
第一次碰到:调试器能连上,但 PC 一直在 0x00 到 0x20 之间来回跳,程序永远进不到主逻辑。
原因是向量表被链接到了错误的地址。芯片从上电复位开始,有一段片内 Boot ROM 占据 0x00,用户镜像实际从 0x1000 或更靠后的位置开始,而链接脚本里 ROM 的 ORIGIN 写的是 0x00000000。结果向量表里那 8 条b指令落在了 Boot ROM 区域的地址上,复位后处理器跳到一个没有有效指令的地方,触发了未定义指令异常,又跳回 0x04,形成死循环。
解决办法是先确认芯片的启动介质和启动地址映射关系,把链接脚本里的 ROM ORIGIN 改成真实的加载地址;如果芯片需要在启动早期做地址重映射才能接管 0x00,就在启动代码里按 3.3 节的流程处理。验证手段是看 map 文件里.vectors的 VMA,以及在调试器里 dump 0x00 开始的 32 字节,确认内容和镜像里的向量表一致。
第二次:关中断时程序跑得好好的,一开中断就飞或者直接复位。
原因可能有两个。一是向量表的 IRQ 入口用了b,而真正的处理函数被链接到了 ±32MB 之外,跳转指令静默溢出,落到随机地址。二是向量表被编译器或链接器插入了额外内容,比如加了ENTRY标记以外的对齐填充,导致 8 个入口不再连续占据 0x00 到 0x1C。
前者把b handler改成ldr pc, =handler,后者用反汇编逐字检查前 32 字节的结构。对于 IRQ 返回还有一个高频错误:中断处理函数结束时应该用subs pc, lr, #4而不是普通的movs pc, lr,因为 IRQ 模式下 LR 保存的是被打断指令的下一条地址,必须减 4 才能正确返回。这个减 4 漏掉,现象就是中断返回后从中间某条指令继续执行,看起来像随机跑飞。
5.2 存储器与重映射相关的两条
第三次:RAM 读写测试能过,但写到某个特定地址就进数据中止,或者读回来的值只有低 8 位是对的。
先说数据中止这一种。多半是存储器控制器的时序参数设得太激进:等待周期按器件最快档算,甚至直接填了 0,常温实验室里能跑,一到板子批次差异或者温度变化就开始出问题。排查方法是先把所有等待周期翻倍跑一遍读写测试,如果问题消失,说明是时序余量不足,再逐级往回收。DRAM 还有另一种可能,刷新计数器没配或者配错,数据在两次访问之间就漏掉了,表现为“写进去的值隔一会儿读出来变了”。
读回值只有低 8 位正确,基本可以确定是总线宽度配置和硬件连接不匹配。控制器配成 32 位,硬件实际是 16 位器件接在低半部分,读一个 32 位字时高 16 位来自悬空的数据线,值不确定。解决要分两步:软件上把控制器的 bank 宽度改成 16 位,硬件上确认地址线的接法(16 位器件时处理器的 A1 接器件 A0)。用 0x55 和 0xAA 这两个值分别按字节、半字、字写入再读回,能快速判断错位发生在哪一级。
第四次:重映射寄存器写完,程序没跑到新地址,而是直接停住或者复位。
根因是执行流在映射切换的瞬间不连续。写入重映射寄存器后,0x00 处的物理内容就变了,如果此时 PC 还在按旧映射顺序往后取指,取到的指令完全不对。另一种情况是搬移代码时把正在执行的代码段覆盖了——比如把源区放在目标区上方,从低地址往高地址复制,还没读的指令就被前面的写操作改掉了。
正确顺序固定为:在目标位置准备好向量表 → 关中断 → 写映射寄存器 → 立刻绝对跳转。搬移方向上,源地址低于目标地址时从高往低复制,反之从低往高,这是 memcpy 类操作的基本纪律。调试时可以在重映射前后各翻转一次 GPIO,用逻辑分析仪看波形,能明确区分“没执行到”和“执行了但跳飞了”。
5.3 堆栈与模式切换相关的一条,以及通用排查手段
第五次:全局变量初值不对,main里读到的数组全是零或者随机数;严重时进 C 函数第一条指令就崩。
前者是.data没搬或者搬错源地址。如果启动代码里__data_load的符号名和链接脚本定义的名称不一致,链接器不会报错,只会把一个未解析的符号解析成 0,搬移循环就从 0 地址开始读,读到的全是 Boot ROM 的内容。后者通常是栈的问题:栈顶没有按 8 字节对齐,或者栈区大小不够,函数一进去就溢出踩到了.bss。栈溢出最难查,因为踩坏的是别的变量,崩溃现场离真正的原因可能隔了几百条指令。
排查手段按成本从低到高排列。最便宜的是 GPIO 翻转:在启动流程的几个关键节点各翻转一次,用示波器或逻辑分析仪看哪一级没出去,能直接把问题范围缩到一个函数。其次是串口打印,但要注意串口初始化本身要用到栈和时钟,只能放在栈建立之后。第三是objdump反汇编加 map 文件交叉核对段地址和符号值。最后才是上 JTAG 单步,慢但能看到每一个寄存器。
栈的验证可以用哨兵值:初始化时把整个栈区填成 0xDEADBEEF,运行一段时间后扫一遍,看从栈底往上被改写了多少字节,实际用量一目了然。这个习惯我保持了很多年,比事后猜“是不是栈不够”高效太多。
6. 验证启动代码真的生效:反汇编、哨兵值与启动耗时测量
启动代码跑通不等于写对。能点亮灯只能说明执行流走到了点灯那一行,中间可能有若干配置项是错但恰好不影响当前路径的——比如某个 bank 的时序配错,恰好那颗器件容错性高;某个模式的栈没设,恰好那个异常从来没被触发。给启动流程补一套可重复的验证动作,比事后救火划算得多。
反汇编核对向量表是第一步,成本几乎为零:
# 确认 .vectors 段落在 0x00,且长度为 0x20 字节 arm-none-eabi-objdump -h firmware.elf | grep -A1 vectors # 反汇编前 32 字节,应该看到 8 条 B 指令依次排列 arm-none-eabi-objdump -d firmware.elf --start-address=0x0 --stop-address=0x20输出里如果看到 8 条b且目标地址都落在.text段范围内,向量表就没问题;如果阈值超出 ±32MB,反汇编注释会显示出异常的目标地址,一眼可见。
第二步用哨兵值量化栈用量。初始化时把每个模式的栈区填满固定模式,比如 0xDEADBEEF,运行完整业务流程后再扫一遍,统计从栈顶往下被改写的字节数:
| 检查项 | 预期结果 | 异常含义 |
|---|---|---|
| 栈区哨兵残留 | 剩余 ≥ 30% | 低于 30% 说明栈快溢出 |
.data段内容 | 与镜像一致 | 不一致说明搬移源地址错 |
.bss段内容 | 全零 | 有非零值说明没清零或被踩 |
| 各模式 SP | 落在各自区间内 | 落在外部说明切模式时漏设 |
第三步测启动耗时。在复位入口翻转一个空闲 GPIO,在进入 C 入口时再翻转一次,用逻辑分析仪量两个边沿的间隔,就得到从复位到 main 的纯耗时。如果系统里有自由运行的定时器,也可以在启动最开始读一次计数、进 main 前再读一次,把差值打到串口。这个数字在优化启动速度时非常有用——我曾经花了两天优化存储器初始化代码,最后发现真正的大头是.data段搬移,几 KB 的全局变量用字节循环搬,改成一个字一次搬之后直接省掉三分之二的时间。
从那以后,我每次接手新的启动代码,都强制先跑这三步验证:反汇编看向量表、哨兵值看栈、GPIO 打点看耗时。三步加起来不到半小时,却能把后面几天的玄学问题挡在门外。希望帮到你。
本文还有配套的精品资源,点击获取