OpenPLC v3 + Modbus TCP: Open-Source PLC Architecture

OpenPLC v3 + Modbus TCP: Open-Source PLC Architecture

OpenPLC v3 + Modbus TCP: Open-Source PLC Architecture

A Modbus TCP server is the simplest way to make a controller visible to the outside world, and that simplicity hides a trap. A PLC runs a deterministic scan loop; a Modbus server answers whenever a client happens to ask. Put the two on a general-purpose Linux box and you have two asynchronous actors sharing a block of memory, with no timestamp, no transaction guarantee and no authentication. Most “my OpenPLC readings look wrong” tickets come from not seeing that.

This article uses OpenPLC Modbus TCP as the lens for understanding open-source soft PLCs end to end. It also carries an important 2026 correction: the OpenPLC v3 repository is now archived and marked end of life, and the project has moved to the MIT-licensed OpenPLC Runtime v4. Everything below that is a protocol or architecture principle holds for both; where v3 and v4 differ, we say so.

You will leave with a precise model of the compile-and-run pipeline, the Modbus TCP frame and function codes, how IEC 61131-3 variables map onto Modbus addresses, what a soft PLC can and cannot promise about timing, how to harden the exposed port, and how to bridge the result to MQTT and OPC UA.

What this covers: OpenPLC lineage and the v3 to v4 transition, the editor-compiler-runtime pipeline, IEC 61131-3 languages, the scan cycle and its interaction with the Modbus server, MBAP framing and function codes, I/O address mapping, determinism limits, security hardening, MQTT and OPC UA integration, simulator-based testing, a worked configuration, and failure modes.

Context and Background

OpenPLC began in 2014 as a research project by Thiago Alves, then a graduate student, to show that a standards-conformant programmable logic controller could be built entirely from open components. The first generation ran on Linux and Raspberry Pi; the third generation, OpenPLC v3, added a browser-based management interface, a Modbus slave and master, DNP3 and EtherNet/IP servers, and a long list of hardware targets. It became the default teaching and security-research PLC: it appears in cybersecurity courses, in ICS honeypot work and in countless lab rigs, because it is free, scriptable and behaves enough like the real thing.

Two facts matter for anyone starting in late 2026. First, the v3 repository on GitHub carries an end-of-life notice and is read-only, with its README pointing to the Autonomy Logic openplc-runtime repository as the successor (OpenPLC_v3 repository). Second, that successor is architecturally different: a headless runtime with a Flask REST API on HTTPS port 8443, a C/C++ execution core, and an editor on a separate machine that compiles locally and uploads the result (OpenPLC Runtime v4 repository). We cover the practical migration path in our OpenPLC Runtime v4 vs v3 migration guide; here we focus on the architecture that both generations share.

Where does a soft PLC fit among industrial controllers? It fits where the control logic is slow, the consequences of a missed cycle are small, and openness or cost matter more than certification: teaching labs, pilot lines, test benches, building automation, protocol gateways and digital-twin plant simulators. It does not fit safety functions, motion control or any loop that needs a bounded worst-case cycle. The wider shift toward software-defined control is covered in our virtual PLC and container-based control article, and the standards behind the programming model are compared with newer distributed approaches in IEC 61499 vs IEC 61131-3.

Modbus itself is older than almost any other protocol you will touch. Modicon published it in 1979 for serial links; Modbus TCP wraps the same application data unit in a TCP segment on registered port 502. The public specification is maintained by the Modbus Organization (Modbus specifications). Its longevity is the reason an open-source PLC speaks it first: every HMI, SCADA package, gateway and test tool already does too.

A thesis for the rest of this article. OpenPLC’s most valuable property is not that it is free. It is that every layer is inspectable: you can read the generated C, watch the scan loop, capture the Modbus bytes and trace a value from a ladder rung to a TCP segment. That makes it the best available instrument for learning why industrial protocols behave the way they do, and for prototyping integrations before they touch proprietary hardware.

The OpenPLC Reference Architecture

OpenPLC is a compiler plus a runtime. The editor translates IEC 61131-3 source into C or C++, a toolchain builds that into a loadable library, and a runtime process calls the program once per scan while exposing a shared process image to I/O drivers and protocol servers such as Modbus TCP.

The architecture has four cooperating parts: the authoring environment, the translator, the runtime core with its process image, and the protocol and I/O plugins. Figure 1 shows how they connect.

OpenPLC Modbus TCP reference architecture from IEC 61131-3 editor through compiler and runtime to the Modbus server and field I/O

Figure 1: OpenPLC compile-and-run pipeline. Source in any IEC 61131-3 language becomes C or C++, is built into a library, and runs inside a scan loop that shares a process image with the Modbus TCP server and the I/O driver.

The diagram shows a one-way compile path on the left and a hub-and-spoke runtime on the right. The process image is the hub: the program reads and writes it, the Modbus server exposes it to the network, and the I/O driver synchronises it with physical or remote devices. Understanding that the process image, not the program, is what the network sees is the key to everything that follows.

Authoring and translation: from ST and ladder to C

IEC 61131-3 defines five languages: Ladder Diagram (LD), Function Block Diagram (FBD), Structured Text (ST), Instruction List (IL) and Sequential Function Chart (SFC). OpenPLC v3 accepted programs authored in its web editor or in the separate OpenPLC Editor, and passed the resulting PLCopen XML or ST through MATIEC, an open-source IEC 61131-3 to C translator. MATIEC emits C source that declares located variables (those with AT %IX0.0 style addresses) and a program entry that the runtime calls each cycle.

OpenPLC Runtime v4 changes the compiler. The newer OpenPLC Editor compiles locally using the STruC++ pipeline, which produces C++ sources, packages them as a ZIP and uploads them to the runtime; the runtime validates the archive, builds it with Make and loads the resulting shared library dynamically (OpenPLC Runtime v4 repository). The practical effect is that the runtime device no longer needs to host a web IDE, and the compile step can run on a workstation rather than on a Raspberry Pi.

Whichever translator is used, the contract is the same. The translator outputs a function that reads from and writes to variables mapped onto the process image; the runtime owns when that function is called. This separation is what lets a ladder rung and an ST statement share the same execution engine, and it is why the choice of language has no effect on timing: all five end up as compiled native code.

The runtime core and the process image

The runtime core is a native process, written in C and C++, that loads the compiled program, starts the protocol servers, and runs the scan loop. The process image is a set of arrays holding the located variables: bit arrays for %IX (input bits) and %QX (output bits), word arrays for %IW and %QW, and a memory area for %MW, %MD and %ML (memory words, double words and long words). These are the IEC 61131-3 direct-variable names: I input, Q output, M memory, then the size prefix X bit, B byte, W 16-bit word, D 32-bit double word and L 64-bit long word.

Every consumer of the PLC’s state goes through this image. The ladder program reads %IX0.0 as an input contact; a Modbus client reading discrete input 0 reads the same byte. Nothing in the architecture gives a client a private view or a historical one. A request arriving between two scans sees the previous scan’s outputs; a request arriving during a scan sees whatever the implementation’s locking permits. That locking behaviour is the subject of the next section.

Protocol and I/O plugins

OpenPLC separates how data reaches the process image from how it is exposed. On the field side, drivers read and write physical or remote I/O: on a Raspberry Pi the GPIO header, on Arduino-class boards a serial link, and on many targets a Modbus master that polls remote slave devices over RTU or TCP and maps their registers into %IW and %QW. On the network side, servers expose the image: Modbus TCP is the baseline, with v3 also providing DNP3 and EtherNet/IP servers. In v4 the protocol surface is delivered as plugins; the repository and documentation describe a Modbus slave plugin, an S7comm plugin, an EtherCAT client and OPC UA work, and that list is evolving, so check the current release notes before depending on a specific protocol.

One detail that surprises newcomers: OpenPLC is simultaneously a Modbus slave and, optionally, a Modbus master. As a slave it answers SCADA; as a master it polls field devices. A single OpenPLC instance can therefore act as a protocol concentrator, reading ten serial meters over RTU and re-exposing their values as holding registers over TCP. That makes it a cheap, fully inspectable Modbus gateway, which we compare with dedicated options in our brownfield Modbus to Unified Namespace article.

Web interface versus headless runtime

In v3 the runtime and the Flask web application shipped together: you opened a browser on port 8080, uploaded a program, pressed Start, and edited settings such as the Modbus port or the slave device list. In v4 the Flask process exposes only a REST API and WebSocket debug interface over HTTPS on port 8443, authenticated by JWT bearer tokens, and the two processes (API server and PLC core) communicate over a Unix domain socket. The design is closer to how a modern edge device is managed: the controller has no UI of its own, and an editor, script or orchestration system drives it through an API. For fleet deployments this is a real improvement; for a hobbyist it means installing the editor.

Scan Cycle, Process Image and the Modbus Server

A PLC scan reads inputs into the process image, executes the program once, then writes outputs, and repeats at a configured period. OpenPLC’s Modbus server runs in a separate thread and reads the same image on demand, so network clients observe values that are consistent only to the extent the runtime serialises access.

The scan cycle is the heartbeat of every PLC, and soft PLCs inherit its semantics from hardware ones. Figure 2 shows how OpenPLC’s loop and its Modbus server thread interleave.

OpenPLC scan cycle sequence showing the scan thread, process image lock and a concurrent Modbus TCP read request

Figure 2: Scan thread and Modbus thread sharing the process image. The scan thread copies inputs in and outputs out under a lock; a Modbus request that arrives mid-scan waits for or reads a snapshot of the image.

Notice what the diagram does not show: any synchronisation between the client’s request time and the scan phase. The client is asynchronous. It might poll every 100 ms while the scan runs every 50 ms, in which case it sees every second scan’s state, and it has no way of telling which.

The anatomy of one scan

Each cycle has three conceptual phases, inherited from IEC 61131-3 practice. First, input sampling: the I/O driver reads the physical or remote inputs and the runtime copies them into the image’s input area. Second, program execution: the compiled program runs once, reading %I and %M locations and writing %Q and %M locations. Third, output update: the image’s output area is pushed to the I/O driver. The runtime then sleeps until the next tick.

OpenPLC makes the period configurable. The v3 runtime exposed a cycle setting in milliseconds (the long-standing default has been 50 ms, a figure you will find in OpenPLC documentation and community posts; verify it against your build), and v4 runs the scan thread with real-time scheduling when it can. The repository for v4 states that execution uses SCHED_FIFO priority and recommends a dedicated CPU core and a PREEMPT_RT kernel, while noting root privileges are required for that scheduling class (OpenPLC Runtime v4 repository).

A subtle but crucial consequence: a program’s logic sees a frozen snapshot of inputs for the whole scan. An input that changes at microsecond 3 of a 50 ms scan is invisible to the logic until the next scan begins. That is by design, because it makes program behaviour reproducible and race-free within a cycle. It is also why edge detection on fast signals in a soft PLC is unreliable: a pulse shorter than the scan period can vanish entirely.

Why Modbus clients can read torn data

Consider a PLC that computes a 32-bit setpoint and stores it across two 16-bit holding registers. The scan thread updates the high word and then the low word. If the Modbus thread services a read between those two updates and sees no lock, the client receives a high word from the new value and a low word from the old: a torn read. The result might be off by 65,536 counts, or wildly wrong when the low word wraps.

Whether this happens depends on the implementation’s locking granularity, not on the Modbus protocol. Modbus guarantees only that one request returns the contents of the requested range at some moment; it says nothing about atomicity relative to the controlling program. Hardware PLCs often update the Modbus-visible area at the end of the scan so clients never see a half-written state. OpenPLC’s design copies the image under a mutex at scan boundaries, which gives atomic snapshots for each phase, but a multi-register value written by your own program inside one scan is still safe only because the copy-out happens after the program ends. The risk reappears whenever a value is written by a different source, for example a Modbus write landing between your program’s read of a register and its use.

Defensive programming therefore matters. Use 32-bit types (%MD) so a double word occupies two adjacent registers that the server copies in one request. Read them in one FC 03 call with quantity 2, not in two separate requests. Add a sequence counter or a status register that the program increments after finishing an update so the client can verify both reads belong to the same generation. These are the same techniques used against real hardware PLCs; the open-source runtime merely makes the need visible.

Writes from the network

Modbus writes (FC 05, 06, 15, 16) land in the same image the program reads. If a SCADA system writes holding register 10 and your program also writes %QW10, the last writer wins, and which is last depends on timing. This is the classic dual-master problem: ownership of each location must be exactly one of {program, network}. Treat network-writable locations as inputs to the program, give them distinct addresses from locations the program drives, and have the program validate range and rate of change before using them.

Modbus TCP: Frames, Function Codes and Exceptions

Modbus TCP is a request-response protocol on TCP port 502. Each message is a seven-byte MBAP header followed by a protocol data unit holding a function code and its data. The server never initiates traffic, which is why a Modbus TCP server is easy to implement and equally easy to poll into overload.

Figure 3 sketches a short client session against an OpenPLC server, including a successful read, a write and a rejected request.

Modbus TCP session with an OpenPLC server showing MBAP request and response frames and an exception response

Figure 3: A Modbus TCP session. The client connects to port 502, reads four holding registers, writes one, and receives exception code 02 (illegal data address) for an out-of-range read.

The MBAP header, byte by byte

The Modbus Application Protocol (MBAP) header, defined in the Modbus Messaging on TCP/IP Implementation Guide, has four fields. The transaction identifier (2 bytes) is chosen by the client and echoed by the server so responses can be matched to requests. The protocol identifier (2 bytes) is always zero for Modbus. The length (2 bytes) counts the remaining bytes: the unit identifier plus the PDU. The unit identifier (1 byte) addresses a downstream serial slave when the server is a gateway, and is largely ignored (often 0xFF or 0x01) by a native TCP device.

After the header comes the PDU: one byte of function code and up to 252 bytes of data, so the PDU never exceeds 253 bytes and a full TCP application message is at most 260 bytes. This ceiling comes from the original RS-485 frame limit of 256 bytes and is the reason a single request can read at most 125 holding registers or 2,000 coils. A worked example makes the layout concrete. The request below reads four holding registers starting at address 0 from unit 1:

00 01  00 00  00 06  01  03  00 00  00 04
txn    proto  len    uid fc  addr    qty

The length field is 6 because it counts unit ID (1), function code (1), address (2) and quantity (2). A well-behaved response echoes the transaction ID and replies with the function code, a byte count and the data:

00 01  00 00  00 0B  01  03  08  00 0A 00 0B 00 0C 00 0D

The byte count 08 is four registers times two bytes, and the length 0x0B equals unit ID, function code, byte count and eight data bytes. Everything is big-endian; the byte order of a 32-bit value spanning two registers is not defined by the protocol, which is why word-order mismatches between vendors are the most common Modbus integration bug.

Function codes OpenPLC serves

The function codes you will actually use map to four data tables. The mapping is the same for any Modbus device, but note which are read-only.

Function code Operation Data table Access
01 Read Coils Coils (output bits) Read
02 Read Discrete Inputs Discrete inputs (input bits) Read
03 Read Holding Registers Holding registers (16-bit) Read
04 Read Input Registers Input registers (16-bit) Read
05 Write Single Coil Coils Write
06 Write Single Register Holding registers Write
15 Write Multiple Coils Coils Write
16 Write Multiple Registers Holding registers Write

The Runtime v4 documentation confirms support for function codes 1, 5 and 15 for coils, 2 for discrete inputs, 3, 6 and 16 for holding registers and 4 for input registers (OpenPLC Modbus server documentation). Limits from the specification worth memorising: a read can return up to 2,000 coils or discrete inputs, or 125 registers; a write can set up to 1,968 coils or 123 registers.

Two function codes that exist in the standard are typically absent or limited in open-source servers: FC 23 (read/write multiple registers in one atomic request) and FC 22 (mask write register). FC 23 would solve the dual-master problem above in a single transaction, so its absence is a real design constraint rather than a curiosity. Check your build before designing a protocol that depends on it.

Exception responses

When a request is valid Modbus but cannot be served, the server replies with the function code with its high bit set (so 0x03 becomes 0x83) followed by a one-byte exception code. The ones you will meet: 01 illegal function, 02 illegal data address, 03 illegal data value, 04 server device failure. Code 02 is by far the most common in practice and nearly always means a client addressed beyond the configured size of a segment. In the Runtime v4 model, each block has a configured size (the documentation gives a default of 800 coil bits in %QX, with caps of 8,192 bits per coil or discrete-input segment and 1,024 registers per register segment), so reading past the end returns exception 02 instead of zeros.

Connection behaviour and polling load

Modbus TCP typically uses one persistent TCP connection per client. Clients may pipeline multiple outstanding requests with distinct transaction IDs, but many embedded servers process one at a time, in order. OpenPLC’s server handles each connection in its own thread, and the number of simultaneous connections is finite; the documentation pages we reviewed do not state a hard limit, so measure it on your target rather than assuming.

Poll rate arithmetic matters more than people expect. A client polling 100 registers every 100 ms from each of 20 stations generates 200 requests per second, each a 12-byte request and a 209-byte response: trivial for bandwidth, but each request wakes a thread, takes the image lock and competes with the scan thread for the CPU. On a single-core board the lock contention shows up as scan-time jitter long before the network saturates.

Mapping IEC 61131-3 Variables to Modbus Addresses

Modbus has four flat tables; IEC 61131-3 has typed, hierarchical variables. OpenPLC bridges them by assigning located variables to fixed offsets in the process image and exposing contiguous ranges of that image as the Modbus tables.

The mapping is the part of an OpenPLC deployment that causes the most confusion, partly because v3 and v4 differ, partly because Modbus tools disagree about whether addresses start at 0 or 1.

The v4 mapping

Runtime v4 documents an explicit, configurable mapping (OpenPLC Modbus addressing):

Modbus table Modbus address IEC location
Coils (FC 1/5/15) 0 to qxBits-1 %QX0.0 onward, then %MX0.0
Discrete inputs (FC 2) 0 onward %IX0.0 onward
Input registers (FC 4) 0 onward %IW0 onward
Holding registers (FC 3/6/16) 0 onward %QW, then %MW, then %MD (2 registers each), then %ML (4 each)

For example, with defaults of 800 coil bits, coil 0 is %QX0.0, coil 8 is %QX1.0, coil 799 is %QX99.7, and coil 800 begins the %MX0.0 memory bits. With a holding layout of four %QW, eight %MW and two %MD, registers 0 to 3 are %QW0 to %QW3, 4 to 11 are %MW0 to %MW7, and 12 to 15 are the two double words. The documentation calls the live Address Mapping Reference in the editor “the source of truth”, and you should too, because the segment sizes are yours to configure.

The v3 mapping

OpenPLC v3 used a fixed layout that many existing tutorials and SCADA templates still assume. From the v3 documentation and widely circulated examples, located inputs and outputs start at %IX100.0, %QX100.0, %IW100 and %QW100, with the Modbus tables arranged so that discrete inputs and coils begin at address 0, input registers at 0 and holding registers at 0, and memory words %MW mapped beginning at holding address 1024. Treat that as a pattern to confirm against the OpenPLC v3 documentation for your installed version rather than as a spec, because the details have shifted between releases. If you inherit a project, check the generated mapping before trusting any HMI tag list.

The migration implication is blunt: a SCADA tag list built for v3 will read the wrong registers on v4 unless you reconfigure the segment sizes and, in some cases, redefine the variable locations. Our v4 vs v3 migration guide walks through that remapping.

Bit packing and the zero-versus-one trap

Coils and discrete inputs are bits, but the process image stores them in the byte-and-bit notation %QXbyte.bit. Coil 8 is therefore %QX1.0, not %QX0.8. Modbus clients pack bits into bytes least-significant bit first in FC 01 and FC 02 responses, so the first requested coil is bit 0 of the first data byte. The addresses themselves are zero-based on the wire. Many tools, historically following the 1-based “reference” numbering such as 00001 for the first coil or 40001 for the first holding register, display addresses with an offset of one. Test with a known value: write 1 to the coil you believe is %QX0.0, then verify with a debug watch in the editor, before trusting any tag list.

Wide types, scaling and endianness

A REAL is 32 bits and therefore occupies two registers; a LREAL four. The IEC standard fixes the types’ internal representation but says nothing about how they are split across 16-bit Modbus registers, and clients disagree about word order (big-endian, little-endian, byte-swapped). OpenPLC places a %MD double word across two consecutive holding registers; confirm by writing a known float such as 1.0 (0x3F800000) and reading both registers. If you read 0x3F80 then 0x0000 you have high-word-first; the reverse means low-word-first. Standardise the order on the client or in a gateway, and document it in the register map.

Scaling is the other perennial source of trouble. Many field devices expose temperatures as integers in tenths of a degree. Do the scaling in the PLC program (convert to REAL, divide by 10) or in one named place in the gateway, never in three different dashboards. The register map should list address, IEC variable, type, scaling, unit, direction and owner as a single source of truth.

Determinism: What a Soft PLC Can and Cannot Promise

A soft PLC’s timing is only as good as its operating system’s scheduler. On a stock Linux kernel OpenPLC offers best-effort cycles with jitter in the millisecond range; with a PREEMPT_RT kernel, SCHED_FIFO, CPU isolation and no blocking calls it can approach the hundred-microsecond class, but never a certified bound.

Hardware PLCs run on a real-time operating system or dedicated firmware where the maximum scan time is a specified, tested quantity, often with a hardware watchdog that forces a safe state when it is exceeded. A soft PLC on Linux gives up that guarantee in exchange for flexibility. Knowing exactly where the variability comes from lets you decide whether it matters.

Sources of jitter

Four mechanisms account for most variation. Scheduler latency is the time between the timer firing and the scan thread actually running; on a loaded non-real-time kernel it can reach tens of milliseconds, and a PREEMPT_RT kernel reduces the worst case dramatically. Interrupts and other tasks preempt the scan; the Modbus threads, the web server, logging and the compiler all compete for the same cores. Blocking I/O inside the scan, such as serial reads from a Modbus RTU master at 9,600 baud, can stretch the cycle by a frame time per device: a single 8-byte request and 21-byte response at 9,600 baud costs on the order of 30 ms of line time before the device even responds, so polling five serial slaves serially in the scan can consume a quarter second. Finally, power management and frequency scaling on small boards change how long the same code takes.

Many OpenPLC deployments run the field-bus master in its own thread, polling devices on a separate schedule and exchanging values with the scan through the process image. That decoupling is the standard fix: the scan stays short and regular, and the slow serial side contributes stale-but-consistent data rather than jitter.

Measuring instead of guessing

Do not accept published numbers for your hardware, including the “20 to 50 ms scan, plus or minus 10 ms jitter on a Pi” folklore. Measure. Toggle a GPIO output every scan and record it on a logic analyser or a second microcontroller timing the edges; histogram the intervals over at least an hour under realistic load, including a Modbus client polling at the production rate. Report the maximum and the 99.9th percentile, not the mean. The mean tells you nothing about the cycle you will miss.

Cyclictest, from the rt-tests package, gives a hardware-and-kernel baseline of scheduler latency independent of OpenPLC, which separates “my kernel is the problem” from “my program is the problem”. If the baseline worst case is already above your tolerance, no amount of ladder optimisation will fix it.

Where determinism requirements end the discussion

Three categories of function should never rely on a general-purpose soft PLC. Safety functions, such as emergency stops and interlocks that protect people, need certified hardware and the functional-safety lifecycle of IEC 61508 and its machinery derivative; OpenPLC is not certified to any safety integrity level and says nothing to the contrary. Motion control with coordinated axes needs sub-millisecond synchronisation and distributed clocks of the kind provided by EtherCAT or TSN; see our comparison of PROFINET, EtherCAT and OPC UA FX over TSN. Fast loops such as high-speed counting or tight PID loops below the scan period belong in dedicated hardware: counters, drives or a microcontroller.

Everything else is a judgement about consequences. A building-automation schedule, a tank level controller with a minute-scale time constant, a lab rig or a gateway between protocols tolerates a missed cycle. A packaging line with sensors at 200 ms spacing likely does as well. The test is simple: ask what happens if the scan stalls for one second, then for ten, and make sure the answer is “the process holds a safe state” without relying on the soft PLC itself to enforce it.

Security Hardening for an Exposed Modbus Port

Modbus TCP has no authentication, authorisation or encryption, so an OpenPLC instance is only as safe as the network around it. Harden in layers: restrict who can reach port 502, run the runtime with least privilege, protect the management interface, and put an authenticated gateway between the controller and everything else.

The Runtime v4 documentation says this plainly: “Modbus TCP has no authentication, so restrict access with a firewall or VLAN”, and notes the server is disabled by default and, once enabled, binds to all interfaces unless you set it to 127.0.0.1 (OpenPLC Modbus server documentation). Take both facts as design inputs. Enable the server only when needed, and bind it to the one interface that faces the control network.

Network segmentation and allow-lists

The model that works is the zone-and-conduit structure of IEC 62443: the controller and its field I/O in a cell zone, a single conduit enforcing an allow-list, and everything else beyond it. Concretely, a host firewall rule that accepts TCP 502 only from the gateway’s address, and drops everything else, removes most of the practical attack surface. An nftables sketch:

table inet plc {
  set modbus_clients { type ipv4_addr; elements = { 10.20.0.10, 10.20.0.11 } }
  chain input {
    type filter hook input priority 0; policy drop;
    ct state established,related accept
    iif lo accept
    tcp dport 502 ip saddr @modbus_clients accept
    tcp dport 8443 ip saddr 10.20.0.5 accept   # management API from engineering host
    icmp type echo-request limit rate 5/second accept
  }
}

The same logic applies to the v3 web interface on port 8080, which is an HTTP service with a login page and, in the v3 era, default credentials documented in the project’s quick-start (change them on first login). Do not expose it beyond an engineering workstation. For v4, the REST API uses HTTPS with an automatically generated self-signed certificate and JWT bearer authentication, with passwords hashed using PBKDF2-SHA256 at 600,000 iterations plus salt and pepper, and the upload path validates archives with size limits (10 MB per file, 50 MB total), extension allow-listing, path-traversal checks and zip-bomb detection, according to the repository README. Replace the self-signed certificate with one from your own certificate authority so the engineering host can verify the controller.

Least privilege and the root problem

Binding to port 502 needs privilege on Linux (ports below 1024). The v4 documentation suggests using 1502 if binding fails, and redirecting 502 to 1502 with a firewall rule keeps the process unprivileged for that function. However, enabling SCHED_FIFO scheduling needs elevated capabilities, and the v4 README states root privileges are required. Reduce the exposure by running the runtime in a container with only the capabilities it needs (CAP_NET_BIND_SERVICE and CAP_SYS_NICE rather than full root), a read-only root filesystem and a non-routable management network. Where the target is a Raspberry Pi with GPIO access, mount only the device nodes required.

Protecting writes and detecting abuse

Because any host that reaches port 502 can write any coil, enforce intent inside the program: treat network-writable locations as untrusted inputs, clamp setpoints to engineered ranges, rate-limit changes, and require a two-step enable (a permissive coil plus a setpoint) for actions that matter. This is application-level defence against a legitimate client sending a bad value as much as against an attacker.

Monitoring is cheap. A passive tap or a switch mirror port with Zeek or Suricata and the Modbus parsers will log function codes, addresses and exception counts. An FC 05 to an address that is never written in normal operation, a burst of exception 02 responses (scanning), or a function code the HMI never uses (FC 08 diagnostics, FC 43 device identification) are high-value signals and rare in healthy traffic. OpenPLC’s popularity as a honeypot and training target means its traffic patterns are well documented, which helps detection rules, but it also means attackers’ tooling knows it.

Modbus/TCP Security and encryption in practice

The Modbus Organization published a Modbus/TCP Security specification that wraps Modbus in TLS with mutual authentication, on a separate registered port (802). Support in open-source stacks and field devices is thin, and most deployments rely on network controls instead. A pragmatic alternative is to terminate TLS at the gateway: keep clear-text Modbus inside the cell zone, and let the gateway speak MQTT over TLS or OPC UA with certificates northbound. For the OPC UA side of that design, see our OPC UA security, certificates and hardening guide.

Regulatory pressure is rising, too. If you ship a product containing an open-source PLC runtime into the EU, the Cyber Resilience Act’s obligations on vulnerability handling and secure defaults apply to you as the manufacturer, regardless of the component’s licence. Our CRA vs NIS2 vs IEC 62443 mapping explains how those frameworks overlap. The end-of-life status of OpenPLC v3 is directly relevant: running an unmaintained controller runtime with network exposure is a documented risk you would need to justify.

Integrating OpenPLC with MQTT and OPC UA

Modbus is a register protocol with no semantics, no timestamps and no push. The usual architecture keeps Modbus TCP at the edge of the cell, polls it with a gateway that adds names, units and timestamps, and republishes the data over MQTT or OPC UA to the rest of the enterprise.

Figure 4 shows the pattern: the controller and field I/O in a cell zone, a firewall conduit, an edge gateway, and northbound MQTT and OPC UA services feeding historian and SCADA.

Zone and conduit architecture connecting an OpenPLC Modbus TCP server through a firewall and gateway to OPC UA and MQTT Sparkplug B services

Figure 4: Segmented OpenPLC deployment. A single gateway polls the Modbus slave through an allow-listed conduit and publishes semantically rich data north over OPC UA and MQTT.

The gateway is where meaning is added. It turns “holding register 12-13, float, word-swapped” into a named, scaled, timestamped value in a topic or node, and it is also the only component allowed to talk to the PLC, which makes access control and audit tractable.

Choosing the gateway

Several open-source options fit. Apache PLC4X provides Modbus drivers (and many others) with a common API and connectors for Kafka and MQTT; our PLC4X tutorial shows a PLC-to-MQTT pipeline. Node-RED with the Modbus nodes is quick for prototypes and small sites. Eclipse Mosquitto or EMQX serve as the broker, and open62541 or Eclipse Milo can host an OPC UA server that mirrors the PLC’s registers into a typed address space. For a broader view of where each fits, see SCADA vs OPC vs IoT platform vs historian.

OpenPLC v4 documentation and repository material reference OPC UA work in the runtime, but the state of any native OPC UA server may change between releases, so verify the current release before designing around it. A gateway-based design is robust to either outcome.

Poll design: rate, grouping and change detection

Design the poll plan from the process, not from convenience. Group registers that change together into one contiguous FC 03 read so the gateway makes one round trip rather than twenty. Use a fast group (100 to 500 ms) for analog process values and a slow group (5 to 60 s) for configuration and status. A gateway publishing every sample to MQTT wastes bandwidth; apply a deadband (publish when the change exceeds a threshold) plus a heartbeat (publish at least every N seconds so consumers know the value is alive).

Because Modbus carries no timestamps, the gateway’s timestamp is the time the response arrived, which is later than the true measurement by half the round trip plus the scan-phase delay. For trending that is fine. For anything that correlates events across devices to the millisecond, do the timestamping in the PLC or a time-synchronised device and expose it as registers; see our PTP vs NTP vs chrony time synchronisation article for the clock problem.

Writing back safely

Command flow is the dangerous direction. If an MQTT topic can write a coil, an unauthenticated publisher can actuate your process. Keep command topics on a separate broker ACL, validate in the gateway (type, range, rate), and write to the PLC a request register that the PLC program interprets, not directly to an output. A command handshake works well: the gateway writes a command code and a sequence number; the PLC executes if valid and echoes the sequence number and a result code in status registers; the gateway confirms before reporting success.

Representing the data in a Unified Namespace

If your site uses a Unified Namespace, publish into a hierarchical topic structure (enterprise, site, area, line, cell) with Sparkplug B or JSON payloads, and let the gateway own the device model. Our unified namespace architecture article explains that structure. The advantage of an OpenPLC cell in this model is that its register map is documented, versioned and reproducible, so the gateway configuration can be generated from the same source as the PLC program.

Testing Without Hardware: Simulators and Software-in-the-Loop

Because OpenPLC runs on ordinary Linux and talks standard Modbus, you can test the whole stack on a laptop: run the runtime in a container, drive its inputs with a script, point your gateway at it, and assert on what comes out the other side.

This is the underrated strength of a soft PLC. A hardware PLC needs a rack, power and licensed software; an OpenPLC test needs a container and a pytest file.

A Python test client

The pymodbus library provides a Modbus TCP client. This test writes a setpoint, waits for the program to react, and checks the output within a tolerance. The addresses assume the v4 default mapping where holding register 0 is %QW0 and holding register 4 is %MW0; adjust to your configured layout.

import time
from pymodbus.client import ModbusTcpClient

HOST, PORT, UNIT = "127.0.0.1", 502, 1

def test_setpoint_drives_output():
    c = ModbusTcpClient(HOST, port=PORT)
    assert c.connect()
    try:
        # Write setpoint to %MW0 (holding register 4 in the v4 default layout)
        rr = c.write_register(4, 750, slave=UNIT)
        assert not rr.isError()
        time.sleep(0.2)  # allow at least 2 scans at 50 ms
        out = c.read_holding_registers(0, count=1, slave=UNIT)  # %QW0
        assert not out.isError()
        assert abs(out.registers[0] - 750) <= 5
        # Out-of-range read must raise exception 02
        bad = c.read_holding_registers(9000, count=1, slave=UNIT)
        assert bad.isError()
    finally:
        c.close()

Note the sleep: because the client is asynchronous to the scan, tests must wait a bounded number of scan periods, then assert. Poll with a timeout instead of a fixed sleep for robustness. The keyword argument for the unit identifier has changed names across pymodbus major versions (unit earlier, slave in recent releases, and further changes possible), so pin the library version in your test environment.

Software-in-the-loop with a plant model

For closed-loop logic, replace the physical plant with a model that talks Modbus. A simple approach is a Python process acting as a Modbus master/client: it reads the PLC’s outputs (valve command), advances a first-order tank model, and writes the resulting level into the PLC’s input registers. In v4, input registers are read-only for network clients, so the plant model must use a writable path, for instance by writing into %MW locations that the program copies to its internal inputs, or by running a Modbus master HAL against a simulated slave. Design this seam deliberately and record it in the register map, because the production program must read the physical %IW while the test reads the %MW mirror; a single IF test_mode THEN selector keeps both paths in one program.

This is the same pattern that digital-twin work generalises: a controller under test, a plant model, and a harness that advances time. Running faster than real time is not possible with a wall-clock scan, so keep test scenarios short or parameterise the time constants.

What to assert

Assert on behaviour, not on internals: that outputs respond within N scans, that interlocks hold when a network write requests an unsafe value, that exception responses appear for out-of-range reads, and that the gateway publishes the expected topic within a deadline. Add a soak test that runs for hours at production polling rates and records scan-time statistics, which catches memory growth and thread leaks that short tests miss. Our article on LLM-generated PLC code and safety verification covers how this kind of harness also becomes the gate for machine-written code.

A Worked Configuration

Here is a small, complete example: a tank-level controller with a start/stop command from the network, a level setpoint, a pump output and a status word. The ST program and the register map below are consistent with each other, and deliberately avoid any version-specific assumptions beyond the v4 default mapping.

PROGRAM tank_ctrl
  VAR
    level      AT %IW0  : INT;     (* tenths of percent, from field *)
    pump_on    AT %QX0.0 : BOOL;   (* coil 0 *)
    run_cmd    AT %MX0.0 : BOOL;   (* coil 800 in default layout *)
    sp         AT %MW0  : INT;     (* holding reg 4, written by SCADA *)
    status     AT %MW1  : INT;     (* holding reg 5, read by SCADA *)
    seq        AT %MW2  : INT;     (* holding reg 6, heartbeat *)
    hyst       : INT := 20;
  END_VAR

  seq := seq + 1;                          (* heartbeat every scan *)

  IF sp < 0 OR sp > 1000 THEN              (* validate network input *)
    status := 3;                           (* 3 = rejected setpoint *)
    pump_on := FALSE;
  ELSIF NOT run_cmd THEN
    pump_on := FALSE;
    status := 0;
  ELSE
    IF level < sp - hyst THEN pump_on := TRUE;
    ELSIF level > sp + hyst THEN pump_on := FALSE;
    END_IF;
    status := 1;
  END_IF;
END_PROGRAM

With the default layout and the counts from the earlier example, the register map is:

Signal IEC location Modbus table and address Direction Owner
Level (0.1 %) %IW0 Input register 0 PLC to client Field I/O
Pump %QX0.0 Coil 0 PLC to client Program
Run command %MX0.0 Coil 800 Client to PLC SCADA
Setpoint %MW0 Holding register 4 Client to PLC SCADA
Status %MW1 Holding register 5 PLC to client Program
Heartbeat %MW2 Holding register 6 PLC to client Program

Three design choices are worth copying. The setpoint and run command live in %M memory, so they are network-owned and never collide with program-driven outputs. The program validates the setpoint on every scan and reports rejection via a status word instead of silently clamping. And the heartbeat counter lets the gateway detect a stalled scan, because a value that stops incrementing means the program is not running, even though the Modbus server may still happily return the last numbers.

That last point is a general rule. A Modbus server answering is not evidence that the control program is alive. Watch a counter, not a TCP socket.

Running it under systemd

For v3 on a Linux host the service ran under systemd; for v4 the runtime is typically installed as a service or container. A minimal unit with restart and hardening options:

[Unit]
Description=OpenPLC runtime
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/opt/openplc/runtime
Restart=on-failure
RestartSec=2
AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_SYS_NICE
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/lib/openplc
LimitRTPRIO=80

[Install]
WantedBy=multi-user.target

Adjust the path and capability set to your installation; the principle is restart on failure, the minimum capabilities, and a writable area limited to program storage. Restart policy is not a safety mechanism. It recovers from crashes, not from a wrong output while the process is hung, so pair it with an external watchdog (a hardware watchdog or a supervisor reading the heartbeat) that drives outputs to a safe state.

Trade-offs, Gotchas, and What Goes Wrong

The recurring failures are not exotic. They are timing assumptions, ownership conflicts on shared registers, unmaintained software on exposed networks, and the gap between “the server responds” and “the program is healthy”.

The v3 end-of-life problem. Tutorials, Docker images and SCADA templates online overwhelmingly describe v3. Starting a new project on an archived repository means no security fixes and a migration later. Prefer v4 for new work, and for existing v3 deployments plan a migration window, inventory the address mapping first, and isolate the v3 host in the meantime.

Dual ownership of registers. The most common functional bug is a location written by both program and network. Symptoms: a setpoint that reverts after a few seconds, an output that flickers. Fix it by assigning single ownership in the register map and enforcing it in review.

Scan time creep. Programs that add logging, file access, string handling or blocking serial calls inside the scan grow the cycle without warning. Track scan-time statistics as a first-class metric, alarm when the 99th percentile exceeds a fraction of the period, and keep slow work in separate threads or gateway-side code.

Word-order and offset errors. A float that reads as 1.4e-41 or 1.1e+9 is almost always the wrong register order; a value that is “one register off” is almost always the 0-versus-1 base. Both are caught on day one with known-value tests, and expensive on day 200.

Retry storms. Some HMI drivers respond to a timeout by reconnecting and re-polling immediately. Against a thread-per-connection server on a small board, a handful of such clients can exhaust threads and CPU. Configure client timeouts and back-off, and cap connections at the firewall.

False confidence in the simulator. A laptop run proves logic, not timing. A program that passes on a laptop at 1 ms scan may fail on a Pi at 50 ms. Always run the final acceptance test on the target hardware, under load, with the kernel and scheduling configuration you will deploy.

Licensing and obligations. v3 is GPL-3.0 and v4 is MIT-licensed. If you distribute a modified v3 runtime inside a product, GPL obligations attach; v4’s licence is far more permissive for commercial embedding. Check with counsel for your case; this is orientation, not legal advice.

Features that may not exist. Do not assume your build supports FC 23, more than a handful of concurrent clients, or a native OPC UA server. Check the release you actually installed, read its source if necessary, and write the answer in the design document. The openness of the project makes this possible; use it.

Practical Recommendations

Use OpenPLC where openness and inspectability are the value: teaching, prototyping, protocol gateways, test benches and low-consequence automation. Choose Runtime v4 for new work, keep the controller behind a gateway, and treat the register map as a versioned interface contract.

A checklist to apply before commissioning:

  • Confirm which runtime generation you are on, and that its security maintenance status is acceptable for the network it will join.
  • Write the register map first: address, IEC variable, type, scaling, unit, word order, direction, owner. Generate gateway and HMI configuration from it.
  • Give every register exactly one writer. Network-owned values go in %M locations the program validates before use.
  • Bind the Modbus server to a specific interface and allow-list client addresses at a host or network firewall. Never expose port 502 or the management interface to an untrusted network.
  • Decouple slow field buses into their own thread or into the gateway so the scan stays short.
  • Include a heartbeat register and monitor it from outside the PLC.
  • Measure scan time and jitter on the target under realistic load (p99 and max), and set an alarm threshold.
  • Run a PREEMPT_RT kernel, SCHED_FIFO and an isolated core if the application needs tight timing, and verify with cyclictest.
  • Keep safety functions in certified hardware, independent of the soft PLC.
  • Test with a scripted client in CI, including exception paths and soak runs.
  • Publish northbound through a gateway that adds timestamps, units and quality, and keep command paths separately authorised.

If this list feels long for a “simple” controller, that is the point. The cost of an open-source PLC is not the licence; it is the engineering discipline you must supply that a commercial vendor has pre-packaged, and the benefit is that you can see and change all of it.

Frequently Asked Questions

What is OpenPLC and is it still maintained?

OpenPLC is an open-source programmable logic controller runtime and editor that executes IEC 61131-3 programs on Linux, Windows and embedded boards. The v3 repository is archived and marked end of life; development continues in the MIT-licensed OpenPLC Runtime v4 maintained by Autonomy Logic, which uses a headless runtime with a REST API. Check the project repositories for the current release before you start.

What port does OpenPLC use for Modbus TCP?

OpenPLC’s Modbus server uses the standard Modbus TCP port 502. Because ports below 1024 need elevated privileges on Linux, the v4 documentation suggests using 1502 if binding fails, which you can combine with a firewall redirect. The v4 Modbus server is disabled by default and must be switched on in the configuration. The v3 web interface used port 8080; v4’s API uses HTTPS on 8443.

Can OpenPLC be used in production?

It can for non-safety, low-consequence applications where its timing is adequate and you accept the integration burden: pilot lines, building systems, gateways and labs. It is not certified for functional safety, and a stock Linux scan cycle has no guaranteed worst case. Measure jitter on your hardware, harden the network, and keep safety interlocks in certified equipment.

How do OpenPLC variables map to Modbus addresses?

OpenPLC maps located variables in its process image to the four Modbus tables. In v4, coils start at %QX0.0 followed by %MX bits, discrete inputs at %IX0.0, input registers at %IW0, and holding registers at %QW, then %MW, %MD and %ML, with configurable segment sizes. v3 used a different fixed layout, so verify against your version’s mapping reference.

Is Modbus TCP secure?

No. Classic Modbus TCP has no authentication, authorisation or encryption: anyone who can reach the port can read and write registers. Defend it with network segmentation, firewall allow-lists, a gateway that authenticates northbound traffic, protocol-aware monitoring and validation inside the PLC program. A Modbus/TCP Security specification with TLS exists, but device support is limited.

How do I connect OpenPLC to MQTT or OPC UA?

Use an edge gateway that polls the OpenPLC Modbus server and republishes the values. Apache PLC4X, Node-RED or a custom Python service can map registers to named, scaled, timestamped values, then publish them over MQTT (often with Sparkplug B) or expose them through an OPC UA server. Keep the gateway as the only client with write access and validate commands before forwarding them.

Further Reading

By Riju — about

2 Comments

Leave a Reply

Your email address will not be published. Required fields are marked *