1. 项目整体思路与设计拆解
1.1 从工控项目需求反推外设选型
第3篇终于写到ADC和DAC了。前两篇我们搭建了GD32H759的RT-Thread最小系统,跑通了点灯和串口,算是把地基打好了。但工控项目真正打交道最多的,其实是模拟量。温度、压力、流量、液位,这些现场信号进来,第一步就是经过传感器变成电压或电流,然后落到MCU的ADC引脚上;反过来,要控制变频器、比例阀、电动执行机构,很多时候输出的就是4-20mA或0-10V的模拟信号,这就得靠DAC来生成。
我在项目里选GD32H759而不是普通M4内核芯片,主要看中的就是两点:第一,主频够高,M7内核跑到很高频率之后,做FFT、PID运算、数字滤波这些算法基本不喘气;第二,ADC和DAC的外设规格够看,特别是ADC的采样率和分辨率,在国产M7里属于第一梯队。说句实在话,用这颗料做模拟量采集,有点"杀鸡用牛刀"的意思,但工控产品就怕裕量不够,等现场出现采样抖动、转换速率跟不上再换料,代价就大了。
回到驱动本身,RT-Thread的设备驱动框架把ADC和DAC抽象成了标准设备,应用层调用rt_adc_read()、rt_dac_write()这类统一接口就行,不用关心底层寄存器具体怎么操作。这套抽象的价值在项目后期特别明显。我见过太多裸机项目,应用代码里直接塞寄存器操作,等换芯片、加功能的时候,那叫一个痛苦。用RT-Thread的设备框架,驱动层单独维护,应用层保持稳定,团队协作的时候各自管各自的模块,互不干扰。
1.2 为什么ADC和DAC要放在同一篇讲
可能会有朋友问,ADC是采集输入,DAC是输出,这俩方向相反的东西,放一起讲是不是硬凑?其实不是。在工控闭环系统里,ADC和DAC经常是成对出现的:PID控制器读ADC拿到当前值,算完控制量之后写DAC输出。比如温控回路,热电偶信号经调理电路进ADC,MCU跑PID算法,输出PWM或DAC电压去控制加热器。两者在驱动层面有很多共性:
- 都要先配置GPIO为模拟复用功能;
- 都在RT-Thread的
struct rt_adc_ops和struct rt_dac_ops里实现同样的操作集合,无非是enabled、disabled、read、write这几个回调; - 都要处理电压换算的问题。
所以把两个驱动放在一篇里写,逻辑上是顺的,代码结构上也能互相参照。你写完ADC驱动再写DAC,会发现RT-Thread这套框架设计得相当顺手,套路是一样的,只是寄存器不同。
1.3 驱动架构划分:分离、分层、可替换
我们在实际项目里的做法,是把模拟量相关代码拆成三层:
板级初始化层:负责GPIO复用、时钟使能。这一层跟具体PCB走线强相关,引脚变了只改这里。
RT-Thread设备驱动层:实现rt_adc_ops结构体,注册ADC设备;实现rt_dac_ops结构体,注册DAC设备。这一层把硬件操作全部封装在回调函数里。
应用层:通过设备框架API读写数据,做工程单位换算,跑控制算法。
这个分层最大的好处是:驱动调试的时候可以用单独的小程序验证,应用层根本不用动;等硬件改版换了引脚,只改第一层;要是以后要换芯片平台,第二层整体重写,第三层还是不动。做产品最怕的就是牵一发动全身,提前把边界划清楚,能省掉后面无数个加班的夜晚。
1.4 关键参数规划:采样率、分辨率与量程
开始写代码之前,先得把需求参数的账算明白。我拿一个典型的多回路数据采集系统举例:
- 16路模拟量输入,信号范围0-10V,前端经过电阻分压或运放调理到0-3.3V;
- 每路采样周期要求不低于1kHz,也就是说每秒钟每通道至少采1000次;
- 分辨率要求12位以上,希望直接用16位采样减少软件滤波压力;
- 4路模拟量输出,输出范围0-5V,用于控制外部执行器。
GD32H759的ADC模块最高可以配置到16位分辨率,采样率在最快模式下能做到很高,对我们这个需求完全够用。16路信号如果逐路轮询扫描,每路1kHz周期就是所有路加起来的采样率要16kHz以上,实际我们配置ADC跑几MHz甚至更高,完全不是问题。DAC方面,H7系列DAC是12位的,参考电压选2.5V还是3.3V,决定了输出精度和量程,这个后面细说。
账算清楚,心里的底就有了。接下来进入正题。
2. ADC驱动核心细节与实操要点
2.1 先搞明白GD32H759的ADC外设结构
GD32H759的ADC模块相比M3/M4时代的单片机,复杂了不止一个档次。首先是分辨率的可配置性,从12位到16位有好几档可以选择。其次是转换模式非常灵活:单次转换、连续转换、扫描模式、间断模式都可以组合使用。再就是触发源,可以是软件触发、定时器触发、外部引脚触发,这个在工控里很关键——比如你要让PWM周期和ADC采样严格同步,就得用定时器触发ADC。
还有一个容易被忽略但很重要的概念:规则组和注入组。规则组是常规转换序列,用来做周期性采样;注入组相当于高优先级通道,可以打断规则组的转换,适合处理突发的高优先级采样请求。在我这个项目里,16路信号用规则组扫描就够,注入组暂时用不到,但你要知道有这个东西,哪天遇到"某一路信号突然报警需要立即采样"的需求,就能想到用注入组来实现。
ADC的时钟也值得注意。ADC时钟源来自APB2总线的分频,但不是说分频越低越好,因为ADC的采样时间还受转换周期影响。采样周期越短越省时间,但采样电容充电不完全,精度会下降;采样周期太长又浪费时间。这个折中要在实测中慢慢调。
2.2 引脚复用与时钟配置:最容易踩坑的地方
ADC驱动的第一行代码,往往不是ADC初始化,而是GPIO配置。GD32的引脚默认都不是模拟功能,必须手动设置成AF模式并且选定正确的复用编号。
static int adc_gpio_init(void) { gpio_af_set(GPIOA, GPIO_AF_7, GPIO_PIN_0 | GPIO_PIN_1); gpio_mode_set(GPIOA, GPIO_MODE_ANALOG, GPIO_PUPD_NONE, GPIO_PIN_0 | GPIO_PIN_1); gpio_af_set(GPIOB, GPIO_AF_7, GPIO_PIN_0); gpio_mode_set(GPIOB, GPIO_MODE_ANALOG, GPIO_PUPD_NONE, GPIO_PIN_0); return RT_EOK; }注意两个细节:GPIO_AF_7这个复用编号跟芯片型号强相关,同样是ADC引脚,在GD32F4上可能就不是AF_7,一定要查对应型号的参考手册。另外模拟引脚要把上下拉设为NONE,默认的上下拉会影响模拟量的测量结果,这个坑我见过很多人踩。
时钟使能方面,除了GPIO时钟,还要注意ADC的时钟使能。GD32H759的ADC和DAC都有独立的时钟控制位,在rcu_periph_clock_enable()里开的是哪个外设时钟,必须跟数据手册对应清楚。
rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_GPIOB); rcu_periph_clock_enable(RCU_ADC0);注意:开完外设时钟之后,最好等一小段稳定时间再操作ADC寄存器,否则第一次上电的时候可能读到随机值。这个在RT-Thread驱动里可以通过宏定义或延迟函数来处理。
2.3 多通道DMA扫描模式的配置方法
16路信号,如果用CPU中断方式一路一路读,一秒钟几万次中断,CPU直接废了。项目中我使用DMA方式实现多通道扫描,配置好DMA之后,ADC自动完成所有通道的转换,转换结果按顺序摆到内存数组里,整个过程中CPU只管做别的事,采完一轮触发一次完成中断就行。
关键代码如下:
#define ADC_CH_NUM 16 static uint16_t adc_buf[ADC_CH_NUM]; static void adc_dma_config(void) { dma_single_data_parameter_struct dma_init_struct; rcu_periph_clock_enable(RCU_DMA0); dma_deinit(DMA0, DMA_CH0); dma_single_data_parameter_struct_init(&dma_init_struct); dma_init_struct.periph_addr = (uint32_t)&ADC0->RDATA; dma_init_struct.periph_inc = DMA_PERIPH_INCREASE_DISABLE; dma_init_struct.memory0_addr = (uint32_t)adc_buf; dma_init_struct.memory_inc = DMA_MEMORY_INCREASE_ENABLE; dma_init_struct.direction = DMA_PERIPHERAL_TO_MEMORY; dma_init_struct.number = ADC_CH_NUM; dma_init_struct.periph_width = DMA_PERIPHERAL_WIDTH_16BIT; dma_init_struct.memory_width = DMA_MEMORY_WIDTH_16BIT; dma_init_struct.priority = DMA_PRIORITY_HIGH; dma_single_data_mode_init(DMA0, DMA_CH0, &dma_init_struct); dma_circulation_enable(DMA0, DMA_CH0); dma_channel_enable(DMA0, DMA_CH0); }这里有几个关键点我要强调:
外设地址永远是ADC0->RDATA。不管当前转换的是哪个通道,DMA读的就是这个寄存器。ADC会自动把对应通道的转换结果送到这个寄存器,DMA搬走之后,ADC接着填下一个通道的值。所以DMA的memory_inc要设为ENABLE,依次把数据存进数组的不同元素。
number要等于扫描通道数。如果ADC配了8个通道,DMA搬运次数就是8,一轮搬完,DMA会产生传输完成中断。但注意GD32的DMA有循环模式dma_circulation_enable,开了之后DMA会从头再来,持续不断。
还有一个细节:数据类型。ADC的16位结果用uint16_t存足够,但如果你配了16位分辨率,高16位才有效,低几位可能是无效的填充位,这个后面换算的时候要处理。
2.4 ADC初始化参数详解:分辨率、对齐、扫描顺序
ADC外设本身的初始化,参数比较多,我贴出项目里实际使用的配置,逐个解释。
static void adc_config(void) { adc_clock_config(ADC0, ADC_CLK_DIV_16); adc_special_function_config(ADC0, ADC_SCAN_MODE, ENABLE); adc_special_function_config(ADC0, ADC_CONTINUOUS_MODE, DISABLE); adc_special_function_config(ADC0, ADC_DMA_MODE, ENABLE); adc_data_alignment_config(ADC0, ADC_DATAALIGN_RIGHT); adc_resolution_config(ADC0, ADC_RESOLUTION_16B); adc_channel_length_config(ADC0, ADC_REGULAR_CHANNEL, ADC_CH_NUM); for (int i = 0; i < ADC_CH_NUM; i++) { adc_channel_config(ADC0, ADC_REGULAR_CHANNEL, adc_ch_list[i], ADC_SAMPLETIME_30); } }逐项说明:
ADC_CLK_DIV_16:ADC时钟分频。分频系数越大,ADC时钟越低,采样越稳定,但速度越慢。项目里信号变化速率不快,用16分频比较稳。ADC_SCAN_MODE:扫描模式,必须要开,否则DMA一次只能采一路。ADC_CONTINUOUS_MODE:连续模式我关掉了。用DMA配合扫描模式,每次触发转完一轮就停,等下个触发信号再开始。如果开了连续模式,ADC就会没完没了地转,DMA缓冲区会被反复刷新,你要是不及时读,数据就不知道被冲到哪里去了。ADC_DMA_MODE:DMA请求使能,必须打开。ADC_DATAALIGN_RIGHT:数据右对齐,这是绝大多数情况下的选择。左对齐一般是配合老式代码的移位运算用的,右对齐直接读就是数值,简单直接。ADC_RESOLUTION_16B:16位分辨率。这里必须提醒:16位分辨率的LSB(最低有效位)对于3.3V参考电压意味着什么,后面讲换算再展开。- 采样时间:配置成30个时钟周期左右,属于偏保守的选择,抗干扰能力强。如果你要追求高采样率,可以往下压,但不能低于手册规定的下限。
2.5 采样值到工程量的换算:这一步错了全盘皆输
驱动把ADC原始值读出来,只是万里长征走完了一半。工控现场要的不是一个0到65535的数字,而是"温度25.3度""压力1.2MPa"这种带物理意义的数据。换算这个环节,最容易出问题。
换算公式其实很简单:
物理量 = 原始值 / 满量程值 * 参考电压 /(前端调理电路增益或分压比)以0-10V输入、分压到0-3.3V、16位ADC为例:
- 满量程值 = 65535
- 参考电压 = 3.3V
- 分压比 = 3.3 / 10 = 0.33
- 所以输入电压Vin = 原始值 / 65535 * 3.3 / 0.33 = 原始值 / 65535 * 10
写成代码就是这样:
float adc_to_voltage(uint16_t raw, float ref_voltage) { return (float)raw / 65535.0f * ref_voltage; } float voltage_to_process_value(float voltage, float divider_ratio) { return voltage / divider_ratio; }注意浮点运算的效率问题。M7内核有硬件浮点单元,跑float计算很快,但在中断里频繁做除法也要心疼一下CPU。项目里我会先把分压比和参考电压合成一个系数,再换算,尽量减少运行时计算量。
还有一个隐蔽坑:电压基准的精度。参考电压如果是VDDA,那么VDDA的精度直接决定了采样精度。普通LDO输出偏差可能到±1%甚至更高,如果你要求0.5%以上的测量精度,最好用专门的电压基准芯片提供VDDA。我习惯直接用2.5V的外部基准,然后前端调理电路把传感器信号也映射到0-2.5V区间,这样精度和量程都好控制。
2.6 ADC驱动注册到RT-Thread:填补ops结构体
RT-Thread的ADC设备驱动核心是struct rt_adc_ops,里面有几个函数指针:enabled、disabled、read等。我们要做的,就是把上面的初始化逻辑和读取逻辑,填进这些函数指针里。
static rt_err_t drv_adc_enabled(struct rt_adc_device *device, rt_uint32_t channel, rt_bool_t enabled) { if (enabled) { adc_channel_enable(device->parent.user_data, channel); } else { adc_channel_disable(device->parent.user_data, channel); } return RT_EOK; } static rt_err_t drv_adc_read(struct rt_adc_device *device, rt_uint32_t channel, rt_uint32_t *value) { if (channel >= ADC_CH_NUM) { return -RT_EINVAL; } adc_software_trigger_enable(device->parent.user_data); while (!adc_flag_get(device->parent.user_data, ADC_FLAG_STRC)) { } adc_flag_clear(device->parent.user_data, ADC_FLAG_STRC); *value = adc_buf[channel]; return RT_EOK; } static struct rt_adc_ops drv_adc_ops = { .enabled = drv_adc_enabled, .read = drv_adc_read, };这里我用了adc_buf[channel]直接返回DMA缓冲区里的值。因为我配的是DMA连续扫描模式,缓冲区里的数据一直在更新,读取操作只是从内存取值,不用等到某个通道转换完成。但如果你的应用对"读取时刻的实时性"要求高,就得用信号量同步:DMA传输完成中断里释放信号量,读取操作获取信号量后再取值。这个看具体需求。
注册设备这一步,在板级初始化函数里完成:
static int rt_hw_adc_init(void) { adc_gpio_init(); adc_dma_config(); adc_config(); return rt_hw_adc_register(&adc0_dev, "adc0", &drv_adc_ops, ADC0_BASE); } INIT_BOARD_EXPORT(rt_hw_adc_init);INIT_BOARD_EXPORT宏保证它在系统启动早期被调用,这样应用层代码在初始化阶段就能直接rt_device_find("adc0")找到设备。
3. DAC驱动核心细节与实操要点
3.1 DAC外设的基本特性与输出能力
DAC这边,GD32H759的片上DAC是12位分辨率的。12位意味着2^12 = 4096个量化等级,对于3.3V参考电压,理论分辨率就是3.3V / 4096,约0.8mV。这个精度对于大多数工控输出需求是够的。
DAC输出是电压形式。如果负载需要的是电流(比如4-20mA),就需要外加电压转电流电路,这个不在驱动范围内,但做硬件方案的时候一定要考虑进去。软件上能做的,是把DAC输出配置成需要的电压范围。
GD32的DAC通常有两个独立通道,可以独立输出,也可以联动输出。每个通道都可以接不同的参考电压,理论上可以做到两路输出范围不同的效果。
3.2 DAC的GPIO与时钟配置:复用编号别搞混
DAC的引脚复用和ADC类似,同样是配置成模拟模式,但复用编号不一样。以最常见的PA4和PA5作为DAC输出脚为例:
static int dac_gpio_init(void) { gpio_af_set(GPIOA, GPIO_AF_7, GPIO_PIN_4 | GPIO_PIN_5); gpio_mode_set(GPIOA, GPIO_MODE_ANALOG, GPIO_PUPD_NONE, GPIO_PIN_4 | GPIO_PIN_5); return RT_EOK; }注意:ADC和DAC的GPIO配置代码看起来非常像,都是模拟模式,都是AF_7(至少在GD32H759上是这样),这既是好事也是坏事。好事是记住一套就行,坏事是复制粘贴的时候容易搞混引脚编号。
3.3 波形输出模式:DMA + 双缓冲实现连续可调
如果你的DAC只是用来输出一个固定电压,配置就很简单,往数据寄存器写值就完了。但工控场景里,DAC经常要输出波形——斜坡信号、正弦信号、甚至任意自定义波形,用来测试执行器的响应特性。
普通写法是每次需要改变输出时,直接调用rt_dac_write()更新寄存器。但如果你要生成一个精确的正弦波,比如1kHz、每个周期64个点,那么每秒钟要更新64000次,CPU全部都耗在写寄存器上了。这时候就需要DMA帮忙。
DMA搬运的思想是:预先在内存里准备好波形表,然后定时器触发DMA,每次触发就把波形表里的一个值搬到DAC的数据寄存器,搬运完成DMA计数器递减,搬到最后一个值之后从头再来。整个过程CPU零介入。
代码结构如下:
#define DAC_WAVE_POINTS 128 static uint16_t dac_wave_buf[DAC_WAVE_POINTS]; static void dac_wave_table_init(float amplitude, float offset) { for (int i = 0; i < DAC_WAVE_POINTS; i++) { float angle = 2.0f * 3.1415926f * i / DAC_WAVE_POINTS; dac_wave_buf[i] = (uint16_t)((offset + amplitude * sinf(angle)) * 4095.0f / 3.3f); } }生成好波形表之后,配置DMA把表里的数据循环送到DAC的数据寄存器。这个过程在GD32上需要把DAC配置成DMA触发模式。
3.4 DAC双缓冲:波形切换不留锯齿
生成连续波形的时候,有个细节必须处理:如果DMA正在搬运波形表A,而此时要切换到波形表B,直接硬切换会导致波形出现一个突变——因为DMA搬完了A的最后一个点,下个周期直接从B的第一个点开始,但如果A和B的起始点相位不同,输出波形就会跳变。
解决办法是使用双缓冲机制。GD32的DMA可以配置两个内存地址,在DMA完成一次完整传输后,自动切换到另一个内存地址作为源地址。我们准备两个波形表,一个填正弦,一个填锯齿。当前正在输出A,下一个周期开始前把B的内容准备好,并设置交接标志。DMA搬完A的最后一个点之后,自动从B的第一个点开始搬。
RT-Thread设备驱动框架里的DAC驱动本身不带波形功能,但你可以通过rt_device_control()加自定义控制命令来实现。我在项目里扩展了三个控制命令:
DAC_CMD_SET_WAVE_TABLE:注册波形表DAC_CMD_START_WAVE:启动DMA波形输出DAC_CMD_STOP_WAVE:停止DMA波形输出
这样应用层代码看起来非常清晰,波形生成规则完全由应用层决定,驱动层只负责搬运和输出。
3.5 输出电压与12位原始值的换算
DAC的换算和ADC相反,是从工程量到寄存器值:
寄存器值 = 目标电压 / 参考电压 * 4095比如参考电压3.3V,要输出1.65V,寄存器值 = 1.65 / 3.3 * 4095 = 2047.5,取整后2047或2048都有可以接受。写成函数:
uint16_t voltage_to_dac(float voltage, float ref_voltage) { if (voltage < 0) voltage = 0; if (voltage > ref_voltage) voltage = ref_voltage; return (uint16_t)(voltage / ref_voltage * 4095.0f); }边界判断一定要有。DAC寄存器是12位的,写入超过4095的值会发生截断或者错误,提前在函数里做饱和处理是负责任的做法。
3.6 DAC设备驱动实现与注册
DAC驱动跟ADC套路一样,填rt_dac_ops结构体:
static rt_err_t drv_dac_enabled(struct rt_dac_device *device, rt_uint32_t channel, rt_bool_t enabled) { if (enabled) { dac_channel_enable(device->parent.user_data, channel); } else { dac_channel_disable(device->parent.user_data, channel); } return RT_EOK; } static rt_err_t drv_dac_write(struct rt_dac_device *device, rt_uint32_t channel, rt_uint32_t value) { if (value > 4095) { return -RT_EINVAL; } if (channel == 0) { DAC0->DAC_R12DH = value; } else if (channel == 1) { DAC0->DAC_R12DL = value; } else { return -RT_EINVAL; } return RT_EOK; } static struct rt_dac_ops drv_dac_ops = { .enabled = drv_dac_enabled, .write = drv_dac_write, };注册函数:
static int rt_hw_dac_init(void) { dac_gpio_init(); rcu_periph_clock_enable(RCU_DAC0); dac_deinit(); return rt_hw_dac_register(&dac0_dev, "dac0", &drv_dac_ops, DAC0_BASE); } INIT_BOARD_EXPORT(rt_hw_dac_init);3.7 应用层调用示例
设备注册好之后,应用层的代码就清爽了:
#define CNT_DEV_NAME "adc0" #define DAC_DEV_NAME "dac0" static rt_adc_device_t adc_dev; static rt_dac_device_t dac_dev; static int analog_io_init(void) { adc_dev = (rt_adc_device_t)rt_device_find(CNT_DEV_NAME); dac_dev = (rt_dac_device_t)rt_device_find(DAC_DEV_NAME); if (!adc_dev || !dac_dev) { return -RT_ERROR; } rt_adc_enable(adc_dev, 0); rt_adc_enable(adc_dev, 1); rt_dac_enable(dac_dev, 0); return RT_EOK; } /* 读取采集值并换算输出 */ float read_channel_voltage(int ch) { rt_uint32_t raw = 0; rt_adc_read(adc_dev, ch, &raw); return adc_to_voltage(raw, 3.3f); } void set_output_voltage(float voltage) { uint16_t dac_val = voltage_to_dac(voltage, 3.3f); rt_dac_write(dac_dev, 0, dac_val); }看到没,应用层不关心底层寄存器叫什么名字、偏移量是多少,只需要拿着设备名找设备、调用接口、传参数。这就是设备驱动框架存在的意义。
4. 常见问题与排查技巧实录
4.1 问题速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| ADC读到的值一直为0 | GPIO复用配置错误或外部信号未接入 | 先用万用表量引脚电压,再看GPIO配置 |
| ADC读到的值满量程或跳动剧烈 | 引脚悬空、时钟未稳定、参考电压不稳 | 给输入端接固定电压,排除外部干扰 |
| DMA搬运一轮后不再更新 | DMA使能顺序问题或缓冲区地址不对 | 检查DMA的memory0_addr和number配置 |
| DAC输出固定为0 | 未调用enabled接口使能通道 | 检查驱动里dac_channel_enable是否执行 |
| DAC输出只在满量程附近跳变 | 写入了超过4095的值或电压换算错误 | 打印寄存器值,检查换算系数 |
| RT-Thread找不到adc0/dac0设备 | 初始化顺序或设备注册失败 | 检查INIT_BOARD_EXPORT是否执行,用list_device命令确认 |
4.2 问题一:ADC采样值波动大,是噪声还是配置问题
我最开始调试这套驱动的时候,大概率会遇到"ADC值上下跳动几十个LSB"的情况。第一反应是硬件抗干扰不够,拿示波器去量输入信号,一看波形确实有点毛刺,但幅度不大,不该导致这么大的ADC波动。
后来发现,问题出在ADC的采样时间设得太短。采样时间短,采样电容充电不完全,测量结果里就带上了充放电噪声。把采样时间从10个周期拉到30个周期,波动明显下降。如果还是波动,就检查参考电压VREF引脚上的去耦电容,该放0.1uF加10uF的组合就放,不要省。
还有一点,软件层面可以加数字滤波。简单的一阶低通滤波:
#define FILTER_ALPHA 0.2f static float adc_filtered = 0; float adc_filter(float new_value) { adc_filtered = adc_filtered + FILTER_ALPHA * (new_value - adc_filtered); return adc_filtered; }这个用浮点计算有点浪费CPU,可以改成定点计算,但对于M7来说问题不大。滤波系数要根据信号频率和采样率调整,别一刀切。
4.3 问题二:DMA第二轮数据跟第一轮错位了
这个问题的典型表现是:第一轮读到的16个通道数据是对的,但第二轮开始,通道数据跟缓冲区下标对不上了,比如本来应该存通道3的数据,跑到了通道7的位置上。
排查思路:先确认ADC的通道扫描顺序和DMA搬运次序是否一致。如果ADC配置的扫描顺序是通道0、1、2、3...,那么DMA第一次搬运就是通道0的结果,第二次搬运是通道1的结果,以此类推。如果有任何一个通道被跳过了,后面全部错位。
还有一种可能是触发方式的问题。如果ADC既能被定时器触发,又能被软件触发,而你的程序里偶尔手动触发了一次,就会多转换一轮,DMA缓冲区被多写一轮数据,但你的读取逻辑还是按16个通道一轮来读,就错位了。解决方法是彻底避免混合触发,只用一种触发源。
4.4 问题三:DAC输出波形有台阶感,如何处理
如果DAC输出一个斜坡或者正弦波,用示波器能看到明显的阶梯,这是量化噪声的典型表现。12位DAC要输出平滑波形,物理上就会有台阶,只能改善不能消除。
改善方法有三个方向:
- 提高DAC更新速率。同样的波形,点越多,台阶越不明显。把波形表从64点加到128点、256点,台阶就细很多。
- 在硬件上加快输出端的低通滤波器。把DAC后面的二阶RC低通频率设计在波形基频的3-5倍左右,台阶能被滤得很干净。注意别把有用信号也滤掉了。
- 软件上做噪声整形或者过采样技术,把前面ADC相关的数字滤波器反转过来用,这个比较复杂,一般不用在DAC上。
其实判断台阶能不能接受的标准是下游设备。如果DAC驱动的是一个电机调速端口,台阶带来的影响可能微乎其微;如果驱动的是一个高精度的位置环,台阶就可能导致系统抖动。具体情况具体分析。
4.5 问题四:RT-Thread设备名称找不到
这个坑几乎每个从裸机转RT-Thread的工程师都会踩:rt_device_find("adc0")返回空指针,程序直接跑飞。
原因多半是设备注册的时机太晚。RT-Thread的INIT_BOARD_EXPORT宏定义的初始化函数会在系统启动早期被调用,但如果你的应用线程是在初始化线程里创建的,而创建线程的代码又依赖设备已经注册成功,就可能有竞争风险。解决办法是把设备查找操作挪到线程入口函数里执行,或者使用INIT_DEVICE_EXPORT、INIT_COMPONENT_EXPORT等不同级别的初始宏,保证设备先注册、应用后启动。
另外,用list_device命令在MSH控制台里看看设备到底注册上没有,如果注册了,名字是不是带空格或者大小写不对。
4.6 问题五:电压换算结果偏差大,根本原因在基准
还有一个比较隐蔽的问题:ADC读到的寄存器值看似正常,但换算成电压之后,跟万用表实测的值差了一截,比如差了0.2V。
这个偏差如果是个固定值,那大概率是参考电压实际值和理论值不一样。例如理论参考3.3V,实际VDDA只有3.24V,换算时按3.3算,差了2%左右。解决方法是把实际参考电压值测出来,写进代码的宏定义里。
如果偏差不是固定的,而是小信号时偏差大、大信号时偏差小,那就要怀疑ADC的失调误差和增益误差了。这种属于芯片本身的特性,可以在软件里做两点校准。校准原理很简单:给ADC输入0V和满量程标称值,分别读出原始值,然后构造一条直线映射关系。现在很多高端一点的板卡都这么做,效果相当明显。
5. 实操过程与核心环节实现
5.1 准备工作:硬件连接与开发环境
在动手写代码之前,我习惯先把硬件环境搭建好。这次用的是自制的GD32H759核心板,板载了ADC输入接口和DAC输出接口,外加一个CMSIS-DAP调试器。如果你用的是官方开发板或者第三方板卡,也没关系,原理一样,只要能找到对应引脚就行。
开发环境方面,RT-Thread Studio自带工程向导,选好芯片型号、配置好调试器就可以生成一个带shell和device框架的最小工程。我建议先跑通一个简单的外设(比如点灯)再开始ADC/DAC,这样能排除环境问题和调试器问题。很多人一上来就调ADC,点灯都没跑过,然后花了半天时间才发现是调试器驱动没装好,这就很浪费时间。
5.2 工程配置要点
RT-Thread Studio里的配置,主要检查这几项:
- 内核:设备对象要开启,这是设备框架的基础。
- 组件:MSH命令最好开,调试的时候实时查状态很方便。
- 设备驱动:ADC和DAC驱动选项如果Studio支持勾选,就直接勾上;不支持就手动添加源码文件。
需要说明的是,RT-Thread官方仓库里,针对特定芯片的ADC/DAC驱动可能不齐全。GD32H759这颗料比较新,很多时候需要自己写驱动或者从相近型号的驱动代码移植过来。所以我的做法是,自己在drivers目录下面新建drv_adc.c和drv_dac.c,把设备注册进系统。这是最稳妥的路径,也能让你真正掌握驱动的每个细节。
5.3 编写驱动文件的顺序和坑点
写驱动文件的顺序,我推荐"自上而下":先搭接口函数的结构骨架,再填充寄存器操作。因为RT-Thread的驱动框架已经定好了函数指针的形状,你只需要填空。一旦骨架清晰了,寄存器操作查参考手册去填就行,不容易漏配。
先定义一个设备实例变量:
struct rt_adc_device adc0_dev;然后是rt_adc_ops里的每个回调函数。注意回调函数的参数里有struct rt_adc_device *device,你要访问底层硬件寄存器,就得从device->parent.user_data里取出注册时传入的基地址。所以注册设备的时候,最后一个参数一定要传对:
rt_hw_adc_register(&adc0_dev, "adc0", &drv_adc_ops, ADC0_BASE);ADC0_BASE要跟芯片头文件里定义的完全一致。
5.4 单步调试信号链路的经验
驱动写完,烧录运行,接下来是调试环节。我先说一个非常实用的调试技巧:把MSH控制台打开,然后手动输入命令去调用ADC读取接口,这样不用写应用层代码就能验证驱动是否正确。
想要这个调试技巧好使,需要给控制台加几条调试命令:
MSH_CMD_EXPORT_ALIAS(adc_test, adc_test, adc sample test); MSH_CMD_EXPORT_ALIAS(dac_test, dac_test, dac output test);在adc_test里,可以循环打印每个通道的原始值和电压值:
void adc_test(void) { rt_uint32_t raw = 0; float voltage = 0; for (int i = 0; i < 8; i++) { rt_adc_read(adc_dev, i, &raw); voltage = adc_to_voltage(raw, 3.3f); rt_kprintf("ch%d raw:%5d voltage:%.4f\r\n", i, raw, voltage); } }用这个命令,你可以快速定位问题是出在硬件还是软件:先给某个ADC引脚接一个已知电压(比如用可调电源输出1V),看打印出来的电压是否在1V附近。如果差很远,说明驱动或硬件有问题,再逐层往下排查。
DAC那边更直观,设置一个输出值,然后用万用表量引脚电压,看是否吻合。比如设置输出1.65V,实测应为1.65V左右,偏差应该在几十毫伏以内。
5.5 使用逻辑分析仪或示波器验证时序
比万用表更进一步的手段是示波器。ADC在DMA模式下工作的时候,你很难在引脚上直接看到采样时刻,因为采样是外设和DMA之间的事,CPU不参与。这时候示波器主要用来观察输入信号的叠加噪声,帮助判断信号调理电路做得够不够好。
DAC验证波形就直观多了。把DMA波形功能打开,示波器探针接到DAC输出脚,应该能看到正弦波斜坡或者你指定的任意波形。用这个方法来验证DMA是否正常工作,比单纯用万用表量平均值靠谱得多。
5.6 性能验证:实测采样率与CPU占用率
驱动调通之后,我还习惯做一个性能验证:看采样率是否达到预设要求,以及CPU占用情况。
测试方法是简单粗暴的:在一个定时器中断里,统计每秒DMA完成中断的次数,换算成实际采样率。CPU占用可以用一个GPIO翻转来粗略测量:任务忙的时候拉高,闲的时候拉低,用示波器看高电平占比。
在这个项目里,16路ADC以1kHz输出频率全速运行,同时DAC输出1kHz正弦波,CPU占用不到5%,剩下的资源全部用来跑算法和通信,这就是DMA+设备驱动框架带来的好处。
6. 设计经验与项目迁移建议
6.1 驱动分层设计带来的长期收益
在做完这套ADC/DAC驱动之后,我对RT-Thread设备驱动框架的体会又深了一层。如果你只是想把某个外设跑起来,直接操作寄存器也完全没问题,10分钟就能搞定。但如果你的目标是做一款要长期维护、可能要迭代好几代的产品,那分层设计的价值就体现出来了。
驱动层和应用层的边界一旦划清楚,意味着模拟量采集、输出相关的应用代码跟具体芯片的外设细节完全解耦。后面哪怕要换更低成本的芯片、或者升级到更高性能的芯片,驱动层整体替换,应用层代码基本不动。这个在项目时间紧张的时候,就是最大的救命稻草。
6.2 代码的可配置化设计
我还推荐把关键参数全部提取成宏定义或者配置文件项,比如ADC通道列表、参考电压、采样周期、波形表点数,都放进一个board_drv_config.h里。这样硬件改版后,软件适配只需要改一个头文件,不用在代码里到处找魔法数字。
/* board_drv_config.h */ #define ADC_CH_NUM 16 #define ADC_REF_VOLT 3.3f #define ADC_DIVIDER 0.33f #define DAC_REF_VOLT 3.3f #define DAC_MAX_VAL 4095与其未来把时间花在反复审代码找魔法数字上,不如现在多花几分钟把参数集中管理起来。
6.3 从驱动到应用的扩展路径
ADC/DAC驱动跑通之后,后面的路就很宽了。你可以继续做以下扩展:
- 把
adc_to_voltage和voltage_to_process_value封装成一个统一的标度变换工具库,后续所有模拟量通道都用这一套函数,避免各写各的导致换算不统一。 - 在ADC数据之上加软件滤波、均值滤波、滑动窗口、中值滤波等多种算法,根据信号类型自动选择。
- 在DAC之上加递变控制功能,例如输出以一定斜率从当前值逐步变到目标值,防止外部执行器因为阶跃响应而冲击损坏。
- 集成PID闭环控制模块,用ADC做测量反馈,用DAC做控制输出,跑一个完整的单回路控制。
这些扩展,因为底层驱动已经是干净的设备模型,做起来会非常顺利。
6.4 一颗芯片两颗外设,一套框架全部搞定
回头总结一下GD32H759 + RT-Thread的这套模拟量驱动方案,核心其实就是四句话:GPIO配置成模拟模式,DMA搬运数据,设备注册进框架,应用层只调接口。掌握了这个套路,从ADC到DAC,再到后面的定时器、PWM、编码器,所有的外设驱动都是同一个思路,无非是ops结构体里的函数不同而已。
我在实际项目里的体会是:真正难的不是把某个外设跑起来,而是如何让整个系统在复杂多变的现场环境中保持稳定可靠。硬件设计是基础,驱动框架是骨架,应用层是血肉,三者缺一不可。这套驱动写完只是开始,后面的信号调理、校准算法、故障诊断、通信协议,才是让产品真正值钱的部分。