乐于分享
好东西不私藏

FoxBMS2 源码分析 (8) | 通信与工具链:CAN 报文如何流动,工程工具如何约束团队行为

FoxBMS2 源码分析 (8) | 通信与工具链:CAN 报文如何流动,工程工具如何约束团队行为
项目地址:https://www.github.com/foxBMS/foxbms-2

1. 前言

前面七篇我们基本都在系统内部打转:调度、数据库、状态机、驱动、算法、诊断。到了这一篇,才真正看到 FoxBMS 如何把内部状态和请求与外部世界连接起来。
这部分看起来像外围功能,但在真实项目中,很多系统行为最终都落在两个问题上:
  1. 你的状态和故障到底如何对外发布。
  2. 你的工程团队如何保证通信定义和工具链长期可维护。
因此,这一篇分两部分收尾:
  • can.c 的发送、接收、调度和错误处理链。
  • tools/dbc 与 fox.py 所体现出来的工程化约束。

1.1 先把通信链和工具链分开看

2.CAN_Initialize()

2.1 初始化时除了硬件,还建立了调度和约束

CAN_Initialize() 的主线包含:
  1. canInit() 初始化外设
  2. 重新配置扩展 ID 邮箱
  3. 初始化 CAN transceiver
  4. 计算周期消息计数器的 reset 值
  5. 检查报文周期与相位配置是否合法
  6. 检查 CAN shim 依赖的数据库句柄是否有效
这说明 FoxBMS 眼里的 CAN 初始化并不是把硬件拉起来就算完,而是还要把之后的周期发送调度条件一起准备好。

2.2 扩展 ID 邮箱是被显式重配的

当前实现里,CAN_ConfigureRxMailboxesForExtendedIdentifiers() 会覆盖 HALCoGen 的默认配置,把 CAN1 和 CAN2 的 61 到 64 号邮箱重配成接收扩展标识符报文的邮箱。源码注释里也明确写了:这是对 HALCoGen 配置的二次修正。
FoxBMS 并没有无条件相信自动生成的底层初始化,而是在项目层显式修正成当前协议真正需要的邮箱布局。

2.3 初始化阶段还会把后续调度是否合法一起检查掉

CAN_Initialize() 的最后几步也很值得注意:
  • 用 CAN_CalculateCounterResetValue() 基于所有 Tx 周期算出内部计数器回绕点;
  • 用 CAN_ValidateConfiguredTxMessagePeriod() 检查所有发送周期必须非 0 且必须是 CAN_TICK_ms 的整数倍;
  • 用 CAN_ValidateConfiguredTxMessagePhase() 检查相位必须小于周期,且同样对齐 CAN_TICK_ms;
  • 最后再用 CAN_CheckDatabaseNullPointer() 确认 CAN shim 里依赖的数据库句柄都有效。
所以它初始化的不只是外设,还有后面这套发送调度能不能安全运行的前提条件。

3. 周期发送:CAN_PeriodicTransmit()

3.1 先处理 unsent queue,再处理正常周期消息

CAN_PeriodicTransmit() 一开始不是直接遍历周期表,而是先调用 CAN_SendMessagesFromQueue()。也就是说,如果上一轮由于邮箱忙、总线状态等原因没有真正发出去,系统不会直接忘掉它,而是先重试历史未发报文。它决定了 FoxBMS 的发送策略并不是当前周期快照覆盖一切,而是优先减少未发积压。

3.2 正常周期消息要同时满足 period 和 phase

之后它才遍历周期消息,并调用:
CAN_IsMessagePeriodElapsed(...)
这一步会同时考虑:
  • period
  • phase
  • 当前内部 timing counter
这和任务系统中的相位错峰是完全一致的设计思路。FoxBMS 不希望所有周期消息都在同一 tick 一起冲向总线。

3.3 发送前先通过回调组帧,失败则入未发队列

每条消息实际发送前,会根据 can_cfg_tx_cyclic.c 里的静态注册表找到对应 callback。这个 callback 会拿到:
  • 消息定义
  • 8 字节缓存
  • 可选 pMuxId
  • can_kShim 数据库句柄
也就是说,FoxBMS 的周期消息不是在 can.c 里手写拼装,而是由独立的 CANTX_... 回调去构帧。若发送失败,则会把消息推进 ftsk_canTxUnsentMessagesQueue。如果这个队列也满了,则通过:
DIAG_Handler(DIAG_ID_CAN_TX_QUEUE_FULL, ...)
上报队列满错误。也就是说,在 FoxBMS 的视角里,报文没发出去不是静默忽略的,而是:
  1. 先尽量保留待重发
  2. 实在积压不下时,再升级为诊断问题

3.4 多路复用消息的轮转也在 callback 层显式维护

can_cfg_tx_cyclic.c 里可以看到,不少消息不是简单单帧,而是带 pMuxId 的多路复用回调。例如:
  • CANTX_CellVoltages
  • CANTX_CellTemperatures
  • CANTX_StringState
  • CANTX_StringValuesP0 / P1
  • CANTX_StringMinimumMaximumValues
  • CANTX_StringStateEstimation
这些回调会自己推进 mux 计数,从而把多 string、多 cell 的内容分批对外发出。这个细节很重要,因为它说明一个消息定义不一定等于一个固定含义的 8 字节快照。

3.5 CAN_CalculateCounterResetValue() 用最小公倍数做计数器回绕点

源码会统计所有 Tx 周期的最小公倍数,作为内部 timing counter 的 reset 值。
这个设计很有工程味:
  • 计数器不会无限增长。
  • 回绕后所有period + phase 相对关系仍然保持一致。
如果不用这种办法,长时间运行后要么计数器不断变大,要么会在回绕时破坏各消息的时序关系。

3.6 TX 主链

4.CAN_MainFunction() 为什么被放在 10ms 主循环里

在 FTSK_RunUserCodeCyclic10ms() 中,CAN 主循环入口是:
CAN_MainFunction();
其内部会先 CAN_CheckCanTiming(),再根据 periodicEnable 决定是否执行 CAN_PeriodicTransmit()。
这意味着 FoxBMS 把对外周期消息发送明确视为主业务循环的一部分,而不是放到某个中断或独立后台线程里偷偷运行。
更进一步说,这条 10ms 链不只负责发消息,还顺手检查:
  • DATA_BLOCK_STATE_REQUEST 的时间戳是否还在允许窗口内;
  • 当前传感器消息是否还在响应;
  • 是否需要上报DIAG_ID_CAN_TIMING、DIAG_ID_CURRENT_SENSOR_RESPONDING、DIAG_ID_CURRENT_SENSOR_CC_RESPONDING、DIAG_ID_CURRENT_SENSOR_EC_RESPONDING。
所以这条 10ms 主链同时承载了通信发送和通信时序健康检查。

5. 接收链:FoxBMS 很克制地把中断工作压到最小

5.1 CAN_RxInterrupt() 只做搬运和最小解析

收到报文后,CAN_RxInterrupt() 的动作大致是:
  1. canGetData() 从硬件邮箱读数据
  2. 判断标准帧还是扩展帧
  3. 把 ID、8 字节数据封装到 CAN_BUFFER_ELEMENT_s
  4. 用 ISR 版本队列接口推入 ftsk_canRxQueue
如果队列满,还会触发:
DIAG_Handler(DIAG_ID_CAN_RX_QUEUE_FULL, ...)
这条路径很成熟,因为 ISR 里没有做复杂业务判断,只做最短的数据搬运和错误升级。
还要再补一个实现细节:只有当 canGetData() 明确返回无数据丢失时,这个报文才会被推进软件队列。因此当前实现并不是中断里有东西就必定交给上层处理,而是先做了一层非常保守的接收有效性过滤。

5.2 canMessageNotification()负责把邮箱中断分流到 TX/RX 处理器

源码会根据 message box 范围把中断转发到:
  • CAN_TxInterrupt()
  • CAN_RxInterrupt()
其中分界线由 CAN_NR_OF_TX_MESSAGE_BOX = 32 决定。说明 FoxBMS 的邮箱和中断职责是有明确划分的,而不是所有通知都塞进一个大处理函数里。

6.CAN_ReadRxBuffer():真正的协议分发发生在任务上下文里

6.1 1ms 任务里接收队列

前面调度篇已经提到,1ms 任务会调用:
CAN_ReadRxBuffer();
它会从 ftsk_canRxQueue 中取出软件缓存的报文,再按照:
  • node
  • id
  • idType
在静态注册表 can_cfg_rx.c 中查找匹配项,最后调用对应 rx callback。
于是完整接收路径就成了:
硬件邮箱 -> ISR 复制到 RAM -> 软件 RX 队列 -> 1ms 任务回调分发

6.2 回调分发不是动态反射,而是静态注册表匹配

can_cfg_rx.c 里现在直接能看到 RX 注册表,例如:
  • CANRX_BmsStateRequest
  • CANRX_CurrentSensor
  • CANRX_ImdInfo
  • CANRX_ImdResponse
  • CANRX_AerosolSensor
  • CANRX_CellVoltages
  • CANRX_CellTemperatures
这说明 FoxBMS 的接收分发不是解析 DBC 后自动路由,而是 C 代码里显式维护的一张静态匹配表。

6.3 这样做的好处不只是中断轻量

更重要的是,它把协议处理统一拉回了任务上下文。这样一来:
  • 回调不需要遵守 ISR 上下文限制。
  • 可以更容易和数据库、诊断模块交互。
  • 时序与系统主节拍更容易分析。
这对可验证性和功能安全都更友好。

6.4 用CANRX_BmsStateRequest() 看一条完整 RX 业务链

CANRX_BmsStateRequest() 很适合作为接收回调样本,因为它收到报文后会直接做 4 类事情:
  • 清 persistent flags,例如 deep discharge 和 sys-mon timing violation;
  • 更新DATA_BLOCK_STATE_REQUEST 里的stateRequestViaCan/stateRequestViaCanPending;
  • 根据报文位设置均衡使能请求;
  • 根据报文内容设置均衡阈值。
也就是说,FoxBMS 的 RX callback 不是只做协议解码,它本身就是外部请求与系统内部的桥。

6.5 RX 主链

7.periodicEnable 是个很关键的系统控制点

上一篇 SYS 状态机里提到,在 current sensor presence check 之前,系统会调用:
CAN_EnablePeriodic(true);
这就把通信层和系统启动编排接起来了。CAN 的周期发送不是永远默认开启,而是被系统状态明确控制。
而且当前实现里不只一条路径会把它打开:
  • 若系统需要先做 current sensor presence check,则在SYS_FSM_SUBSTATE_START_CURRENT_SENSOR_PRESENCE_CHECK 打开;
  • 若工程配置里没有 current sensor,则会在SYS_FSM_SUBSTATE_INITIALIZATION_MISC 打开。

8. DBC 工具链

8.1 tools/dbc/README.md的第一条核心规则:先改.sym,再导出.dbc

README 明确要求:主编辑对象应是 .sym,.dbc 是导出结果。团队不希望多人直接手工改 .dbc 导致格式漂移、注释丢失或变更路径不一致。

8.2 消息命名和归属也被约束了

README 还规定:
  • foxBMS 自有消息使用f_ 前缀
  • 第三方消息放在third-party
  • 尽量补齐 enum、type、unit 信息
这些看似是文档规范,实际上是在控制协议长期演进时的可读性和边界清晰度。

8.3 这些规则不是只写在 README 里,还有测试和钩子在兜底

当前仓库里,DBC / SYM 资产至少被 3 类机制消费:
  • tests/dbc/check_parseable.py 会用cantools 检查foxbms.dbc 是否可解析。
  • tests/dbc/overlapping-signals.py 会显式验证 overlapping signals 这种坏定义会被工具识别出来。
  • pre-commit-config.yaml 里有针对.dbc /.sym 的 ASCII 编码检查。
所以这里不是团队约定你最好这么做,而是仓库里已经有工具在盯着这些资产是否保持可消费。

8.4 DBC 还被文档系统直接消费

docs/conf.py 里会直接加载 tools/dbc/foxbms.dbc,然后生成 supported CAN messages 文档内容。
这意味着 DBC 在这个仓库里不是孤立附件,而是已经成为文档产物的一部分。协议文件一旦失真,受影响的不只是总线定义,还包括说明文档的可用性。

9.fox.py:统一入口的真正价值在于把环境、命令和仓库约束收口到一起

9.1 它首先做的是拦截不合格的执行环境

fox.py 源码一开始就做了两件硬约束:
  • Python 版本必须是 3.12
  • 执行目录必须是仓库根目录
满足后才把控制权转给 cli.cli.main()。

9.2 cli.cli.main() 做的是环境收口和命令总装

进入 cli.cli.main() 后,当前实现还会立刻做下面几件事:
  • initialize_path_variable_for_foxbms():整理 PATH,去重并过滤不希望混入的路径;
  • set_other_environment_variables_for_foxbms():加载conf/env/env.json 并设置平台相关环境变量;
  • create_pre_commit_file():必要时在.git/hooks 下写入 pre-commit 启动脚本;
  • 最后再统一挂载bms、waf、ci、plot、pre-commit 等命令。
所以它是在真正执行业务命令前,先把仓库期望的环境整理成同一套样子。

9.3 为什么这件事重要

因为对大型工程来说,最容易失控的并不是单个函数,而是团队里每个人都用不同环境、不同命令入口、不同脚本习惯。fox.py 的价值就在于把这些分叉尽量收束到统一入口。
这和根 wscript 的做法是一致的:FoxBMS 很强调把环境和流程差异尽量前置成显式约束。

10. 把最后一层放回全局架构里看

到了这里,FoxBMS2 的内外闭环基本完整了:
  • 驱动层把物理测量和反馈写入数据库。
  • 状态机和算法在任务节拍中消费这些数据。
  • 诊断模块把异常组织成可处理的故障语义。
  • CAN 把请求和状态在系统内外之间搬运。
  • DBC 规则和 fox.py 则把工程团队的协作方式固定下来。
这也是为什么说,FoxBMS2 不是一堆散模块,而是一套工程系统。

10.1 用一张图看清协议资产如何被团队流程消费

小结

这一篇最后收了两条线:
  • 通信线:CAN_Initialize()、CAN_MainFunction()、CAN_PeriodicTransmit()、CAN_RxInterrupt()、CAN_ReadRxBuffer() 共同构成了 FoxBMS 的对外消息通道。
  • 工具线:tools/dbc/README.md、tests/dbc、fox.py 和 cli 体现了项目如何把协议维护与开发入口标准化。
如果把整个系列再压缩成一句话,那就是:FoxBMS2 的价值不只在于它实现了多少 BMS 功能,更在于它把这些功能放进了一套层次清楚、时序明确、约束充分的工程框架里。

相关学习资料