
简介针对STM32F072C8T6与中颖SH367309电池模拟前端的I2C通信开发需求这套实验工程演示了16串锂离子电池组电压、电流、温度的采集与管理方案。工程基于HAL/LL库编写从硬件接线、I2C外设初始化、从机地址配置到读写时序、数据解析和异常保护完整覆盖了BMS模拟前端通信的关键链路。开发者可据此掌握I2C多字节读写、寄存器配置、CRC校验失败处理等实用技巧并移植到电动车、储能系统等需要多节电池串联监测的实际项目中。资源包包含228个文件压缩后约5.31MB。除C/H源码外还提供了Keil的uvprojx/uvproj工程文件、启动与链接脚本、axf/hex/map等编译产物、以及PDF说明文档目录结构清晰便于直接打开工程验证或进行二次开发。目前已有1606人学习下载适合有一定嵌入式基础、正在学习电池管理通信协议或准备BMS项目开发的工程师参考借鉴。1. 软件 I2C 与 SH367309 电池模拟先搞清楚为什么要绕开硬件 I2C做电池保护实验时引脚紧张是常态。SH367309 这类电池管理芯片自带 I2C 接口但很多人拿到 STM32F072 之后第一反应是用硬件 I2C结果被超时、总线锁死、时序不匹配折腾半天。软件 I2C 在这种场景下反而更可靠SCL 和 SDA 用任意两个 GPIO 输出时序完全自己控制SH367309 的寄存器读写节奏可以按手册拉满调试时也能随时在中间插断点。这篇文章把 STM32F072C8T6 上的软件 I2C 实验讲透覆盖时序拆解、寄存器读写框架、模拟电压写回和示波器验证适合正在做 BMS 测试、需要在不接真实电芯的情况下验证保护逻辑的工程师也适合第一次接触软件 I2C 的嵌入式开发者。整个实验的核心就是把“读总线状态”和“模拟电池状态”这两件事拆开再用 I2C 协议粘合起来。2. STM32F072C8T6 软件 I2C 时序实现与引脚配置2.1 软件 I2C 的时序拆解起始、停止、应答和 SCL 节奏软件 I2C 说到底是用 GPIO 模拟总线协议。两根线上只有三个状态高电平、低电平和高阻/输入。起始条件是在 SCL 为高时把 SDA 从高拉低停止条件则是在 SCL 为高时把 SDA 从低拉高。传送一个字节时高位在前每个 bit 要在 SCL 低电平期间改变 SDA然后 SCL 拉高让接收方采样最后是第 9 个时钟的 ACK 位。理解这个节奏比背协议更能解决实际问题因为调试时你面对的是示波器上的边沿而不是函数名。为什么要绕开硬件 I2CSTM32F072 的硬件 I2C 在早期固件库版本里有总线忙检测误判的问题出错后需要反复配置外设复位处理起来很烦。做 SH367309 这种电池模拟实验时往往还要同时控制 ADC、DAC 和指示灯用软件 I2C 可以把 SCL 和 SDA 安排到任意引脚布线方便逻辑也更直观。只要延时控制得住软件 I2C 完全能稳定跑在 100kbps 甚至 400kbps。下面从 GPIO 配置开始把每一段代码写清楚。2.2 GPIO 配置开漏输出与上拉电阻的配合软件 I2C 的信号质量取决于引脚配置。最常见的错误是有人用推挽输出把 SDA 强拉高结果读 ACK 时从机拉不动这条线通信永远失败。正确做法是用开漏输出这样芯片只负责把线拉低高电平由上拉电阻完成。下面这段初始化代码基于 STM32F072 的 HAL 库void i2c_gpio_init(void) { GPIO_InitTypeDef gpio {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); gpio.Pin GPIO_PIN_8 | GPIO_PIN_9; /* SCLPA8, SDAPA9可与原理图任意互换 */ gpio.Mode GPIO_MODE_OUTPUT_OD; /* 开漏输出不主动拉高 */ gpio.Pull GPIO_PULLUP; /* 内部上拉保证空闲高电平 */ gpio.Speed GPIO_SPEED_FAST; HAL_GPIO_Init(GPIOA, gpio); /* 初始化后先把两根线置 1开漏模式下等于释放总线 */ HAL_GPIO_WritePin(GPIOA, GPIO_PIN_8 | GPIO_PIN_9, GPIO_PIN_SET); }这段代码里最关键的是开漏输出模式。开漏模式下HAL_GPIO_WritePin(GPIO_PIN_SET)并不是“强输出高”而是把输出驱动器关闭让外部上拉电阻把电平抬到 VCC。这样后面读 ACK 位时从机能把 SDA 拉低。内部上拉约 30kΩ 到 50kΩ适合短距离实验板如果总线走线较长建议在外部再并联 4.7kΩ 上拉否则高速通信时边沿会变缓SH367309 可能采样到错误的电平。引脚选择上PA8 和 PA9 只是示例。STM32F072 几乎每个 GPIO 都支持开漏输出但要注意不要让 SCL/SDA 和其他外设复用。比如 PA9 可能是 USART1_TX如果调试串口也占用它需要换引脚。软件 I2C 的优势就在这里调整宏定义和 GPIO 初始化即可不需要动底层外设。2.3 微秒级延时为什么不能用 HAL_DelayI2C 的速率由 SCL 高低电平的持续时间决定。标准模式 100kbps 要求 SCL 低电平最小约 4.7us快速模式 400kbps 要求约 1.3us。HAL_Delay 最小单位是 1ms用来做 I2C 时序等于让 SCL 工作在几百 HzSH367309 也能响应但实验效率太低而且示波器上看不出真实波形。常见的做法是用空循环或 SysTick 做微秒延时。static void i2c_delay_us(uint32_t us) { /* 48MHz 主频下循环体约 3 个时钟周期 */ uint32_t n us * (SystemCoreClock / 1000000U) / 3U; while (n--) { __NOP(); } }这里用一个简单的参数换算SystemCoreClock / 1000000得到 1us 有多少个时钟周期再除以 3 是因为while加__NOP()大约 3 个周期。这个值不是精确的因为编译优化级别不同循环周期会有差异。正确的做法是先编译一次观察延时函数的反汇编或者直接在调试器里跑一遍看耗时。后续时序参数调优时这个延时会作为基础单位所以先留一个可调的宏方便统一修改。2.4 时序核心代码起始、停止、读写字节下面是一套最小可用的软件 I2C 底层直接写在实验工程里即可运行。代码里没有区分输入输出模式因为开漏模式下只需要通过写 ODR 释放或被拉低再读 IDR 就能拿到总线电平省去来回切换的麻烦。#define SCL_H() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_8, GPIO_PIN_SET) #define SCL_L() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_8, GPIO_PIN_RESET) #define SDA_H() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_9, GPIO_PIN_SET) #define SDA_L() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_9, GPIO_PIN_RESET) #define SDA_READ() HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_9) void i2c_start(void) { SDA_H(); SCL_H(); i2c_delay_us(5); SDA_L(); /* SCL高期间SDA由高变低总线进入忙碌 */ i2c_delay_us(5); SCL_L(); } void i2c_stop(void) { SDA_L(); SCL_H(); i2c_delay_us(5); SDA_H(); /* SCL高期间SDA由低变高总线释放 */ i2c_delay_us(5); } void i2c_write_byte(uint8_t data) { for (int i 7; i 0; i--) { if (data (1 i)) SDA_H(); else SDA_L(); i2c_delay_us(1); SCL_H(); /* 从机在SCL高电平期间采样 */ i2c_delay_us(4); SCL_L(); i2c_delay_us(1); } /* 第9个时钟释放SDA并读ACK */ SDA_H(); i2c_delay_us(1); SCL_H(); i2c_delay_us(4); (void)SDA_READ(); /* 0表示从机应答1表示无应答 */ SCL_L(); i2c_delay_us(1); } uint8_t i2c_read_byte(uint8_t ack) { uint8_t data 0; SDA_H(); /* 开漏模式下释放总线让从机驱动数据 */ for (int i 7; i 0; i--) { SCL_H(); i2c_delay_us(4); data (data 1) | SDA_READ(); /* 从高位开始采集 */ SCL_L(); i2c_delay_us(2); } /* 主机在最后一个字节返回 ACK 或 NACK */ if (ack) SDA_L(); /* ACK继续读下一个字节 */ else SDA_H(); /* NACK告诉从机读数结束 */ SCL_H(); i2c_delay_us(4); SCL_L(); SDA_H(); return data; }逻辑说明i2c_write_byte先发送 8 个数据位第 9 个时钟释放 SDA 并采样 ACK 位。i2c_read_byte在每个 SCL 高电平期间读取 SDA 电平收满 8 位后根据参数决定是否应答。参数ack只有在读最后一个字节时才传 0其他情况传 1。这套代码没有处理总线超时如果 SH367309 工作异常SCL 拉高后 SDA 可能一直处于某种状态需要调用方在循环里加超时判断。下面这张表可以对照时序分析工具里的波形信号阶段SCL 电平SDA 电平说明起始条件HH 转 L总线占用数据位 0LL随后 SCL 升 H从机在上升沿采样数据位 1LH随后 SCL 升 H同上ACK 位第 9 个 HL从机应答停止条件HL 转 H总线释放这套底层一旦调通上层读写 SH367309 就只是把寄存器地址和方向位拼起来逻辑上很直接。但 SH367309 的寄存器映射不是所有版本都一样下一章展开讲寻址和电池模拟这两件关键事。3. SH367309 的寄存器映射与电池模拟原理3.1 寄存器分组与访问方式SH367309 是面向多串锂电池组的保护/管理前端内部集成了电压采样、电流检测、温度采集和保护逻辑并通过 I2C 把状态暴露给 MCU。访问方式分两步先写从机地址再写寄存器地址之后做数据写入或读取。从机地址由芯片引脚或配置位决定不同封装、不同版本可能不一样因此实验代码里应该把地址写成宏而不是硬编码到每个函数里。寄存器通常按功能分成几类电压采样寄存器、电流采样寄存器、温度采样寄存器、配置寄存器和状态寄存器。实验过程中需要重点关注的是“只读状态”和“读写配置”这两类。只用 I2C 读电压时不写配置芯片用默认参数就能工作但要做电池模拟就一定要往配置寄存器里写阈值再通过状态寄存器确认保护动作。功能域典型内容实验中的用途电压采样寄存器每节电芯电压码值判断过压、过放电流采样寄存器充放电电流码值判断过流保护方向温度采样寄存器热敏电阻 ADC 值模拟高温报警配置寄存器保护阈值、使能位修改实验边界条件状态寄存器过压/过放/过流标志验证保护是否触发每个寄存器的位宽不一定是 8 位有些电压值会拆成高字节和低字节也就是 16 位数据。读的时候需要注意字节序。常见的做法是先读高字节再读低字节左移后合并。个别版本可能相反实验代码里最好用一个read_u16函数来封装统一处理字节序。3.2 电池模拟的两种做法电池模拟不是直接把一个大电压灌进芯片而是让 SH367309 正确感知“当前电池组的状态”。在研发验证阶段真实电芯的充放电耗时很长而且过压、过流测试有安全风险所以实验室更愿意用模拟信号来重复触发。第一种做法是信号替换法。用一个可调电源或 DAC 输出经过电阻分压后接到 SH367309 的采样引脚。MCU 通过 I2C 读回电压码值再根据反馈调整 DAC 输出形成闭环。这样不仅能模拟静置电压还能模拟充电时电压缓慢上升、放电时电压下降的过程保护逻辑可以完整看到。第二种做法是阈值写回法。直接通过 I2C 修改过压保护阈值把一个本来需要 4.5V 才能触发的保护改成 3.9V 触发然后用一个固定 4.0V 的模拟电压就能快速验证保护位能不能正常置 1。两种做法不冲突。我一般会建议实验板上同时预留 DAC 输出和跳线开关平时用跳线接固定分压验证通信调试保护逻辑时接入 DAC做动态模拟。这样软件 I2C 的角色从“读状态”扩展成“写配置”实验维度更接近真实 BMS。3.3 从寄存器码值到实际电压的换算电压寄存器里存的是 ADC 采样后的码值不是直接毫伏数。SH367309 的数据手册里会给出 LSB 的含义常见的是 1mV/LSB 或 0.5mV/LSB。如果忽略这个值读出来的数会偏差很大后续比较阈值也没有意义。换算代码很简单uint16_t adc_voltage i2c_read_u16(REG_VCELL_H); uint16_t millivolt adc_voltage * SH367309_LSB_MV; /* LSB_MV 换成手册实际值 */这里的SH367309_LSB_MV要在头文件里按手册定义成 1 或 0.5。注意别把高字节和低字节读反否则结果会像个随机数。另一个容易踩的坑是某些版本的状态寄存器是“写 1 清 0”当保护触发后MCU 读完状态寄存器不能直接丢弃需要按手册要求写入清除位否则下次触发时标志位不会更新模拟实验会误判为保护不生效。4. 软件 I2C 读写 SH367309 的完整实验代码4.1 寄存器读写函数封装前一章的 ACK 检测只是(void)忽略掉了真正做实验时建议把 ACK 结果返回这样排查问题会快很多。下面是一个带简单 ACK 判断的版本结合了前面的底层函数uint8_t i2c_write_byte_with_ack(uint8_t data) { uint8_t ack 1; for (int i 7; i 0; i--) { if (data (1 i)) SDA_H(); else SDA_L(); i2c_delay_us(1); SCL_H(); i2c_delay_us(4); SCL_L(); i2c_delay_us(1); } SDA_H(); i2c_delay_us(1); SCL_H(); i2c_delay_us(4); ack SDA_READ(); /* 0 表示有应答1 表示无应答 */ SCL_L(); i2c_delay_us(1); return ack; }有了这个函数寄存器读写可以这样封装#define SH367309_ADDR 0x0C /* 7 位地址换成手册实际值 */ uint8_t i2c_read_reg(uint8_t reg) { uint8_t val 0; i2c_start(); i2c_write_byte_with_ack((SH367309_ADDR 1) | 0); /* 写方向 */ i2c_write_byte_with_ack(reg); i2c_start(); /* repeated start地址后切换读方向 */ i2c_write_byte_with_ack((SH367309_ADDR 1) | 1); val i2c_read_byte(0); /* 读 1 字节主机 NACK */ i2c_stop(); return val; } void i2c_write_reg(uint8_t reg, uint8_t val) { i2c_start(); i2c_write_byte_with_ack((SH367309_ADDR 1) | 0); i2c_write_byte_with_ack(reg); i2c_write_byte_with_ack(val); i2c_stop(); }逻辑说明i2c_read_reg先发出一遍起始条件和写方向的从机地址再发寄存器地址然后再次发起始条件切换为读方向。第二次起始是 I2C 协议里的 repeated start总线没有被释放SH367309 能正确识别接下来是读操作。i2c_write_reg更简单一次就把地址、寄存器号和数据写完。如果i2c_write_byte_with_ack返回 1应该认为本次通信失败直接退出并置错误标志。4.2 多字节读取一次读连续寄存器电池模拟中经常要连续读电压、电流、温度多个寄存器。逐个读虽然能工作但每次都要发起始、地址、寄存器号效率低而且状态不一致的可能性更大。多字节读取用前面底层函数很容易实现void i2c_read_regs(uint8_t start_reg, uint8_t *buf, uint8_t len) { if (len 0) return; i2c_start(); i2c_write_byte_with_ack((SH367309_ADDR 1) | 0); i2c_write_byte_with_ack(start_reg); i2c_start(); i2c_write_byte_with_ack((SH367309_ADDR 1) | 1); for (uint8_t i 0; i len; i) { buf[i] i2c_read_byte(i (len - 1)); /* 除最后一字节外都回 ACK */ } i2c_stop(); }参数说明start_reg是起始寄存器地址buf是接收缓冲区len是期望读取的字节数。循环里最后一个字节传 0即 NACK通知从机“不需要继续发数据了”。如果传入len为 0函数直接返回避免出现空循环引发总线错误。这个函数适合读取电压的高字节和低字节一次拿回来再合并不容易出现高低字节不同步的问题。4.3 写模拟电压到 SH367309 的流程如果实验板把 DAC 输出接到了 SH367309 的采样引脚那么“模拟电池”的代码可以分成两部分一部分是设置 DAC 输出电压另一部分是通过 I2C 读 SH367309 的反馈。下面是一段主循环示意uint16_t sim_mv 4200; /* 模拟电池电压 4.2V */ while (1) { /* 1. 把模拟目标值写到 DACDAC 驱动自行实现 */ dac_set_voltage_mv(sim_mv); /* 2. 从 SH367309 读取实际采样码值 */ uint16_t measured_mv sh367309_read_voltage_mv(); /* 3. 打印或通过串口发送出来 */ printf(sim%d, measure%d\n, sim_mv, measured_mv); /* 4. 模拟放电过程每次递减 1mV */ if (sim_mv 3000) { sim_mv--; } HAL_Delay(5); }这段代码里的sh367309_read_voltage_mv应该读取电压寄存器的高字节和低字节然后按 LSB 换算成毫伏。dac_set_voltage_mv是外部 DAC 芯片的驱动如果实验板没有 DAC也可以改用定时器 PWM 加 RC 滤波但 PWM 纹波会干扰 SH367309 的 ADC 采样精度有限。主循环里的HAL_Delay(5)控制模拟电压变化速度每 5ms 降 1mV从 4.2V 到 3.0V 需要 6 秒足够观察保护标志变化。代码写完后先不要急着让模拟电压跑起来应该先做一个只读实验读回 SH367309 的版本寄存器确认 I2C 通信稳定再开始写 DAC。这样出了问题能区分是软件 I2C 的问题还是电池模拟前端的问题。5. 让实验在示波器上可验证时序参数的调整与常见坑5.1 用逻辑分析仪抓总线先看 ACK 位I2C 调试验证不是靠眼睛看打印值而是要用逻辑分析仪或示波器抓 SCL 和 SDA 两条线。抓数时把触发模式设为 SDA 下降沿采样率建议 25MHz 以上这样能看清 4us 级别的边沿。抓一段写寄存器操作后数一下 SCL 周期从机地址 8 位加方向位 1 位加 ACK 1 位再寄存器地址 8 位加 ACK如果写入成功在第 9 个、第 18 个、第 27 个时钟后 SDA 都应该被拉低。如果 ACK 位一直是高说明 SH367309 没有参与通信优先检查供电和芯片复位脚。5.2 三个必调参数参数作用调大效果调小效果bit 延时控制 SCL 高低电平持续时间更稳定但速率低可能不满足时序start 后延时保证起始条件建立时间从机更容易识别可能漏检测stop 后延时保证总线释放时间多设备更安全下次 start 可能失败实操时不要把上面三个参数一个个乱试。先全部加倍让每个延时变成 10us确认整个链路能工作再逐步减半。每次减半后用逻辑分析仪抓一次波形观察 SDA 和 SCL 的边沿有没有出现明显振铃。一旦出现振铃退回上一个延时档位。这个办法比直接按手册算可靠因为 PCB 走线电容和 GPIO 驱动能力每一块板都不同。5.3 电池模拟失败时的检查顺序实验不成功时按顺序排查比东改西改效率高。如果总线上根本没有波形先看 GPIO 是否被其他外设占用再用万用表量 SCL/SDA 空闲电平是否为高。如果读回全是 0xFF说明从机没有应答问题集中在地址配置或上拉电阻。如果电压能读回来但数值不变化模拟电压源没有正确接到采样引脚或者分压电阻把信号短路了。如果保护一直不触发不要只查 I2C先确认配置寄存器写成功再检查状态寄存器是否需要写 1 清 0。唯一可靠的办法是把每一步都放在逻辑分析仪抓到的时序里核对不要靠肉眼猜。一个值得保留的习惯是把i2c_delay_us里的延时值定义成宏在头文件里统一管理。这样调试时改一处就能调整整个总线的速度等 SH367309 通信稳定后再慢慢把这个宏的值往下压直到接近手册允许的最小时序。本文还有配套的精品资源点击获取