4、BMS驱动层设计:ADC驱动与电压采集、温度传感器驱动(NTC/DS18B20)、电流传感器驱动(霍尔/分流器)、隔离通信(SPI/I2C)驱动
驱动层,说白了就是硬件和软件之间的翻译官。你写再好的算法,驱动层搞砸了,数据全是错的,那上层状态机再牛也没用。我这些年踩过的坑,有一半都出在驱动层——不是时序没调对,就是隔离芯片没配好。
今天咱们就把BMS里最核心的几个驱动模块拆开聊。电压采集、温度采集、电流采集,再加上隔离通信,这四个东西构成了BMS的数据底座。
4.1 ADC驱动与电压采集
电压采集是BMS的命根子。SOC估算、SOH评估、均衡控制,全指着电压数据。我见过不少项目,ADC精度选得挺高,结果采集出来的电压跳得像心电图——问题出在驱动上。
4.1.1 ADC选型与配置
BMS里常用的ADC分两类:MCU内置ADC和外置独立ADC。内置ADC便宜,但精度和稳定性差点意思。外置ADC贵,但可靠。我个人习惯,12串以下的BMS用MCU内置ADC凑合能用,超过12串必须上外置。
配置ADC时,有几个参数你得盯死:
- 分辨率:至少12位,16位更好。每1位对应多少电压?算一下就知道。
- 采样率:BMS不需要太快,10Hz~100Hz足够。太快反而引入噪声。
- 参考电压:内部参考还是外部参考?我建议用外部精密参考,温度漂移小。
- 采样时间:这个很多人忽略。采样时间太短,电容没充满,数据偏小。
重点:ADC的参考电压一定要稳。我曾经在一个项目里用了MCU内部参考,结果温度一变化,电压采集值跟着飘,最后查了三天才发现是参考电压的问题。
4.1.2 电压采集驱动实现
电压采集驱动,核心就两件事:启动转换和读取结果。但实际写起来,坑不少。
先看一个简单的ADC驱动代码(基于STM32 HAL库):
// ADC初始化
void ADC_Init(void)
{
ADC_ChannelConfTypeDef sConfig = {0};
hadc1.Instance = ADC1;
hadc1.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV4;
hadc1.Init.Resolution = ADC_RESOLUTION_12B;
hadc1.Init.ScanConvMode = DISABLE;
hadc1.Init.ContinuousConvMode = DISABLE;
hadc1.Init.DiscontinuousConvMode = DISABLE;
hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START;
hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT;
hadc1.Init.NbrOfConversion = 1;
HAL_ADC_Init(&hadc1);
sConfig.Channel = ADC_CHANNEL_0;
sConfig.Rank = 1;
sConfig.SamplingTime = ADC_SAMPLETIME_480CYCLES; // 采样时间要够
HAL_ADC_ConfigChannel(&hadc1, &sConfig);
}
// 读取单次电压
uint16_t ADC_ReadVoltage(void)
{
uint16_t adc_value = 0;
HAL_ADC_Start(&hadc1);
if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK)
{
adc_value = HAL_ADC_GetValue(&hadc1);
}
HAL_ADC_Stop(&hadc1);
return adc_value;
}
经验之谈:采样时间我习惯设到480个时钟周期以上。别心疼那点时间,稳定比速度重要。你想想看,电池电压变化多慢?1ms采一次和10ms采一次,对BMS来说没区别。
4.1.3 电压采集的滤波处理
原始ADC数据直接拿来用?别闹。必须滤波。我常用的方法有两种:
- 中值滤波:连续采5次,去掉最大最小,取平均。对付尖峰噪声特别好。
- 一阶低通滤波:当前值 = 上次值 × α + 本次值 × (1-α)。α取0.8~0.95,平滑效果好。
代码实现:
// 一阶低通滤波
#define FILTER_ALPHA 0.9f
static uint16_t last_voltage = 0;
uint16_t VoltageFilter(uint16_t raw_value)
{
uint16_t filtered;
if (last_voltage == 0)
{
filtered = raw_value; // 第一次直接赋值
}
else
{
filtered = (uint16_t)(last_voltage * FILTER_ALPHA +
raw_value * (1.0f - FILTER_ALPHA));
}
last_voltage = filtered;
return filtered;
}
注意:滤波系数别设得太极端。α=0.99虽然平滑,但响应太慢。电池突然过压了,你滤波半天才反应过来,保护都来不及。我一般α取0.85~0.9,兼顾平滑和响应。
4.2 温度传感器驱动
BMS里温度采集比电压采集更麻烦。为什么?因为温度传感器种类太多,每种都有自己的脾气。
4.2.1 NTC热敏电阻驱动
NTC是最常见的温度传感器,便宜、简单。但它的阻值和温度不是线性关系,得查表或者用公式算。
NTC的驱动分三步:
- 通过分压电路读取ADC值
- 计算NTC当前电阻值
- 查表或计算得到温度
查表法最常用。先把NTC的阻值-温度对应表存到Flash里,然后二分查找。我习惯用Steinhart-Hart公式直接算,省空间:
// Steinhart-Hart公式计算温度
// R: NTC当前电阻值
// 返回: 温度值(摄氏度)
float NTC_CalcTemperature(uint32_t R)
{
float T;
float lnR = log(R);
// 这三个系数从NTC datasheet里查
float A = 1.129148e-3;
float B = 2.34125e-4;
float C = 8.76741e-8;
T = 1.0f / (A + B * lnR + C * lnR * lnR * lnR);
T = T - 273.15f; // 开尔文转摄氏度
return T;
}
避坑指南:NTC的自热效应你注意过吗?流过NTC的电流太大,它自己会发热,测出来的温度偏高。我曾经在项目里把分压电阻选得太小,结果NTC自热了0.5℃,电池没超温,NTC先报警了。分压电阻选10kΩ以上比较安全。
4.2.2 DS18B20数字温度传感器驱动
DS18B20是单总线通信,一根线搞定数据和电源。但单总线的时序要求很严格,差几个微秒就读不到数据。
DS18B20驱动的核心是时序控制:
- 复位脉冲:主机拉低480μs,然后释放
- 存在脉冲:DS18B20拉低60~240μs响应
- 写时序:写0拉低60μs,写1拉低1~15μs
- 读时序:主机拉低1μs后释放,15μs内采样
代码示例(关键部分):
// DS18B20复位
uint8_t DS18B20_Reset(void)
{
uint8_t presence = 0;
DS18B20_OUT_LOW(); // 拉低总线
Delay_Us(480); // 保持480μs
DS18B20_OUT_HIGH(); // 释放总线
Delay_Us(70); // 等待70μs
presence = DS18B20_IN_READ(); // 读取存在脉冲
Delay_Us(410); // 等待剩余时间
return presence; // 0表示存在
}
// 读取1位数据
uint8_t DS18B20_ReadBit(void)
{
uint8_t bit = 0;
DS18B20_OUT_LOW();
Delay_Us(1); // 拉低1μs
DS18B20_OUT_HIGH();
Delay_Us(5); // 等待5μs
bit = DS18B20_IN_READ(); // 采样
Delay_Us(55); // 等待剩余时间
return bit;
}
警告:DS18B20的时序对延时精度要求很高。别用HAL_Delay这种毫秒级函数,必须用微秒级延时。我建议用定时器或者DWT(数据观察点跟踪)来实现精确延时。
4.3 电流传感器驱动
电流采集比电压采集难得多。为什么?因为电流变化快,而且噪声大。BMS里常用的电流传感器有两种:霍尔传感器和分流器。
4.3.1 霍尔电流传感器驱动
霍尔传感器是非接触式的,不串入主回路,安全性好。但它的输出是模拟电压,需要ADC采集。而且霍尔传感器有零点漂移,温度变化时零点会跑。
驱动霍尔传感器,我一般这么做:
- 零点校准:上电时没有电流,记录此时的ADC值作为零点
- 灵敏度标定:根据datasheet的灵敏度参数,计算每mV对应多少A
- 温度补偿:如果精度要求高,加一个温度传感器做补偿
// 霍尔电流传感器驱动
#define HALL_SENSITIVITY 0.04f // 40mV/A
#define HALL_ZERO_OFFSET 2048 // 2.5V对应12位ADC的中间值
static int16_t hall_zero = 0;
// 零点校准
void Hall_CalibrateZero(void)
{
hall_zero = ADC_ReadValue(HALL_CHANNEL) - HALL_ZERO_OFFSET;
}
// 读取电流值
float Hall_ReadCurrent(void)
{
int16_t adc_val;
float current;
adc_val = ADC_ReadValue(HALL_CHANNEL) - HALL_ZERO_OFFSET - hall_zero;
current = (float)adc_val * 3.3f / 4096.0f; // 转为电压
current = current / HALL_SENSITIVITY; // 转为电流
return current;
}
经验之谈:霍尔传感器的零点会随着时间漂移。我建议每隔一段时间(比如1小时)重新校准一次零点。怎么判断有没有电流?看电机有没有转,或者看电流值是否长时间稳定在某个小范围内。
4.3.2 分流器电流检测驱动
分流器是串联在主回路里的精密电阻,通过测量两端压降来计算电流。精度高、响应快,但会发热,而且有功率损耗。
分流器驱动的关键点:
- 差分采样:必须用差分ADC或者仪表放大器,不能单端采样
- 共模电压:分流器两端的共模电压可能很高,注意ADC的输入范围
- 热管理:大电流时分流器发热,阻值会变
// 分流器电流检测
#define SHUNT_RESISTOR 0.001f // 1mΩ
#define AMP_GAIN 50.0f // 仪表放大器增益
float Shunt_ReadCurrent(void)
{
int16_t adc_diff;
float voltage_diff;
float current;
// 读取差分ADC值
adc_diff = ADC_ReadDifferential(SHUNT_CHANNEL_P, SHUNT_CHANNEL_N);
// 转为差分电压
voltage_diff = (float)adc_diff * 3.3f / 4096.0f;
// 除以增益得到分流器两端电压
voltage_diff = voltage_diff / AMP_GAIN;
// 欧姆定律算电流
current = voltage_diff / SHUNT_RESISTOR;
return current;
}
重点:分流器的PCB布局非常关键。采样线必须从分流器的焊盘上直接引出,不能经过大电流路径。我见过一个项目,采样线走了一段大电流铜皮,结果测出来的电流偏大20%。
4.4 隔离通信驱动
BMS里高压部分和低压部分必须隔离。隔离通信常用的就是SPI和I2C,加上隔离芯片(比如ISO7240、ADuM1401)。
4.4.1 隔离SPI驱动
SPI隔离后,信号会有延迟。隔离芯片的传播延迟一般在10~50ns,频率高了会出问题。我建议SPI时钟不要超过10MHz,5MHz最稳妥。
隔离SPI驱动的注意事项:
- 时钟极性:隔离芯片可能翻转信号,注意CPOL和CPHA的配置
- 片选信号:隔离后的片选信号要加去毛刺处理
- 数据对齐:发送和接收的时序要对齐,别搞错位
// 隔离SPI读写
uint8_t SPI_Isolated_ReadWrite(uint8_t data)
{
uint8_t rx_data;
// 片选拉低
CS_LOW();
Delay_Us(1); // 等待隔离芯片稳定
// SPI收发
HAL_SPI_TransmitReceive(&hspi1, &data, &rx_data, 1, 100);
// 片选拉高
Delay_Us(1);
CS_HIGH();
return rx_data;
}
注意:隔离芯片的电源去耦要做好。隔离两侧的电源是独立的,每侧都要加去耦电容。我遇到过隔离通信偶尔出错,查了半天发现是隔离芯片的VCC侧没加电容,电源纹波太大导致数据错位。
4.4.2 隔离I2C驱动
I2C隔离比SPI麻烦。因为I2C是开漏输出,需要上拉电阻。隔离后,上拉电阻的位置要选对——放在隔离芯片的哪一侧?
我的做法:
- 上拉电阻:放在隔离芯片的高压侧和低压侧各一组
- 速率:隔离I2C建议用100kHz标准模式,400kHz容易出问题
- 时钟拉伸:从设备时钟拉伸时,隔离芯片可能把拉伸信号吃掉
// 隔离I2C写操作
HAL_StatusTypeDef I2C_Isolated_Write(uint16_t dev_addr, uint8_t *data, uint16_t len)
{
HAL_StatusTypeDef status;
// 加一个重试机制,隔离通信偶尔会失败
for (int retry = 0; retry < 3; retry++)
{
status = HAL_I2C_Master_Transmit(&hi2c1, dev_addr, data, len, 100);
if (status == HAL_OK)
{
break;
}
Delay_Ms(1); // 重试前等1ms
}
return status;
}
避坑指南:隔离I2C最容易出的问题是总线卡死。SDA被拉低后释放不了。我建议在驱动里加一个超时复位机制——如果总线空闲超过50ms,就强制复位I2C外设。这个功能救过我很多次。
4.5 驱动层设计总结
驱动层是BMS软件的地基。地基不稳,上层建筑再漂亮也没用。我总结了几条经验:
| 驱动模块 | 关键点 | 常见坑 |
|---|---|---|
| ADC电压采集 | 参考电压、采样时间、滤波 | 参考电压漂移、采样时间不足 |
| NTC温度采集 | 查表/公式、自热效应 | 分压电阻选太小、查表精度不够 |
| DS18B20 | 精确时序、单总线协议 | 延时不准、多设备冲突 |
| 霍尔电流 | 零点校准、温度补偿 | 零点漂移、灵敏度误差 |
| 分流器电流 | 差分采样、PCB布局 | 共模电压过高、采样线走错 |
| 隔离SPI/I2C | 信号延迟、电源去耦 | 时序错位、总线卡死 |
驱动层写好了,后面的状态机、均衡策略、SOC估算才能跑得稳。别急着往上堆功能,先把驱动层调稳了再说。嗯,今天就聊到这儿,下一章咱们讲状态机的设计——那才是BMS软件的灵魂。