简介:这套源码面向STM32单片机开发者与电池管理系统研究人员,完整实现了通过LTC6804-1芯片读取级联电池电压的功能,工程基于STM32F10x系列,包含RCC、GPIO、串口、DMA及LTC6804初始化等核心配置,可直接在Keil环境中打开编译,也可作为BMS电压采集模块的参考实现。资源包共188个文件,压缩后大小6.35MB,以C语言源码(.c)、头文件(.h)为主,并附带Keil工程文件(.uvproj)、编译中间文件(.o、.crf)及启动文件(.s),便于查看完整构建过程与调试入口。已有1156人学习下载,适合需要上手LTC6804驱动或了解级联电池电压采集逻辑的嵌入式工程师。通过阅读main函数与LTC6804_adc等关键代码,可掌握LTC6804的初始化流程、ADC转换触发及电压数据读取方法,节省从零查阅数据手册的时间。
1. 把 LTC6804-1 接到 STM32 上做级联电池电压采集,最常被低估的不是 ADC 精度,而是软件栈的层次
芯片内部的 ADC、滤波、基准源都已经做好了,主机要做的只是通过 SPI 把命令发进去,再把一包一包 16 位电压数据原样取回来。真正难伺候的反而是三件事:SPI 时序和片选控制、命令帧自带的 PEC 校验、以及菊花链回传时“最远端的芯片最先回来”这个反直觉的数据顺序。本文把这些层拆开讲,给出可以直接照抄的参数、函数和工程组织方式,适合手头有一个 LTC6804-1 工程源码包、正准备移植到自己的 BMS 项目里去的嵌入式开发。如果你刚拿到一个“软件程序工程源码.zip”,先别急着编译,读完第二、三章再动手,能少走大半弯路。
2. 先打通物理层:SPI 速率、片选时序和 PEC 校验这三件事没有先后
2.1 为什么要先确认 SPI 速率和片选极性,再写任何寄存器
LTC6804-1 是一颗电池监控前端,通信接口是 SPI,但它和常见的 Flash、传感器不一样,对 SCLK 频率有明确上限。常见做法是把 STM32 的 SPI 预分频放到 8 分频或 16 分频,让实际 SCLK 落在 1 MHz 以内,而不是追求满速。接口速率不是越高越好,超过器件规格后表现为命令偶尔生效、读回数据错位,而且这种故障很难用示波器一次抓准。
片选信号同样有讲究。LTC6804-1 要求 CS 在“命令帧 + 后续数据帧”的整个事务期间保持低电平,不能像操作普通 SPI 从机那样每收发一个字节就拉高一次 CS。很多移植失败的工程,症状是读回全是 0xFF 或 0x00,根因不是 SPI 没初始化好,而是片选在命令和读数据之间被中断了,器件认为本次事务非法。
还有一点容易被忽略:LTC6804-1 支持 SPI Mode 0 和 Mode 3,具体用哪个以数据手册为准。我一般先按 Mode 0 配置,如果读回数据全 FF,先把 CPOL/CPHA 切到 Mode 3 试一次,比盲目调分频更有效。
2.2 16 位命令帧加 8 位 PEC:写一次命令其实要发 3 个字节
LTC6804-1 的每条命令是 16 位命令字,后面跟着 8 位 PEC 校验,合计 24 位。PEC 是器件自带的 CRC-15 校验结果,取低 8 位跟在命令后面。命令码不是随意编的,数据手册命令表里能查到,常见几条如下:
| 命令 | 命令字 | 用途 |
|---|---|---|
| ADCV | 0x03 | 启动电压 ADC 转换 |
| RDCVA | 0x04 | 读电压寄存器组 A |
| RDCVB | 0x06 | 读电压寄存器组 B |
| RDCVC | 0x08 | 读电压寄存器组 C |
| RDSTATA | 0x0A | 读状态寄存器组 A |
| WRCONF | 0x1A | 写配置寄存器组 |
| RDCONF | 0x2A | 读配置寄存器组 |
注意顺序:命令低字节先发,高字节后发,最后发 PEC。如果 PEC 不对,器件直接丢弃整条命令,不会返回错误标志,肉眼看到的现象就是“发了命令没反应”。这也是读电压全 0xFF 的第二个高发原因。
2.2.1 计算 PEC 的 CRC-15 算法
PEC 本质上是 CRC-15,生成多项式 0x4599,初始值为 0。逐字节输入命令字,最后取结果的高 8 位。参考实现如下:
#define CRC15_POLY 0x4599 uint16_t ltc6804_pec15(uint8_t *data, uint8_t len) { uint16_t crc = 0x0000; uint8_t i, j; for (i = 0; i < len; i++) { crc ^= data[i]; for (j = 0; j < 8; j++) { if (crc & 0x8000) { crc = (crc << 1) ^ CRC15_POLY; } else { crc <<= 1; } } } return (crc & 0x7FFF) >> 7; }这段代码直接用位循环算 CRC-15,输入命令字节流,输出 8 位 PEC。参数data是待校验的字节数组,len是字节数,调用时传入命令字的低字节和高字节两个元素即可。如果要压榨性能,可以改成查表法,但在 1 MHz SPI 下位循环的开销完全可以忽略。
2.3 用 HAL 库封装一条“命令加读回”的通用 SPI 函数
工程里所有 LTC6804 操作都该收敛到一个 SPI 交换函数里,不要在业务代码里直接调 HAL_SPI_Transmit。通用函数长这样:
uint8_t ltc6804_spi_transfer(uint16_t cmd, uint8_t *data, uint8_t len) { uint8_t tx_buf[3]; uint8_t rx_dummy[3]; tx_buf[0] = cmd & 0xFF; // 命令低字节先发 tx_buf[1] = (cmd >> 8) & 0xFF; // 命令高字节 tx_buf[2] = ltc6804_pec15(tx_buf, 2); // 前两字节算 PEC HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); HAL_SPI_TransmitReceive(&hspi1, tx_buf, rx_dummy, 3, 100); if (len > 0) { HAL_SPI_Receive(&hspi1, data, len, 100); } HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); return 0; }逻辑说明:先组 3 字节命令帧,拉低 CS,发命令,然后在不拉高 CS 的前提下继续读回len字节数据。len为 0 时只发命令,例如启动转换;len为 8 时读回一个寄存器组(6 字节数据加 2 字节 PEC)。参数data指向接收缓冲区,cmd传上小节的命令字。
片选拉低到发出第一个字节之间要留一点延时,这个延时在 HAL 库的 SPI 传输内部已经有处理,但如果换成寄存器版或纯 GPIO 模拟 SPI,必须在 CS 拉低后加 2~3 个空指令延时。寄存器版和标准外设库版的移植也是改这个函数而已,上层逻辑完全不用动。
3. 单芯片读电压:ADCV 之后,真正花时间是数据重组
3.1 发起转换的 ADCV 命令和它携带的量程、模式位
读取电压不是直接发 RDCVA 就能拿到数据。芯片必须先执行一次电压 ADC 转换,转换完成后再去读寄存器组。ADCV 命令除了命令字 0x03,还携带转换模式、量程、自测模式等配置位。这些位在数据手册 ADCV 命令一节有明确表格,移植时建议先按默认量程和最慢滤波模式跑通,再去优化速度。
转换需要时间,而且级联时是多颗芯片同时转换,等待时间按链上芯片数线性增长。常见做法是发完 ADCV 后延时,延时时长按“每片约 2 ms”估算,链上有 10 片就至少等 20 ms 以上。延时不够的典型症状是读回电压里有几片是 0,因为数据还没准备好就被读走了。
void ltc6804_adcv_start(uint8_t chain_len) { uint16_t adcv_cmd = 0x03; // 默认量程,默认滤波模式 ltc6804_spi_transfer(adcv_cmd, NULL, 0); HAL_Delay(2 * chain_len + 2); // 每片约 2ms,留 2ms 裕量 }参数说明:chain_len是级联芯片数量,单芯片时传 1。延时时间取“转换时间上限 + 裕量”,不要取典型值,温度低时晶振和芯片内部 RC 都会漂,裕量不足会偶发性读到旧数据。
3.2 电压寄存器组 A/B/C 的字节序和拼接顺序
LTC6804-1 最多测量 12 节电芯电压,分成三组寄存器,每组 4 个电芯。每组数据帧是 6 字节电压数据加 2 字节 PEC,总共 8 字节。读回后先把 PEC 校验掉,再把 6 字节按每 2 字节一个电芯拼成 16 位无符号数。
寄存器组与电芯的对应关系如下:
| 寄存器组 | 命令 | 包含电芯 | 数据区字节数 |
|---|---|---|---|
| 电压组 A | RDCVA | Cell 1~4 | 6 |
| 电压组 B | RDCVB | Cell 5~8 | 6 |
| 电压组 C | RDCVC | Cell 9~12 | 6 |
拼接顺序是高位在前:每节电芯的电压占 2 字节,第一个字节是高 8 位,第二个字节是低 8 位。读回组 A 的 6 字节数据后,Cell 1 电压就是(data[0] << 8) | data[1],Cell 2 是(data[2] << 8) | data[3],依此类推。有些工程源码会把高低字节顺序搞反,导致电压读数在 0~255 之间跳,那就是拼接时把首字节当成低位了。
3.3 实际工程里的电压换算和滤波:宏、浮点与均值
拼出来的 16 位原始值要换算成毫伏或微伏,换算系数来自数据手册“电压转换结果”一节,不同配置下系数不同。代码里不要散落魔法数字,应该用一个宏定义:
#define V_LSB_UV 100 // 1 LSB 对应的微伏数,按手册配置填写 uint32_t ltc6804_raw_to_uv(uint16_t raw) { return (uint32_t)raw * V_LSB_UV; }参数说明:raw是寄存器拼出来的 16 位原始值,返回值是微伏。V_LSB_UV这个宏必须按数据手册核对,不同量程配置下 LSB 权重不一样,先用精密电压源标定一次再固化成常量,比直接照抄网上代码可靠得多。
电压数据抖动是正常的,尤其是多片级联后电源噪声变大。我一般不会对原始值直接做很强滤波,而是先做一次剔除野值再做滑动平均:
#define SAMPLE_BUF_SIZE 8 uint32_t v_filtered[SAMPLE_BUF_SIZE]; uint8_t v_filter_idx = 0; uint32_t voltage_filter(uint32_t new_value) { uint32_t sum = 0; uint8_t i; v_filtered[v_filter_idx] = new_value; v_filter_idx = (v_filter_idx + 1) % SAMPLE_BUF_SIZE; for (i = 0; i < SAMPLE_BUF_SIZE; i++) { sum += v_filtered[i]; } return sum / SAMPLE_BUF_SIZE; }逻辑说明:维护一个 8 个元素的环形缓冲,每次读回都覆盖最旧的值,输出均值。这种滤波对随机噪声有效,但对真实电压跌落会有约 8 个周期的延迟,做保护逻辑时不要直接用滤波后的值判断过压,原始值或浅滤波值要另留一路。
4. 级联读回:链上配置“分地址写入”,数据合并“反向取值”
4.1 菊花链的两种工作相位:配置广播与数据回传
级联模式下,主控 SPI 只接到最底层的那颗 LTC6804-1,后续芯片通过片间接口串联。SDO 数据是逐级传回主控的:最顶层的芯片数据最先被移出来,经过中间芯片一级一级往下传,最后到达主控的 MISO。也就是说,主控读回数据时,收到的第一个字节属于链上地址最大的芯片,最后一组数据才属于最底层芯片。这个顺序和多数人的直觉相反,是级联合并最容易出错的地方。
级联工作分两个相位。配置相位里,主机要保证每颗芯片写进自己的地址和参数;转换相位则简单得多,ADCV 是广播命令,一颗芯片发起,整条链都开始转换,等待时间随链长线性增加。完成转换后,主机逐组读回电压寄存器,把所有芯片的数据拼接成一个完整电池包电压表。
4.2 写配置时地址挨个递增,读回时把数组倒序归位
LTC6804-1 的器件地址写在配置寄存器组 B 的高 4 位里,链上从底层到顶层依次是 0x0、0x1、0x2……写配置时地址不能一样,否则所有芯片都认领同一个地址,读回的数据会互相覆盖。级联写配置的组帧方式和单芯片不同,要按数据手册“写配置寄存器组”一节的菊花链说明来拼接,这一点直接对照官方驱动确认比猜更稳。
读回合并时,应用层要主动反转顺序。我一般这样处理:
#define CELLS_PER_IC 4 // 每组寄存器包含 4 个电芯 void ltc6804_merge_chain_data(uint16_t *group_buf, uint16_t *cell_buf, uint8_t ic_count) { uint8_t i; // group_buf 按读回顺序存放,下标 0 是最远端芯片的数据 for (i = 0; i < ic_count; i++) { uint8_t target_ic = ic_count - 1 - i; memcpy(&cell_buf[target_ic * CELLS_PER_IC], &group_buf[i * CELLS_PER_IC], CELLS_PER_IC * sizeof(uint16_t)); } }参数说明:group_buf是 SPI 读回的原始数据区,按寄存器组顺序依次存放,下标 0 对应最远端芯片;cell_buf是给应用层用的最终数组,下标 0 对应底层芯片。循环里把读回的第i组数据放到target_ic的位置,完成倒序归位。不做这一步,电池编号和实际电芯位置会整个镜像翻转,轻则显示错位,重则均衡策略打错对象。
4.3 一个最小级联读电压流程
完整流程至少分三步:启动转换、按组读回、重排合并。伪代码级别的实现如下:
#define IC_COUNT 6 #define GROUP_SZ 8 // 6 字节数据 + 2 字节 PEC uint8_t raw_buf[IC_COUNT * GROUP_SZ]; uint16_t cell_uv[IC_COUNT * CELLS_PER_IC]; void bms_read_all_voltage(void) { uint8_t i; ltc6804_adcv_start(IC_COUNT); // 广播 ADC 转换 // 逐组读回,这里以电压组 A 为例,B/C 逻辑相同 for (i = 0; i < IC_COUNT; i++) { ltc6804_spi_transfer(0x04, &raw_buf[i * GROUP_SZ], GROUP_SZ); } // 校验每组末尾 2 字节 PEC for (i = 0; i < IC_COUNT; i++) { if (!ltc6804_check_reg_pec(&raw_buf[i * GROUP_SZ], GROUP_SZ)) { // 上报链路异常,本帧丢弃 } } ltc6804_merge_chain_data((uint16_t *)raw_buf, cell_uv, IC_COUNT); }逻辑说明:先发 ADCV 让整条链同时转换,再连续读回 N 组电压数据。读回时 CS 保持低电平,所有组的读回在同一个事务里完成更稳妥,我的示例是按组单独做,实际工程里也可以一次性读回N * 8字节,效果一样。PEC 校验必须做,它不仅是校验传输错误,还能间接反映链路里哪颗芯片的通信开始劣化。
4.4 源码包工程组织:驱动层和应用层分开才找得到人
一个能长期维护的 LTC6804-1 工程,文件结构通常长这样:
app/ bsp/ bsp_spi.c # STM32 SPI 和片选初始化 bsp_ltc6804.c # 协议层封装 bsp_ltc6804.h protocol/ ltc6804_cmd.h # 命令字、寄存器地址定义 ltc6804_pec.c # CRC-15 校验实现 service/ bms_voltage.c # 滤波、换算、保护判断 bms_voltage.h| 文件/目录 | 职责 | 改动频率 |
|---|---|---|
| bsp_spi.c | SPI 引脚、时钟、DMA 配置 | 换平台时改 |
| bsp_ltc6804.c | 命令拼装、数据读回、PEC 组帧 | 协议调整时改 |
| ltc6804_pec.c | CRC-15 算法 | 几乎不改 |
| bms_voltage.c | 换算、滤波、阈值判断 | 业务需求变化时改 |
拿到一个现成的 zip 工程包,我一般先打开 bsp_ltc6804.c 和 ltc6804_pec.c 两个文件,确认 PEC 算法是不是对 16 位命令生成 8 位校验值,再看 bsp_spi.c 里 SCLK 分频是否低于 1 MHz。这两个点没问题,剩下的就是业务层移植。Keil MDK 或 VSCode 加 Embedded IDE 都行,关键是芯片包版本要和工程里 CubeMX 生成代码的 HAL 库版本一致,否则编译报的错会让人误以为代码有问题。
5. 验证读数和观察链路健康:把“能跑”变成“可以上线”
5.1 用自测模式代替精密电压源做初步验证
没有精密电压源的时候,可以先不接电池组,直接用 LTC6804-1 的自测模式验证链路。ADCV 命令里有自测位,设置后芯片内部会切换到已知参考电压去转换,不去测外部电芯。把自测读回的原始值和手册给出的自测典型值对比,能同时验证 SPI 通信、PEC 校验和拼接顺序三件事。如果自测值对得上,再接电池组,就可以把怀疑重点放到外部采样电阻和滤波电路上。
5.2 让每颗芯片在状态寄存器里留下温度,当作链路健康位
LTC6804-1 内部有温度传感器,状态寄存器组 A 里能读回芯片温度。级联工程里我习惯把每颗芯片的温度一起读回来,这不仅是热管理需要,更是一个廉价的链路健康探针。温度读数正常,说明那颗芯片的通信路径是通的;哪颗芯片温度读回 0 或乱码,故障点大概率就在它和上一级之间。这个思路比单纯看电压 CRC 错误计数更直观,因为温度值本身有合理范围,一眼就能看出异常。
5.3 用定时器调度转换相位,链越长越需要显式状态机
转换等待时间随链长线性增长,在 1 kHz 控制循环里直接 HAL_Delay 等转换会卡死整个任务。我一般把读取流程拆成一个状态机:
static uint8_t phase = 0; static uint16_t tick_count = 0; #define WAIT_TICKS (2 * IC_COUNT + 2) void bms_1ms_tick(void) { switch (phase) { case 0: ltc6804_adcv_start(IC_COUNT); tick_count = 0; phase = 1; break; case 1: if (++tick_count >= WAIT_TICKS) { bms_read_all_voltage(); phase = 0; } break; default: phase = 0; break; } }参数说明:phase 0负责启动转换,phase 1负责等待和读回。WAIT_TICKS按芯片数量放大,链越长等待周期越长。这样主循环不会被阻塞,看门狗也不用加长超时。链路稳定后,再把 SPI 读回放到 DMA 里,让状态机变得更精简——这个优化的收益在链长超过 6 颗时非常明显。
本文还有配套的精品资源,点击获取