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抽象层设计,核心就四点:
- 通道管理:用枚举+映射表,隔离硬件变化
- 触发方式:根据信号特性选,别一刀切
- 结果读取:能DMA就别中断,能中断就别轮询
- 校准接口:硬件校准+软件校准,双保险
嗯,这一章就聊到这儿。下一章咱们聊聊PWM抽象层,那个更刺激——特别是做喷油脉宽控制时,一个微秒的误差都可能让发动机抖起来。