Runtime Engine¶
This public entry covers the runtime host/engine side: clocks, tasks, retain, launching, process image handling, and online change plumbing.
Use the full spec below when you need engine/deployment mechanics rather than language-level runtime semantics.
Related: Runtime Semantics, Bytecode Format
Runtime Engine¶
Status: Implemented architecture. Production runtime executes STBC bytecode through the VM only; helper evaluation remains for bounded const/debug/config flows and the old evaluator internals are test-only.
1. Purpose¶
This document specifies the architecture for a portable Structured Text (ST) runtime capable of executing IEC 61131-3 compliant programs. The initial implementation targets desktop operating systems (Linux, Windows, macOS); embedded support is planned.
2. Design Goals¶
| Goal | Description |
|---|---|
| Portability | Single runtime codebase runs on desktop and embedded targets |
| Determinism | Predictable scan cycle execution suitable for automation |
| IEC Compliance | Align task scheduling and execution semantics with IEC 61131-3 Ed.3 |
| Simplicity | Minimal clock abstraction surface |
| Testability | Full runtime testable on desktop without hardware |
3. Architecture Overview¶
┌──────────────────────────────────────────────────┐
│ ST Program (Bytecode) │
└──────────────────────┬───────────────────────────┘
▼
┌──────────────────────────────────────────────────┐
│ ST Runtime Core │
│ ┌────────────┐ ┌──────────────────┐ ┌──────────────┐ │
│ │ Executor │ │Resource Scheduler│ │ Timer System │ │
│ └────────────┘ └──────────────────┘ └──────────────┘ │
│ ┌────────────────────────────────────────────┐ │
│ │ Process Image │ │
│ └────────────────────────────────────────────┘ │
└──────────────────────┬───────────────────────────┘
▼
┌──────────────────────────────────────────────────┐
│ Clock Trait │
└───────────┬──────────────────────┬───────────────┘
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ StdClock │ │ ManualClock │
│ (Linux/Win/Mac) │ │ (Tests/Sim) │
└───────────────────┘ └───────────────────┘
4. Clock Abstraction Layer¶
4.1 Rationale¶
The runtime requires only a monotonic clock and a way to sleep until a deadline. Rather than abstracting entire operating systems, we abstract only what the scheduler actually uses. This keeps the abstraction minimal and each clock implementation small.
4.2 Clock Trait Definition¶
pub trait Clock: Send + Sync + 'static {
/// Returns monotonic time for scheduling (nanosecond Duration).
fn now(&self) -> Duration;
/// Sleeps until a target time. Used only by real resource threads.
fn sleep_until(&self, deadline: Duration);
/// Wake any sleepers (best-effort).
fn wake(&self) { /* optional */ }
}
The runtime scheduler uses a Clock for time and pacing. Thread creation and mutexing are handled by Rust’s standard library, keeping the abstraction surface minimal.
4.3 Why Only a Clock¶
| Operation | Justification |
|---|---|
now |
Required for scan cycle timing and IEC timers (TON, TOF, TP) |
sleep_until |
Paces resource cycles in real threads |
wake |
Allows clean shutdown of resource threads |
Notably absent: file I/O (bytecode loaded at init), networking (handled separately via I/O abstraction), explicit mutex APIs (runtime uses RwLock/Mutex internally), dynamic allocation in hot path.
5. Clock Implementations¶
5.1 StdClock (Desktop)¶
Targets: Linux, Windows, macOS
Implementation: Uses Rust standard library (Instant, thread::sleep).
pub struct StdClock {
start: Instant,
}
impl Clock for StdClock {
fn now(&self) -> Duration {
let elapsed = self.start.elapsed();
let nanos = i64::try_from(elapsed.as_nanos()).unwrap_or(i64::MAX);
Duration::from_nanos(nanos)
}
fn sleep_until(&self, deadline: Duration) {
let now = self.now();
let delta = deadline.as_nanos() - now.as_nanos();
if delta <= 0 {
return;
}
let delta = u64::try_from(delta).unwrap_or(u64::MAX);
thread::sleep(std::time::Duration::from_nanos(delta));
}
}
Justification: Rust’s standard library already abstracts Linux/Windows/macOS differences. Task priority is enforced by the runtime scheduler; OS thread priority is best-effort only and may be ignored.
5.2 ManualClock (Tests)¶
Deterministic clock for unit tests and simulation. Time advances explicitly; no real sleeping occurs. Used by scheduler tests and trace reproducibility checks.
5.3 Embedded Clock (Planned)¶
An RTOS-backed clock (e.g., FreeRTOS) is planned for embedded targets. The runtime core remains unchanged; only the Clock implementation differs.
6. Runtime Components¶
6.1 Executor¶
Interprets compiled ST bytecode. Operates on the process image. Pure computation with no platform dependencies.
Design decisions: - Stack-based bytecode VM (simpler than register-based) - No heap allocation during execution (predictable timing) - All state in process image (inspectable, serializable)
6.2 Task Manager (Resource Scheduler)¶
Implements IEC 61131-3 task scheduling and program organization unit (POU) associations (IEC 61131-3 Ed.3, §6.8.2; Tables 62–63).
Each IEC resource runs inside a dedicated scheduler loop. The scheduler is executed on an OS thread started via std::thread::spawn; IEC tasks are not OS threads.
IEC task model: - Tasks are periodic (INTERVAL) or event-driven (SINGLE rising edge). (IEC 61131-3 Ed.3, §6.8.2 a–b) - If INTERVAL is non-zero, periodic scheduling occurs only while SINGLE is 0. (IEC 61131-3 Ed.3, §6.8.2 b) - If INTERVAL is zero, no periodic scheduling occurs. (IEC 61131-3 Ed.3, §6.8.2 b) - PRIORITY establishes scheduling order with 0 as highest priority and larger numbers as lower priority. (IEC 61131-3 Ed.3, §6.8.2 c; Table 63) - A program with no task association executes once per resource cycle at the lowest priority. (IEC 61131-3 Ed.3, §6.8.2 d) - A function block instance associated with a task executes only under that task, independent of program evaluation rules. (IEC 61131-3 Ed.3, §6.8.2 e)
Scheduling policy (implementer choice permitted by IEC 61131-3, §6.8.2 c): - Deterministic, non-preemptive, fixed-priority scheduling per resource. - Ready tasks at the same priority run in FIFO order by longest waiting time. - Event tasks are edge-detected on the SINGLE input and enqueue one activation per rising edge.
pub struct TaskConfig {
pub name: String,
pub interval: Duration, // INTERVAL; zero disables periodic scheduling
pub single: Option<String>, // SINGLE variable name (event + gating)
pub priority: u32, // 0 = highest priority per IEC 61131-3
pub programs: Vec<ProgramId>,
pub fb_instances: Vec<ValueRef>,
}
pub struct ResourceRunner<C: Clock + Clone> {
runtime: Runtime,
clock: C,
cycle_interval: Duration,
}
impl<C: Clock + Clone> ResourceRunner<C> {
pub fn tick(&mut self) -> Result<(), RuntimeError> {
// single deterministic cycle (tests)
Ok(())
}
pub fn spawn(self, name: &str) -> ResourceHandle<C> {
// start dedicated OS thread
}
}
Implementation notes:
- The SINGLE input is sampled from the current variable state; a transition 0 -> 1 enqueues exactly one activation.
- On task registration, the runtime initializes the previous SINGLE value to avoid a spurious edge on the first cycle.
- Periodic scheduling uses Clock::now() and the task interval (nanosecond Duration).
- Inputs are latched at the start of each scheduler cycle; outputs are committed after all ready tasks complete.
- The maximum number of tasks per resource and minimum interval resolution are implementer-specific and are reported by the runtime configuration.
- The resource loop maintains a RUNNING/FAULT/STOPPED state and halts on faults.
6.3 Timer System¶
Implements IEC 61131-3 timers: TON (on-delay), TOF (off-delay), TP (pulse).
All timers use Clock::now() for elapsed time calculation. Timer instances are evaluated when their owning program or task-associated function block executes; no background threads or interrupts are required.
6.4 Process Image¶
Memory-mapped area for inputs (%I), outputs (%Q), and markers (%M).
pub struct IoInterface {
inputs: Vec<u8>,
outputs: Vec<u8>,
memory: Vec<u8>,
}
Sizes are derived from compiled program metadata at load time. On embedded targets, static sizing may be used, but the logical model remains the same.
The process image is owned by a single resource thread; no internal locking is required. Cross-resource data sharing is synchronized through the configuration-level shared globals lock (see 6.7). External I/O exchange (Modbus, etc.) reads/writes to this image at cycle boundaries.
6.5 I/O Drivers¶
I/O exchange is explicit and deterministic: inputs are read into the input image at the start of each resource cycle, and outputs are written after all ready tasks complete.
Marker bindings (%M) are synchronized with program storage at both cycle boundaries:
- Start of cycle: %M process image -> bound variables (same phase as %I input latch).
- End of cycle: bound variables -> %M process image (same phase as %Q output commit).
pub trait IoDriver: Send {
fn read_inputs(&mut self, inputs: &mut [u8]) -> Result<(), RuntimeError>;
fn write_outputs(&mut self, outputs: &[u8]) -> Result<(), RuntimeError>;
fn health(&self) -> IoDriverHealth { IoDriverHealth::Ok }
}
Multiple drivers may be composed (e.g., fieldbus + simulated I/O). The resource scheduler owns the driver(s) and invokes them at cycle boundaries.
Driver error handling is configurable per driver:
- fault: return an error and fault the resource.
- warn: keep the resource running; driver health becomes degraded.
- ignore: keep the resource running; error is suppressed (health may still degrade).
Driver health is exposed via ctl status and the TUI.
Built-in drivers
- Modbus/TCP
- Uses input registers (0x04) for input image.
- Uses holding registers (0x10) for output image.
- Register payloads are big‑endian (high byte first).
-
Register quantity is derived from the process image size (
ceil(bytes / 2)). -
MQTT (baseline profile)
- Topic bridge between broker payloads and process image bytes.
topic_inpayload bytes are copied into%Iat cycle start.%Qoutput bytes are published totopic_outat cycle end.- Reconnection is non-blocking; runtime cycle remains deterministic.
-
Security baseline rejects insecure remote brokers unless explicitly overridden.
-
EtherCAT (backend v1)
- Driver name:
ethercat. - Deterministic process-image mapping for module-chain profiles (including
EK1100+ digital I/O modules such asEL1008/EL2008). - Startup discovery diagnostics emit discovered module summary and expected process-image sizes.
- Cycle-time health telemetry upgrades driver status to degraded when cycle read/write exceeds configured warning threshold.
- Non-mock adapters are backed by EtherCrab hardware transport on unix targets.
- Deterministic
adapter = "mock"mode is available for CI/offline validation. - Explicit v1 non-goals: no functional safety/SIL claims and no advanced motion profile support.
Protocol roadmap priority after OPC UA baseline: - First: MQTT - Next: EtherNet/IP
6.6 Fault, Overrun, and Watchdog Handling¶
The runtime traps execution faults and reports them through a unified fault channel. By default, a fault transitions the resource into a FAULT state and halts further task execution until restarted.
Faults include: - Arithmetic errors (e.g., divide by zero) - Out-of-bounds accesses - Invalid type conversions - FOR loops with a step expression that evaluates to 0 (guarded by bytecode and treated as a runtime fault) - Task overruns (missed deadlines)
Overrun policy (default): if a periodic task misses its deadline, the missed activation is dropped, the overrun counter increments, and the task is eligible again on the next interval boundary.
Watchdog policy (production):
- A watchdog monitors cycle/task execution time.
- If the watchdog timeout elapses, the runtime raises a FAULT and halts the resource.
- Timeout thresholds and fault action are configured per resource (see §6.9) and are
implementer-specific in IEC 61131-3 (recorded in docs/IEC_DEVIATIONS.md).
- Default action is safe_halt: outputs are set to configured safe values (if provided),
then the resource halts. For halt and safe_halt, safe-state outputs are applied
before halting.
6.7 Retain Storage (IEC 61131-3 §6.5.6)¶
Retentive variables must follow IEC 61131-3 retentive variable rules (§6.5.6, Figure 9). At startup:
- Warm restart: RETAIN variables restore their retained values; NON_RETAIN are initialized.
- Cold restart: RETAIN and NON_RETAIN variables are initialized.
- Unqualified variables follow the runtime's retain policy (see
docs/IEC_DECISIONS.md). VAR_STATfollows the documented vendor-extension storage rules fromdocs/IEC_DEVIATIONS.md: function statics persist across calls, method statics persist per instance and per method, andPROGRAM/FUNCTION_BLOCK/CLASSVAR_STATuses ordinary instance storage.
Retain storage is provided via a pluggable backend:
pub trait RetainStore: Send {
fn load(&self) -> Result<RetainImage, RuntimeError>;
fn store(&self, image: &RetainImage) -> Result<(), RuntimeError>;
}
The runtime loads retained values during resource startup and writes them on shutdown and periodically (policy defined in the runtime configuration). The periodic cadence is rate-limited and only writes when retained values have changed.
Power-loss guidance: retained values are only guaranteed to persist if the most recent snapshot has been flushed to the retain store (i.e., at shutdown or after the save cadence). Unflushed changes may be lost on sudden power loss (implementer-specific).
6.8 Runtime Launcher & Deployment (Project Folder)¶
Production runtimes are started via the CLI (trust-runtime run) using a project folder
(runtime bundle format) directory. The launcher is responsible for:
- Loading the bytecode program (
program.stbc). - Loading runtime configuration (
runtime.toml). - Initializing I/O drivers (
io.tomlor system IO config). - Initializing retain storage (if configured).
- Exposing a control endpoint for local attach/debug.
- Validating bundle version compatibility before execution (internal
bundle.version).
The launcher must run on Linux, Windows, and macOS (desktop targets). Embedded targets may replace the launcher with platform-specific init systems while preserving the same configuration and control protocol.
If a project folder omits io.toml, the launcher loads the system IO config
This behavior is implementer-specific; IEC 61131-3 does not define
hardware driver selection or OS-level IO configuration (see docs/IEC_DEVIATIONS.md).
Control endpoints are local by default (unix:// on Unix-like platforms) and the Unix socket is
created with restrictive permissions (0600) to prevent accidental exposure.
6.9 Debug Attach (Production)¶
Attach debugging is optional in production deployments but must be supported by the runtime when enabled:
- Attach must not restart or reload the runtime.
- Attach must observe the current state (running/paused/faulted).
- Detach must not alter runtime execution.
- Debug hooks must be side-effect-free when disabled.
- Attach is gated by
runtime.control.debug_enabled. When disabled, debug control requests are rejected. The default is disabled in production mode (seeruntime.control.mode). runtime.control.modedefaults toproductionand can be set todebugfor development workflows;runtime.control.debug_enabledoverrides the mode when explicitly set.
6.10 Configuration and Resources¶
IEC configurations may declare multiple resources. Each resource is scheduled independently in its own OS thread. (IEC 61131-3 Ed.3, §6.8.1; Table 62)
Cross-resource data exchange is limited to explicitly declared globals (e.g., VAR_GLOBAL in configuration scope). (IEC 61131-3 Ed.3, §6.8.1; Table 62) Shared globals are synchronized under a single configuration lock: each resource cycle copies shared values in, executes ready tasks, then writes back updates before releasing the lock. This preserves deterministic ordering while serializing shared-global access.
6.11 Bytecode Format (Overview)¶
The executor consumes a stable bytecode container format emitted by the compiler. See the "ST Bytecode Format Specification" section in this document for details. - Instruction encoding and versioning - Program/function/function-block layouts - Constant pools and type descriptors - Resource, task, and POU metadata required by the runtime (process image sizing, task associations)
The runtime rejects unsupported major bytecode versions before configuring resources.
6.12 Browser UI, Discovery, and Mesh (Operational UX)¶
Operational UX is browser‑first (no app). A built‑in web service exposes an operational UI and discovery metadata. This is implementer‑specific and outside IEC 61131‑3 scope.
Configuration (in runtime.toml):
[runtime.web]
enabled = true
listen = "0.0.0.0:8080"
auth = "local" # local|token
[runtime.discovery]
enabled = true
service_name = "truST"
advertise = true
interfaces = ["eth0", "wlan0"]
[runtime.mesh]
enabled = false
listen = "0.0.0.0:5200"
auth_token = "change-me"
publish = ["Status.RunState", "Metrics.CycleMs", "TempA"]
[runtime.mesh.subscribe]
"Plant-1:TempA" = "RemoteTemp"
Rules: - Local‑only by default. Remote access must be explicitly enabled. - Discovery uses mDNS/Bonjour on the local LAN only. - Remote access supports manual add and invite/QR pairing only. - Data sharing is explicit (publish/subscribe mapping only). - TOML remains the source of truth; offline edits are supported.
HMI customization (implementer-specific):
- hmi.schema.get returns theme, pages, and widget-level layout metadata (page, group, order, unit, bounds) in addition to stable widget IDs.
- Project-level hmi.toml supports:
- [theme] (style, optional accent)
- [write] (enabled, allow) for explicit writable-target allowlists.
- [[pages]] (id, title, order)
- [widgets.\"<path>\"] overrides for label/unit/bounds/widget/page/group/order.
- ST-level @hmi(...) annotations on variable declarations support label, unit, min, max, widget, page, group, and order.
- Merge precedence is deterministic: defaults < ST annotations < hmi.toml overrides.
- Theme fallback is deterministic: unknown/missing theme values fall back to built-in classic.
- hmi.write remains disabled unless [write].enabled = true, and writes are accepted only for explicit allowlist matches (id or path) with control authz enforcement.
Operational UX and pairing flow are documented internally.
6.13 Debugging and Diagnostics¶
The runtime emits structured events for debugging and testing: - Cycle start/end (with timestamp) - Task start/end (with task name, priority) - Breakpoint hit / step events (statement boundaries) - Fault and overrun notifications
These events are consumed by the debugger (trust-debug) and test harnesses to validate behavior deterministically.
7. Build Configuration¶
7.1 Feature Flags¶
[features]
default = ["debug"]
debug = [] # enable debug instrumentation and runtime events
7.2 Conditional Compilation¶
Desktop builds use the standard library unconditionally. Embedded support will introduce additional cfg gates for alternative clock implementations.
8. Why Not Alternatives¶
| Alternative | Reason for rejection |
|---|---|
| Containers | Cannot run on microcontrollers. Adds complexity without benefit for this use case. |
| FreeRTOS POSIX simulator | Adds unnecessary layer on desktop. Not production-grade. |
| Embassy (async Rust) | Cooperative scheduling unsuitable for deterministic PLC timing. |
| WASM | Adds complexity. Real-time I/O interaction awkward. Could be future target. |
| Transpile to C | Loses runtime flexibility. Debugging harder. |
9. Future Considerations¶
WebAssembly target: The runtime core (executor, timers) could compile to WASM for browser-based simulation. Would require a WASM-friendly Clock implementation.
Remote I/O: Process image exchange via Modbus TCP is architecturally separate from the clock layer. Networking abstraction would be added alongside the Clock trait, not replacing it.
Retain variables: Persistent storage across power cycles requires platform-specific implementation (filesystem on desktop, flash on embedded). This is orthogonal to the Clock trait and would be added as a separate storage interface if needed.
10. Summary¶
The thin clock abstraction approach provides:
- IEC-aligned task scheduling (periodic and event tasks with defined priority rules)
- Minimal clock surface - easy to maintain and verify
- Clear separation - runtime logic vs clock primitives
- Deterministic behavior - explicit scheduling and I/O latching rules
- Testability - full runtime runs natively on development machine
The runtime is implemented in Rust, using the standard library for desktop targets initially. Embedded backends are planned with identical runtime logic and alternate clock implementations.