资讯动态

GD32H759实战:基于RT-Thread的ADC/DAC设备驱动开发与工控应用

发布时间:2026/9/20 2:39:33 来源:尧图企业网站定制
在工控现场摸爬滚打的人都知道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 滤波和运放跟随把传感器信号稳定送进 ADCAO 侧则是 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-Thread4.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-Thread5.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; /* 满量程 3300mV12位右对齐 */ 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_enableADC 读数一直满量程 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 的工控实战坑填完。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价