手撸 BQ76952 模拟器:用 Python 造一颗 I²C 从机芯片

核心钩子:没有真芯片,怎么调 BMS? 平台:AS32x601(RISC-V,国科安芯)× TI BQ76952 × 至芯 USB2XXX 场景: 电池管理(BMS)软件,BQ76952 I2C0 关键词:I²C 从机模拟器 / CRC8 帧协议 / 直接命令与子命令 / 联调替代真芯片

本系列共两篇:本篇造"假芯片" → 《下篇:AS32x601 的 I²C 主机代码全解析:从引脚到采集任务》 。


0. 没有真芯片,怎么调 BMS?

BMS 软件联调有个经典困境:

  1. 芯片不在手上/板子焊好但电池包没接 —— 想调 I²C 驱动,读回全是 0,分不清是"驱动写错"还是"芯片没接";
  2. 真芯片是黑盒 —— 你想验证"安全状态字 bit0 置 1 时 MCU 会不会正确上报告警",真芯片不会配合你摆姿势;
  3. 故障注入几乎不可能 —— 过压/过温/短路放电这些保护路径,靠真电池包去复现代价极高且危险。 ⚠️本模拟器仅软件层面模拟告警状态,不会产生真实高压大电流,所有真实电池测试务必做好防护。

于是思路反过来:让 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)0x14drv_bq76952.cDEV_ADDR[] = {0x12, 0x12, 0x14}
8bit 写地址0x28DEV_ADDR_REAL = 0x28(0x14<<1)
8bit 读地址0x290x28 + R/W 位
总线速率固件侧 I2C0 ICLKDIVL = 100(源码注释标 100 kHz)iic.c
引脚I2C0:SCL=PE3、SDA=PE2,复用 GPIO_AF_IIC0_M3iic.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);
  • 后续每个数据字节各自单独算 CRCCi = 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], &reg_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

特殊分支:

  • 收到单字节 0x40subcmd_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/DDSG29782985(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 转 mVBQ769x2_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 子命令的副作用表(这部分决定你能造哪些场景)

子命令名称对模拟器状态的影响
0x0022FET_ENABLECHG+DSG 置 1
0x0096ALL_FETS_ONFET 状态 = CHG|DSG
0x0095ALL_FETS_OFFFET 状态 = 0(最常用的"拉闸"实验
0x0093DSG_PDSG_OFF清 DSG、PDSG 位
0x0094CHG_PCHG_OFF清 CHG、PCHG 位
0x001FCHGTEST关充电 FET
0x0020DSGTEST关放电 FET
0x000F / 0x000EDEEPSLEEP / EXIT_DEEPSLEEPbattery_status bit2 置/清
0x0090 / 0x0092SET_CFGUPDATE / EXIT_CFGUPDATEbattery_status bit1 置/清
0x0012BQ769x2_RESETFET 复位为 CHG|DSG,清 battery_status bit1/bit2
0x0029PF_RESETpf_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--软件开发指南.pdfzhcu948b---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 环境准备(三件事,任缺一件都起不来)

要求为什么
Python32 位(如 D:\python_32\python.exeUSB2XXX.dll 是 32 位,64 位 Python 加载会报 WinError 193(不是有效的 Win32 程序)
依赖无需第三方库(纯 ctypes/struct/argparse环境干净,拷走就能跑
驱动至芯 USB2XXX 驱动已安装设备管理器能看到适配器

5.2 接线(三个引脚,接错必不通)

USB2XXX接到 MCU 板备注
SCLPE3(I2C0_SCL)复用 GPIO_AF_IIC0_M3
SDAPE2(I2C0_SDA)同上
GNDGND★ 共地是必须的,只接两根信号线通信必失败

其他要点:

  • 板子上有 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

成功标志

  1. 打印 [OK] USB2XXX 已打开 / 从机初始化成功(含从机地址、时钟);
  2. MCU 复位后,PC 端持续刷出收到的帧与应答日志(read_trigger / subcmd_write / subcmd_read 交替出现);
  3. 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_0p1K3432 = 70 ℃
低温保护(UT)internal_temp_0p1K2530 = −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_cells32(=cell6 在均衡)
FET 初始状态fet_status0(放电关断)、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)=0x07CRC8(0x0E)=0x2ACRC8(0x11)=0x77 等)全通过,末行 PASS
帧解析(读触发/写/子命令/bad_crc/长度异常)正确--selftest 覆盖;--decode 可对任意帧复现
寄存器取值与单位换算正确selftest 打印:cell1 = 3605 mV0x6A → 55.1 ℃(阈值)0x70 → 25.1 ℃(测量)0x9180 → 非零占位
Cell1 应答 = 15 78 0E 2A 00selftest 输出与独立按位算法计算的双向核对,逐字节一致
脚本在 32 位与 64 位 Python 下都能跑纯逻辑路径32 位 3.11.9 与 64 位 3.11.9 各跑一次 --selftest,均 PASS(这两条路径不加载 DLL)

未在本机证、必须上板/接真设备才能确认(诚实边界)

  1. USB2XXX 从机模式的实际吞吐与 2 ms 窗口能否稳定跟上——这依赖具体适配器固件、USB 时延、线长与主机负载,只能在接上 AS32x601 板子后实测(判据:MCU 侧遥测数值与模拟器设定一致,且跑 1 小时不出数值跳变);
  2. 400 kHz 下从机能否跟上——本机未实测;源码注释显示 MCU 侧 I2C0 是 ICLKDIVL=100(标 100 kHz),建议首轮联调就从 --clock 100000 开始
  3. 多包(3 包 / 6 包)场景——模拟器默认只绑一个从机地址 0x14,第二/三包的 0x16/0x17 当前实现未验证,需要另起进程(第二个 USB2XXX 通道/适配器)或扩展成多地址响应;scan_addr.py 可用于确认总线上到底有哪些地址在 ACK;
  4. bms_protect.c 的保护动作闭环(降档、FET 关断)——依赖整机与 CAN 上报,属上板验证范围;
  5. 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 主机代码全解析:从引脚到采集任务》;
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值