STM32启动文件剖析:从复位向量到main函数的完整旅程
做嵌入式开发这几年我见过太多“程序烧进去了但板子没反应”的情况。很多人第一时间怀疑晶振、怀疑电源、甚至怀疑芯片坏了折腾半天最后发现是启动那几步没走对。STM32 的启动文件 startup_stm32f103xe.s 就是决定“上电之后代码第一时间从哪里开始跑”的关键角色。它不是可有可无的编译器模板而是一段真正被 CPU 在复位后首先执行的汇编代码负责设置栈指针、摆好中断向量表、调用时钟初始化最后才把控制权交给 C 语言世界。这篇文章我会把官方这份 startup 文件从头到尾拆开。不讲空道理而是带着大家逐行看每一句是什么意思、删掉会发生什么、在实际工程里常踩哪些坑都点出来。不管你刚开始接触 STM32还是已经写了几年应用代码但没仔细看过启动文件读完之后再遇到“程序不跑、中断不响应、HardFault 满天飞”这类问题都能快速把排查范围缩小到正确的位置。1. 启动文件在工程里到底是什么角色1.1 从按下复位到进入 main中间隔的几步我们在 C 语言里写的程序从 main 函数开始执行这是编译器给我们的错觉芯片本身并不会直接运行 main。Cortex-M3 内核复位之后硬件会固定做三件事先到地址 0x00000000 处读取栈顶指针的初始值再跳到地址 0x00000004 处读取复位向量也就是 Reset_Handler 的入口地址然后跳到这个地址去执行。这一整套“固定动作”要能成立前提是内存最开头放的东西必须是启动文件给出的向量表。所以启动文件承载的功能至少有三个。第一它定义了初始栈指针也就是__initial_sp。如果没有这个值复位后处理器的 SP 寄存器就是一堆随机数程序连第一次函数调用都撑不过去。第二它定义了一张中断向量表把每个异常和中断的入口地址按内核规定的位置排好。中断来了以后硬件才能根据中断号自动找到对应的处理函数。第三它实现了复位入口代码也就是 Reset_Handler。这段代码负责调用 SystemInit 初始化时钟再进入 C 运行时环境最终跳进 main。1.2 这份文件不是“可选组件”很多初学者把它当成 IDE 自动生成的东西顺手就删了或者换工具链时忘了换启动文件结果编译时冒出一堆 undefined symbol或者程序烧进去完全不跑。这里要明确一个概念启动文件不是编译器自动生成的而是芯片厂商针对每一颗具体型号的芯片单独编写的。同样是 STM32F103 系列不同容量的型号外部中断数量不一样向量表长度也不一样。startup_stm32f103xe.s 针对的是大容量型号。如果你的芯片是其他容量等级就必须换成对应的启动文件。选错了向量表长度和芯片外设中断对不上某些中断来了以后会跳到一个错误位置问题非常隐蔽。1.3 所有工具链都有启动文件只是“长相”不同用 Keil MDK 时启动文件以 ARM 汇编语法编写后缀是 .s里面用了AREA、SPACE、DCD、PROC这些指令。如果换成 GCC 工具链同一个启动文件会变成 GNU 汇编语法后缀可能是 .S里面用的是.section、.word、.type这些指令函数定义和段声明的写法完全不同。再换成 IAR又是另一种风格。它们的核心逻辑是一样的但你不能把 MDK 的启动文件直接丢到 GCC 工程里用更不能用 GCC 的启动文件去配 MDK 的 C 库。原因后面讲 Reset_Handler 的时候会详细展开。2. 读懂这份文件需要的汇编知识其实没多少2.1 先把这些指令和伪指令认全很多人在启动文件面前犯怵是因为看到一堆大写英文单词联想起大学里痛苦的汇编课。其实这份文件用到的指令非常有限数来数去就十几个。我把最常用的整理成一张速查表。指令/伪指令作用通俗理解EQU定义一个常量类似 C 语言里的宏定义AREA声明一个段给一段代码或数据划定一个归属区域SPACE保留一段空间不赋初值在内存里划一块地但先不填东西DCD定义一个 32 位数据通常用来放地址在这里写入一个具体数值LDR把内存值或立即数加载进寄存器给寄存器赋值BLX带返回的分支跳转调用一个函数返回地址存到 LRBX无条件跳转跳到指定地址不保留返回信息PROC/ENDP标记一个函数的开始和结束类似于函数的括号EXPORT导出符号让其他文件能用给符号加上“全局属性”IMPORT导入外部符号告诉链接器这个符号在别处定义ALIGN对齐到指定边界让地址按 2 的 n 次方对齐PRESERVE8保证 8 字节对齐满足 AAPCS 调用约定的要求THUMB告诉汇编器这里是 Thumb 指令Cortex-M3 只支持 Thumb-2 指令集[WEAK]把符号标记为弱符号存在强定义时优先使用强定义可能有人会问为什么栈和堆都要ALIGN3也就是按 8 字节对齐而不是 4 字节这里有个背景知识Cortex-M3 的某些双字加载指令要求地址按 8 字节对齐同时 AAPCS 调用约定也要求栈指针在函数入口处保持 8 字节对齐。如果栈顶没有对齐遇到使用高效指令的代码或者某些标准库函数就可能触发总线错误或 HardFault。这就是为什么官方文件里几乎每个段都带着 ALIGN3文件开头还会有 PRESERVE8。2.2 段的概念链接器是按段来摆位置的汇编里的段可以理解成一个有名字的收纳盒。一个盒子里放的是代码另外一个盒子里放只读数据还有一个盒子里是运行时要改写的变量数据。startup 文件里出现的段有 STACK、HEAP、RESET、|.text| 这几个。链接器做最终链接的时候会查看分散加载文件或链接脚本决定这些段分别被放到哪个地址。对启动文件来说最要命的一点是RESET 段必须放在整个镜像的最开头。芯片复位后只会去 0x00000000 和 0x00000004 取向量数据如果你的向量表不在开头芯片取到的就是别的代码或数据程序自然跑飞。这也是为什么我不建议新手轻易改启动文件里的段名更不要把AREA RESET, DATA, READONLY改成别的名字。你改了名字分散加载文件里如果用*(RESET, First)来指定放置顺序就可能匹配不到向量表就会被放到后面去程序烧进去又是无声无息地失败。2.3 读官方文件的顺序建议分成四块第一次读这份文件不用从头到尾死磕。我自己的经验是分成四块来啃先看头部注释和栈定义搞清楚这个文件是给哪颗芯片用的然后看堆定义知道 C 库的动态内存从哪里来接着看向量表和 Reset_Handler这是核心最后看各个中断处理函数的弱定义和 Default_Handler理解中断服务函数是怎么注册的。四块看完基本就掌握了全貌。3. 逐段拆解 startup_stm32f103xe.s3.1 栈区域__initial_sp为什么必须存在官方文件最前面几行的栈定义是这样的。Stack_Size EQU 0x00000400 AREA STACK, NOINIT, READWRITE, ALIGN3 Stack_Mem SPACE Stack_Size __initial_sp这里的 0x00000400 换算成十进制是 1024也就是 1KB。AREA STACK, NOINIT, READWRITE, ALIGN3的意思是声明一个名为 STACK 的段不需要初始化可读可写按 8 字节对齐。接下来用SPACE Stack_Size在内存里划出 1KB 的空白区域__initial_sp是这个区域后面的地址也就是栈顶。这里有一个新手容易搞反的点栈是向下生长的。也就是说栈顶地址是这一段内存里地址最高的位置。__initial_sp指的就是栈区最后一个字节之后的地址处理器复位后会把这个值加载到 MSP 寄存器。如果这个值不存在或者链接器没把 STACK 段放到 RAM 里程序第一次调用函数时就可能向错误地址写入数据。我见过一个实际案例有开发者在工程里删掉了启动文件中的 STACK 段想自己在 main 里重新设置栈指针结果程序到 main 之前就飞了。原因很简单——进入 main 之前的启动代码本身也要用栈你连 Reset_Handler 都还没跑起来哪来的机会执行那个“改栈指针”的 C 代码所以启动文件里的初始栈是启动阶段的生命线不能省。再补充一点这 1KB 栈空间只保证启动阶段和 main 早期运行足够。如果 main 里有很大的局部数组、有递归调用或者中断嵌套比较深这 1KB 很快就会用光造成栈溢出。栈溢出后的表现非常随机可能是一个局部变量被莫名修掉可能是函数返回地址被破坏然后跳进 HardFault。遇到这类问题先把Stack_Size从 0x400 改到 0x800 或更大重新编译看症状是否消失这是成本很低的试探方法。如果用了 FreeRTOS 这类实时操作系统启动文件里的初始栈还承担中断栈的角色任务栈由 RTOS 内核另外分配管理。这 1KB 并不意味着整个系统只有 1KB 栈可用。3.2 堆区域malloc 内存的家堆的定义紧跟栈后面官方默认值通常是 0x200也就是 512 字节。Heap_Size EQU 0x00000200 AREA HEAP, NOINIT, READWRITE, ALIGN3 __heap_base Heap_Mem SPACE Heap_Size __heap_limit__heap_base和__heap_limit这两个符号分别标记堆的起始地址和结束地址。C 库里的malloc、calloc、realloc这些函数会从这段内存里分配空间。如果整个工程从来不用动态内存这个值设大设小影响都不大但建议保留一个较小的值因为某些 C 库函数的内部实现可能隐式依赖堆。我踩过跟堆有关的坑印象比较深。某个设备里跑协议解析用了动态字符串拼接默认 512 字节的堆根本不够。程序刚启动时看起来一切正常跑十几分钟就会随机死机。后来把Heap_Size改成 0x1000同时检查了所有 malloc 的返回值问题才稳定下来。实际上只要是 malloc 没有检查返回值或者分配后忘了释放哪怕你把 Heap_Size 改得再大也只是把崩溃时间往后推迟。所以嵌入式裸机环境下我个人的习惯是能不用动态分配就不用能静态分配就静态分配实在躲不开的时候把堆开大一点并且每个 malloc 之后都判断返回值。MDK 下还有一个细节如果工程设置里勾选了 MicroLIB启动文件中的堆和栈定义会被 MicroLIB 的初始化代码使用但方式略有不同如果用的是标准 C 库__heap_base和__heap_limit的地址会通过运行时初始化流程传入堆管理模块。无论哪种情况堆的实际大小都是由这里定义的Heap_Size决定的不是自己在 C 代码里随便设个宏就行。3.3 向量表芯片的“紧急电话簿”向量表是启动文件里信息量最大、也最容易看晕的部分。官方代码长这样。AREA RESET, DATA, READONLY EXPORT __Vectors EXPORT __Vectors_End EXPORT __Vectors_Size __Vectors DCD __initial_sp DCD Reset_Handler ; Reset Handler DCD NMI_Handler ; NMI Handler DCD HardFault_Handler ; Hard Fault Handler DCD MemManage_Handler ; MPU Fault Handler DCD BusFault_Handler ; Bus Fault Handler DCD UsageFault_Handler ; Usage Fault Handler DCD 0 DCD 0 DCD 0 DCD 0 DCD SVC_Handler ; SVCall Handler DCD DebugMon_Handler ; Debug Monitor Handler DCD 0 DCD PendSV_Handler ; PendSV Handler DCD SysTick_Handler ; SysTick Handler ; 外部中断从这里开始 DCD WWDG_IRQHandler DCD PVD_IRQHandler DCD TAMPER_IRQHandler DCD RTC_IRQHandler DCD FLASH_IRQHandler DCD RCC_IRQHandler ; ... 再往后每个型号都有差别DCD的全称是 Define Constant Double Word但按 ARM 汇编的习惯它就是往当前地址放一个 32 位数据这里放的通常是符号的地址。一行DCD Reset_Handler翻译成大白话就是在这个位置写入 Reset_Handler 这个函数所在的地址。向量表里的顺序不是随便定的而是由 Cortex-M3 内核手册规定的。前 16 个位置对应内核的系统异常包括复位、NMI、HardFault、MemManage、BusFault、UsageFault、SVCall、DebugMon、PendSV、SysTick以及几个保留项。从第 17 个位置开始是芯片自身的外部中断。对 F103 大容量型号来说外部中断有 60 个整张表一共 76 个向量占用 304 字节。__Vectors_Size就是__Vectors_End减去__Vectors算出来的结果。向量表每一位的作用可以理解成一本“紧急电话簿”中断发生的时候内核根据中断号去查表取出保存在对应位置的入口地址然后跳过去执行。如果在某个位置填了 0或者填了一个不存在的地址那这个中断一触发系统就会进入 HardFault。写中断服务函数的时候最核心的一个认知是你不是在“注册”一个中断函数而是让你的函数地址被链接器填到这张向量表对应的位置。启动文件已经用弱符号把每个中断名都定义了一遍并指向默认死循环。如果你写了void USART1_IRQHandler(void)链接器发现这个名字有强定义就会让向量表中对应位置指向你的函数。如果你把名字拼错一个字母链接器不会报错因为启动文件里的弱符号还在你的函数就只是一个普通函数中断来临时照旧跳到弱符号的死循环里。这种隐蔽问题我放到后面的排查部分细说。3.4 Reset_Handler从汇编到 C 的必经之路向量表之后进入代码段核心函数是 Reset_Handler官方写法如下。Reset_Handler PROC EXPORT Reset_Handler [WEAK] IMPORT SystemInit IMPORT __main LDR R0, SystemInit BLX R0 LDR R0, __main BX R0 ENDP第一行EXPORT Reset_Handler [WEAK]表示把 Reset_Handler 这个符号导出并标记为弱符号。正常情况下你不会去重写复位流程但如果确实有特殊需求可以在别的文件里定义一个强符号 Reset_Handler链接器就会优先使用你的定义。不过我强烈不建议这么做。官方这个 Reset_Handler 是进入 C 语言环境的标准路径绕开它等于自己重新实现 C 运行时初始化风险很高。接下来的LDR R0, SystemInit不是普通的一条 LDR 指令准确说是汇编器会把 SystemInit 的地址放到旁边的一个常量池里再生成一条从常量池加载地址到 R0 的指令。然后BLX R0完成一次带返回的跳转调用处理器会先把下一条指令地址保存到 LR再跳过去执行。SystemInit 执行完毕会返回这里接着执行下一条指令。IMPORT __main里的__main不是我们写的 main 函数而是 C 运行时库的入口。__main内部干的事情包括把需要搬运到 RAM 的变量数据搬到运行地址把 ZI 段清零然后进入 C 库初始化流程最后才调用我们写的 main。所以在启动文件里看到LDR R0, __main; BX R0不要误以为这是直接跳进 main。直接跳 main 是不行的因为全局变量还没有就绪。这里也回答了前面埋下的伏笔为什么 MDK 的启动文件不能直接给 GCC 用。在 GCC 工具链里C 库初始化的入口不叫__main而是_start或__libc_init_array之类的符号。把 MDK 启动文件直接拿到 GCC 下编译IMPORT __main这一关就过不去就算强行适配GCC 的链接脚本也不会提供相同的运行时环境结果仍然不可预期。反过来也一样。所以遇到启动相关的移植问题先问一句我用的工具链和这份启动文件匹配吗另一个常见现象是编译时报错找不到 SystemInit。这个函数由 system_stm32f10x.c 提供。如果新建工程时漏加了 system_stm32f10x.c启动文件里的IMPORT SystemInit就会变成未解析符号。解决办法不是注释掉启动文件里那一行而是把 system_stm32f10x.c 加进工程。SystemInit 的作用是完成时钟树初始化芯片复位后默认跑 HSI 内部时钟频率和精度都不满足外设要求。SystemInit 会配置外部高速晶振、PLL、总线分频器、Flash 等待周期等让系统时钟达到 72MHz。调试时发现系统时钟不对先查 SystemInit 是否被调用了以及有没有执行成功。3.5 弱中断函数为什么你不写中断服务函数链接照样能过启动文件后半段还定义了很多中断处理函数。NMI_Handler 的写法一般是这样的。NMI_Handler PROC EXPORT NMI_Handler [WEAK] LDR R0, Default_Handler BX R0 ENDP所有允许用户覆写的中断处理函数都带着[WEAK]函数体通常只是跳转到 Default_Handler。而 Default_Handler 本身是这样定义的。Default_Handler PROC EXPORT Default_Handler [WEAK] B . ENDPB .是一条跳到当前指令地址的指令相当于写了一个死循环 while(1)。它的语义是这里有一个默认的中断入口如果你没有实现对应的中断服务函数中断来了就停在这儿别继续乱跑。这个机制最妙的地方在于它保证启动文件在编译和链接时永远是成功的。你不写任何中断函数也不会报链接错误。缺点也随之而来当程序好像没死在 main 里的某个地方而是毫无动静很有可能就停在了某个没有被覆写的弱中断函数里。调试器里看 PC 值可能正停在Default_Handler的死循环里。这时候用函数调用栈回溯一下往往能定位是哪个中断触发了。还有一个细节值得提醒向量表里的中断名称和标准外设库、HAL 库里的中断服务函数名称不一定一一对应。标准外设库版本里RTC 闹钟对应的中断向量可能叫 RTC_IRQHandler而 HAL 库版本里可能叫 RTC_Alarm_IRQHandler。从标准外设库工程换成 HAL 库工程或者反过来中断函数名没跟着改那这个中断的入口地址仍然指向弱符号也就是 Default_Handler 的死循环。很多“中断没反应”的 bug 就是这么来的。最好的习惯是每次建完工程打开 startup 文件对比一下当前库里的中断服务函数命名把对应关系理顺再写代码。4. 启动文件之外的隐藏机制BOOT 引脚、重定位和链接脚本4.1 内核只认地址 0而你的 Flash 在 0x08000000刚才说的复位流程里隐藏了一个 STM32 特有的映射问题。Cortex-M3 复位后会从 0x00000000 和 0x00000004 取地址但 F103 芯片的 Flash 起始地址是 0x08000000。向量表放在 Flash 里为什么还能正常工作因为芯片内部存在启动映射机制当 BOOT0 引脚接低电平时地址区间 0x00000000 到 0x0007FFFF 会被映射到主 Flash 的起始区域。也就是说你从 0x08000000 烧录的镜像从地址 0x00000000 也能访问到同样的内容。这就带出一个容易搞混的点如果 BOOT0 被拉高芯片可能从系统存储器启动或者从 SRAM 启动此时即使 Flash 里程序烧得完全正确上电后也不会执行它。所以排查“程序不跑”时BOOT 引脚的检查要排在最前面。常见情况是某些开发板的 BOOT0 跳线帽被误插了用户折腾半天软件最后发现只是跳线位置不对。启动模式大致有三种BOOT0 为低从主 Flash 启动这是绝大多数应用场景BOOT0 为高、BOOT1 为低从系统存储器启动这个模式一般用于串口 ISP 下载固件出厂 bootloader 就在系统存储器里BOOT0 和 BOOT1 都为高从 SRAM 启动通常是特殊调试场景才用。平时开发记得把 BOOT0 固定到低电平。4.2 向量表重定位做 BootLoader 的人绕不开启动文件里的向量表默认放在 Flash 开头。当需要在 Flash 里放一个 BootLoader后面再放应用程序时应用程序的向量表不能继续放在 0x08000000因为那里已经被 BootLoader 占用。假设把应用程序整体偏移到 0x08008000那么它的向量表也在 0x08008000。此时必须通过 SCB-VTOR 寄存器告诉内核向量表基址不再是从地址 0 开始而是偏移到了 0x08008000。如果不做这一步应用程序里的任何中断触发时内核还是会按默认基址查老表找到的是 BootLoader 里的中断入口行为完全不可预知。F103 的 VTOR 寄存器地址是 0xE000ED08需要在启动阶段尽早设置。HAL 库的 system_stm32f10x.c 里提供了 VECT_TAB_OFFSET 宏默认是 0。做 BootLoader 偏移时把这个宏改成对应偏移量后重新编译SystemInit 里就会自动设置 VTOR。标准外设库可能需要手动写一句SCB-VTOR 0x08008000;之类代码。向量表重定位还有一个衍生需求如果想让程序跑得更快或者做在线升级可以把整个向量表拷贝到 SRAM再把 VTOR 指向 SRAM 里的新表。这时启动文件里导出的__Vectors_Size就派上用场了可以用它计算需要拷贝的字节数避免手动数 76 个向量。我自己项目里就有一处类似用法先 memcpy 把 Flash 里向量表搬到 SRAM再设置 VTOR中断机制就和在 Flash 下完全一致。4.3 链接脚本是把启动文件放对位置的幕后推手启动文件本身只声明“我有一个 RESET 段”具体放哪里由链接脚本决定。Keil MDK 的分散加载文件里通常会有类似这样的描述LR_IROM1 0x08000000 0x00080000 { ER_IROM1 0x08000000 0x00080000 { * (RESET, First) * (InRoot$$Sections) .ANY (RO) } ... }注意那个First它的意思是把 RESET 段放到整个执行区域的第一个位置。如果新建工程时改了段名或者用了不匹配的链接脚本RESET 段没有被放在最前面向量表就会出现在镜像里的非开头位置复位后芯片从开头取到的数据就是错的。程序跑飞的症状和 BOOT 引脚接错很像都是没反应或者 PC 异常。所以排查启动类问题时链接脚本和启动文件要放在一起看不能只盯着汇编代码。5. 调启动相关问题时我反复用到的排查思路5.1 程序根本不启动按这几步来第一步查 BOOT 引脚确认跳线帽没有被误插。第二步接调试器连上后看能不能停在 Reset_Handler。如果 PC 显示 0xFFFFFFFF 或者 CPU 状态不对优先怀疑芯片没有正常开始运行。第三步查看 0x08000000 开头的 8 个字节正常情况下前 4 个字节应该是一个 RAM 地址也就是栈顶紧接着的 4 个字节应该是 Flash 区域内的地址也就是 Reset_Handler 入口。如果这些值看起来不像地址要么是向量表不在开头要么是镜像里根本没有向量表。第四步在 Reset_Handler 第一行设断点复位运行后如果能够停在断点就说明启动流程已经走到软件这边剩下的就是单步跟踪。5.2 中断没反应先查向量表名字对不对调试过的“中断没反应”案例里很大比例不是 NVIC 没使能而是中断服务函数名和启动文件里的向量表对不上。从标准外设库换到 HAL 库时特别容易踩到。你写了名字 A但向量表里叫名字 B链接器不会报错弱符号依然存在中断来的时候照常跳到 Default_Handler 死循环。排查方法很直接用编译器生成的 map 文件搜你的中断函数名看它最终链接到了哪里同时检查向量表对应位置指向的是你的函数还是 Default_Handler。另一个容易踩的坑是中断函数被 static 修饰导致函数符号不导出链接器无法用强符号替换启动文件里的弱符号。我的规矩是所有中断服务函数一律不带 static。5.3 HardFault 和栈溢出从启动文件角度下手HardFault 是一个综合性的故障现象触发原因很多。但启动阶段相关的 HardFault有一个高频来源栈溢出或者栈指针没配对。可以打开调试器查看当前 MSP 的值与__initial_sp比较。如果 SP 已经越过__initial_sp说明栈向下生长时踩过了边界。还有一种情况是栈顶地址没有对齐到 8 字节这在某些 C 库函数里会触发栈对齐检查异常。定位 HardFault 位置最有效的办法是在 HardFault_Handler 里读取触发异常那一刻压入栈的寄存器内容。原理是硬件进入异常处理前会把 R0、R1、R2、R3、R12、LR、PC、xPSR 这 8 个寄存器自动压到当前栈上。在异常处理函数里取出栈指针按顺序偏移几个字就能拿到异常发生时的 PC 和 LR从而知道代码崩在哪一行。很多调试器的 Fault Report 已经把这个过程自动化了。如果现场没有好的调试环境手写一个简短的 HardFault_Handler通过 UART 把 PC 和 LR 打出来也是嵌入式老兵常用的自救手段。5.4 跨型号和跨工具链移植启动文件必须跟着换最后提醒一个移植时最容易踩的坑换芯片型号或者换工具链启动文件必须同步更换。同样是 F103不同容量等级的型号外部中断数量不同向量表长度也不同。换了芯片以后还用旧的启动文件程序可能不是立即起不来而是某个外设中断映射到错误位置运行一段时间才暴露。最稳妥的做法是先用当前芯片对应的启动文件配合一个最简单的点灯工程验证启动流程正常再继续移植应用代码。换工具链更要谨慎。从 MDK 换 GCC除了启动文件语法不同C 库初始化、链接脚本、堆栈符号规则全都不一样。不要图省事直接从网上随便下载一个 startup 文件丢进工程。最保险的做法是用当前工具链配套的、针对当前芯片型号的启动文件然后先跑通最小工程再继续其他操作。最后再说一点个人体会。启动文件虽然看起来全是汇编对多数人来说比较枯燥但它其实是理解嵌入式系统“从零到一”的最短路径。我每次遇到难缠的启动问题都会回到这份文件重新梳理一遍栈指针有没有安排对向量表有没有放在正确位置Reset_Handler 有没有被绕开。把这几件事想清楚后面再学链接脚本、BootLoader、RTOS 移植思路都会顺很多。希望这篇文章能帮你把这根硬骨头啃下来。以后再碰到“上电没反应”至少能一眼看出问题出在这一层而不是继续对着示波器瞎猜。