8. 自己写库—构建库函数雏形

本章参考资料:《STM32F4xx 中文参考手册》、《STM32F4xx英文数据手册》。

虽然我们上面用寄存器编程的方法点亮了 LED,乍看一下好像代码也很简单,但是我们别侥幸以后就可以一直用寄存器开发。 在用寄存器点亮 LED 的时候,我们会发现 STM32 的寄存器都是 32 位的,每次配置的时候都要对照着《STM32F4xx中文参考手册》 中寄存器的说明,然后根据说明对每个控制的寄存器位写入特定参数,因此在配置的时候非常容易出错,而且代码还很不好理解, 不便于维护。所以学习 STM32 最好的方法是用官方提供的函数库来开发,然后在函数库的基础上了解底层,学遍所有寄存器。

8.1. 什么是STM32函数库

ST官方为F4提供了3种函数库,即函数库、HAL库以及LL库,它们是由ST公司针对STM32提供的函数接口, 即API (Application Program Interface),开发者可调用这些函数接口来配置STM32的寄存器, 使开发人员得以脱离最底层的寄存器操作, 有开发快速,易于阅读,维护成本低等优点。在本册教材中我们主要针对HAL库进行编程。

当我们调用库API的时候不需要挖空心思去了解库底层的寄存器操作,就像当年我们刚开始学习C语言的时候,用prinft()函数时只是学习它的使用格式,并没有去研究它的源码实现,但需要深入研究的时候,经过千锤百炼的库源码就是最佳学习范例。

实际上,库是架设在寄存器与用户驱动层之间的代码, 向下处理与寄存器直接相关的配置, 向上为用户提供配置寄存器的接口。 库开发方式与直接配置寄存器方式的区别见 图8_1

图 8‑1 固件库开发与寄存器开发对比图

8.2. 为什么采用库来开发及学习?

在以前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芯片。

8.3. 实验:构建库函数雏形

虽然库的优点多多,但很多人对库还是很忌惮,因为一开始用库的时候有很多代码,很多文件,不知道如何入手。不知道您是否认同这么一句话:一切的恐惧都来源于无知。我们对库忌惮那是因为我们不知道什么是库,不知道库是怎么实现的。

接下来,我们在寄存器点亮 LED 的代码上继续完善,把代码一层层封装,实现库的最初的雏形,相信经过这一步的学习后,您对库的运用会游刃有余。这里我们只讲如何实现GPIO函数库,其他外设的我们直接参考STM32HAL库学习即可,不必自己写。

下面请打开本章配套例程“自己写库—构建库函数雏形”来阅读理解,该例程是在上一章的基础上修改得来的。

8.3.1. 修改寄存器地址封装

上一章中我们在操作寄存器的时候,操作的都是寄存器的绝对地址,如果每个外设寄存器都这样操作,那将非常麻烦。 我们考虑到外设寄存器的地址都是基于外设基地址的偏移地址,都是在外设基地址上逐个连续递增的,每个寄存器占 32 个字节, 这种方式跟结构体里面的成员类似。所以我们可以定义一种外设结构体,结构体的地址等于外设的基地址,结构体的成员等于寄存器, 成员的排列顺序跟寄存器的顺序一样。这样我们操作寄存器的时候就不用每次都找到绝对地址, 只要知道外设的基地址就可以操作外设的全部寄存器,即操作结构体的成员即可。

在工程中的“stm32f4xx.h”文件中,我们使用结构体封装GPIO及RCC外设的的寄存器,具体见 代码清单8_1。 结构体成员的顺序按照寄存器的偏移地址从低到高排列,成员类型跟寄存器类型一样。

代码清单 8‑1 封装寄存器列表
//寄存器的值常常是芯片外设自动更改的,即使CPU没有执行程序,也有可能发生变化
//编译器有可能会对没有执行程序的变量进行优化

//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;

/*定义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)

这段代码在每个结构体成员前增加了一个“__IO”前缀,它的原型在这段代码的开头,代表了C语言中的关键字“volatile”,在C语言中该关键字用于修饰易变的变量,要求编译器不要优化。这些结构体内的成员,都代表着寄存器,而寄存器很多时候是由外设或STM32芯片状态修改的,也就是说即使CPU不执行代码修改这些变量,变量的值也有可能被外设修改、更新,所以每次使用这些变量的时候,我们都要求CPU去该变量的地址重新访问。若没有这个关键字修饰,在某些情况下,编译器认为没有代码修改该变量,就直接从CPU的某个缓存获取该变量值,这时可以加快执行速度,但该缓存中的是陈旧数据,与我们要求的寄存器最新状态可能会有出入。

8.3.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)
/*定义RCC外设 寄存器结构体指针*/
#define RCC                 ((RCC_TypeDef *) RCC_BASE)

这些宏通过强制类型转换把外设的基地址转换成GPIO_TypeDef类型的指针,从而得到GPIOA、GPIOB等直接指向对应外设的指针,通过对结构体指针的操作,即可访问对应外设的寄存器。

利用这些指针访问寄存器,我们把main文件里对应的代码修改掉,具体见代码清单

/*
* 该章节代码是从 使用寄存器点亮LED灯章节代码修改过来
* 属于循序渐进的一章,每一个步骤都对应一部分代码,所以
* 该章节代码有好多个部分,不同部分都是使用条件编译来控制
* 最后只保留了最后的代码部分。
*
* 条件编译跟C语言的 if else 类似,只有当条件为真的情况下的
* 代码才会被编译,所以不要看到有多个main函数就以为不正确,
* 其实最终编译的只有一个
*/

#if 0
/* 使用寄存器点亮LED灯 章节代码
现在已经使用条件编译注释掉 */
int main(void)
{
 /*开启 GPIOF 时钟,使用外设时都要先开启它的时钟*/
 RCC_AHB1ENR |= (1<<5);

 /* LED 端口初始化 */

 /*GPIOF MODER6清空*/
 GPIOF_MODER  &= ~( 0x03<< (2*6));
 /*PF6 MODER6 = 01b 输出模式*/
 GPIOF_MODER |= (1<<2*6);

 /*GPIOF OTYPER6清空*/
 GPIOF_OTYPER &= ~(1<<1*6);
 /*PF6 OTYPER6 = 0b 推挽模式*/
 GPIOF_OTYPER |= (0<<1*6);

 /*GPIOF OSPEEDR6清空*/
 GPIOF_OSPEEDR &= ~(0x03<<2*6);
 /*PF6 OSPEEDR6 = 0b 速率2MHz*/
 GPIOF_OSPEEDR |= (0<<2*6);

 /*GPIOF PUPDR6清空*/
 GPIOF_PUPDR &= ~(0x03<<2*6);
 /*PF6 PUPDR6 = 01b 上拉模式*/
 GPIOF_PUPDR |= (1<<2*6);

 /*PF6 BSRR寄存器的 BR6置1,使引脚输出低电平*/
 GPIOF_BSRR |= (1<<16<<6);

 /*PF6 BSRR寄存器的 BS6置1,使引脚输出高电平*/
 //GPIOF_BSRR |= (1<<6);

 while (1);
}

// 函数为空,目的是为了骗过编译器不报错
void SystemInit(void)
{
}

#elif 1

/* 自己写库—构建库函数雏形 章节代码
现在已经使用条件编译注释掉 */
int main(void)
{
/*开启 GPIOF 时钟,使用外设时都要先开启它的时钟*/
RCC->AHB1ENR |= (1<<5);

/* LED 端口初始化 */

/*GPIOF MODER6清空*/
GPIOF->MODER  &= ~( 0x03<< (2*6));
/*PF6 MODER6 = 01b 输出模式*/
GPIOF->MODER |= (1<<2*6);

/*GPIOF OTYPER6清空*/
GPIOF->OTYPER &= ~(1<<1*6);
/*PF6 OTYPER6 = 0b 推挽模式*/
GPIOF->OTYPER |= (0<<1*6);

/*GPIOF OSPEEDR6清空*/
GPIOF->OSPEEDR &= ~(0x03<<2*6);
/*PF6 OSPEEDR6 = 0b 速率2MHz*/
GPIOF->OSPEEDR |= (0<<2*6);

/*GPIOF PUPDR6清空*/
GPIOF->PUPDR &= ~(0x03<<2*6);
/*PF6 PUPDR6 = 01b 上拉模式*/
GPIOF->PUPDR |= (1<<2*6);

/*PF6 BSRR寄存器的 BR6置1,使引脚输出低电平*/
GPIOF->BSRRH |= (1<<16<<6);

/*PF6 BSRR寄存器的 BS6置1,使引脚输出高电平*/
//GPIOF->BSRRL |= (1<<6);

while (1);
}

// 函数为空,目的是为了骗过编译器不报错
void SystemInit(void)
{
}
#endif

乍一看,除了把寄存器名字的“-”改成了“->”之外,其他都没有变,但就是这个小变化里面蕴藏这大变化,带“-”的操作是直接操作内存,需要对寄存器的地址一个个进行定义,带“->”的操作是使用外设结构体指针的方式来操作,这为我们继续编写库函数打下了基础。还有一个不同是我们把BSRR寄存器分成BSRRH和BSRRL两段,各16位,高16位控制复位,低16位控制置位,都是写1有效。

打好了地基,下面我们就来建高楼。接下来使用函数来封装GPIO的基本操作,方便以后应用的时候不需要再查询寄存器,而是直接通过调用这里定义的函数来实现。我们把针对GPIO外设操作的函数及其宏定义分别存放在“stm32h7xx_hal_gpio.c”和“stm32h7xx_hal_gpio.h”文件中。

8.3.3. 定义位操作函数

在“stm32f4xx_hal_gpio.c”文件定义两个位操作函数,分别用于控制引脚输出高电平和低电平, 见 代码清单8_3

代码清单 8‑3 GPIO置位函数与复位函数的定义
/**
* 函数功能:设置引脚电平
* 参数说明:GPIOx: 该参数为GPIO_TypeDef类型的指针,指向GPIO端口的地址
*       GPIO_Pin: 选择要设置的GPIO端口引脚,可输入宏GPIO_Pin_0-15,
*             表示GPIOx端口的0-15号引脚
*       PinState: 设置所选引脚的电平
*            @arg GPIO_PIN_RESET:设置低电平
*            @arg GPIO_PIN_SET: 设置高电平
* 返回值: 无
*/
void HAL_GPIO_WritePin( GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin, GPIO_PinState PinState)
{
 /*设置GPIOx端口BSRR寄存器的低16位对应第GPIO_Pin位,使其输出高电平*/
 /*设置GPIOx端口BSRR寄存器的高16位对应第GPIO_Pin位,使其输出低电平*/
 /*因为BSRR寄存器写0不影响,GPIO_Pin只是对应位为1,其它位均为0,所以可以直接赋值*/

 if (PinState != GPIO_PIN_RESET) {
     GPIOx->BSRRL = GPIO_Pin;
 } else {
     GPIOx->BSRRH = GPIO_Pin ;
 }
}

函数体内根据需要的电平,对GPIOx的BSRR寄存器低16位或者高16位赋值,从而设置引脚为高电平或低电平。其中GPIOx是一个指针变量, 通过函数的输入参数我们可以修改它的值,如给它赋予GPIOA、GPIOB、GPIOH等结构体指针值, 这个函数就可以控制相应的GPIOA、GPIOB、GPIOH等端口的输出。

对比我们前面对BSRR寄存器的赋值,都是用“|=”操作来防止对其它数据位产生干扰的,为何此函数里的操作却直接用“=”号赋值, 这样不怕干扰其它数据位吗?见 代码清单8_4

代码清单 8‑4 赋值方式对比
 /*使用 “|=” 来赋值*/
 GPIOF->BSRR |= (1<<16<<6);
 /*直接使用 "=" 号赋值*/
 GPIOF->BSRR = GPIO_Pin;

根据BSRR寄存器的特性,对它的数据位写“0”,是不会影响输出的,只有对它的数据位写“1”,才会控制引脚输出。 对低16位写“1”输出高电平,对高16位写“1”输出低电平。也就是说,假如我们对BSRR(高16位)直接用“=”操作赋二进制值“0000 0000 0000 0001 b”, 它会控制GPIO的引脚0输出低电平,赋二进制值“0000 0000 0001 0000 b”,它会控制GPIO引脚4输出低电平,而其它数据位由于是0, 所以不会受到干扰。同理,对BSRR (低16位)直接赋值也是如此,数据位为1的位输出高电平。代码清单8_5 中的两种方式赋值,功能相同。

代码清单 8‑5 BSRR寄存器赋值等效代码
 /*使用 “|=” 来赋值*/
 GPIOF->BSRR |= (uint16_t)(1<<6);
 /*直接使用“=” 来赋值,二进制数(0000 0000 1000 0000)*/
 GPIOF->BSRR =  (uint16_t)(1<<6);

这两行代码功能等效,都把BSRR的bit10设置为1,控制引脚10输出低电平,且其它引脚状态不变。但第二个语句操作效率是比较高的, 因为“|=”号包含了读写操作,而“=”号只需要一个写操作。因此在定义位操作函数中我们使用后者。

利用这两个位操作函数,就可以方便地操作各种GPIO的引脚电平了,控制各种端口引脚的范例见 代码清单8_6

代码清单 8‑6 位操作函数使用范例
/*控制GPIOF的引脚6输出高电平*/
HAL_GPIO_WritePin(GPIOF,(uint16_t)(1<<6), GPIO_PIN_SET);
/*控制GPIOF的引脚6输出低电平*/
HAL_GPIO_WritePin(GPIOF,(uint16_t)(1<<6), GPIO_PIN_RESET);

/*控制GPIOF的引脚7、引脚8输出高电平,使用“|”同时控制多个引脚*/
HAL_GPIO_WritePin(GPIOF,(uint16_t)(1<<7)| (uint16_t)(1<<8), GPIO_PIN_SET);
/*控制GPIOF的引脚7、引脚8输出低电平*/
HAL_GPIO_WritePin(GPIOF,(uint16_t)(1<<7)| (uint16_t)(1<<8), GPIO_PIN_RESET);

/*控制GPIOF的引脚7输出高电平*/
HAL_GPIO_WritePin(GPIOF,(uint16_t)(1<<7), GPIO_PIN_SET);
/*控制GPIOF的引脚8输出低电平*/
HAL_GPIO_WritePin(GPIOF,(uint16_t)(1<<8), GPIO_PIN_RESET);

使用以上函数输入参数,设置引脚号时,还是很不方便,且可读性差,为此我们把表示16个引脚号的操作数都定义成宏,具体见 代码清单a

/*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的控制代码可改为代码清单

/*控制GPIOF的引脚6输出高电平*/
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_6, GPIO_PIN_SET);
/*控制GPIOF的引脚6输出低电平*/
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_6, GPIO_PIN_RESET);

/*控制GPIOF的引脚7、引脚8输出高电平,使用“|”,同时控制多个引脚*/
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_7| GPIO_PIN_8, GPIO_PIN_SET);
/*控制GPIOF的引脚7、引脚8输出低电平*/
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_7| GPIO_PIN_8, GPIO_PIN_RESET);
/*控制GPIOF的所有输出低电平*/
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_All, GPIO_PIN_RESET);

/*控制GPIOA的引脚8输出高电平*/
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_8, GPIO_PIN_SET);
/*控制GPIOB的引脚9输出低电平*/
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_9, GPIO_PIN_RESET);

使用以上代码控制GPIO,我们就不需要再看寄存器了,直接从函数名和输入参数就可以直观看出这个语句要实现什么操作(英文中“Set”表示“置位”,即高电平,“Reset”表示“复位”,即低电平)。

8.3.4. 定义初始化结构体GPIO_InitTypeDef

定义位操作函数后,控制GPIO输出电平的代码得到了简化,但在控制GPIO输出电平前还需要初始化GPIO引脚的各种模式, 这部分代码涉及的寄存器有很多,我们希望初始化GPIO也能以如此简单的方法去实现。 为此,我们先根据GPIO初始化时涉及到的初始化参数以结构体的形式封装起来,声明一个名为GPIO_InitTypeDef的结构体类型,见 代码8_3

代码 8‑3 定义GPIO初始化结构体
/**
* @brief GPIO初始化结构体类型定义
*/
typedef struct {
 uint32_t Pin;       /*指定要配置的GPIO引脚 */

 uint32_t Mode;      /*指定所选引脚的工作模式*/

 uint32_t Pull;      /*指定所选引脚的上拉或下拉激活 */

 uint32_t Speed;     /*指定所选引脚的速度 */

 uint32_t Alternate;  /*要连接到所选引脚的外设*/
} GPIO_InitTypeDef;

这个结构体中包含了初始化GPIO所需要的信息,包括引脚号、工作模式、输出速率、输出类型以及上/下拉模式。设计这个结构体的思路是:初始化GPIO前,先定义一个这样的结构体变量,根据需要配置GPIO的模式,对这个结构体的各个成员进行赋值,然后把这个变量作为“GPIO初始化函数”的输入参数,该函数能根据这个变量值中的内容去配置寄存器,从而实现GPIO的初始化。

8.3.5. 定义引脚模式的枚举类型

上面定义的结构体很直接,美中不足的是在对结构体中各个成员赋值时还需要看具体哪个模式对应哪个数值, 如GPIO_Mode成员的“输入/输出/复用/模拟”模式对应二进制值“00 、01、 10、 11”, 我们不希望每次用到都要去查找这些索引值,所以使用C语言中的枚举语法定义这些参数,具体见 代码8_4

代码 8‑4 GPIO配置参数的枚举定义
#define  GPIO_MODE_INPUT       ((uint32_t)0x00000000U)   /*!< 浮空输入*/
#define  GPIO_MODE_OUTPUT_PP   ((uint32_t)0x00000001U)   /*!< 推挽输出 */
#define  GPIO_MODE_OUTPUT_OD   ((uint32_t)0x00000011U)   /*!< 开漏输出 */
#define  GPIO_MODE_AF_PP    ((uint32_t)0x00000002U)   /*!< 推挽复用输出*/
#define  GPIO_MODE_AF_OD   ((uint32_t)0x00000012U)   /*!< 开漏复用输出*/

#define  GPIO_MODE_ANALOG     ((uint32_t)0x00000003U)   /*!< 模拟模式*/

#define  GPIO_SPEED_FREQ_LOW         ((uint32_t)0x00000000U)  /*!< 低速*/
#define  GPIO_SPEED_FREQ_MEDIUM      ((uint32_t)0x00000001U)  /*!< 中速*/
#define  GPIO_SPEED_FREQ_HIGH        ((uint32_t)0x00000002U)  /*!< 快速*/
#define  GPIO_SPEED_FREQ_VERY_HIGH   ((uint32_t)0x00000003U)  /*!< 高速*/

#define  GPIO_NOPULL        ((uint32_t)0x00000000U)   /*!< 无上下拉  */
#define  GPIO_PULLUP        ((uint32_t)0x00000001U)   /*!< 上拉   */
#define  GPIO_PULLDOWN      ((uint32_t)0x00000002U)   /*!< 下拉   */

有了这些枚举定义,我们的GPIO_InitTypeDef结构体也可以使用枚举类型来限定输入了,具体见代码

代码 8‑4 使用枚举类型定义的GPIO_InitTypeDef结构体成员
/**
* @brief GPIO初始化结构体类型定义
*/
typedef struct {
 uint32_t Pin;       /*指定要配置的GPIO引脚 */

 uint32_t Mode;      /*指定所选引脚的工作模式*/

 uint32_t Pull;      /*指定所选引脚的上拉或下拉激活 */

 uint32_t Speed;     /*指定所选引脚的速度 */

 uint32_t Alternate;  /*要连接到所选引脚的外设*/
} GPIO_InitTypeDef;

如果不使用枚举类型,仍使用“uint8_t”类型来定义结构体成员,那么成员值的范围就是0-255了,而实际上这些成员都只能输入几个数值。所以使用枚举类型可以对结构体成员起到限定输入的作用,只能输入相应已定义的枚举值。

利用这些枚举定义,给GPIO_InitTypeDef结构体类型赋值配置就非常直观了,具体见代码

代码 8‑4 给GPIO_InitTypeDef初始化结构体赋值范例
GPIO_InitTypeDef InitStruct;

/* LED 端口初始化 */

/*初始化PF6引脚*/
/*选择要控制的GPIO引脚*/
InitStruct.Pin = GPIO_PIN_6;
/*设置引脚的输出类型为推挽输出*/
InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
/*设置引脚为上拉模式*/
InitStruct.Pull = GPIO_PULLUP;
/*设置引脚速率为低速模式 */
InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
/*调用库函数,使用上面配置的GPIO_InitStructure初始化GPIO*/
HAL_GPIO_Init(GPIOF, &InitStruct);

8.3.6. 定义GPIO初始化函数

接着前面的思路,对初始化结构体赋值后,把它输入到GPIO初始化函数,由它来实现寄存器配置。我们的GPIO初始化函数实现具体见 代码8_5

代码 8‑5 GPIO初始化函数
/**
*函数功能:初始化引脚模式
*参数说明:GPIOx,该参数为GPIO_TypeDef类型的指针,指向GPIO端口的地址
*         GPIO_InitTypeDef:GPIO_InitTypeDef结构体指针,指向初始化变量
*/
void HAL_GPIO_Init(GPIO_TypeDef  *GPIOx, GPIO_InitTypeDef *GPIO_Init)
{
 uint32_t position = 0x00;
 uint32_t ioposition = 0x00;
 uint32_t iocurrent = 0x00;
 uint32_t temp = 0x00;

 /* Configure the port pins */
 for (position = 0; position < 16; position++) {
 /*以下运算是为了通过 GPIO_InitStruct->GPIO_Pin 算出引脚号0-15*/
 /*经过运算后pos的pinpos位为1,其余为0,与GPIO_Pin_x宏对应。pinpos变量每次循环加1,*/
 ioposition = ((uint32_t)0x01) << position;
 /* pos与GPIO_InitStruct->Pin做 & 运算,若运算结果currentpin == pos,
 则表示GPIO_InitStruct->Pin的pinpos位也为1,
 从而可知pinpos就是GPIO_InitStruct->Pin对应的引脚号:0-15*/
 iocurrent = (uint32_t)(GPIO_Init->Pin) & ioposition;

 if (iocurrent == ioposition) {
 /*------------- GPIO Mode Configuration -----------------*/
 /* 在复用功能模式选择的情况下 */
 if ((GPIO_Init->Mode == GPIO_MODE_AF_PP) || (GPIO_Init->Mode == GPIO_MODE_AF_OD)) {
 /* 配置与当前IO映射的备用功能 */
 temp = GPIOx->AFR[position >> 3];
 temp &= ~((uint32_t)0xF << ((uint32_t)(position & (uint32_t)0x07) * 4)) ;
 temp |= ((uint32_t)(GPIO_Init->Alternate) << (((uint32_t)position & (uint32_t)0x07) * 4));
 GPIOx->AFR[position >> 3] = temp;
}

/* 配置IO方向模式(输入,输出,复用或模拟) */
temp = GPIOx->MODER;
temp &= ~(GPIO_MODER_MODER0 << (position * 2));
temp |= ((GPIO_Init->Mode & GPIO_MODE) << (position * 2));
GPIOx->MODER = temp;

/* 在输出或复用功能模式选择的情况下 */
if ((GPIO_Init->Mode == GPIO_MODE_OUTPUT_PP) || (GPIO_Init->Mode == GPIO_MODE_AF_PP) ||
(GPIO_Init->Mode == GPIO_MODE_OUTPUT_OD) || (GPIO_Init->Mode == GPIO_MODE_AF_OD)) {

 /* 配置速度参数 */
 temp = GPIOx->OSPEEDR;
 temp &= ~(GPIO_OSPEEDER_OSPEEDR0 << (position * 2));
 temp |= (GPIO_Init->Speed << (position * 2));
 GPIOx->OSPEEDR = temp;

 /* 配置IO输出类型 */
 temp = GPIOx->OTYPER;
 temp &= ~(GPIO_OTYPER_OT_0 << position) ;
 temp |= (((GPIO_Init->Mode & GPIO_OUTPUT_TYPE) >> 4) << position);
 GPIOx->OTYPER = temp;
}

 /* 激活当前IO的上拉或下拉电阻 */
 temp = GPIOx->PUPDR;
 temp &= ~(GPIO_PUPDR_PUPDR0 << (position * 2));
 temp |= ((GPIO_Init->Pull) << (position * 2));
 GPIOx->PUPDR = temp;
}
}
}

这个函数有GPIOx和GPIO_InitStruct两个输入参数,分别是GPIO外设指针和GPIO初始化结构体指针。分别用来指定要初始化的GPIO端口及引脚的工作模式。

函数实现主要分两个环节:

  1. 利用for循环,根据GPIO_InitStruct的结构体成员GPIO_PIN计算出要初始化的引脚号。这段看起来复杂的运算实际上可以这样理解: 它要通过宏“GPIO_PIN_x”的参数计算出x值(宏的参数值是第x数据位为1,其余为0,参考 代码清单a), 计算得的引脚号结果存储在pinpos变量中。

  2. 得到引脚号pinpos后,利用初始化结构体各个成员的值,对相应寄存器进行配置,这部分与我们前面直接配置寄存器的操作是类似的, 先对引脚号pinpos相应的配置位清空,后根据结构体成员对配置位赋值(GPIO_Mode成员对应MODER寄存器的配置, GPIO_PuPd成员对应PUPDR寄存器的配置等)。区别是这里的寄存器配置值及引脚号都是由变量存储的。

8.3.7. 全新面貌,使用函数点亮LED灯

完成以上的准备后,我们就可以用自己定义的函数来点亮LED灯,见 代码8_7 使用函数点亮LED

代码8_7 使用函数点亮LED
 void Delay( uint32_t nCount);
 /**
   *   主函数
   */
 int main(void)
 {
    GPIO_InitTypeDef InitStruct;

    /*开启 GPIOHF时钟,使用外设时都要先开启它的时钟*/
     RCC->AHB1ENR |= (1<<5);

     /* LED 端口初始化 */

     /*初始化PH6引脚*/
     /*选择要控制的GPIO引脚*/
     InitStruct.Pin = GPIO_PIN_6;
     /*设置引脚的输出类型为推挽输出*/
     InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
     /*设置引脚为上拉模式*/
     InitStruct.Pull = GPIO_PULLUP;
     /*设置引脚速率为低速模式 */
     InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
     /*调用库函数,使用上面配置的GPIO_InitStructure初始化GPIO*/
     HAL_GPIO_Init(GPIOF, &InitStruct);

     /*使引脚输出低电平,点亮LED1*/
     HAL_GPIO_WritePin(GPIOF,GPIO_PIN_6,GPIO_PIN_RESET);

     /*延时一段时间*/
     Delay(0xFFFFFF);

     /*使引脚输出高电平,关闭LED1*/
     HAL_GPIO_WritePin(GPIOF,GPIO_PIN_6,GPIO_PIN_SET);

     /*初始化PH11引脚*/
     InitStruct.Pin = GPIO_PIN_7;
     HAL_GPIO_Init(GPIOF,&InitStruct);

     /*使引脚输出低电平,点亮LED2*/
     HAL_GPIO_WritePin(GPIOF,GPIO_PIN_7,GPIO_PIN_RESET);

     while (1);

 }

 //简单的延时函数,让cpu执行无意义指令,消耗时间
 //具体延时时间难以计算,以后我们可使用定时器精确延时
 void Delay( uint32_t nCount)
 {
     for (; nCount != 0; nCount--);
 }
 // 函数为空,目的是为了骗过编译器不报错
 void SystemInit(void)
 {
 }

现在看起来,使用函数来控制LED灯与之前直接控制寄存器已经有了很大的区别:main函数中先定义了一个初始化结构体变量InitStruct,然后对该变量的各个成员按点亮LED灯所需要的GPIO配置模式进行赋值,赋值后,调用HAL_GPIO_Init函数,让它根据结构体成员值对GPIO寄存器写入控制参数,完成GPIO引脚初始化。控制电平时,直接使用HAL_GPIO_WritePin函数控制输出。如若对其它引脚进行不同模式的初始化,只要修改初始化结构体InitStruct的成员值,把新的参数值输入到HAL_GPIO_Init函数再调用即可。

代码中新增的Delay函数,主要功能是延时,让我们可以看清楚实验现象(不延时的话指令执行太快,肉眼看不出来),它的实现原理是让CPU执行无意义的指令,消耗时间,在此不要纠结它的延时时间,写一个大概输入参数值,下载到实验板实测,觉得太久了就把参数值改小,短了就改大即可。需要精确延时的时候我们会用STM32的定时器外设进行精确延时的。

8.3.8. 下载验证

把编译好的程序下载到开发板并复位,可看到板子上的灯先亮红色(LED1),后亮绿色(LED2)。

8.3.9. 总结

什么是ST函数库?我们上面写的就是,相对于官方的完整版的函数库,我们写的只是一个雏形,写这个函数库的雏形目的是为了帮助我们从寄存器编程顺利地过度到到函数库编程的,让我们知道什么是函数库,为以后能够熟练的使用函数库编程打下基础。

我们从寄存器映射开始,把内存跟寄存器建立起一一对应的关系,然后操作寄存器点亮 LED,再把寄存器操作封装成一个个函数。 一步一步走来,我们实现了库最简单的雏形,如果我们不断地增加操作外设的函数,并且把所有的外设都写完,一个完整的库就实现了。

本章中的GPIO相关库函数及结构体定义,实际上都是从ST的HAL库搬过来的。这样分析它纯粹是为了满足自己的求知欲,学习其编程的方式、思想,这对提高我们的编程水平是很有好处的,顺便感受一下ST库设计的严谨性,我认为这样的代码不仅严谨且华丽优美,不知您是否也有这样的感受。

与直接配置寄存器相比,从执行效率上看会有额外的消耗:初始化变量赋值的过程、库函数在被调用的时候要耗费调用时间;在函数内部,对输入参数转换所需要的额外运算也消耗一些时间(如GPIO中运算求出引脚号时)。而其它的宏、枚举等解释操作是作编译过程完成的,这部分并不消耗内核的时间。那么函数库的优点呢?是我们可以快速上手STM32控制器;配置外设状态时,不需要再纠结要向寄存器写入什么数值;交流方便,查错简单。这就是我们选择库的原因。

现在的处理器的主频是越来越高,我们不需要担心CPU耗费那么多时间来干活会不会被累倒,库主要应用是在初始化过程,而初始化过程一般是芯片刚上电或在核心运算之前的执行的,这段时间的等待是0.02us还是0.01us在很多时候并没有什么区别。相对来说,我们还是担心一下如果都用寄存器操作,每行代码都要查《STM32F4xx规格书》中的说明,自己会不会被累倒吧。

在以后开发的工程中,一般不会去分析ST的库函数的实现了。因为外设的库函数是很类似的,库外设都包含初始化结构体,以及特定的宏或枚举标识符,这些封装被库函数这些转化成相应的值,写入到寄存器之中,函数内部的具体实现是十分枯燥和机械的工作。如果您有兴趣,在您掌握了如何使用外设的库函数之后,可以查看一下它的源码实现。

通常我们只需要通过了解每种外设的“初始化结构体”就能够通过它去了解STM32的外设功能及控制了。