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的驱动分三步:

  1. 通过分压电路读取ADC值
  2. 计算NTC当前电阻值
  3. 查表或计算得到温度

查表法最常用。先把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软件的灵魂。