Introduction
rust-plc is an open-source IEC 61131-3 Structured Text compiler toolchain built in Rust. It provides everything you need to write, analyze, and execute PLC programs:
- Compiler — Parses Structured Text source code, performs semantic analysis with 30+ diagnostic checks, and compiles to register-based bytecode
- Runtime — A bytecode VM that executes compiled programs in a PLC-style scan cycle loop
- LSP Server — Full Language Server Protocol integration with 16 features: diagnostics, hover, go-to-definition, go-to-type-definition, completion, signature help, find all references, rename, document/workspace symbols, document highlight, folding, document links, semantic tokens, formatting, and code actions
- DAP Debugger — Full Debug Adapter Protocol support with breakpoints, stepping, variable inspection, and scan-cycle-aware continue
- Online Change — Hot-reload compiled programs without stopping the runtime, with automatic variable migration
- Monitor Server — WebSocket-based live variable dashboard for real-time trend recording
- CLI Tool —
st-cliprovides check, compile, run, and debug commands for the terminal
What is Structured Text?
Structured Text (ST) is one of the five programming languages defined by the IEC 61131-3 standard for programmable logic controllers (PLCs). It’s a high-level, Pascal-like language used extensively in industrial automation:
PROGRAM TemperatureControl
VAR
sensor_temp : REAL := 22.0;
setpoint : REAL := 50.0;
heater_on : BOOL := FALSE;
END_VAR
IF sensor_temp < setpoint - 2.0 THEN
heater_on := TRUE;
ELSIF sensor_temp > setpoint + 2.0 THEN
heater_on := FALSE;
END_IF;
END_PROGRAM
Key Features
| Feature | Status |
|---|---|
| Full ST parser with error recovery | ✅ |
| 30+ semantic diagnostics (type errors, undeclared vars, etc.) | ✅ |
| LSP server (16 features: diagnostics, hover, go-to-def, completion, references, rename, formatting, and more) | ✅ |
| VSCode extension with syntax highlighting | ✅ |
| Bytecode compiler | ✅ |
| Runtime VM with scan cycle engine | ✅ |
| Debugger (DAP) | ✅ |
| Online change (hot reload) | ✅ |
| Monitor server (WebSocket live dashboard) | ✅ |
| LLVM native compilation | 🔜 |
Quick Example
# Check a file for errors
st-cli check program.st
# Compile and run for 1000 scan cycles
st-cli run program.st -n 1000
# Start the LSP server (used by VSCode)
st-cli serve
Architecture
The toolchain follows the same architecture as rust-analyzer: a Rust core process handles all the heavy lifting, while a thin TypeScript extension bridges to VSCode.
Source (.st) → Parser → AST → Semantics → IR → VM
↓
LSP Server → VSCode
Continue to Installation to get started.
Installation
Prerequisites
- Rust 1.85 or later (install via rustup)
- Node.js 18+ (only needed for the VSCode extension)
- C compiler (for building the tree-sitter parser — usually pre-installed on Linux/macOS)
Building from Source
# Clone the repository
git clone https://github.com/user/rust-plc.git
cd rust-plc
# Build the CLI tool
cargo build -p st-cli --release
# The binary is at:
./target/release/st-cli --help
Verify Installation
# Check version
st-cli help
# Parse and check a sample file
st-cli check playground/01_hello.st
# Run a program
st-cli run playground/01_hello.st
Expected output:
playground/01_hello.st: OK
Executed 1 cycle(s) in 8.5µs (avg 8.5µs/cycle, 16 instructions)
Installing the VSCode Extension
See VSCode Setup for detailed instructions.
Using the Devcontainer
The easiest way to get started is with the included devcontainer:
- Open the repository in VSCode
- Click “Reopen in Container” when prompted (or Ctrl+Shift+P → “Dev Containers: Reopen in Container”)
- Wait for the container to build and the post-create script to finish
- Open any
.stfile in theplayground/folder
The devcontainer automatically:
- Installs Rust and Node.js
- Builds
st-cli - Installs the VSCode extension
- Configures syntax highlighting and LSP
Quick Start
This guide walks you through writing and running your first Structured Text program.
1. Create a Program
Create a file called hello.st:
PROGRAM HelloWorld
VAR
counter : INT := 0;
running : BOOL := TRUE;
END_VAR
IF running THEN
counter := counter + 1;
IF counter > 100 THEN
running := FALSE;
END_IF;
END_IF;
END_PROGRAM
2. Check for Errors
st-cli check hello.st
Output:
hello.st: OK
If there are errors, you’ll see them with file, line, and column:
hello.st:5:10: error: undeclared variable 'x'
hello.st:8:8: error: condition must be BOOL, found 'INT'
3. Run the Program
# Run a single scan cycle
st-cli run hello.st
# Run 1000 scan cycles (like a real PLC)
st-cli run hello.st -n 1000
Output:
Executed 1000 cycle(s) in 1.74ms (avg 1.74µs/cycle, 16 instructions)
4. Write a Function
Functions compute and return a value:
FUNCTION Clamp : REAL
VAR_INPUT
value : REAL;
low : REAL;
high : REAL;
END_VAR
IF value < low THEN
Clamp := low;
ELSIF value > high THEN
Clamp := high;
ELSE
Clamp := value;
END_IF;
END_FUNCTION
PROGRAM Main
VAR
sensor : REAL := 150.0;
output : REAL := 0.0;
END_VAR
output := Clamp(value := sensor, low := 0.0, high := 100.0);
END_PROGRAM
5. Write a Function Block
Function blocks maintain state between calls:
FUNCTION_BLOCK Counter
VAR_INPUT
reset : BOOL;
END_VAR
VAR_OUTPUT
count : INT;
END_VAR
VAR
internal : INT := 0;
END_VAR
IF reset THEN
internal := 0;
ELSE
internal := internal + 1;
END_IF;
count := internal;
END_FUNCTION_BLOCK
PROGRAM Main
VAR
cnt : Counter;
value : INT := 0;
END_VAR
cnt(reset := FALSE);
value := cnt.count;
END_PROGRAM
6. Use Global Variables
Global variables persist across scan cycles:
VAR_GLOBAL
total_cycles : INT;
END_VAR
PROGRAM Main
VAR
local_var : INT := 0;
END_VAR
total_cycles := total_cycles + 1;
local_var := total_cycles;
END_PROGRAM
# After 1000 cycles, total_cycles = 1000
st-cli run program.st -n 1000
Next Steps
- VSCode Setup — Get editor support with diagnostics, hover, and completion
- Language Reference — Full language documentation
- CLI Reference — All available commands
VSCode Setup
The rust-plc toolchain includes a VSCode extension that provides full IDE support for Structured Text.
Features
The language server provides 16 LSP features for a full IDE experience:
- Diagnostics — Real-time errors and warnings as you type (30+ diagnostic codes)
- Hover — Ctrl+hover shows type info, function signatures, and variable kinds
- Go-to-definition — Ctrl+Click jumps to variable or POU declarations
- Go-to-type-definition — Jumps to the TYPE, STRUCT, or FUNCTION_BLOCK declaration of a variable’s type
- Completion — Auto-complete with keywords (snippets), variables, functions, struct fields (dot-trigger), FB members, and types
- Signature help — Parameter hints on
(and,inside function and FB calls - Find all references — Shift+F12 finds all usages of a symbol (case-insensitive, whole-word)
- Rename symbol — F2 renames across all occurrences in the file
- Document symbols — Ctrl+Shift+O outline view with nested POUs and variables
- Workspace symbols — Ctrl+T search for any POU or type across all open files
- Document highlight — Cursor on an identifier highlights all occurrences instantly
- Folding ranges — Collapse PROGRAM, FUNCTION, VAR, IF, FOR, WHILE, CASE, and comment blocks
- Document links — File paths in comments (e.g.,
// see utils.st) become clickable links - Semantic tokens — 10 token types for rich, context-aware syntax highlighting
- Formatting — Shift+Alt+F auto-indents the entire file
- Code actions — Ctrl+. quick fix: declare undeclared variable as INT
Installation
Option A: Devcontainer (Recommended)
- Open the
rust-plcrepository in VSCode - Click “Reopen in Container” or run
Dev Containers: Reopen in Container - Everything is configured automatically
Option B: Extension Development Host
-
Build the CLI and extension:
cargo build -p st-cli cd editors/vscode && npm install && npm run compile -
Press F5 in VSCode (with the rust-plc repo open)
-
Select “Launch Extension (playground)”
-
A new VSCode window opens with the extension loaded and the
playground/folder open
Option C: Manual Installation
-
Build
st-cli:cargo build -p st-cli --release -
Package the extension:
cd editors/vscode npm install npm run compile npx @vscode/vsce package --no-dependencies -
Install the
.vsix:code --install-extension iec61131-st-0.1.0.vsix -
Configure the server path in VSCode settings:
{ "structured-text.serverPath": "/path/to/st-cli" }
Configuration
| Setting | Default | Description |
|---|---|---|
structured-text.serverPath | st-cli | Path to the st-cli binary |
File Associations
The extension automatically activates for files with these extensions:
.st— Structured Text.scl— Structured Control Language (Siemens variant)
Troubleshooting
Extension not activating
- Check that
st-cliis built and accessible - Open the Output panel → select “Structured Text Language Server”
- Verify the binary path in settings
No syntax highlighting
- Reload the window: Ctrl+Shift+P → “Developer: Reload Window”
- Check that the file is recognized as “Structured Text” (bottom-right status bar)
LSP errors
- Build st-cli:
cargo build -p st-cli - Check the Output panel for error messages from the language server
Next Steps
Once the extension is installed and working, see the complete walkthrough:
Editing, Running & Debugging in VSCode — step-by-step guide covering hover, completion, diagnostics, running programs, setting breakpoints, stepping through code, and inspecting variables.
Editing, Running & Debugging in VSCode
This is a complete walkthrough of writing, running, and debugging an IEC 61131-3 Structured Text program in Visual Studio Code using the rust-plc toolchain.
Prerequisites
Before starting, make sure you have:
- rust-plc repository cloned and built (
cargo build -p st-cli) - VSCode extension installed (see VSCode Setup)
- Or simply use the Devcontainer — everything is pre-configured
Fastest way to start: Open the repository in VSCode and click “Reopen in Container”. After the container builds, everything is ready.
Step 1: Create a New ST Program
Open VSCode with the playground/ folder (or any folder with .st files).
Create a new file called my_program.st:
File → New File → Save as my_program.st
Paste this code:
(*
* My first ST program — a simple counter with threshold detection.
*)
FUNCTION IsAboveThreshold : BOOL
VAR_INPUT
value : INT;
threshold : INT;
END_VAR
IsAboveThreshold := value > threshold;
END_FUNCTION
PROGRAM Main
VAR
counter : INT := 0;
limit : INT := 50;
exceeded : BOOL := FALSE;
message : INT := 0;
END_VAR
counter := counter + 1;
exceeded := IsAboveThreshold(value := counter, threshold := limit);
IF exceeded THEN
message := 1;
ELSE
message := 0;
END_IF;
IF counter >= 100 THEN
counter := 0;
END_IF;
END_PROGRAM
What you should see immediately
As soon as you save the file:
-
Syntax highlighting — Keywords (
PROGRAM,IF,THEN,END_IF) appear in a distinct color. Types (INT,BOOL) are highlighted differently. Comments are dimmed. String and numeric literals have their own colors. -
No red squiggles — If the code is correct, no error underlines appear. The Problems panel (View → Problems) should show no errors.
-
Status bar — The bottom-right of VSCode shows
Structured Textas the language mode.
Step 2: Explore Editor Features
Hover for Type Information
Hold Ctrl (or Cmd on macOS) and hover over any variable or function name:
- Hover over
counter→ shows:counter : INTwithVarkind - Hover over
IsAboveThreshold→ shows the function signature:FUNCTION(value: INT, threshold: INT) : BOOL - Hover over
exceeded→ shows:exceeded : BOOL
Go to Definition
Ctrl+Click (or Cmd+Click) on any identifier to jump to its declaration:
- Click on
IsAboveThresholdin theexceeded :=line → jumps to theFUNCTION IsAboveThresholddeclaration at the top - Click on
counterin theIF counter >= 100line → jumps to theVARblock wherecounteris declared - Click on
limit→ jumps to its declaration
Code Completion
Start typing inside the program body. Completion suggestions appear automatically:
- Type
cou→ completion list showscounter,count(if any), and keywords starting with “COU” - Type
IF→ completion offers theIF...END_IFsnippet template - After a struct variable, type
.→ field names appear (e.g.,myStruct.showsx,y,value)
Snippet completions insert full control structures:
| Trigger | Expands to |
|---|---|
IF | IF ${condition} THEN ... END_IF; |
FOR | FOR ${i} := ${1} TO ${10} DO ... END_FOR; |
WHILE | WHILE ${condition} DO ... END_WHILE; |
CASE | CASE ${expression} OF ... END_CASE; |
FUNCTION | Full function template with VAR_INPUT |
FUNCTION_BLOCK | Full FB template |
PROGRAM | Full program template |
Go to Type Definition
Ctrl+Shift+Click (or use the right-click context menu → “Go to Type Definition”) on any variable to jump to the declaration of its type. This is especially useful with user-defined types:
- Click on a variable of type
MyStruct→ jumps to theTYPE MyStruct : STRUCT ... END_STRUCT; END_TYPEdeclaration - Click on a variable of type
MyFB→ jumps to theFUNCTION_BLOCK MyFBdeclaration
This differs from Go-to-definition (which jumps to where the variable is declared) by jumping to where the variable’s type is declared instead.
Signature Help
When calling a function or function block, the editor shows parameter hints automatically:
- Type
IsAboveThreshold(→ a tooltip appears showing(value: INT, threshold: INT) : BOOLwith the first parameter highlighted - Type a
,after the first argument → the tooltip advances to highlight the next parameter
This works for all FUNCTION and FUNCTION_BLOCK calls, showing parameter names and types as you type each argument.
Find All References
Press Shift+F12 (or right-click → “Find All References”) on any identifier to find every usage in the file:
- On
counter→ shows all lines wherecounteris read or assigned - On
IsAboveThreshold→ shows the declaration and all call sites
The search is case-insensitive and matches whole words only, consistent with IEC 61131-3 semantics.
Rename Symbol
Press F2 on any variable or POU name to rename it across all occurrences in the file:
- Place the cursor on
counter→ press F2 → typecycle_count→ all occurrences ofcounterare renamed tocycle_count
The rename is case-insensitive and applies to all references, declarations, and usages simultaneously.
Document Symbols (Outline)
Press Ctrl+Shift+O to open the document symbol picker, or open the Outline panel (View → Open View → Outline):
▼ Main (PROGRAM)
counter : Var : INT
limit : Var : INT
exceeded : Var : BOOL
message : Var : INT
▼ IsAboveThreshold (FUNCTION : BOOL)
value : VarInput : INT
threshold : VarInput : INT
This shows all POUs and their variables in a navigable tree. Type in the picker to filter by name.
Workspace Symbols
Press Ctrl+T to search for any POU or type across all open files in the workspace. This is useful when working with multi-file projects:
- Type
Main→ shows all PROGRAM/FUNCTION/FUNCTION_BLOCK declarations named “Main” across all.stfiles - Type
Temp→ finds any POU or type whose name contains “Temp”
Document Highlight
Place your cursor on any identifier and all other occurrences of that symbol in the file are instantly highlighted with a background color. This happens automatically with no keyboard shortcut needed:
- Click on
counter→ every reference tocounterin the file lights up - Click on
exceeded→ all usages are highlighted
This makes it easy to visually trace how a variable flows through your program.
Folding Ranges
Click the fold icons in the gutter (the small triangles next to line numbers) to collapse code blocks:
- PROGRAM / FUNCTION / FUNCTION_BLOCK — collapse entire POU bodies
- VAR / VAR_INPUT / VAR_OUTPUT — collapse variable declaration blocks
- IF / FOR / WHILE / CASE — collapse control flow blocks
- Comment blocks
(* ... *)— collapse multi-line comments
You can also use Ctrl+Shift+[ to fold and Ctrl+Shift+] to unfold the block at the cursor.
Document Links
File paths mentioned in comments become clickable links:
(* See utils.st for helper functions *)
// Reference: alarm_logic.st
Ctrl+Click on these paths to open the referenced file directly in the editor.
Formatting
Press Shift+Alt+F to auto-format the entire document. The formatter normalizes indentation to produce consistently readable code. You can also right-click and select “Format Document” from the context menu.
Code Actions (Quick Fixes)
When the LSP reports an undeclared variable, press Ctrl+. (or click the lightbulb icon) to see available quick fixes:
- If you type
new_var := 42;without declaringnew_var, the diagnostics underline it. Press Ctrl+. and select the quick fix to automatically addnew_var : INT;to the nearest VAR block.
Diagnostics (Error Detection)
Try introducing an error — change counter := counter + 1; to:
counter := counter + TRUE;
Immediately you’ll see:
- A red squiggly underline under
TRUE - The Problems panel shows:
left operand of '+' must be numeric, found 'BOOL' - A red circle appears on the file tab and in the Explorer
Fix the error to clear the diagnostic.
Common diagnostics the LSP catches:
| Error | Example |
|---|---|
| Undeclared variable | x := unknown_var; |
| Type mismatch | int_var := TRUE; |
| Wrong condition type | IF int_var THEN (needs BOOL) |
| Missing parameters | MyFunc() when params are required |
| Unused variables | Variable declared but never read |
| EXIT outside loop | EXIT; in program body |
| Duplicate declarations | Two variables with the same name |
Step 3: Run the Program
From the Terminal
Open the integrated terminal (Ctrl+`) and run:
# Check for errors (no execution)
st-cli check my_program.st
# Run a single scan cycle
st-cli run my_program.st
# Run 1000 scan cycles (like a real PLC)
st-cli run my_program.st -n 1000
Expected output for 1000 cycles:
Executed 1000 cycle(s) in 1.2ms (avg 1.2µs/cycle, 28 instructions)
This tells you:
- 1000 cycles were executed (like a PLC running for 1000 scans)
- 1.2µs per cycle — the average execution time
- 28 instructions — bytecode instructions per cycle
Understanding Scan Cycles
In a real PLC, programs execute in a continuous loop called the scan cycle:
┌─────────────┐
│ Read Inputs │ ← from sensors, switches
├─────────────┤
│ Execute │ ← your ST program runs here
│ Program │
├─────────────┤
│ Write │ ← to motors, valves, lights
│ Outputs │
└─────┬───────┘
│ repeat
└───────→ back to top
The -n 1000 flag simulates 1000 iterations of this loop. Global variables (VAR_GLOBAL) persist across cycles, so a counter increments each time.
Step 4: Debug the Program
Start a Debug Session
- Open
my_program.stin the editor - Set a breakpoint — click in the gutter (left margin) next to line
counter := counter + 1;. A red dot appears. - Press F5 or click Run → Start Debugging
- If prompted, select “Debug Current ST File”
What Happens
The debugger:
- Compiles
my_program.stto bytecode - Starts the VM paused on the first instruction
- Shows the Debug toolbar at the top of the editor:
▶ Continue ⏭ Step Over ⏬ Step Into ⏫ Step Out 🔄 Restart ⏹ Stop
The editor highlights the current line (typically the first executable statement) with a yellow background.
Debug Controls
| Button | Keyboard | Action |
|---|---|---|
| ▶ Continue | F5 | Run until next breakpoint (across scan cycles, up to 100,000) |
| ⏭ Step Over | F10 | Execute one statement, skip into function calls |
| ⏬ Step Into | F11 | Execute one statement, enter function calls |
| ⏫ Step Out | Shift+F11 | Run until current function returns |
| ⏹ Stop | Shift+F5 | End debug session |
PLC-Specific Debug Toolbar Buttons
The VSCode extension adds 4 PLC-specific buttons to the debug toolbar:
| Button | Action |
|---|---|
| Force | Force a variable to a specific value (overrides program logic) |
| Unforce | Remove the force override from a variable |
| List Forced | Show all currently forced variables and their values |
| Cycle Info | Display scan cycle statistics (count, timing) |
You can also use these via the Debug Console by typing evaluate expressions:
force counter = 42
unforce counter
listForced
scanCycleInfo
Inspect Variables
While paused, look at the Variables panel on the left (Debug sidebar):
▼ Locals
counter 0 INT
limit 50 INT
exceeded FALSE BOOL
message 0 INT
▼ Globals
(empty — no VAR_GLOBAL in this program)
The values update as you step through the code.
Step Through Code
- Press F10 (Step Over) — the highlighted line advances to the next statement
- After stepping past
counter := counter + 1;, check the Variables panel:counternow shows1
- Press F10 again — steps to the
exceeded := IsAboveThreshold(...)line - Press F11 (Step Into) — enters the
IsAboveThresholdfunction body - The Call Stack panel shows:
▼ PLC Scan Cycle
IsAboveThreshold line 10
Main line 24
- Press Shift+F11 (Step Out) — returns to
Main - Press F5 (Continue) — runs until the breakpoint is hit again (next scan cycle)
Watch Expressions
In the Watch panel, click + and type a variable name:
- Type
counter→ shows the current value - Type
exceeded→ showsTRUEorFALSE
The watch panel evaluates variable names against the current scope (locals first, then globals).
Breakpoint Features
- Toggle breakpoint: Click the gutter or press F9 on a line
- Remove all breakpoints: Run → Remove All Breakpoints
- Conditional breakpoints are not yet supported (future feature)
Debug a Program with Global Variables
Create counter_demo.st:
VAR_GLOBAL
total_cycles : INT;
END_VAR
PROGRAM Main
VAR
x : INT := 0;
END_VAR
total_cycles := total_cycles + 1;
x := total_cycles * 2;
END_PROGRAM
Debug this file and use Continue (F5) multiple times. Watch total_cycles increment in the Globals scope each time the program completes a cycle and restarts.
Step 5: Debug a Multi-POU Program
The debugger supports stepping into function calls across POUs.
Open playground/06_full_demo.st and set a breakpoint inside the CASE state OF block. Press F5 to start debugging:
- The program stops on entry
- Press F5 to continue — it hits your breakpoint
- Check the Variables panel to see all local variables and their current values
- Step through the state machine logic
- Use Step Into (F11) when a function like
Clamp(...)is called to enter it
Call Stack Navigation
When stopped inside a nested function call, the Call Stack panel shows all active frames:
▼ PLC Scan Cycle
Clamp line 32 ← current position
BottleFiller line 112 ← caller
Click on BottleFiller in the call stack to view the caller’s local variables and source position.
Step 6: PLC Monitor Panel
The Monitor Panel provides a live dashboard for observing and controlling PLC variables in real time while the program runs.
Open the Monitor Panel
- Open the Command Palette:
Ctrl+Shift+P(orCmd+Shift+Pon macOS) - Type and select: “ST: Open PLC Monitor”
- A webview panel opens showing all variables with live values
The monitor connects to the runtime via a WebSocket server that streams variable state after each scan cycle.
Monitor Features
| Feature | Description |
|---|---|
| Live variables | All global and program-local variables update in real time |
| Force variable | Right-click a variable to override its value (useful for testing) |
| Unforce variable | Remove the override and let the program control the value again |
| Trend recording | Watch how variable values change over time |
Forcing Variables
Forcing is essential during commissioning and testing. When a variable is forced:
- The forced value is written at the start of each scan cycle, overriding program logic
- A visual indicator shows which variables are currently forced
- Use “Unforce” to release the variable back to normal program control
Online Change via the Monitor
The monitor server’s WebSocket API also supports online change (hot-reload). When you modify and recompile a source file, you can push the new module to the running engine without stopping it:
- Compatible changes (e.g., modified logic, same variable layout) are applied instantly
- Variable values are automatically migrated to the new module
- Incompatible changes (e.g., added/removed variables, changed types) require a full restart
This enables an iterative development workflow where you can edit program logic and see the effects immediately in the monitor panel, without losing runtime state.
Troubleshooting
“Failed to start ST language server”
- Build the CLI:
cargo build -p st-cli - Check the setting
structured-text.serverPathpoints to the built binary
Breakpoints appear as gray circles (unverified)
- The line may not correspond to any executable bytecode instruction
- Try setting the breakpoint on an assignment or function call line instead of a
VARdeclaration orEND_IF
No syntax highlighting
- Check the status bar shows “Structured Text” (not “Plain Text”)
- If not, click the language mode and select “Structured Text”
- Reload the window: Ctrl+Shift+P → “Developer: Reload Window”
Debug session ends immediately
- Ensure the file has a
PROGRAMPOU (not just functions/FBs) - Check the terminal for compilation errors
Variables show <unknown>
- The variable may be out of scope
- Step into the function where the variable is declared
Quick Reference
| Action | Shortcut / How |
|---|---|
| CLI | |
| Check file | st-cli check file.st |
| Run program | st-cli run file.st -n 100 |
| LSP Features | |
| Hover for type info | Ctrl+hover on identifier (Cmd+hover on macOS) |
| Go to definition | Ctrl+Click on identifier (Cmd+Click on macOS) |
| Go to type definition | Right-click → Go to Type Definition |
| Code completion | Start typing, or Ctrl+Space (Cmd+Space on macOS) |
| Signature help | Type ( or , inside a function call |
| Find all references | Shift+F12 |
| Rename symbol | F2 |
| Document symbols (outline) | Ctrl+Shift+O (Cmd+Shift+O on macOS) |
| Workspace symbols | Ctrl+T (Cmd+T on macOS) |
| Document highlight | Place cursor on identifier (automatic) |
| Fold block | Ctrl+Shift+[ (Cmd+Option+[ on macOS) |
| Unfold block | Ctrl+Shift+] (Cmd+Option+] on macOS) |
| Document links | Ctrl+Click on file path in comment |
| Format document | Shift+Alt+F (Shift+Option+F on macOS) |
| Code action (quick fix) | Ctrl+. (Cmd+. on macOS) |
| Problems panel | View → Problems |
| Debugging | |
| Start debugging | Open .st file → F5 |
| Set breakpoint | Click gutter or F9 |
| Step over | F10 |
| Step into | F11 |
| Step out | Shift+F11 |
| Continue | F5 |
| Stop debugging | Shift+F5 |
| Force variable | Debug toolbar button or force x = 42 in Debug Console |
| Unforce variable | Debug toolbar button or unforce x in Debug Console |
| List forced variables | Debug toolbar button or listForced in Debug Console |
| Scan cycle info | Debug toolbar button or scanCycleInfo in Debug Console |
| Monitor | |
| Open PLC Monitor | Ctrl+Shift+P → “ST: Open PLC Monitor” |
| Force variable (Monitor) | Right-click variable in Monitor panel |
Program Structure
IEC 61131-3 Structured Text organizes code into Program Organization Units (POUs).
There are three kinds of POU: PROGRAM, FUNCTION, and FUNCTION_BLOCK. Each serves a
distinct role in a well-structured automation project.
PROGRAM
A PROGRAM is the top-level entry point. Every ST file executed with st-cli run must
contain at least one program. A program can declare local variables, call functions and
function block instances, and perform I/O.
PROGRAM Main
VAR
counter : INT := 0;
running : BOOL := TRUE;
END_VAR
IF running THEN
counter := counter + 1;
END_IF;
END_PROGRAM
Run it:
st-cli run main.st
A program’s variables retain their values between scan cycles. This is a key difference from functions, whose locals are re-initialized on every call.
FUNCTION
A FUNCTION is a stateless POU that computes a return value. It has no persistent local
state – every invocation starts fresh. Functions are the right choice for pure
computations such as unit conversions, math helpers, or validation checks.
FUNCTION Clamp : REAL
VAR_INPUT
value : REAL;
low : REAL;
high : REAL;
END_VAR
IF value < low THEN
Clamp := low;
ELSIF value > high THEN
Clamp := high;
ELSE
Clamp := value;
END_IF;
END_FUNCTION
The return value is assigned by writing to the function’s own name (Clamp := ...).
FUNCTION_BLOCK
A FUNCTION_BLOCK is a stateful POU. You create instances of a function block, and
each instance maintains its own private copy of all internal variables across calls. This
is the standard building block for timers, counters, PID controllers, and state machines.
FUNCTION_BLOCK PulseCounter
VAR_INPUT
pulse : BOOL;
END_VAR
VAR_OUTPUT
count : INT;
END_VAR
VAR
prev_pulse : BOOL := FALSE;
END_VAR
// Rising-edge detection
IF pulse AND NOT prev_pulse THEN
count := count + 1;
END_IF;
prev_pulse := pulse;
END_FUNCTION_BLOCK
Instantiate and use it inside a program:
PROGRAM Main
VAR
sensor_counter : PulseCounter;
sensor_input : BOOL;
END_VAR
sensor_counter(pulse := sensor_input);
IF sensor_counter.count > 100 THEN
// threshold reached
END_IF;
END_PROGRAM
How POUs Relate
| Feature | PROGRAM | FUNCTION | FUNCTION_BLOCK |
|---|---|---|---|
| Has persistent state | Yes | No | Yes (per instance) |
| Return value | No | Yes (one) | No (use outputs) |
| Can be instantiated | No (singleton) | No (called) | Yes |
| Can call functions | Yes | Yes | Yes |
| Can call FB instances | Yes | No | Yes |
Functions must remain side-effect-free in standard IEC 61131-3: they cannot instantiate function blocks or write to global variables. This compiler relaxes some of those rules, but keeping functions pure is strongly recommended.
POU Lifecycle
-
Programs are instantiated once when the runtime starts. Their
VARsections are initialized at that point. On each scan cycle the program body executes, and variables persist until the next cycle. -
Functions are called, execute, and return. Local variables exist only for the duration of the call. No state carries over between invocations.
-
Function blocks are instantiated as variables (typically inside a program or another function block). Their internal state is initialized when the instance is created and persists across every subsequent call. Each instance is independent.
Nesting and Composition
A program can instantiate multiple function blocks, and function blocks can instantiate other function blocks internally. This enables hierarchical composition:
FUNCTION_BLOCK MotorController
VAR_INPUT
enable : BOOL;
set_rpm : REAL;
END_VAR
VAR_OUTPUT
at_speed : BOOL;
END_VAR
VAR
ramp : RampGenerator;
pid : PID_Controller;
END_VAR
ramp(target := set_rpm, enable := enable);
pid(setpoint := ramp.output, enable := enable);
at_speed := ABS(pid.error) < 5.0;
END_FUNCTION_BLOCK
Minimal Complete Example
FUNCTION DoubleIt : INT
VAR_INPUT
x : INT;
END_VAR
DoubleIt := x * 2;
END_FUNCTION
PROGRAM Main
VAR
result : INT;
END_VAR
result := DoubleIt(x := 10);
// result is now 20
END_PROGRAM
st-cli run example.st
Programs, functions, and function blocks can all coexist in the same source file or be split across multiple files depending on project conventions.
Data Types
IEC 61131-3 defines a rich set of elementary data types designed for industrial automation. This chapter covers every supported type, the type hierarchy, and the literal formats used to write constant values in code.
Boolean
| Type | Size | Values |
|---|---|---|
BOOL | 1 bit | TRUE, FALSE |
VAR
motor_on : BOOL := TRUE;
fault : BOOL := FALSE;
END_VAR
Integer Types
Signed and unsigned integers come in four widths:
| Signed | Unsigned | Size | Range |
|---|---|---|---|
SINT | USINT | 8-bit | -128..127 / 0..255 |
INT | UINT | 16-bit | -32768..32767 / 0..65535 |
DINT | UDINT | 32-bit | -2^31..2^31-1 / 0..2^32-1 |
LINT | ULINT | 64-bit | -2^63..2^63-1 / 0..2^64-1 |
VAR
temperature : INT := -40;
rpm : UINT := 3600;
big_count : LINT := 1_000_000_000;
END_VAR
Underscores in numeric literals are allowed for readability.
Floating-Point Types
| Type | Size | Precision |
|---|---|---|
REAL | 32-bit | ~7 decimal digits |
LREAL | 64-bit | ~15 decimal digits |
VAR
setpoint : REAL := 72.5;
pi : LREAL := 3.14159265358979;
END_VAR
Bit-String Types
These types are used for bitwise operations and direct bit access:
| Type | Size |
|---|---|
BYTE | 8-bit |
WORD | 16-bit |
DWORD | 32-bit |
LWORD | 64-bit |
VAR
status_flags : WORD := 16#00FF;
mask : DWORD := 2#11110000_11110000_11110000_11110000;
END_VAR
Bit-string types are not interchangeable with integers. Use explicit conversions when mixing arithmetic and bitwise operations.
Time and Duration Types
| Type | Represents | Example literal |
|---|---|---|
TIME | Duration | T#5s, T#1h30m |
DATE | Calendar date | D#2024-01-15 |
TOD | Time of day | TOD#14:30:00 |
DT | Date and time combined | DT#2024-01-15-14:30:00 |
VAR
cycle_time : TIME := T#100ms;
start_date : DATE := D#2024-01-15;
shift_start : TOD := TOD#06:00:00;
timestamp : DT := DT#2024-01-15-08:00:00;
END_VAR
Caveat: The keyword
DTis a reserved type name. If you name a variabledt, it will conflict with theDTkeyword. Since keywords are case-insensitive,dt,Dt, andDTall refer to the type. Use descriptive names likedate_timeormy_dtinstead.
TIME literal components
A TIME literal begins with T# or TIME# followed by one or more components:
d– daysh– hoursm– minutess– secondsms– millisecondsus– microsecondsns– nanoseconds
VAR
short_delay : TIME := T#250ms;
work_shift : TIME := T#8h;
precise : TIME := T#1m30s500ms;
END_VAR
String Types
| Type | Encoding | Default max length |
|---|---|---|
STRING | Single-byte | 80 characters |
WSTRING | UTF-16 | 80 characters |
VAR
name : STRING := 'Hello, PLC!';
label : STRING[200] := 'Extended length string';
unicode : WSTRING := "Wide string literal";
END_VAR
Single-byte strings use single quotes. Wide strings use double quotes.
Type Hierarchy
The IEC 61131-3 type system is organized hierarchically:
ANY
├── ANY_BIT
│ ├── BOOL
│ ├── BYTE
│ ├── WORD
│ ├── DWORD
│ └── LWORD
├── ANY_NUM
│ ├── ANY_INT
│ │ ├── ANY_SIGNED (SINT, INT, DINT, LINT)
│ │ └── ANY_UNSIGNED (USINT, UINT, UDINT, ULINT)
│ └── ANY_REAL (REAL, LREAL)
├── ANY_STRING (STRING, WSTRING)
├── ANY_DATE (DATE, TOD, DT)
└── ANY_DURATION (TIME)
The ANY_* groups are used in function signatures to accept a range of types. For
example, an ADD function accepts ANY_NUM inputs.
Literal Formats
Integer literals
| Base | Prefix | Example | Decimal value |
|---|---|---|---|
| Decimal | (none) | 255 | 255 |
| Hex | 16# | 16#FF | 255 |
| Octal | 8# | 8#77 | 63 |
| Binary | 2# | 2#1010 | 10 |
VAR
dec_val : INT := 100;
hex_val : INT := 16#64;
oct_val : INT := 8#144;
bin_val : INT := 2#01100100;
END_VAR
// All four variables hold the value 100.
Real literals
Real numbers use a decimal point and optional exponent:
VAR
a : REAL := 3.14;
b : REAL := 3.14e2; // 314.0
c : REAL := 1.0E-3; // 0.001
d : REAL := -2.5e+10;
END_VAR
Typed literals
A typed literal forces a specific type on a constant value:
VAR
x : INT := INT#42;
y : REAL := REAL#3.14;
z : BYTE := BYTE#16#FF;
w : BOOL := BOOL#1;
END_VAR
This is especially useful in function calls where overload resolution needs a hint, or
when assigning to a generic (ANY_NUM) parameter.
Implicit Conversions
Narrowing conversions (e.g., DINT to INT) are not implicit and require an explicit
conversion function like DINT_TO_INT(). Widening conversions (e.g., INT to DINT) are
generally safe and may be performed implicitly by the compiler.
VAR
small : INT := 42;
big : DINT;
back : INT;
END_VAR
big := small; // OK: widening, implicit
back := DINT_TO_INT(big); // Required: narrowing, explicit
Variables
Variables in Structured Text are declared inside VAR blocks within a POU. The kind of
VAR block determines visibility, direction, and lifetime. This chapter covers every
variable section, qualifiers, initialization, and declaration syntax.
Variable Sections
VAR – Local Variables
Local variables are private to the POU. They persist across scan cycles in programs and function blocks, but are re-initialized on every call in functions.
PROGRAM Main
VAR
cycle_count : INT := 0;
pressure : REAL;
END_VAR
cycle_count := cycle_count + 1;
END_PROGRAM
VAR_INPUT – Input Parameters
Inputs are passed by value into the POU by the caller. The POU may read but should not write to them.
FUNCTION_BLOCK Heater
VAR_INPUT
enable : BOOL;
setpoint : REAL := 70.0;
END_VAR
IF enable THEN
// regulate to setpoint
END_IF;
END_FUNCTION_BLOCK
VAR_OUTPUT – Output Parameters
Outputs are values produced by the POU that the caller can read after invocation via dot notation on function block instances.
FUNCTION_BLOCK TemperatureSensor
VAR_OUTPUT
value : REAL;
valid : BOOL;
END_VAR
value := ReadAnalogInput();
valid := (value > -40.0) AND (value < 150.0);
END_FUNCTION_BLOCK
Reading outputs:
PROGRAM Main
VAR
sensor : TemperatureSensor;
END_VAR
sensor();
IF sensor.valid THEN
// use sensor.value
END_IF;
END_PROGRAM
VAR_IN_OUT – Pass by Reference
VAR_IN_OUT parameters are passed by reference. The caller must supply a variable (not a
literal), and any modification inside the POU is reflected in the caller’s variable.
FUNCTION_BLOCK Accumulator
VAR_IN_OUT
total : REAL;
END_VAR
VAR_INPUT
increment : REAL;
END_VAR
total := total + increment;
END_FUNCTION_BLOCK
PROGRAM Main
VAR
running_sum : REAL := 0.0;
acc : Accumulator;
END_VAR
acc(total := running_sum, increment := 1.5);
// running_sum is now 1.5
END_PROGRAM
VAR_GLOBAL – Global Variables
Global variables are declared at the top level (outside any POU or in a dedicated global
block) and are visible to all POUs that reference them via VAR_EXTERNAL.
VAR_GLOBAL
system_mode : INT := 0;
alarm_active : BOOL := FALSE;
END_VAR
VAR_EXTERNAL – Referencing Globals
A POU uses VAR_EXTERNAL to access a previously declared global variable. The type must
match exactly.
PROGRAM Main
VAR_EXTERNAL
system_mode : INT;
END_VAR
IF system_mode = 1 THEN
// run mode
END_IF;
END_PROGRAM
VAR_TEMP – Temporary Variables
Temporary variables exist only for one execution of the POU body. They are re-initialized
on every scan cycle, even in programs and function blocks (unlike VAR, which persists).
PROGRAM Main
VAR_TEMP
scratch : INT;
END_VAR
scratch := HeavyComputation();
// scratch is gone after this cycle's execution ends
END_PROGRAM
Qualifiers
Qualifiers appear after the VAR keyword (or its variant) and before the variable
declarations.
CONSTANT
Declares read-only variables. The value must be set at declaration and cannot be changed.
VAR CONSTANT
MAX_SPEED : REAL := 1500.0;
SENSOR_COUNT : INT := 8;
END_VAR
RETAIN
Retained variables survive a warm restart of the PLC. Their values are stored in non-volatile memory.
VAR RETAIN
total_runtime : TIME := T#0s;
boot_count : DINT := 0;
END_VAR
PERSISTENT
Persistent variables survive both warm and cold restarts. They are only reset by an explicit user action.
VAR PERSISTENT
machine_serial : STRING := '';
calibration : REAL := 1.0;
END_VAR
Combining Qualifiers
Qualifiers can be combined:
VAR RETAIN PERSISTENT
lifetime_hours : LREAL := 0.0;
END_VAR
Initialization
Every variable can have an initializer using :=. Without one, the variable is
zero-initialized (0, FALSE, empty string, T#0s, etc.).
VAR
a : INT; // initialized to 0
b : INT := 42; // initialized to 42
c : BOOL; // initialized to FALSE
d : STRING := 'OK'; // initialized to 'OK'
END_VAR
Structured initialization
Arrays and structs can be initialized with parenthesized lists:
VAR
temps : ARRAY[1..5] OF REAL := [20.0, 21.5, 22.0, 19.8, 20.5];
origin : Point := (x := 0.0, y := 0.0);
END_VAR
Multiple Variables on One Line
Multiple variables of the same type can be declared on a single line, separated by commas:
VAR
x, y, z : REAL := 0.0;
a, b : INT;
run, stop : BOOL;
END_VAR
All variables on the line share the same type and initial value. If you need different initial values, use separate declarations.
Practical Example: State Machine Variables
PROGRAM ConveyorControl
VAR
state : INT := 0;
speed : REAL := 0.0;
item_count : DINT := 0;
END_VAR
VAR_INPUT
start_btn : BOOL;
stop_btn : BOOL;
sensor : BOOL;
END_VAR
VAR_OUTPUT
motor_cmd : REAL;
running : BOOL;
END_VAR
VAR CONSTANT
MAX_SPEED : REAL := 2.0; // m/s
END_VAR
VAR RETAIN
total_items : DINT := 0;
END_VAR
CASE state OF
0: // Idle
IF start_btn THEN
state := 1;
END_IF;
1: // Running
speed := MAX_SPEED;
IF sensor THEN
item_count := item_count + 1;
total_items := total_items + 1;
END_IF;
IF stop_btn THEN
state := 0;
speed := 0.0;
END_IF;
END_CASE;
motor_cmd := speed;
running := (state = 1);
END_PROGRAM
Summary
| Section | Direction | Persistence | Passed by |
|---|---|---|---|
VAR | Local | Per POU type | N/A |
VAR_INPUT | In | N/A | Value |
VAR_OUTPUT | Out | N/A | Value |
VAR_IN_OUT | In/Out | N/A | Reference |
VAR_GLOBAL | Global | Program scope | N/A |
VAR_EXTERNAL | Global ref | Program scope | N/A |
VAR_TEMP | Local | Single execution | N/A |
Expressions
Expressions in Structured Text combine variables, literals, operators, and function calls to produce values. This chapter covers operator precedence, grouping with parentheses, and using function calls within expressions.
Operator Precedence
Operators are listed from highest to lowest precedence. Operators on the same row have equal precedence and are evaluated left-to-right.
| Precedence | Operator(s) | Description |
|---|---|---|
| 1 (highest) | ** | Exponentiation |
| 2 | - (unary), NOT | Negation, bitwise/logical NOT |
| 3 | *, /, MOD | Multiplication, division, modulo |
| 4 | +, - | Addition, subtraction |
| 5 | <, >, <=, >= | Relational comparisons |
| 6 | =, <> | Equality, inequality |
| 7 | AND, & | Logical/bitwise AND |
| 8 | XOR | Logical/bitwise XOR |
| 9 (lowest) | OR | Logical/bitwise OR |
When in doubt, use parentheses to make intent explicit.
Arithmetic Operators
Standard arithmetic works on numeric types (ANY_NUM):
VAR
a, b, result : INT;
END_VAR
a := 10;
b := 3;
result := a + b; // 13
result := a - b; // 7
result := a * b; // 30
result := a / b; // 3 (integer division)
result := a MOD b; // 1
Exponentiation
The ** operator raises the left operand to the power of the right operand:
VAR
x : REAL;
END_VAR
x := 2.0 ** 10.0; // 1024.0
x := 3.0 ** 0.5; // square root of 3
Unary Negation
VAR
speed : REAL := 50.0;
reverse_speed : REAL;
END_VAR
reverse_speed := -speed; // -50.0
Comparison Operators
Comparisons return BOOL:
VAR
temp : REAL := 85.0;
too_hot : BOOL;
in_range: BOOL;
END_VAR
too_hot := temp > 100.0;
in_range := (temp >= 60.0) AND (temp <= 100.0);
The full set:
| Operator | Meaning |
|---|---|
= | Equal to |
<> | Not equal to |
< | Less than |
> | Greater than |
<= | Less than or equal |
>= | Greater than or equal |
Note that = is the equality comparison operator, not assignment. Assignment uses :=.
Logical and Bitwise Operators
AND, OR, XOR, and NOT operate on BOOL values (logical) or bit-string types
(bitwise), depending on the operand types:
VAR
a, b : BOOL;
result : BOOL;
END_VAR
result := a AND b;
result := a OR b;
result := a XOR b;
result := NOT a;
The & symbol is an alternative for AND:
result := a & b; // same as a AND b
Bitwise example
VAR
flags : WORD := 16#FF00;
mask : WORD := 16#0F0F;
masked : WORD;
END_VAR
masked := flags AND mask; // 16#0F00
Parentheses
Parentheses override the default precedence:
VAR
a : INT := 2;
b : INT := 3;
c : INT := 4;
r : INT;
END_VAR
r := a + b * c; // 14 (multiplication first)
r := (a + b) * c; // 20 (addition first)
A practical example – checking a sensor range with debounce:
VAR
pressure : REAL;
pump_active : BOOL;
override : BOOL;
should_run : BOOL;
END_VAR
should_run := (pressure < 50.0 OR override) AND NOT pump_active;
Without parentheses this expression would bind differently due to AND having higher
precedence than OR. Always parenthesize mixed AND/OR expressions.
Function Calls in Expressions
Functions that return a value can be used directly inside expressions:
FUNCTION ABS_REAL : REAL
VAR_INPUT
x : REAL;
END_VAR
IF x < 0.0 THEN
ABS_REAL := -x;
ELSE
ABS_REAL := x;
END_IF;
END_FUNCTION
PROGRAM Main
VAR
error : REAL := -3.5;
clamped : REAL;
END_VAR
// Function call as part of a larger expression
clamped := ABS_REAL(x := error) * 2.0 + 1.0;
// result: 8.0
END_PROGRAM
Nested function calls are also valid:
VAR
angle : REAL;
dist : REAL;
END_VAR
dist := SQRT(SIN(angle) ** 2.0 + COS(angle) ** 2.0);
// Always 1.0, but demonstrates nesting
Precedence in Practice
Consider a PID error calculation:
VAR
setpoint : REAL := 100.0;
measured : REAL := 95.0;
deadband : REAL := 2.0;
error : REAL;
needs_action : BOOL;
END_VAR
error := setpoint - measured;
needs_action := error > deadband OR error < -deadband;
Because > and < bind tighter than OR, this evaluates as:
(error > deadband) OR (error < -deadband)
which is the intended behavior. Still, explicit parentheses make the intent clearer for anyone reading the code.
Common Pitfalls
Confusing = and :=. The single = is comparison, not assignment. Writing
x = 5 in an IF condition compares; writing x := 5 assigns.
Integer division truncates. 7 / 2 yields 3, not 3.5. Use REAL operands for
fractional results: 7.0 / 2.0 yields 3.5.
MOD with negative operands. The sign of the result follows the dividend:
-7 MOD 3 yields -1.
NOT precedence. NOT a AND b means (NOT a) AND b, not NOT (a AND b). Parenthesize
when in doubt.
Statements
Statements are the executable instructions that make up a POU body. Structured Text provides assignment, conditional branching, looping, and flow control. Every statement ends with a semicolon.
Assignment
The assignment operator is :=. The left side must be a variable; the right side is any
expression of a compatible type.
VAR
speed : REAL;
counter : INT;
running : BOOL;
END_VAR
speed := 1500.0;
counter := counter + 1;
running := speed > 0.0;
IF / ELSIF / ELSE / END_IF
The IF statement executes blocks conditionally. ELSIF and ELSE branches are optional.
IF temperature > 100.0 THEN
alarm := TRUE;
heater := FALSE;
ELSIF temperature < 60.0 THEN
heater := TRUE;
ELSE
// in range, maintain
heater := heater;
END_IF;
Multiple ELSIF branches are allowed:
IF level = 0 THEN
state_name := 'IDLE';
ELSIF level = 1 THEN
state_name := 'LOW';
ELSIF level = 2 THEN
state_name := 'MEDIUM';
ELSIF level = 3 THEN
state_name := 'HIGH';
ELSE
state_name := 'UNKNOWN';
END_IF;
For many discrete values, prefer CASE (below).
CASE / OF / END_CASE
The CASE statement selects among branches based on an integer or enumeration value.
CASE machine_state OF
0:
// Idle
motor := FALSE;
valve := FALSE;
1:
// Starting
motor := TRUE;
valve := FALSE;
2:
// Running
motor := TRUE;
valve := TRUE;
3:
// Stopping
motor := FALSE;
valve := TRUE;
ELSE
// Unknown state, emergency stop
motor := FALSE;
valve := FALSE;
alarm := TRUE;
END_CASE;
Multiple values per branch
A single branch can match several values using commas or ranges:
CASE error_code OF
0:
status := 'OK';
1, 2, 3:
status := 'WARNING';
10..19:
status := 'SENSOR_FAULT';
20..29:
status := 'COMM_FAULT';
ELSE
status := 'UNKNOWN';
END_CASE;
FOR / TO / BY / DO / END_FOR
The FOR loop iterates a counter from a start value to an end value with an optional step.
VAR
i : INT;
total : INT := 0;
data : ARRAY[1..10] OF INT;
END_VAR
FOR i := 1 TO 10 DO
total := total + data[i];
END_FOR;
BY clause
The BY keyword specifies the step. It defaults to 1 if omitted.
// Count down from 10 to 0
FOR i := 10 TO 0 BY -1 DO
// process in reverse
END_FOR;
// Step by 2 (even indices only)
FOR i := 0 TO 100 BY 2 DO
even_sum := even_sum + i;
END_FOR;
The loop variable should not be modified inside the loop body. Doing so leads to undefined behavior in standard IEC 61131-3.
WHILE / DO / END_WHILE
The WHILE loop repeats as long as its condition is TRUE. The condition is checked
before each iteration, so the body may never execute.
VAR
pressure : REAL;
END_VAR
WHILE pressure < 100.0 DO
pressure := pressure + ReadPressureIncrement();
END_WHILE;
A practical example – draining a buffer:
VAR
buffer_count : INT;
END_VAR
WHILE buffer_count > 0 DO
ProcessNextItem();
buffer_count := buffer_count - 1;
END_WHILE;
REPEAT / UNTIL / END_REPEAT
The REPEAT loop is like WHILE but checks its condition after the body, so the body
always executes at least once.
VAR
attempts : INT := 0;
success : BOOL := FALSE;
END_VAR
REPEAT
attempts := attempts + 1;
success := TryConnect();
UNTIL success OR (attempts >= 3)
END_REPEAT;
Note: the condition follows UNTIL without a THEN or DO. The loop exits when the
condition becomes TRUE.
RETURN
RETURN exits the current POU immediately. In a function, it returns whatever value has
been assigned to the function name so far. In a program or function block, it ends the
current scan cycle execution for that POU.
FUNCTION SafeDivide : REAL
VAR_INPUT
numerator : REAL;
denominator : REAL;
END_VAR
IF denominator = 0.0 THEN
SafeDivide := 0.0;
RETURN;
END_IF;
SafeDivide := numerator / denominator;
END_FUNCTION
EXIT
EXIT breaks out of the innermost FOR, WHILE, or REPEAT loop.
VAR
i : INT;
found : INT := -1;
data : ARRAY[1..100] OF INT;
END_VAR
FOR i := 1 TO 100 DO
IF data[i] = 42 THEN
found := i;
EXIT;
END_IF;
END_FOR;
// If found <> -1, data[found] = 42
EXIT only affects the innermost loop. In nested loops, the outer loop continues:
VAR
row, col : INT;
END_VAR
FOR row := 1 TO 10 DO
FOR col := 1 TO 10 DO
IF matrix[row, col] = 0 THEN
EXIT; // exits inner loop only
END_IF;
END_FOR;
// continues with next row
END_FOR;
Empty Statement
A lone semicolon is a valid empty statement. It does nothing and can be useful as a placeholder:
IF condition THEN
; // intentionally empty, to be implemented
END_IF;
Complete Example: Simple State Machine
PROGRAM BatchMixer
VAR
state : INT := 0;
timer : INT := 0;
fill_done : BOOL;
mix_time : INT := 100;
END_VAR
CASE state OF
0: // IDLE
IF start_cmd THEN
state := 1;
timer := 0;
END_IF;
1: // FILLING
fill_valve := TRUE;
IF fill_done THEN
fill_valve := FALSE;
state := 2;
timer := 0;
END_IF;
2: // MIXING
mixer_motor := TRUE;
timer := timer + 1;
IF timer >= mix_time THEN
mixer_motor := FALSE;
state := 3;
END_IF;
3: // DRAINING
drain_valve := TRUE;
IF level <= 0 THEN
drain_valve := FALSE;
state := 0;
END_IF;
ELSE
// fault recovery
state := 0;
END_CASE;
END_PROGRAM
st-cli run batch_mixer.st
Functions
A FUNCTION is a stateless POU that accepts inputs, performs a computation, and returns
a single value. Functions have no persistent state – local variables are initialized fresh
on every call. This makes functions ideal for pure computations: conversions, math helpers,
validation, and formatting.
Declaration
A function declaration specifies the function name and its return type after the colon:
FUNCTION FunctionName : ReturnType
VAR_INPUT
// input parameters
END_VAR
VAR
// local variables
END_VAR
// body
END_FUNCTION
Return Value
The return value is assigned by writing to the function name itself:
FUNCTION CelsiusToFahrenheit : REAL
VAR_INPUT
celsius : REAL;
END_VAR
CelsiusToFahrenheit := celsius * 9.0 / 5.0 + 32.0;
END_FUNCTION
You may assign to the function name multiple times. The last assigned value before the function returns is the result:
FUNCTION Classify : INT
VAR_INPUT
value : REAL;
END_VAR
Classify := 0; // default: nominal
IF value > 100.0 THEN
Classify := 2; // high
ELSIF value > 80.0 THEN
Classify := 1; // warning
ELSIF value < 0.0 THEN
Classify := -1; // underrange
END_IF;
END_FUNCTION
Use RETURN to exit early after assigning the return value:
FUNCTION SafeSqrt : REAL
VAR_INPUT
x : REAL;
END_VAR
IF x < 0.0 THEN
SafeSqrt := -1.0;
RETURN;
END_IF;
SafeSqrt := SQRT(x);
END_FUNCTION
Calling with Named Arguments
Named (formal) argument syntax passes each parameter by name using :=:
PROGRAM Main
VAR
temp_f : REAL;
END_VAR
temp_f := CelsiusToFahrenheit(celsius := 100.0);
// temp_f = 212.0
END_PROGRAM
Named arguments can appear in any order:
FUNCTION Clamp : REAL
VAR_INPUT
value : REAL;
low : REAL;
high : REAL;
END_VAR
IF value < low THEN
Clamp := low;
ELSIF value > high THEN
Clamp := high;
ELSE
Clamp := value;
END_IF;
END_FUNCTION
PROGRAM Main
VAR
result : REAL;
END_VAR
// Order does not matter with named arguments
result := Clamp(high := 100.0, value := 150.0, low := 0.0);
// result = 100.0
END_PROGRAM
Calling with Positional Arguments
Arguments can also be passed positionally, matching the declaration order of VAR_INPUT:
result := Clamp(150.0, 0.0, 100.0);
// value=150.0, low=0.0, high=100.0
Do not mix positional and named arguments in the same call. Use one style consistently.
Local Variables
Functions can declare local variables in a VAR block. These are initialized on every
call and do not persist:
FUNCTION Average : REAL
VAR_INPUT
a, b, c : REAL;
END_VAR
VAR
sum : REAL;
END_VAR
sum := a + b + c;
Average := sum / 3.0;
END_FUNCTION
Functions Without Inputs
A function may have no inputs, though this is uncommon:
FUNCTION GetTimestamp : LINT
GetTimestamp := ReadSystemClock();
END_FUNCTION
Recursive Functions
Functions may call themselves recursively, but be cautious of stack depth in resource-constrained PLC environments:
FUNCTION Factorial : LINT
VAR_INPUT
n : LINT;
END_VAR
IF n <= 1 THEN
Factorial := 1;
ELSE
Factorial := n * Factorial(n := n - 1);
END_IF;
END_FUNCTION
Practical Examples
Linear Interpolation
FUNCTION Lerp : REAL
VAR_INPUT
a : REAL; // start value
b : REAL; // end value
t : REAL; // 0.0 to 1.0
END_VAR
Lerp := a + (b - a) * t;
END_FUNCTION
Scaling an Analog Input
A common PLC task is converting a raw ADC count to engineering units:
FUNCTION ScaleAnalog : REAL
VAR_INPUT
raw : INT; // 0..32767
eng_low : REAL; // engineering low (e.g., 0.0 PSI)
eng_high : REAL; // engineering high (e.g., 100.0 PSI)
END_VAR
VAR
fraction : REAL;
END_VAR
fraction := INT_TO_REAL(raw) / 32767.0;
ScaleAnalog := eng_low + fraction * (eng_high - eng_low);
END_FUNCTION
PROGRAM Main
VAR
raw_pressure : INT := 16384;
pressure_psi : REAL;
END_VAR
pressure_psi := ScaleAnalog(
raw := raw_pressure,
eng_low := 0.0,
eng_high := 100.0
);
// pressure_psi ~ 50.0
END_PROGRAM
Bitfield Check
FUNCTION IsBitSet : BOOL
VAR_INPUT
value : DWORD;
bit : INT;
END_VAR
IsBitSet := (value AND SHL(DWORD#1, bit)) <> 0;
END_FUNCTION
Restrictions
Standard IEC 61131-3 places the following restrictions on functions:
- Functions must not instantiate function blocks.
- Functions must not write to global variables.
- Functions must not have side effects.
These restrictions ensure that a function’s output depends only on its inputs, making programs easier to reason about. This compiler may relax some of these constraints, but adhering to them is strongly recommended for portable, maintainable code.
Function Blocks
A FUNCTION_BLOCK is a stateful Program Organisation Unit. Unlike plain
functions, function blocks retain their internal variables across calls,
making them the primary building block for timers, counters, PID loops,
state machines, and communication handlers.
Declaration
FUNCTION_BLOCK BlockName
VAR_INPUT
enable : BOOL; (* supplied by caller *)
END_VAR
VAR_OUTPUT
result : INT; (* readable by caller after the call *)
END_VAR
VAR_IN_OUT
shared : REAL; (* passed by reference *)
END_VAR
VAR
internal : INT := 0; (* private persistent state *)
END_VAR
(* body *)
END_FUNCTION_BLOCK
Variable Sections
| Section | Direction | Semantics |
|---|---|---|
VAR_INPUT | In | Copied from caller at invocation |
VAR_OUTPUT | Out | Readable by caller via dot notation |
VAR_IN_OUT | In/Out | Passed by reference; caller must supply a variable, not a literal |
VAR | Private | Internal state, invisible to caller |
Instantiation
Function blocks are used by declaring instances as variables. Each instance owns an independent copy of all internal state.
PROGRAM Main
VAR
counter1 : UpCounter;
counter2 : UpCounter;
END_VAR
counter1(increment := TRUE);
counter2(increment := FALSE);
(* counter1 and counter2 have completely separate state *)
END_PROGRAM
Calling with Named Parameters
Invoke an instance by writing its name followed by parenthesised named
arguments using the := assignment syntax:
delay(IN := start_signal, PT := T#5s);
All VAR_INPUT parameters that have no default must be supplied.
Parameters with defaults may be omitted.
Accessing Outputs via Dot Notation
After calling an instance, read its VAR_OUTPUT fields with
instance.output:
delay(IN := start_signal, PT := T#5s);
IF delay.Q THEN
(* 5 seconds have elapsed *)
END_IF;
elapsed := delay.ET;
You may also read outputs without calling first, which returns the value from the previous cycle.
State Persistence Across Calls
VAR and VAR_OUTPUT variables keep their values between calls. This
is the key difference from a plain FUNCTION:
FUNCTION_BLOCK UpCounter
VAR_INPUT
increment : BOOL;
reset : BOOL;
END_VAR
VAR_OUTPUT
count : INT := 0;
END_VAR
VAR
prev_increment : BOOL := FALSE;
END_VAR
IF reset THEN
count := 0;
ELSIF increment AND NOT prev_increment THEN
count := count + 1; (* rising edge detection *)
END_IF;
prev_increment := increment;
END_FUNCTION_BLOCK
Each scan cycle picks up exactly where the last one left off.
Realistic Example: Timer-Like Block
FUNCTION_BLOCK CycleTimer
VAR_INPUT
IN : BOOL;
PT : INT; (* preset in scan cycles *)
END_VAR
VAR_OUTPUT
Q : BOOL;
ET : INT := 0;
END_VAR
VAR
running : BOOL := FALSE;
END_VAR
IF IN THEN
IF NOT running THEN
ET := 0;
running := TRUE;
END_IF;
ET := ET + 1;
Q := ET >= PT;
ELSE
running := FALSE;
Q := FALSE;
ET := 0;
END_IF;
END_FUNCTION_BLOCK
PROGRAM Main
VAR
btn : BOOL;
tmr : CycleTimer;
motor : BOOL;
END_VAR
tmr(IN := btn, PT := 100);
motor := tmr.Q;
END_PROGRAM
Nesting Function Blocks
A function block can instantiate other function blocks in its VAR
section:
FUNCTION_BLOCK Controller
VAR
filter : LowPass;
limiter : Clamp;
END_VAR
(* ... *)
filter(input := raw_value, alpha := 0.1);
limiter(value := filter.output, lo := 0.0, hi := 100.0);
result := limiter.clamped;
END_FUNCTION_BLOCK
Summary
| Aspect | FUNCTION | FUNCTION_BLOCK |
|---|---|---|
| State persistence | None | Yes, per instance |
| Return value | One, via name | None (use VAR_OUTPUT) |
| Instantiation | Called directly | Declared as a variable |
| Use cases | Pure computation | Timers, counters, state |
Classes, Methods & Interfaces
IEC 61131-3 Third Edition introduces object-oriented extensions. A CLASS is a stateful Program Organisation Unit — like a FUNCTION_BLOCK — but with explicit methods, access control, inheritance, and interface implementation.
Class Declaration
CLASS ClassName
VAR_INPUT
setpoint : INT; (* supplied by caller *)
END_VAR
VAR_OUTPUT
output : INT; (* readable via dot notation *)
END_VAR
VAR
_internal : INT := 0; (* private persistent state *)
END_VAR
METHOD MethodName : INT (* methods define behavior *)
VAR_INPUT
param : INT;
END_VAR
MethodName := _internal + param;
END_METHOD
END_CLASS
Instantiation
Classes are instantiated by declaring variables of the class type. Each instance has completely independent state.
PROGRAM Main
VAR
ctrl1 : MotorController;
ctrl2 : MotorController;
END_VAR
ctrl1.Start();
ctrl2.Stop();
(* ctrl1 and ctrl2 have separate internal state *)
END_PROGRAM
Methods
Methods define the behavior of a class. They can accept parameters, return values, and access the class’s variables directly.
Void methods (no return value)
CLASS Counter
VAR
_count : INT := 0;
END_VAR
METHOD Increment
_count := _count + 1;
END_METHOD
METHOD Reset
_count := 0;
END_METHOD
END_CLASS
Methods with return values
Name the return type after a colon. Assign the return value using the method name:
METHOD GetCount : INT
GetCount := _count;
END_METHOD
METHOD Add : INT
VAR_INPUT
a : INT;
b : INT;
END_VAR
Add := a + b;
END_METHOD
Calling methods
Use dot notation with named or positional arguments:
counter.Increment();
val := counter.GetCount();
sum := counter.Add(a := 10, b := 20);
Access Specifiers
Control member visibility with PUBLIC, PRIVATE, PROTECTED, or INTERNAL:
CLASS Sensor
VAR
_raw : INT := 0;
END_VAR
PUBLIC METHOD GetValue : INT
GetValue := _raw;
END_METHOD
PRIVATE METHOD Calibrate
_raw := _raw + 1;
END_METHOD
PROTECTED METHOD InternalUpdate
(* accessible within this class and subclasses *)
END_METHOD
END_CLASS
If omitted, methods default to PUBLIC.
Interfaces
An INTERFACE declares a contract — a set of method signatures that implementing classes must provide.
INTERFACE IResettable
METHOD Reset
END_METHOD
END_INTERFACE
INTERFACE IControllable
METHOD Enable
END_METHOD
METHOD Disable
END_METHOD
END_INTERFACE
Implementing interfaces
Use IMPLEMENTS to declare that a class fulfils an interface. The compiler verifies that all required methods are present:
CLASS Motor IMPLEMENTS IControllable, IResettable
VAR
_running : BOOL := FALSE;
END_VAR
METHOD Enable
_running := TRUE;
END_METHOD
METHOD Disable
_running := FALSE;
END_METHOD
METHOD Reset
_running := FALSE;
END_METHOD
END_CLASS
Interface inheritance
Interfaces can extend other interfaces:
INTERFACE IFullCounter EXTENDS IResettable
METHOD Increment
END_METHOD
METHOD GetCount : INT
END_METHOD
END_INTERFACE
Inheritance
Use EXTENDS to create a subclass that inherits all variables and methods from a base class:
CLASS Base
VAR
_value : INT := 0;
END_VAR
METHOD GetValue : INT
GetValue := _value;
END_METHOD
END_CLASS
CLASS Derived EXTENDS Base
VAR
_extra : INT := 0;
END_VAR
METHOD GetSum : INT
(* can access both inherited _value and own _extra *)
GetSum := _value + _extra;
END_METHOD
END_CLASS
Calling inherited methods
A derived instance can call methods defined in any ancestor class:
VAR d : Derived; END_VAR
val := d.GetValue(); (* inherited from Base *)
sum := d.GetSum(); (* defined in Derived *)
Overriding methods
Use OVERRIDE to replace a parent method’s behavior:
CLASS Base
METHOD Process : INT
Process := 0;
END_METHOD
END_CLASS
CLASS Derived EXTENDS Base
OVERRIDE METHOD Process : INT
Process := 42; (* replaces Base.Process *)
END_METHOD
END_CLASS
The compiler checks that an overridden method actually exists in the base class.
Abstract Classes
An ABSTRACT class cannot be instantiated directly. It defines methods that subclasses must implement:
ABSTRACT CLASS Shape
ABSTRACT METHOD Area : REAL
END_METHOD
METHOD Describe : INT
Describe := 1; (* concrete method — inherited as-is *)
END_METHOD
END_CLASS
CLASS Rectangle EXTENDS Shape
VAR
_w : REAL := 0.0;
_h : REAL := 0.0;
END_VAR
METHOD SetSize
VAR_INPUT w : REAL; h : REAL; END_VAR
_w := w; _h := h;
END_METHOD
OVERRIDE METHOD Area : REAL
Area := _w * _h; (* must implement abstract method *)
END_METHOD
END_CLASS
Abstract methods have no body — only the ABSTRACT METHOD ... END_METHOD signature.
Final Classes and Methods
FINAL prevents further extension or overriding:
FINAL CLASS Singleton
(* this class cannot be extended *)
END_CLASS
CLASS Base
FINAL METHOD Locked : INT
Locked := 42; (* this method cannot be overridden *)
END_METHOD
END_CLASS
Properties
Properties provide getter/setter syntax for encapsulated field access:
CLASS Thermostat
VAR
_setpoint : INT := 20;
END_VAR
PROPERTY Setpoint : INT
GET
Setpoint := _setpoint;
END_GET
SET
IF Setpoint >= 0 THEN
_setpoint := Setpoint;
END_IF;
END_SET
END_PROPERTY
END_CLASS
Read-only properties omit the SET block.
Pointers and Classes
Class methods can accept and use pointers to modify external state:
CLASS Logger
VAR _count : INT := 0; END_VAR
METHOD WriteCount
VAR_INPUT target : REF_TO INT; END_VAR
IF target <> NULL THEN
target^ := _count;
END_IF;
END_METHOD
METHOD Log
_count := _count + 1;
END_METHOD
END_CLASS
Multi-File Projects
Classes, interfaces, and functions can be split across files. The two-pass compiler resolves forward references automatically — file order does not matter:
project/
interfaces/resettable.st (* INTERFACE IResettable *)
classes/sensor.st (* CLASS Sensor IMPLEMENTS IResettable *)
classes/controller.st (* CLASS TempController EXTENDS BaseController *)
utils/math.st (* FUNCTION Clamp *)
main.st (* PROGRAM Main — uses all of the above *)
plc-project.yaml
See playground/oop_project/ for a complete multi-file example.
Realistic Example
A complete PID-like controller using classes, inheritance, and interfaces:
INTERFACE IResettable
METHOD Reset
END_METHOD
END_INTERFACE
CLASS BaseController IMPLEMENTS IResettable
VAR
_enabled : BOOL := FALSE;
_output : INT := 0;
END_VAR
METHOD Enable
_enabled := TRUE;
END_METHOD
METHOD Disable
_enabled := FALSE;
_output := 0;
END_METHOD
METHOD GetOutput : INT
GetOutput := _output;
END_METHOD
METHOD Reset
_enabled := FALSE;
_output := 0;
END_METHOD
END_CLASS
CLASS TempController EXTENDS BaseController
VAR
_setpoint : INT := 50;
_gain : INT := 20;
END_VAR
METHOD Configure
VAR_INPUT sp : INT; gain : INT; END_VAR
_setpoint := sp;
_gain := gain;
END_METHOD
METHOD Compute
VAR_INPUT pv : INT; END_VAR
VAR error : INT; END_VAR
IF _enabled THEN
error := _setpoint - pv;
_output := (error * _gain) / 10;
IF _output < 0 THEN _output := 0; END_IF;
IF _output > 100 THEN _output := 100; END_IF;
ELSE
_output := 0;
END_IF;
END_METHOD
END_CLASS
PROGRAM Main
VAR
ctrl : TempController;
simTemp : INT := 30;
END_VAR
ctrl.Configure(sp := 50, gain := 15);
ctrl.Enable();
ctrl.Compute(pv := simTemp);
(* ctrl.GetOutput() returns the control action *)
END_PROGRAM
Comparison: CLASS vs FUNCTION_BLOCK
| Aspect | FUNCTION_BLOCK | CLASS |
|---|---|---|
| State persistence | Yes | Yes |
| Methods | Single body only | Named methods with access control |
| Inheritance | No | EXTENDS single inheritance |
| Interfaces | No | IMPLEMENTS one or more |
| Abstract/Final | No | ABSTRACT, FINAL modifiers |
| Properties | No | PROPERTY with GET/SET |
| Calling convention | fb(input := val); | obj.Method(param := val); |
| Output access | fb.output | obj.Method() or obj.field |
Playground Examples
playground/10_classes.st— basic OOP feature tourplayground/11_class_patterns.st— industrial patterns (state machines, alarm managers, 3-level inheritance)playground/14_class_instances.st— lifecycle, composition, producer-consumerplayground/oop_project/— multi-file project with classes across files
st-cli run playground/10_classes.st -n 10
st-cli run playground/oop_project/ -n 100
User-Defined Types
IEC 61131-3 allows you to define custom types using TYPE ... END_TYPE blocks. This
includes structures, enumerations, arrays, subrange types, and type aliases. User-defined
types improve readability, enforce constraints, and enable structured data modeling.
TYPE Block Syntax
All user-defined types are declared inside TYPE ... END_TYPE:
TYPE
MyType : <type definition>;
END_TYPE
Multiple types can be declared in a single block.
Structures (STRUCT)
Structures group related variables into a single composite type:
TYPE
MotorData : STRUCT
speed : REAL;
current : REAL;
running : BOOL;
fault : BOOL;
op_hours : LREAL;
END_STRUCT;
END_TYPE
Using structures
PROGRAM Main
VAR
pump_motor : MotorData;
fan_motor : MotorData;
END_VAR
pump_motor.speed := 1450.0;
pump_motor.running := TRUE;
IF pump_motor.fault THEN
pump_motor.speed := 0.0;
pump_motor.running := FALSE;
END_IF;
END_PROGRAM
Nested structures
Structures can contain other structures:
TYPE
Coordinate : STRUCT
x : REAL;
y : REAL;
z : REAL;
END_STRUCT;
RobotArm : STRUCT
position : Coordinate;
target : Coordinate;
speed : REAL;
gripper : BOOL;
END_STRUCT;
END_TYPE
PROGRAM Main
VAR
arm : RobotArm;
END_VAR
arm.position.x := 100.0;
arm.target.x := 200.0;
arm.gripper := TRUE;
END_PROGRAM
Initialization
Structure variables can be initialized with named field values:
VAR
origin : Coordinate := (x := 0.0, y := 0.0, z := 0.0);
arm : RobotArm := (speed := 50.0, gripper := FALSE);
END_VAR
Fields not explicitly initialized default to their zero value.
Enumerations
Enumerations define a type with a fixed set of named values:
TYPE
MachineState : (IDLE, STARTING, RUNNING, STOPPING, FAULTED);
END_TYPE
Using enumerations
PROGRAM Main
VAR
state : MachineState := MachineState#IDLE;
END_VAR
CASE state OF
MachineState#IDLE:
IF start_button THEN
state := MachineState#STARTING;
END_IF;
MachineState#STARTING:
state := MachineState#RUNNING;
MachineState#RUNNING:
IF stop_button THEN
state := MachineState#STOPPING;
END_IF;
MachineState#FAULTED:
IF reset_button THEN
state := MachineState#IDLE;
END_IF;
END_CASE;
END_PROGRAM
The qualified syntax MachineState#IDLE avoids ambiguity when multiple enumerations share
a value name.
Enumerations with explicit values
You can assign integer values to enumeration members:
TYPE
AlarmPriority : (
NONE := 0,
LOW := 1,
MEDIUM := 2,
HIGH := 3,
CRITICAL := 4
);
END_TYPE
This is useful when the enumeration must map to specific protocol codes, register values, or database entries.
TYPE
CommStatus : (
OK := 0,
TIMEOUT := 16#01,
CRC_ERROR := 16#02,
FRAME_ERROR := 16#04,
DISCONNECTED := 16#FF
);
END_TYPE
Arrays
Arrays are declared with index ranges using ARRAY[low..high] OF type:
TYPE
TenReals : ARRAY[1..10] OF REAL;
SensorArray : ARRAY[0..7] OF INT;
END_TYPE
Inline array declarations
Arrays can also be declared directly in variable sections without a separate TYPE:
VAR
temperatures : ARRAY[1..8] OF REAL;
error_log : ARRAY[0..99] OF INT;
END_VAR
Multi-dimensional arrays
TYPE
Matrix3x3 : ARRAY[1..3, 1..3] OF REAL;
END_TYPE
PROGRAM Main
VAR
transform : Matrix3x3;
END_VAR
transform[1, 1] := 1.0;
transform[2, 2] := 1.0;
transform[3, 3] := 1.0;
// identity matrix diagonal
END_PROGRAM
Array initialization
VAR
weights : ARRAY[1..5] OF REAL := [1.0, 2.0, 3.0, 4.0, 5.0];
flags : ARRAY[0..3] OF BOOL := [TRUE, FALSE, FALSE, TRUE];
END_VAR
Arrays of structures
TYPE
SensorReading : STRUCT
channel : INT;
value : REAL;
valid : BOOL;
END_STRUCT;
END_TYPE
VAR
readings : ARRAY[1..16] OF SensorReading;
END_VAR
FOR i := 1 TO 16 DO
IF readings[i].valid THEN
total := total + readings[i].value;
count := count + 1;
END_IF;
END_FOR;
Subrange Types
A subrange type restricts an integer type to a specific range of values:
TYPE
Percentage : INT(0..100);
DayOfWeek : USINT(1..7);
MotorRPM : UINT(0..3600);
END_TYPE
Assigning a value outside the declared range is a runtime error:
VAR
level : Percentage;
END_VAR
level := 50; // OK
level := 150; // runtime error: out of range
Subrange types are useful for self-documenting code and catching logic errors early.
Type Aliases
A type alias gives a new name to an existing type:
TYPE
Temperature : REAL;
Pressure : REAL;
FlowRate : REAL;
ErrorCode : DINT;
END_TYPE
Aliases improve readability and allow you to change the underlying type in one place:
VAR
tank_temp : Temperature := 25.0;
tank_press : Pressure := 101.3;
flow : FlowRate;
error : ErrorCode;
END_VAR
Note that aliases are structurally typed – a Temperature and a Pressure are both
REAL and can be used interchangeably. They do not create distinct types for type-checking
purposes.
Complete Example: Recipe System
TYPE
IngredientUnit : (GRAMS, MILLILITERS, UNITS);
Ingredient : STRUCT
name : STRING[50];
amount : REAL;
unit : IngredientUnit;
added : BOOL;
END_STRUCT;
Recipe : STRUCT
name : STRING[100];
ingredients : ARRAY[1..10] OF Ingredient;
step_count : INT;
mix_time : TIME;
temperature : REAL;
END_STRUCT;
BatchState : (
WAITING := 0,
LOADING := 1,
MIXING := 2,
HEATING := 3,
COMPLETE := 4,
ERROR := 99
);
END_TYPE
PROGRAM BatchController
VAR
recipe : Recipe;
state : BatchState := BatchState#WAITING;
step : INT := 1;
END_VAR
CASE state OF
BatchState#WAITING:
IF start_cmd THEN
state := BatchState#LOADING;
step := 1;
END_IF;
BatchState#LOADING:
IF step <= recipe.step_count THEN
IF recipe.ingredients[step].added THEN
step := step + 1;
END_IF;
ELSE
state := BatchState#MIXING;
END_IF;
BatchState#MIXING:
mixer := TRUE;
// transition after mix_time elapsed
BatchState#COMPLETE:
mixer := FALSE;
heater := FALSE;
END_CASE;
END_PROGRAM
Naming Caveat
When naming types or variables, be aware that all IEC 61131-3 keywords are
case-insensitive. A type or variable named dt, Dt, or DT conflicts with the DT
(Date and Time) keyword. Similarly, tod, int, or real as variable names will conflict
with their respective keywords. Always use descriptive names that avoid keyword collisions:
- Use
date_timeinstead ofdt - Use
time_of_dayinstead oftod - Use
countinstead ofint
Pointers & References
IEC 61131-3 supports typed pointers via REF_TO, the REF() function, and the ^ dereference operator. Pointers allow indirect access to variables — reading or writing a variable through a reference rather than by name.
Declaring a Pointer
Use REF_TO <type> to declare a pointer variable:
VAR
x : INT := 42;
ptr : REF_TO INT; (* pointer to an INT variable *)
bptr : REF_TO BOOL; (* pointer to a BOOL variable *)
rptr : REF_TO REAL; (* pointer to a REAL variable *)
END_VAR
A pointer variable holds the address of another variable of the specified type. When first declared, a pointer is NULL (points to nothing).
Taking a Reference — REF()
Use the REF() function to get a pointer to a variable:
VAR
x : INT := 42;
ptr : REF_TO INT;
END_VAR
ptr := REF(x); (* ptr now points to x *)
REF() works with both local and global variables:
VAR_GLOBAL
g_sensor : REAL;
END_VAR
PROGRAM Main
VAR
ptr : REF_TO REAL;
END_VAR
ptr := REF(g_sensor); (* pointer to a global variable *)
END_PROGRAM
Dereferencing — ^
The ^ operator reads or writes through a pointer:
Reading through a pointer
VAR
x : INT := 42;
ptr : REF_TO INT;
y : INT;
END_VAR
ptr := REF(x);
y := ptr^; (* y is now 42 — read x through ptr *)
Writing through a pointer
VAR
x : INT := 42;
ptr : REF_TO INT;
END_VAR
ptr := REF(x);
ptr^ := 99; (* x is now 99 — written through ptr *)
Read-modify-write
ptr^ := ptr^ + 1; (* increment x through the pointer *)
NULL Pointer
NULL is a built-in literal representing an empty pointer:
VAR
ptr : REF_TO INT;
END_VAR
ptr := NULL; (* explicitly set to null *)
Default value: All REF_TO variables are initialized to NULL unless assigned.
Safe dereference: Dereferencing a NULL pointer returns the default value for the type (0 for INT, 0.0 for REAL, FALSE for BOOL). It does not crash the program.
VAR
ptr : REF_TO INT;
x : INT;
END_VAR
x := ptr^; (* x = 0, because ptr is NULL *)
Passing Pointers to Functions
Pointers are especially useful for functions that need to modify their caller’s variables:
Swap function
FUNCTION SwapInt : INT
VAR_INPUT
a : REF_TO INT;
b : REF_TO INT;
END_VAR
VAR
temp : INT;
END_VAR
temp := a^;
a^ := b^;
b^ := temp;
SwapInt := 0;
END_FUNCTION
PROGRAM Main
VAR
x : INT := 10;
y : INT := 20;
dummy : INT;
END_VAR
dummy := SwapInt(a := REF(x), b := REF(y));
(* x is now 20, y is now 10 *)
END_PROGRAM
Increment via pointer
FUNCTION IncrementBy : INT
VAR_INPUT
target : REF_TO INT;
amount : INT;
END_VAR
target^ := target^ + amount;
IncrementBy := target^;
END_FUNCTION
Reassigning Pointers
A pointer can be reassigned to point to different variables:
VAR
a : INT := 10;
b : INT := 20;
ptr : REF_TO INT;
END_VAR
ptr := REF(a);
ptr^ := ptr^ + 5; (* a is now 15 *)
ptr := REF(b);
ptr^ := ptr^ * 2; (* b is now 40 *)
Comparison with VAR_IN_OUT
Both pointers and VAR_IN_OUT allow indirect variable access:
| Feature | VAR_IN_OUT | REF_TO |
|---|---|---|
| Syntax | VAR_IN_OUT x : INT; END_VAR | ptr : REF_TO INT; |
| Assignment | Bound at call site | Can be reassigned at any time |
| NULL | Never null | Can be NULL |
| Flexibility | Fixed for the duration of the call | Can point to different variables |
| Use case | Function parameters | Dynamic data structures, callbacks |
Restrictions
- No pointer arithmetic — you cannot add or subtract from a pointer (unlike C)
- Type-safe —
REF_TO INTcan only point toINTvariables - No nested pointers —
REF_TO REF_TO INTis not supported - No pointer comparison — comparing two pointers is not currently supported (use
NULLcomparison via<>)
Example: Playground
See playground/09_pointers.st for a complete example demonstrating:
- Basic pointer read/write
- Swap function using pointers
- Increment via pointer
- NULL pointer safety
st-cli run playground/09_pointers.st -n 10
Standard Library
The IEC 61131-3 standard library provides a set of reusable function blocks and functions commonly needed in PLC programming. The library is implemented as plain Structured Text source files in the stdlib/ directory and is automatically loaded by the compiler via builtin_stdlib() – all standard library functions and function blocks are available in every program without any import statements.
The library includes:
| Module | Source File | Contents |
|---|---|---|
| Counters | stdlib/counters.st | CTU, CTD, CTUD |
| Edge Detection | stdlib/edge_detection.st | R_TRIG, F_TRIG |
| Timers | stdlib/timers.st | TON, TOF, TP |
| Math & Selection | stdlib/math.st | MAX, MIN, LIMIT, ABS, SEL |
| Type Conversions | Compiler intrinsics | 30+ TO functions (INT_TO_REAL, REAL_TO_INT, etc.) |
| Trig & Math Intrinsics | Compiler intrinsics | SQRT, SIN, COS, TAN, ASIN, ACOS, ATAN, LN, LOG, EXP |
| System Time | Compiler intrinsic | SYSTEM_TIME() |
Counters
Source: stdlib/counters.st
Counters are function blocks that track rising edges on their count inputs. Because they are function blocks (not functions), each instance retains its internal state across scan cycles.
CTU – Count Up
Increments CV on each rising edge of CU. Sets Q to TRUE when CV reaches or exceeds the preset value PV. The RESET input sets CV back to 0.
Inputs
| Name | Type | Description |
|---|---|---|
CU | BOOL | Count up – increments on rising edge |
RESET | BOOL | Reset counter to 0 |
PV | INT | Preset value – Q goes TRUE when CV >= PV |
Outputs
| Name | Type | Description |
|---|---|---|
Q | BOOL | TRUE when CV >= PV |
CV | INT | Current counter value |
Example
PROGRAM CounterExample
VAR
my_counter : CTU;
pulse : BOOL;
END_VAR
my_counter(CU := pulse, RESET := FALSE, PV := 10);
// my_counter.Q is TRUE after 10 rising edges on pulse
// my_counter.CV holds the current count
END_PROGRAM
CTD – Count Down
Decrements CV on each rising edge of CD. The LOAD input sets CV to PV. Sets Q to TRUE when CV drops to 0 or below.
Inputs
| Name | Type | Description |
|---|---|---|
CD | BOOL | Count down – decrements on rising edge |
LOAD | BOOL | Load preset value into CV |
PV | INT | Preset value |
Outputs
| Name | Type | Description |
|---|---|---|
Q | BOOL | TRUE when CV <= 0 |
CV | INT | Current counter value |
Example
PROGRAM CountdownExample
VAR
parts_left : CTD;
part_sensor : BOOL;
END_VAR
parts_left(CD := part_sensor, LOAD := FALSE, PV := 100);
// parts_left.Q goes TRUE when all 100 parts consumed
END_PROGRAM
CTUD – Count Up/Down
Combines up-counting and down-counting in a single block. RESET sets CV to 0; LOAD sets CV to PV. When neither reset nor load is active, rising edges on CU increment and rising edges on CD decrement.
Inputs
| Name | Type | Description |
|---|---|---|
CU | BOOL | Count up – increments on rising edge |
CD | BOOL | Count down – decrements on rising edge |
RESET | BOOL | Reset counter to 0 |
LOAD | BOOL | Load preset value into CV |
PV | INT | Preset value |
Outputs
| Name | Type | Description |
|---|---|---|
QU | BOOL | TRUE when CV >= PV |
QD | BOOL | TRUE when CV <= 0 |
CV | INT | Current counter value |
Example
PROGRAM UpDownExample
VAR
inventory : CTUD;
item_in : BOOL;
item_out : BOOL;
END_VAR
inventory(CU := item_in, CD := item_out,
RESET := FALSE, LOAD := FALSE, PV := 50);
// inventory.QU = TRUE when stock is full (>= 50)
// inventory.QD = TRUE when stock is empty (<= 0)
END_PROGRAM
Note: All counters detect rising edges internally. They only count on the FALSE-to-TRUE transition of the count input, not while it is held TRUE.
Edge Detection
Source: stdlib/edge_detection.st
Edge detection function blocks produce a single-cycle pulse when a signal changes state. They are the building blocks used internally by counters and timers, and are useful on their own for detecting button presses, sensor transitions, and other discrete events.
R_TRIG – Rising Edge
Q is TRUE for exactly one scan cycle when CLK transitions from FALSE to TRUE.
Inputs
| Name | Type | Description |
|---|---|---|
CLK | BOOL | Signal to monitor |
Outputs
| Name | Type | Description |
|---|---|---|
Q | BOOL | TRUE for one cycle on rising edge |
F_TRIG – Falling Edge
Q is TRUE for exactly one scan cycle when CLK transitions from TRUE to FALSE.
Inputs
| Name | Type | Description |
|---|---|---|
CLK | BOOL | Signal to monitor |
Outputs
| Name | Type | Description |
|---|---|---|
Q | BOOL | TRUE for one cycle on falling edge |
Example
PROGRAM EdgeExample
VAR
start_btn_edge : R_TRIG;
stop_btn_edge : F_TRIG;
start_button : BOOL;
stop_button : BOOL;
motor_on : BOOL;
END_VAR
start_btn_edge(CLK := start_button);
stop_btn_edge(CLK := stop_button);
IF start_btn_edge.Q THEN
motor_on := TRUE; // Start on button press
END_IF;
IF stop_btn_edge.Q THEN
motor_on := FALSE; // Stop on button release
END_IF;
END_PROGRAM
Timers
Source: stdlib/timers.st
Timers use real-time TIME values and the SYSTEM_TIME() intrinsic to measure wall-clock elapsed time. The preset value PT is a TIME type specified using TIME literals (e.g., T#5s, T#100ms, T#1m30s). This makes timers independent of scan cycle speed.
Note: The timer input is named
IN1(notIN) to avoid a keyword conflict in Structured Text.
All three timer blocks share the same input/output signature:
Inputs
| Name | Type | Description |
|---|---|---|
IN1 | BOOL | Timer input |
PT | TIME | Preset time (e.g., T#5s, T#500ms) |
Outputs
| Name | Type | Description |
|---|---|---|
Q | BOOL | Timer output |
ET | TIME | Elapsed time |
TON – On-Delay Timer
Q goes TRUE after IN1 has been TRUE for at least the duration PT. When IN1 goes FALSE, both Q and ET reset immediately. Internally, the timer records the start time via SYSTEM_TIME() when IN1 first goes TRUE, and computes ET as the difference on each scan.
IN1: _____|‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾|_____
ET: T#0s ... increasing ... T#0s
Q: _____| ‾‾‾‾‾‾‾|_____
^-- ET >= PT reached
TOF – Off-Delay Timer
Q goes TRUE immediately when IN1 goes TRUE. When IN1 goes FALSE, Q stays TRUE for the duration PT before turning FALSE.
IN1: _____|‾‾‾‾‾|________________________
Q: _____|‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾|___________
ET: T#0s T#0s ... increasing ... T#0s
^-- ET >= PT, Q goes FALSE
TP – Pulse Timer
On a rising edge of IN1, Q goes TRUE for exactly the duration PT, regardless of what IN1 does during the pulse. A new pulse cannot be triggered while the current one is active.
IN1: _____|‾‾‾‾‾‾‾‾‾‾‾‾‾|________
Q: _____|‾‾‾‾‾‾‾‾‾|____________
ET: T#0s ... increasing ... T#0s
^-- ET >= PT, pulse ends
Example
PROGRAM TimerExample
VAR
debounce : TON;
raw_input : BOOL;
clean_input : BOOL;
END_VAR
// Debounce: require input to be stable for 5 seconds
debounce(IN1 := raw_input, PT := T#5s);
clean_input := debounce.Q;
END_PROGRAM
Math & Selection
Source: stdlib/math.st
Math functions are pure functions (not function blocks) – they have no internal state and return a value directly.
Integer Functions
| Function | Signature | Description |
|---|---|---|
MAX_INT | MAX_INT(IN1: INT, IN2: INT) : INT | Returns the larger of two values |
MIN_INT | MIN_INT(IN1: INT, IN2: INT) : INT | Returns the smaller of two values |
ABS_INT | ABS_INT(IN1: INT) : INT | Returns the absolute value |
LIMIT_INT | LIMIT_INT(MN: INT, IN1: INT, MX: INT) : INT | Clamps IN1 to range [MN, MX] |
REAL Functions
| Function | Signature | Description |
|---|---|---|
MAX_REAL | MAX_REAL(IN1: REAL, IN2: REAL) : REAL | Returns the larger of two values |
MIN_REAL | MIN_REAL(IN1: REAL, IN2: REAL) : REAL | Returns the smaller of two values |
ABS_REAL | ABS_REAL(IN1: REAL) : REAL | Returns the absolute value |
LIMIT_REAL | LIMIT_REAL(MN: REAL, IN1: REAL, MX: REAL) : REAL | Clamps IN1 to range [MN, MX] |
Selection
| Function | Signature | Description |
|---|---|---|
SEL | SEL(G: BOOL, IN0: INT, IN1: INT) : INT | Returns IN0 when G=FALSE, IN1 when G=TRUE |
Example
PROGRAM MathExample
VAR
sensor_val : INT := -42;
clamped : INT;
bigger : INT;
mode : BOOL := TRUE;
chosen : INT;
END_VAR
clamped := LIMIT_INT(MN := 0, IN1 := sensor_val, MX := 100);
// clamped = 0 (sensor_val is below MN)
bigger := MAX_INT(IN1 := 10, IN2 := 20);
// bigger = 20
chosen := SEL(G := mode, IN0 := 50, IN1 := 75);
// chosen = 75 (mode is TRUE, so IN1 selected)
END_PROGRAM
Type Conversions
Type conversion functions are implemented as compiler intrinsics. The compiler recognizes *_TO_* function name patterns and emits ToInt, ToReal, or ToBool VM instructions directly, rather than calling a user-defined function. The file stdlib/conversions.st serves as documentation for the available conversions.
To REAL / LREAL
| Function | Description |
|---|---|
INT_TO_REAL | Integer to REAL |
SINT_TO_REAL | Short integer to REAL |
DINT_TO_REAL | Double integer to REAL |
LINT_TO_REAL | Long integer to REAL |
UINT_TO_REAL | Unsigned integer to REAL |
USINT_TO_REAL | Unsigned short integer to REAL |
UDINT_TO_REAL | Unsigned double integer to REAL |
ULINT_TO_REAL | Unsigned long integer to REAL |
BOOL_TO_REAL | Boolean to REAL (FALSE=0.0, TRUE=1.0) |
INT_TO_LREAL | Integer to LREAL |
SINT_TO_LREAL | Short integer to LREAL |
DINT_TO_LREAL | Double integer to LREAL |
LINT_TO_LREAL | Long integer to LREAL |
REAL_TO_LREAL | REAL to LREAL |
To INT / DINT / LINT / SINT
| Function | Description |
|---|---|
REAL_TO_INT | REAL to integer (truncates) |
LREAL_TO_INT | LREAL to integer (truncates) |
REAL_TO_DINT | REAL to double integer |
LREAL_TO_DINT | LREAL to double integer |
REAL_TO_LINT | REAL to long integer |
LREAL_TO_LINT | LREAL to long integer |
REAL_TO_SINT | REAL to short integer |
LREAL_TO_SINT | LREAL to short integer |
BOOL_TO_INT | Boolean to integer (FALSE=0, TRUE=1) |
BOOL_TO_DINT | Boolean to double integer |
BOOL_TO_LINT | Boolean to long integer |
UINT_TO_INT | Unsigned integer to signed integer |
UDINT_TO_DINT | Unsigned double integer to signed |
ULINT_TO_LINT | Unsigned long integer to signed |
INT_TO_DINT | Integer to double integer |
INT_TO_LINT | Integer to long integer |
DINT_TO_LINT | Double integer to long integer |
SINT_TO_INT | Short integer to integer |
SINT_TO_DINT | Short integer to double integer |
SINT_TO_LINT | Short integer to long integer |
To BOOL
| Function | Description |
|---|---|
INT_TO_BOOL | Integer to boolean (0=FALSE, nonzero=TRUE) |
REAL_TO_BOOL | REAL to boolean |
DINT_TO_BOOL | Double integer to boolean |
LINT_TO_BOOL | Long integer to boolean |
Example
PROGRAM ConversionExample
VAR
flag : BOOL := TRUE;
flag_as_int : INT;
my_real : REAL;
my_int : INT := 42;
value_as_bool : BOOL;
END_VAR
flag_as_int := BOOL_TO_INT(IN1 := flag);
// flag_as_int = 1
my_real := INT_TO_REAL(IN1 := my_int);
// my_real = 42.0
value_as_bool := INT_TO_BOOL(IN1 := my_int);
// value_as_bool = TRUE
END_PROGRAM
Trigonometric & Math Intrinsics
These functions are VM intrinsic instructions – the compiler recognizes the function name and emits a dedicated bytecode instruction. They operate on REAL values.
| Function | Signature | Description |
|---|---|---|
SQRT | SQRT(IN1: REAL) : REAL | Square root |
SIN | SIN(IN1: REAL) : REAL | Sine (radians) |
COS | COS(IN1: REAL) : REAL | Cosine (radians) |
TAN | TAN(IN1: REAL) : REAL | Tangent (radians) |
ASIN | ASIN(IN1: REAL) : REAL | Arc sine |
ACOS | ACOS(IN1: REAL) : REAL | Arc cosine |
ATAN | ATAN(IN1: REAL) : REAL | Arc tangent |
LN | LN(IN1: REAL) : REAL | Natural logarithm |
LOG | LOG(IN1: REAL) : REAL | Base-10 logarithm |
EXP | EXP(IN1: REAL) : REAL | Exponential (e^x) |
Example
PROGRAM TrigExample
VAR
angle : REAL := 1.5708; // approx pi/2
result : REAL;
root : REAL;
END_VAR
result := SIN(IN1 := angle);
// result ~ 1.0
root := SQRT(IN1 := 144.0);
// root = 12.0
END_PROGRAM
System Time
SYSTEM_TIME() is a compiler intrinsic that returns the elapsed time in milliseconds since the engine started, as a TIME value. It is used internally by the standard library timers (TON, TOF, TP) and can also be called directly from user programs.
Example
PROGRAM TimestampExample
VAR
now : TIME;
END_VAR
now := SYSTEM_TIME();
// now contains the elapsed time since engine start
END_PROGRAM
Creating Custom Modules
You can extend the standard library by adding your own .st files to the stdlib/ directory. Any FUNCTION or FUNCTION_BLOCK defined there will be automatically available in all programs.
Steps
- Create a new
.stfile in thestdlib/directory (e.g.,stdlib/my_blocks.st). - Define your functions or function blocks using standard ST syntax.
- They are immediately available in all programs – no import needed.
Guidelines
- Use
FUNCTION_BLOCKwhen you need to retain state across scan cycles (e.g., filters, controllers, state machines). - Use
FUNCTIONfor stateless computations (e.g., math, conversions, scaling). - Follow the naming conventions of the existing library: uppercase names for standard-style blocks, descriptive
VAR_INPUT/VAR_OUTPUTnames. - Add a comment header describing what the module provides.
Example
The file playground/08_custom_module.st demonstrates the pattern with three custom blocks:
- Hysteresis – a function block that turns an output ON when an input exceeds a high threshold and OFF when it drops below a low threshold, preventing oscillation around a single setpoint.
- Averager – a function block that computes a running average of input samples.
- ScaleWithDeadBand – a function that applies a dead band and scale factor to a raw value.
// Define a custom function block
FUNCTION_BLOCK Hysteresis
VAR_INPUT
input_val : INT;
high_threshold : INT;
low_threshold : INT;
END_VAR
VAR_OUTPUT
output : BOOL;
END_VAR
IF input_val >= high_threshold THEN
output := TRUE;
ELSIF input_val <= low_threshold THEN
output := FALSE;
END_IF;
// Between thresholds: output holds previous state
END_FUNCTION_BLOCK
// Use it in a program
PROGRAM Main
VAR
ctrl : Hysteresis;
temperature : INT;
END_VAR
ctrl(input_val := temperature,
high_threshold := 60,
low_threshold := 40);
// ctrl.output is TRUE when temp >= 60,
// FALSE when temp <= 40,
// unchanged in between
END_PROGRAM
To make this available globally, move the FUNCTION_BLOCK Hysteresis definition into a file under stdlib/ (e.g., stdlib/controllers.st) and it will be loaded automatically for every program.
CLI Commands
The st-cli tool provides commands for checking, compiling, running, formatting, and serving Structured Text programs.
st-cli check
Parse and analyze source file(s), reporting all diagnostics.
st-cli check [path] [--json]
Path modes:
| Path | Behavior |
|---|---|
st-cli check | Autodiscover .st files from current directory |
st-cli check file.st | Check a single file |
st-cli check dir/ | Autodiscover from directory |
st-cli check plc-project.yaml | Use project file configuration |
Flags:
| Flag | Description |
|---|---|
--json | Output diagnostics as structured JSON (for CI integration) |
Examples:
$ st-cli check program.st
program.st: OK
$ st-cli check broken.st
broken.st:5:10: error: undeclared variable 'x'
broken.st:8:8: warning: unused variable 'temp'
# Project mode
$ cd my_project/
$ st-cli check
Project 'MyProject': 4 source file(s)
controllers/main.st
types/data.st
utils.st
main.st
Project 'MyProject': OK
# JSON output for CI
$ st-cli check broken.st --json
[
{
"file": "broken.st",
"line": 5,
"column": 10,
"severity": "error",
"code": "UndeclaredVariable",
"message": "undeclared variable 'x'"
}
]
Exit codes:
0— No errors (warnings are OK)1— One or more errors found
st-cli run
Compile and execute a Structured Text program.
st-cli run [path] [-n <cycles>]
Path modes:
| Path | Behavior |
|---|---|
st-cli run | Autodiscover from current directory, run first PROGRAM found |
st-cli run file.st | Compile and run a single file |
st-cli run dir/ | Autodiscover from directory |
st-cli run -n 1000 | Autodiscover + run 1000 scan cycles |
Options:
| Flag | Default | Description |
|---|---|---|
-n <cycles> | 1 | Number of scan cycles to execute |
Examples:
# Single file
$ st-cli run program.st
Executed 1 cycle(s) in 8.5µs (avg 8.5µs/cycle, 16 instructions)
# 10,000 scan cycles
$ st-cli run program.st -n 10000
Executed 10000 cycle(s) in 17.4ms (avg 1.74µs/cycle, 16 instructions)
# Project mode
$ cd my_project/
$ st-cli run -n 100
Project 'MyProject': 5 source file(s)
Executed 100 cycle(s) in 1.8ms (avg 18µs/cycle, 112 instructions)
Pipeline:
- Discover source files (single file, directory, or project yaml)
- Parse all sources with stdlib merged via
builtin_stdlib() - Run semantic analysis — abort if errors
- Compile to bytecode (intrinsics emitted as single instructions)
- Execute in the VM for N cycles
PLC behavior:
- PROGRAM locals persist across scan cycles (like a real PLC)
- Global variables persist across scan cycles
- FB instance state persists across calls
- Timers use wall-clock time via
SYSTEM_TIME()
st-cli compile
Compile a Structured Text source file to a bytecode file.
st-cli compile <file> -o <output>
Example:
$ st-cli compile program.st -o program.json
Compiled to program.json (78047 bytes)
The output is a JSON-serialized Module containing all compiled functions, global variables, type definitions, and source maps. This can be used for offline analysis or loaded by external tools.
Pipeline:
- Parse the source file with stdlib
- Run semantic analysis — abort if errors
- Compile to bytecode
- Serialize module as JSON to the output file
st-cli fmt
Format Structured Text source file(s) in place.
st-cli fmt [path]
Path modes:
| Path | Behavior |
|---|---|
st-cli fmt | Format all .st files in current directory (autodiscover) |
st-cli fmt file.st | Format a single file |
st-cli fmt dir/ | Format all files in directory |
Example:
$ st-cli fmt program.st
Formatted: program.st
Formatted 1 file(s)
# Format entire project
$ cd my_project/
$ st-cli fmt
Formatted: controllers/main.st
Formatted: utils.st
Formatted 2 file(s)
# Already formatted
$ st-cli fmt program.st
All 1 file(s) already formatted
The formatter normalizes indentation (4 spaces per level) for all ST block structures: PROGRAM, FUNCTION, VAR, IF, FOR, WHILE, CASE, STRUCT, TYPE, etc.
st-cli serve
Start the Language Server Protocol (LSP) server for editor integration.
st-cli serve
The server communicates over stdin/stdout using the JSON-RPC protocol. This is typically invoked by the VSCode extension, not directly by users.
Supported LSP capabilities (16 features):
| Feature | Protocol Method |
|---|---|
| Diagnostics | textDocument/publishDiagnostics |
| Hover | textDocument/hover |
| Go-to-definition | textDocument/definition |
| Go-to-type-definition | textDocument/typeDefinition |
| Completion | textDocument/completion (triggers: .) |
| Signature help | textDocument/signatureHelp (triggers: (, ,) |
| Find references | textDocument/references |
| Rename | textDocument/rename |
| Document symbols | textDocument/documentSymbol |
| Workspace symbols | workspace/symbol |
| Document highlight | textDocument/documentHighlight |
| Folding ranges | textDocument/foldingRange |
| Document links | textDocument/documentLink |
| Semantic tokens | textDocument/semanticTokens/full |
| Formatting | textDocument/formatting |
| Code actions | textDocument/codeAction |
st-cli debug
Start a Debug Adapter Protocol (DAP) session for a Structured Text file.
st-cli debug <file>
This is typically invoked by the VSCode extension when you press F5, not called directly by users.
DAP capabilities:
| Capability | Description |
|---|---|
| Breakpoints | Set/clear breakpoints on executable lines |
| Step In | Step into function/FB calls (F11) |
| Step Over | Step over one statement (F10) |
| Step Out | Run until current function returns (Shift+F11) |
| Continue | Run across scan cycles until a breakpoint is hit (F5) |
| Stack Trace | View the full call stack including nested POU calls |
| Scopes | Inspect Locals and Globals scopes |
| Variables | View all variables with types and current values |
| Evaluate | Evaluate variable names or PLC commands |
PLC-specific debug commands (type in the Debug Console):
| Expression | Description |
|---|---|
force x = 42 | Force variable x to value 42 |
unforce x | Remove force from variable x |
listForced | List all forced variables |
scanCycleInfo | Show cycle statistics |
Key behaviors:
- Continue runs across scan cycles (up to 100,000) until a breakpoint hits
- Step at end of cycle wraps to the next cycle
- PROGRAM locals and FB state persist across cycles
- 4 VSCode debug toolbar buttons: Force, Unforce, List Forced, Cycle Info
st-cli help
Show usage information.
$ st-cli help
st-cli: IEC 61131-3 Structured Text toolchain
Usage: st-cli <command> [options]
Commands:
serve Start the LSP server (stdio)
check [path] Parse and analyze, report diagnostics
run [path] [-n N] Compile and execute (N cycles, default 1)
compile <path> -o <output> Compile to bytecode file
fmt [path] Format source file(s) in place
debug <file> Start DAP debug server (stdin/stdout)
help Show this help message
Flags:
--json Output diagnostics as JSON (for CI integration)
Path modes:
(no path) Use current directory as project root
file.st Single file mode
directory/ Project mode (autodiscover .st files)
plc-project.yaml Explicit project file
Architecture Overview
rust-plc is an IEC 61131-3 Structured Text compiler and runtime written in Rust. The project follows the rust-analyzer model: a Rust core that implements parsing, analysis, compilation, and execution, paired with a thin TypeScript extension that wires the Language Server Protocol into VSCode.
High-Level Diagram
+-----------+ +------------+ +--------------+ +---------+
| st-grammar| --> | st-syntax | --> | st-semantics | --> | st-ir |
| tree-sitter | CST->AST | | scope + types| | bytecode|
| parser | | lower.rs | | diagnostics | | defns |
+-----------+ +------------+ +--------------+ +---------+
|
+-----------+ +------------+ +--------------+ |
| st-cli | --> | st-lsp | --> | st-dap | v
| commands | | lang server| | debug adapter| +-----------+
| | | hover, diag| | breakpoints | | st-compiler|
+-----------+ +------------+ +--------------+ | AST -> IR |
+-----------+
|
+-----------+ v
| st-monitor| <-------------------------------------------+-----------+
| WS live | | st-runtime|
| dashboard | | VM engine |
+-----------+ +-----------+
editors/vscode/ Thin TypeScript extension (launches st-cli serve)
The 10 Crates
| Crate | Path | Purpose |
|---|---|---|
| st-grammar | crates/st-grammar | Wraps the tree-sitter generated parser for Structured Text. Exposes language() and 70+ node-kind constants (kind::*). Incremental and error-recovering. |
| st-syntax | crates/st-syntax | Typed AST definitions (ast.rs) and CST-to-AST lowering (lower.rs). Every AST node carries a TextRange for source-location mapping. Provides the one-call parse() convenience function. |
| st-semantics | crates/st-semantics | Two-pass semantic analyzer. Pass 1 registers top-level names in the global scope; Pass 2 analyzes bodies. Includes the hierarchical scope model (scope.rs), semantic type system (types.rs – Ty enum with widening/coercion rules), and diagnostics. Recognizes compiler intrinsics (type conversions, trig/math functions, SYSTEM_TIME). |
| st-ir | crates/st-ir | Intermediate representation: Module, Function, Instruction enum (48 variants), Value enum, MemoryLayout, VarSlot, and SourceLocation. Register-based design with u16 register indices and u32 label indices. Serializable with serde. |
| st-compiler | crates/st-compiler | Compiles a typed AST (SourceFile) into an IR Module. Two internal passes: register all POUs, then compile bodies. Emits register-based instructions with source-map entries for debugger integration. Handles intrinsic function detection (30+ type conversions, 10 trig/math functions, SYSTEM_TIME). Also contains builtin_stdlib() for multi-file compilation and analyze_change() / migrate_locals() for online change. |
| st-runtime | crates/st-runtime | Bytecode VM (vm.rs) with fetch-decode-execute loop and scan-cycle engine (engine.rs). Provides CycleStats, watchdog timeout, configurable max call depth and instruction limits. Supports force/unforce variable overrides and FB instance state persistence. |
| st-lsp | crates/st-lsp | Language Server Protocol implementation via tower-lsp. Per-document state with incremental re-parse on edits. Provides diagnostics, semantic tokens, completion, hover, and go-to-definition. |
| st-dap | crates/st-dap | Debug Adapter Protocol server for online debugging: breakpoints, stepping, variable inspection, force/unforce via evaluate expressions (force x = 42, unforce x, listForced, scanCycleInfo), online change. |
| st-monitor | crates/st-monitor | WebSocket-based live monitoring server. Streams variable values from the runtime to connected dashboards for real-time trend recording. Supports force/unforce and online change via JSON-RPC. |
| st-cli | crates/st-cli | CLI entry point. Commands: serve (start LSP on stdio), check <file> (parse + analyze, report diagnostics), run <file> [-n N] (compile and execute N scan cycles), debug <file> (start DAP session). |
Standard Library and Multi-File Compilation
The standard library lives in the stdlib/ directory as plain .st files
(counters, timers, edge detection, math). At compile time, builtin_stdlib()
embeds all stdlib source files and parse_multi() merges the stdlib AST with
the user’s AST. This means all standard library functions and function blocks
are available in every program without import statements. Parse errors from
stdlib files are suppressed – only user source errors are reported.
In addition to the stdlib-based functions, the compiler recognizes intrinsic functions by name:
- 30+ type conversion functions (
INT_TO_REAL,REAL_TO_INT, etc.) - 10 trig/math functions (
SQRT,SIN,COS,TAN,ASIN,ACOS,ATAN,LN,LOG,EXP) SYSTEM_TIME()– returns elapsed milliseconds since engine start
These compile to dedicated VM instructions rather than function calls.
Data Flow: Source to Execution
The end-to-end pipeline for st-cli run example.st:
- Read source –
st-clireads the.stfile into aString. - Merge stdlib –
builtin_stdlib()provides the standard library source.parse_multi()parses both and merges the ASTs. - Parse –
st_syntax::parse()creates a tree-sitterParser, parses the source into a concrete syntax tree, then callslower::lower()to produce a typedSourceFileAST plus anyLowerErrors. - Analyze –
st_semantics::analyze::analyze()builds aSymbolTable, resolves types, checks type compatibility, and collectsDiagnostics. If any error-severity diagnostics exist,st-clireports them and exits. - Compile –
st_compiler::compile()walks the AST and emits anst_ir::ModulecontainingFunctions (with instructions, label maps, memory layouts, and source maps) plus global variable storage. Intrinsic functions are emitted as single VM instructions. - Execute –
st_runtime::Engine::new()instantiates aVmfrom the module.engine.run()enters the scan-cycle loop, calling the namedPROGRAMonce per cycle and trackingCycleStats.
The VSCode Extension
The extension lives in editors/vscode/ and is intentionally thin:
- Registers the
structured-textlanguage (.st,.sclfiles). - Provides TextMate grammar for syntax highlighting (
syntaxes/structured-text.tmLanguage.json). - Launches
st-cli serveas the language server subprocess. - Configurable server path via
structured-text.serverPath.
All intelligence (diagnostics, completions, semantic tokens) is implemented in the Rust LSP crate, not in TypeScript. This keeps the extension simple and allows the same analysis to power both the CLI and the editor.
DAP Server (st-dap)
The Debug Adapter Protocol server enables interactive debugging of ST programs in VSCode. It sits between the editor and the VM, translating DAP requests (setBreakpoints, stepIn, continue, etc.) into VM control operations.
Key design decisions:
- Scan-cycle-aware continue: When the user presses Continue, execution does not stop at the end of the current scan cycle. Instead, it runs across multiple cycles (up to 100,000) until a breakpoint is hit. This matches PLC debugging expectations.
- Step at end of cycle: When stepping reaches the end of a scan cycle, the VM wraps to the next cycle instead of terminating.
- PROGRAM local retention: The VM skips variable initialization on
subsequent scan cycles (using
body_start_pc) so that local variables retain their values, just like a real PLC. - FB instance persistence: Function block instance state is persisted
across scan cycles via the
fb_instancesHashMap in the VM. - Force/unforce variables: The debugger supports PLC-specific evaluate
expressions:
force x = 42,unforce x,listForced,scanCycleInfo. The VSCode extension provides 4 debug toolbar buttons for these operations. - Source mapping: The compiler emits
SourceLocationentries for every instruction, allowing the DAP server to map bytecode PCs back to source lines.
Online Change Manager
The online change system allows hot-reloading a modified program into a running engine without restarting. The pipeline is:
engine.online_change(source)– Full pipeline: parse, analyze, compile, compare modules, migrate state, and atomic swap.analyze_change(old_module, new_module)– Compares two compiled modules and determines whether the change is compatible (same variable layout) or incompatible (structural changes requiring a full restart).migrate_locals(old_vm, new_module)– For compatible changes, copies local variable values from the old VM state into the new module’s memory layout, preserving runtime state by name and type.vm.swap_module(new_module)– Performs an atomic swap of the running module, replacing bytecode while the engine is between scan cycles.
See Online Change for full details.
Monitor Server (st-monitor)
The monitor server exposes a WebSocket interface for live variable observation and control. It runs alongside the scan-cycle engine and provides:
- Real-time variable streaming – Connected clients receive variable values after each scan cycle.
- Force/unforce variables – Override variable values from the dashboard, useful for testing and commissioning.
- JSON-RPC protocol – All communication uses a simple JSON request/response
format over WebSocket. Supported methods:
subscribe,unsubscribe,read_variables,force_variable,unforce_variable,list_forced,online_change,status. - MonitorHandle API – A thread-safe handle that the engine uses to publish state and receive force commands without blocking the scan loop.
- MonitorPanel webview – VSCode command “ST: Open PLC Monitor” opens a live variable table.
See Monitor Server for the full protocol reference.
Dependency Graph
st-cli
|-- st-lsp
| |-- st-semantics
| | |-- st-syntax
| | | |-- st-grammar
| | |-- st-syntax (ast types)
| |-- st-grammar (incremental re-parse)
|-- st-dap
| |-- st-runtime
| |-- st-compiler
| |-- st-ir
|-- st-compiler
| |-- st-ir
| |-- st-syntax (ast types)
|-- st-runtime
| |-- st-ir
|-- st-monitor
| |-- st-runtime
| |-- st-ir
|-- st-semantics
|-- st-syntax
External dependencies are kept minimal: tree-sitter for parsing,
tower-lsp + lsp-types for the language server, tokio for async,
serde for IR serialization, and thiserror/anyhow for error handling.
Compiler Pipeline
This chapter describes the five stages that transform IEC 61131-3 Structured Text source code into executable bytecode.
Stage 1: Tree-Sitter Parsing
Crate: st-grammar (crates/st-grammar/src/lib.rs)
The grammar crate wraps a tree-sitter parser generated from a custom Structured Text grammar definition. Key properties:
- Incremental – The LSP passes the previous tree into
parser.parse(source, Some(&old_tree))so only the edited region is re-parsed. This keeps keystroke latency low. - Error-recovering – Syntax errors produce
ERRORnodes in the CST but do not prevent the rest of the tree from being built. The testtest_error_recoveryinst-grammarvalidates this: a brokenx := ;statement still yields a validsource_fileroot. - 70+ named node kinds – Defined as string constants in
st_grammar::kind(e.g.PROGRAM_DECLARATION,IF_STATEMENT,ADDITIVE_EXPRESSION). These are the glue between the C tree-sitter parser and the Rust lowering code.
#![allow(unused)]
fn main() {
// crates/st-grammar/src/lib.rs
pub fn language() -> Language {
unsafe { Language::from_raw(tree_sitter_structured_text()) }
}
}
Stage 2: CST-to-AST Lowering
Crate: st-syntax (crates/st-syntax/src/lower.rs)
The lower() function walks the tree-sitter concrete syntax tree and produces
typed Rust AST nodes defined in crates/st-syntax/src/ast.rs.
tree-sitter::Tree + &str --> lower::lower() --> LowerResult {
source_file: SourceFile,
errors: Vec<LowerError>,
}
Design choices:
- Every AST node carries a
TextRange { start, end }(byte offsets). This enables precise diagnostic locations and source-map generation later. - CST
ERRORnodes are collected intoLowerErrors but do not halt construction. Valid subtrees still produce AST nodes. - The top-level
SourceFilecontains aVec<TopLevelItem>with variants:Program,Function,FunctionBlock,TypeDeclaration,GlobalVarDeclaration. - The convenience function
st_syntax::parse(source)creates a parser, parses, and lowers in one call.
Stage 2.5: Multi-File Compilation
Before semantic analysis, the compiler supports multi-file compilation
via parse_multi(). The standard library is embedded at compile time through
builtin_stdlib(), which concatenates all stdlib/*.st files into a single
source string. The parser merges the stdlib AST with the user’s AST, and only
reports errors from the user source (stdlib parse errors are suppressed).
This means all standard library functions (counters, timers, edge detection, math) are available in every program without import statements.
Stage 3: Semantic Analysis
Crate: st-semantics (crates/st-semantics/src/analyze.rs)
Semantic analysis runs in two passes over the AST:
Pass 1 – Register Top-Level Names
#![allow(unused)]
fn main() {
// analyze.rs
for item in &sf.items {
self.register_top_level(item);
}
}
This pass inserts every PROGRAM, FUNCTION, FUNCTION_BLOCK, TYPE declaration,
and global VAR block into the global scope of the SymbolTable. Forward
references between POUs are therefore resolved correctly.
Pass 2 – Analyze Bodies
#![allow(unused)]
fn main() {
for item in &sf.items {
self.analyze_top_level(item);
}
}
For each POU, the analyzer:
- Creates a child scope (global -> POU).
- Registers local variables from VAR / VAR_INPUT / VAR_OUTPUT blocks.
- Walks the statement list, resolving every variable reference through the
scope chain (
SymbolTable::resolve()walks from current scope up to global). - Type-checks expressions using the rules in
types.rs.
Intrinsic Function Recognition
The semantic analyzer recognizes compiler intrinsic functions by name. These include:
- Type conversions (30+):
INT_TO_REAL,REAL_TO_INT,BOOL_TO_INT, etc. The compiler recognizes*_TO_*patterns and emitsToInt,ToReal, orToBoolinstructions directly. - Trig/math functions (10):
SQRT,SIN,COS,TAN,ASIN,ACOS,ATAN,LN,LOG,EXP. Each compiles to a dedicated VM instruction. - SYSTEM_TIME(): Compiles to the
SystemTimeVM instruction, returning elapsed milliseconds since engine start as a TIME value.
These intrinsics bypass normal function call resolution and are emitted as single VM instructions for maximum efficiency.
Scope Chain
SymbolTable
scopes[0] "global" -- types, POUs, global vars
scopes[1] "Main" -- locals of PROGRAM Main (parent = 0)
scopes[2] "Counter" -- locals of FB Counter (parent = 0)
...
resolve(scope_id, name) walks parent links until it finds a match or
reaches the root. Names are case-insensitive (uppercased for lookup).
Type Checking
The semantic type system (crates/st-semantics/src/types.rs) defines Ty:
| Variant | Description |
|---|---|
Elementary(e) | BOOL, SINT..ULINT, REAL, LREAL, BYTE..LWORD, TIME..LDT |
Array { ranges, element_type } | Multi-dimensional arrays |
String { wide, max_len } | STRING / WSTRING |
Struct { name, fields } | Named struct with Vec<FieldDef> |
Enum { name, variants } | Enumeration |
Subrange { name, base, lower, upper } | Constrained integer range |
FunctionBlock { name } | FB instance type |
Alias { name, target } | Type alias (resolved transparently) |
Void / Unknown | Programs (no return) / unresolved |
Widening rules (can_coerce): implicit coercion is allowed when the
source type has a lower numeric_rank than the target. The ranking is:
SINT(1) < USINT(2) < INT(3) < UINT(4) < DINT(5) < UDINT(6) < LINT(7) <
ULINT(8) < REAL(9) < LREAL(10). Enum-to-integer coercion is also permitted.
Common type (common_type): for binary operations, the operand with the
higher rank is selected. If no common type exists the analyzer emits an error
diagnostic.
After both passes, check_unused() scans the symbol table for variables that
were declared but never read, emitting warnings.
Stage 4: IR Compilation
Crate: st-compiler (crates/st-compiler/src/compile.rs)
The compiler translates the AST into register-based bytecode stored in an
st_ir::Module.
Two-Pass Compilation
- Register POUs – Create empty
Functionentries so that cross-functionCallinstructions can resolve indices. - Compile bodies – A per-function
FunctionCompilerallocates registers, labels, and local variable slots, then emits instructions.
Register Allocation
Registers are allocated linearly (alloc_reg() increments a counter). Each
expression evaluation returns the Reg holding its result. There is no
register reuse or optimization pass; the register file is sized to
next_reg at the end of compilation.
Instruction Set Summary
| Category | Instructions |
|---|---|
| Register ops | Nop, LoadConst(dst, val), Move(dst, src) |
| Variable access | LoadLocal(dst, slot), StoreLocal(slot, src), LoadGlobal(dst, slot), StoreGlobal(slot, src) |
| Arithmetic | Add, Sub, Mul, Div, Mod, Pow, Neg |
| Comparison | CmpEq, CmpNe, CmpLt, CmpGt, CmpLe, CmpGe |
| Logic/bitwise | And, Or, Xor, Not |
| Math intrinsics | Sqrt, Sin, Cos, Tan, Asin, Acos, Atan, Ln, Log, Exp |
| System | SystemTime |
| Conversion | ToInt, ToReal, ToBool |
| Control flow | Jump(label), JumpIf(reg, label), JumpIfNot(reg, label) |
| Calls | Call { func_index, dst, args }, CallFb { instance_slot, func_index, args }, Ret(reg), RetVoid |
| Aggregate access | LoadArray, StoreArray, LoadField, StoreField |
Total: 48 instruction variants.
Source Map Generation
Every emit() / emit_sourced() call appends a SourceLocation { byte_offset, byte_end } parallel to the instruction vector. The runtime and debugger use
this mapping to translate instruction indices back to source positions.
Stage 5: Module Output
The final Module contains:
#![allow(unused)]
fn main() {
pub struct Module {
pub functions: Vec<Function>, // compiled POUs
pub globals: MemoryLayout, // global variable slots
pub type_defs: Vec<TypeDef>, // struct/enum/array defs for runtime
}
}
Each Function carries its name, PouKind (Function / FunctionBlock /
Program), register count, instruction vector, label-position map, local
MemoryLayout, and source map. The module is serializable via serde for
potential caching or transport.
Pipeline Summary
.st source
|
v
[tree-sitter] incremental, error-recovering parse
|
v
CST (tree_sitter::Tree)
|
v
[lower.rs] walk CST nodes, produce typed AST
|
v
AST (SourceFile)
|
v
[builtin_stdlib() + parse_multi()] merge stdlib AST with user AST
|
v
[analyze.rs] pass 1: register names, pass 2: analyze bodies
|
v
SymbolTable + Vec<Diagnostic>
|
v
[compile.rs] register POUs, then compile to bytecode (intrinsics emitted inline)
|
v
Module { functions, globals, type_defs }
|
v
[vm.rs / engine.rs] fetch-decode-execute in scan cycles
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.
Online Change
Online change (hot-reload) allows you to modify a running PLC program’s logic without stopping the scan-cycle engine. The system analyzes compatibility between the old and new compiled modules, migrates variable state, and performs an atomic swap.
Overview
The simplest way to perform an online change is via the high-level API:
#![allow(unused)]
fn main() {
engine.online_change(new_source)?;
}
This runs the full pipeline: parse, analyze, compile, compare modules, migrate state, and atomic swap. Under the hood, the pipeline consists of three steps:
Old Module + New Source
|
v
┌─────────────────────┐
│ analyze_change() │ ── Compare old and new modules
│ → Compatible? │ for structural equivalence
└──────────┬──────────┘
│ yes
v
┌────────��────────────┐
│ migrate_locals() │ ── Copy variable values from old
│ → State preserved │ VM state into new module layout
└──────────���───────��──┘
│
v
┌─────���───────────────┐
│ vm.swap_module() │ ── Atomic swap of the module in
│ → Engine updated │ the running engine
└─────────────────────┘
If the change is incompatible, the system reports the reason and the caller must perform a full restart instead.
Compatibility Analysis
analyze_change(old_module, new_module) compares two st_ir::Module values
and returns a ChangeAnalysis:
#![allow(unused)]
fn main() {
pub fn analyze_change(old: &Module, new: &Module) -> ChangeAnalysis;
}
What is Compatible
The following changes can be applied online without stopping the engine:
| Change | Example | Why it works |
|---|---|---|
| Modified program body logic | Changed an IF condition or assignment | Same variable layout, only bytecode changes |
| Reordered statements | Moved assignments around | Same variable layout |
| Changed literal values | limit := 50 to limit := 100 | Same variable layout |
| Added/removed comments | (* new comment *) | No effect on compiled output |
| Modified function bodies | Changed internal computation | Functions are stateless |
What is Incompatible
These changes require a full restart:
| Change | Example | Why it fails |
|---|---|---|
| Added a variable | New VAR counter2 : INT; END_VAR | Memory layout changed |
| Removed a variable | Deleted a VAR declaration | Memory layout changed |
| Changed a variable’s type | counter : INT to counter : DINT | Value size changed |
| Renamed a variable | counter to cnt | Name-based migration cannot match |
| Added/removed a POU | New FUNCTION or FUNCTION_BLOCK | Module structure changed |
| Changed function signatures | Added a parameter to a function | Call sites would be invalid |
Variable Migration
When a change is compatible, migrate_locals(old_vm, new_module) copies
variable values from the old VM’s memory into the new module’s memory layout:
#![allow(unused)]
fn main() {
pub fn migrate_locals(
old_vm: &Vm,
new_module: &mut Module,
) -> Result<MigrationReport, MigrationError>;
}
The migration is name-and-type-based: a variable in the new module receives the old value only if a variable with the same name and same type exists in the old module. This ensures type safety during the swap.
Migration Report
The migration returns a report listing:
- Migrated – Variables whose values were copied successfully
- Defaulted – Variables that exist only in the new module (initialized to defaults)
- Dropped – Variables that exist only in the old module (values discarded)
Atomic Swap
vm.swap_module(new_module) performs the actual replacement:
- The engine finishes the current scan cycle (never interrupts mid-cycle)
- The old module is swapped out and the new module is installed
- The next scan cycle executes the new bytecode
- Program locals that were migrated retain their values
The use of body_start_pc is critical: it causes the VM to skip the variable
initialization preamble on the next cycle, preserving the migrated values. This
is the same mechanism used for normal scan-cycle local retention in PROGRAMs.
Code Example: Hot-Reload Workflow
Original program
PROGRAM Main
VAR
counter : INT := 0;
limit : INT := 50;
active : BOOL := FALSE;
END_VAR
counter := counter + 1;
IF counter > limit THEN
active := TRUE;
END_IF;
END_PROGRAM
Modified program (compatible change)
PROGRAM Main
VAR
counter : INT := 0;
limit : INT := 50;
active : BOOL := FALSE;
END_VAR
counter := counter + 2; (* changed: increment by 2 *)
IF counter > limit THEN
active := TRUE;
counter := 0; (* added: reset on overflow *)
END_IF;
END_PROGRAM
This change is compatible because:
- The variable declarations are identical (same names, same types, same order)
- Only the program body logic changed
After online change:
counterretains its current runtime value (e.g., 37)limitretains its value (50)activeretains its value (FALSE or TRUE depending on state)- The new logic takes effect on the very next scan cycle
Incompatible change example
PROGRAM Main
VAR
counter : DINT := 0; (* changed type: INT → DINT *)
limit : INT := 50;
active : BOOL := FALSE;
log : INT := 0; (* added new variable *)
END_VAR
(* ... *)
END_PROGRAM
This change is incompatible because:
counterchanged type fromINTtoDINT- A new variable
logwas added
The system reports the incompatibility and the program must be fully restarted.
Integration with Monitor Server
Online change can be triggered through the monitor server’s WebSocket API using
the onlineChange request type. This allows the VSCode monitor panel (or any
WebSocket client) to push new source code to the running engine. See
Monitor Server for the protocol details.
Monitor Server
The monitor server (st-monitor crate) provides a WebSocket-based interface
for live variable observation, forced variable control, and online change
triggering. It runs alongside the scan-cycle engine and communicates using a
JSON-RPC protocol.
Architecture
┌──────────────┐ ┌───────────────┐ ┌──────────────┐
│ VSCode │ WS │ st-monitor │ │ st-runtime │
│ MonitorPanel │◄──────►│ WebSocket │◄──────►│ Engine │
│ (webview) │ │ Server │ │ (scan loop) │
└──────────────┘ └───────────────┘ └──────────────┘
│
MonitorHandle
(thread-safe)
The MonitorHandle is the bridge between the WebSocket server and the engine.
It is designed to be non-blocking: the engine publishes variable state into the
handle after each scan cycle, and the server reads it when clients request
updates. Force commands flow in the reverse direction.
Protocol Reference
All messages use JSON over WebSocket. Requests are tagged by method name
with optional params. Responses are tagged by type.
Request Format
Requests use serde’s tag-content encoding:
{
"method": "<method_name>",
"params": { ... }
}
Response Types
The server sends back one of four message types:
| Type | Description |
|---|---|
response | Success/failure response to a request |
variableUpdate | Pushed variable value update for subscribers |
cycleInfo | Scan cycle statistics |
error | Error message |
Response format:
{
"type": "response",
"id": null,
"success": true,
"data": { ... }
}
Error format:
{
"type": "error",
"message": "description of the error"
}
Request Types
The monitor server supports 8 request types:
1. subscribe
Subscribe to live variable updates. After subscribing, the server pushes
variableUpdate messages after each scan cycle (or at the specified interval).
Request:
{
"method": "subscribe",
"params": {
"variables": ["Main.counter", "Main.limit"],
"interval_ms": 0
}
}
| Parameter | Type | Description |
|---|---|---|
variables | string[] | Variable names to subscribe to |
interval_ms | u64 | Update interval in milliseconds (0 = every cycle) |
After subscribing, the server sends push notifications:
{
"type": "variableUpdate",
"cycle": 1042,
"variables": [
{ "name": "Main.counter", "value": "37", "type": "INT" },
{ "name": "Main.limit", "value": "50", "type": "INT" }
]
}
2. unsubscribe
Stop receiving updates for specific variables.
Request:
{
"method": "unsubscribe",
"params": {
"variables": ["Main.counter"]
}
}
3. read
Read current values of specific variables (polling mode).
Request:
{
"method": "read",
"params": {
"variables": ["Main.counter", "Main.limit"]
}
}
Response:
{
"type": "response",
"success": true,
"data": {
"variables": [
{ "name": "Main.counter", "value": "37", "type": "INT" },
{ "name": "Main.limit", "value": "50", "type": "INT" }
]
}
}
4. write
Write a value to a variable.
Request:
{
"method": "write",
"params": {
"variable": "Main.counter",
"value": 100
}
}
5. force
Override a variable’s value. The forced value is written at the start of each scan cycle, overriding whatever the program logic computes.
Request:
{
"method": "force",
"params": {
"variable": "Main.counter",
"value": 100
}
}
6. unforce
Remove the force override from a variable, returning it to normal program control.
Request:
{
"method": "unforce",
"params": {
"variable": "Main.counter"
}
}
7. getCycleInfo
Get scan cycle statistics. This method takes no parameters.
Request:
{
"method": "getCycleInfo"
}
Response:
{
"type": "cycleInfo",
"cycle_count": 1042,
"last_cycle_us": 150,
"min_cycle_us": 120,
"max_cycle_us": 450,
"avg_cycle_us": 165
}
8. onlineChange
Push new source code to the running engine for hot-reload. The server performs the full pipeline: parse, analyze, compile, compatibility analysis, variable migration, and atomic swap.
Request:
{
"method": "onlineChange",
"params": {
"source": "PROGRAM Main\nVAR\n counter : INT := 0;\nEND_VAR\n counter := counter + 2;\nEND_PROGRAM"
}
}
Response (success):
{
"type": "response",
"success": true,
"data": {
"status": "applied"
}
}
Response (incompatible):
{
"type": "error",
"message": "incompatible change: variable 'counter' type changed from INT to DINT"
}
See Online Change for details on compatibility rules.
MonitorHandle API
The MonitorHandle is the Rust API used internally by the engine to communicate
with the monitor server. It is Send + Sync and designed for zero-copy
operation where possible.
#![allow(unused)]
fn main() {
pub struct MonitorHandle { /* ... */ }
impl MonitorHandle {
/// Publish the current variable state after a scan cycle completes.
/// Called by the engine at the end of each cycle.
pub fn publish_state(&self, cycle: u64, variables: &VariableSnapshot);
/// Check for pending force commands from connected clients.
/// Called by the engine at the start of each cycle.
pub fn poll_forces(&self) -> Vec<ForceCommand>;
/// Check for a pending online change request.
/// Returns the new module if one is queued.
pub fn poll_online_change(&self) -> Option<Module>;
/// Report the result of an online change back to the requesting client.
pub fn report_change_result(&self, result: Result<MigrationReport, String>);
}
}
Integration with the Engine
The engine integrates with the monitor handle in its scan loop:
loop {
// 1. Apply any forced variables
for cmd in handle.poll_forces() {
vm.force_variable(cmd.name, cmd.value);
}
// 2. Check for online change
if let Some(new_module) = handle.poll_online_change() {
let result = apply_online_change(&mut vm, new_module);
handle.report_change_result(result);
}
// 3. Execute one scan cycle
vm.run_cycle();
// 4. Publish state to subscribers
handle.publish_state(cycle_count, &vm.snapshot());
cycle_count += 1;
}
VSCode MonitorPanel
The VSCode extension includes a MonitorPanel webview that connects to the
monitor server. Open it via:
Command Palette (Ctrl+Shift+P) -> “ST: Open PLC Monitor”
The panel provides:
- A variable table showing live values, types, and forced status
- Right-click context menu to force/unforce variables
- Visual indicators for forced variables
- Cycle counter showing the current scan cycle number
The panel automatically connects to the monitor server when the engine is running and reconnects if the connection is lost.
Development Setup
This guide covers how to build, run, and develop the rust-plc project.
Prerequisites
- Rust 1.85+ (edition 2024). Install via rustup.
- Node.js LTS (for the VSCode extension). The devcontainer installs this automatically.
- C compiler (for building tree-sitter). Usually available by default on Linux/macOS; on Windows use MSVC.
Clone and Build
git clone <repository-url>
cd rust-plc
# Build the CLI (and all dependencies)
cargo build -p st-cli
# Build the entire workspace
cargo build --workspace
The primary binary is st-cli, which serves as both the command-line tool and
the LSP server process.
Run Tests
# Run all tests across every crate
cargo test --workspace
# Run tests for a specific crate
cargo test -p st-grammar
cargo test -p st-semantics
# Run a single test by name
cargo test -p st-runtime test_arithmetic
Project Structure
rust-plc/
Cargo.toml Workspace root (10 members)
crates/
st-grammar/ Tree-sitter parser wrapper
src/lib.rs language(), kind constants, grammar tests
st-syntax/ AST definitions + CST-to-AST lowering
src/ast.rs Typed AST nodes (SourceFile, Statement, Expr...)
src/lower.rs Tree-sitter walk -> AST construction
src/lib.rs parse() convenience function
tests/
lower_tests.rs AST lowering tests
coverage_gaps.rs Additional coverage tests
st-semantics/ Semantic analysis
src/analyze.rs Two-pass analyzer
src/scope.rs Hierarchical symbol table
src/types.rs Ty enum, coercion rules, numeric ranking
src/diagnostic.rs Diagnostic codes and severities
src/lib.rs check() convenience function
tests/
end_to_end_tests.rs Full parse-analyze round trips
type_tests.rs Type checking
scope_tests.rs Scope resolution
call_tests.rs Function/FB call validation
control_flow_tests.rs IF/FOR/WHILE/CASE checks
struct_array_tests.rs UDT tests
warning_tests.rs Unused variable warnings etc.
coverage_gaps.rs Additional coverage
test_helpers.rs Shared test utilities
st-ir/ Intermediate representation
src/lib.rs Module, Function, Instruction, Value, MemoryLayout
st-compiler/ AST -> IR compilation
src/compile.rs ModuleCompiler + FunctionCompiler
src/lib.rs compile() public API
tests/
compile_tests.rs Compilation tests
st-runtime/ Bytecode VM + scan-cycle engine
src/vm.rs Vm, CallFrame, fetch-decode-execute loop
src/engine.rs Engine, CycleStats, watchdog
src/lib.rs Public re-exports
tests/
vm_tests.rs VM execution tests
st-lsp/ Language Server Protocol
src/server.rs tower-lsp Backend implementation
src/document.rs Per-document state (tree, AST, analysis)
src/completion.rs Completion provider
src/semantic_tokens.rs Semantic token encoding
src/lib.rs run_stdio()
tests/
lsp_integration.rs Subprocess-based LSP tests (13 tests)
unit_tests.rs In-process LSP tests (41 tests)
st-dap/ Debug Adapter Protocol (placeholder)
src/lib.rs
st-monitor/ WebSocket live monitoring (placeholder)
src/lib.rs
st-cli/ CLI entry point
src/main.rs serve, check, run commands
editors/
vscode/ VSCode extension
package.json Extension manifest
src/extension.ts Thin client: launches st-cli serve
syntaxes/ TextMate grammar for highlighting
language-configuration.json
docs/ mdBook documentation (you are here)
.devcontainer/ Development container definition
playground/ Example .st files for testing
Using the Devcontainer
The project includes a devcontainer configuration in .devcontainer/:
- Dockerfile builds an image with Rust and Node.js.
- devcontainer.json configures VSCode with rust-analyzer and ST file associations.
- post-create.sh runs after container creation to install dependencies.
To use it:
- Install the “Dev Containers” extension in VSCode.
- Open the project folder.
- VSCode will prompt “Reopen in Container” – accept.
- The container will build and configure itself automatically.
Launching the Extension Development Host
To test the VSCode extension with the LSP server:
- Open the project in VSCode.
- Make sure the Rust binary is built:
cargo build -p st-cli. - Open the
editors/vscode/folder in VSCode (or use the workspace). - Press F5 to launch the Extension Development Host.
- In the new VSCode window, open a
.stfile. The extension will launchst-cli serveand connect to it over stdio.
The server path is configured via structured-text.serverPath in settings.
The devcontainer sets this to ${workspaceFolder}/target/debug/st-cli.
Useful Commands
# Type check without running
cargo build --workspace 2>&1 | head -20
# Check a Structured Text file for errors
cargo run -p st-cli -- check playground/example.st
# Run a Structured Text program for 10 scan cycles
cargo run -p st-cli -- run playground/example.st -n 10
# Start the LSP server manually (for debugging)
cargo run -p st-cli -- serve
# Format all code
cargo fmt --all
# Run clippy lints
cargo clippy --workspace
# Build in release mode
cargo build --workspace --release
IDE Support
- CLion / RustRover – Open the workspace
Cargo.toml. The IDE will index all 10 crates automatically. - VSCode with rust-analyzer – Same; open the root folder. The devcontainer pre-configures this.
- The workspace uses
resolver = "3"(Rust 2024 edition) so all crates share a single dependency graph.
Testing
This chapter covers the testing strategy, how tests are organized, how to run them, and how to add new tests.
Overview
The workspace contains 483 tests across all crates. Every crate with non-trivial logic has its own test suite. Tests range from unit tests (individual functions) to integration tests (full parse-analyze-compile-run round trips).
# Run the entire test suite
cargo test --workspace
Test Distribution by Crate
| Crate | Test file(s) | Count | What is tested |
|---|---|---|---|
| st-grammar | src/lib.rs (inline) | 11 | Parser loads, minimal programs, FBs, functions, types, control flow, expressions, literals, comments, error recovery, incremental parse |
| st-syntax | tests/lower_tests.rs | 21 | CST-to-AST lowering for all node types |
| st-syntax | tests/coverage_gaps.rs | 58 | Additional lowering edge cases |
| st-semantics | tests/end_to_end_tests.rs | 17 | Full parse-and-analyze round trips |
| st-semantics | tests/scope_tests.rs | 22 | Scope creation, resolution, shadowing |
| st-semantics | tests/type_tests.rs | 38 | Type coercion, common_type, numeric ranking |
| st-semantics | tests/control_flow_tests.rs | 16 | IF/FOR/WHILE/REPEAT/CASE semantics |
| st-semantics | tests/call_tests.rs | 13 | Function/FB call argument checking |
| st-semantics | tests/struct_array_tests.rs | 11 | Struct field access, array indexing, UDTs |
| st-semantics | tests/warning_tests.rs | 10 | Unused variables, write-without-read |
| st-semantics | tests/coverage_gaps.rs | 44 | Edge cases for additional coverage |
| st-lsp | tests/lsp_integration.rs | 13 | Subprocess LSP lifecycle (init, open, diagnostics, shutdown) |
| st-lsp | tests/unit_tests.rs | 41 | In-process tests for completion, semantic tokens, document sync |
| st-compiler | tests/compile_tests.rs | 35 | AST-to-IR compilation for all statement/expression types |
| st-runtime | tests/vm_tests.rs | 42 | VM execution: arithmetic, control flow, calls, limits, cycles, intrinsics |
| st-runtime | tests/stdlib_tests.rs | 16 | Standard library integration: counters, timers, edge detection, math |
| st-runtime | tests/online_change_tests.rs | 10 | Engine-level online change: apply, preserve state, reject incompatible |
| st-runtime | src/online_change.rs (inline) | 11 | analyze_change compatibility, migrate_locals state preservation |
| st-runtime | src/debug.rs (inline) | 9 | Debug-mode VM helpers |
| st-dap | tests/dap_integration.rs | 26 | DAP protocol: breakpoints, stepping, continue across cycles, variables, evaluate, force/unforce |
| st-monitor | tests/monitor_tests.rs | 4 | WebSocket protocol: connect, subscribe, variable streaming, force/unforce |
Test Patterns
Grammar Tests (st-grammar)
Grammar tests are inline in crates/st-grammar/src/lib.rs. They verify that
tree-sitter can parse various ST constructs and produce the expected node
structure:
#![allow(unused)]
fn main() {
#[test]
fn test_parse_minimal_program() {
let mut parser = tree_sitter::Parser::new();
parser.set_language(&language()).unwrap();
let tree = parser.parse(source, None).unwrap();
assert!(!tree.root_node().has_error());
assert_eq!(program.kind(), kind::PROGRAM_DECLARATION);
}
}
The test_error_recovery test confirms that broken syntax still produces a
tree, and test_incremental_parse validates that re-parsing after an edit
uses the old tree for efficiency.
Semantic Tests (st-semantics)
Semantic tests follow a consistent pattern using a test_helpers module:
- Write a complete ST source string.
- Call the parse-and-analyze pipeline.
- Assert the expected diagnostics (errors/warnings) by code and/or message.
- Or assert that zero diagnostics are produced (valid program).
#![allow(unused)]
fn main() {
#[test]
fn test_undeclared_variable() {
let source = r#"
PROGRAM Main
VAR END_VAR
x := 1; // x is not declared
END_PROGRAM
"#;
let result = check(source);
assert!(result.diagnostics.iter().any(|d|
d.message.contains("undeclared")
));
}
}
The test files are split by domain: scope resolution, type checking, control flow validation, function calls, struct/array access, and warnings.
LSP Tests (st-lsp)
LSP tests come in two flavors:
-
Subprocess tests (
lsp_integration.rs, 13 tests): Launchst-cli serveas a child process, send JSON-RPC messages over stdio, and verify responses. These test the full end-to-end LSP protocol including initialization,textDocument/didOpen,textDocument/publishDiagnostics, and shutdown. -
In-process tests (
unit_tests.rs, 41 tests): Directly instantiate theBackendand call its methods, testing completion results, semantic token encoding, and document management without process overhead.
Compiler Tests (st-compiler)
Compiler tests in tests/compile_tests.rs parse ST source, compile it to a
Module, and verify the resulting IR structure:
- Correct number of functions in the module.
- Expected instruction sequences for arithmetic, control flow, and calls.
- Proper local/global variable slot allocation.
- Source map entries present for sourced instructions.
Runtime/VM Tests (st-runtime)
VM tests in tests/vm_tests.rs compile and execute ST programs, then inspect
the VM state:
#![allow(unused)]
fn main() {
#[test]
fn test_for_loop() {
let module = compile_source("PROGRAM Main VAR x:INT; i:INT; END_VAR ...");
let mut vm = Vm::new(module, VmConfig::default());
vm.run("Main").unwrap();
assert_eq!(vm.get_global("x"), Some(&Value::Int(55)));
}
}
These tests cover arithmetic operations, comparison, logic, control flow (IF/FOR/WHILE/REPEAT/CASE), function calls with return values, FB instance calls, safety limits (stack overflow, execution limit), division by zero, scan cycle execution through the Engine, and intrinsic functions (trig, math, conversions, SYSTEM_TIME).
Standard Library Tests (st-runtime)
The tests/stdlib_tests.rs file tests the standard library function blocks
end-to-end: counters (CTU, CTD, CTUD) counting on rising edges, timers
(TON, TOF, TP) with TIME values and SYSTEM_TIME(), edge detection (R_TRIG,
F_TRIG), and math functions (MAX_INT, MIN_INT, ABS_INT, LIMIT_INT, etc.).
DAP Tests (st-dap)
DAP integration tests in tests/dap_integration.rs test the full debug
protocol including breakpoints, stepping, continue across scan cycles,
variable inspection, and PLC-specific extensions: force x = 42,
unforce x, listForced, and scanCycleInfo.
Running Individual Test Suites
# Grammar tests only
cargo test -p st-grammar
# All semantic tests
cargo test -p st-semantics
# Only scope-related semantic tests
cargo test -p st-semantics --test scope_tests
# Only LSP integration tests (these are slower due to subprocess)
cargo test -p st-lsp --test lsp_integration
# Compiler tests
cargo test -p st-compiler
# VM tests
cargo test -p st-runtime
# Standard library tests
cargo test -p st-runtime --test stdlib_tests
# DAP debugger integration tests
cargo test -p st-dap
# Monitor server tests
cargo test -p st-monitor
# Online change tests (engine-level)
cargo test -p st-runtime --test online_change_tests
Code Coverage
The project uses cargo-llvm-cov for coverage reporting:
# Install (one-time)
cargo install cargo-llvm-cov
# Generate an HTML coverage report
cargo llvm-cov --workspace --html
# Generate a summary to the terminal
cargo llvm-cov --workspace
# Open the HTML report
open target/llvm-cov/html/index.html
Current Coverage
Overall workspace coverage is approximately 87%, with core logic crates achieving higher:
| Crate | Approximate Coverage |
|---|---|
| st-grammar | ~95% |
| st-syntax (lower.rs) | ~92% |
| st-semantics (analyze.rs) | ~95% |
| st-semantics (types.rs) | ~98% |
| st-semantics (scope.rs) | ~96% |
| st-ir | ~90% |
| st-compiler | ~88% |
| st-runtime (vm.rs) | ~91% |
| st-runtime (engine.rs) | ~85% |
| st-lsp | ~78% |
| st-cli | ~65% |
| st-dap | ~82% |
| st-monitor | ~80% |
The coverage_gaps.rs files in st-syntax and st-semantics were added
specifically to close coverage holes on edge cases and error paths.
Adding New Tests
Adding a Semantic Test
- Identify the appropriate test file (or create one in
crates/st-semantics/tests/). - Write a complete ST source snippet.
- Call
st_semantics::check(&source)or the test helper functions. - Assert on the diagnostics.
#![allow(unused)]
fn main() {
#[test]
fn test_my_new_check() {
let source = r#"
PROGRAM Main
VAR
x : INT;
END_VAR
x := 3.14; // should warn about implicit narrowing
END_PROGRAM
"#;
let result = st_semantics::check(source);
assert!(result.diagnostics.iter().any(|d|
d.message.contains("type mismatch")
));
}
}
Adding a Compiler/VM Test
- Write the ST source.
- Parse with
st_syntax::parse(). - Compile with
st_compiler::compile(). - Execute with
Vm::new()+vm.run(). - Inspect globals or the return value.
Adding a Grammar Test
Add an inline #[test] in crates/st-grammar/src/lib.rs that parses a new
construct and asserts the CST structure.
Continuous Integration
All tests run on every push. The CI pipeline:
cargo fmt --check– formatting.cargo clippy --workspace– lints.cargo test --workspace– all 483 tests.cargo llvm-cov --workspace– coverage report (optional).