简介:一套面向嵌入式开发者的GD32F407多通道ADC采集与DMA传输完整工程源码,适合正在学习GD32系列单片机、需要实现多路模拟量同步采样的工程师或高校学生。项目基于梁山派GD32F407ZGT6开发板,使用KEIL MDK-ARM V5.35环境与GD32F4xx标准固件库V3.0.0,代码结构清晰,可直接参考或移植。资源包共220个文件,包含81个H头文件、67个C源文件以及编译生成的HEX、AXF、MAP、工程配置文件等,既可用于二次开发,也可对照学习GD32外设初始化、DMA传输与中断处理的完整流程。压缩包整体仅3.68MB,轻量易下载。已有901人学习使用,适合正在调试ADC采样不稳定、多通道切换耗时或希望提高采集效率的开发者作为实战参考。
1. 项目背景与整体方案设计
1.1 为什么选择GD32F407的ADC+DMA
GD32F407是兆易创新推出的一款基于ARM Cortex-M4内核的高性能MCU,主频可以拉到200MHz,片上集成了3个12位ADC,每个ADC最多支持16个外部输入通道。做工业数据采集、电池电压监测、多路传感器信号采样时,经常会遇到"通道数多、数据量大、CPU不能一直忙于搬数据"的尴尬场景。这时候用ADC规则组扫描+DMA搬运,是最省心也最稳定的做法。
我之前在做一个16路模拟量采集模块,输入信号是0~3.3V的电压,要求所有通道同时持续采样,采样结果不能丢,同时CPU还要去处理通信和显示任务。如果用中断方式每次转换完都去读数据寄存器,一个通道进一次中断,16个通道就是16倍的中断开销,CPU几乎什么都干不了。GD32F407的DMA(直接内存访问)正好可以解决这个问题:ADC连续扫描16个通道,每转换完一个通道就把结果自动搬运到内存数组里,全程不需要CPU干预。实测下来,CPU占用几乎可以忽略不计,采样率还能跑得比中断方式高很多。
这套方案非常适合对时序要求不太苛刻、但需要多通道连续采集的项目,比如环境监测、自动化设备模拟量输入采集、电池组电压监控这类的场景。如果你手头正好有一颗GD32F407,也想从零实现一个稳定可靠的16路ADC采样程序,这篇内容可以直接照抄。
1.2 16路采样的硬件资源规划
先梳理一下需要用到的片内外设资源。GD32F407一共有3个ADC,分别是ADC0、ADC1、ADC2,每个ADC都有一套独立的规则通道(regular channel)和注入通道(injected channel)。规则通道就是我们做定时循环扫描时最常用的一组通道,最多可以配置16个转换序列。也就是说,用ADC0单独一个外设,把它的16个规则通道全部利用起来,刚好可以实现16路模拟输入采样。
这里有一个需要注意的地方:GD32F407的ADC0输入通道0~15中,部分型号的通道15内部会连接到温度传感器或内部参考电压上,具体要看芯片型号和数据手册的管脚定义。我在做主板上16路采集时,特意避开了通道15,改用ADC0的通道0~14外加ADC1的通道0来实现16路,避免和内部功能冲突。如果你用的板子明确把ADC_IN15引出来了,数据手册也确认可以当普通模拟输入用,那直接用ADC0的0~15也可以。
DMA资源方面,GD32F407的DMA有DMA0和DMA1两个控制器,每个控制器带8个通道。ADC0的DMA请求固定映射到DMA0的通道0,这个关系需要记住,因为DMA配置里的外设请求号不能配错。我在第一次配置时就是把这个映射关系搞错了,结果DMA一直不工作,后来翻开参考手册的DMA请求映射表才找到原因。
至于ADC的触发方式,最简单的做法是配置为软件触发+连续转换模式,让ADC自己不停地转换,转换完的数据由DMA自动搬走。如果项目对采样时刻有严格要求,也可以用定时器触发ADC转换,比如用TIM2的TRGO事件触发,这样能精确控制采样间隔。我这里先讲软件连续模式,后面会补充定时器触发的改法。
2. ADC采样原理与关键参数配置
2.1 ADC采样工作原理:逐次逼近型ADC
GD32F407内部集成的ADC是12位的逐次逼近型(SAR)ADC。这个原理其实很好用生活里的"猜数字"游戏来理解:假如要猜一个0~4095之间的数字,SAR ADC不是一位一位傻猜,而是先猜中间值2048,比较输入电压和中间值对应电压的大小,如果输入电压高,说明答案在2048~4095之间,于是把最高位记为1,再猜中间值3072,依此类推,经过12次比较就能精确逼近输入电压。所以12位ADC每次转换需要至少12个比较周期,再加上采样阶段充电的时间,总共要几十个ADC时钟周期。
理解这个原理对配置采样时间很有帮助。ADC转换时间由两部分组成:采样阶段和比较阶段。采样阶段是让内部的采样保持电容充分充电到输入电压,这个时间不能太短,否则信号源阻抗较高时会采不准;比较阶段则是逐次逼近的时间。在GD32固件库里,我们只需要配置采样时间即ADC_SAMPLETIME_55POINT5之类的宏,比较时间是固定的(12个周期)。采样时间越长,转换越准,但整体速度越慢,这是一个需要权衡的点。
我实测16路通道,每路设置55.5个ADC时钟周期采样时间,在ADC时钟为30MHz时,单通道转换时间大概是55.5+12=67.5个周期,也就是2.25us,16路一轮下来约36us。这个速度对于电压采集场景完全够用,而且抗干扰能力也不错。如果你的信号源输出阻抗很低,可以适当缩短采样时间到13.5个周期,速度能提升不少。
2.2 分辨率、采样时间与转换速度的取舍
GD32F407的ADC是12位分辨率,也就是满量程对应的数字量是4095。因为它没有可调节分辨率功能,所以我们要在采样时间和转换速度之间做平衡。具体配置时,adc_channel_length_config设置规则组通道数量,adc_regular_channel_config逐个设置每个通道在序列中的位置和采样时间。
关键参数我在工程里是这样定的:
ADC时钟频率:APB2时钟设为60MHz,ADC预分频选6分频,得到10MHz,这个是ADC的输入时钟。对于GD32F407,ADC时钟建议不要超过40MHz,10MHz比较保守,稳定性最好。
采样时间:选55.5周期,如上所述,既保证精度,速度又能接受。
转换模式:独立模式下软件触发,连续转换。也就是
adc_special_function_config(ADC0, ADC_SCAN_MODE, ENABLE)开启扫描模式,adc_external_trigger_config(ADC0, ADC_EXTERNAL_TRIGGER_MODE_NONE, ENABLE)把外部触发关掉,然后用软件触发启动。DMA请求:使能ADC0的DMA请求,同时DMA设置为循环模式。
有一点要特别提醒:对于多通道扫描,每一个通道的采样时间是可以独立配置的。如果你某几个通道外接的传感器阻抗特别高,可以把这几个通道单独设置成更大的采样时间(比如239.5周期),其他通道用小采样时间,这样精度和速度兼顾。
看实际需求选择合适的采样时间,没有绝对最优的参数。比如只做监测,采样率要求不高,那全部设成55.5周期甚至239.5周期都行,稳定性更好。
3. DMA传输机制与配置实操
3.1 DMA为什么是ADC采样的最佳搭档
DMA模块的作用相当于一个"搬运工",它可以在没有CPU参与的情况下,把外设数据寄存器里的数据搬到指定的内存数组里。每次ADC转换完,数据寄存器的内容就会被DMA立刻搬走,所以下一个通道的转换结果不会覆盖上一个通道。如果不用DMA,规则组多通道扫描时,CPU必须赶在下一个通道转换完成前把数据读走,一旦没读走,原来的数据就被覆盖了。这是很多初学者采集到乱码数据的根本原因。
DMA的循环模式还额外解决了一个问题:16个通道扫描完一轮后,DMA的传输计数器会自动重新装载,下一轮转换结果继续从头写入缓冲区。这样一来,ADC和DMA就形成了一个"自动流水线",我们只需要定期去缓冲区取数即可。配合中断或者直接主循环查询,可以做到数据零丢失。
还有一个常被忽略的好处:因为DMA是硬件搬运,数据写入内存的时机和ADC转换完成是严格同步的,所以每次从缓冲区读到的数据,时序上不会错位。这一点在中位值滤波、平均值滤波时特别重要,否则把某两个通道的数据搞混了,滤波算法再正确也是白搭。
3.2 基于GD32F407的ADC+DMA配置步骤
GD32F407的固件库函数和STM32标准库非常像,但函数命名有一点区别。我这里直接给出一份我调试通过的配置代码,配合注释一步一步说明。
#include "gd32f4xx.h" #include "gd32f4xx_dma.h" #include "gd32f4xx_adc.h" #define ADC_SCAN_CHANNEL_NUM 16 // 扫描通道数 #define ADC_SAMPLE_TIMES 8 // 每通道采样次数,缓冲区分组平均 uint16_t adc_buffer[ADC_SCAN_CHANNEL_NUM]; uint16_t adc_dma_buffer[ADC_SCAN_CHANNEL_NUM * ADC_SAMPLE_TIMES]; void gpio_config_adc(void) { rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_GPIOB); rcu_periph_clock_enable(RCU_GPIOC); // 配置ADC输入引脚为模拟模式 gpio_mode_set(GPIOA, GPIO_MODE_ANALOG, GPIO_PUPD_NONE, GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3); gpio_mode_set(GPIOA, GPIO_MODE_ANALOG, GPIO_PUPD_NONE, GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7); gpio_mode_set(GPIOB, GPIO_MODE_ANALOG, GPIO_PUPD_NONE, GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3); gpio_mode_set(GPIOB, GPIO_MODE_ANALOG, GPIO_PUPD_NONE, GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7); gpio_mode_set(GPIOC, GPIO_MODE_ANALOG, GPIO_PUPD_NONE, GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3); gpio_mode_set(GPIOC, GPIO_MODE_ANALOG, GPIO_PUPD_NONE, GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7); } void dma_config_adc(void) { dma_single_data_parameter_struct dma_init_struct; rcu_periph_clock_enable(RCU_DMA0); dma_deinit(DMA0, DMA_CH0); dma_init_struct.periph_addr = (uint32_t)(&ADC_RDATA(ADC0)); dma_init_struct.periph_inc = DMA_PERIPH_INCREASE_DISABLE; dma_init_struct.memory0_addr = (uint32_t)adc_dma_buffer; dma_init_struct.memory_inc = DMA_MEMORY_INCREASE_ENABLE; dma_init_struct.direction = DMA_PERIPHERAL_TO_MEMORY; dma_init_struct.number = ADC_SCAN_CHANNEL_NUM * ADC_SAMPLE_TIMES; dma_init_struct.periph_width = DMA_PERIPHERAL_WIDTH_16BIT; dma_init_struct.memory_width = DMA_MEMORY_WIDTH_16BIT; dma_init_struct.priority = DMA_PRIORITY_HIGH; dma_single_data_mode_init(DMA0, DMA_CH0, &dma_init_struct); dma_circulation_enable(DMA0, DMA_CH0); dma_channel_enable(DMA0, DMA_CH0); } void adc_config(void) { rcu_periph_clock_enable(RCU_ADC0); rcu_adc_clock_config(RCU_CKADC_CKAPB2_DIV6); // ADC时钟 = APB2/6 adc_clock_config(ADC0, ADC_CLK_DIV6); adc_special_function_config(ADC0, ADC_SCAN_MODE, ENABLE); adc_data_alignment_config(ADC0, ADC_DATAALIGN_RIGHT); adc_channel_length_config(ADC0, ADC_REGULAR_CHANNEL, ADC_SCAN_CHANNEL_NUM); // 依次配置16个通道的采样时间和序列位置 adc_regular_channel_config(ADC0, 0, ADC_CHANNEL_0, ADC_SAMPLETIME_55POINT5); adc_regular_channel_config(ADC0, 1, ADC_CHANNEL_1, ADC_SAMPLETIME_55POINT5); adc_regular_channel_config(ADC0, 2, ADC_CHANNEL_2, ADC_SAMPLETIME_55POINT5); adc_regular_channel_config(ADC0, 3, ADC_CHANNEL_3, ADC_SAMPLETIME_55POINT5); adc_regular_channel_config(ADC0, 4, ADC_CHANNEL_4, ADC_SAMPLETIME_55POINT5); adc_regular_channel_config(ADC0, 5, ADC_CHANNEL_5, ADC_SAMPLETIME_55POINT5); adc_regular_channel_config(ADC0, 6, ADC_CHANNEL_6, ADC_SAMPLETIME_55POINT5); adc_regular_channel_config(ADC0, 7, ADC_CHANNEL_7, ADC_SAMPLETIME_55POINT5); adc_regular_channel_config(ADC0, 8, ADC_CHANNEL_8, ADC_SAMPLETIME_55POINT5); adc_regular_channel_config(ADC0, 9, ADC_CHANNEL_9, ADC_SAMPLETIME_55POINT5); adc_regular_channel_config(ADC0, 10, ADC_CHANNEL_10, ADC_SAMPLETIME_55POINT5); adc_regular_channel_config(ADC0, 11, ADC_CHANNEL_11, ADC_SAMPLETIME_55POINT5); adc_regular_channel_config(ADC0, 12, ADC_CHANNEL_12, ADC_SAMPLETIME_55POINT5); adc_regular_channel_config(ADC0, 13, ADC_CHANNEL_13, ADC_SAMPLETIME_55POINT5); adc_regular_channel_config(ADC0, 14, ADC_CHANNEL_14, ADC_SAMPLETIME_55POINT5); adc_regular_channel_config(ADC0, 15, ADC_CHANNEL_15, ADC_SAMPLETIME_55POINT5); adc_external_trigger_config(ADC0, ADC_EXTERNAL_TRIGGER_MODE_NONE, ENABLE); adc_external_trigger_source_config(ADC0, ADC_EXT_TRIGGER_DEFAULT); adc_dma_mode_enable(ADC0); adc_enable(ADC0); delay_1ms(1); // 等待ADC稳定 adc_software_trigger_enable(ADC0, ADC_REGULAR_CHANNEL); }代码里有几个关键点解释一下。adc_dma_buffer为什么设置成16*8个元素?因为我想让每通道连续采样8次,然后软件做平均滤波,这样比单次采样更稳。如果你不需要平均,直接把ADC_SAMPLE_TIMES改成1,缓冲区就是16个元素。
ADC_RDATA(ADC0)是读取ADC0转换结果数据寄存器的宏,注意在GD32固件库里,数据寄存器地址不能直接写死成一个普通变量地址,必须这样传给DMA的外设地址,否则DMA会一直搬运一个错误的地址。
还有dma_circulation_enable,循环模式必须开启,否则传输完一轮之后DMA就停了,ADC继续转换时数据就会覆盖。我见过很多人配完DMA,发现第一次采到数据,后面数据就不刷新了,绝大多数都是因为忘了开循环模式。
3.3 定时器触发改法(可选)
如果项目要求严格的采样间隔,可以把ADC的外部触发切换成定时器触发,下面是在连续转换模式基础上要改的部分。
// 以TIM2为例,配置为500Hz触发一次 void timer_trigger_config(void) { rcu_periph_clock_enable(RCU_TIMER2); timer_parameter_struct timer_initpara;; timer_struct_para_init(&timer_initpara); timer_initpara.prescaler = 59; // 60MHz/60 = 1MHz timer_initpara.period = 1999; // 1MHz/2000 = 500Hz timer_initpara.alignedmode = TIMER_COUNTER_EDGE; timer_initpara.counterdirection = TIMER_COUNTER_UP; timer_initpara.clockdivision = TIMER_CKDIV_DIV1; timer_init(TIMER2, &timer_initpara); timer_primary_output_config(TIMER2, ENABLE); timer_master_output_trigger_config(TIMER2, TIMER_TRI_OUT_SRC_UPDATE); timer_enable(TIMER2); }然后把adc_external_trigger_config改成使用外部触发,触发源选ADC_EXT_TRIGGER_SOURCE_TIMER2_CH0之类的对应宏。具体以固件库版本和芯片手册为准。定时器触发的好处是每一轮16通道的转换启动时刻非常均匀,后续做FFT或信号分析时不会有时间抖动。
4. 16路数据的可靠采集与处理
4.1 多通道循环采集的缓冲区设计
缓冲区设计是这套方案能不能稳定运行的核心。我见过很多工程里直接把DMA缓冲区定义成16个uint16_t,然后dma_transfer_number_config也设置成16,这样做当然能采集,但有个隐患:如果主循环读取缓冲区的时机正好和DMA写入某个通道数据重叠,可能会读到半个通道的数据(虽然16位读写一般是原子的,但多通道之间的关联性会被破坏)。尤其是要做16路同时性数据分析时,最好用双缓冲区或者使用"DMA传输完成中断"来处理数据,而不是随时乱读。
我比较推荐两种做法:
一种是"中断统一处理":关闭DMA传输完成中断,在中断里把adc_dma_buffer整块拷贝到另一个数组。因为DMA是循环模式,每传输完一轮(16*8=128个数据)就会触发一次传输完成中断,在中断里拷贝出来的数据是完整的一帧。要注意中断里不能做太多事,毕竟拷贝128个16位数据很快,但如果你在中断里做16路平均、滤波、协议打包,那中段时间就拉长了,会影响ADC采样。
另一种是"查询标志位":在主循环里持续检查dma_flag_get(DMA0, DMA_CH0, DMA_INT_FLAG_FTF)传输完成标志,置位后拷贝数据并清标志。这种方式代码简单,适合采集速率不高、主循环不忙的场景。我这里的项目用了第一种,因为还要配合Modbus通信,串口那边用的也是DMA+空闲中断,主循环不能阻塞太久。
4.2 数据处理:平均值滤波与坏值剔除
拿到了原始数据之后,不能直接用,尤其是工业现场,模拟信号特别容易混入毛刺和干扰。我习惯在DMA中断里做一层简单的软件滤波:对每个通道的8个原始样本,先去掉最大值和最小值,然后对剩余6个求平均。这样既滤掉了尖峰脉冲,又不影响响应速度,代码量也小。
uint16_t filtered_value[16]; void process_adc_data(void) { uint16_t i; for (i = 0; i < 16; i++) { uint16_t min_val = 4095, max_val = 0; uint32_t sum = 0; uint8_t j; for (j = 0; j < ADC_SAMPLE_TIMES; j++) { uint16_t val = adc_dma_buffer[i * ADC_SAMPLE_TIMES + j]; if (val < min_val) min_val = val; if (val > max_val) max_val = val; sum += val; } sum -= min_val; sum -= max_val; filtered_value[i] = (uint16_t)(sum / (ADC_SAMPLE_TIMES - 2)); } }这段代码要加在DMA传输完成中断里,或者拷贝出完整一帧后在主循环里调用。我这里用的是128个样本不是16个样本,原因是每个通道采8次再滤波,可信度更高。如果你的系统对实时性要求高,可以把ADC_SAMPLE_TIMES设为1,此时滤波就没意义了,直接读原始缓冲即可。
还有人会问:16路通道之间的数据会不会串位?只要DMA配置正确,外设地址固定为ADC的数据寄存器,内存地址按顺序递增,就不会串。串位通常出现在你没有开扫描模式,或者通道序列配置错了。调试时可以用万用表给不同通道加不同电压,然后打印出来的值按通道号递增,就能很容易发现有没有串位。
5. 工程实践中的避坑指南
5.1 引脚冲突与参考电压处理
ADC输入引脚复用到模拟功能后,就不能再配置成GPIO输出或者其他复用功能,这点大家都知道。但容易踩的坑是:某些开发板上ADC的输入引脚同时连接了板上外设,比如按键、LED、传感器,这会导致采样值被拉高或拉低。我一开始就在PA1上接了板载的按钮,ADC采样结果永远不是预期的电压,排查了半天才把跳线帽拔掉。
另一个常见问题是参考电压。GD32F407的ADC参考电压信号叫VREF+,一般直接连接到VDDA。如果你的主供电是3.3V,且电源纹波较大,那么ADC测量结果会跟着纹波跳。设计PCB时一定要在VDDA引脚附近放一个0.1uF和1uF的电容,并且在电源入口加磁珠隔离数字电源。我自己做了一块小板,刚开始没加隔离,采样值在低三位跳动,加了磁珠和滤波电容之后,跳动能控制在2个LSB以内。
5.2 实测波形与性能优化心得
项目做完后,我做了几组实测。输入用可调直流稳压源,从0V逐步调到3.3V,读取DMA缓冲区的数据。线性度表现很好,满量程误差大概在±3个LSB左右。16路同时采集时,ADC单通道转换时间2.25us,16路一轮大约36us,对应数据更新率约27.7kHz,即每通道每秒能产生约27.7k个有效样本。对于大多数传感器采集场景,这个速率已经非常充裕。
如果想让采样速度更快,可以把ADC时钟从10MHz提升到30MHz,采样时间改成13.5周期,这样单通道转换时间约25.5周期,对应的单通道转换时间只需要0.85us,16路一轮13.6us。速度快了,但对电源噪声更敏感,精度可能略微下降。我平时用默认配置,只有在需要高速采集时才调整这个参数。
最后分享一个调试小技巧:用逻辑分析仪或者串口打印,每隔一段时间把filtered_value[0]的十六进制打印出来,同时给通道0输入一个缓慢变化的三角波。如果在屏幕上能看到平滑的连续变化,说明DMA和ADC工作都正常。如果看到数值跳变很激进,优先检查DMA内存地址对齐、缓冲区大小是否匹配,以及是否开启了DMA循环模式。这三个问题的排查顺序,基本能解决90%的ADC+DMA采集异常。
本文还有配套的精品资源,点击获取