江科大STM32 下

ADC数模转换器

先来看一下本节课程序的现象,本节课共有两个程序,第一个程序是AD单通道,第二个是AD多通道,第一个AD单通道,我接了一个电位器,就是滑动变阻器,用这个电位器产生一个0~3.3V连续变化的模拟电压信号,然后接到STM32的PA0口上之后,用STM32内部的ADC读取电压数据显示在屏幕上,这里屏幕第一行显示的是AD转换后的原始数据,第二行是经过处理后实际的电压值,那我们拧一下这个电位器,往左拧AD值减小,电压值也减小,AD值最小是零,对应的电压就是0V,往右AD值变大,对应电压值也变大,STM32的ADC是12位的,所以AD结果最大值是4095,也就是2^12-1对应的电压是3.3V,这就是第一个程序的现象,那对于GPIO来说,它只能读取引脚的高低电平,而使用了ADC之后呢,我们就可以对这个高电平和低电平之间的任意电压进行量化,最终用一个变量来表示,读取这个变量就可以知道引角的具体电压到底是多少,所以ADC其实就是一个电压表哈,把引脚的电压值测出来,放在一个变量里,这就是ADC的作用。

在这里插入图片描述

第二个代码是AD多通道,在这里我又接了三个传感器模块,分别是光敏电阻、热敏电阻和反射红外模块,把他们的AO、模拟电压输出端分别接在了A1、A2、A3引脚,加上刚才的电位器,总共四个输出通道,然后测出来的四个AD数据分别显示在屏幕上,我们来试一下第一个电位器,看第一行的AD0,往左拧减小,往右拧增大,和上一个程序现象一样,然后光敏电阻看第二行的AD1,遮挡一下光敏电阻,光线减小,AD值增大,光线增大,AD值减小,然后热敏电阻看第三行的AD2,用手热一下这个热敏电阻温度升高,AD值减小,移开温度降低,AD值增大,最后是反射红外传感器,手靠近有反光,AD值减小,移开没有反光,AD值增大,这就是第二个程序的现象。

在这里插入图片描述

在这里插入图片描述

ADC可以将引脚上连续变化的模拟电压,转换为内存中存储的数字变量,建立模拟电路到数字电路的桥梁,刚才通过程序现象我们也看到了,STM32主要是数字电路,数字电路只有高低电平,没有几伏电压的概念,所以如果想读取电压值,就需要借助ADC模数转换器来实现了,ADC读取引脚上的模拟电压转化为一个数据存放在寄存器里,我们再把这个数据读取到变量里来,就可以进行显示判断记录等等操作,ADC可以将模拟信号转换为数字信号,是模拟电路到数字电路的桥梁哈,反过来数字到模拟的桥梁,就是DAC数字模拟转换器,使用DAC就可以将数字变量转化为模拟电压,不过在上一节我们还学到了一个数字的模拟的桥梁,我们使用PWM来控制led的亮度,电机的速度,这就是DAC的功能,同时PWM只有完全导通和完全断开两种状态,在这两种状态上都没有功率损耗,所以在直流电机调速这种大功率的应用场景,使用PWM来等效模拟量是比DAC更好的选择,并且PWM电路更加简单,更加常用,所以可以看出PWM还是挤占了DAC的很多应用空间,目前DAC的应用主要是在波形生成这些领域,比如信号发生器,音频解码芯片等,这些领域PWM还是不好替代的哈,我们本节学习的是ADC,这个型号的STM32没有DAC的外设大家自行了解。

在这里插入图片描述

逐次逼近型这是这个ADC的工作模式。然后12位和1us的转换时间,这里就涉及到ADC的两个关键参数了,第一个是分辨率,一般用多少位来表示,12位AD值,它的表示范围就是0-2^12-1,就是量化结果的范围是0~4095。位数越高,量化结果就越精细,对应分辨率就越高;第二个是转换时间,就是转换频率,AD转换是需要花一小段时间的,这里1us就表示从AD转换开始到产生结果,需要花1us的时间,对应AD转换的频率就是1MHz,这个就是STM32 ADC的最快转换频率。如果你需要转换一个频率非常高的信号,那就要考虑一下这个转换频率是不是够用,如果你的信号频率比较低,那这个最大1MHz的转换频率也完全够用了。

在这里插入图片描述

输入电压范围0~ 3.3V ,转换结果范围0~4095,这个ADC的输入电压,一般要求都是要在芯片供电的负极和正极之间变化的啊,最低电压就是负极0伏,最高电压是正极3.3V,经过ADC转换之后,最小值就是零,最大值是4095,0V对应0,3.3V对应4095,中间都是一一对应的线性关系,这个计算起来就非常简单了,直接乘除一个系数就行了。

在这里插入图片描述

外部信号源就是16个GPIO口,在引脚上直接接模拟信号就行了,不需要任何额外的电路,引脚就直接能测电压。2个内部信号源是内部温度传感器和内部参考电压。温度传感器可以测量CPU的温度,比如你电脑可以显示一个CPU温度,就可以用ADC读取这个温度传感器来测量;内部参考电压是一个1.2V左右的基淮电压,这个基准电压是不随外部供电电压变化而变化的,所以如果你芯片的供电不是标准的3.3V,那测量外部引脚的电压可能就不对,这时就可以读取这个基准电压进行校准,这样就能得到正确的电压值了。

在这里插入图片描述

规则组和注入组两个转换单元,这个就是STM32 ADC的增强功能了。普通的AD转换流程是,启动一次转换、读一次值,然后再启动、再读值,这样的流程。但是STM32的ADC就比较高级,可以列一个组,一次性启动一个组,连续转换多个值。并且有两个组,一个是用于常规使用的规则组,一个是用于突发事件的注入组。

在这里插入图片描述

模拟看门狗自动监测输入电压范围,这个ADC,一般可以用于测量光线强度、温度这些值,并且经常会有个需求,就是如果光线高于某个阈值、低于某个阈值或者温度高于某个阈值、低于某个阈值时,执行一些操作。这个高于某个阈值、低于某个阈值的判断,就可以用模拟看门狗来自动执行。模拟看门狗可以监测指定的某些通道,当AD值高于它设定的上阈值或者低于下阈值时,它就会申请中断,你就可以在中断函数里执行相应的操作,这样你就不用不断地手动读值,再用if进行判断了。

在这里插入图片描述

他的ADC资源有ADC1、ADC2共有两个ADC外设,十个外部输入通道,也就是它最多只能测量十个外部引脚的模拟信号,我们之前这里说的16个外部信号源,这是这个系列,最多有16个外部信号源,但是我们这个芯片引脚比较少,有很多引脚没有引出来,所以就只有十个外部信号源,如果你想要更多的外部通道,可以选择引脚更多的型号,具体有多少通道呢,那还需要再参考一下数据手册。

接下来我们来了解一下这个逐次逼近型ADC到底是怎么测电压的,我们看一下这个图,这就是逐次逼近型ADC的内部结构。了解这个结构对你学习STM32的ADC有很大帮助,因为STM32的ADC原理和这个是一样的,但是STM32只画了一个框表示ADC,并没有描述内部结构,所以我们先介绍一下这个结构,这样再理解STM32的ADC就会简单一些了。

我们来看一下,这个图是ADC0809的内部结构图,它是一个独立的8位逐次逼近型ADC芯片。在以前单片性能不太好的时候,是通过外挂一个ADC芯片才能进行AD转换,这个ADC0809就是一款比较经典的ADC芯片。现在随着单片机的性能和集成度都有很大的提升,很多单片机内部就已经集成了ADC外设。这样就不用外挂芯片,引脚可以直接测电压。

在这里插入图片描述

输入选择部分:

在这里插入图片描述

首先左边这里IN0~IN7,是8路输入通道,通过通道选择开关,选中一路,输入到所标点进行转换。
下面这里是地址锁存和译码,就是你想选中哪个通道,就把通道号放在这三个脚(ADDA、ADDB、ADDC)上,然后给一个锁存信号(ALU),上面这里对应的通路开关就可以自动拨好了。这部分就相当于一个可以通过模拟信号的数据选择器。
因为ADC转换是一个很快的过程,你给个开始信号,过几个us就转换完成了。所以说如果你想转换多路信号,那不必设计多个AD转换器,只需要一个AD转换器,然后加一个多路选择开关,想转换哪一路,就先拨一下开关,选中对应通道,然后再开始转换就行了。这就是这个输入通道选择的部分,这个ADC0809只有8个输入通道,我们STM32内部的ADC是有18个输入通道的,所以对应输入电路,就是一个18路输入的多路开关

那然后输入信号选好了,到这里(所标红点)来,怎么才能知道这个电压对应的编码数据是多少呢?这就需要我们用逐次逼近的方法来——比较了

在这里插入图片描述

首先左边这是一个电压比较器,它可以判断两个输入信号电压的大小关系,输出一个高低电平指示谁大谁小。它的两个输入端,一个是待测的电压,另一个是这里DAC的电压输出端,DAC是数模转换器。我们之前说过了,给它一个数据,它就可以输出数据对应的电压,DAC内部是使用加权电阻网络来实现的转换,具体可以江科大51单片机教程里的AD/DA那一节。

那现在,我们有了一个外部通道输入的未知编码的电压,和一个DAC输出的已知编码的电压。它俩同时输入到电压比较器,进行大小判断,如果DAC输出的电压比较大,我就调小DAC数据;如果DAC输出的电压比较小,我就增大DAC数据,直到DAC输出的电压和外部通道输入的电压近似相等 ,这样DAC输入的数据就是外部电压的编码数据了,这就是DAC的实现原理。这个电压调节的过程就是这个逐次逼近SAR来完成的。

为了最快找到未知电压的编码,通常我们会使用二分法进行寻找。比如这里是8位的ADC,那编码就是从0~255。第一次比较的时候,我们就给DAC输入255的一半,进行比较,那就是128,然后看看谁大谁小,如果DAC电压大了;第二次比较的时候,再就给128的一半,64,如果还大,第三次比较的时候就给32,如果这次DAC电压小了,那第四次就给32到64中间的值,然后继续,这样依次进行下去,就能最快地找到未知电压的编码。并且这个过程,如果你用二进制来表示的话,你会发现,128、64、32这些数据,正好是二进制每一位的位权,这个判断过程就相当于是,对二进制从高位到低位依次判断是1还是0的过程,这就是逐次逼近型名字的来源。**那对于8位的ADC,从高位到低位依次判断8次就能找到未知电压的编码了,对于12位的ADC,就需要依次判断12次,**这就是逐次逼近的过程。

那然后,AD转换结束后,DAC的输入数据,就是未知电压的编码,通过右边电路进行输出,8位就有8根线,12位就有12根线。

最后上面这里EOC是end of convert,转换结束信号,start是开始转换给一个输入脉冲,clock是ADC时钟,因为ADC内部是一步一步进行判断的,所以需要时钟来推动这个过程。

下面VREF+和VREF-是DAC的参考电压,比如你给一个数据255是对应5伏还是3.3伏呢,就由这个参考电压决定,这个DAC的参考电压,也决定了ADC的输入范围,所以它也是ADC参考电压,最后左边是整个芯片电路的供电,vcc和gnd,通常参考电压的正极和vcc是一样的,会接在一起,参考电压的负极和gnd也是一样的,也接在一起,所以一般情况下adc输入电压的范围就和adc的供电是一样的。

好,到这里,相信你对逐次逼近型ADC就已经了解差不多了,接下来,我们就来看看STM32的逐次逼近型ADC,看看STM32的ADC和这个相比,有什么更高级的变化,那我们看一下STM32的这个ADC框图。

STM(32逐次逼近型)ADC电路图详解
总图:

一般在手册里,每个外设的最前面都有一个整体的结构图,这个结构图还是非常重要的,需要多花点时间看看。

在这里插入图片描述

核心的大概工作流程:
在这里插入图片描述

在这里左边是ADC的输入通,包括16个GPIO口,IN0~IN15,和两个内部的通道,一个是内部温度传感器,另一个是VREFINT(V Reference Internal),内部参考电压,总共是18个输入通道,然后到达这里,这是一个模拟多路开关,可以指定我们想要选择的通道,右边是多路开关的输出,进入到模数转换器,这里模数转换器就是刚才讲过的逐次比较的过程,转换结果会直接放在这个数据寄存器里,我们读取寄存器就能知道ADC转换的结果了,然后在这里对于普通的ADC,多路开关一般都是只选中一个的,就是选中某个通道开始转换,等待转换完成取出结果,这是普通的流程,但是这里就比较高级了,它可以同时选中多个,而且在转换的时候还分成了两个组,规则通道组和注入通道组。

注入规则组和规则通道组:

在这里插入图片描述

其中规则组可以一次性最多选16个通道,注入组最多可以选中4个通道。

比喻解释注入组和规则组:
这有什么作用呢?举个例子,这就像是你去餐厅点菜,普通的ADC是,你指定一个菜,老板给你做,然后做好了送给你;这里就是,你指定一个菜单,这个菜单最多可以填16个菜,然后你直接递个菜单给老板,老板就按照菜单的顺序依次做好,一次性给你端上菜,这样的话就可以大大提高效率。当然,你的菜单也可以只写一个菜,这样这个菜单就简化成了普通的模式了。
那对于这个菜单呢,也有两种,一种是规则组菜单,可以同时上16个菜,但是它有个尴尬的地方。就是这个规则组只有一个数据寄存器,就是这个桌子比较小,最多只能放一个菜,你如果上16个菜,那不好意思,前15个菜都会被挤掉些,你只能得到第16个菜。所以对于规则组转换来说,如果使用这个菜单的话,最好配合DMA来实现。DMA是一个数据转运小帮手,它可以在每上一个菜之后,把这个菜挪到其他地方去,防止被覆盖。这个DMA我们下一节就会讲,现在先大概了解一下,那现在我们就知道了,这个规则组虽然可以同时转换16个通道,但是数据寄存器只能存一个结果,如果不想之前的结果被覆盖,那在转换完成之后,就要尽快把结果拿走。
接着我们看一下注入组,这个组就比较高级了,它相当于是餐厅的VIP座位,在这个座位上,一次性最多可以点4个菜,并且这里数据寄存器有4个,是可以同时上4个菜的。对于注入组而言,就不用担心数据覆盖的问题了,这就是规则组和注入组的介绍。
一般情况下,我们使用规则组就完全足够了,如果要使用规则组的菜单,那就再配合DMA转运数据,这样就不用担心数据覆盖的问题了。所以接下来就只讲规则组的操作,注入组涉及的不多,大家可以看手册自行了解。

那我们接着继续看这个模数转换器外围的一些线路

在这里插入图片描述

首先,左下角这里是触发转换的部分,也就是这里的START信号,开始转换。那对于STM32的ADC,触发ADC开始转换的信号有两种,一种是软件触发,就是你在程序中手动调用一条代码,就可以启动转换了;另一种是硬件触发,就是这里的这些触发源。上面这些是注入组的触发源,下面这些是规则组的触发源,这些触发源主要是来自于定时器,有定时器的各个通道,还有TRGO定时器主模式的输出,这个之前讲定时器的时候也介绍过。定时器可以通向ADC、 DAC这些外设,用于触发转换。那因为ADC经常需要过一个固定时间段转换一次。比如每隔1ms转换一次,正常的思路就是,用定时器,每隔1ms申请一次中断,在中断里手动开始一次转换,这样也是可以的。但是频繁进中断对我们的程序是有一定影响的,比如你有很多中断都需要频繁进入,那肯定会影响主程序的执行,并且不同中断之间,由于优先级的不同,也会导致某些中断不能及时得到响应。如果触发ADC的中断不能及时响应,那我们ADC的转换频率就肯定会产生影响了。所以对于这种需要频繁进中断,并且在中断里只完成了简单工作的情况,一般都会有硬件的支持。

在这里插入图片描述

比如这里,就可以给TIM3定个1ms的时间,并且把TIM3的更新事件选择为TRGO输出,然后在ADC这里,选择开始触发信号为TIM3的TRGO,这样TIM3的更新事件就能通过硬件自动触发ADC转换了。整个过程不需要进中断,节省了中断资源,这就是这里定时器触发的作用。当然这里还可以选择外部中断引脚来触发转换,都可以在程序中配置。这就是触发转化的部分。

在这里插入图片描述

然后接着看,左上角这里是VREF+、VREF-、VDDA和VSSA。上面两个是ADC的参考电压,决定了ADC输入电压的范围;下面两个是ADC的供电引脚。一般情况下,VREF+要接VDDA,VREF-要接VSSA,在我们这个芯片上,没有VREF+和VREF-的引脚,它在内部就已经和VDDA和VSSA接在一起了。查看引脚定义,VDDA和VSSA是内部模拟部分的电源(前面讲过分区供电),比如ADC、RC振荡器、锁相环等。在这里VDDA接3.3V, VSSA接GND,所以ADC的输入电压范围就是0~3.3V。

在这里插入图片描述

然后继续看右边这里是ADCCLK是ADC的时钟,也就是这里的CLOCK,是用于驱动内部逐次比较的时钟。这个ADCCLK是来自ADC预分频器,而ADC预分频器是来源于RCC的。我们找一下:
在这里插入图片描述

APB2时钟72MHZ,然后通过ADC预分频器进行分频,得到ADCCLK,ADCCLK最大是14MHZ,所以这个预分频器就有点尴尬。它可以选择2、4、6、8分频,如果选择2分频,72M/2=36M,超出允许范围了;4分频之后是18M,也超了,所以对于ADC预分频器只能选择6分频,结果是12M,和8分频,结果是9M,这两个值。这个在程序里要注意一下

在这里插入图片描述

继续看上面这里是DMA请求,这个就是用于触发DMA进行数据转运的,我们下节再讲。
在这里插入图片描述

然后是两个数据寄存器,用于存放转换结果的,上面这里还有模拟开门狗,它里面可以存一个阈值高线和阈值低线,如果启动了模拟看门狗,并且指定了看门的通道,那个看门狗就会关注他开门的通道,一旦超过这个阈值范围了,它就会乱叫啊,就会在上面申请一个模拟看门狗的中段,最后通向NVIC,然后对于规则组和注入组而言呢,它们转换完成之后也会有一个EOC转换完成的信号,在这里EOC是规则组的完成信号,JEOC是注入组完成的信号,这两个信号会在状态寄存器里置一个标志位,我们读取这个标志位就能知道是不是转换结束了,同时这两个标志位也可以去到NVIC申请中断,如果开启了NVIC对应的通道,它们就会触发中断。

好,有关ADC的这个框图,我们就介绍完了。

ADC基本结构
那接下来就来看一下我这里总结的一个ADC基本结构图,再来回忆一下。

在这里插入图片描述

左边是输入通道,16个GPIO口,外加两个内部的通道,然后进入AD转换器。AD转换器里有两个组,一个是规则组,一个是注入组,规则组最多可以选中16个通道,注入组最多可以选择4个通道。然后转换的结果可以存放在AD数据寄存器里,其中规则组只有1个数据寄存器,注入组有4个。
然后下面这里有触发控制,提供了开始转换这个START信号,触发控制可以选择软件触发和硬件触发。硬件触发主要是来自于定时器,当然也可以选择外部中断的引脚,右边这里是来自于RCC的ADC时钟CLOCK,ADC逐次比较的过程就是由这个时钟推动的。
然后上面,可以布置一个模拟看门狗用于监测转换结果的范围,如果超出设定的阈值,就通过中断输出控制,向NVIC申请中断,另外,规则组和注入组转换完成后会有个EOC信号,它会置一个标志位,当然也可以通向NVIC。最后右下角这里还有个开关控制,在库函数中,就是ADC_Cmd函数,用于给ADC上电的,那这些,就是STM32 ADC的内部结构了。

接下来我们再了解一些细节的问题。
首先看一下输入通道,刚才我们说了,这里有16个外部通道,那这16个通道对应的都是哪些GPIO口呢,我们就可以看一下这个表,这些就是ADC通道和引脚复用的关系,这个对应关系也可以通过引脚定义表看出来。

在这里插入图片描述

在这里插入图片描述

这个对应关系也可以通过引脚定义表看出来,我们看一下,在这里可以看到ADC_IN0对应的是PA0引脚,IN1对应PA1引脚,然后IN2、IN3、IN4、IN5、IN6、IN7、IN8、IN9,依次对应的是PA2到PB1,这里只有IN0~IN9,总共只有十个通道,然后其他地方就没有了,所以这个芯片就只能有十个外部输入通道,然后ADC_IN0的意思是,ADC1和ADC2 的IN0都是在PA0上的,然后下面全都是ADC12,这说明ADC1和ADC12的引脚全都是相同的。

ADC1和ADC2的引脚全都是相同的,既然都相同,那要ADC2还有啥用呢。这个就要再说一个ADC的高级功能了,就是双ADC模式,这个模式比较复杂。这里只简单介绍一下,不需要掌握。双ADC模式就是ADC1和ADC2一起工作,它俩可以配合组成同步模式、交叉模式等等模式。比如交叉模式,ADC1和ADC2交叉地对一个通道进行采样,这样就可以进一步提高采样率。当然ADC1和ADC2也是可以分开使用的,可以分别对不同的硬件而进行采样,这样也是可以的。

规则组的4种转换模式
接下来,我们再来了解一下规则组的4种转换模式,分别是单次转换,非扫描模式和连续转换,扫描模式等。那在我们ADC初始化的结构体里,会有两个参数,一个是选择单次转换还是连续转换的,另一个是选择扫描模式还是非扫描模式的,这两个参数组合起来,就有这4种转换方式。我们来逐一看一下。

在这里插入图片描述

第一种,单次转换,非扫描模式,最简单,这里我画了一个列表,这个表就是规则组里的菜单,有16个空位,分别是序列1到序列16,你可以在这里“点菜”,就是写入你要转换的通道,在非扫描的模式下,这个菜单就只有第一个序列1的位置有效,这时,菜单同时选中一组的方式就退化为简单地选中一个的方式了。在这里我们可以在序列1的位置指定我们想转换的通道,比如通道2,写到这个位置。然后,我们就可以触发转换,ADC就会对这个通道2进行模数转换,过一小段时间后,转换完成,转换结果放在数据寄存器里,同时给EOC标志位置1,整个转换过程就结束了。我们判断这个EOC标志位,如果转换完了, 那我们就可以在数据寄存器里读取结果了。如果我们想再启动一次转换,那就需要再触发一次,转换结束,置EOC标志位,读结果。如果想换一个通道转换,那在转换之前,把第一个位置的通道2改成其他通道,然后再启动转换,这样就行了。这就是单次转换,非扫描的转换模式。没有用到这个菜单列表,也是比较简单的一种模式

在这里插入图片描述

接下来我们看一下连续转换,非扫描模式。首先,它还是非扫描模式,所以菜单列表就只用第一个,然后它与上一种单次转换不同的是,它在一次转换结束后不会停止,而是立刻开始下一轮的转换,然后一直持续下去。这样就只需要最开始触发一次,之后就可以一直转换了。这个模式的好处就是,开始转换之后不需要等待一段时间的,因为它一直都在转换,所以你就不需要手动开始转换了,也不用判断是否结束的,想要读AD值的时候,直接从数据寄存器取就是了。这就是连续转换,非扫描的模式

在这里插入图片描述

然后继续看,单次转换,扫描模式。这个模式也是单次转换,所以每触发一次,转换结束后,就会停下来,下次转换就得再触发才能开始。然后它是扫描模式,这就会用到这个菜单列表了,你可以在这个菜单里点菜,比如第一个菜是通道2,第二个菜是通道5,等等等等,这里每个位置是通道几可以任意指定,并且也是可以重复的,然后初始化结构体里还会有个参数,就是通道数目。因为这16个位置你可以不用完,只用前几个,那你就需要再给一个通道数目的参数,告诉它,我有几个通道。比如这里指定通道数目为7,那它就只看前7个位置,然后每次触发之后,它就依次对这前7个位置进行AD转换,转换结果都放在数据寄存器里,这里为了防止数据被覆盖,就需要用DMA及时将数据挪走。那7个通道转换完成之后,产生EOC信号,转换结束,然后再触发下一次,就又开始新一轮的转换,这就是单次转换,扫描模式的工作流程。

在这里插入图片描述

那最后再看一下连续转换,扫描模式。它就是在上一个模式的基础上,变了一点,就是一次转换完成后,立刻开始下一次的转换。和上面这里非扫描模式的单次和连续是一个套路,这就是连续转换,扫描模式。

当然在扫描模式的情况下,还可以有一种模式,叫间断模式。它的作用是,在扫描的过程中,每隔几个转换,就暂停一次,需要再次触发,才能继续。这个模式没有列出来,要不然模式太多了。大家了解一下就可以了,暂时不需要掌握,好,这些就是STM32 ADC的4种转换模式。

几个小知识点细节:
触发控制

在这里插入图片描述

这个表就是规则组的触发源,也就是ADC总框图中的ADC。在这个表里,有来自定时器的信号;还有这个来自引脚或定时器的信号,这个具体是引脚还是定时器,需要用AFIO重映射来确定;最后是软件控制位,也就是我们之前说的软件触发。这些触发信号怎么选择,可以通过设置右边这个寄存器来完成,当然使用库函数的话,直接给一个参数就行了,这就是触发控制。

数据对齐
在这里插入图片描述

我们这个ADC是12位的,它的转换结果就是一个12位的数据,但是这个数据寄存器是16位的,所以就存在一个数据对齐的问题,这里第一种是数据右对齐,就是12位的数据向右靠高位多出来的几位就补零,第二种是数据左对齐,是12位的数据向左靠,低位多出来的补零,在这里我们一般使用的都是第一种右对齐,这样读取这个16位寄存器,直接就是转换结果,如果选择左对齐直接读的话,得到的数据会比实际的大,因为数据左对齐实际上就是把数据左移了四次,二进制有个特点,就是数据左移一次,就等效于把这个数据乘二,那这里左移四次,就相当于把结果乘16了,所以直接读的话会,会比实际值大16倍。

那要这个左对齐有啥用呢,这个用途就是如果你不想要这么高的分辨率,你觉得0~4095数太大了,我就做个简单的判断,不需要这么高分辨率,那你就可以选择左对齐,然后再把这个数据的高八位取出来,这样就舍弃掉了后面四位的精度,这个12位的ADC就退化成了八位的ADC,这就是左对齐的作用,不过我们一般用的话,选右对齐就行了哈,如果需要裁减一些分辨率,大不了就先把12位都取出来再做处理,这也是可以的哈,就是多算了一步而已。

转换时间
在这里插入图片描述

这个大概讲一下,不过转换时间这个参数,我们一般不太敏感,因为一般AD转换都很快,如果不需要非常高速的转换频率,那转换时间就可以忽略了。
我们来看一下,之前我们说了,AD转换是需要一小段时间的,就像厨子做菜一样,也是需要等一会儿才能上菜的,那AD转换的时候都有哪些步骤需要花时间呢?AD转换的步骤,有4步,分别是采样,保持,量化,编码,其中采样保持可以放在一起,量化编码可以放在一起,总共是这两大步。量化编码好理解,就是我们之前讲过的,ADC逐次比较的过程,这个是要花一段时间的,一般位数越多,花的时间就越长。

那采样保持是干啥的呢?这个我们前面这里并没有涉及,为什么需要采样保持呢?这是因为,我们的AD转换,就是后面的量化编码,是需要一小段时间的,如果在这一小段时间里,输入的电压还在不断变化,那就没法定位输入电压到底在哪了,所以在量化编码之前,我们需要设置一个采样开关。先打开采样开关,收集一下外部的电压,比如可以用一个小容量的电容存储一下这个电压,存储好了之后,断开采样开关,再进行后面的AD转换。这样在量化编码的期间,电压始终保持不变,这样才能精确地定位未知电压的位置,这就是采样保持电路。

那采样保持的过程,需要闭合采样开关,过一段时间再断开,这里就会产生一个采样时间。那回到这里,我们就得到了第二条,STM32 ADC的总转换时间为TCONV=采样时间+12.5个ADC周期,采样时间是采样保持花费的时间,这个可以在程序中进行配置,采样时间越大,越能避兔一些毛刺信号的干扰,不过转换时间也会相应延长。12.5个ADC周期是量化编码花费的时间,因为是12位的ADC,所以需要花费12个周期,这里多了半个周期,可能是做其他一些东西花的时间。ADC周期就是从RCC分频过来的ADCCLK,这个ADCCLK最大是14MHz。

所以下面有个例子,这里就是最快的转换时间,当ADCCLK=14MHz,采样时间为1.5个ADC周期,TCONV = 1.5 +12.5 = 14个ADC周期,在14MHz ADCCLK的情况下就 = 1us,这就是转化时间最快1us时间的来源。如果你采样周期再长些,它就达不到1us了;另外你也可以把ADCCLK的时钟设置超过14MHz,这样的话ADC就是在超频了,那转换时间可以比1us还短,不过这样稳定性就没法保证了。

校准
在这里插入图片描述

这个看上去挺复杂,但是我们不需要理解,这个校准过程是固定的。我们只需要在ADC初始化的最后,加几条代码就行了,至于怎么计算、怎么校准的,我们不需要管。

ADC外围电路设计
对于ADC的外围电路,我们应该怎么设计呢?

在这里插入图片描述

这里我给出了三个电路图,第一个是电位器产生一个可调的电压,这里电位器的两个固定端,一端接3.3V,另一端接GND,这样中间的滑动端就可以输出一个0~3.3伏可调的电压输出来,我们这里可以接ADC的输入通道,比如PA0口,当滑动端往上滑时,电压增大,往下滑时电压减小,另外注意一下这个电阻的阻值啊,不要给太小,因为这个电阻两端也是直接跨接在电源正负极的,如果阻值太小,那这个电阻就会比较费电,再小就有可能发热冒烟了,一般至少要接千欧级的电阻啊,比如这里接的是10k的电路,这是电位器产生可调电压的电路。

中间第二个是传感器输出电压的电路,一般来说像光敏电阻,热敏电阻,红外接收管,麦克风等等,都可以等效为一个可变电阻,那电阻阻值没法直接测量,所以这里就可以通过和一个固定电阻串联分压,来得到一个反应电阻值电压的电路,那这里传感器阻值变小时下拉作用变强,输出端电压就下降,传感器阻值变大时下拉作用变弱,输出端受上拉电阻的作用,电压就会升高,这个固定电阻一般可以选择和传感器阻值相近的电阻,这样可以得到一个位于中间电压区域比较好的输出,但这里传感器和固定电阻的位置也可以换过来,这样的话输出电压的极性就翻过来了,这就是这个分压方法来输出传感器阻值的电路。

最后这个电路,这是一个简单的电压转换电路,比如你想测个0~ 5V的VIN电压,但是ADC只能接收0~ 3.3伏的电压,那就可以搭建一个这样的简易转换电路,在这里还是使用电阻进行分压,上面阻值17k,下面阻值33k加一起是50k,所以根据分压公式,中间的电压就是VIN/50Kx33k,最后得到的电压范围就是0~3.3就可以进入ADC转换了,这就是这个简单的电压转换电路。如果你想采集5V,10V这些电压的话,可以使用这个电压转换电路,但是如果你电压再高一些,就不建议使用这个电路了,那可能会比较危险。高电压采集最好使用一些专用的采集芯片,比如隔离放大器等等,做好高低电压的隔离,保证电路的安全。

手册粗讲

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

最后就是一个寄存器的总表,这里有所有的寄存器,中间有这些寄存器的复位值,也就是说上电复位后,寄存器都会变成复位值,这里复位值对应的就是各个外设的默认配置,比如GPIO口上电后默认配置为浮空输入的模式,输出数据寄存器默认输出低电平等等,如果你想了解上电后的默认配置,就可以参考这里的寄存器默认值,再对照相应的寄存器描述,这样就能知道默认配置了,这个寄存器一般上电都默认全为零哈,不过也有的不是,你大家可以找找看。

示例代码(AD单通道&AD多通道)

AD单通道,看一下这个接线图还是比较简单,在这里我们接了一个电位器,这个电位器有三个引脚,分别插在这三排孔里,电位器的内部结构是这样的,左边和右边的两个引脚,接的是电阻的两个固定端,中间这个引脚接的是滑动抽头,电位器外边这里有个十字形状的槽可以拧,往左拧抽头就往左靠,往右拧抽头就往右靠,我们把左边的固定端接在负极,右边的固定端接在正极,中间就可以输出从负极到正极可调到电压了,然后右边这里我们把可调的电压输出接在PA0口,在这里根据引脚定义表,PA0到PB7这十个引脚是ADC的十个通道,所以这十个硬件你可以任意选,接在哪个都行,但是其他的这些引脚不是ADC的通道,就不能接模拟电压了。
在这里插入图片描述

7-1 AD单通道
程序现象:在面包板的中间,也就是芯片左边接了一个电位器,就是滑动变阻器。用这个电位器产生一个0~3.3V连续变化的模拟电压信号。然后接到STM32的PA0口上,之后用STM32内部的ADC读取电压数据,显示在屏幕上。这里屏幕第一行显示的是AD转换后的原始数据,第二行是经过处理后实际的电压值。电位器往左拧,AD值减小,电压值也减小,AD值最小是0,对应的电压就是0V;反之同理STM32的ADC是12位的,所以AD结果最大值是4095,也就是2^12-1,对应的电压是3.3V。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

我们把这个结构打通,在原理上能够让这个外设运转起来,那该怎么配置,自己心里就应该有数了。

第一步,开启RCC时钟,包括ADC和GPIO的时钟,另外这里ADCCLK的分频器,也需要配置一下

在这里插入图片描述

在这里插入图片描述

第二步,配置GPIO。把需要用的GPIO配置成模拟输入的模式

第三步,配置这里的多路开关。把左边的通道接入到右边的规则组列表里。这个过程就是我们之前说的点菜,把各个通道的菜,列在菜单里

在这里插入图片描述

在这里插入图片描述

第四步,就是配置ADC转换器了。在库函数里,是用结构体来配置的,可以配置这一大块电路的参数。包括ADC是单次转换还是连续转换、扫描还是非扫描、有几个通道,触发源是什么,数据对齐是左对齐还是右对齐。

在这里插入图片描述

如果你需要模拟看门狗,那会有几个函数用来配置阈值和监测通道的,如果你想开启中断,那就在中断输出控制里用ITConfig函数开启对应的中断输出,然后再在NVIC里,配置一下优先级,这样就能触发中断了。不过这一块,模拟看门狗和中断,我们本节暂时不用,如果你需要的话,可以自己配置试一下。

接下来,就是开关控制,调用一下ADC_Cmd函数,开启ADC,这样ADC就配置完成了,就能正常工作了。
在这里插入图片描述

当然,在开启ADC之后,根据手册里的建议,我们还可以对ADC进行一下校准,这样可以减小误差,那在ADC工作的时候,如果想要软件触发转换,那会有函数可以触发,如果想读取转换结果,那也会有函数可以读取结果,这个等会儿介绍库函数的时候就可以看到了。

这里有四个函数,对应校准的四个步骤:第一步,调用第一个函数ADC_ResetCalibration,复位校准;第二步,调用第二个函数ADC_GetResetCalibrationStatus,等待复位校准完成;第三步,调用第三个函数ADC_StartCalibration,开始校准;第四步,调用第四个函数ADC_GetCalibrationStatus,等待校准完成。

在这里插入图片描述

如果想要软件触发转换,那会有函数可以触发。如果想读取转换结果,那也会有函数可以读取结果,这个等会儿介绍库函数的时候就可以看到了。好,这些就是我们程序的大概思路了。
首先,软件触发转换;然后等待转换完成,也就是等待EOC标志位置1;最后,读取ADC数据寄存器,就完事了。

在这里插入图片描述

AD.h

#ifndef __AD_H
#define __AD_H

void AD_Init(void);
uint16_t AD_GetValue(void);

#endif

AD.c

#include "stm32f10x.h"                  // Device header

/**
  * 函    数:AD初始化
  * 参    数:无
  * 返 回 值:无
  */
void AD_Init(void)
{
	/*开启时钟*/
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);	//开启ADC1的时钟
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);	//开启GPIOA的时钟
	
	/*设置ADC时钟*/
	RCC_ADCCLKConfig(RCC_PCLK2_Div6);						//选择时钟6分频,ADCCLK = 72MHz / 6 = 12MHz
	
	/*GPIO初始化*/
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;			//模拟输入,在AIN模式下GPIO口是无效的,断开GPIO防止GPIO口的输入输出对模拟电压造成干扰
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);					//将PA0引脚初始化为模拟输入
	
	/*规则组通道配置*/
	ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5);		//规则组序列1的位置,配置为通道0  通道采样时间随便选的55.5个ADCCLK的周期(需要更快的转换就选择小的参数,反之需要更稳定的转换就..)
	//目前只有PA0一个通道,使用的非扫描模式,所以指定的通道就放在第一个序列1的位置
	
	/*ADC初始化*/
	ADC_InitTypeDef ADC_InitStructure;						//定义结构体变量
	ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;		//模式,选择独立模式(还有双ADC模式),即单独使用ADC1
	ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;	//数据对齐,选择右对齐
	ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;	//外部触发转换选择,使用软件触发,不需要外部触发
	//单次转换非扫描的模式
	ADC_InitStructure.ADC_ContinuousConvMode = DISABLE;		//连续转换,失能,每转换一次规则组序列后停止
	ADC_InitStructure.ADC_ScanConvMode = DISABLE;			//扫描模式,失能,只转换规则组的序列1这一个位置
	ADC_InitStructure.ADC_NbrOfChannel = 1;					//通道数,为1,仅在扫描模式下,才需要指定大于1的数,在非扫描模式下,只能是1
	ADC_Init(ADC1, &ADC_InitStructure);						//将结构体变量交给ADC_Init,配置ADC1

	// 中断和模拟看门狗,如果需要可在此处继续配置
	
	/*ADC使能*/
	ADC_Cmd(ADC1, ENABLE);									//使能ADC1,ADC开始运行
	
	/*ADC校准*/  	//固定流程,内部有电路会自动执行校准
	ADC_ResetCalibration(ADC1);								//复位校准	
	while (ADC_GetResetCalibrationStatus(ADC1) == SET);		//返回复位校准的状态
	ADC_StartCalibration(ADC1);								//开始校准
	while (ADC_GetCalibrationStatus(ADC1) == SET);
}

/**
  * 函    数:获取AD转换的值
  * 参    数:无
  * 返 回 值:AD转换的值,范围:0~4095
  */
uint16_t AD_GetValue(void)
{
	ADC_SoftwareStartConvCmd(ADC1, ENABLE);					//软件触发AD转换一次(如果是连续转换模式,只需要触发一次就可以了)
	while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) == RESET);	//等待EOC标志位,即等待AD转换结束
	return ADC_GetConversionValue(ADC1);					//读数据寄存器,得到AD转换的结果
}

main.c

#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "AD.h"

uint16_t ADValue;			//定义AD值变量
float Voltage;				//定义电压变量

int main(void)
{
	/*模块初始化*/
	OLED_Init();			//OLED初始化
	AD_Init();				//AD初始化
	
	/*显示静态字符串*/
	OLED_ShowString(1, 1, "ADValue:");
	OLED_ShowString(2, 1, "Voltage:0.00V");
	
	while (1)
	{
		ADValue = AD_GetValue();					//获取AD转换的值
		Voltage = (float)ADValue / 4095 * 3.3;		//将AD值线性变换到0~3.3的范围,表示电压
		
		OLED_ShowNum(1, 9, ADValue, 4);				//显示AD值
		OLED_ShowNum(2, 9, Voltage, 1);				//显示电压值的整数部分
		OLED_ShowNum(2, 11, (uint16_t)(Voltage * 100) % 100, 2);	//显示电压值的小数部分(注意浮点数不能取余,所以先强制类型转换成uint16_t)
		
		Delay_ms(100);			//延时100ms,手动增加一些转换的间隔时间
	}
}

这一位由软件清除或由读取ADC_DR时清除,ADC_DR是数据计算器,一般EOC标志位置1,我们就会来读取数据,所以它就多设计了一个功能,就是这一位可以在读取数据寄存器之后自动清除,就不需要你再手动清除了,可以省一条代码,这就是这个标志位,当它为0时,表示转换未完成,为1表示转换完成。
在这里插入图片描述
所以在这里当EOC标志位等于等于reset时,转化未完成,while条件为真执行空循环,转换完成后
EOC由硬件自动置1,那while循环就自动跳出来,这样就是等待转换完成的代码,那具体会等待多长时间,我们刚才配置的时候,指定了这个通道的采样周期是55.5,转换周期是固定的12.5,加在一起就是68个周期,前面我们配置的ADCCLK是72MHZ的六分频就是12MHz,12MHz进行68个周期转换才能完成,最终的时间可以算一下,就是1/12MHz x 68,结果大概是5.6us,这个我们上一小节讲转换时间的时候,也讲过,所以这个while循环,大概会等待5.6微秒,这就是等待的时间,那等待完成之后
我们就可以取结果了。

实验发现AD值的末尾会有一些抖动,这是正常的波动,如果你想对这个值进行判断,再执行一些操作,比如光线的AD值小于某一阈值就开灯,大于某一阈值就关灯,那可能会存在这样的情况,比如光线逐渐变暗,AD值逐渐变小,但是由于波动,AD值会在判断阈值附近来回跳变,这会导致输出产生抖动,来回开灯关灯,那如何避免这种情况呢,这个可以使用迟滞比较的方法来完成,设置两个阈值,低于下阈值时开灯,高于上阈值时关灯,这就可以避免输出抖动的问题了,这个跟我们GPIO那一节讲的施密特触发器是一个原理,另外如果你觉得数据跳变太厉害,还可以采用滤波的方法,让AD值平滑一些,比如均值滤波啊,就是读取十个或20个值,取平均值作为滤波的AD值,或者还可以裁剪分辨率,把数据的尾数去掉,这样也可以减少数据波动,这都是可行的方法,大家实际遇到这方面问题的话
可以考虑一下。

7-2 AD多通道

在这里我们使用了4个AD通道,第一个通道还是电位器,接在PA0口之后,上面又接了三个传感器模块,分别是光敏传感器、热敏传感器、反射式红外传感器,他们的VCC和GND都分别接在面包板的正负极,然后这个AO就是模拟量的输出引脚,三个模块的AO分别接在PA1、PA2和PA3,加上电位器的PA0 总共是四个输入通道,同样在这些GPIO口也是可以在PA0到PB1之间任意选择的,我这里就选择前四个了。
在这里插入图片描述

如何实现多通道呢?
我们首先想到的应该是后面这两种扫描模式(连续转换、扫描模式和单次转换、扫描模式),利用这个列表把四个通道都填进去,然后触发转换,这样就能实现多通道了,这样确实是一种不错的方法,但是还是那个数据覆盖的问题,如果想要用扫描模式实现多通道,最好要配合DMA来实现,我们下节讲完DMA之后,再来试一下扫描模式。

那你可能会问
我们一个通道转换完成之后,手动把数据转运出来不就行了,为啥非要用DMA来转运呢,这个方案看似简单,但是实际操作起来会有一些问题,第一个问题就是在扫描模式下,你启动列表之后,它里面每一个单独的通道转换完成之后,不会产生任何的标志位,也不会触发中断,你不知道某一个通道是不是转,换完了,它只有在整个列表都转换完成之后,才会产生一次EOC标志位才能触发中断,而这时前面的数据就已经覆盖丢失了,第二个问题就是AD转换是非常快的,刚才我们也计算过,转换一个通道大概只有几微秒,也就是说,如果你不能在几微秒的时间内把数据转运走,那数据就会丢失,这对我们程序手动转运数据,要求就比较高了,所以在扫描模式下,手动转移数据是比较困难的哈,不过比较困难也不是说手动转运不可行啊,我们可以使用间断模式,在扫描的时候每转换一个通道就暂停一次,等我们手动把数据转运走之后,再继续触发下一次转换,这样可以实现手动转移数据的功能哈,但是由于单个通道转换完成之后,没有标注位,所以启动转换完成之后,只能通过delay延时的方式,延时足够长的时间,才能保证转换完成,这种方式既不,能让我们省心,也不能提高效率,所以我们暂时不推荐使用。

那这些方法都不行,我们本节是不是就不能实现多通道了呢,答案是能实现,而且非常简单,怎么实现呢,我们可以使用上面的这个单次转换非扫描的模式来实现多通道,只需要在每次触发转换之前,手动更改一下列表第一个位置的通道就行了,比如第一次转换先写入通道0之后触发、等待、读值,第二次转换,再把通道0改成通道1之后触发、等待、读值等等,这样在转换前先指定一下通道,再启动转换,就可以轻松地实现多通道转换的功能。代码这里也只需做一些简单的修改就行了。

AD.h

#ifndef __AD_H
#define __AD_H

void AD_Init(void);
uint16_t AD_GetValue(uint8_t ADC_Channel);

#endif

AD.c

#include "stm32f10x.h"                  // Device header

/**
  * 函    数:AD初始化
  * 参    数:无
  * 返 回 值:无
  */
void AD_Init(void)
{
	/*开启时钟*/
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);	//开启ADC1的时钟
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);	//开启GPIOA的时钟
	
	/*设置ADC时钟*/
	RCC_ADCCLKConfig(RCC_PCLK2_Div6);						//选择时钟6分频,ADCCLK = 72MHz / 6 = 12MHz
	
	/*GPIO初始化*/
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);					//将PA0、PA1、PA2和PA3引脚初始化为模拟输入
	
	/*不在此处配置规则组序列,而是在每次AD转换前配置,这样可以灵活更改AD转换的通道*/
	
	/*ADC初始化*/
	ADC_InitTypeDef ADC_InitStructure;						//定义结构体变量
	ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;		//模式,选择独立模式,即单独使用ADC1
	ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;	//数据对齐,选择右对齐
	ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;	//外部触发,使用软件触发,不需要外部触发
	ADC_InitStructure.ADC_ContinuousConvMode = DISABLE;		//连续转换,失能,每转换一次规则组序列后停止
	ADC_InitStructure.ADC_ScanConvMode = DISABLE;			//扫描模式,失能,只转换规则组的序列1这一个位置
	ADC_InitStructure.ADC_NbrOfChannel = 1;					//通道数,为1,仅在扫描模式下,才需要指定大于1的数,在非扫描模式下,只能是1
	ADC_Init(ADC1, &ADC_InitStructure);						//将结构体变量交给ADC_Init,配置ADC1
	
	/*ADC使能*/
	ADC_Cmd(ADC1, ENABLE);									//使能ADC1,ADC开始运行
	
	/*ADC校准*/
	ADC_ResetCalibration(ADC1);								//固定流程,内部有电路会自动执行校准
	while (ADC_GetResetCalibrationStatus(ADC1) == SET);
	ADC_StartCalibration(ADC1);
	while (ADC_GetCalibrationStatus(ADC1) == SET);
}

/**
  * 函    数:获取AD转换的值
  * 参    数:ADC_Channel 指定AD转换的通道,范围:ADC_Channel_x,其中x可以是0/1/2/3
  * 返 回 值:AD转换的值,范围:0~4095
  */
uint16_t AD_GetValue(uint8_t ADC_Channel)
{
	ADC_RegularChannelConfig(ADC1, ADC_Channel, 1, ADC_SampleTime_55Cycles5);	//在每次转换前,根据函数形参灵活更改规则组的通道1 我们要指定的通道是0、1、2、3,对应上面的GPIO 0、1、2、3
	ADC_SoftwareStartConvCmd(ADC1, ENABLE);					//软件触发AD转换一次
	while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) == RESET);	//等待EOC标志位,即等待AD转换结束
	return ADC_GetConversionValue(ADC1);					//读数据寄存器,得到AD转换的结果
}

main.c

#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "AD.h"

uint16_t AD0, AD1, AD2, AD3;	//定义AD值变量

int main(void)
{
	/*模块初始化*/
	OLED_Init();				//OLED初始化
	AD_Init();					//AD初始化
	
	/*显示静态字符串*/
	OLED_ShowString(1, 1, "AD0:");
	OLED_ShowString(2, 1, "AD1:");
	OLED_ShowString(3, 1, "AD2:");
	OLED_ShowString(4, 1, "AD3:");
	
	while (1)
	{
		AD0 = AD_GetValue(ADC_Channel_0);		//单次启动ADC,转换通道0
		AD1 = AD_GetValue(ADC_Channel_1);		//单次启动ADC,转换通道1
		AD2 = AD_GetValue(ADC_Channel_2);		//单次启动ADC,转换通道2
		AD3 = AD_GetValue(ADC_Channel_3);		//单次启动ADC,转换通道3
		
		OLED_ShowNum(1, 5, AD0, 4);				//显示通道0的转换结果AD0
		OLED_ShowNum(2, 5, AD1, 4);				//显示通道1的转换结果AD1
		OLED_ShowNum(3, 5, AD2, 4);				//显示通道2的转换结果AD2
		OLED_ShowNum(4, 5, AD3, 4);				//显示通道3的转换结果AD3
		
		Delay_ms(100);			//延时100ms,手动增加一些转换的间隔时间
	}
}

DMA直接存储器存取

实验现象:

DMA数据转运
在这个程序里,我们将使用DMA进行存储器到存储器的数据转运,也就是把一个数组里面的数据复制到另一个数组里,先看一下最终的代码,这里我定义了一个数组,DataA里面存的是0x01、0x02、0x03、0x04,作为待转运的源数据,然后下面再定义一个数组DataB,里面存的是4个0,作为转运数据的目的地,之后我们将会写一个模块叫MyDMA,进行初始化,把原数组和目的数组的地址传进去,再传入转运数据的长度,使用DMA进行数据转运,和直接for循环,使用CPU一个个手动的转运数据效果是一样的,最后再显示一下DataA和DataB,看一下数据是不是从DataA转运到了DataB。

这里只定义了四个数据,演示一下现象,也可以定义100个1000个等等数据,然后使用DMA来进行转运都是可以的。

在这里插入图片描述

DMA+AD多通道

看第二个程序,DMA+AD多通道,这个程序就是我们上一节预告过的,使用ADC的扫描模式来实现多通道采集,然后使用DMA来进行数据转运,最终AD转换的数据就会像这样直接自动的跑到我们定义的数组里面来,之后我们就只需要用OLED显示一下就行了,看上去还是非常方便的。

在这里插入图片描述

在这里插入图片描述

DMA这个外设是可以直接访问STM32内部的存储器的,包括运行内存SRAM、程序存储器FLASH和寄存器等等,DMA都有权限访问他们,所以DMA才能完成数据转运的工作。

DMA可以提供外设和存储器或者存储器和存储器之间的高速数据传输,无需CPU干预,节省了CPU的资源,这里外设指的就是外设寄存器,一般是外设的数据寄存器器DR, Data Register,比如ADC的数据寄存器,串口的数据寄存器等等,这里存储器指的就是运行内存SRAM和程序存储器flash,是我们存储变量数组和程序代码的地方,在外设和存储器或者存储器和存储器之间进行数据转运,就可以使用DMA来完成,并且在转运的过程中无需CPU的参与,节省了CPU的资源,CPU省下时间就可以干一些其他的更加专业的事,情搬运数据这种杂活,交给DMA就行了。

12个独立可配置的通道,这个通道就是数据转移的路径,从一个地方移动到另一个地方,就需要占用一个通道,如果有多个通道进行转运,那它们之间可以各转各的,互不干扰,这就是DMA的通道。

每个通道都支持软件触发和特定的硬件触发,这里如果执行的是存储器到存储器的转运,比如我们想把flash里的一批数据转运到SRAM里去,那就需要软件触发,使用软件触发之后,DMA就会一股脑的把这批数据以最快的速度全部转运完成,这也是我们想要的效果哈,那如果DMA进行的是外设到存储器的数据转运,就不能一股脑的转运了,因为外设的数据是有一定时机的,所以这时我们就需要用硬件触发,比如转运ADC的数据,那就得ADC每个通道AD转换完成后,硬件触发一次DMA,之后DMA再转运,触发一次,转运一次,这样数据才是正确的,才是我们想要的效果,所以存储器到存储器的数据转运,我们一般使用软件触发,外设到存储器的数据转运,我们一般使用硬件触发,那这里我写的是特定的硬件触发,意思就是每个DMA的通道,它的硬件触发源是不一样的,你要使用某个外设的硬件触发源,就得使用它连接的那个通道,而不能任意选择通道,这个我们等会儿再详细分析。

C8T6的DMA资源是DMA1(7个通道),我们这个芯片只有DMA1的7个通道,没有DMA2,这个注意看一下数据手册。

在这里插入图片描述

我们来看一下STM32的存储器映象,既然DMA是在存储器之间进行数据转运的,那我们就应该要了解一下STM32中都有哪些存储器,这些存储器又是被安排到了哪些地址上,这就是存储器映象的内容,那我们知道计算机系统的五大组成部分是运算器、控制器、存储器、输入设备和输出设备,其中运算器和控制器一般会合在一起,叫做CPU,所以计算机的核心关键部分就是CPU和存储器,存储器又有两个重要知识点,一个是存储器的内容,另一个就是存储器的地址,那STM32也不例外,这个表就是STM32中所有类型的存储器和他们所被安排的地址,在STM32 的数据手册,这里也会有个存储器印象的图,如下所示,我这个表就是从这个图里总结出来的,都是一个意思。

在这个表里,无论是flash还是SRAM还是外设寄存器,它们都是存储器的一种,包括外设寄存器,实际上也是存储器,我们前面这里说的是外设到存储器,存储器到存储器,本质上其实都是存储器之间的数据转运,说成外设的存储器,只不过是STM32 它特别指定了可以转运外设的存储器而已,这个了解一下。

存储器总共分成两大类,rom和ram,rom就是只读存储器,是一种非易失性、掉电不丢失的存储器,ram是随机存储器,是一种易失性、掉电丢失的存储器,其中rom分为了三块,第一块是程序存储器flash,也就是主闪存,它的用途就是存储c语言编译后的程序代码,也就是我们下载程序的位置,运行程序一般也是从主闪存里面开始运行的,这一块存储器STM2 给它分配的地址是0x08000000 ,起始地址,也就是第一个字节的地址是0800这个哈,然后剩余字节的地址依次增长,每个字节都分配一个独一无二的地址,就像给每个住户编门牌号一样,只有分配了独一无二的门牌号程序才能精准地访问这个存储器,最终终止地址是多少呢,这取决于它的容量,编到哪里,哪里就是终止地址,这就是主闪存的地址范围,你之后如果在软件里看到某个数据的地址是0800开头的,那你就可以确定它是属于主闪存的数据。

接着继续看系统存储器和选项字节,这两块存储器也是rom的一种,掉电不丢失,实际上他们的存储介质也是flash,只不过我们一般说flash指的是主闪存flash,而不止这两块区域,那看一下地址,他们的地址都是1fff开头的,紧跟着2000开头的就是ram区的,所以可以看出这两块存储器的位置是在rom区的最后面,它们的用途看下右边说明,系统存储器的用途是存储bootloader,用于串口下载,这个下一节讲串口的时候再给大家演示,那个bootloader程序存储的位置就被分配到了这里,bootloader程序是芯片出厂自动写入的哈,一般也不允许我们修改,之后选项字节,主要是保存一些配置,它的位置是在rom区的最后面,你下载程序可以不刷新选项字节的内容,这样选项字节的配置就可以保持不变,选项字节里存的主要是flash的读保护写保护,还有看门狗等等的配置,这个如果需要的话,可以了解一下。

选项字节在9-6有介绍,比如在FlyMcu里设置读保护,如果你做产品不开启读保护,别人很容易就把你的程序偷走了。
在这里插入图片描述
那选项字节里面的参数有什么好处呢
就是选项字节的数据相当于是世外桃源了哈,无论程序怎么更新,选项字节的数据都可以不变,你可以用这些字节来存储不随程序变化变化的参数,另外选项之前还有一个好处,就是可以用上位机很方便的修改,比如我们这个FlyMcu或者STLINK Utility,在上位机里可以直接修改选项字节的内容,是不是可以用作一些产品中可供用户配置的参数啊,然后继续看最后一项就是写保护了,这里可以对flash的哪几个页单独进行写保护,比如你在主程序的最后几页写了一些自定的数据,不想在下载的时候被擦除了,就可以把最后几页设置写保护锁起来,设置写保护之后就无法再写它,如果想再次写入的话,解除写保护就行了,另外注意一下,设置写保护之后再下载,如果需要写入保护区的话,就会出错,比如你把最前面几页写保护了,下载一次之后再下载,肯定就会出错,而它这个软件设计还不能单独写入选项字节,只能下载flash,顺便写入选项字节,但写保护了,下载不了,就不能解除写保护,形成死循环了。
STLINK Utility可以单独配置选项字节,就可以解决这个问题了。

然后我们看一下ram区域,首先是运行内存sram,分配的地址是0x20000000 ,用途是存储运行过程中的临时变量,也就是我们在程序中定义变量数组结构体的地方,你可以试一下,定义一个变量,再取他的地址显示出来,那这个地址肯定就是2000开头的,类比于电脑的话运行内存就是内存条,然后ram区剩下的还有外设寄存器,它的地址是0x40000000这块区域,用途是存储各个外设的配置参数,也就是我们初始化各个外设最终所读写的东西,刚才我们说了,外设寄存器也是存储器的一种,它的存储介质其实也是sram,只不过我们一般习惯把运行内存叫SRAM,外设寄存器就直接叫寄存器了。

最后是内核外设寄存器地址是0xE0000000这片区域,用途是存储内核各个外设的配置参数,内核外设就是NVIC和SysTick,因为内核外设和其他外设不是一个厂家设计的,所以他们的地址也是被分开的,内核外设是E000,其他外设是4000,那以上这些就是STM32里的存储器和他们被安排的地址。

在这里插入图片描述

我们在这个图里也看一下,在STM32中,所有的存储器都被安排到了0~ffffffff这个地址范围内,因为cpu是32位的,所以寻址范围就是32位的范围,32位的寻址范围是非常大的,最大可以支持4gb容量的存储器,而我们STM32的存储器都是kb级别的,所以这个4gb的寻址空间会有大量的地址都是空的,算一下地址的使用率还不到1%,在这个图里有灰色填充的就是Reserve的区域,也就是保留区域没有使用到,然后这个零地址啊实际上也是没有存储器的,他这里写的是别名到flash或者系统存储器取决于boot引脚(Aliased to Flash or…),因为程序是从零地址开始运行的,所以这里需要把我们想要执行的程序映射到零地址来,如果映射在flash区,就从flash执行,如果映射在系统存储器区,就是从系统存储器运行bootloader,如果映射到sram,就是从sram启动,怎么选择,由boot0和boot1两个硬件来决定,这就是零地址里的别名区,接着剩下的0800开始的flash区,用于存储程序代码,1fff开始的系统存储器和选项字节是在rom区的,最后面存在什么东西,刚才也都介绍过,之后2000开始的是sram区,4000开始的是外设寄存器区,里面可以展开,就是右边这些东西,具体到每个外设又有他们自己的起始地址,比如TIM 2的地址是40000000,TIM3是40000400,然后外设里面又可以具体细分到每个寄存器的地址,寄存器里每个字节的地址,最终所有字节的地址就都可以算出来了,就上面这里E000开始的区域存放的就是内核里面的外设寄存器了,那到这里相信你对STM32 里面有哪些存储器,每种存储器都对应在哪个地址区间里就应该清楚了。

DMA框图

在这里插入图片描述

我们看下DMA框图,左上角这里是Cortex-M3内核,里面包含了CPU和内核外设等等,剩下的这所有东西,你都可以把它看成是存储器,所以总共就是CPU和存储器两个东西。Flash是主闪存,SRAM是运行内存,各个外设,都可以看成是寄存器,也是一种SRAM存储器。
寄存器是一种特殊的存储器,一方面,CPU可以对奇存器进行读写,就像读写运行内存一样,另一方面,寄存器的每一位背后,都连接了一根导线,这些导线可以用于控制外设电路的状态,比如置引脚的高低电平、导通和断开开关、切换数据选择器,或者多位组合起来,当做计数器、数据寄存器等等。所以,寄存器是连接软件和硬件的桥梁,软件读写寄存器,就相当于在控制硬件的执行。
回到这里,既然外设就是寄存器,寄存器就是存储器,那使用DMA进行数据转运,就都可以归为一类问题了。就是从某个地址取内容,再放到另一个地址去。

在这里插入图片描述

我们看图,为了高效有条理地访问存储器,这里设计了一个总线矩阵,总线矩阵的左端,是主动单元,也就是拥有存储器的访问杈,右边这些,是被动单元,它们的存储器只能被左边的主动单元读写。主动单元这里,内核有DCode和系统总线,可以访问右边的存储器,其中DCode总线是专门访问Flash的,系统总线是访问其他东西的,另外,由于DMA要转运数据,所以DMA也必须要有访问的主动权。那主动单元,除了内核CPU,剩下的就是DMA总线了。这里DMA1有一条DMA总线,DMA2也有一条DMA总线,下面这还有一条DMA总线,这是以太网外设自己私有的DMA,这个可以不用管的。
在DMA1和DMA2里面,可以看到,DMA1有7个通道,DMA2有5个通道,各个通道可以分别设置它们转运数据的源地址和目的地址,这样它们就可以各自独立地工作了。

在这里插入图片描述

接着下面这里有个仲裁器,这个是因为,虽然多个通道可以独立转运数据,但是最终DMA总线只有一条,所以所有的通道都只能分时复用这一条DMA总线。如果产生了冲突,那就会由仲裁器,根据通道的优先级来决定谁先使用和后使用。另外在总线矩阵这里,也会有个仲裁器,如果DMA和CPU都要访问同一个目标,那么DMA就会暂停CPU的访问,以防止冲突。不过总线仲裁器,仍然会保证CPU得到一半的总线带宽,使CPU也能正常的工作。

在这里插入图片描述

下面这里是AHB从设备,也就是DMA自身的寄存器,因为DMA作为一个外设,它自己也会有相应的配置寄存器,这里连接在了总线右边的AHB总线上,所以DMA,既是总线矩阵的主动单元,可以读写各种存储器,也是AHB总线上的被动单元。CPU通过这一条线路,就可以对DMA进行配置了。

在这里插入图片描述

接着继续看这里,是DMA请求,请求就是触发的意思,这条线路右边的触发源,是各个外设,所以这个DMA请求就是DMA的硬件触发源。比如ADC转换完成、串口接收到数据,需要触发DMA转运数据的时候,就会通过这条线路,向DMA发出硬件触发信号,之后DMA就可以执行数据转运的工作了。这就是DMA请求的作用。

到这里,有关DMA的结构就讲的差不多了,其中包括:用于访问各个存储器的DMA总线;内部的多个通道,可以进行独立的数据转运;仲裁器,用于调度各个通道,防止产生冲突;AHB从设备,用于配置DMA参数;DMA请求,用于硬件触发DMA的数据转运,这就是这个DMA的各个部分和作用。

在这里插入图片描述

注意一下:就是这里的Flash,它是ROM只读存储器的一种,如果通过总线直接访问的话,无论是CPU,还是DMA,都是只读的,只能读取数据,而不能写入,如果你DMA的目的地址,填了Flash的区域,那转运时,就会出错。当然Flash也不是绝对的不可写入,我们可以配置这个Flash接口控制器,对Flash进行写入,这个流程就比较麻烦了,要先对Flash按页进行擦除,再写入数据,不过这是另一个课题了。总之就是CPU或者DMA直接访问Flash的话,是只可以读而不可以写的,然后SRAM是运行内存,可以任意读写,没有问题,外设寄存器的话,得看参考手册里面的描述。有的寄存器是只读的,有的寄存器是只写的,不过我们主要用的是数据寄存器,数据寄存器都是可以正常读写的。

DMA基本结构

在这里插入图片描述

这里是我总结的DMA基本结构图,如果想编写代码实际去控制DMA的话,那这个图就是必不可少的了
刚才这个框图只是一个笼统的结构图,对于DMA内部的执行细节,它还是没体现出来,所以我们再来分析一下这个图,看看DMA具体是怎么工作的。
在这里插入图片描述

在这个图里,这两部分就是数据转运的两大站点了,左边是外设寄存器站点,右边是存储器站点,包括Flash和SRAM,在STM32手册里,他所说的存储器啊,一般是特指flash和sram,不包含外设寄存器,外设寄存器它一般直接称作外设,所以就是外设到存储器,存储器到存储器这样来描述,虽然我们刚才说了,寄存器也是存储器的一种,但是STM32还是使用了外设和存储器来作为区分,这个注意一下描述方法的不同,那在这里可以看到DMA的数据转运可以是从外设到存储器,也是可以从存储器到外设,具体是向左还是向右,有一个方向的参数可以进行控制,另外还有一种转运方式,就是存储器到存储器,比如flash到sram或者sram到sram这两种方式,由于flash是只读的,所以dma不可以进行sram到flash或者flash到flash的转移操作,然后我们继续看这两边的参数,既然要进行数据转运,那肯定就要指定从哪里转到哪里,具体怎么转呢,所以外设和存储器两个站点就都有三个参数
第一个是起始地址,有外设端的起始地址和存储器端的起始地址,这两个参数决定了数据是从哪里来到哪里去的,之后第二个参数是数据宽度,这个参数的作用是指定一次转运要按多大的数据宽度来进行,他可以选择字节byte、半自HalfWord和字word,字节就是八位,也就是一次转运一个uint8_t这么大的数据,半字是16位就是一次转运一个uint16_t这么大,字是32位,就是一次转运unit32_t这么大,比如转运ADC的数据,ADC的结果是unit16_t这么大,所以这个参数就要选择半字,一次转运一个unit16_t,然后第三个参数是地址是否自增,这个参数的作用是指定一次转移完成后,下一次转运是不是要把地址移动到下一个位置去,这就相当于是指针p++这个意思,比如ADC扫描模式,用DMA进行数据转运,外设地址是ADC_DR寄存器,寄存器这边显然地址是不用指针的,如果自增,那下一次转运就跑到别的寄存器那里去了,存储器这边地址就需要指针,每转运一个数据后就往后挪个坑,要不然下次再转就把上次的覆盖掉了,这就是地址是否自增的作用,就是指定是不是要转运一次挪个坑这个意思,这就是外设站点和存储基站点各自的三个参数了。

如果要进行存储器到存储器的数据转运,那我们就需要把其中一个存储器的地址放在外设的这个站点,这样就能进行存储器到存储器的转运,只要你在外设起始地址里写flash或者sram的地址,那他就会去flash或sram找数据,这个站点虽然叫外设存储器,但是它就只是个名字而已,并不是说这个地址只能写寄存器的地址,如果写flash的地址,那他就会去flash里找,写sram他就会去sram里找,这个没有限制,甚至你可以在外设站点写存储器的地址,存储器站点写外设的地址,然后方向参数给反过来,这样也是可以的,只是ST公司给他起了这样的名字而已,所以我这里就按照它的名字来做的ppt,你也可以把它叫做站点a站点b,从a到b或者从b到a转运数据,不必拘泥于他写的外设站点存储器站点这个名字。

传输计数器和自动重装器:

在这里插入图片描述

传输计数器这个东西就是用来指定我总共需要转运几次的,这个传输计数器是一个自减计数器,比如你给他写个5,那DMA就只能进行5次数据转运,转运过程中每转运一次计数器的数就会减一,当传输计数器减到零之后,DMA就不会再进行数据转运了,另外它减到零之后,之前自增的地址也会恢复到起始地址的位置,以方便之后DMA开始新一轮的转换,在传输计数器的右边有一个自动重装器,这个自动重装器的作用就是传输计数器减到零之后,是否要自动恢复到最初的值,比如最初传输计数器给5
,如果不使用自动重装器,那转运5次后DMA就结束了,如果使用自动重装器,那转运5次计数器减到零后就会立即重装到初始值5,这个就是自动重装器,它决定了转运的模式,如果不重装就是正常的单次模式,如果重装就是循环模式,比如如果你想转运一个数组,那一般就是单次模式转运一轮就结束了,如果是ADC扫描模式加连续转换,那为了配合ADC,DMA也需要使用循环模式,所以这个循环模式和ADC的连续模式差不多啊,都是指定一轮工作完成后,是不是立即开始下一轮工作。

然后继续往下看啊,这一块就是DMA的触发控制了,触发就是决定DMA需要在什么时机进行转运的,触发源有硬件触发和软件触发,具体选择哪个,由M2M这个参数决定,M2M就是memory to memory,因为2的英文two和to同音,所以M2M就是m to m存储器到存储器的意思,当我们给M2M位1时,DMA就会选择软件触发,这个软件触发并不是调用某个函数一次触发一次,这个软件触发的执行逻辑是,以最快的速度,连续不断地触发DMA,争取早日把传输计数器清零,完成这一轮的转换,所以这里的软件触发和我们之前外部中断和ADC的软件出发,可能不太一样,你可以把它理解成连续触发,那这个软件触发和循环模式不能同时用,因为软件触发就是想把传输计数器清零,循环模式是清零后自动重装,如果同时用的话,那DMA就停不下来了,这就是软件触发,软件触发一般适用于存储器到存储器的转运,因为存储器到存储器的转运,是软件启动不需要时机,并且想尽快完成的任务,所以上面这里M2M位给1就是软件触发,就是应用在存储器到存储器转运的情况,M2M位给0,那就是使用硬件触发,硬件触发源可以选择ADC、串口、定时器等等,使用硬件触发的转运一般都是与外设有关的转运,这些转运需要一定的时机,比如ADC转换完成、串口收到数据、定时时间到等等,所以需要使用硬件触发,在硬件达到这些时机时,传个信号过来来触发DMA进行转运,这就是硬件触发。

触发控制部分:
在这里插入图片描述

然后最后,就是开关控制了,也就是DMA_Cmd函数。当给DMA使能后,DMA就准备就绪,可以进行转运了。

DMA进行转运有几个条件
第一就是开关控制,DMA_Cmd必须使能,第二就是传输计数器必须大于0,第三就是触发源必须有触发信号,触发一次转运一次,传输计数器自减一次,当传输计数器等于0,且没有自动重装时,这时无论是否触发,DMA都不会再进行转运了,此时就需要DMA_CMD给DISABLE,关闭DMA,再为传输计数器写一个大于0的数,再DMA_Cmd,给ENABLE,开启DMA,DMA才能继续工作,注意一下,写传输计数器时,必须要先关闭DMA再进行,不能在DMA开启时写传输计数器,这是手册里的规定。

几个小知识点细节:
DMA请求

在这里插入图片描述

在这里插入图片描述

这张图表示的就是我们上面这里的这部分结构,DMA触发的部分,我们来看一下这张图是DMA1的请求映像,下面是DMA的7个通道,每个通道都有一个数据选择器,可以选择硬件触发或软件触发,这里画的图我觉得可能不太好理解,你看他把EN位画在了数据选择器的侧边,一般数据选择器的侧边是输入选择控制位,难道这里的意思是EN给1选择硬件触发,EN给0选择软件触发吗,那显然不对啊,而且他左边这里写的是软件触发括号memory to memory位,难道M2M位是软件触发吗,这个也不太好理解哈,所以这个图我重新给他布了个局,就是上面我这里画的这样,M2M位是数据选择器的控制位,用于选择是硬件触发还是软件触发,EN位是开关控制,EN等于0时不工作,EN等于1时工作,这样就好理解一些了,那这里他这样画的意思应该是,EN并不是数据选择器的控制位啊,而是决定这个数据选择器要不要工作,EN等于0数据选择器不工作,EN等于1数据选择器工作,然后软件触发后面跟个M2M位的意思应该是当M2M位等于1时选择软件触发,这样理解的话就跟我这个图里是一个意思了,那然后继续看左边的硬件触发源,这里是外设请求信号啊,可以看到每个通道的硬件触发源都是不同的,如果你需要用ADC1来触发的话,那就必须选择通道一,如果需要定时器二的更新事件来触发的话,那就必须选择通道二,剩下的也是同理哈,因为每个通道的硬件触发源都不同,所以如果你想使用某个硬件触发源的话,就必须使用它所在的通道,这就是硬件触发的注意事项,而如果使用软件触发的话,那通道就可以任意选择了,因为每个通道的软件触发都是一样的,所以在ppt的在前面写的是每个通道都支持软件触发和特定的硬件触发,这就是特定的意思,选择硬件触发是要看通道的。

然后回来继续看哈,这里通道1的硬件触发是ADC1 、定时器2的通道3和定时器4的通道1,那到底是选择哪个触发源呢,这个是对应的外设是否开启了DMA输出来决定的,比如你要使用ADC1 ,那会有个库函数叫ADC_DMACmd,必须使用这个库函数开启ADC1的这一路输出,它才有效,如果想选择定时器2的通道3,那也会有个TIM_DMACmd函数,用来进行DMA输出控制,所以这三个触发源具体使用哪个,取决于你把哪个外设的DMA输出开启了,如果三个都开启了,那这边是一个或门,理论上三个硬件都可以进行触发,不过一般情况下我们都是开启其中一个,之后这七个触发源进入到仲裁器进行优先级判断,最终产生内部的DMA1请求,这个优先级的判断啊,类似于中断的优先级,默认优先级是通道号越小优先级越高,当然也可以在程序中配置优先级,这个其实影响并不是很大,大家了解一下就行。

数据宽度与对齐
DMA数据转运的两个站点,都有一个数据宽度的参数,如果数据宽度都一样,那就是正常的一个个转运,如果数据宽度不一样,那会怎么处理呢?
这个表就是来说明问题的,

在这里插入图片描述

我们看一下这里第一列是源端宽度,第二列是目标宽度,第三列是传输数目,当源端和目标都是8位时,转运第一步,在源端的0位置读数据B0 ,在目标的0位置写数据b0 ,就是把这个B0从左边挪到右边,之后的步骤,就是把B1从左边挪到右边,接着B2、B3,这是源端和目标都是8位的情况,操作也很正常,接着继续,源端是8位,目标是16位,那他的操作就是在源端读B0,在目标写00B0,之后读B1,写00B1,等等,这个意思就是,如果你目标的数据宽度比源端的数据宽度大,那就在目标数据前面多出来的空位补0,之后8位转运到32位,也是一样的处理哈,前面空出来的都补0,然后下面当目标数据宽度比源端数据宽度小时,比如由16位转到8位去,现象就是读b1 b0 只写入b0 ,读b3 b2 只写入b2 ,也就是把多出来的高位舍弃掉,之后的各种情况也都是类似的操作。

总之一这个表的意思就是如果你把小的数据转到大的里面去,高位就会补0;如果把大的数据转到小的里面去,高位就会舍弃掉;如果数据宽度一样,那就没事。就是跟unit8_t、unit16_t和unit32_t变量之间相互赋值一样,不够就补0,超了就舍弃高位,这是一个道理。

那最后,我们再来看两个例子,看看在这些实际的任务下,DMA是如何工作的。这两个例子和程序例子对应的。

数据转运+DMA
在这里插入图片描述

这个例子的任务是将SRAM里的数组DataA,转运到另一个数组DataB中,我们看一下这种情况下,这个基本结构里的各个参数该如何配置。
首先是外设站点和存储器站点的起始地址、数据宽度、地址是否自增这三个参数。那在这个任务里,外设地址显然应该填DataA数组的首地址,存储器地址,给DataB数组的首地址,然后数据宽度,两个数组的类型都是uint8_t,所以数据宽度都是按8位的字节传输。之后地址是否自增,在中间可以看到,我们想要的效果是DataA[0]转到DataB[0],DataA[1]转到DataB[1],等等。所以转运完DataA[0]和DataB[0]之后,两个站点的地址都应该自增,都移动到下一个数据的位置,继续转运DataA[1]和DataB[1],这样来进行。
之后,这里的方向参数,那显然就是外设站点转运到存储器站点了,当然如果你想把DataB的数据转运到DataA,那可以把方向参数换过来,这样就是反向转运了。
然后是传输计数器和是否要自动重装,在这里,显然要转运7次,所以传输计数器给7,自动重装暂时不需要,之后触发选择部分,这里,我们要使用软件触发。因为这是存储器到存储器的数据转运,是不需要等待硬件时机的,尽快转运完成就行了。
那最后,调用DMA_Cmd,给DMA使能,这样数据就会从DataA转运到DataB了。转运7次之后,传输计数器自减到0,DMA停止,转运完成。这里的数据转运是一种复制转运,转运完成后DataA的数据并不会消失,这个过程相当于是把DataA的数据复制到了DataB的位置。

ADC扫描模式+DMA

在这里插入图片描述

左边是ADC扫描模式的执行流程,在这里有7个通道,触发一次后,7个通道依次进行AD转换,然后转换结果都放到ADC_DR数据寄存器里面。那我们要做的就是,在每个单独的通道转换完成后,进行一个DMA数据转运,并且目的地址进行自增,这样数据就不会被覆盖了。所以在这里DMA的配置就是,外设地址,写入ADC_DR这个寄存器的地址;存储器的地址,可以在SRAM中定义一个数组ADValue,然后把ADValue的地址当做存储器的地址。
之后数据宽度,因为ADC_DR和SRAM数组,我们要的都是uint16_t的数据,所以数据宽度都是16位的半字传输。
接着判断地址是否自增,那从这个图里,显然是外设地址不自增,存储器地址自增;传输方向,是外设站点到存储器站点;传输计数器,这里通道有7个,所以计数7次;计数器是否自动重装,这里可以看ADC的配置,ADC如果是单次扫描,那DMA的传输计数器可以不自动重装,转换一轮就停止,如果ADC是连续扫描,那DMA就可以使用自动重装,在ADC启动下一轮转换的时候,DMA也启动下一轮的转运,ADC和DMA同步工作。
最后是触发选择,这里ADC_DR的值是在ADC单个通道转换完成后才会有效,所以DMA转运的时机,需要和ADC单个通道转换完成同步,所以DMA的触发要选择ADC的硬件触发。
最后硬件触发这里要说明一下,我们上一节说了,ADC扫描模式,在每个单独的通道转换完成后,没有任何标志位,也不会触发中断。所以我们程序不太好判断,某一个通道转换完成的时机是什么时候。但是根据UP主的研究,虽然单个通道转换完成后,不产生任何标志位和中断,但是它应该会产生DMA请求,去触发DMA转运,这部分内容,手册里并没有详细描述,根据我实际实验,单个通道的DMA请求肯定是有的。
这些就是ADC扫描模式和DMA配合使用的流程。一般来说,DMA最常见的用途就是配合ADC的扫描模式,因为ADC扫描模式有个数据覆盖的特征,这个缺陷使ADC和DMA成为了最常见的伙伴,ADC对DMA的需求是非常强烈的,像其他的一些外设,使用DMA可以提高效率,是锦上添花的操作,但是不使用也是可以的,顶多是损失一些性能,但是这个ADC的扫描模式,如果不使用DMA,功能都会受到很大的限制,所以ADC和DMA的结合最为常见。

参考手册

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

之后这里还有一个位段区域,这两个位段区映射了外设寄存器和SRAM中全部的位,这个位段区就相当于是位寻址,它把外设寄存器和SRAM中所有的位都分配了地址,你操作这个新的地址就相当于操作其中某一个位,因为32位的地址有99%都是空的,所以地址空间很充足,即使把每一位都单独编码,那毫无压力,所以就存在了这样一个位段,用于单独操作计算器或sram的某一位,位段区是另找了一个地方,开辟了一段地址区域,其中sram位段区域是2200开头的区域,外设寄存器的位段区是4200开头的区域,需要用的话可以了解一下。

在这里插入图片描述
嵌入式闪存,闪存被分为了很多页,他们的地址都是0800开头的,在闪存区的最后就是系统存储器和选项字节,这两个区域统称为信息块,下面这是闪存接口寄存器,这是外设的一部分,你看它的地址是40开头的,所以它显然是一个外设,这个外设可以对闪存进行读写哈,这就是闪存的部分。

在这里插入图片描述
启动配置,配置boot0和boot1两个引脚来选择程序从哪里启动,这个我们第一节和刚才也介绍过。

在这里插入图片描述

示例代码(DMA数据转运&&DMA+AD多通道)

这个电路和OLED显示屏是一样的,因为数据转运都是在STM32内部进行的,其他的模块都不需要,然后面包板这里也就是一个oled显示屏的电路就行了。
在这里插入图片描述

先来验证存储器映像的内容
在这里插入图片描述

在这里插入图片描述
看一下我们定义的数据,它到底是不是真的存在了这相应的地址区间里,下载看一下,aa这个变量它被存储的地址是20000000,地址是20开头的,对照前面存储器映像里的表就知道了,aa这个变量存储的位置是sram区,在sram区它的地址肯定是20开头的,那它的具体地址是多少呢,这个是由编译器来确定的,目前sram区还没什么东西,所以编译器就把这个变量放在了sram区的第一个位置,也就是20000000,我们可以在这个变量前面加一个const关键字,表示的是常量的意思,被const的修饰的变量在程序中只能读不能写,那我们上一小节说了,flash里面的数据也是只能读不能写的,所以const和flash就联系起来了,在STM32 中,使用const定义的变量是存储在flash里面的,当然这里就不应该说是变量了,而应该说是常量,因为它是不能变的,这个变量的值只能在定义的时候给,如果你在程序这里尝试再次给它赋值,那就会报错啊,错误意思就是不能给cos的常量赋值,那我们下载看一下,,这里就可以看到aa这个变量的地址就变成0800开头的了,在ppt的表格里可以知道现在aa是被存储在了flash里,在flash里存储的是程序代码,当然还有常量数据哈,这里没写出来,那这里的地址尾部有些偏移哈,不像sram里那样直接安排在第一个位置,这是因为flash里还有程序代码这些东西放在了前面,所以编译器给这个常量安排的地址就相对靠后了一些,这就是定义变量和常量的方法,正常情况下我们使用的都是变量哈,直接定义就行,不需要加const的,那什么时候需要定义常量呢,这个是当我们程序中出现了一大批数据,并且不需要更改时,就可以把它定义成常量,这样能节省SRAM的空间,比如查找表,字库数据等等,我们可以打开这个oled_font.h文件,这里面就是oled显示英文的字库,这是一个数组哈,它里面的数据决定了每个字符应该显示哪些像素点,这个数组非常的长啊,而且是不需要更改的,所以在这里就可以加一个const,把它定义在flash里面,这样就可以节省sram的空间,这里如果你一不小心把这个const去掉了,那程序功能并不会有任何影响,但是sram里会有和这个数组一样大的空间被浪费掉了,如果数值很小,那影响也不大,如果数组很大,那就得考虑一下sram是不是消耗得起来,这就是cos的关键字的用途。

接下来我们再研究一下外设寄存器的地址,那对于变量或者常量来说,它的地址是由编译器确定的,不同的程序地址可能不一样,是不固定的,对于外设计算器来说,它的地址是固定的,在手册里都能查得到,在程序里也可以用结构体很方便的访问寄存器,比如要访问ADC1的DR寄存器,就可以写ADC1->DR,这样就可以访问ADC1的DR寄存器了。
在这里插入图片描述

在这里插入图片描述

可以看到ADC1的DR寄存器地址是4001244C,对照ppt的表可以知道他确实是外设寄存器的区域,这个具体地址4001244c是固定的,在手册里也可以查到。

起始地址加偏移就是这个寄存器器的实际地址,手册如下:

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

8-1 DMA数据转运
把一个数组里面的数据,复制到另一个数组里

这就是我们第一个代码的任务定义一下,DMA转运的源端数组和目的数组,初始化DMA,然后让DMA把这里DataA的数据,转运到DataB里面去。

在这里插入图片描述
在这里插入图片描述

初始化第一步,RCC开启DMA的时钟
注意:这里开启DMA时钟的时候,根据型号不同开启时钟参数也不同

在这里插入图片描述

第二步,就可以直接调用DMA_Init,初始化这里的各个参数了,包括外设和存储器站点的起始地址、数据宽度、地址是否自增,方向、传输计数器、是否需要自动重装、选择触发源、通道优先级,那这所有的参数,通过一个结构体,就可以配置好了

在这里插入图片描述

例:

/* Initialize the DMA Channel1 according to the DMA_InitStructure 
members */ 
DMA_InitTypeDef DMA_InitStructure; 
DMA_InitStructure.DMA_PeripheralBaseAddr = 0x40005400; 
DMA_InitStructure.DMA_MemoryBaseAddr = 0x20000100; 
DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; 
DMA_InitStructure.DMA_BufferSize = 256; 
DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; 
DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; 
DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; 
DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; 
DMA_InitStructure.DMA_Mode = DMA_Mode_Normal; 
DMA_InitStructure.DMA_Priority = DMA_Priority_Medium; 
DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; 
DMA_Init(DMA_Channel1, &DMA_InitStructure); 

之后,就可以进行开关控制,DMA_Cmd,给指定的通道使能,就完成了。那在这里,如果你选择的是硬件触发不要忘了在对应的外设调用一下XXX_DMACmd,开启一下触发信号的输出;如果你需要DMA的中断,那就调用DMA_ITConfig,开启中断输出,再在NVIC里,配置相应的中断通道,然后写中断函数就行了。

最后,在运行的过程中,如果转运完成,传输计数器清0了。这时想再给传输计数器赋值的话,就DMA失能、写传输计数器、DMA使能,这样就行了。

MyDMA.h

#ifndef __MYDMA_H
#define __MYDMA_H

void MyDMA_Init(uint32_t AddrA, uint32_t AddrB, uint16_t Size);
void MyDMA_Transfer(void);

#endif

MyDMA.c

#include "stm32f10x.h"                  // Device header

uint16_t MyDMA_Size;					//定义全局变量,用于记住Init函数的Size,供Transfer函数使用

/**
  * 函    数:DMA初始化
  * 参    数:AddrA 原数组的首地址
  * 参    数:AddrB 目的数组的首地址
  * 参    数:Size 转运的数据大小(转运次数)
  * 返 回 值:无
  */
void MyDMA_Init(uint32_t AddrA, uint32_t AddrB, uint16_t Size)
{
	MyDMA_Size = Size;					//将Size写入到全局变量,记住参数Size
	
	/*开启时钟*/
	RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);						//开启DMA的时钟
	
	/*DMA初始化*/
	DMA_InitTypeDef DMA_InitStructure;										//定义结构体变量
	DMA_InitStructure.DMA_PeripheralBaseAddr = AddrA;						//外设基地址,给定形参AddrA
	//对于SRAM数组,地址是编译器分配的,并不固定,所以我们一般不会写绝对地址,而是通过数组名获取地址,所以此处把数组名传过来就行
	DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;	//外设数据宽度,选择字节
	DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Enable;			//外设地址自增,选择使能,数组之间的转运地址需要自增
	DMA_InitStructure.DMA_MemoryBaseAddr = AddrB;							//存储器基地址,给定形参AddrB
	DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte;			//存储器数据宽度,选择字节
	DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;					//存储器地址自增,选择使能
	DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;						//数据传输方向,选择由外设到存储器
	DMA_InitStructure.DMA_BufferSize = Size;								//转运的数据大小(转运次数)
	DMA_InitStructure.DMA_Mode = DMA_Mode_Normal;							//模式,选择正常模式(是否使用自动重装),不用,转运一次停下来就行
	DMA_InitStructure.DMA_M2M = DMA_M2M_Enable;								//存储器到存储器,选择使能(硬件触发还是软件触发),选择软件触发
	DMA_InitStructure.DMA_Priority = DMA_Priority_Medium;					//优先级,选择中等
	DMA_Init(DMA1_Channel1, &DMA_InitStructure);							//将结构体变量交给DMA_Init,配置DMA1的通道1(存储器到存储器的转运,用的软件触发,通道随便选)
	
	/*DMA使能*/
	DMA_Cmd(DMA1_Channel1, DISABLE);	//这里先不给使能,初始化后不会立刻工作,等后续调用Transfer后,再开始
}

/**
  * 函    数:启动DMA数据转运
  * 参    数:无
  * 返 回 值:无
  */
void MyDMA_Transfer(void)
{
	DMA_Cmd(DMA1_Channel1, DISABLE);					//DMA失能,在写入传输计数器之前,需要DMA暂停工作
	DMA_SetCurrDataCounter(DMA1_Channel1, MyDMA_Size);	//写入传输计数器,指定将要转运的次数
	DMA_Cmd(DMA1_Channel1, ENABLE);						//DMA使能,开始工作
	
	while (DMA_GetFlagStatus(DMA1_FLAG_TC1) == RESET);	//等待DMA工作完成    如果中途出错了,这里是不就卡死了?
	DMA_ClearFlag(DMA1_FLAG_TC1);						//清除工作完成标志位
}

mian.c:

#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "MyDMA.h"

// 这里给成千上万个数才能体现DMA优势,这里只是测试用
uint8_t DataA[] = {0x01, 0x02, 0x03, 0x04};				//定义测试数组DataA,为数据源
uint8_t DataB[] = {0, 0, 0, 0};							//定义测试数组DataB,为数据目的地

int main(void)
{
	/*模块初始化*/
	OLED_Init();				//OLED初始化
	
	MyDMA_Init((uint32_t)DataA, (uint32_t)DataB, 4);	//DMA初始化,把源数组和目的数组的地址传入
	
	/*显示静态字符串*/
	OLED_ShowString(1, 1, "DataA");
	OLED_ShowString(3, 1, "DataB");
	
	/*显示数组的首地址*/
	OLED_ShowHexNum(1, 8, (uint32_t)DataA, 8);
	OLED_ShowHexNum(3, 8, (uint32_t)DataB, 8);
		
	while (1)
	{
		DataA[0] ++;		//变换测试数据
		DataA[1] ++;
		DataA[2] ++;
		DataA[3] ++;
		
		OLED_ShowHexNum(2, 1, DataA[0], 2);		//显示数组DataA
		OLED_ShowHexNum(2, 4, DataA[1], 2);
		OLED_ShowHexNum(2, 7, DataA[2], 2);
		OLED_ShowHexNum(2, 10, DataA[3], 2);
		OLED_ShowHexNum(4, 1, DataB[0], 2);		//显示数组DataB
		OLED_ShowHexNum(4, 4, DataB[1], 2);
		OLED_ShowHexNum(4, 7, DataB[2], 2);
		OLED_ShowHexNum(4, 10, DataB[3], 2);
		
		Delay_ms(1000);		//延时1s,观察转运前的现象
		
		MyDMA_Transfer();	//使用DMA转运数组,从DataA转运到DataB
		
		OLED_ShowHexNum(2, 1, DataA[0], 2);		//显示数组DataA
		OLED_ShowHexNum(2, 4, DataA[1], 2);
		OLED_ShowHexNum(2, 7, DataA[2], 2);
		OLED_ShowHexNum(2, 10, DataA[3], 2);
		OLED_ShowHexNum(4, 1, DataB[0], 2);		//显示数组DataB
		OLED_ShowHexNum(4, 4, DataB[1], 2);
		OLED_ShowHexNum(4, 7, DataB[2], 2);
		OLED_ShowHexNum(4, 10, DataB[3], 2);

		Delay_ms(1000);		//延时1s,观察转运后的现象
	}
}

在这里插入图片描述

如果你想把flash的数据转运到sram里的话,可以在这个DataA前面加一个const,把DataA定义在flash里面,那下面这里DataA++就不能要了,因为const数据不能重新更改,然后编译下载看一下。

在这里插入图片描述

8-2 DMA+AD多通道
用ADC的扫描模式来实现多通道采集,然后使用DMA来进行数据转运

接下来我们来写第二个程序ADC加DMA应用,先看一下接线图,这里接线图和上一节AD多通道是一样的,也是PA0接个电位器,PA1到PA3接三个传感器模块的AO输出。

在这里插入图片描述

AD.h

#ifndef __AD_H
#define __AD_H

extern uint16_t AD_Value[4];

void AD_Init(void);

#endif

AD.c

#include "stm32f10x.h"                  // Device header

uint16_t AD_Value[4];					//定义用于存放AD转换结果的全局数组

/**
  * 函    数:AD初始化
  * 参    数:无
  * 返 回 值:无
  */
void AD_Init(void)
{
	/*开启时钟*/
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);	//开启ADC1的时钟
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);	//开启GPIOA的时钟
	RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);		//开启DMA1的时钟
	
	/*设置ADC时钟*/
	RCC_ADCCLKConfig(RCC_PCLK2_Div6);						//选择时钟6分频,ADCCLK = 72MHz / 6 = 12MHz
	
	/*GPIO初始化*/
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);					//将PA0、PA1、PA2和PA3引脚初始化为模拟输入
	
	/*规则组通道配置  扫描PA0到PA3这四个通道  点四个菜 菜单上1~4号空位,我填上了0~3这四个通道 这个通道和次序可任意修改*/
	ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5);	//规则组序列1的位置,配置为通道0
	ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_55Cycles5);	//规则组序列2的位置,配置为通道1
	ADC_RegularChannelConfig(ADC1, ADC_Channel_2, 3, ADC_SampleTime_55Cycles5);	//规则组序列3的位置,配置为通道2
	ADC_RegularChannelConfig(ADC1, ADC_Channel_3, 4, ADC_SampleTime_55Cycles5);	//规则组序列4的位置,配置为通道3
	
	/*ADC初始化*/
	ADC_InitTypeDef ADC_InitStructure;											//定义结构体变量
	ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;							//模式,选择独立模式,即单独使用ADC1
	ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;						//数据对齐,选择右对齐
	ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;			//外部触发,使用软件触发,不需要外部触发
	ADC_InitStructure.ADC_ContinuousConvMode = ENABLE;							//连续转换,使能,每转换一次规则组序列后立刻开始下一次转换
	ADC_InitStructure.ADC_ScanConvMode = ENABLE;								//扫描模式,使能,扫描规则组的序列,扫描数量由ADC_NbrOfChannel确定(告诉厨师,我点了四个菜,你不要只盯着一个菜看)
	ADC_InitStructure.ADC_NbrOfChannel = 4;										//通道数,为4,扫描规则组的前4个通道
	ADC_Init(ADC1, &ADC_InitStructure);											//将结构体变量交给ADC_Init,配置ADC1
	
	/*DMA初始化  想象成一个服务员,ADC这个厨师把菜做好了,DMA这个服务员尽快端菜防止被覆盖*/
	DMA_InitTypeDef DMA_InitStructure;											//定义结构体变量
	DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR;				//外设基地址,给定形参AddrA(端菜的源头地址 之前算过ADC1的DR寄存器地址是0X4001 244C可以这样来填,这里库函数已经算好了)
	DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;	//外设数据宽度,选择半字,对应16为的ADC数据寄存器  来转运
	DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;			//外设地址自增,选择失能,始终以ADC数据寄存器为源(不自增,始终转运同一个位置的数据)
	DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)AD_Value;					//存储器基地址(端菜的目的地,我们想把数据存在SRAM数组里),给定存放AD转换结果的全局数组AD_Value
	DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;			//存储器数据宽度,选择半字,与源数据宽度对应
	DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;						//存储器地址自增,选择使能,每次转运后,数组移到下一个位置
	DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;							//数据传输方向,选择由外设到存储器,ADC数据寄存器转到数组
	DMA_InitStructure.DMA_BufferSize = 4;										//转运的数据大小(转运次数),与ADC通道数一致,4个ADC通道传输4次
	DMA_InitStructure.DMA_Mode = DMA_Mode_Circular;								//模式,选择循环模式,与ADC的连续转换一致 (可以给单次,也可以给自动重装载的循环模式)
	DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;								//存储器到存储器,选择失能,不使用软件触发,硬件触发 触发源为ADC1 数据由ADC外设触发转运到存储器(厨师每个菜做好了,叫我一下我再去端菜,这样时机才合适)
	DMA_InitStructure.DMA_Priority = DMA_Priority_Medium;						//优先级,选择中等
	DMA_Init(DMA1_Channel1, &DMA_InitStructure);								//将结构体变量交给DMA_Init,配置DMA1的通道1  这里必须填通道一,前面图里可以看到ADC1的硬件触发只接在了DMA1的通道1上
	
	/*DMA和ADC使能*/
	DMA_Cmd(DMA1_Channel1, ENABLE);							//DMA1的通道1使能
	ADC_DMACmd(ADC1, ENABLE);								//ADC1触发DMA1的信号使能 开启DMA触发信号
	ADC_Cmd(ADC1, ENABLE);									//ADC1使能
	
	/*ADC校准*/
	ADC_ResetCalibration(ADC1);								//固定流程,内部有电路会自动执行校准
	while (ADC_GetResetCalibrationStatus(ADC1) == SET);
	ADC_StartCalibration(ADC1);
	while (ADC_GetCalibrationStatus(ADC1) == SET);
	
	/*ADC触发*/
	//ADC连续扫描+DMA循环转运的模式
	ADC_SoftwareStartConvCmd(ADC1, ENABLE);	//软件触发ADC开始工作,由于ADC处于连续转换模式,故触发一次后ADC就可以一直连续不断地工作
	
	//ADC单次扫描+DMA单次转运的模式  放到主函数while循环里
	/*
	void AD_GetValue(void)
	{
		DMA_Cmd(DMA1_Channel1, DISABLE);					//DMA失能,在写入传输计数器之前,需要DMA暂停工作
		DMA_SetCurrDataCounter(DMA1_Channel1, 4);			//写入传输计数器,指定将要转运的次数
		DMA_Cmd(DMA1_Channel1, ENABLE);						//DMA使能,开始工作

		ADC_SoftwareStartConvCmd(ADC1, ENABLE);	
		
		while (DMA_GetFlagStatus(DMA1_FLAG_TC1) == RESET);	//等待DMA工作完成    如果中途出错了,这里是不就卡死了?
		DMA_ClearFlag(DMA1_FLAG_TC1);						//清除工作完成标志位
	}
	*/
	
}

main.c

#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "AD.h"

int main(void)
{
	/*模块初始化*/
	OLED_Init();				//OLED初始化
	AD_Init();					//AD初始化
	
	/*显示静态字符串*/
	OLED_ShowString(1, 1, "AD0:");
	OLED_ShowString(2, 1, "AD1:");
	OLED_ShowString(3, 1, "AD2:");
	OLED_ShowString(4, 1, "AD3:");
	
	while (1)
	{
		OLED_ShowNum(1, 5, AD_Value[0], 4);		//显示转换结果第0个数据
		OLED_ShowNum(2, 5, AD_Value[1], 4);		//显示转换结果第1个数据
		OLED_ShowNum(3, 5, AD_Value[2], 4);		//显示转换结果第2个数据
		OLED_ShowNum(4, 5, AD_Value[3], 4);		//显示转换结果第3个数据
		
		Delay_ms(100);							//延时100ms,手动增加一些转换的间隔时间
	}
}

在这里插入图片描述

ADC连续扫描+DMA循环转运的模式,这样也可以完成ad多通道转换的功能,可以看到此时硬件外设已经实现了相互配合和高度的自动化,各种操作都是硬件自己完成的,极大的减轻的软件负担,软件什么都不需要做,也不需要进任何中断,硬件自动就把活干完了,另外这里你还可以再加一个外设,比如定时器,ADC用单次扫描,再用定时器去定时触发,这样就是定时器触发ADC,ADC触发DMA,整个过程完全自动,不需要程序手动进行操作,节省软件资源,这就是STM2中硬件自动化的一大特色
各个外设互相连接,互相交织,不再是传统的一个cpu单独控制多个独立的外设这样的星型结构,而是外设之间互相连接,互相合作,形成一个网状结构,这样在完成某些简单且繁琐的工作的时候,就不需要cpu来统一调度了,可以直接通过外设之间的相互配合,自动完成这些繁琐的工作,这样不仅可以减轻cpu的负担,还可以大大提高外设的性能,在我们之前的学习中,也经常遇到过这样的设计,比如定时器的输出可以通向ADC、DAC或其他定时器,ADC的触发源可以来自定时器或外部中断,DMA的触发源可以来自ADC、定时器、串口等等,这就是这个STM32外设互相配合工作的特色。

本节课的内容就差不多了,还有一个存储器到外设的情况我们目前还没有讲,比如串口发送一批数据,就可以使用DMA进行存储器到外设的转运,这个就留给大家以后自己去研究了。

USART串口

在这里插入图片描述

补充:I2C和SPI由于具有独立的时钟线,因此它们是同步的。在时钟信号的指引下,接收方可以采样数据。然而,串口、CAN和USB没有时钟线,因此需要双方约定一个采样频率,这就是异步通信。为了对齐采样位置,还需要添加一些帧头和帧尾等标识。
同步靠时钟线,异步靠比特率

在这里插入图片描述

在这里插入图片描述

电平标准

根据通讯使用的电平标准不同,串口通讯可分为TTL标准及RS-232标准,见表 TTL电平标准与RS232电平标准 。

在这里插入图片描述

串口常用的电瓶标准有如下三种,第一种就是最常见的TTL电平,+3.3V或者+5V表示1,0V表示0,这里1的电压,如果你是5V的器件就是+5V,如果是3.3V的器件,就是+3.3V,逻辑1就是高电平的电压,就是vcc的电压,除了ttl电平串口,还有rs232电平和rs485电平,其中rs232电平的规定是-3到-15V表示1,+3到+15V表示0,rs232电平一般在大型的机器上使用啊,由于环境可能比较恶劣,静电干扰比较大,所以这里电平的电压都比较大,而且允许波动的范围也很大,另外还有rs485电平,它的规定是两项压差,+2到+6V表示1,-2到-6伏表示0,这里电平参考是两线压差,所以rs485的电平是差分信号,差分信号抗干扰能力非常强悍,使用rs485电平标准通信距离可以达到上千米,而上面这两种电平最远只能达到几十米,像单片机这种低压小型设备使用的都是TTL电平,我们之后的内容也都是基于ttl电平来讲解的,如果你做设备需要其他的电瓶,那就再加电平转换芯片就行了,在软件层面,它们都属于串口,所以程序并不会有什么变化。

因为控制器一般使用TTL电平标准,所以常常会使用MAX232芯片对TTL及RS-232电平的信号进行互相转换。

在这里插入图片描述

串口中,每一个字节都装载在一个数据帧里面,每个数据帧都由起始位、数据位和停止位组成。

波特率

本章中主要讲解的是串口异步通讯,异步通讯中由于没有时钟信号(如前面讲解的DB9接口中是没有时钟信号的), 所以两个通讯设备之间需要约定好波特率。

例如,如果每隔1秒发送一位,那么接收方也必须每隔1秒接收一位。如果接收方过早接收,则可能会重复接收某些位;如果接收方过晚接收,则可能会错过某些位。因此,发送方和接收方必须约定好传输速率,这个速率参数,就是波特率。

波特率本来的意思是每秒传输码元的个数,单位是码元/s或者直接叫波特(Baud),另外还有个速率表示叫比特率,比特率的意思是每秒传输的比特数,单位是bit/s或者叫bps,在二进制调制的情况下,一个码元就是一个bit,此时波特率就等于比特率,像我们单片机的串口通信基本都是二进制调制,也就是高电平表示一,低电平表示零,一位就是一bit,所以说这个串口的波特率经常会和比特率混用哈,不过这也是没关系的,因为这两个说法的数值相等,如果是多进制调制,那波特率就和比特率不一样了,这个了解一下,那反映到波形上,比如我们双方规定波特率为1000bps,那就表示一秒要发1000位,每一位的时间就是1ms,也就是这里这一段时间是1ms,发送方每隔1ms发送一位,接收方每隔一毫秒接收一位,这就是波特率,它决定了每隔多久发送一位。

通讯的起始和停止信号

起始位,它是标志一个数据帧的开始,固定为低电平。首先,串口的空闲状态是高电平,也就是没有数据传输的时候,然后需要传输的时候,必须要先发送一个起始位,这个起始位必须是低电平,来打破空闲状态的高电平,产生一个下降沿。这个下降沿,就告诉接收设备,这一帧数据要开始了。如果没有起始位,那当我发送8个1的时候,是不是数据线就一直都是高电平,没有任何波动,对吧。这样,接收方怎么知道我发送数据了呢。

同理,在一个字节数据发送完成后,必须要有一个停止位,这个停止位的作用是,用于数据帧间隔,固定为高电平。同时这个停止位,也是为下一个起始位做准备的,如果没有停止位,那当我数据最后一位是0的时候,下次再发送新的一帧,是不是就没法产生下降沿了,对吧。这就是起始位和停止位的作用。起始位固定为0,产生下降沿,表示传输开始;停止位固定为1,把引脚恢复成高电平,方便下一次的下降沿,如果没有数据了,正好引脚也为高电平,代表空闲状态。

数据位:

这里数据位表示数据帧的有效载荷,1为高电平,0为低电平,低位先行。比如我要发送一个字节,是0x0F,那就首先把0F转换为二进制,就是0000 1111,然后低位先行,所以数据要从低位开始发送,也就是1111 0000,像这样,依次放在发送引脚上。所以说如果你想发0x0F这一个字节数据,那就按照波特率要求,定时翻转引脚电平,产生一个这样的波形就行了。

有效数据

在数据包的起始位之后紧接着的就是要传输的主体数据内容,也称为有效数据,有效数据的长度常被约定为5、6、7或8位长。

数据校验

最后看一下校验位,它的用途是,用于数据验证,是根据数据位计算得来的。这里串口,使用的是一种叫奇偶校验的数据验证方法,奇偶校验可以判断数据传输是不是出错了。如果数据出错了,可以选择丢弃或者要求重传,校验可以选择3种方式,无校验、奇校验和偶校验。无校验,就是不需要校验位,波形就是左边这个,起始位、数据位、停止位,总共3个部分。

奇校验要求有效数据和校验位中“1”的个数为奇数,比如一个8位长的有效数据为:01101001,此时总共有4个“1”, 为达到奇校验效果,校验位为“1”,最后传输的数据将是8位的有效数据加上1位的校验位总共9位。

偶校验与奇校验要求刚好相反,要求帧数据和校验位中“1”的个数为偶数, 比如数据帧:11001010,此时数据帧“1”的个数为4个,所以偶校验位为“0”。

0校验是不管有效数据中的内容是什么,校验位总为“0”,1校验是校验位总为“1”。

当然奇偶校验的检出率并不是很高,比如如果有两位数据同时出错。奇偶特性不变,那就校验不出来了,所以奇偶校验只能保证一定程度上的数据校验。如果想要更高的检出率,可以了解一下CRC校验,这个校验会更加好用,当然也会更复杂。我们这个STM32内部也有CRC的外设,可以了解一下,那到这里,串口的时序我们就了解了。

说明:我们这里的数据位,有两种表示方法,一种是把校验位作为数据位的一部分,分为8位数据和9位数据,其中9位数据,就是8位有效载荷和1位校验位;另一种就是把数据位和校验位独立开,数据位就是有效载荷,校验位就是独立的1位,像我这上面的描述,就是把数据位和校验位分开描述了,在串口助手里也是分开描述,总之,无论是合在一起,还是分开描述,描述的都是同一个东西,这个应该也好理解。

串口时序波形

在这里插入图片描述
这些波形我是用示波器实测的,操作方法是把探头的GND接在负极,探头接在发送设备的tx引脚
,然后发送数据就能捕捉到这些波形了,那我们先看一下第一个波形,这个波形是发送一个字节数据0x55时,在tx引脚输出的波形,波特率是9600,所以每一位的时间就是1÷9600,大概是104us,可以看到这里一位就是100微秒多一点就是104us,没发送数据的时候是空闲状态高电平,数据帧开始先发送起始位产生下降沿,代表数据帧开始,数据0x55 转为二进制,低位先行,就是一次发送10101010,然后这个参数是八位数据,1位停止无效验位,所以之后就是停止位,把引脚置回高电平,这样一个数据帧就完成了,在STM32中,这个根据字节数据翻转高低电平,是由USART外设自动完成的,不用我们操心,当然你也可以软件模拟产生这样的波形,那就是定时器定一个104微秒的时间,时间到之后按照数据帧的要求调用GPIO_WriteBit置高低电平,产生一个和这一模一样的波形,这样也是可以完成串口通讯的,tx引脚发送,就是置高低电平,那在rx引脚接收就是读取高低电平了,这也可以由USART外设自动来完成,不用我们操心,如果想要软件模拟的话,那就是定时调用GPIO_ReadInputDataBit来读取每一位,最终拼接成一个字节,当然接收的时候应该还需要一个外部中断,在起始位的下降沿触发,进入接收状态,并且对齐采样时钟,然后依次采样8次,这就是接收的逻辑。

总结一下就是,TX引脚输出定时翻转的高低电平,RX引脚定时读取引脚的高低电平。每个字节的数据加上起始位、停止位、可选的校验位,打包为数据帧,依次输出在TX引脚,另一端RX引脚依次接收,这样就完成了字节数据的传递,这就是串口通信。

了解完串口协议,接下来我们就来看一下STM32的USART外设

STM32的USART串口

在这里插入图片描述

我们经常还会遇到串口,叫UART,少了个S,就是通用异步收发器,一般我们串口很少使用这个同步功能,所以USART和UART使用起来,也没有什么区别。其实这个STM32的USART同步模式,只是多了个时钟输出而已,它只支持时钟输出,不支持时钟输入,所以这个同步模式更多的是为了,兼容别的协议或者特殊用途而设计的,并不支持两个USART之间进行同步通信。所以我们学习串口,主要还是异步通信。

我们之前学习了串口的协议,串口主要就是靠收发这样的、约定好的波形来进行通信的,那这个USART外设,就是串口通信的硬件支持电路。当我们配置好了USART电路,直接读写数据寄存器就能自动发送和接收数据的使用还是非常方便的。

在这里插入图片描述
这个波特率发生器,就是用来配置波特率的,它其实就是一个分频器,比如我们APB2总线给个72MHZ频率,然后波特率发生器进行一个分频,得到我们想要的波特率时钟,最后在这个时钟下进行收发,就是我们指定的通信波特率。

串行通信一般是以帧格式传输数据,即是一帧一帧的传输,每帧包含有起始信号、数据信息、停止信息, 可能还有校验信息。USART就是对这些传输参数有具体规定,当然也不是只有唯一一个参数值,很多参数值都可以自定义设置,只是增强它的兼容性。

在这里插入图片描述

这个同步模式,就是多了个时钟CLK的输出;硬件流控制,比如A设备的TX脚向B设备的RX脚发送数据,A设备一直在发,发的太快了,B处理不过来,如果没有硬件流控制,那B就只能抛弃新数据或者覆盖原数据了。如果有硬件流控制,在硬件电路上,会多出一根线,如果B没准备好接收,就置高电平,如果准备好了,就置低电平。A接收到了B反馈的准备信号,就只会在B准备好的时候,才发数据,如果B没准备好,那数据就不会发送出去。这就是硬件流控制,可以防止因为B处理慢而导致数据丢失的问题;之后DMA,是这个串口支持DMA进行数据转运,可以使用DMA转运数据,减轻CPU的负担;最后,智能卡、IrDA、LIN,这些是其他的一些协议。因为这些协议和串口是非常的像,所以STM32就对USART加了一些小改动,就能兼容这么多协议了,不过我们一般不用,像这些协议,Up主也都没用过。

最后看一下我们这个芯片的USART资源,STM32F103C8T6的USART资源有USART1、USART2、USART3,总共三个独立的USART外设,可以挂载很多串口设备,其中这里USART1是APB2总线上的设备,剩下的都是APB1总线中的设备,开启时钟的时候注意一下。

USART框图详解

在这里插入图片描述

接下来我们来看一下USART的框图,这个框图大家第一眼看上去还是非常复杂的是吧,实际上主要部分也没有很多,他这里就是把各个寄存器和寄存器每一位控制的地方都画出来了,所以才显得比较乱
,我们看的时候可以先忽略这些寄存器,先看主体结构。

引脚部分:

在这里插入图片描述

TX: 发送数据输出引脚。

RX: 接收数据输入引脚。

SCLK: 发送器时钟输出引脚。这个引脚仅适用于同步模式。

下面这里的SWRX、IRDA_OUT/IN这些是智能卡和IrDA通信的引脚,我们不用这些协议,所以这些引脚就不用管的。

SW_RX: 数据接收引脚,只用于单线和智能卡模式,属于内部引脚,没有具体外部引脚。

nRTS: 请求以发送(Request To Send),n表示低电平有效。如果使能RTS流控制,当USART接收器准备好接收新数据时就会将nRTS变成低电平; 当接收寄存器已满时,nRTS将被设置为高电平。该引脚只适用于硬件流控制。

nCTS: 清除以发送(Clear To Send),n表示低电平有效。如果使能CTS流控制,发送器在发送下一帧数据之前会检测nCTS引脚, 如果为低电平,表示可以发送数据,如果为高电平则在发送完当前数据帧之后停止发送。该引脚只适用于硬件流控制。

数据寄存器:

在这里插入图片描述

一个是发送数据寄存器TDR(Transmit DR),另一个是接收数据计算器RDR(Receive DR),这两个寄存器占用同一个地址,就跟51单片机串口的SBUF寄存器一样,在程序上只表现为一个寄存器,就是数据寄存器DR(Data Register),但实际硬件中是分成了两个寄存器,一个用于发送TDR,一个用于接收RDR,TDR是只写的,RDR是只读的,当你进行写操作时,数据就写到了TDR,当你进行读操作时,数据就是从RDR读出来的,这个了解一下。

移位寄存器:

然后往下看,下面是两个移位寄存器,一个用于发送,一个用于接收。发送移位寄存器的作用就是,把一个字节的数据一位一位地移出去,正好对应串口协议的波形的数据位。
这两个寄存器是怎么工作的呢?(图中主要讲的是发送寄存器)

在这里插入图片描述

注意一下,当TXE标志位置1时,数据其实还没有发送出去,只要数据从TDR转移到发送移位寄存器了,TXE就会置1,我们就可以写入新的数据了。简单来说,就是你数据一旦从TDR转移到移位寄存器了,管你有没有移位完成,我就立刻把下一个数据放在TDR等着,一旦移完了,新的数据就会立刻跟上,这样做效率就会比较高。

看一下接收端这里,也是类似的。数据从RX引脚通向接收移位寄存器,在接收器控制的驱动下,一位一位地读取RX电平,先放在最高位,然后向右移,移位8次之后,就能接收一个字节了。同样,因为串口协议规定是低位先行,所以接收移位寄存器是从高位往低位这个方向移动的。之后,当一个字节移位完成之后,这一个字节的数据就会整体地,一下子转移到接收数据寄存器RDR里来,在转移的过程中,也会置一个标志位叫RXNE (RXNot Empty),接收数据寄存器非空,当我们检测到RXNE置1之后,就可以把数据读走了。同样,这里也是两个寄存器进行缓存,当数据从移位寄存器转移到RDR时,就可以直接移位接收下一帧数据了。

这就是USART外设整个的工作流程,其实讲到这里,这个外设的主要功能就差不多了。大体上,就是数据寄存器和移位寄存器,发送移位寄存器往TX引脚移位,接收移位寄存器从RX引脚移位。当然发送还需要加上帧头帧尾,接收还需要剔除帧头帧尾,这些操作,它内部有电路会自动执行。我们知道有硬件帮我们做了这些工作就行了。

接着我们继续看一下下面的控制部分和一些其他的增强功能

硬件流控:

下面这里是发送器控制,它就是用来控制发送移位寄存器的工作的;接收器控制,用来控制接收移位寄存器的工作;然后左边这里,有一个硬件数据流控,也就是硬件流控制,简称流控。

这里流控有两个引脚,一个是nRTS,一个是nCTS。nRTS(Request To Send)是请求发送,是输出脚,也就是告诉别人,我当前能不能接收;nCTS (Clear To Send)是清除发送,是输入脚,也就是用于接收别人nRTS的信号的。
  
在这里插入图片描述

这里前面加个n意思是低电平有效,那这两个脚上怎么玩的呢?

首先,我们需要找到一个支持流控的串口,并将它的TX连接到我们的RX。同时,我们的RTS需要输出一个接收反馈信号,并将其连接到对方的CTS。当我们可以接收数据时,RTS会置为低电平,请求对方发送。对方的CTS接收到信号后,就可以继续发送数据。如果处理不过来,比如接收数据寄存器未及时读取,导致新数据无法接收,此时RTS会置为高电平,对方的CTS接收到信号后,就会暂停发送,直到接收数据寄存器被读走,RTS重新置为低电平,数据才会继续发送。

反过来当我们的TX向对方发送数据时,对方的RTS会连接到我们的CTS,用于判断对方是否可以接收数据。TX和CTS是一对对应的信号,RX和RTS也是一对对应的信号。此外,CTS和RTS之间也需要交叉连接,这就是流控的工作模式。然而,我们一般不使用流控,因此只需要了解一下即可。

SCLK控制:

在这里插入图片描述

接着继续看右边这个模块,这部分电路用于产生同步的时钟信号,它是配合发送移位寄存器输出的,发送寄存器每移位一次,同步时钟电平就跳变一个周期。时钟告诉对方,我移出去一位数据,你看要不要让我这个时钟信号来指导你接收一下?当然这个时钟只支持输出,不支持输入,所以两个USART之间,不能实现同步的串口通信。

那这个时钟信号有什么用呢?
兼容别的协议。比如串口加上时钟之后,就跟SPI协议特别像,所以有了时钟输出的串口,就可以兼容SPI。另外这个时钟也可以做自适应波特率,比如接收设备不确定发送设备给的什么波特率,就可以先测量一下这个时钟的周期,然后再计算得到波特率,不过这就需要另外写程序来实现这个功能了。这个时钟功能,我们一般不用,所以也是了解一下就行

唤醒单元:

在这里插入图片描述

这部分的作用是实现串口挂载多设备。我们之前说,串口一般是点对点的通信(只支持两个设备互相通信)。而多设备,在一条总线上,可以接多个从设备,每个设备分配一个地址,我想跟某个设备通信,就先进行寻址,确定通信对象,再进行数据收发。那回到这里,这个唤醒单元就可以用来实现多设备的功能,在这里可以给串口分配一个地址,当你发送指定地址时,此设备唤醒开始工作,当你发送别的设备地址时,别的设备就唤醒工作,这个设备没收到地址,就会保持沉默。这样就可以实现多设备的串口通信了,这部分功能我们一般不用。

中断输出控制:

在这里插入图片描述

中断申请位,就是状态寄存器这里的各种标志位,状态寄存器这里,有两个标志位比较重要,一个是TXE发送寄存器空,另一个是RXNE接收寄存器非空,这两个是判断发送状态和接收状态的必要标志位,剩下的标志位,了解一下就行。中断输出控制这里,就是配置中断是不是能通向NVIC,这个应该好理解。

波特率发生器部分:

在这里插入图片描述

波特率发生器其实就是分频器,APB时钟进行分频,得到发送和接收移位的时钟。看一下,这里时钟输入是fPCLKx(x=1或2),(USART1挂载在APB2,所以就是PCLK2的时钟,一般是72M;其他的USART都挂载在APB1,所以是PCLK1的时钟,一般是36M)之后这个时钟进行一个分频,除一个USARTDIV的分频系数,并且分为了整数部分和小数部分,因为有些波特率,用72M除一个整数的话,可能除不尽,会有误差。所以这里分频系数是支持小数点后4位的,分频就更加精准,之后分频完之后,还要再除个16,得到发送器时钟和接收器时钟,通向控制部分。然后右边这里,如果TE (TX Enable)为1,就是发送器使能了,发送部分的波特率就有效;如果RE(RX Enable)为1,就是接收器使能了,接收部分的波特率就有效。

然后剩下还有一些寄存器的指示
比如各个CR控制寄存器的哪一位控制哪一部分电路,SR状态寄存器都有哪些标志位,这些可以自己看看手册里的寄存器描述,那里的描述比这里清晰很多。

引脚定义表,这里复用功能这一栏,就给出了每个USART它的各个引脚都是复用在了哪个GPIO上的。

在这里插入图片描述

这些引脚都必须按照引脚定义里的规定来,或者看一下重映射这里,有没有重映射,这里有USART1的重映射,所以有机会换一次口,剩下引脚,就没有机会作为USART1的接口了。

在这里插入图片描述

USART基本结构
给的一个简化结构图

在这里插入图片描述

那到这里,USART的基本结构就讲完了。

几个小细节
数据帧:
这个图,是在程序中配置8位字长和9位字长的波形对比。这里的字长,就是我们前面说的数据位长度。他这里的字长,是包含校验位的,是这种描述方式。

在这里插入图片描述

总的来说,这里有4种选择,9位字长,有校验或无校验;8位字长,有校验或无校验。但我们最好选择9位字长 有校验,或8位字长 无校验,这两种,这样每一帧的有效载荷都是1字节,这样才舒服。

配置停止位:
那最后这些时钟什么的,和上面也都是类似的
接下来我们继续来看这个数据帧,看一下不同停止位的波形变化。STM32的串口,可以配置停止位长度为0.5、1、1.5、2,这四种。

在这里插入图片描述

这四种参数的区别,就是停止位的时长不一样。第一个是1个停止位,这时停止位的时长就和数据位的一位,时长一样;然后是1.5个停止位,这时的停止位就是数据位一位,时长的1.5倍;2个停止位,那停止位时长就是2倍;0.5个停止位,时长就是0.5倍。这个也好理解,就是控制停止位时长的,一般选择1位停止位就行了,其他的参数不太常用。这个是停止位。

在这里插入图片描述

起始位侦测和数据采样:

那之后,我们继续来看一些细节问题,这两个图展示的是USART电路输入数据的一些策略。对于串口来说,根据我们前面的介绍,可以想到,串口的输出TX应该是比输入RX简单很多,输出你就定时翻转TX引脚高低电平就行了。但是输入,就复杂一些。你不仅要保证,输入的采样频率和波特率一致,还要保证每次输入采样的位置,【要正好处于每一位的正中间,只有在每一位的正中间采样,这样高低电平读进来,才是最可靠的,如果你采样点过于靠前或靠后,那有可能高低电平还正在翻转,电平还不稳定,或者稍有误差,数据就采样错了】。另外,输入最好还要对噪声有一定的判断能力,如果是噪声,最好能置个标志位提醒我一下,这些就是输入数据所面临的问题。
那我们来看一下STM32是如何来设计输入电路的呢?

在这里插入图片描述

在这里插入图片描述

第一个图展示了USART的起始位侦测。当输入电路侦测到数据帧的起始位后,将以波特率的频率连续采样一帧数据。同时,从起始位开始,采样位置要对齐到位的正中间。只要第一位对齐了,后面就都是对齐的。

为了实现这些功能,输入电路对采样时钟进行了细分,以波特率的16倍频率进行采样。在一位的时间里,可以进行16次采样。比如最开始时,空闲状态为高电平,采样一直是1。在某个位置突然采到0,说明两次采样之间出现了下降沿,如果没有噪声,那之后就应该是起始位了。在起始位,会进行连续16次采样,没有噪声的话,这16次采样肯定都是0。但是实际电路还是会存在一些噪声,所以这里即使出现下降沿了,后续也要再采样几次以防万一。

根据手册描述,接收电路在下降沿之后的第3次、5次、7次进行一批采样,在第8次、9次、10次再进行一批采样。这两批采样都要求每3位里面至少应有2个0。如果没有噪声,那肯定全是0,满足情况;如果有一些轻微的噪声导致3位里面只有两个0,另一个是1,那也算是检测到了起始位(但是在状态寄存器里会置一个NE(Noise Error),提醒你数据收到了但是有噪声,你悠着点用);如果3位里面只有1个0,那就不算检测到了起始位,可能前面那个下降沿是噪声导致的,这时电路就忽略前面的数据重新开始捕捉下降沿。

这就是STM32的串口在接收过程中对噪声的处理。如果通过了这个起始位侦测那接收状态就由空闲变为接收起始位,同时第8、9、10次采样的位置就正好是起始位的正中间。之后接收数据位时就在第8、9、10次进行采样这样就能保证采样位置在位的正中间了。这就是起始位侦测和采样位置对齐的策略。

那紧跟着,我们就可以看这个数据采样的流程了。

在这里插入图片描述

在这里插入图片描述

这里,从1到16,是一个数据位的时间长度,在一个数据位,有16个采样时钟,由于起始位侦测已经对齐了采样时钟,所以,这里就直接在第8、9、10次采样数据位。为了保证数据的可靠性,这里是连续采样3次,没有噪声的理想情况下,这3次肯定全为1或者全为0,全为1,就认为收到了1,全为0,就认为收到了0;如果有噪声,导致3次采样不是全为1或者全为0,那它就按照2:1的规则来,2次为1,就认为收到了1,2次为0,就认为收到了0,在这种情况下,噪声标志位NE也会置1,告诉你,我收到数据了,但是有噪声,你悠着点用,这就是检测噪声的数据采样,可见STM32对这个电路的设计考虑还是很充分的

波特率发生器:
那最后,我们再来看一下波特率发生器

在这里插入图片描述

波特率发生器就是分频器,发射器和接收器的波特率,由波特率寄存器BRR里面的DIV确定,下面这个图就是BRR寄存器,里面就是分频系数div ,div分为整数部分和小数部分,可以实现更细腻的分频,那波特率和分频系数的关系可以由计算公式进行计算,为什么这里多个16,看上面这个图就明白了吧,因为它内部还有一个16倍波特率的采样时钟,所以这里输入时钟除以div要等于16倍的波特率,最终计算波特率自然要多出一个16了。

举个例子,比如我要配置USART1为9600的波特率,那如何配置这个BRR寄存器呢?
我们代入公式,就是9600等于 USART1的时钟是72M 除 16倍的DIV,解得,DIV=72M/9600/16,最终等于468.75,则二进制数是11101 0100.11v。所以最终写到这个寄存器就是整数部分为11101 0100,前面多出来的补0,小数部分为11,后面多出来的补0。这就是根据波特率写BRR寄存器的方法,了解一下,不过,我们用库函数配置的话,就非常方便,需要多少波特率,直接写就行了,库函数会自动帮我们算。

手册讲解
USB转串口模块的内部电路图

在这里插入图片描述

最左边这里是usb的端口,usb有四根线,usb标准供电是5V,然后中间D+和D-是通信线,走的也是usb协议,所以这里需要加一个ch340芯片转换一下,转换之后输出的就是txd和rxd是串口协议,最后通过这里的排针引出来,那需要注意的就是这边的供电策略,首先所有的电都是从这个vcc +5V来的,然后vcc +5V通过这个稳压管电路进行稳压,得到vcc +3.3V之后,vcc +5V和vcc +3.3V都通过排针引出来了,所以这个第六脚和第四脚是分别由5V和3.3V输出的。

。。

参考手册

。。

代码实战:串口发送单字节

9-1串口发送:

下面这个是我们的USB转串口的模块,这里有个跳线帽,上节也说过,要插在VCC和3V3这两个脚上,选择通信的TTL电平为3.3V,然后通信引脚,TXD和RXD,要接在STM32的PA9和PA10口。为什么是这两个口呢,我们看一下引脚定义表就知道USART1的TX是PA9, RX是PA10,我们计划用USART1进行通信,所以就选这两个脚。TX和RX交叉连接,这边一定要注意,别接错了。然后,两个设备之间要把负极接在一起,进行共地,一般多个系统之间互连,都要进行共地。最后,这个串口模块和STLINK都要插在电脑上,这样,STM32和串口模块都有独立供电,所以这里通信的电源正极就不需要接了。

在这里插入图片描述

当然我们第一个代码,只有STM32发送的部分,所以,通信线只有这个发送的有用,另一根线,第一个代码没有用到,暂时可以不接,在我们下一个串口发送+接收的代码,两根通信线就都需要接了。所以我们把这两根通信线一起都接上吧,这样两个代码的接线图是一模一样的。

在这里插入图片描述

老规矩,上来先写一个初始化函数

第一步,开启时钟,把需要用的USART和GPIO的时钟打开
第二步,GPIO初始化,把TX配置成复用输出,RX配置成输入
第三步,配置USART,直接使用一个结构体,就可以把这里所有的参数都配置好了
第四步,如果你只需要发送的功能,就直接开启USART,初始化就结束了。如果你需要接收的功能,可能还需要配置中断,那就在开启USART之前,再加上ITConfig和NVIC的代码就行了。
得益于库函数的封装,内部各种细节问题就不需要我们再关心了。

那初始化完成之后,如果要发送数据,调用一个发送函数就行了;如果要接收数据,就调用接收的函数;如果要获取发送和接收的状态,就调用获取标志位的函数,这就是USART外设的使用思路。

在这里插入图片描述

看一下库函数里面都有哪些库函数,我们找一下usart.h的文件,拖到最后这里函数一眼看上去还是挺多的,但是这里面很多都是那些增强功能和兼容其他协议的函数,真正常用的其实很少。

USART_DMACmd这个可以开启USART到DMA的触发通道,需要用DMA的话可以了解一下,这两个函数比较重要,USART_SendData发送数据,USART_ReceiveData接收数据,这两个函数在我们发送和接收的时候会用到,SendData就是写DR寄存器,ReceiveData就是读DR寄存器。

Serial.h:

#ifndef __SERIAL_H
#define __SERIAL_H

#include <stdio.h>

void Serial_Init(void);
void Serial_SendByte(uint8_t Byte);
void Serial_SendArray(uint8_t *Array, uint16_t Length);
void Serial_SendString(char *String);
void Serial_SendNumber(uint32_t Number, uint8_t Length);
void Serial_Printf(char *format, ...);

#endif

Serial.c:

#include "stm32f10x.h"                  // Device header
#include <stdio.h>//重定向printf 重写fputc
#include <stdarg.h>

/**
  * 函    数:串口初始化
  * 参    数:无
  * 返 回 值:无
  */
void Serial_Init(void)
{
	/*开启时钟*/
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);	//开启USART1的时钟
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);	//开启GPIOA的时钟
	
	/*GPIO初始化*/
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;			//复用推挽输出
	//PA10要选择输入模式,输入模式并不分什么普通输入复用输入,一根线只能有一个输出,但可以有多个输入,但可以有多个输入,但可以有多个输入,所以输入脚,外设和GPIO都可以同时用
	//一般RX配置是浮空输入或者上拉输入,因为串口波形空闲状态是高电平,所以不使用下拉输入
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);					//将PA9引脚初始化为复用推挽输出
	
	/*USART初始化*/
	USART_InitTypeDef USART_InitStructure;					//定义结构体变量
	USART_InitStructure.USART_BaudRate = 9600;				//波特率  写完之后这个函数内部会自动算好9600对应的分频系数,然后写到BRR寄存器
	USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;	//硬件流控制,不需要
	USART_InitStructure.USART_Mode = USART_Mode_Tx;			//模式,选择为发送模式
	USART_InitStructure.USART_Parity = USART_Parity_No;		//奇偶校验,不需要
	USART_InitStructure.USART_StopBits = USART_StopBits_1;	//停止位,选择1位
	USART_InitStructure.USART_WordLength = USART_WordLength_8b;		//字长,选择8位
	USART_Init(USART1, &USART_InitStructure);				//将结构体变量交给USART_Init,配置USART1
	
	/*USART使能*/
	USART_Cmd(USART1, ENABLE);								//使能USART1,串口开始运行
}

/**
  * 函    数:串口发送一个字节
  * 参    数:Byte 要发送的一个字节
  * 返 回 值:无
  */
void Serial_SendByte(uint8_t Byte)
{
	USART_SendData(USART1, Byte);		//将字节数据写入数据寄存器,写入后USART自动生成时序波形
	while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);	//等待发送完成
	/*查看数据手册,下次写入数据寄存器会自动清除发送完成标志位,故此循环后,无需清除标志位*/
}

/**
  * 函    数:串口发送一个数组
  * 参    数:Array 要发送数组的首地址
  * 参    数:Length 要发送数组的长度
  * 返 回 值:无
  */
void Serial_SendArray(uint8_t *Array, uint16_t Length)
{
	uint16_t i;
	for (i = 0; i < Length; i ++)		//遍历数组
	{
		Serial_SendByte(Array[i]);		//依次调用Serial_SendByte发送每个字节数据
	}
}

/**
  * 函    数:串口发送一个字符串
  * 参    数:String 要发送字符串的首地址
  * 返 回 值:无
  */
void Serial_SendString(char *String)//由于字符串自带一个结束标志位,所以就不需要再传递长度参数了。
{
	uint8_t i;
	for (i = 0; String[i] != '\0'; i ++)//遍历字符数组(字符串),遇到字符串结束标志位后停止
	{	//刚才表格里说过了,0对应空字符,是字符串结束标志位,这里数据0也可以写成字符的形式
		//就是单引号引起来反斜杠零,这就是空字符的转义字符表示形式					
		Serial_SendByte(String[i]);		//依次调用Serial_SendByte发送每个字节数据
	}
}

/**
  * 函    数:次方函数(内部使用)
  * 返 回 值:返回值等于X的Y次方
  */
uint32_t Serial_Pow(uint32_t X, uint32_t Y)
{
	uint32_t Result = 1;	//设置结果初值为1
	while (Y --)			//执行Y次
	{
		Result *= X;		//将X累乘到结果
	}
	return Result;
}

/**
  * 函    数:串口发送数字 电脑显示字符串形式的数字  
  * 		 需要把Num的个位、十位、百位等以十进制拆分开,然后转换成字符数字对应的数据
  * 参    数:Number 要发送的数字,范围:0~4294967295
  * 参    数:Length 要发送数字的长度,范围:0~10
  * 返 回 值:无
  */
void Serial_SendNumber(uint32_t Number, uint8_t Length)
{
	uint8_t i;
	for (i = 0; i < Length; i ++)		//根据数字长度遍历数字的每一位
	{
		Serial_SendByte(Number / Serial_Pow(10, Length - i - 1) % 10 + '0');	//依次调用Serial_SendByte发送每位数字
	}
}

/*重定向printf:
使用printf之前,需要打开工程选项,把这个Use MicroLIB勾上,MicroLIB是keil为嵌入式平台优化的一个精简库,
我们等会要用的printf函数就可以用这个MicroLIB,然后我们还需要对printf进行重定向,因为printf函数默认是输出到屏幕
我们单片机没有屏幕,所以要进行重定向输出到串口,在串口模块里最开始加上#include <stdio.h>,然后重写fputc函数
*/

/**
  * 函    数:使用printf需要重定向的底层函数
  * 参    数:保持原始格式即可,无需变动
  * 返 回 值:保持原始格式即可,无需变动
  */
int fputc(int ch, FILE *f)
{
	Serial_SendByte(ch);			//fputc是printf的底层,将printf的底层重定向到自己的发送字节函数
	return ch;
}

/**
  * 函    数:自己封装的prinf函数
  * 参    数:format 格式化字符串
  * 参    数:... 可变的参数列表
  * 返 回 值:无
  */
void Serial_Printf(char *format, ...)//可变参数
{
	char String[100];				//定义字符数组
	va_list arg;					//定义可变参数列表数据类型的变量arg
	va_start(arg, format);			//从format开始,接收参数列表到arg变量
	vsprintf(String, format, arg);	//使用vsprintf打印格式化字符串和参数列表到字符数组中
	va_end(arg);					//结束变量arg
	Serial_SendString(String);		//串口发送字符数组(字符串)
}

mian.c部分:

#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "Serial.h"

int main(void)
{
	/*模块初始化*/
	OLED_Init();						//OLED初始化
	
	Serial_Init();						//串口初始化
	
	/*串口基本函数*/
	Serial_SendByte(0x41);				//串口发送一个字节数据0x41,TX引脚产生一个0x41波形
	//Serial_SendByte('A'); 这两个一样的,线路中传输的还是0x41
	
	uint8_t MyArray[] = {0x42, 0x43, 0x44, 0x45};	//定义数组
	Serial_SendArray(MyArray, 4);		//串口发送一个数组
	
	Serial_SendString("HelloWorld!");	//字符串用双引号括起来,编译器自动追加结束位,所以字符串的存储空间会比字符的个数大1
	Serial_SendString("\r\nNum1=");		//串口发送字符串 \r\n表示换行,ASCII表里有
	
	Serial_SendNumber(111, 3);			//串口发送数字
	

	/*下述3种方法可实现printf的效果*/
	
	/*方法1:直接重定向printf,重定向到串口1了,串口2再用就没有了*/
	printf("\r\nNum2=%d", 222);			
	
	/*方法2:使用sprintf打印到字符数组,再用串口发送字符数组,此方法打印到字符数组,之后想怎么处理都可以,可在多处使用*/
	char String[100];					//定义字符数组
	sprintf(String, "\r\nNum3=%d", 333);//使用sprintf,把格式化字符串打印到字符数组
	Serial_SendString(String);			//串口发送字符数组(字符串)
	
	/*方法3:将sprintf函数封装起来,实现专用的printf,此方法就是把方法2封装起来,更加简洁实用,可在多处使用*/
	Serial_Printf("\r\nNum4=%d", 444);	//串口打印字符串,使用自己封装的函数实现printf的效果
	Serial_Printf("\r\n");
	
	while (1)
	{
		
	}
}

在这里插入图片描述

代码实战:串口发送+接收单字节

Serial.h:

#ifndef __SERIAL_H
#define __SERIAL_H

#include <stdio.h>

void Serial_Init(void);
void Serial_SendByte(uint8_t Byte);
void Serial_SendArray(uint8_t *Array, uint16_t Length);
void Serial_SendString(char *String);
void Serial_SendNumber(uint32_t Number, uint8_t Length);
void Serial_Printf(char *format, ...);

uint8_t Serial_GetRxFlag(void);
uint8_t Serial_GetRxData(void);

#endif

Serial.c:

#include "stm32f10x.h"                  // Device header
#include <stdio.h>
#include <stdarg.h>

uint8_t Serial_RxData;		//定义串口接收的数据变量
uint8_t Serial_RxFlag;		//定义串口接收的标志位变量

/**
  * 函    数:串口初始化
  * 参    数:无
  * 返 回 值:无
  */
void Serial_Init(void)
{
	/*开启时钟*/
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);	//开启USART1的时钟
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);	//开启GPIOA的时钟
	
	/*GPIO初始化*/
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);					//将PA9引脚初始化为复用推挽输出
	
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);					//将PA10引脚初始化为上拉输入
	
	/*USART初始化*/
	USART_InitTypeDef USART_InitStructure;					//定义结构体变量
	USART_InitStructure.USART_BaudRate = 9600;				//波特率
	USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;	//硬件流控制,不需要
	USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;	//模式,发送模式和接收模式均选择
	USART_InitStructure.USART_Parity = USART_Parity_No;		//奇偶校验,不需要
	USART_InitStructure.USART_StopBits = USART_StopBits_1;	//停止位,选择1位
	USART_InitStructure.USART_WordLength = USART_WordLength_8b;		//字长,选择8位
	USART_Init(USART1, &USART_InitStructure);				//将结构体变量交给USART_Init,配置USART1
	
	/*
	到这里串口接收代码其实就配置差不多了,对串口接收来说,可以使用查询和中断两种方法
	如果使用查询,那初始化就结束了,如果使用中段,那还需要在这里开启中段配置NVIC
	查询的流程是在主函数里不断判断RXNE标志位,如果置1了就说明收到数据了,那再调用ReceiveData读取DR寄存器,这样就行了
	代码比较简单可以使用查询法,实际工作中用中断
	*/


	/*中断输出配置*/
	USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);			//开启串口接收数据的中断
	
	/*NVIC中断分组*/
	NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);			//配置NVIC为分组2
	
	/*NVIC配置*/
	NVIC_InitTypeDef NVIC_InitStructure;					//定义结构体变量
	NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;		//选择配置NVIC的USART1线
	NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;			//指定NVIC线路使能
	NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;		//指定NVIC线路的抢占优先级为1
	NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;		//指定NVIC线路的响应优先级为1
	NVIC_Init(&NVIC_InitStructure);							//将结构体变量交给NVIC_Init,配置NVIC外设
	
	/*USART使能*/
	USART_Cmd(USART1, ENABLE);								//使能USART1,串口开始运行
}

/**
  * 函    数:串口发送一个字节
  * 参    数:Byte 要发送的一个字节
  * 返 回 值:无
  */
void Serial_SendByte(uint8_t Byte)
{
	USART_SendData(USART1, Byte);		//将字节数据写入数据寄存器,写入后USART自动生成时序波形
	while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);	//等待发送完成
	/*下次写入数据寄存器会自动清除发送完成标志位,故此循环后,无需清除标志位*/
}

/**
  * 函    数:串口发送一个数组
  * 参    数:Array 要发送数组的首地址
  * 参    数:Length 要发送数组的长度
  * 返 回 值:无
  */
void Serial_SendArray(uint8_t *Array, uint16_t Length)
{
	uint16_t i;
	for (i = 0; i < Length; i ++)		//遍历数组
	{
		Serial_SendByte(Array[i]);		//依次调用Serial_SendByte发送每个字节数据
	}
}

/**
  * 函    数:串口发送一个字符串
  * 参    数:String 要发送字符串的首地址
  * 返 回 值:无
  */
void Serial_SendString(char *String)
{
	uint8_t i;
	for (i = 0; String[i] != '\0'; i ++)//遍历字符数组(字符串),遇到字符串结束标志位后停止
	{
		Serial_SendByte(String[i]);		//依次调用Serial_SendByte发送每个字节数据
	}
}

/**
  * 函    数:次方函数(内部使用)
  * 返 回 值:返回值等于X的Y次方
  */
uint32_t Serial_Pow(uint32_t X, uint32_t Y)
{
	uint32_t Result = 1;	//设置结果初值为1
	while (Y --)			//执行Y次
	{
		Result *= X;		//将X累乘到结果
	}
	return Result;
}

/**
  * 函    数:串口发送数字
  * 参    数:Number 要发送的数字,范围:0~4294967295
  * 参    数:Length 要发送数字的长度,范围:0~10
  * 返 回 值:无
  */
void Serial_SendNumber(uint32_t Number, uint8_t Length)
{
	uint8_t i;
	for (i = 0; i < Length; i ++)		//根据数字长度遍历数字的每一位
	{
		Serial_SendByte(Number / Serial_Pow(10, Length - i - 1) % 10 + '0');	//依次调用Serial_SendByte发送每位数字
	}
}

/**
  * 函    数:使用printf需要重定向的底层函数
  * 参    数:保持原始格式即可,无需变动
  * 返 回 值:保持原始格式即可,无需变动
  */
int fputc(int ch, FILE *f)
{
	Serial_SendByte(ch);			//将printf的底层重定向到自己的发送字节函数
	return ch;
}

/**
  * 函    数:自己封装的prinf函数
  * 参    数:format 格式化字符串
  * 参    数:... 可变的参数列表
  * 返 回 值:无
  */
void Serial_Printf(char *format, ...)
{
	char String[100];				//定义字符数组
	va_list arg;					//定义可变参数列表数据类型的变量arg
	va_start(arg, format);			//从format开始,接收参数列表到arg变量
	vsprintf(String, format, arg);	//使用vsprintf打印格式化字符串和参数列表到字符数组中
	va_end(arg);					//结束变量arg
	Serial_SendString(String);		//串口发送字符数组(字符串)
}

/**
  * 函    数:获取串口接收标志位
  * 参    数:无
  * 返 回 值:串口接收标志位,范围:0~1,接收到数据后,标志位置1,读取后标志位自动清零
  */
uint8_t Serial_GetRxFlag(void)
{
	if (Serial_RxFlag == 1)			//如果标志位为1
	{
		Serial_RxFlag = 0;
		return 1;					//则返回1,并自动清零标志位
	}
	return 0;						//如果标志位为0,则返回0
}

/**
  * 函    数:获取串口接收的数据
  * 参    数:无
  * 返 回 值:接收的数据,范围:0~255
  */
uint8_t Serial_GetRxData(void)
{
	return Serial_RxData;			//返回接收的数据变量
}

/**
  * 函    数:USART1中断函数
  * 参    数:无
  * 返 回 值:无
  * 注意事项:此函数为中断函数,无需调用,中断触发后自动执行
  *           函数名为预留的指定名称,可以从启动文件复制
  *           请确保函数名正确,不能有任何差异,否则中断函数将不能进入
  */
void USART1_IRQHandler(void)
{
	if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET)		//判断是否是USART1的接收事件触发的中断
	{
		Serial_RxData = USART_ReceiveData(USART1);				//读取数据寄存器,存放在接收的数据变量
		Serial_RxFlag = 1;										//置接收标志位变量为1
		USART_ClearITPendingBit(USART1, USART_IT_RXNE);			//清除USART1的RXNE标志位
																//读取数据寄存器会自动清除此标志位
																//如果已经读取了数据寄存器,也可以不执行此代码
	}
}

main.c部分:

#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "Serial.h"

uint8_t RxData;			//定义用于接收串口数据的变量

int main(void)
{
	/*模块初始化*/
	OLED_Init();		//OLED初始化
	
	/*显示静态字符串*/
	OLED_ShowString(1, 1, "RxData:");
	
	/*串口初始化*/
	Serial_Init();		//串口初始化
	
	while (1)
	{
		if (Serial_GetRxFlag() == 1)			//检查串口接收数据的标志位
		{
			RxData = Serial_GetRxData();		//获取串口接收的数据
			Serial_SendByte(RxData);			//串口将收到的数据回传回去,用于测试
			OLED_ShowHexNum(1, 8, RxData, 2);	//显示串口接收的数据
		}
	}
}

在这里插入图片描述

USART串口数据包收发

先来看两张图,是关于我规定的数据包格式,一种是HEX数据包,一种是文本数据包,之后两个图,展示的就是接收数据包的思路。

在这里插入图片描述

接着我们来研究几个问题:

第一个问题:包头包尾和数据载荷重复的问题,这里定义FF为包头,FE为包尾,如果我传输的数据本身就是FF和FE怎么办呢?那这个问题确实存在,如果数据和包头包尾重复,可能会引起误判。对应这个问题我们有如下几种解决方法:第一种,限制载荷数据的范围。如果可以的话,我们可以在发送的时候,对数据进行限幅,比如XYZ,3个数据,变化范围都可以是0~100 那就好办了,我们可以在载荷中只发送0-100的数据,这样就不会和包头包尾重复了;第二种,如果无法避免载荷数据和包头包尾重复,那我们就尽量使用固定长度的数据包。这样由于载荷数据是固定的,只要我们通过包头包尾对齐了数据,我们就可以严格知道,哪个数据应该是包头包尾,哪个数据应该是载荷数据。在接收载荷数据的时候,我们并不会判断它是否是包头包尾,而在接收包头包尾的时候,我们会判断它是不是确实是包头包尾,用于数据对齐。这样,在经过几个数据包的对齐之后,剩下的数据包应该就不会出现问题了;第三种,增加包头包尾的数量,并且尽量让它呈现出载荷数据出现不了的状态。比如我们使用FF、FE作为包头,FD、FC作为包尾,这样也可以避免载荷数据和包头包尾重复的情况发生。

第二个问题:这个包头包尾并不是全部都需要的,比如我们可以只要一个包头,把包尾删掉,这样数据包的格式就是,一个包头FF,加4个数据,这样也是可以的。当检测到FF,开始接收,收够4个字节后,置标志位,一个数据包接收完成,这样也可以。不过这样的话,载荷和包头重复的问题会更严重一些,比如最严重的情况下,我载荷全是FF,包头也是FF,那你肯定不知道哪个是包头了,而加上了FE作为包尾,无论数据怎么变化,都是可以分辨出包头包尾的。

第三个问题:固定包长和可变包长的选择问题,对应HEX数据包来说,如果你的载荷会出现和包头包尾重复的情况,那就最好选择固定包长,这样可以避免接收错误,如果你又会重复,又选择可变包长那数据很容易就乱套了;如果载荷不会和包头包尾重复,那可以选择可变包长,数据长度,像这样,4位、3位、等等,1位、10位,来回任意变,肯定都没问题。因为包头包尾是唯一的,只要出现包头,就开始数据包,只要出现包尾,就结束数据包,这样就非常灵活了,这就是固定包长和可变包长选择的问题。

最后一个问题:各种数据转换为字节流的问题。这里数据包都是一个字节一个字节组成的,如果你想发送16位的整型数据、32位的整型数据,float、double,甚至是结构体,其实都没问题,因为它们内部其实都是由一个字节一个字节组成的,只需要用一个uint8_t的指针指向它,把它们当做一个字节数组发送就行了。

好,有关HEX数据包定义的内容,就讲这么多,接下来看一下文本数据包。

文本数据包和HEX数据包分别对应了文本模式和HEX模式。在HEX数据包中,数据以原始字节形式呈现。而在文本数据包中,每个字节经过了一层编码和译码,最终以文本格式呈现。实际上,每个文本字符背后都有一个字节的HEX数据。

在这里插入图片描述

综上所述,我们需要根据实际场景来选择和设计数据包格式。在需要直接传输和简单解析原始数据的情况下,HEX数据包是更好的选择。而在需要输入指令进行人机交互的场合,文本数据包则更为适用。

好,数据包格式的定义讲完了,接下来我们就来学一下数据包的收发流程。

首先,发送数据包的过程相对简单。在发送HEX数据包时,可以通过定义一个数组,填充数据,然后使用之前我们写过的SendArray函数发送即可。在发送文本数据包时,可以通过写一个字符串,然后调用SendString函数发送。因此,发送数据包的过程是可控的,我们可以根据需要发送任何类型的数据包。相比之下,接收数据包的过程较为复杂。

那接下来,接收一个数据包,这就比较复杂了,我们来学习一下,我这里演示了固定包长HEX数据包的接收方法,和可变包长文本数据包的接收方法,其他的数据包也都可以套用这个形式,等会儿我们写程序就会根据这里面的流程来。

我们先看一下如何来接收这个固定包长的HEX数据包。根据之前的代码,我们每收到一个字节,程序都会进一遍中断,在中断函数里我们可以拿到这一个字节,但拿到之后我们就得退出中断了,所以每拿到一个数据都是一个独立的过程,而对于数据包来说,它具有前后关联性,包头之后是数据,数据之后是包尾,对于包头、数据和包尾这三种状态,我们都需要有不同的处理逻辑要接收固定包长的HEX数据包,我们需要设计一个状态机来处理。
  
在这里插入图片描述

这就是使用状态机接收数据包的思路。这个状态机其实是一种很广泛的编程思路,在很多地方都可以用到,使用的基本步骤是,先根据项目要求定义状态,画几个圈,然后考虑好各个状态在什么情况下会进行转移,如何转移,画好线和转移条件,最后根据这个图来进行编程,这样思维就会非常清晰了。
  
那接下来继续,我们来看一下这个可变包长、文本数据包的接收流程。
  
在这里插入图片描述

好,到这里,我们这个数据包的,定义、分类、优缺点和注意事项,就讲完了,接下来,我们就来写程序,验证一下刚才所学的内容吧。

代码实战:串口收发HEX数据包

下面这个串口模块的接线和之前都是一样的,PB1口接了一个按键,用于控制,下面也同样是串口模块,然后在PA1口接了一个led用于指示。

在这里插入图片描述

Serial.h

#ifndef __SERIAL_H
#define __SERIAL_H

#include <stdio.h>

extern uint8_t Serial_TxPacket[];
extern uint8_t Serial_RxPacket[];

void Serial_Init(void);
void Serial_SendByte(uint8_t Byte);
void Serial_SendArray(uint8_t *Array, uint16_t Length);
void Serial_SendString(char *String);
void Serial_SendNumber(uint32_t Number, uint8_t Length);
void Serial_Printf(char *format, ...);

void Serial_SendPacket(void);
uint8_t Serial_GetRxFlag(void);

#endif

Serial.c部分:

#include "stm32f10x.h"                  // Device header
#include <stdio.h>
#include <stdarg.h>

uint8_t Serial_TxPacket[4];				//定义发送数据包数组,数据包格式:FF 01 02 03 04 FE
uint8_t Serial_RxPacket[4];				//定义接收数据包数组
uint8_t Serial_RxFlag;					//定义接收数据包标志位

/**
  * 函    数:串口初始化
  * 参    数:无
  * 返 回 值:无
  */
void Serial_Init(void)
{
	/*开启时钟*/
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);	//开启USART1的时钟
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);	//开启GPIOA的时钟
	
	/*GPIO初始化*/
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);					//将PA9引脚初始化为复用推挽输出
	
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);					//将PA10引脚初始化为上拉输入
	
	/*USART初始化*/
	USART_InitTypeDef USART_InitStructure;					//定义结构体变量
	USART_InitStructure.USART_BaudRate = 9600;				//波特率
	USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;	//硬件流控制,不需要
	USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;	//模式,发送模式和接收模式均选择
	USART_InitStructure.USART_Parity = USART_Parity_No;		//奇偶校验,不需要
	USART_InitStructure.USART_StopBits = USART_StopBits_1;	//停止位,选择1位
	USART_InitStructure.USART_WordLength = USART_WordLength_8b;		//字长,选择8位
	USART_Init(USART1, &USART_InitStructure);				//将结构体变量交给USART_Init,配置USART1
	
	/*中断输出配置*/
	USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);			//开启串口接收数据的中断
	
	/*NVIC中断分组*/
	NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);			//配置NVIC为分组2
	
	/*NVIC配置*/
	NVIC_InitTypeDef NVIC_InitStructure;					//定义结构体变量
	NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;		//选择配置NVIC的USART1线
	NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;			//指定NVIC线路使能
	NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;		//指定NVIC线路的抢占优先级为1
	NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;		//指定NVIC线路的响应优先级为1
	NVIC_Init(&NVIC_InitStructure);							//将结构体变量交给NVIC_Init,配置NVIC外设
	
	/*USART使能*/
	USART_Cmd(USART1, ENABLE);								//使能USART1,串口开始运行
}

/**
  * 函    数:串口发送一个字节
  * 参    数:Byte 要发送的一个字节
  * 返 回 值:无
  */
void Serial_SendByte(uint8_t Byte)
{
	USART_SendData(USART1, Byte);		//将字节数据写入数据寄存器,写入后USART自动生成时序波形
	while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);	//等待发送完成
	/*下次写入数据寄存器会自动清除发送完成标志位,故此循环后,无需清除标志位*/
}

/**
  * 函    数:串口发送一个数组
  * 参    数:Array 要发送数组的首地址
  * 参    数:Length 要发送数组的长度
  * 返 回 值:无
  */
void Serial_SendArray(uint8_t *Array, uint16_t Length)
{
	uint16_t i;
	for (i = 0; i < Length; i ++)		//遍历数组
	{
		Serial_SendByte(Array[i]);		//依次调用Serial_SendByte发送每个字节数据
	}
}

/**
  * 函    数:串口发送一个字符串
  * 参    数:String 要发送字符串的首地址
  * 返 回 值:无
  */
void Serial_SendString(char *String)
{
	uint8_t i;
	for (i = 0; String[i] != '\0'; i ++)//遍历字符数组(字符串),遇到字符串结束标志位后停止
	{
		Serial_SendByte(String[i]);		//依次调用Serial_SendByte发送每个字节数据
	}
}

/**
  * 函    数:次方函数(内部使用)
  * 返 回 值:返回值等于X的Y次方
  */
uint32_t Serial_Pow(uint32_t X, uint32_t Y)
{
	uint32_t Result = 1;	//设置结果初值为1
	while (Y --)			//执行Y次
	{
		Result *= X;		//将X累乘到结果
	}
	return Result;
}

/**
  * 函    数:串口发送数字
  * 参    数:Number 要发送的数字,范围:0~4294967295
  * 参    数:Length 要发送数字的长度,范围:0~10
  * 返 回 值:无
  */
void Serial_SendNumber(uint32_t Number, uint8_t Length)
{
	uint8_t i;
	for (i = 0; i < Length; i ++)		//根据数字长度遍历数字的每一位
	{
		Serial_SendByte(Number / Serial_Pow(10, Length - i - 1) % 10 + '0');	//依次调用Serial_SendByte发送每位数字
	}
}

/**
  * 函    数:使用printf需要重定向的底层函数
  * 参    数:保持原始格式即可,无需变动
  * 返 回 值:保持原始格式即可,无需变动
  */
int fputc(int ch, FILE *f)
{
	Serial_SendByte(ch);			//将printf的底层重定向到自己的发送字节函数
	return ch;
}

/**
  * 函    数:自己封装的prinf函数
  * 参    数:format 格式化字符串
  * 参    数:... 可变的参数列表
  * 返 回 值:无
  */
void Serial_Printf(char *format, ...)
{
	char String[100];				//定义字符数组
	va_list arg;					//定义可变参数列表数据类型的变量arg
	va_start(arg, format);			//从format开始,接收参数列表到arg变量
	vsprintf(String, format, arg);	//使用vsprintf打印格式化字符串和参数列表到字符数组中
	va_end(arg);					//结束变量arg
	Serial_SendString(String);		//串口发送字符数组(字符串)
}

/**
  * 函    数:串口发送数据包
  * 参    数:无
  * 返 回 值:无
  * 说    明:调用此函数后,Serial_TxPacket数组的内容将加上包头(FF)包尾(FE)后,作为数据包发送出去
  */
void Serial_SendPacket(void)
{
	Serial_SendByte(0xFF);
	Serial_SendArray(Serial_TxPacket, 4);
	Serial_SendByte(0xFE);
}

/**
  * 函    数:获取串口接收数据包标志位
  * 参    数:无
  * 返 回 值:串口接收数据包标志位,范围:0~1,接收到数据包后,标志位置1,读取后标志位自动清零
  */
uint8_t Serial_GetRxFlag(void)
{
	if (Serial_RxFlag == 1)			//如果标志位为1
	{
		Serial_RxFlag = 0;
		return 1;					//则返回1,并自动清零标志位
	}
	return 0;						//如果标志位为0,则返回0
}

/**
  * 函    数:USART1中断函数
  * 参    数:无
  * 返 回 值:无
  * 注意事项:此函数为中断函数,无需调用,中断触发后自动执行
  *           函数名为预留的指定名称,可以从启动文件复制
  *           请确保函数名正确,不能有任何差异,否则中断函数将不能进入
  */
void USART1_IRQHandler(void)
{
	static uint8_t RxState = 0;		//定义表示当前状态机状态的静态变量
	static uint8_t pRxPacket = 0;	//定义表示当前接收数据位置的静态变量
	/*
	这个静态变量类似于全局变量,函数进入只会初始化一次0
	在函数退出后,数据仍然有效,与全局变量不同的是,静态变量只能在本函数使用
	*/
	if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET)		//判断是否是USART1的接收事件触发的中断
	{
		uint8_t RxData = USART_ReceiveData(USART1);				//读取数据寄存器,存放在接收的数据变量
		
		/*使用状态机的思路,依次处理数据包的不同部分*/
		
		/*当前状态为0,接收数据包包头*/
		if (RxState == 0)
		{
			if (RxData == 0xFF)			//如果数据确实是包头
			{
				RxState = 1;			//置下一个状态
				pRxPacket = 0;			//数据包的位置归零
			}
		}
		/*当前状态为1,接收数据包数据*/
		else if (RxState == 1)
		{
			Serial_RxPacket[pRxPacket] = RxData;	//将数据存入数据包数组的指定位置
			pRxPacket ++;				//数据包的位置自增
			if (pRxPacket >= 4)			//如果收够4个数据
			{
				RxState = 2;			//置下一个状态
			}
		}
		/*当前状态为2,接收数据包包尾*/
		else if (RxState == 2)
		{
			if (RxData == 0xFE)			//如果数据确实是包尾部
			{
				RxState = 0;			//状态归0
				Serial_RxFlag = 1;		//接收数据包标志位置1,成功接收一个数据包
			}
		}
		
		USART_ClearITPendingBit(USART1, USART_IT_RXNE);		//清除标志位
	}
}

main.c部分:

#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "Serial.h"
#include "Key.h"

uint8_t KeyNum;			//定义用于接收按键键码的变量

int main(void)
{
	/*模块初始化*/
	OLED_Init();		//OLED初始化
	Key_Init();			//按键初始化
	Serial_Init();		//串口初始化
	
	/*显示静态字符串*/
	OLED_ShowString(1, 1, "TxPacket");
	OLED_ShowString(3, 1, "RxPacket");
	
	/*设置发送数据包数组的初始值,用于测试*/
	Serial_TxPacket[0] = 0x01;
	Serial_TxPacket[1] = 0x02;
	Serial_TxPacket[2] = 0x03;
	Serial_TxPacket[3] = 0x04;
	
	while (1)
	{
		KeyNum = Key_GetNum();			//获取按键键码
		if (KeyNum == 1)				//按键1按下
		{
			Serial_TxPacket[0] ++;		//测试数据自增
			Serial_TxPacket[1] ++;
			Serial_TxPacket[2] ++;
			Serial_TxPacket[3] ++;
			
			Serial_SendPacket();		//串口发送数据包Serial_TxPacket
			
			OLED_ShowHexNum(2, 1, Serial_TxPacket[0], 2);	//显示发送的数据包
			OLED_ShowHexNum(2, 4, Serial_TxPacket[1], 2);
			OLED_ShowHexNum(2, 7, Serial_TxPacket[2], 2);
			OLED_ShowHexNum(2, 10, Serial_TxPacket[3], 2);
		}
		
		if (Serial_GetRxFlag() == 1)	//如果接收到数据包
		{
			OLED_ShowHexNum(4, 1, Serial_RxPacket[0], 2);	//显示接收的数据包
			OLED_ShowHexNum(4, 4, Serial_RxPacket[1], 2);
			OLED_ShowHexNum(4, 7, Serial_RxPacket[2], 2);
			OLED_ShowHexNum(4, 10, Serial_RxPacket[3], 2);
		}
	}
}

在这里插入图片描述

这个程序还隐藏有一个问题,就是这个Serial_RxPacket的数组,它是一个同时被写入又同时被读出的数组,在中断函数里我们会依次写入它,在主函数里我们又会依次读出它,这样数据包之间可能会混在一起,比如你读的过程太慢了,前面两个数据刚读出来,等了一会儿才继续往后读取,那这时后面的数据就有可能会刷新为下一个数据包的数据,也就是你读出的数据可能一部分属于上一个数据包,另一部分属于下一个数据包,这个问题需要注意一下,那解决方法呢可以在接收部分加入判断,就是在每个数据包读取处理完毕后,再接收下一个数据包,当然很多情况下其实还可以不进行处理,像这种hex数据包都是用于传输各种传感器的每个独立数据,比如陀螺仪的xyz轴数据,温湿度数据等,它们相邻数据包之间的数据具有连续性,这样即使相邻数据包混在一起了也没关系,所以这种情况下就不需要关心这个问题,具体到底怎么处理,还需要大家结合实际情况来操作了,这里就提一下这个可能存在的问题,大家了解一下就行。

代码实战:串口收发文本数据包

Serial.h

#ifndef __SERIAL_H
#define __SERIAL_H

#include <stdio.h>

extern char Serial_RxPacket[];
extern uint8_t Serial_RxFlag;

void Serial_Init(void);
void Serial_SendByte(uint8_t Byte);
void Serial_SendArray(uint8_t *Array, uint16_t Length);
void Serial_SendString(char *String);
void Serial_SendNumber(uint32_t Number, uint8_t Length);
void Serial_Printf(char *format, ...);

#endif

Serial.c部分:

#include "stm32f10x.h"                  // Device header
#include <stdio.h>
#include <stdarg.h>

char Serial_RxPacket[100];				//定义接收数据包数组,数据包格式"@MSG\r\n"
uint8_t Serial_RxFlag;					//定义接收数据包标志位

/**
  * 函    数:串口初始化
  * 参    数:无
  * 返 回 值:无
  */
void Serial_Init(void)
{
	/*开启时钟*/
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);	//开启USART1的时钟
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);	//开启GPIOA的时钟
	
	/*GPIO初始化*/
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);					//将PA9引脚初始化为复用推挽输出
	
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);					//将PA10引脚初始化为上拉输入
	
	/*USART初始化*/
	USART_InitTypeDef USART_InitStructure;					//定义结构体变量
	USART_InitStructure.USART_BaudRate = 9600;				//波特率
	USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;	//硬件流控制,不需要
	USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;	//模式,发送模式和接收模式均选择
	USART_InitStructure.USART_Parity = USART_Parity_No;		//奇偶校验,不需要
	USART_InitStructure.USART_StopBits = USART_StopBits_1;	//停止位,选择1位
	USART_InitStructure.USART_WordLength = USART_WordLength_8b;		//字长,选择8位
	USART_Init(USART1, &USART_InitStructure);				//将结构体变量交给USART_Init,配置USART1
	
	/*中断输出配置*/
	USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);			//开启串口接收数据的中断
	
	/*NVIC中断分组*/
	NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);			//配置NVIC为分组2
	
	/*NVIC配置*/
	NVIC_InitTypeDef NVIC_InitStructure;					//定义结构体变量
	NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;		//选择配置NVIC的USART1线
	NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;			//指定NVIC线路使能
	NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;		//指定NVIC线路的抢占优先级为1
	NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;		//指定NVIC线路的响应优先级为1
	NVIC_Init(&NVIC_InitStructure);							//将结构体变量交给NVIC_Init,配置NVIC外设
	
	/*USART使能*/
	USART_Cmd(USART1, ENABLE);								//使能USART1,串口开始运行
}

/**
  * 函    数:串口发送一个字节
  * 参    数:Byte 要发送的一个字节
  * 返 回 值:无
  */
void Serial_SendByte(uint8_t Byte)
{
	USART_SendData(USART1, Byte);		//将字节数据写入数据寄存器,写入后USART自动生成时序波形
	while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);	//等待发送完成
	/*下次写入数据寄存器会自动清除发送完成标志位,故此循环后,无需清除标志位*/
}

/**
  * 函    数:串口发送一个数组
  * 参    数:Array 要发送数组的首地址
  * 参    数:Length 要发送数组的长度
  * 返 回 值:无
  */
void Serial_SendArray(uint8_t *Array, uint16_t Length)
{
	uint16_t i;
	for (i = 0; i < Length; i ++)		//遍历数组
	{
		Serial_SendByte(Array[i]);		//依次调用Serial_SendByte发送每个字节数据
	}
}

/**
  * 函    数:串口发送一个字符串
  * 参    数:String 要发送字符串的首地址
  * 返 回 值:无
  */
void Serial_SendString(char *String)
{
	uint8_t i;
	for (i = 0; String[i] != '\0'; i ++)//遍历字符数组(字符串),遇到字符串结束标志位后停止
	{
		Serial_SendByte(String[i]);		//依次调用Serial_SendByte发送每个字节数据
	}
}

/**
  * 函    数:次方函数(内部使用)
  * 返 回 值:返回值等于X的Y次方
  */
uint32_t Serial_Pow(uint32_t X, uint32_t Y)
{
	uint32_t Result = 1;	//设置结果初值为1
	while (Y --)			//执行Y次
	{
		Result *= X;		//将X累乘到结果
	}
	return Result;
}

/**
  * 函    数:串口发送数字
  * 参    数:Number 要发送的数字,范围:0~4294967295
  * 参    数:Length 要发送数字的长度,范围:0~10
  * 返 回 值:无
  */
void Serial_SendNumber(uint32_t Number, uint8_t Length)
{
	uint8_t i;
	for (i = 0; i < Length; i ++)		//根据数字长度遍历数字的每一位
	{
		Serial_SendByte(Number / Serial_Pow(10, Length - i - 1) % 10 + '0');	//依次调用Serial_SendByte发送每位数字
	}
}

/**
  * 函    数:使用printf需要重定向的底层函数
  * 参    数:保持原始格式即可,无需变动
  * 返 回 值:保持原始格式即可,无需变动
  */
int fputc(int ch, FILE *f)
{
	Serial_SendByte(ch);			//将printf的底层重定向到自己的发送字节函数
	return ch;
}

/**
  * 函    数:自己封装的prinf函数
  * 参    数:format 格式化字符串
  * 参    数:... 可变的参数列表
  * 返 回 值:无
  */
void Serial_Printf(char *format, ...)
{
	char String[100];				//定义字符数组
	va_list arg;					//定义可变参数列表数据类型的变量arg
	va_start(arg, format);			//从format开始,接收参数列表到arg变量
	vsprintf(String, format, arg);	//使用vsprintf打印格式化字符串和参数列表到字符数组中
	va_end(arg);					//结束变量arg
	Serial_SendString(String);		//串口发送字符数组(字符串)
}

/**
  * 函    数:USART1中断函数
  * 参    数:无
  * 返 回 值:无
  * 注意事项:此函数为中断函数,无需调用,中断触发后自动执行
  *           函数名为预留的指定名称,可以从启动文件复制
  *           请确保函数名正确,不能有任何差异,否则中断函数将不能进入
  */
void USART1_IRQHandler(void)
{
	static uint8_t RxState = 0;		//定义表示当前状态机状态的静态变量
	static uint8_t pRxPacket = 0;	//定义表示当前接收数据位置的静态变量
	if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET)	//判断是否是USART1的接收事件触发的中断
	{
		uint8_t RxData = USART_ReceiveData(USART1);			//读取数据寄存器,存放在接收的数据变量
		
		/*使用状态机的思路,依次处理数据包的不同部分*/
		
		/*当前状态为0,接收数据包包头*/
		if (RxState == 0)
		{
			if (RxData == '@' && Serial_RxFlag == 0)		//如果数据确实是包头,并且上一个数据包已处理完毕,这样写数据和读数据就是严格分开的,可以避免数据包错位的现象了
			//不过这样的话,你发送数据包的频率就不能太快,否则会丢弃部分包
			{
				RxState = 1;			//置下一个状态
				pRxPacket = 0;			//数据包的位置归零
			}
		}
		/*当前状态为1,接收数据包数据,同时判断是否接收到了第一个包尾*/
		else if (RxState == 1)
		{
			if (RxData == '\r')			//如果收到第一个包尾
			{
				RxState = 2;			//置下一个状态
			}
			else						//接收到了正常的数据
			{
				Serial_RxPacket[pRxPacket] = RxData;		//将数据存入数据包数组的指定位置
				pRxPacket ++;			//数据包的位置自增
			}
		}
		/*当前状态为2,接收数据包第二个包尾*/
		else if (RxState == 2)
		{
			if (RxData == '\n')			//如果收到第二个包尾
			{
				RxState = 0;			//状态归0
				Serial_RxPacket[pRxPacket] = '\0';			//将收到的字符数据包添加一个字符串结束标志
				Serial_RxFlag = 1;		//接收数据包标志位置1,成功接收一个数据包
			}
		}
		
		USART_ClearITPendingBit(USART1, USART_IT_RXNE);		//清除标志位
	}
}

mian.c部分:

#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "Serial.h"
#include "LED.h"
#include "string.h"

int main(void)
{
	/*模块初始化*/
	OLED_Init();		//OLED初始化
	LED_Init();			//LED初始化
	Serial_Init();		//串口初始化
	
	/*显示静态字符串*/
	OLED_ShowString(1, 1, "TxPacket");
	OLED_ShowString(3, 1, "RxPacket");
	
	while (1)
	{
		if (Serial_RxFlag == 1)		//如果接收到数据包
		{
			OLED_ShowString(4, 1, "                ");
			OLED_ShowString(4, 1, Serial_RxPacket);				//OLED清除指定位置,并显示接收到的数据包
			
			/*将收到的数据包与预设的指令对比,以此决定将要执行的操作*/
			if (strcmp(Serial_RxPacket, "LED_ON") == 0)			//如果收到LED_ON指令
			{
				LED1_ON();										//点亮LED
				Serial_SendString("LED_ON_OK\r\n");				//串口回传一个字符串LED_ON_OK
				OLED_ShowString(2, 1, "                ");
				OLED_ShowString(2, 1, "LED_ON_OK");				//OLED清除指定位置,并显示LED_ON_OK
			}
			else if (strcmp(Serial_RxPacket, "LED_OFF") == 0)	//如果收到LED_OFF指令
			{
				LED1_OFF();										//熄灭LED
				Serial_SendString("LED_OFF_OK\r\n");			//串口回传一个字符串LED_OFF_OK
				OLED_ShowString(2, 1, "                ");
				OLED_ShowString(2, 1, "LED_OFF_OK");			//OLED清除指定位置,并显示LED_OFF_OK
			}
			else						//上述所有条件均不满足,即收到了未知指令
			{
				Serial_SendString("ERROR_COMMAND\r\n");			//串口回传一个字符串ERROR_COMMAND
				OLED_ShowString(2, 1, "                ");
				OLED_ShowString(2, 1, "ERROR_COMMAND");			//OLED清除指定位置,并显示ERROR_COMMAND
			}
			
			Serial_RxFlag = 0;			//处理完成后,需要将接收数据包标志位清零,否则将无法接收后续数据包
										//防止你发的太快了,我还没处理完呢,跳过了这个数据包
		}
	}
}

在这里插入图片描述

I2C(MPU6050陀螺仪和加速度计)

本节课程主要有两个代码,一个是软件IIC读写MPU6050 ,另一个是硬件IIC读写MPU6050 ,两个代码实现的效果是一样的。

在这里插入图片描述

最上面这个数据是设备的ID号,左边三个是加速度传感器的输出数据,分别是X轴、Y轴和Z轴的加速度,右边三个是陀螺仪传感器的输出数据,分别是X轴、Y轴和Z轴的角速度。我们可以改变MPU6050传感器的姿态,这六个数据就会对应变化。

背景:
假如有个大公司找到了你,出资1000万要求你给他设计一个通信协议,这个通信协议是用来干啥的,大概就是这个公司开发出了一款芯片可以干很多事情,比如AD转换温湿度测量姿态测量等等,像我们单片机一样,这个芯片里的众多外设,也都是通过读写寄存器来控制运行的,寄存器本身也是存储器的一种,这个芯片所有的寄存器也都是被分配到了一个线性的存储空间,如果我们想要读写寄存器来控制硬件电路,我们就至少需要定义两个字节数据,一个字节是我们要读写哪个寄存器,也就是指定寄存器的地址,另一个字节就是这个地址下存储器存的内容,写入内容就是控制电路,读出内容就是获取电路状态,这整个流程和我们单片机cpu操作外设的原理是一样的,那现在问题来了,单片机读写自己的寄存器,直接通过内部的数据总线来实现,直接用指针操作就行,不需要我们操心,但是现在这个模块的寄存器在单片机的外面,你要是直接把单片机内部的数据总线拽出来,把两个芯片合为一体,那可能不太现实,所以现在这个大公司就找到了,你要求你给他设计一种通信协议,在单片机和外部模块连接少量的几根线,实现单片机读写外部模块寄存器的功能。

这时候你可能会想这太简单了,我们就用上节课串口的数据包通信就可以完成任务,比如我们就用HEX数据包,定义一个3个字节的数据包,从单片机向外挂模块发过去,第一个字节表示读写,发送0表示这个一个写数据包,发送1表示这个一个读数据包,第二个字节表示读写的地址,第三个字节表示写入的数据,比如我发送的数据包为0x00,0x06,0xAA,这就表示在0x06地址下写入0xAA,模块收到之后就执行这个写入操作,如果我发送的数据包为0x01,0x06,0x00,这就表示我要读取0x06地址下的数据,模块收到之后就要再给我发送一个字节,返回0x06地址下的数据,这样就行了,是不是完美完成任务。

但是这个大公司对这个通信协议的要求非常多,其中要求一,目前串口这个设计是一个需要两根通讯线的全双工协议,但是可以明显的发现,我们这个操作流程,是一种基于对话的形式来进行的,我们在整个过程中,并不需要同时进行发送和接收,发送的时候就不需要接收,接收的时候就不需要发送,这样就会导致始终存在一根信号线处于空闲状态,这就是资源的浪费,所以要求依旧是删掉一根通讯线,只能在同一根线上进行发送和接收,也就是把全双工变成半双工,然后要求二,我们这个协议并没有一个应答机制,也就是单片机发送了一个数据,对方有没有收到单片机是完全不了解的,所以为了安全起见,大公司要求增加应答机制,要求每发送一个字节,对方都要给我个应答,每接收一个字节,我也要给对方一个应答,然后第三个要求,大公司说你这一根线只能接一个模块不给力,他要求你这一根线上能同时接多个模块,单片机可以指定和任意一个模块通信,同时单片机在跟某个模块进行通信时,其他模块不能对正常的通信产生干扰,最后第四个要求,这个串口是异步的时序,也就是发送方和接收方约定的传输速率是非常严格的,始终不能有过大的偏差,也不能在传输过程中单片机有点事进中断了,这个时序能不能暂停一下,对于异步时序来说,这是不行的,单片机一个字节发一半暂停的,接收方可是不知道的,它仍然会按照原来那个约定的数据读取,这就会导致传输出错,所以异步时序的缺点就是非常依赖硬件外设的支持,必须要有USART电路才能方便的使用,如果没有USART硬件电路的支持,那么串口是很难用软件来模拟的,虽然说软件模拟串口通信也是行得通的,但是由于异步时序对时间要求很严格,一般很少用软件来模拟串口通信,所以这个大公司的要求是,你要把这个协议改成同步的协议,另外加一条时钟线来指导对方,由于存在时钟线,对传输的时间要求就不高了,单片机也可以随时暂停传输去处理其他事情,因为暂停传输的同时时钟线也暂停了,所以传输双方都能定格在暂停的时刻,可以过一段时间再来继续,不会对传输造成影响,这就是同步时序的好处,使用同步时序就可以极大地降低单片机对硬件电路的依赖,即使没有硬件电路的支持也可,以很方便地用软件手动翻转电平来实现通信,异步时序的好处就是省一根时钟线,节省资源,缺点就是对时间要求严格,对硬件电路的依赖比较严重,同步时序的好处就是反过来,对时间要求不严格,对硬件电路不怎么依赖,在一些低端单片机没有硬件资源的情况下,也很容易使用软件的模拟时序,缺点就是多一根时钟线。

那现在要求就提完了,看看你能不能设计出一款让大公司满意的协议,其实通信协议这个东西就是一个很灵活的设计方案,并没有很严格的要求,说它必须是这样,只要你的设计能实现项目要求,符合电路原理,性能和稳定性好,那你的设计就是好设计,如果能有大公司帮你推广,得到了广泛的认可就能广泛使用了。

再声明一下项目要求,我们最基本的任务是,通过通信线实现单片机读写外挂模块寄存器的功能,其中至少要实现在指定的位置写寄存器和在指定的位置读寄存器这两个功能,实现了读写寄存器就实现了对这个外挂模块的完全控制。
在这里插入图片描述

I2C 通讯协议(Inter-Integrated Circuit)是由Phiilps公司开发的,由于它引脚少,硬件实现简单,可扩展性强, 不需要USART、CAN等通讯协议的外部收发设备,现在被广泛地使用在系统内多个集成电路(IC)间的通讯。

I2C总线是一种用于芯片之间进行通信的串行总线。它由两条线组成:串行时钟线(SCL)和串行数据线(SDA)。这种总线允许多个设备在同一条总线上进行通信。

作为一个通信协议,它必须要在硬件和软件上都做出规定,硬件上的规定,就是你的电路应该如何连接,端口的输入输出模式都是啥样的,软件上的规定,就是你的时序是怎么定义的,字节如何传输,高位先行还是低位先行,一个完整的时序有哪些部分构成这些东西,硬件的规定和软件的规定配合起来,就是一个完整的通信协议。

在这里插入图片描述

这个图就是I2C的典型电路模型,这个模型采用了一主多从的结构。在左侧,我们可以看到CPU作为主设备,控制着总线并拥有很大的权利。其中,主机对SCL线拥有完全的控制权,无论何时何地,主机都负责掌控SCL线。在空闲状态下,主机还可以主动发起对SDA的控制。但是,从机发送数据或应答时,主机需要将SDA的控制权转交给从机。

接下来,我们看到了一系列被控IC,它们是挂载在12C总线上的从机设备,如姿态传感器、OLED、存储器、时钟模块等。这些从机的权利相对较小。对于SCL时钟线,它们在任何时刻都只能被动的读取,不允许控制SCL线;对于SDA数据线,从机也不允许主动发起控制,只有在主机发送读取从机的命令后,或从机应答时,从机才能短暂地取得SDA的控制权。这就是一主多从模型中协议的规定。
然后我们来看接线部分。所有I2C设备的SCL和SDA都连接在一起。主机的SCL线拉出来,所有从机的SCL都接在这上面。主机的SDA线也是一样,拉出来,所有从机的SDA接在这上面。这就是SCL和SDA的接线方式。

那到现在,我们先不继续往后看了,先忽略这两个电阻,那到现在,假设我们就这样连接,那如何规定每个设备SCL和SDA的输入输出模式呢?

由于现在是一主多从结构,主机拥有SCL的绝对控制权,因此主机的SCL可以配置成推挽输出,所有从机的SCL都配置成浮空输入或上拉输入。数据流向为主机发送、所有从机接收。但是到SDA线这里就比较复杂了,因为这是半双工协议,所以主机的SDA在发送时是输出,在接收时是输入。同样地,从机的SDA也会在输入和输出之间反复切换。如果能够协调好输入输出的切换时机就没有问题。但是这样做的话,如果总线时序没有协调好,就极有可能发生两个引脚同时处于输出的状态。如果此时一个引脚输出高电平,一个引脚输出低电平,就会造成电源短路的情况,这是要极力避免的。
为了避免这种情况的发生,I2C的设计规定所有设备不输出强上拉的高电平,而是采用外置弱上拉电阻加开漏输出的电路结构。这两点规定对应于前面提到的“设备的SCL和SDA均要配置成开漏输出模式”以及“SCL和SDA各添加一个上拉电阻,阻值一般为4.7KΩ左右”。对应上面这个图。

所有的设备,包括CPU和被控IC,它们的引脚内部结构都如上图所示。图左侧展示的是SCL的结构,其中SClk代表SCL;右侧则是SDA的结构,其中DATA代表SDA。引脚的信号输入都可以通过一个数据缓冲器或施密特触发器进行输入,因为输入对电路无影响,所以任何设备在任何时刻都可以输入。然而,在输出部分,采用的是开漏输出的配置。

正常的推挽输出方式如下:上面一个开关管连接正极,下面一个开关管连接负极。当上面导通时,输出高电平;下面导通时,输出低电平。因为这是通过开关管直接连接到正负极的,所以这是强上拉和强下拉的模式。
而开漏输出呢,就是去掉这个强上拉的开关管,输出低电平时,下管导通,是强下拉,输出高电平时,下管断开,但是没有上管了,此时引脚处于浮空的状态,这就是开漏输出。

和这里图示是一样的,输出低电平,这个开关管导通,引脚直接接地,是强下拉,输出高电平,这个开关管断开,引脚什么都不接,处于浮空状态,这样的话,所有的设备都只能输出低电平而不能输出高电平,为了避免高电平造成的引脚浮空,这时就需要在总线外面,SCL和SDA各外置一个上拉电阻,这是通过一个电阻拉到高电平的,所以这是一个弱上拉。

用我们之前的弹簧和杆子的模型来解释,就是SCL或SDA就是一根杆子,为了防止有人向上推杆子,有人向下拉杆子造成冲突,我们就规定所有的人不准向上推杆子,只能选择向下拉或者放手,然后我们再外置一根弹簧向上拉,你要输出低电平就往下拽,这个弹簧肯定拽不赢你,所以弹簧被拉伸杆子处于低电平状态,你要输出高电平就放手,杆子在弹簧的拉力下回弹到高电平,这就是一个弱上拉的高电平,但是完全不影响数据传输。

这样做的好处是:

第一,完全杜绝了电源短路现象,保证电路的安全。你看所有人无论怎么拉杆子或者放手,杆子都不会处于一个被同时强拉和强推的状态,即使有多个人同时往下拉杆子,也没问题。
第二,避免了引脚模式的频繁切换。开漏加弱上拉的模式,同时兼具了输入和输出的功能,你要是想输出,就去拉杆子或放手,操作杆子变化就行了,你要是想输入,就直接放手,然后观察杆子高低就行了,因为开漏模式下,输出高电平就相当于断开引脚,所以在输入之前,可以直接输出高电平,不需要再切换成输入模式了。
第三,就是这个模式会有一个“线与”的现象。就是只要有任意一个或多个设备输出了低电平,总线就处于低电平,只有所有设备都输出高电平,总线才处于高电平。I2C可以利用这个电路特性执行多主机模式下的时钟同步和总线仲裁,所以这里SCL虽然在一主多从模式下可以用推挽输出,但是它仍然采用了开漏加上拉输出的模式,因为在多主机模式下会利用到这个特征。
好,以上就是I2C的硬件电路设计,那接下来,我们就要来学习软件,也就是时序的设计了。

在这里插入图片描述

首先我们来学习一下I2C规定的一些时序基本单元。

起始和终止条件

起始条件是指SCL高电平期间,SDA从高电平切换到低电平。在I2C总线处于空闲状态时,SCL和SDA都处于高电平状态,也就是没有任何一个设备去碰SCL和SDA,由外挂的上拉电阻拉高至高电平,总线处于平静的高电平状态。当主机需要数据收发时打破平静,会首先产生一个起始条件。这个起始条件是,SCL保持高电平,然后把SDA拉低,产生一个下降沿。当从机捕获到这个SCL高电平,SDA下降沿信号时,就会进行自身的复位,等待主机的召唤。之后,主机需要将SCL拉低。这样做一方面是占用这个总线,另一方面也是为了方便这些基本单元的拼接。这样,除了起始和终止条件,每个时序单元的SCL都是以低电平开始,低电平结束。

终止条件是,SCL高电平期间,SDA从低电平切换到高电平。SCL先放开并回弹到高电平,SDA再放开并回弹高电平,产生一个上升沿。这个上升沿触发终止条件,同时终止条件之后,SCL和SDA都是高电平,回归到最初的平静状态。这个起始条件和终止条件就类似串口时序里的起始位和停止位。一个完整的数据帧总是以起始条件开始、终止条件结束。另外,起始和终止都是由主机产生的。因此,从机必须始终保持双手放开,不允许主动跳出来去碰总线。如果允许从机这样做,那么就会变成多主机模型,不在本节的讨论范围之内。这就是起始条件和终止条件的含义。

在这里插入图片描述

发送一个字节
接着继续看,在起始条件之后,这时就可以紧跟着一个发送一个字节的时序单元,如何发送一个字节呢?

就是SCL低电平期间,主机将数据位依次放到SDA线上,高位先行,然后释放SCL,从机将在SCL高电平期间读取数据位,所以SCL高电平期间,SDA不允许有数据变化,依次循环上述过程8次即可发送一个字节,起始条件之后,第一个字节也必须是主机发送的,主机如何发送呢,就是最开始SCL低电平,主机如果想发送0,就拉低SDA到低电平,如果想发送1就放手,SDA回弹到高电平,在SCL低电平期间允许改变SDA的电平,当这一位放好之后,主机就松手时钟线,SCL回弹到高电平,在高电平期间是从机读取SDA的时候,所以高电平期间SDA不允许变化,SCL处于高电平之后,从机需要尽快的读取SDA,一般都是在上升沿这个时,从机就已经读取完成了,因为始终是主机控制的,从机并不知道什么时候就会产生下降沿了,你从机要是磨磨唧唧的,主机可不会等你的,所以从机在上升沿时就会立刻把数据读走,那主机在放手SCL一段时间后就可以继续拉低SCL传输下一位了,主机也需要在SCL下降沿之后,尽快把数据放在SDA上,但是主机有时钟的主导权哈,所以主机并不需要那么着急,只需要在低电平的任意时刻把数据放在sda上就行了,晚点也没关系,数据放完之后,主机再松手SCL,SCL高电平,从机读取这一位。

就这样的流程,主机拉低SCL,把数据放在SDA上,主机松开SCL,从机读取SDA的数据,在SCL的同步下,依次进行主机发送和从机接收,循环8次,就发送了8位数据,也就是一个字节,另外注意,这里是高位先行,所以第一位是一个字节的最高位B7,然后依次是次高位B6…这个和串口是不一样的,串口时序是低位先行,这里I2C是高位先行。

另外由于这里有时钟线进行同步,所以如果主机一个字节发送一半,突然进中断了,不操作SCL和SDA的,那时序就会在中段的位置不断拉长,SCL和SDA电平都暂停变化,传输也完全暂停,等中段结束后,主机回来继续操作,传输仍然不会出问题,这就是同步时序的好处。

最后就是,由于这整个时序是主机发送一个字节,所以在这个单元里,SCL和SDA全程都有主机掌控
,从机只能被动读取。

在这里插入图片描述

接收一个字节

主机在接收之前需要释放SDA,刚才我们说了,释放SDA其实就相当于切换成输入模式,或者这样来理解,所有设备包括主机都始终处于输入模式,当主机需要发送的时候,就可以主动去拉低SDA,而主机在被动接收的时候,就必须先释放SDA,不要去动它,以免影响别人发送,因为总线是线与的特征,任何一个设备拉低了总线就是低电平,如果你接收的时候还拽着SDA不放手,那别人无论发什么数据,总线都始终是底电平,你自己给他拽着不放,还让别人怎么发送呢是吧,所以主机在接收之前需要释放SDA ,从流程上来看,接收一个字节和发送一个字节是非常相似的,区别就是发送一个字节是低电平主机放数据,高电平从机读数据,而接收一个字节是低电平从机放数据,高电平主机读数据,然后看一下下面的时序,和上面的基本一样,区别就是SDA线,主机在接收之前需要释放SDA,然后这时从机就取得了SDA的控制权,从机需要发送0就把SDA拉低,从机需要发送1就放手,sda回弹高电平,然后同样的,低电平变换数据,高电平读取数据,这里实线部分表示主机控制的电平,虚线部分表示从机控制的电平,SCL全程由主机控制,SDA主机在接收前要释放,交由从机控制,之后还是一样,因为SCL始终是有主机控制的,所以从机的数据变换基本上都是贴着SCL下降沿进行的,而主机可以在SCL高电平的任意时刻读取,这是接收一个字节的时序。

在这里插入图片描述

那我们再继续看最后两个基本单元,就是应答机制的设计。

发送应答和接收应答
这个意思就是当我们在调用发送一个字节之后,就要紧跟着调用接收应答的时序,用来判断从机有没有收到刚才给它的数据,如果从机收到了,那在应答位这里,主机释放SDA的时候,从机就应该立刻把SDA拉下来,然后在SCL高电平期间,主机读取应答位,如果应答位为0,就说明从机确实收到了,这个场景就是主机刚发送一个字节,然后说有没有人收到啊,我现在把SDA放手了哈,如果有人收到的话,你就把SDA拽下来,然后主机高电平读取数据,发现诶确实有人给他拽下来了,那就说明有人收到了,如果主机发现我松手了,结果这个SDA就跟着回弹到高电平了,那就说明没有人回应我,刚发的一个字节可能没人收到,或者他收到了但是没给我回应,这是发送一个字节接收应答的流程,同理啊,在接收一个字节之后,我们也要给从机发送一个应答位,发送应答位的目的是告诉从机,你是不是还要继续发哈,如果从机发送一个数据后,得到了主机的应答,那从机就还会继续发送
,如果从机没得到主机的应答,那从机就会认为那我发生了一个数据,但是主机不理我,可能主机不想要了吧,这时从机就会乖乖地释放SDA,交出SDA的控制权,防止干扰主机之后的操作,这就是应答位的执行逻辑。

到这里,我们I2C的六块拼图就已经集齐了,分别是起始条件,终止条件,发送一个字节,接收一个字节,发送应答和接收应答,接下来我们就来拼接这些基本单元,组成一个完整的数据帧。

I2C的完整时序主要有指定地址写,当前地址读和指定地址读这三种。

我们这个I2C是一主多从的模型,主机可以访问总线上的任何一个设备,那如何发送指令,来确定访问的是哪个设备呢,这个需要首先把每个从机都确定一个唯一的设备地址,从机设备地址就相当于每个设备的名字,主机在起始条件之后,要先发送一个字节,叫一下从机的名字,所有从机都会收到第一个字节,和自己的名字进行比较,如果不一样,则认为主机没有叫我,之后的时序我就不管了,如果一样,就说明主机现在在叫我,那我就响应之后主机的读写操作,在同一条I2C总线里,挂载的每个设备地址必须不一样,否则主机叫一个地址,有多个设备都响应,那不就乱套了,从机设备地址,在I2C协议标准里分为7位地址和10位地址,我们目前只讲7位地址的模式,因为7位地址比较简单,而且应用范围最广,那在每个I2C设备出厂时,厂商都会为它分配一个7位的地址,这个地址具体是什么,可以在芯片手册里找到,比如我们MPU6050 这个芯片的7位地址是1101000,一般不同型号的芯片地址都是不同的,但相同型号的芯片地址是相同的。
如果多个相同型号的芯片挂载在同一条总线上,我们可以通过调整地址的最后几位来解决这个问题。例如,MPU6050地址的最后一位,可以通过板子上的AD0引脚来改变,比如这个引脚接低电平,那它的地址就是1101000,这个引脚接高电平,那它的地址就是1101001。
而AT24C02的地址可以通过板子上的A0、A1、A2引脚来改变,比如A0引脚接低电平,地址对应的位就是0,接高电平,地址对应的位就是1,A1A2也是同理,一般I2C的从机设备地址,高位都是由厂商确定的,低位可以由引脚来灵活切换。这样,即使相同型号的芯片,挂载在同一个总线上,也可以通过切换地址低位的方式,保证每个设备的地址都不一样。这就是12C设备的从机地址。

下面时序讲解详情
在这里插入图片描述
指定地址写

指定设备,通过Slave Address从机地址来确定,这个指定地址,就是某个设备内部的Reg Address,寄存器地址,写入指定数据,就是要在这个寄存器中写入的data数据,然后看一下下面的时序,这个时序是我在示波器下实际抓的波形,大家也可以用逻辑分析仪抓这个波形,而且逻辑分析仪还自带协议解析的功能,还是非常方便的,我们来一一分析一下,在这里上面的线是SCL,下面的线是SDA,空闲状态他俩都是高电平,然后主机需要给从机写入数据的时候,首先SCL高电平期间,拉低SDA产生起始条件,在起始条件之后,紧跟着的时序,必须是发送一个字节的时序,字节的内容必须是从机地址+读写位,正好从机地址是7位,读写位是1位,加起来是一个字节8位,发送从机地址,就是确定通信的对象,发送读写位,就是确认我接下来是要写入还是要读出,具体发送的时候呢,在这里低电平期间SDA变换数据,高电平期间从机读取SDA,这里我用绿色的线来标明了从机读到的数据,比如这样的波形,那从机收到的第一位就是高电平1,然后SCL低电平期间主机继续变换数据,因为第二位还是1,所以这里SDA电平并没有变换,然后SCL高电平,从机读到第二位是1,之后继续低电平变换数据,高电平读取数据,第三位就是0,这样持续8次就发送了一个字节数据,其中这个数据的定义,高7位表示从机地址,比如这个波形下,主机寻找的统计地址就是1101000,这个就是MPU6050的地址,然后最低位表示读写位,0表示之后的时序主机要进行写入操作,1表示之后的时序主机要进行读出操作,这里是0,说明之后我们要进行写入操作,那目前主机是发生了一个字节,字节内容转化为16进制,高位先行就是0xD0 ,然后根据协议规定,紧跟着的单元就得是接收从机的应答位,在这个时刻,主机要释放SDA,所以如果单看主机的波形啊,应该是这样,释放SDA之后引脚电平回弹到高电平,但是根据协议规定,从机要在这个位拉低SDA,所以单看从机的波形,应该是这样,该应答的时候,从机立刻拽住SDA,然后应答结束之后,从机再放开SDA,那现在综合两者的波形,结合线与的特性,在主机释放SDA之后,由于SDA也被从机拽住了,所以主机松手后,SDA并没有回弹高电平,这个过程就代表从机产生了应答,最终高电平期间主机读取SDA发现是0,就说明我进行寻址,有人给我应答了,传输没问题,如果主机读取SDA发现是1,就说明我进行寻址,应答位期间我松手了,但是没人拽住他,没人给我应答,那就直接产生停止条件吧,并提示一些信息,就是应答位,然后这个上升沿就是应答位结束后,从机释放SDA产生的,从机交出了SDA的控制权,因为从机要在低电平尽快变换数据,所以这个上升沿和SCL的下降沿,几乎是同时发生的,然后继续往后,由于之前我们读写位给了0,所以应答结束后,我们要继续发送一个字节,同样的时序再来一遍,第二个字节就可以送到指定设备的内部来,从机设备可以自己定义第二个字节和后续字节的用途,一般第二个字节可以是寄存器地址,或者是指令控制字等,比如MPU16050定义的第二个字节就是寄存器地址,比如AD转换器,第二个字节可能就是指令控制字,比如存储器,第二个字节可能就是存储器地址,那图示这里主机发送这样一个波形,我们一一判定,数据为00011001,即主机向从机发送了0x19 这个数据,在MPU6050 里,就表示我要操作你0x19地址下的寄存器了,接着同样是从机应答,主机释放sda,从机拽住SDA,SDA表现为低电平,主机收到应答位为0,表示收到了从机的应答,然后继续同样的流程,再来一遍,主机再发送一个字节,这个字节就是主机想要写入到0x19地址下寄存器的内容了,比如这里发送了0xAA的波形,就表示我在0x19地址下写入0xAA,最后是接收应答位,如果主机不需要继续传输了,就可以产生停止条件,在停止条件之前先拉低SDA,为后续SDA的上升沿做准备,然后释放SCL,再释放SDA,这样就产生了SCL高电平期间SDA的上升沿,这样一个完整的数据帧就拼接完成了,那套用上面这句话呢,这个数据帧的目的,就是对于指定从机地址为1001000的设备,在其内部0x19地址下的寄存器中,写入0xAA这个数据。

在这里插入图片描述
如果主机想要读取从机的数据,就可以执行这个时序,那最开始还是SCL高电平期间,拉低SDA产生起始条件,起始条件开始后,主机必须首先调用发送一个字节,来进行从机的寻址和指定读写标志位,比如图示的波形,表示本次寻址的目标是1001000的设备,同时最后一位读写标志为1,表示主机接下来想要读取数据,紧跟着发送一个字节之后接收一下从机应答位,从机应答为0代表从机收到了第一个字节,在从机应答之后,从这里开始数据的传输方向就要反过来了,因为刚才主机发出了读的命令,所以这之后主机就不能继续发送了,要把SDA的控制权交给从机,主机调用接收一个字节的时序进行接收操作,然后在这一块从机就得到了主机的允许,可以在SCL低电平之间写入SDA,然后主机在SCL高电平期间读取SDA,那最终主机在SCL高电平期间,依次读取8位,就接收到了从机发送的一个字节数据,00001111,也就是0x0f,那现在问题就来了,这个0x0f是从机哪个寄存器的数据呢,我们看到在读的时序中,I2C协议的规定是,主机进行寻址时,一旦读写标志位给1了,下一个字节就要立马转为读的时序,所以主机还来不及指定,我想要读哪个寄存器就得开始接收了,所以这里就没有指定地址这个环节,那主机并没有指定寄存器的地址,从机到底该发哪个寄存器的数据呢,这需要用到我们上面说的当前地址指针了,在从机中,所有的寄存器被分配到了一个线性区域中,并且会有个单独的指针变量,指示着其中一个寄存器,这个指针上电默认一般指向0地址,并且每写入一个字节和读出一个字节后,这个指针就会自动自增一次,移动到下一个位置,主机没有指定要读哪个地址,从机就会返回当前指针指向的寄存器的值,那假设我刚刚调用了这个指定地址写的时序,在0x19 的位置写出了0xAA,那么指针就会加1移动到0x1a的位置,我再调用这个当前地址读的时序,返回的就是0x1a地址下的值,如果再调用一次,返回的就是0x1b地址下的值,以此类推,这就是当前地址读时序的操作逻辑,由于当前地址读并不能指定读的地址,所以这个时序用的不是很多。

在这里插入图片描述

最后我们就继续来看下一个时序,指定地址读,这个时序的目的就是,对于指定设备在指定地址下读取从机数据,那这个时序为什么能指定读的地址呢,我们看一下指定地址写,在这前面一部分就是指定地址的时序,我们把最后面的写数据的这一部分给去掉,然后把前面这一段设置地址,还没有指定写什么数据的时序,给他追加到这个当前地址读时序的前面,就得到了指定地址读的时序,一般我们也把它称作复合格式,下面的时序在这里分隔一下,前面的部分是指定地址写,但是只指定了地址,还没来得及写,后面的部分是当前地址读,因为我们刚指定的地址,两者加在一起就是指定地址读了,所以指定地址读的时序会复杂一些,我们来详细分析一下,首先最开始仍然是启动条件,然后发送一个字节进行寻址,这里指定从机地址是1001000,读写标志位是0,代表我要进行写的操作,经过从机应答之后,再发送一个字节,第二个字节用来指定地址,这个数据就写入到了从机的地址指针里了,也就是说从机接收到这个数据之后,它的寄存器指针就指向了0x19 这个位置,之后我们要写入的数据,不给他发,而是直接再来个起始条件,这个Sr的意思就是重复起始条件,相当于另起一个时序,因为指定读写标志位,只能是跟着起始条件的第一个字节,所以如果想切换读写方向,只能再来个起始条件,然后起始条件后重新寻址并且指定读写标志位,此时读写标志位是1代表我要开始读了,接着主机接收一个字节,这个字节是不是就是0x19 地址下的数据,这就是指定地址读,另外在这里啊,你也可以再加一个停止条件,这样也行哈,这样的话就是两个完整的时序了,先起始写入地址停止,因为写入的地址会存在地址指针里面,所以这个地址并不会因为时序的停止而消失,我们就可以再提示读当前位置停止,这样两条时序也可以完成任务,但是I2C协议官方规定的复合格式是一整个数据帧,就是先起始再重复起始再停止,相当于把两条时序拼接成一条了,好这些就是这三个I2C完整时序的介绍了。

其中第一个指定地址写,和第三个指定地址读用的比较多,也是我们本节代码使用的时序,然后除了这三个时序啊,I2C还有这些时序的进阶版本,大概介绍一下,就是我们这些时序啊,指定地址写只是写一个字节,当前地址读和指定地址读,也都是读一个字节,那进阶版本就是指定地址写多个字节,当前地址读多少个字节,和指定地址读多少个字节,时序上呢和这些都非常相似,只需要增加一些小细节就行,我们看一下在这里指定地址,然后写入一个字节,如果你只想写一个字节,那就停止就行了,如果你想写多个字节,就可以把这最后一部分多重复几次,比如这里重复三遍发送一个字节和接受应答,这样第一个数据就写入到了指定地址0x19 的位置,然后不要忘了刚才说的哈,写入一个数据后,地址指针会自动加1,变成0x1a,所以这第二个数据就写入到了,0x1a的位置,同理,第三个数据就写入的是0x1b的地址,以此类推,这样这个时序就进阶为在指定的位置开始,按顺序连续写入多个字节,比如你需要连续写入多个寄存器,就可以考虑这样来操作,这样在一条数据帧里,就可以同时写入多个字节,执行效率就会比较高,然后同理当前位置读和指定位置读,也可以多次执行这最后一部分时序,由于地址指针在读后也会自增,所以这样就可以连续读出一片区域的寄存器,效率也会非常高,然后这里还要注意一下,如果你只想读一个字节就停止的话,在读完一个字节之后,一定要给从机发个非应答,就是该主机应答的时候,主机不把SDA拉低,从机读到SDA为1,就代表主机没有应答,从机收到非应答之后,就知道主机不想要继续了,从机就会释放总线,把SDA的控制权交还给主机,如果主机读完仍然给从机应答了,从机就会认为主机还想要数据,就会继续发送下一个数据,而这时主机如果想产生停止条件,SDA可能就会因为被从机拽住了,而不能正常弹回高电平,这个注意一下,如果主机想连续读取多个字节,就需要在最后一个字节给非应答,而之前的所有字节都要给应答,简单来说就是主机给应答了,从机就会继续发,主机给非应答了,从机就不会再发了,交出SDA的控制权,从机控制SDA发送一个自己的权利哈,开始于读写标志位为1,结束于主机给应答位为1,这是主机给从机发送应答位的作用。

以上就是I2C总线的硬件规定和软件规定了,有了这些规定,我们就可以按照硬件规定来连接线路,用软件规定来操作总线,以此实现指定位置写寄存器和指定位置读计算器,有了这两个功能,主机就可以完全掌控外挂模块的运行了,也就实现了我们设计这个协议的目的。

MPU6050简介

上一小节我们主要讲的是I2C通信的协议标准,有了I2C通信,我们就可以实现指定地址写和指定地址读的逻辑,这样即使这个外挂芯片的各种寄存器不在STM32的内部,我们仍然可以通过通讯协议实现读写外挂芯片寄存器器的功能。

要想研究清楚这个芯片的功能,看它对应的数据手册是必不可少的。

在这里插入图片描述

如果芯片里再集成一个三轴的磁场传感器,测量x y z轴的磁场强度,那就叫做九轴姿态传感器,如果再集成一个气压传感器,测量气压大小,那就叫做十轴姿态传感器,一般气压值反映的是高度信息哈,海拔越高,气压越低,所以气压计是单独测量垂直地面的高度信息的,这就是姿态传感器的一些术语。

那要这么多轴的信息是要干啥呢,答案是通过数据融合可进一步得到姿态角,或者叫做欧拉角,这个欧拉角是什么呢,以我们这个飞机为例子,欧拉角就是飞机机身相对于初始三个轴的夹角,飞机机头下沉或上仰,这个轴的夹角叫做俯仰Pitch,飞机机身左翻滚或者右翻滚,这个轴的夹角叫滚转Roll,飞机机身保持水平,机头向左转向或者向右转向,这个轴的夹角叫做偏航Yaw,简单来说,欧拉角就表达了飞机此时的姿态,飞机是上仰呢还是下倾呢,飞机向左倾斜还是向右倾斜了,通过欧拉角都能清晰地表示出来,如果你想做一个飞控算法,为了保持飞机姿态平稳,那么得到一个精确且稳定的欧拉角就至关重要,但是可惜的是,之前我们所说的加速度计、陀螺仪、磁力计,任何一种传感器都不能获得精确且稳定的欧拉角,要想获得精确且稳定的欧拉角呢,就必须进行数据融合,把这几种传感器的数据结合起来,综合多种传感器的数据取长补短,这样才能获得精确且稳定的欧拉角,常见的数据融合算法一般有互补滤波,卡尔曼滤波等,这就涉及到惯性导航领域里姿态解算的知识点了,不过我们本节课的侧重点是I2C通信,我们最终的程序现象就是把这些传感器的原始数据读出来,显示在OLED上就完事了,姿态传感器计算出姿态角之后,就常应用于平衡车,飞行器等需要检测自身姿态的场景,平衡车呢如果传感器检测到车身向前或者向后倾斜了,程序就可以控制轮子进行调整,保持平衡车的平衡,飞行器呢这控制的轴就多些,一般至少需要检测俯仰角和滚转角两个夹角,然后控制电机保持飞机的平衡,这就是这个mpu6050 姿态传感器的作用。

另外UP主还讲了一些测量的物理学原理。

在这里插入图片描述

16位adc采集传感器的模拟信号量化范围,-32768~+32767,之前我们也了解过,它们的基本原理都是设计一种装置,当传感器所感应的参数变化时,这个装置能带动电位器滑动,或者装置本身的电阻可以随感应参数变化而变化,这样在外接一个电源,通过电阻分压,就能把现实世界的各种状态用电压表示出来了,但这个传感器里面肯定不是图片这里这样的机械结构,芯片里面都是通过电子的技术来完成各种参数的测量的,要不然也塞不到这么小的芯片里,我们理解的时候可以把它想象成这种机械结构,但实际上芯片里面如何用电来完成同样的功能,这就是这个厂家的秘籍了,我们也不用管。总之电子的传感器最终也是输出一个随姿态变化而变化的电压,要想量化这个电压信号,那就离不开AD转换器了。

满量程范围就相当于我们之前学ADC的时候,那个VREF参考电压一样,你AD值达到最大值,对应电压是3.3V还是5V,需要有一个参考电压来指定,这里也是一样,16位AD值达到最大值对应的物理参量具体是多少,也是由满量程范围来决定的,因为AD的范围是一定的,所以满量程选的越小,测量就会越细腻,线性关系乘以一个系数就可以了,这跟我们之前学ADC的时候,由AD值求电压值是一样的道理。

可配置的数字低通滤波器,在这个芯片里可以配置寄存器来选择对输出数据进行低通滤波,如果你觉得输出数据抖动太厉害,就可以加一点低通滤波,这样输出数据就会平缓一些。

可配置的时钟源和可配置的采样分频,这两个参数是配合使用的,时钟源经过这个分频器的分频,可以为AD转换和内部其他电路提供时钟,控制分频系数就可以控制ad转换的快慢了。

芯片进行I2C通信的从机地址,这个可以在手册里查到,当AD0等于0,地址为1001000,当AD0等于1时,地址为1001001,AD0就是板子引出来的一个引脚,可以调节I2C从机地址的最低位,这里地址是七位的,如果像这样用二进制来表示的话,一般没啥问题,如果在程序中用16进制表示的话,一般会有两种表示方式,以这个1001000的地址为例,第一种就是单纯的把这七位的二进制转化为16进制,这里1001000低4位和高3位切开转换,16进制就是0x68 ,所以有的地方就说MPU6050的从机地址是0x68 ,然后我们看一下之前I2C通信的时序,这里第一个字节的高7位是从机地址,最低位是读写位,所以如果你认为0x68是从机地址的话,在发送第一个字节时,要先把0x68 左移一位,再按位或上读写位,读1写0,这是认为从机地址是0x68 的操作,当然目前还有另一种常见的表示方式,就是把0x68 左移移位后的数据当做从机地址,0x68 左移1位之后是0xd0 ,那这样MPU6050的从机地址就是0xd0 ,这时在实际发送第一个字节时,如果你要写,就直接把0xd0 当做第一个字节,如果你要读
就把0xd0或上0x01 即0xD1当做第一个字节,这种表示方式就不需要进行左移的操作了,或者说这种表示方式是把读写位也融入到了从机地址里来,0xD0 是写地址,0xD1是读地址,这样表示的,所以你之后看到有地方说0xD0 是MPU6050的从机地址,那它就是融入了读写位的从机地址,如果你看到有地方说0x68 是MPU6050的从机地址,这也不要奇怪,这种方式就是直接把7位地址转换16进制得到的,在实际发送第一个字节时,不要忘了先左移一位,再或上读写位,这是两种统计地址的表示方式。

在这里插入图片描述
我们接着继续来看一下硬件电路,右边这个是MPU6050的芯片,左下角是一个八针的排针,左上角是一个LDO低压差线性稳压器,我们来看一下右边这个mpu6050的芯片,芯片本身的引脚是非常多的,不过这里有很多引脚我们都用不到,还有一些硬件呢是这个芯片最小系统里的固定连接,这个最小系统一般手册里都会有,抄过来就行了,然后看左下角引出来的引脚,有VCC和GND这两个引脚是电源供电,然后SCL和SDA这两个引脚是I2C通信的引脚,在这里可以看到,SCL和SDA模块已经内置了两个4.7k的上拉电阻了,所以在我们接线的时候,直接把SCL和SDA接在GPIO口就行了,不需要再在外面另外接上拉电阻了,接着下面有xcl和xda这两个是芯片里面的主机I2C通信引脚,设计这两个引脚是为了扩展芯片功能,之前我们说过,mpu6050是一个六轴姿态传感器,这是九轴姿态传感器多出的磁力计的作用,另外如果你要制作无人机,需要定高飞行,这时候就还需要增加气压计,扩展为十轴提供一个高度信息的稳定参考,所以根据项目要求啊,这个六轴传感器可能不够用,需要进行扩展,那这个时候这个xcl和xda就可以起作用了,xcl和xda通常就是用于外接磁力计或者气压计,当接上磁力计或气压计之后,mpu6050的主机接口可以直接访问这些扩展芯片的数据,把这些扩展芯片的数据读取到mpu6050 里面,在mpu6050 里面会有dmp单元进行数据融合和姿态解算,如果你不需要按mpu6050 的解算功能的话,也可以把这个磁力计或者气压计直接挂载在xcl和xda这条总线上,因为I2C本来就可以挂载多设备,所以把多个设备都挂载在一起也是没问题的。

下面AD0引脚,这个之前说过,他是从机地址的最低位,接低电平的话七位从机地址就是1001000,接高电平的话七位从机地址就是1001001,这里电路中可以看到有一个电阻默认弱下拉到低电平了,所以引脚悬空的话就是低电平,如果想接高电平,就可以把ad0直接引到vcc,强上拉至高电平。

最后一个引脚是INT,也就是中断输出引脚,可以配置芯片内部的一些事件来触发中断引脚的输出,比如数据准备好了、I2C主机错误等,另外芯片内部还内置了一些实用的小功能、比如自由落体检测、运动检测、零运动检测等,这些信号都可以触发INT引脚产生电平跳变,需要的话可以进行中断信号的配置,但如果不要的话,那也可以不配置这个引脚。

左上角这个LDO,这部分是供电的逻辑,手册里介绍这个mpu6050 芯片的vdd供电是2.375~3.46V,属于3.3V供电的设备,不能直接接5V,所以为了扩大供电范围,这个模块的设计者就加了个3.3V的稳压器,输入端电压vcc_5v可以在3.3v到5v之间,然后经过3.3伏的稳压器输出稳定的3.3伏电压给芯片端供电,然后这一块是电源指示灯,只要3.3v端有电,电源指示灯就会亮,所以这一块需不需要,可以根据你的项目要求来,如果你已经有了稳定的3.3伏电源了,就不再需要这一部分了。我们本实验直接vcc、gnd接上电,sscl和sda接上I2C通信的GPIO口就行了。

在这里插入图片描述

我们再看一下这个芯片的模块框图,这个图就是整个芯片的内部结构,左上角是时钟系统,有时钟输入脚和输出脚,不过我们一般使用内部时钟,硬件电路这里呢CLKIN直接接了地,CLKOUT没有引出,所以这部分不需要过多关心,然后下面这些灰色的部分就是芯片内部的传感器,包括x y z轴的陀螺仪陀螺仪,另外这个芯片还内置了一个温度传感器,你要是想用它来测量温度也是没问题的,那这么多传感器本质上也都相当于可变电阻,通过分压后输出模拟电压,然后通过adc进行模数转换,转化完成之后呢,这些传感器的数据统一都放到数据寄存器中,我们读取数据寄存器就能得到传感器测量的值了,这个芯片内部的转换都是全自动进行的,就类似我们之前学的AD连续转换加dma转运,每个adc输出,对应16位的数据寄存器,不存在数据覆盖的问题,我们配置好转换频率之后,每个数据就自动以我们设置的频率刷新到数据寄存器,我们需要数据的时候直接来读就行了,其他的都不用管,还是非常方便的。

接着每个传感器都有个自测单元self test,这部分是用来验证芯片好坏的,当启动自测后,芯片内部就会模拟一个外力施加在传感器上,这个外力导致传感器数据会比平时大一些,那如何进行自测呢,我们可以先使能自测读取数据,再失能自测读取数据,两个数据相减得到的数据叫自测响应,芯片手册里给出了一个范围,如果自测响应在这个范围内就说明芯片没问题,如果不在就说明芯片可能坏了,使用的时候就要小心点,这个是自测的功能。

然后下面这个Charge Pump是电荷泵或者叫充电泵,CPOUT的引脚需要外接一个电容,什么样的电容呢,在这个手册里有说明,电荷泵是一种升压电路,在其他地方也有出现过,比如我们用的这个OLED屏幕里面就有电荷泵进行升压,电荷泵的升压原理呢,我简单描述一下,大家了解一下这个,比如我有个电池电压是5伏,然后我再来个电容,首先电池和电容并联,电池给电容充电,充满之后,电容是不是也相当于一个5伏的电池了,然后呢关键部分来了,我在修改电路的接法,把电池和电容串联起来,电池5伏电容也是5伏,这样输出就是10伏的电压了,是不是凭空就把电池电压升高至两倍了,不过由于这个电容电荷比较少哈,用一下就不行了,所以这个并联串联的切换速度要快,趁电容还没放电完,就要及时并联充电,这样一直持续并联充电,串联放电,并联充电,串联放电,然后后续再加一个电源滤波平稳升压,这是电荷泵的升压原理,那这里由于陀螺仪内部是需要一个高电压支持的,所以这里设计了一个电荷泵进行升压,当然这个升压过程是自动的,不需要我们管了解一下即可。

右边这一大块就是寄存器和通信接口部分了,中断状态寄存器可以控制内部的哪些事件到中断引脚的输出,FIFO是先入先出寄存器,可以对数据流进行缓存,我们本节暂时不用,配置寄存器,可以对内部的各个电路进行配置,传感器寄存器也就是数据寄存器,存储在各个传感器的数据,工厂校准这个意思就是内部的传感器都进行了校准我们不用了解,然后右边这个数字运动处理器简称dmp,还是芯片内部自带的一个姿态解算的硬件算法,配合官方的dmp库可以进行姿态解算,因为姿态解算还是比较难的,而且算法也很复杂,所以如果使用了内部的dmp进行姿态解算,姿态解算就会方便一些,暂时不涉及,这个FSYNC是帧同步,我们用不到,最后上面这块就是通信接口部分,上面一部分就是从机的I2C和spi通信接口,用于和stm32通信,下面这一部分是主机的I2C通信接口,用于和mpu6050扩展的设备进行通信,这里有个接口旁路选择器(MUX)就是一个开关,如果拨到上面,辅助的I2C引脚就和正常的I2C引脚接到一起,这样两路总线就合在一起了,stm32可以控制所有设备,这时sm32就是大哥mpu6050和这个扩展设备都是stm32的小弟,如果拨到下面,辅助的I2C引脚就由mpu6050控制,两条I2C总线独立分开,这时s t m32 是mpu6050的大哥,mpu6050又是扩展设备的大哥,我们本节课程不会用到这个扩展功能。

最后下面这里是供电的部分,按照手册里的电压要求和参考电路来接线就行了。

看一下两个手册
产品说明书

寄存器映像的手册
其实寄存器还是非常重要的,看原理你只能知道哪个是哪个电路怎么运作的,真正要写程序的还得是研究寄存器才能明白,那我们stm32有库函数封装了,所以对寄存器器要求不是很高,但是这个芯片的话还是需要手动配置一下寄存器,所以研究一下寄存器还是很有必要的。

在这里插入图片描述
这个手册的前面这里有个寄存器的总表,这个表里寄存器非常多,但是我们不需要全都了解,简单应用的话了解其中一部分就行了,在这里需要了解的我都用黄色标记标出来其他的暂时可以不看。

第一列是寄存器的地址,16进制表示的,第二列是寄存器的地址,十进制表示的,第三列是寄存器的名称,第四列是读写权限,RW分别代表可读可写,这后面是寄存器内的每一位的名字,每个寄存器都是8位的,我们需要了解的寄存器依次是采样频率分频器,配置计算器,陀螺仪配置计数器,加速度计配置计数器。

在这里插入图片描述

下面这一大块是数据寄存器,这里杠L表示低八位,杠H表示高八位。

在这里插入图片描述
在这里插入图片描述

那接下来我们来一一看一下每个寄存器的说明,参照手册,视频详细讲解。

代码实战:软件I2C读写MPU6050

本小节的代码也是主要分为两个部分,第一部分我们完成软件I2C协议的时序,第二部分我们基于I2C协议读写寄存器来操控mpu6050。

在这里插入图片描述

由于这个模块把各种基础电路都封装好了,所以接线图这里也是比较简单的,在这里vcc和gnd分别接到电源正负极进供电,然后SCL这里我引到了stm32 的PB10号引脚,SDA我引到了PB11号引脚,由于我们这个代码使用的是软件I2C,就是用普通的GPIO口,手动翻转电平实现的协议,它并不需要STM32内部的外设资源支持,所以这里的端口(SDA,SCL),其实可以任意指定,不局限于这两个端口,你也可以SCL接PA0,SDA接PB12,或者SCL接PA8,SDA接PA9看,等等等等,接在任意的两个普通的GPIO口就可以,这算是软件I2C相比硬件I2C的一大优势。

根据I2C协议的硬件规定,scl和sda都应该外挂一个上拉电阻,但是这里并没有外挂上来电阻,因为上节我们分析模块电路的时候提到过,这个模块内部自带了上拉电阻,所以外部的上拉电阻就不需要接了,目前这里stm32是主机,mpu6050是从机,是一主一从的模型,当然主机和从机的执行逻辑是完全不同的,我们程序中一般只关注主机端的程序,然后后面xcl和xda用于扩展的接口,我们用不到,ad0引脚修改从机地址的最低位,我们等会写程序的时候可以试下这个功能,这里由于模块内置的下拉电阻,所以引脚悬空的话就相当于接地,最后INT中断信号输出引脚我们用不到可以不接,那这就是硬件电路。

软件I2C,只需要用gpio的读写函数就行了,就不用I2C的库函数了。

程序的整体框架:

这里我们首先建立I2C通信层的.c和.h模块,在通讯层里写好I2C底层的GPIO初始化和六个时序基本单元,也就是起始、终止、发送一个字节、接收一个字节、发送应答、接收应答,写好I2C通讯层之后,我们再建立mpu6050 的.c和.h模块,在这一层,我们将基于I2C通信的模块来实现指定地址读、指定地址写,再实现写寄存器对芯片进行配置,读寄存器得到传感器数据,最后在main.c里调用mpu6050的模块,初始化,拿到数据显示数据,这就是程序的整体架构。

MyI2C.h

#ifndef __MYI2C_H
#define __MYI2C_H

void MyI2C_Init(void);
void MyI2C_Start(void);
void MyI2C_Stop(void);
void MyI2C_SendByte(uint8_t Byte);
uint8_t MyI2C_ReceiveByte(void);
void MyI2C_SendAck(uint8_t AckBit);
uint8_t MyI2C_ReceiveAck(void);

#endif

MyI2C.C

#include "stm32f10x.h"                  // Device header
#include "Delay.h"

/*引脚配置层*/

/**
  * 函    数:I2C初始化
  * 参    数:无
  * 返 回 值:无
  * 注意事项:此函数需要用户实现内容,实现SCL和SDA引脚的初始化
  */
void MyI2C_Init(void)
{
	/*开启时钟*/
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);	//开启GPIOB的时钟
	
	/*GPIO初始化*/
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD;		// 开漏输出,仍然可以输入
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOB, &GPIO_InitStructure);					
	
	/*设置默认电平*/
	GPIO_SetBits(GPIOB, GPIO_Pin_10 | GPIO_Pin_11);			//设置PB10和PB11引脚初始化后默认为高电平(释放总线状态)
}



/**
  * 函    数:I2C写SCL引脚电平
  * 参    数:BitValue 协议层传入的当前需要写入SCL的电平,范围0~1
  * 返 回 值:无
  * 注意事项:此函数需要用户实现内容,当BitValue为0时,需要置SCL为低电平,当BitValue为1时,需要置SCL为高电平
  */
void MyI2C_W_SCL(uint8_t BitValue)
{
	GPIO_WriteBit(GPIOB, GPIO_Pin_10, (BitAction)BitValue);		//根据BitValue,设置SCL引脚的电平
	Delay_us(10);												//如果你单片机主频比较快,延时10us,防止时序频率超过mpu6050要求。慢点无所谓
}

/**
  * 函    数:I2C写SDA引脚电平
  * 参    数:BitValue 协议层传入的当前需要写入SDA的电平,范围0~0xFF
  * 返 回 值:无
  * 注意事项:此函数需要用户实现内容,当BitValue为0时,需要置SDA为低电平,当BitValue非0时,需要置SDA为高电平
  */
void MyI2C_W_SDA(uint8_t BitValue)
{
	GPIO_WriteBit(GPIOB, GPIO_Pin_11, (BitAction)BitValue);		//根据BitValue,设置SDA引脚的电平,BitValue要实现非0即1的特性
	Delay_us(10);												//延时10us,防止时序频率超过要求
}

/**
  * 函    数:I2C读SDA引脚电平
  * 参    数:无
  * 返 回 值:协议层需要得到的当前SDA的电平,范围0~1
  * 注意事项:此函数需要用户实现内容,当前SDA为低电平时,返回0,当前SDA为高电平时,返回1
  */
uint8_t MyI2C_R_SDA(void)
{
	uint8_t BitValue;
	BitValue = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11);		//读取SDA电平
	Delay_us(10);												//延时10us,防止时序频率超过要求
	return BitValue;											//返回SDA电平
}


/*协议层*/

/**
  * 函    数:I2C起始
  * 参    数:无
  * 返 回 值:无
  */
void MyI2C_Start(void)
{
	MyI2C_W_SDA(1);							//释放SDA,确保SDA为高电平
	MyI2C_W_SCL(1);							//释放SCL,确保SCL为高电平
	MyI2C_W_SDA(0);							//在SCL高电平期间,拉低SDA,产生起始信号
	MyI2C_W_SCL(0);							//起始后把SCL也拉低,即为了占用总线,也为了方便总线时序的拼接
}

/**
  * 函    数:I2C终止
  * 参    数:无
  * 返 回 值:无
  */
void MyI2C_Stop(void)
{
	MyI2C_W_SDA(0);							//拉低SDA,确保SDA为低电平
	MyI2C_W_SCL(1);							//释放SCL,使SCL呈现高电平
	MyI2C_W_SDA(1);							//在SCL高电平期间,释放SDA,产生终止信号
}

/**
  * 函    数:I2C发送一个字节
  * 参    数:Byte 要发送的一个字节数据,范围:0x00~0xFF
  * 返 回 值:无
  */
void MyI2C_SendByte(uint8_t Byte)
{
	uint8_t i;
	for (i = 0; i < 8; i ++)				//循环8次,主机依次发送数据的每一位
	{					//1000 0000
		MyI2C_W_SDA(Byte & (0x80 >> i));	//趁SCL低电平,使用掩码的方式取出Byte的指定一位数据并写入到SDA线
		MyI2C_W_SCL(1);						//释放SCL,从机在SCL高电平期间读取SDA
		MyI2C_W_SCL(0);						//拉低SCL,主机开始发送下一位数据
	}
}

/**
  * 函    数:I2C接收一个字节
  * 参    数:无
  * 返 回 值:接收到的一个字节数据,范围:0x00~0xFF
  */
uint8_t MyI2C_ReceiveByte(void)
{
	uint8_t i, Byte = 0x00;					//定义接收的数据,并赋初值0x00,此处必须赋初值0x00,后面会用到
	MyI2C_W_SDA(1);							//接收前,主机先确保释放SDA,避免干扰从机的数据发送
	for (i = 0; i < 8; i ++)				//循环8次,主机依次接收数据的每一位
	{
		MyI2C_W_SCL(1);						//释放SCL,主机机在SCL高电平期间读取SDA
		if (MyI2C_R_SDA() == 1){Byte |= (0x80 >> i);}	//读取SDA数据,并存储到Byte变量
														//当SDA为1时,置变量指定位为1,当SDA为0时,不做处理,指定位为默认的初值0
		MyI2C_W_SCL(0);						//拉低SCL,从机在SCL低电平期间写入SDA
	}
	return Byte;							//返回接收到的一个字节数据
}

/**
  * 函    数:I2C发送应答位
  * 参    数:Byte 要发送的应答位,范围:0~1,0表示应答,1表示非应答
  * 返 回 值:无
  */
void MyI2C_SendAck(uint8_t AckBit)
{
	MyI2C_W_SDA(AckBit);					//主机把应答位数据放到SDA线
	MyI2C_W_SCL(1);							//释放SCL,从机在SCL高电平期间,读取应答位
	MyI2C_W_SCL(0);							//拉低SCL,开始下一个时序模块
}

/**
  * 函    数:I2C接收应答位
  * 参    数:无
  * 返 回 值:接收到的应答位,范围:0~1,0表示应答,1表示非应答
  */
uint8_t MyI2C_ReceiveAck(void)
{
	uint8_t AckBit;							//定义应答位变量
	MyI2C_W_SDA(1);							//接收前,主机先确保释放SDA,避免干扰从机的数据发送
	MyI2C_W_SCL(1);							//释放SCL,主机机在SCL高电平期间读取SDA
	AckBit = MyI2C_R_SDA();					//将应答位存储到变量里
	MyI2C_W_SCL(0);							//拉低SCL,开始下一个时序模块
	return AckBit;							//返回定义应答位变量
}

函数逻辑:

void MyI2C_Start(void)

如果起始条件之前SCL和SDA已经是高电平了,那先释放哪一个是一样的效果,但是在指定地址读中,为了改变读写标志位,我们这个Start还要兼容这里的重复起始条件Sr。

Sr最开始,SCL是低电平,SDA电平不敢确定,所以保险起见,我们趁SCL是低电平,先确保释放SDA,再释放SCL,这时SDA和SCL都是高电平,然后再拉低SDA、拉低SCL,这样这个Start就可以兼容起始条件和重复起始条件了。
【如果先释放SCL,在SCL高电平期间再释放SDA会被误以为是终止条件;这里Sr是需要重新生成一个开始条件即SCL高电平期间,SDA从高变低。如果不先拉低SDA,就容易造成。SCL高电平期间,SDA从低变高。变成结束信号了。】

void MyI2C_Stop(void)

在这里,如果Stop开始时,那就先释放SCL,再释放SDA就行了,但是在这个时序单元开始时,SDA并不一定是低电平,所以为了确保之后释放SDA能产生上升沿,我们要在时序单元开始时,先拉低SDA,然后再释放SCL、释放SDA。

void MyI2C_SendByte(uint8_t Byte)

发送一个字节时序开始时,SCL是低电平,实际上,除了终止条件,SCL以高电平结束,所有的单元我们都会保证SCL以低电平结束,这样方便各个单元的拼接。
补充:
Byte & 0x80 就是用按位与的方式,取出数据的某一位或某几位。

MyI2C_ReceiveAck(void)
之前经常有人问我说,程序里主机先把sda置1了,然后再读取sda,这应答位肯定是1啊,这有什么意义呢,那问出这样的问题,说明你对两个知识点还不理解,第一I2C的引脚都是开漏输出加弱上拉的配置,主机输出1并不是强制sda为高电平而是释放sda,第二你要明白I2C是在进行通信,主机释放了sda,那从机又不是在外面看戏,从机如果在的话,它是有义务在此时把sda再拉低的,所以这里即使之前主机把SDA置1了,之后再读取sda,读到的值也可能是0,读到0代表从机给他应答,读到1代表从机没给应答,这就是接收应答的执行流程。

MyI2C_ReceiveByte(void)
这里也有人问我说,这里不断读取sda,但是你for循环中又没写过sda,那sda读出来应该始终是一个值啊,这有啥意义呢,这个问题和刚才那个也是一样,就是我们I2C是在进行通信,通信是有从机的,当主机不断驱动SCL时钟时,从机就有义务去改变sda的电平,所以主机每次循环读取sda的时候,这个读取到的数据是从机控制的,这个数据也正是从机想要给我们发送的数据,所以这个时序叫做接收一个字节,如果你自己写sda,自己读sda,那还要通信干啥呢,就是你要明白我们是在通信通信是有时序的,有些引脚的电平,我们之前读和之后读读的值就是不一样的。

MPU6050_Reg.h

#ifndef __MPU6050_REG_H
#define __MPU6050_REG_H

#define	MPU6050_SMPLRT_DIV		0x19
#define	MPU6050_CONFIG			0x1A
#define	MPU6050_GYRO_CONFIG		0x1B
#define	MPU6050_ACCEL_CONFIG	0x1C

#define	MPU6050_ACCEL_XOUT_H	0x3B
#define	MPU6050_ACCEL_XOUT_L	0x3C
#define	MPU6050_ACCEL_YOUT_H	0x3D
#define	MPU6050_ACCEL_YOUT_L	0x3E
#define	MPU6050_ACCEL_ZOUT_H	0x3F
#define	MPU6050_ACCEL_ZOUT_L	0x40
#define	MPU6050_TEMP_OUT_H		0x41
#define	MPU6050_TEMP_OUT_L		0x42
#define	MPU6050_GYRO_XOUT_H		0x43
#define	MPU6050_GYRO_XOUT_L		0x44
#define	MPU6050_GYRO_YOUT_H		0x45
#define	MPU6050_GYRO_YOUT_L		0x46
#define	MPU6050_GYRO_ZOUT_H		0x47
#define	MPU6050_GYRO_ZOUT_L		0x48

#define	MPU6050_PWR_MGMT_1		0x6B
#define	MPU6050_PWR_MGMT_2		0x6C
#define	MPU6050_WHO_AM_I		0x75

#endif

MPU6050.h

#ifndef __MPU6050_H
#define __MPU6050_H

void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data);
uint8_t MPU6050_ReadReg(uint8_t RegAddress);

void MPU6050_Init(void);
uint8_t MPU6050_GetID(void);
void MPU6050_GetData(int16_t *AccX, int16_t *AccY, int16_t *AccZ, 
						int16_t *GyroX, int16_t *GyroY, int16_t *GyroZ);

#endif

MPU6050.c

#include "stm32f10x.h"                  // Device header
#include "MyI2C.h"
#include "MPU6050_Reg.h"

#define MPU6050_ADDRESS		0xD0		//MPU6050的I2C从机地址

/**
  * 函    数:MPU6050写寄存器  指定地址写一个字节
  * 参    数:RegAddress 寄存器地址,范围:参考MPU6050手册的寄存器描述
  * 参    数:Data 要写入寄存器的数据,范围:0x00~0xFF
  * 返 回 值:无
  */
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{
	MyI2C_Start();						//I2C起始
	MyI2C_SendByte(MPU6050_ADDRESS);	//发送从机地址,读写位为0,表示即将写入
	MyI2C_ReceiveAck();					//接收应答,这里方便学习就不展开了  收到应答返回值是0
	MyI2C_SendByte(RegAddress);			//发送寄存器地址
	MyI2C_ReceiveAck();					//接收应答
	MyI2C_SendByte(Data);				//发送要写入寄存器的数据
	MyI2C_ReceiveAck();					//接收应答
	//如果要发送多个字节,用for循环把上面这两个多执行几遍
	MyI2C_Stop();						//I2C终止
}

/**
  * 函    数:MPU6050读寄存器  指定地址读一个字节
  * 参    数:RegAddress 寄存器地址,范围:参考MPU6050手册的寄存器描述
  * 返 回 值:读取寄存器的数据,范围:0x00~0xFF
  */
uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{
	uint8_t Data;
	
	MyI2C_Start();						//I2C起始
	MyI2C_SendByte(MPU6050_ADDRESS);	//发送从机地址,读写位为0,表示即将写入
	MyI2C_ReceiveAck();					//接收应答
	MyI2C_SendByte(RegAddress);			//发送寄存器地址
	MyI2C_ReceiveAck();					//接收应答
	
	MyI2C_Start();						//I2C重复起始
	MyI2C_SendByte(MPU6050_ADDRESS | 0x01);	//发送从机地址,读写位为1,表示即将读取 控制权交给从机
	MyI2C_ReceiveAck();					//接收应答
	Data = MyI2C_ReceiveByte();			//接收指定寄存器的数据
	MyI2C_SendAck(1);					//发送应答,给从机非应答,终止从机的数据输出  如果要读取多个字节,就要给应答0
	// for循环,上面两个函数就能读取多个字节
	MyI2C_Stop();						//I2C终止
	
	return Data;
}

/**
  * 函    数:MPU6050初始化
  * 参    数:无
  * 返 回 值:无
  */
void MPU6050_Init(void)
{
	MyI2C_Init();									//先初始化底层的I2C
	
	/*MPU6050寄存器初始化,需要对照MPU6050手册的寄存器描述配置,此处仅配置了部分重要的寄存器*/
	MPU6050_WriteReg(MPU6050_PWR_MGMT_1, 0x01);		//电源管理寄存器1,取消睡眠模式,选择时钟源为X轴陀螺仪(手册建议使用陀螺仪时钟更精确)
	MPU6050_WriteReg(MPU6050_PWR_MGMT_2, 0x00);		//电源管理寄存器2,保持默认值0,所有轴均不待机
	MPU6050_WriteReg(MPU6050_SMPLRT_DIV, 0x09);		//采样率分频寄存器,配置采样率 10分频
	MPU6050_WriteReg(MPU6050_CONFIG, 0x06);			//配置寄存器,配置数字低通滤波器
	MPU6050_WriteReg(MPU6050_GYRO_CONFIG, 0x18);	//陀螺仪配置寄存器,选择满量程为±2000°/s,不自测
	MPU6050_WriteReg(MPU6050_ACCEL_CONFIG, 0x18);	//加速度计配置寄存器,选择满量程为±16g,不自测
	/*
	配置完之后,
	陀螺仪内部就在连续不断地进行数据转换了,
	输出的数据就存放在这里的数据寄存器里,
	接下来我们想获取数据的话,
	只需要再写一个获取数据寄存器的函数即可
	*/
}

/**
  * 函    数:MPU6050获取ID号
  * 参    数:无
  * 返 回 值:MPU6050的ID号
  */
uint8_t MPU6050_GetID(void)
{
	return MPU6050_ReadReg(MPU6050_WHO_AM_I);		//返回WHO_AM_I寄存器的值
}

/**
  * 函    数:MPU6050获取数据  
  * 参    数:AccX AccY AccZ 加速度计X、Y、Z轴的数据,使用输出参数的形式返回,范围:-32768~32767
  * 参    数:GyroX GyroY GyroZ 陀螺仪X、Y、Z轴的数据,使用输出参数的形式返回,范围:-32768~32767
  * 返 回 值:无
  */                                     //返回多个参数
void MPU6050_GetData(int16_t *AccX, int16_t *AccY, int16_t *AccZ, 
						int16_t *GyroX, int16_t *GyroY, int16_t *GyroZ)
{
	uint8_t DataH, DataL;								//定义数据高8位和低8位的变量
	
	DataH = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_H);		//读取加速度计X轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_L);		//读取加速度计X轴的低8位数据
	*AccX = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
	
	DataH = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_H);		//读取加速度计Y轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_L);		//读取加速度计Y轴的低8位数据
	*AccY = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
	
	DataH = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_H);		//读取加速度计Z轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_L);		//读取加速度计Z轴的低8位数据
	*AccZ = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
	
	DataH = MPU6050_ReadReg(MPU6050_GYRO_XOUT_H);		//读取陀螺仪X轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_GYRO_XOUT_L);		//读取陀螺仪X轴的低8位数据
	*GyroX = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
	
	DataH = MPU6050_ReadReg(MPU6050_GYRO_YOUT_H);		//读取陀螺仪Y轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_GYRO_YOUT_L);		//读取陀螺仪Y轴的低8位数据
	*GyroY = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
	
	DataH = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_H);		//读取陀螺仪Z轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_L);		//读取陀螺仪Z轴的低8位数据
	*GyroZ = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
	/*
	这里我们是用读取一个寄存器器的函数
	连续调用了12次
	才读取完12个寄存器
	还有一种更高效的方法
	读取多个字节的时序
	从一个基地址开始连续读取一片的寄存器
	因为我们这个寄存器器的地址是连续的
	这样在时序上读取效率就会大大提升
	*/
}

main.c

#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "MPU6050.h"

uint8_t ID;								//定义用于存放ID号的变量
int16_t AX, AY, AZ, GX, GY, GZ;			//定义用于存放各个数据的变量

int main(void)
{
	/*模块初始化*/
	OLED_Init();		//OLED初始化
	MPU6050_Init();		//MPU6050初始化
	
	/*显示ID号*/
	OLED_ShowString(1, 1, "ID:");		//显示静态字符串
	ID = MPU6050_GetID();				//获取MPU6050的ID号
	OLED_ShowHexNum(1, 4, ID, 2);		//OLED显示ID号
	
	while (1)
	{
		MPU6050_GetData(&AX, &AY, &AZ, &GX, &GY, &GZ);		//获取MPU6050的数据
		OLED_ShowSignedNum(2, 1, AX, 5);					//OLED显示数据
		OLED_ShowSignedNum(3, 1, AY, 5);
		OLED_ShowSignedNum(4, 1, AZ, 5);
		OLED_ShowSignedNum(2, 8, GX, 5);
		OLED_ShowSignedNum(3, 8, GY, 5);
		OLED_ShowSignedNum(4, 8, GZ, 5);
	}
}

在这里插入图片描述

可以看到目前oled显示的六个数据,并且不断在刷新,首先这里左边三个数是x y z轴的加速度计,我们按照之前说的一个正方形里面,放置一个小球的模型来理解一下,小球压在哪个面上,就产生对应轴的输出,目前这个芯片是水平放置,对应正方体四个侧面应该不受力,所以这里显示的xy轴数据基本为0,小球压在底面上产生一个g的重力加速度,这里显示的数据是1943,这个数据对应的重力加速度值,可以算一下,目前初始化配置,这里我们选择的满量程是最大的16g,所以按比例算一下,1943÷32768等于x除16g,解得x就是测量值,计算一下,1943÷32768x16=0.95g,即测得z轴的加速度值是0.95g,这里标准的答案应该是1个g,所以测量基本没问题。

然后我们看一下陀螺仪,这里右边三个数据为三个轴的角速度,那我们这样转应该是绕z轴旋转陀螺仪z轴会输出对应的角速度,这样转呢是绕y轴的转动图了以y轴数据变化,这样呢就是绕x轴的转动陀螺仪x轴数据变化,大家可以试一下,具体每个轴旋转的角速度是多少,也是按照我们刚才说的比例公式计算,读取的数据除32768等于x除满量程,解得x就是具体的角速度值,那这些就是这个传感器,测量各轴姿态数据的实验现象。

I2C通信外设

那之前的课程我们用的是软件I2C,手动拉低或释放时钟线,然后再手动对每个数据位进行判断,拉低或释放数据线,这样来产生这个的波形,这是软件I2C。由于12C是同步时序,这每一位的持续时间要求不严格,某一位时间长一点短一点或许中途暂停一下时序,影响都不大,所以I2C是比较容易用软件模拟的。

在实际项目中,软件模拟的I2C也是非常常见的,但是作为一个协议标准,I2C通信,也是可以有硬件收发电路的。就像之前的串口通信一样,我们先讲了串口的时序波形,但是在程序中,我们并没有用软件去手动翻转电平来实现这个波形,这是因为串口是异步时序,每一位的时间要求很严格,不能过长也不能过短,所以串口时序虽然可以用软件模拟,但是操作起来比较困难。另外,由于串口的硬件收发器在单片机中的普及程度非常高,基本上每个单片机都有串口的硬件资源,而且硬件实现的串口使用起来还非常简单,所以,串口通信,我们基本都是借助硬件收发器来实现的。

I2C通信外设
硬件实现串口(USART)的使用流程:首先配置USART外设,然后写入数据寄存器DR,然后硬件收发器就会自动生成波形发送出去,最后我们等待发送完成的标志位即可。
回到I2C这里,I2C也可以有软件模拟和硬件收发器自动操作这两种异步时序,对于串口这样的异步时序,软件实现麻烦,硬件实现简单,所以串口的实现基本是全部倒向硬件。而对于I2C这样的同步时序来说,软件实现简单灵活,硬件实现麻烦,但可以节省软件资源、性能更强,可以实现完整的多主机通信模型等,各有优缺点。

在这里插入图片描述

多主机模型了解即可,I2C通信分为主机和从机,主机就是拥有主动控制中心的权利,而从机只能在主机允许的情况下才能控制总线,在一主多从的模型下,就是这样,这个是唯一的主机,下面这里可以挂载多个从机等等,那这个过程就很容易操作了,主机一个人掌控所有从机都得听他的话,不存在什么权力冲突,那进阶版的I2C还设计了多主机的模型,多主机又可以分为固定多主机和可变多主机。固定多主机,就是这条总线上有两个或更多个固定的主机,上面这几个始终固定为主机,下面这几个始终固定为从机,这个状态就像是在教室里,讲台上同时站了多个老师,下面坐的所有学生可以被任意一个老师点名,老师可以主动发起对学生的控制,学生不能去控制老师,当两个老师同时想说话时就是总线冲突状态,这时就要进行总线仲裁,仲裁失败的一方让出总线控制权。

然后是可变多主机,这个模型的意思是假设这是I2C总线,可以挂载多个设备,总线上没有固定的主机和从机,任何一个设备都可以在总线空闲时跳出来作为主机,然后指定其他任何一个设备进行通信,当这个通信完成之后,这个跳出来的主机就要退回到从机的位置,当有多个同时跳出来时,就是总线冲突状态,这时就要进行总线仲裁,仲裁失败的一方让出总线控制权,对于我们stm32的I2C而言,它使用的是可变多主机的模型,所以我们还是得按照谁要做主机,谁就跳出来的思路来操作。

下面这些了解一些即可,UP主稍微讲解了下。

I2C框图
在这里插入图片描述
首先左边这里是外设的通信引脚sda和scl,下面SMBALERT是SMBus用到的,像这种外设模块引出来的引脚,一般都是借助GPIO口的复用模式与外部世界相连的,具体是复用在了哪个GPIO口呢,还是查询这个引脚定义表,在复用功能这两栏里找一下,因为内部电路设计的时候引脚就是连接好了的,所以如果想使用硬件I2C,就只能使用它连接好的指定硬件,不像软件I2C那样引脚可以任意指定。

继续看内部电路,上面这一块是sda,也就是数据控制部分,数据收发的核心部分是这里的数据寄存器和数据移位寄存器,当我们需要发送数据时,可以把一个字节数据写到数据寄存器DR,当移位寄存器没有数据移位时,这个数据寄存器的值就会进一步转到移位寄存器里,在移位的过程中我们就可以直接把下个数据放到数据寄存器器里等着了,一旦前个数据移位完成,下一个数据就可以无缝衔接继续发送,当数据由数据寄存器器转到移位寄存器时,就会置位状态寄存器的TXE位为1,表示发送寄存器器为空,那在接收时也是这一路,输入的数据一位一位的从引脚移入到移位寄存器里,当一个字节的数据收起之后,数据就整体从移位寄存器转到数据寄存器,同时置位标志位RXNE表示接收寄存器器非空,这时候我们就可以把数据从数据寄存器读出来了,这个流程和之前串口是一样的,只不过串口是全双工。

至于什么时候收,什么时候发,需要我们写入控制寄存器的对应位进行操作,对于起始条件,终止条件,应答位什么的,这里都有控制电路可以完成,至于具体实现细节,这里也没详细化,大家知道有电路可以完成这些工作就行了。

比较器和自身地址寄存器,双地址寄存器,帧错误校验计算和真错误校验寄存器,首先说一下这两块内容,我们用不到了解即可,那这里比较器和地址寄存器,这是从机模式使用的,stm32是基于可变多主机模型设计的,不进行通信的时候就是从机,就可以被别人召唤,想被别人召唤,它就应该有从机地址,就可以由这个自身地址寄存器制定,我们可以自定义一个从机地址写到这个寄存器,当stm32作为从机在被寻址时,如果收到的寻址通过比较器判断和自身地址相同,那stm32就作为从机响应外部主机的召唤,并且这个s t m32 支持同时响应两个从机地址,所以就有自身地址寄存器和双地址寄存器。

右边这一块也是进阶的内容,这是stm32设计的一个数据校验模块PEC,当我们发送一个多字节的数据帧,硬件可以自动执行crc校验计算,附加在这个数据帧后面,crc校验算法通不过硬件就会自校验错误标志位告诉你数据错了,这一块我们也不会用的,也是了解即可。

继续看下面scl的这部分,时钟控制是用来控制scl线的,至于控制的细节,这里也没画,你就把它当做是一个黑盒子就行了,在这个时钟控制寄存器写对应的位电路就会执行对应的功能,然后控制逻辑电路也是黑盒子,写入控制寄存器可以对整个电路进行控制,读取状态寄存器可以得知电路的工作状态,之后是中断,当内部有些标志位置1之后,可能事件比较紧急,就可以申请中断,如果我们开启了这个中断,那当这个事件发生后,程序就可以跳到中断函数来处理这个事件了。

最后是dma请求与响应,在进行很多字节的收发时,可以配合dma来提高效率,这个也了解一下。

其实也没有很多东西,大部分都是黑盒模型。

在这里插入图片描述

接着我们看一下这个基本结构图,结构图画的也是比较简单,首先移位进寄存器和数据寄存器DR的配合是通信的核心部分,这里因为I2C是高位先行,所以这个移位寄存器是向左移位,在发送的时候最高位先移出去,一个scl时钟移位一次,八次这样就能把一个字节由高位到低位依次放到sda线上了,那在接收的时候呢,数据通过GPIO口从右边依次移进来,最终移8次1个字节就接收完成了,使用硬件I2C的时候GPIO口都要配置成复用开漏输出的模式,交由片上外设来控制,这里即使是开漏输出模式,GPIO口也是可以进行输入的,然后scl这里时钟控制器,通过GPIO去控制时钟线,这里我简化成一主多从的模型了,所以时钟这里只画了输出的方向,实际上前面这里如果是多主机的模型,时钟线也是会进行输入的。

sda的部分输出数据通过GPIO输出到端口,输入数据通过gpio输入到移位寄存器,那这两个箭头连接在GPIO的哪个位置呢,我们看一下ppt最前面,在这个位置复用开漏和推挽输出模式,我们要使用开漏输出,所以这个p-mos是没有的,然后刚才看的移位计算器输出的数据通向GPIO,就接在了这个位置,就是来自片上外设的复用功能输出,所以I2C外设的输出就接到这里,之后控制这个N-MOS的通断,进而控制这个io引脚是拉低到低电平还是释放悬空,然后对于输入部分可以看到,虽然这是复用开漏输出,但是输入这一路仍然有效,lO引脚的高低电平,通过这里进入片上外设来进行复用功能输入,所以I2C外设通向GPIO输出出就接到了这里,输入就接到了这里。

数据控制器是黑盒模型,没啥说的。

最后还是有个开关控制,也就是I2C_Cmd,配置好了就使能外设,外设就能正常工作了,这些就是外设的基本结构图。

接下来看下硬件I2C的操作流程:

这个操作流程图就告诉了我们,要想产生这样的I2C时序,啥时候该干些啥,啥时候会产生啥事件,我们写程序的时候就是参考这个流程来写的,所以还是要仔细分析一下的。
在这里插入图片描述
我们先看一下主机发送,当stm32 想要执行指定地址写的时候,就按照这个主发送器传送序列图来进行,这里有七位地址的主发送和十位地址的主发送,它们的区别就是七位地址,起始条件后的一个字节是寻址,十位地址,起始条件后的两个字节都是寻址,其中前一个字节,这里写的是帧头,内容是五位的标志位,11110+2位地址+1位读写位,然后后一个字节内容就是纯粹的八位地址了,两个字节加一起构成十位的寻址,这是十位地的选择模式啊,我们主要关注七位地址的就行了,七位主发送这个时序,流程是起始、从机地址、应答,后面是数据1应答,数据1应答等等,最后是p停止,因为I2C协议只规定了起始之后必须是寻址,至于后面数据的用途啊,并没有明确的规定,这些数据可以由各个芯片厂商自己来规定,比如mpu6050规定,就是寻址之后数据1为指定寄存器地址,数据2为指定寄存器地址下的数据,之后的数据n啊,就是从指定寄存器地址开始依次往后写,就是一个典型的指定地址写的时序流程,然后我们从头来看一下,首先初始化之后,总线默认空闲状态,stm32 默认是从模式,为了产生一个起始条件,stm32 需要写入控制寄存器,这个得看一下手册的寄存器描述,在控制寄存器中有个START位,在这一位写1就可以产生起始条件了,当起始条件发出后,这一位可以由硬件清除,所以只要在这一位写1,s,tm32就自动产生起始条件了,之后stm32 由从模式转为主模式,也就是多主机模型下,stm32有数据要发就要跳出来这个意思,然后控制完硬件电路之后,我们就要检查标志位,来看看硬件有没有达到我们想要的状态,在这里起始条件之后,会发生EV5事件,又可以把它当成是标志位,这个手册这里都是用EV几这个事件来代替标志位的,为什么要设计这个事件,而不直接说产生什么标志位呢,这是因为有的状态会同时产生多个标志位,所以这个事件,就是组合了多个标志位的一个大标志位,在库函数中也有对应的,检查EV几事件是否发生的函数,所以你就当成是一个大标志位来理解就行了,下面这里解释EV5事件,sb是状态寄存器的一个位,表示它硬件的状态,查看手册这位置1代表起始条件已发送,软件读取SR1寄存器进入机后,也就是查看了这一位,然后写数据进器的操作,将清除该位,写数据计算器dr就是我们接下来的操作,所以按照正常的流程呢,这个状态寄存器是不需要手动清除的,然后继续这个流程,当我们检测起始条件已发送时,就可以发送一个字节的从机地址了,从机地址需要写到数据寄存器DR中,写入DR之后,硬件电路就会自动把这一字节转到移位寄存器里,再把这一个字节发送到iI2C总线上,之后硬件会自动接收应答位并判断,如果没有应答硬件就会置应答失败的标志位,然后这个标志位可以申请中断来提醒我们,在选址完成之后会发生EV6事件,下面EV6事件的解释就是ADDR标志位为1,在手册中可以找到ADDR标志位,在主模式状态下为1就代表地址发送结束,大家继续看EV6事件结束后是EV8_1事件,下面解释EV8_1事件,就是TXE标志位等于1,移位寄存器空,数据寄存器空,这时需要我们写入数据寄存器dr进行数据发送的,一旦写入dr之后,因为移位寄存器也是空,所以DR会立刻转到移位寄存器进行发送,这时就是EV8事件,移位计算器非空,数据寄存器空,这时就是移位寄存器器正在发数据的状态,所以流程这里啊,数据1的时序就产生了,这个数据寄存器和移位寄存器的配合,要把前面这个结构记好,就是发送的时候数据先写入数据寄存器器,如果移位寄存器器没有数据,再转到移位寄存器进行发送,这个流程要理解清楚。

那继续看,在这个位置,EV8事件没有了,对应下面这里写入dr,将清除该事件,所以按理说这个位置应该是写入了下一个数据,也就是后面这个数据2,在这个时刻就被写入到数据寄存器里等着了,然后接收应答位之后,数据2就转入移位寄存器进行发送,此时的状态是移位寄存器非空,数据寄存器空,所以这时这个EV8事件就又发生了,这个位置数据2还正在移位发送,但此时下一个数据已经被写到数据寄存器等着了,所以这个时候EV8事件消失,之后应答产生EV8事件写入数据寄存器,EV8事件消失
,按照这个流程来啊,一旦我们检测到EV8事件,就可以写入下一个数据了,最后当我们想要发送的数据写完之后,这时就没有新的数据可以写入到数据寄存器去了,当移位寄存器当前的数据移位完成时,此时就是移位寄存器空数据寄存器也空的状态,这个事件就是这里的EV8_2,下面解释啊,EV8_2是TXE等于1,也就是数据寄存器空,BTF这个是字节发送结束标志位,手册可以看一下这里字节发送结束,下面解释是在发送时,当一个新数据将被发送,且数据寄存器还未写入新的数据时,BTF标志位置1,这个意思就是当前的移位寄存器已经移完了,该找数据寄存器要下一个数据了,但是一看数据寄存器没有数据,这就说明主机不想发了,这时就代表字节发送结束,是时候停止了,所以在这里当检测到EV8_2时,就可以产生终止条件了,产生终止条件显然应该在控制寄存器里有相应的位可以控制,手册这里控制寄存器CR1中,stop位写1,就会在当前字节传输或当前起始条件发送后产生停止条件,那到这里一个完整的时序就发送完成了。

整个过程看上去可能比较复杂,但是简单来说就是写入控制寄存器CR,或者数据寄存器DR,就可以控制时序单元的发生,比如产生起始条件,发送一个字节数据,时序单元发生后检查相应的ev事件,其实就是检查状态寄存器器SR,来等待时序单元发送完成,然后依次按照这个流程操作等待操作等待等等,这样就能实现时序了。

当然在程序中,我们有库函数,不需要实际去配置寄存器的,所以这个过程会比想象中简单一些。
在这里插入图片描述
接着我们继续看主机接收的流程,当然我们主要还是看七位地址的就行,这里看一下,首先写入控制寄存器器的start位,产生起始条件,然后等待Ev5事件,下面解释和刚才一样,Ev5事件就代表起始条件已发送,最后是寻址、接收应答、结束后产生Ev6事件,这数据1这一块代表数据正在通过移位寄存器进行输入,Ev6_1事件,下面解释是没有对应的事件标志,只适于接收一个字节的情况,这个Ev6_1可以看到数据1其实还正在移位,还没收到呢,所以这个事件就没有标志位,之后当这个时序单元完成时,硬件会自动根据我们的配置,把应答位发送出去,如何配置是否要给印答呢,也是手册控制净寄存器CR1里,这里有一位ACK应答使能,如果写1在接收一个字节后就返回一个应答,写零就是不给应答,就是应答位的配置,之后继续当这个时序单元结束后,就说明移位寄存器器已经成功移入一个字节的数据1了,这时移入的一个字节就整体转移到数据寄存器,同时置RxNE标志位,表示数据寄存器非空,也就是收到了一个字节的数据,这个状态就是Ev7事件,下面解释是RxNE等于1,数据寄存器非空,读DR寄存器清除该事件,也就是收到数据了,当把这个数据读走之后,这个事件就没有了,上面这里Ev7事件没有了,说明此时数据1被读走,当然数据1还没读走的时候啊,数据2就可以直接移入移位寄存器了,之后数据2移位完成,收到数据2产生Ev7事件,读走数据2Ev7 事件没有了,然后按照这个流程就可以一直接收数据了,最后当我们不需要继续接收时,需要在最后一个时序单元发生时,提前把刚才说的应答位控制寄存器ack置0,并且设置终止条件请求,这就是Ev7_1事件。

下面解释和Ev7_1样,后面加了一句,设置ack等于0和stop请求,也就是我们想要结束了,之后在这个时序完成后,由于设置了ack等于0,所以这里就会给出非应答,最后由于设置stop位,所以产生终止条件,这样接收一个字节的时序就完成了。

在这里插入图片描述

我们再看一下,软件I2C和硬件I2C的波形对比,可以加深你对协议的理解,这里是我用示波器抓取的,上面这个是软件I2C的波形,可以看出这是一个指定地址读的时序,我们看一下软件模拟的时序和硬件生成的时序有什么异同,首先从引脚电平变化趋势上看,这两个波形都是一样的,对应的数据也都是一样的,然后从时钟线的规整程度上看,硬件I2C的波形会更加规整,这里硬件I2C每个时钟的周期、占空比都非常一致,而软件这里呢,由于操作引脚之后都加了延时,这个延时有时候加的多,有时候加的少,所以软件时序的时钟周期占空比可能不规整,不过由于I2C是同步时序,这些不规整也没有影响,然后还有就是,SCL低电平写高电平读,虽然整个电平的任意时候都可以读写,但是一般要求保证尽早的原则,所以可以直接认为是SCL下降沿写,上升沿读,这里看一下,软件I2C在下降沿之后,因为操作端口之后有些延时,所以这里等了一会儿才进行写入操作,后面的写也是等了一会儿,但在硬件这里数据写入都是紧贴下降沿的,这里SCL下降沿,sda立马就切换数据了,后面也是这样的哈,在读的时候,我这里虽然绿线画在了高电平中间了,但实际上读的时刻也是紧贴上升沿进行的,之后在这个时刻就更明显了,这里是应答结束,从机在SCL下降沿立刻释放了sda,但是软件I2C的主机过了一会儿才变换数据,所以这里就出现了一个短暂的高电平,而硬件I2C应答结束后,scl下降沿从机立刻释放sda,同时主机也立刻拉低sda,所以这里就出现了一个小尖峰。

硬件操作的I2C是硬件操作的,这些sda的数据变化,都是在scl的下降沿进行的,而这些软件操作的I2C波形,可能就不是那么标准了,当然还是因为I2C同步时序的原因,这些不标准的波形也完全不影响通信,这也正是同步时序的好处,可以容忍不标准的波形,那这些就是软件和硬件波形的对比。

手册

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

代码实战:硬件I2C读写MPU6050

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

库函数的使用流程:
第一步开启I2C外设和对应GPIO口的时钟
第二步把I2C外设对应的GPIO口初始化为复用开漏模式
第三步使用结构体对整个I2C进行配置
第四步I2C_Cmd,使能I2C
这样初始化配置就完成了。

在这里插入图片描述
在这里插入图片描述

MPU6050.h

#ifndef __MPU6050_H
#define __MPU6050_H

void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data);
uint8_t MPU6050_ReadReg(uint8_t RegAddress);

void MPU6050_Init(void);
uint8_t MPU6050_GetID(void);
void MPU6050_GetData(int16_t *AccX, int16_t *AccY, int16_t *AccZ, 
						int16_t *GyroX, int16_t *GyroY, int16_t *GyroZ);

#endif

MPU6050_REG.h

#ifndef __MPU6050_REG_H
#define __MPU6050_REG_H

#define	MPU6050_SMPLRT_DIV		0x19
#define	MPU6050_CONFIG			0x1A
#define	MPU6050_GYRO_CONFIG		0x1B
#define	MPU6050_ACCEL_CONFIG	0x1C

#define	MPU6050_ACCEL_XOUT_H	0x3B
#define	MPU6050_ACCEL_XOUT_L	0x3C
#define	MPU6050_ACCEL_YOUT_H	0x3D
#define	MPU6050_ACCEL_YOUT_L	0x3E
#define	MPU6050_ACCEL_ZOUT_H	0x3F
#define	MPU6050_ACCEL_ZOUT_L	0x40
#define	MPU6050_TEMP_OUT_H		0x41
#define	MPU6050_TEMP_OUT_L		0x42
#define	MPU6050_GYRO_XOUT_H		0x43
#define	MPU6050_GYRO_XOUT_L		0x44
#define	MPU6050_GYRO_YOUT_H		0x45
#define	MPU6050_GYRO_YOUT_L		0x46
#define	MPU6050_GYRO_ZOUT_H		0x47
#define	MPU6050_GYRO_ZOUT_L		0x48

#define	MPU6050_PWR_MGMT_1		0x6B
#define	MPU6050_PWR_MGMT_2		0x6C
#define	MPU6050_WHO_AM_I		0x75

#endif

MPU6050.c

#include "stm32f10x.h"                  // Device header
#include "MPU6050_Reg.h"

#define MPU6050_ADDRESS		0xD0		//MPU6050的I2C从机地址

/**
  * 函    数:MPU6050等待事件
  * 参    数:同I2C_CheckEvent
  * 返 回 值:无
  */
void MPU6050_WaitEvent(I2C_TypeDef* I2Cx, uint32_t I2C_EVENT)
{
	uint32_t Timeout;
	Timeout = 10000;									//给定超时计数时间
	while (I2C_CheckEvent(I2Cx, I2C_EVENT) != SUCCESS)	//循环等待指定事件
	{
		Timeout --;										//等待时,计数值自减
		if (Timeout == 0)								//自减到0后,等待超时
		{
			/*超时的错误处理代码,可以添加到此处*/
			break;										//跳出等待,不等了  防止程序卡死
		}
	}
}
/*
软件I2C的这些函数内部都有delay操作,是一种阻塞式的流程,也就是函数运行完成之后啊,对应的波形也肯定发送完毕了
所以上一个函数运行完之后,就可以紧跟下一个函数,但是下面这个函数呢,包括之后的硬件I2C函数,都不是阻塞式的
这些硬件I2C函数只管给寄存器的位置1,或者只在DR写入数据就结束退出函数,至于波形是否发送完毕,他是不管的
所以对于这种非阻塞式的程序,在函数结束之后,我们都要等待相应的标志位,来确保这个函数的操作执行到位了

当起始条件的波形确实发出来,会产生EV5事件,所以在程序中我们要等待EV5事件的到来
*/


/**
  * 函    数:MPU6050写寄存器
  * 参    数:RegAddress 寄存器地址,范围:参考MPU6050手册的寄存器描述
  * 参    数:Data 要写入寄存器的数据,范围:0x00~0xFF
  * 返 回 值:无
  */
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{
	I2C_GenerateSTART(I2C2, ENABLE);										//硬件I2C生成起始条件
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);					//等待EV5
	
	I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter);	//硬件I2C发送从机地址,方向为发送  自己用I2C_SendData实现也可以
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED);	//等待EV6
	
	I2C_SendData(I2C2, RegAddress);											//硬件I2C发送寄存器地址
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTING);			//等待EV8
	
	I2C_SendData(I2C2, Data);												//硬件I2C发送数据
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED);				//等待EV8_2
	
	I2C_GenerateSTOP(I2C2, ENABLE);											//硬件I2C生成终止条件
}

/**
  * 函    数:MPU6050读寄存器
  * 参    数:RegAddress 寄存器地址,范围:参考MPU6050手册的寄存器描述
  * 返 回 值:读取寄存器的数据,范围:0x00~0xFF
  */
uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{
	uint8_t Data;
	
	I2C_GenerateSTART(I2C2, ENABLE);										//硬件I2C生成起始条件
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);					//等待EV5
	
	I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter);	//硬件I2C发送从机地址,方向为发送
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED);	//等待EV6
	
	I2C_SendData(I2C2, RegAddress);											//硬件I2C发送寄存器地址
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED);				//等待EV8_2
	

	I2C_GenerateSTART(I2C2, ENABLE);										//硬件I2C生成重复起始条件
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);					//等待EV5
	
	I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Receiver);		//硬件I2C发送从机地址,方向为接收
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED);		//等待EV6
	
	I2C_AcknowledgeConfig(I2C2, DISABLE);									//在接收最后一个字节之前提前将应答失能(下面有解释)
	I2C_GenerateSTOP(I2C2, ENABLE);											//在接收最后一个字节之前提前申请停止条件
	
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_RECEIVED);				//等待EV7
	Data = I2C_ReceiveData(I2C2);											//接收数据寄存器
	
	I2C_AcknowledgeConfig(I2C2, ENABLE);									//将应答恢复为使能,为了不影响后续可能产生的读取多字节操作
	//默认状态下ACK就是1,给从机应答,在收最后一个字节之前,临时把ACK置0,给非应答,所以在接收函数最后恢复默认的ACK=1
	//这个流程是为了方便指定地址收多个字节,虽然我们程序自始至终只收一个字节我们从来就没给过应答,但是这个形式还是要写下的,方便我们之后进一步改进代码
	
	return Data;
}

/**
  * 函    数:MPU6050初始化
  * 参    数:无
  * 返 回 值:无
  */
void MPU6050_Init(void)
{
	/*开启时钟*/
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C2, ENABLE);		//开启I2C2的时钟
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);		//开启GPIOB的时钟
	
	/*GPIO初始化*/
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD;				//复用开漏模式
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOB, &GPIO_InitStructure);					//将PB10和PB11引脚初始化为复用开漏输出
	
	/*I2C初始化*/
	I2C_InitTypeDef I2C_InitStructure;						//定义结构体变量
	I2C_InitStructure.I2C_Mode = I2C_Mode_I2C;				//模式,选择为I2C模式
	I2C_InitStructure.I2C_ClockSpeed = 50000;				//时钟速度,选择为50KHz mpu6050支持最大400KHz 
	I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2;		//时钟占空比,选择Tlow/Thigh = 2:1
	I2C_InitStructure.I2C_Ack = I2C_Ack_Enable;				//应答,选择使能
	I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit;	//应答地址,选择7位(还有10位地址模式),从机模式下才有效
	I2C_InitStructure.I2C_OwnAddress1 = 0x00;				//自身地址,从机模式下才有效 暂时不做从机这里随便给一个值,不要和总线其他地址重复
	I2C_Init(I2C2, &I2C_InitStructure);						//将结构体变量交给I2C_Init,配置I2C2
	
	/*I2C使能*/
	I2C_Cmd(I2C2, ENABLE);									//使能I2C2,开始运行
	
	/*MPU6050寄存器初始化,需要对照MPU6050手册的寄存器描述配置,此处仅配置了部分重要的寄存器*/
	MPU6050_WriteReg(MPU6050_PWR_MGMT_1, 0x01);				//电源管理寄存器1,取消睡眠模式,选择时钟源为X轴陀螺仪
	MPU6050_WriteReg(MPU6050_PWR_MGMT_2, 0x00);				//电源管理寄存器2,保持默认值0,所有轴均不待机
	MPU6050_WriteReg(MPU6050_SMPLRT_DIV, 0x09);				//采样率分频寄存器,配置采样率
	MPU6050_WriteReg(MPU6050_CONFIG, 0x06);					//配置寄存器,配置DLPF
	MPU6050_WriteReg(MPU6050_GYRO_CONFIG, 0x18);			//陀螺仪配置寄存器,选择满量程为±2000°/s
	MPU6050_WriteReg(MPU6050_ACCEL_CONFIG, 0x18);			//加速度计配置寄存器,选择满量程为±16g
}


/**
  * 函    数:MPU6050获取ID号
  * 参    数:无
  * 返 回 值:MPU6050的ID号
  */
uint8_t MPU6050_GetID(void)
{
	return MPU6050_ReadReg(MPU6050_WHO_AM_I);		//返回WHO_AM_I寄存器的值
}

/**
  * 函    数:MPU6050获取数据
  * 参    数:AccX AccY AccZ 加速度计X、Y、Z轴的数据,使用输出参数的形式返回,范围:-32768~32767
  * 参    数:GyroX GyroY GyroZ 陀螺仪X、Y、Z轴的数据,使用输出参数的形式返回,范围:-32768~32767
  * 返 回 值:无
  */
void MPU6050_GetData(int16_t *AccX, int16_t *AccY, int16_t *AccZ, 
						int16_t *GyroX, int16_t *GyroY, int16_t *GyroZ)
{
	uint8_t DataH, DataL;								//定义数据高8位和低8位的变量
	
	DataH = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_H);		//读取加速度计X轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_L);		//读取加速度计X轴的低8位数据
	*AccX = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
	
	DataH = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_H);		//读取加速度计Y轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_L);		//读取加速度计Y轴的低8位数据
	*AccY = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
	
	DataH = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_H);		//读取加速度计Z轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_L);		//读取加速度计Z轴的低8位数据
	*AccZ = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
	
	DataH = MPU6050_ReadReg(MPU6050_GYRO_XOUT_H);		//读取陀螺仪X轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_GYRO_XOUT_L);		//读取陀螺仪X轴的低8位数据
	*GyroX = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
	
	DataH = MPU6050_ReadReg(MPU6050_GYRO_YOUT_H);		//读取陀螺仪Y轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_GYRO_YOUT_L);		//读取陀螺仪Y轴的低8位数据
	*GyroY = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
	
	DataH = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_H);		//读取陀螺仪Z轴的高8位数据
	DataL = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_L);		//读取陀螺仪Z轴的低8位数据
	*GyroZ = (DataH << 8) | DataL;						//数据拼接,通过输出参数返回
}

main.c

#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "MPU6050.h"

uint8_t ID;								//定义用于存放ID号的变量
int16_t AX, AY, AZ, GX, GY, GZ;			//定义用于存放各个数据的变量

int main(void)
{
	/*模块初始化*/
	OLED_Init();		//OLED初始化
	MPU6050_Init();		//MPU6050初始化
	
	/*显示ID号*/
	OLED_ShowString(1, 1, "ID:");		//显示静态字符串
	ID = MPU6050_GetID();				//获取MPU6050的ID号
	OLED_ShowHexNum(1, 4, ID, 2);		//OLED显示ID号
	
	while (1)
	{
		MPU6050_GetData(&AX, &AY, &AZ, &GX, &GY, &GZ);		//获取MPU6050的数据
		OLED_ShowSignedNum(2, 1, AX, 5);					//OLED显示数据
		OLED_ShowSignedNum(3, 1, AY, 5);
		OLED_ShowSignedNum(4, 1, AZ, 5);
		OLED_ShowSignedNum(2, 8, GX, 5);
		OLED_ShowSignedNum(3, 8, GY, 5);
		OLED_ShowSignedNum(4, 8, GZ, 5);
	}
}

注意:

在这里插入图片描述

在接收一个字节时,有个EV6_1事件,这个事件没有标志位,也不需要我们等待,它是适合接收一个字节的情况,那么目前是要接收几个字节呢,就是一个字节,我们是指定地址读一个字节,所以在EV6_1时,也就是EV6事件之后啊,我们要注意什么呢,就是后面这里说的恰好在EV6之后,要清除响应和停止条件的产生,也就是告诉我们啊,在代码的这个时刻,我们要把应答位ack置0,同时把停止条件生成位stop置1,那你可能会问哈,这时不应该是接收数据吗,数据都没收到,就要产生停止条件,答案确实如此,这里规定就是在接收最后一个字节之前,就要提前把ack置0,同时设置停止位stop,因为我们目前是接收一个字节,所以在进入接收模式之后,就要立刻ack置0,stop置1,为什么要这样设计呢,我们看一下序列图啊,这里如果你不提前在数据还没收到的时候给ack置0,那等时序到了这里,数据已经收到了,你再说我要置0,我要给非应答,这时是不是就晚了,数据收到之前,应答位就已经发送出去了,你这时再给ack置0,那只能是在下一个数据之后给非应答了,时序不等人哈,所以在最后一个数据之前就要给ack置0,同时这里也建议我们提前设置stop中止条件,这个终止条件也不会截断当前字节,它会等当前字节接收完成后,再产生终止条件的波形,所以总结一下,就是如果你是读取多个字节,那直接等待EV7事件读取dr就能收到数据了,这样依次接收,在接收最后一个字节之前,也就是这里的EV7_1事件,需要提前把ack置0stop置1,如果你只需要读取1个字节,那在EV6事件之后,就要立刻ack置0stop置1,要不然你设置晚了,实际上就会多一个字节出来。

UP主做的实验,抓取各个频率下的波形:

在这里插入图片描述
可以清晰的看出来最前面这里是起始信号,后面是数据传输,数据位在scl高电平比划一下1101 0000,那目前scl频率50千赫处于标准速度,所以这里占空比可以观察到低电平比高电平是一比一,也就是50%占空比的方波,这看上去都非常完美,然后我们加大频率看一下。

在这里插入图片描述

100kHz仍然是标准速度,所以时钟占空比仍然是1比1,不过一个更重要的细节,我们观察到scl和sda的下降沿,变化是非常快的,但是在他们的上升沿就会像这样,哎这个线它是缓慢上去的,这是为什么呢,其实也好理解,按照我们之前的感知弹簧模型来解释,你就明白了,我们这个线就是一根杆子,它是由一根弹簧默认拉到高电平,当我们输出低电平时,我们要用强下拉,也就是无穷大的力,用力把杆子拽下来,那这时可想而知,因为这个拽下来的力非常大,所以这个下降沿就非常的果断和迅速,但是输出高电平呢,我们是释放了杆子,杆子通过弹簧拉回至高电平,弹簧是个弱上拉,所以这里上升沿就有一个回弹的过程,波形就会相对缓慢的上去啊,那这个缓慢变化的上升沿有什么影响呢,我们继续往后看。

在这里插入图片描述

下面一个图片是101kHz的波形,100kHz和101kHz频率差不多,但是101kHz就进入快速状态了,这时I2C会对scl占空比进行调节,低电平比高电平由原来的1比1,变为大概2比1,增大了低电平时间占整个周期的比例,为什么要增大低电平的比例呢,因为低电平数据变化,高电平数据读取,数据变化需要一定时间来翻转波形,尤其是这个数据的上升沿变化比较慢,所以在快速传输的状态下,你要给低电平多分配一些资源,要不然低电平数据变化来不及,你高电平数据读取也没用,就像是我们的硬盘一样,一般都是读取速度要大于写入速度,所以要想快速转入给低电平的写入时间,多分配点资源是合理的,这是标准速度下时钟占空比接近一比一,快速状态下时钟占空比接近二比一的原因。

在这里插入图片描述
这个时钟频率是200kHz,由于时间轴尺度进一步缩小,这时这个弯弯的上升沿就更加明显了,比如这个时钟的低电平期间啊,如果你不给他多分配一些时间,他可能都来不及进行数据变化,当然这个波形虽然是弯弯的哈,但是整体上还是非常清晰的,可以辨认出来。

在这里插入图片描述

最后我们继续看快速模式的极限速度400kHz,这时的波形就比较惨了,可以看出这个时钟的高电平从这里开始释放scl,scl还没完全回弹到高电平呢,就立刻拉下来传输下一个数据来,所以整个scl波形就变成三角形了,这和我们想象的不太一样是吧,我们想的是scl释放应该立刻回弹高电平,谁会管这里还有个弯弯呢,但是在快速的数据传输中,这个弯弯就拖了后腿,限制了I2C总线的最大传输速度,另外在下面这里可以看到,scl低电平期间,数据变化也不是完全贴到下降沿的,这也会有些延时,所以这就更有必要,在低电平多分配一些时间了,这些就是有关sl时钟频率和占空比的相关研究了。

  • 30
    点赞
  • 100
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

行稳方能走远

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值