ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Keil5下STM32F103纯汇编工程:从向量表到点灯串口全解析

Keil5下STM32F103纯汇编工程:从向量表到点灯串口全解析

简介:面向STM32入门开发者,这套Keil5汇编点灯工程提供了一份不依赖C库的GPIO控制示例,演示如何直接用Cortex-M3指令操作寄存器与硬件外设,适合想理解芯片底层机制或为后续调试、移植打基础的读者。包内共19个文件、约28KB,包含启动文件startup_stm32f10x_md.s、Keil工程project.uvprojx/uvoptx、编译链接产物axf/map/lst,以及txt/htm说明记录,结构简洁,便于对照分析启动流程和编译细节。已有1650人学习下载。示例围绕最经典的LED闪烁展开,覆盖LDR/STR/MOV等基本指令、GPIO配置寄存器操作、循环跳转控制,并演示在Keil5中设断点、单步跟踪汇编代码的方法。借助这套工程,无需重头搭建环境即可直接打开编译与仿真,快速建立汇编视角下的STM32启动、外设初始化和中断向量表概念,为后续阅读启动代码或优化底层性能提供直观参照。

1. 从点灯看全貌:一份基于Keil5的STM32F103纯汇编工程能做什么

第一次完整读一遍这套 STM32F103 汇编程序时,我一度以为工程文件放错了位置——整个 Keil5 工程里能编译的源文件全是 .s 结尾,一行 C 代码都没有。烧录之后板子上的 LED 按预定时序闪起来,串口助手也收到了从 USART1 发出的字符,所有行为完全由汇编指令接管。这套工程的价值不在外设演示本身,而是把 Cortex-M3 从上电取向量、初始化栈指针,到配置 RCC 和外设寄存器的完整链路压缩进几百行代码。适合想弄清启动流程、排查复位异常、准备在裸机上写 Bootloader 的开发者;如果只想调业务逻辑,倒不必从这份开始啃。

2. 技术选型与工程结构:Keil5下纯汇编项目如何脱离C运行时启动

2.1 为什么是STM32F103加纯汇编

实际工程里没有人会拿汇编写全部业务逻辑,但我见过不少被“启动文件到底改了什么”卡住的开发者。STM32F103 是 Cortex-M3 内核,指令集是 Thumb-2,寄存器模型和外设地址映射清晰,是拿汇编理解硬件启动流程成本最低的路径。Keil5 自带的 armasm 汇编器对 AREA、DCD、LDR 这类伪指令支持很直接,配合调试器可以逐条看指令执行,比在 C 代码里设断点更能说明问题。

纯汇编在嵌入式工程里通常承担三类任务:启动环境准备、中断向量表定义、对时序敏感的外设操作。常规 C 工程虽然也有 startup 汇编文件,但里面混着库函数调用和分散加载配置,容易让人忽略上电后真正发生的细节。这套工程干脆把这些细节全部打开,从第一条指令到串口发送,每一步都是自己写自己控。对想深入内核初始化的人来说,它是一个可以反复单步执行的“活教材”。

2.2 工程目录与文件职责

工程文件按职责拆分得很清楚,拿到手不会产生“该先看哪个文件”的选择困难:

stm32f103_asm/ ├── Startup/ │ └── startup_f103.s ; 向量表 + Reset_Handler 入口 ├── User/ │ ├── main.s ; 主循环逻辑 │ ├── system_f103.s ; 简化版时钟初始化 │ ├── gpio.s ; GPIO 操作封装 │ ├── timer.s ; SysTick 延时封装 │ └── uart.s ; USART1 初始化与发送 ├── Linker/ │ └── stm32f103.sct ; 分散加载文件 └── Output/ ; 编译产物,hex 和 axf 都在这

startup_f103.s 是整套工程的骨架,它决定了芯片从哪里开始执行、异常来了跳到哪里;main.s 里是主循环点灯逻辑;system_f103.s 在演示工程里不追求 72MHz 主频,只确认 HSI 振荡器就绪;gpio.s、timer.s、uart.s 则是对外设寄存器的读改写子程序。每个文件都保持“单一职责”,实际拆开看时能迅速定位到一个具体外设。

2.3 Keil5工程配置与分散加载文件

在 Keil 里新建工程选好芯片型号后,默认会带软件包里的启动文件。这套工程是自包含的,需要在 Options for Target 里做三处调整:把 Startup 和 User 目录加进汇编器 Include Path;在 Linker 页面取消勾选 Use Memory Layout from Target Dialog,手动指定 Linker/stm32f103.sct;在 Output 页面勾选 Create HEX File。最后一项漏掉的话,调试能正常跑,但单独烧录时会发现根本没有生成 hex 文件。

分散加载文件的作用是告诉链接器代码和变量分别放哪里。核心内容如下:

LR_IROM1 0x08000000 0x00010000 { ER_IROM1 0x08000000 0x00010000 { ; startup_f103.o 里的 RESET 段必须放在最前面 Startup\startup_f103.o (RESET, +First) .ANY (+RO) } RW_IRAM1 0x20000000 0x00005000 { .ANY (+RW +ZI) } }

这个文件的逻辑说白了就两条:ROM 从 0x08000000 开始放只读代码,其中 startup_f103.o 的 RESET 段用 +First 强制排在最前;RAM 从 0x20000000 开始放可读写变量和零初始化数据。向量表必须落在物理地址 0x08000000,这是 Cortex-M3 硬件决定的,没有商量的余地。如果忘了写 +First,链接器可能把某个普通函数段排到向量表前面,上电后第一跳就飞了,这是纯汇编工程里最隐蔽的排错点之一。

3. 启动与内核初始化:向量表与Reset_Handler的汇编级拆解

3.1 向量表是如何变成“跳板”的

Cortex-M3 上电后并不是从 main 开始执行的,它先读 0x08000000 处的 4 字节作为主栈指针,再读 0x08000004 处的 4 字节作为复位入口地址。只有这两步正常,处理器才会走进 Reset_Handler。向量表本质上是一组函数地址的清单,每个异常或中断占 4 字节,而第一项 0x20005000 是数据而不是代码,这是新手最容易看懵的地方。

AREA RESET, CODE, READONLY EXPORT __Vectors EXPORT Reset_Handler __Vectors DCD 0x20005000 ; 栈顶,RAM 大小决定这个值 DCD Reset_Handler ; 复位入口 DCD NMI_Handler ; NMI 不可屏蔽中断 DCD HardFault_Handler ; 硬件错误 DCD MemManage_Handler ; 内存管理错误 DCD BusFault_Handler ; 总线错误 DCD UsageFault_Handler ; 用法错误 DCD 0 DCD 0 DCD 0 DCD 0 DCD SVC_Handler ; 系统服务调用 DCD DebugMon_Handler ; 调试监视 DCD 0 DCD PendSV_Handler ; 可挂起系统服务 DCD SysTick_Handler ; 系统滴答定时器 AREA |.text|, CODE, READONLY

DCD 伪指令的作用是定义一个 32 位数据,汇编器会把对应符号的地址自动填入这个位置。向量表段名必须叫 RESET,因为分散加载文件正是靠这个名字用 +First 把它排到 Flash 起始处。没有实现的异常入口先用 DCD 0 占位,或者全部指向一个空的 Default_Handler,否则异常真正来临时 PC 会跑到 0x00000000,结果就是 HardFault。调试阶段我习惯把一个断点设在 HardFault_Handler 入口,这样一旦跑飞能第一时间停在现场。

注意:向量表第一项不是代码,是栈顶地址。调试时如果看到 PC 停在 0xFFFFFFFF 附近,先回头检查这一项的值是否落在 RAM 范围内。

3.2 Reset_Handler:第一条指令把自己交代清楚

Reset_Handler LDR SP, =0x20005000 ; 重新设置栈指针 LDR R0, =SystemInit ; 等待 HSI 就绪 BLX R0 LDR R0, =main ; 跳转主程序 BLX R0 DeadLoop B DeadLoop ; 理论上来不了这里

这段代码解释了硬件上电后进入 main 之前必须完成的三件事:重新加载栈指针、调用 SystemInit 确认时钟、跳转到主程序。BLX R0 与直接 BL main 的区别值得单独说:BLX 支持用寄存器保存目标地址,也是一条能切换指令集的指令。Cortex-M3 虽然只跑 Thumb-2,但保留这条指令意味着即使后续把启动代码挪到兼容 ARM 状态的场景,也不需要改跳转方式。

如果完全依赖向量表第一项来设置 SP,大多数情况下也能跑。但自己写启动文件时,我建议两条路径都做一遍——后续一旦在 Reset_Handler 里初始化外部 RAM,栈可能会被搬到新位置,那时 SP 只认汇编代码里的这次赋值。

3.3 SystemInit的简化做法:只让HSI就绪

厂商固件库里的 SystemInit 通常要把时钟树配置到 72MHz:打开 HSE、配置 PLL 倍频、等待 PLL Ready、再切换 SYSCLK。这套汇编工程把这些全省了,原因是演示外设时主频不是决定性因素。HSI 振荡器上电即自启,硬件复位后默认就是系统时钟源,频率通常为 8MHz。

SystemInit LDR R0, =0x40021000 ; RCC_CR 基地址 LDR R1, [R0] ORR R1, R1, #0x00000001 ; HSION = 1 STR R1, [R0] WaitHSI LDR R1, [R0] TST R1, #0x00000002 ; HSIRDY = 1? BEQ WaitHSI BX LR

这段代码的作用只有一个:打开 HSI 并等待它就绪。TST 指令执行按位与但不写回结果,所以后面的 BEQ 仍然基于 R1 的原始值判断,这是一个很容易被 C 语言习惯带偏的细节——如果写成 CMP R1, #2,就是在拿整个 RCC_CR 寄存器的值跟 2 比较,永远等不到就绪标志。RCC_CR 的 bit0 是 HSION,bit1 是 HSIRDY,等待循环退出后,SYSCLK 已经是 HSI 输出,外设总线频率也跟随确定下来。后续所有延时和波特率计算都基于这个 8MHz,这就是整个工程不配置 PLL 的原因。

4. 外设控制实战:GPIO点灯、定时器中断与串口打印的寄存器写法

4.1 GPIO点灯:先开时钟再改CRL和ODR

点灯代码的核心不是 ODR 那一位,而是先找到总线时钟。F103 的 GPIOB 挂载在 APB2 总线上,使能时钟的寄存器是 RCC_APB2ENR(地址 0x40021018),GPIOB 对应 bit2。不开这个位,后面往 GPIO 寄存器里写什么都不生效,而且静默失败,这是外设开发里最常见的“寄存器改了没反应”根源。

; 假设 LED 接 PB0,高电平点亮 LDR R0, =0x40021018 ; RCC_APB2ENR LDR R1, [R0] ORR R1, R1, #0x00000004 ; bit2 = IOPBEN,使能 GPIOB 时钟 STR R1, [R0] LDR R0, =0x40010C00 ; GPIOB_CRL,控制 PB0~PB7 LDR R1, [R0] BIC R1, R1, #0x0000000F ; 只清 PB0 的 4 位配置 ORR R1, R1, #0x00000003 ; MODE=11(输出50MHz) CNF=00(推挽) STR R1, [R0] LDR R0, =0x40010C0C ; GPIOB_ODR,输出数据寄存器 LDR R1, [R0] ORR R1, R1, #0x00000001 ; PB0 = 1 STR R1, [R0]

CRL 寄存器里每个引脚占 4 位,两位 MODE 决定输入输出方向和速度,两位 CNF 决定复用与上下拉。这里用 BIC 先清零再 ORR 设置,是为了不干扰 PB0 之外的引脚;如果直接 STR 覆盖整个寄存器,PB1 到 PB7 的配置会被全部冲掉。想验证这段逻辑,可以手动把 ORR 改回 BIC,让 PB0 输出低电平,观察 LED 熄灭,能亲眼确认寄存器写回路径是通的。

4.2 SysTick:把延时从空循环换成内核定时器

纯汇编最容易写出的延时是循环减计数,但循环时间依赖流水线和指令取指宽度,同一段代码在不同编译优化级别下可能差出好几倍。SysTick 是 Cortex-M3 内核自带的 24 位向下计数器,基地址固定在 0xE000E010,不占用外设资源,适合做可预期但不追求精度的软件延时。

Delay_ms LDR R0, =0xE000E014 ; SYST_RVR LDR R1, =7999 ; 8MHz 下计数 8000 次 = 1ms STR R1, [R0] LDR R0, =0xE000E018 ; SYST_CVR MOV R1, #0 STR R1, [R0] ; 写 CVR 会清除 COUNTFLAG LDR R0, =0xE000E010 ; SYST_CSR MOV R1, #0x00000005 ; ENABLE=1, CLKSOURCE=1(内核时钟) STR R1, [R0] WaitFlag LDR R1, [R0] TST R1, #0x00010000 ; COUNTFLAG 置 1 表示计数到 0 BEQ WaitFlag MOV R1, #0 STR R1, [R0] ; 关闭 SysTick BX LR

计数值 7999 对应 8MHz 内核时钟下延时 1ms。如果后来把系统时钟切到 72MHz,这个数要同步改成 71999,否则延时时间会缩短到约九分之一。这个子程序没有参数入口,调用方需要按自己想要的毫秒数直接改 RVR 里的立即数。代价是灵活性稍差,但好处是汇编代码里一眼能看到当前延时时长,不会出现“传了个参数进来却忘了存在哪个寄存器”的尴尬。

4.3 串口发送:从引脚复用配置到TXE标志位循环

串口相比点灯多出两个步骤:GPIO 引脚要配成复用推挽输出和浮空输入;波特率发生器需要写预分频值。这里仍以 8MHz 作为 UART 时钟,目标波特率 115200。

; 使能 GPIOA 和 USART1 时钟 LDR R0, =0x40021018 ; RCC_APB2ENR LDR R1, [R0] ORR R1, R1, #0x00004002 ; bit14=USART1EN, bit1=IOPAEN STR R1, [R0] ; PA9=TX 复用推挽输出,PA10=RX 浮空输入 LDR R0, =0x40010804 ; GPIOA_CRH LDR R1, [R0] BIC R1, R1, #0x00000FF0 ; 清 PA9/PA10 共 8 位 ORR R1, R1, #0x000004A0 ; PA9=1010, PA10=0100 STR R1, [R0] ; USART1 波特率与控制寄存器 LDR R0, =0x40013808 ; USART1_BRR MOV R1, #0x45 ; 8MHz 下设 115200 的 USARTDIV STR R1, [R0] LDR R0, =0x4001380C ; USART1_CR1 MOV R1, #0x200C ; UE=1, TE=1, RE=1 STR R1, [R0]

BRR 的值来自标准波特率公式:USARTDIV = 8000000 / (16 * 115200) ≈ 4.34,整数部分 4 写入 bit15:4,小数部分 0.34 乘以 16 后取整写入 bit3:0,最终得到 0x45。如果使用 72MHz 主频,同样的波特率要重新算 BRR,这也是工程里刻意停用 PLL 的原因之一——HSI 直驱时所有计算都更容易在纸上验证。CR1 里 0x200C 拆开看是 UE、TE、RE 三个位同时置 1,缺少 TE 的话 TX 引脚不会输出数据,这是排查“串口没反应”时第二个要看的地方。

发送单个字符使用轮询方式,等待发送数据寄存器空,再写入 DR 寄存器:

UartSendChar LDR R0, =0x40013800 ; USART1_SR WaitTXE LDR R1, [R0] TST R1, #0x00000080 ; TXE 位 BEQ WaitTXE LDR R0, =0x40013804 ; USART1_DR STRB R2, [R0] ; 要发送的字符在 R2 BX LR

用 STRB 而不是 STR,是因为 DR 寄存器只用到低 8 位,写入后硬件会自动启动发送流程。如果写成 STR,R2 高 24 位里残留的垃圾数据也会一起写进 DR,虽然多数情况下发送结果不受影响,但严格来说已经越界。串口在调试纯汇编工程时几乎是眼睛的延伸,我习惯在点灯循环里顺带发送一个计数 ASCII 字符,这样不接调试器也能判断程序跑到哪一步。

5. 避坑指南:Keil5编译、烧录与调试里的常见问题排查

5.1 编译与链接阶段的三个坑

现象:汇编器报错 Undefined symbol R0,或者 Undefined symbol Reset_Handler。

原因:多半是汇编指令里的寄存器名被当成符号解析,比如 LDR R0 =0x40021018 这种写法漏了逗号;也可能是 EXPORT 的标号名与引用处大小写不一致。ARM 汇编对大小写敏感,Reset_Handler 和 reset_handler 是两个完全不同的符号。

解决:先看 Build Output 窗口的错误定位到具体文件和行号,对照指令语法修正。符号找不到时,检查汇编文件里 EXPORT 是否有拼写错误,并确认引用方是否带了下划线前缀。Keil 的 armcc 工具链对汇编导出的符号名不做额外修饰,两边必须完全一致。

现象:链接时弹出 Error: L6218E: Undefined symbol __Vectors。

原因:启动文件没有被打进编译列表,或者 RESET 段被链接优化丢弃。Keil 里如果源文件没加入工程就不会被汇编;AREA RESET 段如果没有任何交叉引用,链接时可能被当作无用段剔除。

解决:在 Project 窗口确认 Startup\startup_f103.s 在工程列表中;在分散加载文件里显式引用 RESET 段,例如 Startup\startup_f103.o (RESET, +First)。这两步能解决大多数“启动代码消失”类链接错误。

现象:编译通过,但反汇编窗口里看不到向量表。

原因:AREA 段的属性写错,把 RESET 段声明成了 DATA,导致它被放进 RW 数据区而不是 ROM 代码区。向量表虽然是只读数据,却必须放在代码加载段里才能被链接器排到 Flash 起始地址。

解决:AREA RESET, CODE, READONLY 三个属性缺一不可。CODE 让链接器按代码段处理,READONLY 保证它进入 RO 段,这样分散加载才能把它安排在 0x08000000。

5.2 烧录与上电阶段的坑

现象:下载完成,板子没反应,断电重上电后程序丢失。

原因:调试器下载成功后没有勾选 Reset and Run,芯片其实停在调试状态;或者下载算法指向的 Flash 编程地址不是 0x08000000。这类问题最容易和“程序跑飞”混淆,因为表面现象都是 LED 不闪。

解决:在 Options for Target 的 Debug 页面打开 Settings,在 Flash Download 里勾选 Reset and Run。如果换了外部下载器,确认编程算法选择与芯片 Flash 容量匹配;用板载调试器时,偶尔需要把算法从对应的大容量型号改成目标板实际型号,视板子而定。

现象:上电后直接进 HardFault_Handler,现场寄存器看不出明显错误。

原因:最常见的是向量表第一项栈顶地址写得不合适,或者复位入口后面的某个 DCD 0 被意外触发。另一种可能是汇编里用了未对齐的 LDRD/STRD 指令,Cortex-M3 要求这类双字访问按 8 字节对齐。

解决:先把 __Vectors 里用不到的异常全部指向 Default_Handler,再把 Default_Handler 写成死循环并在入口设断点;然后在向量表前加 ALIGN 4,确保每个入口都字对齐。定位栈顶问题时,打开调试器的 Memory 窗口看 0x08000000 处是否为 0x20005000、0x08000004 处是否为 Reset_Handler 地址。

5.3 运行时的几个玄学现场

现象:GPIO 寄存器看着改对了,LED 就是不变。

原因:RCC_APB2ENR 的时钟位没生效,或者 CRL 清位掩码算错,把相邻引脚配置覆盖了。汇编里读改写三步缺一不可,直接 STR 覆盖整字最容易翻车。

解决:在 Peripherals 窗口观察 GPIOB_CRL,确认低 4 位是 0x3;再用内存窗口监控 0x40021018,看看 IOPBEN 是否为 1。如果寄存器值都正常,多半是 ODR 的写入极性反了,把 LED 接到了低电平点亮。

现象:串口收到的数据是乱码,或者根本没有数据。

原因:波特率不匹配。USART1 挂载在 APB2 上,如果系统时钟不是 8MHz 却按 8MHz 算了 BRR,实际波特率会明显偏移;另一个常见原因是 PA9/PA10 的 CRH 配置写错,把 TX 配成了普通推挽输出而不是复用推挽。

解决:先用示波器或逻辑分析仪测 TX 引脚的起始位宽度,确认物理层是否正常;再写一段回环代码,把接收到的字节原样发送。工程里统一按 HSI 直驱 8MHz 计算,只要不额外开 PLL,波特率就是准的。

6. 二次开发思路:从纯汇编升级为C/汇编混编并复用自己的启动文件

6.1 改造路径

这份工程最值得保留的是 startup_f103.s 和分散加载文件。真正做产品时不会用纯汇编写全部业务,常见做法是启动文件保留汇编,业务逻辑逐渐迁移到 C。

第一步:把 Reset_Handler 里跳转 main 改成跳转 __main。__main 是 Keil C 运行时库的入口,负责 RW 数据段搬运、ZI 段清零和库初始化,全部完成之后才会调用 C 的 main 函数。

LDR R0, =__main BLX R0

第二步:新建 main.c,里面的初始化函数直接操作与汇编里相同的寄存器。汇编子程序如果还要用,就在 .s 文件里 EXPORT,并在 C 文件里声明成 extern 函数。以延时为例:

extern void Delay_asm(unsigned int count); int main(void) { /* 时钟与 GPIO 初始化,寄存器操作同前 */ while (1) { GPIOB->ODR ^= 0x0001; Delay_asm(500000); } return 0; }

对应的汇编导出代码:

EXPORT Delay_asm Delay_asm SUBS R0, R0, #1 BNE Delay_asm BX LR

C 与汇编混编的接口规则并不复杂:R0 传递第一个参数,SUBS 让 R0 递减到 0 后返回,返回值仍放在 R0。但如果这类函数内部还需要用其他临时寄存器,就必须先压栈保存,避免破坏调用方的现场。改造完成后有个很有效的验证方法:打开生成的 map 文件,检查 __Vectors 段是否仍以 0x08000000 开头,Reset_Handler 是否被链接进 FIRST 区段,同时确认 C 函数的代码段排在启动代码之后。这一步通常是混编改造是否成功的分水岭。

从那以后,我每次改启动文件都强制自己在反汇编窗口走一遍 Reset_Handler 的前三条指令,确认跳转目标是 __main 还是 main,再继续写业务代码。这个习惯帮我避开过好几次“全局变量初值全乱”的坑,希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进