☰
LTC681x驱动源码解析:isoSPI菊花链与PEC15采样调试
2026/11/2 16:47:56 网站建设 项目流程

简介:面向电池管理系统(BMS)开发者的LTC681X官方驱动源码,围绕ADI高精度多节电池监测AFE的SPI通信与数据处理展开,适合电动汽车、储能等场景下需要快速打通底层采样链路的嵌入式工程师参考,也便于具备C语言与SPI基础的开发者理解AFE接入主控的完整路径。压缩包共4个文件,以2个.h头文件和2个.c源文件为主,约20KB,分别承载寄存器定义、硬件抽象与驱动实现,体量轻便,便于按接口移植到现有工程。已有862人学习,说明其在LTC681X开发生态中具备一定参考价值。源码覆盖通信初始化、模块配置、电池电压与温度数据采集、CRC错误检测及上层API封装等关键环节,读者可据此梳理隔离SPI时序、采样范围设置和异常上报逻辑,并对照示例思路减少从零编写驱动、联调通信与定位数据异常的时间。

1. 从一块 12 串电池包说起:LTC681x 官方驱动源码到底给了什么

采样板焊完第一版,程序烧进去,逻辑分析仪上 SCLK 和 MOSI 的波形漂亮得很,读回来的十二节单体电压却全是 0x00。这种时刻最难受——硬件看起来没有异常,软件也看起来没有异常,问题就落在那层没人写清楚的通信协议上。LTC681x 这套官方驱动源码,价值就在这儿:它把「MCU 怎么通过 isoSPI 跟一颗 AFE 说话」这件事完整摊开了,从 11 位命令字怎么拼、PEC15 校验怎么算,到 N 片菊花链上的数据怎么按 8 字节一块切回来,每一步都有可对照的实现。它面向的不是写应用层的人,而是那些手上已经有一块采样板、必须让 LTC6811 或 LTC6813 老实吐数据的嵌入式开发者。

2. 源码结构拆解:从命令层到寄存器层的三段式结构

拿到一份官方驱动,最忌讳的做法是直接从例程的main()往下照着抄一遍。例程只是为了证明开发板能跑,真集成到自己的板子上时,几乎每一条“例程里没写”的隐含前提都会成为问题。正确的打开方式是先看清它的分层:哪些文件是芯片协议,哪些是平台适配,哪些只是一次性的验证代码。看清了,你才知道自己该动哪一刀。

2.1 目录与文件:谁在跟硬件打交道,谁在算数据

这套驱动的组织方式相当传统,按职责切成三层。最底下是平台适配层,负责片选拉低拉高、SPI 收发一个字节、毫秒计时这些具体动作;中间是协议层,.c文件里全是命令常量、PEC 表和寄存器打包解包;最上面是示例层,用一块现成的评估板把整套流程跑通给你看。

文件/目录职责你实际会改动它的概率
ltc681x.c命令常量、PEC15 查表、寄存器打包解包、轮询低,基本别动
ltc681x.hcell_asic结构体、通道宏、函数声明中,可能要扩通道数
LTC681x.cpp把 C 接口封成类,方便 Arduino 风格调用中,取决于你用不用 C++
LT_SPI.cpp/Linduino.h评估板上的 SPI 与延时适配高,几乎必须整体替换
板级例程目录从唤醒到读电压的完整流程演示高,只作为流程参考
EEPROM 读取相关读评估板 ID,跟芯片功能无关低,可以直接删

真正的协议实现集中在那一个.c文件里,大约一千行上下,其中将近一半是 PEC 表。剩下的逻辑其实不多:拼命令、算校验、发出去、收回来、校验、拆字节。把这一千行读完,LTC681x 就没有秘密了。反之,如果只抄例程里那几十行调用,遇到通信失败你会发现无处下手,因为你根本不知道那几十行背后还压着什么前提。

我一般的处理是把协议层原样保留,只在最前面补一个ltc681x_port.h,把片选、收发、延时声明成外部函数,让驱动去调我的实现。这样将来换 MCU 或者换 SPI 外设,只改一个文件,协议层一个字节都不碰。

2.2 isoSPI 与菊花链:一帧数据怎么穿过 12 节电池

LTC681x 本身不直接输出 SPI,它输出的是 isoSPI——一对差分线,靠变压器隔离,抗干扰强,适合高压电池包这种环境。中间要有一颗转换芯片把 MCU 的标准 SPI 翻译成差分信号,然后一根线进、一根线出,把多块采样板串成一条链。

这里有个非常关键、但文档里要翻好几页才能拼出来的事实:在菊花链模式下,主机发出去的命令会依次穿过链上的每一片,最后一片拿到的是它前面所有片“转交”过来的命令;而返回的数据也是沿着同一条链路反向传回主机。也就是说,你要读 N 片的数据,必须给足 N 倍的时钟。每一片贡献 8 个字节的返回内容,N 片就是N × 8个字节。

这个倍数关系一旦记错,翻车现场极具迷惑性:电压能读出来,看着还是合理值,只是第 3 片的数据跑到了第 2 片的位置上,最后一片永远是 0。我见过有人为此去怀疑变压器、怀疑隔离电源、怀疑焊盘虚焊,折腾两天,最后发现只是total_ic少写了一片。

还有一点:链路空闲一段时间后,芯片会进入低功耗待机,此时它不再响应命令。所以每次开始一轮通信前,都得先发一段唤醒信号,把整条链上的片子叫醒,并且要等它们彻底醒来再发正式命令。这个“醒来需要时间”的窗口,在例程里通常被一行不起眼的延时函数掩盖着,移植时非常容易被删掉。

2.3 cell_asic 结构体:整包数据的内存模型

驱动的所有数据交换都围绕一个结构体转,理解它就理解了驱动的数据模型。大致长这样:

/* 每片 AFE 对应一个这样的结构体,TOTAL_IC 片就开 TOTAL_IC 个 */ typedef struct { struct { uint8_t cfgr[6]; /* 配置寄存器的 6 个字节,原样收发 */ } tx_cfga; /* 准备写下去的配置 */ struct { uint8_t cfgr[6]; } rx_cfga; /* 从芯片读回来的配置,用于回读比对 */ struct { uint16_t c_codes[18]; /* 单体电压原始码值,LSB = 100uV */ uint8_t pec_match[6]; /* 每组数据的 PEC 是否校验通过 */ } cells; struct { uint16_t a_codes[9]; /* GPIO1~5 与内部温度,同样是码值 */ uint8_t pec_match[4]; } aux; struct { uint8_t st[6]; /* 状态寄存器:MUX 失效、通信超时等 */ uint8_t pec_match[3]; } stat; } cell_asic; cell_asic ic[TOTAL_IC]; /* 全局一份,注意内存占用 */

c_codes里存的是原始码值,不是毫伏。LTC681x 的单体电压 LSB 是 100 微伏,所以c_codes[0] = 36000表示 3.6V。别在驱动层做换算,换算是应用层的事,驱动只负责把码值原样搬出来。

pec_match是我最看重的字段。它不是简单的“通过/不通过”,而是一组位掩码,每一位对应一组寄存器的校验结果。有了它,你可以精确知道是 RDCVA 这一组数据错了,还是 RDCVC 错了。刚开始集成时把这几个字节全打印出来,能省掉大量猜测。

结构体里的tx_cfga用了一个联合体加位域,让每个配置位都能单独访问:

/* 配置寄存器第 0 字节的位定义,方便按位操作而不是手算掩码 */ typedef union { struct { uint8_t GPIO5 : 1; /* 置 1 打开该引脚内部下拉 */ uint8_t GPIO4 : 1; uint8_t GPIO3 : 1; uint8_t GPIO2 : 1; uint8_t GPIO1 : 1; uint8_t REFON : 1; /* 内部基准使能,务必置 1 */ uint8_t SWTRD : 1; /* 软件看门狗超时标志回读 */ uint8_t ADCOPT : 1; /* 与 MD 位组合决定 ADC 速度 */ } bits; uint8_t byte; } cfg0_t;

用位域的好处是可读,坏处是不同编译器对位序的排布方向可能不同。我一般会在初始化完成后,把这 6 个字节原样打印出来跟数据手册逐位对一遍,确认编译器没有按相反的顺序排。这个动作花五分钟,能避免后面所有“配置写下去但行为不对”的玄学问题。

3. 跑通第一个采样:从空工程到读回 12 节单体电压

大部分人卡在第一步不是因为协议难,而是因为把“移植”想象成了“复制文件”。官方驱动是在一块现成的评估板上验证通过的,那块板子的 SPI 外设、片选引脚、延时函数都是现成的。你把它搬到自己的 MCU 上,等于把它脚下的地板全抽走了,得先把自己家的地板铺好。

3.1 移植三件套:SPI 收发、片选、毫秒计时

驱动对平台的依赖其实很轻,归纳起来就三件事。我习惯在工程里单开一个文件,把这几个函数写死:

/* ltc681x_port.c —— 驱动唯一依赖的平台接口,换 MCU 只改这个文件 */ /* 片选:LTC681x 靠 CS 的下降沿启动一次帧传输,脉宽要够 */ void ltc681x_cs_low(void) { GPIOA->BRR = GPIO_PIN_4; /* 拉低 CS */ } void ltc681x_cs_high(void) { GPIOA->BSRR = GPIO_PIN_4; /* 拉高 CS */ } /* 全双工收发:tx 为 NULL 表示只收,rx 为 NULL 表示只发 */ int ltc681x_spi_xfer(const uint8_t *tx, uint8_t *rx, int len) { for (int i = 0; i < len; i++) { uint8_t out = tx ? tx[i] : 0xFF; /* 只收时也要给时钟,发 0xFF */ uint8_t in = spi_tx_rx_byte(out); if (rx) rx[i] = in; } return len; } /* 毫秒计时:轮询 ADC 完成状态时用来做超时 */ uint32_t ltc681x_millis(void) { return HAL_GetTick(); }

三个函数,缺一不可。ltc681x_spi_xfer里那个“只收时发 0xFF”的细节别忽略——SPI 是全双工的,你想收字节就必须同时给它时钟,而给时钟就得给数据,此时 MOSI 上发什么其实无所谓,但发 0x00 在某些从机眼里会被当成有效命令。发 0xFF 是最保险的做法。

SPI 参数上,按数据手册的时序来:SCK 空闲低电平,数据在上升沿被采样,也就是常说的模式 1;时钟频率先压到 1MHz 以下,等一切跑通再往上调。很多人一上来就开 8MHz,然后抱怨读到的数据一半错一半对——其实那不是协议问题,是信号完整性问题。

3.2 唤醒与配置:先叫醒,再写 CFG

上电后的顺序不能乱。第一步是唤醒整条链,第二步是写配置,第三步才轮到启动转换。顺序颠倒的话,你会得到一堆看似有值实则无效的数据。

#define TOTAL_IC 3 /* 链上几块采样板就写几,这个数错了后面全错 */ static cell_asic ic[TOTAL_IC]; void bms_init(void) { memset(ic, 0, sizeof(ic)); /* 1. 唤醒:CS 拉低保持一段时间,期间持续发 0xFF 提供时钟 */ ltc681x_cs_low(); for (int i = 0; i < 5; i++) { uint8_t dummy = 0xFF; ltc681x_spi_xfer(&dummy, NULL, 1); } ltc681x_cs_high(); delay_us(500); /* 等芯片内部电源稳定,别删 */ /* 2. 配置:只用一片的配置,其余片会在链上依次接收 */ ic[0].tx_cfga.bits.REFON = 1; /* 内部基准必须开,否则转换结果是垃圾 */ ic[0].tx_cfga.bits.ADCOPT = 0; /* 与 MD 组合,决定 7kHz/422Hz 那组速度 */ ic[0].tx_cfga.bits.GPIO1 = 0; /* 接 NTC 时不要打开内部下拉 */ for (int i = 0; i < TOTAL_IC; i++) { ltc681x_wrcfg(TOTAL_IC, ic); /* 写配置,链上每片都会收到 */ } /* 3. 回读比对:确认写下去的和读回来的一致,不一致直接报错 */ ltc681x_rdcfg(TOTAL_IC, ic); for (int i = 0; i < TOTAL_IC; i++) { if (memcmp(ic[i].tx_cfga.cfgr, ic[i].rx_cfga.cfgr, 6) != 0) { bms_report_fault(FAULT_CFG_MISMATCH, i); } } }

唤醒那段里的delay_us(500)是我踩过问题之后加上的。芯片刚从待机状态醒来时,内部基准和电荷泵还没稳,此时写进去的配置有可能不生效登记,表现就是“配置写下去了,但 ADC 转换出来全是 0”。等 500 微秒再动手,问题消失。

wrcfg在菊花链下也是一个容易被误解的地方。写命令发出去时,链上每一片都会收到同一份配置数据,但这并不意味着所有片都写入了——实际是每片取走属于自己的那一段。所以循环调用写配置时,得保证TOTAL_IC跟实际片数严格一致,多一片少一片,配置就会整体错位。

3.3 启动 ADC、轮询完成状态、读回四组电压寄存器

配置写对了,采样流程就是三段式的固定动作:

int bms_sample_once(void) { /* 1. 启动转换:MD 选速度,DCP 选转换期间是否禁止放电,CH 选通道 */ if (ltc681x_start_adcv(MD_422HZ_1KHZ, DCP_DISABLED, CELL_CH_ALL) != 0) return -1; /* 2. 轮询:芯片在 SDO 上回 0xFF 表示还在转换,回别的值表示完成 */ if (ltc681x_poll_adc(50) != 0) /* 50ms 超时,12 串全通道约 1ms 量级 */ return -2; /* 3. 读回:RDCVA 到 RDCVD 四组,每组 3 节,共 12 节 */ for (int grp = 0; grp < 4; grp++) { if (ltc681x_rdcv_group(grp, TOTAL_IC, ic) != 0) return -3; } return 0; }

三个参数各管一件事,值得单独说清。MD决定 ADC 的采样速度和滤波强度,同一组值在不同ADCOPT配置下对应不同的转换时间,选得越快,抗干扰越差;DCP决定转换期间是否允许均衡放电继续,如果你正在做被动均衡,这里必须设成禁止放电,否则转换瞬间的负载会把电压拉低几十毫伏;CH决定这次转哪些通道,全通道最省事但也最慢,做快速保护时通常只转需要的那几节。

ltc681x_poll_adc的行为值得说明:它发出的不是一串重复命令,而是发一次轮询命令之后,保持片选有效,然后持续从 MISO 上读字节。读到的字节是 0xFF 就继续等,读到别的值就说明转换结束。这个细节在移植时特别容易写错——如果每次都重新发一遍命令再读,你会因为每条命令都重新触发一次状态查询而永远等不到结果。

3.4 怎么确认读到的不是垃圾

数据读回来了,接下来最危险的是“看起来对”。3.6V 左右的数字确实很容易蒙混过关,但只要有一个通道是 0 或者 65535,就说明有问题。我一般会做三件事:

一是把pec_match全部打印出来,任何一位非零都说明那一组数据传输有问题,先解决通信再谈精度。

二是把所有通道的码值排序,看最小值与最大值。真实的 12 串电池包,满电与亏电的差距通常在几百毫伏量级,如果出现 0.1V 和 4.9V 这种,那基本是通道映射错了或者那一节根本没接。

三是拿万用表量其中两节的真实电压,跟读数值对一下。误差在正负几毫伏以内算正常,如果差了 100 毫伏以上,先查是不是分压或者滤波网络的问题,别急着怀疑芯片。

4. 关键代码分析:PEC15 校验、命令拼装与批量读

协议层最核心的部分其实就四段代码,加起来不到两百行,但每一段都有“差一个比特就全错”的性质。把这几段看懂,剩下的都是排列组合。

4.1 PEC15:一个查表 CRC 决定了这条数据的有效性

LTC681x 的每一条命令、每一组返回数据后面都跟着两个字节的 PEC,本质是一个 15 位的 CRC。算法不长:

/* 256 项查表,表本身占了大半个文件,这里省略具体数值 */ static const uint16_t pec15_table[256] = { 0x0000, 0x0000, /* ... */ }; /* 对 len 个字节算 PEC,返回值已经把 15 位 CRC 左移一位放进 16 位里 */ uint16_t ltc681x_pec15(const uint8_t *data, int len) { uint16_t remainder = 16; /* 初始种子,固定值,不是 0 */ uint8_t index; for (int i = 0; i < len; i++) { index = (uint8_t)(((remainder >> 7) ^ data[i]) & 0xFF); remainder = (uint16_t)((remainder << 8) ^ pec15_table[index]); } return (uint16_t)(remainder * 2); /* 左移一位,最低位补 0 */ }

三个地方容易出错。第一,初始值是 16 不是 0,这个值来自多项式本身,改了就全错。第二,remainder >> 7而不是>> 8,因为参与运算的是 15 位而不是 16 位。第三,最后那个* 2不能省,PEC 在总线上占两个字节,但有效位只有 15 位,最低位是补出来的,收发两端都按这个约定来。

校验函数反过来用同一套逻辑:

/* 校验 data 后面紧跟的两个字节 PEC 是否正确 */ int ltc681x_pec_ok(const uint8_t *data, int len) { uint16_t pec_calc = ltc681x_pec15(data, len); uint16_t pec_recv = (uint16_t)((data[len] << 8) | data[len + 1]); return (pec_calc == pec_recv) ? 1 : 0; }

我见过有项目为了“提高效率”把这个校验直接注释掉,理由是“数据看着都对”。这个决定在实验室里不会有报错,但到了现场,一次电磁干扰就能让某一节电压少个几千码值,而整包保护逻辑会把它当成真实电压去判断。PEC 是这套协议里唯一的错误检测手段,没有替代品。

4.2 命令帧拼装:11 位命令字加 2 字节 PEC

LTC681x 的命令是 11 位宽,放在两个字节里发出,后面跟两个字节 PEC,一共 4 个字节。不同命令之间,其实就是这 11 位里某几位的组合不同。

/* 常用命令字,都是 11 位,注意不是标准 16 位 */ #define CMD_WRCFG 0x001 #define CMD_RDCFG 0x002 #define CMD_RDCVA 0x004 #define CMD_RDCVB 0x006 #define CMD_RDCVC 0x008 #define CMD_RDCVD 0x00A #define CMD_RDAUXA 0x00C #define CMD_ADCV 0x260 /* 启动单体电压转换的基础命令 */ #define CMD_ADAX 0x400 /* 启动辅助通道转换的基础命令 */ #define CMD_PLADC 0x714 /* 查询转换是否完成 */ #define CMD_CLRCELL 0x711 /* 清空电压寄存器组 */ /* 把 11 位命令和 PEC 拼成 4 字节待发帧 */ void ltc681x_build_cmd(uint16_t cmd, uint8_t out[4]) { uint16_t pec; out[0] = (uint8_t)((cmd >> 8) & 0x0F); /* 高 3 位有效,高 4 位清零 */ out[1] = (uint8_t)(cmd & 0xFF); pec = ltc681x_pec15(out, 2); /* PEC 只对命令的两个字节算 */ out[2] = (uint8_t)(pec >> 8); out[3] = (uint8_t)(pec & 0xFF); }

ADCV 那条命令的拼装稍有不同,因为它的 11 位里还塞了三个参数:

/* MD: 00=422Hz/1kHz, 01=7kHz/3kHz, 10=26Hz/2kHz, 11=1kHz/1kHz * DCP: 0=转换期间允许放电, 1=转换期间禁止放电 * CH : 通道选择,全通道、单节、奇偶等,具体编码查手册 */ uint16_t adcv_cmd(uint8_t md, uint8_t dcp, uint8_t ch) { return (uint16_t)(CMD_ADCV | ((uint16_t)md << 7) | ((uint16_t)dcp << 4) | ch); }

参数落在第几位的编码方式,数据手册里给的是位图,我建议第一次集成时用计算器手算几组值,跟例程里的常量对比一遍,确认移位方向没搞反。这一步五分钟,比后面调试两天划算得多。

4.3 批量读的字节数:为什么缓冲区长度必须是片数乘以 8

读电压是这套驱动里最能体现菊花链特性的地方。以 RDCVA 为例,它返回 3 节电池的电压,也就是 6 个字节,加 2 个字节 PEC,总共

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询