STM32外设学习--DMA直接存储器读取(AD扫描程序,DMA搬运)--学习笔记。
上次我们完成了DMA数据转运的基本了解,这次我们来进行代码部分的讲解。
一.DMA数据转运
1.硬件接线图

因为我们DMA数据转运是发生在单片机内部的,所以外部接线图就是这些。
2.函数学习
![]()
复制一下我们OLED显示的函数,起一个新名字。
(1)小实验-查看存储器映像
在学习代码之前我们先做一个实验,看一下我们定义的数据是不是真的存放在了相应的地址区间里
![]()
先定义一个变量。

让我们OLED先显示一下变量。
![]()
然后显示变量的地址,这里的取地址,应该存放在指针变量里,如果单纯要显示数字,要加上强制类型转换(uint32_t)如果不加强制类型转换那么就是指针跨级赋值,编译时候就会有警告。
最后长度是8,8个16进制数,表示长度是32位。因为32位系统地址都是32位的。

编译一下,0错误,0警告。

第一行显示的是aa变量的内容,第二行显示的是aa这个变量被存储的地址。
可以看到地址是2000 0000地址是2000开头的。

对照这个表我们就可以看到aa这个变量存储的位置是SRAM区。在SRAM区地址肯定是以0x2000开头的,0x表示十六进制。具体地址是由编译器决定的,目前SRAM区没有什么东西,所以编译器就把他放在了第一位。
![]()
我们可以在这个变量前面加一个关键字const,const是表示常量的意思,被const修饰的变量在程序中只能读,不能写,我们上一篇博客了解到,Flash中的数据也是只能读,不能写。所以const和Flash就联系起来了。
在STM32中使用const定义的变量是存储在Flash中的,这里不因该说变量了,而是说常量。因为是不能变的,这个常量的值,只能在定义时候给,如果在程序这里,尝试再次给他赋值,就会报错。

编译下载查看一下。

在这里就可以看到aa这个常量的地址,就变为了0800开头的了。

可以看到,aa是被放在了Flash里面。在Flash里存放的是程序代码,以及,常量数据。
这里的代码尾部有一些偏移,不想是SRAM里那样,直接放在第一位,这是因为Flash里还有程序代码这些东西,放在了前面所以编译器给这个常量安排的地址,就相对靠后了一些,这就是定义变量和常量的方法。
正常情况下我们使用的都是变量,直接定义就行,不需要加const。
当我们程序中出现了一大堆数据,并且不需要更改时候,就可以使用const修饰这样能节省SROM的空间,比如说查找表,字库数据等等。

比如我们可以打开这个OLED_font.H文件,就可以看到这里面就是OLED显示英文的字库,这是一个数组,它里面的数据,决定了每个字符应该显示那些像素点。这个数组非常的长,而且是不需要更改的,所以这里就可以加一个const 把它定义在Flash里面,这样就可以,节省我们SRAM的空间。如果把const去掉了,程序不会有任何影响,只是会在SRAM里浪费这么大数组的一块空间,如果数组很大就要考虑是否消耗的起了。
(2)外设寄存器地址
对于外设寄存器来说,他的地址是固定的在手册中可以查到。在程序中可以用结构体很方便的访问寄存器,比如要访问ADC1的DR寄存器。

这样子就是直接访问寄存器的模式了。
![]()
我们取一下这个寄存器的地址。

可以看到他的地址是4001244C。

对照PPT表格我们可以看到0x4000开头的是外设寄存器。这个地址是固定的,手册也可以查到。
![]()
我们看到ADC1的起始地址是4001 2400。

然后我们可以看到ADC_DR寄存器地址偏移是4C然后ADC1起始地址是4001 2400。所以ADC1_DR寄存器地址应该为4001 2400+4C = 4001 244C。和我们LOED显示的一样。

我们再来研究一下,为什么这样子就可以找到DR。
![]()
我们跳转到定义。发现ADC1就是这个东西,左边是强制类型转换,把ADC1_BASE强制转换为,ADC_TypeDef类型的指针。右边的ADC1_BASE就是ADC1的基地址,基地址就是起始地址的意思。也就是我们查表得到的,4001 2400。
![]()
转到定义看一下,ADC1的基地址,就是APB2的基地址+0x2400。
![]()
在转到定义,APB2外设的基地址,就是外设基地址+0x10000。
![]()
在转到定义,外设基地址就是0x40000000。
![]()
还可以看到SRAM基地址是0x20000000
![]()
Flash基地址是0x08000000。
![]()
我们看到外设基地址是0x4000 0000加上0x1 0000 = 4001 0000。就是我们APB2外设基地址。
然后外设基地址0x4001 0000加上0x2400 = 0x4001 2400就是ADC1的基地址,现在基地址有了,但是基地址+偏移才是我们寄存器的地址。
![]()
在这里使用了一个非常巧妙的偏移,就是结构体,

跳转到结构体,就可以看到这里依次定义了各种寄存器。

这个寄存器的数据和我们手册中的是一一对应的。
![]()
回到这里,如果我们定义了一个结构体的指针。并且指针地址就是这个外设的起始地址。那么这个结构体的每个成员,就会正好映射实际的每个寄存器,
假设在内存的4001 2400开始的位置存放的是ADC1寄存器。存放顺序是SR,CR1,CR2等等,我们定义ADC_TypeDef这样的一个结构体,那他在内存的存储情况也是SR,CR1,CR2等待。这样子来储存的。如果指定这个结构体的起始地址就是ADC1外设寄存器的起始地址。那么这个结构体的内存和外设寄存器的内存,就会完美重合。我在访问结构体的某个成员,就是访问这个外设的某个寄存器了。这就是STM32中使用结构体来访问寄存器的流程。
![]()
回到这里,ADC1是结构体指针,指向的是ADC1外设的起始地址。访问结构体成员,就是相当于加了一个地址偏移。起始地址+偏移就是指定的寄存器。

如果我们宏定义一下ADC1_DR然后把他的地址强制转换为指针,之后使用*号取ADC1_DR指针的内容,这样也可以访问ADC1的DR寄存器,和结构体访问效果是一样的。
3.DMC配置
我们来定义一下DMA的源端数组和转运数组。
![]()
定义一下源端数组。实际上这个数组数据特别多,这样子才能显示出我们DMA的优势。
![]()
然后是转运数组。
我们的任务是初始化DataA让DMA把DataA的数据转运到DataB里面。这就是我们第一个代码的任务。
我们先给DMA建立一个模块,因为DMA不涉及外部电路,所以我们把它加载System中就可以。

快速添加模块。不要用DMA.c防止跟库函数的重名。

快速写一个我们的文件。

我们在这里进行DMA的配置。

初始化的步骤就是按照我们这个图的流程。
4.库函数学习
![]()
恢复缺省配置,所有寄存器恢复为复位后的默认值,清除之前配置
![]()
初始化。
![]()
结构体初始化。
![]()
DMA使能。
![]()
中断输出使能。

DMA设置当前数据寄存器。

这个函数就是给我门这个传输计数器写数据的,
![]()
DMA获取当前数据寄存器。这个函数就是返回传输计数器的值。想看还有多少数据没有转运就可以调用这个函数。
![]()
获取标志位状态。
![]()
清除标志位
![]()
获取中断状态。
![]()
清除中断挂起位。
5.程序编写
(1)DMA初始化

因为DMA实时AHB总线上的设备所以我们开起AHB时钟。

然后转到定义,这里的意思是,对于互联型设备,这里的参数可以是下面这些值的组合

对于其他的设备,这个参数可以是下面的组合。
互联型是STM32F105/107的型号,我们的是103所以我们在下面的参数里面选择。
![]()
开启AHB总线上DMA1的时钟。

老三样,定义结构体,引出结构体成员,然后一个一个修改。

重新排列一下。
![]()
在DMA_Init处进行跳转。第一个参数y选择DMA1或2用来确定是哪个DMA。x可以是1-7对于DMA1来说。或者是1-5对于DMA2来说。
![]()
所以第一个参数,就可以选定是哪个参数。
![]()
这里是存储器到存储器的转运,我们选择DMA1因为用的是软件触发,所以可以任意选择通道。这里选择通道1。
![]()
把结构体配置好的参数,传给DMA的通道1。
![]()
第一个参数,外设站点的基地址,要写一个32位的地址。对于SRAM的数组,他的地址是编译器分配的,并不是固定的。所以我们一般不会写地址,而是通过数组名来获取地址。这里我们就把地址提取成初始化函数的参数。这样在初始化的时候你想转运哪个数组就把哪个数组的地址传进来就可以了。
因为数组名就是数组首元素的地址。

所以我们这里改成uint32_t AddrA。
![]()
所以我们在治理放一个AddrA。

数据宽度跳转到定义,这里我们可以看到Byte字节也就是uint8_t,让HalfWord半字也就是uint16_t然是Word字uint32_t,
![]()
这里我们选择字节。
![]()
接下来是地址是否自增。

跳转定义看一下,这里有两个选择,自增,Enable,Disable就是不自增。根据上一个博客分析,我们这种,地址之间的转运,是肯定需要自增的。
![]()
我们选择地址自增。
![]()
存储器站点的基地址,我们也需要提取成参数。
![]()
再加一个。

然后把AddrB放在这里,作为存储器站点的起始地址。
![]()
接下来是数据宽度,转到定义。

我们也选择以字节传输。

存储器站点地址自增。

转到定义,这一也选择自增。
![]()
选择自增。
![]()
接着看,指定外设地点是源端还是目的地

第一个参数是外设站点作为DST:destination,表示目的地。(传输方向就是存储器站点到外设站点)
第二个参数是外设站点作为SRC:source,表示源头(传输方向是外设站点到存储器站点)。
我们打算是把DataA放在外设站点,DataB放在存储器站点,所以传输方向就是外设站点到存储器站点,所以选择第二个参数,选择外设站点作为数据源。
![]()
这里我们选择好。
![]()
缓存区大小。

转到定义,这里的意思是,以数据单元,指定缓存区大小,这个数据单元等于外设数据宽度,或者存储器数据宽度,取决于传输方向
这句话解释就是说你要传送几个数据单元,这个数据单元等于你传输源端站点的DataSize。简单点就是Buffersize就是传输计数器,指定传输几次。他的取值是0-65535。
![]()
我们把这个也加在函数里面。整理为参数。
![]()
然后把Size放在这里。
![]()
指定传输计数器是否重装。

转到定义,有一个注意事项,写的是循环模式,也就是自动重装,不能应用存储器到存储器的情况下。也就是上节课说的,自动重装和软件触发,不能同时使用。如果同时使用DMA就会连续触发,永远也停不下来了。

转到定义,第一个是传输计数器自动重装,也就是循环模式,第二个是正常模式,传输计数器不会自动重装。这里我们转运数组,是存储器到存储器的传输,转运一次停下来就可以了。
![]()
我们选择不自动重装
![]()
然后是选择DMA是否应用存储器到存储器的转运。存储器到存储器的模式就是软件触发

这里Enable就是使用软件触发,第二个Disable就是不使用软件触发,也就是硬件触发。我们转运数组,所以选择第一个,复制
![]()
这里选择我们的Enable。
![]()
最后一个参数是指定通道的软件优先级。

第一个是非常高,第二个是高,第三个是中等,第四个是低。
这个优先级,是如果你有多个通道的话可以指定一下,确保紧急的转运通道有更高的优先级。我们就一个通道,随便就可以。
![]()
这里选择中等。
到这里DMA的参数就配置好了。但是到目前为止,DMA还不会工作。
DMA转运有三个条件:1.传输计数器大于0,2.触发源有触发信号,3.DMA使能。
(2)DMA使能

这样子我们DMA才会工作,转运一次,传输计数器自减一次,当传输计数器减到0之后,转运完成,同时第一个条件不满足,转运停止。这样就完成了一次数组的转运。
(3)主程序编写
![]()
我们在.h文件中声明一下。


可以按住alt键,然后就可以进行逐个框选。

先在第一行显示DataA数据,第二行显示DatB数据。

转运之后在显示一下我们的数组。一个简单的测试程序就完成了。

在屏幕上前两行是转运前的数据,后两行是转运后的数据。这说明我们成功转运了,在转运的过程中,源端数据是不会改变的。
最后我们来进行程序完善。写一个DMA的传输函数。

每调用一次这个函数,就再次启动一次DMA转运。
在里面我们需要重新给传输计数器赋值,要先给传输寄存器赋值,必须要先给DMA失能。

先关闭DMA。然后就可以给传输计数器赋值了。
![]()
DMA传输计数器赋值函数。
![]()
第一个参数,选择我们DMA1的通道1。第二个是你要给传输计数器写入的值。

定义一个全局变量。
![]()
然后再初始化函数里存入Size。
![]()
在这里就可以使用全局变量了。

最后再给DMA使能然后就可以完成我们修改传输寄存器值的操作了。

我们先再初始化函数里,把DMA给失能,不让DMA初始化完成就立刻运行。而是再调用了Transfer之后,再进行转运。调用一次,转运一次,当然再转运开始之后,我们还需要进行一个操作。就是等待转运完成。
![]()
查看标志位函数。

转到定义,可以看到,这里有很多的标志位可以查看。
总共就是四种标志:
![]()
第一种:全局标志位。
![]()
第二种:转运完成标志位。
![]()
第三种:转运过半标志位。
![]()
第四中:转运错误标志位。
然后所有的通道都是这四种标志位。这里我们需要检查DMA1通道1转换完成的标志位。
![]()
所以我们选择这个参数。
![]()
我们用一个while循环,如果没有转换完成,那么就一直等待。这样就实现了等待转运完成了。
标志位置1后需要手动清除标志位
![]()
使用我们这个函数。

到这里我们这个模块就全部写完了。
![]()
放在.h文件中声明。

然后两个数组名显示。

显示DataA和DataB的地址。

更改一下DataA的显示位置。

DataB的数据也换行。

然后把DMA函数初始化

之后编译下载。

目前显示的是DataA地址是2000 0002现在可能是size那个变量被分到了0的位置。所以这个地址就往后偏移了两个字节。因为现在还没有进行数据转运。
然后我们来到主函数,进行测试,先变化一下DataA的源端数据

使他们自增1。

把显示DataA和DataB的数据先放在这里。

延迟1S。

开始转运。

再显示一下转运后的数据。

延时1S方便观看。

0错误0警告。


然后就可以看到数据再不断地变化,不断地转运。DataA的数据先变化一下,然后转运到DataB在进行显示。DataB的数据就和DataA相同了。
这就是DMA转运数据的效果。
如果想把Flash里面的数据,转运到SRAM里面,可以再DataA前面加一个const。


DataA++就不能要了因为const数据不能更改。
然后编译下载

然后就可以看到DataA的地址是0800开头的。说明DataA是Flash里面的数据。下面DataB的数据和DataA一样,说明DataA的数据已经成功转运到DataB里面了。
这就是第一个程序的内容

我们把程序该回去。
那么第一个程序,DMA存储器到存储器的代码我们就完成了
二.ADC+DMA应用
1.硬件接线图

这里和上一个博客的接线图是一样的,都是PA0接一个电位器,PA1和PA3接三个传感器模块的输出。
2.程序编写
![]()
我们复制一下ADC多通道的程序,然后重命名。
(1)ADC配置

复制上一个DMA配置的程序,然后复制,粘贴到DMA使能之前。

把开启DMA的时钟放在前面。

然后把规则组注入通道放在GPIO初始化下面。其中通道0放在序列1位置,通道1放在序列2位置,通道2放在序列3位置,通道3放在序列4位置。这样子序列就完成了,通道的顺序可以任意修改,这样子最终存放的数据的顺序,也会发生改变。
![]()
然后打开连续转换模式。
![]()
要扫描我们前四个通道。
![]()
我先用单次扫描模式。
(2)DMA配置
之后进行DMA配置,ADC扫描完一个数据,DMA就需要搬走。防止数据被覆盖。
![]()
外设基地址,数据的源头,ADC扫描好的数据放在了ADC_DR寄存器里。我们以前过ADC_DR的地址就是0x40001 244c,可以这样子填写
![]()
填写ADC1_DR寄存器的地址,然后强制转换为结果。
![]()
之后数据宽度,我们想要DR寄存器低16位的数据,所以数据宽度,就是HalfWord以半字,16位转运。
![]()
外设地址自增选择不自增,始终转运,同一个位置的数据。
接下来,存储器站点,也就是传输数据的目的地。我们需要把数据存在SRAM数组里所以我们定义一个数组
![]()
然后把数组放在这里,强制类型转换为uint32_t,因为数组名本来就是首元素地址。
![]()
之后数据宽度也是半字。
![]()
地址是否自增给Enable存储器的地址是自增的每转运一次换一个位置,防止数据被覆盖。
到这里我们源端和目的地参数就配置好了。
![]()
之后传输方向外设站点时源头,没问题。
![]()
传输数量写4个。因为有4个ADC通道所以传输数量写4个。
![]()
传输模式可以给单次传输,也可以给自动重装的循环模式。先给正常模式。
![]()
然后M2M要给到Disable不使用软件触发。我们需要硬件触发,触发源时ADC1,因为我们ADC每扫描完一个通道,DMA就会触发搬运一次。
![]()
最后所有的参数都配置到DMA1的通道里面,这里通道就不能任意选择了。

这里我们可以看到ADC1的硬件触发只接在了DMA1的通道上。所以这里通道只能选择DMA1的通道1,其他的通道不可以。
![]()
这里可以直接使能,这样子DMA转运的3个条件,第一个传输计数器不为0,满足,第三个DMA使能满足,但是。
![]()
第二个条件触发源有信号,暂时不满足。因为这里是硬件触发,ADC还没有使能,不会有触发信号。所以DMA使能之后,不会立即工作。

最后再DMA使能之前,还需要有一个条件,就是开启ADC-DMA的输出。

这里有三个触发源,具体选择哪个,取决于把哪个DMA的输出给开启了。

打开ADC.h函数,可以看到
![]()
有一个ADC_MDMACmd的函数。这个函数就是用来开启DMA的触发信号的。

我们开启DMA。
到目前位置,ADC和DMA配合工作的配置就完成了。
(3)子函数
![]()
来到子函数,把它参数和返回值修改为void。

因为ADC还是单次模式,所以需要函数触发一下ADC开始。其他的就不需要了。

因为DMA也是正常的单数模式,所以再触发ADC之前,还需要重新写入一下传输计数器。

把这个复制一下。传输次数给4。
最后等待ADC和DMA转运完成,因为转运总是在转换之后的。

然后等待DMA转运完成。等待ADC转换完成就不需要了。
这样子当我们调用void AD_GetValue()函数ADC开始转换,连续扫描四个通道DMA也同步进行转运ADC转换结果依次存放在AD_Value[4]数组里。

声明一下子函数。

另外数组我们也放在头文件里声明一下,把这个数组作为一个可以外部调用的数组。当然记得加一个extern。
(4)主函数

先再主函数里调用AD_GetValue()函数,这样子数据就直接跑到AD_Value数组里面了。

AD_Value[0]是通道0转换结果,AD_Value[1]是通道1转换结果,AD_Value[2]是通道2转换结果,AD_Value[3]是通道3转换结果。

编译一下。

这四个通道就显示出来了
这就是ADC单次扫描+DMA单次转运的方式。
我们还可以配置为ADC连续扫描+DMA循环转运的方式。
![]()
我们来到这个函数,再这里改为ENABLE。
![]()
DMA循环模式打开。
![]()
然后把ADC触发,直接放在初始化最后一行,当ADC触发之后,ADC连续转换,DMA循环转运两者一直再工作,始终把最新的转换结果,刷新到SRAM数组里,当我门想要数据的时候,随时去数组里取就行。

这样这个函数就完全不需要了。

主循环直接读取我们的数组就可以了。

编译一下。然后下载。

可以看到这样子也可以完成AD多通道转换功能。
这样就完成了硬件的高度自动化,软件什么也不用做,也不用触发中断。硬件自动就把活干完了。
也可以再加入一个外设定时器,ADC用单次扫描,在用定时器去定时触发,这样子就是定时器触发ADC,ADC触发DMA,整个过程完全自动,不需要程序手动进行操作。这就是STM32中硬件全自动化的一大特色。各个外设互相交织,互相连接,不再是传统的那样子,一个CPU,单独控制多个独立的外设。而是外设之间,互相连接,互相合作形成一个网状结构。这样再完成某些简单而繁琐的工作的时候,就不需要CP统一进行调度了,而是通过外设之间的互相配合,自动完成繁琐的工作。
这样不仅可以减轻CPU的负担,还可以大大提升外设的性能,在之前也遇到过这样的性能,比如定时器的输出可以通向ADC,DAC或者其他的定时器。ADC的触发源可以来自定时器或外部中断,DMA的触发源可以来自ADC定时器,串口等等。这就是STM32外设互相配合工作的特色。
