乐于分享
好东西不私藏

FoxBMS2 源码分析 (2) | 操作系统与任务调度

FoxBMS2 源码分析 (2) | 操作系统与任务调度

1. 前言

上一篇我们停在了 OS_StartScheduler(),从那一刻开始,FoxBMS2 就不再是一个顺序执行的初始化程序,而是一套由多个周期任务、一个最高优先级引擎任务,以及若干监控机制共同驱动的实时系统。
刚开始看源码时我们可能会停留在 1ms、10ms、100ms 三类任务这种层面,但对于 FoxBMS2 来说,真正重要的是下面三个问题:
  1. OS 适配层到底封装了哪些 FreeRTOS 能力。
  2. 各个任务不只是周期不同,而是承担了怎样的职责分层。
  3. 如果调度失序、任务超时或系统卡死,FoxBMS 靠什么发现并处理。
这一篇就顺着 os.cos_freertos.cftask.cftask_cfg.csys_mon.csbc.c 把这条调度骨架讲清楚。

任务层级与模块挂载图

下面这张图只画当前源码里能直接确认的任务、优先级层级和主要挂载点,先把谁在什么位置跑这件事搞明白:

2. OS 适配层

2.1 分阶段建立系统资源

src/app/task/os/os.c 里的 OS_InitializeOperatingSystem() 把启动阶段拆得很明确:
    /* Initialize the scheduler */    os_boot = OS_INITIALIZE_SCHEDULER;    OS_InitializeScheduler();    /* operating system configuration (Queues, Tasks) */    os_boot = OS_CREATE_QUEUES;    FTSK_CreateQueues();    os_boot = OS_CREATE_TASKS;    FTSK_CreateTasks();    os_boot = OS_INIT_PRE_OS;
这里的 os_boot 不是普通状态变量,它是整个启动阶段的同步信号。后面的任务创建函数会反复轮询它,确保高优先级任务、1ms 任务和其余周期任务按既定顺序启动。
换句话说,FoxBMS2 不是任务都创建出来,谁先调度到算谁先跑,而是连任务的启动顺序也用状态机化的方式管理起来了

2.2 os_freertos.c

src/app/task/os/freertos/os_freertos.c 至少做了四类事情:
①. 静态任务内存配置
vApplicationGetIdleTaskMemory() 和 vApplicationGetTimerTaskMemory() 为 Idle Task 和 Timer Task 提供静态 TCB 与栈空间。这和车规项目常见的避免动态内存不确定性思路一致。
②. 统一的临界区与节拍接口
例如:
  • OS_EnterTaskCritical() / OS_ExitTaskCritical()
  • OS_GetTickCount()
  • OS_DelayTaskUntil()
上层代码完全不需要知道 FreeRTOS 的类型细节。
③. 任务通知与队列接口抽象
OS_WaitForNotification()OS_NotifyFromIsr()OS_ReceiveFromQueue()OS_SendToBackOfQueue() 等都集中在这里,后面数据库引擎和 CAN 队列都会依赖这些接口。
④. 故障钩子统一化
最典型的是:
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) {    /* After the FreeRTOS stack overflow detection has been triggered, try to       instantly send a CAN message, that tells the higher level control unit,       that a stack overflow appeared in one of the FreeRTOS tasks. */    CANTX_CrashDump(CANTX_FATAL_ERRORS_ACTIONS_STACK_OVERFLOW);    FAS_ASSERT(FAS_TRAP);}
这说明在 FoxBMS2 的设计里,FreeRTOS 的异常不只是调试信息,而是可以升级为系统级致命故障并对外广播的事件。

2.3 软件时间基准并不只是一串 tick

OS_IncrementTimer() 在 1ms 任务里被调用,用层层进位的方式维护:1ms、10ms、100ms、1s、1min、1h、1d。同时还调用了 RTC_IncrementSystemTime()。这意味着 FoxBMS 的时间至少有两层用途:
  1. 给调度和超时判断提供毫秒级系统节拍。
  2. 给上层诊断、记录、时间戳相关逻辑提供更长时间尺度的基准。

3. 任务创建顺序:谁先跑,不是偶然

调度器启动后任务进入路径

把 OS_StartScheduler() 之后的主启动链压成一张图,当前源码里能直接确认的顺序是这样的:

3.1 Engine 任务是系统最高优先级中枢

FTSK_CreateTaskEngine() 在 ftask.c 里的顺序是:
    OS_MarkTaskAsRequiringFpuContext();    os_boot = OS_SCHEDULER_RUNNING;    FTSK_InitializeUserCodeEngine();    os_boot = OS_ENGINE_RUNNING;    /* AXIVION Next Codeline Style MisraC2012-2.2 FaultDetection-DeadBranches: FreeRTOS task setup requires an infinite     * loop for the user code (see www.freertos.org/a00125.html)*/    while (true) {        /* notify system monitoring that task will be called */        SYSM_Notify(SYSM_TASK_ID_ENGINESYSM_NOTIFY_ENTEROS_GetTickCount());        /* user code implementation */        FTSK_RunUserCodeEngine();        /* notify system monitoring that task has been called */        SYSM_Notify(SYSM_TASK_ID_ENGINESYSM_NOTIFY_EXITOS_GetTickCount());    }
Engine 任务并不按固定周期休眠,它的职责是:
  • 初始化数据库 DATA_Initialize()
  • 初始化系统监控 SYSM_Initialize()
  • 在运行期不断执行 DATA_Task() 和 SYSM_CheckNotifications()
这决定了一个非常重要的架构:数据库处理和任务超时监控都被放在了全系统最高优先级任务里

3.2 1ms 任务并不只是快,它还负责拉起其余系统

FTSK_CreateTaskCyclic1ms() 启动前会先等 OS_ENGINE_RUNNING,然后调用:
    FTSK_InitializeUserCodePreCyclicTasks();    os_boot = OS_PRE_CYCLIC_INITIALIZATION_HAS_FINISHED;
而 FTSK_InitializeUserCodePreCyclicTasks() 实际做的事情很多:
  • SYS_SetStateRequest(SYS_STATE_INITIALIZATION_REQUEST)
  • PEX_Initialize()
  • 使能温湿度传感器供电
  • CONT_Initialize()
  • SPS_Initialize()
  • MEAS_Initialize()
  • MRC_Initialize()
  • 以太网 PHY 初始化
  • 设置正常运行时的 LED 闪烁
也就是说,1ms 任务不仅负责高频周期执行,它还承担了在系统进入调度前做一轮前置初始化的职责。

3.3 10ms、100ms、算法 100ms 任务

ftask_cfg.c 里最值得看的不是任务定义结构体,而是 FTSK_RunUserCode…() 里到底挂了哪些模块。

1ms 任务

OS_IncrementTimer();DIAG_UpdateFlags();MEAS_Control();CAN_ReadRxBuffer();
这四句非常说明问题:
  • 维护系统时间基准
  • 刷新诊断标志
  • 驱动测量状态机
  • 把 CAN 接收队列里的报文尽快搬到上层处理链路里

10ms 任务

10ms 任务是真正的业务主轴:
SYSM_UpdateFramData();SYS_Trigger(&sys_state);ILCK_Trigger();ADC_Control();SPS_Ctrl();CAN_MainFunction();SOF_Calculation();ALGO_MonitorExecutionTime();SBC_Trigger(&sbc_stateMcuSupervisor);
并且每 50ms 才额外做一次:
MRC_ValidateAfeMeasurement();MRC_ValidatePackMeasurement();
最后才调用:
BMS_Trigger();
源码里对这句有明确注释:把 BMS_Trigger() 放在 10ms 任务末尾,是为了让前面已经更新过数据的模块先完成采样和判定,尽量缩短数据更新到状态机响应之间的延迟。
这就是 FoxBMS 的调度风格:不是只给模块分周期,还在同一个周期内部安排执行先后

100ms 任务

100ms 任务主要挂慢速模块:
  • BAL_Trigger()
  • IMD_Trigger()
  • LED_Trigger()
  • MINFO_CheckSupplyVoltageClamp30c()
同时每 1 秒才运行一次:
SE_RunStateEstimations()
这说明默认配置下的 SOX 估算并不是每 100ms 都更新,而是按 1s 节奏执行。源码注释也写得很清楚:如果不用带积分能力的电流传感器,单纯人工积分就可能需要更高频率。

Algorithm 100ms 任务

它只做一件事:ALGO_MainFunction()
这反而说明 FoxBMS 团队是有意把常规 100ms 周期任务和算法任务隔开的,避免所有慢速逻辑混在一个大周期里难以分析执行时间。

3.4 I2C 任务和 AFE 任务是独立执行单元

除了我们经常提的 1/10/100ms 任务,源码里还有:
  • I2C 任务:PEX_Trigger()、HTSEN_Trigger()、RTC_Trigger(),然后 OS_DelayTaskUntil(&current_time, 2u)。
  • AFE 任务:在 FOXBMS_AFE_DRIVER_TYPE_NO_FSM == 1 时才主动执行 MEAS_Control()。
这两个任务的存在说明:FoxBMS 并不是把所有外设都硬塞进统一周期任务里,而是根据驱动模型决定是否给出独立执行上下文

4. 相位差与启动门闩:避免任务一窝蜂上线

FTSK_CreateTaskCyclic10ms()FTSK_CreateTaskCyclic100ms() 和 FTSK_CreateTaskCyclicAlgorithm100ms() 都会先做两件事:
1. 等待 OS_PRE_CYCLIC_INITIALIZATION_HAS_FINISHED
2. 用 OS_DelayTaskUntil(&os_schedulerStartTime, phase) 做首轮相位偏移
这背后的意义很实在:
  • 门闩作用:1ms 任务没完成前置初始化之前,其余周期任务不能抢跑。
  • 错峰作用:不同周期任务不会在调度器启动瞬间全部在同一 tick 抢占 CPU。
尤其是 FTSK_CreateTaskCyclicAlgorithm100ms() 里还会把 os_boot 设置为 OS_SYSTEM_RUNNING,说明系统对何时算真正跑起来也有明确定义。

5. 软件监控:sys_mon.c 不是日志模块,而是运行时裁判

5.1 所有关键任务都要显式报到

每个关键任务执行前后都会调用:
SYSM_Notify(taskId, SYSM_NOTIFY_ENTER, OS_GetTickCount());...SYSM_Notify(taskId, SYSM_NOTIFY_EXIT, OS_GetTickCount());
sys_mon.c 会记录:
  • timestampEnter
  • timestampExit
  • duration
而且这些更新是在临界区里完成的,避免并发读写把时间戳本身搞坏。

5.2 它检查的不是某次慢了,而是是否持续违背节拍要求

SYSM_CheckNotifications() 的判据是:
time_since_last_call > cycleTime + maxJitter&&duration > cycleTime
满足时才会:
  • DIAG_Handler(DIAG_ID_SYSTEM_MONITORING, …)
  • 记录违例到本地 FRAM 
  • 调用对应任务的回调函数
这里有两个容易被忽略的工程点:
1. 违例会被持久化
SYSM_RecordTimingViolation() 会把具体哪个任务、持续了多久、何时进入任务记录到 FRAM 结构里,不是简单打一条日志。
2. FRAM 写入是延迟提交的
SYSM_UpdateFramData() 先检查 sysm_flagFramCopyHasChanges,需要时才真正写 FRAM。这避免了在 10ms 周期里每次都做存储器写操作。

6. 硬件看门狗:SBC 才是最后的物理托底

软件监控只能在软件还活着的时候起作用。FoxBMS 2 的最后一道防线在 sbc.c

6.1 SBC 初始化不是一次 SPI 配置就完事

SBC_Trigger() 的初始化阶段有清晰的子流程:
  • FS85_InitializeFsPhase()
  • 激活周期性喂狗
  • 查询需要多少次有效 watchdog refresh 才能清掉 fault error counter
  • 等待这些 refresh 完成
  • FS85_CheckFaultErrorCounter()
  • FS85_SafetyPathChecks()
  • 最后进入 SBC_STATEMACHINE_RUNNING
这说明 SBC 并不是外设驱动那么简单,而是直接参与了系统安全链的建立。

SBC 状态推进简表

下表把 sbc.c 里当前实现能直接确认的主状态和初始化子状态整理到一起,方便回源码时快速定位:
状态 进入条件 核心动作 退出条件
SBC_STATEMACHINE_UNINITIALIZED 上电后的初始状态 等待 SBC_STATE_INIT_REQUEST 收到初始化请求后转入 SBC_STATEMACHINE_INITIALIZATION
SBC_STATEMACHINE_INITIALIZATION / SBC_ENTRY 从未初始化状态收到初始化请求 调 FS85_InitializeFsPhase();成功后开启周期喂狗并转下一个子状态;失败则最多重试 3 次 成功转入 SBC_INIT_RESET_FAULT_ERROR_COUNTER_PART1;超过重试上限转 ERROR
SBC_STATEMACHINE_INITIALIZATION / SBC_INIT_RESET_FAULT_ERROR_COUNTER_PART1 FS85 初始化成功后 调 FS85_InitializeNumberOfRequiredWatchdogRefreshes(),据此设置等待时间 成功转入 PART2;超过重试上限转 ERROR
SBC_STATEMACHINE_INITIALIZATION / SBC_INIT_RESET_FAULT_ERROR_COUNTER_PART2 已等待足够 watchdog refresh 次数 调 FS85_CheckFaultErrorCounter() 检查 fault error counter 是否清零 成功转入 SBC_INITIALIZE_SAFETY_PATH_CHECK;超过重试上限转 ERROR
SBC_STATEMACHINE_INITIALIZATION / SBC_INITIALIZE_SAFETY_PATH_CHECK fault error counter 已通过检查 调 FS85_SafetyPathChecks() 成功转入 SBC_STATEMACHINE_RUNNING;超过重试上限转 ERROR
SBC_STATEMACHINE_RUNNING 初始化链完成 设置长定时;若配置启用 ignition power-down 检查,则调用 SBC_IsIgnitionSignalDetected() 保持运行态,当前代码片段未显示额外自动跳转
SBC_STATEMACHINE_ERROR 初始化阶段超过重试上限或检查失败 记录最后状态并进入长定时循环 保持错误态,当前代码片段未显示自动恢复路径

6.2 周期喂狗逻辑也被纳入 10ms 主循环

一旦 watchdogState == SBC_PERIODIC_WATCHDOG_ACTIVATEDSBC_TriggerWatchdogIfRequired() 就会在每次 10ms 调用时递减计数器,减到 0 时执行:
FS85_TriggerWatchdog(...)
成功后再把计数器重装成 watchdogPeriod_10ms
这意味着喂狗不是一个普通后台线程,而是明确绑定在主系统周期中的安全动作。从当前实现看,只要软件调度仍能推进,sys_mon 会先对周期任务违例做检测;而 10ms 主循环里的周期喂狗逻辑,则把 SBC 硬件看门狗保留成更靠后的托底层。

软件监控与硬件看门狗关系图

7. 这一套调度设计的真正价值

FoxBMS2 的任务系统最值得学习的地方,不是用了 FreeRTOS,而是它把下面几件事同时做到了:
  1. 启动顺序可控:用 os_boot 明确分隔 scheduler、engine、1ms 前置初始化和完整系统运行阶段。
  2. 周期职责明确:1ms 负责时间基准和快路径输入,10ms 负责系统主控制,100ms 负责慢速控制与估算。
  3. 运行期可追责:sys_mon 记录节拍违例,SBC 负责物理层托底。
  4. 时序可解释:不仅知道什么模块在哪个周期里,还知道为什么要在本周期的这个位置执行。

关键配置项

配置类别 当前源码中的代表项 作用
任务优先级 FTSK_TASK_ENGINE_PRIORITYFTSK_TASK_CYCLIC_1MS_PRIORITYFTSK_TASK_CYCLIC_10MS_PRIORITYFTSK_TASK_CYCLIC_100MS_PRIORITYFTSK_TASK_CYCLIC_ALGORITHM_100MS_PRIORITYFTSK_TASK_AFE_PRIORITY 决定 Engine、各周期任务和 AFE 任务的抢占关系。
任务首轮 phase FTSK_TASK_CYCLIC_1MS_PHASEFTSK_TASK_CYCLIC_10MS_PHASEFTSK_TASK_CYCLIC_100MS_PHASEFTSK_TASK_CYCLIC_ALGORITHM_100MS_PHASE 决定调度器启动后各周期任务第一次进入执行时的错峰偏移。
周期节拍 FTSK_TASK_CYCLIC_1MS_CYCLE_TIMEFTSK_TASK_CYCLIC_10MS_CYCLE_TIMEFTSK_TASK_CYCLIC_100MS_CYCLE_TIMEFTSK_TASK_CYCLIC_ALGORITHM_100MS_CYCLE_TIME 决定各任务的名义执行周期,也是 sys_mon 配置的基础输入。
最大允许抖动 FTSK_TASK_ENGINE_MAXIMUM_JITTERFTSK_TASK_CYCLIC_1MS_MAXIMUM_JITTERFTSK_TASK_CYCLIC_10MS_MAXIMUM_JITTERFTSK_TASK_CYCLIC_100MS_MAXIMUM_JITTERFTSK_TASK_CYCLIC_ALGORITHM_100MS_MAXIMUM_JITTER 决定 SYSM_CheckNotifications() 里多大调度偏差会被视为违例。
连续任务配置 FTSK_TASK_I2C_PRIORITY / FTSK_TASK_I2C_CYCLE_TIMEFTSK_TASK_AFE_PRIORITY / FTSK_TASK_AFE_CYCLE_TIME 说明 I2C 和 AFE 任务不是固定周期任务,而是连续运行任务。
SBC 周期喂狗节拍 SBC_WINDOW_WATCHDOG_PERIOD_MSwatchdogPeriod_10ms 决定硬件看门狗的窗口周期以及 10ms 主循环里重装计数的频率。

小结

这一篇真正回答了调度器启动之后发生了什么:
  • OS 适配层负责把 FreeRTOS 变成一套受控的、可移植的系统接口。
  • Engine 任务负责数据库与系统监控,站在任务体系最顶层。
  • 1ms、10ms、100ms、算法 100ms、I2C、AFE 任务共同构成了 FoxBMS 的时间骨架。
  • sys_mon 和 SBC 分别构成软件和硬件两层看门狗。
下一篇我们就沿着这条骨架往里走,去看最核心的那层中枢:为什么 FoxBMS2 几乎所有模块都不直接互相调用,而是把数据交给数据库引擎来中转。

推荐阅读顺序

  • 先看 OS_InitializeOperatingSystem(),确认启动阶段创建了哪些队列和任务。
  • 再看 FTSK_CreateTaskEngine() 和 FTSK_CreateTaskCyclic1ms(),理解任务启动顺序与 os_boot 门闩。
  • 接着读 FTSK_RunUserCodeCyclic1ms()、FTSK_RunUserCodeCyclic10ms()、FTSK_RunUserCodeCyclic100ms(),把不同周期各自挂载的模块建立起来。
  • 然后回到 OS_IncrementTimer(),理解全局时间基准是如何被维护的。
  • 最后读 SYSM_CheckNotifications() 和 SBC_Trigger(),看软件监控与硬件看门狗如何形成闭环。

思考题
sys_mon.c 记录的 duration 是用 timestampExit – timestampEnter 算出来的。如果任务执行中途被更高优先级任务抢占,这段被抢占时间会不会被算进 duration?如果会,这种设计对任务执行时长和系统调度压力的解释意味着什么?

参考答案

会被算进去。因为 duration 的定义不是这段代码真正占了多少个 CPU 指令周期,而是从任务宣告进入,到任务宣告退出之间,经过了多少系统时间。只要 timestampEnter 和 timestampExit 都是在任务上下文里读取系统 tick,那么中间无论这段任务是在真正执行,还是被更高优先级任务抢占挂起,这段墙上时间都会被包含进 duration
这意味着 sys_mon 观测到的并不是纯粹的函数本体执行耗时,而是该任务完成一次调度周期工作所经历的总时延。这个定义在实时系统里其实更有价值,因为系统真正关心的不是某段代码理论上只需跑 2ms,而是它在实际调度压力下是否还能在自己的时间窗内完成。如果一个 10ms 任务本体只需要 2ms,但因为频繁被更高优先级任务抢占,实际从进入到退出花了 11ms,那么对系统来说它就是超时了,后果并不会因为代码本身其实不慢而变得更安全。
这也给超时配置带来一个重要启发:监控阈值不能只按单函数裸跑耗时设,而要按在最坏合理抢占条件下的周期完成时间设。换句话说,sys_mon 更像在度量系统级调度压力下的任务可完成性,而不是在做微观性能剖析。
因此,FoxBMS 这里的设计是有意把执行耗时和调度干扰一起纳入监控模型。它回答的问题不是代码快不快,而是这个任务在真实系统里还能不能按节拍活下去。