最小STM32 LED闪烁系统详解(纯汇编实现)
本文会简要介绍如何用纯ARM汇编语言编写一个最小化的STM32 LED闪烁程序。展示了嵌入式系统的最基本结构,包括启动文件、链接脚本、主程序以及构建过程。
目录
- 项目概述
- 启动文件详解
- 主程序分析
- 链接脚本解析
- Makefile构建系统
- MAP文件解读
- 总结

项目概述
这个项目实现了STM32F40上PB2引脚连接的LED灯的简单闪烁功能。整个程序完全使用ARM汇编语言编写,不依赖任何C库或其他高级语言组件。相信这个工程会让初学者对STM32会更加系统性的理解,对于老鸟说不定也会有所得。
启动文件详解
启动文件[startup.s]是嵌入式系统运行的第一个代码,负责初始化硬件环境并跳转到主程序。
向量表(Vector Table)
向量表是位于程序起始位置的一组指针,指向各种异常和中断处理程序:
1 2 3 4 5 6 7 8 9
| .section .isr_vector .align 2 .globl __isr_vector __isr_vector: .long __StackTop /* 栈顶指针 */ .long Reset_Handler /* 复位处理程序 */ .long NMI_Handler /* 不可屏蔽中断处理程序 */ .long HardFault_Handler /* 硬件故障处理程序 */ /* 其他异常处理程序... */
|
第一个条目__StackTop是栈顶地址,当处理器复位时,它会被加载到主堆栈指针(MSP)中。第二个条目Reset_Handler是复位后执行的第一个程序入口点。上面的.align 2 是指按 2^2 = 4 字节边界对齐
栈和堆定义
手动定义栈和堆空间:
1 2 3 4 5 6 7 8 9
| /* 栈空间定义 */ .section .stack .align 3 .equ Stack_Size, 0x400 .globl __StackTop .globl __StackLimit __StackLimit: .space Stack_Size __StackTop:
|
这里我们为栈分配了0x400(1KB)的空间。栈是从高地址向低地址增长的内存区域,主要用于存储函数调用时的返回地址、局部变量等。
复位程序
复位程序是系统启动后执行的第一个真正有意义的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| .thumb_func .align 2 .globl Reset_Handler .type Reset_Handler, %function Reset_Handler: /* 将数据段初始值从Flash复制到SRAM */ movs r1, #0 b LoopCopyDataInit
CopyDataInit: ldr r3, =_sidata ldr r3, [r3, r1] str r3, [r0, r1] adds r1, r1, #4
LoopCopyDataInit: ldr r0, =_sdata ldr r3, =_edata adds r2, r0, r1 cmp r2, r3 bcc CopyDataInit ldr r2, =_sbss b LoopFillZerobss
/* 将bss段清零 */ FillZerobss: movs r3, #0 str r3, [r2], #4
LoopFillZerobss: ldr r3, = _ebss cmp r2, r3 bcc FillZerobss
/* 调用主函数 */ bl main bx lr
|
复位处理程序的主要任务包括:
- 初始化数据段:将已初始化的全局变量从Flash复制到RAM
- 清零bss段:将未初始化的全局变量所在的内存区域清零
- 调用主函数[main]
注意这里的.thumb_func指令,它告诉汇编器该函数使用Thumb指令集。STM32 Cortex-M系列处理器主要使用Thumb指令集以提高代码密度。
简要介绍一下上面用到的汇编指令:
- movs r1, #0:将立即数0移动到寄存器r1中,并更新状态标志位
- ldr r3, =_sidata:将符号_sidata的地址加载到寄存器r3中
- ldr r3, [r3, r1]:从内存地址(r3 + r1)处加载数据到寄存器r3
- str r3, [r0, r1]:将寄存器r3中的值存储到内存地址(r0 + r1)处
- str r3, [r2], #4:将寄存器r3中的值存储到地址r2处,然后将r2增加4
主程序分析
主程序文件[main.s]实现了LED控制逻辑:
寄存器地址定义
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| /* 定义GPIO寄存器地址 */ .equ GPIOB_BASE, 0x40020400 .equ GPIOB_MODER, GPIOB_BASE + 0x00 .equ GPIOB_OTYPER, GPIOB_BASE + 0x04 .equ GPIOB_OSPEEDR, GPIOB_BASE + 0x08 .equ GPIOB_PUPDR, GPIOB_BASE + 0x0C .equ GPIOB_IDR, GPIOB_BASE + 0x10 .equ GPIOB_ODR, GPIOB_BASE + 0x14 .equ GPIOB_BSRR, GPIOB_BASE + 0x18 .equ GPIOB_LCKR, GPIOB_BASE + 0x1C .equ GPIOB_AFRL, GPIOB_BASE + 0x20 .equ GPIOB_AFRH, GPIOB_BASE + 0x24
/* 定义RCC寄存器地址 */ .equ RCC_BASE, 0x40023800 .equ RCC_AHB1ENR, RCC_BASE + 0x30
|
这些地址定义对应STM32F407参考手册中的外设寄存器地址。通过直接操作这些寄存器,可以控制GPIO引脚的行为。
主函数实现
1 2 3 4 5 6 7 8 9 10 11 12 13
| main: /* 使能GPIOB时钟 */ ldr r0, =RCC_AHB1ENR ldr r1, [r0] orr r1, r1, #(1<<1) /* 使能GPIOB时钟(第1位) */ str r1, [r0]
/* 配置PB2为输出模式 */ ldr r0, =GPIOB_MODER ldr r1, [r0] bic r1, r1, #(0x3 << (LED_PIN * 2)) /* 清除PB2的模式位 */ orr r1, r1, #(0x1 << (LED_PIN * 2)) /* 设置为输出模式 */ str r1, [r0]
|
主函数首先使能GPIOB端口的时钟,因为STM32的所有外设都需要先使能时钟才能工作。然后配置PB2引脚为输出模式。可能你注意到了这里只配置了GPIO的时钟,并没有配置MCU的系统时钟,通过查阅F407的用户手册可以看到,HSION位在上电后默认开启。为了工程简单,使用默认的内部快速时钟即可。
里面有用到汇编指令:
- orr r1, r1, #(1<<1):将寄存器 r1 与立即数 (1<<1) 进行按位或运算,结果存储在 r1 中,用于设置特定位(使能 GPIOB 时钟)
- bic r1, r1, #(0x3 << (LED_PIN * 2)):将寄存器 r1 与立即数 (0x3 << (LED_PIN * 2)) 进行按位清除运算,用于清除 PB2 的模式位
LED控制循环
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| loop: /* 点亮LED */ ldr r0, =GPIOB_BSRR mov r1, #LED_PIN_MASK str r1, [r0] /* 简单延时 */ bl delay /* 熄灭LED */ ldr r0, =GPIOB_BSRR mov r1, #(LED_PIN_MASK << 16) str r1, [r0] /* 简单延时 */ bl delay /* 循环 */ b loop
|
在这个循环中,我们通过操作BSRR(Bits Set/Reset Register)寄存器来控制LED的亮灭。当写入低16位时,对应的引脚被置位(点亮);当写入高16位时,对应的引脚被复位(熄灭)。
延时函数
1 2 3 4 5 6 7 8 9 10 11 12
| /* 延时函数 */ delay: push {r4, lr} mov r4, #0xFF delay_outer: mov r3, #0xFFF delay_inner: subs r3, r3, #1 bne delay_inner subs r4, r4, #1 bne delay_outer pop {r4, pc}
|
一个软件延时函数,通过嵌套循环消耗时间。
链接脚本解析
链接脚本[linker.ld]定义了程序在内存中的布局:
1 2 3 4 5 6 7
| ENTRY(Reset_Handler)
MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K }
|
ENTRY(Reset_Handler)指定了程序的入口点是[Reset_Handler]函数。
MEMORY部分定义了STM32F407的内存布局:
- Flash存储器从0x08000000开始,大小为512KB,具有读(read)和执行(execute)属性
- RAM从0x20000000开始,大小为128KB,具有读(read)、写(write)和执行(execute)属性
段定义
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| SECTIONS { .text : { KEEP(*(.isr_vector)) *(.text*) *(.rodata*) _etext = .; } > FLASH
_sidata = .;
.data : AT (_sidata) { _sdata = .; *(.data*) _edata = .; } > RAM
|
各段的作用:
.text段包含程序代码和只读数据,存储在Flash中
.data段包含已初始化的全局变量,在Flash中存储但在RAM中运行
.bss段包含未初始化的全局变量,全部清零后使用
KEEP(*(.isr_vector))确保向量表不会被链接器优化掉。
Makefile构建系统
我们的Makefile配置了完整的构建流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| CC = arm-none-eabi-gcc AS = arm-none-eabi-as LD = arm-none-eabi-ld OBJCOPY = arm-none-eabi-objcopy SIZE = arm-none-eabi-size
TARGET = stm32f407_blinky
ASM_SOURCES = startup.s main.s
LINKER_SCRIPT = linker.ld
OBJECTS = $(ASM_SOURCES:.s=.o)
ASFLAGS = -mcpu=cortex-m4 -mthumb LDFLAGS = -T$(LINKER_SCRIPT) -Wl,-Map=$(TARGET).map CPFLAGS = -O binary
all: $(TARGET).bin $(TARGET).hex $(TARGET).map
|
关键点解释:
- 使用ARM GCC工具链进行编译
-mcpu=cortex-m4 -mthumb指定目标处理器和指令集
-T$(LINKER_SCRIPT) -Wl,-Map=$(TARGET).map指定链接脚本并生成MAP文件
- 生成多种格式的输出文件:ELF、BIN、HEX
MAP文件解读
MAP文件[stm32f407_blinky.map]提供了详细的内存映射信息:
1 2 3 4 5 6
| .text 0x08000000 0x124 *(.isr_vector) .isr_vector 0x08000000 0x8c startup.o *(.text*) .text 0x0800008c 0x44 startup.o .text 0x080000d0 0x54 main.o
|
从MAP文件可以看出:
- 整个程序的代码段只有0x124(292字节),非常小巧
- 向量表占用了0x8c(140字节)
- 启动代码占用了0x44(68字节)
- 主程序占用了0x54(84字节)
这充分体现了纯汇编编程的高效性,相比C语言实现可以显著减小程序体积。
1 2
| .stack 0x20000000 0x400 .heap 0x20000000 0x200
|
栈和堆都在RAM中分配,符合嵌入式系统的内存管理方式。
总结
通过这个最小化的STM32 LED闪烁项目,我们可以知道STM32工程的基本构成:
- 启动文件负责系统初始化和跳转到主程序
- 主程序实现具体的业务逻辑
- 链接脚本定义程序在内存中的布局
- Makefile自动化构建过程
- MAP文件提供内存使用的详细信息