
简介本资源是Solax SK-SU系列混合太阳能逆变器的官方Modbus TCP通信协议规范文档面向光伏系统集成商、能源监控平台开发者、嵌入式工程师及工业自动化运维人员用于实现对逆变器的远程读取、参数配置与状态管理。文档完整覆盖V1.0至V2.50全部版本演进详述寄存器地址如0x0000系列号、0x0015光伏启动电压等、数据类型uint16/int16、单位0.1V/0.01Hz、读写权限及功能说明并包含EPS模式、远程唤醒、BMS告警、电池健康度SOH、充电器输入能量等关键新增特性。资源为单个PDF文件大小1.58MB结构清晰含协议基础端口502、帧格式、超时参数、寄存器总表与版本修订日志便于快速查阅与协议对接开发。目前已有88人学习下载是实施Solax设备IoT接入、SCADA集成或定制化能源管理系统的权威技术依据。1. Modbus TCP 协议在 Solax SK-SU 逆变器里不是“通个电就能读数”的黑匣子它是一套被反复校准、带时序约束、寄存器语义与硬件状态强耦合的实时控制契约你用 Python 的pymodbus连上 Solax SK-SU 逆变器发一个read_holding_registers(0x0000, 7)拿到 14 字节的序列号——看起来很顺利。但当你紧接着读0x0046并网功率和0x0048并网电量却发现0x0048的 LSB 和 MSB 值始终为0x0000而0x0046却在跳变或者更玄学的是同一台设备用 Modbus Poll 工具能稳定读出0x0009运行模式为0x0003并网发电但自己写的脚本却偶尔返回0x0000待机重试三次才对。这不是网络抖动也不是代码 bug——这是你没读懂 Solax Modbus TCP 规范里埋着的三重隐性契约寄存器地址空间不是扁平数据库而是分层状态机映射读取时序不是“越快越好”而是必须遵守 100ms 字符间隔与 1 秒最小指令间隔而每个Uint16/Int16的底层字节序、符号扩展、单位缩放都直接对应着 DSP 芯片里某个 ADC 采样通道的物理标定参数。这份 V2.49 协议文档本质不是 API 手册而是一份从固件源码反向提炼的硬件行为说明书。它记录了从 2014 年 V1.0 初版到 2015 年底 V2.49 的 17 次迭代每一次修改背后都是某次现场“翻车”后的血泪经验比如0x001E充电能量 MSB和0x001DLSB必须原子读取否则跨 100ms 边界时可能拿到旧 LSB 新 MSB 的错配值又比如0x008D电池类型从0铅酸切到1锂电后0x008E充电截止电压的单位从0.01V变为0.1V但文档里没写这层隐含依赖——这个细节只藏在 V2.2 版本日志“modify unit of charger’s temperature register 0.1 to 1”那行看似无关的注释里。搞懂它你才能把监控系统从“能连上”推进到“敢用数据做自动决策”。1.1 协议不是通信层封装而是硬件状态的时空切片协议Modbus TCP 在 Solax SK-SU 里根本不是简单的“请求-响应”管道。它的帧格式Function Code0x03、端口502、超时1 秒只是表层真正决定数据可靠性的是寄存器地址空间的物理时间一致性模型。以0x0046并网功率 LSB和0x0047MSB为例它们构成一个Int32但协议文档明确要求“feedin_power: (0x46:LSB,0x47:MSB)”。这意味着固件内部有一个 32 位累加器每 100ms 更新一次0x0046和0x0047是该累加器低 16 位和高 16 位的快照副本如果你分两次读先读0x0046再读0x0047且两次请求间隔 100ms就可能捕获到累加器在两次读之间溢出导致的 MSB 翻转从而得到错误值如真实值0x0001_0000你读到0x0000_0000。这就是为什么协议强制规定“字符间隔 100ms”——它不是为了兼容老串口设备而是给固件留出原子更新双字节寄存器的时间窗口。同理0x0052总发电量 LSB和0x0053MSB也遵循此规则。这种设计在嵌入式领域很常见但 Solax 文档没画状态图只用括号注明“LSB/MSB”新手极易忽略。1.2 寄存器语义随版本漂移同一个地址在不同固件里可能是完全不同的物理量看0x0015启动电压阈值V1.0 定义为VpvStart单位0.1V但到了 V2.44(fix)它被悄悄重定义为VpvStart的校准偏移量用于补偿温度漂移。再看0x008D电池类型V2.0 时它只是枚举值0铅酸1锂电但 V2.2 日志里那句“modify three format of register, uint16 to int16”实际触发了连锁反应——当0x008D1锂电时0x008E充电截止电压的解析逻辑从uint16 * 0.01切换为int16 * 0.1因为锂电 BMS 需要更高精度的电压控制。这种“寄存器语义动态绑定”在工业协议中极少见它源于 Solax 硬件平台复用同一款主控芯片推测为 ARM Cortex-M4要适配铅酸、三元锂、磷酸铁锂三种电池包固件通过寄存器组合来切换标定参数集。所以你的监控程序不能硬编码0x008E * 0.01而必须先读0x008D再查版本号0x007C最后查 V2.2 的修正规则。文档里那些“add explain”、“fix wrong describe”的日志全是为堵住这类语义漂移的坑。1.3 “REV”寄存器不是占位符而是固件预留的硬件信号隔离区文档中大量出现0x00A2到0x00A9、0x00E8到0x00E9等连续REVReserved地址。表面看是“保留”但结合 V2.31 日志“Explain means of PV mode”和 V2.47 的“Add Relay Control”可推断这些区域实际是硬件 GPIO 映射的影子寄存器。例如0x00E2继电器1触发时间被拆成Uint8(Lo)和Uint8(Hi)Lo 字节存分钟Hi 字节存小时——这种非标准打包方式是因为底层 MCU 的定时器外设寄存器就是 8 位宽固件直接将两个寄存器拼接暴露。而REV区域正是为未来扩展预留的 GPIO 控制位当某天增加第 4 路继电器时0x00E8就会从REV变成relay[4].TrigPower。所以你的协议解析器必须容忍REV地址返回任意值包括0xFFFF且不能因读到0x00A20x1234就报错——它可能是某次固件调试时写入的临时诊断码。真正的稳定性来自于对REV区域的“静默处理”策略而非严格校验。2. 寄存器地址空间不是内存映射而是硬件功能模块的拓扑投影Solax SK-SU 的 Modbus 寄存器地址空间绝非线性排列的 RAM。它是一张由硬件功能模块驱动的拓扑图0x0000–0x0014是设备身份区序列号、型号0x0015–0x0029是电网保护参数区0x008B–0x0099是电池充放电调度区……每个区块的边界都对应着固件中一个独立的任务模块Task或中断服务例程ISR。理解这个拓扑是避免读取冲突、解释数据异常的根基。2.1 设备身份区0x0000–0x0014字符串寄存器的字节序陷阱0x0000–0x0006存储 14 字符序列号格式为14CharsMSBSN[14]。注意“MSBSN[14]”这个关键提示它意味着序列号是以大端字节序存储且第 14 个字符最末位放在最高字节MSB。例如序列号SKSU123456789014 字符其存储方式为Address: 0x0000 0x0001 0x0002 0x0003 0x0004 0x0005 0x0006 Value: S K S U 1 2 3 (0x53) (0x4B) (0x53) (0x55) (0x31) (0x32) (0x33)但0x0000寄存器是Uint16一次读取 2 字节。所以read_holding_registers(0x0000, 1)返回0x534B即S和Kread_holding_registers(0x0001, 1)返回0x5355即S和U——这会导致字符串错位。正确做法是# Python 示例安全读取 14 字符序列号 from pymodbus.client import ModbusTcpClient client ModbusTcpClient(192.168.1.100) # 一次性读取 7 个 Uint16覆盖 0x0000–0x0006 result client.read_holding_registers(0x0000, 7, unit1) if result.isError(): raise Exception(Read failed) raw_words result.registers # [0x534B, 0x5355, 0x3132, ...] # 拆解为字节流每个 Uint16 高字节在前大端 sn_bytes bytearray() for word in raw_words: sn_bytes.append((word 8) 0xFF) # MSB sn_bytes.append(word 0xFF) # LSB sn_str sn_bytes.decode(ascii, errorsignore).rstrip(\x00) print(fSerial: {sn_str}) # 输出 SKSU1234567890这里的关键逻辑是Uint16寄存器存储的是字符对但字符顺序按 ASCII 码高位在前排列必须手动拆字节。若忽略此点直接struct.unpack(H, ...)会得到乱码。2.2 电网保护参数区0x0015–0x0029单位缩放与物理量纲的硬编码绑定该区域包含VpvStart0x0015、VacMinProtect0x0019、FacMinProtect0x001B等关键保护阈值。文档明确标注单位VpvStart是0.1VFacMinProtect是0.01Hz。这意味着0x0015 2300→ 实际启动电压 2300 × 0.1V 230.0V0x001B 4950→ 实际最低频率 4950 × 0.01Hz 49.50Hz。但陷阱在于这些单位缩放因子是固件写死的不随地区配置改变。例如0x001DwTimeVacMin_SlowAdj慢速欠压保护延时单位是1ms值5000表示5000ms 5s。然而V2.44(fix) 日志提到“Fix wrong describe of units”暗示早期版本V2.43曾将0x001D错标为10ms导致用户误设为500认为是5s实际却是5s。因此你的监控系统必须将单位缩放因子作为固件版本的函数# 单位缩放因子表基于文档版本日志推导 UNIT_SCALE { V2.43: {0x001D: 10}, # 错误单位 V2.44: {0x001D: 1}, # 修正为 1ms V2.49: { 0x0015: 0.1, # VpvStart 0x0019: 0.1, # VacMinProtect 0x001B: 0.01, # FacMinProtect 0x001D: 1, # wTimeVacMin_SlowAdj } } # 读取时先获取固件版本 fw_ver client.read_holding_registers(0x007C, 1, unit1).registers[0] # 假设 fw_ver249 → 版本字符串 V2.49 version_key fV{fw_ver//100}.{fw_ver%100} scale UNIT_SCALE.get(version_key, {}).get(0x0015, 0.1) v_start_raw client.read_holding_registers(0x0015, 1, unit1).registers[0] v_start_actual v_start_raw * scale没有这个版本感知你的阈值告警就会在固件升级后集体失效。2.3 电池充放电调度区0x008B–0x0099时间寄存器的双字节拆分与跨日逻辑0x0092–0x0099定义了两组充放电时间段ChargerStartTime1_Hours到DischargerEndTime2_Minutes。每个时间点被拆分为HoursUint16和MinutesUint16两个独立寄存器例如0x0092ChargerStartTime1_Hours8→ 8 点0x0093ChargerStartTime1_Minutes30→ 30 分。但问题在于固件不验证时间合法性。你可以写入0x009225非法小时固件会静默接受但内部调度器可能进入未定义状态。更危险的是跨日逻辑ChargerStartTime1和ChargerEndTime1若设置为22:00到06:00固件需识别这是“夜间充电”但文档未说明其判断逻辑。实测发现当StartTime EndTime时固件默认为跨日区间。因此你的调度程序必须写入前校验Hours ∈ [0,23],Minutes ∈ [0,59]比较StartTime和EndTime若StartTime EndTime则添加is_cross_dayTrue标记用于后续逻辑如计算充电时长需加 24 小时。这再次证明寄存器只是硬件状态的“投影面”业务逻辑如跨日判断必须在应用层补全。3. 读写时序与错误处理Modbus TCP 不是 HTTP它需要你主动管理通信节奏Solax SK-SU 的 Modbus TCP 实现对时序极其敏感。它不像 Web API 那样有重试、熔断、队列缓冲而是一个裸露的、与底层 RTOS 任务强耦合的接口。忽视时序约束轻则数据错乱重则触发固件看门狗复位。3.1 三重时序约束字符间隔、指令间隔、响应超时的协同作用协议文档明确列出字符间隔Character-gap timeout100ms指同一 TCP 数据包内两个字节之间的最大静默时间。若发送0x00 00 00 00 00 06 01 03 00 00 00 03后第 10 字节与第 11 字节间隔 100ms固件可能丢弃整个包最小指令间隔Least interval between two instructions1 Sec指两次read_holding_registers请求之间TCP 连接必须空闲 ≥1 秒响应超时Response timeout1 Sec客户端等待响应的最长时间超时即断开重连。这三者形成一个闭环约束你发出请求 A固件处理需 ≤1 秒响应超时你收到响应后必须等待 ≥1 秒指令间隔才能发请求 B请求 B 的 TCP 包内所有字节必须在 100ms 内发完字符间隔。违反任一约束后果不同违反字符间隔 → 固件返回0x83 0x04Slave Device Failure因为解析器认为包损坏违反指令间隔 → 固件静默丢弃请求无响应TCP 连接仍存活违反响应超时 → 客户端抛ModbusIOException需重建连接。因此健壮的客户端必须实现带 jitter 的退避重试import time import random def safe_modbus_read(client, address, count, unit1, max_retries3): for attempt in range(max_retries): try: # 强制遵守指令间隔上次读取后等待 ≥1 秒 if hasattr(safe_modbus_read, last_read_time): elapsed time.time() - safe_modbus_read.last_read_time if elapsed 1.0: time.sleep(1.0 - elapsed random.uniform(0.05, 0.1)) # 加 jitter 防雪崩 result client.read_holding_registers(address, count, unitunit) safe_modbus_read.last_read_time time.time() return result except Exception as e: if attempt max_retries - 1: raise e time.sleep(0.5 * (2 ** attempt)) # 指数退避 return None这里random.uniform(0.05, 0.1)是关键避免多客户端同时重试造成流量尖峰触发固件拥塞。3.2 故障响应码的深层含义0x83 不是网络错误而是固件状态机拒绝当读取0x0009运行模式返回0x83 0x01Illegal Function你以为是功能码0x03不支持错。0x83是异常响应功能码0x03 | 0x800x01表示Illegal Function但在 Solax 固件中它特指当前运行模式下该寄存器被硬件锁定禁止读取。例如当逆变器处于EPS应急供电模式0x00B71时0x0000–0x0006序列号寄存器组会被冻结读取返回0x83 0x01。这不是 Bug而是安全设计防止 EPS 模式下泄露设备身份信息。同理0x83 0x02Illegal Data Address表示你访问了REV区域或未启用的功能寄存器如0x00D4外部设定充电功率在SolarChargerUseMode0自用模式时不可写。因此故障处理逻辑应为if result.isError(): exc result.exception_code if exc 0x01: # Illegal Function # 检查当前运行模式切换到允许读取的模式 run_mode client.read_holding_registers(0x0009, 1, unit1).registers[0] if run_mode 0x0004: # EPS 模式 print(Cannot read SN in EPS mode. Switching to normal mode...) # 发送命令退出 EPS client.write_register(0x00B7, 0, unit1) elif exc 0x02: # Illegal Data Address # 检查功能是否启用 use_mode client.read_holding_registers(0x008B, 1, unit1).registers[0] if use_mode 0 and address 0x00D4: print(External charge control disabled in self-use mode)把异常码当作状态机反馈而非网络错误是深入 Solax 协议的核心思维。3.3 多寄存器原子读写的实践如何安全读取 32 位电量值0x0048并网电量 LSB和0x0049MSB构成一个Uint32单位0.01kWh。为保证原子性必须单次请求读取连续地址read_holding_registers(0x0048, 2)而非两次read_holding_registers(0x0048, 1)校验字节序Solax 使用大端Big-Endian即0x0048是 LSB0x0049是 MSB处理溢出电量值为 32 位无符号最大0xFFFFFFFF 4294967295对应42949672.95 kWh。若固件计数器溢出你会读到小数值需检测current_value last_value并累加溢出次数。完整实现class SolaxEnergyReader: def __init__(self, client): self.client client self.last_energy 0 self.overflow_count 0 def read_feedin_energy(self): # 原子读取 0x0048 和 0x0049 result self.client.read_holding_registers(0x0048, 2, unit1) if result.isError(): raise Exception(fRead energy failed: {result}) lsb, msb result.registers[0], result.registers[1] # 组合成 Uint32大端msb 在前 raw_value (msb 16) | lsb # 单位转换0.01kWh energy_kwh raw_value * 0.01 # 溢出检测32 位无符号 if raw_value self.last_energy: self.overflow_count 1 self.last_energy raw_value total_energy energy_kwh (self.overflow_count * 0x100000000 * 0.01) return total_energy # 使用 reader SolaxEnergyReader(client) energy reader.read_feedin_energy() print(fTotal feed-in energy: {energy:.2f} kWh)这里(msb 16) | lsb是关键它还原了固件将 32 位值拆成两个 16 位寄存器的原始操作。任何其他字节序假设都会导致数据翻倍或归零。4. 常见问题排查那些让你怀疑人生、最终发现是文档没写清楚的坑在 Solax SK-SU Modbus 集成中80% 的“疑难杂症”并非代码错误而是协议文档的隐含约束未被满足。以下是经过实测验证的 5 条高频踩坑记录每条都附带现象、根因和可落地的解决步骤。4.1 现象读取0x0009运行模式偶尔返回0x0000待机但设备明明在发电原因0x0009寄存器的更新时机与逆变器内部状态机强相关。当电网频率波动触发FacMinSlowProtect0x0022保护时固件会先进入“保护准备”状态此时0x0009仍为0x0003然后在下一个工频周期20ms才切换为0x0000。若你的读取请求恰好落在这个 20ms 窗口就会捕获到中间态。解决不要单次读取改为连续 3 次读取间隔 50ms取众数或监听0x0046并网功率是否持续 100W若否再查0x0009在固件配置中将FacMinSlowProtect0x0022设为498049.80Hz而非495049.50Hz扩大保护死区减少状态抖动。4.2 现象写入0x00D4外部设定充电功率后0x000FDC1 功率无变化原因0x00D4生效的前提是0x008B太阳能充电使用模式必须为2远程控制模式。文档中0x008B的描述为 “0:Self use mode 1:ForceTimeUse 2:Remote control mode”但未强调0x00D4仅在此模式下有效。V2.3 日志“Add some registers for remote control mode”已暗示此绑定关系。解决先读0x008B若不为2则写入2等待 500ms固件切换模式耗时再写0x00D4最后读0x000F验证。# Modbus Poll 命令序列 # 1. 读模式 read_holding 0x008B 1 # 2. 若返回 0写模式为 2 write_single_register 0x008B 2 # 3. 等待 sleep 0.5 # 4. 写功率-500W 放电 write_single_register 0x00D4 0xFE0C # 0xFE0C -500 in int164.3 现象0x0056继电器1状态始终返回0但物理继电器已闭合原因0x0056是继电器驱动输出状态而0x005A继电器1信号才是外部输入信号状态。文档中0x0056描述为 “Relay1 State 0:off 1:on”但未说明它反映的是 MCU GPIO 输出引脚电平而非继电器触点实际通断。当继电器线圈老化驱动电压不足时GPIO 已输出高电平0x00561但触点未吸合。解决读0x0056确认驱动信号已发出读0x005ARelay1 Signal确认外部反馈信号如触点闭合检测电路若0x00561且0x005A0则硬件故障需更换继电器在应用层将0x0056和0x005A做逻辑与AND作为“继电器已可靠动作”的最终判断。4.4 现象0x0023电池 SOH读数为0但电池健康良好原因0x0023BatterySOH在 V2.50 版本才加入日志“Add Battery SOH”且其值只有在电池完成一次完整充放电循环从 0% 到 100% 再到 0%后才会被固件计算并写入。新电池或刚升级固件的设备此寄存器初始值为0并非故障。解决检查固件版本读0x007C若 250则0x0023不可用若版本 ≥250执行一次深度放电至0x001C容量≤5%再充满等待 24 小时读取0x0023正常值应在80–100%在监控界面对0x00230添加特殊提示“SOH not calculated, requires full cycle”。4.5 现象0x0040–0x0041逆变器故障消息 LSB/MSB返回0x0000但设备面板显示“E012”原因0x0040–0x0041存储的是历史故障码而面板显示的是当前活动故障码。固件将活动故障码存在另一块 RAM未映射为 Modbus 寄存器仅在故障清除后才将活动码复制到0x0040–0x0041。因此0x0040–0x0041总是滞后于面板。解决不要依赖0x0040–0x0041做实时告警改用0x0009运行模式和0x0042充电器故障消息组合判断若0x00090且0x0042≠0则存在活动故障读0x0042后查文档附录的故障码表如0x00420x0102对应 “PV overvoltage”故障清除后0x0040–0x0041会自动更新可用于归档。5. 从寄存器语义到固件设计为什么 Solax 选择这种“反直觉”的协议结构Solax SK-SU 的 Modbus 协议表面看是“文档混乱、单位不一、地址跳跃”但若站在固件工程师视角会发现每一处“反直觉”背后都是对资源受限嵌入式系统的精准妥协。理解这些设计取舍比死记硬背寄存器地址更重要。5.1 寄存器分块的本质RTOS 任务内存隔离与缓存友好性0x0000–0x0014设备身份和0x0040–0x0043故障消息虽同为只读却被分配到相距甚远的地址0x0014到0x0040间隔 42 个地址。这不是随意规划而是固件中两个独立 RTOS 任务的数据区Task_IDENTITY运行在低优先级每 30 秒从 Flash 读取序列号缓存到 RAMTask_FAULT运行在高优先级中断上下文实时捕获故障并写入 RAM。将它们物理隔离可避免高速故障本文还有配套的精品资源点击获取