简介:这是一份面向STM32开发者和嵌入式工程师的贝岭BL0940电能计量芯片驱动代码包,解决智能电表、能源监控等场景中通过SPI总线读写BL0940寄存器、获取电能参数的问题。压缩包体积仅3KB,包含3个文件:一个C源文件、一个头文件和一个说明文档,结构一目了然,方便直接移植到STM32工程中。目前已有2293人学习下载,适合正在调试BL0940或需要快速上手SPI驱动开发的开发者。驱动代码基于STM32 HAL库编写,封装了SPI初始化、寄存器读写、数据解析等核心功能,并对GPIO复用配置、时钟分频、CPOL/CPHA模式选择等关键步骤进行了清晰注释,同时结合BL0940数据手册梳理了寄存器映射与配置要点,便于理解通信协议与计量逻辑。说明文档中还提供了示波器或逻辑分析仪验证波形的调试建议,以及中断服务程序的设计思路,帮助开发者排查通信异常、缩短驱动开发周期,将更多精力用于电能算法和上层应用开发。 如果你最近在折腾智能插座、智能排插、机柜PDU或者家庭能耗监测这一类项目,拆开外壳后大概率会在主控旁边看到一颗丝印BL0940的芯片。这颗上海贝岭出品的单相电能计量芯片,内部已经把计量环节里最麻烦的部分全部做完了:两路Sigma-Delta ADC、电压电流有效值计算、有功功率计算、电能累加,MCU要做的事只剩通过UART把寄存器读回来,再乘上标定系数。这篇文章就围绕BL0940的驱动展开,从硬件连线和采样电路开始,把UART协议每一字节讲清楚,给出一份可以直接移植的状态机代码,再把我自己在校准和量产调试中踩过的坑一并写出来。正在写底层驱动、手头有模块却读不出数据、以及想把这颗芯片做进量产项目的工程师,应该都能从里面找到有用的东西。

1. 为什么智能插座、插排和PDU里都藏着这颗BL0940

1.1 一颗芯片把计量算法全部接管了

在没有这类专用计量芯片的年代,想在嵌入式产品里测电压电流,意味着要在MCU上自己搭信号调理电路、做ADC采样、再跑有效值和功率算法。市电工频50Hz要采够波形,采样率至少要1kHz以上,算RMS还要开窗、做平方和平均,CPU占用和代码复杂度都不小,更别提温度和器件离散性带来的精度漂移,最后做出来的产品误差能不能看,全看硬件调得够不够细。

BL0940的思路是把计量固化到硬件里。芯片内部是两个高位数ADC加数字计量核,上电后不需要MCU参与,它自己就在那里持续采样、持续累加电能。MCU只要隔一段时间把电压、电流、功率、电能计数的寄存器读出来,就能拿到一组连续的计量结果。这对于ESP8266、STM32这类主控来说简直是降维打击,主控省下来的算力可以全部交给网络协议栈和上层业务。

这也是为什么很多公模智能插座、WiFi排插和一些入门级PDU都把它当作计量核心:外围器件少、BOM成本低、软件工作量小,精度上限还比自己用MCU硬算高得多。官方资料里给出的典型精度指标在0.1%到0.2%级别,动态范围能做到几千比一,从待机0.1W到大功率负载都不用切换量程。

1.2 选型时要看清BL0940和几个亲戚芯片的区别

这颗芯片容易和贝岭同系列的其他型号混淆,采购和开发前最好先搞清楚区别。BL0942经常被当成BL0940备货,封装、引脚定义和寄存器布局都不完全一致,驱动不能直接套;BL0937则是只有CF脉冲输出,没有UART直读寄存器;HLW8032是涂鸦老款方案里常见的串口计量芯片,协议和BL0940完全不同。做选型和BOM核对时,丝印这一关不能省。

我整理了一个简单的对比表,方便快速定位:

芯片型号 接口类型 典型封装 驱动难度 备注
BL0940 UART / SPI SOP-16 低 寄存器直读电压、电流、功率、电能,本文主角
BL0942 UART / SPI SOP-8 中 封装更小,寄存器与BL0940有差异,按型号单独适配
BL0937 CF脉冲输出 SOP-8 中 只能读脉冲频率/占空比,功率需要MCU换算
HLW8032 UART SOP-8 低 老款方案常见,协议是另外一套
HLW8012 频率输出 SOP-8 中 频率和占空比分离,需要两路输入捕获

选芯片有一个判断原则:如果产品对软件迭代速度有要求,优先选BL0940这种寄存器直读方案;如果只关心电能累计、对实时功率曲线没需求,CF脉冲方案也能用,但调试体验要差不少。

2. 最小硬件系统:UART接线和电压/电流采样通道的要点

2.1 芯片供电与串口电平匹配

BL0940的供电一般取3.3V,和绝大多数MCU同电压,连接起来非常省事。芯片的TXD接MCU的RX,芯片的RXD接MCU的TX,地线共地,UART参数固定为4800波特率、8N1。需要特别注意的一点是,BL0940是3.3V逻辑器件,千万不要图省事直接拿5V的USB转串口模块去怼它的RXD,长期使用有烧引脚的风险,调试时最好用支持3.3V电平的转换器或者确认模块上已经带了电平转换。

上电顺序也值得养成习惯:芯片上电后需要一小段稳定时间,代码里应该在系统启动后延时50到100毫秒再发第一条读命令。要是上电后立刻读,偶尔会收到一些未定义的数据,容易让人误判成芯片坏了。实际调试中我把这个延时加上之后,首帧响应率就稳定了许多。

2.2 电压采样和电流采样通道

电压通道从市电L端取电,经过一串大阻值电阻分压后进入芯片的VP引脚。参考设计里常见的是多颗470kΩ到1MΩ电阻串联,并联一个小电容做低通滤波。这里用多颗电阻串联不只是为了分压比,更是耐压和爬电距离的需要,单颗电阻扛220V交流在安全上不靠谱,PCB Layout时电容和电阻的位置也要靠近芯片输入引脚。

电流通道走的是分流器方案,一般用1mΩ级别的锰铜分流器,或者用电流互感器做隔离采样,差分输入到IP和IN引脚。分流器的精度直接影响电流和功率读数的上限,锰铜温漂特性比普通铜要好,别为了省几毛钱换成普通绕线电阻。采样走线尽量短而粗,和开关电源的功率走线保持距离,避免高频dv/dt耦合进计量环路,这一点在电源纹波比较大的公模板上尤其明显。

安全提示:任何直接接触市电的板子,开发调试时都建议使用隔离变压器供电,USB转串口也尽量用隔离版本。示波器探头如果没上差分探头,至少确认探头地和板子被测点的电位关系是明确的。我见过不止一次因为探头地线把板子地线接进了大地,导致跳火甚至炸采样电阻的事故,这条不是客套话。

3. UART协议逐字节拆解:读命令、响应帧和同步状态机

3.1 4800波特率下的帧结构

BL0940的UART协议非常简洁,所有命令和响应都是固定长度。读寄存器时,MCU发出3字节命令:

0x58 | reg_addr | checksum

其中checksum等于0x58加上寄存器地址,取低8位。例如读0x02地址的电压寄存器,发送的字节就是:

0x58 0x02 0x5A

芯片收到后返回6字节响应帧:

0xA5 | reg_addr | data0 | data1 | data2 | checksum

返回的数据是三字节24位值,字节顺序是低字节在前。checksum等于前面5个字节的累加和低8位。我记录过一组电压寄存器的响应,原始帧是 A5 02 50 C3 04 BE ,把0x50、0xC3、0x04拼起来得到0x04C350,也就是312144,这个值在后面校准小节会继续用到。

写寄存器则是6字节命令帧:

0xA5 | reg_addr | data0 | data1 | data2 | checksum

比如要把0x08模式寄存器写成0,发 A5 08 00 00 00 AD 即可,checksum同样是前面5字节累加和的低8位。

为什么校验只用这么简单的累加和?因为通信双方就在同一块PCB上,走线长度通常只有几厘米,干扰源主要是电源开关噪声,累加和已经能挡住绝大多数错帧;真正需要关注的是帧边界同步,而不是校验强度。

3.2 为什么驱动要写成状态机而不是阻塞读

4800波特率下,一个字节的传输时间大约是2.08毫秒,一帧响应6字节就要12.5毫秒。如果主循环里用阻塞方式等待响应,这十几毫秒里MCU什么都干不了,要是再叠加超时重试,性能损失就很可观。

更现实的问题在于,通信并非永远一帆风顺。芯片上电瞬间、电源波动、甚至MCU复位期间,都可能让串口线上出现杂散字节。如果驱动只按固定顺序读取,一次错帧就会让后续所有解析全部错位。所以驱动应该用一个按字节喂入的状态机:从串口中断里拿到每个字节,喂给状态机去识别帧头、核对寄存器地址、收集数据、最后校验,任何一步对不上就丢弃当前帧,重新回到等待帧头的状态。这样无论来的是正常响应还是噪声,状态机都能自己恢复到同步状态。

3.3 超时和重试策略

MCU发完读命令后,如果在合理时间内没收到完整响应,就应该判定本次通信失败。以4800波特率计算,一帧最多6字节,50毫秒的超时时间已经非常宽裕。超时后可以重新发送读命令,连续失败3次再上报通信故障,恢复后继续正常轮询即可。不要做无限等待,那会让系统在芯片异常时彻底卡死。

状态机收到字节后还有一个容易被忽略的细节:即使第一个字节匹配了帧头0xA5,第二个字节如果不是期望的寄存器地址,也应该立刻丢弃,不能把它当成新帧的帧头继续解析。很多公模板上的串口线上偶尔会混进半截数据,少了这一步,错帧就会污染整张数据表。

4. 寄存器地图与物理量换算:从原始值到电压电流功率

4.1 常用寄存器一览

BL0940向MCU暴露的寄存器不多,最常用的测量寄存器就那么几个。下面这张表是结合多个参考工程的项目笔记整理的,具体位定义以你手里那版官方数据手册为准:

地址 寄存器名 说明
0x01 I_FAST_RMS 电流快速有效值,24位
0x02 V_FAST_RMS 电压快速有效值,24位
0x03 I_RMS 电流有效值,更新较慢
0x04 V_RMS 电压有效值,更新较慢
0x05 WATT 有功功率,24位有符号
0x06 CF_CNT 电能脉冲累计计数
0x08 MODE 工作模式配置
0x09 STATUS 状态和事件标志
0x1B CORRECTION 增益修正寄存器

日常驱动只需要关注0x01、0x02、0x05、0x06这4个就够用了。0x08模式寄存器在上电默认值下一般就能正常工作,没有特殊需求不要随手去改。0x09状态寄存器做事件告警才会用到,普通能监测量产项目可以先不碰。

4.2 24位数据的拼装和符号

响应帧里的三字节数据,拼装规则是低字节在前。比如电压寄存器收到 50 C3 04 ,拼出来就是0x04C350。代码上可以这样处理:

static uint32_t bl0940_parse_u24(const uint8_t *d)
{
    return (uint32_t)d[0] | ((uint32_t)d[1] << 8) | ((uint32_t)d[2] << 16);
}

功率寄存器WATT是带符号数,直接按无符号拼出来再转成int32_t会有问题。需要做符号扩展:

static int32_t bl0940_parse_s24(const uint8_t *d)
{
    uint32_t raw = bl0940_parse_u24(d);
    return (int32_t)(raw << 8) >> 8;   // 保留符号位
}

正常接线情况下,插座类产品的功率读数应该是正的。如果发现功率长期为负,不要简单取绝对值掩盖问题,这通常是电流采样方向接反或者互感器相位不对的信号,排查清楚了再决定要不要在软件里做符号处理。

4.3 原始值怎么换算成物理量

很多初学者拿到寄存器值后第一反应是找数据手册里的固定公式,但BL0940的寄存器原始值和实际物理量之间不是固定比例,比例系数根本上取决于你板子上的分压电阻、分流电阻和芯片内部增益设置。也就是说,两块设计不同的板子,同样的220V电压读出来的寄存器值会不一样。

正确的思路是标定。拿一块参考板举例,我同时用万用表测市电和读电压寄存器,得到一组数据:

物理量 寄存器原始值 标准表读数 标定系数
电压 0x04C350(312144) 220.3V 0.000706
电流 0x024F34(151348) 6.78A 0.0000448
功率 0x4B8A10(约495万) 1492W 0.000301

计算方法就是标准表读数除以寄存器原始值。算出三个系数后,驱动里每次读到原始值,乘上对应系数就能得到电压、电流和功率。注意这些系数只对这块板子成立,换一块板子哪怕用的是同型号电阻,1%的阻值误差也会让结果差出几个百分点,所以量产时每台设备标定是绕不开的。

电能累计则有两种做法。一种是读CF_CNT寄存器增量,配合标定出的“每计数对应多少瓦时”来累计;另一种更简单也更常用,就是软件自己在定时间隔里对功率做积分:

energy_kwh += power_w * dt_sec / 3600000.0f;

第二种方法不依赖芯片的电能寄存器,只要功率系数标得准,累计误差同样可控,调试时也更容易追溯。

5. 一套可直接移植的驱动代码:接口、状态机与轮询任务

5.1 驱动对外接口设计

驱动层的核心思路是:串口底层只负责收发字节,协议层负责组帧和解析,业务层只管读写数据。对外接口保持精简:

typedef struct {
    float voltage;        // V
    float current;        // A
    float power;          // W,带符号
    uint32_t cf_cnt;      // 电能脉冲计数
    float energy_kwh;     // kWh,软件积分结果
} bl0940_data_t;

typedef struct {
    float v_k;            // 电压系数
    float i_k;            // 电流系数
    float p_k;            // 功率系数
} bl0940_cal_t;

void bl0940_init(const bl0940_cal_t *cal);
void bl0940_poll(void);                       // 触发一轮寄存器读取
int  bl0940_get_data(bl0940_data_t *out);     // 取最新解析结果

5.2 命令发送和接收状态机的完整实现

发送读命令的代码很简单:

#define BL0940_CMD_READ  0x58
#define BL0940_RESP_HEAD 0xA5

static void bl0940_send_read_cmd(uint8_t reg)
{
    uint8_t cmd[3];
    cmd[0] = BL0940_CMD_READ;
    cmd[1] = reg;
    cmd[2] = (uint8_t)(BL0940_CMD_READ + reg);
    uart_write_bytes(cmd, 3);
}

串口中断每收到一个字节,就喂给状态机:

typedef enum {
    RX_IDLE,
    RX_REG,
    RX_D0,
    RX_D1,
    RX_D2,
    RX_CHK
} bl0940_rx_state_t;

static bl0940_rx_state_t rx_state = RX_IDLE;
static uint8_t rx_buf[6];
static uint8_t rx_cnt;
static uint8_t expect_reg;
static void (*frame_handler)(const uint8_t *frame);

void bl0940_uart_rx_isr(uint8_t byte)
{
    switch (rx_state) {
    case RX_IDLE:
        if (byte == BL0940_RESP_HEAD) {
            rx_buf[0] = byte;
            rx_cnt = 1;
            rx_state = RX_REG;
        }
        break;
    case RX_REG:
        if (byte == expect_reg) {
            rx_buf[1] = byte;
            rx_cnt = 2;
            rx_state = RX_D0;
        } else {
            rx_state = RX_IDLE;   /* 寄存器地址对不上,丢弃错帧重新同步 */
        }
        break;
    case RX_D0:  rx_buf[2] = byte; rx_state = RX_D1; break;
    case RX_D1:  rx_buf[3] = byte; rx_state = RX_D2; break;
    case RX_D2:  rx_buf[4] = byte; rx_state = RX_CHK; break;
    case RX_CHK:
        rx_buf[5] = byte;
        if (byte == bl0940_calc_checksum(rx_buf, 5) && frame_handler) {
            frame_handler(rx_buf);
        }
        rx_state = RX_IDLE;
        break;
    default:
        rx_state = RX_IDLE;
        break;
    }
}

校验函数就是把前5个字节累加取低8位:

static uint8_t bl0940_calc_checksum(const uint8_t *buf, uint8_t len)
{
    uint8_t sum = 0;
    for (uint8_t i = 0; i < len; i++) {
        sum += buf[i];
    }
    return sum;
}

在框架解析回调里,只更新一个受保护的缓存区,不直接操作业务数据结构,这样能避免后续加RTOS或者DMA时出现并发问题。

5.3 裸机轮询和RTOS下的接入方式

裸机场景下,可以用一个1秒或500毫秒的软件定时器触发 bl0940_poll() ,在中断里解析好数据后置一个标志位,主循环检测到标志就更新业务层数据。如果需要OLED显示或者云端上报,1Hz的刷新率就足够平滑;要做保护逻辑的话,可以配合CF引脚的外部中断实现毫秒级响应,而不是依赖寄存器轮询。

如果跑FreeRTOS这类系统,建议把串口接收做成队列或者二值信号量:中断里收到完整校验帧后,把6字节原始帧放进队列,由一个独立解析任务取出帧并调用 frame_handler 。这样协议解析和业务逻辑完全解耦,排查问题也容易定位。Linux下做驱动同理,底层就是tty串口加字符设备框架,协议层这份状态机代码可以原样搬过去。

6. 校准流程与实战踩坑:让0.1W级别的读数真正可信

6.1 一套完整的校准操作流程

校准说白了就是干一件事:让驱动里的系数等于“标准表读数÷寄存器原始值”。我习惯按下面的顺序来做:

  1. 准备一只质量过得去的标准功率计,或者精度较高的计量插座,再准备一个稳定负载,白炽灯、电暖器都可以,阻性负载波形干净,最不容易引入功率因数误差。
  2. 上电后先等1分钟,让芯片和电源都稳定下来,再连续读20次电压寄存器取平均,同时记录标准表上的电压读数,相除得到电压系数。
  3. 接上负载,等功率稳定后,同时读电流和功率寄存器,分别与标准表读数和有功功率读数相除,得到电流系数和功率系数。
  4. 把3个系数写入Flash的配置分区,驱动上电时加载。建议只写软件系数,不要一上来就改CORRECTION增益修正寄存器,软件标定灵活,还能OTA修正,产测节奏也好安排。

按照这个流程,无隔离直连方案下把电压误差做到0.5%以内、功率误差做到1%以内并不难。如果对全量程精度有更高要求,可以分小电流、大电流两个点标定,用分段线性或者简单二次曲线拟合非线性段。

6.2 出厂为什么不做准,以及公模模块为什么不能直接抄

产品能出厂,是因为它经过了产线标定,而不是因为芯片天生就准。电压分压电阻有1%或者0.5%的误差,锰铜分流器的阻值也有离散性,这些硬件偏差最后都会反映到寄存器原始值上。所以从淘宝买的公模计量模块,即便卖家贴出了“电压乘以0.0007”这类系数,也只对卖家的那一批板子成立,你手里的板子电阻批次一变,照样会偏。

这也是我在第4节反复强调“系数一定要自己标”的原因。抄驱动代码没问题,协议和状态机是通用的;但抄系数等于默认你的硬件和别人的硬件完全一致,这种事在中低频量产里几乎不可能发生。

6.3 三个高频故障的排错链路

先说“电压能读出来,但电流和功率一直是0”的情况。这种故障九成出在采样通道:电流采样线虚焊、采样电阻被跳线短路、或者IP和IN引脚被静电打坏。排查时先用万用表从分流器两端量到芯片引脚,确认通路和阻值,再用示波器看芯片引脚上有没有交流波形,基本两步就能定位。

再说“数据偶发校验失败”。这种问题多半不是协议本身的问题,而是中断和主循环之间发生了数据竞争。比如中断里校验通过后直接更新了业务结构体,主循环同时又在读这个结构体,就会拼出高低字节错位的脏数据。解决方法是中断里只更新缓存区并置标志,业务层统一按标志去消费数据;如果缓存区是多字节结构,读的时候最好关一下中断保护。

最后是“怎么调都读不对”。这时候先核对丝印,确认你买的是BL0940而不是BL0942。这两颗芯片不是pin to pin兼容的,封装、引脚、寄存器都不同,网上很多人买到BL0942后照着BL0940的代码去调,把UART波特率和寄存器猜了一遍都不对,最后才发现在芯片选型时就错了。拿到新模块的第一步永远是确认丝印,这个习惯能省掉大量的无效调试。

6.4 容易被忽略的CF引脚设计价值

CF引脚是芯片的能量脉冲输出,频率和实时功率成正比。很多驱动代码完全忽略它,其实它在低功耗产品里作用很大:把CF引脚接到MCU的外部中断上,让主控深度睡眠,只靠CF脉冲唤醒去累加电能或上报数据,比定时醒来轮询寄存器省电得多。我做低功耗计量插座时,就是用CF脉冲做唤醒源,配合寄存器轮询做校验,两条数据链路互相印证,整机平均功耗降了一个数量级。

最后再分享一点经验

做完这个驱动,我最大的体会是:驱动代码要尽量“笨”一点,把职责划清楚。串口中断只负责收字节,状态机只负责拼帧校验,业务层只负责乘系数和上报。三层之间不要互相越界,尤其不要在中断里写业务逻辑,这样出问题时你永远能通过加打印来定位是哪一层出了问题。

另一个实用建议是把标定系数和驱动代码彻底分开存。系数是产品数据,属于每一块板子的“个体差异”,换固件不应该丢,产测写入也不应该改代码。把系数放在独立Flash分区后,批量换电阻批次、修正标定算法都只需要重新写数据,不用折腾编译和烧录流程。处理好这两件事,BL0940这颗芯片用起来会非常省心。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

更多推荐