核心钩子:没有真芯片,怎么调 BMS? 平台:AS32x601(RISC-V,国科安芯)× TI BQ76952 × 至芯 USB2XXX 场景: 电池管理(BMS)软件,BQ76952 I2C0 关键词:I²C 从机模拟器 / CRC8 帧协议 / 直接命令与子命令 / 联调替代真芯片
本系列共两篇:本篇造"假芯片" → 《下篇:AS32x601 的 I²C 主机代码全解析:从引脚到采集任务》 。
0. 没有真芯片,怎么调 BMS?
BMS 软件联调有个经典困境:
- 芯片不在手上/板子焊好但电池包没接 —— 想调 I²C 驱动,读回全是 0,分不清是"驱动写错"还是"芯片没接";
- 真芯片是黑盒 —— 你想验证"安全状态字 bit0 置 1 时 MCU 会不会正确上报告警",真芯片不会配合你摆姿势;
- 故障注入几乎不可能 —— 过压/过温/短路放电这些保护路径,靠真电池包去复现代价极高且危险。 ⚠️本模拟器仅软件层面模拟告警状态,不会产生真实高压大电流,所有真实电池测试务必做好防护。
于是思路反过来:让 PC 扮演从机。USB2XXX(至芯的 USB 转 I²C/SPI/CAN 适配器)自带"从机模式",把它的 SCL/SDA 接到 MCU 的 I2C0 上,PC 端用 Python 收帧、按 BQ76952 的协议回帧。MCU 完全不知道对面是假的——它以为自己在读一颗真的 BQ76952。
这就是本篇的主角 usb2xxx_slave_bq76952.py。
注意方向别搞混(这是最容易踩的坑):
usb2xxx_slave_bq76952.py—— USB2XXX 做从机(假芯片),回 MCU 的命令帧 ← 本篇主题usb2xxx_bq76952.py/.c/scan_addr.py—— USB2XXX 做主机,去读真芯片(旧工具)
一句话价值:一块几十块钱的 USB2XXX + 一个 900 行 Python 脚本,就能在没有真 BQ76952 芯片的情况下,把 AS32x601 的 I²C 驱动、CRC 校验、子命令交互、采集任务全部跑通——这比等芯片到货或者靠"示波器猜"快得多。 ⚠️【重要坑】USB2XXX 官方提供的 DLL 为 32 位库,必须使用 32 位 Python;64 位 Python 会直接报WinError 193 不是有效的Win32程序,不是代码 bug。
1. 先看这张表:你想干什么 → 看哪节
| 你的目的 | 看哪节 |
|---|---|
| 快速搞懂 BQ76952 在 I²C 上到底怎么说话 | §2 协议真值 |
| 想知道这台"假芯片"是怎么造出来的 | §3 软件设计方案 |
| 想照着跑起来(不接 MCU 先自检) | §5.1 无硬件自检 |
| 想接上板子和 MCU 联调 | §5.3 三步启动与成功标志 |
| 想改模拟电压/温度/告警做异常注入 | §5.4 --config |
| 想直接复制命令跑一遍 | §8 快速上手清单 |
| 通信不通、读回全是 0 | §5.5 故障速查 / §6 时序 |
| 想知道哪些结论是实测、哪些必须上板 | §7 证据边界 |
| MCU 那边怎么写才能对上 | 《下篇:AS32x601 的 I²C 主机代码全解析:从引脚到采集任务》 |
| 换了串数或电芯类型,不知道要改哪里 | 《下篇:换电池包不烧板:串数与电芯类型的适配指南》 |
2. 协议真值:BQ76952 在 I²C 上是怎么说话的
本节所有协议数值,来自 TI 官方手册,同时对照项目 BMS 固件驱动源码做了对齐。,不是示意图——要复刻一个从机,先得把帧格式钉死。
2.1 物理层与地址
| 项 | 值 | 出处 |
|---|---|---|
| 从机地址(7bit) | 0x14 | drv_bq76952.c:DEV_ADDR[] = {0x12, 0x12, 0x14} |
| 8bit 写地址 | 0x28 | DEV_ADDR_REAL = 0x28(0x14<<1) |
| 8bit 读地址 | 0x29 | 0x28 + R/W 位 |
| 总线速率 | 固件侧 I2C0 ICLKDIVL = 100(源码注释标 100 kHz) | iic.c |
| 引脚 | I2C0:SCL=PE3、SDA=PE2,复用 GPIO_AF_IIC0_M3 | iic.c |
| 多包寻址 | 3 包:{0x14, 0x16, 0x17}(实为 0x28/0x2C/0x2E 右移 1 位) | drv_bq76952.c |
一个容易看错的细节:
drv_bq76952.h里#define Cell1Voltage 0x15 / Cell16Voltage 0x33(奇数),而数据手册的 Cell 1 直接命令是 0x14、Cell 16 是 0x32(偶数)。真正的读循环用的是Cell1Voltage - 1 + 2*x = 0x14 + 2x,也就是偶数地址,与手册一致。所以模拟器对0x14..0x33的奇偶地址都回同一个 cell——这样不管你照 .h 还是照手册写代码,都能读通。
2.2 CRC8:整个协议的"门锁"
- 多项式
0x07,初值0x00,MSB 先出,无反转、无终值异或; - 固件用一张 256 字节查表实现(
crc8_table[256]),PC 侧用按位算法实现——两者必须逐字节相同,所以模拟器里做了锚点自检。 - 首个数据字节的 CRC 参与运算的字节序列不同:写用
{写地址, 子地址, D0},读用{读地址, D0}(注意是 8bit 读地址 0x29,不是 7bit); - 后续每个数据字节各自单独算 CRC:
Ci = CRC8([Di])。
这是 BQ76952 协议里最反直觉的地方——不是"整帧一个 CRC",而是每个数据字节后面跟一个它自己的 CRC,且首字节的 CRC 还要把地址算进去。
2.3 三种帧:读触发 / 写数据 / 子命令
① 直接命令读(读寄存器)
主机: [子地址] ← 只写 1 字节,不写数据
↓ 等待 ≥ 2ms
主机: 读 2N+1 字节
从机: [D0][CRC8(0x29,D0)][D1][CRC8(D1)]...[填充0x00]
注意主机读的是 2N+1 字节(源码 I2C_Master_Receive(..., crc_count + 1, ...),crc_count = 2N),最后一个字节是填充位,固件只校验前 2N 字节——所以从机应答时补一个 0x00 让长度对得上即可。
② 直接命令写(写寄存器)
主机: [子地址][D0][CRC8(0x28,子地址,D0)][D1][CRC8(D1)]...
③ 子命令(Subcommand):先写 0x3E,再读 0x40
主机: 写 [0x3E][cmd_lo][CRC8(0x28,0x3E,cmd_lo)][cmd_hi][CRC8(cmd_hi)]
↓ 等待 ≥ 2ms
主机: 写 [0x40] ← 读触发
↓ 等待 ≥ 2ms
主机: 读 2N+1 字节 ← 回上次子命令的结果
子命令用小端两字节:0x3E 后面先低字节后高字节(源码 cmd = (data[1] << 8) | data[0])。
2.4 时序:2ms 是这场联调的"生死线"
固件 I2C_ReadReg() 里的节奏是死的:
I2C_Master_Transmit(hi2c1[Index], DEV_ADDR[Index], ®_addr, 1, TIMEOUT_IIC);
vTaskDelay(2); // ← 只有 2ms
I2C_Master_Receive(hi2c1[Index], DEV_ADDR[Index], ReceiveBuffer, crc_count + 1, TIMEOUT_IIC);
这 2ms 是给 BQ76952 芯片"准备数据"的窗口。但 PC 扮演从机时,这 2ms 要用来做"收到偏移 → 查表算值 → 算 CRC → 组帧 → 预装载应答",Python + USB 单趟往返 1~5ms 都很正常。跟不上的后果不是报错,而是 MCU 读到 0 或 CRC 失败——这是本方案唯一真正的风险点,后面 §8 给对策。
3. 软件设计方案:900 行 Python 造一颗芯片
3.1 设计目标与约束
| 目标 | 对应设计决策 |
|---|---|
| 帧格式必须与固件逐字节一致 | CRC8 表/算法独立实现,自检锚点比对;帧解析严格按 drv_bq76952.c 的字节规则 |
| 不动 MCU 一行代码 | 只做"协议层仿真",MCU 侧零适配(它就是当真的在读) |
| 没硬件也能验证逻辑 | --selftest / --decode 两个无硬件入口 |
| 能造异常场景 | --config JSON 只覆盖要改的键 |
| 读不到的寄存器不能崩 | 未建模地址一律回稳定默认值,而不是异常 |
3.2 模块分层
usb2xxx_slave_bq76952.py (~900 行)
├─ ① 常量与真值表
│ SLAVE_ADDR_7BIT=0x14 / DEV_ADDR_W=0x28 / DEV_ADDR_R=0x29
│ CRC_POLY=0x07, CRC_INIT=0x00
│ DIRECT_COMMANDS{} 直接命令地址表
│ SUBCOMMANDS{} 子命令表 (名字, 字节数, 说明)
│ FET_BIT_* / ALARM_* 位定义
├─ ② CRC8 引擎
│ _build_crc8_table() → CRC8_TABLE[256] # 与固件 crc8_table 等价
│ crc8(data) → 按位实现(可读、可验证)
├─ ③ 寄存器模型 SIM{}
│ 产品ID / 16 节电压 / 组压 / 电流 / 温度 / 状态字 / 均衡状态
├─ ④ 取值逻辑
│ read_direct(addr, nbytes) # 地址 → 原始小端字节
│ write_direct(addr, data) # 0x62 写1清告警 / 0x66 使能
│ subcmd_result(cmd) # 子命令 → 结果字节
│ apply_subcommand(cmd, data) # 有副作用的命令(FET/均衡/模式)
├─ ⑤ 帧编解码
│ parse_write_frame(frame) # → (kind, addr, data),逐字节验 CRC
│ build_reply(data_bytes) # → [D0,CRC,D1,CRC,...,0x00]
├─ ⑥ USB2XXX 封装 (ctypes)
│ IIC_CONFIG 结构体 / USB2XXX 类 / scan / open / iic_init_slave
│ slave_read / slave_write
├─ ⑦ 从机主循环 run_slave()
└─ ⑧ 工具入口 main()
--selftest / --decode / --config / --iic / --addr / --clock / --dll
分层的好处很实在:③④⑤ 三块是纯逻辑,可以脱开硬件单测(--selftest/--decode 就是直接调它们),⑥⑦ 才是碰硬件的那一小层。
3.3 核心状态机:一个"写触发读"的从机
USB2XXX 的从机 API 是被动式的——IIC_SlaveReadBytes() 阻塞等你收到数据,你拿到的永远是"主机写过来的字节"。BQ76952 协议恰好是 "读之前先写一个偏移" 的结构,所以从机这条状态机天然成立:
┌──────────────────────────────────────────┐
│ IIC_SlaveReadBytes(timeout=2000ms) │
└───────────────┬──────────────────────────┘
│ 收到帧
▼
parse_write_frame(frame)
│
┌──────────────────┼───────────────────┬──────────────┐
▼ ▼ ▼ ▼
单字节写 len≥3 addr==0x3E addr==0x60
"read_trigger" ("direct_write") "subcmd_write" "checksum"
│ │ │ │
│ 查 read_direct │ write_direct │ 记 pending │ 仅记录
│ 组 build_reply │ (0x62/0x66) │ apply_subcmd │
▼ ▼ ▼ ▼
IIC_SlaveWriteBytes (不回包) 等下一次 0x40
预装载应答 ──→ subcmd_read
特殊分支:
- 收到单字节
0x40→subcmd_read,回最近一次子命令的结果(pending_subcmd); - CRC 校验失败 →
("bad_crc", ...),计数并打印,不回包(真芯片此时也不会给正确数据); - 长度不是 3/5/7/9/11/13/15 →
unknown,跳过。
3.4 两个关键工程设计
① 应答缓冲必须开大:512 字节
def slave_read(self, dev, idx, timeout_ms=2000):
# IIC_SlaveReadBytes 没有长度参数: 它把从机收到的"所有字节"往 pReadData 里写。
# 缓冲区不够 → DLL 越界写 → 堆破坏 → segfault / WinError。
buf = (ctypes.c_ubyte * 512)()
n = self.dll.IIC_SlaveReadBytes(dev, idx, buf, timeout_ms)
return list(buf[:max(n, 0)]) if n > 0 else []
这是踩过的坑:IIC_SlaveReadBytes 的签名里没有长度参数,DLL 按"它认为收到多少"就往缓冲区写,缓冲区给小了就是堆破坏——现象是随机 WinError 或进程直接挂掉,而不是干净的报错。单帧理论上限 65 字节(2N+1, N≤32),开 512 是为了兼容 SCL/SDA 抖动导致的字节合并传输。
② 从机配置用 IIC_CONFIG 结构体,Master=0
cfg.ClockSpeedHz = clock # 默认 400000
cfg.OwnAddr = own_addr & 0x7F # 默认 0x14
cfg.Master = 0 # ★ 0 = 从机(假芯片),1 = 主机
cfg.AddrBits = 7 # 7bit 地址
cfg.EnablePu = 1 # 内部上拉(板子上没有上拉电阻时救急)
cfg.ByteIntervalUs = 0
Master=0 就是"假芯片模式"的总开关——同一个脚本,改这一个字段它就变成读真芯片的主机工具。
3.5 寄存器模型:一套"自洽的整包电池数据"
模拟器的默认值不是随便填的,而是一整套物理上自洽的整包数据——这样 MCU 侧的保护判据、SOC 估算、极值统计才不会因为数据不合理而乱跳:
| 量 | 默认值 | 说明 |
|---|---|---|
| 16 节单体电压 | 3595 ~ 3612 mV | 差异化分布,避免"全 3600"看不出映射错位 |
| 组压(0x34) | stack_voltage_mV: null → 自动 = Σ各节 = 57653 mV | 与各节之和必然自洽 |
| PACK 引脚(0x36) | 40960 mV(约 40.96V) | |
| LD 引脚(0x38) | 0 mV | |
| CC2 电流(0x3A) | +1850 userA(正=放电) | 有符号 int16 |
| 内部温度(0x68) | 2982 = 25.0 ℃ | 单位 0.1K |
| TS1/TS2/TS3/HDQ/DCHG/DDSG | 29782985(24.725.4 ℃) | 各点轻微差异 |
| CFETOFF/DFETOFF/ALERT 温度阈值 | 3282/3382/3432(55/65/70 ℃) | 阈值不是测量值,单独建模 |
| FET 状态(0x7F) | bit0(CHG)=1, bit1(DSG)=1 | 充放电双双打开 |
| 告警 / 安全状态 | 全 0x0000 | 无告警、无 PF |
| 均衡 | cb_active_cells=0x0000,但 cell2=3600s、cell5=1200s、cell15=900s 有累计时长 | 让"均衡时长"这条路也能读到非零 |
单位与符号是这个模型里最容易错的地方(写错了 MCU 侧解出来的值就完全不对):
- 温度
0.1K:25 ℃ = 2982,换算式℃ = raw/10 - 273.15; - 组压/PACK/LD 是
0.01V单位:固件读回后 ×10 转 mV(BQ769x2_ReadVoltage里的return (10 * ret_value)); - 单体电压直接是 mV,不做换算;
- 电流为有符号 int16,正=放电、负=充电(注意:不同版本的示例工具对符号的解释需要按你的采集电路确认,模拟器按"正=放电"建模)。
3.6 覆盖范围:让"读不到"不成为bug
联调中最让人抓狂的不是"读错",而是"读不到"——固件启动时会成片地扫配置/校准区,模拟器如果不响应或回 0,MCU 侧会把它当成"配置未编程"从而走异常分支。
所以本模拟器的策略是分级响应:
| 区域 | 地址/子命令 | 策略 |
|---|---|---|
| 量测区 | 0x03/0x05/0x07/0x0A/0x0B/0x12/0x14..0x33/0x34/0x36/0x38/0x3A/0x62/0x64/0x66/0x68/0x6A..0x7A/0x7F | 真值建模(58 个地址,往返可读) |
| PF 诊断扩展 | 0x0C..0x11 | 固定回 0(本模拟器视为无 PF) |
| 状态/读型子命令 | 0x0001..0x0009(版本/signature)、0x0053/0x0057/0x0070 等 | 真值建模 |
| 有副作用的子命令 | 0x000E/0x000F/0x0010(深睡/关断)、0x001C..0x0024(FET/测试)、0x0029(PF_RESET)、0x0083..0x0087(均衡)、0x0090/0x0092(CFGUPDATE)、0x0093..0x0096(FET 开关)、0x0099/0x009A(睡眠) | 真值建模并改变内部状态 |
| 配置/校准区 | 子命令 0x9100–0x93FF(校准)、0x9200–0x93FF(配置)、0xF000–0xF0FF | 按范围命中,回稳定非零占位 (0x00B0 << 8) | (cmd & 0xFF) —— 高字节 0x00B0 是"配置已编程"标记,低字节随子命令号派生(确定性、16 位内) |
| 子命令表 | 共 98 条命名(含 0x92xx/0x93xx 25 条) | 表内命名的走"真值/副作用",表外落到范围规则 |
后两行是点睛之笔:真实值本应逐条对照 BQ76952 各子命令定义,但那做不到也没必要——联调阶段真正要的是"MCU 判定配置已编程"这个语义成立,于是用一个确定性、非零、16 位内的占位值即可。唯一的要求是确定性(每次读一样),否则 MCU 侧会看到配置反复变化。
3.7 子命令的副作用表(这部分决定你能造哪些场景)
| 子命令 | 名称 | 对模拟器状态的影响 |
|---|---|---|
0x0022 | FET_ENABLE | CHG+DSG 置 1 |
0x0096 | ALL_FETS_ON | FET 状态 = CHG|DSG |
0x0095 | ALL_FETS_OFF | FET 状态 = 0(最常用的"拉闸"实验) |
0x0093 | DSG_PDSG_OFF | 清 DSG、PDSG 位 |
0x0094 | CHG_PCHG_OFF | 清 CHG、PCHG 位 |
0x001F | CHGTEST | 关充电 FET |
0x0020 | DSGTEST | 关放电 FET |
0x000F / 0x000E | DEEPSLEEP / EXIT_DEEPSLEEP | battery_status bit2 置/清 |
0x0090 / 0x0092 | SET_CFGUPDATE / EXIT_CFGUPDATE | battery_status bit1 置/清 |
0x0012 | BQ769x2_RESET | FET 复位为 CHG|DSG,清 battery_status bit1/bit2 |
0x0029 | PF_RESET | pf_alert=0,pf_status=0 |
0x0083 | 均衡 cell 掩码 | cb_active_cells ← 写入值 |
0x0084 | 均衡门限 | cb_set_lvl_mV ← 写入值 |
配合上位机发 0x17 BMS FET 开关控制 命令,就能在不碰真电池的情况下验证一整条"指令 → 子命令 → FET 状态 → 遥测回报"的链路。
4. 文件清单:哪些是假芯片、哪些是主机工具
host_tool/ 目录里混着两代工具,先分清再动手:
| 文件 | 角色 | 说明 |
|---|---|---|
usb2xxx_slave_bq76952.py | ★ 假芯片(本篇主角) | 905 行,USB2XXX 作 I²C 从机,模拟 BQ76952 |
usb2xxx_bq76952.py / .c | 主机工具 | USB2XXX 作 I²C 主机,去读真芯片(PY + C 双版本) |
scan_addr.py | 主机工具 | 扫总线:对候选 7bit 地址(0x08/0x14/0x16/0x06)发读,看谁 ACK |
USB2XXX.dll | 依赖 | 32 位,需 32 位 Python |
libusb-1.0.dll | 依赖 | USB2XXX 底层 |
README.md / 启动.md | 文档 | 接线、启动、注意事项 |
zhcaai0b--软件开发指南.pdf、zhcu948b---BQ芯片介绍.pdf | 资料 | TI 中文文档(子命令定义查这里) |
注意两代工具用的是两代 SDK API:主机工具用
USB2XXX_Init/I2C_Init/I2C_WriteRead+I2C_SPEED_400K;从机模拟器用更完整的USB_ScanDevice/USB_OpenDevice/USB_IIC_Init/IIC_SlaveReadBytes+IIC_CONFIG结构体。看到"没有 SlaveWrite 函数"的旧 DLL 报错,就是这个原因——换 USB2XXX.dll 版本。
5. 操作说明
5.1 环境准备(三件事,任缺一件都起不来)
| 项 | 要求 | 为什么 |
|---|---|---|
| Python | 32 位(如 D:\python_32\python.exe) | USB2XXX.dll 是 32 位,64 位 Python 加载会报 WinError 193(不是有效的 Win32 程序) |
| 依赖 | 无需第三方库(纯 ctypes/struct/argparse) | 环境干净,拷走就能跑 |
| 驱动 | 至芯 USB2XXX 驱动已安装 | 设备管理器能看到适配器 |
5.2 接线(三个引脚,接错必不通)
| USB2XXX | 接到 MCU 板 | 备注 |
|---|---|---|
| SCL | PE3(I2C0_SCL) | 复用 GPIO_AF_IIC0_M3 |
| SDA | PE2(I2C0_SDA) | 同上 |
| GND | GND | ★ 共地是必须的,只接两根信号线通信必失败 |
其他要点:
- 板子上有 4.7k 上拉更好;没有就用
EnablePu=1让适配器内部上拉救急(README里的说明); - 线越短越好(<20 cm),面包板飞线在 400 kHz 下容易出现随机 NACK;
- 别忘了 I2C 是开漏总线——MCU 侧
iic.c里 PE2/PE3 配的就是GPIO_Out_OD+9mA。
5.3 三步启动,认准"成功标志"
第一步:无硬件自检(强烈建议,先排除脚本自身问题)
cd host_tool
python usb2xxx_slave_bq76952.py --selftest
成功标志:末行打印 [selftest] 结果: PASS,且无 FAIL 行。 这一步覆盖 CRC8 与固件查表锚点比对、帧解析(正常/CRC 错/长度异常)、各寄存器取值与单位换算、子命令副作用等纯逻辑路径——不通就不要往下走了。
第二步:不上硬件,先解码一帧看看("协议可视化")
# 你自己抓的帧 / 或者手搓一帧给脚本解码
python usb2xxx_slave_bq76952.py --decode 14 3E 57 00
成功标志:打印出 parse_write_frame 的判定结果(kind/addr/data)与 CRC 校验结论。用它对照 MCU 侧 I2C_WriteReg 实际发出的字节,帧格式对不对,这里一眼就能判。
第三步:接上硬件,跑真从机
python usb2xxx_slave_bq76952.py --iic 0 --addr 0x14 --clock 400000
成功标志:
- 打印
[OK] USB2XXX 已打开/ 从机初始化成功(含从机地址、时钟); - MCU 复位后,PC 端持续刷出收到的帧与应答日志(
read_trigger/subcmd_write/subcmd_read交替出现); - MCU 侧遥测(CAN 上报或调试串口)显示单体电压 ≈ 3.6 V、组压 ≈ 57.6 V、温度 ≈ 25 ℃ —— 数值对得上,才算通了。
关键判据:不是"有日志",而是"MCU 解出来的数值正确"。有日志但 MCU 读回全 0,说明应答没赶上 2ms 窗口(§6.1)。
5.4 用 --config 注入场景(这功能才是联调的价值所在)
模拟器的全部默认值都放在一个 SIM 字典里,--config 加载的 JSON 按 key 覆盖(未知 key 会打印"忽略"而不是报错):
# 1) 先导出/手写一份只包含你要改的键
cat > ov.json <<'EOF'
{
"cell_volts_mV": [4250, 4200, 4180, 4240, 4210, 4230, 4190, 4220,
4205, 4185, 4215, 4195, 4200, 4235, 4188, 4212],
"internal_temp_0p1K": 3432,
"safety_status_a": 1,
"cb_active_cells": 32
}
EOF
python usb2xxx_slave_bq76952.py --config ov.json --iic 0 --addr 0x14
常用"造场景"对照表(SIM 的真实键名,照抄即可):
| 想验证什么 | 改哪个键 | 建议值 |
|---|---|---|
| 单体过压保护(COV) | cell_volts_mV | 抬高到 4200+(如全 4250) |
| 单体欠压保护(CUV) | cell_volts_mV | 压到 2500 附近 |
| 过温保护(OT) | internal_temp_0p1K | 3432 = 70 ℃ |
| 低温保护(UT) | internal_temp_0p1K | 2530 = −20 ℃ |
| 压差告警 | cell_volts_mV | 制造 300 mV 以上离散(如 3000 / 4200 混搭) |
| 安全状态告警 | safety_status_a/b/c | 置对应 bit(如 safety_status_c: 64 验 SCDL→A.bit0 合并) |
| 永久失效(PF) | pf_alert / pf_status | 置非零 |
| 均衡状态回报 | cb_active_cells | 32(=cell6 在均衡) |
| FET 初始状态 | fet_status | 0(放电关断)、1(仅充电) |
| SOC / 电流方向 | cc2_current_userA | 负值 = 充电 |
写好 JSON 的三个注意:① 电压数组长度决定"这套电池是几节",别少于固件要读的 16 节;② 温度是
0.1K,写摄氏度数会得到"−263 ℃"这种鬼数据;③ 一个字都不能带注释(JSON 不支持//)。推荐使用 VS Code 校验 JSON 格式,逗号多一个都会直接加载失败。
5.5 故障速查表(照着查,别猜)
| 现象 | 根因 | 处置 |
|---|---|---|
WinError 193 / 不是有效的 Win32 应用程序 | 用了 64 位 Python 加载 32 位 DLL | 换 32 位 Python(D:\python_32\python.exe) |
找不到 USB2XXX dll | --dll 默认路径是开发机上的路径,客户机上不存在 | 显式传 --dll .\USB2XXX.dll(或改脚本里的 DEFAULT_DLL) |
设备打开失败(USB2XXX_Init 返回 0) | 驱动未装 / 没插 / 被别的进程占用 | 设备管理器确认;关掉 usb2xxx_bq76952.py 等占用进程;多适配器时换 --iic |
进程随机崩溃 / WinError | 从机读缓冲区开小了,DLL 越界写 | 保持 512 字节缓冲(当前实现已修) |
| 有帧日志,但 MCU 读回全 0 | 应答没赶上 2 ms 窗口(最常见) | 见 §6.1:降 --clock、缩短日志、优先用预装载应答 |
CRC 全失败(bad_crc) | 帧格式不对:把"每字节独立 CRC"当成"整帧一个 CRC",或首个 CRC 忘了带地址 | 用 --decode 比对 MCU 实际发出的字节序列 |
| 只有第一帧通,后面全不通 | 从机应答与下一次"读触发"撞车 | 看 §6.2 的应答顺序要求 |
| 通信随线长变化、偶发 NACK | 无上拉 / 上拉不足 / 线太长 / 400 kHz 太快 | 加 4.7k 上拉、缩线、--clock 100000 |
| MCU 侧偶发死机在 I²C 等待 | 从机不响应导致主机超时(TIMEOUT_IIC 很小) | 从机侧日志确认每次都回包;必要时加大 MCU 侧超时 |
6. 时序:唯一真正的风险点与对策
6.1 2 ms 窗口 vs Python 往返
MCU 侧是死的:写偏移 → vTaskDelay(2) → 读应答。而 PC 侧要在这 2 ms 内完成"收帧 → 解析 → 查表 → 算 CRC → 组帧 → 调 IIC_SlaveWriteBytes"。Python + USB 的单趟往返通常 1~5 ms,余量很薄。
现象与对策:
| 现象 | 原因 | 对策 |
|---|---|---|
| MCU 读回全 0 / CRC 失败率高 | 应答没在窗口内准备好 | ① --clock 降速(如 --clock 100000);② 关掉逐帧日志打印(stdout 在 Windows 终端上会阻塞);③ 让 IIC_SlaveWriteBytes 在收到读触发后立刻调用,不要插入额外 sleep |
| 起初正常,跑几分钟后开始丢 | GC/日志缓冲/终端刷屏 | 日志改为写文件或降低详细度;避免在循环里做字符串拼接 |
| 偶发单字节错 | 总线毛刺(线长/上拉不足) | 缩线、加 4.7k 上拉;注意 MCU 侧 RX_CRC_Fail > 1 才丢帧,单字节错会被容忍 |
调优优先级:先降速 → 再减日志 → 最后才怀疑协议。
6.2 一个容易忽略的顺序要求
从机侧是被动式的:IIC_SlaveReadBytes 只有被调用时才收字节。如果写完应答后立刻又调 slave_read,正好落在主机"发偏移字节"的空隙里没问题;但如果上一轮应答还没写出去就开始收下一帧,USB2XXX 内部缓冲会错位,表现为"第一帧正常、之后全乱"。
所以主循环的顺序必须严格是:收帧 → 判断 → (读触发就)立刻写应答 → 再回到收帧。当前实现对 read_trigger/subcmd_read 都是"收到就写"(不缓存延迟回复),就是这个原因。
7. 证据边界:哪些是实测、哪些必须上板
已在 PC 侧实测通过(可复现):
| 结论 | 证据 |
|---|---|
| CRC8 实现与固件查表一致 | --selftest 锚点比对(CRC8(0x01)=0x07、CRC8(0x0E)=0x2A、CRC8(0x11)=0x77 等)全通过,末行 PASS |
| 帧解析(读触发/写/子命令/bad_crc/长度异常)正确 | --selftest 覆盖;--decode 可对任意帧复现 |
| 寄存器取值与单位换算正确 | selftest 打印:cell1 = 3605 mV、0x6A → 55.1 ℃(阈值)、0x70 → 25.1 ℃(测量)、0x9180 → 非零占位 |
Cell1 应答 = 15 78 0E 2A 00 | selftest 输出与独立按位算法计算的双向核对,逐字节一致 |
| 脚本在 32 位与 64 位 Python 下都能跑纯逻辑路径 | 32 位 3.11.9 与 64 位 3.11.9 各跑一次 --selftest,均 PASS(这两条路径不加载 DLL) |
未在本机证、必须上板/接真设备才能确认(诚实边界):
- USB2XXX 从机模式的实际吞吐与 2 ms 窗口能否稳定跟上——这依赖具体适配器固件、USB 时延、线长与主机负载,只能在接上 AS32x601 板子后实测(判据:MCU 侧遥测数值与模拟器设定一致,且跑 1 小时不出数值跳变);
- 400 kHz 下从机能否跟上——本机未实测;源码注释显示 MCU 侧 I2C0 是
ICLKDIVL=100(标 100 kHz),建议首轮联调就从--clock 100000开始; - 多包(3 包 / 6 包)场景——模拟器默认只绑一个从机地址
0x14,第二/三包的0x16/0x17当前实现未验证,需要另起进程(第二个 USB2XXX 通道/适配器)或扩展成多地址响应;scan_addr.py可用于确认总线上到底有哪些地址在 ACK; bms_protect.c的保护动作闭环(降档、FET 关断)——依赖整机与 CAN 上报,属上板验证范围;Bms_Startup_Configure()里写入 BQ 的那些子命令(CFGUPDATE/FET_ENABLE 等)在真芯片上的写入时序与 NVM 行为——模拟器只能验"帧格式与副作用逻辑",不能替代真芯片。
8. 快速上手清单(可直接复制,根据你本机实际 32 位 Python 路径修改这条命令。)
# 0) 准备: 32 位 Python; 脚本目录放 USB2XXX.dll + libusb-1.0.dll
cd <host_tool 目录>
# 1) 无硬件自检(必须先过这一步)
D:\python_32\python.exe usb2xxx_slave_bq76952.py --selftest
# 期望末行: [selftest] 结果: PASS
# 2) 不接硬件, 先对一帧(把你在 MCU 侧抓到的字节喂进来)
D:\python_32\python.exe usb2xxx_slave_bq76952.py --decode 14
# 期望: kind=read_trigger addr=0x14 / 应答数据 = ['0x15','0xe']
# 3) 接线: USB2XXX SCL->PE3, SDA->PE2, GND->GND(板上有 4.7k 上拉)
# 4) 起从机(硬件路径, 必须指 --dll)
D:\python_32\python.exe usb2xxx_slave_bq76952.py --iic 0 --addr 0x14 --clock 100000 --dll .\USB2XXX.dll
# 期望: USB2XXX 已连接 / 等待 BQ76952 主机命令... / 持续刷"读 0xXX -> [...]"
# 5) 造异常场景
D:\python_32\python.exe usb2xxx_slave_bq76952.py --config ov.json --dll .\USB2XXX.dll
# ov.json 里只写要改的键, 例如 {"cell_volts_mV":[4250,4250,...], "internal_temp_0p1K":3432}
# 6) 停: Ctrl+C(会打印统计: 帧/读/写/坏CRC)
成功标志(三步递进):① 自检 PASS;② --decode 能正确解释你抓到的帧;③ 接上板子后 MCU 遥测显示的电压/温度与模拟器设定值一致,且 Ctrl+C 时的统计里 坏CRC=0。
9. 小结与建议
这套"USB2XXX 扮 BQ76952"的方案,本质上是把协议层从黑盒变成白盒:
- 对 AS32x601 学习和验证 I²C 来说,它是最好的教具——你能同时看到主机侧每一条
I2C_Master_Transmit/Receive和从机侧收到的每一个字节,帧格式、CRC 归属、2 ms 时窗这些"书上看不出来的细节"全部现形; - 对 FAE 现场支持来说,一个 900 行的单文件脚本 + 一块 USB2XXX,就是随身的"假电池",客户报"读不到 BMS"时可以先自证驱动与协议,把问题空间砍一半。
接下来读:
- 对面那块 MCU 到底怎么发帧(引脚 → I2C0 → 原语 → 帧 → 采集任务)→《下篇:AS32x601 的 I²C 主机代码全解析:从引脚到采集任务》;
72

被折叠的 条评论
为什么被折叠?



