Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

RuSTy Documentation

RuSTy compiles IEC 61131-3 Structured Text into machine code, with LLVM as its back end. The compiler binary is called plc.

This book has two parts. Open the one you need in the sidebar.

  • User Documentation: how to install the compiler, how to use it, and how to write Structured Text for it.
  • Technical Documentation: how the compiler works inside. The stages of the pipeline, the participants that rewrite the program between the stages, the other outputs, and the internals of each language construct.

New to the compiler? Start with Install.

User Documentation

After these chapters you can install the compiler, write Structured Text for it, and build what you wrote. The compiler binary is called plc.

They start with the installation and a first program. The language chapters then explain Structured Text itself, from the shape of a source file to interfaces, generic functions, and programs drawn as charts. After that comes the compiler as a tool, then the border to code written in C, and last a reference part that lists every option, key, construct, and error code for looking up.

Getting Started

After this chapter you have a working compiler and a program of your own that builds and runs.

It covers how to build the compiler from source and what else your system needs. It then shows how one source file becomes an executable, and how to read what the compiler reports when that file is wrong. At the end it shows how a project file holds the inputs and the options of a build, which you need as soon as a program has more than one file.

Install

You build the compiler from source and get a binary that you can run from anywhere. The binary is called plc. On Windows you can download it instead, see Windows.

The build needs Rust and a full LLVM installation. plc also needs a linker on the system to produce executables and shared objects. Use the compiler driver of the system: cc on Linux, and clang on macOS and Windows.

Note

The LLVM installation must match the major version that the compiler is built against. This is LLVM 21. LLVM gives no API compatibility between major versions, so another version does not link.

Follow the section for your system, and then build. The dev container brings everything with it.

Dev container

A dev container is the shortest way to a complete environment. Open the repository in VS Code and choose “Reopen in Container”. The container is defined in .devcontainer/ and is based on Ubuntu 26.04.

The container runs with a read-only root filesystem and without sudo. The workspace is mounted at /workspace. The home directory and the target/ directory live on named volumes, so builds and tools installed with cargo install survive a rebuild of the container.

Ubuntu 26.04

Ubuntu 26.04 ships LLVM 21 in its own package archive, so the LLVM apt repository is not necessary:

# Install the prerequisites
sudo apt install build-essential clang lld zlib1g-dev libzstd-dev llvm-21-dev llvm-21-tools libpolly-21-dev

# Install Rust, see https://rust-lang.org/tools/install/
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# lit is shipped with llvm-21-tools, make it available as `lit`
sudo ln -s /usr/lib/llvm-21/bin/lit /usr/local/bin/lit

Ubuntu 24.04

# Install the prerequisites
sudo apt install lsb-release wget software-properties-common gnupg build-essential zlib1g-dev libzstd-dev lld clang

# Install Rust, see https://rust-lang.org/tools/install/
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# Install LLVM 21, see https://apt.llvm.org/
wget https://apt.llvm.org/llvm.sh
chmod +x llvm.sh
sudo ./llvm.sh 21 && sudo apt install libpolly-21-dev

# Install uv, see https://docs.astral.sh/uv/getting-started/installation/
# (only necessary to run the test suite)
curl -LsSf https://astral.sh/uv/install.sh | sh
source $HOME/.local/bin/env
uv tool install lit

Debian Trixie

Use the instructions for Ubuntu 24.04, but remove software-properties-common, which Debian does not have.

macOS

Install the Xcode Command Line Tools and the LLVM toolchain with Homebrew:

brew install llvm@21 lld gnu-getopt lit

Then put the Homebrew binaries in your PATH:

echo 'export PATH="/opt/homebrew/opt/llvm@21/bin:$PATH"' >> ~/.zprofile
echo 'export PATH="/opt/homebrew/opt/gnu-getopt/bin:$PATH"' >> ~/.zprofile

The lit test suite expects FileCheck-21. If it is not there, make a symbolic link:

ln -svf /opt/homebrew/opt/llvm@21/bin/FileCheck /opt/homebrew/opt/llvm@21/bin/FileCheck-21

Windows

The releases page publishes plc.exe together with iec61131std.lib and iec61131std.dll, the two parts of the standard library. Put them into a directory of your PATH. LLVM is inside the binary, so this way needs neither Rust nor LLVM, and you can go straight to Verify the installation.

Build from source when you want to work on the compiler itself. Install Rust and the matching LLVM build from the llvm-package-windows releases. Extract it and add its bin/ directory to your PATH.

Rust needs the C++ build tools. A full Visual Studio installation gives them, but the build tools alone are smaller and faster to install.

Build

cargo build --release

The binary is written to target/release/plc. Put that directory into your PATH, or copy the binary into a directory that is in it already.

The standard library is a second artifact, and the build script builds and collects it:

./scripts/build.sh --build --release --package

This writes the static and the shared library into output/lib, and the declaration files of the standard functions into output/include. The programs of the next two chapters do not need it. Linking and Libraries shows how to link it when you call a standard function.

Verify the installation

plc --version

The command prints the version, the commit date, and the commit hash of the binary.

What’s next

The compiler is ready. Write your first program in the next chapter.

Hello, World

You write one file, compile it, and run it.

The program

A program needs a place to start. The compiler does not choose one; the C runtime calls the function main.

Printing needs a function that writes to the terminal, and puts from the C library does that. The {external} attribute tells the compiler that the implementation is somewhere else and that the linker will find it.

Write this into hello_world.st:

{external}
FUNCTION puts: DINT
    VAR_INPUT {ref}
        text: STRING;
    END_VAR
END_FUNCTION

FUNCTION main: DINT
    puts('hello, world!');
END_FUNCTION

Two details of the language show up already. A string literal stands between single quotation marks, and a $ inside it starts an escape, such as $N for a new line. This literal needs no $N, because puts writes a new line after the text.

Compile and run

plc hello_world.st -o hello_world --linker=cc
./hello_world
hello, world!

The compiler translated the file into an object file and then called cc to link that object with the C library into an executable. -o names the result. --linker=cc names the program that links; Install says which one your system has.

Leave out -o and the executable is named after the input file, here hello_world.st.out. Add -c and the compiler stops after the object file, hello_world.st.o.

When something is wrong

Now make a mistake on purpose. Write this into hello_world.st instead:

FUNCTION main: DINT
    VAR
        x: DINT;
    END_VAR

    x := 'text';
END_FUNCTION
error[E037]: Invalid assignment: cannot assign 'STRING' to 'DINT'
  ┌─ hello_world.st:6:5
  │
6 │     x := 'text';
  │     ^^^^^^^^^^^ Invalid assignment: cannot assign 'STRING' to 'DINT'

error: Compilation aborted due to critical errors.
Hint: You can use `plc explain <ErrorCode>` for more information

The first line carries the code of the message. plc explain E037 prints what the code means, with an example. The compiler also returns a non-zero exit code, so a script sees the failure.

To check the file without producing anything, use plc --check hello_world.st.

What’s next

One file on the command line is enough for one program. Real code lives in several files and needs the same options on every build. The next chapter puts both into a project.

Your First Project

You build something small but real: a plant with two tanks, in two files, driven by a project file.

A function block

A function block is a piece of code with memory. You declare it once and use it as often as you want, and every use has its own data.

Write src/tank.st:

FUNCTION_BLOCK Tank
    VAR_INPUT
        inflow: DINT;
    END_VAR
    VAR_OUTPUT
        level: DINT;
        full: BOOL;
    END_VAR
    VAR CONSTANT
        CAPACITY: DINT := 10;
    END_VAR

    level := level + inflow;
    IF level >= CAPACITY THEN
        level := CAPACITY;
        full := TRUE;
    END_IF
END_FUNCTION_BLOCK

VAR_INPUT is what the caller gives, VAR_OUTPUT is what the caller reads back, and VAR CONSTANT is a value that nobody can write.

A program that uses it

A program is a function block whose instance the compiler creates itself. There is one, and it is global, which makes a program the natural place for the state of the plant.

The program prints numbers, so it uses printf from the C library. The ... declares a variadic parameter, so a call passes one value for each %d in the format string.

Write src/main.st:

{external}
FUNCTION printf: DINT
    VAR_INPUT {ref}
        format: STRING;
    END_VAR
    VAR_INPUT
        args: ...;
    END_VAR
END_FUNCTION

PROGRAM Plant
    VAR
        left: Tank;
        right: Tank;
        cycle: DINT;
    END_VAR

    cycle := cycle + 1;
    left(inflow := 4);
    right(inflow := 7);
    printf('cycle %d: left=%d right=%d full=%d$N', cycle, left.level, right.level, right.full);
END_PROGRAM

FUNCTION main: DINT
    Plant();
    Plant();
END_FUNCTION

left and right are two instances of the same function block. left(inflow := 4) calls the instance and gives its input a value. After the call, left.level reads its output. Plant() calls the program, because its instance has no name of its own.

The two files know each other without an import. What one file declares at the top level, the whole project can use.

The project file

A command line that names every file grows with each new file. A project file says it once. Put the inputs and the kind of artifact into plc.json, next to the src directory:

{
    "name": "tank",
    "files": [ "src/*.st" ],
    "compile_type": "Static"
}

compile_type is Static here, which produces an executable. The Project File reference lists every key.

plc build reads that file:

plc build --linker=cc
./build/tank.out
cycle 1: left=4 right=7 full=0
cycle 2: left=8 right=10 full=1

The output shows what a function block is for. Each instance kept its own level between the two cycles, and the right tank reached its capacity in the second one.

The build wrote everything into build/: the artifact tank.out, and one object file per source file under the path of the source, so src/tank.st became build/src/tank.st.o. The artifact carries the name of the project, because the file sets no output.

What’s next

You can write, build, and run a project. The Language Guide starts at the beginning and explains the language itself, from the shape of a source file to interfaces and generics.

Language Guide

After this chapter you can write Structured Text as this compiler accepts it. Its subchapters build on each other, so read them in order the first time.

They cover the shape of a source file, the rules for names and scope, the declaration of variables, and the types that hold numbers, text, time, and structured data. They then cover the expressions and statements that compute and decide, the units of code that a program is built from, and the mechanisms for larger designs, from methods and interfaces to generic functions, pointers, hardware addresses, and bodies that are drawn as charts. Each one also states what the compiler rejects, and what it accepts without doing what the words suggest.

Source Files

Before the language itself, look at the files that hold it: what goes into one, how you write a comment, how the compiler reads a name, and where that name is visible. The last section lists the attributes that change how the compiler treats a declaration.

What a file contains

A source file is a list of declarations at the top level. There is no wrapper around them and no order requirement:

TYPE Level: INT (0..100);
END_TYPE

VAR_GLOBAL
    cycleTime: TIME := T#10ms;
END_VAR

FUNCTION_BLOCK Pump
    (* ... *)
END_FUNCTION_BLOCK

PROGRAM Plant
    (* ... *)
END_PROGRAM

A file may hold type declarations (TYPE), global variables (VAR_GLOBAL), hardware bindings (VAR_CONFIG), program organization units (FUNCTION, FUNCTION_BLOCK, PROGRAM, CLASS, INTERFACE), and the actions of a POU (ACTIONS). One file can hold all of them.

There is no import and no module. The compiler reads every file that the command line or the project file names, and everything they declare forms one project. A function in one file calls a function block in another without any declaration between them.

The extension does not group the files, it only selects the reader. .cfc, .fbd, and .xml are read as graphical sources, .o, .so, and .exe go straight to the linker, and everything else is read as Structured Text. The usual extension is .st. A file that only declares interfaces for foreign code is usually named .pli, but that is a convention: the compiler reads it as Structured Text like any other file.

Comments

A comment can go wherever a space can. There are three forms, and the two block forms nest, in themselves and in each other:

(* a comment *)

/* another form,
   (* with a nested comment inside it *)
   that ends here */

x := 1;   // to the end of the line

Nesting is what lets you comment out a piece of code that already holds a comment.

Names

A name starts with a letter or an underscore and continues with letters, digits, and underscores. A name that starts with a digit is not accepted.

Names are not case-sensitive. Motor, motor, and MOTOR are the same name, which also means that two declarations that differ only in case collide:

PROGRAM Main
END_PROGRAM

FUNCTION main: DINT    (* error[E004]: main: Duplicate symbol. *)
END_FUNCTION

A keyword cannot be a name, and because names are not case-sensitive, type is the keyword TYPE. The names of the builtin functions are taken as well: a function of your own called Add collides with the builtin ADD and is rejected.

Names that start with two underscores belong to the compiler. It generates names such as __vtable_Pump and __PI_0_0. It does not reject a name of yours with the same prefix, so a program that declares __level is accepted and only collides later, with a message that points somewhere else. Do not start a name with two underscores.

Where a name is visible

A variable declared in a POU is visible in that POU and in its methods and actions. A variable declared in VAR_GLOBAL is visible in the whole project, without a declaration in the POU that uses it.

When a local name and a global name are the same, the local one wins. A leading dot reaches past it to the global:

VAR_GLOBAL
    shared: DINT := 1;
END_VAR

FUNCTION main: DINT
    VAR
        shared: DINT := 2;
    END_VAR

    main := shared;    (* 2, the local one *)
    main := .shared;   (* 1, the global one *)
END_FUNCTION

There is no block scope. A variable belongs to its POU, not to the IF or the FOR that uses it.

Attributes

An attribute in braces changes how the compiler treats a declaration. Three of them matter for everyday code. {external} stands before a POU and says that the implementation is elsewhere, which is how you call C. {ref} stands after VAR_INPUT, the only block that accepts it, and passes the whole block by reference. {sized} stands before a variadic type and gives the callee a count and an array.

What’s next

The next chapter declares variables and gives them their first values.

Variables

A variable holds a value while the program runs. Where you declare it decides how long the value lives and which code can see it.

A variable is declared inside a variable block, with a name, a type, and an optional initial value. Several names can share one declaration:

VAR
    count: DINT := 1;
    x, y, z: REAL;
    name: STRING[20] := 'motor';
END_VAR

Assignment uses :=, and it works in one direction only. a := b writes the value of b into a, never the value of a into b.

Where a variable lives

VAR inside a POU declares data of that POU. In a function block or a program the data survives the call; in a function it exists for the call only.

VAR_TEMP declares data that exists for one call, also in a function block. Use it for a value that you compute and use inside the body and that must not survive.

VAR_GLOBAL stands outside every POU and declares data of the whole project:

VAR_GLOBAL
    cycleTime: TIME := T#10ms;
END_VAR

A global is visible everywhere, and the POU that uses it declares nothing.

The blocks that declare parameters, VAR_INPUT, VAR_OUTPUT, and VAR_IN_OUT, belong to the POU that they are written in. The chapters on functions and function blocks explain them.

Note

VAR_EXTERNAL is parsed, and the compiler warns that the block has no effect. It is not necessary, because a global is visible without it.

Constants

Write CONSTANT on the block and every variable in it becomes a constant. An assignment to one is rejected:

VAR_GLOBAL CONSTANT
    MAX_SIZE: INT := 99;
    MIN_LEN: INT := 1;
END_VAR

Constants are the way to give a name to a number that appears in declarations, because a constant can be used where the compiler needs a value before the program runs, for example in the bounds of an array.

Retained variables

A control system loses its memory when it loses power. RETAIN marks the variables that must survive that:

PROGRAM Main
    VAR RETAIN
        partsProduced: DINT;
    END_VAR
    VAR NON_RETAIN
        scratch: DINT;
    END_VAR
END_PROGRAM

The compiler puts retained variables into a section of the binary that is called .retain, and leaves the rest to the target. NON_RETAIN states the normal behavior, which is also the default.

Note

RETAIN needs explicit handling in the runtime that manages the project. The compiler only marks the storage. It does not save the section, it does not restore it, and the start-up code writes the declared initial value into the section at every start.

Initial values

An initial value is computed while the program is compiled, so it can use literals, constants, and expressions of them, but nothing that is only known while the program runs:

VAR_GLOBAL CONSTANT
    MIN_LEN: INT := 1;
    MAX_LEN: INT := 100;
    SIZE: INT := MAX_LEN - MIN_LEN;
END_VAR

A variable without an initial value is not undefined. It gets the initial value of its type if the type has one, and zero otherwise: 0 for numbers, FALSE for BOOL, the empty string for text, and the same rule for every element of an array and every member of a struct.

An array takes a list, a struct takes its members by name:

VAR
    values: ARRAY[0..4] OF DINT := [1, 2, 3, 4, 5];
    origin: Point := (x := 0, y := 0);
END_VAR

A list that is shorter than the array fills the rest with the default value of the element type, and the compiler warns so that you notice.

Values that are addresses, such as REF(x), are not constants and cannot be written into the static data of a program. Such a variable starts as a null pointer, and the compiler emits start-up code that sets it before the first call of your code. The pointers chapter explains REF and the types that hold an address.

What’s next

Variables need types. The next chapter introduces the basic types: numbers, bits, and truth values.

Basic Types

Numbers, bits, and truth values. Every other type in the language is built from these.

Integers

Eight integer types, four signed and four unsigned:

TypeSizeRange
SINT8 bit-128 to 127
USINT8 bit0 to 255
INT16 bit-32 768 to 32 767
UINT16 bit0 to 65 535
DINT32 bit-2 147 483 648 to 2 147 483 647
UDINT32 bit0 to 4 294 967 295
LINT64 bit-9 223 372 036 854 775 808 to 9 223 372 036 854 775 807
ULINT64 bit0 to 18 446 744 073 709 551 615

DINT is the type to reach for. It is the type that the compiler gives to a whole-number literal, and only a literal too large for 32 bits becomes a LINT.

A literal can be written in another base, and _ between two digits groups them:

i1: DINT := 42;
i2: DINT := 2#101010;     (* binary *)
i3: DINT := 8#52;         (* octal *)
i4: DINT := 16#2A;        (* hexadecimal *)
i5: DINT := 1_000_000;

Integer division cuts towards zero and never produces a fraction: 7 / 2 is 3 and -7 / 2 is -3. MOD gives the rest of that division, so 7 MOD 2 is 1 and -7 MOD 2 is -1.

BOOL

BOOL holds TRUE or FALSE and takes one byte. It is the type of every condition in IF, WHILE, and UNTIL.

An integer in a condition is accepted and counts as true when it is not zero. The compiler warns and asks you to add an = or a <> operator, so that the test says what it means:

IF level THEN        (* accepted, with a warning *)
IF level <> 0 THEN   (* the same test, and it says so *)

Bit strings

Four types that mean “a sequence of bits”, not “a number”:

TypeSize
BYTE8 bit
WORD16 bit
DWORD32 bit
LWORD64 bit

Use them for flags, masks, and values that come from hardware. AND, OR, XOR, and NOT work on them bit by bit, and the hardware access chapter shows how to read a single bit out of one.

VAR
    flags: BYTE := 2#0000_1100;
    mask: BYTE := 16#0F;
    result: BYTE;
END_VAR

result := flags AND mask;   (* 2#0000_1100 *)

Reals

REAL is 32 bits wide and LREAL is 64. A literal with a decimal point or an exponent is a real:

r1: REAL := 1.5;
r2: LREAL := 1.0e-9;

A REAL keeps about seven decimal digits, which is less than a DINT needs. A large whole number does not survive a trip through a REAL: 123456789 comes back as 123456792.

Conversion between types

The compiler converts a value to a wider type of the same family by itself:

VAR
    small: INT := 300;
    big: DINT;
END_VAR

big := small;   (* fine, every INT fits in a DINT *)

The other direction also compiles, but the value can change, so the compiler warns:

VAR
    small: INT := 300;
    tiny: SINT;
END_VAR

tiny := small;   (* warning[E067]: Implicit downcast from 'INT' to 'SINT'. *)

Here tiny keeps the low eight bits of 300, which is 44. Write the conversion yourself when the narrowing is intended. The standard library has a function for every pair of types, named after them:

tiny := INT_TO_SINT(small);

Note

The conversion functions live in the standard library, so a project that calls one, for example INT_TO_SINT, must link iec61131std. See Linking and Libraries.

A value also crosses between the two families. An integer becomes a real without any warning, although a large DINT loses digits on the way. A real becomes an integer with the same downcast warning, and the fraction is cut off. Two standard functions say which result you want:

VAR
    r: REAL := 2.7;
    n: DINT;
END_VAR

n := REAL_TO_DINT(r);   (* 3, the nearest whole number *)
n := TRUNC_DINT(r);     (* 2, the fraction is cut off *)

A type name with # in front of a value states the type of that value. On a literal it decides how the literal is read, and on a variable it converts:

x := DINT#16#2A;    (* the literal 16#2A, as a DINT *)
y := DINT#small;    (* small, converted to DINT *)

What’s next

The next chapter is about text: the two string types, their length, and what you can do with them.

Text

Two types hold text, and two more hold one character each. STRING stores UTF-8 bytes and its literals stand between single quotation marks. WSTRING stores UTF-16 and its literals stand between double quotation marks.

VAR
    name: STRING := 'motor';
    label: WSTRING := "Motor";
END_VAR

Length

A text variable has a fixed capacity, which you write in brackets. The storage is one element longer, for the terminator that marks the end, so STRING[20] takes 21 bytes and WSTRING[20] takes 21 units of 16 bits. Without a length, the capacity is 80.

The capacity of a STRING counts bytes, not characters. A character outside ASCII takes two bytes or more, so a text that is not plain ASCII needs more capacity than it has characters.

The capacity is the whole story about what fits. An assignment that does not fit is cut off, and nothing reports it:

VAR
    long: STRING[20] := 'abcdefghij';
    short: STRING[5];
END_VAR

short := long;   (* 'abcde' *)

So declare the capacity that the value needs. The compiler cannot warn about a text that grows only while the program runs.

Single characters

CHAR holds one byte and WCHAR one unit of 16 bits. A literal is written like a text literal of the same kind, with one character in it:

VAR
    letter: CHAR := 'a';
    wide: WCHAR := "b";
END_VAR

A character is not a text of length one, and the compiler keeps the two apart. An assignment between CHAR and STRING, or between CHAR and WCHAR, is rejected, and a text cannot be indexed to take a character out of it. A comparison with a plain literal is rejected as well, because that literal is a text; write the type in front of it, as in letter = CHAR#'a'. The standard library converts in both directions: STRING_TO_CHAR gives the first byte of a text, CHAR_TO_STRING makes a text of one character, and WSTRING_TO_WCHAR, WCHAR_TO_WSTRING, CHAR_TO_WCHAR, and WCHAR_TO_CHAR do the same for the other pairs.

A literal with more than one character keeps its first unit only, and the compiler does not warn. For a CHAR that unit is one byte, so a character outside ASCII does not fit: 'ü' gives the first byte of its two-byte code.

Escape sequences

A literal ends at the quotation mark that opened it, and it cannot hold a line break. $ starts an escape that writes such a character. It works in both string kinds, and the letter after it can be upper case or lower case.

SequenceMeaning
$L, $NLine feed
$PForm feed
$RCarriage return
$TTabulator
$$A dollar sign
$'A single quotation mark, which ends a STRING literal
$"A double quotation mark, which ends a WSTRING literal
$XXThe character with that hexadecimal code, four digits in a WSTRING
message: STRING := 'Line 1$NLine 2';
price: STRING := 'costs $$5';

In a STRING the code of $XX has two digits and must be an ASCII code, because a STRING holds UTF-8. Write a character outside ASCII into the literal itself, as 'Grüße'.

Comparison

The comparison operators work on text and compare it character by character:

IF name = 'motor' THEN

< and > order two texts by the first character that differs, and a text that is the start of a longer one comes first. The order is the order of the character codes, so 'Z' comes before 'a'.

Note

Comparison of text calls the standard library, so a project that compares text must link iec61131std. See Linking and Libraries.

Working with text

The same library holds the text operations of the standard. These are the ones you reach for first:

FunctionResult
LEN(in)The number of characters
CONCAT(in1, in2, ...)The texts joined
LEFT(in, l), RIGHT(in, l)The first or last l characters
MID(in, l, p)l characters, starting at position p
FIND(in1, in2)The position of in2 inside in1, or 0
INSERT(in1, in2, p)in1 with in2 put in after position p
DELETE(in, l, p)in with l characters removed from position p
REPLACE(in1, in2, l, p)in1 with l characters from position p exchanged for in2

Positions count from 1. Note the order of the arguments of MID, DELETE and REPLACE: the length comes before the position.

VAR
    source: STRING[20] := 'abcdefghij';
    part: STRING[20];
END_VAR

part := MID(source, 3, 2);   (* 'bcd' *)

The standard library reference lists the rest of the family, and the functions that convert between the text types.

What’s next

The next chapter covers the types for time and date, which every control program needs.

Time and Date

Four things can be measured: how long something takes, which day it is, which moment of a day it is, and which exact point in time it is. The language has a type for each, in two families.

The short family is 32 bits wide, the long family is 64 bits and starts with L:

ShortLongHoldsWhat the short type counts
TIMELTIMEA durationmilliseconds
DATELDATEA dayseconds since 1970-01-01 UTC
TIME_OF_DAYLTIME_OF_DAYA moment of a daymilliseconds since midnight
DATE_AND_TIMELDATE_AND_TIMEA point in timeseconds since 1970-01-01 UTC

Each long type counts the same thing as the short type next to it, but in nanoseconds. Each type also has a short name: T, LT, D, LD, TOD, LTOD, DT, LDT.

A part of a value that is finer than what the type counts is lost. T#500us is a TIME of zero, and DT#1999-12-31-23:59:59.999 is the same DATE_AND_TIME as DT#1999-12-31-23:59:59. The long types count in nanoseconds and keep both.

The short types are unsigned. DATE and DATE_AND_TIME reach from 1970-01-01 to 2106-02-07, and TIME reaches T#49d17h2m47s295ms, about 49 days. A literal outside that range compiles with a warning and wraps around, so D#1969-12-31 is a day in February 2106 and T#49d17h2m47s296ms is zero. The long types are signed and reach about 292 years to either side of 1970, from LD#1677-09-22 to LD#2262-04-11. A long literal outside that range is rejected.

Literals

A literal starts with the name of its type and #:

VAR
    cycle: TIME := T#10ms;
    startup: TIME := TIME#2d4h6m8s10ms;
    day: DATE := D#2024-05-02;
    moment: TIME_OF_DAY := TOD#23:59:59.999;
    stamp: DATE_AND_TIME := DT#1999-12-31-23:59:59.999;
END_VAR

A duration is a sequence of segments, in the order d, h, m, s, ms, us, ns. You leave out the ones you do not need, and a segment can have a fraction:

T#2d4h          (* two days and four hours *)
T#2d4.2h        (* a segment may be fractional *)
T#90s           (* a segment may exceed its usual range *)

The order is not optional. A literal that changes it, such as T#4h2d, is rejected.

In a moment of a day or a point in time, only the seconds can have a fraction. A date has no fraction at all.

Calculating

Durations add and subtract, and they compare:

VAR
    a: TIME := T#1s;
    b: TIME := T#500ms;
    total: TIME;
END_VAR

total := a + b;        (* 1500 ms *)
IF a > b THEN          (* TRUE *)

A duration also multiplies and divides by a number, which is how you scale a cycle time.

A negative duration does not fit into the unsigned TIME. A literal such as T#-10s still compiles, but the compiler warns about an underflow and the value wraps around to a large positive duration. Use LTIME when a duration must be able to go below zero: LT#-10s is a negative LTIME and gets no warning.

To read a time value as a number, convert it with a cast. DINT#cycle gives the milliseconds of a TIME, because that is what the type holds.

The standard library converts between the families and to text, for example TIME_TO_LTIME and TIME_TO_STRING, and it brings the timers TON, TOF, and TP, which are function blocks. The standard library reference lists them.

What’s next

The next chapter builds bigger types out of the ones so far: arrays, structs, and enumerations.

Arrays, Structs and Enumerations

A TYPE block declares a type of your own. Everything in this chapter lives in such a block, or directly in the declaration of a variable.

TYPE Point:
    STRUCT
        x, y: DINT;
    END_STRUCT
END_TYPE

Arrays

An array holds a fixed number of elements of one type. You write the range of the index, not the count:

VAR
    samples: ARRAY[1..3] OF DINT := [10, 20, 30];
    grid: ARRAY[0..1, 0..2] OF DINT := [1, 2, 3, 4, 5, 6];
END_VAR

samples[2] := 25;
grid[1, 0] := 7;

The range can start anywhere, so ARRAY[1..3] and ARRAY[0..2] both hold three elements. A comma adds a dimension. The initial values of such an array stay in one flat list, in which the last index changes fastest.

The bounds must be known while the program is compiled, so they are literals or constants:

VAR CONSTANT
    COUNT: INT := 16;
END_VAR

VAR
    buffer: ARRAY[0..COUNT - 1] OF BYTE;
END_VAR

An index that is a constant outside the range is rejected. An index that is computed while the program runs is not checked, by the compiler or at run time. The compiler emits the address calculation with no test, so a wrong index reads or writes memory outside the array. Check the index yourself where the value comes from outside.

A function can take an array of any size. That form, ARRAY[*], is explained with the other parameter rules.

Structs

An array holds many values of one type. A struct groups a few values of different types, and every member has a name. The members are read and written through a dot:

TYPE Motor:
    STRUCT
        speed: INT;
        running: BOOL;
        name: STRING[20];
    END_STRUCT
END_TYPE

PROGRAM Plant
    VAR
        pump: Motor := (speed := 100, running := FALSE, name := 'pump');
    END_VAR

    pump.speed := 120;
END_PROGRAM

Members lie in memory in the order of their declaration. A struct can hold another struct, an array, or an instance of a function block, and the dot chains: plant.pump.speed.

Assigning one struct to another copies every member.

Enumerations

An array and a struct collect values. An enumeration instead lists the values that one variable may take. Each name stands for a number, counting from zero, and a name can set its own value, after which counting continues from there:

TYPE State: (Idle, Running := 5, Stopped);   (* 0, 5, 6 *)
END_TYPE

PROGRAM Machine
    VAR
        current: State := Idle;
    END_VAR

    IF current = Running THEN
        (* ... *)
    END_IF
END_PROGRAM

The names are visible without the type in front of them. A name that two enumerations declare resolves to the enumeration that was declared first, and the compiler does not warn you. Give every variant a name of its own, and write the type in front of a name where you want to be explicit:

current := State#Stopped;

An enumeration is an integer underneath, so it fits everywhere an integer fits, and CASE works on it.

Subranges

An enumeration limits a variable to a list of names. A subrange limits an integer to a range of numbers:

TYPE Percent: INT (0..100);
END_TYPE

The compiler does not enforce the range by itself. It enforces it when the project provides a check function, and then every assignment to such a variable goes through that function, which decides what happens:

FUNCTION CheckRangeSigned: DINT
    VAR_INPUT
        value: DINT;
        lower: DINT;
        upper: DINT;
    END_VAR

    IF value < lower THEN
        CheckRangeSigned := lower;
    ELSIF value > upper THEN
        CheckRangeSigned := upper;
    ELSE
        CheckRangeSigned := value;
    END_IF
END_FUNCTION

With that function in the project, an assignment of 200 to a Percent stores 100. CheckRangeUnsigned does the same for the unsigned types. Without such a function, a subrange behaves like the type it is based on.

Aliases

A subrange adds a range to an existing type. A type declaration that adds nothing makes an alias, and the alias can carry an initial value:

TYPE Signal: INT := -1;
END_TYPE

PROGRAM Reader
    VAR
        reading: Signal;   (* an INT that starts at -1 *)
    END_VAR
END_PROGRAM

Use an alias to give a meaning to a plain type, for example TYPE Celsius: INT; END_TYPE.

What’s next

You now have the types. The next chapter combines their values into expressions.

Expressions and Operators

An expression computes a value from literals, variables, and calls.

Arithmetic

+, -, *, /, MOD, and ** work on numbers. Division of integers cuts towards zero, and MOD gives the rest of that division; the basic types chapter gives the rule for negative operands.

** is the power operator. It calls the standard library, so a project that uses it must link iec61131std (see Linking and Libraries), and it computes in floating point even when both sides are integers. The result is a REAL, so an integer target needs a conversion, and a result that a REAL cannot hold exactly loses digits:

VAR
    r: REAL;
    n: DINT;
END_VAR

r := 2 ** 10;                 (* 1024.0 *)
n := REAL_TO_DINT(2 ** 10);   (* 1024 *)
n := REAL_TO_DINT(7 ** 11);   (* 1977326720, not 1977326743 *)

Comparison

=, <>, <, >, <=, and >= compare two values of the same kind and produce a BOOL. They work on numbers, on enumerations, and on text, where they compare character by character. A comparison of two values that do not belong together, such as a text and a number, is rejected.

Note that the test for equality is a single =, because := is the assignment.

Boolean and bit operators

AND, OR, XOR, and NOT do two jobs. On BOOL values they are the logical operators; on the bit string types they work bit by bit. & is another spelling of AND.

ready := motorOn AND NOT alarm;
masked := flags AND 16#0F;

AND and OR always evaluate both sides, also when the left side already decides the result. When the right side is a call that you want to avoid, use the short-circuit forms:

IF valid AND_THEN check() THEN     (* check() runs only when valid is TRUE *)
IF failed OR_ELSE check() THEN     (* check() runs only when failed is FALSE *)

Precedence

An expression that mixes operators from more than one of these groups needs a rule for which operator binds first. Operators bind in this order, the strongest first. Operators of the same level group from left to right, so 10 - 3 - 2 is 5.

LevelOperators
1(...), a call, ^, .%X and the other direct accesses, TYPE#value
2NOT, unary -, unary +
3**
4*, /, MOD
5+, -
6<, >, <=, >=
7=, <>
8AND, AND_THEN, &
9XOR
10OR, OR_ELSE

So a + b * c multiplies first, and x < 1 AND y > 2 compares first. A sign is part of the base of a power, so -2 ** 2 is 4. Parentheses override every rule and are worth writing wherever a reader would have to think.

Mixed types

Precedence says how an expression is grouped. The types of its operands say in which type it is computed, and that type does not come from the target of the assignment. Every integer narrower than DINT is widened to DINT first, and a pair of different types is computed in the wider of the two. The result then moves to the target: a move to a wider type is silent, and a move to a narrower one compiles with a warning, because the value can change.

VAR
    i: INT := 300;
    j: INT := 200;
    d: DINT;
    k: INT;
    s: SINT;
END_VAR

d := i * j;   (* 60000, because the product is computed in 32 bits *)
k := i * j;   (* -5536, the same product cut down to the 16 bits of k *)
s := i;       (* warning[E067]: Implicit downcast from 'INT' to 'SINT'. *)

An integer and a real mix the same way, and the expression is then computed in the real type. The basic types chapter shows what each conversion costs.

Calls in expressions

A function call is an expression, so it can appear anywhere a value can:

level := MIN(measured, maximum) + offset;

MIN comes from the standard library, so this line needs iec61131std as well.

A call of a function block instance is a statement, not an expression: it runs the instance, and you read the results from the instance afterwards. The function blocks chapter shows this.

What’s next

Expressions compute values. The next chapter decides which statements run, with control flow.

Control Flow

Five statements decide what runs: two that choose, and three that repeat. Three more end a pass, a loop, or a POU early.

IF

IF tests a condition and runs the block of the first test that is true:

IF level > high THEN
    valve := Closed;
ELSIF level < low THEN
    valve := Open;
ELSE
    valve := Hold;
END_IF

ELSIF and ELSE are optional, and ELSIF can repeat. The condition is a BOOL; see basic types for what happens when you write a number.

CASE

CASE chooses on one value. A branch can name one value, a list, or a range, and ELSE catches the rest:

CASE step OF
    0:        motor := 0;
    1, 2:     motor := 50;
    3..6:     motor := 100;
ELSE
    motor := 0;
END_CASE

The value can be an integer or an enumeration, which is what makes CASE the natural shape for a state machine:

TYPE State: (Idle, Running, Stopped);
END_TYPE

PROGRAM Machine
    VAR
        current: State;
        start, stop: BOOL;
    END_VAR

    CASE current OF
        Idle:    IF start THEN current := Running; END_IF
        Running: IF stop  THEN current := Stopped; END_IF
        Stopped: current := Idle;
    END_CASE
END_PROGRAM

Only the matching branch runs. There is no fall-through between branches.

FOR

IF and CASE choose once. The next three statements repeat. FOR counts a variable from one value to another, and BY sets the step, which can be negative:

FOR i := 1 TO 10 DO
    total := total + samples[i];
END_FOR

FOR i := 10 TO 1 BY -3 DO   (* 10, 7, 4, 1: four passes *)
    total := total + i;
END_FOR

The end value is included. The counter is an ordinary variable of the POU, and after the loop it keeps the value that ended it: after FOR i := 1 TO 3, i is 4, and after FOR i := 10 TO 1 BY -3, i is -2. When the body never runs, the counter keeps the start value.

WHILE and REPEAT

FOR needs the number of passes in advance. WHILE and REPEAT test a condition instead. WHILE tests before the body, so the body can run zero times. REPEAT tests after it, so the body always runs at least once. UNTIL states when to stop, and its condition takes no semicolon:

WHILE remaining > 0 DO
    remaining := remaining - 1;
END_WHILE

REPEAT
    attempts := attempts + 1;
UNTIL attempts >= 3
END_REPEAT

A loop whose condition never becomes false never ends. There is no watchdog in the language.

EXIT, CONTINUE and RETURN

EXIT leaves the loop, CONTINUE starts the next pass, and both act on the innermost loop only:

FOR i := 1 TO 10 DO
    IF samples[i] = 0 THEN
        CONTINUE;
    END_IF
    IF samples[i] > limit THEN
        EXIT;
    END_IF
    total := total + samples[i];
END_FOR

RETURN leaves the POU at once. In a function, assign the result before you return:

FUNCTION Divide: DINT
    VAR_INPUT
        a, b: DINT;
    END_VAR

    IF b = 0 THEN
        Divide := 0;
        RETURN;
    END_IF

    Divide := a / b;
END_FUNCTION

What’s next

The next chapter writes code that others can call: functions.

Functions

A function computes a result from its arguments and forgets everything else. Two calls with the same arguments give the same answer, as long as the function does not read a global variable.

FUNCTION Scale: DINT
    VAR_INPUT
        value: DINT;
        factor: DINT;
    END_VAR

    Scale := value * factor;
END_FUNCTION

The type after the colon is the result type. Inside the body, the name of the function is the result: you assign to it, and the last value you assigned is what the caller gets. A function without a result type returns nothing, and a call of it is a statement.

Nothing survives a call

Every local variable of a function is temporary, also in a VAR block:

FUNCTION Counter: DINT
    VAR
        n: DINT;
    END_VAR

    n := n + 1;
    Counter := n;
END_FUNCTION

Both calls of Counter() return 1. When you need the value of the previous call, use a function block.

A function may call itself. The compiler sets no limit on the depth, but the stack of the target does.

Parameters

Four blocks describe what goes in and what comes out:

FUNCTION Measure: DINT
    VAR_INPUT
        raw: INT;            (* a copy for the function *)
    END_VAR
    VAR_INPUT {ref}
        label: STRING;       (* the caller's text, not a copy *)
    END_VAR
    VAR_IN_OUT
        total: DINT;         (* read and written in the caller *)
    END_VAR
    VAR_OUTPUT
        valid: BOOL;         (* a second result *)
    END_VAR

    (* ... *)
END_FUNCTION

VAR_INPUT gives the function a copy, so a change inside the function stays inside it. This holds for large values as well: a string, an array, or a struct in VAR_INPUT is copied, and the caller does not see a write to it.

VAR_INPUT {ref} marks the whole block as “do not copy”. The function then works on the value of the caller, which is what a foreign function usually expects, and what you want for a large value that the function only reads.

VAR_IN_OUT is for data that the function reads and writes. It is always the caller’s variable.

VAR_OUTPUT carries a second result out of the call.

Calling

A call supplies every parameter, including the outputs. Either by position, in the order of the declaration, or by name:

r := Measure(raw, 'sensor', total, valid);
r := Measure(raw := raw, label := 'sensor', total := total, valid => valid);

:= gives a value to an input or to an in-out, => takes a value out of an output. A missing argument is an error, in both forms:

error[E032]: this POU takes 4 arguments but 2 arguments were supplied

An input with a default value (factor: DINT := 2;) is the exception: you can leave it out and get the default. This applies to the inputs at the end of the list only. When a parameter without a default comes after it, you must supply both. A call of a function block instance may leave out any parameter, because the instance keeps what the previous call gave it.

Arrays of any size

A function that works on an array of any length declares the parameter with * instead of a range, one * per dimension. LOWER_BOUND and UPPER_BOUND then give the bounds of the array that the caller passed:

FUNCTION Sum: DINT
    VAR_INPUT
        values: ARRAY[*] OF DINT;
    END_VAR
    VAR
        i: DINT;
    END_VAR

    FOR i := LOWER_BOUND(values, 1) TO UPPER_BOUND(values, 1) DO
        Sum := Sum + values[i];
    END_FOR
END_FUNCTION

The second argument of the two functions is the number of the dimension. They accept a parameter of this kind only, not an array of a fixed size. Such a parameter is always passed by reference, also in a VAR_INPUT block, and the compiler says so:

warning[E047]: Variable Length Arrays are always by-ref, even when declared in a by-value block

Results that are not a number

A result type can be a string, an array, or a struct:

FUNCTION Describe: STRING[20]
    VAR_INPUT
        code: DINT;
    END_VAR

    Describe := 'code ';
END_FUNCTION

The caller provides the memory for such a result. The compiler makes the result a hidden first parameter, a pointer to a variable of the caller, and the function writes the result through it.

What’s next

Functions forget. The next chapter is about the POUs that remember: function blocks and programs.

Function Blocks and Programs

A function block is a type with memory. You declare it once, and every variable of that type is an instance with its own data, which survives between calls. That is what a controller needs: a filter keeps its last value, a counter keeps its count, a timer keeps its start.

FUNCTION_BLOCK Counter
    VAR_INPUT
        step: DINT := 1;
    END_VAR
    VAR_OUTPUT
        total: DINT;
    END_VAR
    VAR
        calls: DINT;
    END_VAR

    calls := calls + 1;
    total := total + step;
END_FUNCTION_BLOCK

Instances

An instance is declared like any other variable, and it is called by its own name:

VAR
    fast: Counter;
    slow: Counter;
    value: DINT;
END_VAR

fast(step := 2);
fast(step := 2);
slow(step := 1);

fast.total is now 4 and slow.total is 1. The two instances share the code and nothing else.

A call passes as many inputs as you want to change. A parameter that the call does not name keeps the value from the declaration, or the value that the last call left in the instance. This is the opposite of a function, where every call supplies every parameter.

Read a result after the call, or take it out as part of the call with =>:

fast(step := 2);
value := fast.total;

fast(step := 2, total => value);

An instance can live anywhere a variable can: in a POU, in a struct, in an array, or in VAR_GLOBAL. A function block that holds another function block builds a larger unit out of smaller ones:

FUNCTION_BLOCK Axis
    VAR
        position: Counter;
        speed: Counter;
    END_VAR

    position(step := 5);
    speed(step := 1);
END_FUNCTION_BLOCK

What the outside may touch

The variable blocks decide what a caller can do with an instance. A caller reads and writes an input, reads an output, and leaves a plain VAR alone:

fast.step := 3;        (* fine, an input *)
value := fast.total;   (* fine, an output *)
fast.total := 0;       (* error[E037]: VAR_OUTPUT variables cannot be assigned outside of their scope. *)
fast.calls := 0;       (* warning[E049]: Illegal access to private member Counter.calls *)

The write to an output stops the build; the access to a VAR is a warning only. The compiler reports that warning for every VAR, whatever access modifier the block gives it.

So the inputs and the outputs are the interface of the block, and a VAR is its own business. When something else must reach such a value, give the block a method or a property.

Programs

A program is a function block whose instance the compiler creates itself. There is one instance, it is global, and it keeps its data between calls like every other instance:

PROGRAM Plant
    VAR
        cycle: DINT;
        pumps: ARRAY[1..4] OF Counter;
    END_VAR

    cycle := cycle + 1;
END_PROGRAM

You call it by its own name, Plant(), because the instance has no name of its own. Use a program for the top level of an application, and a function block for everything that exists more than once.

Actions

An action is a named piece of body that belongs to a POU and works on its data. It declares nothing of its own, which makes it a way to split a long body into parts that you can call separately:

FUNCTION_BLOCK Valve
    VAR
        state: BOOL;
    END_VAR
END_FUNCTION_BLOCK

ACTION Valve.Open
    state := TRUE;
END_ACTION

ACTION Valve.Close
    state := FALSE;
END_ACTION

Call an action through the instance: inlet.Open(). Two other spellings exist. An ACTIONS container directly after the POU takes the name of that POU, and an ACTIONS <name> container names its POU and stands anywhere in the project:

ACTIONS Valve
    ACTION Toggle
        state := NOT state;
    END_ACTION
END_ACTIONS

Initialization

The initial values in the declaration apply to every instance. When an instance needs more than that, declare the method FB_INIT:

FUNCTION_BLOCK Buffer
    VAR
        size: DINT;
        ready: BOOL;
    END_VAR

    METHOD FB_INIT
        size := 128;
        ready := TRUE;
    END_METHOD
END_FUNCTION_BLOCK

The compiler calls FB_INIT once for each instance, when that instance is created. For a global instance and for a member of a program, that is before the program starts. Declare the method without parameters and without a return type, because the compiler calls it with no arguments.

What’s next

A function block can do more than run a body. The next chapter gives it methods and properties.

Methods and Properties

A function block can do more than run one body. A method adds an operation, a property adds a value that looks like a variable but runs code.

Methods

A method is declared inside the function block, it has its own parameters and its own result, and it works on the data of the instance:

FUNCTION_BLOCK Tank
    VAR
        level: DINT;
    END_VAR

    METHOD Fill: DINT
        VAR_INPUT
            amount: DINT;
        END_VAR

        level := level + amount;
        Fill := level;
    END_METHOD
END_FUNCTION_BLOCK

Call it through an instance:

VAR
    inlet: Tank;
    value: DINT;
END_VAR

value := inlet.Fill(amount := 3);

value is now 3, the new level. A method is called like a function: the arguments come by position, in the order of the declaration, or by name. Give every parameter a value, because a parameter that the call leaves out takes the default value of its declaration, and a parameter without a default holds no defined value. The body of the block and its methods share the data of the instance, but a local variable of a method does not survive the call.

Inside a method, THIS^ names the instance itself. You need it when a parameter and a member have the same name, because the parameter hides the member:

METHOD SetLevel
    VAR_INPUT
        level: DINT;
    END_VAR

    THIS^.level := level;
END_METHOD

THIS works in a function block, in its methods, and in its actions. A CLASS does not have it, and a use of THIS there is rejected.

Properties

Where a method is called, a property is read and written. It is a value with code behind it, and it has a getter, a setter, or both. Inside an accessor, the name of the property is the value, exactly as the name of a function is its result:

FUNCTION_BLOCK Tank
    VAR
        level: DINT;
    END_VAR

    PROPERTY_GET Percent: DINT
        Percent := level * 10;
    END_PROPERTY

    PROPERTY_SET Percent: DINT
        level := Percent / 10;
    END_PROPERTY
END_FUNCTION_BLOCK

From outside, the property is used like a member, and the accessor runs:

VAR
    inlet: Tank;
    reading: DINT;
END_VAR

inlet.Percent := 50;      (* runs the setter, level becomes 5 *)
reading := inlet.Percent; (* runs the getter, 50 *)

When a property has both accessors, both must name the same type. A property with a getter only is read-only, and a write to it is reported:

error[E048]: PROPERTY_SET for property `Percent` is not defined

A property with a setter only is write-only, and a read of it is reported the same way.

A CLASS declares a property like a function block does. An INTERFACE declares one without bodies, and every block that implements the interface must supply the accessors that the interface names.

Method or property

Use a property when the caller thinks of the thing as a value: a level, a limit, a state. Use a method when the caller thinks of it as an action. An accessor takes no parameters, so an operation that needs arguments is always a method. Keep the work behind a property small, because the caller reads it as a plain variable.

What’s next

The next chapter lets one function block build on another, with inheritance and interfaces.

Inheritance and Interfaces

Two mechanisms let one piece of code work with many types: a function block can build on another one, and it can promise to provide a set of methods.

Extending a function block

EXTENDS takes everything from the base block, its members and its methods, and adds to it:

FUNCTION_BLOCK Sensor
    VAR
        raw: DINT;
    END_VAR

    METHOD Read: DINT
        Read := raw;
    END_METHOD
END_FUNCTION_BLOCK

FUNCTION_BLOCK ScaledSensor EXTENDS Sensor
    METHOD OVERRIDE Read: DINT
        Read := SUPER^.Read() * 10;
    END_METHOD
END_FUNCTION_BLOCK

OVERRIDE replaces a method of the base. SUPER^ is the same instance seen as the base type, so SUPER^.Read() runs the method that was replaced. Without SUPER^, a call to Read() inside ScaledSensor reaches the new one.

The members of the base are members of the derived block, but the body is not. A call to the derived block runs its own body only. Write SUPER^(); in that body to run the body of the base as well.

Dispatch

A call through a variable of the base type runs the method of the actual instance, not the one of the declared type:

VAR
    scaled: ScaledSensor;
    any: REF_TO Sensor;
    value: DINT;
END_VAR

any := ADR(scaled);
value := any^.Read();   (* the method of ScaledSensor *)

Take the address with ADR. REF gives a pointer to the exact type of its argument, so any := REF(scaled) warns that REF_TO Sensor and ScaledSensor are different types, although the call works.

This is what makes a list of different sensors possible: they all have Read, and each one brings its own.

Interfaces

An interface is a set of method declarations without bodies. A function block that names it in IMPLEMENTS must provide those methods, and a missing one is rejected:

INTERFACE ISensor
    METHOD Read: DINT
    END_METHOD
END_INTERFACE

FUNCTION_BLOCK Analog IMPLEMENTS ISensor
    VAR
        raw: DINT;
    END_VAR

    METHOD Read: DINT
        Read := raw;
    END_METHOD
END_FUNCTION_BLOCK

A variable of the interface type holds any instance that implements it, and a call through it reaches the instance:

VAR
    analog: Analog;
    sensor: ISensor;
    value: DINT;
END_VAR

sensor := analog;
value := sensor.Read();

A parameter of an interface type is the usual way to write code that works with every implementation:

FUNCTION Report: DINT
    VAR_INPUT
        device: ISensor;
    END_VAR

    Report := device.Read();
END_FUNCTION

Use an interface when the implementations have nothing in common but their operations, and EXTENDS when they share data or behavior.

Classes

Neither mechanism is limited to the function block. A CLASS is a function block without a body. It holds members and methods, and you reach it through its methods:

CLASS Formatter
    VAR
        width: DINT;
    END_VAR

    METHOD Pad: DINT
        Pad := width;
    END_METHOD
END_CLASS

A statement outside a method is rejected, because a class cannot have an implementation, and THIS is not available in a class either. Everything else, EXTENDS, IMPLEMENTS, methods, and properties, works as in a function block. Use a class for a type that has no cyclic behavior of its own.

Access modifiers

A block that others extend often wants to say which of its declarations they may use. A member or a method can carry PUBLIC, PRIVATE, PROTECTED, or INTERNAL for that:

FUNCTION_BLOCK Box
    VAR PROTECTED
        state: DINT;
    END_VAR

    METHOD PUBLIC Open: DINT
        Open := state;
    END_METHOD
END_FUNCTION_BLOCK

Warning

The compiler parses the four modifiers and then ignores them. They do not change what a caller may touch.

Every VAR member counts as private to the block that declares it, whatever the modifier says. A read of state from outside, and also from a block that extends Box, gets the warning Illegal access to private member Box.state. A modifier on a method changes nothing at all, so a PRIVATE method can be called from anywhere. The variable block decides what the outside may touch, not the modifier.

A POU and a method can also carry ABSTRACT or FINAL, and these two are parsed and ignored in the same way. A block declared FINAL can still be extended, a method declared FINAL can still be replaced, and a variable can take the type of a block declared ABSTRACT. A method declared ABSTRACT has no body, and a call to it returns the default value of its result type.

What’s next

One more mechanism writes code that works for many types, and it does so without instances: generic functions.

Generic Functions

A function can declare type parameters. A type parameter stands for the type that the call uses, and it has a constraint that says which types are allowed.

FUNCTION MAX <T: ANY_ELEMENTARY>: T
    VAR_INPUT
        in1: T;
        in2: T;
    END_VAR
END_FUNCTION

A type parameter can be the type of an input, of an output, and of the return value.

How a call is resolved

A generic function is never called. Every call resolves to a concrete version, and the name of that version is the name of the function and then, for each type parameter, two underscores and the resolved type:

x := MAX(aDint, bDint);   (* calls MAX__DINT *)

The compiler does not write the body of that version. Either a FUNCTION with the resolved name provides it, written in Structured Text, or an object file or library that the linker finds does, for example one written in C.

FUNCTION MAX__DINT: DINT
    VAR_INPUT
        in1: DINT;
        in2: DINT;
    END_VAR

    IF in1 > in2 THEN
        MAX__DINT := in1;
    ELSE
        MAX__DINT := in2;
    END_IF
END_FUNCTION

When nothing in the project defines the name, the compiler writes an {external} declaration for it and leaves the symbol to the linker. If the linker finds nothing either, the build fails with an undefined symbol.

The generic functions that the compiler knows itself work differently. A call to ABS, ADD, or SEL gets no version of its own, because the compiler writes the code at the place of the call.

Constraints

The constraint is a type nature, for example ANY_INT, ANY_REAL, ANY_ELEMENTARY, or ANY. An argument whose type does not have that nature is rejected:

FUNCTION Inc <T: ANY_INT>: T
    VAR_INPUT
        v: T;
    END_VAR
END_FUNCTION

FUNCTION main: DINT
    VAR
        value: REAL := 1.0;
    END_VAR

    (* error[E062]: Invalid type nature for generic argument. REAL is no ANY_INT *)
    main := Inc(value);
END_FUNCTION

An integer argument for an ANY_REAL constraint is the one exception. The compiler converts it to a floating-point type and resolves the call with that type, so a <T: ANY_REAL> function called with a DINT resolves to its REAL version.

ANY accepts every type, so a call with a type that has no implementation passes the compiler and fails at the link step:

TYPE Point:
    STRUCT
        x, y: DINT;
    END_STRUCT
END_TYPE

FUNCTION main: DINT
    VAR
        s: STRING;
        p: Point;
    END_VAR

    s := TO_STRING(p);   (* undefined symbol TO_STRING__Point at link time *)
END_FUNCTION

The standard library uses this for its conversions. TO_STRING declares <T: ANY> and the library provides an implementation for every type that it supports, such as TO_STRING__DINT and TO_STRING__REAL.

The natures that a constraint can name are ANY, ANY_DERIVED, ANY_ELEMENTARY, ANY_MAGNITUDE, ANY_NUM, ANY_REAL, ANY_INT, ANY_SIGNED, ANY_UNSIGNED, ANY_DURATION, ANY_BIT, ANY_CHARS, ANY_STRING, ANY_CHAR, and ANY_DATE. Any other name is rejected. They form a tree with ANY at the root: ANY_SIGNED is part of ANY_INT, ANY_INT is part of ANY_NUM, ANY_NUM is part of ANY_MAGNITUDE, and ANY_MAGNITUDE is part of ANY_ELEMENTARY. A constraint accepts every type below it, so ANY_INT takes a DINT and also a UDINT.

What’s next

The next chapter is about working with addresses: pointers and references.

Pointers and References

A pointer holds the address of a variable instead of a value. With one you reach the same data from two places, walk through an array element by element, and talk to code that was not written in Structured Text.

Declaring and dereferencing

REF_TO and POINTER TO declare the same type, and a value of the one is accepted for the other. They differ in what the compiler checks: it warns about an assignment that would make a REF_TO point at another type, and says nothing for a POINTER TO.

VAR
    value: DINT := 10;
    p: REF_TO DINT;
    q: POINTER TO DINT;
END_VAR

p := REF(value);
q := ADR(value);

REF and ADR both give the address of a variable. REF keeps the type of that variable, which is what the check above reads. ADR gives the address alone, so an assignment of it is never reported.

^ reads and writes through the pointer:

p^ := p^ + 1;   (* value is now 11 *)

A pointer that holds no address is NULL, and a pointer starts as NULL until something gives it an address. Nothing checks a dereference. A program that reads through a NULL pointer compiles without a diagnostic and reads address zero: it stops with a segmentation fault, or gives a value that has no meaning.

A declaration can give the address at once, which keeps the pointer out of that state:

VAR
    x: DINT := 7;
    p: REF_TO DINT := REF(x);
END_VAR

An address is not a constant, so the compiler writes it in code that runs before the body.

Adding a whole number to a pointer moves it by that many elements, not by that many bytes, which is how you walk an array:

VAR
    values: ARRAY[0..3] OF DINT := [10, 20, 30, 40];
    p: REF_TO DINT;
    first: DINT;
    second: DINT;
END_VAR

p := REF(values[0]);
first  := p^;         (* 10 *)
second := (p + 1)^;   (* 20 *)

References

A reference is a pointer that you do not dereference. It is declared with REFERENCE TO, it is bound with REF=, and afterwards it is used exactly like the variable it points to:

VAR
    target: DINT := 5;
    alias: REFERENCE TO DINT;
END_VAR

alias REF= target;
alias := alias + 1;   (* target is now 6 *)

REF= also works in the declaration, as REF does for a pointer: alias: REFERENCE TO DINT REF= target;. AT binds a name to another variable in the same way, and the hardware access chapter shows it.

Use a reference where the code reads better without ^, and a pointer where the address itself is the subject.

Passing data without copying

Both are needed less often than you may expect, because the parameter blocks already say how data travels: VAR_IN_OUT passes the caller’s variable, and VAR_INPUT {ref} passes a large value without a copy. Reach for a pointer when neither fits, for example in a structure that refers to another structure, or at the border to C.

TYPE Node:
    STRUCT
        value: DINT;
        next: REF_TO Node;
    END_STRUCT
END_TYPE

The C interface chapter shows what a pointer looks like on the other side.

What’s next

The next chapter reaches the process image and single bits of a value: hardware access.

Direct and Hardware Access

Two things use the % sign: reading a part of a value, and binding a variable to a hardware address.

Direct access on a value

%<size><position> after a value reads a part of it. The standard allows this for bit string types, and this compiler allows it for every integer type as well.

LetterReadsExample
X1 bit%X1
B8 bit%B1
W16 bit%W1
D32 bit%D1

The position counts parts of that size, from 0, and it must fit the value. An LWORD has the bits 0 to 63 and the words 0 to 3; a position above that is rejected. For a bit, the %X can be left out.

FUNCTION main: DINT
    VAR
        variable: LWORD;
        bitTarget: BOOL;
        byteTarget: BYTE;
        wordTarget: WORD;
        dwordTarget: DWORD;
    END_VAR

    variable    := 16#AB_CD_EF_12_34_56_78_90;
    bitTarget   := variable.%X63;   (* the last bit *)
    byteTarget  := variable.%B7;    (* the last byte, 16#AB *)
    wordTarget  := variable.%W3;    (* the last word, 16#ABCD *)
    dwordTarget := variable.%D1;    (* the last double word, 16#ABCDEF12 *)

    bitTarget   := variable.%D1.%W1.%B1.%X1;   (* accesses can be chained *)
END_FUNCTION

The chained access reads 16#ABCDEF12, then 16#ABCD, then 16#AB, then bit 1 of it, which is TRUE.

The position can also be a variable. The standard allows a literal only; this is an extension.

access_var := 63;
bitTarget  := variable.%Xaccess_var;

A variable position needs the %X, because the short form without it is not allowed there. The variable must be a plain name, not a qualified one.

Hardware addresses

The second use of % is an address. AT %<area><size><position> binds a variable to a fixed address.

AreaMeaning
IInput
QOutput
MMemory
GGlobal

The size letter is the same as above, with L for 64 bit in addition. The position has one or more parts, separated by .:

VAR_GLOBAL
    inBit   AT %IX1.0: BOOL;
    outWord AT %QW2.5: WORD;
    memory  AT %MD3: DWORD;
END_VAR

AT also binds a variable to another variable, which makes it an alias:

VAR
    alias AT shared: STRING;
END_VAR

alias gets no storage of its own. It points at shared, so a write to alias writes to shared. It behaves like a reference that is bound at its declaration.

Addresses for instances

A function block that is instantiated more than once cannot name a fixed address in its declaration, because every instance needs its own. The declaration writes a template with * instead, and a VAR_CONFIG block gives the address of each instance:

FUNCTION_BLOCK Sensor
    VAR
        raw AT %I*: INT;
    END_VAR
END_FUNCTION_BLOCK

PROGRAM Cycle
    VAR
        s1: Sensor;
        s2: Sensor;
    END_VAR
END_PROGRAM

VAR_CONFIG
    Cycle.s1.raw AT %IW1.2: INT;
    Cycle.s2.raw AT %IW1.3: INT;
END_VAR

The path in VAR_CONFIG names the member from the outside: where the instance is, then the instance, then the member. Here the instance is in the program Cycle; a global instance is written without a program in front. The type must be the type of the member. Each template variable needs one entry, and the compiler rejects a template variable that has no entry as well as one that has two.

A build can write the list of all bound variables as a file, with --hwmap-file=<file>. Each entry gives the name in the source, the address, and the name of the symbol that holds the storage. A tool that reads the symbols of the binary, for example a live monitor in an IDE, needs that last name to find the value. The command line reference describes the option.

What’s next

The last chapter here is about code that is not text at all: graphical programs.

Graphical Programs

A Continuous Function Chart (CFC) holds the body of a POU as a diagram instead of text. You draw it in an engineering tool, which saves it as XML in the PLCopen exchange format, and the compiler reads that file like any other source.

plc chart.cfc library.st main.st -o app --linker=cc

A chart and a text file work together in both directions: the chart calls what the text declares, and the text calls the POU that the chart holds.

What a chart contains

A chart has two parts. The declaration is Structured Text, written in the declaration editor of the tool and stored as text in the file:

PROGRAM Mixer
    VAR
        left: Counter;
        right: Counter;
        outA, outB: DINT;
    END_VAR

The body is the network: the elements and the wires between them. The compiler turns the network into a list of statements, so a chart is a program in the same language as everything else in this guide.

ElementWhat it does
InputReads a variable or a literal and feeds it into a wire
OutputWrites the value of its wire into a variable
BlockCalls a function, a function block instance, a program, or an action
Connector and continuationA named break in a wire, to avoid drawing across the whole sheet
Jump and labelA conditional jump, and the place it jumps to
ReturnLeaves the POU when its condition is true

A small bubble on a pin negates the value that passes it.

Execution order

The wires say where a value goes, not when. The order of the statements is the order of the evaluation priority that you give the elements in the tool. It is not the order of the wires and not the position on the sheet:

        Add (0)             Scale (1)
 a --> | in1   out | --> | in    out | --> result (2)
 b --> | in2       |

The numbers in parentheses are the priorities. An element without a priority runs after every element that has one, in the order in which the file stores it.

This matters when two blocks write the same variable, or when one block reads a value that another one produces: the priority decides what happens first. Give a priority to every element that becomes a statement: an output, a block, a jump, a label, and a return.

Calls

What a block call does with the values on its pins depends on what the block calls.

A block that calls a function has no memory, so every output that a wire reads is stored in a hidden variable during the call. An input that no wire feeds uses the default value of the parameter.

A block that calls a function block instance, a program, or an action keeps its outputs in the instance, and a wire that reads such an output reads the member afterwards. Two blocks that read each other are therefore allowed: the priority decides which one runs first, and that one reads the value the other left in the previous evaluation.

EN and ENO

A block can carry two more pins. EN is a condition: the call runs only when the value on that pin is true. ENO reports the same condition to the next block, which is how a chain of blocks is switched on and off together.

When EN is false, the call does not run, and the outputs of the block keep the values they had.

Storage modes

An output element can carry a storage mode, which you set in the tool. Without one, the output writes the value on its wire every time it runs. With Set, the wire is a condition instead: when it is true, the variable becomes TRUE, and when it is false, nothing is written and the variable keeps its value. Reset writes FALSE under the same condition. Two outputs on one variable, one with each mode, build a latch.

Reference writes no value at all. The output becomes a reference that is bound to the variable on its wire, so a later read of the output sees the value that variable holds at that time. A negation bubble on such an output is rejected.

What the compiler reports

The compiler checks the drawing before it turns the network into statements. A chart has no lines and columns, so a diagnostic names the element instead:

error[E083]: Unsupported CFC expression: `foo + 1`
 = mixer.cfc: Block 6

The number is the identifier that the tool gave the element. The message repeats the rule of the table above: an input or an output element holds a variable or a literal, and not an expression.

Other checks are about the routing. A wire must lead somewhere, so a continuation needs a connector of its name, and a connector that something reads needs an input. A name is claimed once, so two connectors with the same label are rejected, and so are two labels with the same name. A jump to a label that no element defines is rejected as well. A label that no jump uses, a jump without a condition, and an element that you placed but never wired are warnings. A return without a condition is rejected, because it could never fire.

A block is checked against what the project declares. Its type must be a POU that the project knows. Every output that a wire reads must be one that the callee declares, and only one output pin may carry the return value. A generic callee needs an input that decides its type. A block with EN needs a wire on that pin, and the chain of ENO pins behind it must not lead back to the block itself.

The error code reference has a page per code.

What’s next

That is the language. The next chapter explains the compiler as a tool: building.

Building

After this chapter you can turn a project into the artifact that you want to ship.

It covers how sources become an artifact, and which options decide what that artifact is, for which target it is built, and how hard the optimizer works. It then covers how the object files are joined with the libraries and with the standard library, what the compiler reports when a build fails, and how to change the severity of a message. At the end it covers what to put into the binary so that a debugger finds the source of a shipped build.

Compiling

The compiler takes source files and produces one artifact. This chapter covers the options that a normal build needs. The command line reference lists all of them.

Files in, artifact out

plc main.st                      # one file
plc main.st motor.st sensor.st   # several files
plc "src/**/*.st"                # everything below src

Quote a pattern, so that the compiler expands it and not the shell.

Without an option that says otherwise, the compiler links an executable and names it after the first input file, here main.st.out. -o gives it another name. A pattern has no first file, so the name then comes from the pattern text and you get a file called *.st.out. Always give -o with a pattern.

Build a project

Once a build needs more than a file list, put it into plc.json next to the sources:

{
    "name": "plant",
    "files": [ "src/**/*.st" ],
    "compile_type": "Static",
    "output": "plant"
}
plc build

plc build reads plc.json from the current directory, or from the path that you give it. Everything lands in build/: the artifact, and one object file per source file under the path of the source, so src/motor.st becomes build/src/motor.st.o. --build-location moves that directory.

The project file reference describes every key, including the libraries.

Choose what to produce

OptionArtifact
noneAn executable
-cAn object file, not linked
--sharedA shared object
--irLLVM intermediate representation, as text

In a project file, the key compile_type does the same. Use Static, Object, Shared, Relocatable, Bitcode, or IR.

Note

Static and --static mean “link the units into one executable”. They do not produce a fully static binary. The system libraries, the C library included, stay dynamic.

Optimization

plc main.st -O aggressive

The four levels are none, less, default, and aggressive, and they are the levels of LLVM from -O0 to -O3. The default is default. Use none while you debug, because the generated code then follows the source closely. The level changes the machine code only. The text that --ir writes is the same at every level.

Check without producing anything

plc --check "src/**/*.st"
plc check plc.json

Both run the compiler up to validation and report every diagnostic, which is what an editor or a pre-commit hook needs.

Build for another machine

plc main.st --target aarch64-linux-gnu --sysroot /opt/toolchains/aarch64 -o app

--target takes a target triple that LLVM knows, and --sysroot tells the linker where the headers and libraries of that target are. The compiler builds for one target per run, so a build for two machines runs twice.

Speed

The compiler uses every core of the machine. -j 4 limits it to four threads.

Each unit becomes its own module, and the linker joins them. --single-module builds one module for the whole project instead, which is slower but sometimes necessary for a tool that reads the result. A plc build of the project above then writes one object file, build/src/main.st.o, in place of three.

Which compiler built an artifact

Every artifact carries the version of the compiler that produced it. In a linked artifact it sits in the .comment section, next to the lines of the linker and of the C runtime:

readelf -p .comment app
String dump of section '.comment':
  [     1]  Linker: Ubuntu LLD 21.1.8
  [    1b]  plc version 1.1.0-dev (Thu Sep 10 12:08:58 2026 +0200, 6f6e1d7f2db)
  [    5f]  GCC: (Ubuntu 15.2.0-16ubuntu1) 15.2.0

The version, the date, and the commit are the ones of the compiler that you used, and plc --version prints the same three. A deployed binary can therefore be matched to the compiler that built it. A pipeline that needs identical artifacts across compiler updates suppresses the line with --fno-ident.

What’s next

The compiler produced object files. The next chapter joins them with libraries into the final artifact.

Linking and Libraries

The compiler does not link by itself. It writes object files and then calls a linker program with them, with the libraries that you named, and with the options below.

Which linker runs

--linker=<command> names a linker with a cc compatible command line:

plc main.st -o app --linker=cc        # Linux
plc main.st -o app --linker=clang     # macOS, Windows

Without the option, the compiler takes the first of cc, clang, ld.lld, and ld that exists and supports the target. A compiler driver, cc or clang, is the better choice, because it knows the startup files and the default libraries of the platform. A bare linker cannot produce an executable that starts.

Two options adjust the driver. --fuse-ld=<name> selects the back end linker of the driver, for example mold; when ld.lld is on the machine, the compiler selects it already. --linker-arg=<argument> passes one argument through to the linker, and you repeat the option for each argument.

Using a library

A library has two parts, and you need both. The declarations tell the compiler what exists, and the binary provides the code:

plc main.st -i "vendor/include/*.st" -L vendor/lib -l vendor -o app --linker=cc

-i reads a file of declarations. Everything in it is external: the compiler takes the interfaces and ignores the bodies. -l names a library, so -lvendor links libvendor.so, and -L adds a directory to search. Two more forms exist: -l:libvendor.so.1 names an exact file, and -l/opt/lib/libvendor.so.1 links that path.

An object file is an input like a source file:

plc main.st helper.o -o app --linker=cc

In a project file, the libraries key holds the same information and adds packaging, which the project file reference describes.

The standard library

The functions of IEC 61131-3, the timers, the counters, and the string operations live in iec61131std. Install shows how to build it. The build writes the libraries into output/lib and the declarations into output/include, and a project that uses any of the functions needs both:

plc main.st -i "output/include/*.st" -L output/lib -l iec61131std -o app --linker=cc

Some language features call it as well, for example ** and the comparison of text, so link it whenever you are not sure.

Missing symbols

A shared object is linked with --no-undefined, so a symbol that nothing defines fails the build instead of failing later, when someone loads the library. The linker names the symbol:

ld.lld: error: undefined symbol: host_log

--allow-undefined-symbols turns that off, for the case where the host program provides the symbols.

Position-independent code

The compiler generates position-independent code for a shared object and for an executable. An object built with -c takes the default model of the target instead. --fpic and --fno-pic force one of the two, and they exclude each other.

What --fno-pic changes depends on the target. On x86_64 and on aarch64, the default model reaches a global through the global offset table as well, so the machine code is the same, and the visible effect is at link time: the compiler passes -no-pie to the driver, which produces an executable that is not position-independent. On 32-bit x86 and on 32-bit ARM, the default model addresses a global directly, so the code differs, and a shared object built from it fails to link, exactly as with gcc and clang:

ld.lld: error: relocation R_386_32 cannot be used against symbol 'g'; recompile with -fPIC

Windows

Linking on Windows uses the Microsoft libraries, so the toolchain needs three things: the Windows SDK and MSVC, an LIB environment variable that holds the directories of iec61131std.lib, ws2_32.lib, ntdll.lib, userenv.lib, libcmt.lib, oldnames.lib, and libucrt.lib, and a restarted terminal so that the variable is visible.

A shared library also needs a file that lists the exported names:

EXPORTS
    main
plc hello_world.st -c -l iec61131std -l ws2_32 -l ntdll -l userenv -o hello_world.o
clang hello_world.o --shared -l iec61131std -l ws2_32 -l ntdll -l userenv ^
    -fuse-ld=lld-link "-Wl,/DEF:exports.def" -o hello_world.dll

Bare metal

--nocrt leaves out the C runtime startup files, and --nolibc leaves out the default C libraries. With a driver they become -nostartfiles and -nodefaultlibs. Both are for targets that bring their own startup code. --script <file> gives the linker a linker script:

plc main.st -o app --linker=cc --nocrt --nolibc --script link.ld

What’s next

When a build fails, the compiler tells you why. The next chapter explains how to read that and how to change it.

Diagnostics

A diagnostic of the compiler has a code, a severity, and a position. This chapter explains how to read one, how to look up its code, and how to change its severity for a project.

Read a diagnostic

error[E037]: Invalid assignment: cannot assign 'STRING' to 'DINT'
  ┌─ main.st:6:5
  │
6 │     x := 'text';
  │     ^^^^^^^^^^^ Invalid assignment: cannot assign 'STRING' to 'DINT'

error: Compilation aborted due to critical errors.
Hint: You can use `plc explain <ErrorCode>` for more information

The first line gives the severity, the code, and the problem. The second gives the position: the compiler writes the path of the file in full, and the examples in this book shorten it to the file name. The marker under the source line shows the part of the statement that the message is about.

The compiler collects the diagnostics of a stage before it stops, so one run usually reports more than one problem. An error stops the run before code generation. A warning and an information message do not.

Look up a code

plc explain E037

The command prints the explanation of the code: usually a description with an example of the mistake and of the correct form, and for a part of the codes a title and nothing more. It works without a project, and the error code reference has the same text.

Severity

A diagnostic has one of four severities:

SeverityEffect
errorReported, and the run stops after the stage
warningReported, the run continues
infoReported, the run continues
ignoreNot reported

plc config diagnostics prints the severity of every code as JSON:

{"ignore":["E132","E015"],"warning":["E096","E042", ...],"info":["E092", ...],"error":["E119", ...]}

Change the severity

Write the codes you want to move into a file, with the severity as the key:

{
    "warning": [ "E037" ],
    "ignore":  [ "E023" ]
}
plc --check main.st --error-config severities.json

E037 is now a warning, so the run continues and the exit code stays 0. A code that you do not name keeps its default severity.

The option is global, so it works with build and check as well. To see the result of your file, print the merged configuration:

plc config diagnostics --error-config severities.json

Output format

ValueOutput
richThe default. Source snippet, position marker, and color
clangOne line per diagnostic, in the format of clang, for tools that parse it
noneNo messages. A run that fails still reports that it was aborted
plc --check main.st --error-format=clang
main.st:6:5:{6:5-6:16}: error[E037]: Invalid assignment: cannot assign 'STRING' to 'DINT'
error: Compilation aborted due to critical errors

What’s next

The error code reference has a page per code, with an example of the mistake and of the correct form. The next chapter is about the information that the compiler puts into the artifact for a debugger.

Debug Information

The compiler writes DWARF debug information into the artifact, so that a debugger can show source lines, variables, and types.

Generate debug information

OptionEffect
-g, --debugSource lines, variables, and types
--debug-variablesThe global variables only, without source lines
--gdwarf <2..5>The same as -g, with a fixed DWARF version
--gdwarf-variables <2..5>The same as --debug-variables, with a fixed DWARF version
plc -g main.st -o app --linker=cc

The four exclude each other, so give one of them. -g already covers the global variables, and the version it writes is DWARF 5. Use a fixed version when the debugger or the runtime on the target accepts one version only.

Why paths matter

The debug information stores the path of every source file. For local work this is fine. For a shipped binary and for remote debugging you usually want two properties:

  • no local paths of the build machine inside the artifact
  • stable paths that an IDE or gdb can map to a local checkout

Without any option, the compiler writes compile units relative to the compilation directory where it can, and the compilation directory itself can stay absolute. This follows what clang does.

Rewrite the paths

--file-prefix-map OLD=NEW rewrites every path that starts with OLD, so that it starts with NEW. Repeat the option for more mappings.

plc -g \
  --file-prefix-map /home/alice/work/MyApp=/src/MyApp \
  --file-prefix-map /home/alice/work/MyApp/build=/build/MyApp \
  app.st
  • OLD resolves against the current directory, and is canonicalized where possible.
  • NEW is used as written, and normalized for the platform.
  • When two mappings match, the longest one wins.

--debug-prefix-map is another name for the same option. It exists so that build systems can use the same spelling as with GCC and Clang.

--debug-compilation-dir <dir> sets the compilation directory of the debug information.

plc -g \
  --file-prefix-map /home/alice/work/MyApp=/src/MyApp \
  --debug-compilation-dir /src/MyApp \
  app.st

The two options are independent. The prefix map rewrites the recorded source file paths, the compilation directory sets one field of the compile unit. With both, the debug information can hold two records for one file, one for the source and one for the compile unit:

!2  = !DIFile(filename: "main.st", directory: "/SOURCE_ROOT/...")
!10 = !DIFile(filename: "/SOURCE_ROOT/.../main.st", directory: "/BUILD_ROOT")

This is how clang behaves with -ffile-prefix-map and -fdebug-compilation-dir together. A tool resolves whichever record it reads. For one canonical path, choose a prefix map that puts the source below the compilation directory.

A convention for shipped builds

Use virtual roots that exist on no machine:

  • source root /src/<Product>
  • build root /build/<Product>
plc -g \
  --file-prefix-map /real/source/root=/src/MyApp \
  --file-prefix-map /real/build/root=/build/MyApp \
  --debug-compilation-dir /src/MyApp \
  ...

The same options work with the build subcommand:

plc build plc.json -g \
  --file-prefix-map /real/source/root=/src/MyApp \
  --debug-compilation-dir /src/MyApp

Note

On Windows, prefer virtual roots to paths with a drive letter. The drive of the build machine and the drive of the developer machine are often different, and a virtual root keeps the mapping stable.

Sources above the working directory

A build that runs in a subdirectory and compiles a file above it, for example ../main.st, keeps that form:

cd examples/test
plc -g ../main.st \
  --file-prefix-map "$(pwd)=/root" \
  --debug-compilation-dir "$(pwd)"

The compile unit then holds the directory /root and the name ../main.st, and no local path of the build machine.

Map the paths in the debugger

set substitute-path /src/MyApp /home/bob/dev/MyApp
set substitute-path /build/MyApp /home/bob/dev/MyApp/build

For a remote session:

file /path/to/local/unstripped/binary
set substitute-path /src/MyApp /home/bob/dev/MyApp
target remote <host>:<port>

Projects with C and Structured Text

If the project also builds C or C++ with clang, use the same virtual roots on both sides:

-ffile-prefix-map=<real-source-root>=/src/MyApp
-ffile-prefix-map=<real-build-root>=/build/MyApp

One convention for both compilers keeps the source lookup consistent in Eclipse, in gdb, and in every debug adapter.

What’s next

That is the toolchain. The next chapter connects a project to code that is not written in Structured Text: interoperability.

Interoperability

After this chapter you can call a C library from Structured Text, and call what this compiler built from C.

It covers how to declare a function that lives in a C library, the variadic forms included, and what every construct of the language becomes on the C side, down to the layout of a function block and the names of the constructors that the compiler generates. It then shows how to let the compiler write the C headers of a project instead of writing them by hand. At the end it gives the rules that make an interface which other people can work with.

Calling C

A POU marked {external} has its implementation somewhere else. The compiler takes the declaration, generates no body, and leaves the symbol for the linker.

{external}
FUNCTION log: DINT
    VAR_IN_OUT
        message: STRING[1024];
    END_VAR
    VAR_INPUT
        severity: (Err, Warn, Info) := Info;
    END_VAR
END_FUNCTION

log can now be called from Structured Text. At link time a function with a compatible signature must exist, otherwise the link fails with an undefined symbol.

The attribute works on PROGRAM, FUNCTION, and FUNCTION_BLOCK.

Declarations from a file

-i includes a whole file as external. The compiler reads its declarations and ignores every body:

plc main.st -i vendor.st -L/opt/vendor/lib -lvendor -o app --linker=cc

Repeat -i for more files, and quote a pattern, so that the shell does not expand it into arguments that the compiler cannot read:

plc main.st -i "/usr/share/plc/include/*.st" -l iec61131std -o app --linker=cc

Call a C function

Give the C function a declaration in Structured Text. The signature must match what C expects.

int min(int a, int b);
{external}
FUNCTION min: DINT
    VAR_INPUT
        a: DINT;
        b: DINT;
    END_VAR
END_FUNCTION

The C interface chapter has the rules that turn a declaration into a C signature.

Variadic arguments

A parameter of type ... in the last VAR_INPUT block makes the function variadic, like printf in C.

{external}
FUNCTION printf: DINT
    VAR_INPUT {ref}
        format: STRING;
    END_VAR
    VAR_INPUT
        args: ...;
    END_VAR
END_FUNCTION

FUNCTION main: DINT
    VAR
        tmp: DINT;
    END_VAR

    tmp := 1;
    printf('Value %d, %d, %d$N', tmp, tmp * 10, tmp * 100);
    main := tmp;
END_FUNCTION
plc printer.st -o printer --linker=cc
./printer

There are three variadic forms, and they differ in what the callee receives:

FormWhat the callee gets
args: ...Every argument, then a null pointer after the last one
args: T...Every argument as a T, and nothing after them
args: {sized} T...The number of arguments, then a pointer to an array of T

The null pointer of the untyped form lets a callee that walks the list until a terminator always find the end, also when the call carries no variadic argument at all. A terminator that the caller writes ends the list first, so the added one stays unread. A callee that reads a fixed number of arguments, such as printf with its format string or a function with a count parameter, never sees the terminator.

The typed forms keep exactly the argument list of the caller, so their callees must not expect a terminator.

Note

Arguments of the untyped form follow the promotion rules of C: values smaller than 32 bits arrive as 32-bit values.

What’s next

A declaration must match what the other side expects. The next chapter gives the C type of every construct.

The C Interface

This chapter is the contract between Structured Text and C. It says which C type a declaration has, how a parameter is passed, and which symbols a library must provide.

The compiler can write these declarations for you, see Generating Headers.

Types

Structured TextCSize in bits
BOOLbool8
BYTEuint8_t8
SINTint8_t8
USINTuint8_t8
WORDuint16_t16
INTint16_t16
UINTuint16_t16
DWORDuint32_t32
DINTint32_t32
UDINTuint32_t32
LWORDuint64_t64
LINTint64_t64
ULINTuint64_t64
REALfloat32
LREALdouble64
TIME, DATE, TIME_OF_DAY, DATE_AND_TIMEuint32_t32
LTIME, LDATE, LTIME_OF_DAY, LDATE_AND_TIMEint64_t64
CHARuint8_t8
WCHARuint16_t16
STRING[n]char[n + 1]8 * (n + 1)
WSTRING[n]uint16_t[n + 1]16 * (n + 1)
REF_TO T, POINTER TO TT*64
ARRAY[a..b] OF TT[b - a + 1]

A string holds one more element than its declared length, for the terminator. A pointer is 64 bits, which is the size of LWORD and not of DWORD.

Functions

A FUNCTION becomes a C function. The table above gives the type of a parameter, and the block that declares it decides how the parameter arrives:

  • A VAR_INPUT of an elementary type is passed by value.
  • A VAR_INPUT of a string, array, or struct type is passed as a pointer to the caller’s variable. A callee that the compiler generates copies the value on entry, so the caller sees no change. A callee that you write in C must do the same and not write through the pointer.
  • A VAR_INPUT {ref}, a VAR_IN_OUT, and a VAR_OUTPUT are passed as a pointer to the caller’s variable. A write through the pointer reaches the caller.
TYPE Point:
    STRUCT
        x: DINT;
        y: DINT;
    END_STRUCT
END_TYPE

FUNCTION F1: DINT
    VAR_INPUT
        i: DINT;
        s: STRING[10];
        p: Point;
    END_VAR
    VAR_IN_OUT
        io: DINT;
    END_VAR
    VAR_OUTPUT
        o: DINT;
    END_VAR
END_FUNCTION
typedef struct {
    int32_t x;
    int32_t y;
} Point;

int32_t F1(int32_t i, char* s, Point* p, int32_t* io, int32_t* o);

The parameters keep the order of the declaration blocks.

Return values

An elementary return type is the return value of the C function.

An aggregate return type (string, array, or struct) is returned through a pointer that the caller provides, and that pointer is the first parameter. The C function then returns void.

FUNCTION RetString: STRING[20]
    VAR_INPUT
        n: DINT;
    END_VAR
END_FUNCTION
void RetString(char* RetString, int32_t n);

Function blocks

A FUNCTION_BLOCK is a struct plus a function that takes a pointer to an instance. Every variable block becomes a member of the struct, in declaration order, and that includes the private VAR members. A VAR_TEMP block is not a member, because it lives only for the duration of one call.

FUNCTION_BLOCK FB1
    VAR_INPUT
        i: DINT;
        s: STRING[10];
    END_VAR
    VAR_IN_OUT
        io: DINT;
    END_VAR
    VAR_OUTPUT
        o: DINT;
    END_VAR
    VAR
        priv: DINT;
    END_VAR
END_FUNCTION_BLOCK
typedef struct {
    uint64_t* __vtable;
    int32_t i;
    char s[11];
    int32_t* io;
    int32_t o;
    int32_t priv;
} FB1_type;

void FB1(FB1_type* self);

Important

The first member of every function block struct is __vtable, the pointer to the method table. Structured Text has no virtual keyword, so every function block gets the member, whether it uses inheritance or not. A C struct without it has the wrong layout.

A PROGRAM has the same shape, but without the __vtable member, and the compiler creates its one instance as the global <Program>_instance. Do not use programs in a library.

Inheritance

A FUNCTION_BLOCK Derived EXTENDS Base embeds the base as its first member, named __Base, and the __vtable member stays in the root of the chain. Write the C struct the same way, nested and not flattened:

FUNCTION_BLOCK Base
    VAR
        b: SINT;
    END_VAR
END_FUNCTION_BLOCK

FUNCTION_BLOCK Derived EXTENDS Base
    VAR_INPUT
        c: DINT;
    END_VAR
END_FUNCTION_BLOCK
typedef struct {
    uint64_t* __vtable;
    int8_t b;
} Base_type;

typedef struct {
    Base_type __Base;
    int32_t c;
} Derived_type;

The nesting keeps the padding at the end of the base, so c sits at offset 16 and an instance takes 24 bytes on both sides. A flattened struct with the same three members compiles as well, but it puts c at offset 12 in 16 bytes, so the two sides read different memory and nothing reports it. The generated headers nest for you.

Struct layout

Layout and alignment follow the rules of C. In C, declare a normal struct. In another language, force the C layout, for example with #[repr(C)] in Rust:

use std::ffi::c_char;

#[repr(C)]
pub struct MyStruct {
    x: i32,
    y: *mut i32,
    z: [c_char; 256],
}

Initialization

The compiler writes a constructor function for every type and one for every source file. The constructor of a source file sets the globals of that file and calls the constructors of the types in it. It is registered in the constructor list of the binary, so it runs before the application starts and no manual call is necessary.

SymbolPurpose
<TypeName>__ctorInitializes one instance of a struct or function block
<FunctionBlock>__FB_INITThe FB_INIT method of a function block, called by the constructor of the type
__unit_<file>_<hash>__ctorInitializes the globals of one source file, and calls the constructors above

In the last symbol, <file> is the file name with every character that is not a letter, a digit, or an underscore replaced by an underscore, so fb1.st becomes fb1_st. The <hash> is eight hexadecimal characters that come from the full path, which keeps two files of the same name apart.

A function block that needs initialization in C implements the FB_INIT symbol:

void myFunctionBlock__FB_INIT(myFunctionBlock_type* self) {
    self->a = 1;
    self->b = 2;
}

The declaration on the Structured Text side only states that the method exists:

{external}
FUNCTION_BLOCK myFunctionBlock
    VAR
        a: DINT;
        b: DINT;
    END_VAR

    METHOD FB_INIT
    END_METHOD
END_FUNCTION_BLOCK

The C side must also define myFunctionBlock, the body of the function block, because the constructor of the type writes its address into the method table.

Constructors for external code

The compiler writes no constructor for an {external} unit until you ask for it, so the FB_INIT above is never called. Two options control for which units the compiler writes constructors:

OptionUse
--constructors-onlyWrite the generated constructors and no bodies. For building the constructor object of an external library. It implies --generate-external-constructors
--generate-external-constructorsWrite constructors for {external} units as well. For the application that links such a library
# 1. Build the constructor object of the library
plc --constructors-only -c -o libext_ctor.o my_lib.pli

# 2. Build and ship the shared library
gcc -shared -fPIC -o libext.so my_lib.c libext_ctor.o

# 3. Build the application, with constructors for the external declarations
plc -L. -lext -i my_lib.pli --generate-external-constructors app.st

For a library that mixes foreign code with Structured Text sources, compile the sources with constructors and archive the result:

plc iec61131-st/*.st -c --generate-external-constructors -o st.o
ar crs libst.a st.o

What’s next

You do not have to write these declarations by hand. The next chapter makes the compiler generate them.

Generating Headers

The compiler can write the declarations of a project as C headers. Use them when you implement a declared interface in C, or when C code calls into compiled Structured Text. Only C is supported. The generation replaces code generation: the compiler parses and validates the project, writes the headers, and stops before it produces an object file.

From the command line

plc --generate-headers "**/*.pli" --header-output include
OptionPurpose
--generate-headersGenerate headers instead of code
--header-output <dir>The directory for the generated files, created when it is missing
-o <name>Write one file, <name>.h, with the declarations of every input
-i <file>Add declarations that the inputs need, without a header for them

Without -o, every input file gets its own header, named after it. Give the name after -o without the .h, because the compiler always appends the extension. Without --header-output, a header lands beside its source, and a combined header beside the first input.

From a project file

The generate subcommand reads the same project file as plc build, and writes one header per source file of the project:

plc generate plc.json headers
OptionPurpose
--header-output <dir>The directory for the generated files
--header-language <lang>The language. c is the default and the only implemented value
--header-prefix <name>Name every header <name>.h instead of naming it after its source

Warning

--header-prefix is not a prefix. Each source file writes the same <name>.h and overwrites what the file before it wrote, so only the declarations of the last source survive, and nothing is reported. Use it for a project with one source file only. To get one header for a project with more, combine the sources with --generate-headers and -o.

An example

The file motor.pli declares an alias, a function, and a function block:

TYPE T_Message: STRING[255];
END_TYPE

FUNCTION PrintMessage: DINT
    VAR_INPUT
        message: T_Message;
    END_VAR
END_FUNCTION

FUNCTION_BLOCK Counter
    VAR_INPUT
        step: DINT;
    END_VAR
    VAR_OUTPUT
        value: DINT;
    END_VAR
END_FUNCTION_BLOCK

The command at the top of this chapter writes include/motor.h:

// ---------------------------------------------------- //
// This file is auto-generated                          //
// Manual changes made to this file will be overwritten //
// ---------------------------------------------------- //

#ifndef INCLUDE_MOTOR_H_
#define INCLUDE_MOTOR_H_

#include <stdint.h>
#include <stdbool.h>
#include <math.h>
#include <time.h>
#include <dependencies.plc.h>

#ifdef __cplusplus
extern "C" {
#endif

typedef char T_Message[256];

typedef struct {
    uint64_t* __vtable;
    int32_t step;
    int32_t value;
} Counter_type;

// message: maximum of 256 T_Message(s)
int32_t PrintMessage(T_Message* message);

void Counter(Counter_type* self);

#ifdef __cplusplus
}
#endif /* __cplusplus */

#endif /* !INCLUDE_MOTOR_H_ */

The include guard is the path of the header in upper case, with each / and . turned into _ and one _ at the end. The C interface of a function block is its struct plus a function that takes a pointer to an instance. The first member of every function block struct is the pointer to its method table.

Every generated header includes dependencies.plc.h, and the compiler never writes that file. Put it on the include path yourself, empty or with the declarations the generator left out, or the C compiler stops at the include.

What the generator skips

  • Declarations marked {external}, because their implementation is elsewhere already.
  • Declarations that came in with -i.
  • The constructors that the compiler generates, and the types whose names start with __.

A file whose declarations are all external or included produces no header at all. A file that uses such a declaration still names it in its prototypes, so dependencies.plc.h is where the C side declares it.

The C interface of each construct, and the rules behind the type translation, are in the C interface chapter.

What’s next

The last chapter here is about the interface itself: how to design one.

API Guidelines

These guidelines are for developers who write a library for IEC 61131-3 applications. They explain which construct to choose for an interface, and why. The C interface chapter explains what each construct becomes in C.

Choose the POU kind

Use a FUNCTION when the result depends only on the arguments, and nothing must survive the call. A function fits well into expressions, because it has a return value. It cannot keep data.

Use a FUNCTION_BLOCK when the interface keeps state between calls, for example a timer or a counter. When the state belongs to the caller and not to the library, a function that takes the data as VAR_IN_OUT is the other way to reach it.

Never put a PROGRAM in a library. A program has one instance, and that instance belongs to the application.

Parameters

  • VAR_INPUT is for values that the POU only reads.
  • VAR_IN_OUT is for data that the POU reads and writes. It is always a pointer to the caller’s variable, and a call must always supply it.
  • VAR_OUTPUT is for a result that the POU writes.

Use VAR_IN_OUT instead of a pointer in VAR_INPUT. The pointer makes the caller responsible for the address, and it hides the direction of the data in the interface.

Note

In a FUNCTION, a string, array, or struct in VAR_INPUT arrives as a pointer, and a Structured Text body copies it before the first statement. A change inside the function does not reach the caller. A body written in another language gets no such copy, so it must treat the pointer as read only. In a FUNCTION_BLOCK, such a value is a member of the instance.

A function that must accept arrays of different lengths declares the parameter as ARRAY[*]. The compiler always passes such a parameter by reference, whatever block it stands in.

Results

A FUNCTION states its result as its return type. A FUNCTION_BLOCK states its results as VAR_OUTPUT. Do not return a result through a pointer in VAR_INPUT.

A return type can be a string, an array, or a struct. The compiler makes such a result a hidden first parameter, and the caller provides the memory, so a large result needs no output parameter.

Private members are visible

Every member of a function block is part of its struct, so the user of the library sees the members that you keep for internal use. They stand in the generated header next to the inputs and the outputs, and an access modifier does not hide them, because the compiler parses the modifier and ignores it. Structured Text code that reads such a member gets a warning and builds; C code sees no difference at all.

Give these members names that say that they are internal, and document that they are not part of the interface.

Types

Choose the type that carries the intention of the value:

  • A bit sequence belongs in BYTE, WORD, DWORD, or LWORD, not in an integer type.
  • A time or a date belongs in a time type, not in LINT or LWORD.
  • A pointer belongs in REF_TO, not in an integer type. REF_TO is the form of the standard, and the compiler checks the type of what you assign to it.
  • A text belongs in STRING or WSTRING with an explicit length. Without a length, the capacity is 80.

Initialization

A function block that needs setup declares the method FB_INIT. The compiler calls it once for each instance, when that instance is created. Declare it without parameters and without a return type, because the compiler calls it with no arguments.

FUNCTION_BLOCK Counter
    VAR
        current: DINT;
    END_VAR

    METHOD FB_INIT
        current := 1;
    END_METHOD
END_FUNCTION_BLOCK

For a library whose implementation is not Structured Text, provide the symbol <FunctionBlock>__FB_INIT and declare the method in the interface file. The C interface chapter has the symbol names and the build options.

What’s next

That is everything the guide covers. The reference holds the complete lists: every option, every project key, every construct, and every error code.

Reference

This chapter is for looking things up, not for reading from the top.

It lists every option and subcommand of plc, every key of the project file, the function families of the standard library with the file that declares each of them, and every diagnostic code with the text that plc explain prints.

The guide chapters explain the concepts behind all of it.

Command Line

Every option of plc, grouped by purpose. This page is for looking things up; Building explains the options that a normal build uses.

plc [OPTIONS] <input-files>...
plc [OPTIONS] <input-files>... <SUBCOMMAND>

Most options are global, so they work with a subcommand as well. plc -h prints the same list.

Subcommands

SubcommandPurpose
build [plc.json]Build the project that the project file describes
check [plc.json]Run the compiler up to validation, write nothing
config schemaPrint the JSON schema of the project file
config diagnosticsPrint the severity of every diagnostic code
explain <code>Print the explanation of a diagnostic code
generate [plc.json] headersGenerate C headers for a project

config prints JSON. generate headers accepts --header-language, where c is the implemented language, --header-output, and --header-prefix.

Input

OptionEffect
<input-files>Paths or glob patterns. Quote a pattern so the compiler expands it
-i, --include <file>Read declarations without compiling their bodies. Repeatable
--encoding <name>Read the sources with this encoding instead of UTF-8

The extension decides how a file is read: .cfc, .fbd, and .xml are graphical sources, .o, .so, and .exe go to the linker, and everything else is Structured Text.

Output

OptionArtifactDefault name
none, --staticAn executable<first input>.out
-cAn object file, not linked<first input>.o
--relocatableOne object file with every unit<first input>.o
--sharedA shared object<first input>.so
--irLLVM intermediate representation<first input>.ll
--bcLLVM bitcode<first input>.bc
--astThe syntax tree after parsingstandard output
--ast-loweredThe syntax tree after every rewritestandard output

The default name comes from the first argument as it was written, so a glob pattern gives a file whose name holds the pattern. Give -o with a pattern.

OptionEffect
-o, --output <file>Name of the artifact
--build-location <dir>Directory for the intermediate object files
--checkProduce nothing; report the diagnostics only

Code generation

OptionEffect
-O, --optimization <level>none, less, default (the default), aggressive
--target <triple>Build for this LLVM target instead of the host
--sysroot <dir>Root for the headers and libraries of that target
-j, --threads <n>Use n threads. Without the option the compiler uses every core
--single-moduleBuild one LLVM module for the whole project
--fpicForce position-independent code
--fno-picForce code that is not position-independent
--fno-identDo not embed the compiler version in the artifact

Linking

OptionEffect
--linker <command>Use this linker, for example cc or clang
--fuse-ld <name>Back end linker for a driver, for example mold
--linker-arg <argument>Pass one argument to the linker. Repeatable
-l, --library <name>Link lib<name>. Also -l:libfoo.so.1 and a full path
-L, --library-path <dir>Add a directory to the library search
--script <file>Give the linker a linker script
--nocrtDo not link the C runtime startup files
--nolibcDo not link the default C libraries
--allow-undefined-symbolsAllow undefined symbols in a shared object

Debug information

OptionEffect
-g, --debugSource lines, variables, and types, as DWARF 5
--debug-variablesThe global variables only
--gdwarf <2..5>The same as -g, with a fixed DWARF version
--gdwarf-variables <2..5>The same as --debug-variables, with a fixed DWARF version
--file-prefix-map OLD=NEWRewrite recorded paths. Repeatable. Alias --debug-prefix-map
--debug-compilation-dir <dir>Set the compilation directory in the debug information

Diagnostics

OptionEffect
--error-config <file>Change the severity of diagnostic codes
--error-format <format>rich (the default), clang, or none
--log-level <level>off, error, warn, info, debug, trace
-v, --verboseThe same as --log-level=debug

Other outputs

OptionEffect
--generate-headersWrite C headers instead of code
--header-output <dir>Directory for the generated headers
--hwmap-file[=<file>]Write the map of hardware-bound variables. The = is required
--generate-external-constructorsAlso write constructors for {external} units
--constructors-onlyWrite the generated constructors and no bodies
--online-changeEmit the type information that a runtime needs to exchange code while it runs
--got-layout-file <file>Read and write the table layout that an online change keeps stable

Deprecated

OptionUse instead
--pic--shared --fpic
--no-pic--shared --fno-pic
--hardware-conf <file>--hwmap-file=<file>, which also carries the mangled symbol names
--no-linker-scriptNothing. No script is used unless --script names one

Project File

Every key of plc.json, for looking up. Compiling explains how a project is built.

The file holds the inputs, the artifact kind, and the libraries of a build. It is a JSON file, and it is called plc.json by convention.

plc build                 # reads ./plc.json
plc build src/plc.json    # reads the given file

plc check, plc config, and plc generate take the same argument.

Keys

KeyNecessaryDefaultPurpose
nameyesThe name of the project, and the name of the artifact
filesyesThe source files, as paths or glob patterns
compile_typenoStaticWhat the build produces
outputno<name> with the extension of the formatThe name of the artifact
librariesnononeThe libraries to include and to link
versionnononeFree text, for your own use
format_versionnononeFree text, for your own use

Any other key is an error, and the message names the keys that the compiler accepts. Two of those keys are not in the table above, because they do nothing: package_commands is read and never used, and format-version is a second spelling of format_version.

{
    "name": "motor",
    "files": [ "src/**/*.st" ],
    "compile_type": "Shared",
    "output": "libmotor.so"
}

compile_type

ValueResult
ObjectOne object file with all units, no link step
StaticAn executable
SharedA shared object
RelocatableOne object file, combined by a partial link
BitcodeLLVM bitcode
IRLLVM intermediate representation

Warning

Deprecated. PIC and NoPIC are Shared with a fixed relocation model. Use Shared with --fpic or --fno-pic.

libraries

A library entry adds the declarations of a precompiled library to the project, and links the library:

"libraries": [
    {
        "name": "iec61131std",
        "path": "libs/",
        "link_path": "libiec61131std.so.1",
        "package": "Copy",
        "include_path": [ "include/*.st" ]
    }
]
KeyNecessaryPurpose
nameyesThe name for the linker. mylib links libmylib.so
pathyesThe directory of the library, absolute or relative to the project
packageyesHow the library reaches the target system
include_pathyesThe declaration files of the library, resolved against path. Their bodies are ignored
link_pathnoAn exact file to link instead of the name, for example libmylib.so.1. A relative value resolves against path
architecturesnoAccepted and never used

package takes these values:

ValueMeaning
Copy, LocalThe library is copied to the library location of the build
SystemThe library is already on the target system
StaticThe library is linked statically

Where the build writes

LocationDefaultOption
Intermediate objects and the artifactbuild, next to the project file--build-location <dir>
Copied librariesthe build location--lib-location <dir>

--lib-location exists on build only, and the directory must exist already. Outside build, the compiler writes intermediate objects to the temporary directory of the operating system unless --build-location is given, and -o always resolves against the current directory.

Environment variables

A $NAME in any value is replaced with the value of the environment variable NAME before the file is read. A variable that is not set stays as written.

SYSROOT=/opt/toolchain plc build
"libraries": [
    { "name": "vendor", "path": "$SYSROOT/lib", "package": "System", "include_path": [ "vendor.st" ] }
]

Validation

The compiler validates the file against a JSON schema before the build starts. The schema is part of the compiler, and plc config schema prints it:

plc config schema > plc-json.schema

Give that file to your editor to get completion and validation while you write the project file. The schema is stricter than the compiler in one place: it marks compile_type as necessary, and the compiler takes the default instead.

The project file itself takes no $schema key, because the compiler rejects every key that it does not know.

Standard Library

iec61131std provides the functions and function blocks of IEC 61131-3. This page says what is in it and which file declares each family. For the signature of a single function, read that file.

Using it

A release installs the library and its declarations:

FileContents
/usr/share/plc/include/*.stThe declarations
/usr/lib/<triplet>/libiec61131std.soThe implementation, also as libiec61131std.a
plc main.st -i "/usr/share/plc/include/*.st" -l iec61131std -o app --linker=cc

Some parts of the language call the library by themselves, so link it also when your own code names none of these functions:

  • ** calls EXPT
  • a comparison of STRING or WSTRING calls the string functions

Families

FamilyDeclared inContains
Arithmeticarithmetic_functions.stSQRT, LN, LOG, EXP, SIN, COS, TAN, ASIN, ACOS, ATAN, ATAN2, EXPT, the variadic ADD and MUL, and the constants PI_REAL, FRAC_PI_2_REAL, FRAC_PI_4_REAL, E_REAL, INF_REAL, and NAN_REAL, each also in an LREAL form such as PI_LREAL
Numericalnumerical_functions.stABS
Selectorsselectors.stMAX, MIN, LIMIT
Bit shiftsbit_shift_functions.stROL, ROR
Endiannessendianness_conversion_functions.stTO_BIG_ENDIAN, TO_LITTLE_ENDIAN, and back
Validationvalidation_functions.stIS_VALID, IS_VALID_BCD
Textstring_functions.stLEN, LEFT, RIGHT, MID, CONCAT, INSERT, DELETE, REPLACE, FIND, and the comparisons
Text conversionstring_conversion.stBetween STRING, WSTRING, CHAR, and WCHAR
Timerstimers.stTP, TON, TOF, each also in a _TIME and an _LTIME form
Counterscounters.stCTU, CTD, CTUD, each also with the suffix _INT, _DINT, _UDINT, _LINT, or _ULINT
Edgesflanks.stR_TRIG, F_TRIG
Bistablebistable_functionblocks.stSR, RS
Date and timedate_time_numeric_functions.stAdding and subtracting durations, dates, and times of day, and MUL_TIME and DIV_TIME
Date and timedate_time_conversion.stBetween the date and time types, and between the short and long families
Date and timedate_time_extra_functions.stCONCAT_DATE, CONCAT_TOD, the SPLIT_ family that takes such a value apart again, and DAY_OF_WEEK
Numeric conversionnum_conversion.st<TYPE>_TO_<TYPE> for every pair of numeric types
Bit conversionbit_conversion.stBetween the bit string types and BOOL, and between them and CHAR and WCHAR
Bit and numberbit_num_conversion.stBetween the bit string types and the numeric types
Truncationtrunc_int.stTRUNC_<TYPE>, which cuts the fraction of a real
Truncationreal_trunc_int.stREAL_TRUNC_<TYPE> and LREAL_TRUNC_<TYPE>, the same for one source type each
Generic conversionto_num.st, to_bit.st, to_string.st, to_date_time.stTO_<TYPE>, one generic function per target type
Text output and inputextra_functions.st<TYPE>_TO_STRING and <TYPE>_TO_WSTRING, the STRING_TO_<TYPE> family that reads a value back, TRUNC, and TIME(), which gives the time since midnight

The text conversion family covers eight of the twelve directions: each of the four text types converts to two of the other three. STRING to WCHAR, WSTRING to CHAR, CHAR to WSTRING, and WCHAR to STRING do not exist.

<TYPE>_TO_STRING covers most types, but not all of them. There is no INT_TO_STRING, SINT_TO_STRING, WORD_TO_STRING, or BOOL_TO_STRING, and the generic TO_STRING has no version for those types either, so a call compiles and then fails at the link step. Convert such a value to a wider type first, for example with INT_TO_DINT. <TYPE>_TO_WSTRING covers the same types except the unsigned integers.

Two forms of conversion

The library provides the same conversion twice. INT_TO_DINT(x) names both types, and TO_DINT(x) is generic and takes the source type from the argument. The generic form calls the named one, so both give the same result, and the named one saves a call.

Where a conversion can lose information, the name says so: TRUNC_DINT cuts the fraction of a real, and REAL_TO_DINT rounds it.

Error Codes

Every diagnostic of the compiler has a code. The pages below hold one code each. Most of them say what the code reports, why it is reported, and what a correct program looks like; the rest carry a title and nothing more.

The same text is available on the command line:

plc explain E037

To change the severity of a code for your project, see Diagnostics.

General Error

This error is a catch all error. It is usually thrown when no other error better matches the case.

General IO Error

This error describes a problem during an IO operation such as reading or writing a file. It is usually accompanied by an internal error with further details.

Parameter Error

This error describes a problem with the command parameters, such as a file required for the compilation not being found.:

Duplicate Symbol

The marked symbol has been defined multiple times.

Generic LLVM Error

An unexpected error occurred during the LLVM generation phase. This is usually a follow up problem from a different diagnostics. If it occurrs without a previous diagnostics please file a bug report.

Missing Token

During the parsing phase, an additional Token (Element) was required to correctly interpret the code. The error message usually indicates what Token was missing.

Example

In the following example the name (Identifier) of the program is missing.

PROGRAM (*name*)
END_PROGRAM
error: Unexpected token: expected Identifier but found END_PROGRAM
  ┌─ example.st:2:1
  │
2 │ END_PROGRAM
  │ ^^^^^^^^^^^ Unexpected token: expected Identifier but found END_PROGRAM

Unexpected Token

During parsing, a Token (Element) was encountered in the wrong location. This could be an indication of a missused or misspelled keyword

E008: Invalid Range

This error is emitted when an array range declaration is malformed. There are two cases that trigger it.

1. Missing or malformed range

An array dimension must be written as start .. end. A bare literal or any other expression in a dimension slot is rejected, including in multi-dimensional arrays where any single dimension is not a range.

Invalid:

VAR
    arr1 : ARRAY[5] OF DINT;           // bare literal, no range
    arr2 : ARRAY[0..5, 5] OF DINT;     // second dimension is not a range
END_VAR

How to fix

Use start .. end for every dimension:

VAR
    arr1 : ARRAY[0..5] OF DINT;
    arr2 : ARRAY[0..5, 0..5] OF DINT;
END_VAR

2. Non-integer range bounds

Array range bounds must be of an integer type. BOOL, REAL / LREAL, STRING / WSTRING, time/date types, and other non-integer types are rejected. This matches the IEC 61131-3 requirement that array dimensions are integer-indexable.

Invalid:

VAR
    a : ARRAY[FALSE .. TRUE] OF BOOL;       // BOOL bounds
    b : ARRAY[1.5 .. 3.5] OF INT;           // REAL bounds
    c : ARRAY['a' .. 'z'] OF INT;           // STRING bounds
    d : ARRAY[T#0s .. T#1s] OF INT;         // TIME bounds
END_VAR

How to fix

Use integer literals, integer constants, or integer-typed expressions:

VAR_GLOBAL CONSTANT
    LO : DINT := 0;
    HI : DINT := 10;
END_VAR

VAR
    a : ARRAY[0..1] OF BOOL;
    b : ARRAY[LO..HI] OF INT;
END_VAR

Valid integer types

The valid integer types for array bounds are the same as for enum base types (see E122): SINT, USINT, INT, UINT, DINT, UDINT, LINT, ULINT, BYTE, WORD, DWORD, LWORD.

Mismatched Parantheses

Invalid time literal

Invalid Number

Missing Case Contition

Keywords should contain Underscores

Wrong paranthese for String delimiter

POINTER TO is type-unsafe

Variables defined as a POINTER TO data-type are considered type-unsafe in the sense that assigning between incompatible types will not be caught. For example the following code, while incorrect, will not return any diagnostics when compiling:

VAR
    stringVar : STRING;
    unsafePtrA : POINTER TO DINT := ADR(stringVar);
    unsafePtrB : POINTER TO DINT := REF(stringVar);
END_VAR

Note that for class and function block hierarchies, POINTER TO assignments are validated — the compiler checks that the pointee types are related via EXTENDS (see E125). POINTER TO is the recommended mechanism for polymorphism per IEC 61131-3.

For other types, consider using REF_TO instead of POINTER TO, which is a type-safe alternative and should catch type-mismatches early on.

Return types cannot have a default value

Classes cannot contain implementation

Duplicate Label

Classes cannot contain IN_OUT variables

Classes cannot contain a return type

Variable re-declatation in Subclasses is not allowed

A variable already declared in a parent class cannot be re-declared in a subclass.

Example:

FUNCTION_BLOCK FB
VAR
    a : INT;
END_VAR
END_FUNCTION_BLOCK

FUNCTION_BLOCK FB2 EXTENDS FB
VAR
    a : INT; // Error: Variable 'a' is already declared in the parent class
END_VAR
END_FUNCTION_BLOCK

Missing container name for action

Statement has no effect

Invalid Pragma Location

Missing return type

Unexpected return type

Unsupported return type

Empty variable block

E029: Recursive data structure

This error occurs when data structures contain themselves directly or indirectly, creating infinite recursion during type resolution.

Example

TYPE MyStruct : STRUCT
    value : INT;
    nested : MyStruct;  (* This creates infinite recursion *)
END_STRUCT; END_TYPE

In this example, MyStruct contains a field of type MyStruct, which would require infinite memory to represent.

Another example - indirect recursion

TYPE StructA : STRUCT
    data : INT;
    ref_b : StructB;
END_STRUCT; END_TYPE

TYPE StructB : STRUCT
    info : STRING;
    ref_a : StructA;  (* Creates a cycle: StructA -> StructB -> StructA *)
END_STRUCT; END_TYPE

This shows two structures that reference each other, creating a circular dependency.

Valid self-referential structures with pointers

Self-referential data structures are allowed when using references or pointers, as these have fixed size:

(* Tree node structure *)
TYPE TreeNode : STRUCT
    value : INT;
    left : REF_TO TreeNode;   (* Pointer to left child *)
    right : REF_TO TreeNode;  (* Pointer to right child *)
END_STRUCT; END_TYPE

(* Linked list node *)
TYPE ListNode : STRUCT
    data : STRING;
    next : REF_TO ListNode;   (* Pointer to next node *)
END_STRUCT; END_TYPE

How to fix

Use references or pointers for self-referential structures

Break the infinite recursion by using REF_TO for recursive references:

TYPE Node : STRUCT
    value : INT;
    child : REF_TO Node;  (* Use REF_TO instead of direct inclusion *)
END_STRUCT; END_TYPE

Restructure circular dependencies

For mutually recursive structures, consider using references or redesigning the data structure:

TYPE PersonID : DINT; END_TYPE

TYPE Person : STRUCT
    name : STRING;
    manager_id : PersonID;    (* Reference by ID instead of direct inclusion *)
    reports : ARRAY[0..9] OF PersonID;
END_STRUCT; END_TYPE

Missing IN_OUT parameters

Invalid parameter type

Invalid number of arguments

An invalid number of arguments was passed to a POU. For example

FUNCTION foo
    (* ... *)
END_FUNCTION

FUNCTION main : DINT
    foo('bar'); // Error, foo isn't expecting any arguments
END_FUNCTION

Note that for FUNCTIONs the argument count must match with the parameter list and can be bigger if a variadic parameter is present. For stateful POUs variadic parameters are not supported, thus the argument count must be equal or less than the parameter list depending on whether optional arguments such as VAR_INPUT or VAR_OUTPUT were passed or not.

Unresolved constant

A CONSTANT declaration could not be resolved to a compile-time value.

This error is reported when the compiler cannot determine a valid constant initializer, for example when:

  • the initializer references non-constant values,
  • the initializer contains non-constant operations/calls,
  • a referenced constant is itself unresolved,
  • a CONSTANT has no explicit initializer and no usable default can be derived from its type.

Example

TYPE MyInt : INT; END_TYPE

VAR_GLOBAL CONSTANT
    // No explicit initializer, and no type default available
    a : MyInt;

    // Not a constant expression
    b : INT := someVar + 1;
END_VAR

Possible fixes

  • Provide an explicit compile-time initializer, e.g. a : MyInt := 0;.
  • Ensure all referenced symbols in the initializer are themselves constants.
  • Replace non-constant calls/operations with compile-time expressions.
  • If relying on implicit defaults, define a default on the declared type.

Invalid constant block

Invalid Constant

Cannot assign to constant

E037: Invalid assignment

This error is reported for assignments or accesses that are not allowed, for example when the types of the left- and right-hand side are incompatible, when a VOID function result is assigned to a variable, or when a variable is written or read from a place where it must not be accessed.

Example - incompatible types

FUNCTION main
VAR
    x : INT;
    s : STRING;
END_VAR
    x := s; (* cannot assign 'STRING' to 'INT' *)
END_FUNCTION

Example - VAR_OUTPUT assigned outside of its scope

VAR_OUTPUT variables of a function block may only be written by the function block itself. From the outside they can only be read.

FUNCTION_BLOCK FB
VAR_OUTPUT
    out : BOOL;
END_VAR
END_FUNCTION_BLOCK

PROGRAM main
VAR
    fb : FB;
END_VAR
    fb.out := TRUE; (* VAR_OUTPUT variables cannot be assigned outside of their scope *)
END_PROGRAM

Example - VAR_IN_OUT accessed outside of its scope

VAR_IN_OUT variables of a function block or program may not be read or written from outside. They are references that are only bound to a target while the function block is being called, so any outside access - reading, writing or taking the address - would dereference an unbound reference. Pass the variable in the call instead.

FUNCTION_BLOCK FB
VAR_IN_OUT
    inOut : LREAL;
END_VAR
END_FUNCTION_BLOCK

PROGRAM main
VAR
    fb : FB;
    value : LREAL;
END_VAR
    fb.inOut := value;  (* VAR_IN_OUT variables cannot be accessed outside of their scope *)
    value := fb.inOut;  (* VAR_IN_OUT variables cannot be accessed outside of their scope *)
    fb(inOut := value); (* correct: the variable is passed in the call *)
END_PROGRAM

Missing type

Variable Overflow

Non-Standard Enum Variant

This warning indicates the right-hand side in an enum assignment does not match a defined variant. For example an enum such as TYPE Color : (red := 0, green := 1, blue := 2); END_TYPE is expected to take values which (internally) yield a literal integer 0, 1 or 2.

Invalid variable initializer

Unused

This error code is currently not emitted by any diagnostic.

Invalid array assignment

Invalid POU for VLA

Invalid VLA array access

VLA Dimension out of bounds

VLAs are always By Reference

Unresolved Reference

Illegal reference access

Expression is not assignable

Typecast error

Unknown type

Use of undeclared type-identifier.

Literal out of range

Literal not compatible with type

Incompatible direct access

Incompatible variable for direct access

Invalid range for direct access

Invalid range for array access

Invalid variable for array access

Direct access to variable with %

Expected literal

Invalid Nature

Unknown Nature

Unresolved Generic

Incompatible size

Invalid operation

Implicit typecast

Pointer derefernce to non pointer

Array access to non array value

Address-of requires a value

General codegen error

Missing function

Missing compare function

Cannot generate string literal

Initial values were not generated

General debug error

Generic linker error

Duplicate case condition

Case condition outside of a case statement

Invalid case condition

Duplicate CFC connector

Two connectors in a CFC network share the same label. A connector is the single named source for its label, so continuations reading it would be ambiguous. Give each connector a distinct label.

Dangling CFC continuation

A continuation refers to a label that no connector defines. It re-emits a named signal that was never captured, so whatever reads it receives no value. Add the matching connector or remove the continuation.

Unsupported CFC expression

A variable, source or sink element in a CFC network carries an expression that is not supported. Only variable references (foo), qualified references (foo.bar), indexed references (arr[i]) and literals (5) are allowed; function calls and other expressions are not.

Unconnected CFC element

A CFC network contains an element that is not wired to anything. It has no effect on the program and is ignored during transpilation.

Disconnected CFC return

A RETURN element in a CFC network is not wired to a condition. A conditional return only fires when its incoming value is true, so a return with nothing connected can never trigger and is almost certainly a mistake.

Open CFC connector

A connector has no incoming connection, yet something reads its label through a continuation. The named signal is never produced, so the consumer would be left without a value. Wire a source into the connector.

Unnamed control

Invalid PLC Json file

Invalid Call parameters

Incompatible reference assingment

Unsafe Enum Assignment

At runtime there is no way to guarantee that a non-const reference will not change its value to something out-of-bounds for enums. For example consider the following

PROGRAM main
    VAR
        zero  : DINT := 0;
        color : (red := 0, green := 1, blue := 2);
    END_VAR

    zero := 10;
    color := zero; // Invalid because `color` accepts values from 0 to 2, but we assigned 10 to it
END_PROGRAM

Equivalent enum value used

This message indicates that the assigned enum value is not part of the enum, but is equivalent to one of the internal values of the enum.

Example:

TYPE Colors : (Red, Green, Blue, Yellow) END_TYPE
TYPE Directions : (N, S, W, E) END_TYPE

VAR_GLOBAL
    col : Colors := N; //N is equivalent to Red but is not part of the enum
    dir : Directions := Red; //Red is equivalent to N but is not part of the enum
END_VAR

To solve the issue, use the equivalent value indicated by the enum

Return Value Of Void Functions

Functions of type VOID can not have an explicit return value, e.g. foo := 1 in the following example is invalid.

FUNCTION foo
    foo := 1;
END_FUNCTION

Choose a type for your function, if a value must be returned.

Invalid Conditional Value

Control statements such as IF, FOR and WHILE require specific types for their condition.

If, While

IF and WHILE statements require an expression which yields a boolean, any other type is invalid and will trigger an error.

For

FOR statements require four conditional values: a counter, a start value, an end value and a step value. All of these need to be integers and share the same type.

FOR start := counter TO end BY step DO
// ...
END_FOR

Action call without parentheses

Integer Condition

This error is generated because an integer was used in a IF or WHILE statement, when a boolean was expected.

See also plc explain E094

Invalid Array Range

Ranges such as ARRAY [0..-1] are invalid in ST because end values of ranges must be greater than their start values. A valid range for the given statement would have been ARRAY[-1..0].

Invalid REF= assignment

REF= assignments are considered valid if the left-hand side of the assignment is a pointer variable and the right-hand side is a variable of the type that is being referenced.

For example assignments such as the following are invalid

VAR
    foo     : DINT;
    bar     : DINT;
    qux     : SINT;
    refFoo  : REFERENCE TO DINT;
END_VAR

refFoo  REF= 5;         // `5` is not a variable
foo     REF= bar;       // `foo` is not a pointer
refFoo  REF= qux;       // `refFoo` and `qux` have different types, DINT vs SINT

Invalid REFERENCE TO declaration

REFERENCE TO variable declarations are considered valid if the referenced type is not of the following form

  • foo : REFERENCE TO REFERENCE TO (* ... *)
  • foo : ARRAY[...] OF REFERENCE TO (* ... *)
  • foo : REF_TO REFERENCE TO (* ... *)

Immutable Variable Address

Alias variables are immutable with regards to their pointer address, thus re-assigning an address will return an error. For example the following code will not compile

FUNCTION main
    VAR
        foo AT bar : DINT;
        bar : DINT;
        baz : DINT;
    END_VAR

    foo := baz;     // Valid, because we are changing the pointers dereferenced value
    foo REF= baz;   // Invalid, `foo` is immutable with regards to it's pointer address
END_FUNCTION

Template variable does not exist

A variable was configured in a VAR_CONFIG block, but the variable can not be found in the code.

Erroneous code example:

VAR_CONFIG
    main.foo.bar AT %IX1.0 : BOOL;
END_VAR

PROGRAM main
    VAR
        foo : foo_fb;
    END_VAR
END_PROGRAM

FUNCTION_BLOCK foo_fb
    VAR
        qux AT %I* : BOOL;
    END_VAR
END_FUNCTION_BLOCK

In this example a variable named bar is configured, however the function block foo_fb does not contain a bar variable. The could should have been main.foo.qux AT %IX1.0 : BOOL instead for it to be valid.

Template variable without hardware binding

A template variable must contain a hardware binding.

Erroneous code example:

VAR_CONFIG
    main.foo.bar AT %IX1.0 : BOOL;
END_VAR

PROGRAM main
    VAR
        foo : foo_fb;
    END_VAR
END_PROGRAM

FUNCTION_BLOCK foo_fb
    VAR
        bar : BOOL;
    END_VAR
END_FUNCTION_BLOCK

In this example the VAR_CONFIG block declares the bar variable inside foo_fb as a template variable. However bar does not have a hardware binding. For the example to be considered valid, bar should have been declared as e.g. bar AT %I* : BOOL.

Immutable Hardware Binding

Variables configured in a VAR_CONFIG block can not override their hardware binding.

Erroneous code example:

VAR_CONFIG
    main.foo.bar AT %IX1.0 : BOOL;
END_VAR

PROGRAM main
    VAR
        foo : foo_fb;
    END_VAR
END_PROGRAM

FUNCTION_BLOCK foo_fb
    VAR
        bar AT IX1.5: BOOL;
    END_VAR
END_FUNCTION_BLOCK

In this example the VAR_CONFIG block configures bar to have a hardware adress IX1.0. However, at the same time the bar inside the POU foo_fb assigns a hardware address IX1.5.

For the code to be considered valid, bar should have been declared as bar AT %I* : BOOL.

Config Variable With Incomplete Address

Variables defined in a VAR_CONFIG block, i.e. config variables, must specify a complete address.

Erroneous code example:

VAR_CONFIG
    main.foo.bar AT %I* : BOOL;
END_VAR

In this example main.foo.bar has specified a placeholder hardware address. For the example to be considered valid, a specific address such as %IX1.0 should have been declared.

CONSTANT keyword in POU

The CONSTANT keyword is not allowed for POU declarations, only variables can be CONSTANT

Erroneous code example:

FUNCTION FOO : BOOL CONSTANT 
VAR_INPUT
END_VAR
    // ...
END_FUNCTION

VAR_EXTERNAL blocks have no effect

Variables declared in a VAR_EXTERNAL block are currently ignored and the referenced globals will be used instead.

Example:

VAR_GLOBAL
    myArray : ARRAY [0..10] OF INT;
    myString: STRING;
END_VAR

FUNCTION main
VAR_EXTERNAL CONSTANT
    myArray : ARRAY [0..10] OF INT;
END_VAR
    myArray[5] := 42;
    myString := 'Hello, world!';
END_FUNCTION

In this example, even though arr is declared as VAR_EXTERNAL CONSTANT, the CONSTANT constraint will be ignored and the global myArray will be mutated. The global myString can be read from and written to from within main even though it is not declared in a VAR_EXTERNAL block.

Missing configuration for template variable

A template variable was left unconfigured.

Erroneous code example:

VAR_CONFIG
    main.foo.bar AT %IX1.0 : BOOL;
END_VAR

PROGRAM main
    VAR
        foo : foo_fb;
    END_VAR
END_PROGRAM

FUNCTION_BLOCK foo_fb
    VAR
        bar AT %I* : BOOL;
        qux AT %I* : BOOL;
    END_VAR
END_FUNCTION_BLOCK

In this example a variable named main.foo.qux is declared as a template, however the VAR_CONFIG-block does not contain an address-configuration for it. Each template variable needs to be configured, otherwise it could lead to segmentation faults at runtime.

Template variable is configured multiple times

A template variable is configured more than once, leading to ambiguity.

Erroneous code example:

VAR_CONFIG
    main.foo.bar AT %IX1.0 : BOOL;
    main.foo.bar AT %IX1.1 : BOOL;
END_VAR

PROGRAM main
    VAR
        foo : foo_fb;
    END_VAR
END_PROGRAM

FUNCTION_BLOCK foo_fb
    VAR
        bar AT %I* : BOOL;
    END_VAR
END_FUNCTION_BLOCK

In this example a variable named main.foo.bar has multiple configurations in the VAR_CONFIG-block. It is not clear which address this variable should map to - only a single configuration entry per instance-variable is allowed.

Stateful member variable initialized with temporary reference

Stack-local variables do not yet exist at the time of initialization. Additionally, pointing to a temporary variable will lead to a dangling pointer as soon as it goes out of scope - potential use after free.

Erroneous code example:

FUNCTION_BLOCK foo
    VAR
        a : REF_TO BOOL := REF(b);
    END_VAR
    VAR_TEMP
        b : BOOL;
    END_VAR
END_FUNCTION_BLOCK

Invalid POU Type for Inheritance

Base Classes and Interfaces can only be used on CLASSes and FUNCTION_BLOCKs, any other POU type is invalid and will result in this error.

Errouneus code example:

INTERFACE interfaceA
    /* ... */
END_INTERFACE

FUNCTION_BLOCK fb
END_FUNCTION_BLOCK

FUNCTION foo EXTENDS fb IMPLEMENTS interfaceA
    /* ... */
END_FUNCTION_BLOCK

In the example above, the POU type of foo should have been CLASS or FUNCTION_BLOCK.

Duplicate interface methods with different signatures

POUs implementing multiple interfaces where both interfaces define a method with the same name are required to have the same signature for the method. A method signature is thereby defined by its name, return type and parameter list.

Errouneus code example:

INTERFACE interfaceA
    METHOD foo : INT
        VAR_INPUT
            a : INT;
        END_VAR
    END_METHOD
END_INTERFACE

INTERFACE interfaceB
    METHOD foo : DINT
        VAR_OUTPUT
            a : INT;
        END_VAR
    END_METHOD
END_INTERFACE

FUNCTION_BLOCK fb IMPLEMENTS interfaceA, interfaceB
    // Signatures for foo differs, do we implement foo as defined in interfaceA or interfaceB?
END_FUNCTION_BLOCK

In the example above, the method foo is defined in both interfaces interfaceA and interfaceB. However, the return type of foo in interfaceA is INT whereas in interfaceB it is DINT. Futhermore, the parameter a in interfaceA is an input parameter whereas in interfaceB it is an output parameter. As a result both you and the compiler are left in doubt as to which method signature to implement in the function block fb and as a result the compiler will raise this error.

Incomplete interface implementation

Any class or function block implementing an interface must implement all methods as defined in the interface. Generally speaking this error is raised when any of the following conditions are met:

  1. The method is not implemented in the class or function block at all
  2. The return type of the method is different from the return type defined in the interface
  3. The order of the parameters in the method is different from the order of the parameters in the interface
  4. The size of the parameter list does not match the size of the parameter list in the interface
  5. The parameter at any given position in the method is different from the parameter at the same position in the interface (name, data type, or variable block type i.e. INPUT, OUTPUT, IN_OUT)

Errouneus code example:

INTERFACE interfaceA
    METHOD foo : INT
        VAR_INPUT
            a : INT;
            b : DINT;
        END_VAR
    END_METHOD
END_INTERFACE

FUNCTION_BLOCK fb IMPLEMENTS interfaceA
    METHOD foo : DINT   // Incorrect return type, should have been `INT`
        VAR_OUPUT       // Incorrect variable block type, should have been `VAR_INPUT`
            b : DINT;   // Incorrect order, should have been `a : INT`; as a result also an incorrect data type
            a : INT;    // Incorrect order, should have been `b : DINT`; as a result also an incorrect data type
            c : INT;    // Incorrect parameter list length, 3 > 2
        END_VAR
    END_METHOD
END_FUNCTION_BLOCK

Note: The third bullet point can be confusing, however for implicit calls the order of the parameters is important. For example if a interface defines the order of the parameters as a, b and the function block implements the method as b, a a method call such as foo(1, 2) should be interpreted as foo(a := 1, b := 2) but instead will be interpreted as foo(a := 2, b := 1). As a result consistency between any POU implementing an interface can no longer be guaranteed.

Interface default method implementation

Methods defined in interfaces must not have an implementation. While the compiler parses them, they are not used to validate and/or generate code and thus will have no effect. This may change in the future but as of now is not supported.

Erreneous code example:

INTERFACE interfaceA
    METHOD methodA : INT
        methodA := 5; // This counts as a default implementation and hence will return a warning
    END_METHOD
END_INTERFACE

Cannot extend a POU multiple times

Multiple EXTENDS keywords are not allowed

Erreneous code example:

FUNCTION_BLOCK foo EXTENDS bar EXTENDS baz
    // ...
END_FUNCTION_BLOCK

Property defined in non-stateful POU type

Properties may only be defined in stateful POUs such as a PROGRAM,CLASS or FUNCTION_BLOCK.

Errouneus code example:

FUNCTION foo
    // Invalid definition
    PROPERTY_GET bar: DINT
        bar := 42;
    END_PROPERTY
END_FUNCTION

Property defined in unsupported variable block

Properties only allow for variable blocks of type VAR.

Errouneus code example:

FUNCTION foo
    PROPERTY_GET bar: DINT
        VAR         /* ... */   END_VAR
        VAR_INPUT   /* ... */   END_VAR // Invalid
    END_PROPERTY
END_FUNCTION

Non-constant array boundary

Array boundaries must be compile-time constants.

Examples of invalid boundaries:

  • Local variables declared without CONSTANT
  • Expressions that are not compile-time evaluable constants

Signature mismatch

This error is generally a follow-up error to “E112” to give more detailed information about why the error occured. Please use plc explain E112 get more information about the general causes of these errors.

Invalid use of the SUPER keyword

The SUPER keyword provides access to members of a parent POU (Program Organization Unit) in an inheritance hierarchy. However, there are several rules governing its proper use:

Common errors

  1. Using SUPER in a POU that doesn’t extend another POU:
    The SUPER keyword can only be used inside a POU that directly extends another POU through the EXTENDS keyword.

  2. Not dereferencing SUPER to access members:
    When accessing members of a superclass, SUPER must be dereferenced using the ^ operator: SUPER^.member.

  3. Chaining SUPER references:
    SUPER cannot be accessed as a member of another object. Expressions like SUPER^.SUPER^ are invalid.

  4. Global access position:
    SUPER cannot be used with the global access operator (.SUPER^.member).

  5. Using SUPER with type cast operators:
    The type cast operator (<type>#) cannot be used with SUPER.

Examples of invalid use

// Error: Using SUPER in a POU that doesn't extend another POU
FUNCTION_BLOCK fb
    SUPER^.x := 2;  // Error: Invalid use of `SUPER`
END_FUNCTION_BLOCK

// Error: Not dereferencing SUPER when accessing members
FUNCTION_BLOCK child EXTENDS parent
    SUPER.x := 20;  // Error: `SUPER` must be dereferenced to access its members
END_FUNCTION_BLOCK

// Error: Chaining SUPER references/SUPER in member access
FUNCTION_BLOCK child EXTENDS parent
    x.SUPER^.y := 20;    
    SUPER^.SUPER^.x := 20;  
    // Error: `SUPER` is not allowed in member-access position
END_FUNCTION_BLOCK

// Error: Global access position
FUNCTION_BLOCK child EXTENDS parent
    .SUPER^.x := 20;  // Error: `SUPER` is not allowed in global-access position
END_FUNCTION_BLOCK

// Error: Using SUPER with type cast
FUNCTION_BLOCK child EXTENDS parent
    p := parent#SUPER;  // Error: The `<type>#` operator cannot be used with `SUPER`
END_FUNCTION_BLOCK

Invalid use of the THIS keyword

The THIS keyword provides access to the current instance of a FUNCTION_BLOCK. However, there are several rules governing its proper use:

Common errors

  1. Using THIS outside of a FUNCTION_BLOCK context: The THIS keyword can only be used inside a FUNCTION_BLOCK or its METHODs or ACTIONs. It cannot be used in FUNCTIONs, PROGRAMs, or .

  2. Not dereferencing THIS to access members: When accessing members using THIS, it must be dereferenced using the ^ operator: THIS^.member.

  3. Member access position: THIS cannot be accessed as a member of another object. Expressions like x.THIS^ are invalid.

  4. Global access position: THIS cannot be used with the global access operator (.THIS^.member).

  5. Using THIS with type cast operators: The type cast operator (<type>#) cannot be used with THIS.

Examples of invalid use

// Error: Using THIS outside `FUNCTION_BLOCK` context
FUNCTION func
    THIS^.x := 2;  // Error: Invalid use of `THIS`
END_FUNCTION

// Error: Not dereferencing THIS when accessing members
FUNCTION_BLOCK fb
    THIS.x := 20;  // Error: `THIS` must be dereferenced to access its members
END_FUNCTION_BLOCK

// Error: THIS in member access position
FUNCTION_BLOCK fb
    x.THIS^.y := 20;  // Error: `THIS` is not allowed in member-access position
END_FUNCTION_BLOCK

// Error: Global access position
FUNCTION_BLOCK fb
    .THIS^.x := 20;  // Error: `THIS` is not allowed in global-access position
END_FUNCTION_BLOCK

// Error: Using THIS with type cast
FUNCTION_BLOCK fb
    p := fb#THIS;  // Error: The `<type>#` operator cannot be used with `THIS`
END_FUNCTION_BLOCK

Examples of valid use

FUNCTION_BLOCK Counter
    VAR
        count : INT;
        enabled : BOOL;
    END_VAR

    // Valid: Direct use in FUNCTION_BLOCK implementation
    METHOD increment
        IF THIS^.enabled THEN
            THIS^.count := THIS^.count + 1;
        END_IF
    END_METHOD

    // Valid: Using THIS to pass the instance to another FB
    METHOD send_to_logger : BOOL
        VAR_IN_OUT
            logger : Logger;
        END_VAR
        logger.log_counter(THIS);
    END_METHOD

    // Valid: Using THIS to compare with another instance
    METHOD equals : BOOL
        VAR_IN_OUT
            other : Counter;
        END_VAR
        equals := THIS^.count = other.count;
    END_METHOD

    ACTION my_action
        IF THIS^.enabled THEN
            THIS^.count := THIS^.count + 1;
        END_IF
    END_ACTION
END_FUNCTION_BLOCK

E121: Recursive type alias

This error occurs when type aliases reference each other in a cycle, creating an infinite recursion.

Example

TYPE type1 : type2; END_TYPE
TYPE type2 : type1; END_TYPE

In this example, type1 is defined as type2, which is in turn defined as type1, creating a circular dependency.

Another example

TYPE self_type : self_type; END_TYPE

This shows a type alias that directly references itself.

How to fix

Break the chain by resolving to concrete types

Break the circular dependency by ensuring that type aliases eventually resolve to concrete types:

TYPE typeA : DINT; END_TYPE      (* Points to concrete type *)
TYPE typeB : typeA; END_TYPE     (* Points to another alias, but ultimately resolves to DINT *)

E122: Invalid enum base type

This error occurs when an enum is declared with a base type that is not a valid integer type. Enums in IEC 61131-3 can only use integer types as their underlying representation.

Example

TYPE Color : STRING (red := 1, green := 2, blue := 3);
END_TYPE

TYPE Status : REAL (active := 1, inactive := 0);
END_TYPE

TYPE Timestamp : TIME (start := 0, stop := 1);
END_TYPE

These examples show invalid base types:

  • STRING is not a valid base type for an enum. Only integer types are allowed.
  • REAL is a floating-point type, not an integer type
  • TIME is a time/date type, which although internally represented as an integer, should not be used as an enum base type

Valid integer types

The following integer types are valid for enum base types:

  • INT, UINT - 16-bit integers
  • SINT, USINT - 8-bit integers
  • DINT, UDINT - 32-bit integers
  • LINT, ULINT - 64-bit integers
  • BYTE - 8-bit unsigned
  • WORD - 16-bit unsigned
  • DWORD - 32-bit unsigned
  • LWORD - 64-bit unsigned

How to fix

Use a valid integer type

Change the base type to one of the supported integer types:

TYPE Color : INT (red := 1, green := 2, blue := 3);
END_TYPE

TYPE Status : BYTE (active := 1, inactive := 0);
END_TYPE

Or omit the type specification

If no specific size is required, you can omit the type specification (will default to DINT):

TYPE Color (red := 1, green := 2, blue := 3);
END_TYPE

E123: Division by zero error

This error occurs when a literal or constant on the right side of a division operation is zero.

Examples

Literal is zero

FUNCTION main
    VAR
        x : DINT;
        divX : DINT;
    END_VAR

    x := 5;
    divX := x / 0;
END_FUNCTION

Constant is zero

VAR_GLOBAL CONSTANT
    ConstantZero: DINT := 0;
END_VAR

FUNCTION main
    VAR
        x : DINT;
        divX : DINT;
    END_VAR

    x := 5;
    divX := x / ConstantZero;
END_FUNCTION

E124: Invalid escape sequence in string literal

This error occurs when a string literal contains a $-escape sequence that is not valid per IEC 61131-3.

Valid escape sequences are:

SequenceValid inResult
$$STRING, WSTRINGLiteral $
$'STRINGLiteral '
$"WSTRINGLiteral "
$L, $l, $N, $nSTRING, WSTRINGLine feed (\n)
$R, $rSTRING, WSTRINGCarriage return (\r)
$T, $tSTRING, WSTRINGHorizontal tab (\t)
$P, $pSTRING, WSTRINGForm feed (\x0C)
$hhSTRINGByte with hex value hh (exactly 2 hex digits)
$hhhhWSTRINGUTF-16 code unit with hex value hhhh (exactly 4 hex digits)

Examples

Unrecognised escape character

FUNCTION main : DINT
VAR
    s : STRING[20] := 'test$Qtest';   (* $Q is not a valid escape *)
END_VAR
    main := 0;
END_FUNCTION

Incomplete hex escape

FUNCTION main : DINT
VAR
    s : STRING[10] := '$A';       (* STRING needs exactly 2 hex digits: e.g. $41 *)
    w : WSTRING[10] := "$004";    (* WSTRING needs exactly 4 hex digits: e.g. $0041 *)
END_VAR
    main := 0;
END_FUNCTION

Trailing dollar sign

FUNCTION main : DINT
VAR
    s : STRING[10] := 'hello$';   (* '$' at end of literal has nothing to escape *)
END_VAR
    main := 0;
END_FUNCTION

Incompatible POINTER TO types in class/function block hierarchy

This error occurs when a POINTER TO assignment involves two class or function block types that are not in a valid inheritance relationship. Only upcasts (child to parent) and same-type assignments are allowed.

POINTER TO is the recommended mechanism for polymorphism per IEC 61131-3. When working with class or function block hierarchies, the compiler checks that pointer assignments respect the EXTENDS chain: the right-hand side must be the same type as, or a subtype of, the left-hand side’s pointee type.

Invalid examples

Unrelated types

FUNCTION_BLOCK FbA
END_FUNCTION_BLOCK

FUNCTION_BLOCK FbX
END_FUNCTION_BLOCK

FUNCTION main
    VAR
        instance : FbX;
        ptr      : POINTER TO FbA;
    END_VAR
    ptr := ADR(instance);  // Error: FbX is not a subtype of FbA
END_FUNCTION

Downcast (parent assigned to child pointer)

FUNCTION_BLOCK FbA
END_FUNCTION_BLOCK

FUNCTION_BLOCK FbB EXTENDS FbA
END_FUNCTION_BLOCK

FUNCTION main
    VAR
        instance : FbA;
        ptr      : POINTER TO FbB;
    END_VAR
    ptr := ADR(instance);  // Error: FbA is not a subtype of FbB
END_FUNCTION

Sibling types

FUNCTION_BLOCK FbA
END_FUNCTION_BLOCK

FUNCTION_BLOCK FbB EXTENDS FbA
END_FUNCTION_BLOCK

FUNCTION_BLOCK FbC EXTENDS FbA
END_FUNCTION_BLOCK

FUNCTION main
    VAR
        instance : FbC;
        ptr      : POINTER TO FbB;
    END_VAR
    ptr := ADR(instance);  // Error: FbC is not a subtype of FbB
END_FUNCTION

Valid examples

Same type

FUNCTION_BLOCK FbA
END_FUNCTION_BLOCK

FUNCTION main
    VAR
        instance : FbA;
        ptr      : POINTER TO FbA;
    END_VAR
    ptr := ADR(instance);  // OK
END_FUNCTION

Upcast (child assigned to parent pointer)

FUNCTION_BLOCK FbA
END_FUNCTION_BLOCK

FUNCTION_BLOCK FbB EXTENDS FbA
END_FUNCTION_BLOCK

FUNCTION main
    VAR
        instanceB : FbB;
        ptrA      : POINTER TO FbA;
        ptrB      : POINTER TO FbB;
    END_VAR
    ptrA := ADR(instanceB);  // OK: FbB extends FbA
    ptrA := ptrB;            // OK: same relationship
END_FUNCTION

Incompatible types in interface polymorphism

This error occurs when an assignment or call argument involves an interface type but the source type does not satisfy the interface contract. There are two cases:

Case 1: POU does not implement the interface

When assigning a concrete function block or class instance to an interface-typed variable (or passing it as an argument), the POU must implement that interface — either directly via IMPLEMENTS or transitively through its EXTENDS chain.

Invalid

INTERFACE IA
    METHOD foo END_METHOD
END_INTERFACE

FUNCTION_BLOCK FbX
END_FUNCTION_BLOCK

FUNCTION main
    VAR
        instance : FbX;
        ref      : IA;
    END_VAR
    ref := instance;  // Error: FbX does not implement IA
END_FUNCTION

Valid

INTERFACE IA
    METHOD foo END_METHOD
END_INTERFACE

FUNCTION_BLOCK FbA IMPLEMENTS IA
    METHOD foo END_METHOD
END_FUNCTION_BLOCK

FUNCTION main
    VAR
        instance : FbA;
        ref      : IA;
    END_VAR
    ref := instance;  // OK: FbA implements IA
END_FUNCTION

Implementation is also satisfied transitively through inheritance:

FUNCTION_BLOCK FbA IMPLEMENTS IA
    METHOD foo END_METHOD
END_FUNCTION_BLOCK

FUNCTION_BLOCK FbB EXTENDS FbA
END_FUNCTION_BLOCK

FUNCTION main
    VAR
        instance : FbB;
        ref      : IA;
    END_VAR
    ref := instance;  // OK: FbB inherits IA implementation from FbA
END_FUNCTION

When assigning one interface-typed variable to another, the source interface must be the same as or extend the target interface. Downcasts (parent to child) and assignments between unrelated interfaces are rejected.

Invalid — downcast

INTERFACE IA
    METHOD foo END_METHOD
END_INTERFACE

INTERFACE IB EXTENDS IA
    METHOD bar END_METHOD
END_INTERFACE

FUNCTION main
    VAR
        refIA : IA;
        refIB : IB;
    END_VAR
    refIB := refIA;  // Error: cannot downcast IA to IB
END_FUNCTION

Invalid — unrelated interfaces

INTERFACE IA
    METHOD foo END_METHOD
END_INTERFACE

INTERFACE IB
    METHOD bar END_METHOD
END_INTERFACE

FUNCTION main
    VAR
        refIA : IA;
        refIB : IB;
    END_VAR
    refIA := refIB;  // Error: IB and IA are not related
END_FUNCTION

Valid — upcast

INTERFACE IA
    METHOD foo END_METHOD
END_INTERFACE

INTERFACE IB EXTENDS IA
    METHOD bar END_METHOD
END_INTERFACE

FUNCTION main
    VAR
        refIA : IA;
        refIB : IB;
    END_VAR
    refIA := refIB;  // OK: IB extends IA
END_FUNCTION

Array initialized with fewer elements than expected

An array initializer provides fewer values than the declared array size. The remaining elements will be zero-initialized.

Example:

VAR
    arr : ARRAY[1..5] OF DINT := [1, 2, 3]; // only 3 of 5 elements
END_VAR

This is a warning because the compiler will implicitly fill the unspecified elements with their default (zero) value, which may or may not be intentional.

To silence this warning, provide all elements explicitly or use a multiplied initializer to fill the rest:

VAR
    arr : ARRAY[1..5] OF DINT := [1, 2, 3, 0, 0];   // explicit
    arr2 : ARRAY[1..5] OF DINT := [1, 2, 3, 2(0)];   // multiplied fill
END_VAR

Invalid assignment through a property

Properties can only be assigned as a whole, not through member or index access.

A setter position is conceptually just a method call, similar to:

instance.set_position(value)

That means the setter updates the entire property value. It does not produce an assignable intermediate value, so writing through a property into nested fields or elements is not allowed.

Invalid

Assigning to a field of a property:

instance.position.x := 5;

Assigning to an indexed element of a property:

instance.values[1] := 5;

Using a property as the base of a larger assignment target:

instance.positions[1].x := 5;

Valid

Assign the full property value:

instance.position := value;
instance.values := local_array;

Read from a property and then access members or indices:

local := instance.position.x;
arr[instance.position.x] := 5;

Interfaces cannot be called directly

Calling an interface-typed variable directly like myInterface() is invalid and instead a concrete method must be called, e.g. myInterface.foo().

Invalid

INTERFACE IA
    METHOD foo END_METHOD
END_INTERFACE

FUNCTION main
    VAR
        refIA : IA;
    END_VAR

    refIA();
END_FUNCTION

This also applies to qualified references and array elements:

THIS^.refIA();
refs[i]();

Valid

Specify the method name explicitly:

INTERFACE IA
    METHOD foo END_METHOD
END_INTERFACE

FUNCTION main
    VAR
        refIA : IA;
    END_VAR

    refIA.foo();
END_FUNCTION

Array size exceeds supported limit

The declared array has more elements than the compiler can safely handle. The total number of elements (the product of all dimension lengths) must not exceed UDINT#4_294_967_295.

Extremely large arrays cause excessive compilation times and may fail to link because the resulting object file cannot represent them.

Example

VAR
    // 5 × 988_010 × 3 × 91 × ... = ~837 billion elements — exceeds UDINT max
    huge : ARRAY[1..5, 2345324..3333333, -1..1, 10..100] OF DINT;
END_VAR

Possible fixes

  • Reduce the array dimensions.
  • Use dynamically allocated memory via REF_TO and runtime allocation if the platform supports it.
  • Split the data across multiple smaller arrays.

Positional argument collides with later named argument

In a mixed implicit/explicit call, a positional argument would naturally fill the same parameter that a later named argument also targets. The positional would spill into the next free slot - a silent reinterpretation that is almost never what the caller intended. This is an error because no legitimate code should depend on that disambiguation.

FUNCTION myfunc : INT
VAR_INPUT
    a : INT;
    b : INT;
END_VAR
END_FUNCTION

PROGRAM main
VAR x : INT; END_VAR
    // `1` sits in position 0 (param `a`), but `a := 10` also targets `a`.
    x := myfunc(1, a := 10);
END_PROGRAM

Rewrite the call so the intent is unambiguous - either name both arguments or drop the duplicate name.

Mixing implicit and explicit call parameters

A call mixes positional (implicit) arguments with named (explicit) arguments. The compiler accepts this, but the resolution is order-sensitive: named args first claim their slots, and any remaining positional args fill the leftover slots left-to-right. This is easy to misread and easy to break by reordering.

Example:

FUNCTION myfunc : INT
VAR_INPUT
    a : INT;
    b : INT;
    c : INT;
END_VAR
END_FUNCTION

PROGRAM main
VAR x : INT; END_VAR
    // `b := 20` claims slot 1; positional `1` fills slot 0 (a), `3` fills slot 2 (c).
    x := myfunc(b := 20, 1, 3);
END_PROGRAM

Prefer one style per call - fully positional or fully named - so a reader can see the mapping at a glance. If a mix is genuinely the clearest option (e.g. to override one default in the middle of a long parameter list), name every argument whose position isn’t obvious.

To silence this warning, rewrite the call with a single convention:

    x := myfunc(1, 20, 3);                    // all positional
    x := myfunc(a := 1, b := 20, c := 3);     // all named

See also E131 — positional arg collides with later named arg — which catches the more dangerous case where a positional argument’s natural slot is also named, and the resolver silently shifts it to a different parameter.

AND_THEN / OR_ELSE Used With Non-Boolean Operands

The AND_THEN and OR_ELSE operators provide explicit short-circuit evaluation semantics and are only meaningful for boolean operands. When used with integer types, short-circuit evaluation does not apply — use AND / OR instead for bitwise operations.

Erroneous code example:

PROGRAM main
VAR
    a : DINT;
    b : DINT;
    c : DINT;
END_VAR
    c := a AND_THEN b;  // ❌ AND_THEN requires BOOL operands
    c := a OR_ELSE b;   // ❌ OR_ELSE requires BOOL operands
END_PROGRAM

To fix, either use boolean operands:

PROGRAM main
VAR
    a : BOOL;
    b : BOOL;
    c : BOOL;
END_VAR
    c := a AND_THEN b;  // ✅ correct: both operands are BOOL
    c := a OR_ELSE b;   // ✅ correct: both operands are BOOL
END_PROGRAM

Or use AND / OR for bitwise operations on integers:

PROGRAM main
VAR
    a : DINT;
    b : DINT;
    c : DINT;
END_VAR
    c := a AND b;  // ✅ correct: bitwise AND
    c := a OR b;   // ✅ correct: bitwise OR
END_PROGRAM

Invalid Hardware Map Output Configuration

This error describes a problem with the --hwmap-file command-line argument - most commonly an output path whose extension does not identify a supported serialization format. The hardware map sidecar is currently emitted as JSON or TOML, selected by the file extension.

Erroneous invocation:

plc main.st --hwmap-file=output.xml

To fix, use a supported extension:

plc main.st --hwmap-file=output.json
plc main.st --hwmap-file=output.toml

Or omit the value to let the compiler derive <output>.hwmap.json next to the binary:

plc main.st -o main.so --hwmap-file

=> Used for a Non-Output Parameter

=> captures a value out of an OUTPUT parameter into a caller variable. Using it for an INPUT or IN_OUT parameter is a direction mismatch — those parameters expect := to pass a value into the call.

Erroneous code example:

FUNCTION_BLOCK fb
VAR_INPUT in_val : DINT; END_VAR
END_FUNCTION_BLOCK

PROGRAM main
VAR
    instance : fb;
    source   : DINT;
END_VAR
    instance(in_val => source);  // invalid: ':=' expected for an input parameter
END_PROGRAM

To fix, use := for the input (or in-out) parameter:

PROGRAM main
VAR
    instance : fb;
    source   : DINT;
END_VAR
    instance(in_val := source);  // correct
END_PROGRAM

Incomplete hardware address in FUNCTION or METHOD

Variables with an incomplete hardware address (e.g. AT %I*) are placeholders that expect a complete address to be supplied later by a VAR_CONFIG block. They can only be declared in stateful contexts: PROGRAM, FUNCTION_BLOCK, or VAR_GLOBAL.

FUNCTION and METHOD bodies have no persistent state to bind a hardware address to, so an incomplete address there cannot be resolved.

Erroneous code example:

FUNCTION foo : DINT
VAR
    flag AT %I* : BOOL;
END_VAR
END_FUNCTION

Move the variable into a PROGRAM, FUNCTION_BLOCK, or VAR_GLOBAL block, or supply a complete address (e.g. AT %IX1.0) directly.

FB-level VAR_TEMP referenced from a METHOD

A VAR_TEMP declared in a FUNCTION_BLOCK belongs to the FB body’s call frame. A METHOD has its own stack frame, so it cannot share that temporary.

Erroneous code example:

FUNCTION_BLOCK Bug
VAR_TEMP
    scratch : BOOL;
END_VAR
METHOD PUBLIC DoIt
    scratch := TRUE; // ❌ scratch is not visible here
END_METHOD
END_FUNCTION_BLOCK

To fix, either declare the variable as a member of the FUNCTION_BLOCK (so it becomes part of the instance and is visible to the method), or declare a VAR_TEMP local to the method itself:

FUNCTION_BLOCK Bug
VAR
    scratch : BOOL; // ✅ instance member, visible to methods
END_VAR
METHOD PUBLIC DoIt
    scratch := TRUE;
END_METHOD
END_FUNCTION_BLOCK
FUNCTION_BLOCK Bug
METHOD PUBLIC DoIt
    VAR_TEMP
        scratch : BOOL; // ✅ method-local temporary
    END_VAR
    scratch := TRUE;
END_METHOD
END_FUNCTION_BLOCK

Reserved keyword used as a name

A reserved keyword was used in a position that expects a user-defined name (variable, parameter, POU, type alias, enum variant, struct field, …). Reserved keywords carry syntactic meaning and cannot double as identifiers.

Erroneous code example:

FUNCTION main
    VAR
        retain : DINT; // `RETAIN` is reserved
    END_VAR
END_FUNCTION

Rename the variable to something that is not a reserved keyword:

FUNCTION main
    VAR
        is_retained : DINT;
    END_VAR
END_FUNCTION

The same restriction applies to POU names, parameter names, type aliases, enum variants, struct fields, generic parameters, method names, and property names.

Note that VAR RETAIN ... END_VAR is still valid — there RETAIN is the variable-block modifier, not a variable name.

Linker invocation failed

plc could not spawn the linker subprocess, or the OS rejected the assembled command line before the linker had a chance to run.

The diagnostic body includes the linker that was selected, the number of arguments, the assembled command-line length in bytes, and the longest single argument (and its value). These numbers usually point at the cause.

Common causes:

  • Windows CreateProcess command-line limit (32,767 bytes; 8,191 if the spawn went through cmd.exe). Visible as os error 206. plc already routes long command lines through a @response_file to avoid this, but the response file can itself fail if the temp directory is unwritable — consult any preceding warn log lines.
  • Windows MAX_PATH (260 chars) on a single path argument without the \\?\ prefix. Also surfaces as os error 206. Shorten paths or relocate the build closer to the drive root.
  • Linker binary not on PATH / not executable. os error 2 or os error 13. Check which <linker> or pass --linker=<path>.
  • Insufficient permissions on the temp directory used for the response file. Set TMPDIR (Linux/macOS) or TEMP (Windows) to a writable path.

If none of the above apply the error string returned by the OS is reproduced verbatim and is the primary clue.

:= Used for an Output Parameter

In a call’s named argument list, := and => are direction-keyed: := writes a value into an INPUT (or IN_OUT) parameter, while => captures a value out of an OUTPUT parameter into a caller variable. Using := for an OUTPUT parameter is a typo for => and silently produces broken code.

Erroneous code example:

FUNCTION_BLOCK fb
VAR_INPUT  in_val  : DINT; END_VAR
VAR_OUTPUT out_val : DINT; END_VAR
    out_val := in_val + 1;
END_FUNCTION_BLOCK

PROGRAM main
VAR
    instance : fb;
    captured : DINT;
END_VAR
    instance(in_val := 5, out_val := captured);  // invalid: '=>' expected for an output parameter
END_PROGRAM

To fix, use => for the output parameter:

PROGRAM main
VAR
    instance : fb;
    captured : DINT;
END_VAR
    instance(in_val := 5, out_val => captured);  // correct
END_PROGRAM

Member access on a non-auto-deref pointer base

Member access (base.member) requires base to be a struct, function-block, or class instance — not a pointer to one. The compiler will not implicitly dereference pointers when they appear on the left of a . (auto-dereferencing applies only to REFERENCE TO / alias pointers).

This rule covers three cases that share the same underlying shape — the value of base is a pointer, not the pointee:

  1. THIS is POINTER TO <enclosing FUNCTION_BLOCK>. Use THIS^.member.
  2. SUPER is POINTER TO <parent POU> (before dereferencing). Use SUPER^.member.
  3. A user-declared POINTER TO ... variable. Use <pointer>^.member.

Example of invalid use

FUNCTION_BLOCK fb
    VAR
        a : DINT;
    END_VAR
END_FUNCTION_BLOCK

FUNCTION_BLOCK other
    VAR
        p_fb : POINTER TO fb;
    END_VAR
    METHOD m : DINT
        p_fb.a := 1;   // Error: Cannot access `a` on `POINTER TO fb`
        THIS.b := 2;   // Error: Cannot access `b` on `POINTER TO other` (THIS is a pointer)
    END_METHOD
END_FUNCTION_BLOCK

Example of valid use

FUNCTION_BLOCK other
    VAR
        p_fb : POINTER TO fb;
        b    : DINT;
    END_VAR
    METHOD m : DINT
        p_fb^.a := 1;   // Dereference the pointer first
        THIS^.b := 2;   // Dereference THIS first
    END_METHOD
END_FUNCTION_BLOCK

Undefined CFC jump target

A JMP element in a CFC network refers to a label that no LABEL element defines. The jump has nowhere to land. Add the matching label or correct the jump’s target.

Unused CFC label

A LABEL element in a CFC network is not the target of any JMP. It has no effect on control flow. Remove the label or wire a jump to it.

Duplicate CFC label

Two LABEL elements in a CFC network share the same name. A jump to that name would be ambiguous, since it could land on either one. Give each label a distinct name.

Disconnected CFC jump

A JMP element in a CFC network is not wired to a condition. A conditional jump only fires when its incoming value is true, so a jump with nothing connected can never be taken and is almost certainly a mistake.

Unknown CFC block type

A block element in a CFC network calls a POU the project does not declare. The call cannot be classified (a function is lowered differently than a program or function block), so the block is rejected.

Undeclared CFC block output

A block element in a CFC network exposes an output pin the called POU does not declare. This usually means the diagram is stale: the callee’s signature changed after the block was placed.

E148 - Temporal literal overflow or underflow

This warning is emitted when a temporal literal cannot be represented safely.

Covered temporal literals include DATE, DT, TOD, TIME and their long variants.

For short temporal types (DATE/DT/TOD/TIME), values outside the 32-bit runtime storage domain trigger this warning. For long temporal types, out-of-range literal encodings also trigger this warning.

Unresolved generic CFC block output

A generic block like ADD<T1: ANY_NUM, T2: ANY_NUM> : T1 — where IN1 binds T1, the remaining variadic inputs bind T2, and the output is typed T1 — has no types of its own: every call must resolve the bindings to concrete types, and in a CFC network they are inferred from the block’s wired inputs. Feeding generic outputs back into generic inputs is ambiguous: each side waits on the other and the binding could be anything, so nothing ever decides it.

        ADD
      +---------------+
 .--> | IN1       ADD | --+--> acc
 +--> |               |   |
 |    +---------------+   |
 '------------------------'   [output feeds back into every input]

Wire at least one input to a concretely typed source — a variable, a literal, or the output of a non-generic block:

        ADD
      +---------------+
x --> | IN1       ADD | --+--> acc     [x : DINT decides T1 = DINT]
 .--> |               |   |
 |    +---------------+   |
 '------------------------'

Routing a feedback loop through a declared variable also resolves it: the variable’s declared type decides the generic binding.

E150 - ABS on a value of unsigned type has no effect

ABS returns the absolute value of its argument. A value of an unsigned type cannot be negative, so ABS returns the argument unchanged. Such a call usually points at a mistake, for example an argument expression that already underflowed:

FUNCTION delta : UINT
VAR_INPUT
    a, b : UINT;
END_VAR
    // WRONG: a - b underflows for a < b, ABS does not correct the result
    delta := ABS(a - b);
END_FUNCTION

If the distance between two unsigned values is needed, compare them first:

IF a >= b THEN
    delta := a - b;
ELSE
    delta := b - a;
END_IF

Unary NOT Used With an Unsupported Operand Type

The NOT operator is valid for boolean, integer and bit-oriented operands. Other types, such as structs, arrays and strings, are not supported.

Erroneous code example:

TYPE Wrapper : STRUCT
    out : BOOL;
END_STRUCT
END_TYPE

PROGRAM mainProg
VAR
    value : Wrapper;
END_VAR
    value := NOT value;
END_PROGRAM

To fix this, apply NOT to the actual scalar field instead:

TYPE Wrapper : STRUCT
    out : BOOL;
END_STRUCT
END_TYPE

PROGRAM mainProg
VAR
    value : Wrapper;
END_VAR
    value.out := NOT value.out;
END_PROGRAM

Unconnected CFC EN pin

A block in a CFC network declares execution control but its EN pin carries no connection. Without a wired enable the guard can never be decided, so the call is rejected; unlike an unwired data input there is no sensible default.

CFC ENO cycle

An ENO pin mirrors its block’s EN value, so a wire reading ENO resolves through the block to the EN source. When the EN pins of two or more blocks are fed only by each other’s ENO pins, the wire never reaches a real source and the blocks are rejected.

Negated CFC reference assignment

A data sink with the REF= storage mode stores the address of its source, and an address has no negation. A negation bubble anywhere on the wire, on the source, on the sink, or on a block output pin feeding it, is rejected.

Duplicate CFC return pin

A function block in a CFC network shows more than one return pin. A function returns at most one value, so only one output pin can carry it; every other output pin belongs to a declared VAR_OUTPUT variable and shows its name.

        myAdd
      +--------------------+
a --> | in1                | -->  the return pin, unnamed
b --> | in2                | -->  a second unnamed pin: rejected
      |       myAddDoubled | -->  a declared output
      +--------------------+

This usually means the diagram is stale: the callee’s signature changed after the block was placed. Re-export the diagram from the IDE, or delete the block and place it again, so the block matches the callee’s current signature.

Technical details

In the exported CFC file, an output pin is an OutputVariable element. The return pin is the one with an empty parameterName; declared outputs always carry their parameter name. The block is rejected when more than one output pin has an empty name:

<ppx:OutputVariables>
    <ppx:OutputVariable parameterName="">           <!-- the return pin -->
    <ppx:OutputVariable parameterName="">           <!-- a second one: rejected -->
    <ppx:OutputVariable parameterName="doubled">    <!-- a declared output -->
</ppx:OutputVariables>

Overview

RuSTy compiles IEC 61131-3 Structured Text into machine code, with LLVM as its back end. This book explains how the compiler works, from source text to linked binary.

The compiler is a pipeline of stages. Each stage takes the result of the previous one and adds to it.

flowchart LR
    parse[Parse] --> index[Index] --> annotate[Annotate] --> validate[Validate] --> codegen[Codegen] --> link[Link]
    index -. participants .-> annotate
    validate --> outputs[Headers, hardware map]

The book has four parts:

  1. Pipeline describes each stage, in execution order. The driver runs the stages. The parser builds one syntax tree per file, the index collects all declarations, the resolver annotates every expression with its type, validation checks the language rules, codegen writes LLVM IR, and the linker joins the object files. Start here if you are new to the compiler.
  2. Participants describes the rewrites that run between stages. A participant hooks in before or after a stage and simplifies the syntax tree, so later stages see simpler code. Examples are loops, properties, inheritance, generics, and initializers. Each chapter shows the tree before and after the rewrite.
  3. Outputs describes the results other than machine code. The header generator writes the declarations of a project as C headers. The hardware map lists the variables bound to hardware addresses as a JSON or TOML file.
  4. Internals follows one language construct at a time (POUs, structs, arrays, strings, enums, references, initializers) from declaration to LLVM IR. The last chapter is a reference of every annotation the resolver can attach to a node.

Four flags stop the compiler after a stage and print what it has, which is how most of the examples in these chapters were checked. --ast prints the tree after parsing, --ast-lowered the tree after the last participant, --ir the generated LLVM IR, and --check stops after validation and prints the diagnostics alone.

The IR in these chapters comes from the target aarch64-unknown-linux-gnu, and the blocks are trimmed: the complete module starts with a target datalayout and a target triple line. Two details follow the target. On x86_64, a parameter or a return value narrower than 32 bits carries a signext or a zeroext attribute at the declaration and at each call, and the stack slot of such a value gets its natural alignment instead of align 4. The layout of each type, the order of the instructions, and every index are the same on both targets.

Pipeline

After these chapters you know what each stage of the compiler does, and why it needs the one before it.

flowchart LR
    parse[Parse] --> index[Index] --> annotate[Annotate] --> validate[Validate] --> codegen[Codegen] --> link[Link]

The subchapters follow that order. The driver reads the command line and the project file and runs everything; the lexer and parser build one syntax tree per file; the index collects the declarations of every tree into one symbol table; the resolver annotates each expression with what it is and what it must become; validation turns the index and the annotations into diagnostics, where an error stops the run; codegen writes one LLVM module and object file per unit; and the linker joins those objects with the libraries into the artifact.

Read them from the top to follow one compilation from source to binary. Each of them closes with a table that names the crates and modules of its stage.

Driver

The driver loads a project, runs the pipeline, and sends generated objects to the linker. This chapter explains the inputs and execution order.

Project inputs

A project comes from a build description (plc.json) or from the positional arguments of a plc call. Its inputs fall into four groups:

  1. Sources are parsed with internal linkage and compiled.
  2. Includes (from -i or from library headers) are parsed with include linkage: their declarations are indexed, but no code is generated for them.
  3. Objects are not parsed and go directly to the linker.
  4. Libraries contribute headers as includes, and names and paths as link options.

Pipeline

flowchart LR
    parse[Parse] --> index[Index] --> annotate[Annotate] --> validate[Validate] --> codegen[Codegen] --> link[Link]

The result of one stage is the input of the next:

StageResult
ParseOne compilation unit (AST) per source file, include file, and library header
IndexThe units plus the global symbol table
AnnotateThe units, the symbol table, and the annotation map with the resolved type of every expression
ValidateNo new data; aborts the run if any diagnostic reached error severity
CodegenOne LLVM module per unit, written to object files
LinkThe final artifact: executable, shared object, relocatable object, LLVM IR, or bitcode

Before the pipeline runs, the driver loads the source files into memory and selects the diagnostic renderer (--error-format) and the linker (--linker). Later stages use the loaded copies. Parsing is sequential. Indexing, annotation, and codegen process units in parallel, with the thread count set by --threads.

By default, codegen creates one LLVM module and object file per unit. The output path mirrors the source path under the build location. With --single-module or -c, the units are merged into one module, one after the other. The output format then decides the final step: merge IR or bitcode, copy a single object, or run the linker.

Participants

The stages do not run back to back. At fixed points called hooks, the driver stops and hands the current result to the registered participants. They use these hooks to lower language features by rewriting the AST. A participant can read the project or return a rewritten one.

flowchart LR
    parse[Parse] -- pre_index --> index[Index] -- post_index, pre_annotate --> annotate[Annotate] -- post_annotate --> validate[Validate] -- pre_generate --> codegen[Codegen] -- post_generate --> link[Link]
    annotate -. rewrite .-> index

The solid edges name the hooks. Indexing, annotation, and code generation each have one hook before them and one after; parsing, validation, and linking have none. A participant implements only the hooks it needs. The dashed edge shows that a rewrite makes the index and the annotations stale. Each participant that rewrites the tree runs the affected stages again before it returns.

There are two kinds of participants:

  • Mutating participants take the project by value and return a new one. They are the lowerers, and they can use the four hooks around indexing and annotation. The diagnostics they collect while they rewrite are gathered after the last post_annotate hook and reported with the validation diagnostics.
  • Read-only participants get a shared reference and cannot change the project. They see all six hooks, plus one call per generated module. The only one by default is the codegen participant, which writes the modules to disk and links them.

The driver registers twelve mutating participants. Hook order determines when they run; registration order determines their order within a hook. Later participants can depend on earlier rewrites. The Participants chapters follow the registration order.

Note

Developer note. The participant model started small. It gave a simple way to lower inheritance without a dedicated intermediate representation, and thus without a large architectural change. But the number of participants grew quickly, and today they are technical debt. Other compilers do not lower in the syntax tree, and the reasons are visible here:

  • Order dependence. All participants change one tree, and each one can depend on the rewrites before it. A different registration order can change the behavior of the program.
  • Generated nodes. Lowering adds constructors, normalized loops, and result parameters. Validation, diagnostics, and debug information must distinguish these nodes from source constructs through locations and metadata.
  • Repeated analysis. A rewrite can make a new index or a new annotation map necessary for the whole project. A plain build runs both stages nine times each; generic calls add more rounds.
  • One tree, two forms. The AST must hold both source constructs and their lowered forms. Each stage must know which form it gets.
  • No fixed boundary. Codegen uses the combined result of all participants, not a separate representation with a stable contract.

A dedicated intermediate representation (IR) would have been the better fit. Replacing the model today is a large refactor.

Where it lives

The driver is the plc_driver crate under compiler/ and produces the plc binary.

WhatWhere
Command linecompiler/plc_driver/src/cli.rs
Stages and hookscompiler/plc_driver/src/pipelines.rs, compiler/plc_driver/src/pipelines/
Participantscompiler/plc_lowering, compiler/plc_cfc, src/lowering/
Project modelcompiler/plc_project

What’s next

The driver has loaded the source files. The Lexer and Parser now turn their text into the tree used by later stages.

Lexer and Parser

Parsing turns source text into an abstract syntax tree (AST). The tree records declarations, statement nesting, and operator precedence. Later stages use this structure to interpret the program.

flowchart LR
    parse[Parse] --> index[Index] --> annotate[Annotate] --> validate[Validate] --> codegen[Codegen] --> link[Link]
    style parse fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a

The stage has two halves: the lexer cuts the character stream into tokens, and the parser builds the tree from them. The driver calls the pair once per source file, once per include file, and once for the built-in declarations that ship with the compiler. Every call produces one compilation unit, the tree of one file tagged with its linkage: internal for project sources, include for headers, built-in for the compiler’s own declarations.

Lexer

The lexer groups characters into tokens. In foo := 1, foo is an identifier, := an assignment token, and 1 an integer literal. Each token records its kind, text, and byte range. The parser uses these tokens without having to recognize names, whitespace, or operators itself.

A table defines each token kind by a keyword or character pattern. Keywords such as FUNCTION are case-insensitive. At each position, the lexer selects the longest match. Thus := becomes one assignment token.

Whitespace, comments ((* *), /* */, //), and unknown pragmas in braces are matched and dropped, so the parser never sees them. The few pragmas the compiler understands ({external}, {ref}, {constant}, {sized}) are token kinds of their own.

The lexer is also where the parser’s cursor lives. The parser holds a session that owns the lexer, the current token, the previous token, and a stack of closing keywords for error recovery. A step of the cursor pulls the next token and checks a few lexical rules on the way, for example that END_IF is written with an underscore.

For this file:

FUNCTION compute: DINT
    compute := 1 + 2 * scale(bar, 3);
END_FUNCTION

the lexer emits:

TokenRangeText
KeywordFunction0..8FUNCTION
Identifier9..16compute
KeywordColon16..17:
Identifier18..22DINT
Identifier27..34compute
KeywordAssignment35..37:=
LiteralInteger38..391
OperatorPlus40..41+
LiteralInteger42..432
OperatorMultiplication44..45*
Identifier46..51scale
KeywordParensOpen51..52(
Identifier52..55bar
KeywordComma55..56,
LiteralInteger57..583
KeywordParensClose58..59)
KeywordSemicolon59..60;
KeywordEndFunction61..73END_FUNCTION
End74..74

Ranges are byte offsets into the file; the gap between DINT and the second compute is the newline and the indentation. DINT is an identifier, not a keyword, because type names are resolved later. An offset becomes a line and a column only when a node’s location is created, through a table of newline offsets that the session builds once per file.

Parser

The parser builds a compilation unit through recursive descent: a parsing function calls other parsing functions for the constructs it contains. A POU declaration can contain a variable block, which contains a variable declaration, which contains a type. The call stack follows this nesting.

The top-level parser dispatches on the current token. POU keywords start POU declarations. TYPE, VAR_GLOBAL, VAR_CONFIG, and INTERFACE start their corresponding declaration blocks. ACTIONS and ACTION start action bodies. A pragma marks the construct that follows: {external} sets its linkage, {constant} marks it as constant. Unexpected tokens are reported and skipped.

A POU becomes two separate things: the declaration (name, kind, return type, variable blocks, methods, properties) and the implementation (the statement list of the body). Later stages treat them as different objects. In pseudocode, the top level is:

loop {
    match token {
        Program | Function | FunctionBlock | Class => parse_pou(),
        Type                                       => parse_type(),
        VarGlobal                                  => parse_variable_block(),
        VarConfig                                  => parse_config_variables(),
        Interface                                  => parse_interface(),
        Actions                                    => parse_actions(),
        Action                                     => parse_action(),
        External | Constant                        => tag_next_construct(),
        EndActions | End                           => return unit,

        other => {
            report("Unexpected token: expected StartKeyword but found {other}");
            advance();
        }
    }
}

Expressions get one function per precedence level instead of one per construct. The chain runs from the loosest binding to the tightest: expression list, range, OR, XOR, AND, equality, comparison, addition, multiplication, exponent, unary, and last the leaf (a literal, a reference, a call, or a parenthesized expression). Each level parses its left operand with a call to the next tighter level, then loops while it sees one of its own operators.

This is why 1 + 2 * scale(bar, 3) becomes an addition whose right side is a multiplication: the addition level hands control down to multiplication, which consumes 2 * scale(...) as a whole before it returns. A parenthesized leaf calls back to the top of the chain and closes the recursion.

Every node gets a source location and a unique ID from a counter shared by all files in the run. Later stages use the ID to attach information without changing the node. In foo := bar + 5, child nodes are created before their parents:

Assignment {                                    // id: 7
    left: ReferenceExpr {                       // id: 2
        Member "foo"                            // id: 1
    },
    right: BinaryExpression {                   // id: 6
        operator: Plus,
        left: ReferenceExpr {                   // id: 4
            Member "bar"                        // id: 3
        },
        right: LiteralInteger 5,                // id: 5
    },
}

Output

For this function:

FUNCTION compute: DINT
VAR_INPUT
    bar: DINT;
END_VAR
VAR
    foo: DINT;
END_VAR
    foo := 1 + 2 * scale(bar, 3);
    compute := foo;
END_FUNCTION

the parser functions are called in this order and nesting. Each line names the function and the token under the cursor when it is entered. The stack is trimmed: a function that only hands the call down to the next level is not shown.

                                                       Parsing "FUNCTION compute: DINT"
parse_pou                                              at "FUNCTION"
  parse_return_type                                    at ":"
    parse_data_type_definition                         at "DINT"

                                                       Parsing "VAR_INPUT bar: DINT; END_VAR"
  parse_variable_block                                 at "VAR_INPUT"
    parse_variable_line                                at "bar"
      parse_data_type_definition                       at "DINT"

                                                       Parsing "VAR foo: DINT; END_VAR"
  parse_variable_block                                 at "VAR"
    parse_variable_line                                at "foo"
      parse_data_type_definition                       at "DINT"

                                                       Parsing "foo := 1 + 2 * scale(bar, 3);"
  parse_implementation                                 at "foo"
    parse_statement                                    at "foo"
      parse_expression                                 at "foo"
        parse_or_expression                            at "foo"
          ... one call per precedence level ...
            parse_multiplication_expression            at "foo"
              parse_unary_expression                   at "foo"
                parse_leaf_expression                  at "foo"    // consumes foo, sees ":=", parses the right side
                  parse_additive_expression            at "1"
                    parse_multiplication_expression    at "1"      // left operand of "+", returns after "1"
                    parse_multiplication_expression    at "2"      // right operand of "+", consumes "2 * scale(bar, 3)"
                      parse_unary_expression           at "2"
                      parse_unary_expression           at "scale"
                        parse_call_statement           at "scale"
                          parse_expression_list        at "bar"

                                                       Parsing "compute := foo;"
    parse_statement                                    at "compute"
      ...

and produces this compilation unit (locations and IDs omitted):

CompilationUnit {
    pous: [
        POU {
            name: "compute",
            pou_type: Function,
            return_type: DataTypeReference "DINT",
            variable_blocks: [
                VariableBlock { variable_block_type: Input(ByVal), variables: [ bar: DINT ] },
                VariableBlock { variable_block_type: Local,        variables: [ foo: DINT ] },
            ],
        },
    ],
    implementations: [
        Implementation {
            name: "compute",
            statements: [
                Assignment {
                    left:  ReferenceExpr { Member "foo" },
                    right: BinaryExpression {
                        operator: Plus,
                        left:  LiteralInteger 1,
                        right: BinaryExpression {
                            operator: Multiplication,
                            left:  LiteralInteger 2,
                            right: CallStatement {
                                operator: ReferenceExpr { Member "scale" },
                                parameters: ExpressionList [ ReferenceExpr { Member "bar" }, LiteralInteger 3 ],
                            },
                        },
                    },
                },
                Assignment {
                    left:  ReferenceExpr { Member "compute" },
                    right: ReferenceExpr { Member "foo" },
                },
            ],
        },
    ],
    user_types: [],
    global_vars: [],
    linkage: Internal,
}

plc --ast <file> prints this tree and stops before any later stage runs. The dump has more fields than the example above, but it does not print the ID or the location of a statement.

Graphical sources in XML (CFC, Continuous Function Chart) are not handled here. A separate crate reads the XML and produces the same compilation unit type, so from the index stage on both kinds of source look alike.

Note

The unit stores compute twice: its declaration in pous and its body in implementations. The split exists because of actions. An action is a body that belongs to a POU, but the source can place it outside the POU, in an ACTIONS block. For

FUNCTION_BLOCK Counter
    VAR
        count: DINT;
    END_VAR

    count := count + 1;
END_FUNCTION_BLOCK

ACTIONS Counter
    ACTION reset
        count := 0;
    END_ACTION
END_ACTIONS

the unit holds one POU, Counter, and two implementations, Counter and Counter.reset. Both bodies use the variables of Counter. One declaration owns several bodies, and each body is stored the same way, regardless of where it appears in the source. Two lists model this directly.

Error handling

The parser does not stop at the first error. It collects the diagnostics in the session and continues, so that one run reports as many problems as possible.

Recovery works on regions. When a function starts a construct with a known end, such as a variable block that ends with END_VAR or a parenthesized expression that ends with ), it pushes the closing tokens on the session’s stack. If parsing inside the region fails, the parser skips tokens until it finds one that closes the current region or an outer one, reports what it skipped, and continues after the region. A missing operand becomes an empty statement node, so the shape of the tree stays valid. For

PROGRAM main
    VAR
        i: DINT
        text: STRING;
    END_VAR

    i := 1;
END_PROGRAM

FUNCTION scale: DINT
    scale := 2 *;
END_FUNCTION

the parser expects a semicolon after i: DINT and finds text: STRING instead. It reports the tokens it skips, continues with the body of main, and therefore also finds the missing operand in scale. One run reports both problems, here in the one-line format of --error-format=clang:

broken.st:4:9:{4:9-4:21}: error[E007]: Unexpected token: expected KeywordSemicolon but found 'text: STRING'
broken.st:11:17:{11:17-11:18}: error[E007]: Unexpected token: expected expression but found ;
error: Compilation aborted due to critical parse errors
Unexpected token: expected KeywordSemicolon but found 'text: STRING' at: broken.st:3:8:{3:8-3:20}:
Unexpected token: expected expression but found ; at: broken.st:10:16:{10:16-10:17}:

After a file is parsed, its diagnostics go to the diagnostician. If one of them has error severity, the stage aborts the whole run with “Compilation aborted due to critical parse errors”. The abort carries the diagnostics of the file, which the last two lines print again. No unit reaches the index stage, not even the units of the files that parsed cleanly.

Where it lives

WhatWhere
Lexercompiler/plc_lexer
Parsersrc/parser.rs, src/parser/, compiler/plc_parser
ASTcompiler/plc_ast
CFCcompiler/plc_cfc

What’s next

The tree records syntax, but scale and bar are not yet connected to declarations. The Index collects those declarations into one symbol table. It also gives inline types such as STRING[80] names that later stages can look up.

Index

The index is the project’s symbol table. It records declared names, their kinds, and their types. The indexer reads declaration sections and records implementations, but does not resolve expressions in their bodies. For

FUNCTION_BLOCK Buffer
    // Declarations: indexed, they introduce Buffer.limit and Buffer.values
    VAR_INPUT
        limit: INT;
    END_VAR
    VAR
        values: ARRAY[1..5] OF DINT;
    END_VAR
END_FUNCTION_BLOCK

PROGRAM main
    // Declarations: indexed, they introduce main.bufferInstance and main.i
    VAR
        bufferInstance: Buffer;
        i: DINT;
    END_VAR

    // Body: skipped, it only references names defined in declarations elsewhere
    bufferInstance.values[i] := bufferInstance.limit;
END_PROGRAM

the index records:

  • Buffer is a function block.
  • Buffer.limit is an input variable of type INT.
  • Buffer.values is a local variable of an array type.
  • main is a program.
  • main.bufferInstance is a local variable of type Buffer.
  • main.i is a local variable of type DINT.

The declarations can be in different files. The index combines them so that later stages can look up names across the project.

flowchart LR
    parse[Parse] --> index[Index] --> annotate[Annotate] --> validate[Validate] --> codegen[Codegen] --> link[Link]
    style index fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a

The stage receives the parsed compilation units and returns them with one global index. Each unit is pre-processed and indexed into a table of its own, the tables are merged with the built-in declarations, and the constant expressions collected on the way are evaluated.

Pre-processing

Index entries refer to types by name. For limit: INT, the variable entry stores "INT"; finding that type requires another lookup. An inline type such as ARRAY[1..5] OF DINT has no name yet.

Pre-processing fixes this before indexing starts. It walks each unit, turns every anonymous type into a named type declaration, and replaces the inline definition with a reference to the new name. The name is built from the container and the member; for a return type it is the function name and return:

+TYPE
+    __describe_return: STRING[80];
+    __describe_values: ARRAY[1..5] OF DINT;
+END_TYPE
+
-FUNCTION describe: STRING[80]
+FUNCTION describe: __describe_return
     VAR_INPUT
-        values: ARRAY[1..5] OF DINT;
+        values: __describe_values;
     END_VAR
 END_FUNCTION

The same happens to pointers (REF_TO INT) and to the type parameters of generic functions (__ADD__T for parameter T of ADD).

Pre-processing also gives enum variants explicit values. TYPE Speed: (Slow, Normal, Fast := 10); END_TYPE becomes Slow := 0, Normal := Speed#Slow + 1, Fast := 10.

For a hardware binding such as sensor AT %IX0.0: BOOL, it creates the global __PI_0_0. The variable sensor becomes an alias pointer to that global, with the generated type __global_sensor. The Hardware Map chapter follows this connection.

Generated helper names often use a double underscore prefix. This is a naming convention, not an enforced restriction. The pre_index participants run before pre-processing, so some generated declarations already exist at this point.

The index

This is what the index holds, trimmed to its fields:

pub struct Index {
    /// Variables declared in VAR_GLOBAL blocks
    global_variables: SymbolMap<String, VariableIndexEntry>,

    /// Generated globals holding the default value of a struct, array, string, or POU instance
    global_initializers: SymbolMap<String, VariableIndexEntry>,

    /// Enum variants, keyed by the variant name alone
    enum_global_variables: SymbolMap<String, VariableIndexEntry>,

    /// Programs, functions, function blocks, classes, methods, and actions
    pous: SymbolMap<String, PouIndexEntry>,

    /// Interfaces
    interfaces: SymbolMap<String, InterfaceIndexEntry>,

    /// Properties, keyed by the POU that declares them
    properties: SymbolMap<String, Identifier>,

    /// Bodies, keyed by call name
    implementations: FxIndexMap<String, ImplementationIndexEntry>,

    /// Types: built-in, user-declared, created by pre-processing, and the instance struct of every POU
    type_index: TypeIndex,

    /// Initializers, array bounds, and string sizes, as expressions until evaluated
    constant_expressions: ConstExpressions,

    /// Size and alignment of the primitive types on the target
    data_layout: DataLayout,

    /// Jump labels, keyed by POU
    labels: FxIndexMap<String, SymbolMap<String, Label>>,

    /// VAR_CONFIG declarations
    config_variables: Vec<ConfigVariable>,
}

Structured Text is case-insensitive, so every key is lowercased on insert and on lookup: Buffer, buffer, and BUFFER find the same entry. Above the raw maps sit lookup helpers for the common questions of the later stages: the effective type behind an alias chain, a member of a container including its super classes, the parameters of a POU in call order.

Indexing a unit

The indexer walks the declarations of a unit and puts an entry into the maps above for each one. For

FUNCTION_BLOCK Counter
    VAR_INPUT
        step: DINT := 1;
    END_VAR
    VAR_OUTPUT
        count: DINT;
    END_VAR

    count := count + step;
END_FUNCTION_BLOCK

the indexer creates two member entries in declaration order: Counter.step and Counter.count. Both have type DINT; step is input 0 and count is output 1. The indexer stores the initializer 1 in the constant store and keeps its ID on step. It does not evaluate the expression yet.

With the declaration part done, the indexer knows the full shape of a Counter and registers it in three maps. The type index gets the instance struct, a type named Counter with the two entries as members in order; this struct is the memory layout of every instance. The POU map gets the entry that says Counter is a function block with that struct.

The global initializers get a variable __Counter__init of type Counter, a default instance to copy from (see the Initializers chapter).

The implementation entry records that Counter has a body. The resolver later connects count := count + step to the member entries.

Visualized, trimmed to the maps that received entries:

Index {
    pous: {
        "counter": FunctionBlock { name: "Counter", instance_struct_name: "Counter" },
    },
    type_index: {
        pou_types: {
            "counter": Struct {
                name: "Counter",
                members: [
                    { name: "step",  qualified_name: "Counter.step",  data_type_name: "DINT", argument_type: ByVal(Input),  location_in_parent: 0, initial_value: ConstId(0) },
                    { name: "count", qualified_name: "Counter.count", data_type_name: "DINT", argument_type: ByVal(Output), location_in_parent: 1, initial_value: None },
                ],
            },
        },
    },
    global_initializers: {
        "__counter__init": { name: "__Counter__init", data_type_name: "Counter" },
    },
    implementations: {
        "counter": { call_name: "Counter", type_name: "Counter", implementation_type: FunctionBlock },
    },
    constant_expressions: [
        ConstId(0): { expression: 1, target_type_name: "DINT", state: Unresolved },
    ],
}

The keys are the lowercased names. The member entries live inside the struct type; the index has no map of members of its own, and find_member("Counter", "step") looks up the type and searches its members.

The other declaration kinds follow the same walk with fewer stops. Global variables become entries in global_variables. A struct becomes a type in type_index with its members as variable entries, plus a global_initializers entry. An enum becomes a type and, in addition, one constant global per variant in enum_global_variables, so that Slow resolves without the qualifier Speed.Slow. Array bounds and string sizes go to constant_expressions like initializers.

The Internals chapters show how each language construct uses these records.

Merging

Units are indexed concurrently, one table each, and the tables are merged in unit order into one global index. Merging appends: a name declared in two files ends with two entries under one key, which validation later reports as a duplicate. Constant expressions are copied into the global constant store, receive new IDs, and the entries that hold an ID are updated.

Two more tables are merged in after the user’s units. The built-in types, BOOL, INT, DINT, REAL, STRING, TIME, and the rest, are constructed directly. The built-in functions, ADR, SIZEOF, MUX, SEL, the generic arithmetic and comparison functions, and the array bound functions, are Structured Text declarations embedded in the compiler. They are parsed, pre-processed, and indexed like any other unit, and merged last. A user function named add therefore shares a key with the built-in ADD and is a duplicate symbol.

Visualized for two files, showing only the POU map:

a.st      pous: { scale }
b.st      pous: { main, buffer }
built-in  pous: { adr, sizeof, mux, sel, ... }

merged    pous: { scale, main, buffer, adr, sizeof, mux, sel, ... }

Constant evaluation

Later stages need constant values for array bounds, string sizes, initializers, and enum variants. Evaluation runs after merging, when all declarations are available. In

VAR_GLOBAL CONSTANT
    SCALE_FACTOR: DINT := MAX_ITEMS + 1;
    MAX_ITEMS: DINT := 3;
    TOO_BIG: SINT := 300;
END_VAR

VAR_GLOBAL
    sensor AT %IX0.0: BOOL;
    NOT_CONST: DINT := 5;
END_VAR

VAR_GLOBAL CONSTANT
    C: DINT := NOT_CONST + 1;
END_VAR

the constant store holds one expression per initializer, plus the address __PI_0_0 that pre-processing gave sensor and one expression per segment of that address. The evaluator works through them as a queue and tries to fold each into a literal. An expression that depends on a name that is not resolved yet goes to the back of the queue; every other expression is marked resolved, or unresolvable with a reason. The first pass over the example:

  1. MAX_ITEMS + 1: MAX_ITEMS is not resolved yet, back of the queue.
  2. 3: already a literal, resolved.
  3. 300: a literal, but it initializes a SINT and does not fit; unresolvable, “This will overflow for type SINT”.
  4. __PI_0_0: an address, and addresses exist only after codegen has allocated the globals; unresolvable, “Try to re-resolve during codegen”, which codegen understands as an instruction.
  5. 5: resolved.
  6. NOT_CONST + 1: references a variable that is not declared constant; unresolvable, “NOT_CONST is no const reference”.

The queue now holds only MAX_ITEMS + 1. On the second pass MAX_ITEMS is known to be 3, and the expression resolves to 4. The loop stops when a full pass makes no progress.

Visualized:

VAR_GLOBAL CONSTANT
    SCALE_FACTOR: DINT := MAX_ITEMS + 1;
                          ^^^^^^^^^^^^^   { target_type_name: "DINT", Resolved(4) }
    MAX_ITEMS: DINT := 3;
                       ^                  { target_type_name: "DINT", Resolved(3) }
    TOO_BIG: SINT := 300;
                     ^^^                  { target_type_name: "SINT", Unresolvable("This will overflow for type SINT") }
END_VAR

VAR_GLOBAL
    sensor AT %IX0.0: BOOL;
              ^^^^^^                       { target_type_name: "__global_sensor", Unresolvable("Try to re-resolve during codegen") }
    NOT_CONST: DINT := 5;
                       ^                  { target_type_name: "DINT", Resolved(5) }
END_VAR

VAR_GLOBAL CONSTANT
    C: DINT := NOT_CONST + 1;
               ^^^^^^^^^^^^^              { target_type_name: "DINT", Unresolvable("NOT_CONST is no const reference") }
END_VAR

Validation turns the stored reasons into diagnostics: TOO_BIG becomes a warning, and C an error that aborts the compilation, because codegen has no value to write for it. As a last step, every enum without an explicit default gets one: the variant that evaluates to zero, or the first variant if none does.

Where it lives

WhatWhere
Pre-processingcompiler/plc_ast
Indexsrc/index.rs, src/index/
Constant evaluationsrc/resolver/
Typessrc/typesystem.rs
Built-inssrc/builtins.rs

What’s next

The index now holds the declarations, but body references still need meaning. The Resolver connects bufferInstance.values[i] and bufferInstance.limit to these entries and determines their types.

Resolver

After indexing, every declaration is known, but the statement bodies are still trees of names. In

PROGRAM main
    VAR
        i: DINT;
        sintVar: SINT;
    END_VAR

    sintVar := i + 1;
END_PROGRAM

the parser produced an assignment: the name sintVar on the left, the name i plus the literal 1 on the right. Nothing says that sintVar is the local variable main.sintVar, that i + 1 is a DINT, or that a SINT target needs a narrowing conversion. The resolver answers these questions for every expression of the project.

The driver calls this stage annotate. The resolver stores its results in an annotation map keyed by node ID. It can also attach replacement expressions without changing the original tree. Validation uses the map to check types; codegen uses it to select instructions and conversions.

flowchart LR
    parse[Parse] --> index[Index] --> annotate[Annotate] --> validate[Validate] --> codegen[Codegen] --> link[Link]
    style annotate fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a

The stage receives the units and the global index. Each unit is annotated concurrently by its own visitor, which returns an annotation map, the set of names the unit depends on, and the string literals it contains. The maps are merged into one, and the few types the visitors had to create are imported into the index. Like the index, the map is never updated in place: when a participant rewrites the tree, the stage runs again and replaces it.

Annotations and hints

The resolver distinguishes an expression’s annotation from its type hint. The annotation identifies its declaration or result type. The hint records the type expected where the expression is used. In sintVar := i + 1, the sum has type DINT and hint SINT. Codegen truncates the result, and validation warns about the implicit downcast.

Annotations distinguish variable, function, type, and POU references from plain expression values. Argument hints also identify matched parameters. Annotated AST describes each kind.

Trimmed to its fields, the annotation map looks like this:

pub struct AnnotationMapImpl {
    /// What each expression is, keyed by node id
    type_map: FxIndexMap<AstId, StatementAnnotation>,

    /// What each expression should become, keyed by node id
    type_hint_map: FxIndexMap<AstId, StatementAnnotation>,

    /// Range-check calls that codegen emits in place of an assigned value
    hidden_function_calls: FxIndexMap<AstId, AstNode>,

    /// Types created while annotating, such as sized string literal types
    pub new_index: Index,

    // ... other omitted fields
}

A failed name lookup leaves the reference without an annotation. Validation reports the unresolved reference. Some containers, such as named argument assignments, also have no annotation of their own; their children and hints carry the required information.

Walking a unit

The resolver walks each unit from top to bottom and visits every expression: the initializers in variable blocks, the bounds in type declarations, and the statements of the bodies. Each expression is visited with the POU it sits in as its context. For

FUNCTION_BLOCK Buffer
    VAR_INPUT
        limit: INT;
    END_VAR
END_FUNCTION_BLOCK

PROGRAM main
    VAR
        bufferInstance: Buffer;
        i: DINT := 1;
    END_VAR

    i := bufferInstance.limit;
END_PROGRAM

the resolver walks declarations first and bodies second: the variable blocks of every POU, then the body of every POU. Only expressions get entries, so Buffer, whose one variable has no initializer and whose body is empty, ends without a single entry.

In main, only i has an initializer, 1: an integer literal is a DINT value, and because it initializes a DINT variable it is also hinted DINT.

The body is one assignment, which the resolver visits value first, target second, and hints last. The value bufferInstance.limit is resolved left to right: bufferInstance is found as a member of main, the current POU, and its type Buffer becomes the qualifier under which limit is found as Buffer.limit of type INT. The whole reference takes the entry of its last part. The target i is found as main.i and gets no hint. Last, the value is hinted with the target’s type DINT, a widening that codegen emits later.

Visualized:

PROGRAM main
    VAR
        bufferInstance: Buffer;
        i: DINT := 1;
                   ^          { kind: Value, resulting_type: "DINT", hint: "DINT" }
    END_VAR

    i := bufferInstance.limit;
    ^                          { kind: Variable, qualified_name: "main.i",              resulting_type: "DINT",   hint: None }
         ^^^^^^^^^^^^^^        { kind: Variable, qualified_name: "main.bufferInstance", resulting_type: "Buffer", hint: None }
                        ^^^^^  { kind: Variable, qualified_name: "Buffer.limit",        resulting_type: "INT",    hint: None }
         ^^^^^^^^^^^^^^^^^^^^  { kind: Variable, qualified_name: "Buffer.limit",        resulting_type: "INT",    hint: "DINT" }
END_PROGRAM

Now imagine a global variable that is also named bufferInstance. The resolver tries a fixed order of lookups and takes the first that succeeds: a member of the current POU, then a global variable or enum variant, then a POU (program, function, or function block), then a type. The local member wins. The one exception is the operator of a call, which tries functions first: inside a function scale, the name scale is the return variable, but scale(...) is the function.

Promotion

Arithmetic and comparisons combine operands of different types. The resolver decides which type the operation runs in and marks the operands that have to be converted. In

PROGRAM main
    VAR
        sintVar: SINT;
        dintVar: DINT;
        boolVar: BOOL;
    END_VAR

    sintVar := dintVar + 1;
    boolVar := sintVar < dintVar;
END_PROGRAM

the addition uses DINT for both operands and for its result. The assignment then gives the sum the hint SINT. The operands are not narrowed: codegen performs the addition first and converts its result.

The comparison widens sintVar from SINT to DINT through a hint on that operand. Its result has type BOOL, which already matches the assignment target.

Visualized:

    sintVar := dintVar + 1;
    ^^^^^^^                             { kind: Variable, qualified_name: "main.sintVar", resulting_type: "SINT", hint: None }
               ^^^^^^^^^^^              { kind: Value,                                    resulting_type: "DINT", hint: "SINT" }
               ^^^^^^^                  { kind: Variable, qualified_name: "main.dintVar", resulting_type: "DINT", hint: None }
                         ^              { kind: Value,                                    resulting_type: "DINT", hint: None }

    boolVar := sintVar < dintVar;
    ^^^^^^^                             { kind: Variable, qualified_name: "main.boolVar", resulting_type: "BOOL", hint: None }
               ^^^^^^^^^^^^^^^^^        { kind: Value,                                    resulting_type: "BOOL", hint: "BOOL" }
               ^^^^^^^                  { kind: Variable, qualified_name: "main.sintVar", resulting_type: "SINT", hint: "DINT" }
                         ^^^^^^^        { kind: Variable, qualified_name: "main.dintVar", resulting_type: "DINT", hint: None }

Calls

In

FUNCTION scale: DINT
    VAR_INPUT
        value: DINT;
        factor: INT;
    END_VAR
END_FUNCTION

PROGRAM main
    VAR
        i: DINT;
        sintVar: SINT;
    END_VAR

    i := scale(i, factor := sintVar);
END_PROGRAM

the resolver visits the call before the assignment target. It identifies scale as a function returning DINT, then resolves the arguments. i refers to main.i. In factor := sintVar, the value refers to main.sintVar, but factor names the callee’s parameter scale.factor. That parameter has type INT, so sintVar gets the hint INT.

Then the arguments are matched to the parameters of scale. A positional argument takes the parameter at its place, a named argument the parameter with its name. i is matched to parameter 0 and hinted DINT, factor := sintVar to parameter 1 and hinted INT. These hints also record the parameter position, which codegen uses to place the values. The call as a whole takes the return type of the function, a DINT value, and the rest is the ordinary assignment: the target i is main.i, and the call gets the hint DINT.

Visualized:

    i := scale(i, factor := sintVar);
    ^                                 { kind: Variable, qualified_name: "main.i",       resulting_type: "DINT", hint: None }
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^  { kind: Value,                                    resulting_type: "DINT", hint: "DINT" }
         ^^^^^                        { kind: Function, qualified_name: "scale",        return_type: "DINT",    hint: None }
               ^                      { kind: Variable, qualified_name: "main.i",       resulting_type: "DINT", hint: Argument { resulting_type: "DINT", position: 0 } }
                  ^^^^^^^^^^^^^^^^^   { kind: None,                                                             hint: Argument { resulting_type: "INT", position: 1 } }
                  ^^^^^^              { kind: Variable, qualified_name: "scale.factor", resulting_type: "INT",  hint: None }
                            ^^^^^^^   { kind: Variable, qualified_name: "main.sintVar", resulting_type: "SINT", hint: "INT" }

The named argument factor := sintVar has no annotation of its own, only the hint that ties it to parameter 1; its two sides are annotated like any assignment.

For a function block call, the operator is a variable of the block’s type. Arguments match that block’s parameters, and the call has no result type. Built-ins such as REF, array bound functions, and generic arithmetic functions have special annotation rules because their types depend on the arguments.

Literals and generated types

Integer literals use DINT if they fit 32 bits and LINT otherwise. Real literals use REAL or LREAL by the same size rule. Typed literals use their prefix, as in INT#5. String literals get types sized to their contents: 'hello' has type __STRING_5, a STRING[5], even when it is assigned to a STRING that was declared without a size.

That sized type does not exist in the index, so the resolver registers it in a small index of its own. After all units are annotated, these generated types, together with the on-demand pointer types, are imported into the global index, where codegen finds them like any declared type. String literals found in bodies are also collected per unit, because codegen emits them as global constants. For

PROGRAM main
    VAR
        text: STRING := 'hello';
        large: LINT := 5_000_000_000;
        n: INT := INT#5;
    END_VAR
END_PROGRAM

Visualized:

        text: STRING := 'hello';
                        ^^^^^^^        { kind: Value, resulting_type: "__STRING_5", hint: "STRING" }
        large: LINT := 5_000_000_000;
                       ^^^^^^^^^^^^^   { kind: Value, resulting_type: "LINT",       hint: "LINT" }
        n: INT := INT#5;
                  ^^^^^                { kind: Value, resulting_type: "INT",        hint: "INT" }

Dependencies

While it annotates, the visitor records every type, variable, and callable the unit refers to. It follows a type into its members, an array into its element type, and a pointer into its target. Codegen uses the set to declare in a module only what that module needs, instead of every declaration of the project. For the unit of the walk example above, the set holds the data types main, Buffer, INT, and DINT, among others.

Where it lives

WhatWhere
Resolversrc/resolver.rs, src/resolver/
Built-inssrc/builtins.rs

What’s next

The resolver has recorded the names and types it could resolve, plus the required conversions. The Validation stage now checks missing references, incompatible types, and access rules.

Validation

After resolution, the index describes declarations and the annotation map describes expressions. Validation uses both to check language rules. In

PROGRAM main
    VAR
        sintVar: SINT;
        dintVar: DINT;
    END_VAR

    sintVar := dintVar;
    sintVar := unknown;
END_PROGRAM

the first assignment narrows a DINT to a SINT, which can lose data. The second uses an undeclared name. Validation reports a warning for the downcast and an error for the unknown reference.

flowchart LR
    parse[Parse] --> index[Index] --> annotate[Annotate] --> validate[Validate] --> codegen[Codegen] --> link[Link]
    style validate fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a

The stage receives the annotated project: the units after all lowering, the global index, and the annotation map. It produces no new data. Its only output is the list of diagnostics, which it hands to the diagnostician as it goes. If any diagnostic reached error severity, the run stops after the walk with “Compilation aborted due to critical errors”; otherwise the project goes on to codegen unchanged. plc --check runs the pipeline up to here and exits.

Two sources of facts

The index describes declarations, such as parameter lists, constant variables, and base types. The annotation map identifies expressions and their types. Some checks need both. A private-member check uses the annotation to identify the member and the index to determine whether the current POU may access it.

The validator carries both, with the name of the POU or type it is in, in a small context that travels down the walk:

pub struct ValidationContext<'s, T: AnnotationMap> {
    /// What every expression is and what it should become
    annotations: &'s T,

    /// What every name declares
    index: &'s Index,

    /// The POU or type whose declarations or body are being validated
    qualifier: Option<&'s str>,

    // ... other omitted fields
}

Every failed check produces a diagnostic. The content of a Diagnostic is a message, an error code, and where in the source it applies:

pub struct DiagnosticsInner {
    /// The description of the problem, as shown to the user
    pub message: String,

    /// Where the problem is
    pub primary_location: SourceLocation,

    /// Other places that take part in it, such as the second declaration of a duplicate name
    pub secondary_locations: Option<Vec<SourceLocation>>,

    /// The code that identifies the rule, such as E037
    pub error_code: &'static str,

    // ... other omitted fields
}

The validator only collects these. What a code means for the build is decided later (see Severity and reporting below).

Global validation

Some rules concern the project as a whole and cannot be checked file by file. For

(* a.st *)
FUNCTION scale: DINT
END_FUNCTION

(* b.st *)
FUNCTION scale: DINT
END_FUNCTION

neither file is wrong on its own; the conflict only exists in the merged index. Global validation therefore runs once, before any unit is walked, and reads only the index. It checks that:

  • Names are unique within their group: callables, types, and global variables. Every declaration of a duplicate is reported, with the others as secondary locations. Built-in names such as ADD count too.
  • Data structures are finite. A struct that contains itself by value, an alias chain that loops, or interfaces that extend each other are reported.
  • Template variables (AT %I*) are configured exactly once in a VAR_CONFIG block.
  • Overflowing constants, such as TOO_BIG: SINT := 300, are reported as a warning, with the reason the index stored.

Per-unit validation

Everything else is checked unit by unit, in the order in which the units were parsed. Inside a unit the walk follows the shape of the compilation unit: the POU declarations first, then the user types, the VAR_CONFIG blocks, the global variable blocks, the implementations, and last the interfaces. Declarations and bodies are validated separately, as they are stored.

POU checks depend on the kind. A program cannot return a value, and a class cannot have input or output parameters. Inheritance checks require existing base types and interfaces, matching method signatures, and implementations for abstract methods.

Variable checks cover declared types, initializer compatibility, constant array bounds, and names that shadow base members. They also check restrictions on constant function block instances. The statement visitor checks initializers because they are expressions.

An implementation is walked statement by statement. The visitor is recursive and mirrors the tree: it visits the children of a node first, then applies the checks for the node itself. A reference is checked for resolution, visibility, and pointer access. An assignment compares the type of the value with the hint the resolver attached to it. A call is matched against the parameters of the callee from the index: argument count, direction of := and =>, by-reference arguments, and required VAR_IN_OUT arguments. Control statements check their conditions and walk their bodies. For

FUNCTION_BLOCK Buffer
    VAR_INPUT
        limit: INT;
    END_VAR
    VAR_IN_OUT
        target: DINT;
    END_VAR
    VAR
        count: DINT;
    END_VAR
END_FUNCTION_BLOCK

ACTIONS Buffer
    ACTION reset
        count := 0;
    END_ACTION
END_ACTIONS

PROGRAM main
    VAR CONSTANT
        MAX: DINT := 10;
    END_VAR
    VAR
        bufferInstance: Buffer;
        i: DINT;
        text: STRING;
    END_VAR

    MAX := 11;
    text := i;
    bufferInstance(limit := 5);
    bufferInstance.count := 3;
    bufferInstance.reset;
END_PROGRAM

the body of main produces one diagnostic per statement, each from a different combination of the two sources:

    MAX := 11;
    ^^^                           E036  annotation: main.MAX is a constant variable
    text := i;
    ^^^^^^^^^                     E037  annotation: i is DINT, its hint is STRING; the index says the types are not compatible
    bufferInstance(limit := 5);
    ^^^^^^^^^^^^^^                E030  index: Buffer has the VAR_IN_OUT target, and no argument was matched to it
    bufferInstance.count := 3;
                   ^^^^^          E049  annotation: Buffer.count is a local variable; index: main is neither Buffer nor a child of it
    bufferInstance.reset;
                   ^^^^^          E095  annotation: the reference resolves to the action Buffer.reset, but the statement is not a call

Declarations with external or include linkage belong to another compilation and are skipped. Generated nodes with internal locations are trusted. Built-ins use placeholder parameter types, so general argument checks do not always apply. Built-ins such as ADR, REF, and SEL provide their own validation rules.

Severity and reporting

Every diagnostic carries an error code, and the code decides how serious it is. A registry maps each code to a default severity: E048 (unresolved reference) is an error, E067 (implicit downcast) is a warning, E060 (a hint to use direct access) is informational, and a few codes are ignored by default. plc explain E067 prints the description behind a code. The defaults can be overridden per project with a JSON file passed as --error-config:

{ "error": ["E067"], "ignore": ["E048"] }

With this file the downcast in the introduction aborts the compilation and the unknown name is not reported.

The validator reports diagnostics after global validation and after each unit. The diagnostician assigns severity, resolves source locations, and renders messages in the selected --error-format. The stage keeps the highest severity across batches and stops after the last unit if it is an error.

Validation in participants

Not every rule can wait for this stage. Some checks must run before lowering removes the original construct. Properties become methods, interface variables become pointer structs, and generic calls become concrete calls. The final validator cannot recover all original rules from those forms.

Those rules are checked by the participant itself, before it rewrites the tree. The participant keeps its diagnostics until the last post_annotate hook has run, and the stage handles them before global validation, so they appear at the top of the output.

Note

Developer note. Participants rewrite the source tree in place. Checks that need the original construct must run before that rewrite. The Driver chapter explains the hook order.

Where it lives

WhatWhere
Validatorsrc/validation.rs, src/validation/
Diagnosticscompiler/plc_diagnostics
Validate stepcompiler/plc_driver

What’s next

If no error stops the run, the lowered project proceeds to Codegen. Its expressions have types and conversion hints, but still need memory layouts and LLVM instructions.

Codegen

Codegen turns the validated and fully lowered tree into LLVM IR, the input of the LLVM back end that produces machine code. In

PROGRAM main
    VAR
        sintVar: SINT;
        dintVar: DINT;
    END_VAR

    sintVar := dintVar;
END_PROGRAM

the assignment is a node with two references, an annotation that says dintVar is a DINT, and a hint that says the value must become a SINT. A processor cannot run that. Codegen turns it into three instructions:

%load_dintVar = load i32, ptr %dintVar        ; read dintVar
%1 = trunc i32 %load_dintVar to i8            ; cut it to 8 bits, the hint says SINT
store i8 %1, ptr %sintVar                     ; write sintVar

Both names became addresses inside the program’s instance structure, and the hint became the truncation between the load and the store.

flowchart LR
    parse[Parse] --> index[Index] --> annotate[Annotate] --> validate[Validate] --> codegen[Codegen] --> link[Link]
    style codegen fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a

The stage receives the annotated project and produces one LLVM module per compilation unit, in parallel. Each module is built from the unit, the global index, the annotation map, and two things the resolver collected for this purpose: the set of names the unit depends on and the string literals it contains. The finished module is written as an object file, or as textual IR or bitcode with --ir or --bc, and handed to the Linker. With --single-module, and always with -c, all units are merged into one module first.

The following project connects the mechanisms in this chapter. Each section shows the relevant source operation and trimmed LLVM IR. plc --ir writes the complete module.

TYPE Speed: (Slow, Fast); END_TYPE

TYPE Point:
    STRUCT
        x: DINT;
        y: DINT;
    END_STRUCT
END_TYPE

VAR_GLOBAL
    counter: DINT := 7;
END_VAR

FUNCTION scale: DINT
    VAR_INPUT
        value: DINT;
        factor: INT;
    END_VAR
    VAR_IN_OUT
        total: DINT;
    END_VAR
    VAR_OUTPUT
        overflow: BOOL;
    END_VAR

    scale := value * factor;
    total := total + scale;
    overflow := scale > 100;
END_FUNCTION

FUNCTION_BLOCK Buffer
    VAR_INPUT
        limit: INT;
    END_VAR
    VAR
        count: DINT;
        values: ARRAY[0..3] OF DINT;
    END_VAR

    IF count < limit THEN
        values[count] := count;
        count := count + 1;
    END_IF
END_FUNCTION_BLOCK

PROGRAM main
    VAR
        bufferInstance: Buffer;
        i: DINT := 1;
        sintVar: SINT;
        text: STRING := 'hello';
        p: Point;
        speed: Speed;
        ptr: REF_TO DINT;
        flag: BOOL;
    END_VAR

    bufferInstance(limit := 5);
    i := scale(i, factor := 3, total := counter, overflow => flag);
    sintVar := i;
    text := 'world';
    p.x := i;
    ptr := REF(i);
    ptr^ := 2;
    CASE speed OF
        Slow: i := 0;
        Fast: i := 1;
    END_CASE
END_PROGRAM

Building a module

Codegen builds a module in dependency order: types, globals, function declarations, initializer and string constants, then function bodies. A global needs its type; a function body needs declarations for the functions it calls.

The global index describes declarations by name. A second index connects those names to LLVM types, values, and addresses. Each generation step adds entries for later steps:

pub struct LlvmTypedIndex<'ink> {
    /// Lookups that fail here continue in the parent index
    parent_index: Option<&'ink LlvmTypedIndex<'ink>>,

    /// Type name to LLVM type, for data types and for POU instance structs
    type_associations: FxHashMap<String, AnyTypeEnum<'ink>>,
    pou_type_associations: FxHashMap<String, AnyTypeEnum<'ink>>,

    /// Variable name to global variable
    global_values: FxHashMap<String, GlobalValue<'ink>>,

    /// Type or variable name to its constant initial value
    initial_value_associations: FxHashMap<String, BasicValueEnum<'ink>>,

    /// Qualified variable name to the address it lives at inside the current function
    loaded_variable_associations: FxHashMap<String, PointerValue<'ink>>,

    /// POU name to LLVM function
    implementations: FxHashMap<String, FunctionValue<'ink>>,

    /// String literal to the global constant that holds it
    utf08_literals: FxHashMap<String, GlobalValue<'ink>>,
    utf16_literals: FxHashMap<String, GlobalValue<'ink>>,

    // ... other omitted fields
}

The module index holds types, globals, and functions. Each function body has a child index for its variable addresses. A lookup continues in the parent when the child has no entry. Keys are case-insensitive, as in the global index.

Data types

Every type the unit depends on becomes an LLVM type:

Structured TextLLVM
BOOL, SINT, USINT, BYTEi8 (comparisons produce i1 and are widened)
INT, UINT, WORDi16
DINT, UDINT, DWORD, TIME, DATEi32
LINT, ULINT, LWORD, LTIME, LDATEi64
REAL, LREALfloat, double
STRING[n], WSTRING[n][n+1 x i8], [n+1 x i16], one slot for the terminator
ARRAY[a..b, c..d] OF T[len x T], all dimensions flattened into one length
STRUCT, FUNCTION_BLOCK, PROGRAM, CLASSa named struct with one field per member, in declaration order
POINTER TO, REF_TO, REFERENCE TOptr
enum, subrange, aliasthe LLVM type of the underlying type

For the example, Point, Buffer, and main become named structs; scale is a function and gets none, its variables live on the stack; Speed is an i32:

%Point  = type { i32, i32 }
%Buffer = type { ptr, i16, i32, [4 x i32] }
%main   = type { %Buffer, i32, i8, [81 x i8], %Point, i32, ptr, i8 }

The leading ptr of Buffer is the __vtable member that the polymorphism lowerer adds to every function block. main embeds its Buffer by value, and text is [81 x i8]: 80 characters plus the terminator.

Codegen first registers empty named structs, then fills their members. This permits pointers to the same struct or one declared later. Function return variables and temporary variables live on the stack, outside these layouts.

Codegen then computes a constant initial value for each type: a struct from the evaluated initializers of its members, an array or a string from its literal. One type’s initial value can depend on another’s, so the computation runs as a queue that stops when a full pass makes no progress.

Global variables

The dependency set decides which globals a module declares: every global variable and every program instance the unit refers to becomes an LLVM global. A variable declared in this unit gets its initial value, which is the evaluated initializer, otherwise the initial value of its type, otherwise zero:

@counter       = global i32 7
@main_instance = global %main { %Buffer zeroinitializer, i32 1, i8 0, [81 x i8] c"hello\00...", %Point zeroinitializer, i32 0, ptr null, i8 0 }
@Speed.Slow    = unnamed_addr constant i32 0
@Speed.Fast    = unnamed_addr constant i32 1

A program instance is named after the program with an _instance suffix and starts at the constant of its type; i := 1 and text := 'hello' are visible in it. An enum variant is a constant named by its qualified name. A variable declared in another file appears as @counter = external global i32, without a value, and the linker connects the two.

Functions

Structured Text separates stateless from stateful POUs, and codegen makes the split visible in every signature:

  • Stateless: a function. A call knows nothing of the call before it, so its variables live on the stack; the LLVM function takes the inputs as arguments and returns the result.
  • Stateful: a function block or program. Inputs, outputs, and locals are members of its instance struct, which survives between calls; the LLVM function takes one argument, a pointer to that instance, and returns nothing. Methods and actions are ordinary LLVM functions that take the instance pointer first; a method adds its own parameters after it.
define i32  @scale(i32 %0, i16 %1, ptr %2, ptr %3)   ; value, factor, total (in-out), overflow (output)
define void @Buffer(ptr %0)                           ; the instance
define void @main(ptr %0)                             ; the instance

In a function, VAR_IN_OUT and VAR_OUTPUT parameters are pointers, and an aggregate input (struct, array, string) is passed as a pointer and copied into a local inside the callee. In a stateful POU only VAR_IN_OUT is a pointer; an output and an aggregate input are ordinary members of the instance, which the caller fills before the call and reads after it.

Functions are created in two passes, like structs: first a declaration for every POU the unit depends on, including POUs from other units, then the bodies of the POUs declared in this unit. If scale were in a second file, the module of main would contain declare i32 @scale(i32, i16, ptr, ptr) with no body.

A body starts by making every variable addressable, so the statements can treat both kinds alike. A function copies each argument into a stack slot and starts its return variable at zero; a stateful POU computes one pointer per member into the instance:

define i32 @scale(i32 %0, i16 %1, ptr %2, ptr %3) {
entry:
  %scale = alloca i32                 ; stack slot for the return variable
  %value = alloca i32                 ; stack slot for value
  store i32 %0, ptr %value            ; copy the argument into it
  %factor = alloca i16
  store i16 %1, ptr %factor
  %total = alloca ptr                 ; in-out: the slot holds the caller's address
  store ptr %2, ptr %total
  %overflow = alloca ptr              ; output: same
  store ptr %3, ptr %overflow
  store i32 0, ptr %scale             ; the return variable starts at zero
  ...
  %scale_ret = load i32, ptr %scale   ; read the return variable
  ret i32 %scale_ret                  ; and return it
}

define void @Buffer(ptr %0) {
entry:
  %this = alloca ptr                                                     ; the instance pointer, for THIS
  store ptr %0, ptr %this
  %__vtable = getelementptr inbounds nuw %Buffer, ptr %0, i32 0, i32 0   ; address of member 0 in the instance
  %limit    = getelementptr inbounds nuw %Buffer, ptr %0, i32 0, i32 1   ; member 1
  %count    = getelementptr inbounds nuw %Buffer, ptr %0, i32 0, i32 2   ; member 2
  %values   = getelementptr inbounds nuw %Buffer, ptr %0, i32 0, i32 3   ; member 3
  ...
  ret void                                                               ; nothing to return
}

Every address is registered in the function’s child index under its qualified name (Buffer.count). A function ends by loading the variable that carries its own name and returning it.

Expressions

Two rules drive almost everything the expression generator does.

A reference is an address until a value is needed

p.x is one getelementptr per member step, values[count] a pointer computation into the array. On the left of an assignment the address is the target of a store; as an operand, its value is loaded. For p.x := i:

%x       = getelementptr inbounds nuw %Point, ptr %p, i32 0, i32 0   ; address of p.x: member 0 of p
%load_i2 = load i32, ptr %i                                          ; read i
store i32 %load_i2, ptr %x                                           ; write it to p.x

An array index becomes an offset first: the index minus the lower bound, times the stride of the dimension. For values[count] := count, with lower bound 0 and one dimension, both corrections do nothing and are still emitted:

%tmpVar2 = mul i32 1, %load_count                                              ; count times the stride (1)
%tmpVar3 = add i32 %tmpVar2, 0                                                 ; minus the lower bound (0)
%tmpVar4 = getelementptr inbounds [4 x i32], ptr %values, i32 0, i32 %tmpVar3  ; address of values[offset]
store i32 %load_count5, ptr %tmpVar4                                           ; write count there

Pointer variables that the resolver marked as auto-dereferencing (VAR_IN_OUT, REFERENCE TO) get one extra load. Inside scale, reading total loads the address from the stack slot first, then the value behind it. An explicit pointer works the same way without the marker: ptr := REF(i) stores the address of i, and ptr^ := 2 loads that address and stores through it:

%deref1     = load ptr, ptr %total     ; the address the caller passed
%load_total = load i32, ptr %deref1    ; the value behind it

store ptr %i, ptr %ptr                 ; ptr := REF(i)
%deref = load ptr, ptr %ptr            ; ptr^ := 2
store i32 2, ptr %deref

The type hint decides the conversion

Whenever a value flows into a place of a different type, codegen compares the annotated type with the hint and emits the cast: extension and truncation between integers, conversion between integers and floats, widening or narrowing between floats. In scale := value * factor, factor is an INT hinted to DINT; sintVar := i is the introduction’s truncation:

%4      = sext i16 %load_factor to i32   ; widen factor from INT to DINT
%tmpVar = mul i32 %load_value, %4        ; value * factor

%2 = trunc i32 %load_i1 to i8            ; cut i from DINT to SINT
store i8 %2, ptr %sintVar                ; write sintVar

Literals are created directly in the hinted type, so factor := 3 for an INT parameter produces i16 3 and no cast.

Binary and unary expressions become the matching integer or float instruction. Comparisons produce an i1, which is widened to i8 because that is the width of BOOL; where a branch needs a condition, the i8 is compared against zero to get an i1 back. For overflow := scale > 100:

%tmpVar5 = icmp sgt i32 %load_scale4, 100   ; scale > 100, as i1
%5       = zext i1 %tmpVar5 to i8           ; widen to the width of BOOL
store i8 %5, ptr %deref3                    ; write overflow through the output pointer

Codegen expects earlier stages to have resolved names and checked types. An unsupported expression stops the run with an internal codegen error.

Statements

Assignments

A single value (integer, real, pointer) is a store. An aggregate (struct, array, string) is a memcpy of the target’s size; for a string the copy stops at the target’s length, so a longer value is cut and never overflows. A string literal is a private global constant, created once per module. For text := 'world':

@utf08_literal_1 = private unnamed_addr constant [6 x i8] c"world\00"   ; the literal, once per module

call void @llvm.memcpy.p0.p0.i32(ptr align 1 %text, ptr align 1 @utf08_literal_1, i32 6, i1 false)   ; copy 6 bytes into text

Calls to functions

Codegen places named and positional arguments in parameter order and fills omitted parameters with defaults. It passes scalar inputs by value and VAR_IN_OUT and VAR_OUTPUT arguments by address. The callee writes through these addresses. For i := scale(i, factor := 3, total := counter, overflow => flag):

%call = call i32 @scale(i32 %load_i, i16 3, ptr @counter, ptr %flag)   ; value, factor, address of total, address of overflow
store i32 %call, ptr %i                                                ; write the result to i

Calls to function blocks and programs

Their inputs are members of the instance, so codegen stores every passed argument into the member, calls the POU with the instance pointer, and copies => outputs back out afterwards. For bufferInstance(limit := 5):

%1 = getelementptr inbounds %Buffer, ptr %bufferInstance, i32 0, i32 1   ; address of bufferInstance.limit
store i16 5, ptr %1                                                      ; limit := 5
call void @Buffer(ptr %bufferInstance)                                   ; run the body on the instance

Built-in functions such as ADR, REF, SIZEOF, or MUX have their own code generators; REF(i) above produced no call, only the address of i.

IF

IF becomes one block per branch and a continue block after them; the condition is narrowed to i1 and branched on. For the IF in Buffer:

  %3 = icmp ne i8 %2, 0                                 ; narrow the BOOL to i1
  br i1 %3, label %condition_body, label %continue      ; jump to the branch or past it

condition_body:
  ...
  br label %continue                                    ; join after the branch

continue:
  ret void

CASE

CASE becomes a switch for literal labels and a chain of comparisons for range labels. The enum variants are constants, so both labels end up in the switch:

  switch i32 %load_speed, label %else [   ; no label matched: ELSE (empty here)
    i32 0, label %case                    ; Slow
    i32 1, label %case3                   ; Fast
  ]

Loops

FOR, WHILE, and REPEAT all arrive as WHILE TRUE loops from the loop desugarer, with the exit conditions as ordinary IF ... EXIT statements in the body. Codegen emits only the skeleton, a while_body block that branches back to itself and a continue block after it; EXIT and CONTINUE become jumps to those two blocks.

Initialization

A constant initial value goes into the global instance directly, as shown under Global variables. Everything that cannot be a compile-time constant, such as a pointer to another variable or a nested function block, is the work of the constructor functions. The init participant created them before codegen as ordinary POUs with a __ctor suffix, one per type and one per unit, and codegen treats them like any other function. For the example (the real name of the unit constructor also carries a hash of the file path):

@llvm.global_ctors = appending global [1 x { i32, ptr, ptr }] [{ i32, ptr, ptr } { i32 65535, ptr @__unit_cg_st__ctor, ptr null }]   ; run before the program starts

define void @main__ctor(ptr %0) {
entry:
  ...
  call void @Buffer__ctor(ptr %bufferInstance)   ; construct the embedded function block
  store i32 1, ptr %i                            ; i := 1
  call void @llvm.memcpy.p0.p0.i32(ptr align 1 %text, ptr align 1 @utf08_literal_0, i32 6, i1 false)   ; text := 'hello'
  call void @Point__ctor(ptr %p)                 ; construct the struct member
  ...
  ret void
}

define void @__unit_cg_st__ctor() {
entry:
  store i32 7, ptr @counter                                          ; counter := 7
  call void @__vtable_Buffer__ctor(ptr @__vtable_Buffer_instance)   ; fill the method table
  call void @main__ctor(ptr @main_instance)                         ; construct the program instance
  ret void
}

main__ctor receives the instance, calls the constructor of the embedded Buffer, and stores the initial values of i and text. The unit constructor calls it for the global instance, and the llvm.global_ctors entry runs the unit constructor before the program starts.

Note

The unoptimized IR includes redundant work. It computes unused member pointers, repeats some initialization, and emits offset arithmetic such as multiplication by one. LLVM removes much of this at -O1 and above. Method table members remain part of function block layouts.

Output

For an object file, LLVM runs its optimization passes at the chosen -O level and emits machine code for the target triple; the output format and the --fpic and --fno-pic flags decide whether the code is position-independent. IR and bitcode are written as they are, without optimization. With -g, a debug builder runs next to the generators and attaches DWARF debug information: one entry per POU, one per member and local variable, and a source location per statement.

Where it lives

WhatWhere
Codegensrc/codegen.rs, src/codegen/

What’s next

The generated objects can refer to symbols defined in other files. The Linker resolves these references, adds libraries, and produces the requested artifact.

Linker

Linking joins the object files that codegen produced into one artifact. For

(* scale.st *)
FUNCTION scale: DINT
    VAR_INPUT
        value: DINT;
    END_VAR

    scale := value * 2;
END_FUNCTION

(* main.st *)
PROGRAM main
    VAR
        i: DINT;
    END_VAR

    i := scale(i);
END_PROGRAM

the object for main.st contains a call to scale; the object for scale.st defines it. The linker combines the objects and libraries, resolves symbols, and writes an executable, shared object, or combined object.

flowchart LR
    parse[Parse] --> index[Index] --> annotate[Annotate] --> validate[Validate] --> codegen[Codegen] --> link[Link]
    style link fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a

The compiler does not link by itself. It assembles a command line and runs a linker that is installed on the system, in the same way a C compiler driver does. The stage receives the objects that codegen persisted, together with the objects and libraries named by the project, and returns the path of the artifact.

Inputs

The linker receives generated objects, object files supplied directly, and libraries. It finds libraries through -l names and -L or project search paths. Include files supplied declarations during parsing; the corresponding library binaries supply their definitions here.

Output formats

The output format is chosen on the command line and decides whether a linker runs at all:

FlagResultHow
defaultexecutablelinker, -o <output>
--sharedshared objectlinker, --shared -o <output>
--relocatableone object combining all inputslinker, -r -o <output>
-cone object, no linkingthe single object is copied to the output path
--ir, --bcone LLVM IR or bitcode filethe modules are merged in memory and written; no linker

A shared object is linked with --no-undefined, so that a symbol nobody defines fails the build instead of the load of the library; --allow-undefined-symbols turns this off for libraries whose loader provides the missing symbols. --fno-pic on an executable adds -no-pie, so that the linker accepts the fixed-address code codegen emitted.

Choosing the linker

Without a --linker flag the compiler looks for a linker in a fixed order: cc, then clang, then ld.lld, then ld. The first two are compiler drivers: they know the platform’s startup files and default libraries and add them on their own, so an executable linked through them can start. The last two are direct linkers that take only what they are given. --linker <command> skips the search.

The compiler checks a candidate driver by linking an empty C program with the selected --target and --sysroot. Cross-compilation requires the target’s libraries in the sysroot.

A direct linker receives --linker-arg values unchanged. A compiler driver receives them through -Xlinker. With a driver, --nocrt and --nolibc omit startup files and default libraries for targets that supply their own.

The command line

The command starts with the driver and the arguments it needs itself, then the supplied and generated objects, then the -L paths and the -l libraries. The sysroot and implicit search paths follow, then linker options, mode, and output path. For the two-file example with a library:

cc -fuse-ld=lld build/scale.st.o build/main.st.o -L/opt/plc/lib -liec61131std -L. -Lbuild -o out

The command is written to the debug log before it runs, and --log-level debug shows it. The linker’s own output goes to the terminal unchanged, and a non-zero exit code becomes the diagnostic E077, “An error occurred during linking”. When the project comes from a build description, the build subcommand ends by copying every library marked as Copy next to the artifact, so that the result can be deployed as one directory.

Note

Developer note. The linker is an external process, found on the PATH at run time. A working installation needs at least one of cc, clang, ld.lld, or ld, and the exact behavior of a link depends on which one is found. The --script and --no-linker-script flags are left over from a built-in linker script that is no longer used; a script is only added to the command when the user passes one.

Where it lives

WhatWhere
Linkersrc/linker.rs, src/output.rs
Link stepcompiler/plc_driver

What’s next

The pipeline has turned source text into an artifact. Along the way, participants introduced constructor functions, method tables, and normalized loops. The Participants chapters explain those rewrites and the order they require.

Participants

After these chapters you know what each participant rewrites, and why the stages after it need the rewrite. A participant runs between two pipeline stages, so that the later ones see a simpler program.

The subchapters follow the order in which the driver registers them, because each participant sees the rewrites of the ones before it: graphical charts become statements, the loops and ELSIF become one shape each, properties and methods become calls, inheritance and interfaces become embedded members and method tables, initial values become constructor functions, retained variables become globals, generic and aggregate-returning calls become concrete ones, and array literals become element assignments. Every subchapter shows the tree before and after its rewrite, marks the hooks that the participant uses, and explains which other participants it depends on.

The hooks live in compiler/plc_driver/src/pipelines, and the transformations in compiler/plc_lowering and src/lowering.

CFC

A Continuous Function Chart (CFC) stores a POU body as a diagram. Its XML file contains a Structured Text declaration and a network of elements, numbered pins, and wires. The network

          myAdd (0)
        +--------------------+
in1 --> | in1          myAdd | --> function_call (1)
in2 --> | in2   myAddDoubled | --> doubledOut (2)
        +--------------------+

calls myAdd with the function’s two inputs and routes its return value to the function’s result and its output myAddDoubled to the output doubledOut. The numbers in parentheses are the evaluation priorities the user assigned. Trimmed to what matters, the document reads:

<ppx:Function name="function_call">
    <ppx:AddData>
        <ppx:Data>
            <bmx:TextDeclaration>FUNCTION function_call: INT
VAR_INPUT
    in1, in2: DINT;
END_VAR
VAR_OUTPUT
    doubledOut: DINT;
END_VAR</bmx:TextDeclaration>
        </ppx:Data>
    </ppx:AddData>
    <ppx:MainBody><ppx:BodyContent><ppx:Network>
        <ppx:FbdObject xsi:type="ppx:Block" typeName="myAdd" globalId="1">
            <ppx:InputVariables>
                <ppx:InputVariable parameterName="in1">
                    <ppx:ConnectionPointIn><ppx:Connection refConnectionPointOutId="2"/></ppx:ConnectionPointIn>
                </ppx:InputVariable>
                ...
            </ppx:InputVariables>
            <ppx:OutputVariables>
                <ppx:OutputVariable parameterName="">
                    <ppx:ConnectionPointOut connectionPointOutId="4"/>
                </ppx:OutputVariable>
                <ppx:OutputVariable parameterName="myAddDoubled">
                    <ppx:ConnectionPointOut connectionPointOutId="5"/>
                </ppx:OutputVariable>
            </ppx:OutputVariables>
        </ppx:FbdObject>
        <ppx:FbdObject xsi:type="ppx:DataSource" identifier="in1" globalId="6">
            <ppx:ConnectionPointOut connectionPointOutId="2"/>
        </ppx:FbdObject>
        <ppx:FbdObject xsi:type="ppx:DataSink" identifier="doubledOut" globalId="9">
            <ppx:ConnectionPointIn><ppx:Connection refConnectionPointOutId="5"/></ppx:ConnectionPointIn>
        </ppx:FbdObject>
        ...
    </ppx:Network></ppx:BodyContent></ppx:MainBody>
</ppx:Function>

Each element has a globalId; each output pin has a connectionPointOutId. An input pin names the output pin it reads. The POU declaration is Structured Text without its closing keyword. A block’s unnamed output pin carries its return value. The CFC participant converts the network into an AST statement list:

FUNCTION function_call: INT
VAR_INPUT
    in1, in2: DINT;
END_VAR
VAR_OUTPUT
    doubledOut: DINT;
END_VAR
VAR
    __out_myAdd_1: DINT;
    __out_myAddDoubled_1: DINT;
END_VAR
    __out_myAdd_1 := myAdd(in1 := in1, in2 := in2, myAddDoubled => __out_myAddDoubled_1);
    function_call := __out_myAdd_1;
    doubledOut := __out_myAddDoubled_1;
END_FUNCTION
flowchart LR
    pre_index[pre_index] --> index[Index] --> post_index[post_index] --> pre_annotate[pre_annotate] --> annotate[Annotate] --> post_annotate[post_annotate]
    style post_index fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a

The participant acts during parsing and at post_index. Parsing routes .cfc, .fbd, and .xml files to the CFC parser. It reads the XML and parses the text declaration, then returns a compilation unit with an empty body. This lets the index register the POU and its parameters before the network is converted.

To build the body, the participant needs the index. A block’s callee kind and output parameters determine which call and temporary variables it requires.

At post_index the participant transpiles every CFC document against the index, replaces the unit of the parse step with the full one, and indexes the project again, so that the bodies and their temporaries are known. Then it runs the inference rounds for generic temporaries described below. Its diagnostics are collected after annotation, like those of the other participants. A project without CFC sources passes through the hook unchanged.

Transformation

The CFC resolver converts the document into assignments, returns, jumps, labels, and calls, each with an evaluation priority. It also records temporary variables. The transpiler converts this intermediate list into AST nodes. The following sections describe the resolver’s decisions, then the rendering step.

Elements and wires

Each element is classified by its xsi:type:

ElementRoleResult
DataSourcea value: a variable or literal, read by othersnothing on its own
DataSinka variable that receives a valueone assignment
Blocka call of a function, function block, program, or actionone call
Connector, Continuationa named wire break: the connector receives, continuations of the same label re-emitnothing; wires pass through
Returna conditional returnone RETURN with a condition
CfcJump, CfcLabela conditional jump and its targetone jump, one label
Unconnectedan element the user placed but never wireda warning

The resolver first surveys the network. It maps every output pin ID to the element that owns it and every connector label to its connector, and it collects the label names and the jump targets, so each can check the other. To read an input, it follows the referenced output pin until it reaches a value producer.

Two kinds of element are stepped over. A continuation is replaced by whatever feeds the connector of the same label, so a connector pair behaves like a wire. An ENO pin of a block is replaced by whatever feeds the EN pin of that block, because ENO mirrors the guard (see below). Both hops are cycle-guarded. A continuation without a connector, a connector without an input, or a block whose ENO leads back to itself is a dead end, reported once per element however many consumers reach it.

A trace ends at a block output pin, which is read as described under blocks, or at a plain element, whose identifier text goes through the compiler’s expression parser. The trace also collects the negation bubbles on the way. A bubble on the producer and one on the consumer each wrap the value in one more NOT. The bubble of a hopped ENO pin and the one of the EN pin behind it invert the same value, so a pair of them cancels.

The rest of this section takes one element kind at a time. Each example shows the network the user drew, then the statements the transpiler renders from it. A number in parentheses is the evaluation priority of the element, a bubble o--> is a negation, and [name |S] is a sink with a storage mode.

Data source

A data source supplies a variable or literal through its output pin. It produces no statement by itself. Each consumer follows the wire independently, so a source connected to two sinks produces two assignments:

foo --+--> bar (0)
      '--> baz (1)
bar := foo;
baz := foo;

A literal source works in the same way. The identifier goes through the compiler’s expression parser, so 5 becomes a literal node and foo becomes a reference node:

5 --> foo (0)
foo := 5;

The parser accepts more than the element may hold. A source or a sink is limited to a literal or a reference, and an expression such as in1 + 1 is rejected (E083), because a diagram models arithmetic as blocks. The condition of a return or a jump is the exception and accepts any expression.

A negation bubble on the pin wraps the value in a NOT:

foo o--> bar (0)
bar := NOT foo;

Data sink

A data sink is a write. It traces its input back to a producer and assigns the value to its own identifier. A source wired to a sink is therefore one assignment:

foo --> bar (0)
bar := foo;

The result of a CFC function is written in the same way, by a sink whose identifier is the name of the function. A sink that the user left unwired renders nothing and is not reported.

A storage mode turns the sink from an assignment into a latch. In Set mode the traced value is no longer the value stored: it is the guard, and the value stored is TRUE. Nothing is written while the guard is false, so a variable that was set once keeps its value:

a --> [b |S] (0)
IF a THEN b := TRUE; END_IF

Reset mode is the counterpart and stores FALSE under the same guard. Reference mode stores no value at all; it stores the address, so later reads of the sink see whatever the source holds at that time:

a --> [b |REF] (0)
b REF= a;

A negation bubble has no meaning on a reference assignment and is rejected (E154).

Block

A block is a call. Its typeName names the callee, its input pins are the parameters, and its output pins are the outputs of the callee. A callee with state keeps its outputs in its own instance, so the network needs nothing more than the call and a read of the member:

localIn --> in [inst : counter] out (0) --> localOut (1)
inst(in := localIn);
localOut := inst.out;

Blocks carry more rules than the other elements, because the shape of the call depends on what the index says the callee is. The Blocks section below covers them.

Connector and continuation

A connector ends a wire and names it. Each continuation with that name resumes the wire. Neither produces a statement; the resolver follows the connection to its source. One connector can feed several continuations:

foo --> x>
>x --> bar (0)
>x --> baz (1)
bar := foo;
baz := foo;

A pair can feed another pair. The trace follows the chain, with a cycle guard, until it reaches a producer:

foo --> a>
>a --> b>
>b --> c>
>c --> bar (0)
bar := foo;

A pair that nothing reads renders nothing, and a connector without an input is only reported when something reads it (E086). The user can leave a routing aid unfinished without a diagnostic:

x>       (no source)
>x       (nobody reads it)

A label that two connectors claim is E081, and a continuation whose label no connector defines is E082.

Return

A return leaves the POU early. It is always conditional: the traced value becomes the guard. The return carries no value, because the result of a function is written by the sink named after the function.

myCondition --> RETURN (0)
IF myCondition THEN RETURN; END_IF

A return without a wired condition could never fire. It is dropped and reported (E085).

Jump and label

A jump is a conditional GOTO, and a label is its target. The two are separate elements with no wire between them; the jump names its target as text, and the survey matches the names. Both render directly, in priority order like every other element, so the priorities of the user decide whether a jump goes forward or backward:

myCondition --> JMP skipAssignment (0)
x --> y (1)
LABEL skipAssignment (2)
IF myCondition THEN GOTO skipAssignment;
y := x;
LABEL: skipAssignment

A jump without a wired condition is kept instead of dropped, with a FALSE guard, so the label it targets stays the target of a valid statement. A warning says that the jump can never be taken (E145):

(unwired) --> JMP skipAssignment (0)
x --> y (1)
LABEL skipAssignment (2)
IF FALSE THEN GOTO skipAssignment;
y := x;
LABEL: skipAssignment

A jump to a name that no label defines is E142, a label that no jump targets is kept and reported with E143, and the same label defined twice is E144.

Unconnected

An unconnected element is a box that the user placed and never wired. It renders nothing, and the warning it produces (E084) is its only result, once per box. The rest of the network is unaffected:

foo          (unconnected)
bar          (unconnected)
foo --> bar (0)
bar := foo;

Blocks

The index decides how a block is rendered. A callee that is a function and has no instance name is stateless: its outputs exist only during the call. Every output that a consumer reads is therefore captured into a temporary named __out_<pin>_<globalId>, declared in a VAR block of the POU with the type the callee declares for that output. The return pin carries no parameter name, so it contributes the name of the callee (__out_myAdd_1 below); it is captured by an assignment of the call, every other output with =>. A fan-out then calls once and reads twice:

        myAdd (0)
      +--------------------+
a --> | in1          myAdd | --+--> x (1)
b --> | in2   myAddDoubled |   |
      +--------------------+   '--> y (2)
      (myAddDoubled unread)
__out_myAdd_1 := myAdd(in1 := a, in2 := b, myAddDoubled => );
x := __out_myAdd_1;
y := __out_myAdd_1;

An unwired function input is passed as an empty argument, in2 := , to use the callee’s default. An unread output uses an empty =>, as with myAddDoubled above. Variadic calls such as ADD receive positional values in pin order and omit unwired pins. Feedback from the block’s own output reads the previous temporary value because capture follows the call.

A callee with state, a function block instance or a program, keeps its outputs in the instance, so no temporaries are necessary: the call passes the wired inputs only, and a consumer of an output reads the member. The Block example above shows the plain case. An action is called through its owner, and its outputs are the members of the owner, not of the action:

localIn --> in [inst : counter.increment] out (0) --> localOut (1)
inst.increment(in := localIn);
localOut := inst.out;

A program is read in the same way through the name of its one instance, counter.out. Two programs wired in a cycle are legal for the same reason: each one reads the member of the other from the previous evaluation, and the priorities decide which one runs first.

Execution control adds EN and ENO pins. EN becomes an IF around the call and its output capture; a skipped call leaves temporaries unchanged. ENO refers to the EN source rather than a result of the callee. Thus done := trigger reads the same source as the guard. A chain of these pins produces a sequence of IF trigger guards:

                  myAdd (0)
            +--------------------+
trigger --> | EN             ENO | --> done (2)
      a --> | in1          myAdd | --> sum (1)
      b --> | in2   myAddDoubled |
            +--------------------+
            (myAddDoubled unread)
IF trigger THEN
    __out_myAdd_7 := myAdd(in1 := a, in2 := b, myAddDoubled => );
END_IF
sum := __out_myAdd_7;
done := trigger;

The flag decides whether a pin named EN is the control pin; without the flag, EN and ENO are ordinary parameters.

Note

Developer note. The export format does not say how to tell the control pin from a parameter of the same name that the callee declares itself. Two pins named EN on one block therefore stop the compiler with a panic instead of a guess.

Order

Each element renders on its own. A whole network is a set of them, and the wires do not say which of them runs first. The user’s priorities do: statements and temporaries are sorted by evaluation priority, and elements without a priority come last, in document order.

Rendering

With the list in order, the transpiler turns each statement into an AST node with the constructors the parser uses, so the result is indistinguishable from parsed text. Every statement carries a location of a kind that only this participant creates: the globalId of the element instead of a line and column. A diagnostic on such a node is printed as file.cfc: Block 6 without a source snippet. The temporaries go into one additional VAR block, and the statement list replaces the empty body the parse step left.

Generic temporaries

The rendered body is complete, except where a callee is generic. A temporary takes its output parameter’s declared type, and for a generic function such as myGenAdd<T: ANY_NUM>: T that type is __myGenAdd__T. Codegen needs a concrete type. The compiler’s expression resolver can derive it from the call arguments after transpilation.

After indexing the new units, the participant runs type inference rounds. Each round annotates the unit and checks generic temporaries whose inputs already have concrete types. It updates their declarations and index entries with the inferred types. A chain of generic calls resolves one step per round:

__out_myGenAdd_1 := myGenAdd(a := a, b := b);                  (* a, b : INT   -> round 1: INT  *)
__out_myGenAdd_5 := myGenAdd(a := __out_myGenAdd_1, b := c);   (* c : DINT     -> round 2: DINT *)

The rounds stop when a round patches nothing. A temporary that is still generic then, because nothing but its own generic outputs feeds the call, is reported in terms of the block the user placed (E149).

Interactions

The participant is registered first and runs at post_index, so the pre_index participants have already processed the interface-only unit the parse step produced. The transpiled unit that replaces it is a fresh parse of the declaration plus the rendered body; from the re-index on, every later participant sees the CFC POU as ordinary declarations and statements. Calls of CFC POUs from Structured Text resolve against the parse step’s unit already, because the declaration is complete before the body exists.

The generic lowerer, at post_annotate, replaces the generic calls in the rendered body by calls of concrete implementations. It relies on the inference rounds having replaced every generic temporary type first; the concrete declarations are what let the annotator type those calls.

The transpiler does not generate loops. Its IF guards are the shape parsed text produces, so the later participants treat them like any other. Its jumps, its labels, and its conditional RETURN have no Structured Text syntax at all; they exist for this participant, and codegen has a branch for each.

Validation

The participant checks the diagram before pins and wires disappear from the AST. This lets diagnostics name the user’s elements instead of generated temporaries:

CodeReported for
E081a connector label claimed twice
E082a continuation with no connector
E083an expression where only a name or a literal is allowed
E084an element that is placed but not wired
E085a return without a condition
E086a connector without an input
E142a jump to a label nobody defines
E143a label no jump targets
E144a label defined twice
E145a jump without a condition
E146a block whose type the index does not know
E147a wired output the callee does not declare
E149a generic output no round could type
E152an execution-controlled block whose EN is unwired
E153an ENO chain that loops
E154a negation bubble on a reference assignment
E155a block with two unnamed return pins

Everything about the rendered statements themselves (unknown variables, type mismatches, wrong argument counts) is left to the validation stage, which reports it at the block location of the element too.

Loop Desugar

Structured Text has three loops: WHILE, REPEAT, and FOR. They differ in when they test the condition and how they update the counter. Codegen uses one form, WHILE TRUE, with explicit EXIT statements. In

FOR i := 10 TO 1 BY -3 DO
    sum := sum + i;
END_FOR

the counter starts at 10 and decreases by 3 each iteration. The loop ends when i is below 1. The loop desugarer makes these steps explicit inside a WHILE TRUE body.

flowchart LR
    pre_index[pre_index] --> index[Index] --> post_index[post_index] --> pre_annotate[pre_annotate] --> annotate[Annotate] --> post_annotate[post_annotate]
    style pre_index fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a

The participant runs once at pre_index. It needs only the parsed tree, so no index or annotations need to be rebuilt.

It makes three passes over every unit, one per loop kind, in the order WHILE, REPEAT, FOR. Each pass visits the body of a loop before the loop itself, so nested loops are rewritten from the inside out.

Transformation

WHILE

WHILE checks its condition before every iteration. The condition moves into the body as a guard that leaves the loop when it is false:

-WHILE count < limit DO
+WHILE TRUE DO
+    IF NOT count < limit THEN
+        EXIT;
+    END_IF
     count := count + 1;
 END_WHILE

REPEAT

REPEAT checks its condition after the body. Placing the check at the end of the generated body would let CONTINUE skip it. Instead, the lowerer puts the check at the start and skips it on the first iteration:

+alloca __ran_once_0: BOOL;
-REPEAT
+WHILE TRUE DO
+    IF __ran_once_0 THEN
+        IF n >= 4 THEN
+            EXIT;
+        END_IF
+    END_IF
+    __ran_once_0 := TRUE;
     n := n + 1;
-UNTIL n >= 4
-END_REPEAT
+END_WHILE

FOR

FOR uses two flags. __ran_once_N skips the counter update on the first iteration. Later iterations update the counter at the start, so CONTINUE cannot skip the update. __is_incrementing_N records the initial step direction and selects the exit comparison:

+alloca __ran_once_0: BOOL;
+alloca __is_incrementing_0: BOOL;
-FOR i := 10 TO 1 BY -3 DO
+i := 10;
+__is_incrementing_0 := -3 > 0;
+WHILE TRUE DO
+    IF __ran_once_0 THEN
+        i := i + -3;
+    END_IF
+    __ran_once_0 := TRUE;
+    IF __is_incrementing_0 THEN
+        IF i > 1 THEN
+            EXIT;
+        END_IF
+    ELSE
+        IF i < 1 THEN
+            EXIT;
+        END_IF
+    END_IF
     sum := sum + i;
-END_FOR
+END_WHILE

Without BY, the step is the literal 1 and the direction flag is set to TRUE directly. The step and the end value stay expressions in the tree, so a variable step or end is read again on every iteration, as in the source. A step of 0 counts as not incrementing: the loop exits as soon as the counter is below the end value, so FOR i := 1 TO 3 BY 0 runs zero times instead of forever. An EXIT or CONTINUE written by the user stays where it was and now refers to the generated WHILE.

The alloca lines are allocation statements, a node kind that has no spelling in Structured Text. They declare a variable in the middle of a body instead of in a VAR block. The resolver treats one like a local variable of the POU, named main.__ran_once_0, and codegen gives it a stack slot at the start of the function that starts at FALSE and lives for the whole call. The numbering is one counter shared by all REPEAT and FOR loops of the run, so every temporary has a unique name.

Most generated nodes have internal source locations. Reused source nodes keep theirs: conditions still point to the original conditions, and counter setup and comparisons point to the FOR header. A debugger can therefore stop on the source loop lines while skipping generated flags and loop-back jumps.

Interactions

Codegen accepts only WHILE loops with the literal condition TRUE. A FOR or REPEAT reaching it indicates a pipeline error. The init participant runs later and generates this final loop form directly. The control statement participant also sees the rewritten loops.

Property

A property gives a function block a member that reads like a variable but runs code. In

FUNCTION_BLOCK fb
    VAR
        raw: DINT;
    END_VAR

    PROPERTY_GET scaled: DINT
        scaled := raw * 10;
    END_PROPERTY

    PROPERTY_SET scaled: DINT
        raw := scaled / 10;
    END_PROPERTY
END_FUNCTION_BLOCK

FUNCTION main
    VAR
        inst: fb;
        x: DINT;
    END_VAR

    inst.scaled := 50;
    x := inst.scaled;
END_FUNCTION

the assignment inst.scaled := 50 calls the setter, while x := inst.scaled calls the getter. The property lowerer generates the methods fb.__get_scaled and fb.__set_scaled, then replaces property accesses with calls. Later stages can process those methods and calls using their usual rules.

flowchart LR
    pre_index[pre_index] --> index[Index] --> post_index[post_index] --> pre_annotate[pre_annotate] --> annotate[Annotate] --> post_annotate[post_annotate]
    style pre_index fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a
    style post_annotate fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a

At pre_index, the participant converts accessor blocks into method declarations and bodies. The index registers these methods, and the resolver can find them.

At post_annotate, it reads property annotations. Each annotation names the getter or setter required by the access. The lowerer inserts the call and reruns annotation. It keeps the index because the declarations have not changed.

The order inside a statement is fixed. In an assignment the participant lowers the index expressions of the target first, then the right side, then the assignment itself; in a reference it lowers the base and the index before the reference. inst.foo[inst.bar] therefore becomes inst.__get_foo()[inst.__get_bar()], and inst.scaled := inst.scaled + 1 becomes inst.__set_scaled(inst.__get_scaled() + 1). The driver collects the participant’s diagnostics after the last post_annotate hook.

Transformation

Getter to method

The getter uses the property name as a local result variable. The generated method keeps that variable and the original body, then copies the result to the method’s return variable:

-PROPERTY_GET scaled: DINT
+METHOD __get_scaled: DINT
+    VAR
+        scaled: DINT;
+    END_VAR
+
     scaled := raw * 10;
-END_PROPERTY
+    __get_scaled := scaled;
+END_METHOD

Setter to method

In the setter body the property name stands for the incoming value, so it becomes a by-value input parameter, and the method has no return type:

-PROPERTY_SET scaled: DINT
+METHOD __set_scaled
+    VAR_INPUT
+        scaled: DINT;
+    END_VAR
+
     raw := scaled / 10;
-END_PROPERTY
+END_METHOD

The generated methods are named <parent>.__get_<property> and <parent>.__set_<property> and are appended to the unit’s POU and implementation lists. Their kind is Method, and the kind also records the property name and whether this is the getter or the setter; the validator uses that to name an accessor as a property instead of a method in its messages. The property block itself stays on the function block, so that the validator can still check the definition.

Properties are accepted in a FUNCTION_BLOCK, a CLASS, a PROGRAM, and an INTERFACE. For an interface only the method declarations are generated and added to the interface’s method list, because an interface property has no body; the parser rejects statements there.

Read to getter call

Every reference with a getter annotation is replaced by a call without arguments, wherever it stands: as an operand, as a call argument, as an array index, or as the base of an index access:

-x := inst.scaled;
+x := inst.__get_scaled();

The call takes the location of the reference it replaces. Inside the body of the function block or one of its actions, the unqualified scaled becomes __get_scaled() without a base; the resolver looks the property up in the parent POU of a method or action.

Assignment to setter call

When the target of an assignment carries a setter annotation, the whole assignment is replaced by a call statement whose only argument is the right-hand side; the call takes the location of the assignment:

-inst.scaled := 50;
+inst.__set_scaled(50);

Inside its own accessors the property name is not lowered. scaled := raw * 10 in the getter stays an assignment to the added local variable, because the resolver tries variables before properties. A different property named in an accessor body is lowered like everywhere else.

If an accessor is missing, the resolver still records its expected name. The lowerer generates the call, and validation reports a property-specific error: PROPERTY_GET for property scaled is not defined (E048). Generated locals and return assignments have internal locations; methods use the property name’s location.

Interactions

The resolver selects a getter for a read and a setter for an assignment target. It searches the base type, enclosing POU, base types, and interfaces as needed. The lowerer reads that result from the annotation; it does not repeat the lookup.

Later participants process accessor methods and calls. The polymorphism lowerer adds accessors to method and interface tables. Thus iface.value := 3 can become an indirect setter call, and an unqualified scaled := 2 can dispatch through the current instance’s table.

When a getter returns an array or a struct, the aggregate-return lowerer turns its return into a by-reference parameter named __get_data.

Validation

Before rewriting an assignment, the participant checks the target’s base chain. A property in that chain, as in inst.data[1] := 5 or inst.point.x := 4, produces E128: Properties can only be assigned as a whole, not through member or index access. The statement is left unchanged. A setter accepts a complete value, not an individual member or element.

This check needs the original assignment and its property annotations. After lowering, Validation sees a call instead.

Polymorphism

Polymorphism is part of object-oriented programming in Structured Text. There are two kinds:

  1. Pointer variables to a class or function block, for example refMyFb: POINTER TO MyFb; or refMyFb: REF_TO MyFb;
  2. Interface-typed variables, for example refMyInterface: MyInterface;

In case (1), any instance of a type derived from the base type can be assigned to the reference. In case (2), any instance of a class or function block that implements the interface can be assigned. In both cases, calling a method executes the implementation defined by the actual (run-time) type of the assigned instance, not the statically declared type of the variable. Consider

VAR
    instanceA: FbA; // Has methods foo and bar
    instanceB: FbB; // Extends FbA; inherits foo, overrides bar, adds baz
    instanceC: FbC; // Has method foo

    refInstance: POINTER TO FbA;
    refInterface: InterfaceAC; // Defines method foo; both FbA and FbC implement it
END_VAR

// Base type
refInstance := ADR(instanceA);
refInstance^.foo(); // Calls FbA.foo
refInstance^.bar(); // Calls FbA.bar

// Derived type. Valid because FbB derives from FbA. Only the methods of FbA are accessible.
refInstance := ADR(instanceB);
refInstance^.foo(); // Calls FbA.foo (inherited)
refInstance^.bar(); // Calls FbB.bar (overridden)

// FbA implements InterfaceAC, so this assignment is valid
refInterface := instanceA;
refInterface.foo(); // Calls FbA.foo

// FbC also implements InterfaceAC, so this assignment is valid too
refInterface := instanceC;
refInterface.foo(); // Calls FbC.foo

To call the correct method at run time (dynamic dispatch), the compiler must generate supporting data structures and lookup logic. The core data structure is the virtual table, from now on called vtable.

A vtable is a struct of function pointers, where each field points to a method implementation. Each class or function block type has exactly one vtable, generated at compile time. Every instance embeds a pointer to the vtable of its type as a hidden first field, which is used at run time to resolve method calls. For example

┌─VTable FbA─┐     ┌────────────┐     ┌─VTable FbB─┐
├────────────┤  ┌─▶│  FbA.foo   │◀─┐  ├────────────┤
│    foo     │──┘  ├────────────┤  └──│    foo     │
├────────────┤  ┌─▶│  FbA.bar   │     ├────────────┤
│    bar     │──┘  ├────────────┤  ┌──│    bar     │
└────────────┘     │  FbB.bar   │◀─┘  ├────────────┤
                   ├────────────┤  ┌──│    baz     │
                   │  FbB.baz   │◀─┘  └────────────┘
                   ├────────────┤
                   │    ...     │
                   └────────────┘

Here the vtable fields of FbA point to FbA.foo and FbA.bar. FbB points to its own implementations FbB.bar (overridden) and FbB.baz (unique to FbB), but because it inherits foo, that field points to the implementation of the parent, FbA.foo. At run time, the call refInstance^.bar() reads the vtable pointer of the instance and fetches the function pointer for the correct method. The key takeaway is: dynamic dispatch is just an indirect function call through a function pointer.

The polymorphism lowerer generates these tables and rewrites the calls. It runs at two hooks:

flowchart LR
    pre_index[pre_index] --> index[Index] --> post_index[post_index] --> pre_annotate[pre_annotate] --> annotate[Annotate] --> post_annotate[post_annotate]
    style post_index fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a
    style post_annotate fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a

At post_index, the table generators run: first the vtable generator for classes and function blocks, then the interface table generator. They append the table struct types and the global table instances to the compilation units. Table types land in the unit that declares the interface or POU, instances in the unit that declares the implementing POU, so each artifact is local to its unit and multi-file builds work. The project is then indexed again so that the new types and globals are visible.

At post_annotate, the dispatch lowerers run: first the interface dispatch lowerer, which replaces interface type declarations with __FATPOINTER and lowers assignments, method calls, and call arguments; then the POU dispatch lowerer, which patches class and function block calls to go through the vtable. The project is then indexed and annotated again, so that the injected types and calls are resolved for code generation. The Validation section covers the checks that must happen before this rewrite.

The Transformation section takes the two kinds of polymorphism in turn, then traces one program from source to lowered form.

Transformation

Class and function block polymorphism

As mentioned in the introduction, any derived POU instance can be assigned to a reference of its base type. For example, assume a hierarchy where FbA is the parent of FbB, which in turn is the parent of FbC. This allows

VAR
    instanceA: FbA; // Declares foo
    instanceB: FbB; // Extends FbA, overrides foo, adds bar and baz
    instanceC: FbC; // Extends FbB, adds qux

    refInstanceA: POINTER TO FbA;
END_VAR

// All of these assignments are valid, because inheritance guarantees that A, B and C
// all share at least the methods of A.
refInstanceA := ADR(instanceA);
refInstanceA^.foo(); // Calls FbA.foo
refInstanceA := ADR(instanceB);
refInstanceA^.foo(); // Calls FbB.foo, the override
refInstanceA := ADR(instanceC);
refInstanceA^.foo(); // Calls FbB.foo, the closest ancestor that overrides foo

To achieve dynamic dispatch, the compiler must perform a vtable lookup to execute the correct method. It does so by patching every such method call:

-refInstanceA^.foo();
+__vtable_FbA#(refInstanceA^.__vtable^).foo^(FbA#(refInstanceA^) /*, other arguments */);

Two casts are needed to reinterpret the void pointers as the correct types. The vtable cast, __vtable_FbA#(...), names the declared type of the pointer. The instance cast, FbA#(...), names the POU that declares the method; for an inherited method that is a base type, not the pointer’s type. A call of a function block body through a pointer, refInstanceA^(), uses the __body slot described below and passes the instance without a cast.

That in turn requires that classes and function blocks have a __vtable member field, which the compiler injects as a new first variable block of the POU:

 FUNCTION_BLOCK FbA
+    VAR
+        __vtable: POINTER TO __VOID;
+    END_VAR
     VAR
         // ...other member fields
     END_VAR
 END_FUNCTION_BLOCK

The __vtable field is initialized at construction time by the init participant, which assigns it to ADR(__vtable_FbA_instance). The table itself is a __vtable_FbA struct definition whose members carry default initializers, each pointing to the corresponding method implementation. Function blocks also include a __body entry for their callable body (classes do not, since they cannot be called directly). For clarity, the ASCII diagrams in this chapter omit __body and show only named methods:

+TYPE __vtable_FbA:
+    STRUCT
+        __body: __FPOINTER FbA := ADR(FbA);
+        foo: __FPOINTER FbA.foo := ADR(FbA.foo);
+        bar: __FPOINTER FbA.bar := ADR(FbA.bar);
+    END_STRUCT
+END_TYPE

And finally a global instance of that struct, one per POU:

 VAR_GLOBAL
+    __vtable_FbA_instance: __vtable_FbA;
 END_VAR

The global itself has no initializer. The member initializers of the struct become a constructor, which fills the instance before the program starts (see Interactions). A POU from an include file or with {external} linkage gets its instance declared in an {external} global block instead, because the library defines it. The --generate-external-constructors flag reverses this for {external} POUs; that is how the library itself is built.

For derived POUs the process is the same, except that they do not get their own __vtable member field. They access the __vtable of the root parent and override it. This is also why the vtable pointer is a void pointer: different vtables, and therefore different types, are assigned to the __vtable field of the root. For the A <- B <- C inheritance chain the result is

 FUNCTION_BLOCK FbA
     VAR
+        __vtable: POINTER TO __VOID; // Initialized to ADR(__vtable_FbA_instance)
     END_VAR
 END_FUNCTION_BLOCK

 FUNCTION_BLOCK FbB
     VAR
+        __FbA: FbA; // Parent, with __vtable overridden to ADR(__vtable_FbB_instance)
     END_VAR
 END_FUNCTION_BLOCK

 FUNCTION_BLOCK FbC
     VAR
+        __FbB: FbB; // Parent, with __FbA.__vtable overridden to ADR(__vtable_FbC_instance)
     END_VAR
 END_FUNCTION_BLOCK

The parent member fields (for example __FbA) are created by the inheritance lowerer, and the vtable pointer assignments are handled by the init participant. The inheritance lowerer also spells out the path to the member: a call through a POINTER TO FbB first becomes refInstanceB^.__vtable^ here and then refInstanceB^.__FbA.__vtable^ when the inheritance lowerer runs.

One more note: methods called from within other methods, or from a function block body, also need to go through the vtable, because an inherited method may call an overridden method. The missing base becomes THIS^:

 METHOD foo
     // This call must be evaluated at run time, because a child POU might have overridden bar
-    bar();
+    __vtable_FbA#(THIS^.__vtable^).bar^(FbA#(THIS^));
 END_METHOD

Calls written as THIS^.bar() or SUPER^.bar() are left untouched, and so is a call on a plain instance variable, instanceA.bar(). In all three cases the type of the instance is exact, so the call is statically dispatched.

One question remains about __vtable_FbA#(refInstanceA^.__vtable^).foo^(FbA#(refInstanceA^)): why is it safe to cast one vtable to another? The vtable layouts give the answer:

┌─VTable FbA─┐   ┌─VTable FbB─┐   ┌─VTable FbC─┐
├────────────┤   ├────────────┤   ├────────────┤
│    foo     │   │    foo     │   │    foo     │
└────────────┘   ├────────────┤   ├────────────┤
                 │    bar     │   │    bar     │
                 ├────────────┤   ├────────────┤
                 │    baz     │   │    baz     │
                 └────────────┘   ├────────────┤
                                  │    qux     │
                                  └────────────┘

The order of the function pointers is stable: the pointers of the parent types come first, then the ones the type adds. This works because the generated vtable structs have a guaranteed sequential layout with no field reordering; each derived vtable is a strict prefix extension of the vtable of its parent. A cast therefore only reinterprets the vtable as the parent type, cutting off trailing fields but keeping the content of the existing ones. In other words, upcasting from a derived class to a parent requires no run-time conversion. This property only holds for single, linear inheritance chains; interfaces require a different dispatch mechanism (see the next section).

Putting it all together, the compiler does the following to achieve dynamic dispatch for classes and function blocks:

  1. Generate a vtable struct for every class and function block, populated with function pointers for every method these POUs define or inherit
  2. Generate a global variable instance for each vtable, filled with the correct addresses at construction time
  3. Generate and inject a __vtable member field of type POINTER TO __VOID into every non-extended class or function block, initialized by the init participant to the address of the global instance
  4. Transform method calls to use the lookup table, where the
    1. method is called from within another method or a function block body, or
    2. method is called through a variable of type POINTER TO <CLASS|FUNCTION_BLOCK>, REF_TO, or REFERENCE TO,
    3. but leave THIS^, SUPER^, and instance variable calls untouched, since those are statically dispatched

Interface polymorphism

Interfaces need that different mechanism. An interface can be used as a variable type, and any concrete instance can be assigned to it, provided that its POU implements the interface. For example

VAR
    instanceFbA: FbA; // Implements interface IA (method foo)
    instanceFbB: FbB; // Implements interfaces IA (method foo) and IB (method bar)

    refInterface: IA;
END_VAR

refInterface := instanceFbA;
refInterface.foo(); // Calls FbA.foo

// Assigning an instance of FbB to interface IA works, because FbB implements IA
refInterface := instanceFbB;
refInterface.foo(); // Calls FbB.foo

The problem: why vtables do not work for interfaces

Apply the mechanism of the previous section to interfaces. Assume the following interface definitions

//   IA
//  /  \
// IB   IC
//  \  /
//   ID
//
// IA: foo
// IB EXTENDS IA: foo, bar
// IC EXTENDS IA: foo, baz
// ID EXTENDS IB, IC: foo, bar, baz, qux

and some function blocks that implement them, plus code that makes use of polymorphism

VAR
    instanceD: FbD; // Implements interface ID (foo, bar, baz, qux)

    refInterfaceB: IB;
    refInterfaceC: IC;
END_VAR

refInterfaceB := instanceD;
refInterfaceB.foo();
refInterfaceB.bar();

refInterfaceC := instanceD;
refInterfaceC.foo();
refInterfaceC.baz();

Two problems arise:

  1. What types do refInterfaceB and refInterfaceC have?
  2. How can the vtable of instanceD be upcast to the vtable of IB or IC, given that their layouts are incompatible?

Take the vtable issue first. Assume that for each interface there is a function block that implements it. A naive vtable, built from the methods of each POU in declaration order, would give

┌─VTable FbA─┐   ┌─VTable FbB─┐   ┌─VTable FbC─┐   ┌─VTable FbD─┐
├────────────┤   ├────────────┤   ├────────────┤   ├────────────┤
│    foo     │   │    foo     │   │    foo     │   │    foo     │
└────────────┘   ├────────────┤   ├────────────┤   ├────────────┤
                 │    bar     │   │    baz     │   │    bar     │
                 └────────────┘   └────────────┘   ├────────────┤
                                                   │    baz     │
                                                   ├────────────┤
                                                   │    qux     │
                                                   └────────────┘

Upcasting from vtable FbD to FbB works (both have foo in slot 0 and bar in slot 1), but FbD to FbC does not, because bar in FbD would be interpreted as baz. That is

refInterfaceC := instanceD;
refInterfaceC.baz(); // This would call FbD.bar rather than FbD.baz!

Swapping the order of bar and baz in FbD would make the upcast to FbC work and break the one to FbB. There is no single layout that satisfies both. A different approach is needed.

Interface tables (itables)

The solution is a separate data structure: interface tables, itables for short. The idea is to have one itable struct per interface and one global itable instance per (interface, POU) pair where the POU implements the interface, directly or indirectly. Each itable struct contains function pointer fields that match the method signatures of the interface, and each instance fills those pointers with the concrete implementations of the POU.

For the diamond hierarchy above, the compiler generates the following itable struct definitions:

+TYPE __itable_IA:
+    STRUCT
+        foo: __FPOINTER IA.foo;
+    END_STRUCT
+END_TYPE
+
+TYPE __itable_IB:
+    STRUCT
+        __upcast_IA: POINTER TO __VOID;
+        foo: __FPOINTER IA.foo;
+        bar: __FPOINTER IB.bar;
+    END_STRUCT
+END_TYPE
+
+TYPE __itable_IC:
+    STRUCT
+        __upcast_IA: POINTER TO __VOID;
+        foo: __FPOINTER IA.foo;
+        baz: __FPOINTER IC.baz;
+    END_STRUCT
+END_TYPE
+
+TYPE __itable_ID:
+    STRUCT
+        __upcast_IA: POINTER TO __VOID;
+        __upcast_IB: POINTER TO __VOID;
+        __upcast_IC: POINTER TO __VOID;
+        foo: __FPOINTER IA.foo;
+        bar: __FPOINTER IB.bar;
+        baz: __FPOINTER IC.baz;
+        qux: __FPOINTER ID.qux;
+    END_STRUCT
+END_TYPE

Each itable struct includes __upcast_<Ancestor> pointer fields for every proper ancestor interface in its hierarchy, sorted alphabetically. Root interfaces like IA have none. These fields enable interface upcasting at run time with a single field read (see Interface upcasting below).

The function pointer types reference the original interface method (for example IA.foo), which already exists in the index as a registered implementation without a body. This avoids separate forward declarations. Inherited methods are included: __itable_IB contains both foo (from IA) and bar (from IB), with inherited methods first. In the diamond above, the methods of the ancestors (IA.foo, IB.bar, IC.baz) come before the own methods of ID (ID.qux).

Then, the compiler generates global instances for every (interface, POU) combination, sorted by name. Each __upcast field is initialized to the ancestor instance for the same POU:

+VAR_GLOBAL
+    __itable_IA_FbA_instance: __itable_IA := (foo := ADR(FbA.foo));
+    __itable_IA_FbB_instance: __itable_IA := (foo := ADR(FbB.foo));
+    __itable_IA_FbC_instance: __itable_IA := (foo := ADR(FbC.foo));
+    __itable_IA_FbD_instance: __itable_IA := (foo := ADR(FbD.foo));
+    __itable_IB_FbB_instance: __itable_IB := (__upcast_IA := ADR(__itable_IA_FbB_instance), foo := ADR(FbB.foo), bar := ADR(FbB.bar));
+    __itable_IB_FbD_instance: __itable_IB := (__upcast_IA := ADR(__itable_IA_FbD_instance), foo := ADR(FbD.foo), bar := ADR(FbD.bar));
+    __itable_IC_FbC_instance: __itable_IC := (__upcast_IA := ADR(__itable_IA_FbC_instance), foo := ADR(FbC.foo), baz := ADR(FbC.baz));
+    __itable_IC_FbD_instance: __itable_IC := (__upcast_IA := ADR(__itable_IA_FbD_instance), foo := ADR(FbD.foo), baz := ADR(FbD.baz));
+    __itable_ID_FbD_instance: __itable_ID := (__upcast_IA := ADR(__itable_IA_FbD_instance), __upcast_IB := ADR(__itable_IB_FbD_instance), __upcast_IC := ADR(__itable_IC_FbD_instance), foo := ADR(FbD.foo), bar := ADR(FbD.bar), baz := ADR(FbD.baz), qux := ADR(FbD.qux));
+END_VAR

A POU gets one instance per interface in its hierarchy. FbA implements IA alone and gets one. FbD implements ID, which extends IB and IC, and both of those extend IA, so FbD gets four.

While verbose, this solves the layout incompatibility problem entirely. There is no need to upcast one itable to another. Instead the itable pointer is swapped to the address of the correct global instance. Each interface has its own consistent layout, and each POU gets its own instance with the correct function pointers. Like the vtable instances, itable instances of external POUs are declared in an {external} global block.

Two additional cases are worth calling out:

POU inheritance: When a POU extends another POU that implements an interface, the child POU inherits the interface obligation. For example, if FbB EXTENDS FbA and FbA IMPLEMENTS IA, then FbB also gets an __itable_IA_FbB_instance. If FbB overrides a method, its itable instance points to the override; otherwise it points to the inherited implementation.

Method resolution: When filling an itable instance, the compiler walks the inheritance chain of the POU to find the most derived implementation of each method. For example, if FbA defines foo, FbB EXTENDS FbA overrides foo, and FbC EXTENDS FbB does not, then the itable of FbC points foo to FbB.foo.

The fat pointer

Itables solve the function pointer lookup, but one question is still open: what type does an interface variable have? Interfaces are shallow constructs with no state. They serve purely as a contract that certain methods exist. Dispatch, however, needs two things:

  1. A way to find the correct itable (to call the right method)
  2. A way to pass the data of the concrete instance to that method (so it can access state)

This leads to the fat pointer struct:

+TYPE __FATPOINTER:
+    STRUCT
+        data: POINTER TO __VOID;
+        table: POINTER TO __VOID;
+    END_STRUCT
+END_TYPE

The data field holds a pointer to the concrete POU instance, and the table field holds a pointer to the correct itable. Both are void pointers because different concrete types and different itable types may be assigned over the lifetime of the variable.

The compiler replaces every interface type reference with __FATPOINTER. This happens uniformly across all declarations, including struct members and function return types:

 VAR
-    reference: IA;
+    reference: __FATPOINTER;
 END_VAR

 VAR_INPUT
-    param: IA;
+    param: __FATPOINTER;
 END_VAR

 // Also works for arrays
 VAR
-    refs: ARRAY[1..3] OF IA;
+    refs: ARRAY[1..3] OF __FATPOINTER;
 END_VAR

 // And function return types
-FUNCTION producer: IA
+FUNCTION producer: __FATPOINTER

The __FATPOINTER struct is generated on demand: it is added to the first compilation unit of the project only when at least one interface is used as a type. If no code uses interface types, no fat pointer struct is emitted. A function that returns __FATPOINTER returns an aggregate, so the aggregate-return lowerer later turns its return into a VAR_IN_OUT parameter like for any struct.

Dispatch transformations

With itables and fat pointers in place, the compiler can transform all interface-related operations. There are four kinds of transformations.

Assignments: When a concrete POU instance is assigned to an interface variable, the compiler expands the single assignment into two: one for the data pointer and one for the itable pointer.

-reference := instanceFbA;
+reference.data := ADR(instanceFbA);
+reference.table := ADR(__itable_IA_FbA_instance);

This also works with array elements:

-refs[1] := instanceFbA;
+refs[1].data := ADR(instanceFbA);
+refs[1].table := ADR(__itable_IA_FbA_instance);

The compiler determines the itable instance name from the type annotations: the type of the right-hand side gives the POU name, and the type hint (the expected type on the left) gives the interface name.

Method calls: When a method is called on an interface variable, the compiler transforms it into an indirect call through the itable. The transformation has four steps:

Step 1: Prepend the data pointer as the implicit first argument (this is the instance the method expects):

-reference.foo(args);
+reference.foo(reference.data^, args);

Step 2: Replace the base of the operator with a dereferenced .table access:

-reference.foo(reference.data^, args);
+reference.table^.foo(reference.data^, args);

Step 3: Cast the itable access to the concrete itable type so the compiler knows the struct layout:

-reference.table^.foo(reference.data^, args);
+__itable_IA#(reference.table^).foo(reference.data^, args);

Step 4: Dereference the function pointer to perform the indirect call:

-__itable_IA#(reference.table^).foo(reference.data^, args);
+__itable_IA#(reference.table^).foo^(reference.data^, args);

Putting those steps together:

-reference.foo(1, 2);
+__itable_IA#(reference.table^).foo^(reference.data^, 1, 2);

This also works with named arguments:

-reference.foo(a := 10, b := 20);
+__itable_IA#(reference.table^).foo^(reference.data^, a := 10, b := 20);

And with nested interface calls, which are lowered bottom up:

-reference.baz(reference.foo(reference.bar()), 42);
+__itable_IA#(reference.table^).baz^(reference.data^, __itable_IA#(reference.table^).foo^(reference.data^, __itable_IA#(reference.table^).bar^(reference.data^)), 42);

Call arguments: When a concrete POU instance is passed as an argument to a function that expects an interface type, the compiler allocates a temporary fat pointer, fills it, and passes it in place of the original argument. The temporary is an allocation statement scoped to the enclosing statement, not a declared variable:

-consumer(instanceFbA);
+alloca __fatpointer_0: __FATPOINTER;
+__fatpointer_0.data := ADR(instanceFbA);
+__fatpointer_0.table := ADR(__itable_IA_FbA_instance);
+consumer(__fatpointer_0);

This works with named arguments too:

-consumer(in := instanceFbA);
+alloca __fatpointer_0: __FATPOINTER;
+__fatpointer_0.data := ADR(instanceFbA);
+__fatpointer_0.table := ADR(__itable_IA_FbA_instance);
+consumer(in := __fatpointer_0);

Multiple interface arguments in a single call each get their own temporary; the counter is shared by the whole compilation and never reset:

-consumer(instanceA, instanceB, instanceC);
+alloca __fatpointer_0: __FATPOINTER;
+__fatpointer_0.data := ADR(instanceA);
+__fatpointer_0.table := ADR(__itable_IA_FbA_instance);
+alloca __fatpointer_1: __FATPOINTER;
+__fatpointer_1.data := ADR(instanceB);
+__fatpointer_1.table := ADR(__itable_IA_FbB_instance);
+alloca __fatpointer_2: __FATPOINTER;
+__fatpointer_2.data := ADR(instanceC);
+__fatpointer_2.table := ADR(__itable_IA_FbC_instance);
+consumer(__fatpointer_0, __fatpointer_1, __fatpointer_2);

The preamble (allocations and assignments) is hoisted before the call. When the call is nested inside another statement, for example result := consumer(instance) or the condition of an IF, the preamble is hoisted above that whole statement, so that the fat pointer is fully constructed before the call executes.

Interface upcasting: When a child interface variable is assigned to a parent interface variable (for example refIA := refIB where IB EXTENDS IA), both sides are already fat pointers. The .data field can be copied directly; it still points to the same concrete POU instance. However, the .table field points to an __itable_IB_* instance but must point to the corresponding __itable_IA_* instance for the same POU. Since the concrete POU is only known at run time, the correct itable instance cannot be determined statically.

The solution uses the __upcast_<Ancestor> fields embedded in each itable struct. Each itable instance initializes these fields to point directly to the ancestor itable instance for the same POU, so one field read resolves the upcast regardless of hierarchy depth:

-refIA := refIB;
+refIA.data := refIB.data;
+refIA.table := __itable_IB#(refIB.table^).__upcast_IA;

The same transformation applies when a child interface is passed as a call argument where a parent interface is expected:

-consumer(refIB);
+alloca __fatpointer_0: __FATPOINTER;
+__fatpointer_0.data := refIB.data;
+__fatpointer_0.table := __itable_IB#(refIB.table^).__upcast_IA;
+consumer(__fatpointer_0);

Same-interface assignments (for example refIA1 := refIA2) remain plain struct copies, since the itable layout is identical.

Complete example

To tie everything together, here is one program traced from user code to lowered form.

User code (across multiple files):

// ia.st
INTERFACE IA
    METHOD describe: DINT
    END_METHOD
END_INTERFACE

// fb_a.st
FUNCTION_BLOCK FbA IMPLEMENTS IA
    METHOD describe: DINT
        printf('FbA$N');
        describe := 1;
    END_METHOD
END_FUNCTION_BLOCK

// fb_b.st
FUNCTION_BLOCK FbB IMPLEMENTS IA
    METHOD describe: DINT
        printf('FbB$N');
        describe := 2;
    END_METHOD
END_FUNCTION_BLOCK

// main.st
FUNCTION main
    VAR
        instA: FbA;
        instB: FbB;
        refs: ARRAY[1..2] OF IA;
        i: DINT;
    END_VAR

    refs[1] := instA;
    refs[2] := instB;

    FOR i := 1 TO 2 DO
        printf('id=%d$N', refs[i].describe());
    END_FOR;
END_FUNCTION

After table generation (post_index), the following artifacts are added. In the compilation unit of ia.st:

TYPE __itable_IA:
    STRUCT
        describe: __FPOINTER IA.describe;
    END_STRUCT
END_TYPE

In the compilation unit of fb_a.st:

VAR_GLOBAL
    __itable_IA_FbA_instance: __itable_IA := (describe := ADR(FbA.describe));
END_VAR

In the compilation unit of fb_b.st:

VAR_GLOBAL
    __itable_IA_FbB_instance: __itable_IA := (describe := ADR(FbB.describe));
END_VAR

FbA and FbB also get their __vtable members, vtable structs, and vtable instances, which are omitted here.

After dispatch lowering (post_annotate), the main function becomes (the loop is shown in its source form; the loop desugarer has already rewritten it by then):

FUNCTION main
    VAR
        instA: FbA;
        instB: FbB;
        refs: ARRAY[1..2] OF __FATPOINTER;
        i: DINT;
    END_VAR

    // refs[1] := instA becomes two field assignments
    refs[1].data := ADR(instA);
    refs[1].table := ADR(__itable_IA_FbA_instance);

    // refs[2] := instB becomes two field assignments
    refs[2].data := ADR(instB);
    refs[2].table := ADR(__itable_IA_FbB_instance);

    FOR i := 1 TO 2 DO
        // refs[i].describe() becomes an indirect call through the itable
        printf('id=%d$N', __itable_IA#(refs[i].table^).describe^(refs[i].data^));
    END_FOR;
END_FUNCTION

At run time, when i = 1, refs[1].table points to __itable_IA_FbA_instance, so describe resolves to FbA.describe. When i = 2, refs[2].table points to __itable_IA_FbB_instance, so describe resolves to FbB.describe. The output is:

FbA
id=1
FbB
id=2

Interactions

At pre_index, the property lowerer creates __get_x and __set_x methods. By the time the tables are generated they are ordinary methods, so they receive vtable and itable slots, and property accesses through pointers and interfaces dispatch dynamically.

This participant creates the __vtable member but never fills it. The init participant, registered later and running at post_annotate too, does the storing: the constructor of every class and function block ends with self.__vtable := ADR(__vtable_FbA_instance). For a derived type the constructor of the base runs first and stores the table of the base, then the derived constructor overwrites the same member through the embedded base: self.__FbA.__vtable := ADR(__vtable_FbB_instance). The member initializers of the vtable structs and the initializers of the itable instances become constructors too, and __FATPOINTER gets one like any struct. All of them run from the global constructors before the program starts.

The inheritance lowerer, also later, resolves the members the rewrite introduced. An access follows the declared type, so a pointer declared as POINTER TO FbB gives refInstanceB^.__vtable, and because FbB has no member of that name it becomes refInstanceB^.__FbA.__vtable. The re-annotation at the end of this participant finds the inherited member; the inheritance lowerer spells out the path.

The aggregate-return lowerer runs after this participant and sees the indirect calls like any other call. This matters when both transformations apply to the same call, for example an interface method that returns a STRING and takes an interface argument. The interface dispatch pass produces the fat pointer preamble followed by the call; the aggregate lowerer then processes each statement individually, so the order is preserved:

 // User code:
-result := reference.foo(instance);

 // After interface dispatch lowering:
+alloca __fatpointer_0: __FATPOINTER;
+__fatpointer_0.data := ADR(instance);
+__fatpointer_0.table := ADR(__itable_IA_FbA_instance);
+result := __itable_IA#(reference.table^).foo^(reference.data^, __fatpointer_0);

 // After aggregate return lowering:
+alloca __fatpointer_0: __FATPOINTER;
+__fatpointer_0.data := ADR(instance);
+__fatpointer_0.table := ADR(__itable_IA_FbA_instance);
+alloca __0: STRING;
+__itable_IA#(reference.table^).foo^(reference.data^, __0, __fatpointer_0);
+result := __0;

The result temporary carries no callee name, because this rewrite leaves the operator without a plain name.

Validation

The participant reports E126 when an instance is assigned or passed to an interface its type does not implement: Invalid assignment: 'FbX' does not implement interface 'IA'. The same code covers an interface variable assigned or passed to an unrelated or a child interface, where the message names both interfaces: Invalid assignment: 'IB' and 'IA' are not related and cannot be used polymorphically. It reports E129 when an interface variable is called directly, refInterface(): Interfaces cannot be called directly.

These checks run before interface types become __FATPOINTER, while the original interface names are still available. On failure, the lowerer drops the statement or leaves an unfilled argument temporary to avoid diagnostics about generated code. It passes its diagnostics to the driver.

Control Statements

The parser stores an IF and its ELSIF branches in one node. Later, the aggregate-return lowerer moves calls that return strings, arrays, or structs into separate statements. Each call must still run only when its condition is reached. For

IF foo(counter) = 'Hello' THEN
    // ...
ELSIF foo(counter) = 'Goodbye' THEN
    // ...
END_IF

moving both calls before the IF would run foo twice even when the first condition is true. This participant gives each ELSIF a nested IF inside the preceding ELSE. The later lowerer can then place each call beside its own condition.

flowchart LR
    pre_index[pre_index] --> index[Index] --> post_index[post_index] --> pre_annotate[pre_annotate] --> annotate[Annotate] --> post_annotate[post_annotate]
    style pre_index fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a

The participant runs once at pre_index. It moves parsed nodes and needs no index or annotations.

The participant visits each unit once. It first restructures a multi-condition IF, then visits its bodies, including the new nested IF statements. The rewrite therefore proceeds from the outside in. It also visits CASE and loop bodies without changing those nodes.

The participant exists only for the aggregate-return lowerer. Codegen compiles an IF with several condition blocks directly, and no other stage needs the split.

Transformation

An IF with n condition blocks becomes n nested IF statements. The first condition stays in place. Each ELSIF becomes an IF in the previous level’s ELSE. The original ELSE body moves to the innermost level:

 IF val = 1 THEN
     c := 'a';
-ELSIF val = 2 THEN
-    c := 'b';
-ELSIF val = 3 THEN
-    c := 'c';
 ELSE
-    c := 'x';
+    IF val = 2 THEN
+        c := 'b';
+    ELSE
+        IF val = 3 THEN
+            c := 'c';
+        ELSE
+            c := 'x';
+        END_IF
+    END_IF
 END_IF

Without an ELSE, the innermost IF has no ELSE body. The conditions and the bodies are the parser’s nodes; only the wrapping IF nodes are new. Each one takes the location of the original END_IF as its own location and as its end location, and gets a fresh node ID. Because the conditions keep their locations, a debugger still stops on every ELSIF line, and the jump at the end of each nested IF points to the one END_IF the user wrote.

The aggregate-return lowerer inserts extracted calls immediately before their containing statement. In the example, the second call now belongs inside the ELSE:

+alloca __foo0: STRING;
+foo(__foo0, counter);
-IF foo(counter) = 'Hello' THEN
+IF __foo0 = 'Hello' THEN
     ;
-ELSIF foo(counter) = 'Goodbye' THEN
-    ;
+ELSE
+    alloca __foo1: STRING;
+    foo(__foo1, counter);
+    IF __foo1 = 'Goodbye' THEN
+        ;
+    END_IF
 END_IF

Of these lines, only the ELSE and the nested IF are the work of this participant; the alloca lines and the rewritten calls come from the aggregate-return lowerer. In the generated code the second call sits in the else branch and runs only when the first condition is false.

Interactions

The participant sees loops already in the WHILE TRUE form of the loop desugarer; their guard IFs have one condition block each and pass through unchanged. The CFC transpiler, which runs later, creates only single-block IF statements as well.

The aggregate-return lowerer is the one consumer that depends on the rewrite. It walks all conditions of an IF in the scope of the statement that contains the IF, so only a single-block IF gives every condition its own place for the calls moved out of it.

Participants that run later, such as the array lowerer, generate single-condition IF statements directly.

Reference To Return

A function or method may declare REFERENCE TO as its return type. In

FUNCTION referenceFunc: REFERENCE TO INT
    VAR_INPUT
        in: REFERENCE TO INT;
    END_VAR

    in := in + 1;
    referenceFunc REF= in;
END_FUNCTION

the caller can write refVal REF= referenceFunc(refVal). Codegen cannot use a call directly on the right of REF=. The lowerer adds a result parameter that points to caller-owned storage. It replaces the call expression with setup statements, a call, and a reference to that storage. The callee copies the result value there; the caller receives a reference to the copy.

flowchart LR
    pre_index[pre_index] --> index[Index] --> post_index[post_index] --> pre_annotate[pre_annotate] --> annotate[Annotate] --> post_annotate[post_annotate]
    style pre_index fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a
    style post_annotate fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a

The participant uses two hooks. At pre_index, it records functions and methods with an inline REFERENCE TO return type. This must happen before pre-processing replaces that definition with a generated type name such as __referenceFunc_return.

At post_annotate, annotations identify callees and assignment targets. The index identifies return variables. The participant counts call sites per implementation and callee, rewrites declarations and bodies, then rebuilds the index and annotations.

Transformation

The callee

The callee loses its return type and gets a by-value VAR_INPUT variable named __<pou>_return_val at the front of its by-value input block. The variable’s type is a generated named pointer type, __referenceFunc__referenceFunc_return_val here, that is a REFERENCE TO the original referenced type and is appended to the unit’s type list. The REF= that assigns the return variable becomes a plain assignment, so the callee writes the value through the reference into the caller’s storage:

-FUNCTION referenceFunc: REFERENCE TO INT
+FUNCTION referenceFunc
     VAR_INPUT
+        __referenceFunc_return_val: REFERENCE TO INT;
         in: REFERENCE TO INT;
     END_VAR

     in := in + 1;
-    referenceFunc REF= in;
+    __referenceFunc_return_val := in;
 END_FUNCTION

The name is the POU name with every . replaced by _, prefixed with __ unless the name already starts with __: a method fb.get gets __fb_get_return_val, a function __pick gets __pick_return_val. When the callee has no by-value VAR_INPUT block, one is appended after its existing blocks. The caller always passes the reference as the first argument, so the rewrite expects the by-value input block to be the first block of the POU.

Only a REF= to the return variable is rewritten; a plain assignment to it becomes an empty statement. The type __referenceFunc_return that pre-processing generated for the original return type stays in the unit, unused.

The caller

The caller gets two VAR_TEMP variables per call site: a reference and storage for the copied value. Their names are __<callee>_return_val_N and __<callee>_return_val_store_N; the reference uses the generated type __<caller>__<callee>_return_val_N. The counter starts at 1 for each callee in an implementation. A VAR_TEMP block is added if needed. The containing statement becomes an expression list: point the reference at storage, call the callee, then use the reference:

 FUNCTION main
     VAR
         refVal: REFERENCE TO INT;
         tmpVal: INT;
     END_VAR
+    VAR_TEMP
+        __referenceFunc_return_val_1: REFERENCE TO INT;
+        __referenceFunc_return_val_store_1: INT;
+    END_VAR

     refVal REF= tmpVal;
-    refVal REF= referenceFunc(refVal);
+    __referenceFunc_return_val_1 REF= __referenceFunc_return_val_store_1;
+    referenceFunc(__referenceFunc_return_val_1, refVal);
+    refVal REF= __referenceFunc_return_val_1;
 END_FUNCTION

After the call, refVal points to the generated storage. That storage holds a copy of the result. A write through refVal no longer changes tmpVal.

The same rewrite applies wherever the call stands: as an operand (r := inst.get() + __pick(v)), as the argument of another call (r := twice(inst.get())), or as the base of a member access after property lowering (y := __fb___get_myStructuredVar_return_val_1.x). A call used as a statement on its own keeps its third part as a bare reference expression, for which codegen emits a load whose result is not used. The generated statements go in front of the statement that is visited when the call is met.

Nested calls

Nested calls are set up from the outside in and called from the inside out, because the outer call walks its arguments after it has queued its own setup and before it queues itself:

-refVal REF= doubleIt(addOne(refVal));
+__doubleIt_return_val_1 REF= __doubleIt_return_val_store_1;
+__addOne_return_val_1 REF= __addOne_return_val_store_1;
+addOne(__addOne_return_val_1, refVal);
+doubleIt(__doubleIt_return_val_1, __addOne_return_val_1);
+refVal REF= __doubleIt_return_val_1;

Property getters

Property getters already exist as methods. For PROPERTY_GET value: REFERENCE TO INT, property lowering keeps value REF= _value and appends __get_value := value. This participant recognizes value as the getter’s result. It changes the reference binding into a value copy through the result parameter and removes the appended return assignment:

-METHOD __get_value: REFERENCE TO INT
+METHOD __get_value
     VAR
         value: REFERENCE TO INT;
     END_VAR
+    VAR_INPUT
+        __fb___get_value_return_val: REFERENCE TO INT;
+    END_VAR

-    value REF= _value;
-    __get_value := value;
+    __fb___get_value_return_val := _value;
+    ;
 END_METHOD

A read x := inst.value, which the property lowerer has turned into x := inst.__get_value(), is then lowered like any other call. A setter has no return type and is not touched.

The generated variables and types take the location of the callee’s return type; the generated statements and references take the location of the call they replace, and the wrapping expression list gets a fresh node ID.

Interactions

The property lowerer creates the accessor methods and names used by getter handling. The resolver identifies qualified callees such as fb.get; the index identifies each POU’s return variable.

The init participant runs next at post_annotate. It creates constructors for the generated pointer types and constructs result storage when its type requires it, such as a struct.

The aggregate-return lowerer sees no reference return to rewrite: the function now returns VOID. Any string or struct result is in the caller’s storage. Validation and codegen process the generated pointer parameter and temporaries using their usual rules.

Init

Codegen can place constant initial values in a global instance’s static data. Other initialization needs executable code. In

PROGRAM main
    VAR
        i: DINT := 1;
        counterInstance: Counter;
    END_VAR
END_PROGRAM

the 1 can be static data, but counterInstance can need member initialization, a method table address, reference bindings, and an FB_INIT call. The init participant generates constructor functions that perform this work through assignments and calls.

flowchart LR
    pre_index[pre_index] --> index[Index] --> post_index[post_index] --> pre_annotate[pre_annotate] --> annotate[Annotate] --> post_annotate[post_annotate]
    style post_annotate fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a

The participant runs once at post_annotate. It uses the index to identify types, stateful POUs, and FB_INIT methods, and to read evaluated array bounds and literals. It does not need the annotation map.

For each unit, the participant first registers its POUs and types. It then visits types, globals, VAR_CONFIG entries, and POUs. It collects three kinds of statements: type and POU constructor bodies, initialization at the start of a POU body, and unit initialization.

The participant appends constructor POUs and inserts stack initialization at the start of existing bodies. It removes non-constant array initializers from declarations, then rebuilds the index and annotations.

Transformation

Stateful POUs

A program, function block, or class gets a <Name>__ctor function with an instance parameter named self. It visits members in declaration order. For each member, it calls the type constructor before applying the member’s initializer. A function block or class then sets its method table pointer. An FB_INIT method declared by the POU runs last.

The constructor of the intro example holds self.i := 1; and Counter__ctor(self.counterInstance);. The constructor of its Counter function block, with a member limit: INT := 10 and an FB_INIT method, is:

+FUNCTION Counter__ctor
+    VAR_IN_OUT
+        self: Counter;
+    END_VAR
+
+    __Counter___vtable__ctor(self.__vtable);
+    self.limit := 10;
+    self.__vtable := ADR(__vtable_Counter_instance);
+    self.FB_INIT();
+END_FUNCTION

The generated POU has an internal kind, Init, that the index registers like a function; FUNCTION is only the closest spelling. A derived function block starts its constructor with a call to the base constructor on the injected base member, Base__ctor(self.__Base), and skips that member during the walk, so that the base and its FB_INIT run exactly once.

Unit constructor

A source unit with initialization statements gets a constructor named __unit_<file>_<hash>__ctor. Included files do not. The file name is normalized to identifier characters; the hash uses eight hex digits from the full path. The body initializes globals, applies VAR_CONFIG entries, then constructs program instances:

+FUNCTION __unit_main_st_<hash>__ctor
+    gVal := 3;
+    __vtable_Counter__ctor(__vtable_Counter_instance);
+    main.child.hw := %IX1.0;
+    main__ctor(main);
+END_FUNCTION

The method table instances are global variables that the polymorphism lowerer added in a block behind the user’s own, so they are constructed here like any other global, after them. VAR_GLOBAL CONSTANT blocks are skipped, because their values are static data only. Globals from included files are skipped too, and globals in an {external} block are skipped unless --generate-external-constructors is set.

Types

Every user type gets a constructor as well. A struct constructs and initializes its fields exactly like a POU does its members, and an enum, a subrange, or a renamed scalar with a default assigns self directly. An alias type such as TYPE MyPoint: Point; END_TYPE calls the constructor of the type it renames. A struct literal is decomposed into one assignment per leaf; for a struct Line with the fields start: Point; and stop: Point := (x := 5); the constructor is:

+FUNCTION Line__ctor
+    VAR_IN_OUT
+        self: Line;
+    END_VAR
+
+    Point__ctor(self.start);
+    Point__ctor(self.stop);
+    self.stop.x := 5;
+END_FUNCTION

Built-in types, generic types, and variable-length arrays get no constructor. The types the pre-processor created for inline declarations, such as __Refs_r for the member r below, are user types too and get constructors like every other type; most of them are empty.

References and pointers

A REFERENCE TO variable, an AT alias, and a hardware-mapped variable are initialized with REF=, because their initial value is an address, not a value; a REF(...) call in the initializer is unwrapped. A pointer keeps its := with the ADR or REF call. Inside a stateful POU, a bare name in the initializer that is a member of that POU is qualified with self.:

 FUNCTION_BLOCK Refs
     VAR
         x: DINT;
         r: REFERENCE TO DINT REF= x;
         p: POINTER TO DINT := ADR(x);
     END_VAR
 END_FUNCTION_BLOCK
+FUNCTION Refs__ctor
+    VAR_IN_OUT
+        self: Refs;
+    END_VAR
+
+    __Refs___vtable__ctor(self.__vtable);
+    __Refs_r__ctor(self.r);
+    self.r REF= self.x;
+    __Refs_p__ctor(self.p);
+    self.p := ADR(self.x);
+    self.__vtable := ADR(__vtable_Refs_instance);
+END_FUNCTION

An alias px AT x: DINT gets self.px REF= self.x in the same way. For a variable declared AT %IX1.2 the pre-processor has already injected the initializer __PI_1_2, the backing global of that address, so it gets REF= __PI_1_2 here. A template address such as %I* gets no assignment in the POU constructor; the VAR_CONFIG entry that binds it becomes the assignment in the unit constructor shown above.

Arrays

An array whose element type has a constructor gets a loop that constructs every element, one loop per dimension, so that defaults, method table pointers, and FB_INIT reach every element. The bounds are taken from the index as constants, because the declaration may name a constant of the declaring POU that does not resolve inside the constructor. The loop is written in the WHILE TRUE form that the loop desugarer would have produced, since that participant has already run:

 PROGRAM main
     VAR
         motors: ARRAY[0..3] OF Motor;
     END_VAR
 END_PROGRAM
+FUNCTION __main_motors__ctor
+    VAR_IN_OUT
+        self: __main_motors;
+    END_VAR
+
+    alloca __main_motors__idx0: DINT;
+    __main_motors__idx0 := 0;
+    WHILE TRUE DO
+        IF __main_motors__idx0 > 3 THEN
+            EXIT;
+        END_IF
+        Motor__ctor(self[__main_motors__idx0]);
+        __main_motors__idx0 := __main_motors__idx0 + 1;
+    END_WHILE
+END_FUNCTION

An array of a built-in type gets an empty constructor. An array literal that is not constant, such as [five(), 2], cannot be static data: the participant assigns it in the constructor and removes it from the declaration, so codegen emits zeros for the global. A constant array literal stays in the declaration and is additionally assigned in the constructor, from the version the constant evaluator folded.

Stack variables

Function and method locals, and VAR_TEMP variables in any POU, are initialized at the start of the POU body. These statements use no self. prefix. A function also constructs its return value when needed: FUNCTION useLine: Point with localLine: Line starts with Line__ctor(localLine); and Point__ctor(useLine);. VAR_IN_OUT variables use caller-owned storage and get no constructor call.

Linkage

For external or included types and POUs, the participant declares constructors without bodies. The linker must find their definitions in the library. Calls are still generated where the types are used. --generate-external-constructors, also implied by --constructors-only, generates bodies for {external} items. This lets a library be built from its external declarations.

Interactions

The participant depends on three earlier participants. The polymorphism lowerer (post_index) added the __vtable member, the __vtable_<Name> struct types, and the __vtable_<Name>_instance globals that the constructors assign and construct. The inheritance lowerer (pre_index) injected the __<Base> member that the base constructor call addresses. The reference-to-return participant is registered directly before it and generates new types and variables at post_annotate, and the init participant runs after it so that those get constructors too.

Pre-processing supplies names for inline types. Constant evaluation supplies array bounds and folded array literals.

Every later participant sees the constructors as ordinary POUs and processes their bodies. The inheritance lowerer rewrites the self.__vtable assignment of a derived block into self.__Base.__vtable, and the array lowerer turns the assignment of an array literal with non-constant elements in a constructor into one assignment per element.

Codegen handles constructor bodies with a few special rules. In Init and ProjectInit, assignment to an alias or REFERENCE TO stores an address. This permits VAR_CONFIG to bind hardware storage. Constructors have no debug information, and codegen errors identify their type. Validation skips POU-level checks for these generated kinds.

A unit that uses another unit’s type calls its constructor; the linker supplies the definition. Merging units into one LLVM module also combines their llvm.global_ctors entries. The Initializers chapter follows this work alongside static initialization.

Retain

RETAIN marks storage intended to survive a power cycle. The compiler places retained globals in the .retain linker section; the runtime must provide persistent storage. Program members need a rewrite because a linker section applies to a global symbol, not an individual struct field. In

PROGRAM Main
    VAR RETAIN
        counter: INT := 5;
    END_VAR

    counter := counter + 1;
END_PROGRAM

counter is a member of the program instance. The participant moves its storage into a retained global and leaves an alias pointer in the instance. Codegen can then apply its usual rule for retained globals.

flowchart LR
    pre_index[pre_index] --> index[Index] --> post_index[post_index] --> pre_annotate[pre_annotate] --> annotate[Annotate] --> post_annotate[post_annotate]
    style post_index fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a

The participant runs once at post_index. A variable requires retained storage if its block has RETAIN or its type contains a retained member. The index follows nested structs, arrays, and aliases, with cycle detection. The participant uses that result.

For each unit, the participant visits globals and then POUs. It appends extracted variables to a VAR_GLOBAL RETAIN block, creating one if needed. It then rebuilds the index so that annotation sees the new globals and pointer types.

Transformation

Program variables

A program has exactly one instance, so a retained member can become a global of its own without a change in meaning. The variable’s type and initializer move to a new global named __<program>_<variable>__retain. The variable itself stays in its block with the same name, but its type becomes an alias pointer to the global, and its initializer the global itself. The block loses its RETAIN modifier:

+VAR_GLOBAL RETAIN
+    __Main_counter__retain: INT := 5;
+END_VAR
+
 PROGRAM Main
-    VAR RETAIN
-        counter: INT := 5;
+    VAR
+        counter: __Main_counter__retain_ptr := __Main_counter__retain;
     END_VAR

     counter := counter + 1;
 END_PROGRAM

__Main_counter__retain_ptr is a named pointer type with automatic dereferencing, the same kind of node the parser produces for an AT alias such as x AT y: INT. It is registered as a user type scoped to the program. Its name comes from the global and not from the variable: the pre-processor has already extracted inline types under __Main_<variable>, so an inline array or struct in the retain block would clash with its own pointer type.

The body is not changed. The resolver treats an alias variable as automatically dereferenced, so counter := counter + 1 reads and writes through the pointer. The rewrite applies to every block of the program that carries RETAIN, VAR_INPUT and VAR_TEMP included; a retained temporary becomes a local pointer that is set to the global at the start of every call.

Function block instances

A retained member of a function block is not extracted. A function block can have many instances, and each one lives inside whatever declares it, so the participant keeps the VAR RETAIN block of the function block as it is and retains the container instead. A program variable whose type retains transitively is moved like a plain retained variable:

 FUNCTION_BLOCK Fb
     VAR RETAIN
         a: INT := 5;
     END_VAR
 END_FUNCTION_BLOCK

+VAR_GLOBAL RETAIN
+    __Main_x__retain: Fb;
+END_VAR
+
 PROGRAM Main
     VAR
-        x: Fb;
+        x: __Main_x__retain_ptr := __Main_x__retain;
     END_VAR
 END_PROGRAM

The whole instance lands in .retain, its non-retained members and its method table pointer included. The same happens for a struct with a member of type Fb, for an array of Fb, and for an instance nested several function blocks deep.

Globals

A global in a plain VAR_GLOBAL block whose type retains transitively is moved into the retain block; a global declared in VAR_GLOBAL RETAIN stays where it is:

 VAR_GLOBAL RETAIN
     explicit: Fb;
+    implicit: Fb;
 END_VAR

 VAR_GLOBAL
-    implicit: Fb;
     x: INT;
 END_VAR

Codegen would place implicit in .retain even without the move, because it asks the same transitive question for every global it emits. The move makes the answer visible in the lowered tree. A block created by the participant has an internal source location, internal linkage, and public access.

Function blocks, functions, and methods are otherwise left alone. Their VAR RETAIN blocks keep the modifier, and a VAR RETAIN in a function or method has no effect: the variable stays on the stack and no diagnostic is reported.

Interactions

The polymorphism lowerer runs before this participant, so the __vtable member it adds to every function block ends in the retained instance. The retain flag comes from the parser, which accepts RETAIN on every block kind and parses NON_RETAIN without a record of it, through the indexer, which copies the flag of a block to each of its members. A struct member declared in a TYPE block and a return variable are never flagged themselves.

The resolver marks the replacement variable for automatic dereferencing. Codegen loads its pointer before each access.

The init participant binds each alias to its retained global. For example, the program constructor stores @__Main_counter__retain in the counter field. The unit constructor also initializes the retained global, so the declared initial value is written at every start. Retention across restarts therefore depends on how the runtime handles initialization.

Generic

A generic function declares an interface with type parameters. Each concrete implementation is a separate function. In

FUNCTION times_two<T: ANY_NUM>: T
    VAR_INPUT
        val: T;
    END_VAR
END_FUNCTION

the template names the type parameter T and constrains it to ANY_NUM. Its implementations are ordinary functions such as times_two__INT and times_two__REAL, written in Structured Text or supplied by a library. Pre-processing creates the scoped type __times_two__T. The resolver initially records calls against the template.

Codegen cannot call a template. The generic lowerer works out the concrete type of every type parameter from the arguments and rewrites the call to the implementation name. When no implementation of that name exists, it declares one as an external function and leaves it to the linker to find it.

flowchart LR
    pre_index[pre_index] --> index[Index] --> post_index[post_index] --> pre_annotate[pre_annotate] --> annotate[Annotate] --> post_annotate[post_annotate]
    style post_annotate fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a

The participant runs at post_annotate. The index supplies template and parameter declarations. Annotations identify each callee and argument type.

Each pass visits call arguments before the call itself and skips generic template bodies. It collects declarations for missing implementations, appends them, and rebuilds the index and annotations. Passes continue until none changes the project. Even a project without generic calls performs one analysis round.

Transformation

A call to an existing implementation

A call to an existing implementation changes only the call operator. Arguments for a type parameter contribute candidate types, including through pointer, array, and variadic parameters. The widest candidate wins. The parameter’s constraint sets a minimum: USINT for ANY_INT and ANY_NUM, or REAL for ANY_REAL. Thus ANY_REAL selects REAL for a DINT argument and LREAL for a LINT. String candidates use STRING or WSTRING without a length.

The implementation name is the template name, __, and the derived type of each type parameter in declaration order, mix__INT__REAL for mix<T: ANY_INT, U: ANY_REAL>:

-a := times_two(INT#100);
-b := times_two(2.5);
+a := times_two__INT(INT#100);
+b := times_two__REAL(2.5);

A call without an implementation

A call without an implementation causes the participant to declare one. It copies the template signature, substitutes concrete types, and adds an external declaration to the template’s unit. It creates at most one declaration per implementation name:

-c := times_two(DINT#7);
+c := times_two__DINT(DINT#7);
+
+{external}
+FUNCTION times_two__DINT: DINT
+    VAR_INPUT
+        val: DINT;
+    END_VAR
+END_FUNCTION

Later index runs keep the new declaration. Codegen emits declare i32 @times_two__DINT(i32), and the linker must find its definition.

This is how library generics work. The standard library header declares {external} FUNCTION LEN<T: ANY_STRING>: DINT and nothing else. A call LEN('abc') becomes LEN__STRING, the participant declares it, and the linker resolves it against the library. A template marked {external} itself changes nothing: its implementations are external whether a hand or this participant declared them. Where the header provides a Structured Text implementation, such as LEFT__STRING, that one is used and nothing is declared.

Note

The rewritten operator keeps the location of the original call, so diagnostics and debug information still point at times_two(DINT#7) in the source. The declared implementation takes the location of the template.

Nested calls

Nested calls need more than one pass. In times_two(times_two(a)) the inner call is rewritten first, but the annotation of the outer call’s argument still says __times_two__T, so the outer call would offer a generic type and is left for the next pass. After the re-annotation the argument is an INT:

-a := times_two(times_two(a));
+a := times_two(times_two__INT(a));
-a := times_two(times_two__INT(a));
+a := times_two__INT(times_two__INT(a));

The third pass changes nothing and ends the loop. A call whose type parameter gets no offer at all, because no argument is bound to a parameter of that type, is skipped in every pass and stays a generic call.

Two kinds of call are never touched. Built-in generics such as MUX, SEL, ADD, or SHL are resolved by the resolver and generated inline by codegen, so there is no implementation to pick. A call inside the body of a generic template works on T itself, and a template is never generated; such a call stays as written, and the validator reports it as an unresolved generic type (E064).

Interactions

Pre-processing gives each type parameter a scoped name and constraint. The index records the template’s parameters. The resolver identifies the template at each call and records the argument types. This participant uses those three results to select an implementation.

The resolver and this participant share the same derivation of the concrete types and of the implementation name, so a built-in and a user generic resolve by the same rules. The CFC participant, which runs first, also uses that derivation to infer the types of the temporaries wired to generic blocks.

The aggregate-return lowerer runs next and expects concrete signatures. It handles generated external declarations like user functions. For example, MID__STRING: STRING receives a result-buffer parameter and becomes declare void @MID__STRING(ptr, ptr, i32, i32).

Codegen defines no function for a generic or external implementation and emits a declaration for every external one that is called. The uniqueness check of the validator skips generic functions, so several templates can share a name and differ only in their type parameters.

The pre-processor runs during each new index round and appends generic parameter types again.

Validation

Before it rewrites a call, the participant checks every argument against the nature of its type parameter. A violation such as a REAL for ANY_INT is reported as E062, once per call, although the call is visited again in every pass. An integer for ANY_REAL is allowed, because it resolves to a real type. A call with a violation is left unresolved, so no implementation is declared for it and no follow-up diagnostic appears.

After rewriting, the call names a concrete function and no longer carries the template constraints. The participant must therefore check them first.

Aggregate Return

A function or method can return a string, array, or struct. Such a result can occupy many bytes. The compiler passes it through caller-owned storage. In

FUNCTION greet: STRING
    VAR_INPUT
        who: STRING;
    END_VAR

    greet := who;
END_FUNCTION

the caller writes s := greet('world'). The lowerer adds a VAR_IN_OUT result parameter to greet. At the call site, it allocates a result buffer, passes its address, then copies the result to s.

flowchart LR
    pre_index[pre_index] --> index[Index] --> post_index[post_index] --> pre_annotate[pre_annotate] --> annotate[Annotate] --> post_annotate[post_annotate]
    style post_annotate fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a

The participant runs once at post_annotate. The index identifies aggregate return types and parameter declarations. Annotations identify each callee and its argument types.

For each unit, the participant rewrites POU declarations, implementation bodies, and interface method declarations. It keeps the original index and annotations throughout this pass so that calls still describe the original signatures. It then rebuilds both.

Transformation

The callee

The callee gets a leading VAR_IN_OUT parameter with the POU’s name. The AST keeps the return type but marks it as aggregate; the diff below shows the equivalent signature. The index no longer creates a separate return variable. Assignments to greet now write through the result parameter:

-FUNCTION greet: STRING
+FUNCTION greet
+    VAR_IN_OUT
+        greet: STRING;
+    END_VAR
     VAR_INPUT
         who: STRING;
     END_VAR

     greet := who;
 END_FUNCTION

Methods and interface methods are rewritten the same way, so a method Named.getName: STRING gets an in-out variable getName. Generic functions are skipped; only their concrete instances, which the generic lowerer created before, are rewritten. An inline return type such as ARRAY[0..1] OF DINT has already been replaced by the index pre-processing with a reference to a generated type, __pair_return, and that is the type the in-out variable gets.

The caller

The caller gets one temporary of the return type per call. The containing statement becomes an expression list: allocate the temporary, pass it to the callee, then use it in the original statement. The internal alloca node is described in Loop Desugar:

-s := greet('world');
+alloca __greet0: STRING;
+greet(__greet0, 'world');
+s := __greet0;

The temporary is named __<callee><N> after the last part of the callee’s name, so inst.getName() gives __getName3. N is one counter for the whole compiler run, shared by every temporary this participant creates.

The rewrite handles calls in comparisons, IF conditions, CASE bodies, and other calls. Setup appears before the containing statement. A standalone call leaves a bare reference to the result temporary. Built-ins such as SEL and MUX keep their own codegen paths.

Nested calls

Nested calls are moved out from the inside out, because a call walks its arguments before it queues its own allocation and call:

-s := shout(greet('x'));
+alloca __greet1: STRING;
+greet(__greet1, 'x');
+alloca __shout2: STRING;
+shout(__shout2, __greet1);
+s := __shout2;

Named arguments

When any argument of the call is written with :=, the temporary is passed by name too. The name is the return name of the callee, the in-out variable the callee just received:

-greet(who := 'formal');
+alloca __greet3: STRING;
+greet(greet := __greet3, who := 'formal');
+__greet3;

For a call through a function pointer, fp^(inst), the temporary comes second, after the instance argument, and is named __4 because the operator has no plain name.

Pinned temporaries

An allocation node records whether its temporary is limited to the statement. Codegen reserves the stack slot at function entry and marks its lifetime from allocation to the end of the expression list. Non-overlapping temporaries can then share a slot.

When the address of the result leaves the statement, inside an argument of ADR or REF or on the right side of REF=, the temporary is pinned instead. It gets no such markers and lives for the whole function call:

-ptr := ADR(greet('pin'));
+alloca __greet5: STRING;
+greet(__greet5, 'pin');
+ptr := ADR(__greet5);

Output assignments

The participant also rewrites output arguments of functions and methods. Normally, result => i1 passes the address of i1 directly. A temporary is needed if the output requires conversion, targets a bit such as b.%X0, or uses incompatible string capacities. For strings, this includes a sized/unsized mismatch or a callee buffer larger than the target.

In these cases the argument is replaced by a temporary of the parameter’s type, and a copy-back assignment after the call lets codegen do its usual conversion, bit store, or length-capped copy:

-libFunction(inVar1 := 0, result => i1);
+alloca __libFunction_result6: REAL;
+libFunction(inVar1 := 0, result => __libFunction_result6);
+i1 := __libFunction_result6;

The temporary is named __<callee>_<parameter><N> and is always statement-scoped. Positional outputs are rewritten the same way. Outputs of aggregate type other than sized strings, literal arguments, and calls to function blocks or programs are left alone; codegen copies those outputs itself.

The allocation and the references to a temporary take the location of the call; the wrapping expression list takes the location of the statement it replaces and a fresh node ID. The in-out variable takes the location of the POU name.

Interactions

The participant depends on most of the participants before it. The loop desugarer has moved every loop condition into a guard IF inside a WHILE TRUE body, so a call in a loop condition is moved out inside the loop and runs on every iteration. The control statement participant has split every ELSIF into a nested IF, so each condition has a statement list of its own for the calls moved out of it. The polymorphism lowerer wraps an interface instance capture in ADR(...), which is one reason why ADR pins a temporary.

The reference-to-return participant has already removed reference returns, so they are skipped here. The init participant has added calls such as Point__ctor(origin) for struct results. After this rewrite, that constructor fills the caller’s buffer through the in-out parameter. Codegen only zero-fills the temporary before the call.

The generic lowerer has replaced a generic call by a call to a concrete instance, so LEFT(s, 2) reaches this participant as LEFT__STRING. Its return type STRING[__STRING_LENGTH] gives a 2049-byte temporary __LEFT__STRING7: __LEFT__STRING_return, and the copy into s is cut to the length of s like any string assignment.

Later stages see an ordinary void function with a pointer parameter. The index registers no return variable for a POU whose return type is marked as aggregate, and the in-out variable is its first declared parameter. Codegen passes VAR_IN_OUT parameters as pointers (see Codegen, Functions), so a method Named.getName compiles to void @Named__getName(ptr instance, ptr getName) and the callee writes its result through the pointer like any in-out variable.

The inheritance lowerer, registered after this participant, descends into the expression lists it created; the array lowerer looks only at the top-level statements of a body and leaves these lists alone.

Inheritance

A function block or class can extend another one and use the members of its base as if they were its own. In

FUNCTION_BLOCK Base
    VAR
        counter: DINT;
    END_VAR
END_FUNCTION_BLOCK

FUNCTION_BLOCK Child EXTENDS Base
    counter := counter + 1;
END_FUNCTION_BLOCK

the resolver finds counter in Base and records Base.counter. Codegen needs an explicit path through the instance layout. The inheritance lowerer adds a first member, __Base: Base, to Child, then rewrites the assignment as __Base.counter := __Base.counter + 1.

flowchart LR
    pre_index[pre_index] --> index[Index] --> post_index[post_index] --> pre_annotate[pre_annotate] --> annotate[Annotate] --> post_annotate[post_annotate]
    style pre_index fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a
    style post_annotate fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a

At pre_index, the participant inserts a __<Base> member into every function block or class with EXTENDS. The index and resolver then process it like a declared member.

At post_annotate, it uses the index’s base-type chains and the annotations on member accesses. The first pass replaces SUPER; the second inserts paths through embedded bases. Each reference is visited after its base, so nested accesses are completed from the inside out.

Afterwards the participant sends the project through annotate again. The index is not rebuilt, because this hook changes no declaration. The participant reports no diagnostics.

Transformation

Embedded base

The derived block gets a new local variable block with one variable, named after the base with a __ prefix and typed with the base:

 FUNCTION_BLOCK Child EXTENDS Base
+    VAR
+        __Base: Base;
+    END_VAR
     VAR
         offset: DINT;
     END_VAR

The base member is first, so %Child = type { %Base, i32 } starts at the address of its %Base part. A chain C EXTENDS B EXTENDS A embeds __B: B in C and __A: A in B. The EXTENDS clause remains for index lookup. The new member and block have internal source locations.

Inherited members

Inside the derived block, its methods, its actions, and the initializers of its variables, a member declared in a base is reached through the embedded base. limit below is a constant of Base, which is what an initializer may name:

 FUNCTION_BLOCK Child EXTENDS Base
     VAR
-        offset: DINT := limit;
+        offset: DINT := __Base.limit;
     END_VAR

     METHOD reset
-        counter := 0;
+        __Base.counter := 0;
     END_METHOD

-    counter := counter + 1;
+    __Base.counter := __Base.counter + 1;
 END_FUNCTION_BLOCK

The lowerer identifies both the lookup type and the member’s declaring type. An explicit base supplies the lookup type; a plain name uses the current POU, or its parent for a method or action. The annotation supplies the declaring type through a qualified name such as Base.counter.

If these types differ, the lowerer inserts one embedded-base step per link. For a declared in A, an access from C becomes __B.__A.a. Local members such as offset, globals, and local variables need no added path. Generated identifiers have internal locations; the original member keeps its source location.

Access from outside

The same rule applies to an instance, a pointer, or an array element of the derived type, wherever it is used. The base expression has the type Child, the member’s home is Base:

-child.counter := 3;
-child.step();
-pc^.x := 1;
+child.__Base.counter := 3;
+child.__Base.step();
+pc^.__Base.x := 1;

child.step() stays a direct call; it now names the method on the embedded part, Base.step. THIS^.counter inside Child is handled the same way and becomes THIS^.__Base.counter, because THIS^ has the type Child.

Method table pointer

Every function block or class carries a pointer, __vtable, to its method table, the struct of method addresses that the polymorphism lowerer builds for it. That lowerer adds the pointer only to blocks without a base, so in a derived block __vtable is an inherited member with home Base. The dispatch the polymorphism lowerer generated and the constructor the init participant generated are rewritten like user code:

-__vtable_Child#(THIS^.__vtable^).step^(Base#(THIS^));
+__vtable_Child#(THIS^.__Base.__vtable^).step^(Base#(THIS^));
 FUNCTION Child__ctor
     VAR_IN_OUT
         self: Child;
     END_VAR

     Base__ctor(self.__Base);
-    self.__vtable := ADR(__vtable_Child_instance);
+    self.__Base.__vtable := ADR(__vtable_Child_instance);
 END_FUNCTION

The call Base__ctor(self.__Base) already addressed the injected member when the init participant wrote it. After the rewrite, the one pointer in the root part of a Child instance points at the table of Child, which is what makes a call through a REF_TO Base reach the overriding method.

SUPER

SUPER^ denotes the base part of the current instance and becomes a reference to the embedded member; SUPER without ^ is a pointer to it and becomes REF(__Base):

     METHOD describe: DINT
-        describe := SUPER^.describe() + 10;
+        describe := __Base.describe() + 10;
     END_METHOD

-    p := SUPER;
+    p := REF(__Base);

The base is the super class of the block whose body is walked, the parent block inside a method. The replacement takes the location of the keyword and keeps the original SUPER node as metadata, so that later checks can tell it apart from a user-written __Base. The new node is resolved on the spot with a resolver limited to that statement, so the second pass knows its type: SUPER^.counter needs no further step, while SUPER^.z with z declared in the grandparent becomes __Base.__Grandparent.z.

SUPER in a position where it is not valid, after a dot, as .SUPER, or as the target of a cast, is left as it is for the validator. A method called through SUPER^ is a direct call of the base implementation, because the polymorphism lowerer does not route SUPER calls through the method table. This is how an overriding method calls the version it overrides.

Call arguments

The name on the left of an argument assignment, child(setpoint := 3), is not rewritten even when setpoint is declared in Base; the lowerer skips the left side of assignments inside a call. The name identifies a parameter, not a place in the struct. The resolver records with each argument how many EXTENDS steps lie between the called block and the parameter’s home, and codegen emits the path itself.

Interactions

The index supplies base-type chains. The resolver supplies the declaring POU of each member and the type of each base expression. The lowerer turns this information into explicit member paths.

Before this participant runs, SUPER^ has no type, and the resolver resolves a member behind it, SUPER^.text, like a plain name in the current block. The earlier participants therefore see it annotated and rewrite it: the property lowerer turns SUPER^.scaled into SUPER^.__get_scaled(), and the aggregate-return lowerer moves SUPER^.text() into a temporary. Both results reach this participant as ordinary references and get their __Base steps here, so an inherited property read from outside ends as child.__Base.__get_scaled().

Polymorphism uses the embedded base layout. A derived method table already names inherited implementations, such as ADR(Base.step). This participant supplies the path to the table pointer, THIS^.__Base.__vtable. Casting the instance to Base is valid because that base occupies the first field.

The init participant writes Base__ctor(self.__Base) and skips the member during its walk, so the base is constructed exactly once. Every other statement of a constructor, such as self.offset := self.__Base.limit, is rewritten like user code.

This participant runs late so that it can rewrite member accesses introduced by property, polymorphism, initializer, and call lowering. Only the array lowerer follows.

Array

Codegen can copy an array literal such as [1, 2, 3] from one constant. A literal with runtime values needs executable statements instead. In

PROGRAM main
    VAR
        readings: ARRAY[0..2] OF DINT := [sample(), 0, sample()];
    END_VAR
END_PROGRAM

the init participant moves the initializer into main__ctor as self.readings := [sample(), 0, sample()];. The array lowerer then splits it into element assignments. Large repeated segments can become loops.

flowchart LR
    pre_index[pre_index] --> index[Index] --> post_index[post_index] --> pre_annotate[pre_annotate] --> annotate[Annotate] --> post_annotate[post_annotate]
    style post_annotate fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a

The participant runs once at post_annotate, after all other participants. It uses the index for target array types, evaluated bounds, and constant names. It does not need annotations.

For each unit, it first resolves the (N)(value) repetition form in declarations and bodies. It then lowers array literal assignments at the top level of each body. Finally, it adds a VAR_TEMP block for generated loop counters.

The participant rebuilds the index and annotations. Validation and codegen use these final results.

Transformation

Element lists

An assignment whose right side is an array literal is lowered only when one element is not constant: a variable, a call, or a struct literal such as (x := 1), which codegen cannot evaluate inside an array. Each element becomes an assignment to its own position, counted from the lower bound of the array. The left side is the original reference with an index appended; the element expression is the parser’s node and keeps its location:

-self.readings := [sample(), 0, sample()];
+self.readings[0] := sample();
+self.readings[1] := 0;
+self.readings[2] := sample();

A literal with only constant elements is left alone and takes the memcpy path of codegen. The lowerer decides without the index, so a reference to a constant counts as a runtime value here. Such a literal never reaches it in a constructor, though: the init participant has already replaced it by the version the constant evaluator folded, in which every constant is a number.

Repetition

The spelling n(value) repeats one element n times. Fewer than 32 repetitions are unrolled into one assignment each, with the element expression copied into every one of them, so a call is called once per element. From 32 repetitions on, the participant emits a counted loop in the WHILE TRUE form that the loop desugarer would have produced, since that participant will not run again. The counter is a VAR_TEMP variable of the POU named __literal_idx, one per POU and shared by all such loops in it:

+VAR_TEMP
+    __literal_idx: DINT;
+END_VAR
-self.big := [40(sample())];
+__literal_idx := 1;
+WHILE TRUE DO
+    IF __literal_idx > 40 THEN
+        EXIT;
+    END_IF
+    self.big[__literal_idx] := sample();
+    __literal_idx := __literal_idx + 1;
+END_WHILE

for big: ARRAY[1..40] OF DINT. A literal may mix segments, [2(a), b, 2(c)]; each segment is lowered on its own at the position it occupies, and the threshold applies per segment, so [10(v), 10(v), 10(v), 10(v)] is unrolled into 40 assignments.

Several dimensions

For grid: ARRAY[0..1, 0..2] OF DINT, the positions of the flat literal are converted into one index per dimension, the last dimension varying fastest and every index offset by its lower bound:

-self.grid := [sample(), 1, 2, 3, 4, sample()];
+self.grid[0, 0] := sample();
+self.grid[0, 1] := 1;
+self.grid[0, 2] := 2;
+self.grid[1, 0] := 3;
+self.grid[1, 1] := 4;
+self.grid[1, 2] := sample();

A repetition of at least 32 elements that fills a multi-dimensional array becomes nested loops. Each dimension gets a counter, named __literal_idx_0, __literal_idx_1, and so on. For [40(sample())] on ARRAY[0..7, 1..5], the counters run over 0..7 and 1..5. The innermost body assigns self.big[__literal_idx_0, __literal_idx_1]. A repetition that fills only part of such an array is unrolled.

Constant multipliers

A constant can specify the repeat count: [(N)(0.5)]. The parser treats this as a call to a parenthesized expression. The lowerer looks up N in the enclosing POU or globals. If it is an integer constant, the call becomes a repetition node. This also applies to declaration initializers, which can then remain static data.

The generated assignments, loops, indices, and counters carry internal source locations; the copied left side and the element expressions keep the locations they had.

Interactions

The init participant puts non-constant array initializers into constructors or the start of function bodies. These are top-level assignments, where this lowerer can find them. It also handles array literal assignments that the user writes at that level.

The loop desugarer and the control statement participant ran long before, so the generated WHILE TRUE loops and single-block IF guards are written directly in their final form.

Outputs

After these chapters you know what a run can produce besides machine code, and what decides the shape of each file. Both outputs are built from the declarations and index entries that the pipeline has already collected.

There are two: the C headers of a project, which replace code generation and end the run, and the map of the variables that are bound to hardware addresses, which a normal build or a --check run can write beside its artifact. Each subchapter follows one output from the option that asks for it to the file that it writes, with the model and the naming rules in between.

Header Generator

C code and Structured Text code can call each other through linked functions. Both need compatible declarations. For

FUNCTION scale: DINT
    VAR_INPUT
        value: DINT;
        factor: INT;
    END_VAR
    VAR_IN_OUT
        total: DINT;
    END_VAR
END_FUNCTION

the C declaration is int32_t scale(int32_t value, int16_t factor, int32_t* total);. The widths, pointer parameters, and symbol name must match. The header generator translates the validated project’s declarations into a C header. The examples below show its mappings and current limits.

flowchart LR
    parse[Parse] --> index[Index] --> annotate[Annotate] --> validate[Validate] --> codegen[Codegen] --> link[Link]
    validate --> header[Header]
    style header fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a

The generator runs after validation, in place of codegen: the run stops once the headers are written and no object file is produced. It reads the declarations of every unit and the global index; the bodies are never looked at.

Invocation

These commands request headers and stop the pipeline after validation:

CommandHeadersName
plc --generate-headers a.st b.stone per source file, next to ita.h, b.h
plc --generate-headers a.st b.st -o apione combined header, next to the first source with declarationsapi.h
plc generate plc.json headersone per source of the build description, next to itsource name

--header-output <dir> moves the headers into a directory, which is created when it is missing. It works with both commands. The three remaining options belong to the generate subcommand only. --header-prefix <name> names the header after the prefix instead of after the source, so every unit writes to the same file and only the declarations of the last unit survive. --header-language rust is accepted by the command line and rejected by the generator, because only C is implemented. --include-stubs is parsed and ignored.

A unit whose declarations are all external or included, such as an -i include file, produces an empty model and no file. The include guard is built from the path of the header, relative to the working directory when the header is inside it: each / and . becomes _, a character that is not a letter, a digit, or _ is dropped, the result is upper case, and one _ is appended. So include/motor.h gets INCLUDE_MOTOR_H_. A combined header is the exception, because its guard is built before the .h is appended: -o api gets API_.

From declarations to a template

The generator first collects declarations in a template model, then renders that model as text:

pub struct TemplateData {
    /// Aliases (typedefs), structs, and enums
    pub user_defined_types: UserDefinedTypes,

    /// extern declarations, including one instance per program
    pub global_variables: Vec<Variable>,

    /// Prototypes: functions, function block bodies, methods, actions
    pub functions: Vec<Function>,
}

pub struct Variable {
    /// Already a C type name, such as `int32_t` or `Point*`
    pub data_type: String,

    pub name: String,

    /// Default, Array(size), Declaration(value), Variadic, Struct, or MultidimensionalArray(sizes)
    pub variable_type: VariableType,
}

The generator visits globals, user types, POUs, and implementations. It skips external and included declarations, generated constructors, and types with names that start with __. The sizes of an inline array declaration, such as a: ARRAY[0..1, 0..2] OF DINT inside a POU, are read from the helper type that the pre-processor created for it.

Built-in types are translated from the compiler’s table of built-in types. Integers use intN_t or uintN_t; BOOL uses bool; REAL and LREAL use float_t and double_t. Date and time types use time_t. Strings become arrays of char or int16_t, including a terminator slot. User type names stay unchanged.

Array bounds and string lengths that are constant expressions rather than literals are evaluated through the index, so STRING[MESSAGE_LEN] with a constant MESSAGE_LEN := 80 becomes char[81]. A bound that is not constant stops the run with an error.

The example, rendered

The project below covers the main declaration forms. Each following subsection pairs a construct with trimmed C output.

TYPE Speed: (Slow, Fast := 10, Turbo); END_TYPE

TYPE Point:
    STRUCT
        x: DINT;
        label: STRING[20];
    END_STRUCT
END_TYPE

TYPE Message: STRING[80]; END_TYPE
TYPE Grid: ARRAY[0..1, 0..2] OF DINT; END_TYPE
TYPE PointRef: REF_TO Point; END_TYPE
TYPE Percent: INT(0..100); END_TYPE

VAR_GLOBAL
    counter: DINT;
    origin: Point;
    callback: __FPOINTER scale;
END_VAR

FUNCTION scale: DINT
    VAR_INPUT
        value: DINT;
    END_VAR
    VAR_INPUT {ref}
        p: Point;
    END_VAR
    VAR_IN_OUT
        total: DINT;
    END_VAR
    VAR_OUTPUT
        overflow: BOOL;
    END_VAR
END_FUNCTION

FUNCTION sum: DINT
    VAR_INPUT
        args: {sized} DINT...;
    END_VAR
END_FUNCTION

FUNCTION_BLOCK Buffer
    VAR_INPUT
        limit: INT;
    END_VAR
    VAR
        count: DINT;
    END_VAR
    METHOD push: BOOL
        VAR_INPUT
            value: DINT;
        END_VAR
    END_METHOD
END_FUNCTION_BLOCK

ACTIONS Buffer
    ACTION reset
    END_ACTION
END_ACTIONS

FUNCTION_BLOCK RingBuffer EXTENDS Buffer
    VAR
        head: DINT;
    END_VAR
END_FUNCTION_BLOCK

PROGRAM main
    VAR
        i: DINT;
    END_VAR
END_PROGRAM

Named types

Named types become typedefs. An enum is a typedef of its numeric type plus one #define per variant, named Type_Variant so that variants of different enums cannot clash. The index pre-processor has already given every variant a value, but the generator reads back only a literal one and counts one up from the variant before it for the rest. A variant with a computed value, such as A := 2+3, therefore gets the wrong number:

typedef int16_t Percent;
typedef Point* PointRef;
typedef int32_t Grid[2][3];
typedef char Message[81];

typedef int32_t Speed;
#define Speed_Slow ((Speed)0)
#define Speed_Fast ((Speed)10)
#define Speed_Turbo ((Speed)11)

typedef struct {
    int32_t x;
    char label[21];
} Point;

Stateful POUs

Stateful POUs follow the split the Codegen chapter describes. A function block or program is a struct with the _type suffix that holds inputs, outputs, in-outs, and locals in declaration order, and its body is a function that takes a pointer to that struct. The __vtable member the polymorphism lowerer added is rendered as uint64_t*, and an extended block embeds its parent as a member named after the base type, again with the _type suffix.

A program also gets an extern for its instance. Methods and actions take the instance pointer first and are named Parent__member, the C spelling of the qualified name:

typedef struct {
    uint64_t* __vtable;
    int16_t limit;
    int32_t count;
} Buffer_type;

typedef struct {
    Buffer_type __Buffer;
    int32_t head;
} RingBuffer_type;

typedef struct {
    int32_t i;
} main_type;

extern main_type main_instance;

void Buffer(Buffer_type* self);
bool Buffer__push(Buffer_type* self, int32_t value);
void RingBuffer(RingBuffer_type* self);
void main(main_type* self);
void Buffer__reset(Buffer_type* self);

Functions

Functions become prototypes with parameters in declaration order. Scalar inputs are values; {ref} inputs, in-outs, and outputs are pointers. Struct, array, and string parameters are also pointers. Array and string parameters include capacity comments. Sized variadics become a count and a pointer; unsized variadics become ...:

int32_t scale(int32_t value, Point* p, int32_t* total, bool* overflow);

int32_t sum(int32_t args_count, int32_t* args);

Globals

Globals are extern declarations with their C type. A variable declared as __FPOINTER f refers to a function, so the generator adds a function pointer typedef named f_ptr with the signature of f, once per header, and declares the variable with it:

typedef int32_t (*scale_ptr)(int32_t, Point*, int32_t*, bool*);

extern int32_t counter;
extern Point origin;
extern scale_ptr callback;

The generator sorts aliases by dependency, so TYPE Msgs: ARRAY[0..1] OF Msg is written after Msg even when it is declared before it, and it drops the generated aliases that nothing names. The template writes the include guard, <stdint.h>, <stdbool.h>, <math.h>, <time.h>, and <dependencies.plc.h>, a file the compiler never writes and the C side must supply, then aliases, enums, structs, globals, and functions, inside an extern "C" block.

Note

Developer note. That order is not a C declaration order. Aliases come first, and the structs keep their declaration order, so a typedef that names a struct (PointRef, scale_ptr) and a struct that names a later struct both refer to a type C has not seen yet. The header of the project above does not compile for this reason.

Combining headers

With -o, the generator appends the per-unit models in unit order and renders one header. It writes to --header-output or the directory of the first unit with declarations.

Where it lives

WhatWhere
Header generatorcompiler/plc_header_generator
Generate step, command linecompiler/plc_driver

Hardware Map

A variable bound to a hardware address does not own its storage. For

VAR_GLOBAL
    start AT %IX0.0: BOOL;
END_VAR

pre-processing creates the global __PI_0_0 and turns start into an alias pointer to it. The constructor binds the pointer. Thus the binary contains both the source variable and its generated storage, as described in Index.

A monitoring tool needs this connection to display the source name with the correct hardware value. The hardware map lists each bound variable’s source name, generated global, and address.

flowchart LR
    parse[Parse] --> index[Index] --> annotate[Annotate] --> validate[Validate] --> codegen[Codegen] --> link[Link]
    validate --> hwmap[Hardware map]
    style hwmap fill:#bfdbfe,stroke:#000,stroke-width:1px,stroke-dasharray:4 3,color:#0f172a

When requested, the map is written after validation and before codegen. It uses only the index. plc --check --hwmap-file=map.json writes it without generating an object file; a normal build can produce both the map and the binary.

The map

The chapter follows one project with the main forms of binding:

FUNCTION_BLOCK Sensor
    VAR
        raw AT %I*: INT;
        alarm AT %QX3.1: BOOL;
    END_VAR
END_FUNCTION_BLOCK

VAR_GLOBAL
    start AT %IX0.0: BOOL;
    speed, speedCopy AT %QW2.5: WORD;
    counter AT %MD1: DWORD;
    sensors: ARRAY[0..1] OF Sensor;
END_VAR

VAR_CONFIG
    sensors[0].raw AT %IW5.0: INT;
    sensors[1].raw AT %IW5.1: INT;
END_VAR

PROGRAM main
    VAR
        stop AT %IX0.1: BOOL;
    END_VAR
END_PROGRAM

Each entry has five fields: the source path (name), generated global (mangled_name), source address (address), direction, and access width. direction is Input, Output, Memory, or Global; access_type is Bit, Byte, Word, DWord, or LWord. For this project:

{
  "VariableMap": [
    { "name": "start",            "mangled_name": "__PI_0_0", "address": "%IX0.0", "direction": "Input",  "access_type": "Bit"   },
    { "name": "speed",            "mangled_name": "__PI_2_5", "address": "%QW2.5", "direction": "Output", "access_type": "Word"  },
    { "name": "speedCopy",        "mangled_name": "__PI_2_5", "address": "%QW2.5", "direction": "Output", "access_type": "Word"  },
    { "name": "counter",          "mangled_name": "__M_1",    "address": "%MD1",   "direction": "Memory", "access_type": "DWord" },
    { "name": "sensors[0].alarm", "mangled_name": "__PI_3_1", "address": "%QX3.1", "direction": "Output", "access_type": "Bit"   },
    { "name": "sensors[1].alarm", "mangled_name": "__PI_3_1", "address": "%QX3.1", "direction": "Output", "access_type": "Bit"   },
    { "name": "main.stop",        "mangled_name": "__PI_0_1", "address": "%IX0.1", "direction": "Input",  "access_type": "Bit"   },
    { "name": "sensors[0].raw",   "mangled_name": "__PI_5_0", "address": "%IW5.0", "direction": "Input",  "access_type": "Word"  },
    { "name": "sensors[1].raw",   "mangled_name": "__PI_5_1", "address": "%IW5.1", "direction": "Input",  "access_type": "Word"  }
  ]
}

speed and speedCopy share an address and the global __PI_2_5. Both names appear in the map. The two alarm members also share one global, __PI_3_1, because their address belongs to the function block declaration rather than to an individual instance.

Generated names combine a direction prefix with the address segments, separated by underscores. %I and %Q both use __PI_ for the process image, %M uses __M_, and %G uses __G_. The size letter is absent, so %QW2.5 and %QX2.5 give the same name and collide. Pre-processing and map generation use the same naming function.

Collecting the entries

Finding the variables is the other half of the work. The map walks the index’s variable-instance iterator twice. That iterator starts at every global variable and every program instance, then goes into their members, level by level.

The first pass takes every instance whose declaration carries a direct address, and skips the templates (AT %I*), because a template has no address until a VAR_CONFIG block gives it one. The address segments are constant expressions in the index: they went through the constant evaluator, so AT %IX0.0 is stored as two evaluated integers.

The instance path is expanded over every array dimension. The one declaration alarm in Sensor therefore gives sensors[0].alarm and sensors[1].alarm, because sensors has two elements, and a three-dimensional array of blocks gives one name per element, in [i,j,k] form.

The second pass reads the VAR_CONFIG entries, which give a concrete address to a template variable. The source path is the path written in the block, and the generated global comes from the configured address, so it matches the global that pre-processing created. The passes cover different declarations, but the map still removes duplicate pairs of source name and generated global.

Files and formats

The collected list is then written to one file. --hwmap-file=<path> selects JSON or TOML by the extension; any other extension is E134. Without a value, the map is written next to the output as <output>.hwmap.json, so plc main.st -o main.so --hwmap-file gives main.so.hwmap.json. The = is required: --hwmap-file map.json makes map.json a source file. The TOML form is the same list as an array of tables:

[[VariableMap]]
name = "start"
mangled_name = "__PI_0_0"
address = "%IX0.0"
direction = "Input"
access_type = "Bit"

Note

Developer note. --hardware-conf=<path> is the older form of the same idea and is deprecated. It writes a HardwareConfiguration list without the generated names and with the address as a list of segment strings, and it lists templates with an empty address, which the new map leaves out because a template maps to nothing. It prints a deprecation warning that points to --hwmap-file.

Where it lives

WhatWhere
Hardware map, deprecated hardware configuration, instance path expansionsrc/hw_map.rs, src/hardware_binding.rs, src/expression_path.rs
Name mangling and pre-processing of addressescompiler/plc_ast
Command line and write stepcompiler/plc_driver

Internals

After these chapters you can follow one language construct from its declaration to the LLVM IR that carries it. The pipeline part explains one stage at a time; these chapters take the same work from the other side, one construct at a time.

POUs, structs, arrays, strings, enumerations, variable-length arrays, reference expressions, and initial values each get a subchapter that follows the same sequence: declaration, index, annotations, lowering, code generation, and validation. One more subchapter is a reference of every annotation that the resolver can attach to a node, organized by kind.

Start with POUs for the storage and calling rules, then read the subchapter that your question is about.

POUs

A program organization unit (POU) contains code and declarations. Programs, function blocks, and classes keep state in an instance. Functions keep their local data on the stack. Methods and actions use their owner’s instance. These storage rules determine the generated layouts and call signatures.

The example uses a function, a function block, a class, a method, and an action. Follow how their variables become instance fields or stack slots:

FUNCTION scale: DINT
    VAR_INPUT
        value: DINT;
        factor: INT := 2;
    END_VAR
    VAR_IN_OUT
        count: DINT;
    END_VAR
    VAR_OUTPUT
        overflow: BOOL;
    END_VAR
    VAR
        tmp: DINT;
    END_VAR

    count := count + 1;
    tmp := value * factor;
    overflow := tmp < value;
    scale := tmp;
END_FUNCTION

FUNCTION_BLOCK Counter
    VAR_INPUT
        step: DINT := 1;
    END_VAR
    VAR_OUTPUT
        total: DINT;
    END_VAR
    VAR
        calls: DINT;
    END_VAR
    VAR_TEMP
        scratch: DINT;
    END_VAR

    METHOD reset
        total := 0;
        calls := 0;
    END_METHOD

    scratch := step;
    total := total + scratch;
    calls := calls + 1;
END_FUNCTION_BLOCK

ACTIONS Counter
    ACTION double
        step := step * 2;
    END_ACTION
END_ACTIONS

CLASS Limits
    VAR
        max: DINT := 100;
    END_VAR

    METHOD exceeded: BOOL
        VAR_INPUT
            candidate: DINT;
        END_VAR

        exceeded := candidate > max;
    END_METHOD
END_CLASS

PROGRAM main
    VAR
        counter: Counter;
        limits: Limits;
        hits: DINT;
        flag: BOOL;
    END_VAR

    counter(step := 5, total => hits);
    counter.double();
    hits := scale(counter.total, count := hits, overflow => flag);
    IF limits.exceeded(hits) THEN
        counter.reset();
    END_IF
END_PROGRAM

Declaration

The parser splits every POU into two nodes: a declaration with the name, the kind, the return type, and the variable blocks, and an implementation with the statements (see Lexer and Parser).

A method has its own POU declaration, such as Counter.reset, with a reference to its parent. The parser stores parent and method side by side. An action has only an implementation, such as Counter.double, which uses Counter as its type. A class also has an implementation; its body is empty in a valid program.

Index

The index records POU declarations, implementations, and variable layouts. Actions reuse the parent’s layout. The POU entry identifies the kind:

pub enum PouIndexEntry {
    /// One static instance, held in instance_variable, of the struct instance_struct_name
    Program { name, instance_struct_name, instance_variable, .. },

    /// Many instances; the struct is instance_struct_name; super_class and interfaces for polymorphism
    FunctionBlock { name, instance_struct_name, super_class, interfaces, .. },

    /// Like a function block, but it declares no parameters and has no body
    Class { name, instance_struct_name, super_class, interfaces, .. },

    /// No instance; the return type and whether the parameter list is variadic or generic
    Function { name, return_type, generics, is_variadic, .. },

    /// Belongs to parent_name; its own parameters live in the struct instance_struct_name
    Method { name, parent_name, return_type, instance_struct_name, .. },

    /// Belongs to parent_name and shares its instance struct
    Action { name, parent_name, instance_struct_name, .. },
}

The implementation entry connects a callable name with the body and its kind:

pub struct ImplementationIndexEntry {
    /// The name a call uses, "Counter.reset" for a method
    call_name: String,

    /// The struct type the body runs on; the parent for an action, the method's own struct for a method
    type_name: String,

    /// The class or function block a method belongs to
    associated_class: Option<String>,

    /// Program, Function, FunctionBlock, Class, Method, Action, or a generated constructor
    implementation_type: ImplementationType,

    // ... other omitted fields
}

The type is the instance struct, registered under the name of the POU in the type index. It has one member per declared variable, in declaration order, whatever block the variable is in, and each member records its block as an argument type.

Two rules change the type a member stores. A VAR_IN_OUT, and a VAR_OUTPUT of a function or method, is passed by reference, so the member gets an auto-dereferencing pointer type, __auto_pointer_to_DINT, that the indexer registers on demand. And a function or method with a return type gets one extra member named like the POU, so scale.scale and Limits.exceeded.exceeded carry argument type Return. For the example:

scale            Function, return_type "DINT"
    scale.value       DINT                     ByVal(Input)     0
    scale.factor      INT                      ByVal(Input)     1   initial value: 2
    scale.count       __auto_pointer_to_DINT   ByRef(InOut)     2
    scale.overflow    __auto_pointer_to_BOOL   ByRef(Output)    3
    scale.tmp         DINT                     ByVal(Local)     4
    scale.scale       DINT                     ByVal(Return)    5

Counter          FunctionBlock, instance struct "Counter"
    Counter.step      DINT                     ByVal(Input)     0   initial value: 1
    Counter.total     DINT                     ByVal(Output)    1
    Counter.calls     DINT                     ByVal(Local)     2
    Counter.scratch   DINT                     ByVal(Temp)      3

Counter.reset    Method, parent "Counter", instance struct "Counter.reset" (no members)
Counter.double   Action, parent "Counter", instance struct "Counter"

Limits           Class, instance struct "Limits"
    Limits.max        DINT                     ByVal(Local)     0   initial value: 100

Limits.exceeded  Method, parent "Limits", return_type "BOOL"
    Limits.exceeded.candidate   DINT           ByVal(Input)     0
    Limits.exceeded.exceeded    BOOL           ByVal(Return)    1

main             Program, instance variable "main_instance" of type "main"
    main.counter      Counter                  ByVal(Local)     0
    main.limits       Limits                   ByVal(Local)     1
    main.hits         DINT                     ByVal(Local)     2
    main.flag         BOOL                     ByVal(Local)     3

The struct of a function exists only so that scale.tmp can be looked up like any other member; no instance is ever created. A VAR_OUTPUT of a function block is a by-value member, because the instance keeps it; in a function or method it is a by-reference parameter, because no instance keeps it.

A program also records its single instance as a global variable entry named main_instance. A function block or class registers a default-instance entry __Counter__init as well, which no later stage reads (see the Initializers chapter).

Annotations

With the index in place, the resolver gives every name in a body its meaning. Function and method names receive Function annotations with return types. Program and action names receive Program annotations. Function block and class instances are variables of the corresponding POU type. Member references use the declaring POU in their qualified name, including parameters and return variables:

    counter(step := 5, total => hits);
    ^^^^^^^                        { kind: Variable, qualified_name: "main.counter", resulting_type: "Counter" }
            ^^^^^^^^^              { kind: None,                                     hint: Argument { resulting_type: "DINT", position: 0, pou: "Counter" } }
            ^^^^                   { kind: Variable, qualified_name: "Counter.step",  resulting_type: "DINT" }
                       ^^^^^^^^^^^^^  { kind: None,                                  hint: Argument { resulting_type: "DINT", position: 1, pou: "Counter" } }

    counter.double();
            ^^^^^^                 { kind: Program,  qualified_name: "Counter.double" }

    hits := scale(counter.total, count := hits, overflow => flag);
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^  { kind: Value, resulting_type: "DINT", hint: "DINT" }
            ^^^^^                  { kind: Function, qualified_name: "scale", return_type: "DINT" }
                  ^^^^^^^^^^^^^    { kind: Variable, qualified_name: "Counter.total", resulting_type: "DINT", hint: Argument { resulting_type: "DINT", position: 0, pou: "scale" } }
                                 ^^^^^^^^^^^^^  { kind: None, hint: Argument { resulting_type: "__auto_pointer_to_DINT", position: 2, pou: "scale" } }
                                 ^^^^^          { kind: Variable, qualified_name: "scale.count", argument_type: ByRef(InOut), auto_deref: Default }

    IF limits.exceeded(hits) THEN
       ^^^^^^^^^^^^^^^^^^^^^       { kind: Value,    resulting_type: "BOOL" }
              ^^^^^^^^             { kind: Function, qualified_name: "Limits.exceeded", return_type: "BOOL" }

An argument hint carries the position of the parameter in the declaration of the callee, the POU that declares it, and the number of EXTENDS steps between the two. Codegen uses the position to find the struct member or the argument slot, the POU to read the declaration, and the step count to reach the base part of the instance.

Inside the callee the return member is an ordinary variable: scale := tmp in scale annotates the left side as scale.scale with argument type Return, and exceeded := candidate > max as Limits.exceeded.exceeded. The VAR_IN_OUT member count carries the auto-dereference marker, which is what makes count := count + 1 in the body read and write through the pointer without a ^.

Lowering

The index and the annotations above describe the POUs as the parser read them. Several participants then change the declarations, and each one runs the affected stages again before the next one sees the tree. The polymorphism lowerer adds method tables and a __vtable member to root classes and function blocks. Derived types access that member through the base inserted by the inheritance lowerer. The init participant creates constructors. The aggregate-return lowerer adds result parameters for strings, arrays, and structs.

After lowering, Counter has four instance fields instead of three. scratch remains a stack variable. The unit also contains ten type and POU constructors of kind Init, plus one unit constructor.

Codegen

Codegen reads the lowered tree and the rebuilt index. The layout comes first, because every signature and every call refers to it.

Layout

Every stateful POU becomes a named struct with one field per member that is not a VAR_TEMP, in member order; a function has no struct. A program instance is a global initialized with the constant initial values of its members, nested instances included:

%main = type { %Counter, %Limits, i32, i8 }
%Counter = type { ptr, i32, i32, i32 }
%Limits = type { ptr, i32 }

@main_instance = global %main { %Counter { ptr null, i32 1, i32 0, i32 0 }, %Limits { ptr null, i32 100 }, i32 0, i8 0 }

The first ptr of Counter and Limits is the __vtable member; the constructors store the table addresses at start-up (see Init, and the Initialization section of Codegen).

Signatures

Every POU becomes exactly one LLVM function, named after its call name with . replaced by __. The kind decides the arguments:

POUSignatureArguments
Program, function block, actionvoid @Counter(ptr)the instance
Classvoid @Limits(ptr)the instance; the body is empty
Methodi8 @Limits__exceeded(ptr, i32)the instance, then the method’s own parameters like a function
Functioni32 @scale(i32, i16, ptr, ptr)one argument per VAR_INPUT by value, one pointer per VAR_IN_OUT and VAR_OUTPUT; the return type is the result

A method receives its own parameters like a function and accesses parent members through the first argument, the instance pointer.

Bodies

A body starts by making every member addressable, and the two kinds differ here. A stateful body computes one pointer per struct member into the instance and allocates a stack slot only for VAR_TEMP:

define void @Counter(ptr %0) {
entry:
  %this = alloca ptr, align 8
  store ptr %0, ptr %this, align 8
  %__vtable = getelementptr inbounds nuw %Counter, ptr %0, i32 0, i32 0
  %step = getelementptr inbounds nuw %Counter, ptr %0, i32 0, i32 1
  %total = getelementptr inbounds nuw %Counter, ptr %0, i32 0, i32 2
  %calls = getelementptr inbounds nuw %Counter, ptr %0, i32 0, i32 3
  %scratch = alloca i32, align 4
  store i32 0, ptr %scratch, align 4
  ...
  ret void
}

The %this slot provides the instance pointer for THIS^ in function blocks and their methods and actions. Programs and classes do not get this slot. A method or action of Counter computes the same member pointers, so reset writes directly to the instance. An action also gets a fresh stack slot for each VAR_TEMP of its owner, so a temp never carries a value from one call to the next. A method cannot reach a VAR_TEMP of its owner at all.

A function allocates stack slots for its parameters, locals, and return variable. It stores the incoming arguments in those slots, gives each local its initial value, and zeroes the return variable:

define i32 @scale(i32 %0, i16 %1, ptr %2, ptr %3) {
entry:
  %scale = alloca i32, align 4
  %value = alloca i32, align 4
  store i32 %0, ptr %value, align 4
  %factor = alloca i16, align 4
  store i16 %1, ptr %factor, align 2
  %count = alloca ptr, align 8
  store ptr %2, ptr %count, align 8
  %overflow = alloca ptr, align 8
  store ptr %3, ptr %overflow, align 8
  %tmp = alloca i32, align 4
  store i32 0, ptr %tmp, align 4
  store i32 0, ptr %scale, align 4
  ...
  %scale_ret = load i32, ptr %scale, align 4
  ret i32 %scale_ret
}

count and overflow hold pointers, and every access loads the pointer first and then the value behind it; that is the auto-dereference the resolver marked. Writing overflow writes the caller’s flag directly.

Calls

The kind of the callee decides how a call is built. A call to a stateful POU stores each passed argument into the member of the instance, calls the function with the instance pointer, and, after the call, copies every => output out of the instance into its target. Unpassed inputs keep their last value:

  %1 = getelementptr inbounds %Counter, ptr %counter, i32 0, i32 1
  store i32 5, ptr %1, align 4
  call void @Counter(ptr %counter)
  %2 = getelementptr inbounds %Counter, ptr %counter, i32 0, i32 2
  %3 = load i32, ptr %2, align 4
  store i32 %3, ptr %hits, align 4
  call void @Counter__double(ptr %counter)

A program call passes its global instance; an action call passes its owner’s instance. A function call places arguments in parameter order, uses defaults for omitted inputs, and passes addresses for by-reference parameters. The callee writes through those addresses:

  %load_total = load i32, ptr %total, align 4
  %call = call i32 @scale(i32 %load_total, i16 2, ptr %hits, ptr %flag)
  store i32 %call, ptr %hits, align 4

factor was not passed, so its default 2 appears as the literal i16 2. A method call is a function call with the instance in front: call i8 @Limits__exceeded(ptr %limits, i32 %load_hits). A call of an unqualified method or action inside a body takes the instance from the first argument of the running function.

Aggregate VAR_INPUT parameters (strings, arrays, structs) are passed as pointers even though they are by-value, and the callee copies the value into a local of its own; the Strings chapter shows the copy. VAR_INPUT {ref} skips the copy and makes the parameter a pointer that the body reads through.

Validation

Codegen only sees a POU that the validator accepted. The rules that concern the POU as a whole live in the POU validator. A program, function block, or class must not declare a return type (E026). A class must not declare VAR_INPUT, VAR_OUTPUT, or VAR_IN_OUT (E019) and must not have a body (E017). An ACTIONS block with no container name takes the POU above it; with no POU above it, the block is reported (E022). EXTENDS and IMPLEMENTS are allowed on classes and function blocks only (E110), the base and the interfaces must exist (E048), and an implementing method must match the declared signature (E112, E118).

Calls and bodies are checked in the statement validator. A call of a program, function block, or method must pass an argument for every VAR_IN_OUT (E030); a function call with too few arguments is reported by the argument count instead (E032). A reference to an action without the call parentheses is reported (E095), while a bare reference to a program is accepted. A method that names a VAR_TEMP of its owner is rejected (E137). Duplicate POU names are found by the global validation of the index (E004).

At a glance

Structured TextIndexAnnotation of a referenceLLVM
PROGRAM mainProgram entry, struct main, global main_instanceProgram%main, @main_instance, void @main(ptr)
FUNCTION_BLOCK CounterFunctionBlock entry, struct CounterVariable of type Counter%Counter, void @Counter(ptr)
CLASS LimitsClass entry, struct LimitsVariable of type Limits%Limits, empty void @Limits(ptr)
FUNCTION scale: DINTFunction entry, struct scale with return member scale.scaleFunction, return type DINTi32 @scale(...), no struct
METHOD resetMethod entry, struct Counter.reset for its parametersFunctionvoid @Counter__reset(ptr, ...)
ACTION doubleAction entry, parent’s structProgramvoid @Counter__double(ptr)
VAR_INPUT xByVal(Input) memberstruct field, or a by-value argument in a function
VAR_OUTPUT xByVal(Output) member, ByRef(Output) in a function or methodstruct field copied out after the call, or a pointer argument
VAR_IN_OUT xByRef(InOut) member of type __auto_pointer_to_Tauto_deref: Defaultptr field or argument, loaded before every access
VAR_TEMP xByVal(Temp) memberstack slot in the body and in each action, not a struct field

Structs

A struct groups named members into one value. The index keeps the members in declaration order, and codegen builds an LLVM type with the fields in that order. Member access computes an address; assignment between struct variables copies the whole value. The init participant supplies constructor code for defaults and initializers.

The example extends the instance-layout model from POUs to nested data. It shows member defaults, a whole-struct copy, a function argument, and a struct return:

TYPE Point:
    STRUCT
        x, y: INT;
    END_STRUCT
END_TYPE

TYPE Rect:
    STRUCT
        topLeft: Point;
        bottomRight: Point := (x := 10, y := 10);
        label: STRING[5] := 'rect';
    END_STRUCT
END_TYPE

FUNCTION area: INT
    VAR_INPUT
        r: Rect;
    END_VAR

    area := (r.bottomRight.x - r.topLeft.x) * (r.bottomRight.y - r.topLeft.y);
END_FUNCTION

FUNCTION origin: Point
    origin := (x := 0, y := 0);
END_FUNCTION

PROGRAM main
    VAR
        r1: Rect := (topLeft := (x := 1, y := 2));
        r2: Rect;
        p: Point;
        a: INT;
    END_VAR

    r2 := r1;
    r2.bottomRight.x := r1.topLeft.x + 5;
    a := area(r1);
    p := origin();
END_PROGRAM

Declaration

A struct declaration contains a name and a list of variables. Each variable has a name, type, and optional initializer, as in a POU variable block.

A struct literal (x := 10, y := 10) is not a node kind of its own. The parser produces a parenthesized expression list of assignments, and only the resolver decides that the list is a struct value. The inline STRING[5] of label is moved out by pre-processing into the type __Rect_label, like every inline type (see Index, Pre-processing).

Index

The type index holds one record per struct. Trimmed to the struct variant of the type information:

Struct {
    /// The type name, `Rect`
    name: TypeId,

    /// One variable entry per member, in declaration order
    members: Vec<VariableIndexEntry>,

    /// Where the struct came from: a TYPE declaration, a POU, or an internal type
    source: StructSource,
}

Struct members use the same variable records as POU members. Each record identifies the member, its type, its position, and any initializer in the constant store:

Point    { members: [ Point.x : INT @0,  Point.y : INT @1 ],                                   source: OriginalDeclaration }
Rect     { members: [ Rect.topLeft : Point @0,  Rect.bottomRight : Point @1 := ConstId(0),
                      Rect.label : __Rect_label @2 := ConstId(1) ],                             source: OriginalDeclaration }

The constant store keeps (x := 10, y := 10) as a struct expression. main.r1 has a separate entry for its own literal. The index stores member and type names; codegen later computes sizes and offsets for the target.

Members of a struct declared in a TYPE block get the argument type Input, because the indexer reuses the variable indexing of POUs. Nothing reads that flag for a struct.

Every POU has a struct record of its own, the instance struct with the source Pou(Program) or Pou(Function), whose members are the variables of the POU; the POUs chapter describes it. area and origin have such a struct here although they are functions, so that area.r and origin.origin can be looked up like any member.

Note

Developer note. The indexer registers __Rect__init in an unused map of default-value globals. Codegen computes defaults from the type index instead. See Initializers.

Annotations

A struct itself is never an expression. What the resolver annotates are member references and struct literals. A member reference is resolved left to right, each segment under the type of the one before, and the whole reference takes the annotation of its last segment (see Resolver, Walking a unit). For the second statement of main:

    r2.bottomRight.x := r1.topLeft.x + 5;
    ^^                          { kind: Variable, qualified_name: "main.r2",          resulting_type: "Rect",  hint: None }
    ^^^^^^^^^^^^^^              { kind: Variable, qualified_name: "Rect.bottomRight", resulting_type: "Point", hint: None }
    ^^^^^^^^^^^^^^^^            { kind: Variable, qualified_name: "Point.x",          resulting_type: "INT",   hint: None }
                        ^^^^^^^^^^^^^^^^    { kind: Value,                            resulting_type: "DINT",  hint: "INT" }
                        ^^^^^^^^^^^^        { kind: Variable, qualified_name: "Point.x", resulting_type: "INT", hint: "DINT" }

The qualified name of a member is always <struct>.<member>, never <variable>.<member>: r1.topLeft.x and r2.bottomRight.x both end in Point.x. Codegen uses the qualified name to find the member’s position in its struct; the base expression tells it which struct instance to start from.

A struct literal gets no annotation of its own, only a hint with the struct type, taken from the left side of the assignment or from the declared type of the variable. Under that hint the left side of every inner assignment is looked up as a member of the struct, and the right side is hinted with the type of that member:

    r1: Rect := (topLeft := (x := 1, y := 2));
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^  { kind: None,                                                    hint: "Rect" }
                 ^^^^^^^                        { kind: Variable, qualified_name: "Rect.topLeft", resulting_type: "Point", hint: None }
                            ^^^^^^^^^^^^^^^^    { kind: None,                                                    hint: "Point" }
                             ^                  { kind: Variable, qualified_name: "Point.x",      resulting_type: "INT",   hint: None }
                                  ^             { kind: Value,                                     resulting_type: "DINT",  hint: "INT" }

Nested literals are hinted recursively, so (x := 1, y := 2) inside the topLeft assignment is hinted Point. The same happens for a literal assigned in a body, origin := (x := 0, y := 0). A member name the struct does not have, z := 2, gets no annotation at all.

A struct variable passed as an argument, area(r1), is annotated like any argument: the variable main.r1 of type Rect with an argument hint for parameter 0 of area. A struct assignment r2 := r1 hints r1 with Rect; the hint equals the type, and there is nothing to convert.

Lowering

Two participants rewrite the tree before codegen sees it. The init participant creates Point__ctor and Rect__ctor. It splits declaration literals into assignments for individual fields. Thus Rect__ctor sets self.bottomRight.x and .y to 10. main__ctor sets self.r1.topLeft.x := 1 and self.r1.topLeft.y := 2.

The aggregate-return lowerer turns origin into a void function with a VAR_IN_OUT origin: Point parameter and gives the call site a temporary. A struct literal assigned in a body is left as it is.

Codegen

Layout

Each struct becomes a named LLVM type with fields in declaration order. Nested structs are embedded by value. LLVM computes padding and offsets from the target data layout. The Codegen chapter explains the two-pass creation:

%main = type { %Rect, %Rect, %Point, i16 }
%Rect = type { %Point, %Point, [6 x i8] }
%Point = type { i16, i16 }

The initial value of a struct type is a constant struct whose fields are the evaluated initializers of the members, or the initial value of the member’s type, or zero. A variable with a struct literal gets that literal folded into the constant of its instance:

@main_instance = global %main {
    %Rect { %Point { i16 1, i16 2 }, %Point { i16 10, i16 10 }, [6 x i8] c"rect\00\00" },
    %Rect { %Point zeroinitializer, %Point { i16 10, i16 10 }, [6 x i8] c"rect\00\00" },
    %Point zeroinitializer,
    i16 0 }

r1.topLeft takes its value from the variable’s literal. bottomRight and label use the defaults of Rect. Constructors apply these initial values again at startup, as described in Initializers.

Member access

A member reference is one getelementptr per segment, each starting from the pointer the previous segment produced. The indices are the position in the struct type, which codegen takes from the member’s entry in the index. A read loads from the final address and a write stores to it:

  %bottomRight = getelementptr inbounds nuw %Rect, ptr %r2, i32 0, i32 1
  %x = getelementptr inbounds nuw %Point, ptr %bottomRight, i32 0, i32 0
  %topLeft = getelementptr inbounds nuw %Rect, ptr %r1, i32 0, i32 0
  %x1 = getelementptr inbounds nuw %Point, ptr %topLeft, i32 0, i32 0
  %load_x = load i16, ptr %x1, align 2
  %1 = sext i16 %load_x to i32
  %tmpVar = add i32 %1, 5
  %2 = trunc i32 %tmpVar to i16
  store i16 %2, ptr %x, align 2

Assignment

A struct is an aggregate, so r2 := r1 is a memcpy of the size of the type, computed by LLVM from the type itself. Unlike strings, nothing is cut, since both sides have the same type:

  call void @llvm.memcpy.p0.p0.i64(ptr align 1 %r2, ptr align 1 %r1, i64 ptrtoint (ptr getelementptr (%Rect, ptr null, i32 1) to i64), i1 false)

A struct literal assigned in a body takes one of two paths. When every member value is a constant, the literal becomes a private constant global and is copied from there, so origin := (x := 0, y := 0) is a memcpy from @.const_init. When a member is a run-time value, the struct is built in registers with one insertvalue per assigned member and stored as a whole:

  %load_n = load i16, ptr %n, align 2
  %1 = insertvalue %Point undef, i16 %load_n, 0
  %2 = insertvalue %Point %1, i16 2, 1
  store %Point %2, ptr %q, align 2

In both paths the members the literal does not name are filled with their initial value from the type, so a literal always produces a complete struct.

Passing and returning

A by-value struct argument is passed by pointer, then copied into a local in the callee. Thus area works on a copy of r1; changing r.topLeft.x does not change main.r1. A struct return uses the in-out result pointer added by aggregate-return lowering:

define i16 @area(ptr %0) {
entry:
  %area = alloca i16, align 4
  %r = alloca %Rect, align 8
  call void @llvm.memcpy.p0.p0.i64(ptr align 1 %r, ptr align 1 %0, i64 ptrtoint (ptr getelementptr (%Rect, ptr null, i32 1) to i64), i1 false)
  ...

define void @origin(ptr %0) {
entry:
  %origin = alloca ptr, align 8
  store ptr %0, ptr %origin, align 8
  %deref = load ptr, ptr %origin, align 8
  call void @Point__ctor(ptr %deref)
  %deref1 = load ptr, ptr %origin, align 8
  call void @llvm.memcpy.p0.p0.i64(ptr align 1 %deref1, ptr align 1 @.const_init, i64 ptrtoint (ptr getelementptr (%Point, ptr null, i32 1) to i64), i1 false)
  ret void
}

define void @main(ptr %0) {
  ...
  %call = call i16 @area(ptr %r1)
  %__origin0 = alloca %Point, align 8
  call void @llvm.memset.p0.i64(ptr align 1 %__origin0, i8 0, i64 ptrtoint (ptr getelementptr (%Point, ptr null, i32 1) to i64), i1 false)
  call void @origin(ptr %__origin0)
  call void @llvm.memcpy.p0.p0.i64(ptr align 1 %p, ptr align 1 %__origin0, i64 ptrtoint (ptr getelementptr (%Point, ptr null, i32 1) to i64), i1 false)
  ...

The Point__ctor call at the start of origin is the init participant constructing the return variable. For a struct with member defaults it fills the caller’s storage with those defaults before the body overwrites them.

A VAR_IN_OUT struct is passed as a pointer without a copy, and a REF_TO or POINTER TO a struct is an ordinary pointer whose member access adds one load in front of the address computation.

Validation

Codegen can only lay out a type whose size is known, so a struct type must be finite. A struct that holds itself, directly or through other structs or arrays, is reported as a recursive data structure (E029) by the global validation from the index (see Validation, Global validation).

A member name in a literal or an access that the struct does not declare is an unresolved reference (E048). A struct assigned to a variable of a different type, or a scalar assigned to a struct, is an invalid assignment (E037); two struct types are compatible only when they are the same type. A struct literal in a declaration is checked member by member with the same rules as an assignment.

At a glance

Structured TextIndexAnnotationLLVM
TYPE Rect: STRUCT ... END_STRUCT END_TYPEStruct { members, source: OriginalDeclaration }, one variable entry per member%Rect = type { ... }, fields in declaration order
topLeft: Point; inside a structmember entry Rect.topLeft, position 0field 0, embedded by value
r1.topLeft.xeach segment Variable, last one Point.x of type INTone getelementptr per segment, then load or store
(x := 1, y := 2)expression in the constant store, if in a declarationno annotation, hint Point; members resolved under that hintfolded into the instance constant; in a body, memcpy from a constant or insertvalue chain
r2 := r1r1 hinted Rectmemcpy of sizeof(%Rect)
area(r1)area.r of type Rectr1 hinted as argument 0ptr, copied into a local %Rect in the callee
FUNCTION origin: Pointorigin.origin return member of type Pointvoid function with a ptr result parameter

Arrays

A fixed array has an element type and constant bounds for each dimension. Codegen combines the dimensions into one flat block and computes an offset for each access. For example, ARRAY[3..5] OF DINT has three elements, so source index 3 maps to offset 0.

The example puts a multi-dimensional array next to a nested array and an array of structs. It also shows initialization and parameter passing:

VAR_GLOBAL CONSTANT
    MAX: DINT := 3;
END_VAR

TYPE Point:
    STRUCT
        x: DINT;
        y: DINT;
    END_STRUCT
END_TYPE

TYPE Data: ARRAY[0..9] OF DINT := [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; END_TYPE

FUNCTION sum: DINT
    VAR_INPUT
        values: ARRAY[1..MAX] OF DINT;
    END_VAR
    VAR_IN_OUT
        target: ARRAY[0..1] OF DINT;
    END_VAR

    sum := values[1] + values[2] + values[3];
    target[0] := sum;
END_FUNCTION

PROGRAM main
    VAR
        a: Data;
        b: ARRAY[3..5] OF DINT := [3, 4, 5];
        neg: ARRAY[-2..2] OF INT;
        grid: ARRAY[0..1, 0..2] OF DINT;
        nested: ARRAY[0..1] OF ARRAY[0..2] OF DINT;
        points: ARRAY[0..1] OF Point := [(x := 1, y := 2), (x := 3)];
        rep: ARRAY[1..MAX] OF DINT := [(MAX)(7)];
        i: DINT;
        pair: ARRAY[0..1] OF DINT;
    END_VAR

    a[2] := b[4];
    neg[-2] := 1;
    grid[1, 2] := neg[i];
    nested[1][2] := grid[1, 2];
    points[1].y := points[0].x;
    a := [9, 8, 7, 6, 5, 4, 3, 2, 1, 0];
    pair := [i, 2];
    i := sum(rep, pair);
END_PROGRAM

Declaration

The parser records one range expression per dimension and a nested element type. Pre-processing gives each inline array a name, such as __main_b or __sum_values. A nested array needs two names: __main_nested refers to the inner array type __main_nested_. See Index.

An array literal is a literal node whose elements are an expression list. The repetition (MAX)(7) becomes a call whose operator is the parenthesized constant, because the parser cannot tell the two apart.

Index

The type index holds one record per array type. Trimmed to the array variant of the type information:

Array {
    /// The type of the elements, as a name; for an array of arrays the inner generated type
    inner_type_name: TypeId,

    /// One entry per dimension, each with a start and an end offset
    dimensions: Vec<Dimension>,
}

pub struct Dimension {
    /// The lower bound, as a literal or a constant expression
    pub start_offset: TypeSize,

    /// The upper bound, inclusive
    pub end_offset: TypeSize,
}

Every bound is put into the constant store as an expression, even a plain literal, and constant evaluation at the end of the stage folds it (see Index, Constant evaluation). values: ARRAY[1..MAX] therefore holds the expression MAX until the evaluator resolves it to 3. After indexing, the project has these array types, all with the nature Any:

Data             inner: DINT              dims: [0..9]            initializer: ConstId -> [0, 1, ..., 9]
__sum_values     inner: DINT              dims: [1..MAX -> 3]
__sum_target     inner: DINT              dims: [0..1]
__main_b         inner: DINT              dims: [3..5]
__main_neg       inner: INT               dims: [-2..2]
__main_grid      inner: DINT              dims: [0..1], [0..2]
__main_nested    inner: __main_nested_    dims: [0..1]
__main_nested_   inner: DINT              dims: [0..2]
__main_points    inner: Point             dims: [0..1]
__main_rep       inner: DINT              dims: [1..MAX -> 3]
__main_pair      inner: DINT              dims: [0..1]

A type-level initializer belongs to the type: Data stores the ID of its literal, and codegen computes the type’s default value from it (a default-instance entry __Data__init is registered next to it, but no stage reads it; see Initializers). A variable-level initializer belongs to the variable entry: main.b and main.points store the ID of their literal, and their types have none.

Variable entries store type names. main.a uses Data, and the by-value input sum.values uses __sum_values. The in-out parameter sum.target instead stores the generated pointer type __auto_pointer_to___sum_target, which enables automatic dereferencing.

Annotations

An index access is a reference expression whose access part is the index expression, or an expression list for several dimensions, and whose base is the array. The resolver annotates the access with the element type of the base’s array type; the index expressions themselves are ordinary values with no hint. A member access on an element continues from there. For the body of main:

    a[2] := b[4];
    ^^^^                     { kind: Value,    resulting_type: "DINT",  hint: None }
    ^                        { kind: Variable, qualified_name: "main.a",   resulting_type: "Data",         hint: None }
      ^                      { kind: Value,    resulting_type: "DINT",  hint: None }
            ^^^^             { kind: Value,    resulting_type: "DINT",  hint: "DINT" }

    grid[1, 2] := neg[i];
    ^^^^^^^^^^               { kind: Value,    resulting_type: "DINT",  hint: None }
    ^^^^                     { kind: Variable, qualified_name: "main.grid", resulting_type: "__main_grid", hint: None }
         ^^^^                no annotation; the list is only a container for the two index values
                  ^^^^^^     { kind: Value,    resulting_type: "INT",   hint: "DINT" }
                      ^      { kind: Variable, qualified_name: "main.i",   resulting_type: "DINT",         hint: None }

    nested[1][2] := grid[1, 2];
    ^^^^^^^^^^^^             { kind: Value,    resulting_type: "DINT",  hint: None }
    ^^^^^^^^^                { kind: Value,    resulting_type: "__main_nested_", hint: None }

    points[1].y := points[0].x;
              ^              { kind: Variable, qualified_name: "Point.y", resulting_type: "DINT", hint: None }
    ^^^^^^^^^                { kind: Value,    resulting_type: "Point", hint: None }

An array literal gets no annotation of its own, only a hint: the type of the place it is assigned to, Data for the literal in a := [9, ...], __main_pair for pair := [i, 2]. The hint is pushed down. The element list receives the same array type, and every element receives the element type as its hint, so 9 is a DINT value hinted DINT and i is main.i hinted DINT.

For an array of structs, every parenthesized element is hinted with the struct type, Point, and its member assignments resolve against that struct, Point.x and Point.y:

        points: ARRAY[0..1] OF Point := [(x := 1, y := 2), (x := 3)];
                                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^   hint: "__main_points"
                                         ^^^^^^^^^^^^^^^^              hint: "Point"
                                          ^                            { kind: Variable, qualified_name: "Point.x", resulting_type: "DINT" }

The repetition (MAX)(7) is still a call here: its operator resolves to the constant MAX and the argument 7 is a DINT. The array lowerer sorts that out later. An array passed to a function is hinted with the type of the parameter: rep is hinted __sum_values, and pair is hinted __auto_pointer_to___sum_target, the pointer type of the in-out parameter.

Lowering

Three participants touch arrays. The init participant moves every variable initializer into a constructor, so self.b := [3, 4, 5] and self.points := [...] become statements in main__ctor. It also gives every array type a constructor, an empty one, or a loop over the elements when the element type has one.

The array lowerer turns (MAX)(7) into the repetition 3(7). It splits points into assignments of struct elements and rewrites pair := [i, 2] as pair[0] := i; pair[1] := 2. Variable-to-variable copies and constant literals such as a := [9, ...] remain whole-array assignments.

A function that returns an array is rewritten by the aggregate-return lowerer into a by-reference result parameter, the same way as for a string.

Codegen

Layout

An array type becomes an LLVM array of its element type, with the product of all dimension lengths as its length. grid has two dimensions of 2 and 3 elements and becomes [6 x i32]. nested is an array of arrays and stays nested, [2 x [3 x i32]], because its inner type is an array type of its own. The program instance shows all of them, with the constant initializers in the static data and everything else zero:

%main = type { [10 x i32], [3 x i32], [5 x i16], [6 x i32], [2 x [3 x i32]], [2 x %Point], [3 x i32], i32, [2 x i32] }

@main_instance = global %main {
    [10 x i32] [i32 0, i32 1, i32 2, i32 3, i32 4, i32 5, i32 6, i32 7, i32 8, i32 9],
    [3 x i32] [i32 3, i32 4, i32 5],
    [5 x i16] zeroinitializer, [6 x i32] zeroinitializer, [2 x [3 x i32]] zeroinitializer,
    [2 x %Point] zeroinitializer, [3 x i32] zeroinitializer, i32 0, [2 x i32] zeroinitializer }

a uses the default of Data; b uses its own literal. points and rep start as zeroed static data. Their initializers require lowering, so the constructors apply them later from private constants:

@.const_init.3 = private unnamed_addr constant %Point { i32 1, i32 2 }
@.const_init.5 = private unnamed_addr constant [3 x i32] [i32 7, i32 7, i32 7]

define void @main__ctor(ptr %0) {
  ...
  %tmpVar = getelementptr inbounds [2 x %Point], ptr %points9, i32 0, i32 0
  call void @llvm.memcpy.p0.p0.i64(ptr align 1 %tmpVar, ptr align 1 @.const_init.3, i64 ptrtoint (ptr getelementptr (%Point, ptr null, i32 1) to i64), i1 false)
  ...
  call void @llvm.memcpy.p0.p0.i64(ptr align 1 %rep15, ptr align 1 @.const_init.5, i64 ptrtoint (ptr getelementptr ([3 x i32], ptr null, i32 1) to i64), i1 false)
  ret void
}

A repetition written with a literal count, [3(7)], is folded into the static data as well; only the constant-name spelling stays zero there. The constructor writes both, because it writes every variable initializer.

Note

Developer note. A multi-dimensional array takes one flat initializer list. The nested form, [[1, 2, 3], [4, 5, 6]], passes --check and then stops codegen with Cannot generate literal initializer. It is valid only for an array of arrays, which has an array type at each level.

Element access

An access is one address computation, getelementptr, with a first index of zero to step into the array and a second index that is the flattened offset. For one dimension the offset is the index minus the lower bound. With constant indices LLVM folds that at build time, so b[4] on ARRAY[3..5] becomes index 1 and neg[-2] becomes index 0. For a variable index the subtraction is emitted, followed by the multiplication and addition the general formula below needs even for one dimension:

  %tmpVar1 = getelementptr inbounds [3 x i32], ptr %b, i32 0, i32 1
  %tmpVar2 = getelementptr inbounds [5 x i16], ptr %neg, i32 0, i32 0

  %load_i = load i32, ptr %i, align 4
  %1 = sub i32 %load_i, -2
  %tmpVar4 = mul i32 1, %1
  %tmpVar5 = add i32 %tmpVar4, 0
  %tmpVar6 = getelementptr inbounds [5 x i16], ptr %neg, i32 0, i32 %tmpVar5

For several dimensions, subtract each lower bound and multiply by the number of elements in the following dimensions. Add the results. The last dimension therefore varies fastest. Thus grid[1, 2] on ARRAY[0..1, 0..2] has offset 1 * 3 + 2 * 1 = 5. The lower bound is subtracted in the type of the index, and the difference is converted to DINT for the multiplication. Nested arrays use one getelementptr per level. Struct elements add a member access after the array access.

  %tmpVar3 = getelementptr inbounds [6 x i32], ptr %grid, i32 0, i32 5
  %tmpVar8 = getelementptr inbounds [2 x [3 x i32]], ptr %nested, i32 0, i32 1
  %tmpVar9 = getelementptr inbounds [3 x i32], ptr %tmpVar8, i32 0, i32 2
  %tmpVar12 = getelementptr inbounds [2 x %Point], ptr %points, i32 0, i32 1
  %y = getelementptr inbounds nuw %Point, ptr %tmpVar12, i32 0, i32 1

No bounds are checked at run time. A constant index outside the declared range is rejected by the validator; a variable index is not checked anywhere.

Assignment

Assignment between compatible array variables copies the target type’s size. A literal whose elements are all constant becomes a private global that is copied. A literal with a non-constant element is split into one store per element by the array lowerer. In the example, a := [9, 8, ...] uses a copy, while pair := [i, 2] uses two stores:

  call void @llvm.memcpy.p0.p0.i64(ptr align 1 %a, ptr align 1 @.const_init, i64 ptrtoint (ptr getelementptr ([10 x i32], ptr null, i32 1) to i64), i1 false)
  %tmpVar14 = getelementptr inbounds [2 x i32], ptr %pair, i32 0, i32 0
  %load_i15 = load i32, ptr %i, align 4
  store i32 %load_i15, ptr %tmpVar14, align 4
  %tmpVar16 = getelementptr inbounds [2 x i32], ptr %pair, i32 0, i32 1
  store i32 2, ptr %tmpVar16, align 4

Passing

An array is passed as a pointer in both directions. A by-value VAR_INPUT is copied into a local of the parameter’s type at the start of the callee, so the callee works on its own copy; a VAR_IN_OUT is a pointer that is stored and dereferenced on every access:

define i32 @sum(ptr %0, ptr %1) {
entry:
  %sum = alloca i32, align 4
  %values = alloca [3 x i32], align 4
  call void @llvm.memcpy.p0.p0.i64(ptr align 1 %values, ptr align 1 %0, i64 ptrtoint (ptr getelementptr ([3 x i32], ptr null, i32 1) to i64), i1 false)
  %target = alloca ptr, align 8
  store ptr %1, ptr %target, align 8
  ...
  %deref = load ptr, ptr %target, align 8
  %tmpVar7 = getelementptr inbounds [2 x i32], ptr %deref, i32 0, i32 0

The caller passes the addresses of its own variables in both cases, call i32 @sum(ptr %rep, ptr %pair). The copy in the callee uses the parameter’s size, which is also the argument’s size, because the validator requires an argument of the same total size. A function block keeps its parameters in the instance, so the copy moves to the caller: a by-value array is copied into the member before the call, and a VAR_IN_OUT member holds the address of the argument.

LOWER_BOUND and UPPER_BOUND accept variable-length arrays, which carry bounds at run time. A fixed array call produces E037, for example cannot assign 'ARRAY[-2..2] OF INT' to 'VARIABLE LENGTH ARRAY'.

Validation

The declaration is checked for bounds that are constant (E117), integer (E008), and in ascending order (E097, Invalid range `5..0`, did you mean `0..5`?). An initializer with more elements than the array holds is rejected (E043); one with fewer elements is a warning (E127). An access is checked for the number of dimensions (E045) and, for a constant index, against the declared range (E058, Array access must be in the range 0..2). Two arrays are assignable only when the element type is the same and both types have the same total size. The bounds and the number of dimensions are not compared, so an ARRAY[0..1, 0..2] OF DINT is assignable to an ARRAY[0..5] OF DINT. Anything else is an invalid assignment (E037), and the same comparison applies to an array argument.

At a glance

Structured TextIndexAnnotationLLVM
ARRAY[a..b] OF Tpre-processed type __<pou>_<var>, one dimension, bounds in the constant storethat type’s name[b-a+1 x T]
ARRAY[a..b, c..d] OF Tone type, two dimensionsthat type’s name[(b-a+1)*(d-c+1) x T]
ARRAY[..] OF ARRAY[..] OF Ttwo types, the outer’s inner type is the inner’s name[n x [m x T]]
arr[i]Value of the element type; i has no hintgetelementptr arr, 0, i - lower
arr[i, j]Value of the element typegetelementptr arr, 0, (i - lower_i) * len_j + (j - lower_j)
[1, 2, 3]no annotation, hinted with the target array type; elements hinted with the element typeprivate constant plus memcpy, or one store per element after lowering
a := bb hinted with the type of amemcpy of the type’s size
f(arr) by valuef.arr of the array typearr hinted with the parameter typeptr, copied into a local of the parameter’s size
VAR_IN_OUT arr__auto_pointer_to_<type>hinted with the pointer typeptr, dereferenced on every access

Strings

STRING uses one-byte storage units; WSTRING uses two-byte units. Both have fixed capacity plus one slot for a zero terminator. STRING[5] has five data slots, while an unsized STRING has 80. Assignment copies data within the target capacity and truncates longer values without a diagnostic. Parameter passing has a separate copy path, described below.

Strings use the array storage described in Arrays, with special rules for length and termination. The example shows literals, a shorter target, wide strings, and a function return:

VAR_GLOBAL CONSTANT
    SIZE: DINT := 5;
END_VAR

FUNCTION greet: STRING
    VAR_INPUT
        who: STRING;
    END_VAR

    greet := who;
END_FUNCTION

PROGRAM main
    VAR
        text: STRING;
        short: STRING[SIZE] := 'hi';
        wide: WSTRING[10];
    END_VAR

    text := 'hello';
    short := text;
    wide := "world";
    text := greet('bob');
END_PROGRAM

Declaration

The parser produces a string type node with two facts and no name: whether the type is wide, and the length expression if one was written. text: STRING is not such a node; it is a plain reference to the built-in type STRING.

Only STRING[SIZE] and WSTRING[10] are inline type definitions. Pre-processing at the start of the index stage moves them out into named types scoped to main, __main_short and __main_wide, and replaces the declaration with a reference to that name (see Index, Pre-processing). A string literal is a literal node with the text and a wide flag: 'hello' is narrow, "world" is wide.

Index

The type index holds one record per string type. Trimmed to the string variant of the type information:

String {
    /// Capacity in characters plus one for the terminator, as a literal or a constant expression
    size: TypeSize,

    /// Utf8 for STRING, Utf16 for WSTRING
    encoding: StringEncoding,

    /// Whether the source wrote a length; false for plain STRING and WSTRING
    declared_with_length: bool,
}

The stored size includes the terminator. Built-in strings have size 81, and WSTRING[10] has size 11. For STRING[SIZE], the index stores the expression SIZE + 1; constant evaluation resolves it to 6. The example therefore has four string types:

STRING          { size: 81,                encoding: Utf8,  declared_with_length: false }   built-in
WSTRING         { size: 81,                encoding: Utf16, declared_with_length: false }   built-in
__main_short    { size: SIZE + 1 -> 6,     encoding: Utf8,  declared_with_length: true }    from STRING[SIZE]
__main_wide     { size: 11,                encoding: Utf16, declared_with_length: true }    from WSTRING[10]

Variable entries store the string type name and any initializer ID. Here, main.text, greet.who, and the return variable use STRING; main.short uses __main_short. The type index supplies capacity and encoding.

Annotations

Each string literal gets a type named for its encoding and length. For 'hello', the resolver creates __STRING_5 with size 6 and later imports it into the global index. The target type becomes the hint used for copying. For the body of main:

    text := 'hello';
    ^^^^                     { kind: Variable, qualified_name: "main.text",  resulting_type: "STRING",       hint: None }
            ^^^^^^^          { kind: Value,                                  resulting_type: "__STRING_5",   hint: "STRING" }

    short := text;
    ^^^^^                    { kind: Variable, qualified_name: "main.short", resulting_type: "__main_short", hint: None }
             ^^^^            { kind: Variable, qualified_name: "main.text",  resulting_type: "STRING",       hint: "__main_short" }

    wide := "world";
    ^^^^                     { kind: Variable, qualified_name: "main.wide",  resulting_type: "__main_wide",  hint: None }
            ^^^^^^^          { kind: Value,                                  resulting_type: "__WSTRING_5",  hint: "__main_wide" }

    text := greet('bob');
    ^^^^                     { kind: Variable, qualified_name: "main.text",  resulting_type: "STRING",       hint: None }
            ^^^^^^^^^^^^     { kind: Value,                                  resulting_type: "STRING",       hint: "STRING" }
            ^^^^^            { kind: Function, qualified_name: "greet",      return_type: "STRING",          hint: None }
                  ^^^^^      { kind: Value,                                  resulting_type: "__STRING_3",   hint: Argument { resulting_type: "STRING", position: 0 } }

The resolver collects distinct literals from bodies so that codegen can emit private constants. The initializer 'hi' enters this collection after the init participant moves it into a constructor body.

For a comparison such as text = 'hello', the resolver attaches a replacement expression: STRING_EQUAL(text, 'hello'), or WSTRING_EQUAL for wide strings. It combines _EQUAL, _LESS, and _GREATER calls with NOT and OR for the other comparisons. The standard library supplies these functions; a missing one produces E073. See ReplacementAst.

Lowering

No participant rewrites strings themselves, but two rewrite the places they appear in. The aggregate-return lowerer turns greet into a void function with a VAR_IN_OUT greet: STRING parameter and gives the call site a temporary, so by codegen the last statement of main reads greet(__greet0, 'bob'); text := __greet0;. The init participant moves the initializer 'hi' out of the declaration into the constructor of main, as self.short := 'hi'. Codegen sees only assignments, calls, and pointer parameters.

Codegen

Layout

A string type becomes an array of its size, i8 for STRING and i16 for WSTRING. The program instance of the example is:

%main = type { [81 x i8], [6 x i8], [11 x i16] }

@main_instance = global %main { [81 x i8] zeroinitializer, [6 x i8] c"hi\00\00\00\00", [11 x i16] zeroinitializer }

The constant initializer 'hi' is written into the static data padded with zeros to the full size; the constructor copies it once more at start-up, like every constant initial value (see Init). Every literal collected by the resolver becomes a private constant sized to the literal, not to any target, with the terminator included:

@utf08_literal_0 = private unnamed_addr constant [4 x i8] c"bob\00"
@utf08_literal_1 = private unnamed_addr constant [6 x i8] c"hello\00"
@utf08_literal_2 = private unnamed_addr constant [3 x i8] c"hi\00"
@utf16_literal_0 = private unnamed_addr constant [6 x i16] [i16 119, i16 111, i16 114, i16 108, i16 100, i16 0]

Assignment

A string assignment uses memcpy. Its byte count is min(target size - 1, source size) multiplied by the storage-unit width. The copy leaves the target’s final terminator slot unchanged. The three assignments in main copy 6, 5, and 12 bytes:

  call void @llvm.memcpy.p0.p0.i32(ptr align 1 %text, ptr align 1 @utf08_literal_1, i32 6, i1 false)
  call void @llvm.memcpy.p0.p0.i32(ptr align 1 %short, ptr align 1 %text, i32 5, i1 false)
  call void @llvm.memcpy.p0.p0.i32(ptr align 2 %wide, ptr align 2 @utf16_literal_0, i32 12, i1 false)

short := text copies five bytes of a possibly 80-character value. If text holds 'hello', short receives hello and keeps its terminator in slot six; if it held a longer value, the value is cut without a diagnostic. A one-character literal assigned to a CHAR is the one exception to the copy rule: c := 'x' becomes store i8 120.

Passing and returning

A string argument is passed as a pointer. In the callee, a by-value VAR_INPUT gets a local array of the parameter’s size, which is zeroed and then filled from the pointer with a copy of the parameter’s size minus one character. The result travels the other way, through the in-out pointer the aggregate-return lowerer added: the caller provides a zeroed temporary of the return type and copies it into the target afterwards:

define void @greet(ptr %0, ptr %1) {
entry:
  %greet = alloca ptr, align 8
  store ptr %0, ptr %greet, align 8
  %who = alloca [81 x i8], align 4
  call void @llvm.memset.p0.i64(ptr align 1 %who, i8 0, i64 81, i1 false)
  call void @llvm.memcpy.p0.p0.i64(ptr align 1 %who, ptr align 1 %1, i64 80, i1 false)
  %deref = load ptr, ptr %greet, align 8
  call void @llvm.memcpy.p0.p0.i32(ptr align 1 %deref, ptr align 1 %who, i32 80, i1 false)
  ret void
}

define void @main(ptr %0) {
  ...
  %__greet0 = alloca [81 x i8], align 4
  call void @llvm.memset.p0.i64(ptr align 1 %__greet0, i8 0, i64 ptrtoint (ptr getelementptr ([81 x i8], ptr null, i32 1) to i64), i1 false)
  call void @greet(ptr %__greet0, ptr @utf08_literal_0)
  call void @llvm.memcpy.p0.p0.i32(ptr align 1 %text, ptr align 1 %__greet0, i32 80, i1 false)
  ...
}

This is also the C calling convention for the standard library: a C function that takes or returns a STRING takes char* parameters, with the result buffer first.

Comparison

The hidden call the resolver attached is what codegen emits. Both operands are pointers, the literal directly from its constant, and the result is the BOOL the library function returns:

  %call = call i8 @STRING_EQUAL(ptr %text, ptr @utf08_literal_1)
  store i8 %call, ptr %same, align 1

Everything else that works on strings, such as LEN, CONCAT, LEFT, or FIND, is a library function declared in Structured Text and reached through the generic lowerer; codegen has no string instructions beyond copy.

Validation

The validator rejects assignments between STRING and WSTRING (E037), and literals longer than one character assigned to CHAR (E065 and E037). Ordinary string assignments do not require equal lengths: short := 'far too long' stores far t without a diagnostic. Interface method signatures do require matching string lengths and encodings (E118).

At a glance

Structured TextIndexAnnotationLLVM
STRINGbuilt-in, size 81, Utf8STRING[81 x i8]
STRING[n]pre-processed type __<pou>_<var>, size n + 1, Utf8that type’s name[n+1 x i8]
WSTRING[n]pre-processed type, size n + 1, Utf16that type’s name[n+1 x i16]
'abc'none until the resolver runs__STRING_3, size 4, hinted to the targetprivate constant [4 x i8] c"abc\00"
"abc"none until the resolver runs__WSTRING_3, size 4, hinted to the targetprivate constant [4 x i16]
a := bb hinted to the type of amemcpy of min(size(a) - 1, size(b)) characters
f(s)f.s of type STRINGs hinted to STRINGptr, copied into a local of the parameter’s size
a = breplaced by a call to STRING_EQUALcall i8 @STRING_EQUAL(ptr, ptr)

Enums

An enum gives names to integer values. TYPE Color: (Red, Green, Blue := 5); END_TYPE defines three variants of Color. Codegen uses the underlying integer type, DINT by default, and folds variant references into constant values.

The example shows explicit values, type defaults, and different ways to name a variant. It also includes a cross-enum assignment, state := Door#Closed, which the compiler reports with two warnings:

TYPE Color: (Red, Green, Blue := 5); END_TYPE

TYPE State: (Open := 1, Closed := 4, Idle, Running) BYTE := Closed; END_TYPE

TYPE Door: (Open := 8, Closed := 16); END_TYPE

FUNCTION isGreen: BOOL
    VAR_INPUT
        color: Color;
    END_VAR

    isGreen := color = Green;
END_FUNCTION

PROGRAM main
    VAR
        paint: Color;
        state: State;
        mode: (Manual, Auto) := Auto;
        flag: BOOL;
    END_VAR

    paint := Blue;
    paint := Color#Red;
    paint := Color.Green;
    state := Door#Closed;
    state := Closed;
    flag := isGreen(paint);
    flag := paint = Green;
    flag := state <> Idle;
END_PROGRAM

Declaration

The enum node records its name, underlying integer type, and variant list. The default underlying type is DINT. Each variant is either an identifier such as Red or an assignment such as Blue := 5.

The two spellings of the underlying type, : BYTE (...) in the standard and (...) BYTE after the list, produce the same node. A default written after the list, := Closed, becomes the initializer of the type declaration. mode: (Manual, Auto) is an inline type, which pre-processing at the start of the index stage moves out as __main_mode (see Index, Pre-processing).

Pre-processing also rewrites every variant list so that each variant has an explicit value. A bare first variant gets the literal 0, and every later bare variant gets the expression <Enum>#<previous> + 1, a typed reference to the variant before it:

-TYPE Color: (Red, Green, Blue := 5); END_TYPE
+TYPE Color: (Red := 0, Green := Color#Red + 1, Blue := 5); END_TYPE
-TYPE State: (Open := 1, Closed := 4, Idle, Running) BYTE := Closed; END_TYPE
+TYPE State: (Open := 1, Closed := 4, Idle := State#Closed + 1, Running := State#Idle + 1) BYTE := Closed; END_TYPE

From here on no stage has to count variants; every value is an expression like any other initializer.

Index

The type index holds one record per enum type. Trimmed to the enum variant of the type information:

Enum {
    /// The enum's own name
    name: TypeId,

    /// The underlying integer type: DINT by default, BYTE for State
    referenced_type: TypeId,

    /// One variable entry per variant, in declaration order
    variants: Vec<VariableIndexEntry>,
}

Each variant is a constant global variable entry, such as Color.Red. Its value expression is stored with the enum’s underlying type as the evaluation target. The index registers the variant under its enum and under its bare name, so Red can resolve without a qualifier.

After indexing and constant evaluation the project holds four enum types and eleven variants:

Color        DINT   Red := 0   Green := 1   Blue := 5                    default: Red (0)
State        BYTE   Open := 1  Closed := 4  Idle := 5   Running := 6     default: Closed (4), explicit
Door         DINT   Open := 8  Closed := 16                              default: Open (8)
__main_mode  DINT   Manual := 0   Auto := 1                              default: Manual (0)

Constant evaluation folds the generated expressions, Color#Red + 1 to 1 and State#Idle + 1 to 6 (see Index, Constant evaluation). Its last step gives every enum type without an explicit default one: the variant whose value is zero, or the first variant if no value is zero. Door therefore defaults to Open, value 8, not to 0. The default is stored as the initial value of the type and points at the same constant-store entry as the variant.

The variable entries know only the type name: main.paint is of type Color, and main.mode is of type __main_mode with the initializer Auto, which evaluates to 1.

Annotations

A variant in a body resolves to its variable entry, and the annotation records that it is a constant global of the enum type. The hint is the type of the target, so a variant of the wrong enum gets a hint that differs from its own type. For the body of main:

    paint := Blue;
    ^^^^^                    { kind: Variable, qualified_name: "main.paint",   resulting_type: "Color", hint: None }
             ^^^^            { kind: Variable, qualified_name: "Color.Blue",   resulting_type: "Color", hint: "Color", constant: true }

    paint := Color#Red;
             ^^^^^           { kind: Type,     type_name: "Color" }
                   ^^^       { kind: Variable, qualified_name: "Color.Red",    resulting_type: "Color", constant: true }
             ^^^^^^^^^       { kind: Value,                                    resulting_type: "Color", hint: "Color" }

    paint := Color.Green;
             ^^^^^           { kind: Type,     type_name: "Color" }
             ^^^^^^^^^^^     { kind: Variable, qualified_name: "Color.Green",  resulting_type: "Color", hint: "Color", constant: true }

    state := Door#Closed;
             ^^^^^^^^^^^     { kind: Value,                                    resulting_type: "Door",  hint: "State" }

    state := Closed;
             ^^^^^^          { kind: Variable, qualified_name: "State.Closed", resulting_type: "State", hint: "State", constant: true }

    flag := paint = Green;
            ^^^^^^^^^^^^^    { kind: Value,                                    resulting_type: "BOOL",  hint: "BOOL" }
            ^^^^^            { kind: Variable, qualified_name: "main.paint",   resulting_type: "Color", hint: None }
                    ^^^^^    { kind: Variable, qualified_name: "Color.Green",  resulting_type: "Color", hint: None,    constant: true }

    flag := state <> Idle;
            ^^^^^            { kind: Variable, qualified_name: "main.state",   resulting_type: "State", hint: "UDINT" }
                     ^^^^    { kind: Variable, qualified_name: "State.Idle",   resulting_type: "State", hint: "UDINT", constant: true }

The example uses three forms of variant access: Color#Red, Color.Green, and the unqualified Closed.

Color#Red is a cast expression: the base Color is annotated as a type, and because that type is an enum, the target Red is looked up among its variants only. The expression as a whole is a value of type Color. Color.Green is an ordinary member access: the base resolves to the type Color, and the member lookup finds Green, because the variants of an enum are its members.

Closed without a qualifier goes through the normal name lookup (see Resolver, Walking a unit): a member of the current POU first, then a global. Inside a POU the member lookup also searches the variants of every enum type one of the POU’s variables has. That is why Closed in main finds State.Closed and not Door.Closed: main has a variable of type State and none of type Door. Without such a variable, the global lookup takes the first variant of that name in declaration order.

Comparisons are promoted through the underlying type. paint = Green compares two DINT values and needs no hints. state <> Idle compares two BYTE values, and the resolver widens both to 32 bits; the promoted type is UDINT because BYTE is unsigned. The argument paint in isGreen(paint) is hinted to the parameter type Color like any argument, and the variant initializers are annotated too, with the underlying type as hint, so the values of State are hinted BYTE.

Note

Names are case-insensitive, so a variable color: Color shadows the type name in Color.Green: the base resolves to the variable main.color, and the variant is then found through the variable’s type. The result is the same entry, which is why the example uses paint for the variable.

Lowering

Enums keep their representation during lowering. The init participant creates an empty constructor unless the type declares an explicit default. Thus State__ctor stores 4. A variable initializer such as mode := Auto becomes an assignment in main__ctor.

Codegen

Layout

An enum type becomes the LLVM integer of its underlying type, i32 for Color and i8 for State; the type itself leaves no trace in the module. Every variant becomes a global constant named by its qualified name, and the instance of main carries the initial value of each of its variables:

%main = type { i32, i8, i32, i8 }

@main_instance = global %main { i32 0, i8 4, i32 1, i8 0 }
@__main_mode.Auto = unnamed_addr constant i32 1
@Color.Red = unnamed_addr constant i32 0
@Color.Green = unnamed_addr constant i32 1
@Color.Blue = unnamed_addr constant i32 5
@State.Open = unnamed_addr constant i8 1
@State.Closed = unnamed_addr constant i8 4
@State.Idle = unnamed_addr constant i8 5
@State.Running = unnamed_addr constant i8 6
@Door.Open = unnamed_addr constant i32 8
@Door.Closed = unnamed_addr constant i32 16
@__main_mode.Manual = unnamed_addr constant i32 0

paint starts at 0, the Red default; state at 4, the explicit Closed; mode at 1 from its own initializer. The variant globals carry no debug information, so a debugger does not list them as variables.

Assignment and comparison

A variant is a constant, so codegen never loads its global; it folds the value into the instruction. The five assignments of main are five stores of immediates, and Door#Closed into a State becomes store i8 16 with the value cut to the underlying type. A comparison with a variant compares against the immediate; when the underlying type is smaller than DINT, the variable is widened first, as the hints said:

  store i32 5, ptr %paint, align 4
  store i32 0, ptr %paint, align 4
  store i32 1, ptr %paint, align 4
  store i8 16, ptr %state, align 1
  store i8 4, ptr %state, align 1
  %load_paint1 = load i32, ptr %paint, align 4
  %tmpVar = icmp eq i32 %load_paint1, 1
  %load_state = load i8, ptr %state, align 1
  %2 = zext i8 %load_state to i32
  %tmpVar2 = icmp ne i32 %2, 5

Passing

An enum parameter is its integer: isGreen is define i8 @isGreen(i32 %0), and the call passes the loaded i32. Nothing distinguishes it from a DINT parameter.

Validation

The declaration is checked for an integer underlying type (E122; REAL or TIME are rejected) and for an empty variant list (E028).

Enum assignment diagnostics are warnings or informational messages by default. They do not stop codegen unless the severity configuration changes them. See Severity and reporting.

The assignment check reads the right side as a constant integer. When it cannot, it reports the value as evaluated at run time (E091). An integer variable lands here, and so does the cast form Enum#Variant, which is why state := Door#Closed in the example reports E091 and not a value mismatch. When it can, it compares the value with the variants of the target: a match gives the note Replace `1` with `Green` (E092), and no match gives E040. A narrower underlying type gives E067 on top, as the example also shows.

Two enum types are the same type only when their names are equal, so a copy of a variant list under a second name is a different type. An enum assigned to an integer variable is not checked at all.

At a glance

Structured TextIndexAnnotationLLVM
TYPE Color: (Red, Green); END_TYPEenum type, underlying DINT, two variants with constant-store valuesnone; variables are i32
(...) BYTE or : BYTE (...)underlying BYTEi8
Red as a variantconstant global entry Color.Red, also findable by bare nameVariable Color.Red, constant, type Color@Color.Red = unnamed_addr constant i32 0, folded to an immediate
Color#Redbase Type Color, whole a Value of type Colorimmediate
Color.Redbase Type Color, member Variable Color.Redimmediate
mode: (Manual, Auto)pre-processed type __main_modei32, constants @__main_mode.Manual
x: Color without initializertype default: zero variant, else the firstinitial value of the type’s default
a = b on enumsoperands hinted to the promoted type when it is wider than the enumicmp on the promoted integer

Variable-Length Arrays

A fixed array carries its bounds in its type. A variable-length array (VLA) parameter uses * instead: ARRAY[*] OF DINT accepts different one-dimensional DINT arrays. ARRAY[*, *] OF INT accepts two-dimensional INT arrays. The caller passes a small struct containing the array address and bounds. The callee uses these bounds to compute offsets at run time.

The example passes two arrays with different bounds to sum and a two-dimensional array to fill:

FUNCTION sum: DINT
    VAR_IN_OUT
        values: ARRAY[*] OF DINT;
    END_VAR
    VAR
        i: DINT;
    END_VAR

    FOR i := LOWER_BOUND(values, 1) TO UPPER_BOUND(values, 1) DO
        sum := sum + values[i];
    END_FOR
END_FUNCTION

FUNCTION fill: DINT
    VAR_INPUT {ref}
        grid: ARRAY[*, *] OF INT;
    END_VAR

    grid[0, 1] := 7;
END_FUNCTION

PROGRAM main
    VAR
        small: ARRAY[0..2] OF DINT := [1, 2, 3];
        large: ARRAY[10..19] OF DINT;
        table: ARRAY[0..1, 0..2] OF INT;
        total: DINT;
    END_VAR

    total := sum(small);
    total := total + sum(large);
    fill(table);
END_PROGRAM

Declaration

The parser produces an ordinary array type node with a flag that marks it as variable-length. The bounds are one placeholder node per *, so ARRAY[*] has one and ARRAY[*, *] has a list of two; the element type is a reference like in any array.

Pre-processing names these inline types __sum_values and __fill_grid, as it does for fixed arrays. The {ref} modifier marks an input as passed by reference. The Validation section explains allowed parameter forms and the behavior without this modifier.

Index

A VLA does not become an array in the index. The indexer registers it as a struct with two members and a source marker that says what the struct stands for:

Struct {
    /// The pre-processed type name, __sum_values
    name: TypeId,

    /// Two members: a pointer to the array and the bounds array
    members: Vec<VariableIndexEntry>,

    /// For a VLA: Internal(VariableLengthArray { inner_type_name, ndims })
    source: StructSource,
}

For values: ARRAY[*] OF DINT, the indexer creates four types. The main struct, __sum_values, has the nature __VLA, a marker used by resolution and validation.

The first member, struct_vla_dint_1, points to an array type with undetermined bounds. That type lets the resolver attach an array hint; codegen does not lay it out. The second member, dimensions, is a fixed DINT array with a lower and upper bound for each dimension:

__sum_values                       Struct, nature __VLA, source VariableLengthArray { DINT, 1 dim }
  .struct_vla_dint_1               __ptr_to___sum_values_vla_1_dint
  .dimensions                      __bounds___sum_values_vla_1_dint       ARRAY[0..1] OF DINT
__sum_values_vla_1_dint            Array of DINT, bounds Undetermined
__fill_grid                        Struct, nature __VLA, source VariableLengthArray { INT, 2 dims }
  .struct_vla_int_2                __ptr_to___fill_grid_vla_2_int
  .dimensions                      __bounds___fill_grid_vla_2_int         ARRAY[0..1, 0..1] OF DINT

The parameter entries do not point at the struct directly. Like every by-reference parameter, sum.values and fill.grid store an auto-dereferencing pointer type, __auto_pointer_to___sum_values, so that a body can write values[i] without a ^ (see Index, Indexing a unit). The names of the helper types are built from the struct name, the dimension count, and the element type, so two VLA parameters never share them, even when their shapes agree.

Annotations

Inside the callee, the VLA reference has a variable annotation for the struct type and a hint for the array behind its pointer. The hint permits indexing. The complete element access has the array’s element type:

    sum := sum + values[i];
                 ^^^^^^^^^        { kind: Value,                                        resulting_type: "DINT" }
                 ^^^^^^           { kind: Variable, qualified_name: "sum.values",       resulting_type: "__sum_values",  hint: Variable "__sum_values_vla_1_dint" }
                        ^         { kind: Variable, qualified_name: "sum.i",            resulting_type: "DINT",          hint: None }

    grid[0, 1] := 7;
    ^^^^^^^^^^                    { kind: Value,                                        resulting_type: "INT" }
    ^^^^                          { kind: Variable, qualified_name: "fill.grid",        resulting_type: "__fill_grid",   hint: Variable "__fill_grid_vla_2_int" }
                  ^               { kind: Value,                                        resulting_type: "DINT",          hint: "INT" }

At the call site the argument is an ordinary fixed array, and it receives the argument hint of the parameter, the auto-dereferencing pointer to the VLA struct. The mismatch between the annotation (a fixed array) and the hint (a pointer to a VLA struct) is the signal codegen acts on:

    total := sum(small);
                 ^^^^^            { kind: Variable, qualified_name: "main.small",       resulting_type: "__main_small",  hint: Argument { resulting_type: "__auto_pointer_to___sum_values", position: 0 } }

LOWER_BOUND and UPPER_BOUND are built-in generic functions, declared as FUNCTION LOWER_BOUND<U: __ANY_VLA, T: ANY_INT>: DINT. Their annotation hints the first argument with its own VLA type when it is one, and with the reserved placeholder type __VLA when it is not, so that the argument fails the type check with a readable name. The second argument keeps its integer type, and the call is a DINT value.

Lowering

No participant rewrites VLAs. The loop desugarer turns the FOR of the example into a WHILE TRUE loop, which is why the IR below reads the lower bound once in front of the loop and the upper bound again on every iteration.

Codegen

Layout

The struct is laid out as declared: a pointer and an array of i32 with two entries per dimension. The callee receives a pointer to it:

%__sum_values = type { ptr, [2 x i32] }
%__fill_grid = type { ptr, [4 x i32] }

define i32 @sum(ptr %0)
define i32 @fill(ptr %0)

Passing

The caller allocates the VLA struct on its stack when a fixed-array argument has a VLA parameter hint. It stores the first element’s address and the array’s known bounds. Bounds follow declaration order, lower then upper for each dimension. For sum(large) and fill(table):

  %outer_arr_gep2 = getelementptr inbounds [10 x i32], ptr %large, i32 0, i32 0
  %vla_struct3 = alloca %__sum_values, align 8
  %vla_array_gep4 = getelementptr inbounds nuw %__sum_values, ptr %vla_struct3, i32 0, i32 0
  %vla_dimensions_gep5 = getelementptr inbounds nuw %__sum_values, ptr %vla_struct3, i32 0, i32 1
  store [2 x i32] [i32 10, i32 19], ptr %vla_dimensions_gep5, align 4
  store ptr %outer_arr_gep2, ptr %vla_array_gep4, align 8
  ...
  %call7 = call i32 @sum(ptr %vla_struct_ptr6)

  store [4 x i32] [i32 0, i32 1, i32 0, i32 2], ptr %vla_dimensions_gep12, align 4
  ...
  %call14 = call i32 @fill(ptr %vla_struct_ptr13)

The same call with small stores [i32 0, i32 2]. The callee only ever sees the struct, so sum compiles once and runs on both arrays.

Note

Developer note. Every call allocates a fresh struct, and the value is copied once more into a second stack slot before the call (%vla_struct then %vla_struct_ptr). A loop that calls sum a thousand times performs a thousand allocations; the optimizer removes most of them. The wrap is done by the argument generator for functions and methods.

Element access

An access values[i] cannot use a constant offset, because the lower bound is a run-time value. Codegen loads the data pointer and the bounds from the struct, subtracts the lower bound from the index, and indexes the data pointer with the result:

  %vla_arr_gep = getelementptr inbounds nuw %__sum_values, ptr %deref17, i32 0, i32 0
  %vla_arr_ptr = load ptr, ptr %vla_arr_gep, align 8
  %dim_arr = getelementptr inbounds nuw %__sum_values, ptr %deref17, i32 0, i32 1
  %start_idx_ptr0 = getelementptr inbounds [2 x i32], ptr %dim_arr, i32 0, i32 0
  %start_idx_value0 = load i32, ptr %start_idx_ptr0, align 4
  %tmpVar19 = sub i32 %load_i18, %start_idx_value0
  %arr_val = getelementptr inbounds i32, ptr %vla_arr_ptr, i32 %tmpVar19
  %load_tmpVar = load i32, ptr %arr_val, align 4

With bounds 10..19, values[10] has offset zero. For several dimensions, codegen uses the same formula as fixed arrays, but loads the bounds at run time. It subtracts each lower bound, multiplies by the lengths of later dimensions, and adds the results. For grid[0, 1]:

  %1 = sub i32 %end_idx_value0, %start_idx_value0
  %len_dim0 = add i32 1, %1
  %2 = sub i32 %end_idx_value1, %start_idx_value1
  %len_dim1 = add i32 1, %2
  %adj_access0 = sub i32 0, %start_idx_value0
  %adj_access1 = sub i32 1, %start_idx_value1
  %multiply = mul i32 %adj_access0, %accessor_factor
  %multiply3 = mul i32 %adj_access1, 1
  %accessor = load i32, ptr %accum1, align 4
  %arr_val = getelementptr inbounds i16, ptr %vla_arr_ptr, i32 %accessor
  store i16 7, ptr %arr_val, align 2

Temporary stack slots named accum hold intermediate products and sums, which accounts for some of the extra IR.

Bounds

LOWER_BOUND(values, 1) reads one entry of the dimensions array. Dimension n occupies entries 2(n-1) and 2(n-1)+1, so the lower bound of dimension 1 is entry 0 and the upper bound entry 1; a literal dimension gives a constant entry, an expression gives the same arithmetic at run time:

  %dim = getelementptr inbounds nuw %__sum_values, ptr %deref, i32 0, i32 1
  %1 = getelementptr inbounds [2 x i32], ptr %dim, i32 0, i32 0
  %2 = load i32, ptr %1, align 4

No bounds check is generated for an element access; an index outside the passed array reads or writes past it, as it does for a fixed array.

Validation

The validator keeps VLAs to the places where a struct of caller-owned storage makes sense. A VLA is accepted as VAR_INPUT {ref}, VAR_OUTPUT, or VAR_IN_OUT of a function or method, and as VAR_IN_OUT of a function block. Every other block of a function or a method is rejected with E044, Variable Length Arrays are not allowed to be defined as Local variables inside a Function. A by-value VAR_INPUT without {ref} in a function is only the warning E047, Variable Length Arrays are always by-ref, even when declared in a by-value block; the parameter is still passed as a pointer to the struct.

Warning

Three more forms never reach validation. A global VLA, a VLA anywhere in a program, and a VLA in a function block outside VAR_IN_OUT abort the resolver with internal error: entered unreachable code, so their E044 is never printed. The accepted VAR_IN_OUT of a function block is unsafe today as well: the caller stores the bare array address into the parameter instead of the wrapped struct, and the body then reads that address as a struct.

At a call, the argument must match the parameter in element type and dimension count. sum(ints) with an INT array and sum(grid) with a two-dimensional array are invalid assignments (E037), reported with the array types in the message. Inside a body, a VLA cannot be assigned to another VLA (E037).

An access must use as many indices as the VLA has dimensions (E045), and a literal dimension passed to LOWER_BOUND or UPPER_BOUND must be between one and the dimension count (E046). A fixed array passed to a bound function fails the generic constraint and is reported against the placeholder type as cannot assign 'ARRAY[0..2] OF DINT' to 'VARIABLE LENGTH ARRAY' (E037).

At a glance

Structured TextIndexAnnotationLLVM
values: ARRAY[*] OF DINT (parameter)struct __<pou>_<var> with a pointer and a bounds member, nature __VLA; the parameter holds an auto-dereferencing pointer to itVariable of the struct, hinted with the placeholder array __<pou>_<var>_vla_1_dint{ ptr, [2 x i32] }, passed as ptr
ARRAY[*, *] OF INTsame, with two dimensions in the source markersame{ ptr, [4 x i32] }
f(small) with small: ARRAY[0..2] OF DINTargument hinted with the pointer to the VLA structalloca of the struct, store data pointer and [0, 2], pass its address
values[i]Value of the element typeload pointer and lower bound, sub, getelementptr
grid[a, b]Value of the element typerun-time lengths, normalized indices, multiply and accumulate
LOWER_BOUND(values, 1)built-in generic <U: __ANY_VLA, T: ANY_INT>Value DINT; the VLA argument hinted with its own typeload entry 2(n-1) of the bounds array; UPPER_BOUND entry 2(n-1)+1

Reference Expressions

A reference expression connects a name to a variable, member, element, pointer target, or part of a value. In pShape^.points[i].x, each segment performs one step. The resolver identifies its declaration or type; codegen uses it to compute an address or value. This chapter connects the member and array accesses from the preceding chapters.

The example combines member access, array indexing, pointers, casts, direct access, and an explicit global reference:

TYPE Point:
    STRUCT
        x: DINT;
        y: DINT;
    END_STRUCT
END_TYPE

TYPE Color: (Red, Green, Blue); END_TYPE

FUNCTION_BLOCK Shape
    VAR_INPUT
        origin: Point;
    END_VAR
    VAR_OUTPUT
        points: ARRAY[0..3] OF Point;
    END_VAR
END_FUNCTION_BLOCK

FUNCTION bump: DINT
    VAR_IN_OUT
        target: DINT;
    END_VAR
    VAR_INPUT
        step: INT;
    END_VAR

    target := target + step;
    bump := target;
END_FUNCTION

VAR_GLOBAL
    count: DINT;
END_VAR

PROGRAM main
    VAR
        shape: Shape;
        pShape: POINTER TO Shape;
        pDint: REF_TO DINT;
        rDint: REFERENCE TO DINT;
        i: DINT;
        paint: Color;
        word: WORD;
        flag: BOOL;
        count: DINT;
    END_VAR

    pShape := ADR(shape);
    pShape^.points[i].x := 1;
    pDint := REF(i);
    rDint REF= i;
    rDint := rDint + 1;
    i := bump(count, 2);
    paint := Color#Red;
    i := INT#5;
    flag := word.%X0;
    i := word.%B1;
    .count := 3;
    i := pShape^.origin.x + shape.points[2].y;
END_PROGRAM

Declaration

The parser has one node kind for every reference. It holds an access, which is the kind of step, and an optional base, which is the reference the step is applied to.

The parser reads a chain from left to right, wrapping each new step around the previous result. Thus the root of pShape^.points[i].x is x, whose base is [i], then points, then ^, then pShape. A plain name is a member step with no base. Each step can inspect its base to find the type it operates on.

Six kinds of access exist:

AccessSpellingHolds
Membera, a.bthe identifier, or a direct access such as %X0
Indexa[i], a[i, j]the index expression, or a list of them
Dereferencep^nothing; the base is the pointer
CastINT#5, Color#Redthe value to cast; the base is the type name
Global.countthe identifier, looked up in the global scope only
Addressnonereserved; the parser never produces it

A cast takes only the name that follows the #, and the chain goes on from the cast: INT#a.b means (INT#a).b, so a member step after a cast applies to the result of the cast. x.5 is a shorthand for x.%X5 and becomes a bit access. Pointer declarations are inline type definitions, so pre-processing moves POINTER TO Shape out into the named type __main_pShape before indexing, like every other anonymous type.

Note

Developer note. The address access exists in the node kind, in the resolver, in codegen, and in the validator, but no spelling reaches it: the parser rejects &i, and no lowering creates it. The address of a variable is taken with the built-in calls ADR and REF, described below.

Index

The index does not record body references. It supplies their declarations and types, including the pointer types used for explicit and automatic dereferencing:

Pointer {
    /// The type pointed to, by name
    inner_type_name: TypeId,

    /// None for a pointer the user dereferences with ^; otherwise the kind of implicit dereference
    auto_deref: Option<AutoDerefType>,

    /// false for POINTER TO, true for REF_TO and REFERENCE TO
    type_safe: bool,

    /// Whether the pointer targets a POU rather than data
    is_function: bool,
}

The auto-dereference marker distinguishes explicit pointers from implicit references. Default marks by-reference parameters, Reference marks REFERENCE TO, and Alias marks x AT y. These variables read and write through their pointers without an explicit ^:

__main_pShape           { inner_type_name: "Shape", auto_deref: None,            type_safe: false }   from POINTER TO Shape
__main_pDint            { inner_type_name: "DINT",  auto_deref: None,            type_safe: true }    from REF_TO DINT
__main_rDint            { inner_type_name: "DINT",  auto_deref: Some(Reference), type_safe: true }    from REFERENCE TO DINT
__auto_pointer_to_DINT  { inner_type_name: "DINT",  auto_deref: Some(Default),   type_safe: true }    for bump.target

The indexer creates pointer types for VAR_IN_OUT, VAR_INPUT {ref}, and function VAR_OUTPUT parameters. Thus bump.target uses __auto_pointer_to_DINT. By-reference parameters with the same target type share this generated type, whose inner type preserves the declared DINT.

Enum variants are the other index entries a reference can land on. Color.Red is a constant global variable of type Color with the initializer 0, and it is also findable under the bare name Red, which is what lets paint := Red and Color#Red both resolve.

Annotations

The resolver visits the base of a reference first, then applies the step. Each segment gets its own annotation, and the whole chain takes the annotation of its last segment (see Resolver, Walking a unit, for the lookup order of a plain name). What a step produces depends on its kind:

  • Member with a base. The type of the base becomes the qualifier, and the member is looked up inside that type, following EXTENDS chains and properties. The annotation is the variable entry: its qualified name, type, and auto-deref kind.
  • Member without a base. The lookup order is a member of the current POU, then a global or enum variant, then a POU, then a type. When the reference is the operator of a call, functions are tried first.
  • Index. The base must be an array; the step is annotated as a value of the element type. The index expression is resolved on its own and gets no hint; codegen widens it to DINT before the offset arithmetic.
  • Dereference. The base must be a pointer without auto-deref; the step is a value of the inner type.
  • Cast. The base is resolved as a type, and the target is resolved as a variable under that type when the type is an enum, so Color#Red finds Color.Red. The step is a value of the type; a cast literal is retyped to the cast type.
  • Global. The identifier is looked up among global variables only; local names cannot shadow it.
  • Direct access. A %X, %B, %W, %D, or %L member is a value of BOOL, BYTE, WORD, DWORD, or LWORD.

For the body of main, two statements show most of these at once:

    pShape^.points[i].x := 1;
    ^^^^^^                        { kind: Variable, qualified_name: "main.pShape",   resulting_type: "__main_pShape", auto_deref: None }
    ^^^^^^^                       { kind: Value,                                     resulting_type: "Shape" }
    ^^^^^^^^^^^^^^                { kind: Variable, qualified_name: "Shape.points",  resulting_type: "__Shape_points", auto_deref: None }
                   ^              { kind: Variable, qualified_name: "main.i",        resulting_type: "DINT" }
    ^^^^^^^^^^^^^^^^^             { kind: Value,                                     resulting_type: "Point" }
    ^^^^^^^^^^^^^^^^^^^           { kind: Variable, qualified_name: "Point.x",       resulting_type: "DINT" }

    i := bump(count, 2);
              ^^^^^               { kind: Variable, qualified_name: "main.count",    resulting_type: "DINT",  hint: Argument { resulting_type: "__auto_pointer_to_DINT", position: 0 } }

count resolves to the local main.count, not the global; the global is reachable only as .count. Its hint names the pointer type of the parameter, which is how codegen knows to pass the address of count rather than its value.

The auto-deref pointers show up as a flag on the variable annotation, not as a type. rDint is annotated with the resulting type DINT, its inner type, and auto_deref: Reference; inside bump, target is annotated DINT with auto_deref: Default. Every stage after the resolver treats such a variable as a DINT and adds one load when it needs the address:

    rDint REF= i;
    ^^^^^                         { kind: Variable, qualified_name: "main.rDint",    resulting_type: "DINT",  auto_deref: Some(Reference("__main_rDint")) }
    rDint := rDint + 1;
    ^^^^^                         { kind: Variable, qualified_name: "main.rDint",    resulting_type: "DINT",  auto_deref: Some(Reference("__main_rDint")) }

The address of a variable is not a reference step but a call. ADR(shape) is annotated as a call of the built-in ADR returning LWORD, an integer wide enough for any address, and the assignment hints it to __main_pShape. REF(i) is typed: its annotation reads the type of the argument and registers a pointer type to it on the fly, __POINTER_TO_DINT, in the resolver’s own index, and the call is a value of that type. Casts and direct accesses complete the picture:

    pShape := ADR(shape);
              ^^^^^^^^^^          { kind: Value,                                     resulting_type: "LWORD",            hint: "__main_pShape" }
    pDint := REF(i);
             ^^^^^^               { kind: Value,                                     resulting_type: "__POINTER_TO_DINT", hint: "__main_pDint" }
    paint := Color#Red;
             ^^^^^                { kind: Type,     type_name: "Color" }
                   ^^^            { kind: Variable, qualified_name: "Color.Red",     resulting_type: "Color", constant: true }
             ^^^^^^^^^            { kind: Value,                                     resulting_type: "Color",            hint: "Color" }
    i := INT#5;
         ^^^^^                    { kind: Value,                                     resulting_type: "INT",              hint: "DINT" }
    flag := word.%X0;
                 ^^^              { kind: Value,                                     resulting_type: "BOOL" }
    i := word.%B1;
              ^^^                 { kind: Value,                                     resulting_type: "BYTE",             hint: "DINT" }
    .count := 3;
    ^^^^^^                        { kind: Variable, qualified_name: "count",         resulting_type: "DINT", argument_type: Global }

Lowering

No participant rewrites reference expressions as such, but several rewrite what they refer to. The inheritance lowerer inserts __<Base> member steps in front of inherited members. The property lowerer replaces a member step that names a property with a call. The polymorphism lowerer turns a method call through a pointer into a dereference of a function pointer. Codegen sees only the six access kinds above.

Codegen

For a reference to storage, codegen computes the address from the base outward and loads only when a value is needed. Casts and direct accesses have separate value rules, described below. See Codegen.

Member

A plain name is the address the function setup registered for it: a member pointer into the instance struct for a program or function block variable, a stack slot for a function variable, a global otherwise. A member with a base is one getelementptr from the address of the base, with the position of the member in the struct as the index. origin.x is two such steps:

  %origin = getelementptr inbounds nuw %Shape, ptr %deref8, i32 0, i32 1
  %x9 = getelementptr inbounds nuw %Point, ptr %origin, i32 0, i32 0
  %load_x = load i32, ptr %x9, align 4

The struct positions come from the index. A function block struct starts with the __vtable member, so origin, the first variable that Shape declares, is at position 1. A program or function block struct also skips VAR_TEMP variables, which live on the stack, so the position is computed rather than read from the entry.

Index

Array access subtracts the lower bound from each index, then multiplies by the lengths of later dimensions. The sum is the flat offset used by getelementptr. For points[i] on ARRAY[0..3]:

  %load_i = load i32, ptr %i, align 4
  %tmpVar = mul i32 1, %load_i
  %tmpVar1 = add i32 %tmpVar, 0
  %tmpVar2 = getelementptr inbounds [4 x %Point], ptr %points, i32 0, i32 %tmpVar1

The multiplication by one and the addition of zero are the general formula applied to one dimension with lower bound zero; the optimizer removes them. No bounds check is emitted.

Dereference

A pointer variable holds an address, so pShape^ loads that address from the variable’s slot, and the result is the address of the target. pShape^.points is the load followed by the member step:

  %deref = load ptr, ptr %pShape, align 8
  %points = getelementptr inbounds nuw %Shape, ptr %deref, i32 0, i32 2

Auto-dereference

A variable whose annotation carries an auto-deref kind gets the same load inserted without a ^ in the source. In bump, target is a stack slot that holds the caller’s address, and every use loads the address first. In main, rDint is a struct member that holds an address:

  %deref3 = load ptr, ptr %rDint, align 8
  %deref4 = load ptr, ptr %rDint, align 8
  %load_rDint = load i32, ptr %deref4, align 4
  %tmpVar5 = add i32 %load_rDint, 1
  store i32 %tmpVar5, ptr %deref3, align 4

The left and right side of rDint := rDint + 1 each load the pointer once. A REF= assignment is the one place where an auto-deref variable is not dereferenced: rDint REF= i stores the address of i into the slot, store ptr %i, ptr %rDint.

Address

ADR(x) and REF(x) generate the address of their argument and stop there, so pShape := ADR(shape) is one store of the member pointer: store ptr %shape, ptr %pShape. When the argument names a function or a method, the address of that function is taken instead. Passing count to the VAR_IN_OUT parameter of bump works the same way without a call to ADR: the argument hint names a pointer type, so codegen passes the address, call i32 @bump(ptr %count, i16 2).

Cast

A typed literal is created in the named type, then converted as required by its hint. Thus i := INT#5 produces store i32 5. An enum variant can be folded into a constant, so paint := Color#Red produces store i32 0. A cast of a non-literal, non-identifier expression uses a bit cast.

Direct access

word.%X0 and word.%B1 load the whole base, shift it right by the bit offset (the index times the access width), truncate to the access type, and for a bit mask the result to one bit:

  %load_word = load i16, ptr %word, align 2
  %shift = lshr i16 %load_word, 0
  %1 = trunc i16 %shift to i8
  %2 = and i8 %1, 1
  store i8 %2, ptr %flag, align 1

A direct access is therefore a value, not an address. Assigning to one, word.%X0 := TRUE, is handled by the assignment generator as a read-modify-write of the base.

Global

.count is the global’s address, store i32 3, ptr @count, whatever the local scope declares.

Validation

Validation checks each access kind. A member access through a pointer needs an explicit ^ unless the pointer is auto-dereferenced, so pShape.points is E141, for REF_TO as much as for POINTER TO. Dereferencing a non-pointer is E068; indexing a non-array is E059. Access to a private function block member from outside is E049. For REF=, the left side must be a pointer or auto-dereferenced variable, and the right side a reference (E098).

The parser reports a POINTER TO declaration as type-unsafe (E015), but that code is registered as ignored, so a normal run prints nothing and --error-config has to raise it. The validator compares the inner types of an assignment between type-safe pointers (E090). It compares the kind of type, not the exact name, so REF_TO DINT accepts a pointer to any integer but not a pointer to a struct. A POINTER TO accepts any address, except that a pointer to a function block or a class must point at a related one (E125).

At a glance

Structured TextIndexAnnotationLLVM
xthe variable entryVariable, qualified name and typethe registered address of the slot or member
a.bmember b of the type of aVariable on b, chain takes itgetelementptr by struct position
a[i]the array type of aValue of the element typeoffset from the lower bound, one getelementptr
p^pointer type, auto_deref: NoneValue of the inner typeload ptr from the slot
r: REFERENCE TO T, VAR_IN_OUTpointer type, auto_deref: Reference or DefaultVariable of type T with an auto-deref flagan extra load ptr on every use
ADR(x), REF(x)built-in functionsValue of LWORD, or of a generated __POINTER_TO_Tthe address of x, no load
T#vthe type TType on T, Value of T on the wholenone; the value is created in T
w.%Xn, w.%BnValue of BOOL, BYTE, and so onload, shift, truncate, mask
.gthe global gVariable with argument_type: Globalthe global’s address

Initializers

A declaration can specify an initial value: i: DINT := 1, TYPE MyInt: INT := 7, p: Point := (y := 2), or ptr: POINTER TO DINT := ADR(i). The index first tries to evaluate that expression as a constant.

Constant values can become static data. Constructors initialize instances, and statements at the start of a body initialize stack variables. These paths can write the same value more than once. This chapter follows where each initializer goes and which values need runtime work.

The example combines defaults, aggregate literals, addresses, and a call inside an array initializer. It brings together the storage rules from the preceding construct chapters:

VAR_GLOBAL CONSTANT
    MAX: DINT := 3;
END_VAR

VAR_GLOBAL
    gCount: DINT := MAX + 1;
END_VAR

TYPE MyInt: INT := 7; END_TYPE

TYPE Point:
    STRUCT
        x: DINT := 1;
        y: DINT;
    END_STRUCT
END_TYPE

FUNCTION_BLOCK Counter
    VAR_INPUT
        step: DINT := 1;
    END_VAR
    VAR
        count: DINT;
    END_VAR
END_FUNCTION_BLOCK

FUNCTION pick: DINT
    VAR
        local: DINT := 5;
    END_VAR

    pick := local;
END_FUNCTION

PROGRAM main
    VAR
        i: DINT := MAX + 1;
        n: MyInt;
        p: Point := (y := 2);
        values: ARRAY[0..2] OF DINT := [1, 2, 3];
        counter: Counter := (step := 10);
        ptr: POINTER TO DINT := ADR(i);
        r: REFERENCE TO DINT REF= i;
        readings: ARRAY[0..1] OF DINT := [pick(), 2];
    END_VAR
    VAR_TEMP
        t: DINT := 4;
    END_VAR
END_PROGRAM

Declaration

The parser stores the initializer as an expression on a variable or type declaration. MAX + 1 is a binary expression; (y := 2) is a parenthesized assignment. [1, 2, 3] is an array literal, and ADR(i) is a call. REF= i stores the reference i and marks the declaration for reference initialization.

Pre-processing at the start of the index stage moves inline types such as ARRAY[0..2] OF DINT out into named types (__main_values), but the initializer stays on the variable, not on the new type.

Index

Initializers share the index’s constant store with array bounds and string sizes. A variable or type entry keeps the ID of its expression. Each store entry records whether evaluation succeeded and, if not, why:

pub enum ConstExpression {
    /// Not evaluated yet; scope is the POU the expression was written in, lhs the variable it initializes
    Unresolved { statement: AstNode, scope: Option<String>, lhs: Option<String> },

    /// Folded to a literal, or accepted as is for a struct or array literal
    Resolved(AstNode),

    /// Cannot become static data; the reason says why and what to do instead
    Unresolvable { statement: AstNode, reason: Box<UnresolvableKind> },
}

pub enum UnresolvableKind {
    /// Not a constant, and never will be; reported by validation
    Misc(String),

    /// A literal that does not fit the target type; reported as a warning
    Overflow(String, SourceLocation),

    /// An address, which exists only after codegen has laid out the memory
    Address(InitData),
}

Each entry also records the target type name, so that the evaluator knows in which type to fold and whether the result fits. For the example, the store holds eighteen entries: one per initializer and four for the array bounds 0, 2, 0, 1. After evaluation, the initializers read:

MAX                 DINT             Resolved(3)
gCount              DINT             Resolved(4)                        folded from MAX + 1
MyInt               MyInt            Resolved(7)                        the type's default
Point.x             DINT             Resolved(1)
Counter.step        DINT             Resolved(1)
pick.local          DINT             Resolved(5)
main.i              DINT             Resolved(4)                        folded from MAX + 1
main.p              Point            Resolved(y := 2)                   struct literal, kept as written
main.values         __main_values    Resolved([1, 2, 3])                array literal, kept as written
main.counter        Counter          Resolved(step := 10)
main.ptr            __main_ptr       Unresolvable(ADR(i), Address)      "Try to re-resolve during codegen"
main.r              __main_r         Unresolvable(i, Address)           "Try to re-resolve during codegen"
main.readings       __main_readings  Unresolvable([pick(), 2], Misc)    "Call-statement 'pick' in initializer is not constant."
main.t              DINT             Resolved(4)

The evaluator processes a queue. Literals resolve after a range check. Constant references use the referenced value when available; otherwise they return to the queue. This allows expressions to depend on constants declared later.

A reference to a variable that is not constant is unresolvable, “x is no const reference”, unless the target is a pointer type; then it is an address. ADR, REF, and a bare reference that initializes a REFERENCE TO are addresses too. A call to a user function is a plain unresolvable, because the compiler does not execute code at compile time.

Struct and array literals keep their shape: every element is folded on its own, but the literal as a whole stays a list instead of one value. An element that is not constant makes the whole literal unresolvable, as readings shows. Outside a CONSTANT block, a variable without an initializer has no entry at all, and its value comes from its type: the type’s own default (n: MyInt starts at 7), or zero.

Note

Developer note. The index has an unused map of default-instance entries such as __Point__init and __Counter__init. These entries do not produce LLVM globals. Codegen reads defaults from the type index, while constructors provide runtime initialization.

Annotations

The resolver visits an initializer like any other expression, with the declaring POU as context, and hints it with the declared type of the variable (see Resolver). A struct literal gets no annotation of its own, only the hint, and its assignments resolve the member name against the target type, not against the current POU:

    i: DINT := MAX + 1;
               ^^^^^^^        { kind: Value,                                   resulting_type: "DINT",  hint: "DINT" }
               ^^^            { kind: Variable, qualified_name: "MAX", constant: true, resulting_type: "DINT", hint: None }

    p: Point := (y := 2);
                ^^^^^^^^      { kind: None,                                                             hint: "Point" }
                 ^            { kind: Variable, qualified_name: "Point.y",      resulting_type: "DINT",  hint: None }
                      ^       { kind: Value,                                   resulting_type: "DINT",  hint: "DINT" }

    ptr: POINTER TO DINT := ADR(i);
                            ^^^^^^   { kind: Value,                                resulting_type: "LWORD", hint: "__main_ptr" }
                                ^    { kind: Variable, qualified_name: "main.i",   resulting_type: "DINT",  hint: "DINT" }

    r: REFERENCE TO DINT REF= i;
                              ^      { kind: Variable, qualified_name: "main.i",   resulting_type: "DINT",  hint: "__main_r" }

TYPE MyInt: INT := 7; END_TYPE
                   ^                 { kind: Value,                                resulting_type: "DINT",  hint: "INT" }

ADR(i) is a LWORD value hinted to the pointer type __main_ptr; REF= i is the variable itself hinted to the reference type. Both hints tell codegen to store an address rather than a value. The elements of [1, 2, 3] are each hinted DINT, the literal as a whole __main_values.

Lowering

The init participant moves instance and type initialization into constructors such as main__ctor and Point__ctor. Function locals and VAR_TEMP variables get statements at the start of the body. It also removes the non-constant array literal from readings, so the rebuilt index no longer holds that unresolved initializer.

The array lowerer then splits the assignment self.readings := [pick(), 2] in the constructor into one assignment per element. Everything else stays where it is: the resolved entries are still in the store, and codegen reads them for the static data.

Codegen

Static data

Every global and every program instance gets a compile-time initial value. For a variable, codegen takes the resolved expression from the store; when there is none, the default of its type; when the type has none, zero. Unresolvable entries count as none. The instance of main and the globals are therefore complete before any code runs, apart from the two addresses and the array with the call:

@MAX = unnamed_addr constant i32 3
@gCount = global i32 4
@main_instance = global %main { i32 4, i16 7, %Point { i32 1, i32 2 }, [3 x i32] [i32 1, i32 2, i32 3], %Counter { ptr null, i32 10, i32 0 }, ptr null, ptr null, [2 x i32] zeroinitializer }

i starts at the folded value 4, and n uses the MyInt default 7. p combines the type’s x := 1 with the variable’s y := 2. counter uses its variable initializer step := 10 instead of the type default. Codegen computes struct defaults once per type and reuses them.

For every local or temporary member of aggregate type with an initializer, codegen also emits a constant named after the member, __main.values__init, that holds the value. When such a variable lives on the stack, in a function or a VAR_TEMP block, its slot is initialized with a memcpy from that constant instead of element by element. For a member of a program or function block the constant is emitted but not used, because the value is already in the instance.

Constructor

The generated constructor writes the same values again at start-up, and this is the only place where the three unresolvable initializers get their value. The stores and calls of main__ctor, without the address arithmetic and the empty constructors of the inline types:

  store i32 4, ptr %i, align 4
  call void @MyInt__ctor(ptr %n)
  call void @Point__ctor(ptr %p)
  store i32 2, ptr %y, align 4
  call void @llvm.memcpy.p0.p0.i64(ptr align 1 %values7, ptr align 1 @.const_init, i64 ptrtoint (ptr getelementptr ([3 x i32], ptr null, i32 1) to i64), i1 false)
  call void @Counter__ctor(ptr %counter)
  store i32 10, ptr %step, align 4
  store ptr %i15, ptr %ptr13, align 8
  store ptr %i21, ptr %r19, align 8
  %call = call i32 @pick()
  store i32 %call, ptr %tmpVar, align 4
  store i32 2, ptr %tmpVar27, align 4

The type constructor runs before the member’s own literal, so Point__ctor sets x := 1 and the following store sets y := 2. ADR(i) and REF= i become stores of the address of i, and pick() is called once, at start-up, not on every cycle. The unit constructor stores gCount := 4 in the same way and then calls main__ctor on the instance (see Codegen, Initialization).

Stack

A function local, a return variable, and a VAR_TEMP have no instance. Codegen stores their initial value when it creates the stack slot at the start of the body, and the statement the init participant prepended stores it again:

define i32 @pick() {
entry:
  %pick = alloca i32, align 4
  %local = alloca i32, align 4
  store i32 5, ptr %local, align 4
  store i32 0, ptr %pick, align 4
  store i32 5, ptr %local, align 4
  ...

The example writes resolved initializers twice: in static data and a constructor, or twice at the start of a body. LLVM can remove redundant stores. Addresses and runtime calls still need executable initialization, and the @llvm.global_ctors entry of the unit constructor makes that work happen before the program starts, also for a C program that links the object.

Validation

The validator turns the states of the store into diagnostics. An Unresolvable entry with a Misc reason is an error at the initializer, “Unresolved constant chosen variable: Call-statement ‘pick’ in initializer is not constant.” (E033). The same code is reported for an entry that is still Unresolved after evaluation, which happens when constants reference each other in a cycle, and for a CONSTANT variable whose type has no default that can be resolved. A CONSTANT without an initializer is otherwise fine: the parser gives it a default-value node that folds to the default of the type.

An Overflow reason is a warning. An Address reason is not an error; the validator checks instead that the pointed-to type matches the declared pointer type. A separate rule rejects the address of a temporary kept in a member variable (E109). It fires when the initializer names the temporary directly, as an AT alias or a REF= binding, but not for ADR or REF: it reads the argument of the call directly, and by then lowering has put that argument in an expression list.

A scalar initializer containing a call is rejected. The same call inside an array literal can pass because init lowering removes that initializer before validation. Array lowering then turns it into element assignments.

At a glance

InitializerConstant storeStatic dataConstructor or body
i: DINT := 1Resolved(1)i32 1 in the instancestore i32 1
i: DINT := MAX + 1Resolved(4), foldedi32 4store i32 4
TYPE MyInt: INT := 7Resolved(7) on the typedefault for every MyInt without its own initializerMyInt__ctor stores 7
p: Point := (y := 2)Resolved, literal kept as writtenstruct constant, type default for the other membersPoint__ctor(p), then a store for y
values: ARRAY := [1, 2, 3]Resolved, literal keptarray constantmemcpy from a constant
counter: Counter := (step := 10)Resolved, literal keptstruct constant with the FB’s defaultsCounter__ctor then store
ptr := ADR(i)Unresolvable(Address)ptr nullstore of the address of i
r REF= iUnresolvable(Address)ptr nullstore of the address of i
readings := [pick(), 2]Unresolvable(Misc), removed by loweringzeroinitializerone store per element, the call runs once
local: DINT := 5 in a functionResolved(5)nonetwo store at the start of the body
t: DINT := 4 in VAR_TEMPResolved(4)nonetwo store at the start of the body
chosen: DINT := pick()Unresolvable(Misc)nonenone; E033 rejects the program

Annotated AST

The resolver records meaning in a map keyed by AST node ID. An annotation identifies a declaration or result type. A type hint records the type expected where an expression is used. Validation and codegen combine this information with the index. Participants can rewrite the tree and request new annotations.

The Resolver chapter explains how the table is filled. This chapter is the reference for what it holds: one section per annotation kind, with its fields, an example, and the stages that read it.

The example produces the annotations used below. Labels are introduced separately because they come from CFC diagrams:

TYPE Color: (Red, Green, Blue); END_TYPE
TYPE Percent: INT(0..100); END_TYPE

FUNCTION CheckRangeSigned: INT
    VAR_INPUT
        value, lower, upper: INT;
    END_VAR

    CheckRangeSigned := value;
END_FUNCTION

{external}
FUNCTION STRING_EQUAL: BOOL
    VAR_INPUT
        a, b: STRING;
    END_VAR
END_FUNCTION

FUNCTION scale: DINT
    VAR_INPUT
        value: DINT;
        factor: INT := 1;
    END_VAR

    scale := value * factor;
END_FUNCTION

FUNCTION_BLOCK Base
    VAR_INPUT
        limit: INT;
    END_VAR

    METHOD area: DINT
        area := limit;
    END_METHOD
END_FUNCTION_BLOCK

FUNCTION_BLOCK Counter EXTENDS Base
    VAR_INPUT
        step: DINT;
    END_VAR
    VAR_OUTPUT
        count: DINT;
    END_VAR

    PROPERTY_GET scaled: DINT
        scaled := count * 10;
    END_PROPERTY

    METHOD area: DINT
        area := SUPER^.area() + step;
    END_METHOD

    count := count + step;
END_FUNCTION_BLOCK

PROGRAM logger
    VAR_INPUT
        text: STRING;
    END_VAR
END_PROGRAM

PROGRAM main
    VAR
        counter: Counter;
        i: DINT;
        pct: Percent;
        hue: Color;
        fp: __FPOINTER Base.area := ADR(Base.area);
        same: BOOL;
        text: STRING;
        r: REFERENCE TO DINT;
    END_VAR
    VAR CONSTANT
        MAX: DINT := 10;
    END_VAR

    i := MAX;
    counter(limit := 3, step := 2, count => i);
    i := scale(i, factor := INT#5);
    i := counter.area();
    logger(text := 'x');
    hue := Color#Red;
    i := fp^(counter);
    pct := i;
    same := text = 'hello';
    i := counter.scaled;
    r REF= i;
END_PROGRAM

The annotations below are the ones after the first resolver pass, with the accessor methods of the property lowerer already in place. Later participants rewrite some of these statements and the resolver runs again. A rewrite can change which kind a node gets, but not the set of kinds described below.

Entries store type names, which consumers resolve through the index. Most entries describe expressions; method and POU declarations also receive annotations for validation.

Type hints

A hint is a second entry for the same node. It usually uses Value or Argument and specifies the required type: an assignment target, parameter, promoted operand, or condition type. A hint can equal the annotated type; no conversion is then needed.

    i := MAX;
         ^^^          Variable "DINT"   hint Value "DINT"
    pct := i;
           ^          Variable "DINT"   hint Argument "INT", position 0, pou CheckRangeSigned

The second line shows a hint that was replaced: pct is a subrange of INT, so the value is not hinted to Percent but to the parameter of the range check function the hidden call below inserts. Codegen compares annotation and hint and emits the conversion (see Codegen); the validator compares them to report the implicit downcast (E067).

Hidden function calls

An assignment to a subrange such as INT(0..100) can call a range-check function. The signedness and the width of the type choose the name: CheckRangeSigned and CheckRangeUnsigned up to 32 bits, CheckLRangeSigned and CheckLRangeUnsigned above. If the project declares that function, the resolver builds a call with the value and bounds. It annotates the call and stores it under the assigned value’s node ID. The original value keeps its annotation.

When codegen stores into a subrange variable, it asks the table and generates the call in place of the value. Without the function declared, the store is plain.

    pct := i;
           ^          hidden: CheckRangeSigned(i, 0, 100)

Annotation kinds

Each entry below shows its fields, an example, and the stages that use it.

Value

Value {
    /// The type the expression evaluates to
    resulting_type: String,
}

Value records a result type without identifying a variable declaration. It covers literals, arithmetic, casts, call results, and array element accesses. In the example:

    i := scale(i, factor := INT#5);
         ^^^^^^^^^^^^^^^^^^^^^^^^^   Value "DINT"      the call takes the function's return type
                            ^^^^^    Value "INT"       the cast takes the type it names
                                ^    Value "INT"       the literal inside a cast is typed by the cast
    logger(text := 'x');
                   ^^^               Value "__STRING_1" a literal is typed by its own value

Consumers read the type through get_type. Value alone does not determine whether an expression has an address: an array element does, while i + 1 does not. The AST form also matters to codegen.

Variable

Variable {
    /// The type name of the variable
    resulting_type: String,

    /// The declaration it refers to, such as "main.i" or "Base.limit"
    qualified_name: String,

    /// Declared in a CONSTANT block, or an enum variant
    constant: bool,

    /// Which variable block declares it, and whether it is held by value or by reference
    argument_type: ArgumentType,

    /// Set when reading the variable reads through a pointer: REFERENCE TO, AT alias, or VAR_IN_OUT
    auto_deref: Option<AutoDerefType>,
}

The kind for a reference that resolves to a declared variable, wherever it is declared: a local, a member reached through an instance, a global, an enum variant, the return variable of a function. A qualified reference a.b.c gets the entry of its last segment, and every segment has an entry of its own.

    i := MAX;
    ^                Variable "DINT",  main.i,      constant: false, Local
         ^^^         Variable "DINT",  main.MAX,    constant: true,  Local
    counter(limit := 3, step := 2, count => i);
    ^^^^^^^          Variable "Counter", main.counter                 the operator of a function block call
            ^^^^^    Variable "INT",   Base.limit,  Input             looked up in the callee, not in main
    hue := Color#Red;
                 ^^^ Variable "Color", Color.Red,   constant: true,  Global
    r REF= i;
    ^                Variable "DINT",  main.r,      auto_deref: Reference("__main_r")

r is declared as REFERENCE TO DINT, and its type is reported as DINT, the type behind the reference. auto_deref records that one load through the pointer type __main_r is needed to reach that DINT. An AT alias records its own pointer type the same way. A VAR_IN_OUT parameter is also read through a pointer, but the entry keeps no type name for it.

This is the kind codegen uses most. It looks the address of a reference up in the LLVM index by qualified_name, replaces a constant variable of a scalar type by its evaluated value instead of a load, and adds the load through the pointer for auto_deref.

Validation uses variable annotations for constant assignments (E036), private-member access (E049), and reference assignments (E098). It also checks constants passed by reference and references in VAR_CONFIG.

Function

Function {
    /// The declared return type, or VOID
    return_type: String,

    /// The function or method, such as "scale" or "Counter.area"
    qualified_name: String,

    /// For a call to a generic function: the template's name
    generic_name: Option<String>,

    /// For a call to a generic function: the concrete implementation to call, when it differs
    call_name: Option<String>,
}

The kind for the operator of a call to a function or method, and for a bare reference to one. The resolver looks the operator up with functions first, so inside scale the name scale as an operator is the function and everywhere else the return variable.

    i := scale(i, factor := INT#5);
         ^^^^^                       Function return "DINT", scale
    i := counter.area();
                 ^^^^                Function return "DINT", Counter.area

The resolver uses the return type to annotate the call result. Generic lowering uses the callee and argument information to select an implementation. Aggregate-return lowering identifies calls that need result storage. Codegen uses call_name when present and qualified_name otherwise.

FunctionPointer

FunctionPointer {
    /// The return type of the referenced function
    return_type: String,

    /// The method or function block the pointer type names, such as "Base.area"
    qualified_name: String,
}

The kind for the operator of an indirect call: a dereferenced variable whose type is a pointer to a method or to a function block body. Method tables are built from such pointers, so almost every entry of this kind comes from the polymorphism lowerer. The example writes one by hand:

    i := fp^(counter);
         ^^^             FunctionPointer return "DINT", Base.area
         ^^              Variable "__main_fp", main.fp

Codegen generates an indirect call through the loaded pointer and takes the parameter list from the declaration qualified_name names. The aggregate-return lowerer treats it like Function.

Type

Type {
    /// The name of the type
    type_name: String,
}

The kind for a reference that names a type: the left side of a cast INT#5 or Color#Red, a function block type used as a qualifier, a data type in an expression position.

    i := scale(i, factor := INT#5);
                            ^^^      Type "INT"
    hue := Color#Red;
           ^^^^^                     Type "Color"

The resolver reads it to type the right side of the cast. The validator reads it to check a literal against the type it is cast to: a value that does not fit the type or a literal kind the type cannot take (E053, E054, E061).

Program

Program {
    /// The program, class, or action
    qualified_name: String,
}

The kind for a reference to a program, and also for a reference to a class or to an action, whatever POU the action belongs to. A program has exactly one instance, so the name is enough to find its memory.

    logger(text := 'x');
    ^^^^^^                Program logger

Codegen loads the global instance of the program by qualified_name and passes its address to the call. The validator uses it to report an action referenced without parentheses (E095).

Argument

Argument {
    /// The declared type of the parameter
    resulting_type: String,

    /// The position of the parameter among the members of the POU that declares it
    position: usize,

    /// How many EXTENDS steps lie between the called block and the block that declares the parameter
    depth: usize,

    /// The block that declares the parameter, which may be a base of the called block
    pou: String,
}

Argument is used only as a hint. It connects each argument to a parameter. A positional argument carries the hint on its expression; a named argument carries it on the assignment node.

    counter(limit := 3, step := 2, count => i);
            ^^^^^^^^^^                 hint Argument "INT",  position 1, depth 1, pou Base
                        ^^^^^^^^^      hint Argument "DINT", position 1, depth 0, pou Counter
                                   ^^^^^^^^^^  hint Argument "DINT", position 2, depth 0, pou Counter
    i := scale(i, factor := INT#5);
               ^                       hint Argument "DINT", position 0, depth 0, pou scale
                  ^^^^^^^^^^^^^^^      hint Argument "INT",  position 1, depth 0, pou scale

limit is declared in Base, one EXTENDS step above Counter, so the hint says Base at depth 1, not Counter at depth 0. The position counts members, not parameters, inside the block that declares the parameter. limit is the first input of Base but sits at position 1, because the __vtable member takes position 0. step and count sit behind the __Base member of Counter for the same reason. A function gets neither member, so the parameters of scale start at position 0.

Codegen uses pou and position to find the member of the instance struct that receives the value, and depth to walk through the embedded base parts first. The aggregate-return lowerer reads pou and position to rewrite an output argument. The type is the hint for the conversion of the argument value, like any other hint.

Property

Property {
    /// The accessor to call: "__get_<name>" or "__set_<name>"
    name: String,
}

The kind for a reference to a property, before it is lowered. The property lowerer has already turned the accessors into methods when the resolver runs. The resolver recognizes the member as a property, decides from the position of the reference whether the getter or the setter is meant, and stores the name of that accessor.

    i := counter.scaled;
                 ^^^^^^    Property "__get_scaled"

Only the property lowerer reads it: at post_annotate it replaces every such reference by a call to the named accessor and annotates again. A Property entry that survives to codegen is an error; codegen has no case for it.

ReplacementAst

ReplacementAst {
    /// The statement to generate instead of the annotated one
    statement: AstNode,
}

ReplacementAst attaches a replacement expression without changing the original node. String equality becomes a STRING_EQUAL call. Other string comparisons combine _EQUAL, _LESS, and _GREATER calls with NOT and OR. The replacement has its own annotations.

    same := text = 'hello';
            ^^^^^^^^^^^^^^    ReplacementAst STRING_EQUAL(text, 'hello'),  hint "BOOL"
            ^^^^              Variable "STRING", main.text,  hint Argument "STRING", position 0, pou STRING_EQUAL
                   ^^^^^^^    Value "__STRING_5",            hint Argument "STRING", position 1, pou STRING_EQUAL

Codegen checks every expression for this kind first and generates the replacement instead of the node. The type of the node is the type of its replacement; see Deriving a type.

Label

Label {
    /// The label the jump targets
    name: String,
}

The kind for a jump statement. Structured Text has no spelling for jumps and labels; they come from the CFC participant, which renders a jump element into a jump statement and a label element into a label statement. The resolver collects every jump per POU and, once the labels of the POU are known, annotates each jump with the label it targets. Codegen looks the basic block of the label up by name and emits the branch.

MethodDeclarations and Override

MethodDeclarations {
    /// Method name to the declarations of it that reach the block
    declarations: FxHashMap<String, Vec<MethodDeclarationType>>,
}

Override {
    /// Every method this method overrides, in the bases and interfaces
    definitions: Vec<MethodDeclarationType>,
}

These two kinds do not sit on expressions. MethodDeclarations is stored under the ID of a function block, class, or interface declaration. For every method name it lists the declarations that reach the block: one Concrete entry for the declaration nearest the block in its EXTENDS chain, and one Abstract entry for each interface that declares the same method. A declaration that an entry further down the chain overrides is not listed. Override is stored under the ID of a method declaration and lists the methods it overrides.

FUNCTION_BLOCK Base           MethodDeclarations { area: [Concrete Base.area] }
FUNCTION_BLOCK Counter        MethodDeclarations { area: [Concrete Counter.area], __get_scaled: [Concrete Counter.__get_scaled] }
    METHOD area               Override { [Concrete Base.area] }

Only the validator reads them: the first for the abstract-signature and implemented-methods checks (E111, E112), the second for the override checks (E112, E118).

Note

Developer note. Two variants of the annotation enum are never produced. Super was meant for the SUPER keyword, but the inheritance lowerer rewrites SUPER^ before it needs a type of its own, and the resolver leaves the keyword node unannotated. None is the default value and is not stored. The wrapper the driver puts around the map, which answers one reserved node ID with a BOOL value, is not read by any stage either.

Deriving a type

Consumers use get_type for the annotation’s type and get_type_hint for its hint. Both resolve a stored name through the index.

Value, Variable, and Argument use resulting_type; Type uses type_name. POU annotations use qualified_name to find the instance type. ReplacementAst uses the replacement’s hint or annotation. Label, Override, MethodDeclarations, and Property have no type.

Codegen compares the annotated type with the expected type from the hint. The annotation describes the value; the hint determines any conversion needed at its use. The Codegen chapter shows the resulting instructions.

At a glance

KindProduced forFieldsRead by
Valueliterals, expressions, casts, call results, element accessestypeeveryone, through get_type
Variablereferences to declared variables, members, globals, enum variants, return variablestype, qualified name, constant, argument type, auto-derefcodegen (address, constant folding, deref load), validator (E036, E049, E098)
Functioncall operators and references naming a function or methodreturn type, qualified name, generic name, call nameresolver, generic lowerer, aggregate-return lowerer, codegen
FunctionPointerdereferenced pointer-to-method operatorsreturn type, qualified nameaggregate-return lowerer, codegen (indirect call)
Typethe type side of a cast, a type used as qualifiertype nameresolver, validator (literal casts)
Programreferences to programs, classes, actionsqualified namecodegen (instance address), validator (E095)
Argument (hint only)every call argumenttype, position, depth, declaring POUcodegen (parameter slot), aggregate-return lowerer, conversion like any hint
Propertyproperty references before loweringaccessor nameproperty lowerer
ReplacementAststring and other library-compared comparisonsthe replacement statementcodegen
Labeljump statements from CFClabel namecodegen
MethodDeclarationsblock, class, and interface declarations (by declaration ID)method name to declarationsvalidator (E111, E112)
Overridemethod declarations that override (by declaration ID)overridden methodsvalidator (E112, E118)