简介:一份面向光伏储能领域开发人员的Modbus协议规范资料,针对Solax SK-SU系列混合太阳能逆变器,解决设备远程监控、参数读写与系统联调时的通信协议适配问题。资源为单个PDF文档,压缩包大小1.58MB,全文以寄存器说明和版本变更记录为主,适合嵌入式工程师、逆变器运维人员及能源管理系统开发者查阅。目前已有87人学习下载。文档内容较为完整,除基础的协议类型、端口号、帧格式与请求时序外,还逐项列出各寄存器的地址、读写属性、单位及数据格式;同时梳理了从V1.0到V2.50的功能演进,涵盖EPS功能寄存器、远程控制模式、电池唤醒与健康状态、安全配置、继电器控制、BMS警报等模块,便于对照不同固件版本快速定位功能差异,也可作为开发调试或反向解析同类逆变器Modbus接口的参考资料。对于需要接入第三方监控平台或自行开发上位机的工程团队,具有较强的直接参考价值。
1. 一张寄存器表为什么能遥控一台光储逆变器
在光储逆变器内部,主控芯片几乎每秒都在做同一个循环:采样电网电压、跟踪光伏最大功率、计算电池充放电电流、判断并网保护条件。计算密集到日志系统都要省着用,更别提对外提供什么通用服务接口。而某厂商 SK-SU 系列混合逆变器把整台设备的对外控制面压缩成了一张 Modbus TCP 寄存器表——读 0x0003 知道光伏电压,写 0x00D5 限制并网功率,看 0x00DA 的某个位就知道机器是软件关还是硬件关。这张表从 V1.0 草稿迭代到 V2.50,新增了 EPS 应急供电、远程功率控制、并网自测、继电器控制,每次修订都对应设备能力的一次扩张。搞懂寄存器规划、定标规则和位打包逻辑,就等于拿到了这台逆变器数字内核的透视图。
2. 通信底座:Modbus TCP 的 502 端口、帧格式与 1 秒间隔约束
2.1 为什么偏偏是 Modbus TCP:主从问答是最稳妥的工业远程协议
光伏逆变器不是普通消费电子。它装在配电房、屋顶和储能柜里,旁边就是变频器和电机,电网电压自己还在波动,主控每秒要处理上百个中断。这种场景选通信协议,第一需求不是快,而是确定性。Modbus 的核心模型是主从问答——主机发一帧请求,从机回一帧响应,没有总线竞争,没有冲突检测,没有节点主动上报。无论线路上有多少干扰,只要一问一答能完成,双方对设备状态的理解就是一致的。这套模型从 1979 年的 PLC 串口时代一直活到今天,靠的就是「简单到不可能出错」。
选 Modbus TCP 而不是更现代的协议,还有三个现实理由。TCP 端口 502 是 IANA 为 Modbus 保留的知名端口,意味着任何语言的网络库都能直接构造报文,不需要额外装驱动或组态软件;以太网的物理层隔离和差分信号天生比 RS-485 抗干扰;一台逆变器只需一个 IP 就能同时服务本地调试、远程监控和能量管理平台三个角色。对协议维护方来说,Modbus 的门槛足够低——一套寄存器表加功能码说明,就是完整接口规范,不需要定义对象字典、服务发现或加密握手这些额外概念层。
代价同样明显:Modbus 没有事件主动上报,没有按名字查变量的能力,也没有鉴权。从站只能被动等待主机来读,主机想知道某个参数是否存在,必须事先知道它的地址。这直接导致一个设计约束:寄存器表的规划必须一次到位、长期稳定,因为线上运行的上百套监控系统都在依赖这些固定的地址。这也是后面章节里所有位打包、保留地址段设计的起点。
2.2 MBAP 头 + PDU:一次请求在网线上到底长什么样
Modbus TCP 报文由 MBAP 头加 PDU 组成。一次读请求在网线上只有 12 字节:
| 字段 | 长度 | 值 | 含义 |
|---|---|---|---|
| 事务标识符 | 2 字节 | 0x0001 | 匹配请求与响应 |
| 协议标识符 | 2 字节 | 0x0000 | Modbus 固定为 0 |
| 长度 | 2 字节 | 0x0006 | 其后字节总数 |
| 单元标识符 | 1 字节 | 0x01 | 从站模块号 |
| 功能码 | 1 字节 | 0x03 | 读保持寄存器 |
| 起始地址 | 2 字节 | 0x0000 | 第一个寄存器 |
| 寄存器数量 | 2 字节 | 0x0009 | 连续读 9 个 |
文档示例请求是03 00 6B 00 03:功能码 0x03,起始地址 0x006B,连续读 3 个保持寄存器。正常响应为03 06 02 2B 00 00 00 64:功能码原样返回,0x06 表示后面跟着 6 字节数据,然后是三组十六位寄存器值。一次往返能拿 3 个点,12 字节开销,这个效率对光伏监控完全够用。
异常时功能码最高位置 1:读保持寄存器返回 0x83,后面跟一个异常码。01 表示功能码不支持,02 是起始地址非法,03 是寄存器数量非法,04 是从站内部故障。老派但实用——主机收到异常码不用猜,直接知道是自己发错还是设备坏了。实现时最常犯的错是把 0x83 当正常帧解析,导致后续数据完全错乱。
2.3 1 秒最小间隔、100ms 静默、1 秒超时:实时系统的通信预算
协议时间参数表里有三个硬数字:两条指令最小间隔 1 秒,帧间静默时间大于 100ms,响应超时 1 秒。新开发者会觉得「这也太慢了」,但这里约束的不是带宽,而是主控的实时任务模型。主控在一个控制周期里要完成采样、控制环计算、保护判断、PWM 输出,通信是中断里「顺带处理」的次级任务。如果主机每 100ms 来一帧,通信中断就会持续抢占控制任务的执行窗口,轻则 MPPT 跟踪精度下降,重则保护响应出现抖动。
100ms 静默要求继承自串口时代的帧边界定义:没有帧头帧尾,靠「静默超过一定时长视为一帧结束」切分数据。TCP 上保留这个约束,是为了给从站足够时间把缓冲区里的字节整理成完整请求。1 秒响应超时说明从站收到请求后必须在下一个控制周期内准备好数据,超过 1 秒基本是网络故障或从站异常,主机应该重试而不是无限等下去。
实际轮询周期设在 1.2 到 2 秒之间最稳。更聪明的做法是用一次请求批量读连续寄存器,比如把 0x0000 到 0x0009 的电网参数一次拉完,而不是分成 9 次请求,这样既守住 1 秒间隔,均摊到每个点位的带宽占用也最低。
3. 寄存器地图:保持寄存器与输入寄存器背后的分区哲学
3.1 保持寄存器与输入寄存器:可写配置区与只读采集区的分工
SK-SU 系列的寄存器表被清晰地切成两个区域。功能码 0x03 读保持寄存器,存放的是配置类数据:启动电压阈值、保护阈值、工作模式、充放电定时、远程控制指令,全部集中在 0x0000 到 0x00DF 区间。功能码 0x04 读输入寄存器,存放的是实时遥测:电网电压、PV 电压电流、温度、功率、能量、故障消息,从 0x0000 到 0x0061。一个可写一个只读,这个分区不是 Modbus 强制要求,但几乎所有电力设备都这么设计。
分区的好处有三个。第一是权限清晰:配置区偶发误写只会影响某个设定值,遥测区如果可写,整个监控数据的可信度就崩塌了。第二是可恢复性:配置区可以被完整读回备份,设备更换后一键下发恢复,遥测区没有备份价值。第三是故障定位效率:排查问题时先读输入寄存器确认实时状态,再读保持寄存器检查配置是否被改过,两步就能缩小范围。
这个区域里还有个容易忽略的细节——字符串寄存器。序列号占 0x0000 到 0x0006 共 7 个寄存器,工厂名、模块名各占 7 个,文档注明 14 个字符、MSB=SN[14]。实际编码是每个十六位寄存器的高字节和低字节各存一个 ASCII 字符,7 个寄存器正好装 14 个字符。解析时要把字节拆开再按序拼接,而不是像数值寄存器那样整体解包。这类字符串寄存器在光伏协议里很常见,因为设备铭牌信息需要一个固定长度的编码方式。
3.2 定点数与单位定标:0.1V、0.01Hz 是怎么藏进 uint16 的
寄存器里存的永远是裸整数,物理量靠「单位」转换。整张表的核心定标关系如下:
| 物理量 | 寄存器示例 | 单位 | 格式 |
|---|---|---|---|
| 电网电压/光伏电压 | 0x0000 / 0x0003 | 0.1V | uint16 |
| 电网频率 | 0x0007 | 0.01Hz | uint16 |
| 电池电压/电流 | 0x0014 / 0x0015 | 0.01V / 0.01A | int16 |
| 温度 | 0x0008 | 1℃ | int16 |
| 并网功率 | 0x0002 | 1W | int16 |
| 总发电量 Etotal | 0x0052-0x0053 | 0.001kWh | uint32 |
这就是定点小数:物理值等于寄存器值乘以单位。0.1V 精度对并网保护够用——过压欠压阈值判断需要分辨到零点几伏;0.01Hz 频率精度能捕捉 0.2Hz 以内的频率扰动;电池电流用 0.01A 是因为充放电管理要精确到几十毫安级别。用定点数而不是浮点数的原因很朴素:协议定型时主流逆变器主控还是没有 FPU 的 MCU,浮点运算要用软件模拟,慢且占 ROM。定点数只需要乘法,一个周期就能完成换算。
int16 和 uint16 的混用也值得注意。需要表达负值的物理量——温度、充放电功率、部分电流——用 int16;不可能为负的量——电压、频率、能量——用 uint16。协议版本历史里专门有一条「修改三种寄存器的格式,uint16 改为 int16」,还有「修正充电器温度寄存器单位从 0.1 到 1」的记录。这说明定标方式本身就是协议演进的一部分,后面接手的开发者如果只看旧版文档,极容易把有符号数当无符号数解析。
3.3 寄存器里的位打包:一个地址如何表达两种状态
寄存器只有 16 位,但设备状态不止 16 种,所以协议大量使用位打包。最典型的是 0x00DA 机器开关寄存器:最高位(MSB)是软件开关,1 为开、0 为关;最低位(LSB)是硬件开关,同样 1 为开、0 为关。软件开关对应远程指令,硬件开关对应机壳上的物理按键。如果拆成两个寄存器,就会引入「写一半掉电导致状态不一致」的风险;打包进一个寄存器,无论什么时候写入,两个开关状态都是原子更新的。
继电器控制区用了更激进的字节打包。0x00E2 寄存器的高字节存 CloseTimeSet(关闭时间,单位小时),低字节存 TrigTimeSet(触发时间,单位分钟)——两个不同单位、不同含义的参数硬塞进同一个十六位寄存器。读的时候按字节拆,写的时候要同时构造两个字节再整体写入。这种设计在地址资源紧张时非常有效,但也非常考验文档准确度:一旦文档没标清高低字节对应关系,开发者基本只能靠盲试。
位标志在遥测区也大量出现。BMS 警告寄存器是 16 位位图,每一位对应一种电池告警;逆变器故障消息跨两个寄存器,组合成 32 位故障位图;充电器和管理器各有独立的故障寄存器。这样设计的好处是读一次寄存器就能拿到全部告警状态,不用逐个地址轮询 32 个布尔量。解析时需要把位号对应到文档附录的故障码表,用位运算逐位检查。
3.4 远程控制链路:从模式门锁 0x008B 到功率寄存器 0x00D4
远程控制是这份协议最值得深挖的部分。0x008B 是 SolarChargerUseMode 寄存器:0 表示自用模式,1 表示定时模式,2 表示远程控制模式。而真正下发功率指令的是 0x00D4:wExt_SetChargerPower,int16 格式,正值表示充电功率限制,负值表示放电功率限制,单位瓦。0x00D5 wExt_SetInverterPower 则是逆变器输出功率上限,uint16 格式。
关键在设计顺序:必须先写 0x008B 为 2,把设备切换到远程控制模式,0x00D4 的写入才会生效。如果设备还在自用模式,固件会直接忽略任何远程功率指令。这就是模式门锁——远程控制必须显式使能,任何外部写入都无法绕过模式判断直接改变功率。考虑一下动机:并网逆变器直接连接电网,功率指令一旦失控可能造成设备损坏甚至电网安全事故,所以固件设计者用模式寄存器当总闸,功率寄存器当分闸,总闸不开,分闸怎么拧都没用。
完整的远程功率链路是:把 0x008B 写成 2,等待设备确认进入远程模式,再写 0x00D4 下发充放电功率限制,读回确认值。这套时序在很多光伏监控平台里被封装成「远程调度」功能,但底层原理就是两个寄存器的主从配合。理解了模式门锁,就理解了为什么很多开发者抱怨「写了 0x00D4 没反应」——十有八九是漏掉了 0x008B。
4. 源码级实现:用 Python 把协议文档变成可用的读点工具
4.1 不装第三方库,裸写一个 Modbus TCP 读取函数
协议文档已经给出了完整的帧格式,这意味着不需要任何第三方库,标准库的 socket 和 struct 就能实现一个可用的 Modbus TCP 客户端。下面这段代码是核心读取函数,也是后续所有监控脚本的通用模板:
import socket import struct def modbus_read(host: str, func: int, start: int, count: int, unit: int = 1, port: int = 502, timeout: float = 1.0): # func: 0x03 读保持寄存器 / 0x04 读输入寄存器 tid = 0x0001 # 事务标识符,请求与响应需匹配 pid = 0x0000 # 协议标识符,Modbus 固定为 0 length = 6 # 单元ID(1) + 功能码(1) + 起始地址(2) + 数量(2) adu = struct.pack('>HHHB', tid, pid, length, unit) pdu = struct.pack('>BHH', func, start, count) with socket.create_connection((host, port), timeout) as s: s.sendall(adu + pdu) resp = s.recv(512) r_tid, r_pid, r_len, r_unit = struct.unpack('>HHHB', resp[:7]) if r_pid != 0: raise RuntimeError('协议标识符异常,非 Modbus 响应') r_func = resp[7] if r_func == (func | 0x80): err = resp[8] raise RuntimeError(f'Modbus 异常响应: 码 {err}') byte_cnt = resp[8] data = resp[9:9 + byte_cnt] # 每个寄存器 2 字节,按大端序解包 return [struct.unpack('>H', data[i:i + 2])[0] for i in range(0, byte_cnt, 2)]函数逻辑分四步:组装 MBAP 头和 PDU 并发送;接收响应后先校验协议标识符是不是 0;检查功能码最高位是否为 1,是则抛出异常码;最终按每寄存器 2 字节、大端序解析数据。大端序在这里是硬性要求——Modbus 协议规定多字节字段高位在前,struct 的>前缀就是干这个的,如果换成小端'<H',读出来的每个寄存器值都会字节翻转。
timeout=1.0对应文档的 1 秒响应超时;事务标识符在单连接场景下可以固定,但严谨做法是每次请求递增一个计数器,这样即使响应乱序也能通过事务 ID 匹配。注意异常响应和正常响应都可能在一次 recv 中被拆包或粘包,生产环境需要循环 recv 直到收满r_len字段声明的字节数。
4.2 从裸数值到物理量:int16 符号扩展与 32 位拼接
modbus_read返回的是无符号 16 位整数,但协议里大量寄存器是 int16——温度在冬天可能是负的,远程功率指令的负值代表放电。转换成有符号数只需要一行扩展:
def to_int16(raw: int) -> int: # 大端无符号值 >= 0x8000 时,减去 65536 得到真正的负值 return raw - 65536 if raw >= 0x8000 else raw regs = modbus_read(host, 0x04, 0x0000, 9) grid_voltage = regs[0] * 0.1 # 0x0000 电网电压,0.1V grid_current = to_int16(regs[1]) * 0.1 # 0x0001 电网电流,0.1A grid_power = to_int16(regs[2]) # 0x0002 并网功率,1W pv1_voltage = regs[3] * 0.1 # 0x0003 光伏1电压 pv2_voltage = regs[4] * 0.1 # 0x0004 光伏2电压 pv1_current = regs[5] * 0.1 # 0x0005 光伏1电流 pv2_current = regs[6] * 0.1 # 0x0006 光伏2电流 grid_freq = regs[7] * 0.01 # 0x0007 电网频率,0.01Hz temperature = to_int16(regs[8]) # 0x0008 机内温度,1℃参数说明:读 9 个连续输入寄存器,一次拿到全部电网和 PV 侧实时量。to_int16的>= 0x8000判断是补码的数学定义——无符号值的高位为 1 时,它代表的有符号数就是这个值减去 2 的 16 次方。这个技巧适用于协议里所有 int16 字段,写远程功率指令时同样用它把负数编码回裸寄存器值。
能量类寄存器更复杂。feedin_energy 在 0x0048-0x0049,Etotal 在 0x0052-0x0053,都是 32 位无符号整数跨两个寄存器,文档明确标注低地址是 LSB、高地址是 MSB。拼接方法如下:
def read_u32(host: int, addr_low: int, scale: float) -> float: regs = modbus_read(host, 0x04, addr_low, 2) lsb, msb = regs[0], regs[1] value = (msb << 16) | lsb # 高地址寄存器放高位 return value * scale feedin_energy = read_u32(host, 0x0048, 0.01) # 馈网电量,kWh consum_energy = read_u32(host, 0x004A, 0.01) # 购网电量,kWh etotal = read_u32(host, 0x0052, 0.001) # 总发电量,kWh(msb << 16) | lsb是 Modbus 32 位数据最标准的组装方式。如果 firmware 版本文档里注明某个 32 位量是有符号的(比如 feedin_power),还要在拼完后做一次符号扩展:值大于等于 0x80000000 时减去 2 的 32 次方。这类寄存器最容易翻车,下面避坑章会专门展开。
4.3 轮询调度与故障位解析:一次读清全部告警
完整的监控程序不是单次读取,而是按协议要求持续轮询。批量读连续地址是减少请求次数的最佳手段:故障区集中在输入寄存器 0x0040 到 0x0043,正好一次读完。
import time def poll_loop(host: str): while True: try: fault = modbus_read(host, 0x04, 0x0040, 4) inv_fault = (fault[1] << 16) | fault[0] # 32位逆变器故障位图 charger_fault = fault[2] # 16位充电器故障位图 mgr_fault = fault[3] # 16位管理器故障位图 for bit in range(32): if inv_fault & (1 << bit): print(f'inverter fault bit {bit} active') except Exception as e: print(f'轮询异常: {e}') time.sleep(1.2) # 严格遵守最小指令间隔,留出裕量这个循环的机制要点是位运算:inv_fault & (1 << bit)逐位检查 32 位故障位图,哪个位为 1 就对应文档故障表里的哪一条。一次 4 寄存器的请求就能覆盖逆变器、充电器、管理器三块子系统的全部告警,比逐个地址查询高效一个数量级。time.sleep(1.2)不是随手写的——协议规定最小间隔 1 秒,1.2 秒既保证不触发主控通信保护,又让单个点位刷新周期在可接受范围。
实际工程里还可以把modbus_read的结果与定标表解耦:用字典维护「寄存器地址 -> (单位, 数据类型, 固件版本)」的映射关系,固件版本从保持寄存器 0x007D-0x0083 读回。这样同一套轮询代码可以适配不同固件版本的设备,避免因为文档更新导致定标错误。
5. 读寄存器避坑指南:五个最容易翻车的现场与修复
5.1 能量寄存器数值乱跳:32 位拼接只做了一半
现象:读 Etotal 总发电量,数值忽大忽小,有时只有几百,下次刷新变成几十万,毫无规律。
原因:Etotal 是跨 0x0052-0x0053 两个寄存器的 uint32,低地址是 LSB。如果只读一个寄存器,拿到的是低 16 位。当真实值超过 65536 时,高位部分全被丢掉,剩余低位恰好是快速翻转的进位痕迹,看起来就像数据在乱跳。
解决:连续读两个寄存器,按(msb << 16) | lsb拼接后再乘以 0.001 换算成 kWh。代码里写成read_u32函数一次封装,所有 32 位能量量都走这个入口,避免每个点单独写拼接逻辑。
5.2 负温度读出 65535:有符号数当无符号数解
现象:冬季环境温度应该是 -5℃,寄存器读回来却是 65531,换算后高达 65531℃。
原因:温度寄存器是 int16 格式,-5 的补码表示是 0xFFFB。如果用struct.unpack('>H')按无符号解包,得到的就是 65531,直接乘单位 1℃ 自然离谱。
解决:解包后做符号扩展,raw >= 0x8000时减 65536,得到 -5 的真值。协议里所有温度、电流、充放电功率都可能是负数,统一封装to_int16函数,在解析入口处全部转换一次,而不是在每个换算点临时判断。
5.3 远程功率写不进去:模式门锁没打开
现象:向 0x00D4 写入充电功率限制值,读回寄存器发现还是 0,设备毫无响应。
原因:0x008B SolarChargerUseMode 还停在 0(自用模式)。固件在自用模式下直接忽略远程功率寄存器,任何写入都被静默丢弃。这是模式门锁的预期行为,不是设备故障。
解决:先写 0x008B = 2 切换远程控制模式,等一个轮询周期确认设备已进入该模式,再写 0x00D4。生产代码里要把「模式切换」和「功率下发」做成两个步骤的状态机,切换失败时明确报错,不能直接继续写功率。
5.4 偶发超时:轮询间隔压得比协议还紧
现象:程序刚启动时一切正常,连续跑几个小时后开始零星出现超时,日志里全是 timeout,重启后又恢复。
原因:轮询周期设成了 500ms,小于协议规定的最小指令间隔 1 秒。逆变器主控的通信任务按 1 秒预算规划,高频请求让通信中断逐步挤占控制环时间片,最终触发从站侧的保护性不响应。
解决:把轮询周期调到 1.2 秒以上,并且用批量读替代单点读。读 20 个连续寄存器只需 1 次请求,均摊下来每点位的刷新间隔反而更短了。这是少有的「降低请求频率反而让数据更实时」的场景。
5.5 同地址不同含义:定标表必须绑定固件版本
现象:同一地址读回的数值,在两台设备上换算出的物理量相差巨大,查文档发现单位说明和实际值对不上。
原因:协议文档本身就是演进的产物。V2.1 把多个寄存器从 uint16 改成 int16,后来又有充电器温度单位从 0.1 改成 1 的记录;还有寄存器的手册描述和寄存器名自相矛盾(例如功率变化率的 upper/lower 写反)。不同固件版本在同一地址上的格式或单位可能已经不同。
解决:读保持寄存器 0x007D-0x0083 获取主控、充电器、管理器的固件版本号,把定标系数放进按版本区分的配置表。上线前先读版本,再选择对应的解析方案。协议文档不是永恒真理,实测值才是——遇到可疑数据,第一反应是查版本历史而不是查代码。
6. 设计取舍:寄存器表为什么只增不减、保留地址段从不删除
回看这份协议的版本历史,几个细节很说明问题。V2.0 以下很少删寄存器,大多是「add」和「fix」;地址表里大片 REV 保留段,从 0x00A2 到 0x00B3、0x00E8 到 0x00F2,全是预留给未来功能的空位。这种设计在今天的 REST API 世界看起来不可理解——一个完整的接口文档,为什么要留一堆没人用的地址?
因为 Modbus 没有服务发现机制。一台从站被问到一个它不认识的地址时,要么返回异常码,要么返回垃圾数据,两种结果都会让已上线的监控系统产生误报。预分配保留地址段,是给固件升级留的「官宣后路」:新功能用固定地址发布,旧系统读到这些保留地址时不解析、不报警,新系统按新版文档解析,两代软件在同一台设备上共存不冲突。这就是工业协议的兼容性策略——不是靠版本协商,而是靠事先挖好的预留坑位。
定点数和位打包看似古老,实则是资源约束下的最优解。int16 配合 0.01 单位能表达 -327.68 到 327.67 的电压电流,覆盖量程但寄存器只有 2 字节,换算只要一次乘法。相比之下浮点数要用 4 字节且换算复杂,对 RS-485 时代的带宽就是奢侈。位打包牺牲了可读性,换来了地址空间和写操作的原子性——一个寄存器同时控制软件开关和硬件开关,比两个独立寄存器在掉电瞬间保持不了状态一致要可靠得多。
从那以后,我每次设计设备通信接口,都强制自己做一遍这份文档式的推演:先划分可写区与只读区,再定每个物理量的单位和格式,最后为未来功能预留地址段。写代码时也固定把定标系数和固件版本绑定,绝不在解析函数里硬编码单位。协议文档本身是死的,但它的版本历史、REV 保留段和模式门锁这些细节,才是真正值得照抄的设计模板。希望你也能从这张寄存器表里,读出比地址和数值更多的东西——那是一个嵌入式团队在可靠性、兼容性和演进成本之间反复权衡的真实轨迹。
本文还有配套的精品资源,点击获取