STM32的通用输入输出端口

STM32的GPIO概览

微控制器最基本的功能就是电平信号的输入/输出。在 STM32 中,I/O 引脚可以被软件设置成不同的功能,包括输入和输出,因此又被称为通用输入/输出口(General-Purpose I/O, GPIO)。作为输入时,它可以读取外部电路的电平状态是高还是低;作为输出时,它可以向外部电路输出高电平或低电平。GPIO 是 STM32 中最常用的外设,因此掌握 GPIO 的使用方法是非常必须也是非常基本的。

STM32 将 GPIO 相关的引脚分成了若干组,称为“端口(port)”,通常用字母 A, B, C ... 来命名,即 GPIOA, GPIOB, GPIOC 等。每个端口最多包含 16 个引脚,编号从 0 到 15 ,通过端口名 + 引脚编号的方式来唯一地标识一个 GPIO 引脚。例如,PA0 代表 GPIOA 端口的第 0 号引脚,PC13 代表 GPIOC 端口的第 13 号引脚等。

STM32 的电源电压是 3.3v ,因此 GPIO 输出的高电平电压也是 3.3v ,低电平电压是接地的 0v 。

STM32 的 GPIO 功能极其强大,强大之处就在于它的每一个引脚都可以被配置成不同的工作模式,以适应不同的外部电路需求。这些模式可以被归纳为两大类:输入模式和输出模式。

GPIO输出

在使用 GPIO 时,最重要的就是根据需求将 GPIO 设置为合理的工作模式。而 STM32 的 GPIO 足足有 8 种工作模式,为了理解各个模式到底应该如何使用,就需要明白 GPIO 的原理。

下图展示了 STM32 的 GPIO 结构:

图中最右侧为实际的 I/O 引脚,其余的部分都为内部结构图。以上图片看起来有些复杂,但它可以拆解为输入部分和输出部分。接下来先看结构图下半部分代表的输出部分,它可以简化为以下结构:

根据输出信号的来源,GPIO 的输出模式可以分为 I/O 的输出和复用功能输出:复用输出模式下,输出信号由其它外设如串口产生。普通的 I/O 输出则由 GPIO 的输出数据寄存器控制,向数据寄存器 ODR 的对应位写入某个值就可以控制对应的引脚电平。

以上结构图还说明了可以通过位设置/清除寄存器控制输出数据寄存器的内容。位设置/清除寄存器 BSRR 在需要单独操作输出数据寄存器的某一位时非常有用:如果通过直接操作输出数据寄存器来修改某一位的值,为了不影响其它位,需要先读入整个输出数据寄存器的值,通过按位与和按位或的方式修改某一位后,再将其回写到输出数据寄存器中,这样做的效率较低;而向位设置/清除寄存器的某个位写 1 代表需要设置或清除指向的位,其余写 0 的位置不会受到影响,这样操作起来就非常简单。

STM32 是 32 位的单片机,内部的所有寄存器都是 32 位的。但由于 GPIO 只有 16 个输出引脚,因此输出数据寄存器只用到了低 16 位;位设置/清除寄存器的低 16 位用于执行设置操作,高 16 位用于执行清除操作。

根据输入驱动的方式,这四种输出模式可以分为推挽输出(push pull output)和开漏输出(open drain output)模式,这两种模式的区别主要是在对 MOS 管的控制上:

MOS 管是一种电子开关,只需要很小的电流信号就能控制开关的导通和关闭。P-MOS 管接到 3.3V 的 VDD ,N-MOS 管接到 0V 的 VSS 。在推挽输出模式下,这两个 MOS 管都在工作:在输出高电平时,P-MOS 管导通;低电平时,N-MOS 管导通。两个 MOS 管轮流导通,一个使 VDD 的电流流出,一个使 VSS 的电流流入,因此得名推挽输出。

而在开漏输出模式下,只有 N-MOS 管有效,此时 GPIO 无法直接输出高电平,需要外接一个电源才可以正常输出高电平:

推挽输出模式下,由于芯片是主动向外提供高电平,因此负载能力和开关速度都比较好。开漏输出模式由于依赖的是外部电源输出,可以应用在电平不匹配的场合,如需要输出 5v 的高电平,就可以在外部接 5v 的电源,并把 GPIO 设置为开漏模式。

开漏输出模式还具有“线与”特性,即很多个开漏输出模式的器件连接到一起时,只有当所有引脚控制输出都为 1 ,才由外部电源提供高电平。若其中一个引脚为低电平,那线路就相当于短路接地,使得整条线路都为低电平。

这两类模式组合起来,就构成了 STM32 的四种输出模式:

  • 0b00 :普通开漏输出模式
  • 0b01 :普通推挽输出模式
  • 0b10 :复用开漏输出模式
  • 0b11 :复用推挽输出模式

在 GPIO 的配置寄存器中,还有一些特殊的位用于配置 GPIO 的“速度”,它表示 GPIO 引脚的输出速率或输出驱动能力。为了认识这个成员的作用,首先要认识到的一点是,GPIO 驱动信号发生变化不是立即完成的,而是有一定的上升时间 tu 和下降时间 td

当外界负载有较大容值的电容时,电容充放电会延长上升/下降时间。如果要求输出以很高的频率变化,那么上升/下降时间就会占据相当可观的时间,造成输出信号的失真:

此时,缩短上升时间和下降时间的唯一方式是提高 GPIO 的驱动电流,使电容更快地充放电。因此,这些配置位改变的是 GPIO 输出部分的“输出控制”的驱动能力。速度等级越高,上升/下降时间越短,输出变化速度越快。但同样的,较高速度等级的 GPIO 需要提供更大的驱动能力来实现更快的输出变化速度,通常会消耗更多的功耗。STM32 提供了以下驱动等级:

  • 0b00 :没有驱动能力,用于输入模式(这是复位后默认状态)
  • 0b01 :最大输出变化速度为 10 MHz
  • 0b10 :最大输出变化速度为 2 MHz
  • 0b11 :最大输出变化速度为 50 MHz

如果对功耗没有要求,可以直接选用最高的速度等级,否则应该根据需要选择合适的速度等级。

使用STM32标准外设库

什么是标准外设库

在传统的单片机开发场景中,一般都是使用寄存器开发的开发方式,即找到特定寄存器的地址,然后直接向这些地址写入或读取数据,从而控制硬件。这种方式确实是最底层、最直接的开发方式,可以彻底理解 STM32 内部的工作原理,同时直接操作寄存器生成的代码也最精简、执行效率最高。

使用寄存器开发时,编写的代码一般都像这样:

/* open clock for GPIOB */ *(uint32_t*) 0x40021018 |= ((1) << 3); /* set output mode */ *(uint32_t*) 0x40010c00 |= ~(1 << (4 * 0)); /* set output register bit */ *(uint32_t*) 0x40010c0c &= ~(1 << 0);

很显然,这种开发方式难度极大,开发者需要花费大量时间阅读参考手册(Reference Manual),查阅或核对繁杂的寄存器和位定义,而且代码中充满了各种“魔法数字”和位运算,即便在有注释的情况下,代码的可读性还是非常低下。虽然这种代码执行效率最高,但它几乎没有可移植性:由于外设的寄存器地址和按位生效方式可能完全不同,为 STM32F1 系列产品编写的寄存器代码,几乎无法直接用在 STM32F4 或类似的产品上。

因此,这种开发方式仅适合对成本和性能要求极其苛刻的商业产品,或需要彻底学习和研究芯片的底层原理。

通常来说,当某款芯片的使用者开发了若干个项目后,都会倾向于将一些具有相似功能的代码封装成函数,从而尽可能避免繁琐的寄存器操作。为此,ST 官方推出了标准外设库(Standard Peripheral Library, SPL),这是 ST 公司曾经主推的开发方式。它将繁琐的寄存器操作封装成一个个具有固定格式的函数,可以直接通过调用这些库函数来配置和控制外设。

以上寄存器操作相关代码,在标准外设库的代码为:

RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitTypeDef GPIO_InitStructure = { .GPIO_Pin = GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5, .GPIO_Mode = GPIO_Mode_Out_PP, .GPIO_Speed = GPIO_Speed_2MHz, }; GPIO_Init(GPIOB, &GPIO_InitStructure); GPIO_SetBits(GPIOB, GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5);

相较于寄存器开发,使用标准库的开发方式只需理解函数的功能和参数即可,这样一来不仅极大地地提升了开发效率和代码的可读性,而且也方便代码在不同芯片上移植。虽然有函数调用的开销,但封装较薄,性能损失不大,而且在使用过程中可以充分学习到芯片的底层原理。

标准外设库平衡了效率与便捷,无疑是一代经典的开发方式,也是 STM32 能在诸多 32 位单片机中脱颖而出的重要因素,因此学习使用标准外设库是很有必要的。

注意,以上的表述是“曾经”主推的开发方式。实际上,近些年 ST 官方已宣布停止对标准库的更新和支持,取而代之的是全新的硬件抽象层(Hardware Abstraction Layer, HAL)库,它的特点是 API 接口非常友好、统一,配合 ST 官方推出的可视化配置工具,可以省去大量繁琐的初始化工作,在熟悉工具链之后拥有极高的开发效率,同时拥有无可比拟的可移植性。

但是,就如同它的命名“抽象”一样,HAL 库在底层的寄存器之上建立了太多的封装,学习过程中可能会忽略底层重要的细节。同时,HAL 库在封装时包含了很多复杂的状态机和回调机制,学习曲线较为陡峭。

考虑到对于 STM32F1 系列来说,大量现有代码和经典教程均使用标准库;且由标准库到 HAL 库的入门较容易,而反之未必,这里仍然推荐在学习过程中采用标准库。

标准外设库的结构

STM32 的标准外设库可以在其官网获取,但是操作较繁琐。同时本文对于的 Github 仓库也包含了一份标准外设库最新版本( v3.6.0 )的原始内容。

获取标准外设库后,解压该压缩包,可以直接看到以下结构,它们的用途为:

文件/目录名 说明
Libraries 驱动库的源代码及启动文件
Project 用驱动库写的例程和一个工程模板
Utilities 基于 ST 官方开发板的例程
Release_Notes.html 版本更新说明
stm32f10x_stdperiph_lib_um.chm 帮助文档

在开发中,主要用到 Libraries 目录。这个目录的结构为:

Libraries 目录主要包含两大部分:CMSIS 目录存放内核相关的代码,而 STM32F10x_StdPeriph_Driver 目录存放外设相关的代码。

CMSIS(Cortex Microcontroller Software Interface Standard)是 ARM 与芯片厂商联合制定的标准,主要用于解决不同芯片厂商生产的 Cortex 微控制器的兼容性问题。该标准统一了内核各寄存器和结构的名称,而各芯片产商只确定它们的具体地址、中断定义等实现细节。

CMSIS/CM3 包含的关键文件有:

  • core_cm3.c 及其对应的头文件位于 CoreSupport 中,由 ARM 官方提供,定义了 Cortex-M3 内核的核心功能的地址定义和函数实现,还有一些用于屏蔽不同编译器差异的条件编译语句,为所有使用 Cortex-M3 内核的芯片提供了通用的接口。
  • startup 目录位于 DeviceSupport 中,它存放启动文件,这是一个用汇编语言编写的文件(扩展名为 .s)。它是芯片上电复位后第一个执行的文件,主要职责是做一些必要的初始化工作(例如初始化堆栈、配置中断向量表、配置系统时钟等,在[[$redirect|相关章节]]会详细介绍)。注意需要根据所用的编译器和芯片的 Flash 容量选择正确的启动文件。
  • system_stm32f10x.c 及其对应的头文件位于 DeviceSupport 中,它由 ST 公司编写,最核心的功能是实现了 SystemInit() 函数,它主要负责配置系统时钟,在相关章节会详细介绍。
  • 还有一个非常重要的头文件 stm32f10x.h ,它根据对应的芯片型号,包含了所有寄存器的地址映射、数据结构定义等。

以上文件都是基础性的文件,即便使用寄存器编程的方式,也可能用到。而 STM32F10x_StdPeriph_Driver 目录中包含的文件,才是包含了 STM32F103 上所有外设驱动函数的具体实现。其中,inc 目录存放所有的头文件,src 目录存放所有的源文件。这些函数根据具体所属外设的不同存放在不同的文件中。

标准库的架构非常扁平、直观。它的核心思想就是为 STM32 上的每一个独立外设(Peripheral)都提供一个独立的驱动文件。当需要用到哪个外设,就在工程里添加对应的 .c 文件,并在代码里 #include 它的 .h 文件,这样职责清晰,易于理解和管理。

所有这些外设驱动都建立在 CMSIS 核心层之上,并最终由 main.c 等应用层代码来调用。

原则上,标准外设库 Libraries 文件夹中的文件是不能随意修改的。除此之外,根目录下的 Project\STM32F10x_StdPeriph_Template 中还提供了几个用于对标准外设库调配资源和设置参数的辅助文件:

  • stm32f10x_conf.h 是一个用于“项目裁剪”的配置文件。可以在这个文件里通过注释或取消注释其中包含的 #include 语句,来决定项目中具体包含哪些外设的头文件。这样做在用不到某些外设时,可以避免编译进不需要的库,加快编译速度。
  • stm32f10x_it.c 及其头文件主要提供了一些系统异常的接口,其余中断相关服务函数也应统一在该文件中的管理。

不管使用哪个开发环境,搭建一个标准库工程的关键步骤和注意事项都是相似的。

首先,项目的工程目录可以参照如下结构:

STM32Project/ ├── Core/ # 存放 CMSIS 核心文件 │ ├── core_cm3.c │ ├── core_cm3.h │ └── stm32f10x.h ├── FWLib/ # 存放 ST 标准固件库文件 │ ├── inc/ │ │ ├── stm32f10x_gpio.h │ │ └── ... │ └── src/ │ ├── stm32f10x_gpio.c │ └── ... ├── Startup/ # 存放启动文件 │ └── startup_stm32f10x_xx.s └── User/ # 存放项目相关代码 ├── main.c ├── stm32f10x_conf.h ├── stm32f10x_it.c ├── stm32f10x_it.h ├── system_stm32f10x.c └── system_stm32f10x.h

在 IDE 中,添加其中所有的源文件( .c 文件),并确保所有的头文件( .h 文件)中都处于搜索路径中。

Startup/ 目录下,注意只添加对应芯片的启动文件(上一节介绍了这些缩写的含义,它们主要和芯片的 Flash 容量有关)。然后注意要在 stm32f10x.h 的第 65~74 行左右的位置,取消相应产品的宏定义,从而生成正确的寄存器定义(如果不想改动这些底层库,也可以在 IDE 中定义相关的宏):

/* #define STM32F10X_LD */ /* #define STM32F10X_LD_VL */ #define STM32F10X_MD /* #define STM32F10X_MD_VL */ /* #define STM32F10X_HD */ /* #define STM32F10X_HD_VL */ /* #define STM32F10X_XL */ /* #define STM32F10X_CL */

接下来,需要在 IDE 中定义宏 USE_STDPERIPH_DRIVER ,使 stm32f10x.h 直接包含头文件 stm32f10x_conf.h

完成以上步骤,就可以得到一个规范的工程框架。现在可以在 main.c#include "stm32f10x.h",然后开始编写应用程序。不过在编写程序之前,应该先对标准外设库的特点有一定的认识。

CMSIS的封装特点

在使用标准外设库前,有必要简单介绍一下 CMSIS 层对寄存器操作的封装特点。

在查阅 STM32 参考手册时,可以发现相应的寄存器并没有直接给出它的地址,而是提供了一个“地址偏移量”:

![[02-datasheet-register-address-1.png]]

这是因为 STM32 在为寄存器编址前,首先为每个外设分配了一个基地址(起始地址),可以在参考手册或相应芯片的数据手册中找到:

这些地址也定义在 stm32f10x.h 中:

#define PERIPH_BASE ((uint32_t)0x40000000) #define APB2PERIPH_BASE (PERIPH_BASE + 0x10000) #define GPIOA_BASE (APB2PERIPH_BASE + 0x0800) #define GPIOB_BASE (APB2PERIPH_BASE + 0x0C00) // ...

对于 GPIOA、GPIOB 这一系列外设,它们的功能几乎完全相同,因此具有的寄存器种类和数量也基本相同,可以使用相同的布局安排它们。因此只要提供寄存器的偏移地址,通过各个外设的基地址就可以计算出每个外设相应寄存器的实际地址。

在标准库中,ST 为每一个外设都定义了一个结构体,结构体成员的顺序和名称与该外设的寄存器在内存中的布局完全一致:

typedef struct { volatile uint32_t CRL; volatile uint32_t CRH; volatile uint32_t IDR; volatile uint32_t ODR; volatile uint32_t BSRR; volatile uint32_t BRR; volatile uint32_t LCKR; } GPIO_TypeDef;

最后在 stm32f10x.h 中通过一个宏定义,将这个数字地址强制转换为一个指向刚才定义的结构体的指针:

#define GPIOA ((GPIO_TypeDef *) GPIOA_BASE) #define GPIOB ((GPIO_TypeDef *) GPIOB_BASE)

通过指针与结构的方式,即可批量生成各个外设的寄存器定义。它的使用方式如下:

GPIOA->ODR = 0xFFFF;

以上代码在编译时,GPIOA 宏被替换为一个结构体指针,指向 GPIOA 外设的基地址;->ODR 表示需要访问这个结构体中名为 ODR 的成员。于是编译器根据结构体定义,计算出 ODR 成员相对于基地址的偏移量,就可以得到 GPIOA_ODR 寄存器的实际地址。

同时,在代码中的 GPIOA->ODR 是一个可读写的左值,因此在最终在生成的机器码中,可以将 0xFFFF 这个值写入 GPIOA_ODR 寄存器中。

标准外设库的特点

虽然 CMSIS 层对寄存器定义做了一定的封装,但是直接操作寄存器还是太过低效。为此,ST 在标准外设库中将寄存器操作封装为函数的形式。

认识标准外设库的最佳方式就是阅读其文档 stm32f10x_stdperiph_lib_um.chm ,该文档包含了从概览到每一个函数的每一个参数的说明。下面介绍一些通用的内容:

标准外设库的编程风格非常统一。文档中提到标准外设库符合 MISRA - C 2004 标准,这是汽车工业软件可靠性联会(The Motor Industry Software Reliability Association)发布的一个针对汽车工业软件安全性的 C 语言编程规范,它对语言环境、文档、标识符命名、类型声明和运行时错误等都提出了一些要求,致力于提升代码的可靠性、可移植与可维护性,不仅适用于汽车工业,也适用于任何嵌入式系统。

在标准外设库中,所有函数的命名均以外设+动作的形式,采用大驼峰的命名规范,但外设缩写和动作之间必须使用一个下划线来分隔,例如 GPIO_InitUSART_SendDataTIM_GetCounter 。不管是文件的命名还是标识符的命名,所有的外设都使用英文缩写表示,因此记住外设对应的缩写也是很有必要的。

在所有函数中,有一类函数比较特别:大部分外设在使用前都需要配置一些寄存器以指定运行方式(例如 GPIO 需要指定是输入还是输出,定时器需要设置定时值,串行通信口需要指定通信的波特率和校验方式等),而标准外设库对于外设的初始化严格遵循一个被称为“结构体配置”的编程模式:

  1. 每个外设都有一个后缀为 _InitTypeDef 的结构体,它的成员是外设可用的配置参数。
  2. 如果想要让某些配置参数保持默认值(上电复位后的状态),可以通过结构体变量指针调用后缀为 _StructInit() 的函数获得默认值。
  3. 如果需要改变某些配置参数,那么可以将期望的值赋给这个结构体变量的相应成员。
  4. 将这个配置好的结构体变量的指针,作为参数传递给后缀为 _Init() 的函数,完成对外设的最终配置。

这样做的好处是,一个外设的配置参数往往非常多。如果写成一个有十几个参数的函数,调用起来会非常混乱且容易出错。而使用结构体,可以将所有相关配置项清晰地组织在一起,代码可读性极高,也便于复用和修改。

此外,每个外设都有一个后缀为 _DeInit() 的函数,用于将外设相关的寄存器设置为复位后的默认值。

除了后缀为 _Init() 的函数用于一次性配置外,标准库还提供了大量独立的“动作”函数用于在程序运行时控制外设,例如:

  • _Set...() / _Write...(): 用于写入数据或设置状态
  • _Get...() / _Read...(): 用于读取数据或状态
  • ...Cmd() 函数通常用于使能/失能外设,它的第二个参数取值是如下枚举成员:
typedef enum {DISABLE = 0, ENABLE = !DISABLE} FunctionalState;

标准外设库还提供了一种称为运行时检查(Run-time checking)的方式来防止传入了不合理的参数。运行时检查具体是通过 assert_param 宏定义来实现的,这个宏定义位于 stm32f10x_conf.h 文件中:

/* Uncomment the line below to expanse the "assert_param" macro in the Standard Peripheral Library drivers code */ /* #define USE_FULL_ASSERT 1 */ #ifdef USE_FULL_ASSERT #define assert_param(expr) ((expr) ? (void)0 : assert_failed((uint8_t *)__FILE__, __LINE__)) void assert_failed(uint8_t* file, uint32_t line); #else #define assert_param(expr) ((void)0) #endif /* USE_FULL_ASSERT */

从以上代码可以看出,为了使 assert_param 宏有实际效果,需要取消 #define USE_FULL_ASSERT 1 宏的注释,然后提供 void assert_failed(uint8_t* file, uint32_t line) 函数的定义。当 assert_param 宏的表达式是 false 时,便会调用 assert_failed() 函数,通过用户指定的方式提示传入了错误的参数。

GPIO输出程序

在了解了标准外设库的特点以后,就可以使用标准外设库编写操作 GPIO 的程序了。按照标准外设库的特点,在使用 GPIO 时,首先应该查阅 GPIO_InitTypeDef 结构体的定义:

typedef struct { uint16_t GPIO_Pin; GPIOSpeed_TypeDef GPIO_Speed; GPIOMode_TypeDef GPIO_Mode; } GPIO_InitTypeDef;

第一个成员 GPIO_Pin 用于控制需要配置的引脚。在同一个头文件中,可以找到以下的宏定义,可以提供给该字段用于选定需要配置的引脚:

#define GPIO_Pin_0 ((uint16_t)0x0001) /*!< Pin 0 selected */ #define GPIO_Pin_1 ((uint16_t)0x0002) /*!< Pin 1 selected */ #define GPIO_Pin_2 ((uint16_t)0x0004) /*!< Pin 2 selected */ // ... #define GPIO_Pin_15 ((uint16_t)0x8000) /*!< Pin 15 selected */ #define GPIO_Pin_All ((uint16_t)0xFFFF) /*!< All pins selected */

从以上宏定义的值(都是 2 的整数次方)可以看出,一个 GPIO_initTypeDef 结构可以用于配置 GPIOx 的 16 个输出位引脚中的一个、多个或全部;如果要配置多个,可以使用按位或运算符 | 将它们相接,例如 GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_5 可以同时操作某个 GPIO 外设的 0 号、1 号和 5 号引脚。

第二个成员 GPIO_Speed 控制上文介绍的 GPIO “速度”。这个成员接收的值是如下枚举变量定义的值:

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

标准外设库在枚举变量的命名也有一定特点:对于 GPIO_Speed 之类的配置成员,如果它的取值是一系列预定义的枚举常量或符号常量,那么它们的名称以 GPIO_Speed_(即相同名称加上下划线)之类的形式开头。

第三个成员 GPIO_Mode 表示输出模式。以上介绍的输出结构对应了以下 4 种输出模式:

typedef enum { GPIO_Mode_Out_OD = 0x14, // 普通开漏输出模式 GPIO_Mode_Out_PP = 0x10, // 普通推挽输出模式 GPIO_Mode_AF_OD = 0x1C, // 复用开漏输出模式 GPIO_Mode_AF_PP = 0x18, // 复用推挽输出模式 // ... } GPIOMode_TypeDef;

于是,可以通过 GPIOSpeed_TypeDef 结构与 GPIO_Init() 函数对 GPIO 端口实现配置。这些可配置的数值,已经由标准外设库封装成见名知义的枚举常量,这使编写的代码变得非常简洁与清晰:

GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure);

这样,以后看到这段代码,就可以立即从表明推断出,它的含义是:将 GPIOB 的第 3 、4 、5 号口设置为“通用推挽输出模式”,且最大输出速度为 50MHz 。

但是注意,需要让 GPIO 可以正常工作的话,在配置前还需要执行一个步骤:在 STM32 中,为了节省功耗,所有外设的时钟默认都是关闭的(也就是说,所有外设默认都不在运行状态)。因此,在使用任何一个外设之前,第一步永远是使其能对应的时钟,这会涉及到以下两个函数:

  • void RCC_APB1PeriphClockCmd(uint32_t RCC_APB1Periph, FunctionalState NewState)
  • void RCC_APB2PeriphClockCmd(uint32_t RCC_APB2Periph, FunctionalState NewState)

根据标准外设库的命名风格,它的第一个参数取值应该是以相同名称加上下划线开头的某个常量;而该函数以 ...Cmd 结尾,所以第二个参数是 ENABLEDISABLE 。因此,打开 GPIOB 外设的时钟,可以通过如下方式调用函数:

RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);

标准库提供了和 GPIO 相关的一系列动作函数,以下两个和 GPIO 的“写”相关:

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

以下两个和 GPIO 的“读”相关,不过这里读取的是当前的输出状态:

  • uint16_t GPIO_ReadOutputData(GPIO_TypeDef* GPIOx)
  • uint8_t GPIO_ReadOutputDataBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)

由于 GPIO 可以单独操作输出数据寄存器的某一位,因此标准外设库也提供了相应的函数:

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

例如,以下函数调用将 GPIOB 的第 0 、1 号口输出设置为低电平:

GPIO_ResetBits(GPIOB, GPIO_Pin_0 | GPIO_Pin_1);

再如,以下函数调用将 GPIOB 的第 0 号口切换当的输出电平(即实现位翻转):

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

GPIO输入

在理解了 GPIO 输出的原理及程序编写思路后,GPIO 输入也是类似的。

GPIO输入原理

结构图的上半部分为输入结构,这个结构对应了以下 4 种输入模式:

typedef enum { // ... GPIO_Mode_AIN = 0x0, GPIO_Mode_IN_FLOATING = 0x04, GPIO_Mode_IPD = 0x28, GPIO_Mode_IPU = 0x48 } GPIOMode_TypeDef;

从数据流动的方向来看,首先 I/O 引脚外接了两个用于保护的二极管,它们的原理是对输入电压限幅:如果输入电压比 VDD (3.3V) 还高,则上方的二极管导通,使输入的电流直接流入 VDD ;同理,如果输入电压比 VSS (0V) 还低,则下方的这个二极管导通,使输入的电流直接流入 VSS ,这样就防止输入电压对内部电路造成损伤。正常范围内的输入电压下二极管均截止,对电路没有影响。

部分引脚的高电压保护连接的是芯片内部的 5V 区域,因此这些引脚具有更高的有效输入电压,具体的引脚号可以查阅芯片的数据手册,标有 FT(Five Tolerant) 的就是支持 5V 输入的引脚。

输入的信号接下来在内部遇到了两个开关和电阻:与 VDD 相连的为上拉电阻,与 VSS 相连的为下拉电阻。可以通过设置配置寄存器 CRL 、CRH 来控制这两个开关,于是就可以得到 GPIO 的上拉输入模式(对应 GPIO_Mode_IPU )和下拉输入模式(对应 GPIO_Mode_IPD )。从它的结构可以看出,若 GPIO 引脚配置为上拉输入模式,在默认状态下( GPIO 引脚无输入),读取得的 GPIO 引脚数据为 1(高电平)。而下拉模式则相反,在默认状态下其引脚数据为 0(低电平)。

如果既没有接上拉电阻,也没有接下拉电阻,这样直接输入就得到了 STM32 的浮空输入模式(对应 GPIO_Mode_IN_FLOATING )。配置成该模式时,在没有输入信号的情况下引脚电压是不确定的值。由于其输入阻抗较大,一般把这种模式用于标准的通信协议如 UART 和 I2C 等的接收端。

GPIO 的上拉电阻和下拉电阻的阻值都较大,这样可以尽量不影响输入的电压。

输入的信号接下来会继续进入到 TTL 施密特触发器中,TTL 施密特触发器是一种阈值开关电路,当输入电压大于某一阈值时,它会将输入抬升为固定的高电平;当输入电压小于某一阈值时,它会将输入降低为固定的低电平,这样可以改善数字信号的质量。模拟输入模式(对应 GPIO_Mode_AIN )则关闭了施密特触发器,不接上、下拉电阻,经由另一线路把电压信号传送到其它外设模块。如果要使用 STM32 的模拟电压采集功能,就必须关闭施密特触发器,设置为模拟输入模式。

最后,输入的数字信号还可能需要转交给其余外设模块,例如串口也需要通过 GPIO 引脚实现输入,这就需要复用功能输入。复用功能输入不需要由 GPIO 配置,而是由使用它的相关外设配置。

由于 GPIO 的输入部分和输出部分是串联的,因此在输出模式下,输入部分其实也是有效的,可以读取引脚的电平;不过在输入模式下,两个 MOS 管都无效,因此无法使用输出功能。

GPIO输入程序

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

和输出一样,程序在使用 GPIO 时,应首先打开 GPIO 外设时钟,然后初始化 GPIO 外设。这里选用 GPIOC 的 0 号引脚接收按键输入,模式为浮空输入模式,速度用不着,因此对应的初始化方式为:

RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE); GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOC, &GPIO_InitStructure);

之所以选择使用浮空输入模式,是因为通常在设计按键时,在按键没有按下的时候一般直接接地或接电源,本身就处于一种下拉或上拉模式,所以将输入模式配置成浮空输入。

GPIO 的输入的“读”主要依靠以下两个函数:

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

之所以使用 uint16_t 类型的值接收整个外设的读入,是因为 GPIOx 外设的数据接收寄存器 IDR 同样只有 16 位有效。

仿照按键输入的一般思路,可以编写出以下判断按键是否按下的程序:(这里不考虑按键的抖动消除处理)

typedef enum { KEY_ON = 0, KEY_OFF } KeyState; KeyState 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; }
京ICP备2021034974号
contact me by hello@frozencandles.fun