STM32F103汇编工程实战:从启动文件到寄存器点灯全解析
简介这是基于Keil5的STM32F103汇编点灯工程面向嵌入式初学者和希望深入理解Cortex-M3内核底层机制的开发者。工程从零配置GPIO直接操作寄存器完成LED点亮涵盖R0-R15通用寄存器、GPIOx_MODER等配置寄存器以及LDR、STR、MOV、BL等常用汇编指令能够帮助读者掌握硬件寻址、程序跳转循环与Keil5断点调试等关键技能。压缩包共19个文件包括Keil5工程源文件.uvprojx、汇编启动文件.s、编译生成的map/lst/axf文件、链接脚本.sct以及对象文件.o等整体约28KB结构紧凑便于对照源码与编译输出进行学习。目前已有1650人学习下载。该工程以点灯这一典型示例串联起寄存器操作、外设控制与程序流程设计适合在入门汇编后进一步尝试定时器、中断或串口等扩展练习是理解STM32底层工作原理的实用参考。1. 从 Keil5 打开 STM32F103 汇编工程这不是黑魔法是启动代码的真实面貌很多人看到「STM32F103 汇编程序」第一反应是劝退觉得都 2024 年还在用汇编开发嵌入式多少有点「行为艺术」。实际上你翻开任何一个正经的 STM32 工程的启动文件看到的都是汇编只是平时没人让你读它。这份资源就是把启动文件、寄存器操作、点灯逻辑全部换成汇编来写在 Keil5 的模拟器里不用接板子就能完整跑通适合两类人一类是刚学完单片机原理、想亲眼看看上电后芯片到底先干哪几件事的新手另一类是写 C 写过几年、想确认自己理解的启动流程到底有没有偏差的熟手。它不是要你告别 C而是把平时封装在黑匣子里的那几步——建栈、查栈、初始化时钟——摊开让你看明白。2. 骨架先立住启动文件里藏着芯片的上电真相2.1 一个能编译的汇编工程最小要哪几样在 Keil5 里新建一个纯汇编的 STM32F103 工程比想象中简单但也比想象中挑剔。一个能正常编译、能烧录、能仿真跑起来的最小工程至少要包含三样东西启动汇编文件、分散加载文件也就是 scatter 文件和一个空的 main 函数——注意这里说的是工程结构上的 main 函数汇编工程里也可以直接写一个名为 main 的段去让 C 库帮忙初始化。文件清单如下文件作用是否必须startup_stm32f10x_hd.s中断向量表 Reset_Handler 汇编语法入口必须scatter 文件或 Keil 的 Target 选项卡自动生成描述 ROM 和 RAM 的地址范围必须业务汇编文件或内嵌汇编的 C 文件你真正要写的 LED 翻转、延时等逻辑非必须但没它你跑完复位就没活了打开 Keil5新建工程时会弹出来一个选择芯片型号的窗口选择某个 STM32F103 系列的大容量型号后Keil 会自动询问是否添加启动文件——你在这里直接点「是」得到的就是startup_stm32f10x_hd.s。很多新手拿到这个文件的第一反应是「看不懂删了吧」然后工程就开始报诡异的链接错误。实际上这个启动文件不是一个可读可不读的装饰品它是芯片上电后最先执行的一段代码所有 C 语言的运行前提都依赖它。2.2 数据段与执行段光看注释就知道怎么回事打开启动文件里面密密麻麻全是 EQU 和 DCD。EQU 相当于 C 语言里的#define用来给寄存器地址起名字DCD 则是声明一个 32 位数通常用来放向量表的入口地址。下面这个片段是几乎所有 STM32F103 启动文件里都会出现的部分值得一行一行看; Stack Configuration Stack_Size EQU 0x00000400 AREA STACK, NOINIT, READWRITE, ALIGN3 Stack_Mem SPACE Stack_Size __initial_sp ; Vector Table Mapped to Address 0 at Reset AREA RESET, DATA, READONLY EXPORT __Vectors EXPORT __Vectors_End EXPORT __Vectors_Size __Vectors DCD __initial_sp ; Top of Stack DCD Reset_Handler ; Reset Handler DCD NMI_Handler DCD HardFault_HandlerStack_Mem 后面的SPACE指令是在 RAM 地址空间里预留一段连续区域大小就是前面定义的Stack_Size0x400 也就是 1KB。__initial_sp这个符号紧跟在预留区后面它的地址就是栈顶地址稍后会被放进向量表的第一个位置。向量表里第一项必须是栈顶地址第二项必须是复位入口这两项的DCD顺序绝对不能调换否则芯片上电后第一条指令就会从错误的地址取指。注意AREA RESET, DATA, READONLY这一句它把向量表放到了只读区域而前面的栈区域是READWRITE。如果你手动改启动文件时把向量表挪到读写区域某些仿真器会直接拒绝加载因为地址 0 处应该放 Flash 内容不应该有可写的属性。我见过一个项目因为想把向量表放到 RAM 里做动态跳转反手把READONLY改成了READWRITE结果整个工程在 Debug 模式下直接启动失败连断点都打不上。2.3 向量表手动布局中断入口不是玄学向量表的本质是一张 32 位地址表每个表项对应一个中断源表项的偏移量 中断号 × 4。STM32F103 的向量表从地址 0 开始也可以通过在 SCB-VTOR 寄存器里写值重定位但复位后默认是在 0 地址。启动文件里后面一长串DCD xxx_Handler实际上就是在 0x00、0x04、0x08……依次放置中断处理函数的入口地址。中断函数的名字是启动文件和中断向量表之间的契约。启动文件里EXPORT的是NMI_Handler这种弱定义符号如果你在 C 文件里定义了一个同名的强符号函数链接器会用你写的强符号覆盖掉启动文件里的弱定义。所以你会发现你在 C 里随便写一个void HardFault_Handler(void)不用任何声明就能让芯片在异常时进入你的函数——这不是玄学是汇编里的EXPORT和IMPORT在链接阶段帮你完成了符号解析。如果这份资源里附赠的启动文件和你的芯片型号不匹配比如小容量芯片用了startup_stm32f10x_md.s最大的区别在于中断向量表的长度和 Flash 容量设定。用 Keil5 打开工程后可以在编译输出的 map 文件里搜__Vectors_Size来确认实际生成的向量表大小。提示向量表不是越大越好。如果你用startup_stm32f10x_xl.s在大容量芯片上跑没问题但换到中容量芯片上向量表尾部那几个中断入口会落到不存在的地址上触发 BadISR_Handler 跳飞。3. 点亮一盏灯直接用汇编写业务逻辑3.1 把寄存器操作写进汇编开时钟、配模式、写 ODR现在从 ToDo 列表里去掉「但我只想看看启动流程」的阶段真正写一段可运行的汇编业务逻辑。以 PA0 点亮一个 LED 为例整个过程只需要三步打开 GPIOA 的时钟、把 PA0 配成推挽输出、往 ODR 寄存器写 1 或 0。这三个操作在 C 语言里是几句简单的赋值换成汇编就变成了LDR、STR、ORR的组合。下面这段代码是我在模拟项目X里反复调整过的版本去掉了位绑定和宏抽象只保留最原始的寄存器操作方便在仿真器里逐步看GPIOA_BASE EQU 0x40010800 RCC_APB2ENR EQU 0x40021018 GPIOA_CRL EQU 0x40010800 GPIOA_ODR EQU 0x4001080C AREA LED_CODE, CODE, READONLY THUMB EXPORT LED_Task LED_Task LDR R0, RCC_APB2ENR LDR R1, [R0] ORR R1, R1, #0x00000004 STR R1, [R0] LDR R0, GPIOA_CRL LDR R1, [R0] BIC R1, R1, #0x0000000F ORR R1, R1, #0x00000003 STR R1, [R0] LDR R0, GPIOA_ODR LDR R1, [R0] ORR R1, R1, #0x00000001 STR R1, [R0] BX LR第一段里RCC_APB2ENR是时钟使能寄存器的地址从[R0]里读出原值或上0x4的 bit2 使能 GPIOA 时钟。这里的0x4对应的是 bit2不是 bit0——APB2 外设时钟使能寄存器里GPIOA 的位是第 2 位。很多刚开始调的人以为使能 GPIOA 应该写 bit0结果灯死活不亮查半天发现是开了串口 1 的时钟。第二段操作GPIOA_CRL端口配置低寄存器PA0 对应的是 CRL 的最低四位先用BIC清掉低四位再或上3二进制0011表示 PA0 作为 50MHz 推挽输出。第三段就把 PA0 的电平拉高LED 亮。这段代码最后用BX LR返回。如果你的汇编函数是从 C 语言调用的LR 里保存着返回地址BX LR直接跳回去但如果是复位后由Reset_Handler直接调进来的必须保证调用方式一致否则执行流会跟着 LR 跑到随机地址上。所以后面讲避坑时我会专门说这个。3.2 用 Keil5 的调试器验证每一步把工程切到 Debug 模式Keil5 的模拟器可以直接在逻辑分析仪里观察引脚电平不需要接开发板。步骤很简单代码下载到仿真器后在LED_Task这一次进入时打开 Peripheral 窗口里的GPIOA选项卡然后单步执行。你会看到 CRL 的值在STR指令执行后从 0x44000000 变成 0x44000003ODR 从 0 变成 1。在纯汇编工程的调试中有两点比 C 工程更值得注意。一是观察当前PC和SP的值复位后 PC 应该是 0x08000000 附近SP 应该是Stack_Mem的末尾地址这两个值和__initial_sp的对应关系是验证向量表布局是否正确的直接证据。二是看编译器生成的代码里是否插入了大量 LDR 字面量池如果你写的常量没法编码成立即数汇编器会在代码段末尾放一个常量池然后LDR Rn, value变成从 PC 相对地址读值。这个行为是正常的但如果你在仿真里看到LDR指令后面跟着一个跳变到某个莫名其妙地址的分支十有八九是字面量池没有对齐。3.3 为什么这条路比库函数慢但值得走一遍STM32F103 主频 72MHz一条汇编指令通常在 1~3 个周期内完成操作 GPIO 的开销比库函数调用少了至少一个函数栈帧的建立和拆解。不过在现代工程里这点性能差距根本不构成「用汇编写业务」的理由真正值得的是另一件事把芯片的寄存器手册当成唯一的权威而不是把 HAL 库的实现当成事实。HAL 库里HAL_GPIO_WritePin的调用路径长且杂断言、锁、模式切换都在里面出问题时你很难判断底层到底做了什么。而上面这段汇编每一行对应手册上的一句话时钟控制寄存器 bit2 是 GPIOA 使能位CRL 低四位是 MODE0CNF0 紧随其后。仿真器里跑一遍值是怎么变的一目了然。这个功夫练完之后你再回去读标准外设库会发现库函数里的GPIO_InitStructure不过是把这几个寄存器操作封装成了结构体赋值水位明显不一样。4. 混合编程汇编和 C 在同一个工程里怎么握手4.1 从 C 调汇编函数符号导出与传参规则实际开发中不会有一个项目完全是汇编更多的情况是主逻辑用 C性能关键段落用汇编。从 C 调用汇编函数最关键的动作是「符号可见」和「参数寄存器约定」。在 ARM 的 AAPCS 调用约定里前四个参数放在 R0~R3 中返回值放在 R0 中如果参数超过四个剩下的会压栈。汇编函数想被 C 调用必须在汇编里用EXPORT把它导出同时在 C 端做 extern 声明。拿实现一个简单的延时函数举例汇编文件里这样写AREA DELAY_ASM, CODE, READONLY THUMB EXPORT Delay_Asm Delay_Asm LDR R2, 72000000 LDR R3, 0 Loop ADDS R3, R3, #1 CMP R3, R2 BCC Loop BX LRC 端只需要extern void Delay_Asm(uint32_t ms);就可以直接调用。需要特别留意的有两点。第一编译选项里务必要确认 Code Generation 选了 Thumb 模式STM32F103 是 Cortex-M3 内核只支持 Thumb-2 指令集如果你把汇编文件编译成 ARM 指令集芯片上电执行第一条指令就进 HardFault。第二汇编文件里不能写AREA |.text|, CODE, READONLY这种和 C 编译器默认段重名的写法否则链接器会抱怨段属性冲突。4.2 在 C 文件里嵌入汇编__asm 的写法和边界另一种混合方式是直接在 C 文件里用__asm关键字嵌入汇编代码Keil 的 AC5 编译器对这种写法的包容度尚可但有几个边界必须知道。第一种是定义嵌入式汇编函数__asm void my_nop_delay(unsigned int count) { loop SUBS count, count, #1 BNE loop BX lr }注意这种写法下参数通过寄存器直接传递但你不能再在 C 代码里用return来返回也不能在汇编里访问变量名。第二种是在函数体内插入汇编片段void delay_cycles(unsigned int cycles) { __asm volatile { MOV R0, #0 ADD R0, R0, #1 BX LR } }volatile告诉编译器这段代码有副作用不能因为「你没用变量就把它优化掉」。这在 Keil 的优化等级开到 O3 时特别重要——我曾经写过一个纯寄存器操作的汇编片段被编译器以「结果未使用」为由直接删掉整段优化成了空函数灯当然不亮。查了很久才意识到是优化等级的问题后来强制加上volatile才恢复正常。4.3 链接脚本对段的安排MEMORY 描述不能被汇编感知进入混合工程后链接脚本的重要性立刻显现。Keil 工程的分散加载文件通常会自动生成但你得知道它干了什么。看下面这个典型的 STM32F103 scatter 文件片段LR_IROM1 0x08000000 0x00080000 { ER_IROM1 0x08000000 0x00080000 { *.o (RESET, First) *.o (LED_CODE) *(InRoot$$Sections) } RW_IRAM1 0x20000000 0x00010000 { .ANY (RW ZI) } }First的意思是让RESET段尽量放在地址 0x08000000 起始处向量表必须满足这个条件。而你的LED_CODE段会排在 RESET 段之后地址由链接器决定。汇编文件里的AREA名字只要和 scatter 文件里提到的名字一致就能被正确路由但如果你的汇编文件里写了一个 scatter 描述里没有的段名它会被扔进*或.ANY的统一桶里位置不确定如果依赖绝对地址访问就会有坑。注意ARM Compiler 6 和 AC5 在 scatter 文件的语法上有细微差别直接从旧工程搬*.sct文件到 AC6 工程下往往会报LR_IROM1被重复定义的错误。资源里如果带了.sct文件建议先检查是不是非标准写法。5. 避坑Keil5 汇编工程的五个经典翻车现场5.1 常见报错一Error: A1137E Unexpected characters at end of line现象把 C 语言风格的语句写在汇编文件里或者某一行末尾多了一个空格Keil 直接报A1137E: Unexpected characters at end of line指向的位置往往是行尾但看起来干干净净。原因汇编器对行尾的空白和注释符号很敏感。KEIL 的 armasm 要求注释必须用分号;开头//和/* */都会被当成指令字符处理更隐蔽的是宏定义或者 EQU 后面如果被复制粘贴进了不可见字符比如从网页直接复制的代码里带 UTF-8 的空格也会触发这个报错。解决用垃圾编辑器打开汇编文件开启「显示所有字符」把所有行尾统一成 CRLF 换行注释全部改成;如果是从 PDF 或网页里复制的起始文件建议挨个删掉行尾再重新键入换行。这种问题在纯汇编工程里出现频率极高几乎每个第一次手写 STM32 汇编的人都会撞到。5.2 常见报错二链接时报 Undefined symbol现象编译通过链接阶段报Undefined symbol Reset_Handler或者自定义的LED_Task找不到定义。原因符号在汇编文件里定义了但没有EXPORT或者文件名/AREA 名写错导致目标文件没被链接器收录。还有一种情况是符号大小写不匹配armasm 里符号名区分大小写Reset_Handler和reset_handler是两个符号。ARM 链接器对大小写敏感的程度比 GCC 还苛刻。解决在汇编源文件里所有需要被外部引用的符号都必须写成EXPORT xxx同时确认工程的 source group 里有没有把这个.s文件真正加进去。这个坑看起来低级但在项目从 C 转汇编的过渡阶段非常容易出——你把一个已有的.s文件从文件夹里拖进了 Keil但它的文件名是中文或者路径里有空格编译器能编译却找不到符号。5.3 常见报错三Debug 卡在 HardFault_Handler现象程序在仿真器里启动还没执行到 main调试器就调入了HardFault_HandlerSP 和 PC 的值完全不是预期值。原因八成是栈指针初始化出了问题。如果你在启动文件里定义了Stack_Mem但向量表第一个元素写的不是__initial_sp而是某个立即数甚至漏掉了第一个 DCD 项复位后处理器取不到正确的 MSP 值。另一种情况是AREA STACK被放在READONLY区域Keil 把它分配进了 Flash 地址MSP 指向了只读空间任何压栈操作都会立即触发总线错误。解决回到启动文件的向量表部分确认第一项是DCD __initial_sp再看散列文件的 RW_IRAM1 起始地址是否和芯片的 RAM 基地址匹配。对于 STM32F103 中容量芯片RAM 是 0x20000000大容量是 0x20000000 但容量更大不同型号的 RAM 大小需要同步修改Stack_Size和Heap_Size。5.4 常见报错四汇编里写 C 风格注释直接编译失败现象源码看起来「逻辑完全没问题」但就是编译报错错误位置行号和内容在逻辑上完全对不上。比如在汇编指令后面写了// enable clockarmasm 可能会把//解释为一个新的操作符随后把整行截断报出各种莫名其妙的错误。原因这是上一类问题的变体本质是汇编语法里注释符是分号不是双斜杠。由于 C 语言视觉习惯更强很多人从其他文件复制代码行时把注释也带进来了于是编译器解析到//时就认为行尾出现了非法字符。解决在 Keil 的编辑器里用正则搜索把;以外的注释符统一替换掉。特别注意宏展开里的;也会被当成注释起点如果汇编里用了MACRO定义或重复块分号一定要谨慎出现。我在某次跑一份启动文件的修改版时宏内部一行原本该执行BCC %B0的指令被加分号注释掉了结果整个循环直接跳过延时函数变成空操作。5.5 常见报错五Reset_Handler 里忘了跳转导致上电死循环现象烧录后程序完全没反应仿真器里 PC 停在非常小的地址上比如 0x00000000 附近转圈setup 里的中断表看起来也对。原因有些手写的启动文件在Reset_Handler里做完堆栈和时钟初始化后直接用LDR R0, main替换了BL main。LDR改写了 R0后面BX R0跳转确实能到 main但如果你在Reset_Handler末尾直接写了B .这种死循环留作故障保险而主逻辑函数又不是通过BL调用而是跳过编译器的 microLIB 初始化直接跳程序就把保险当成了主路执行永久停在原地。解决标准启动文件里 Reset_Handler 应该调用SystemInit如果有然后__main完成 C 库初始化最后 C 库再调 main。如果你是自己手写的汇编入口至少在最后要LDR R0, main; BX R0并把B .留在真正的中断错误入口里。这个坑在从老工程转型时特别容易踩到——很多旧启动文件把B .写在向量表尾部当占位符后来向量表扩展后新增的中断入口直接落在这个死循环上芯片一旦触发中断就永远卡死。6. 把这份资源用出价值从点灯到能长跑的最小验证6.1 对照移植改芯片型号时哪些段位要动整个工程跑通之后你会想换一块芯片型号这时候你会发现汇编工程移植比 C 工程快因为寄存器地址几乎是死的。STM32F103 系列里中容量和大容量的 GPIO 寄存器基地址是一样的真正要改的是启动文件的向量表长度、Flash/RAM 容量的 scatter 描述以及个别外设的时钟分频系数。改的时候记住三条向量表长度查芯片手册的中断号列表Stack_Size和 RAM 大小按实际需求调整RCC 的配置如果用了外部晶振还得核对 HSE 参数。6.2 用调试断言验证汇编执行顺序软仿不接板Keil5 的模拟器支持printf重定向到串口窗口但在纯汇编工程里这需要额外开销不推荐。更轻量的验证方式是直接在汇编代码里嵌入断点陷阱在关键寄存器操作之后比较 ODR 的值如果不等于预期就执行一条BKPT指令让仿真器停下来。这样能快速定位是时钟配置失效还是模式配置错误。6.3 进阶技巧在循环里用延时计数代替 SysTick避免中断干扰实验当你想验证一个纯汇编的循环点灯频率时SysTick 中断会引入额外的执行路径干扰你把「汇编周期数」和「实际频率」对应起来。这时候改用纯寄存器计数延时代码里延时循环的每次迭代周期数是确定的Delay LDR R1, 0x00FFFFFF Delay_Loop SUBS R1, R1, #1 BNE Delay_Loop BX LRSUBS和BNE的组合在 72MHz 下每次迭代约 2 个周期算好循环次数就能得到毫秒级延时——但注意这只是个粗糙估算流水线预取、Flash 等待周期都会影响实际值。我后来每次做汇编实验都在 Keil 的寄存器窗口里先读一遍DWT-CYCCNT去校准迭代次数从那以后就不再靠瞎猜参数调延时了。希望这个套路能帮你把这套汇编资源跑得更顺手。本文还有配套的精品资源点击获取