1. 为什么在这个项目里我较真了 ADC/DAC 驱动
搞工控的人看到“ADC/DAC 驱动”这几个字,第一反应多半是:这有什么好写的?芯片手册一翻,固件库一调,ADC_Convert()和DAC_SetValue()完事。我原来也是这么想的,直到我在 GD32H759 上接了一个 4-20mA 变送器、一个 0-10V 比例阀,并且用 RT-Thread 把它们跑进一条 1kHz 的控制链路时,才意识到事情没那么简单。
驱动不是把寄存器配上、测到数值就结束的。它要回答的问题包括:参考电压够不够稳、采样通道和 DMA 在 RT-Thread 调度下会不会丢数、12 位码值怎么映射成工程单位、零点会不会随温度飘、DAC 输出带负载后电压会不会塌。这些问题不解决,程序写得再花哨,到了现场就是数据跳、阀门抖、电流环报警。
这篇就围绕“GD32H759 + RT-Thread 工控实战”里的 ADC/DAC 部分展开。适合两类人看:一类是从裸机往 RTOS 上迁移的开发,另一类是已经在 RT-Thread 上点灯串口跑通、但第一次接模拟量的朋友。我不打算把参考手册重新抄一遍,重点讲我实际改了三版之后才稳定下来的思路。
1.1 我在这块板子上到底干了什么
先说现场需求,否则后面的引脚和代码都显得莫名其妙。
设备上有四路模拟量输入,前两路接压力变送器,输出信号是标准的 4-20mA 电流环,经过 100Ω 精密电阻转成 0.4V 到 2.0V 的电压信号,再进 ADC。另外两路接温度变送器,采样周期不需要太快,但要求长时间在线,不能出现连续丢点。
还有一路模拟量输出,控制一个 0-10V 输入的比例阀。这部分用 DAC 输出小电压,再经过一级运放放大到 0-10V。
控制逻辑是:ADC 采到当前压力/温度,RT-Thread 里的控制线程根据设定值计算输出,DAC 驱动比例阀动作。整个闭环周期设计成 1ms,也就是说每秒采 1000 个点、算 1000 次、输出 1000 次。
这个节奏在裸机上很常见,但在 RT-Thread 里,如果一个线程里既做rt_adc_read()又做rt_dac_write(),再夹杂一些浮点运算,时序抖动会非常明显。后面我会专门讲 DMA 和缓冲区的处理。
1.2 为什么例程代码只能当“能亮”的证明
GD32H759 官方例程、网上各种移植工程,大多会给你一段 ADC 单次转换、串口打印的代码。那种代码的意义是证明芯片没焊错、固件库能编译过,仅此而已。
例程不会告诉你:当你的 ADC 参考电压 VREF+ 和数字 3.3V 用同一个 LDO 供电时,板子上一有继电器动作,采样值会整体跳十几个字;也不会告诉你,DAC 输出引脚如果直接接长线,容性负载一大,方波会变成圆头波,比例阀低频响应直接变差。
所以这篇里的重点不是“怎么把 ADC/DAC 调通”,而是“怎么调通之后还能在工控现场稳定跑”。
2. 硬件细节没处理好,驱动写得再好也是白搭
做嵌入式的人有个通病:能软不硬。遇到 ADC 数据不对,第一反应是重读寄存器、切换通道、加滤波,很少有人先拿示波器看 VREF+ 引脚上的纹波。
我在这块板子上吃过一次大亏。第一版硬件里,模拟电源和数字电源都从同一个 DC-DC 出来,LDO 后面也没做磁珠隔离,结果压力变送器一上电,ADC 采集值在 1200 到 1250 之间来回跳。软件上做了滑动平均还是压不住。后来把 VREF+ 单独用一颗低噪声基准电压芯片供电,纹波从 20mV 降到 2mV 以内,同一个滤波算法,数据稳定了一个数量级。
结论很直接:ADC/DAC 驱动的天花板是硬件决定的,不是软件决定的。
2.1 参考电压和模拟电源的处理方式
GD32H759 这类 MCU 的 ADC 精度上限,很大程度取决于 VREF+ 的质量。如果板子上 VREF+ 直接接 3.3V,那默认放弃高精度采集。
实际项目中我按这个优先级处理:
- 首先,VREF+ 引脚单独走线,不能和数字电源引脚共走一小段 PCB 线。
- 其次,VREF+ 前加一个低噪声基准源,例如 REF5030 这类 3.0V/3.3V 基准,输出端并联 1μF 陶瓷电容和 0.1μF 高频电容。
- 最后,模拟电源和数字电源之间串磁珠,地平面要求不高的话可以在 ADC 区域做单点接地,但不要在模拟区域中间切开一条地缝。
如果你的硬件已经固定,VREF 就是 3.3V,那软件上能做的是避免采集窗口附近有大的数字开关噪声。RT-Thread 下可以让 ADC 转换线程和继电器控制线程错开,但这属于补救,不是正路。
2.2 引脚映射和 GPIO 模式的坑
GD32 和 STM32 一样,ADC 引脚要配成模拟模式,不是复用推挽模式。很多人从 GPIO 例程复制过来,把 PA0 配成GPIO_MODE_AF_PP,结果读到的值永远是 0 或者随机数。
我在这块板子上用的是 PA0-PA3 做四路模拟输入,PA4 做 DAC 输出。初始化代码大概是这样:
/* ADC 输入引脚 */ gpio_mode_set(GPIOA, GPIO_MODE_ANALOG, GPIO_PUPD_NONE, GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3); /* DAC 输出引脚 */ gpio_mode_set(GPIOA, GPIO_MODE_ANALOG, GPIO_PUPD_NONE, GPIO_PIN_4);注意一点:DAC 引脚通常也是模拟模式,不是普通推挽输出。如果固件库里有gpio_init和gpio_mode_set两套接口,一定要仔细看参考手册,模式配置错了会导致输出带不动负载。
另外,别只看原理图上的网络名。有些开发板把 PA0 同时引出到 ADC 和按键,按键电路上有下拉电阻,接上之后 ADC 量程会被钳位。这个在见客户硬件之前很难发现,排查方法是用万用表量引脚直流电平。
2.3 采样前端和抗混叠滤波
ADC 转换本身很快,但外部信号不是理想的。压力变送器的 4-20mA 信号经过 100Ω 电阻后,会叠加现场变频器的共模噪声和射频干扰。
我实际用的前端电路是一个 RC 低通加一级电压跟随器。参数如下:
- 采样电阻:100Ω/0.1% 精密电阻,4mA 时压降 0.4V,20mA 时压降 2.0V;
- RC 滤波器:100Ω 串联电阻 + 100nF 电容到地;
- 电压跟随器:用一颗双运放,把信号缓冲后进 MCU。
RC 截止频率大概算一下:f_c = 1 / (2 * π * 100 * 100e-9) ≈ 15.9kHz。对 1kHz 的信号采样绰绰有余,又能滤掉一部分高频噪声。注意,这个 RC 必须是接到 ADC 引脚之后尽量短的距离,否则那段走线本身就成天线了。
软件滤波永远救不了硬件噪声,这句话值得写在工位旁边。
3. RT-Thread 下 ADC 驱动的完整接入链路
硬件稳定之后,才轮到驱动。
RT-Thread 的设备驱动框架把 ADC 抽象成了rt_adc_device,应用层不用关心底层寄存器,只需要rt_device_find()找到设备,然后rt_adc_enable()、rt_adc_read()。但前提是你得先按框架要求把底层 ops 函数实现好,并在系统初始化时把设备挂上去。
我建议直接用 RT-Thread Studio 建工程,然后在rtconfig.h或者图形化配置界面里打开 ADC 设备驱动框架。手动从零搭组件树也能跑,但容易漏依赖。
3.1 BSP 里已有的驱动和我要补的驱动
拿到一个新 BSP,先去看drv_adc.c存不存在。如果存在,先别急着写自己的,直接把它生成的设备adc0或者adc1找出来测试。
我这套板子的 BSP 里,ADC 驱动只有轮询单次转换,没有 DMA 采集,也没有多通道自动扫描。所以我没直接用,而是在它的基础上补了一层环形 DMA 操作。
如果你懒得看底层,也可以先用最粗的方式验证硬件通路:
rt_adc_device_t adc_dev = RT_NULL; rt_uint32_t raw = 0; adc_dev = (rt_adc_device_t)rt_device_find("adc0"); if (adc_dev == RT_NULL) { rt_kprintf("adc0 not found\n"); return -RT_ERROR; } rt_adc_enable(adc_dev, 0); raw = rt_adc_read(adc_dev, 0); rt_adc_disable(adc_dev, 0); rt_kprintf("raw: %d\n", raw);这段能跑通,说明设备注册、GPIO、参考电压都基本正常。接下来再考虑性能。
3.2 自己实现 adc_ops 时需要注意的细节
如果 BSP 没有适配,或者你想改成 DMA 模式,就要写struct rt_adc_ops。RT-Thread 的 ADC 框架核心就两个函数指针:
static rt_err_t adc_enabled(struct rt_adc_device *device, rt_uint32_t channel, rt_bool_t enabled) { if (enabled) { adc_channel_enable(ADC0, channel); } else { adc_channel_disable(ADC0, channel); } return RT_EOK; } static rt_err_t adc_convert(struct rt_adc_device *device, rt_uint32_t channel, rt_uint32_t *value) { if (value == RT_NULL) { return -RT_EINVAL; } /* 启动软件触发,等待转换结束 */ adc_software_trigger_enable(ADC0); while (adc_flag_get(ADC0, ADC_FLAG_ENDC) == RESET); *value = adc_data_get(ADC0, channel); return RT_EOK; } static const struct rt_adc_ops gd32_adc_ops = { adc_enabled, adc_convert, };这段代码里的寄存器函数名在不同固件库里可能不一样,关键点是:convert函数必须阻塞到本次转换完成再返回,否则应用层拿到的可能是上一通道的旧数据。
设备注册代码放在板级初始化里:
static struct rt_adc_device adc0_device; int rt_hw_adc_init(void) { adc0_device.ops = &gd32_adc_ops; rt_device_register(&(adc0_device.parent), "adc0", RT_DEVICE_FLAG_RDWR); return RT_EOK; } INIT_BOARD_EXPORT(rt_hw_adc_init);注册之后,应用层用rt_device_find("adc0")就能拿到设备句柄。注意设备名别和系统其他设备冲突。
3.3 多通道采集时最容易出错的“通道号对应关系”
芯片手册里的 ADC 通道号,和物理引脚不是一回事。我在第一版里默认通道 0 就是 PA0,结果 PA0 上接的是温度,程序读出来当压力算,控制逻辑自然全乱。
建议在驱动层加一个转换表:
| 应用逻辑通道 | 物理引脚 | ADC 通道号 |
|---|---|---|
| 0 | PA0 | ADC_CHANNEL_0 |
| 1 | PA1 | ADC_CHANNEL_1 |
| 2 | PA2 | ADC_CHANNEL_2 |
| 3 | PA3 | ADC_CHANNEL_3 |
然后在convert里按逻辑通道号去 switch 到真正的寄存器通道。这样做的好处是将来改板子只动驱动层,应用层代码和量程换算不用动。
4. DAC 驱动:0~10V 输出不是“写个寄存器”那么简单
DAC 驱动看起来比 ADC 简单:把数字值写进去,引脚就有电压。但实际项目里,这个电压要驱动外部执行机构,负载能力、放大电路、上电瞬间的输出状态,全是坑。
4.1 DAC 输出级和运放放大
GD32H759 的内部 DAC 输出电压范围通常是0 到 VREF+。如果我直接用 3.3V 参考,DAC 最多输出 3.3V,带不动 0-10V 的比例阀。
所以我在 DAC 输出后面加了一级同相放大电路,放大倍数设为 3.03 倍,设计目标:DAC 满量程输出 3.3V 时,最终输出约 10V。
运放供电要特别注意,如果运放正电源只给 5V,那输出永远到不了 10V。我用的是 12V 供电的运放,输出余量足够。第一次实验时,我只改了代码没确认运放供电,DAC 值写满,输出却卡在 4.9V,查了半天才发现是供电问题。
4.2 RT-Thread 的 DAC 驱动框架怎么挂
RT-Thread 的 DAC 设备和 ADC 类似,应用层用rt_dac_enable()和rt_dac_write()。底层需要实现 ops:
static rt_err_t dac_enabled(struct rt_dac_device *device, rt_uint32_t channel, rt_bool_t enabled) { if (enabled) { dac_software_trigger_enable(DAC0); } else { dac_software_trigger_disable(DAC0); } return RT_EOK; } static rt_err_t dac_write(struct rt_dac_device *device, rt_uint32_t channel, rt_uint32_t value) { dac_data_set(DAC0, channel, value); return RT_EOK; } static const struct rt_dac_ops gd32_dac_ops = { dac_enabled, dac_write, };应用层调用:
rt_dac_device_t dac_dev = (rt_dac_device_t)rt_device_find("dac0"); rt_dac_enable(dac_dev, 0); rt_dac_write(dac_dev, 0, 2048);如果你用的 BSP 里已经有dac0,直接这样调用即可。如果设备找不到,检查INIT_BOARD_EXPORT是否把 dac 设备注册到了dac0这个名字上。
4.3 电压码值换算和斜坡输出
12 位 DAC 的码值和电压关系是:
V_out = value / 4095 * V_max所以如果目标输出电压是 5V,V_max=10V,那么:
value = 5.0 / 10.0 * 4095 = 2047.5 ≈ 2048但实际电路里放大倍数、运放偏置都会引入误差,不能直接按理论值。我的方法是先做两点校准:DAC 写 0 时用万用表读实际输出电压,DAC 写 4095 时再读一次,然后用线性插值。
比例阀这类执行机构,最忌输出电压突变。如果控制线程发现目标值从 3V 直接跳到 8V,立刻把新值写进 DAC,阀门会猛开猛关,现场管道都会跟着抖。我在输出线程里加了一个斜坡限幅:
#define DAC_SLOPE_STEP 50 /* 每个控制周期最大变化码值 */ if (target_code > current_code) { current_code += (target_code - current_code > DAC_SLOPE_STEP) ? DAC_SLOPE_STEP : (target_code - current_code); } else { current_code -= (current_code - target_code > DAC_SLOPE_STEP) ? DAC_SLOPE_STEP : (current_code - target_code); } rt_dac_write(dac_dev, 0, current_code);这个斜坡不是 PID 输出的一部分,它只是保护执行机构的输出限速。具体步长要根据现场阀门响应速度调,调太慢会让系统变迟钝,调太快则失去保护意义。
5. 采样任务在高负载下抖动:DMA + 双缓冲方案
前面说的轮询方式,在控制线程不忙时没问题。但一旦系统里同时跑着网络、文件系统、多个 IO 线程,rt_adc_read()的阻塞时间会被任务调度放大,采样周期就会忽快忽慢。
我实测过:在不加 DMA 的版本里,控制线程设定 1ms 周期,实际间隔在 0.7ms 到 1.5ms 之间抖动。对于温度采集和普通压力监视,这完全能接受。但对于后面接了 PID 调节的回路,这种抖动会直接变成控制量的高频晃动,阀门寿命都会受影响。
所以我把 ADC 改成了 DMA 循环采集。
5.1 DMA 环形缓冲的思路
先申请一个数组,让 DMA 自动把 ADC 数据搬到数组里,不需要 CPU 参与。每次 DMA 传输到一半或者满了,触发中断,通知 RT-Thread 线程去取数据。
配置要点:
- ADC 选择扫描模式,把四路通道按顺序排好。
- DMA 选择循环模式,自动循环搬运。
- 中断配置成半传输完成和全传输完成,或者只用一个,保证数据不会被读一半覆盖。
我用的缓冲区长度是 1024,也就是每个周期 256 个点,四路共享。因为控制周期是 1ms,ADC 采样率远高于控制频率,所以线程取数据时总能拿到最近一段时间的平均值或中值。
5.2 和 RT-Thread 消息队列的配合
DMA 中断是 ISR,不能直接在里面做浮点换算和 PID,所以只在中断里释放一个信号量,唤醒采集线程。
static rt_sem_t adc_sem; void adc_dma_isr(void) { rt_sem_release(adc_sem); } static void adc_thread_entry(void *param) { rt_uint32_t raw[4]; while (1) { rt_sem_take(adc_sem, RT_WAITING_FOREVER); /* DMA 半满/全满时把数据取走 */ for (int i = 0; i < 4; i++) { raw[i] = adc_dma_buffer[index + i]; } /* 这里做滤波、量程换算、发送给控制线程 */ } }控制线程和数据采集线程之间的数据,我建议用rt_mq_send()消息队列传。不要直接用全局变量,因为多线程读写共享变量需要保证原子性,否则极端情况下读到“半新半旧”的数据,控制结果会很奇怪。
用了 DMA 之后,1ms 控制周期内的抖动降到 100μs 以下,控制量输出平滑了很多。这个改动比在轮询代码里加各种优先级调整都有效。
5.3 一个容易被忽略的问题:DMA 缓冲区的长度对齐
DMA 搬运 16 位 ADC 数据时,缓冲区地址和长度都要对齐,否则部分 DMA 控制器会进入错误状态。尤其是 RT-Thread 下,如果你用动态分配的内存,可能没有按 32 位对齐,这会导致 DMA 配置完成后数据错位。
我的做法是直接用静态数组,并且强制对齐:
static rt_uint16_t adc_dma_buffer[1024] __attribute__((aligned(4)));看起来是小事,但一旦踩到,排查成本很高。
6. 校准、量程映射和现场调试的土办法
驱动能跑、能读数据,只是第一步。真正让这个系统在现场能用,靠的是校准和量程映射。
6.1 两点校准法
以 4-20mA 输入为例,采样电阻 100Ω,理论上 4mA 对应 0.4V,20mA 对应 2.0V。假设 ADC 是 12 位,参考电压 3.3V,那么:
- 0.4V 对应码值:0.4 / 3.3 * 4095 ≈ 496
- 2.0V 对应码值:2.0 / 3.3 * 4095 ≈ 2482
但实际电阻有误差、参考电压不是精确 3.3V,所以必须现场标定。我用一个信号发生器输出标准电流,分别记录零点和满量程时的码值:
| 输入电流 | 理论码值 | 实测码值 |
|---|---|---|
| 4.00 mA | 496 | 501 |
| 20.00 mA | 2482 | 2477 |
换算公式很简单:
eng = (raw - raw_zero) * (20.0 - 4.0) / (raw_full - raw_zero) + 4.0把这组参数写到片内 Flash 或者外部 EEPROM,每次上电读取。不要写在代码里,因为每一台设备的零点和满量程都会有细微差别。
6.2 软件滤波不能乱用
很多同事拿到采集数据第一件事就是加滑动平均,窗口越长越平。但滑动平均本质是低通滤波,窗口太长会把高频变化抹平,闭环控制会变得迟钝。
我这套系统里,温度通道用 5 点中值滤波,压力通道用 3 点平均,控制量输出不做滤波。一定要分通道区别对待,不能一套滤波走天下。
另外,中值滤波实现时要小心排序过程中破坏原始数组。简单做法是把五个数拷到临时数组再排序,虽然多花点时间,但稳定。
6.3 J-Link 和串口调试的一些体会
这套工程我一直在用 J-Link 下载调试,RT-Thread Studio 里选的调试器类型、固件版本、J-Link 驱动版本之间偶尔会有兼容问题。如果出现“连接不到目标”或者“ID code 读不出来”,先别怀疑芯片坏,大概率是除了 SWDIO/SWCLK 之外,某个引脚被程序复用成别的功能,导致调试口失效。
我遇到过一次:代码里把 PA13/PA14 配成了普通 GPIO,烧录完再想调试,J-Link 连不上了。解决办法是按住复位键的同时连接,趁芯片还没跑到初始化代码时立刻擦除。
串口调试我也建议提前准备。CH340 的驱动装好后,用 RT-Thread 的 FinSH 组件可以直接在命令行里调adc probe之类的命令,比反复烧录看 printf 快很多。J-Link 的 RTT 也可以,但要确保驱动版本和 IDE 匹配,不然会出现调试器闪断。
7. 如果重新做一版,我会改哪些地方
写到这,说说我如果重新设计这套 ADC/DAC 驱动,会在一开始就改掉的地方。
第一,硬件上 VREF+ 必须独立供电,不能省。我会直接在原理图阶段就用低噪声基准源,而不是等样机出来了再飞线改。
第二,输入端会留出可调的 RC 参数位置。现场噪声频率你不知道,板子上的 RC 参数最好能通过换电阻电容调整,而不是焊死。这一点在原理图评审时就要提。
第三,驱动层一开始就按 DMA 模式设计。轮询模式虽然简单,但一旦控制周期加快、任务变多,重写一次驱动的时间成本远高于一开始多写几行配置。
第四,所有通道都做逻辑通道号和物理通道号的映射,并且在驱动层打印通道名。别相信“开发者自己心里记住”这件事,三个月后你自己也会忘。
第五,DAC 输出会加一个上电锁定。部分执行机构在上电瞬间收到一个非零电压会乱动,所以在系统初始化完成之前,DAC 输出要强制为 0,并且用一个全局标志控制是否解除锁定。
这些看起来都不是什么高深技术,但每一件都是我在现场和调试台上实实在在踩过的坑。ADC/DAC 驱动说到底是“硬件 + 软件 + 现场工艺”三方打架的接口,只有每一方都稳住,整个控制系统才能让人睡个踏实觉。