4. ADC抽象层设计:ADC通道管理、采样触发方式、结果读取、校准接口

ADC这个模块,说实话,在ECU开发里是个"看着简单,用起来坑多"的东西。我最早做发动机控制时,就被ADC的采样抖动坑过一次——油门踏板信号跳来跳去,差点让客户以为我们写的代码有bug。后来才发现,是采样触发时机没处理好。

所以这一章,咱们就聊聊ADC抽象层该怎么设计。说白了,就是让上层应用不用关心底层硬件细节,只管"我要读哪个通道"就行。

4.1 通道管理:别让应用层碰寄存器

我个人习惯,先把所有ADC通道抽象成一个枚举。你想想看,如果应用层直接写ADC1->CR2 |= 0x01,那换芯片时得改多少地方?

/* adc_channel.h */
typedef enum {
    ADC_CH_ACCEL_PEDAL = 0,    /* 油门踏板位置 */
    ADC_CH_BRAKE_PRESSURE,     /* 制动压力 */
    ADC_CH_COOLANT_TEMP,       /* 冷却液温度 */
    ADC_CH_BATTERY_VOLTAGE,    /* 电池电压 */
    ADC_CH_OIL_PRESSURE,       /* 机油压力 */
    ADC_CH_INTAKE_AIR_TEMP,   /* 进气温度 */
    ADC_CH_MAX
} AdcChannel_t;

嗯,这里要注意:枚举的顺序不代表物理通道号。我见过有人直接把枚举值当硬件通道号用,结果换了个芯片引脚重映射,全乱了。

正确的做法是加一个映射表:

/* adc_cfg.c */
static const AdcChannelConfig_t s_adcChannelMap[ADC_CH_MAX] = {
    [ADC_CH_ACCEL_PEDAL]    = { .hwUnit = ADC_UNIT_1, .hwChannel = 3, .sampleTime = 15 },
    [ADC_CH_BRAKE_PRESSURE] = { .hwUnit = ADC_UNIT_1, .hwChannel = 5, .sampleTime = 15 },
    [ADC_CH_COOLANT_TEMP]   = { .hwUnit = ADC_UNIT_2, .hwChannel = 1, .sampleTime = 71 },
    /* ... */
};

这样改硬件时,只需要改这个表,应用层代码一行都不用动。

我的经验: 采样时间别全用默认值。比如冷却液温度变化慢,采样时间长一点没关系;但油门踏板信号,采样时间太长会导致响应延迟。我一般把快速信号(踏板、压力)设成15个周期,慢速信号(温度)设成71个周期。

4.2 采样触发方式:选对了省一半的CPU

ADC采样触发,说白了就是"什么时候开始采"。常见的有三种:

  • 软件触发:代码里写一句ADC_StartConversion(),简单粗暴
  • 定时器触发:比如PWM的边沿触发,适合固定频率采样
  • 硬件事件触发:比如某个引脚电平变化了才采

我个人最常用的是定时器触发。为什么?因为ECU里很多信号都有固定的采样频率要求。比如发动机转速信号,每10ms采一次就够了;爆震信号,那得每1ms采一次。

/* adc_trigger.c */
void Adc_InitTrigger(AdcChannel_t channel, AdcTriggerMode_t mode) {
    switch (mode) {
        case ADC_TRIGGER_SOFTWARE:
            /* 软件触发,调用 Adc_StartConversion() 即可 */
            break;
        case ADC_TRIGGER_TIMER:
            /* 配置定时器,比如 TIM3 的 CCR1 事件 */
            TIM3->DIER |= TIM_DIER_CC1DE;
            ADC1->CR2 |= (2 << 20);  /* 选择 TIM3 CCR1 作为触发源 */
            break;
        case ADC_TRIGGER_EXT_EVENT:
            /* 外部引脚触发,比如刹车信号 */
            break;
    }
}
我曾经踩过的坑: 有一次我把油门踏板和冷却液温度都用同一个定时器触发。结果油门信号采得没问题,但温度信号因为采样频率太高,DMA缓冲区老是溢出。后来才意识到——不同信号应该用不同的触发源,或者至少用不同的采样组。

4.3 结果读取:轮询、中断还是DMA?

这个问题,说白了就是"怎么把ADC结果拿回来"。三种方式各有适用场景:

方式 适用场景 CPU占用 实时性
轮询 慢速信号(温度、电压) 高(但简单)
中断 中等速度信号(踏板、压力) 中等
DMA 高速连续采样(爆震、电流) 极低 极高

我个人习惯:能用DMA的尽量用DMA。你想想看,如果每个ADC通道都进中断,那CPU大部分时间都在处理中断上下文切换,真正干活的时间就少了。

/* adc_read.c */
uint16_t Adc_ReadBlocking(AdcChannel_t channel) {
    /* 轮询方式,适合调试或慢速信号 */
    Adc_StartConversion(channel);
    while (!Adc_IsConversionDone(channel));
    return Adc_GetResult(channel);
}

void Adc_ReadDma(AdcChannel_t channel, uint16_t *buffer, uint32_t length) {
    /* DMA方式,适合连续采样 */
    /* 配置DMA,把ADC数据寄存器直接搬运到buffer */
    DMA1->CPAR = (uint32_t)&(ADC1->DR);
    DMA1->CMAR = (uint32_t)buffer;
    DMA1->CNDTR = length;
    DMA1->CCR |= DMA_CCR_EN;
    ADC1->CR2 |= ADC_CR2_DMA;
}
一个小技巧: 用DMA时,记得把buffer设成环形缓冲区。这样你永远能拿到最近N次采样的数据,做滤波或者过采样都很方便。

4.4 校准接口:别让精度毁在细节上

ADC校准,说白了就是"让读出来的数字和实际电压对上"。ECU里常用的校准方式有两种:

  • 硬件校准:芯片自带的校准功能,比如STM32的ADC自校准
  • 软件校准:用两个已知电压点做线性拟合

我建议两种都用。硬件校准保证基本精度,软件校准修正系统误差。

/* adc_calib.c */
typedef struct {
    int32_t offset;   /* 零点偏移 */
    int32_t gain;     /* 增益系数,Q15格式 */
} AdcCalibParams_t;

static AdcCalibParams_t s_calibParams[ADC_CH_MAX];

void Adc_Calibrate(AdcChannel_t channel, uint16_t rawLow, uint16_t rawHigh,
                   int32_t physLow, int32_t physHigh) {
    /* 两点校准,计算offset和gain */
    int32_t deltaRaw = (int32_t)rawHigh - (int32_t)rawLow;
    int32_t deltaPhys = physHigh - physLow;
    
    s_calibParams[channel].gain = (deltaPhys << 15) / deltaRaw;
    s_calibParams[channel].offset = physLow - 
        ((s_calibParams[channel].gain * (int32_t)rawLow) >> 15);
}

int32_t Adc_GetPhysicalValue(AdcChannel_t channel) {
    uint16_t raw = Adc_GetResult(channel);
    int32_t result = (s_calibParams[channel].gain * (int32_t)raw) >> 15;
    result += s_calibParams[channel].offset;
    return result;
}
我曾经踩过的坑: 有一次做电池电压监测,发现读出来的值总是偏大0.2V。查了半天,发现是ADC的参考电压Vref+没校准。芯片内部的Vref+其实有±1%的误差,如果不做硬件校准,这个误差会直接反映在结果里。所以我现在每次初始化ADC,第一件事就是调用硬件自校准。

4.5 总结一下

ADC抽象层设计,核心就四点:

  1. 通道管理:用枚举+映射表,隔离硬件变化
  2. 触发方式:根据信号特性选,别一刀切
  3. 结果读取:能DMA就别中断,能中断就别轮询
  4. 校准接口:硬件校准+软件校准,双保险

嗯,这一章就聊到这儿。下一章咱们聊聊PWM抽象层,那个更刺激——特别是做喷油脉宽控制时,一个微秒的误差都可能让发动机抖起来。