双 ECU 电池监测如何挡住瞬态故障并留下现场证据
双 ECU 电池监测如何挡住瞬态故障并留下现场证据
电池监测板最容易被忽略的故障,不是“电压读错一位”,而是异常只出现了几个采样周期,系统却已经报警、恢复,最后谁也说不清发生过什么。把 ADC、故障判断、CAN 显示和 Flash 日志全部塞进一个主循环,还会让某次串口输出或 Flash 写入拖慢后续判断。
我以立创开源项目的双 ECU 电池监测板为对象,重新核对同一版嘉立创 EDA 原理图和 PCB,并把问题收敛成一条可以复现的链路:ECU-A 只负责产生带时间戳的测量快照,故障管理器用“确认时间+迟滞+恢复稳定时间”处理生命周期,CAN 发送只拿一致快照,故障事件则按值复制进队列交给 Flash 任务。ECU-B 继续用裸机循环做报文检查和 OLED 显示。
这篇文章讲的是如何读懂和验证这条链路,不把它包装成车规 BMS。当前工程没有电流采样、均衡电路或充放电 MOS,FAULT 只能报警、上报和记录,不能物理切断电流;SOC 也只是基于电压的简化估算。

图1 从嘉立创 EDA 专业版 V4.1.97 重新导出的 BMS 原理图总览。ECU-A、ECU-B、分压、NTC、CAN 收发器和 SPI Flash 都在同一张图上,本文后面的代码和引脚说明均以这张图为准。
先把硬件边界钉死
板上有两个 STM32F103 节点。ECU-A 的 PA0 接电压分压,PA1 接 10 kΩ/B3950 NTC;SPI 口连接外部 Flash,CAN1 经过 TJA1050 收发器接入总线。ECU-B 的 CAN2 接收状态帧,I²C 连接 OLED。两个节点和收发器必须共地,MCU 引脚不能直接接 CANH 或 CANL。

图2 原理图局部。电池电压先经分压再进 PA0,NTC 通过 10 kΩ 上拉接到 PA1。工程把 ADC 参考电压写成 3.27 V,电压还原比例为 1.5;这两个数是软件换算的前提,不能把 PA0 的电压直接当成电池电压。

图3 ECU-A 负责采样、判断、记录和发送,ECU-B 负责接收和显示。USART 通过拨动开关复用到不同节点,调试时要先确认开关位置;这是原理图中的实际连接,不是本文新增的接线。
PCB 也重新从同一工程导出。顶层可以看到两块核心板和右侧的传感器、Flash、CAN 接口,底层主要是回流和过孔连接。图中没有补绘任何缺失走线,因此它只用于核对元件位置和层面关系,不能替代制造检查。

图4 PCB1 顶面导出图,4096×3818 像素。

图5 PCB1 底面导出图,4096×3818 像素。底面按编辑器视角保留,没有镜像成“翻面实物照”。
最新值和事件不能用同一种通信方式
传感器每次 DMA 采样得到的是一个“最新值”。如果 BMS 任务已经落后,继续处理几百帧旧 ADC 数据只会增加延迟,所以 SensorTask 在临界区外完成换算,再一次性发布带 sequence 和 timestamp_ms 的快照。BMS、CAN 和 CLI 复制这份快照,各自只读自己的副本。
故障发生和恢复则是不能丢的事件。BMS 任务把事件结构体按值复制到 8 项静态队列,FaultLogTask 再把发生时的电压、温度、SOC、故障掩码和复位原因写入固定 32 字节 Flash 记录。队列满时只增加丢弃计数,不能阻塞故障判断;这也是必须把“快照”和“事件”分开的原因。
用迟滞和时间把瞬态挡在门外
故障配置是逐项独立的。过压高于 4.20 V 连续 500 ms 才确认,恢复要低于 4.10 V 并稳定 1 s;欠压是 3.00 V/3.10 V,过温是 60 ℃/55 ℃。NTC 开路、短路和数据不一致也走同一生命周期,ADC 超时和 CAN Bus-Off 则立即确认。恢复稳定后,人工清除故障的按键才有效。
状态机实际包含 CLEAR、PENDING、CONFIRMED 和 RECOVERY_PENDING 四态。异常在确认时间内消失会退回 CLEAR;恢复阶段再次异常会回到 CONFIRMED,重新累计恢复时间。无符号时间差计算也能覆盖 32 位 Tick 回绕。
下面是我从仓库状态机抽出的最小可运行模型,用来检查边界,而不是替代原工程固件:
from dataclasses import dataclass
@dataclass
class Fault:
state: str = "clear"
since: int = 0
ready: bool = False
def update(self, trip: bool, recovery: bool, now: int,
confirm: int = 500, recover: int = 1000) -> None:
if self.state == "clear":
if trip:
self.state, self.since = (
("confirmed", now) if confirm == 0
else ("pending", now)
)
elif self.state == "pending":
if not trip:
self.state = "clear"
elif now - self.since >= confirm:
self.state, self.since = "confirmed", now
elif self.state == "confirmed":
self.ready = False
if recovery:
self.state, self.since = "recovery_pending", now
elif self.state == "recovery_pending":
if trip or not recovery:
self.state, self.since, self.ready = "confirmed", now, False
elif now - self.since >= recover:
self.ready = True
f = Fault()
f.update(True, False, 0) # 进入等待确认
f.update(True, False, 500) # 500 ms 后确认
assert f.state == "confirmed"
f.update(False, True, 501) # 开始累计恢复稳定时间
f.update(False, True, 1501) # 1 s 后只允许人工清除
assert f.ready and f.state == "recovery_pending"
f.update(True, False, 1502) # 恢复阶段再次异常,取消清除资格
assert not f.ready and f.state == "confirmed"
这个模型的价值在于把“达到恢复阈值”和“已经清除”分成两件事。注入测试时,fault inject ov 只把触发条件置为真,不改 ADC 寄存器,所以 status 里的真实电压仍应保持原值。停止注入后,系统仍要等待恢复阈值和稳定时间,按键不能强行取消报警。
CAN 报文要能发现旧数据和位错误
ECU-A 发送三种 8 字节标准帧。0x301 携带毫伏电压、简化 SOC、监测状态和 4 位 Alive Counter;0x302 携带温度和自己的计数器;0x303 携带 16 位活动故障掩码。每帧的 Byte7 是 Byte0~Byte6 的 CRC-8/SAE-J1850,保留字节发送前固定清零。ECU-B 同时检查 CRC、Alive Counter 和接收超时,超时后显示 CAN LOST,不把上一次数据继续当成实时状态。
CRC 的独立核对实现如下,参数来自仓库的 can_protocol.h:
def crc8_j1850(data: bytes) -> int:
crc = 0xFF
for byte in data:
crc ^= byte
for _ in range(8):
crc = ((crc << 1) ^ 0x1D) & 0xFF if crc & 0x80 else (crc << 1) & 0xFF
return crc ^ 0xFF
def pack_voltage(mv: int, soc: int, status: int, alive: int) -> bytes:
frame = bytearray(8)
frame[0], frame[1] = (mv >> 8) & 0xFF, mv & 0xFF
frame[2], frame[3] = soc & 0xFF, status & 0xFF
frame[4] = alive & 0x0F
frame[5] = frame[6] = 0
frame[7] = crc8_j1850(frame[:7])
return bytes(frame)
frame = pack_voltage(3900, 50, 3, 17)
assert frame.hex() == "0f3c3203010000ab"
assert frame[7] == crc8_j1850(frame[:7])
这里的 17 会被限制成低四位的 1。如果接收端只比较数据而不比较计数器,重复帧和总线卡住都可能被误认为“仍有更新”;如果只比较计数器而不验 CRC,位错误又可能变成一条看似新鲜的故障状态。
ADC 和 NTC 的边界也要单独测
ADC 代码不能只测一个正常值。按照工程中的 3.27 V 参考、1.5 倍电压还原和 10 kΩ/B3950 公式,我用 Python 对 3.90 V、室温、NTC 短路疑似和开路疑似四类输入做了回归。结果是 3.90 V 输入还原误差小于 2 mV,2048 码对应约 25.0 ℃;PA1 小于等于 16 码标为短路疑似,大于等于 4079 码标为开路疑似,不把这些端点代入对数公式。
这一步只验证换算和分类边界,不代表电阻容差、参考电压漂移、板上噪声或整机温度精度已经完成标定。原工程的 ADC 超时还要求采样快照不超过 100 ms;没有新 DMA 数据时,故障管理器会确认超时,而不是沿用旧快照。

图6 PCB1 右侧采样、Flash 与接口区域的无损裁剪,用于把图2中的网络和实际布局对应起来。
可复现验证和明确限制
我从 GitHub main 分支逐文件读取了业务源码、README.md、LICENSE 和 THIRD_PARTY_NOTICES.md。仓库给出的主线提交是 8b4082df71d1c0d4f7b0dfe45ece8d6de21653ff,README 记录的构建环境是 Keil ARMCC 5.06,ECU-A 和 ECU-B 均为 0 Error、0 Warning;当前机器没有安装 Keil,因此本文没有把这段历史构建记录冒充本次本机重编译。
我本次实际运行了独立的 verify_bms.py,覆盖电压/NTC 换算、端点分类、故障确认与恢复状态转移、CRC 参数、Alive Counter 截断和 0x301 报文打包,输出为:
{'sensor_cell_v': 3.9, 'sensor_temp_c': 24.989,
'crc_voltage_frame': '0f3c3203010000ab',
'fault_recovery_ready': False, 'checks': 'passed'}
这组结果是合成输入和源码级核对,不包含真实电池、CAN 总线、NTC 热箱、Flash 掉电、EMC、长期压力或整车环境测试。工程也没有电流采样、均衡和功率 MOS,不能用来宣称车规安全、充放电保护或量产可用。
工程来源和许可
硬件原理图和 PCB 取自立创开源项目双 ECU 电池监测与故障管理系统,页面显示工程许可为 OpenAtom OHL 1.0,EDA 编辑器版本为 V4.1.97。本文图片由对应版本 EDA 重新导出,仅做裁剪和保真缩放,没有补画连线或生成器件丝印。
业务源码来自GitHub Nway-J/stm32-freertos-can-battery-monitor 的 main 分支,仓库根目录自编业务代码和文档采用 MIT;其中 FreeRTOS 内核保留其上游 MIT 条款,ST/CMSIS 等依赖需要按仓库第三方说明自行取得。本文的独立验证脚本按 MIT 许可表达,不能替代这些第三方许可。
硬件工程、源码版本、图片处理和验证范围都已在上文明确。复现时请以两个入口的当前版本为准,并先确认芯片、引脚、供电和总线终端条件。
技术交流,欢迎关注微信公众号:美男子玩编程。



