1. DMA简介
1.1 基本概念
DMA(Direct Memory Access)直接存储器存取/访问,DMA 这个外设是可以直接访问 STM32 内部的存储器的,包括运行内存 SRAM、程序存储器 Flash 和寄存器等等,DMA 都有权限访问它们,所以 DMA 才能完成数据转运的工作。
DMA 是一个数据转运小助手,它主要是用来协助 CPU,完成数据转运的工作。
DMA可以提供外设和存储器或者存储器和存储器之间的高速数据传输,无须CPU干预,节省了CPU的资源
外设指的是外设寄存器,一般是外设的数据寄存器 DR,Data Register,比如 ADC 的数据寄存器、串口的数据寄存器等等。
存储器指的就是运行内存 SRAM 和程序存储器 Flash,是我们存储变量数组和程序代码的地方。
12个独立可配置的通道: DMA1(7个通道), DMA2(5个通道)
通道就是数据转运的路径,从一个地方移动到另一个地方,就需要占用一个通道;如果有多个通道进行转运,那它们之间可以各转各的,互不干扰,这就是 DMA 通道。
每个通道都支持软件触发和特定的硬件触发
- 如果 DMA 进行的是存储器到存储器的数据转运,比如我们想把 Flash 里的一批数据,转运到 SRAM 里去,那就需要软件触发了。使用软件触发之后,DMA 就会一股脑地把这批数据,以最快的速度,全部转运完成,这也是我们想要的效果。
- 如果DMA 进行的是外设到存储器的数据转运,就不能一股脑地转运了。因为外设的数据是有一定时机的,所以这时我们就需要用硬件触发了,比如转运 ADC 的数据,那就得 ADC 每个通道 AD 转换完成后,硬件触发一次 DMA,之后 DMA 再转运,触发一次,转运一次,这样数据才是正确的,才是我们想要的效果。
所以存储器到存储器的数据转运,我们一般使用软件触发,外设到存储器的数据转运,我们一般使用硬件触发。
- 特定的硬件触发:意思就是每个 DMA 的通道,它的硬件触发源是不一样的,你要使用某个外设的硬件触发源,就得使用它连接的那个通道,而不能任意选择通道。这个之后我们再详细分析。
STM32F103C8T6 DMA资源:DMA1(7个通道)
我们这个芯片只有 DMA1 的 7 个通道,没有 DMA2,这个注意数据手册。
1.2 存储器映像
既然 DMA 是在存储器之间进行数据转运的,那我们就应该要了解一下,STM32 中都有哪些存储器,这些存储器又是被安排到了哪些地址上,这就是存储器映像的内容。
计算机系统的 5 大组成部分是 运算器、控制器、存储器、输入设备和输出设备。其中运算器和控制器一般会合在一起,叫做 CPU,所以计算机的核心关键部分就是 CPU 和存储器。存储器又有两个重要知识点,一个是存储器的内容,另一个就是存储器的地址,那 STM32 也不例外。
这个表是 STM32 中所有类型的存储器和它们所被安排的地址,在 STM32 的数据手册里,也会有个存储器映像的图,此表是从图里总结出来的,都是一个意思。
在这个表里,无论是 Flash,还是 SRAM,还是外设寄存器,它们都是存储器的一种,包括外设寄存器,实际上也是存储器。
所以DMA 转运本质上都是存储器之间的数据转运,说成外设到存储器只不过是 STM32 它特别指定了可以转运外设的存储器而已。
在这个表里,存储器总共分为两大类:
- ROM:只读存储器,是一种非易失性、掉电不丢失的存储器。其中 ROM 分为了三块:
- 第一块是程序存储器 Flash,也就是主闪存,闪存被分为了很多页;它的用途就是,存储 C 语言编译后的程序代码,也就是我们下载程序的位置;运行程序,一般也是从主闪存里开始运行的。最终终止地址取决于它的容量,编到哪里,哪里就是终止地址,这就是主闪存的地址范围。之后如果在软件里看到某个数据的地址是 0800 开头的,那你就可以确定,它是属于主闪存的数据。
- 系统存储器和选项字节,这两块存储器统称为信息块,也是 ROM 的一种,掉电不丢失,实际上它们的存储介质也是 Flash,只不过我们一般说 Flash 指的是主闪存 Flash,而不指这两块区域。这两款存储器的位置是在 ROM 区的最后面,BootLoader 程序存储的位置被分配到系统存储器,BootLoader 程序是芯片出厂自动写入的,一般也不允许我们修改;你下载程序可以不刷新选项字节的内容,这样选项字节的配置就可以保持不变,选项字节里存的主要是 Flash 的读保护、写保护,还有看门狗等等的配置,这个如果需要的话,可以了解一下。
闪存模块中还有闪存接口寄存器,这是外设的一部分,地址是 40 开头的,这个外设可以对闪存进行读写。
- RAM:随机存储器,是一种易失性、掉电丢失的存储器。
- 首先是运行内存 SRAM,也就是我们在程序中定义变量、数组、结构体的地方,定一个变量,再取它的地址显示出来,那这个地址肯定就是 2000 开头的。类比于电脑的话,运行内存就是内存条。
- 接下来是外设寄存器,也就是我们初始化各个外设,最终所读写的东西。之前我们说了,外设寄存器也是存储器的一种,它的存储介质其实也是 SRAM,只不过我们一般习惯把运行内存叫 SRAM,外设寄存器就直接叫寄存器了。具体到每个外设,又有它们具体的起始地址,然后外设地址里面又可以具体细分到每个寄存器的地址、寄存器里的每个字节的地址,最终所有字节的地址,就都可以算出来了。
- 最后是内核外设寄存器,内核外设就是 NVIC 和 SysTick,因为内核外设和其他外设不是一个厂家设计的,所以它们的地址也是被分开的。
在 STM32 中,所有的存储器都被安排到了 0 到 8 个 F,这个地址范围内,因为 CPU 是 32 位的,所以寻址范围就是 32 位的范围,32 位的寻址范围是非常大的,最大可以支持 4 GB 容量的存储器,而我们 STM32 的存储器都是 KB 级别的,所以这个 4 GB 的寻址空间,会有大量的地址都是空的,目前地址的使用率还不到 1%。
0 地址实际上也是没有存储器的,它这里写的是别名到 Flash 或者系统存储器,取决于 BOOT 引脚。因为程序是从 0 地址开始运行的,所以这里需要把我们想要执行的程序,映射到 0 地址来,如果映射在 Flash 区,就是从 Flash 执行;如果映射在系统存储器区,就是从系统存储器运行 BootLoader;如果映射到 SRAM,就是从 SRAM 启动。怎么选择,由 BOOT1 和 BOOT2 两个引脚来决定,这就是 0 地址里的别名区
存储器包含两个位段区域,这两个位段区,映射了外设寄存器和 SRAM 中全部的位,这个位段区就相当于是位寻址,它把外设寄存器和 SRAM 中所有的位都分配了地址,你操作了这个新的地址,就相当于操作其中某一个位,因为 32 位的地址有 99% 都是空的,所以地址空间很充足,即使把每一位都单独编码,那也毫无压力,所以就存在了这样一个位段,用于单独操作寄存器或 SRAM 的某一位,位段区是另找了一个地方,开辟了一段地址区域。
其中 SRAM 位段区是 2200 开头的区域,外设寄存器的位段区是 4200 开头的区域,需要用的话了解一下。
1.3 DMA 框图
- 左上角是 Cortex-M3 内核,里面包含了 CPU 和内核外设等等,剩下的这所有东西,你都可以把它看成是存储器,所以总共就是 CPU 和存储器两个东西。
- Flash 是主闪存,SRAM 是运行内存,各个外设都可以看成是寄存器,也是一种 SRAM 存储器,寄存器是一种特殊的存储器,一方面 CPU 可以对寄存器进行读写,就像读写运行内存一样;另一方面,寄存器的每一位背后,都连接了一根导线,这些导线可以用于控制外设电路的状态,比如置引脚的高低电平、导通和断开开关、切换数据选择器或者多位结合起来,当作计数器、数据寄存器等等。所以寄存器是连接软件和硬件的桥梁,软件读写寄存器,就相当于在控制硬件的执行。既然外设就是寄存器,寄存器就是存储器,那使用 DMA 进行数据转运,就都可以归为一类问题了,就是从某个地址取内容,再放到另一个地址去。
- 我们看图,为了高效有条理的访问存储器,设计了一个总线矩阵。总线矩阵的左端是主动单元,也就是拥有存储器的访问权;右边这些是被动单元,它们的存储器只能被左边的主动单元读写。主动单元这里,内核有 DCode 和系统总线,可以访问右边的存储器,其中 DCode 总线是专门访问 Flash 的,系统总线是访问其他东西的。另外,由于 DMA 要转运数据,所以 DMA 也必须要有访问的主动权,那主动单元,除了内核 CPU,剩下的就是 DMA 总线了,这里 DMA1 有一条 DMA 总线,DMA2 也有一条 DMA 总线,下面还有一条 DMA 总线,这是以太网外设自己私有的 DMA,可以不用管的。在 DMA1 和 DMA2 里面可以看到,DMA1 有 7 个通道,DMA2 有 5 个通道,各个通道可以分别设置它们转运数据的源地址和目的地址,这样它们就可以各自独立的工作了。
- 接着下面这里有个仲裁器,这是因为虽然多个通道可以独立转运数据,但是最终 DMA 总线只有一条,所以所有的通道都只能分时复用这一条 DMA 总线,如果产生了冲突,那就会由仲裁器,根据通道的优先级,来决定谁先用,谁后用。另外在总线矩阵这里,也会有个仲裁器,如果 DMA 和 CPU 都要访问同一个目标,那么 DMA 就会暂停 CPU 的访问,以防止冲突,不过总线仲裁器,仍然会保证 CPU 得到一半的总线带宽,使 CPU 也能正常工作,这就是仲裁器的作用
- 接着下面这里是 AHB 从设备,也就是 DMA 自身的寄存器,因为 DMA 作为一个外设,它自己也会有相应的配置寄存器,这里连接在了总线右边的 AHB 总线上。所以 DMA 既是总线矩阵的主动单元,可以读写各种存储器,也是 AHB 总线上的被动单元,CPU 通过这一条线路,就可以对 DMA 进行配置了。
- 接着继续看 DMA 请求,请求就是触发的意思,这条线路右边的触发源是各个外设,所以这个 DMA 请求就是 DMA 的硬件触发源,比如 ADC 转换完成、串口接收到数据,需要触发 DMA 转运数据的时候,就会通过这条线路,向 DMA 发出硬件触发信号,之后 DMA 就可以执行数据转运的工作了,这就是 DMA 请求的作用。
有关 DMA 结构总结而言,其中包括用于访问各个存储器的 DMA 总线;内部的多个通道,可以进行独立的数据转运;仲裁器,用于调度各个通道,防止产生冲突;AHB 从设备,用于配置 DMA 参数;DMA 请求,用于硬件触发 DMA 的数据转运。这就是这个 DMA 的各个部分和作用。
Flash 是 ROM 只读存储器的一种,如果通过总线直接访问的话,无论是 CPU,还是 DMA,都是只读的,只能读取数据,而不能写入。如果 DMA 的目的地址,填了 Flash 的区域,那转运时,就会出错,这个注意一下。(当读写一个保留的地址区域,将会产生 DMA 传输错误)
当然 Flash 也不是绝对的不可写入,我们可以配置这个 Flash 接口控制器,对 Flash 进行写入,这个流程就比较麻烦了,要先对 Flash 按页进行擦除,再写入数据,不过这是另一个课题了,这里不再讨论。
总之就是 CPU 或者 DMA 直接访问 Flash 的话,是只可以读而不可以写的,然后 SRAM 是运行内存,可以任意读写,没有问题,外设寄存器的话,得看参考手册里面的描述,有的寄存器是只读的,有的寄存器是只写的,不过我们主要用的是数据寄存器,数据寄存器都是可以正常读写的。
1.4 DMA 基本结构
如果想编写代码实际去控制 DMA 的话,那 DMA 基本结构图就是必不可少的。
DMA 框图只是一个笼统的结构图,对于 DMA 内部的执行细节,它还是没体现出来,所以我们再来分析一下这个图,看看 DMA 具体是怎么工作的。
在这个图里,左边是外设寄存器站点,右边是存储器站点,包括 Flash 和 SRAM,这两就是数据转运的两大站点。
- 可以看到,DMA 的数据转运,可以是从外设到存储器,也是可以从存储器到外设,具体是向左还是向右,有一个方向的参数,可以进行控制。另外还有一种转运方式,就是存储器到存储器,比如 Flash 到 SRAM 或者 SRAM 到 SRAM 这两种方式,由于 Flash 是只读的,所以 DMA 不可以进行SRAM 到 Flash,或者 Flash 到 Flash 的转运操作。
在 STM32 手册里,他所说的存储器,一般是特指 Flash 和 SRAM,不包含外设寄存器,外设寄存器一般直接称作外设,虽然我们刚才说了,寄存器也是存储器的一种,但是 STM32 还是使用了外设和存储器来作为区分。
- 然后我们继续看外设和存储器的参数,既然要进行数据转运,那肯定就要指定从哪里转到哪里,具体怎么转了,所以外设和存储器两个站点都有 3 个参数:
- 第一个是起始地址,有外设端的起始地址和存储器端的起始地址,这两个参数决定了数据是从哪里来的,到哪里去的;
- 之后第二个参数是数据宽度,这个参数的作用是,指定一次转运要按多大的数据宽度来进行,它可以选择字节 Byte、半字 HaldWord 和字 Word,字节就是 8 位,也就是一次转运一个 uint8_t,这么大的数据,半字是 16 位,也就是一次转运一个 uint16_t 这么大的数据,字是 32 位,也就是一次转运一个 uint32_t 这么大的数据,比如转运 ADC 的数据,ADC 的结果是 uint16_t 这么大,所以这个参数就要选择半字,一次转运一个 uint16_t,这样才对;
- 第三个参数,是地址是否自增,这个参数的作用是,指定一次转运完成后,下一次转运,是不是要把地址移动到下一个位置去,这就相当于是指针 p++ 这个意思,比如 ADC 扫描模式,用 DMA 进行数据转运,外设地址是 ADC_DR 寄存器,寄存器这边,显然地址是不用自增的,如果自增,那下一次转运就跑到别的寄存器里去了;存储器这边,地址就需要自增,每转运一个数据后,就往后挪个坑,要不然下次再转就把上次的覆盖掉了,这就是地址是否自增的作用,就是指定,是不是要转运一次挪个坑,这个意思。
这就是外设站点和存储器站点各自的 3 个参数了,如果要进行存储器到存储器的数据转运,那我们就需要把其中一个存储器的地址,放在外设的这个站点,这样就能进行存储器到存储器的转运了。只要你在外设起始地址里写 Flash 或者 SRAM 的地址,那它就会去 Flash 或 SRAM 找数据,这个站点虽然叫外设寄存器,但是它就只是个名字而已,并不是说这个地址只能写寄存器的地址,如果写 Flash 的地址,那它就会去 Flash 里找;写 SRAM 的地址,那它就会去 SRAM 里找,这个没有限制,甚至你可以在外设站点写存储器的地址,存储器站点写外设的地址,然后方向参数给反过来,这样也是可以的,只是 ST 公司给它起了这样的名字而已,你也可以把它叫做站点 A、站点 B,从 A 到 B 或者 从 B 到 A 转运数据,不必拘泥于它写的外设站点、存储器站点这个名字。
- 接着往下面看有个传输计数器,这个东西就是用来指定,总共需要转运几次的,这个传输计数器是一个自减计数器,比如你给它写个 5,那 DMA 就只能进行 5 次数据转运。转运过程中,每转运一次,计数器的数就会减 1,当传输计数器减到 0 之后,DMA 就不会再进行数据转运了,另外,它减到 0 之后,之前自增的地址,也会恢复到起始地址的位置,以方便之后 DMA 开始新一轮的转运。在传输计数器的右边,有一个自动重装器,这个自动重装器的作用就是,传输计数器减到 0 之后,是否要自动恢复到最初的值,比如最初传输计数器给 5,如果不使用自动重装器,那转运 5 次后,DMA 就结束了;如果使用自动重装值,那转运 5 次,计数器减到 0 后,就会立刻重装到初始值 5,这个就是自动重装器,它决定了转运的模式。如果不重装,就是正常的单次模式,如果重载,就是循环模式,比如如果你想转运一个数组,那一般就是单次模式,转运一轮,就结束了;如果是 ADC 扫描模式 + 连续转换,那为了配合 ADC,DMA 也需要使用循环模式,所以这个循环模式和 ADC 的连续模式差不多,都是指定一轮工作完成后,是不是立即开始下一轮工作。
传输计数器只有低 16 位有效,范围是 0~65535,所以传输个数最多是 65535 个。
- 最下面就是 DMA 的触发控制了,触发就是决定 DMA 需要在什么时机进行转运的,触发源有硬件触发和软件触发,具体选择哪个,由 M2M 这个参数决定,M2M 就是 Memory to Memory,因为 2 的英文 two,和 to 同音,所以 M2M 就是 M to M,存储器到存储器的意思。当我们给 M2M 位 1 时,DMA 就会选择软件触发,这个软件触发并不是调用某个函数一次,触发一次。它这个软件触发的执行逻辑是,以最快的速度,连续不断的触发 DMA,争取早日把传输计数器清零,完成这一轮的转换,所以这里的软件触发,和我们之前外部中断和 ADC 的软件触发可能不太一样,你可以把它理解成连续触发,那这个软件触发和循环模式,不能同时用,因为软件触发就是想把传输计数器清零,循环模式是清零后自动重装,如果同时用的话,那 DMA 就停不下来了。软件触发一般适用于存储器到存储器的转运,因为存储器到存储器的转运,是软件启动,不需要时机,并且想尽快完成的任务,所以软件触发就是应用在存储器到存储器转运的情况。当 M2M 位给 0 时,就是使用硬件触发了,硬件触发源可以选择 ADC、串口、定时器等等,使用硬件触发的转运,一般都是与外设有关的转运,这些转运需要一定的时机,比如 ADC 转换完成、串口收到数据、定时时间到等等,所以需要使用硬件触发,在硬件达到这些时机时,传一个信号过来,来触发 DMA 进行转运,这就是硬件触发。
- 最后就是开关控制,也就是 DMA_Cmd 函数,当给 DMA 使能后,DMA 就准备就绪,可以进行转运了,DMA 进行转运,有几个条件:
- 开关控制,DMA_Cmd 必须使能
- 传输计数器必须大于 0
- 触发源,必须有触发信号。
触发一次,转运一次,传输计数器自减一次,当传输计数器等于 0,且没有自动重装时,这时无论是否触发,DMA 都不会再进行转运了,此时就需要 DMA_Cmd,给 DISABLE,关闭 DMA,再为传输计数器写入一个大于 0 的数,再 DMA_Cmd,给 ENABLE,开启 DMA,DMA 才能进行工作。注意一下,写传输计数器时,必须要先关闭 DMA,再进行,不能在 DMA 开启时,写传输计数器,这是手册里的规定
每个 DMA 通道都可以在 DMA 传输过半、传输完成和传输错误时产生中断。发送中断后,在中断状态寄存器里置标志位,如果配置了 NVIC,就会向 CPU 申请中断。之后自动标志位清除,用于清除中断标志位。
1.5 DMA 请求
对应于 DMA 基本结构中的 DMA 触发的部分。
- 这张图是 DMA1 的请求映像,下面是 DMA 的 7 个通道,每个通道都有一个数据选择器,可以选择硬件触发或软件触发。一般数据选择器的侧边的输入选择控制位,但是这里左边写的是软件触发(MEM2MEM位),M2M 位是数据选择器的控制位,用于选择是硬件触发还是软件触发,当 M2M 位 = 1 时,选择软件触发;而 EN 位是并不是数据选择器的控制位,而是开关控制,决定这个数据选择器要不要工作,EN = 0 时不工作,EN = 1 时工作。
- 继续看左边的硬件触发源,这里是外设请求信号,可以看到,每个通道的硬件触发源都是不同的,如果你需要用 ADC1 来触发的话,那就必须选择通道 1,如果需要定时器 2 的更新事件来触发的话,那就必须选择通道 2,剩下的也是同理。因为每个通道的硬件触发源都不同,所以如果你想使用某个硬件触发源的话,就必须使用它所在的通道,这就是硬件触发的注意事项。而如果使用软件触发的话,那通道就可以任意选择了,因为每个通道的软件触发都是一样的。选择哪个触发源是由对应的外设是否开启了 DMA 输出来决定的,比如你要使用 ADC1,那会有个库函数叫 ADC_DMACmd,必须使用这个库函数开启 ADC1 这一路的输出,它才有效;如果想选择定时器 2 的通道 3,那也会有个 TIM_DMACmd 函数,用来进行 DMA 输出控制,所以触发源使用哪个取决于你把哪个外设的 DMA 输出开启了,如果 3 个都开启了,那这边是一个或门,理论上 3 个硬件都可以进行触发,不过一般情况下,我们都是开启其中一个。。
- 之后,这 7 个触发源,进入到仲裁器,进行优先级判断,最终产生内部 DMA1 请求,这个优先级的判断,类似于中断的优先级,默认优先级是通道号越小,优先级越高;当然也可以在程序中配置优先级,这个其实影响并不是很大,大家了解一下即可。
1.6 数据宽度与对齐
两个站点都有数据宽度的参数,如果数据宽度都一样,那就是正常的一个个转运。如果数据宽度不一样,那会怎么处理呢?这个表就是来说明这个问题的;第一列是源端宽度,第二列是目标宽度,第三列是传输数目。
- 当源端和目标都是 8 位时,转运第一步,在源端的 0 位置,读数据 B0,在目标的 0 位置,写数据 B0,就是把这个 B0,从左边挪到右边。之后的步骤就是,把 B1 从左边挪到右边,接着 B2、B3,这是源端和目标都是 8 位的情况,操作也很正常。
- 当源端是 8 位,目标是 16 位时,转运第一步,在源端的 0 位置,读数据 B0,在目标的 0 位置,写数据 00B0。之后的步骤就是,读数据 B1,写数据 00B1,等等,如果你目标的数据宽度,比源端的数据宽度大,那就再目标数据前面多出来的空位补 0,之后 8 位 转运到 32 位,也是一样的处理,前面空出来的都补 0。
- 当目标数据宽度,比源端数据宽度小时,比如,由 16 位转到 8 位去,现象就是,读 B1B0,只写入 B0;读 B3B2,只写入 B2,也就是把多出来的高位舍弃掉,之后的各种情况,也都是类似的操作。
总之,这个表的意思就是,如果你把小的数据转到大的里面去,高位就会补 0;如果把大的数据转到小的里面去,高位就会舍弃掉,如果数据宽度一样,那就没事。就是跟 uint8_t、uint16_t 和 uint32_t 变量之间相互赋值一样,不够就补 0,超了就舍弃高位。
1.7 DMA 转运实例
1.7.1 数据转运 + DMA
任务:存储器到存储器的数据转运,将 SRAM 里的数据 DataA,转运到另一个数组 DataB 中。这种情况下,DMA 基本结构里的各个参数该如何配置?
- 首先是外设站点、存储器站点的起始地址、数据宽度、地址是否自增这三个参数。在这个任务里,外设地址显然应该填 DataA 数组的首地址,存储器地址,给 DataB 数组的首地址;然后数据宽度,两个数组的类型都是 uint8_t,所以数据宽度都是按 8 位的字节传输,之后地址是否自增,在中间可以看到,我们想要的效果是 DataA[0] 转到 DataB[0],DataA[1] 转到 DataB[1],等等,两个数组的位置一一对应,所以转运完 DataA[0] 和 DataB[0] 之后,两个站点的地址都应该自增,都移动到下一个数据的位置,继续转运 DataA[1] 和 DataB[1] ,这样来进行。如果左边不自增,右边自增,转运完成后,DataB 的所有数据都会等于 DataA[0];如果左边自增,右边不自增,转运完成后,DataB[0] 等于 DataA 的最后一个数,DataB 其他的数不变。如果左右都不自增,那就一直是 DataA[0] 转到 DataB[0],其他的数据不变,这就是地址是否自增的效果。
- 之后,方向参数显然是外设站点转运到存储器站点,如果你想要把 DataB 的数据转运到 DataA,那可以把方向参数换过来,这样就是反向转运了。
- 然后是传输计数器和是否要自动重装,这里显然要转运 7 次,所以传输计数器给 7,自动重装暂时不需要。
- 之后触发选择部分,这里我们要使用软件触发,因为这是存储器到存储器的数据转运,是不需要等待硬件时机的,尽快转运完成就行了。
- 最后,调用 DMA_Cmd 给 DMA 使能,这样数据就会从 DataA 转运到 DataB 了。
转运 7 次之后,传输计数器自减到 0,DMA 停止,转运完成。这里的数据转运是一种复制转运,转运完成后 DataA 的数据并不会消失,这个过程相当于把 DataA 的数据复制到了 DataB 的位置。
1.7.2 ADC 扫描模式 + DMA
任务:ADC 扫描模式和 DMA 配合使用的流程。一般来说,DMA 最常见的用途就是配合 ADC 的扫描模式,因为 ADC 扫描模式有个数据覆盖的特征,或者可以说这个数据覆盖的问题是 ADC 固有的缺陷,这个缺陷使 ADC 和 DMA 成为了最常见的伙伴,ADC 对 DMA 的需求是非常强烈的。像其他的一些外设,使用 DMA 可以提高效率,是锦上添花的操作,但是不使用也是可以的,顶多是损失一些性能。但是这个 ADC 的扫描模式,如果不使用 DMA,功能都会受到很大的限制,所以 ADC 和 DMA 的结合最为常见
左边是 ADC 扫描模式的执行流程,在这个有 7 个通道,触发一次后,7 个通道依次进行 AD 转换,然后转换结果都放到 ADC_DR 数据寄存器里面,那我们要做的就是,在每个单独的通道转换完成后,进行一次 DMA 数据转运,并且目的地址进行自增,这样数据就不会被覆盖了。
所以在这里 DMA 的配置就是:外设地址,写入 ADC_DR 这个寄存器的地址,存储器的地址,可以在 SRAM 中定义一个数组 ADValue,然后把 ADValue 的地址当作存储器的地址,之后数据宽度,因为 ADC_DR 和 SRAM 数组我们要的是 uint16_t 的数据,所以数据宽度都是 16 位的半字传输。继续是地址是否自增,从这个图里,显然是外设地址不自增,存储器地址自增。传输方向是外设站点到存储器站点。传输计数器,这里通道有 7 个,所以计数 7 次,计数器是否自动重装,这里可以看 ADC 的配置,ADC 如果是单次扫描,那 DMA 的传输计数器可以不自动重装,转换一轮就停止;如果 ADC 是连续扫描,那 DMA 就可以使用自动重装,在 ADC 启动下一轮转换的时候,DMA 也启动下一轮的转运,ADC 和 DMA 同步工作,最后是触发选择,ADC_DR 的值是在 ADC 单个通道转换完成后才会有效的,所以 DMA 转运的时机,需要和 ADC 单个通道转换完成同步,所以 DMA 的触发要选择 ADC 的硬件触发。
最后硬件触发这里说明一下,ADC 扫描模式,在每个单独的通道转换完成后,没有任何标志位,也不会触发中断,所以我们程序不太好判断,某一个通道转换完成的时机是什么时候。但是虽然单个通道转换完成后,不产生任何标志位和中断,但是它会产生 DMA 请求,去触发 DMA 转运,这部分内容,手册里并没有详细描述。
2. 两个 DMA 转运功能案例
2.1 DMA 数据转运
2.1.1 硬件电路图
数据转运都是在 STM32 内部进行,所以外围电路就在一个 OLED 显示一下就行了。其他的模块都不需要。
2.1.2 存储器映像验证
看一下我们定义的数据是不是真的存储在了这相应的地址区间里。
本节是有关存储器地址、常量和变量、结构体访问寄存器这些知识点的一些扩展内容。
- 常量和变量
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
const uint8_t aa = 0x66;
const uint8_t bb = 0x66;
int main(void) {
OLED_Init();
//正常的定义变量,引用变量
OLED_ShowHexNum(1, 1, aa, 2);
//显示变量的地址,8 个 16 进制的数表示 32 位(32 位系统)
OLED_ShowHexNum(2, 1, (uint32_t)&aa, 8);//不加强制类型转换,就是指针跨级赋值了,编译的时候会给报警告
OLED_ShowHexNum(3, 1, (uint32_t)&bb, 8);
while(1){
}
}
- aa 变量的地址是 20000000,地址以 20 开头,所以 aa 变量存储的位置是 SRAM 区,在 SRAM 区,它的地址肯定是以 20 开头的,具体地址由编译器来确定。目前 SRAM 区没什么东西,所以编译器就把这个变量放在了 SRAM 区的第一个位置。
- const 是 C 语言的关键字,表示的是常量的意思,被 const 修饰的变量,在程序中,只能读,不能写,而 Flash 里面的数据也是只能读,不能写,所以 const 和 Flash 就联系起来了。在 STM32 中,使用 const 修饰的常变量是存储在 Flash 里面的,这个常变量的值只能在定义的时候给 aa 常变量的地址,就变成了 08 开头的,所以 aa 常变量被存储在了 Flash 里,在 Flash 里存储的是程序代码,当然还有常量数据,这里地址尾部有些偏移,不像 SRAM 里那样,直接安排在第一个位置,这是因为 Flash 里还有程序代码的东西,放在了前面,所以编译器给这个常量安排的地址,就相对靠后了一些,这就是定义变量和常量的方法。
- 正常情况下,我们使用的都是变量,直接定义就行,不需要加 const,那什么时候需要定义常量呢?这个是当我们程序中出现了一大批数据,并且不需要更改时,就可以把它定义成常量,这样能节省 SRAM 的空间,比如查找表、字库数据等等。字库是一个数组,它里面的数据决定了每个字符应该显示哪些像素点,这个数组非常长,而且是不需要更改的,所以可以加一个 const,把它定义在 Flash 里面,这样就可以节省 SRAM 的空间。如果一不小心,把 const 去掉了,那程序功能并不会有任何影响,但是 SRAM 里会有和这个数组一样大的空间被浪费掉,如果数组很小,那影响也不大,但是如果数组很大,那就得考虑一下,SRAM 是不是消耗的起了,这就是 const 关键字的用途。如果你有一个很大的查找表或者字符,最好加一个 const。
- 寄存器
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
int main(void) {
OLED_Init();
OLED_ShowHexNum(4, 1, (uint32_t)&ADC1->DR, 8);
while(1){
}
}
接下来我们再研究下外设寄存器的地址,对于变量或者常量来说,它的地址是由编译器确定的,不同的程序,地址可能不一样,是不固定的。对于外设寄存器来说,它的地址是固定的,在手册里都能查得到,在程序里,也可以用结构体,很方便的访问寄存器。比如要访问 ADC1 的 DR 寄存器就可以写 ADC1->DR,这样就可以访问 ADC1 的 DR 寄存器了,可以看到它的地址是 4001 244C,是 40 开头的,对照 PPT 的表,可以知道它确实是外设寄存器的区域,这个具体地址 4001 244C 是固定的,在手册里可以查到,如果你想算某个寄存器的地址,就可以查手册计算一下(首先查一下这个寄存器所在外设的起始地址,然后再在外设的寄存器总表里,查一下偏移,起始地址 + 偏移就是这个寄存器的实际地址。
ADC1->DR 如何知道 ADC1_DR 寄存器的地址的呢?这种结构体的方式,又是如何访问到寄存器的呢?
- ADC1 是 ((ADC_TypeDef *) ADC1_BASE),左边是一个强制类型转换,把 ADC1_BASE 转换为了 ADC_TypeDef 类型的指针右边 ADC1_BASE 就是 ADC1 的基地址,基地址就是起始地址的意思,那就是查表看到的 4001 2400,转到定义看一下,ADC1 的基地址就是 APB2 外设基地址(APB2PERIPH_BASE) + 0x2400,再转到定义,APB2 外设基地址 就是 外设基地址(PERIPH_BASE) + 0x10000,再转到定义,外设基地址就是 ((uint32_t)0x40000000)。
- 回过来看,外设基地址(4000 0000) + 10000 = 4001 0000 就是 APB2 外设基地址,再回过来,再 + 2400 = 4001 2400 就得到了 ADC1 的基地址。
- 再回过来,现在基地址有了,但是基地址 + 偏移,才是寄存器的实际地址,在这里,它使用了一个非常巧妙的方法来实现这个偏移,就是使用结构体,来实现我们跳转到结构体的定义,可以看到依次定义了各种寄存器,这个结构体成员的顺序,看一下手册,和寄存器实际存放的顺序,是一一对应的,SR、CR1、CR2 等等,在结构体,也是 SR、CR1、CR2 等等,所以说,如果我们定义一个 ADC 结构体的指针,并且指针的地址就是这个外设的起始地址,那这个结构体的每个成员,就会正好映射实际的每个寄存器,那么这个结构体的内存和外设寄存器的内存就会完美重合,我在访问结构体的每个成员,就相当于访问这个外设的某个寄存器,这就是 STM32 中是用结构体来访问寄存器的流程。
所以 ADC1 是结构体指针,指向的是 ADC1 外设的起始地址,访问结构体成员,就相当于是加一个地址偏移,起始地址加偏移,就是指定的寄存器。
不得不说,STM32 这个库函数,把访问一个寄存器做的还是比较麻烦的,其实如果你想简单点的话,直接用指针访问某个物理地址就行了。
#define ADC1_DR (uint32_t*)0x4001244C
*ADC1_DR
,这样也是可以访问 ADC1 的 DR 寄存器,和这个结构体访问的效果,是一模一样的。
2.1.3 DMA 转运模块初始化
DMA 不涉及外围硬件电路,所以可以在 System 里添加。(为了防止与库函数里面的 DMA 重复,起名 MyDMA)
- DMA 转运模块的库函数(在 dma.h 中)
void DMA_DeInit(DMA_Channel_TypeDef* DMAy_Channelx);//恢复缺省配置
void DMA_Init(DMA_Channel_TypeDef* DMAy_Channelx, DMA_InitTypeDef* DMA_InitStruct);//初始化,把结构体指定的参数,配置到 DMAx 的通道 y 里面去
void DMA_StructInit(DMA_InitTypeDef* DMA_InitStruct);//结构体初始化
void DMA_Cmd(DMA_Channel_TypeDef* DMAy_Channelx, FunctionalState NewState);//使能
void DMA_ITConfig(DMA_Channel_TypeDef* DMAy_Channelx, uint32_t DMA_IT, FunctionalState NewState);//中断输出使能
void DMA_SetCurrDataCounter(DMA_Channel_TypeDef* DMAy_Channelx, uint16_t DataNumber); //用于给传输计数器写数据的
uint16_t DMA_GetCurrDataCounter(DMA_Channel_TypeDef* DMAy_Channelx);//用于返回传输计数器的值,如果想看看,还剩多少数据没有转运,就可以调用这个函数获取传输计数器的值。
FlagStatus DMA_GetFlagStatus(uint32_t DMAy_FLAG);//获取标志位状态
void DMA_ClearFlag(uint32_t DMAy_FLAG);//清除标志位
ITStatus DMA_GetITStatus(uint32_t DMAy_IT);//获取中断状态
void DMA_ClearITPendingBit(uint32_t DMAy_IT);//清除中断挂起位
- 配置 DMA 转运模块
- RCC 开启 DMA 的时钟
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);//DMA 是 AHB 总线的设备,所以要用 AHB 开启时钟的函数
第一个参数(互联型设备参数表/其他设备参数表):互联型设备是 STM32F105/107 的型号,我们使用的是 F103,所以在其他设备的参数表里选
- 直接调用 DMA_Init,初始化各个参数(包括外设和存储器站点的起始地址、数据宽度、地址是否自增,方向,传输计数器,是否需要自动重装,选择触发源,还有通道优先级),通过一个结构体,就可以配置好了。
DMA_InitTypeDef DMA_InitStructure;
DMA_InitStructure.DMA_PeripheralBaseAddr = AddrA;//外设站点的起始地址(32 位地址),
DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;//外设站点的数据宽度
DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Enable;//外设站点是否自增
DMA_InitStructure.DMA_MemoryBaseAddr = AddrB;//存储器站点的起始地址
DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte;//存储器站点的数据宽度
DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;//存储器站点是否自增
DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;//传输方向,指定外设站点是源端还是目的地,选择外设站点作为源端,传输方向是外设站点到存储器站点
DMA_InitStructure.DMA_BufferSize = size;//缓存区大小,以数据单元指定缓冲区,就是说你要传送几个数据单元,这个数据单元,等于你传输源端站点的 DataSize。
DMA_InitStructure.DMA_Mode = DMA_Mode_Normal;//传输模式(循环模式/正常模式),其实就是指定传输计数器是否使用自动重装
DMA_InitStructure.DMA_M2M = DMA_M2M_Enable;//选择是否应用于存储器到存储器的转运(软件触发),其实就是选择硬件触发还是软件触发
DMA_InitStructure.DMA_Priority = DMA_Priority_Medium;//优先级(非常高/高/中等/低),按照参数要求,确定优先级即可。(如果有多个通道,可以指定一下优先级,确保紧急的转运有更高的优先级)
DMA_Init(DMA1_Channel1, &DMA_InitStructure);//第一个参数,DMAy_ChannelX,y 可以是 1 或 2,用来选择是哪个 DMA,x 用来选择是哪一个通道,对于 DMA 1 而言,可以是 1~7,对于 DMA 2 而言,可以是 1~5
注意事项:
- 对于 SRAM 的数组,它的地址是编译器分配的,并不是固定的,所以我们一般不会写绝对地址,而是通过数组名来获取地址。
- 缓存区大小,说简单点其实就是传输计数器,指定传输几次。在源码中这个参数直接赋值给了传输计数器的寄存器,它的取值是 0 ~65535。
- 循环模式不能应用在存储器到存储器的情况下,也就是自动重装和软件触发不能同时使用,如果同时使用,DMA 就会连续触发,永远也不会停下来。
- DMA_Init 的第一个参数,既选择了是哪个 DMA,也选择了是 DMA 的哪个通道。这里因为是存储器到存储器的转运,用的是软件触发,所以通道可以任意选择。这里就给通道 1 吧
DMA 转运三个条件:传输计数器 > 0,触发源有触发信号,DMA 使能,三个条件缺一不可
- 传一个 > 0 的 size,第一个条件满足
- 触发源为软件触发,所以一直都有触发信号,第二个条件满足
- DMA 还没有使能,第三个条件不满足,所以 DMA 还不会工作
- 进行开关控制,DMA_Cmd,给指定的通道使能,就完成了。
- 如果选择硬件触发,不要忘记在对应的外设调用一下 XXX_DMACmd 开启一下触发信号的输出
- 如果你需要 DMA 的中断,那就调用 DMA_ITConfig,开启中断输出,再在 NVIC 里,配置相应的中断通道,然后写中断函数就行了,中断的配置各个外设都一样
DMA_Cmd(DMA1_Channel1, ENABLE);
如果想在初始化之后就立刻工作,可以在初始化函数最后加上。这样的话初始化就会后立刻开始转运,并且转运一次之后,DMA 就停止了
使能 DMA 之后,三个条件满足,DMA 就会进行数据转运了,转运一次,传输计数器自减一次,当传输计数器减到 0 之后,转运完成,同时第一个条件就不满足了,转运停止。这样就完成了一次数组之间的数据转运,最后在运行的过程中,如果转运完成,传输计数器清 0 了,这时再想给传输计数器赋值的话,就 DMA 失能,写传输计数器,DMA 使能,这样就行了,这就是 DMA 的编程思路。
- 如果 DataA 的数据有变化了,我们想再转运一次,那该怎么办呢?这时我们就需要给传输计数器重新赋值了
void MyDMA_Transfer(void) {//DMA 传输函数,调用一次这个函数,就再次启动一次 DMA 转运
//1. 我们需要重新给传输计数器赋值,必须要先给 DMA 失能
DMA_Cmd(DMA1_Channel1, DISABLE);
//2. 然后就可以给传输计数器赋值了
DMA_SetCurrDataCounter(DMA1_Channel1, MyDMA_Size);
//3. 最后再次调用 Cmd 函数给 DMA 使能
DMA_Cmd(DMA1_Channel1, ENABLE);//这样 DMA 转运的 3 个条件又重新满足了,DMA 就会再次开始转运
//4. 等待转运完成
while(DMA_GetFlagStatus(DMA1_FLAG_TC1) == RESET);//获取标志位状态,选择转运完成标志位。
//5. 不要忘了清除标志位,这个标志位是需要手动清除的。
DMA_ClearFlag(DMA1_FLAG_TC1);//清除标志位
}
DMA 所有的通道都是这 4 种标志位:全局标志位/转运完成标志位/转运过半标志位/转运错误标志位。
2.1.4 示例代码
Alt 键 + 鼠标左键可以以方框的形式进行框选。如果不按 Alt 键,那就是一行一行连续的框选。
MyDMA.h
#ifndef __MYDMA_H
#define __MYDMA_H
void MyDMA_Init(uint32_t AddrA, uint32_t AddrB, uint16_t size);
void MyDMA_Transfer(void);
#endif
MyDMA.c
#include "stm32f10x.h" // Device header
uint16_t MyDMA_Size;
void MyDMA_Init(uint32_t AddrA, uint32_t AddrB, uint16_t size) {//把地址提取成初始化函数的参数,在初始化时,想转运哪个数组,就把哪个数组的地址传进来就行了
MyDMA_Size = size;
//1.RCC 开启 DMA 的时钟
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);//DMA 是 AHB 总线的设备,所以要用 AHB 开启时钟的函数
//2.直接调用 DMA_Init,初始化各个参数(包括外设和存储器站点的起始地址、数据宽度、地址是否自增,方向,传输计数器,是否需要自动重装,选择触发源,还有通道优先级),通过一个结构体,就可以配置好了
DMA_InitTypeDef DMA_InitStructure;
DMA_InitStructure.DMA_PeripheralBaseAddr = AddrA;//外设站点的起始地址(32 位地址),
DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;//外设站点的数据宽度
DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Enable;//外设站点是否自增
DMA_InitStructure.DMA_MemoryBaseAddr = AddrB;//存储器站点的起始地址
DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte;//存储器站点的数据宽度
DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;//存储器站点是否自增
DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;//传输方向,指定外设站点是源端还是目的地,选择外设站点作为源端,传输方向是外设站点到存储器站点
DMA_InitStructure.DMA_BufferSize = size;//缓存区大小,说简单点其实就是传输计数器,指定传输几次。在源码中这个参数直接赋值给了传输计数器的寄存器,它的取值是 0 ~65535
DMA_InitStructure.DMA_Mode = DMA_Mode_Normal;//传输模式(循环模式/正常模式),其实就是指定传输计数器是否使用自动重装
DMA_InitStructure.DMA_M2M = DMA_M2M_Enable;//选择是否应用于存储器到存储器的转运(软件触发),其实就是选择硬件触发还是软件触发
DMA_InitStructure.DMA_Priority = DMA_Priority_Medium;//优先级(非常高/高/中等/低),按照参数要求,确定优先级即可。(如果有多个通道,可以指定一下优先级,确保紧急的转运有更高的优先级)
DMA_Init(DMA1_Channel1, &DMA_InitStructure);//第一个参数,DMAy_ChannelX,y 可以是 1 或 2,用来选择是哪个 DMA,x 用来选择是哪一个通道,对于 DMA 1 而言,可以是 1~7,对于 DMA 2 而言,可以是 1~5
//3.进行开关控制,DMA_Cmd,给指定的通道使能,就完成了。
DMA_Cmd(DMA1_Channel1, DISABLE);//不让 DMA 初始化后,就立刻开始转运,而是等调用了 Transfer 函数后,再进行转运,调用一次转运一次,这样来工作
//如果想在初始化之后就立刻工作,可以在这最后加上 ENABLE
}
void MyDMA_Transfer(void) {//DMA 传输函数,调用一次这个函数,就再次启动一次 DMA 转运
//1. 我们需要重新给传输计数器赋值,必须要先给 DMA 失能
DMA_Cmd(DMA1_Channel1, DISABLE);
//2. 然后就可以给传输计数器赋值了
DMA_SetCurrDataCounter(DMA1_Channel1, MyDMA_Size);
//3. 最后再次调用 Cmd 函数给 DMA 使能
DMA_Cmd(DMA1_Channel1, ENABLE);//这样 DMA 转运的 3 个条件又重新满足了,DMA 就会再次开始转运
//4. 等待转运完成
while(DMA_GetFlagStatus(DMA1_FLAG_TC1) == RESET);//获取标志位状态,选择转运完成标志位。
//5. 不要忘了清除标志位,这个标志位是需要手动清除的。
DMA_ClearFlag(DMA1_FLAG_TC1);//清除标志位
}
DMA 初始化后,不开始转运,而是等调用了 Transfer 函数后,再进行转运,调用一次转运一次,这样来工作
main.c
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "MyDMA.h"
uint8_t DataA[] = {0x01, 0x02, 0x03, 0x04};
uint8_t DataB[] = {0x00, 0x00, 0x00, 0x00};
int main(void) {
OLED_Init();
MyDMA_Init((uint32_t)DataA, (uint32_t)DataB, 4);//DMA 初始化后,不转运,而是等调用了 Transfer 函数后,再进行转运,调用一次转运一次,这样来工作
OLED_ShowString(1, 1, "DataA");
OLED_ShowString(3, 1, "DataB");
OLED_ShowHexNum(1, 8, (uint32_t)DataA, 8);//可能是模块里的 Size 变量被分配到了 0 的位置,所以这个地址就往后偏移了 2 个字节
OLED_ShowHexNum(3, 8, (uint32_t)DataB, 8);
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);
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);
MyDMA_Transfer();
OLED_ShowHexNum(2,1, DataA[0], 2);//在整个转运过程中,源端数据 DataA 是不会变化的
OLED_ShowHexNum(2,4, DataA[1], 2);
OLED_ShowHexNum(2,7, DataA[2], 2);
OLED_ShowHexNum(2,10, DataA[3], 2);
OLED_ShowHexNum(4,1, DataB[0], 2);
OLED_ShowHexNum(4,4, DataB[1], 2);
OLED_ShowHexNum(4,7, DataB[2], 2);
OLED_ShowHexNum(4,10, DataB[3], 2);
Delay_ms(1000);
}
}
如果想把 Flash 的数据转运到 SRAM,可以在 DataA 前面加一个 const:
const uint8_t DataA[] = {0x01, 0x02, 0x03, 0x04};
,把 DataA 定义在 Flash 里面。因为 const 数据不能重新更改,所以 DataA 的++部分就不能要了。然后编译下载。
- 此时可以看到,DataA 地址是 08 开头的,说明 DataA 是 Flash 里面的数据。
- 下面 DataB 的数据和 DataA 一样,说明 DataA 的数据成功转运到 DataB 里面来了。
实际上 DataA 中可能会有成千上万个数据,这样才能发挥 DMA 转运的优势,目前测试的话,先给 4 个就行了
2.2 DMA + AD 多通道
2.2.1 硬件电路图
这里的接线图和 AD 多通道是一样的,也是 PA0 接一个电位器,PA1~PA3 接三个传感器模块的 AO 输出。
2.1.2 ADC 单次扫描 + DMA 单次转运的程序代码
AD.h
#ifndef __AD_H
#define __AD_H
extern uint16_t AD_Value[4];//放在头文件中声明,作为外部可调用的数组
void AD_Init(void);
void AD_GetValue(void);
#endif
AD.c
#include "stm32f10x.h" // Device header
uint16_t AD_Value[4];
void AD_Init(void) {
//1.开启 RCC 时钟,包括 ADC、DMA 和 GPIO 时钟,另外 ADCCLK 的分频器也需要配置一下,
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);//ADC 都是 APB2 上的设备
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);//DMA 是 AHB 总线的设备,所以要用 AHB 开启时钟的函数
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_ADCCLKConfig(RCC_PCLK2_Div6);//配置 ADCCLK,PCLK2 就是 APB2时钟 的意思(72M / 6 = 12M)
//2.配置 GPIO,把需要用的 GPIO 配置成模拟输入的模式
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;//模拟输入模式,AIN 模式是 ADC 的专属模式,在 AIN 模式下,GPIO 口是无效的,断开 GPIO,防止你 GPIO 口的输入输出对我模拟电压的造成干扰
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);//GPIO 配置好默认低电平
//3.在转换前指定了转换的通道(点菜,把各个通道的菜,列在菜单里)
ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5);
ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_55Cycles5);
ADC_RegularChannelConfig(ADC1, ADC_Channel_2, 3, ADC_SampleTime_55Cycles5);
ADC_RegularChannelConfig(ADC1, ADC_Channel_3, 4, ADC_SampleTime_55Cycles5);
//4.配置 ADC 转换器,在库函数里,用结构体来配置的(包括ADC转换模式,有几个通道,触发源、数据对齐等等)
ADC_InitTypeDef ADC_InitStructure;
ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;// ADC 工作模式(独立模式/双 ADC 模式)。选择独立模式
ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;//数据对齐。选择右对齐
ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;//触发控制的触发源,定义用于启动规则组转换的外部触发源。不使用外部触发,使用内部软件触发
ADC_InitStructure.ADC_ContinuousConvMode = DISABLE;//连续转换模式,选择连续转换/单次转换(ENABLE/DISABLE)
ADC_InitStructure.ADC_ScanConvMode = ENABLE;//扫描转换模式,选择扫描模式/非扫描模式(ENABLE/DISABLE)
ADC_InitStructure.ADC_NbrOfChannel = 4;//规则组转换列表里通道数目(参数在 1~16),指定在扫描模式下,总共会用到几个通道。在非扫描的模式下,这个参数其实是没有用的,无论写多少数目,最终都是只有序列1 的位置有效。
ADC_Init(ADC1, &ADC_InitStructure);
//5.直接调用 DMA_Init,初始化各个参数
DMA_InitTypeDef DMA_InitStructure;
DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR;//外设站点的起始地址(32 位地址),这里就是 ADC_DR 的地址
DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;//外设站点的数据宽度,想要的是 ADC_DR 寄存器低 16 位的数据
DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;//外设站点是否自增,不自增
DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)AD_Value;//存储器站点的起始地址,我们想把数据存储在 SRAM 的数组里,这里是 数组的地址
DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;//存储器站点的数据宽度,也是半字halfword
DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;//存储器站点是否自增,这里存储器地址是自增的,每转运一次挪一个坑
DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;//传输方向,指定外设站点是源端还是目的地,选择外设站点作为源端,传输方向是外设站点到存储器站点
DMA_InitStructure.DMA_BufferSize = 4;//缓存区大小,说简单点其实就是传输计数器,指定传输几次。因为有 4 个 ADC 通道,所以传输 4 次
DMA_InitStructure.DMA_Mode = DMA_Mode_Normal;//传输模式(循环模式/正常模式),其实就是指定传输计数器是否使用自动重装
DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;//选择是否应用于存储器到存储器的转运(软件触发),其实就是选择硬件触发还是软件触发,我们需要硬件触发,触发源为 ADC1
DMA_InitStructure.DMA_Priority = DMA_Priority_Medium;//优先级(非常高/高/中等/低),按照参数要求,确定优先级即可。(如果有多个通道,可以指定一下优先级,确保紧急的转运有更高的优先级)
DMA_Init(DMA1_Channel1, &DMA_InitStructure);//这里通道需要根据硬件触发源来进行选择的。这里必须要使用 DMA1 的通道 1,其他的通道都不行
//6.进行开关控制,DMA_Cmd,给指定的通道使能。
DMA_Cmd(DMA1_Channel1, ENABLE);
//此时DMA转运的触发源有信号条件目前是不满足的,因为这里是硬件触发,ADC 还没启动,不会有触发信号。所以 DMA 使能之后不会立刻工作。
//7.开启 ADC 到 DMA 的输出(通道有多个触发源,具体使用哪个,取决于把哪个 DMA 输出给开启了)
ADC_DMACmd(ADC1, ENABLE);//用来开启 DMA 触发信号的
//到目前为止,ADC 和 DMA 配合工作的配置就完成了
//8.开关控制,调用ADC_Cmd 函数,开启 ADC,这样 ADC 就配置完成了,就能正常工作了
ADC_Cmd(ADC1, ENABLE);
//9.开启 ADC 之后,根据手册建议,对 ADC 进行一下校准,这样可以减小误差
ADC_ResetCalibration(ADC1);//复位校准
while (ADC_GetResetCalibrationStatus(ADC1) == SET);//获取复位校准状态,空循环等待校准完成。
//获取 CR2 寄存器里的 RSTCAL 标志位,软件置该位为 1,那硬件就会开始复位校准,当复位校准完成后,该位就会由硬件自动清 0。
ADC_StartCalibration(ADC1);//开始校准
while (ADC_GetCalibrationStatus(ADC1) == SET);//获取开始校准状态,空循环等待校准完成。
}
void AD_GetValue(void) {
//1.因为 DMA 是正常的单次模式,所以在触发 ADC 之前,需要再重新写一下传输计数器
DMA_Cmd(DMA1_Channel1, DISABLE);
DMA_SetCurrDataCounter(DMA1_Channel1, 4);
DMA_Cmd(DMA1_Channel1, ENABLE);
//2.软件触发转换(因为 ADC 还是单次模式,所以还是需要软件触发一下 ADC 开始)
ADC_SoftwareStartConvCmd(ADC1, ENABLE);
//3.最后等待 ADC 转换和 DMA 转运完成,这里因为转运总是在转换之后,所以等待 DMA 转运完成,等待 ADC 转换完成就不需要了
while(DMA_GetFlagStatus(DMA1_FLAG_TC1) == RESET);//获取标志位状态,选择转运完成标志位。
DMA_ClearFlag(DMA1_FLAG_TC1);//清除标志位
}
当我们调用 AD_GetValue 函数时,ADC 开始转换,连续扫描 4 个通道,DMA 也同步进行转运,AD 转换结果,依次放在 AD_Value 数组中。
- 这里通道需要根据硬件触发源来进行选择的。这里必须要使用 DMA1 的通道 1,其他的通道都不行。
- DMA_Cmd 给指定的通道使能以后,此时DMA转运的触发源有信号条件目前是不满足的,因为这里是硬件触发,ADC 还没启动,不会有触发信号。所以 DMA 使能之后不会立刻工作。
- 只有 开启 ADC 到 DMA 的输出后,ADC 和 DMA 配合工作的配置才完成了。
main.c
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "AD.h"
int main(void) {
OLED_Init();
AD_Init();
OLED_ShowString(1, 1, "AD0:");
OLED_ShowString(2, 1, "AD1:");
OLED_ShowString(3, 1, "AD2:");
OLED_ShowString(4, 1, "AD3:");
while(1){
AD_GetValue();
OLED_ShowNum(1, 5, AD_Value[0], 4);
OLED_ShowNum(2, 5, AD_Value[1], 4);
OLED_ShowNum(3, 5, AD_Value[2], 4);
OLED_ShowNum(4, 5, AD_Value[3], 4);
Delay_ms(100);
}
}
这就是 ADC 单次扫描 + DMA 单次转运的模式。
2.1.3 ADC 连续扫描 + DMA 循环转运的程序代码
那我们还可以配置成 ADC 连续扫描 + DMA 循环转运的模式,这样代码就更加方便。具体步骤是:
- ADC 连续模式 打开
- DMA 循环模式 打开
- ADC 软件触发直接放在初始化最后一行
- AD_GetValue 完全不需要了,直接删掉
AD.h
#ifndef __AD_H
#define __AD_H
extern uint16_t AD_Value[4];//放在头文件中声明,作为外部可调用的数组
void AD_Init(void);
#endif
AD.c
#include "stm32f10x.h" // Device header
uint16_t AD_Value[4];
void AD_Init(void) {
//1.开启 RCC 时钟,包括 ADC、DMA 和 GPIO 时钟,另外 ADCCLK 的分频器也需要配置一下,
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);//ADC 都是 APB2 上的设备
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);//DMA 是 AHB 总线的设备,所以要用 AHB 开启时钟的函数
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_ADCCLKConfig(RCC_PCLK2_Div6);//配置 ADCCLK,PCLK2 就是 APB2时钟 的意思(72M / 6 = 12M)
//2.配置 GPIO,把需要用的 GPIO 配置成模拟输入的模式
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;//模拟输入模式,AIN 模式是 ADC 的专属模式,在 AIN 模式下,GPIO 口是无效的,断开 GPIO,防止你 GPIO 口的输入输出对我模拟电压的造成干扰
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);//GPIO 配置好默认低电平
//3.在转换前指定了转换的通道(点菜,把各个通道的菜,列在菜单里)
ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5);
ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_55Cycles5);
ADC_RegularChannelConfig(ADC1, ADC_Channel_2, 3, ADC_SampleTime_55Cycles5);
ADC_RegularChannelConfig(ADC1, ADC_Channel_3, 4, ADC_SampleTime_55Cycles5);
//4.配置 ADC 转换器,在库函数里,用结构体来配置的(包括ADC转换模式,有几个通道,触发源、数据对齐等等)
ADC_InitTypeDef ADC_InitStructure;
ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;// ADC 工作模式(独立模式/双 ADC 模式)。选择独立模式
ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;//数据对齐。选择右对齐
ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;//触发控制的触发源,定义用于启动规则组转换的外部触发源。不使用外部触发,使用内部软件触发
ADC_InitStructure.ADC_ContinuousConvMode = ENABLE;//连续转换模式,选择连续转换/单次转换(ENABLE/DISABLE)
ADC_InitStructure.ADC_ScanConvMode = ENABLE;//扫描转换模式,选择扫描模式/非扫描模式(ENABLE/DISABLE)
ADC_InitStructure.ADC_NbrOfChannel = 4;//规则组转换列表里通道数目(参数在 1~16),指定在扫描模式下,总共会用到几个通道。在非扫描的模式下,这个参数其实是没有用的,无论写多少数目,最终都是只有序列1 的位置有效。
ADC_Init(ADC1, &ADC_InitStructure);
//5.直接调用 DMA_Init,初始化各个参数
DMA_InitTypeDef DMA_InitStructure;
DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR;//外设站点的起始地址(32 位地址),这里就是 ADC_DR 的地址
DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;//外设站点的数据宽度,想要的是 ADC_DR 寄存器低 16 位的数据
DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;//外设站点是否自增,不自增
DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)AD_Value;//存储器站点的起始地址,我们想把数据存储在 SRAM 的数组里,这里是 数组的地址
DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;//存储器站点的数据宽度,也是半字halfword
DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;//存储器站点是否自增,这里存储器地址是自增的,每转运一次挪一个坑
DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;//传输方向,指定外设站点是源端还是目的地,选择外设站点作为源端,传输方向是外设站点到存储器站点
DMA_InitStructure.DMA_BufferSize = 4;//缓存区大小,说简单点其实就是传输计数器,指定传输几次。因为有 4 个 ADC 通道,所以传输 4 次
DMA_InitStructure.DMA_Mode = DMA_Mode_Circular;//传输模式(循环模式/正常模式),其实就是指定传输计数器是否使用自动重装
DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;//选择是否应用于存储器到存储器的转运(软件触发),其实就是选择硬件触发还是软件触发,我们需要硬件触发,触发源为 ADC1
DMA_InitStructure.DMA_Priority = DMA_Priority_Medium;//优先级(非常高/高/中等/低),按照参数要求,确定优先级即可。(如果有多个通道,可以指定一下优先级,确保紧急的转运有更高的优先级)
DMA_Init(DMA1_Channel1, &DMA_InitStructure);//第一个参数,DMAy_ChannelX,y 可以是 1 或 2,用来选择是哪个 DMA,x 用来选择是哪一个通道,对于 DMA 1 而言,可以是 1~7,对于 DMA 2 而言,可以是 1~5
//这里通道需要根据硬件触发源来进行选择的。这里必须要使用 DMA1 的通道 1,其他的通道都不行
//6.进行开关控制,DMA_Cmd,给指定的通道使能。
DMA_Cmd(DMA1_Channel1, ENABLE);
//此时DMA转运的触发源有信号条件目前是不满足的,因为这里是硬件触发,ADC 还没启动,不会有触发信号。所以 DMA 使能之后不会立刻工作。
//7.开启 ADC 到 DMA 的输出(通道有多个触发源,具体使用哪个,取决于把哪个 DMA 输出给开启了)
ADC_DMACmd(ADC1, ENABLE);//用来开启 DMA 触发信号的
//到目前为止,ADC 和 DMA 配合工作的配置就完成了
//8.开关控制,调用ADC_Cmd 函数,开启 ADC,这样 ADC 就配置完成了,就能正常工作了
ADC_Cmd(ADC1, ENABLE);
//9.开启 ADC 之后,根据手册建议,对 ADC 进行一下校准,这样可以减小误差
ADC_ResetCalibration(ADC1);//复位校准
while (ADC_GetResetCalibrationStatus(ADC1) == SET);//获取复位校准状态,空循环等待校准完成。
//获取 CR2 寄存器里的 RSTCAL 标志位,软件置该位为 1,那硬件就会开始复位校准,当复位校准完成后,该位就会由硬件自动清 0。
ADC_StartCalibration(ADC1);//开始校准
while (ADC_GetCalibrationStatus(ADC1) == SET);//获取开始校准状态,空循环等待校准完成。
//10.软件触发转换(因为 ADC 是扫描模式,所以只需要软件触发一下 ADC 开始)
ADC_SoftwareStartConvCmd(ADC1, ENABLE);
}
main.c
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "AD.h"
int main(void) {
OLED_Init();
AD_Init();
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);
OLED_ShowNum(2, 5, AD_Value[1], 4);
OLED_ShowNum(3, 5, AD_Value[2], 4);
OLED_ShowNum(4, 5, AD_Value[3], 4);
Delay_ms(100);
}
}
当 ADC 触发之后,ADC 连续转换,DMA 循环转运,两者一直在工作,始终把最新的转换结果,刷新到 SRAM 数组里,我们想要数据的时候,随时去数组里取就行了。主循环啥都不干,直接读取 AD_Value 数组,就能得到结果。这样也可以完成 AD 多通道转换的功能。
可以看到,此时硬件外设,已经实现了相互配合 和 高度的自动化,各种操作都是硬件自己完成的,极大地减轻了软件负担,软件什么都不需要做,也不需要进任何中断,硬件自动就把活干完了。这就是 STM32 中硬件自动化的一大特色,各个外设互相连接,互相交织,不再是传统的一个 CPU 单独控制多个独立的外设这样的星型结构,而是外设之间互相连接,互相合作,形成一个网状结构,这样在完成某些简单且繁琐的工作的时候,就不需要 CPU 来统一调度了,可以直接通过外设之间的相互配合,自动完成这些繁琐的工作,这样不仅可以减轻 CPU 的负担,还可以大大提高外设的性能。比如定时器的输出,可以通向 ADC、DAC 或其他定时器,ADC 的触发源可以来自定时器或外部中断,DMA 的触发源可以来自 ADC、定时器、串口等等。这就是 STM32 外设相互配合工作的特色。
另外这里,你还可以再加一个外设,比如定时器,ADC 用单次扫描,再用定时器去定时触发,这样就是定时器触发 ADC,ADC 触发 DMA,整个过程完全自动,不需要程序手动进行操作,节省软件资源。
本节演示了 DMA 存储器到存储器、外设到存储器的情况,当然还有一个存储器到外设的情况,我们目前还没有介绍,比如串口发送一批数据,就可以使用 DMA 进行存储器到外设的转运。