逆变器 Modbus 写控制与参数掉电保持实战:EMS 下发”充电限流 30A”、回读也是 30A,第二天早上柜子照旧 100A 在充——我把写帧、写序、生效链路和那片 EEPROM 的寿命从头对了一遍

2026年10月15日 0 By admin

上一篇写的是读:从线上字节到屏幕上那个工程值,中间隔着地址口径、字序、类型、倍率、有效性五层。

这篇写写。写比读多三件事,而这三件事恰好是现场翻车最集中的地方:

  1. 写是有副作用的。读错了顶多显示错;写错了设备真会按错的值动作。逆变器是功率设备,写错一个限值就是跳机、过流、甚至烧东西。
  2. 写有”落不落盘”的问题。寄存器的值活在 RAM 里,掉电就没了;要它活过重启,得写进 EEPROM / Flash,而那片存储器的写寿命是有上限的。
  3. 写有”原子性”的问题。一个 32 位量要占两个寄存器;参数集有几十个字节。写在中间掉电,会留下”半新半旧”的参数。

这三件事叠在一起,就是那句在现场反复出现的话:“我明明写进去了,它回读也是对的,怎么第二天又变回去了?”


一、先把这台设备和这条链路交代清楚

  • 设备:50kW 三相储能变流器(PCS),光伏 + 电池 + 电网,Modbus RTU 从站。
  • 链路:PCS 的 RS485 口 → 屏蔽双绞线 8 米 → 工控机(跑 EMS)。总线上一共挂了 3 个从站:PCS(地址 1)、BMS 侧网关(地址 2)、电表(地址 3)。
  • 串口:19200-8N1,无校验。主机侧是工控机上自写的采集/控制程序(Python + 自己拼 RTU 帧,没有用第三方 Modbus 库)。
  • 文档:厂家《储能变流器通信协议 V2.1》,里面有 4 页”可写参数点表”。
  • 现场权限:本地 HMI(柜门上那块触摸屏)、远程 EMS、还有厂家调试用的上位机——三套东西都能写同一批参数。

先摆三个现场症状,都是真实记录,一个字没改:

症状 表象 实际
A. 写了不生效 EMS 下发”充电限流 30A”,从站回正常响应,回读设定寄存器 = 30A,但柜子上电流表还在 100A 只写进了 RAM 里的设定值,没触发”参数生效”
B. 生效了但重启丢 现场确认限流确实降到 30A 了;第二天早班发现又回到 100A 没落 NVM;开机装载默认参数把它覆盖了
C. 用了 5 个月参数区全变默认 某天早上所有设定参数变成出厂值,报警一片 EEPROM 写寿命耗尽,读出来全是 0xFF(或 0x00)

三个症状,三个完全不同的层:A 在”生效链路”,B 在”持久化”,C 在”物理寿命”。而它们之所以能同时出现在一台设备上,是因为厂家把”收到写命令”当成了”参数已变更”,于是无论值有没有变,每收到一次就触发一次落盘——这个设计同时制造了 A 的错觉、B 的假象和 C 的根因。

拆之前先划边界:协议基础盘(功能码、数据模型、异常响应)在 Modbus 协议在工业物联网中的实现 里;物理层(差分、偏置、接地、终端电阻)在 RS485/Modbus 抗干扰实战 里;读方向的解码链在 逆变器 Modbus 点表与上位机对接实战 里。本篇是它的下游姊妹篇,讲写。


二、写方向的六层模型 W0~W5

读方向我提过”五层错位模型”。写方向把同一件事镜像过来,但要多一层——因为在写里,”值正确”只是中间状态,不是终点。

上位机意图 ──► [W0 地址] ──► [W1 打包] ──► [W2 帧/响应] ──► [W3 生效] ──► [W4 持久化] ──► 真正生效的参数
              写到哪个寄存器   值怎么拼        帧对不对        有没有跑进控制环   有没有落 NVM
              (只读还是可写)  (32位/位域)   (长度/CRC)    (要不要写"保存") (写了几次了)
层 关心什么 典型症状 归属
W0 地址 这个寄存器可写吗?是保留区吗?参考号换算了没? 从站回异常码 0x02 协议/文档
W1 打包 32 位分几帧?位域怎么改? 值跳变、把别的位清了、值被截断 协议/实现
W2 帧与响应 帧长、CRC、响应形状 本地解析失败、异常码 0x03 协议
W3 生效 值进了控制环吗? 回读对,行为不变 设备实现
W4 持久化 落 NVM 了吗?写了几次? 重启回旧值 / 用几个月变默认 设备实现 + 存储
W5 联锁与安全 这个值现在允许写吗?写错了会怎样? 跳机、过流、反复复位、失联后不收敛 应用层设计

关键认知:W0~W2 是协议层,协议会帮你把关(写错地址回 0x02,值超限回 0x03);W3~W5 是设备实现层和应用设计层,协议一个字都不管。 现场 90% 的”写了没用”都掉在 W3 到 W5 这三层里,而这三层在文档上往往只有一行字。

这就是本篇真正要拆的东西。


三、写功能码全景:别只会用 0x06

大部分人写参数只会用 0x06(写单个寄存器)。但 Modbus 的写功能码有一整族,各有各的场合:

功能码 名称 写什么 单帧上限 什么时候必须用它
0x05 Write Single Coil 单个线圈 1 位 开/关类命令,值只能是 0xFF00(ON) 或 0x0000(OFF)
0x06 Write Single Register 单个 16 位寄存器 1 个 16 位标量参数
0x0F Write Multiple Coils 多个线圈 1968 位 成批开关量(使能字、IO 配置)
0x10 Write Multiple Registers 多个 16 位寄存器 123 个 32 位量、参数组、批量下发
0x16 Mask Write Register 单寄存器按位改 1 个 只改几位、其余保持
0x17 Read/Write Multiple Registers 一次交互先写后读 读 125 写 121 需要”写完立刻回读”的强确认场景

⚠ 这里有个坑,我自己先踩了:0x17 才是”读/写多个寄存器”,0x11 是 Report Server ID(读服务器识别码),两者差了 6。这两个功能码写混,从站一定回 异常码 0x01(非法功能码)。我写这一篇配套的小工具时就把 0x17 错写成了 0x11,靠自检里补的一条”功能码必须等于 0x17″才逮住——这说明自检如果只跟自己对拍,错的常量是查不出来的,后面第十六节细说。

数量上限的来历也不是拍脑袋,是由 RTU 单帧 256 字节倒算出来的:

RTU ADU 上限 256 字节 = 从站地址(1) + PDU + CRC(2)
→ PDU 上限 253 字节

0x10 请求 PDU = 功能码(1) + 起始(2) + 数量(2) + 字节数(1) + 2×数量
             1+2+2+1+2q ≤ 253 → q ≤ 123
0x0F 请求 PDU = 1 + 2 + 2 + 1 + ceil(q/8)
             ceil(q/8) ≤ 247,规范另取整到 1968(0xB0×8 附近)
0x17 请求 PDU = 1+2+2+2+2+1+2q ≤ 253 → 写数量 q ≤ 121(读 125)

超过上限,从站应回异常码 0x03。这条在”参数组批量下发”时最容易撞到——一套参数 200 个寄存器,必须拆成两帧,而两帧之间就有个窗口(下一节)。

响应帧的两处反直觉

响应帧的形状和请求不一样,且不是所有写响应都回显数据:

  • 0x05 / 0x06 / 0x16:回显请求原样(0x05 是 8 字节,0x06 是 8 字节,0x16 是 10 字节)。
  • 0x0F / 0x10:响应是 addr|功能码|起始(2)|数量(2)|CRC(2),固定 8 字节,没有字节数字段。

第二条是现场解析翻车第一名:如果你按”请求里有字节数字段,响应里也有”去解 0x10 的响应,就会多读一个字节,然后 CRC 必然失败、帧长变成 9 字节。这时候现象是”写命令看起来发出去了,但我的程序报 CRC 错”,而错的是你的解析器,不是从站。

用本地工具校一下就很直观(下面第十二节会给出这个工具):

$ python modbus_write_calc.py parse --hex "01 10 13 88 00 02 C5 66"
从站 1 · 功能码 0x10 (写多个寄存器) · 共 8 字节
CRC: 计算值 0x66C5 / 帧内 0x66C5 → 通过
类型: 正常响应(写入确认,**无字节数字段**)
  起始 0x1388 数量 2

四、32 位量:一帧原子,还是两帧撕裂

这是 W1 层最要命的一条。

Modbus 的寄存器是 16 位。而逆变器里大量参数是 32 位:有功功率设定(有符号 W)、充放电电流限值(mA 级需要大范围)、累计电量、时间戳、运行时长。

假设”充电限流”是 32 位无符号、单位 10mA,要写 3000(= 30.00A),它的两个寄存器是:

高字 = 0x0000    低字 = 0x0BB8

错误做法:两个 0x06

第 1 帧: 01 06 13 88 00 00  →  先写高字 = 0
第 2 帧: 01 06 13 89 0B B8  →  再写低字 = 3000

在第 1 帧和第 2 帧之间(典型间隔 1~10ms,取决于轮询间隔),寄存器里是 {高字=0, 低字=旧值} 或 {高字=新值, 低字=旧值} 这种”半新半旧”的组合。如果控制环恰好在那一刻去读这个 32 位量,它读到的可能是:

  • 从 100.00A(低字 0x2710)切到 30.00A:中间一帧是 {0x0000, 0x2710} = 100.00A(旧的,无害);
  • 反过来从 30.00A 切到 100.00A:中间一帧是 {0x0000, 0x0BB8} = 30.00A(旧的,无害);
  • 但如果值跨过高字边界,比如从 65535(0x0000FFFF)切到 65536(0x00010000):中间帧是 {0x0001, 0xFFFF} = 131071,或者 {0x0000, 0x0000} = 0。

那个 131071 就是灾难。限流值取 131071,控制环要么直接跳机(超量程),要么按 1310.71A 去执行。

正确做法:一个 0x10 写两个连续寄存器

$ python modbus_write_calc.py frame --op w10 --addr 1 --reg 0x1388 --vals 300,0
请求: 01 10 13 88 00 02 04 01 2C 00 00 E3 6C (13 字节, 数量 2, 字节数 4)
响应: addr|10|起始(2)|数量(2)|CRC(2) = 8 字节,**没有字节数字段**

一帧里两个寄存器一起到,从站在整帧 CRC 通过后才开始落值,不存在”只落了一半”的中间态被控制环看到。

但”一帧原子”有一半是协议保证,一半不是

必须说清楚,别过度信任:

  • 协议保证的部分:从站必须完整接收并校验整帧后才开始写。所以”半帧”不会被写进去。
  • 协议不保证的部分:从站把这些寄存器同时交给控制环,是应用层语义。有些实现(尤其是把点表映射到内部变量的那种)是按寄存器逐个赋值的,两个赋值之间仍有一个极小的窗口;还有些实现要求你另外写一个”参数组生效”寄存器才真正应用。

所以问厂家的那句话是:”用 0x10 一次写两个连续寄存器,控制环看到的是新值还是可能看到中间态?”——这一句必须问,问不到就在点表里加”影子寄存器 + 生效命令”(见第十三节)。

撕裂概率的量化

如果实在只能用两个 0x06(比如厂家只支持单写),撕裂窗口和读窗口的比值就是概率:

p ≈ w / T
w = 两帧之间的时间间隔(含从站处理时间)
T = 控制环读取这个 32 位量的周期

举例:主机轮询 500ms、控制环 1ms 读一次该参数、两帧间隔 8ms:

p ≈ 8e-3 / 1e-3 → 不是 0.008,而是"控制环每 1ms 读一次,主机每 500ms 制造一个 8ms 的污染窗口"
污染占空比 = 8ms / 500ms = 1.6%
→ 每秒约 16 次,每天约 138 万次

凡是”读到的值和写下去的值偶尔对不上、且对不上时差值巨大”,先怀疑这条。修法就一句话:32 位量一律用 0x10 双字连写,不要拆成两个 0x06。


五、位域参数:读-改-写的竞态,与 0x16 掩码写

逆变器里另一大类”参数”是位域状态字 / 使能字:一个 16 位寄存器里塞了 16 个开关。比如”功能使能字”:

bit0 = 充电使能
bit1 = 放电使能
bit2 = 并网使能
bit3 = 无功使能
bit4 = 黑启动使能
bit5..15 = 保留(必须保持 0)

天真做法:读-改-写

上位机要”打开无功使能”(bit3=1),而 bit2 当前是 0:

1. 读寄存器 → 0x000C (bit2=1, bit3=1)   ← 等下,这里 bit2 是 1
2. 我在本地把 bit3 置 1 → 0x000C
3. 写回 0x000C

问题出在第 1 步和第 3 步之间。如果这期间别人(本地 HMI、保护逻辑、或者设备自己)把 bit2 清了,那么你写回的 0x000C 会把 bit2 又置回 1 —— 你把别人刚清掉的位又打开了。这就是经典的”读-改-写竞态”,两个 master 各改一位,结果互相覆盖。

现场症状非常典型:“我在 EMS 上改了 A 功能,结果 B 功能自己开了(或者自己关了)。”

正确做法:0x16 掩码写

Modbus 专门为这个场景留了功能码 0x16(Mask Write Register),规范公式是:

结果 = (当前值 AND 与掩码) OR (或掩码 AND (NOT 与掩码))

一句话理解:与掩码里为 0 的位会被清掉;或掩码里为 1 的位会被置上;两个掩码都没提到的位保持不动。 从站在同一条命令里完成”读-与-或-写”,中间不留窗口。

用工具算一下”把 bit2 清 0、bit3 置 1、其余保持”:

$ python modbus_write_calc.py mask --cur 0x000C --set 2:0,3:1
当前值 0x000C (12)
  bit0  AND=1 OR=0 → 保持
  ...
  bit2  AND=0 OR=0 → 清 0
  bit3  AND=0 OR=1 → 置 1
  ...
AND 掩码 = 0xFFF3   OR 掩码 = 0x0008
结果 = (0x000C & 0xFFF3) | (0x0008 & 0x000C) = 0x0008 (8)

对应的请求帧:

$ python modbus_write_calc.py frame --op mask --addr 1 --reg 0x0100 --and 0xFFF3 --or 0x0008
请求: 01 16 01 00 FF F3 00 08 <CRC>
响应: 回显:与请求帧逐字节相同(10 字节)

注意一个容易被吓到的细节:0x16 的响应是”请求原样回显”。如果你按”响应里有结果值”去解,会读到 {AND掩码, OR掩码} 并把它当结果——大错。结果值要靠另发一帧 0x03 读回来确认。

但 0x16 不是万能的

三个现实约束,写代码前必须确认:

  1. 很多从站不实现 0x16(回了异常码 0x01)。用之前先拿 0x16 对着一个无害寄存器试一发。
  2. 0x16 只能操作单个寄存器。跨寄存器的位域(比如 32 位标志字)它管不了。
  3. 0x17(读/写多个寄存器)在同一个事务里先写后读,是另一种解法:写完立刻在同一帧里读回,天然排除了”我读的时候别人也在改”的窗口。但同样不是每个从站都实现。

如果从站这两样都不支持,唯一安全的做法是在设计阶段就避开位域:把”一寄存器 16 个开关”改成”每功能一个独立的 0/1 寄存器”。这是固件设计决策,一旦点表发出去了就改不动了——所以位域这件事要在定义点表时就决定,不能等到对接时再救。


六、回 OK ≠ 生效:三档确认

这一节是症状 A 的正面回答。

从站回了正常响应,到底证明了什么?只证明了一件事:帧收到了、CRC 过了、地址合法、这个功能码它认。仅此而已。它没有证明值跑进了控制环。

现场排查必须把”确认”分成三档,逐档往上走:

档位 怎么确认 证明了什么 没证明什么
第一档:协议确认 从站回了非异常的响应帧 帧合法、被受理 值有没有进寄存器都不知道
第二档:寄存器确认 另发一帧 0x03 读回该寄存器,值等于写入值 值进了寄存器 进的是 RAM 设定值还是控制环?
第三档:行为确认 读另一个“实际生效值”寄存器(或看电流表/示波器/功率读数) 控制环真的改了 ——

关键认知:几乎所有逆变器都有”设定寄存器”和”实际生效值”两套地址。 写设定寄存器 → 回读设定寄存器 = 都对;但读”实际限流”寄存器可能还是旧值,因为它要等:

  • 一个 “参数生效/保存”命令寄存器被写(最常见);
  • 或者下一个控制周期(几十 ms 后自动生效);
  • 或者必须停机/重启才生效(最坑,见下一节)。

所以写完之后必须做第三档确认,而且要知道”等多久”。这段等待时间是设备的实现细节,文档里通常不写。

一个能把这个问题彻底消掉的固件设计:配置序号

在固件里加两个只读寄存器:

  • CFG_SEQ:每次参数集成功应用(进控制环)后 +1;
  • CFG_CRC:当前生效参数集的 CRC16。

上位机的正确流程就变成:

读 CFG_SEQ (旧值)  →  写参数  →  写"保存/生效"命令  →
轮询 CFG_SEQ 直到它变化(带上超时)  →  读 CFG_CRC 校验  →  才算成功

这个模式的好处是它把”写了没生效”变成了可判定的。没有它,你只能靠”看电流表”这种带人为延迟的土办法。

顺带说一句:同样的模式在 OTA 里就是”升级完成确认”——写完镜像要读一个”当前运行版本/启动计数”来确认新固件真的起来了。这两件事的结构一模一样,我在 差分包也要签名 里讲过它的安全版本(防回滚计数器)。


七、安全联锁:遥控遥调不能裸写

逆变器不是传感器,它是功率执行器。一个裸写的”放电电流限值 = 500A”或者在运行中写一个不合理的 PID 参数,后果是硬件级的。所以 W5 层不是”锦上添花”,是必需品。

7.1 范围联锁:拒绝,而不是静默 clamp

假设”充电限流”合法范围是 0~100A(寄存器单位 10mA,即 0~10000)。上位机写了 150A(15000)。

两种实现:

实现 从站行为 上位机体验 后果
静默 clamp 把 15000 夹到 10000 存下,回正常响应 以为写成功了,实际是 100A 危险:上位机以为在跑 150A,实际 100A,逻辑全乱
拒绝 回异常码 0x03,不改变任何状态 立刻知道写失败,可以报错 正确

结论:范围校验必须”拒绝并回异常码 0x03″,绝不静默 clamp。 静默 clamp 是”看起来更友好、实际更坏”的典型。

有个例外值得说:有些厂家对”写入被钳位”的做法是”存储钳位后的值,并在响应里回钳位后的值”(不用回显原值)。这样上位机通过”回读 != 写入”能察觉。这比静默 clamp 好,但比直接拒绝差——因为它把”判断”推给了上位机。要用这种做法,必须在文档里写清楚。

7.2 状态联锁:运行中禁止写的参数

有些参数在运行中改是危险的(改控制拓扑、改保护阈值、改从站地址)。固件必须判断当前状态:

if (参数属于"仅停机可改" && 当前状态 != STOPPED) → 回异常码 0x03

现场最常见的翻车是改从站地址:你写下去的那一瞬间,设备立刻改用新地址应答了,而你的主机还在旧地址轮询 —— 于是”设备掉线了”。正确顺序是:写新地址 → 切到新地址轮询 → 确认通了 → 再改下一台。这个动作必须在文档里写成步骤,不能只写”地址可写”。

7.3 多主联锁:三套上位机抢同一个参数

这是本篇这台设备最真实的问题:本地 HMI、远程 EMS、厂家调试上位机,三个 master 都能写。 Modbus 是单主协议,没有内置的仲裁机制,所以必须自己设计。

三种做法,从简到繁:

  1. 写权寄存器(推荐):一个”所有权”寄存器,写 0x00A5(魔术字)表示”我要改,别人请退让”;谁持有谁可写;超时(比如 30s 无心跳)自动释放。其他 master 写参数前先读它。
  2. 只允许单点写:在系统层面规定”运行期间只有 EMS 可写,HMI 只读”。简单粗暴但有效,缺点是现场调试时很别扭。
  3. 参数变更序号 + 一致性检查:不阻止并发写,但每个 master 写完后校验 CFG_SEQ/CFG_CRC 是不是自己期望的值,被抢了就重写。

无论选哪种,点表里都要有一列标注”允许多主写吗”,这是设计信息,不是实现细节。

7.4 复位型与使能型:边沿语义必须写在点表里

这条我觉得是整个点表设计里最容易被漏、后果最严重的一列。

可写点分三类:

类型 语义 固件义务 例子
电平型(level) 写什么值就保持什么 值持久有效 功率设定、限流值、SOC 下限
边沿型(edge) 写一次触发一次动作 执行后必须自清(自动回 0) 故障复位、并网合闸、手动均充
自清型(autoclear) 同边沿型,但要求”写 1 才动作,写 0 无效” 执行后回 0 清除电量、清除告警

最典型的事故:把”故障复位”做成了普通保持寄存器。

  • 上位机写 1 到复位寄存器 → 设备复位 → 但寄存器里还是 1 → 设备刚起来又读到 1 → 又复位 → 无限重启。
  • 现场表现:”一上电就反复重启,把上位机断开就好了。” 这个现象极其好认,但每年都能见到。

反过来,“使能位”不能自清——如果固件把”充电使能”做成写完自清,那设备就永远只是”闪一下”就关了。

所以:每个可写点在点表里必须有一列 kind: level | edge | autoclear,且”edge”类从站响应里必须回自清后的状态或明确说明。我在下面第十三节给出了带这一列的点表模板。


八、落 NVM 的时机:写 RAM ≠ 写 EEPROM

现在进入症状 B 和 C 的层:持久化。

设备侧参数一般分三片区域:

区域 介质 谁在写 掉电后
运行参数(RAM) 片内 RAM / 影子寄存器 通信写、控制环读 丢
掉电保持区(NV) EEPROM / 数据 Flash / FRAM 只在”保存”或”变更”时写 保持
出厂标定区 OTP / 独立写保护扇区 产线一次写入 保持,且不该能被通信改

写命令进来后,存到哪里、什么时候存,三种策略:

策略 行为 优点 缺点
A. 每次都落 NV 每收一条写命令就写 EEPROM 掉电不丢,实现简单 写寿命被通信频率直接消耗,正是症状 C 的根因
B. 只写 RAM + 显式”保存”命令 写命令只改 RAM;另有一条”保存”命令才落 NV 寿命可控、可批量 上位机必须多写一步;忘了写就丢;掉电在”保存”前丢参数
C. 写 RAM + 延时/合并落盘(推荐) 写命令标脏;2 秒静默后或”参数集 CRC 变化”才落一次 寿命可控、上位机无感 需要”脏标记 + 定时器 + 掉电兜底”

本篇这台设备用的是 A,而且更糟——它连”值有没有变”都不判断,收到就写。 这就是为什么 EMS 每 2 秒轮询一次下发限值,一个月就把 EEPROM 写穿了。

症状 B 的另一个来源:开机默认值覆盖

还有一个和持久化无关但症状一样的坑:有些固件在开机时”无条件装载默认参数”,然后才去读 NV——顺序反了。或者读 NV 失败(校验不过)时静默装载默认值、不报警。这两种都会表现成”重启回旧值/回默认”,但根因完全不在存储,而在启动流程的顺序和失败处理。

判断方法很简单:看它回的是”上一次写的值”还是”出厂默认值”。 回上一次 = 没落盘;回出厂默认 = 启动覆盖或 NV 读失败。


九、EEPROM 寿命这笔账,必须算

这一节给症状 C 一个定量答案。

存储介质的擦写寿命差好几个数量级:

介质 单区擦写寿命 口径
STM32F4 主 Flash(程序区兼做参数) 10,000 次 按扇区擦除计
STM32L4/L0 数据 Flash(EEPROM 模拟) 100,000 次 按页计
独立 EEPROM(24Cxx / 内置) 1,000,000 次 按字节计
FRAM(Fujitsu MB85RC 系列) 10^12 次 按字节计,几乎写不坏
FRAM(Infineon Excelon 等) 10^14 次 厂家宣传上限口径

⚠ 这里要诚实一点:“FRAM 10^14” 是乐观宣传口径,Fujitsu MB85RC 的官方数据手册写的是 10^12 读写次数。做寿命预算时按 10^12 算,别按 10^14 算——反正结论一样(够用几千年),但口径要对得起数据手册。这也是我把工具里那格标成”几乎无限”的原因。

寿命公式

写穿时间 = 单区寿命次数 × 物理区数 × 区内轮转槽数 / (86400 / 写周期秒数)   [单位:天]

关键点是分子上的两个乘数:物理区数(A/B 双区)和区内轮转槽数(把一个大参数区切成 N 份轮流写),是摊薄磨损的两个手段。

直接看表

$ python modbus_write_calc.py life --table
间隔      STM32F4 主 Flash      STM32L4/L0 数据 Flash   独立 EEPROM (24Cxx/内置)   FRAM (MB85RC 等)
1s      2.8 小时             1.2 天                 11.6 天                 3,168,809 年
2s      5.6 小时             2.3 天                 23.1 天                 6,337,618 年
5s      13.9 小时            5.8 天                 57.9 天                 15,844,044 年
10s     1.2 天              11.6 天                115.7 天                31,688,088 年
60s     6.9 天              69.4 天                1.9 年                  190,128,527 年
600s    69.4 天             1.9 年                 19.0 年                 1,901,285,269 年
3600s   1.1 年              11.4 年                114.1 年                11,407,711,613 年

目标 10 年寿命时,各器件的『最长安全写周期』(单区、无轮转):
  STM32F4 主 Flash                8.77 h
  STM32L4/L0 数据 Flash            3156 s
  独立 EEPROM (24Cxx/内置)            316 s
  FRAM (MB85RC 等)                   0 s

把这张表读成一句话:用”每 2 秒写一次”的频率去写 EEPROM,只能活 23 天。 这是本篇这台设备 5 个月写穿的直接解释——它 3 个参数被 EMS 每 2 秒写一次,还每写一次就落一次盘。

本设备的实际账

$ python modbus_write_calc.py life --interval 2 --cycles 1000000 --banks 1 --slots 1
单区寿命 1,000,000 次 × 物理区 1 × 区内轮转槽 1 = 可用 1,000,000 次写入
写周期 2.0s → 每天 43,200 次
→ 写穿时间 ≈ 23.1 天
→ 想撑 10 年,最短写周期不能小于 5.3 分钟(是当前 2.0s 的 157.8 倍)

把三个改进措施各自的价值算出来:

$ python modbus_write_calc.py budget --years 10 --cycles 1000000 --banks 2 --slots 4
目标寿命 10.0 年,可用写入 8,000,000 次
→ 每天最多写 2,190.3 次,即最短写周期 39.4 s

也就是说:A/B 双区(×2)+ 区内 4 个轮转槽(×4)把可用写次数放大 8 倍之后,”最短安全写周期”从 5.3 分钟降到 39.4 秒——这一步仍然不够(EMS 是每 2 秒一写)。还要叠加”值没变就不写”和”合并写”:

  • 值变化才写:EMS 下发的是限值,一天真正变化可能只有几次 → 写频率从 43200 次/天降到个位数;
  • 去抖 + 合并:2 秒的静默窗口把连续下发合并成一次落盘。

两者叠加后,写频率从”每 2 秒”降到”每天几次”,寿命问题直接消失。这就是为什么”值没变就不算变更”这一条纪律,同时解决了症状 A(假象)、B(假象)和 C(真因)。

介质选型决策表

参数被写的最快周期 建议
快于 60s(如 EMS 周期下发) 改设计:值变化才写 + 合并写;或换 FRAM
1~60s FRAM 直接解决;EEPROM + 双区 + 多槽 + 变化才写
1min~1h EEPROM + A/B 双区 + CRC
慢于 1h(纯人工设定) 任何介质都行,但仍要做原子提交(防”写一半掉电”)

十、原子提交:写一半掉电怎么办

这一节解决”参数集写到一半掉电”。

EEPROM 单区写入不是原子的。假设参数集 8 字节,你分 4 次写,写到第 3 次掉电 —— 结果是前 3 个字是新值、最后 1 个字是旧值,而且整体的 CRC 很可能还是”旧 CRC”或干脆没有 CRC。这种”半新半旧”的参数集比”全旧”危险得多:它可能让几组互相矛盾的参数同时生效(比如”电池类型 = 磷酸铁锂”配”浮充电压 = 铅酸档”),保护逻辑完全错乱。

更糟的一种实现是“先擦后写”:

1. 擦除参数区(全变 0xFF)
2. 写入新参数
3. 计算并写入 CRC

如果掉电发生在第 1 步和第 2 步之间,唯一的一份参数已经被擦掉了,设备只剩默认值可载入。

正确做法:A/B 双区 + 序号 + CRC

结构:

区 A: [magic(3B)][seq(4B)][len(2B)][payload...][CRC16(2B)]
区 B: [magic(3B)][seq(4B)][len(2B)][payload...][CRC16(2B)]

写入流程(永远写”旧的那一区”):

1. 读 A、B 两区,各自校验(magic + CRC)
2. 选出"校验通过且 seq 最大"的那一区 = 当前有效区 cur
3. 目标区 dst = 另一区(即旧区)
4. 擦 dst → 写 {magic, seq=cur.seq+1, len, payload} → 写 CRC
5. 回读 dst 校验;不过则重试(此时 cur 仍完好,可重试)
6. 校验通过 → dst 成为新的有效区,返回成功

读取流程:

1. 读 A、B,各自校验
2. 取"校验通过且 seq 最大"的
3. 若两区都校验不过 → 载入出厂默认 + **上报"参数丢失"告警**(不许静默)

用工具模拟一次”第 5 次写的正中间掉电”:

$ python modbus_write_calc.py dual --writes 5 --cut-at 5
第 1 次写 → 区 A (seq=1) 完成
第 2 次写 → 区 B (seq=2) 完成
第 3 次写 → 区 A (seq=3) 完成
第 4 次写 → 区 B (seq=4) 完成
第 5 次写 → 区 A (seq=5)  ← 写到第 9/19 字节时掉电,本区残缺
恢复扫描 区A: **CRC 失败(残缺)**
恢复扫描 区B: 校验通过 seq=4
→ 采纳 区B seq=4,参数完好(掉电点那一版被丢弃,前一版仍有效)

结论:”要么全新、要么全旧”,永远不会”半新半旧”。 这就是双区的全部价值。

三个实现细节,每个都有坑

① 序号的比较必须用”模 2^n 的有符号差”,不能用”谁大取谁”

seq 是 32 位无符号,会回绕(0xFFFFFFFF → 0)。到那一刻,”谁大取谁”会选到 0 那一区——参数集体回退到很旧的一版。

正确写法:

int32_t diff = (int32_t)(new_seq - old_seq);
if (diff > 0) → new_seq 更新

这样”回绕前后相差 1″仍然被判定为”新”。

② 参数结构必须显式打包,不能直接 dump 结构体

三个原因:编译器会插入 padding;字节序依赖平台(小端 MCU 和大端上位机不一致);成员类型一变布局就全变。正确做法是用固定的打包函数:

/* 全部按大端、显式字段顺序 */
void cfg_pack(const cfg_t *c, uint8_t *buf) {
    put_u16_be(buf + 0,  c->limit_charge_ma / 10);   /* 单位统一在打包时做 */
    put_u16_be(buf + 2,  c->limit_discharge_ma / 10);
    put_u32_be(buf + 4,  c->soc_min_pct);
    /* ... */
}

③ CRC 要覆盖”头部 + 载荷”,且长度字段本身也要参与校验

如果 CRC 只覆盖 payload,那么”magic 对了、len 被写坏”的情况会被判为有效——然后你按错误的 len 去解析,越界。magic + seq + len + payload 一起进 CRC,一个都不能漏。


十一、掉电检测:写到一半要能”及时收手”

双区解决的是”掉电后还能恢复”。但如果能在”掉电”和”还没开始写”之间就拦住,就更稳。

掉电不是瞬间的

电源跌落有个时间常数:输入大电容 + 后级 LDO 的压差,构成一个”还能撑多久”的窗口。MCU 侧的欠压检测(PVD / BOR)在电压跌破阈值时产生中断/复位,而这个阈值到 MCU 真正不可用之间,还有几十毫秒的余量。

这段余量就是“把这次写做完”的预算。

算一笔储能账

假设:

  • EEPROM 一次页写最长 5ms(AT24Cxx 页写周期上限);
  • 一区 64 字节 = 8 页,加回读校验和重试,预算 20ms;
  • 掉电检测到 MCU 挂掉之间的可用窗口 30ms。

需要什么电容?从 12V 跌到 LDO 最低输入 7V(ΔV = 5V),满载电流 30mA:

t = C × ΔV / I  →  C = t × I / ΔV = 20ms × 30mA / 5V = 120µF
取 220µF(2 倍余量)→ 可撑 36.7ms

给 3 倍余量,220µF 够用。 这是”用两个数换一个确定性”的典型:知道写一次要 20ms,知道电容能给 36ms,就知道掉电时能不能收手。

(这段账和 多路电源上电时序控制实战 里讲的上电时序是同一类”时序预算”问题,一个是上电,一个是掉电。电源轨本身的裕量在 LDO 与 DCDC 电源设计实战 里算过。)

固件侧的”迟到者策略”

即时有储能,固件也必须配合:

写流程的每一页写完后检查一次电源标志;
若已检测到掉电(PVD 标志置位)→
   ① 不要再开始新的页写;
   ② 当前这一区标为"未完成"(其实不标也行,因为 CRC 会失败);
   ③ 立刻返回,把控制权交给复位流程;
   ④ 复位后走"双区恢复"路径。

“不要在掉电后还开始新的写”这一条,是防止把最后一版好参数也写坏的关键。


十二、让上位机知道”它真的落了”:配置序号

前面散着提过两次,这里收成一条完整机制。

加两个只读寄存器:

寄存器 名称 语义
CFG_SEQ 配置序号 每次参数集成功落 NV 后 +1(32 位,占 2 寄存器)
CFG_CRC 配置校验 当前 NV 里那套参数的 CRC16
CFG_SRC 最后写入来源 最近一次写参数来自哪个 master(诊断用)
CFG_CNT 落盘次数 NV 累计写入次数(用于寿命预警)

上位机的标准流程:

1. 读 CFG_SEQ → seq0
2. 用 0x10 写参数组(或 0x16 改位域)
3. 写"保存"命令(若设备需要)
4. 轮询 CFG_SEQ,直到 != seq0(超时 3s)
5. 读回参数 + CFG_CRC 校验 → 成功
6. 失败 → 报错,不重试超过 N 次

CFG_CNT 是这一组里最容易被忽视、但现场最有用的一个。 它把”EEPROM 什么时候会写穿”变成了一个可以远程监控的数字:

  • 装机时记下 CFG_CNT 初值;
  • 现场每隔一段时间读一次,算日均增长;
  • 日均增长 × 365 × 10 年 > 单区寿命 → 提前告警,而不是等它某天早上炸。

这是把”症状 C”从”事后救火”变成”事前预警”的唯一办法。没有这个寄存器,你只能等它坏。


十三、固件范式:声明式点表 + 写处理流程

把前面所有结论落成可抄的东西。

13.1 可写点表:在上一篇的基础上加五列

上一篇(读方向)给过一个声明式点表。这里把写方向必需的列补上:

{
  "format": "modbus-point-table/v2",
  "device": "PCS-50K",
  "version": "2.1.0",
  "byte_order": "big",
  "points": [
    {
      "name": "limit_charge_a",
      "addr": 5000,
      "type": "u32",
      "regs": 2,
      "scale": 0.01,
      "unit": "A",
      "rw": "rw",
      "kind": "level",
      "range": [0, 100],
      "write": { "fc": "0x10", "group": "limit_grp" },
      "persist": "nvm",
      "apply": "on_save",
      "atomic_group": "limit_grp"
    },
    {
      "name": "fault_reset",
      "addr": 5120,
      "type": "u16",
      "regs": 1,
      "scale": 1,
      "unit": "-",
      "rw": "w",
      "kind": "edge",
      "range": [1, 1],
      "autoclear": true,
      "persist": "none",
      "apply": "immediate",
      "doc": "写 1 触发一次故障复位,从站执行后自动清 0"
    },
    {
      "name": "grid_enable",
      "addr": 5121,
      "type": "u16",
      "regs": 1,
      "scale": 1,
      "unit": "-",
      "rw": "rw",
      "kind": "level",
      "range": [0, 1],
      "persist": "nvm",
      "state_guard": "stopped_only",
      "apply": "immediate"
    },
    {
      "name": "enet_flag",
      "addr": 5130,
      "type": "u16",
      "regs": 1,
      "rw": "w",
      "kind": "level",
      "bitfield": {
        "0": "charge_en",
        "1": "discharge_en",
        "2": "grid_en",
        "3": "reactive_en",
        "write_mode": "mask_fc16"
      },
      "persist": "nvm",
      "apply": "immediate"
    },
    {
      "name": "save_cfg",
      "addr": 5150,
      "type": "u16",
      "regs": 1,
      "rw": "w",
      "kind": "edge",
      "range": [1, 1],
      "autoclear": true,
      "persist": "none",
      "apply": "trigger_nv_commit"
    },
    { "name": "cfg_seq", "addr": 5160, "type": "u32", "regs": 2, "rw": "r" },
    { "name": "cfg_crc", "addr": 5162, "type": "u16", "regs": 1, "rw": "r" },
    { "name": "cfg_cnt", "addr": 5163, "type": "u32", "regs": 2, "rw": "r" }
  ]
}

新增的五列,每一列都对应前面某一节的结论:

列 含义 对应章节
kind 电平 / 边沿 / 自清 7.4
range 合法范围,超范围拒绝 7.1
state_guard 状态联锁(仅停机可写等) 7.2
persist nvm / ram / none 第八节
apply 立即 / 保存后 / 重启后生效 第六节
atomic_group 同一组内必须原子生效 第四节
autoclear 边沿类必须自清 7.4
bitfield.write_mode 位域用 mask_fc16 还是独立寄存器 第五节

13.2 写处理流程(伪代码)

handle_write(fc, addr, values):
    p = lookup_write_point(addr, len(values))
    if p is None:
        return EXC(0x02)                     # 地址不存在 / 只读 / 长度不对

    if p.kind == "edge" and values != p.edge_value:
        return EXC(0x03)                     # 边沿类只接受特定值

    for v in values:
        if v < p.range_min or v > p.range_max:
            return EXC(0x03)                 # 范围校验:拒绝,不 clamp

    if p.state_guard and not state_ok(p.state_guard):
        return EXC(0x03)                     # 状态联锁

    if p.bitfield and fc != 0x16 and multi_master_enabled():
        # 位域且非掩码写 → 存在读改写竞态,警告或拒绝
        log_warn("bitfield write without mask fc")

    # 写影子 → 整组一起提交,保证 atomic_group 内一致
    shadow_apply(p.atomic_group, addr, values)

    if p.apply == "immediate":
        commit_to_control_loop(p)            # 只到 RAM/控制环
        bump_cfg_seq_applied()               # 但 CFG_SEQ 只在落 NV 后 +1

    if p.persist == "nvm" and p.apply == "on_save":
        mark_dirty(CFG_GROUP)
        start_save_timer(2000)               # 2s 静默窗口后合并落盘

    if p.kind in ("edge", "autoclear"):
        schedule_autoclear(addr)             # 执行后自动清 0

    if p.apply == "trigger_nv_commit":
        nv_commit(CFG_GROUP)                 # 走双区原子提交,成功后 CFG_SEQ+1

    return RESP(fc, addr, len(values))

三条值得单独圈出来的:

  1. commit_to_control_loop 和 nv_commit 是两个不同的动作。前者改行为,后者保命。混淆这两件事,就是症状 A 和 B 的根源。
  2. CFG_SEQ 只在 NV 提交成功后 +1。如果写成”收到写命令就 +1″,那 CFG_SEQ 就退回成”第一档确认”,失去意义。
  3. start_save_timer(2000) 是”合并写”的实现:2 秒内的多次写只落一次盘。这个 2000 是寿命账里那个”变化才写”的落地方式。

13.3 原子提交(双区)的 C 骨架

/* ---- 参数区布局 ---- */
#define CFG_MAGIC0   'M'
#define CFG_MAGIC1   'B'
#define CFG_MAGIC2   0x01
#define CFG_BANK_LEN 128                       /* 一区总字节 */

typedef struct { uint32_t seq; uint16_t len; uint8_t payload[CFG_PAYLOAD_MAX]; } cfg_blob_t;

/* 读:两区都校验,取"校验通过且 seq 最大" */
int cfg_load(cfg_blob_t *out) {
    cfg_blob_t a, b;
    int oka = cfg_read_bank(BANK_A, &a);
    int okb = cfg_read_bank(BANK_B, &b);
    if (!oka && !okb) { cfg_counts.lost++; return -1; }      /* 上报,不静默 */
    if (oka && okb) {
        int32_t d = (int32_t)(a.seq - b.seq);                /* 模 2^32 有符号差 */
        *out = (d > 0) ? a : b;
    } else {
        *out = oka ? a : b;
    }
    return 0;
}

/* 写:永远写"旧的那一区",成功后再让它成为有效 */
int cfg_save(const cfg_blob_t *in) {
    cfg_blob_t a, b;
    int oka = cfg_read_bank(BANK_A, &a);
    int okb = cfg_read_bank(BANK_B, &b);
    int dst = (!oka) ? BANK_A : (!okb) ? BANK_B : (a.seq > b.seq ? BANK_B : BANK_A);
    uint32_t new_seq = (oka ? a.seq : 0) > (okb ? b.seq : 0)
                     ? (oka ? a.seq : 0) : (okb ? b.seq : 0);
    cfg_blob_t w = *in;
    w.seq = new_seq + 1;
    if (cfg_write_bank(dst, &w) != 0) return -1;             /* 原区仍完好,可重试 */
    if (cfg_read_bank(dst, &b) != 0 || b.seq != w.seq) return -1;
    cfg_counts.writes++;                                     /* 供 CFG_CNT 读出 */
    return 0;
}

/* 一区的封包 / 校验:magic+seq+len+payload 全部进 CRC */
static int cfg_write_bank(int bank, const cfg_blob_t *b) {
    uint8_t buf[CFG_BANK_LEN];
    size_t n = 0;
    buf[n++] = CFG_MAGIC0; buf[n++] = CFG_MAGIC1; buf[n++] = CFG_MAGIC2;
    put_u32_be(buf + n, b->seq); n += 4;
    put_u16_be(buf + n, b->len); n += 2;
    memcpy(buf + n, b->payload, b->len); n += b->len;
    uint16_t crc = crc16(buf, n);
    put_u16_be(buf + n, crc); n += 2;
    return eeprom_write_page(bank * CFG_BANK_LEN, buf, n);
}

注意 int32_t d = (int32_t)(a.seq - b.seq); 这一行——它是”序号回绕”那个坑的唯一正解,写错这一个类型转换,几年后设备会因为一次回绕把参数集体退回最旧版本,而且极难复现。


十四、现场五招

按”从最便宜、最快、信息量最大”排序:

① 抓帧,看”上位机到底发了什么”

八成”写了没生效”的案子,抓到帧就结束了——上位机压根没发那条写命令,或者发到了错的地址(被从站回 0x02 静默丢掉了,而程序没检查异常码)。用逻辑分析仪或串口嗅探器抓 RS485 上的原始字节,比在代码里打断点快得多。(抓包与协议分析的手段在 逻辑分析仪使用手册 里写过。)

② 三档确认,逐档往上走

读完设定寄存器(第二档)必须再读实际生效值(第三档)。如果两个不一致,问题在 W3 层;如果一致但行为不变,问题在控制环之后。

③ 断电重读:三步定”有没有落 NVM”

写参数 → 回读确认 → 断电 → 等 10s → 上电 → 回读
  • 回读还是新值 → 落了,问题在别处;
  • 回读变回”上一次的值” → 没落 NVM;
  • 回读变成”出厂默认值” → 启动流程覆盖了,或 NV 读校验失败(第八节末)。

④ 掉电折磨测试

在写参数的过程中反复随机断电,≥100 次。每次上电后校验参数集完整性。目标不是”每次都成功”,而是“要么全新、要么全旧,永远不出现半新半旧”。做法:上位机在随机延时后直接切继电器断 AC,让 UPS 之外的部分硬掉电。

这个测试是唯一能验证双区实现是否正确的手段,别省。

⑤ 寿命账:读 CFG_CNT,算日均

现场装完机,第一次记下 CFG_CNT;一个月后再读一次,算日均增长。然后:

日均增长 × 365 × 目标年数 > 单区寿命   →  现在就改设计,别等它炸

如果设备没有 CFG_CNT,就退而求其次:用抓包统计单位时间的写命令条数,乘上”是否每次都落盘”来估算。

⑥(补)多主互踩测试

本地 HMI 与远程 EMS 同时改同一个位域寄存器的不同位,看会不会互相覆盖。这是第五节那个竞态的现场验证法,一次测试 5 分钟,能提前发现一类极难复现的偶发故障。


十五、对接自查清单

写完上位机、定完点表之后,逐条过。这一份比任何”协议概述”都值得留着。

  • ☐ 32 位量是否一律用 0x10 双字连写,没有拆成两个 0x06?
  • ☐ 参数组超过 123 个寄存器时,是否知道要拆帧、且拆帧之间不涉及”必须同时生效”的量?
  • ☐ 点表里每个可写点是否都标了 kind(level / edge / autoclear)?
  • ☐ 边沿类(故障复位、合闸、均充)从站是否执行后自清?
  • ☐ 每个可写点的 range 是否明确?超范围是回异常码 0x03 拒绝,不是静默 clamp?
  • ☐ 是否有 state_guard(仅停机可写)的参数,且从站实现了状态校验?
  • ☐ 多主场景下是否有写权/所有权机制,或在系统层面约定了单点写?
  • ☐ 写完后是否需要写”保存/生效”命令?生效是立即、保存后、还是重启后?
  • ☐ 是否有 CFG_SEQ / CFG_CRC 供上位机确认”真的落盘了”?
  • ☐ 是否有 CFG_CNT(落盘次数)可用于寿命预警?
  • ☐ 掉电保持策略是”每次写落”还是”显式保存”还是”变化才写”?
  • ☐ EEPROM/Flash 写寿命算过没有(写频率 × 参数数 × 目标年数)?
  • ☐ 双区 + 序号 + CRC 是否齐备?序号回绕比较是否用了模 2^n 有符号差?
  • ☐ 参数结构是否显式按大端打包,没有直接 dump 结构体?
  • ☐ 掉电检测 + 储能电容是否够完成一次提交(且”掉电后不再开始新写”)?
  • ☐ 断电折磨测试做过 ≥100 次,且从未出现”半新半旧”?
  • ☐ 通信失联后,设备是否会自动收敛到安全限值或停机(而不是继续保持最后一次指令)?

最后一条单独说一下:“通信断了设备还在满功率跑”是储能系统里最危险的失效模式之一。 有个”心跳/失联超时”机制,超时后自动降到安全状态(或按最后一次本地设定运行),这条和持久化无关,但属于同一类”遥控遥调的边界条件”,必须一起设计。


十六、本篇的配套工具与一次自检抓到的真 bug

配套脚本:drafts/_tools/modbus_write_calc.py(纯标准库、全离线,可直接当现场核对工具)。

mode:
  frame    构造写帧:--op w5 | w6 | w10 | w15 | w16(mask) | w17
  parse    解析任意帧(含异常响应解读、CRC 校验)
  mask     位域掩码写解算:--cur 0x000C --set 2:0,3:1
  life     EEPROM/Flash 写寿命账:--interval --cycles --banks --slots,或 --table
  budget   反解"撑 N 年"允许的最快写周期
  dual     A/B 双区 + seq + CRC 的掉电恢复模拟:--writes 5 --cut-at 5
  ramp     速率限制轨迹(写限值不会让 PCS 跳机)
  latch    命令寄存器幂等(防掉线重连重放)
  silence  RTU 帧间静默间隔 T1.5 / T3.5
  triage   症状 → 层 → 根因 分诊表
  selftest 自检

这一次自检逮到了一个真 bug,值得单独记一笔。

我最初把”读/写多个寄存器”的功能码写成了 0x11,但它其实是 0x17(0x11 是 Report Server ID)。而且——自检全过了。

为什么?因为自检的断言是”构造出来的帧,功能码字节应等于我写的那个常量”,两边用的是同一个错误常量,自洽但不正确。

修完之后我补了一条断言:

check("w17 功能码", f17[1], 0x17)
check("w17 与 0x11 区分", f17[1] != 0x11, True)

这条给了一个可推广的教训:自检必须包含”外部锚点”(目录化检查值、公开规范里的向量、独立实现的交叉验证),不能只跟自己的常量对拍。 这和我在 母线电压采样链路 那篇里”用 CRC 目录化检查值而不是报文向量”是同一个道理——CRC16 输入 123456789 得 0x4B37,这是全世界通用的锚点;报文向量只能证明”我算的和文档抄的一致”,锚点才能证明”我算的是对的”。 本脚本的自检里两条都有(锚点 50 项 + 向量若干),59 项全过。


十七、小结:三个症状,一层一层落

回到开头那三个症状,现在可以一一对号:

症状 根因层 修法
A. 回读对、行为不变 W3 生效 三档确认;加参数生效命令;加 CFG_SEQ
B. 生效了、重启丢 W4 持久化 显式保存命令 或 变化才写;开机默认装载顺序要正确且失败要报警
C. 5 个月参数全变默认 W4 物理寿命 算寿命账;值变化才写 + 合并写 + A/B 双区 + 轮转槽;加 CFG_CNT 预警

而这台设备的三个症状来自同一个设计决策:把”收到写命令”当成了”参数已变更”。

改一句话就能同时解决三个症状:“收到 ≠ 变更;变更才落盘;落盘才递增序号。”

这句话背后是三件工程上的小事:值比较、合并写、原子提交。加上前四节的写帧纪律(一帧原子、掩码写、范围拒绝、边沿自清),就构成了一套”写方向”的完整纪律。


下一篇预告

写完了”写”,遥控遥调链路还剩两块没拆,下一篇挑其中一块:

并离网切换与充放电限值的联锁实战——一台储能系统在”并网转离网”的那 100ms 里,为什么会让负载闪一下、为什么 BMS 会报过流、以及”切换过程中哪些寄存器该被禁写”。这是第七节”安全联锁”的完整展开,也是最贴近真实电网工况的一篇。

如果那篇之前先把上一篇埋的坑补上,就是母线电压那篇里”板面漏电”往保护侧的延伸——绝缘监测与漏电流检测我在 储能 PCS 绝缘监测与漏电流检测实战 里已经写过,那条线算收尾了。电池侧的 SOC 与限值策略在 电池管理与 SOC 估算实战 里,届时会互链。