BMS/BCU FreeRTOS 任务设计指南
从电池控制单元(BCU)的视角,讨论任务划分、优先级分配、栈大小设置的方法论。
基于 STM32F407 + FreeRTOS V11.1.0 实战经验。
一、怎么划分任务 ------ 从功能堆叠到数据流切分
1.1 最常见的错误:按函数名分任务
❌ 错误示范:10 个函数 = 10 个任务
Task1: BootComCheckActivationRequest()
Task2: ADS1115_AdhesionVol()
Task3: ADS1115_BusVol()
Task4: CalcCurrent()
Task5: CIR_FUN()
... 每一个函数一个任务
后果:
- 10 个 TCB = ~1KB RAM 浪费
- 10 个栈 = ~60KB RAM 浪费(还没算栈本身的大小)
- 任务间通信复杂度爆炸(10×10=100 条消息路径)
- 调度器 10 个任务切来切去,CPU 时间全花在上下文切换上
1.2 正确的切分维度:数据流 + 功能安全级别
BCU 的数据从左到右流动,每个阶段对实时性和安全性的要求不同:
传感器/执行器 数据处理 外部系统
─────────────────────────────────────────────────────
→ →
ADC 电压 │ SOC 安时积分 │ HMI 显示屏
ADS1115 粘连 │ SOH 健康度 │ EMS 能量管理
DI 状态 │ SOP 功率预测 │ 温湿度模块
DO 控制 │ SOE 能量估算 │
CIR 绝缘 │ BDS 电池统计 │
│ │
↑ 物理层 │ ↑ 算法层 │ ↑ 通信层
I/O 密集 │ CPU 密集 │ 可能阻塞
周期 100ms │ 周期 100ms │ 周期可变
栈小 │ 栈大(矩阵运算) │ 栈中
按这三个阶段切成 5 类任务:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ SENSORS │───→│ ALGORITHM │───→│ PROTOCOLS │
│ 数据采集 │ │ 算法计算 │ │ 外部通信 │
│ 优先级 4 │ │ 优先级 5 │ │ 优先级 3 │
└──────┬──────┘ └──────┬──────┘ └─────────────┘
│ │
│ ┌───────┴───────┐
│ │ SAFETY │ ← 独立的安全路径
│ │ 保护 & 接触器 │
│ │ 优先级 6 │
│ └───────────────┘
│
┌──────┴──────┐
│ HOUSEKEEPING│ ← 后台维护
│ 存储 & RTC │
│ 优先级 1 │
└─────────────┘
1.3 每类任务的职责边界
SENSORS(数据采集)
职责:把物理量变成 cluster 里的数字。只负责"读",不负责"判断"。
包含:
✅ ADC 原始值读取 + 偏移/增益校准 + 温度补偿
✅ DI 读取 + 软件去抖(3 次确认)
✅ 非安全 DO 输出(风扇、状态指示灯)
✅ ADS1115 I2C 读、CIR 绝缘检测
✅ 单字段原子写入 cluster
不包含:
❌ 任何保护逻辑(那是 SAFETY 的事)
❌ 算法计算(那是 ALGORITHM 的事)
❌ 协议解析(那是 PROTOCOLS 的事)
SAFETY(保护 & 接触器控制)
职责:消费 SENSORS 的数据,做保护判断,直接操作接触器 GPIO。
安全原则:决策和执行必须在同一个任务里,中间不能有 IPC。
✅ SSM 系统状态机(判断该不该合接触器)
✅ ATC 告警控制(告警 LED)
✅ FDR 故障诊断 & 记录(抓冻结帧)
✅ 接触器 GPIO 直接操控(posRelay/negRelay/preRelay)
✅ 过压/过流/过温硬件阈值比对
优先级必须是所有应用任务中最高的。
这个任务阻塞 = 接触器该断的时候不断 = 安全事故。
ALGORITHM(算法计算)
职责:纯 CPU 计算。读 cluster 输入,算完写回 cluster。
✅ SOC 安时积分 + 卡尔曼滤波
✅ SOH 健康状态估算
✅ SOP 功率预测
✅ SOE 能量估算
✅ BDS 电池数据统计
这个任务的特征:
- CPU 密集型(SOC 卡尔曼滤波可能跑 10-40ms)
- 栈最大(float[64] 矩阵临时数组在栈上)
- 不能阻塞 I/O(纯计算,不碰硬件)
- 优先级仅次于 SAFETY(保证 dt 精确)
PROTOCOLS(外部通信)
职责:和外部系统的 CAN/UART 通信。可以阻塞------不影响安全。
✅ HMI 显示屏通信(UART)
✅ EMS 能量管理系统(CAN)
✅ 温湿度模块协议(UART)
为什么可以阻塞:
- 即使 CAN 消息超时 500ms,SAFETY 还是独立在跑
- 协议栈的阻塞隔离在 PROTOCOLS 内,不扩散到其他任务
HOUSEKEEPING(后台维护)
职责:不紧急的后台工作。优先级最低。
✅ RTC 授时同步
✅ DSM 数据存储(写 EEPROM/Flash)
✅ BootCom 激活检测
优先级最低意味着:只要有任何其他任务就绪,HOUSEKEEPING 就会被抢占。
Flash 写 500ms?没关系------SAFETY 随时可以抢走 CPU。
1.4 判断"这个函数该放哪个任务"的问自己口诀
问 1: 它会阻塞吗?(等 I2C、等 CAN、等 Flash 写)
是 → 谁被它阻塞了?谁都不受影响就随便放,影响 SAFETY 就隔离出去
问 2: 它是安全的吗?(接触器控制、紧急停机)
是 → 放 SAFETY,优先级最高,不能等任何东西
问 3: 它算得重吗?(卡尔曼、矩阵、FFT)
是 → 放 ALGORITHM,给大栈,优先级高但低于 SAFETY
问 4: 别人在不在乎它跑没跑完?
不在乎 → 放 HOUSEKEEPING,优先级最低
二、优先级怎么设 ------ 不是"重要的高",而是"等不起的高"
2.1 优先级本质:谁不能等,谁优先级就高
错误认知: "这个功能重要 → 优先级高"
正确认知: "这个任务被延迟的后果严重 → 优先级高"
例子:
接触器断开延迟 50ms → 500A 短路电流多流了 50ms → 🔴 优先级必须最高
SOC 更新延迟 200ms → 显示的电量慢了 0.2 秒 → 🟢 优先级可以低一些
RTC 授时延迟 5 秒 → 时间慢了 5 秒 → 🟢 优先级最低,无所谓
2.2 BCU 任务的推荐优先级表
优先级(数值) 任务 被延迟 N ms 的后果
─────────────────────────────────────────────────────────
7 (保留给极端紧急)
6 SAFETY 接触器延迟=短路风险 最高
5 ALGORITHM SOC dt 误差 < 0.01% 高
4 SENSORS 采样延迟=数据年龄偏大 中
3 PROTOCOLS 通信延迟=显示慢半拍 中低
2 (FreeRTOS Timer)
1 HOUSEKEEPING 存储延迟=少存几秒数据 低
0 IDLE 喂狗+WFI 最低
2.3 优先级和周期的关系
优先级和周期是两个维度的概念,不能混为一谈。
vTaskDelayUntil(&last, 100ms) ← 这是周期,决定"多久跑一次"
xTaskCreate(..., priority, ...) ← 这是优先级,决定"谁先跑"
高优先级 + 长周期: SAFETY 优先级 6,周期 100ms
→ 到点立刻运行,抢占所有低优先级任务
跑完立刻让出 CPU,等下一个 100ms
低优先级 + 短周期: 不常见,因为频繁打断高优先级任务本身就是问题
2.4 同优先级的时间片
BMS 一般不推荐同优先级任务------抢占式调度的优势就在于"紧急的事情立即响应"。如果两个任务优先级相同且都就绪,FreeRTOS 会按时间片(1 tick = 1ms)轮流切换,这不符合 BMS 对确定性时延的要求。
结论:BMS 的每个应用任务给一个独特优先级。不要把优先级当"重要性标签"------多一个优先级多不了几字节 RAM。
三、任务栈大小怎么设 ------ 先估后测,测完再调
3.1 单位陷阱
c
xTaskCreate(Task, "name", 1024, ...)
↑
单位是 words,不是 bytes!
Cortex-M4: 1 word = 4 bytes
1024 words = 4096 bytes = 4KB
3.2 栈消耗的构成
栈顶(高地址)
│
├─ 硬件自动压栈 (exception entry)
│ R0, R1, R2, R3, R12, LR, PC, xPSR = 8 words (32B)
│
├─ FreeRTOS 手动压栈 (PendSV context switch)
│ R4, R5, R6, R7, R8, R9, R10, R11 = 8 words (32B)
│ EXC_RETURN = 1 word (4B)
│
├─ FPU 惰性压栈 (如果任务用过浮点)
│ S0 - S31 (32 个单精度浮点寄存器) = 32 words (128B)
│ FPSCR = 1 word (4B)
│
├─ 中断嵌套 (最坏情况)
│ 每层 ISR 嵌套 = 8 words (整数) + 33 words (FPU)
│ BMS 典型 2 层: ~50 words
│
├─ 任务自己的局部变量
│ 函数调用深度 × 每层局部变量数组
│ SOC_FUN → CalcSOC → KalmanFilter → MatrixInverse
│ 4 层 × 每层可能开 float[32]
│
├─ 编译器生成的栈帧 (prologue/epilogue/spilling)
│
└─ 栈底(低地址)
3.3 初始估算
任务 特征 初始栈
────────────────────────────────────────────────────────────────
SENSORS I2C 读 ADS1115 (3层调用深) 1024 words (4KB)
局部变量: uint16_t raw[2] 等小数组
无浮点密集运算
SAFETY SSM 状态机 (可能有大的 switch-case) 2048 words (8KB)
FDR 冻结帧结构体 (可能 20+ 字段)
不做 CPU 密集运算
ALGORITHM SOC 卡尔曼滤波 (4-5 层调用深) 8192 words (32KB)
局部变量: float P[16][16] 协方差矩阵
float K[16] 卡尔曼增益
float x[16] 状态向量
→ 16×16×4 = 1KB 一个矩阵就吃这么多
PROTOCOLS UART/CAN 收发 buffer 2048 words (8KB)
协议解析状态机
DMA 缓冲区 (可能在全局区,不在栈上)
HOUSEKEEPING BootCom + RTC + DSM 1024 words (4KB)
EEPROM 写 buffer (可能 256B)
浅调用深度
3.4 运行时测量
用 uxTaskGetStackHighWaterMark() 获取历史极值------这个值从任务创建以来只降不升:
c
void vSensorsTask(void *pvParameters)
{
UBaseType_t uxFree; // 声明变量
for (;;)
{
// ... 所有采集函数 ...
uxFree = uxTaskGetStackHighWaterMark(NULL);
// uxFree 是还剩下多少 words 没被用过
// 这个值从创建以来只降不升 → 记录的是最坏情况
}
}
必须实测的最坏工况(否则测到的是假的):
| 任务 | 最坏工况 | 怎么触发 |
|---|---|---|
| SENSORS | 所有 ADS1115 通道轮流读 + CIR 检测 | 正常运行即可 |
| SAFETY | SSM + FDR 冻结帧 (full state machine) | 注入故障信号 |
| ALGORITHM | SOC 全量卡尔曼滤波 + BDS 全量统计 | SOC 初始值偏差大,滤波器收敛期 |
| PROTOCOLS | EMS 发来完整的一帧最长数据 | CAN 工具模拟 |
| HOUSEKEEPING | DSM 写 EEPROM 最长一帧数据 | 发送存储命令 |
3.5 根据测量值调栈
uxFree (words) 判断
────────────────────────────────────────────
< 50 立即翻倍,紧急------离栈溢出只差一口气
50 - 100 危险 ------ 翻倍或 ×1.5
100 - 300 刚好 ------ 保持当前值
300 - 500 舒适 ------ 可以适当缩减
> 500 浪费 ------ 减半释放堆空间
3.6 FPU 项目额外注意
无 FPU 的任务:
栈帧 = 8 (硬件) + 9 (手动) = 17 words (68B)
有 FPU 且使用过的任务:
栈帧 = 8 + 9 + 33 (FPU) = 50 words (200B)
编译器标志 -mfloat-abi=hard 意味着:
即使你的代码里完全没有 float,编译器也可能用 FPU 寄存器
来做 memcpy 优化 ------ 所以栈大小要按"可能会用 FPU"来估
四、一张图总结
数据流 →
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ SENSORS │ │ SAFETY │ │ALGORITHM │ │PROTOCOLS │
│ │ │ │ │ │ │ │
│ 采集物理量│ │ 保护逻辑 │ │ SOC/SOH │ │ CAN/UART │
│ 写入cluster│ │ 控制接触器│ │ 纯计算 │ │ 可能阻塞 │
│ │ │ │ │ │ │ │
│ Prio 4 │ │ Prio 6 │ │ Prio 5 │ │ Prio 3 │
│ 堆 4KB │ │ 堆 8KB │ │ 堆 32KB │ │ 堆 8KB │
│ 100ms │ │ 100ms │ │ 100ms │ │ 100ms │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
│
┌──────────┐ │
│HOUSEKEEP │ ← 最低优先级,后台杂务 │
│ RTC+存储 │ │
│ Prio 1 │ │
│ 堆 4KB │ │
│ 500ms │ │
└──────────┘ │
↑ │
└─── IDLE (Prio 0) ─── 喂 IWDG + WFI ──────┘
核心原则三条:
- 按数据流 + 安全级别切任务,不按函数数量。功能类似、安全级别相同 → 合并。安全关键 → 隔离。
- 优先级 = 被延迟的后果严重度。接触器延迟 → 最高。存储延迟 → 最低。
- 栈大小 = 先估算 → 实测最坏工况 → 留 20% 余量 。用
uxTaskGetStackHighWaterMark看历史极值,不是看当前值。