st2cpp Released · v0.5
A transpiler that compiles IEC 61131-3 Structured Text into native C++17, validated by a complete semantic analysis stage and driven by JSON-defined data and external libraries. There is no virtual machine and no interpreter: the output is auditable, standard C++ that compiles, debugs and deploys on any toolchain.
Overview
st2cpp is the compiler component of the undoRT ecosystem.
It takes .st source files and produces .hpp / .cpp pairs
that compile with any C++17-capable toolchain.
Before any code is emitted the program is checked, not just parsed: a two-pass semantic analysis verifies symbols, scopes, types, array bounds, inheritance and interfaces, and process-image addresses — catching at build time the errors that would otherwise surface on a live plant.
And while st2cpp reads Structured Text, it speaks JSON: libraries, data types, variables, dependencies and C++ bindings are declared in standard, versioned JSON descriptors that the toolchain can both import (external libraries, project configuration) and export (ST → library descriptor → JSON) with a guaranteed round trip.
- Full semantics — two-pass analysis, IEC case-insensitive resolution, type checking
- Fail-fast
--strictmode for CI and production builds - All IEC 61131-3 elementary types, STRUCT, ENUM, arrays, pointers, interfaces
- FUNCTION / FUNCTION_BLOCK / PROGRAM, FB inheritance with
SUPER^ - Named & positional calls,
VAR_OUTPUTbinding via=> - Method visibility (PRIVATE, PROTECTED, PUBLIC)
- Process Image
%I/%Q/%Mwith auto-detection - External libraries & data in JSON — Library Descriptor v1.0
- ST → JSON export: semantic-only library descriptors with round trip
- Modular project generation with automatic dependency sorting
- Header-only runtime (zero dependencies), single binary, no install
tasks.json document
declares which cyclic task runs which programs, at which cycle, priority and CPU core, and
the compiler emits an undoPLC runtime around the generated code.
The whole toolchain also drops to C++17: undoPLC only needed C++20 for
std::latch, which it has now replaced. The test suite comprises
765 automated tests, executed on every commit.
What's New in v0.5
| Area | Feature | Benefit |
|---|---|---|
| Tasks | Task Configuration JSON v1.0 (tasks.json, --tasks) with loader and full validation |
The cycle, priority, CPU core and program list of every cyclic task are declared as data |
| Runtime | Generates Runtime.cpp: one undoPLC Master per PLC instance, one Worker per task |
The generated project is a runnable PLC rather than a set of translation units |
| Runtime | One PROGRAM instance per calling task, called in declared order | Matches TwinCAT, so a program shared by two tasks cannot corrupt the other one's state |
| Toolchain | C++17 across st2cpp, the tests, the generated code, undoPLC and undoCore | Reaches toolchains that do not implement C++20 |
| Runtime | Startup and shutdown diagnostics no longer go through the deferred logger | Messages logged before ioc.run() were not shown until later and were lost if the program exited first |
| Semantic analysis | Full two-pass analyzer: symbol table, scopes, type checking, interfaces & inheritance verification, circular-dependency detection | Errors are caught before codegen, so fewer failures reach the target |
| Semantic analysis | --strict vs --permissive modes |
Choose fail-fast CI builds or incremental adoption on existing code |
| Data (JSON) | Library Descriptor JSON v1.0 with loader, registry, validation and canonical serializer | Declare libraries and data as portable, versioned data — not code |
| Data (JSON) | Project Configuration JSON v1.0 (--ext-libs) |
One config file wires libraries into semantic analysis and codegen |
| Export (JSON) | LibraryDescriptorBuilder: ST → semantic-only descriptor → JSON, round-trippable |
ST logic becomes reusable, re-importable, machine-readable data |
| Codegen | External-library calls, namespace-qualified symbols and automatic #includes |
Call native C++ (or other ST libraries) from ST, cleanly |
| Process image | --pi-auto detection and manual --pi-input/output/marker |
Zero-configuration I/O mapping, or explicit control of every address |
Installation
Download pre-built binary
The pre-built binaries are on GitHub Releases:
| Platform | File |
|---|---|
| Linux x64 | st2cpp-linux-x64.tar.gz |
| Linux ARM64 | st2cpp-linux-arm64.tar.gz |
| macOS ARM64 | st2cpp-macos-arm64.tar.gz |
| Windows x64 | st2cpp-win-x64.zip |
# Linux / macOS
tar -xzf st2cpp-*.tar.gz
./st2cpp --help
# Windows
unzip st2cpp-win-x64.zip
st2cpp.exe --help
Build from source
Prerequisites: a C++17 compiler (GCC 7+, Clang 5+, or MSVC 2019+), CMake 3.16+.
git clone https://github.com/undoRT/st2cpp.git
cd st2cpp
./build.sh
# Or manually:
mkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release
make -j$(nproc)
Quick Start
# Transpile a single file
./st2cpp program.st --namespace myplc
# Transpile an entire workspace in project mode
./st2cpp --workspace ./src --project-style --output-dir build --namespace myplc
# Wire in external libraries declared in JSON
./st2cpp --workspace . --ext-libs project.json --namespace myplc
# Fail-fast semantic validation for CI
./st2cpp program.st --strict
# Compile the generated C++
g++ -std=c++17 -I./st2cpp_includes build/Programs/Main.cpp -o plc_main
Semantic Analysis
St2cpp doesn't stop at parsing. Every source is analyzed before code generation: the analyzer builds a full symbol table with case-insensitive IEC 61131-3 identifier resolution, resolves type references across scopes, and checks assignments, calls, array indices, and member accesses for type compatibility.
Because PLC programs run critical equipment, errors are surfaced at compile time
with precise file:line:col diagnostics and stable error codes — so a semantics failure
never becomes a runtime failure on the controller.
Two-Pass Analysis
- First pass — registers every enum, struct, interface and POU header with a stable type id, so forward references (a struct or FB that uses a type declared later) resolve correctly.
- Second pass — resolves every body: names, types, expressions, calls, members.
- Declaration-order independent: the result is identical regardless of file order after a workspace merge.
- Circular dependency detection — by-value cycles (
AcontainsBcontainsA) are reported; pointer /REF_TOcycles stay legal, as in IEC. - Inheritance & interfaces —
EXTENDS/SUPER^resolution andIMPLEMENTScontract verification (abstract methods, overrides). - Enums are typed — enumerators carry their enum type, usable in assignments and comparisons.
Strict vs Permissive Modes
| Mode | Behaviour | Best for |
|---|---|---|
Permissive (default) |
Collects diagnostics, surfaces them with --verbose, but still generates, gracefully falling back to legacy inference |
Incremental adoption on existing codebases |
Strict (--strict) |
Prints every diagnostic and blocks generation as soon as any error is found | CI pipelines, production and safety-critical builds |
Process Image & AT Addresses
Globals mapped with AT %I…, AT %Q… and AT %M… are resolved and
validated against the configured process image layout, and accessors are generated for bit, byte, word
and dword sizes. --pi-auto detects the image size automatically; leave it on for zero-config
I/O, or use --pi-input/output/marker to pin exact sizes.
JSON Configuration
Libraries, data types and projects are data, not code. st2cpp ships a self-contained JSON engine and two versioned schemas, so the engineering data survives across tools, versions and teams — and the compiler both consumes and produces it.
Library Descriptor JSON v1.0
A Library Descriptor declares one library: identity (id, name, semver
version), dependencies, and the resources it exposes — constants,
enums, types (structs), globalVariables, functions
and functionBlocks — each with typed references, initializers and, when available, the
C++ binding to back them.
{
"$schemaVersion": "1.0",
"id": "io",
"name": "I/O Library",
"version": "1.0.0",
"enums": [{
"name": "AlarmMode",
"baseType": { "kind": "primitive", "name": "INT" },
"members": [
{ "name": "Off", "value": 0 },
{ "name": "On", "value": 1 }
]
}],
"globalVariables": [{
"name": "factoryAlarm",
"type": { "kind": "named", "name": "AlarmMode" },
"scope": "global"
}]
}
The loader validates structure and cross-entity rules (unique names, resolvable references, valid initializers, coherent bindings) and returns all problems at once, not just the first one. A canonical serializer round-trips the descriptor to byte-identical JSON.
Project Configuration JSON v1.0
A project.json lists the libraries a project uses. One --ext-libs flag on the
command line loads the registry, injects the external symbols into semantic analysis, and makes the
code generator emit namespace-qualified calls, FB instance methods and the required
#includes automatically.
{
"$schemaVersion": "1.0",
"libraries": [
{ "id": "io", "path": "libraries/io.json" },
{ "id": "math", "path": "libraries/math.json" }
]
}
Import & Export — a Closed Loop
The pipeline is bidirectional. Beyond importing JSON libraries, st2cpp exports
analyzed Structured Text back to a semantic-only Library Descriptor through
LibraryDescriptorBuilder:
ST ──▶ Parser ──▶ SemanticAnalyzer ──▶ LibraryDescriptorBuilder ──▶ JSON (v1.0)
▲ │
└─────────────── re-import (round trip) ◀── LibraryLoader ◀────────┘
- Semantic-only descriptors — valid without C++ bindings (functions and FBs may omit
cppBinding). - Unrepresentable constructs produce explicit, named errors — never silent omissions.
- Dependencies are derived from analysis and validated against version policies.
- Deterministic output: same ST + same options ⇒ byte-identical JSON.
st2cpp lib.st --export-descriptor lib.json \
--lib-id timerlib --lib-name TimerLib --lib-version 1.0.0
The export command runs on a single .st file or on a whole
--workspace (merged as one library), composing with --ext-libs so external
references become real dependencies with explicit version policies
(--lib-dependency <id>=<constraint>). Unrepresentable constructs are reported as
Export error lines, never silently dropped.
External Libraries in Action
st2cpp --workspace . --ext-libs project.json --output-dir generated
Symbols from the declared libraries resolve during analysis, and the generated C++ calls compile against the referenced C++ (or other ST libraries) with the right namespace and includes — a clean boundary between the transpiled logic and third-party or native code, driven entirely by data.
Task Configuration and the PLC Runtime
A tasks.json document declares the cyclic tasks of a PLC: which programs each
one runs, at which cycle, priority and CPU core. It is passed to st2cpp, not to the
generated binary. With --tasks the compiler emits an extra
Runtime.cpp around the generated code, and the result is a runnable PLC.
{
"schema_version": "1.0",
"plc_name": "Demo",
"tasks": [
{ "name": "cycle", "plc": "Line1", "cycle_ms": 10, "priority": 40,
"programs": ["MAIN", "Control", "Report"] }
]
}
st2cpp --workspace ./src --project-style --tasks tasks.json --output-dir generated
--tasks requires --workspace and --project-style,
because Runtime.cpp includes the modular Programs.hpp.
What the generated runtime looks like
One UndoMasterTaskBase per distinct plc value, and one
UndoWorkerTaskBase per task, each calling run() on the programs
it lists, in the order they are declared:
Line1_cycle _program_MAIN.run();
Line1_cycle _program_CONTROL.run();
Line1_cycle _program_REPORT.run();
Each Worker declares its own member per program, so a program listed by two tasks is instantiated once in each: two independent objects, as in TwinCAT. They cannot corrupt each other's state.
How the Master is derived
This is not a free choice. undoPLC drives every Worker from a single Master cycle, so the Master runs at the finest cycle its tasks asked for, and at a priority strictly above all of them, so a task that overruns cannot delay the cycle supervising it.
| Task | PLC | Cycle | Priority | Master cycle | Master priority |
|---|---|---|---|---|---|
cycle | Line1 | 10 ms | 40 | 10 ms | 41 |
aux | Line1 | 20 ms | 45 | ||
logic | Line2 | 20 ms | 40 | 20 ms | 41 |
cycle_ms does not slow the task down.
It still runs every Master cycle. In the table above aux declares 20 ms and
runs at 10 ms, because it shares Line1's Master with cycle. A task that
genuinely has to run slower belongs on a PLC instance whose fastest task is that slow.
Validation
The loader collects every violation before it fails, so one run reports the whole set rather than the first problem. Worker priorities run from 1 to 98, with 99 reserved for the Master. Every name must be unique, the cycle must be positive, and each program in a task must exist in the workspace.
SCHED_FIFO priorities and to pin the isolated cores
to their nominal frequency. Both are checked at startup and both are fatal, because a PLC
running with an unpinned governor would appear to work while its jitter stopped being
bounded. The examples/tasks directory in the repository is a complete worked
example that measures the invocation rate of each program.
Supported Constructs
| Category | Constructs |
|---|---|
| POUs | FUNCTION, FUNCTION_BLOCK, PROGRAM |
| Variable sections | VAR, VAR_INPUT, VAR_OUTPUT, VAR_IN_OUT, VAR_TEMP, VAR_EXTERNAL, VAR_GLOBAL (CONSTANT/RETAIN) |
| Data types | All IEC 61131-3 elementary types (BOOL, INT, DINT, REAL, LREAL, STRING, TIME, …) |
| User-defined types | STRUCT … END_STRUCT, ENUM … END_ENUM via TYPE … END_TYPE |
| Arrays | Single and multi-dimensional; bounds-checked via STArray<T,Low,High> (from undoCore) |
| Pointers | POINTER TO, REF_TO |
| Control flow | IF/ELSIF/ELSE, FOR/TO/BY/DO, WHILE/DO, REPEAT/UNTIL, CASE/OF |
| Calls | Positional, named (:=), output binding (=>), IN_OUT reference |
| Inheritance & interfaces | FUNCTION_BLOCK B EXTENDS A with SUPER^, IMPLEMENTS |
| Method visibility | METHOD PRIVATE, METHOD PROTECTED, METHOD PUBLIC |
| Initializers | Variable initializers, array literal and struct initializers |
| Typed literals | UDINT#123, ULINT#16#9E3779B9, LREAL#3.14159 |
| Process image | AT %I…, AT %Q…, AT %M… with --pi-auto |
| External libraries | JSON Library Descriptor + Project Configuration via --ext-libs |
SIZEOF | SIZEOF(type) and SIZEOF(expression) (extension) |
Type Mapping
st2cpp uses undoCore for type definitions, ensuring consistent type mapping across the entire undoRT ecosystem.
| ST Type | C++ Type | Notes |
|---|---|---|
BOOL | bool | |
SINT | int8_t | |
INT | int16_t | |
DINT | int32_t | |
LINT | int64_t | |
USINT | uint8_t | |
UINT | uint16_t | |
UDINT | uint32_t | |
ULINT | uint64_t | |
REAL | float | |
LREAL | double | |
STRING | std::string | |
TIME | uint32_t | milliseconds |
ARRAY[L..H] OF T | STArray<T, L, H> | bounds-checked (from undoCore) |
POINTER TO T | T* | |
VAR_IN_OUT T | VAR_INOUT<T> (reference wrapper) | from undoCore |
Function Call Translation
| ST Syntax | C++ Output |
|---|---|
foo(10, FALSE) | Positional arguments mapped in declaration order |
foo(a := 1, b := 2) | Named arguments reordered to match declaration |
foo(out => myVar) | Output bound as &myVar, unbound outputs get a temp variable |
foo(inout := myVar) | IN_OUT bound as reference |
FB.method() | Method call on FB struct member |
SUPER^.method() | Base class method call via C++ inheritance |
Method Visibility
st2cpp supports PRIVATE, PROTECTED, and PUBLIC visibility
modifiers for methods inside FUNCTION_BLOCK.
ST Syntax
FUNCTION_BLOCK MyFB
VAR
counter : INT;
END_VAR
METHOD PRIVATE splitMix : UDINT
VAR_IN_OUT state : UDINT; END_VAR
// ...
END_METHOD
METHOD PROTECTED init : BOOL
// ...
END_METHOD
METHOD PUBLIC exec : UDINT
VAR_INPUT N : UDINT; END_VAR
// ...
END_METHOD
END_FUNCTION_BLOCK
Generated C++
struct MYFB {
public:
// Variables...
// SETTER/GETTER...
// --- PRIVATE METHODS ---
private:
virtual UInt32 SPLITMIX(UInt32& STATE);
// --- PROTECTED METHODS ---
protected:
virtual Bool INIT();
// --- PUBLIC METHODS ---
public:
MYFB();
void operator()();
virtual UInt32 EXEC(Int16 N);
virtual ~MYFB() = default;
};
PUBLIC.
Constructor, operator(), and destructor are always PUBLIC.
Known Limitations (v0.4)
| Area | Status |
|---|---|
ARRAY[*] variable-length arrays | Planned |
VAR_CONFIG | Planned |
SFC (ACTION, TRANSITION) | Planned |
| IEC standard function library (TON, CTU, ABS…) | Planned |
| Multi-target / multi-version codegen profiles | Planned |
CLI Reference
| Option | Description | Default |
|---|---|---|
-o <file> | Output .cpp source file | <input>.cpp |
-H <file> | Output .hpp header file | <input>.hpp |
--namespace <name> | C++ namespace for generated code | undoCore |
--runtime <file> | Custom runtime header | undoCore/undoCore.hpp |
--tokens | Dump token stream and exit (debug) | — |
--caseSensitive | Preserve original case (IEC identifiers are otherwise normalized) | off |
--workspace <path> | Process all .st files recursively | — |
--ext-libs <file.json> | Load external libraries from a Project Configuration JSON | — |
--tasks <tasks.json> | Task Configuration; emits an undoPLC runtime. Requires --workspace and --project-style | — |
--project-style | Modular output with dependency management | off |
--output-dir <dir> | Output directory (workspace/project mode) | generated/ |
--pi-auto | Auto-detect Process Image sizes | on |
--pi-no-auto | Disable auto-detection, use manual sizes | — |
--pi-input <bytes> | Process Image Input size | 1024 |
--pi-output <bytes> | Process Image Output size | 1024 |
--pi-marker <bytes> | Process Image Marker size | 1024 |
--export-descriptor <file.json> | Export ST to a semantic-only JSON Library Descriptor | — |
--lib-id <id> | Library id for export (required) | — |
--lib-name <name> | Library name for export (required) | — |
--lib-version <ver> | Library semver version for export (required) | — |
--lib-description <text> | Optional library description | — |
--lib-dependency <id>=<constraint> | Version policy for an exported external dependency (repeatable) | — |
-v, --verbose | Print detailed processing information (incl. diagnostics) | off |
--strict | Strict mode: block generation on any semantic error | off |
--version | Print version and exit | — |
-h, --help | Show help message | — |
Single File Mode
The default mode. Produces one .hpp and one .cpp from a single .st source.
Best for small projects or quick experiments.
./st2cpp counter.st -o counter.cpp -H counter.hpp --namespace myplc
Include the undoCore headers (shipped in
st2cpp_includes/undoPLC/third_party/undoCore) in the compiler's include path when
building the output:
g++ -std=c++17 -I./st2cpp_includes counter.cpp -o counter
Project Mode
For workspaces with multiple POUs. st2cpp scans all .st files recursively,
resolves dependencies between Function Blocks via topological sort, and generates a
circular-dependency-safe header layout:
./st2cpp --workspace ./plc_src --project-style --output-dir build --namespace myplc
build/
├── SimpleGVLs.hpp # ENUMs, simple STRUCTs, simple globals
├── FunctionBlocks.hpp # Aggregator — includes all FB headers
├── FunctionBlocks/ # One .hpp/.cpp per Function Block
│ ├── Timer.hpp / .cpp
│ └── Counter.hpp / .cpp
├── GVLs.hpp / .cpp # Complex globals (STRUCTs containing FBs)
├── Functions.hpp / .cpp # Global functions
└── Programs/
└── Main.hpp / .cpp
Full Example
Input (counter.st)
FUNCTION_BLOCK Counter
VAR_INPUT
Enable : BOOL;
Reset : BOOL;
END_VAR
VAR_OUTPUT
Count : INT;
END_VAR
IF Reset THEN
Count := 0;
ELSIF Enable THEN
Count := Count + 1;
END_IF
END_FUNCTION_BLOCK
PROGRAM Main
VAR
C : Counter;
END_VAR
C(Enable := TRUE, Reset := FALSE);
END_PROGRAM
Generated header (counter.hpp)
#pragma once
#include "undoCore/undoCore.hpp"
namespace myplc {
// FUNCTION BLOCK COUNTER
struct COUNTER {
public:
// VAR_INPUT
Bool ENABLE{};
Bool RESET{};
// VAR_OUTPUT
Int16 COUNT{};
// SETTER/GETTER
inline void set_ENABLE(Bool val) { ENABLE = val; }
inline void set_RESET(Bool val) { RESET = val; }
inline Int16 get_COUNT() const { return COUNT; }
// End SETTER/GETTER
COUNTER();
void operator()();
virtual ~COUNTER() = default;
};
// PROGRAM MAIN
struct MAIN {
COUNTER C{};
void run();
};
} // namespace myplc
Generated source (counter.cpp)
#include "counter.hpp"
namespace myplc {
// FUNCTION BLOCK COUNTER
COUNTER::COUNTER() {}
void COUNTER::operator()() {
if (RESET) {
COUNT = 0;
} else if (ENABLE) {
COUNT = COUNT + 1;
}
}
// END FUNCTION BLOCK COUNTER
void MAIN::run() {
C.set_ENABLE(true);
C.set_RESET(false);
C();
}
} // namespace myplc
Testing
st2cpp ships a comprehensive suite built with Google Test, covering every stage of the compiler pipeline and compiling the generated C++ with a real compiler. All tests run automatically on every commit.
| Category | Description |
|---|---|
| Lexer & Parser | Tokenization and AST construction for all IEC 61131‑3 constructs |
| Semantic Analysis | Symbols, scopes, type checking, enums, structs, arrays, interfaces, inheritance, forward references, dependency cycles, process image, external-library import |
| Code Generation | Single-file and modular project output |
| JSON / Library Descriptor | JSON engine, descriptor loader & validation, canonical serializer, Project Configuration |
| Descriptor Export | ST → descriptor → JSON → re-import round trip, dependency policies, representability errors |
| Compilation | End-to-end compilation of generated C++ with g++ |
The suite lives in tests/ and can be run with ctest after building
with -DBUILD_TESTS=ON.
Architecture
st2cpp follows a classic compiler frontend pipeline — hand-written in C++17, no external parser generators — extended with a semantic analysis stage and a bidirectional JSON data layer:
ST Source
└─▶ Lexer (Token stream)
└─▶ Parser (Recursive descent → AST)
└─▶ SemanticAnalyzer (2 passes → symbol table, diagnostics)
└─▶ CodeGen (AST + semantics → C++17 source)
JSON side:
Project Config / Library Descriptor (v1.0)
└─▶ LibraryLoader / LibraryRegistry ──▶ SemanticAnalyzer (external symbols)
└─▶ LibraryDescriptorBuilder ────────▶ JSON export (ST → descriptor)
The AST uses std::variant for node types (ExprVariant, StmtVariant)
with std::visit dispatch — no virtual dispatch, no dynamic_cast.
#include <undoCore/undoCore.hpp> and uses STArray,
VAR_INOUT and the conversion functions from undoCore. The undoPLC runtime
in Runtime.cpp additionally derives from undoTasks.hpp, whose own
dependencies ship in the same submodule.
Repository Layout
st2cpp/
├── include/
│ ├── ast/ # AST node definitions (std::variant-based)
│ ├── lexer/ # Token types and Lexer class
│ ├── parser/ # Parser class and helpers
│ ├── semantic/ # SemanticAnalyzer, symbol table, global functions
│ ├── library/ # Library Descriptor model, loader, serializer
│ ├── project/ # Project Configuration model + loader
│ ├── json/ # Self-contained JSON engine (DOM, parse, dump)
│ └── codegen/ # CodeGenerator class
├── src/
│ ├── lexer/ # Lexer implementation
│ ├── parser/ # Parser + statement parser
│ ├── semantic/ # Analyzer + LibraryDescriptorBuilder + symbol importer
│ ├── library/ # Loader / serializer / registry implementation
│ ├── project/ # Project config implementation
│ ├── json/ # JSON engine implementation
│ ├── codegen/ # Code generator
│ └── cli/ # main.cpp + argument parsing
├── st2cpp_includes/
│ └── undoPLC/ # undoPLC submodule (undoTasks runtime, nested undoCore)
├── tests/ # 765 tests
├── scripts/ # build.sh, generate_docs.sh
└── CMakeLists.txt
Full API documentation is generated with Doxygen: Browse API Reference
st2cpp