本章在上一节的基础上,介绍如何创建库函数,实现点亮LED灯的MDK工程。 我们上一章用寄存器点亮了LED,代码好像没有几行,看着也很简单,但是我们需要明白,我们点亮LED这个案例功能非常简单,只用了STM32功能的九牛一毛。在用寄存器点亮LED的时候,每次配置写代码的时候都要对照着《STM32F10X-中文参考手册》中寄存器的说明,然后根据说明对每个控制的寄存器位写入特定参数,因此在配置的时候非常容易出错,而且代码可读性不强不好理解,难于维护。所以学习STM32最好的方法是用固件库,然后在固件库的基础上了解底层,学习寄存器。懂得原理后,我们开发自然是用已有的固件库去开发效率最高,也便于维护。
11.1 基于库函数的开发方式
这个问题我们前面第8章已经进行过了介绍,这里再简单提一下。固件库是指“STM32标准函数库”,它是由ST公司针对STM32提供的函数接口,即API(Application Program Interface),开发者可调用这些函数接口来配置STM32的寄存器,使开发人员得以脱离最底层的寄存器操作,有开发快速,易于阅读,维护成本低等优点。当我们调用库API的时候不需要挖空心思去了解库底层的寄存器操作,就像当年我们编程的时候调用某个函数,我们会用就行,并不需要去研究它的源码实现。
简单来讲库就是架设在寄存器与用户驱动层之间的代码,向下处理与寄存器直接相关的配置,向上为用户提供配置寄存器的接口。库开发方式与直接配置寄存器方式的区别见图11.1-1。
图11.1-1 固件库开发与寄存器开发对比
相对于库开发的方式,直接配置寄存器方式生成的代码量的确会少一点,但因为STM32 有充足的资源,权衡库的优势与不足,绝大部分时候,我们愿意牺牲一点CPU 资源,选择库开发。一般只有在对代码运行时间要求极苛刻的地方,才用直接配置寄存器的方式代替,对于库开发与直接配置寄存器的方式,就好比编程是用汇编好还是用C 好一样。
那么对于STM32的学习哪种方式好呢?有人认为用寄存器好。事实上,库函数的底层实现正是直接配置寄存器的最好例子,它代替我们完成了寄存器配置的工作,而想深入了解芯片是如何工作的,我们只要直接查看库函数的最底层实现就能理解。等我们读懂了库函数的实现方式,一定会为它的严谨和优美的实现方式而倾倒,也是我们学习C语言的极好教材,ST的库实现方式堪称教科书级别的上好资料。所以基于ST库的学习,我们既能学会用寄存器控制STM32,还能学到库函数的封装技巧。
11.2 构建自己的库函数
构建自己的库函数,其实就是把我们上一节中,寄存器地址计算和一些位操作封装起来到一个.c文件或者头文件中。然后用的时候直接调用即可。
如图11.2-1,我们和上节一样的方式创建一个MDK工程,命名为LED_LibVersionTest.在文件夹中新建一个stm32f10x.h的空文件,并添加到Startup组里(或者从startup里右键创建也可以),这个文件用于我们后面编写库函数。如图11.2-2.
图11.2-1 新建库函数的MDK工程
11.2-2 新建自建库函数版MDK工程文件夹
后面我们在上节寄存器点亮LED 的代码上继续完善,把代码一层层封装,实现库的最初的雏形,经过这一步的学习后,我们对库的理解和运用会更加深入。本节主要是实现GPIO的函数库,其他外设大同小异,我们直接参考ST标准库即可,不必自己写,懂得原理就够了。
下面的代码都是标准库里的,我们只是摘出来,了解库的建立过程。举一反三,道理都是一样的。
11.2.1 头文件的常见操作
在开始后面内容之前我们先讲一个C编程的常见知识点。假如我们编写了一个.c文件,文件中的变量或者函数,是可能被其他文件调用的,我们一般会相应创建一个同名的.h文件,用以对这个.c文件的声明。例如我们创建了一个head.c文件,对应的我们要新建一个head.h文件。而在head.h文件里,开头的语句一般都是固定的防重复包含的预处理指令#ifndef,#define,#endif语句。如下代码所示:
在C语言(以及C++)中,使用#ifndef、#define和#endif预处理指令来防止头文件被多次包含(也称为“包含守卫”或“头文件保护”)是一种常见的做法。这样做的目的是避免在编译时因多次包含同一个头文件而导致的重复定义错误。
具体来说,#ifndef __HEAD_H检查是否已定义了名为__HEAD_H的宏。如果没有定义(即这是第一次包含该头文件),则编译器会执行#define __HEAD_H,定义这个宏,并继续处理头文件中的其余内容。如果__HEAD_H已经被定义(即这不是第一次包含该头文件),则编译器会跳过头文件中的其余内容,从而避免了重复定义。
注意:宏名(如__HEAD_H)通常是大写的,并且包含双下划线前缀和后缀,以避免与程序中的其他标识符冲突。
11.2.2 外设寄存器结构体定义
上一章我们在操作寄存器的时候,是查到寄存器的绝对地址后,挨个进行配置,如果每个外设寄存器都这样操作,那就太麻烦了。从前面第5章,我们知道外设寄存器的地址都是基于外设基地址加偏移地址,都是在外设基地址上逐个连续递增的,每个寄存器占32 个字节,这种方式跟结构体里面的成员类似。因此我们可以定义一种外设结构体,结构体的地址等于外设的基地址,结构体的成员等于寄存器,成员的排列顺序跟寄存器的顺序一样。这样我们操作寄存器的时候就不用每次都找到绝对地址,只要知道外设的基地址就可以操作外设的全部寄存器,即操作结构体的成员即可。
在工程中的“stm32f10x.h”文件中,我们使用结构体封装GPIO 及RCC 外设的的寄存器,代码如下。结构体成员的顺序按照寄存器的偏移地址从低到高排列,成员类型跟寄存器类型一样。
typedef unsigned int uint32_t; typedef unsigned short uint16_t;
|
图11.2-3 寄存器结构体定义
代码中结构体成员前增加了前缀“__IO”,代码的第一行#define__IO volatile,指定了C语言中的关键字“volatile”,含义是要求编译器不要优化,这个在前面《第2章.STM32开发C语言常用知识点》已有介绍。
11.2.3 外设存储器映射
外设寄存器结构体定义之后,下一步就是把寄存器地址跟结构体的地址对应起来。映射的方法在上一节以及《第5章.STM32F1x的寄存器和存储器》里已经有提及。这块代码如下:
#define PERIPH_BASE ((unsigned int)0x40000000) #define APB2PERIPH_BASE (PERIPH_BASE + 0x10000) #define AHBPERIPH_BASE (PERIPH_BASE + 0x20000) #define GPIOA_BASE (APB2PERIPH_BASE + 0x0800) #define GPIOB_BASE (APB2PERIPH_BASE + 0x0C00) #define GPIOC_BASE (APB2PERIPH_BASE + 0x1000) #define GPIOD_BASE (APB2PERIPH_BASE + 0x1400) #define GPIOE_BASE (APB2PERIPH_BASE + 0x1800) #define GPIOF_BASE (APB2PERIPH_BASE + 0x1C00) #define GPIOG_BASE (APB2PERIPH_BASE + 0x2000) #define RCC_BASE (AHBPERIPH_BASE + 0x1000)
|
11.2.4 外设声明
实现完外设存储器映射后,我们再把外设的基地址进行强制类型转换,转换为我们前面定义的外设寄存器结构体指针类型,然后再把该指针声明成外设名,外设名(即寄存器结构体指针)就跟外设的地址对应起来了,通过该外设名可以直接操作该外设的全部寄存器,代码如下:
#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 RCC ((RCC_TypeDef *) RCC_BASE) #define RCC_APB2ENR *(unsigned int*)(RCC_BASE+0x18)
|
下面开始,我们就对上节main.c函数中出现的操作函数,一一进行函数定义,再写main.c的时候,就可以直接调用。
11.2.5 GPIO的位操作函数
现在我们在组“Startup”里再新建2个文件,分别是stm32f10x_gpio.c和stm32f10x_gpio.h。操作方法如下图11.2-4.
图 11.2-4 新建.c和.h文件
把上节Main函数中对GPIO外设操作的函数及其宏定义分别存放在stm32f10x_gpio.c和stm32f10x_gpio.h文件中。可以理解为.c文件是用来描述函数的具体的实现方式,.h文件是对这些.c里定义的函数或变量的全局声明。也就是这2个文件都是和GPIO相关的。
在上一节我们把PB0设置为0的时候,是通过把GPIO的ODR寄存器对应端口直接写入值实现,我们也可以通过BSRR和BRR寄存器对相应位进行置位或清除操作。
11.2.5.1 位设置函数
图11.2-5 STM32F10X-中文参考手册中位设置/清除寄存器BSRR说明
如上图是BSRR端口设置/清除寄存器的说明, 我们如果要设置PB0为1,只需要设置BSRR寄存器的0位为1即可,即:
如果是设置第二位为1就是, GPIOB->BSRR |= 0x0002;第三位就是GPIOB->BSRR |= 0x0004;我们这里会发现一个问题,就是0x0002等不够形象,我们如果用宏定义,用对应的Pin名称来代替就会好很多,于是我们可以这么操作,在stm32f10x_gpio.h对各pin做如下宏定义:
#define GPIO_Pin_0 ((uint16_t)0x0001) #define GPIO_Pin_1 ((uint16_t)0x0002) #define GPIO_Pin_2 ((uint16_t)0x0004) #define GPIO_Pin_3 ((uint16_t)0x0008) #define GPIO_Pin_4 ((uint16_t)0x0010) #define GPIO_Pin_5 ((uint16_t)0x0020) #define GPIO_Pin_6 ((uint16_t)0x0040) #define GPIO_Pin_7 ((uint16_t)0x0080) #define GPIO_Pin_8 ((uint16_t)0x0100) #define GPIO_Pin_9 ((uint16_t)0x0200) #define GPIO_Pin_10 ((uint16_t)0x0400) #define GPIO_Pin_11 ((uint16_t)0x0800) #define GPIO_Pin_12 ((uint16_t)0x1000) #define GPIO_Pin_13 ((uint16_t)0x2000) #define GPIO_Pin_14 ((uint16_t)0x4000) #define GPIO_Pin_15 ((uint16_t)0x8000)
|
11.2.5.2 位清除函数
位清除函数和位设置函数的操作方式一样,只是需要操作BRR寄存器,如图11.2-6. 这里不再赘述。
图11.2-6 STM32F10X-中文参考手册中位清除寄存器BRR说明
在stm32f10x_gpio. c中定义位设置函数GPIO_ResetBits如下:
void GPIO_ResetBits( GPIO_TypeDef *GPIOx,uint16_t GPIO_Pin )
|
11.2.6 定义GPIO初始化函数
上一节我们知道,除了位操作,还有GPIO工作模式以及速度等的设置。下面我们开始这一部分的功能设计。设计的核心思想其实就是用“名称”去替代那些难以记忆的数字,把一切操作尽量都做到“名称化”,只要看到名称就知道是什么意思,提高代码的可读性和可操作性,不用再每写一个功能就去不停地翻看参考手册。
那么根据前面一节main.c中这部分的代码,需要“名称化”的内容主要有:GPIO引脚,GPIO速度,GPIO工作模式,以及GPIO的初始化函数。GPIO引脚前面已经实现了,这里就不讲了。
11.2.6.1 GPIO初始化结构体
为方便后续的GPIO初始化,我们有必要声明一个名为GPIO_InitTypeDef的结构体类型。 我们在头文件stm32f10x_gpio.h中进行如下定义:
定义这个结构体之后,我们以后在初始化某个GPIO前,就可以先定义一个这样的结构体变量,根据需要配置的GPIO模式,对这个结构体的成员进行赋值,最后再把这个变量作为“GPIO初始化函数”的输入参数,该函数能根据这个输入参数值中的内容去配置相应寄存器,从而实现了GPIO的初始化操作。
但是我们上述定义的结构体类型,速率和模式仍使用“uint16_t”类型,那么成员值还得输入数字,赋值时还需要查询参考手册的寄存器说明。而实际上像速度和模式只能输入几个固定的数值。我们如何解决这个问题呢,让代码看上去既形象又不易出错?答案就是使用枚举类型。枚举类型可以对结构体成员起到限定输入的作用,只能输入相应已定义的枚举值,而且比较形象,见名知意。
下面我们就对GPIO的速率和工作模式进行枚举类型定义。
11.2.6.2 定义引脚模式的枚举类型
在上一节中,我们知道GPIO的PB0的工作模式和速率是在CRL寄存器配置的,CRL控制GPIOB的低8位,CRH控制高8位,因为我们用的是0位,这里方便起见,我们就只配置CRL。
图.11.2-7 CRL寄存器配置图
GPIO_Speed枚举类型:
由上图11.2-7可见,GPIO_Speed主要有10MHZ,2MHZ,50MHZ三个值,分别对应二进制数0b01,0b10,0b11,对应十进制的1,2,3.那么定义枚举类型就非常简单了,我们在 stm32f10x_gpio.h中做如下定义:
如上代码中,枚举类型的定义中,第一个给出数字后,后面的如果是比前面得都大1,那么后面的枚举定义可以不用再写“=多少” ,当然写上也是无所谓的。如果不是这种后面比前面大1的关系,就必须每个都进行赋值。
GPIO_Mode枚举类型:
工作模式的枚举类型定义就比较难理解一些,我们先看代码,代码我们也是直接参考标准库。
GPIO_Mode_IN_FLOATING = 0x04,
|
单纯从定义的这些数值,我们很难发现什么规律,可以说之所以这么定义,完全是人为的,在便于理解的前提下通过后续我们编写的函数实现引脚的初始化配置。也就是根据我们人为指定的这个枚举类型,进行工作模式的配置。在引脚的初始化中引脚工作模式和速率是都要指定和配置的,这2个要结合起来看。为了便于理解,整理如下图11.2-8,转化成二进制之后,就比较容易发现规律。
图11.2-8 GPIO 引脚工作模式真值表
这个表里的高4位是人为定义的,可以根据个人习惯随意配置,仅仅是为了后续的GPIO初始化函数方便区分,真正要写进寄存器的是bit2和bit3,对应寄存器的CNFY[1:0]位,是我们真正要写入到CRL这个端口控制寄存器中的值。而bit1和bit0之所以都配置为0,主要是后续GPIO的初始化函数里,这2位是由前面的GPIO_Speed定义的。 bit4用来区分端口是输入还是输出,0表示输入,1表示输出。其中在下拉输入和上拉输入中我们设置 bit5 和 bit6 的值为 01 和 10 来以示区别。
至此,我们就可以对上节的GPIO初始化结构体,再进行改进。 我们的 GPIO_InitTypeDef 结构体就可以使用枚举类型来限定输入参数,也更形象。代码修改如下,unit16_t就可以替换为枚举类型了:
GPIOSpeed_TypeDef GPIO_Speed; GPIOMode_TypeDef GPIO_Mode;
|
11.2.6.3 定义GPIO 初始化函数
在开始写函数之前,需要首先讲一个知识点,否则代码就会看的云里雾里。如前面“图11.2-7 CRL寄存器配置图”,这里面上拉和下拉输入对应的CNF位都是10,并没有说明是怎么配置实现区分的。实际上是而是通过写BSRR 或者 BRR寄存器来实现的。
*下拉输入模式,引脚默认置0,对BRR寄存器写1对引脚置0;
*上拉输入模式,引脚默认值为1,对BSRR寄存器写1对引脚置1;
代码如下:
void GPIO_Init(GPIO_TypeDef* GPIOx, GPIO_InitTypeDef* GPIO_InitStruct) uint32_t currentmode = 0x00, currentpin = 0x00, pinpos = 0x00, pos = 0x00; uint32_t tmpreg = 0x00, pinmask = 0x00; currentmode = ((uint32_t)GPIO_InitStruct->GPIO_Mode) & ((uint32_t)0x0F); if ((((uint32_t)GPIO_InitStruct->GPIO_Mode) & ((uint32_t)0x10)) != 0x00) currentmode |= (uint32_t)GPIO_InitStruct->GPIO_Speed; if (((uint32_t)GPIO_InitStruct->GPIO_Pin & ((uint32_t)0x00FF)) != 0x00) for (pinpos = 0x00; pinpos < 0x08; pinpos++) currentpin = (GPIO_InitStruct->GPIO_Pin) & ( ((uint32_t)0x01) << pinpos); pinmask = ((uint32_t)0x0F) << pos; tmpreg |= (currentmode << pos); if (GPIO_InitStruct->GPIO_Mode == GPIO_Mode_IPD) GPIOx->BRR = (((uint32_t)0x01) << pinpos); if (GPIO_InitStruct->GPIO_Mode == GPIO_Mode_IPU) GPIOx->BSRR = (((uint32_t)0x01) << pinpos); if (GPIO_InitStruct->GPIO_Pin > 0x00FF) for (pinpos = 0x00; pinpos < 0x08; pinpos++) currentpin = (GPIO_InitStruct->GPIO_Pin) & ((((uint32_t)0x01) << (pinpos + 0x08))); pinmask = ((uint32_t)0x0F) << pos; tmpreg |= (currentmode << pos); if (GPIO_InitStruct->GPIO_Mode == GPIO_Mode_IPD) GPIOx->BRR = (((uint32_t)0x01) << (pinpos + 0x08)); if (GPIO_InitStruct->GPIO_Mode == GPIO_Mode_IPU) GPIOx->BSRR = (((uint32_t)0x01) << (pinpos + 0x08));
|
下图是对程序中循环体的解释说明:
图11.2-9 程序循环体说明
11.3 基于自己构建库函数的主程序
完成以上工作后,我们就可以基于自己写的库函数,点亮LED。为和上次寄存器版本的做区分,这次我们点亮PB1端口。
RCC_APB2ENR |= 0x00000008; GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); GPIO_ResetBits(GPIOB,GPIO_Pin_1);
|
因为只是为了讲解原理,为使篇幅不至太长,上述代码中,RCC部分我们还没有构建函数,但道理是一样的,有兴趣的朋友可以自己尝试一下。
11.4 程序现象
编译下载后,LED成功点亮,如图11.4-1.
图11.4-1 程序现象
|