☰
ATT7022三相电能计量程序:SPI驱动、寄存器换算与三点校准
2026/10/26 5:03:23 网站建设 项目流程

简介:面向电力计量与嵌入式开发者的 ATT7022 电能计量芯片 C 语言程序代码包,聚焦高精度单相电能采集的软件实现,针对初始化配置、数据采集、功率与电能计算及通信接口驱动等实际编程问题,适合已具备单片机基础、希望打通芯片寄存器操作与算法落地的中初级工程师参考。压缩包共 13 个文件、约 58KB,以 C 源码为主体,辅以汇编、工程与链接配置、HEX 烧录文件、列表与映射输出等编译中间产物,构成一套可直接编译验证的完整工程骨架。目前已有 1354 人学习下载。读者可从中获得寄存器初始化与采样频率设置的写法、周期采集任务的调度思路、瞬时功率积分累计电能的浮点处理细节,以及 I2C 或 SPI 读写时序的驱动参考;同时包含通信异常与数据溢出的检测机制、低功耗采样策略和实时性取舍的实践样例,便于按项目需求裁剪扩展。

1. 一块三相表的开机自检:ATT7022 程序代码到底要解决什么

样机上电,LCD 全 0,串口打印出来的电压电流一动不动——这是我第一次接手三相电能计量项目时的场面。板子上焊着一颗 ATT7022,互感器、分压电阻、晶振看着都没问题,卡住的地方在于没人写过一套能用的驱动和换算层。这颗芯片本身已经把有效值、有功/无功功率、电能脉冲都算好了,MCU 要做的其实只有三件事:把 24 位寄存器读对、把 LSB 换算成工程量、把累加和校准做扎实。

这份 ATT7022 程序代码解决的就是这三件事:SPI 时序驱动、寄存器映射、系数换算、电能累加、三点校准、参数掉电保存,一整套从裸机到应用层的链路。适合两类人看:一是刚拿到计量芯片、手上只有一份寄存器手册不知道从哪下手的嵌入式新手;二是已经有表在做,但精度总在临界值上晃、想找人对着抄一遍校准流程的老手。代码本身不复杂,难的是那些手册上不会写、只有实际调试时才能明白的细节。

2. 分层拆解:从 board 层到 app 层的目录与技术栈

2.1 硬件底子与软件栈:裸机 SPI + 定时任务就够

这份代码对 MCU 的要求低到几乎没有门槛:一颗带硬件 SPI(或者干脆用 GPIO 模拟)、几十 KB Flash、几 KB RAM 的单片机就能跑。选择裸机而不是 RTOS,原因很实际——计量任务的节奏是固定的,电压电流每 200ms 采一轮、电能每 1s 累加一次、显示每 500ms 刷一次,这种周期用 SysTick 加一个任务表就能安排得明明白白,上 RTOS 反而多了一层调度开销和栈溢出的风险。

技术栈大致是这样几块:

层次内容依赖
端口层GPIO 翻转、微秒延时MCU SDK
驱动层SPI 时序、24 位寄存器读写、复位初始化端口层
换算层LSB 到 V/A/W/kWh 的系数运算驱动层
应用层电能累加、需量、事件判断、参数管理换算层
通信层串口 Modbus RTU 从机、红外应用层

常见的做法是把端口层单独抽成一个att7022_port.h,里面只有六七个函数声明:拉低片选、拉高片选、时钟高低、MOSI 输出、MISO 读取、微秒延时。换 MCU 的时候只改这一个文件的实现,上层一行不动。这个习惯我在三四个项目里都保留了,血泪经验是——不抽这一层,第一次换平台就要把驱动重写一遍,还得重新调时序。

延时函数这里有个坑值得提前说:不要用for循环空转凑微秒。编译器优化等级一变,循环次数就变了,时序直接崩。我一般用 MCU 的 DWT 周期计数器或者一个已经跑起来的定时器做基准,实在没有就拿示波器标定一个nop循环,把系数写成宏。

/* att7022_port.h —— 移植时只改这一个文件的实现 */ #ifndef __ATT7022_PORT_H #define __ATT7022_PORT_H #include <stdint.h> void att_port_cs_low(void); void att_port_cs_high(void); void att_port_sclk_low(void); void att_port_sclk_high(void); void att_port_mosi(uint8_t level); uint8_t att_port_miso(void); void att_port_delay_us(uint16_t us); /* 必须是真延时,不能是空循环 */ #endif

这里的att_port_delay_us是关键。ATT7022 的 SCLK 频率上限不高,用 GPIO 模拟时半周期给到 1~2 微秒是安全区,太快容易在读数据阶段丢位。参数us传 1 就是半周期,传 2 是片选前后的建立保持时间。

2.2 目录结构:五层分工,改动只落在两处

代码包解开之后的目录层次大致长这样,不同人写会有出入,但分层的思路基本一致:

att7022_meter/ ├── board/ 板级支持:时钟、GPIO、串口、EEPROM │ ├── bsp_gpio.c │ ├── bsp_uart.c │ └── bsp_eeprom.c ├── driver/ │ ├── att7022_port.h 端口抽象 │ ├── att7022_port.c 具体 GPIO 实现 │ ├── att7022_reg.h 寄存器地址与位定义 │ └── att7022.c SPI 时序 + 读写接口 ├── meter/ │ ├── meter_coef.c/.h 校准系数存储与加载 │ ├── meter_calc.c/.h LSB 转工程量 │ ├── meter_energy.c/.h 电能累加与回绕处理 │ └── meter_cal.c/.h 增益/相位/偏置校准 ├── app/ │ ├── app_main.c 任务调度 │ ├── app_display.c │ └── app_event.c 失压、断相、开盖判断 └── protocol/ └── modbus_slave.c

真正需要动的目录只有两个:driver/att7022_port.c(换硬件时改)和meter/(换表规格、换量程、换校准点的时候改)。app/和protocol/属于业务层,跟芯片无关。

我一般会把寄存器地址全部塞进att7022_reg.h,用宏而不是枚举,因为有些编译器对枚举做位宽优化,写uint8_t addr传参的时候会冒出警告:

/* att7022_reg.h —— 只放地址,不放业务 */ #define REG_P_A 0x01u /* A 相有功功率,24 位有符号 */ #define REG_P_B 0x02u #define REG_P_C 0x03u #define REG_P_TOTAL 0x04u /* 合相有功功率 */ #define REG_Q_TOTAL 0x08u /* 合相无功功率 */ #define REG_S_TOTAL 0x0Cu /* 合相视在功率 */ #define REG_UA_RMS 0x0Du /* A 相电压有效值,24 位无符号 */ #define REG_UB_RMS 0x0Eu #define REG_UC_RMS 0x0Fu #define REG_IA_RMS 0x10u #define REG_IB_RMS 0x11u #define REG_IC_RMS 0x12u #define REG_PF_TOTAL 0x13u /* 合相功率因数,有符号 */ #define REG_FREQ 0x14u /* 电网频率 */ #define REG_ANGLE_A 0x15u /* A 相电压电流夹角 */ #define REG_EP_A 0x2Du /* A 相有功电能 */ #define REG_EP_B 0x2Eu #define REG_EP_C 0x2Fu #define REG_EP_TOTAL 0x30u /* 合相有功电能,重点看这个 */ #define REG_EQ_TOTAL 0x34u /* 合相无功电能 */

提示:不同后缀(B/C/E 系列)和不同版本手册之间,个别地址会挪位置,也可能多出谐波、基波分离之类的寄存器。上面这份是我这套代码实际在用的映射,动手前拿手头手册对一遍地址表,二十分钟的事,能省掉两天排查。

2.3 寄存器地图:动手前先把这张表抄在纸上

寄存器分成三类,用途完全不同,混着看很容易糊涂。

第一类是瞬时值寄存器,功率、有效值、相角、频率都在这一片,芯片内部每隔一个采样周期刷新一次。这类寄存器用来做校准和显示,读得再快也不会累加。

第二类是电能寄存器,24 位,只增不减(或者只减不增,取决于方向),累加到 0xFFFFFF 之后回绕到 0。它才是真正结算用的数据源,MCU 定时去读、做差值累加。

第三类是校准与配置寄存器,增益、相位、偏置、模式配置、脉冲常数都写在这里。这类寄存器一般有写保护,要先往一个约定地址写解锁值,写完再锁回去,否则掉电重上电参数就丢了。

类别地址范围位宽/格式典型用途
瞬时功率0x01 ~ 0x0C24 位有符号校准主战场、实时功率显示
有效值0x0D ~ 0x1224 位无符号电压电流显示、失压断相判断
功率因数/频率/相角0x13 ~ 0x17有符号/无符号相位校准、频率监测
电能0x2D ~ 0x3424 位,回绕电量结算
校准/配置0x30 之后特殊区24 位增益、相位、偏置、模式

功率因数和相角这两类是有符号的,频率是无符号的,有效值是无符号的,功率是有符号的——这个格式区分一定要在代码里落实成不同的读取函数,否则负功率被当成 1600 多万的正数,表会显示成天文数字。我第一次就栽在这里,掉电后重新上电,表上显示功率 1600 万瓦,当场愣住。

3. SPI 驱动:一个地址字节加三个数据字节的时序账

3.1 端口抽象层:换 MCU 只改一个文件

端口层的实现简单到不值一提,但有几个细节决定成败。片选和数据线在没有操作的时候要保持确定电平,MISO 如果是复用推挽输出就要在初始化时切成浮空输入或者上拉输入,否则读回来永远是 0。

/* att7022_port.c —— 以某款通用 Cortex-M 为例,实际按你的 SDK 改 */ #include "att7022_port.h" #include "bsp_gpio.h" void att_port_cs_low(void) { GPIO_ResetBits(ATT_CS_PORT, ATT_CS_PIN); } void att_port_cs_high(void) { GPIO_SetBits (ATT_CS_PORT, ATT_CS_PIN); } void att_port_sclk_low(void) { GPIO_ResetBits(ATT_SCLK_PORT, ATT_SCLK_PIN); } void att_port_sclk_high(void){ GPIO_SetBits (ATT_SCLK_PORT, ATT_SCLK_PIN); } void att_port_mosi(uint8_t level) { if (level) GPIO_SetBits(ATT_MOSI_PORT, ATT_MOSI_PIN); else GPIO_ResetBits(ATT_MOSI_PORT, ATT_MOSI_PIN); } uint8_t att_port_miso(void) { /* 读之前确保引脚已配成输入,读到的是真实电平 */ return GPIO_ReadInputDataBit(ATT_MISO_PORT, ATT_MISO_PIN) ? 1u : 0u; } void att_port_delay_us(uint16_t us) { /* 用 DWT 周期计数器做基准,不依赖循环次数 */ uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000u); while ((DWT->CYCCNT - start) < ticks) { } }

att_port_cs_low和att_port_cs_high必须成对出现,中间不能有其它 SPI 设备抢占总线。这块板上如果同时挂了 EEPROM 或者 LCD 驱动,一定要用互斥标志或者干脆分时调度,否则一帧数据被插进来,读到的就是两个设备的混合数据。

att_port_miso里那句注释是我特意加的——很多 MCU 的 GPIO 在初始化时默认是输出,如果忘记把 MISO 切成输入,GPIO_ReadInputDataBit读回来的其实是输出寄存器里的值,永远是 0,现象就是所有寄存器都读成 0x000000,特别容易误判成芯片坏了。

3.2 读写 24 位:命令字节的最高位是方向位

ATT7022 的 SPI 帧格式很朴素:先发 8 位命令字节,最高位是方向位,1 表示读、0 表示写,低 7 位是寄存器地址;紧接着是 24 位数据,高位在前。也就是说读一个寄存器要 32 个时钟,写一个寄存器也是 32 个时钟,位宽完全对称。

/* 发送 8 位命令/地址 */ static void att_send_cmd(uint8_t cmd) { for (uint8_t i = 0; i < 8; i++) { att_port_sclk_low(); att_port_mosi((cmd & 0x80u) ? 1u : 0u); att_port_delay_us(1); att_port_sclk_high(); att_port_delay_us(1); cmd <<= 1; } } /* 连续读 24 位,高位在前 */ static uint32_t att_read_data24(void) { uint32_t v = 0; for (uint8_t i = 0; i < 24; i++) { att_port_sclk_low(); att_port_delay_us(1); v <<= 1; if (att_port_miso()) { v |= 1u; } att_port_sclk_high(); att_port_delay_us(1); } return v; } /* 对外接口:读寄存器 */ uint32_t att7022_read_reg(uint8_t addr) { uint32_t raw; att_port_cs_low(); att_port_delay_us(1); att_send_cmd(addr | 0x80u); /* bit7 = 1,读方向 */ att_port_delay_us(1); raw = att_read_data24(); att_port_cs_high(); att_port_delay_us(2); /* 片选释放后的恢复时间 */ return raw; }

att_read_data24里先移位再采样,是因为数据在 SCLK 的下降沿移出、上升沿稳定,采样点落在低电平期间最保险。有人喜欢在上升沿之后采样,也能跑,但在长线或者慢速光耦隔离的板子上会偶发丢位,表现出来就是功率偶尔跳一下——这种玄学问题查起来最难受。

写操作的逻辑一样,只是方向位清零,然后按位把 24 位数据推出去:

/* 对外接口:写寄存器 */ void att7022_write_reg(uint8_t addr, uint32_t val24) { att_port_cs_low(); att_port_delay_us(1); att_send_cmd(addr & 0x7Fu); /* bit7 = 0,写方向 */ for (int8_t i = 23; i >= 0; i--) { att_port_sclk_low(); att_port_mosi((val24 >> i) & 1u); att_port_delay_us(1); att_port_sclk_high(); att_port_delay_us(1); } att_port_cs_high(); att_port_delay_us(2); }

参数val24只有低 24 位有效,高位会被移位操作自动丢掉,所以传int32_t进去也不会出错。这点在写负的偏置值时很方便,不用手动截断。

3.3 上电初始化:先复位,再等稳定,最后才是读

上电之后不能直接读寄存器。芯片内部的数字部分需要一个稳定时钟,电源也需要爬升时间,官方手册里通常会给出一个最小等待时间。我一般保守给 1ms,这颗料不差这一毫秒。

int att7022_init(void) { att_port_cs_high(); att_port_delay_us(1000); /* 等电源和内部振荡器稳定 */ att7022_soft_reset(); /* 复位数字部分,内部寄存器回默认值 */ att_port_delay_us(200); /* 随便读一个有效值寄存器,判断总线通不通 */ uint32_t probe = att7022_read_reg(REG_UA_RMS); if (probe == 0x000000u || probe == 0xFFFFFFu) { return -1; /* 全 0 / 全 1 基本都是接线或时序问题 */ } att7022_unlock(); /* 解除校准寄存器写保护 */ att7022_write_reg(REG_MODE_CFG, MODE_3P4W); /* 三相四线 */ /* 打开偏置校准,然后写各通道初始增益(一般给 1.0 对应的默认值) */ att7022_write_reg(REG_GAIN_UA, GAIN_DEFAULT); att7022_write_reg(REG_GAIN_IA, GAIN_DEFAULT); att7022_lock(); /* 写保护重新打开 */ att_port_delay_us(100); return 0; }

这段代码里有两个值得说的点。

第一个是探针寄存器。不是每个批次都能读到芯片 ID,有的版本压根没做 ID 寄存器,那就退一步,读电压有效值寄存器,看数字是不是一个稳定的小值(比如几万到几十万,视分压比而定)。如果读出来是个固定的 0xFFFFFF 或者干脆每次都不一样,八成是 SPI 接线问题,不是芯片问题。

第二个是att7022_unlock()。校准寄存器写完必须加锁,不然正常运行时一个干扰就能把系数改了,表就跑飞了。我见过一个现场案例,表装在配电柜里,接触器一吸合系数就被冲掉一次,查了半个月才定位到是没加写保护。

提示:复位之后内部寄存器的默认值不一定等于你要的配置。模式寄存器默认可能是三相三线,如果你的表是三相四线,不显式写一遍就会出现“B 相数据一直为 0”的现象。

4. 计量应用层:有效值、功率与电能累加的换算链路

4.1 从 LSB 到工程量:三个系数的来源

寄存器里躺着的都是 24 位整数,要变成 220.35V、5.02A、1103.2W,中间隔着一层系数。系数不是固定的,它跟你的分压电阻比、互感器变比、采样电阻阻值强相关,所以只能在实物上标定出来。

typedef struct { float kv; /* 电压系数,单位 V/LSB */ float ki; /* 电流系数,单位 A/LSB */ float kp; /* 有功功率系数,单位 W/LSB */ float kq; /* 无功功率系数,单位 var/LSB */ float kwh; /* 电能系数,单位 kWh/LSB */ } meter_coef_t; static meter_coef_t s_coef; /* 读一次完整采样,全部转成工程量 */ void meter_read_sample(meter_sample_t *s) { s->ua = att7022_read_reg(REG_UA_RMS) * s_coef.kv; s->ub = att7022_read_reg(REG_UB_RMS) * s_coef.kv; s->uc = att7022_read_reg(REG_UC_RMS) * s_coef.kv; s->ia = att7022_read_reg(REG_IA_RMS) * s_coef.ki; s->ib = att7022_read_reg(REG_IB_RMS) * s_coef.ki; s->ic = att7022_read_reg(REG_IC_RMS) * s_coef.ki; s->pa = att7022_read_signed(REG_P_A) * s_coef.kp; s->pb = att7022_read_signed(REG_P_B) * s_coef.kp; s->pc = att7022_read_signed(REG_P_C) * s_coef.kp; s->pt = att7022_read_signed(REG_P_TOTAL) * s_coef.kp; s->freq = att7022_read_reg(REG_FREQ) * 0.01f; /* 频率一般是 0.01Hz/LSB */ s->pf = att7022_read_signed(REG_PF_TOTAL) * 0.001f; }

这里的att7022_read_signed是 4.1 以外的另一个必要函数,它把 24 位补码扩展成 32 位有符号数:

int32_t att7022_read_signed(uint8_t addr) { uint32_t raw = att7022_read_reg(addr); if (raw & 0x800000u) { /* 第 23 位是符号位 */ return (int32_t)(raw | 0xFF000000u); } return (int32_t)raw; }

0xFF000000这个掩码的作用是把高 8 位全部补成 1,实现符号扩展。不做这一步,-1000W 会被当成 16776216,显示出来就是 1600 万瓦。这个坑我提过,但真的每个新手都会踩一次。

系数kp的物理来源是这样的:芯片内部对电压采样值和电流采样值做乘法,得到瞬时功率,再累加平均。它输出的满量程功率对应的 LSB 是固定的,乘以你的分压比和电流变比,才是实际功率。所以kp一般写成:

/* 校准得到的实际值 / 同一点上的寄存器原始值 */ s_coef.kp = P_standard / (float)p_raw_at_standard;

4.2 电能累加:24 位寄存器的回绕必须自己处理

电能寄存器是整份代码里最容易出问题的地方,因为它是唯一一个会一直涨、涨到头再回绕的寄存器。24 位,最大值 16777215,按常见脉冲常数折算,满量程可能只对应十几度电,一天就能回绕好几次。

累加的逻辑必须写成差值形式,不能直接读绝对值:

static uint32_t s_last_raw = 0; static uint64_t s_acc_lsb = 0; /* 累加值,单位是寄存器 LSB */ static uint8_t s_first_read = 1; void energy_poll(void) { uint32_t raw = att7022_read_reg(REG_EP_TOTAL); if (s_first_read) { s_last_raw = raw; s_first_read = 0; return; /* 第一次只记基准,不累加 */ } if (raw >= s_last_raw) { s_acc_lsb += (uint32_t)(raw - s_last_raw); } else { /* 跨过 0xFFFFFF 回绕 */ s_acc_lsb += (uint32_t)(0x1000000u - s_last_raw) + raw; } s_last_raw = raw; } /* 换算成 kWh */ float energy_get_kwh(void) { return (float)s_acc_lsb * s_coef.kwh; }

s_acc_lsb用uint64_t不是浪费。24 位寄存器回绕一次是 1600 万,如果每天回绕 10 次、表要跑 10 年,累加值会超过 32 位上限,用 32 位会在若干年后悄悄归零——这种问题在现场暴露出来是最难查的,谁也不会想到是累加器溢出。

s_first_read这个标志也别省。上电第一次读到的值只是基准,不能算进电量,否则每次上电都要多算一截。有的实现把上一次的值存进 EEPROM,上电直接接着算,那更要注意掉电瞬间的写入顺序——先存电量还是先存基准,顺序反了就会重复计费。

掉电保存我一般用双备份加校验:

typedef struct { uint32_t magic; uint64_t acc_lsb; uint32_t last_raw; uint16_t crc; } energy_save_t; void energy_save(void) { energy_save_t rec; rec.magic = 0x454E4552u; /* 'ENER' */ rec.acc_lsb = s_acc_lsb; rec.last_raw = s_last_raw; rec.crc = crc16((uint8_t *)&rec, sizeof(rec) - sizeof(uint16_t)); eeprom_write_bank(0, &rec, sizeof(rec)); eeprom_write_bank(1, &rec, sizeof(rec)); /* 写两份,读的时候挑校验通过的 */ }

4.3 校表三件套:增益、相位、偏置的顺序不能乱

校准是这份代码最值钱的部分,也是最容易做错的部分。顺序必须是增益 → 相位 → 偏置,反过来做,前一步的结果会被后一步带偏。

增益校准在额定电压、额定电流、功率因数 1.0 的条件下做。加标准源,读芯片功率,和标准表读数比:

void cal_gain(float u_std, float i_std, float p_std) { /* 1. 电压通道 */ uint32_t u_raw = att7022_read_reg(REG_UA_RMS); s_coef.kv = u_std / (float)u_raw; /* 2. 电流通道 */ uint32_t i_raw = att7022_read_reg(REG_IA_RMS); s_coef.ki = i_std / (float)i_raw; /* 3. 功率通道 */ int32_t p_raw = att7022_read_signed(REG_P_TOTAL); s_coef.kp = p_std / (float)p_raw; /* 4. 把系数折算成寄存器增益写回去,让原始值直接落在便于观察的范围 */ att7022_unlock(); att7022_write_reg(REG_GAIN_UA, gain_code_from(s_coef.kv)); att7022_write_reg(REG_GAIN_IA, gain_code_from(s_coef.ki)); att7022_lock(); }

相位校准只在功率因数 0.5L(也就是 60°)的条件下做。为什么?因为在 60° 附近,功率对相位误差的敏感度是tan(60°)≈1.73,也就是说 0.1° 的相位偏差会造成约 0.3% 的功率误差,放大效应最明显。校准公式可以这么推:

/* 在 PF=0.5L 点,测得相对误差 e,反推相位补偿角 */ float e = (p_meas - p_std) / p_std; /* 相对误差,正数表示读大了 */ float dphi = -e / 1.732f; /* 弧度,1.732 就是 tan(60°) */ /* s_phase_k 是实测出来的换算斜率,单位 寄存器码/弧度 */ int32_t code = (int32_t)(s_phase_k * dphi); att7022_unlock(); att7022_write_reg(REG_PHASE_A, (uint32_t)(code & 0xFFFFFF)); att7022_lock();

符号这里要留个心眼。dphi前面的负号取决于你的电流通道是正接还是反接、寄存器是增函数还是减函数,不同板子可能正好相反。我一般的做法是先按公式算一遍,看误差是变小还是变大,变大了就把符号翻过来,翻一次就够,别在公式里反复纠结。

偏置校准在 5% 额定电流、功率因数 1.0 的条件下做,目的是把 ADC 底噪和小信号非线性压下去。做法很直接:加 5%Ib 的电流,读功率,减掉理论功率,差值折算成偏置码写进偏置寄存器。

void cal_offset(float i_small, float p_std_small) { int32_t p_raw = att7022_read_signed(REG_P_TOTAL); float p_meas = (float)p_raw * s_coef.kp; float delta = p_meas - p_std_small; /* 理论上应该接近 0 */ int32_t code = (int32_t)(delta / s_coef.kp); att7022_unlock(); att7022_write_reg(REG_OFFSET_P, (uint32_t)(code & 0xFFFFFF)); att7022_lock(); }

三个点校完之后,一定要回到 100%Ib、50%Ib、5%Ib、1%Ib 各打一遍点,看全量程误差是否都落在精度等级要求之内。如果只有小电流点超差,那是偏置没校好;如果 0.5L 点超差,相位还得再调;如果全量程都偏同一个方向,增益系数算错了。

5. 避坑与排查:调 ATT7022 最容易翻车的五个现象

5.1 时序与接线类:读回来全是 0 或全是 1

现象一:所有寄存器读回来都是 0x000000 或 0xFFFFFF,读多少次都一样。

原因通常有两个:MISO 引脚没有切成输入模式,MCU 读的是自己输出寄存器里的值;或者片选根本没拉低,芯片压根没响应。还有一种可能是 SPI 模式接反了,数据在错误的时钟沿被采样。

解决顺序是先用万用表量 MISO 静态电平,芯片正常输出时应该有确定的电平(不是悬空)。再用逻辑分析仪抓一次片选和时钟,看帧长度是不是 32 个时钟、片选是不是在整个帧期间保持低电平。如果手上没有分析仪,就把 SCLK 半周期从 1 微秒加到 10 微秒试一次,很多时候是模拟 SPI 太快导致丢位。

现象二:读电压电流正常,一读功率就跳成天文数字,或者符号来回翻。

这是 24 位符号扩展没做,把负数当成了大正数。检查att7022_read_signed里那个0x800000判断,以及返回值类型是不是int32_t。还有一种情况是变量声明成了uint32_t,赋值的时候被隐式转换,编译器一句警告都不给。

解决方法是把整个链路的数据类型统一:寄存器原始值用uint32_t,转换后立刻转int32_t,工程量用float,别在中间混用。打开编译器的-Wconversion,能挡掉一大半。

现象三:接上互感器之后功率是负的,但接线看着没反。

真正的原因可能是电流互感器的同名端接反了,也可能是三相四线接法里某一相的电压和电流没对上。还有一种比较隐蔽:三相三线和三相四线的模式寄存器配错了,芯片内部按错误的相序做矢量运算,合成功率自然不对。

解决办法是先单相验证:只给 A 相加电压电流,看 B、C 两相读数是不是接近 0。如果 B 相也有读数,说明模式配置错了。单相正常之后再接三相,问题范围立刻缩小一半。

现象四:单独测每一相都准,合相功率却对不上分相之和。

这种情况我遇到过两次,都是采样不同步导致的。三相电压电流不是同一时刻采的,动态负载下合相值和分相和就会有偏差。检查读取顺序,把 A/B/C 三相的读取放在尽可能短的窗口内完成,中间不要插入串口打印之类的耗时操作。

另一个可能是合相寄存器本身的计算方式跟你想的不一样,有的版本合相功率是矢量和,不是简单代数和。看手册里合相功率的定义,别想当然。

5.2 数据与精度类:符号、回绕、系数

现象五:校完表当场很准,跑一夜回来误差又回到原点。

十个里面有八个是校准参数没存进 EEPROM,或者存进去了但上电没加载。检查meter_coef_load()是不是在att7022_init()之后被调用了,以及 EEPROM 的 magic 校验是不是被写成了 0。还有一种情况是参数存了,但写入时没加写保护解锁,写进去的值被芯片忽略了,读回来还是默认值。

我一般会在上电时把加载到的系数通过串口打一行出来,格式化成kv=0.012345 ki=0.000987 kp=0.003210,每次开机扫一眼。这一行日志救过我不少次,尤其是换了新板子、系数还没标定的时候。

还有一个更隐蔽的:系数存在 EEPROM 里用float类型,但 EEPROM 的读写函数是按字节操作的,大小端没对齐,读出来的浮点数是个乱七八糟的值。解决办法是统一按uint32_t存位模式,读写都用整数,用的时候memcpy转回float。

现象六(顺带提一句):电能偶尔跳变一大截。

除了前面说的回绕处理,还有一个原因是读电能寄存器的时候正好赶上内部更新,读到了半新半旧的数据。常见的做法是连续读两次,两次一致才采用,不一致就重读。这个逻辑加进去之后,跳变基本就没了。

6. 二次开发:在现有代码上接 Modbus 和费率

拿到这套代码之后,多数人的下一步是接通信和做费率。方向不难,但有几个地方值得提前规划。

通信层我建议单独开一个protocol/modbus_slave.c,把内部数据映射成一段连续的保持寄存器区,读写都走这张表。这样一来,Modbus 协议本身跟业务完全解耦,加一个量只需要在表里加一行。

/* 内部数据到 Modbus 保持寄存器的映射,寄存器地址从 0x0000 起 */ typedef struct { uint16_t addr; uint16_t (*get)(void); } mb_reg_map_t; static const mb_reg_map_t s_map[] = { { 0x0000, mb_get_ua_x10 }, /* A 相电压,0.1V 为单位 */ { 0x0001, mb_get_ub_x10 }, { 0x0002, mb_get_uc_x10 }, { 0x0003, mb_get_ia_x100 }, /* A 相电流,0.01A 为单位 */ { 0x0010, mb_get_pt_low }, /* 合相有功功率低 16 位 */ { 0x0011, mb_get_pt_high }, /* 高 16 位,拼起来才是完整值 */ { 0x0020, mb_get_kwh_low }, { 0x0021, mb_get_kwh_high }, };

32 位的电量必须拆成两个 16 位寄存器,这个没什么商量余地,Modbus 保持寄存器就是 16 位。注意高低字顺序,不同主站的习惯不一样,有的先高后低,有的反过来,调试的时候先用一个小脚本扫一遍,确认主站怎么解析,再决定要不要加一个字节序配置项。

费率功能本身不难,难点在时段表和时间基准。常见做法是维护一张最多 8 个时段、4 个费率的表,每秒比对一次当前时间落在哪个时段,把电能差值累加到对应的费率寄存器里。时间基准要么用外部 RTC 芯片,要么用 MCU 内部 RTC 加晶振,后者在温差大的现场会漂,一个月差个几分钟很正常,需要定期校时。

typedef struct { uint8_t hour; uint8_t minute; uint8_t rate; /* 0=尖 1=峰 2=平 3=谷 */ } tou_point_t; /* 找到当前时间所属的费率,时段表按时间升序排列 */ static uint8_t tou_rate_of(uint8_t hour, uint8_t minute) { uint8_t rate = 3; /* 默认按谷算,找不到就落到最便宜的档 */ for (uint8_t i = 0; i < s_tou_count; i++) { if (hour > s_tou[i].hour || (hour == s_tou[i].hour && minute >= s_tou[i].minute)) { rate = s_tou[i].rate; } else { break; } } return rate; }

验证方法上,我现在习惯做两步。第一步是台架验证:用可编程交流源打几个固定点(100%Ib、50%Ib、5%Ib,PF=1.0 和 0.5L),跟标准表比对,误差记在表格里,看是否单调、是否有跳变。第二步是长时间运行:接一个稳定的负载跑 24 小时,每小时读一次电量,跟标准表比对累计误差,同时观察有没有回绕、有没有掉电保存异常。

改造过程中最容易忽略的是升级兼容。现场已经有表在跑,新固件要能读旧固件存的 EEPROM 格式。我的习惯是给参数区加一个版本号字段,新固件读到老版本号就做一次迁移,把老的float系数转成新的定点格式再存回去。这个版本号字段一开始就加进去,后面能省很多事。

从那以后,我每次动计量相关的代码,哪怕是改一个换算系数,都强制走一遍“三点校准 + 24 小时跑表”的流程,不管改动多小。因为这颗芯片的输出是 24 位整数,中间任何一步类型转换、任何一次系数加载失败,在显示上可能只差 0.1%,但在结算上就是实打实的偏差。希望帮到你。

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

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

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

立即咨询