本文章采用的开发板是STM32F103C8T6,下载器是ST_Link,开发环境是Keil5。本文要实现的是控制STM32F103C8T6(后文简称STM32)初始化GPIO,并控制其引脚PA0(红色二极管)、PB5(蓝色二极管)和PC13(STM32自带的黄色LED)的高低电平实现LED流水灯。
本文的实现前提是:
- 完成配置keil5的环境(包括keil内魔法棒的一些参数配置);
- 能简单创建keil的工程
如果不会的小伙伴们可以看看[江协科技](STM32入门教程-2023版 细致讲解 中文字幕_哔哩哔哩_bilibili)大大的视频,本文也是基于江科大的视频实现的。
一、基于寄存器实现LED流水灯
1.前置知识:STM32 GPIO 核心原理
在开始写代码前,必须先搞懂 2 个核心概念,这是寄存器操作的基础:
-
STM32 外设时钟默认关闭:STM32 为了低功耗,所有外设(包括 GPIO)的时钟默认是断开的,必须先通过 RCC 寄存器开启对应 GPIO 的时钟,否则后续配置全无效;
-
GPIO 引脚配置三要素
:每个 GPIO 引脚的工作模式由「端口配置寄存器(CRL/CRH)」的 2 组位控制 :
- MODE 位:控制输出速度(仅输出模式有效);
- CNF 位:控制工作模式(推挽 / 开漏 / 输入等);
- 寄存器地址:每个 GPIO 端口(A/B/C)有固定基地址,寄存器通过「基地址 + 偏移」访问。
2、找到所要调用的寄存器地址
我们可以在STM32的官方网站或者其他渠道下载STM32的官方参考手册,对于具体的 STM32 型号,其数据手册会详细列出各个外设(如GPIO、定时器、USART 等)的寄存器映射表。这些映射表明确给出了每个寄存器的名称、功能描述以及对应的偏移地址(相对于外设基地址)。本文所用的官方参考手册是STM32F10xxx参考手册(中文)。
打开手册,因为STM32 所有外设时钟默认关闭,必须先开启 GPIOA 的时钟,否则后续配置 CRL/ODR 等寄存器都无效。各个时钟中,APB2外设时钟使能寄存器(RCC_APB2ENR)是控制GPIO的时钟。我们在手册中找到APB2外设时钟使能寄存器(RCC_APB2ENR)的解释部分:
我们要使能的分别是GPIOA、GPIOB和GPIOC的时钟。以使能GPIOA端口的时钟为例,我们可以看到下方的介绍,找到位2,IOPAEN:IO端口A时钟使能 (I/O port A clock enable),其置0:IO端口A时钟关闭;置1:IO端口A时钟开启。于是我们使位2置1,其它位置0。如下图所示:
可以看到我们读出的地址为0x00000004,将这个地址赋给RCC_APB2ENR,在keil中的代码如下:
当然我们可以用stm32f10x.h
库中定义的变量映射,如下图所示:
图上的RCC_APB2ENR_IOPAEN
其实是stm32f10x.h
库中定义的地址:0x00000004,我们选中它右键,选择
Go To Definition Of 'RCC_APB2ENR_IOPAEN'
,跳转到它定义看看:
可以看见,RCC_APB2ENR_IOPAEN其实就是0x00000004,那么同理,我们也可以打开GPIOB和GPIOC的时钟了。
时钟配置完后,我们需要将GPIO的输出模式配置为「通用推挽输出」模式。依旧是打开手册,以PC13为例,找到8.2.2 端口配置高寄存器(GPIOx_CRH) (x=A..E),然后看下面的解释,对照计算其设置的地址,如下图所示:
模式类型 | CNF [1:0](配置位) | MODE [1:0](速度位) |
---|---|---|
通用推挽输出 | 00 | 01/10/11(非 00) |
我这里将速度位设置成了11,也就是最大速度50MHz。
//stm32f10x.h中定义
#define GPIO_CRH_MODE13 ((uint32_t)0x00300000) /*!< MODE13[1:0] bits (Port x mode bits, pin 13) */
#define GPIO_CRH_MODE13_0 ((uint32_t)0x00100000) /*!< Bit 0 */
#define GPIO_CRH_MODE13_1 ((uint32_t)0x00200000) /*!< Bit 1 */
//主函数GPIOC->CRH |= GPIO_CRH_MODE13;GPIOC->CRH &= ~GPIO_CRH_CNF13;
代码逻辑解释
|=
操作:只将目标位置 1,不影响其他位(比如这里只改 MODE13,不碰其他引脚的配置);&= ~
操作:只将目标位清 0,不影响其他位(比如这里清除 CNF13,确保是推挽模式);- 若写成
GPIOC->CRH = GPIO_CRH_MODE13;
会覆盖 CRL 寄存器的其他位,导致 PC8-15引脚配置混乱,这是新手常见错误!
3.输出电平的设置以及主函数的设计
初始化成功后,我们接下来控制 PC13输出低电平,点亮板载二极管。
我这里采用的是BSRR 寄存器来控制高低电平,手册中找到8.2.5 端口位设置清除寄存器(GPIOx_BSRR) (x=A..E):
BSRR 是「置位 / 复位寄存器」,写 1 有效,不影响其他位:
- 低 16 位(bit0-bit15):置位(写 1→对应引脚输出高电平);
- 高 16 位(bit16-bit31):复位(写 1→对应引脚输出低电平)。
GPIOC->BSRR = GPIO_BSRR_BR13;
// PC13输出低电平(点亮PC13引脚)
GPIOC->BSRR = GPIO_BSRR_BR13;
在网上找到实现delay
延时的函数:
/*** @brief 微秒级延时* @param xus 延时时长,范围:0~233015* @retval 无*/
void Delay_us(uint32_t xus)
{SysTick->LOAD = 72 * xus; //设置定时器重装值SysTick->VAL = 0x00; //清空当前计数值SysTick->CTRL = 0x00000005; //设置时钟源为HCLK,启动定时器while(!(SysTick->CTRL & 0x00010000)); //等待计数到0SysTick->CTRL = 0x00000004; //关闭定时器
}/*** @brief 毫秒级延时* @param xms 延时时长,范围:0~4294967295* @retval 无*/
void Delay_ms(uint32_t xms)
{while(xms--){Delay_us(1000);}
}/*** @brief 秒级延时* @param xs 延时时长,范围:0~4294967295* @retval 无*/
void Delay_s(uint32_t xs)
{while(xs--){Delay_ms(1000);}
}
实现流水灯的具体代码如下:(延时函数已封装没有给出,不知道怎么封装的自行学习)
#include "stm32f10x.h"
#include "Delay.h"int main(void) {// 启用GPIOA、GPIOB和GPIOC时钟RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_IOPBEN | RCC_APB2ENR_IOPCEN;// 配置引脚为通用推挽输出GPIOA->CRL |= GPIO_CRL_MODE0 ;// |=:用于设置特定位为 1(不影响其他位)GPIOA->CRL &= ~GPIO_CRL_CNF0;// &= ~:用于清除特定位为 0(不影响其他位),如配置推挽模式GPIOB->CRL |= GPIO_CRL_MODE5;GPIOB->CRL &= ~GPIO_CRL_CNF5;GPIOC->CRH |= GPIO_CRH_MODE13;GPIOC->CRH &= ~GPIO_CRH_CNF13;while (1) {// 点亮A0引脚,同时熄灭 B5, PC13引脚GPIOA->BSRR = GPIO_BSRR_BR0;GPIOB->BSRR = GPIO_BSRR_BS5;GPIOC->BSRR = GPIO_BSRR_BS13;Delay_s(1); // 延时一秒// 点亮B5引脚,同时熄灭A0, PC13引脚GPIOA->BSRR = GPIO_BSRR_BS0;GPIOB->BSRR = GPIO_BSRR_BR5;GPIOC->BSRR = GPIO_BSRR_BS13;Delay_s(1); // 延时一秒// 点亮PC13引脚,同时熄灭A0, B5引脚GPIOA->BSRR = GPIO_BSRR_BS0;GPIOB->BSRR = GPIO_BSRR_BS5;GPIOC->BSRR = GPIO_BSRR_BR13;Delay_s(1); // 延时一秒}}
4.实验结果
编译烧录后,实验结果如以下视频:
二、基于标准库实现LED流水灯
1. 标准库实现原理概述
STM32 标准库(STM32F10x_StdPeriph_Lib)封装了底层寄存器操作,通过提供结构化的 API 函数简化开发。相比寄存器操作,标准库的优势在于:
- 无需记忆复杂的寄存器地址和位操作
- 通过结构体统一配置参数,代码可读性更强
- 兼容不同型号 STM32 芯片,移植性更好
实现 LED 流水灯的核心流程与寄存器方式一致(时钟使能→GPIO 配置→电平控制→延时循环),但操作方式通过标准库函数完成。
2. 代码解析
(1)头文件与函数声明(light.h)
#ifndef _light_H_
#define _light_H_
#include "stm32f10x.h" // 包含标准库核心头文件// 声明LED初始化和控制函数
void LED_INIT();
void LED1_ON(void);
void LED1_OFF(void);
void LED2_ON(void);
void LED2_OFF(void);
void LED3_ON(void);
void LED3_OFF(void);#endif
- 头文件保护符
_light_H_
防止重复包含 - 依赖
stm32f10x.h
,该文件包含了标准库所有外设的定义
(2)LED 驱动实现(light.c)
① 时钟使能
#include "stm32f10x.h"
#include "Delay.h"void LED_INIT()
{// 使能GPIOA、GPIOB、GPIOC时钟RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB | RCC_APB2Periph_GPIOC, ENABLE);// 函数说明:RCC_APB2PeriphClockCmd(外设列表, 使能状态)// 作用等价于寄存器操作中的`RCC->APB2ENR |= ...`,但无需记忆具体位定义
}
② GPIO 初始化结构体配置
void LED_INIT()
{// ...(时钟使能代码同上)GPIO_InitTypeDef GPIO_InitStructure; // 定义GPIO初始化结构体// 通用配置(适用于所有LED引脚)GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; // 推挽输出模式(对应寄存器的CNF=00)GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; // 输出速度50MHz(对应寄存器的MODE=11)// 初始化PA0(LED1)GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; // 指定引脚GPIO_Init(GPIOA, &GPIO_InitStructure); // 应用配置到GPIOA// 初始化PB5(LED2)GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5; // 复用结构体,仅修改引脚参数GPIO_Init(GPIOB, &GPIO_InitStructure);// 初始化PC13(LED3)GPIO_InitStructure.GPIO_Pin = GPIO_Pin_13;GPIO_Init(GPIOC, &GPIO_InitStructure);
}
- 结构体
GPIO_InitTypeDef
是标准库的核心设计,将引脚模式、速度、引脚号等参数封装成结构体 GPIO_Init()
函数会根据结构体参数自动配置对应的 CRL/CRH 寄存器,无需手动计算位偏移
③ LED 电平控制函数
// LED1(PA0)控制
void LED1_ON(void)
{GPIO_ResetBits(GPIOA, GPIO_Pin_0); // 输出低电平(点亮LED)// 等价于寄存器操作:GPIOA->BSRR = GPIO_BSRR_BR0;
}
void LED1_OFF(void)
{GPIO_SetBits(GPIOA, GPIO_Pin_0); // 输出高电平(熄灭LED)// 等价于寄存器操作:GPIOA->BSRR = GPIO_BSRR_BS0;
}// LED2(PB5)控制(逻辑同上)
void LED2_ON(void) { GPIO_ResetBits(GPIOB, GPIO_Pin_5); }
void LED2_OFF(void) { GPIO_SetBits(GPIOB, GPIO_Pin_5); }// LED3(PC13)控制(逻辑同上)
void LED3_ON(void) { GPIO_ResetBits(GPIOC, GPIO_Pin_13); }
void LED3_OFF(void) { GPIO_SetBits(GPIOC, GPIO_Pin_13); }
GPIO_SetBits()
和GPIO_ResetBits()
分别对应寄存器操作中的 "置位" 和 "复位",内部已处理 BSRR 寄存器的高低位映射
(3)主函数逻辑(main.c)
#include "stm32f10x.h"
#include "Delay.h"
#include "light.h"int main(void) {LED_INIT(); // 初始化LED引脚while (1) {// 轮流点亮三个LED,间隔1秒LED1_ON(); LED2_OFF(); LED3_OFF(); Delay_s(1);LED1_OFF(); LED2_ON(); LED3_OFF(); Delay_s(1);LED1_OFF(); LED2_OFF(); LED3_ON(); Delay_s(1);}
}
- 主函数逻辑清晰:先初始化硬件,再在死循环中通过控制函数切换 LED 状态,配合延时实现流水效果
- 相比寄存器版本,代码更接近自然语言,可读性大幅提升
3. 标准库与寄存器操作对比
操作环节 | 寄存器方式 | 标准库方式 |
---|---|---|
时钟使能 | 直接操作RCC->APB2ENR 寄存器 |
调用RCC_APB2PeriphClockCmd() 函数 |
GPIO 配置 | 手动计算 CRL/CRH 寄存器位值 | 配置GPIO_InitTypeDef 结构体 |
电平控制 | 操作GPIOx->BSRR 寄存器 |
调用GPIO_SetBits() /GPIO_ResetBits() |
代码可读性 | 低(需记忆寄存器地址和位定义) | 高(函数和结构体语义化) |
移植性 | 差(不同型号寄存器可能有差异) | 好(标准库统一接口) |
4. 实验结果
实验结果如下:
与寄存器实现效果一致:PA0(红)→ PB5(蓝)→ PC13(黄)依次循环点亮,间隔 1 秒,形成流水灯效果。
三、Keil的软件仿真和误差分析
写延时函数的时候,我们用Delay_s(1)
想让 LED 亮 1 秒,但实际芯片运行时,这个时间总会有点偏差。这不是函数写错了,而是硬件运行、代码执行这些环节总会有微小的时间差。那怎么知道差多少呢?Keil 自带的逻辑分析仪能帮我们看到引脚的波形,轻松测出实际延时。
1.为啥要看波形?
Delay_s(1)
的意思是 “延时 1 秒”,但实际运行时:
- 延时函数本身的代码执行需要时间
- 芯片的系统时钟可能有微小波动
- 其他代码(比如切换 LED 状态的指令)也会占一点时间
这些都会让实际延时和 1 秒有偏差。逻辑分析仪能像 “慢镜头” 一样拍下引脚的高低电平变化,让我们直接看到每个 LED 亮了多久。
2.用 Keil 逻辑分析仪的步骤
2.1 仿真的设置以及调试模式的打开
根据下图步骤进行仿真设置:
先在 Keil 里打开我们的 LED 工程,点击菜单栏的Debug
按钮(像个小虫子的图标),进入调试模式。
2.2 调出逻辑分析仪
在调试界面里,点击菜单栏的View
,选择Logic Analyzer
,这时会弹出一个空白的波形窗口。
2.3 添加要观察的引脚
点击波形窗口里的Setup
按钮,弹出配置框。我们要添加观察控制 LED 的三个引脚:
输完后,每个信号的 “Display Format” 选Bit
(只看高低电平),点Close
关掉配置框。
2.4 开始运行并抓波形
点击调试工具栏的Run
按钮(三角箭头),让程序跑起来。等几秒后点击Stop
,波形窗口就会显示三个引脚的高低电平变化了 —— 高电平表示 LED 灭,低电平表示 LED 亮。
3.看波形算误差
在波形图上,找一个 LED 亮的时间段(比如 PB5 的低电平部分),用鼠标拖动测量这段时间的长度。
观察上方的波形图,可以看见LED流水灯正常实现功能,波形时序状态正确。
看到下方显示的时间分别为 4.001923和5.030682,那么它们之间的差值1.028759即为真正延时的时间,真实周期为3.086277。
为啥会有误差呢?主要是Delay_s(1)
内部是用Delay_ms(1000)
循环实现的,而Delay_ms
又靠Delay_us
,每一层循环都多花了一点点时间。这些 “一点点” 加起来,就造成了最终的偏差。
四、总结
本文围绕 STM32F103C8T6 实现 LED 流水灯,从寄存器操作与标准库应用两方面展开,为嵌入式入门者提供了清晰的实践路径。
寄存器方式中,核心在于理解 STM32 外设时钟默认关闭的特性,需先通过 RCC_APB2ENR 寄存器开启 GPIOA、GPIOB、GPIOC 时钟,再通过 CRL/CRH 寄存器配置引脚为推挽输出模式,最后利用 BSRR 寄存器控制电平实现灯的亮灭。这一过程要求开发者熟悉寄存器地址与位操作,虽略显繁琐,却能直观理解硬件工作机制。
标准库方式则通过封装好的 API 函数(如 RCC_APB2PeriphClockCmd、GPIO_Init)简化操作,借助结构体统一配置参数,大幅提升了代码可读性与移植性,更适合快速开发。
文中还通过 Keil 仿真分析了延时误差,揭示了软件设计与硬件运行的细微偏差,体现了工程实践中理论与实际的辩证关系。
两种方式各有价值:寄存器操作是理解底层逻辑的基础,培养对硬件的深度认知;标准库则展现了软件工程中抽象与封装的思想,为复杂项目提供效率保障。对于入门者而言,先掌握寄存器原理,再过渡到标准库应用,能构建更完整的嵌入式开发知识体系。