STM32学习笔记02

STM32标准库入门

系列文章

STM32 库入门

库 Why

单片机最基本的功能就是电平的输入/输出。在STM32中,I/O引脚可以被软件设置成不同的功能,包括输入和输出,因此又被称为GPIO(General-Purpose I/O)。而STM32的GPIO引脚又分为 GPIOA 、GPIOB 一直到 GPIOG 几个端口,每个端口可以控制0号到15号各个位。

在学习51单片机的过程中,都是通过直接操作单片机的寄存器,来改变51单片机的运行状态。STM32当然也支持这么做,不过在使用STM32时,更建议使用官方库来操作单片机。

例如,如果需要让51单片机对外输出 8 个高电平来控制数码管,可以控制51单片机的 P0 口,让其全部输出高电平,在不包含任何头文件的情况下,其语句类似:

sfr P0 = 0x80;
P0 = 0xFF;

该操作分为两步:首先使用 sfr 关键字找到特殊功能寄存器 P0 对应的内存地址,然后设置该特殊功能寄存器的各位的值。

如果想使用 STM32 实现以上过程,方法则复杂得多。下面是使用 STM32 实现以上要求的步骤。

使用寄存器控制引脚输出

如果需要控制 STM32 的引脚向外输出数字信号,从而控制外部设备例如LED灯、蜂鸣器等,也需要通过某一个寄存器对外输出数据。在 STM32 内,端口输出数据寄存器 GPIOx_ODR 充当该角色。这样的寄存器一共有 5 个,因此 x 可以代表 A~E 。每个端口输出数据寄存器都有 32 位,不过用户仅可以操作它的低 16 位。

例如,已知 GPIOB_ODR 寄存器的绝对地址是 0x4001 0C0C ,通过该信息,可以很快写出以下语句,让 GPIOB_ODR 寄存器的 16 位管脚全部输出高电平:

unsigned int* GPIOB_ODR = 0x40010C0C;
*GPIOB_ODR = 0xFFFF;

直接操作寄存器的第一个难点在于找到寄存器对应的内存地址。STM32 的片上外设区分为三条总线,根据外设速度的不同,不同总线挂载了不同的外设:APB1 挂载低速外设,APB2 和 AHB 挂载高速外设。相应总线的最低地址称为该总线的基地址,总线基地址也是挂载在该总线上的首个外设的地址。其中 APB1 总线的地址最低,片上外设从这里开始,也叫外设基地址。各个总线的地址如下表所示:

总线名称总线基地址相对外设基地址的偏移
APB10x4000 00000x0
APB20x4001 00000x0001 0000
AHB0x4001 80000x0001 8000

每一条总线上也挂载着各种外设,这些外设也有自己的地址范围。以 GPIO 外设为例,该外设挂载在 APB2 总线上,外设的地址可以使用相对该总线的偏移地址来衡量。STM32 的和 GPIO 有关的外设共有 7 个,依次命名为 GPIOA~GPIOG 。其中 GPIOB 外设相对 APB2 总线的偏移地址为 C00 ,那么它的基地址就为 0x4001 0C00

GPIO有很多个寄存器,每一个都有特定的功能。每个寄存器为 32bit ,占用 4 个字节。在该外设的基地址上按照顺序排列,寄存器的位置都以相对该外设基地址的偏移地址来描述。以 GPIOB_ODR 寄存器为例,它排在第 4 位,那么相对 GPIOB 寄存器的偏移地址为 0x0C 。将以上偏移地址相加,最终得到 GPIOB_ODR 的绝对地址 0x4001 0C0C

仅仅将数据写入寄存器 GPIOB_ODR 内,还不能点亮LED灯。这是因为端口输出数据寄存器的每个位都是可以同时被读/写的。STM32 为了保证读/写过程顺利进行并提升效率,所有的 GPIO 外设需要提前设置好使用哪一个模式。这个读/写模式由端口配置低寄存器 GPIOx_CRL 和端口配置高寄存器 GPIOx_CRH 来决定是输入还是输出。

这两种寄存器都以 2 位为一组控制一个 I/O 位的一个功能。I/O 可以改变两种功能:模式(低2位控制)和速度(高2位控制),因此每 4 位决定一个 I/O 口的具体状态。因此,一个这样的寄存器可以控制 8 个 I/O 口的读写状态,一对端口配置高/低寄存器即可管理一个端口输出数据寄存器 16 位的全部状态。

为了点亮LED灯,查阅资料后决定将 I/O 口配置推挽输出模式(对应的 2 位需要设置为 0b00 ),任意输出速度(只要非 0b00 值即可)。那么就要设置 GPIOB 的端口配置低寄存器 GPIOB_CRL 的值。由于控制LED灯只需要 1 位输出,因此如果要让 GPIOB_ODR 的最低位为输出状态,只需要通过位操作来将 GPIOB_CRL 的最低 4 位设置为 0b0100 即可。再已知 GPIOx_CRL 是对应外设基地址上的第一个寄存器,那么可以计算出 GPIOB_CRL 的绝对地址为 0x4001 0c00

将这一步整理成代码为:

*(unsigned int*) 0x40010c00 |= ~(1 << (0b0100 * 0));

其实仅靠以上两步,程序依旧不能让GPIO正常输出低电平。仔细观察STM32系统结构,为了降低功耗,GPIO 置于外设上,如下图所示:

内核要操作这些外设,需要经过 AHB 系统总线、桥接1、APB2 线路到达 GPIO 上。而 STM32 上电后,外设中的复位和时钟控制(RCC)相应的寄存器并没有开始运作,AHB系统总线也就无法传输数据。

APB2 外设时钟使能寄存器 RCC_APB2ENR 负责控制 I/O 端口的时钟使能。它的第 2~8 位 IOPxEN 决定 GPIOx 的 I/O 端口时钟使能,当清 0 时,I/O 端口时钟关闭;置 1 时时钟开启。

与复位和时钟控制(RCC)有关的寄存器都在 AHB 系统总线上,且相对偏移地址为 0x0000 9000 。RCC_AHBENR 是其中的第 7 个寄存器,因此相对再偏移 0x18 字节。通过以上的偏移和,可以计算出 RCC_AHBENR 的绝对地址为 0x4002 1018

终于,可以编写出如下代码:

int main(void) {
    /* open clock for GPIOB */
    *(unsigned int*) 0x40021018 |= ((1) << 3);
    /* set output mode */
    *(unsigned int*) 0x40010c00 |= ~(1 << (4 * 0));
    /* set output register bit */
    *(unsigned int*) 0x40010c0c &= ~(1 << 0);
}

库 How

STM32 的标准外设库可以在 https://www.st.com/content/st_com/en/products/embedded-software/mcu-mpu-embedded-software/stm32-embedded-software/stm32-standard-peripheral-libraries.html#products 里下载到,打开该页面之后选择对应芯片版本的固件库(注意第一个内容不是固件库),点击第一列进入下载页面,然后可以看到中央的“Get latest”按钮,点击后要输入邮箱名,稍等几分钟可以在邮箱中看到一封邮件,点击邮件中的下载按钮就可以下载了。

STM32标准库的一般结构

从官网获取 STM32 的标准库后,直接解压到本地,就可以看到 STM32 标准库的结构了。这里以3.6版本的固件库为例,介绍STM32固件库的主要文件结构。

标准库文件夹里主要包含了以下文件或目录:

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

在开发中,主要用到 Libraries 目录,将该目录下的标准库文件添加到工程中,并借助文档来使用这些库函数。

下图展示了 Libraries 目录的结构:

最外层主要包含两个文件夹:CMSIS 文件夹存放内核的库文件,而 STM32F10x_StdPeriph_Driver 文件夹存放外设的库文件。

STM32采用的是 Cortex-M3 内核,采用该内核的单片机 ST 等公司则基于该内核规划了不同的外设,这些差异却导致软件在同内核、不同外设的芯片上移植困难。为了解决不同芯片厂商生产的 Cortex 微控制器软件的兼容性问题,ARM 与芯片厂商建立了 CMSIS 标准(Cortex Microcontroller Software Interface Standard)。该标准在用户和单片机之间建立新建了一个软件抽象层,ARM 公司负责定义内核寄存器和外设的名称,而芯片生产商则负责确定外设的具体地址、中断定义等信息。

所谓 CMSIS 标准,实际是在用户和单片机之间建立新建了一个软件抽象层。用户只需要知道单片机里有哪些可用的设施,而不需要管这些设施的具体位置、实现细节。

CMSIS/CM3 里又分为两个文件夹,其中 DeviceSupport 文件夹存放启动文件,它们根据编译器的不同被划分在各自的文件夹中。另一个文件夹 CoreSupport 包含了两个文件 core_cm3.c 和对应的头文件,它们的作用是为采用 Cortex-M3 核设计 SoC 的芯片商设计的芯片外设提供一个进入 CM3 内核的接口。这里可以先不用理会它有什么用,只需要将它加入工程文件即可。

core_cm3.c 文件还有一些与编译器相关的条件编译语句,用于屏蔽不同编译器的差异。例如,它定义了 uint32_t 等一系列类型声明。这些类型等定义可以用来避免因为编译器不同而导致的跨平台错误。core_cm3.c 与启动文件一样都是底层文件,都是由 ARM 公司提供的,遵守 CMSIS 标准,即所有 Cortex-M3 芯片的库都带有这个文件,这样软件在不同芯片的移植工作就得以简化。

DeviceSupport 中包含的是启动文件、外设寄存器定义和中断向量定义层的一些文件,它们是由 ST 公司提供的。层次进入之后可以看到其中包含的 system_stm32f10x.c 及其头文件,功能是设置系统时钟和总线时钟。所有的外设都与时钟的频率有关,所以这个文件的时钟配置是很关键的。除此之外,该文件夹中还包含一个头文件 stm32f10x.h ,它是一个非常重要的头文件,其重要性几乎等价于 51 单片机的 reg51.h 。它包含了 STM32 中所有的地址定义并包含了几个重要的配置文件,没有这些地址就无法正确控制 STM32 ,因此在使用到 STM32 固件库的地方都要包含这个头文件。

STM32F10x_StdPeriph_Driver 文件夹下有 incsrc 两个文件夹,属于CMSIS的设备外设函数部分,这些外设是芯片制造商在 Cortex-M3 核外加进去的。要使用到的 GPIO 的相关函数与功能就存放在这个文件夹里。

这两个文件夹中还有很特别的 misc.c 及其头文件,这个文件提供了外设对内核中的 NVIC(中断向量控制器)的访问函数,在配置中断时,必须把这个文件添加到工程中。


标准库的介绍就到这里了。最后,在总目录的 Project/STM32F10x_StdPeriph_Template 文件夹下,存放了官方的一个库工程模板。在用库建立一个完整的工程时,还需要添加这个目录下的 stm32f10x_it.cstm32f10x_it.hstm32f10x_conf.h 这三个文件:

标准库对GPIO的封装思路

在之前的示例中,通过寄存器来控制GPIO。在不包括任何头文件的情况下,只能通过指针访问寄存器的地址,这样做需要查阅相关的文档,不仅效率低下,而且容易出错,在查看代码时的可读性也很差。

接下来以通过 GPIO 点亮LED灯(输出)和从 GPIO 读取键盘状态(输入)为例,介绍通过标准库操作 GPIO 的方法,并体会到标准库是如何封装 GPIO 的相关功能的。

之前通过寄存器来操作 GPIO 口点亮LED灯,这个过程需要明白以下信息:

  1. 需要操作哪一个 GPIO 口的哪些位
  2. 需要对这个 GPIO 口的这些位实施哪些操作

首先来看标准库对 GPIO 有关的寄存器的封装。这一部分内容在头文件 stm32f10x.h 里。

首先,宏 PERIPH_BASE 提供了所有外设的基地址:

#define PERIPH_BASE           ((uint32_t)0x40000000)

所有的外设都挂载在三条总线上:APB1 总线、APB2 总线和 AHB 系统总线,它们的基地址定义由以下宏给出:

#define APB1PERIPH_BASE       PERIPH_BASE
#define APB2PERIPH_BASE       (PERIPH_BASE + 0x10000)
#define AHBPERIPH_BASE        (PERIPH_BASE + 0x20000)

这些地址都是由外设基地址+偏移地址的形式构成。

GPIO 端口的基地址挂载在 APB2 总线上,它们的宏定义同样由基地址和偏移地址构成:

#define GPIOA_BASE            (APB2PERIPH_BASE + 0x0800)
#define GPIOB_BASE            (APB2PERIPH_BASE + 0x0C00)
// ...

通过宏 GPIOB_BASE ,便可以得到 B 组 GPIO 相关寄存器的基地址。而与 GPIOB 相关的寄存器地址,都可以通过该基地址获取。

例如,在 STM32 官方手册中查阅 GPIOB_ODR 寄存器的地址,可以找到它的地址偏移量:

该偏移量就是基于 GPIOB_BASE 基地址的偏移值。

有了以上 GPIO 的基地址和各个寄存器的地址偏移值,很容易就可以通过宏定义出各个寄存器的地址。不过,STM32的固件库以结构的方式定义了这些寄存器:

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;

这样就可以通过一个结构管理一组 GPIO 有关的寄存器。前文说过,每个寄存器为 32bit ,占用 4 个字节,且它们的地址在该外设的基地址上按照顺序排列。如果采用结构体的方式定义,将每个结构成员设置为 32bit 大小,再将结构体的地址指向对应的外设的基地址上,由于结构体成员的内存空间是紧密排列的,这样结构成员的地址偏移,恰好与 GPIO 外设定义的寄存器地址偏移一一对应!这里,__IO 宏是 volatile 修饰符的别名,它的目的是防止编译器优化地址的排布,而打乱寄存器正确的地址信息。

因此,只要给结构体设置好首地址,就能确定结构体内成员的地址确定,然后就能以结构体的形式访问寄存器:

GPIO_TypeDef* GPIOB = GPIOB_BASE;
GPIOB->ODR = 0xFFFF;        // write data
uint32_t tmp = GPIOB->IDR;  // read data

标准库已经更进一步,直接使用宏定义好的 GPIO_Type 类型指针和各个 GPIO 端口的首地址,可以以结构指针的形式通过该宏访问寄存器:

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

只要通过这些宏,就可以直接操作外设的寄存器了,无需考虑寄存器的地址如何。


虽然 stm32f10x.h 已经将各个寄存器封装到一种非常简明的程度了,但是既然使用库操作 STM32 ,通过直接操作寄存器的方式来操作 GPIO 仍然十分繁琐。

这个时候,就要借助标准库对外设的封装了。stm32f10x_gpio.h 头文件对GPIO的操作进行了抽象化的封装。接下来看看这一部分。

首先,在该头文件内,有对 GPIO 进行初始化的结构体封装:

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 个输出位引脚中的一个、多个或全部;如果要配置多个,可以使用或运算符 | 将它们相接。

GPIOSpeed_TypeDefGPIOMode_TypeDef 是两个枚举类型,它们的具体定义如下:

typedef enum {
    GPIO_Speed_10MHz = 1,
    GPIO_Speed_2MHz,
    GPIO_Speed_50MHz
} GPIOSpeed_TypeDef;
typedef enum {
    GPIO_Mode_AIN = 0x0,
    GPIO_Mode_IN_FLOATING = 0x04,
    GPIO_Mode_IPD = 0x28,
    GPIO_Mode_IPU = 0x48,
    GPIO_Mode_Out_OD = 0x14,
    GPIO_Mode_Out_PP = 0x10,
    GPIO_Mode_AF_OD = 0x1C,
    GPIO_Mode_AF_PP = 0x18
} GPIOMode_TypeDef;

这两个字段的含义,就是前文说到的每一个 I/O 口可以改变的两种功能:模式和速度。

于是,可以通过初始化 GPIOSpeed_TypeDef 结构与它的三个字段,使用合适的标识符名便可以对 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;

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

使用标准库操作GPIO

之前已经定义了一个 GPIO_InitTypeDef 结构的变量,但很显然它不能直接用于底层的配置,因为还缺少以下内容:

  1. 只给了一个结构体,那么要如何根据这个结构体设置相应的寄存器?
  2. 要设置 GPIOA~GPIOG 的哪一个外设?

对于第一个问题,stm32f10x_gpio.h 提供了以下函数:

void GPIO_Init(GPIO_TypeDef* GPIOx, GPIO_InitTypeDef* GPIO_InitStruct)

该函数接受两个参数,类型 GPIO_TypeDef 之前介绍过,stm32f10x.h 内的宏 GPIOA ~ GPIOG 正是该类型的指针。

该函数的作用是通过初始化结构的信息,来初始化 GPIOx 外设的寄存器。因此,完整的代码:

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(GPIOA, &GPIO_InitStructure);

表达的含义是:初始化 GPIOB 外设,让它的 Pin3 、Pin4 、Pin5 设置为最高频率为 50MHz 的“通用推挽输出模式”。

借助抽象的结构,通过封装的标准库函数,直接将给定的信息映射到底层寄存器中。这就是使用标准库的优点,只需要知道 STM32 提供了哪些资源,然后就可以使用标准库操作这些资源了,根本不需要知道要配置哪些寄存器,设置它们的哪些位为什么值。

最后,可以对配置完成的GPIO引脚置1或清0。它们分别要用到以下函数:

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

调用以上函数便可对 GPIOx 中的某几个位置1或清零。例如:

GPIO_SetBits(GPIOB, GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5);

可以将 GPIOB 的第 3 、4 、5 位设置为 1 。


以上借助库函数,很方便地便配置完成了 GPIO 相应的寄存器并使其对外输出。不过在这之前还有一步,就是打开外设时钟。

其实打开外设时钟之前,还需要配置好系统时钟,设置一系列的时钟来源、倍频、分频等控制参数。不过这些工作都由标准库处理完成了。这里可以先不管标准库如何处理的,只需要打开外设时钟即可。

与复位和时钟控制(RCC)有关的函数和宏都定义在 stm32f10x_rcc.h 内,这里使用以下函数打开 APB2 外设的时钟:

void RCC_APB2PeriphClockCmd(uint32_t RCC_APB2Periph, FunctionalState NewState)

该函数接收两个参数。第一个参数指定外设名,可以在同一个头文件内找到若干以 RCC_APB2Periph_ 开头的宏定义,它们用于表示哪一个挂载在 APB2 总线上的外设时钟。第二个参数是枚举类型,其定义为:

typedef enum { DISABLE = 0, ENABLE = !DISABLE } FunctionalState;

这样,只需要通过如下调用函数的方式:

RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);

就可以将 APB2 总线上的 GPIOB 外设时钟置为打开状态。