正点原子stm32mini全套讲解,正点原子STM32Mini板资料连载第四章
1)实验平台:正点原子stm32mini 开发板
2)摘自《正点原子STM32 不完全手册(HAL 库版)》关注官方微信号公众号,获取更多资料:正点原子
第四章 STM32F1 基础知识入门
这一章,我们将着重 STM32 开发的一些基础知识,让大家对 STM32 开发有一个初步的了
解,为后面 STM32 的学习做一个铺垫,方便后面的学习。这一章的内容大家第一次看的时候
可以只了解一个大概,后面需要用到这方面的知识的时候再回过头来仔细看看。这章我们分 7
个小结,
·4.1 MDK 下 C 语言基础复习
·4.2 STM32F1 系统架构
·4.3 STM32F103 时钟系统
·4.4 IO 引脚复用器和映射
·4.5 STM32F1 NVIC 中断优先级管理
·4.6 MDK 中寄存器地址名称映射分析
·4.7 MDK 固件库快速开发技巧
4.1 MDK 下 C 语言基础复习
这一节我们主要讲解一下 C 语言基础知识。C 语言知识博大精深,也不是我们三言两语能
讲解清楚,同时我们相信学 STM32F4 这种级别 MCU 的用户,C 语言基础应该都是没问题的。我
们这里主要是简单的复习一下几个 C 语言基础知识点,引导那些 C 语言基础知识不是很扎实的
用户能够快速开发 STM32 程序。同时希望这些用户能够多去复习一下 C 语言基础知识,C 语言
毕竟是单片机开发中的必备基础知识。对于 C 语言基础比较扎实的用户,这部分知识可以忽略
不看。
4.1.1 位操作
C 语言位操作相信学过 C 语言的人都不陌生了,简而言之,就是对基本类型变量可以在位级
别进行操作。这节的内容很多朋友都应该很熟练了,我这里也就点到为止,不深入探讨。下面
我们先讲解几种位操作符,然后讲解位操作使用技巧。
C 语言支持如下 6 种位操作
表 4.1.1 16 种位操作
这些与或非,取反,异或,右移,左移这些到底怎么回事,这里我们就不多做详细,相信
大家学 C 语言的时候都学习过了。如果不懂的话,可以百度一下,非常多的知识讲解这些操作
符。下面我们想着重讲解位操作在单片机开发中的一些实用技巧。
1) 不改变其他位的值的状况下,对某几个位进行设值。
这个场景单片机开发中经常使用,方法就是先对需要设置的位用&操作符进行清零操作,
然后用|操作符设值。比如我要改变 GPIOA->ODR 的状态,可以先对寄存器的值进行&清零
操作
GPIOA->ODR &=0XFF0F; //将第 4-7 位清 0
然后再与需要设置的值进行|或运算
GPIOA->ODR |=0X0040;
//设置相应位的值,不改变其他位的值
2) 移位操作提高代码的可读性。
移位操作在单片机开发中也非常重要,我们来看看下面一行代码
GPIOA->ODR| = 1 << 5;
这个操作就是将 ODR 寄存器的第 5 位设置为 1,为什么要通过左移而不是直接设置一个
固定的值呢?其实,这是为了提高代码的可读性以及可重用性。这行代码可以很直观
明了的知道,是将第 5 位设置为 1,其他位的值不变。如果你写成
GPIOA->ODR =0x0020;
这样的代码可读性非常差同时也不好重用。
3) ~取反操作使用技巧
例如 GPIOA->ODR 寄存器的每一位都用来设置一个 IO 口的输出状态,某个时刻我们
希望去设置某一位的值为 0,同时其他位都为 1,简单的作法是直接给寄存器设置一个值:
GPIOA->ODR =0xFFF7;
这样的作法设置第 3 位为 0,但是这样的写法可读性很差。看看如果我们使用取反操作怎
么实现:
GPIOA->ODR= (uint16_t)~(1<<3);
看这行代码应该很容易明白,我们设置的是 ODR 寄存器的第 3 位为 0,其他位为 1,可读性
非常强。
4.1.2 define 宏定义
define 是 C 语言中的预处理命令,它用于宏定义,可以提高源代码的可读性,为编程提供
方便。常见的格式:
#define 标识符 字符串
“标识符”为所定义的宏名。“字符串”可以是常数、表达式、格式串等。例如:
#define HSI_VALUE ((uint32_t)16000000)
定义标识符 HSI_VALUE 的值为 16000000。这样我们就可以在代码中直接使用标识符
HSI_VALUE,而不用直接使用常量 16000000,同时也很方便我们修改 HSI_VALUE 的值。
至于 define 宏定义的其他一些知识,比如宏定义带参数这里我们就不多讲解。
4.1.3# ifdef 和 #if defined 条件编译
单片机程序开发过程中,经常会遇到一种情况,当满足某条件时对一组语句进行编译,而
当条件不满足时则编译另一组语句。条件编译命令最常见的形式为:
#ifdef 标识符
程序段 1
#else
程序段 2
#endif
它的作用是:当标识符已经被定义过(一般是用#define 命令定义),则对程序段 1 进行编译,
否则编译程序段 2。 其中#else 部分也可以没有,即:
#ifdef
程序段 1
#endif
这个条件编译在 MDK 里面是用得很多的,在 stm32f4xx_hal_conf.h 这个头文件中会看到这样的
语句:#ifdef HAL_GPIO_MODULE_ENABLED
#include "stm32f1xx_hal_gpio.h"
#endif
这段代码的作用是判断宏定义标识符 HAL_GPIO_MODULE_ENABLED 是否被定义,如果被定
义了,那么就引入头文件 stm32f1xx_hal_gpio.h。
对于条件编译,还有个常用的格式,如下:
#if defined XXX1
程序段 1
#elif defined XXX2
程序段 2
…
#elif defined XXXn
程序段 n
…
#endif
这种写法的作用实际跟 ifdef 很相似,不同的是 ifdef 只能在两个选择中判断是否定义,
而 if defined 可以在多个选择中判断是否定义。
条件编译也是 c 语言的基础知识,这里就给大家讲解到这里,不懂的大家可以查看在网上
搜索相关资料学习。
4.1.4 extern 变量申明
C 语言中 extern 可以置于变量或者函数前,以表示变量或者函数的定义在别的文件中,提示
编译器遇到此变量和函数时在其他模块中寻找其定义。这里面要注意,对于 extern 申明变量可
以多次,但定义只有一次。在我们的代码中你会看到看到这样的语句:
extern u16 USART_RX_STA;
这个语句是申明 USART_RX_STA 变量在其他文件中已经定义了,在这里要使用到。所以,你肯定
可以找到在某个地方有变量定义的语句:
u16 USART_RX_STA;
的出现。下面通过一个例子说明一下使用方法。
在 Main.c 定义的全局变量 id,id 的初始化都是在 Main.c 里面进行的。
Main.c 文件
u8 id;//定义只允许一次
main()
{
id=1;
printf("d%",id);//id=1
test();
printf("d%",id);//id=2
}
但是我们希望在main.c的 changeId(void)函数中使用变量id,这个时候我们就需要在main.c
里面去申明变量 id 是外部定义的了,因为如果不申明,变量 id 的作用域是到不了 main.c 文件
中。看下面 main.c 中的代码:
extern u8 id;//申明变量 id 是在外部定义的,申明可以在很多个文件中进行void test(void){
id=2;
}
在 main.c 中申明变量 id 在外部定义,然后在 main.c 中就可以使用变量 id 了。
对于 extern 申明函数在外部定义的应用,这里我们就不多讲解了。
4.1.5 typedef 类型别名
typedef 用于为现有类型创建一个新的名字,或称为类型别名,用来简化变量的定义。
typedef 在 MDK 用得最多的就是定义结构体的类型别名和枚举类型了。
struct _GPIO
{
__IO uint32_t MODER;
__IO uint32_t OTYPER;
…
};
定义了一个结构体 GPIO,这样我们定义变量的方式为:
struct _GPIO GPIOA;//定义结构体变量 GPIOA
但是这样很繁琐,MDK 中有很多这样的结构体变量需要定义。这里我们可以为结体定义一个别
名 GPIO_TypeDef,这样我们就可以在其他地方通过别名 GPIO_TypeDef 来定义结构体变量了。
方法如下:
typedef struct
{
__IO uint32_t MODER;
__IO uint32_t OTYPER;
…
} GPIO_TypeDef;
Typedef 为结构体定义一个别名 GPIO_TypeDef,这样我们可以通过 GPIO_TypeDef 来定义结构体
变量:
GPIO_TypeDef _GPIOA,_GPIOB;
这里的 GPIO_TypeDef 就跟 struct _GPIO 是等同的作用了。 这样是不是方便很多?
4.1.6 结构体
经常很多用户提到,他们对结构体使用不是很熟悉,但是 MDK 中太多地方使用结构体以及
结构体指针,这让他们一下子摸不着头脑,学习 STM32 的积极性大大降低,其实结构体并不是
那么复杂,这里我们稍微提一下结构体的一些知识,还有一些知识我们会在下一节的“寄存器
地址名称映射分析”中讲到一些。
声明结构体类型:
Struct 结构体名{
成员列表;
}变量名列表;
例如:
Struct G_TYPE {
uint32_t Pin;uint32_t Mode;
uint32_t Speed;
}GPIOA,GPIOB;
在结构体申明的时候可以定义变量,也可以申明之后定义,方法是:
Struct 结构体名字 结构体变量列表 ;
例如:struct G_TYPE GPIOA,GPIOB;
结构体成员变量的引用方法是:
结构体变量名字.成员名
比如要引用 GPIOA 的成员 Mode,方法是:GPIOA. Mode;
结构体指针变量定义也是一样的,跟其他变量没有啥区别。
例如:struct G_TYPE *GPIOC;//定义结构体指针变量 GPIOC;
结构体指针成员变量引用方法是通过“->”符号实现,比如要访问 GPIOC 结构体指针指向的结
构体的成员变量 Speed,方法是:
GPIOC-> Speed;
上面讲解了结构体和结构体指针的一些知识,其他的什么初始化这里就不多讲解了。讲到这里,
有人会问,结构体到底有什么作用呢?为什么要使用结构体呢?下面我们将简单的通过一个实
例回答一下这个问题。
在我们单片机程序开发过程中,经常会遇到要初始化一个外设比如 IO 口。它的初始化状态
是由几个属性来决定的,比如模式,速度等。对于这种情况,在我们没有学习结构体的时候,
我们一般的方法是:
void HAL_GPIO_Init (uint32_t Pin, uint32_t Mode, uint32_t Speed);
这种方式是有效的同时在一定场合是可取的。但是试想,如果有一天,我们希望往这个函数里
面再传入一个参数,那么势必我们需要修改这个函数的定义,重新加入上下拉 Pull 这个入口参
数。于是我们的定义被修改为:
void HAL_GPIO_Init (uint32_t Pin, uint32_t Mode, uint32_t Speed,uint32_t Pull);
但是如果我们这个函数的入口参数是随着开发不断的增多,那么是不是我们就要不断的修改函
数的定义呢?这是不是给我们开发带来很多的麻烦?那又怎样解决这种情况呢?
这样如果我们使用到结构体就能解决这个问题了。我们可以在不改变入口参数的情况下,
只需要改变结构体的成员变量,就可以达到上面改变入口参数的目的。
结构体就是将多个变量组合为一个有机的整体。上面的函数中 Pin, Mode,
Speed 和 Pull 这些参数,他们对于 GPIO 而言,是一个有机整体,都是来设置 IO 口参数的,所
以我们可以将他们通过定义一个结构体来组合在一个。MDK 中是这样定义的:
typedef struct
{
uint32_t Pin;
uint32_t Mode;
uint32_t Pull;
uint32_t Speed;
}GPIO_InitTypeDef;
于是,我们在初始化 GPIO 口的时候入口参数就可以是 GPIO_InitTypeDef 类型的变量或者指针
变量了,MDK 中是这样做的:
void HAL_GPIO_Init(GPIO_TypeDef *GPIOx, GPIO_InitTypeDef *GPIO_Init);这样,任何时候,我们只需要修改结构体成员变量,往结构体中间加入新的成员变量,而不需
要修改函数定义就可以达到修改入口参数同样的目的了。这样的好处是不用修改任何函数定义
就可以达到增加变量的目的。
理解了结构体在这个例子中间的作用吗?在以后的开发过程中,如果你的变量定义过多,
如果某几个变量是用来描述某一个对象,你可以考虑将这些变量定义在结构体中,这样也许可
以提高你的代码的可读性。
使用结构体组合参数,可以提高代码的可读性,不会觉得变量定义混乱。当然结构体的作
用就远远不止这个了,同时,MDK 中用结构体来定义外设也不仅仅只是这个作用,这里我们只
是举一个例子,通过最常用的场景,让大家理解结构体的一个作用而已。后面一节我们还会讲
解结构体的一些其他知识。
4.2 STM32F1 系统架构
STM32 的系统架构比 51 单片机就要强大很多了。STM32 系统架构的知识可以在《STM32
中文参考手册 V10》的 P25~28 有讲解,这里我们也把这一部分知识抽取出来讲解,是为了大
家在学习 STM32 之前对系统架构有一个初步的了解。这里的内容基本也是从中文参考手册中
参考过来的,让大家能通过我们手册也了解到,免除了到处找资料的麻烦吧。如果需要详细深
入的了解 STM32 的系统架构,还需要在网上搜索其他资料学习学习。
我们这里所讲的 STM32 系统架构主要针对的 STM32F103 这些非互联型芯片。首先我们看
看 STM32 的系统架构图:
图 4.2.1STM32 系统架构图
STM32 主系统主要由四个驱动单元和四个被动单元构成。
四个驱动单元是:
内核 DCode 总线;
系统总线;
通用 DMA1;
通用 DMA2;
四被动单元是:
AHB 到 APB 的桥:连接所有的 APB 设备;
内部 FlASH 闪存;
内部 SRAM;
FSMC;
下面我们具体讲解一下图中几个总线的知识:
① ICode 总线:该总线将 M3 内核指令总线和闪存指令接口相连,指令的预取在该总线上
面完成。
② DCode 总线:该总线将 M3 内核的 DCode 总线与闪存存储器的数据接口相连接,常量
加载和调试访问在该总线上面完成。
③ 系统总线:该总线连接 M3 内核的系统总线到总线矩阵,总线矩阵协调内核和 DMA 间
访问。
④ DMA 总线:该总线将 DMA 的 AHB 主控接口与总线矩阵相连,总线矩阵协调 CPU 的
DCode 和 DMA 到 SRAM,闪存和外设的访问。
⑤ 总线矩阵:总线矩阵协调内核系统总线和 DMA 主控总线之间的访问仲裁,仲裁利用
轮换算法。
⑥ AHB/APB 桥:这两个桥在 AHB 和 2 个 APB 总线间提供同步连接,APB1 操作速度限于
36MHz,APB2 操作速度全速。
对于系统架构的知识,在刚开始学习 STM32 的时候只需要一个大概的了解,大致知道是个
什么情况即可。对于寻址之类的知识,这里就不做深入的讲解,中文参考手册都有很详细的讲
解。
4.3 STM32F103 时钟系统
STM32F1 时钟系统的知识在《STM32 中文参考手册 V10》第六章复位和时钟控制章节有非
常详细的讲解,网上关于时钟系统的讲解也基本都是参考的这里。这些知识也不是什么原创,
纯粹根据官方提供的中文参考手册和自己的应用心得来总结的,如有不合理之处望大家谅解。
这部分内容我们分 3 个小节来讲解:
·4.3.1 STM32F103 时钟树概述
·4.3.2 STM32F103 时钟初始化配置
·4.3.3 STM32F103 时钟使能和配置
4.3.1 STM32F103 时钟树概述
众所周知,时钟系统是 CPU 的脉搏,就像人的心跳一样。所以时钟系统的重要性就不言而
喻了。
STM32F103的时钟系统比较复杂,不像简单的51单片机一个系统时钟就可以解决一切。
于是有人要问,采用一个系统时钟不是很简单吗?为什么 STM32 要有多个时钟源呢? 因为首
先 STM32 本身非常复杂,外设非常的多,但是并不是所有外设都需要系统时钟这么高的频率,
比如看门狗以及 RTC 只需要几十 k 的时钟即可。同一个电路,时钟越快功耗越大,同时抗电磁
干扰能力也会越弱,所以对于较为复杂的 MCU 一般都是采取多时钟源的方法来解决这些问题。
首先让我们来看看 STM32F103 的时钟系统图:
图 4.3.1.1 STM32F103 时钟系统图
在 STM32 中,有五个时钟源,为 HSI、HSE、LSI、LSE、PLL。从时钟频率来分可以分为
高速时钟源和低速时钟源,在这 5 个中 HIS,HSE 以及 PLL 是高速时钟,LSI 和 LSE 是低速时
钟。从来源可分为外部时钟源和内部时钟源,外部时钟源就是从外部通过接晶振的方式获取时
钟源,其中 HSE 和 LSE 是外部时钟源,其他的是内部时钟源。下面我们看看 STM32 的 5 个时
钟源,我们讲解顺序是按图中红圈标示的顺序:
①、HSI 是高速内部时钟,RC 振荡器,频率为 8MHz。
②、HSE 是高速外部时钟,可接石英/陶瓷谐振器,或者接外部时钟源,频率范围为 4MHz~16MHz。
我们的开发板接的是 8M 的晶振。
③、LSI 是低速内部时钟,RC 振荡器,频率为 40kHz。独立看门狗的时钟源只能是 LSI,同
时 LSI 还可以作为 RTC 的时钟源。
④、LSE 是低速外部时钟,接频率为 32.768kHz 的石英晶体。这个主要是 RTC 的时钟源。
⑤、PLL 为锁相环倍频输出,其时钟输入源可选择为 HSI/2、HSE 或者 HSE/2。倍频可选择为
2~16 倍,但是其输出频率最大不得超过 72MHz。
上面我们简要概括了 STM32 的时钟源,那么这 5 个时钟源是怎么给各个外设以及系统提
供时钟的呢?这里我们将一一讲解。我们还是从图的下方讲解起吧,因为下方比较简单。
图中我们用 A~E 标示我们要讲解的地方。
A.
MCO 是 STM32 的一个时钟输出 IO(PA8),它可以选择一个时钟信号输出,可以
选择为 PLL 输出的 2 分频、HSI、HSE、或者系统时钟。这个时钟可以用来给外
部其他系统提供时钟源。
B.
这里是 RTC 时钟源,从图上可以看出,RTC 的时钟源可以选择 LSI,LSE,以及
HSE 的 128 分频。
C.
从图中可以看出 C 处 USB 的时钟是来自 PLL 时钟源。STM32 中有一个全速功能
的 USB 模块,其串行接口引擎需要一个频率为 48MHz 的时钟源。该时钟源只能
从 PLL 输出端获取,可以选择为 1.5 分频或者 1 分频,也就是,当需要使用 USB
模块时,PLL 必须使能,并且时钟频率配置为 48MHz 或 72MHz。
D.
D 处就是 STM32 的系统时钟 SYSCLK,它是供 STM32 中绝大部分部件工作的时
钟源。系统时钟可选择为 PLL 输出、HSI 或者 HSE。系统时钟最大频率为 72MHz,
当然你也可以超频,不过一般情况为了系统稳定性是没有必要冒风险去超频的。
E.
这里的 E 处是指其他所有外设了。从时钟图上可以看出,其他所有外设的时钟最
终来源都是 SYSCLK。SYSCLK 通过 AHB 分频器分频后送给各模块使用。这些模块包
括:
①、AHB 总线、内核、内存和 DMA 使用的 HCLK 时钟。
②、通过 8 分频后送给 Cortex 的系统定时器时钟,也就是 systick 了。
③、直接送给 Cortex 的空闲运行时钟 FCLK。
④、送给 APB1 分频器。APB1 分频器输出一路供 APB1 外设使用(PCLK1,最大
频率 36MHz),另一路送给定时器(Timer)2、3、4 倍频器使用。
⑤、送给 APB2 分频器。APB2 分频器分频输出一路供 APB2 外设使用(PCLK2,
最大频率 72MHz),另一路送给定时器(Timer)1 倍频器使用。
其中需要理解的是 APB1 和 APB2 的区别,APB1 上面连接的是低速外设,包括电源接口、
备份接口、CAN、USB、I2C1、I2C2、UART2、UART3 等等,APB2 上面连接的是高速外设包
括 UART1、SPI1、Timer1、ADC1、ADC2、所有普通 IO 口(PA~PE)、第二功能 IO 口等。居宁
老师的《稀里糊涂玩 STM32》资料里面教大家的记忆方法是 2>1, APB2 下面所挂的外设的时
钟要比 APB1 的高。
在以上的时钟输出中,有很多是带使能控制的,例如 AHB 总线时钟、内核时钟、各种 APB1
外设、APB2 外设等等。当需要使用某模块时,记得一定要先使能对应的时钟。后面我们讲解
实例的时候回讲解到时钟使能的方法。
4.3.2 STM32F103 时钟系统配置
上一小节我们对 STM32F103 时钟树进行了详细讲解,接下来我们来讲解通过 STM32F1 的
HAL 库进行 STM32F103 时钟系统配置步骤。实际上,STM32F1 的时钟系统配置也可以通过图
形化配置工具 STM32CubeMX 来配置生成,这里我们讲解初始化代码,是为了让大家对 STM32
时钟系统有更加清晰的理解。
前面我们讲解过,在系统启动之后,程序会先执行 HAL 库定义的 SystemInit 函数,进行系
统一些初始化配置。那么我们先来看看 SystemInit 程序:
void SystemInit (void)
{
/* 将 RCC 时钟配置重置为默认重置状态(用于调试)*/
RCC->CR |= (uint32_t)0x00000001; //打开 HSION 位
/* 设置 SW, HPRE, PPRE1, PPRE2, ADCPRE 和 MCO 位 */
#if !defined(STM32F105xC) && !defined(STM32F107xC)
RCC->CFGR &= (uint32_t)0xF8FF0000;
#else
RCC->CFGR &= (uint32_t)0xF0FF0000;
#endif /* STM32F105xC */
RCC->CR &= (uint32_t)0xFEF6FFFF; // 复位 HSEON, CSSON 和 PLLON 位
RCC->CR &= (uint32_t)0xFFFBFFFF; // 复位 HSEBYP 位
RCC->CFGR &= (uint32_t)0xFF80FFFF; //复位 CFGR 寄存器
#if defined(STM32F105xC) || defined(STM32F107xC)
RCC->CR &= (uint32_t)0xEBFFFFFF; // 复位 PLL2ON 和 PLL3ON 位
RCC->CIR = 0x00FF0000; // 禁用所有中断并清除挂起位
RCC->CFGR2 = 0x00000000; // 重置 CFGR2 注册
#elif defined(STM32F100xB) || defined(STM32F100xE)
RCC->CIR = 0x009F0000; // 禁用所有中断并清除挂起位
RCC->CFGR2 = 0x00000000; // 重置 CFGR2 注册
#else
RCC->CIR = 0x009F0000; // 禁用所有中断并清除挂起位
#endif /* STM32F105xC */
#if defined(STM32F100xE) || defined(STM32F101xE) || defined(STM32F101xG) ||
defined(STM32F103xE) || defined(STM32F103xG)
#ifdef DATA_IN_ExtSRAM
SystemInit_ExtMemCtl();
#endif /* DATA_IN_ExtSRAM */
#endif
/* 配置中断向量表地址=基地址 偏移地址 ------------------*
#ifdef VECT_TAB_SRAM
SCB->VTOR = SRAM_BASE | VECT_TAB_OFFSET; //内部 SRAM 中的向量表重定位
#else
SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET; //在内部 FLASH 中的向量表重定位
#endif
}从上面代码可以看出,SystemInit 主要做了如下三个方面工作:
1) 复位 RCC 时钟配置为默认复位值(默认开始了 HIS)
2) 外部存储器配置
3) 中断向量表地址配置
HAL 库的 SystemInit 函数并没有像标准库的 SystemInit 函数一样进行时钟的初始化配置。HAL
库的 SystemInit 函数除了打开 HSI 之外,没有任何时钟相关配置,所以使用 HAL 库我们必须编
写自己的时钟配置函数。首先我们打开工程模板看看我们在工程 SYSTEM 分组下面定义的 sys.c
文件中的时钟初始化函数 Stm32_Clock_Init 的内容:
//时钟系统配置函数
//PLL:选择的倍频数,RCC_PLL_MUL2~RCC_PLL_MUL16
//返回值:0,成功;1,失败
void Stm32_Clock_Init(u32 PLL)
{
HAL_StatusTypeDef ret = HAL_OK;
RCC_OscInitTypeDef RCC_OscInitStructure;
RCC_ClkInitTypeDef RCC_ClkInitStructure;
RCC_OscInitStructure.OscillatorType=RCC_OSCILLATORTYPE_HSE; //时钟源为 HSE
RCC_OscInitStructure.HSEState=RCC_HSE_ON; //打开 HSE
RCC_OscInitStructure.HSEPredivValue=RCC_HSE_PREDIV_DIV1; //HSE 预分频
RCC_OscInitStructure.PLL.PLLState=RCC_PLL_ON;
//打开 PLL
RCC_OscInitStructure.PLL.PLLSource=RCC_PLLSOURCE_HSE;
//PLL 时钟源选择 HSE
RCC_OscInitStructure.PLL.PLLMUL=PLL;
//主 PLL 倍频因子
ret=HAL_RCC_OscConfig(&RCC_OscInitStructure);//初始化
if(ret!=HAL_OK) while(1);
//选中 PLL 作为系统时钟源并且配置 HCLK,PCLK1 和 PCLK2
RCC_ClkInitStructure.ClockType=(RCC_CLOCKTYPE_SYSCLK|
RCC_CLOCKTYPE_HCLK|RCC_CLOCKTYPE_PCLK1|
RCC_CLOCKTYPE_PCLK2);
//设置系统时钟时钟源为 PLL
RCC_ClkInitStructure.SYSCLKSource=RCC_SYSCLKSOURCE_PLLCLK;
RCC_ClkInitStructure.AHBCLKDivider=RCC_SYSCLK_DIV1;
//AHB 分频系数为 1
RCC_ClkInitStructure.APB1CLKDivider=RCC_HCLK_DIV2;
//APB1 分频系数为 2
RCC_ClkInitStructure.APB2CLKDivider=RCC_HCLK_DIV1;
//APB2 分频系数为 1
ret=HAL_RCC_ClockConfig(&RCC_ClkInitStructure,FLASH_LATENCY_2);
//同时设置 FLASH 延时周期为 2WS,也就是 3 个 CPU 周期。
if(ret!=HAL_OK) while(1);
}
从函数注释可知,函数 Stm32_Clock_Init 的作用是进行时钟系统配置,除了配置 PLL 相关
参数确定 SYSCLK 值之外,还配置了 AHB,APB1 和 APB2 的分频系数,也就是确定了 HCLK,
PCLK1 和 PCLK2 的时钟值。
接下来我们看看结构体 RCC_OscInitTypeDef 的定义:
typedef struct
{
uint32_t OscillatorType; //需要选择配置的振荡器类型
uint32_t HSEState; //HSE 状态
uint32_t HSEPredivValue; // Prediv1 值
uint32_t LSEState; //LSE 状态
uint32_t HSIState; //HIS 状态
uint32_t HSICalibrationValue; //HIS 校准值
uint32_t LSIState;
//LSI 状态
RCC_PLLInitTypeDef PLL; //PLL 配置
}RCC_OscInitTypeDef;
对于这个结构体,前面几个参数主要是用来选择配置的振荡器类型。比如我们要开启 HSE,
那么我们会设置 OscillatorType 的值为 RCC_OSCILLATORTYPE_HSE,然后设置 HSEState 的值
为 RCC_HSE_ON 开启 HSE。对于其他时钟源 HSI,LSI 和 LSE,配置方法类似。这个结构体还
有一个很重要的成员变量是 PLL,它是结构体 RCC_PLLInitTypeDef 类型。它的作用是配置 PLL
相关参数,我们来看看它的定义:
typedef struct
{
uint32_t PLLState; //PLL 状态
uint32_t PLLSource; //PLL 时钟源
uint32_t PLLMUL; //PLL VCO 输入时钟的乘法因子
}RCC_PLLInitTypeDef;
从 RCC_PLLInitTypeDef;结构体的定义很容易看出该结构体主要用来设置 PLL 时钟源以及
相关分频倍频参数。
这个结构体的定义我们就不做过多讲解,接下来我们看看我们的时钟初始化函数
Stm32_Clock_Init 中的配置内容:
RCC_OscInitStructure.OscillatorType=RCC_OSCILLATORTYPE_HSE; //时钟源为 HSE
RCC_OscInitStructure.HSEState=RCC_HSE_ON; //打开 HSE
RCC_OscInitStructure.HSEPredivValue=RCC_HSE_PREDIV_DIV1; //HSE 预分频
RCC_OscInitStructure.PLL.PLLState=RCC_PLL_ON;
//打开 PLL
RCC_OscInitStructure.PLL.PLLSource=RCC_PLLSOURCE_HSE;
//PLL 时钟源选择 HSE
RCC_OscInitStructure.PLL.PLLMUL=PLL;
//主 PLL 倍频因子
ret=HAL_RCC_OscConfig(&RCC_OscInitStructure);//初始化
通过该段函数,我们开启了 HSE 时钟源,同时选择 PLL 时钟源为 HSE,然后把
Stm32_Clock_Init 的唯一的入口参数直接设置作为 PLL 的倍频因子。设置好 PLL 时钟源参数之
后,也就是确定了 PLL 的时钟频率,接下来我们就需要设置系统时钟,以及 AHB,APB1 和
APB2 相关参数。
接下来我们来看看步骤 5 中提到的 HAL_RCC_ClockConfig()函数,声明如下:
HAL_StatusTypeDef HAL_RCC_ClockConfig(RCC_ClkInitTypeDef *RCC_ClkInitStruct,
uint32_t FLatency);
该函数有两个入口参数,第一个入口参数 RCC_ClkInitStruct 是结构体 RCC_ClkInitTypeDef
指针类型,用来设置 SYSCLK 时钟源以及 AHB,APB1 和 APB2 的分频系数。第二个入口参数
FLatency 用来设置 FLASH 延迟,这个参数我们放在后面讲解。
RCC_ClkInitTypeDef 结构体类型定义非常简单,这里我们就不列出来,我们来看看
Stm32_Clock_Init 函数中的配置内容:
//选中 PLL 作为系统时钟源并且配置 HCLK,PCLK1 和 PCLK2
RCC_ClkInitStructure.ClockType=(RCC_CLOCKTYPE_SYSCLK|
RCC_CLOCKTYPE_HCLK|RCC_CLOCKTYPE_PCLK1|
RCC_CLOCKTYPE_PCLK2);
//设置系统时钟时钟源为 PLL
RCC_ClkInitStructure.SYSCLKSource=RCC_SYSCLKSOURCE_PLLCLK;
RCC_ClkInitStructure.AHBCLKDivider=RCC_SYSCLK_DIV1;
//AHB 分频系数为 1
RCC_ClkInitStructure.APB1CLKDivider=RCC_HCLK_DIV2;
//APB1 分频系数为 2
RCC_ClkInitStructure.APB2CLKDivider=RCC_HCLK_DIV1;
//APB2 分频系数为 1
ret=HAL_RCC_ClockConfig(&RCC_ClkInitStructure,FLASH_LATENCY_2);
//同时设置 FLASH 延时周期为 2WS,也就是 3 个 CPU 周期。
第一个参数 ClockType 配置说明我们要配置的是 SYSCLK,HCLK,PCLK1 和 PCLK2 四个时钟。
第二个参数 SYSCLKSource 配置选择系统时钟源为 PLL。
第三个参数 AHBCLKDivider 配置 AHB 分频系数为 1。
第四个参数 APB1CLKDivider 配置 APB1 分频系数为 2。
第五个参数 APB2CLKDivider 配置 APB2 分频系数为 1。
根据我们在主函数中调用 Stm32_Clock_Init(RCC_PLL_MUL9)时候设置的入口参数值,我
们可以计算出,PLL 时钟为 PLLCLK=HSE*9 =8MHz*9=72MHz,同时我们选择系统时钟源为
PLL , 所 以 系 统 时 钟 SYSCLK=72MHz 。 AHB 分频系数为 1 ,故其频率为
HCLK=SYSCLK/1=72MHz。APB1 分频系数为 2,故其频率为 PCLK1=HCLK/2=36MHz。APB2
分频系数为 1,故其频率为 PCLK2=HCLK/1=72/1=72MHz。最后我们总结一下通过调用函数
Stm32_Clock_Init(RCC_PLL_MUL9)之后的关键时钟频率值:
SYSCLK(系统时钟)
=72MHz
PLL 主时钟
=72MHz
AHB 总线时钟(HCLK=SYSCLK/1)
=72MHz
APB1 总线时钟(PCLK1=HCLK/2)
=36MHz
APB2 总线时钟(PCLK2=HCLK/1)
=72MHz
4.3.3 STM32F1 时钟使能和配置
上一节我们讲解了时钟系统配置步骤。在配置好时钟系统之后,如果我们要使用某些外设,
例如 GPIO,ADC 等,我们还要使能这些外设时钟。这里大家必须注意,如果在使用外设之前
没有使能外设时钟,这个外设是不可能正常运行的。STM32 的外设时钟使能是在 RCC 相关寄
存器中配置的。因为 RCC 相关寄存器非常多,有兴趣的同学可以直接打开《STM32 中文参考手
册 V10》6.3 小节查看所有 RCC 相关寄存器的配置。接下来我们来讲解通过 STM32F1 的 HAL
库使能外设时钟的方法。
在 STM32F1 的 HAL 库中,外设时钟使能操作都是在 RCC 相关固件库文件头文件
stm32f1xx_hal_rcc.h 定义的。大家打开 stm32f1xx_hal_rcc.h 头文件可以看到文件中除了少数几
个函数声明之外大部分都是宏定义标识符。外设时钟使能在 HAL 库中都是通过宏定义标识符
来实现的。首先,我们来看看 GPIOA 的外设时钟使能宏定义标识符:
#define __HAL_RCC_GPIOA_CLK_ENABLE() do { \
__IO uint32_t tmpreg; \
SET_BIT(RCC->APB2ENR, RCC_APB2ENR_IOPAEN);\
tmpreg = READ_BIT(RCC->APB2ENR, RCC_APB2ENR_IOPAEN);\
UNUSED(tmpreg); \
} while(0U))
这几行代码非常简单,主要是定义了一个宏定义标识符__HAL_RCC_GPIOA_CLK_ENABLE(),
它的核心操作是通过下面这行代码实现的:
SET_BIT(RCC->APB2ENR, RCC_APB2ENR_IOPAEN);
这行代码的作用是,设置寄存器 RCC_APB2ENR 的相关位为 1,至于是哪个位,是由宏定义标
识符 RCC_APB2ENR_IOPAEN 的值决定的,而它的值为:
#define RCC_APB2ENR_IOPAEN ((uint32_t)0x00000001)
所以,我们很容易理解上面代码的作用是设置寄存器 RCC->APB2ENR 寄存器的位 2 为 1。我
们可以从 STM32F1 的中文参考手册中搜索 APB2ENR 寄存器定义,位 2 的作用是用来使用
GPIOA 时钟。APB2ENR 寄存器的位 2 描述如下:
位 2
IOPAEN:IO 端口 A 时钟使能
由软件置 1 和清零
0:禁止 IO 端口 A 时钟
1:使能 IO 端口 A 时钟
那么我们只需要在我们的用户程序中调用宏定义标识符__HAL_RCC_GPIOA_CLK_ENABLE()
就可以实现 GPIOA 时钟使能。使用方法为:
__HAL_RCC_GPIOA_CLK_ENABLE();//使能 GPIOA 时钟
对于其他外设,同样都是在 stm32f1xx_hal_rcc.h 头文件中定义,大家只需要找到相关宏定义标
识符即可,这里我们列出几个常用使能外设时钟的宏定义标识符使用方法:
__HAL_RCC_DMA1_CLK_ENABLE();//使能 DMA1 时钟
__HAL_RCC_USART2_CLK_ENABLE();//使能串口 2 时钟
__HAL_RCC_TIM1_CLK_ENABLE();//使能 TIM1 时钟
我们使用外设的时候需要使能外设时钟,如果我们不需要使用某个外设,同样我们可以禁
止某个外设时钟。禁止外设时钟使用方法和使能外设时钟非常类似,同样是头文件中定义的宏
定义标识符。我们同样以 GPIOA 为例,宏定义标识符为:
#define __HAL_RCC_GPIOA_CLK_DISABLE() \
(RCC->APB2ENR &= ~(RCC_APB2ENR_IOPAEN))
同样,宏定义标识符__HAL_RCC_GPIOA_CLK_DISABLE()的作用是设置 RCC->APB2ENR 寄
存器的位 2 为 0,也就是禁止 GPIOA 时钟。具体使用方法我们这里就不做过多讲解,我们这里
同样列出几个常用的禁止外设时钟的宏定义标识符使用方法:
__HAL_RCC_DMA1_CLK_DISABLE();//禁止 DMA1 时钟
__HAL_RCC_USART2_CLK_DISABLE();//禁止串口 2 时钟
__HAL_RCC_TIM1_CLK_DISABLE();//禁止 TIM1 时钟
关于 STM32F1 的外设时钟使能和禁止方法我们就给大家讲解到这里。
4.4 端口复用和重映射
STM32F1 有很多的内置外设,这些外设的外部引脚都是与 GPIO 复用的。也就是说,一个 GPIO
如果可以复用为内置外设的功能引脚,那么当这个 GPIO 作为内置外设使用的时候,就叫做复用。
这部分知识在《STM32 中文参考手册 V10》的 P109,P116~P121 有详细的讲解哪些 GPIO 管脚是
可以复用为哪些内置外设的。这里我们就不一一讲解。
大家都知道,MCU 都有串口,STM32 有好几个串口。比如说 STM32F103RCT6 有 5 个串口,我
们可以查手册知道,串口 1 的引脚对应的 IO 为 PA9,PA10.PA9,PA10 默认功能是 GPIO,所以当
PA9,PA10 引脚作为串口 1 的 TX,RX 引脚使用的时候,那就是端口复用。
图 4.4.1.1 串口 1 复用管脚
接下来我们以串口 1 为例来讲解配置 GPOPA.9,GPIOA.10 口为串口 1 复用功能的一般步骤。
① 首先,我们要使用 IO 复用功能,必须先打开对应的 IO 时钟和复用功能外设时钟,这里
我们使用了 GPIOA 以及 USART1,所以我们需要使能 GPIOA 和 USART1 时钟。方法如下:
__HAL_RCC_GPIOA_CLK_ENABLE();
//使能 GPIOA 时钟
__HAL_RCC_USART1_CLK_ENABLE();
//使能 USART1 时钟
__HAL_RCC_AFIO_CLK_ENABLE(); //使能辅助功能 IO 时钟
② 然后,我们在 GIPOx_MODER 寄存器中将所需 IO(对于串口 1 是 PA9,PA10)配置为复
用功能。
③ 最后,我们还需要对 IO 口的其他参数,例如上拉/下拉以及输出速度等进行配置。
上面三步,在我们 HAL 库中是通过 HAL_GPIO_Init 函数来实现的,参考代码如下:
GPIO_InitTypeDef GPIO_Initure;
GPIO_Initure.Pin=GPIO_PIN_9;
//PA9
GPIO_Initure.Mode=GPIO_MODE_AF_PP; //复用推挽输出
GPIO_Initure.Pull=GPIO_PULLUP;
//上拉
GPIO_Initure.Speed=GPIO_SPEED_FREQ_HIGH;//高速
HAL_GPIO_Init(GPIOA,&GPIO_Initure); //初始化 PA9
通过上面的配置,PA9 复用为串口 1 的发送引脚。这个时候,PA9 将不再作为普通的 IO 口
使用。对于 PA10,配置方法一样,修改 Pin 成员变量值为 PIN_10 即可。
STM32F1 的端口复用和映射就给大家讲解到这里,希望大家课余结合相关实验工程和手册
巩固本小节知识。
4.5 STM32 NVIC 中断优先级管理
CM3 内核支持 256 个中断,其中包含了 16 个内核中断和 240 个外部中断,并且具有 256
级的可编程中断设置。但 STM32 并没有使用 CM3 内核的全部东西,而是只用了它的一部分。
STM32 有 84 个中断,包括 16 个内核中断和 68 个可屏蔽中断,具有 16 级可编程的中断优先级。
而我们常用的就是这 68 个可屏蔽中断,但是 STM32 的 68 个可屏蔽中断,在 STM32F103 系列
上面,又只有 60 个(在 107 系列才有 68 个)。因为我们开发板选择的芯片是 STM32F103 系列
的所以我们就只针对 STM32F103 系列这 60 个可屏蔽中断进行介绍。
在 MDK 内,与 NVIC 相关的寄存器,MDK 为其定义了如下的结构体:
typedef struct
{
__IOM uint32_t ISER[8U];
uint32_t RESERVED0[24U];
__IOM uint32_t ICER[8U];
uint32_t RSERVED1[24U];
__IOM uint32_t ISPR[8U];
uint32_t RESERVED2[24U];
__IOM uint32_t ICPR[8U];
uint32_t RESERVED3[24U];
__IOM uint32_t IABR[8U];
uint32_t RESERVED4[56U];
__IOM uint8_t IP[240U];
uint32_t RESERVED5[644U];
__OM uint32_t STIR;
} NVIC_Type;;
STM32 的中断在这些寄存器的控制下有序的执行的。只有了解这些中断寄存器,才能方便
的使用 STM32 的中断。下面重点介绍这几个寄存器:
ISER[8]:ISER 全称是:Interrupt Set-Enable Registers,这是一个中断使能寄存器组。上面
说了 CM3 内核支持 256 个中断,这里用 8 个 32 位寄存器来控制,每个位控制一个中断。但是
STM32F103 的可屏蔽中断只有 60 个,所以对我们来说,有用的就是两个(ISER[0]和 ISER[1]),
总共可以表示 64 个中断。而 STM32F103 只用了其中的前 60 位。ISER[0]的 bit0~bit31 分别对
应中断 0~31。ISER[1]的 bit0~27 对应中断 32~59;这样总共 60 个中断就分别对应上了。你要
使能某个中断,必须设置相应的 ISER 位为 1,使该中断被使能(这里仅仅是使能,还要配合中
断分组、屏蔽、IO 口映射等设置才算是一个完整的中断设置)。具体每一位对应哪个中断,请
参考 stm32f10x.h 里面的第 140 行处(针对编译器 MDK5 来说)。
ICER[8]:全称是:Interrupt Clear-Enable Registers,是一个中断除能寄存器组。该寄存器组
与 ISER 的作用恰好相反,是用来清除某个中断的使能的。其对应位的功能,也和 ICER 一样。
这里要专门设置一个 ICER 来清除中断位,而不是向 ISER 写 0 来清除,是因为 NVIC 的这些寄
存器都是写 1 有效的,写 0 是无效的。具体为什么这么设计,请看《CM3 权威指南》第 125 页,
NVIC 概览一章。
ISPR[8]:全称是:Interrupt Set-Pending Registers,是一个中断挂起控制寄存器组。每个位
对应的中断和 ISER 是一样的。通过置 1,可以将正在进行的中断挂起,而执行同级或更高级别
的中断。写 0 是无效的。
ICPR[8]:全称是:Interrupt Clear-Pending Registers,是一个中断解挂控制寄存器组。其作
用与 ISPR 相反,对应位也和 ISER 是一样的。通过设置 1,可以将挂起的中断接挂。写 0 无效。
IABR[8]:全称是:Interrupt Active Bit Registers,是一个中断激活标志位寄存器组。对应位
所代表的中断和 ISER 一样,如果为 1,则表示该位所对应的中断正在被执行。这是一个只读寄
存器,通过它可以知道当前在执行的中断是哪一个。在中断执行完了由硬件自动清零。
IP[240]:全称是:Interrupt Priority Registers,是一个中断优先级控制的寄存器组。这个寄
存器组相当重要!STM32 的中断分组与这个寄存器组密切相关。IP 寄存器组由 240 个 8bit 的寄
存器组成,每个可屏蔽中断占用 8bit,这样总共可以表示 240 个可屏蔽中断。而 STM32 只用到
了其中的前 60 个。IP[59]~IP[0]分别对应中断 59~0。而每个可屏蔽中断占用的 8bit 并没有全部
使用,而是 只用了高 4 位。这 4 位,又分为抢占优先级和子优先级。抢占优先级在前,子优先
级在后。而这两个优先级各占几个位又要根据 SCB->AIRCR 中的中断分组设置来决定。
这里简单介绍一下 STM32 的中断分组:STM32 将中断分为 5 个组,组 0~4。该分组的设
置是由 SCB->AIRCR 寄存器的 bit10~8 来定义的。具体的分配关系如表 4.5.1 所示:
表 4.5.1 AIRCR 中断分组设置表
通过这个表,我们就可以清楚的看到组 0~4 对应的配置关系,例如组设置为 3,那么此时
所有的 60 个中断,每个中断的中断优先寄存器的高四位中的最高 3 位是抢占优先级,低 1 位是响应优先级。每个中断,你可以设置抢占优先级为 0~7,响应优先级为 1 或 0。抢占优先级的
级别高于响应优先级。而数值越小所代表的优先级就越高。
这里需要注意两点:第一,如果两个中断的抢占优先级和响应优先级都是一样的话,则看
哪个中断先发生就先执行;第二,高优先级的抢占优先级是可以打断正在进行的低抢占优先级
中断的。而抢占优先级相同的中断,高优先级的响应优先级不可以打断低响应优先级的中断。
结合实例说明一下:假定设置中断优先级组为 2,然后设置中断 3(RTC 中断)的抢占优先级
为 2,响应优先级为 1。中断 6(外部中断 0)的抢占优先级为 3,响应优先级为 0。中断 7(外
部中断 1)的抢占优先级为 2,响应优先级为 0。那么这 3 个中断的优先级顺序为:中断 7>中
断 3>中断 6。
上面例子中的中断 3 和中断 7 都可以打断中断 6 的中断。而中断 7 和中断 3 却不可以相互
打断!
通过以上介绍,我们熟悉了 STM32F103 中断设置的大致过程。接下来我们介绍如何使用
HAL 库实现以上中断分组设置以及中断优先级管理,使中断配置简单化。NVIC 中断管理相关
函数主要在 HAL 库关键文件 stm32f1xx_hal_cortex.c 中定义。
首先要讲解的是中断优先级分组函数 HAL_NVIC_SetPriorityGrouping,其函数申明如下:
void HAL_NVIC_SetPriorityGrouping(uint32_t PriorityGroup);
这个函数的作用是对中断的优先级进行分组,这个函数在系统中只需要被调用一次,一旦
分组确定就最好不要更改,否则容易造成程序分组混乱。这个函数我们可以找到其函数体内容
如下:
void HAL_NVIC_SetPriorityGrouping(uint32_t PriorityGroup)
{
/* Check the parameters */
assert_param(IS_NVIC_PRIORITY_GROUP(PriorityGroup));
/* Set the PRIGROUP[10:8] bits according to the PriorityGroup parameter value */
NVIC_SetPriorityGrouping(PriorityGroup);
}
从函数体以及注释可以看出,这个函数是通过调用函数 NVIC_SetPriorityGrouping 来进行中断
优先级分组设置。通过查找(参考 3.5.3 小节 MDK 中“Go to definition of”的使用方法),我们可
以知道函数 NVIC_SetPriorityGrouping 是在文件 core_cm3.h 头文件中定义的。接下来,我们来
分析一下函数 NVIC_SetPriorityGrouping 函数定义。定义如下:
__STATIC_INLINE void NVIC_SetPriorityGrouping(uint32_t PriorityGroup)
{
uint32_t reg_value;
uint32_t PriorityGroupTmp = (PriorityGroup & (uint32_t)0x07UL);
reg_value= SCB->AIRCR; /* read old register configuration */
reg_value&=~((uint32_t)(SCB_AIRCR_VECTKEY_Msk |SCB_AIRCR_PRIGROUP_Msk));
reg_value = (reg_value|((uint32_t)0x5FAUL << SCB_AIRCR_VECTKEY_Pos) |
(PriorityGroupTmp<< SCB_AIRCR_PRIGROUP_Pos) );
SCB->AIRCR = reg_value;
}
从函数内容可以看出,这个函数主要作用是通过设置 SCB->AIRCR 寄存器的值来设置中断优先
级分组,这在前面寄存器讲解的过程中已经讲到。
关于函数 HAL_NVIC_SetPriorityGrouping 的函数体内容解读我就给大家介绍到这里。接下
来我们来看看这个函数的入口参数。大家继续回到函数 HAL_NVIC_SetPriorityGrouping 的定义
可以看到,函数的最开头有这样一行函数:
assert_param(IS_NVIC_PRIORITY_GROUP(PriorityGroup));
其中函数 assert_param 是断言函数,它的作用主要是对入口参数的有效性进行判断。也就是说
我们可以通过这个函数知道入口参数在哪些范围内是有效的。而其入口参数通过在 MDK 中双
击选中 “IS_NVIC_PRIORITY_GROUP”,然后右键“Go to defition of …”可以查看到为:
#define IS_NVIC_PRIORITY_GROUP(GROUP)
(((GROUP) == NVIC_PriorityGroup_0) ||\
((GROUP) == NVIC_PriorityGroup_1) || \
((GROUP) == NVIC_PriorityGroup_2) || \
((GROUP) == NVIC_PriorityGroup_3) || \
((GROUP) == NVIC_PriorityGroup_4))
从这个内容可以看出,当 GROUP 的值为 NVIC_PriorityGroup_0~ NVIC_PriorityGroup_4 的时候,
IS_NVIC_PRIORITY_GROUP 的值才为真。这也就是我们上面表 4.5.1 讲解的,分组范围为 0-4,
对应的入口参数为宏定义值 NVIC_PriorityGroup_0~ NVIC_PriorityGroup_4。比如我们设置整个
系统的中断优先级分组值为 2,那么方法是:
HAL_NVIC_SetPriorityGrouping (NVIC_PriorityGroup_2);
这样就确定了中断优先级分组为 2,也就是 2 位抢占优先级,2 位响应优先级,抢占优先级和响
应优先级的值的范围均为 0-3。
讲到这里,大家对怎么进行系统的中断优先级分组设置,以及具体的中断优先级设置函数
HAL_NVIC_SetPriorityGrouping 的内部函数实现都有了一个详细的理解。接下来我们来看看在
HAL 库里面,是怎样调用 HAL_NVIC_SetPriorityGrouping 函数进行分组设置的。
打开 stm32f1xx_hal.c 文件可以看到,文件内部定义了 HAL 库初始化函数 HAL_Init,这个
函数非常重要,其作用主要是对中断优先级分组,FLASH 以及硬件层进行初始化,我们在 3.1
小节对其进行了比较详细的讲解。这里我们只需要知道,在系统主函数 main 开头部分,我们都
会首先调用 HAL_Init 函数进行一些初始化操作。在 HAL_Init 内部,有如下一行代码:
HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4);
这行代码的作用是把系统中断优先级分组设置为分组 4,这在我们前面已经详细讲解。也
就是说,在主函数中调用 HAL_Init 函数之后,在 HAL_Init 函数内部会通过调用我们前面讲解
的 HAL_NVIC_SetPriorityGrouping 函数来进行系统中断优先级分组设置。所以,我们要进行中
断优先级分组设置,只需要修改 HAL_Init 函数内部的这行代码即可。中断优先级分组的内容我
们就给大家讲解到这里。
设置好了系统中断分组,也就是确定了那么对于每个中断我们又怎么确定他的抢占优先级
和响应优先级呢?官方 HAL 库文件 stm32f1xx_hal_cortex.c 中定义了三个单个中断优先级设置
函数。函数如下:
void HAL_NVIC_SetPriority(IRQn_Type IRQn,
uint32_t PreemptPriority, uint32_t SubPriority);
void HAL_NVIC_EnableIRQ(IRQn_Type IRQn);
void HAL_NVIC_DisableIRQ(IRQn_Type IRQn);
第一个函数 HAL_NVIC_SetPriority 是用来设置单个优先级的抢占优先级和响应优先级的值。
第二个函数 HAL_NVIC_EnableIRQ 是用来使能某个中断通道。
第三个函数 HAL_NVIC_DisableIRQ 是用来清除某个中断使能的,也就是中断失能。
这三个函数的使用都非常简单,对于具体的调用方法,大家可以参考我们后面第九章外部中断
实验讲解。
这里大家还需要注意,中断优先级分组和中断优先级设置是两个不同的概念。中断优先级
分组是用来设置整个系统对于中断分组设置为哪个分组,分组号为 0-4,设置函数为
HAL_NVIC_SetPriorityGrouping,确定了中断优先级分组号,也就确定了系统对于单个中断的
抢占优先级和响应优先级设置各占几个位(对应表 4.5.1)。设置好中断优先级分组,确定了分
组号之后,接下来我们就是要对单个优先级进行中断优先级设置。也就是这个中断的抢占优先
级和响应优先级的值,设置方法就是我们上面讲解的三个函数。
最后我们总结一下中断优先级设置的步骤:
①系统运行开始的时候设置中断分组。确定组号,也就是确定抢占优先级和响应优先级的
分配位数。设置函数为 HAL_NVIC_PriorityGroupConfig。对于 HAL 库,在文件 stm32f1xx_hal.c
内部定义函数 HAL_Init 中有调用 HAL_NVIC_PriorityGroupConfig 函数进行相关设置,所以我
们只需要修改 HAL_Init 内部对中断优先级分组设置即可。
② 设置单个中断的中断优先级别和使能相应中断通道,使用到的函数函数主要为函数
HAL_NVIC_SetPriority 和函数 HAL_NVIC_EnableIRQ。
4.6 HAL 库中寄存器地址名称映射分析
之所以要讲解这部分知识,是因为经常会遇到客户提到不明白 HAL 库中那些结构体是怎么
与寄存器地址对应起来的。这里我们就做一个简要的分析吧。
首先我们看看 51 中是怎么做的。51 单片机开发中经常会引用一个 reg51.h 的头文件,下
面我们看看他是怎么把名字和寄存器联系起来的:
sfr P0 =0x80;
sfr 也是一种扩充数据类型,点用一个内存单元,值域为 0~255。利用它可以访问 51 单片
机内部的所有特殊功能寄存器。如用 sfr P1 = 0x90 这一句定义 P1 为 P1 端口在片内的寄存
器。然后我们往地址为 0x80 的寄存器设值的方法是:P0=value;
那么在 STM32 中,是否也可以这样做呢??答案是肯定的。肯定也可以通过同样的方
式来做,但是 STM32 因为寄存器太多太多,如果一一以这样的方式列出来,那要好大的篇
幅,既不方便开发,也显得太杂乱无序的感觉。所以 MDK 采用的方式是通过结构体来将
寄存器组织在一起。下面我们就讲解 MDK 是怎么把结构体和地址对应起来的,为什么我
们修改结构体成员变量的值就可以达到操作对应寄存器的值。这些事情都是在 stm32f1xx.h
文件中完成的。我们通过 GPIOA 的几个寄存器的地址来讲解吧。
首先我们可以查看《STM32 中文参考手册 V10》中的寄存器地址映射表(P129)。这里
我们选用 GPIOA 为例来讲解。GPIO 寄存器地址映射如下表 4.6.1:
表 4.6.1 GPIO 寄存器地址映射表
从这个表我们可以看出,GPIOA 的 7 个寄存器都是 32 位的,所以每个寄存器占有 4
个地址,一共占用 28 个地址,地址偏移范围为(000h~01Bh)。这个地址偏移是相对 GPIOA
的基地址而言的。GPIOA 的基地址是怎么算出来的呢?因为 GPIO 都是挂载在 APB2 总线
之上,所以它的基地址是由 APB2 总线的基地址 GPIOA 在 APB2 总线上的偏移地址决定
的。同理依次类推,我们便可以算出 GPIOA 基地址了。下面我们打开 stm32f103.h 定位到
GPIO_TypeDef 定义处:
typedef struct
{
__IO uint32_t CRL;
__IO uint32_t CRH;
__IO uint32_t IDR;
__IO uint32_t ODR;
__IO uint32_t BSRR;
__IO uint32_t BRR;
__IO uint32_t LCKR;
} GPIO_TypeDef;
然后定位到:
#define GPIOA ((GPIO_TypeDef *) GPIOA_BASE)
可以看出,GPIOA 是将 GPIOA_BASE 强制转换为 GPIO_TypeDef 结构体指针,这句话的
意思是,GPIOA 指向地址 GPIOA_BASE,GPIOA_BASE 存放的数据类型为 GPIO_TypeDef。
然后在 MDK 中双击“GPIOA_BASE”选中之后右键选中“Go to definition of ”,便可以查
看 GPIOA_BASE 的宏定义:
#define GPIOA_BASE (APB2PERIPH_BASE 0x0800)
依次类推,可以找到最顶层:
#define APB2PERIPH_BASE (PERIPH_BASE 0x10000)
#define PERIPH_BASE ((uint32_t)0x40000000)
所以我们便可以算出 GPIOA 的基地址位:GPIOA_BASE= 0x40000000 0x10000 0x0800=0x40010800
下面我们再跟《STM32 中文参考手册 V10》比较一下看看 GPIOA 的基地址是不是
0x40010800。截图 P28 存储器映射表我们可以看到,GPIOA 的起始地址也就是基地址确实
是 0x40010800:
图 4.6.2 GPIO 存储器地址映射表
同样的道理,我们可以推算出其他外设的基地址。
上面我们已经知道 GPIOA 的基地址,那么那些 GPIOA 的 7 个寄存器的地址又是怎么
算出来的呢??在上面我们讲过 GPIOA 的各个寄存器对于 GPIOA 基地址的偏移地址,所
以我们自然可以算出来每个寄存器的地址。
GPIOA 的寄存器的地址=GPIOA 基地址 寄存器相对 GPIOA 基地址的偏移值
这个偏移值在上面的寄存器地址映像表中可以查到。
那么在结构体里面这些寄存器又是怎么与地址一一对应的呢?这里就涉及到结构体的
一个特征,那就是结构体存储的成员他们的地址是连续的。上面讲到 GPIOA 是指向
GPIO_TypeDef 类型的指针,又由于 GPIO_TypeDef 是结构体,所以自然而然我们就可以算
出 GPIOA 指向的结构体成员变量对应地址了。
表 4.6.3 GPIOA 各寄存器实际地址表
我们可以把GPIO_TypeDef的定义中的成员变量的顺序和GPIOx寄存器地址映像对比
可以发现,他们的顺序是一致的,如果不一致,就会导致地址混乱了。
这就是为什么固件库里面:GPIOA->BRR=value;就是设置地址为0x40010800
0x014(BRR偏移量)=0x40010814的寄存器BRR的值了。它和51里面P0=value是设置地
址为0x80的P0寄存器的值是一样的道理。看到这里你是否会学起来踏实一点呢??STM32使用的方式虽然跟51单片机不一样,
但是原理都是一致的。
.7 MDK中使用HAL库快速组织代码技巧
这一节主要讲解在MDK中使用HAL库开发的一些小技巧,仅供初学者参考。这节的知识
大家可以在学习第一个跑马灯实验的时候参考一下,对初学者应该很有帮助。我们就用最简单
的GPIO初始化函数为例。
现在我们要初始化某个GPIO端口,我们要怎样快速操作呢?在头文件stm32f1xx_hal_gpio.h 头文件中,声明 GPIO 初始化函数为:
void HAL_GPIO_Init(GPIO_TypeDef *GPIOx, GPIO_InitTypeDef *GPIO_Init);
现在我们想写初始化函数,那么我们在不参考其他代码的前提下,怎么快速组织代码呢?
首先,我们可以看出,函数的入口参数是 GPIO_TypeDef 类型指针和 GPIO_InitTypeDef 类
型指针,因为 GPIO_TypeDef 入口参数比较简单,所以我们就通过第二个入口参数
GPIO_InitTypeDef 类型指针来讲解。双击 GPIO_InitTypeDef 后右键选择“Go to definition of…”,
如下图 4.7.1:
图 4.7.1 查看类型定义方法
于是定位到 stm32f1xx_hal_gpio.h 中 GPIO_InitTypeDef 的定义处:
typedef struct
{
uint32_t Pin;
uint32_t Mode;
uint32_t Pull;
uint32_t Speed;
}GPIO_InitTypeDef;
可以看到这个结构体有 4 个成员变量,这也告诉我们一个信息,一个 GPIO 口的状态是由模式
(Mode),速度(Speed)以及上下拉(Pull)来决定的。我们首先要定义一个结构体变量,下面
我们定义:
GPIO_InitTypeDef GPIO_InitStructure;
接着我们要初始化结构体变量 GPIO_InitStructure。首先我们要初始化成员变量 Pin,这个时候我
们就有点迷糊了,这个变量到底可以设置哪些值呢?这些值的范围有什么规定吗?
这里我们就回到 HAL_GPIO_Init 声明处,同样双击 HAL_GPIO_Init,右键点击“Go to
definition of …”,这样光标定位到 stm32f1xx_hal_gpio.c 文件中的 HAL_GPIO_Init 函数体开始处,
我们可以看到在函数中有如下几行:
void HAL_GPIO_Init(GPIO_TypeDef *GPIOx, GPIO_InitTypeDef *GPIO_Init)
{
…//此处省略部分代码
assert_param(IS_GPIO_ALL_INSTANCE(GPIOx));
assert_param(IS_GPIO_PIN(GPIO_Init->Pin));
assert_param(IS_GPIO_MODE(GPIO_Init->Mode));
…//此处省略部分代码
assert_param(IS_GPIO_PULL(GPIO_Init->Pull));
…//此处省略部分代码
}
顾名思义,assert_param 是断言语句,是对函数入口参数的有效性进行判断,所以我们可以从
这个函数入手,确定入口参数范围。第一行是对第一个参数 GPIOx 进行有效性判断,双击
“IS_GPIO_ALL_INSTANCE”右键点击“go to defition of…” 定位到了下面的定义:
#define IS_GPIO_ALL_INSTANCE(INSTANCE) (((INSTANCE) == GPIOA) || \
((INSTANCE) == GPIOB) || \
((INSTANCE) == GPIOC) || \
((INSTANCE) == GPIOD) || \
((INSTANCE) == GPIOE) || \
((INSTANCE) == GPIOF) || \
((INSTANCE) == GPIOG))
很明显可以看出,GPIOx 的取值规定只允许是 GPIOA~GPIOG。
同样的办法,我们双击“IS_GPIO_PIN” 右键点击“go to defition of…”,定位到下面的定义:
#define IS_GPIO_PIN(PIN) (((((uint32_t)PIN) & GPIO_PIN_MASK ) != 0x00u)
&& ((((uint32_t)PIN) & ~GPIO_PIN_MASK) == 0x00u))
同时,宏定义标识符 GPIO_PIN_MASK 的定义为:
#define GPIO_PIN_MASK 0x0000FFFFu
从上面可以看出,PIN 取值只要低 16 位不为 0 即可。这里需要大家注意,因为一组 IO 口只有
16 个 IO,实际上 PIN 的值在这里只有低 16 位有效,所以 PIN 的取值范围为 0x0001~0xFFFF。
那么是不是我们写代码初始化就是直接给一个 16 位的数字呢?这也是可以的,但是大多数情况
下,我们不会直接在入口参数处设置一个简单的数字,因为这样代码的可读性太差,HAL 库会
将这些数字的含义通过宏定义定义出来,这样可读性大大增强。我们可以看到在
GPIO_PIN_MASK 宏定义的上面还有数行宏定义:
#define GPIO_PIN_0 ((uint16_t)0x0001)
#define GPIO_PIN_1 ((uint16_t)0x0002)
#define GPIO_PIN_2 ((uint16_t)0x0004)
…//此处省略部分定义
#define GPIO_PIN_14 ((uint16_t)0x4000)
#define GPIO_PIN_15 ((uint16_t)0x8000)
#define GPIO_PIN_All ((uint16_t)0xFFFF)
这些宏定义 GPIO_PIN_0 ~ GPIO_PIN_All 就是 HAL 库事先定义好的,我们写代码的时候初始
化结构体 成员变量 Pin 的时候入口参数可以是这些宏定义标识符。
同理,对于成员变量 Pull,我们用同样的方法,可以找到其取值范围定义为:
#define IS_GPIO_PULL(PULL) (((PULL) == GPIO_NOPULL)\
|| ((PULL) == GPIO_PULLUP) || \ ((PULL) == GPIO_PULLDOWN))
也就是 PULL 的 取 值 范 围 只 能 是 标 识 符 GPIO_NOPULL , GPIO_PULLUP 以 及
GPIO_PULLDOWN。
对于成员变量 Mode,方法都是一样的,这里基于篇幅考虑我们就不重复讲解。讲到这里,
我们基本对 HAL_GPIO_Init 的入口参数有比较详细的了解了。于是我们可以组织起来下面的代
码:
GPIO_InitTypeDef GPIO_Initure;
GPIO_Initure.Pin=GPIO_PIN_9;
//PA9
GPIO_Initure.Mode=GPIO_MODE_AF_PP; //复用推挽输出
GPIO_Initure.Pull=GPIO_PULLUP;
//上拉
GPIO_Initure.Speed=GPIO_SPEED_FREQ_HIGH;//高速
HAL_GPIO_Init(GPIOA,&GPIO_Initure); //初始化 PA9
接着又有一个问题会被提出来,这个初始化函数一次只能初始化一个 IO 口吗?我要同时
初始化很多个 IO 口,是不是要复制很多次这样的初始化代码呢?
这里又有一个小技巧了。从上面的 GPIO_PIN_X 的宏定义我们可以看出,这些值是 0,1,2,4
这样的数字,所以每个 IO 口选定都是对应着一个位,16 位的数据一共对应 16 个 IO 口。这个
位为 0 那么这个对应的 IO 口不选定,这个位为 1 对应的 IO 口选定。如果多个 IO 口,他们都
是对应同一个 GPIOx,那么我们可以通过|(或)的方式同时初始化多个 IO 口。这样操作的前
提是,他们的 Mode,Speed 和 Pull 参数值相同,因为这些参数并不能一次定义多种。所以初始
化多个具有相同配置的 IO 口的方式可以是如下:
GPIO_InitTypeDef GPIO_Initure;
GPIO_Initure.Pin=GPIO_PIN_9| GPIO_PIN_10| GPIO_PIN_11; //PA9,PA10,PA11
GPIO_Initure.Mode=GPIO_MODE_AF_PP; //复用推挽输出
GPIO_Initure.Pull=GPIO_PULLUP;
//上拉
GPIO_Initure.Speed=GPIO_SPEED_FREQ_HIGH;//高速
HAL_GPIO_Init(GPIOA,&GPIO_Initure); //初始化 PA9 ,PA10,PA11
对于那些参数可以通过|(或)的方式连接,这既有章可循,同时也靠大家在开发过程中不断积累。
大家会觉得上面讲解有点麻烦,每次要去查找 assert_param()这个函数去寻找,那么有没有
更好的办法呢?大家可以打开 GPIO_InitTypeDef 结构体定义:
typedef struct
{
uint32_t Pin; /*!< Specifies the GPIO pins to be configured.
This parameter can be any value of @ref GPIO_pins_define */
uint32_t Mode; /*!< Specifies the operating mode for the selected pins.
This parameter can be a value of @ref GPIO_mode_define */
uint32_t Pull; /*!< Specifies the Pull-up or Pull-Down activation for the selected pins.
This parameter can be a value of @ref GPIO_pull_define */
uint32_t Speed; /*!< Specifies the speed for the selected pins.
This parameter can be a value of @ref GPIO_speed_define */
}GPIO_InitTypeDef;
从上图的结构体成员后面的注释我们可以看出 Pin 的意思是
“Specifies the GPIO pins to be configured.
This parameter can be any value of @ref GPIO_pins_define”。
从这段注释可以看出 Pin 的取值需要参考注释 GPIO_pins_define,大家可以在 MDK 中搜索注释
GPIO_pins_define,就可以找到上面我们提到的 Pin 的取值范围宏定义。如果要确定详细的信息
我们就得去查看手册了。对于去查看手册的哪个地方,你可以在函数 HAL_GPIO_Init ()的函数
体中搜索 Pin 关键字,然后查看库函数设置 Pin 是设置的哪个寄存器的哪个位,然后去中文参
考手册查看该寄存器相应位的定义以及前后文的描述。
这一节我们就讲解到这里,希望能对大家的开发有帮助。