GD32H759 ADC/DAC驱动实战:从RT-Thread框架到工业级精度
2026/9/19 18:50:03 网站建设 项目流程

这个系列做到第3篇,ADC/DAC 驱动终于要登场了。前面两篇分别把 GD32H759 的时钟树和 RT-Thread 基础线程理清了,这一篇聊两个模拟外设:ADC 通道的采集和 DAC 通道的输出。做工业控制器的人都知道,真正的麻烦往往不在协议解析、不在显示界面,而是模拟量这一路:采得不准、输出抖了一下,后面整个 PID 和质量判断全乱。所以这一篇不打算只贴一段“能跑的驱动”,更想把采集链路、输出链路、抗干扰布局和验证方法一起讲透。

1. 为什么把 ADC/DAC 单独拿出来:工控信号的三种场景

1.1 模拟量采集不是“读寄存器”那么简单

工业控制里 ADC 的用途大致分三种。第一种是过程量采集,比如 4-20mA 的变送器经过采样电阻转成电压,或者 0-10V 电压信号直接进 ADC,这类信号变化慢,但对精度要求高,往往要做滤波、校准、软件线性化。第二种是快速模拟量反馈,比如电机相电流、开关电源的电感电流,这类信号变化快,需要同步采样、高频连续采集,一般都要 DMA。第三种是触发式采集,等待某个事件发生,比如过压保护、过流保护,ADC 要能迅速锁存一个瞬时值,这时候轮询反而跟不上。

GD32H759 的 ADC 能力放在工控场景里属于比较够用的那一档,但“够用”不等于“直接读就完事”。你读到的每一个数字,背后都经过了采样保持电容、逐次逼近比较器、参考电压源、供电噪声这一大串物理路径。任何一个环节出问题,代码优化得再好也没有用。所以我一直觉得,聊 ADC 驱动,不能只看寄存器,得从信号链的角度看。

1.2 输出模拟量:想清楚你是在做信号,不是做开关

DAC 在工控里常见的是做模拟量输出,比如控制变频器的 0-10V 给定、阀门的 4-20mA 电流环、伺服驱动的模拟速度参考。这类应用里,DAC 输出的微伏级抖动看起来不起眼,但经过外部放大、隔离、功率驱动之后,可能直接被放大成明显的转速波动或压力波动。

更隐蔽的问题是“上电毛刺”。很多 DAC 在上电瞬间输出会有一段不定态,如果你的外部电路没有做钳位或使能控制,执行机构可能在开机时猛地动一下。设计驱动的时候,必须把 DAC 的使能时序、输出缓冲、外部放电电路一起考虑进去,而不是写完rt_dac_write就算完。

这三种场景对应着三种驱动写法:慢速高精度走框架轮询加重采样;快速连续采集走 DMA;需要闭环控制的,用定时器触发 ADC/DAC,让转换周期和 PWM 周期精确对齐。接下来我按实际落地顺序把这些展开。

2. RT-Thread 上用框架还是直接操作寄存器

2.1 rt_adc/rt_dac 设备框架到底能省多少事

RT-Thread 的 ADC 设备框架把“打开设备、使能通道、读取值”这三件事抽象成了标准接口。读一遍rt_adc_read,写一遍rt_dac_write,业务层甚至不用关心你这颗芯片的 ADC 是 12 位还是 16 位,也不用管寄存器地址是多少。这种抽象在功能验证阶段特别有用:

#include <rtthread.h> #include <rtdevice.h> #define ADC_DEV_NAME "adc0" #define ADC_DEV_CHANNEL 5 rt_adc_device_t adc_dev; static void adc_sample_once(void) { rt_uint32_t raw_value; adc_dev = (rt_adc_device_t)rt_device_find(ADC_DEV_NAME); if (adc_dev == RT_NULL) { rt_kprintf("cannot find %s\n", ADC_DEV_NAME); return; } rt_adc_enable(adc_dev, ADC_DEV_CHANNEL); raw_value = rt_adc_read(adc_dev, ADC_DEV_CHANNEL); rt_adc_disable(adc_dev, ADC_DEV_CHANNEL); rt_kprintf("raw: %u, voltage: %d.%03d V\n", raw_value, (int)(raw_value * 3300 / 4096 / 1000), (int)(raw_value * 3300 / 4096 % 1000)); }

关键理解点有两个。第一,rt_adc_enable不只是“打开通道”,它内部会完成 ADC 外设的时钟使能、通道配置、启动转换等动作,所以要放到循环外面做,不要每采样一次就使能关闭一次。第二,框架读到的 raw_value 只代表 ADC 数字量,要换算成工程值,还得结合参考电压做一次比例运算。

DAC 的框架接口同样简洁:

#define DAC_DEV_NAME "dac0" static void dac_set_voltage(void) { rt_dac_device_t dac_dev = (rt_dac_device_t)rt_device_find(DAC_DEV_NAME); if (dac_dev == RT_NULL) { rt_kprintf("cannot find %s\n", DAC_DEV_NAME); return; } rt_dac_enable(dac_dev, 0); rt_dac_write(dac_dev, 0, 2048); }

rt_dac_write的第二个参数是通道号,第三个是数字量。对 12 位 DAC 来说,0-4095 对应 0V 到参考电压。这里我建议先写上2048,也就是一半量程,再用万用表量一下输出是否约为 1.65V,以此确认通道映射和基准电压源都正确。

2.2 我实际用的初始化流程

框架层面对外是同一套 API,但对内还是需要把引脚、时钟和采样时间配好。我的习惯是分三步。

第一步,查数据手册确认 ADC 通道对应的 GPIO。GD32H7 系列通常把 ADC 通道分布在 PA/PB/PC 上,比如 ADC0 的通道 5 对应 PA5,但这不绝对,务必以你所用的具体型号引脚定义为准。第二步,配置 GPIO 为模拟模式,这一步容易错,漏写或配成复用功能会导致采样值始终是 0 或者跳动剧烈。第三步,配置 ADC 时钟。ADC 的采样时钟不是越高越好,时钟太高采样电容充电时间不够,低阻抗信号源可能勉强,但高阻抗源就会采出明显偏小的值。

用 GD32 标准库风格的代码大致是这样:

static void adc_gpio_init(void) { rcu_periph_clock_enable(RCU_GPIOA); gpio_mode_set(GPIOA, GPIO_MODE_ANALOG, GPIO_PUPD_NONE, GPIO_PIN_5); } static void adc_config(void) { rcu_periph_clock_enable(RCU_ADC0); adc_clock_config(ADC0, ADC_CLK_DIV6); adc_channel_length_config(ADC0, ADC_ROUTINE_CHANNEL, 1); adc_routine_channel_config(ADC0, 0, ADC_CHANNEL_5, ADC_SAMPLETIME_15); adc_data_alignment_config(ADC0, ADC_DATAALIGN_RIGHT); adc_enable(ADC0); }

采样时间我这里先写了ADC_SAMPLETIME_15,实际使用中如果外部信号源阻抗比较大,我会往上调到ADC_SAMPLETIME_239甚至更高。代价是采样速率下降,但对工控里的大部分传感器信号来说,完全划得来。

2.3 什么时候必须绕过框架

框架接口不适合所有场景。比如你要做 DMA 循环采集,或者要用定时器触发 ADC 以精确对齐 PWM 中点,或者要把 DAC 的输出和目标正弦表通过 DMA 连续搬运,这时候rt_adc_readrt_dac_write就有点不够用了。RT-Thread 的设备驱动模型里,这几个接口是同步阻塞式,不会帮你管理 DMA、触发源和中断标志。

我的建议是分两层:业务逻辑尽量走框架,底层高频通道自己用寄存器或者直接改 BSP 驱动。如果你愿意折腾,也可以在 RT-Thread 的 ADC 驱动里把 DMA 模式加进去,把新的控制接口通过rt_device_control暴露出来,这样既有框架的封装性,又保留了底层灵活性。

3. ADC 驱动实现:轮询、DMA 和硬件滤波怎么配合

3.1 引脚、时钟和采样周期的关系

配置 ADC 时首先要想清楚输入信号的源头。温度变送器输出 4-20mA 经过 250Ω 电阻变成 1-5V,这种信号源输出阻抗不高,采样周期只要不是极端小都能采准。但如果你直接采集一个高内阻信号源,比如几十 kΩ 的分压网络,而 ADC 采样时间只有 2-3 个 ADC 时钟周期,那采样保持电容还没充满电,读到的值就会明显偏小。

量级的估算是这样:采样保持电容一般在几 pF 到十几 pF,如果外部源阻抗是 10kΩ,那么 RC 充放电时间常数大约是 0.1μs 量级。ADC 的采样开关导通时间必须达到几个时间常数,电荷才能稳定。所以采样周期式并非越长越好,但要结合源阻抗去选。

实际工程里我遵守一个原则:凡是有可能被分压电阻或传感器内阻拉高的输入,一律把采样时间配成最大值附近,先保证稳定,再考虑速度。GD32H7 的 ADC 采样时间选项里有一档几十个周期的值,做过程量采样完全足够。

3.2 轮询模式与 DMA 模式的取舍

轮询模式适合单通道、低速、不要求 CPU 实时响应场景。代码简单,一条adc_software_trigger_enable后轮询结束标志,读走数据就是一次采样。但轮询的问题在于,CPU 被卡住等标志位,RT-Thread 下其他线程会被阻塞。如果采样周期很短、频率很高,轮询会占掉大量 CPU 时间。

所以一旦遇到多通道连续采集,或者需要和 PWM 同步,我就会切到 DMA。DMA 模式下的基本思路是:ADC 每一次转换完成,DMA 把结果搬到内存数组里,一批数据采完之后触发传输完成中断,再通知 RT-Thread 的线程去处理。这样 CPU 只在这个完整周期被唤醒一次,效率高很多。

DMA 配置的一个关键点是数据宽度要匹配。ADC 规则数据寄存器通常是 16 位或 32 位,如果你的 DMA 数据宽度配成 8 位,会把一个值拆成几个字节,数据全乱。我吃过这个亏,查了半天最后发现是GD32_DMA_WIDTH_16BIT没配。

3.3 GD32H 系列硬件滤波的使用

GD32H7 的 ADC 里带了一档硬件滤波能力,看参考手册里的 ADC 滤波功能,它可以对转换结果做平均/低通,主要是为了省掉一部分软件平均运算,同时降低 CPU 压力。

我在固件库的 ADC 配置里这样用:

adc_filter_enable(ADC0); adc_filter_config(ADC0, ADC_FILTER_ORDER_8);

这里的“滤波阶数”我理解为一个滑动窗口长度。设成 8 阶,相当于硬件做了 8 次结果的平均。它和软件平均的区别是,硬件会在每次转换后自动滑动更新,实时性比“采 N 次停下来算平均”更好,内存和代码也省。

但注意,硬件滤波不是万能的。它对白噪声抑制有效,对脉冲型干扰几乎无能为力。所以我的建议是:硬件滤波负责日常平滑,软件里再加一层中值判断,两者配合。如果遇到 EFT 浪涌之类的瞬态干扰,重点还得靠输入端的 RC 滤波和 TVS 管来解决。

3.4 软件滤波:C 语言实现一段实用的均值/滑动滤波

与硬件滤波结合时,我用过最顺手的软件滤波是滑动平均加去极值,代码如下:

#define ADC_FILTER_BUF_LEN 16 static rt_uint32_t adc_filter_buf[ADC_FILTER_BUF_LEN]; static rt_uint8_t adc_filter_idx; rt_uint32_t adc_filter(rt_uint32_t value) { rt_uint32_t sum = 0; rt_uint32_t min = 0xFFFFFFFF; rt_uint32_t max = 0; rt_uint8_t i; adc_filter_buf[adc_filter_idx] = value; adc_filter_idx = (adc_filter_idx + 1) % ADC_FILTER_BUF_LEN; for (i = 0; i < ADC_FILTER_BUF_LEN; i++) { if (adc_filter_buf[i] < min) min = adc_filter_buf[i]; if (adc_filter_buf[i] > max) max = adc_filter_buf[i]; sum += adc_filter_buf[i]; } return (sum - min - max) / (ADC_FILTER_BUF_LEN - 2); }

这个函数先去掉了 16 个采样里的最大最小值,再对剩余 14 个求平均。这样脉冲干扰会被直接剔除,不会像普通滑动平均那样被一个毛刺带偏。实测下来,对 4-20mA 变送器信号,原本偶尔有 ±3 个 LSB 的跳动会被压到 ±1 个 LSB 以内。缺点是多了一些比较运算,但以 H759 的 CPU 算力来说,这点开销完全可以忽略。

4. DAC 驱动实现:电压输出和定时器触发波形

4.1 为什么要用 DAC 而不是 PWM

经常有人问,输出电压给定可以用 PWM + RC 滤波,为什么要上 DAC?PWM 加滤波的优点是便宜、驱动简单,但缺点也很明显。第一,PWM 滤波后纹波比较大,想要纹波低就得把截止频率做得很低,导致响应变慢;第二,PWM 的周期抖动会叠加到输出上,在音频和精密模拟控制场景很致命;第三,PWM 输出能力一般,电平转换时会有较大的毛刺。

所以只要产品里真正需要“干净的模拟电压”,我都会直接安排 DAC。GD32H759 的 DAC 输出是缓冲型的,可以直接驱动一定大小的负载,配合外部运放做电压放大或 V/I 转换。调试时先用万用表量输出,再用示波器看纹波,能非常直观地判断信号质量。

4.2 DAC 初始化、DHR 寄存器与 rt_dac 接口

GD32 的 DAC 配置逻辑里,DAC 触发源可以选软件触发、定时器触发或外部触发。它的输出寄存器走一套 DHR 数据保持寄存器,DHR 全称是 Data Holding Register,DHR12R、DHR12L、DHR8R 对应不同的数据宽度和对齐方式。你用 RT-Thread 框架rt_dac_write时,驱动内部会帮你处理这些寄存器的对齐,但你自己写底层时一定要把握好分辨率。

我实际初始化 DAC 的步骤是:先使能 GPIO 时钟,把 DAC 输出引脚配置为模拟模式;再使能 DAC 时钟,配置触发源、输出缓冲,最后使能 DAC 通道。以 GD32 风格代码为例:

static void dac_config(void) { rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_DAC); gpio_mode_set(GPIOA, GPIO_MODE_ANALOG, GPIO_PUPD_NONE, GPIO_PIN_4); dac_deinit(); dac_trigger_source_config(DAC0, DAC_TRIGGER_SOFTWARE); dac_output_buffer_enable(DAC0); dac_enable(DAC0); }

这里dac_output_buffer_enable就是打开输出缓冲,输出能力会强不少。若你的电路后级已经有运放,也可以关掉缓冲,具体以驱动能力为准。

4.3 用定时器触发 DAC 生成正弦波的关键代码

在工控里,DAC 不但要输出直流,偶尔也要输出斜坡、正弦波这类规律波形。让 CPU 手动控制每个时刻的 DAC 值虽然可行,但很难保证精确时间间隔,因此我通常让定时器负责节奏。

思路是这样:预先把一个周期的正弦波离散值存进数组,然后启动 DMA 循环搬运,每次定时器更新事件触发 DAC,DAC 转换完毕产生 DMA 请求,DMA 自动从数组里取下一个值写入 DHR 寄存器。整个过程中 CPU 完全不用参与,DAC 输出频率由定时器的更新频率决定。

关键代码框架如下:

#define SIN_TABLE_SIZE 256 static rt_uint16_t sin_table[SIN_TABLE_SIZE]; static void sin_table_init(void) { for (rt_uint16_t i = 0; i < SIN_TABLE_SIZE; i++) { float v = 2048.0f + 2047.0f * sinf(2.0f * 3.1415926f * i / SIN_TABLE_SIZE); sin_table[i] = (rt_uint16_t)v; } } static void dac_timer_dma_start(TIMER_T *timer) { dac_trigger_source_config(DAC0, DAC_TRIGGER_TIMER); dac_trigger_enable(DAC0); dma_single_data_parameter_struct dma_param = {0}; dma_deinit(DMA0, DMA_CH3); dma_single_data_parameter_struct_init(&dma_param); dma_param.periph_addr = (uint32_t)&DAC0->DHR12R0; dma_param.memory_addr = (uint32_t)sin_table; dma_param.memory_inc = DMA_MEMORY_INCREASE_ENABLE; dma_param.periph_inc = DMA_PERIPH_INCREASE_DISABLE; dma_param.direction = DMA_MEMORY_TO_PERIPH; dma_param.number = SIN_TABLE_SIZE; dma_param.periph_width = DMA_PERIPH_WIDTH_16BIT; dma_param.memory_width = DMA_MEMORY_WIDTH_16BIT; dma_circular_mode_enable(DMA0, DMA_CH3); dma_single_data_mode_init(DMA0, DMA_CH3, &dma_param); dma_channel_enable(DMA0, DMA_CH3); timer_enable(timer); }

注意,DMA 循环模式下必须保证内存地址连续,数组大小和定时器更新频率匹配,否则波形相位会跳。另外,DAC 触发的真正时机是定时器更新事件,所以你要确认定时器的 TRGO 输出配置正确,别只是开了定时器中断,结果发现波形根本不动。

5. 精度上不去的三个真凶:基准、噪声和 PCB

5.1 从 VREF 说起:基准源选择

很多工程师把 ADC 的参考电压直接用 MCU 的电源引脚接上就完事,这个习惯在要求不高的板子上勉强能跑,但在工控上很容易翻车。因为 MCU 内核电源上会有数字翻转噪声,开关噪声会直接耦合进参考电压,ADC 的量化基准不稳,读数自然就飘。

GD32H759 这类芯片通常会有独立的 VREF 引脚,或者你可以通过配置把基准源切到 VREF+ 通道。设计上我强烈建议用外部基准源芯片给 VREF 供电,常见的像 REF3025、LM4040 这类,精度和温漂都远好于直接用 LDO。只要 VREF 稳了,ADC/DAC 的精度就有一大半的保障。

如果硬件已经固定,只能用 VDD 当基准,那就必须在 PCB 布局上把 VREF 引脚和数字电路尽量隔开,并加足够容量的去耦电容。软件补偿只能校准增益误差,补偿不了动态噪声,这一点要有清晰认知。

5.2 电源噪声与时钟抖动的影响

ADC 是模拟电路和数字电路混合的边界。采样保持开关打开时,采样电容从外部输入取样,同时 VREF 要在极短时间内给内部电容网络充电,这个过程一旦叠上电源噪声,直接结果就是转换结果的低位乱跳。

时钟抖动同样会伤害 ADC。转换时钟每个周期的时间抖动会导致比较器的比较时刻不稳定,尤其在高频输入信号时,时间轴上的抖动会转换成幅度上的噪声,表现为采样值在某一两个 LSB 之间跳动。这种问题靠调采样时间很难消除,得从时钟源、PLL 配置和 PCB 走线去治。

我一般会看 ADC 时钟是从哪个 PLL 分出来的,如果 PLL 锁相环的参数配得不好,输出频谱里可能带杂散。另一个常见做法是稍微降低 ADC 时钟频率,比如配置成 6 分频、8 分频,换来的是更好的稳定度。对工控应用来说,采样率低一点通常没关系,稳定才是第一位的。

5.3 PCB 布局的三个要点

结合我自己的踩坑经验,PCB 布局上最重要的三个要点可以这样概括。

第一,ADC/DAC 相关的模拟地要和数字地做规划,但不一定非得暴力分割。我的经验是,电源地、MCU 地、模拟电路地尽量在一个完整的地平面上,再通过单点或窄桥方式连接到系统地,避免地平面形成天线。模拟采样回路要短、要粗,尽量避开开关电源的电感下方。

第二,VREF、AVDD 引脚附近要放高质量去耦电容,我习惯用电容组:一个 10μF 钽电容或 MLCC,一个 0.1μF 小电容,紧贴引脚放置。去耦电容的地过孔要就近打,不能绕很远再接地,否则效果会大打折扣。

第三,ADC 输入端前面要留了对地电容和串联电阻的位置。这个 RC 电阻电容除了抗干扰,还给采样保持电容提供足够的瞬时充电电流来源。RC 截止频率要结合信号带宽选择,不能一味加大电容,不然信号相位延迟会变大。需要说明的是,根据你手里的应用场景,这些数值最好实际调试确定。

5.4 一个实际的数据漂移排查过程

我之前遇到过一个现象:ADC 常温下稳定,跑半小时后读数逐渐往下漂了十几个 LSB。一开始怀疑是基准芯片温漂,但用万用表量基准电压发现只有 1mV 变化,不像是主因。后来把示波器探头接到 ADC 输入引脚,发现信号本身还在缓慢上升,原来板子上传感器激励电源用的是普通三端稳压,发热后输出电压下降,导致传感器输出整个跟着漂。

这个案例给了一个很重要的排查思路:当 ADC 数据漂移时,不能只盯着 MCU 侧,要从“信号源头 → 传输链路 → 调理电路 → MCU 引脚 → 参考电压 → 软件换算”整条链路逐级排查。很多看起来像 ADC 的问题,其实根本不是 ADC 的问题。

排查链路我一般这样走:

  1. 把 ADC 输入引脚短接到 GND,读到的值应当在接近 0 的很小范围内,如果不是,说明后端有偏置或基准问题。
  2. 把 ADC 输入引脚接到 VREF,读到的值应当接近满量程,以此验证参考电压和通道增益。
  3. 用稳定的可调电源分别给 10%、50%、90% 量程电压,记录 ADC 读数,计算实际增益和偏置误差,再做软件校正。
  4. 将信号源断开,直接把引脚悬空或通过电阻接地,观察底噪,以此判断 MCU ADC 自身噪声。

这套方法我在每一块工控板上都会做一遍,能快速定位问题出在软件、外围电路还是电源。

6. 调试验证:如何证明这套驱动真的能用

6.1 用波形和分压验证线性度

ADC/DAC 驱动写完,不能只在终端打印几个数据就说“通了”。我会用可调直流稳压电源加一个精密电阻分压网络,给 ADC 输入 0V、0.5V、1V、1.5V、2V、2.5V、3.0V、3.3V 这组电压,把实际读数和理论值列成表。通过对比算出线性度误差。如果线性度不好,先查外部 RC 滤波对信号的衰减,再查参考电压是否稳定,最后查 ADC 内部校准系数。

DAC 的线性度验证反之,往 DAC 里写 0、512、1024、2048、3072、4095,用万用表量输出电压。如果输出和理论值偏差大了,可能是 DAC 缓冲器负载太重,也可能基准电压不准。这里我习惯把负载断开再量一次,对比带载和空载的电压差,以此判断驱动能力。

6.2 频谱和统计方法看噪声

示波器看 ADC 输入波形时,能看到的是宏观噪声,很难看出具体频率成分。更好用的办法是把采样数据通过串口传到上位机,用 Python 或 MATLAB 画功率谱,看信号里有没有 50Hz 工频、开关电源开关频率以及这些频率的谐波。

如果频谱上有一个明显的窄带尖峰,比如 300kHz 左右,多半是板载 DC-DC 的开关频率串进来了。这个噪声靠软件滤波很难完全抑制,因为频带太窄,最终还是要从布局或屏蔽下手。如果是均匀分布的底噪,软件滤波效果会好很多。

用统计方法看也很直观:连续采 1000 个点,计算标准差。如果标准差小于 1-2 个 LSB,说明这条采集链路噪声水平可以接受;如果标准差十几个 LSB,就要继续排查。这个方法尤其适合做产线质检时给每块板子定“噪声基线”。

6.3 长稳测试

最后一关是长时间稳定性测试。我会让板子连续运行 24 小时,固定采集一个稳定的模拟信号,每隔 5 分钟记录一次平均值。重点关注两个指标:一是均值是否随时间缓慢漂移,二是瞬时值是否有间歇性跳变。均值漂移很大可能是温漂,间歇性跳变则要怀疑干扰或程序里缓冲区越界。

实际遇到的情况里,间歇性跳变往往最隐蔽,它可能几小时才出现一次,但一旦跳变发生在 PID 闭环里,就可能被误判成真实负载突变,引起输出振荡。长稳测试跑完后,如果数据仍然平稳,我才会把这段 ADC/DAC 驱动代码固化成正式版本,再进下一个功能模块。

最后分享一个我自己的习惯:无论用 RT-Thread 框架还是直接操作寄存器,我都会在 ADC/DAC 驱动里保留一个调试用的 shell 命令,比如adc_getdac_set 2048。后续在项目现场调试时,不需要重新烧程序就能读到模拟量、输出固定电压,排查问题效率会高非常多。这个习惯帮我省了很多来回烧固件的时间,你可以试试。

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

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

立即咨询