A compiler toolchain and simulator for IEC 61131-3 Structured Text, the standard programming language for industrial PLCs. Written in Rust with a stack-based C virtual machine.
.st source
│
▼
┌──────────┐ ┌────────┐ ┌──────────┐
│Preprocess│───▶│ Lexer │───▶│ Parser │
└──────────┘ └────────┘ └──────────┘
INCLUDE & tokens AST
pragmas │
┌────────┴────────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ Compiler │ │ CodegenC │
└──────────┘ └──────────┘
bytecode C source
│ │
▼ ▼
.stbc binary .c file (stcc)
│
▼
┌─────────┐
│ C VM │
└─────────┘
│
┌────────┴────────┐
▼ ▼
CLI Simulator TUI Debugger
- Programs & Functions —
PROGRAM … END_PROGRAM,FUNCTION … END_FUNCTION - Data types —
BOOL,INT,DINT,STRING - Variables —
VAR,VAR_INPUT,VAR_OUTPUT,VAR_IN_OUT,VAR CONSTANT - Structs —
TYPE … STRUCT … END_STRUCT END_TYPEwith dot-member access - Control flow —
IF/ELSIF/ELSE,CASE,FOR,WHILE,REPEAT/UNTIL - Operators — arithmetic (
+,-,*,/,MOD,**), comparison, logical (AND,OR,XOR,NOT) - I/O —
GPIO_READ(),GPIO_WRITE(),EXT_READ(),EXT_WRITE(),TIME_MS() - Cyclic execution — program body is the scan cycle; both backends automatically loop and yield
- Include files —
INCLUDE 'path.st';with cycle detection - Pragma directives —
{#pragma KEY VALUE}to customise generated code (see Pragmas) - Multi-arg PRINT —
PRINT('label:', value1, value2); - Comments —
(* block *),/* block */,// line
| Binary | Description |
|---|---|
stm |
Compiler — .st → .stbc bytecode |
stcc |
C transpiler — .st → .c source (PLC-style init/cycle) |
sim |
CLI simulator — run for N cycles, dump variables |
tui |
Interactive TUI debugger with live variable/GPIO editing |
Four-panel terminal UI built with tui-realm:
| Panel | Content |
|---|---|
| Watch | Configurable variable watch list with labels & colors |
| Variables | All VM variables with live values |
| GPIO | Pin state with toggle support |
| Output | PRINT output log |
Key bindings:
| Key | Action |
|---|---|
F5 |
Run continuously |
F2 |
Pause |
F8 |
Step one scan cycle |
R |
Reset VM |
Tab |
Switch panel focus |
Shift+Tab |
Switch panel focus backwards |
↑/↓ |
Navigate items |
Space |
Toggle GPIO pin |
Enter |
Edit variable value |
Esc |
Cancel edit |
F1 / ? |
Help overlay |
Q |
Quit |
# Build everything
cargo build --release
# Compile a program
cargo run --bin stm -- examples/tls.st -o tls.stbc
# Transpile to C
cargo run --bin stcc -- examples/tls.st -o tls.c --stubs
# Run in CLI simulator (3 cycles)
cargo run --bin sim -- examples/tls.st -n 3
# Run in TUI debugger with config
cargo run --bin tui -- examples/tls.st -c examples/tls.cfg{#pragma init_name tls_init}
{#pragma cycle_name tls_cycle}
{#pragma state_type TlsState}
{#pragma state_var tls}
INCLUDE 'utils.st';
TYPE MssSwitch : STRUCT
mss : INT := 0;
cnt : INT := 0;
END_STRUCT;
END_TYPE;
PROGRAM Main
VAR CONSTANT
PIN_MSS1 : INT := 0;
DEBOUNCE_THRESHOLD : INT := 3;
END_VAR
VAR
sw1 : MssSwitch;
mode : INT := 0;
END_VAR
Debounce(PIN_MSS1, sw1.mss, sw1.cnt, DEBOUNCE_THRESHOLD
=> sw1.mss, sw1.cnt);
mode := sw1.mss;
PRINT('mode:', mode);
END_PROGRAM
The program body is the scan cycle — it is executed repeatedly by both backends automatically.
Config files give GPIO pins friendly names and define a watch variable list with optional colors for the TUI:
[gpio]
0 = MSS1
1 = MSS2
2 = MSS3
[watch]
mode = Operating Mode | cyan
cycle = Scan Cycle | yellow
sw1.mss = Switch 1 Value | green
sw1.cnt = Debounce Cnt 1 | grayAvailable colors: red, green, blue, yellow, cyan, magenta,
white, gray, light_red, light_green, light_blue, light_yellow,
light_cyan, light_magenta.
stm <input.st> [output.stbc]
stm-sim [OPTIONS] <source>
Options:
-c, --config <PATH> Simulator config file (.cfg)
-n, --cycles <N> Number of scan cycles [default: 1000]
-p, --pin <PIN=VALUE> Pre-set a GPIO pin
-e, --ext <ADDR:SIZE=VAL> Pre-set an external variable
-v, --verbose Print GPIO activity
stcc [OPTIONS] <source>
Options:
-o, --output <PATH> Output .c file path
-s, --stubs Generate stub implementations for runtime callbacks
stm-tui [OPTIONS] <source>
Options:
-c, --config <PATH> Simulator config file (.cfg)
-p, --pin <PIN=VALUE> Pre-set a GPIO pin
-e, --ext <ADDR:SIZE=VAL> Pre-set an external variable
Pragma directives customise the C code generator output. Place them at the
top of your .st file using the {#pragma KEY VALUE} syntax:
{#pragma init_name tls_init}
{#pragma cycle_name tls_cycle}
{#pragma state_type TlsState}
{#pragma state_var tls}
| Pragma | Default | Description |
|---|---|---|
init_name |
plc_init |
Name of the generated initialisation function |
cycle_name |
plc_cycle |
Name of the generated scan-cycle function |
state_type |
<Program>State |
Name of the state struct typedef |
state_type_comment |
— | Trailing /* … */ comment on the state struct typedef |
state_var |
s |
Name of the global state instance variable |
state_var_comment |
— | Trailing /* … */ comment on the state variable declaration |
comment_style |
inline |
Comment style for function parameters: inline (trailing /**< comments) or doxygen (@param block) |
c_include |
— | Adds a #include directive to the generated C (repeatable) |
c_header |
— | Injects raw C code after the includes (repeatable, supports block form) |
stm_trace |
— | Route PRINT output through a custom function (see Tracing) |
Pragmas are stripped during preprocessing and have no effect on the bytecode compiler.
Comments written in ST source are carried through to the generated C output. Use leading comments — a comment on the line immediately before a declaration — to annotate struct fields, variables, and function parameters:
TYPE MssSwitch : STRUCT
(* Debounced stable value *)
mss : INT := 0;
(* Disagreement counter *)
cnt : INT := 0;
END_STRUCT;
END_TYPE;
FUNCTION Debounce : INT
VAR_INPUT
(* GPIO pin number to read *)
pin : INT;
(* Previous debounced stable value *)
prev_stable : INT;
END_VAR
In the generated C the comments appear as:
- Struct fields — trailing
/**< … */on the member line - Function parameters — trailing
/**< … */on the definition only (inline style, default) or a@paramDoxygen block (doxygen style); forward declarations are kept comment-free - Local variables — trailing
/**< … */on the declaration line - Constants — trailing
/**< … */on thestatic constline - Function / program headers — block comment before the definition
Select the parameter comment style with {#pragma comment_style inline} or
{#pragma comment_style doxygen}.
The C code generator uses abstract type aliases so that the concrete integer widths can be changed without modifying generated code:
| Alias | Default C type | Used for |
|---|---|---|
StmBool |
int8_t |
BOOL values |
StmShort |
int16_t |
— |
StmUShort |
uint16_t |
— |
StmLong |
int32_t |
INT values |
StmULong |
uint32_t |
— |
StmLLong |
int64_t |
DINT values |
StmULLong |
uint64_t |
— |
When no customisation is applied the generated C contains default typedefs
guarded by #ifndef STM_TYPES_DEFINED, so they can be overridden in three
ways:
1. Provide a custom header via c_include:
{#pragma c_include "my_stm_types.h"}
When c_include is present the default typedefs are not emitted — your
header must define all Stm* types.
2. Inline C via c_header (single-line or block):
(* Single-line form — repeatable *)
{#pragma c_header #define STM_TYPES_DEFINED}
{#pragma c_header typedef int32_t StmBool;}
(* Block form *)
{#pragma c_header
#define STM_TYPES_DEFINED
typedef int32_t StmBool;
typedef int32_t StmLong;
typedef int64_t StmLLong;
#pragma end}
3. From the C side — define the types before including the generated file:
#define STM_TYPES_DEFINED
typedef int32_t StmBool;
typedef int32_t StmLong;
// …
#include "tls.c"PRINT statements in ST are compiled to a single STM_PRINTF(...) macro
call per statement. By default tracing is disabled — STM_PRINTF expands
to a no-op unless you compile with -DSTM_TRACE:
/* Default behaviour (generated in the prelude): */
#ifndef STM_PRINTF
#ifdef STM_TRACE
#define STM_PRINTF(...) printf(__VA_ARGS__)
#else
#define STM_PRINTF(...) ((void)0)
#endif
#endifThree ways to enable or customise tracing:
1. Compile with -DSTM_TRACE — uses printf:
gcc -DSTM_TRACE -o app tls.c main.c2. Use the stm_trace pragma — route all output through your own
function directly from the ST source:
{#pragma stm_trace my_log}
This emits #define STM_PRINTF(...) my_log(__VA_ARGS__) — no #ifdef
guard, your function is always called.
3. Define STM_PRINTF from the C side before including the generated
code:
#define STM_PRINTF(...) my_logger(__VA_ARGS__)
#include "tls.c"The #ifndef STM_PRINTF guard ensures the default is skipped.
The PROGRAM body represents a single scan cycle. Each backend handles
repetition automatically:
- Bytecode VM — the compiler emits a
Yield+Jumploop around the body so the VM yields after each cycle and the host (sim/TUI) drives the next call tovm_step(). - C code generator — produces an
init()function (variable setup) and acycle()function (one scan), leaving the caller responsible for the main loop.
src/
├── lib.rs Library crate root
├── token.rs Token enum
├── lexer.rs Tokenizer (case-insensitive keywords)
├── ast.rs AST node types
├── parser.rs Recursive-descent parser
├── preprocess.rs INCLUDE directive expansion
├── compiler.rs AST → bytecode compiler
├── bytecode.rs Bytecode instruction set & serialization
├── codegen_c.rs AST → C code generator
├── ffi.rs Rust ↔ C VM FFI bridge
├── simcfg.rs .cfg file parser
├── main.rs `stm` compiler binary
├── bin/
│ ├── stcc.rs `stcc` C transpiler binary
│ ├── sim.rs `sim` CLI simulator binary
│ └── tui/ `tui` TUI debugger binary
│ ├── main.rs Entry point & event loop
│ ├── model.rs App state & update logic
│ ├── vm.rs SimState & VM callbacks
│ └── components/ UI panels
│ ├── watch.rs Watch panel
│ ├── variables.rs Variables panel
│ ├── gpio.rs GPIO panel
│ ├── output.rs Output panel
│ ├── status.rs Status & help bars
│ └── help.rs Help overlay
vm/
├── vm.c Stack-based C virtual machine
└── vm.h VM public API header
The Rust compiler (lexer → parser → compiler) produces a compact bytecode
format. The C VM (vm/vm.c) executes it, linked into the Rust binaries
via FFI using the cc build crate. Callbacks for GPIO, external memory,
timers, and print output are trampolined from C back into Rust.
Requires a C compiler (for the VM) and Rust 2021 edition.
cargo build # debug build
cargo build --release # optimised build
cargo test # run all testsISC
