写点什么

STM32 标准库开发实战指南

发布于: 2021 年 01 月 11 日

摘要:从 STM32 新建工程、编译下载程序出发,让新手由浅入深,尽享 STM32 标准库开发的乐趣。


自从 CubeMX 等图像配置软件的出现,同学们往往点几下鼠标就解决了单片机的配置问题。对于追求开发速度的业务场景下,使用快速配置软件是合理的,高效的,但对于学生的学习场景下,更为重要的是知其然并知其所以然。


以下是学习(包括但不限于)嵌入式的三个重要内容,


1、学会如何参考官方的手册和官方的代码来独立写自己的程序。


2、积累常用代码段,知道哪里的问题需要哪些代码处理。


3、跟随大佬步伐,一步一个脚印。


首先:我们都知道编程时一般查的是《参考手册》,而进行芯片选型或需要芯片数据时,查阅的是《数据手册》。此外市面上所有关于 STM32 的书籍都是立足于前二者(+Cortex 内核手册)进行编著。


其次:要分清什么是内核外设与内核之外的外设,为了便于区分,按照网上的一种说法,将“内核之外的外设”以“处理器外设”代替。


再者:如今很少使用标准库了,都是 HAL 库,但作为高校目前教学方式,


我们将以 STM32f10xxx 为例对标准库开发进行概览。


一、STM32 系统结构



STM32f10xxx 系统结构


内核 IP


从结构框图上看,Cortex-M3 内部有若 干个总线接口,以使 CM3 能同时取址和访内(访问内存),它们是: 指令存储区总线(两条)、系统总线、私有外设总线。有两条代码存储区总线负责对代 码存储区(即 FLASH 外设)的访问,分别是 I-Code 总线和 D-Code 总线。


I-Code 用于取指,D-Code 用于查表等操作,它们按最佳执行速度进行优化。


系统总线(System)用于访问内存和外设,覆盖的区域包括 SRAM,片上外设,片外 RAM,片外扩展设备,以及系统级存储区的部分空间。


私有外设总线负责一部分私有外设的访问,主要就是访问调试组件。它们也在系统级 存储区。


还有一个 MDA 总线,从字面上看,DMA 是 data memory access 的意思,是一种连接内核和外设的桥梁,它可以访问外设、内存,传输不受 CPU 的控制,并且是双向通信。简而言之,这个家伙就是一个速度很快的且不受老大控制的数据搬运工。


处理器外设(内核之外的外设)


从结构框图上看,STM32 的外设有 串口、定时器、IO 口、FSMC、SDIO、SPI、I2C 等,这些外设按 照速度的不同,分别挂载到 AHB、APB2、APB1 这三条总线上。


二、寄存器


什么是寄存器?寄存器是内置于各个 IP 外设中,是一种用于配置外设功能的存储器,并且有想对应的地址。一切库的封装始于映射。




是不是“又臭又长”,如果进行寄存器开发,就需要怼地址以及对寄存器进行字节赋值,不仅效率低而且容易出错。


来,开个玩笑。


你也许听说过“国际 C 语言乱码大赛(IOCCC)”下面这个例子就是网上广为流传的 一个经典作品:


#include <stdio.h>main(t,_,a)char *a;{return!0<t?t<3?main(-79,-13,a+main(-87,1-_,main(-86,0,a+1)+a)):1,t<_?main(t+1,_,a):3,main(-94,-27+t,a)&&t==2?_<13?main(2,_+1,"%s %d %dn"):9:16:t<0?t<-72?main(_,t,"@n'+,#'/*{}w+/w#cdnr/+,{}r/*de}+,/*{*+,/w{%+,/w#q#n+,/#{l+,/n{n+,/+#n+,/#;#q#n+,/+k#;*+,/'r :'d*'3,}{w+K w'K:'+}e#';dq#'l q#'+d'K#!/+k#;q#'r}eKK#}w'r}eKK{nl]'/#;#q#n'){)#}w'){){nl]'/+#n';d}rw' i;# ){nl]!/n{n#'; r{#w'r nc{nl]'/#{l,+'K {rw' iK{;[{nl]'/w#q#n'wk nw' iwk{KK{nl]!/w{%'l##w#' i; :{nl]'/*{q#'ld;r'}{nlwb!/*de}'c ;;{nl'-{}rw]'/+,}##'*}#nc,',#nw]'/+kd'+e}+;#'rdq#w! nr'/ ') }+}{rl#'{n' ')# }'+}##(!!/"):t<-50?_==*a?putchar(31[a]):main(-65,_,a+1):main((*a=='/')+t,_,a+1):0<t?main(2,2,"%s"):*a=='/'||main(0,main(-61,*a,"!ek;dc i@bK'(q)-[w]*%n+r3#l,{}:nuwloca-O;m.vpbks,fxntdCeghiry"),a+1);}复制代码
复制代码


库的存在就是为了解决这类问题,将代码语义化。语义化思想不仅仅是嵌入式有的,前端代码也在追求语义特性。


三、万物始于点灯



(1)内核库文件分析


  • cor_cm3.h


这个头文件实现了:1、内核结构体寄存器定义 2、内核寄存器内存映射 3、内存寄存 器位定义。跟处理器相关的头文件 stm32f10x.h 实现的功能一样,一个是针对内核的寄存器,一个是针对内核之外,即处理器的寄存器。


  • misc.h


内核应用函数库头文件,对应 stm32f10x_xxx.h


  • misc.c


内核应用函数库文件,对应 stm32f10x_xxx.c。在 CM3 这个内核里面还有一些功能组 件,如 NVIC、SCB、ITM、MPU、CoreDebug,CM3 带有非常丰富的功能组件,但是芯片 厂商在设计 MCU 的时候有一些并不是非要不可的,是可裁剪的,比如 MPU、ITM 等在 STM32 里面就没有。其中 NVIC 在每一个 CM3 内核的单片机中都会有,但都会被裁剪,只能是 CM3 NVIC 的一个子集。在 NVIC 里面还有一个 SysTick,是一个系统定时器,可以提 供时基,一般为操作系统定时器所用。 misc.h 和 mics.c 这两个文件提供了操作这些组件的函数,并可以在 CM3 内核单片机 直接移植。


(2)处理器外设库文件分析


  • startup_stm32f10x_hd.s


这个是由汇编编写的启动文件,是 STM32 上电启动的第一个程序,启动文件主要实现 了:1、初始化堆栈指针 SP;2、设置 PC 指针=Reset_Handler ;3、设置向量表的地址,并 初始化向量表,向量表里面放的是 STM32 所有中断函数的入口地址 4、调用库函数 SystemInit,把系统时钟配置成 72M,SystemInit 在库文件 stytem_stm32f10x.c 中定义;5、 跳转到标号_main,最终去到 C 的世界。


  • system_stm32f10x.c


这个文件的作用是里面实现了各种常用的系统时钟设置函数,有 72M,56M,48, 36,24,8M,我们使用的是是把系统时钟设置成 72M。


  • Stm32f10x.h


这个头文件非常重要,这个头文件实现了:1、处理器外设寄存器 的结构体定义 2、处理器外设的内存映射 3、处理器外设寄存器的位定义。


关于 1 和 2 我们在用寄存器点亮 LED 的时候有讲解。


其中 3:处理器外设寄存器的位定义,这个非常重要,具体是什么意思?我们知道一个寄存器有很多个位,每个位写 1 或 者写 0 的功能都是不一样的,处理器外设寄存器的位定义就是把外设的每个寄存器的每一 个位写 1 的 16 进制数定义成一个宏,宏名即用该位的名称表示,如果我们操作寄存器要开启某一个功能的话,就不用自己亲自去算这个值是多少,可以直接到这个头文件里面找。


我们以片上外设 ADC 为例,假设我们要启动 ADC 开始转换,根据手册我们知道是要控制 ADC_CR2 寄存器的位 0:ADON,即往位 0 写 1,即:


ADC->CR2=0x00000001;复制代码
复制代码


这是 一般的操作方法。现在这个头文件里面有关于 ADON 位的位定义:


 #define ADC_CR2_ADON ((uint32_t)0x00000001)复制代码
复制代码


有了这个位定义,我们刚刚的 代码就变成了:


ADC->CR2=ADC_CR2_ADON复制代码
复制代码


· stm32f10x_xxx.h


外设 xxx 应用函数库头文件,这里面主要定义了实现外设某一功能 的结构体,比如通用定时器有很多功能,有定时功能,有输出比较功能,有输入捕捉功 能,而通用定时器有非常多的寄存器要实现某一个功能,比如定时功能,我们根本不知道 具体要操作哪些寄存器,这个头文件就为我们打包好了要实现某一个功能的寄存器,是以机构体的形式定义的,比如通用定时器要实现一个定时的功能,我们只需要初始化 TIM_TimeBaseInitTypeDef 这个结构体里面的成员即可,里面的成员就是定时所需要 操作的寄存器。 有了这个头文件,我们就知道要实现某个功能需要操作哪些寄存器,然后 再回手册中精度这些寄存器的说明即可。


· stm32f10x_xxx.c


stm32f10x_xxx.c:外设 xxx 应用函数库,这里面写好了操作 xxx 外设的所有常用的函 数,我们使用库编程的时候,使用的最多的就是这里的函数。


(3)SystemInit


工程中新建 main.c 。


在此文件中编写 main 函数后直接编译会报错:


Undefined symbol SystemInit (referred from startup_stm32f10x_hd.o).复制代码
复制代码


错误提示说 SystemInit 没有定义。从分析启动文件 startup_stm32f10x_hd.s 时我们知道,


1 ;Reset handler2 Reset_Handler PROC3 EXPORT Reset_Handler [WEAK]4 IMPORT __main5 ;IMPORT SystemInit6 ;LDR R0, =SystemInit7 BLX R08 LDR R0, =__main9 BX R010 ENDP复制代码
复制代码


汇编中;分号是注释的意思


第五行第六行代码 Reset_Handler 调用了 SystemInit 该函数用来初始化系统时钟,而该函数是在库文件 system_stm32f10x.c 中实现的。我们重新写一个这样的函数也可以,把功能完整实现一遍,但是为了简单起见,我们在 main 文件里面定义一个 SystemInit 空函数,为的是骗过编译器,把这个错误去掉。关于配置系统时钟之后会出文章 RCC 时钟树详细介绍,主要配置时钟控制寄存器(RCC_CR)和时钟配置寄存器(RCC_CFGR)这两个寄存器,但最好是直接使用 CubeMX 直接生成,因为它的配置过程有些冗长。


如果我们用的是库,那么有个库函数 SystemInit,会帮我们把系统时钟设置成 72M。


现在我们没有使用库,那现在时钟是多少?答案是 8M,当外部 HSE 没有开启或者出现故


障的时候,系统时钟由内部低速时钟 LSI 提供,现在我们是没有开启 HSE,所以系统默认的时钟是 LSI=8M。


(4)库封装层级



如图,达到第四层级便是我们所熟知的固件库或 HAL 库的效果。当然库的编写还需要考虑许多问题,不止于这些内容。我们需要的是了解库封装的大概过程。


将库封装等级分为四级来介绍是为了有层次感,就像打怪升级一样,进行认知理解的升级。


我们都知道,操作 GPIO 输出分三大步:


时钟控制:


STM32 外设很多,为了降低功耗,每个外设都对应着一个时钟,在系统复位的时候这


些时钟都是被关闭的,如果想要外设工作,必须把相应的时钟打开。


STM32 的所有外设的时钟由一个专门的外设来管理,叫 RCC(reset and clockcontrol),RCC 在 STM32 参考手册的第六章。


STM32 的外设因为速率的不同,分别挂载到三条总系上:AHB、APB2、APB1,AHB 为高速总线,APB2 次之,APB1 再次之。所以的 IO 口都挂载到 APB2 总线上,属于高速


外设。


模式配置:


这个由端口配置寄存器来控制。端口配置寄存器分为高低两个,每 4bit 控制一


个 IO 口,所以端口配置低寄存器:CRL 控制这 IO 口的低 8 位,端口配置高寄存器:CRH


控制这 IO 口的高 8bit。在 4 位一组的控制位中,CNFy[1:0] 用来控制端口的输入输出,


MODEy[1:0]用来控制输出模式的速率,又称驱动电路的响应速度,注意此处速率与程序无关,具体内容见文章:【嵌入式】GPIO 引脚速度、翻转速度、输出速度区别


输入有 4 种模式,输出有 4 种模式,我们在控制 LED 的时候选择通用推挽输出。


输出速率有三种模式:2M、10M、50M,这里我们选择 2M。


电平控制:


STM32 的 IO 口比较复杂,如果要输出 1 和 0,则要通过控制:端口输出数据寄存


器 ODR 来实现,ODR 是:Output data register 的简写,在 STM32 里面,其寄存器的命名


名称都是英文的简写,很容易记住。从手册上我们知道 ODR 是一个 32 位的寄存器,低 16


位有效,高 16 位保留。低 16 位对应着 IO0~IO16,只要往相应的位置写入 0 或者 1 就可以


输出低或者高电平。


第一层级:基地址宏定义



时钟控制:




在 STM32 中,每个外设都有一个起始地址,叫做外设基地址,外设的寄存器就以这个基地址为标准按照顺序排列,且每个寄存器 32 位,(后面作为结构体里面的成员正好内存对齐)。查表看到时钟由 APB2 外设时钟使能寄存器(RCC_APB2ENR)来控制,其中 PB 端口的时钟由该寄存器的位 3 写 1 使能。我们可以通过基地址+偏移量 0x18,算出 RCC_APB2ENR 的地址为:0x40021018。那么使能 PB 口的时钟代码则如下所示:


 #define RCC_APB2ENR *(volatile unsigned long *)0x40021018// 开启端口B 时钟 RCC_APB2ENR |= 1<<3;复制代码
复制代码


模式配置:



同 RCC_APB2ENR 一样,GPIOB 的起始地址是:0X4001 0C00,我们也可以算出 GPIO_CRL 的地址为:0x40010C00。那么设置 PB0 为通用推挽输出,输出速率为 2M 的代码则如下所示:



同上,从手册中我们看到 ODR 寄存器的地址偏移是:0CH,可以算出 GPIOB_ODR 寄存器的地址是:0X4001 0C00 + 0X0C = 0X4001 0C0C。现在我们就可以定义 GPIOB_ODR 这个寄存器了,代码如下:


#define GPIOB_ODR *(volatile unsigned long *)0x40010C0C
//PB0 输出低电平GPIOB_ODR = 0<<0;复制代码
复制代码


第一层级:基地址宏定义完成用 STM32 控制一个 LED 的完整代码:


1 #define RCC_APB2ENR *(volatile unsigned long *)0x400210182 #define GPIOB_CRL *(volatile unsigned long *)0x40010C003 #define GPIOB_ODR *(volatile unsigned long *)0x40010C0C45int main(void)6 {7 // 开启端口B 的时钟8 RCC_APB2ENR |= 1<<3;910 // 配置PB0 为通用推挽输出模式,速率为2M11 GPIOB_CRL = (2<<0) | (0<<2);1213 // PB0 输出低电平,点亮LED14 GPIOB_ODR = 0<<0;15 }1617 void SystemInit(void)18 {19 }复制代码
复制代码


第二层级:基地址宏定义+结构体封装


外设寄存器结构体封装


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


下面我们先定义一个 GPIO 寄存器结构体,结构体里面的成员是 GPIO 的寄存器,成员的顺序按照寄存器的偏移地址从低到高排列,成员类型跟寄存器类型一样。(struct 用法参考【C 语言】(2):关键字的详细介绍)


1 typedef struct {2 volatile uint32_t CRL;3 volatile uint32_t CRH;4 volatile uint32_t IDR;5 volatile uint32_t ODR;6 volatile uint32_t BSRR;7 volatile uint32_t BRR;8 volatile uint32_t LCKR;9 } GPIO_TypeDef;复制代码
复制代码


在《STM32 中文参考手册》8.2 寄存器描述章节,我们可以找到结构体里面的 7 个寄存器描述。在点亮 LED 的时候我们只用了 CRL 和 ODR 这两个寄存器,至于其他寄存器的功能大家可以自行看手册了解。在 GPIO 结构体里面我们用了两个数据类型,一个是 uint32_t,表示无符号的 32 位整型,因为 GPIO 的寄存器都是 32 位的。这个类型声明在标准头文件 stdint.h 里面使用 typedef 对 unsigned int 重命名,我们在程序上只要包含这个头文件即可。另外一个是 volatile(volatile 用法参考【C 语言】(2):关键字的详细介绍),作用就是告诉编译器这里的变量会变化不因优化而省略此指令,必须每次都直接读写其值,这样就能确保每次读或者写寄存器都真正执行到位。


外设封装



STM32F1 系列的 GPIO 端口分 A~G,即 GPIOA、GPIOB。。。。。。GPIOG。每个端口都含有 GPIO_TypeDef 结构体里面的寄存器,我们可以根据手册各个端口的基地址把 GPIO 的各个端口定义成一个 GPIO_TypeDef 类型指针,然后我们就可以根据端口名(实际上现在是结构体指针了)来操作各个端口的寄存器,代码实现如下:


1 #define GPIOA ((GPIO_TypeDef *) 0X4001 0800)2 #define GPIOB ((GPIO_TypeDef *) 0X4001 0C00)3 #define GPIOC ((GPIO_TypeDef *) 0X4001 1000)4 #define GPIOD ((GPIO_TypeDef *) 0X4001 1400)5 #define GPIOE ((GPIO_TypeDef *) 0X4001 1800)6 #define GPIOF ((GPIO_TypeDef *) 0X4001 1C00)7 #define GPIOG ((GPIO_TypeDef *) 0X4001 2000)复制代码
复制代码


外设内存映射


讲到基地址的时候我们再引人一个知识点:Cortex-M3 存储器系统,这个知识点在《Cortex-M3 权威指南》第 5 章里面讲到。CM3 的地址空间是 4GB,如下图所示:



我们这里要讲的是片上外设,就是我们所说的寄存器的根据地,其大小总共有 512MB,512MB 是其极限空间,并不是每个单片机都用得完,实际上各个 MCU 厂商都只是用了一部分而已。STM32F1 系列用到了:0x4000 0000 ~0x5003 FFFF。现在我们说的 STM32 的寄存器就是位于这个区域


  • APB1、APB2、AHB 总线基地址


现在我们说的 STM32 的寄存器就是位于这个区域,这里面 ST 设计了三条总线:AHB、APB2 和 APB1,其中 AHB 和 APB2 是高速总线,APB1 是低速总线。不同的外设根据速度不同分别挂载到这三条总线上。从下往上依次是:APB1、APB2、AHB,每个总线对应的地址分别是:APB1:0x40000000,APB2:0x4001 0000,AHB:0x4001 8000。这三条总线的基地址我们是从《STM32 中文参考手册》2.3 小节—存储器映像得到的:APB1 的基地址是 TIM2 定时器的起始地址,APB2 的基地址是 AFIO 的起始地址,AHB 的基地址是 SDIO 的起始地址。


其中 APB1 地址又叫做外设基地址,是所有外设的基地址,叫做 PERIPH_BASE。现在我们把这三条总线地址用宏定义出来,以后我们在定义其他外设基地址的时候,只需要在这三条总线的基址上加上偏移地址即可,代码如下:


1 #define PERIPH_BASE ((uint32_t)0x40000000)2 #define APB1PERIPH_BASE PERIPH_BASE3 #define APB2PERIPH_BASE (PERIPH_BASE + 0x10000)4 #define AHBPERIPH_BASE (PERIPH_BASE + 0x20000)复制代码
复制代码


  • GPIO 端口基地址


因为 GPIO 挂载到 APB2 总线上,那么现在我们就可以根据 APB2 的基址算出各个 GPIO 端口的基地址,用宏定义实现代码如下:


1 #define GPIOA_BASE (APB2PERIPH_BASE + 0x0800)2 #define GPIOB_BASE (APB2PERIPH_BASE + 0x0C00)3 #define GPIOC_BASE (APB2PERIPH_BASE + 0x1000)4 #define GPIOD_BASE (APB2PERIPH_BASE + 0x1400)5 #define GPIOE_BASE (APB2PERIPH_BASE + 0x1800)6 #define GPIOF_BASE (APB2PERIPH_BASE + 0x1C00)7 #define GPIOG_BASE (APB2PERIPH_BASE + 0x2000)复制代码
复制代码


第二层级:基地址宏定义+结构体封装完成用 STM32 控制一个 LED 的完整代码:


1 #include <stdint.h>2 #define __IO volatile34typedef struct {5 __IO uint32_t CRL;6 __IO uint32_t CRH;7 __IO uint32_t IDR;8 __IO uint32_t ODR;9 __IO uint32_t BSRR;10 __IO uint32_t BRR;11 __IO uint32_t LCKR;12 } GPIO_TypeDef;1314typedef struct {15 __IO uint32_t CR;16 __IO uint32_t CFGR;17 __IO uint32_t CIR;18 __IO uint32_t APB2RSTR;19 __IO uint32_t APB1RSTR;20 __IO uint32_t AHBENR;21 __IO uint32_t APB2ENR;22 __IO uint32_t APB1ENR;23 __IO uint32_t BDCR;24 __IO uint32_t CSR;25 } RCC_TypeDef;2627 #define PERIPH_BASE ((uint32_t)0x40000000)2829 #define APB1PERIPH_BASE PERIPH_BASE30 #define APB2PERIPH_BASE (PERIPH_BASE + 0x10000)31 #define AHBPERIPH_BASE (PERIPH_BASE + 0x20000)3233 #define GPIOA_BASE (APB2PERIPH_BASE + 0x0800)34 #define GPIOB_BASE (APB2PERIPH_BASE + 0x0C00)35 #define GPIOC_BASE (APB2PERIPH_BASE + 0x1000)36 #define GPIOD_BASE (APB2PERIPH_BASE + 0x1400)37 #define GPIOE_BASE (APB2PERIPH_BASE + 0x1800)38 #define GPIOF_BASE (APB2PERIPH_BASE + 0x1C00)39 #define GPIOG_BASE (APB2PERIPH_BASE + 0x2000)
40 #define RCC_BASE (AHBPERIPH_BASE + 0x1000)4142 #define GPIOA ((GPIO_TypeDef *) GPIOA_BASE)43 #define GPIOB ((GPIO_TypeDef *) GPIOB_BASE)44 #define GPIOC ((GPIO_TypeDef *) GPIOC_BASE)45 #define GPIOD ((GPIO_TypeDef *) GPIOD_BASE)46 #define GPIOE ((GPIO_TypeDef *) GPIOE_BASE)47 #define GPIOF ((GPIO_TypeDef *) GPIOF_BASE)48 #define GPIOG ((GPIO_TypeDef *) GPIOG_BASE)49 #define RCC ((RCC_TypeDef *) RCC_BASE)505152 #define RCC_APB2ENR *(volatile unsigned long *)0x4002101853 #define GPIOB_CRL *(volatile unsigned long *)0x40010C0054 #define GPIOB_ODR *(volatile unsigned long *)0x40010C0C5556 int main(void)57 {58 // 开启端口B 的时钟59 RCC->APB2ENR |= 1<<3;6061 // 配置PB0 为通用推挽输出模式,速率为2M62 GPIOB->CRL = (2<<0) | (0<<2);6364 // PB0 输出低电平,点亮LED65 GPIOB->ODR = 0<<0;6667 }6869 void SystemInit(void)70 {71 }复制代码
复制代码


第二层级变化:


①、定义一个外设(GPIO)寄存器结构体,结构体的成员包含该外设的所有寄存器,


成员的排列顺序跟寄存器偏移地址一样,成员的数据类型跟寄存器的一样。


②外设内存映射,即把地址跟外设建立起一一对应的关系。


③外设声明,即把外设的名字定义成一个外设寄存器结构体类型的指针。


④通过结构体操作寄存器,实现点亮 LED。


第三层级:基地址宏定义+结构体封装+“位封装(每一位的对应字节封装)


上面我们在控制 GPIO 输出内容的时候控制的是 ODR(Output data register)寄存器,ODR 是一个 16 位的寄存器,必须以字的形式控制其实我们还可以控制 BSRR 和 BRR 这两个寄存器来控制 IO 的电平,下面我们简单介绍下 BRR 寄存器的功能,BSRR 自行看手册研究。



位清除寄存器 BRR 只能实现位清 0 操作,是一个 32 位寄存器,低 16 位有效,写 0 没影响,写 1 清 0。现在我们要使 PB0 输出低电平,点亮 LED,则只要往 BRR 的 BR0 位写 1 即可,其他位为 0,代码如下:


1 GPIOB->BRR = 0X0001;复制代码
复制代码


这时 PB0 就输出了低电平,LED 就被点亮了。如果要 PB2 输出低电平,则是:


1 GPIOB->BRR = 0X0004;复制代码
复制代码


如果要 PB3/4/5/6。。。。。。这些 IO 输出低电平呢?道理是一样的,只要往 BRR 的相应位置赋不同的值即可。因为 BRR 是一个 16 位的寄存器,位数比较多,赋值的时候容易出错,而且从赋值的 16 进制数字我们很难清楚的知道控制的是哪个 IO。这时,我们是否可以把 BRR 的每个位置 1 都用宏定义来实现,如 GPIO_Pin_0 就表示 0X0001,GPIO_Pin_2 就表示 0X0004。只要我们定义一次,以后都可以使用,而且还见名知意。“位封装”(每一位的对应字节封装) 代码如下:


1 #define GPIO_Pin_0 ((uint16_t)0x0001) /*!< Pin 0 selected */2 #define GPIO_Pin_1 ((uint16_t)0x0002) /*!< Pin 1 selected */3 #define GPIO_Pin_2 ((uint16_t)0x0004) /*!< Pin 2 selected */4 #define GPIO_Pin_3 ((uint16_t)0x0008) /*!< Pin 3 selected */5 #define GPIO_Pin_4 ((uint16_t)0x0010) /*!< Pin 4 selected */6 #define GPIO_Pin_5 ((uint16_t)0x0020) /*!< Pin 5 selected */7 #define GPIO_Pin_6 ((uint16_t)0x0040) /*!< Pin 6 selected */8 #define GPIO_Pin_7 ((uint16_t)0x0080) /*!< Pin 7 selected */9 #define GPIO_Pin_8 ((uint16_t)0x0100) /*!< Pin 8 selected */10 #define GPIO_Pin_9 ((uint16_t)0x0200) /*!< Pin 9 selected */11 #define GPIO_Pin_10 ((uint16_t)0x0400) /*!< Pin 10 selected */12 #define GPIO_Pin_11 ((uint16_t)0x0800) /*!< Pin 11 selected */13 #define GPIO_Pin_12 ((uint16_t)0x1000) /*!< Pin 12 selected */14 #define GPIO_Pin_13 ((uint16_t)0x2000) /*!< Pin 13 selected */15 #define GPIO_Pin_14 ((uint16_t)0x4000) /*!< Pin 14 selected */16 #define GPIO_Pin_15 ((uint16_t)0x8000) /*!< Pin 15 selected */17 #define GPIO_Pin_All ((uint16_t)0xFFFF) /*!< All pins selected */复制代码
复制代码


这时 PB0 就输出了低电平的代码就变成了:


1 GPIOB->BRR = GPIO_Pin_0;复制代码
复制代码


如果同时让 PB0/PB15 输出低电平,用或运算,代码:


1 GPIOB->BRR = GPIO_Pin_0|GPIO_Pin_15;复制代码
复制代码


为了不使 main 函数看起来冗余,上述库封装 的代码不应该放在 main 里面,因为其是跟 GPIO 相关的,我们可以把这些宏放在一个单独的头文件里面。在工程目录下新建 stm32f10x_gpio.h,把封装代码放里面,然后把这个文件添加到工程里面。这时我们只需要在 main.c 里面包含这个头文件即可。


第四层级:基地址宏定义+结构体封装+“位封装”+函数封装


我们点亮 LED 的时候,控制的是 PB0 这个 IO,如果 LED 接到的是其他 IO,我们就需要把 GPIOB 修改成其他的端口,其实这样修改起来也很快很方便。但是为了提高程序的可读性和可移植性,我们是否可以编写一个专门的函数用来复位 GPIO 的某个位,这个函数有两个形参,一个是 GPIOX(X=A...G),另外一个是 GPIO_Pin(0...15),函数的主体则是根据形参 GPIOX 和 GPIO_Pin 来控制 BRR 寄存器,代码如下:


1 void GPIO_ResetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)2 {3 GPIOx->BRR = GPIO_Pin;4 }复制代码
复制代码


这时,PB0 输出低电平,点亮 LED 的代码就变成了:


1 GPIO_ResetBits(GPIOB,GPIO_Pin_0);复制代码
复制代码


同理, 我们可以控制 BSRR 这个寄存器来实现关闭 LED,代码如下:


1 // GPIO 端口置位函数2 void GPIO_SetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)3 {4 GPIOx->BSRR = GPIO_Pin;5 }复制代码
复制代码


这时,PB0 输出高电平,关闭 LED 的代码就变成了:


1 GPIO_SetBits(GPIOB,GPIO_Pin_0);复制代码
复制代码


同样,因为这个函数是控制 GPIO 的函数,我们可以新建一个专门的文件来放跟 gpio


有关的函数。在工程目录下新建 stm32f10x_gpio.c,把 GPIO 相关的函数放里面。这时我们是否发现刚刚新建了一个头文件 stm32f10x_gpio.h,这两个文件存放的都是跟外设 GPIO 相关的。C 文件里面的函数会用到 h 头文件里面的定义,这两个文件是相辅相成的,故我们在 stm32f10x_gpio.c 文件中也包含 stm32f10x_gpio.h 这个头文件。别忘了把 stm32f10x.h 这个头文件也包含进去,因为有关寄存器的所有定义都在这个头文件里面。


如果我们写其他外设的函数,我们也应该跟 GPIO 一样,新建两个文件专门来存函数,比如 RCC 这个外设我们可以新建 stm32f10x_rcc.c 和 stm32f10x_rcc.h。其他外依葫芦画瓢即可。


(5)实例编写


以上,是对库封住过程的概述,下面我们正在地使用库函数编写 LED 程序


①管理库的头文件


当我们开始调用库函数写代码的时候,有些库我们不需要,在编译的时候可以不编译,可以通过一个总的头文件 stm32f10x_conf.h 来控制,该头文件主要代码如下:


1 //#include "stm32f10x_adc.h"2 //#include "stm32f10x_bkp.h"3 //#include "stm32f10x_can.h"4 //#include "stm32f10x_cec.h"5 //#include "stm32f10x_crc.h"6 //#include "stm32f10x_dac.h"7 //#include "stm32f10x_dbgmcu.h"8 //#include "stm32f10x_dma.h"9 //#include "stm32f10x_exti.h"10 //#include "stm32f10x_flash.h"11 //#include "stm32f10x_fsmc.h"12 #include "stm32f10x_gpio.h"13 //#include "stm32f10x_i2c.h"14 //#include "stm32f10x_iwdg.h"15 //#include "stm32f10x_pwr.h"16 #include "stm32f10x_rcc.h"17 //#include "stm32f10x_rtc.h"18 //#include "stm32f10x_sdio.h"19 //#include "stm32f10x_spi.h"20 //#include "stm32f10x_tim.h"21 //#include "stm32f10x_usart.h"22 //#include "stm32f10x_wwdg.h"23 //#include "misc.h"复制代码
复制代码


这里面包含了全部外设的头文件,点亮一个 LED 我们只需要 RCC 和 GPIO 这两个外设的库函数即可,其中 RCC 控制的是时钟,GPIO 控制的具体的 IO 口。所以其他外设库函数的头文件我们注释掉,当我们需要的时候就把相应头文件的注释去掉即可。stm32f10x_conf.h 这个头文件在 stm32f10x.h 这个头文件的最后面被包含,在第 8296 行:


1 #ifdef USE_STDPERIPH_DRIVER2 #include "stm32f10x_conf.h"
3 #endif复制代码
复制代码


代码的意思是,如果定义了 USE_STDPERIPH_DRIVER 这个宏的话,就包含 stm32f10x_conf.h 这个头文件。我们在新建工程的时候,在魔术棒选项卡 C/C++中,我们定义了 USE_STDPERIPH_DRIVER 这个宏,所以 stm32f10x_conf.h 这个头文件就被 stm32f10x.h 包含了,我们在写程序的时候只需要调用一个头文件:stm32f10x.h 即可。(预处理指令详细内容会在【C 语言】的文章中提到)


②编写 LED 初始化函数


经过寄存器点亮 LED 的操作,我们知道操作一个 GPIO 输出的编程要点大概如下:


1、开启 GPIO 的端口时钟


2、选择要具体控制的 IO 口,即 pin


3、选择 IO 口输出的速率,即 speed


4、选择 IO 口输出的模式,即 mode


5、输出高/低电平


STM32 的时钟功能非常丰富,配置灵活,为了降低功耗,每个外设的时钟都可以独自的关闭和开启。STM32 中跟时钟有关的功能都由 RCC 这个外设控制,RCC 中有三个寄存器控制着所以外设时钟的开启和关闭:RCC_APHENR、RCC_APB2ENR 和 RCC_APB1ENR,AHB、APB2 和 APB1 代表着三条总线,所有的外设都是挂载到这三条总线上,GPIO 属于高速的外设,挂载到 APB2 总线上,所以其时钟有 RCC_APB2ENR 控制。


GPIO 时钟控制


固件库函数:RCC_APB2PeriphClockCmd( RCC_APB2Periph_GPIOB, ENABLE)函数的原型为:


1 void RCC_APB2PeriphClockCmd(uint32_t RCC_APB2Periph,                               FunctionalState NewState)2 {3 /* Check the parameters */4 assert_param(IS_RCC_APB2_PERIPH(RCC_APB2Periph));5 assert_param(IS_FUNCTIONAL_STATE(NewState));6 if (NewState != DISABLE) {7 RCC->APB2ENR |= RCC_APB2Periph;8 } else {9 RCC->APB2ENR &= ~RCC_APB2Periph;10 }11 }复制代码
复制代码


当程序编译一次之后,把光标定位到函数/变量/宏定义处,按键盘的 F12 或鼠标右键的 Go to definition of,就可以找到原型。固件库的底层操作的就是 RCC 外设的 APB2ENR 这个寄存器,宏 RCC_APB2Periph_GPIOB 的原型是:0x00000008,即(1<<3),还原成存器操作就是:RCC->APB2ENR |= 1<<<3。相比固件库操作,寄存器操作的代码可读性就很差,只有才查阅寄存器配置才知道具体代码的功能,而固件库操作恰好相反,见名知意。


GPIO 端口配置


GPIO 的 pin,速度,模式,都由 GPIO 的端口配置寄存器来控制,其中 IO0IO7 由端口配置低寄存器 CRL 控制,IO8IO15 由端口配置高寄存器 CRH 配置。固件库把端口配置的 pin,速度和模式封装成一个结构体:


1 typedef struct {2 uint16_t GPIO_Pin;3 GPIOSpeed_TypeDef GPIO_Speed;4 GPIOMode_TypeDef GPIO_Mode;5 } GPIO_InitTypeDef;复制代码
复制代码


pin 可以是 GPIO_Pin_0~GPIO_Pin_15 或者是 GPIO_Pin_All,这些都是库预先定义好的宏。


speed 也被封装成一个结构体:


1 typedef enum {2 GPIO_Speed_10MHz = 1,3 GPIO_Speed_2MHz,4 GPIO_Speed_50MHz5 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;67// 设置引脚为推挽输出8 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;910 // 设置引脚速率为50MHz11 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_2MHz;1213 /*调用库函数,初始化GPIOB0*/14 GPIO_Init(GPIOB, &GPIO_InitStructure);复制代码
复制代码


倘若同一端口下不同引脚有不同的模式配置,每次对每个引脚配置完成后都要调用 GPIO 初始化函数,代码如下:


 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;  GPIO_InitStructure.GPIO_Pin = GPIO_Pin_15 ;                        GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;                  //上拉输入  GPIO_Init(GPIOB, &GPIO_InitStructure);  GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 ;                       GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;               //推挽输出  GPIO_InitStructure.GPIO_Speed = GPIO_Speed_2MHz;  GPIO_Init(GPIOB, &GPIO_InitStructure); 复制代码
复制代码


GPIO 输出控制


GPIO 输出控制,可以通过端口数据输出寄存器 ODR、端口位设置/清除寄存器 BSRR 和端口位清除寄存器 BRR 这三个来控制。端口输出寄存器 ODR 是一个 32 位的寄存器,低 16 位有效,对应着 IO0~IO15,只能以字的形式操作,一般使用寄存器操作。


// PB0 输出高电平,点亮LED  GPIOB->ODR = 1<<0;复制代码
复制代码


端口位清除寄存器 BRR 是一个 32 位的寄存器,低十六位有效,对应着 IO0~IO15,只能以字的形式操作,可以单独对某一个位操作,写 1 清 0。


// PB0 输出低电平,点亮LED  GPIO_ResetBits(GPIOB, GPIO_Pin_0);复制代码
复制代码


BSRR 是一个 32 位的寄存器,低 16 位用于置位,写 1 有效,高 16 位用于复位,写 1 有效,相当于 BRR 寄存器。高 16 位我们一般不用,而是操作 BRR 这个寄存器,所以 BSRR 这个寄存器一般用来置位操作。


// PB0 输出高电平,熄灭LED  GPIO_SetBits(GPIOB, GPIO_Pin_0);复制代码
复制代码


综上:固件库 LED GPIO 初始化函数


1 void LED_GPIO_Config(void)2 {3 // 定义一个GPIO_InitTypeDef 类型的结构体4 GPIO_InitTypeDef GPIO_InitStructure;56// 开启GPIOB 的时钟7 RCC_APB2PeriphClockCmd( RCC_APB2Periph_GPIOB, ENABLE);89// 选择要控制的IO 口10 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;1112 // 设置引脚为推挽输出13 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;1415 // 设置引脚速率为50MHz16 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;1718 /*调用库函数,初始化GPIOB0*/19 GPIO_Init(GPIOB, &GPIO_InitStructure);2021 // 关闭LED22 GPIO_SetBits(GPIOB, GPIO_Pin_0);23 }复制代码
复制代码


主函数


1 #include "stm32f10x.h"23void SOFT_Delay(__IO uint32_t nCount);4 void LED_GPIO_Config(void);56int main(void)7 {8 // 程序来到main 函数之前,启动文件:statup_stm32f10x_hd.s 已经调用9 // SystemInit()函数把系统时钟初始化成72MHZ10 // SystemInit()在system_stm32f10x.c 中定义11 // 如果用户想修改系统时钟,可自行编写程序修改1213 LED_GPIO_Config();1415 while ( 1 ) {16 // 点亮LED17 GPIO_ResetBits(GPIOB, GPIO_Pin_0);18 Time_Delay(0x0FFFFF);1920 // 熄灭LED21 GPIO_SetBits(GPIOB, GPIO_Pin_0);22 Time_Delay(0x0FFFFF);23 24 }25// 简陋的软件延时函数26 void Time_Delay(volatile uint32_t Count)27 {28 for (; Count != 0; Count--);29 }复制代码
复制代码


注意 void Time_Delay(volatile uint32_t Count)只是一个简陋的软件延时函数,如果小伙伴们有兴趣可以看一看 MultiTimer,它是一个软件定时器扩展模块,可无限扩展所需的定时器任务,取代传统的标志位判断方式, 更优雅更便捷地管理程序的时间触发时序。


本文分享自华为云社区《【嵌入式】层层递进,了解库开发》,原文作者:LongYorke。


点击关注,第一时间了解华为云新鲜技术~


发布于: 2021 年 01 月 11 日阅读数: 65
用户头像

提供全面深入的云计算技术干货 2020.07.14 加入

华为云开发者社区,提供全面深入的云计算前景分析、丰富的技术干货、程序样例,分享华为云前沿资讯动态,方便开发者快速成长与发展,欢迎提问、互动,多方位了解云计算! 传送门:https://bbs.huaweicloud.com/

评论

发布
暂无评论
STM32标准库开发实战指南