双 ECU 电池监测如何挡住瞬态故障并留下现场证据

2026-11-092
AI 快速预览详细 收起
这篇文章介绍了双 ECU 电池监测板的设计,通过两个STM32F103节点实现电池状态监测和故障管理。ECU-A负责产生带时间戳的测量快照,并通过“确认时间+迟滞+恢复稳定时间”处理生命周期,确保瞬态故障被有效挡住并记录。ECU-B则负责报文检查和OLED显示。这种设计解决了瞬态故障难以追踪的问题,提高了系统的可靠性。

双 ECU 电池监测如何挡住瞬态故障并留下现场证据

电池监测板最容易被忽略的故障,不是“电压读错一位”,而是异常只出现了几个采样周期,系统却已经报警、恢复,最后谁也说不清发生过什么。把 ADC、故障判断、CAN 显示和 Flash 日志全部塞进一个主循环,还会让某次串口输出或 Flash 写入拖慢后续判断。

我以立创开源项目的双 ECU 电池监测板为对象,重新核对同一版嘉立创 EDA 原理图和 PCB,并把问题收敛成一条可以复现的链路:ECU-A 只负责产生带时间戳的测量快照,故障管理器用“确认时间+迟滞+恢复稳定时间”处理生命周期,CAN 发送只拿一致快照,故障事件则按值复制进队列交给 Flash 任务。ECU-B 继续用裸机循环做报文检查和 OLED 显示。

这篇文章讲的是如何读懂和验证这条链路,不把它包装成车规 BMS。当前工程没有电流采样、均衡电路或充放电 MOS,FAULT 只能报警、上报和记录,不能物理切断电流;SOC 也只是基于电压的简化估算。

双 ECU 电池监测板原理图总览

图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。

电压分压和 NTC 局部

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

两个 ECU 与 CAN 局部

图3 ECU-A 负责采样、判断、记录和发送,ECU-B 负责接收和显示。USART 通过拨动开关复用到不同节点,调试时要先确认开关位置;这是原理图中的实际连接,不是本文新增的接线。

PCB 也重新从同一工程导出。顶层可以看到两块核心板和右侧的传感器、Flash、CAN 接口,底层主要是回流和过孔连接。图中没有补绘任何缺失走线,因此它只用于核对元件位置和层面关系,不能替代制造检查。

PCB 顶面

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

PCB 底面

图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 数据时,故障管理器会确认超时,而不是沿用旧快照。

PCB 传感器和 Flash 区域

图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 许可表达,不能替代这些第三方许可。

硬件工程、源码版本、图片处理和验证范围都已在上文明确。复现时请以两个入口的当前版本为准,并先确认芯片、引脚、供电和总线终端条件。

技术交流,欢迎关注微信公众号:美男子玩编程。

创作许可协议

本项目采用 CC BY(署名) 进行许可。

评论(0)
- 没有更多了 -