Solax SK-SU逆变器Modbus TCP协议实战指南:字节序、版本适配与寄存器解析

发布时间:2026/11/4 23:54:34
Solax SK-SU逆变器Modbus TCP协议实战指南:字节序、版本适配与寄存器解析

简介:本资源是Solax SK-SU系列混合太阳能逆变器的官方Modbus TCP通信协议规范文档,面向光伏系统集成商、能源监控平台开发者、嵌入式工程师及工业自动化运维人员,用于实现对逆变器的远程数据读取、状态监控与指令控制。文档完整覆盖V1.0至V2.50全部版本演进,详述寄存器地址(如0x0000起始的设备信息区、0x0015起始的电压/频率保护阈值区)、功能定义、数据格式(uint16/int16)、单位换算(如0.1V、0.01Hz)及通信参数(端口502、帧格式、超时规则),并记录了EPS功能、远程唤醒、BMS告警、电池健康度(SOH)、充电器输入能量等关键新增寄存器的迭代过程。资源为单个PDF文件,大小1.58MB,结构清晰、版本标注明确,便于开发对接与现场调试。目前已有89人学习下载,是实施Solax设备IoT接入、构建SCADA系统或编写Modbus主站程序不可或缺的技术依据。

1. Solax SK-SU 逆变器 Modbus TCP 协议不是“查手册就通”的黑匣子:它是一份带版本演进痕迹的现场调试契约,专治监控断连、读数跳变、写入失效三类翻车现场

你手头有一台 Solax SK-SU 系列混合太阳能逆变器,刚接上局域网,用 Modbus 调试工具(比如 QModMaster 或 modbus-cli)发了0x03读保持寄存器请求,地址0x0000,长度10,结果返回0x83 0x02—— 非法数据地址?再换0x04读输入寄存器,地址0x0000,又卡在超时?或者更玄学的是:明明读到了GridVoltage(0x0000),数值却是2765,你心算276.5V感觉合理,但下一秒GridCurrent(0x0001)返回65535,换算成6553.5A显然离谱——这根本不是通信失败,而是你没踩中协议里埋的三个关键地雷:字节序方向、有符号/无符号混用、以及寄存器地址偏移的隐式约定。这份 V2.49 版本的 Modbus TCP 协议文档,不是教科书里的理想模型,而是某公司某实验室在 2014–2016 年间,由 Liming、xiezhengxun、weir、wangjianxing 等工程师在真实产线、海外项目、客户投诉倒逼下,一版一版打补丁、修单位、调格式、加安全配置的血泪日志。它解决的不是“能不能通”,而是“通了之后数据准不准、写入稳不稳、多机并发会不会撞车”。适合谁?是正在把 Solax 接入 SCADA 系统的自动化集成商,是自建家庭能源看板需要稳定拉取Etoday和feedin_power的 DIY 工程师,更是被BatSOH寄存器(0x0023)返回值忽高忽低搞到怀疑人生的技术支持一线人员。它不承诺“开箱即用”,但只要你愿意按它的版本逻辑拆解寄存器表、校验字节序、绕过保留区(REV)、处理好int16与uint16的边界,就能把一台黑盒逆变器变成可编程、可验证、可审计的能量节点。

1.1 这份协议的本质:不是标准,是“带注释的现场操作日志”

很多人第一眼看到 “Modbus TCP” 就默认它是通用协议,直接套用 Modbus 标准库。错。Solax SK-SU 的这份文档,本质是一份嵌入式设备固件与上位机之间的私有契约快照。你看它的历史列表:2014 年 9 月 V1.0 还是草稿,2014 年 10 月 V2.1 就因“移除 charger2 寄存器”而更新,2015 年 3 月 V2.3 开始解释 PV 模式含义,2015 年 12 月 V2.49 加入Battery SOH—— 这些不是功能迭代,是现场问题倒逼的协议修正。比如VpvLowStop(0x0018)单位是0.1V,但早期固件可能误传为1V,用户反馈低压保护误触发,于是 V2.2 就明确写了单位;再比如ChargerStartTime1_Hours(0x0092)必须是0–23,但若上位机传了24,固件不会报错,而是静默归零或溢出,导致定时充电失效。所以,这份文档的每一行“Unit”、“dataformat”、“W/R”,都是前人踩坑后刻下的路标,不是可选参数,是强制约束。你跳过它去抄别人家的 Modbus 示例代码,大概率会在凌晨三点收到客户告警:“逆变器突然脱网,RunMode寄存器读出来是65535”。

1.2 为什么必须盯死 V2.49 这个版本号:跨版本寄存器语义漂移是最大隐患

文档末尾明确标注V2.49 Add 2016-06-03,并新增了Battery SOH(0x0023)和Charger Input Energy(0x0020/0x0021)。但注意:V2.49 不是终点,而是分水岭。V2.48 加了Remote wake up Idle (0x90),V2.49 在此基础上修正了Etotal寄存器(0x0052/0x0053)的描述,而 V2.50 又新增了Charger InputEnergy。这意味着,如果你的逆变器固件是 V2.47,你去读0x0023,得到的可能是0或乱码,因为该地址在 V2.47 中还是REV(保留区);反之,若固件是 V2.50,你用 V2.49 文档去读0x0020/0x0021,会发现单位是0.1kWh,但实际固件可能已升级为0.01kWh。这种“寄存器地址相同、语义不同”的漂移,在Safety类寄存器(0x001D)中尤为致命:V2.46 加了Manager EE Reset configuration,V2.47 又加了Relay Control,如果上位机未做固件版本协商,直接向0x0056(Relay1 State)写1,而固件不支持该功能,轻则无响应,重则触发安全锁死。因此,任何基于此协议的开发,第一步不是写代码,而是通过FirmwareVersion_InverterMaster(0x007D)和FirmwareVersion_Manager(0x0083)两个寄存器,实测读取设备真实固件版本,并严格匹配文档版本号。这是所有稳定性的前提,不是可选项。

1.3 它能解决什么具体问题:从“能连上”到“敢用于生产系统”的三道门槛

很多用户卡在第一关:连不上。但真正拦住项目交付的,是后面两关。第一关“连接层”靠基础网络排查:确认逆变器 IP 与上位机同网段、502 端口开放、防火墙放行、Modbus TCP 帧格式正确(事务标识符、协议标识符、长度字段需自动生成,不能硬编码)。第二关“数据层”才是核心:GridPower(0x0002)是int16,值-1234表示反向送电123.4W,但若你用uint16解析,就变成64202W,整个功率平衡计算崩盘;Battery1_ChargeMaxCurrent(0x0090)单位是0.01A,返回5000应是50.00A,但若忽略小数点,直接当整数用,保护阈值就设错了十倍。第三关“控制层”最易翻车:wExt_SetChargerPower(0x00D4)是wInt16,正数充电、负数放电,但Machine switch(0x00DA)是uint16,MSB 是软件开关,LSB 是硬件开关,写0x0001只开软件,写0x0002才开硬件,写0x0003才全开——少一位比特,指令就石沉大海。这份协议的价值,就是把这三道门槛的“为什么失败”转化成“怎么验证成功”:它告诉你0x0000的GridVoltage必须在1800–2700(即180.0–270.0V)之间才合理,0x0009的RunMode值0–5对应待机、PV、市电、电池、EPS、故障六种状态,超出即异常。它不教你 Modbus 原理,只给你一把量尺,让你亲手丈量数据是否可信。

2. Modbus TCP 基础配置与帧结构解析:端口、超时、字节序,三个参数定生死

2.1 端口与连接:502 端口不是“默认”,而是唯一合法入口

协议明确写着port:502,这不是建议,是硬性规定。Solax SK-SU 固件的 Modbus TCP 服务只监听 502 端口,其他端口(如常见的 503、8888)一律拒绝连接。很多初学者用 nmap 扫描发现502/tcp open就以为通了,但实际测试时仍超时,原因往往出在连接建立后的保活机制缺失。Modbus TCP 本身无心跳,但工业现场要求长连接稳定。常见做法是:上位机在建立 TCP 连接后,每 30 秒发送一个空闲请求(例如读0x0000长度为 0 的寄存器),或启用 TCP Keep-Alive 选项(Linux 下setsockopt(sockfd, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive)))。否则,中间路由器或交换机的 NAT 表项超时(通常 300 秒),连接会被静默断开,下次发请求时 TCP 层才报Connection reset by peer,而 Modbus 层只显示超时。此外,IP Method(0x00BC)寄存器决定设备是 DHCP 还是静态 IP,若设为0(DHCP),但网络无 DHCP 服务器,设备会卡在获取 IP 阶段,此时MyAddress(0x0084)读出来是0,所有后续通信必然失败。务必先用网线直连电脑,用arp -a查看设备 MAC 地址,再 ping 其默认网关确认物理连通性,最后才发起 Modbus 连接。

# Linux 下检查 TCP 连接状态(替换为你的逆变器IP) nc -zv 192.168.1.100 502 # 若返回 "succeeded!",说明端口可达;若超时,检查网线、IP、防火墙

提示:某些企业内网策略会封禁 502 端口,需联系网络管理员白名单放行。不要尝试改端口——固件不支持。

2.2 时间参数:1 秒间隔不是性能瓶颈,而是防冲突的生存法则

协议规定The least interval time between two instructions 1 Sec,即两次请求最小间隔 1 秒。这不是因为设备慢,而是为防止寄存器读写冲突。Solax 固件的 Modbus 处理是单线程轮询,若连续快速发包(如 100ms 间隔),后一个请求会覆盖前一个的响应缓冲区,导致数据错乱。例如,你同时读0x0000(GridVoltage)和0x0001(GridCurrent),若间隔小于 1 秒,0x0001的响应可能携带0x0000的数据。实测中,将间隔设为1200ms是更稳妥的选择,留出 200ms 余量应对网络抖动。Response timeout 1 Sec同样关键:若设备忙于处理 EPS 切换或自检,响应可能延迟。设为1500ms更鲁棒。Character-gap time out >100ms指同一帧内字节间隔,Modbus TCP 无此概念(TCP 已保证字节流有序),可忽略,但若用 Modbus RTU 转换器,此值必须大于 100ms。

# Python 示例:使用 pymodbus 3.6+ 设置合规超时 from pymodbus.client import ModbusTcpClient from pymodbus.transaction import ModbusSocketFramer client = ModbusTcpClient( host="192.168.1.100", port=502, # 关键:设置响应超时和连接超时 timeout=1.5, # 响应超时 1.5 秒 retries=1, # 重试 1 次,避免无限等待 retry_on_empty=True, # 禁用自动重连,由上层控制 auto_open=False, auto_close=False ) client.connect() # 发送请求前,确保距上次请求 >= 1.2 秒 import time last_request_time = 0 def safe_read(client, address, count, unit=1): global last_request_time now = time.time() if now - last_request_time < 1.2: time.sleep(1.2 - (now - last_request_time)) result = client.read_holding_registers(address, count, slave=unit) last_request_time = time.time() return result

2.3 帧格式与字节序:MSB/LSB 不是概念,是寄存器地址映射的物理真相

Modbus TCP 帧格式包含 7 字节头 + 功能码 + 数据。头字段中,Transaction Identifier(2 字节)由客户端自增,Protocol Identifier(2 字节)固定0x0000,Length(2 字节)是后续字节数(含功能码),Unit Identifier(1 字节)通常是0x01。但真正决定数据能否正确解析的,是寄存器地址的字节序约定。文档中SeriesNumber(0x0000–0x0006)注明MSB=SN[14],即最高字节存放在地址0x0000,最低字节存放在0x0006。这意味着,若序列号是"SKSU1234567890AB"(14 字符),其 ASCII 码为0x53 0x4B 0x53 0x55 0x31 0x32 0x33 0x34 0x35 0x36 0x37 0x38 0x39 0x30,那么0x0000返回0x534B("SK"),0x0001返回0x5355("SU"),依此类推。若你用小端序解析,就会得到乱码。同样,feedin_power(0x0046–0x0047)是Int32,需合并两个uint16:0x0046是 LSB,0x0047是 MSB,组合为value = (reg_0047 << 16) | reg_0046。文档中所有length为1的寄存器(如0x0000)都是uint16或int16,length为2的(如0x0046)才是uint32/int32,必须严格按此规则拼接。

# 解析 Int32 类型寄存器(如 feedin_power 0x0046) def parse_int32(registers): # registers 是 [reg_0046, reg_0047],即 [LSB, MSB] if len(registers) < 2: raise ValueError("Need at least 2 registers for Int32") lsb = registers[0] msb = registers[1] # 合并为 32 位整数 value = (msb << 16) | lsb # 转换为有符号整数(因 feedin_power 可为负) if value & 0x80000000: value -= 0x100000000 return value # 示例:假设读到 [0x0123, 0x4567] -> 0x45670123 -> 1164273955 # 但实际值应为 1164273955 / 1 = 1164273955 W?不对!需查单位 # 文档写 unit: 1w,所以 1164273955 W = 1.16 GW?显然错误 # 正确做法:先确认寄存器是否真的返回了有效值,再计算

注意:Int32类型寄存器的单位(如feedin_power是1w)是最终值,不是每个uint16的单位。拼接后再乘单位。

3. 寄存器详解与实战读写:从只读监控到安全写入的完整链路

3.1 只读寄存器:GridVoltage、RunMode、Etoday 的精准解析与校验逻辑

只读寄存器是监控系统的基石,但“读出来”不等于“数据可用”。以GridVoltage(0x0000)为例,文档写unit: 0.1V,dataformat: uint16,length: 1。实测中,正常值应在1800–2700(180.0–270.0V)之间。若读到0,可能是设备未并网;若读到65535,则是通信错误或寄存器未就绪。RunMode(0x0009)是uint16,但值域是0–5,对应Standby,PV,Grid,Battery,EPS,Fault。若读到6或65535,说明固件异常或版本不匹配,必须告警。Etoday(0x0050)是uint16,单位0.1kwh,最大值6553.5 kWh,对家庭系统足够,但若读到65535,需检查是否当天电表清零或固件 bug。关键校验逻辑是:对每个关键寄存器,定义合理值域,并在读取后立即验证。

# 定义寄存器校验规则 REGISTER_RULES = { 0x0000: {"name": "GridVoltage", "unit": 0.1, "type": "uint16", "min": 1800, "max": 2700, "desc": "Grid voltage in 0.1V"}, 0x0009: {"name": "RunMode", "unit": 1, "type": "uint16", "min": 0, "max": 5, "desc": "Operating mode: 0=Standby,1=PV,2=Grid,3=Battery,4=EPS,5=Fault"}, 0x0050: {"name": "Etoday", "unit": 0.1, "type": "uint16", "min": 0, "max": 65535, "desc": "Energy today in 0.1kWh"}, } def validate_register(address, value): rule = REGISTER_RULES.get(address) if not rule: return True, "No rule defined" if rule["type"] == "uint16" and (value < 0 or value > 65535): return False, f"Value {value} out of uint16 range" if value < rule["min"] or value > rule["max"]: return False, f"{rule['name']} {value * rule['unit']:.1f} out of range [{rule['min']*rule['unit']:.1f}, {rule['max']*rule['unit']:.1f}]" return True, "OK" # 使用示例 result = client.read_input_registers(0x0000, 1, slave=1) if not result.isError(): grid_volt_raw = result.registers[0] is_valid, msg = validate_register(0x0000, grid_volt_raw) if is_valid: grid_volt = grid_volt_raw * 0.1 # 0.1V unit print(f"Grid Voltage: {grid_volt:.1f} V") else: print(f"Validation failed: {msg}")

3.2 读写寄存器:PowerLimitsPercent、SolarChargerUseMode 的安全写入流程

读写寄存器(W/R)是控制的核心,但风险极高。PowerLimitsPercent(0x0025)是uint16,范围0–100,表示输出功率百分比。写入前必须确认:1)设备当前RunMode不是Fault(0x0009 != 5);2)Machine switch(0x00DA)的 LSB(硬件开关)为1;3)写入值在0–100内。否则,写入无效或触发保护。SolarChargerUseMode(0x008B)控制充电模式:0=Self use,1=ForceTimeUse,2=Remote control。切换模式需先读当前值,再写新值,并等待1.5秒后读回确认。严禁在RunMode为EPS时写PowerLimitsPercent,会导致 EPS 切换失败。

# 安全写入 PowerLimitsPercent (0x0025) def safe_write_power_limit(client, limit_percent, slave=1): # 步骤1:检查 RunMode run_mode_result = client.read_input_registers(0x0009, 1, slave=slave) if run_mode_result.isError() or run_mode_result.registers[0] == 5: raise RuntimeError("Cannot write power limit in Fault mode") # 步骤2:检查硬件开关 machine_result = client.read_holding_registers(0x00DA, 1, slave=slave) if machine_result.isError(): raise RuntimeError("Failed to read Machine switch") machine_val = machine_result.registers[0] hardware_on = (machine_val & 0x0001) == 1 # LSB if not hardware_on: raise RuntimeError("Hardware switch is OFF, cannot write") # 步骤3:校验 limit_percent if not (0 <= limit_percent <= 100): raise ValueError("Power limit must be between 0 and 100") # 步骤4:写入 write_result = client.write_register(0x0025, int(limit_percent), slave=slave) if write_result.isError(): raise RuntimeError(f"Write failed: {write_result}") # 步骤5:等待并验证 time.sleep(1.5) verify_result = client.read_holding_registers(0x0025, 1, slave=slave) if not verify_result.isError() and verify_result.registers[0] == int(limit_percent): print(f"Power limit set to {limit_percent}% successfully") return True else: raise RuntimeError("Verification failed after write") # 使用 safe_write_power_limit(client, 80) # 设为 80%

3.3 多字节寄存器:feedin_energy、Etotal 的 Int32 拼接与溢出处理

feedin_energy(0x0048–0x0049)和Etotal(0x0052–0x0053)是Uint32,单位0.01kwh和0.001kwh。拼接时,0x0048是 LSB,0x0049是 MSB。但Uint32最大值4294967295,乘以0.01是42949672.95 kWh,对十年运行的电站足够。然而,固件可能有溢出重置逻辑。实测中,若Etotal从4294967294突然跳回0,说明发生了 32 位溢出。解决方案是:记录上一次读取值,若本次值远小于上次(如差值 > 1e9),则视为溢出,累加2^32。

class EnergyCounter: def __init__(self): self.last_value = 0 self.overflow_count = 0 def update(self, lsb, msb, unit=0.001): # 拼接为 Uint32 raw_value = (msb << 16) | lsb # 处理溢出:若 raw_value < last_value 且差值巨大,则累加 2^32 if raw_value < self.last_value and (self.last_value - raw_value) > 1000000000: self.overflow_count += 1 self.last_value = raw_value total_raw = raw_value + (self.overflow_count << 32) return total_raw * unit # 使用 counter = EnergyCounter() # 读取 Etotal (0x0052 LSB, 0x0053 MSB) etotal_regs = client.read_input_registers(0x0052, 2, slave=1) if not etotal_regs.isError(): lsb = etotal_regs.registers[0] msb = etotal_regs.registers[1] etotal_kwh = counter.update(lsb, msb, unit=0.001) print(f"Etotal: {etotal_kwh:.3f} kWh")

4. 常见问题与避坑指南:那些让工程师凌晨三点爬起来的 5 个血泪现场

4.1 现象:读GridCurrent(0x0001)总是65535,但GridVoltage(0x0000)正常

原因:GridCurrent是int16类型,值65535对应十六进制0xFFFF,是有符号数-1。文档中unit: 0.1A,所以实际电流是-0.1A,表示微弱反向电流(如待机损耗)。但若你用uint16解析,就误判为6553.5A。
解决:严格按文档dataformat字段解析。0x0001是int16,必须进行符号扩展。Python 中struct.unpack('>h', bytes)或numpy.int16()。

4.2 现象:向wExt_SetChargerPower(0x00D4)写-500(充电 5A),但BatCurrent_Charge1(0x0015)无变化

原因:wExt_SetChargerPower是wInt16,但BatCurrent_Charge1单位是0.01A,且受SolarChargerUseMode(0x008B)控制。若模式是0(Self use),该寄存器被忽略;必须先设为2(Remote control)。
解决:写入前,先读0x008B,若非2,则写2,等待 1 秒,再写0x00D4。

4.3 现象:Etoday(0x0050)每天 0 点归零,但Etotal(0x0052/0x0053)不增长

原因:Etotal是Uint32,但固件 V2.47 之前存在Etotal wrong Register问题(见历史列表 V2.47 fix)。若固件版本低于 V2.47,0x0052/0x0053实际指向其他寄存器。
解决:先读FirmwareVersion_Manager(0x0083),若 <0x0247,则Etotal不可用,只能用Etoday累加(需处理每日归零)。

4.4 现象:Relay1 State(0x0056)读为1,但继电器无动作

原因:Relay1 State仅表示软件指令状态,实际动作由Relay1 Signal(0x005A)和Relay1 Answer(0x005B)控制。0x005A是触发信号,0x005B是执行反馈。
解决:要控制继电器,需写Relay1 Signal(0x005A)为1,然后轮询0x005B直到返回1,表示硬件已响应。

4.5 现象:批量读0x0000–0x000F,0x000A(Powerdc1)和0x000B(Powerdc2)返回0,但光伏板有输出

原因:Powerdc1和Powerdc2是uint16,单位1W,但若直流电压低于VpvStart(0x0015)设定值,逆变器未启动 MPPT,功率为0。
解决:先读VpvStart(0x0015),再用万用表测 PV 输入电压,确认是否高于该阈值。若低于,检查 PV 接线或光照。

5. 进阶技巧:固件版本自动协商、寄存器批量读取优化与安全写入熔断机制

5.1 固件版本自动协商:让上位机主动适配设备能力,而非硬编码 V2.49

硬编码 V2.49 文档去读所有寄存器,是最大风险源。正确做法是:上位机启动时,先读取关键版本寄存器,动态构建支持的寄存器列表。FirmwareVersion_InverterMaster(0x007D)和FirmwareVersion_Manager(0x0083)各占 1 个uint16,值0x0249表示 V2.49。但注意,0x007D是主逆变器固件,0x0083是管理器固件,两者版本可能不同。应以Manager版本为准,因其控制通信协议栈。构建一个版本映射表,定义每个版本支持的寄存器及单位。

# 固件版本映射(简化版) FIRMWARE_MAP = { "2.46": {"supports": ["0x0000", "0x0009", "0x0050"], "units": {"0x0000": 0.1, "0x0009": 1, "0x0050": 0.1}}, "2.47": {"supports": ["0x0000", "0x0009", "0x0050", "0x0052"], "units": {"0x0052": 0.001}}, "2.49": {"supports": ["0x0000", "0x0009", "0x0050", "0x0052", "0x0023"], "units": {"0x0023": 1}}, # Battery SOH } def get_firmware_version(client, slave=1): try: # 读 Manager Firmware Version result = client.read_holding_registers(0x0083, 1, slave=slave) if result.isError(): return "unknown" ver_code = result.registers[0] # 转换为 X.YZ 格式,如 0x0249 -> "2.49" major = (ver_code & 0xFF00) >> 8 minor = ver_code & 0x00FF return f"{major}.{minor:02d}" except Exception as e: return "unknown" # 使用 fw_ver = get_firmware_version(client) print(f"Detected firmware: {fw_ver}") if fw_ver in FIRMWARE_MAP: supported_regs = FIRMWARE_MAP[fw_ver]["supports"] print(f"Supported registers: {supported_regs}")

5.2 批量读取优化:用最少请求覆盖最多关键数据,规避 1 秒间隔瓶颈

频繁单寄存器读取效率极低。应按功能域分组,合并为最少数量的请求。例如,电网参数0x0000–0x0008(9 个寄存器)可一次读取;电池参数0x0014–0x0023

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