☰
STM32F103纯汇编点灯:从启动文件到寄存器操作全解析
2026/10/16 3:10:52 网站建设 项目流程

简介:面向初学者的STM32F103汇编点灯示例工程,基于Keil5集成开发环境编写,适合想深入理解Cortex-M3内核与底层寄存器操作的嵌入式开发者。工程共包含十九个文件,压缩包仅二十八KB,主要有汇编启动文件、工程配置文件、编译映射与列表文件、可执行文件,以及文本说明和清理脚本,结构紧凑便于按需查阅,从启动代码到应用逻辑一目了然。目前已有一千六百五十人学习。通过该示例可系统掌握常见汇编指令、GPIO各配置寄存器的操作方法、跳转与循环控制,以及启动文件中中断向量表的初始化写法;结合Keil5调试器的断点、单步和寄存器查看功能,能直观跟踪从复位到点亮LED的完整流程,理解堆栈指针、链接寄存器和程序计数器的作用,并了解中断异常处理的基本思路,为后续C语言开发和寄存器级调试打下扎实基础。

1. STM32F103 汇编程序:为什么我劝你至少手动写完一次启动文件

很多时候我们用 ST 库把 STM32F103 玩得飞起,甚至忘了面前这层寄存器操作到底是什么。我认识不少开发者,C 语言裸机都写熟了,问到“上电后第一句执行的代码在哪”却答不上来——答案就藏在 startup 汇编里。

这份 STM32F103 汇编工程,在 Keil5 下把复位向量、栈顶初始化、时钟使能、GPIO 翻转、延时这一整条链路全部摊开在汇编源文件里,没有库函数,也没有被隐藏的启动代码。它不面向想快速出货的人,而面向两类人:刚接触 Cortex-M3 想弄懂底层的人,以及要做指令级时序控制、不希望编译器优化悄悄改掉行为的开发者。

它能做到的事情很具体:任一块 F103 板子只靠 .s 文件就能点亮板载 LED,并提供可复用的 SysTick 精确延时子程序。

2. 工程结构与构建链路:从一个 .s 文件到可烧录 HEX

2.1 解压后你会看到的目录

拿到这份资源,先把文件结构看明白。按常见打包习惯,工程一般拆成 User 和 Project 两个目录,源码和 Keil5 配置分开放,避免重装软件或换版本时把 uvprojx 里的绝对路径搅进来。

STM32F103汇编程序/ ├── User/ │ ├── main.s # 用户汇编:主循环、SysTick 延时 │ ├── startup_stm32f10x_hd.s # 启动汇编:栈段、向量表、Reset_Handler │ └── stm32f10x_reg.s # 寄存器地址 EQU 映射,统一管地址 ├── Project/ │ ├── led.uvprojx # Keil5 工程文件 │ └── led.uvoptx # 调试器配置与窗口布局 └── README.md # 接线说明与烧录步骤

startup_stm32f10x_hd.s是整份工程的地基。hd 代表高密度 Flash,对应 STM32F103ZET6 这类 512KB 的芯片;如果你的板子是 C8T6 这种中低密度,需要换成startup_stm32f10x_md.s,向量表内容和链接地址一样,区别只在芯片 Flash 容量对应的宏。

main.s是你真正要看的业务代码。它做的事情不多:初始化 RCC 时钟、配一个 GPIO 引脚为推挽输出、然后在一个死循环里翻转电平。stm32f10x_reg.s这个文件不一定每份包都有,但把寄存器地址单独抽出来算是正规做法,好处是以后扩展外设时不用到处翻魔数。

README 里一般写了板子上 LED 接在哪个引脚。我看到很多包的默认代码写的是 PA0,如果你的板子 LED 在 PC13 或 PB1,改起来不复杂,后面第三章会讲怎么改地址。

2.2 Keil5 下 .s 文件的构建流程

这个工程不经过 C 编译器,Keil5 的构建链路是典型的 armasm 加 armlink。先把过程理清,出问题才知道去哪看日志。

第一步,armasm 把每个 .s 文件编译成 .o 目标文件。Keil5 对汇编文件的后缀名比较宽松,.s 和 .asm 都能识别,关键是文件里不能混用两种语法风格。这份工程里的EQU、AREA、PROC、ENDP是 ARM Compiler 5 的经典写法,在工程配置里要把编译器版本切到 AC5,下面第四章节会说具体位置。

第二步,armlink 把 .o 文件链接成 .axf 可执行镜像。链接时的基地址在工程配置里写死:IROM1 从0x08000000开始,IRAM1 从0x20000000开始。这是 STM32F103 的固定存储布局,Flash 和 SRAM 分别映射在这两个区间,不能改。

第三步,fromelf 根据 .axf 提取出 .hex 烧录文件。在 Keil5 的 Output 选项卡里勾选 Create HEX File,每次构建完会自动生成。真正烧进芯片的是 HEX,不是编译中间产物。

armasm -g --cpu=Cortex-M3 startup_stm32f10x_hd.s armasm -g --cpu=Cortex-M3 main.s armlink --first __Vectors --entry Reset_Handler *.o -o led.axf fromelf --bin --output led.bin led.axf

参数里最关键的是--first __Vectors,它强制把向量表放在镜像最前面,也就是0x08000000位置。芯片复位后从0x08000000读取栈顶指针,从0x08000004读取复位程序入口,这两项必须出现在镜像开头,否则上电直接跑飞。

2.3 技术选型:为什么要纯汇编

有人会问,点个灯用寄存器操作 C 语言也能写,何必折腾汇编。从结果看确实 C 更省事,但这工程的定位不是节省开发时间,而是把 Keil5 的启动黑匣子拆开给人看。

对比点C 语言裸机工程纯汇编工程
代码量少,库函数封装好多,每条指令都在说寄存器
启动流程编译器自动生成 __main 引导完全由自己控制,一个符号一个地址
时序精度受编译器优化影响指令周期可逐条数
可移植性换芯片改库即可换芯片得重写外设部分
适合场景产品开发、快速验证学习内核、定制极简启动

我一般建议刚接触 STM32 的人先把这份汇编跑通一遍,再回去用 C 写外设。原因很简单:当你看到 C 代码里一行GPIOA->ODR ^= 1能编译成几条指令、中间经过哪些总线,才真正理解数据手册里那一堆寄存器地址的意义。

纯汇编的代价也很明显,外设一多就难维护。所以这份工程的定位很清晰:能点灯、能延时、能看明白启动链路就够了,后面要扩展功能,常见做法是在汇编框架里混编 C,这个放在最后一章讲。

3. 核心代码拆解:从复位向量到 GPIO 翻转的完整链路

3.1 启动汇编:栈顶、向量表与 Reset_Handler

打开startup_stm32f10x_hd.s,先看最前面的栈段定义。Cortex-M3 的栈是满递减的,栈指针 SP 初始值必须指向 RAM 区域的最高地址,这是芯片硬件规定的。

; 栈大小定义为 1KB,SPACE 分配内存空间 Stack_Size EQU 0x00000400 AREA STACK, NOINIT, READWRITE, ALIGN=3 Stack_Mem SPACE Stack_Size __initial_sp ; 向量表,从 0x08000000 开始存放 AREA RESET, DATA, READONLY __Vectors DCD __initial_sp ; 第 0 项:栈顶 DCD Reset_Handler ; 第 1 项:复位入口 DCD NMI_Handler ; 第 2 项:NMI DCD HardFault_Handler ; 第 3 项:硬件错误 __Vectors_End

Stack_Size EQU 0x400定义了 1KB 栈空间。AREA STACK, NOINIT, READWRITE表示这块区域不初始化、可读写。ALIGN=3让段按 2 的 3 次方也就是 8 字节对齐,Cortex-M3 的栈指针要求对齐到 8 字节边界,这是 AAPCS 调用约定的硬性要求。

__initial_sp这个符号很关键,它没有实际代码,是汇编器在段末尾自动计算的地址值。链接后它指向栈顶,被放到向量表第一项。这里有个容易错的地方:第一项必须是栈顶地址,不是栈底,有人写成Stack_Mem的起始地址,程序一进中断 SP 就往下溢出。

向量表后续的异常向量继续往下填,这里只列了 NMI 和 HardFault,完整工程还会有 MemManage、BusFault、UsageFault、SVCall、PendSV、SysTick,一共 16 项。写不写全不影响点灯,但 HardFault 建议保留,调试时 PC 停在哪个 Handler 一眼就能判断异常类型。

接着是复位处理函数,这是上电后第一个真正执行的代码:

AREA |.text|, CODE, READONLY Reset_Handler PROC EXPORT Reset_Handler [WEAK] LDR R0, =SystemInit BLX R0 LDR R0, =main BLX R0 B . ENDP

PROC和ENDP是 armasm 的函数定义语法,EXPORT [WEAK]表示这个符号可以被子工程里的同名定义覆盖。LDR R0, =SystemInit是伪指令,汇编器会把 SystemInit 的地址装载到 R0,然后BLX跳转过去。

注意BLX会自动切换 ARM 和 Thumb 状态。Cortex-M3 只支持 Thumb-2 指令集,所以这里所有代码都必须是 Thumb 指令。跳转后B .是原地死循环,防止 Reset_Handler 返回后执行到未定义区域。

3.2 SystemInit:RCC 时钟与 GPIO 输出配置

接下来看用户代码的核心,寄存器地址定义和 SystemInit 实现。F103 的 GPIOA 挂在 APB2 总线上,要先用 RCC 打开对应时钟,否则写 GPIO 寄存器全部无效。

; 寄存器地址映射,全部以 EQU 定义 RCC_APB2ENR EQU 0x40021018 GPIOA_CRL EQU 0x40010800 GPIOA_ODR EQU 0x4001080C SystemInit PROC EXPORT SystemInit ; 1. 打开 GPIOA 时钟:RCC_APB2ENR 第 2 位 LDR R0, =RCC_APB2ENR LDR R1, [R0] ORR R1, R1, #0x00000004 STR R1, [R0] ; 2. PA0 配置为推挽输出,2MHz 速率 LDR R0, =GPIOA_CRL LDR R1, [R0] AND R1, R1, #0xFFFFFFF0 ; 清低 4 位 ORR R1, R1, #0x00000002 ; MODE0=10, CNF0=00 STR R1, [R0] ; 3. 初始输出低电平 LDR R0, =GPIOA_ODR LDR R1, [R0] AND R1, R1, #0xFFFFFFFE ; 清 bit0 STR R1, [R0] BX LR ENDP

RCC_APB2ENR是 APB2 外设时钟使能寄存器,地址偏移是0x10 + 0x18。它的第 2 位对应 GPIOA,写 1 表示开启。代码先读整个寄存器的值,再用ORR只把第 2 位置 1,其他外设的时钟配置不受影响。这就是典型的读-改-写操作。

GPIOA_CRL是端口配置低寄存器,控制 PA0 到 PA7 这 8 个引脚,每个引脚占 4 位。PA0 的 4 位是CNF0加MODE0,范围在寄存器的低 4 位。AND R1, R1, #0xFFFFFFF0把这 4 位清零,然后ORR R1, R1, #0x00000002写入 MODE0 的值为二进制10,表示输出模式、2MHz 最大翻转速率。CNF0 保持00,即通用推挽输出。

推挽输出和开漏输出的区别这里提一下:推挽能主动拉高拉低,开漏只能拉低,要靠外部上拉电阻才能变高。驱动 LED 用推挽最直接。如果你的板子上 LED 是三极管驱动或者低电平点亮,初始化时输出低电平这步逻辑可能要反过来。

插一句,很多人看到RCC_APB2ENR就下意识照抄 0x40021018,但 F103 系列里不同型号的 APB2 外设基址是相同的,这个地址没有歧义。真正容易错的是把RCC_APB2ENR和RCC_APB1ENR搞混,后者地址是 0x4002101C,差了 4 字节,点了 GPIOB 的时钟发现怎么都使能不上。

3.3 主循环与 SysTick 延时

主函数的逻辑非常直观:读 ODR 当前值,异或翻转 bit0,再写回去,中间夹一个延时,让 LED 闪烁肉眼可见。

main PROC EXPORT main main_loop LDR R0, =GPIOA_ODR LDR R1, [R0] EOR R1, R1, #0x00000001 ; bit0 翻转 STR R1, [R0] LDR R0, =400000 ; 延时参数 BL Delay_Cycles B main_loop ENDP

这里的翻转没有用 BSRR 寄存器而是读写 ODR,因为 BSRR 是写 1 置位或复位,单次只能往一个方向走。要翻转就得先读出当前值再异或,这是 ODR 读改写和 BSRR 直写的区别。

延时子程序用 SysTick 定时器实现,SysTick 是 Cortex-M3 内核自带的 24 位递减计数器,不依赖特定外设,Keil5 和所有 F103 芯片都支持。

; 入口参数 R0 = 需要延时的 SysTick 周期数 Delay_Cycles PROC EXPORT Delay_Cycles LDR R1, =0xE000E018 ; SysTick->VAL MOVS R2, #0 STR R2, [R1] ; 清空计数器 LDR R1, =0xE000E014 ; SysTick->LOAD STR R0, [R1] ; 写入重装载值 LDR R1, =0xE000E010 ; SysTick->CTRL MOVS R2, #0x07 ; 使能 + 中断使能 + 内核时钟 STR R2, [R1] wait_start LDR R1, =0xE000E010 ; 再读 CTRL LDR R2, [R1] ANDS R2, R2, #0x00010000 ; 检查 COUNTFLAG BEQ wait_start BX LR ENDP

SysTick 有四个寄存器:CTRL 控制、LOAD 重装载、VAL 当前值、CALIB 校准值。代码先把 VAL 清零,这样计数过程从头开始,再把参数 R0 写入 LOAD,最后把 CTRL 的 bit0、bit1、bit2 全部置 1,分别表示使能计数器、使能中断、选择处理器时钟源。

COUNTFLAG 是 CTRL 的第 16 位,当计数器从 1 递减到 0 时自动置 1,读取后会清 0。这里用ANDS把 COUNTFLAG 提出来,为 0 就继续等,为 1 说明时间到。

延时时间怎么算:工程里没做 PLL 倍频时,F103 默认使用内部 8MHz HSI 时钟,HCLK 就是 8MHz。传入 400000,那么一次延时就是 400000 除以 8000000,约 50ms。要改成 500ms,参数换成 4000000 就行。

如果系统时钟切到了 72MHz,同一个参数的实际时间会缩短到原来的九分之一,这是踩过坑的地方,后面第五章节还会拿这个说事。

4. Keil5 编译、烧录与调试:让首颗 LED 亮起来的完整动作

4.1 从零新建汇编工程

如果你拿到的包不带 uvprojx,需要自己在 Keil5 里重建工程,步骤不难,但每步都有讲究。

打开 Keil5 后选择 Project 菜单,New uVision Project,先给工程命名,然后弹出芯片选择窗口。这里选 STM32F103 系列里跟你板子对应的一款,比如某入门板上的 STM32F103C8T6。选完芯片会有个 Manage Run-Time Environment 窗口,因为我们是纯汇编工程,什么组件都不勾,直接点 OK,否则 Keil 会自动引入一堆 C 库启动代码。

然后右键 Source Group 1,选择 Add Existing Files,把main.s、startup_stm32f10x_hd.s、stm32f10x_reg.s加进去。这个步骤容易忘,忘掉的结果是编译报 "No source files",因为 Keil 不会自动扫描目录里的 .s 文件。

接下来最关键的一步,点魔术棒打开 Options for Target。在 Target 选项卡里,Xtal 填 8.0,IROM1 起始地址填0x08000000、大小按芯片型号填,IRAM1 起始地址填0x20000000、大小也按实际 SRAM 填。C8T6 是 20KB SRAM,ZET6 是 64KB,ARM 编译器会根据这些数值决定链接地址。

Output 选项卡勾上 Create HEX File,这步容易忽略,不勾的话构建完只有 axf,没有烧录用的 hex 文件。

4.2 ARM 编译器版本与构建参数

这是组装工程最常翻车的一处。这份汇编代码用的是 ARM Compiler 5 的语法,Keil5 默认安装的可能是 AC5 和 AC6 共存。在 Options for Target 的 Target 选项卡最下方,有一个 ARM Compiler 下拉框,默认选的是Use default compiler version 5还是 6,取决于你安装的 Keil 版本和包版本。

选 AC5 时,EQU、AREA、PROC这些关键字直接可用。选 AC6 时,它内置的 armclang 汇编器对旧语法不兼容,把EQU换成.equ、AREA换成.section,指令风格也可能要做调整。最省事的办法是切回 AC5。

FromELF --bin -o led.bin led.axf

构建日志里会出现 armasm 和 armlink 的完整命令行。第一次构建成功后,可以在 User 目录下看到 Listings 和 Objects 两个自动生成的文件夹。Objects 里的 led.axf 和 led.hex 就是这次构建的产物。

如果构建时报错提示某行指令 unknown opcode,先确认后缀名是 .s 而不是 .S,Linux 习惯的.S在 Keil 里可能被当成 C 预处理文件,预处理宏展开失败。

4.3 下载、复位与寄存器验证

构建通过不等于板上能跑,烧录环节还有一套独立配置。Options for Target 切到 Debug 选项卡,右侧 Use 选择调试器,常见是 ST-Link Debugger 或 J-LINK。点旁边的 Settings,在 Debug 子页选择 SW 模式,速度可以设 10MHz,JTAG 模式在四线情况下也可以,但 SWD 只占两根线,接线更省心。

接着切到 Utilities 选项卡,点 Settings 进入 Flash Download 配置。这里必须勾选 Reset and Run,否则下载完程序后芯片停在复位状态,看起来就是“烧录成功但板子没反应”。同时 Programming Algorithm 里要有对应芯片的烧录算法,F103 一般是STM32F10x High-density Flash。

下载完成后进入调试模式,先看两个地方。第一个是 Disassembly 窗口,PC 指针应该停在 Reset_Handler 附近,而不是停在 0x00000000。第二个是 Peripherals 菜单里的 GPIOA 和 RCC,打开外设窗口能看到实时的寄存器状态。

单步执行到STR R1, [R0]写出 GPIOA_CRL 后,外设窗口里 CRL 低 4 位应该变为0010,即 MODE0 为10。再单步几次,能看到 ODR 的 bit0 在 0 和 1 之间切换。用示波器或逻辑分析仪接 PA0,能测到约 10Hz 的方波,也就是 50ms 翻转一次,周期 100ms。

如果你没有硬件,用 Keil 的 Simulator 也能跑。在 Debug 设置里选 Use Simulator,限制是外设寄存器的模拟不如真实芯片完整,但 GPIO 翻转这种基础行为是可以看寄存器窗口确认的。

5. 避坑:Keil5 汇编工程常见报错与排查

5.1 报错 A1163E: Unknown opcode

现象:armasm 编译到某一行指令时报A1163E: Unknown opcode,指向的往往是.s文件里看起来很正常的MOVS或LDR指令。

原因:armasm 对缩进有严格要求,标签必须从第一列开始,指令必须缩进。如果指令顶格写,汇编器把它当成标签,下一行真正的操作数就全部错位,最终报 unknown opcode。这个错误在从别的工程复制代码时特别容易触发,因为不同编辑器的缩进方式不同。

解决:把每条指令前统一加 Tab,标签顶格,并保证整个文件缩进风格一致。我一般会在 Keil 的编辑环境里开启“显示空格与制表符”,一眼就能看出哪些行顶格了。

5.2 链接报 L6218E: Undefined symbol Reset_Handler

现象:编译阶段 .s 文件全部通过,链接时报L6218E: Undefined symbol Reset_Handler,后面跟着一堆 Reset_Handler 的引用位置。

原因:Reset_Handler 这个名字在向量表和主文件里拼写不一致。区分大小写是汇编器的默认行为,reset_handler、Reset_Handler、ResetHandler是三个完全不同的符号。启动文件里 EXPORT 的名字和 DCD 引用的名字不一致,也会出现这个问题。

解决:在工程范围内搜索 Reset_Handler,检查三处是否完全一致——向量表里的 DCD 引用、启动文件里的 EXPORT、处理函数定义处的标号。同名函数如果被修改过前缀或下划线,直接复制对齐即可。

5.3 LED 不亮,但寄存器窗口看到 ODR 在正常翻转

现象:调试时看到 GPIOA_ODR 的 bit0 在 0 和 1 之间跳动,明显在翻转,但板子上的 LED 一点反应没有。

原因:最常见的是 GPIO 时钟没打开。写 CRL 和 ODR 之前,必须先给 GPIOA 所在的 APB2 总线使能时钟,也就是 RCC_APB2ENR 的第 2 位。另一种可能是你把端口搞混了,配置的是 GPIOA 的 CRL,但 LED 实际接在 GPIOB 上。

解决:用 Peripherals 菜单打开 RCC 窗口,确认 IOPAEN 位已经为 1。如果没开,回看 SystemInit 里第一段代码的地址,检查ORR的立即数是不是0x00000004。GPIOB 时钟对应的是第 3 位,也就是0x00000008。

5.4 上电直接进 HardFault_Handler

现象:复位后 PC 停在 HardFault_Handler 里,或者调试器一进 Main 就显示 HardFault,单步无法继续。

原因:大多数情况是栈顶地址不对。向量表第一项DCD __initial_sp必须指向 RAM 的最高地址,这是链接器根据 IRAM 配置自动算出来的。有人为了图省事直接在向量表里写死0x20000000,这个地址是 SRAM 的起始地址,栈从那里向下增长,一压栈就越界到未分配空间,触发总线错误。

解决:栈顶必须用__initial_sp这种符号引用,不要手工填数字。同时确认 IRAM1 的起始地址和大小配置正确,如果 SRAM 起始地址填成了0x08000000,栈顶会被链接到 Flash 区域,也是同样的故障现象。

5.5 延时时间完全不对,闪烁频率离谱

现象:程序能跑,LED 也亮,但闪烁频率和预期差很多,比如本来想 0.5 秒一次,结果 0.05 秒一次。

原因:SysTick 的 LOAD 值写的是周期数,延时长度取决于 HCLK 频率。默认 8MHz 和倍频后 72MHz,相差 9 倍。如果工程里有人写了 PLL 初始化代码,或者 Keil 环境自动帮你在 SystemInit 里配了倍频,延时参数必须按实际核时钟重新计算。

解决:先用 Peripherals 窗口或 RCC 寄存器确认 SYSCLK 当前值。计算方式很简单,LOAD 值除以核时钟频率得到秒数。例如核时钟 72MHz,想要 500ms 延时,LOAD 填 36,000,000。SysTick 是 24 位计数器,最大值约 16,777,215,超过这个值要拆分延时,不能一次装载。

6. 二次开发与扩展:从点灯到 SysTick 中断和混编

6.1 改成 SysTick 中断驱动翻转

主循环主动查询 COUNTFLAG 的方式,延时时 CPU 一直在空转。如果想练中断,可以把 SysTick 配置成中断模式,翻转动作放到中断处理函数里。

做法是修改主程序的延时代码,让它不再轮询 COUNTFLAG,而是先点亮 LED,再循环执行WFE低级休眠指令:

SysTick_Handler PROC EXPORT SysTick_Handler [WEAK] LDR R0, =GPIOA_ODR LDR R1, [R0] EOR R1, R1, #0x00000001 STR R1, [R0] BX LR ENDP

同时要在启动文件的向量表里补上DCD SysTick_Handler,位置在向量表的第 16 项,也就是复位向量往后偏移15 * 4 = 60字节处。向量表顺序错了,中断来了会跳错地址,又是一个 HardFault。

6.2 汇编调用 C 函数

外设驱动越来越大之后,纯汇编维护成本很高,常见做法是保留汇编启动,业务逻辑切到 C。汇编里调用 C 函数,先把参数放进 R0 到 R3,然后 BL 跳转。

IMPORT c_led_handler LDR R0, =0x00000001 BL c_led_handler

C 那边对应写一个普通函数,返回值通过 R0 传递。AAPCS 调用约定要求函数进入时栈指针对齐到 8 字节,汇编侧调用前要确保 SP 当前值满足对齐条件。如果 C 函数里有浮点运算,还需要处理 FPU 寄存器,不过 F103 没有 FPU,暂时不用考虑。

6.3 我是怎么验证改动正确性的

每次改完工程,我不会直接看 LED 亮不亮就完事,而是习惯按固定顺序验证:先确认链接后的 map 文件里__initial_sp指向 SRAM 最高地址,再确认 Reset_Handler 的地址落在 0x08000000 附近,最后用逻辑分析仪测实际波形频率和计算值是否一致。

这三个点全部通过,我才认为这次改动是可靠的。从那以后,我每次接手别人的 STM32 汇编工程,都会强制完整走一遍这个三查三对流程,省下了大量定位 HardFault 的时间。希望帮到你。

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

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

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

立即咨询