FreeRTOS 移植到 STM32F407VETX 记录(四)

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 ──────┘

核心原则三条

  1. 按数据流 + 安全级别切任务,不按函数数量。功能类似、安全级别相同 → 合并。安全关键 → 隔离。
  2. 优先级 = 被延迟的后果严重度。接触器延迟 → 最高。存储延迟 → 最低。
  3. 栈大小 = 先估算 → 实测最坏工况 → 留 20% 余量 。用 uxTaskGetStackHighWaterMark 看历史极值,不是看当前值。
相关推荐
结城明日奈是我老婆2 小时前
基于stm32f103c8t6最小系统板俩块版通讯
stm32·单片机·嵌入式硬件
fengfuyao9853 小时前
STM32F030 SD卡文件系统读取实例
stm32·单片机·嵌入式硬件
kebidaixu3 小时前
FreeRTOS 移植到 STM32F407VETX 记录(三)
stm32·单片机·嵌入式硬件
普中科技13 小时前
【普中STM32F1xx开发攻略--标准库版】-- 第 45 章 FSMC-外扩 SRAM 实验
stm32·单片机·嵌入式硬件·fsmc·普中科技·外扩sram·is62wv51216
嵌入式ZYXC17 小时前
第3篇:《面试题:I2C为什么要加上拉电阻?阻值怎么选?》
stm32·单片机·嵌入式硬件·面试·职场和发展
你疯了抱抱我17 小时前
【STM32】使用 STM32CubeMX 生成项目,LED测试;上位机:STM32F411CEU6
stm32·单片机·嵌入式硬件
嵌入式小站20 小时前
STM32 零基础可移植教程 24:SPI Flash 读数据,先从指定地址读几个字节
chrome·stm32·嵌入式硬件
guygg8821 小时前
基于C# + Halcon的通用ROI绘制工具
stm32·单片机·c#