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个样本做滤波。这样既保证了实时性,又不会丢数据。

注意:DMA传输的数据宽度必须和ADC分辨率匹配。比如12位ADC,数据寄存器是16位的,DMA就要配成16位传输。我曾经配成8位,结果高4位数据全丢了,查了两天才发现。

4.4 实战:一个完整的ADC采集链路

把上面三部分串起来,就是一个完整的模拟量采集方案。我以某个实际项目为例:

  1. 配置ADC序列:油门踏板(通道0)、刹车压力(通道1)、水温(通道2),采样时间分别设为1μs、2μs、5μs。
  2. 配置DMA:源地址为ADC数据寄存器,目的地址为全局数组adc_buffer[3],循环模式,每次传输6字节(3个通道×2字节)。
  3. 启动采集:调用Adc_StartConversion(),ADC开始自动扫描,DMA自动搬运。
  4. 处理数据: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);
        }
    }
}
小技巧:如果ADC采样率很高(比如超过100kHz),建议在DMA回调里只置标志位,不要做复杂计算。滤波和标定放到主循环或低优先级任务里,避免阻塞中断。

4.5 避坑指南

最后分享几个我踩过的坑:

  • 采样时间不足:高阻抗信号源需要更长的采样时间,否则电压还没稳定就被ADC采走了。我遇到过水温传感器因为采样时间不够,读数跳变几十度。
  • DMA地址对齐:有些MCU要求DMA目的地址必须按4字节对齐。如果缓冲区定义成uint16_t数组,可能不对齐。我后来统一用uint32_t数组,或者加__attribute__((aligned(4)))
  • 序列中断丢失:如果ADC序列转换完成中断被高优先级中断抢占太久,可能丢失数据。解决方案是用DMA,或者把ADC中断优先级设高。
  • 参考电压噪声:ADC的参考电压如果纹波大,采集值会抖动。我曾在项目里加了一个RC滤波,效果立竿见影。

嗯,ADC驱动抽象就聊到这儿。说白了,核心就是让上层应用不关心底层细节,同时保证数据采集的实时性和可靠性。下一章咱们聊聊PWM输出和定时器抽象,那个也有不少门道。