
简介基于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路ADCDMAGD32F407上最容易被低估的采集工程做一块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负责05路ADC1负责611路ADC2负责1215路三个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路ADCDMA跑通初始化、循环搬运与数据校验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路ADCDMA的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 815是否真的配了通道号。排查时用调试器看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外设功能并不差翻车基本都翻在映射关系、时钟分频这类「看起来不用查」的细节上。现在每换一块板子我第一件事都是把三张表先画出来再写代码。希望帮到你。本文还有配套的精品资源点击获取