STM32学习笔记03

GPIO外设

系列文章

STM32 GPIO外设

GPIO 是一个非常常用的外设,STM32 与外围器件交互都是靠它完成。因此,掌握好 GPIO 的使用方法是非常必须也是非常基本的。

GPIO的工作模式

在初始化 GPIO 时,根据使用需求必须把 GPIO 设置为相应的模式,如果设置成错误的模式,那么输入输出是不能正常进行的。

要明白 GPIO 各个模式到底如何使用,就需要明白 GPIO 的原理。下图展示了 STM32 的 GPIO 结构:

图中最右侧为实际的 I/O 引脚,其余的部分都为内部结构图。

以上图片看起来有些复杂,可以将其简化成以下模型:

I/O 引脚并联了两个用于保护的二极管。

结构图的上半部分为输入模式结构,分为上拉输入模式、下拉输入模式、浮空输入模式和模拟输入模式。

输入的信号遇到的第一个部件为两个开关和电阻:与VDD相连的为上拉电阻,与VSS相连的为下拉电阻。再连接到TTL施密特触发器就把电压信号转化为0、1的数字信号,存储在输入数据寄存器 IDR 中。

可以通过设置配置寄存器 CRL 、CRH 来控制这两个开关,于是就可以得到 GPIO 的上拉输入模式(GPIO_Mode_IPU)和下拉输入模式(GPIO_Mode_IPD)。

从它的结构可以看出,若 GPIO 引脚配置为上拉输入模式,在默认状态下( GPIO 引脚无输入),读取得的 GPIO 引脚数据为1(高电平)。而下拉模式则相反,在默认状态下其引脚数据为 0(低电平)。

而 STM32 的浮空输入模式(GPIO_Mode_IN_FLOATING)则在芯片内部,既没有接上拉电阻,也没有接下拉电阻,而是经由触发器输入。配置成该模式引脚电压是不确定的值。由于其输入阻抗较大,一般把这种模式用于标准的通信协议如I2C、USART的接收端。

模拟输入模式(GPIO_Mode_AIN)则关闭了施密特触发器,不接上、下拉电阻,经由另一线路把电压信号传送到片上外设模块。如传送至ADC模块,由ADC采集电压信号。所以使用ADC外设的时候,必须设置为模拟输入模式。

结构图的下半部分为输出模式结构,分为推挽输出模式、开漏输出模式、复用推挽输出模式和复用开漏输出模式。

线路经过一个由 P-MOS 管和 N-MOS 管组成的单元电路。而所谓推挽输出模式,则是根据其工作方式来命名的。在输出高电平时,P-MOS 管导通;低电平时,N-MOS 管导通。两个管子轮流导通,一个负责灌电流,一个负责拉电流,使其负载能力和开关速度都比普通的方式有很大的提高。推挽输出的供电平为 0 伏,高电平为 3.3 伏。

在开漏输出模式时,如果控制输出为 0(低电平),则使 N-MOS 管导通,使输出接地,若控制输出为 1(无法直接输出高电平),则既不输出高电平,也不输出低电平,为高阻态。

为了正常输出高电平,必须在外部接上一个上拉电阻。它具有“线与”特性,即很多个开漏模式引脚连接到一起时,只有当所有引脚都输出高阻态,才由上拉电阻提供高电平,此高电平的电压为外部上拉电阻所接电源的电压。若其中一个引脚为低电平,那线路就相当于短路接地,使得整条线路都为低电平。

STM32 的 GPIO 输出模式就分为普通推挽输出(GPIO_Mode_Out_PP)、普通开漏输出(GPIO_Mode_Out_OD)及复用推挽输出(GPIO_Mode_AF_PP)、复用开漏输出(GPIO_Mode_AF_OD)。

普通推挽输出模式一般应用在输出电平为0和3.3伏的场合。而普通开漏输出模式一般应用在电平不匹配的场合,如需要输出5伏的高电平,就需要在外部接一个上拉电阻,电源为5伏,把GPIO设置为开漏模式,当输出高阻态时,由上拉电阻和电源向外输出5伏的电平。

对于相应的复用模式,则是根据GPIO的复用功能来选择的,如GPIO的引脚用作串口的输出,则使用复用推挽输出模式。如果用在IC、SMBUS这些需要线与功能的复用场合,就使用复用开漏模式。其它不同复用场合的复用模式引脚配置将在具体的实例中说明。在使用任何一种开漏模式时,都需要接上拉电阻。

GPIO输入实例:按键检测

接收GPIO输入

接下来以按键输入的示例,说明 GPIO 接收输入数据的一般方法。

首先,在打开 GPIO 外设时钟后,要先初始化 GPIO 引脚的功能。这里选用 GPIOC 的 Pin0 接收按键输入,模式为浮空输入模式,速度为 10MHz 。

GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_10MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);

之所以选择使用浮空输入模式,是因为按键在没有按下的时候,默认接地,为低电平;当按下后,按键导通了外接的电源,这时 I/O 口就变成高电平。因此与按键相接的 I/O 口本身外接就处于一种下拉输入模式,所以将输入模式配置成浮空输入。

处理 GPIO 的输入主要依靠以下两个函数:

uint8_t GPIO_ReadInputDataBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
uint16_t GPIO_ReadInputData(GPIO_TypeDef* GPIOx);

其中第一个函数可以接收一个 GPIOx 外设的某一位引脚的值:高电平返回 1 ,低电平返回 0 。第二个函数可以接收整个 GPIOx 外设的值。之所以使用 uint16_t 类型的值接收整个外设的读入,是因为 GPIOx 外设的数据接收寄存器 IDR 同样只有 16 位有效。

仿照51单片机时按键输入的一般思路,很快就可以编写出以下判断按键是否按下的程序:(按键已做物理消除抖动处理)

#define KEY_ON  0
#define KEY_OFF 1
uint8_t Key_Scan(void) {
    /* Check if key pressed */
    if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == KEY_ON) {
    /* wait for key release */
        while (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == KEY_OFF)
            continue;
        return KEY_ON;
    }
    else
        return KEY_OFF;
}

每一次扫描,判断按键是否按下;若是,直到按键松开才完成一次按键处理。

获取到了输入的值之后,接下来的操作思路就和51单片机类似了。


如果要让按键按下之后改变LED灯的亮/灭状态,这涉及到一个翻转操作。由于LED灯靠某一位引脚控制,因此需要对 GPIO 的某个输出位实施位翻转

51单片机可以非常方便地通过以下方式来实现位翻转:

sbit P0_1 = P0^1;
P0_1 = ~P0_1;

如果想要实现这种位翻转,需要能读取或写入一个单独的位。当然,STM32 可以通过以下方式拼凑实现该效果:

GPIO_WriteBit(GPIOA, GPIO_Pin_0, (BitAction)((1-GPIO_ReadOutputDataBit(GPIOA, GPIO_Pin_0))));

这样做涉及两个知识点,其一是 stm32_gpio.h 对位的值的定义:

typedef enum { Bit_RESET = 0, Bit_SET } BitAction;

其二是标准库提供的以下函数:

void GPIO_WriteBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin, BitAction BitVal)

该函数的特点是可以选择性地对某个位置1或清0,灵活性更强。

位带区

实际上,STM32 也提供了一种类似51单片机的操作位的方式,这是通过访问位带别名区来实现的。

之前说过,STM32 中的 4GB 内存被划分为 8 块,每块 512MB 。其中有两块位置拥有位带这个特性,一个是 SRAM 区的最低 1MB 空间,另一个是外设区最低 1MB 空间。这两个 1MB 的空间除了可以像正常的 RAM 一样操作外,它们还有自己的位带别名区,位带别名区将这 1MB 的空间的每一个位都对应一个 32bit 的地址,当访问位带别名区的这些地址时,就等价于访问位带区某个单独的位。

需要注意的是,位带区和位带别名区在内存中都是真实存在的:

只不过位带别名区可以间接访问位带中的位而已。

对于位带区的某个位,记它所在字节的地址为 Address , 位序号为 n (0≤n≤7) ,则该位在别名区对应的地址为:

AliasAddress = AliasBaseAddress + (Address-BaseAddress) * 32 + n * 4

其中 AliasBaseAddress 是外设位带别名区的起始地址,BaseAddress 是外设位带区的起始地址,(Address-BaseAddress) 表示该位前的字节数。n 表示该位在其所在地址的哪一位,它映射后得到的别名占用 4 个字节。

可以使用以下宏把位带区位的序号转换成别名区地址:

#define BIT_BAND(addr, bitnum) ((addr & 0xF0000000) + 0x02000000 + ((addr & 0x00FFFFFF) << 5) + (bitnum << 2))

这里按位或操作符的目的是区别地址位于 SRAM 区还是外设区。

最后,就可以通过指针的形式操作这些位带别名区地址,实现对位带区位的操作:

#define MEM_ADDR(addr) *((volatile uint32_t *)(addr))
#define BIT_ADDR(addr, bitnum) MEM_ADDR(BIT_BAND(addr, bitnum))

外设的位带区覆盖了外设的所有寄存器,每个寄存器都可以利用以上宏为每个位找出相对应的位带别名地址,从而实现位操作。当然,实际使用时仅需要为少数常用的需要操作某些位的寄存器设置找出并操作对应的位,例如 GPIO 的输入和输出寄存器。

之前说过,GPIO 外设的 ODR(output data register)寄存器负责执行输出,IDR(input data register)负责读取输入,它们按位操作的需求会多一些,因此可以获取这些寄存器对应的位带别名区地址,以实现对每个 GPIO 引脚的高效读写操作。

首先,需要得到这些寄存器的地址:

#define GPIOA_ODR_Addr (GPIOA_BASE + 12)
#define GPIOB_ODR_Addr (GPIOB_BASE + 12)
#define GPIOC_ODR_Addr (GPIOC_BASE + 12)
// ...
#define GPIOA_IDR_Addr (GPIOA_BASE + 8)
#define GPIOB_IDR_Addr (GPIOB_BASE + 8)
#define GPIOC_IDR_Addr (GPIOC_BASE + 8)
// ...

之前说过,GPIOA_BASE 是 GPIOA 外设的基地址,ODR 是第 4 个寄存器,因此有 (4-1)×4 个字节的偏移。

利用之前获取位带别名区地址的宏,可以找到这些寄存器每个位对应位带的位带别名区地址:

#define PAout(n) BIT_ADDR(GPIOA_ODR_Addr, n)
#define PAin(n)  BIT_ADDR(GPIOA_IDR_Addr, n)

这样,就可以很方便地读写每一个 I/O 引脚了。例如,直接简单地翻转一下输出的电平:

PCout(5) = ~PCout(5);

为了完成这个简单的动作,这个位的值能同时被读出或写入。

位带别名区读出或写入的是实在的一个比特位,并且也只能以比特位的形式读出或写入[存疑]

使用以下代码即可实现LED灯的一闪一闪效果:

PBout(0) = ~PBout(0);
delay(0x2FFFFF);

实际上,如果将 STM32 的资源封装到一定程度,使用并不会比51单片机复杂多少。