资讯动态

深度解析ARM嵌入式启动流程:从向量表到main函数的关键链路

发布时间:2026/10/15 17:50:32 来源:尧图企业网站定制
在这篇第三篇学习总结里我打算把整个启动流程彻底说透。前两篇写的是开发环境怎么搭、GPIO和寄存器怎么操作但真正让我对ARM嵌入式开发有开窍感觉的反而是这个经常被忽略的启动文件——从汇编启动、设置栈到调用C语言这一小段代码决定了你的程序能不能活着见到 main。刚开始学的时候我有一个很大的困惑单片机上电之后凭什么知道第一条指令在哪为什么非要写一大堆汇编来启动C程序后来踩了不少板子起不来的坑才把这条链路完全看明白。这篇就把我自己的理解、验证过程和踩过的坑整理出来尤其适合刚学ARM汇编、翻开启动文件一脸茫然的同学。1. 为什么单片机要从汇编启动梳理上电到 main 的完整链路1.1 上电后的第一个瞬间硬件到底做了什么很多同学学STM32或其它Cortex-M内核芯片时第一个Demo往往是点灯编译器帮我们把一切细节都藏起来了。你按下下载按钮程序就跑起来了于是产生了一个错觉C语言入口 main 是天然存在的。实际上硬件根本不知道 main 在哪它只认两个地址。对Cortex-M内核来说上电复位后硬件自动做两件事从地址0x00000000处读取一个32位数据写入主栈指针 SP也叫MSP。从地址0x00000004处读取一个32位数据写入程序计数器 PC。这个从向量表第0项和第1项取SP、取PC的机制和很多人印象里的ARM7/ARM9不一样。ARM9复位后是从0x00000000开始取指令执行而Cortex-M是从向量表里取值来初始化SP和PC。差别看似不大但直接决定了启动文件的长相你的工程里必须有一个向量表并且这个向量表在链接后要被放到地址0x00000000或者被配置到其它基址后面会提VTOR。如果把向量表第一项写错、SP地址非法、第二项Reset_Handler地址没链接对那整个程序上电后就是起飞状态。我遇到过一种现象程序明明编译下载成功了但一复位就跑飞其实就是因为向量表第二项指向的位置不对PC跳到了随机地址。1.2 汇编启动文件的不可替代任务清单有人会问既然C语言能访问寄存器、能操作内存为什么启动代码不用C写理论上报错、刷寄存器也能凑合但有几个现实问题C语言运行环境本身就是靠汇编搭起来的编译器生成的C函数默认SP已经是合法的、BSS段已经被清零、全局变量初值已经被加载这些前提在你进入C main之前都不存在。你连栈都没设置好怎么调用函数这是一个先有鸡还是先有蛋的问题。所以启动文件在嵌入式ARM工程里至少要完成这几件事建立中断向量表其中第0项是栈顶地址第1项是复位处理函数地址。设置初始的栈指针也就是让SP指向一块合法的RAM区域。如果使用编译器C运行库把控制权交给库的启动代码如果裸机手写则自己完成BSS段清零、data段拷贝。最终跳转到 main并在 main 返回后提供死循环兜底。有些启动文件还会顺带配置VTOR寄存器、初始化浮点单元甚至提前开看门狗保护。它们看起来又多又杂但主线永远是上面那四条。把主线理清楚以后再去看不同厂商的启动文件会发现套路非常接近只是外设数量不同导致向量表长短不同而已。1.3 汇编和链接脚本的分工边界学启动文件的时候另一个容易混淆的概念是哪些活是汇编干的哪些活是链接脚本.ld或.sct文件干的链接脚本负责布局它决定向量表放在哪个段、RAM区域有多大、栈顶符号__StackTop到底是哪个地址。汇编负责搬运它用指令去读取这些由链接脚本提供的符号写入寄存器。比如链接脚本里定义了一个符号__StackTop汇编启动代码里用ldr r0, __StackTop; mov sp, r0就把这个符号的值拿过来了。我见过很多新手试图在汇编里直接写死栈地址比如ldr r0, 0x20001000。这本身不是不能用但一旦换了芯片、改了RAM大小整个工程就废了。正确做法永远是从链接脚本继承地址。代码里只写操作逻辑不写死地址。这一点值得一开始就养成习惯。2. 栈指针不是随便设的容量预估、对齐规则与放置策略2.1 SP的本质一块寄存器和RAM之间的缓冲区汇编语言里设置栈看起来只是一行mov sp, r0但理解栈的本质才能理解为什么这一行这么关键。C函数调用时局部变量、函数参数、返回地址、被中断保存的现场全部依赖于栈。ARM的栈是满递减的SP初始指向栈顶地址这个顶其实是内存的高地址每次压栈SP先减4再把数据写进去。整个过程不断向下增长一旦触到RAM底部或者其他数据区就会把别的变量踩坏。我在调试嵌入式程序时有相当一部分莫名其妙的bug比如某个全局变量突然变成0xFFFFFFFF、函数返回后PC错乱追根究底都是栈溢出或者栈指针初始化错误。所以设置栈不是一行形式主义代码而是给整个C程序划出了一块信任边界。2.2 栈到底该开多大一套可落地的估算方法栈容量开小了会溢出开大了浪费RAM。对于入门项目怎么估算一个比较直观的经验流程先确定你项目的RAM里静态分配的数据占了多少剩余空间就是栈和堆可以瓜分的。对裸机项目我一般把总RAM的剩余空间砍一半给栈剩下作为堆或备用再根据实际运行情况调整。但不是盲砍要脑子里过一遍栈开销开销来源大致字节数进入函数时的返回地址、寄存器保存16字节左右起局部变量数组或结构体视代码而定几十到几百字节都可能中断嵌套一层需要额外保存的现场几十字节函数调用嵌套深度逐层累加每层16字节起举个例子一个Cortex-M3内核的芯片内部SRAM有20KB。全局变量和静态缓冲用了大约8KB剩下12KB。你的主流程里有3层函数调用每层局部变量约40字节再算上可能发生的定时器中断、串口中断嵌套每层中断现场按100字节估一个余量。这种情况下我通常会直接把栈设成4KB剩余8KB当堆或者留着做缓冲。跑完一轮压力测试后再通过修改栈大小观察是否更稳定来决定调整方向。如果项目用了RTOS主栈MSP只需要给中断服务程序和启动阶段用任务栈是各任务自己划分的不要把所有任务栈都算进初始栈大小里这点新手特别容易弄混。2.3 栈顶地址必须八字节对齐进入C语言后AAPCSARM过程调用标准要求SP在函数调用接口处保持8字节对齐。为什么因为有些指令对对对齐有要求而且C库的某些浮点操作、原子操作要求栈对齐。如果SP初始就不对齐可能第一层函数调用都正常但深层函数一用双字加载指令就出奇怪问题。所以设计向量表第一项时要给栈顶一个8字节对齐的地址。用链接脚本定义栈区时一般写成. ALIGN(8); __StackTop .;或者在汇编段里.section .stack, aw .align 3 __StackTop: .space 0x1000align 3就是按8字节对齐。别小看这个.align 3我有一段时期每次复位程序就跑飞查了半天发现栈顶地址是0x20001FFC而不是0x200020004字节边界没对齐。虽然SP本身对4字节对齐没问题但异常压栈时Cortex-M要求将当前SP调整到8字节对齐如果初始就没对齐中断一来压栈现场就和编译器预期对不上程序自然崩。2.4 栈区放在哪里最合理对于大多数MCU栈区放在RAM最高地址区域让栈向下生长到较大空间这是最经典的做法。因为栈向下增长从高地址往低地址用把栈顶设置成RAM的末尾能最大限度避免栈空间和普通全局变量打架。但也有例外比如某些芯片的特定RAM区读写速度不同或者有紧耦合RAMTCM你希望栈放在访问速度更快的内存里。这时候你可以在链接脚本里为栈单独划分一个段。不管怎么放核心原则是栈区必须是RAM中真实存在且没有被其它占用区域覆盖的地址。如果你用的芯片有指令总线访问限制或者DMA缓冲独占区域要把这些区域排除在外。3. 逐行拆解一段可用的汇编启动代码3.1 向量表前两项错了会怎样Cortex-M的向量表本质是一组32位字存放在flash或RAM中。第0项是栈顶地址第1项是复位处理函数地址从第2项开始就是各个异常和中断的处理入口。我手边一个典型工程的向量表长这样以GNU汇编语法为例.syntax unified .cpu cortex-m3 .thumb .section .isr_vector, a, %progbits .globl __Vectors .type __Vectors, %object __Vectors: .long __StackTop /* 第0项栈顶地址 */ .long Reset_Handler /* 第1项复位入口 */ .long Default_Handler /* 第2项NMI */ .long Default_Handler /* 第3项HardFault */ .long Default_Handler /* 第4项MemManage */ .long Default_Handler /* 第5项BusFault */ .long Default_Handler /* 第6项UsageFault */ .long 0 /* 保留 */ .long 0 /* 保留 */ .long 0 /* 保留 */ .long 0 /* 保留 */ .long Default_Handler /* SVCall */ .long Default_Handler /* DebugMon */ .long 0 /* 保留 */ .long Default_Handler /* PendSV */ .long Default_Handler /* SysTick */ .long Default_Handler /* 外部中断0 */ .long Default_Handler /* 外部中断1 */ /* ... 后面是芯片自身的外设中断数量由芯片型号决定 */ .size __Vectors, . - __Vectors前两项错一个系统都起不来。第0项SP如果指向非法地址上电复位后硬件往SP写入非法值之后的弹跳栈操作全都在非法内存上进行。第1项Reset_Handler地址如果不对PC被引导到错误位置通常表现为一复位就进HardFault。很多芯片还支持通过VTOR寄存器把向量表搬到SRAM里这样可以做运行中的动态中断向量切换。但要注意上电初期VTOR默认值是0如果你把向量表放在0x08000000这类地址而现代芯片默认能从flash映射到0x00000000通常没问题如果你把程序放在外部NOR并且没有映射就必须在启动代码最前面手工设置VTOR。这一步漏掉程序同样起不来。3.2 Reset_Handler设栈、清BSS、拷贝data段才是主线复位入口函数是启动文件的核心。下面这段代码是我在裸机项目里最常用的最小启动逻辑注释里每一步都有原因.section .text.Reset_Handler, ax, %progbits .thumb_func .globl Reset_Handler .type Reset_Handler, %function Reset_Handler: /* 1. 设置栈指针从链接脚本拿栈顶地址 */ ldr r0, __StackTop mov sp, r0 /* 2. BSS段清零C语言的未初始化全局变量要求为0 */ ldr r0, __bss_start ldr r1, __bss_end movs r2, #0 bss_loop: cmp r0, r1 bcs bss_done str r2, [r0] adds r0, #4 b bss_loop bss_done: /* 3. 数据段拷贝把初始化过的全局变量初值从flash搬到RAM */ ldr r0, __data_load_start ldr r1, __data_start ldr r2, __data_end data_loop: cmp r1, r2 bcs data_done ldr r3, [r0] str r3, [r1] adds r0, #4 adds r1, #4 b data_loop data_done: /* 4. 进入C世界前的系统时钟配置 */ bl SystemInit /* 5. 调用用户主函数 */ bl main main_loop: b main_loop .size Reset_Handler, . - Reset_Handler这里有几个细节必须讲清楚。第一为什么设栈用ldr r0, __StackTop而不是mov r0, #__StackTopARM的MOV指令对立即数有限制不是任意32位数值都能编码而栈顶地址在链接前不确定所以工程惯例是使用ldr r0, 符号由汇编器自动生成合适的加载方式——它可能变成一条PC相对字面量加载也可能优化成MOV这都不需要我们操心。第二清除BSS段时R0指向段起始地址R1指向段结束地址R2清零。每个字清零后R0加4直到R0超过R1。这里用无符号比较指令bcs高于或等于则跳转而不是bge原因是地址是按无符号数递增的使用有符号比较在一些边界地址下可能出错。第三data段拷贝必须从flash里读初值写到RAM里的目标地址。这里的__data_load_start符号告诉汇编器初值数据存放在ROM区域。如果没有这段拷贝你的全局变量在启动后可能就是flash里的某个奇怪数值程序初始状态就错了。第四为什么清BSS和拷贝data都用手写循环而不是直接调__main如果你的工具链支持C运行库直接把控制权交给__main也可以它会自动完成这些脏活。但手写一遍能让你彻底明白C程序启动依赖什么等你以后做操作系统引导或者某些极端优化的裸机环境手写这套流程就非常有用。而且手写BSS清零只是一个简单循环出错也好排查。3.3 跳转指令选择为什么用 bl 而不是 b 或 bx进入main时我喜欢写bl main后面紧跟一个main_loop: b main_loop。有人会问为什么不用b main如果用b mainmain执行完返回时LR里是复位后的初始值可能落在任何地方直接导致系统跑飞。使用bl main硬件会把返回地址写入LR即下一条指令main_loop的地址即使main不小心返回了也会跳进我们写的死循环不会乱跑。至于bx它更多用于切换ARM/Thumb指令集的场景。在Cortex-M上只有Thumb指令bx和blx通常用于函数指针调用或运行库跳转。启动文件里跳main用bl已经完全够用。顺带一提如果写Cortex-A或其它需要显式切换指令集的ARM平台启动代码里跳C函数会用到bx因为要保证LR的bit0为1表示目标处于Thumb状态。Cortex-M统一了这一点新手可以等到接触更大系统时再深入。4. 跳进C语言之前还欠系统三件事4.1 BSS清零与data段拷贝不处理C代码照样崩很多用IDE创建工程的开发者根本没见过BSS清零代码因为IDE自动生成的启动文件或编译链接脚本把这些事情包了。但一旦你决定手写启动文件或者移植某套开源工程就必须理解这两个任务。C语言标准规定未初始化的全局变量和静态变量默认值是0。这个默认值为0并不是硬件白送的而是启动代码把对应内存区域逐字清零得到的。如果你的启动文件漏掉BSS清零一个全局的int flag;可能是上电前RAM里的残留数据于是你的 if 判断可能一开始就进入错误分支。data段拷贝解决的问题是全局变量的初值写在flash里RAM上电后并没有这些初值必须从flash标定的LOAD区域拷贝到RAM的运行区域。典型代表是一个const表或者大数组的初值。如果你不拷贝程序读到的全局变量初值是乱码。我在移植某个开源项目时它的链接脚本和启动文件不配套data段拷贝目标地址符号名对不上导致运行时所有带初值的全局变量全是随机数。第一次排查时我还以为是程序逻辑问题后来在调试器里看到全局变量初值不符合预期再回到启动文件检查才定位到问题。经验是凡是全局变量初值异常优先怀疑data段拷贝凡是全局变量偶尔能工作偶尔不能优先怀疑BSS起始地址符号是否有对齐错误。4.2 SystemInit应该在什么时候调用Cortex-M芯片上电后通常运行在内部低速时钟主频很低。如果直接进main你的延时函数、串口波特率、定时器周期全都按默认低频算很多外设就异常了。所以启动代码需要在跳进main之前把系统时钟切换到高速晶体和PLL配置好总线时钟分频。这个函数通常叫SystemInit。我是这样安排的先设栈再清BSS、拷贝data最后调用bl SystemInit然后再进main。调SystemInit之前必须保证SP有效因为它内部有局部变量有函数调用但它不依赖BSS和数据段所以放在清BSS之后比较好保证它内部使用的任何全局变量都是初始状态。也有厂商的库把SystemInit放在清BSS之前调用他们这么做的原因是SystemInit内部完全不碰全局变量只要栈能用就行。两种顺序在多数情况下都能工作但如果你在SystemInit里使用了全局变量比如标志位或者配置数据结构就一定要放在BSS清零之后。为了让自己的工程更稳健我倾向于统一采用设置栈→清BSS→拷data→SystemInit→main这个顺序并且写注释说明为什么。4.3 堆与C运行库的隐藏需求C语言里使用malloc、calloc、printf等标准库函数实际上会依赖堆区以及运行时环境。裸机工程如果没有调用这些函数堆区可以完全不要。但很多项目一旦用了printf做串口调试麻烦就来了。标准库的printf内部可能需要为浮点数格式化和缓冲分配内存如果堆区没有准备好函数内部会访问一个非法堆指针直接进HardFault。初创文件里如果完全没定义堆段链接时可能报错也可能链接成功但运行崩溃。最常见的做法是在链接脚本里划出一块堆区并且在汇编启动文件里给C库提供堆边界符号比如__heap_start、__heap_end、__initial_sp。如果你用的是GNU工具链还可能要提供_sbrk的实现让库知道怎样扩展堆。我这里有一个建议裸机学习阶段尽量少用堆。嵌入式里malloc容易产生碎片和不确定性调试困难。真要动态缓冲声明固定大小数组或者用环形缓冲远比靠堆安全。如果你非要用那就在启动阶段顺手把堆区定义好并把_sbrk写好别等崩溃了再回来补。5. 上电即跑飞的常见翻车点与排查手法5.1 三类经典根因向量表错位、链接脚本地址不对、SP指向非法地址我总结了自己和周围朋友在启动阶段最常见的三类翻车点这里先按发生频率排个序现象根因检查思路复位后直接进入HardFault向量表第二项Reset_Handler地址不对查看反汇编确认Reset_Handler的地址和向量表存储的值是否一致程序运行到一半随机跑飞SP初始值未指向合法RAM打开调试器看SP初值是否落在RAM范围内全局变量初值全乱data段没有拷贝或符号对不上检查链接脚本中的LOAD区符号和启动文件引用的符号是否一致中断一触发就死机栈顶未8字节对齐异常压栈现场错乱查看向量表第0项是否按8字节对齐最坑的情况是以极低概率跑飞这种大概率是RAM区被覆盖栈太大压到了全局变量或者某个数组越界踩到了栈。这类问题不是启动代码本身错而是运行时内存布局紧张。遇到这种情况先把栈注释掉一部分看现象是否变化如果变化了说明栈与数据区边界挨得太近如果没变化就要用断点去追踪写坏地址的代码。5.2 用调试器而不是靠猜查SP、查PC、查运行栈帧的具体操作我给新手的建议是跑飞之后不要急着改代码先打开调试器看三个值SP、PC、LR。如果PC是一个0xFFFFFFFx附近的地址说明进了HardFault或异常表默认处理函数最常见的入口就是Default_Handler里的死循环。你可以在调试器里给Default_Handler打断点然后查看SP。如果SP指向了0x00000000附近或者一个不在RAM范围内的地址那SP初始化一定有问题回到向量表第一项。如果SP看起来还在RAM内那就在调用栈窗口里看能不能回溯出函数调用链。Cortex-M的压栈现场会自动保存R0-R3、R12、LR、PC、xPSR一共8个32位字调试器可以把这个栈帧解析出来直接告诉你是哪条指令触发了异常。如果你的调试器解析失败可以手动看栈上内存按SP对齐后读出PC位置的值再去反汇编窗口搜索这个地址附近的指令基本就能定位到是在哪个函数崩溃的。这个过程多练几次就比依赖运气高效得多。5.3 一些可以提前避开的习惯在启动阶段有几个习惯能帮你省下大量时间第一新建工程时最小系统先不要急着加一堆外设初始化。先把启动文件、链接脚本、main函数和LED翻转弄通再去加时钟、串口、中断。每加一块模块就跑一遍。这样如果崩了一定是刚加的部分引起的。第二在Default_Handler里加一个死循环标记不要在中断处理函数里继续跳回。调试时你会一眼看出是哪个异常被触发了而不是程序跑飞到某个未知位置。第三不要随意修改链接脚本里的内存区域描述。比如你把FLASH长度改小了但程序超过长度链接器可能不报错只是程序被截断运行时全是乱跳。如果程序下载后行为异常先用工具链提供的map文件看看各个段落址是否合理。第四如果你参考其它开源项目的启动文件一定要对照你手头芯片的库文件确认向量表的中断数量。缺一项或顺序错一位中断触发时就会调进错误的处理函数极其难查。我第一次移植时就漏了三个外设中断向量触发串口接收中断后直接进到别的中断里排查了整整一个下午。写在最后的一点体会这篇是这个系列第三篇我把上电到main之间最关键的环节拆成了四块向量表、栈指针、运行时环境、跳转入口。写汇编启动代码这件事难不在于指令本身而在于你必须把硬件复位行为、编译器规则、链接脚本布局、C语言运行模型串成一条线。我第一次看到__StackTop、__bss_start这些符号时也是一头雾水后来自己动手新建工程、调整栈大小、手写清BSS循环才慢慢把这些概念焊进了脑子里。如果你现在也卡在这一步我的建议很简单不要去背启动文件去新建一个最小裸机工程从空main开始手动补上向量表和Reset_Handler编译、下载、点灯再逐步加BSS清零、data拷贝、SystemInit。踩几个坑之后你对ARM嵌入式开发的理解会扎实非常多。后面我还会继续整理外设初始化、中断管理相关的学习总结如果你在实践中遇到启动阶段的怪问题也欢迎把你的排查过程记录下来和我交流。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑