4、ADC驱动抽象:模拟量采集的HAL封装、采样序列管理、DMA传输抽象
ADC驱动,说白了就是把物理世界里的电压值,变成芯片能理解的数字。这件事听起来简单,但做起来坑不少。我最早做ECU时,以为ADC就是读个寄存器完事,结果被噪声、采样时序、DMA丢数据折磨得够呛。今天咱们就聊聊,怎么把ADC驱动封装得既通用又可靠。
4.1 为什么需要HAL封装?
不同MCU的ADC外设,寄存器差异很大。比如NXP的S32K系列和Infineon的TC3xx,配置方式完全不同。但上层应用不关心这些,它只想要一个函数:uint16_t GetAdcValue(uint8_t channel)。
HAL封装的目的,就是屏蔽底层差异。我习惯把ADC驱动拆成三层:
- 硬件抽象层(HAL):定义统一的接口,比如初始化、启动转换、读取结果。
- MCU适配层:针对具体芯片实现寄存器操作。
- 应用层:直接调用HAL接口,不碰任何寄存器。
举个例子,一个典型的HAL接口长这样:
/* adc_hal.h */
typedef struct {
uint8_t channel;
uint8_t resolution; /* 8/10/12/16 bit */
uint32_t sampling_time_ns;
} Adc_ChannelConfig_t;
typedef struct {
uint8_t num_channels;
Adc_ChannelConfig_t *channels;
void (*callback)(uint16_t *buffer, uint8_t len);
} Adc_SequenceConfig_t;
/* 统一接口 */
void Adc_Init(const Adc_SequenceConfig_t *config);
void Adc_StartConversion(void);
uint16_t Adc_ReadChannel(uint8_t channel);
void Adc_RegisterCallback(void (*cb)(uint16_t *, uint8_t));
你看,应用层根本不知道底层是逐次逼近型还是Σ-Δ型。这就是封装的价值。
4.2 采样序列管理:别让CPU傻等
ECU里经常要采集多个通道,比如油门踏板、刹车压力、水温。如果每个通道都单独启动、等待、读取,CPU就啥也别干了。所以我们需要采样序列管理。
我个人习惯用自动扫描模式。配置好一个通道列表,ADC自己按顺序扫过去,扫完一轮触发中断或DMA。这样CPU只需要在最后收数据。
序列配置的关键参数:
| 参数 | 说明 | 我的建议 |
|---|---|---|
| 通道顺序 | 决定采样优先级 | 关键信号(如刹车)放前面 |
| 采样时间 | 每个通道的采样保持时间 | 高阻抗信号要加长,我吃过亏 |
| 触发方式 | 软件触发或硬件PWM触发 | 发动机同步用硬件触发更准 |
| 转换完成事件 | 中断或DMA请求 | 批量数据用DMA,单点用中断 |
这里有个坑:序列长度不能超过硬件FIFO深度。我曾经在某个芯片上配置了16个通道的序列,结果硬件只支持8个,最后几个通道的数据全是乱的。嗯,从那以后我每次都会先查手册里的最大序列长度。
4.3 DMA传输抽象:让数据自己跑
ADC+DMA是绝配。ADC转换完数据,DMA自动搬到内存里,CPU全程不用管。但DMA的配置比ADC还复杂,所以也需要抽象。
我设计的DMA抽象层包含三个要素:
- 传输描述符:定义源地址、目的地址、数据宽度、传输长度。
- 传输模式:单次传输、循环传输、链表传输。
- 回调机制:传输完成、半完成、错误时通知上层。
看一个实际例子:
/* dma_hal.h */
typedef struct {
uint32_t src_addr; /* ADC数据寄存器地址 */
uint32_t dst_addr; /* RAM缓冲区地址 */
uint16_t transfer_size; /* 传输字节数 */
uint8_t data_width; /* 8/16/32 bit */
uint8_t mode; /* DMA_MODE_SINGLE / DMA_MODE_CIRCULAR */
} Dma_TransferConfig_t;
void Dma_Init(Dma_TransferConfig_t *config);
void Dma_Start(void);
void Dma_Stop(void);
void Dma_RegisterCallback(uint32_t event, void (*cb)(void));
对于ADC采集,我通常用循环模式。DMA不停地把ADC数据搬到环形缓冲区,应用层随时取最新的N个样本做滤波。这样既保证了实时性,又不会丢数据。
4.4 实战:一个完整的ADC采集链路
把上面三部分串起来,就是一个完整的模拟量采集方案。我以某个实际项目为例:
- 配置ADC序列:油门踏板(通道0)、刹车压力(通道1)、水温(通道2),采样时间分别设为1μs、2μs、5μs。
- 配置DMA:源地址为ADC数据寄存器,目的地址为全局数组
adc_buffer[3],循环模式,每次传输6字节(3个通道×2字节)。 - 启动采集:调用
Adc_StartConversion(),ADC开始自动扫描,DMA自动搬运。 - 处理数据:DMA传输完成中断里,设置一个标志位。主循环检测到标志位后,读取
adc_buffer做滤波和标定。
代码实现大致如下:
/* 全局变量 */
static uint16_t adc_buffer[3];
static volatile uint8_t adc_ready = 0;
/* ADC序列配置 */
Adc_ChannelConfig_t channels[] = {
{.channel = 0, .resolution = 12, .sampling_time_ns = 1000},
{.channel = 1, .resolution = 12, .sampling_time_ns = 2000},
{.channel = 2, .resolution = 12, .sampling_time_ns = 5000}
};
Adc_SequenceConfig_t seq_cfg = {
.num_channels = 3,
.channels = channels,
.callback = NULL /* 用DMA,不用回调 */
};
/* DMA配置 */
Dma_TransferConfig_t dma_cfg = {
.src_addr = (uint32_t)&ADC->DR, /* 假设数据寄存器地址 */
.dst_addr = (uint32_t)adc_buffer,
.transfer_size = 6, /* 3通道 × 2字节 */
.data_width = 16,
.mode = DMA_MODE_CIRCULAR
};
void Adc_InitTask(void)
{
Adc_Init(&seq_cfg);
Dma_Init(&dma_cfg);
Dma_RegisterCallback(DMA_EVENT_COMPLETE, Adc_DmaCallback);
Adc_StartConversion();
Dma_Start();
}
void Adc_DmaCallback(void)
{
adc_ready = 1; /* 通知主循环 */
}
void MainLoop(void)
{
while(1) {
if(adc_ready) {
adc_ready = 0;
/* 读取并处理 adc_buffer[0..2] */
ProcessAdcData(adc_buffer);
}
}
}
4.5 避坑指南
最后分享几个我踩过的坑:
- 采样时间不足:高阻抗信号源需要更长的采样时间,否则电压还没稳定就被ADC采走了。我遇到过水温传感器因为采样时间不够,读数跳变几十度。
- DMA地址对齐:有些MCU要求DMA目的地址必须按4字节对齐。如果缓冲区定义成
uint16_t数组,可能不对齐。我后来统一用uint32_t数组,或者加__attribute__((aligned(4)))。 - 序列中断丢失:如果ADC序列转换完成中断被高优先级中断抢占太久,可能丢失数据。解决方案是用DMA,或者把ADC中断优先级设高。
- 参考电压噪声:ADC的参考电压如果纹波大,采集值会抖动。我曾在项目里加了一个RC滤波,效果立竿见影。
嗯,ADC驱动抽象就聊到这儿。说白了,核心就是让上层应用不关心底层细节,同时保证数据采集的实时性和可靠性。下一章咱们聊聊PWM输出和定时器抽象,那个也有不少门道。