第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采样和温度检测的那些细节。到时候我会分享一个我调试了三天才搞定的采样抖动问题——嗯,那故事可有意思了。