st2cpp v0.5

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.

v0.5 highlights: task configuration. A 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

AreaFeatureBenefit
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:

PlatformFile
Linux x64st2cpp-linux-x64.tar.gz
Linux ARM64st2cpp-linux-arm64.tar.gz
macOS ARM64st2cpp-macos-arm64.tar.gz
Windows x64st2cpp-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

Strict vs Permissive Modes

ModeBehaviourBest 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 ◀────────┘
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.

TaskPLCCyclePriorityMaster cycleMaster priority
cycleLine110 ms4010 ms41
auxLine120 ms45
logicLine220 ms4020 ms41
A slower 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.

Running the result. The PLC needs one isolated CPU per Master plus one per task, and root: for 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

CategoryConstructs
POUsFUNCTION, FUNCTION_BLOCK, PROGRAM
Variable sectionsVAR, VAR_INPUT, VAR_OUTPUT, VAR_IN_OUT, VAR_TEMP, VAR_EXTERNAL, VAR_GLOBAL (CONSTANT/RETAIN)
Data typesAll IEC 61131-3 elementary types (BOOL, INT, DINT, REAL, LREAL, STRING, TIME, …)
User-defined typesSTRUCT … END_STRUCT, ENUM … END_ENUM via TYPE … END_TYPE
ArraysSingle and multi-dimensional; bounds-checked via STArray<T,Low,High> (from undoCore)
PointersPOINTER TO, REF_TO
Control flowIF/ELSIF/ELSE, FOR/TO/BY/DO, WHILE/DO, REPEAT/UNTIL, CASE/OF
CallsPositional, named (:=), output binding (=>), IN_OUT reference
Inheritance & interfacesFUNCTION_BLOCK B EXTENDS A with SUPER^, IMPLEMENTS
Method visibilityMETHOD PRIVATE, METHOD PROTECTED, METHOD PUBLIC
InitializersVariable initializers, array literal and struct initializers
Typed literalsUDINT#123, ULINT#16#9E3779B9, LREAL#3.14159
Process imageAT %I…, AT %Q…, AT %M… with --pi-auto
External librariesJSON Library Descriptor + Project Configuration via --ext-libs
SIZEOFSIZEOF(type) and SIZEOF(expression) (extension)

Type Mapping

st2cpp uses undoCore for type definitions, ensuring consistent type mapping across the entire undoRT ecosystem.

ST TypeC++ TypeNotes
BOOLbool
SINTint8_t
INTint16_t
DINTint32_t
LINTint64_t
USINTuint8_t
UINTuint16_t
UDINTuint32_t
ULINTuint64_t
REALfloat
LREALdouble
STRINGstd::string
TIMEuint32_tmilliseconds
ARRAY[L..H] OF TSTArray<T, L, H>bounds-checked (from undoCore)
POINTER TO TT*
VAR_IN_OUT TVAR_INOUT<T> (reference wrapper)from undoCore

Function Call Translation

ST SyntaxC++ 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;
};
Default visibility: Methods without explicit visibility default to PUBLIC. Constructor, operator(), and destructor are always PUBLIC.

Known Limitations (v0.4)

AreaStatus
ARRAY[*] variable-length arraysPlanned
VAR_CONFIGPlanned
SFC (ACTION, TRANSITION)Planned
IEC standard function library (TON, CTU, ABS…)Planned
Multi-target / multi-version codegen profilesPlanned

CLI Reference

OptionDescriptionDefault
-o <file>Output .cpp source file<input>.cpp
-H <file>Output .hpp header file<input>.hpp
--namespace <name>C++ namespace for generated codeundoCore
--runtime <file>Custom runtime headerundoCore/undoCore.hpp
--tokensDump token stream and exit (debug)—
--caseSensitivePreserve 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-styleModular output with dependency managementoff
--output-dir <dir>Output directory (workspace/project mode)generated/
--pi-autoAuto-detect Process Image sizeson
--pi-no-autoDisable auto-detection, use manual sizes—
--pi-input <bytes>Process Image Input size1024
--pi-output <bytes>Process Image Output size1024
--pi-marker <bytes>Process Image Marker size1024
--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, --verbosePrint detailed processing information (incl. diagnostics)off
--strictStrict mode: block generation on any semantic erroroff
--versionPrint version and exit—
-h, --helpShow 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
Use project mode for any workspace with more than 2–3 Function Blocks. It enables incremental compilation and keeps each FB in a separate file, which is what an IDE indexes well.

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.

CategoryDescription
Lexer & ParserTokenization and AST construction for all IEC 61131‑3 constructs
Semantic AnalysisSymbols, scopes, type checking, enums, structs, arrays, interfaces, inheritance, forward references, dependency cycles, process image, external-library import
Code GenerationSingle-file and modular project output
JSON / Library DescriptorJSON engine, descriptor loader & validation, canonical serializer, Project Configuration
Descriptor ExportST → descriptor → JSON → re-import round trip, dependency policies, representability errors
CompilationEnd-to-end compilation of generated C++ with g++
Total (v0.5): 765 tests run on every commit to catch regressions before they ship.

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.

Integration with undoCore: The generated code includes #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