资讯动态

STM32F103汇编启动原理与实战:从复位向量到异常返回

发布时间:2026/10/21 3:37:16 来源:尧图企业网站定制
简介本资源是一份面向嵌入式初学者与底层开发爱好者的STM32F103汇编语言实践项目聚焦“点灯”这一经典入门实验帮助读者深入理解Cortex-M3内核寄存器操作、GPIO配置流程及Keil5环境下汇编程序的完整构建链路。压缩包共19个文件涵盖核心汇编源码.s、工程配置.uvprojx/.uvoptx、链接脚本.sct、编译输出.axf/.map/.lst、调试支持JLinkSettings.ini/.iex及一键清理脚本keilkilll.bat结构完整便于逆向分析编译逻辑与启动流程。资源仅28KB轻量精炼已获1652人学习下载。读者可直接导入Keil5运行调试通过单步执行观察R0–R15寄存器变化、跟踪GPIOx_MODER等外设寄存器写入过程并结合注释清晰的startup_stm32f10x_md.s掌握中断向量表布局与复位流程是夯实ARM汇编基础、建立硬件-指令映射直觉的优质实操材料。1. STM32F103汇编程序不是“写不动C才退守汇编”而是芯片启动那一刻就已决定你必须懂它你可能以为STM32F103上写汇编只是老工程师的怀旧行为或是Keil5里点几下就自动生成的startup.s——直到某天你改了个中断向量表偏移系统在__main之前就硬fault或者你在低功耗模式唤醒后发现SysTick计数器归零却不再递增又或者你用__attribute__((naked))写了个中断服务函数结果返回时PC跳进了一片未初始化的RAM……这些不是玄学是汇编层面对寄存器、堆栈、异常返回机制的刚性约束在说话。STM32F103的启动流程从复位向量取地址开始到调用C库__main前的最后一步全程由汇编主导它不依赖C运行时不经过任何抽象层直接操作NVIC、SCB、SP、PC和PSR。这份“STM32F103汇编程序”资源不是教你如何手写冒泡排序而是提供一套经实测验证的、覆盖复位处理、中断向量重映射、堆栈初始化、异常返回EXC_RETURN构造、以及与C代码ABI兼容的完整汇编骨架。它解决的从来不是“能不能用”而是“为什么必须在这里写汇编、写错一个bit会怎样、以及Keil5链接脚本里.isr_vector段的对齐要求到底在保护什么”。2. 启动文件的本质不是模板而是CPU复位后执行的第一行真实指令流2.1 复位向量表4字节对齐背后的硬件强制逻辑ARM Cortex-M3架构规定复位后CPU从地址0x00000000或向量表偏移后的起始地址读取初始主堆栈指针MSP值紧接着读取复位向量即复位处理函数入口地址。这个向量表必须严格按4字节对齐且每个向量项为32位无符号整数。为什么因为Cortex-M3的取指总线宽度为32位且向量表访问使用非特权模式下的字对齐加载LDR若地址非4字节对齐将触发UsageFault。; startup_stm32f103xb.s 片段Keil5默认生成但关键细节常被忽略 AREA RESET, DATA, READONLY EXPORT __Vectors __Vectors DCD __initial_sp ; MSP初始值必须是RAM中有效地址 DCD Reset_Handler ; 复位处理函数入口必须是Thumb指令地址LSB1 DCD NMI_Handler DCD HardFault_Handler ; ... 其余异常向量提示__initial_sp必须指向一块已知大小、未被其他模块占用的RAM区域如0x20005000且该地址需在链接脚本中通过_estack符号显式定义。若此处填错比如指向Flash或未映射地址复位后第一条指令就会触发HardFault——此时调试器甚至来不及停在Reset_Handler第一行。2.2 Reset_Handler从裸机状态到C环境的三步不可逆跃迁Reset_Handler是整个启动链的真正起点。它不调用任何C函数不使用任何全局变量所有操作必须基于寄存器和已知内存布局。其核心任务分三阶段堆栈初始化设置MSP主堆栈指针为后续调用C函数准备栈空间数据段复制将RO/RW段从Flash拷贝到RAM.data初始化清零BSS段.bss未初始化跳转至C入口调用__mainARM C库初始化函数最终进入用户main()。Reset_Handler PROC EXPORT Reset_Handler [WEAK] IMPORT __main IMPORT SystemInit ; Step 1: 初始化MSP使用向量表首项 LDR R0, __initial_sp MSR MSP, R0 ; 设置主堆栈指针 ; Step 2: 调用SystemInit可选但强烈建议 BL SystemInit ; 配置时钟、Flash等待周期等 ; Step 3: 复制.data段 清零.bss段Keil5通常由__main自动完成 ; 但若你禁用__main或需自定义初始化此处必须手动实现 LDR R0, _sidata ; Flash中.data起始地址 LDR R1, _sdata ; RAM中.data目标起始地址 LDR R2, _edata ; RAM中.data结束地址 MOV R3, #0 BEQ data_init_done data_loop LDRB R3, [R0], #1 ; 从Flash读一字节 STRB R3, [R1], #1 ; 写入RAM CMP R1, R2 BNE data_loop data_init_done ; 清零.bss段 LDR R0, _sbss LDR R1, _ebss MOV R2, #0 CMP R0, R1 BEQ bss_init_done bss_loop STRB R2, [R0], #1 CMP R0, R1 BNE bss_loop bss_init_done ; Step 4: 跳转至C库入口 BL __main ; 不是直接BL main! ENDP这段代码的关键在于__main不是你的main()而是ARM标准C库提供的初始化桩。它负责调用__scatterload分散加载、__rt_entry运行时环境建立、最终才BL main。若跳过__main直接BL main.data不会被初始化、.bss不会被清零、堆栈可能溢出、甚至浮点单元FPU未使能——所有这些都会导致main()中看似正常的变量赋值、数组访问、结构体初始化瞬间崩溃。2.3 异常向量重映射当你的固件不在0x00000000启动时STM32F103支持向量表重映射Vector Table Remap通过设置SYSCFG_MEMRMP寄存器可将向量表基址从默认的0x00000000系统存储器切换到0x20000000SRAM或0x08000000Flash。这在IAPIn-Application Programming或双Bank Boot场景中至关重要。但重映射后向量表本身的位置和内容必须同步更新否则异常发生时CPU仍会去旧地址取向量。; 若启用SRAM向量表重映射例如IAP升级时临时跳转到RAM执行 LDR R0, 0x40010000 ; SYSCFG base address (APB2) LDR R1, [R0, #0x04] ; 读取MEMRMP寄存器 ORR R1, R1, #0x01 ; 设置bit0 1 (remap to SRAM) STR R1, [R0, #0x04] ; 此时必须确保新的向量表已拷贝至SRAM起始处0x20000000 LDR R0, __Vectors_RAM ; 新向量表在RAM中的地址 LDR R1, 0xE000ED08 ; VTOR寄存器地址Vector Table Offset Register STR R0, [R1] ; 更新VTOR告诉CPU新向量表在哪注意VTOR寄存器仅在特权模式下可写且必须在MSR MSP, R0之后、任何异常使能之前设置。若在SystemInit之后、__main之前漏掉此步即使重映射了SYSCFGCPU仍会从旧地址取向量——而那个地址此刻可能已是无效数据导致HardFault无法捕获。3. 中断与异常汇编层面对EXC_RETURN、LR和PSR的精确操控3.1 Naked函数的陷阱为什么__attribute__((naked))不是“免写汇编”的许可证在C中声明void SysTick_Handler(void) __attribute__((naked));意味着编译器不会为你生成函数序言prologue和尾声epilogue不保存/恢复寄存器不调整堆栈不执行BX LR返回。这给了你完全控制权但也意味着你必须自己处理一切。最常见的翻车点是忘记手动POP {PC}或错误地使用BX LR而非BX LR配合EXC_RETURN。// 错误示范看似简洁实则致命 void SysTick_Handler(void) __attribute__((naked)); void SysTick_Handler(void) { // 业务逻辑... __asm(BX LR); // ❌ 错LR此时是异常返回地址但BX LR会尝试普通返回 }正确做法是利用EXC_RETURN值构造返回。Cortex-M3规定异常返回时CPU检查LR寄存器的低4位EXC_RETURN[3:0]来判断返回模式Thread/Handler、MSP/PSP、SPSEL。对于SysTick属于系统异常LR通常为0xFFFFFFF9返回Thread模式使用MSP。因此应显式MOV PC, LR// 正确示范显式返回不依赖编译器 void SysTick_Handler(void) __attribute__((naked)); void SysTick_Handler(void) { // 业务逻辑... __asm(MOV PC, LR); // ✅ 正确直接跳转到LR指向的返回地址 }更安全的做法是在汇编中编写完整Handler; 在startup.s中定义 EXPORT SysTick_Handler SysTick_Handler PUSH {R0-R3,R12,LR} ; 保存所有caller-saved寄存器若需 ; ... 你的C风格业务逻辑调用如BL Update_System_Time POP {R0-R3,R12,LR} ; 恢复 BX LR ; ✅ 此时LR含EXC_RETURNBX会触发异常返回流程3.2 NVIC配置为什么NVIC_EnableIRQ()在汇编里要拆成三步在C中调用NVIC_EnableIRQ(EXTI0_IRQn)看似简单但其底层是三步寄存器操作计算IRQn对应NVIC_ISER寄存器偏移 → 构造bitmask → 执行STR写入。若你在汇编中手动配置必须严格遵循此顺序否则使能失败。; 手动使能EXTI0中断IRQn 6 LDR R0, 0xE000E100 ; NVIC_ISER0 base address MOV R1, #1 LSLS R1, R1, #6 ; bit6 0x40 STR R1, [R0] ; 写入ISER0使能EXTI0 ; 同时需配置优先级NVIC_IPR0 LDR R0, 0xE000E400 ; NVIC_IPR0 base address MOV R1, #0x20 ; 优先级24bit field左移4位 LSLS R1, R1, #4 STRB R1, [R0, #0] ; EXTI0优先级写入IPR0[0]关键机制NVIC_ISER是“Set-Enable-Register”写1使能写0无效而NVIC_ICERClear-Disable才是写1禁用。若误写STR R1, [R0, #4]ISER1则可能使能了错误的中断如EXTI15_10导致不可预测行为。3.3 堆栈切换PSP与MSP的隐式切换如何被异常打断Cortex-M3支持两个堆栈指针MSP主堆栈用于Handler模式和PSP进程堆栈用于Thread模式。异常发生时CPU自动切换到MSP返回时根据EXC_RETURN值决定切回PSP还是继续用MSP。但在汇编中若你手动修改了CONTROL寄存器如MSR CONTROL, R0就可能破坏这一机制。; 危险操作在Handler中擅自切换堆栈 MRS R0, CONTROL ORR R0, R0, #0x02 ; 设置bit1 1启用PSP MSR CONTROL, R0 ; ❌ 在Handler模式下切换到PSP是未定义行为ARMv7-M手册明确指出CONTROL[1]SPSEL仅在Thread模式下可安全修改。在Handler模式下写入会导致不可预测结果。正确做法是在main()中初始化PSP然后在需要时通过BX指令触发EXC_RETURN返回到Thread模式并自动切换。4. Keil5工程集成链接脚本、分散加载与汇编符号的生死绑定4.1 scatter文件.isr_vector段为何必须放在0x08000000且绝对对齐Keil5使用scatter文件.sct定义内存布局。对于STM32F103典型配置如下LR_IROM1 0x08000000 0x00020000 { ; load region size_region ER_IROM1 0x08000000 0x00020000 { ; load address execution address *.o(.text) ; 代码段 *(InRoot$$Sections) ; 系统根段 } RW_IRAM1 0x20000000 0x00005000 { ; RAM区 *.o(.data) ; 初始化数据 *.o(.bss) ; 未初始化数据 } ARM_LIB_HEAP 0 ; 堆 ARM_LIB_STACK 0 ; 栈 }但向量表必须显式放置在Flash起始LR_IROM1 0x08000000 0x00020000 { VECTOR_TABLE 0 ; ← 关键定义VECTOR_TABLE区域 { startup_stm32f103xb.o (RO) ; 只放startup.o的只读段 } ER_IROM1 0 ; 其余代码接在后面 { *(RO) } ... }为什么必须这样因为startup_stm32f103xb.o中的.isr_vector段即__Vectors必须位于镜像的最前端0x08000000否则烧录后复位向量读取的将是垃圾数据。Keil5默认不保证.isr_vector在输出镜像中的位置必须通过scatter文件强制定位。4.2 符号导出与引用__initial_sp如何从scatter文件流入汇编scatter文件中定义的符号如LR_IROM1 0x08000000 0x00020000 { VECTOR_TABLE 0 { startup_stm32f103xb.o (RO) } ER_IROM1 0 { *(RO) } RW_IRAM1 0x20000000 0x00005000 { *.o(.data) *.o(.bss) __initial_sp 0x20005000; ; ← 显式定义符号 } }此__initial_sp会被链接器导出为全局符号供汇编文件引用IMPORT __initial_sp ... LDR R0, __initial_sp MSR MSP, R0若scatter中未定义__initial_sp或拼写错误如__init_sp汇编链接时会报Error: L6218E: Undefined symbol __initial_sp。这是新手最常踩的坑——以为startup.s里写了DCD __initial_sp就万事大吉却忘了链接器根本不知道这个符号指向哪。4.3 分散加载Scatter Loading.data复制的真相与__scatterload的隐藏逻辑__main调用的__scatterload函数其行为由scatter文件中的RO、RW、ZI属性驱动。例如RW_IRAM1 0x20000000 0x00005000 { *.o(RW) ; 所有RW段.data复制到这里 }__scatterload会遍历scatter描述的加载域Load Region和执行域Execution Region对每个RW段从加载地址Load Address通常是Flash读取原始数据向执行地址Execution AddressRAM写入对ZI段.bss则向执行地址写0。这个过程完全由ARM库实现但你必须确保scatter中定义的执行地址与startup.s中_sdata/_edata符号一致。否则__scatterload会把.data复制到错误RAM地址导致全局变量永远是初值。5. 避坑指南那些让Keil5调试器沉默、却让芯片彻底失联的汇编级错误5.1 现象复位后停在0x00000000调试器显示Cannot access memory at 0x00000000原因向量表未正确烧录或Flash起始地址0x08000000被擦除但未编程导致复位向量读取全0xFF。CPU尝试从0xFFFFFFFE因LSB0被解释为ARM指令执行非法指令触发HardFault而HardFault Handler又未定义陷入死循环。解决用ST-Link Utility确认Flash 0x08000000处是否为有效向量表前4字节应为MSP值如0x20005000检查Keil5 Output窗口中Program Size是否包含.isr_vector段确保Options for Target → Utilities → Settings → Flash Download中勾选了Reset and Run。5.2 现象main()中第一个printf就HardFault但单步到printf前一切正常原因.data段未初始化。printf依赖__stdout等全局FILE结构体其指针成员在.data中若未从Flash复制值为0fputc尝试解引用空指针。解决检查scatter文件中是否遗漏RW属性确认__main被调用而非直接BL main在Reset_Handler末尾加NOP并单步观察是否进入__scatterload。5.3 现象中断服务函数执行完后程序跳转到随机地址如0x00000000或0xDEADBEEF原因Naked函数中使用了BX LR但LR已被破坏或在中断中调用了非naked的C函数而该函数内部有PUSH {LR}导致返回时POP {PC}弹出错误地址。解决一律用MOV PC, LR替代BX LR若需调用C函数确保该函数不使用naked属性并在汇编Handler中手动PUSH/POP所有被C函数修改的寄存器。5.4 现象SystemInit()执行后RCC-CFGR显示PLL未锁但时钟树配置明明正确原因SystemInit()中调用了FLASH_SetLatency(FLASH_Latency_2)但Flash等待周期设置前未检查当前电压VOSF103在2.0-3.6V下最大支持72MHz若VOS11.8V则只能到48MHz强行设高频率导致PLL无法锁定。解决在SystemInit()开头添加电压检测读取PWR-CR的VOS位或直接使用ST标准外设库中的RCC_DeInit()RCC_HSEConfig()RCC_PLLConfig()完整流程。5.5 现象使用__attribute__((section(.my_section)))定义变量但链接时报L6218E: Undefined symbol原因.my_section未在scatter文件中定义链接器无法为其分配地址导致符号无定义。解决在scatter文件中添加RW_IRAM1 0x20000000 0x00005000 { *.o(.my_section) ; 显式包含该段 *(RW) }并在C文件中用extern int my_var __attribute__((section(.my_section)));声明。6. 设计取舍为什么坚持手写汇编启动代码而不是全盘交给CMSIS Startup6.1 CMSIS Startup的便利性与黑匣子代价CMSIS提供的startup_stm32f103xb.s确实开箱即用它自动处理向量表、堆栈、.data/.bss、调用SystemInit和__main。但它的“自动化”建立在一系列隐含假设之上假设你的RAM布局是标准的0x20000000起始假设你不做IAP向量表永远在Flash起始假设你不需要在__main前执行任何自定义初始化如EEPROM校验、安全启动密钥加载假设你接受CMSIS对SystemInit的简化实现它不配置ADC时钟、不处理USB时钟、不校准内部RC。一旦项目需求偏离这些假设——比如你要把.data段放在CCM RAM0x10000000以加速访问或要在复位后立即读取备份寄存器BKP_DR1判断是否为看门狗复位——CMSIS Startup就变成了障碍。你得在Reset_Handler里打补丁而补丁代码必须比CMSIS原生代码更早执行这就回到了手写汇编的起点。6.2 手写汇编的“后悔药”可验证、可审计、可裁剪我见过太多项目因为一个__main调用失败团队花了三天排查最后发现是scatter文件里ARM_LIB_STACK大小设成了0x200512字节而__scatterload内部递归调用消耗了超过这个栈深导致栈溢出覆盖了.data。如果是手写汇编你会在Reset_Handler开头就看到LDR R0, 0x20000200 ; MSP 0x20000000 0x200 MSR MSP, R0这个地址一目了然你可以立刻用调试器查看0x20000200附近内存是否被意外写入。而CMSIS Startup里栈地址藏在__initial_sp符号背后你得先查scatter再查map文件再反汇编__main才能定位问题。更重要的是手写汇编让你对每一个字节负责。当你在NVIC_ISER写入0x40使能EXTI0时你知道这代表bit6当你在VTOR写入0x20000000时你知道这强制CPU从SRAM取向量。这种确定性在安全关键系统如某高校电机控制Demo中不是加分项而是准入门槛。从那以后我每次新建STM32F103项目第一件事就是删掉CMSIS Startup从零手写startup.s哪怕只写三行.stack 0x200、.data复制、BL __main。不是为了炫技而是为了在HardFault发生时我能第一时间说出“问题出在第7行的MSR MSP, R0因为R0是0x00000000”。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑