在工控现场摸爬滚打的人都知道,PLC 之外还有一大类 MCU 板卡要面对成百上千的模拟量信号。我接手的项目中,GD32H759 配 RT-Thread 的组合越来越多,而每次移植到新板卡,最先要干的事就是把 ADC/DAC 驱动理顺。这篇是系列第 3 篇,专门聊这两个模拟外设怎么从寄存器落到 RT-Thread 设备框架里:先讲清楚为什么工控不能只写寄存器,再拆 GD32H759 的硬件,然后直接给出可抄作业的 ADC/DAC 驱动代码,最后把现场踩过的坑一并列出来。搞过一段 STM32 或者有 RTOS 基础的人,跟着做一遍基本就能在自己板子上跑通。
1. 写这篇之前,先想清楚工控系统里 ADC/DAC 是干嘛的
1.1 模拟量是工控系统的手臂和眼睛
工业现场里绝大多数的“感觉”和“动作”都离不开模拟量。压力变送器、PT100 温度传感器、液位计、流量计,输出最常见的信号是 4-20mA 电流环或 0-10V 电压信号。这些信号到了采集板,不能直接进 MCU,得先经过采样电阻、分压电阻和运放调理电路,变成 MCU 的 ADC 能量程对应的电压,通常是 0-3.3V,再由 ADC 转成数字量。反过来,PLC 或控制器算出的控制量,要输出给变频器、伺服驱动器、比例阀这些执行机构,也经常需要 0-10V 电压给定或者 4-20mA 电流给定。MCU 内部 DAC 产生的 0-3.3V 单极性电压,经后级运放放大或 V/I 变换后,才能变成这些标准信号。
我最近做的板卡是 8 路 AI 和 4 路 AO 的扩展模块,就是拿 GD32H759 当一个采集和输出的大脑。AI 侧每个通道前面都加了 RC 滤波和运放跟随,把传感器信号稳定送进 ADC;AO 侧则是 DAC 输出后接一级运放和 V/I 电路,把 0-3.3V 放大到 0-10V 或者转成 4-20mA。你说 ADC/DAC 驱动重不重要?它其实只是最底层的一小段代码,但上层所有的滤波算法、报警判断、PID 控制全都建立在它的正确性之上。这个环节一旦翻车,后面整个系统都是错的。
1.2 为什么不能直接在业务代码里写寄存器
很多从裸机转过来的朋友第一反应是:读 ADC 不就三条语句吗?adc_enable(),然后等标志位,最后adc_data_read()拿结果,直接在业务代码里写不就行了?行是行,但只适合做 demo。真正拿到工控板卡上,你会遇到几个躲不开的问题。
第一是并发访问。RT-Thread 里可能有采集线程、控制线程、Modbus 通信线程,多个线程同时想读同一个 ADC 设备时,如果不经过统一的设备框架,你自己加锁维护状态非常容易出错。第二是可移植性。今天你用 GD32H759,明天客户指定换一颗其他型号,或者同一颗芯片但引脚通道换了,业务代码里到处是寄存器操作,改起来想哭。第三是可维护性。靠人脑记住某个地址是哪个外设,在几百上千行的工程里根本不现实。RT-Thread 设备框架把这些收敛成一套标准的rt_device_find、rt_adc_read、rt_dac_write接口,驱动层只负责把寄存器操作填进约定的回调函数。业务代码基本不用动,驱动替换掉就行。这个抽象的价值,项目越往后越明显。
2. GD32H759 的 ADC/DAC 硬件,先知道手上有什么牌
2.1 ADC:不是只有一个“读”这么简单
GD32H759 内部集成的 ADC 是 12 位逐次逼近型,不是一颗 ADC 用一个通道,而是多通道复用加扫描,配合 DMA 可以连续采集多路模拟量。实际需要几路、每路采样率多少,直接决定了你在驱动层怎么写。别小看这一步,我见过不少工程师一上来就抄例程里的单个通道配置,结果要做 8 路采集时发现一个通道一个通道轮询,速度完全跟不上。
从硬件层面看,工控项目里最该关心的是参考电压和引脚映射。参考电压直接决定 ADC 换算公式里的分母,GD32 这类 MCU 一般是 VDDA/VREF 引脚供电,如果你板上 VREF 是 3.3V 万用表实测 3.28V,那么 12 位满量程对应的就不是 3300mV,而是 3280mV,这个误差在工业现场放大后很可观。引脚映射则需要对着数据手册确认,想采哪一路,就要查对应的是哪个 ADC 单元的哪个通道,比如很多板子把 ADC 通道 0 默认引到 PA0,但你的板子不一定这么做,一定要看原理图。
软件层面,ADC 有单次转换、连续转换、扫描模式、注入组和规则组的概念。在 RT-Thread 设备框架里,单通道规则组转换是最省事的,因为驱动回调一次只负责一个通道,你可以在每次 convert 时切换通道号并触发一次软件转换。如果追求高性能,要么用硬件扫描模式配合 DMA,要么直接用双 ADC 并行采集。不过这些属于扩展玩法,先把基础驱动跑通,再考虑加码。
2.2 DAC:输出通道哪怕是 12 位也要当回事
GD32H759 的 DAC 模块在规格上并不复杂,常见配置是两个独立 12 位电压输出通道,支持软件触发、定时器触发,还有输出缓冲。输出缓冲的问题特别容易被忽略:开启缓冲后带载能力强一点,可以直接驱动一些后级电路,但输出范围往往不是完美的轨到轨,靠近 0V 和靠近 VREF 的地方会有死区。如果你的 AO 信号要求小电压时很准,比如 0-10V 对应 0-100%,输出 0.5V 时误差可能就被放大得很离谱。所以驱动层给上层返回的只是一个 12 位数字量,真正要保证精度,还得结合后级调理电路和校准表一起考虑。
DAC 的数据寄存器有左对齐和右对齐两种写入方式,换算公式不一样。右对齐写 12 位数比较直观:直接写 0~4095,输出电压大约是VREF / 4095 * code。左对齐适合从高 12 位拼接数据的场景,但如果搞混了,输出值会整体偏移,现场排查半天都查不出来。后面我会专门把这个坑拿出来说。
3. RT-Thread 的 ADC/DAC 设备框架,到底在框架里写什么
3.1 设备模型就像“接口约定”
可以把 RT-Thread 的设备框架理解成一套“接口约定”。底层驱动是后端服务,业务应用是前端调用方,两边不直接认识,而是通过一个设备对象和一组操作函数指针来通信。驱动初始化时,把实现好的操作函数集合注册到一个设备对象里,再给这个对象起一个名字,比如adc0、dac0。应用层拿到名字,用rt_device_find("adc0")找到设备对象,然后调统一的 API 去读去写,完全不管底层到底是 GD32 还是 STM32,也不关心哪个寄存器。
这个思想跟我们写 C 语言时用函数指针做回调很像。带来的好处是:驱动可以独立测试,业务代码可以静态看逻辑,板卡换芯片时只要重写驱动层。坏处是,框架加了一层封装,如果你不理解回调机制,遇到底层报错会不知道去哪里查。所以我不建议一上来就抄代码,先把struct rt_adc_device、struct rt_adc_ops这些结构体打开看一眼,搞清楚哪个函数指针是干嘛的,再开始动手。
3.2 你需要实现的最小回调集合
RT-Thread 的 ADC 设备接口,核心就是两个回调函数:enabled负责打开/关闭指定通道或整个 ADC 外设,convert负责真正发起转换并把结果写到value指针指向的变量里。上层调用rt_adc_enable()和rt_adc_read()时,内部就会去调用这两个回调。DAC 设备也是类似,一个enabled控制通道使能,一个convert接收上层传入的数字量,写入 DAC 数据寄存器。
注册的时候,用rt_device_adc_register()和rt_device_dac_register()把设备和 ops 绑定在一起,并且通过INIT_BOARD_EXPORT这类自动初始化宏,让驱动在系统启动早期就完成注册。这里有个细节:RT-Thread 的自动初始化分为好几个阶段,板级外设一般放在INIT_BOARD_EXPORT,如果你用的 Nano 版本没有自动初始化机制,那就要自己在 main 启动前手动调用初始化函数。
4. 实战:把 GD32H759 的 ADC 接到 RT-Thread
4.1 环境准备与工程配置
我用的是 RT-Thread Studio 创建工程,芯片型号选择 GD32H759 系列,BSP 包里一般已经带好了drv_gpio.c、drv_usart.c这些基础驱动。ADC/DAC 属于模拟外设,如果 BSP 没有现成的drv_adc.c和drv_dac.c,就需要自己新建驱动文件,放在drivers目录下,然后在rtconfig.h里确认已经定义了RT_USING_ADC和RT_USING_DAC。如果是在 Studio 的图形化配置界面,路径是RT-Thread Settings -> Device Drivers -> Analog,把 ADC Device 和 DAC Device 勾上。
有的朋友直接下载官方 SDK 就跑,结果编译报错说找不到adc.h,其实是因为工程里没使能这两个设备宏,头文件根本没参与编译。这个卡住不少人,先检查清楚。
4.2 底层 ADC 初始化和 ops 对接
下面我给出一个单通道轮询版的 ADC 驱动骨架,基于 GD32 标准固件库的常见 API 写。注意厂家在不同系列里函数命名会有差异,比如有的叫adc_clock_config,有的叫adc_clock_set,以你 BSP 里实际提供的为准,核心逻辑是一样的。
#include <rtthread.h> #include <rtdevice.h> #include "gd32h7xx.h" static void _adc_port_init(void) { /* 开启 ADC0 和 GPIOA 的时钟 */ rcu_periph_clock_enable(RCU_ADC0); rcu_periph_clock_enable(RCU_GPIOA); /* 把 PA0 配成模拟输入,作为 ADC0 通道0 的采样引脚 */ gpio_mode_set(GPIOA, GPIO_MODE_ANALOG, GPIO_PUPD_NONE, GPIO_PIN_0); /* ADC 时钟分频,具体分频系数看系统主频和器件手册 */ adc_clock_config(ADC0, ADC_CLK_DIV_6); /* 数据右对齐,单次转换 */ adc_data_alignment_config(ADC0, ADC_DATAALIGN_RIGHT); /* 规则组只有一个通道 */ adc_channel_length_config(ADC0, ADC_REGULAR_CHANNEL, 1); adc_channel_config(ADC0, ADC_REGULAR_CHANNEL, ADC_CHANNEL_0, ADC_SAMPLETIME_55POINT5); /* 不用外部触发,软件触发 */ adc_external_trigger_config(ADC0, ADC_REGULAR_CHANNEL, EXTERNAL_TRIGGER_DISABLE); adc_enable(ADC0); /* 上电后做一次校准,能有效减少内部电容失配导致的增益误差 */ adc_calibration_enable(ADC0); }初始化完成后,最关键的是把操作函数按照 RT-Thread 的 ops 结构体填好。enabled回调很简单,打开就是adc_enable,关闭就是adc_disable。convert回调要做的动作是:先选择本次要采的通道,然后触发软件转换,等转换结束标志位置位后读出数据,写入value。
static rt_err_t _adc_enabled(struct rt_adc_device *device, rt_uint32_t channel, rt_bool_t enabled) { if (enabled) { adc_enable(ADC0); } else { adc_disable(ADC0); } return RT_EOK; } static rt_err_t _adc_convert(struct rt_adc_device *device, rt_uint32_t channel, rt_uint32_t *value) { rt_err_t ret = RT_EOK; /* 每次转换前重新配置规则组通道,这样单通道轮询最稳 */ adc_channel_length_config(ADC0, ADC_REGULAR_CHANNEL, 1); adc_channel_config(ADC0, ADC_REGULAR_CHANNEL, channel, ADC_SAMPLETIME_55POINT5); /* 清标志位,触发软件转换,等待完成 */ adc_flag_clear(ADC0, ADC_FLAG_END); adc_software_trigger_enable(ADC0, ADC_REGULAR_CHANNEL); while (RESET == adc_flag_get(ADC0, ADC_FLAG_END)) { /* 如果怕死等时间长,可以加超时,工业现场稳定性第一 */ } *value = adc_regular_data_read(ADC0); return ret; } static struct rt_adc_ops _adc_ops = { .enabled = _adc_enabled, .convert = _adc_convert, }; static struct rt_adc_device _adc0_dev; int rt_hw_adc_init(void) { _adc_port_init(); rt_device_adc_register(&_adc0_dev, "adc0", &_adc_ops, RT_NULL); return 0; } INIT_BOARD_EXPORT(rt_hw_adc_init);这里我专门强调一下通道切换的位置。RT-Thread 的 ADC 框架一次只采一个通道,所以你完全可以在每次 convert 时重新配置规则组。有人喜欢在初始化时把所有通道都加到扫描列表里,然后让 convert 查询当前是第几个通道,这也能实现,但调试的时候很容易被状态搞晕。单通道轮询虽然朴素,但逻辑直白,也符合“驱动要先稳定”的工控原则。
4.3 应用层采集电压并验证
驱动注册好之后,应用层代码非常清爽。先在初始化或者线程入口里找设备,然后使能通道,最后读值。我习惯把原始值和换算后的毫伏值都打印出来,方便和万用表对拍。
#include <rtthread.h> #include <rtdevice.h> static void adc_sample_thread_entry(void *param) { rt_adc_device_t adc_dev = RT_NULL; rt_uint32_t raw = 0; rt_uint32_t mv = 0; adc_dev = (rt_adc_device_t)rt_device_find("adc0"); if (adc_dev == RT_NULL) { rt_kprintf("find adc0 failed\n"); return; } rt_adc_enable(adc_dev, 0); while (1) { raw = rt_adc_read(adc_dev, 0); /* 12位ADC,右对齐,满量程为4095 */ mv = raw * 3300U / 4095U; rt_kprintf("raw: %d, mv: %d\n", raw, mv); rt_thread_mdelay(500); } }换算公式里用的分母是 4095,不是 4096,这个细节经常有人写错。12 位 ADC 的最大输出码是2^12 - 1 = 4095,表示满量程电压。raw * 3300 / 4095才是线性比例换算。如果你用 4096 做分母,算出来的满量程值只有 3299.2mV,不对。另外,这个公式默认 VREF 恰好等于 3300mV,上电后建议用万用表实测 VREF,把 3300 替换成实测值,或者直接做两点校准,现场精度才有保障。
5. 实战:把 GD32H759 的 DAC 接到 RT-Thread
5.1 DAC 通道与引脚配置
DAC 的初始化相比 ADC 简单一些。首先要确认你的板卡把 DAC 输出引脚接在哪,常见的是 DAC0 对应 PA4、DAC1 对应 PA5,但一切以原理图为准。初始化时开启 GPIO 和 DAC 外设时钟,把对应引脚配置成模拟模式,然后设置数据对齐方式,最后使能 DAC 通道。
static void _dac_port_init(void) { rcu_periph_clock_enable(RCU_DAC); rcu_periph_clock_enable(RCU_GPIOA); /* PA4 作为 DAC0 输出引脚 */ gpio_mode_set(GPIOA, GPIO_MODE_ANALOG, GPIO_PUPD_NONE, GPIO_PIN_4); /* 右对齐,使能 DAC 通道0 输出缓冲 */ dac_trigger_config(DAC0, DAC_TRIGGER_SOFTWARE); dac_data_alignment_config(DAC0, DAC_ALIGN_12B_R); /* 输出缓冲建议先打开,驱动后级能力更强 */ dac_buffer_enable(DAC0, DAC_CHANNEL_0); dac_enable(DAC0, DAC_CHANNEL_0); }如果后级驱动的是高阻输入,缓冲开不开无所谓;如果后面接了直接负载,比如小功率的电压输出模块,缓冲建议打开,不然 DAC 输出电压会被拉低,导致实际输出和你写入的数字量对不上。这一点在调试时要特别注意。
5.2 DAC ops 的实现和设备注册
DAC 的 ops 同样只需要两个回调。enabled负责使能和失能指定通道,convert接收上层传入的数字量,把值写入 DAC 数据寄存器。这里value是一个指针,指向的内容才是要写入的码值。
static rt_err_t _dac_enabled(struct rt_dac_device *device, rt_uint32_t channel, rt_bool_t enabled) { if (enabled) { dac_enable(DAC0, DAC_CHANNEL_0); } else { dac_disable(DAC0, DAC_CHANNEL_0); } return RT_EOK; } static rt_err_t _dac_convert(struct rt_dac_device *device, rt_uint32_t channel, rt_uint32_t *value) { if (value == RT_NULL) { return -RT_EINVAL; } dac_data_set(DAC0, DAC_CHANNEL_0, DAC_ALIGN_12B_R, *value); return RT_EOK; } static struct rt_dac_ops _dac_ops = { .enabled = _dac_enabled, .convert = _dac_convert, }; static struct rt_dac_device _dac0_dev; int rt_hw_dac_init(void) { _dac_port_init(); rt_device_dac_register(&_dac0_dev, "dac0", &_dac_ops, RT_NULL); return 0; } INIT_BOARD_EXPORT(rt_hw_dac_init);如果你的板子上有两个 DAC 通道,可以只注册一个设备名,比如dac0,然后在 convert 回调里根据channel参数判断是写 DAC0 还是 DAC1。RT-Thread 的rt_dac_write(dev, channel, value)会把通道号原样传到底层回调,所以底层的 channel 判断并不复杂。
5.3 业务里真正去“写”电压值
应用层使用 DAC 的核心就是把目标电压换算成 12 位数字量,然后调用rt_dac_enable和rt_dac_write。我一般封装一个小函数,把毫伏值转换成码值,方便控制逻辑里直接填电压。
static void set_dac_mv(rt_dac_device_t dac_dev, rt_uint16_t mv) { rt_uint32_t code = 0; /* 满量程 3300mV,12位右对齐 */ code = (rt_uint32_t)((rt_uint32_t)mv * 4095U / 3300U); rt_dac_enable(dac_dev, 0); rt_dac_write(dac_dev, 0, code); }调用前想清楚:DAC 输出的是单极性 0-3.3V 电压,工控执行机构要的 0-10V 或 4-20mA 信号,必须靠板上的后级调理电路完成。如果你发现写了某个码值但外部设备没有反应,先拿示波器测 DAC 引脚有没有电压,再往后看调理电路供电和接线。很多问题不是驱动写得不对,而是板子后面那级电路没调好。
6. 现场跑起来以后,常见问题与排查实录
6.1 读数异常速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| ADC 读数一直是 0 | 输入引脚没接信号;GPIO 模式配错;ADC 未使能 | 用万用表量引脚电压,检查 GPIO 模拟模式,确认 RCU 和adc_enable |
| ADC 读数一直满量程 0xFFF | 输入被上拉到 VREF;通道号配置错;引脚悬空导致噪声耦合 | 查原理图确认是否外部上拉,核对通道号和引脚映射,悬空引脚接下拉电阻 |
| 电压换算结果偏差大 | VREF 实际值不是 3.3V;没有做 ADC 校准 | 实测 VREF,用实际值替换换算公式;执行校准函数;做两点校准 |
| 采集值跳动明显 | 前级信号阻抗太高;采样时间太短;电源纹波大 | 加 RC 滤波,增大 ADC 采样时间,软件做均值或滑动滤波 |
| DAC 输出始终为 0 | 未dac_enable;引脚模式错误;输出缓冲未开且负载过重 | 示波器测 DAC 引脚,检查 GPIO 配置,打开缓冲 |
| DAC 输出电压非线性 | 数据对齐方式与写入码值不匹配;单极性输出靠近电源轨 | 确认右对齐,校准后级放大电路,必要时避开 0V 附近死区 |
这张表是我排查问题时的第一反应来源。尤其要注意的是,ADC 读数为 0 或满量程这类“极端值”问题,往往不是驱动回调写错,而是通道号或引脚映射搞错了。GD32H759 的通道编号和引脚号经常不是一一对应的,对着数据手册一个一个核,才能少走弯路。
6.2 三个我踩过且容易忽略的坑
第一个坑是 ADC 数据对齐方式。有一块板子先用的是左对齐,所有电压读出来都是满量程的大数,我还以为是传感器接线问题,折腾半天。其实代码里一行adc_data_alignment_config(ADC0, ADC_DATAALIGN_RIGHT);就能解决。对齐方式直接影响raw * 3300 / 4095这条公式,要是用 8 位拼接的方式读左对齐数据,正确做法是raw >> 4,或者把换算公式改成按 12 位满量程 255 去算。现在驱动代码统一右对齐,省了很多麻烦。
第二个坑是 RT-Thread 框架里的并发访问。ADC 驱动里如果用了同一个变量保存通道号或者标志位,多个线程同时调rt_adc_read时可能会互相踩。虽然 RT-Thread 的设备框架有一些互斥保护,但底层驱动如果自己维护状态,还是容易出问题。我的做法是:驱动里尽量不保存可变的全局状态,每次 convert 都重新配置通道,这样天然无状态;如果一定要用 DMA 连续采集,就在驱动内部用一个信号量或互斥量保护共享缓冲。
第三个坑是现场地线的问题。实验室里用开发板测 ADC,信号源和板卡共地很好,读数很干净。到了工业现场,模拟地和数字地、电源地绞在一起,ADC 读数会飘得让你怀疑人生。这时候别急着调代码,先看接地图。模拟采样部分最好与数字部分单点共地,或者用隔离电源和隔离运放隔开。DAC 输出的地也一样,如果执行器和板卡之间电位差大,输出信号就会杂散跳动。驱动代码解决不了物理层的共地问题,但这恰恰是工控项目里最容易遗忘的一环。
7. 从这一篇往后,ADC/DAC 还能怎么扩展
最后聊点个人体会和后续方向,不算总结,算是给继续填坑留个记号。
我在实际项目里用这套框架跑通后,最明显的感觉是:RT-Thread 把设备驱动和业务代码之间的界限划得很清楚,调完驱动,上层线程只需要关心 “我要采哪一路、我要输出多少电压”,再不用去和寄存器搏斗。但 ADC 单通道轮询只是基本功,真正做中高端的工控板,还得继续扩展。
第一个扩展方向是 DMA 采样。单通道轮询在低速率下没问题,一旦需要同时对多路信号做波形分析,比如采集电机三相电流,就必须让 ADC 按固定频率自动扫描,把结果通过 DMA 搬到内存环形缓冲区,驱动内部用rt_ringbuffer管理,再向应用层提供读取接口。RT-Thread 的设备框架对这类批量化读取支持不算原生,需要自己封装一层。
第二个扩展方向是双 ADC 同步采样。GD32H759 支持双 ADC 模式,可以让两个 ADC 同时采样两路信号,这对做功率计算、电机控制非常关键。此时一个rt_adc_device内含两路原始数据,驱动注册名可以叫adc01,底层 convert 回调里根据通道号去区分,或者干脆写一个独立设备对象做专用接口,不与通用 ADC 框架混在一起。
第三个方向是校表和量程标定。工控的 ADC/DAC 不是读出来显示个数字就完事,所有模拟量通道都要做零点校准和满量程校准,存到 Flash 或 EEPROM 里。驱动可以做control回调,支持设置校准参数;业务层用通道校准系数把原始值换算成真实工程值。这一层加进去之后,ADC/DAC 驱动才算真正具备工业级可用性。后面有空我会把这几个扩展一个个写出来,继续把 GD32H759 的工控实战坑填完。