Phoenix Contact PLCnext (experimental)
A PLCnext controller is a PREEMPT_RT Linux box with an Axioline bus, and
Phoenix Contact publishes the pieces needed to run something other than
its own IEC engine on it. nautilus uses those pieces: the controller runs
the same static naut binary as a laptop, Phoenix Contact’s
BusConductor configures the bus
at start, and the driver exchanges the process image either through the
firmware’s own data-access service or, faster, through a small C process
the firmware starts beside its own (the in-process
tier). PLCnext Engineer is never opened, and
no IEC program of Phoenix Contact’s runs.
What you need
Section titled “What you need”- A PLCnext controller with its Axioline modules on the local bus, and
SSH as
admin(on by default). - BusConductor, from its upstream repository, plus three controller-side
files from this repository’s
examples/plcnext-bench/controller/. nautfor ARMv7: thelinux_armv7archive from the release page, orCGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build ./cmd/naut.
How the I/O reaches nautilus
Section titled “How the I/O reaches nautilus”Three facts about the firmware decide the shape. Each was found by trying it, on 2023.0:
- The data-access service resolves MainProcess ports only (eCLR
variables, ESM, components such as BusConductor’s). The I/O
components’ own ports answer
DAE_NotExistsunder every spelling, so I/O reaches a service only through a GDS connector into a port the service can see. - It reads and writes array elements by name (
Port[17]) and refuses a whole array. The driver reads the bytes its bindings reach as one multi-item read per scan and flushes the output span as one write. - BusConductor’s requests act on a rising edge sampled every 100 ms. The driver holds each request low, raises it, leaves it high, and skips a bus that is already configured and running, so a reconnect never restarts good I/O.
The connector needs no Engineer project: a bare controller’s eCLR exposes
two 512-byte PROFINET-device arrays, Arp.Plc.Eclr/PND_S1_INPUTS and
PND_S1_OUTPUTS, which are unused on a controller that is not a PROFINET
device. The example’s PCWE.gds.config re-points those two connectors at
the Axioline images, and the driver’s defaults read and write the mirrors.
Set up the controller
Section titled “Set up the controller”BusConductor goes in as its own project directory, so the factory project is untouched and rollback is one symlink:
git clone https://github.com/PLCnext/BusConductor /tmp/bcmkdir -p /tmp/bcp && cp -r /tmp/bc/generic_axioline/. /tmp/bc/no_engineer/. /tmp/bcp/cp examples/plcnext-bench/controller/PCWE.gds.config /tmp/bcp/Plc/Gds/cp examples/plcnext-bench/controller/pcwe.plm.config /tmp/bcp/Plc/Plm/cp examples/plcnext-bench/controller/PCWE.opcua.config /tmp/bcp/Services/OpcUA/ # optionaltar czf bc.tgz -C /tmp/bcp . && scp bc.tgz admin@<controller>:/opt/plcnext/
ssh admin@<controller>mkdir -p /opt/plcnext/projects/BusConductortar xzf /opt/plcnext/bc.tgz -C /opt/plcnext/projects/BusConductorcd /opt/plcnext/projects && rm current && ln -s BusConductor currentsudo /etc/init.d/plcnext restart # about a minuteadmin’s sudo is an allowlist: the runtime’s init script is on it, and
the projects directory is admin-owned, so the symlink needs no sudo.
Rollback: point current back at PCWE and restart.
Run nautilus
Section titled “Run nautilus”scp naut admin@<controller>:/opt/plcnext/apps/nautilus/nautssh admin@<controller> '/opt/plcnext/apps/nautilus/naut plcnext browse'browse brings the bus up the way the driver does (idempotently), prints
CONFIGURED, NUM_MODULES and the bus RUN bit, and dumps the first
bytes of both images, so a manifest’s offsets can be checked against the
modules actually there. Then the project:
driver: type: plcnext manifest: plcnext_manifest.yaml scan-rate: 50msThe manifest lists the modules in bus order and binds tags to byte
offsets and channel, bit or integer formats; Write the
manifest says how, and how naut plcnext import
writes it for you.
naut plcnext read <port> and naut plcnext write <port> <value> read
or write any GDS name the service will answer for, which is the way to
learn whether a name exists before a manifest binds it.
Measured on the bench unit with the plcnext-bench example (16 inputs
mirrored, a 1 Hz heartbeat, 50 ms scan): 9 % of one core and 23 MB
resident. Each scan’s exchange is one round trip over the local
socket, about 11 ms for this manifest. That is remote-I/O-grade timing,
the same tier as an Engineer program reading the GDS from a
non-real-time thread.
Write the manifest
Section titled “Write the manifest”In BusConductor’s generic mode every module takes its frame length in both images, in bus order: a DO16 takes two bytes of the input image it never writes, and a DI16 two bytes of the output image. So a module’s offset is the sum of the frame lengths before it, the same in both images.
A digital module numbers its channels byte by byte: channel 01 is
bit 0 of the module’s first byte, channel 09 bit 0 of its second. The
ch:N format is that number, with the module’s first byte as the
offset, so a binding reads like the terminal label:
modules: - { slot: 1, part: "AXL SE DO16/1", in: 2, out: 2 } - { slot: 2, part: "AXL SE DI16/1", in: 2, out: 2 }tags: - { name: DI_00, dir: in, offset: 2, format: "ch:1" } # DI16 terminal 01 - { name: DO_15, dir: out, offset: 0, format: "ch:16", init: false } # DO16 terminal 16ch:01 is accepted too. With a module list, a channel that runs past
its module (ch:17 on a DI16) is refused by naut check. The older
bit:N still works and means bit N of the big-endian 16-bit word at the
offset, so channel n of a 16-channel module is bit:((n+7) mod 16)
(channel 01 is bit:8): the same bits, harder to get right. Numbers
are uint8, int8, uint16, int16, uint32, int32 and float32,
big-endian unless byte-order: little.
From the device list
Section titled “From the device list”naut plcnext import writes the module list, every offset and a binding
for every channel from the bus’s device list: the Axioline device
types in bus order, slot 1 next to the controller.
devices: - { type: 0x00400002, desc: "K2.1" } # AXL SE DO16/1 - { type: 0x00800002, desc: "K2.2" } # AXL SE DI16/1 - { type: 0x00000100 } # no process data - { type: 0x00200008 } # AXL SE AI4 Unaut plcnext import plcnext_devices.yaml --transport shmnaut check .It writes plcnext_manifest.yaml and tags/plcnext.yaml (and refuses
to replace either if it holds something else, unless --force).
Digital channels come out as ch:N, analog inputs as int16 raw
counts. The types it knows are the four on the bench unit:
| device type | laid out as | bytes, both images |
|---|---|---|
0x00400002 | AXL SE DO16/1, 16 digital outputs | 2 |
0x00800002 | AXL SE DI16/1, 16 digital inputs | 2 |
0x00000100 | no process data | 0 |
0x00200008 | AXL SE AI4 U, 4 analog inputs | 8 |
Any other type needs its frame length in bits (length: 16, as a TIC
file’s DeviceLength or the data sheet’s process-data width gives it):
the import lays it out so the modules after it land right, and leaves a
commented example binding for you to finish. A device type names a
process-data shape, not a part, so another module that reports one of
these types gets the same layout under the bench part’s name; part:
in the list names it yourself. Types are always hexadecimal (00400002
as a TIC file writes it means the same).
The firmware does not hand naut the device list. BusConductor reads
every device’s type and publishes only how many there are
(NUM_MODULES), and the Axioline master’s own configuration RPCs answer
Unimplemented on 2023.0. So the list is a file you write, from the
modules’ data sheets, the TIC files an earlier Engineer or IoConf
project left, or the labels; --check-bus, run on the controller,
compares its length with NUM_MODULES without touching the bus.
Either way, check a new layout with eyes on one output LED and one input
before trusting it: the bytes alone do not say which module is which.
Faster: the in-process tier
Section titled “Faster: the in-process tier”The firmware refreshes the Axioline images every 500 µs; a gRPC round
trip takes about 11 ms. To reach the images at the bus rate, the
repository has rt/plcnext-io: a small C process, nautilus_io, that
the firmware’s own application framework starts, which reads and writes
the Axioline frames every millisecond through the ANSI-C API the
firmware ships (the way Phoenix Contact’s SampleRuntime does, built
with zig and no PLCnext SDK). It shares them with naut through a
shared-memory page. Each CLI release attaches it built, as
nautilus-plcnext-io_<version>_linux_armv7.tar.gz (the binary, its two
ACF files and the README); deploy it as rt/plcnext-io/README.md says,
or build it yourself with rt/plcnext-io/build.sh. Once it runs, it is the output frame’s only writer, so the
project’s PCWE.gds.config must have no connector into DO4096. Then:
transport: shm # default grpcoutput-watchdog: 1s # the default; "0" holds the last outputsThe bindings and the tag file do not change; only the bytes’ route
does. The BusConductor handshake still goes over gRPC. Every scan the
driver copies the inputs out of the page and its outputs into it; the
shim writes the frame on its next cycle. If naut stops refreshing the
page (a crash, a kill, a redeploy), the shim drives the output frame to
zero once naut’s outputs are older than output-watchdog. If the
runtime restarts, the shim does too, and the driver notices the new
start and runs the handshake again.
Measured on the bench unit with plcnext-bench at a 50 ms scan:
transport: grpc | transport: shm | |
|---|---|---|
| time per scan for the image | ~11 ms | 0.03–0.05 ms |
| input age when the program reads it | the RPC plus up to 500 µs | 0.1–1.1 ms |
| naut CPU (of one core) | 9 % | 4 % |
| naut resident | 23 MB | 20 MB |
| shim CPU / resident | — | ~3 % / 17 MB |
A 5 ms scan works on shm too, at 21.5 % of a core for naut.
naut plcnext shm prints the page: whether the PLC has started, the
bus RUN bit, the shim’s cycle, priority and CPU, its lateness counters,
and both frames with who wrote them and how fresh they are. --watch 250ms prints a line per interval, which is the way to see an output
toggle without a meter on the terminals. naut plcnext bench measures
one window of the shim’s lateness and naut’s scan lateness and CPU.
Real-time priority
Section titled “Real-time priority”Out of the box the shim runs best-effort: the firmware’s own process
holds the Linux capability to ask for SCHED_FIFO, and the processes it
starts do not inherit it. Pin the shim to CPU 1 (echo 1 > io/nautilus_io.cpu beside the binary) and naut to CPU 0: CPU 0 carries
the firmware’s periodic real-time tasks, and naut’s threads sharing the
shim’s core were most of its lateness.
To run the shim at SCHED_FIFO, the controller’s owner gives both
binaries the cap_sys_nice file capability once, as root
(rt/plcnext-io/README.md has the one-line recipe, which sets a root
password, uses it, and locks root again). The capability belongs to
the file: copying a new build into place drops it, and the step is
needed again. Without it both programs run best-effort and say so in the
shim’s log, the page, and naut’s scan.sched. With it the shim asks for
priority 80 (a nautilus_io.prio file asks for another), and a naut
task may set priority:.
Shim lateness on the bench unit, 3-minute windows, naut at a 50 ms scan on CPU 0, late meaning more than 200 µs past the shim’s 1 ms deadline, “worst” the worst 10 000-cycle window:
| shim on CPU 1 | quiet | two busy loops | a stream into the Ethernet over ssh |
|---|---|---|---|
| best-effort | 0.13 % late, worst 2.6 ms | 0.20 %, worst 0.95 ms | 33 %, worst 9.6 ms |
SCHED_FIFO 80 | none late, worst 51 µs | none late, worst 154 µs | 3.2 %, worst under 1 ms |
At FIFO 80 nothing was late by more than a millisecond in any run, so the 1 ms cycle holds. The 200 µs line does not hold while someone streams into the controller’s Ethernet: the network receive task outranks the shim on its core.
Holding outputs until an operator acknowledges
Section titled “Holding outputs until an operator acknowledges”When the image is fresh again, after a stall, a lost connection or a restart, the driver drives the outputs to their commands again on its own. A program that should keep them off until someone has looked can read the driver’s status, bound in the manifest like any other tag:
- { name: AXC__Online, status: online } - { name: AXC__Outages, status: outages } - { name: AXC__WatchdogTrips, status: watchdog-trips }naut plcnext tags puts them in the tag file as inputs. __Online
(BOOL) is true while the inputs are good: the image is fresh and the
bus runs. The Axioline image is local I/O, so a channel input the driver
has not delivered yet still faults the scan that reads it, unlike a
protocol driver’s input, which reads as zero until delivered (see Inputs
from a device that is
down). __Outages (DINT) counts the times it went false.
__WatchdogTrips (DINT) counts the times the output frame went to zero
without the driver choosing it: naut stalled for longer than
output-watchdog between two writes to the page, the shim reported its
watchdog had fired, or the shim restarted. A naut killed and restarted
by the keepalive finds the shim’s watchdog bit set by its predecessor’s
outputs, so it starts with __WatchdogTrips at 1: the program sees
“restarted after a fault” from its first scans. A first start on a page
no naut has written counts nothing. On transport: grpc there is no
output watchdog, and the count stays 0.
The values are there from the first scan, always with good quality, and named the same way on every driver that has them. The counters only go up, so a program compares them with last scan’s values and seals in a hold:
VAR_EXTERNAL AXC__Online : BOOL; AXC__Outages : DINT; AXC__WatchdogTrips : DINT; OperatorAck : BOOL; (* a setpoint: the HMI's acknowledge button *)END_VARVAR held : BOOL; seenOutages : DINT; seenTrips : DINT; ackEdge : R_TRIG;END_VAR
ackEdge(CLK := OperatorAck);IF ackEdge.Q AND AXC__Online THEN held := FALSE; (* released only once the I/O is back *)END_IF;IF AXC__Outages <> seenOutages OR AXC__WatchdogTrips <> seenTrips THEN held := TRUE; (* sealed in on any new outage or trip *)END_IF;seenOutages := AXC__Outages;seenTrips := AXC__WatchdogTrips;
Pump1_Run := Pump1_Cmd AND NOT held;examples/plcnext-bench does this for its outputs with
lib/io_hold.st, switched on by a HoldAfterOutage setpoint (off at
start: with it on, every redeploy that outlasts the shim’s 1 s watchdog
holds until acknowledged), and its naut test suite covers the hold,
the acknowledge, the restart case and the hold switched off.
Start on boot
Section titled “Start on boot”The controller has no systemd, and admin cannot write /etc/init.d,
but it may keep a crontab, and cron starts before the PLCnext runtime.
examples/plcnext-bench/controller/naut-keepalive.sh starts naut run
for the project unless it is already running, pinned to the CPU in
naut.cpu if that file exists:
ssh admin@<controller> 'printf "@reboot %s\n* * * * * %s\n" \ /opt/plcnext/apps/nautilus/plcnext-bench/controller/naut-keepalive.sh \ /opt/plcnext/apps/nautilus/plcnext-bench/controller/naut-keepalive.sh | crontab -'It starts naut at boot and restarts it within a minute of a crash;
crontab -r takes it out. naut need not wait for the runtime: the
driver retries the socket and the page. The shim starts with the
runtime, because the firmware starts it. On the bench, after a reboot
naut, the shim (FIFO 80, capabilities intact) and the heartbeat were all
back about a minute after the controller answered ssh.
What it does not do yet
Section titled “What it does not do yet”- Per-channel quality. The driver has one quality for the whole
image: the bus
RUNbit. False makes every inputbad. - The status tags on the controller.
__Online,__Outagesand__WatchdogTripsare tested against an in-process fake of the shim, not yet on the bench unit. - Holding outputs without a program. The acknowledge lives in your program; there is no manifest setting that keeps the frame at zero until one is written.
- Real-time priority without root.
SCHED_FIFOneeds the owner’s one-time root step, and again after every deploy of either binary. - Import from a bus scan.
naut plcnext importworks from a device list you write; the firmware publishes only the module count, which--check-buscompares. Neither has been run on the controller yet. - Analog modules without their supply. An Axioline module whose UP
supply is not wired reports Axioline’s supply-missing marker (
0x8020) as its reading; the driver passes it through as a value. - Other firmware versions. The three facts above were measured on 2023.0 LTS.
- Support. Phoenix Contact supports its own runtime. A third-party runtime on the controller is yours (or your integrator’s) to support.
Where the evidence is
Section titled “Where the evidence is”examples/plcnext-bench in the repository is the bench project, with the
controller-side files and an acceptance suite (naut test . runs with no
controller). rt/plcnext-io is the shim, with its build, the exchange
page’s layout and the full lateness tables. docs/design/open-controllers.md
has the measurements and the boot log lines that show each step working.