第3章 BMS软件架构:应用层、中间层、驱动层划分,状态机与任务调度机制

好,咱们直接切入正题。BMS的软件架构,说白了就是一套分工明确的组织架构。你想想看,一个电池包里有几百个电芯,又要采样、又要均衡、还要跟整车通信,如果所有代码都揉在一起,那调试起来绝对是一场噩梦。

我个人习惯把BMS软件分成三层:驱动层、中间层、应用层。这三层各司其职,互不干扰。我最早做项目时,就吃过不分层的亏——改一个ADC采样通道,结果把SOC算法也带崩了。从那以后,我对分层这件事就特别较真。

3.1 三层架构的职责划分

先看一张我常用的分层表,你对照着理解会更容易:

层级 核心职责 典型模块 依赖关系
应用层 业务逻辑、策略决策 SOC估算、SOH诊断、均衡控制、故障处理 调用中间层接口
中间层 数据抽象、协议封装、任务管理 电池参数管理、CAN协议栈、状态机引擎 调用驱动层接口
驱动层 硬件直接操作、寄存器读写 ADC驱动、GPIO驱动、SPI/I2C驱动 直接操作硬件

驱动层是离硬件最近的。它负责跟芯片寄存器打交道,比如配置ADC采样速率、读取温度传感器的原始值。我见过不少新手喜欢在驱动层里写业务逻辑,比如在ADC中断里直接算SOC。嗯,千万别这么干。驱动层只做一件事:把硬件数据“搬”上来,不做任何加工。

中间层是个“翻译官”。它把驱动层拿到的原始电压值,换算成实际的毫伏数;把CAN总线上的报文,解析成结构体。中间层还负责管理任务调度——说白了就是决定“什么时候该干什么事”。

应用层是大脑。它不关心电压是怎么采到的,也不关心CAN报文是怎么发出去的。它只关心:当前SOC是多少?要不要开启均衡?有没有故障需要报警?这一层写起来最爽,因为全是逻辑判断,没有硬件干扰。

核心原则:上层可以调用下层,下层绝不能反向调用上层。驱动层永远不知道应用层在算什么SOC,应用层也永远不需要知道ADC的采样时钟是多少。

3.2 状态机:BMS的灵魂

BMS本质上是一个状态机。从休眠到唤醒,从待机到充电,再到放电和保护,每个状态都有严格的进入条件和退出条件。

我习惯用枚举来定义状态:

typedef enum {
    BMS_STATE_SLEEP,      // 休眠
    BMS_STATE_WAKEUP,     // 唤醒自检
    BMS_STATE_IDLE,       // 待机
    BMS_STATE_CHARGING,   // 充电
    BMS_STATE_DISCHARGING,// 放电
    BMS_STATE_FAULT,      // 故障保护
    BMS_STATE_BALANCING   // 均衡
} BmsState_t;

状态机的实现,我推荐用查表法。为什么?因为if-else嵌套多了,你自己都看不懂。我曾经接手过一个项目,状态切换用了7层if嵌套,改一个条件要翻半天代码。后来我全部改成了状态转移表,清晰多了。

状态转移表的核心结构:

typedef struct {
    BmsState_t currentState;
    BmsEvent_t event;
    BmsState_t nextState;
    void (*action)(void);  // 状态进入时执行的函数
} StateTransition_t;

举个例子,当系统处于IDLE状态,收到“充电插入”事件时,应该做什么?

StateTransition_t g_stateTable[] = {
    {BMS_STATE_IDLE, EVENT_CHARGER_PLUGIN, BMS_STATE_CHARGING, Charging_Enter},
    {BMS_STATE_CHARGING, EVENT_CHARGER_REMOVE, BMS_STATE_IDLE, Idle_Enter},
    {BMS_STATE_CHARGING, EVENT_OVERVOLTAGE, BMS_STATE_FAULT, Fault_Overvoltage},
    // ... 更多转移
};

每次状态机运行时,只需要遍历这张表,匹配当前状态和事件,然后执行对应的动作函数。这样做的好处是:新增一个状态或事件,只需要在表里加一行,不用改任何逻辑代码

我的小技巧:状态机的执行频率不要太高。我一般放在10ms或20ms的周期任务里跑。太快了浪费CPU,太慢了响应不及时。另外,每个状态进入时,记得加一个“进入动作”和“退出动作”的回调,这样能避免状态切换时的资源泄漏。

3.3 任务调度:让一切有序进行

BMS里有很多任务:电压采样、温度采样、SOC计算、均衡控制、通信收发……如果所有任务都在一个循环里跑,那高优先级的任务会被低优先级的拖死。

我常用的调度机制是时间片轮转 + 优先级抢占。说白了,就是把任务分成几个等级:

  • 紧急任务(1ms级):过压/欠压保护、过流保护。这些必须在1ms内响应,否则电芯可能出问题。
  • 周期任务(10ms级):电压采样、温度采样、状态机运行。
  • 后台任务(100ms级):SOC估算、SOH诊断、均衡策略。
  • 空闲任务(1s级):日志记录、数据存储、看门狗喂狗。

代码实现上,我习惯用一个简单的调度器:

void TaskScheduler_Run(void) {
    static uint32_t tick10ms = 0;
    static uint32_t tick100ms = 0;
    static uint32_t tick1s = 0;

    // 1ms定时器中断中递增 tickCount
    uint32_t now = GetTickCount();

    // 紧急任务:每次循环都检查
    Emergency_Protection_Check();

    // 10ms任务
    if (now - tick10ms >= 10) {
        tick10ms = now;
        Voltage_Sample();
        Temperature_Sample();
        StateMachine_Run();
    }

    // 100ms任务
    if (now - tick100ms >= 100) {
        tick100ms = now;
        SOC_Calculate();
        Balancing_Strategy();
    }

    // 1s任务
    if (now - tick1s >= 1000) {
        tick1s = now;
        Log_Record();
        Watchdog_Feed();
    }
}

你看,这个调度器没有用RTOS,但已经能满足大部分BMS的需求了。为什么?因为BMS的任务大多是周期性的,而且对实时性要求没那么变态(除了保护任务)。

注意:千万不要在中断服务函数里跑长时间的任务。我见过有人把SOC计算放在ADC中断里,结果中断执行时间过长,导致系统时钟跑偏。中断里只做“标记”,把真正的处理放到主循环里。

3.4 避坑指南:我踩过的几个坑

坑一:状态机死锁。 我曾经遇到过一个问题:系统进入FAULT状态后,因为某个条件一直不满足,导致永远退不出故障状态。后来我加了一个“超时强制退出”的机制——在FAULT状态里,如果超过10秒没有新事件,就自动恢复到IDLE状态。

坑二:任务优先级反转。 低优先级的任务占着某个资源不放,高优先级的任务只能干等。解决办法是:关键资源用互斥锁,并且锁的持有时间要尽可能短

坑三:采样时序冲突。 电压采样和温度采样如果共用一个ADC通道,一定要错开时间。我习惯在10ms任务里先采电压,再采温度,中间留出足够的转换时间。

3.5 小结

这一章的内容,说白了就是三件事:分层、状态机、调度。分层让代码好维护,状态机让逻辑清晰,调度让任务有序。你把这三点吃透了,BMS软件的大框架就立住了。

下一章我们会深入驱动层,聊聊ADC采样和温度检测的那些细节。到时候我会分享一个我调试了三天才搞定的采样抖动问题——嗯,那故事可有意思了。