☰
STM32F103纯汇编开发:从Reset Handler到外设操作的底层契约
2026/10/21 3:37:11 网站建设 项目流程

简介:本资源是一份面向嵌入式初学者与底层开发爱好者的STM32F103汇编语言实践项目,聚焦“点灯”这一经典入门实验,帮助读者深入理解Cortex-M3内核寄存器操作、GPIO配置流程及Keil5汇编开发全流程。压缩包共19个文件,涵盖核心启动文件(startup_stm32f10x_md.s)、工程配置(uvprojx/uvoptx)、编译输出(axf/map/lst/o)、链接脚本(sct)、调试支持(JLinkSettings.ini)及一键清理脚本(keilkilll.bat)等,类型丰富且结构完整,便于学习编译构建机制与调试技巧。资源包仅28KB,轻量精炼,无冗余内容。已有1652人学习下载,适合希望突破C语言依赖、掌握硬件直控能力的学习者。通过该资源,可系统实践寄存器级GPIO初始化、MOV/LDR/BL等关键指令应用、中断向量表配置基础,并借助Keil5单步调试直观观察PC、SP、R0-R15等寄存器变化,夯实嵌入式底层开发根基。

1. STM32F103汇编程序:为什么在Keil5里写纯汇编,反而比C语言更难触发硬件异常?

你可能试过——在Keil5中新建一个.asm文件,照着参考手册抄几条LDR R0, =0x40010800、STR R1, [R0, #0x0C],烧录后LED不亮;换成等效的C代码(GPIOA->BSRR = 1 << 0;),却立刻生效。这不是玄学,而是STM32F103的启动流程、向量表布局、寄存器映射和ARM Cortex-M3异常模型共同作用下的必然结果。纯汇编绕过了CMSIS标准初始化、中断向量重定向、栈指针自动配置这些“默认保障”,把所有底层契约都摊开在你眼前:哪条指令改了SP?哪个地址没对齐?哪次未使能时钟就访问外设?全得自己扛。这份《STM32F103汇编程序》资源不是教学模板,而是一份可运行的“契约验证集”——它用最简汇编代码,逐行暴露C语言隐藏的初始化细节,帮你把Reset Handler、SysTick配置、NVIC使能这些黑匣子,变成可单步、可修改、可证伪的确定性逻辑。搞懂它,你才真正看懂Keil5生成的.map文件里__main之前那几百字节到底干了什么。


2. 启动代码:Reset Handler如何接管CPU控制权并完成最小化初始化

2.1 向量表必须严格对齐且位置不可变:为什么0x08000000处的前8个字必须是SP初始值和Reset Handler入口

ARM Cortex-M3规定:复位后,CPU从地址0x00000000(或重映射后的0x08000000)读取主栈指针(MSP)初始值,紧接着读取复位向量(Reset Handler地址)。这个向量表是硬编码协议,不允许任何跳转、计算或条件分支。常见错误是把向量表定义成.data段变量,导致链接时被放到RAM区——CPU上电第一刻就读错地址,直接锁死。

正确做法是在汇编文件开头强制定义只读向量表:

AREA RESET, DATA, READONLY EXPORT __Vectors __Vectors DCD 0x20005000 ; MSP初始值:指向SRAM末尾(0x20000000 + 20KB) DCD Reset_Handler ; 复位处理函数入口地址 DCD NMI_Handler ; NMI中断向量(必须占位,即使为空) DCD HardFault_Handler ; 硬故障向量(关键!调试必看) ; ... 后续13个向量(SVC、PendSV、SysTick等)均需显式占位

提示:DCD(Define Constant Doubleword)生成32位字,确保每个向量占4字节;0x20005000是STM32F103CB典型SRAM大小(20KB)的末地址,必须与实际芯片SRAM容量匹配,否则栈溢出无声崩溃。

2.2 Reset_Handler必须完成三件不可省略的事:栈指针设置、时钟使能、向量表重映射

C语言启动文件(如startup_stm32f10x_md.s)里看似简单的Reset_Handler,在汇编中每一步都需手动实现:

AREA |.text|, CODE, READONLY ENTRY Reset_Handler ; 1. 设置主栈指针(MSP)——这是CPU复位后唯一信任的栈 LDR SP, =0x20005000 ; 2. 使能系统时钟:RCC_CR寄存器第0位(HSION)默认置1,但PLL和AHB/APB时钟未开 LDR R0, =0x40021000 ; RCC_BASE地址 LDR R1, [R0, #0x00] ; 读RCC_CR ORR R1, R1, #(1<<24) ; 置位PLLON位(若需PLL) STR R1, [R0, #0x00] ; 3. 使能GPIOA时钟:RCC_APB2ENR第2位(IOPAEN) LDR R0, =0x40021018 ; RCC_APB2ENR地址 MOV R1, #0x00000004 STR R1, [R0] ; 4. (可选但强烈建议)重映射向量表到SRAM或FLASH,便于动态更新 LDR R0, =0xE000ED08 ; VTOR寄存器地址(Vector Table Offset Register) LDR R1, =0x20000000 ; 向量表新基址(SRAM起始) STR R1, [R0] ; 5. 跳转到C语言main函数(若需混合编程) IMPORT main BL main B .

这段代码揭示了C语言“自动”完成的底层动作:

  • LDR SP, =0x20005000是CPU执行任何指令前的唯一依赖,错则全盘崩溃;
  • RCC_APB2ENR写入必须在访问GPIOA_BSRR之前,否则总线返回0xFFFFFFFF(未使能外设读取返回全1);
  • VTOR配置让后续中断向量可动态修改,否则所有中断都只能走固定FLASH向量表。

2.3 为什么不能直接用B main?——C运行时环境(CRT)缺失导致的隐式崩溃

新手常犯错误:在Reset_Handler末尾写B main,认为等同于C的main()调用。但main函数签名是int main(void),其调用约定要求:

  • R0-R3传参(此处无参,但调用框架仍存在);
  • LR寄存器需保存返回地址;
  • C库函数(如memset、memcpy)依赖.data段复制和.bss段清零——这由__main函数完成,而非main本身。

若跳过__main,.data中全局变量仍为Flash值(非初始化值),.bss中变量为随机内存垃圾。例如:

uint32_t flag = 0x12345678; // .data段,需从Flash拷贝到RAM uint32_t counter; // .bss段,需清零

纯汇编中若未手动实现.data拷贝(_sidata,_sdata,_edata符号),flag永远是Flash里的旧值;counter可能是任意值。这就是“LED不亮”的真实原因:你以为初始化了GPIO寄存器,其实操作的是未清零的垃圾内存。


3. 外设寄存器操作:为什么STR R1, [R0, #0x0C]可能根本没写进GPIOA_BSRR?

3.1 寄存器地址映射不是“直接等于”,而是受APB2时钟门控和字节序影响

STM32F103的GPIOA_BSRR寄存器物理地址为0x4001080C,但这只是理论地址。实际能否写入,取决于三个前置条件:

  1. APB2总线时钟已使能(前文RCC_APB2ENR[2]);
  2. 地址必须字对齐:ARM Thumb指令STR要求目标地址低2位为0(即4字节对齐),0x4001080C满足;
  3. 写操作必须符合寄存器位宽:BSRR是32位寄存器,STR(32位存储)合法,但STRB(8位)会触发总线错误。

验证方法:在Keil5调试模式下单步执行,观察Memory窗口0x4001080C值是否变化。若不变,检查RCC_APB2ENR寄存器第2位是否为1。

3.2 BSRR寄存器的“原子性”陷阱:为什么分两次写BSRR[0]和BSRR[16]不如一次写BSRR[0:16]

BSRR寄存器设计为“写1置位/写1复位”:

  • 写BSRR[0:15]中某位为1 → 对应ODR位置1(点亮LED);
  • 写BSRR[16:31]中某位为1 → 对应ODR位清零(熄灭LED)。

常见错误写法:

MOV R1, #1 STR R1, [R0, #0x0C] ; 写BSRR[0]=1 → PA0置1 MOV R1, #(1<<16) STR R1, [R0, #0x0C] ; 写BSRR[16]=1 → PA0清零

问题在于:两次STR之间,若发生中断,其他代码可能修改ODR,导致状态不一致。BSRR的原子性仅保证单次32位写入的完整性。正确做法是合并操作:

MOV R1, #1 ; 置位PA0 ORR R1, R1, #(1<<16) ; 同时清零PA0(无效,因PA0已为1) STR R1, [R0, #0x0C] ; 单次写入,安全

或更清晰地分离:

LDR R1, =0x00010001 ; BSRR[0]=1(置位)+ BSRR[16]=1(清零)→ 实际只影响PA0 STR R1, [R0, #0x0C]

3.3 汇编中访问外设必须用LDR/STR,禁用MOV直接加载大立即数

ARM Thumb指令中,MOV只能加载8位立即数(0–255)。尝试MOV R0, #0x40010800会报错:#0x40010800超出范围。正确方式是分两步:

LDR R0, =0x40010800 ; Keil汇编器自动拆解为MOVW+MOVT(ARMv7-M) ; 或手动(兼容老版本): ; MOVW R0, #0x0800 ; 低16位 ; MOVT R0, #0x4001 ; 高16位

LDR R0, =xxx是伪指令,Keil5将其编译为最优指令序列。若手动写MOVW/MOVT,需确认目标芯片支持ARMv7-M指令集(STM32F103支持)。


4. 中断与异常:HardFault_Handler为何是汇编调试的第一道关卡

4.1 HardFault触发的四大高频原因及寄存器线索定位法

当汇编程序跑飞,HardFault_Handler被触发。此时不靠printf,而靠SCB->HFSR(HardFault Status Register)和SCB->CFSR(Configurable Fault Status Register)定位:

CFSR[Bit]含义典型汇编错误场景
CFSR[0]IACCVIOL(指令访问违规)访问未映射地址(如LDR R0, [R1]时R1=0)
CFSR[1]DACCVIOL(数据访问违规)STR R2, [R3]时R3指向ROM或非法区域
CFSR[7]UNDEFINSTR(未定义指令)使用了Cortex-M3不支持的指令(如SWP)
CFSR[12]STKOF(栈溢出)SP初始值过小或递归调用未限制深度

调试步骤:

  1. 在HardFault_Handler中暂停,查看CFSR值(Keil调试器Register窗口输入SCB->CFSR);
  2. 若CFSR[1]置位,检查BFAR(Bus Fault Address Register)获知非法访问地址;
  3. 回溯调用栈:LR寄存器值减去2(Thumb指令偏移),即为触发Fault的上一条指令地址。

4.2 SysTick定时器配置:为什么STK_LOAD写入后必须等待STK_CTRL[16]就绪

SysTick初始化需三步,缺一不可:

LDR R0, =0xE000E010 ; STK_CTRL地址 MOV R1, #0 STR R1, [R0] ; 清零控制寄存器(禁用) LDR R0, =0xE000E014 ; STK_LOAD地址 LDR R1, =9999 ; 1ms@10MHz STR R1, [R0] LDR R0, =0xE000E010 MOV R1, #(1<<2)|(1<<1)|(1<<0) ; CLKSOURCE=1, TICKINT=1, ENABLE=1 STR R1, [R0] ; 关键:写ENABLE后需等待COUNTFLAG清零(表示计数器已就绪) WaitReady LDR R0, =0xE000E010 LDR R1, [R0] TST R1, #(1<<16) ; 测试COUNTFLAG位 BNE WaitReady

STK_CTRL[16](COUNTFLAG)在计数器首次从LOAD值减至0时置位。若未等待就启用中断,可能导致第一次中断丢失或延迟。

4.3 NVIC使能必须配合向量表偏移(VTOR):为什么NVIC_ISER写入无效

NVIC寄存器(如NVIC_ISER)位于0xE000E100,但写入使能位后中断仍不触发,往往因为:

  • 向量表仍在默认0x00000000,而你的中断Handler定义在FLASH中(0x08000000+);
  • VTOR未配置,CPU从中断时仍跳转到0x00000000+4*IRQn,该地址无有效代码。

解决方案:

  1. 在Reset_Handler中配置VTOR指向你的向量表(如0x08000000);
  2. 确保向量表中对应IRQn位置填入Handler地址(如EXTI0_IRQHandler);
  3. 写NVIC_ISER[IRQn/32]使能中断。
LDR R0, =0xE000E100 ; NVIC_ISER0地址 MOV R1, #(1<<6) ; 使能EXTI0(IRQn=6) STR R1, [R0]

5. 常见问题排查:5条血泪经验总结的汇编翻车现场

5.1 现象:程序烧录后完全无反应,Keil调试器无法连接

原因:向量表首地址(MSP)设置错误,导致CPU从非法栈地址取指,立即进入HardFault,且HardFault_Handler未定义(向量表第二项为空)。
解决:检查向量表第一项DCD值是否为有效RAM地址(如0x20005000),并在向量表第二项明确定义Reset_Handler符号。

5.2 现象:GPIO寄存器读写值始终为0xFFFFFFFF

原因:对应外设时钟未使能(如GPIOA需RCC_APB2ENR[2]),或访问了未实现的寄存器偏移(如误写0x40010810而非0x4001080C)。
解决:用Keil Memory窗口查看RCC_APB2ENR值,确认bit2=1;核对参考手册“Memory Map”章节确认寄存器偏移。

5.3 现象:SysTick中断只触发一次,之后不再进入

原因:STK_VAL(当前计数值)未清零,导致下一次计数无法从LOAD重新开始;或STK_CTRL[16](COUNTFLAG)未在中断服务中清除。
解决:在SysTick_Handler末尾添加LDR R0, =0xE000E010; MOV R1, #0; STR R1, [R0, #0x04](写STK_VAL清零)。

5.4 现象:LDR R0, =0x40010800编译报错“out of range”

原因:使用了不支持MOVW/MOVT的老版本ARM汇编器(如ARMCC v5.02以下),或目标处理器未设为Cortex-M3。
解决:Keil5中Project → Options → Target → ARM Compiler选择Use default compiler version,并确认Processor设为Cortex-M3。

5.5 现象:混合编程时C函数调用汇编函数,参数传递错乱

原因:ARM AAPCS调用约定要求:R0-R3传参,R4-R11为callee-saved寄存器。汇编函数若修改R4-R11未保存/恢复,会破坏C函数上下文。
解决:在汇编函数开头PUSH {R4-R11},结尾POP {R4-R11};或仅使用R0-R3和R12(scratch register)。


6. 设计取舍思考:为什么坚持手写汇编?一份资源教会我的三重验证习惯

这份《STM32F103汇编程序》最珍贵的不是代码本身,而是它强迫我建立的三层验证闭环:硬件层验证、工具链层验证、时序层验证。

硬件层验证,是直面数据手册的敬畏。比如配置AFIO时,我曾以为AFIO_MAPR(0x40010004)写入即可,直到发现AFIO_MAPR[23](SWJ_CFG)默认为10(JTAG-DP + SW-DP),若想释放PA13/PA14为GPIO,必须先写00(Full SWJ),再写01(SW-DP only)——这个顺序在手册“Debug port configuration”小节有明确时序要求,C语言封装库自动处理,而汇编逼你逐字阅读。

工具链层验证,是理解Keil5链接脚本的真相。.map文件里RESET段起始地址0x08000000,但向量表实际在0x08000000还是0x08000004?答案是:0x08000000存放MSP,0x08000004才是Reset_Handler。很多汇编教程忽略这点,导致重映射失败。我从此养成习惯:每次修改scatter文件,必用fromelf --text -c xxx.axf反汇编,对照__Vectors符号地址。

时序层验证,则是中断嵌套的生死线。写SysTick Handler时,我曾用B指令跳转到C函数,结果发现main中while(1)循环被中断打断后,返回地址错乱。根源是B不保存LR,而BL才保存。现在我所有中断Handler入口第一行必写PUSH {LR},出口必写POP {PC}——这不是教条,是无数次PC寄存器飘到0x00000000后练出的肌肉记忆。

从那以后我每次写裸机驱动,都强制走一遍这三重验证:

  1. 打开Reference Manual PDF,Ctrl+F搜寄存器名,精读“Description”和“Note”;
  2. 编译后立刻查.map,确认向量表、栈、堆地址无重叠;
  3. 调试时打开Core Peripherals → NVIC,观察Pending/Active状态,用ITM打点验证中断频率。

汇编不是为了替代C,而是为了在C失效时,你还能亲手拧紧每一颗螺丝。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询