逆变器 Modbus 写控制与参数掉电保持实战:EMS 下发”充电限流 30A”、回读也是 30A,第二天早上柜子照旧 100A 在充——我把写帧、写序、生效链路和那片 EEPROM 的寿命从头对了一遍
上一篇写的是读:从线上字节到屏幕上那个工程值,中间隔着地址口径、字序、类型、倍率、有效性五层。
这篇写写。写比读多三件事,而这三件事恰好是现场翻车最集中的地方:
- 写是有副作用的。读错了顶多显示错;写错了设备真会按错的值动作。逆变器是功率设备,写错一个限值就是跳机、过流、甚至烧东西。
- 写有”落不落盘”的问题。寄存器的值活在 RAM 里,掉电就没了;要它活过重启,得写进 EEPROM / Flash,而那片存储器的写寿命是有上限的。
- 写有”原子性”的问题。一个 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 不是万能的
三个现实约束,写代码前必须确认:
- 很多从站不实现
0x16(回了异常码0x01)。用之前先拿0x16对着一个无害寄存器试一发。 0x16只能操作单个寄存器。跨寄存器的位域(比如 32 位标志字)它管不了。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 是单主协议,没有内置的仲裁机制,所以必须自己设计。
三种做法,从简到繁:
- 写权寄存器(推荐):一个”所有权”寄存器,写
0x00A5(魔术字)表示”我要改,别人请退让”;谁持有谁可写;超时(比如 30s 无心跳)自动释放。其他 master 写参数前先读它。 - 只允许单点写:在系统层面规定”运行期间只有 EMS 可写,HMI 只读”。简单粗暴但有效,缺点是现场调试时很别扭。
- 参数变更序号 + 一致性检查:不阻止并发写,但每个 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))
三条值得单独圈出来的:
commit_to_control_loop和nv_commit是两个不同的动作。前者改行为,后者保命。混淆这两件事,就是症状 A 和 B 的根源。CFG_SEQ只在 NV 提交成功后 +1。如果写成”收到写命令就 +1″,那CFG_SEQ就退回成”第一档确认”,失去意义。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 估算实战 里,届时会互链。