☰
GD32F407多通道ADC+DMA采集:16路规则组配置与避坑指南
2026/10/13 15:56:41 网站建设 项目流程

简介:基于GD32F407的16路ADC采样+DMA工程示例,面向使用立创梁山派开发板的嵌入式开发者与单片机学习者,解决多通道模拟信号同步采集与DMA高效搬运数据的实现问题,适用于工业数据采集、传感器阵列等多通道测量场景。资源包共220个文件,核心包含81个C源文件与67个头文件,以及o、d等编译辅助文件,另有uvprojx工程配置、hex烧写文件与链接脚本,整体仅3.68MB,但覆盖从源码到固件的完整链路。目前已有904人学习下载,具备一定参考价值。工程基于GD32F4xx标准固件库V3.0.0,在KEIL MDK环境下可直接编译调试,代码涵盖ADC多通道配置、DMA循环传输、定时器触发等关键环节,同时包含系统时钟初始化与启动文件,帮助读者快速掌握GD32F407外设驱动开发流程,也为后续扩展以太网等高级功能提供基础。

1. 16路ADC+DMA:GD32F407上最容易被低估的采集工程

做一块16路采集板,很多人以为就是把16个引脚连上ADC,开个DMA,然后数组就自己在更新了。真正把基于GD32F407的16路ADC采样+DMA调稳之后,我发现最花时间的反而是三件小事:ADC时钟分频、DMA请求映射和规则组序列长度。这三处任何一个错,16路数据要么全0,要么串位,要么采样率高得吓人但波形根本还原不了。这篇文章把引脚分配、规则组配置、DMA循环搬运、电压标定这条链路一次讲透,适合正在做多通道数据采集、传感器阵列、电池监测或电网采样的开发者,也适合从其他M4平台迁过来的朋友少踩两天坑。

2. GD32F407的ADC与DMA结构:16路到底是怎么被采回来的

2.1 规则组扫描模式:16路正好压着规则组上限走

GD32F407内部有ADC0、ADC1、ADC2三个12位逐次逼近型ADC,每个ADC都维护着两套转换队列:规则组和注入组。规则组最多可以排16个通道,注入组最多4个通道。16路采集,恰恰是卡着规则组的上限做的,所以这类项目的标准做法就是:把16个通道全部排进规则组,开扫描模式,触发一次,通道按排序依次转换。

这里要纠正一个直觉:规则组并不是「16路同时采」,而是排着队一个一个转换。ADCCLK给到25MHz、采样时间28周期时,一路转换需要40个周期,16路一次完整扫描就是640个周期,约25.6微秒。第0路和第15路的实际采样时刻差了25微秒以上。如果你的信号是慢变化的直流或低频信号,这完全无所谓;可如果你要采三相电压、多路振动或者需要做相位分析的信号,这个时间差就是硬伤。这也是后面要讲多ADC同步采样的原因。

连续模式和扫描模式经常一起用,扫描让16路按顺序转一遍,连续让整组反复转,DMA把每路结果依次搬进内存,CPU从头到尾不用碰ADC寄存器。这是目前最省事的组合,但注意它有一个副作用:DMA会持续占用总线,16路全速跑的时候对CPU和总线都有压力。如果采样率要求不高,我更建议用定时器触发,第五章会专门讲。

2.2 单ADC扫16路还是三ADC并行:什么时候选哪个

把这个决策放在写代码之前,能省掉后面大量返工。两种方案我都实际用过,按应用场景选,不是按通道数选。

单ADC扫16路的好处是代码短、DMA链路只有一条、排查方便。ADC0的16个规则组通道全部启用,DMA搬运16个半字,中断处理也简单。缺点是各路采样时刻有先后偏差,而且全部挤在一个ADC上,单路等效采样率被16路摊薄。

三ADC并行是把16路拆给ADC0、ADC1、ADC2,比如ADC0负责0~5路,ADC1负责6~11路,ADC2负责12~15路,三个ADC用同一个外部触发同时启动转换,DMA各搬各的。这样各路之间的时间偏差从「微秒级排队偏差」缩小到「触发同步偏差」,对工频信号、振动信号这种在乎相位的场景非常值得。代价是引脚分配受限、DMA通道占用三个、中断和缓冲管理复杂不少。

下面这张表是我自己做选型时对照用的:

维度单ADC扫16路三ADC并行
代码复杂度低中高
路间采样时刻偏差微秒级排队偏差触发同步偏差
单路等效采样率受16路摊薄每ADC只摊薄几路
引脚自由度只需满足一个ADC三个ADC的引脚都要可分配
典型场景电压巡检、温度采集、电池监测电能质量、振动分析、同步波形

一句话给结论:采集对象是直流或低频,单ADC扫16路够了;采集对象是交流或需要时间对齐,直接上三ADC同步。别在单ADC方案里折腾软件对齐,物理上的采样时刻差是补不回来的。

2.3 采样时间与等效采样率:先算完再写代码,免得返工

GD32F407的12位ADC,一次转换需要12个转换周期,再加上可配置的采样时间。采样时间越长,输入阻抗要求越低,但总时间越长。常见档位有5、14、28、56周期等,具体枚举名以你的固件库为准,关键是下面这个公式:

单路等效采样率 = ADCCLK / (通道数 × (12 + 采样时间周期))

举例:ADCCLK = 25MHz,采样时间28周期,16路:

一次扫描总周期 = 16 × (12 + 28) = 640周期 一次扫描耗时 = 640 / 25MHz = 25.6微秒 单路等效采样率 = 25.6微秒的倒数 ≈ 39kS/s

看起来39kS/s不低,但如果你每路想要10kS/s的最终数据率,触发间隔要大于等于25.6微秒,也就是触发频率不能超过39kHz,否则上一轮没扫完下一轮就来了。这个账必须提前算好,否则后面定时器触发时会出现数据覆盖、通道错位,表现就是「采样率配得很高,波形一塌糊涂」。

采样时间档位的选择还要看信号源阻抗。信号源输出阻抗高,就把采样时间拉长,让内部采样电容充分充电;信号源是低阻抗的运放输出,可以用短采样时间换速度。这个参数不是随便挑的,高阻抗源配短采样时间,读数会偏低而且抖动明显,这是模拟链路的基本规律,后面避坑章还会再遇到。

3. 把16路ADC+DMA跑通:初始化、循环搬运与数据校验

3.1 动手前先查三张表:引脚映射、DMA请求、中断向量

这一步看起来不性感,但翻车的案例里有一半是因为少做了它。GD32F407的ADC通道引脚在不同封装上分布并不完全一样,网上流传的引脚对照图经常对应错封装,直接照抄大概率有一两路落在不能用的引脚上。

第一张表是ADC通道与引脚映射表,从参考手册里查,把每个ADC的通道号对应到具体引脚,再对照你的原理图确认这16个引脚有没有被别的外设占用。第二张表是DMA请求映射表,查ADC0、ADC1、ADC2的DMA请求分别落在哪个DMA控制器、哪个通道上。注意GD32F407有两个DMA控制器,每个还有多个通道,请求映射并不是想当然的那个编号,这一步错了DMA就永远不会搬数据。第三张表是启动文件里的中断向量名,半满中断、全满中断要在NVIC配好,向量名写在启动文件里,抄错一个字母编译都过不了。

我自己的习惯是先在纸上画一张三列的表:通道号、ADC单元、引脚号,再画一张DMA请求表和一张中断向量表。三张表确认完再开IDE,写代码的时间能省掉一半。

3.2 ADC初始化与校准:规则组长度、扫描模式、采样时间的落地代码

下面这段是按GD32F4系列固件库的常见写法组织的,函数名在不同版本里可能略有差别,但参数逻辑是通用的,照着参数逻辑就能迁移。

#define ADC_CH_NUM 16 static uint16_t adc_buf[ADC_CH_NUM]; /* 把16个引脚配成模拟输入,示例用PA0~PA7 + PC0~PC7,按你自己的原理图改 */ static void adc_gpio_init(void) { rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_GPIOC); gpio_mode_set(GPIOA, GPIO_MODE_ANALOG, GPIO_PUPD_NONE, GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3 | 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_PIN_4 | GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7); }

GPIO配成模拟输入是关键,配成复用功能或推挽输出,ADC采到的都是错误电平。GPIO_PUPD_NONE是为了把上下拉电阻关掉,否则外部信号会被内部电阻分压,读数产生固定偏差。

static void adc16_init(void) { rcu_periph_clock_enable(RCU_ADC0); rcu_adc_clock_config(RCU_CKADC_CKAPB2_DIV4); /* 分频到ADC时钟上限以内 */ adc_channel_length_config(ADC0, ADC_REGULAR_CHANNEL, ADC_CH_NUM); /* 按你的通道映射表依次配置rank 0~15,这里只写前4路作为示例 */ adc_regular_channel_config(ADC0, 0, ADC_CHANNEL_0, ADC_SAMPLETIME_28); adc_regular_channel_config(ADC0, 1, ADC_CHANNEL_1, ADC_SAMPLETIME_28); adc_regular_channel_config(ADC0, 2, ADC_CHANNEL_2, ADC_SAMPLETIME_28); adc_regular_channel_config(ADC0, 3, ADC_CHANNEL_3, ADC_SAMPLETIME_28); /* 其余12路照抄,rank递增,通道号改成实际引脚对应的通道 */ adc_scan_mode_enable(ADC0); /* 扫描模式 */ adc_special_function_config(ADC0, ADC_CONTINUOUS_MODE, ENABLE); /* 连续模式 */ adc_external_trigger_config(ADC0, ADC_EXTERNAL_TRIGGER_MODE_NONE, ENABLE); adc_enable(ADC0); delay_ms(1); /* 等待ADC稳定 */ adc_calibration(ADC0); /* 上电校准,必须有 */ adc_software_trigger_enable(ADC0); /* 软件触发启动 */ }

adc_channel_length_config是规则组序列长度,16路就要写16,写成8的话后面8路永远不转。adc_regular_channel_config的第二个参数是序号,代表这是第几个被转换的通道,不是通道号本身,这两个概念特别容易混。采样时间统一给28周期是保守做法,先保证数据稳定,再根据信号阻抗往下压。

adc_calibration这个函数很多人会忽略,但它直接影响精度。GD32F407在ADC使能后需要校准一次,校准会修正内部电容失配,不校准的话同一路输入电压在不同温度下读数会有明显漂移。延时1毫秒是为了让ADC内部稳定,具体延时时长不用太讲究。

3.3 DMA循环搬运与半满中断:数据不丢的关键配置

ADC自己不会等CPU,转换结果如果不及时搬走就会被下次覆盖。DMA在这里扮演电梯,每转换完一路就往内存里搬一个半字,全程不打扰CPU。

static void dma16_init(void) { dma_parameter_struct dma_para; rcu_periph_clock_enable(RCU_DMA0); dma_deinit(DMA0, DMA_CH0); /* DMA_CH0按3.1查到的实际通道改 */ dma_para.periph_addr = (uint32_t)(&ADC_RDATA); /* ADC数据寄存器,固定地址 */ dma_para.periph_inc = DMA_PERIPH_INCREASE_DISABLE; dma_para.memory_addr = (uint32_t)adc_buf; /* 目标缓冲区 */ dma_para.memory_inc = DMA_MEMORY_INCREASE_ENABLE; dma_para.periph_width = DMA_PERIPH_WIDTH_16BIT; /* ADC结果占低16位 */ dma_para.memory_width = DMA_MEMORY_WIDTH_16BIT; dma_para.direction = DMA_DIRECTION_PERIPHERAL_TO_MEMORY; dma_para.number = ADC_CH_NUM; /* 每次搬运16个半字 */ dma_para.priority = DMA_PRIORITY_HIGH; dma_init(DMA0, DMA_CH0, &dma_para); dma_circulation_enable(DMA0, DMA_CH0); /* 循环模式,搬完自动重新装载 */ dma_transfer_flag_clear(DMA0, DMA_CH0); dma_half_transfer_flag_clear(DMA0, DMA_CH0); dma_interrupt_enable(DMA0, DMA_CH0, DMA_INT_HALF_TRANSFER); dma_interrupt_enable(DMA0, DMA_CH0, DMA_INT_FULL_TRANSFER); dma_channel_enable(DMA0, DMA_CH0); adc_dma_mode_enable(ADC0); /* 让ADC通过DMA请求通知搬运 */ }

这里最关键的三个地方:外设地址固定、内存地址自增、数据宽度用16位。ADC_RDATA虽然是32位寄存器,但有用的数据只在低16位,DMA用16位宽度去读正好只取有效部分,如果用32位宽度会把寄存器高位保留段也搬进来,缓冲区的数据排列就会错位。

循环模式打开后,DMA搬完16个半字会自动把内存地址重置回首地址,不需要软件重新使能,这是连续采集能长期跑而不丢数据的前提。半满中断在搬完前8路时触发,全满中断在搬完16路时触发,这两个中断把缓冲区切成两个窗口,让主循环去处理上一窗口的数据时,DMA还在写另一窗口。

3.4 拿到第一批数据后怎么确认没接错:电压换算与通道核对

DMA跑起来之后,第一件事不是看波形,是确认通道和数据值能对得上。写一个电压换算函数,把原始码值转成实际电压:

#define ADC_REF_VOLTAGE 3.3f /* 参考电压,按实际硬件填写 */ static float adc_raw_to_voltage(uint16_t raw) { /* 12位ADC,满量程对应参考电压;k和b由后面第六章的标定过程得出 */ return (raw / 4095.0f) * ADC_REF_VOLTAGE; }

验证方法很简单:通道0接一个已知电压(比如1.25V),其余通道全部接地。运行后看adc_buf[0]换算出来是不是接近1.25V,其他索引是否接近0。如果已知电压出现在adc_buf[3]或者更后面的位置,说明rank配置或引脚映射出了问题,按3.1那张表重新排查。

还要确认数据的新鲜度。DMA循环模式下,adc_buf里每时每刻都有数据,但主循环读到的可能是上一个周期的旧值,也可能是正在被DMA写入的半新值。这就是半满中断存在的意义:process_adc_half(adc_buf + 0)处理前8路时,DMA正在写后8路;处理完后8路时,DMA又开始写前8路,两个窗口天然互不干扰。如果不用中断而是主循环直接读数组,就要自己维护一个读写握手标志,否则高速采样时读到的数据可能是「半新半旧拼起来的一帧」,这是非常隐蔽的坑。

4. 16路ADC+DMA的5个高频坑:现象、根因与排错路径

4.1 后8路永远读0:规则组长度只配了8

现象:前8路数据正常,后8路要么是0,要么是上一次的残留值,看起来像DMA没搬完。

原因:adc_channel_length_config只配了8。扫描模式是按规则组长度来转的,长度8就只扫前8个rank,后8个rank根本没进序列,DMA搬运的16个半字里有8个是无效数据。

解决:把规则组长度显式写成16,并且逐个检查rank 8~15是否真的配了通道号。排查时用调试器看adc_channel_length_config配置后的寄存器值最直接。

4.2 DMA只搬了一次就不动:循环、连续和请求缺一不可

现象:程序跑起来,adc_buf里有第一帧数据,之后这个数组再也不更新,像被冻住一样。

原因:三个地方漏了一个。DMA没开循环模式,搬完16个半字就停了;ADC没开连续模式,软件触发只触发了一次转换;或者adc_dma_mode_enable没调用,ADC转换完成信号根本没接到DMA上。

解决:按3.3节的配置顺序逐项检查,dma_circulation_enable、连续模式、adc_dma_mode_enable三个都确认之后再想其他原因。这个坑在从其他M4平台迁移时尤其常见,因为两个平台的DMA请求架构不一样,函数名也经常对不上,很容易漏配置。

4.3 通道数据串位:rank顺序与引脚映射没对齐

现象:数据一直在动,但已知电压出现在错误的数组索引上,而且每次上电位置可能还不一样。

原因:rank参数被理解成了通道号。adc_regular_channel_config(ADC0, 5, ADC_CHANNEL_2, ...)的意思是「第5个被转换的是通道2」,而不是「通道5」。如果按通道号从小到大的直觉去填rank,实际转换顺序会和预期完全错位。

解决:在纸上把「rank序号、通道号、引脚、adc_buf索引」四列对应关系写出来,通道0接已知电压,逐个索引打印一遍,建立完整的映射表。这个映射表以后做电压标定也要用,早列早省事。

4.4 计算采样率很高,实际波形却还原不了:扫描开销被忽略了

现象:定时器触发频率设了100kHz,输入一个10kHz正弦波,还原出来的波形完全变形,甚至看起来像混叠。

原因:只算了单路转换时间,忘了乘16。一路40周期、ADCCLK 25MHz时,单路转换1.6微秒,16路一次扫描25.6微秒,触发周期必须大于25.6微秒,也就是触发频率上限约39kHz。设100kHz意味着上一轮没扫完下一轮触发就来了,数据自然错乱。

解决:用2.3节的公式先算清楚再配参数。如果确需要100kHz每路,就要降低采样时间档位、提高ADCCLK,或改用三ADC并行的方案把通道数摊薄。

4.5 直流输入读数乱跳:阻抗、参考电压与校准的连锁问题

现象:所有通道接同一个稳压源,读数却在几十个LSB范围里跳,中位值和平均值对不上。

原因:通常是三个问题叠加。信号源输出阻抗太高,内部采样电容在短采样时间内没充满;VDDA或参考电压引脚上的纹波太大;跳过校准时ADC内部的失调电压没被修正。

解决:先用低阻抗的运放输出驱动ADC输入,采样时间档位适当拉长,确认VDDA和VREF引脚上的去耦电容够不够。然后把所有通道一起接地,看读数和0的偏差,再做一次adc_calibration。接地读0、接参考电压读4095,这两个点是判断全局误差最快的方法。

5. 16路不够用怎么办:定时器触发、多ADC同步与通道扩展

5.1 定时器触发替代软件延时:等间隔采样怎么配

软件触发加延时是低速调试期的权宜之计。延时本身有误差,中断一多,采样间隔就被拉偏,做FFT时频谱上会出现多余的旁瓣。等间隔采样必须把触发节奏交给硬件定时器。

常见做法是用一个定时器产生更新事件或TRGO信号,接到ADC的外部触发输入,ADC在检测到触发边沿后启动一次规则组扫描。配置时定时器周期就是触发间隔,这个间隔必须大于16路一次完整扫描的时间,否则上一轮没结束下一轮就来了。

timer_enable(TIMER1); /* 配置TIMER1周期为100us,对应10kHz触发频率 */ /* 触发源选择TIMER1的TRGO,边沿模式按具体库函数配置 */ adc_external_trigger_config(ADC0, ADC_EXTERNAL_TRIGGER_MODE_RISING, ENABLE);

这样配置后,每隔100微秒硬件自动开始一轮16路扫描,CPU什么都不用管,DMA把结果搬进缓冲区,主循环只负责消费数据。触发周期的抖动被硬件降到纳秒级,FFT和频谱分析才靠谱。

5.2 三ADC同步采样:让16路尽量站在同一个时刻

前面说过,单ADC扫16路时第0路和第15路之间差着几十微秒,这对工频信号、振动信号是致命的。三ADC同步方案可以把这个偏差压缩到触发同步精度以内。

做法是把16路拆给三个ADC,比如ADC0负责6路、ADC1负责6路、ADC2负责4路,三个ADC配同一个外部触发源。触发信号到来时,三个ADC各自开始转换自己规则组里的通道序列,再通过三条DMA请求把结果搬到三个缓冲区。三个ADC的规则组长度各自独立配置,DMA初始化也要分别做三份。

分配时先查引脚映射表,确认你需要的通道号在对应ADC上可用。不同ADC的通道分布不是完全对称的,某个通道只在特定ADC上存在,这需要在方案设计阶段就定好。同步采样后,16路的时间对齐误差主要来自触发同步的硬件延迟,对绝大多数采集场景都够用了。

5.3 扩展到32路以上:模拟开关复用的代价与稳定时间

16路不够时,常见做法不是找更多ADC,而是在前端加模拟多路选择开关,把多路传感器分时接入同一个ADC输入,用GPIO控制开关地址。比如用模拟开关把8路外部信号切到1路ADC输入,原本的16路就能扩到32路甚至更多。

代价很直接:单路等效采样率要除以扩展倍数。如果原来每路10kS/s,扩展一倍后每路只剩5kS/s。另一个容易被忽略的是稳定时间——模拟开关切换后,开关导通电阻和输入电容会形成一个RC回路,需要几十微秒才能稳定到足够精度,切换完马上启动ADC转换会读到跳变值。所以扩展通道的代码里,切完地址后至少要等一个稳定延时再触发转换。

这个方案适合巡检式采集,不适合所有通道同时高速采。真需要同时高速采大量通道时,只能外挂同步采样ADC芯片,那就不是单片机外设的范畴了。

6. 验证16路数据准确性的三个习惯:标定、噪声和动态响应

数据能不能用,不是看DMA搬得多流畅,而是看它准不准、稳不稳。我每次调完16路采样都会做三件事,做完才敢把数据交给后级算法。

第一是两点标定。把16路全部接同一个精密电压源,分别给两个已知电压,比如0.5V和2.5V,采集后做线性拟合,算出每路的增益和偏移。运放电路、分压电阻、PCB走线都会引入每路不完全一致的误差,只用一个理想公式换算电压是不行的。每路单独记一组标定系数,数据才算真正落地。

第二是噪声评估。所有通道接同一个稳定电源,连续采几千个点,算标准差。如果标准差超过两三个LSB,说明模拟链路有问题,先去查采样时间档位和参考电压去耦,而不是急着调软件滤波。软件滤波只能掩盖噪声,不能消除产生噪声的根源。

第三是动态响应测试。通道0输入一个方波,观察上升沿是否只出现在通道0的索引上,同时确认相邻通道没有被串扰带出毛刺。这一步同时验证了引脚顺序、rank配置和PCB布局三个环节。

这轮调完我也踩了不少坑,最深的体会是:GD32F407的ADC外设功能并不差,翻车基本都翻在映射关系、时钟分频这类「看起来不用查」的细节上。现在每换一块板子,我第一件事都是把三张表先画出来再写代码。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询