前面两篇把GD32H759的工程模板、GPIO、串口这些基础部分捋顺之后,这一篇终于轮到模拟量了。在工控现场,ADC和DAC几乎是绕不开的一对组合:传感器给的是4~20mA电流或者0~10V电压,得靠ADC变成数字量给CPU算;算完之后要控制变频器、比例阀或者仪表,又得靠DAC输出模拟量。所以这一篇我直接把GD32H759的ADC和DAC都讲透,包括硬件设计要考虑的东西、RT-Thread设备驱动怎么对接、实际采样和输出会遇到哪些坑,全部揉在一起,照着做就能跑起来。
无论你是刚从STM32转过来,还是已经把GD32H759当裸机单片机用过一阵子,这篇文章都能帮你少走弯路。GD32H759的ADC模块说实话比ST的H7系列更接近传统GD32风格,初始化套路熟悉之后上手很快;DAC部分则要格外注意触发方式和DHR寄存器这两件事,很多人在这一步被卡住。
1. 先把需求和硬件底子摸清楚
1.1 工控场景里ADC/DAC到底在干什么
别一上来就写代码。我见过太多人拿到板子就开始点寄存器,最后做出来的东西在现场根本没法用。我们先理清楚,工控环境里的模拟量输入输出,和实验室里拿个电位器拧一拧完全是两码事。
ADC端的典型需求是:采集4~20mA变送器信号(压力、温度、液位)、采集0~10V或0~5V标准电压信号、采集NTC热电阻或热电偶的微弱电压。这些信号有几个共同特征,一是现场干扰大,电机启停、变频器PWM都可能把噪声耦合进来;二是信号源阻抗可能很高,尤其热电偶和某些电流环电路;三是用户对精度有硬指标,比如0.5%级仪表就要求12位ADC加上合理的信号调理,误差不能随便漂。
DAC端的典型需求则是:输出0~10V控制变频器频率、输出4~20mA驱动阀门定位器、或者直接产生一个可编程的电压基准给测试台用。这些场景对DAC的要求没有ADC那么苛刻,但因为DAC输出要驱动外部负载,输出缓冲、驱动能力和输出电压范围必须单独考虑。
GD32H759自带3个12位ADC和2个12位DAC,从外设数量上说,单芯片覆盖这些场景完全够用。有人会问,既然要跑RT-Thread,为什么还要用MCU自带的ADC而不外挂一个高精度ADC芯片?答案很简单:成本和响应速度。MCU内置ADC在大多数中小型工控设备里精度足够,而且数据直接进内存、进总线,延迟极小;外挂ADC芯片通常是24位高精度场景才需要,而且驱动复杂度会明显上升。
1.2 GD32H759的ADC/DAC外设能力
GD32H759这一代芯片主核是Cortex-M7,跑600MHz,外设资源相当豪横。具体到ADC,它有三个独立的ADC模块(ADC0、ADC1、ADC2),每个都可以配置为12位分辨率,支持多种触发源,包括定时器触发和外部引脚触发,每个模块有十几个外部输入通道。DAC部分是两路独立的12位DAC输出,支持三角波和噪声波生成模式,支持定时器触发和DMA搬运数据。此外还有模拟看门狗、过采样(硬件求平均)、规则组和注入组这些标准高级外设都具备的功能。
对工控项目来说,三个ADC最实用的价值是可以并行采集,比如一路采集电机相电流,一路采集母线电压,另一路采集温度,互不干扰。DAC两路可以分别输出不同的控制电压,两个阀门或者两台变频器就能同时控。
ADC的硬件过采样值得一提。12位ADC配合最高32倍过采样,理论上能把有效位数往上提,虽然达不到传闻中“等效16位”那么夸张,但在信号比较平稳的工控场景里,噪声抑制效果非常明显。我实际测试过,环境干扰较强时,打开4倍过采样之后,采样数据的跳动幅度能从十几个LSB压到三四个LSB。这个功能后面我会详细讲怎么配。
1.3 为什么要用RT-Thread设备驱动框架而不是裸机寄存器直操作
很多从裸机转过来的工程师,第一次看到RT-Thread的设备框架会有点抵触,觉得多了一层封装,跑起来不如直接写寄存器爽快。我的看法是:裸机直接操作寄存器适合Demo和验证,但一旦项目进入正轨,任务一多,设备框架的优势就出来了。
RT-Thread把ADC设备抽象成rt_adc_read、rt_adc_enable、rt_adc_disable这几个接口,把DAC设备抽象成rt_dac_write、rt_dac_enable这些接口。应用层的代码完全不需要关心底层寄存器细节,换芯片平台时,只要驱动层对接好了,应用层一行都不用改。对于工控产品这种动辄维护三五年的项目,这种解耦带来的维护红利非常大。
另一个实际原因:RT-Thread的rt_device_find机制可以让你在应用层用字符串名字找设备,配合msh命令行调试非常方便。调试ADC的时候,直接敲一行命令就能读回采样值,不用每次重新编译烧录。这个体验一旦用上就回不去了。
2. 硬件设计与引脚分配(动手前的关键一步)
2.1 引脚和通道怎么选
GD32H759的ADC引脚通常是PA0~PA7对应ADC0的通道0~7,PB0、PB1对应通道8、9,PC0~PC5对应通道10~15,DAC0输出在PA4,DAC1输出在PA5。具体到某个封装可能略有差异,拿到板子第一件事就是打开原理图确认引脚,别凭记忆接。
选通道有几个原则。第一,尽量把强干扰源和模拟输入引脚隔离开。比如PWM输出引脚和ADC输入引脚不要挨在一起,否则采样数据里全是毛刺。第二,如果你要用三个ADC并行采集,三个通道要分布好,避免一个ADC模块切换通道带来的串扰。第三,DAC输出引脚和ADC输入引脚也要保持距离,DAC输出的是模拟电压,如果走线太长又和ADC输入走线平行,很容易把噪声耦合进采样通道。
GPIO模式必须配置成模拟输入,这一点特别容易忘。用GD32标准库的话是gpio_mode_set(GPIOA, GPIO_MODE_ANALOG, GPIO_PUPD_NONE, GPIO_PIN_1),注意要关掉上下拉。模拟输入本来就应该是高阻态,如果漏配成浮空输入,虽然也能工作,但耐压和噪声特性都不对。
2.2 采集链路:采样电阻、运放和RC滤波
采集4~20mA电流信号最常规的做法是串一个250Ω的精密电阻,把电流转成1~5V电压。问题是GD32H759的ADC供电电压是3.3V(VDDA),1~5V直接进ADC会超量程,必须分压或者用运放做电平转换。
简单一点的做法是分压。要在1~5V输入下把满量程压到3.3V以内,分压比取0.66左右。取R1=12kΩ、R2=23.2kΩ,5V输入时分压输出约3.295V,留了一点余量。但分压之后的源阻抗大约是7.7kΩ,直接进ADC会拖慢采样建立时间,所以后面最好加一个运放跟随器缓冲,也可以用仪表放大器直接做差分转单端。
0~10V电压采集更简单,分压比取1/3,用R1=20kΩ、R2=10kΩ,10V输入时分压输出约3.333V。同样建议加跟随器。
RC低通滤波是ADC前端必不可少的,现场信号的干扰频率通常远高于信号本身。以100Hz的信号为参考,取R=1kΩ、C=100nF,截止频率约1.59kHz,既能有效衰减高频干扰,又不会把有用信号压瘪。如果你采集的是更慢的温度信号,C还可以加大到1uF,截止频率降到159Hz,抗干扰更强。
ADC源阻抗对采样精度的影响很多人不重视。12位ADC要求采样电容在采样窗口内充到最终值的0.024%以内,这需要大约8.3个时间常数的时间。假设外部源阻抗是10kΩ,ADC内部采样电容约10pF,时间常数就是100ns,8.3个时间常数约830ns。如果ADC时钟36MHz、采样时间配15个周期,只有约417ns,那就明显不够,测出来会偏小且不稳定。所以要么加运放把源阻抗压到几百欧以内,要么把采样时间调到更长的档位。
2.3 输出链路:DAC缓冲、放大与保护
DAC的输出路径很多人只关心DHR寄存器里写多少,忽略了后面的模拟电路。GD32H759的DAC模块内部有输出缓冲,开启之后可以直接驱动一定负载,但带载能力有限。要驱动更重的负载,比如控制一个电压输入的变频器,外部最好再放一级运放跟随器。
如果DAC要输出0~10V,而MCU供电是3.3V,就必须用运放把0~3.3V放大到0~10V。取放大倍数约3.03倍,可以用同相放大电路,Rf=20kΩ、Rg=10kΩ,实际放大3倍,最大输出9.9V,够用。
DAC输出端也需要保护。外部接线端子万一碰到强电,一瞬间就能把MCU引脚打坏。常规做法是在DAC输出引脚上串一个100Ω电阻,再接一个TVS管或者肖特基二极管钳位到3.3V电源轨。这样就算外部误接24V,电流也会被TVS吸收,而不是直接灌进MCU。
对ADC输入端同样要加钳位保护,现场长长的信号线就是一根天线,静电和浪涌随时可能打进来。ADC输入并联的TVS电容不能太大,否则会影响信号上升时间,常规选几pF到几十pF的低结电容TVS,加上原有的RC滤波电容,保护效果足够。
3. ADC驱动:从寄存器到RT-Thread设备
3.1 初始化配置:时钟、分辨率、对齐、采样时间
ADC初始化的关键流程我直接给出一段可参考的GD32标准库代码,配置ADC0的通道1(PA1)为12位右对齐,软件触发,单次转换模式:
void adc0_init(void) { rcu_periph_clock_enable(RCU_ADC0); rcu_periph_clock_enable(RCU_GPIOA); /* PA1 模拟输入, 对应 ADC0_IN1 */ gpio_mode_set(GPIOA, GPIO_MODE_ANALOG, GPIO_PUPD_NONE, GPIO_PIN_1); adc_deinit(ADC0); /* 独立工作模式 */ adc_sync_mode_config(ADC0, ADC_SYNC_MODE_INDEPENDENT); /* 数据右对齐 */ adc_data_alignment_select(ADC0, ADC_DATAALIGN_RIGHT); /* 12位分辨率 */ adc_resolution_config(ADC0, ADC_RESOLUTION_12B); /* 规则组只配1个通道 */ adc_channel_length_config(ADC0, ADC_REGULAR_CHANNEL, 1); /* 配置通道1, 排在规则组第1个位置, 采样时间用15个周期 */ adc_regular_channel_config(ADC0, 0, ADC_CHANNEL_1, ADC_SAMPLETIME_15); /* 无外部触发, 纯软件触发 */ adc_trigger_config(ADC0, ADC_REGULAR_CHANNEL, ADC_EXTERNAL_TRIGGER_MODE_NONE); /* 使能连续转换 */ adc_continuous_mode_enable(ADC0); /* 使能ADC */ adc_enable(ADC0); /* 开启校准, 必须先使能后校准 */ adc_calibration_enable(ADC0); /* 触发第一轮转换 */ adc_software_trigger_enable(ADC0, ADC_REGULAR_CHANNEL); }这段代码里最关键的两步是adc_enable和adc_calibration_enable的顺序。先使能、再校准,这是GD32系列一个约定俗成的步骤,顺序反了校准值可能无效。校准完成之后ADC内部的失调误差会被修正,直接关系到底数准不准。
采样时间的选择我给个通用建议:源阻抗低、信号频率高,就用短采样时间;源阻抗高、信号频率低或者追求稳定性,就用长采样时间。15个周期是一个比较折中的值,信号源经过运放跟随之后完全够用。
3.2 触发方式与多通道扫描+DMA
软件触发适合单通道或低频轮询,但工控项目里多通道连续采集是常态。比如我要同时看4路传感器,每路每秒才变化几十次,如果每次都用软件触发串行去读,CPU的时间就全耗在等待转换上了。这时候正确的做法是配置DMA,让硬件自己把多通道的转换结果搬到内存数组里,CPU在后台该干嘛干嘛。
多通道+DMA的配置套路如下:先把规则组长度改成4,依次把4个通道排进去,然后使能DMA请求,配置DMA为循环模式,源地址指向ADC规则数据寄存器,目标地址指向一个uint16_t adc_values[4]的数组,最后软件触发一次,之后ADC每转换完一个通道就会通过DMA把数据搬到内存。
uint16_t adc_values[4] = {0}; void adc0_multi_dma_init(void) { /* 参考单通道初始化, 将通道长度改为4 */ adc_channel_length_config(ADC0, ADC_REGULAR_CHANNEL, 4); adc_regular_channel_config(ADC0, 0, ADC_CHANNEL_1, ADC_SAMPLETIME_15); adc_regular_channel_config(ADC0, 1, ADC_CHANNEL_2, ADC_SAMPLETIME_15); adc_regular_channel_config(ADC0, 2, ADC_CHANNEL_3, ADC_SAMPLETIME_15); adc_regular_channel_config(ADC0, 3, ADC_CHANNEL_4, ADC_SAMPLETIME_15); /* 使能DMA请求 */ adc_dma_mode_enable(ADC0); /* DMA初始化: 外设地址为 ADC0_RDATA, 内存地址为 adc_values, 长度4, 循环模式 */ dma_single_data_parameter_struct dma_param = {0}; dma_param.periph_addr = (uint32_t)(&ADC_RDATA(ADC0)); dma_param.periph_inc = DMA_PERIPH_INCREASE_DISABLE; dma_param.memory_addr = (uint32_t)adc_values; dma_param.memory_inc = DMA_MEMORY_INCREASE_ENABLE; dma_param.direction = DMA_PERIPH_TO_MEMORY; dma_param.number = 4; dma_param.periph_width = DMA_PERIPH_WIDTH_16BIT; dma_param.memory_width = DMA_MEMORY_WIDTH_16BIT; dma_init(DMA0, DMA_CH0, &dma_param); dma_circulation_enable(DMA0, DMA_CH0); dma_channel_enable(DMA0, DMA_CH0); adc_software_trigger_enable(ADC0, ADC_REGULAR_CHANNEL); }DMA搬运到内存后,应用层直接读adc_values[0]到adc_values[3]就是4个通道的原始值,不需要任何CPU介入。如果希望这4个通道固定以某个频率刷新,配合定时器触发ADC即可,这个后面会在DAC部分一起讲。
3.3 硬件过采样怎么用
GD32H759的ADC过采样模块本质上是对多次转换结果做累加和移位,等效为硬件级的滑动平均。它和软件滤波的区别在于,硬件过采样不占CPU时间,而且内部处理逻辑比软件循环更快,不会出现数据断流。
过采样配置有两个关键参数:过采样倍率和位移位数。过采样倍率是累加的次数,一般支持2、4、8、16、32倍。位移位数是把累加结果右移的比特数,右移位数决定了最终数据的有效位宽。比如12位ADC做16倍过采样,累加结果最大有16位,右移4位后仍然是12位数据,但等效噪声降低;如果只右移0位,结果会变成16位,分辨率看起来更高,但实际只有低4位是噪声平均出来的伪精度,功耗和转换时间却实实在在增加了。
工控场景我推荐4倍过采样配合右移2位,输出还是12位,但数据稳定性提升非常明显。如果你对响应速度不敏感,可以开到16倍过采样,数据基本就是稳稳的。
/* 使能16倍过采样, 右移4位 */ adc_oversample_mode_enable(ADC0); adc_oversample_shift_config(ADC0, ADC_OVERSAMPLE_SHIFT_4BIT); adc_oversample_ratio_config(ADC0, ADC_OVERSAMPLE_RATIO_16);配置过采样之后,ADC的转换时间会成倍增加,这一点务必记牢。16倍过采样意味着完成一次有效转换要等16次内部转换全部结束,如果信号本身变化很快,过采样反而会造成信号滞后。所以在做电机电流环这种高频场合,过采样要慎用。
3.4 对接RT-Thread ADC设备框架
RT-Thread的ADC设备框架抽象非常干净,驱动层只需要实现两个回调:enabled和convert。我基于GD32标准库直接给出一个可用的适配代码:
#include <rtdevice.h> #include "gd32h7xx.h" static struct rt_adc_device adc0_device; static rt_err_t _adc0_enabled(struct rt_adc_device *dev, rt_uint32_t channel, rt_bool_t enabled) { if (enabled) { /* 使能对应通道的模拟输入, 实际GPIO配置在init里已经完成 */ } else { /* 通道禁用处理 */ } return RT_EOK; } static rt_err_t _adc0_convert(struct rt_adc_device *dev, rt_uint32_t channel, rt_uint32_t *value) { uint16_t raw = 0; adc_regular_channel_config(ADC0, 0, (uint8_t)channel, ADC_SAMPLETIME_15); adc_software_trigger_enable(ADC0, ADC_REGULAR_CHANNEL); while (SET != adc_flag_get(ADC0, ADC_FLAG_EOC)) { /* 等待转换结束 */ } adc_flag_clear(ADC0, ADC_FLAG_EOC); raw = (uint16_t)adc_regular_data_read(ADC0); *value = raw; return RT_EOK; } static struct rt_adc_ops adc0_ops = { .enabled = _adc0_enabled, .convert = _adc0_convert, }; int rt_hw_adc0_init(void) { adc0_init(); rt_hw_adc_register(&adc0_device, "adc0", &adc0_ops, (void *)ADC0); return RT_EOK; } INIT_BOARD_EXPORT(rt_hw_adc0_init);这里有一个RT-Thread的坑必须提醒:rt_adc_read返回的值不是原始转换结果的直接值,而是框架统一做了16位对齐。12位ADC的原始值范围是0~4095,对齐之后变成0~65520,也就是原始值左移了4位。很多人在应用层写float voltage = rt_adc_read(adc, 1) * 3.3f / 4095;,结果算出来的电压明显偏大,这就是忘了除以16。正确写法是float voltage = rt_adc_read(adc, 1) / 16 * 3.3f / 4095;,或者说先除以16还原原始值。
3.5 应用层读取与数值换算
设备注册好之后,应用层就是标准的RT-Thread三板斧:先找设备,再使能通道,最后读取。我习惯把“原始值转物理量”的换算单独封装一个函数,这样上层只管电压、电流,不用关心ADC细节。
#include <rtdevice.h> #define ADC_DEV_NAME "adc0" #define ADC_CHANNEL_PRES 1 /* PA1 */ #define ADC_REF_VOLTAGE 3.3f #define ADC_RESOLUTION 4095 static rt_adc_device_t adc_dev = RT_NULL; float adc_read_voltage(void) { rt_uint32_t raw_aligned = 0; rt_uint16_t raw = 0; float voltage = 0.0f; if (adc_dev == RT_NULL) { adc_dev = (rt_adc_device_t)rt_device_find(ADC_DEV_NAME); if (adc_dev == RT_NULL) { return -1.0f; } rt_adc_enable(adc_dev, ADC_CHANNEL_PRES); } raw_aligned = rt_adc_read(adc_dev, ADC_CHANNEL_PRES); raw = (rt_uint16_t)(raw_aligned / 16); /* 还原12位原始值 */ voltage = (float)raw * ADC_REF_VOLTAGE / ADC_RESOLUTION; return voltage; }如果是4~20mA经过250Ω电阻转成1~5V再分压,最终计算电流时要把分压比和电阻值一起折算回去。我习惯在代码里把链路写清楚,这样几个月后自己回来看也能一眼明白。实测下来,用这个流程采一个稳定的直流电压,12位分辨率下波动基本控制在一两个LSB以内,配合过采样更稳。
4. DAC驱动:从DHR寄存器到模拟波形
4.1 初始化配置:时钟、触发源、输出模式
DAC的初始化比ADC简单,但触发方式的选择直接决定了后面能不能做定时器触发和DMA波形输出。先给一个最基础的单通道软件控制版本:
void dac0_init(void) { rcu_periph_clock_enable(RCU_DAC); rcu_periph_clock_enable(RCU_GPIOA); /* PA4 模拟输出, 对应 DAC0_OUT */ gpio_mode_set(GPIOA, GPIO_MODE_ANALOG, GPIO_PUPD_NONE, GPIO_PIN_4); dac_deinit(DAC0); /* 不使用波形发生器, 直接输出恒定电平 */ dac_wave_mode_config(DAC0, DAC_WAVE_DISABLE); /* 触发源选软件, 或者直接禁止触发 */ dac_trigger_source_config(DAC0, DAC_TRIGGER_SOFTWARE); dac_trigger_enable(DAC0); /* 使能输出缓冲, 增强带载能力 */ dac_output_buffer_enable(DAC0); /* 先输出0V, 12位右对齐 */ dac_data_set(DAC0, DAC_ALIGN_12B_R, 0); dac_enable(DAC0); }输出缓冲建议默认打开。GD32的DAC输出缓冲开启后,输出阻抗明显降低,驱动能力增强,虽然会带来一点点增益误差和失调误差,但绝大多数工控场景下利大于弊。只有你要做高精度低噪声输出时,才会考虑关闭缓冲、外部再加精密运放。
4.2 重点理解DHR数据保持寄存器
DAC的DHR(Data Holding Register)寄存器是这个外设的“灵魂”。12位模式下,数据不是直接写进最终输出寄存器(DOR)的,而是先写到DHR,再由触发事件搬运到DOR,最终体现在模拟输出引脚上。这个设计让MCU可以在DAC输出还没更新的时候,提前把下一个值准备好,从而实现“数据更新不占用CPU、由硬件定时触发”的效果。
DHR寄存器有几种对齐形式:8位右对齐、12位右对齐、12位左对齐。最常见的12位右对齐方式下,写入0对应输出0V,写入4095对应输出满量程(接近VREF)。如果误用了8位模式,写入的数值就被截断成8位精度,输出分辨率会明显下降。这一点在写代码时要注意,dac_data_set的第二个参数要对齐模式,第三个参数才是数值。
软件触发模式下,写DHR后数据会立即更新到DOR并体现在引脚上。如果你配置了外部触发或者定时器触发,那么写DHR只是“准备好”,要等触发信号到来才会真正更新输出。这个机制就是我们做定时器驱动波形输出的核心原理。
4.3 对接RT-Thread DAC设备框架
RT-Thread DAC框架的适配和ADC类似,核心回调是enabled和write。我给出DAC0的适配代码:
static struct rt_dac_device dac0_device; static rt_err_t _dac0_enabled(struct rt_dac_device *dev, rt_uint32_t channel, rt_bool_t enabled) { if (enabled) { dac_enable(DAC0); } else { dac_disable(DAC0); } return RT_EOK; } static rt_err_t _dac0_write(struct rt_dac_device *dev, rt_uint32_t channel, rt_uint32_t value) { /* 限制在12位范围内 */ dac_data_set(DAC0, DAC_ALIGN_12B_R, value & 0xFFF); return RT_EOK; } static struct rt_dac_ops dac0_ops = { .enabled = _dac0_enabled, .write = _dac0_write, }; int rt_hw_dac0_init(void) { dac0_init(); rt_hw_dac_register(&dac0_device, "dac0", &dac0_ops, (void *)DAC0); return RT_EOK; } INIT_BOARD_EXPORT(rt_hw_dac0_init);应用层使用DAC同样三步走:
void dac_write_voltage(float voltage) { rt_dac_device_t dac_dev = (rt_dac_device_t)rt_device_find("dac0"); if (dac_dev == RT_NULL) { return; } rt_dac_enable(dac_dev, 0); /* 0~3.3V映射到0~4095 */ rt_uint32_t value = (rt_uint32_t)(voltage * 4095.0f / 3.3f); if (value > 4095) { value = 4095; } rt_dac_write(dac_dev, 0, value); }注意RT-Thread的rt_dac_write不会自动做位宽对齐,传进去多少就是多少,所以上层必须自己保证数值范围。如果应用层习惯用16位满量程0~65535来思考,在写回驱动之前要先右移4位变成12位值,这个和ADC那头的方向正好相反。
4.4 定时器触发+DMA生成正弦波
DAC配合定时器触发和DMA,可以做到完全不占CPU的连续波形输出。工控里最常见的应用就是低频正弦波扫频测试,或者用三角波做阀门抖动控制。我把核心思路说一下。
先准备一个正弦波查找表,比如256点,每个点对应12位DAC值。然后配置DMA从正弦波表循环搬运数据到DAC的DHR寄存器,DMA每搬运完一个数据,DAC根据配置的触发源更新一次输出。如果以定时器作为DAC触发源,定时器更新事件的频率就是DAC输出波形的更新频率。
#define WAVE_POINTS 256 uint16_t sine_table[WAVE_POINTS]; void sine_table_init(void) { for (uint16_t i = 0; i < WAVE_POINTS; i++) { double angle = 2.0 * 3.1415926535 * i / WAVE_POINTS; double val = (1.0 + sin(angle)) / 2.0; /* 0~1 */ sine_table[i] = (uint16_t)(val * 4095.0); } }DMA配置上,源地址指向sine_table,目标地址是DAC0的DHR寄存器地址,传输宽度16位,数量256,循环模式。然后定时器触发源选择成DAC的触发输入,定时器更新中断频率根据需要设置。整个过程中,CPU只需要启动一次DMA,后面波形就会一直循环输出。
有一点要特别注意:DAC的DMA请求方向和ADC不一样。ADC是外设主动请求DMA把结果搬走,DAC是DMA把数据搬到DHR后,还要等触发信号来了才会输出。所以使用定时器触发DAC时,要把定时器的更新事件同时作为DAC触发源,并且DMA搬运要和定时器节奏对齐,否则可能出现波形错位。
实测下来,用这种方法输出1kHz正弦波,CPU占用几乎可以忽略,波形在示波器上看很干净。如果输出出现台阶感,可以在DAC后面加一个低通滤波器把高频阶梯滤掉,这也就是热词里常说的DAC插值数字滤波器思路,模拟电路实现就是一个RC低通或者运放组成的二阶低通。
5. 软件滤波与校准实战
5.1 几种好用的C语言滤波函数
ADC采样值抖得厉害的时候,先别急着怀疑硬件,很多时候软件加一道滤波就解决了。但滤波不能盲目加,选错滤波器会导致信号滞后严重,控制系统跟着一起抖。
滑动平均滤波是最常用的,对周期性干扰抑制效果不错,缺点是数据会滞后约(N-1)/2个采样周期。采样点数N取4~8比较合适,点数太多滞后就大了。中位值平均滤波则是先去掉最大值和最小值,再求平均,适合有随机尖峰脉冲干扰的场合,工控现场电磁干扰打出来的毛刺用这个最有效。一阶低通滤波(也叫惯性滤波)实现最简单,适合平滑变化缓慢的模拟量信号,尤其适合温度、液位这种大惯性对象。
我直接把三种滤波的代码一起给出:
/* 滑动平均滤波, N为采样点数 */ #define FILTER_N 8 uint16_t sliding_average_filter(uint16_t new_value) { static uint16_t buf[FILTER_N] = {0}; static uint8_t index = 0; static uint32_t sum = 0; sum -= buf[index]; buf[index] = new_value; sum += buf[index]; index = (index + 1) % FILTER_N; return (uint16_t)(sum / FILTER_N); } /* 中位值平均滤波 */ #define FILTER_M 5 uint16_t median_average_filter(uint16_t *buf, uint8_t len) { uint16_t temp[FILTER_M]; uint16_t sum = 0; /* 拷贝并排序 */ for (uint8_t i = 0; i < len; i++) { temp[i] = buf[i]; } for (uint8_t i = 0; i < len - 1; i++) { for (uint8_t j = 0; j < len - i - 1; j++) { if (temp[j] > temp[j + 1]) { uint16_t t = temp[j]; temp[j] = temp[j + 1]; temp[j + 1] = t; } } } for (uint8_t i = 1; i < len - 1; i++) { sum += temp[i]; } return (uint16_t)(sum / (len - 2)); } /* 一阶低通滤波 */ uint16_t lpf_filter(uint16_t new_value, uint16_t prev_value, float alpha) { return (uint16_t)((float)prev_value + alpha * ((float)new_value - (float)prev_value)); }一阶低通的alpha取值很关键,alpha越小滤波越强但滞后越大。一个可参考的经验是:采样率为1kHz时,alpha取0.2左右对100Hz以内的信号影响很小,但能把500Hz以上的噪声压掉不少。
5.2 ADC/DAC的软校准:两点线性校正
芯片的ADC和DAC都存在增益误差和失调误差,手册里给的参数通常是±几LSB。如果项目要求0.5%甚至0.2%的精度,不做校准是难以达标的。好在软件两点校准就能解决大部分问题。
ADC校准的思路:输入端加两个已知的精密电压,比如V1=0.5V、V2=3.0V,分别读出两次采样值raw1、raw2。然后建立线性映射y = k×raw + b,其中k和b由这两个点求出。之后每次读到的raw都代入这个公式,得到的y就是校准后的电压值。
typedef struct { float k; float b; } calib_param_t; calib_param_t adc_calib_calc(uint16_t raw1, uint16_t raw2, float volt1, float volt2) { calib_param_t param; param.k = (volt2 - volt1) / (float)(raw2 - raw1); param.b = volt1 - param.k * (float)raw1; return param; } float adc_calib_convert(calib_param_t *param, uint16_t raw) { return param->k * (float)raw + param->b; }DAC校准的方向相反,是给DAC写两个已知数字量,然后用万用表测实际输出电压,依然两点拟合出数字量到实际电压的线性关系。应用层输出时,把目标电压先逆映射成数字量再写入DAC。这个逆映射其实就是解反函数,同样用两点法求。
校准务必在系统上电稳定后做,而且要等芯片温度基本稳定。运行一段时间后温漂是正常的,如果项目要求全程高精度,只能换更高规格的基准源和精密运放,软件校准只能解决固定偏移和增益问题,救不了温漂。
6. 常见问题与排查实录
6.1 采样值跳动、漂移、满偏
ADC采样值乱跳,排在第一位的原因永远是“电压基准或地没处理好”。ADC的VDDA引脚必须干净,旁边要放去耦电容;AGND和DGND在PCB上要单点连接。如果电源纹波大,采样值就会跟着抖。第二个常见原因是采样时间配短了,外部源阻抗又高,采样电容没充到位,测出来的值就忽大忽小。这时候把采样时间从15个周期改成55个周期甚至更长,一般能明显改善。
值恒为0或者恒为满量程,那就是通道问题。恒为0先查是不是使能了过采样但没配位移,或者通道号写错,引脚根本没接进来;恒为满量程通常是引脚悬空或者内部上拉没关掉,模拟输入引脚必须配成无上下拉的模拟模式。
漂移问题多半来自温度。芯片刚上电的时候会有一点自热,ADC读到的值随着时间缓慢变化,这是正常现象,软件开机后等几秒再做一次校准能缓解。如果漂移量大得离谱,查一下是不是有强干扰源把基准电压污染了。
6.2 DAC输出非线性、电压不对
DAC输出电压不是目标值,先确认你往DHR里写的数据格式对不对。12位右对齐模式下,写4095才对应满量程,如果打成255之类的小值,输出自然不对。另一个常见问题是输出缓冲没开,导致接上负载后电压被拉低,示波器一看波形是“塌”的,这时候把dac_output_buffer_enable打开就好了。
如果DAC输出带载后波形振荡,多半是输出缓冲和外部运放配合不稳定。排查办法是外部运放反馈电阻不要太小,至少选几kΩ级别,同时在输出端加一个小电容吸收振铃。
DAC输出波形有台阶感是正常现象,DAC本身就是零阶保持输出,输出恒定电压直到下一个更新点。台阶感太明显就加低通滤波,或者提高DAC的更新率,让每一步台阶更小更密。
6.3 RT-Thread设备访问的坑
RT-Thread最常见的问题是设备找不到。rt_device_find("adc0")返回空指针,十有八九是驱动初始化没有执行。检查一下INIT_BOARD_EXPORT宏有没有把它挂到自动初始化流程里,如果用线程栈调试,确认初始化确实在读取之前跑完了。
还有个坑是设备框架的rt_adc_read返回16位对齐值,前面已经强调过,这里再提醒一次。很多人辛辛苦苦调了半天,最后发现算出来的电压偏大16倍,就是没除这个16。DAC那边反过来,写进去的值不会帮你移位,两边机制完全不同,移植代码时要格外留意。
6.4 调试环境驱动问题
用RT-Thread Studio调试时,最容易出问题的是调试器驱动和串口驱动没装好。CH340、CP2102这类USB转串口芯片,插上电脑后设备管理器里能看到COM口才能用;ST-Link、J-Link这类调试器更要注意驱动版本,不然连接目标板时报错会让人一头雾水。这些驱动安装包都很容易找到,装完重启一次电脑基本都能解决。
6.5 排查顺序建议
我把常见问题整理成一个速查表,排查时按这个顺序来,可以少走很多弯路:
| 现象 | 优先排查项 | 次要排查项 |
|---|---|---|
| ADC值乱跳 | VDDA去耦、AGND连接 | 采样时间、外部源阻抗 |
| ADC恒为0 | 通道号、引脚配置 | 过采样配置、触发方式 |
| ADC恒为满量程 | 引脚上下拉、引脚悬空 | DMA传输宽度 |
| DAC电压偏低 | 输出缓冲、负载电流 | DHR写入格式 |
| DAC波形振荡 | 输出缓冲、运放反馈 | 外部负载电容 |
| 设备找不到 | 初始化宏、自动初始化 | 设备名字拼写 |
| 数据偏大16倍 | rt_adc_read对齐处理 | 应用层换算公式 |
单独讲一个我踩过的坑:有一次做DAC三角波输出,发现波形频率完全不对,以为是定时器配置错了,折腾了半天。最后查出来是DMA配置里传输宽度写成了8位,导致每次只搬运一个字节到DHR寄存器,高8位数据全丢了。这种情况不会报错,波形看起来“有点像”但完全不对,最坑人。遇到波形异常,先把DMA的宽度、数量、地址这三个参数逐一打印出来检查,能省很多时间。
我个人在实际操作中的体会是,GD32H759的ADC和DAC外设本身不难,难的是把“硬件链路、外设配置、RT-Thread框架、应用层换算”这四个环节串成一个闭环。每一个环节都可能埋坑,但只要你按照“先硬件、再外设、再框架、再应用”的顺序去排查,问题基本都能定位到具体某一行。这一篇的内容足够支撑你完成一个最基本的模拟量采集和输出系统了,后续如果再深入,可以把DMA环形缓冲、多ADC同步触发、以及DAC配合PID控制器的实时输出这些工程优化加进来。