A PROFINET RT IO-Device (Conformance Class A, RT class 1) in pure Rust, for Linux
PREEMPT_RT. It lets an edge computer sit on a PLC's PROFINET IO system like any other
field device — the controller (an S7‑1500 in our bench) exchanges cyclic process data with it
every 500 µs or 1 ms, sees its diagnosis alarms, reads its I&M identification, and
you program the device side in Rust with typed reads and writes.
Validated byte‑exact against captures of a real S7‑1515‑2 PN (TIA Portal V21) and in hardware‑in‑the‑loop: a 12 h 51 soak at 500 µs with 0 missed cycles, tick lateness p99.99 ≈ 20 µs on a 4‑core Atom.
Disclaimer. Community project, not affiliated with, endorsed or certified by PROFIBUS & PROFINET International (PI). "PROFINET" is a registered trademark of PNO. This is a clean‑room implementation from the public IEC 61158/61784 standards and from Wireshark captures; no normative text is reproduced.
- Cyclic real‑time exchange (
rt): PPM/CPM with IOPS/IOCS, cycle counter, consumer watchdog, data status (incl. the station problem indicator), on aSCHED_FIFOthread pinned to an isolated core, memory locked. 500 µs and 1 ms send clocks held against a real controller. - AR establishment (
cm,rpc): DCE‑RPC Connect / Write / PrmEnd / ApplicationReady / Release, controller reconnect, record reads and writes. - Discovery (
dcp): Identify and Set‑IP on the device side. - Alarm channel (
alarm): RTA codec and a sender/receiver with retries, deduplication and ERR‑RTA in both directions — the controller learns about a device stop within a millisecond instead of waiting for its watchdog. - Channel diagnosis (
diag):raise_diagnosis/clear_diagnosisfrom your code → the CPU's diagnostic buffer, OB82 and the device fault state; re‑announced after an AR loss. - I&M records (
im): I&M0 from the device configuration, I&M1‑3 readable/writable with an optional file store. - Typed configuration → GSDML (
config,gsdml): declare slots and fields in Rust, render the GSDML V2.4 TIA imports from the same object — no drift between what the controller expects and what the device does. - A small facade (
api::IoDevice): typedBOOL/INT/WORD/DINT/REALreads and writes, per‑cycle‑consistent slot snapshots, freshness/validity of the controller's data.
cargo add pnio # https://crates.io/crates/pnio — API docs: https://docs.rs/pnio
# or, to hack on it:
git clone https://github.com/core-engineering/pnio-rs.git
cd pnio-rs
cargo test # unit suite + byte-exact capture replays
cargo run --example gen_gsdml -- --out . # the GSDML matching the sample declarationLinux only: the AF_PACKET backend needs CAP_NET_RAW/CAP_NET_ADMIN at runtime (plus
CAP_SYS_NICE/CAP_IPC_LOCK for the real‑time thread); tests that need a real socket are
#[ignore]d.
Declare the device's process data, start it, exchange typed values:
use pnio::api::{IoDevice, StartOptions};
use pnio::config::{DeviceConfig, Slot};
use pnio::data::FieldType;
use pnio::device::RtOptions;
let cfg = DeviceConfig::builder("pnio-dev")
.input(Slot(1), &[FieldType::Real; 16]) // device -> controller (%I)
.output(Slot(3), &[FieldType::Real; 16]) // controller -> device (%Q)
.build()
.expect("valid declaration");
let dev = IoDevice::start(cfg, StartOptions {
iface: "eno2".into(),
ip: [172, 16, 2, 10],
rt: Some(RtOptions { iface: "eno2".into(), cpu_pin: Some(3), rt_priority: Some(80), lock_memory: true }),
app_cpus: Some(vec![0, 1, 2]),
im_store: Some("/var/lib/pnio/im.bin".into()), // persists the I&M1-3 written by TIA
}).expect("start (needs cap_net_raw/cap_net_admin/cap_sys_nice/cap_ipc_lock)");
while !dev.ready() {
std::thread::sleep(std::time::Duration::from_millis(5));
}
// Echo the controller's first REAL back into our first input, consistently per cycle.
if let Ok(out) = dev.outputs(Slot(3)) {
dev.with_inputs(Slot(1), |w| w.real(0, out.real(0)?)).ok();
}Tell the controller something is wrong, then that it is fixed:
use pnio::diag::{ChannelError, Severity};
dev.raise_diagnosis(Slot(1), 0, ChannelError::LineBreak, Severity::Fault)?;
// -> "Wire break on input channel 0" in the CPU's diagnostic buffer, device shown in fault,
// ProblemIndicator cleared in every cyclic frame until:
dev.clear_diagnosis(Slot(1), 0, ChannelError::LineBreak)?;IoDevice::alarm_stats() mirrors the live alarm channel (counters restart with each AR);
alarm_rx_no_channel() is the one process‑wide counter. The GSDML for your declaration comes from
pnio::gsdml::render (see the gen_gsdml example and docs/gsdml.md for the
TIA import and XSD validation steps).
⚠️ Identity. The builder's default identity (vendor_id = 0xFFFF,device_id = 0x0001) is a development value, not a PI‑assigned ID. Set.identity(vendor_id, device_id)before any deployment outside the lab.
| Area | Module | Status |
|---|---|---|
L2 framing, AF_PACKET transport, BPF filters, mock |
eth |
✅ |
| DCP Identify (name / all), Set‑IP | dcp |
✅ — Set‑name, Signal, Reset‑to‑factory: see Limitations |
| DCE‑RPC CL codec, UDP transport | rpc |
✅ |
| AR state machine, records, reconnect | cm |
✅ HIL |
| Cyclic exchange, watchdog, RT thread | rt |
✅ HIL — 500 µs / 1 ms, 0 missed ticks |
| Alarm channel (RTA, retries, ERR‑RTA) | alarm |
✅ HIL — byte‑exact vs a p‑net capture |
| Channel diagnosis, problem indicator, replay | diag |
✅ HIL |
| I&M0 read; I&M1‑3 read/write + file store | im, cm::records |
✅ I&M0 HIL; I&M1‑3 unit‑tested (see Limitations) |
| Typed configuration, generated GSDML | config, gsdml |
✅ HIL — imported by TIA V21 |
| Device facade, typed I/O | api |
✅ HIL |
pcap/pcapng replay harness (feature capture, default on) |
capture |
✅ |
Measured on the bench (Atom E3940, Debian 13 PREEMPT_RT, S7‑1515‑2 PN, TIA V21, X1 port at
500 µs): tick lateness p99.99 13‑22 µs / max ≤ 66 µs, 12 h 51 soak with 0 anomalies over 79.7 M
application samples, controller‑side abort detected in 0.65 ms on device stop. Full reports:
docs/bench-pnet-device.md.
- No DCP Set‑NameOfStation, Signal (LED flash) or Reset‑to‑factory: the station name comes from the configuration; TIA's Assign PROFINET device name is not served yet.
- No process alarms (OB40), manufacturer‑specific diagnosis codes, plug/pull alarms,
ModuleDiffBlock, or diagnosis record reads (0x800x,0xF8xx— answered "invalid index"). - I&M1‑3 writes were never issued by TIA V21 to this device (GSDML
PNIO_Version="V2.3"); the write path is unit‑tested only. - RT class 1 only: no IRT, no MRP, no LLDP/PTP topology (the V2.31+ GSDML profile).
- Linux only, single AR, development Vendor ID — not PI‑certified.
Pure Rust, four runtime dependencies (libc, nix, log, thiserror; pcap-file behind the
capture feature). Everything on the wire is big‑endian, exactly as in Siemens memory. Two
threads: an acyclic loop (DCP, RPC/AR, alarms, records) and a real‑time loop (cyclic
frames only — one atomic load, no allocation, no lock held across a syscall) that share a
per‑cycle‑consistent I/O image. unsafe is confined to the raw‑socket and scheduler modules
(eth::afpacket, eth::poll, rt::sched, rt::runner); the rest of the crate is
#![deny(unsafe_code)].
Design documents: docs/design/ — the overall device design and one design note
per subsystem (AR, cyclic exchange, 1 ms determinism, configuration/GSDML/API, alarms/diagnosis/I&M).
Derived from the public IEC standards (IEC 61158‑6‑10 protocol, 61158‑5‑10 services,
61784‑2‑3 RT profiles) and from Wireshark captures of real traffic. Every frame the device
emits that has a real counterpart is pinned byte‑exact as a "golden frame" with its provenance
(docs/dcp-golden-frames.md,
docs/cm-golden-frames.md,
docs/alarm-golden-frames.md). No copyleft code is included.
CHANGELOG.md— releases and known limitationsdocs/design/— design documents;docs/plans/— the task‑by‑task implementation plans they were built fromdocs/gsdml.md— typed configuration, generated GSDML, TIA import, XSD validationdocs/bench-pnet-device.md— bench setup and every HIL report;bench/README.md— campaign scriptsFOLLOWUPS.md— open points, by subsystemCONTRIBUTING.md
0.1 (this): CC‑A device with alarms, diagnosis and I&M, validated on an S7‑1500. Next: the
V2.31+ GSDML profile (LLDP, PTP/DCP boundaries, reset‑to‑factory, certification info), DCP
Set‑name/Signal, process alarms, then the 250 µs work (PACKET_MMAP/busy‑poll).
Your choice: MIT or Apache‑2.0 (SPDX: MIT OR Apache-2.0).
Unless stated otherwise, any contribution submitted for inclusion is under this dual license.