本章参考资料: 《STM32F4xx 中文参考手册》 、 《STM32F429 规格书》
虽然我们上面用寄存器点亮了 LED,乍看一下好像代码也很简单,但是我们别侥幸以后就可以一直用寄存器开发。在用寄存器点亮 LED 的时候,我们会发现 STM32 的寄存器都是 32 位的,每次配置的时候都要对照着《STM32F4xx 参考手册》中寄存器的说明,然后根据说明对每个控制的寄存器位写入特定参数,因此在配置的时候非常容易出错,而且代码还很不好理解,不便于维护。所以学习 STM32 最好的方法是用软件库,然后在软件库的基础上了解底层,学习遍所有寄存器。
以上所说的软件库是指“STM32 标准函数库”,它是由 ST 公司针对 STM32 提供的函数接口,即 API (Application Program Interface),开发者可调用这些函数接口来配置 STM32的寄存器,使开发人员得以脱离最底层的寄存器操作,有开发快速,易于阅读,维护成本低等优点。
当我们调用库 API 的时候不需要挖空心思去了解库底层的寄存器操作,就像当年我们刚开始学习 C 语言的时候,用 prinft()函数时只是学习它的使用格式,并没有去研究它的源码实现, 但需要深入研究的时候,经过千锤百炼的库 API 源码就是最佳学习范例。
实际上, 库是架设在寄存器与用户驱动层之间的代码,向下处理与寄存器直接相关的配置,向上为用户提供配置寄存器的接口。 库开发方式与直接配置寄存器方式的区别见图6-1。
在以前 8 位机时代的程序开发中, 一般直接配置芯片的寄存器,控制芯片的工作方式,如中断,定时器等。配置的时候, 常常要查阅寄存器表,看用到哪些配置位,为了配置某功能,该置 1 还是置 0。这些都是很琐碎的、机械的工作,因为 8 位机的软件相对来说较简单,而且资源很有限,所以可以直接配置寄存器的方式来开发。
对于 STM32,因为外设资源丰富,带来的必然是寄存器的数量和复杂度的增加,这时直接配置寄存器方式的缺陷就突显出来了:
(1) 开发速度慢
(2) 程序可读性差
(3) 维护复杂
这些缺陷直接影响了开发效率,程序维护成本,交流成本。库开发方式则正好弥补了这些缺陷。
而坚持采用直接配置寄存器的方式开发的程序员,会列举以下原因:
(1) 具体参数更直观
(2) 程序运行占用资源少
相对于库开发的方式,直接配置寄存器方式生成的代码量的确会少一点,但因为STM32 有充足的资源,权衡库的优势与不足,绝大部分时候,我们愿意牺牲一点 CPU 资源,选择库开发。一般只有在对代码运行时间要求极苛刻的地方,才用直接配置寄存器的方式代替,如频繁调用的中断服务函数。
对于库开发与直接配置寄存器的方式,就好比编程是用汇编好还是用 C 好一样。在STM32F1 系列刚推出函数库时引起程序员的激烈争论,但是,随着 ST 库的完善与大家对库的了解,更多的程序员选择了库开发。 现在 STM32F1 系列和 STM32F4 系列各有一套自己的函数库,但是它们大部分是兼容的, F1 和 F4 之间的程序移植,只需要小修改即可。而如果要移植用寄存器写的程序,我只想说:“呵呵”。
用库来进行开发,市场已有定论,用户群说明了一切,但对于 STM32 的学习仍然有人认为用寄存器好,而且汇编不是还没退出大学教材么?认为这种方法直观,能够了解到是配置了哪些寄存器,怎样配置寄存器。事实上,库函数的底层实现恰恰是直接配置寄存器
方式的最佳例子,它代替我们完成了寄存器配置的工作,而想深入了解芯片是如何工作的话,只要直接查看库函数的最底层实现就能理解,相信你会为它严谨、优美的实现方式而陶醉, 要想修炼 C 语言,就从 ST 的库开始吧。 所以在以后的章节中,使用软件库是我们的重点,而且我们通过讲解库 API 去高效地学习 STM32 的寄存器,并不至于因为用库学习,就不会用寄存器控制 STM32 芯片。
虽然库的优点多多,但很多人对库还是很忌惮,因为一开始用库的时候有很多代码,很多文件,不知道如何入手。不知道您是否认同这么一句话:一切的恐惧都来源于认知的空缺。我们对库忌惮那是因为我们不知道什么是库,不知道库是怎么实现的。
接下来,我们在寄存器点亮 LED 的代码上继续完善,把代码一层层封装,实现库的最初的雏形,相信经过这一步的学习后,您对库的运用会游刃有余。这里我们只讲如何实现GPIO 函数库,其他外设的我们直接参考 ST 标准库学习即可,不必自己写。
下面请打开本章配套例程“构建库函数雏形”来阅读理解,该例程是在上一章的基础上修改得来的。
上一章中我们在操作寄存器的时候,操作的是都寄存器的绝对地址,如果每个外设寄存器都这样操作,那将非常麻烦。我们考虑到外设寄存器的地址都是基于外设基地址的偏移地址,都是在外设基地址上逐个连续递增的,每个寄存器占 32 个或者 16 个字节,这种方式跟结构体里面的成员类似。所以我们可以定义一种外设结构体,结构体的地址等于外设的基地址,结构体的成员等于寄存器,成员的排列顺序跟寄存器的顺序一样。这样我们操作寄存器的时候就不用每次都找到绝对地址,只要知道外设的基地址就可以操作外设的全部寄存器,即操作结构体的成员即可。
在工程中的“stm32f4xx.h”文件中,我们使用结构体封装 GPIO 及 RCC 外设的的寄存器,见代码清单 6-1。结构体成员的顺序按照寄存器的偏移地址从低到高排列,成员类型跟寄存器类型一样。如不理解 C 语言对寄存器的封的语法原理,请参考《C 语言对寄存器的封装》 小节。
代码清单6-1 封装寄存器列表
//volatile 表示易变的变量,防止编译器优化
#define __IO volatile
typedef unsigned int uint32_t;
typedef unsigned short uint16_t;
/* GPIO 寄存器列表 */
typedef struct {
__IO uint32_t MODER; /*GPIO 模式寄存器 地址偏移: 0x00 */
__IO uint32_t OTYPER; /*GPIO 输出类型寄存器 地址偏移: 0x04 */
__IO uint32_t OSPEEDR; /*GPIO 输出速度寄存器 地址偏移: 0x08 */
__IO uint32_t PUPDR; /*GPIO 上拉/下拉寄存器 地址偏移: 0x0C */
__IO uint32_t IDR; /*GPIO 输入数据寄存器 地址偏移: 0x10 */
__IO uint32_t ODR; /*GPIO 输出数据寄存器 地址偏移: 0x14 */
__IO uint16_t BSRRL; /*GPIO 置位/复位寄存器低 16 位部分 地址偏移: 0x18 */
__IO uint16_t BSRRH; /*GPIO 置位/复位寄存器 高 16 位部分地址偏移: 0x1A */
__IO uint32_t LCKR; /*GPIO 配置锁定寄存器 地址偏移: 0x1C */
__IO uint32_t AFR[2]; /*GPIO 复用功能配置寄存器 地址偏移: 0x20-0x24 */
} GPIO_TypeDef;
/*RCC 寄存器列表*/
typedef struct {
__IO uint32_t CR; /*!< RCC 时钟控制寄存器,地址偏移: 0x00 */
__IO uint32_t PLLCFGR; /*!< RCC PLL 配置寄存器,地址偏移: 0x04 */
__IO uint32_t CFGR; /*!< RCC 时钟配置寄存器,地址偏移: 0x08 */
__IO uint32_t CIR; /*!< RCC 时钟中断寄存器,地址偏移: 0x0C */
__IO uint32_t AHB1RSTR; /*!< RCC AHB1 外设复位寄存器,地址偏移: 0x10 */
__IO uint32_t AHB2RSTR; /*!< RCC AHB2 外设复位寄存器,地址偏移: 0x14 */
__IO uint32_t AHB3RSTR; /*!< RCC AHB3 外设复位寄存器,地址偏移: 0x18 */
__IO uint32_t RESERVED0; /*!< 保留, 地址偏移: 0x1C */
__IO uint32_t APB1RSTR; /*!< RCC APB1 外设复位寄存器,地址偏移: 0x20 */
__IO uint32_t APB2RSTR; /*!< RCC APB2 外设复位寄存器,地址偏移: 0x24*/
__IO uint32_t RESERVED1[2]; /*!< 保留,地址偏移: 0x28-0x2C*/
__IO uint32_t AHB1ENR; /*!< RCC AHB1 外设时钟寄存器,地址偏移: 0x30 */
__IO uint32_t AHB2ENR; /*!< RCC AHB2 外设时钟寄存器,地址偏移: 0x34 */
__IO uint32_t AHB3ENR; /*!< RCC AHB3 外设时钟寄存器,地址偏移: 0x38 */
/*RCC 后面还有很多寄存器,此处省略*/
} RCC_TypeDef;
这段代码在每个结构体成员前增加了一个“__IO”前缀,它的原型在这段代码的第一行,代表了 C 语言中的关键字“volatile”,在 C 语言中该关键字用于表示变量是易变的,要求编译器不要优化。这些结构体内的成员,都代表着寄存器,而寄存器很多时候是由外设或 STM32 芯片状态修改的,也就是说即使 CPU 不执行代码修改这些变量,变量的值也有可能被外设修改、更新,所以每次使用这些变量的时候,我们都要求 CPU 去该变量的地址重新访问。若没有这个关键字修饰,在某些情况下,编译器认为没有代码修改该变量,就直接从 CPU 的某个缓存获取该变量值,这时可以加快执行速度,但该缓存中的是陈旧数据,与我们要求的寄存器最新状态可能会有出入。
以结构体的形式定义好了外设寄存器后,使用结构体前还需要给结构体的首地址赋值,才能访问到需要的寄存器。为方便操作,我们给每个外设都定义好指向它地址的结构体指针,见代码清单 6-2。
代码清单 6-2 指向外设首地址的结构体指针
/*定义 GPIOA-H 寄存器结构体指针*/
#define GPIOA ((GPIO_TypeDef *) GPIOA_BASE)
#define GPIOB ((GPIO_TypeDef *) GPIOB_BASE)
#define GPIOC ((GPIO_TypeDef *) GPIOC_BASE)
#define GPIOD ((GPIO_TypeDef *) GPIOD_BASE)
#define GPIOE ((GPIO_TypeDef *) GPIOE_BASE)
#define GPIOF ((GPIO_TypeDef *) GPIOF_BASE)
#define GPIOG ((GPIO_TypeDef *) GPIOG_BASE)
#define GPIOH ((GPIO_TypeDef *) GPIOH_BASE)
/*定义 RCC 外设 寄存器结构体指针*/
#define RCC ((RCC_TypeDef *) RCC_BASE)
这些宏通过强制把外设的基地址转换成 GPIO_TypeDef 类型的地址,从而得到 GPIOA、GPIOB 等直接指向对应外设的指针,通过结构体的指针操作,即可访问对应外设的寄存器。利用这些指针访问寄存器,我们把 main 文件里对应的代码修改掉,见代码清单 6-3。
代码清单 6-3 使用结构体指针方式控制 LED 灯
/**
* 主函数
*/
int main(void)
{
RCC->AHB1ENR |= (1<<7);
/* LED 端口初始化 */
/*GPIOH MODER10 清空*/
GPIOH->MODER &= ~( 0x03<< (2*10));
/*PH10 MODER10 = 01b 输出模式*/
GPIOH->MODER |= (1<<2*10);
/*GPIOH OTYPER10 清空*/
GPIOH->OTYPER &= ~(1<<1*10);
/*PH10 OTYPER10 = 0b 推挽模式*/
GPIOH->OTYPER |= (0<<1*10);
/*GPIOH OSPEEDR10 清空*/
GPIOH->OSPEEDR &= ~(0x03<<2*10);
/*PH10 OSPEEDR10 = 0b 速率 2MHz*/
GPIOH->OSPEEDR |= (0<<2*10);
/*GPIOH PUPDR10 清空*/
GPIOH->PUPDR &= ~(0x03<<2*10);
/*PH10 PUPDR10 = 01b 上拉模式*/
GPIOH->PUPDR |= (1<<2*10);
/*PH10 BSRR 寄存器的 BR10 置 1,使引脚输出低电平*/
GPIOH->BSRRH |= (1<<10);
/*PH10 BSRR 寄存器的 BS10 置 1,使引脚输出高电平*/
//GPIOH->BSRRL |= (1<<10);
while (1);
}
乍一看,除了最后一部分,把 BSRR 寄存器分成 BSRRH 和 BSRRL 两段,其它部分跟直接用绝对地址访问只是名字改了而已,用起来跟上一章没什么区别。这是因为我们现在只实现了库函数的基础,还没有定义库函数。
打好了地基,下面我们就来建高楼。接下来使用函数来封装 GPIO 的基本操作,方便以后应用的时候不需要再查询寄存器,而是直接通过调用这里定义的函数来实现。我们把针对 GPIO 外设操作的函数及其宏定义分别存放在“stm32f4xx_gpio.c”和“stm32f4xx_gpio.h”文件中。
定义位操作函数
在“stm32f4xx_gpio.c”文件定义两个位操作函数,分别用于控制引脚输出高电平和低电平,见代码清单 6-4。
代码清单 6-4 GPIO 置位函数与复位函数的定义
/**
*函数功能:设置引脚为高电平
*参数说明: GPIOx:该参数为 GPIO_TypeDef 类型的指针,指向 GPIO 端口的地址
* GPIO_Pin:选择要设置的 GPIO 端口引脚,可输入宏 GPIO_Pin_0-15,
* 表示 GPIOx 端口的 0-15 号引脚。
*/
void GPIO_SetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
/*设置 GPIOx 端口 BSRRL 寄存器的第 GPIO_Pin 位,使其输出高电平*/
/*因为 BSRR 寄存器写 0 不影响,
宏 GPIO_Pin 只是对应位为 1,其它位均为 0,所以可以直接赋值*/
GPIOx->BSRRL = GPIO_Pin;
}
/**
*函数功能:设置引脚为低电平
*参数说明: GPIOx:该参数为 GPIO_TypeDef 类型的指针,指向 GPIO 端口的地址
* GPIO_Pin:选择要设置的 GPIO 端口引脚,可输入宏 GPIO_Pin_0-15,
* 表示 GPIOx 端口的 0-15 号引脚。
*/
void GPIO_ResetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
/*设置 GPIOx 端口 BSRRH 寄存器的第 GPIO_Pin 位,使其输出低电平*/
/*因为 BSRR 寄存器写 0 不影响,
宏 GPIO_Pin 只是对应位为 1,其它位均为 0,所以可以直接赋值*/
GPIOx->BSRRH = GPIO_Pin;
}
这两个函数体内都是只有一个语句,对 GPIOx 的 BSRRL 或 BSRRH 寄存器赋值,从而设置引脚为高电平或低电平。其中 GPIOx 是一个指针变量,通过函数的输入参数我们可以修改它的值,如给它赋予 GPIOA、 GPIOB、 GPIOH 等结构体指针值,这个函数就可以控制相应的 GPIOA、 GPIOB、 GPIOH 等端口的输出。
对比我们前面对 BSRR 寄存器的赋值,都是用“|=”操作来防止对其它数据位产生干扰的,为何此函数里的操作却直接用“=”号赋值,这样不怕干扰其它数据位吗?见代码清单 6-5。
代码清单 6-5 赋值方式对比
/*使用 “|=” 来赋值*/
GPIOH->BSRRH |= (1<<10);
/*直接使用 "=" 号赋值*/
GPIOx->BSRRH = GPIO_Pin;
根据 BSRR 寄存器的特性,对它的数据位写“0”,是不会影响输出的,只有对它的数据位写“1”,才会控制引脚输出。对低 16 位写“1”输出高电平,对高 16 位写“1”输出低电平。也就是说,假如我们对 BSRRH(高 16 位)直接用“=”操作赋二进制值“0000 00000000 0001 b”,它会控制 GPIO 的引脚 0 输出低电平,赋二进制值“0000 0000 0001 0000b”,它会控制 GPIO 引脚 4 输出低电平,而其它数据位由于是 0,所以不会受到干扰。同理,对 BSRRL(低 16 位)直接赋值也是如此,数据位为 1 的位输出高电平。 代码清单 6-6 中的两种方式赋值,功能相同。
代码清单 6-6 BSRR 寄存器赋值等效代码
/*使用 “|=” 来赋值*/
GPIOH->BSRRH |= (uint16_t)(1<<10);
/*直接使用“=” 来赋值,二进制数(0000 0100 0000 0000)*/
GPIOH->BSRRH = (uint16_t)(1<<10);
这两行代码功能等效,都把 BSRRH 的 bit10 设置为 1,控制引脚 10 输出低电平,且其它引脚状态不变。但第二个语句操作效率是比较高的,因为“|=”号包含了读写操作,而“=”号只需要一个写操作。因此在定义位操作函数中我们使用后者。
利用这两个位操作函数,就可以方便地操作各种 GPIO 的引脚电平了,控制各种端口引脚的范例见代码清单 6-7。
代码清单 6-7 位操作函数使用范例
/*控制 GPIOH 的引脚 10 输出高电平*/
GPIO_SetBits(GPIOH,(uint16_t)(1<<10));
/*控制 GPIOH 的引脚 10 输出低电平*/
GPIO_ResetBits(GPIOH,(uint16_t)(1<<10));
/*控制 GPIOH 的引脚 10、引脚 11 输出高电平,使用“|”同时控制多个引脚*/
GPIO_SetBits(GPIOH,(uint16_t)(1<<10)|(uint16_t)(1<<11));
/*控制 GPIOH 的引脚 10、引脚 11 输出低电平*/
GPIO_ResetBits(GPIOH,(uint16_t)(1<<10)|(uint16_t)(1<<10));
/*控制 GPIOA 的引脚 8 输出高电平*/
GPIO_SetBits(GPIOA,(uint16_t)(1<<8));
/*控制 GPIOB 的引脚 9 输出低电平*/
GPIO_ResetBits(GPIOB,(uint16_t)(1<<9));
使用以上函数输入参数,设置引脚号时,还是稍感不便,为此我们把表示 16 个引脚的操作数都定义成宏,见代码清单 6-8。
代码清单 6-8 选择引脚参数的宏
/*GPIO 引脚号定义*/
#define GPIO_Pin_0 (uint16_t)0x0001) /*!< 选择 Pin0 (1<<0) */
#define GPIO_Pin_1 ((uint16_t)0x0002) /*!< 选择 Pin1 (1<<1)*/
#define GPIO_Pin_2 ((uint16_t)0x0004) /*!< 选择 Pin2 (1<<2)*/
#define GPIO_Pin_3 ((uint16_t)0x0008) /*!< 选择 Pin3 (1<<3)*/
#define GPIO_Pin_4 ((uint16_t)0x0010) /*!< 选择 Pin4 */
#define GPIO_Pin_5 ((uint16_t)0x0020) /*!< 选择 Pin5 */
#define GPIO_Pin_6 ((uint16_t)0x0040) /*!< 选择 Pin6 */
#define GPIO_Pin_7 ((uint16_t)0x0080) /*!< 选择 Pin7 */
#define GPIO_Pin_8 ((uint16_t)0x0100) /*!< 选择 Pin8 */
#define GPIO_Pin_9 ((uint16_t)0x0200) /*!< 选择 Pin9 */
#define GPIO_Pin_10 ((uint16_t)0x0400) /*!< 选择 Pin10 */
#define GPIO_Pin_11 ((uint16_t)0x0800) /*!< 选择 Pin11 */
#define GPIO_Pin_12 ((uint16_t)0x1000) /*!< 选择 Pin12 */
#define GPIO_Pin_13 ((uint16_t)0x2000) /*!< 选择 Pin13 */
#define GPIO_Pin_14 ((uint16_t)0x4000) /*!< 选择 Pin14 */
#define GPIO_Pin_15 ((uint16_t)0x8000) /*!< 选择 Pin15 */
#define GPIO_Pin_All ((uint16_t)0xFFFF) /*!< 选择全部引脚 */
这些宏代表的参数是某位置“1”其它位置“0”的数值,其中最后一个“GPIO_Pin_ALL”是所有数据位都为“1”,所以用它可以一次控制设置整个端口的 0-15所有引脚。利用这些宏, GPIO 的控制代码可改为代码清单 6-9。
代码清单 6-9 使用位操作函数及宏控制 GPIO
/*控制 GPIOH 的引脚 10 输出高电平*/
GPIO_SetBits(GPIOH,GPIO_Pin_10);
/*控制 GPIOH 的引脚 10 输出低电平*/
GPIO_ResetBits(GPIOH,GPIO_Pin_10);
/*控制 GPIOH 的引脚 10、引脚 11 输出高电平,使用“|”,同时控制多个引脚*/
GPIO_SetBits(GPIOH,GPIO_Pin_10|GPIO_Pin_11);
/*控制 GPIOH 的引脚 10、引脚 11 输出低电平*/
GPIO_ResetBits(GPIOH,GPIO_Pin_10|GPIO_Pin_11);
/*控制 GPIOH 的所有输出低电平*/
GPIO_ResetBits(GPIOH,GPIO_Pin_ALL);
/*控制 GPIOA 的引脚 8 输出高电平*/
GPIO_SetBits(GPIOA,GPIO_Pin_8);
/*控制 GPIOB 的引脚 9 输出低电平*/
GPIO_ResetBits(GPIOB,GPIO_Pin_9);
使用以上代码控制 GPIO,我们就不需要再看寄存器了,直接从函数名和输入参数就可以直观看出这个语句要实现什么操作。 (英文中―Set‖表示“置位”,即高电平,“Reset”表示“复位”,即低电平)
定义位操作函数后,控制 GPIO 输出电平的代码得到了简化,但在控制 GPIO 输出电平前还需要初始化 GPIO 引脚的各种模式,这部分代码涉及的寄存器有很多,我们希望初始化 GPIO 也能以如此简单的方法去实现。为此,我们先根据 GPIO 初始化时涉及到的初始化参数以结构体的形式封装起来,声明一个名为 GPIO_InitTypeDef 的结构体类型,见代码清单 6-10。
代码清单 6-10 定义 GPIO 初始化结构体
typedef uint8_t unsigned char;
/**
* GPIO 初始化结构体类型定义
*/
typedef struct {
uint32_t GPIO_Pin; /*!< 选择要配置的 GPIO 引脚
可输入 GPIO_Pin_ 定义的宏 */
uint8_t GPIO_Mode; /*!< 选择 GPIO 引脚的工作模式
可输入二进制值: 00 、 01、 10、 11
表示输入/输出/复用/模拟 */
uint8_t GPIO_Speed; /*!< 选择 GPIO 引脚的速率
可输入二进制值: 00 、 01、 10、 11
表示 2/25/50/100MHz */
uint8_t GPIO_OType; /*!< 选择 GPIO 引脚输出类型
可输入二进制值: 0 、 1
表示推挽/开漏 */
uint8_t GPIO_PuPd; /*!<选择 GPIO 引脚的上/下拉模式
可输入二进制值: 00 、 01、 10
表示浮空/上拉/下拉*/
} GPIO_InitTypeDef;
这个结构体中包含了初始化 GPIO 所需要的信息,包括引脚号、工作模式、输出速率、输出类型以及上/下拉模式。设计这个结构体的思路是:初始化 GPIO 前,先定义一个这样的结构体变量,根据需要配置 GPIO 的模式,对这个结构体的各个成员进行赋值,然后把这个变量作为“GPIO 初始化函数”的输入参数,该函数能根据这个变量值中的内容去配置寄存器,从而实现初始化 GPIO。
上面定义的结构体很直接,美中不足的是在对结构体中各个成员赋值时还需要看具体哪个模式对应哪个数值,如 GPIO_Mode 成员的“输入/输出/复用/模拟”模式对应二进制值“00 、 01、 10、 11” ,我们不希望每次用到都要去查找这些索引值,所以使用 C 语言中的枚举语法定义这些参数,见代码清单 6-11。
代码清单 6-11 GPIO 配置参数的枚举定义
/**
* GPIO 端口配置模式的枚举定义
*/
typedef enum {
GPIO_Mode_IN = 0x00, /*!< 输入模式 */
GPIO_Mode_OUT = 0x01, /*!< 输出模式 */
GPIO_Mode_AF = 0x02, /*!< 复用模式 */
GPIO_Mode_AN = 0x03 /*!< 模拟模式 */
} GPIOMode_TypeDef;
/**
* GPIO 输出类型枚举定义
*/
typedef enum {
GPIO_OType_PP = 0x00, /*!< 推挽模式 */
GPIO_OType_OD = 0x01 /*!< 开漏模式 */
} GPIOOType_TypeDef;
/**
* GPIO 输出速率枚举定义
*/
typedef enum {
GPIO_Speed_2MHz = 0x00, /*!< 2MHz */
GPIO_Speed_25MHz = 0x01, /*!< 25MHz */
GPIO_Speed_50MHz = 0x02, /*!< 50MHz */
GPIO_Speed_100MHz = 0x03 /*!<100MHz */
} GPIOSpeed_TypeDef;
/**
*GPIO 上/下拉配置枚举定义
*/
typedef enum {
GPIO_PuPd_NOPULL = 0x00,/*浮空*/
GPIO_PuPd_UP = 0x01, /*上拉*/
GPIO_PuPd_DOWN = 0x02 /*下拉*/
} GPIOPuPd_TypeDef;
有了这些枚举定义,我们的 GPIO_InitTypeDef 结构体也可以使用枚举类型来限定输入了, 代码清单 6-13。
代码清单 6-12 使用枚举类型定义的 GPIO_InitTypeDef 结构体成员
/**
* GPIO 初始化结构体类型定义
*/
typedef struct {
uint32_t GPIO_Pin; /*!< 选择要配置的 GPIO 引脚
可输入 GPIO_Pin_ 定义的宏 */
GPIOMode_TypeDef GPIO_Mode; /*!< 选择 GPIO 引脚的工作模式
可输入 GPIOMode_TypeDef 定义的枚举值*/
GPIOSpeed_TypeDef GPIO_Speed; /*!< 选择 GPIO 引脚的速率
可输入 GPIOSpeed_TypeDef 定义的枚举值 */
GPIOOType_TypeDef GPIO_OType; /*!< 选择 GPIO 引脚输出类型
可输入 GPIOOType_TypeDef 定义的枚举值*/
GPIOPuPd_TypeDef GPIO_PuPd; /*!<选择 GPIO 引脚的上/下拉模式
可输入 GPIOPuPd_TypeDef 定义的枚举值*/
} GPIO_InitTypeDef;
如果不使用枚举类型,仍使用“uint8_t”类型来定义结构体成员,那么成员值的范围就是 0-255 了,而实际上这些成员都只能输入几个数值。所以使用枚举类型可以对结构体成员起到限定输入的作用,只能输入相应已定义的枚举值。
利用这些枚举定义,给 GPIO_InitTypeDef 结构体类型赋值配置就非常直观了,范例见代码清单 6-13
代码清单 6-13 给 GPIO_InitTypeDef 初始化结构体赋值范例
GPIO_InitTypeDef InitStruct;
/* LED 端口初始化 */
/*选择要控制的 GPIO 引脚*/
InitStruct.GPIO_Pin = GPIO_Pin_10;
/*设置引脚模式为输出模式*/
InitStruct.GPIO_Mode = GPIO_Mode_OUT;
/*设置引脚的输出类型为推挽输出*/
InitStruct.GPIO_OType = GPIO_OType_PP;
/*设置引脚为上拉模式*/
InitStruct.GPIO_PuPd = GPIO_PuPd_UP;
/*设置引脚速率为 2MHz */
InitStruct.GPIO_Speed = GPIO_Speed_2MHz;
接着前面的思路,对初始化结构体赋值后,把它输入到 GPIO 初始化函数,由它来实现寄存器配置。我们的 GPIO 初始化函数实现见代码清单 6-14,
代码清单 6-14 GPIO 初始化函数
/**
*函数功能:初始化引脚模式
*参数说明: GPIOx,该参数为 GPIO_TypeDef 类型的指针,指向 GPIO 端口的地址
* GPIO_InitTypeDef:GPIO_InitTypeDef 结构体指针,指向初始化变量
*/
void GPIO_Init(GPIO_TypeDef* GPIOx, GPIO_InitTypeDef* GPIO_InitStruct)
{
uint32_t pinpos = 0x00, pos = 0x00 , currentpin = 0x00;
/*-- GPIO Mode Configuration --*/
for (pinpos = 0x00; pinpos < 16; pinpos++) {
/*以下运算是为了通过 GPIO_InitStruct->GPIO_Pin 算出引脚号 0-15*/
/*经过运算后 pos 的 pinpos 位为 1,其余为 0,与 GPIO_Pin_x 宏对应。
pinpos 变量每次循环加 1, */
pos = ((uint32_t)0x01) << pinpos;
/* pos 与 GPIO_InitStruct->GPIO_Pin 做 & 运算,
若运算结果 currentpin == pos,
则表示 GPIO_InitStruct->GPIO_Pin 的 pinpos 位也为 1,
从而可知 pinpos 就是 GPIO_InitStruct->GPIO_Pin 对应的引脚号: 0-15*/
currentpin = (GPIO_InitStruct->GPIO_Pin) & pos;
/*currentpin == pos 时执行初始化*/
if (currentpin == pos) {
/*GPIOx 端口, MODER 寄存器的 GPIO_InitStruct->GPIO_Pin 对应的引脚,
MODER 位清空*/
GPIOx->MODER &= ~(3 << (2 *pinpos));
/*GPIOx 端口, MODER 寄存器的 GPIO_Pin 引脚,
MODER 位设置"输入/输出/复用输出/模拟"模式*/
GPIOx->MODER |= (((uint32_t)GPIO_InitStruct->GPIO_Mode) << (2 *pinpos));
/*GPIOx 端口, PUPDR 寄存器的 GPIO_Pin 引脚,
PUPDR 位清空*/
GPIOx->PUPDR &= ~(3 << ((2 *pinpos)));
/*GPIOx 端口, PUPDR 寄存器的 GPIO_Pin 引脚,
PUPDR 位设置"上/下拉"模式*/
GPIOx->PUPDR |= (((uint32_t)GPIO_InitStruct->GPIO_PuPd) << (2 *pinpos));
/*若模式为"输出/复用输出"模式,则设置速度与输出类型*/
if ((GPIO_InitStruct->GPIO_Mode == GPIO_Mode_OUT) ||
(GPIO_InitStruct->GPIO_Mode == GPIO_Mode_AF)) {
/*GPIOx 端口, OSPEEDR 寄存器的 GPIO_Pin 引脚,
OSPEEDR 位清空*/
GPIOx->OSPEEDR &= ~(3 << (2 *pinpos));
/*GPIOx 端口, OSPEEDR 寄存器的 GPIO_Pin 引脚,
OSPEEDR 位设置输出速度*/
GPIOx->OSPEEDR |= ((uint32_t)(GPIO_InitStruct->GPIO_Speed)<<(2 *pinpos));
/*GPIOx 端口, OTYPER 寄存器的 GPIO_Pin 引脚,
OTYPER 位清空*/
GPIOx->OTYPER &= ~(1 << (pinpos)) ;
/*GPIOx 端口, OTYPER 位寄存器的 GPIO_Pin 引脚,
OTYPER 位设置"推挽/开漏"输出类型*/
GPIOx->OTYPER |= (uint16_t)(( GPIO_InitStruct->GPIO_OType)<< (pinpos));
}
}
}
}
这个函数有 GPIOx 和 GPIO_InitStruct 两个输入参数,分别是 GPIO 外设指针和 GPIO初始化结构体指针。分别用来指定要初始化的 GPIO 端口及引脚的工作模式。
函数实现主要分两个环节:
(1) 利用 for 循环,根据 GPIO_InitStruct 的结构体成员 GPIO_Pin 计算出要初始化的引脚号。这段看起来复杂的运算实际上可以这样理解:它要通过宏“GPIO_Pin_x”的参数计算出 x 值(宏的参数值是第 x 数据位为 1,其余为 0,参考代码清单 8-8),计算得的引脚号结果存储在 pinpos 变量中。
(2) 得到引脚号 pinpos 后,利用初始化结构体各个成员的值,对相应寄存器进行配置,这部分与我们前面直接配置寄存器的操作是类似的,先对引脚号 pinpos 相应的配置位清空,后根据结构体成员对配置位赋值(GPIO_Mode 成员对应 MODER 寄存器的配置, GPIO_PuPd 成员对应 PUPDR 寄存器的配置等)。区别是这里的寄存器配置值及引脚号都是由变量存储的。
完成以上的准备后,我们就可以自己定义的函数来点亮 LED 灯了,见代码清单 6-15。
/*
使用寄存器的方法点亮 LED 灯
*/
#include "stm32f4xx_gpio.h"
void Delay( uint32_t nCount);
/**
* 主函数,使用封装好的函数来控制 LED 灯
*/
int main(void)
{
GPIO_InitTypeDef InitStruct;
/*开启 GPIOH 时钟,使用外设时都要先开启它的时钟*/
RCC->AHB1ENR |= (1<<7);
/* LED 端口初始化 */
/*初始化 PH10 引脚*/
/*选择要控制的 GPIO 引脚*/
InitStruct.GPIO_Pin = GPIO_Pin_10;
/*设置引脚模式为输出模式*/
InitStruct.GPIO_Mode = GPIO_Mode_OUT;
/*设置引脚的输出类型为推挽输出*/
InitStruct.GPIO_OType = GPIO_OType_PP;
/*设置引脚为上拉模式*/
InitStruct.GPIO_PuPd = GPIO_PuPd_UP;
/*设置引脚速率为 2MHz */
InitStruct.GPIO_Speed = GPIO_Speed_2MHz;
/*调用库函数,使用上面配置的 GPIO_InitStructure 初始化 GPIO*/
GPIO_Init(GPIOH, &InitStruct);
/*使引脚输出低电平,点亮 LED1*/
GPIO_ResetBits(GPIOH,GPIO_Pin_10);
/*延时一段时间*/
Delay(0xFFFFFF);
/*使引脚输出高电平,关闭 LED1*/
GPIO_SetBits(GPIOH,GPIO_Pin_10);
/*初始化 PH11 引脚*/
InitStruct.GPIO_Pin = GPIO_Pin_11;
GPIO_Init(GPIOH,&InitStruct);
/*使引脚输出低电平,点亮 LED2*/
GPIO_ResetBits(GPIOH,GPIO_Pin_11);
while (1);
}
//简单的延时函数,让 cpu 执行无意义指令,消耗时间
//具体延时时间难以计算,以后我们可使用定时器精确延时
void Delay( uint32_t nCount)
{
for (; nCount != 0; nCount--);
}
// 函数为空,目的是为了骗过编译器不报错
void SystemInit(void)
{
}
现在看起来,使用函数来控制 LED 灯与之前直接控制寄存器已经有了很大的区别:main 函数中先定义了一个初始化结构体变量 InitStruct,然后对该变量的各个成员按点亮LED 灯所需要的 GPIO 配置模式进行赋值,赋值后,调用 GPIO_Init 函数,让它根据结构体成员值对 GPIO 寄存器写入控制参数,完成 GPIO 引脚初始化。控制电平时,直接使用GPIO_SetBits 和 GPIO_Resetbits 函数控制输出。如若对其它引脚进行不同模式的初始化,只要修改初始化结构体 InitStruct 的成员值,把新的参数值输入到 GPIO_Init 函数再调用即可。
代码中新增的 Delay 函数,主要功能是延时,让我们可以看清楚实验现象(不延时的话指令执行太快,肉眼看不出来),它的实现原理是让 CPU 执行无意义的指令,消耗时间,在此不要纠结它的延时时间,写一个大概输入参数值,下载到实验板实测,觉得太久了就把参数值改小,短了就改大即可。需要精确延时的时候我们会用 STM32 的定时器外设进行精确延时的。
把编译好的程序下载到开发板并复位,可看到板子上的灯先亮红色(LED1),后亮绿色(LED2)。
什么是 ST 标准软件库?这就是。
我们从寄存器映像开始,把内存跟寄存器建立起一一对应的关系,然后操作寄存器点亮 LED,再把寄存器操作封装成一个个函数。一步一步走来,我们实现了库最简单的雏形,如果我们不断地增加操作外设的函数,并且把所有的外设都写完,一个完整的库就实现了。
本章中的 GPIO 相关库函数及结构体定义,实际上都是从 ST 标准库搬过来的。这样分析它纯粹是为了满足自己的求知欲,学习其编程的方式、思想,这对提高我们的编程水平是很有好处的,顺便感受一下 ST 库设计的严谨性,我认为这样的代码不仅严谨且华丽优美,不知您是否也有这样的感受。
与直接配置寄存器相比,从执行效率上看会有额外的消耗:初始化变量赋值的过程、库函数在被调用的时候要耗费调用时间;在函数内部,对输入参数转换所需要的额外运算也消耗一些时间(如 GPIO 中运算求出引脚号时)。而其它的宏、枚举等解释操作是作编译过程完成的,这部分并不消耗内核的时间。那么函数库的优点呢?是我们可以快速上手STM32 控制器;配置外设状态时,不需要再纠结要向寄存器写入什么数值;交流方便,查错简单。这就是我们选择库的原因。
现在的处理器的主频是越来越高,我们不需要担心 CPU 耗费那么多时间来干活会不会被累倒,库主要应用是在初始化过程,而初始化过程一般是芯片刚上电或在核心运算之前的执行的,这段时间的等待是 0.02us 还是 0.01us 在很多时候并没有什么区别。相对来说,我们还是担心一下如果都用寄存器操作,每行代码都要查《STM32F4xx 规格书》中的说明,自己会不会被累倒吧。
在以后开发的工程中,一般不会去分析 ST 的库函数的实现了。因为外设的库函数是很类似的,库外设都包含初始化结构体,以及特定的宏或枚举标识符,这些封装被库函数这些转化成相应的值,写入到寄存器之中,函数内部的具体实现是十分枯燥和机械的工作。如果您有兴趣,在您掌握了如何使用外设的库函数之后,可以查看一下它的源码实现。
通常我们只需要通过了解每种外设的“初始化结构体”就能够通过它去了解 STM32 的外设功能及控制了。