Bytecode Format¶
Bytecode lookup for the truST container format, versioning, sections, flags, validation, binary layout, opcode contracts, and version gates.
Related: Runtime Engine, Debug Adapter
Status: Implemented container + execution format. The runtime validates STBC sections and executes bytecode instructions through the VM backend.
1. Purpose¶
This document defines the bytecode format consumed by the ST runtime executor. It is intended to be stable, versioned, and easy to inspect for debugging and testing. The IEC 61131-3 standard does not define a bytecode format; this container is implementer-specific.
2. Goals¶
- Deterministic execution across platforms
- Compact, mostly fixed-width instruction encoding
- Explicit typing information for runtime checks
- Backward-compatible evolution via versioning
- KISS: one container, one section table, clear validation rules
3. Conventions¶
- Endianness: little-endian for all multi-byte integers.
- Integer sizes:
- u8/u16/u32/u64: unsigned
- i32/i64: signed two's complement
- Strings: UTF-8, encoded as
u32 lengthfollowed by raw bytes (no trailing NUL). - Arrays:
u32 countfollowed by count entries. - Offsets:
u32byte offsets from the start of the file. - Alignment: section offsets and lengths are 4-byte aligned; padding bytes are
0x00. - Jump offsets are
i32byte deltas relative to the next instruction.
4. Container Layout¶
The bytecode is a single container with a fixed-size header and a section table.
4.1 Header (Version 1.x)¶
struct Header {
u8 magic[4]; // "STBC"
u16 version_major; // currently 1
u16 version_minor; // currently 1
u32 flags; // header flags (see below)
u16 header_size; // bytes, header only (currently 24)
u16 section_count; // number of section table entries
u32 section_table_off; // offset to section table (currently 24)
u32 checksum; // CRC32 if flags&0x0001 != 0, else 0
}
Validation rules:
- magic must be STBC.
- version_major must be supported by the runtime.
- header_size and section_table_off must be >= 24 and 4-byte aligned.
- section_table_off + section_count * 12 must fit within the file.
- If flags & 0x0001 is set, checksum must be the CRC32 of the section table and all section payloads (bytes from section_table_off to end of file).
4.2 Section Table Entry¶
struct SectionEntry {
u16 id; // section identifier
u16 flags; // 0 = none
u32 offset; // absolute offset in file
u32 length; // section length in bytes
}
Section table rules:
- Entries may appear in any order.
- Each standardized section ID in the range 0x0001 through 0x000C may
appear at most once. A duplicate standardized section ID invalidates the
container before any section is selected for validation or execution.
- Offsets must be 4-byte aligned.
- Sections must not overlap.
- In STBC version 1.x, an unknown section ID with flags = 0 is an optional
extension. The decoder preserves its payload as uninterpreted bytes, semantic
validation ignores it, and runtime apply does not execute or otherwise
interpret it.
- STBC version 1.x defines no required-extension flag. A future producer that
needs an unknown section to be mandatory must use a separately reviewed
versioned contract; it cannot encode that requirement in a version 1.x file.
4.3 Section Flags¶
| Bit | Name | Meaning |
|---|---|---|
0x0001 |
COMPRESSED_ZSTD |
section payload is zstd compressed |
| all others | reserved | ignore if unknown |
4.4 Header Flags¶
| Bit | Name | Meaning |
|---|---|---|
0x0001 |
CRC32 |
header checksum is CRC32 of the section table and section payloads |
4.5 Collection Count Bounds¶
Before reserving capacity for any top-level or nested collection encoded by a
u32 count, the decoder must prove with checked arithmetic that
count * minimum_entry_bytes fits in the unread bytes of the containing
section or entry. minimum_entry_bytes includes only fields present in every
encoded entry of that collection. A count that cannot fit must be rejected
before a count-sized allocation is attempted.
This is a necessary structural bound, not a general VM resource budget. It does not define maximum container size, instruction count, stack depth, local count, reference count, call depth, or execution-time limits; those broader determinism and resource-limit contracts remain separately specified.
4.6 Fixed Resource Limits¶
The STBC decoder, validator, and executor enforce the following fixed limits. They are part of the bytecode version 1.x product contract and are not configuration knobs:
| Resource | Maximum |
|---|---|
| Encoded STBC container | 67,108,864 bytes (64 MiB) |
| Decoded instructions in one module | 1,000,000 |
| References in one module | 65,536 |
| Local references in one POU | 65,536 |
| Declared POU parameters | 1,024 |
| Native-call arguments | 1,024 |
| Operand-stack values | 16,384 |
| Active VM call frames | 1,024 |
| Executed instructions in one top-level VM invocation | 1,000,000 |
| Nested constant-payload type references | 64 |
The encoded-container limit is checked before checksum calculation or section decoding. Count and decoded-instruction limits are checked before count-sized or instruction-state allocation. A module above any validation limit is rejected as a complete candidate before it can replace the active module.
The execution budget counts each executed bytecode instruction, not only loop back-edges. Nested POU and native calls share the caller's remaining budget. The stack interpreter and optimized register/tier-1 paths charge the same original bytecode instructions even when lowering fuses or expands internal operations. Exhausting the instruction budget faults the invocation through the existing execution-timeout category; it does not define a stable public error identifier. Deadline/watchdog checks remain independent and may fault an invocation before its instruction budget is exhausted.
4.6.1 Exact Boundaries and Counter Arithmetic¶
An empty REF_TABLE and an empty POU_INDEX are valid inputs to declared
resource-limit validation. For every fixed declared-count limit, the exact
maximum is accepted and the first value above it is rejected. In particular,
65,536 references are accepted and 65,537 references are rejected with an
InvalidSection diagnostic identifying REF_TABLE entries as exceeding the
fixed resource limit.
Decoded-instruction accounting uses checked addition before either the fixed
limit comparison or mutation of the accepted total. If incrementing the
machine-sized counter would overflow, validation returns InvalidSection with
a diagnostic identifying decoded module instruction count overflow, and the
previous total remains unchanged.
5. Section IDs (Version 1.x)¶
| ID | Name | Required | Purpose |
|---|---|---|---|
| 0x0001 | STRING_TABLE | Yes | Interned UTF-8 strings |
| 0x0002 | TYPE_TABLE | Yes | Type declarations |
| 0x0003 | CONST_POOL | Yes | Constant literals |
| 0x0004 | REF_TABLE | Yes | Value reference table |
| 0x0005 | POU_INDEX | Yes | POU directory and signatures |
| 0x0006 | POU_BODIES | Yes | Bytecode bodies |
| 0x0007 | RESOURCE_META | Yes | Resources/tasks/process image |
| 0x0008 | IO_MAP | Yes | Direct I/O bindings |
| 0x0009 | DEBUG_MAP | No | Source mapping, breakpoints |
| 0x000A | DEBUG_STRING_TABLE | No | Debug-only strings (file paths) |
| 0x000B | VAR_META | No | Variable type and retention metadata |
| 0x000C | RETAIN_INIT | No | Retain initialization values |
| 0x8000-0xFFFF | VENDOR | No | Vendor/experimental |
6. Section Definitions¶
6.1 STRING_TABLE (0x0001)¶
struct StringTable {
u32 count;
StringEntry entries[count];
}
struct StringEntry {
u32 length;
u8 bytes[length];
}
String indices are zero-based. All identifiers in other sections refer to this table.
For version >= 1.1, each StringEntry is padded with 0x00 bytes to the next 4-byte boundary; the padding is not included in length.
The DEBUG_STRING_TABLE section uses the same encoding.
6.2 TYPE_TABLE (0x0002)¶
struct TypeTable {
u32 count;
u32 offsets[count]; // byte offsets from TYPE_TABLE start (version >= 1.1)
TypeEntry entries[count];
}
struct TypeEntry {
u8 kind; // see TypeKind
u8 flags; // reserved
u16 reserved;
u32 name_idx; // 0xFFFFFFFF for anonymous
// payload follows based on kind
}
For version 1.0, offsets is omitted and entries are stored back-to-back.
Type kinds (Version 1.x): - 0 PRIMITIVE - 1 ARRAY - 2 STRUCT - 3 ENUM - 4 ALIAS - 5 SUBRANGE - 6 REFERENCE - 7 UNION - 8 FUNCTION_BLOCK - 9 CLASS - 10 INTERFACE
Primitive payload:
struct PrimitiveType {
u16 prim_id; // see PrimitiveId
u16 max_length; // for STRING/WSTRING; 0 means default/unspecified
}
Array payload:
struct ArrayType {
u32 elem_type_id;
u32 dim_count;
Dim dims[dim_count];
}
struct Dim {
i64 lower;
i64 upper;
}
Struct payload:
struct StructType {
u32 field_count;
Field fields[field_count];
}
struct Field {
u32 name_idx;
u32 type_id;
}
Enum payload:
struct EnumType {
u32 base_type_id; // integer type
u32 variant_count;
Variant variants[variant_count];
}
struct Variant {
u32 name_idx;
i64 value;
}
Alias payload:
struct AliasType {
u32 target_type_id;
}
Ordinary enumerated data types use ENUM and retain their closed, ordered
variant set. Data types with named integer values use ALIAS to their declared
integer base because they retain that base type's full value range and
arithmetic behavior rather than forming a closed enumeration.
Subrange payload:
struct SubrangeType {
u32 base_type_id; // signed/unsigned integer
i64 lower;
i64 upper;
}
Reference payload:
struct ReferenceType {
u32 target_type_id;
}
Union payload:
struct UnionType {
u32 field_count;
Field fields[field_count];
}
POU type payload (FUNCTION_BLOCK / CLASS):
struct PouType {
u32 pou_id; // POU_INDEX id
}
Interface payload:
struct InterfaceType {
u32 method_count;
InterfaceMethod methods[method_count];
}
struct InterfaceMethod {
u32 name_idx;
u32 slot; // interface method slot (0..method_count-1)
}
Primitive IDs (Version 1.x): - 1 BOOL - 2 BYTE - 3 WORD - 4 DWORD - 5 LWORD - 6 SINT - 7 INT - 8 DINT - 9 LINT - 10 USINT - 11 UINT - 12 UDINT - 13 ULINT - 14 REAL - 15 LREAL - 16 TIME - 17 LTIME - 18 DATE - 19 LDATE - 20 TOD - 21 LTOD - 22 DT - 23 LDT - 24 STRING - 25 WSTRING - 26 CHAR - 27 WCHAR
6.3 CONST_POOL (0x0003)¶
struct ConstPool {
u32 count;
ConstEntry entries[count];
}
struct ConstEntry {
u32 type_id;
u32 payload_len;
u8 payload[payload_len];
}
Payload encoding follows the referenced type:
- Integer/boolean: little-endian, natural size of the primitive.
- REAL/LREAL: IEEE-754 binary32/binary64.
- STRING/WSTRING: the complete entry payload is UTF-8/UTF-16LE text. When a
string value is nested in an aggregate constant, the aggregate child frame
supplies its payload boundary.
- TIME/LTIME: i64 nanoseconds.
- DATE/TOD/DT: i64 ticks in the runtime DateTimeProfile resolution.
- LDATE/LTOD/LDT: i64 nanoseconds.
- REFERENCE: 0xFFFFFFFF for NULL. STBC 1.1 does not materialize a live
REF_TABLE identity from CONST_POOL; every other u32 value is invalid.
- ARRAY: u32 elem_count followed by elem_count child frames. Each child
frame is u32 payload_len plus that element's constant payload.
ARRAY[*] uses the sentinel bounds (0, i64::MAX) in TYPE_TABLE and has
exactly zero elements in CONST_POOL; the concrete caller shape is supplied
by call binding.
- STRUCT/UNION: u32 field_count followed by one child frame per field in
declaration order. Each child frame is u32 payload_len plus that field's
constant payload.
- ENUM: i64 numeric value.
Aggregate child framing is the canonical STBC 1.1 representation. Aggregate constants were not materializable by the earlier implementation, so this defines their first accepted wire representation without making an older unframed draft valid. Encoder, validator, and runtime materialization apply the same maximum of 64 nested alias, subrange, array, structure, and union type references. A deeper or cyclic type path rejects the complete candidate before apply.
6.4 REF_TABLE (0x0004)¶
Static value references used by LOAD/STORE instructions and task FB associations.
struct RefTable {
u32 count;
RefEntry entries[count];
}
struct RefEntry {
u8 location; // see RefLocation
u8 flags; // reserved
u16 reserved;
u32 owner_id; // frame/instance id; 0 for global/retain/io
u32 offset; // variable index within the owner scope
u32 segment_count;
RefSegment segments[segment_count];
}
Reference locations: - 0 GLOBAL - 1 LOCAL - 2 INSTANCE - 3 IO - 4 RETAIN
Reference segments:
struct RefSegment {
u8 kind; // 0 = INDEX, 1 = FIELD
u8 reserved[3];
union {
IndexSegment index;
FieldSegment field;
};
}
struct IndexSegment {
u32 count;
i64 indices[count];
}
struct FieldSegment {
u32 name_idx;
}
6.5 POU_INDEX (0x0005)¶
struct PouIndex {
u32 count;
PouEntry entries[count];
}
struct PouEntry {
u32 id;
u32 name_idx;
u8 kind; // 0 PROGRAM, 1 FUNCTION_BLOCK, 2 FUNCTION, 3 CLASS, 4 METHOD
u8 flags; // reserved
u16 reserved;
u32 code_offset; // offset within POU_BODIES section
u32 code_length; // byte length (0 if no body)
u32 local_ref_start;
u32 local_ref_count;
u32 return_type_id; // 0xFFFFFFFF if no return
u32 owner_pou_id; // METHOD only; 0xFFFFFFFF otherwise
u32 param_count;
ParamEntry params[param_count];
// if kind == FUNCTION_BLOCK or CLASS:
u32 parent_pou_id; // 0xFFFFFFFF if no EXTENDS
u32 interface_count;
InterfaceImpl interfaces[interface_count];
u32 method_count;
MethodEntry methods[method_count];
}
struct ParamEntry {
u32 name_idx;
u32 type_id;
u8 direction; // 0 IN, 1 OUT, 2 IN_OUT
u8 flags; // reserved
u16 reserved;
u32 default_const_idx; // CONST_POOL index (0xFFFFFFFF if none; version >= 1.1)
}
`default_const_idx` is present in bytecode format `1.1`, which is the only
supported minor version. It carries portable call-local defaults for function
and method `IN` and `OUT` parameters. An interface type default remains NULL
and therefore needs no constant entry. A class or function-block formal has no
portable `CONST_POOL` default; a supplied actual is bound directly and omission
cannot fabricate an instance constant. Function-block parameter defaults remain
in instance storage and are not duplicated as call-local constants; the
explicit `EN`/`ENO` execution-control defaults remain encoded.
struct MethodEntry {
u32 name_idx;
u32 pou_id; // method POU id
u32 vtable_slot; // virtual dispatch slot
u8 access; // 0 PUBLIC, 1 PROTECTED, 2 PRIVATE
u8 flags; // 0x01 OVERRIDE, 0x02 FINAL, 0x04 ABSTRACT
u16 reserved;
}
struct InterfaceImpl {
u32 interface_type_id; // TYPE_TABLE index
u32 method_count;
u32 vtable_slots[method_count]; // map interface slot -> class vtable slot
}
6.6 POU_BODIES (0x0006)¶
A raw bytecode blob that contains all POU instruction streams. Offsets are relative to the start of this section.
6.7 RESOURCE_META (0x0007)¶
struct ResourceMeta {
u32 resource_count;
ResourceEntry resources[resource_count];
}
struct ResourceEntry {
u32 name_idx;
u32 inputs_size;
u32 outputs_size;
u32 memory_size;
u32 task_count;
TaskEntry tasks[task_count];
}
struct TaskEntry {
u32 name_idx;
u32 priority; // 0 = highest priority
i64 interval_nanos; // 0 disables periodic scheduling
u32 single_name_idx; // 0xFFFFFFFF means none
u32 program_count;
u32 program_name_idx[program_count];
u32 fb_ref_count;
u32 fb_ref_idx[fb_ref_count];
}
ResourceEntry.name_idx resolves through STRING_TABLE to the exact IEC
RESOURCE identifier represented by that entry. Newly encoded bytecode must
not substitute a generic placeholder when the source declares a resource. A
source without a RESOURCE declaration uses the synthetic name RESOURCE for
the runtime's single implicit execution resource.
For newly encoded bytecode, inputs_size and outputs_size are derived from
the highest addressed input and output process-image byte required by the
encoded IO_MAP bindings. Bit and byte bindings occupy at least one byte;
WORD, DWORD, and LWORD bindings occupy two, four, and eight bytes respectively,
and byte-array bindings occupy their declared length. A resource with an
addressed input or output therefore declares a non-zero corresponding process
image size large enough to contain that binding.
6.8 IO_MAP (0x0008)¶
Direct I/O bindings between the process image and program variables.
struct IoMap {
u32 binding_count;
IoBinding bindings[binding_count];
}
struct IoBinding {
u32 address_str_idx; // IEC address string (e.g., "%IX0.0")
u32 ref_idx; // REF_TABLE entry
u32 type_id; // 0xFFFFFFFF if unspecified
}
6.9 DEBUG_STRING_TABLE (0x000A, optional)¶
Same encoding as STRING_TABLE. Used for debug-only strings such as source file paths.
6.10 DEBUG_MAP (0x0009, optional)¶
struct DebugMap {
u32 entry_count;
DebugEntry entries[entry_count];
}
struct DebugEntry {
u32 pou_id;
u32 code_offset; // offset within POU_BODIES
u32 file_idx; // debug string table index (v1.1+)
u32 line; // 1-based
u32 column; // 1-based
u8 kind; // 0 statement, 1 breakpoint, 2 scope
u8 reserved[3];
}
For version >= 1.1, file_idx refers to DEBUG_STRING_TABLE. For version 1.0, it refers to STRING_TABLE.
6.11 VAR_META (0x000B, optional)¶
struct VarMeta {
u32 entry_count;
VarMetaEntry entries[entry_count];
}
struct VarMetaEntry {
u32 name_idx; // STRING_TABLE index
u32 type_id; // TYPE_TABLE index
u32 ref_idx; // REF_TABLE index
u8 retain; // 0=UNSPECIFIED, 1=RETAIN, 2=NON_RETAIN, 3=PERSISTENT
u8 reserved;
u16 reserved2;
u32 init_const_idx; // CONST_POOL index (0xFFFFFFFF if none)
}
VarMeta entries describe typed storage references. Global and instance-storage
entries use their source variable names and may carry retain or initializer
metadata. Base local declarations use the reserved name
@local/<pou_id>/<slot>/<name>, where slot is the zero-based offset within
the POU's contiguous local-reference range. Local entries must use retain = 0,
must not carry an initializer constant, and must refer to an empty-path LOCAL
reference owned by exactly one POU. Return and parameter entry types must match
the corresponding POU_INDEX signature; the entry type is authoritative for
declared local variables, whose types are otherwise absent from POU_INDEX.
Within one VAR_META section, every ref_idx is unique and every resolved
textual name is unique even when duplicate text appears at different
STRING_TABLE indexes. These constraints prevent order-dependent metadata
selection. A local entry must not carry retain state or an initializer; those
states are rejected rather than silently ignored.
Version 1.1 containers produced before local metadata was introduced may omit
these local entries. A runtime may continue untyped numeric copy-back for such a
container, but it must reject STRING or WSTRING output copy-back when the
receiving declaration's type cannot be recovered; it must not perform an
unbounded raw string write. Internal Null remains the unassigned-output
sentinel; every non-null value copied to a declared STRING or WSTRING target
must match that target's string family before normalization.
The local metadata extension retains the version 1.1 wire layout. Runtimes before truST 0.24.34 do not enforce its local string-copy semantics, so bytecode generated by truST 0.24.34 or later that uses local string output targets must be deployed with a runtime from the same or a later release. Mixed deployment with an older runtime is unsupported.
6.12 RETAIN_INIT (0x000C, optional)¶
struct RetainInit {
u32 entry_count;
RetainInitEntry entries[entry_count];
}
struct RetainInitEntry {
u32 ref_idx; // REF_TABLE index
u32 const_idx; // CONST_POOL index
}
RetainInit provides cold-start initialization values for retained variables; warm restarts restore retained state instead.
7. Instruction Encoding (Version 1.x)¶
7.1 Encoding Rules¶
- Each instruction begins with a 1-byte opcode.
- Operands are encoded in little-endian, with sizes defined per opcode.
- Invalid opcodes or malformed operands cause a runtime fault.
7.2 Operand Types¶
u32indexes refer to STRING_TABLE, TYPE_TABLE, CONST_POOL, REF_TABLE, or POU_INDEX as documented.i32offsets are relative to the next instruction.- Stack values are
Valueinstances; references are pushed asValue::Reference.
7.3 Accepted Instruction Set¶
This table is the executable STBC 1.1 contract. An opcode not listed here is not accepted merely because an older design document assigned it a mnemonic. The validator rejects unimplemented values before dispatch.
Control flow:
- 0x00 NOP
- 0x01 HALT
- 0x02 JMP i32
- 0x03 JMP_TRUE i32 (pop bool)
- 0x04 JMP_FALSE i32 (pop bool)
- 0x06 RET
- 0x09 CALL_NATIVE u32 u32 u32 (kind, symbol_idx, arg_count; pop
encoded arguments and push the call result)
Stack and constants:
- 0x10 CONST u32 (const pool index)
- 0x11 DUP
- 0x12 POP
- 0x13 SWAP
Static references:
- 0x20 LOAD_REF u32 (ref table index)
- 0x21 STORE_REF u32 (ref table index)
- 0x22 PUSH_REF u32 (push Value::Reference)
- 0x23 LOAD_SELF (push the current instance)
- 0x24 LOAD_SUPER (push the current instance's parent)
- 0x25 LOAD_NULL (push the null value)
Dynamic references:
- 0x30 REF_FIELD u32 (field name index; pop ref, push ref)
- 0x31 REF_INDEX (pop index, pop ref, push ref)
- 0x32 LOAD (pop ref, push value)
- 0x33 STORE (pop value, pop ref)
Arithmetic and logic:
- 0x40 ADD
- 0x41 SUB
- 0x42 MUL
- 0x43 DIV (fault on divide by zero)
- 0x44 MOD
- 0x45 NEG
- 0x46 AND
- 0x47 OR
- 0x48 XOR
- 0x49 NOT
- 0x4C EXPT
Comparison:
- 0x50 EQ
- 0x51 NE
- 0x52 LT
- 0x53 LE
- 0x54 GT
- 0x55 GE
Type and partial access:
- 0x60 SIZEOF_TYPE u32 (type-table index; push byte size as DINT)
- 0x61 SIZEOF_VALUE (pop value; push byte size as DINT)
- 0x62 PARTIAL_READ u32 (pop value; push selected bit/byte/word/dword field)
- 0x63 PARTIAL_WRITE u32 (pop replacement and value; push updated value)
- 0x64 REFERENCE_ATTEMPT u32 (target type-table index; pop source and push
the compatible reference/interface identity or the target family's null)
Debug marker:
- 0x70 DEBUG_MARK u32 (consume a debug marker index without changing
product state)
The following previously published or legacy values are explicitly
unimplemented in STBC 1.1 and are rejected before dispatch: 0x05, 0x07,
0x08, 0x14, 0x15, 0x16, 0x4A, 0x4B, 0x4D, and 0x4E.
Standard-library, function, function-block, and method calls use
CALL_NATIVE; bit shifts and rotates use the registered runtime operations
rather than those unimplemented bytecode values.
REFERENCE_ATTEMPT is the executable form of source ?=. Its operand must
name a Reference or Interface type-table entry. For REF_TO, the VM reads
the source reference's live storage type and accepts an exact target,
derived-to-base relation, or implemented-interface relation; incompatibility
pushes Value::Reference(None). For an interface target, the VM checks the
live instance POU against the class/function-block parent and implemented-
interface metadata; incompatibility pushes Value::Null. A null source always
produces the target family's null. The opcode does not mutate the assignment
target itself; the following validated store performs the single write.
Reserved opcode ranges:
- 0x80-0xEF reserved for future core extensions.
- 0xF0-0xFF vendor/experimental.
7.4 Validator Before Apply¶
An STBC module must pass complete structural and semantic validation before it may replace the runtime's active module or mutate runtime metadata, configured tasks, process-image sizing, retain state, or executable VM state. Validation is fail-closed: the first observed violation rejects the complete candidate module; validation order does not make an otherwise invalid module acceptable.
The validator enforces these module-wide contracts:
- every required section in section 5 is present with the decoded section kind assigned to that ID;
- string, type, constant, reference, POU, variable, resource, I/O, retain, and debug indexes resolve inside their owning table;
- array bounds are ordered, constant payloads are complete, bounded to 64 nested type references, and compatible with their declared type, and optional metadata agrees with the referenced POU or storage declaration;
- POU IDs are unique, code ranges stay inside
POU_BODIES, local-reference ranges are checked for arithmetic overflow, bounds, overlap, contiguous offsets, local location, and unique frame ownership; - a POU may use a local reference only from its declared local range, including path references rooted in that same frame owner;
- a frame-local reference must not be stored into global, retain, I/O, instance, or otherwise longer-lived storage, directly or through a dynamic non-local reference;
- every instruction is recognized and carries its complete fixed-width operand payload; an opcode in a reserved range remains invalid until the accepted bytecode version explicitly implements it;
- direct, native, method, and interface calls resolve to compatible targets, metadata, parameter directions, and argument shapes supported by the active runtime;
- every relative jump stays inside its POU body and lands at a decoded instruction boundary or the exact end of that body;
- operand-stack dataflow has no underflow, has compatible depth at each control-flow merge, uses reference/numeric/boolean shapes where required, and leaves no values at a normal POU-body exit; and
- resource, task, program, I/O, retain, variable, and debug metadata resolves to compatible table entries and code locations.
BytecodeModule::decode may reject malformed container bytes before semantic
validation. BytecodeModule::validate performs the decoded semantic checks.
Runtime::apply_bytecode_bytes must perform both boundaries and must
materialize the candidate executable module before changing live runtime
metadata. Any rejection leaves the previously active runtime configuration and
executable module unchanged.
BytecodeError variants identify the in-process failure category. Every
variant also has the following stable machine identifier. Diagnostic text may
provide a narrower reason, but text is not part of the machine contract.
BytecodeError variant |
Stable identifier |
|---|---|
InvalidMagic |
bytecode_invalid_magic |
UnsupportedVersion |
bytecode_unsupported_version |
InvalidHeader |
bytecode_invalid_header |
InvalidChecksum |
bytecode_invalid_checksum |
InvalidSectionTable |
bytecode_invalid_section_table |
SectionOutOfBounds |
bytecode_section_out_of_bounds |
SectionOverlap |
bytecode_section_overlap |
SectionAlignment |
bytecode_section_alignment |
UnexpectedEof |
bytecode_unexpected_eof |
InvalidSection |
bytecode_invalid_section |
MissingSection |
bytecode_missing_section |
InvalidOpcode |
bytecode_invalid_opcode |
InvalidJumpTarget |
bytecode_invalid_jump_target |
InvalidPouId |
bytecode_invalid_pou_id |
InvalidIndex |
bytecode_invalid_index |
BytecodeError::stable_code() returns the table entry. Conversion into the
public runtime error preserves that identifier; it must not derive a code by
parsing Display text. Direct decode/validation and
Runtime::apply_bytecode_bytes therefore report the same identifier for the
same rejected candidate. Control responses place the identifier in
error_code while retaining the existing human-readable error field.
The fixed limits in section 4.6 are validated before apply and enforced again at their allocation or execution boundary. Their diagnostic text remains non-normative even though the enclosing error category has a stable machine identifier.
7.4.1 VM Trap Identifiers¶
VM traps that represent malformed executable state retain a stable identifier
when converted to RuntimeError. Invalid opcode, jump, POU, and table-index
traps reuse the corresponding bytecode_* identifier above. The remaining
VM-only structural identifiers are:
| Trap category | Stable identifier |
|---|---|
| Operand stack underflow | vm_stack_underflow |
| Operand stack overflow | vm_stack_overflow |
| Call stack underflow | vm_call_stack_underflow |
| Call stack overflow | vm_call_stack_overflow |
| Unsupported runtime opcode | vm_unsupported_opcode |
| Unsupported reference location | vm_unsupported_reference_location |
| Invalid native-call metadata or payload | vm_invalid_native_call |
| Bytecode decode failure without a narrower decoder variant | vm_bytecode_decode |
Condition, null-reference, loop-step, deadline, instruction-budget, and other
runtime-value traps use the stable runtime_* identifiers in
docs/specs/10-runtime-semantics.md. VmTrap::Runtime preserves the embedded
runtime error's identifier.
7.4.2 Numeric and Partial-Access Domains¶
For bytecode stack-shape validation, primitive IDs 6 through 15 inclusive are the complete numeric domain. IDs outside that interval, including BOOL, bit-string, temporal, string, null, and unknown IDs, are not classified as numeric merely because they have a primitive table entry.
The u32 operand of PARTIAL_READ and PARTIAL_WRITE packs the partial kind
in bits 8 and 9 and the zero-based index in bits 0 through 7; all higher bits
must be zero. Kind 0 accepts bit indices 0 through 63, kind 1 accepts byte
indices 0 through 7, kind 2 accepts word indices 0 through 3, and kind 3
accepts dword indices 0 through 1. The validator rejects the first index above
each range and any operand with bits outside the ten-bit encoding domain.
7.5 Fault Semantics¶
The executor must fault on:
- Type mismatches (e.g., BOOL in arithmetic)
- Invalid references or out-of-bounds indexes
- Divide by zero
- FOR loop step expressions that evaluate to 0 (encoder emits a step==0 guard that executes HALT before loop entry)
- Invalid jump targets
- Method/interface dispatch on NULL or incompatible references
7.5.1 Source-to-Bytecode Projection¶
Successful source lowering emits one complete module that decodes and passes section 7.4 validation. Identifier lookup performed before emission is case-insensitive, so accepted call-heavy source remains encodable regardless of identifier spelling case; emission preserves the resolved declaration identity.
The encoder projects source declarations as follows:
- aliases, arrays, structures, unions, enumerations, subranges, references,
classes, function blocks, and interfaces retain their declared type
relationships in
TYPE_TABLE; - class inheritance, method ownership, override slots, interface slots,
parameter directions, and call-local function/method parameter defaults
retain their relationships in
POU_INDEXandCONST_POOL; - function and method return, parameter, and local slots occupy contiguous
LOCAL
REF_TABLEranges, and their scoped names and declared types are emitted inVAR_META; - retained initialized storage emits compatible
VAR_META,CONST_POOL, andRETAIN_INITentries; - direct variables emit
IO_MAPbindings and the correspondingRESOURCE_METAprocess-image sizes described in sections 6.7 and 6.8; - declared resource and task identities are preserved so applying the encoded module materializes the same named runtime resource and tasks;
- source locations emit
DEBUG_MAPentries with the owning POU, bytecode offset, file, line, column, and statement kind; - a label with an empty statement emits an explicit
NOP, preserving a valid instruction location; - IF/ELSIF, CASE, WHILE, REPEAT, and FOR control flow emits validated relative branch instructions and explicit comparison/stack operations; and
- instance and method field access emits
LOAD_SELF,REF_FIELD,LOAD, andSTOREoperations as required by the resolved lvalue or expression.
An enum initializer must use a constant payload compatible with its enum base type. Any unsupported source construct fails lowering; it is not replaced with an unrelated placeholder instruction.
7.5.1.1 Recursive Lowering Classification¶
The encoder's support and required-seam classification traverses complete
expression, lvalue, statement, and call-argument shapes. This includes call
targets, value and writable-target arguments, indexed and dereferenced
lvalues, REF expressions, and SIZEOF targets nested inside a returned or
assigned expression. A plain name with no such descendant is not classified as
containing a call or SIZEOF. This recursive classification prevents a nested
unsupported construct from being hidden by an otherwise supported parent.
7.5.1.2 RETURN, REF, and Partial-Access Lowering¶
A bare RETURN emits opcode 0x06. A value-bearing RETURN is emitted only
when the current POU has a resolved return slot; without that slot it remains
unsupported and no value-return code is emitted. The REF builtin requires
exactly one addressable target. Wrong arity rejects lowering, while one
resolved writable target emits LOAD_REF_ADDR (0x22) followed by its
four-byte reference-table index.
A static partial read first resolves and loads its target, then emits
PARTIAL_READ (0x62) followed by the packed four-byte partial-access
operand. A partial write emits PARTIAL_WRITE (0x63) with the same operand
encoding. The kind byte is 0 for bit, 1 for byte, 2 for word, and 3 for dword;
the selected zero-based index occupies the low byte.
7.5.1.3 Literal Index Projection¶
Static index projection accepts every signed integer, unsigned integer, and
bit-string literal width represented by the runtime value model and preserves
its mathematical value in an i64 index. A nonliteral expression or a
nonnumeric literal is not a static literal index and is left for dynamic
lowering. An unsigned ULINT or LWORD value above i64::MAX rejects static
projection with an index literal overflow diagnostic; it is never wrapped or
truncated.
7.5.1.4 Public Constructor Debug Projection¶
All three public BytecodeModule runtime constructors emit the same supported
bytecode version and a module that passes validation. The source-free
constructor emits no debug sections. When source text is supplied, every
emitted statement location produces debug sections and uses the deterministic
fallback label file_<file_id>. When matching source paths are also supplied,
the same debug entry instead resolves to the supplied path. Supplying a source
and path count mismatch rejects module construction.
7.5.1.5 Decoded Module and Class-Like Encoder Structure¶
trust_runtime::bytecode::BytecodeModule is the public decoded-container
representation. Its version, flags, and ordered sections fields expose
the values decoded from or destined for the container described in sections 4
through 6. Constructing or decoding this structure does not by itself assert
semantic validity; callers must pass it through the validator before runtime
application. Rust field layout is not a wire-format or stable ABI promise.
Wire compatibility is defined only by the encoded byte sequence in this
specification. Structural equality compares all three exposed components.
The encoder's private ClassLike adapter is a structural projection shared by
class and function-block metadata emission:
name()borrows the exact declared class or function-block identity;base_name()returns the declared base identity without changing spelling or assigning different encoder semantics to a function-block base and class base; absence remainsNone; andinterfaces()borrows the complete declared interface list in source order; andmethods()borrows the complete method list in declaration order.
These signatures create no independent runtime behavior. Their authority is
the preservation of already-specified source identities and relationships
while the class metadata paths in section 7.5.1 emit TYPE_TABLE and
POU_INDEX. They must not synthesize a base, reorder or filter methods, or
rename the owner. The public type declaration and the four private trait
signatures are structural code facts: their acceptance evidence is exact
source-to-spec identity and downstream encoder behavior proof, not an invented
unit test for the existence of a Rust declaration.
7.6 Source-to-Bytecode Fail-Closed Boundary¶
Source analysis and bytecode lowering are separate acceptance boundaries. A
source construct may be valid in the analyzed runtime model while the bytecode
encoder does not yet implement its executable semantics. In that case,
bytecode-module construction must fail visibly and return no module. The
encoder must not replace the construct with NOP, discard the unsupported
subtree, or return a module containing only the successfully emitted prefix.
The reviewed lowering partitions are:
- supported
EXITandCONTINUEstatements inside active loops emit their defined jump paths and remain executable; - a source
JMPstatement, which is accepted by source analysis but has no reviewed bytecode lowering, rejects bytecode-module construction; and - an executable array-initializer assignment, including one following an otherwise supported statement, rejects bytecode-module construction while that expression remains unsupported by the encoder; and
- a function declared with the reviewed explicit
: VOIDreturn type rejects bytecode-module construction with a diagnostic containingunsupported generic type. This failure occurs before the function body'sVAR_IN_OUTexpressions execute and therefore proves no copy-back or conversion behavior.
During source lowering, an explicit empty label is the sole reviewed
intentional no-action statement that may emit NOP. The presence of the
encoded NOP instruction in an already constructed module is governed by the
normal instruction contract and does not authorize fail-open source lowering.
This boundary requires a visible compiler build error and is not a runtime failure surface. The stable bytecode and VM identifiers in Sections 7.4 and 7.4.1 apply after bytecode production; they do not replace source-lowering diagnostics.
7.7 VM Call Binding and Copy-Back¶
VM calls bind arguments deterministically to the callee's declared parameter order. Positional arguments consume the next available parameters; named arguments bind by the declared name without changing declaration order. Duplicate or unknown names, excess positional arguments, missing required arguments, and holes in a variadic suffix reject before callee execution or caller mutation.
An omitted function-block input preserves the instance's stored field value.
An omitted output or VAR_IN_OUT parameter creates no copy-back binding and
does not attempt to resolve a target. A supplied output or VAR_IN_OUT target
must be writable and compatible with the declared parameter type.
VAR_IN_OUT copy-in and copy-back preserve the same caller target and require
exact family and bounded-string capacity compatibility. A rejected binding or
copy-back leaves the caller target and unaffected instance fields unchanged.
The complete binding set is validated before any copy-in or field mutation, so
a failure on a later argument also leaves earlier inputs and fields unchanged.
Standard-function fixed and variadic parameters use the same declaration-order,
duplicate-name, arity, and variadic-hole rules. Native split functions write
each declared output once and return NULL; SPLIT_DATE requires one input and
three writable outputs, and named split outputs match formal names
case-insensitively. Runtime clock functions accept exactly zero arguments.
Integer output helpers preserve the target's declared integer width and reject
a negative value for unsigned storage before writing.
A native-call symbol descriptor contains the target followed by ordered E
expression and T writable-target descriptors, each optionally carrying a
formal name. Payload decoding preserves descriptor and argument order,
distinguishes values from writable references, and removes the declared
receiver separately. An unknown descriptor kind rejects as
vm_invalid_native_call.
7.8 VM Reference Resolution¶
Every VM reference route resolves the same logical storage location and produces the same value, mutation, or trap. An empty path may use direct global, instance, or current-frame storage. Nested paths, inherited fields, and dynamic references may use generic resolution, but the selected access route is not observable in the program result.
Field and array-path extension preserves the existing path and applies each
additional segment against the declared aggregate shape. Index arithmetic is
overflow-checked, including arrays with extreme signed lower bounds. A null
reference, missing field, incompatible aggregate shape, or invalid index faults
without mutating storage. Function and method interface slots that have no
materialized value begin as NULL.
7.9 Optimized Backend Semantic Equivalence¶
The stack executor defines the VM's observable semantics. Register-IR and tier-1 execution must produce the same return value, declared runtime types, storage mutations, instruction-budget boundary, and runtime-error category from the same module and initial state. Nested calls share the top-level instruction budget. Deadline and budget failure occur before the guarded operation commits an observable mutation.
An optimized backend may decline an instruction, block, or POU and fall back only before that backend has made an observable mutation. Cache state, profiling counters, allocation reuse, polling stride, and diagnostic prose are implementation details and are not part of this semantic-equivalence oracle.
7.9.1 Register-IR lowering, verification, and fusion¶
Register-IR lowering preserves bytecode operands, control flow, stack
semantics, and original instruction costs. It rejects stack underflow,
inconsistent merge depths, invalid block targets, undefined register reads,
out-of-range destinations, and missing or inconsistent original-cost metadata.
RETURN ends propagation and does not acquire a synthetic fallthrough edge.
Fusion may replace only a complete reviewed instruction window. The fused form preserves operand order, read dependencies, branch behavior, runtime faults, and the sum of the original bytecode instruction costs. A partial, guarded, or otherwise unmatched window remains unfused without changing the surrounding instructions. Unsupported lowering preserves the complete original operands for a pre-mutation stack-executor fallback.
7.9.2 Tier-1 specialization¶
Tier-1 compilation either accepts a reviewed register block and executes it with register-interpreter semantics, or declines it before mutation. A guard mismatch is a decline rather than a reinterpretation of the operands. Accepted blocks preserve arithmetic and comparison results, boolean and branch behavior, dynamic and inherited reference semantics, function and function-block calls, runtime traps, and original instruction costs.
7.9.3 Optimized-backend operational observability¶
Operational controls and telemetry remain separate from the semantic- equivalence oracle above, but their own runtime contract is deterministic:
- absent, explicit true/false, and invalid Boolean environment tokens resolve to their documented defaults; tier-1 is disabled by default, and valid threshold and capacity tokens select the requested positive bounds;
- lowering and specialized-block caches reuse prior results, cache lowering failures, respect configured capacity, evict when bounded capacity is exceeded, stay cold until the hot threshold, and clear all entries and counters on reset;
- pooled register files and execution buffers preserve requested capacity, return cleared frames and registers, and never retain more than the configured pool limit;
- profile snapshots count the executed register, reference, call, fallback, cache, and value-movement operations actually taken. Direct scalar and borrowed-reference paths do not increment clone counters that those paths avoid;
- stack and register deadline polling check the first guarded operation and the documented stride boundaries. Unsupported lowering or specialization records a bounded reason and falls back before an observable mutation;
- debug-map lookup returns the source location owned by the current bytecode instruction, and diagnostic corpus probes report register execution, lowering-cache use, and fallback reasons without changing program results; and
- native-call descriptor parsing and resolved native-function lookup may be cached, but a cached parse failure preserves its original error and cached success preserves the same ordered descriptor and resolved function identity.
These observations specify configuration, resource, and diagnostic behavior only. Cache hits, counters, buffer identity, polling frequency, and prose are not Structured Text program outputs and remain excluded from optimized-backend semantic equivalence.
7.10 VM Module Materialization and Lookup¶
After container and semantic validation, VM materialization builds one
immutable execution view before any POU runs. STRING_TABLE, TYPE_TABLE,
CONST_POOL, REF_TABLE, POU_INDEX, and POU_BODIES are required at this
boundary. A missing required section, invalid table reference, duplicate POU
identity, duplicate owner-local method identity, invalid code range, or
fixed-limit violation rejects the complete view as vm_bytecode_decode; no
partially indexed module is returned.
POU names are indexed case-insensitively by their declared kind. Program,
function, function-block, and class names occupy distinct lookup maps. Method
lookup is case-insensitive within its owning class or function block and does
not search an unrelated owner. Parameter order, direction, type, default
constant, return-slot presence, and local-reference range are preserved from
POU_INDEX.
VAR_META binds at most one declared type to each reference index. A duplicate
reference index rejects materialization rather than allowing the later row to
replace the earlier type. Missing VAR_META yields an empty optional type map;
it does not invent types.
Each REF_TABLE row materializes exactly one VM reference:
- GLOBAL, RETAIN, and INSTANCE preserve their offset and path;
- LOCAL additionally preserves its owning frame identity;
- IO owner 0, 1, or 2 selects input, output, or memory respectively; any other IO owner rejects materialization;
- index segments preserve every signed path index; and
- field segments must resolve their string-table entry and preserve its exact text.
The VM may infer a primary instance owner for a POU only when every statically referenced instance slot in that POU names the same owner. Zero owners, multiple owners, an unknown opcode, or a truncated operand yields no inferred owner. Scanning must consume the complete operand width of every recognized opcode, including partial-access operands, so operand bytes are never misinterpreted as reference instructions.
Native-call symbol descriptors are parsed once during materialization.
Successful descriptors preserve the normalized target, ordered expression and
writable-target arguments, optional formal names, conversion identity, and
resolved function POU. A parse failure is retained as a failure and produces
vm_invalid_native_call when selected; it is not reparsed into a different
result during execution.
7.11 Declared Local and Static Initialization¶
Every stack or optimized VM call initializes its declared frame state before the first POU instruction. The initialization plan is selected by exact POU identity and declaration kind and is cached only for the current materialized module. Replacing or invalidating the module clears the cache; a plan from a different module must never be reused.
Frame layout is deterministic:
- a function or value-returning method places its return slot first;
- function and method parameters follow in declaration order;
- automatic locals follow the parameters;
- function-block parameters remain instance fields rather than frame locals; and
- external locals are not overwritten by automatic initialization.
A NULL return slot receives the declared default; a caller-supplied non-NULL return slot is preserved. Parameters already copied into the frame are preserved. Automatic locals receive their explicit initializer or declared default. Interface-typed slots default to NULL. Class and function-block locals create their declared instance; a class local rejects an explicit initializer, while a function-block structure initializer applies only reviewed, case-insensitively unique input fields. Unknown, duplicate, unwritable, or wrong-typed initializer fields reject the call.
Function and method static locals use their qualified static owner and initialize at most once. A method's static owner includes both the declaring class or function block and method name. Instance-backed static storage stays on that runtime instance; otherwise it stays in global storage. A later call preserves the stored value and does not re-evaluate the initializer.
Initializer expressions use the same runtime value operations and stable errors as executable expressions, with the following closed behavior:
- literals, unary and binary expressions, structures, arrays,
THIS,SUPER,SIZEOF, field/index access, dereference, standard functions, and conversions are evaluated from the visible frame and runtime state; - frame names resolve case-insensitively in return, parameter, then local order, followed by static, recursive instance, global, and retain storage;
- a local declared later than the current visible-slot boundary is not readable by an earlier initializer;
- array repeat groups accept non-negative integer literal counts, reject named repeat arguments, negative counts, and counts that do not fit the host, and preserve the repeated argument order;
- named fixed and variadic standard-function arguments are reordered to formal order, reject duplicates, unknown or unnamed entries, enforce the required prefix start, and reject holes in a variadic suffix;
- array index rank and bounds are checked with overflow-safe signed arithmetic;
string and wide-string access accepts exactly one integer index; an unsigned
or bit-string index above
i64::MAXreports overflow and is never wrapped to a negative index; - a dereferenced NULL reference reports
runtime_null_reference, while incompatible values and aggregate shapes report the applicable stable type or bounds error; and SIZEOFreturns DINT, rejects an unknown or unsupported type as a type mismatch, and reports overflow when the byte size does not fit DINT.
An initialization failure identifies the owning POU and variable and aborts the call before the first instruction. It does not leave a partially initialized automatic frame. Existing persistent static storage and unrelated runtime storage remain unchanged.
7.12 Execution Buffers, Budget, Deadline, and Debug Location¶
The stack and register executors acquire reusable buffers only as an allocation optimization. Acquisition returns empty logical stacks, frames, registers, and temporary values. Release clears all contents and retains no more than the documented pool limit; reuse must not expose a prior execution's values or frame identities.
The top-level instruction budget is shared by nested VM calls. Charging exactly
the remaining budget succeeds and leaves zero; the next charge rejects with
runtime_instruction_budget_exceeded. A rejected charge does not underflow or
otherwise change the remaining budget. Original bytecode instruction cost is
charged before the guarded operation commits its mutation, including fused and
tier-1 forms.
Deadline polling occurs on the first guarded operation and at the documented
stack/register stride boundaries. A missing or future deadline permits
execution; an expired deadline rejects before the guarded mutation with
runtime_deadline_exceeded.
Debug lookup is optional and fail-soft. Valid variable metadata creates
case-preserving symbol-to-reference and first-symbol reference-to-symbol
lookups. Valid debug entries create exact (pou_id, code_offset) source
locations. A missing optional section or an entry whose string index is
invalid is omitted without inventing a symbol, file, line, or column. Debug
lookup never changes execution results.
8. Versioning¶
- Major version changes are breaking and must be rejected by older runtimes.
- Minor version changes may be accepted if the runtime recognizes all required sections and opcodes.
- New sections and opcodes must be added in reserved ID/opcode ranges.
Version 1.1 additions:
- TYPE_TABLE offset index for O(1) lookup
- DEBUG_STRING_TABLE for debug-only strings
- VAR_META and RETAIN_INIT sections
- Param default values (default_const_idx)
- STRING_TABLE entry padding
- Header CRC32 flag (flags & 0x0001)
9. Metadata Integration Requirements¶
The loader must populate runtime metadata from: - RESOURCE_META -> resources, tasks, process image sizes - IO_MAP -> I/O bindings - STRING_TABLE -> names for tasks/programs/resources - REF_TABLE -> FB instance references - POU_INDEX -> method tables, inheritance, interface dispatch mapping - VAR_META / RETAIN_INIT -> variable type metadata and retain initialization (if present)
Loading preserves resource and task names, task priority and interval, single-trigger identity, program order, program-to-POU associations, and function-block reference associations. Applying validated encoded bytes materializes those associations and process-image sizes before execution. Unsupported major versions are rejected before any live metadata, process image, retain state, task configuration, or executable module is replaced.
10. Debugging Data¶
The DEBUG_MAP section provides a deterministic mapping between bytecode offsets and source locations. Debug entries must refer to valid POU IDs and code offsets.
For version >= 1.1, file paths are stored in DEBUG_STRING_TABLE and referenced by file_idx.
11. Future Tasks (Deferred)¶
No deferred items at this time.