STM32F103汇编实战:从启动文件到WS2812B时序控制
简介一份基于Keil5的STM32F103汇编点灯程序包面向希望深入理解ARM Cortex-M3内核的嵌入式开发者和初学者。RAR压缩包将启动代码、Keil工程配置、编译输出与调试辅助文件整理在一起共19个文件整体约28KB。其中startup_stm32f10x_md.s是汇编启动文件负责中断向量表与堆栈初始化uvprojx和uvoptx是Keil工程文件双击即可打开还原实验环境map与lst分别呈现符号映射和反汇编列表便于核对每条指令对应的地址sct声明了链接布局axf与o为编译产物keilkilll.bat则可快速清理临时中间文件。通过点灯实验可以系统练习R0-R15通用寄存器、GPIO的MODER、OTYPER、OSPEEDR、PUPDR等配置寄存器以及LDR、STR、MOV、BL、B等核心指令和循环、跳转写法在Keil5中还能借助断点、单步、寄存器窗口观察硬件变化形成从源码到寄存器再到端口状态的直观对应。目前已有1646人学习适合想从汇编层面精调外设、优化执行效率并补充调试经验的开发者。 我最早被F103汇编程序折磨是在一块STM32F103最小系统板上。当时跟着教程用标准库点了个灯觉得一切都很简单直到某天程序跑飞打开反汇编窗口看到满屏看不懂的指令才发现自己对这块芯片其实一无所知。后来专门啃了ARM汇编指令和F103中文参考手册才发现那些所谓的玄学问题根因全在这条指令链里。这篇帖子就把我个人在STM32F103上从零写汇编的经验一次性讲透包括启动文件、寄存器操作、点灯和驱动WS2812B的时序控制以及汇编和C混合编程时最容易踩的坑。适合想看懂启动文件、遇到HardFault不知道从哪查、或者单纯想把底层彻底搞清楚的人。1. 为什么现在还有人写F103汇编1.1 启动文件之外的三种硬需求先说结论在F103上用纯汇编写应用不是为了复古纯粹是有需求。很多人用标准库或HAL库写一辈子C也未必写过一行汇编但工程里最靠近硬件的几个环节永远离不开汇编。第一个是启动。任何F103工程都少不了一个startup_stm32f10x_xx.s文件它定义栈、向量表和复位入口。就算你全程用C这段汇编也会在main之前先跑。第二个是精确时序。WS2812B这类单总线器件对电平宽度要求是纳秒级C函数调用的进出栈、编译器的指令调度都会引入抖动用汇编写延时才能把误差压到可接受范围。第三个是RTOS的上下文切换。像FreeRTOS的PendSV_Handler底层保存和恢复寄存器的工作用汇编最直接C语言无论如何写不出那种对CPU状态的精确控制。1.2 能读反汇编才算真的会用单片机还有一个看起来没那么刚需、但实际特别重要的场景排错。F103在运行中突然进HardFaultC语言层只能看到死循环或错误中断真正的现场在PC和LR里这时候如果完全看不懂反汇编基本就是盲人摸象。我印象很深的一次程序在-O2优化下出现一个极其诡异的闪灯时序故障C代码看着完全正常打开反汇编窗口才发现一个for延迟循环整个被优化器拆掉了编译器认为循环体内没有任何副作用直接把循环清空。类似的问题不懂汇编根本没法定位。所以这篇写的不是让你丢掉C去考古而是把汇编当作看懂芯片最后一道防线的工具。2. 启动文件你的C代码执行前发生了什么2.1 栈和向量表是“摆”出来的先看启动文件最核心的一小段Stack_Size EQU 0x400 AREA STACK, NOINIT, READWRITE, ALIGN3 Stack_Mem SPACE Stack_Size __initial_sp AREA RESET, DATA, READONLY __Vectors DCD __initial_sp ; Top of Stack DCD Reset_Handler ; Reset Handler第一行把栈大小定义为0x400也就是1KB。AREA STACK, NOINIT, READWRITE, ALIGN3 定义了一块可读写、不初始化的栈区域ALIGN3意思是8字节对齐。不要小看这个对齐AAPCS要求函数调用时栈地址按8字节对齐如果栈顶不对齐在C库和浮点计算里可能会出问题。SPACE 0x400 分配了1KB空间紧跟其后的 __initial_sp 就是这个栈顶地址。__Vectors 段是只读的DATA区域DCD伪指令用来定义4字节数据它把 __initial_sp 和 Reset_Handler 依次排进Flash。Cortex-M3复位后CPU从0x00000000读取初始SP从0x00000004读取复位向量地址这是芯片设计死的。F103的Flash地址在0x08000000系统启动时硬件会把地址0重映射到Flash区域所以向量表物理上放在0x08000000就能被正确读取。如果不理解这张表后面在中断里怎么配都白搭。2.2 Reset_Handler 的三步跳转再看复位处理函数Reset_Handler PROC EXPORT Reset_Handler [WEAK] IMPORT SystemInit IMPORT __main LDR R0, SystemInit BLX R0 LDR R0, __main BX R0 ENDPEXPORT将其导出为全局符号[WEAK]表示弱定义。IMPORT从其它模块引入SystemInit和__main。LDR R0, SystemInit 是伪指令把SystemInit函数地址加载到R0BLX R0跳过去并保存返回地址到LR。SystemInit在启动阶段配置时钟、Flash预取缓冲等这是F103能在72MHz跑起来的前提。之后通过LDR R0, __main 和 BX R0 跳入C库入口。重要的事情说三遍__main不是main函数。它是C库提供的初始化入口负责把RW段从Flash拷贝到RAM、把ZI段清零然后才调用你写的main。很多人手动改了Reset_Handler后程序“跑飞”或全局变量初始值不对多半就是跳成了main而不是__main。2.3 [WEAK] 符号和缺省中断看懂前面的代码就能明白中断服务函数是怎么被“注册”的。继续往下看向量表你会看到一长串DCD后面跟着HardFault_Handler、PendSV_Handler、SysTick_Handler这些符号每个符号都对应一个处理函数。关键点是这些符号都标了[WEAK]。如果整个工程里找不到同名强符号链接器就用默认实现如果你在C里写了一个void SysTick_Handler(void)链接器就会优先选择你的强符号中断自然而然被接管。这也是为什么在C里写个同名函数中断就能生效。反过来如果两个库同时定义同名弱符号行为就不可预测排查起来就很痛苦。3. 手写F103汇编必会的指令与立即数陷阱3.1 LDR/STR与伪指令LDR Rd,const绝大多数寄存器操作本质就是一次LDR读内存到寄存器和一次STR写寄存器到内存。比如读某个外设寄存器、修改某一位、再写回去编译后就是这几条指令的组合。这里最容易被初学者坑的是立即数加载。你可能会以为 MOVS R0, #0x40021018 能直接把地址放进寄存器但Thumb-2下MOVS的立即数并不是任意32位值都能编码它把一个8位基础值循环右移偶数位得到0xFF能直接写0x40021018这种地址就不行。汇编器要么报错要么生成一个意想不到的结果。遇到这种场景正确写法是使用伪指令LDR R0, 0x40021018这条伪指令不是一条真实机器指令。汇编器会在代码附近只读区放一个32位常量再生成一条LDR R0, [PC, #offset]从字面量池加载。所以用伪指令加载常量会额外占用一些Flash但可读性和正确性都更好。如果对Flash占用非常敏感可以用MOVW/MOVT组合构造任意32位立即数MOVW载入低16位MOVT载入高16位两条指令搞定。3.2 ORR/BIC修改寄存器某几位的标准姿势操作寄存器某一位最标准的姿势是读-改-写。置位用ORR清位用BIC。比如C代码里写 RCC-APB2ENR | (1 3)实际展开就是下面这段汇编LDR R0, 0x40021018 LDR R1, [R0] ORR R1, R1, #(1 3) STR R1, [R0]清位用BICBIC R2, R2, #0x0F 的意思是把R2的低4位清成0其它位保持不变。相比ANDBIC更直观可以直接写想清的位。测试某个位则用TST或AND。还有一个容易忽略的点这类读-改-写在多中断场景下并不原子。如果两个中断同时对同一个寄存器做修改中间可能丢更新。这个问题汇编没有自动解法必要时要关中断保护。3.3 关中断与批量压栈临界区保护的基本功嵌入式开发里关中断是家常便饭。C语言里调用__disable_irq()本质就是执行CPSID i。CPSID i屏蔽IRQCPSIE i恢复。在纯汇编里直接写这两条就行副作用极小速度比操作NVIC寄存器快得多。中断服务和函数调用都要用栈所以PUSH和POP是汇编里最常用的压栈指令。PUSH {R4, LR}一次把两个寄存器压到栈顶POP {R4, PC}把栈顶恢复到R4并把PC设置成LR等效于函数返回。Cortex-M3里PC在POP中是一个特殊用法直接把栈里保存的LR弹回PC顺便完成返回。当需要保存多个寄存器时PUSH {R4-R7, LR}这种批量写法非常省字节底层对应STMDB指令反过来批量恢复用LDMIA。理解这一点反汇编读起来会顺很多。4. 实战用汇编点亮LED并驱动WS2812B4.1 从最小系统板寄存器地址开始点灯STM32F103最小系统板上最常见的LED接法是PB0或PC13我这里以PB0为例。要让一个引脚输出高电平需要做三件事打开对应GPIO端口的时钟、把引脚配置成推挽输出、往BSRR寄存器写入置位或复位数据。查F103中文参考手册需要操作这几个地址寄存器地址作用RCC-APB2ENR0x40021018外设时钟使能bit3对应GPIOBGPIOB-CRL0x40010C00PB0~PB7引脚模式配置GPIOB-BSRR0x40010C10置位/复位端口引脚CRL的每4位对应一个引脚PB0是低4位MODE011表示输出50MHzCNF000表示通用推挽输出。这是F103最容易看错的地方CRL管低8个引脚CRH管高8个引脚别把PB13的配置写到CRH然后在CRL里找半天。4.2 点灯汇编RCC开启时钟、配置GPIO、BSRR拉电平下面给一段核心汇编流程RCC_APB2ENR EQU 0x40021018 GPIOB_CRL EQU 0x40010C00 GPIOB_BSRR EQU 0x40010C10 main_loop LDR R0, RCC_APB2ENR LDR R1, [R0] ORR R1, R1, #0x08 STR R1, [R0] LDR R0, GPIOB_CRL LDR R1, [R0] ORR R1, R1, #0x03 STR R1, [R0] blink LDR R0, GPIOB_BSRR MOVS R1, #1 STR R1, [R0] LDR R2, 600000 delay SUBS R2, R2, #1 BNE delay LDR R0, GPIOB_BSRR LDR R1, 0x00010000 STR R1, [R0] LDR R2, 600000 delay2 SUBS R2, R2, #1 BNE delay2 B blinkBSRR的bit0写1把PB0拉高bit16写1把PB0拉低写完立即生效不需要先读再写比ODR方便。这里有个执行时间细节delay循环里SUBS是1个周期BNE在跳转时是2个周期循环体大约3个周期600000次循环在72MHz下大概25ms肉眼看起来就是闪烁。如果你在调试器里单步跑建议先改成小值验证。4.3 WS2812B纳秒级时序NOP延迟循环的编排WS2812B是单总线全彩LED数据协议靠高低电平的宽度区分0和1。它要求的时序精度比GPIO点灯高一个数量级先把一组常用参数列出来信号持续时间ns72MHz下约合周期数T0H35025T0L80058T1H70050T1L60043这个参数不是唯一的实际要在WS2812B允许范围内T0H 200~500ns、T0L 650~950ns、T1H 550~850ns、T1L 450~750ns。选中间值相对稳妥。如果用C写延时函数调用时PUSH、BL、POP各占若干周期再加上编译器优化带来的不确定性最后波形往往和理论计算对不上。更麻烦的是优化等级一变整个延时全变。用汇编写时延的好处是你能精确数出每条指令占多少个周期。4.4 发送前关中断与实测微调纯汇编按位发送的核心是一个指令序列。比如发送0码大致是拉高电平、用几个NOP凑够25个周期、拉低、再用NOP凑够58个周期、返回。NOP就是空操作占1个周期用多个NOP堆出目标延迟。片段长这样send_0 LDR R0, GPIOB_BSRR MOVS R1, #1 STR R1, [R0] NOP NOP ; 继续补 NOP 直到接近25个周期 LDR R1, 0x00010000 STR R1, [R0] NOP NOP ; 继续补 NOP 直到接近58个周期 BX LR这里有个反直觉的事NOP并不是唯一的延迟来源LDR、STR本身也要占几个周期BX LR返回也要周期所以最后几个NOP必须留着实测再调整。千万别按指令序号想当然一定要用逻辑分析仪或示波器看实际波形然后再增删NOP。另一个关键是发送整帧数据前必须CPSID i关中断否则任何一个中断响应都会在位时序里炸出一个几百纳秒的毛刺整条灯带颜色直接乱掉。帧发送完成后再CPSIE i恢复。这个注意点放在C里同样适用很多WS2812B驱动不稳定不是时序参数不对而是忘了关中断。5. 汇编与C互调、中断导汇编和调试避坑5.1 ATPCS调用约定哪些寄存器要“保命”写F103汇编不意味着整个工程全用汇编更多场景是C调汇编函数或者汇编里调C函数。为了不把寄存器玩坏AAPCS必须清楚。规则很简单R0~R3用来传参多余参数压栈返回值放R0。R4~R11是callee-saved被调函数如果要用它们必须自己保存并在返回前恢复调用者可以放心认为这些寄存器没变。R12是临时寄存器LR保存返回地址。按这个规则写一个汇编延时函数并供C调用很直接EXPORT asm_delay asm_delay ; 入口 R0 循环次数 loop SUBS R0, R0, #1 BNE loop BX LRC里声明 extern void asm_delay(uint32_t ticks); 就能直接调用。如果汇编函数要调用C函数汇编侧要把入参放到R0~R3然后用BL调过去。注意BL会覆盖LR所以如果这个汇编函数返回后还需要之前的LR必须在进入时PUSH {LR}返回时POP {PC}。5.2 中断里调用C函数的经典模板在SysTick中断里想调C处理函数最稳的模板是这样SysTick_Handler PROC EXPORT SysTick_Handler [WEAK] IMPORT on_systick PUSH {R4, LR} BL on_systick POP {R4, PC} ENDP为什么压栈要带R4因为Cortex-M3在进入中断时硬件已经自动把xPSR、PC、LR、R0~R3、R12压好了你带R4纯粹是为了凑栈对齐。AAPCS要求函数调用时栈8字节对齐如果只PUSH {LR}栈会变成4字节对齐。实测中很多场合不会立刻出问题但一旦在中断里调用了浮点库或某些对对齐敏感的库就会遇到玄学HardFault。直接养成PUSH {R4, LR}的好习惯能省掉很多半夜排查。5.3 调试中的反直觉坑立即数、LR bit0与map文件先说最普遍的坑立即数。前面提过Thumb-2的MOVS不能构造任意32位常量。错误写法是把0x40021018直接塞进MOVS R0, #0x40021018汇编器如果宽容可能生成一个循环右移后的错误值如果严格直接报错。凡是复杂常量一律用LDR伪指令或MOVW/MOVT。还有一个坑是LR的bit0。Cortex-M3只支持Thumb-2函数地址最低位必须是1。你打开map文件看函数入口地址往往都是0x08000xxx这种奇数地址这不是错乱。如果手动用一个偶数地址去BX芯片会直接进UsageFault或者行为异常。调试F103汇编项目map文件是必看的东西里面会列出每个函数的起始地址、栈大小、堆大小。遇到HardFault时先看LR是普通地址还是EXC_RETURN。如果LR等于0xFFFFFFF9说明异常发生在线程模式继续用反汇编窗口定位PC如果LR是0xFFFFFFED则是在中断模式。顺着调用栈往回推比在C代码里瞎猜快得多。另一个和掉电保存数据相关的场景直接对F103内部Flash做写操作时必须等FPEC的BSY位清0写之前还要解除写保护。用汇编轮询BSY标志时建议把整个擦写流程放到关中断状态下执行否则写入过程中被中断打断轻则BSY卡死重则数据错乱这些都是我在实际项目里踩过的。说到底汇编不是用来炫技的它是一把把单片机最后一块遮羞布掀开的钥匙。我在实际调试中最大的体会是遇到底层问题先把map文件和反汇编窗口打开十次里有八次能直接找到答案。你不需要成为汇编大师但至少要把启动文件和点灯用汇编跑一遍这比背一百条指令都管用。本文还有配套的精品资源点击获取