GPIO是General-purpose input/output的简称,意为通用输入/输出。GPIO端口是MCU芯片上负责信息(数据和控制信号)输入输出的引脚的统称,也称为I/O引脚。之所以被称为“通用”接口,是因为每个引脚的功能不是唯一的,而是可以通过配置相关寄存器进行设置的。初始化GPIO配置往往是开发MCU驱动程序的第一步。初始化GPIO配置的方法就是配置相关的寄存器。STM32F1系列芯片的每组GPIO,都设有7个寄存器,包括:

  • 2个32位配置寄存器(GPIOx_CRL, GPIOx_CRH)
  • 2个32位数据寄存器(GPIOx_IDR, GPIOx_ODR)
  • 1个32位设置/复位寄存器(GPIOx_BSRR)
  • 1个16位复位寄存器(GPIOx_BRR)
  • 1个32位锁定寄存器(GPIOx_LCKR)

(注:寄存器的每个位可理解为一个MOS开关,配置位1开关闭合/断开,配置位0开关断开/闭合)

一、 GPIO配置寄存器(Configuration Register)

GPIO配置寄存器是用来设置I/O引脚工作模式的寄存器。STM32的每组GPIO都有16个引脚,从Px0到Px15编号,例如GPIOA对应的引脚编号为PA0到PA15。每个引脚的配置需要4个位,其中2位配置引脚的功能,2位配置引脚的模式。这样每组GPIO的16个引脚就需要2个32位的CR寄存器。其中,用于配置编号Px0-Px7引脚的寄存器,称为GPIOx_CRL;用于配置编号Px8-Px15引脚的寄存器,称为GPIOx_CRH。

1. 工作原理

无论归属哪个CR寄存器配置,GPIO引脚的配置方法是相同的,只是不同引脚对应的配置位在寄存器中的位置不同。以GPIOx_CRL寄存器为例,各引脚占位分配如下图所示。

每个引脚占用连续的4个位,其中高位的2个位为功能配置位(CNF: CoNFigure)),低位的2个位为工作模式设置位(MODE)。图1的表格中,CNFy和MODEy中的y即为引脚编号,下方的rw表示该位可read和write。端口模式配位表如下图所示:

简言之,当MODEy等于00时,y引脚为输入模式,此时配置CNFy可以得到4种输入模式:

  • 模拟输入(Analog mode)
  • 浮空输入(Floating input)
  • 输入上拉(Input with pull-up)
  • 输入下拉(Input with pull-down)

当MODEy不等于00时,y引脚为输出模式,此时配置CNFy可以得到4种输出模式:

  • 通用开漏输出(General purpose output push-pull)
  • 通用推挽输出(General purpose output open-drain)
  • 复用开漏输出(Alternate function output push-pull)
  • 复用推挽输出(Alternate function output open-drain)

每种输出模式又可通过MODEy配置3种速度:

  • 01 最高输出速度10 MHz
  • 10 最高输出速度 2 MHz
  • 11 最高输出速度50 MHz

综上,每个GPIO引脚有4种输入模式和4种输出模式,每种输出模式又有3种速度。可见,通过配置CR寄存器的4个位,每个GPIO引脚可实现多达16种配置,“通用”名副其实。具体到接某个外设时IO口该配置成哪种模式,需要根据所接的外设和外设的工作状态确定,可以查阅芯片的《Reference
manual》中GPIO configurations for device peripherals章节与外设相关的表格。

说明:

  • 输入下拉和输入上拉的CNFy和MODEy配置是一样的,为了区分两种模式,还需要配置引脚的ODR寄存器,ODRy为0则为下拉,反之则为上拉。
  • GPIO的CR寄存器的复位值为0x44444444,即复位后引脚的默认状态为输入浮空。
  • 在程序初始化阶段,建议将所有引脚复位为模拟输入,即所有CR寄存器位置0,然后按需配置引脚模式。这样可以关闭闲置引脚上的TTL Schmitt trigger,达到省电的目的。

2. 操作方法

CR寄存器的设置既可直接对寄存器进行位操作,也可以通过调用库函数进行操作。

(1)直接操作寄存器

为了便于对寄存器进行位操作,在头文件stm32f10x.h(以F103ZET6标准库为例,HAL库中对应的头文件为stm32f103xe.h)中定义了大量的宏、枚举类和结构体,来实现与寄存器地址的映射。以GPIOA为例,从上到下的映射关系为:

#define PERIPH_BASE       ((uint32_t)0x40000000)         /* 外设总线地址 */
#define APB2PERIPH_BASE   (PERIPH_BASE + 0x10000)        /* APB2总线地址 0x40010000 */
#define GPIOA_BASE        (APB2PERIPH_BASE + 0x0800)     /* GPIOA地址 0x40010800 */
#define GPIOA             ((GPIO_TypeDef *) GPIOA_BASE)  /* GPIOA宏定义 0x40010800 */
typedef struct
{
  __IO uint32_t CRL;  //0x40010800
  __IO uint32_t CRH;  //0x40010804
  __IO uint32_t IDR;  //0x40010808
  __IO uint32_t ODR;  //0x4001080C
  __IO uint32_t BSRR; //0x40010810
  __IO uint32_t BRR;  //0x40010814
  __IO uint32_t LCKR; //0x40010818
} GPIO_TypeDef; 

(注:地址以字节为单位,每个地址存放1个字节,即8个bit,每个寄存器占32bit ,所以地址偏移0x04)

从下往上看,GPIO_TypeDef结构体中为每组GPIO的7个寄存器都定义了一个别名,指向相应寄存器的首地址;为每组GPIO也定义了别名,GPIOx(x=A....)是指向GPIO_TypeDef结构体对象的首地址的指针,GPIOx挂载在APB2外设总线上,APB2又挂载在APB总线上(APB-Advanced
Peripheral Bus,高级外设总线,分APB1和APB2)。从上往下看,是先定义一个基地址,然后通过地址偏移(Address
offset)来逐级分配地址。

有了这些与地址相关联的别名,在程序中就可以便捷地来设置寄存器的相关位了。为了在设置寄存器的功能位时,不影响其他位,操作分为两步:

  • 用一个Reset mask(复位掩码),与寄存器现有的值作&运算,定位清零。
  • 再用一个Set mask(设置掩码),与寄存器的现有值作|运算,写入新设置。

比如,将PA5引脚设为50MHz的推挽输出:

GPIOA->CRL&=0XFF0FFFFF; //先清空。与操作,将PA5对应的4个位设为0,其余位不受影响
GPIOA->CRL|=0X00300000; //后赋值。或操作,将PA5对应的位设为0011b,对应16进制的3。

这种直接赋值的方法虽然简单直接,但影响程序的可读性和通用性。单片机开发中更常用的方法是移位操作。例如,用移位操作上述代码可改写为:

GPIOA->CRL&= ~(((uint32_t)0x0F)<<5); //移位得到0x00F00000,再取反即为0XFF0FFFFF
GPIOA->CRL|=  (0x03<<5); //0x03左移5次即为0x00300000

为了进一步提高寄存器操作代码的可读性和通用性,头文件stm32f10x.h中为所有寄存器的功能位定义了宏名称,以PA5对应的位为例:

#define  GPIO_CRL_MODE5     ((uint32_t)0x00300000)  /*!< MODE5[1:0]=11 */
#define  GPIO_CRL_MODE5_0   ((uint32_t)0x00100000)  /*!< MODE5[1:0]=01 */
#define  GPIO_CRL_MODE5_1   ((uint32_t)0x00200000)  /*!< MODE5[1:0]=10 */

#define  GPIO_CRL_CNF5      ((uint32_t)0x00C00000)  /*!< CNF5[1:0]=11 */
#define  GPIO_CRL_CNF5_0    ((uint32_t)0x00400000)  /*!< CNF5[1:0]=01 */
#define  GPIO_CRL_CNF5_1    ((uint32_t)0x00800000)  /*!< CNF5[1:0]=10 */

用上述宏定义,前述设置等效为:

tempRV = GPIOA->CRL; //定义临时变量,减少读写寄存器次数
tempRV &= ~(GPIO_CRL_CNF5|GPIO_CRL_MODE5);//1111取反,定位清零
tempRV |= (~GPIO_CRL_CNF5)|GPIO_CRL_MODE5;//0011,写入新设置
GPIOA->CRL = tempRV; //写入寄存器

说明:

  • GPIO相关的寄存器不支持按字节(byte,8bit)或半字(half-word, 16bit)操作,只能通过全字(word,32-bit)方式进行访问。
  • 对输入上拉或下拉还要配置同引脚的ODR的对应位。可以直接设置ODR的位,也可通过操作BRR或BSRR寄存器来实现。详见下文BSRR和BRR寄存器。
  • 移位操作可提高代码可读性和重用性,也是库函数中常用的方法。要熟悉和掌握。
  • 定义临时变量存储寄存器值,可将原来的2次读取2次写入简化为1次读取1次写入。
  • 这里仅以CRL寄存器的操作为例,掌握了基本原理,其余寄存器的操作可触类旁通。

(2)调用标准库函数

标准库(STD库)中,与GPIO相关的库函数及相关参数在头文件stm32f10x_gpio.h中声明,在stm32f10x_gpio.c中实现。头文件中GPIO各Pin定义如下:

#define GPIO_Pin_0    ((uint16_t)0x0001)  //0b0000000000000001
#define GPIO_Pin_1    ((uint16_t)0x0002)  //0b0000000000000010
#define GPIO_Pin_2    ((uint16_t)0x0004)  //0b0000000000000100
#define GPIO_Pin_3    ((uint16_t)0x0008)  //0b0000000000001000
#define GPIO_Pin_4    ((uint16_t)0x0010)  //0b0000000000010000
#define GPIO_Pin_5    ((uint16_t)0x0020)  //0b0000000000100000
#define GPIO_Pin_6    ((uint16_t)0x0040)  //0b0000000001000000
#define GPIO_Pin_7    ((uint16_t)0x0080)  //0b0000000010000000
#define GPIO_Pin_8    ((uint16_t)0x0100)  //0b0000000100000000
#define GPIO_Pin_9    ((uint16_t)0x0200)  //0b0000001000000000
#define GPIO_Pin_10  ((uint16_t)0x0400)  //0b0000010000000000
#define GPIO_Pin_11  ((uint16_t)0x0800)  //0b0000100000000000
#define GPIO_Pin_12  ((uint16_t)0x1000)  //0b0001000000000000
#define GPIO_Pin_13  ((uint16_t)0x2000)  //0b0010000000000000
#define GPIO_Pin_14  ((uint16_t)0x4000)  //0b0100000000000000
#define GPIO_Pin_15  ((uint16_t)0x8000)  //0b1000000000000000
#define GPIO_Pin_All ((uint16_t)0xFFFF)   //0b1111111111111111

可见,为每个Pin设定了一个别名,对应一个16位的无符号整数。注释部分是该整数对应的二进制数,可以看到其中只有一个位是1且位置与引脚编号相关。在GPIO_Init()函数中,正是通过对0x01进行循环左移操作,通过搜索与输入引脚匹配时移位的次数来判断引脚编号的。(疑问1:为何用循环移位,不直接用switch…case呢?)

头文件中还定义了两个枚举类来分别管理GPIO的模式(GPIO_Mode_TypeDef)和输出模式的速度(GPIO_Speed_TypeDef),并定义了一个结构体(GPIO_InitTypeDef)来便于向初始化函数传递参数。

GPIO_Mode_TypeDef枚举类定义如下:

typedef enum
{ GPIO_Mode_AIN = 0x0,                    //0b00000000
  GPIO_Mode_IN_FLOATING = 0x04,  //0b00000100
  GPIO_Mode_IPD = 0x28,                  //0b00101000
  GPIO_Mode_IPU = 0x48,                  //0b01001000
  GPIO_Mode_Out_OD = 0x14,          //0b00010100
  GPIO_Mode_Out_PP = 0x10,          //0b00010000
  GPIO_Mode_AF_OD = 0x1C,          //0b00011100
  GPIO_Mode_AF_PP = 0x18            //0b00011000
}GPIOMode_TypeDef;

可见,在该枚举类中为GPIO的8种模式分别定义了一个名称,每个名称被赋值一个十六进制数,在GPIO_Init()函数中会基于这个十六进制数来判断GPIO的模式。判断的方法也是通过位操作来实现的:位5为1则为输出,反之为输入;低4位用来设置CR寄存器,其中高2位对应CNFy,低2位与下述GPIOSpeed作&运算后的结果对应MODEy。

GPIOSpeed_TypeDef枚举类定义如下:

typedef enum
{ 
  GPIO_Speed_10MHz = 1,  //MODE[1:0]=01
  GPIO_Speed_2MHz,          //MODE[1:0]=10
  GPIO_Speed_50MHz         //MODE[1:0]=11
}GPIOSpeed_TypeDef;

可见,每个模式速度也被定义了一个名称,并被赋值了一个十进制数,对应的二进制码即为MODEy的逻辑码。

GPIO_InitTypeDef结构体用来设置GPIO的初始化参数,其定义如下:

typedef struct
{
  uint16_t GPIO_Pin;                            /*!< 指定管脚 */
  GPIOSpeed_TypeDef GPIO_Speed;  /*!< 指定管脚的速度 */
  GPIOMode_TypeDef GPIO_Mode;    /*!< 指定管脚的模式*/
}GPIO_InitTypeDef;

可见,这个结构体汇总了前述管脚编号、速度、模式,以结构体作为入口参数,可以简化函数的接口。最终,GPIO端口的配置通过调用初始化函数GPIO_Init()完成,其接口如下:

void GPIO_Init(GPIO_TypeDef* GPIOx, GPIO_InitTypeDef* GPIO_InitStruct);

可见,函数共需要传入两个参数,第一个参数指定GPIO引脚所在的分组,第二个参数是个GPIO_InitTypeDef结构体,传入引脚、模式、速度的配置参数。例如:将PA5配置为推挽输出,速度50MHz。

GPIO_InitTypeDef  GPIO_InitStruct;  //先定义一个GPIO_InitTypeDef结构体变量
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_5;  //引脚号
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP;  //模式
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz; //速度
GPIO_Init(GPIOA, &GPIO_InitStruct); //调用GPIO_Int函数完成初始化

GPIO_Init()函数的定义可在stm32f10xx_gpio.c中查看。其基本步骤和原理总结如下:

  • 确定CR寄存器的位:取出GPIO_Mode(&0x0F)的低四位;根据GPIO_MODE判断是输入还是输出(&0x10);若为输出则将前面取出的四个位与GPIO_Speed作或操作,确定CR寄存器的4个位的值。
  • 判断寄存器及对应位:根据GPIO_Pin编号判断该配置CRL寄存器还是CRH寄存器及该修改的位置(即CNFy和MODEy中的y)。
  • 设置寄存器:与前述直接操作寄存器的设置方法是一致的,即通过与操作先清除,然后通过或操作再把新的设置写入。
  • 处理输入上/下拉:如果GPIO_Mode是IPD或IPU,GPIO_Int()函数中还会通过设置BRR或BSRR寄存器,将引脚ODR对应位设为0或1。

(3)调用HAL库函数

标准库函数一般只适用于某个具体芯片(如F10x),而HAL(HARDWARE
Abstraction
Layer,硬件抽象层)库通过进一步的抽象和封装,大大提高了代码的可移植性,其目标是实现对STM32全系列的兼容。就像标准库是对寄存器操作的封装一样,HAL库可以理解为是对不同系列芯片库函数的进一步封装。当然,不会是简单的打包,也有架构的调整。

具体到这里讨论的GPIO的配置,HAL库的头文件stm32f1xx_hal_gpio.h中,除了引脚的别名定义基本未变(变为全部大写),其余部分都重写了。

#define  GPIO_MODE_INPUT      0x00000000u   /*!<输入浮空*/
#define  GPIO_MODE_OUTPUT_PP  0x00000001u   /*!<输出推挽*/
#define  GPIO_MODE_AF_PP      0x00000002u   /*!<复用推挽*/
#define  GPIO_MODE_ANALOG     0x00000003u   /*!<模拟模式*/
#define  GPIO_MODE_OUTPUT_OD  0x00000011u   /*!<输出开漏*/
#define  GPIO_MODE_AF_OD      0x00000012u   /*!<复用开漏*/
#define  GPIO_MODE_AF_INPUT   GPIO_MODE_INPUT  /*!<复用输入*/
#define  GPIO_MODE_IT_RISING          0x10110000u   /*!< 外部中断模式上升沿触发*/
#define  GPIO_MODE_IT_FALLING         0x10210000u   /*!< 外部中断模式下降沿触发*/
#define  GPIO_MODE_IT_RISING_FALLING  0x10310000u   /*!< 外部中断模式上下沿触发*/
 
#define  GPIO_MODE_EVT_RISING         0x10120000u   /*!< 事件模式上升沿触发*/
#define  GPIO_MODE_EVT_FALLING        0x10220000u   /*!< 事件模式下降沿触发*/
#define  GPIO_MODE_EVT_RISING_FALLING 0x10320000u   /*!< 事件模式上下沿触发*/
 
#define  GPIO_NOPULL        0x00000000u   /*!<无上下拉*/
#define  GPIO_PULLUP        0x00000001u   /*!<上拉*/
#define  GPIO_PULLDOWN      0x00000002u   /*!<下拉*/
 
#define  GPIO_SPEED_FREQ_LOW     (GPIO_CRL_MODE0_1) /*!< 低速*/
#define  GPIO_SPEED_FREQ_MEDIUM  (GPIO_CRL_MODE0_0) /*!< 中速*/
#define  GPIO_SPEED_FREQ_HIGH    (GPIO_CRL_MODE0)   /*!< 高速*/

可见,MODE和SPEED的设置都不再用枚举类定义,而是直接用#define定义了别名;上下拉被分离了出来,可独立设置;MODE扩充明显,外部中断和事件模式也被整合了进来,不同的芯片可能支持的模式会有所不同;速度模式采用高、中、低来定义,不再是具体的xxMHz,目的显然是为了兼容不同芯片的速度。

typedef struct
{
  uint32_t Pin;   /*!< 指定管脚 */
  uint32_t Mode;  /*!< 指定模式 */
  uint32_t Pull;  /*!< 指定PULL方式 */
  uint32_t Speed; /*!< 指定速度 */
} GPIO_InitTypeDef;

GPIO_InitTypeDef仍用结构体定义,但字段名发生了变化,更简洁了,增加了专门的Pull字段。虽然HAL库内容变化很大,但用户代码方面的差别不是太大。例如:将PA5配置为推挽输出,速度50MHz,HAL库函数版本的代码如下:

GPIO_InitTypeDef  GPIO_InitStruct = {0};  //先定义一个GPIO_InitTypeDef结构体变量
GPIO_InitStruct.Pin = GPIO_PIN_5;  //引脚号,HAL库中全大写
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;  //模式
GPIO_InitStruct.Pull = GPIO_NOPULL; //无上下拉
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; //速度
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); //调用GPIO_Int函数完成初始化

HAL库的函数开头一般以HAL_开头,相应的.h和.c文件中也加_hal_以示区别。例如与STD库中stm32f10x_gpio.h对应的头文件在HAL库中名为stm32f1xx_hal_gpio.h。除了名称的变化,函数的代码也是重写过的。比如,HAL_GPIO_Init()函数中,是用switch...case语句来区别处理的各种MODE;通过直接比较管脚的编号大小来确定的CRL/CRH寄存器;定义了一个专门的MODIFY_REG宏函数来完成清除和设置寄存器;......HAL库中开始出现了越来越多软件工程的思想。但万变不离其宗,无论是HAL库还是STD库,各种函数最终实现的其实还是对寄存器的操作。由此可见,熟悉底层硬件和学会直接操作寄存器的必要性,寄存器操作是库函数的基石。

二、 GPIO数据寄存器(Data Register)

每个GPIO引脚都有两个DR寄存器与之相连:GPIOx_IDR输入数据寄存器(Input Data Register)和GPIOx_ODR输出数据寄存器(Output Data Register)。

1. 工作原理

两个寄存器都是32bit寄存器,但都只用到其中的低16位,每位对应GPIOx中的一个引脚。引脚分配如下图所示:

GPIOx_IDR寄存器是只读的,用于查询I/O引脚的电平状态,借以判断与之相连的外设的状态,例如按键是否按下。在I/O端口被配置为模拟输入模式时,该寄存器被强制置零,这种情况下查询返回的值始终为0。

GPIOx_ODR寄存器可读可写,在I/O端口被配置位输出模式时,通过写该寄存器可以控制引脚的电平。而在输入模式配置下,该寄存器与外部引脚之间被断开,此时它被用来配合CR寄存器,来实现输入上拉(ODRy置1)或下拉(ODRy置0)模式的配置。

2. 操作方法

虽然不同的寄存器功能不同,但操作方法都是相似的。有了前面GPIO CR寄存器操作方法的详细介绍,DR寄存器的操作可触类旁通,毋庸赘言。我们分别用不同的方法来实现:读取PA5引脚的电平;先设置PA6引脚输出高电平,然后读取PA6引脚的电平。

(1)直接操作寄存器

PA5in = GPIOA->IDR&(0x01<<5);  //移位操作提高代码可读性
GPIOA->ODR &=0xFFFFFF4F;       //ODR可写
PA6out = GPIOA->ODR&(0x01<<6); //ODR可读

说明:

  • 虽然两个寄存器都只用到了低16位,但访问时必须以32bit的word模式访问,十六进制数前导0可省略。
  • 移位操作可提高代码可读性,也省却了换算的麻烦。如0x01<<5,即为0x020,即第6位置1,对应PA5引脚。

(2)调用标准库函数

STD库中操作GPIO的IDR和ODR寄存器的库函数有5个。在stm32f10x_gpio.h中声明、stm32f10x_gpio.c中实现。函数声明如下:

uint16_t GPIO_ReadInputData(GPIO_TypeDef* GPIOx);    //整体读取IDR
uint16_t GPIO_ReadOutputData(GPIO_TypeDef* GPIOx);   //整体读取ODR
uint8_t GPIO_ReadInputDataBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);  //定Pin读取IDR
uint8_t GPIO_ReadOutputDataBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin); //定Pin读取ODR
void GPIO_Write(GPIO_TypeDef* GPIOx, uint16_t PortVal); //整体写入ODR,16个引脚一起写
void GPIO_WriteBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin, BitAction BitVal); //定Pin写入IDR

用STD库函数来实现,本节例子的代码如下:

PA5in =  GPIO_ReadInputData(GPIOA) & GPIO_Pin_5; //整体读取,作与过滤
PA5in = GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_5); //定Pin读取IDR
GPIO_WriteBit(GPIOA,GPIO_Pin_6,Bit_RESET); //GPIO_Write无法单独改变一个引脚
PA6out = GPIO_ReadOutputDataBit(GPIOA, GPIO_Pin_6); //定Pin读取ODR

说明:

  • 根据引脚宏定义,GPIO_Pin_5对应的就是0x0020,可以用引脚别名与GPIO_ReadInputData读取的整个IDR的数据作&过滤出引脚对应的位的电平。由此可见,STD库中引脚宏定义的设计也是深思熟虑的结果。
  • GPIO_Write函数无法单独写1个位,写一个位要用GPIO_WriteBit函数。但GPIO_WriteBit函数不是操作的ODR寄存器,而是BRR和BSRR寄存器。BitAction是为配合BRR和BSRR寄存器操作而定义的枚举类:
typedef enum
{ Bit_RESET = 0,
  Bit_SET
}BitAction;

(3)调用HAL库函数

HAL库中读取和修改GPIO引脚电平的函数精简到了3个。在stm32f1xx_hal_gpio.h中声明、stm32f1xx_hal_gpio.c中定义。接口如下:

GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin); //定pin读取IDR
void HAL_GPIO_WritePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin, GPIO_PinState PinState); //定Pin写入ODR
void HAL_GPIO_TogglePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin); //反转指定Pin的状态

读一下HAL_GPIO_ReadPin函数的源码会发现,这个函数是通过读取GPIOx->IDR来实现的。严格来说,HAL库中没有直接操作GPIOx_ODR的函数,HAL_GPIO_WritePin和HAL_GPIO_TogglePin这两个函数中是通过设置BSRR和BSR寄存器来修改引脚状态的,原因在BSRR和BSR寄存器部分解释。STD库中有分别读取IDR和ODR状态的两个函数,这其实是不必要的,因为IDRy和ODRy连接到的都是y引脚,同一个引脚不可能同时有两个电平状态。HAL库中合二为一,只定义了一个HAL_GPIO_ReadPin函数。

HAL库中为引脚状态专门定义了一个GPIO_PinState枚举类:

typedef enum
{
  GPIO_PIN_RESET = 0u,
  GPIO_PIN_SET
} GPIO_PinState;

可见,PIN_RESET是低电平状态,PIN_SET为高电平状态。用HAL库函数来实现,本节例子的代码如下:

PA5in = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_5); //返回参数类型是GPIO_PinState
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_6, GPIO_PIN_SET);
PA6in = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_6);

三、BSRR和BSR寄存器

从前述ODR寄存器的设置过程可以看出,为了在设置一个引脚的电平时不影响其它引脚的电平,实际上是分三步实现的:先读取ODR寄存器的值,与一个合适的32位的数(mask)作&运算后,再将运算结果写回ODR寄存器。GPIO的BSRR寄存器和BRR寄存器也是用来设置引脚电平的,但只需一步。用这两个寄存器来操作引脚电平,不仅逻辑简洁,而且可以有效避免ODR分步操作过程中因高优先级中断介入而造成的ODR状态设置错误。

1. 工作原理

BSRR(Bit Set/Reset Registor)和BRR(Bit Reset Register) 均为32位的只写寄存器。BSRR寄存器的引脚分配如下图所示。

BSRR寄存器的低16位BSy(y=1...15)用于设置y引脚电平(置1),高16位BRy(y=0...15)用于复位y引脚的电平(置0)。换句话说,当BSy位被赋值为1时,y引脚被设为高电平1,而当BSy被赋值为0时,则不改变y引脚的当前电平;与此相反,当BRy位被赋值为1时,y引脚被强制复位,设为低电平0,而当BRy被赋值为0时,则不改变y引脚的当前电平。

BRR寄存器的引脚分配如下图所示。

可以看出,BRR虽然也是32bit只写寄存器,但只用了低16位。该寄存器的作用与BSRR寄存器的高16位完全相同,不在赘述。

BSRR、BSR、ODR三个寄存器都可以设置引脚的电平。但由于用ODR设置引脚电平需要三步,如果期间有高优先级的中断发生,当中断执行完毕返回继续执行时,原来的设置有可能会与中断期间的设置相冲突,造成ODR寄存器设置错误。因此,一般不用ODR寄存器来设置引脚电平,而是用BSRR的低16位来作位设置(置1),而用BRR来作位清除(置0)。如前所述,在输出模式下,ODR与IDR寄存器是同步的,所一般用IDR来读取引脚的电平状态

2. 操作方法

(1)直接操作寄存器

GPIOB->BRR  = 0x01<<5; //将PB5设为0
GPIOE->BSRR = 0x01<<5;//将PE5设为1

(2)调用标准库函数

STD库中设置和清除位的两个对应函数在头文件stm32f10x_gpio.h中的声明如下:

void GPIO_SetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);
void GPIO_ResetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);

查看stm32f10x_gpio.c中的函数定义可以发现,两个函数中是分别通过BSRR和BRR寄存器来设置和清除位的。另外,前述的GPIO_WriteBit函数中,也是基于操作这两个寄存器来实现位写入的,写0时调用了BRR,写1时调用了BSRR。

GPIO_ReSetBits(GPIOB,GPIO_Pin_5); //将PB5设为0
GPIO_SetBits(GPIOB,GPIO_Pin_5);     //将PB5设为1
GPIO_WriteBit(GPIOB,GPIO_Pin_5,Bit_RESET); //将PB5设为0
GPIO_WriteBit(GPIOB,GPIO_Pin_5,Bit_SET);     //将PB5设为1

(3)调用HAL库函数

HAL库中修改引脚电平的函数只有两个,即前面介绍过的HAL_GPIO_WritePin和HAL_GPIO_TogglePin。

HAL_GPIO_WritePin(GPIOB,GPIO_PIN_5,PIN_RESET); //将PB5设为0
HAL_Delay(1000); //延时1s
HAL_GPIO_TogglePin(GPIOB,GPIO_PIN_5); //反转PB5的电平

四、端口配置锁定寄存器(Configuration Lock Registor)

如前所述,通过配置CR寄存器的4个位,每个GPIO端口理论上来说有多达16种配置。有时候希望MCU工作过程中能够锁定某个I/O引脚的配置,避免在传输重要数据时受到干扰。GPIOx_LCKR寄存器就是为这一目的而设计的。执行端口上锁操作后,与端口对应的CR寄存器中的4个配置位将被锁定,在下次系统复位前不可修改。

1. 工作原理

LCKR(LoCK Register)是一个32bit寄存器,引脚分配如下图所示。LCKR的低16位各自负责GPIOx的一个引脚,位16为上锁位。上锁后,若LCKy=1,则y端口的配置被锁定,即y端口对应的CR寄存器中的4位不可修改。

为了避免误锁,上锁过程需要按顺序对LCKR寄存器执行一套操作,称为密钥写入序列(Key writing
sequence)。密钥输入过程包括3次写入,2次读取。3次写入中在保证锁定位写入1的情况下,LCKK位要依次写入1-0-1。具体实现细节,请参考下面的直接操作寄存器。

2. 操作方法

(1)直接操作寄存器

比如要锁定PB6,加锁过程如下:

uint32_t tmp = 0x00010000; //定义一个32位的临时参数,LCKK位为1,其余位为0
uint32_t pin = (0x1<<6);   //LCK6位为1。 也可直接用引脚别名GPIO_Pin_6
tmp |= pin; //或操作后,LCKK=1,LCK6=1
GPIOB->LCKR = tmp; //第1次写入,LCKK=1,LCK6=1
GPIOB->LCKR = pin;  //第2次写入,LCKK=0,LCK6=1
GPIOB->LCKR = tmp; //第3次写入,LCKK=1,LCK6=1
tmp = GPIOB->LCKR; //第1次读取
tmp = GPIOB->LCKR; //第2次读取。可省略,也可用这次读取结果去判断是否上锁成功。

(2)调用标准库函数

STD库中的GPIO_PinLockConfig函数,可以便捷地锁定指定引脚的配置。该函数声明如下:

void GPIO_PinLockConfig(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)

例如,

GPIO_PinLockConfig(GPIOB,GPIO_Pin_6); //锁定PB6的配置

GPIO_PinLockConfig的代码可以在stm32f10x_gpio.c中查看,与上面直接配置寄存器的代码基本一致。此例中,库函数的优势尽显。

(3)调用HAL库函数

HAL库中锁定引脚配置的函数名为HAL_GPIO_LockPin,声明如下:

HAL_StatusTypeDef HAL_GPIO_LockPin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin);

除了函数名跟STD库中不同外,HAL库中的函数还返回一个状态参数,可以根据该参数判断是否上锁成功。例如:

if (HAL_GPIO_LockPin(GPIOB, GPIO_PIN_6) != HAL_OK) //锁定PB6的配置,如果返回不是HAL_OK
{
  Error_Handler(); //调用容错处理函数
}

HAL_GPIO_LockPin与STD库中的函数源代码基本一致,都是对上锁过程的一组寄存器操作进行了封装。

五、总结

以GPIO相关的7个寄存器为主线,学习了GPIO寄存器工作原理,分别采用直接操作寄存器、调用STD库函数、调用HAL库函数进行了GPIO设置,并对不同的方法进行了比较。简单总结几点体会:

  • 库函数把对寄存器的操作进行了封装,一定程度上降低了代码对硬件参数的依赖,为程序编写、修改维护和移植提供了便利。
  • 直接操作寄存器的优点是:代码简洁,效率高,编译后文件体积小(省内存);缺点是:代码可读性差,可移植性也较差,需要熟悉底层硬件及原理,编程过程中需要经常翻阅芯片的用户手册。
  • 库函数的优点是:代码可读性好,有一定的可移植性,容易上手;缺点是:代码稍显冗长,效率稍差,编译后文件体积较大。由于函数要有一定的通用性,其中的循环和条件判断无疑会增加程序的运行开销,特别是在编译阶段。当然,如果编译器优化的到位,最终写到芯片里的代码或许性能损失并不大。(如何定量比较?有没有剖析软件?)
  • 无论喜欢用哪种方式编程,多多研读库函数的代码大有裨益。特别对初学者,尤为必要,有助于提高编程技巧和对硬件本身的了解。
  • 库函数中的运算基本都是通过位运算(移位/按位逻辑运算)来实现的,这是最贴近硬件的编程方法。初学者刚开始可能会不适应,仔细研究几个库函数,坚持一下就欲罢不能啦。
  • 目前ST已有STD库、HAL库、LL库,虽然后两者有ST亲生的Cube工具链加持,但总感觉未来难免会有所变化。ST最新的芯片已经没有配套的STD库了。操作寄存器是所有库函数的基石,要想将嵌入式系统开发作为一技之长,学会库函数背后的寄存器操作很重要。
  • 库函数和直接操作寄存器只是实现同一目的的不同方式,各有优点,可以混用,并不矛盾。不要因为会用c语言操作寄存器就自视甚高,汇编语言其实更底层。打个比方,用打火机和火柴都可以点烟,钻木取火也可以。

发现库函数中还有些关于GPIO引脚重映射、复用、外部中断响应的函数。留待以后学习吧。革命尚未成功,同志仍需努力。STM32要是有MATLAB那样强大的帮助系统就好啦。


附录:库函数

1. 标准库中GPIO相关函数 (见stm32f10x_gpio.h)

void GPIO_DeInit(GPIO_TypeDef* GPIOx); 
void GPIO_AFIODeInit(void);
void GPIO_Init(GPIO_TypeDef* GPIOx, GPIO_InitTypeDef* GPIO_InitStruct); //GPIO初始化
void GPIO_StructInit(GPIO_InitTypeDef* GPIO_InitStruct); //初始化GPIO结构体为默认值
uint8_t GPIO_ReadInputDataBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);//定Pin读取IDR
uint16_t GPIO_ReadInputData(GPIO_TypeDef* GPIOx);//整体读取IDR
uint8_t GPIO_ReadOutputDataBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);//定Pin读取ODR
uint16_t GPIO_ReadOutputData(GPIO_TypeDef* GPIOx);//整体读取ODR
void GPIO_SetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin); //引脚设置(1)
void GPIO_ResetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin); //引脚复位(0)
void GPIO_WriteBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin, BitAction BitVal);
void GPIO_Write(GPIO_TypeDef* GPIOx, uint16_t PortVal);//整体写入ODR,16个引脚一起写
void GPIO_PinLockConfig(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin); //锁定引脚配置
void GPIO_EventOutputConfig(uint8_t GPIO_PortSource, uint8_t GPIO_PinSource);
void GPIO_EventOutputCmd(FunctionalState NewState);
void GPIO_PinRemapConfig(uint32_t GPIO_Remap, FunctionalState NewState);
void GPIO_EXTILineConfig(uint8_t GPIO_PortSource, uint8_t GPIO_PinSource);
void GPIO_ETH_MediaInterfaceConfig(uint32_t GPIO_ETH_MediaInterface);

2. HAL库中GPIO相关函数(见stm32f1xx_hal_gpio.h)

/* Initialization and de-initialization functions *****************************/
void  HAL_GPIO_Init(GPIO_TypeDef  *GPIOx, GPIO_InitTypeDef *GPIO_Init);//GPIO初始化
void  HAL_GPIO_DeInit(GPIO_TypeDef  *GPIOx, uint32_t GPIO_Pin);
/* IO operation functions *****************************************************/
GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin); //定位读取IDR
void HAL_GPIO_WritePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin, GPIO_PinState PinState); //设置引脚电平
void HAL_GPIO_TogglePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin); //反转引脚电平
HAL_StatusTypeDef HAL_GPIO_LockPin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin); //锁定引脚配置
void HAL_GPIO_EXTI_IRQHandler(uint16_t GPIO_Pin);
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin);