7 模数转换ADC

7.1 ADC简介

ADC可以将引脚上连续变化的模拟电压,转换为内存中存储的数字变量,建立模拟电路到数字电路的桥梁,刚才通过程序现象我们也看到了,STM32主要是数字电路,数字电路只有高低电平,没有几伏电压的概念,所以如果想读取电压值,就需要借助ADC模数转换器来实现了,ADC读取引脚上的模拟电压转化为一个数据存放在寄存器里,我们再把这个数据读取到变量里来,就可以进行显示判断记录等等操作。

注解,关于DAC:

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,中间都是一一对应的线性关系,这个计算起来就非常简单了,直接乘除一个系数就行了。
  • 18个输入通道数
    • 外部信号源就是16个GPIO口,在引脚上直接接模拟信号就行了,不需要任何额外的电路,引脚就直接能测电压。2个内部信号源是内部温度传感器和内部参考电压。温度传感器可以测量CPU的温度,比如你电脑可以显示一个CPU温度,就可以用ADC读取这个温度传感器来测量;内部参考电压是一个1.2V左右的基准电压,这个基准电压是不随外部供电电压变化而变化的,所以如果你芯片的供电不是标准的3.3V,那测量外部引脚的电压可能就不对,这时就可以读取这个基准电压进行校准,这样就能得到正确的电压值了。
  • 规则组和注入组两个转换单元
    • 这个是STM32 ADC的增强功能了。普通的AD转换流程是,启动一次转换、读一次值,然后再启动、再读值,这样的流程。但是STM32的ADC就比较高级,可以列一个组,一次性启动一个组,连续转换多个值。并且有两个组,一个是用于常规使用的规则组,一个是用于突发事件的注入组
  • 模拟看门狗自动监测输入电压范围
    • 这个ADC,一般可以用于测量光线强度、温度这些值,并且经常会有个需求,就是如果光线高于某个阈值、低于某个阈值或者温度高于某个阈值、低于某个阈值时,执行一些操作。这个高于某个阈值、低于某个阈值的判断,就可以用模拟看门狗来自动执行。模拟看门狗可以监测指定的某些通道,当AD值高于它设定的上阈值或者低于下阈值时,它就会申请中断,你就可以在中断函数里执行相应的操作,这样你就不用不断地手动读值,再用if进行判断了。
  • STM32F103C8T6 ADC资源:ADC1、ADC2,10个外部输入通道
    • 这款芯片的ADC资源有ADC1、ADC2共有两个ADC外设,十个外部输入通道,也就是它最多只能测量十个外部引脚的模拟信号,我们之前这里说的16个外部信号源,这是这个系列最多有16个外部信号源,但是我们这个芯片引脚比较少,有很多引脚没有引出来,所以就只有十个外部信号源,如果你想要更多的外部通道,可以选择引脚更多的型号,具体有多少通道呢,那还需要再参考一下数据手册。

7.2 逐次逼近的原理

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

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

7.2.1 输入选择部分

首先左边这里IN0~IN7,是8路输入通道,通过通道选择开关,选中一路,输入到所标点进行转换。

下面这里是地址锁存和译码,就是你想选中哪个通道,就把通道号放在这三个脚(ADDA、ADDB、ADDC)上,然后给一个锁存信号(ALU),上面这里对应的通路开关就可以自动拨好了。这部分就相当于一个可以通过模拟信号的数据选择器

因为ADC转换是一个很快的过程,你给个开始信号,过几个us就转换完成了。所以说如果你想转换多路信号,那不必设计多个AD转换器,只需要一个AD转换器,然后加一个多路选择开关,想转换哪一路,就先拨一下开关,选中对应通道,然后再开始转换就行了。这就是这个输入通道选择的部分,这个ADC0809只有8个输入通道,我们STM32内部的ADC是有18个输入通道的,所以对应输入电路,就是一个18路输入的多路开关。

7.2.2 比较和输出结果部分

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

首先左边这是一个电压比较器,它可以判断两个输入信号模拟量电压的大小关系,输出一个高低电平指示谁大谁小。它的两个输入端,一个是待测的电压,另一个是这里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框图。

7.3 STM32 ADC电路图详解

7.3.1 18路输入通道

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

7.3.2 注入组和规则组

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

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

一般情况下,我们使用规则组就完全足够了,如果要使用规则组的菜单,那就再配合DMA转运数据,这样就不用担心数据覆盖的问题了。所以接下来就只讲规则组的操作,注入组涉及的不多,大家可以看手册自行了解。

7.3.3 ADC外围的一些线路

首先,左下角这里是触发转换的部分,也就是这里的START信号,开始转换。那对于STM32的ADC,触发ADC开始转换的信号有两种:

  1. 一种是软件触发,就是你在程序中手动调用一条代码,就可以启动转换了;
  2. 另一种是硬件触发,就是这里的这些触发源。

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

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

注意:想要使用STM32的定时器硬件触发ADC,必须将定时器配置为比较输出PWM模式,并且一定要注意TIMx_CHx输出上升沿才触发转换,若是在比较匹配的瞬时产生的不是上升沿而是下降沿,那么就不一定是在比较匹配的瞬间触发ADC了,特别是在类似于电机控制的应用中要注意这一点。 这一部分的具体分析请参考下面的这个链接。

参考链接:STM32关于使用定时器触发ADC转换的理解_stm32 定时器触发adc-CSDN博客

然后接着看,左上角这里是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对应的通道,它们就会触发中断。

7.4 ADC基本结构

7.4.1 基本结构框图

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

左边是输入通道,16个GPIO口,外加两个内部的通道,然后进入AD转换器。AD转换器里有两个组,一个是规则组,一个是注入组,规则组最多可以选中16个通道,注入组最多可以选择4个通道。然后转换的结果可以存放在AD数据寄存器里,其中规则组只有1个数据寄存器,注入组有4个。

然后下面这里有触发控制,提供了开始转换这个START信号,触发控制可以选择软件触发和硬件触发。硬件触发主要是来自于定时器,当然也可以选择外部中断的引脚,右边这里是来自于RCC的ADC时钟CLOCK,ADC逐次比较的过程就是由这个时钟推动的。

然后上面,可以布置一个模拟看门狗用于监测转换结果的范围,如果超出设定的阈值,就通过中断输出控制,向NVIC申请中断,另外,规则组和注入组转换完成后会有个EOC信号,它会置一个标志位,当然也可以通向NVIC。最后右下角这里还有个开关控制,在库函数中,就是ADC_Cmd()函数,用于给ADC上电的,那这些,就是STM32 ADC的内部结构了。

7.4.2 18个输入通道详解

首先看一下输入通道,刚才我们说了,这里有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上的,然后下面全都是一样的,这说明ADC1和ADC2的引脚全都是相同的。

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

注意,本芯片没有PC0-PC5引脚,故只有10个通道。

7.4.3 规则组的4种转换模式

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

  • 单次转换,非扫描模式

第一种,单次转换,非扫描模式,最简单,这里我画了一个列表,这个表就是规则组里的“菜单”,有16个空位,分别是序列1到序列16,你可以在这里“点菜”,就是写入你要转换的通道。

在非扫描的模式下,这个菜单就只有第一个序列1的位置有效,这时,菜单同时选中一组的方式就退化为简单地选中一个的方式了。在这里我们可以在序列1的位置指定我们想转换的通道,比如通道2,写到这个位置。然后,我们就可以触发转换,ADC就会对这个通道2进行模数转换,过一小段时间后,转换完成,转换结果放在数据寄存器里,同时给EOC标志位置1,整个转换过程就结束了。

判断EOC标志位,如果转换完了, 那我们就可以在数据寄存器里读取结果了。如果我们想再启动一次转换,那就需要再触发一次,转换结束,置EOC标志位,读结果。

PS:如果想换一个通道转换,那在转换之前,把第一个位置的通道2改成其他通道,然后再启动转换,这样就行了。

这就是单次转换,非扫描的转换模式。没有用到这个菜单列表,也是比较简单的一种模式。

  • 连续转换,非扫描模式

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

  • 单次转换,扫描模式

这个模式也是单次转换,所以每触发一次,转换结束后,就会停下来,下次转换就得再触发才能开始。然后它是扫描模式,这就会用到这个菜单列表了,你可以在这个菜单里点菜,比如第一个菜是通道2,第二个菜是通道5,等等等等,这里每个位置是通道几可以任意指定,并且也是可以重复的,然后初始化结构体里还会有个参数,就是通道数目。

如果这16个位置你可以不用完,只用前几个,那你就需要再给一个通道数目的参数,告诉它,我有几个通道。比如这里指定通道数目为7,那它就只看前7个位置,然后每次触发之后,它就依次对这前7个位置进行AD转换,转换结果都放在数据寄存器里,这里为了防止数据被覆盖,就需要用DMA及时将数据挪走。那7个通道转换完成之后,产生EOC信号,转换结束,然后再触发下一次,就又开始新一轮的转换,这就是单次转换,扫描模式的工作流程。

  • 连续转换,扫描模式

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

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

7.5 几个知识点补充

7.5.1 触发控制

这个表就是规则组的触发源,也就是ADC总框图中的左下角部分。

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

7.5.2 数据对齐

7.5.3 转换时间

这个大概讲一下,不过转换时间这个参数,我们一般不太敏感,因为一般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还短,不过这样稳定性就没法保证了。

7.5.4 校准

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

7.5.5 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这些电压的话,可以使用这个电压转换电路,但是如果你电压再高一些,就不建议使用这个电路了,那可能会比较危险。高电压采集最好使用一些专用的采集芯片,比如隔离放大器等等,做好高低电压的隔离,保证电路的安全。

7.6 ADC实验

根据7.4.1 基本结构框图打通整个ADC外设,具体步骤如下:

  1. 开启RCC时钟,包括ADC和GPIO的时钟,另外这里ADCCLK的分频器,也需要配置一下
  2. 配置GPIO,把需要用的GPIO配置成模拟输入的模式
  3. 配置多路开关,把左边的通道接入到右边的规则组列表里。这个过程就是我们之前说的点菜,把各个通道的菜,列在菜单里
  4. 配置ADC转换器,在库函数里,是用结构体来配置的,可以配置这一大块电路的参数。包括ADC是单次转换还是连续转换、扫描还是非扫描、有几个通道,触发源是什么,数据对齐是左对齐还是右对齐。
  5. 使能ADC
  6. 在开启ADC之后,根据手册里的建议还可以对ADC进行一下校准,这样可以减小误差;

如果想要使用软件触发ADC转换,也有专门的库函数可以触发;

如果读取转换结果,也有专门的库函数可以读取结果;

如果需要使用模拟看门狗,有几个专门的库函数用来配置阈值和监测通道;

如果想使用中断,那就在中断输出控制里用ITConfig函数开启对应的中断输出,然后再在NVIC里,配置一下优先级,这样就能触发中断了,比如:

1
2
3
4
/**
*@brief 用于控制某个中断,能不能通往NMIC
*/
void ADC_ITConfig(ADC_TypeDef* ADCx, uint16_t ADC_IT, FunctionalState NewState);

ADC_RegularChannelConfig()是STM32微控制器中用于配置ADC(模数转换器)‌规则通道‌的核心函数。其主要作用包括:① 指定ADC外设的通道编号(如ADC_Channel_0对应PA0引脚);② 设置通道在规则组中的转换顺序(Rank值1~16);③ 配置采样时间(以ADC时钟周期数为单位)

1
2
3
4
5
6
void ADC_RegularChannelConfig(
ADC_TypeDef* ADCx, // ADC外设(如ADC1、ADC2)
uint8_t ADC_Channel, // 通道号(0-17,具体取决于型号)
uint8_t Rank, // 规则组中的转换顺序(1-16)
uint8_t ADC_SampleTime // 采样时间(时钟周期数)
);

硬件连接对应关系,对于STM32F103系列:

  • ADC_Channel_0 → PA0引脚
  • ADC_Channel_1 → PA1引脚
  • ADC_Channel_15 → PC5引脚

举例:

1
2
3
4
// 通道0(PA0引脚)第1个转换
ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5);
// 通道5(PA5引脚)第2个转换
ADC_RegularChannelConfig(ADC1, ADC_Channel_5, 2, ADC_SampleTime_239Cycles5);

参考链接:对ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5);深入理解。_st32的adc的dma配置中选择channel0、channel1的区别是什么-CSDN博客

相关的库函数列表

7.6.1 AD单通道转换实验

测量旋转电位器的模拟电压:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
#include "stm32f10x.h"                  // Device header
#include "OLED.h"
#include "Delay.h"

uint16_t AD_Value;
float Voltage;

/**
*@breif 初始化ADC1
*/
void My_ADC_Init(void)
{
// 1. 开启ADC时钟和GPIO的时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);

// 2. 配置ADCCLK的分频器
RCC_ADCCLKConfig(RCC_PCLK2_Div6); //6分频使得ADC时钟为12MHz

// 3. 配置GPIO口的PA0为模拟输入
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_Init(GPIOB, &GPIO_InitStructure);

// 4. 配置多路开关, 18个通道中的通道0,序列1
ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_41Cycles5);

// 5. 初始化外设ADC
ADC_InitTypeDef ADC_InitStruct;
ADC_InitStruct.ADC_Mode = ADC_Mode_Independent; // 选择独立模式而不是双ADC模式
ADC_InitStruct.ADC_DataAlign = ADC_DataAlign_Right; // 数据右对齐
ADC_InitStruct.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; // 非外部触发,用软件触发

ADC_InitStruct.ADC_ContinuousConvMode = DISABLE; // 单次转换
ADC_InitStruct.ADC_ScanConvMode = DISABLE; // 非扫描
ADC_InitStruct.ADC_NbrOfChannel = 1;
ADC_Init(ADC1, &ADC_InitStruct);

/* 配置中断 */

/* 配置模拟看门狗 */

// 6. 使能ADC
ADC_Cmd(ADC1, ENABLE);

// 7. 校准ADC
ADC_ResetCalibration(ADC1); // 复位校准
while(ADC_GetResetCalibrationStatus(ADC1) == SET); // 等待复位校准完成
ADC_StartCalibration(ADC1); // 开始校准
while(ADC_GetCalibrationStatus(ADC1) == SET); // 等待校准完成
}


/**
*@breif 软件触发ADC转换,并读取转换数值
*/
uint16_t ADC1_GetValue(void)
{
// 软件触发转换
ADC_SoftwareStartConvCmd(ADC1, ENABLE);
// 等待转换完成
while(ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) == RESET);

// 读取转换结果
return ADC_GetConversionValue(ADC1);
}


int main()
{
OLED_Init();
My_ADC_Init();

uint16_t Value = ADC1_GetValue();

OLED_ShowString(1, 1, "AD = ");
OLED_ShowString(2, 1, "Voltage = ");
OLED_ShowString(2, 12, ".");
while(1)
{
AD_Value = ADC1_GetValue();
Voltage = 3.3 * AD_Value/4095.0;
OLED_ShowNum(1, 6, AD_Value, 5);
OLED_ShowNum(2, 11, Voltage, 1);
OLED_ShowNum(2, 13, (uint16_t)(Voltage * 100) % 100, 2);
Delay_ms(200);
}
}

7.6.2 AD多通道转换实验

需求:同时获取电位器、光敏电阻模块、热敏电阻模块、反射红外模块共四组数字量。

  • main.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include "stm32f10x.h"                  // Device header
#include "OLED.h"
#include "ADC_User.h"

int main(void)
{
int i = 0;
//OLED显示屏初始化
OLED_Init();
OLED_ShowString(1,1,"C0:00.00 V");
OLED_ShowString(2,1,"C1:00.00 V");
OLED_ShowString(3,1,"C2:00.00 V");
OLED_ShowString(4,1,"C3:00.00 V");

//ADC初始化
ADC_User_InitMuti();

while(1)
{
for (i=0; i<4; i++)
{
ADC_User_MutiSel(i);
ADC_User_Start();
OLED_ShowFloat(i+1,4,(float)ADC_User_Get()*3.3/4095,2,2);
}
};
}

  • ADC_User.h
1
2
3
4
5
6
7
8
#ifndef __ADC_USER_H
#define __ADC_USER_H

void ADC_User_Init(void);
void ADC_User_Start(void);
uint16_t ADC_User_Get(void);

#endif
  • ADC_User.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
//ADC多通道初始化-ADC1的通道0~3-PA0~PA3共四个通道
void ADC_User_InitMuti(void)
{
//1.开启外设时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_ADCCLKConfig(RCC_PCLK2_Div6);//6分频使得ADC时钟为12MHz
//2.配置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);
//3.配置多路开关,选择通道进入规则组
ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_1Cycles5);
//4.配置ADC转换器
ADC_InitTypeDef ADC_InitStructure;
ADC_InitStructure.ADC_ContinuousConvMode = DISABLE;//单次转换
ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;//数据右对齐
ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;//不使用外部触发(软件触发)
ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;//独立模式
ADC_InitStructure.ADC_NbrOfChannel = 1;//只有1个通道(非扫描模式,参数不起作用)
ADC_InitStructure.ADC_ScanConvMode = DISABLE;//非扫描模式(因为是单通道)
ADC_Init(ADC1, &ADC_InitStructure);
//5.配置开关控制
ADC_Cmd(ADC1, ENABLE);
//6.进行ADC校准
ADC_ResetCalibration(ADC1);
while(ADC_GetResetCalibrationStatus(ADC1)==SET);
ADC_StartCalibration(ADC1);
while(ADC_GetCalibrationStatus(ADC1)==SET);
}

//使用ADC的多路开关,选择哪个通道
//通道范围0~3
void ADC_User_MutiSel(uint16_t channelx)
{
switch(channelx)
{
case 0:
ADC_RegularChannelConfig(ADC1,ADC_Channel_0,1,ADC_SampleTime_1Cycles5); break;
case 1:
ADC_RegularChannelConfig(ADC1,ADC_Channel_1,1,ADC_SampleTime_1Cycles5); break;
case 2:
ADC_RegularChannelConfig(ADC1,ADC_Channel_2,1,ADC_SampleTime_1Cycles5); break;
case 3:
ADC_RegularChannelConfig(ADC1,ADC_Channel_3,1,ADC_SampleTime_1Cycles5); break;
default:
ADC_RegularChannelConfig(ADC1,ADC_Channel_0,1,ADC_SampleTime_1Cycles5); break;
}
}

//对ADC进行一次软件触发
void ADC_User_Start(void)
{
ADC_SoftwareStartConvCmd(ADC1, ENABLE);
}

//获取ADC转换结果
uint16_t ADC_User_Get(void)
{
//等待转换完成并读取
while(ADC_GetFlagStatus(ADC1,ADC_FLAG_EOC) == RESET);
return ADC_GetConversionValue(ADC1);//硬件会自动清除EOC标志位
}

如何实现多通道ADC。若使用扫描模式实现多通道ADC,需要考虑数据覆盖的问题。下面是几种实现ADC多通道的思路:

  1. 扫描模式+DMA转运数据:DMA是转运多通道数据的最优解,但下节才学DMA,本节用不了。
  2. 扫描模式+手动转运数据:存在两个问题,一个是ADC在最后一个通道转换完成后才会产生EOC标志位,此时,数据寄存器早就被覆盖成最后一个通道的数据了,所以无法确定某个通道的转运时刻;ADC转换速度非常快,对于手动转运数据的要求非常高。解决思路就是使用间断模式,可以使ADC每转换一个通道就暂停一次,等待下一次触发才进行下一个通道的转换。于是便可以:触发–>Delay一段足够长的时间–>手动转运完数据–>触发……不难发现,效率极低。
  3. 非扫描模式+“时分复用”【本节思路】:还是采用“单次转换、非扫描模式”的单路ADC,但是可以不断第更换通道–>触发ADC–>读取数据,以软件完成扫描模式,进而实现多路ADC“单次转换、扫描模式”的功能。

参考链接:江科大STM32学习笔记—ADC模数转换器_江科大32-CSDN博客

8 直接存储器存取DMA

8.1 DMA简介

  • DMA这个外设是可以直接访问STM32内部的存储器的,包括运行内存SRAM程序存储器FLASH寄存器等等,DMA都有权限访问他们,所以DMA才能完成数据转运的工作。
  • DMA可以提供外设和存储器或者存储器和存储器之间的高速数据传输,无需CPU干预,节省了CPU的资源,这里外设指的就是外设寄存器,一般是外设的数据寄存器器(Data Register, DR),比如外设电路ADC的数据寄存器,外设电路USART串口的数据寄存器等等。这里存储器指的就是运行内存SRAM和程序存储器Flash,是我们存储变量数组和程序代码的地方,在外设和存储器或者存储器和存储器之间进行数据转运,就可以使用DMA来完成,并且在转运的过程中无需CPU的参与,节省了CPU的资源,CPU省下时间就可以干一些其他的更加专业的事,情搬运数据这种杂活,交给DMA就行了。
  • 12个独立可配置的通道,这个通道就是数据转移的路径,从一个地方移动到另一个地方,就需要占用一个通道,如果有多个通道进行转运,那它们之间可以各转各的,互不干扰,这就是DMA的通道。
  • 每个通道都支持软件触发和特定的硬件触发
    • 软件触发
      • 如果执行的是存储器到存储器的转运,比如想把Flash里的一批数据转运到SRAM里去,那就需要软件触发,使用软件触发之后,DMA就会一股脑的把这批数据以最快的速度全部转运完成,这也是我们想要的效果哈。
    • 硬件触发
      • 那如果DMA进行的是外设到存储器的数据转运,就不能一股脑的转运了,因为外设的数据是有一定时机的,所以这时我们就需要用硬件触发,比如转运ADC的数据,那就得ADC每个通道AD转换完成后,硬件触发一次DMA,之后DMA再转运,触发一次,转运一次,这样数据才是正确的,才是我们想要的效果。
    • 所以存储器到存储器的数据转运,一般使用软件触发,外设到存储器的数据转运,一般使用硬件触发,那这里我写的是特定的硬件触发,意思就是每个DMA的通道,它的硬件触发源是不一样的,你要使用某个外设的硬件触发源,就得使用它连接的那个通道,而不能任意选择通道,这个后面会再分析。

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

8.2 STM32的存储器映像

我们来看一下STM32的存储器映象,既然DMA是在存储器之间进行数据转运的,那我们就应该要了解一下STM32中都有哪些存储器,这些存储器又是被安排到了哪些地址上,这就是存储器映象的内容,那我们知道计算机系统的五大组成部分是运算器、控制器、存储器、输入设备和输出设备,其中运算器和控制器一般会合在一起,叫做CPU,所以计算机的核心关键部分就是CPU和存储器。下图是STM32的总的存储器映像图示。

如上图所示,在STM32中,所有的存储器都被安排到了0x0000 0000 ~ 0xFFFF FFFF这个地址范围内,因为CPU是32位的,所以寻址范围就是32位的范围,32位的寻址范围是非常大的,最大可以支持4GB容量的存储器,而我们STM32的存储器都是KB级别的,所以这个4GB的寻址空间会有大量的地址都是空的,算一下地址的使用率还不到1%,在这个图里有灰色填充的就是Reserve的区域,也就是保留区域没有使用到。

然后这个零地址啊实际上也是没有存储器的,它这里写的是别名到Flash或者系统存储器取决于boot引脚(Aliased to Flash or…),因为程序是从零地址开始运行的,所以这里需要把我们想要执行的程序映射到零地址来,怎么选择,由boot0boot1两个硬件来决定,这就是零地址里的别名区

  • 如果映射在Flash区,就从Flash执行;
  • 如果映射在系统存储器区,就是从系统存储器运行bootloader;
  • 如果映射到SRAM,就是从SRAM启动

接着剩下的0800开始的Flash区,用于存储程序代码,1FFF开始的系统存储器和选项字节是在ROM区的,最后面存在什么东西,刚才也都介绍过,之后2000开始的是SRAM区,4000开始的是外设寄存器区,里面可以展开,就是右边这些东西,具体到每个外设又有各自的起始地址,比如TIM 2的地址是40000000,TIM3是40000400,然后外设里面又可以具体细分到每个寄存器的地址,寄存器里每个字节的地址,最终所有字节的地址就都可以算出来了,就上面这里E000开始的区域存放的就是内核里面的外设寄存器了,那到这里相信你对STM32 里面有哪些存储器,每种存储器都对应在哪个地址区间里就应该清楚了。

PS:关于存储器映像的介绍,建议参考尚硅谷的STM32课程。

stm32f103c8t6的存储器映像 – 尚硅谷 – 哔哩哔哩

我觉得这个视频讲的比较好,而且视频中画的存储器映像和普通的不太一样,比较清晰明了。

存储器又有两个重要知识点,一个是存储器的内容,另一个就是存储器的地址,那STM32也不例外,下面这个表就是STM32中所有类型的存储器和它们所被安排的地址,在STM32 的数据手册,这里也会有个存储器印象的图,如下所示,我这个表就是从这个图里总结出来的,都是一个意思。

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

存储器分类:ROM和RAM,介绍如下:

  • ROM(只读存储器)是一种非易失性、掉电不丢失的存储器,ROM分为了三块:
    • 第一块是程序存储器Fash,也就是主闪存,它的用途就是存储c语言编译后的程序代码,也就是我们下载程序的位置,运行程序一般也是从主闪存里面开始运行的,这一块存储器STM2 给它分配的地址是0x08000000,起始地址,也就是第一个字节的地址是0800这个哈,然后剩余字节的地址依次增长,每个字节都分配一个独一无二的地址,就像给每个住户编门牌号一样,只有分配了独一无二的门牌号程序才能精准地访问这个存储器,最终终止地址是多少呢,这取决于它的容量,编到哪里,哪里就是终止地址,这就是主闪存的地址范围,你之后如果在软件里看到某个数据的地址是0800开头的,那你就可以确定它是属于主闪存的数据。
    • 第二块和第三块是系统存储器和选项字节,这两块存储器也是ROM的一种,掉电不丢失,实际上他们的存储介质也是Flash,只不过我们一般说Flash指的是主闪存Flash,而不止这两块区域,那看一下地址,他们的地址都是1FFF开头的,紧跟着2000地址开头的就是RAM区的,所以可以看出这两块存储器的位置是在RAM区的最后面,它们的用途看下右边说明,系统存储器的用途是存储bootloader,用于串口下载,这个下一节讲串口的时候再给大家演示,那个bootloader程序存储的位置就被分配到了这里,bootloader程序是芯片出厂自动写入的哈,一般也不允许我们修改,之后选项字节,主要是保存一些配置,它的位置是在ROM区的最后面,你下载程序可以不刷新选项字节的内容,这样选项字节的配置就可以保持不变,选项字节里存的主要是Flash的读保护写保护,还有看门狗等等的配置,这个如果需要的话,可以了解一下。

PS:临时补充

选项字节在后面的9-6有介绍,比如在FlyMcu里设置读保护,如果你做产品不开启读保护,别人很容易就把你的程序偷走了。

那选项字节里面的参数有什么好处呢?

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

  • RAM(随机存储器)是一种易失性、掉电丢失的存储器
    • 首先,运行内存SRAM,分配的地址是0x20000000 ,用途是存储运行过程中的临时变量,也就是我们在程序中定义变量数组结构体的地方,你可以试一下,定义一个变量,再取他的地址显示出来,那这个地址肯定就是2000开头的,类比于电脑的话运行内存就是内存条
    • 其次,RAM区剩下的还有外设寄存器,它的地址是0x40000000这块区域,用途是存储各个外设的配置参数,也就是我们初始化各个外设最终所读写的东西,刚才我们说了,外设寄存器也是存储器的一种,它的存储介质其实也是SRAM,只不过我们一般习惯把运行内存叫SRAM,外设 就直接叫寄存器了。
    • 最后,内核外设寄存器,地址是0xE0000000这片区域,用途是存储内核各个外设的配置参数,内核外设就是NVIC和SysTick,因为内核外设和其他外设不是一个厂家设计的,所以他们的地址也是被分开的,内核外设是E000,其他外设是4000,那以上这些就是STM32里的存储器和他们被安排的地址。

8.3 DMA基本结构

8.3.1 DMA的结构框图

对于上面这个DMA框图,左上角这里是Cortex-M3内核,里面包含了CPU和内核外设(NVIC和SysTick)等等,剩下的这所有东西,你都可以把它看成是存储器,所以总共就是CPU和存储器两个东西。Flash是主闪存,SRAM是运行内存,各个外设都可以看成是寄存器,也是一种SRAM存储器。

寄存器是一种特殊的存储器,一方面,CPU可以对寄存器进行读写,就像读写运行内存一样,另一方面,寄存器的每一位背后,都连接了一根导线,这些导线可以用于控制外设电路的状态,比如置引脚的高低电平、导通和断开开关、切换数据选择器,或者多位组合起来,当做计数器、数据寄存器等等。所以,寄存器是连接软件和硬件的桥梁,软件读写寄存器,就相当于在控制硬件的执行。

既然外设就是寄存器,寄存器就是存储器,那使用DMA进行数据转运,就都可以归为一类问题了。就是从某个地址取内容,再放到另一个地址去。

  • 总线介绍
    • 为了高效有条理地访问存储器,这里设计了一个总线矩阵总线矩阵的左端是主动单元,也就是拥有存储器的访问权右边这些是被动单元,它们的存储器只能被左边的主动单元读写
    • 主动单元这里,内核有DCode总线和系统总线,可以访问右边的存储器,其中DCode总线是专门访问Flash的系统总线是访问其他东西的(视频没有明指,只是在外设寄存器比划了一下,可能系统总线访问的是外设?)。
    • DMA总线:由于DMA要转运数据,所以DMA也必须要有访问的主动权。那主动单元,除了内核CPU,剩下的就是DMA总线了。这里DMA1有一条DMA总线,DMA2也有一条DMA总线,下面这还有一条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请求就是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是运行内存,可以任意读写,没有问题。

外设寄存器的话,得看参考手册里面的描述,有的寄存器是只读的,有的寄存器是只写的,不过我们主要用的是数据寄存器,数据寄存器都是可以正常读写的。

8.3.2 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开启时写传输计数器,这是手册里的规定

8.3.3 几个小知识点细节

(一) DMA请求

这张图是DMA1的请求映像,下面是DMA1的7个通道,每个通道都有一个数据选择器,可以选择硬件触发或软件触发。

PS:这里画的图我觉得可能不太好理解,你看他把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 ,也就是把多出来的高位舍弃掉,之后的各种情况也都是类似的操作。

💖总之,这个表的意思就是:

  1. 如果你把小的数据转到大的里面去,高位就会补0;
  2. 如果把大的数据转到小的里面去,高位就会舍弃掉;
  3. 如果数据宽度一样,那就没事。

其实就是跟unit8_t、unit16_t和unit32_t变量之间相互赋值一样,不够就补0,超了就舍弃高位,这是一个道理。

8.4 DMA实验

  • 先来验证存储器映像的内容

注意:aa这个变量取地址之后,应该要存在一个指针变量里,这里直接想当做一个数字显示,那么还得在前面加上强制类型转换,(uint32_t),如果不加强制类型转换,就是指针跨级赋值了,编译的时候会给你报个警告

看一下我们定义的数据,它到底是不是真的存在了这相应的地址区间里,下载看一下,aa这个变量它被存储的地址是20000000,地址是20开头的,对照前面存储器映像里的表就知道了,aa这个变量存储的位置是SRAM区,在SRAM区它的地址肯定是20开头的,那它的具体地址是多少呢,这个是由编译器来确定的,目前SRAM区还没什么东西,所以编译器就把这个变量放在了SRAM区的第一个位置,也就是20000000

我们可以在这个变量前面加一个const关键字,表示的是常量的意思,被const的修饰的变量在程序中只能读不能写,那我们上一小节说了,Flash里面的数据也是只能读不能写的,所以const和Flash就联系起来了,在STM32 中,使用const定义的变量是存储在Flash里面的,当然这里就不应该说是变量了,而应该说是常量,因为它是不能变的,这个变量的值只能在定义的时候给,如果你在程序这里尝试再次给它赋值,那就会报错啊,错误意思就是不能给const的常量赋值,那我们下载看一下,这里就可以看到aa这个变量的地址就变成08000DF8开头的了,在ppt的表格里可以知道现在aa是被存储在了Flash里(由此可知,在Flash里至少存储着程序代码和常量数据)。

那这里的地址尾部有些偏移哈,不像SRAM里那样直接安排在第一个位置,这是因为Flash里还有程序代码这些东西放在了前面,所以编译器给这个常量安排的地址就相对靠后了一些,这就是定义变量和常量的方法。

正常情况下我们使用的都是变量哈,直接定义就行,不需要加const的,那什么时候需要定义常量呢,这个是当我们程序中出现了一大批数据,并且不需要更改时,就可以把它定义成常量,这样能节省SRAM的空间。比如查找表,字库数据等等,我们可以打开这个oled_font.h文件,这里面就是oled显示英文的字库,这是一个数组哈,它里面的数据决定了每个字符应该显示哪些像素点,这个数组非常的长啊,而且是不需要更改的,所以在这里就可以加一个const,把它定义在Flash里面,这样就可以节省SRAM的空间,这里如果你一不小心把这个const去掉了,那程序功能并不会有任何影响,但是SRAM里会有和这个数组一样大的空间被浪费掉了,如果数值很小,那影响也不大,如果数组很大,那就得考虑一下SRAM是不是消耗得起来,这就是const的关键字的用途。

  • 再研究一下外设寄存器的地址

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

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

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

8.4.1 存储器间数据转运+DMA

这个例子的任务是将SRAM里的数组DataA,转运到SRAM的另一个数组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的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
#include "stm32f10x.h"                  // Device header
#include "OLED.h"
#include "Delay.h"

// 源数组
uint8_t DataA[] = {0x01, 0x02, 0x03, 0x04};
// 目标地址数组
uint8_t DataB[4]; // 全局变量编译器默认赋值0


void My_DMA_Init(uint32_t adda, uint32_t addb, uint16_t size)
{
// 开启时钟
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);

// 初始化DMA
DMA_InitTypeDef DMA_InitStruct;
// 外设站点配置
DMA_InitStruct.DMA_PeripheralBaseAddr = adda;
DMA_InitStruct.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;
DMA_InitStruct.DMA_PeripheralInc = DMA_PeripheralInc_Enable;
// 存储器站点配置
DMA_InitStruct.DMA_MemoryBaseAddr = addb;
DMA_InitStruct.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte;
DMA_InitStruct.DMA_MemoryInc = DMA_MemoryInc_Enable;
// 缓存区大小,其实就是传输计数器
DMA_InitStruct.DMA_BufferSize = size;
// 外设站点作为源头,外设站点到存储器站点的传输方向
DMA_InitStruct.DMA_DIR = DMA_DIR_PeripheralSRC;
// 选择是否是存储器到存储器,其实就是选择硬件触发还是软件触发
DMA_InitStruct.DMA_M2M = DMA_M2M_Enable;
// 是否使用自动重装
DMA_InitStruct.DMA_Mode = DMA_Mode_Normal;
// 通道的优先级,紧急的转运需要更高的优先级
DMA_InitStruct.DMA_Priority = DMA_Priority_Medium;
DMA_Init(DMA1_Channel1, &DMA_InitStruct);


// 使能DMA1的通道1
// DMA_Cmd(DMA1_Channel1, ENABLE);
}

void My_DMA_Transfor(uint16_t size)
{
// 重新给传输计数器赋值前需要先失能DMA1的通道1
DMA_Cmd(DMA1_Channel1, DISABLE);
// 重新给传输计数器赋值
DMA_SetCurrDataCounter(DMA1_Channel1, size);
// 重新使能DMA1的通道1
DMA_Cmd(DMA1_Channel1, ENABLE);

// 等待转运完成
while(DMA_GetFlagStatus(DMA1_FLAG_TC1) == RESET);
// 注意:转运完成后要手动清除标志位
DMA_ClearFlag(DMA1_FLAG_TC1);
}


int main()
{
OLED_Init();
My_DMA_Init((uint32_t)DataA, (uint32_t)DataB, 4);


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);
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);
OLED_ShowHexNum(4, 4, DataB[1], 2);
OLED_ShowHexNum(4, 7, DataB[2], 2);
OLED_ShowHexNum(4, 10, DataB[3], 2);

Delay_ms(1000);

// 启动DMA转运
My_DMA_Transfor(4);

OLED_ShowHexNum(2, 1, DataA[0], 2);
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);
OLED_ShowHexNum(4, 4, DataB[1], 2);
OLED_ShowHexNum(4, 7, DataB[2], 2);
OLED_ShowHexNum(4, 10, DataB[3], 2);

Delay_ms(1000);

}
}

8.4.2 ADC扫描模式+DMA

感觉我下面写的程序有点问题,建议还是先参考下面的链接查看程序的编写,暂时不要看下面展示的程序了:

STM32江科大————DMA数据转运(实现AD多通道)_江科大32-CSDN博客

左边是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请求肯定是有的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
#include "stm32f10x.h"                  // Device header
#include "OLED.h"
#include "Delay.h"

uint16_t AD_Value[4];

/**
*@breif 初始化ADC1
*/
void My_ADC_Init(void)
{
// 1. 开启ADC时钟和GPIO的时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);

// 2. 配置ADCCLK的分频器
RCC_ADCCLKConfig(RCC_PCLK2_Div6);

// 3. 配置GPIO口的PA0、PA1、PA2、PA3为模拟输入
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3;
GPIO_Init(GPIOB, &GPIO_InitStructure);

// 4. 配置多路开关
// 18个通道中的通道0,序列1(即外设ADC1的通道0 PA0引脚第1个转换)
ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_41Cycles5);
// 18个通道中的通道1,序列2(即外设ADC1的通道1 PA1第2个转换)
ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_41Cycles5);
// 18个通道中的通道2,序列3(即外设ADC1的通道2 PA2第3个转换)
ADC_RegularChannelConfig(ADC1, ADC_Channel_2, 3, ADC_SampleTime_41Cycles5);
// 18个通道中的通道3,序列4(即外设ADC1的通道3 PA3第4个转换)
ADC_RegularChannelConfig(ADC1, ADC_Channel_3, 4, ADC_SampleTime_41Cycles5);

// 5. 初始化外设ADC
ADC_InitTypeDef ADC_InitStruct;
ADC_InitStruct.ADC_Mode = ADC_Mode_Independent; // 选择独立模式而不是双ADC模式
ADC_InitStruct.ADC_DataAlign = ADC_DataAlign_Right; // 数据右对齐
ADC_InitStruct.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; // 不使用外部触发,使用软件触发

ADC_InitStruct.ADC_ContinuousConvMode = DISABLE; // 单次转换
ADC_InitStruct.ADC_ScanConvMode = ENABLE; // 扫描
ADC_InitStruct.ADC_NbrOfChannel = 4;
ADC_Init(ADC1, &ADC_InitStruct);

/* 配置中断 */

/* 配置模拟看门狗 */

// 开启ADC的DMA硬件触发信号
ADC_DMACmd(ADC1, ENABLE);

// 6. 使能ADC
ADC_Cmd(ADC1, ENABLE);

// 7. 校准
ADC_ResetCalibration(ADC1); // 复位校准
while(ADC_GetResetCalibrationStatus(ADC1) == SET); // 等待复位校准完成
ADC_StartCalibration(ADC1); // 开始校准
while(ADC_GetCalibrationStatus(ADC1) == SET); // 等待校准完成
}

void My_DMA_Init(uint32_t adda, uint32_t addb, uint16_t size)
{
// 开启时钟
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);

// 初始化DMA
DMA_InitTypeDef DMA_InitStruct;
// 外设站点配置
DMA_InitStruct.DMA_PeripheralBaseAddr = adda;
DMA_InitStruct.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;
DMA_InitStruct.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
// 存储器站点配置
DMA_InitStruct.DMA_MemoryBaseAddr = addb;
DMA_InitStruct.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;
DMA_InitStruct.DMA_MemoryInc = DMA_MemoryInc_Enable;
// 缓存区大小,其实就是传输计数器
DMA_InitStruct.DMA_BufferSize = size;
// 外设站点作为源头,外设站点到存储器站点的传输方向
DMA_InitStruct.DMA_DIR = DMA_DIR_PeripheralSRC;
// 选择是否是存储器到存储器,其实就是选择硬件触发还是软件触发
DMA_InitStruct.DMA_M2M = DMA_M2M_Disable;
// 是否使用自动重装
DMA_InitStruct.DMA_Mode = DMA_Mode_Normal;
// 通道的优先级,紧急的转运需要更高的优先级
DMA_InitStruct.DMA_Priority = DMA_Priority_Medium;
DMA_Init(DMA1_Channel1, &DMA_InitStruct);


// 使能DMA1的通道1
DMA_Cmd(DMA1_Channel1, ENABLE);
}


int main(void)
{
/*模块初始化*/
OLED_Init(); //OLED初始化
AD_Init(); //AD初始化
My_DMA_Init(); //DMA初始化

/*显示静态字符串*/
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配合使用的流程。一般来说,DMA最常见的用途就是配合ADC的扫描模式,因为ADC扫描模式有个数据覆盖的特征,这个缺陷使ADC和DMA成为了最常见的伙伴,ADC对DMA的需求是非常强烈的,像其他的一些外设,使用DMA可以提高效率,是锦上添花的操作,但是不使用也是可以的,顶多是损失一些性能,但是这个ADC的扫描模式,如果不使用DMA,功能都会受到很大的限制,所以ADC和DMA的结合最为常见。

9 USART串口通信

通信的目的:将一个设备的数据传送到另一个设备,扩展硬件系统。

通信协议:制定通信的规则,通信双方按照协议规则进行数据收发。

STM32C8T6的通信接口如下表所示:

名称 引脚 双工 时钟 电平 设备
USART TX、RX 全双工 异步 单端 点对点
I2C SCL、SDA 半双工 同步 单端 多设备
SPI SCLK、MOSI、MISO、CS 全双工 同步 单端 多设备
CAN CAN_H、CAN_L 半双工 异步 差分 多设备
USB DP、DM 半双工 异步 差分 点对点

补充:I2C和SPI由于具有独立的时钟线,因此它们是同步的。在时钟信号的指引下,接收方可以采样数据。

然而,串口、CAN和USB没有时钟线,因此需要双方约定一个采样频率,这就是异步通信。为了对齐采样位置,还需要添加一些帧头和帧尾等标识。

总结一句话:同步靠时钟线,异步靠比特率

  • 单端、差分的区别
    • 单端:引脚的高低电平都是对GND的电压差,所以单端信号通信的双方必须要共地,就是把GND接在一起,不接GND是没法通信的。
    • 差分:靠两个差分引脚的电压差来传输信号的,在通信的时候,可以不需要GND。使用差分信号可以极大地提高抗干扰特性,所以差分信号一般传输速度和距离都会非常高。

9.1 串口通信

9.1.1 串口的基本使用

在我们这个单片机的领域,串口其实是一种最简单的通信接口,它的协议相比较I2C、SPI等,已经是非常简单的了。而且一般单片机都会有对应串口的硬件外设电路(也就是设计了硬件电路直接进行通信,否则就要使用GPIO软件模拟通信协议)。一般串口都是点对点的通信,所以是两个设备之间的互相通信。

其中单片机和电脑通信,是串口的一大优势,可以接电脑屏幕,非常适合调试程序,打印信息,像12C和SPI这些,一般都是芯片之间的通信,不会接在电脑上。

9.1.2 串口的电路接线

9.1.3 串口常用的电平标准

电平标准是数据1和数据0的表达方式,是传输线缆中人为规定的电压与数据的对应关系,串口常用的电平标准有如下三种:

  1. TTL电平:+3.3V或+5V表示1,0V表示0
  2. RS232电平:-3 ~ -15V表示1,+3 ~ +15V表示0
    • RS232电平一般在大型的机器上使用啊,由于环境可能比较恶劣,静电干扰比较大,所以这里电平的电压都比较大,而且允许波动的范围也很大。
  3. RS485电平:两线压差+2 ~ +6V表示1,-2 ~ -6V表示0(差分信号)
    • RS485的电平是差分信号,差分信号抗干扰能力非常强悍,使用RS485电平标准通信距离可以达到上千米,而上面这两种电平最远只能达到几十米,像单片机这种低压小型设备使用的都是TTL电平。

9.1.4 串口协议的软件部分

串口中,每一个字节都装载在一个数据帧里面,每个数据帧都由起始位数据位停止位组成。 上图中左侧显示数据位有8个,代表一个字节的8位;右侧显示可以在数据位的最后加一个奇偶校验位,这样数据位总共就是9位。

(一) 波特率

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

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

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

(二) 通讯的起始和停止信号

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

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

停止位的长度可以修改的,可以选择1位、1.5位、2位等

(三) 数据位

这里数据位表示数据帧的有效载荷,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位,像我这上面的描述,就是把数据位和校验位分开描述了,在串口助手里也是分开描述,总之,无论是合在一起,还是分开描述,描述的都是同一个东西,这个应该也好理解。

9.1.5 串口时序波形示例

这些是示波器实测的串口通信波形,操作方法是把探头的GND接在负极,探头接在发送设备的TX引脚,然后发送数据就能捕捉到这些波形了。

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

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

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

9.2 STM32的USART外设

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

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

  • 自带波特率发生器,最高达4.5Mbits/s
    • 这个波特率发生器,就是用来配置波特率的,它其实就是一个分频器,比如我们APB2总线给个72MHZ频率,然后波特率发生器进行一个分频,得到我们想要的波特率时钟,最后在这个时钟下进行收发,就是我们指定的通信波特率。
    • 串行通信一般是以帧格式传输数据,即是一帧一帧的传输,每帧包含有起始信号、数据信息、停止信息, 可能还有校验信息。USART就是对这些传输参数有具体规定,当然也不是只有唯一一个参数值,很多参数值都可以自定义设置,只是增强它的兼容性。
  • 可配置数据位长度(8/9)、停止位长度(0.5/1/1.5/2)
  • 可选校验位(无校验/奇校验/偶校验)
  • 支持同步模式、硬件流控制、DMA、智能卡、IrDA、LIN
    • 这个同步模式,就是多了个时钟CLK的输出;
    • 硬件流控制,比如A设备的TX脚向B设备的RX脚发送数据,A设备一直在发,发的太快了,B处理不过来,如果没有硬件流控制,那B就只能抛弃新数据或者覆盖原数据了。如果有硬件流控制,在硬件电路上,会多出一根线,如果B没准备好接收,就置高电平,如果准备好了,就置低电平。A接收到了B反馈的准备信号,就只会在B准备好的时候,才发数据,如果B没准备好,那数据就不会发送出去。这就是硬件流控制,可以防止因为B处理慢而导致数据丢失的问题;
    • DMA,是这个串口支持DMA进行数据转运,可以使用DMA转运数据,减轻CPU的负担;
    • 智能卡、IrDA、LIN,这些是其他的一些协议。因为这些协议和串口是非常的像,所以STM32就对USART加了一些小改动,就能兼容这么多协议了,不过我们一般不用,像这些协议,Up主也都没用过。

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

9.3 USART框图

9.3.1 输入引脚部分

  1. TX: 发送数据输出引脚。
  2. RX: 接收数据输入引脚。
  3. SW_RX: 数据接收引脚,只用于单线和智能卡模式,属于内部引脚,没有具体外部引脚。
  4. SCLK: 发送器时钟输出引脚。这个引脚仅适用于同步模式。

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

9.3.2 数据寄存器

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

9.3.3 移位寄存器

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

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

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

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

9.3.4 硬件流控制

上面提到过,如果发送设备发的太快,接收设备来不及处理,就会出现丢弃或覆盖数据的现象。那有了流控,就可以避免这个问题了。这里流控有两个引脚,一个是nRTS,另一个是nCTS

  • nRTS: 请求以发送(Request To Send),n表示低电平有效,是输出脚,如果使能RTS流控制,当USART接收器准备好接收新数据时就会将nRTS变成低电平; 当接收寄存器已满时,nRTS将被设置为高电平。该引脚只适用于硬件流控制。
    • 也就是告诉别人,我当前能不能接收。
  • nCTS: 清除以发送(Clear To Send),n表示低电平有效,是输入脚。如果使能CTS流控制,发送器在发送下一帧数据之前会检测nCTS引脚, 如果为低电平,表示可以发送数据,如果为高电平则在发送完当前数据帧之后停止发送。该引脚只适用于硬件流控制。
    • 也就是用于接收别人nRTS的信号的。

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

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

9.3.5 SCLK控制

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

那这个时钟信号有什么用呢?

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

9.3.6 唤醒单元

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

9.3.7 中断输出控制

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

9.3.8 波特率发生器

波特率发生器其实就是分频器用于产生约定的通信速率。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,就是接收器使能了,接收部分的波特率就有效。

9.3.9 部分寄存器的指示

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

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

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

9.4 USART基本结构

9.4.1 USART抽象结构

9.4.2 数据帧设置

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

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

9.4.3 配置停止位

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

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

9.4.4 起始位侦测

下面两个图展示的是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次进行采样这样就能保证采样位置在位的正中间了。这就是起始位侦测和采样位置对齐的策略。

9.4.5 数据采样流程

这里,从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对这个电路的设计考虑还是很充分的。

9.4.6 波特率发生器

波特率发生器就是分频器,发射器和接收器的波特率,由波特率寄存器BRR里面的DIV确定,下面这个图就是BRR寄存器,里面就是分频系数DIV,`DIV分为整数部分和小数部分,可以实现更细腻的分频,那波特率和分频系数的关系可以由下面的计算公式进行计算:

f的下标PCLK2/1是表示PCLK2或者PCLK1(也即对应APB2APB1时钟),为什么这里多个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寄存器的方法,了解一下。

不过,用ST公司库函数配置的话,就非常方便,需要多少波特率,直接写就行了,库函数会自动帮我们算。

9.5 手册讲解

9.5.1 USB转串口模块的内部电路图

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

9.5.2 STM32的UASRT手册部分

9.6 USART实验

9.6.1 串口发送

根据 9.4.1 USART的抽象结构配置USART外设,具体步骤如下:

  1. 开启时钟,把需要用的USART和GPIO的时钟打开
  2. GPIO初始化,把TX配置成复用输出,RX配置成输入
  3. 配置USART,直接使用一个结构体,就可以把这里所有的参数都配置好了
  4. 如果你只需要发送的功能,就直接开启USART,初始化就结束了。如果你需要接收的功能,可能还需要配置中断,那就在开启USART之前,再加上ITConfig和NVIC的代码就行了。

中断方式接收串口数据请参考B站UP主“铁头山羊”:

铁头山羊stm32入门教程 4.4. 中断方式的数据接收(上)

得益于库函数的封装,内部各种细节问题就不需要我们再关心了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
#include "stm32f10x.h"                  // Device header
#include "OLED.h"
#include <stdio.h>
#include <stdarg.h>

uint8_t RxData;

/**
*@brief 初始化USART1,用于发送
*/
void USART1_Init(void)
{
// 开启时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);

// 初始化GPIOA对应的USART1的引脚PA9、PA10
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP; // 复用推挽输出
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_9;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStruct);
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_10;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStruct);

// 初始化USART1
USART_InitTypeDef USART_InitStruct;
USART_InitStruct.USART_BaudRate = 9600;
USART_InitStruct.USART_HardwareFlowControl = USART_HardwareFlowControl_None; // 不使用硬件流控
USART_InitStruct.USART_Mode = USART_Mode_Tx | USART_Mode_Rx; // 选择发送或者接收模式,两者都用,使用或符号连接
USART_InitStruct.USART_Parity = USART_Parity_No; // 选择校验模式(这里无校验)
USART_InitStruct.USART_StopBits = USART_StopBits_1; // 选择停止位长度
USART_InitStruct.USART_WordLength = USART_WordLength_8b; // 选择字长为8位
USART_Init(USART1, &USART_InitStruct);

// 可以使用中断的方式接收数据,开启RXNE标志位到NVIC的输出
USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);

// 配置NVIC
// 将中断优先级分为2位抢占优先级和2位子优先级(共4位优先级位),允许4级抢占优先级和4级子优先级。
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
// 创建NVIC初始化结构体,用于配置中断参数
NVIC_InitTypeDef NVIC_InitStruct;
// 设置要配置的中断为USART1串口中断(当串口发生接收/发送等事件时触发)
NVIC_InitStruct.NVIC_IRQChannel = USART1_IRQn;
NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStruct.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&NVIC_InitStruct);

// 使能USART1
USART_Cmd(USART1, ENABLE);
}


/**
*@brief 发送一个字节的数据
*/
void USART1_Send_Byte(uint8_t Byte)
{
// 发送数据
USART_SendData(USART1, Byte);
// 等待数据发送完成
while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
}


/**
*@brief 发送一个数组的数据
*/
void USART1_Send_Array(uint8_t* Array, uint16_t len)
{
uint16_t i = 0;
for(i=0; i<len; i++)
USART1_Send_Byte(Array[i]);

}

/**
*@brief 发送一个字符串
*/
void USART1_Send_String(char* str)
{
uint16_t i = 0;
for(i=0; str[i]!='\0'; i++)
USART1_Send_Byte(str[i]);

}

/**
*@brief 发送一个数字
*/
/*
void USART1_Send_NUM(uint32_t num, uint8_t )
{
uint16_t i = 0;
for(i=0; str[i]!='\0'; i++)
USART1_Send_Byte(str[i]);

}
*/

/**
*@brief 重写fputc()函数
*/

int fputc(int ch, FILE* f)
{
USART1_Send_Byte(ch);
return ch;
}

/**
*@brief 封装可变参数
*/
void Serial_printf(char* format, ...)
{
char string[200];
va_list arg;
va_start(arg, format);
vsprintf(string, format, arg);
va_end(arg);
USART1_Send_String(string);
}

int main()
{
OLED_Init();
USART1_Init();
USART1_Send_Byte(0x41);

/*
printf("Num=%d\n", 666);
*/
char string[100];
sprintf(string, "Num=%d\n", 666);
USART1_Send_String(string);

while(1)
{
// 接收数据方式1:循环查询
if(USART_GetFlagStatus(USART1, USART_FLAG_RXNE) == SET)
{
RxData = USART_ReceiveData(USART1);
OLED_ShowHexNum(1, 1, RxData, 2);

}
// 接收数据方式2:中断接收,下面的USART1_IRQHandler()函数

}
}

/**
*@brief USART1中断函数
*@note 当USART1接收到数据时自动触发中断,避免CPU轮询查询,提升效率
*/
void USART1_IRQHandler(void)
{
if(USART_GetFlagStatus(USART1, USART_FLAG_RXNE) == SET)
{
RxData = USART_ReceiveData(USART1);
USART_ClearITPendingBit(USART1, USART_FLAG_RXNE);
}

}

9.6.2 USART串口数据包收发

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

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

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

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

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

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

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

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

比如你要做个菜单,就可以使用状态机的思维,按什么键切换什么菜单,执行什么样的程序。

那接下来继续,我们来看一下这个可变包长、文本数据包的接收流程。

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

10 FlyMcu串口下载&STLINK Utility

江科大笔记—FlyMcu串口下载&STLINK Utility - CSDN

11 I2C通信

11.1 I2C通信的基本介绍

11.1.1 初识I2C通信

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

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

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

11.1.2 I2C的硬件电路结构

上图就是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。引脚的信号输入都可以通过一个数据缓冲器或施密特触发器进行输入,因为输入对电路无影响,所以任何设备在任何时刻都可以输入。然而,在输出部分,采用的是开漏输出的配置。

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

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

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

这样做的好处是:

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

好,以上就是I2C的硬件电路设计,那接下来,我们就要来学习软件,也就是时序的设计了。

11.1.3 I2C的软件时序协议

(一)起始和终止时序

  • 起始时序
    • 起始条件是:时钟线SCL高电平期间,数据线SDA从高电平切换到低电平
    • I2C总线处于空闲状态时,SCL和SDA都处于高电平状态,也就是没有任何一个设备去碰SCL和SDA,由外挂的上拉电阻拉高至高电平,总线处于平静的高电平状态。当主机需要数据收发时打破平静,会首先产生一个起始条件。这个起始条件是,SCL保持高电平,然后把SDA拉低,产生一个下降沿。当从机捕获到这个SCL高电平,SDA下降沿信号时,就会进行自身的复位,等待主机的召唤。之后,主机需要将SCL拉低。这样做一方面是占用这个总线,另一方面也是为了方便这些基本单元的拼接。这样,除了起始和终止条件,每个时序单元的SCL都是以低电平开始,低电平结束。
  • 终止时序
    • 终止条件是:时钟线SCL高电平期间,数据线SDA从低电平切换到高电平
    • 准备结束I2C通信时,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电平都暂停变化,传输也完全暂停,等中段结束后,主机回来继续操作,传输仍然不会出问题,这就是同步时序的好处。

(三)接收字节时序

主机在接收之前需要释放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的几个时序:
1️⃣ 起始时序
    时钟线SCL高电平,数据线SDA产生下升沿,最后拉低SCL
2️⃣ 终止时序
    时钟线SCL高电平,数据线SDA产生上升沿
3️⃣ 主机发送一个字节
    时钟线SCL低电平,主机改变数据线SDA的电平,时钟线SCL高电平,从机从数据线SDA读取电平
4️⃣ 主机接收一个字节
    时钟线SCL低电平,从机改变数据线SDA的电平,时钟线SCL高电平,主机从数据线SDA读取电平
    接收之前,主机需要释放SDA(释放方法:???给GPIO端口写高电平)
🔼 小结
    时钟线SCL低电平,放数据,时钟线SCL高电平,读数据

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

(五)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设备的从机地址。

下面通过具体的例子来看一下

  • 指定地址写

  • 当前地址读

可以看到,当前地址I2C读的时序和I2C写的时序差不多,但是缺少一个指定寄存器读的时序,那如何解决这个问题呢?具体分析如下:

我们看到在读的时序中,I2C协议的规定是,主机进行寻址时,一旦读写标志位给1了,下一个字节就要立马转为读的时序,所以主机还来不及指定,我想要读哪个寄存器就得开始接收了,所以这里就没有指定地址这个环节,那主机并没有指定寄存器的地址,从机到底该发哪个寄存器的数据呢,这需要用到我们上面说的当前地址指针了,在从机中,所有的寄存器被分配到了一个线性区域中,并且会有个单独的指针变量,指示着其中一个寄存器,这个指针上电默认一般指向0地址,并且每写入一个字节和读出一个字节后,这个指针就会自动自增一次,移动到下一个位置,主机没有指定要读哪个地址,从机就会返回当前指针指向的寄存器的值,那假设我刚刚调用了这个指定地址写的时序,在0x19的位置写出了0xAA,那么指针就会加1移动到0x1a的位置,我再调用这个当前地址读的时序,返回的就是0x1a地址下的值,如果再调用一次,返回的就是0x1b地址下的值,以此类推,这就是当前地址读时序的操作逻辑。

由于当前地址读并不能指定读的地址,所以当前地址读这个时序用的不是很多

  • 指定地址读

提前简单一下说,指定地址读就是“指定地址写”的前一半 + “当前地址读”的全部。在说的直白一点就是利用了指定地址写的时序能修改当前地址指针这个性质。

指定地址读,这个时序的目的就是,对于指定设备在指定地址下读取从机数据,那这个时序为什么能指定读的地址呢,下面我们先回看一下指定地址写

简单来说就是:我们去看指定地址写的时序,该时序的前面两个白色填充框就是指定地址+指定寄存器的时序,我们把最后面的写数据的这一部分给去掉,然后把前面这一段设置地址,还没有指定写什么数据的时序,给他追加到这个当前地址读时序的前面,就得到了指定地址读的时序,一般我们也把它称作复合格式。下面的时序在这里分隔一下,前面的部分是指定地址写,但是只指定了地址,还没来得及写,后面的部分是当前地址读,因为我们刚指定的地址,两者加在一起就是指定地址读了,所以指定地址读的时序会复杂一些,我们来详细分析一下:

  1. 首先,最开始仍然是启动条件;
  2. 然后,发送一个字节进行寻址,比如上图这里指定从机地址是1001000,读写标志位是0,代表我要进行写的操作,经过从机应答之后,再发送第二个字节0x19用来指定地址,这个地址数据就写入到了从机的当前地址指针里了,也就是说从机接收到这个数据之后,它的寄存器指针就指向了0x19 这个位置。
  3. 之后,要写入的数据,不给他发,而是直接再来个起始条件,上图中这个Sr的意思就是重复起始条件,相当于另起一个时序,因为指定读写标志位,只能是跟着起始条件的第一个字节,所以如果想切换读写方向,只能再来个起始条件,然后起始条件后重新寻址并且指定读写标志位,此时读写标志位是1代表我要开始读了,接着主机接收一个字节,这个字节是不是就是0x19地址下的数据,这就是指定地址读

注意,在上图Sr的前面,其实也可以再加一个停止条件,这样也行哈,这样的话就是两个完整的时序了,先起始写入地址停止,因为写入的地址会存在地址指针里面,所以这个地址并不会因为时序的停止而消失,我们就可以再提示读当前位置停止,这样两条时序也可以完成任务,但是I2C协议官方规定的复合格式是一整个数据帧,就是先起始再重复起始再停止,相当于把两条时序拼接成一条了,好这些就是这三个I2C完整时序的介绍了。

其中第一个指定地址写和第三个指定地址读用的比较多,也是我们本节代码使用的时序,然后除了这三个时序啊,I2C还有这些时序的进阶版本,大概介绍一下,就是我们这些时序啊,指定地址写只是写一个字节,当前地址读和指定地址读,也都是读一个字节,那进阶版本就是指定地址写多个字节,当前地址读多少个字节,和指定地址读多少个字节,时序上只需要重复发送/接收字节就行,比如,我们看一下指定地址写多个字节:

开始写入一个字节,如果你只想写一个字节,那就停止就行了,如果你想写多个字节,就可以把停止时序前最后一部分(白色填充框)多重复几次,比如这里重复三遍发送一个字节和接受应答,这样第一个数据就写入到了指定地址0x19 的位置,然后不要忘了刚才说的哈,写入一个数据后,地址指针会自动加1,变成0x1a,所以这第二个数据就写入到了,0x1a的位置,同理,第三个数据就写入的是0x1b的地址。

以此类推,这样这个时序就进阶为在指定的位置开始,按顺序连续写入多个字节,比如你需要连续写入多个寄存器,就可以考虑这样来操作,这样在一条数据帧里,就可以同时写入多个字节,执行效率就会比较高,然后同理当前位置读和指定位置读,也可以多次执行这最后一部分时序,由于地址指针在读后也会自增,所以这样就可以连续读出一片区域的寄存器,效率也会非常高。

然后这里还要注意一下,如果你只想读一个字节就停止的话,在读完一个字节之后,一定要给从机发个非应答,就是该主机应答的时候,主机不把SDA拉低,从机读到SDA为1,就代表主机没有应答,从机收到非应答之后,就知道主机不想要继续了,从机就会释放总线,把SDA的控制权交还给主机,如果主机读完仍然给从机应答了,从机就会认为主机还想要数据,就会继续发送下一个数据,而这时主机如果想产生停止条件,SDA可能就会因为被从机拽住了,而不能正常弹回高电平。

总之这个注意一下,如果主机想连续读取多个字节,就需要在最后一个字节给非应答,而之前的所有字节都要给应答,简单来说就是主机给应答了,从机就会继续发,主机给非应答了,从机就不会再发了,交出SDA的控制权,从机控制SDA发送一个自己的权利哈,开始于读写标志位为1,结束于主机给应答位为1,这是主机给从机发送应答位的作用。

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

11.2 MPU6050简介

11.2.1 初识MPU6050

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

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

那要这么多轴的信息是要干啥呢,答案是通过数据融合可进一步得到姿态角,或者叫做欧拉角,这个欧拉角是什么呢,以飞机为例子,如下图:

欧拉角就是飞机机身相对于初始三个轴的夹角,飞机机头下沉或上仰,这个轴的夹角叫做俯仰Pitch,飞机机身左翻滚或者右翻滚,这个轴的夹角叫滚转Roll,飞机机身保持水平,机头向左转向或者向右转向,这个轴的夹角叫做偏航Yaw,简单来说,欧拉角就表达了飞机此时的姿态,飞机是上仰呢还是下倾呢,飞机向左倾斜还是向右倾斜了,通过欧拉角都能清晰地表示出来。

如果你想做一个飞控算法,为了保持飞机姿态平稳,那么得到一个精确且稳定的欧拉角就至关重要,但是可惜的是,之前我们所说的加速度计、陀螺仪、磁力计,任何一种传感器都不能获得精确且稳定的欧拉角,要想获得精确且稳定的欧拉角呢,就必须进行数据融合,把这几种传感器的数据结合起来,综合多种传感器的数据取长补短,这样才能获得精确且稳定的欧拉角,常见的数据融合算法一般有互补滤波卡尔曼滤波等,这就涉及到惯性导航领域里姿态解算的知识点了,不过我们本节课的侧重点是I2C通信,我们最终的程序现象就是把这些传感器的原始数据读出来,显示在OLED上就完事了。

姿态传感器计算出姿态角之后,就常应用于平衡车,飞行器等需要检测自身姿态的场景,平衡车呢如果传感器检测到车身向前或者向后倾斜了,程序就可以控制轮子进行调整,保持平衡车的平衡,飞行器呢这控制的轴就多些,一般至少需要检测俯仰角和滚转角两个夹角,然后控制电机保持飞机的平衡,这就是这个mpu6050姿态传感器的作用。

另外UP主在视频中还讲了一些测量的物理学原理,这里就不详述了。

11.2.2 MPU6050的基本参数

  1. MPU6050内部有16位ADC采集传感器的模拟信号,量化范围:-32768~32767

    • 加速度计满量程选择:±2、±4、±8、±16(g)

    • 陀螺仪满量程选择: ±250、±500、±1000、±2000(°/sec)

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

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

  1. 可配置的数字低通滤波器

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

    • 这两个参数是配合使用的,时钟源经过采样分频这个分频器的分频,可以为AD转换和内部其他电路提供时钟,控制分频系数就可以控制AD转换的快慢了
  3. I2C从机地址:1101000(AD0=0)、 1101001(AD0=1)

    • 芯片进行I2C通信的从机地址,这个可以在手册里查到,当AD0=0时,地址为1001000,当AD0=1时,地址为1001001,AD0就是板子引出来的一个引脚,可以调节I2C从机地址的最低位。

    关于MPU6050从机地址格式的说明:

    这里地址是七位的,如果像这样用二进制来表示的话,一般没啥问题,如果在程序中用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进制得到的,在实际发送第一个字节时,不要忘了先左移一位,再或上读写位,这是两种统计地址的表示方式。

11.2.3 MPU6050硬件电路

我们接着继续来看一下硬件电路,右边这个是MPU6050的芯片,左下角是一个八针的排针,左上角是一个LDO低压差线性稳压器。

我们来看一下右边这个MPU6050的芯片,芯片本身的引脚是非常多的,不过这里有很多引脚我们都用不到,还有一些硬件呢是这个芯片最小系统里的固定连接,这个最小系统一般手册里都会有,抄过来就行了。

然后看左下角八针的排针引出来的引脚,有VCC和GND这两个引脚是电源供电,然后SCL和SDA这两个引脚是I2C通信的引脚,在这里可以看到,SCL和SDA模块已经内置了两个4.7k的上拉电阻了,所以在我们接线的时候,直接把SCL和SDA接在GPIO口就行了,不需要再在外面另外接上拉电阻了,接着下面有XCL和XDA这两个是芯片里面的主机I2C通信引脚,设计这两个引脚是为了扩展芯片功能,解释请参考下面的注释。

之前我们说过,MPU6050是一个六轴姿态传感器,但是只有加速度计和陀螺仪的6个轴,融合出来的姿态角是有缺陷的,这个缺陷就是绕Z轴的角度,也就是偏航角,它的漂移无法通过加速度计进行纠正。这是九轴姿态传感器多出的磁力计的作用。

参考链接0:MPU6050的偏航角飘移原因 - DeepSeek

参考链接1:mpu6050航向角漂移很严重的原因及解决方法-电子发烧友网
参考链接2:六轴姿态模块yaw角零漂去除方法_较全面的解析_yaw漂移-CSDN博客
参考链接3:mpu6050航向角漂移很严重 - 电子发烧友网
参考链接4:MPU6050传感器——全面了解其功能与使用 - 百度知道
参考链接5:MPU6050的陀螺仪 - 博客园

另外如果你要制作无人机,需要定高飞行,这时候就还需要增加气压计,扩展为十轴提供一个高度信息的稳定参考,所以根据项目要求啊,这个六轴传感器可能不够用,需要进行扩展,那这个时候这个XCL和XDA就可以起作用了,XCL和XDA通常就是用于外接磁力计或者气压计,当接上磁力计或气压计之后,MPU6050的主机接口可以直接访问这些扩展芯片的数据,把这些扩展芯片的数据读取到MPU6050里面,在MPU6050里面会有DMP单元进行数据融合和姿态解算,如果你不需要按MPU6050的解算功能的话,也可以把这个磁力计或者气压计直接挂载在SCL和SDA上,因为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.3V的稳压器输出稳定的3.3V电压给芯片端供电,然后这一块是电源指示灯,只要3.3V端有电,电源指示灯就会亮,所以这一块需不需要,可以根据你的项目要求来,如果你已经有了稳定的3.3V电源了,就不再需要这一部分了。我们本实验直接VCC、GND接上电,SCL和SDA接上I2C通信的GPIO口就行了。

11.2.4 MPU6050的结构框图

我们再看一下这个芯片的模块框图,这个图就是整个芯片的内部结构,左上角是时钟系统,有时钟输入脚和输出脚,不过我们一般使用内部时钟,硬件电路这里呢CLKIN直接接了地,CLKOUT没有引出,所以这部分不需要过多关心。

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

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

然后下面这个Charge Pump是电荷泵或者叫充电泵,CPOUT的引脚需要外接一个电容,什么样的电容呢,在这个手册里有说明,电荷泵是一种升压电路,在其他地方也有出现过,比如我们用的这个OLED屏幕里面就有电荷泵进行升压。

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

右边这一大块就是寄存器和通信接口部分了,分别介绍如下:

  1. 中断状态寄存器:可以控制内部的哪些事件到中断引脚的输出;
  2. FIFO是先入先出寄存器:可以对数据流进行缓存,我们本节暂时不用;
  3. 配置寄存器:可以对内部的各个电路进行配置,指定芯片的工作模式;
  4. 传感器寄存器也就是数据寄存器,存储各个传感器的数据;
  5. 工厂校准:这个意思就是内部的传感器都进行了校准我们不用了解;
  6. 数字运动处理器简称DMP,还是芯片内部自带的一个姿态解算的硬件算法,配合官方的DMP库可以进行姿态解算,因为姿态解算还是比较难的,而且算法也很复杂,所以如果使用了内部的DMP进行姿态解算,姿态解算就会方便一些,暂时不涉及;
  7. FSYNC引脚是帧同步,我们用不到。

最后上面这块就是通信接口部分,上面一部分就是MPU6050作为从机的I2C和SPI通信接口,用于和STM32通信,下面这一部分是MPU6050作为主机的I2C通信接口,用于和MPU6050扩展的设备进行通信。

注意:

这里有个接口旁路选择器(MUX)就是一个开关,如果拨到上面,辅助的I2C引脚就和正常的I2C引脚接到一起,这样两路总线就合在一起了,STM32可以控制所有设备,这时STM32就是大哥MPU6050和这个扩展设备都是STM32的小弟,如果拨到下面,辅助的I2C引脚就由MPU6050控制,两条I2C总线独立分开,这时STM32是MPU6050的大哥,MPU6050又是扩展设备的大哥,我们本节课程不会用到这个扩展功能。

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

11.3 软件I2C实验

解释一下为什么软件模拟I2C要使用开漏输出模式

虽然开漏输出,名字上带了个输出,但这并不代表它只能输出,开漏输出模式仍然可以输入。输入时,先输出1,使得GPIO端口变成高阻态(浮空状态),然后再直接读取输入数据寄存器就行了,因为浮空模式下输入寄存器的值就是外部输入到引脚的电平高低。

  • 软件I2C模拟6个基本时序
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
#include "MyI2C.h"

void MyI2C_W_SCL(uint8_t BitValue)
{
GPIO_WriteBit(GPIOB, GPIO_Pin_10, (BitAction)BitValue);
Delay_us(10);
}

void MyI2C_W_SDA(uint8_t BitValue)
{
GPIO_WriteBit(GPIOB, GPIO_Pin_11, (BitAction)BitValue);
Delay_us(10);
}

uint8_t MyI2C_R_SDA(void)
{
uint8_t BitValue;
BitValue = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11);
Delay_us(10);
return BitValue;
}

/**
*@brief 软件I2C的初始化
*/
void MyI2C_Init(void)
{
// 开启时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);

// 初始化配置GPIO端口
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
GPIO_Init(GPIOB, &GPIO_InitStructure);

// 将端口置高电平
GPIO_SetBits(GPIOB, GPIO_Pin_10 | GPIO_Pin_11); // I2C总线处于空闲状态

}

/**
*@brief 软件I2C的起始时序
*/
void MyI2C_Start(void)
{
// 首先释放SDA和SCL,注意,顺序比较重要
MyI2C_W_SDA(1);
MyI2C_W_SCL(1);

// 拉低SDA,产生下降沿
MyI2C_W_SDA(0);
// 再拉低SCL
MyI2C_W_SCL(0);
}

/**
*@brief 软件I2C的终止时序
*/
void MyI2C_End(void)
{
// 因为SDA的高低不确定,因此先拉低SDA
MyI2C_W_SDA(0);
// 拉高SCL,因为只需要在SCL高电平的时候SDA上升沿,
// 也就是不需要SCL上升沿,所以没必要和上面的SDA一样提前拉低了
MyI2C_W_SCL(1);
// 拉高SDA
MyI2C_W_SDA(1);

}

/**
*@brief 软件I2C的主机指定地址写一个字节
*/
void MyI2C_SendByte(uint8_t data)
{
int i;
for(i=7; i>=0; i--)
{
// 拉低SCL
// MyI2C_W_SCL(0);
/* 这个其实不需要,除了停止时序,每一个时序的最后总会保证SCL是低电平 */

// 在SCL低电平期间,改变SDA的电平
MyI2C_W_SDA(data &(0x01 << i));
// 拉高SCL
MyI2C_W_SCL(1);
// 在SCL高电平期间,从机读取SDA的电平

// 拉低SCL
MyI2C_W_SCL(0);
}
}

/**
*@brief 软件I2C的主机指定地址读一个字节
*/
uint8_t MyI2C_ReceiveByte(void)
{
int i;
uint8_t out = 0;

// 一开始SCL就是低电平,所以为了让从机控制SDA,主机需要释放SDA
MyI2C_W_SDA(1);
for(i=7; i>=0; i--)
{
// 拉高SCL
MyI2C_W_SCL(1);
// 在SCL高电平期间,主机读取SDA的电平
out = out | (MyI2C_R_SDA() << i);
// 拉低SCL
MyI2C_W_SCL(0);
// 拉低SCL期间,从机改变SDA的数据
}
return out;
}

/**
*@brief 软件I2C的发送应答
*@note 主机在接收完一个字节之后,在下一个时钟发送一位数据,数据0表示应答,数据1表示非应答
*/
void MyI2C_SendACK(uint8_t ACKBit)
{
// 一开始SCL就是低电平,所以主机控制SDA,主机可自由决定是否给出应答
MyI2C_W_SDA(ACKBit);
// 再拉高SCL
MyI2C_W_SCL(1);
// 从机控制读取SDA,查看主机是否应答

// 最后,拉低SCL
MyI2C_W_SCL(0);
}

/**
*@brief 软件I2C的接收应答
*@note 主机在发送完一个字节之后,在下一个时钟接收一位数据,数据0表示应答,数据1表示非应答
*/
uint8_t MyI2C_ReceiveACK(void)
{
uint8_t ack;
// 一开始SCL就是低电平,所以为了让从机控制SDA,主机需要释放SDA
MyI2C_W_SDA(1);
// 从机控制SDA,发送应答位

// 再拉高SCL
MyI2C_W_SCL(1);
// 在SCL高电平期间,主机读取SDA的电平,查看从机是否应答
ack = MyI2C_R_SDA();

// 最后,拉低SCL
MyI2C_W_SCL(0);
return ack;
}
  • MPU6050的配置和测量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
#include "stm32f10x.h"                  // Device header
#include "OLED.h"
#include "MyI2C.h"
#include "MPU6050_Reg.h"

void MPU6050_WriteReg(uint8_t RegAddr, uint8_t Data);
uint8_t MPU6050_ReadReg(uint8_t RegAddr);
void MPU6050_Init(void);
void MPU6050_GetData(int16_t* AccX, int16_t* AccY, int16_t* AccZ, int16_t* GyroX, int16_t* GyroY, int16_t* GyroZ);


/**
*@brief 初始化MPU6050
*/
void MPU6050_Init(void)
{
MyI2C_Init();

// =============================================
// 初始化配置MPU6050的工作模式
// =============================================
// 初始化电源管理寄存器1
MPU6050_WriteReg(MPU6050_PWR_MGMT_1, 0x01);
// 初始化电源管理寄存器2
MPU6050_WriteReg(MPU6050_PWR_MGMT_2, 0x00);
// 初始化采样率分频
MPU6050_WriteReg(MPU6050_SMPLRT_DIV, 0x09);
// 初始化配置寄存器
MPU6050_WriteReg(MPU6050_CONFIG, 0x06);
// 初始化陀螺仪配置寄存器
MPU6050_WriteReg(MPU6050_GYRO_CONFIG, 0x18);
// 初始化加速度计配置寄存器
MPU6050_WriteReg(MPU6050_ACCEL_CONFIG, 0x18);
}

/**
*@brief MPU6050指定地址写
*/
void MPU6050_WriteReg(uint8_t RegAddr, uint8_t Data)
{
// 1. 起始时序
MyI2C_Start();

// 2.发送从机地址和读写位, 接收应答
MyI2C_SendByte(0xD0);
MyI2C_ReceiveACK();

// 3. 指定寄存器地址
MyI2C_SendByte(RegAddr);
MyI2C_ReceiveACK();

// 4. 发送数据
MyI2C_SendByte(Data);
MyI2C_ReceiveACK();

// 5.终止时序
MyI2C_End();
}

/**
*@brief MPU6050指定地址读
*/
uint8_t MPU6050_ReadReg(uint8_t RegAddr)
{
uint8_t out_byte;

// 1. 起始时序
MyI2C_Start();

// 2.发送从机地址和读写位, 接收应答
MyI2C_SendByte(0xD0);
MyI2C_ReceiveACK();

// 3. 指定寄存器地址
MyI2C_SendByte(RegAddr);
MyI2C_ReceiveACK();

// 4. 重新起始时序
MyI2C_Start();

// 5.发送从机地址和读写位, 接收应答
MyI2C_SendByte(0xD1);
MyI2C_ReceiveACK();

// ================ SDA控制权交给从机 ==================

// 6. 读取一个字节数据
out_byte = MyI2C_ReceiveByte();
MyI2C_SendACK(1); // 如果只接收一个字节,则不给应答

// 7. 终止时序
MyI2C_End();

return out_byte;
}

/**
*@brief 获取MPU6050测量的数据
*/
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;
// 获取X轴加速度
DataH = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_H); // 高八位
DataL = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_L); // 低八位
*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; //数据拼接,通过输出参数返回
}

int main()
{
OLED_Init();
MPU6050_Init();
while(1)
{

}
}

11.4 I2C通信外设

11.4.1 I2C外设的基本介绍

由于12C是同步时序,这每一位的持续时间要求不严格,某一位时间长一点短一点或许中途暂停一下时序,影响都不大,所以I2C是比较容易用软件模拟的。

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

回顾一下硬件实现串口(USART)的使用流程:首先配置USART外设,然后写入数据寄存器DR,然后硬件收发器就会自动生成波形发送出去,最后我们等待发送完成的标志位即可。

回到I2C这里,I2C也可以有软件模拟和硬件收发器自动操作这两种异步时序,对于串口这样的异步时序,软件实现麻烦,硬件实现简单,所以串口的实现基本是全部倒向硬件。而对于I2C这样的同步时序来说,软件实现简单灵活,硬件实现麻烦,但可以节省软件资源、性能更强,可以实现完整的多主机通信模型等,各有优缺点。

多主机模型了解即可,I2C通信分为主机和从机,主机就是拥有主动控制总线的权利,而从机只能在主机允许的情况下才能控制总线。

在一主多从的模型下,STM32是唯一的主机,下面这里可以挂载多个从机等等,那这个过程就很容易操作了,主机一个人掌控所有从机都得听他的话,不存在什么权力冲突。

进阶版的I2C还设计了多主机的模型,多主机又可以分为固定多主机可变多主机

固定多主机,就是这条总线上有两个或更多个固定的主机,上面这几个始终固定为主机,下面这几个始终固定为从机,这个状态就像是在教室里,讲台上同时站了多个老师,下面坐的所有学生可以被任意一个老师点名,老师可以主动发起对学生的控制,学生不能去控制老师,当两个老师同时想说话时就是总线冲突状态,这时就要进行总线仲裁,仲裁失败的一方让出总线控制权。

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

11.4.2 I2C硬件框图

(一)通信引脚SDA和SCL

首先左边这里是外设的通信引脚SDA和SCL,下面SMBALERT是SMBus(System Management Bus是系统管理总线)用到的,像这种外设模块引出来的引脚,一般都是借助GPIO口的复用模式与外部世界相连的,具体是复用在了哪个GPIO口呢,还是查询这个引脚定义表,在复用功能这两栏里找一下,因为内部电路设计的时候引脚就是连接好了的,所以如果想使用硬件I2C,就只能使用它连接好的指定硬件,不像软件I2C那样引脚可以任意指定

继续看内部电路,上面这一块是SDA,也就是数据控制部分,数据收发的核心部分是这里的数据寄存器DR和数据移位寄存器

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

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

(二)硬件I2C相关寄存器

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

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

(三)硬件I2C时钟线

继续看下面SCL的这部分,时钟控制是用来控制SCL线的,至于控制的细节,这里也没画,你就把它当做是一个黑盒子就行了。在下面的时钟控制寄存器CCR写对应的位电路就会执行对应的功能,然后控制逻辑电路也是黑盒子,写入控制寄存器CR1&CR2可以对整个电路进行控制,读取状态寄存器SR1&SR2可以得知电路的工作状态。

(四)中断和DMA

之后是中断,当内部有些标志位置1之后,可能事件比较紧急,就可以申请中断,如果我们开启了这个中断,那当这个事件发生后,程序就可以跳到中断函数来处理这个事件了。

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

11.4.3 I2C的抽象结构图

接着我们看一下这个基本结构图。

首先移位进寄存器和数据寄存器DR的配合是通信的核心部分,这里因为I2C是高位先行,所以这个移位寄存器是向左移位,在发送的时候最高位先移出去,一个SCL时钟移位一次,八次这样就能把一个字节由高位到低位依次放到SDA线上了,那在接收的时候呢,数据通过GPIO口从右边依次移进来,最终移8次1个字节就接收完成了,使用硬件I2C的时候GPIO口都要配置成复用开漏输出的模式,交由片上外设来控制(PS:当然这里即使是开漏输出模式,GPIO口也是可以进行输入的)。

然后SCL这里时钟控制器,通过GPIO去控制时钟线,这里我简化成一主多从的模型了,所以时钟这里只画了输出的方向,实际上前面这里如果是多主机的模型,时钟线也是会进行输入的。

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

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

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

11.4.4 硬件I2C的操作流程

(一)主机发送时序

我们先看一下主机发送,当STM32想要执行指定地址写的时候,就按照这个主发送器传送序列图来进行,这里有七位地址的主发送和十位地址的主发送,它们的区别是:

  1. 七位地址,起始条件后的一个字节是寻址
  2. 十位地址,起始条件后的两个字节都是寻址,其中前一个字节,这里写的是帧头,内容是五位的标志位11110+2位地址+1位读写位,然后后一个字节内容就是纯粹的八位地址了,两个字节加一起构成十位的寻址,这是十位地的选择模式啊,

我们主要关注七位地址的就行了,七位主机发送的时序流程是起始、从机地址、应答,后面是数据1应答,数据1应答等等,最后是p停止。因为I2C协议只规定了起始之后必须是寻址,至于后面数据的用途啊,并没有明确的规定,这些数据可以由各个芯片厂商自己来规定,比如MPU6050规定,就是寻址之后数据1为指定寄存器地址,数据2为指定寄存器地址下的数据,之后的数据n啊,就是从指定寄存器地址开始依次往后写,就是一个典型的指定地址写的时序流程。

然后我们从头来看一下,首先初始化之后,总线默认空闲状态,STM32默认是从模式,为了产生一个起始条件,STM32需要写入控制寄存器,这个得看一下手册的寄存器描述,在控制寄存器中有个START位,在这一位写1就可以产生起始条件了,当起始条件发出后,这一位可以由硬件自动清除,所以只要在这一位写1,STM32就自动产生起始条件了。之后STM32由从模式转为主模式,也就是多主机模型下,STM32有数据要发就要跳出来这个意思

控制完硬件电路之后,就要检查标志位:
通过检查标志位,来查看硬件有没有达到我们想要的状态。
首先起始条件之后,会发生`EV5`事件,又可以把它当成是标志位,这个手册这里都是用EV几这个事件来代替标志位的,为什么要设计这个事件,而不直接说产生什么标志位呢?这是因为有的状态会同时产生多个标志位,所以这个事件,就是组合了多个标志位的一个大标志位,在库函数中也有对应的,检查`EV几`事件是否发生的函数,所以你就当成是一个大标志位来理解就行了。
下面这里解释`EV5`事件,`SB`是状态寄存器`SR1`的一个位,表示它硬件的状态,`SB=1`代表起始条件已发送,软件读取`SR1`寄存器之后,也就是查看了这一位,然后写数据寄存器的操作,将清除该位,写数据计算器`DR`就是我们接下来的操作,所以按照正常的流程呢,这个状态寄存器是不需要手动清除的
然后继续这个流程,当我们检测起始条件已发送时,就可以发送一个字节的从机地址了,从机地址需要写到数据寄存器`DR`中,写入`DR`之后,硬件电路就会自动把这一字节转到移位寄存器里,再把这一个字节发送到I2C总线上,之后硬件会自动接收应答位ACK并判断,如果没有应答硬件就会置应答失败的标志位,然后这个标志位可以申请中断来提醒我们。
在选址完成之后会发生`EV6`事件,下面`EV6`事件的解释就是`ADDR`标志位为1,在手册中可以找到`ADDR`标志位,在**主模式**状态下`ADDR=1`就代表**地址发送结束**。
`EV6`事件结束后是`EV8_1`事件,就是`TXE`标志位等于1,表示移位寄存器空,数据寄存器空,这时需要我们写入数据寄存器DR进行数据发送的
`EV8`事件:一旦写入DR之后,因为移位寄存器也是空,所以DR会立刻转到移位寄存器进行发送,这时就是`EV8`事件,移位计算器非空,数据寄存器空,也即`EV8`事件就代表移位寄存器器正在发数据的状态,所以流程这里啊,数据1的时序就产生了,这个数据寄存器和移位寄存器的配合,要把前面这个结构记好,就是发送的时候数据先写入数据寄存器器,如果移位寄存器器没有数据,再转到移位寄存器进行发送,这个流程要理解清楚。
那继续看上图时序,`EV8`事件在主机接收从机应答位ACK的时候没有了,对应图中下部分的解释:这里写入DR,将清除该事件,所以按理说这个位置应该是写入了下一个数据,也就是后面这个数据2,在这个时刻就被写入到数据寄存器里等着了(这里建议 参考原视频https://www.bilibili.com/video/BV1th411z7sn?t=1842.2&p=34 的图示讲解)。
然后接收应答位之后,数据2就转入移位寄存器进行发送,此时的状态是移位寄存器非空,数据寄存器空,所以这时这个`EV8`事件就又发生了,这个位置数据2还正在移位发送,但此时下一个数据已经被写到数据寄存器等着了,所以这个时候`EV8`事件消失,之后应答产生`EV8`事件写入数据寄存器,`EV8`事件消失。按照这个流程来啊,一旦我们检测到`EV8`事件,就可以写入下一个数据了
最后当我们想要发送的数据写完之后,这时就没有新的数据可以写入到数据寄存器去了,当移位寄存器当前的数据移位完成时,此时就是移位寄存器空、数据寄存器也空的状态,这个事件就是这里的`EV8_2`,图示下面部分解释啊,`EV8_2`事件是`TXE=1`,也就是数据寄存器空,`BTF=1`这个是字节发送结束标志位,手册可以看一下具体的解释:在发送时,当一个新数据将被发送,且数据寄存器还未写入新的数据时,`BTF`标志位置1,直观来说:这个意思就是当前的移位寄存器已经移完了,该找数据寄存器要下一个数据了,但是一看数据寄存器没有数据,这就说明主机不想发了,这时就代表字节发送结束,是时候停止了,所以在这里当检测到`EV8_2`时,就可以产生终止条件了。
产生终止条件显然应该在控制寄存器里有相应的位可以控制,手册这里控制寄存器CR1中,stop位写1,就会在当前字节传输或当前起始条件发送后产生停止条件,那到这里一个完整的时序就发送完成了。

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

当然在程序中,我们有库函数,不需要实际去配置寄存器的,所以这个过程会比想象中简单一些。

(二)主机接收时序

接着我们继续看主机接收的流程,当然我们主要还是看七位地址的就行:

首先写入控制寄存器CR1start位,产生起始条件,然后等待Ev5事件,下面解释和刚才一样,Ev5事件就代表起始条件已发送,之后是寻址、接收应答、结束后产生Ev6事件,EV6事件和主机发送的EV6事件相同,代表寻址已完成。

之后,数据1这块,代表数据正在通过移位寄存器进行输入,EV6_1事件,下面解释是没有对应的事件标志,只适于接收一个字节的情况,这个Ev6_1可以看做数据1其实还正在移位,还没收到呢,所以EV6_1这个事件就没有标志位。

之后当这个时序单元完成时,硬件会自动根据我们的配置,把应答位发送出去,如何配置是否要给应答呢,也是手册控制净寄存器CR1里,这里有一位ACK应答使能,如果写1在接收一个字节后就返回一个应答,写零就是不给应答,就是应答位的配置。

之后,当这个时序单元结束后,就说明移位寄存器器已经成功移入一个字节的数据1了,这时移入的一个字节就整体转移到数据寄存器,同时置标志位RxNE=1,表示数据寄存器非空,也就是收到了一个字节的数据,这个状态就是Ev7事件,下面解释是RxNE=1,数据寄存器非空,读DR寄存器清除该事件,也就是收到数据了,当把这个数据读走之后,这个事件就没有了,上面这里Ev7事件没有了,说明此时数据1被读走,当然数据1存在数据寄存器DR中还没读走的时候啊,数据2就可以开始移入移位寄存器了

之后数据2移位完成,收到数据2产生Ev7事件,读走数据2则Ev7事件没有了,然后按照这个流程就可以一直接收数据了。

最后当我们不需要继续接收时,需要在最后一个时序单元发生时,提前把刚才说的应答位控制寄存器ACK置0(即不给应答),并且设置终止条件请求,这就是Ev7_1事件。下面解释和Ev7_1样,后面加了一句,设置ACK等于0和stop请求,也就是我们想要结束了,之后在这个时序完成后,由于设置了ACK等于0,所以这里就会给出非应答,最后由于设置stop位,所以产生终止条件,这样接收一个字节的时序就完成了。

11.5 硬件I2C实验

结合11.4.3 I2C的抽象结构图小节,可将硬件I2C的使用流程总结如下:

  1. 第一步,开启I2C外设和对应GPIO口的时钟;
  2. 第二步,把I2C外设对应的GPIO口初始化为复用开漏模式;
  3. 第三步,使用结构体对整个I2C进行配置;
  4. 第四步,调用I2C_Cmd()函数,使能I2C。

这样初始化配置就完成了。

硬件I2C读取MPU6050实验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
#include "stm32f10x.h"                  // Device header
#include "OLED.h"
#include "MPU6050_Reg.h"

void MPU6050_WriteReg(uint8_t RegAddr, uint8_t Data);
uint8_t MPU6050_ReadReg(uint8_t RegAddr);
void MPU6050_Init(void);
void MPU6050_GetData(int16_t* AccX, int16_t* AccY, int16_t* AccZ, int16_t* GyroX, int16_t* GyroY, int16_t* GyroZ);


/**
*@brief 初始化MPU6050
*/
void MPU6050_Init(void)
{
// 软件I2C初始化
// MyI2C_Init();

// 硬件I2C初始化
// 1. 开启时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C2, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);

// 2. 配置GPIO为复用开漏输出
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_OD;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStruct);

// 3. 初始化配置硬件I2C
I2C_InitTypeDef I2C_InitStruct;
I2C_InitStruct.I2C_Mode = I2C_Mode_I2C; // 选择I2C外设的工作模式为I2C
I2C_InitStruct.I2C_ClockSpeed = 50000; // 配置I2C的时钟频率为50kHz
I2C_InitStruct.I2C_DutyCycle = I2C_DutyCycle_2; // 时钟占空比
I2C_InitStruct.I2C_Ack = I2C_Ack_Enable; // 初始给出应答位
I2C_InitStruct.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit; // STM32作为从机使用7位地址
I2C_InitStruct.I2C_OwnAddress1 = 0x00; // 人为设定STM32作为从机使用的7位地址
I2C_Init(I2C2, &I2C_InitStruct);

// 4. 使能I2C
I2C_Cmd(I2C2, ENABLE);

// =============================================
// 初始化配置MPU6050的工作模式
// =============================================
// 初始化电源管理寄存器1
MPU6050_WriteReg(MPU6050_PWR_MGMT_1, 0x01);
// 初始化电源管理寄存器2
MPU6050_WriteReg(MPU6050_PWR_MGMT_2, 0x00);
// 初始化采样率分频
MPU6050_WriteReg(MPU6050_SMPLRT_DIV, 0x09);
// 初始化配置寄存器
MPU6050_WriteReg(MPU6050_CONFIG, 0x06);
// 初始化陀螺仪配置寄存器
MPU6050_WriteReg(MPU6050_GYRO_CONFIG, 0x18);
// 初始化加速度计配置寄存器
MPU6050_WriteReg(MPU6050_ACCEL_CONFIG, 0x18);
}

/**
*@brief MPU6050指定地址写
*/
void MPU6050_WriteReg(uint8_t RegAddr, uint8_t Data)
{
// 1. 产生起始条件
I2C_GenerateSTART(I2C2, ENABLE);
while(I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT) != SUCCESS);

// 2. 发送从机地址和写位
I2C_Send7bitAddress(I2C2, 0xD0, I2C_Direction_Transmitter);
while(I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED) != SUCCESS);

// 3. 写入数据——寄存器地址
I2C_SendData(I2C2, RegAddr);
while(I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTING) != SUCCESS);

// 4. 写入数据——具体数据
I2C_SendData(I2C2, Data);
while(I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED) != SUCCESS);

// 5. 终止时序
I2C_GenerateSTOP(I2C2, ENABLE);
}

/**
*@brief MPU6050指定地址读
*/
uint8_t MPU6050_ReadReg(uint8_t RegAddr)
{
uint8_t out_byte;

// 1. 产生起始条件
I2C_GenerateSTART(I2C2, ENABLE);
while(I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT) != SUCCESS);

// 2. 发送从机地址和写位
I2C_Send7bitAddress(I2C2, 0xD0, I2C_Direction_Transmitter);
while(I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED) != SUCCESS);

// 3. 写入数据——寄存器地址
I2C_SendData(I2C2, RegAddr);
while(I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED) != SUCCESS);

// 4. 生成重复起始条件
I2C_GenerateSTART(I2C2, ENABLE);
while(I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT) != SUCCESS);

// 5. 发送从机地址和读位
I2C_Send7bitAddress(I2C2, 0xD0, I2C_Direction_Receiver);
while(I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED) != SUCCESS);

// 6. 设置ACK=0不给应答,因为这只是一个字节
I2C_AcknowledgeConfig(I2C2, DISABLE);

// 7.终止时序
I2C_GenerateSTOP(I2C2, ENABLE);

// 8. 等待EV7事件
while(I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_BYTE_RECEIVED) != SUCCESS);

// 9. 读取接收的数据
out_byte = I2C_ReceiveData(I2C2);

return out_byte;
}

/**
*@brief 获取MPU6050测量的数据
*/
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;
// 获取X轴加速度
DataH = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_H); // 高八位
DataL = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_L); // 低八位
*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; //数据拼接,通过输出参数返回
}

int main()
{
OLED_Init();
OLED_ShowChar(1, 1, 'A');
OLED_ShowString(1, 3, "Junheng");
OLED_ShowNum(2, 1, 12345, 5);
OLED_ShowSignedNum(2, 7, -58, 2);
OLED_ShowHexNum(3, 1, 0xAA55, 4);
OLED_ShowBinNum(4, 1, 0xAA55, 16);
while(1)
{

}
}