Bytecode VM
The runtime virtual machine lives in crates/st-runtime. It has two
main components: the VM (vm.rs) that executes bytecode, and the
Engine (engine.rs) that drives the PLC scan-cycle loop.
Register-Based Architecture
The VM uses a register-based IR rather than a stack-based one. Each
function call allocates a flat array of registers (Vec<Value>) sized
to Function::register_count. The compiler assigns temporaries to
registers during code generation.
Value Types
Every register and variable slot holds a Value:
#![allow(unused)]
fn main() {
pub enum Value {
Bool(bool),
Int(i64), // covers SINT through LINT
UInt(u64), // covers USINT through ULINT
Real(f64), // covers REAL and LREAL
String(String),
Time(i64), // nanoseconds
Void,
}
}
All IEC integer widths widen to i64/u64; all floats become f64.
This keeps the instruction set small – one Add, not eight
width-specific variants.
Time Arithmetic
The Value::Time(i64) variant stores time durations in nanoseconds.
Time values participate in standard arithmetic operations:
- Addition/Subtraction:
TIME + TIMEandTIME - TIMEproduceTIMEresults. This is used by the standard library timers to compute elapsed time (e.g.,ET := SYSTEM_TIME() - start_time). - Comparison:
TIME >= TIME,TIME < TIME, etc. are used by timers to check if the preset has been reached (e.g.,ET >= PT). - TIME literals (
T#5s,T#100ms,T#1m30s) compile toLoadConstwith aValue::Time(...)constant.
Call Frames
Each function invocation pushes a CallFrame:
#![allow(unused)]
fn main() {
struct CallFrame {
func_index: u16,
registers: Vec<Value>, // sized to Function::register_count
locals: Vec<Value>, // one per VarSlot in the function's MemoryLayout
pc: usize, // program counter (instruction index)
return_reg: Option<Reg>, // where to store return value in the caller
}
}
Registers are per-frame and never shared between frames. Local variables
are separate from registers: locals correspond to declared VAR slots,
while registers hold intermediate expression results.
Variable Storage
| Storage | Instructions | Lifetime |
|---|---|---|
Locals (CallFrame::locals) | LoadLocal / StoreLocal | One function invocation |
Globals (Vm::globals) | LoadGlobal / StoreGlobal | Entire VM lifetime (persists across scan cycles) |
Slots are addressed by u16 indices into the corresponding
MemoryLayout.
Force / Unforce Variables
The VM supports forcing variable values, which overrides the normal program-computed value with a fixed value set by the debugger or monitor:
#![allow(unused)]
fn main() {
pub fn force_variable(&mut self, name: &str, value: Value);
pub fn unforce_variable(&mut self, name: &str);
}
When a variable is forced:
LoadLocalandLoadGlobalinstructions check the force table first. If the variable is forced, the forced value is returned instead of the actual stored value.- The forced value persists across scan cycles until explicitly unforced.
- Force/unforce is accessible from both the DAP debugger (via evaluate
expressions like
force x = 42,unforce x) and the monitor server (via WebSocketforce_variable/unforce_variablerequests).
The VM maintains a HashMap of forced variables (by name) that is
consulted during variable load operations.
FB Instance State
Function block instances maintain persistent state across scan cycles
via the fb_instances HashMap in the VM. When a CallFb instruction
executes, the VM looks up (or creates) the instance state for that
particular instance slot, ensuring that internal variables (like edge
detection prev flags or timer start_time values) are preserved
between calls.
Fetch-Decode-Execute Loop
The core loop in Vm::execute():
- If the call stack is empty, return
Value::Void. - Read the current frame’s
pcandfunc_index. - If
pc >= instructions.len(), perform an implicit return (pop frame). - Clone the instruction at
pcand advancepcby one. - Increment
instruction_count; check againstmax_instructions. - Match on the
Instructionvariant and execute it.
The PC advances before execution so jump instructions simply
overwrite frame.pc without off-by-one issues. Instructions are cloned
out of the function vector to avoid borrow conflicts with the mutable
call stack.
Arithmetic with Int/Real Dispatch
Binary arithmetic dispatches on operand types at runtime:
#![allow(unused)]
fn main() {
fn arith_op(&self, l: Reg, r: Reg,
int_op: impl Fn(i64, i64) -> i64,
real_op: impl Fn(f64, f64) -> f64) -> Value {
match (lv, rv) {
(Value::Real(_), _) | (_, Value::Real(_)) =>
Value::Real(real_op(lv.as_real(), rv.as_real())),
_ =>
Value::Int(int_op(lv.as_int(), rv.as_int())),
}
}
}
If either operand is Real, both promote to f64. Otherwise the
operation stays in i64. Comparisons follow the same pattern through
cmp_op(). Division by zero on integer operands returns
VmError::DivisionByZero.
Intrinsic Instructions
The VM executes several intrinsic instructions that map directly to native operations:
Math Intrinsics
Sqrt, Sin, Cos, Tan, Asin, Acos, Atan, Ln, Log,
Exp – each takes a source register, converts its value to f64,
applies the corresponding Rust f64 method, and stores the result
as Value::Real.
SystemTime
SystemTime(dst) writes the current elapsed time since engine start
into the destination register as a Value::Time(...). This is the
foundation for the real-time timers in the standard library.
Type Conversions
ToInt(dst, src), ToReal(dst, src), ToBool(dst, src) convert
between value types. These are emitted by the compiler for the 30+
*_TO_* intrinsic functions.
Control Flow via Labels
Labels are u32 indices into Function::label_positions, which maps
each label to an instruction index. The compiler allocates labels with
alloc_label() and resolves them with place_label().
Three jump instructions exist:
Jump(label)– unconditional.JumpIf(reg, label)– jump when register is truthy.JumpIfNot(reg, label)– jump when register is falsy.
A WHILE loop compiles to:
place_label(loop_start)
<condition -> reg>
JumpIfNot(reg, exit_label)
<body>
Jump(loop_start)
place_label(exit_label)
FOR and REPEAT loops follow analogous patterns.
Function Calls
Call { func_index, dst, args } performs:
- Check depth against
max_call_depth(default 256). - Allocate a new
CallFramewith default-initialised locals. - Copy arguments: each
(param_slot, arg_reg)pair writes the caller’s register into the callee’s local slot. - Set
return_regon the caller’s frame. - Push the new frame; execution continues in the callee.
Ret(reg) pops the frame and writes the value into the caller’s
return_reg. RetVoid pops without writing (used by PROGRAMs and
FUNCTION_BLOCKs). CallFb is the variant for function block instances,
carrying an additional instance_slot.
Safety Limits
| Limit | Default | Error |
|---|---|---|
max_call_depth | 256 | VmError::StackOverflow |
max_instructions | 10,000,000 | VmError::ExecutionLimit |
Division by zero produces VmError::DivisionByZero. Invalid function
or label indices produce VmError::InvalidFunction and
VmError::InvalidLabel.
Scan Cycle Engine
The Engine (engine.rs) wraps a Vm and drives it cyclically:
#![allow(unused)]
fn main() {
pub fn run_one_cycle(&mut self) -> Result<Duration, VmError> {
self.vm.reset_instruction_count();
self.vm.scan_cycle(&self.program_name)?;
// check watchdog, update stats
}
}
CycleStats
#![allow(unused)]
fn main() {
pub struct CycleStats {
pub cycle_count: u64,
pub last_cycle_time: Duration,
pub min_cycle_time: Duration,
pub max_cycle_time: Duration,
pub total_time: Duration,
}
}
avg_cycle_time() returns total_time / cycle_count.
Watchdog
If EngineConfig::watchdog_timeout is set and a single cycle exceeds
that duration, the engine aborts with VmError::ExecutionLimit.
Configuration
#![allow(unused)]
fn main() {
pub struct EngineConfig {
pub cycle_time: Option<Duration>, // None = fast as possible
pub max_cycles: u64, // 0 = unlimited
pub vm_config: VmConfig,
pub watchdog_timeout: Option<Duration>,
}
}
PROGRAM Local Retention
The VM uses body_start_pc to skip variable initialization on
subsequent scan cycles. This means PROGRAM locals retain their values
across cycles, matching real PLC behavior. The same mechanism is used
after online change to preserve migrated variable values.
Variable Access Between Cycles
#![allow(unused)]
fn main() {
engine.vm().get_global("counter") // read
engine.vm_mut().set_global("counter", Value::Int(0)) // write
}
This is the foundation for st-monitor (live variable streaming) and
st-dap (debug adapter protocol).
Online Change
The engine supports hot-reloading via engine.online_change(source),
which performs the full pipeline: parse, analyze, compile, compare
modules via analyze_change(), migrate state via migrate_locals(),
and atomically swap via vm.swap_module(). See
Online Change for full details.