8. 构建库函数雏形

8. 构建库函数雏形

虽然上面用寄存器点亮了LED,乍看代码很简单,但不能侥幸地认为以后可以一直用寄存器开发。在用寄存器点亮LED时,会发现STM32的寄存器都是32位的,每次配置都要对照《STM32F10X-中文参考手册》中的寄存器说明,对每个控制的寄存器位写入特定参数。因此,在配置时非常容易出错,代码也不好理解,不便于维护。所以,学习STM32最好的方法是用固件库,然后在固件库的基础上了解底层,学习寄存器。

8.1 什么是STM32函数库

STM32函数库是指“STM32标准函数库”,由ST公司针对STM32提供的函数接口(API,Application Program Interface)。开发者可调用这些函数接口来配置STM32的寄存器,使开发人员得以脱离最底层的寄存器操作,有开发快速、易于阅读、维护成本低等优点。

当我们调用库API时,不需要挖空心思地去了解库底层的寄存器操作,就像学习C语言时,只需了解printf()函数的使用格式,不必研究其源码实现。但当需要深入研究时,库源码就是最佳的学习范例。

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

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

在以前8位机时代的程序开发中,一般直接配置芯片的寄存器控制芯片的工作方式,如中断、定时器等。配置时常常要查阅寄存器表,决定对这些位的配置。8位机的软件相对简单且资源有限,所以可以用直接配置寄存器的方式来开发。

对于STM32,因为外设资源丰富,寄存器的数量和复杂度增加,直接配置寄存器方式的缺陷显现出来:

  1. 开发速度慢。
  2. 程序可读性差。
  3. 维护复杂。

这些缺陷影响了开发效率、程序维护成本和交流成本。而库开发方式则正好弥补了这些缺陷。

尽管直接配置寄存器方式生成的代码量会少一点,但STM32资源充足,权衡库的优势与不足,绝大部分情况下我们愿意牺牲一点CPU资源,而选择库开发。只有在对代码运行时间要求极苛刻的情况下,才用直接配置寄存器的方式代替,如频繁调用的中断服务函数。

对于库开发与直接配置寄存器的方式,就如编程用汇编语言还是用C语言一样。STM32F1系列刚推出函数库时引起了程序员的激烈争论,但随着ST库的完善和大家对库的了解,更多的程序员选择了库开发。现在STM32F1系列和STM32F4系列各有一套自己的函数库,大部分是兼容的,移植时只需小改动。而如果要移植用寄存器写的程序,那几乎是重写。

库函数的底层实现是直接配置寄存器方式的最佳例子,它代替我们完成了寄存器配置的工作,要深入了解芯片的工作方式,只需查看库函数的底层实现。所以,在以后的章节中,使用软件库是我们的重点,通过讲解库API去高效地学习STM32的寄存器,并不会因为用库学习,就不会用寄存器控制STM32芯片。

8.3 实验:构建库函数雏形

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

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

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

8.3.1 外部寄存器结构体定义

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

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

typedef struct
{
  __IO uint32_t CRL;   // GPIO port configuration register low
  __IO uint32_t CRH;   // GPIO port configuration register high
  __IO uint32_t IDR;   // GPIO port input data register
  __IO uint32_t ODR;   // GPIO port output data register
  __IO uint32_t BSRR;  // GPIO port bit set/reset register
  __IO uint32_t BRR;   // GPIO port bit reset register
  __IO uint32_t LCKR;  // GPIO port configuration lock register
} GPIO_TypeDef;

typedef struct
{
  __IO uint32_t CR;    // Clock control register
  __IO uint32_t CFGR;  // Clock configuration register
  __IO uint32_t CIR;   // Clock interrupt register
  __IO uint32_t APB2RSTR; // APB2 peripheral reset register
  __IO uint32_t APB1RSTR; // APB1 peripheral reset register
  __IO uint32_t AHBENR; // AHB peripheral clock enable register
  __IO uint32_t APB2ENR; // APB2 peripheral clock enable register
  __IO uint32_t APB1ENR; // APB1 peripheral clock enable register
  __IO uint32_t BDCR;   // Backup domain control register
  __IO uint32_t CSR;    // Control/status register
} RCC_TypeDef;

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

8.3.2 外设存储器映射

外部寄存器结构体定义仅仅是一个定义,要想实现给这个结构体赋值就达到操作寄存器的效果,我们还需要找到该寄存器的地址,把寄存器地址与结构体的地址对应起来。所以还要再找到外设的地址,根据前面的学习,可以把这些外设的地址定义成一个个宏,以实现外设存储器的映射。

#define GPIOA_BASE (0x40010800)
#define GPIOB_BASE (0x40010C00)
#define RCC_BASE   (0x40021000)
8.3.3 外设声明

定义好外部寄存器结构体,实现外设存储器映射后,我们再把外设的基地址强制类型转换成相应的外部寄存器结构体指针,然后再把该指针声明成外设名,这样一来,外设名就与外设的地址对应起来了,而且该外设名还是一个该外设类型的寄存器结构体指针,通过该指针可以直接操作该外设的全部寄存器,见代码清单8-2。

#define GPIOA ((GPIO_TypeDef *) GPIOA_BASE)
#define GPIOB ((GPIO_TypeDef *) GPIOB_BASE)
#define RCC   ((RCC_TypeDef *) RCC_BASE)

首先通过强制类型转换把外设的基地址转换成GPIO_TypeDef类型的结构体指针,然后通过宏定义把GPIOA、GPIOB等定义成外设的结构体指针,通过外设的结构体指针就可以达到访问外设的寄存器的目的。

通过操作外设结构体指针的方式,我们把main文件里对应的代码修改掉,见代码清单8-3和代码清单中else部分。

// 修改前
GPIOB_BSRR = 0x00000001;

// 修改后
GPIOA->BSRR = 0x00000001;

乍一看,除了把“_”换成了“->”,其他都与使用寄存器点亮LED的代码一样。这是因为我们现在只是实现了库函数的基础,还没有定义库函数。

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

8.3.4 定义位操作函数

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

void GPIO_SetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
    GPIOx->BSRR = GPIO_Pin;
}

void GPIO_ResetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
    GPIOx->BRR = GPIO_Pin;
}

这两个函数体内都只有一个语句,对GPIOx的BSRR或BRR寄存器赋值,从而设置引脚为高电平或低电平,操作BSRR或者BRR可以实现单独地操作某一位,有关这两个的寄存器说明见图8-2和图8-3。GPIOx是一个指针变量,通过函数的输入参数可以修改它的值,如给它赋予GPIOA、GPIOB、GPIOH等结构体指针值,这个函数就可以控制相应的GPIOA、GPIOB、GPIOH等端口的输出。

在这里插入图片描述

在这里插入图片描述

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

GPIO_SetBits(GPIOA, GPIO_Pin_0);
GPIO_ResetBits(GPIOA, GPIO_Pin_1);

使用以上函数输入参数、但设置引

脚号时,还是稍感不便,为此我们把表示16个引脚的操作数都定义成宏,见代码清单8-7。

#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)
#define GPIO_Pin_All ((uint16_t)0xFFFF)

利用这些宏,GPIO的控制代码可改为代码清单8-8。

GPIO_SetBits(GPIOA, GPIO_Pin_0);
GPIO_ResetBits(GPIOA, GPIO_Pin_1);

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

8.3.5 定义初始化结构体

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

typedef struct
{
  uint16_t GPIO_Pin;              // Specifies the GPIO pins to be configured.
  GPIOSpeed_TypeDef GPIO_Speed;   // Specifies the speed for the selected pins.
  GPIOMode_TypeDef GPIO_Mode;     // Specifies the operating mode for the selected pins.
} GPIO_InitTypeDef;

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

8.3.6 定义引脚模式的枚举类型

上面定义的结构体很直接,美中不足的是在对结构体中各个成员赋值实现某个功能时还需要查手册中的寄存器说明。我们不希望每次用到寄存器的时候都查询手册,所以可以使用C语言中的枚举定义功能,根据手册把每个成员的所有取值都定义好,具体见代码清单8-10。GPIO_Speed和GPIO_Mode这两个成员对应的寄存器是CRL和CRH这两个端口配置寄存器,具体见图8-4和图8-5。

typedef enum
{
  GPIO_Speed_10MHz = 1,
  GPIO_Speed_2MHz,
  GPIO_Speed_50MHz
} GPIOSpeed_TypeDef;

typedef enum
{
  GPIO_Mode_AIN = 0x0,
  GPIO_Mode_IN_FLOATING = 0x04,
  GPIO_Mode_IPD = 0x28,
  GPIO_Mode_IPU = 0x48,
  GPIO_Mode_Out_OD = 0x14,
  GPIO_Mode_Out_PP = 0x10,
  GPIO_Mode_AF_OD = 0x1C,
  GPIO_Mode_AF_PP = 0x18
} GPIOMode_TypeDef;

在这里插入图片描述

在这里插入图片描述

关于这两个枚举类型的值如何与端口控制寄存器里面的说明对应起来,我们简单分析一下。有关速度的枚举类型有:(01)b 10MHz、(10)b 2MHz和(11)b 50MHz,这3个值与寄存器说明对得上,很容易理解。至于模式的枚举类型的值理解起来就比较难,这让很多人费了脑筋,下面我们通过一个表格来梳理一下,具体见图8-6。

【GPIO引脚工作模式真值表】

在这里插入图片描述

在这里插入图片描述

如果从这些枚举值的十六进制来看,很难发现规律,转化成二进制之后,就比较容易发现规律。bit4用来区分端口是输入还是输出,0表示输入,1表示输出,bit2和bit3对应寄存器的CNFY[1:0]位,是真正要写入CRL和CRH这两个端口控制寄存器中的值。bit0和bit1对应寄存器的MODEY[1:0]位,这里暂不初始化,在GPIO_Init()初始化函数中用来与GPIO-Speed的值相加即可实现速率的配置。有关具体的代码分析见GPIO_Init()库函数。其中在下拉输入和上拉输入中设置bit5和bit6的值为01和10以示区别。

有了这些枚举定义,GPIO_InitTypeDef结构体就可以使用枚举类型来限定输入参数,使用枚举定义的GPIO初始化结构体见代码清单8-11。

GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_10MHz;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;

如果不使用枚举类型,仍使用“uint16_t”类型来定义结构体成员,那么成员值的范围就是0~255,而实际上这些成员只能输入几个数值。所以使用枚举类型可以对结构体成员起到限定输入的作用,只能输入相应已定义的枚举值。利用这些枚举定义,给GPIO_InitTypeDef结构体类型赋值就变得非常直观,范例见代码清单8-12。

GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
8.3.7 定义GPIO初始化函数

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

void GPIO_Init(GPIO_TypeDef* GPIOx, GPIO_InitTypeDef* GPIO_InitStruct)
{
  uint32_t pinpos = 0x00, pos = 0x00, currentpin = 0x00;

  for (pinpos = 0x00; pinpos < 0x10; pinpos++)
  {
    pos = ((uint32_t)0x01) << pinpos;
    currentpin = (GPIO_InitStruct->GPIO_Pin) & pos;

    if (currentpin == pos)
    {
      if ((GPIO_InitStruct->GPIO_Mode == GPIO_Mode_IPD) || (GPIO_InitStruct->GPIO_Mode == GPIO_Mode_IPU))
      {
        GPIOx->ODR &= ~currentpin;
        if (GPIO_InitStruct->GPIO_Mode == GPIO_Mode_IPU)
        {
          GPIOx->ODR |= currentpin;
        }
      }

      pos = pinpos << 2;
      GPIOx->CRL &= ~((uint32_t)0x0F << pos);
      GPIOx->CRL |= (GPIO_InitStruct->GPIO_Mode << pos);

      if ((GPIO_InitStruct->GPIO_Pin & ((uint32_t)0x00FF)) == 0x00)
      {
        pos = (pinpos - 0x08) << 2;
        GPIOx->CRH &= ~

((uint32_t)0x0F << pos);
        GPIOx->CRH |= (GPIO_InitStruct->GPIO_Mode << pos);
      }
    }
  }
}
8.3.8 全新面貌,使用函数点亮LED

完成以上的准备后,我们就可以用自己定义的函数来点亮LED,见代码清单8-14。

int main(void)
{
  GPIO_InitTypeDef GPIO_InitStructure;

  RCC->APB2ENR |= RCC_APB2ENR_IOPAEN; // Enable GPIOA clock

  GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
  GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
  GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
  GPIO_Init(GPIOA, &GPIO_InitStructure);

  while (1)
  {
    GPIO_SetBits(GPIOA, GPIO_Pin_0);
    Delay(0xFFFFF);
    GPIO_ResetBits(GPIOA, GPIO_Pin_0);
    Delay(0xFFFFF);
  }
}

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

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

8.3.9 下载验证

把编译好的程序下载到开发板并复位,可看到板子上的灯被点亮。

8.3.10 总结

什么是ST标准固件库?不懂的时候总觉得它高深莫测,懂了之后会发现一切都是“纸老虎”。

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

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

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

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

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

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

  • 14
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值