0%

一、实验设计目标

(1)通过编程轮流点亮8个LED灯,形成流水灯效果。

(2)通过此实验熟练掌握在Anlogic TD中新建工程的方法。

二、实验设计思路

第一篇中的1.3.2节已经介绍到,LED灯为高电平驱动点亮,即FPGA的对应IO输出“1”为点亮,输出“0”为熄灭,所以流水灯的实质就是“1”的流动,在寄存器内存储“0000_0001”这样一组数据,其中LSB(最低有效位)为“0”,随着每一次流水灯工作时钟的到来,整组数据循环右移一次,这样便实现了数据“1”从左向右的流水效果,通过I/O口将寄存器的值输送到LED显示出来,这样便实现了从左向右的流水灯效果。

三、功能模块图与输入输出引脚说明

流水灯工程包含顶层模块run_led与底层模块led8_module,图1.1是使用Tang生成的顶层原理图,图1.2是整个工程的模块功能图。下面介绍一下各主要引脚的功能:

img

1.1 流水灯顶层文件原理图

img

1.2 流水灯模块功能图

​ (1)CLK: 50MHz的系统基准时钟输入。将其每计数满5,000,000次,即每隔0.1秒,流水灯效果右移一次。

(2)RSTn:系统复位输入信号,低电平有效。复位后系统回到初始状态,内部计数器归零,LED7~LED1熄灭,LED0点亮。

(3)LED_Out:输出到LED灯,共有八位总线。当系统处于工作状态时,LED_Out[7:0]的值每隔0.1秒循环右移一位。

四、程序设计

​ 图1.3是截取自底层模块led8_module的部分代码:

10-12:参数及寄存器型信号声明。

​ 15-19:复位状态描述语句,复位后计数器Count归零,rLED_Out为“00000000”。

20-22,28-29:这是一个计数器,当系统基准时钟CLK出现一个上升沿时,Count计数加1,当Count计数到4999999时,它将在CLK的下一个上升沿处置零。目的是产生一个10Hz的控制时钟,控制流水灯效果的变换时间。

​ 25-26:这是本次实验的重要程序,通过循环右移语句控制“1”在rLED_Out的8位数据间从左至右移动。

img

1.3 流水灯实验核心代码

五、FPGA管脚配置

​ 以下是Anlogic_FPGA开发板的IO Constraint,CLK时钟输入信号与Anlogic_FPGA开发板上的50MHz的晶振时钟相连;LED_Out[7:0]输出信号分别与开发板上的LED7~LED0相连;RSTn复位输入信号与开发板上的SW0相连,当SW0拨至DOWN时,系统复位。

set_pin_assignment { CLK } { LOCATION = R7; IOSTANDARD = LVCMOS33; }

set_pin_assignment { LED_Out[0] } { LOCATION = B14; IOSTANDARD = LVCMOS33; }

set_pin_assignment { LED_Out[1] } { LOCATION = B15; IOSTANDARD = LVCMOS33; }

set_pin_assignment { LED_Out[2] } { LOCATION = B16; IOSTANDARD = LVCMOS33; }

set_pin_assignment { LED_Out[3] } { LOCATION = C15; IOSTANDARD = LVCMOS33; }

set_pin_assignment { LED_Out[4] } { LOCATION = C16; IOSTANDARD = LVCMOS33; }

set_pin_assignment { LED_Out[5] } { LOCATION = E13; IOSTANDARD = LVCMOS33; }

set_pin_assignment { LED_Out[6] } { LOCATION = E16; IOSTANDARD = LVCMOS33; }

set_pin_assignment { LED_Out[7] } { LOCATION = F16; IOSTANDARD = LVCMOS33; }

set_pin_assignment { RSTn } { LOCATION = A9; IOSTANDARD = LVCMOS33; }

六、实验结果

复位信号RSTn接拨动开关SW0,当SW0拨至“DOWN”位置时,系统复位,开发板上开关SW0的指示灯熄灭,LED0点亮,LED7LED1熄灭;当SW0拨至“UP”位置时,开发板上开关SW0的指示灯亮,LED7LED0呈流水灯效果从左至右依次点亮,具体动态现象请自行观察。

七、思考与拓展

(1)图1.3中23-24行代码的含义是什么?这样设计有什么好处?

(2)请通过调整程序中的参数来调整流水灯变换的速度,实现1秒钟变换一个灯的效果。

**
**

一.实验目标

\1. 通过本实验熟悉STM32的FSMC接口;

2.熟悉TFTLCD显示屏使用。

3.掌握在2.4寸显示屏上显示操作。

二.知识储备及设计思路

TFTLCD简介

TFT-LCD即薄膜晶体管液晶显示器。其英文全称为:Thin Film Transistor-Liquid Crystal Display。TFT-LCD与无源TN-LCD、STN-LCD的简单矩阵不同,它在液晶显示屏的每一个象素上都设置有一个薄膜晶体管(TFT),使显示液晶屏的静态特性与扫描线数无关,因此大大提高了图像质量。TFT-LCD也被叫做真彩液晶显示器。

本章,我们给大家介绍TFTLCD模块,该模块有如下特点:

1,2.4’寸大小的屏幕。

2,320×240的分辨率。

3,16位真彩显示。

TFTLCD模块模块支持65K色显示,显示分辨率为320×240,接口为16位的80并口,该模块的外观图如图所示:

img

管脚接口定义:

img

和单片机连接方式:

img

imgimg

​ 如上图所示,屏幕和开发板P1座自连接方式。

从图可以看出, TFTLCD模块采用16位的并方式与外部连接,之所以不采用8位的方式,是因为彩屏的数据量比较大,尤其在显示图片的时候,如果用8位数据线,就会比16位方式慢一倍以上,我们当然希望速度越快越好,所以我们选择16位的接口。该模块的80并口有如下一些信号线:

CS:TFTLCD片选信号。

WR:向TFTLCD写入数据。

RD:从TFTLCD读取数据。

D[15:0]:16位双向数据线。

RST:硬复位TFTLCD。

RS:命令/数据标志(0,读写命令;1,读写数据)

BLK: 背光控制。

TFTLCD模块的RST信号线是直接接到STM32F4的复位脚上,并不由软件控制,这样可以省下来一个IO口。另外我们还需要一个背光控制线来控制TFTLCD的背光。所以,我们总共需要的IO口数目为21个。这里还需要注意,我们标注的DB0DB15,是相对于LCD控制IC标注的,实际上大家可以把他们就等同于FMC_D0 FMC_D15。

ILI9341液晶控制器自带显存,其显存总大小为172800(24032018/8),即18位模式(26万色)下的显存量。在16位模式下,ILI9341采用RGB565格式存储颜色数据,此时ILI9341的18位数据线与MCU的16位数据线以及LCD GRAM的对应关系如图:

img

从图中可以看出,ILI9341在16位模式下面,数据线有用的是:D17D13和D11D1,D0和D12没有用到,,ILI9341的D0和D12压根就没有引出来,这样,ILI9341的D17D13和D11D1对应MCU的FMC_D0~ FMC_D15。这样MCU的16位数据,最低5位代表蓝色,中间6位为绿色,最高5位为红色。数值越大,表示该颜色越深。另外,特别注意ILI9341所有的指令都是8位的(高8位无效),且参数除了读写GRAM的时候是16位,其他操作参数,都是8位的。

接下来,我们介绍一下ILI9341的几个重要命令,因为ILI9341的命令很多,我们这里就不全部介绍了,有兴趣的大家可以找到ILI9341的datasheet看看。里面对这些命令有详细的介绍。我们将介绍:0XD3,0X36,0X2A,0X2B,0X2C,0X2E等6条指令。

首先来看指令:0XD3,这个是读ID4指令,用于读取LCD控制器的ID,该指令如表所示:

img

从上表可以看出,0XD3指令后面跟了4个参数,最后2个参数,读出来是0X93和0X41,刚好是我们控制器ILI9341的数字部分,从而,通过该指令,即可判别所用的LCD驱动器是什么型号,这样,我们的代码,就可以根据控制器的型号去执行对应驱动IC的初始化代码,从而兼容不同驱动IC的屏,使得一个代码支持多款LCD。

接下来看指令:0X36,这是存储访问控制指令,可以控制ILI9341存储器的读写方向,简单的说,就是在连续写GRAM的时候,可以控制GRAM指针的增长方向,从而控制显示方式(读GRAM也是一样)。该指令如表所示:

img

从上表可以看出,0X36指令后面,紧跟一个参数,这里我们主要关注:MY、MX、MV这三个位,通过这三个位的设置,我们可以控制整个ILI9341的全部扫描方向,如表所示:

img

img

这样,我们在利用ILI9341显示内容的时候,就有很大灵活性了,比如显示BMP图片,BMP解码数据,就是从图片的左下角开始,慢慢显示到右上角,如果设置LCD扫描方向为从左到右,从下到上,那么我们只需要设置一次坐标,然后就不停的往LCD填充颜色数据即可,这样可以大大提高显示速度。

接下来看指令:0X2A,这是列地址设置指令,在从左到右,从上到下的扫描方式(默认)下面,该指令用于设置横坐标(x坐标),该指令如表所示:

img

在默认扫描方式时,该指令用于设置x坐标,该指令带有4个参数,实际上是2个坐标值:SC和EC,即列地址的起始值和结束值,SC必须小于等于EC,且0≤SC/EC≤239。一般在设置x坐标的时候,我们只需要带2个参数即可,也就是设置SC即可,因为如果EC没有变化,我们只需要设置一次即可(在初始化ILI9341的时候设置),从而提高速度。

与0X2A指令类似,指令:0X2B,是页地址设置指令,在从左到右,从上到下的扫描方式(默认)下面,该指令用于设置纵坐标(y坐标)。该指令如表所示:

img

在默认扫描方式时,该指令用于设置y坐标,该指令带有4个参数,实际上是2个坐标值:SP和EP,即页地址的起始值和结束值,SP必须小于等于EP,且0≤SP/EP≤319。一般在设置y坐标的时候,我们只需要带2个参数即可,也就是设置SP即可,因为如果EP没有变化,我们只需要设置一次即可(在初始化ILI9341的时候设置),从而提高速度。

接下来看指令:0X2C,该指令是写GRAM指令,在发送该指令之后,我们便可以往LCD的GRAM里面写入颜色数据了,该指令支持连续写,指令描述如表所示:

img

img

从上表可知,在收到指令0X2C之后,数据有效位宽变为16位,我们可以连续写入LCD GRAM值,而GRAM的地址将根据MY/MX/MV设置的扫描方向进行自增。例如:假设设置的是从左到右,从上到下的扫描方式,那么设置好起始坐标(通过SC,SP设置)后,每写入一个颜色值,GRAM地址将会自动自增1(SC++),如果碰到EC,则回到SC,同时SP++,一直到坐标:EC,EP结束,其间无需再次设置的坐标,从而大大提高写入速度。

最后,来看看指令:0X2E,该指令是读GRAM指令,用于读取ILI9341的显存(GRAM),该指令在ILI9341的数据手册上面的描述是有误的,真实的输出情况如表所示:

img

该指令用于读取GRAM,如表所示,ILI9341在收到该指令后,第一次输出的是dummy数据,也就是无效的数据,第二次开始,读取到的才是有效的GRAM数据(从坐标:SC,SP开始),输出规律为:每个颜色分量占8个位,一次输出2个颜色分量。比如:第一次输出是R1G1,随后的规律为:B1R2àG2B2àR3G3àB3R4àG4B4àR5G5… 以此类推。如果我们只需要读取一个点的颜色值,那么只需要接收到参数3即可,如果要连续读取(利用GRAM地址自增,方法同上),那么就按照上述规律去接收颜色数据。

以上,就是操作ILI9341常用的几个指令,通过这几个指令,我们便可以很好的控制ILI9341显示我们所要显示的内容了。

一般TFTLCD模块的使用流程如图:

img

任何LCD,使用流程都可以简单的用以上流程图表示。其中硬复位和初始化序列,只需要执行一次即可。而画点流程就是:设置坐标à写GRAM 指令à写入颜色数据,然后在LCD 上面,我们就可以看到对应的点显示我们写入的颜色了。读点流程为:设置坐标à读GRAM 指令à读取颜色数据,这样就可以获取到对应点的颜色数据了。

以上只是最简单的操作,也是最常用的操作,有了这些操作,一般就可以正常使用TFTLCD 了。接下来我们将该模块用来来显示字符和数字,通过以上介绍,我们可以得出TFTLCD 显示需要的相关设置步骤如下:

1)设置STM32F4TFTLCD 模块相连接的IO

这一步,先将我们与TFTLCD 模块相连的IO 口进行初始化,以便驱动LCD。这里我们用到的是FSMC。

2)初始化TFTLCD 模块。

初始化序列,这里我们没有硬复位LCD,因为开发板的LCD 接口,将TFTLCD 的RST 同STM32F4 的RESET 连接在一起了,只要按下开发板的RESET 键,就会对LCD 进行硬复位。初始化序列,就是向LCD 控制器写入一系列的设置值,这些初始化序列一般LCD 供应商会提供给客户,我们直接使用这些序列即可,不需要深入研究。在初始化之后,LCD 才可以正常使用。

3)通过函数将字符和数字显示到TFTLCD 模块上。

这一步的流程,即:设置坐标à写GRAM 指令à写GRAM 来实现,但是这个步骤,只是一个点的处理,我们要显示字符/数字,就必须要多次使用这个步骤,从而达到显示字符/数字的目的,所以需要设计一个函数来实现数字/字符的显示,之后调用该函数,就可以实现数字/字符的显示了。

FSMC 简介

STM32F407 或STM32F417 系列芯片都带有FSMC 接口, STM32F4 开发板的主芯片为STM32F407IGT6,是带有FSMC 接口的。

FSMC,即灵活的静态存储控制器,能够与同步或异步存储器和16 位PC 存储器卡连接,STM32F4 的FSMC 接口支持包括SRAM、NAND FLASH、NOR FLASH 和PSRAM 等存储器。FSMC 的框图如图所示:

img

从上图我们可以看出,STM32F4的FSMC将外部设备分为2类:NOR/PSRAM设备、NAND/PC卡设备。他们共用地址数据总线等信号,他们具有不同的CS以区分不同的设备,比如本章我们用到的TFTLCD就是用的FSMC_NE4做片选,其实就是将TFTLCD当成SRAM来控制。

这里我们介绍下为什么可以把TFTLCD当成SRAM设备用:首先我们了解下外部SRAM的连接,外部SRAM的控制一般有:地址线(如A0A18)、数据线(如D0D15)、写信号(WE)、读信号(OE)、片选信号(CS),如果SRAM支持字节控制,那么还有UB/LB信号。而TFTLCD的信号我们前面有介绍,包括:RS、D0D15、WR、RD、CS、RST和BL等,其中真正在操作LCD的时候需要用到的就只有:RS、D0D15、WR、RD和CS。其操作时序和SRAM的控制完全类似,唯一不同就是TFTLCD有RS信号,但是没有地址信号。

TFTLCD通过RS信号来决定传送的数据是数据还是命令,本质上可以理解为一个地址信号,比如我们把RS接在A0上面,那么当FSMC控制器写地址0的时候,会使得A0变为0,对TFTLCD来说,就是写命令。而FSMC写地址1的时候,A0将会变为1,对TFTLCD来说,就是写数据了。这样,就把数据和命令区分开了,他们其实就是对应SRAM操作的两个连续地址。当然RS也可以接在其他地址线上,开发板STM32F4开发板是把RS连接在A18上面的。

STM32F4的FSMC支持8/16/32位数据宽度,我们这里用到的LCD是16位宽度的,所以在设置的时候,选择16位宽就OK了。我们再来看看FSMC的外部设备地址映像,STM32F4的FSMC将外部存储器划分为固定大小为256M字节的四个存储块,如图所示:

img

从上图可以看出,FSMC总共管理1GB空间,拥有4个存储块(Bank),本章,我们用到的是块1,所以在本章我们仅讨论块1的相关配置,其他块的配置,请参考《STM32F4xx中文参考手册》第32章(1191页)的相关介绍。

STM32F4的FSMC存储块1(Bank1)被分为4个区,每个区管理64M字节空间,每个区都有独立的寄存器对所连接的存储器进行配置。Bank1的256M字节空间由28根地址线(HADDR[27:0])寻址。

这里HADDR是内部AHB地址总线,其中HADDR[25:0]来自外部存储器地址FSMC_A[25:0],而HADDR[26:27]对4个区进行寻址。如表所示:

img

我们要特别注意HADDR[25:0]的对应关系:

当Bank1接的是16位宽度存储器的时候:HADDR[25:1]àFSMC_A[24:0]。

当Bank1接的是8位宽度存储器的时候:HADDR[25:0]àFSMC_A[25:0]。

不论外部接8位**/16位宽设备,FSMC_A[0]永远接在外部设备地址A[0]**。这里,TFTLCD使用的是16位数据宽度,所以HADDR[0]并没有用到,只有HADDR[25:1]是有效的,对应关系变为:HADDR[25:1]àFSMC_A[24:0],相当于右移了一位,这里请大家特别留意。另外,HADDR[27:26]的设置,是不需要我们干预的,比如:当你选择使用Bank1的第三个区,即使用FSMC_NE3来连接外部设备的时候,即对应了HADDR[27:26]=10,我们要做的就是配置对应第3区的寄存器组,来适应外部设备即可。STM32F4的FSMC各Bank配置寄存器如表:

img

对于NOR FLASH控制器,主要是通过FSMC_BCRx、FSMC_BTRx和FSMC_BWTRx寄存器设置(其中x=1~4,对应4个区)。通过这3个寄存器,可以设置FSMC访问外部存储器的时序参数,拓宽了可选用的外部存储器的速度范围。FSMC的NOR FLASH控制器支持同步和异步突发两种访问方式。选用同步突发访问方式时,FSMC将HCLK(系统时钟)分频后,发送给外部存储器作为同步时钟信号FSMC_CLK。此时需要的设置的时间参数有2个:

1,HCLK与FSMC_CLK的分频系数(CLKDIV),可以为2~16分频;

2,同步突发访问中获得第1个数据所需要的等待延迟(DATLAT)。

对于异步突发访问方式,FSMC主要设置3个时间参数:地址建立时间(ADDSET)、数据建立时间(DATAST)和地址保持时间(ADDHLD)。FSMC综合了SRAM/ROM、PSRAM和NOR Flash产品的信号特点,定义了4种不同的异步时序模型。选用不同的时序模型时,需要设置不同的时序参数,如表所列:

img

在实际扩展时,根据选用存储器的特征确定时序模型,从而确定各时间参数与存储器读/写周期参数指标之间的计算关系;利用该计算关系和存储芯片数据手册中给定的参数指标,可计算出FSMC所需要的各时间参数,从而对时间参数寄存器进行合理的配置。

本章,我们使用异步模式A(ModeA)方式来控制TFTLCD,模式A的读操作时序如图所示:

img

模式A读操作时序图

模式A支持独立的读写时序控制,这个对我们驱动TFTLCD来说非常有用,因为TFTLCD在读的时候,一般比较慢,而在写的时候可以比较快,如果读写用一样的时序,那么只能以读的时序为基准,从而导致写的速度变慢,或者在读数据的时候,重新配置FSMC的延时,在读操作完成的时候,再配置回写的时序,这样虽然也不会降低写的速度,但是频繁配置,比较麻烦。而如果有独立的读写时序控制,那么我们只要初始化的时候配置好,之后就不用再配置,既可以满足速度要求,又不需要频繁改配置。

模式A的写操作时序如图所示:

img

模式A写操作时序

​ 图中的ADDSET与DATAST,是通过不同的寄存器设置的,接下来我们讲解一下Bank1的几个控制寄存器。

SRAM/NOR闪存片选控制寄存器:FSMC_BCRx(x=1~4),该寄存器各位描述如图所示:

img

图FSMC_BCRx寄存器各位描述

该寄存器我们在本章用到的设置有:EXTMOD、WREN、MWID、MTYP和MBKEN这几个设置,我们将逐个介绍。

EXTMOD:扩展模式使能位,也就是是否允许读写不同的时序,很明显,我们本章需要读写不同的时序,故该位需要设置为1。

WREN:写使能位。我们需要向TFTLCD写数据,故该位必须设置为1。

MWID[1:0]:存储器数据总线宽度。00,表示8位数据模式;01表示16位数据模式;10和11保留。我们的TFTLCD是16位数据线,所以设置WMID[1:0]=01。

MTYP[1:0]:存储器类型。00表示SRAM、ROM;01表示PSRAM;10表示NOR FLASH;11保留。前面提到,我们把TFTLCD当成SRAM用,所以需要设置MTYP[1:0]=00。

MBKEN:存储块使能位。这个容易理解,我们需要用到该存储块控制TFTLCD,当然要使能这个存储块了。

​ SRAM/NOR闪存片选时序寄存器:FSMC_BTRx(x=1~4),该寄存器各位描述如图所示:

img

FSMC_BTRx寄存器各位描述

这个寄存器包含了每个存储器块的控制信息,可以用于SRAM、ROM和NOR闪存存储器。如果FSMC_BCRx寄存器中设置了EXTMOD位,则有两个时序寄存器分别对应读(本寄存器)和写操作(FSMC_BWTRx寄存器)。因为我们要求读写分开时序控制,所以EXTMOD是使能了的,也就是本寄存器是读操作时序寄存器,控制读操作的相关时序。本章我们要用到的设置有:ACCMOD、DATAST和ADDSET这三个设置。

ACCMOD[1:0]:访问模式。00表示访问模式A;01表示访问模式B;10表示访问模式C;11表示访问模式D,本章我们用到模式A,故设置为00。

DATAST[7:0]:数据保持时间。0为保留设置,其他设置则代表保持时间为: DATAST个HCLK时钟周期,最大为255个HCLK周期。对ILI9341来说,其实就是RD低电平持续时间,一般为355ns。而一个HCLK时钟周期为6ns左右(1/168Mhz),为了兼容其他屏,我们这里设置DATAST为60,也就是60个HCLK周期,时间大约是360ns。

ADDSET[3:0]:地址建立时间。其建立时间为:ADDSET个HCLK周期,最大为15个HCLK周期。对ILI9341来说,这里相当于RD高电平持续时间,为90ns,我们设置ADDSET为15,即15*6=90ns。

SRAM/NOR闪写时序寄存器:FSMC_BWTRx(x=1~4),该寄存器各位描述如图所示:

img

FSMC_BWTRx寄存器各位描述

该寄存器在本章用作写操作时序控制寄存器,需要用到的设置同样是:ACCMOD、DATAST和ADDSET这三个设置。这三个设置的方法同FSMC_BTRx一模一样,只是这里对应的是写操作的时序,ACCMOD设置同FSMC_BTRx一模一样,同样是选择模式A,另外DATAST和ADDSET则对应低电平和高电平持续时间,对ILI9341来说,这两个时间只需要15ns就够了,比读操作快得多。所以我们这里设置DATAST为2,即3个HCLK周期,时间约为18ns。然后ADDSET设置为3,即3个HCLK周期,时间为18ns。

至此,我们对STM32F4的FSMC介绍就差不多了,通过以上两个小节的了解,我们可以开始写LCD的驱动代码了。不过,这里还要给大家做下科普,在MDK的寄存器定义里面,并没有定义FSMC_BCRx、FSMC_BTRx、FSMC_BWTRx等这个单独的寄存器,而是将他们进行了一些组合。

FSMC_BCRx和FSMC_BTRx,组合成BTCR[8]寄存器组,他们的对应关系如下:

BTCR[0]对应FSMC_BCR1,BTCR[1]对应FSMC_BTR1

BTCR[2]对应FSMC_BCR2,BTCR[3]对应FSMC_BTR2

BTCR[4]对应FSMC_BCR3,BTCR[5]对应FSMC_BTR3

BTCR[6]对应FSMC_BCR4,BTCR[7]对应FSMC_BTR4

FSMC_BWTRx则组合成BWTR[7],他们的对应关系如下:

BWTR[0]对应FSMC_BWTR1,BWTR[2]对应FSMC_BWTR2,

BWTR[4]对应FSMC_BWTR3,BWTR[6]对应FSMC_BWTR4,

BWTR[1]、BWTR[3]和BWTR[5]保留,没有用到。

通过上面的讲解,通过对FSMC相关的寄存器的描述,大家对FSMC的原理有了一个初步的认识,如果还不熟悉的朋友,请一定要搜索网络资料理解FSMC的原理。只有理解了原理,使用库函数才可以得心应手。那么在库函数中是怎么实现FSMC的配置的呢?FSMC_BCRx,FSMC_BTRx寄存器在库函数是通过什么函数来配置的呢?下面我们来讲解一下FSMC相关的库函数:

FSMC初始化函数

根据前面的讲解,初始化FSMC主要是初始化三个寄存器FSMC_BCRx,FSMC_BTRx,FSMC_BWTRx,在HAL库中提供了FSMC初始化函数为

1
HAL_SRAM_Init();  

下面我们看看函数定义:

1
HAL_StatusTypeDef HAL_SRAM_Init(SRAM_HandleTypeDef *hsram, FMC_NORSRAM_TimingTypeDef  *Timing, FMC_NORSRAM_TimingTypeDef *ExtTiming);  

​ 这个函数有三个入口参数,SRAM_HandleTypeDef类型指针变量、FMC_NORSRAM_TimingTypeDef类型指针变量、FMC_NORSRAM_TimingTypeDef类型指针变量。

FMC_NORSRAM_TimingTypeDef指针类型的成员变量。前面我们讲到,FSMC有读时序和写时序之分,所以这里就是用来设置读时序和写时序的参数了,也就是说,这两个参数是用来配置寄存器FSMC_BTRx和FSMC_BWTRx,后面我们会讲解到。下面我们主要来看看模式A下的相关配置参数:

参数NSBank用来设置使用到的存储块标号和区号,前面讲过,我们是使用的存储块1区号1,所以选择值为FSMC_NORSRAM_BANK1。

参数MemoryType用来设置存储器类型,我们这里是SRAM,所以选择值为

FSMC_MEMORY_TYPE_SRAM。

参数MemoryDataWidth用来设置数据宽度,可选8位还是16位,这里我们是16位数据宽度,所以选择值为FSMC_NORSRAM_MEM_BUS_WIDTH_16。

参数WriteOperation用来设置写使能,毫无疑问,我们前面讲解过我们要向TFT写数据,所以要写使能,这里我们选择FSMC_WRITE_OPERATION_ENABLE。

参数ExtendedMode是设置扩展模式使能位,也就是是否允许读写不同的时序,这里我们采取的读写不同时序,所以设置值为FSMC_EXTENDED_MODE_ENABLE。

上面的这些参数是与模式A相关的,下面我们也来稍微了解一下其他几个参数的意义吧:

参数DataAddressMux用来设置地址/数据复用使能,若设置为使能,那么地址的低16位和数据将共用数据总线,仅对NOR和PSRAM有效,所以我们设置为默认值不复用,值

FSMC_DATA_ADDRESS_MUX_DISABLE。

其他参数在成组模式同步模式才需要设置,大家可以参考中文参考手册了解相关参数的意思。

接下来我们看看设置读写时序参数的两个变量FSMC_ReadWriteTim和FSMC_WriteTim,他们都是FSMC_NORSRAM_TimingTypeDef结构体指针类型,这两个参数在初始化的时候分别用来初始化片选控制寄存器FSMC_BTRx和写操作时序控制寄存器FSMC_BWTRx。下面我们看看FSMC_NORSRAMTimingInitTypeDef类型的定义:

1
typedef  struct   {   uint32_t AddressSetupTime;  uint32_t  AddressHoldTime;   uint32_t  DataSetupTime;   uint32_t  BusTurnAroundDuration;   uint32_t  CLKDivision;   uint32_t  DataLatency;   uint32_t  AccessMode;   }FSMC_NORSRAM_TimingTypeDef;  

这个结构体有7个参数用来设置FSMC读写时序。其实这些参数的意思我们前面在讲解FSMC的时序的时候有提到,主要是设计地址建立保持时间,数据建立时间等等配置,对于我们的实验中,读写时序不一样,读写速度要求不一样,所以对于参数FSMC_DataSetupTime设置了不同的值,大家可以对照理解一下。记住,这些参数的意义在前面讲解FSMC_BTRx和FSMC_BWTRx寄存器的时候都有提到,大家可以翻过去看看。

三.引脚说明与硬件连接

我们显示屏十连接在P1插座下方,P1是连接STM32内部FSMC总线。打开开发板的原理图,如图1所示,在使用过程中将TFTLCD屏幕向下对齐插在P1排母座上。

img

图1 TFTLCD显示屏接口

表1 开发板上TFTLCD输出脚位置

设备名 引脚号 设备名 引脚号
3V3 3V3 PF13 PF13
GND GND FMC_D15 PD10
FMC_D14 PD9 FMC_D13 PD8
FMC_D12 PE15 FMC_D11 PE14
FMC_D10 PE13 FMC_D9 PE12
FMC_D8 PE11 FMC_D7 PE10
FMC_D6 PE9 FMC_D5 PE8
FMC_D4 PE7 FMC_D3 PD1
FMC_D2 PD0 FMC_D1 PD15
FMC_D0 PD14 RESET RESET
FMC_NOE PD4 FMC_NWE PD5
FMC_A18 PD13 FMC_NE1 PD7

图中圈出来的部分就是连接TFTLCD模块的接口,液晶模块直接插上去即可。

在硬件上,TFTLCD模块与探索者STM32F4开发板的IO口对应关系如下:

LCD_BL(背光控制)对应PF13;

LCD_CS对应PD7即FMC_NE1;

LCD _RS对应PD13即FMC_A18;

LCD _WR对应PD5即FMC_NWE;

LCD _RD对应PD4即FMC_NOE;

LCD _D[15:0]则直接连接在FMC_D15~FMC_D0;

这些线的连接,探索者STM32F4开发板的内部已经连接好了,我们只需要将TFTLCD模块插上去就好了。实物连接如图18.2.2所示:

​ TFTLCD屏幕和P1座子连接如图所示:

img

四.程序设计

本实验,我们新建了lcd.c , lcd.h , font.h ,文件。文件用来存放lcd相关的驱动函数及显示代码。

同时,FSMC相关的库函数分布在stm32f4xx_hal_fsmc.c文件和头文件stm32f4xx_hal_fsmc.h中。配置FSMC接口的文件在fsmc.c中。

本实验,我们用到FSMC驱动LCD,通过前面的介绍,我们知道TFTLCD的RS接在FSMC的A18上面,CS接在FSMC_NE1上,并且是16位数据总线。即我们使用的是FSMC存储器1的第4区,我们定义如下LCD操作结构体(在lcd.h里面定义):

1
//LCD操作结构体  typedef struct   {   vu16 LCD_REG;   vu16 LCD_RAM;   } LCD_TypeDef;   //使用NOR/SRAM的Bank1.sector4,地址位HADDR[27,26]=11 A18作为数据命令区分线  //注意16位数据总线时,STM32内部地址会右移一位对齐!   #define LCD_BASE ((u32)(0x60000000 | 0x0007FFFE))   #define LCD ((LCD_TypeDef *) LCD_BASE)  

其中LCD_BASE,必须根据我们外部电路的连接来确定,我们使用Bank1.sector1就是从地址0X60000000开始,而0x0007FFFE,则是A18的偏移量,这里很多朋友不理解这个偏移量的概念,简单说明下:以A18为例,7FFFE转换成二进制就是:1111111111111111110,而16位数据时,地址右移一位对齐,那么实际对应到地址引脚的时候,就是:A18:A0=0111111111111111111,此时A18是0,但是如果16位地址再加1(注意:对应到8位地址是加2,即7FFFE +0X02),那么:A18:A0=1000000000000000000,此时A18就是1了,即实现了对RS的0和1的控制。

我们将这个地址强制转换为LCD_TypeDef结构体地址,那么可以得到LCD->LCD_REG的地址就是0X6007FFFE,对应A18的状态为0(即RS=0),而LCD-> LCD_RAM的地址就是0X6080,0000(结构体地址自增),对应A18的状态为1(即RS=1)。

所以,有了这个定义,当我们要往LCD写命令/数据的时候,可以这样写:

1
LCD->LCD_REG=CMD;  //写命令  LCD->LCD_RAM=DATA; //写数据  

而读的时候反过来操作就可以了,如下所示:

1
CMD=  LCD->LCD_REG;//读LCD寄存器  DATA = LCD->LCD_RAM;//读LCD  

这其中,CS、WR、RD和IO口方向都是由FSMC控制,不需要我们手动设置了。接下来,我们先介绍一下lcd.h里面的另一个重要结构体:

1
//LCD重要参数集  typedef struct   {   u16 width; //LCD 宽度  u16 height; //LCD 高度  u16 id; //LCD ID   u8 dir; //横屏还是竖屏控制:0,竖屏;1,横屏。  u16 wramcmd; //开始写gram指令  u16 setxcmd; //设置x坐标指令  u16 setycmd; //设置y坐标指令  }_lcd_dev;   //LCD参数  extern _lcd_dev lcddev; //管理LCD重要参数 

该结构体用于保存一些LCD重要参数信息,比如LCD的长宽、LCD ID(驱动IC型号)、LCD横竖屏状态等,这个结构体虽然占用了十几个字节的内存,但是却可以让我们的驱动函数支持不同尺寸的LCD,同时可以实现LCD横竖屏切换等重要功能,所以还是利大于弊的。有了以上了解,下面我们开始介绍lcd.c里面的一些重要函数。

先看7个简单,但是很重要的函数:

1
//写寄存器函数  //regval:寄存器值  void LCD_WR_REG(vu16 regval)   { regval=regval; //使用-O2优化的时候,必须插入的延时  LCD->LCD_REG=regval;//写入要写的寄存器序号  }   //写LCD数据  //data:要写入的值  void LCD_WR_DATA(vu16 data)   { data=data; //使用-O2优化的时候,必须插入的延时  LCD->LCD_RAM=data;   }  //读LCD数据  //返回值:读到的值  u16 LCD_RD_DATA(void)   { vu16 ram; //防止被优化  ram=LCD->LCD_RAM;   return ram;   }   //写寄存器  //LCD_Reg:寄存器地址  //LCD_RegValue:要写入的数据  void LCD_WriteReg(vu16 LCD_Reg, vu16 LCD_RegValue)   { LCD->LCD_REG = LCD_Reg; //写入要写的寄存器序号  LCD->LCD_RAM = LCD_RegValue; //写入数据  }   //读寄存器  //LCD_Reg:寄存器地址  //返回值:读到的数据  u16 LCD_ReadReg(vu16 LCD_Reg)   { LCD_WR_REG(LCD_Reg); //写入要读的寄存器序号  delay_us(5);   return LCD_RD_DATA(); //返回读到的值  }   //开始写GRAM   void LCD_WriteRAM_Prepare(void)   { LCD->LCD_REG=lcddev.wramcmd;   }   //LCD写GRAM    //RGB_Code:颜色值  void LCD_WriteRAM(u16 RGB_Code)   { LCD->LCD_RAM = RGB_Code;//写十六位GRAM   }  

该函数实现将LCD的当前操作点设置到指定坐标(x,y)。因为9341/5310/6804/5510等的设置同其他屏有些不太一样,所以进行了区别对待。

接下来我们介绍第八个函数:画点函数。该函数实现代码如下:

1
//画点  //x,y:坐标  //POINT_COLOR:此点的颜色  void LCD_DrawPoint(u16 x,u16 y)   {   LCD_SetCursor(x,y); //设置光标位置  LCD_WriteRAM_Prepare(); //开始写入GRAM   LCD->LCD_RAM=POINT_COLOR;  }  

该函数实现比较简单,就是先设置坐标,然后往坐标写颜色。其中POINT_COLOR是我们定义的一个全局变量,用于存放画笔颜色,顺带介绍一下另外一个全局变量:BACK_COLOR,该变量代表LCD的背景色。LCD_DrawPoint函数虽然简单,但是至关重要,其他几乎所有上层函数,都是通过调用这个函数实现的。

有了画点,当然还需要有读点的函数,第九个介绍的函数就是读点函数,用于读取LCD的GRAM,这里我们读取TFTLCD模块数据的函数为LCD_ReadPoint,该函数直接返回读到的GRAM值。该函数使用之前要先设置读取的GRAM地址,通过LCD_SetCursor函数来实现。LCD_ReadPoint的代码如下:

1
//读取个某点的颜色值  //x,y:坐标  //返回值:此点的颜色  u16 LCD_ReadPoint(u16 x,u16 y)   {   vu16 r=0,g=0,b=0;   if(x>=lcddev.width||y>=lcddev.height)return 0; //超过了范围,直接返回  LCD_SetCursor(x,y);   if(lcddev.id==0X9341||lcddev.id==0X6804||lcddev.id==0X5310)LCD_WR_REG(0X2E);    //9341/6804/3510 发送读GRAM指令  else if(lcddev.id==0X5510)LCD_WR_REG(0X2E00); //5510 发送读GRAM指令  else LCD_WR_REG(R34); //其他IC发送读GRAM指令  if(lcddev.id==0X9320)opt_delay(2); //FOR 9320,延时2us   LCD_RD_DATA(); //dummy Read   opt_delay(2);   r=LCD_RD_DATA(); //实际坐标颜色  if(lcddev.id==0X9341||lcddev.id==0X5310||lcddev.id==0X5510)    { //9341/NT35310/NT35510要分2次读出  opt_delay(2);   b=LCD_RD_DATA();   g=r&0XFF;//9341/5310/5510等,第一次读取的是RG的值,R在前,G在后,各占8位  g<<=8;   }   if(lcddev.id==0X9325||lcddev.id==0X4535||lcddev.id==0X4531||lcddev.id==0XB505||    lcddev.id==0XC505)return r; //这几种IC直接返回颜色值  else  if(lcddev.id==0X9341||lcddev.id==0X5310||lcddev.id==0X5510)return  (((r>>11)<<11)   |((g>>10)<<5)|(b>>11));  //ILI9341/NT35310/NT35510需要公式转换一下  else return LCD_BGR2RGB(r); //其他IC   }  

在LCD_ReadPoint函数中,因为我们的代码不止支持一种LCD驱动器,所以,我们根据不同的LCD驱动器((lcddev.id)型号,执行不同的操作,以实现对各个驱动器兼容,提高函数的通用性。

第十个要介绍的是字符显示函数LCD_ShowChar,该函数同前面OLED模块的字符显示函数差不多,但是这里的字符显示函数多了1个功能,就是可以以叠加方式显示,或者以非叠加方式显示。叠加方式显示多用于在显示的图片上再显示字符。非叠加方式一般用于普通的显示。该函数实现代码如下:

1
//在指定位置显示一个字符  //x,y:起始坐标  //num:要显示的字符:" "--->"~"   //size:字体大小12/16/24   //mode:叠加方式(1)还是非叠加方式(0)   void  LCD_ShowChar(u16 x,u16 y,u8 num,u8 size,u8 mode)  {                                    u8 temp,t1,t;       u16 y0=y;       u8  csize=(size/8+((size%8)?1:0))*(size/2);               num=num-'  ';       for(t=0;t<csize;t++)       {               if(size==12)temp=asc2_1206[num][t];                   else  if(size==16)temp=asc2_1608[num][t];               else  if(size==24)temp=asc2_2412[num][t];               else  if(size==32)temp=asc2_3216[num][t];               else return;                                                for(t1=0;t1<8;t1++)            {                                  if(temp&0x80)LCD_Fast_DrawPoint(x,y,POINT_COLOR);                else  if(mode==0)LCD_Fast_DrawPoint(x,y,BACK_COLOR);                temp<<=1;                y++;                if(y>=lcddev.height)return;                        if((y-y0)==size)                {                     y=y0;                     x++;                     if(x>=lcddev.width)return;                         break;                }            }           }                      }  

在LCD_ShowChar函数里面,我们采用快速画点函数LCD_Fast_DrawPoint来画点显示字符,该函数同LCD_DrawPoint一样,只是带了颜色参数,且减少了函数调用的时间,详见本例程源码。该代码中我们用到了三个字符集点阵数据数组asc2_2412、asc2_1206和asc2_1608,

最后,我们再介绍一下FSMC初始化函数MX_FSMC_Init ,该函数先初始化STM32与TFTLCD连接的IO口,并配置FSMC控制器,然后其简化代码如下:

1
void  MX_FSMC_Init(void)  {   FSMC_NORSRAM_TimingTypeDef Timing = {0};   FSMC_NORSRAM_TimingTypeDef ExtTiming = {0};      /** Perform the SRAM1 memory initialization  sequence   */   hsram1.Instance = FSMC_NORSRAM_DEVICE;   hsram1.Extended = FSMC_NORSRAM_EXTENDED_DEVICE;   /* hsram1.Init */   hsram1.Init.NSBank = FSMC_NORSRAM_BANK1;   hsram1.Init.DataAddressMux =  FSMC_DATA_ADDRESS_MUX_DISABLE;   hsram1.Init.MemoryType =  FSMC_MEMORY_TYPE_SRAM;   hsram1.Init.MemoryDataWidth =  FSMC_NORSRAM_MEM_BUS_WIDTH_16;   hsram1.Init.BurstAccessMode =  FSMC_BURST_ACCESS_MODE_DISABLE;   hsram1.Init.WaitSignalPolarity =  FSMC_WAIT_SIGNAL_POLARITY_LOW;   hsram1.Init.WrapMode =  FSMC_WRAP_MODE_DISABLE;   hsram1.Init.WaitSignalActive =  FSMC_WAIT_TIMING_BEFORE_WS;   hsram1.Init.WriteOperation =  FSMC_WRITE_OPERATION_ENABLE;   hsram1.Init.WaitSignal =  FSMC_WAIT_SIGNAL_DISABLE;   hsram1.Init.ExtendedMode =  FSMC_EXTENDED_MODE_ENABLE;   hsram1.Init.AsynchronousWait =  FSMC_ASYNCHRONOUS_WAIT_DISABLE;   hsram1.Init.WriteBurst =  FSMC_WRITE_BURST_DISABLE;   hsram1.Init.PageSize = FSMC_PAGE_SIZE_NONE;   /* Timing */   Timing.AddressSetupTime = 15;   Timing.AddressHoldTime = 15;   Timing.DataSetupTime = 255;   Timing.BusTurnAroundDuration = 15;   Timing.CLKDivision = 16;   Timing.DataLatency = 17;   Timing.AccessMode = FSMC_ACCESS_MODE_A;   /* ExtTiming */   ExtTiming.AddressSetupTime = 15;   ExtTiming.AddressHoldTime = 15;   ExtTiming.DataSetupTime = 255;   ExtTiming.BusTurnAroundDuration = 15;   ExtTiming.CLKDivision = 16;   ExtTiming.DataLatency = 17;   ExtTiming.AccessMode = FSMC_ACCESS_MODE_A;      if (HAL_SRAM_Init(&hsram1, &Timing,  &ExtTiming) != HAL_OK)   {    Error_Handler( );   }     }  

TFTLCD模块的初始化函数LCD_Init,读取LCD控制器的型号,根据控制IC的型号执行不同的初始化代码,

1
//尝试9341 ID的读取  LCD_WR_REG(0XD3);   lcddev.id=LCD_RD_DATA(); //dummy read   lcddev.id=LCD_RD_DATA(); //读到0X00   lcddev.id=LCD_RD_DATA(); //读取93   lcddev.id<<=8;   lcddev.id|=LCD_RD_DATA(); //读取41   if(lcddev.id!=0X9341) //非9341,尝试看看是不是NT35310   {   LCD_WR_REG(0XD4);   lcddev.id=LCD_RD_DATA();//dummy read   lcddev.id=LCD_RD_DATA();//读回0X01   lcddev.id=LCD_RD_DATA();//读回0X53   lcddev.id<<=8;   lcddev.id|=LCD_RD_DATA(); //这里读回0X10   if(lcddev.id!=0X5310) //也不是NT35310,尝试看看是不是NT35510   {   LCD_WR_REG(0XDA00);   lcddev.id=LCD_RD_DATA(); //读回0X00   LCD_WR_REG(0XDB00);   lcddev.id=LCD_RD_DATA(); //读回0X80   lcddev.id<<=8;  LCD_WR_REG(0XDC00);    lcddev.id|=LCD_RD_DATA();  //读回0X00   if(lcddev.id==0x8000)lcddev.id=0x5510;   //NT35510读回的ID是8000H,为方便区分,我们强制设置为5510   if(lcddev.id!=0X5510) //也不是NT5510,尝试看看是不是SSD1963   {   LCD_WR_REG(0XA1);   lcddev.id=LCD_RD_DATA();   lcddev.id=LCD_RD_DATA(); //读回0X57   lcddev.id<<=8;   lcddev.id|=LCD_RD_DATA(); //读回0X61   if(lcddev.id==0X5761)lcddev.id=0X1963;   //SSD1963读回的ID是5761H,为方便区分,我们强制设置为1963   }   }   }   printf(" LCD ID:%x\r\n",lcddev.id); //打印LCD ID   if(lcddev.id==0X9341) //9341初始化  {   ……//9341初始化寄存器序列  }   else if(lcddev.id==0xXXXX) //其他LCD初始化代码  {   ……//其他LCD驱动IC,初始化代码  }   LCD_Display_Dir(0); //默认为竖屏显示  LCD_LED=1; //点亮背光  LCD_Clear(WHITE);   }  

先对FSMC相关IO进行初始化,然后是FSMC的初始化,这个我们在前面都有介绍,最后根据读到的LCD ID,对不同的驱动器执行不同的初始化代码,从上面的代码可以看出,这个初始化函数可以针对十多款不同的驱动IC执行初始化操作,这样大大提高了整个程序的通用性。大家在以后的学习中应该多使用这样的方式,以提高程序的通用性、兼容性。

我们最后来看main函数,

1
int  main(void)  {      uint16_t len=0;          HAL_Init();      SystemClock_Config();   MX_GPIO_Init();   MX_I2C2_Init();   MX_USART3_UART_Init();   MX_FSMC_Init();   MX_NVIC_Init();         delay_init(168);      delay_ms(100);      CH455_init();      LCD_Init();            CH455_Display(1,1);      CH455_Display(2,2);      CH455_Display(3,3);      CH455_Display(4,4);            __HAL_UART_ENABLE_IT(&huart3,  UART_IT_RXNE);         HAL_UART_Transmit(&huart3,uartSendTitle,sizeof(uartSendTitle),1000);         while(__HAL_UART_GET_FLAG(&huart3,UART_FLAG_TC)!=SET);             while  (1)   {         POINT_COLOR=RED;         LCD_ShowString(10,0,240,32,32,"WWW.EMOOC.CC");                LCD_ShowString(10,40,240,32,32,"  STM32F4/H7");              LCD_ShowString(10,80,240,24,24,"TFTLCD  TEST");         LCD_ShowString(10,110,240,16,16,"2.4TFT@emooc");                            LCD_ShowString(10,150,240,12,12,"2021/6/30");                            }  }  

五.实验结果

将TFTLCD屏幕插在P1接口上,下载程序后,观察界面波形:

img

六.STM32CubeMX配置关键硬件截图

配置FSMC总线的cube图如图,所用到的外设如图所示:

img

一.实验目标

1.通过编程STM32实现使用内部DAC输出电压;

2.熟悉内部DAC使用。

二.知识储备及设计思路

DAC指数模转换器,即能将数字信号转为模拟信号的器件。DAC的实现有不同结构,如权电阻网络、R-2R型等结构。STM32F407的DAC模块(数字/模拟转换模块)是12位数字输入,电压输出型的DAC。DAC可以配置为8位或12位模式,也可以与DMA控制器配合使用。DAC工作在12位模式时,数据可以设置成左对齐或右对齐。DAC模块有2个输出通道,每个通道都有单独的转换器。在双DAC模式下,2个通道可以独立地进行转换,也可以同时进行转换并同步地更新2个通道的输出。DAC可以通过引脚输入参考电压Vref+(通ADC共用)以获得更精确的转换结果。

STM32F407的DAC模块主要特点有:

① 2个DAC转换器:每个转换器对应1个输出通道

② 8位或者12位单调输出

③ 12位模式下数据左对齐或者右对齐

④ 同步更新功能

⑤ 噪声波形生成

⑥ 三角波形生成

⑦ 双DAC通道同时或者分别转换

⑧ 每个通道都有DMA功能

本开发板中DAC有如下特性:

数据手册中还给出了框图说明,可以在程序设计的过程中进行参考:

三.引脚说明与硬件连接

打开开发板的原理图,可以看到DAC的引脚是PA4和PA5,分别是通道1和通道2的输出,如图1所示,在使用过程中将这两个管脚接在万用表或者示波器上即可观察DAC两个通道的输出。

图1 单片机内部DAC的输出引脚

图2 开发板上DAC输出脚位置

如图2所示,在开发板上,这两个引脚接在了J2座子上,可以采用杜邦线进行连接。

在实际使用中,我们选择了输出1通道,在实际调试时可以将1通道接在示波器,共地之后就能在示波器上看到DAC的输出波形。

DAC常用的寄存器如下(以DAC1为例):

控制寄存器(CR):

控制寄存器具体介绍如下:

其他寄存器可以在数据手册中查阅,这里仅介绍比较重要的控制寄存器。该寄存器可以配置DAC的使能情况,并配置输出缓冲器,此外还包括DMA、中断等功能的配置,可以通过更改寄存器的值来改变模式。

在HAL库开发模式下,可以用库函数代替寄存器的配置过程。所使用的结构体为:

所使用的主要结构体为DAC_HandleTypeDef,其中主要成员的含义为:

Instance:寄存器基地址

State:DAC通讯状态

……

大部分的定义都是通过对instance成员赋值进行的,具体可参考工程代码。

四.程序整体框图和设计

整体程序采用模块化的设计思路,对关键模块的代码封装在一组.c和.h文件中,便于查阅和移植。本程序在例程键盘显示的基础上进行开发,可以通过调用LCD函数进行人机交互,并控制单片机实现不同的DAC输出。图3为本次程序设计的整体框图。

图3 程序设计框图

下面对DAC配置函数进行简单介绍。

1.DAC初始化配置

1
2
3
4
| void MX_DAC_Init(void) {  DAC_ChannelConfTypeDef sConfig = {0};  //GPIO_InitTypeDef GPIO_InitStruct = {0};  \__HAL_RCC_GPIOA_CLK\_ENABLE();   /\* USER CODE END DAC_Init 1 \*/  /\*\* DAC Initialization   \*/  hdac.Instance = DAC;  if (HAL_DAC_Init(&hdac) != HAL_OK)  {  Error\_Handler();  }  /\*\* DAC channel OUT1 config   \*/  sConfig.DAC_Trigger = DAC_TRIGGER_NONE;  sConfig.DAC_OutputBuffer = DAC_OUTPUTBUFFER_ENABLE;  if (HAL_DAC\_ConfigChannel(&hdac, &sConfig, DAC_CHANNEL_1) != HAL_OK)  {  Error\_Handler();  }  /\* USER CODE BEGIN DAC_Init 2 \*/  HAL_DAC_Start(&hdac,DAC_CHANNEL_1);//开启DAC通道1  /\* USER CODE END DAC_Init 2 \*/  } |
| ------------------------------------------------------------ |


DAC的初始化是用cubeMX完成的,其中定义了DAC配置所需的结构体,对结构体赋值并打开相应GPIO的时钟(DAC时钟的开启要在文件stm32f4xx_hal_msp.c文件中进行配置,并在stm32f4xx_hal_conf.c文件中打开相应的标识符),按手册配置其触发类型和输出缓冲等。这一部分是cubeMX自动生成的,在移植过程中需要注意配置项目不能缺少。

2.电压的输出与显示

1
2
3
4
| void DAC\_Direct(double v){  /\*  计算公式:输出电压v = (VREF+) \* DOR / 4095  VREF+是电压参考基准,一般为3.3V  若输出0.7V直流电压,按公式计算为  v = 4095\*0.7/3.3 = 869  DOR = v \* 4095 / 3.3  实测VREF约为3.2566  \*/    hdac.Instance-\>DHR12R1=(uint32_t)(v \* 40950 / 32.566);   }  double Show_Current_V(void){  return HAL_DAC_GetValue(&hdac,DAC_CHANNEL_1)\*32.566/40950;//输出显示当前的输出电压 } |
| ------------------------------------------------------------ |


根据直流电压的输出公式,可以得到输出电压为V时寄存器的值。此处采用1通道的12位右对齐寄存器,对该寄存器进行赋值即可得到需要的输出电压。在此处为了便于书写,使用了转换函数,直接传入电压值即可自动转为寄存器所需的值。

电压显示部分采用了库函数,可以得到当前DOR寄存器的值(码数),再进行类似的转换即可得到当前电压值,用LCD打印即可实时获知。

其余部分代码见工程源文件。

五.实验结果

1.直流电压步进输出

如图4,显示了当按下key18时,输出电压的变化:

图4 直流电压步进输出

程序设计为按键一次输出电压增加0.5V,直到输出3V时清零,重新输出。用户也可以对程序重新定义,改变不同的步进值。

2.波形输出

如图5所示,按下按键KEY17通过设置输出的形式,可以实现锯齿波和正弦波的输出,波形的幅度、形状等可以由用户自由配置。

图5 锯齿波和正弦波波形

至此,本实验的内容基本上完成了。

六.STM32CubeMX配置关键硬件截图

本实验配置比较简单,选择好器件后,在Analog选项中选择DAC,选择需要的通道和中断即可,如图6所示。

图6 CubeMX配置页面

一.实验目标

  1. 通过编程STM32实现使用内部ADC采集电压;

  2. 熟悉内部ADC使用。

二.知识储备及设计思路

ADC是模数转换器的简称,是将模拟信号转化为数字信号的重要器件。其具体流程可简单概括为:取样、保持、量化、编码。ADC的实现结构有许多种,如逐次逼近型,流水线型等。单片机内ADC大多数是逐次逼近型。STM32F407IGT6
有3 个ADC,每个ADC 有12 位、10 位、8 位和6 位可选,每个ADC 有16
个外部通道。另外还有两个内部ADC 源和VBAT 通道挂在ADC1 上。ADC
具有独立模式、双重模式和三重模式,对于不同AD
转换要求几乎都有合适的模式可选。ADC功能非常强大,具体的我们在功能框图中分析每个部分的功能。

1. 电压输入范围

ADC 输入范围为:VREF- ≤ VIN ≤ VREF+。由VREF-、VREF+ 、VDDA 、VSSA、这四个外部

引脚决定。

我们在设计原理图的时候一般把VSSA 和VREF-接地,把VREF+和VDDA 接3V3,得到ADC
的输入电压范围为:0~3.3V。

如果我们想让输入的电压范围变宽,去到可以测试负电压或者更高的正电压,我们可以在外部加一个电压调理电路,把需要转换的电压抬升或者降压到0~3.3V,这样ADC
就可以测量了。

2. 输入通道

我们确定好ADC 输入电压之后,那么电压怎么输入到ADC?这里我们引入通道的概念,STM32
的ADC 多达19 个通道,其中外部的16
个通道就是框图中的ADCx_IN0、ADCx_IN1…ADCx_IN5。这16 个通道对应着不同的IO
口,具体是哪一个IO 口可以从手册查询到。其中ADC1/2/3 还有内部通道: ADC1
的通道ADC1_IN16 连接到内部的VSS,通道ADC1_IN17 连接到了内部参考电压VREFINT
连接,通道ADC1_IN18 连接到了芯片内部的温度传感器或者备用电源VBAT。ADC2 和ADC3
的通道16、17、18 全部连接到了内部的VSS。

开发板上的ADC具有如下特性:

数据手册中还提供了ADC的电路框图,有助于进行程序设计:

三.引脚说明与硬件连接

根据开发板原理图(如图1),ADC的输入端口为PC2,故只需将外部直流电压接在PC2处,即可对外部直流源进行采样。这里是ADC1、ADC2、ADC3公用的引脚,开发板上J2提供了引出接口,使用杜邦线即可与外界信号进行连接。

图1 ADC硬件连接

ADC配置过程中较为重要的寄存器如下:

状态寄存器:

状态寄存器描述了ADC转换过程中的多种状态,在使用过程中读取这些状态即可获知ADC处于何种工作模式下,通过库函数也能获得这些状态。

控制寄存器:

控制寄存器负责对ADC的工作模式进行配置,如进行使能、进行DMA或中断配置等,具体内容可以参考手册相关部分。上述寄存器在使用过程中较为常用,其他寄存器内容可以在参考手册中查阅。

在实际HAL库开发时,对寄存器的操作可以通过结构体的赋值来完成。配置需要的结构体如下:

其中比较重要的是instance成员,他代表了寄存器基地址,对该成员的操作即可以操作控制寄存器,从而对工作模式进行配置。具体可以参考工程代码。

四.程序整体框图和设计

本次程序框图比较简单,只用到了ADC模块。整体思路是:对ADC进行初始化,对结构体进行配置,调用库函数读取ADC码值,经过适当转换得到直流电压值。

部分程序源码如下:

1
ADC_HandleTypeDef hadc1; void MX_ADC1_Init(void)//CubeMX配置的初始化函数 {   ADC_ChannelConfTypeDef sConfig = {0};   hadc1.Instance = ADC1;  hadc1.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV2;  hadc1.Init.Resolution = ADC_RESOLUTION_12B;  hadc1.Init.ScanConvMode = DISABLE;  hadc1.Init.ContinuousConvMode = DISABLE;  hadc1.Init.DiscontinuousConvMode = DISABLE;  hadc1.Init.ExternalTrigConvEdge = ADC_EXTERNALTRIGCONVEDGE_NONE;  hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START;  hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT;  hadc1.Init.NbrOfConversion = 1;  hadc1.Init.DMAContinuousRequests = DISABLE;  hadc1.Init.EOCSelection = ADC_EOC_SINGLE_CONV;   if (HAL_ADC_Init(&hadc1) != HAL_OK)  {  Error_Handler();  }  sConfig.Channel = ADC_CHANNEL_12;//配置为通道12  sConfig.Rank = 1;//第一个序列  sConfig.SamplingTime = ADC_SAMPLETIME_3CYCLES;//采样时间
1
2
3
4
| if (HAL_ADC_ConfigChannel(&hadc1, \&sConfig) != HAL_OK)  {  Error_Handler();  } } |
| ------------------------------------------------------------ |


这一部分代码是MX生成的初始化代码,对ADC的工作模式进行了配置,具体过程见第六部分。同时定义了转换时的速率和使用的通道。此时ADC已经配置完毕,可以进行数据的采集了。

1
2
3
4
| void HAL_ADC_MspInit(ADC_HandleTypeDef\* hadc) {//打开GPIOC时钟和ADC时钟  GPIO_InitTypeDef GPIO_Initure;  \__HAL_RCC_ADC1_CLK_ENABLE(); //在stm32f4xx_hal_msp.c中配置也可以  \__HAL_RCC_GPIOC_CLK_ENABLE();     GPIO_Initure.Pin=GPIO_PIN_2;   GPIO_Initure.Mode=GPIO_MODE_ANALOG;  GPIO_Initure.Pull=GPIO_NOPULL;  HAL_GPIO_Init(GPIOC,&GPIO_Initure);//配置PC2为模拟模式 } |
| ------------------------------------------------------------ |


这一部分对时钟和端口进行了初始化,主要是端口工作模式和GPIO以及ADC的时钟。此外,注意在stm32f4xx_hal_conf.c文件里面打开ADC的声明。

1
2
3
4
| uint16_t Get_Ave_Value(uint16_t times){//输出times采样的平均值  int i;  uint16_t temp;  for(i=0;i\<times;i++){  HAL_ADC_Start(&hadc1);//每次都要先开始才能取值  temp += HAL_ADC_GetValue(&hadc1);  delay_ms(5);  }  return temp/times;//平均 } |
| ------------------------------------------------------------ |


这一部分开始处理采集的数据,由于噪声等影响,我们可以多次采样取平均值,得到一个相对稳定的码值。类似于DAC,我们可以通过码值转换出此时的直流电压值。

ADC的主要代码介绍完毕,具体可以参见工程文件。

五.实验结果

实验过程中,可以实时地获得PC2管脚此时的输入电压值,并将码率和电压显示在LCD上,如图2所示。

图2 实验结果

六.STM32CubeMX配置关键硬件截图

图3 CubeMX配置截图

CubeMX的配置比较简单,只需打开ADC的相应通道,即可生成代码,如图3。

“大拇指”安路FPGA极简开发板是专门针对数字电路课程开发的,核心板不追求大而全,但已经覆盖数电课程的基础需求、简单易用并拥有较强的扩展性,结合学校一线教学经验和国产化趋势,我们将开发板的核心芯片型号选定为安路科技的EG4S20BG256,它资源丰富且管脚多;在外围功能模块的设计上,则以简洁直观、小巧易携带为目标,主要包括以下几类模块:板载USB-JTAG电路,实现一根线供电和调试;输出显示类,如LED灯、数码管;输入操作类的,如矩阵按键、拨动开关;发声及音频类的,如蜂鸣器;对外通信接口类的,如UART转USB接口;存储器类的,如FLASH存储器、SDRAM存储器;模数混合类的,如ADC和DAC。

Read more »

一.实验目标

1.编程使用STM32F407的串口来发送和接收数据;

2.STM32F407通过串口和上位机的对话,STM32F429在收到上位机发过来的字符串后,原原本本的返回.给上位机;

3.通过本实验掌握STM32的串口收发数据。

二.知识储备及设计思路

STM32F407串口简介

串口作为MCU的重要外部接口,同时也是软件开发重要的调试手段,其重要性不言而喻。现在基本上所有的MCU都会带有串口,STM32自然也不例外。

STM32F407的串口资源相当丰富的,功能也相当强劲。STM32F407开发板所使用的STM32F407IGT6芯片最多可提供6路串口,有分数波特率发生器、支持同步单线通信和半双工单线通讯、支持LIN、支持调制解调器操作、智能卡协议和IrDA
SIR ENDEC规范、具有DMA等。

接下来我们先从寄存器层面,告诉你如何设置串口,以达到我们最基本的通信功能。本章,我们将实现开机后利用串口3打印信息到电脑上,同时接收从串口发过来的数据,把发送过来的数据直接送回给电脑。开发板板载了1个USB串口,该串口和下载器usb口共用。本章介绍通过USB串口和电脑通信。

串口最基本的设置,就是波特率的设置。STM32F407的串口使用起来还是蛮简单的,只要你开启了串口时钟,并设置相应IO口的模式,然后配置一下波特率,数据位长度,奇偶校验位等信息,就可以使用了。下面,我们就简单介绍下这几个与串口基本配置直接相关的寄存器。

USART寄存器介绍

1,串口时钟使能。串口作为STM32F407的一个外设,其时钟由外设时钟使能寄存器控制,这里我们使用的串口3是在APB1ENR寄存器的第18位。APB1ENR寄存器功能如下面所示。只是说明一点,就是除了串口1和串口6的时钟使能在APB2ENR寄存器,其他串口的时钟使能位都在APB1ENR寄存器。

2,串口波特率设置。每个串口都有一个自己独立的波特率寄存器USART_BRR,通过设置该寄存器就可以达到配置不同波特率的目的。具体实现方法,请参考5.3.2节。

波特率计算公式:

位 15:4 DIV_Mantissa[11:0]:USARTDIV 的尾数,这 12 个位用于定义 USART 除数
(USARTDIV) 的尾数。

位 3:0 DIV_Fraction[3:0]:USARTDIV 的小数,这 4 个位用于定义 USART 除数
(USARTDIV) 的小数。当 OVER8 = 1 时,不考虑
DIV_Fraction3位,且必须将该位保持清零。

计算USARTDIV示例:

3,串口控制。STM32F407的每个串口都有3个控制寄存器USART_CR1~3,串口的很多配置都是通过这3个寄存器来设置的。这里我们只要用到USART_CR1就可以实现我们的功能了,该寄存器的各位描述如图

该寄存器的高16位没有用到,低16位用于串口的功能设置。OVER8为过采样模式设置位,我们一般设置位0,即16倍过采样已获得更好的容错性;UE为串口使能位,通过该位置1,以使能串口;M为字长选择位,当该位为0的时候设置串口为8个字长外加n个停止位,停止位的个数(n)是根据USART_CR2的[13:12]位设置来决定的,默认为0;PCE为校验使能位,设置为0,则禁止校验,否则使能校验;PS为校验位选择位,设置为0则为偶校验,否则为奇校验;TXIE为发送缓冲区空中断使能位,设置该位为1,当USART_SR中的TXE位为1时,将产生串口中断;TCIE为发送完成中断使能位,设置该位为1,当USART_SR中的TC位为1时,将产生串口中断;RXNEIE为接收缓冲区非空中断使能,设置该位为1,当USART_SR中的ORE或者RXNE位为1时,将产生串口中断;TE为发送使能位,设置为1,将开启串口的发送功能;RE为接收使能位,用法同TE。

其他位的设置,这里就不一一列出来了,大家可以参考《STM32F4xx中文参考手册》第714页有详细介绍,在这里我们就不列出来了。

4,数据发送与接收。STM32F407的发送与接收是通过数据寄存器USART_DR来实现的,这是一个双寄存器,包含了TDR和RDR。当向DR寄存器写数据的时候,实际是写入TDR,串口就会自动发送数据;当收到数据,读DR寄存器的时候,实际读取的是RDR。TDR和RDR对外是不可见的,所以我们操作的就只有DR寄存器,该寄存器的各位描述如图

可以看出,虽然是一个32位寄存器,但是只用了低9位(DR[8:0]),其他都是保留。

DR[8:0]为串口数据,包含了发送或接收的数据。由于它是由两个寄存器(TDR和RDR)组成的,一个给发送用(TDR),一个给接收用(RDR),该寄存器兼具读和写的功能。TDR寄存器提供了内部总线和输出移位寄存器之间的并行接口。RDR寄存器提供了输入移位寄存器和内部总线之间的并行接口。

当使能校验位(USART_CR1中PCE位被置位)进行发送时,写到MSB的值(根据数据的长度不同,MSB是第7位或者第8位)会被后来的校验位取代。

当使能校验位进行接收时,读到的MSB位是接收到的校验位。

5,串口状态。串口的状态可以通过状态寄存器USART_SR读取。USART_SR的位描述。

这里我们关注一下两个位,第5、6位RXNE和TC。

RXNE(读数据寄存器非空),当该位被置1的时候,就是提示已经有数据被接收到了,并且可以读出来了。这时候我们要做的就是尽快去读取USART_DR,通过读USART_DR可以将该位清零,也可以向该位写0,直接清除。

TC(发送完成),当该位被置位的时候,表示USART_DR内的数据已经被发送完成了。如果设置了这个位的中断,则会产生中断。该位也有两种清零方式:1)读USART_SR,写USART_DR。2)直接向该位写0。

HAL库函数和cube生成函数介绍

通过以上一些寄存器的操作外加一下IO口的配置,我们就可以达到串口最基本的配置了,关于串口更详细的介绍,请参考《STM32F4xx中文参考手册》第676页至720页,通用同步异步收发器这一章节。接下来我们将着重讲解使用HAL库实现串口配置和使用的方法。在HAL库中,串口相关的函数和定义主要在文件stm32f4xx_hal_uart.c和stm32f4xx_hal_uart.h中。接下来我们看看HAL库提供的串口相关操作函数。

  1. 串口参数初始化(波特率/停止位等),并使能串口。

串口作为STM32的一个外设,HAL库为其配置了串口初始化函数。接下来我们看看使用CUBE生成的串口3初始化函数MX_USART3_UART_Init

1
void MX_USART3_UART_Init(void);

其中用到了初始化函数HAL_UART_Init相关知识,定义如下:

1
HAL_StatusTypeDef  HAL_UART_Init(UART_HandleTypeDef *huart);

该函数只有一个入口参数huart,为UART_HandleTypeDef结构体指针类型,我们俗称其为串口句柄,它的使用会贯穿整个串口程序。一般情况下,我们会定义一个UART_HandleTypeDef结构体类型全局变量,然后初始化各个成员变量。接下来我们看看结构体UART_HandleTypeDef的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef struct __UART_HandleTypeDef
{
USART_TypeDef *Instance; /*!< UART registers base address */
UART_InitTypeDef Init; /*!< UART communication parameters */
uint8_t *pTxBuffPtr; /*!< Pointer to UART Tx transfer Buffer */
uint16_t TxXferSize; /*!< UART Tx Transfer size */
__IO uint16_t TxXferCount; /*!< UART Tx Transfer Counter */
uint8_t *pRxBuffPtr; /*!< Pointer to UART Rx transfer Buffer */
uint16_t RxXferSize; /*!< UART Rx Transfer size */
__IO uint16_t RxXferCount; /*!< UART Rx Transfer Counter */
DMA_HandleTypeDef *hdmatx; /*!< UART Tx DMA Handle parameters */
DMA_HandleTypeDef *hdmarx; /*!< UART Rx DMA Handle parameters */
HAL_LockTypeDef Lock; /*!< Locking object */
__IO HAL_UART_StateTypeDef gState; /*!< UART state information related to
__IO HAL_UART_StateTypeDef RxState; /*!< UART state information related to Rx
__IO uint32_t ErrorCode; /*!< UART Error code */
} UART_HandleTypeDef;

该结构体成员变量非常多,一般情况下载调用函数HAL_UART_Init对串口进行初始化的时候,我们只需要先设置Instance和Init两个成员变量的值。接下来我们依次解释一下各个成员变量的含义。

Instance是USART_TypeDef结构体指针类型变量,它是执行寄存器基地址,实际上这个基地址HAL库已经定义好了,如果是串口3,取值为USART3即可。

Init是UART_InitTypeDef结构体类型变量,它是用来设置串口的各个参数,包括波特率,停止位等,它的使用方法非常简单。UART_InitTypeDef结构体定义如下:

1
2
3
4
5
6
7
8
9
10
11
typedef struct
{
uint32_t BaudRate;
uint32_t WordLength;
uint32_t StopBits;
uint32_t Parity;
uint32_t Mode;
uint32_t HwFlowCtl;
uint32_t OverSampling;
} UART_InitTypeDef;

该结构体第一个参数BaudRate为串口波特率,波特率可以说是串口最重要的参数了,它用来确定串口通信的速率。第二个参数WordLength为字长,可以设置为8位字长或者9位字长,这里我们设置为8位字长数据格式UART_WORDLENGTH_8B。第三个参数StopBits为停止位设置,可以设置为1个停止位或者2个停止位,这里我们设置为1位停止位UART_STOPBITS_1。第四个参数Parity设定是否需要奇偶校验,我们设定为无奇偶校验位。第五个参数Mode为串口模式,可以设置为只收模式,只发模式,或者收发模式。这里我们设置为全双工收发模式。第六个参数HwFlowCtl为是否支持硬件流控制,我们设置为无硬件流控制。第七个参数OverSampling用来设置过采样为16倍还是8倍。

pTxBuffPtr,TxXferSize和TxXferCount三个变量分别用来设置串口发送的数据缓存指针,发送的数据量和还剩余的要发送的数据量。而接下来的三个变量pRxBuffPtr,RxXferSize和RxXferCount则是用来设置接收的数据缓存指针,接收的最大数据量以及还剩余的要接收的数据量。这六个变量是HAL库处理中间变量,详细使用方法在我们讲解中断服务函数的时候给大家讲解。

hdmatx和hdmarx是串口DMA相关的变量,指向DMA句柄,这里我们先不讲解。其他的三个变量就是一些HAL库处理过程状态标志位和串口通信的错误码。

函数MX_USART3_UART_Init使用的一般格式为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void MX_USART3_UART_Init(void)
{

huart3.Instance = USART3;
huart3.Init.BaudRate = 115200;
huart3.Init.WordLength = UART_WORDLENGTH_8B;
huart3.Init.StopBits = UART_STOPBITS_1;
huart3.Init.Parity = UART_PARITY_NONE;
huart3.Init.Mode = UART_MODE_TX_RX;
huart3.Init.HwFlowCtl = UART_HWCONTROL_NONE;
huart3.Init.OverSampling = UART_OVERSAMPLING_16;
if (HAL_UART_Init(&huart3) != HAL_OK)
{ Error_Handler(); }
}

这里我们需要说明的是,函数HAL_UART_Init内部会调用串口使能函数使能相应串口,所以调用了该函数之后我们就不需要重复使能串口了。当然,HAL库也提供了具体的串口使能和关闭方法,具体使用方法如下:

1
2
__HAL_UART_ENABLE(__HANDLE__)
__HAL_UART_DISABLE(__HANDLE__)

这里还需要提醒大家,串口作为一个重要外设,在调用的初始化函数HAL_UART_Init内

部,会先调用MSP初始化回调函数进行MCU相关的初始化,函数为:

1
void HAL_UART_MspInit(UART_HandleTypeDef *huart);

我们在程序中,只需要重写该函数即可。一般情况下,该函数内部用来编写IO口初始化,时钟使能以及NVIC配置。

2)使能串口和配置GPIO口,中断配置

我们要使用串口,所以我们必须使能串口时钟和使用到的GPIO口时钟。例如我们要使用串口3,所以我们必须使能串口3时钟和GPIOB时钟(串口3使用的是PB10和PB11)。然后还需要配置GPIO口,具体方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void HAL_UART_MspInit(UART_HandleTypeDef* uartHandle)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
if(uartHandle->Instance==USART3)
{
__HAL_RCC_USART3_CLK_ENABLE();
__HAL_RCC_GPIOB_CLK_ENABLE();
/**USART3 GPIO Configuration
PB10 ------> USART3_TX
PB11 ------> USART3_RX
*/
GPIO_InitStruct.Pin = GPIO_PIN_10|GPIO_PIN_11;
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
GPIO_InitStruct.Alternate = GPIO_AF7_USART3;
HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);
/* USART3 interrupt Init */
HAL_NVIC_SetPriority(USART3_IRQn, 3, 3);
HAL_NVIC_EnableIRQ(USART3_IRQn);
}
}

3)串口中断开启/关闭

HAL库中定义了一个使能串口中断的标识符__HAL_UART_ENABLE_IT,大家可以把它当一个函数来使用,具体定义请参考HAL库文件stm32f4xx_hal_uart.h中该标识符定义。例如我们要使能接收完成中断,方法如下:

1
__HAL_UART_ENABLE_IT(huart,UART_IT_RXNE); //开启接收完成中断

关闭接收中断,方法如下:

1
__HAL_UART_DISABLE_IT(huart,UART_IT_RXNE); //关闭接收完成中断

4) 编写中断服务函数

串口3中断服务函数为:

1
void USART3_IRQHandler(void)

当发生中断的时候,程序就会执行中断服务函数。然后我们在中断服务函数中编写们相应的逻辑代码即可。

5) 串口数据接收和发送

STM32F4的发送与接收是通过数据寄存器USART_DR来实现的,这是一个双寄存器,包含了TDR和RDR。当向该寄存器写数据的时候,串口就会自动发送,当收到数据的时候,也是存在该寄存器内。HAL库操作USART_DR寄存器发送数据的函数是:

1
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout);

HAL库通过中断操作USART_DR寄存器读取串口接收到的数据的函数是:

1
HAL_StatusTypeDef HAL_UART_Receive_IT(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);

三.引脚说明与硬件连接

板载一个串口(USART3)连接到单片机stm32f103c8t6(JTAG功能)的串口,STM32F407可以通过USART3和上位机通信。

图9.1为STM32F407的USART3(PB10,PB11)和stm32f103c8t6的串口硬件连接图。表9.1为其引脚说明。

9.1 UART硬件连接图

9.1 UART引脚说明

设备名 引脚号
USART3_RX PB11
USART3_TX PB10

四.程序设计

本节,我们首先讲解使用HAL库配置串口的一般步骤。然后我们会具体讲解我们串口实验程序实现。

和其他外设一样,HAL库为串口的使用开放了MSP函数。在串口初始化函数HAL_UART_Init内部,会调用串口MSP函数HAL_UART_MspInit来设置与MCU相关的配置。

根据前面的讲解,函数HAL_UART_Init主要用来初始化与串口相关的参数(这些参数与MCU无关),包括波特率,停止位等。而串口MSP函数HAL_UART_MspInit用来设置GPIO初始化,NVIC配置等于MCU相关的配置。

这里我们在main函数调用cube生成的初始化函数MX_USART3_UART_Init初始化串口参数配置,具体函数如下:

1
void MX_USART3_UART_Init(void);

串口MSP函数HAL_UART_MspInit用来设置GPIO初始化,NVIC配置等于MCU相关的配置。

1
void HAL_UART_MspInit(UART_HandleTypeDef* uartHandle);

通过上面两个函数,我们就配置了串口相关设置。接下来就是编写中断服务函数USART3_IRQHandler。我们把中断服务函数USART3_IRQHandler从写到debug.c文件中,需要将stm32f4xx_it.h的中断服务函数USART3_IRQHandler注释掉。在HAL库中,对中断服务函数的编写有非常严格的讲究。

首先HAL库定义了一个串口中断处理通用函数HAL_UART_IRQHandler,该函数声明如下:

1
void HAL_UART_IRQHandler(UART_HandleTypeDef *huart);

该函数只有一个入口参数就是UART_HandleTypeDef结构体指针类型的串口句柄huart,我们在调用HAL_UART_Init函数时设置的同一个变量即可。该函数一般在中断服务函数中调用,作为串口中断处理的通用入口。一般调用方法为:

1
2
3
4
void USART3_IRQHandler(void){
HAL_UART_IRQHandler(&usart3); //调用HAL库中断处理公用函数
//中断处理完成后的结束工作
}

也就是说,真正的串口中断处理逻辑我们会最终在函数HAL_UART_IRQHandler内部执行。而该函数是HAL库已经定义好,而且用户一般不能随意修改。这个时候大家会问,那么我们的中断控制逻辑编写在哪里呢?为了把这个问题讲解清楚,我们要来看看函数HAL_UART_IRQHandler内部具体实现过程。因为本章实验,我们主要实现的是串口中断接收,也就是每次接收到一个字符后进入中断服务函数来处理。所以我们就以中断接收为例给大家讲解。这里为了篇幅考虑,我们仅仅列出串口中断执行流程中与接收相关的源码。函数HAL_UART_IRQHandler关于串口接收相关源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
void HAL_UART_IRQHandler(UART_HandleTypeDef *huart){
uint32_t isrflags = READ_REG(huart->Instance->SR);
uint32_t cr1its = READ_REG(huart->Instance->CR1);
/* UART in mode Receiver -------------------------------------------------*/
if (((isrflags & USART_SR_RXNE) != RESET) && ((cr1its & USART_CR1_RXNEIE) != RESET))
{
UART_Receive_IT(huart);
return;
}
//省略部分代码
}

从代码逻辑可以看出,在函数HAL_UART_IRQHandler内部通过判断中断类型是否为接收完成中断,确定是否调用HAL另外一个函数UART_Receive_IT()。函数UART_Receive_IT()的作用是把每次中断接收到的字符保存在串口句柄的缓存指针pRxBuffPtr中,同时每次接收一个字符,其计数器RxXferCount减1,直到接收完成RxXferSize个字符之后RxXferCount设置为0,同时调用接收完成回调函数HAL_UART_RxCpltCallback进行处理。为了篇幅考虑,这里我们仅列出UART_Receive_IT()函数调用回调函数HAL_UART_RxCpltCallback的处理逻辑,代码如下:

1
2
3
4
5
6
7
8
9
static HAL_StatusTypeDef UART_Receive_IT(UART_HandleTypeDef *huart)
{
//省略部分代码
if (--huart->RxXferCount == 0U)
{
HAL_UART_RxCpltCallback(huart);
}
}

最后我们列出串口接收中断的一般流程,如图所示:

这里,我们再把串口接收中断的一般流程进行概括:当接收到一个字符之后,在函数UART_Receive_IT中会把数据保存在串口句柄的成员变量pRxBuffPtr缓存中,同时RxXferCount计数器减1。如果我们设置RxXferSize=10,那么当接收到10个字符之后,RxXferCount会由10减到0(RxXferCount初始值等于RxXferSize),这个时候再调用接收完成回调函数HAL_UART_RxCpltCallback进行处理。接下来我们看看我们的配置。

在main函数中我们再开启中断接收使能。

1
__HAL_UART_ENABLE_IT(&huart3, UART_IT_RXNE);

在进入接收中断中,当接收到一个字符处理完成功,我们需要再开启中断,调用HAL_UART_Receive_IT函数后,除了开启接收中断外还确定了每次接收RXBUFFERSIZE个字符后标示接收结束从而进入回调函数HAL_UART_RxCpltCallback进行相应处理。aRxBuffer是我们定义的一个全局数组变量,RXBUFFERSIZE是我们定义的一个标识符:

1
2
3
#define RXBUFFERSIZE   1 
uint8_t aRxBuffer[RXBUFFERSIZE];

最后我们看看HAL_UART_RxCpltCallback函数定义:

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
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{

if(huart->Instance==USART3)//如果是串口3
{
if((USART_RX_STA&0x8000)==0)//接收未完成
{
if(USART_RX_STA&0x4000)//接收到了0x0d
{
if(aRxBuffer[0]!=0x0a)USART_RX_STA=0;//接收错误,重新开始
else
{
USART_RX_STA|=0x8000; //接收完成了
}
}
else //还没收到0X0D
{
if(aRxBuffer[0]==0x0d)USART_RX_STA|=0x4000;
else
{
USART_RX_BUF[USART_RX_STA&0X3FFF]=aRxBuffer[0] ;
USART_RX_STA++;
if(USART_RX_STA>(USART_REC_LEN-1))USART_RX_STA=0;//接收数据错误,重新开始接收
}
}
}
}
}

因为我们设置了串口句柄成员变量RxXferSize为1,也就是每当串口1发生了接收完成中断后(接收到一个字符),就会跳到该函数执行。当串口接受到一个字符后,它会保存在缓存aRxBuffer中,由于我们设置了缓存大小为1,而且RxXferSize=1,所以每次接受一个字符,回直接保存到RxXferSize[0]中,我们直接通过读取RxXferSize[0]的值就是本次接收到的字符。这里我们设计了一个小小的接收协议:通过这个函数,配合一个数组USART_RX_BUF[],一个接收状态寄存器USART_RX_STA(此寄存器其实就是一个全局变量,由作者自行添加。由于它起到类似寄存器的功能,这里暂且称之为寄存器)实现对串口数据的接收管理。USART_RX_BUF的大小由USART_REC_LEN定义,也就是一次接收的数据最大不能超过USART_REC_LEN个字节。USART_RX_STA是一个接收状态寄存器其各的定义如表所示:

设计思路如下:

当接收到从电脑发过来的数据,把接收到的数据保存在USART_RX_BUF中,同时在接收状态寄存器(USART_RX_STA)中计数接收到的有效数据个数,当收到回车(回车的表示由2个字节组成:0X0D和0X0A)的第一个字节0X0D时,计数器将不再增加,等待0X0A的到来,而如果0X0A没有来到,则认为这次接收失败,重新开始下一次接收。如果顺利接收到0X0A,则标记USART_RX_STA的第15位,这样完成一次接收,并等待该位被其他程序清除,从而开始下一次的接收,而如果迟迟没有收到0X0D,那么在接收数据超过USART_REC_LEN的时候,则会丢弃前面的数据,重新接收。

在函数USART3_IRQHandler的结尾还有几行行代码,其中部分代码是超时退出逻辑,关键逻辑代码如下:

1
2
while (HAL_UART_GetState(huart_debug) != HAL_UART_STATE_READY)//等待就绪 
while(HAL_UART_Receive_IT(huart_debug, (uint8_t *)aRxBuffer, RXBUFFERSIZE) != HAL_OK)//一次处理完成之后,重新开启中断并设置RxXferCount为1

第一行代码是判断串口是否就绪,如果没有就绪就等待就绪。第二行代码是继续调用HAL_UART_Receive_IT函数来开启中断和重新设置RxXferSize和RxXferCount的初始值为1,也就是开启新的接收中断。

最后我们来看一下主函数main

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
int main(void)
{
uint16_t len=0; //串口接收字符长度;
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_I2C2_Init();
MX_SPI5_Init();
MX_USART3_UART_Init();

//开起串口3接收中断
__HAL_UART_ENABLE_IT(&huart3, UART_IT_RXNE);
HAL_UART_Transmit(&huart3,uartSendTitle,sizeof(uartSendTitle),1000); //发送接收到的数据
while(__HAL_UART_GET_FLAG(&huart3,UART_FLAG_TC)!=SET); //等待发送结束
while (1)
{
if(USART_RX_STA&0x8000)
{
len=USART_RX_STA&0x3fff;//得到此次接收到的数据长度
printf("\r\nsend data:\r\n");
HAL_UART_Transmit(&huart3,(uint8_t*)USART_RX_BUF,len,1000);//发送接收到的数据
while(__HAL_UART_GET_FLAG(&huart3,UART_FLAG_TC)!=SET);
USART_RX_STA=0;
}
}
}

这段代码逻辑比较简单,首先判断全局变量USART_RX_STA的最高位是否为1,如果为1的话,那么代表前一次数据接收已经完成,接下来就是把我们自定义接收缓冲的数据发送到串口。接下来我们重点以下两句:

1
2
3
HAL_UART_Transmit(&huart3,uartSendTitle,sizeof(uartSendTitle),1000);	//发送接收到的数据
while(__HAL_UART_GET_FLAG(&huart3,UART_FLAG_TC)!=SET); //等待发送结束

第一句,其实就是调用HAL串口发送函数HAL_UART_Transmit来发送一个字符到串口。第二句呢,就是我们发送一个字节之后之后,要检测这个数据是否已经被发送完成了。

串口还可以通过中断的方式发送,再while循环中,我们使用中断方式发送:

1
HAL_UART_Transmit_IT(&huart3,(uint8_t*)USART_RX_BUF,len);	//发送接收到的数据

在实验中,我们经常需要需要用到printf函数,将调试信息打印到串口上,我们只需要从定义fputc函数,从定义如下:

1
2
3
4
5
6
7
8
9
#define PUTCHAR_PROTOTYPE  int  fputc(int ch, FILE *f)
static uint8_t s_usart_tmp;
int fputc(int ch,FILE *f)
{
s_usart_tmp=(uint8_t)(ch);
HAL_UART_Transmit(huart_debug,&s_usart_tmp,1,10);
return ch;
}

这段代码我们添加在debug.c文件中,实现printf函数从映射。

五.实验结果

我们打开上位机串口软件,串口连接为开发板的DAP-LINK接口(我的电脑是COM4,另外,请注意:波特率是115200),我们把程序下载到STM32F407开发板,可以在上位机看到串口打印的提示信息。

从图中可以看出串口发送数据没有问题,单片机能够发送数据到上位机。

在单片机接收程序中,因为我们在程序上面设置了必须输入回车,串口才认可接收到的数据,所以必须在发送数据后再发送一个回车符,上位机提供的发送方法是通过勾选发送新行实现,只要勾选了这个选项,每次发送数据后,都会自动多发一个回车(0X0D+0X0A)。设置好了发送新行,我们再在发送区输入你想要发送的文字,然后单击发送,可以得到如图所示结果:

六.STM32CubeMx配置串口

本小节讲解使用STM32CubeMX配置串口方法。我们仅讲解串口的配置,其他部分不讲解

我们配置串口3,所以首先我们要使能串口3,然后设置相应通信模式。我们开启串口3的异步模式,并且不使用硬件流控制,我们还需要配置USART3外设相关的参数,包括波特率,停止位等。

在STM32CubeMX中,当我们选择好外设的工作模式之后,软件会自动配置GPIO口的相关模式和参数。在Pinout界面我们看看芯片引脚图会发现,PB10和PB11端口的模式会自动复用为发送和接收模式

NVIC Setting选项卡用来使能USART3中断。这里我们勾上Enabled选项。

NVIC
Configuration界面,我们首先设置中断优先级分组级别,我们系统初始化设置为2,为2位抢占优先级。所以这里的参数我们选择“2
bits for pre-emption priority”,也就是2位抢占优先级。

关于使用STM32CubeMX配置串口的方法就给大家介绍到这里。

一.实验目标

1.编程使用STM32F407的定时器来实现精准延时;

2.通过本实验掌握TIMER使用。

二.知识储备及设计思路

TIMER简介

定时器(Timer)最基本的功能就是定时了,比如定时发送USART
数据、定时采集AD数据等等。如果把定时器与GPIO
结合起来使用的话可以实现非常丰富的功能,可以测量输入信号的脉冲宽度,可以生产输出波形。定时器生成PWM控制电机状态是工业控制普遍方法。

STM32F42xxx 系列控制器有2 个高级控制定时器、10 个通用定时器和2
个基本定时器,还有2
个看门狗定时器。看门狗定时器不在本章讨论范围,有专门讲解的章节。控制器上所有定时器都是彼此独立的,不共享任何资源。

定时器类 型 Timer 计数器 分辨率 计数器类 型 预分频系数 DMA请 求生成 捕获/ 比较 通道 互补 输出 最大接口时 钟(MHz) 最大定时器 时钟(MHz)
高级控制 TIM1 和 TIM8 16 位 递增、递 减、递增/ 递减 1~65536(整数) 4 84 (APB2) 168
通用 TIM2, TIM5 递增、递 减、递增/ 递减 1~65536(整数) 4 42 (APB1) 84/168
TIM3, TIM4 16 位 递增、递 减、递增/ 递减 1~65536(整数) 4 42 (APB1) 84/168
TIM9 16 位 递增 1~65536(整数) 2 84 (APB2) 168
TIM10, TIM11 16 位 递增 1~65536(整数) 1 84 (APB2) 168
TIM12 16 位 递增 1~65536(整数) 2 42 (APB1) 84/168
TIM13, TIM14 16 位 递增 1~65536(整数) 1 42 (APB1) 84/168
基本 TIM6 和 TIM7 16 位 递增 1~65536(整数) 0 42 (APB1) 84/168

本节中我们使用的是TIM2,我们就介绍TIM2定时器。通用定时器框图如图:

由于STM32F407通用定时器比较复杂,这里我们不再多介绍,请大家直接参考《STM32F4xx中文参考手册》第392页,通用定时器一章。下面我们介绍一下与我们这章的实验密切相关的几个通用定时器的寄存器(以下均以TIM2~TIM5的寄存器介绍,TIM9~TIM14的略有区别,具体请看《STM32F4xx中文参考手册》对应章节)。

TIM2 到 TIM5 主要特性

● 16 位(TIM3 和 TIM4)或 32 位(TIM2 和 TIM5)
递增、递减和递增/递减自动重载计数器。

● 16 位可编程预分频器,用于对计数器时钟频率进行分频
(即运行时修改),分频系数介于 1 到 65536 之间。

● 多达 4 个独立通道,可用于:

— 输入捕获

— 输出比较

— PWM 生成(边沿和中心对齐模式)

— 单脉冲模式输出

● 使用外部信号控制定时器且可实现多个定时器互连的同步电路。

● 发生如下事件时生成中断/DMA 请求:

— 更新:计数器上溢/下溢、计数器初始化(通过软件或内部/外部触发)

— 触发事件(计数器启动、停止、初始化或通过内部/外部触发计数)

— 输入捕获

— 输出比较

● 支持定位用增量(正交)编码器和霍尔传感器电路

● 外部时钟触发输入或逐周期电流管理

TIM通用寄存器简介

TIMx 控制寄存器 1 (TIMx_CR1),寄存器的各位描述如图所示:

本节中只需配置CEN使能位1。

TIMx 预分频器 (TIMx_PSC),寄存器的各位描述如图所示:

这里,定时器的时钟来源有4个:

1)内部时钟(CK_INT)

2)外部时钟模式1:外部输入脚(TIx)

3)外部时钟模式2:外部触发输入(ETR),仅适用于TIM2、TIM3、TIM4

4)内部触发输入(ITRx):使用A定时器作为B定时器的预分频器(A为B提供时钟)。

这些时钟,具体选择哪个可以通过TIMx_SMCR寄存器的相关位来设置。这里的CK_INT时钟是从APB1倍频的来的,除非APB1的时钟分频数设置为1(一般都不会是1),否则通用定时器TIMx的时钟是APB1时钟的2倍,当APB1的时钟不分频的时候,通用定时器TIMx的时钟就等于APB1的时钟。这里还要注意的就是高级定时器以及TIM9~TIM11的时钟不是来自APB1,而是来自APB2的。

这里顺带介绍一下TIMx_CNT寄存器,该寄存器是定时器的计数器,该寄存器存储了当前定时器的计数值。

TIMx 自动重载寄存器 (TIMx_ARR),寄存器的描述如图所示:

自动重载寄存器是预装载的。对自动重载寄存器执行写入或读取操作时会访问预装载寄存器。预装载寄存器的内容既可以直接传送到影子寄存器,也可以在每次发生更新事件
(UEV) 时传送到影子寄存器,这取决于 TIMx_CR1 寄存器中的自动重载预装载使能位
(ARPE)。当 计数器达到上溢值(或者在递减计数时达到下溢值)并且 TIMx_CR1
寄存器中的 UDIS 位为 0 时,将发送更新事件。该更新事件也可由软件产生。

TIMx 状态寄存器 (TIMx_SR),寄存器的各位描述如图所示:

该寄存器用来标记当前与定时器相关的各种事件/中断是否发生。

HAL库函数和cube生成函数介绍

通过以上一些寄存器的操作配置,我们就可以达到TIM最基本的配置了,接下来我们将着重讲解使用HAL库实现串口配置和使用的方法。在HAL库中,TIM相关的函数和定义主要在文件stm32f4xx_hal_tim.c和stm32f4xx_hal_tim.h中。接下来我们看看HAL库提供的TIM相关操作函数。

  1. TIM初始化。

TIM作为STM32的一个重要,HAL库为其配置了TIM初始化函数。接下来我们看看使用CUBE生成的TIM2初始化函数MX_TIM2_Init

void MX_TIM2_Init(void);

其中用到了初始化函数HAL_TIM_Base_Init相关知识,定义如下:

HAL_StatusTypeDef HAL_TIM_Base_Init(TIM_HandleTypeDef *htim);

该函数只有一个入口参数htim,为TIM_HandleTypeDef结构体指针类型,我们俗称其为句柄,它的使用会贯穿整个程序。一般情况下,我们会定义一个TIM_HandleTypeDef结构体类型全局变量,然后初始化各个成员变量。接下来我们看看结构体TIM_HandleTypeDef的定义:

{ TIM_TypeDef *Instance; /*!< Register base address */ TIM_Base_InitTypeDef Init; /*!< TIM Time Base required parameters */ HAL_TIM_ActiveChannel Channel; /*!< Active channel */ DMA_HandleTypeDef *hdma[7]; /*!< DMA Handlers array HAL_LockTypeDef Lock; /*!< Locking object */ __IO HAL_TIM_StateTypeDef State; /*!< TIM operation state */ } TIM_HandleTypeDef;

一般情况下载调用函数HAL_TIM_Base_Init对TIM进行初始化的时候,我们只需要先设置Instance和Init两个成员变量的值。接下来我们依次解释一下各个成员变量的含义。

Instance是TIM_TypeDef结构体指针类型变量,它是执行寄存器基地址,实际上这个基地址HAL库已经定义好了,如果是TIM2,取值为TIM2即可。

Init是TIM_Base_InitTypeDef结构体类型变量,它是用来设置TIM的各个参数,包括分频,计数等,它的使用方法非常简单。TIM_Base_InitTypeDef结构体定义如下:

typedef struct { uint32_t Prescaler; uint32_t CounterMode; uint32_t Period; uint32_t ClockDivision; uint32_t RepetitionCounter; uint32_t AutoReloadPreload; } TIM_Base_InitTypeDef;

该结构体第一个参数Prescaler为psc时钟分频系数,它用来确定工作频率。第二个参数CounterMode位计数器模式,包括向上/向下/中心。第三个参数Period为自动从装寄存器的值。第四个参数ClockDivision为内部时钟分频系数。

函数MX_TIM2_Init使用的一般格式为:

void MX_TIM2_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; htim2.Instance = TIM2; htim2.Init.Prescaler = 83; htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 999999; htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; if (HAL_TIM_Base_Init(&htim2) != HAL_OK) { Error_Handler(); } sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL; if (HAL_TIM_ConfigClockSource(&htim2, &sClockSourceConfig) != HAL_OK) { Error_Handler();} sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET; sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; if (HAL_TIMEx_MasterConfigSynchronization(&htim2, &sMasterConfig) != HAL_OK) { Error_Handler(); } }

这里我们需要说明的是,初始化除了使用函数HAL_TIM_Base_Init,还通过函数HAL_TIM_ConfigClockSource配置tim的时钟源,函数HAL_TIMEx_MasterConfigSynchronization配置关闭triger
output模式。

因为我们在SystemClock_Config函数里面已经初始化APB1的时钟为4分频,所以APB1的时钟为42M,而从STM32F407的内部时钟树图得知:当APB1的时钟分频数为1的时候,TIM2~7以及TIM12~14的时钟为APB1的时钟,而如果APB1的时钟分频数不为1,那么TIM2~7以及TIM12~14的时钟频率将为APB1时钟的两倍。因此,TIM2的时钟为84M,再根据我们设计的arr和psc的值,就可以计算中断时间了。计算公式如下:

Tout= ((arr+1)*(psc+1))/Tclk;

其中: Tclk:TIM2是输入时钟频率(单位为Mhz)。

Tout:TIM2溢出时间(单位为us)。

调用HAL_TIM_Base_Init时,内部会调用使能函数使能相应tim,所以调用了该函数之后我们就不需要重复使能。

__HAL_RCC_TIM2_CLK_ENABLE();

三.程序设计

本节,我们首先讲解使用HAL库配置TIM的一般步骤。然后我们会具体讲解我们实验程序实现。

这里我们在main函数调用cube生成的初始化函数MX_TIM2_Init初始化参数配置,具体函数如下:

void MX_TIM2_Init(void)

通过上面一个函数,我们就配置了TIM相关初始化设置。接下来就是编写TIM实现精确延时服务函数AccurateDelay。

//利用TIM2定时,实现精确延时 void AccurateDelay(uint32_t delayMs) { // 经分频后,TIM2的时钟频率为1MHz,即周期为1us // 将ms转换为us uint32_t delayUs = delayMs * 1000; if (delayUs > 0) { // 设置TIM2定时周期 htim2.Init.Period = delayUs - 1; htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; if (HAL_TIM_Base_Init(&htim2) != HAL_OK) { Error_Handler(); } // TIM2开始计时前,清除UPDATE标志 __HAL_TIM_CLEAR_FLAG(&htim2, TIM_FLAG_UPDATE); // TIM2开始计时 HAL_TIM_Base_Start(&htim2); // 等待计时 while (__HAL_TIM_GET_FLAG(&htim2, TIM_FLAG_UPDATE) != SET) { } // 计时结束,停止TIM2 __HAL_TIM_CLEAR_FLAG(&htim2, TIM_FLAG_UPDATE); HAL_TIM_Base_Stop(&htim2); } }

该函数用于实现ms级别的延时。首先设定定时器周期为delayMs *
1000,然后初始化定时器;清除UPDATE标志后,TIM2开始计时,然后一直停在while处,等待计时标志TIM_FLAG_UPDATE清0完成。然后停止TIM2计数。这个过程就完成了精准延时。

接下来,我们来看一下主函数main:

int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_TIM2_Init(); while (1) { AccurateDelay(500); //精确延时500ms HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_6); AccurateDelay(1000); //精确延时1000ms HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_6); AccurateDelay(2000); //精确延时2000ms HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_6); } }

初始化函数实现硬件配置的初始化,不再讲述。再while循环中,通过TIM2实现延时,分别隔500ms/1000ms/2000ms翻转LED1管脚PB6的电平。

四.实验结果

我们把程序下载到STM32F407开发板,观察LED1闪烁间隔时间。

五.STM32CubeMx配置TIM2

本小节讲解使用STM32CubeMX配置TIM方法,我们配置TIM2,所以首先我们要选择内部时钟作为时钟源,时钟源频率设置。预分频设置89,向上计数等。

关于使用STM32CubeMX配置tim的方法就给大家介绍到这里。

一. 实验目标

1.通过编程检测普通按键KEY1和KEY2的状态,并通过LED1和LED2指示按键状态;

2.通过本实验掌握Keil
5新建工程、使用STM32库函数编写应用程序、将程序下载至实验板的步骤、方法;

3.通过本实验掌握STM32的GPIO外设的基本概念,掌握对GPIO输入状态进行读取和输出状态进行控制的基本方法。

二. 知识储备及设计思路

在上一节实验中用到GPIO配置输出IO口作为输出,本节我们需要用到输入IO口,需要使用IDR寄存器,其余需要配置的寄存器不在描述,IDR该寄存器用于读取GPIOx的输入数据,该寄存器各位描述见表:

该寄存器用于读取某个IO的电平,如果对应的位为0(IDRy=0),则说明该IO输入的是低电平,如果是1(IDRy=1),则表示输入的是高电平。HAL库操作该寄存器读取IO输入数据相关函数:

GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);

STM32F4的IO口做输入使用的时候,是通过调用函数HAL_GPIO_ReadPin
()来读取IO口的状态的。了解了这点,就可以开始我们的代码编写了。

三.引脚说明与硬件连接

图3.2.1(a) 普通按键KEY1,KEY2硬件连接图

图3.2.1(b) 普通按键KEY1,KEY2的管脚分配图

图3.2.1(c) LED1、LED2硬件连接图 图3.2.1(d) LED1、LED2管脚分配图

如图3.2.1(a)、(b)、(c)、(d)可知,普通按键KEY1、KEY2分别连接至GPIO PG6和PG7管脚,
LED1和LED2分别连接至GPIO
PF6和PF7管脚。当某一按键按下时,PG6(或PG7)为低电平,按键松开时则为高电平。当GPIO
PF6(或PF7)输出高电平时,LED1(或LED2)点亮,输出低电平时,LED1(或LED2)点灭。

由硬件连接图可知,当按键按下时,对应GPIO管脚电平为低电平(0V),当按键松开时,对应GPIO管脚为高电平(3.3V)。通过程序循环读取GPIO管脚的高、低电平,来判断当前按键的状态(按下或是松开)。

四.程序设计

根据检测到的按键状态,控制LED对应的GPIO分别输出高、低电平,来点亮或点灭对应的LED,指示当前按键的状态。

在主函数中,主要实现按键检测和led控制。代码如下:

int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); while (1) { if (GPIO_PIN_RESET == HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_6)){ // 按键KEY1按下,点亮LED1 HAL_GPIO_WritePin(GPIOF, GPIO_PIN_6, GPIO_PIN_SET); } else { // 按键KEY1松开,点灭LED1 HAL_GPIO_WritePin(GPIOF, GPIO_PIN_6, GPIO_PIN_RESET); } if (GPIO_PIN_RESET == HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_7)){ // 按键KEY2按下,点亮LED2 HAL_GPIO_WritePin(GPIOF, GPIO_PIN_7, GPIO_PIN_SET); } else { // 按键KEY2松开,点灭LED2 HAL_GPIO_WritePin(GPIOF, GPIO_PIN_7, GPIO_PIN_RESET); } } }

main函数代码中while(1)循环中的代码。其中,代码分析如下:

读取GPIO PG6管脚的输入电平,并判断是否为低电平

如果为低电平,表示按键KEY1按下,则点亮LED1

如果为高电平,表示按键KEY1松开,则点灭LED1

读取GPIO PG7管脚的输入电平,并判断是否为低电平

如果为低电平,表示按键KEY2按下,则点亮LED2

如果为高电平,表示按键KEY2松开,则点灭LED2

接下来,将介绍使用CUBE生成的初始化函数MX_GPIO_Init,几个主要子函数的功能与内容。

void MX_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOI_CLK_ENABLE(); __HAL_RCC_GPIOF_CLK_ENABLE(); __HAL_RCC_GPIOH_CLK_ENABLE(); __HAL_RCC_GPIOG_CLK_ENABLE(); HAL_GPIO_WritePin(GPIOI,GPIO_PIN_10|GPIO_PIN_11,GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOF,GPIO_PIN_6|GPIO_PIN_7,GPIO_PIN_RESET); GPIO_InitStruct.Pin = GPIO_PIN_10|GPIO_PIN_11; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOI, &GPIO_InitStruct); GPIO_InitStruct.Pin = GPIO_PIN_6|GPIO_PIN_7; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOF, &GPIO_InitStruct); GPIO_InitStruct.Pin = GPIO_PIN_6|GPIO_PIN_7; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_NOPULL; HAL_GPIO_Init(GPIOG, &GPIO_InitStruct); }

函数MX_GPIO_Init代码,是stm32cube自动生成的代码,其功能为对LED灯和按键对应的GPIO引脚进行初始化配置。程序实现功能为:

使能对应GPIO的时钟。

拉低对应的PF6和PF7引脚使LED1、LED2灯熄灭,作为初始状态。

配置PG6和PG7引脚为输入模式,无上拉或下拉,并通过函数HAL_GPIO_Init完成配置。

配置PF6和PF7引脚为输出模式,输出状态为推挽输出,输出速度为低速,无上拉或下拉,并通过函数HAL_GPIO_Init完成配置。

在这里用到了HAL_GPIO_Init函数,用于配置GPIO口。

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

五.实验结果

将编译好的代码下载到实验板中,按下复位键。

按下普通按键KEY1后,LED1点亮;松开KEY1后,LED1点灭。

按下普通按键KEY2后,LED2点亮;松开KEY2后,LED2点灭。

六.思考与拓展

1.本实验通过循环轮询GPIO的输入状态来实现按键检测,是否可以加入延迟函数来适当增大检测的间隔时间?检测的间隔时间多长较为合适?

2.按键所用的开关为机械弹性开关,由于弹性作用,按键开关闭合或断开瞬间会伴随一连串的抖动,因此要进行按键消抖。请尝试借助延迟函数实现按键消抖处理。

七.STM32CubeMx配置GPIO

在cube中将按键输入IO口配置为输入模式。

一.实验目标

  1. 通过外部中断检测普通按键KEY1和KEY2是否按下,并通过LED1和LED2指示-每按一次,LED状态发生翻转;

  2. 通过本实验掌握STM32的中断基本概念,掌握对外部中断配置基本方法。

二.知识储备及设计思路

嵌套向量中断控制器简介

嵌套向量中断控制器 NVIC 包含以下特性:

● STM32F407xx 具有 82 个可屏蔽中断通道

● 16 个可编程优先级(使用了 4 位中断优先级)

● 低延迟异常和中断处理

● 电源管理控制

● 系统控制寄存器的实现

STM32F4外部中断简介

STM32F4的每个IO都可以作为外部中断的中断输入口,外部中断/事件控制器包含多达 23
个用于产生事件/中断请求的边沿检测器。每根输入线都可
单独进行配置,以选择类型(中断或事件)和相应的触发事件(上升沿触发、下降沿触发或边沿触发)。每根输入线还可单独屏蔽。挂起寄存器用于保持中断请求的状态线。

外部中断/事件控制器框图如图:

要产生中断,必须先配置好并使能中断线。根据需要的边沿检测设置 2
个触发寄存器,同时在
中断屏蔽寄存器的相应位写“1”使能中断请求。当外部中断线上出现选定信号沿时,便会产生中断请求,对应的挂起位也会置
1。在挂起寄存器的对应位写“1”,将清除该中断请求。

要产生事件,必须先配置好并使能事件线。根据需要的边沿检测设置 2
个触发寄存器,同时
在事件屏蔽寄存器的相应位写“1”允许事件请求。当事件线上出现选定信号沿时,便会产生事件脉冲,对应的挂起位不会置
1。

通过在软件中对软件中断/事件寄存器写“1”,也可以产生中断/事件请求。

STM32F407的23个外部中断为:

EXTI线0~15:对应外部IO口的输入中断。

EXTI线16:连接到PVD输出。

EXTI线17:连接到RTC闹钟事件。

EXTI线18:连接到USB OTG FS唤醒事件。

EXTI线19:连接到以太网唤醒事件。

EXTI线20:连接到USB OTG HS(在FS中配置)唤醒事件。

EXTI线21:连接到RTC入侵和时间戳事件。

EXTI线22:连接到RTC唤醒事件。

从上面可以看出,STM32F4供IO口使用的中断线只有16个,但是STM32F4的IO口却远远不止16个,那么STM32F4是怎么把16个中断线和IO口一一对应起来的呢?于是STM32就这样设计,GPIO的引脚GPIOx.0~GPIOx.15(x=A,B,C,D,E,F,G,H,I)分别对应中断线0~15。这样每个中断线对应了最多9个IO口,以线0为例:它对应了GPIOA.0、GPIOB.0、GPIOC.0、GPIOD.0、GPIOE.0、GPIOF.0、GPIOG.0,GPIOH.0,GPIOI.0。而中断线每次只能连接到1个IO口上,这样就需要通过配置来决定对应的中断线配置到哪个GPIO上了。下面我们看看GPIO跟中断线的映射关系图:

中断寄存器简介

当使用外部中断时,需要配置寄存器,在这里我们介绍部分EXIT寄存器:EXTI_IMR,EXTI_EMR
,EXTI_RTSR,EXTI_FTSR,EXTI_SWIER,EXTI_PR。

EXTI_IMR中断屏蔽寄存器用于开放/屏蔽来自 x
线的中断请求,寄存器如图:

EXTI_RTSR上升沿触发选择寄存器用于禁止/允许来自 x 线的上升沿触发,寄存器如图:

EXTI_FTSR下降沿触发选择寄存器用于禁止/允许来自 x 线的上升沿触发,寄存器如图:

EXTI_PR挂起寄存器,当外部发生了边沿事件,该位自动被置“1”,存寄存器如图:

配置EXIT

要使用外部中断,需要使能相应的寄存器,我们使用HAL库配置的时候,底层寄存器操作被封装成函数,我们只是需要调用相应的函数。

使用HAL库配置外部中断的一般步骤。HAL中外部中断相关配置函数和定义在文件stm32f4xx_hal_exti.h和stm32f4xx_hal_exti.c文件中。

1.I/O口作为中断输入,所以我们要使能相应的I/O口时钟;设置IO口模式,触发条件,开启SYSCFG时钟,设置IO口与中断线的映射关系。

SYSCFG 外部中断配置寄存器 2
(SYSCFG_EXTICR2),开启SYSCFG时钟和外部中断配置寄存器关系入图:

这部分代码在函数MX_GPIO_Init中实现:

__HAL_RCC_GPIOG_CLK_ENABLE(); GPIO_InitStruct.Pin = GPIO_PIN_6|GPIO_PIN_7; GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING; GPIO_InitStruct.Pull = GPIO_NOPULL; HAL_GPIO_Init(GPIOG, &GPIO_InitStruct);

例如:我们这里初始化的是PG6,调用该函数后中断线6会自动连接到PG6。

2.
配置中断优先级(NVIC),并使能中断。我们设置好中断线和GPIO映射关系,然后又设置好了中断的触发模式等初始化参数。既然是外部中断,涉及到中断我们当然还要设置NVIC中断优先级。

中断优先级我们全部配置成4bit抢占优先级,这个在cube软件中配置,生成的代码如下:

HAL_NVIC_SetPriority(EXTI9_5_IRQn, 0, 0); HAL_NVIC_EnableIRQ(EXTI9_5_IRQn);

3. 编写中断服务函数

配置完中断优先级之后,接着要做的就是编写中断服务函数。中断服务函数的名字是在HAL库中事先有定义的。这里需要说明一下,STM32F4的IO口外部中断函数只有7个。使用cube生成的代码,中断服务函数为EXTI9_5_IRQHandler,这个函数在stm32fxx_ic.c中生成:

void EXTI9_5_IRQHandler(void) { HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_6); HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_7); }

HAL库为了用户使用方便,它提供了一个中断通用入口函数HAL_GPIO_EXTI_IRQHandler,在该函数内部直接调用回调函数HAL_GPIO_EXTI_Callback。

void HAL_GPIO_EXTI_IRQHandler(uint16_t GPIO_Pin) { if(__HAL_GPIO_EXTI_GET_IT(GPIO_Pin) != RESET) { __HAL_GPIO_EXTI_CLEAR_IT(GPIO_Pin); HAL_GPIO_EXTI_Callback(GPIO_Pin); } }

该函数实现的作用非常简单,就是清除中断标志位,然后调用回调函数HAL_GPIO_EXTI_Callback()实现控制逻辑。所以我们编写中断控制逻辑将跟串口实验类似,在中断服务函数中直接调用外部中断共用处理函数HAL_GPIO_EXTI_IRQHandler,然后在回调函数HAL_GPIO_EXTI_Callback中通过判断中断是来自哪个IO口编写相应的中断服务控制逻辑。

三.引脚说明与硬件连接

图3.2.1(a) 普通按键KEY1,KEY2硬件连接图

图3.2.1(b) 普通按键KEY1,KEY2的管脚分配图

如图3.2.1(a)、(b)、(c)、(d)可知,普通按键KEY1、KEY2分别连接至GPIO PG6和PG7管脚,
LED1和LED2分别连接至GPIO
PF6和PF7管脚。当某一按键按下时,PG6(或PG7)产生中断,控制LED1(或LED2)发生一次翻转。

四.程序设计

在主函数main中,除了初始换函数后,在while循环中判断按键标志位是否为“1”,标志位为高的话,延时10ms左右,再次判断按键IO口状态,如果保持一致,翻转一次LED状态。代码如下:

int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); while (1) { if (key1Interrupt == 1){ // 响应PG6中断,将标志清零 key1Interrupt = 0; // 延迟10ms,按键消抖处理 HAL_Delay(10); // 再次判断PG6是否能为低电平,如果是,说明按键KEY1被按下 if (HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_6) == GPIO_PIN_RESET){ // 翻转LED1的状态 HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_6); } }else if (key2Interrupt == 1){ // 响应PG7中断,将标志清零 key2Interrupt = 0; // 延迟10ms,按键消抖处理 HAL_Delay(10); // 再次判断PG7是否能为低电平,如果是,说明按键KEY2被按下 if (HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_7) == GPIO_PIN_RESET){ // 翻转LED2的状态 HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_7); } } }

在回调函数HAL_GPIO_EXTI_Callback中,判断中断来自哪个管脚,然后置1相应的按键标志位。

// 按键中断回调函数,在中断环境中执行 void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin){ if (GPIO_Pin == GPIO_PIN_6){ // 判断中断来自于PG6管脚 key1Interrupt = 1; __HAL_GPIO_EXTI_CLEAR_FLAG(GPIO_PIN_6); }else if (GPIO_Pin == GPIO_PIN_7){ // 判断中断来自于PG7管脚 key2Interrupt = 1; __HAL_GPIO_EXTI_CLEAR_FLAG(GPIO_PIN_7); } }

接下来,将介绍使用CUBE生成的初始化函数MX_GPIO_Init,函数的功能用于使能时钟,配置中断/GPIO。

void MX_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; /* GPIO Ports Clock Enable */ __HAL_RCC_GPIOI_CLK_ENABLE(); __HAL_RCC_GPIOF_CLK_ENABLE(); __HAL_RCC_GPIOH_CLK_ENABLE(); __HAL_RCC_GPIOG_CLK_ENABLE(); /*Configure GPIO pin Output Level */ HAL_GPIO_WritePin(GPIOF, GPIO_PIN_6|GPIO_PIN_7, GPIO_PIN_RESET); /*Configure GPIO pins : PF6 PF7 */ GPIO_InitStruct.Pin = GPIO_PIN_6|GPIO_PIN_7; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOF, &GPIO_InitStruct); /*Configure GPIO pins : PG6 PG7 */ GPIO_InitStruct.Pin = GPIO_PIN_6|GPIO_PIN_7; GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING; GPIO_InitStruct.Pull = GPIO_NOPULL; HAL_GPIO_Init(GPIOG, &GPIO_InitStruct); /* EXTI interrupt init*/ HAL_NVIC_SetPriority(EXTI9_5_IRQn, 0, 0); //中断设置优先级 HAL_NVIC_EnableIRQ(EXTI9_5_IRQn);//中断使能 }

五.实验结果

将编译好的代码下载到实验板中,按下复位键。

按下普通按键KEY1后,LED1点亮;再按KEY1后,LED1点灭。

按下普通按键KEY2后,LED2点亮;再按KEY2后,LED2点灭。

六.思考与拓展

  1. 按键所用的开关为机械弹性开关,由于弹性作用,按键开关闭合或断开瞬间会伴随一连串的抖动,因此要进行按键消抖。请尝试其他软件方式按键消抖处理。

七.STM32CubeMx配置GPIO

在cube中将按键输入IO口配置为外部中断模式。

一.实验目标

1.通过编程systick实现精准的延时

2.通过本实验掌握Keil 5新建工程、使用STM32库函数编写应用程序、将程序下载至实验板的步骤、方法;

3.通过本实验掌握STM32的systick精准延时实现。

二.知识储备及设计思路

SysTick 简介

SysTick—系统定时器是属于CM4 内核中的一个外设,内嵌在NVIC 中。系统定时器是一个24bit 的向下递减的计数器,计数器每计数一次的时间为1/SYSCLK,一般我们设置系统时钟SYSCLK 等于180M。当重装载数值寄存器的值递减到0 的时候,系统定时器就产生一次中断,以此循环往复。

因为SysTick 是属于CM4 内核的外设,所以所有基于CM4 内核的单片机都具有这个系统定时器,使得软件在CM4 单片机中可以很容易的移植。系统定时器一般用于操作系统,用于产生时基,维持操作系统的心跳。

SysTick 寄存器介绍

SysTick—系统定时有4 个寄存器,简要介绍如下。在使用SysTick 产生定时的时候,

只需要配置前三个寄存器,最后一个校准寄存器不需要使用。

img

再来看看怎样通过使用SysTick 定时器来实现延时。

延时包含了delay.c和delay.h两个文件,这两个文件用来实现系统的延时功能,其中包含7个函数,其中有4个是支持操作系统,在这里我们不研究这4个函数,只看需要用到的3个函数:

1
2
3
void delay_init(uint8_t SYSCLK);
void delay_ms(uint16_t nms);
void delay_us(uint32_t nus);

在介绍这些函数之前,我们先了解一下编程思想:CM4内核的处理和CM3一样,内部都包含了一个SysTick定时器,SysTick 是一个24 位的倒计数定时器,当计到0 时,将从RELOAD 寄存器中自动重装载定时初值。只要不把它在SysTick 控制及状态寄存器中的使能位清除,就永不停息。SysTick在《STM32xx中文参考手册》里面基本没有介绍,其详细介绍,请参阅《STM32F3与F4系列Cortex M4内核编程手册》第230页。我们就是利用STM32的内部SysTick来实现延时的,这样既不占用中断,也不占用系统定时器。

这里我们将介绍的是新版本的延时函数,延时函数支持在任意操作系统(OS)下面使用,它可以和操作系统共用SysTick定时器。

这里,我们以UCOSII为例,介绍如何实现操作系统和我们的delay函数共用SysTick定时器。首先,我们简单介绍下UCOSII的时钟:ucos运行需要一个系统时钟节拍(类似“心跳”),而这个节拍是固定的(由OS_TICKS_PER_SEC宏定义设置),比如要求5ms一次(即可设置:OS_TICKS_PER_SEC=200),在STM32上面,一般是由SysTick来提供这个节拍,也就是SysTick要设置为5ms中断一次,为ucos提供时钟节拍,而且这个时钟一般是不能被打断的(否则就不准了)。

因为在ucos下systick不能再被随意更改,如果我们还想利用systick来做delay_us或者delay_ms的延时,就必须想点办法了,这里我们利用的是时钟摘取法。以delay_us为例,比如delay_us(50),在刚进入delay_us的时候先计算好这段延时需要等待的systick计数次数,这里为50168(假设系统时钟为168Mhz,因为我们设置systick的频率为系统时钟频率,那么systick每增加1,就是1/168us),然后我们就一直统计systick的计数变化,直到这个值变化了50168,一旦检测到变化达到或者超过这个值,就说明延时50us时间到了。这样,我们只是抓取SysTick计数器的变化,并不需要修改SysTick的任何状态,完全不影响SysTick作为UCOS时钟节拍的功能,这就是实现delay和操作系统共用SysTick定时器的原理。

下面我们开始介绍这几个函数。

三.程序设计

我们不使用操作系统,只看需要用到的三个函数,当需要用到操作系统时,请自行阅读代码。

delay_init函数

该函数用来初始化2个重要参数:fac_us以及fac_ms;同时把SysTick的时钟源选择为外部时钟。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//初始化延迟函数
//当使用ucos的时候,此函数会初始化ucos的时钟节拍
//SYSTICK的时钟固定为AHB时钟
//SYSCLK:系统时钟频率
void delay_init(uint8_t SYSCLK)
{
#if SYSTEM_SUPPORT_OS
//如果需要支持OS. uint32_t reload;
#endif
HAL_SYSTICK_CLKSourceConfig(SYSTICK_CLKSOURCE_HCLK);//SysTick频率为HCLK
fac_us=SYSCLK; //不论是否使用OS,fac_us都需要使用
#if SYSTEM_SUPPORT_OS //如果需要支持OS.
reload=SYSCLK; //每秒钟的计数次数 单位为K
reload*=1000000/delay_ostickspersec; //根据delay_ostickspersec设定溢出时间 //reload为24位寄存器,最大值:16777216,在180M下,约合0.745s左右
fac_ms=1000/delay_ostickspersec; //代表OS可以延时的最少单位
SysTick->CTRL|=SysTick_CTRL_TICKINT_Msk;//开启SYSTICK中断
SysTick->LOAD=reload; //每1/OS_TICKS_PER_SEC秒中断一次
SysTick->CTRL|=SysTick_CTRL_ENABLE_Msk; //开启SYSTICK
#else
#endif
}

可以看到,delay_init函数使用了条件编译,来选择不同的初始化过程,如果不使用OS的时候,只是设置一下SysTick的时钟源以及确定fac_us值。而如果使用OS的时候,则会进行一些不同的配置,这里的条件编译是根据SYSTEM_SUPPORT_OS这个宏来确定的。

SysTick是MDK定义了的一个结构体(在core_m4.h里面),里面包含CTRL、LOAD、VAL、CALIB等4个寄存器,

SysTick->CTRL的各位定义如图5.1所示:

img

图5.1 SysTick->CTRL寄存器各位定义

SysTick-> LOAD的定义如图5. 2所示:

img

图5. 2 SysTick->LOAD寄存器各位定义

SysTick-> VAL的定义如图5. 3所示:

img

图5. 3 SysTick->VAL寄存器各位定义

SysTick-> CALIB不常用,在这里我们也用不到,故不介绍了。

1
SysTick_CLKSourceConfig(SysTick_CLKSource_HCLK);

这句代码把SysTick的时钟选择为内核时钟,这里需要注意的是:SysTick的时钟源自HCLK,假设我们外部晶振为25M,然后倍频到180MHZ,那么SysTick的时钟即为180Mhz,也就是SysTick的计数器VAL每减1,就代表时间过了1/180us。所以fac_us=SYSCLK;这句话就是计算在SYSCLK时钟频率下延时1us需要多少个SysTick时钟周期。

在不使用OS的时候:fac_us,为us延时的基数,也就是延时1us,Systick定时器需要走过的时钟周期数。当使用OS的时候,fac_us,还是us延时的基数,不过这个值不会被写到SysTick->LOAD寄存器来实现延时,而是通过时钟摘取的办法实现的(前面已经介绍了)。而fac_ms则代表ucos自带的延时函数所能实现的最小延时时间(如delay_ostickspersec=200,那么fac_ms就是5ms)。

delay_us函数

该函数用来延时指定的us,其参数nus为要延时的微秒数。该函数有使用OS和不使用OS两个版本,这里我们介绍不使用OS的时候,实现函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//延时nus//nus为要延时的us数.
//nus:0~190887435(最大值即2^32/fac_us@fac_us=22.5)
void delay_us(uint32_t nus)
{
uint32_t ticks;
uint32_t told,tnow,tcnt=0;
uint32_t reload=SysTick->LOAD; //LOAD的值
ticks=nus*fac_us; //需要的节拍数
told=SysTick->VAL; //刚进入时的计数器值
while(1)
{
tnow=SysTick->VAL;
if(tnow!=told)
{
if(tnow<told)tcnt+=told-tnow; //这里注意一下SYSTICK是一个递减的计数器就可以了.
else tcnt+=reload-tnow+told;
told=tnow;
if(tcnt>=ticks)break; //时间超过/等于要延迟的时间,则退出.
}
};
}

这里就正是利用了我们前面提到的时钟摘取法,ticks是延时nus需要等待的SysTick计数次数(也就是延时时间),told用于记录最近一次的SysTick->VAL值,然后tnow则是当前的SysTick->VAL值,通过他们的对比累加,实现SysTick计数次数的统计,统计值存放在tcnt里面,然后通过对比tcnt和ticks,来判断延时是否到达,从而达到不修改SysTick实现nus的延时。对于使用OS的时候,delay_us的实现函数和不使用OS的时候方法类似,都是使用的时钟摘取法,只不过使用delay_osschedlock和delay_osschedunlock两个函数,用于调度上锁和解锁,这是为了防止OS在delay_us的时候打断延时,可能导致的延时不准,所以我们利用这两个函数来实现免打断,从而保证延时精度。

delay_ms函数

该函数是用来延时指定的ms的,其参数nms为要延时的毫秒数。该函数有使用OS和不使用OS两个版本,这里我们分别介绍,首先是不使用OS的时候,实现函数如下:

1
2
3
4
5
6
7
//延时nms
//nms:要延时的ms数
void delay_ms(uint16_t nms)
{
uint32_t i;
for(i=0;i<nms;i++) delay_us(1000);
}

该函数其实就是多次调用前面所讲的delay_us函数,来实现毫秒级延时的。

HAL库延时函数HAL_Delay解析

前面我们讲解了提供的使用Systick实现延时相关函数。实际上,HAL库有提供延时函数,只不过它只能实现简单的毫秒级别延时,没有实现us级别延时。下面我们列出HAL库实现延时相关的函数。首先是功能配置函数:

//调用HAL_SYSTICK_Config函数配置每隔1ms中断一次:文件stm32f4xx_hal.c中定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
__weak HAL_StatusTypeDef HAL_InitTick(uint32_t TickPriority)
{
/* Configure the SysTick to have interrupt in 1ms time basis*/
if (HAL_SYSTICK_Config(SystemCoreClock / (1000U / uwTickFreq)) > 0U)
{
return HAL_ERROR;
}

/* Configure the SysTick IRQ priority */
if (TickPriority < (1UL << __NVIC_PRIO_BITS))
{
HAL_NVIC_SetPriority(SysTick_IRQn, TickPriority, 0U);
uwTickPrio = TickPriority;
}
else
{
return HAL_ERROR;
}

/* Return function status */
return HAL_OK;
}

//HAL库的SYSTICK配置函数:文件stm32f4xx_hal_context.c中定义

1
2
3
4
uint32_t HAL_SYSTICK_Config(uint32_t TicksNumb)
{
return SysTick_Config(TicksNumb);
}

//内核的Systick配置函数,配置每隔ticks个systick周期中断一次//文件core_cm4.h中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
STATIC_INLINE uint32_t SysTick_Config(uint32_t ticks)
{
if ((ticks - 1UL) > SysTick_LOAD_RELOAD_Msk)
{
return (1UL); /* Reload value impossible */
}
SysTick->LOAD = (uint32_t)(ticks - 1UL); /* set reload register */
NVIC_SetPriority (SysTick_IRQn, (1UL << __NVIC_PRIO_BITS) - 1UL); /* set Priority for Systick Interrupt */
SysTick->VAL = 0UL; /* 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 (0UL); /* Function successful */
}

上面三个函数,实际上开放给HAL调用的主要是HAL_InitTick函数,该函数在HAL库初始化函数HAL_Init中会被调用。该函数通过间接调用SysTick_Config函数配置Systick定时器每隔1ms中断一次,永不停歇。

接下来我们来看看延时的逻辑控制代码:

//Systick中断服务函数:文件stm32f4xx_it.c中

1
2
3
4
void SysTick_Handler(void)
{
HAL_IncTick();
}

//下面代码均在文件stm32f4xx_hal.c中

1
2
3
4
5
6
static __IO uint32_t uwTick; //定义计数全局变量

__weak void HAL_IncTick(void)
{
uwTick += uwTickFreq;
}
1
2
3
4
__weak uint32_t HAL_GetTick(void)
{
return uwTick;
}

//开放的HAL延时函数,延时Delay毫秒

1
2
3
4
5
6
7
8
9
10
11
12
13
__weak void HAL_Delay(uint32_t Delay)
{
uint32_t tickstart = HAL_GetTick();
uint32_t wait = Delay;
/* Add a freq to guarantee minimum wait */
if (wait < HAL_MAX_DELAY)
{
wait += (uint32_t)(uwTickFreq);
}
while((HAL_GetTick() - tickstart) < wait)
{
}
}

HAL库实现延时功能非常简单,首先定义了一个32位全局变量uwTick,在Systick中断服务函数SysTick_Handler中通过调用HAL_IncTick实现uwTick值不断增加,也就是每隔1ms增加1。而HAL_Delay函数在进入函数之后先记录当前uwTick的值,然后不断在循环中读取uwTick当前值,进行减运算,得出的就是延时的毫秒数,整个逻辑非常简单也非常清晰。

但是,HAL库的延时函数有一个局限性,在中断服务函数中使用HAL_Delay会引起混乱,因为它是通过中断方式实现,而Systick的中断优先级是最低的,所以在中断中运行HAL_Delay会导致延时出现严重误差。所以一般情况下,推荐大家使用我们提供的延时函数库。

四.实验结果

将编译好的代码下载到实验板中,按下复位键,流水灯会间隔1s闪烁。

五.思考与拓展

本实验通过使用systick实现延时,还有没其他精确延时方式?和怎么实现?