简介面向光伏逆变器研发、嵌入式通信调试与电站运维人员的Modbus协议规范文档聚焦Solax SK-SU系列混合太阳能逆变器在Modbus TCP通信中的寄存器定义与版本演进。内容涵盖协议类型、502端口、帧格式与请求时序参数并给出保持寄存器地址、功能码、数据类型、单位和读写属性的对照说明同时梳理从V1.0到V2.50各版本对EPS功能、远控模式、意大利自测、语言选择、电池唤醒、BMS告警、CAN通讯丢失、电池SOH及充电器输入能量等项的增补与修正。资源包为1个PDF文件大小约1.58MB单文件便于检索和随设备调试时查阅可作为协议对接、寄存器核对与故障排查的参考材料。已有88人学习适合需要理解逆变器通信协议细节、核对历史版本差异或开展二次开发的技术人员使用。1. 一份寄存器表为什么要改二十次Modbus 协议本身只有二十几页纸真正容易出问题的是它背后的寄存器表。一份混合储能逆变器的 Modbus 协议规范从 V1.0 草稿排到 V2.50两年多、近二十次修订新增功能的版本只占少数剩下大量改动是把三个寄存器的 uint16 改成 int16、把充电器温度的单位从 0.1 改成 1、修正某个能量寄存器写错的地址。端口没变功能码没变帧格式没变变的只是同一串字节的解读方式——你手上跑得好好的采集程序可能在对方升级一次固件之后就全错而且错得毫无征兆。搞懂这张表的分层逻辑哪些地址是身份、哪些是阈值、哪些必须按有符号解析、哪些字段被塞进一个字节的高低两位比背下任何一个功能码都值钱。2. 寄存器地图为什么同一台设备要开两个地址空间这份规范在传输层上没什么花活Modbus TCP端口 502标准 MBAP 头加 PDU。真正需要读懂的是它把功能码分成了两条互不干扰的路——0x03 读保持寄存器0x04 读输入寄存器。前者放可配置、可写回、掉电后需要保留的参数后者放实时刷新的测量值和状态。同一个物理量在两条路上不会有第二份副本。分区的动机很实在。逆变器的采样任务和控制任务跑在不同周期上测量值是每个控制周期刷新的配置项只在人机界面或上位机下发时才会改。若把这些东西混在同一个地址空间里读取一个 200 寄存器的大块时配置区会在读取过程中被别的客户端改写读到的一致性和一致性之间就断了。分开之后读输入寄存器可以放心用大块连读读保持寄存器则天生带着“这是配置”的语义写之前自然会掂量一下。2.1 地址分段的三个意图保持寄存器的地址从 0x0000 往上排大致可以切出四段每段有各自的设计意图地址段内容设计意图0x0000–0x0014序列号、工厂名、模块名各占 7 个寄存器上电握手时一次连读就能识别对端0x0015–0x002B启机阈值、并网保护阈值、安规枚举这些值决定设备敢不敢并网0x0085–0x00A1RTC 年月日时分秒、充放电时段时间是本地时钟不是网络时间0x00E1–0x00F9三个继电器的完整配置块强电侧动作必须可独立校验最前面那 21 个寄存器0x0000–0x0014是最值得先读的。上位机连上之后先读这一段拿到序列号、工厂名、模块名就能判断对面是不是自己要找的那台设备、固件大概在哪个世代。放在最前面的好处是一次0x03请求起始地址 0x0000、数量 0x0015就能拿到全部身份信息不需要先探测再定位。协议本身允许一次读 125 个寄存器但这里更实用的做法是分块身份块读一次阈值块读一次运行数据按需要读。原因在后面的时序部分会讲到——从机的处理能力有限读得越宽越容易撞上响应超时。2.2 14 个字符为什么要拆成 7 个寄存器序列号、工厂名、模块名都是 14 个字符正好占 7 个寄存器。这不是巧合一个寄存器 16 位即 2 字节7 个寄存器 14 字节刚好装下 14 个 ASCII 字符。规范里写的“MSBSN[14]”说的就是字节顺序——每个寄存器的内容都是高字节在前、低字节在后从第一个寄存器的高字节开始一路排到第七个寄存器的低字节。常见的实现思路是这样解析def regs_to_ascii(regs): raw bytearray() for r in regs: raw.append((r 8) 0xFF) # 高字节在前符合 Modbus 大端 raw.append(r 0xFF) # 低字节在后 # 未用满的尾部可能是 0x00 或空格需要裁掉 return raw.decode(ascii, errorsreplace).rstrip(\x00 ).strip() # 假设已经读到 7 个寄存器的值 sn regs_to_ascii(regs[0x0000:0x0007])这里有一个真实存在的坑Modbus 传输层本身是大端寄存器内部也是高字节在前于是字符串天然是正的。但如果用某些把寄存器按小端处理的库部分 PLC 网关或者自研的从机适配层读出来的字符串会变成两两交换的乱码比如 “AB” 变成 “BA”。排查方法很土但有效——把读到的字节按十六进制打印出来看看是不是每两个字符都反了。反了就说明中间有一层做了字节交换改库的配置不要改解析代码。2.3 安规枚举一个寄存器装下十九种并网规范0x001D 这个地址很有意思它是只读的语义是“当前生效的并网规范”取值范围 0 到 18对应十九种不同的接入标准。协议没有为每种标准分配独立的参数区而是用一张枚举表把整套阈值打包——选 0设备按 A 标准的电压频率窗口保护选 5换成 B 标准的窗口。这样设计的好处是配置面收敛上位机不需要知道每种标准的细节只需要知道“这台设备在哪个电网环境里”。代价是灵活性被封死——用户没法在某个标准上做微调只能整体切换。协议里那些独立的电压、频率阈值寄存器0x0019–0x0023是另一条路允许细调但一部分固件会把它们和安规枚举绑定改了枚举就覆盖掉手工阈值。提示读到安规枚举之后不要假设邻近的阈值寄存器仍然保留着你上次写的值。切换枚举通常会重写整组保护参数。3. 定点数为什么这套协议里一个浮点数都没有整张表从头翻到尾数据类型只有 uint16、int16、uint32、int32 和几个 uint8 的拆字节没有任何 float32。这不是偷懒是有意为之。第一寄存器的天然宽度是 16 位浮点数要占两个寄存器就得处理字节序问题Modbus 规定了寄存器之间不规定顺序于是业界出现了 ABCD、CDAB、BADC、DCBA 四种流派。一旦引入 float跨厂商对接的调试成本会翻好几倍。定点数只需要约定一个缩放因子天然规避了这个问题。第二控制逻辑里的比较是精确的。过压保护阈值是 253.0V寄存器里存 2530比较就是整数比较如果存浮点2530 * 0.1在二进制里不一定等于 253.0阈值附近会出现抖动。这种抖动在实验室里看不出来到了现场就会表现为偶发的误保护。第三早期的控制芯片没有硬件浮点单元软浮点的开销在中断里是不可接受的。协议的版本历史横跨两年这个约束一直存在。3.1 缩放因子决定了你小数点后能看几位每个量都有自己的缩放因子规范在“Unit”一列里写得清清楚楚但很容易被跳过物理量单位缩放因子备注电网电压0.1 V×0.1uint16最大 6553.5 V电网电流0.1 A×0.1int16可为负电网频率0.01 Hz×0.0150 Hz 存 5000电池电压0.01 V×0.01int16电池电流0.01 A×0.01int16充电为正温度1 ℃ 或 0.1 ℃×1 / ×0.1见 3.2 的单位变更日发电量0.1 kWh×0.1uint16总发电量0.001 kWh×0.001uint32精度更高这里有一处值得注意的设计取舍日发电量用 0.1 kWh 精度、16 位上限 6553.5 kWh够一天用总发电量用 0.001 kWh 精度、32 位上限约 429 万 kWh也够一台户用设备跑十几年。总量精度比日量高两个数量级原因是累计值一旦被量化误差会一直累积下去每天丢 0.05 kWh十年就是 180 kWh。日量每天清零误差不会传递。3.2 int16 的引入负数到底从哪来协议的版本历史里明确写着“modify three format of register, uint16 to int16”。这个改动看着不起眼实际影响面很大。按规范下面这些量是有符号的电网电流0x0001输入寄存器电流方向随并网和离网切换而反转电网功率0x0002负值表示反送电温度0x0008户外设备零下是常态电池充放电电流0x0015充电为正、放电为负外部设定的充电器功率0x00D4正数充电、负数放电举个最容易出问题的例子冬天早晨电池温度是 -5℃寄存器里存的是 -500.1 ℃ 精度下或者 -51 ℃ 精度下按 uint16 解析就变成了 65486 或者 65531。程序里如果有个“温度超过 60℃ 就降额”的判断这一读就会触发保护现场表现为设备一到冬天就莫名其妙地限功率。符号扩展的写法有两种用与不用差别很大def to_int16(raw): 把 Modbus 读到的 0..65535 转成有符号 return raw - 0x10000 if raw 0x8000 else raw def to_int16_struct(raw): 用 struct 做同样的事适合批量解析 import struct return struct.unpack(h, struct.pack(H, raw 0xFFFF))[0] # 温度 -5.0 ℃1 ℃ 精度寄存器值 0xFFFB raw 0xFFFB print(to_int16(raw)) # -5 print(to_int16(raw) * 1.0) # -5.0 ℃0x8000是符号位只要最高位是 1减去 65536 就得到负值。这个判断不能省也不能用“大于 32767 就减”这种近似写法去糊弄——在某些语言里比如 JavaScript 的位运算结果会因为你用的是|还是而完全不同。3.3 32 位量低字在前高字在后能量和功率这类量用 32 位占两个连续寄存器。规范在每个条目上标了.LSB和.MSB比如总发电量在 0x0052 和 0x0053前者是低 16 位后者是高 16 位。注意这里的字节序是“混合”的寄存器之间是低字在前小端寄存器内部是高字节在前大端。拼装时必须两层都处理对def u32(lo, hi): 无符号 32 位lo 是低地址寄存器hi 是高地址 return ((hi 0xFFFF) 16) | (lo 0xFFFF) def s32(lo, hi): 有符号 32 位用于反送电功率这类可正可负的量 v u32(lo, hi) return v - 0x100000000 if v 0x80000000 else v # 总发电量低字 0x1234高字 0x0001单位 0.001 kWh etotal u32(0x1234, 0x0001) * 0.001 # ≈ 74.54 kWh # 反送电功率int32低字 0xFFF6高字 0xFFFF单位 1 W feedin s32(0xFFF6, 0xFFFF) # -10 W表示正在从电网取电0xFFF6 0xFFFF按无符号拼出来是 4294967286减掉 2^32 得到 -10。如果漏掉这一步功率会变成一个天文数字界面上显示成几百万瓦用户第一反应是设备坏了实际上是解析错了。4. 位打包一个寄存器里塞进两个开关16 位寄存器天然浪费。一个开关只需要 1 位一个模式只需要 2 位如果每个都占一个寄存器地址空间会以肉眼可见的速度枯竭。这份规范在几个关键位置做了字节级和位级的复用看懂这些复用方式是读懂它的门槛。4.1 Machine switch高字节软件低字节硬件0x00DA 这个只读寄存器的定义是MSB 是软件开关LSB 是硬件开关。也就是说一次读取拿到的是两个独立的状态分别表示“软件层面允许输出”和“物理开关处于闭合位置”。为什么要分开因为这两件事在安全语义上完全不同。硬件开关是装在机箱上的物理旋钮或急停任何人伸手就能关掉软件无法伪造它的状态软件开关是远程或界面上的使能位可能被远控指令改写。把两者塞进一个寄存器是为了让上位机一次读取就能判断“设备到底有没有真的在输出”——只有两个位同时为 1才说明软件放行且物理通路是通的。读取和判断的写法def parse_machine_switch(raw): soft (raw 8) 0x01 # 高字节最低位软件开关 hard raw 0x01 # 低字节最低位硬件开关 return {software: bool(soft), hardware: bool(hard), output_allowed: bool(soft and hard)} # raw 0x0101 表示软硬件都开 # raw 0x0100 表示软件开了但硬件旋钮在关的位置第二行那种情况是最容易误判的上位机看到软件使能了就以为设备在工作实际上物理开关被人关了输出为零。现场排查时先看这个寄存器的低字节能省掉很多来回。4.2 继电器块六个寄存器描述一个继电器继电器部分是一个典型的“字段被拆到高低字节”的结构。每个继电器占用 6 个寄存器其中两对寄存器被做成了高低字节复用地址继电器 1低字节高字节0x00E2TrigTimeSet触发持续分钟CloseTimeSet闭合保持小时0x00E3TrigMode0 禁用 / 1 循环 / 2 单次 / 3 单次已发生Time_Control0 禁用 / 1 强制定时0x00E4Start_Time1 分钟Start_Time1 小时0x00E5End_Time1 分钟End_Time1 小时第一行是最反直觉的触发持续时间和闭合保持时间一个是分钟、一个是小时量纲都不一样却被塞进同一个寄存器的两个字节。规范这么写多半是因为两个值都不大各自一个字节够用合并之后省下一个地址。写的时候必须两个字节一起写不能只写低字节否则高字节会被清零def pack_relay_timing(trig_min: int, close_hour: int) - int: trig_min: 0-255 分钟close_hour: 0-255 小时 assert 0 trig_min 0xFF and 0 close_hour 0xFF return ((close_hour 0xFF) 8) | (trig_min 0xFF) # 触发 30 分钟后闭合闭合保持 2 小时 value pack_relay_timing(30, 2) # 0x021E那个assert不是形式主义。范围超了不会报错只会静默截断——写 300 分钟进去实际存的是 44。这类错误在现场表现为“继电器动作时间不对”但日志里看不出任何异常因为它确实按你写下去的值执行了。4.3 Allow_Grid_Charge看起来像位掩码其实是枚举0x00B4 允许电网给电池充电取值 0/1/2/3定义是0 禁止1 允许时段一2 允许时段二3 两个时段都允许。乍一看这就是个两位的位掩码bit0 对应时段一bit1 对应时段二3 就是两个都开。确实传 3 的结果和位掩码一致。但传 1 的时候位掩码的直觉是“时段一开、时段二关”枚举的语义也是“只允许时段一”——两者恰好重合于是很多人就把它当位掩码用了。风险在于未来。如果固件哪天加了时段三枚举会变成 0-7位掩码语义下 1 仍然只表示时段一但如果有固件把 1 定义为“仅时段二”代码就静默错了。稳妥的做法是按枚举写用常量而不是位运算GRID_CHARGE_FORBIDDEN 0 GRID_CHARGE_PERIOD1 1 GRID_CHARGE_PERIOD2 2 GRID_CHARGE_BOTH 3 # 不要写 value | 2 这种位运算写成显式枚举 def set_grid_charge(client, addr, mode): client.write_register(addr0x00B4, valuemode)4.4 一次性动作寄存器写 1 之后还得读结果自测和电池唤醒是两个特殊寄存器。0x00C5 是自测步骤写 1 表示启动读回 8 表示成功中间 1 到 8 是过程状态0x00D7 是电池唤醒写 1 表示“现在就唤醒”。这类寄存器的语义不是“状态”而是“命令”。写进去之后从机会执行一个耗时动作寄存器本身会被改写为过程值。所以不能写完就不管必须轮询直到读到终态def run_self_test(client, timeout60): client.write_register(0x00C5, 1) # 启动 deadline time.time() timeout while time.time() deadline: step client.read_holding_registers(0x00C5, 1)[0] if step 8: return True # 成功 if step 0: return False # 被中止或未启动 time.sleep(1) # 遵守 1 秒指令间隔 raise TimeoutError(自测超时)这里的time.sleep(1)不是随手写的。协议在时间参数里规定了两条指令之间的最小间隔是 1 秒字符间隔两个包之间的静默时间要大于 100 毫秒响应超时 1 秒。自测过程中从机的主循环被占用轮询太密会直接被丢弃或者返回异常码。注意一次性动作寄存器不要用“写 1 再读回 1 就算成功”的方式判断。读回 1 只说明命令被接收不说明动作完成。5. 避坑排查四类最常见的翻车现场这一节记录的是实际对接里反复出现的四类问题每一条都按“现象→原因→解决”写可以直接当排查清单用。5.1 现象电流读出来是六万多现象电网电流、电池电流在某些工况下读成 65000 以上的数字同一时刻功率却是正常的负数。原因寄存器是有符号 int16代码按 uint16 解析。电流方向反转时寄存器变成补码形式比如 -100.1 A 精度下是 -100存成 0xFF9C按无符号读就是 65436。解决先确认地址对应的类型。这份规范里输入寄存器的 0x0001电网电流、0x0008温度、0x0014–0x0016电池充放电电压电流功率都是有符号的保持寄存器里 0x00D4、0x00D5 也是。确认之后统一走符号扩展函数不要在每处读点分别写一遍判断——分散的判断迟早会漏掉一处。5.2 现象温度比实际高了十倍现象电池温度显示 250℃手摸上去是温的或者显示 -50℃实际是 -5℃。原因单位变更。协议的修订记录里明确有一条“把充电器温度寄存器的单位从 0.1 改成 1”后来又加了一条“修正若干寄存器的单位描述”。如果你的解析代码是按老版本写的 ×0.1遇到新固件就会差十倍反过来按 ×1 解析老固件读数会小十倍。解决把固件版本作为解析参数的输入而不是硬编码。0x007D–0x0083 这一串是各模块的固件版本读出来之后按版本号选缩放表def temp_scale(fw_version: int) - float: # 版本号需要按实际编码解析这里用阈值示意 return 0.1 if fw_version 0x0210 else 1.0 def read_temperature(client, fw_version): raw client.read_input_registers(0x0008, 1)[0] return to_int16(raw) * temp_scale(fw_version)版本号本身的编码方式规范里没有给公式只有版本号寄存器的定义。实践中可靠的做法是拿一台已知固件的设备做基准读出寄存器值反推编码规则然后写死在配置里不要试图动态猜测。5.3 现象继电器配置写进去不生效现象往 0x00E4/0x00E5 写了起止时间读回来发现分钟和小时对调了或者小时变成了 0。原因高低字节顺序搞反。这两个寄存器是低字节存分钟、高字节存小时如果按“先写小时再写分钟”的习惯拼成一个 16 位数顺序正好相反。更隐蔽的一种是只写了一个字节另一个字节在读取-修改-写入的过程中被清零。解决写之前先读一次原值只改目标字节保留另一字节def set_relay_start(client, addr, hour, minute): old client.read_holding_registers(addr, 1)[0] new (old 0x00FF) | ((hour 0xFF) 8) # 保留低字节 client.write_register(addr, new) # 回读校验别省这一步 assert client.read_holding_registers(addr, 1)[0] new回读校验这一步很关键。Modbus 的写单个寄存器有正常响应但正常响应只表示从机收到了不表示它接受并持久化了。有些固件在参数越界时会静默丢弃响应仍然正常。5.4 现象轮询跑几分钟就开始随机超时现象刚连上时一切正常连续采集几分钟后开始零星超时再过一会儿整条连接卡死。原因时序约束被忽略。这份规范在时间参数里给了三个硬指标两条指令之间最少间隔 1 秒两个数据包之间的字符间隔要大于 100 毫秒响应超时 1 秒。很多采集程序按“能多快就多快”的节奏轮询从机的通信任务处理不过来缓冲区积压表现就是越跑越慢、最后超时。解决把轮询周期压到规范允许的范围并且做请求合并。一次读 125 个寄存器的开销和一次读 3 个差不多与其分开读十几个小段不如按用途合并成几大块# 不推荐十几个小请求每个之间还要等 1 秒 for addr, cnt in [(0x0000, 7), (0x0007, 7), (0x000E, 7)]: client.read_holding_registers(addr, cnt) time.sleep(1) # 推荐一次读连续的 21 个一次拿全身份信息 identity client.read_holding_registers(0x0000, 21)如果实时性要求高可以在协议之外再想别的办法比如只对关键量做高频读、对配置量做低频读但两点之间那个 1 秒的约束最好不要去挑战。这个数值不是随便定的是从机通信任务周期和看门狗共同决定的压得越狠现场越容易出玄学问题。6. 兼容策略的取舍改单位、改类型还是加新地址看完这二十来次修订最值得琢磨的不是哪个寄存器干什么而是修订者面对“需要新增一个量”或“发现一个量单位写错了”时在四种手段之间怎么选。手段兼容性代价适用场景原地改单位破坏新旧固件解析结果不同所有客户端都要改早期、用户基数小原地改类型uint16→int16破坏负数全部解析错同上原类型确实不够用原地改地址破坏读到的可能是别的量同上地址分配失误新增地址、旧地址保留兼容新老客户端都能跑地址空间消耗、双份维护功能新增这份规范的修订记录里前三种手段都用过温度单位从 0.1 改成 1、三个寄存器从 uint16 改成 int16、某个能量寄存器的地址被修正、某个版本还删除了充电器相关的寄存器。每一次都是破坏性变更靠版本号区分。第四种也用得很多比如后期新增的电池健康度、充电器输入能量、BMS 告警、CAN 通信丢失、远程唤醒空闲态都是占了新的地址没有动老的。站在工程角度看前三种手段便宜但危险第四种稳妥但会慢慢把地址空间吃完。真正的取舍点在于这台设备的客户端有多少是自己能控制的。如果采集端、云端、本地界面全是自家代码原地改单位是最省事的一个版本号全网同步就行一旦有第三方接入、有历史设备在网、有客户不肯升级原地改就是给自己埋雷后期排查成本远高于当初省下的那几个地址。我自己的做法是只要存在一个自己控制不了的客户端就把“原地改”排除掉。新增一个地址老地址继续按老语义维护哪怕它已经不准了——在文档里标个“已废弃”代码里给它一个独立的解析分支。这种做法在地址空间紧张时会很难受但比起半夜被叫起来查“为什么某客户的电量统计一夜之间涨了十倍”这点难受可以接受。从那以后我每次对接一份寄存器表第一件事都不是打开代码写解析而是先翻修订记录找出所有“改单位”“改类型”“改地址”的条目把它们标成红色然后在解析层给每个红色条目留一个按版本切换的开关。这个习惯救过我几次也让我少写了很多次道歉邮件。希望帮到你。本文还有配套的精品资源点击获取