1. 项目背景与整体设计思路
1.1 为什么工控场景下ADC/DAC驱动值得单独拎出来讲
在工控板卡开发里,MCU的GPIO翻转、串口收发这些外设,调通一次基本就能稳定跑很久。但ADC和DAC不一样,它们直接跟模拟世界打交道,板子上的电源纹波、地平面噪声、走线耦合、参考电压抖动,任何一个环节出问题,采样值就会飘。我在做GD32H759这块板子的数据采集模块时,最开始用轮询方式读ADC,发现采出来的值跳动能有±15个LSB,后来换成DMA加定时器触发,配合硬件过采样,才把波动压到±2个LSB以内。
这篇内容主要围绕GD32H759这颗Cortex-M7内核的高性能MCU,在RT-Thread操作系统下如何把ADC和DAC驱动跑通、跑稳。适合已经上手过RT-Thread基础外设驱动、正在做数据采集或模拟量输出类工控项目的开发者参考。如果你之前只用过STM32的HAL库,转到GD32的固件库加RT-Thread的设备驱动框架,中间有不少细节需要重新捋一遍。
GD32H759的ADC是12位逐次逼近型,最高采样率可以到5.3MSPS左右,支持差分输入、硬件过采样、模拟看门狗等功能。DAC是12位电压输出型,带输出缓冲,可以配置成噪声波或三角波发生器。这些特性在工控场景里很实用,比如电机电流采样需要高速ADC配合定时器同步触发,而模拟量输出控制阀门或变频器则需要DAC稳定输出0-10V或4-20mA信号。
1.2 驱动方案选型:RT-Thread设备框架 vs 裸机寄存器操作
RT-Thread提供了ADC设备驱动框架,通过rt_adc_enable、rt_adc_read这些标准接口就能读值。但实际工控项目里,我建议不要完全依赖这套上层接口,原因有几个:第一,标准接口每次读都要走一遍设备查找和参数检查,在1kHz以上的采样率下开销不可忽略;第二,硬件过采样、差分输入、模拟看门狗这些高级功能,标准接口没有直接暴露;第三,DMA搬运和定时器触发这种组合拳,需要直接操作寄存器才能配得灵活。
我的做法是:底层用GD32的固件库直接配置ADC和DAC的寄存器,把DMA通道和定时器触发源接好,然后在RT-Thread里注册一个字符设备或者直接用消息队列把数据传给应用线程。这样既保留了RT-Thread的线程调度和同步机制,又不会被上层框架的限制卡住。
DAC这边相对简单一些,因为输出速率通常不高,用RT-Thread的DAC设备框架或者直接写寄存器都行。但要注意DAC的输出建立时间和负载效应,如果后面接的是运放或者隔离变送器,输出阻抗和容性负载会影响稳定性。
1.3 整体数据流设计
整个ADC/DAC驱动的数据流是这样的:定时器产生周期性触发信号,ADC在触发沿启动转换,转换完成后DMA把数据搬到内存缓冲区,缓冲区半满或全满时产生中断,在中断里给RT-Thread的信号量或消息队列发通知,应用线程拿到数据后做滤波、标定、打包,再通过串口或以太网上传。DAC这边,应用线程把要输出的值写入DMA缓冲区,定时器触发DAC转换,DMA自动搬运下一个值,实现任意波形的连续输出。
这个架构的关键在于触发源和DMA的配合。定时器的触发频率决定了采样率,DMA的搬运宽度和缓冲区大小决定了能连续采集多长时间而不丢数据。我一般会把缓冲区设成两倍于一次处理所需的数据量,用双缓冲模式,这样在处理当前缓冲区的数据时,下一个缓冲区已经在后台填充了。
2. ADC驱动核心细节与实操要点
2.1 GD32H759 ADC的时钟与参考电压配置
GD32H759的ADC时钟来源是APB2总线时钟经过一个分频器,最高不能超过60MHz。我一般会把APB2设到120MHz,然后ADC分频系数选2,得到60MHz的ADC时钟。采样率计算公式是:采样率 = ADC时钟 / (采样周期 + 转换周期)。12位转换周期固定是12个ADC时钟,采样周期可以设成1.5到239.5个时钟周期不等。
参考电压这块要特别注意。GD32H759的VREF+可以外部提供,也可以用内部的VREFBUF。工控场景下我强烈建议用外部精密基准源,比如REF5025或者ADR4525,温漂能控制在3ppm/°C以内。如果用MCU的VDDA做参考,开关电源的纹波会直接调制到采样值上,12位ADC的有效位数可能只剩9位不到。
注意:VREF+引脚旁边一定要放一个1μF的陶瓷电容和一个100nF的高频电容,且尽量靠近引脚。我踩过的坑是只放了100nF,结果采样值在低频段有周期性波动,后来加了1μF才压下去。
2.2 硬件过采样与差分输入的配置方法
GD32H759的ADC支持硬件过采样,可以配置2x到256x的过采样率,还能选择右移位数。这个功能在工控里非常实用,比如电机电流采样,PWM开关噪声很大,硬件过采样相当于在ADC内部做了多次采样求平均,比软件滤波省CPU且效果更好。
配置过采样的寄存器是ADC_OVSAMPCTL,需要设置OVSEN使能位、OVSR过采样率、OVSS右移位数。我一般用16x过采样,右移4位,这样等效于把12位ADC变成16位分辨率,但实际有效位数大概能到13.5位左右。右移位数不能超过过采样率的log2值,否则会丢失有效数据。
差分输入这边,GD32H759的ADC支持全差分和伪差分模式。全差分模式下,两个输入引脚分别接信号的正负端,共模电压要设在VREF/2附近。伪差分模式是一个引脚接信号,另一个接固定参考。工控现场的传感器很多是差分输出的,比如应变桥式传感器,用全差分模式能有效抑制共模干扰。
2.3 定时器触发与DMA搬运的联合配置
定时器触发ADC转换是工控采样的标准做法。我一般用高级定时器TIMER0或通用定时器TIMER2,配置成PWM模式或者输出比较模式,在更新事件时产生触发信号。触发源要选对,GD32H759的ADC支持多种触发源,包括定时器0的CH0、CH1、CH2、CH3,定时器1的CH0,定时器2的TRGO等。
DMA配置这边,要把ADC的DMA请求使能,设置成循环模式,数据宽度选半字(16位),因为ADC数据寄存器是16位的。DMA的源地址是ADC的规则数据寄存器,目标地址是内存缓冲区。缓冲区大小我一般设成256或512个半字,配合双缓冲模式,半满和全满各产生一次中断。
// ADC+DMA+定时器触发的关键配置片段 void adc_dma_timer_init(void) { // 定时器配置:1kHz触发频率 timer_parameter_struct timer_initpara; rcu_periph_clock_enable(RCU_TIMER2); timer_deinit(TIMER2); timer_initpara.prescaler = 12000 - 1; // 120MHz/12000 = 10kHz timer_initpara.period = 10 - 1; // 10kHz/10 = 1kHz timer_initpara.clockdivision = TIMER_CKDIV_DIV1; timer_initpara.alignedmode = TIMER_COUNTER_EDGE; timer_initpara.counterdirection = TIMER_COUNTER_UP; timer_init(TIMER2, &timer_initpara); // 配置TRGO输出:更新事件 timer_master_output_trigger_source_select(TIMER2, TIMER_TRI_OUT_SRC_UPDATE); timer_enable(TIMER2); // ADC配置 rcu_periph_clock_enable(RCU_ADC0); adc_deinit(ADC0); adc_clock_config(ADC_ADCCK_PCLK2_DIV2); adc_resolution_config(ADC0, ADC_RESOLUTION_12B); adc_data_alignment_config(ADC0, ADC_DATAALIGN_RIGHT); adc_channel_length_config(ADC0, ADC_ROUTINE_CHANNEL, 1); adc_routine_channel_config(ADC0, 0, ADC_CHANNEL_0, ADC_SAMPLETIME_56); // 外部触发配置 adc_external_trigger_source_config(ADC0, ADC_ROUTINE_CHANNEL, ADC_EXTTRIG_ROUTINE_T0_CH0); adc_external_trigger_config(ADC0, ADC_ROUTINE_CHANNEL, ENABLE); // DMA配置 rcu_periph_clock_enable(RCU_DMA0); dma_deinit(DMA0, DMA_CH0); dma_init_struct.direction = DMA_PERIPHERAL_TO_MEMORY; dma_init_struct.memory_addr = (uint32_t)adc_buffer; dma_init_struct.memory_inc = DMA_MEMORY_INCREASE_ENABLE; dma_init_struct.memory_width = DMA_MEMORY_WIDTH_16BIT; dma_init_struct.number = ADC_BUFFER_SIZE; dma_init_struct.periph_addr = (uint32_t)&ADC_RDATA(ADC0); dma_init_struct.periph_inc = DMA_PERIPH_INCREASE_DISABLE; dma_init_struct.periph_width = DMA_PERIPHERAL_WIDTH_16BIT; dma_init_struct.priority = DMA_PRIORITY_HIGH; dma_init(DMA0, DMA_CH0, &dma_init_struct); dma_circulation_enable(DMA0, DMA_CH0); dma_memory_to_memory_disable(DMA0, DMA_CH0); dma_transfer_direction_config(DMA0, DMA_CH0, DMA_PERIPHERAL_TO_MEMORY); dma_channel_enable(DMA0, DMA_CH0); adc_dma_mode_enable(ADC0); adc_enable(ADC0); adc_calibration_enable(ADC0); }这段代码里有个细节:adc_calibration_enable必须在ADC使能之后、开始转换之前调用,否则校准不生效。我一开始把校准放在使能之前,结果采样值一直偏大,查了半天才发现是校准没跑。
2.4 采样数据的软件滤波与标定
硬件过采样之后,软件这边我还会加一层滑动平均或者中值滤波。滑动平均适合抑制随机噪声,中值滤波适合剔除脉冲干扰。工控现场两种干扰都有,所以我一般用“中值+滑动平均”的组合:先取5个点做中值,再对中值结果做8点滑动平均。
标定这边,两点标定法最实用。给ADC输入两个已知电压,比如0.5V和2.5V,记录对应的采样值,算出斜率和截距。工控里经常用4-20mA电流环,需要在ADC前端加一个精密电阻把电流转成电压,比如250Ω电阻把4-20mA转成1-5V。这时候标定点就选1V和5V对应的采样值。
实操心得:标定的时候不要只标一次就完事,最好在常温下标定后,再放到高低温箱里跑一遍,记录温漂曲线。如果温漂超过0.1%/°C,就要考虑加温度补偿或者换低温漂的基准和电阻。
3. DAC驱动核心细节与实操要点
3.1 DAC输出缓冲与负载匹配
GD32H759的DAC输出缓冲可以使能或禁用。使能缓冲时,输出阻抗低,驱动能力强,但输出范围会受限,大概在0.2V到VDDA-0.2V之间。禁用缓冲时,输出范围能到轨到轨,但输出阻抗高,带负载能力弱。工控场景下,如果DAC后面直接接运放做电压跟随,我建议禁用缓冲,让运放来处理驱动问题;如果直接驱动小负载,就使能缓冲。
负载匹配这块,DAC输出端一定要加RC低通滤波,截止频率根据信号带宽来定。比如输出的是4-20mA对应的控制电压,带宽只有几十Hz,RC可以选1kΩ+10μF,截止频率约16Hz,能有效滤掉高频噪声。但要注意电容的等效串联电阻和温度特性,X7R或X5R的陶瓷电容在温度变化时容值会变,最好用C0G或薄膜电容。
3.2 定时器触发DAC与DMA波形输出
DAC要输出连续波形,必须用定时器触发加DMA搬运。GD32H759的DAC支持多种触发源,包括定时器0的TRGO、定时器1的TRGO、定时器2的TRGO等。我一般用TIMER1的TRGO,因为TIMER1是通用定时器,配置灵活。
DMA这边,要把DAC的DMA请求使能,设置成循环模式,数据宽度选半字。DMA的源地址是波形数据数组,目标地址是DAC的数据保持寄存器。每次定时器触发,DAC就从DHR寄存器取一个新值转换输出。
// DAC+DMA+定时器触发输出正弦波的关键配置 #define WAVE_POINTS 256 uint16_t sine_wave[WAVE_POINTS]; void dac_wave_init(void) { // 生成正弦波表:0-4095对应0-3.3V for (int i = 0; i < WAVE_POINTS; i++) { sine_wave[i] = (uint16_t)(2048 + 2047 * sin(2 * 3.1415926 * i / WAVE_POINTS)); } // 定时器配置:输出10kHz正弦波 timer_parameter_struct timer_initpara; rcu_periph_clock_enable(RCU_TIMER1); timer_deinit(TIMER1); timer_initpara.prescaler = 120 - 1; // 120MHz/120 = 1MHz timer_initpara.period = 100 - 1; // 1MHz/100 = 10kHz timer_initpara.clockdivision = TIMER_CKDIV_DIV1; timer_initpara.alignedmode = TIMER_COUNTER_EDGE; timer_initpara.counterdirection = TIMER_COUNTER_UP; timer_init(TIMER1, &timer_initpara); timer_master_output_trigger_source_select(TIMER1, TIMER_TRI_OUT_SRC_UPDATE); timer_enable(TIMER1); // DAC配置 rcu_periph_clock_enable(RCU_DAC); dac_deinit(); dac_trigger_source_config(DAC_OUT0, DAC_TRIGGER_T1_TRGO); dac_trigger_enable(DAC_OUT0); dac_wave_mode_config(DAC_OUT0, DAC_WAVE_DISABLE); dac_output_buffer_enable(DAC_OUT0); dac_enable(DAC_OUT0); // DMA配置 rcu_periph_clock_enable(RCU_DMA1); dma_deinit(DMA1, DMA_CH0); dma_init_struct.direction = DMA_MEMORY_TO_PERIPHERAL; dma_init_struct.memory_addr = (uint32_t)sine_wave; dma_init_struct.memory_inc = DMA_MEMORY_INCREASE_ENABLE; dma_init_struct.memory_width = DMA_MEMORY_WIDTH_16BIT; dma_init_struct.number = WAVE_POINTS; dma_init_struct.periph_addr = (uint32_t)&DAC_DHR12R0; dma_init_struct.periph_inc = DMA_PERIPH_INCREASE_DISABLE; dma_init_struct.periph_width = DMA_PERIPHERAL_WIDTH_16BIT; dma_init_struct.priority = DMA_PRIORITY_HIGH; dma_init(DMA1, DMA_CH0, &dma_init_struct); dma_circulation_enable(DMA1, DMA_CH0); dma_channel_enable(DMA1, DMA_CH0); dac_dma_enable(DAC_OUT0); }这段代码里,正弦波表用sin函数生成,但要注意sin返回的是浮点数,乘完2047后要强制转成uint16_t。另外,DAC的数据对齐方式要跟DMA的搬运宽度匹配,12位右对齐时,DHR12R0寄存器的低12位有效,DMA搬16位数据时高4位会被忽略,所以波形表的值不能超过4095。
3.3 DAC输出建立时间与更新速率
DAC的输出建立时间是指从DHR寄存器写入新值到输出稳定到目标值±0.5LSB所需的时间。GD32H759的DAC建立时间典型值是1μs左右,所以更新速率最高可以到1MSPS。但实际工控场景下,DAC后面通常接RC滤波和运放,运放的压摆率会限制实际建立时间。比如用OPA2277做输出缓冲,压摆率是2.3V/μs,输出3V跳变需要1.3μs,加上RC滤波的充电时间,整体建立时间可能到10μs以上。
所以DAC的更新速率不能只看DAC本身,要把整个模拟链路的建立时间算进去。我一般会留3倍余量,比如链路建立时间10μs,更新周期就设成30μs以上,对应33kHz以下的更新率。
注意:DAC输出端如果接的是隔离变送器,变送器内部通常有采样保持和调制解调,建立时间会更长。我遇到过用某品牌隔离变送器,DAC更新率超过1kHz后输出就开始畸变,后来降到500Hz才正常。
3.4 DAC输出值的标定与校准
DAC的标定比ADC简单,因为输出是确定的。给DHR寄存器写0,测输出电压,再写4095,测输出电压,算出实际斜率和截距。工控里经常需要输出0-10V或4-20mA,DAC的0-3.3V输出需要经过运放放大。放大电路的增益电阻精度直接影响输出精度,我一般用0.1%精度的电阻,温漂25ppm/°C以内的。
校准的时候,不要只校两个点,最好在0%、25%、50%、75%、100%五个点各测一次,看线性度。如果非线性误差超过0.1%,就要考虑DAC本身的积分非线性指标,或者放大电路的线性度问题。
4. RT-Thread环境下的驱动集成与线程同步
4.1 在RT-Thread中注册ADC/DAC设备
RT-Thread的设备驱动框架要求先注册设备,再通过rt_device_find查找。对于ADC,RT-Thread有标准的rt_adc_device结构,但前面说了,我建议底层自己管,只在上层用RT-Thread的同步机制。具体做法是:在rt_hw_adc_init里完成GD32的ADC寄存器配置和DMA初始化,然后创建一个消息队列或信号量,在DMA中断里释放信号量,应用线程等待信号量后从缓冲区取数据。
如果一定要用RT-Thread的ADC框架,需要实现rt_adc_ops结构里的enabled、convert、control这几个回调。convert回调里读ADC数据寄存器,返回采样值。但这种方式每次读都要进一次回调,效率不高,适合低速采样场景。
DAC这边,RT-Thread有rt_dac_device框架,实现rt_dac_ops里的enabled和set_value回调即可。set_value回调里把值写入DHR寄存器。但同样,高速波形输出还是要自己配DMA和定时器。
4.2 DMA中断与线程间通信
DMA半满和全满中断里,我一般只做两件事:清除中断标志,释放信号量。数据处理放在应用线程里做,避免在中断里做浮点运算或内存拷贝。信号量用rt_sem_release释放,应用线程用rt_sem_take等待,超时时间设成采样周期的两倍,防止死等。
// DMA中断服务程序 void DMA0_Channel0_IRQHandler(void) { if (dma_interrupt_flag_get(DMA0, DMA_CH0, DMA_INT_FLAG_FTF)) { dma_interrupt_flag_clear(DMA0, DMA_CH0, DMA_INT_FLAG_FTF); rt_sem_release(&adc_sem); } if (dma_interrupt_flag_get(DMA0, DMA_CH0, DMA_INT_FLAG_HTF)) { dma_interrupt_flag_clear(DMA0, DMA_CH0, DMA_INT_FLAG_HTF); rt_sem_release(&adc_sem); } } // 应用线程 void adc_thread_entry(void *parameter) { while (1) { if (rt_sem_take(&adc_sem, rt_tick_from_millisecond(10)) == RT_EOK) { // 处理adc_buffer里的数据 process_adc_data(adc_buffer, ADC_BUFFER_SIZE); } } }这里有个坑:DMA中断标志清除要在释放信号量之前,否则如果信号量释放后线程立刻被调度,中断标志还没清,会再次进中断。我一开始顺序反了,导致中断频繁触发,CPU占用率飙到80%。
4.3 采样线程与输出线程的优先级安排
RT-Thread的线程优先级是数值越小优先级越高。ADC采样线程我一般设成优先级10,DAC输出线程设成优先级12,数据处理和通信线程设成优先级15。这样采样线程能及时响应DMA中断,DAC输出线程保证波形连续,数据处理线程在后台慢慢算。
如果采样率很高,比如100kHz以上,采样线程可能来不及处理所有数据。这时候要么提高采样线程优先级,要么用双缓冲加零拷贝的方式,让采样线程只负责切换缓冲区,数据处理交给更低优先级的线程。
实操心得:RT-Thread的线程栈大小要留够。ADC数据处理如果用了浮点运算和
printf,栈至少给2KB。我一开始给1KB,结果跑一段时间就hard fault,查了半天是栈溢出。
5. 常见问题与排查技巧实录
5.1 ADC采样值跳动大或始终为0
采样值跳动大,先查参考电压。用示波器看VREF+引脚,纹波超过1mV就会影响12位ADC。如果VREF+是从VDDA分压来的,大概率是电源纹波问题,换外部基准源。再查模拟输入端的RC滤波,截止频率太高会让高频噪声混进来,太低会让信号建立时间不够。我一般用1kΩ+100nF,截止频率约1.6kHz,对大多数工控信号够用。
采样值始终为0,先查ADC使能位和校准位。GD32H759的ADC使能后需要等稳定时间,大概1μs左右,然后才能校准。校准需要大概14个ADC时钟周期。如果校准没完成就开始转换,读出来的就是0。再查DMA配置,如果DMA没使能或者传输方向配反了,数据也搬不到缓冲区。
5.2 DAC输出无变化或波形畸变
DAC输出无变化,先查DAC使能位和触发源。如果触发源选错了,DAC不会更新。再查DMA配置,如果DMA没使能或者循环模式没开,波形只输出一次就停了。还要查DHR寄存器的数据对齐方式,12位右对齐时写超过4095的值会被截断。
波形畸变,先查定时器触发频率和DAC建立时间是否匹配。触发太快,DAC还没建立就更新下一个值,输出就会失真。再查RC滤波的截止频率,如果截止频率低于信号频率,波形会被严重衰减。我遇到过用10kΩ+100nF做滤波,截止频率160Hz,输出1kHz正弦波时幅度只剩一半。
5.3 DMA传输丢数据或中断不触发
DMA丢数据,先查DMA缓冲区大小和采样率是否匹配。缓冲区太小,DMA搬完一圈后如果CPU还没处理完,新数据就会覆盖旧数据。我一般把缓冲区设成一次处理数据量的4倍以上。再查DMA优先级,如果ADC的DMA和别的外设共用DMA控制器,优先级低的会被抢占。
中断不触发,先查中断使能位和NVIC配置。GD32H759的DMA中断需要在DMA控制器和NVIC里都使能。再查中断标志清除时机,如果清除太早,中断可能丢失;清除太晚,会重复进中断。我一般是在中断服务程序开头清除标志,然后处理数据。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| ADC采样值跳动大 | 参考电压纹波大 | 示波器测VREF+ | 换外部基准源,加滤波电容 |
| ADC采样值始终为0 | 校准未完成 | 查校准寄存器 | 使能后延时再校准 |
| DAC输出无变化 | 触发源配置错误 | 查触发源寄存器 | 选对定时器TRGO |
| DAC波形畸变 | 建立时间不够 | 算触发周期 | 降低触发频率 |
| DMA丢数据 | 缓冲区太小 | 查缓冲区大小 | 增大缓冲区,用双缓冲 |
| DMA中断不触发 | NVIC未使能 | 查NVIC寄存器 | 使能对应中断通道 |
| 采样线程栈溢出 | 栈太小 | 查hard fault信息 | 增大线程栈 |
| 中断频繁触发 | 标志清除顺序错 | 查中断服务程序 | 先清标志再释放信号量 |
5.5 几个容易忽略的硬件细节
ADC输入端一定要加钳位保护。工控现场传感器接线错误或者浪涌,可能把高于VDDA的电压灌进ADC引脚,烧毁内部ESD二极管。我一般用BAV99双二极管做钳位,再加一个100Ω限流电阻。
DAC输出端如果接长线,要加RC滤波和TVS管。长线容易耦合干扰,TVS管能吸收浪涌。RC滤波的电阻不要太大,否则输出阻抗高,容易受后级负载影响。
地平面分割要合理。模拟地和数字地单点连接,连接点选在ADC或DAC的AGND引脚附近。如果地平面分割不好,数字噪声会通过地平面耦合到模拟部分,采样值上会看到跟数字信号同步的尖峰。
最后分享一个小技巧:调试ADC的时候,先把采样率降到很低,比如10Hz,用万用表测输入电压,跟ADC读数对比,确认标定准确。然后再逐步提高采样率,观察噪声变化。这样能把标定问题和噪声问题分开排查,效率高很多。