兆易创新 UINIO-MCU-GD32F350 固件库开发指南

早在新冠疫情爆发前的 2019 年,就曾经撰写过一篇关于 ARM 标准库的技术长文 《意法半导体 STM32F103 标准库典型实例》 ,文章非常详尽的介绍了各种常见片上外设资源的应用。时至 4 年以后的今天,国产微控制器在工程实践领域已经得到了广泛运用,因而基于兆易创新 推出的国产 ARM 微控制器,设计和制作了 UINIO-MCU-GD32F350RBT6 这款开源核心板,同时撰写了本篇文章作为配套的资料教程,希冀为国产芯片的商业化普及尽自己一份绵薄之力。

UINIO-MCU-GD32F350RBT6 是一款采用 LQFP64 封装的 GD32F350RBT6 微控制器核心板,基于 ARM Cortex-M4 内核架构,主频高达 108MHz,拥有 128K 容量 Flash,以及 16K 的 SRAM。而 UINIO-MCU-GD32F103C 采用 LQFP48 封装的 GD32F103Cxxx 系列微控制器(包括 GD32F103CBT6GD32F103C8T6GD32F103C6T6GD32F103C4T6),基于 ARM Cortex-M3 内核架构,主频达到 108MHz,拥有 16K ~ 128K 容量 Flash,以及 6K ~ 20K 的 SRAM。

准备 GD32 支持包 & 固件库

这里以 UINIO-MCU-GD32F350RBT6 核心板作为例子,首先需要前往兆易创新的 GD32 MCU 微控制器 官方网站,把如下两个开发资源下载到本地计算机:

  1. GD32F350RBT6 固件库 GD32F3x0_Firmware_Library_V2.2.1
  2. Keil uVision5 开发环境的支持包 GigaDevice.GD32F3x0_DFP.3.0.2.pack

然后,启动 Keil uVision5 开发环境,开始导入或者在线安装 GigaDevice.GD32F3x0_DFP.3.0.2.pack 支持包:

接下来,解压 GD32F3x0_Firmware_Library_V2.2.1 固件库,此时会得到如下一系列目录:

  • Docs:包含有官方评估板的原理图和固件库的使用指南
  • Examples:各种 GD32F350RBT6 片上外设的官方示例源程序。
  • Firmware:包含有内核库 CMSIS标准外设库 GD32F3x0_standard_peripheralUSB 文件系统库 GD32F3x0_usbfs_library 三个子目录。
  • Template:集成开发环境 IARKeil uVision4 的工程模板,包含有 LED 闪烁、USART 打印、按键控制的简单示例程序。
  • Utilities:一些第三方组件和 GD32 配套的评估板测试文件。

其中 Examples 下面的每一个子目录,都对应着一种片上外设的示例程序,里面通常会包含有如下的源文件:

  • main.c:主程序源文件。
  • systick.h:SysTick 精准延时头文件;
  • systick.c:SysTick 精准延时源文件;
  • GD32f3x0.it.h:中断处理程序头文件;
  • GD32f3x0_it.c:中断处理程序源文件(未使用中断,所有函数体为空);
  • GD32f3x0_libopt.h:通过预处理语句 #include 包含指定的外设库 .h 头文件(默认导入全部外设);

Firmware 目录下面包含有 GD32F350RBT6 固件库的核心源文件:

  • CMSIS 子目录包含有 ARM Cortex-M4 内核的支持文件、启动代码、库引导文件,以及 GD32F3x0 的全局头文件和系统配置文件。
  • GD32F3x0_standard_peripheral 子目录下的 Include 包含了固件库所需要的头文件,而 Source 则包含有固件库所需的源文件。

测试 UINIO-MCU-GD32 核心板

打开 GD32F3x0_Firmware_Library_V2.2.1 固件库下面的 Template 目录,删掉除开 Keil_project 目录之外的其它文件与目录,然后将 Examples\GPIO\Running_led 内的全部源文件,拷贝至 Template 目录当中,从而获得如下的文件目录结构:

1
2
3
4
5
6
7
8
9
10
11
Template
├── Keil_project
│ ├── Project.uvopt
│ └── Project.uvproj
├── gd32f3x0_it.c
├── gd32f3x0_it.h
├── gd32f3x0_libopt.h
├── main.c
├── readme.txt
├── systick.c
└── systick.h

鼠标双击 Template 目录下面的工程描述文件 Project.uvproj,启动 Keil uVision5。由于 GD32F3x0_Firmware_Library_V2.2.1 当中的示例工程采用的是 Keil uVision4 建立和编译,因而此时会弹出下面的错误信息:

按下【确定】按钮忽略这些错误信息,依次选择顶部菜单栏上面的【Project -> Manage -> Migrate to Version 5 Format...】,把工程迁移成为 Keil uVision5 兼容的格式:

此时会提示工程描述文件需要从 Keil uVision4Project.uvproj 保存为 Keil uVision5Project.uvprojx,直接按下【确定】按钮即可:

在开始接下来的操作之前,需要先将 Keil uVision5 工程的编译目标切换为 UINIO-MCU-GD32F350RBT6 核心板所使用的型号【GD32F350】:

点击顶部工具栏上的【Options for Target...】按钮,指定目标选项对话框里的【ARM Compiler】版本为 compiler version 5

注意:这里必须修改 Keil uVision5 当中 ARM 编译器版本,否则会导致后续的编译操作出现错误,具体请参考 《ARM 调试工具 UINIO-DAP-Link 应用详解》 一文的 添加 ARM Compiler version 5 小节内容。

切换至对话框的【Output】选项卡,在勾选【Create HEX File】的同时,把【Name of Executable】修改为 Project.hex(务必添加 .hex 后缀,否则默认烧录的是 .axf 文件):

再切换至对话框当中的【Debug】选项卡,此时需要将 UINIO-DAP-Link 插入至计算机的 USB 接口,然后在下拉选择【CMSIS-DAP Debugger】之后,再按下右侧的【Settings】按钮:

此时会弹出调试器设置对话框,这里我们选择【UINIO-CMSIS-DAP】,并且将【Max Clock】配置为 10MHz

最后再切换至【Flash Download】选项卡,勾选【Reset and Run】,并且点击【Add】按钮添加片上 Flash 的编程算法:

完成上述配置步骤之后点击【OK】,回到 Keil uVision5 的主界面,此时按下快捷键【F7】或者顶部工具栏上的【Build】按钮编译示例工程,再按下快捷键【F8】或者【Download】按钮将编译后得到的 .hex 程序下载至 UINIO-MCU-GD32F350RBT6 核心板运行,此时整个工程的目录文件结构如下所示,其中的 Out 目录保存着编译后产生的十六进制 .hex 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Template
├── Keil_project
│ ├── Project.uvguix.hank
│ ├── Project.uvopt
│ ├── Project.uvoptx
│ ├── Project.uvproj.saved_uv4
│ ├── Project.uvprojx
│ ├── RTE
│ │ ├── _GD32F310
│ │ │ └── RTE_Components.h
│ │ ├── _GD32F330
│ │ │ └── RTE_Components.h
│ │ └── _GD32F350
│ │ └── RTE_Components.h
│ ├── list
│ │ ├── Project.map
│ │ └── startup_gd32f3x0.lst
│ └── output
├── gd32f3x0_it.c
├── gd32f3x0_it.h
├── gd32f3x0_libopt.h
├── main.c
├── readme.txt
├── systick.c
└── systick.h

该示例程序会每间隔 4 秒的时间,循环切换 UINIO-MCU-GD32F350RBT6 的四个 GPIO 引脚 C2C10C11C12 的高低电平状态,此时通过万用表就可以测量出运行结果,从而方便的判断出程序是否下载成功,以及核心板运行是否存在有故障。

注意DAPLink 是 ARM 系列微控制器开发过程当中,程序下载与调试不可少的工具,相关资料和设计资源可以参考笔者之前撰写的《ARM 调试工具 UINIO-DAP-Link 应用详解》 一文。

搭建 Keil uVision5 自定义工程

新建目录与拷贝源文件

本节内容开始尝试自己动手搭建 Keil uVision5 工程,首先新建一个名称为 Keil-GD32F350RBT6Keil uVision5 工程,并将其保存至同名的目录下面,然后再新建如下一系列子目录,并且将固件库里的源文件拷贝至对应的子目录:

  • Applications:保存应用层相关的源文件。
  • Documents:用于存放 Markdown 说明文档,可以预先放置一个 README.md 文件。
  • Drivers:存放针对 UINIO-MCU-GD32F350RBT6 定制的板级驱动程序。
  • Firmware:用于放置 GD32F3x0_Firmware_Library_V2.2.1 当中 Firmware 目录下的全部内容(即 CMSISGD32F3x0_standard_peripheralGD32F3x0_usbfs_library 三个子目录)。
  • Sources:用于保存 GD32F3x0_Firmware_Library_V2.2.1 下面的 Template 目录当中,除 IAR_projectKeil_projectreadme.txt 之外的文件(即main.c/hsystick.c/hgd32f3x0_it.c/hgd32f3x0_libopt.h 七个源文件)。

创建分组与添加源文件

鼠标点击 Keil uVision5 顶部菜单栏上面的【File Extensions, Books and Environment...】按钮:

在弹出的工程管理项对话框当中,分别将左侧的【Project Targets】命名为 Keil-GD32F350RBT6,而中间的【Groups】则分别建立如下几个分组,并且通过右侧的 Files 向指定分组添加相应的源文件:

  • CMSIS 分组:分别添加 Keil-GD32F350RBT6\Firmware\CMSIS\GD\GD32F3x0\Source 目录下的 system_gd32f3x0.c 外设接入层源文件,以及 Keil-GD32F350RBT6\Firmware\CMSIS\GD\GD32F3x0\Source\ARM 目录下的 startup_gd32f3x0.s 启动文件(添加对话框的文件类型要修改为 .s)。
  • Drivers 分组:暂时不需要添加任何源文件。
  • Firmware 分组:按需添加 Keil-GD32F350RBT6\Firmware\GD32F3x0_standard_peripheral\Source 目录下的 .c 源文件(其中的 gd32f3x0_rcu.cgd32f3x0_gpio.c 属于必须添加)。
  • Documents 分组:将 Keil-GD32F350RBT6\Documents 目录下新建的 README.md 文件添加进去。
  • Applications 分组:添加 Keil-GD32F350RBT6\Sources 目录下的 main.csystick.cgd32f3x0_it.c 三个源文件。
  • Sources 分组:暂时不需要添加任何源文件。

完成上述操作之后,在工程管理项对话框当中,各个分组下面的源文件情况如下图所示:

点击【OK】按钮关闭工程管理项对话框,此时 Keil uVision5 左侧呈现的工程目录结构如下面所示:

移除 Source 目录下的冗余代码

为了避免工程搭建过程当中,直接拷贝官方固件库 Template 目录下的源文件,出现冗余代码导致编译错误的情况,接下来还需要对 Source 目录进行一些清理工作。首先需要删除掉该目录下 main.c 源文件里多余的内容,只需要保留如下所示的代码:

1
2
3
4
5
6
7
8
9
10
11
#include "gd32f3x0.h"
#include "systick.h"
#include <stdio.h>
#include "main.h"

int main(void) {
/* configure systick */
systick_config();

while(1) {}
}

除此之外,还需要再移除掉 Source 目录下 gd32f3x0_it.c 源文件里面,如下所示的无效代码片段:

1
2
3
4
5
6
7
8
9
10
11
/*!
\brief this function handles SysTick exception
\param[in] none
\param[out] none
\retval none
*/
void SysTick_Handler(void)
{
led_spark();
delay_decrement();
}

配置编译器路径与选项

点击 Keil uVision5 工具栏顶部的【Options for target】,在弹出的目标选项对话框当中,首先切换至【C/C++】选项卡,将【Define】输入框设置为 USE_STDPERIPH_DRIVER,GD32F3X0,GD32F350

然后再点击对话框当中【Include Paths】输入框右侧的按钮,配置 ARM 编译器分别包含 Keil-GD32F350RBT6 工程目录下的如下路径:

  • .\Sources
  • .\Firmware\CMSIS
  • .\Firmware\CMSIS\GD\GD32F3x0\Include
  • .\Firmware\GD32F3x0_standard_peripheral\Include

接下来切换至【Target】选项卡,选择 ARM 编译器的版本为 5,并勾选界面上 Keil uVision5 自带的用于串口重定向的【Use MicroLIB】工具库:

最后切换到【Output】选项卡,将【Name of Executable】输入框设置为 Keil-GD32F350RBT6.hex,并且勾选 Create HEX File 使得编译结果为十六进制 .hex 格式:

测试工程的编译下载

完成上述配置工作之后,关闭 Keil uVision5 界面上的全部对话框,然后按下快捷键【F7】或者顶部工具栏上的【Build】按钮,将新建工程里的相关源文件编译为一个 Keil-GD32F350RBT6.hex 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
Build started: Project: Keil-GD32F350RBT6
*** Using Compiler 'V5.06 update 7 (build 960)', folder: 'D:\Software\Tech\Keil\ARM\ARMCC\Bin'
Build target 'Keil-GD32F350RBT6'
assembling startup_gd32f3x0.s...
compiling gd32f3x0_dma.c...
compiling gd32f3x0_dbg.c...
compiling gd32f3x0_crc.c...
compiling gd32f3x0_ctc.c...
compiling gd32f3x0_cec.c...
compiling gd32f3x0_dac.c...
compiling gd32f3x0_exti.c...
compiling gd32f3x0_adc.c...
compiling gd32f3x0_cmp.c...
compiling system_gd32f3x0.c...
compiling gd32f3x0_fmc.c...
compiling gd32f3x0_fwdgt.c...
compiling gd32f3x0_i2c.c...
compiling gd32f3x0_misc.c...
compiling gd32f3x0_gpio.c...
compiling gd32f3x0_pmu.c...
compiling gd32f3x0_spi.c...
compiling gd32f3x0_syscfg.c...
compiling gd32f3x0_rcu.c...
compiling gd32f3x0_rtc.c...
compiling gd32f3x0_tsi.c...
compiling gd32f3x0_timer.c...
compiling gd32f3x0_wwdgt.c...
compiling gd32f3x0_usart.c...
compiling gd32f3x0_it.c...
compiling main.c...
compiling systick.c...
linking...
Program Size: Code=1104 RO-data=368 RW-data=4 ZI-data=1028
FromELF: creating hex file...
".\Objects\Keil-GD32F350RBT6.hex" - 0 Error(s), 0 Warning(s).
Build Time Elapsed: 00:00:03

如果编译结果显示 0 Error(s), 0 Warning(s),说明这个 Keil uVision5 工程已经搭建成功。接下来,就可以按下快捷键【F8】或者顶部工具栏上的【Download】按钮,把编译后得到的十六进制文件 Keil-GD32F350RBT6.hex,通过 UINIO-DAP-Link 下载至 UINIO-MCU-GD32F350RBT6 核心板上面运行:

1
2
3
4
5
Load "D:\\Workspace\\UINIO-MCU-GD32F350RBT6\\Keil-GD32F350RBT6\\Objects\\Keil-GD32F350RBT6.hex"
Erase Done.
Programming Done.
Verify OK.
Flash Load finished at 18:22:55

注意:为了大家能够方便快速的搭建测试项目,该自定义工程已被保存到开源硬件项目 UINIO-MCU-GD32F350RBT6Keil-GD32F350RBT6 目录里面。

让 Keil uVision5 支持中文注释

鼠标依次选择 Keil uVision5 菜单栏上的 【Edit -> Configration... -> 】,将弹出窗口【Editor】选项卡下的 Encoding 选择为 Chinese GB2312 (Simplified) 就可以支持中文注释:

注意:这种方式会导致 Keil uVision 显示的源代码字体非常不美观,更佳的处理办法是利用 Sublime 等文本编辑器提供的 ConvertToUTF8 插件,将源代码文件全部转换为 UTF-8 格式的编码。

使用 AStyle 格式化源代码

AStyle 是一款用于对 C/C++ 源代码进行格式化的开源插件,鼠标点击 Keil uVision5 菜单栏上的【Tools -> Customize Tools Menu】:

在弹出的对话框当中进行如下的设置,其中的 Command 就是 astyle.exe 可执行文件所在的路径:

  • Command: D:\Software\Tech\AStyle\astyle.exe
  • AStyle All"$E*.c" "$E*.h" --style=google --indent=spaces=2
  • AStyle File!E --style=google --indent=spaces=2

完成上述步骤之后,就可以在 Keil uVision5 的菜单栏上发现【Tools -> AStyle All】和【Tools -> AStyle File】两条自定义菜单项:

MCU 微控制器系统结构概览

芯片资源简介

GD32F350RBT6 是一款采用 Arm Cortex-M4 内核架构的 32 位微控制器,工作频率为 108MHz,工作电压范围在 2.6V ~ 3.6V 之间,工作温度介于 -40°C ~ +85°C 范围。提供高达 128KB 的片上 Flash 闪存和 16KBSRAM 内存,其它的片上资源情况可以参考下面表格:

资源名称 数量 资源名称 数量
12 位 ADC 1 个 SPI 2 个
12 位 DAC 1 个 I2C 2 个
通用比较器 CMP 2 个 USART 2 个
通用 16 位定时器 5 个 I2S 1 个
通用 32 位定时器 1 个 HDMI-CEC 1 个
基本定时器 1 个 TSI 1 个
PWM 高级定时器 1 个 USBFS 全速 USB 1 个

UINIO-MCU-GD32F350RBT6 采用的 GD32F350RBT6 微控制器使用的是 LQFP64 封装形式,其具体 64 个引脚的功能分配可以参见下图:

ARM Cortex-M4 内核架构

ARM Cortex-M4 系列微控制器基于 ARMv7 架构,其内核主要由下面一系列的功能单元构成:

  • 嵌套式向量型中断控制器NVIC,Nested Vectored Interrupt Controller)。
  • 浮点运算单元FPU,Floating Point Unit)。
  • 闪存地址重载及断点单元FPB,Flash Patch Breakpoint)。
  • 串行线调试接口SW-DP,Serial-Wire Debug Port)。
  • 数据观测点及跟踪单元DWT,Data Watchpoint And Trace)。
  • 指令跟踪宏单元ITM,Instrumentation Trace Macrocell)。
  • 跟踪端口接口单元TPIU,Trace Port Interface Unit)。
  • 内部总线矩阵Bus Matrix,用于实现 I-Code 指令总线、D-Code 数据总线、System 系统总线、PPB 专用总线、AHB-AP 调试专用总线的相互联接)。

GD32F350RBT6 外设架构

GD32F350RBT6 微控制器的整体系统架构如下面的框图所示,其中 AHB(Advanced High performance Bus)高级高性能总线矩阵采用的是多层总线结构,支持多个主从设备之间实现并行通信,其中主设备包含有来自 ARM Cortex-M4 内核架构的 I-Code 指令总线D-Code 数据总线System 系统总线,以及来自于内核外部的 DMA 总线

  • I-Code 总线:即 Instruction Code,用于从 0x 0000 0000 ~ 0x 1FFF FFFF 代码区域获取向量。
  • D-Code 总线:即 Data Code,用于加载和存储数据,以及调试访问代码区域
  • System 系统总线:用于获取指令和向量、加载与存储数据、调试访问系统区域(包括内部 SRAM 和外设区域)。
  • DMA 总线:用于直接内存访问(DMA,Direct Memory Access)的传输总线。

除此之外,AHB 总线矩阵从设备包含有来自 Flash 存储控制器的 IBUSDBUS 总线、SRM 控制器总线,以及 AHB1AHB2 总线:

  • AHB2 总线连接了 ABCDF 一共五组 GPIO 端口。
  • AHB1 总线连接的是其它片上外设资源,其通过两组 AHB-APB 总线桥(AHB to APB Bridge 1/2)分别提供了 AHB1 总线与高级外设总线APB,Advanced Peripheral Bus)之间的同步连接。

地址空间映射

ARM Cortex M4 内核采用了哈佛结构,使用相互独立的总线来读取指令和操作数据。这些指令和数据都存储在一个大小为 4GB 的相同地址空间(因为 ARM Cortex M4 的地址总线宽度为 32 位,所以其对应的地址范围为 232 次方等于 4GB),但是处于不同的地址范围

观察上面的表格可以发现 GD32F350RBT6  的片上外设地址空间被划分为 AHB1AHB2 总线、APB1APB2 总线共四个部分,这些总线的最低地址被称为总线基地址,也就是挂载在该总线上第 1 个外设的地址,而每个外设的最低地址则被称为外设基地址,每个外设的地址范围内都分布着该外设所对应的寄存器,通过操作这些寄存器就可以达到控制外设的目的

操作寄存器 → 运用固件库

操作寄存器

如果需要将 AHB 总线上的 GPIOA 外设对应的 16 个引脚全部置为 1,那么就需要去配置端口输出控制寄存器 GPIOx_OCTL,通过查询用户手册可以知道其地址偏移量为 0x14

由于 GPIOA 的外设基地址为 0x4800 0000,所以寄存器 GPIOA_OCTL 的地址计算方式如下所示:

1
0x4800 0000 + 0x0000 0014 = 0x4800 0014

换而言之,将寄存器 OCTL(0~15) 相应的设置为 1,就可以把对应的 GPIOA(0~15) 控制为高电平。如果要让全部 16 个引脚输出高电平,那么相应的 GPIOA_OCTL 寄存器的高 16 位可以置为 0 而低 16 位置为 1,即 0000 0000 0000 0000 1111 1111 1111 1111,转换为十六进制就是 0x0000FFFF

1
*(unsigned int*)(0x48000014) = 0x0000FFFF;      // 将 GPIOA 外设对应的 16 个引脚全部输出高电平

像上面这样直接对寄存器地址进行操作会比较麻烦,下面可以通过宏定义 #define,为每一个寄存器地址都分配一个名称:

1
2
#define GPIOA_OCTL (unsigned int*)(0x48000014)  // 将寄存器地址 0x48000014 定义为指针类型的 GPIOA_OCTL
*GPIOA_OCTL = 0x0000FFFF; // 将 GPIOA 外设对应的 16 个引脚全部输出高电平

为了进一步简化代码,可以将指针类型 * 的声明合并到 GPIOA_OCTL 的宏定义当中:

1
2
#define GPIOA_OCTL *(unsigned int*)(0x48000014) // 将寄存器地址 0x48000014 定义为 GPIOA_OCTL
GPIOA_OCTL = 0x0000FFFF; // 将 GPIOA 外设对应的 16 个引脚全部输出高电平

运用库函数

兆易创新官方固件库 GD32F3x0_Firmware_Library_V2.2.1 当中标准外设库 Firmware\GD32F3x0_standard_peripheral 目录下的 Include\gd32f3x0_gpio.hSource\gd32f3x0_gpio.c 两个源文件,提供有一系列用于操作 GPIO 的库函数:

其中的 void gpio_port_write(uint32_t gpio_periph, uint16_t data) 函数可以用于向特定的 GPIO 端口写入状态值:

1
gpio_port_write(GPIOB, 0xFFFF);

该函数被定义在 Source\gd32f3x0_gpio.c 源文件当中,可以看到其函数体内调用了 GPIO_OCTL(gpio_periph) 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
/*!
\brief write data to the specified GPIO port
\param[in] gpio_periph: GPIOx(x = A,B,C,D,F)
only one parameter can be selected which is shown as below:
\arg GPIOx(x = A,B,C,D,F)
\param[in] data: specify the value to be written to the port output control register
\param[out] none
\retval none
*/
void gpio_port_write(uint32_t gpio_periph, uint16_t data)
{
GPIO_OCTL(gpio_periph) = (uint32_t)data;
}

而这个 GPIO_OCTL(gpio_periph) 函数又被预定义在了 Include\gd32f3x0_gpio.h 头文件里面,其最终调用的是 REG32(addr) 函数:

1
#define GPIO_OCTL(gpiox)           REG32((gpiox) + 0x00000014U)    /*!< GPIO port output control register */

REG32(addr) 函数的定义位于官方固件库 Firmware\CMSIS\GD\GD32F3x0\Include 目录下的 gd32f3x0.h 头文件当中:

1
#define REG32(addr)                  (*(volatile uint32_t *)(uint32_t)(addr))

把前面寄存器 GPIOA_OCTL 的地址计算式 0x4800 0000 + 0x0000 0014 作为 addr 参数代入之后,就会发现标准外设库底层也是在操作寄存器,只是在使用的时候更加直观简单:

1
#define REG32(addr) (*(volatile uint32_t *)(uint32_t)(0x4800 0000 + 0x0000 0014))

注意:通过寄存器直接控制外设,性能开销更少,运行更加迅速,适用于片上资源有限,且对于实时性要求较高的场景。而使用标准外设库来操控外设,其优势主要体现在提升代码的开发效率以及可读性与可维护性。

通过 GPIO 寄存器控制 LED

使用 UINIO-MCU-GD32F350RBT6 核心板来控制 GPIO 端口的输出,整体需要经历下面几个步骤:

  1. 开启指定 GPIO 的端口时钟
  2. 配置指定 GPIO 的工作模式
  3. 配置指定 GPIO 的输出类型

开始编写代码之前,首先需要将一枚 4.7K 的电阻 R1 与一枚 LED 发光二极管串联,然后再连接到 UINIO-MCU-GD32F350RBT6 核心板的 GPIOB8 引脚,当该引脚输出高电平的时候 LED 发光二极管就会点亮,而输出低电平的时候 LED 发光二极管就会熄灭,具体的电路连接关系请参考下面的示意图:

开启 GPIO 的端口时钟

由于 GD32F350RBT6 的外设时钟资源默认情况下都是关闭的,所以在配置外设之前需要先开启其对应的时钟

AHB 总线使能寄存器 RCU_AHBEN

GPIOB 引脚分组被挂载到了 GD32F350RBT6 微控制器的 AHB 总线下面,在用户手册的 复位和时钟单元(RCU) 章节里,描述了 AHB 总线使能寄存器 RCU_AHBEN 的地址偏移量为 0x14、复位值为 0x0000 0014,可以按照 8 位的字节、16 位的半字以及 32 位的进行访问:

AHB 总线使能寄存器 RCU_AHBEN位于复位和时钟单元 RCU 外设的地址范围之内,由于 RCU 的外设基地址为 0x4002 1000,所以 RCU_AHB1EN 寄存器的实际地址计算过程如下面等式所示:

1
RCU_AHBEN = RCU 的外设基地址 + AHB 总线使能寄存器偏移量 = 0x4002 1000 + 0x14 = 0x4002 1014

根据用户手册当中接下来的内容,可以发现 RCU_AHB1EN 寄存器的第 18PBEN 就是 GPIOB 时钟的使能位

所以只需要往 RCU_AHB1EN 寄存器的第 18 位写入 1,其它位保持不变,就可以实现对 GPIOB 外设时钟的使能,这里我们可以通过一个或运算移位运算来完成:

1
RCU_AHBEN |= (1 << 18)

注意:上面等式要使能的是第几位,就向右移多少位。例如上面等式向第 18 位写入 1,所以就右移 18 位。

配置 GPIO 的工作模式

接下来,着手配置 GD32F350RBT6 的 GPIO 工作模式,这里具体可以划分为下面两个步骤:

  1. 端口控制寄存器 GPIOx_CTL 配置为输入模式(默认) / 输出模式 / 备用功能模式 / 模拟模式
  2. 端口上下拉寄存器 GPIOx_PUD 配置为上拉模式 / 下拉模式 / 悬空模式(默认)

配置端口控制寄存器 GPIOB_CTL

已知 GPIOB 寄存器的基地址为 0x4800 0400,而端口控制寄存器 GPIOB_CTL 的地址偏移量为 0x00

从而就可以计算出 GPIOB 端口控制寄存器 GPIOB_CTL 的实际地址为:

1
GPIOB_CTL = 0x4800 0400 + 0x00 = 0x4800 0400

该寄存器通过两个位来进行控制,例如这里需要操作的是 Pin8 引脚,就是需要控制 GPIOB_CTL 寄存器的第 17 和 16 位。通过将这两位配置为 01,就可以将 GPIOB8 端口配置为输出模式。此时向 GPIOB_CTL 寄存器写入的二进制数据为 0000 0000 0000 0001 0000 0000 0000 0000,转换为十六进制就是 00010000。为了确保其它位不会被修改,需要先将第 15 和第 14 两位置零,然后再将其配置为 01

1
2
GPIOB_CTL &= 0xFFFCFFFF;  // 把第 17 和 16 位置为 00
GPIOB_CTL |= 0x00004000; // 配置第 17 和 16 位为 01

除此之外,还可以采用下面的计算方式进行配置:

1
2
GPIOB_CTL &= ~(0x03 << (2 * 8)); // 把第 17 和 16 位置为 00
GPIOB_CTL |= (0x01 << (2 * 8)); // 配置第 17 和 16 位为 01

注意:上面代码当中的数值 8 对应的是 GPIOB8,反之如果是 GPIOB5 则可以将该值替换为 5

配置端口上下拉寄存器 GPIOB_PUD

GPIOB8 引脚配置为输出模式之后,还需要再进一步通过端口上下拉寄存器 GPIOB_PUD 将其进一步配置为悬空模式(默认值,即没有上下拉电阻):

同样已知 GPIOB 寄存器的基地址为 0x4800 0400,而端口上下拉寄存器 GPIOB_PUD 的地址偏移量为 0x0C,从而就可以计算出其实际地址为:

1
GPIOB_PUD = 0x4800 0400 + 0x0C = 0x4800 040C

该寄存器同样通过 GPIOB_PUD 寄存器的第 17 和第 16 两个位来进行控制:

使用时也依然需要先进行清零,然后再将其配置为 00 所代表的悬空模式

1
2
GPIOB_PUD &= ~(0x03 << (2 * 8)); // 将第 17 和 16 位清零
GPIOB_PUD |= (0x00 << (2 * 8)); // 配置第 17 和 16 位为 00

配置 GPIO 的输出类型

配置 UINIO-MCU-GD32F350RBT6 的 GPIO 输出类型也可以划分为如下两个步骤:

  1. 配置端口输出模式寄存器 GPIOx_OMODE,也就是选择推挽输出还是开漏输出
  2. 配置端口速度寄存器 GPIOx_OSPD 的输出速度等级,在这里我们选择 50MHz 的频率;

端口输出模式寄存器 GPIOB_OMODE

GPIO 的开漏输出模式需要外接上拉电阻,才能够输出高电平,不适用于当前的电路连接关系,在这里我们需要通过端口输出模式寄存器 GPIOB_OMODE,将其设置为推挽输出模式:

同样已知 GPIOB 的寄存器基地址为 0x4800 0400,而端口输出模式寄存器 GPIOB_OMODE 的地址偏移量为 0x04,那么 GPIOB_OMODE 的准确寄存器地址为:

1
GPIOB_OMODE = 0x4800 0400 + 0x04 = 0x4800 0404

根据上图的描述可知,向 GPIOB_OMODE 寄存器的第 8 位写入 0,就可以将其配置为推挽输出模式

1
GPIOB_OMODE &= ~(0x01 << 8) // 将 GPIOB_OMODE 的第 8 位置为 0

端口速度寄存器 GPIOB_OSPD

接下来,需要再将端口速度寄存器 GPIOx_OSPD 的输出频率设置为 50MHz

根据前面的计算方法,已知 GPIOB 的寄存器基地址为 0x4800 0400,而端口速度寄存器 GPIOB_OSPD 的地址偏移量为 0x08,则 GPIOB_OSPD 的准确寄存器地址,可以按照如下方式进行计算得到:

1
GPIOB_OSPD = 0x4800 0400 + 0x08 = 0x4800 0408

根据上图描述的信息,可以向 GPIOB_OSPD 寄存器的第 17 和 第 16 位写入 10(复位值),就可以将其配置为 2MHz 的输出速率:

1
GPIOB_OSPD |=  (0x02 << (2 * 8));  // 向第 17 和 16 位写入 10

而向 GPIOB_OSPD 寄存器的第 17 和 第 16 位写入 01,则可以将其配置为 10MHz 的输出速率:

1
GPIOB_OSPD |=  (0x01 << (2 * 8));  // 向第 17 和 16 位写入 10

如果向 GPIOB_OSPD 寄存器的第 17 和 第 16 位写入的是 11,则可以将其配置为 50MHz 的输出速率,也就是当前需要为 UINIO-MCU-GD32F350RBT6GPIOB 配置的目标频率:

1
GPIOB_OSPD &=  ~(0x03 << (2 * 8)); // 向第 17 和 16 位写入 11

注意:十六进制 0x03 的二进制形式为 0000 0011,十六进制 0x02 的二进制形式为 0000 0010,十六进制 0x01 的二进制形式为 0000 0001

控制 GPIO 的输出状态

配置好 GPIOB8 对应的端口时钟工作模式输出类型之后,就可以通过使其输出高电平点亮 LED 发光二极管,或者通过低电平熄灭 LED 发光二极管。

端口输出控制寄存器 GPIOB_OCTL

根据用户手册已知 GPIOB 的寄存器基地址为 0x4800 0400,而端口输出模式寄存器 GPIOB_OCTL 的地址偏移量为 0x14

那么端口输出控制寄存器 GPIOB_OCTL 的实际地址,就可以通过下面的等式计算得到:

1
GPIOB_OCTL = 0x4800 0400 + 0x14 = 0x4800 0414

通过向上图当中 GPIOB_OCTL 寄存器的第 8 位 OCTL8 位写入 1 或者 0,就可以控制相应的 GPIO 引脚输出高电平或者低电平

1
2
GPIOB_OCTL &= ~ (0x01 << 8); // 输出低电平
GPIOB_OCTL |= (0x01 << 8); // 输出高电平

端口位操作寄存器 GPIOB_BOP

除此之外,我们还可以通过端口位操作寄存器 GPIOB_BOP 来操作 GPIO 端口的状态。根据用户手册已知 GPIOB 的寄存器基地址为 0x4800 0400,而端口输出模式寄存器 GPIOB_BOP 的地址偏移量为 0x18

那么端口输出控制寄存器 GPIOB_BOP 的实际地址,就可以通过下面的计算过程获得:

1
GPIOB_BOP = 0x4800 0400 + 0x18 = 0x4800 0418

观察可以发现 GPIOB_BOP 寄存器的高 16 位和低 16 位的每一位,都分别对应着一个 GPIO 引脚。其中低十六位 \(CR_{0 \sim 15}\) 是置 1 位,而高十六位 \(BOP_{0 \sim 15}\) 则属于清 0 位:

  • GPIOB_BOP 寄存器的高十六位 \(CR_{0 \sim 15}\):置为 1 输出低电平,置 0 电平状态不改变;
  • GPIOB_BOP 寄存器的低十六位 \(BOP_{0 \sim 15}\):置为 1 输出高电平,置 0 电平状态不改变;
1
2
GPIOB_BOP |= (0x01 << (8 + 16)); // 输出低电平
GPIOB_BOP |= (0x01 << 8); // 输出高电平

完整 Keil µVision 工程代码

Keil-GD32F350RBT6 工程的 Driver 目录下建立一个名为 LED 的子目录,然后分别新建 LED.hLED.c 两个源文件,并且在 main.c 里包含 LED.h 头文件,全部的示例代码内容如下面所示,即 UINIO-MCU-GD32F350RBT6 工程 Examples 目录下的 1-LED-Register 工程:

Drivers/LED.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*========== LED.h ==========*/
#ifndef UINIO_Driver_LED_H
#define UINIO_Driver_LED_H

#include "gd32f3x0.h"
#include "systick.h"

#define UINIO_RCU_BASE (unsigned int)0x40021000U // RCU 寄存器的基地址
#define UINIO_RCU_AHBEN *(unsigned int *)(UINIO_RCU_BASE + 0x14U) // AHB 使能寄存器地址

#define UINIO_GPIOB_BASE (unsigned int)0x48000400U // GPIOB 的基地址
#define UINIO_GPIOB_CTL *(unsigned int *)(UINIO_GPIOB_BASE + 0x00U) // GPIOB 控制寄存器的地址
#define UINIO_GPIOB_PUD *(unsigned int *)(UINIO_GPIOB_BASE + 0x0CU) // GPIOB 的上下拉寄存器的地址
#define UINIO_GPIOB_OMODE *(unsigned int *)(UINIO_GPIOB_BASE + 0x04U) // GPIOB 的输出模式寄存器的地址
#define UINIO_GPIOB_OSPD *(unsigned int *)(UINIO_GPIOB_BASE + 0x08U) // GPIOB 的速度寄存器的地址
#define UINIO_GPIOB_OCTL *(unsigned int *)(UINIO_GPIOB_BASE + 0x14U) // GPIOB 的输出控制寄存器的地址
#define UINIO_GPIOB_BOP *(unsigned int *)(UINIO_GPIOB_BASE + 0x18U) // GPIOB 的位操作寄存器的地址

void UINIO_LED_GPIO_Config(void); // LED 相关的 GPIO 端口配置函数

#endif /* UINIO_Driver_LED_H */

Drivers/LED.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/*========== LED.c ==========*/
#include "LED.h"

/* LED 相关的 GPIO 端口配置函数 */
void UINIO_LED_GPIO_Config(void) {
/* AHB 总线使能寄存器 RCU_AHBEN */
UINIO_RCU_AHBEN |= (0x01 << 18); // RCU_AHBEN 寄存器的第 18 位置为 1

/* 配置端口控制寄存器 GPIOB_CTL */
UINIO_GPIOB_CTL &= ~(0x03 << (2*8)); // 把 GPIOB_CTL 的第 17 和 16 位置为 00
UINIO_GPIOB_CTL |= (0x01 << (2*8)); // 配置 GPIOB_CTL 的第 17 和 16 位为 01

/* 配置端口上下拉寄存器 GPIOB_PUD */
UINIO_GPIOB_PUD &= ~(0x03 << (2 * 8)); // 将 GPIOB_PUD 的第 17 和 16 位清零
UINIO_GPIOB_PUD |= (0x00 << (2 * 8)); // 配置 GPIOB_PUD 的第 17 和 16 位为 00

/* 端口输出模式寄存器 GPIOB_OMODE */
UINIO_GPIOB_OMODE &= ~(0x01 << 8); // 将 GPIOB_OMODE 的第 8 位置为 0

/* 端口速度寄存器 GPIOB_OSPD */
UINIO_GPIOB_OSPD &= (0x03 << (2 * 8)); // 向 GPIOB_OSPD 的第 17 和 16 位写入 11
}

Sources/main.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*========== main.c ==========*/
#include "gd32f3x0.h"
#include "main.h"

#include "../Drivers/LED/LED.h"

int main(void) {
systick_config(); // 配置系统滴答定时器
UINIO_LED_GPIO_Config(); // 配置连接 LED 的 GPIO 端口

/* 端口输出控制寄存器 GPIOB_OCTL 方式控制 LED */
UINIO_GPIOB_OCTL &= ~(0x01 << 8); // 输出低电平,LED 熄灭
UINIO_GPIOB_OCTL |= (0x01 << 8); // 输出高电平,LED 点亮

/* 端口位操作寄存器 GPIOB_BOP 方式控制 LED */
UINIO_GPIOB_BOP |= (0x01 << (8 + 16)); // 输出低电平,LED 熄灭
UINIO_GPIOB_BOP |= (0x01 << 8); // 输出高电平,LED 点亮

while(1) {}
}

通过 GPIO 固件库控制 LED

本节内容将采用兆易创新官方提供的标准外设固件库 GD32F3x0_Firmware_Library_V2.2.1 来完成点亮 LED 的实验,这通常需要经历如下四个步骤:

  1. 调用 rcu_periph_clock_enable() 固件库函数使能 GPIO 端口对应的外设时钟
1
void rcu_periph_clock_enable(rcu_periph_enum periph);
  1. 通过 gpio_mode_set() 函数配置 GPIO 端口的工作模式以及设置上下拉电阻状态
1
void gpio_mode_set(uint32_t gpio_periph, uint32_t mode, uint32_t pull_up_down, uint32_t pin);
  1. 通过 gpio_output_options_set() 函数配置指定 GPIO 引脚的输出类型与速率:
1
`void gpio_output_options_set(uint32_t gpio_periph, uint8_t otype, uint32_t speed, uint32_t pin);
  1. 通过 gpio_bit_set/write() 指定 GPIO 引脚的电平状态:
1
void gpio_bit_set/write(uint32_t gpio_periph, uint32_t pin)

使能 GPIO 外设时钟

官方固件库 Firmware\GD32F3x0_standard_peripheral\Include 目录下的头文件 gd32f3x0_rcu.h 里,定义了一个专门用于使能外设时钟的库函数:

1
void rcu_periph_clock_enable(rcu_periph_enum periph)

这个函数的 periph 参数是一个 rcu_periph_enum 枚举类型的变量,其具体的定义如下面所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/* peripheral clock enable */
typedef enum {
/* AHB peripherals */
RCU_DMA = RCU_REGIDX_BIT(IDX_AHBEN, 0U), /*!< DMA clock */
RCU_CRC = RCU_REGIDX_BIT(IDX_AHBEN, 6U), /*!< CRC clock */
RCU_GPIOA = RCU_REGIDX_BIT(IDX_AHBEN, 17U), /*!< GPIOA clock */
RCU_GPIOB = RCU_REGIDX_BIT(IDX_AHBEN, 18U), /*!< GPIOB clock */
RCU_GPIOC = RCU_REGIDX_BIT(IDX_AHBEN, 19U), /*!< GPIOC clock */
RCU_GPIOD = RCU_REGIDX_BIT(IDX_AHBEN, 20U), /*!< GPIOD clock */
RCU_GPIOF = RCU_REGIDX_BIT(IDX_AHBEN, 22U), /*!< GPIOF clock */
RCU_TSI = RCU_REGIDX_BIT(IDX_AHBEN, 24U), /*!< TSI clock */

/* APB2 peripherals */
RCU_CFGCMP = RCU_REGIDX_BIT(IDX_APB2EN, 0U), /*!< CFGCMP clock */
RCU_ADC = RCU_REGIDX_BIT(IDX_APB2EN, 9U), /*!< ADC clock */
RCU_TIMER0 = RCU_REGIDX_BIT(IDX_APB2EN, 11U), /*!< TIMER0 clock */
RCU_SPI0 = RCU_REGIDX_BIT(IDX_APB2EN, 12U), /*!< SPI0 clock */
RCU_USART0 = RCU_REGIDX_BIT(IDX_APB2EN, 14U), /*!< USART0 clock */
RCU_TIMER14 = RCU_REGIDX_BIT(IDX_APB2EN, 16U), /*!< TIMER14 clock */
RCU_TIMER15 = RCU_REGIDX_BIT(IDX_APB2EN, 17U), /*!< TIMER15 clock */
RCU_TIMER16 = RCU_REGIDX_BIT(IDX_APB2EN, 18U), /*!< TIMER16 clock */

/* APB1 peripherals */
RCU_TIMER1 = RCU_REGIDX_BIT(IDX_APB1EN, 0U), /*!< TIMER1 clock */
RCU_TIMER2 = RCU_REGIDX_BIT(IDX_APB1EN, 1U), /*!< TIMER2 clock */
RCU_TIMER13 = RCU_REGIDX_BIT(IDX_APB1EN, 8U), /*!< TIMER13 clock */
RCU_WWDGT = RCU_REGIDX_BIT(IDX_APB1EN, 11U), /*!< WWDGT clock */
RCU_SPI1 = RCU_REGIDX_BIT(IDX_APB1EN, 14U), /*!< SPI1 clock */
RCU_USART1 = RCU_REGIDX_BIT(IDX_APB1EN, 17U), /*!< USART1 clock */
RCU_I2C0 = RCU_REGIDX_BIT(IDX_APB1EN, 21U), /*!< I2C0 clock */
RCU_I2C1 = RCU_REGIDX_BIT(IDX_APB1EN, 22U), /*!< I2C1 clock */
RCU_PMU = RCU_REGIDX_BIT(IDX_APB1EN, 28U), /*!< PMU clock */
#if defined(GD32F350)
RCU_DAC = RCU_REGIDX_BIT(IDX_APB1EN, 29U), /*!< DAC clock */
RCU_CEC = RCU_REGIDX_BIT(IDX_APB1EN, 30U), /*!< CEC clock */
RCU_TIMER5 = RCU_REGIDX_BIT(IDX_APB1EN, 4U), /*!< TIMER5 clock */
RCU_USBFS = RCU_REGIDX_BIT(IDX_AHBEN, 12U), /*!< USBFS clock */
#endif /* GD32F350 */
RCU_RTC = RCU_REGIDX_BIT(IDX_BDCTL, 15U), /*!< RTC clock */

/* RCU_ADDAPB1EN */
RCU_CTC = RCU_REGIDX_BIT(IDX_ADDAPB1EN, 27U) /*!< CTC clock */
} rcu_periph_enum;

观察可以发现,如果向 rcu_periph_clock_enable() 函数传入上述枚举类型变量当中的枚举值 RCU_GPIOB,就可以使能 GPIOB 对应的外设时钟:

1
rcu_periph_clock_enable(RCU_GPIOB);

配置 GPIO 模式

类似的,固件库 Firmware\GD32F3x0_standard_peripheral\Include 目录下的头文件 gd32f3x0_gpio.h 里定义了一个用于设置 GPIO 工作模式的函数:

1
2
/* set GPIO mode */
void gpio_mode_set(uint32_t gpio_periph, uint32_t mode, uint32_t pull_up_down, uint32_t pin);

该函数的四个参数,分别用于 设置 GPIO 分组配置工作模式选择上下拉状态指定 GPIO 引脚,具体参数选项请参考下面的源代码片断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/* GPIOx(x=A,B,C,D,F) definitions */
#define GPIOA (GPIO_BASE + 0x00000000U)
#define GPIOB (GPIO_BASE + 0x00000400U)
#define GPIOC (GPIO_BASE + 0x00000800U)
#define GPIOD (GPIO_BASE + 0x00000C00U)
#define GPIOF (GPIO_BASE + 0x00001400U)

/* output mode definitions */
#define GPIO_MODE_INPUT CTL_CLTR(0) /*!< input mode */
#define GPIO_MODE_OUTPUT CTL_CLTR(1) /*!< output mode */
#define GPIO_MODE_AF CTL_CLTR(2) /*!< alternate function mode */
#define GPIO_MODE_ANALOG CTL_CLTR(3) /*!< analog mode */

/* pull-up/pull-down definitions */
#define PUD_PUPD(regval) (BITS(0,1) & ((uint32_t)(regval) << 0))
#define GPIO_PUPD_NONE PUD_PUPD(0) /*!< floating mode, no pull-up and pull-down resistors */
#define GPIO_PUPD_PULLUP PUD_PUPD(1) /*!< with pull-up resistor */
#define GPIO_PUPD_PULLDOWN PUD_PUPD(2) /*!< with pull-down resistor */

/* GPIO pin definitions */
#define GPIO_PIN_0 BIT(0) /*!< GPIO pin 0 */
#define GPIO_PIN_1 BIT(1) /*!< GPIO pin 1 */
#define GPIO_PIN_2 BIT(2) /*!< GPIO pin 2 */
#define GPIO_PIN_3 BIT(3) /*!< GPIO pin 3 */
#define GPIO_PIN_4 BIT(4) /*!< GPIO pin 4 */
#define GPIO_PIN_5 BIT(5) /*!< GPIO pin 5 */
#define GPIO_PIN_6 BIT(6) /*!< GPIO pin 6 */
#define GPIO_PIN_7 BIT(7) /*!< GPIO pin 7 */
#define GPIO_PIN_8 BIT(8) /*!< GPIO pin 8 */
#define GPIO_PIN_9 BIT(9) /*!< GPIO pin 9 */
#define GPIO_PIN_10 BIT(10) /*!< GPIO pin 10 */
#define GPIO_PIN_11 BIT(11) /*!< GPIO pin 11 */
#define GPIO_PIN_12 BIT(12) /*!< GPIO pin 12 */
#define GPIO_PIN_13 BIT(13) /*!< GPIO pin 13 */
#define GPIO_PIN_14 BIT(14) /*!< GPIO pin 14 */
#define GPIO_PIN_15 BIT(15) /*!< GPIO pin 15 */
#define GPIO_PIN_ALL BITS(0,15) /*!< GPIO pin all */

例如现在要配置 GPIOB8 引脚为悬空输出模式,则只需要向 gpio_mode_set() 函数传入相应的参数即可:

1
gpio_mode_set(GPIOB, GPIO_MODE_OUTPUT, GPIO_PUPD_NONE, GPIO_PIN_8);

配置 GPIO 输出类型与速度

固件库 Firmware\GD32F3x0_standard_peripheral\Include 目录下的 gd32f3x0_gpio.h 头文件里面,同样定义有一个用于设置 GPIO 输出类型和速率的库函数:

1
void gpio_output_options_set(uint32_t gpio_periph, uint8_t otype, uint32_t speed, uint32_t pin);

这个函数的四个参数,则是分别用于 设置 GPIO 分组配置输出类型最大输出速率,具体的参数选项同样请参考下面的源代码片断:

1
2
3
4
5
6
7
8
9
/* GPIO output type */
#define GPIO_OTYPE_PP ((uint8_t)(0x00U)) /*!< push pull mode */
#define GPIO_OTYPE_OD ((uint8_t)(0x01U)) /*!< open drain mode */

/* GPIO output max speed value */
#define GPIO_OSPEED_2MHZ OSPD_OSPD0(0) /*!< output max speed 2MHz */
#define GPIO_OSPEED_10MHZ OSPD_OSPD0(1) /*!< output max speed 10MHz */
#define GPIO_OSPEED_50MHZ OSPD_OSPD0(3) /*!< output max speed 50MHz */
#define GPIO_OSPEED_MAX ((uint32_t)0x0000FFFFU) /*!< GPIO very high output speed, max speed more than 50MHz */

例如当前要配置 GPIOB8 引脚为推挽输出方式,其最大输出速率为 50MHz,则只需要向 gpio_output_options_set() 函数传入下面的参数即可:

1
gpio_output_options_set(GPIOB, GPIO_OTYPE_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_8);

指定 GPIO 引脚电平状态

固件库的 gd32f3x0_gpio.h 头文件里,存在着下面三个可以用于定义 GPIO 引脚电平状态的函数:

1
2
3
4
5
6
/* set GPIO pin bit */
void gpio_bit_set(uint32_t gpio_periph, uint32_t pin);
void gpio_bit_reset(uint32_t gpio_periph, uint32_t pin);

/* write data to the specified GPIO pin */
void gpio_bit_write(uint32_t gpio_periph, uint32_t pin, bit_status bit_value);

其中 gpio_bit_set()gpio_bit_reset() 函数用于指定 GPIO 引脚为固定的高电平状态:

1
2
gpio_bit_set(GPIOB, GPIO_PIN_8);    // 指定 GPIOB8 引脚为高电平
gpio_bit_reset(GPIOB, GPIO_PIN_8); // 指定 GPIOB8 引脚为低电平

gpio_bit_write() 函数则可以用来灵活的设置 GPIO 引脚为高电平或者低电平状态:

1
2
gpio_bit_write(GPIOB, GPIO_PIN_8, 0);  // 让 GPIOB8 引脚输出低电平
gpio_bit_write(GPIOB, GPIO_PIN_8, 1); // 让 GPIOB8 引脚输出高电平

完整 Keil µVision 工程代码

接下来,将 Keil-GD32F350RBT6 示例工程里的 LED.hLED.c 以及 main.c 替换为使用固件库的版本,全部的示例代码内容如下面所示,即 UINIO-MCU-GD32F350RBT6 工程 Examples 目录下的 2-LED-Library 工程:

Drivers/LED.h

1
2
3
4
5
6
7
8
9
10
11
12
/*========== UINIO_LED.h ==========*/
#ifndef UINIO_LED_H
#define UINIO_LED_H

#include "gd32f3x0.h"

#define UINIO_LED_RCU RCU_GPIOB // 宏定义 LED 对应的 GPIO 端口时钟
#define UINIO_LED_PORT GPIOB // 宏定义 LED 对应的 GPIO 端口
#define UINIO_LED_PIN GPIO_PIN_8 // 宏定义 LED 对应的 GPIO 引脚

void UINIO_LED_GPIO_Config(void); // LED 关联 GPIO 引脚的配置函数
#endif /* UINIO_LED_H */

Drivers/LED.c

1
2
3
4
5
6
7
8
9
/*========== UINIO_LED.c ==========*/
#include "LED.h"

/* LED 对应 GPIO 引脚的配置函数 */
void UINIO_LED_GPIO_Config(void) {
rcu_periph_clock_enable(UINIO_LED_RCU); // 使能 GIPO 外设对应的 RCU 复位和时钟单元
gpio_mode_set(UINIO_LED_PORT, GPIO_MODE_OUTPUT, GPIO_PUPD_NONE, UINIO_LED_PIN); // 配置 GPIO 为浮空输出模式
gpio_output_options_set(UINIO_LED_PORT, GPIO_OTYPE_PP, GPIO_OSPEED_50MHZ, UINIO_LED_PIN); // 设置 GPIO 的输出模式为推挽输出,速度为 50MHz
}

Sources/main.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*========== main.c ==========*/
#include "gd32f3x0.h"

#include "main.h"
#include "../Drivers/LED/LED.h"

int main(void) {
UINIO_LED_GPIO_Config(); // 初始化 LED 相关的 GPIO 引脚

gpio_bit_set(UINIO_LED_PORT, UINIO_LED_PIN); // GPIOB8 输出高电平
gpio_bit_reset(UINIO_LED_PORT, UINIO_LED_PIN); // GPIOB8 输出低电平

gpio_bit_write(UINIO_LED_PORT, UINIO_LED_PIN, RESET); // GPIOB8 输出低电平
gpio_bit_write(UINIO_LED_PORT, UINIO_LED_PIN, SET); // GPIOB8 输出高电平

while(1) {}
}

启动文件 startup_gd32f3x0.s 剖析

在开启进一步的标准固件库学习之前,首先需要了解 Keil-GD32F350RBT6 工程的启动顺序,其中 Firmware\CMSIS\GD\GD32F3x0\Source\ARM 目录下的 startup_gd32f3x0.s 源文件是 GD32F350RBT6 微控制器上电复位之后,执行的第一段程序(由汇编语言编写),该程序主要完成了如下几项工作:

  1. 配置信息;
  2. 配置信息;
  3. 映射向量表
  4. 设置复位处理程序
  5. 定义异常/外部中断处理程序
  6. 初始化用户堆栈

在接下来的内容当中,将会根据执行顺序依次探讨 startup_gd32f3x0.s 当中各个代码块的功能与用途。

配置栈信息

主要用于存放局部变量函数调用函数形式参数,其由高向低生长,且容量不能超过片上 SRAM 存储器的容量大小。

1
2
3
4
5
6
7
8
9
; <h> Stack Configuration
; <o> Stack Size (in Bytes) <0x0-0xFFFFFFFF:8>
; </h>

Stack_Size EQU 0x00000400

AREA STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem SPACE Stack_Size
__initial_sp

上面的汇编代码,开辟了一个大小为 0X00000400 (1KB) 名称为 STACK,其中 NOINIT 表示不初始化,READWRITE 表示可读可写,ALIGN=3 表示 \(2^3 = 8\) 字节对齐。最后的 __initial_sp 表示栈的结束地址,也就是栈顶地址。

配置堆信息

主要用于完成动态内存分配,其由低向高生长,例如 malloc() 函数申请的内存就位于在堆上面。

1
2
3
4
5
6
7
8
9
10
11
12
13
; <h> Heap Configuration
; <o> Heap Size (in Bytes) <0x0-0xFFFFFFFF:8>
; </h>

Heap_Size EQU 0x00000400

AREA HEAP, NOINIT, READWRITE, ALIGN=3
__heap_base
Heap_Mem SPACE Heap_Size
__heap_limit

PRESERVE8
THUMB

上面的汇编代码,开辟了一个大小为 0X00000400 (1KB) 名称为 HEAP,同样的 NOINIT 表示不初始化,READWRITE 表示可读可写,ALIGN=3 表示 \(2^3 = 8\) 字节对齐。

除此之外,__heap_base 表示堆的起始地址,而 __heap_limit 表示堆的结束地址。后续的 PRESERVE8 表示保留 8 字节对齐,而 THUMB 表示兼容 THUMB 指令集。

映射向量表

向量表是一个 32 位 WORD 数组,其按照 4 字节进行边界对齐,从片上 Flash 的零地址开始进行放置,这个数组保存着一系列程序的入口地址,当 GD32F350RBT6 微控制器处于不同的预定义状态时,就会通过查找向量表,进入执行对应地址的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
;               /* reset Vector Mapped to at Address 0 */
AREA RESET, DATA, READONLY
EXPORT __Vectors
EXPORT __Vectors_End
EXPORT __Vectors_Size

__Vectors DCD __initial_sp ; Top of Stack
DCD Reset_Handler ; Reset Handler
DCD NMI_Handler ; NMI Handler
DCD HardFault_Handler ; Hard Fault Handler
DCD MemManage_Handler ; MPU Fault Handler
DCD BusFault_Handler ; Bus Fault Handler
DCD UsageFault_Handler ; Usage Fault Handler
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD SVC_Handler ; SVCall Handler
DCD DebugMon_Handler ; Debug Monitor Handler
DCD 0 ; Reserved
DCD PendSV_Handler ; PendSV Handler
DCD SysTick_Handler ; SysTick Handler

; /* external interrupts handler */
DCD WWDGT_IRQHandler ; 16:Window Watchdog Timer
DCD LVD_IRQHandler ; 17:LVD through EXTI Line detect
DCD RTC_IRQHandler ; 18:RTC through EXTI Line
DCD FMC_IRQHandler ; 19:FMC
DCD RCU_CTC_IRQHandler ; 20:RCU and CTC
DCD EXTI0_1_IRQHandler ; 21:EXTI Line 0 and EXTI Line 1
DCD EXTI2_3_IRQHandler ; 22:EXTI Line 2 and EXTI Line 3
DCD EXTI4_15_IRQHandler ; 23:EXTI Line 4 to EXTI Line 15
DCD TSI_IRQHandler ; 24:TSI
DCD DMA_Channel0_IRQHandler ; 25:DMA Channel 0
DCD DMA_Channel1_2_IRQHandler ; 26:DMA Channel 1 and DMA Channel 2
DCD DMA_Channel3_4_IRQHandler ; 27:DMA Channel 3 and DMA Channel 4
DCD ADC_CMP_IRQHandler ; 28:ADC and Comparator 0-1
DCD TIMER0_BRK_UP_TRG_COM_IRQHandler ; 29:TIMER0 Break,Update,Trigger and Commutation
DCD TIMER0_Channel_IRQHandler ; 30:TIMER0 Channel Capture Compare
DCD TIMER1_IRQHandler ; 31:TIMER1
DCD TIMER2_IRQHandler ; 32:TIMER2
DCD TIMER5_DAC_IRQHandler ; 33:TIMER5 and DAC
DCD 0 ; Reserved
DCD TIMER13_IRQHandler ; 35:TIMER13
DCD TIMER14_IRQHandler ; 36:TIMER14
DCD TIMER15_IRQHandler ; 37:TIMER15
DCD TIMER16_IRQHandler ; 38:TIMER16
DCD I2C0_EV_IRQHandler ; 39:I2C0 Event
DCD I2C1_EV_IRQHandler ; 40:I2C1 Event
DCD SPI0_IRQHandler ; 41:SPI0
DCD SPI1_IRQHandler ; 42:SPI1
DCD USART0_IRQHandler ; 43:USART0
DCD USART1_IRQHandler ; 44:USART1
DCD 0 ; Reserved
DCD CEC_IRQHandler ; 46:CEC
DCD 0 ; Reserved
DCD I2C0_ER_IRQHandler ; 48:I2C0 Error
DCD 0 ; Reserved
DCD I2C1_ER_IRQHandler ; 50:I2C1 Error
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD USBFS_WKUP_IRQHandler ; 58:USBFS Wakeup
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD DMA_Channel5_6_IRQHandler ; 64:DMA Channel5 and Channel6
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD USBFS_IRQHandler ; 83:USBFS
__Vectors_End

__Vectors_Size EQU __Vectors_End - __Vectors

上述汇编代码中的 __Vectors 表示向量表的起始地址,而 __Vectors_End 表示向量表的结束地址。除此之外,其中的 DCD 指令用于分配初始化一个或者多个以 Word 为单位的内存空间,并且以 4 字节进行对齐。

设置复位处理程序

复位处理程序是 GD32F350RBT6 上电之后首个要运行的程序,其首先会调用 SystemInit 函数初始化系统时钟,然后再调用 C 库函数 __main 进入用户定义的主函数 main()

  1. SystemInit() 是一个 ARM 标准库函数,定义在 Keil-GD32F350RBT6 工程 Firmware\CMSIS\GD\GD32F3x0\Source 目录下的 system_gd32f3x0.c 当中(即 CMSIS Cortex-M4 外设接入层源文件),主要用于初始化各种系统时钟。
  2. __main 是一个标准 C 库函数,主要用于初始化用户堆栈,并且会在最后调用自定义的 main() 函数,这也就是 main() 总是作为 Keil uVision5 工程入口函数的原因所在。
1
2
3
4
5
6
7
8
9
10
11
12
                AREA    |.text|, CODE, READONLY

;/* reset Handler */
Reset_Handler PROC
EXPORT Reset_Handler [WEAK]
IMPORT SystemInit
IMPORT __main
LDR R0, =SystemInit
BLX R0
LDR R0, =__main
BX R0
ENDP

注意:上述源文件当中的第一句代码,用于定义一个名称为 .text 的只读代码段区域。

定义异常/外部中断处理程序

接下来的代码片段,定义了一系列的异常处理程序外部中断处理程序,而这些程序的完整实现则保存在外部的 .c 源文件当中。当出现相关异常或者发生指定外部中断的时候,程序的执行流程就会跳转至这里指定的各种服务程序当中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
;/* dummy Exception Handlers */
NMI_Handler PROC
EXPORT NMI_Handler [WEAK]
B .
ENDP
HardFault_Handler\
PROC
EXPORT HardFault_Handler [WEAK]
B .
ENDP
MemManage_Handler\
PROC
EXPORT MemManage_Handler [WEAK]
B .
ENDP
BusFault_Handler\
PROC
EXPORT BusFault_Handler [WEAK]
B .
ENDP
UsageFault_Handler\
PROC
EXPORT UsageFault_Handler [WEAK]
B .
ENDP
SVC_Handler PROC
EXPORT SVC_Handler [WEAK]
B .
ENDP
DebugMon_Handler\
PROC
EXPORT DebugMon_Handler [WEAK]
B .
ENDP
PendSV_Handler\
PROC
EXPORT PendSV_Handler [WEAK]
B .
ENDP
SysTick_Handler\
PROC
EXPORT SysTick_Handler [WEAK]
B .
ENDP

Default_Handler PROC
; /* external interrupts handler */
EXPORT WWDGT_IRQHandler [WEAK]
EXPORT LVD_IRQHandler [WEAK]
EXPORT RTC_IRQHandler [WEAK]
EXPORT FMC_IRQHandler [WEAK]
EXPORT RCU_CTC_IRQHandler [WEAK]
EXPORT EXTI0_1_IRQHandler [WEAK]
EXPORT EXTI2_3_IRQHandler [WEAK]
EXPORT EXTI4_15_IRQHandler [WEAK]
EXPORT TSI_IRQHandler [WEAK]
EXPORT DMA_Channel0_IRQHandler [WEAK]
EXPORT DMA_Channel1_2_IRQHandler [WEAK]
EXPORT DMA_Channel3_4_IRQHandler [WEAK]
EXPORT ADC_CMP_IRQHandler [WEAK]
EXPORT TIMER0_BRK_UP_TRG_COM_IRQHandler [WEAK]
EXPORT TIMER0_Channel_IRQHandler [WEAK]
EXPORT TIMER1_IRQHandler [WEAK]
EXPORT TIMER2_IRQHandler [WEAK]
EXPORT TIMER5_DAC_IRQHandler [WEAK]
EXPORT TIMER13_IRQHandler [WEAK]
EXPORT TIMER14_IRQHandler [WEAK]
EXPORT TIMER15_IRQHandler [WEAK]
EXPORT TIMER16_IRQHandler [WEAK]
EXPORT I2C0_EV_IRQHandler [WEAK]
EXPORT I2C1_EV_IRQHandler [WEAK]
EXPORT SPI0_IRQHandler [WEAK]
EXPORT SPI1_IRQHandler [WEAK]
EXPORT USART0_IRQHandler [WEAK]
EXPORT USART1_IRQHandler [WEAK]
EXPORT CEC_IRQHandler [WEAK]
EXPORT I2C0_ER_IRQHandler [WEAK]
EXPORT I2C1_ER_IRQHandler [WEAK]
EXPORT USBFS_WKUP_IRQHandler [WEAK]
EXPORT DMA_Channel5_6_IRQHandler [WEAK]
EXPORT USBFS_IRQHandler [WEAK]

;/* external interrupts handler */
WWDGT_IRQHandler
LVD_IRQHandler
RTC_IRQHandler
FMC_IRQHandler
RCU_CTC_IRQHandler
EXTI0_1_IRQHandler
EXTI2_3_IRQHandler
EXTI4_15_IRQHandler
TSI_IRQHandler
DMA_Channel0_IRQHandler
DMA_Channel1_2_IRQHandler
DMA_Channel3_4_IRQHandler
ADC_CMP_IRQHandler
TIMER0_BRK_UP_TRG_COM_IRQHandler
TIMER0_Channel_IRQHandler
TIMER1_IRQHandler
TIMER2_IRQHandler
TIMER5_DAC_IRQHandler
TIMER13_IRQHandler
TIMER14_IRQHandler
TIMER15_IRQHandler
TIMER16_IRQHandler
I2C0_EV_IRQHandler
I2C1_EV_IRQHandler
SPI0_IRQHandler
SPI1_IRQHandler
USART0_IRQHandler
USART1_IRQHandler
CEC_IRQHandler
I2C0_ER_IRQHandler
I2C1_ER_IRQHandler
USBFS_WKUP_IRQHandler
DMA_Channel5_6_IRQHandler
USBFS_IRQHandler

B .
ENDP

注意:上述汇编代码当中的 B . 语句表示进入了一个无限循环,即通俗意义上的死循环

初始化用户堆栈

接着判断当前 Keil uVision5 工程是否启用有 __MICROLIB 库,如果有启用就赋予栈顶地址 __initial_sp、堆起始地址 __heap_base、堆结束地址 __heap_limit。如果没有启用,则会使用双段存储器模式,并且由用户来初始化堆栈空间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
                ALIGN

; user Initial Stack & Heap

IF :DEF:__MICROLIB

EXPORT __initial_sp
EXPORT __heap_base
EXPORT __heap_limit

ELSE

IMPORT __use_two_region_memory
EXPORT __user_initial_stackheap

__user_initial_stackheap PROC
LDR R0, = Heap_Mem
LDR R1, =(Stack_Mem + Stack_Size)
LDR R2, = (Heap_Mem + Heap_Size)
LDR R3, = Stack_Mem
BX LR
ENDP

ALIGN

ENDIF

END

注意:上述代码当中的 END 是一个汇编程序结束标记

时钟配置 system_gd32f3x0.c 解析

前面已经介绍过,由汇编语言编写的系统启动文件 startup_gd32f3x0.s 调用了 Keil-GD32F350RBT6 工程的 Firmware\CMSIS\GD\GD32F3x0\Source\system_gd32f3x0.c 源文件当中,由 C 语言编写的系统初始化函数 SystemInit()

1
2
3
4
5
6
7
8
9
10
11
12
/*!
\brief setup the microcontroller system, initialize the system
\param[in] none
\param[out] none
\retval none
*/
void SystemInit(void) {
... ... ... ...
/* configure system clock */
system_clock_config();
... ... ... ...
}

可以看到该函数最终调用的是同样定义在这个源文件里的 system_clock_config() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*!
\brief configure the system clock
\param[in] none
\param[out] none
\retval none
*/
static void system_clock_config(void)
{
#ifdef __SYSTEM_CLOCK_8M_HXTAL
system_clock_8m_hxtal();
#elif defined (__SYSTEM_CLOCK_108M_PLL_HXTAL)
system_clock_108m_hxtal();
#else
system_clock_8m_irc8m();
#endif /* __SYSTEM_CLOCK_8M_HXTAL */
}

由于在 system_gd32f3x0.c 源文件的开头位置,存在着如下针对 GDF350 系列微控制器的宏定义:

1
2
3
#if defined (GD32F350)
#define __SYSTEM_CLOCK_108M_PLL_HXTAL (uint32_t)(108000000)
#endif /* GD32F350 */

所以 system_clock_config() 方法最终实际调用的是该源文件中的函数 system_clock_108m_hxtal(),这个函数的具体定义如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#elif defined (__SYSTEM_CLOCK_108M_PLL_HXTAL)
/*!
\brief configure the system clock to 84M by PLL which selects HXTAL as its clock source
\param[in] none
\param[out] none
\retval none
*/
static void system_clock_108m_hxtal(void) {
uint32_t timeout = 0U;
uint32_t stab_flag = 0U;

/* enable HXTAL */
RCU_CTL0 |= RCU_CTL0_HXTALEN;

/* wait until HXTAL is stable or the startup time is longer than HXTAL_STARTUP_TIMEOUT */
do {
timeout++;
stab_flag = (RCU_CTL0 & RCU_CTL0_HXTALSTB);
} while((0U == stab_flag) && (HXTAL_STARTUP_TIMEOUT != timeout));
/* if fail */
if(0U == (RCU_CTL0 & RCU_CTL0_HXTALSTB)) {
return;
}
/* HXTAL is stable */
/* AHB = SYSCLK */
RCU_CFG0 |= RCU_AHB_CKSYS_DIV1;
/* APB2 = AHB/2 */
RCU_CFG0 |= RCU_APB2_CKAHB_DIV2;
/* APB1 = AHB/2 */
RCU_CFG0 |= RCU_APB1_CKAHB_DIV2;

/* PLL = HXTAL/2 * 27 = 108 MHz */
RCU_CFG0 &= ~(RCU_CFG0_PLLSEL | RCU_CFG0_PLLMF | RCU_CFG0_PLLMF4 | RCU_CFG0_PLLPREDV);
RCU_CFG1 &= ~(RCU_CFG1_PLLPRESEL | RCU_CFG1_PLLMF5 | RCU_CFG1_PREDV);
RCU_CFG0 |= (RCU_PLLSRC_HXTAL_IRC48M | (RCU_PLL_MUL27 & (~RCU_CFG1_PLLMF5)));
RCU_CFG1 |= (RCU_PLLPRESEL_HXTAL | RCU_PLL_PREDV2);
RCU_CFG1 |= (RCU_PLL_MUL27 & RCU_CFG1_PLLMF5);

/* enable PLL */
RCU_CTL0 |= RCU_CTL0_PLLEN;

/* wait until PLL is stable */
while(0U == (RCU_CTL0 & RCU_CTL0_PLLSTB)) {
}

/* select PLL as system clock */
RCU_CFG0 &= ~RCU_CFG0_SCS;
RCU_CFG0 |= RCU_CKSYSSRC_PLL;

/* wait until PLL is selected as system clock */
while(0U == (RCU_CFG0 & RCU_SCSS_PLL)) {
}
}

锁相环(PLL,Phase Locking Loop)是一种反馈控制电路,其工作过程当中,当输出信号频率与输入信号频率相同时,可以使输出电压与输入电压保持固定的相位差,就像输入/输出电压的相位被锁住了一样,所以这种电路被称为锁相环。GD32F350RBT6 内部的 PLL 主要用于根据特定的外部晶振信号来生成其它频率的信号。

观察可以发现,上述代码使能了 GD32F350RBT6 内部的 PLL 锁相坏,由于当前 UINIO-MCU-GD32F350RBT6 核心板的外部贴片晶振频率为 8MHz,所以高速外部晶体振荡器时钟 HXTAL = 8MHz,这样锁相环的输出频率可以按照 如下方式进行计算:

\[ PLL = \frac{HXTAL}{2} \times 27 = \frac{8MHz}{2} \times 27 = 108 MHz \]

上述代码选择了锁相环的输出作为系统时钟 SYSCLK,并且将高级高性能总线AHB,Advanced High-performance Bus)时钟配置为了与系统时钟的频率相等:

\[ AHB = SYSCLK = 108 MHz \]

而两条高级外设总线APB,Advanced Peripheral Bus)时钟频率分别为 AHB 总线时钟的二分之一:

\[ \begin{cases} APB1 = \frac{AHB}{2} = \frac{108MHz}{2} = 54MHz \\ APB2 = \frac{AHB}{2} = \frac{108MHz}{2} = 54MHz \end{cases} \]

系统滴答定时器 SysTick

SysTick 定时器是一个拥有自动重装载能力的 24 位向下计数器,所有 ARM Cortex-M4 内核微控制器都具备该定时器,从而能够方便的在不同型号微控制器之间进行代码移植。当设定 SysTick 定时器的初始值并且使能之后,每经过一个系统时钟周期,定时器的计数值就会减去 1,当减至 0 的时候,SysTick 就会自动重新装载初始值,并且继续开始计数,同时置位内部的 COUNTFLAG 标志位,并且触发中断(如果使能有相应的定时器中断)。

观察 GD32F350RBT6 微控制器时钟树可以发现,108MHz 频率的 AHB 总线时钟 CK_AHB,在经过 8 分频之后,默认作为了 SysTick 系统定时器的时钟源。

使用 SysTick_Config() 配置寄存器

标准固件库 Firmware\CMSIS\core_cm4.h 源文件中的 SysTick_Type 结构体类型,定义了系统滴答定时器 SysTick 相关的寄存器:

1
2
3
4
5
6
7
8
/** \brief  Structure type to access the System Timer (SysTick).
*/
typedef struct {
__IO uint32_t CTRL; /*!< Offset: 0x000 (R/W) SysTick Control and Status Register */
__IO uint32_t LOAD; /*!< Offset: 0x004 (R/W) SysTick Reload Value Register */
__IO uint32_t VAL; /*!< Offset: 0x008 (R/W) SysTick Current Value Register */
__I uint32_t CALIB; /*!< Offset: 0x00C (R/ ) SysTick Calibration Register */
} SysTick_Type;

该源文件中的 SysTick_Config() 函数,则是用于对上述 SysTick 相关的寄存器进行配置,在初始化和启动系统滴答定时器的同时,产生周期性的中断。概而言之,其主要完成了下面四个步骤的工作:

  1. 设置 LOAD 重载寄存器的初始值;
  2. 设置 SysTick 定时器中断的优先级为 (1 << 4) - 1 = 15,即优先级为最低;
  3. 配置 VAL 寄存器,装载 SysTick 的计数值;
  4. 配置 CTRL 寄存器,使能 SysTick 的时钟源、中断、以及外设本身;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/** \brief  System Tick Configuration

The function initializes the System Timer and its interrupt, and starts the System Tick Timer.
Counter is in free running mode to generate periodic interrupts.

\param [in] ticks Number of ticks between two interrupts.

\return 0 Function succeeded.
\return 1 Function failed.

\note When the variable <b>__Vendor_SysTickConfig</b> is set to 1, then the
function <b>SysTick_Config</b> is not included. In this case, the file <b><i>device</i>.h</b>
must contain a vendor-specific implementation of this function.

*/
__STATIC_INLINE uint32_t SysTick_Config(uint32_t ticks) {
if((ticks - 1) > SysTick_LOAD_RELOAD_Msk) {
return (1); /* Reload value impossible */
}

SysTick->LOAD = ticks - 1; /* set reload register */
NVIC_SetPriority(SysTick_IRQn, (1 << __NVIC_PRIO_BITS) - 1); /* set Priority for SysTick Interrupt */
SysTick->VAL = 0; /* Load the SysTick Counter Value */
SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk |
SysTick_CTRL_TICKINT_Msk |
SysTick_CTRL_ENABLE_Msk; /* Enable SysTick IRQ and SysTick Timer */
return (0); /* Function successful */
}

滴答定时器官方示例 systick.c

Keil-GD32F350RBT6 示例工程 Sources 目录下提供的 systick.c 源文件,其中的 systick_config() 方法就封装并且调用了上面的 SysTick_Config() 函数,在使能 SysTick 中断服务程序与定时器的同时,以系统时钟频率的千分之一 SystemCoreClock / 1000U 作为 SysTick 滴答定时器配置参数,也就是每 1 秒计数一千次,每一次 1 毫秒(如果修改为 SystemCoreClock / 1000000U 则可以实现微秒级的延时)。

systick.c 源文件当中提供的另一个函数 delay_1ms(),则是以 count 参数(单位为毫秒)进行定时计数,当 volatile 关键字修饰的全局变量 delay 被自减至零的时候,就会自动退出该函数的执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/* the systick configuration file */
#include "gd32f3x0.h"
#include "systick.h"

volatile static uint32_t delay;

/*!
\brief configure systick
\param[in] none
\param[out] none
\retval none
*/
void systick_config(void) {
/* setup systick timer for 1000Hz interrupts */
if(SysTick_Config(SystemCoreClock / 1000U)) {
/* capture error */
while(1) {}
}
/* configure the systick handler priority */
NVIC_SetPriority(SysTick_IRQn, 0x00U);
}

/*!
\brief delay a time in milliseconds
\param[in] count: count in milliseconds
\param[out] none
\retval none
*/
void delay_1ms(uint32_t count) {
delay = count;

while(0U != delay) {}
}

/*!
\brief delay decrement
\param[in] none
\param[out] none
\retval none
*/
void delay_decrement(void) {
if(0U != delay) {
delay--;
}
}

除此之外,上面 systick.c 代码中定义的 delay_decrement() 函数,则会被 Keil-GD32F350RBT6 工程下 Sources/gd32f3x0_it.h/c 源文件内的 SysTick 中断服务程序 SysTick_Handler() 调用,具体调用代码如下所示:

1
2
3
4
5
6
7
8
9
/*!
\brief this function handles SysTick exception
\param[in] none
\param[out] none
\retval none
*/
void SysTick_Handler(void) {
delay_decrement();
}

当每一次进入 SysTick 系统滴答定时器中断的时候,上面这个函数就会被调用一次,从而就完成了一次对于 delay 变量的自减。

编写 main.c 测试代码

接下来,将前面 LCD 示例工程当中的 main.c 源文件修改为如下的代码,使得 LED 发光二极管可以每间隔 1 秒钟循环进行闪烁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/*========== main.c ==========*/
#include "gd32f3x0.h"
#include "systick.h"
#include "main.h"

#include "../Drivers/LED/LED.h"

int main(void) {
systick_config(); // 初始化系统滴答定时器
UINIO_LED_GPIO_Config(); // 初始化 LED 相关的 GPIO 引脚

while(1) {
gpio_bit_set(UINIO_LED_PORT, UINIO_LED_PIN); // GPIOB8 输出高电平
delay_1ms(1000);

gpio_bit_reset(UINIO_LED_PORT, UINIO_LED_PIN); // GPIOB8 输出低电平
delay_1ms(1000);

gpio_bit_write(UINIO_LED_PORT, UINIO_LED_PIN, SET); // GPIOB8 输出高电平
delay_1ms(1000);

gpio_bit_write(UINIO_LED_PORT, UINIO_LED_PIN, RESET); // GPIOB8 输出低电平
delay_1ms(1000);
}
}

UINIO_SysTick_Delay_us/ms()

SysTick 系统滴答定时器的 counterreload 值往下递减到零的时候,CTRL 寄存器相应的就会被置为 1,而读取该位的时候,其值又会自动被清零,所以利用这个特点就能够以非常简短的代码,实现类似于官方 SysTic 示例的定时器延时功能:

Drivers/SysTick.h

1
2
3
4
5
6
7
8
9
/*========== SysTick.h ==========*/
#ifndef UINIO_SysTick_H
#define UINIO_SysTick_H
#include "gd32f3x0.h"

void UINIO_SysTick_Delay_us(__IO uint32_t us); // 微秒级延时,参数 us 的单位为微秒
void UINIO_SysTick_Delay_ms(__IO uint32_t ms); // 毫秒级延时,参数 ms 的单位为毫秒

#endif /* UINIO_SysTick_H */

Drivers/SysTick.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/*========== SysTick.c ==========*/
#include "SysTick.h"

/* 微秒级延时,参数 us 的单位为微秒 */
void UINIO_SysTick_Delay_us (__IO uint32_t us) {
uint32_t index;
SysTick_Config(SystemCoreClock/1000000U); // 调用 core_cm4.h 头文件中定义的 SysTick_Config() 函数

for (index = 0; index < us; index++) {
while ( !((SysTick->CTRL) & (1UL << 16)) ); // 当计数值减小到 0 时,CRTL 寄存器相应的位会被置为 1
}

SysTick->CTRL &= ~SysTick_CTRL_ENABLE_Msk; // 失能 SysTick 系统滴答定时器
}

/* 毫秒级延时,参数 ms 的单位为毫秒 */
void UINIO_SysTick_Delay_ms (__IO uint32_t ms) {
uint32_t index;
SysTick_Config(SystemCoreClock/1000U); // 调用 core_cm4.h 头文件中定义的 SysTick_Config() 函数

for (index = 0; index < ms; index++) {
while ( !((SysTick->CTRL) & (1UL << 16)) ); // 当计数值减小到 0 时,CRTL 寄存器相应的位会被置为 1
}

SysTick->CTRL &= ~SysTick_CTRL_ENABLE_Msk; // 失能 SysTick 系统滴答定时器
}

修改 main.c 测试代码

这里可以修改前面的 main.c 源文件,通过自定义的 UINIO_SysTick_Delay_us()UINIO_SysTick_Delay_ms() 函数来进行延时,从而实现相同的 LED 间隔 1 秒循环闪烁的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*========== main.c ==========*/
#include "gd32f3x0.h"
#include "main.h"

#include "../Drivers/LED/LED.h"
#include "../Drivers/SysTick/SysTick.h"

int main(void) {
UINIO_LED_GPIO_Config(); // 初始化 LED 相关的 GPIO 引脚

while(1) {
gpio_bit_write(UINIO_LED_PORT, UINIO_LED_PIN, SET); // GPIOB8 输出高电平
UINIO_SysTick_Delay_ms(1000);

gpio_bit_write(UINIO_LED_PORT, UINIO_LED_PIN, RESET); // GPIOB8 输出低电平
UINIO_SysTick_Delay_us(1000000);
}
}

注意:本节内容涉及的全部源代码,已经保存在 UINIO-MCU-GD32F350RBT6 核心板工程 Examples 目录下的 3-Systick

库函数修改 SysTick 的时钟源

Keil-GD32F350RBT6 示例工程在 Firmware\GD32F3x0_standard_peripheral\Source\gd32f3x0_misc.c 源文件内提供有一个名为 systick_clksource_set() 的库函数,可以用于修改 SysTick 的时钟源,其功能与参数说明如下面表格所示:

可以看到,该库函数可以用于选择 SysTick 系统滴答定时器的时钟源,可以选择的参数有如下两个:

  • SYSTICK_CLKSOURCE_HCLK:系统滴答定时器时钟源来自 AHB 时钟;
  • SYSTICK_CLKSOURCE_HCLK_DIV8: 系统滴答定时器时钟源来自 AHB 时钟 8 分频(默认);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*!
\brief set the systick clock source
\param[in] systick_clksource: the systick clock source needed to choose
only one parameter can be selected which is shown as below:
\arg SYSTICK_CLKSOURCE_HCLK: systick clock source is from HCLK
\arg SYSTICK_CLKSOURCE_HCLK_DIV8: systick clock source is from HCLK/8
\param[out] none
\retval none
*/

void systick_clksource_set(uint32_t systick_clksource) {
if(SYSTICK_CLKSOURCE_HCLK == systick_clksource) {
/* set the systick clock source from HCLK */
SysTick->CTRL |= SYSTICK_CLKSOURCE_HCLK;
} else {
/* set the systick clock source from HCLK/8 */
SysTick->CTRL &= SYSTICK_CLKSOURCE_HCLK_DIV8;
}
}

基于位带 Bit Band 执行位操作

嵌入式开发过程当中,经常需要进行位操作(即对一个比特位进行读写),早期的 STC51 系列单片机可以通过关键字 sbit 实现位操作,但是 ARM Cortex-M4 架构的微控制器并不存在类似语法,而是通过提供位带别名区位带区的映射来实现对比特位的操作。

位带别名区 → 位带区

ARM Cortex-M4 存储映射当中包含有位带别名区(Bit Band Alias)和位带区(Bit Band Region)两个区域,通过将位带别名区(Bit Band Alias)当中的每 1 个 Word 映射到位带区(Bit Band Region)里的 Bit (ARM 体系结构中 1 个的长度为 32 ),这样操作位带别名区当中的,就等于操作位带区相应的,具体原理可以参照下面示意图:

基于 ARM Cortex-M4 架构的 GD32F350RBT6 微控制器,分别在两个区域实现了位带功能(即从位带别名区位带区的映射):

  1. 外设 Peripheral0x44000000 ~ 0x42000000 地址范围属于位带别名区,而最低的 0x40100000 ~ 0x40000000 区别则属于位带区
  2. 静态随机存储器 SRAM0x24000000 ~ 0x22000000 地址范围属于位带别名区,而最低的 0x20100000 ~ 0x20000000 区别则属于位带区

建立通用的映射公式

下面的公式展示了位带别名区域当中的每 1 个,如何对应到位带区域的相应上面:

1
映射到位带区目标位的别名区的字地址 = 位带别名区起始地址 + (位带区目标位所在字节的地址偏移量 × 32) + (目标位在对应字节当中的位置 × 4)

根据上面的公式,位带区目标位的序号为 number(取值范围 0 <= number <= 31,具体由待操作的目标寄存器决定),则该比特位在别名区的对应地址为:

1
2
目标位映射到外设别名区的地址 = 0x42000000 + (位带区目标位所在字节的地址 - 0x40000000) * 8 * 4 + (number * 4);
目标位映射到 SRAM 别名区的地址 = 0x22000000 + (位带区目标位所在字节的地址 - 0x20000000) * 8 * 4 + (number * 4);

注意:上述公式当中,因为 1 个字节有 8 位,所以需要乘以 8,而 1 个位膨胀之后对应着 4 个字节,所以需要再乘以 4

接下来,可以将上述的两个公式合并,成为一个用于将位带区地址 address位序号 bit_number 转换为位带别名区地址BITBAND() 宏定义函数:

1
#define BITBAND(address, bit_number) ((address & 0xF0000000) + 0x02000000 + ((address & 0x00FFFFFF) << 5) + (bit_number << 2)) // 将位带区地址和位序号转换为位带别名区地址

上述宏定义语句当中的 address & 0xF0000000 用于取出 4 或者 2,并且以此来判断当前操作的是 SRAM 还是外设别名区:

  • 如果取出的是 4,加上 0X0200 0000 之后等于外设别名区的起始地址 0X4200 0000;
  • 如果取出的是 2,加上 0X0200 0000 之后等于 SRAM 别名区的起始地址 0X2200 0000

而上述宏定义语句当中 address & 0x00FF FFFF 得到的结果,与减去 0X2000 0000 或者 0X4000 0000 得到的结果相同,而后续的 << 5 以及 << 2 则分别起到了乘以 32 和乘以 4 的作用(即两种计算方式获得的二进制、十进制、十六进制结果完全相同):

乘法与位运算的对应关系
10 * 2 = 10 << 1 10 * 4 = 10 << 2 10 * 8 = 10 << 3 10 * 16 = 10 << 4
10 * 32 = 10 << 5 10 * 64 = 10 << 6 10 * 108 = 10 << 7 10 * 256 = 10 << 8
除法与位运算的对应关系
10 / 2 = 10 >> 1 10 / 4 = 10 >> 2 10 / 8 = 10 >> 3 10 / 16 = 10 >> 4
10 / 32 = 10 >> 5 10 / 64 = 10 >> 6 10 / 108 = 10 >> 7 10 / 256 = 10 >> 8

最后,就可以通过指针操作位带别名区的地址,进而实现对于位带区相应比特位的操作:

1
2
#define ADDRESS_POINTER(address)         *((volatile unsigned long  *)(address))       // 将地址转换为 unsigned long 类型指针
#define BIT_ADDRESS(address, bit_number) ADDRESS_POINTER(BITBAND(address, bit_number)) // 使用前面定义的宏定义语句 BITBAND(),将位带别名区地址转换为指针

由于 GD32F350RBT6 微控制器的 GPIOA ~ GPIOF 基地址定义如下面列表所示:

  1. GPIOA 基地址:0x4800 0000
  2. GPIOB 基地址:0x4800 0400
  3. GPIOC 基地址:0x4800 0800
  4. GPIOD 基地址:0x4800 0C00
  5. GPIOF 基地址:0x4800 1400

则可以将上述 GPIO 接口所对应的端口输出控制寄存器 GPIOx_OCTL 以及端口输入状态寄存器 GPIOx_ISTAT 地址,封装为如下的宏定义语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* 端口输出控制寄存器 GPIOx_OCTL 地址 */
#define GPIOA_OCTL_ADDRESS (GPIOA + 0x14)
#define GPIOB_OCTL_ADDRESS (GPIOB + 0x14)
#define GPIOC_OCTL_ADDRESS (GPIOC + 0x14)
#define GPIOD_OCTL_ADDRESS (GPIOD + 0x14)
#define GPIOE_OCTL_ADDRESS (GPIOE + 0x14)
#define GPIOF_OCTL_ADDRESS (GPIOF + 0x14)

/* 端口输入状态寄存器 GPIOx_ISTAT 地址 */
#define GPIOA_ISTAT_ADDRESS (GPIOA + 0x10)
#define GPIOB_ISTAT_ADDRESS (GPIOB + 0x10)
#define GPIOC_ISTAT_ADDRESS (GPIOC + 0x10)
#define GPIOD_ISTAT_ADDRESS (GPIOD + 0x10)
#define GPIOE_ISTAT_ADDRESS (GPIOE + 0x10)
#define GPIOF_ISTAT_ADDRESS (GPIOF + 0x10)

上述代码当中的 GPIOx 已经被定义在固件库的 gd32f3x0_gpio.h 头文件当中,编译的时候已经由 Keil uVision5 自动包含相关路径。接下来的时间,就可以基于上面列出的寄存器地址,进一步将它们封装为控制 GPIO 输入与输出的宏定义函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* 控制 GPIOA 的输入与输出 */
#define GPIOA_Out(number) BIT_ADDRESS(GPIOA_OCTL_ADDRESS, number)
#define GPIOA_In(number) BIT_ADDRESS(GPIOA_ISTAT_ADDRESS, number)

/* 控制 GPIOB 的输入与输出 */
#define GPIOB_Out(number) BIT_ADDRESS(GPIOB_OCTL_ADDRESS, number)
#define GPIOB_In(number) BIT_ADDRESS(GPIOB_ISTAT_ADDRESS, number)

/* 控制 GPIOC 的输入与输出 */
#define GPIOC_Out(number) BIT_ADDRESS(GPIOC_OCTL_ADDRESS, number)
#define GPIOC_In(number) BIT_ADDRESS(GPIOC_ISTAT_ADDRESS, number)

/* 控制 GPIOD 的输入与输出 */
#define GPIOD_Out(number) BIT_ADDRESS(GPIOD_OCTL_ADDRESS, number)
#define GPIOD_In(number) BIT_ADDRESS(GPIOD_ISTAT_ADDRESS, number)

/* 控制 GPIOF 的输入与输出 */
#define GPIOF_Out(number) BIT_ADDRESS(GPIOF_OCTL_ADDRESS, number)
#define GPIOF_In(number) BIT_ADDRESS(GPIOF_ISTAT_ADDRESS, number)

完整 Keil µVision 工程代码

BitBand.h

把上述的宏定义代码保存至 Drivers/BitBand 目录下的 BitBand.h 头文件里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/*========== BitBand.h ==========*/
#ifndef UINIO_BitBand_H
#define UINIO_BitBand_H

#define BITBAND(address, bit_number) ((address & 0xF0000000) + 0x02000000 + ((address & 0x00FFFFFF) << 5) + (bit_number << 2)) // 将位带区地址和位序号转换为位带别名区地址
#define ADDRESS_POINTER(address) *((volatile unsigned long *)(address)) // 将地址转换为 unsigned long 类型指针
#define BIT_ADDRESS(address, bit_number) ADDRESS_POINTER(BITBAND(address, bit_number)) // 使用前面定义的宏定义语句 BITBAND(),将位带别名区地址转换为指针

/* 端口输出控制寄存器 GPIOx_OCTL 地址 */
#define GPIOA_OCTL_ADDRESS (GPIOA + 0x14)
#define GPIOB_OCTL_ADDRESS (GPIOB + 0x14)
#define GPIOC_OCTL_ADDRESS (GPIOC + 0x14)
#define GPIOD_OCTL_ADDRESS (GPIOD + 0x14)
#define GPIOE_OCTL_ADDRESS (GPIOE + 0x14)
#define GPIOF_OCTL_ADDRESS (GPIOF + 0x14)

/* 端口输入状态寄存器 GPIOx_ISTAT 地址 */
#define GPIOA_ISTAT_ADDRESS (GPIOA + 0x10)
#define GPIOB_ISTAT_ADDRESS (GPIOB + 0x10)
#define GPIOC_ISTAT_ADDRESS (GPIOC + 0x10)
#define GPIOD_ISTAT_ADDRESS (GPIOD + 0x10)
#define GPIOE_ISTAT_ADDRESS (GPIOE + 0x10)
#define GPIOF_ISTAT_ADDRESS (GPIOF + 0x10)

/* 控制 GPIOA 的输入与输出 */
#define GPIOA_Out(number) BIT_ADDRESS(GPIOA_OCTL_ADDRESS, number)
#define GPIOA_In(number) BIT_ADDRESS(GPIOA_ISTAT_ADDRESS, number)

/* 控制 GPIOB 的输入与输出 */
#define GPIOB_Out(number) BIT_ADDRESS(GPIOB_OCTL_ADDRESS, number)
#define GPIOB_In(number) BIT_ADDRESS(GPIOB_ISTAT_ADDRESS, number)

/* 控制 GPIOC 的输入与输出 */
#define GPIOC_Out(number) BIT_ADDRESS(GPIOC_OCTL_ADDRESS, number)
#define GPIOC_In(number) BIT_ADDRESS(GPIOC_ISTAT_ADDRESS, number)

/* 控制 GPIOD 的输入与输出 */
#define GPIOD_Out(number) BIT_ADDRESS(GPIOD_OCTL_ADDRESS, number)
#define GPIOD_In(number) BIT_ADDRESS(GPIOD_ISTAT_ADDRESS, number)

/* 控制 GPIOF 的输入与输出 */
#define GPIOF_Out(number) BIT_ADDRESS(GPIOF_OCTL_ADDRESS, number)
#define GPIOF_In(number) BIT_ADDRESS(GPIOF_ISTAT_ADDRESS, number)

#endif /* UINIO_BitBand_H */

main.c

继续修改之前的 LED 闪烁示例工程 main.c 源文件,在包含 BitBand.h 头文件的同时,通过调用宏定义函数 GPIOB_Out(8) 修改 GPIOB8 引脚的电平状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*========== main.c ==========*/
#include "gd32f3x0.h"
#include "main.h"

#include "../Drivers/LED/LED.h"
#include "../Drivers/SysTick/SysTick.h"
#include "../Drivers/BitBand/BitBand.h"

int main(void) {
systick_config(); // 初始化系统滴答定时器
UINIO_LED_GPIO_Config(); // 初始化 LED 相关的 GPIO 引脚

while(1) {
GPIOB_Out(8) = 1; // GPIOB8 输出高电平
UINIO_SysTick_Delay_ms(1000);

GPIOB_Out(8) = 0; // GPIOB8 输出低电平
UINIO_SysTick_Delay_ms(1000);
}
}

除此之外,也可以将位带操作相关的宏定义代码,更加直观的声明在 main.c 源文件当中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/*========== main.c ==========*/
#include "gd32f3x0.h"
#include "main.h"

#include "../Drivers/LED/LED.h"
#include "../Drivers/SysTick/SysTick.h"

/* 偏移量 = GPIOB 基地址 0x48000400 + GPIOx_OCTL 偏移量 0x14 - 片上外设起始地址 0x40000000 */
#define GPIOB_OCTL_OFFSET ((GPIOB + 0x14) - 0x40000000)

/* 位带别名区的字地址 = 位带别名区起始地址 + (位带区目标位所在字节的地址偏移量 × 32) + (目标位在对应字节当中的位置 × 4) */
#define BIT_ADDRESS(byte_offset, bit_number) (volatile unsigned long *)(0x42000000 + (byte_offset << 5) + (bit_number << 2))

/* 配置 GPIOB 端口指定引脚的输出状态,例如 GPIOB_OUT(8) */
#define GPIOB_OUT(number) *(BIT_ADDRESS(GPIOB_OCTL_OFFSET, number))

int main(void) {
UINIO_LED_GPIO_Config(); // 初始化 LED 相关的 GPIO 引脚

while(1) {
GPIOB_OUT(8) = 1; // GPIOB8 输出高电平
UINIO_SysTick_Delay_ms(1000);

GPIOB_OUT(8) = 0; // GPIOB8 输出低电平
UINIO_SysTick_Delay_ms(1000);
}
}

GPIO 输入模式与按键

按键使用原理

微动开关或者轻触按键是通过内部的触点弹片来实现导通与截止,将其连接至 GD32F350RBT6 的 GPIO 引脚,就可以通过检测其按下之后 GPIO 引脚获取的电平状态,来判断当前是处于按下还是松开的情况:

当按键在被按下或者松开的时候,会由于触点上弹片的弹性作用,发生 5ms ~ 10ms 机械抖动,为了避免 GPIO 得到错误的状态,必须考虑采取一定的措施去消除这种抖动所带来的干扰:

  • 硬件消抖:微动开关两侧并联上一枚电容,利用其充放电作用吸收抖动产生的振荡。
  • 软件消抖:当开关按下时,通过延时代码规避掉抖动发生的时间。

首先,将一枚轻触按键 SW1 连接到 UINIO-MCU-GD32F350RBT6GPIOB9 引脚,其中一端连接至 3V3 引脚,而另一端经过位号为 R310KΩ 下拉电阻之后连接至 GND 引脚:

然后,使用固件库提供的工具函数,把 GPIOB9 配置为带下拉电阻输入模式,通过检测该引脚的电平状态,就可以判断按键 SW1 是否被按下(按键松开为低电平,按键按下为高电平)。

完整 Keil µVision 工程代码

如前所述,使用 GPIO 端口的输入输出功能,通常会需要经历下面三个步骤:

  1. 通过 void rcu_periph_clock_enable(rcu_periph_enum periph); 固件库函数启用 GPIO 端口对应的外设时钟
  2. 通过 void gpio_mode_set(uint32_t gpio_periph, uint32_t mode, uint32_t pull_up_down, uint32_t pin); 固件库函数配置 GPIO 端口的工作模式(输入模式 GPIO_MODE_INPUT、输出模式 GPIO_MODE_OUTPUT、备用功能模式 GPIO_MODE_AF、模拟模式 GPIO_MODE_ANALOG),以及设置上下拉电阻状态(悬空无上下拉 GPIO_PUPD_NONE、带上拉电阻 GPIO_PUPD_PULLUP、带下拉电阻 GPIO_PUPD_PULLDOWN)。
  3. 通过 void gpio_bit_toggle(uint32_t gpio_periph, uint32_t pin); 固件库函数翻转 LED 对应 GPIO 引脚的电平状态。

注意:本节内容所涉及的全部测试代码,已保存在 UINIO-MCU-GD32F350RBT6 核心板开源项目 Examples 目录下的 5-Key 工程当中。

Drivers/key.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*========== Key.h ==========*/
#ifndef UINIO_KEY_H
#define UINIO_KEY_H

#include "gd32f3x0.h"
#include "systick.h"

#define UINIO_KEY_RCU RCU_GPIOB // 宏定义按键对应的 GPIO 端口时钟
#define UINIO_KEY_PORT GPIOB // 宏定义按键对应的 GPIO 端口
#define UINIO_KEY_PIN GPIO_PIN_9 // 宏定义按键对应的 GPIO 引脚

void UINIO_Key_GPIO_Config(void); // 调用开关对应 GPIO 引脚的配置函数
void UINIO_Key_Scan(void); // 按键状态扫描函数

#endif /* UINIO_KEY_H */

Drivers/key.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/*========== Key.c ==========*/
#include "Key.h"
#include "../LED/LED.h"

/* 按键对应 GPIO 引脚的配置函数 */
void UINIO_Key_GPIO_Config(void) {
rcu_periph_clock_enable(UINIO_KEY_RCU); // 使能外设时钟
gpio_mode_set(UINIO_KEY_PORT, GPIO_MODE_INPUT, GPIO_PUPD_PULLDOWN, UINIO_KEY_PIN); // 配置 GPIO 为带下拉电阻的输入模式
}

/* 按键扫描函数 */
void UINIO_Key_Scan(void) {
/* 判断按键是否被按下(高电平按下,低电平松开) */
if (gpio_input_bit_get(UINIO_KEY_PORT, UINIO_KEY_PIN) == SET) {
delay_1ms(20); // 延时 20 毫秒,规避按键弹片抖动时间

/* 再次判断按键是否被按下 */
if (gpio_input_bit_get(UINIO_KEY_PORT, UINIO_KEY_PIN) == SET) {
gpio_bit_toggle(UINIO_LED_PORT, UINIO_LED_PIN); // 翻转 LED 对应 GPIO 引脚的电平状态
while (gpio_input_bit_get(UINIO_KEY_PORT, UINIO_KEY_PIN) == SET); // 判断按键是否被松开
}
}
}

Sources/main.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*========== main.c ==========*/
#include "gd32f3x0.h"
#include "systick.h"
#include "main.h"

#include "../Drivers/LED/LED.h"
#include "../Drivers/Key/Key.h"
#include "../Drivers/SysTick/SysTick.h"

int main(void) {
systick_config(); // 滴答定时器初始化
UINIO_LED_GPIO_Config(); // 配置 LED 相关的 GPIO 外设
UINIO_Key_GPIO_Config(); // 配置按键相关的 GPIO 外设

while (1) {
UINIO_Key_Scan(); // 循环扫描按键状态
}
}

USART 通用同步/异步收发器

USART 串行协议分析

通用同步/异步收发器(USART,Universal Synchronous/Asynchronous Receiver Transmitter)是一种基于数据帧的串行数据通信方式,每一个数据帧都会以 1 个起始位开始,并且以 1 个停止位结束,其数据帧的基本格式如下面示意图所示:

  1. 起始位:首先发送一个起始位,通常为逻辑低电平 0,用于通知接收端数据即将开始发送。
  2. 数据位:紧接着是数据位,可以有 5 ~ 8 位长度,按照由 LSB(最低有效位)到 MSB(最高有效位)的顺序发送。
  3. 校验位:数据位之后是可选的奇偶校验位,用于检查数据传输过程当中,是否存在错误。
  4. 停止位:最后是停止位,通常为逻辑高电平 1,用于标记数据帧传输完毕。

注意空闲帧与停止位一样均为高电平,如果 USART 连接断开,则下拉为低电平,从而成为断开帧

USART 串行接口可以工作在单工(单向通信)、半双工(双向分时通信)、全双工(双向通信)模式下。每个 USART 通信设备之间的 波特率(单位为 bit/s,即每秒钟传送的比特位数)、数据位停止位奇偶校验位 必须保持一致。

UINIO-MCU-GD32F350RBT6 核心板与另一款 UINIO 系列开源硬件 UINIO-USB-UART 串口调试器 ,参照下图的线路相互进行连接(即 UINIO-MCU-GD32F350RBT6 核心板的 GPIOA9GPIOA10 分别连接至 UINIO-USB-UART 串口调试器的 RXDTXD 引脚),并且将后者的 Type-C 接口通过 USB 线缆连接至计算机,从而建立起与串行通信上位机软件的 USART 连接,进而可以查看到后续实验代码所打印出的测试数据。

完整 Keil µVision 工程代码

使用 GD32F350RBT6 的片上 USART 外设进行通信,需要经历下面六个步骤:

  1. 使能 USART 和 GPIO 外设时钟 rcu_periph_clock_enable()
  2. 配置 GPIO 复用模式 gpio_af_set()
  3. 配置 GPIO 的工作模式 gpio_mode_set()
  4. 配置 GPIO 的输出模式与速度 gpio_output_options_set
  5. 复位 USART 外设 usart_deinit(),并且配置其工作参数 usart_deinit()usart_baudrate_set()usart_parity_config()usart_word_length_set()usart_stop_bit_set()
  6. 使能 USART 串口 usart_enable() 及其发送功能 usart_transmit_config()

注意:本节内容所涉及的全部测试代码,已保存在 UINIO-MCU-GD32F350RBT6 核心板开源项目 Examples 目录下的 6-USART 工程当中。

Drivers/USART.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#ifndef UINIO_USART_H
#define UINIO_USART_H

#include "gd32f3x0.h"
#include "systick.h"

#define UINIO_USART USART0 // 定义 USART 外设资源
#define UINIO_USART_RCU RCU_USART0 // 定义 USART0 的外设时钟
#define UINIO_USART_AF GPIO_AF_1 // 定义 GPIO 引脚的复用功能

/* 定义 TX 和 RX 对应的 GPIO 外设时钟 */
#define UINIO_USART_TX_RCU RCU_GPIOA
#define UINIO_USART_RX_RCU RCU_GPIOA

/* 定义 TX 和 RX 对应的 GPIO 接口*/
#define UINIO_USART_TX_PORT GPIOA
#define UINIO_USART_RX_PORT GPIOA

/* 定义 TX 和 RX 对应的 GPIO 引脚*/
#define UINIO_USART_TX_PIN GPIO_PIN_9
#define UINIO_USART_RX_PIN GPIO_PIN_10

void UINIO_USART_GPIO_Config(uint32_t band_rate); // USART 外设资源配置函数
void UINIO_USART_Send_Data(char ucch); // 用于发送 1 个字节的函数
void UINIO_USART_Send_String(char *ucstr); // 用于发送字符串的函数

#endif /* UINIO_USART_H */

Drivers/USART.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include "USART.h"
#include "stdio.h"

/** 配置 USART 相关的 GPIO,参数 band_rate 是波特率 */
void UINIO_USART_GPIO_Config(uint32_t band_rate) {
/* 开启时钟 */
rcu_periph_clock_enable(UINIO_USART_TX_RCU); // 使能 TX 对应 GPIO 引脚的时钟
rcu_periph_clock_enable(UINIO_USART_RX_RCU); // 使能 RX 对应 GPIO 引脚的时钟
rcu_periph_clock_enable(UINIO_USART_RCU); // 使能 USART 外设时钟

/* 配置 GPIO 引脚的复用功能 */
gpio_af_set(UINIO_USART_TX_PORT, UINIO_USART_AF, UINIO_USART_TX_PIN);
gpio_af_set(UINIO_USART_RX_PORT, UINIO_USART_AF, UINIO_USART_RX_PIN);

/* 配置 TX 和 RX 对应 GPIO 引脚的模式与速度 */
gpio_mode_set(UINIO_USART_TX_PORT, GPIO_MODE_AF, GPIO_PUPD_PULLUP, UINIO_USART_TX_PIN); // 配置 TX 引脚为带上拉电阻的复用模式
gpio_output_options_set(UINIO_USART_TX_PORT, GPIO_OTYPE_PP, GPIO_OSPEED_50MHZ, UINIO_USART_TX_PIN); // 配置 TX 引脚为推挽输出,速率为 50MHZ
gpio_mode_set(UINIO_USART_RX_PORT, GPIO_MODE_AF, GPIO_PUPD_PULLUP, UINIO_USART_RX_PIN); // 配置 RX 引脚为带上拉电阻的复用模式
gpio_output_options_set(UINIO_USART_RX_PORT, GPIO_OTYPE_PP, GPIO_OSPEED_50MHZ, UINIO_USART_RX_PIN); // 配置 RX 引脚为推挽输出,速率为 50MHZ

/* 配置 USART 串行通信的参数 */
usart_deinit(UINIO_USART); // 复位 USART 外设
usart_baudrate_set(UINIO_USART, band_rate); // 设置 USART 波特率
usart_parity_config(UINIO_USART, USART_PM_NONE); // 设置 USART 奇偶校验位
usart_word_length_set(UINIO_USART, USART_WL_8BIT); // 设置 USART 数据位长度为 8 位
usart_stop_bit_set(UINIO_USART, USART_STB_1BIT); // 设置 USART 停止位长度为 1 位

/* 使能和配置 USART串口 */
usart_transmit_config(UINIO_USART, USART_TRANSMIT_ENABLE); // 使能 USART 发送功能
usart_enable(UINIO_USART); // 使能 USART 串口
}

/** 通过 USART 发送一个字节 */
void UINIO_USART_Send_Data(char data) {
usart_data_transmit(UINIO_USART, (uint8_t)data); // 通过 USART 发送数据
while (RESET == usart_flag_get(UINIO_USART, USART_FLAG_TBE)); // 通过发送数据缓冲区空标志位来判断发送是否完成
}

/** 通过 USART 发送字符串 */
void UINIO_USART_Send_String(char *string) {
/* 开始循环发送,当字符串为空或者指针地址为空时跳出 */
while (string && *string) {
UINIO_USART_Send_Data(*string++); // 调用上面的函数,循环发送单个字符
}
}

/** 通过重写 C 语言 printf() 不断循环调用的 fputc() 函数,实现串口数据输出的重定向 */
int fputc(int character, FILE *stream) {
UINIO_USART_Send_Data(character); // 调用上面的函数,发送单个字符
return character;
}

Sources/main.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/*========== main.c ==========*/
#include "gd32f3x0.h"
#include "systick.h"
#include "main.h"
#include <stdio.h>

#include "../Drivers/USART/USART.h"

int main(void) {
UINIO_USART_GPIO_Config(9600U); // 配置 USART0,并将波特率设置为 9600

unsigned short count1 = 0; // 声明短整型测试变量 count1
float count2 = 0.0; // 声明浮点型测试变量 count2

while (1) {
/* 依次发送单个字符 `H` `a` `n` `k` */
UINIO_USART_Send_Data('H');
UINIO_USART_Send_Data('a');
UINIO_USART_Send_Data('n');
UINIO_USART_Send_Data('k');
UINIO_USART_Send_Data('\n');

UINIO_USART_Send_String("UinIO.com\n"); // 发送字符串 UinIO.com

/* 测试 printf() 串口打印重定向 */
count1++; // 短整型测试变量自增 1
count2 += 1.0F; // 浮点型测试变量自增 1
printf("count1 = %d, count2 = %.2f \n", count1, count2); // 向 USART 串行接口打印格式化信息
}
}

外部中断 EXTI

嵌套向量中断控制 NVIC

GD32F350RBT6 微控制器所采用的 ARM Cortex-M4 内核架构集成有嵌套式矢量型中断控制器(NVIC,Nested Vectored Interrupt Controller),主要用于管理和处理中断:

  1. 中断管理:当中断源产生中断信号的时候,NVIC 就会捕获处理这些信号。
  2. 优先级处理:通过对中断优先级进行排序,NVIC 会确保高优先级的中断首先得到处理。
  3. 中断嵌套:如果在一个中断服务程序当中,发生了另外一个更高优先级的中断,NVIC 会暂停处理当前中断,转而处理更高优先级的中断。
  4. 向量中断:每一个中断都关联有一个固定地址的中断服务程序,当中断发生时 NVIC 就会根据这个地址去执行相应的中断服务程序。
  5. 中断屏蔽:NVIC 允许通过编程来屏蔽某些中断,以防止它们被处理,该功能在特定情况下非常有用,例如需要执行关键任务而不希望被其它中断事件打断的时候。
  6. 低功耗模式支持:NVIC 与微控制器的低功耗模式紧密集成,休眠模式下 NVIC 可以检测外部中断并且唤醒 MCU,从而实现在低功耗状态下的快速响应。

外部中断/事件控制器 EXTI

外部中断/事件控制器(EXTI,External Interrupt/Event Controller)负责检测来自于中断源的中断请求,并且通知微控制器进行处理。其包含有 24 个相互独立的边沿检测电路(每个边沿检测电路都可以独立进行配置与屏蔽),能够在微控制器内核当中产生中断请求以及唤醒事件。每一个中断都拥有 4 位的中断优先级配置位,可以提供 16 个中断优先等级,并且这些中断都拥有着 上升沿下降沿任意边沿 三种触发方式:

  1. 中断线配置:配置外部中断线(EXTI Line),也就是 MCU 当中用于接收中断请求的物理线路
  2. 触发条件方式:设置中断的触发方式,即 上升沿下降沿任意边沿
  3. 中断优先级管理:分配不同的中断优先级,确保 MCU 能够按照预期的顺序与优先级处理中断请求。
  4. 中断请求生成与处理:当外部事件满足中断触发条件时,生成一个中断请求发送给 NVIC(嵌套向量中断控制器),后者会根据中断的优先级来决定是否响应该中断,如果需要响应,就会暂停执行当前的程序,转而将控制权移交给指定的 ISR(中断服务程序,Interrupt Service Routine)。
  5. 中断服务程序执行:在 ISR 中断服务程序当中,可以编写代码处理外部事件。待处理完毕之后,就会将控制权返还给刚才被中断的程序,从之前暂停的位置继续运行。

EXTI 中断的触发源可以来自于 GPIOA/B/C/F (0~15) 引脚,以及 LVD(低电压检测)、RTC(实时时钟)、CEC(HDMI 的 CEC 控制器)、CMP(比较器)、USBUSART 等片上外设:

注意:上述表格当中 EXTI 中断线触发源的对应关系非常重要。

接下来的实验里,首先需要将一枚轻触按键 SW2 连接到 UINIO-MCU-GD32F350RBT6 核心板的 GPIOA0 引脚,其中一端连接至 3V3,而另外一端经过位号为 R510KΩ 下拉电阻之后连接至 GND。然后再把 LED3 的一端通过限流电阻 R4 连接至 GPIOB8 引脚,另外一端连接到 GND 引脚:

最后,再将 UINIO-MCU-GD32F350RBT6GPIOA9GPIOA10 分别连接至 UINIO-USB-UART 串口调试器的 RXDTXD 引脚,以便于通过上位机软件观察 USART 串口输出的调试数据。

完整 Keil µVision 工程代码

使用 GD32F350RBT6 微控制器的 EXTI 外部中断功能,通常需要经历下面一系列步骤:

  1. 通过 rcu_periph_clock_enable() 使能 GPIO 引脚和 CGFCMP 系统配置外设时钟。
  2. 调用 nvic_priority_group_set() 配置优先级分组
  3. 调用 nvic_irq_enable() 使能 NVIC 中断,并且配置抢占优先级响应优先级
  4. 通过 syscfg_exti_line_config() 将中断线与 GPIO 引脚进行连接。
  5. 调用 exti_init() 设置中断线中断模式触发类型
  6. 使能中断线 exti_interrupt_enable(),并且清除中断标志位 exti_interrupt_flag_clear()
  7. 编写已经在 startup_gd32f3x0.s 启动文件当中定义好名称,且参数和返回值皆为 void中断服务函数(每次中断执行完毕之后都需要清除一下中断标志位)。

注意:本节内容所涉及的全部测试代码,已保存在 UINIO-MCU-GD32F350RBT6 核心板开源项目 Examples 目录下的 7-EXTI-Key 工程当中。

Drivers/EXTI-Key.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*========== EXTI-Key.h ==========*/
#ifndef UINIO_EXTI_KEY_H
#define UINIO_EXTI_KEY_H

#include "gd32f3x0.h"

#define UINIO_KEY_PORT GPIOA // 按键对应的 GPIO 端口
#define UINIO_KEY_PIN GPIO_PIN_0 // 按键对应的 GPIO 引脚
#define UINIO_KEY_RCU RCU_GPIOA // 按键对应 GPIO 端口的外设时钟

#define UINIO_KEY_EXTI_PORT_SOURCE EXTI_SOURCE_GPIOA // 定义用于 EXTI 的 GPIO 端口
#define UINIO_KEY_EXTI_PIN_SOURCE EXTI_SOURCE_PIN0 // 定义用于 EXTI 的 GPIO 引脚
#define UINIO_KEY_EXTI_LINE EXTI_0 // 定义 EXTI 中断线 0
#define UINIO_KEY_EXTI_IRQN EXTI0_1_IRQn // 定义 EXTI 线 0 和线 1 中断
#define UINIO_KEY_EXTI_IRQ_Handler EXTI0_1_IRQHandler // 定义 EXTI 中断函数的名称

void UINIO_EXTI_Key_GPIO_Config(void); // 按键中断配置函数

#endif /* UINIO_EXTI_KEY_H */

注意:上述代码当中的中断线 0 和 1 的中断服务函数名称 EXTI0_1_IRQHandler 已经被定义在 startup_gd32f3x0.s 启动文件当中。

Drivers/EXTI-Key.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/*========== EXTI-Key.c ==========*/
#include "stdio.h"

#include "../LED/LED.h"
#include "../EXTI-Key/EXTI-Key.h"

/** 按键中断配置函数 */
void UINIO_EXTI_Key_GPIO_Config(void) {
rcu_periph_clock_enable(UINIO_KEY_RCU); // 使能按键对应的 GPIO 外设时钟
rcu_periph_clock_enable(RCU_CFGCMP); // 使能 CGFCMP 系统配置外设时钟

gpio_mode_set(UINIO_KEY_PORT, GPIO_MODE_INPUT, GPIO_PUPD_PULLDOWN, UINIO_KEY_PIN); // 配置按键对应的 GPIO 引脚为带下拉电阻的输入模式,默认为低电平
nvic_irq_enable(UINIO_KEY_EXTI_IRQN, 3U, 3U); // 使能 NVIC 中断,抢占优先级 1,子优先级 1

syscfg_exti_line_config(UINIO_KEY_EXTI_PORT_SOURCE, UINIO_KEY_EXTI_PIN_SOURCE); // 配置 GPIO 引脚作为 EXTI 外部中断
exti_init(UINIO_KEY_EXTI_LINE, EXTI_INTERRUPT, EXTI_TRIG_BOTH); // 初始化 EXTI 外部中断线
exti_interrupt_enable(UINIO_KEY_EXTI_LINE); // 使能 EXTI 外部中断
exti_interrupt_flag_clear(UINIO_KEY_EXTI_LINE); // 清除 EXTI 外部中断标志位
}

/** 按键中断处理函数 */
void UINIO_KEY_EXTI_IRQ_Handler(void) {
/* 如果中断标志位为 1,那么表示按键被按下 */
if (exti_interrupt_flag_get(UINIO_KEY_EXTI_LINE) == SET) {
/* 当按键被按下时,执行的任务 */
if (gpio_input_bit_get(UINIO_KEY_PORT, UINIO_KEY_PIN) == SET) {
gpio_bit_toggle(UINIO_LED_PORT, UINIO_LED_PIN); // 翻转 LED 电平状态
printf("Key Press\n"); // 串口打印 Key Press
}
/* 当按键被松开时,执行的任务 */
else {
printf("Key Release\n"); // 串口打印 Key Release
}
exti_interrupt_flag_clear(UINIO_KEY_EXTI_LINE); // 清除 EXTI 外部中断标志位
}
}

Sources/main.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*========== main.c ==========*/
#include "gd32f3x0.h"
#include "main.h"
#include <stdio.h>

#include "../Drivers/LED/LED.h"
#include "../Drivers/USART/USART.h"
#include "../Drivers/EXTI-Key/EXTI-Key.h"

int main(void) {
/* 配置优先级分组(2 位用于抢占优先级,2 位用于响应优先级) */
nvic_priority_group_set(NVIC_PRIGROUP_PRE2_SUB2);

UINIO_LED_GPIO_Config(); // 初始化 LED 对应的 GPIO 引脚资源
UINIO_EXTI_Key_GPIO_Config(); // 初始化按键所对应 GPIO 引脚的中断配置
UINIO_USART_GPIO_Config(9600U); // 初始化 USART 串口,设置波特率为 9600

while (1) {};
}

定时器 TIMER 概览

GD32F350RBT6 微控制器的定时器是一个可编程的无符号计数器,支持输入捕获输出比较,可以按照功能特性被划分为六种类型:

  1. 高级定时器TIMER0);
  2. 通用定时器 L0TIMER1TIMER2);
  3. 通用定时器 L2TIMER13);
  4. 通用定时器 L3TIMER14);
  5. 通用定时器 L4TIMER15TIMER16);
  6. 基本定时器TIMER5);

也就是 1 个 16 位高级定时器TIMER0),1 个 32 位通用定时器TIMER1),5 个 16 位通用定时器TIMER2TIMER13 ~ TIMER16),1 个 16 位基本定时器TIMER5)。

高级定时器 TIMER0

高级定时器(TIMER0)属于可编程的四通道定时器,包含有 16 位无符号计数器,支持输入捕获与输出比较。可以用于产生 PWM 信号控制电机(包含有死区时间插入模块)以及进行电源管理,其主要特性如下表所示:

高级定时器 TIMER0 特性 功能描述
总通道数 4 通道
计数器宽度 TIMER016 位
时钟源可选 内部时钟、内部触发、外部输入、外部触发
多种计数模式 向上计数、向下计数、中央计数
正交编码器接口 用于追踪运动和分辨旋转方向和位置
霍尔传感器接口 可以用于控制三相电机
可编程的预分频器 16 位(运行时可以被改变)
每个通道可配置 输入捕获模式、输出比较模式、可编程的 PWM 模式、单脉冲模式
可编程的死区时间 支持
自动重装载功能 支持
可编程的计数器重复功能 支持
中止输入功能 支持
中断输出和 DMA 请求 更新事件、触发事件、比较/捕获事件、换相事件、中止事件
多个定时器的菊链 使得一个定时器,能够同时启动多个定时器
定时器的同步 允许被选择的定时器在同一个时钟周期开始计数
定时器主-从管理 支持

下面的结构框图提供了高级定时器(TIMER0)的内部配置细节:

通用定时器 L0 - TIMER1, TIMER2

通用定时器 L0(TIMER1, TIMER2)同样属于可编程的四通道定时器,包含有 16 位无符号计数器,支持输入捕获与输出比较。可以用于产生 PWM 信号控制电机以及进行电源管理,其主要特性如下表所示:

高级定时器 TIMER1, TIMER2 特性 功能描述
总通道数 4 通道
计数器宽度 TIMER216 位TIMER132 位
时钟源可选 内部时钟、内部触发、外部输入、外部触发
多种计数模式 向上计数、向下计数、中央计数
正交编码器接口 用于追踪运动和分辨旋转方向和位置
霍尔传感器接口 可以用于控制三相电机
可编程的预分频器 16 位(运行时可以被改变)
每个通道可配置 输入捕获模式、输出比较模式、可编程的 PWM 模式、单脉冲模式
自动重装载功能 支持
中断输出和 DMA 请求 更新事件、触发事件、比较/捕获事件
多个定时器的菊链 使得一个定时器,能够同时启动多个定时器
定时器的同步 允许被选择的定时器在同一个时钟周期开始计数
定时器主-从管理 支持

下面的结构框图提供了通用定时器 L0(TIMER1, TIMER2)的内部配置细节:

通用定时器 L2 - TIMER13

通用定时器 L2(TIMER13)属于可编程的单通道定时器,包含有 16 位无符号计数器,支持输入捕获与输出比较。可以用于产生 PWM 信号控制电机以及进行电源管理,其主要特性如下表所示:

高级定时器 TIMER13 特性 功能描述
总通道数 1 通道
计数器宽度 TIMER1316 位
时钟源可选 内部时钟
计数模式 向上计数
可编程的预分频器 16 位(运行时可以被改变)
每个通道可配置 输入捕获模式、输出比较模式、可编程的 PWM 模式
自动重装载功能 支持
中断输出 更新事件、比较/捕获事件

下面的结构框图提供了通用定时器 L2(TIMER13)的内部配置细节:

通用定时器 L3 - TIMER14

通用定时器 L3(TIMER14)属于可编程的两通道定时器,包含有 16 位无符号计数器,支持输入捕获与输出比较。可以用于产生 PWM 信号控制电机(包含有死区时间插入模块)以及进行电源管理,其主要特性如下表所示:

高级定时器 TIMER14 特性 功能描述
总通道数 2 通道
计数器宽度 TIMER1416 位
时钟源可选 内部时钟、内部触发、外部输入
计数模式 向上计数
可编程的预分频器 16 位(运行时可以被改变)
每个通道可配置 输入捕获模式、输出比较模式、可编程的 PWM 模式、单脉冲模式
可编程的死区时间 支持
自动重装载功能 支持
可编程的计数器重复功能 支持
中止输入功能 支持
中断输出和 DMA 请求 更新事件、比较/捕获事件、换相事件、中止事件
多个定时器的菊链 使得一个定时器,能够同时启动多个定时器
定时器的同步 使得一个定时器,能够同时启动多个定时器
定时器主-从管理 支持

下面的结构框图提供了通用定时器 L3(TIMER14)的内部配置细节:

通用定时器 L4 - TIMER15, TIMER16

通用定时器 L4(TIMER15, TIMER16)属于可编程的单通道定时器,包含有 16 位无符号计数器,支持输入捕获与输出比较。可以用于产生 PWM 信号控制电机(包含有死区时间插入模块)以及进行电源管理,其主要特性如下表所示:

高级定时器 TIMER15, TIMER16 特性 功能描述
总通道数 单通道
计数器宽度 TIMER15TIMER16 都是 16 位
时钟源可选 内部时钟
计数模式 向上计数
可编程的预分频器 16 位(运行时可以被改变)
每个通道可配置 输入捕获模式、输出比较模式、可编程的 PWM 模式、单脉冲模式
可编程的死区时间 支持
自动重装载功能 支持
可编程的计数器重复功能 支持
中止输入功能 支持
中断输出和 DMA 请求 更新事件、比较/捕获事件、换相事件、中止事件

下面的结构框图提供了通用定时器 L4(TIMER15, TIMER16)的内部配置细节:

基本定时器 - TIMER5

基本定时器(TIMER5)包含有 16 位无符号计数器,支持输入捕获与输出比较。可以用于通用定时器,产生 DMA 请求,以及为 DAC 数模转换提供时钟,其主要特性如下表所示:

基本定时器 TIMER5 特性 功能描述
计数器宽度 TIMER516 位
时钟源可选 内部时钟
计数模式 向上计数
可编程的预分频器 16 位(运行时可以被改变)
自动重装载功能 支持
中断输出和 DMA 请求 更新事件

下面的结构框图提供了基本定时器(TIMER5)的内部配置细节:

基本定时器 TIMER5 与中断

本章节内容,将会利用基本定时器 TIMER5 以及其关联的定时器中断,来实现让 LED 每隔 1 秒不间断进行闪烁的实验。其中,定时器时钟运行参数的配置,是两个比较重要的知识点,需要大家在实验过程当中特别留意。

定时器时钟配置

观察下面定时器相关的时钟树,可以发现如果 APB 总线的时钟分频系数1,那么定时器时钟频率就会与 AHB 总线保持一致。否则,定时器的时钟频率会被设定为 APB 总线频率的 2 倍

可以看到,系统时钟 CK_SYS 在经过 AHB 预分频器之后,可以得到 AHB 总线时钟 CK_AHB。而这个 CK_AHB 再经过 APB1 和 APB2 预分频器之后,就可以得到定时器时钟 CK_TIMERx,具体请参考下面的计算公式:

\[ CK_{TIMERx} = \frac{CK_{AHB}}{APB_{x预分频值} \div 2} \]

由于固件库 system_gd32f3x0.c 源文件的 system_clock_config() 函数当中,已经将 APB1APB2 的预分频值设定为 2

1
2
3
4
/* APB2 = AHB/2 */
RCU_CFG0 |= RCU_APB2_CKAHB_DIV2;
/* APB1 = AHB/2 */
RCU_CFG0 |= RCU_APB1_CKAHB_DIV2;

根据上面的计算公式,就可以知道定时器时钟 CK_TIMERxAHB 总线时钟 CK_AHB 的时钟频率值相等:

\[ CK_{TIMERx} = \frac{CK_{AHB}}{2 \div 2} = CK_{AHB} = 108MHz \]

定时器工作参数配置

官方固件库 gd32f3x0_timer.h 头文件当中定义的结构体变量 timer_parameter_struct,可以用于配置定时器的相关工作参数:

1
2
3
4
5
6
7
8
9
10
/* constants definitions */
/* TIMER init parameter struct definitions*/
typedef struct {
uint16_t prescaler; /*!< prescaler value */
uint16_t alignedmode; /*!< aligned mode */
uint16_t counterdirection; /*!< counter direction */
uint16_t clockdivision; /*!< clock division value */
uint32_t period; /*!< period value */
uint8_t repetitioncounter; /*!< the counter repetition value */
} timer_parameter_struct;

在下面的列表里,展示了这些参数的具体功能与用途:

  • prescaler:时钟的 16 位 预分频值,取值范围为 0 ~ 65535
  • alignedmode:对齐模式,可供选取的值有 TIMER_COUNTER_EDGETIMER_COUNTER_CENTER_DOWNTIMER_COUNTER_CENTER_UPTIMER_COUNTER_CENTER_BOTH
  • counterdirection:计数方向,可供选取的值有 TIMER_COUNTER_UPTIMER_COUNTER_DOWN
  • period:周期,取值范围为 0 ~ 65535,当计数器达到周期值的时候,计数值将会清零,可以配合计数器时钟频率计算出中断时间。
  • clockdivision:时钟分频因子,可供选取的值有 TIMER_CKDIV_DIV1TIMER_CKDIV_DIV2TIMER_CKDIV_DIV4,主要用于输入捕获场景。
  • repetitioncounter:重复计数器值(仅限于高级定时器),取值范围为 0 ~ 255

实验电路的搭建

类似于前面 《通过 GPIO 固件库控制 LED》 章节的实验电路,这里同样将 4.7K 限流电阻 R1 与 LED 发光二极管串联之后,再连接到 UINIO-MCU-GD32F350RBT6 核心板的 GPIOB8 引脚(高电平点亮,低电平熄灭):

除此之外,还需要再将 UINIO-MCU-GD32F350RBT6 核心板的 GPIOA9GPIOA10 引脚,分别连接至 UINIO-USB-UART 串口调试器的 RXDTXD 引脚,这样就可以完成实验电路的搭建。

完整 Keil µVision 工程代码

本节内容的实验,主要基于 16 位的基本定时器 TIMER5 来实现 LED 每间隔 1 秒进行闪烁的效果,完成该功能大致需要经历下面六个步骤:

  1. 配置定时器时钟,由于固件库已经默认 CK_TIMERx = CK_AHB = 108MHz,所以本示例缺省该步骤。
  2. 配置并且初始化定时器,也就是设置 timer_parameter_struct 结构体的成员属性,然后调用 timer_init() 初始化定时器。
  3. 调用 nvic_irq_enable() 设置定时器中断的优先级。
  4. 调用 timer_interrupt_enable() 使能定时器更新中断事件。
  5. 调用 timer_enable() 函数使能定时器自身。
  6. 自定义基本定时器 TIMER5 相关的中断服务函数 TIMER5_DAC_IRQHandler(),该函数名称已在启动文件 startup_gd32f3x0.s 进行过声明。

注意:本节内容所涉及的全部测试代码,已保存在 UINIO-MCU-GD32F350RBT6 核心板开源项目 Examples 目录下的 8-Timer-LED 工程当中。

Drivers/Timer-LED.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*========== TIMER_LED.h ==========*/
#ifndef UINIO_TIMER_LED_H
#define UINIO_TIMER_LED_H

#include "gd32f3x0.h"

#define UINIO_TIMER_RCU RCU_TIMER5 // 定时器 Timer5 时钟
#define UINIO_TIMER TIMER5 // 定时器 Timer5
#define UINIO_TIMER_IRQ TIMER5_DAC_IRQn // 定时器 Timer5 中断
#define UINIO_TIMER_IRQ_Handler TIMER5_DAC_IRQHandler // 定时器 Timer5 中断服务函数

// #define UINIO_TIMER_RCU RCU_TIMER2 // 定时器 Timer2 时钟
// #define UINIO_TIMER TIMER2 // 定时器 Timer2
// #define UINIO_TIMER_IRQ TIMER2_IRQn // 定时器 Timer2 中断
// #define UINIO_TIMER_IRQ_Handler TIMER2_IRQHandler // 定时器 Timer2 中断服务函数

void UINIO_Basic_Timer_Config(uint16_t pre, uint16_t per); // 基本定时器配置函数

#endif /* UINIO_TIMER_LED_H */

Drivers/Timer-LED.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/*========== TIMER_LED.c ==========*/
#include "stdio.h"

#include "../LED/LED.h"
#include "../Timer-LED/Timer-LED.h"

/** 基本定时器配置,参数 UINIO_Clock_Prescale 为时钟预分频值,参数 UINIO_Clock_Period 为时钟周期 */
void UINIO_Basic_Timer_Config(uint16_t UINIO_Clock_Prescale, uint16_t UINIO_Clock_Period) {
rcu_periph_clock_enable(UINIO_TIMER_RCU); // 使能定时器外设时钟
/* CK_TIMERx = CK_AHB = 108MHz */
timer_deinit(UINIO_TIMER); // 复位定时器外设

/* 配置定时器参数 */
timer_parameter_struct TimerParameter; // 定义 timer_parameter_struct 定时器参数结构体
TimerParameter.prescaler = UINIO_Clock_Prescale - 1; // 预分频值,由于该值从 0 开始计数,所以这里需要减去 1
TimerParameter.alignedmode = TIMER_COUNTER_EDGE; // 对齐模式,边缘对齐
TimerParameter.counterdirection = TIMER_COUNTER_UP; // 计数方向,向上计数
TimerParameter.period = UINIO_Clock_Period - 1; // 周期,同样由于该值从 0 开始计数,这里同样需要减去 1
TimerParameter.clockdivision = TIMER_CKDIV_DIV1; // 时钟分频因子
TimerParameter.repetitioncounter = 0; // 重复计数器值,取值范围为 0 ~ 255,配置为 x 就会重复 x+1 次进入中断
timer_init(UINIO_TIMER, &TimerParameter); // 初始化定时器

nvic_irq_enable(UINIO_TIMER_IRQ, 3U, 3U); // 配置定时器中断优先级,抢占优先级 3,子优先级 2

timer_interrupt_enable(UINIO_TIMER, TIMER_INT_UP); // 使能定时器更新中断
timer_enable(UINIO_TIMER); // 使能定时器
}

/** 基本定时器中断服务函数 */
void UINIO_TIMER_IRQ_Handler(void) {
/* 判断定时器中断标志位 TIMER_INT_FLAG_UP 是否置位 */
if (timer_interrupt_flag_get(UINIO_TIMER, TIMER_INT_FLAG_UP) == SET) {
timer_interrupt_flag_clear(UINIO_TIMER, TIMER_INT_FLAG_UP); // 清除定时器更新中断标志位
gpio_bit_toggle(UINIO_LED_Port, UINIO_LED_Pin); // 翻转 LED 对应 GPIO 引脚的电平状态
printf("UinIO.com\n"); // 串口打印调试信息 UinIO.com
}
}

Sources/main.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*========== main.c ==========*/
#include "gd32f3x0.h"
#include "main.h"
#include <stdio.h>

#include "../Drivers/LED/LED.h"
#include "../Drivers/USART/USART.h"
#include "../Drivers/Timer-LED/Timer-LED.h"

int main(void) {
/* 配置优先级分组(2 位用于抢占优先级,2 位用于响应优先级) */
nvic_priority_group_set(NVIC_PRIGROUP_PRE2_SUB2);

UINIO_LED_GPIO_Config(); // 初始化 LED 对应的 GPIO 引脚资源
UINIO_USART_GPIO_Config(9600U); // 初始化 USART 串口,设置波特率为 9600

/* 频率(10800/108)兆赫兹 * 周期(10000)微秒 = 闪烁间隔为 1 秒 */
UINIO_Basic_Timer_Config(10800, 10000); // 初始化基本定时器,第 1 个参数为时钟预分频值,第 2 个参数为时钟周期

while (1) {};
}

通用定时器 TIMER1 与 PWM

脉冲宽度调制 PWM 简介

脉冲宽度调制PWM,Pulse-width modulation)是一种通过将电平信号分散为离散形式,从而达到调整电压频率,乃至于平均功率的目的。

这项技术可以用于动态控制 LED 亮度乃至于电机转速,其主要涉及到如下三个重要的参数:

  1. 频率(Frequency):单位时间内周期性事件的重复次数,即 PWM 在 1 秒钟之内,脉冲信号完整周期的出现次数,其值等于 \(频率 f = \frac{1}{周期 T}\),单位为赫兹
  2. 周期(Period):一个完整信号周期所持续的时间,其值等于 \(周期 T = \frac{1}{频率 f}\),单位为
  3. 占空比(Duty Cycle):在一个完整的脉冲信号周期当中,高电平所占据的百分比值。

定时器 TIMER1 的 PWM 通道

GD32F350RBT6 微控制器的 TIMER1 是一个 通用定时器,拥有四路 PWM 通道,其中的每一路通道都对应着 1 个 GPIO 引脚(需要进行复用设置)。通过下面的表格,可以发现 GPIOA5 引脚的复用功能 AF2,对应的就是 TIMER1 定时器的 CH0 通道:

注意:GPIO 的复用功能可以通过固件库函数 void gpio_af_set(uint32_t gpio_periph, uint32_t alt_func_num, uint32_t pin) 进行设置。

PWM 脉冲频率的计算

根据下面通用定时器 TIMER1 的结构框图,可以观察到该定时器各个通道时钟信号的来龙去脉。其中带有层叠效果的框图,表示其对应有影子寄存器

注意影子寄存器可以让指令重复使用相同的寄存器编码,但是在不同模式下,这些编码对应的是不同的物理寄存器。

相比于之前基本定时器的实验,本实验需要将定时器配置函数 UINIO_PWM_Config() 的时钟分频值修改为 108,从而使得分频后的定时器时钟频率等于:

\[ 分频后的时钟频率 PSC_{CLK} = \frac{定时器时钟频率 108MHz}{预分频值108} = 1MHz \]

再根据下面的公式,就可以计算得到此时 PWM 脉冲宽度调制信号的输出频率为 100Hz

\[ PWM 输出频率 = \frac{分频后的时钟频率 1MHz}{周期值 10000 微秒} = 100Hz \]

注意:该脉冲频率远高于肉眼可以鉴别出的 50Hz 临界闪烁频率,所以不会导致 LED 发生明显的闪烁现象,可以呈现出比较完美的呼吸灯效果。

实验电路的搭建

类似于之前 《基本定时器 TIMER5 与中断》 章节的实验电路,这里同样需要将 4.7K 限流电阻 R1 与 LED 发光二极管进行串联,有所不同之处在于这里需要将其连接至 UINIO-MCU-GD32F350RBT6 核心板的 GPIOA5 引脚,然后由通用定时器 TIMER1 的通道 0 输出 PWM 脉冲信号:

除此之外,依然需要把 UINIO-MCU-GD32F350RBT6 核心板的 GPIOA9GPIOA10 引脚,分别连接至 UINIO-USB-UART 串口调试器的 RXDTXD 引脚,从而能够使用串口上位机软件,查看到当前 LED 的亮灭状态调试信息。

完整 Keil µVision 工程代码

本实验通过 PWM 输出脉冲波来实现 LED 的呼吸灯效果,大致上需要经历如下一系列的配置过程:

  1. 调用 gpio_af_set() 配置 PWM 功能对应 GPIO 引脚的复用功能。
  2. 使用 timer_init() 配置 PWM 定时器参数。
  3. 使用 timer_channel_output_config() 配置 PWM 输出通道参数。
  4. 通过 timer_channel_output_pulse_value_config() 函数将定时器 TIMER1 通道输出的脉冲值置为 0
  5. 使用 timer_channel_output_mode_config() 配置定时器输出通道的比较模式为 PWM 模式 0。
  6. 使用 timer_channel_output_shadow_config() 失能定时器输出通道的比较影子寄存器
  7. 调用 timer_auto_reload_shadow_enable() 使能定时器自动重载影子寄存器
  8. 调用 timer_enable() 使能 PWM 相关的定时器。
  9. 循环调用 timer_channel_output_pulse_value_config() 函数,通过动态设定脉冲值(介于 0 ~ 65535 范围)实现 LED 的呼吸灯效果。

Drivers/PWM-LED.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*========== PWM_LED.h ==========*/
#ifndef UINIO_PWM_LED_H
#define UINIO_PWM_LED_H

#include "gd32f3x0.h"

#define UINIO_PWM_RCU RCU_GPIOA // 定义 PWM 对应 GPIOA 端口的外设时钟
#define UINIO_PWM_PORT GPIOA // 定义 PWM 对应的 GPIOA 端口
#define UINIO_PWM_PIN GPIO_PIN_5 // 定义 PWM 对应的 GPIO 引脚
#define UINIO_PWM_AF GPIO_AF_2 // 定义 PWM 对应 GPIO 引脚的复用功能 2

#define UINIO_PWM_TIMER_RCU RCU_TIMER1 // 定义通用定时器 TIMER1 的外设时钟
#define UINIO_PWM_TIMER TIMER1 // 定义通用定时器 TIMER1 自身
#define UINIO_PWM_CHANNEL TIMER_CH_0 // 定义定时器的通道 0

void UINIO_PWM_Config(uint16_t UINIO_Clock_Prescale, uint16_t UINIO_Clock_Period); // 预定义 PWM 工作参数配置函数
void UINIO_PWM_LED_Breathing(void); // 预定义 PWM 呼吸灯控制函数

#endif /* UINIO_PWM_LED_H */

Drivers/PWM-LED.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
/*========== PWM_LED.h ==========*/
#include "stdio.h"
#include "systick.h"
#include "../PWM-LED/PWM-LED.h"

/** 配置 PWM 功能对应的 GPIO 引脚 */
static void UINIO_PWM_GPIO_Config(void) {
rcu_periph_clock_enable(UINIO_PWM_RCU); // 使能 PWM 对应 GPIO 引脚的外设时钟
gpio_mode_set(UINIO_PWM_PORT, GPIO_MODE_AF, GPIO_PUPD_NONE, UINIO_PWM_PIN); // 配置 GPIO 引脚为悬空的复用功能模式
gpio_output_options_set(UINIO_PWM_PORT, GPIO_OTYPE_PP, GPIO_OSPEED_50MHZ, UINIO_PWM_PIN); // 设置 GPIO 引脚的输出模式(推挽输出)与速率(50MHz)
gpio_af_set(UINIO_PWM_PORT, UINIO_PWM_AF, UINIO_PWM_PIN); // 设置 GPIO 引脚的复用功能
}

/** 配置脉冲宽度调制 PWM 的工作参数,参数 UINIO_Clock_Prescale 为时钟预分频值,参数 UINIO_Clock_Period 为时钟周期 */
void UINIO_PWM_Config(uint16_t UINIO_Clock_Prescale, uint16_t UINIO_Clock_Period) {
UINIO_PWM_GPIO_Config(); // 调用前面已经定义的 PWM 对应 GPIO 引脚的配置函数

rcu_periph_clock_enable(UINIO_PWM_TIMER_RCU); // 使能定时器 TIMER1 外设时钟
/* CK_TIMERx = CK_AHB = 108MHz */
timer_deinit(UINIO_PWM_TIMER); // 复位定时器 TIMER1

/* 配置 PWM 定时器参数 TimerParameter */
timer_parameter_struct TimerParameter; // 定义 timer_parameter_struct 定时器参数结构体
TimerParameter.prescaler = UINIO_Clock_Prescale - 1; // 预分频值,由于该值从 0 开始计数,所以这里需要减去 1
TimerParameter.alignedmode = TIMER_COUNTER_EDGE; // 对齐模式,边缘对齐
TimerParameter.counterdirection = TIMER_COUNTER_UP; // 计数方向,向上计数
TimerParameter.period = UINIO_Clock_Period - 1; // 周期,同样由于该值从 0 开始计数,这里同样需要减去 1
TimerParameter.clockdivision = TIMER_CKDIV_DIV1; // 时钟分频因子
TimerParameter.repetitioncounter = 0; // 重复计数器值,取值范围为 0 ~ 255,配置为 x 就会重复 x+1 次进入中断
timer_init(UINIO_PWM_TIMER, &TimerParameter); // 初始化 PWM 相关的定时器

/* 配置 PWM 输出通道参数 TimerOutChannel */
timer_oc_parameter_struct TimerOutChannel; // 输出通道配置结构体 timer_oc_parameter_struct
TimerOutChannel.ocpolarity = TIMER_OC_POLARITY_HIGH; // 设置通道输出极性为高电平有效
TimerOutChannel.outputstate = TIMER_CCX_ENABLE; // 使能通道输出功能
timer_channel_output_config(UINIO_PWM_TIMER, UINIO_PWM_CHANNEL, &TimerOutChannel); // 开始配置定时器通道的输出功能

/* 配置占空比 */
timer_channel_output_pulse_value_config(UINIO_PWM_TIMER, UINIO_PWM_CHANNEL, 0); // 配置定时器输出通道的脉冲值
timer_channel_output_mode_config(UINIO_PWM_TIMER, UINIO_PWM_CHANNEL, TIMER_OC_MODE_PWM0); // 配置定时器输出通道的比较模式为 PWM 模式 0
timer_channel_output_shadow_config(UINIO_PWM_TIMER, UINIO_PWM_CHANNEL, TIMER_OC_SHADOW_DISABLE); // 失能定时器输出通道的比较影子寄存器

timer_auto_reload_shadow_enable(UINIO_PWM_TIMER); // 使能定时器自动重载影子寄存器
timer_enable(UINIO_PWM_TIMER); // 使能 PWM 相关的定时器
}

/** PWM 呼吸灯控制函数 */
void UINIO_PWM_LED_Breathing(void) {
static uint8_t Direct = 0; // 亮暗调节方向
static uint16_t Value = 0; // 脉冲值

/* 逐渐变亮 */
if (Direct == 0) {
Value += 500; // 该值越大 LED 越亮
printf("Get brighter...\n");

if (Value > 10000) {
Direct = 1; // 切换至渐暗模式
printf("Switch to dark...\n");
}
}
/* 逐渐变暗 */
else {
Value -= 500; // 该值越小 LED 越暗
printf("Get darker...\n");

if (Value <= 0) {
Direct = 0; // 切换至渐亮模式
printf("Switch to bright...\n");
}
}

timer_channel_output_pulse_value_config(UINIO_PWM_TIMER, UINIO_PWM_CHANNEL, Value); // 配置定时器通道输出的脉冲值
delay_1ms(50); // 系统滴答定时器延时 50 毫秒
}

Sources/main.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/*========== main.c ==========*/
#include "gd32f3x0.h"
#include "main.h"
#include <stdio.h>

#include "../Drivers/LED/LED.h"
#include "../Drivers/USART/USART.h"
#include "../Drivers/PWM-LED/PWM-LED.h"

int main(void) {
systick_config();

nvic_priority_group_set(NVIC_PRIGROUP_PRE2_SUB2); // 优先级分组

UINIO_USART_GPIO_Config(9600U); // 初始化 USART 串口,设置波特率为 9600

/* 频率(10800/108)兆赫兹 * 周期(10000)微秒 = 亮度调整间隔为 0.01 秒 */
UINIO_PWM_Config(108, 10000); // PWM 初始化

while (1) {
UINIO_PWM_LED_Breathing(); // 调用 PWM 呼吸灯控制函数
}
}

直接存储器存取 DMA 与中断

DMA 功能简介

直接存储器存取(DMA,Direct Memory Access)主要运用在不占用内核计算资源的情况下,进行数据的传递(外设 → 存储器存储器 → 外设存储器 → 存储器)。GD32F350RBT6 只拥有一个 DMA 控制器,其拥有 7 个通道,每个通道都用于处理各个外设的存储器访问请求,这些外设包括有 ADCSPII2CUSARTDACI2S 以及定时器

观察上面的 DMA 功能结构框图,可以发现 DMA 控制器主要由如下四个部分组成:

  1. 通过 AHB 总线从接口进行 DMA 配置。
  2. 通过 AHB 总线主接口进行数据传输。
  3. 仲裁器(Arbiter)对 DMA 请求的优先级进行管理。
  4. 控制存储器或者外设的状态,并且管理计数器。

实验电路的搭建

本节内容的实验,需要通过 USART 输出 DMA 传输过来的数据信息,所以依然要把 UINIO-MCU-GD32F350RBT6 核心板与另外一款 UINIO 系列开源硬件 UINIO-USB-UART 串口调试器 ,参照下图的线路进行相互连接:

也就是把 UINIO-MCU-GD32F350RBT6 核心板的 GPIOA9GPIOA10 引脚,分别连接至 UINIO-USB-UART 串口调试器的 RXDTXD 引脚,然后将后者的 Type-C 接口通过 USB 线缆连接到计算机,进而可以借助 COMTransmit 等串口调试助手软件,查看到 DMA 传输过来的各种数据和日志信息。

完整 Keil µVision 工程代码

当使用 DMA 进行数据传输时,会首先从源地址读取数据,然后再将读取的数据存储到目的地址,使用时通常需要遵循如下步骤:

  1. 通过 rcu_periph_clock_enable(RCU_DMA) 使能 DMA 外设时钟。
  2. 配置 DMA 参数结构体 dma_parameter_struct
  3. 初始化 DMA 通道 dma_init()
  4. 调用 dma_circulation_enable/disable()dma_memory_to_memory_enable/disable() 配置 DMA 相关模式。
  5. 执行 dma_interrupt_enable() 使能 DMA 中断。
  6. 执行 dma_channel_enable() 使能 DMA 通道本身。

Sources/gd32f3x0_it.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*========== gd32f3x0_it.c ==========*/
/* interrupt service routines */
#include "gd32f3x0_it.h"
#include "main.h"
#include "systick.h"

extern FlagStatus UINIO_Transfer_Complete;

void DMA_Channel1_2_IRQHandler(void) {
/* 判断 DMA 通道传输是否已经完成,参数 DMA_INT_FLAG_FTF 是传输完成中断标志位 */
if(dma_interrupt_flag_get(DMA_CH1, DMA_INT_FLAG_FTF)) {
dma_interrupt_flag_clear(DMA_CH1, DMA_INT_FLAG_G); // 清除 DMA 通道全局中断标志位状态
UINIO_Transfer_Complete = SET;
}
}

Sources/main.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/*========== main.c ==========*/
#include "gd32f3x0.h"
#include "../Drivers/USART/USART.h"

#define GetArrayNumber(arr_nanme) (uint32_t)(sizeof(arr_nanme) / sizeof(*(arr_nanme)))

uint8_t UINIO_String[] = "UinIO.com : Copy current string from RAM to USART by DMA.\n"; // 需要通过 DMA 传输给 USART 的字符串
__IO FlagStatus UINIO_Transfer_Complete = RESET; // 固件库中预定义的枚举类型变量,取值为 SET 或者 RESET

int main(void) {
UINIO_USART_GPIO_Config(9600U); // 调用 USART 串口配置函数

rcu_periph_clock_enable(RCU_DMA); // 使能 DMA 相关的外部时钟
nvic_irq_enable(DMA_Channel1_2_IRQn, 0, 0); // 配置 DMA 中断服务程序

/* 初始化 DMA 通道 */
dma_deinit(DMA_CH1);
dma_parameter_struct DMA_Init_Struct;
DMA_Init_Struct.direction = DMA_MEMORY_TO_PERIPHERAL; // 设置 DMA 通道数据传输方向为【读取存储器写入外设】
DMA_Init_Struct.memory_addr = (uint32_t)UINIO_String; // 设置存储器基地址为字符串首地址
DMA_Init_Struct.memory_inc = DMA_MEMORY_INCREASE_ENABLE; // 配置外设地址生成算法模式为递增
DMA_Init_Struct.memory_width = DMA_MEMORY_WIDTH_8BIT; // 存储器数据传输宽度为 8 位(串口每次传送 1 个字节 8 位)
DMA_Init_Struct.number = GetArrayNumber(UINIO_String); // 设置 DMA 通道数据传输量
DMA_Init_Struct.periph_addr = (uint32_t)(&USART_TDATA(USART0)); // 设置外设基地址
DMA_Init_Struct.periph_inc = DMA_PERIPH_INCREASE_DISABLE; // 设置外设地址生成算法为固定地址模式
DMA_Init_Struct.periph_width = DMA_PERIPHERAL_WIDTH_8BIT; // 外设数据传输宽度为 8 位
DMA_Init_Struct.priority = DMA_PRIORITY_ULTRA_HIGH; // 配置 DMA 传输通道优先级为最高
dma_init(DMA_CH1, &DMA_Init_Struct); // 开始初始化 DMA 通道 1

/* 配置 DMA 模式 */
dma_circulation_disable(DMA_CH1); // 禁用 DMA 循环模式
dma_memory_to_memory_disable(DMA_CH1); // 禁用存储器到存储器的 DMA 传输

usart_dma_transmit_config(USART0, USART_DENT_ENABLE); // 使能串口 USART0 的 DMA 发送功能
dma_interrupt_enable(DMA_CH1, DMA_INT_FTF); // 使能 DMA1 通道传输完成中断
dma_channel_enable(DMA_CH1); // 使能 DMA1 通道本身

/* 等待传输完成 */
while(RESET == UINIO_Transfer_Complete);
}

ADC 模数转换器外设

ADC 外设简介

GD32F350RBT6 微控制器集成有 12 位逐次逼近型模数转换器ADC,Analog Digital Converter),可以采集来自 16外部通道(即 MCU 引脚)、2内部通道,以及电池电压 VBAT 通道的模拟信号。采样转换完成之后,转换结果可以按照最低/最高有效位的对齐方式,保存在相应的数据寄存器当中。

注意逐次逼近型 ADC 通过产生一系列比较电压,逐次与输入的模拟电压信号进行比较,以一次一次逐步接近的方式,将模似信号转换成最接近的数字信号。

ADC 内部输入信号 功能说明 ADC 输入引脚定义 功能说明
\(V_{SENSE}\) 内部温度传感器输出电压。 \(VDDA\) 模拟电源正等于 \(V_{DD}\)\(2.6V \le VDDA \le 3.6V\)
\(V_{REFINT}\) 内部参考输出电压。 \(VSSA\) 模拟电源负等于 \(V_{SS}\),通过磁珠单点接入 GND
\(V_{BAT} / 2\) 硬件输入电压除以二。 \(ADCx_IN [15:0]\) 多达 16 路外部通道。

注意UINIO-MCU-GD32F350RBT6 核心板的模拟电源负引脚 VSSA,使用了对于 100Mhz 高频杂散信号存在 1KΩ 阻抗的磁珠进行单点接地;

ADC 采样通道与模式

GD32F350RBT6 微控制器上的这总共 19 条 ADC 采样通道,都支持如下几种运行模式:

  • 单次转换模式:每进行 1 次 ADC 转换后,ADC 就会自动停止,并将结果保存在 ADC 数据寄存器当中。
  • 扫描模式:用于对多个输入通道进行依次采集,ADC 会根据配置的通道采集顺序,对多个通道依次进行采样转换。
  • 连续转换模式:当 ADC 完成 1 次转换之后,就会启动另外 1 次转换,周而复始,直至外部触发或者软件触发停止这个转换过程。
  • 间断模式:用于在注入通道(即在规则通道转换时,需要强行插入的通道)和常规通道之间进行切换,ADC 会优先转换注入通道,完成之后再自动切换到常规通道进行转换。

ADC 采样的触发方式主要有外部触发软件触发两种:

  • 外部触发:在外部输入信号的上升沿 或者 下降沿,都可以触发规则组或者注入组的 ADC 转换。
  • 软件触发:由软件控制在固定的时间点进行 ADC 转换,通常用于采集精度要求较高的场景。

ADC 性能参数

使用 ADC 模数转换器外设的时候,需要特别注意下面三个主要性能参数:

  1. 分辨率:表示 ADC 转换器的输出精度,单位为 bit 位,分辨率越高,采样精度也就越高,但是 ADC 所花费的采样转换时间就会越长。
  2. 采样率:表示 ADC 每秒对于模拟信号进行采样的次数,单位为赫兹 Hz 或者 样本数量Sample / 秒S。采样率高就表示 ADC 能够更快的将模拟信号转换为数字信号,从而更加准确的反映模拟信号的变化。
  3. 采样范围:是指 ADC 可以采集到的模拟电压输入信号范围,通常位于参考电压 \(V_{REF}\) 范围之内,即 \(0V \le ADC \le V_{REF}\)

实验电路的搭建

本节的实验会将 UINIO-MCU-GD32F350RBT6 核心板的 GPIOC1 作为 ADC 采样引脚,分别去获取 TP1(连接至 3V3)和 TP2(连接至 GND)两个测试点的电压数据,同时仍然将核心板与另外一款 UINIO 系列开源硬件 UINIO-USB-UART 串口调试器 ,参照下面的示意图相互进行连接:

UINIO-MCU-GD32F350RBT6 核心板的 GPIOA9GPIOA10 引脚,分别连接至 UINIO-USB-UART 串口调试器的 RXDTXD 引脚,然后将后者的 Type-C 接口通过 USB 线缆连接到计算机,从而借助串口调试助手软件 COMTransmit 查看 ADC 采集到的数据信息。

完整 Keil µVision 工程代码

本实验通过将 UINIO-MCU-GD32F350RBT6 核心板的 GPIOC1 作为 ADC 采样引脚,分别去获取核心板上 3V3GND 引脚的电压数据,并且通过 USART 串口将这些数据打印出来。实现这个功能,需要遵循如下一系列的步骤去配置 ADC 外设:

  1. 使用 rcu_periph_clock_enable() 使能 GPIO 和 ADC 外设时钟。
  2. 通过 rcu_adc_clock_config() 配置 ADC 时钟。
  3. 通过 gpio_mode_set() 配置 GPIO 引脚为模拟输入模式。
  4. 配置 ADC 的特殊功能 adc_special_function_config()数据对齐方式 adc_data_alignment_config()分辨率 adc_resolution_config()通道长度 adc_channel_length_config()
  5. 配置 ADC 通道的触发源 adc_external_trigger_source_config()
  6. 使能 ADC 的触发方式是软件触发还是外部触发 adc_external/software_trigger_config()
  7. 调用 adc_enable() 使能 ADC 外设以及 adc_calibration_enable() 使能校准功能
  8. 配置 ADC 规则通道组或者插入通道组 adc_regular/inserted_channel_config()
  9. 使能 ADC 外部触发或者软件触发功能 adc_external/software_trigger_enable()
  10. 持续判断通道组转换结束标志位 ADC_FLAG_EOC,然后再通过 adc_regular_data_read() 读取范围为 0 ~ 4095 的 ADC 采样数据。

在上面的配置过程当中,adc_data_alignment_config() 函数所配置的数据对齐方式是指:

  • 右对齐模式:ADC 采集到的数据被右对齐到最低位,不足的位数填充 0,该模式可以在不损失精度的前提下,获得更好的动态范围。
  • 左对齐模式:ADC 采集到的数据被左对齐到最高位,不足的位数填充 0,虽然该模式可以提高采样的分辨率,但是会降低动态范围。

Drivers/ADC.h

1
2
3
4
5
6
7
8
9
10
/*========== ADC.h ==========*/
#ifndef UINIO_ADC_H
#define UINIO_ADC_H

#include "gd32f3x0.h"

void UINIO_ADC_Config(void); // ADC 外设配置函数
unsigned int UINIO_ADC_Value(uint8_t ADC_CHANNEL_x); // 获取指定 ADC 通道的采集值

#endif /* UINIO_ADC_H */

Drivers/ADC.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/*========== ADC.c ==========*/
#include "stdio.h"
#include "ADC.h"

/** ADC 外设配置函数 */
void UINIO_ADC_Config(void) {
rcu_periph_clock_enable(RCU_GPIOC); // 使能 GPIOC 外设时钟
rcu_periph_clock_enable(RCU_ADC); // 使能 ADC 外设时钟
rcu_adc_clock_config(RCU_ADCCK_APB2_DIV6); // 选择 APB 总线频率的 6 分频作为 ADC 的时钟源

gpio_mode_set(GPIOC, GPIO_MODE_ANALOG, GPIO_PUPD_NONE, GPIO_PIN_1); // 配置 GPIOC1 为不带上下拉电阻的模拟输入模式

adc_special_function_config(ADC_SCAN_MODE, ENABLE); // 使能 ADC 特殊功能的扫描模式
adc_data_alignment_config(ADC_DATAALIGN_RIGHT); // 配置 ADC 数据对齐方式为右对齐
adc_resolution_config(ADC_RESOLUTION_12B); // 配置 ADC 分辨率为 12 位
adc_channel_length_config(ADC_REGULAR_CHANNEL, 1); // 配置规则通道组的通道长度为 1

adc_external_trigger_source_config(ADC_REGULAR_CHANNEL, ADC_EXTTRIG_REGULAR_NONE); // 配置规则通道组的外部触发源为软件触发(规则组)
adc_external_trigger_config(ADC_REGULAR_CHANNEL, ENABLE); // 使能 ADC 外部触发

adc_enable(); // 使能 ADC 外设
adc_calibration_enable(); // 使能 ADC 校准
}

/** 获取 ADC 的值,参数 ADC_CHANNEL_x 用于指定采集通道 */
unsigned int UINIO_ADC_Value(uint8_t ADC_CHANNEL_x) {
adc_regular_channel_config(0U, ADC_CHANNEL_x, ADC_SAMPLETIME_55POINT5); // 配置 ADC 规则通道组,选择通道 0,并且指定采样时间为 1.5 个周期
adc_software_trigger_enable(ADC_REGULAR_CHANNEL); // 使能 ADC 软件触发功能

/* 根据 ADC 状态标志位,等待 ADC 采样完成 */
while ( adc_flag_get(ADC_FLAG_EOC) == RESET ) {}

unsigned int ADC_Value = adc_regular_data_read(); // 读 ADC 规则组的采样数据
return ADC_Value; // 返回 ADC 采样数据
}

Sources/main.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*========== main.c ==========*/
#include "gd32f3x0.h"
#include "stdio.h"
#include "systick.h"

#include "../Drivers/ADC/ADC.h"
#include "../Drivers/USART/USART.h"

int main(void) {
systick_config(); // 初始化系统滴答定时器
UINIO_USART_GPIO_Config(9600U); // 配置 USART0,并将波特率设置为 9600
UINIO_ADC_Config(); // 配置 ADC 外设

uint16_t Voltage = 0; // ADC 采集到的原始值(0 ~ 4095)

while(1) {
Voltage = UINIO_ADC_Value(ADC_CHANNEL_11);

/* 通过串口打印出实际电压值 */
printf("ADC_Value = %f\n",(( Voltage / 4095.0 ) * 3.3) ); // 接入 3.3V 采集的 ADC 值为 4095,接入 GND 采集的 ADC 值为 0

delay_1ms(1000); // 延时 1 秒
}
}

I2C 集成电路总线

SPI 串行外设总线

兆易创新 UINIO-MCU-GD32F350 固件库开发指南

http://www.uinio.com/Project/UINIO-MCU-GD32/

作者

Hank

发布于

2024-03-18

更新于

2024-03-30

许可协议