简介:本资源是一份面向嵌入式初学者与底层开发爱好者的STM32F103汇编语言实践项目,聚焦“点灯”这一经典入门实验,帮助读者深入理解Cortex-M3内核寄存器操作、GPIO配置流程及Keil5环境下汇编程序的完整构建链路。压缩包共19个文件,含核心汇编源码(.s)、Keil5工程文件(.uvprojx、.uvoptx)、链接脚本(.sct)、编译输出(.axf、.map、.lst)、启动代码(startup_stm32f10x_md.s)及自动化批处理脚本(keilkilll.bat),覆盖从编写、编译、链接到调试的全流程所需构件。资源体积仅28KB,结构精炼,无冗余依赖,便于快速导入Keil5直接运行与单步调试。目前已有1652人学习下载,适合希望夯实ARM汇编基础、掌握硬件寄存器直控方法、理解中断向量表与启动流程的开发者,是进阶C语言裸机开发前不可或缺的底层认知训练材料。
1. 这不是“写代码”,是给芯片下指令:STM32F103汇编程序到底是什么、为什么新手要从它开始?
你有没有试过拧开一个遥控器,看到里面密密麻麻的黑色小方块和细如发丝的铜线?那里面真正干活的,不是电池,也不是按钮,而是一块叫“微控制器”的芯片——比如 STM32F103。它就像一台微型电脑,但没有屏幕、没有键盘,甚至不认得 Python 或 C 语言写的“你好世界”。它只听一种话:二进制机器码。而汇编语言,就是人类能看懂、又能准确翻译成机器码的“最后一层人话”。
所以,“STM32F103汇编程序”不是一堆老古董代码,它是你第一次亲手把想法变成芯片动作的桥梁:按一下按键,LED 灯亮;读一次温度传感器,寄存器里就多出两个字节;让电机转半圈,其实是往某个地址写了特定数值……这些事,C 语言帮你藏起来了,而汇编,让你亲手摸到开关。
它适合谁?不是给想快速做出智能手表的工程师,而是给刚拆开第一块开发板、对着原理图发懵、搞不清“GPIOA_BSRR”和“RCC_APB2ENR”到底谁管谁的新手。因为只有从汇编起步,你才不会把“点亮 LED”当成魔法,而是清楚知道:
→ 先打开时钟(不然外设根本没电)
→ 再配置引脚模式(推挽?上拉?)
→ 最后往输出寄存器写 1(或 0)——这一行指令,就是光本身。
这不是为了复古,而是为了建立“控制感”:当程序不跑,你知道该查哪几个寄存器;当灯不亮,你不会先怀疑硬件坏了,而是翻出参考手册第 142 页,核对 BSRR 的 bit 位是否写反了。这种确定性,是所有嵌入式开发的底气起点。
2. 为什么非得用 Keil5?先搞清工具链:编辑、编译、烧录,三步缺一不可
2.1 Keil5 不是“软件”,而是一整套“芯片翻译工厂”
想象你要把中文菜谱(你的汇编代码)做成一道真实可吃的菜(能让 STM32F103 执行的机器码),你需要三样东西:
- 厨师(编辑器):让你写代码、改错、加注释——Keil5 的编辑器支持语法高亮、跳转定义,写
LDR R0, =0x40010800时,它会立刻告诉你这地址是不是 GPIOA 的基地址; - 翻译官(ARMASM 汇编器):把
MOV R1, #0x01这种人类可读的指令,逐条转成E3A01001这种芯片能执行的 32 位二进制; - 快递员(Flash 下载器):把生成的
.hex或.axf文件,通过 ST-Link 或 J-Link,一比特一比特地“灌”进芯片的 Flash 存储器里。
Keil5 把这三者打包成一个界面:左边是文件树,中间是代码编辑区,顶部是“Build”(编译)、“Download”(烧录)、“Debug”(单步调试)三个大按钮。它不强制你学 Makefile、不让你手动敲arm-none-eabi-gcc,新手第一次点“Build”,看到 Output 窗口跳出[0 errors, 0 warnings],那种“我造出来了”的实感,比任何教程都管用。
提示:Keil5 官方提供免费版(MDK-Lite),最大支持 32KB 代码——而一个纯汇编的 LED 闪烁程序,通常不到 200 字节。够用,且无功能阉割。
2.2 你的第一个工程长什么样?5 个真实文件,一个都不能少
新建一个 Keil5 工程后,你会看到这些文件(名称可能略有差异,但角色固定):
| 文件名 | 类型 | 作用 | 新手易忽略点 |
|---|---|---|---|
startup_stm32f103xb.s | 启动文件(汇编) | 芯片上电后第一条执行的代码:初始化栈指针、复制数据段、跳转到main | 别删!它定义了中断向量表位置,删了程序根本启动不了 |
main.s | 主程序(汇编) | 你写的逻辑:配置时钟、点灯、循环延时 | 别写成main.c!.s后缀告诉 Keil:“请用 ARMASM,不是 C 编译器” |
stm32f103xb.h | 头文件(C) | 定义所有寄存器地址,如#define GPIOA_BASE 0x40010800 | 汇编里不能直接#include,但 Keil 会自动关联,你在main.s里写GPIOA_BSRR,它知道对应0x40010808 |
system_stm32f103xb.c | 系统初始化(C) | 配置系统时钟(HSE/HSI/PLL) | 汇编项目里可删,但删前必须确认startup.s里已手动配置好时钟(否则 APB2 总线没电,GPIO 不工作) |
target.hex | 输出文件(二进制) | 编译后生成,可直接用烧录工具写入芯片 | 不是.bin!.hex是 Intel 格式,含地址信息,ST-Link Utility 默认认它 |
注意:Keil5 创建新工程时,默认勾选“Run-Time Environment”,会自动生成
system_stm32f103xb.c和startup_stm32f103xb.s。新手建议保留startup.s,删掉system_*.c——因为我们要用汇编手动配时钟,避免 C 初始化和汇编逻辑打架。
2.3 从零创建工程:6 步操作,每一步都在解决一个具体问题
打开 Keil5 → Project → New µVision Project
→ 选一个空文件夹(如D:\stm32_asm\led_blink),输入工程名led_blink.uvprojx
→为什么强调“空文件夹”?Keil 会在该目录下建Objects/、Listings/等子目录,混入其他文件会导致编译路径错乱。选择 Device:STM32F103C8(或你手头芯片型号)
→ 在弹出窗口搜索 “STM32F103”,选STM32F103C8T6(常见蓝 pill 板主控)
→关键点:Keil 会自动加载该芯片的启动文件和头文件路径,省去手动配置。添加启动文件:Project → Manage → Run-Time Environment
→ 展开Device→ 勾选Startup(确保startup_stm32f103xb.s被加入)
→不勾选CMSIS或RTOS——汇编项目不需要这些高级组件。新建汇编文件:File → New → 保存为
main.s(注意后缀是.s,不是.asm)
→ 右键Source Group 1→ Add Existing Files to Group → 选中main.s
→为什么必须是.s?Keil 对.s文件默认调用 ARMASM;.asm可能被识别为其他汇编器格式,报错“unknown instruction”。配置汇编器:Project → Options for Target → ASM 标签页
→ 勾选Generate Listings(生成.lst列表文件,方便查指令编码)
→ 在Define栏填USE_STDPERIPH_DRIVER=0(关闭标准外设库,避免符号冲突)
→这步漏掉,编译时可能报Error: #20: identifier "GPIOA" is undefined——因为 Keil 默认启用 CMSIS,而汇编里未声明符号。设置输出格式:Output 标签页 → 勾选
Create HEX File
→ Debug 标签页 → UseST-Link Debugger(如果你用 ST-Link)
→.hex是烧录必备格式;不勾选,编译完只有.axf,无法用 ST-Link Utility 烧录。
完成这 6 步,你的工程骨架就立住了——不是“看起来像”,而是每个文件各司其职,编译器知道怎么处理每一行。
3. 第一个例子:用汇编点亮一颗 LED,从寄存器地址开始写起
3.1 硬件准备:蓝 pill 板上的 LED 接在哪?先查手册再动手
别急着写代码。拿出你的 STM32F103C8T6 开发板(俗称“蓝 pill”),找到板载的 LED(通常标着LD2或PC13)。翻开《STM32F103xx 参考手册》(RM0008)第 279 页,查 GPIO 端口寄存器映射:
- GPIOC 基地址:
0x40011000 - GPIOC_BSRR(置位/复位寄存器):
0x40011008 - PC13 对应 BSRR 的 bit 13(置位)和 bit 13+16=bit 29(复位)
为什么是 PC13?因为蓝 pill 板原理图上,LED 阳极接 3.3V,阴极通过限流电阻接到 PC13 引脚——这意味着:
✅PC13 输出低电平(0)→ LED 亮
❌PC13 输出高电平(1)→ LED 灭
这个“低电平点亮”是硬件决定的,不是软件规则。很多新手翻车,就是因为没看原理图,死磕“为什么写 1 不亮”。
提示:Keil5 自带的
stm32f103xb.h里已定义#define GPIOC_BASE 0x40011000,但汇编里不能直接#include。我们用更稳妥的方式:在main.s里用EQU伪指令定义常量。
3.2 逐行写main.s:12 行汇编,解释每一行在干什么
; main.s - STM32F103 汇编点灯程序 AREA RESET, DATA, READONLY EXPORT __main IMPORT SystemInit IMPORT __main_post_stack_init ; 定义寄存器地址常量(用 EQU,不是 C 的 #define) GPIOC_BASE EQU 0x40011000 GPIOC_BSRR EQU GPIOC_BASE + 0x08 RCC_BASE EQU 0x40021000 RCC_APB2ENR EQU RCC_BASE + 0x18 AREA |.text|, CODE, READONLY ENTRY __main: ; Step 1: 开启 GPIOC 时钟(APB2 总线) LDR R0, =RCC_APB2ENR LDR R1, [R0] ORR R1, R1, #(1 << 4) ; bit4 = IOPCEN, 使能 GPIOC 时钟 STR R1, [R0] ; Step 2: 配置 PC13 为推挽输出(CNF13[1:0]=00, MODE13[1:0]=11) LDR R0, =GPIOC_BASE LDR R1, [R0, #0x04] ; 读取 GPIOC_CRL(低 8 位配置寄存器) BIC R1, R1, #(0xF << 20) ; 清除 CNF13[1:0] 和 MODE13[1:0](bit20~23) ORR R1, R1, #(0x3 << 20) ; MODE13=11(输出模式),CNF13=00(推挽) STR R1, [R0, #0x04] ; 写回 GPIOC_CRL ; Step 3: 点亮 LED(PC13 = 0 → 写 BSRR 的 bit29 复位) LDR R0, =GPIOC_BSRR MOV R1, #(1 << 29) ; bit29 = 复位 PC13 STR R1, [R0] stop B stop ; 无限循环,防止跑飞 END逐行白话解释:
AREA RESET, DATA, READONLY:声明一个名为RESET的只读数据区(存放向量表等)EXPORT __main:告诉链接器,“__main是程序入口”,Keil 启动文件会跳转到这里GPIOC_BASE EQU 0x40011000:EQU是汇编里的“宏定义”,相当于 C 的#define,让代码可读LDR R0, =RCC_APB2ENR:把RCC_APB2ENR地址(0x40021018)装入 R0 寄存器(=表示加载立即数地址)LDR R1, [R0]:从 R0 指向的地址(即RCC_APB2ENR)读出当前值,存入 R1ORR R1, R1, #(1 << 4):对 R1 执行“按位或”,把 bit4 置 1 → 即开启 GPIOC 时钟STR R1, [R0]:把修改后的 R1 值写回RCC_APB2ENR地址,生效!LDR R1, [R0, #0x04]:[R0, #0x04]表示“R0 地址 + 4”,即GPIOC_CRL(配置低 8 位寄存器)BIC R1, R1, #(0xF << 20):BIC是“位清除”,把 R1 的 bit20~23 清零(为重写 MODE/CNF 做准备)ORR R1, R1, #(0x3 << 20):把 MODE13 设为11(最大输出速度 50MHz),CNF13 设为00(推挽)MOV R1, #(1 << 29):构造一个数:bit29 = 1,其余为 0 → 写入 BSRR 就是复位 PC13(输出 0)B stop:无条件跳转到stop标签,形成死循环,防止 CPU 执行垃圾内存
关键参数说明:
1 << 4:左移 4 位 = 16 = 0x10,对应RCC_APB2ENR的 bit4(IOPCEN)0x3 << 20:0x3是二进制11,左移 20 位后占据 bit20~21(MODE13),bit22~23 保持 0(CNF13)1 << 29:BSRR 高 16 位是复位位,bit29 对应 PC13,写 1 即复位(输出 0)
3.3 编译 & 烧录:3 个按钮,看清每一步的反馈信息
点击 Build(锤子图标)
→ 观察 Output 窗口:compiling main.s... assembling startup_stm32f103xb.s... linking... Program Size: Code=124 RO-data=0 RW-data=0 ZI-data=1624 Bytes✅ 如果出现
0 Error(s), 0 Warning(s),说明汇编通过;
❌ 如果报Error: #20: identifier "GPIOC_BASE" is undefined,检查main.s是否漏写EQU定义;
❌ 如果报Error: #65: expected a string,检查LDR R0, =RCC_APB2ENR中的=是否误写为:或空格缺失。点击 Download(向下箭头图标)
→ 确保 ST-Link 已连接、开发板供电正常;
→ Keil 会自动调用 ST-Link 驱动,显示:Loading file... Erasing target... Programming... Verify OK.✅
Verify OK表示烧录成功;
❌ 如果卡在Erasing target...,检查 ST-Link 接线(SWDIO/SWCLK/GND)是否松动;
❌ 如果报No Cortex-M device found,重启 Keil 并右键 Target → Connect 重连。观察现象
→ 上电后,LD2(PC13)应常亮(因为我们只写了复位,没写置位);
→ 如果不亮:用万用表测 PC13 引脚电压,应为 0V(低电平);若为 3.3V,说明寄存器没写成功,回头查BSRR地址和 bit 位。
4. 避坑指南:新手必踩的 4 个汇编“玄学”问题,亲测有效
4.1 现象:编译通过,但 LED 完全不响应,万用表测 PC13 电压是 3.3V
原因:GPIOC_BSRR地址写错,或 bit 位弄反。BSRR 高 16 位是复位(写 1 置 0),低 16 位是置位(写 1 置 1)。新手常把1 << 29写成1 << 13,结果写的是低 16 位 → 置位 PC13(输出 1),LED 灭。
解决:打开 Keil 的View → Memory Windows → Memory 1,输入0x40011008,查看 BSRR 寄存器值。运行后应看到0x20000000(bit29=1),而不是0x00002000(bit13=1)。
4.2 现象:编译报错Error: #65: expected a string,指向LDR R0, =RCC_APB2ENR
原因:=符号前后有中文空格、全角字符,或RCC_APB2ENR未用EQU定义(汇编器不认识这个符号)。
解决:删除整行重新手打,确保=是英文半角;检查RCC_APB2ENR是否在main.s开头用EQU正确定义(如RCC_APB2ENR EQU RCC_BASE + 0x18)。
4.3 现象:烧录时报No Cortex-M device found,ST-Link 指示灯不亮
原因:ST-Link 与开发板接线错误。常见错误:SWDIO 和 SWCLK 接反;GND 未接;或开发板 USB 供电不足导致 ST-Link 无法识别。
解决:断电,用杜邦线对照原理图重接:
- ST-Link 的
SWDIO→ 开发板PA13(或SWDIO标注点) - ST-Link 的
SWCLK→ 开发板PA14(或SWCLK标注点) - ST-Link 的
GND→ 开发板任意 GND - 不接 ST-Link 的 3.3V 引脚(开发板自己供电,避免电压冲突)。
4.4 现象:LED 闪一下就灭,或者完全不亮,Debug 模式下单步执行却正常
原因:startup_stm32f103xb.s里的SystemInit()函数被调用,它会重置时钟配置,覆盖你手动写的 RCC 设置。
解决:打开startup_stm32f103xb.s,找到__main_post_stack_init调用处,注释掉BL SystemInit这一行(在它前面加;)。因为纯汇编项目,我们自己配时钟,不需要 C 库的初始化。
5. 下一步学什么?从“点灯”到“能干活”的 3 个扎实台阶
5.1 台阶一:给汇编加“时间感”——手写延时循环,理解时钟树
现在 LED 是常亮的,但真正的应用需要“闪烁”。C 语言用Delay_ms(500),汇编得自己算周期。STM32F103 默认用内部 HSI 时钟(8MHz),经 PLL 倍频后系统时钟为 72MHz。一条SUBS R0, R0, #1指令耗时 1 个周期,那么让 R0 从 1000000 减到 0,大约耗时:1000000 / 72000000 ≈ 0.0139 秒
但实际还要考虑流水线、分支预测。更可靠的做法是:
- 用 Keil 的
View → Performance Analyzer查看某段循环的精确周期数; - 写一个嵌套循环:外层控制秒级,内层控制毫秒级;
- 把延时封装成子程序,用
BL delay_ms调用,避免代码重复。
delay_ms PUSH {R4-R6} ; 保护寄存器 MOV R4, #1000 ; 外层计数(1ms ≈ 1000 次内层) outer_loop MOV R5, #72000 ; 内层计数(72MHz / 1000 = 72000 cycles per ms) inner_loop SUBS R5, R5, #1 BNE inner_loop SUBS R4, R4, #1 BNE outer_loop POP {R4-R6} BX LR ; 返回调用处从那以后我每次写延时,都先用 Performance Analyzer 实测 10 次取平均值,再微调内层计数。因为不同优化等级、不同 Keil 版本,指令周期会有细微差别——这是嵌入式里最朴素的“后悔药”:不靠猜,靠测。
5.2 台阶二:让芯片“听懂按钮”——汇编读取 GPIO 输入
点灯是输出,读按键是输入。蓝 pill 板的按键(B1)通常接PA0,按下时接地(低电平)。要检测它,需:
- 开启 GPIOA 时钟(同点亮灯,改
RCC_APB2ENRbit2); - 配置 PA0 为浮空输入(
GPIOA_CRL bit0~3 = 0b0100); - 循环读
GPIOA_IDR(输入数据寄存器)的 bit0; - 加消抖:连续读 3 次,间隔 10ms,都为 0 才判定“按下”。
这里的关键是理解IDR和ODR的分离:IDR只读,反映引脚真实电平;ODR只写,控制输出值。新手常误用ODR读按键,结果永远读到自己上次写的值。
5.3 台阶三:走出“裸机”,接入中断——用汇编写 NVIC 配置
目前程序是“查询式”(Polling):CPU 一直问“按键按了没?”,很累。中断能让 CPU 去干别的,等按键按下时,硬件自动暂停当前任务,跳转到你的中断服务程序(ISR)。
步骤精简为三步:
- 在
startup.s的中断向量表里,把EXTI0_IRQHandler地址指向你的 ISR 标签; - 配置 EXTI:设置
EXTI_IMR(中断屏蔽寄存器)bit0 = 1,允许 PA0 中断; - 配置 NVIC:写
NVIC_ISER(中断使能寄存器)bit6 = 1(EXTI0 对应 IRQ6); - 在 ISR 里清中断标志(
EXTI_PR bit0 = 1),再执行点灯逻辑。
从那以后我每次配置中断,都强制走一遍“查向量表 → 设屏蔽 → 开 NVIC → 清标志”四步 checklist。因为漏一步,中断就不触发,而现象是“完全没反应”,比逻辑错误更难排查。
5.4 学习资源推荐:拒绝碎片化,用好这 3 个“官方锚点”
| 资源 | 用途 | 为什么靠谱 |
|---|---|---|
| 《STM32F103xx 参考手册》(RM0008) | 查寄存器地址、位定义、时序图 | ST 官方发布,PDF 里 Ctrl+F 搜GPIOC_BSRR,第 282 页直接给出 bit 功能表,比任何博客都准 |
| Keil ARMASM 用户指南(ARM DUI0204) | 查LDR/BIC/BL等指令语法、寻址模式 | Keil 官网免费下载,讲透LDR R0, =label和LDR R0, [R1]的区别,避坑神器 |
| STM32CubeMX 生成的汇编启动文件 | 对照学习:用 CubeMX 配一个 GPIO 输出,导出汇编工程,看它怎么写RCC和GPIO | 不用它烧录,只当“活教材”——生成的startup.s和system_stm32f103xb.s是经过千人验证的工业级写法 |
希望帮到你。
本文还有配套的精品资源,点击获取