Fix a line of ST, hit transfer, and watch the machine keep running — with its counters, its timers, its SFC states and its forced variables carried over into the new program. Beremiz can now replace the logic of a running PLC without stopping it.
Two binaries instead of one
Hot-swap starts at build time. PLC code can now be bound to the runtime in three ways, and the toolchain says which one it uses:
| build type | what it means |
|---|---|
MONOLITHIC |
PLC code is linked into the runtime binary |
SPLIT |
IOs and logic go to two shared objects, loaded separately |
ABI |
PLC code is a standalone blob calling back through the ABI structure |
GCC based targets build SPLIT. The IOs .so holds the PLC thread, the IO extensions, the
configuration globals, the force and retain lists — everything that owns a connection to the outside
world. The logic .so holds the IEC program itself and its instance tree, behind a single entry
point the IOs side calls through a function pointer once per cycle.
Only the second one is replaced by a hot-swap. Which is also the rule for when a hot-swap is possible: if the IO configuration changed, the IOs .so changed, and the PLC has to be stopped. The IDE asks before doing so; the CLI stops the PLC and transfers.
$ Beremiz_cli.py --uri ERPC://192.168.0.12:61131 --project-home ./myproject build transfer
Same IOs, PLC logic hot-swap
Hot-swap: logic updated to 8e3f1c...
Transplanting the state
The hard part is not loading a new shared object — it is moving the running program’s state into it without the PLC noticing. That happens in four steps.
1. Load. The new logic .so is written to the runtime’s working directory and dlopened. The PLC
keeps cycling on the old one throughout.
2. Initialise, carefully. The new library’s instance tree has to be initialised by
config_init__() before anything can be read from it. But configuration-level globals live in the IOs
.so and are reached by every logic .so through symbol interposition — so a naive config_init__()
would reset globals the running program is using. The PLC thread therefore does it in one pass:
snapshot those globals, initialise, restore. The reset is never observable from a PLC cycle.
3. Reconcile. Both instance trees — old and new — are streamed side by side through the same
ScanInstances() walk the debugger uses. It is a two-pointer merge over paths: leaves that exist in
both trees with the same type are paired into a copy operation; a variable that only exists in the
new program keeps its initial value; one that disappeared is simply dropped. Because both trees stream
in declaration order, memory stays bounded by how far the two programs diverge, not by how large they
are. Containers are skipped — they hold no state of their own, and their contents are matched
individually.
4. Commit. The list of copy operations is handed to the PLC thread, which picks it up at the top of its next cycle: copy every paired value across, switch the function pointers to the new logic, done. The swap happens between two cycles, never inside one.
What survives
- Variable state, for every variable the two programs have in common — including the contents of arrays and structures, now that every leaf is individually addressable
- Forced variables. The force list is rebuilt against the new program’s memory as part of the copy pass, before the switch, so a force set before the swap is still a force after it.
- RETAIN variables, whose list is rebuilt from the flags the new
config_init__()set. - SFC states. matiec’s SFC code generation was reworked so that all states live in the instance tree — precisely so they could be transplanted.
- Global function block instances, which keep their internal state rather than being reset.
PYTHON_EVALrequests in flight, re-bound to the function block instance that claims them in the new program instead of being dropped.
Extensions get a say
Runtime extensions that keep state tied to the logic .so are notified when it changes, through a new
swap lifecycle hook alongside init / start / stop / cleanup. SVGHMI uses it to close its
sessions so that browsers reconnect and re-subscribe against the new HMI tree — and to jump back to the
page that was displayed before the swap, which makes a logic update nearly invisible on the panel.
On the C side, a PLC library can register __logic_bound_* / __logic_active_* hooks, called when a
logic library is loaded and when it becomes the active one. And because a single PLCBinary handle is
ambiguous once there are two shared objects, extensions now get PLCIOsBinary and
PLCLogicBinary explicitly — PLCBinary still resolves, against the IOs library first, with a
one-shot deprecation notice per symbol.
Limits
Hot-swap is best-effort by design, and it is worth knowing where it stops:
- It only replaces logic. Any change to the IO configuration, to an extension’s C code, or to anything else that lands in the IOs .so requires a stop.
- A variable that changed type between the two versions is not carried over; it starts from its initial value. Same for renamed variables — the reconciliation matches on paths.
- The swap is implemented in the Python runtime. The C++ runtime already loads the split pair of shared objects, but does not perform the swap itself yet.
Try it
The behaviour is covered by a CLI test that builds a project, transfers it, changes one string in the
logic, rebuilds against the same build directory and transfers again — then checks that the new code
runs, that the log says Hot-swap: and, crucially, that it never says PLC stopped:
$ tests/cli_tests/plc_hotswap_test.bash
>>> PLC v1 running.
>>> Initiating hot-swap to v2...
>>> HOT-SWAP SUCCESS: PLC logic swapped without restart.