储能逆变器Modbus TCP寄存器解析与稳定采集实战

发布时间:2026/10/31 8:20:01
储能逆变器Modbus TCP寄存器解析与稳定采集实战 简介面向光伏逆变器研发、嵌入式通信调试与电站运维人员的 Modbus TCP 协议参考文档聚焦 Solax SK-SU 系列混合储能逆变器的寄存器定义与通信实现可用于上位机开发、数据采集对接和现场故障排查。文档以寄存器表为核心逐条列出地址、读写属性、单位、数据格式与量程说明涵盖序列号与厂商信息、并网保护阈值、安规类型选择以及 EPS 功能、远程控制、电池 SOH、充电器输入能量等扩展寄存器并明确协议端口 502、帧格式与指令最小间隔、响应超时等时序参数。随附的版本变更记录从 V1.0 延续至 V2.50逐版标注人员、日期与改动内容如 uint16 改为 int16、寄存器单位修正、新增单写寄存器与硬件开关状态读取便于对照固件版本定位差异。压缩包共 1 个 PDF 文件约 1.58MB已有 88 人学习。1. 一台连不上云的储能机把我拉回这份停在 V2.50 的寄存器表上个月帮朋友处理一台二手混合储能逆变器机器在发电手机端却一直离线。现场没有网关也没有厂家调试口机箱侧面只剩一个 RJ45。想把电池 SOC、PV 两路电压电流、今日发电量取出来唯一的路是走 Modbus TCP 直接问设备。于是翻出这份 SK-SU 系列混合储能逆变器的 Modbus 协议文档主体内容 2016 年定稿最后一版停在 V2.50。它更像一张寄存器地图哪个地址放什么、单位是 0.1 还是 0.01、哪些只读哪些能写、写之前要先打开哪个开关。适合三类人手上有一台机器却拿不到数据的运维要做本地采集网关的嵌入式开发以及需要把储能数据接进自有平台的集成方。2. 先把通信底座搭对502 端口、100ms 帧窗口与 1s 指令间隔很多人拿到这类文档第一反应是翻寄存器表直接抄地址开读然后卡在超时上。文档开头那几行“协议通用参数”才是真正决定能不能通的部分它们不是性能指标是设备主控的处理窗口。2.1 三个时序参数限速并非追求速度参数文档取值落地含义协议类型Modbus TCP直连不需要 RTU 转 TCP 的转换板端口502固定端口改不了两条指令最小间隔1 s轮询周期的硬下限不是建议值字符间隔超时 100 ms两包之间的静默时间超过就判定一帧结束响应超时1 s客户端 timeout 建议设 1.01.5 s第一条最容易被忽略两条指令之间至少隔 1 秒。这不是“最好这样”是设备的处理节奏——主控要在两次请求之间完成采样刷新和寄存器映射间隔不够就直接丢包或回异常。这意味着你的轮询周期有一个算得出来的下限假设一轮要读 120 个寄存器按每段 40 个拆成 3 条报文光指令间隔就是 2 秒加上处理和网络往返3 秒一轮是现实值。想做到 1 秒刷一次全量数据物理上不成立只能靠分组。第二条“字符间隔 100ms”更隐蔽。Modbus TCP 本身没有字符间隔的概念这是串行链路的 t3.5 遗留但不少逆变器固件内部还是 RTU 状态机靠包间静默判断帧边界。如果客户端把一帧拆成两个 TCP 段发出去中间因为 Nagle 或者调度延迟隔了 100ms 以上设备会把前半截当成一个完整帧解析长度对不上回异常码或者干脆不理。常见做法是一次sendall把整帧拼好发出去别在业务层做分段。提示客户端 timeout 别设 0.5 s。文档写的是响应超时 1 s设备在高负载比如并网切换、电池唤醒时响应会逼近这个值设太短会把正常响应误判成掉线然后触发重连风暴越重连越不通。2.2 0x03 和 0x04 的分工配置区和实时区是两张地图文档把寄存器分成两大块功能码不同地址空间是平行的别混着读。功能码区域地址范围放什么读写属性0x03保持寄存器0x0000–0x00FB序列号、厂名、安规类型、电池参数、充放电时段、RTC、继电器配置大部分 R部分 W0x04输入寄存器0x0000–0x0062电网电压电流功率、PV 电压电流、机内温度、能量累计、故障字只读0x03 区里有一段值得单独记住0x002C 到 0x007B 是 80 个寄存器的 PowerManagerConfigData紧跟着 0x007C 是 PowerManagerEnable。这 80 个寄存器文档没给逐位定义属于厂内配置区日常采集别去碰读了也是黑匣子。0x007D 到 0x0083 是五路固件版本逆变器主/从、充电器 14、管理器做设备指纹很有用——同一个地址在不同固件版本上单位可能不一样先读版本再决定换算系数能省掉一堆“为什么我的数据和 APP 对不上”的争论。0x04 区里则有大片 REV 空洞比如 0x0024 到 0x003F 这一段大部分是保留位。从头读到尾看着省事实际上既拉长了轮询周期又可能在保留区拿到不定值还可能触发非法地址异常。2.3 用 pymodbus 打通第一条链路先只读 0x04 区开头 9 个寄存器验证链路、验证字节序、验证单位换算三件事一次做完。from pymodbus.client import ModbusTcpClient HOST, PORT, UNIT 192.168.1.50, 502, 1 # UNIT 对应保持寄存器 0x0084 MyAddress client ModbusTcpClient(HOST, portPORT, timeout1.2) # 略大于文档的 1s 响应超时 client.connect() # 0x04 区 0x0000~0x0008 共 9 个电网电压/电流/功率、PV1/PV2 电压、PV1/PV2 电流、频率、温度 rr client.read_input_registers(address0x0000, count9, slaveUNIT) raw rr.registers def s16(v): int16 还原文档里电流、功率、温度类字段标的是 Int16读到的是无符号位模式 return v - 0x10000 if v 0x8000 else v print(电网电压 V :, raw[0] * 0.1) # Uint16, 0.1V print(电网电流 A :, s16(raw[1]) * 0.1) # Int16, 0.1A print(电网功率 W :, s16(raw[2])) # Int16, 1W print(PV1 电压 V :, raw[3] * 0.1) # Uint16, 0.1V print(PV2 电压 V :, raw[4] * 0.1) print(PV1 电流 A :, raw[5] * 0.1) print(PV2 电流 A :, raw[6] * 0.1) print(电网频率 Hz :, raw[7] * 0.01) # Uint16, 0.01Hz print(机内温度 ℃ :, s16(raw[8])) # Int16, 1℃ client.close()逻辑说明一次请求覆盖一段连续地址count9对应 0x0000 到 0x0008中间没有 REV 空洞是这段地址能整读的原因。换算全部在本地做别指望设备返回浮点——这份文档里没有一个浮点寄存器全是定点整数加单位系数。参数说明slave在 pymodbus 3.x 是关键字参数2.x 叫unit直接抄旧代码会报unexpected keyword argument这是我第一次接这类设备时卡了半小时的地方。count上限文档没写死实践上一次别超过 4060 个寄存器一是部分固件对单帧长度有限制二是单帧越长中途被判定帧结束的概率越高。3. 写寄存器的三道锁安规枚举、运行模式与符号位编码读通了不代表能写。这份文档里带 W 属性的寄存器不少但每一个前面都有一道前置条件跳过前置条件直接写设备不报错就是不执行——这种“静默失败”比报异常难查得多。3.1 0x001D Safety19 个枚举值决定了整套保护阈值0x001D 是安规类型只读取值 0 到 18对应不同的并网标准代号值标准代号值标准代号0VDE012610NRS0971ARN410511VDE0126_Greece2AS477712UTE_C15_7123G83/2小于 3.7k13IEC617274C10/1114G59_35OVE/ONORM E800115VDE0126_Grece_Continent6EN50438_NL16France_VFR20147EN50438_DK17France_Island_50Hz8CEB18France_Island_60Hz9CEI021这个值的意义在于它决定了 0x0019 到 0x0023 那一段保护阈值的合法区间。同一台机器切到不同的安规档VacMinProtect、VacMaxProtect、FacMinProtect、FacMaxProtect 的可用范围跟着变。所以调试顺序永远是先读 0x001D 确认当前档位再去解读后面的电压频率阈值反过来做会把正常保护值当成故障。文档里 0x0015 到 0x0018 是 PV 侧的启动门槛VpvStart0.1V、TimeStart1s、VpvHighStop、VpvLowStop。这四个直接决定机器什么时候开始发电、什么时候停机做发电量分析时要一起取只看 PV 电压不看启动阈值会出现“明明有电压为什么不发电”的误判。3.2 0x008B 运行模式时间表能不能生效的总开关0x008B SolarChargerUseMode 三个取值0 自用模式、1 强制时段模式、2 远控模式。0x0092 到 0x00A1 这两段时间表充电/放电各两组每组含起止时分只有在模式等于 1 的时候才被执行。现场最典型的翻车是这样集成方把时间表写进去读回来数据也对等了一天发现电池根本没按时段充。原因是模式还停在 0。这一段没有任何“写入成功即生效”的反馈写了就是写了执行不执行是另一回事。模式 2 是远控此时充电功率由 0x00D4 wExt_SetChargerPower 决定正数充电、负数放电逆变输出功率由 0x00D5 wExt_SetInverterPower 决定。这两个是整份文档里少数几个明确标了有符号WInt16的保持寄存器也是远程调度场景的核心。0x00B4 Allow_Grid_Charge 则是另一个维度的开关0 禁止从电网充电1 允许时段 12 允许时段 23 两段都允许。它和 0x008B 是叠加关系不是替代关系。3.3 uint16 改 int16 那一段历史符号位不处理读到 65486这份文档从 V1.0 到 V2.50 改了二十多次其中影响最大的两次修改是单位调整和符号类型调整。V2.1 把三个寄存器的格式从 uint16 改成 int16V2.2 又把输入寄存器 0x46 改了。改的原因很实际充电器在放电时功率是负的用无符号表示不了。如果按无符号去读 0x00D4放电 50W 会读成 65486。这个数字看着像坏数据其实是 -50 的补码。换算和写入都得处理符号位UNIT 1 def to_s16(u16): 原始 16 位值 - 有符号整数。 适用0x00D4 wExt_SetChargerPower、0x00D5 wExt_SetInverterPower、 0x0014~0x0019 电池电压电流温度、0x0046 feedin_power 等 return u16 - 0x10000 if u16 0x8000 else u16 def from_s16(v): 有符号整数 - 写入用的 16 位位模式。别直接传负数给某些客户端库 return v 0xFFFF # 例子远控模式下让充电器以 800W 充电 client.write_register(address0x008B, value2, slaveUNIT) # 先切远控模式 client.write_register(address0x00D4, valuefrom_s16(800), slaveUNIT) # 例子读回来验证放电时应该是负数 rr client.read_holding_registers(address0x00D4, count1, slaveUNIT) print(外部设定充电功率 W:, to_s16(rr.registers[0]))逻辑说明先切模式再写功率顺序不能反。写入前把负数转成无符号位模式是因为部分客户端库在write_register里对负值会做范围校验直接抛异常或者按有符号打包出错。参数说明0x00D4 单位是 1WInt16 范围 ±32767W实际可用范围还受电池最大充放电电流0x0090、0x0091单位 0.01A约束。写入一个超出电池能力的值设备不会报错只会按内部限幅执行读回来和你写进去的不一样——这不算故障是限幅逻辑在起作用。4. 四类数据的拆包倒序字符串、32 位能量、电池簇与故障位图寄存器读回来只是一串 065535 的整数真正的活在拆包。这份文档里的数据格式至少有四种倒序字符串、16 位定点、32 位高低字组合、按字节打包的双参数。4.1 序列号和厂名14 个字符是倒着存的0x0000 到 0x0006 是序列号7 个寄存器正好 14 个字符0x0007 到 0x000D 是厂名0x000E 到 0x0014 是模块名结构一样。文档在描述里写了一句容易看漏的话MSB SN[14]意思是第一个寄存器的高字节存放的是序列号的最后一个字符。def read_reversed_string(client, start, count7, unit1): 读 14 字符的倒序字符串7 个寄存器 - 14 字节 - 反转 - 解码 rr client.read_holding_registers(addressstart, countcount, slaveunit) buf bytearray() for reg in rr.registers: buf.append((reg 8) 0xFF) # 高字节在前 buf.append(reg 0xFF) return bytes(reversed(buf)).decode(ascii, errorsignore).strip(\x00 ) sn read_reversed_string(client, 0x0000) # 序列号 vendor read_reversed_string(client, 0x0007) # 厂名 model read_reversed_string(client, 0x000E) # 模块名逻辑说明先按大端把 7 个寄存器摊成 14 字节再整体反转最后按 ASCII 解码。不反转的话读出来是一串看着像乱码但又有规律可循的字符——我第一次读的时候以为是编码问题换了三种解码方式都不对最后才回头看见那句 MSB 说明。参数说明errorsignore用来吃掉尾部填充的空字符不同固件对未使用位的填充不一致有的是 0x00有的是 0x20 空格。4.2 32 位能量寄存器低字在前单位差十倍寄存器变量单位格式0x0046–0x0047feedin_power1WInt320x0048–0x0049feedin_energy0.01kWhUint320x004A–0x004Bconsum_energy0.01kWhUint320x0052–0x0053Etotal0.001kWhUint320x0058–0x0059Load1 Yield0.01kWhUint32文档明确标了低字在前0x0048 是 LSB0x0049 是 MSB。拼接方式是low (high 16)不是反过来。Etotal 的单位是 0.001kWh而 feedin_energy 和 Load1 Yield 是 0.01kWh两者差十倍混用会让发电量报表直接错一个数量级。def u32(low, high): return (high 16) low def s32(low, high): v u32(low, high) return v - 0x100000000 if v 0x80000000 else v rr client.read_input_registers(address0x0046, count14, slaveUNIT) r rr.registers print(馈入电网功率 W :, s32(r[0], r[1])) print(馈入电量 kWh :, u32(r[2], r[3]) * 0.01) print(用电量 kWh :, u32(r[4], r[5]) * 0.01) print(总发电量 kWh :, u32(r[12], r[13]) * 0.001) # 0x0052/0x0053逻辑说明0x0046 到 0x0053 中间夹着 0x0044、0x0045 两个 REV但从 0x0046 起连续 14 个寄存器是一整段有效数据所以能一次读完。32 位字段必须一次请求读回两个寄存器分两次读会在中间插入一次采样高字和低字来自不同时刻累计电量会出现跳变甚至倒退。参数说明0x0051 是夹在 Etoday 和 Etotal 之间的 REV取值无意义别拿它做校验。4.3 电池簇0x0014 到 0x0023 这一段单位最杂地址变量单位格式0x0014BatVoltage_Charge10.01VInt160x0015BatCurrent_Charge10.01AInt160x0016Batpower_Charge11WInt160x0017–0x0019板温/电池温/Boost 温1℃Int160x001CCapacity_Charge11%Uint160x001D–0x001EOutputEnergy_Charge10.1kWhUint320x001FBMS warnings—Uint160x0020–0x0021InputEnergy_Charge10.1kWhUint320x0022BatteryPackageNum—Uint160x0023BatterySOH%Uint16温度这一组有三个寄存器挨着单位都是 1℃但文档 V2.1 改过一次充电器温度的单位从 0.1 统一成 1。如果手上有旧版采集脚本这里就会出现“温度差了十倍”。另外 0x0055 的 Bat_THERMA 单位是 0.1℃和 0x0018 的电池温度不是同一个口径两个都放在报表里却不做标注读数会对不上。0x0023 BatterySOH 是 V2.50 才加进来的老固件这个地址是保留位读回来是 0。判断方式是先读 0x0083 管理器固件版本老版本直接跳过这个字段别把 0 当成“电池健康度归零”报警出去。4.4 故障字文档给了地址没给位定义0x0040 / 0x0041 是 InvFaultMessage 的 LSB 和 MSB拼起来是一个 32 位故障码0x0042 是充电器故障0x0043 是管理器故障0x001F 是 BMS 警告另外还有 CAN CommLost 这类离散标志。这里有个必须说清楚的边界这份文档只给了寄存器地址和“故障信息”这个描述没有逐位定义表。实践中常见的做法是把它当作整体故障码记录配合时间戳做“故障发生前最后一次正常快照”和“故障时快照”的对比先定位到是 PV 侧、电网侧还是电池侧要做精确到位的告警解读只能另外要一份位定义表。自己按猜测去映射位最后一定会在某台机器上把“过温”报成“漏电流”。rr client.read_input_registers(address0x0040, count4, slaveUNIT) inv_fault u32(rr.registers[0], rr.registers[1]) print(f逆变器故障码 0x{inv_fault:08X}) print(f充电器故障码 0x{rr.registers[2]:04X}) print(f管理器故障码 0x{rr.registers[3]:04X})逻辑说明故障码用十六进制打印而不是十进制方便和现场记录、售后工单上的写法对齐。0 表示无故障非 0 就把整轮快照电压、电流、温度、能量一起落库这是后面复盘时唯一能用的材料。5. 避坑与排查五条从现场带回来的记录这一章的五条都是我或者身边人真实撞过的按“现象→原因→解决”写每条都能对上文档里的具体某一行。5.1 全部读请求超时但 ping 得通现象客户端连上就超时网线换过、IP 换过ping 一直是通的。原因有三个层次。最常见的是从站地址不对这份文档的 MyAddress 在保持寄存器 0x0084如果现场装过多台机器做过地址偏移按默认 1 去请求会全部无响应。第二是端口被上位机网关占着同一台设备不接受第二个 TCP 连接。第三就是 2.1 节说的帧被拆包设备把半帧当完整帧丢掉表现同样是超时但会偶发一次成功容易误判成网络抖动。解决先用 0x03 读 0x0084 拿到真实地址再用这个地址去读 0x04 区确认没有其它客户端连着发送侧保证整帧一次性发出关闭业务层的分段逻辑。三条按顺序排能省掉大量无效排查。5.2 返回异常码 0x83 0x02现象读某一段地址时设备回 0x83异常码 0x02意思是非法数据地址。原因请求跨越了 REV 保留区或者越过了一段地址的末尾。典型翻车是从 0x0014 一口气读到 0x0024中间 0x0024 是 REV部分固件对保留地址直接拒绝整帧而不是返回 0。另一处高发区是 0x002C 到 0x007B 的 PowerManagerConfigData看着是一整块实际不是所有固件版本都完整实现。解决按功能段拆请求每段的起止地址在文档表里逐条核对过再发。别用“从最小地址读到最大地址再切片”的偷懒写法。注意异常码 0x02 出现一次之后部分固件会有一小段静默期紧接着的合法请求也可能被丢掉。重试之前先等够 1 秒间隔别立刻狂发。5.3 充放电时间表写进去了机器不执行现象0x0092 到 0x00A1 的时段寄存器读回来和写进去的一致到点电池不动。原因0x008B SolarChargerUseMode 不等于 1。时间表只在强制时段模式下生效自用模式和远控模式下这两段时间表就是死数据。还有一种情况是 0x00B4 Allow_Grid_Charge 为 0市电充电被整体禁止时段到了也不会从电网取电。解决写入序列固定为“先读 0x008B 确认当前模式 → 写 0x008B 1 → 写时段 → 读回校验 → 再读 0x00B4 确认允许电网充电”。顺序错了不会报错只是不生效。5.4 继电器参数写完互相覆盖现象按 0x00E1 到 0x00E7 配好继电器 1写完 TrigTimeSet 之后 CloseTimeSet 变了或者干脆两个都不对。原因这一组寄存器是双参数打包的。0x00E2 的低字节是 TrigTimeSet单位 min高字节是 CloseTimeSet单位 Hour0x00E3 低字节 TrigMode、高字节 Time_Control0x00E4 到 0x00E7 同样是低字节分钟、高字节小时。按 uint16 整体写等于把另一个参数一起改了。def pack_u8_pair(lo_val, hi_val): 把两个 8 位参数打进一个 16 位寄存器低字节 lo高字节 hi return ((hi_val 0xFF) 8) | (lo_val 0xFF) # 继电器 1触发时长 30 分钟、闭合时长 2 小时 client.write_register(address0x00E2, valuepack_u8_pair(30, 2), slaveUNIT)逻辑说明读的时候反向拆lo v 0xFF、hi (v 8) 0xFF。千万别图省事直接写十进制整数比如写个 30高字节就是 0等于把 CloseTimeSet 清成 0。5.5 累计电量跳变或倒退现象Etotal 隔几秒跳一次甚至比上一次读到的还小。原因两个寄存器分两次请求读高字和低字来自不同时刻中间低字进位了拼出来就是个错误值。另一个原因是单位看错把 0.001kWh 当 0.1kWh 算数值直接差一百倍看起来像跳变其实是换算错。解决32 位字段一律单次请求读两个连续寄存器换算系数在代码里写死并加注释标明来源行对 Etotal 做单调性校验新值小于旧值时丢弃本次采样而不是覆盖入库。6. 长期采集把一百多个寄存器砍成三档轮询跑通单次读取之后真正的难点是让它连续跑几个月不出事。1 秒指令间隔这条硬约束决定了不可能全量高频轮询我的做法是按变化速度分三档。档位周期内容寄存器段快3 s电网电压电流功率、PV 电压电流、频率、温度、运行模式0x04 区 0x0000–0x0013中15 s电池电压电流功率温度、SOC、EPS 数据、今日电量0x04 区 0x0014–0x0056慢60 s固件版本、序列号、安规类型、功率限制、累计电量0x03 区若干段 0x04 区 0x0046–0x0053快档一轮只有一条报文加上间隔大约 4 秒一轮完全够看趋势中档和慢档穿插在快档的间隔里发用一个简单的调度器控制节奏不要开多线程同时打设备——多线程会让两条指令的间隔小于 1 秒直接踩 2.1 节的坑。import time def poll_once(client, plan, unit1): 按计划表顺序轮询强制满足 1 秒指令间隔 out {} for name, fc, addr, cnt in plan: try: if fc 0x04: rr client.read_input_registers(addressaddr, countcnt, slaveunit) else: rr client.read_holding_registers(addressaddr, countcnt, slaveunit) if rr.isError(): out[name] None else: out[name] rr.registers except Exception as e: out[name] None client.close() client.connect() # 掉线重连别在这里递归重试 time.sleep(1.0) # 文档硬性要求两条指令最小间隔 1s return out # 快档计划一段地址读完实时区 FAST_PLAN [(realtime, 0x04, 0x0000, 20)]逻辑说明time.sleep(1.0)放在每次请求之后而不是之前保证无论成功失败间隔都成立。异常分支里只做一次关闭重连不做循环重试——设备掉线往往是并网切换或者电池唤醒引起的短暂不可用狂重连只会把恢复时间拉长。返回结构里失败段置 None 而不是抛异常让上层决定是补采还是记一次空洞。慢档里我最看重的是 0x007D 到 0x0083 的固件版本和 0x001D 的安规类型这两个值平时不变一旦变了就说明有人动过设备配置这时候把前后快照拉出来对比比事后猜要省事得多。至于 PowerManagerConfigData 那 80 个寄存器采集端一直跳过需要时单独抓一次留档就够。早几年我接手一个采集项目图省事把所有寄存器塞进一个大循环5 秒一轮跑了三天数据全是锯齿状。后来才发现指令间隔不够导致设备丢包客户端重连后读到的是缓存值。从那以后我每次接新设备第一件事不是写业务逻辑而是拿秒表把时序参数跑一遍确认 1 秒间隔和 100ms 帧窗口都守住了再往上叠功能。这份协议文档的价值也在这里它把设备的脾气一条条写在最前面读懂了能少走很多弯路。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询