ch04:把 700 台逆变器变成 8 条连接——Modbus 协议栈与设备接入

第 5 / 14 章
ch04:把 700 台逆变器变成 8 条连接——Modbus 协议栈与设备接入

接入联调的第一晚,凌晨一点十七分,值班群炸了:EMS 给光伏逆变器下发的限功率指令全部报 Modbus 异常,白天却一切正常。电气工程师老张在群里甩了一句:"华为的设备还能分白天黑夜挑主人?"

第二天我翻 OpenEMS 华为驱动源码,在写任务旁边看到一行注释:"The Write Task will fail if the Inverter sleeps during the night."——逆变器夜间会休眠,休眠期间写寄存器直接失败。这个白天永远测不出来的坑,德国人十年前就踩过,并且在驱动里留了保护。这一章就讲设备接入:我们怎么把 700 台光伏逆变器、24 台储能 PCS 接进 OpenEMS,以及"接入"这两个字背后,到底藏了多少工程。

一、事故现场:白天好的系统,晚上瘫痪

先把事故讲完整。联调那晚的现象有三个特征:只有写失败、读正常;只发生在逆变器上,PCS 侧一切正常;故障时间精确地卡在日落之后。前两个特征基本排除了网络和寄存器地址问题——如果地址错了,读也会错。第三个特征把方向指到了设备状态。

用调试工具手动读华为 SUN2000 的 32274 号寄存器区,电压、电流、功率数据都正常返回;往 40235 号寄存器(有功限值)写一个值,设备返回异常码。翻华为的协议文档才知道:组串式逆变器为了效率,夜间会进入休眠态,通信芯片部分功能被关闭,此时写控制字会返回异常——而我们的调度引擎当时还在做夜间策略推演,每隔几分钟就尝试下发一次限值指令,结果就是告警风暴。

有意思的是解决方案不在我们的代码里,而在 OpenEMS 的华为驱动里早就写好了。这段逻辑值得原样看一遍:

private void addPowerListener() {
    // Catch sleeping inverter and sets Write to null to avoid a Modbus Fault.
    this.getActivePowerLimitChannel().onSetNextWrite(value -> {
        var powerLimit = this.getActivePower().orElse(0) > 0 ? value : null;
        this.getHuaweiActivePowerLimitChannel().setNextWriteValue(powerLimit);
    });
}

翻译一下:任何要写限值通道的值,先检查逆变器当前有没有出力(有功功率是否大于 0)——夜间没出力,说明大概率在休眠,就不写,把写值置空丢弃。一行注释加三行代码,把"夜间写失败"消解在驱动层。我们那次事故的直接原因:联调环境用的是从上游原样拿的组件配置,readOnly 模式没开,而上游驱动只有在非只读时才挂这个监听器——保护其实已在,是我们没读懂配置语义。改完配置,当晚告警清零。

这件事给我的教训比技术本身值钱:改造开源项目,第一课是"把上游当老师,而不是当代码提供方"。它的每一行注释背后都是一次真实故障。

二、梳理:接入拓扑——为什么是 8 条连接而不是 700 条

修好告警之后,我们复盘的第一个问题是架构级的:150MW 光伏约 700 台组串式逆变器,EMS 怎么接?

最直觉的方案是每台逆变器一条 Modbus TCP 连接、一个 Unit-ID。算笔账就知道不可行:700 条 TCP 连接,按每秒轮询一次、每台十几个测点算,Edge 层每秒要发起七千多次寄存器读取;更致命的是组串式逆变器的通信接口设计初衷是 RS485 组网,TCP 网口带宽和并发能力都撑不起这种打法。

现场的实际拓扑是华为 SmartLogger 智能汇流采集器:每台 SmartLogger 通过 RS485 环网汇纳约 88 台逆变器,全场 8 台 SmartLogger 再以 Modbus TCP 从站身份接入场站环网交换机。对 EMS 来说,"光伏"这个设备的粒度不是逆变器,而是 SmartLogger——8 个 Modbus Unit-ID、8 条 TCP 连接,就完成了 700 台设备、150MW 的接入。

OpenEMS 上游恰好有现成的 SmartLogger 驱动(组件名 PV-Inverter.Huawei.SmartLogger),它的协议定义暴露了这种汇聚式架构的读法:

new FC3ReadRegistersTask(40525, Priority.HIGH,
    this.m(ElectricityMeter.ChannelId.ACTIVE_POWER,
        new SignedDoublewordElement(40525),
        ElementToChannelConverter.SCALE_FACTOR_MINUS_3)),

一段读任务:FC3 功能码,从 40525 号寄存器读有功功率,双字(两个寄存器拼 32 位有符号数),SCALE_FACTOR_MINUS_3 表示寄存器原始值要乘 0.001 才是通道值——厂商点表里"单位 0.001kW"这种定标约定,被抽象成了转换器。全场有功功率这一个数字,就读自这一处;至于它底下汇聚了多少台逆变器,EMS 不关心。

储能侧正好相反。24 台 2.5MW 的阳光储能 PCS 是直连的——每台 PCS 一条 Modbus TCP 连接、一个 Unit-ID,因为 PCS 是被控对象,控制指令必须精确到每一台。但阳光在 OpenEMS 上游没有现成驱动:华为有人贡献了模块,阳光没人写。这就是我们 fork 之后第一个真正的自研模块。

阳光 PCS 的协议是"两段式"的:遥测部分(电压、电流、功率、温度)兼容 SunSpec 标准模型,走 OpenEMS 的 SunSpec generic 通道就能读;控制部分(启停、限功率模式、充放电指令)和告警字是厂商私有寄存器,需要向厂商申请点表。所以我们的驱动是"SunSpec 读 + 私有写"的混合体——这也解释了为什么 generic 通道不够用,必须写专门模块:标准协议覆盖不到的地方,恰恰是 EMS 的核心价值所在。

三、底层原理:Element、Task、Bridge 三层抽象

改驱动之前得先读懂 OpenEMS Modbus 协议栈的分层。这套抽象设计得相当漂亮,值得拆开讲。

第一层:Element——字节语义。 UnsignedWordElement 是单字无符号数,SignedDoublewordElement 是双字有符号数,FloatDoublewordElement 是双字浮点,还有处理位标志的 BitsWordElement。每个 Element 声明自己占几个寄存器、怎么解释字节、字序如何。特别要提 DummyRegisterElement:厂商寄存器表里大量的保留区,用 Dummy 元素填充占位——后面你会看到它有个巧妙用途。

第二层:Task——一次总线事务。 FC3ReadRegistersTask(读保持寄存器)、FC4ReadInputRegistersTask、FC16WriteRegistersTask(写多寄存器)、FC6WriteRegisterTask(写单寄存器)等,每个 Task 持有一组连续地址的 Element,并带一个 Priority:HIGH 意味着每个执行周期都执行,LOW 则是"隔一阵子执行一次"的低频任务。一个 Task 对应一次 Modbus 报文往返,所以编排寄存器表的原则是:把连续地址的读合并成一个 Task,减少报文次数——Modbus 协议单次读上限 125 个寄存器,地址再连续也得分段。

第三层:Bridge 与 Worker——调度引擎。 一个 Modbus Bridge(TCP 或串口)服务多个组件,每个组件 activate 时把自己的协议(Task 集合)注册给 Bridge。Bridge 内部的 ModbusWorker 是全场的流量调度员,核心是一个状态机:

INITIAL_WAIT → WRITE → WAIT_BEFORE_READ → READ → FINISHED → (下一周期) INITIAL_WAIT

调度策略藏在源码的 Javadoc 里:"尽量在 EXECUTE_WRITE 事件后尽早执行所有写任务,在 BEFORE_PROCESS_IMAGE 事件前尽可能晚地执行读任务"。写优先,因为控制指令的时效性高于遥测新鲜度;读压后,因为读到的数据要赶在下一个控制周期计算前就位,读太早,周期计算时用的就是旧值。

WAIT_BEFORE_READ 这个状态最有意思:Worker 并不知道读任务要花多久,于是 WaitDelayHandler 用一个环形队列持续学习最近若干个周期里每个任务的实际执行耗时,再倒推出"最晚几点开始读,才能刚好赶在周期计算前读完"。实测它给每次等待留了 20 毫秒的安全余量。这是教科书级的自适应调度:不猜、不拍脑袋,拿历史耗时算。

容错设计同样克制:某个组件通信失败,进入 DefectiveComponents 名单,重试间隔从 2 秒起步线性递增、封顶 5 分钟——既不会让一台掉线设备的重试风暴拖垮总线,也不会把偶发抖动当成永久故障。恢复后自动移出名单。

最后是双向数据流:读方向上,Element 收到寄存器值,经 ElementToChannelConverter 转换后写入 Channel(遥测语义);写方向上,Channel 的写值经反向转换器回到 Element(控制语义)。SCALE_FACTOR_2、SCALE_FACTOR_MINUS_3、INVERT 这些转换器把厂商点表里五花八门的定标约定统一收敛在一处声明,驱动代码本体几乎看不到一个魔法数字。

四、正确姿势:我们怎么写阳光 PCS 驱动

有了分层认知,自研驱动就是"填空题"。我们的步骤,按 ch03 定下的模块模板走:

第一步,模块骨架。 建目录 io.openems.edge.sungrow.pcs,写 bnd.bnd,五分钟挂进构建——ch03 铺的路在这里兑现。实现 AbstractOpenemsModbusComponent 子类,实现接口栈:ManagedSymmetricEss(对称储能语义,充放功率通道)+ ElectricityMeter + ModbusComponent。

第二步,点表翻译。 把厂商 Excel 点表逐行翻译成协议定义。我们的翻译规范有三条:连续地址遥测合并成一个 FC3 读任务,用 Dummy 元素填保留区;每个寄存器声明转换器时必须注释点表原文的单位说明,可追溯;控制字用 FC16 双字写,写通道挂 onSetNextWrite 监听器做前置校验——华为夜眠保护那三行代码就是范本,我们给 PCS 加的是"充放电方向互锁校验",防止上游逻辑 bug 同时下发正负功率指令。

第三步,仿真先行。 现场只有一套 PCS 可供联调,排队成本高。我们用 OpenEMS 自带的 Modbus 从站模拟器(测试模块里就有 ModbusSlaveSimulator)按点表灌了一台虚拟 PCS:给它预设功率遥测值、让它响应写指令。驱动在仿真环境里跑通全部读写用例后,才第一次连真设备。事后统计,真机联调只花了一个下午——仿真把"协议理解错误"这类最贵的返工提前消掉了。

第四步,合规与文档。 ch03 的 NOTICE.md 登记新模块,CI 合规扫描自动检查许可证头。另外给驱动模块写了一份 README,核心内容是一张"点表-寄存器-通道"三列对照表——后来现场运维排障,这张表被打印出来贴在集控室,是整个模块被引用次数最多的"文档"。

一个容易忽略的细节:写任务即使在 readOnly 配置下也不该在协议定义里缺席判断——华为驱动的做法是 if (!config.readOnly()) 时才 addTask 写任务。我们第一版把写任务无条件注册了,结果某个只读场景下 Worker 每周期尝试写一台不该写的设备,靠 DefectiveComponents 的退避机制才没酿成事故。上游源码里的每一个 if 都有来历,抄要抄全。

五、数据说话:接入层的账

改造一个月后的盘点:

  • 连接规模:光伏 700 台逆变器 → 8 条 SmartLogger 连接;储能 24 台 PCS → 24 条直连;风机 64 台经厂家网关聚合为一份点表接入。全场 EMS 主动发起的 Modbus TCP 连接共 33 条;
  • 报文效率:全场高频遥测合并为约 120 个读任务,按 1 秒控制节拍轮转,单周期报文数稳定在 120 次以内——对比"每台设备独立轮询"的朴素方案(700+24+64 台设备、每秒近 800 次报文),压缩了 85%;
  • 调度精度:WaitDelayHandler 学习后的读任务延迟误差稳定在 20 毫秒余量内,遥测数据到齐后再进控制计算,AGC 秒级环不吃旧数据;
  • 容错表现:联调期间一次环网交换机断电,33 条连接同时故障,DefectiveComponents 退避机制让重试流量自动衰减;交换机恢复后 3 分钟内全部组件自愈,无人工干预;
  • 自研产出:阳光 PCS 驱动模块 1 个(约 1600 行含测试)、点表对照文档 1 份、仿真用例 27 个,真机联调 0.5 天。

这组数字里我最看重的是最后一行:自研 1600 行代码换来 0.5 天真机联调——工程化的价值不是炫技,是把现场时间买回来。现场联调窗口是要跟电网公司约的,错过一次等一周。

六、面试怎么答

"你做过大规模设备接入吗?怎么设计的?"答题框架:

  1. 先拓扑:设备接入的第一决策是连接粒度——直连还是汇聚。算三笔账:连接数、报文频率、总线带宽。700 台逆变器聚合成 8 条连接的例子说明"设备数量"和"连接数量"是两个量纲;
  2. 再分层:讲协议栈三层抽象——字节语义(Element)、总线事务(Task)、调度容错(Bridge/Worker),以及"写优先读压后"的调度哲学;
  3. 落细节:主动讲一个真实故障(夜间休眠写失败)和它的驱动层解法,比背十遍概念都有说服力;
  4. 加一句认知:设备接入的本质是"把厂商差异收敛在驱动层,让上层只面对语义通道"——驱动写得越厚,调度引擎写得越薄,系统的可维护性就越好。

如果面试官追问"自研驱动和用通用协议(如 SunSpec)怎么权衡",参考本章第二节:标准协议覆盖遥测,私有协议覆盖控制,混合是常态,关键是别让私有部分污染分层。

七、落地清单

  1. 接入拓扑先算账:连接数 = 聚合粒度决策的输出,不是设备数量的自然结果;能汇聚就汇聚,被控粒度才直连;
  2. 寄存器编排三原则:连续地址合并读任务、Dummy 元素填保留区、转换器声明必须可追溯(注释点表原文);
  3. 写任务必须挂前置校验监听器——设备休眠、方向互锁、越界保护,都在写通道上拦,别让错误指令上总线;
  4. 自研驱动先仿真后真机:ModbusSlaveSimulator 灌虚拟设备,把协议理解错误消灭在联调窗口之外;
  5. 下一章(ch05),我们从"对设备说话"转向"对电网说话":IEC 60870-5-104 规约上省调、AGC 指令的两级控制仲裁——那是"两个细则"考核的正面战场。