简介:面向STM32单片机和电池管理系统开发者,这是一份通过LTC6804-1读取级联电池组各节电压的完整软件工程源码,适用于电池管理项目前期的驱动验证与移植参考。压缩包共188个文件、约6.35MB,核心为32个C源文件与33个H头文件,并带有Keil工程配置(uvproj/uvopt)、可烧录HEX、AXF调试文件、MAP映射、启动文件及链接脚本,可直接打开工程查看或重新编译。当前已有1156人学习下载,是比较受关注的LTC6804级联电压采集例程。源码详细展示了RCC、GPIO、NVIC、SysTick、LTC6804初始化、USART与DMA配置等完整流程,主循环通过TimingDelay周期性触发LTC6804_adc采集,便于快速理解级联电池电压采样的工程结构和代码写法。
1. 用 STM32 SPI 吃掉 LTC6804-1 的级联电压,先过 PEC 这关再谈精度
12 节电芯串联成组,想每节电压误差小于 1mV,MCU 内置 ADC 基本是摆设。LTC6804-1 这颗电池监控芯片把电芯电压、温度、均衡开关全挂在一条 SPI 总线上,级联几片就能把几十节电芯的电压读数收回来。很多拿到工程源码的人第一反应是找“读电压函数”,结果翻遍驱动发现先要过 CRC 校验,也就是 PEC。这是因为 LTC6804-1 的命令和回包都带 8 位 CRC,任何一个字节错了整包数据都会被丢弃。这篇文章按工程落地顺序讲清三件事:SPI 报文里每个字节怎么来、PEC 怎么算、级联时读取顺序怎么排。适合正在调 BMS 采样板、或准备把现成源码工程移植到自家 STM32 板子上的工程师,看完能直接对着代码核对时序。
2. LTC6804-1 级联采样原理:从电芯电压到 SPI 报文的数据链路
2.1 一颗芯片怎么量 12 节电芯:内部 ADC 与测量通道的切换
LTC6804-1 单芯片支持 12 节串联电芯,每节电压范围是 0V 到 5V,内部集成了一个 16 位 ΔΣ ADC 和一个多路复用器。测量时并不是每个通道一个独立 ADC,而是通过 MUX 把所有电芯电压轮流切换到同一个 ADC 输入上。这样做的好处是通道一致性极好,坏处是如果你想同时拿到所有电芯的“瞬时值”,硬件层面做不到,只能靠连续扫描。
芯片内部还有几个关键模块:低压差稳压器给内部逻辑供电,带隙基准提供采样参考,以及一串均衡 MOSFET(DCC 引脚)可以配合软件做被动均衡。这些功能都通过配置寄存器组 CFGR0/CFGR1 控制,主机通过 SPI 写入配置值后,芯片才会按设定执行测量或均衡。
模块框图可以简化为:
电芯电压 -> MUX -> ΔΣ ADC -> ADC结果寄存器组 A/B/C/D ↑ SPI主机 -> 命令字+PEC -> 控制逻辑测量命令 ADCV 决定了四件事:转换模式(滤波深度)、是否包含辅助引脚或温度测量、电芯电压测量范围(是否含第三组),以及是否把结果存入寄存器组 A 到 D。寄存器组分为 A、B、C、D 四组,每组装 4 个电芯的 12 位结果,正好 12 节。
2.2 级联是怎么“穿”起来的:SPI 菊花链与数据输出顺序
-1 后缀代表标准 4 线 SPI 接口(-2 后缀才是 isoSPI 双线隔离接口)。级联时所有芯片共用 MOSI、MISO、SCLK、CS,从机之间按硬件连接位置形成菊花链。主机发起一条广播命令后,所有芯片同时执行;读回数据时,每片芯片按链的位置依次把寄存器内容移到 MISO 上。
读取顺序有个反直觉的地方:最先移出的是离主机最远的那片,最后移出的是离主机最近的那片。假设主机连接了 3 片,读取每片 8 字节(6 字节电压寄存器 + 2 字节 PEC),主机需要连续读 24 字节。第 1 个字节来自第 3 片,第 9 个字节来自第 2 片,第 17 个字节才轮到第 1 片。
这个顺序不是靠软件“数”出来的,而是 SCLK 翻转次数决定的。只要主机在读命令发出后连续产生 N×64 个 SCLK 时钟(每字节 8 位,N 为级联数),数据就会按链序依次被顶出来。所以级联数必须事先知道,否则数据会对不齐。
2.3 PEC 不是可有可无:CRC-8 防的是整组电压判错
LTC6804-1 在数据手册里把校验叫 PEC(Packet Error Checking),本质上是一个多项式为 x^8 + x^2 + x + 1(即 0x07)的 CRC-8,初始值为 0。发送命令时,主机需要把命令的两个字节和 PEC 一起发出去;读回数据时,芯片返回的每包数据尾部也带着 2 字节 PEC,主机必须自己算一遍再比对。
为什么要单独强调这一步?因为很多从 Arduino 或其他平台移植过来的代码,会忽略 PEC 校验直接解析电压字节。结果就是在实验室环境里跑得好好的,上了车规或工业现场后,偶发一个位反转导致电芯电压突变成 5.2V,BMS 直接报过压故障。PEC 不能保证每个 bit 都对,但能保证错误包不会被当作有效数据用。
PEC 的手算逻辑不复杂:对每个字节先异或到当前值,然后按位判断最高位是否置位,置位则左移后异或 0x07,否则只左移。工程上更快的做法是查表,256 字节的表格能把逐位循环变成三次查表加异或。
3. STM32 侧驱动实现:从 SPI 初始化和 PEC 计算到 ADC 转换命令
3.1 用 STM32CubeMX 把 SPI 配成 LTC6804-1 能认的时序
如果是从零开始写驱动,我建议直接用 STM32CubeMX 生成工程,把精力花在应用层而不是寄存器配置上。LTC6804-1 的 SPI 时序要求先确认四个参数:时钟极性、时钟相位、数据宽度、通信速率。
以下是我在工程里实际使用的配置表:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| SPI Mode | Master | 主机模式 |
| CPOL | Low | SCK 空闲时为低电平 |
| CPHA | 1 Edge | 第一个边沿采样数据 |
| Data Size | 8 bit | 命令和响应都是字节流 |
| NSS | Software | 软件控制 CS,方便级联时拉低保持 |
| Prescaler | 16 | 频率过高时走线电容会导致边沿变形 |
| First Bit | MSB First | LTC6804-1 要求高位先发 |
对应 HAL 库风格生成的初始化代码大致如下:
SPI_HandleTypeDef hspi2; void MX_SPI2_Init(void) { hspi2.Instance = SPI2; hspi2.Init.Mode = SPI_MODE_MASTER; hspi2.Init.Direction = SPI_DIRECTION_2LINES; hspi2.Init.DataSize = SPI_DATASIZE_8BIT; hspi2.Init.CLKPolarity = SPI_POLARITY_LOW; hspi2.Init.CLKPhase = SPI_PHASE_1EDGE; hspi2.Init.NSS = SPI_NSS_SOFT; hspi2.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_16; hspi2.Init.FirstBit = SPI_FIRSTBIT_MSB; HAL_SPI_Init(&hspi2); }时钟极性为 Low、相位为 1Edge,意味着 SCK 空闲低电平、数据在上升沿被采样。如果用了隔离芯片(比如数字隔离器),要额外考虑隔离芯片的传播延迟,这时应把波特率预分频加大一档,而不是靠降低 CPU 主频来迁就。主频 72MHz 时预分频 16 得到 4.5MHz SPI 时钟,这是我在 10cm 以内排线上验证过比较稳的组合。
3.2 命令帧与 PEC 的字节级构造:先写对 0x02 和 0x04 这两个操作码
LTC6804-1 的命令是 16 位,分两个字节发送。最常用的两条命令:ADCV 启动电压转换,完整命令字是 0x0260;RDCV 读取电压寄存器组,完整命令字是 0x0400。发送顺序是高字节在前、低字节在后,即先发 0x02 再发 0x60。
0x0260 里的 0x60 拆开看:bit7-6 是模式选择(这里取 10 对应快速模式),bit5 固定为 1,bit4 到 bit3 选择是否读辅助引脚和温度,bit2 到 bit1 是电压测量范围,bit0 固定为 0。调整这四个字段就能改变一次测量的对象和深度。滤波越深,转换时间越长,噪声越低,这是一个直接需要权衡的参数。
PEC 计算函数用查表法实现:
static const uint8_t pec_table[256] = { 0x00, 0x07, 0x0E, 0x09, 0x1C, 0x1B, 0x12, 0x15, // 其余 248 项由多项式 0x07 生成 }; uint8_t ltc6804_pec(uint8_t *data, uint8_t len) { uint8_t pec = 0x00; for (uint8_t i = 0; i < len; i++) { pec = pec_table[pec ^ data[i]]; } return pec; }查表表的生成代码可以在上位机里一次性算出来,烧进固件后运行期只需要三次异或和查表。注意 PEC 是逐字节计算的,命令的两个字节作为输入,返回一个字节的校验值。添加命令校验时,把校验字节追加在命令字节后面发送。
如果不想维护 256 字节的表格,也可以用逐位循环版本,但 SPI 中断里调用时要留意时间开销。我一般用查表版本,因为它在任何 Cortex-M 内核上都能做到微秒级完成。
3.3 一次电压转换的完整时序:从 SPI 写命令到读回应答
一次完整测量的流程是:拉低 CS,发送 ADCV 命令加 PEC,拉高 CS,等待转换完成,再拉低 CS,发送 RDCV 命令加 PEC,连续读回 8 字节,拉高 CS。其中等待时间取决于滤波模式,快速模式约 1.4ms,正常模式约 3ms,过滤模式则需要十几毫秒到几十毫秒。
驱动函数可以这样组织:
void ltc6804_adcv_fast(void) { uint8_t cmd[4]; cmd[0] = 0x02; // ADCV 高字节 cmd[1] = 0x60; // 快速模式、读所有电芯电压 cmd[2] = ltc6804_pec(cmd, 2); cs_low(); hal_spi_write(cmd, 3); cs_high(); }读回部分用 HAL 的接收函数,注意要先发命令字节再收数据:
void ltc6804_rdcv(uint8_t *rx_buf) { uint8_t cmd[4]; cmd[0] = 0x04; cmd[1] = 0x00; cmd[2] = ltc6804_pec(cmd, 2); cs_low(); hal_spi_write(cmd, 3); hal_spi_read(rx_buf, 8); // 6 字节电压 + 2 字节 PEC cs_high(); }电压数据每 3 字节装 2 个电芯的结果,每个电芯占 12 位。解析时要先把 3 字节拼成 24 位整数,再按低 12 位和高 12 位拆分。这个“拼字节”的位操作是新手最容易写错的地方,后面章节会给出可以直接抄的解析代码。
4. 级联场景下的软件组织:从多板堆叠的地址分配到读取顺序的容错
4.1 多片级联的硬件连接与软件“编号”约定
级联 3 片就意味着要读 36 节电芯,硬件上所有片子的 SCLK、MOSI、MISO、CS 并联到 STM32 的同一组 SPI 引脚。芯片本身没有地址引脚,区分不同片子靠的是它们在菊花链上的物理位置。软件里需要用宏或枚举把位置编号固定下来:
#define LTC6804_CHAINED_DEV_NUM 3 typedef enum { DEV_TOP = 0, /* 离主机最远 */ DEV_MIDDLE = 1, DEV_BOTTOM = 2 /* 离主机最近 */ } ltc6804_dev_id_t;编号本身不发给芯片,只是让代码在拼接数据时知道自己正在处理的字节属于哪一片。如果后续硬件改版调换了板子顺序,不要只改宏,还要重新核对读取顺序,这是最常见的移植坑。
4.2 广播转换加连续读取:一条命令把 36 节电芯数据拼出来
级联模式下,电压转换命令仍然可以广播,所有芯片同时对自己管理的电芯执行测量。但读取命令发出后,主机必须一次性读完所有芯片的数据。假设级联 3 片,每片返回 8 字节,那么主机在发送 RDCV 后要连续接收 24 字节:
#define CELLS_PER_DEV 12 #define BYTES_PER_DEV 8 #define TOTAL_DEV 3 uint16_t cell_voltage_mv[TOTAL_DEV][CELLS_PER_DEV]; void ltc6804_read_all_voltage(void) { uint8_t cmd[4]; uint8_t raw[TOTAL_DEV * BYTES_PER_DEV]; cmd[0] = 0x04; cmd[1] = 0x00; cmd[2] = ltc6804_pec(cmd, 2); cs_low(); hal_spi_write(cmd, 3); hal_spi_read(raw, sizeof(raw)); cs_high(); for (int dev = 0; dev < TOTAL_DEV; dev++) { uint8_t *p = &raw[dev * BYTES_PER_DEV]; uint8_t pec_rx = p[6]; uint8_t pec_calc = ltc6804_pec(p, 6); if (pec_rx != pec_calc) { continue; /* 本片数据放弃,保留上一次有效值 */ } for (int i = 0; i < 6; i += 3) { uint32_t v = (uint32_t)p[i] << 16 | (uint32_t)p[i+1] << 8 | p[i+2]; cell_voltage_mv[dev][i/3 * 2] = (v >> 12) & 0x0FFF; cell_voltage_mv[dev][i/3 * 2 + 1] = v & 0x0FFF; } } }代码里有两个关键点。第一是先校验 PEC 再解析电压,校验失败时跳过该片,而不是用脏数据覆盖上一次的好结果,这个策略在 BMS 里叫“数据保持”。第二是拼接位序:每 3 字节作为一个 24 位整数,高 12 位是编号靠近 A 组的电芯,低 12 位是另一个电芯。偏移量的计算要跟数据手册里的寄存器映射表核对,不能想当然。
4.3 均衡开关对电压读数的影响与级联下的超时判定
被动均衡在工作时会把电芯通过内部 MOSFET 放电,这会导致该节电芯电压读数略微偏低。如果你在均衡期间读电压用来做 SOC 估算,会得到偏保守的结果。工程上两种处理:一种是均衡期间不更新电压缓存,另一种是读完后对均衡中的电芯补一个经验补偿值。前者简单可靠,后者省电但需要标定。
级联下还有一种隐蔽故障:某一片芯片没有正常上电或 SPI 接线虚焊,读回的数据全是 0xFF 或 0x00,PEC 大概率对不上。此时不要立刻判定是这片坏了,先检查它在链上的前一片是否在正常输出,因为前一片的输出时序异常会把整条链的数据全部挤偏。常见做法是在读取循环里给每片加上超时计数,连续 5 次 PEC 失败就置故障标志位,并通过串口打印是哪一片的哪一包数据出错。
5. 用逻辑分析仪核对报文:验证 PEC 计算与移植现成工程源码的技巧
拿到一份现成的 LTC6804-1 工程源码,第一步不是看主函数,而是先找到 PEC 相关的两个函数:查表法和逐位法。用一份已知的报文数据在 PC 上算一遍,和固件输出比对。可以用下面这段 Python 代码在本地验证抓包得到的报文 PEC 是否正确:
def pec_calc(data): pec = 0x00 for b in data: pec ^= b for _ in range(8): if pec & 0x80: pec = ((pec << 1) ^ 0x07) & 0xFF else: pec = (pec << 1) & 0xFF return pec # 抓包得到的一帧 RDCV 响应:6 字节数据 + 2 字节 PEC resp = bytes.fromhex("22 33 44 55 66 77 A1 B2") print(hex(pec_calc(resp[:6]))) # 期望等于 0x??如果算出的值和抓包尾部两个字节不同,先检查抓包软件是否把 MISO 上的空闲电平误判为数据位。LTC6804-1 的 MISO 在 CS 拉高后是高阻态,如果逻辑分析仪没有接上拉电阻,空闲电平会漂移,抓到一串随机数据。
验证通过后,再移植到自己的 STM32 工程。如果原工程是标准外设库写的,而你现在用 HAL 库,不要逐行翻译寄存器操作,而是把 LTC6804 驱动封装成独立的 C 文件,只暴露ltc6804_init、ltc6804_adcv、ltc6804_read_all_voltage三个接口,底层把 HAL 的 SPI 句柄作为结构体成员传进去。这样换芯片型号时只需要改结构体里的 hspi 指针。
最后的实测验证建议:把读回来的原始电压值和万用表测量值对比,记录两者偏差。LTC6804-1 在正常模式下精度应该在 1mV 左右,如果偏差超过 5mV,优先排查参考电压引脚的去耦电容是否靠近芯片,以及 SPI 时钟频率是否因为走线过长导致采样时刻偏移。用 ST-LINK Utility 直接查看运行中变量比串口打印更快,但要注意调试器冻结 CPU 时 SPI 时序也会停住,此时读数会静止在最后一帧。
本文还有配套的精品资源,点击获取