第4章:ADC驱动抽象——采样通道管理、分辨率配置、结果读取
ADC驱动,说白了就是把模拟电压变成数字值。这事儿看着简单,但不同MCU的ADC模块差异大得惊人。我做过一个项目,换了MCU后ADC代码几乎重写了一遍——从那以后,我就铁了心要做抽象层。
4.1 采样通道管理:别让硬件细节污染上层
不同MCU的ADC通道编号方式五花八门。有的用ADC1_IN0这种格式,有的直接给个GPIO引脚号,还有的带序列器概念。你想想看,上层应用只想说“我要读温度传感器”,结果还得知道它接在哪个ADC模块的第几个通道上?这不合理。
我的做法是:定义一套逻辑通道号。比如0代表温度、1代表电压、2代表电流。底层驱动负责把逻辑号映射到物理通道。这样上层代码永远不用改。
核心设计思路:逻辑通道与物理通道解耦。上层只认逻辑通道号,底层负责映射。
/* 逻辑通道到物理通道的映射表 */
typedef struct {
uint8_t logical_ch; /* 逻辑通道号 */
uint8_t adc_instance; /* ADC模块实例 */
uint8_t physical_ch; /* 物理通道号 */
uint8_t sampling_time; /* 采样周期 */
} adc_channel_map_t;
static const adc_channel_map_t g_adc_channel_table[] = {
{ .logical_ch = 0, .adc_instance = 0, .physical_ch = 3, .sampling_time = 15 },
{ .logical_ch = 1, .adc_instance = 1, .physical_ch = 0, .sampling_time = 10 },
/* 新增MCU时,只改这个表 */
};
我在项目中遇到过一个问题:某款MCU的ADC通道有优先级限制,高优先级通道必须排在序列前面。当时我就在映射表里加了个优先级字段,排序时自动处理。嗯,这种坑只有踩过才知道。
4.2 分辨率配置:12位还是10位?别写死
ADC分辨率这事儿,不同MCU支持的范围不一样。有的支持6/8/10/12位,有的支持8/10/12/14位。我见过有人直接在代码里写死12位,换了个MCU就炸了。
抽象层应该提供一个统一的配置接口,底层去适配。我个人习惯用枚举定义分辨率,这样可读性好,也方便扩展。
typedef enum {
ADC_RES_6BIT = 0,
ADC_RES_8BIT = 1,
ADC_RES_10BIT = 2,
ADC_RES_12BIT = 3,
ADC_RES_14BIT = 4,
ADC_RES_16BIT = 5
} adc_resolution_t;
/* 统一配置接口 */
int32_t adc_set_resolution(uint8_t adc_instance, adc_resolution_t resolution);
小技巧:分辨率越高,转换时间越长。如果应用不需要高精度(比如按键检测),用8位就够了,能省不少时间。
为什么分辨率会影响转换时间?因为逐次逼近型ADC需要比较的次数等于分辨率位数。12位就要比12次,10位比10次。你想想看,省下那2位时间,在高速采样场景下差别很大。
4.3 结果读取:同步、异步还是DMA?
读取ADC结果的方式,不同MCU差异最大。有的只能轮询,有的支持中断,有的支持DMA。抽象层得把这些都包起来。
我建议提供三种读取模式:
- 阻塞模式:启动转换,等结果。适合低频采样。
- 非阻塞模式:启动转换,注册回调。适合中等频率。
- DMA模式:配置好缓冲区,自动填充。适合高频连续采样。
/* 统一结果读取接口 */
int32_t adc_read_blocking(uint8_t logical_ch, uint16_t *value);
int32_t adc_read_nonblocking(uint8_t logical_ch, adc_callback_t cb);
int32_t adc_read_dma(uint8_t logical_ch, uint16_t *buffer, uint32_t length);
注意:DMA模式下,缓冲区必须是全局变量或静态变量,不能是局部变量。我曾经犯过这个错,DMA写完后局部变量已经出栈了,数据全乱套了。
4.4 结果转换:原始值到物理量
ADC读出来的是原始数字值,上层要的是电压、温度、电流。这个转换公式其实很简单:
物理量 = (原始值 / 2^分辨率) * 参考电压
但不同MCU的参考电压来源不同。有的内部固定,有的外部输入,有的还分高参考和低参考。抽象层应该把参考电压也抽象出来。
typedef struct {
uint32_t vref_mv; /* 参考电压,单位mV */
uint8_t resolution; /* 实际分辨率位数 */
uint8_t alignment; /* 左对齐还是右对齐 */
} adc_conversion_param_t;
/* 原始值转电压值 */
float adc_raw_to_voltage(uint16_t raw_value, adc_conversion_param_t *param)
{
return (float)raw_value * param->vref_mv / (1 << param->resolution);
}
避坑指南:我曾经遇到过某款MCU的ADC结果是左对齐的,直接拿来算电压值全错了。所以对齐方式一定要在参数里体现。
4.5 统一接口设计:一个头文件搞定
说了这么多,最终要落到接口上。我建议只暴露一个头文件给上层:
/* hal_adc.h - 上层唯一需要包含的头文件 */
/* 初始化 */
int32_t hal_adc_init(void);
/* 通道管理 */
int32_t hal_adc_channel_config(uint8_t logical_ch);
int32_t hal_adc_channel_enable(uint8_t logical_ch);
int32_t hal_adc_channel_disable(uint8_t logical_ch);
/* 分辨率配置 */
int32_t hal_adc_set_resolution(adc_resolution_t res);
/* 结果读取 */
int32_t hal_adc_read(uint8_t logical_ch, uint16_t *value);
int32_t hal_adc_read_voltage(uint8_t logical_ch, float *voltage_mv);
/* 高级功能 */
int32_t hal_adc_set_callback(uint8_t logical_ch, adc_callback_t cb);
int32_t hal_adc_start_dma(uint8_t logical_ch, uint16_t *buf, uint32_t len);
你看,上层应用根本不知道底层用的是哪款MCU。换芯片时,只需要重新实现这个头文件对应的.c文件就行。我做过一个项目,从STM32换到NXP,上层代码一行没改,只换了底层驱动和映射表。
4.6 实战经验总结
最后分享几个我踩过的坑:
- 采样时间别设太短:输入阻抗大的信号源需要更长的采样时间。我遇到过采样值跳变严重,最后发现是采样时间设短了。
- 多通道切换要加延时:有些MCU切换通道后需要稳定时间,否则前几次采样值不准。
- DMA缓冲区对齐:有些MCU要求DMA缓冲区地址对齐到4字节或8字节,否则会触发硬件错误。
- 参考电压滤波:如果参考电压来自外部,一定要加滤波电容。我见过参考电压纹波大导致ADC结果波动的情况。
我的习惯:每个ADC通道都做一次软件滤波,比如中值滤波或滑动平均。虽然增加了一点计算量,但能省去很多排查噪声的时间。
ADC驱动抽象,说白了就是让上层代码不知道底层用的是哪款MCU。你想想看,如果每个项目都要重写ADC代码,那得多累?做好抽象层,一次设计,到处复用。嗯,这才是架构师该干的事。