简介:面向光伏逆变器研发、储能系统集成与Modbus通信调试人员的协议规范文档,聚焦Solax SK-SU系列混合太阳能逆变器在Modbus TCP下的寄存器定义与版本演进。内容以协议总则开篇,明确端口502、帧格式、指令间隔与响应超时等时序参数,并逐项列出保持寄存器地址、读写属性、单位与数据格式,覆盖序列号、额定参数、安规类型、EPS功能、远程控制、BMS告警、CAN通信丢失、电池SOH及充电器输入能量等条目。文档还保留从V1.0到V2.50的修订记录,便于读者追溯寄存器增删、uint16转int16、单位修正等变更,减少联调时因版本差异导致的误读。压缩包仅含1个PDF文件,大小约1.58MB,结构紧凑,适合作为寄存器映射核对、上位机点表整理和故障排查的案头参考。已有88人学习,可供从事逆变器监控、能量管理平台对接的工程师按需查阅。
1. 从修订史切入:混合储能逆变器的 Modbus TCP 接口为什么是一项长期约定
把一台混合储能逆变器接入自建监控,多数人的第一反应是翻到寄存器表照地址抄。真正让项目拖上两周的,往往不是抄错地址,而是表格前后那些看似配角的约束:帧怎么定界、两条指令之间要隔多久、单位是 0.1 还是 0.01、这个字段到底带不带符号。这份 Modbus TCP 规范从 2014 年的一页草稿一路修到 2016 年的 V2.50,二十来条修订记录里,超过一半是「改单位」「uint16 改 int16」「补充说明」「修正错误描述」——没有一条是新增了什么了不得的功能,全是把已有约定打磨准确。
这就是储能逆变器协议的真实形态:它并不按「设计一个漂亮接口」的思路做,而是把一块不断长大的寄存器空间当作长期 ABI 在维护。你的采集程序、能量管理策略、告警规则会与这套地址绑死好几年,读懂它为什么这样长,比背下地址表有用得多。
2. 帧结构与三行时间参数:502 端口上为什么还留着 100ms 静默
协议总则只占了大半页:类型 Modbus TCP,端口 502,然后是三行时间参数——两条指令最小间隔 1 秒,两包之间的静默时间大于 100ms,响应超时 1 秒。很多人扫一眼就跳过去了,直接去抄寄存器。可这三行恰恰是整份文档里最像「设备自白」的部分。
2.1 MBAP 头七个字节:没有 CRC 之后靠什么判断一帧的边界
Modbus RTU 靠 3.5 个字符时间的静默来切帧,再加 CRC 校验;换成 TCP 之后,这两件事都被 MBAP 头接管了。头是固定七字节:事务标识(2)、协议标识(2,恒为 0)、长度(2,等于单元标识加 PDU 的字节数)、单元标识(1)。长度字段本身就是分帧依据,所以 TCP 上根本不需要字符间隔,也不需要 CRC——链路层已经保证了完整性。
import struct def build_read_holding(tid: int, unit_id: int, start: int, count: int) -> bytes: # PDU:功能码 0x03 + 起始地址(2B) + 寄存器数量(2B) pdu = struct.pack(">BHH", 0x03, start, count) # MBAP:事务ID(2) + 协议ID(2,固定0) + 长度(2,=单元ID+PDU) + 单元ID(1) # 注意长度字段不包含 MBAP 自身那 6 个字节 mbap = struct.pack(">HHHB", tid, 0, len(pdu) + 1, unit_id) return mbap + pdu def parse_response(frame: bytes) -> list: tid, pid, length, unit = struct.unpack(">HHHB", frame[:7]) assert pid == 0, "协议ID非0,说明这条流不是纯 Modbus" fc = frame[7] if fc & 0x80: # 0x83 = 0x03 | 0x80,异常响应 raise IOError(f"exception code {frame[8]:#04x}") # 01/02/03/04 byte_count = frame[8] # 寄存器在线上永远是大端,一个寄存器两字节 return list(struct.unpack(">" + "H" * (byte_count // 2), frame[9:9 + byte_count]))事务标识由客户端自增,回包必须原样带回。单连接、一问一答的场景里它看着多余,但只要做过一次流水线发送就会发现它是唯一的防串包手段:响应慢的时候,晚到的回包和早到的回包在字节流上没有任何区别,只有事务标识能对上号。
2.2 最小指令间隔 1 秒与响应超时 1 秒,各自约束的对象
1 秒这个数字不是协议标准,是设备能力声明。逆变器主控是个跑着 MPPT 扫描、充电管理、保护采样的实时循环,Modbus 任务挂在低优先级上,一次请求的处理窗口要从这些任务之间挤出来。把最小间隔写成 1 秒,等于告诉你「我的协议任务一秒钟只有这么多时间片」。
100ms 静默更值得玩味。RTU 里这个量级是 1.75ms(19200 波特下的 3.5 字符),这里放大到两个数量级,说明它并不是分帧需要,而是给固件留出「收完这一包、处理完、把缓冲区清掉」的时间。换句话说,设备的 TCP 接收端很可能不是严格按长度字段流式解析的,而是按「一次 recv 一包」的朴素实现。知道这点,你在写客户端时就别把两条请求塞进同一个 send。
响应超时 1 秒是写给客户端的。现场最容易犯的错是把它调成 200ms 求「实时性」,结果电网一次暂态让设备忙了几百毫秒,客户端判超时重发,设备端只是多收到一条无法识别为重复的请求,负载翻倍,然后雪崩。合理的做法是超时设到 1.5~2 秒,重试前先等够静默时间。
2.3 单连接串行:并发读为什么会读到别人的数据
这类设备一般只接受一条 TCP 连接,且内部没有请求队列。两条连接同时发请求时,后到的那条要么被拒,要么进了同一个接收缓冲区被当成上一条的续包,解析出来的寄存器数值看上去完全合法,只是属于另一个地址。这种错误最难查——数据不报错,只是不对。
我一般会把采集器设计成单连接 + 请求队列 + 固定节拍(≥1 秒),把所有并发需求(实时曲线、告警轮询、页面刷新)合并到同一张轮询表里,按优先级分配时隙。听上去土,但它和设备的实现模型是对齐的。
3. 0x03 与 0x04:两套地址空间的分工与复合量的拼装
文档把寄存器分成两张表:功能码 0x03 读保持寄存器(0x0000 起,配置与参数为主),功能码 0x04 读输入寄存器(0x0000 起,实时量与状态为主)。两张表的地址都从 0 开始,同一个 0x0014 在两个功能码下是完全不同的两个量。这是新人第一个大坑:用 0x03 去读实时量,不会报错,某些实现下会读回全 0 或者异常码 0x02。
3.1 保持寄存器:可写配置面与它的「半开」边界
保持寄存器区大致是设备的行为参数:启动电压阈值(0x0015,0.1V)、启动等待时间(0x0016,1s)、并网电压/频率的保护上下限(0x0019~0x001C)、安规档位(0x001D,十几个枚举值对应不同市场的保护曲线)、功率因数模式与上下限(0x0026~0x0029)、电池类型与充放电截止电压(0x008D~0x0091)、两段定时充放电时间窗(0x0092~0x00A1)、EPS 相关配置(0x00B7~0x00B9)、语言与 IP 获取方式(0x00BB、0x00BC)。
这里有个细节值得单独说:表里 W/R 那一列几乎全是 R,但修订记录里又明确写了「新增两个单写寄存器 PfLockInPoint、PfLockOutPoint」,还有 0x00D4、0x00D5 这类带 w 前缀的远程功率设定。也就是说 W/R 列的标注并不可靠,判断某个地址能不能写,得综合修订记录、字段命名和实测三条线。乱写保护类寄存器的代价很直接——设备会按新曲线判定并网条件,轻则反复脱网,重则进保护锁死。
0x001D 安规档位是个典型例子:改它等于把整套电压频率保护阈值换一套,而且很可能需要重启才生效。这类寄存器我只在「已备份全片、已知原值、有回滚脚本」的前提下碰。
3.2 输入寄存器:只读观测面与故障位域
输入寄存器区是设备状态的快照:并网电压/电流/功率/频率(0x0000~0x0002、0x0007)、两路 PV 电压电流(0x0003~0x0006)、机内温度(0x0008)、运行模式(0x0009)、各路直流量(0x000A、0x000B)、故障触发时的瞬时值快照(0x000C~0x0012)、电池侧电压电流功率与温度(0x0014~0x0019)、电池容量与 SOH(0x001C、0x0023)、充放电累计能量(0x001D~0x0021)、EPS 输出(0x004C~0x004F)、当日与累计发电量(0x0050、0x0052~0x0053)、继电器状态与负载统计(0x0056~0x0062)。
0x0029 之后到 0x003F 是一段标着 REV 的空洞,从命名看是给第 2、3、4 台充电模块预留的实例块。这种「先占位后启用」的布局很常见,好处是地址不会因为多插一块板而整体平移,坏处是客户端扫描时会读到一堆未定义的地址,异常码 0x02 满天飞。
故障信息用了两个寄存器拼一个 32 位位域:0x0040 是低字,0x0041 是高字,另有 0x0042 充电模块故障字、0x0043 管理器故障字。位域的问题在于「哪一位对应哪个故障」这张表不在协议文档的寄存器表里,它随固件版本变。所以我建议原始位值一定要落库,别只存解出来的文字——文字解错了没法回滚,原始位永远能重新解。
3.3 符号位:int16 与 uint16 的区别是怎么变成线上事故的
修订记录里有一条很朴素:「modify three format of register, uint16 to int16」,后面还有一次专门把输入寄存器 0x46 从 uint16 改成 int16。这类修订背后一定是现场数据出过丑:功率反送时是负数,用无符号读出来就是五万多瓦。
看符号定义要抓物理含义:电流、功率、温度、充放电能量增量,这四类默认当有符号看;电压、频率、容量百分比、SOH 当无符号看。文档在这点上基本自洽——GridCurrent、GridPower、Temperature、BatVoltage_Charge1、BatCurrent_Charge1、Batpower_Charge1 都标了 Int16,Etotal、feedin_power 标了 Int32/Uint32。
import struct def to_int16(raw: int) -> int: # 寄存器里存的是补码,按无符号解出来是 0~65535,需要重解释 return struct.unpack(">h", struct.pack(">H", raw))[0] def join_i32(lsb: int, msb: int) -> int: # 低字在前:先拼成 32 位无符号,再按补码重解释 v = (msb << 16) | lsb return struct.unpack(">i", struct.pack(">I", v))[0]to_int16里那次 pack/unpack 的往返看着绕,但它是唯一不依赖语言位移规则的做法:很多语言里对 16 位做位移会先提升到 32 位,符号扩展的时机不同,结果就不同。这类边界我曾经为了「优化」写成一行位运算,在夜间反送功率上翻过车。
3.4 缩放因子:单位表为什么必须变成常量表
整份文档里最琐碎也最致命的就是单位列。0.1V、0.01Hz、0.1A、1℃、0.01kWh、0.001kWh、1mA、1ms、1%、0.01(功率因数)。它们不以任何形式出现在协议层,纯粹靠文档约定,设备只负责把整数送上来。
| 地址 | 字段 | 缩放 | 类型 | 备注 |
|---|---|---|---|---|
| 0x0000 | GridVoltage | 0.1 V | u16 | |
| 0x0001 | GridCurrent | 0.1 A | i16 | 文档单位列误写为 0.1V |
| 0x0002 | GridPower | 1 W | i16 | 负值表示反送 |
| 0x0007 | GridFrequency | 0.01 Hz | u16 | |
| 0x0008 | Temperature | 1 ℃ | i16 | |
| 0x0014 | BatVoltage_Charge1 | 0.01 V | i16 | 分辨率比并网电压高一个量级 |
| 0x001D/0x001E | OutputEnergy_Charge1 | 0.1 kWh | u32 | 低字在前 |
| 0x0050 | Etoday | 0.1 kWh | u16 | 单寄存器,够用一天 |
| 0x0052~0x0053 | Etotal | 0.001 kWh | u32 | 精度比 Etoday 高 |
| 0x0048~0x0049 | feedin_energy | 0.01 kWh | u32 |
注意 0x0001 那一行:字段名是并网电流,单位列却写着 0.1V。这不是笔误一次就完了,修订史里至少三条记录在做「修正单位描述」。所以单位表不能靠读文档时随手内联,得固化成一张带来源注释的常量表,再配一个量纲审计:并网功率应当近似等于电压乘电流乘功率因数,三者对不上就说明某个缩放错了。这个自检我后来加进了所有采集器,比任何文档都可靠。
累计电量还藏着一个隐含约束:Etotal 用 0.001kWh 的 32 位无符号,量程约 429 万 kWh;Etoday 用 0.1kWh 的 16 位,量程 6553.5 kWh。它们不是同一精度,做日增量差分时必须先统一到 0.001 再比,否则每天都会丢一点点,一年下来累计误差肉眼可见。
3.5 32 位量的拼装:LSB 在前,寄存器内仍是大端
文档里凡 32 位量都标了两个地址,并在名字后写.LSB、.MSB。这是典型的「小端字序、大端字节序」:低 16 位放在低地址,但每个寄存器内部仍然高字节在前。这两层顺序混一次,读出来的数就是天文数字。
还有个容易忽略的点:32 位量在一次 0x03/0x04 请求里要连续读两个寄存器,中间不能被打断。如果客户端按 1 秒节拍把两个寄存器分配在不同请求里,两次采样之间设备更新了一次值,拼出来的结果就是「新低字 + 旧高字」的弗兰肯斯坦数。所有跨寄存器的量,必须同一个请求里读回来。
4. 状态与控制:RunMode、远程功率和自检握手在寄存器层怎么表达
前面三章讲的是「怎么把数读对」,这一章讲「怎么把动作做对」。储能逆变器的可写面不只有参数,还有运行模式和功率指令,它们带状态、带约束、带回执。
4.1 RunMode:只读枚举背后的能量流状态机
输入寄存器 0x0009 的 RunMode 是个只读枚举,代表设备当前处在哪条能量路径上:待机、自用、定时强充、定时强放、离网供电、故障、升级之类。文档在 V2.2 补过一次「运行模式寄存器的说明」,在 V2.47 又补了一次「运行模式描述」——同一件事补两遍,说明枚举值在固件迭代里加过新成员。
这就是写状态解析时的现实:别用 if/else 穷举,用字典加默认分支,遇到未知值原样透出而不是抛异常。设备升级新增一个模式,你的监控不该整页空白。
RunMode 和保持寄存器里的 SolarChargerUseMode(0x008B,0 自用 / 1 定时强充放 / 2 远程控制)是一对:后者是「你让它怎么干」,前者是「它现在实际在怎么干」。两者不一致是排查时最有价值的信息——比如你切到远程控制,RunMode 却还在自用模式,多半是远程指令没生效或被更高优先级的保护逻辑接管了。
4.2 远程控制模式:写进去的功率为什么会被夹回来
远程控制模式下有两个关键写入点:0x00D4(wExt_SetChargerPower,有符号,正数充电、负数放电)和 0x00D5(wExt_SetInverterPower,逆变输出上限)。看起来很简单,写个数字就行。实际会遇到的第一个问题就是「写进去的和读回来的不一样」。
原因在于这两个值只是「期望」,设备还会拿它和一堆边界做夹紧:电池最大充放电电流(0x0090、0x0091,0.01A)、充放电截止电压(0x008E、0x008F)、最小保留容量(0x008C)、是否允许电网充电(0x00B4)、以及电池管理系统的实时告警(0x001F)。任何一条触发,写入值都会被裁到可行域内。
def set_remote_charge_power(dev, watts: int) -> int: """0x00D4:有符号 16 位,正数充电,负数放电。返回设备实际接受的值。""" raw = max(-32768, min(32767, int(watts))) # 先看可行性边界,避免无效写入:电流上限 × 电池电压 ≈ 功率上限 max_current = dev.read_holding(0x0090) * 0.01 # 0.01A 缩放 bat_voltage = dev.read_input(0x0014) * 0.01 # 0.01V 缩放 feasible = min(raw, int(max_current * bat_voltage)) dev.write_single(0x00D4, feasible & 0xFFFF) # 写完必须回读:夹紧发生在设备侧,只有回读才知道真实生效值 return to_int16(dev.read_holding(0x00D4))注意这段里feasible只用了电流和电压两条边界,真实固件的判据更多,所以回读不是可选项,而是唯一可信的结果来源。另一个坑是写入频率:这类寄存器多半落在 EEPROM 或参数区,高频写会磨损存储,也不排除固件做了写入次数限制。远程控制指令应当有去抖和最小变更阈值,别每秒钟追一次目标值。
4.3 自检寄存器 0xC5~0xD3:一次 1 到 8 的处理过程
自检流程是这套协议里少见的「有回执的异步操作」。0x00C5 是 TestStep:写 1 表示启动,读到 8 表示成功,中间过程返回 1~8 的不同阶段值。配套的 0x00C6~0x00CD 是过压/欠压/过频/欠频的触发值与持续时间,0x00CE~0x00D3 是 10 分钟平均值的判定参数。
正确做法是写 1,然后按不低于 1 秒的间隔轮询 TestStep,直到读到终态;不要在一个循环里连打,设备的自检本身要占用保护采样通道。更不要在没有确认终态的情况下断电——自检中途被打断,设备可能停在某个保护状态,需要重新上电才恢复。
0x00C5 到 0x00D3 这十几个寄存器都属于「安规相关」,不同市场的并网规范要求不同的自检项和判定时间。这类寄存器的写入权限在部分固件上是被锁的,需要先写 0x0054(文档里只写了 Unlock 加一条横线,没有取值说明)才能改。这种「有地址、没语义」的寄存器最麻烦,只能实测,且不要在生产设备上试。
4.4 用固件版本寄存器做能力探测,而不是比较版本号字符串
保持寄存器 0x007D~0x0083 是一组固件版本:逆变器主从、四路充电模块、管理器各一个寄存器。加上 0x0084 的机器地址,八个寄存器构成了一台设备的「身份证」。
它们的价值不在版本号本身,而在于能力判定。这套协议的地址空间是只增不减地长出来的,老固件上读 0x00E1 之后的继电器寄存器会拿到异常码 0x02,新固件上却能读。与其在代码里写「V2.44 以后支持继电器」,不如在连接建立时先做一次地址探测:读一段连续区间,记录哪些地址返回异常,把得到的异常地址集合当作能力指纹存起来。固件版本号只是这个指纹的一个可读标签,真正决定分支的是探测结果。
5. 避坑与排查:现场最容易翻车的五个地方
5.1 电流值看起来像电压:单位列的复制粘贴
现象:并网电流显示 23.0,而实际电流在 2.3A 附近,差十倍;或者数值量级对得上但单位标错,报表里出现「230V 电流」。
原因:输入寄存器 0x0001 的字段名是 GridCurrent,单位列却标注 0.1V。这一列在多个版本里被反复修正过,说明它是人工维护、复制粘贴风险最高的部分。
解决:单位表固化为常量并附来源注释;用功率、电压、电流三者的物理关系做量纲自检;把每次固件升级后的首批采样与上一版对比,缩放变化会立刻暴露。
5.2 夜间功率变成 65486:有符号被当成无符号
现象:白天功率读数正常,傍晚开始跳到 65xxx 这种六万多的值,凌晨又恢复。
原因:反送或放电时功率为负,寄存器里是补码,按 uint16 解出来就是 65536 减去实际值。
解决:按字段语义约定符号,功率、电流、温度一律走 int16 解码;对 32 位量用先拼后重解释的两步法;加一条「物理量越界」校验,超出额定值就告警而不是入库。
5.3 累计电量跳变:32 位量拼反或跨请求读
现象:Etotal 偶尔跳到一个不可能的数,或者一天里多次小幅回退。
原因:两个来源。一是 LSB/MSB 顺序与寄存器内字节序混淆,拼出来是错的;二是两个寄存器分两次请求读回来,中间设备更新了值,高低字来自不同时刻。
解决:低字在前拼装、寄存器内按大端解;所有 32 位量必须在同一次请求里连续读取;用 Etoday 与 Etotal 的日增量做交叉校验,差值超过阈值就标记该采样点无效。
5.4 间歇超时与异常码 0x04:轮询节拍和设备能力不匹配
现象:跑几分钟就开始零星超时,偶尔收到异常码 0x04(从站设备故障),重启采集器能好一阵。
原因:最小间隔 1 秒和 100ms 静默被忽略;客户端超时设得比设备响应能力短,触发重试,重试又叠加负载;多线程并发共享一条连接。
解决:单连接串行化,轮询周期不低于 1 秒,超时设 1.5~2 秒,重试次数限 1 次并退避;把实时曲线、告警、页面刷新合并进同一张轮询表,别各自开线程。
5.5 改了安规参数之后设备不再并网
现象:调了一次保护阈值或安规档位,设备从此反复脱网,读故障寄存器有保护类置位。
原因:0x0015~0x0025 这一段决定了并网判据,改动会直接影响设备对电网质量的容忍度;部分参数需要重启生效,还有一部分写在非易失区,有写入寿命限制。
解决:改动前把整片保持寄存器读一遍存档,记录原值和时间;改完立刻回读比对;一次只改一个参数并观察一个完整并网周期;对可能落盘的寄存器加写入频率限制,别在策略里高频追值。
6. 设计取舍:把兼容性押在地址上,是一种清醒的妥协
这套协议最值得琢磨的地方,是它把几乎所有的兼容性责任都押在了「地址」这一个维度上。没有自描述、没有类型系统、没有语义版本协商,寄存器地址就是唯一稳定的标识。好处非常直接:客户端解析器可以用一张表驱动,几行代码就能跑通;设备端不需要实现任何元数据服务,MCU 上省下的每一字节 RAM 都能给控制算法。
代价同样清晰。修订史里出现过「移除某些寄存器」,也出现过地址段被重新启用,这意味着地址空间并不是纯粹的单调增长,客户端无法仅凭「读到了合法值」判断这个值的含义有没有变过。所以这台设备把固件版本寄存器单独放在 0x007D~0x0083 一片连续区域,本质上是用一小块元数据区,去补地址 ABI 缺失的版本信息。用能力探测而不是版本号比较,是对这个设计最贴合的使用方式。
第二个取舍是单位。缩放因子不进协议、只进文档,等于用「人的约定」替代了「机器的约定」。这在窄带、低算力的场景下是合理的——16 位整数传 0.1V 精度,比传浮点省一半带宽;但当单位本身被修订(这套协议修订过多次),历史数据的可比性就断了。我现在的做法是原始值、缩放因子、文档修订号三者一起入库,任何一次单位修正都能通过重算追溯,而不是让旧数据变成孤儿。
第三个取舍是把复杂度推给客户端:只允许一条 TCP 连接、最小间隔 1 秒、没有请求队列。这在一台以控制为主的设备上是正确的资源分配——协议任务不该抢保护采样的时间片。
验证这套判断的方法不复杂:接一台新固件的设备,先读 0x007D~0x0084 存档指纹,再扫一遍 0x0000~0x0100 记录异常地址集合,然后只读跑满 24 小时,用 Etoday 的累加与 Etotal 的日增量做一致性比对。三步下来,地址、单位、符号三层的坑基本都能提前暴露。
从那以后,我每接一台新固件的机器,第一件事永远是先把 0x007D 到 0x0084 那八个寄存器读出来存一份,再谈采集和策略——这份几十页的协议文档,最好也放在手边当基线,而不是每次现场翻截图。希望帮到你。
本文还有配套的精品资源,点击获取