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

iecf — the IEC Forge

iecf is a compiler for IEC 61131-3, the language standard of programmable logic controllers. It reads Structured Text and SFC, checks them against the standard as written, and writes portable C99 that any C compiler can build for any controller, with a symbol table beside it. It is written in Rust, runs on the command line and, compiled to WebAssembly, in a browser. It is the compiler behind Prokop.

This book is for three readers:

  • who writes PLC programs: Getting started, and the Behaviour reference — what each operation gives, its edge cases included: an overflow, a division by zero, a conversion that does not fit, a string too long, a date past its range;
  • who puts iecf’s C on a controller: The C output — the entry points, the process image, faults and flags, the symbol table, RETAIN;
  • who wants to know why: Implementer choices, where each choice the standard leaves open is made after checking what CODESYS, TwinCAT and Siemens do, and the design.

Where it stands

The checker covers the standard’s core: every declaration, type and ST statement, SFC, configurations, with each diagnostic explained under Diagnostics. The C generator covers programs over the integer, bit-string, real, time, date and string types and the derived ones, the standard functions and function blocks, the user’s functions and function blocks, and RETAIN. What it does not cover yet is reported as E0009.

Every example in the Behaviour reference is checked: CI builds it with iecf, runs it and compares the result and the flag with the page. The generated C is checked against matiec’s on the same programs (Testing against matiec), and against the programs of Prokop, iecf’s first user (The Prokop survey).

Standards

iecf follows IEC 61131-3 Ed.4 (2025) without IL, which Ed.4 removed; the clause and table numbers in this book are Ed.3’s (2013) unless they say otherwise. iecf is not affiliated with the IEC.

License

iecf is licensed under either of the Apache License, Version 2.0, or the MIT license, at your option.

Getting started

Building iecf

iecf is a Rust workspace with no dependencies beyond the standard library.

git clone https://github.com/raspihats/iecf
cd iecf
cargo build --release

The compiler is target/release/iecf.

Checking a program

iecf check reads IEC 61131-3 files — any number, a project’s POUs, types and configuration across them — and reports every diagnostic:

$ iecf check main.st
main.st:12:9: error[E0013]: Ed.3 Figure 11 defines no conversion from BYTE to BOOL

Each code has its explanation, also on the command line:

iecf explain E0013

--format json gives the diagnostics as JSON, for an editor.

Building C

iecf build writes the C for a project with one CONFIGURATION:

iecf build -o out *.st

out/ then holds <configuration>.c, <configuration>.h and iecf.h, the runtime header, and <configuration>.symbols.json, the symbol table for tools (The C output §6). The C is portable C99, no compiler extensions, no allocation. The C output says what the platform has to give it — a process image and a clock — and what it gives back.

A first program

PROGRAM main
VAR
    start AT %IX0.0 : BOOL;
    motor AT %QX0.0 : BOOL;
    run : TON;
END_VAR
run(IN := start, PT := T#2s);
motor := run.Q;
END_PROGRAM

CONFIGURATION plant
    RESOURCE cpu ON PLC
        TASK fast(INTERVAL := T#10ms, PRIORITY := 0);
        PROGRAM p WITH fast : main;
    END_RESOURCE
END_CONFIGURATION

Extensions

By default iecf checks the standard as written. A few things vendors accept that the standard does not — a global used without VAR_EXTERNAL, TIME converted to an integer — are extensions, each switched on by name:

iecf check --extension implicit-externals *.st

In a browser

crates/iecf-wasm builds the checker as a WebAssembly module of about 95 KB gzipped, and iecf.js wraps it:

import { load } from "./iecf.js";
const iecf = await load(fetch("iecf.wasm"));
iecf.check([{ name: "main.st", text }]);
// -> [{ code, severity, message, file, line, column, ... }]

The diagnostics are those iecf check --format json gives.

Implementer extensions

IEC 61131-3 Ed.3 clause 5 c asks an implementation for “a document that specifies all Implementer specific extensions. These are any features accepted by the system that are prohibited or not specified.” This is that document for iecf.

By default iecf accepts none: it checks the standard as written. Each extension is switched on by name, for one run:

iecf check --extension implicit-externals FILE...

A program that needs an extension is not portable to another compliant tool as it stands; the section of each extension says what to change to make it so.

implicit-externals: a POU uses a global variable without declaring it VAR_EXTERNAL

The standard. Ed.3 6.5.2.2: global variables are “only accessible to a program organization unit via a VAR_EXTERNAL declaration”, with the global’s type, and CONSTANT if the global is. Without the extension, a global used undeclared is E0010.

The extension. A name a POU does not declare, which a VAR_GLOBAL of the configuration, of a resource or of a global variable list does, is that global, as if the POU had declared it VAR_EXTERNAL (VAR_EXTERNAL CONSTANT for a constant global). The POU’s own declarations come first: a local of the same name hides the global. Everything else about the global is checked as usual: its type, and that a constant is not assigned to. It is compiled as the VAR_EXTERNAL would be, in any PROGRAM, FUNCTION_BLOCK or FUNCTION; a FUNCTION that writes a global so has effects, and its calls keep the standard’s order of evaluation (dependencies).

Why. Some tools and editors keep a project’s globals in one list and let every POU use them. A platform that wants that may switch this on, or have its editor write the VAR_EXTERNAL declarations where the user sees them; iecf supports both and decides neither.

To make a program portable. Add to each POU a VAR_EXTERNAL block naming every global it uses, with the global’s type.

var-config-list: VAR_CONFIG outside a CONFIGURATION, paths from the PROGRAM’s name

The standard. Ed.3 6.8.2 and Annex A (Config_Decl): VAR_CONFIG is part of a CONFIGURATION, after its resources; a path starts with the resource (if the configuration names one), then the program instance. Without the extension, a VAR_CONFIG at the top of a file is E0025.

The extension. As CODESYS writes it: VAR_CONFIG ... END_VAR at the top of any file, in as many blocks as wanted. Each PROGRAM is its own one instance, under its own name, and a path starts there:

VAR_CONFIG
  PLC_PRG.valve1.coil AT %QX0.0 : BOOL;
END_VAR

Everything else is checked as for the standard form: the path, the repeated type, the address’s area, the initial values, and every * address of every PROGRAM completed (E0023). A project that has a CONFIGURATION keeps its VAR_CONFIG there; a list of its own is then still E0025.

Why. CODESYS projects, and those of the systems built on CODESYS, complete their AT %I* addresses this way: there is no textual CONFIGURATION, tasks call PROGRAMs by name. (Beckhoff TwinCAT has no VAR_CONFIG at all; it links %I* variables in its I/O configuration, outside the source text, which iecf does not read: PROPOSAL.md §2b.)

To make a program portable. Write a CONFIGURATION with a resource, its tasks and a program instance per PROGRAM, and move the entries into its VAR_CONFIG, each path prefixed with the resource’s name.

time-integer-conversions: TIME and LTIME to and from integers, in ms and ns

The standard. Ed.3 Figure 11 defines no conversion between TIME or LTIME and an integer or bit-string type (“-”), and Ed.2 named none. Without the extension, DINT_TO_TIME, TIME_TO_UDINT, TO_TIME(i) and the like are E0013. A duration is made from a number with TIME arithmetic: T#1ms * n.

The extension. <int>_TO_TIME, TIME_TO_<int>, the same for LTIME, the overloaded TO_TIME/TO_LTIME from an integer, and the same with BYTE, WORD, DWORD and LWORD. A TIME converts as its count of milliseconds, an LTIME as its count of nanoseconds: DINT_TO_TIME(1500) is T#1s500ms, TIME_TO_DINT(T#2s) is 2000. A value that does not fit the target keeps its low bits and is flagged, as any integer conversion (docs/c-output.md §5).

Why. CODESYS, Beckhoff TwinCAT and Siemens all have these conversions, counting milliseconds; programs written for them use them. (matiec has them too, counting seconds: a program carried over from matiec waits a thousand times less here.)

To make a program portable. Replace DINT_TO_TIME(n) with T#1ms * n (Table 35, MUL). The standard has no way back from a duration to a number (Table 35 divides a TIME only by a number), so keep a duration in a TIME throughout and compare it with TIME literals.

bit-string-to-bool: BYTE, WORD, DWORD and LWORD to BOOL: TRUE for any value but 0

The standard. Ed.3 contradicts itself. Figure 11 defines no conversion from a bit string to BOOL (“-”); Table 25 lists BYTE_TO_BOOL, WORD_TO_BOOL, DWORD_TO_BOOL and LWORD_TO_BOOL as “Binary transfer of the rightmost bit into the target”. iecf follows Figure 11: without the extension they, and TO_BOOL of a bit string, are E0013.

The extension. They convert, and the result is TRUE for any value but 0: BYTE_TO_BOOL(BYTE#2) is TRUE, where Table 25’s rightmost bit would make it FALSE. Integers to BOOL stay refused.

Why. CODESYS and Beckhoff TwinCAT have these conversions and give TRUE for any value but 0; programs written for them use them (decided, florin, 2026-11-08: PROPOSAL.md §6.8).

To make a program portable. Write what is meant: b <> 0 for any bit set, b.0 (Ed.3 6.5.3, bit access) for the rightmost bit.

reference-to: CODESYS’s REFERENCE TO, bound with REF=, tested with __ISVALIDREF

The standard. Ed.3 6.4.4.10 has one kind of reference, REF_TO, taken with REF() and dereferenced with ^. REFERENCE TO, REF= and __ISVALIDREF are not IEC 61131-3. Without the extension the type is E0011 and REF= is E0005.

The extension. As CODESYS and Beckhoff TwinCAT have it:

  • r : REFERENCE TO T is a reference that stands for what it references wherever it is used: r := 5, x := r + 1, r.f, r[i] and r(...) for a function block go through it, with no ^.
  • r REF= x binds it to x, which REF(x) could take (E0026); r REF= r2 binds it to what the REFERENCE TO r2 references; r REF= 0 unbinds it. It starts unbound; it takes no initial value (E0013).
  • __ISVALIDREF(r) is TRUE when r is bound.
  • A function block’s VAR_INPUT of a REFERENCE TO is bound by a call, fb(r := x): by its argument, as REF= would. A call that does not pass it leaves it bound as it was, as an IEC input keeps its value.
  • No vendor page found says what a call that does not pass such an input does, nor shows r.f, r[i] or r(...) through a REFERENCE TO; both are as above, inferred from the rest (decided, florin, 2026-11-09).
  • An unbound REFERENCE TO used is a fault, IECF_FAULT_NULL, as a NULL REF_TO dereferenced is (dependencies, References).
  • Where a REF_TO cannot be, a REFERENCE TO cannot either: not a VAR_IN_OUT, not RETAIN, not at an address. Nor, as in CODESYS and TwinCAT, an array of REFERENCE TO, a REF_TO a REFERENCE TO, or a REFERENCE TO a reference (E0027).

In the C, a REFERENCE TO is the same iecf_ref_t as a REF_TO (The C output §4), and its uses are generated as the REF_TO ones they stand for: r as r^, r REF= x as r := REF(x). The symbol table names its type REFERENCE TO T.

Why. Programs written for CODESYS and TwinCAT use REFERENCE TO for what Ed.3 does with REF_TO (decided, florin: PROPOSAL.md §5 step 3).

To make a program portable. Declare r : REF_TO T, write r := REF(x) for r REF= x, r := NULL for r REF= 0, r <> NULL for __ISVALIDREF(r), and r^ wherever r is used. A function block’s REFERENCE TO input becomes a REF_TO input passed REF(x).

global-namespace: _. names the global namespace, as Siemens and PLCnext write it

The standard. Ed.3 6.9.1 puts every element declared outside a NAMESPACE, and every standard function and function block, in the global namespace, but gives no way to name it. Its one example, Ton2: PUBLIC.TON; (* uses the standard timer *) (6.9.3), cannot be read: PUBLIC is an access specifier, and the grammar has no such name. So a namespace that declares its own TON cannot reach the standard one. Without the extension, _. is E0028.

The extension. As Siemens TIA Portal and PLCnext Engineer write it: _. before a name looks it up in the global namespace only, past every namespace the code is in and every USING (C#’s global::).

  • _.TON is the standard timer, from inside a namespace with its own TON;
  • _.Plant.Main is the top-level namespace Plant’s Main, even inside a namespace that has a Plant of its own;
  • _.g, a global variable, is g.

Why. Example 2 of Ed.3 6.9.2 declares a namespace’s own TON, “an implementation of TON with a new name”; inside that namespace the standard TON is then out of reach (NAMESPACES.md, D4).

To make a program portable. Give the namespace’s own element another name, so the standard one is reached by its short name.

Diagnostics

Every diagnostic iecf reports has a code: E for an error, which stops a build, W for a warning, which does not. iecf explain CODE prints the code’s page; each is here too.

E0009 is not a fault in the program: it marks something the checker accepts and the C generator does not cover yet.

CodeWhat
E0001a character that has no place in IEC 61131-3 text
E0002a comment that is never closed
E0003a string literal that is never closed
E0004a literal that is not well formed
E0005the text does not fit the grammar here
E0006a block whose END keyword never comes
E0007a $ escape that a string literal does not have
E0008Instruction List, removed from IEC 61131-3 in Ed.4
E0009a part of IEC 61131-3 that iecf does not compile yet
E0010a name that nothing declares
E0011a type that nothing declares
E0012a name declared twice
E0013a value whose type does not convert implicitly
E0014operands an operator or index does not take
E0015an assignment to what cannot be assigned
E0016a call that does not fit what it calls
E0017a VAR_EXTERNAL that matches no VAR_GLOBAL
E0018a name the standard reserves for a type, function or block
E0019a condition that is not BOOL
E0020a literal outside its type’s range
E0021EXIT or CONTINUE outside a loop
E0022an address where the standard does not allow one
E0023a * address that VAR_CONFIG does not complete
E0024a VAR_CONFIG entry that does not fit the variable it names
E0025a VAR_CONFIG outside a CONFIGURATION
E0026REF() of what cannot be referenced
E0027a reference where it cannot be
E0028a name a namespace does not have, or does not let this code see
E0029a short name that two USINGs both bring in
W0001an octal literal, removed from IEC 61131-3 in Ed.4
W0002a direct address in a body, deprecated since Ed.3
W0003TRUNC without a type, deprecated since Ed.3
W0004an address whose size prefix is not its variable’s size
W0005REF() of a variable at an input address

E0001: a character that has no place in IEC 61131-3 text

Outside comments, pragmas and string literals, IEC 61131-3 text is made of identifiers, keywords, literals, operators and delimiters (Ed.3 6.1). A character that starts none of them, such as @, ? or !, is an error.

x := a @ b;      (* E0001: '@' *)
x := a + b;      (* fixed *)

Characters other than ASCII are allowed inside comments and string literals only.

E0002: a comment that is never closed

A comment opened with (* or /* runs until its matching *) or */. Comments nest (Ed.3 Table 3, features 3a and 3b), so every (* inside a comment needs its own *).

(* counts the boxes (* every scan *)
x := x + 1;       (* still inside the first comment: E0002 at its start *)

iecf reports the comment once, at its start, and stops reading the file there rather than guessing where it was meant to end.

E0003: a string literal that is never closed

A STRING literal is written between single quotes, a WSTRING literal between double quotes (Ed.3 6.3.3), and both end on the line they start. A quote inside the text is written $' or $".

msg := 'it's done';     (* E0003: the literal ends after "it" *)
msg := 'it$'s done';    (* fixed *)

E0004: a literal that is not well formed

Numeric, duration, date and direct-address literals have fixed shapes (Ed.3 6.3, Tables 5, 8 and 9, and 6.5.5 for % addresses). Examples that are refused:

x := 3#12;         (* bases are 2, 8 and 16 *)
x := 16#;          (* no digits *)
t := T#;           (* no duration *)
a AT %IY0 : BOOL;  (* areas are I, Q and M; sizes X, B, W, D and L *)

E0005: the text does not fit the grammar here

The message says what was expected and what was found. iecf then skips to the end of the statement, declaration or block it was reading and carries on, so one mistake gives one error, and the rest of the file is still checked.

IF x > 0
    y := 1;        (* E0005: expected THEN *)
END_IF;

Every statement ends with ;, including END_IF, END_CASE, END_FOR, END_WHILE and END_REPEAT (Ed.3 Annex A, Stmt_List).

E0006: a block whose END keyword never comes

PROGRAM ends with END_PROGRAM, VAR with END_VAR, IF with END_IF, and so on. The file ended, or another block’s end came first, before this one was closed. The error points at the block’s start.

PROGRAM main
VAR x : INT; END_VAR
x := x + 1;
(* E0006: PROGRAM main is not closed, END_PROGRAM missing *)

E0007: a $ escape that a string literal does not have

In a string literal, $ starts an escape (Ed.3 Table 7): $$, $', $", $L or $N (line feed), $P (form feed), $R (carriage return), $T (tab), two hex digits in a STRING or four in a WSTRING, or a character code in braces, ${1F579} (Ed.4).

s := 'cost: 5$';      (* E0007: '$' at the end *)
s := 'cost: 5$$';     (* fixed: a dollar sign *)

E0008: Instruction List, removed from IEC 61131-3 in Ed.4

Ed.3 (2013) deprecated Instruction List (Annex B) and Ed.4 (2025) removed it. iecf compiles the standard as it is now and has no IL. Rewrite the POU in Structured Text: each IL accumulator sequence is one ST expression.

LD   a          (* IL *)
AND  b
ST   q

q := a AND b;   (* ST *)

E0009: a part of IEC 61131-3 that iecf does not compile yet

iecf is built in order: the Ed.2 core first, then Ed.3’s additions (references, namespaces, classes, methods, inheritance and interfaces are in, OO.md), then Ed.4’s (see PROPOSAL.md §5). The message names the construct. iecf skips it to its END keyword and checks the rest of the file.

iecf build reports E0009 too, where the C generator does not cover a construct yet: it is built in slices (PROPOSAL.md §5 step 2), and iecf check may pass a program that iecf build cannot compile yet. The generator never writes C for what it does not cover.

This is a gap in iecf, not a mistake in the program.

E0010: a name that nothing declares

Every variable a POU uses is declared in one of its VAR blocks (Ed.3 6.5). A global variable is seen in a POU only through a VAR_EXTERNAL declaration of it (Ed.3 6.5.2.2); a step’s flags are step.X and step.T; an enumerated value is its name or Type#Value.

PROGRAM p
VAR x : INT; END_VAR
y := x;            (* E0010: y is not declared *)
END_PROGRAM

A member that a type does not have, such as timer.QQ on a TON, and the internal VAR of a function block read from outside it, are E0010 too; so is a name in an instance’s initial value that is not one of its inputs, outputs or PUBLIC variables (Ed.3 6.6.3.3): c : Count := (limit := 5) is E0010 when limit is in the block’s plain VAR.

E0011: a type that nothing declares

A type is elementary (INT, TIME, … Ed.3 Table 10), a standard function block (TON, CTU, …), or declared in a TYPE block or as a FUNCTION_BLOCK in the project.

VAR t : TIMER; END_VAR     (* E0011: no type TIMER - TON? *)

A PROGRAM or FUNCTION name is not a type either.

E0012: a name declared twice

Within a POU, each variable name is declared once; across the project, each POU and type name, and each global variable, once. IEC compares names ignoring case (Ed.3 6.1.2), so Count and COUNT are the same name.

VAR
    count : INT;
    Count : DINT;     (* E0012 *)
END_VAR

E0013: a value whose type does not convert implicitly

A value converts to another type implicitly only where no value or precision can be lost (Ed.3 6.6.1.6 and Figure 11): INT to DINT or REAL, BYTE to WORD, TIME to LTIME. Anything else takes a conversion function, which the message names when there is one.

VAR i : INT; d : DINT; r : REAL; END_VAR
d := i;              (* fine: INT widens to DINT *)
i := d;              (* E0013: use DINT_TO_INT *)
r := d;              (* E0013: use DINT_TO_REAL; a DINT can lose digits in a REAL *)

Ed.2 had no implicit conversions; matiec, which follows Ed.2, refuses d := i too.

E0014: operands an operator or index does not take

Arithmetic takes numbers (ANY_NUM), MOD integers, AND, OR, XOR and NOT booleans or bit strings (ANY_BIT), and both sides of an operator must meet in one type by implicit conversion (Ed.3 6.6.2.5). An array index is an integer; x.3 takes a bit string; r^ takes a reference. A comparison takes elementary values (Ed.3 Table 33, ANY_ELEMENTARY): two arrays, two structures or two function block instances are not compared, their elements, fields or variables are.

VAR b : BOOL; i : INT; d : DINT; r : REAL; END_VAR
i := i + b;        (* E0014: INT and BOOL *)
r := r + d;        (* E0014: DINT does not widen to REAL; DINT_TO_REAL *)
i := i AND 1;      (* E0014: AND takes BOOL or bit strings, not INT *)

E0015: an assignment to what cannot be assigned

The left of :=, the target of =>, an in-out argument and a FOR loop’s variable must be variables that the POU may write: not a CONSTANT, not a literal or a call, not a function block’s output from outside the block, not an input address.

VAR CONSTANT LIMIT : INT := 10; END_VAR
VAR t : TON; END_VAR
LIMIT := 5;        (* E0015: a CONSTANT *)
t.Q := TRUE;       (* E0015: only TON writes its Q *)

E0016: a call that does not fit what it calls

A function is called with its inputs either all by name or all in order (Ed.3 6.6.1.4); a function block is called through an instance, not by its type name; := passes an input or in-out, => takes an output; every name must be one of the callee’s parameters. Every in-out is given a variable (Ed.3 6.6.1.4 rule 2): at each call of a function, and in a resource’s connections for a program, which bind it to a global.

VAR t : TON; END_VAR
TON(IN := x, PT := T#1s);     (* E0016: call an instance: t(...) *)
t(IN := x, Q := y);           (* E0016: Q is an output: Q => y *)
PROGRAM p WITH fast : counter;  (* E0016: counter's VAR_IN_OUT n is not
                                   connected: counter(n := g) *)

A program’s connection names what Ed.3 6.8.2’s grammar lets it (Prog_Data_Source, Data_Sink, Global_Var_Access): an input a constant, an enumerated value, a global variable or one field of one, or an address; an output a global variable, one field of one, or an address. An array’s element, an expression or a field of a field is none of them:

PROGRAM p WITH fast : counter(
    n := g,                     (* fine *)
    step := gp.x,               (* fine: one field of a global *)
    limit := ga[2],             (* E0016: an element *)
    start := g + 1);            (* E0016: an expression *)

E0017: a VAR_EXTERNAL that matches no VAR_GLOBAL

VAR_EXTERNAL names a global variable declared in a VAR_GLOBAL block of the configuration, a resource, or a global variable list, with the same type (Ed.3 6.5.2.2). A global declared CONSTANT must be VAR_EXTERNAL CONSTANT where it is used (Ed.2 Table E.1).

VAR_GLOBAL counter : INT; END_VAR
PROGRAM p
VAR_EXTERNAL count : INT; END_VAR    (* E0017: no global named count *)
END_PROGRAM

E0018: a name the standard reserves for a type, function or block

Data type names (DT, TOD, INT), standard function names (LT, ADD, INT_TO_REAL) and standard function block names (TON, CTU) are keywords: they “shall not be used for any other purpose, for example, variable names” (Ed.2 2.1.3 and Table C.2; Ed.3 6.1.3).

VAR dt : TIME; END_VAR       (* E0018: DT is a data type *)
VAR cycle_dt : TIME; END_VAR (* fixed *)

E0019: a condition that is not BOOL

IF, ELSIF, WHILE, UNTIL and an SFC transition take a BOOL condition. There is no implicit test against zero.

VAR n : INT; END_VAR
IF n THEN ...          (* E0019 *)
IF n <> 0 THEN ...     (* fixed *)

E0020: a literal outside its type’s range

An integer literal takes the type of what it meets, and must fit it (Ed.3 Table 10 gives each type’s range).

VAR s : SINT; b : BYTE; END_VAR
s := 200;        (* E0020: SINT is -128..127 *)
b := -1;         (* E0020: BYTE is 0..255 *)
s := INT#5;      (* E0013, not E0020: INT does not narrow to SINT *)

A STRING holds one byte per character, Latin-1 (docs/dependencies.md), so a STRING literal with a character above U+00FF is refused: write it as a WSTRING, "...". An initial value longer than its STRING[n] or WSTRING[n] is refused too. A WCHAR is one UTF-16 unit, so WCHAR#"X" holds one character up to U+FFFF; ${D800}, half of a surrogate pair, is no character, in a WSTRING either; and a single-byte literal is written in single quotes, so STRING#"a" is refused.

VAR s : STRING[3] := 'abcd'; END_VAR   (* E0020: 4 characters in STRING[3] *)
t := '€';                              (* E0020: not in Latin-1 *)
w := WCHAR#"${1F600}";                 (* E0020: two UTF-16 units *)

A DATE, TOD or DT literal must name a day and a time of day that exist, and that iecf holds (docs/dependencies.md): a DATE or DT from 1970-01-01 to 2106-02-07-06:28:15, a TOD in whole milliseconds, a DT in whole seconds.

d := D#2023-02-29;               (* E0020: 2023 is no leap year *)
d := D#1969-12-31;               (* E0020: before 1970 *)
t := TOD#12:00:00.0005;          (* E0020: TOD counts milliseconds *)
x := DT#2026-11-07-12:34:56.5;   (* E0020: DT counts seconds *)

An LDATE, LTOD or LDT literal counts nanoseconds, up to nine digits of a second; an LDT holds 1677-09-21-00:12:43.145224192 to 2262-04-11-23:47:16.854775807, an LDATE the midnights between.

l := LD#1677-09-21;                       (* E0020: no midnight an LDATE holds *)
l := LTOD#12:00:00.0000000001;            (* E0020: finer than a nanosecond *)

A constant expression in an initial value is worked out by the compiler (Initial values), and its value must fit the variable as a literal’s must:

VAR CONSTANT K : INT := 300; KR : REAL := 3.0E38; END_VAR
VAR
    x : INT := K * 200;          (* E0020: 60000, which INT does not hold *)
    r : REAL := KR * 10.0;       (* E0020: infinite in a REAL *)
    s : SINT := INT_TO_SINT(K);  (* E0020: 300, which SINT does not hold *)
    q : REAL := SQRT(-1.0);      (* E0020: no number *)
END_VAR

E0021: EXIT or CONTINUE outside a loop

EXIT leaves, and CONTINUE restarts, the innermost FOR, WHILE or REPEAT (Ed.3 7.3.3.4); outside one there is nothing for them to act on. To leave a POU, use RETURN.

E0022: an address where the standard does not allow one

A FUNCTION has no located variables: it keeps nothing between calls (Ed.2 Table E.1).

AT %I*, AT %Q* and AT %M* leave the address for the configuration to give in VAR_CONFIG (Ed.3 6.5.5.4). VAR_CONFIG reaches a variable by its name, through the program instance and the function block instances it sits in, so the * is allowed only where that path exists:

  • in VAR of a PROGRAM or FUNCTION_BLOCK, under a name (Ed.3 Annex A, Loc_Partly_Var_Decl; CODESYS documents the same);
  • in a field of a structure type (Ed.3 6.4.4.8), when the structure is declared there.

Not in VAR_OUTPUT, VAR_INPUT, VAR_IN_OUT, VAR_TEMP or VAR_EXTERNAL, not in a global (a global has one instance: write its address in full), not inside an array (a path names no element), and not in a body: a body uses the variable’s name.

Inside a PROGRAM or FUNCTION_BLOCK, one VAR block holds one kind of declaration: plain variables, variables located in full, or * addresses. The grammar gives each its own block (Ed.3 Annex A Var_Decls, Loc_Var_Decls, Loc_Partly_Var_Decl; Ed.2 B.1.4.3 the same), and matiec refuses a mix too. CODESYS accepts one; split it into two blocks and every compiler reads it:

PROGRAM main
VAR
    count : INT;
    lamp AT %QX0.0 : BOOL;   (* E0022: a located variable among plain ones *)
END_VAR
END_PROGRAM

The * takes the place of the size and the numbers both: %I*, not %IX* (that is E0004).

FUNCTION_BLOCK valve
VAR_OUTPUT open AT %I* : BOOL; END_VAR   (* E0022: not in VAR_OUTPUT *)
VAR       coil AT %Q* : BOOL; END_VAR    (* fine *)
END_FUNCTION_BLOCK

Only a BOOL starts at a bit; an array, structure or string, and any wider variable, starts at a byte: its address is where it starts, and it takes its own type’s size from there (Ed.3 6.5.5.3; W0004 when the size prefix is not that size).

n AT %MX0.3 : INT;    (* E0022 *)
n AT %MW0 : INT;      (* fixed *)

A function block instance has no address (an interface there is E0031): AT takes an elementary type, an array, a structure or a string (Ed.3 6.5.5, Loc_Var_Spec_Init).

t AT %MW0 : TON;      (* E0022 *)

E0023: a * address that VAR_CONFIG does not complete

Every variable declared AT %I*, %Q* or %M* gets its address in the configuration’s VAR_CONFIG, once for every instance of the program or function block that declares it; one missing is an error (Ed.3 6.5.5.4).

The path starts with the resource (when the configuration names its resources), then the program instance, the function block instances, and the variable:

PROGRAM line
VAR pump : valve; END_VAR
END_PROGRAM

CONFIGURATION plant
  RESOURCE cpu ON PLC
    TASK fast (INTERVAL := T#10ms, PRIORITY := 1);
    PROGRAM main WITH fast : line;     (* E0023: cpu.main.pump.coil *)
  END_RESOURCE
  VAR_CONFIG
    (* the fix: *)
    (* cpu.main.pump.coil AT %QX0.0 : BOOL; *)
  END_VAR
END_CONFIGURATION

The error points at the program instance whose variable has no address.

E0024: a VAR_CONFIG entry that does not fit the variable it names

A VAR_CONFIG entry names a variable inside a program instance and gives it an address, an initial value, or both (Ed.3 6.8.2):

  • the path names a variable, not just a program instance;
  • an address goes only to a variable declared AT %I*, %Q* or %M*, is in the same area (%Q* gets a %Q address), and is complete;
  • an initial value does not go to a VAR_TEMP, VAR_EXTERNAL, VAR_IN_OUT or CONSTANT variable.

The entry repeats the variable’s type; a different type is E0013.

VAR_CONFIG
  cpu.main.pump.coil AT %IX0.0 : BOOL;   (* E0024: coil is AT %Q* *)
  cpu.main.count AT %MW4 : INT;          (* E0024: count has no '*' address *)
  cpu.main.count : INT := 100;           (* fine: an initial value *)
END_VAR

E0025: a VAR_CONFIG outside a CONFIGURATION

The standard puts VAR_CONFIG inside the CONFIGURATION, after its resources (Ed.3 6.8.2, Annex A Config_Decl), and its paths start with the resource and the program instance.

CODESYS writes it instead in a list of its own, at the top of a file, with paths starting at the PROGRAM’s name. iecf reads that form only with --extension var-config-list (docs/extensions.md), and only in a project without a CONFIGURATION: with one, VAR_CONFIG goes inside it.

VAR_CONFIG                                   (* E0025 without the extension *)
  PLC_PRG.valve1.coil AT %QX0.0 : BOOL;
END_VAR

The fix, by the standard:

CONFIGURATION plant
  RESOURCE cpu ON PLC
    TASK main_task (INTERVAL := T#10ms, PRIORITY := 1);
    PROGRAM PLC_PRG WITH main_task : PLC_PRG;
  END_RESOURCE
  VAR_CONFIG
    cpu.PLC_PRG.valve1.coil AT %QX0.0 : BOOL;
  END_VAR
END_CONFIGURATION

E0026: REF() of what cannot be referenced

REF(x) gives a reference to the variable x (Ed.3 6.4.4.10.3), which stays valid as long as x exists and can be written through with ^. So x is a variable, or an element or field of one, that lives as long as the program and could be assigned:

  • not a temporary: a VAR_TEMP, or any variable of a FUNCTION (its inputs, outputs, locals and result) but a VAR_EXTERNAL. Ed.3: “Applying the REF() operator to a temporary variable … is not permitted”; it is gone when the POU returns;
  • not EN or ENO (Ed.3 6.6.1.5, rule 8);
  • not a CONSTANT, and not a function block’s output taken from outside (REF(timer.Q)): writing through the reference would change what the checker keeps from being assigned (docs/dependencies.md);
  • not a single bit of a bit string (REF(w.3)), a direct address (REF(%MW4): declare a variable AT %MW4), a literal or a call’s result.
FUNCTION_BLOCK fb
VAR_TEMP t : INT; END_VAR
VAR n : INT; r : REF_TO INT; END_VAR
r := REF(t);    (* E0026: t is gone when fb returns *)
r := REF(n);    (* fine: n lives as long as the instance *)
END_FUNCTION_BLOCK

E0027: a reference where it cannot be

A reference (REF_TO, or REFERENCE TO with the reference-to extension) holds where a variable is, which means something only inside the running program:

  • VAR_IN_OUT: “References shall not be used as in-out variables” (Ed.3 6.4.4.10.3). Pass it as an input: the reference is copied, what it references is shared;
  • RETAIN or PERSISTENT: after a restart or a new build the variable it referenced may be elsewhere or gone. iecf refuses a retained reference, and a retained structure, array or function block instance that holds one (docs/dependencies.md): retain the variable it references instead, and take the reference again at start. A variable of an INTERFACE is a reference to an instance (Ed.3 6.6.6.5.1), and is refused the same way;
  • at an address (AT %MB0): the process image holds values the host and other devices read, and a reference is no value to them;
  • REFERENCE TO (extensions, reference-to): not an array of them, not a REF_TO one, and not a REFERENCE TO a reference, as in CODESYS and TwinCAT. Use REF_TO for those.
VAR RETAIN
    r : REF_TO INT;      (* E0027 *)
END_VAR
VAR RETAIN
    level : INT;         (* fine: retain the value, REF(level) at start *)
END_VAR

E0028: a name a namespace does not have, or does not let this code see

A name written with its namespaces in front, Standard.Timers.TON, or a short one a USING brings in, has to name an element the namespace has, and one this code may see (Ed.3 6.9, Figure 29):

  • not there: Standard.Timers.TOM, or USING Standard.Timer; for a namespace that does not exist;
  • INTERNAL: an element declared FUNCTION INTERNAL, or FUNCTION_BLOCK INTERNAL, is seen only inside its own namespace; the public elements of a NAMESPACE INTERNAL are seen only in the namespace one level above it;
  • a namespace where something else is meant: as a type (t : Standard.Timers;), as a function, or with a variable after it (Standard.x): a namespace holds types, functions, function blocks and programs, no variables (Ed.3 6.9.2; NAMESPACES.md D7);
  • USING of what is not a namespace: USING names namespaces (Ed.3 6.9.4);
  • _. for the global namespace without --extension global-namespace (extensions).
NAMESPACE Timers
    FUNCTION INTERNAL tick : DINT
        tick := 1;
    END_FUNCTION
END_NAMESPACE

PROGRAM main
VAR n : DINT; END_VAR
n := Timers.tick();      (* E0028: tick is INTERNAL to Timers *)
END_PROGRAM

Declare it without INTERNAL to call it from outside its namespace, or call it from inside.

E0029: a short name that two USINGs both bring in

USING A, B; brings in the elements of both namespaces by their short names. When both have one of the name a POU uses, which one is meant is not said, and iecf does not guess: name the one meant in full. As in C#, whose using directives Ed.3 6.9.4 follows, it is an error only where the name is used, and a namespace’s own element hides one a USING brings in (NAMESPACES.md D2).

NAMESPACE A FUNCTION f : INT f := 1; END_FUNCTION END_NAMESPACE
NAMESPACE B FUNCTION f : INT f := 2; END_FUNCTION END_NAMESPACE

FUNCTION_BLOCK user
USING A, B;
VAR n : INT; END_VAR
n := f();                (* E0029: A.f or B.f *)
n := A.f();              (* fine *)
END_FUNCTION_BLOCK

E0030: a method or variable its access specifier keeps from here

A method, or a variable of a CLASS’s or FUNCTION_BLOCK’s VAR section, is used where its access specifier does not let it be (Ed.3 6.6.5.9, 6.6.5.10):

  • PUBLIC: anywhere the type can be used.
  • PROTECTED: in the type itself and the types derived from it. A method or VAR section without a specifier is PROTECTED (Ed.3; OO.md D1), where CODESYS and TwinCAT make it PUBLIC.
  • PRIVATE: in the type itself only.
  • INTERNAL: in the type’s namespace.
CLASS Counter
  METHOD Count     (* PROTECTED: no specifier *)
  END_METHOD
END_CLASS
...
c.Count();         (* E0030 *)

Write METHOD PUBLIC Count to call it from outside; VAR PUBLIC for a variable read or written from outside, as c.limit.

E0031: a class, method, interface, THIS or specifier where Ed.3 does not allow it

Ed.3’s object orientation (6.6.5 to 6.6.7) puts each of its parts in one place; this one is somewhere else:

  • a CLASS declares only VAR and VAR_EXTERNAL sections; its methods have the inputs and outputs (6.6.5.2);
  • an access specifier stands on a CLASS’s or FUNCTION_BLOCK’s VAR section, not on its inputs, outputs or externals, nor on a method’s variables (6.6.5.10, 6.6.7.2.6);
  • EXTENDS of a type that cannot be the base: a CLASS extends a CLASS, a FUNCTION_BLOCK a FUNCTION_BLOCK or a CLASS (Table 48 row 6); not a FINAL one (6.6.5.5.4); not the type itself, directly or not (6.6.5.5.5, error 3);
  • OVERRIDE on a method that has no base method to override (6.6.5.5.5, error 4); an override whose result, inputs, outputs or in-outs differ from its base method’s, or whose access does (6.6.5.5.3); an override of a FINAL method;
  • a method with the name of a base method and no OVERRIDE: write it (OO.md D6);
  • FINAL and ABSTRACT together; an ABSTRACT method in a type that is not ABSTRACT, with OVERRIDE, or with statements (OO.md D12); an ABSTRACT type without an abstract method, a type that leaves one it inherits unimplemented and is not ABSTRACT, and an instance of an ABSTRACT type other than a VAR_IN_OUT (6.6.5.8);
  • SUPER outside a derived type; SUPER.m() of an ABSTRACT method; SUPER() in a method, of a CLASS (which has no body), with arguments, in a loop, or a second time (6.6.7.2.9);
  • a derived instance given to a base-typed VAR_IN_OUT, or referenced by a base-typed REF_TO, when its type adds an in-out: a call through the base would leave it unbound (6.6.8.3; OO.md D7);
  • an INTERFACE with variables, or a method of one with an access specifier, FINAL, ABSTRACT, OVERRIDE, statements, or sections other than inputs, outputs and in-outs (6.6.6.1); EXTENDS of an interface or IMPLEMENTS naming what is not an interface, or an interface its own base (6.6.6.3, 6.6.6.4);
  • a type that implements an interface’s method with another result or other parameters, or neither PUBLIC nor INTERNAL (6.6.6.4.2; OO.md D10); one that leaves a method out and is not ABSTRACT;
  • an interface as a VAR_IN_OUT (6.6.6.2; OO.md D9), or at an address: it references an instance, and is declared as an input or a variable;
  • ?= other than an interface from an interface, a REF_TO a CLASS or FUNCTION_BLOCK from an interface, or such a reference from a reference to its base or a type derived from it (6.6.6.7);
  • THIS outside a method or a function block’s body (6.6.5.7.2);
  • an instance of a CLASS, or of a FUNCTION_BLOCK with methods, a base, a derived type or an interface, copied with :=, made an input or output, or a variable of a FUNCTION or a method: it is passed as a VAR_IN_OUT instead (OO.md D11). A copy would cut a derived instance down to its base. An array or a structure that holds one, anywhere in it, is refused the same way: it would be copied with it.

E0032: a value the standard requires constant that is not

Some values are fixed when the program is compiled, and the standard says they are constant expressions (Ed.3 6.4.4.5.1, 6.4.4.4, 6.6.1.2.4, 7.3.3.3.3):

  • an array’s bounds, ARRAY[1..N];
  • a STRING’s length, STRING[N];
  • a subrange’s limits, INT (0..N);
  • a CASE’s labels on an integer, N: and 1..N: (Ed.3 7.3.3.3.3).

A constant expression is made of integer literals, + - * / MOD and parentheses, and the names of constants: a VAR CONSTANT of the POU, or a VAR_GLOBAL CONSTANT (through its VAR_EXTERNAL CONSTANT, or seen directly from a TYPE, as a CODESYS GVL’s constants are). The constant is of an integer type and has an initial value that is itself a constant expression.

VAR CONSTANT
    N : INT := 10;
END_VAR
VAR
    n : INT := 10;
    a : ARRAY[1..N] OF REAL;      (* fine *)
    b : ARRAY[1..n] OF REAL;      (* E0032: n is a variable *)
END_VAR

An initial value is a constant expression too (Ed.3 6.6.1.2.4, Figure 6), of any elementary type: literals, constants, the operators and the standard functions on them (Initial values). A variable there, or the result of a FUNCTION of the program’s, is known only as the program runs:

VAR
    n : INT := 10;
    a : INT := n + 1;             (* E0032: 'n' is a variable *)
    b : INT := Twice(2);          (* E0032: the result of Twice *)
END_VAR

Set such a variable in the body instead, at the first cycle.

A constant expression that has no value is refused too, in an initial value as well as in a bound: a division by zero, a shift by a negative count, a MUX selecting an input it has not, an index outside a constant array, and a constant whose initial value names the constant itself.

VAR CONSTANT
    Z : INT := 0;
    KA : ARRAY[1..3] OF INT := [4, 5, 6];
    A : INT := B;                 (* E0032 where A is used: A is B is A *)
    B : INT := A;
END_VAR
VAR
    x : INT := 10 / Z;            (* E0032: divides by zero *)
    w : WORD := SHL(16#01, -1);   (* E0032: shifts by a negative count *)
    m : INT := MUX(2, 5, 6);      (* E0032: MUX's inputs are 0 and 1 *)
    e : INT := KA[4];             (* E0032: KA is ARRAY[1..3] *)
END_VAR

For an array whose size is the caller’s, use ARRAY[*] (Ed.3 6.5.3, E0033).

E0033: an ARRAY[*] where Ed.3 does not allow one

ARRAY[*] is an array whose bounds are the caller’s (Ed.3 6.5.3). It is a parameter only:

  • an input, output or in-out of a FUNCTION or a method;
  • an in-out of a FUNCTION_BLOCK.

Every dimension of it is *: ARRAY[*, *] OF INT, not ARRAY[*, 1..3] OF INT. A variable, a structure’s field, a global or a TYPE has bounds of its own.

FUNCTION SUM : DINT
    VAR_IN_OUT a : ARRAY[*] OF INT; END_VAR       (* fine *)
    VAR i : DINT; END_VAR
    FOR i := LOWER_BOUND(a, 1) TO UPPER_BOUND(a, 1) DO
        SUM := SUM + a[i];
    END_FOR;
END_FUNCTION

PROGRAM main
    VAR x : ARRAY[*] OF INT; END_VAR              (* E0033 *)
END_PROGRAM

An array of fixed bounds is given to an ARRAY[*] of as many dimensions and the same element type; an ARRAY[*] is not given to one of fixed bounds, whose size it may not have.

W0001: an octal literal, removed from IEC 61131-3 in Ed.4

8# literals were deprecated by Ed.3 (Annex B) and removed by Ed.4. iecf still reads them, and warns. Write the value in decimal, hex or binary.

mask := 8#777;      (* W0001 *)
mask := 16#1FF;     (* the same value *)

W0002: a direct address in a body, deprecated since Ed.3

A direct address (%IX0.0, %QW2) belongs in a declaration: lamp AT %QX0.0 : BOOL;. Using one in a statement or a condition is deprecated since Ed.3 (Annex B: “Use of directly represented variables in the body of POUs and methods”). Declare a name for the address and use the name.

%QX0.0 := %IX0.1;          (* W0002, twice *)

VAR
    lamp AT %QX0.0 : BOOL;
    button AT %IX0.1 : BOOL;
END_VAR
lamp := button;            (* fixed *)

W0003: TRUNC without a type, deprecated since Ed.3

TRUNC(x) with no type in its name is feature 2a of Ed.3 Table 22, the “old” overloaded truncation, deprecated by Ed.3 (Annex B). iecf still reads it, and gives a DINT, as CODESYS, TwinCAT and Siemens do (docs/dependencies.md). The forms Ed.3 keeps say the result’s type: TRUNC_INT(x) (2c) or REAL_TRUNC_INT(x) (2b).

d := TRUNC(r);        (* W0003: a DINT *)
i := TRUNC(r);        (* W0003, and E0013: DINT does not convert to INT implicitly *)
i := TRUNC_INT(r);    (* fixed *)

Truncation is toward zero: TRUNC_INT(-1.6) is -1.

W0004: an address whose size prefix is not its variable’s size

An address is where a variable starts (Ed.3 6.5.5.3: the variable “does not need to be of equal or bigger size than the given direct representation, i.e. empty memory or overlapping is permitted”). It takes its own type’s size from that byte, whatever the size prefix says. CODESYS asks that “the data type should match the size prefix”; iecf warns when it does not, and compiles it (docs/dependencies.md).

The sizes: BOOL a bit (X); BYTE, SINT, USINT 1 byte (B); WORD, INT, UINT 2 (W); DWORD, DINT, UDINT, REAL, TIME, DATE, TOD and DT 4 (D); LWORD, LINT, ULINT, LREAL, LTIME, LDATE, LTOD and LDT 8 (L).

t AT %MD0 : TIME;     (* no warning: 4 bytes, D names 4 *)
r AT %IB12 : REAL;    (* W0004: bytes 12 to 15 *)
x AT %QB3 : BOOL;     (* W0004: the whole byte 3 *)

An array, structure or STRING lies from its address, its parts back to back; the size prefix is compared with its first element’s or field’s (a BOOL inside takes a byte):

a AT %QW6 : ARRAY[0..9] OF INT;   (* no warning *)
b AT %QB6 : ARRAY[0..9] OF INT;   (* W0004: an INT is 2 bytes *)

A BOOL at a byte, word or longer takes that whole byte: TRUE is written as 1, and any value but 0 reads TRUE, as CODESYS documents it. Only a BOOL starts at a bit: i AT %MX0.3 : INT is E0022.

W0005: REF() of a variable at an input address

A variable at an input address (AT %IW0, or %I*) is written by the host from the inputs before each task runs. Reading it through a reference is the common use; writing through the reference changes the copy in the process image, which the next inputs overwrite. CODESYS warns the same way (“invalid assignment target”) when a reference is taken to an input; iecf compiles it and warns (docs/dependencies.md).

VAR level AT %IW0 : INT; END_VAR
VAR r : REF_TO INT; END_VAR
r := REF(level);     (* W0005 *)

Behaviour reference

IEC 61131-3 says what most operations give and leaves the rest to the implementer: what happens at the edges of a type, how text looks, what an error does. This part of the book says, for each operation iecf compiles, what it gives — the ordinary cases and every edge — and what the program is told when something is not as it should be.

Each choice is recorded with the vendors it was checked against in Implementer choices. This part is the reference for the results themselves.

Three outcomes

An operation in iecf either gives its result, or gives a result and flags it, or faults.

The taskThe program seesThe platform sees
Resultgoes onthe valuenothing
Flaggoes ona value, as the page for the operation saysctx->flag: the first flag’s code, POU and line, until the platform clears it
Faultstopsnothing more: the task returns at oncectx->fault: its code, POU and line; no task runs until a restart

A flag is for a result that is defined but probably not what the program wanted: an overflow that wrapped, a string cut to fit, a REAL that became infinite. The program carries on, as Siemens and Rockwell carry on with an error bit set; the platform can show the first flag to the user. A fault is for an operation that has no result to go on with: a division by zero, an index outside its array, a NULL reference dereferenced.

Some flagged conversions have no value to give: a REAL too large for an INT has no low bits to keep. Then the statement is left undone: an assignment keeps its target’s old value, an IF or CASE runs no branch, a FOR does not run, a WHILE or REPEAT ends (Conversions).

The codes

ctx->flag.code and ctx->fault.code hold an IECF_FAULT_* of runtime/iecf.h (The C output §5):

CodeNameKindWhen
1DIVISION_BY_ZEROfaultan integer, real or duration divided by zero
2OVERFLOWflagan integer, TIME, DATE or DT result outside its type, wrapped; an infinite real
3INDEXfaultan array index outside its bounds
4CONVERSIONflaga conversion to a type that cannot hold the value; text that is no literal of the type; a subrange store outside its bounds
5MUXfaultMUX’s K outside 0 .. n-1
6NEGATIVEfaulta negative shift or rotate count
7STRING_LENGTHflaga string cut to fit the variable it is stored in
8IMAGEfaultthe platform’s process image smaller than the program addresses
9NANflaga real result that is not a number
10STRING_POSITIONflaga string function’s length or position outside its string
11SFCflagan SFC step entered while still active; an action’s associations in conflict (SFC)
12NULLfaulta NULL reference dereferenced (References)

Reading the tables

The examples in this part are checked: CI compiles each row with iecf, runs it, and fails if the result or the flag is not what the row says (cargo run -p iecf-harness -- book). Each row is the statement r := Expression; with r of the row’s Type. A table that needs an extension says so above it, and its rows are built with that extension on (extensions).

  • Result is the value r holds afterwards, as an IEC literal; NaN, +inf and -inf for those reals; unchanged when the statement was left undone; — when the operation faulted.
  • Flag is flag NAME or fault NAME, raised by that statement, or — for neither.

A typed literal (INT#32767) is used where a plain one would be computed by the checker: 32767 + 1 is a constant 32768, which an INT cannot hold, and is refused before the program runs (E0020).

Statements

The ST statements (Ed.3 7.3.3) do what the standard says; this page has what it leaves to the implementer, and what iecf adds at the edges.

A statement that faults changes nothing

Each expression that can fault is computed, and checked, before the statement stores anything: an assignment whose value divides by zero leaves its target as it was, and the task stops there (Results, flags and faults). A statement left undone — a REAL that does not fit its integer, a call whose EN is FALSE — stores nothing either, and the task goes on.

FOR

FOR i := a TO b BY s DO ... END_FOR evaluates a, b and s once, before the first iteration (Ed.3 7.3.3.4.2). The test is made before each iteration, so the body does not run at all when a is already past b.

After the loop, the variable is one step past its end: after FOR i := 1 TO 10, i is 11; after FOR i := 3 TO 1 BY -1, 0. The standard calls the value Implementer specific, but its own examples assume this (“terminates when the value of the variable I reaches 0”; Table 72’s J “will have the value 101”), and CODESYS (“at the end, i always will be one step outside the range”) and matiec do the same. An EXIT leaves the variable where it was. Decided 2026-11-07 (florin, Implementer choices).

At the type’s limit the loop still ends. FOR i := 0 TO 32767 on an INT runs 32768 times and stops; its last step wraps i to -32768 and is flagged OVERFLOW, as any overflow is. On CODESYS that loop never ends (“If the end value of the counter is equal to the upper limit of the data type of the counter, then an infinite loop results”).

A variable of a subrange type stays inside its subrange: the loop ends at its last value, and an end beyond the subrange is the bound it would pass, flagged CONVERSION (Subranges).

A step of 0 never ends, as written.

A body that moves the variable outside the TO range ends the loop (Ed.3: “The iteration is terminated when the value of the control variable is outside the range specified by the TO construct”; the standard also says the body “shall not” alter it). FOR k := 1 TO 10 with k := k + 2 in its body runs for k 1, 4, 7 and 10, and ends with k 13. Until 2026-11-12 such a loop ran on through the type’s range, wrapping.

The variable may be at an address, i AT %MW2 : INT: the loop counts in a copy, written to the address as each pass starts and read back after it, so the body sees it and may change it there; after the loop the address holds the value the variable would, an EXIT leaving what the body left. The harness case locmore runs these.

WHILE, REPEAT, EXIT, CONTINUE, RETURN

WHILE tests before each iteration, REPEAT after. EXIT leaves the innermost loop; CONTINUE goes to its next iteration — in REPEAT, to the UNTIL test. RETURN leaves the POU; in an SFC action, it ends the action (SFC).

CASE

The first list a value matches runs; none matching runs the ELSE, if there is one. A selector left undone runs no branch.

A label is a constant expression (Ed.3 7.3.3.3.3): integer literals, + - * / MOD, and the names of constants, as an array’s bound is (E0032 for a variable there). It is compared by its value, whatever integer type its constants are; a value the selector’s type cannot hold is refused as a literal is (E0020). For an enumeration, a label is one of its values. The harness case flexarrays runs one.

VAR CONSTANT FIRST : INT := 1; LAST : INT := 8; END_VAR
CASE k OF
    FIRST:            x := 1;
    FIRST + 1..LAST:  x := 2;
ELSE
    x := 0;
END_CASE;

Integers and bit strings

The integer types are two’s complement (signed) or plain binary (unsigned), of the widths Ed.3 Table 10 gives them:

TypeRangeTypeRange
SINT-128 .. 127USINT0 .. 255
INT-32768 .. 32767UINT0 .. 65535
DINT-2³¹ .. 2³¹-1UDINT0 .. 2³²-1
LINT-2⁶³ .. 2⁶³-1ULINT0 .. 2⁶⁴-1

BYTE, WORD, DWORD and LWORD are 8, 16, 32 and 64 bits; BOOL is one bit, held in a byte as 0 or 1.

Overflow wraps, and is flagged

A result outside its type wraps, two’s complement for a signed type, modulo 2ⁿ for an unsigned one, and is flagged OVERFLOW. The task goes on. No vendor stops a task on an overflow; Siemens and Rockwell set an error bit, as iecf flags (Implementer choices).

ExpressionTypeResultFlagNote
INT#32767 + INT#1INT-32768flag OVERFLOW
INT#-32768 - INT#1INT32767flag OVERFLOW
SINT#100 * SINT#2SINT-56flag OVERFLOW200 - 256
UINT#0 - UINT#1UINT65535flag OVERFLOW
USINT#200 + USINT#100USINT44flag OVERFLOW300 - 256
LINT#9223372036854775807 + LINT#1LINT-9223372036854775808flag OVERFLOW
ULINT#18446744073709551615 + ULINT#1ULINT0flag OVERFLOW
ABS(INT#-32768)INT-32768flag OVERFLOW32768 is no INT
ABS(INT#-5)INT5—
INT#30000 + INT#2767INT32767—the edge itself is no overflow

Division

Integer division goes toward zero, as C’s and every vendor’s does. MOD gives the remainder of that division, with the sign of the dividend (Ed.3 Table 29: IN1 - (IN1 / IN2) * IN2). Division by zero is a fault: no vendor agrees on a value to go on with. MOD by zero is 0, as Ed.3 Table 29, note e, has it.

ExpressionTypeResultFlagNote
DINT#7 / DINT#2DINT3—
DINT#-7 / DINT#2DINT-3—toward zero
DINT#7 / DINT#0DINT—fault DIVISION_BY_ZERO
UDINT#7 / UDINT#0UDINT—fault DIVISION_BY_ZERO
INT#-32768 / INT#-1INT-32768flag OVERFLOW32768 is no INT
DINT#7 MOD DINT#3DINT1—
DINT#-7 MOD DINT#3DINT-1—the dividend’s sign
DINT#7 MOD DINT#-3DINT1—
DINT#7 MOD DINT#0DINT0—Ed.3 Table 29 e

Selection

ExpressionTypeResultFlagNote
MAX(INT#3, INT#-4, INT#2)INT3—
MIN(UINT#3, UINT#7)UINT3—
LIMIT(INT#0, INT#150, INT#100)INT100—MN, IN, MX
LIMIT(INT#0, INT#-5, INT#100)INT0—
SEL(FALSE, INT#1, INT#2)INT1—G = FALSE takes IN0
MUX(INT#1, INT#10, INT#20, INT#30)INT20—K counts from 0
MUX(INT#3, INT#10, INT#20, INT#30)INT—fault MUXno IN3

SEL and MUX are functions: every input is evaluated, also the ones not selected, as any function’s inputs are.

MOVE, SEL and MUX select arrays, structures, instances, interfaces and references too (Ed.3 Table 32, footnote d: “allowed, but not required”): SEL(g, a, b)[2], MUX(k, p1, p2).x, i := SEL(g, o1, o2), SEL(g, r1, r2)^ := 30. The result is the input selected, where it is: nothing is copied until it is stored, and an element or field of it is read in place. Every input is computed first, in order, its indices checked; MUX’s K outside its inputs faults as for numbers. NULL goes beside an interface or a reference, and an instance beside an interface its type implements: MUX(k, o1, NULL, i) is of the interface. MIN, MAX and LIMIT stay ANY_ELEMENTARY, as Table 32 has them. The harness case selectdata runs each.

Partial access: a bit, byte, word of a bit string

A BYTE, WORD, DWORD or LWORD has parts (Ed.3 6.6.1.3, Table 17), numbered from the least significant: w.3 or w.%X3 a bit, a BOOL; w.%B1 a byte, dw.%W0 a word, l.%D1 a double word. A part is read, and written - into its whole, the rest of it kept - wherever the variable is: a plain one, an element, a field, one at an address, through a reference, an output taken into it. A part outside the variable is E0014 (b.%X8 of a BYTE, w.%B2 of a WORD), and so is a part of an INT, or of a direct variable (%MB0.%X1: name it, m AT %MB0). The harness case partaccess runs each.

VAR w : WORD := 16#1234; b : BYTE; END_VAR
b := w.%B1;          (* 16#12 *)
w.%B0 := 16#FF;      (* w is 16#12FF *)
w.0 := FALSE;        (* w is 16#12FE *)

Comparisons of more than two

GT, GE, EQ, LE and LT take any number of inputs (Ed.3 Table 33): the result holds when each neighbouring pair does, GT(a, b, c) being a > b AND b > c. NE takes two. Every input is computed once and in order, a middle one too, though it is in two pairs: a FUNCTION called there is called once (the harness case extcompare).

ExpressionTypeResultFlagNote
GT(INT#5, INT#2, INT#1)BOOLTRUE—5 > 2 > 1
GT(INT#5, INT#6, INT#1)BOOLFALSE—
GE(INT#5, INT#5, INT#5, INT#1)BOOLTRUE—
LT(INT#1, INT#2, INT#2)BOOLFALSE—2 < 2 does not hold
LE(INT#1, INT#2, INT#2)BOOLTRUE—
EQ('ab', 'ab', 'ab')BOOLTRUE—strings too

Bit strings: logic and shifts

AND, OR, XOR and NOT work bit by bit. SHL and SHR shift in zeros; a count of the width or more leaves 0. ROL and ROR rotate by the count modulo the width. A negative count is a fault: the standard has no meaning for it. A BOOL is a bit string of one bit (Ed.3 Table 33’s ANY_BIT): shifted by 1 or more it is FALSE, rotated it is itself.

ExpressionTypeResultFlagNote
WORD#16#F0F0 AND WORD#16#FF00WORD16#F000—
NOT BYTE#16#0FBYTE16#F0—
BYTE#16#0F XOR BYTE#16#FFBYTE16#F0—
SHL(BYTE#16#81, 1)BYTE16#02—the top bit falls out
SHL(BYTE#1, 8)BYTE16#00—the width or more: 0
SHR(WORD#16#8000, 15)WORD16#0001—zeros in, also on top
ROL(BYTE#16#81, 1)BYTE16#03—
ROR(BYTE#16#01, 9)BYTE16#80—9 is 1, modulo 8
SHL(BYTE#1, -1)BYTE—fault NEGATIVE
SHL(TRUE, 1)BOOLFALSE—one bit: it falls out
ROL(TRUE, 5)BOOLTRUE—

REAL and LREAL

REAL is IEEE 754 binary32 (about 7 significant digits) and LREAL binary64 (about 15), as Ed.3 Table 10 requires. Each operation rounds its result to its own type, half to even, and keeps no extra precision between operations.

Infinity and NaN are flagged, division by zero faults

A result too large for its type becomes an infinity and is flagged OVERFLOW; one that is not a number is flagged NAN. Both carry on as IEEE 754 has them: a later operation on them gives what IEEE 754 says. Division by zero — 0.0 or -0.0 — is a fault, as an integer’s is. (Implementer choices)

ExpressionTypeResultFlagNote
REAL#1.0E38 * REAL#10.0REAL+infflag OVERFLOW
REAL#-1.0E38 * REAL#10.0REAL-infflag OVERFLOW
REAL#1.0 / REAL#0.0REAL—fault DIVISION_BY_ZEROnot +inf
SQRT(REAL#-1.0)REALNaNflag NAN
LN(REAL#0.0)REAL-infflag OVERFLOW
LREAL_TO_REAL(LREAL#1.0E300)REAL+infflag OVERFLOWbeyond REAL
EXPT(REAL#2.0, 10)REAL1024.0—
REAL#2.0 ** REAL#0.5REAL1.4142135—

Precision

A REAL holds every integer up to 2²⁴ = 16777216 exactly, an LREAL up to 2⁵³; past that, a sum rounds to the nearest the type holds. Neither is flagged: it is how floating point is, not an error.

ExpressionTypeResultFlagNote
REAL#0.1 + REAL#0.2REAL0.3—the nearest REAL to each is the same
LREAL#0.1 + LREAL#0.2LREAL0.30000000000000004—not LREAL’s 0.3
REAL#16777216.0 + REAL#1.0REAL16777216.0—16777217 is no REAL
REAL#1.0 / REAL#3.0REAL0.33333334—

The functions of Ed.3 Table 28 (SQRT, LN, LOG, EXP, SIN, COS, TAN, ASIN, ACOS, ATAN, EXPT) come from the C library’s <math.h>, or from the platform’s own when it gives one (The C output §1): their last digit is the library’s.

Conversions

<FROM>_TO_<TO>(x) and TO_<TO>(x) convert a value; TRUNC and its named forms cut a real to an integer. Ed.3 6.6.2.5 gives three kinds, and iecf keeps them apart:

  • between numbers — integers and reals — the value is kept if the target holds it;
  • between bit strings, or a bit string and a number, the bits are kept: a binary transfer (Ed.3 Table 25);
  • to and from STRING, the value is written or read as text (Text conversions).

Implicit conversions (Ed.3 Figure 11) only widen: an INT goes where a DINT or a REAL is wanted without a word, and keeps its value.

Implicit conversions: they widen

Ed.3 6.6.1.6 applies Figure 11’s implicit conversions in an assignment, to a function’s or block’s input, to a block’s output taken with =>, and to the operands of an operator or overloaded function, so that both are one type: the narrower one is widened to the other’s. Never to an in-out (rule 6): a VAR_IN_OUT takes a variable of its own type.

An operation is done in the type of its operands, and its result is widened only where it is used (Ed.3 6.6.1.6, Example 3): INT * INT is an INT, and overflows as one, even into a DINT. CODESYS and TwinCAT compute in their registers’ width instead, at least 32 bits, so the same product there is 60000; iecf follows the standard, and flags it. Make one operand wide, INT_TO_DINT(i) * j, for the product in DINT.

An unsigned type widens to the signed type twice its width, keeping its value: UINT#40000 is 40000 as a DINT, not -25536. Two integers neither of which widens to the other are both widened to the narrowest signed type that holds them both, as Siemens SCL does: SINT and USINT to INT, INT and UINT to DINT, DINT and UDINT to LINT. ULINT and LINT have none, and are refused (E0014): convert one.

ExpressionTypeResultFlagNote
DINT#-3 + UINT#40000DINT39997—the UINT widened
UINT#40000 > DINT#-3BOOLTRUE—
SINT#-1LINT-1—the sign kept
USINT#255INT255—
BYTE#16#FF OR WORD#16#0100WORD16#01FF—a bit string: no sign
INT#3 / REAL#2.0REAL1.5—Ed.3 Example 6
MAX(SINT#-1, DINT#-3)DINT-1—
INT#30000 * DINT#2DINT60000—
INT#30000 * INT#2DINT-5536flag OVERFLOWmultiplied as INT, then widened
INT#30000 + UINT#40000DINT70000—both widened to DINT
SINT#-1 + USINT#255INT254—both to INT
DINT#-1 + UDINT#4000000000LINT3999999999—both to LINT
INT#-1 < UINT#40000BOOLTRUE—compared as DINTs
MAX(INT#-1, UINT#40000)DINT40000—

Integer to integer

A value the target holds is kept. One it does not hold keeps its low bits — what a two’s complement machine gives — and is flagged CONVERSION: Siemens’s 2011 manual keeps “the least-significant bytes of IN”, and TwinCAT’s INT_TO_SINT(4223) is 127.

ExpressionTypeResultFlagNote
INT_TO_DINT(INT#-5)DINT-5—
INT_TO_SINT(INT#100)SINT100—
INT_TO_SINT(INT#200)SINT-56flag CONVERSION16#C8
INT_TO_SINT(INT#4223)SINT127flag CONVERSION16#107F: its low byte
DINT_TO_UINT(DINT#-1)UINT65535flag CONVERSION
UINT_TO_INT(UINT#40000)INT-25536flag CONVERSION
ULINT_TO_LINT(ULINT#18446744073709551615)LINT-1flag CONVERSION

Real to integer: rounded half to even

REAL_TO_INT and the others round to the nearest integer, a half to the even one (Ed.3 6.6.2.5.3: “rounding … to the nearest integer”; IEEE 754’s default). TRUNC_INT, REAL_TRUNC_INT and the old TRUNC (which gives DINT and warns, W0003) drop the fraction, toward zero.

A real the target cannot hold — out of its range, an infinity, a NaN — has no low bits to keep, and iecf invents no value: the conversion is flagged CONVERSION and its statement is left undone. An assignment keeps its target’s old value; an IF or CASE runs no branch; a FOR does not run; a WHILE or REPEAT ends; a function block’s input keeps its value, and the block is called. Only the first flag of a scan is kept, so a NaN made by SQRT shows as NAN, not as the conversion after it.

Undone, the statement faults on nothing it computes after: where the conversion’s place in it would divide by zero or index outside an array (10 / REAL_TO_INT(x), a[REAL_TO_INT(x)] := 1), the statement is flagged and left undone, and the task carries on. A fault inside a FUNCTION the statement calls still stops it, as does a fault in a statement whose conversions all succeeded. An assignment’s target may be indexed by a conversion, or by a FUNCTION with effects, computed before the value (Ed.3 7.3.2); the harness case targetidx runs these.

ExpressionTypeResultFlagNote
REAL_TO_INT(REAL#2.6)INT3—
REAL_TO_INT(REAL#2.5)INT2—half to even
REAL_TO_INT(REAL#3.5)INT4—half to even
REAL_TO_INT(REAL#-2.5)INT-2—
REAL_TO_UINT(REAL#-0.4)UINT0—rounds to 0, which UINT holds
REAL_TO_INT(REAL#40000.0)INTunchangedflag CONVERSIONstatement left undone
REAL_TO_UINT(REAL#-1.0)UINTunchangedflag CONVERSION
INT#10 / REAL_TO_INT(REAL#40000.0)INTunchangedflag CONVERSIONno division by zero
LREAL_TO_DINT(SQRT(LREAL#-1.0))DINTunchangedflag NANSQRT’s flag came first
TRUNC_INT(REAL#-2.7)INT-2—toward zero
REAL_TRUNC_DINT(REAL#2.7)DINT2—

Integer to real

The nearest real, half to even. Past 2²⁴ for a REAL, 2⁵³ for an LREAL, not every integer is a real; the nearest is no error and is not flagged.

ExpressionTypeResultFlagNote
DINT_TO_REAL(DINT#16777216)REAL16777216.0—
DINT_TO_REAL(DINT#16777217)REAL16777216.0—the nearest REAL, half to even
INT_TO_LREAL(INT#-3)LREAL-3.0—

Bit strings: a binary transfer

To or from a bit string, the bits move as they are (Ed.3 Table 25): to a narrower type its rightmost (low) bits, to a wider one zeros on the left. A signed integer’s bits are its two’s complement. Nothing is flagged: a bit string has no range to leave.

From BOOL, 1 or 0. To BOOL only with an extension. Ed.3 contradicts itself here: Figure 11, which the checker follows, defines no conversion from BYTE, WORD, DWORD or LWORD to BOOL, while Table 25 lists BYTE_TO_BOOL and the others as “Binary transfer of the rightmost bit into the target” — so BYTE_TO_BOOL(2) would be FALSE. CODESYS and TwinCAT accept them and give TRUE for any value other than 0. iecf refuses them (E0013) unless --extension bit-string-to-bool is on, and then gives what CODESYS and TwinCAT give (decided, florin, extensions). Portable either way: b.0 for the rightmost bit, b <> 0 for any.

ExpressionTypeResultFlagNote
BYTE_TO_BOOL(BYTE#16#02)BOOLTRUE—not the rightmost bit: any value but 0
BYTE_TO_BOOL(BYTE#0)BOOLFALSE—
WORD_TO_BOOL(WORD#16#8000)BOOLTRUE—
DWORD_TO_BOOL(DWORD#1)BOOLTRUE—
LWORD_TO_BOOL(LWORD#16#8000000000000000)BOOLTRUE—
TO_BOOL(BYTE#16#10)BOOLTRUE—the overloaded form
ExpressionTypeResultFlagNote
INT_TO_WORD(INT#-1)WORD16#FFFF—
WORD_TO_INT(WORD#16#8000)INT-32768—
DWORD_TO_BYTE(DWORD#16#12345678)BYTE16#78—the rightmost byte
BYTE_TO_DWORD(BYTE#16#FF)DWORD16#000000FF—zeros on the left
BOOL_TO_WORD(TRUE)WORD16#0001—
BYTE#2 <> 0BOOLTRUE—any bit set

Between a 32 or 64-bit string and REAL or LREAL the bits are the IEEE 754 encoding:

ExpressionTypeResultFlagNote
DWORD_TO_REAL(DWORD#16#3F800000)REAL1.0—
REAL_TO_DWORD(REAL#-2.0)DWORD16#C0000000—
LREAL_TO_LWORD(LREAL#1.0)LWORD16#3FF0000000000000—

Typed functions

An overloaded standard function can be called typed: its name, _, and a type, ADD_INT (Ed.3 6.6.2.4, Table 21). The typed function “is performed using the type as data type for its inputs and outputs”: each input is made that type first, by an implicit conversion, which only widens (Conversions), and the result is that type. So ADD_DINT(i, j) of two INTs adds in DINT, where i + j adds in INT; and NOT_WORD(b) of a BYTE widens it before it inverts. An input the type does not take implicitly is E0013: ADD_INT(d, i) of a DINT.

ExpressionTypeResultFlagNote
ADD_DINT(INT#30000, INT#30000)DINT60000—added as DINTs
ADD_INT(SINT#100, SINT#100, SINT#100)INT300—
MUL_INT(INT#300, INT#300)INT24464flag OVERFLOW90000 wrapped
SUB_LINT(UDINT#0, UDINT#1)LINT-1—
DIV_REAL(INT#7, INT#2)REAL3.5—
MOD_DINT(DINT#7, INT#3)DINT1—
ABS_LINT(SINT#-128)LINT128—
NOT_WORD(BYTE#16#0F)WORD16#FFF0—widened, then inverted
SHL_WORD(BYTE#16#81, 1)WORD16#0102—SHL(BYTE#16#81, 1) is 16#02
AND_DWORD(BYTE#16#FF, WORD#16#0F0F)DWORD16#0000000F—
MAX_LREAL(REAL#1.5, INT#2)LREAL2.0—
LIMIT_DINT(INT#0, DINT#-5, INT#10)DINT0—
SEL_LREAL(TRUE, INT#1, REAL#2.5)LREAL2.5—
MUX_UINT_DINT(UINT#1, INT#-5, DINT#7)DINT7—K a UINT, the inputs DINT
GT_DINT(INT#-1, DINT#0)BOOLFALSE—
EQ_LREAL(REAL#1.5, LREAL#1.5)BOOLTRUE—
SQRT_LREAL(REAL#2.25)LREAL1.5—
EXPT_REAL(REAL#2.0, 3)REAL8.0—
MOVE_LINT(SINT#-7)LINT-7—
CONCAT_STRING('ab', 'cd')STRING'abcd'—
LEFT_STRING('abc', 2)STRING'ab'—
FIND_STRING('abcd', 'cd')INT3—
MAX_CHAR(CHAR#'a', 'b')CHAR'b'—'b' a CHAR here

ADD_TIME, SUB_TIME, ADD_LTIME and SUB_LTIME are Table 30’s functions of durations, + and - of TIME or LTIME; MUL_TIME and DIV_TIME are Table 30’s too, a duration by a number (TIME and LTIME). CONCAT_DATE and the other Table 36 names are those functions, not typed forms of CONCAT.

ExpressionTypeResultFlagNote
ADD_TIME(T#1s, T#500ms)TIMET#1s500ms—
SUB_LTIME(LTIME#1s, T#1ms)LTIMELTIME#999ms—the TIME widened

Which functions are typed

Table 21 asks the implementer for “an additional table showing which functions are overloaded and which are typed”. In iecf, every overloaded standard function whose inputs share one type has a typed form, for each type of its inputs’ class:

FunctionTyped for
ABSthe integer and real types
SQRT, LN, LOG, EXP, SIN, COS, TAN, ASIN, ACOS, ATAN, ATAN2, EXPTREAL, LREAL
ADD, SUB, MUL, DIVthe integer and real types, TIME, LTIME
MODthe integer types
SHL, SHR, ROL, ROR, AND, OR, XOR, NOTthe bit strings and BOOL
MOVE, SEL, MAX, MIN, LIMIT, MUX, GT, GE, EQ, LE, LT, NEevery elementary type
LEFT, RIGHT, MID, INSERT, DELETE, REPLACE, FINDSTRING, WSTRING
CONCATSTRING, WSTRING, CHAR, WCHAR

MUX_K_T names K’s type and the inputs’ (Table 32, footnote c): MUX_UINT_REAL. A function whose inputs are of no one type - LEN, the conversions, the date and time functions - has none; a conversion is typed by its own name already (INT_TO_REAL, REAL_TRUNC_INT). Not compiled yet: a shift of BOOL.

STRING

A STRING is STRING[80]; a STRING[n] holds up to n characters, one byte each, read as Latin-1, followed by a 0 byte: n + 1 bytes in all, as on CODESYS and TwinCAT. n is at most 65535. A literal with a character above U+00FF is refused (E0020): write it as a WSTRING. $N is a line feed.

Strings compare by their characters’ codes, left to right, a shorter string as if extended with zeros (Ed.3 6.6.2.5.11): 'Z' > 'AZ', and 'ab' < 'abc'.

Too long: cut, and flagged

A string stored where it does not fit is cut to fit and flagged STRING_LENGTH. Every vendor cuts; iecf also says so. A function’s result is never cut on its way: its buffer holds the most its inputs can make, so only the store at the end can cut. A FUNCTION’s or method’s STRING input is cut to its length as the function starts, flagged at the input’s declaration; its STRING result is its own declared length, and is cut by what it is stored in, as any string. A STRING in-out is given a variable of its length exactly (E0013 otherwise), by name or in order alike, STRING being STRING[80]; and the harness case fnparams runs each.

ExpressionTypeResultFlagNote
CONCAT('ab', 'cd')STRING'abcd'—
CONCAT('ab', 'cd')STRING[3]'abc'flag STRING_LENGTHcut where it is stored
CONCAT('ab', 'cd', 'ef')STRING'abcdef'—CONCAT takes any number
'Z' > 'AZ'BOOLTRUE—
'ab' < 'abc'BOOLTRUE—
LEN('')INT0—

Lengths and positions outside the string

LEFT, RIGHT, MID, DELETE, INSERT and REPLACE take a length L and a position P, counted from 1. The standard calls a negative L or P, or a position past the end, an error and leaves what it does to the implementer. iecf gives what Siemens documents for each case — the one vendor that documents them all — and flags STRING_POSITION; the task goes on. A length of 0 takes nothing and is no error; INSERT at P = 0 puts IN2 first, as CODESYS and Siemens do.

ExpressionTypeResultFlagNote
LEFT('abc', 2)STRING'ab'—
LEFT('abc', 0)STRING''—no error
LEFT('abc', 5)STRING'abc'flag STRING_POSITIONL past the end: all of IN
LEFT('abc', -1)STRING''flag STRING_POSITIONL < 0: empty
RIGHT('abc', 2)STRING'bc'—
RIGHT('abc', 5)STRING'abc'flag STRING_POSITION
MID('abcde', 2, 2)STRING'bc'—MID(IN, L, P)
MID('abc', 5, 2)STRING'bc'flag STRING_POSITIONL past the end: from P to the end
MID('abc', 2, 0)STRING''flag STRING_POSITIONP < 1: empty
MID('abc', 1, 4)STRING''flag STRING_POSITIONP past the end: empty
DELETE('abcde', 2, 2)STRING'ade'—DELETE(IN, L, P)
DELETE('abc', 2, 5)STRING'abc'flag STRING_POSITIONP past the end: IN unchanged
DELETE('abcde', 9, 2)STRING'a'flag STRING_POSITIONL past the end: to the end
INSERT('abc', 'XY', 2)STRING'abXYc'—after the P-th character
INSERT('abc', 'XY', 0)STRING'XYabc'—P = 0: first
INSERT('abc', 'XY', 5)STRING'abcXY'flag STRING_POSITIONP past the end: appended
REPLACE('abcde', 'X', 2, 2)STRING'aXde'—REPLACE(IN1, IN2, L, P)
REPLACE('abc', 'X', 2, 9)STRING'abcX'flag STRING_POSITIONP past the end: appended
FIND('abcbc', 'bc')INT2—the first
FIND('abc', 'x')INT0—not there
FIND('abc', '')INT0—

LEN and FIND give INT, as CODESYS, TwinCAT and Siemens do.

MOVE, SEL, MUX, MIN, MAX and LIMIT take strings as they take numbers (Ed.3 Tables 31, 32): every input is evaluated, then one is given; MIN, MAX and LIMIT order strings as the comparisons do. MUX with K outside its inputs faults, as for any type.

ExpressionTypeResultFlagNote
SEL(TRUE, 'ab', 'cd')STRING'cd'—
MUX(INT#1, 'a', 'b', 'c')STRING'b'—K counts from 0
MUX(INT#3, 'a', 'b', 'c')STRING—fault MUXno IN3
MAX('ab', 'abc', 'Z')STRING'abc'—by codes: 'a' > 'Z'
MIN('ab', 'abc')STRING'ab'—
LIMIT('b', 'z', 'd')STRING'd'—
MOVE('xy')STRING[1]'x'flag STRING_LENGTHcut where it is stored
MAX("a", "$263A")WSTRING"$263A"—

CHAR

A CHAR holds one Latin-1 character, its code a byte (Ed.3 Table 10); it starts as '$00'. CHAR#'X' and CHAR#16#58 are CHAR literals; an untyped 'X' of one character is a CHAR where a CHAR is wanted - given to a CHAR, or compared with one (Ed.3 Table 6, 1b) - and a STRING elsewhere. A CHAR goes where a STRING goes, as a string of that one character (Figure 11: implicit), and '$00' there is the empty string, as '$00' ends any string.

CHAR_TO_BYTE, BYTE_TO_CHAR and CHAR_TO_WORD and wider are binary transfers (Table 25), CHAR_TO_USINT the code (Figure 11). STRING_TO_CHAR takes the first character (Table 27); of an empty string, for which Ed.3 leaves the result undefined, it gives '$00' and is flagged CONVERSION, as any value its target cannot hold (dependencies).

ExpressionTypeResultFlagNote
CHAR#'a'CHAR'a'—
CHAR#16#41CHAR'A'—
BYTE_TO_CHAR(BYTE#16#42)CHAR'B'—
CHAR_TO_BYTE(CHAR#'a')BYTE16#61—
CHAR_TO_USINT('a')USINT97—'a' a CHAR here
CHAR_TO_WORD(CHAR#'$FF')WORD16#00FF—
CHAR#'a' < CHAR#'b'BOOLTRUE—by code
CHAR#'a' = 'a'BOOLTRUE—
MAX(CHAR#'a', CHAR#'b')CHAR'b'—
STRING_TO_CHAR('xyz')CHAR'x'—the first character
STRING_TO_CHAR('')CHAR'$00'flag CONVERSIONundefined in Ed.3
CHAR_TO_STRING(CHAR#'q')STRING'q'—
CONCAT('ab', CHAR#'c')STRING'abc'—
CHAR_TO_STRING(CHAR#'$00')STRING''—the empty string

One character: s[i]

s[i] is the i-th character of a STRING, counted from 1, a CHAR (Ed.3 6.4.2); it reads and it is written: c := s[2];, s[3] := 'B';, a[1][2] in an array of strings. Ed.3 gives the functions it is equivalent to, STRING_TO_CHAR(MID(s, 1, i)) and REPLACE(s, c, 1, i), and leaves a position outside the string open:

  • a read outside the string gives '$00', flagged STRING_POSITION, as that MID and STRING_TO_CHAR give;
  • a write changes only a character the string has: below 1 or past its end, s is left as it is, flagged STRING_POSITION - it neither grows nor empties;
  • s[i] := '$00' cuts the string there, as '$00' ends any string: 'ABCD' becomes 'A' for i = 2. CODESYS and TwinCAT cut there too; Siemens, whose STRING keeps its length in a byte, keeps a '$00' inside, which an iecf STRING cannot hold.

A position that is a constant outside 1..n of a STRING[n] is an error (E0014). s[i] is not a variable: no REF() of it (E0026), and it is no in-out’s (E0015). A STRING is not stored in a CHAR, so s[2] := t for a STRING t is E0013 (Ed.3’s error cases); a CHAR goes into a STRING. CODESYS and TwinCAT count from 0 and give a BYTE, not Ed.3’s; Siemens counts from 1 and gives a CHAR, as iecf. None documents a position outside the string.

In these rows s is a STRING[10] holding 'ABCD':

ExpressionTypeResultFlagNote
s[2]CHAR'B'—from 1
s[4]CHAR'D'—the last
s[INT#2 * 2 - 1]CHAR'C'—computed
s[5]CHAR'$00'flag STRING_POSITIONpast the end, inside STRING[10]
s[LEN(s) - 4]CHAR'$00'flag STRING_POSITION0
s[1] = 'A'BOOLTRUE—
CONCAT(s[4], s[1])STRING'DA'—a CHAR as a string

The harness case stringchars writes characters, past the end and below 1 too (s unchanged, flagged), with values worked by hand.

WSTRING and WCHAR

A WSTRING is WSTRING[80]; a WSTRING[n] holds up to n UTF-16 units, 16 bits each, followed by a 0: Ed.3 Table 27 names UTF-16. A WCHAR is one unit, starting as "$0000". A character above U+FFFF - an emoji, a rare CJK character - is two units in a WSTRING, a surrogate pair, and LEN, positions and ws[i] count both; a WCHAR literal cannot hold one (E0020). CODESYS calls its WSTRING UCS-2, the same units for every character up to U+FFFF.

A WSTRING literal is written in double quotes, "ab", its escapes $hhhh with four hex digits ("$263A") or ${1F600}; WSTRING#'ab' and WCHAR#'X' take single quotes and $hh (Ed.3 Table 6, 4c and 4d). WCHAR#"X", WCHAR#16#263A, and an untyped "X" of one character where a WCHAR is wanted are WCHARs; 'X' never is (E0013), nor is "X" a CHAR. A WCHAR goes where a WSTRING goes (Figure 11: implicit); STRING and WSTRING, CHAR and WCHAR do not mix without a conversion (Ed.3 6.6.2.5.11).

LEFT, RIGHT, MID, DELETE, INSERT, REPLACE, FIND, LEN, CONCAT, comparison and ws[i] work on WSTRINGs as on STRINGs, unit by unit, with the same flags.

At an address, a WSTRING[n] takes 2(n + 1) bytes from its byte, an odd one too, each unit little-endian, as any part there; %QW1 addresses byte 1. A reference to one reaches it there (References).

Between the two widths (Ed.3 Table 27): a STRING’s characters are Latin-1, U+0000 to U+00FF, and carry across as they are. A character a STRING cannot hold, WSTRING_TO_STRING skips - a surrogate pair whole - and WCHAR_TO_CHAR gives '$00'; both are flagged CONVERSION. Beckhoff documents the same skip for its WSTRING_TO_STRING2 and WCHAR_TO_CHAR; CODESYS and Siemens document nothing (dependencies). WSTRING_TO_WCHAR gives the first unit, "$0000" flagged for an empty string, as STRING_TO_CHAR does; Figure 11 lists no such conversion, but Table 27 defines it, and iecf follows Table 27. WCHAR_TO_WORD, _DWORD and _LWORD and WORD_TO_WCHAR are binary transfers (Table 25).

Numbers and the other types to and from a WSTRING (INT_TO_WSTRING, WSTRING_TO_REAL, TO_WSTRING) give and read the same text as to and from a STRING (Text conversions); a character outside ASCII is none a literal has.

ExpressionTypeResultFlagNote
LEN("a$263Ab")INT3—
LEN("${1F600}")INT2—a surrogate pair
CONCAT("ab", "cd")WSTRING[3]"abc"flag STRING_LENGTHcut where it is stored
MID("a$263Ab", 1, 2)WSTRING"$263A"—
FIND("a$263Ab", "b")INT3—
"Z" > "AZ"BOOLTRUE—
WCHAR#16#263A > WCHAR#'z'BOOLTRUE—by unit
WCHAR#"x" = "x"BOOLTRUE—"x" a WCHAR here
CONCAT("a", WCHAR#16#263A)WSTRING"a$263A"—a WCHAR as a string
WSTRING_TO_STRING("caf$00E9")STRING'caf$E9'—Latin-1 kept
WSTRING_TO_STRING("a$263Ab")STRING'ab'flag CONVERSIONskipped
WSTRING_TO_STRING("a${1F600}b")STRING'ab'flag CONVERSIONa pair skipped whole
STRING_TO_WSTRING('caf$E9')WSTRING"caf$00E9"—
WCHAR_TO_CHAR(WCHAR#16#E9)CHARCHAR#16#E9—
WCHAR_TO_CHAR(WCHAR#16#263A)CHAR'$00'flag CONVERSION
CHAR_TO_WCHAR(CHAR#'q')WCHAR"q"—
WSTRING_TO_WCHAR("xyz")WCHAR"x"—the first character
WSTRING_TO_WCHAR("")WCHAR"$0000"flag CONVERSIONundefined in Ed.3
WCHAR_TO_WORD(WCHAR#16#263A)WORD16#263A—
INT_TO_WSTRING(-7)WSTRING"-7"—
WSTRING_TO_INT("42")INT42—

The harness case wstrings runs them in a program: in an array and a structure, a function block’s input and output, ws[i] written.

Text conversions

<T>_TO_STRING, TO_STRING and STRING_TO_<T> write a value as text and read it back. The standard leaves the text to the implementer; iecf writes what CODESYS, TwinCAT and Siemens write where they document it, and reads what they read (Implementer choices). Neither direction uses the C library: the digits come from iecf’s own arithmetic, the same on every target.

To STRING

  • BOOL: TRUE or FALSE.
  • Integers and bit strings: decimal, a - for a negative number and no +.
  • REAL and LREAL: C’s %g with 7 and 15 significant digits, correctly rounded, trailing zeros dropped — inf, -inf and nan for those.
  • TIME and LTIME: the literal, with the units that are not 0, in lower case.
  • DATE, TOD, DT: the literal; a TOD shows milliseconds only when they are not 0.
  • LDATE, LTOD, LDT: the literal, prefixed LD#, LTOD#, LDT#; the fraction of a second in groups of three digits, as many as are not 0 (.500, .000001).
ExpressionTypeResultFlagNote
BOOL_TO_STRING(TRUE)STRING'TRUE'—
INT_TO_STRING(INT#-123)STRING'-123'—
UDINT_TO_STRING(UDINT#4294967295)STRING'4294967295'—
BYTE_TO_STRING(BYTE#16#FF)STRING'255'—decimal, not 16#FF
REAL_TO_STRING(REAL#2.5)STRING'2.5'—
REAL_TO_STRING(REAL#1.0)STRING'1'—trailing zeros dropped
REAL_TO_STRING(REAL#0.1)STRING'0.1'—7 digits: not 0.100000001
REAL_TO_STRING(REAL#16777216.0)STRING'1.677722e+07'—
REAL_TO_STRING(REAL#0.00001)STRING'1e-05'—
LREAL_TO_STRING(LREAL#0.1)STRING'0.1'—15 digits
REAL_TO_STRING(REAL#1.0E38 * REAL#10.0)STRING'inf'flag OVERFLOWthe multiplication’s flag
TIME_TO_STRING(T#1d2h3m4s500ms)STRING'T#1d2h3m4s500ms'—
TIME_TO_STRING(T#0s)STRING'T#0ms'—
TIME_TO_STRING(T#-1.5s)STRING'T#-1s500ms'—
LTIME_TO_STRING(LTIME#1s5us)STRING'LTIME#1s5us'—
DATE_TO_STRING(D#2026-11-07)STRING'D#2026-11-07'—
TOD_TO_STRING(TOD#12:34:56)STRING'TOD#12:34:56'—
TOD_TO_STRING(TOD#12:34:56.007)STRING'TOD#12:34:56.007'—
DT_TO_STRING(DT#2026-11-07-12:34:56)STRING'DT#2026-11-07-12:34:56'—
LDATE_TO_STRING(LD#1969-12-31)STRING'LD#1969-12-31'—
LTOD_TO_STRING(LTOD#12:34:56.5)STRING'LTOD#12:34:56.500'—
LDT_TO_STRING(LDT#2026-11-08-12:34:56.000000001)STRING'LDT#2026-11-08-12:34:56.000000001'—

From STRING

The text is read as CODESYS and TwinCAT read it: spaces before it are skipped; then comes a literal of the type — a type prefix (INT#5), a sign, a based integer (16#FF, 2#1111_0000), underscores between digits, a REAL’s fraction and exponent, a duration’s T# (any case), TRUE or FALSE in any case, or 1 or 0; what follows is ignored: '123abc' is 123. A duration or date needs its prefix. A TIME keeps whole milliseconds and a DT whole seconds, the rest dropped. A REAL is read exactly and rounded once, half to even.

No literal, or a value the type cannot hold, gives 0 (for a DATE or DT, 1970-01-01) and is flagged CONVERSION, as Siemens gives 0 and ENO = FALSE. The task goes on.

ExpressionTypeResultFlagNote
STRING_TO_INT(' 42')INT42—spaces before are skipped
STRING_TO_INT('INT#-5')INT-5—a type prefix
STRING_TO_INT('16#FF')INT255—
STRING_TO_DWORD('2#1111_0000')DWORD16#F0—
STRING_TO_INT('123abc')INT123—the rest is ignored
STRING_TO_INT('abc')INT0flag CONVERSIONno literal
STRING_TO_INT('')INT0flag CONVERSION
STRING_TO_LDT('LDT#1677-09-21-00:12:43.145224192')LDTLDT#1677-09-21-00:12:43.145224192—the first LDT
STRING_TO_LDATE('LD#1677-09-21')LDATELD#1970-01-01flag CONVERSIONno midnight LDATE holds
STRING_TO_SINT('200')SINT0flag CONVERSIONnot a SINT
STRING_TO_BOOL('true')BOOLTRUE—any case
STRING_TO_BOOL('1')BOOLTRUE—
STRING_TO_BOOL('yes')BOOLFALSEflag CONVERSION
STRING_TO_REAL('2.5e3')REAL2500.0—
STRING_TO_REAL('1e39')REAL0.0flag CONVERSIONbeyond REAL
STRING_TO_TIME('T#1m30s')TIMET#1m30s—
STRING_TO_TIME('t#1.5s')TIMET#1s500ms—
STRING_TO_TIME('T#1.0005s')TIMET#1s—below a millisecond dropped
STRING_TO_TIME('90')TIMET#0sflag CONVERSIONno T#
STRING_TO_DATE('D#2024-02-29')DATED#2024-02-29—
STRING_TO_DATE('D#2023-02-29')DATED#1970-01-01flag CONVERSIONno such day
STRING_TO_TOD('TOD#24:00:00')TODTOD#00:00:00flag CONVERSION
STRING_TO_DT('DT#2026-11-07-12:34:56.9')DTDT#2026-11-07-12:34:56—whole seconds

EN and ENO

Any function call may pass EN := c, an enable input, and ENO => x (or NOT ENO => x), which tells whether the call went well (Ed.3 6.6.1.5). The standard leaves what happens in detail to the implementer; iecf decided, 2026-11-07 (Implementer choices):

  • EN = FALSE: the call does not happen. Its inputs are not evaluated — a division by zero among them does not fault — the function is not called, ENO is FALSE, and the statement the call is in is left undone: an assignment keeps its target’s value, as when a REAL does not fit an integer. Nothing is flagged. This is Ed.3’s “external implementation” (its Example 2), and what CODESYS does with a box whose EN is FALSE.
  • ENO is FALSE when the function’s own operation raised a flag: an overflow, a conversion that does not fit, a string length or position outside its string. A flag raised while computing an argument is the argument’s, not the function’s, and leaves ENO TRUE. The flag still reaches ctx->flag as any flag does. This is Ed.3’s rule 3, and what Siemens does.
  • Otherwise ENO is TRUE. A fault stops the task, as without ENO.
ExpressionTypeResultFlagNote
DIV(EN := TRUE, IN1 := INT#7, IN2 := INT#2, ENO => eno)INT3, ENO TRUE—
DIV(EN := FALSE, IN1 := INT#7, IN2 := INT#2, ENO => eno)INTunchanged, ENO FALSE—the assignment is left undone
DIV(EN := FALSE, IN1 := INT#7, IN2 := INT#0, ENO => eno)INTunchanged, ENO FALSE—not called: no fault
DIV(EN := TRUE, IN1 := DINT#7, IN2 := DINT#0, ENO => eno)DINT—fault DIVISION_BY_ZEROa fault still stops the task
ADD(IN1 := INT#32767, IN2 := INT#1, ENO => eno)INT-32768, ENO FALSEflag OVERFLOWits own overflow
ADD(IN1 := INT#32767 + INT#1, IN2 := INT#1, ENO => eno)INT-32767, ENO TRUEflag OVERFLOWthe argument’s overflow, not ADD’s
ADD(IN1 := INT#1, IN2 := INT#1, NOT ENO => eno)INT2, ENO FALSE—ENO negated
REAL_TO_INT(IN := REAL#40000.0, ENO => eno)INTunchanged, ENO FALSEflag CONVERSION
LEFT(IN := 'abc', L := 5, ENO => eno)STRING'abc', ENO FALSEflag STRING_POSITION
CONCAT(EN := FALSE, IN1 := 'a', IN2 := 'b')STRINGunchanged—
MAX(EN := TRUE, IN1 := INT#1, IN2 := INT#2)INT2—EN without ENO

The same holds for the project’s own FUNCTIONs, those with effects too

  • an in-out, a VAR_EXTERNAL written, an output taken: with EN = FALSE the function is not called, so its in-outs and globals stay as they were and its outputs are not stored; ENO is not cleared by a flag from storing an output (into a subrange, clamped), only by one the function raised. The harness case fneffen runs each, with values worked by hand.

A call with EN inside another’s arguments is one of its inputs: not evaluated when the outer EN is FALSE, so nothing of it runs; with its own EN FALSE the statement is left undone, the outer call having no input to work on. An inner call’s flag is an argument’s to the outer: ADD(IN1 := ADD(IN1 := t, IN2 := 1, ENO => e2), IN2 := 0, ENO => e1) with t 32767 clears e2 and leaves e1 TRUE. A FUNCTION giving an array or a structure takes EN too, a := Make(EN := go, x := 4); with EN FALSE the store is left undone. A statement left undone faults on nothing it computes after, as one whose conversion failed (Conversions). The harness case nesten runs these.

ExpressionTypeResultFlagNote
ADD(EN := TRUE, IN1 := ADD(EN := TRUE, IN1 := INT#1, IN2 := INT#2), IN2 := INT#3)INT6—nested
ADD(EN := TRUE, IN1 := ADD(EN := FALSE, IN1 := INT#1, IN2 := INT#2), IN2 := INT#3)INTunchanged—the inner’s EN FALSE undoes the statement

On a function block call

t(EN := c, IN := x, PT := T#1s, Q => q, ENO => ok); follows the same rules (decided 2026-11-08):

  • EN = FALSE: the block is not called. Its inputs are neither evaluated nor set in the instance, its body does not run — a timer or counter stays as it was — its outputs are not taken (q keeps its value), and ok is FALSE. Nothing is flagged.
  • ENO is FALSE when a flag is raised while the block runs, in its body or anything it calls; not one from computing its inputs or from storing its outputs.
  • A block that declares EN or ENO itself gets them as plain parameters, and its body decides.

The harness’s fbenable and fbskip cases run these. ENO is taken into a BOOL variable, an element, a field or a bit of a bit string (ENO => ok[i], NOT ENO => w.3), found - its index checked - before the block runs, as an output’s target is (the harness case enointo).

On a SPLIT

SPLIT_DATE(EN := run, IN := days[i], YEAR => y, MONTH => m, DAY => d, ENO => ok);
  • EN = FALSE: nothing is split. IN is not evaluated - days[i] with i out of bounds does not fault - every output keeps its value, and ok is FALSE.
  • ENO is FALSE when a part does not fit its output - the year into a USINT - besides being wrapped and flagged CONVERSION. Unlike a block’s output, a SPLIT’s output has no type of its own: the call picks it, so the narrowing is the call’s own work (decided 2026-11-08, florin; dependencies).

The harness’s splitenable case runs these.

Set in a POU’s own body

Every FUNCTION and FUNCTION_BLOCK has ENO without declaring it: TRUE as each run starts, its body may read it and set it (decided 2026-11-08). The caller’s ENO is FALSE when the body set it FALSE, or a flag was raised while it ran — ENO := TRUE cannot hide an overflow.

FUNCTION half : INT
VAR_INPUT x : INT; END_VAR
half := x / 2;
ENO := x >= 0;          (* half(x := -4, ENO => ok): ok is FALSE *)
END_FUNCTION

A PROGRAM has no ENO. The harness’s enobody case runs these.

TIME and LTIME

A TIME is a signed 32-bit count of milliseconds, ±24d20h31m23s647ms; an LTIME a signed 64-bit count of nanoseconds, ±292 years. Both may be negative (Ed.3 Table 8: T#-14ms). A TIME literal must be whole milliseconds; an LTIME literal whole nanoseconds.

Adding and subtracting

Exact; a result outside the type wraps and is flagged OVERFLOW, as an integer’s does. So is a negation, -t: the lowest TIME has no positive.

ExpressionTypeResultFlagNote
T#1s + T#500msTIMET#1s500ms—
T#1s - T#1500msTIMET#-500ms—negative durations exist
T#24d20h31m23s647ms + T#1msTIMET#-24d20h31m23s648msflag OVERFLOW
LTIME#1s + LTIME#1nsLTIMELTIME#1s1ns—
-(T#1s500ms)TIMET#-1s500ms—a negation
-(LTIME#2ns)LTIMELTIME#-2ns—
-(T#-24d20h31m23s648ms)TIMET#-24d20h31m23s648msflag OVERFLOWits negation is past the top

TIME and LTIME, one made the other

A TIME becomes an LTIME without a conversion function (Ed.3 Figure 11), exactly. LTIME_TO_TIME drops what is below a millisecond, rounding down, unflagged; an LTIME beyond TIME gives T#0s, flagged CONVERSION (decided 2026-11-08, florin; LDATE, LTOD and LDT).

ExpressionTypeResultFlagNote
T#-1500ms + LTIME#0sLTIMELTIME#-1500ms—widened
LTIME_TO_TIME(LTIME#1ms999us)TIMET#1ms—rounding down
LTIME_TO_TIME(LTIME#-1500us)TIMET#-2ms—down, not toward zero
LTIME_TO_TIME(LTIME#25d)TIMET#0sflag CONVERSIONbeyond TIME

Times and divided by a number

Ed.3 Table 30 multiplies and divides a duration by any number (MUL_TIME, DIV_TIME, or * and /); a number may also stand first in *. How the result becomes whole milliseconds is the implementer’s (Implementer choices):

  • by an integer, exact; an overflow wraps and is flagged; a division goes toward zero, as DINT’s;
  • by a REAL or LREAL, computed in LREAL and rounded to whole milliseconds, half to even, as REAL_TO_DINT rounds; a result outside TIME, or infinite, or NaN is flagged CONVERSION and the statement left undone, as any real made an integer;
  • by zero, 0 or 0.0, a fault.

Panasonic and PLCnext round the factor to a whole number first, so on them T#1s * 2.5 is 2 or 3 seconds; in iecf it is 2.5.

ExpressionTypeResultFlagNote
T#1s * 3TIMET#3s—
3 * T#1sTIMET#3s—either order
T#10s / 3TIMET#3s333ms—toward zero
T#-7ms / 2TIMET#-3ms—toward zero
T#1s * REAL#2.5TIMET#2s500ms—
T#3ms * REAL#1.5TIMET#4ms—4.5: half to even
T#5ms * REAL#1.5TIMET#8ms—7.5: half to even
T#1s / REAL#3.0TIMET#333ms—333.33
T#1s * REAL#1.0E10TIMEunchangedflag CONVERSIONbeyond TIME: left undone
T#1s / 0TIME—fault DIVISION_BY_ZERO
T#1s / REAL#0.0TIME—fault DIVISION_BY_ZERO
T#24d * 2TIMET#-1d17h2m47s296msflag OVERFLOWwrapped
MUL_TIME(T#2s, 2)TIMET#4s—
DIV_TIME(T#1m, 4)TIMET#15s—
LTIME#1us * 1500LTIMELTIME#1ms500us—

matiec holds nanoseconds in a TIME, so its T#10s / 3 is 3.333333333 s; iecf’s TIME counts milliseconds, as every vendor’s does.

DATE, TOD and DT

TypeHeld asRange
DATEseconds since 1970-01-01, at midnight; unsigned 32-bitD#1970-01-01 .. D#2106-02-07
TODmilliseconds since midnight; unsigned 32-bitTOD#00:00:00 .. TOD#23:59:59.999
DTseconds since 1970-01-01 00:00; unsigned 32-bitDT#1970-01-01-00:00:00 .. DT#2106-02-07-06:28:15

CODESYS and TwinCAT hold them the same way. The default initial value of a DATE and a DT is 1970-01-01: Ed.3 Table 10 leaves it to an implementer whose calendar does not start at 0001-01-01. A literal that names no day or time, or one iecf cannot hold — before 1970, a TOD finer than a millisecond, a DT with a fraction of a second — is refused (E0020).

At the edges

(Implementer choices)

  • A TOD wraps at midnight, unflagged: a time of day is a clock.
  • A DATE or DT outside its range wraps and is flagged OVERFLOW, as an integer does; so does a difference outside TIME. A TIME holds ±24 days and 20 hours: the difference of two dates 25 days apart is already too much for it.
  • A DT holds whole seconds: milliseconds that come to one are dropped, rounding down, unflagged.
ExpressionTypeResultFlagNote
TOD#23:00:00 + T#2hTODTOD#01:00:00—wraps at midnight
TOD#01:00:00 - T#2hTODTOD#23:00:00—
TOD#01:00:00 - TOD#23:00:00TIMET#-22h—
DT#2026-11-07-22:00:00 + T#7hDTDT#2026-11-08-05:00:00—
DT#2026-11-07-12:00:00 + T#1500msDTDT#2026-11-07-12:00:01—ms dropped, rounding down
DT#2026-11-07-12:00:00 - T#1500msDTDT#2026-11-07-11:59:58—down, not toward zero
DT#1970-01-01-00:00:00 - T#1sDTDT#2106-02-07-06:28:15flag OVERFLOWwrapped
D#2026-12-01 - D#2026-11-07TIMET#24d—
D#2026-12-07 - D#2026-11-07TIMET#-19d17h2m47s296msflag OVERFLOW30 days: beyond TIME
SUB_DT_DT(DT#2026-11-07-12:00:00, DT#2026-11-07-11:00:00)TIMET#1h—
DT_TO_TOD(DT#2026-11-07-12:34:56)TODTOD#12:34:56—
DT_TO_DATE(DT#2026-11-07-12:34:56)DATED#2026-11-07—
CONCAT_DATE_TOD(D#2026-11-07, TOD#12:34:56.789)DTDT#2026-11-07-12:34:56—ms dropped
DAY_OF_WEEK(D#2026-11-07)USINT6—0 is Sunday (Ed.3 Table 36)
DAY_OF_WEEK(D#1970-01-01)USINT4—a Thursday
MAX(D#2026-11-07, D#2025-01-01)DATED#2026-11-07—

From parts and into them

CONCAT_DATE, CONCAT_TOD and CONCAT_DT (Ed.3 Table 36) take integers of any type. A part out of its range gives the type’s default value, flagged CONVERSION: a month not 1 to 12, a day not in its month, an hour not 0 to 23, a minute or second not 0 to 59, a millisecond not 0 to 999, or a date outside the type’s range. CODESYS’s DateConcat and PLCnext’s CONCAT_LDATE give the same (Implementer choices). A DT’s millisecond is checked, then dropped.

ExpressionTypeResultFlagNote
CONCAT_DATE(2026, 11, 8)DATED#2026-11-08—
CONCAT_DATE(2024, 2, 29)DATED#2024-02-29—a leap year
CONCAT_DATE(2023, 2, 29)DATED#1970-01-01flag CONVERSIONno such day
CONCAT_DATE(2026, 13, 1)DATED#1970-01-01flag CONVERSION
CONCAT_DATE(1969, 12, 31)DATED#1970-01-01flag CONVERSIONbefore the range
CONCAT_TOD(12, 34, 56, 789)TODTOD#12:34:56.789—
CONCAT_TOD(24, 0, 0, 0)TODTOD#00:00:00flag CONVERSION
CONCAT_TOD(12, 0, 0, 1000)TODTOD#00:00:00flag CONVERSION
CONCAT_DT(2026, 11, 8, 12, 34, 56, 789)DTDT#2026-11-08-12:34:56—ms dropped
CONCAT_DT(2106, 2, 7, 6, 28, 16, 0)DTDT#1970-01-01-00:00:00flag CONVERSIONpast the range

SPLIT_DATE, SPLIT_TOD and SPLIT_DT are statements of their own: each output takes any integer variable, and a part that does not fit it - the year in a USINT - wraps and is flagged CONVERSION, as any integer conversion does; one stored into a subrange is clamped and flagged, as any store into it. An output element out of bounds faults before any part is stored. With EN FALSE, IN is not evaluated and every output keeps its value; ENO is FALSE when EN is, or when a part did not fit its output (EN and ENO). Inside another call’s arguments they are not compiled yet (E0009). The harness’s splitwrap, splitclamp, splitindex and splitenable cases run each of these.

SPLIT_DT(IN := stamp, YEAR => y, MONTH => m, DAY => d, HOUR => h);

LDATE, LTOD and LDT

TypeHeld asRange
LDATEnanoseconds since 1970-01-01, at midnight; signed 64-bitLD#1677-09-22 .. LD#2262-04-11
LTODnanoseconds since midnight; signed 64-bitLTOD#00:00:00 .. LTOD#23:59:59.999999999
LDTnanoseconds since 1970-01-01 00:00; signed 64-bitLDT#1677-09-21-00:12:43.145224192 .. LDT#2262-04-11-23:47:16.854775807

Ed.3 Table 10 makes each a signed 64-bit count of nanoseconds; the range is the whole of it, as CODESYS, TwinCAT and PLCnext give it (decided 2026-11-08, florin; implementer choices). A date before 1970 is a negative count. A literal takes up to nine digits of a second; one that names no day or time, or that the type cannot hold, is refused (E0020).

At the edges

  • An LTOD wraps at midnight, unflagged, as a TOD does.
  • An LDT outside its range wraps and is flagged OVERFLOW, as a DT does; so does a difference outside LTIME.
  • A long value made a short one drops what the short type is too coarse for, rounding down, unflagged: LDT_TO_DT the fraction of a second, LTOD_TO_TOD and LTIME_TO_TIME what is below a millisecond. Before 1970 down is earlier: LDT_TO_LDATE of the last nanosecond of 1969-12-31 is 1969-12-31.
  • A long value the short type cannot hold gives the short type’s default value, flagged CONVERSION: an LDT before 1970 or after 2106-02-07 made a DT or DATE is 1970-01-01, an LTIME beyond ±24d20h31m23s647ms made a TIME is T#0s. The task goes on. Ed.3 Table 22 leaves this result to the implementer.
  • DT, DATE, TOD and TIME widen to the long types without a conversion function (Ed.3 Figure 11), exactly. With one long operand the other is widened (Ed.3 6.6.1.6, rule 7): DT + LTIME is an LDT, never a DT, which could not hold the LTIME’s range.
ExpressionTypeResultFlagNote
LTOD#23:00:00 + LTIME#2hLTODLTOD#01:00:00—wraps at midnight
LTOD#01:00:00 - LTIME#2h1nsLTODLTOD#22:59:59.999999999—
LTOD#01:00:00 - LTOD#23:00:00LTIMELTIME#-22h—
LDT#2026-11-08-22:00:00 + LTIME#7hLDTLDT#2026-11-09-05:00:00—
LDT#1970-01-01-00:00:00 - LTIME#1nsLDTLDT#1969-12-31-23:59:59.999999999—before 1970
LDT#2262-04-11-23:47:16.854775807 + LTIME#1nsLDTLDT#1677-09-21-00:12:43.145224192flag OVERFLOWwrapped
LDT#2026-11-08-12:00:00 + T#1500msLDTLDT#2026-11-08-12:00:01.5—a TIME widened
DT#2026-11-08-12:00:00 + LTIME#1500msLDTLDT#2026-11-08-12:00:01.5—the DT widened
LDT#2026-11-08-12:00:01.5 - DT#2026-11-08-12:00:00LTIMELTIME#1500ms—
TOD#10:00:00 + LTIME#1500msLTODLTOD#10:00:01.5—
LD#2026-11-09 - D#2026-11-08LTIMELTIME#1d—
LD#2026-12-08 - LD#2026-11-08LTIMELTIME#30d—
SUB_LDT_LDT(LDT#2262-04-11-00:00:00, LDT#1677-09-22-00:00:00)LTIMELTIME#-1d23h34m33s709ms551us616nsflag OVERFLOWbeyond LTIME: wrapped
DT_TO_LDT(DT#2106-02-07-06:28:15)LDTLDT#2106-02-07-06:28:15—
LDT_TO_DT(LDT#2026-11-08-12:00:00.999999999)DTDT#2026-11-08-12:00:00—rounding down
LDT_TO_DT(LDT#1969-12-31-23:59:59)DTDT#1970-01-01-00:00:00flag CONVERSIONbefore DT’s range
LDT_TO_DATE(LDT#2106-02-08-00:00:00)DATED#1970-01-01flag CONVERSIONafter it
LDATE_TO_DATE(LD#2026-11-08)DATED#2026-11-08—
LDT_TO_LDATE(LDT#1969-12-31-23:59:59.999999999)LDATELD#1969-12-31—down is earlier
LDT_TO_LTOD(LDT#1969-12-31-23:59:59.999999999)LTODLTOD#23:59:59.999999999—
LDT_TO_TOD(LDT#2026-11-08-12:34:56.789999999)TODTOD#12:34:56.789—
LTOD_TO_TOD(LTOD#23:59:59.999999999)TODTOD#23:59:59.999—
DT_TO_LTOD(DT#2026-11-08-12:34:56)LTODLTOD#12:34:56—
CONCAT_DATE_LTOD(D#2026-11-08, LTOD#12:34:56.000000001)LDTLDT#2026-11-08-12:34:56.000000001—
CONCAT_LDT(1677, 9, 21, 0, 12, 43, 146)LDTLDT#1677-09-21-00:12:43.146—
CONCAT_LDT(1677, 9, 21, 0, 12, 43, 145)LDTLDT#1970-01-01-00:00:00flag CONVERSIONbefore the range
CONCAT_LTOD(23, 59, 59, 999)LTODLTOD#23:59:59.999—
MAX(LD#1969-12-31, LD#1677-09-22)LDATELD#1969-12-31—

SPLIT_LTOD and SPLIT_LDT give milliseconds, the rest dropped rounding down, as SPLIT_DT’s do (DATE, TOD and DT); the harness’s ldates case runs them, with the other conversions, tick by tick.

Arrays, subranges and enumerations

An index outside its array faults

Every index that is not a constant is checked where it is used. One outside the array’s bounds is a fault INDEX: carrying on would read or write memory that is not the array’s. A constant index outside the bounds is refused when the program is built.

The C reads element 0 on its way out, so no memory outside the array is ever touched, even for the one statement that faults. Rockwell’s Logix also makes an out-of-range subscript a major fault; CODESYS and TwinCAT leave it unchecked unless the project adds CheckBounds, whose template clamps the index instead.

Bounds are constants

An array’s bounds, a STRING’s length and a subrange’s limits are fixed when the program is built (Ed.3 6.4.4.5.1): literals, + - * / MOD, and the names of constants (Ed.3 6.6.1.2.4), E0032 for anything else. A constant is a POU’s own VAR CONSTANT, or a VAR_GLOBAL CONSTANT through its VAR_EXTERNAL CONSTANT; a TYPE sees the global constants directly, as a CODESYS GVL’s VAR_GLOBAL CONSTANT are (CODESYS refuses a plain VAR_GLOBAL there, C0161):

TYPE ROW : ARRAY [0..W] OF SINT; END_TYPE     (* W a VAR_GLOBAL CONSTANT *)
PROGRAM main
    VAR CONSTANT N : INT := 5; END_VAR
    VAR
        a : ARRAY [1..N] OF INT;
        s : STRING[N];
        lim : INT (0..N) := N;                (* an initial value too *)
    END_VAR
END_PROGRAM

The bounds are within DINT, as CODESYS has them, since LOWER_BOUND and UPPER_BOUND give a DINT; the lower one is not above the upper one.

Initial values are constant expressions

An initial value is a literal or a constant expression (Ed.3 6.4.4.1, Figure 6; 6.6.1.2.4): literals, the names of constants, enumerated and named values, the operators, and the standard functions on them. The compiler works it out, so nothing of it is left to the run - no flag and no fault as the program starts:

VAR CONSTANT
    N  : INT := 3;
    N2 : INT := N + 1;                    (* 4: a constant of constants *)
    KR : REAL := 1.5;
    KT : TIME := T#2s;
    KM : MODE := run;
END_VAR
VAR
    a   : INT := N * 2 + 1;               (* 7 *)
    r   : REAL := KR * 2.0;               (* 3.0 *)
    t   : TIME := KT + T#500ms;           (* T#2s500ms *)
    t2  : TIME := KT * 1.5;               (* T#3s *)
    b   : BOOL := NOT (N > 2);            (* FALSE *)
    w   : BYTE := 16#0F AND 16#3C;        (* 16#0C *)
    w2  : WORD := SHL(16#0001, N);        (* 16#0008 *)
    m   : MODE := KM;                     (* run *)
    x   : INT := MAX(N, ABS(-5));         (* 5 *)
    i   : INT := REAL_TO_INT(KR);         (* 2: to the even one *)
    q   : REAL := SQRT(2.0) * KR;         (* the target's SQRT *)
    arr : ARRAY [1..N] OF INT := [N, 2(N2)];
    p   : PT := (x := N2, y := KR);
END_VAR
  • The constants are those a bound names (above). A method also names its type’s constants, a derived type its bases’; a TYPE’s initial value and a VAR_GLOBAL’s name the VAR_GLOBAL CONSTANTs directly. A constant may be declared below where it is named.
  • A string, an array or a structure given a constant’s name starts as that constant’s own initial value.
  • A part of a constant array or structure is a constant: KA[2], KP.x, KPS[N].y, the index a constant expression itself. It is the part’s initial value: the one the constant’s lists, else its type’s. A whole part goes where its type does (p : PT := KPS[2]). An index outside the array is E0032, as it would fault when the program runs. CODESYS and TwinCAT take a constant’s element there too.
  • An integer expression is worked out exactly, not in its operands’ type, as a bound is: d : DINT := N2 * 10000 is 40000 though N2 is an INT. A value its variable’s type does not hold is E0020, and so is one a conversion’s type does not: INT_TO_SINT(300), REAL_TO_INT(1.0E10). REAL is worked out in single precision, LREAL in double, as the run would; an infinite result or one that is no number is E0020 too.
  • The functions worked out are those whose result is exact: the operators’ own (ADD, GT, …), ABS, MIN, MAX, LIMIT, SEL, MUX, MOVE, the shifts and rotations, the conversions between numbers and TRUNC, and a duration times or over a number.
  • The mathematics of Table 28 and ** - SQRT, LN, LOG, EXP, SIN, COS, TAN, ASIN, ACOS, ATAN, EXPT - are the target’s own, which may differ from the compiler’s in the last digit. The compiler works the expression out only to tell that the result is a number its type holds, and the target computes it as the variable is set up. A result that is infinite or no number (SQRT(-1.0), LN(0.0)) is E0020, and so is one within a factor of four of the type’s largest value, where the last digit could decide.
  • A division by zero, a shift by a negative count, a MUX selecting an input it has not, an index outside a constant array, and a constant whose initial value names itself, have no value: E0032. MOD by zero is 0, as when the program runs (Integers).
  • A variable, or the result of a FUNCTION of the program’s, is no constant: E0032. CODESYS and TwinCAT take any expression there and compute it as the application starts; Ed.3 has a constant expression, and so has iecf.
  • An input, a block’s or a FUNCTION’s, not given at a call takes its initial value the same way, and so does a FUNCTION’s own variable at each call.

Not compiled yet (E0009) in an initial value: a function of strings or of dates (LEN('abc'), CONCAT_DATE_TOD), a conversion to or from text, and what depends on the last digit of the target’s mathematics - an integer converted from it (REAL_TO_INT(SQRT(X))), a division by it. The harness cases constinit and constfns run the rest.

Arrays of any size: ARRAY[*]

A parameter declared ARRAY [*] OF INT, or ARRAY [*, *] OF INT, takes an array of any bounds with as many dimensions and the same element type (Ed.3 6.5.3); LOWER_BOUND(a, d) and UPPER_BOUND(a, d) give dimension d’s bounds, a DINT (Table 15). This is Ed.3’s own Example 2:

FUNCTION SUM : DINT
    VAR_IN_OUT A : ARRAY [*] OF INT; END_VAR
    VAR i, s : DINT; END_VAR
    FOR i := LOWER_BOUND(A, 1) TO UPPER_BOUND(A, 1) DO
        s := s + A[i];
    END_FOR;
    SUM := s;
END_FUNCTION

It is an in-out of a FUNCTION, a method or a FUNCTION_BLOCK, as in CODESYS and TwinCAT, and is passed on to another’s (the harness case flexarrays). It is also a FUNCTION’s or method’s input or output, as Ed.3 allows and Siemens has it for an FC (the harness case flexio):

FUNCTION TOTAL : DINT
    VAR_INPUT a : ARRAY [*] OF INT; END_VAR
    VAR i : DINT; END_VAR
    FOR i := LOWER_BOUND(a, 1) TO UPPER_BOUND(a, 1) DO
        TOTAL := TOTAL + a[i];
    END_FOR;
END_FUNCTION

FUNCTION CLEAR_TO
    VAR_INPUT v : INT; END_VAR
    VAR_OUTPUT a : ARRAY [*] OF INT; END_VAR
    VAR i : DINT; END_VAR
    FOR i := LOWER_BOUND(a, 1) TO UPPER_BOUND(a, 1) DO
        a[i] := v;
    END_FOR;
END_FUNCTION

called TOTAL(x), TOTAL(a := x), CLEAR_TO(v := 7, a => x) or CLEAR_TO(7, x). Such an input or output is the caller’s array itself, not a copy of it - the function cannot hold an array whose size it does not know - and what follows from that is:

  • the input is read, never written: an assignment to it or to an element, or giving it to an in-out or an output, is E0015. It is given on to another ARRAY[*] input;
  • the output’s elements are set to their type’s initial value as the call starts, as any output of a FUNCTION is; what the function then writes is in the caller’s array at once, so a call that faults has already changed it, where an output of fixed bounds is only stored when the call returns;
  • the call gives each one an array: there is no initial value to stand in for an input left out, and no place for an output not taken (E0016);
  • one array given as the input and as the output of the same call is set to its initial value before it is read.

A FUNCTION_BLOCK’s input or output is kept in the instance, so an ARRAY[*] is only its in-out. Anywhere else an ARRAY[*] is E0033.

A whole ARRAY[*] is assigned from another, or from an array of fixed bounds, of as many dimensions and the same element type: dst := src;. The two must have as many elements in each dimension, which only the run knows: when they differ it is a fault INDEX and nothing is copied. The bounds themselves need not match, 1..3 copies onto -1..1. An ARRAY[*] is not assigned to an array of fixed bounds, nor given to a parameter of fixed bounds (E0013), as Siemens has it (the harness case flexcopy).

An index into one is checked against the bounds it was given, a fault INDEX outside them, as for any array. So is LOWER_BOUND(a, d) with a d outside the array’s dimensions, which Ed.3 calls an error: a fault INDEX when d is computed, refused when it is a constant. LOWER_BOUND and UPPER_BOUND take an array of fixed bounds too, as Ed.3’s Example 1 does.

Ed.3 Example 2 also gives SUM(A2[2]), one row of a two-dimensional ARRAY [1..20, -2..2]. An array indexed in its first dimensions only is the array of the others; so is an element of an array of arrays, by its declaration:

VAR
    a2 : ARRAY [1..2, 0..2] OF INT := [1, 2, 3, 4, 5, 6];
    aa : ARRAY [1..2] OF ARRAY [0..2] OF INT := [[1, 2, 3], [4, 5, 6]];
    row : ARRAY [0..2] OF INT;
END_VAR
total := SUM(a2[2]);        (* 15: the row 4, 5, 6 *)
total := SUM(aa[2]);        (* 15 *)
total := SUM(a2[i]);        (* the row i; i checked, a fault INDEX outside 1..2 *)
row := a2[1];               (* a row copied: 1, 2, 3 *)
x := a2[2][0];              (* 4, as a2[2, 0] *)
x := aa[2][0];              (* 4 *)

A row is an array like any other: given to an ARRAY[*] or to a parameter of its bounds, or assigned. An ARRAY[*, *] indexed once is an ARRAY[*], to pass on. More indices than dimensions is E0014.

The harness case flexarrays runs these.

A value outside a subrange is clamped, and flagged

A value stored into a subrange variable becomes the bound it passed (INT (0..100) given 150 holds 100) and is flagged CONVERSION. It holds for every store: an assignment, an element or field of the type, a function block’s input, an output taken into one, and an in-out given one: the function or block works on a copy, which is stored back - clamped - when the call returns (the harness case inoutcopy). A FOR over a subrange starts at its start clamped and stops at the bound it would pass. A constant initial value outside the subrange is refused when the program is built.

A subrange variable starts at its lower limit (Ed.3 6.4.4.4.2): x : INT (2..5) starts as 2, an element of that type too, unless the type or the declaration gives an initial value.

A subrange is a FUNCTION’s or a method’s input, output, in-out and result, and a block’s in-out, as it is a block’s input and output:

  • an input given a value outside it is clamped and flagged as the function starts, where the input is declared (as a STRING input too long is cut there); not given, it is its initial value;
  • an output, and the result, are clamped as the body stores into them; a result the body does not set is the lower limit;
  • an in-out is the caller’s variable: a store through it is clamped to the in-out’s own bounds, whatever the caller’s variable is.

The harness case subparams runs each.

CODESYS and TwinCAT do the same when the project adds the CheckRangeSigned and CheckRangeUnsigned functions, and nothing without them; iecf always checks, and the platform sees it.

ExpressionTypeResultFlagNote
INT#50INT(0..100)50—
INT#150INT(0..100)100flag CONVERSIONthe bound it passed
INT#-5INT(0..100)0flag CONVERSION
INT#100INT(0..100)100—the bound itself is inside

Enumerations

An enumerated value is held as an integer, the values numbered from 0 in their order, in an INT (as CODESYS and TwinCAT hold one), so it has at most 32768 of them (E0020 at the next). Only the type’s own values can be stored in it (Ed.3 6.4.4.2); there is nothing to check at run time. A variable starts as the type’s initial value, the first of its values unless the declaration gives one. A value two enumerations have is written Type#value (E0010 otherwise).

TYPE
    mode : (idle, run, stop) := run;
END_TYPE
VAR
    m  : mode;                      (* run *)
    mi : (low, mid, top) := mid;    (* declared in place *)
END_VAR

An enumeration is also a FUNCTION’s or method’s input, output, in-out and result and a block’s input, output and in-out; an input not given and a result not set are the type’s initial value.

Types with named values

A declaration that gives its values numbers, or names a base type, is a type with named values (Ed.3 6.4.4.3). Its variables are used as the base type is: any value of it assigned, arithmetic, comparison with a number; the names are constants of that type, a CASE’s labels among them.

TYPE
    col : INT (red := 1, green := 5, blue) := green;   (* blue is 6 *)
    lv  : WORD (lo := 16#10, hi := 16#20);
END_TYPE
VAR c : col; x : INT; END_VAR                          (* c is green, 5 *)
c := 27;
c := red + 1;
x := c + col#blue;
CASE c OF
    red:              x := 1;
    green - 3, blue:  x := 2;
END_CASE;

The base type is an integer or a bit string type (but BOOL), INT when the declaration names none, as in CODESYS and TwinCAT: (slow := 2, fast := 4) is added to an INT as it is, and a value an INT cannot hold needs the type named, DINT (far := 100000). A value is a constant expression the base type holds (E0020 otherwise); one not given is one more than the value before it, from 0, as CODESYS and TwinCAT count. A variable of the base type is given to an in-out of the type. A variable’s own declaration may be one, x : INT (lo := 1, hi := 5) := hi;: its names are known in the POU, its numbers may name the POU’s constants, and where two such declarations share a name it is used without a type’s name as long as they give it the same number and base type (fbargs). The harness case enums runs all of this.

Arrays and structures given to a FUNCTION

An array or a structure is also a FUNCTION’s or method’s input, output, in-out or result: an input copied in as the function starts, so the function never writes the caller’s; a result returned whole, xs := RAMP(start := 1), or given straight to another’s input (the harness case fnparams). A part of what a FUNCTION gives is read where it is called, Shift(p).x, Ramp(3)[i], Make(2).n for an instance (the harness case callparts): the call is made once, into a buffer of the statement’s, and the part is read from it; an index is checked as any other. An in-out of one is given any variable of its type: one at an address (AT %MW0), a part of it, or one reached through a reference, is given as a copy, read before the call and stored back as it returns, since the image holds no C object to point at; so is an array there given to an ARRAY[*]. Two in-outs of one call given the same variable at an address are two copies: the one stored back last is what stays (the harness case inoutdata). A FUNCTION_BLOCK’s in-out of an array, a structure or a STRING is too, set by each call: the block works on the caller’s variable (flexarrays).

References

A reference (REF_TO, Ed.3 6.4.4.10) holds where a variable is, or nothing: NULL, which every reference starts as unless its declaration gives REF(x). REF(x) takes a reference to x; r^ is the variable r references, read and written as x itself would be, and r^.f, r^[i] its parts; rf^(...) calls the function block instance it references. Two references to one type compare with = and <>, equal when they reference the same variable, and either compares with NULL.

VAR
    level : INT;
    r : REF_TO INT := REF(level);
END_VAR
r^ := r^ + 1;          (* level + 1 *)
IF r <> NULL THEN ...

A reference given, stored through, called through

A FUNCTION or a method may give a reference, Pick(w) : REF_TO INT, NULL when it sets none; Pick(TRUE)^ reads where it points, Pick(FALSE)^ := 5 writes there, PickB()^(i := 4) calls the instance it gives, each call made once and the reference checked before anything is stored. Through a reference to an instance, its input is set from outside, rt^.PT := T#3s, and the whole of it assigned, rt^ := t3, as at its own name. REF(i) of an interface variable, an element or a field of one, gives a REF_TO the interface; p^ := o sets the interface it references. The harness case refmore runs each.

A NULL reference dereferenced faults

r^ with r NULL is a fault NULL, as an index outside its array is: the statement stops before it stores anything, and the task stops (Results, flags and faults). Ed.3 calls it an error and leaves the check to the implementer (“at compile time, by the runtime system, or by the application program”). TwinCAT stops its runtime on an invalid pointer, unless the project’s CheckPointer function says otherwise; CODESYS offers the same hook and __ISVALIDREF. Test r <> NULL first where a reference may be NULL.

ExpressionTypeResultFlagNote
rz^INT0—z, through rz := REF(z)
rz^ + INT#5INT5—
rz^ OR TRUEBOOLTRUE—
nr^INT—fault NULLnr is NULL
nr^ + INT#1INT—fault NULLnothing is stored
CONCAT(rz^, 'ab')STRING'ab'—z is ''
rz^WSTRING""—
CONCAT(rz^, "a$263A")WSTRING"a$263A"—
CONCAT(nr^, "a")WSTRING—fault NULLnothing is stored

A reference into the process image

REF(x) of a variable at an address references it there: r^ reads and writes the bytes the variable has, as the variable does - little-endian, its type’s width, a BOOL at a bit that bit only, a BOOL in a whole byte 1 or 0 and any value but 0 TRUE, an array or structure its parts back to back (The C output §4). Where a reference points is known only as the program runs, so each r^ looks: in C memory, or in the image. A WSTRING there is its units back to back, each little-endian, at an odd byte too; through a reference it is copied out to be read, and a store into it - whole, or r^[i] - is written back whole, so units after its end stay as they were, as at its own name. A reference to a variable at an input address warns (W0005): what is written through it, the next inputs overwrite.

What REF() takes, and where a reference cannot be

REF(x) takes a variable, an element or a field that lives as long as the program and could be assigned: not a VAR_TEMP or any variable of a FUNCTION (gone when it returns, Ed.3), not EN or ENO, not a CONSTANT and not a function block’s output taken from outside (both could be written through the reference) - E0026.

A reference is not a VAR_IN_OUT (Ed.3), not RETAIN - after a restart it would reference nothing - and not at an address (E0027).

A reference takes only its own type: REF_TO ARRAY[0..9] OF INT not an array of other bounds, REF_TO INT not a subrange of INT (E0013).

REFERENCE TO (an extension)

With the reference-to extension (Implementer extensions), iecf takes CODESYS’s REFERENCE TO too: a reference used without ^, bound with REF=, tested with __ISVALIDREF.

VAR
    level : INT;
    r : REFERENCE TO INT;
END_VAR
r REF= level;
r := r + 1;            (* level + 1 *)
IF __ISVALIDREF(r) THEN ...
r REF= 0;              (* unbound *)

It starts unbound, and used unbound it is the same fault NULL as nr^:

ExpressionTypeResultFlagNote
__ISVALIDREF(ur)BOOLFALSE—ur unbound
urINT—fault NULL
ur + INT#1INT—fault NULLnothing is stored

Namespaces

A namespace (Ed.3 6.9) groups types, functions, function blocks and programs under a name, so that two libraries can each have a Motor. Everything declared outside one is in the global namespace, the standard functions and function blocks among it. A project without a NAMESPACE compiles as it always did.

The choices below are iecf’s where Ed.3 says nothing or contradicts itself; each is a decision of the proposal (D1 to D8), checked against the vendors there.

Declaring

NAMESPACE Lib.Shapes                   (* Lib, and Shapes inside it *)
    TYPE Corner : (Sharp, Round); END_TYPE
    FUNCTION area : INT
    VAR_INPUT w, h : INT; END_VAR
        area := w * h + offset();
    END_FUNCTION
    FUNCTION INTERNAL offset : INT     (* seen only inside Lib.Shapes *)
        offset := 100;
    END_FUNCTION
END_NAMESPACE
  • A namespace holds types, functions, function blocks, programs and namespaces. A PROGRAM may be in one (D1); its CONFIGURATION names it by its path, PROGRAM p WITH fast : Plant.Main;.
  • No variables: VAR_GLOBAL, a CONFIGURATION and VAR_CONFIG stay outside every NAMESPACE (E0005), and Lib.x is never a variable (E0028, D7).
  • NAMESPACE A.B is NAMESPACE A with NAMESPACE B inside. Declared again, in the same file or another, a namespace gets more elements (Table 65 NOTE); an element declared twice is E0012.
  • An element and a namespace of one name side by side - a FUNCTION_BLOCK Motor and a NAMESPACE Motor - is E0012 (D8): Motor.Start could be read two ways.
  • An element’s name may not be an elementary type’s (INT, E0018), but may be a standard function’s or function block’s: a namespace’s own TON hides the standard one inside it (6.9.2 Example 2).
  • INTERNAL before a function’s, function block’s or program’s name keeps it inside its namespace and the namespaces in that one; NAMESPACE INTERNAL N shows N’s elements only in the namespace one level above (Figure 29, E0028). Ed.3 lists types too, but gives them no place to write INTERNAL; iecf’s types are public.

Using

From outside, an element is named with its namespaces in front, Lib.Shapes.area(2, 3). USING Lib.Shapes; brings in its elements by their short names; not the namespaces inside it (6.9.4 Example 2):

WhereWrittenFor
at the top of a file, before its declarationsUSING Lib.Shapes;that file (D6)
after a namespace’s nameNAMESPACE Plant USING Lib.Shapes;that declaration of the namespace
after a POU’s name, a function’s result typeFUNCTION_BLOCK user USING Lib;that POU; a PROGRAM too (D5)

A USING names a namespace from where it is written outwards: inside Lib, USING Shapes; is Lib.Shapes.

Which one a name means

A short name, or the first part of a dotted one, is looked for from the inside out (D2, D3), as C#’s names are:

  1. the POU’s own variables, then what the POU’s USINGs bring in;
  2. the namespace the POU is in, then what its USINGs bring in;
  3. each namespace around that one, outwards, the same;
  4. the global namespace, then what the file’s USINGs bring in.

The first step that has the name decides. Within a step, a namespace’s own element comes before one a USING brings in. Two USINGs of one step that both bring in the name is E0029, only where it is used: name the one meant in full.

So inside a namespace Plant that has a namespace Lib of its own, Lib.twice is Plant.Lib.twice, and the top-level Lib cannot be reached by name. With the extension global-namespace, _. starts at the global namespace: _.Lib.twice, and _.TON for the standard timer where a namespace has its own (D4).

In the C and the symbol table

A namespace’s element is named in the C by its path, its parts joined by two underscores (B_LIB__SHAPES__AREA), and in <p>_pou_names, the symbol table and every message by its path with dots (The C output §2).

The harness cases namespaces (over two files) and globalns run each rule above with values worked by hand; the checker’s tests (crates/iecf-sema/src/namespaces.rs) give each error.

Classes and methods

Ed.3 (6.6.5 to 6.6.7) gives a CLASS, and a FUNCTION_BLOCK, methods: operations on an instance’s variables, called on the instance. iecf compiles them as the proposal says, its decisions D1 to D12 florin’s. This page is its three steps: methods and classes, inheritance with its dynamic binding, and interfaces.

A class

CLASS Counter
    VAR count : UINT; END_VAR              (* PROTECTED: no specifier *)
    VAR PUBLIC limit : UINT := 10; END_VAR (* read and written outside *)

    METHOD PUBLIC Up : UINT                (* a result, as a FUNCTION's *)
        VAR_INPUT by_ : UINT := 1; END_VAR
        VAR_OUTPUT full : BOOL; END_VAR
        IF count < limit THEN
            count := count + by_;
        END_IF;
        full := count >= limit;
        Up := count;
    END_METHOD

    METHOD PUBLIC Reset                    (* no result: a statement *)
        THIS.count := 0;
    END_METHOD
END_CLASS
  • A CLASS has variables and methods, no body: only VAR and VAR_EXTERNAL sections; its methods have the inputs and outputs. An instance is never called, only its methods are (6.6.5.2).
  • A method is a function on its instance: its inputs, outputs, in-outs and result as a FUNCTION’s, its VAR and VAR_TEMP temporary, set at each call (6.6.5.4.3). It reads and writes the instance’s variables by their names, or as THIS.count.
  • inst.m(...) calls a method of another instance, THIS.m(...) one of its own; a bare m() is a FUNCTION’s call, as Ed.3 says (D3). A method with a result goes in an expression or a statement; one without, in a statement only. Formal or in order, with EN and ENO, as a FUNCTION’s call.
  • Access (6.6.5.9, 6.6.5.10): PUBLIC anywhere; PROTECTED in the type and the types derived from it; PRIVATE in the type only; INTERNAL in its namespace. Without a specifier a method or VAR section is PROTECTED, as Ed.3 says (D1); CODESYS and TwinCAT make it PUBLIC, so code written for them needs PUBLIC written (E0030).
  • Specifiers come in any order: METHOD PUBLIC FINAL m or METHOD FINAL PUBLIC m (D2). They are words only there: a variable may still be named final. THIS and SUPER are reserved.
VAR c : Counter; n : UINT; full : BOOL; END_VAR

n := c.Up(by_ := 2, full => full);
c.limit := 20;
IF c.Up() > 15 THEN
    c.Reset();
END_IF;

A function block with methods

A FUNCTION_BLOCK may have methods too, written after its declarations and before its body (6.6.7.2). They reach its inputs, outputs, VARs and externals, not its VAR_TEMP, VAR_IN_OUT or R_EDGE and F_EDGE inputs (6.6.7.2.3). Its body may call them with THIS.m(). Its inputs may be set from outside before a call (lamp.sw := TRUE;), as Ed.3 Figure 20 shows.

Inheritance

CLASS CIRCLE
    METHOD PUBLIC PI : REAL
        PI := 3.0;
    END_METHOD
    METHOD PUBLIC CF : REAL
        VAR_INPUT d : REAL; END_VAR
        CF := THIS.PI() * d;           (* the instance's own PI *)
    END_METHOD
END_CLASS

CLASS CIRCLE2 EXTENDS CIRCLE
    METHOD PUBLIC OVERRIDE PI : REAL
        PI := 3.14;
    END_METHOD
END_CLASS
  • EXTENDS names one base (6.6.5.5.2): a CLASS extends a CLASS, a FUNCTION_BLOCK a FUNCTION_BLOCK or a CLASS. The derived type has every variable and method of its base but the PRIVATE ones, and declares none of the same names (6.6.5.5.5).
  • OVERRIDE replaces a base method, with the same result, inputs, outputs, in-outs and access (6.6.5.5.3). A method with a base method’s name and no OVERRIDE is an error (D6), which says to write it; CODESYS, which has no OVERRIDE, overrides silently. FINAL on a method: not overridden; on a type: not a base.
  • Dynamic binding (6.6.5.6): a call through THIS, a VAR_IN_OUT or a reference runs the method of the instance’s own type. Above, c2.CF(d := 100.0) of a CIRCLE2 is 314, its CF calling CIRCLE2’s PI; c1.CF(d := 100.0) of a CIRCLE is 300. A call on a named instance, and SUPER.m(), the base’s method, are bound when compiling.
  • A FUNCTION_BLOCK’s body is not inherited (6.6.7.2.9): a derived block runs its own, empty if it has none; SUPER() in it runs its base’s, once and not in a loop, never in a method. A block called through a base-typed in-out or reference runs the body of the instance’s type.
  • A derived instance stands where its base is wanted: given to a base-typed VAR_IN_OUT, of a block, FUNCTION or method, or referenced by a base-typed REF_TO. Its type may add inputs and outputs, which keep their values through a call by the base, not in-outs, which the base’s call would leave unbound (D7).
  • An ABSTRACT type has no instances and holds an ABSTRACT method, one with no statements (D12); the types derived from it implement each one or are ABSTRACT too (6.6.5.8). It is the type of a VAR_IN_OUT or a REF_TO, given an instance of a type derived from it.
  • A method called through a NULL reference is not called: the fault is NULL, and the statement stops, as r^ of one (D8).
FUNCTION_BLOCK BASE
    VAR_INPUT a : INT; END_VAR
    VAR_OUTPUT x : INT; END_VAR
    x := a + 1;
END_FUNCTION_BLOCK

FUNCTION_BLOCK DERIVED_1 EXTENDS BASE
    VAR_INPUT b : INT; END_VAR
    SUPER();                           (* BASE's body: x := a + 1 *)
    x := 3 * x + b;
END_FUNCTION_BLOCK

Interfaces

INTERFACE ROOM
    METHOD DAYTIME
    END_METHOD
    METHOD NIGHTTIME
    END_METHOD
END_INTERFACE

CLASS LIGHTROOM IMPLEMENTS ROOM
    VAR PUBLIC LIGHT : BOOL; END_VAR
    METHOD PUBLIC DAYTIME
        LIGHT := FALSE;
    END_METHOD
    METHOD PUBLIC NIGHTTIME
        LIGHT := TRUE;
    END_METHOD
END_CLASS

FUNCTION_BLOCK ROOM_CTRL
    VAR_INPUT RM : ROOM; night : BOOL; END_VAR
    IF RM = NULL THEN
        RETURN;
    END_IF;
    IF night THEN
        RM.NIGHTTIME();                (* the method of RM's instance *)
    ELSE
        RM.DAYTIME();
    END_IF;
END_FUNCTION_BLOCK
  • An INTERFACE (6.6.6.1) declares method prototypes only: a name, a result, inputs, outputs and in-outs; no variables, no body, no access specifier (always PUBLIC). It may EXTEND any number of interfaces (6.6.6.3), declaring none of their methods again.
  • A CLASS or FUNCTION_BLOCK IMPLEMENTS any number (6.6.6.4): it has each of their methods, its own or inherited, with the prototype’s result and parameters, PUBLIC or INTERNAL (D10); a type that leaves one out is ABSTRACT, and a type derived from it implements the rest. A derived type implements what its base does.
  • A variable of an interface references an instance (6.6.6.5): of a type that implements it, or NULL, its initial value. It is given an instance (r := room1;, ctrl(RM := THIS)), another variable of the interface or of one that extends it, or NULL. = and <> compare two: equal when they reference one instance, or are both NULL.
  • RM.NIGHTTIME() calls the method of the instance it references, of its own type: given a LIGHT2ROOM, which overrides NIGHTTIME, LIGHT2ROOM’s. Through a NULL interface the method is not called: the fault is NULL and the statement stops (D8), as a call through a NULL reference.
  • An interface is not an in-out (6.6.6.2, D9), and anything else a variable can be (6.6.6.5.1: “all cases where variables may be used”): an input, an output, a VAR, a VAR_GLOBAL, a FUNCTION’s or method’s variable or result, an array’s element, a structure’s field, holding the reference. Its initial value is NULL or an instance, r : ROOM := room1;, of the POU’s or a global one.
  • a ?= b, the assignment attempt (6.6.6.7), gives a what b references if it is of a type a can hold, NULL otherwise: an interface from another (d ?= r: the instance, if it implements DIMMED), a REF_TO a CLASS or FUNCTION_BLOCK from an interface (the instance, if it is of that type or one derived from it), and a REF_TO such a type from a REF_TO its base (a downcast) or a type derived from it.
VAR r : ROOM; d : DIMMED; rd : REF_TO DIMMER; END_VAR

r := dm;               (* a DIMMER, which IMPLEMENTS DIMMED EXTENDS ROOM *)
d ?= r;                (* the DIMMER: it implements DIMMED *)
rd ?= r;               (* REF(dm) *)
r := room1;
d ?= r;                (* NULL: a LIGHTROOM is no DIMMED *)

What an instance is not

An instance of a CLASS, or of a FUNCTION_BLOCK with methods, a base or a derived type, or an interface, is not copied with :=, is not an input or output, and is not a variable of a FUNCTION or method: it is passed as a VAR_IN_OUT (D11, E0031). A copy into a base-typed variable would cut the derived part away.

In C

A CLASS is a structure B_<NAME>_t with its _init, as a FUNCTION_BLOCK, without a body function. A method is M_<TYPE>__<METHOD>(ctx, self, ...), its parameters as a FUNCTION’s; inst.m(...) passes &inst (The C output). A fault in a method stops the statement that called it, which stores nothing; the fault record names it TYPE.METHOD. A derived type’s structure starts with its base’s; each instance in a hierarchy points to its type’s table of methods, from which a call through THIS, an in-out or a reference takes the function to run. A variable of an interface is the instance and its type’s table for the interface; each type that implements one lists its tables, where THIS, another interface and ?= find them.

An instance of a CLASS is an array’s element or a structure’s field as a block’s is, its methods called there, accs[i].Add(...), and given to an interface (Standard function blocks); what holds one is not copied nor an input or output (D11).

A method’s inputs, outputs, in-outs and result may be STRINGs, arrays and structures, as a FUNCTION’s (The C output §2); one with a STRING result is called through an interface or an override as any other.

A RETAIN interface, or anything retained that holds one, is an error (E0027), as a RETAIN reference is: after a new build the instance may be elsewhere. A PROGRAM’s input or output of an interface is connected in the CONFIGURATION as an elementary one is: main(r := gl, o => go) with a global instance or interface (Dependencies).

Standard function blocks

The bistables, edge detectors, counters and timers of Ed.3 Tables 43 to 46, with what the standard leaves to the implementer.

Inputs and outputs that are arrays, structures or strings

A FUNCTION_BLOCK’s VAR_INPUT or VAR_OUTPUT may be an array, a structure or a STRING. A call copies an input in, f(xs := a), and an output out, f(hist => b); each source is found or computed before any input is set, so a call whose arguments fault changes nothing. A STRING too long for the input or the variable it is taken into is cut and flagged STRING_LENGTH, as any string store. From outside, an output reads as the instance’s member, f.hist[0], f.text.

An in-out given, an output taken into, any variable

count(v := totals[i], twice => report.double);
count(v := ptr^, NOT neg => ok);

A function block’s VAR_IN_OUT is given any variable of its type, and an output is taken into any variable that holds it: an element, a field, one at an address, one through a reference. Where each is, is found before the block runs, so an index out of bounds or a NULL reference stops the statement first and the block does not run. A variable at an address, of a subrange or through a reference is given to an in-out as a copy, stored back as the block returns (Arrays); an output is widened, and clamped into a subrange, as any store (the harness cases fbargs, inoutcopy).

R_EDGE and F_EDGE inputs

VAR_INPUT clk : BOOL R_EDGE; (or F_EDGE) in a FUNCTION_BLOCK or a PROGRAM declares, implicitly, an R_TRIG (an F_TRIG) in the instance (Ed.3 6.6.3.2 rule 13): called with the input as the body starts, its Q is what the body reads as clk - TRUE in the call that sees the edge only. The trigger is the standard one, so an F_EDGE input first given FALSE is TRUE once (Table 44: M starts FALSE). From outside, e.clk is what was given. A method cannot read such an input (6.6.5.4 rule 3), and a FUNCTION or a method cannot declare one: it keeps nothing from one call to the next (E0016). The harness case edgeinputs runs a block’s and a program’s.

An input set from outside

An instance’s input may be set apart from a call, tm.PT := T#2s (Ed.3 6.6.7.2.2, Figure 20 a): its field, which the next call reads, of a standard block as of the program’s own, named directly, as an element, or through a reference. An output is the block’s alone (E0015).

Instances: initial values, arrays, structures, globals

TYPE Cooler : STRUCT
    temp : INT;
    cooling : TOF := (PT := T#100ms);       (* Ed.3 Table 11 7a *)
END_STRUCT; END_TYPE

VAR
    t : TON := (PT := T#500ms);
    c : Count := (inc := 2, limit := 100);   (* an input, a PUBLIC variable *)
    tons : ARRAY[1..50] OF TON := [50(PT := T#100ms)];   (* Table 11 5b *)
    line : ARRAY[1..4] OF Cooler;
END_VAR

tons[i](IN := start);
line[2].cooling(IN := hot);
  • An instance’s initial value names its inputs, outputs and PUBLIC variables (Ed.3 6.6.3.3, and 6.6.5.3 for a class); the others keep their type’s own. [50(PT := T#100ms)] repeats one, as Table 11 writes it; the grammar’s 50((PT := T#100ms)) is the same.
  • An instance is an array’s element or a structure’s field as any value is, and is called, read and given there: tons[i](...), tons[i].Q, line[2].cooling(...), a method accs[i].Add(...), an in-out or an interface given tons[i] (Figure 8 d, e). Its index is checked first: out of bounds is an INDEX fault, and the statement stops before anything runs. An element is an instance of exactly its type, so a call on it is bound statically.
  • A global instance, in the CONFIGURATION’s or a resource’s VAR_GLOBAL, is reached by VAR_EXTERNAL from a program or a block.
  • A restart keeps them as it keeps their instances.

An instance as a value: copied, and given as an input

VAR a, b : Count; line2 : ARRAY[1..4] OF Cooler; END_VAR

b := a;              (* every variable of a copied into b *)
line2 := line;       (* an array or structure that holds instances, too *)
total := Peek(a);    (* FUNCTION Peek : INT  VAR_INPUT c : Count; END_VAR *)
watch(c := a);       (* FUNCTION_BLOCK Watch  VAR_INPUT c : Count; END_VAR *)
  • b := a copies the whole instance (Ed.3 Table 72, 1d): its inputs, outputs and internal variables, and the instances it contains, as CODESYS and TwinCAT copy one. b then runs on from where a was, and the two are separate. The two are of one type (E0013 otherwise). A reference, an interface or an in-out in it is copied as it is: both then name the same variable.
  • An instance given as an input is a copy (Ed.3 Table 42, 9a), to a FUNCTION, a method or a FUNCTION_BLOCK. In the POU that takes it its variables are read, c.n, c.Q; it is not written (E0015) and not called (E0016), which Ed.3 6.6.3.4 rules 6 and 7 keep for an instance given as a VAR_IN_OUT. A call made to the copy would be lost with it. An input not given is an instance with its initial values. A function block keeps its copy from call to call, as any input, and watch.c := a; sets it apart from a call.
  • An instance given as a VAR_IN_OUT is the caller’s own (Ed.3 6.6.3.4 rules 6 and 7), of a standard block as of the program’s: a FUNCTION, a method or a FUNCTION_BLOCK calls it, tm(IN := go, PT := T#1s), reads its outputs and passes it on, and it is the caller’s timer that ran. It is given a variable, an array’s element or what a reference refers to, of the in-out’s type exactly (E0013 otherwise). The harness case stdinout runs a counter and a timer through each.
  • A FUNCTION_BLOCK’s output may be an instance, called by the block that owns it: watch.own.n reads it, watch(own => d) copies it out.
  • A FUNCTION’s or method’s output, result or own variable may be an instance. It is set up at each call and keeps nothing from one call to the next, as every variable of a FUNCTION (Ed.3 6.6.2.2 rule 10): a timer there never runs on. The function calls it, Make.inc := 2; Make(); for its result, and the caller gets a copy: e := Make(2);, Work(o => f);, or reads one variable of it, Make(2).n.
  • A structure or an array that holds instances is a value in all the same places: an input, an output, an in-out and a result of a FUNCTION, a method or a FUNCTION_BLOCK, an ARRAY[*] of instances, and a PROGRAM’s input or output connected to a global of its type. In an input, the instances it holds are read only, as above; its other parts are written as any input’s (the harness case holders).
  • Two instances are not compared (E0014): compare their variables.
  • An instance of a class, or of a block with methods, a base or an interface, is not copied nor an input or output at all (E0031), nor is an array or structure that holds one: see objects.

The harness case instvalues runs these.

Bistables and edges

SR is set-dominant, RS reset-dominant (Table 43). R_TRIG and F_TRIG see an edge between one call and the next: a rising edge at the first call counts when CLK is already TRUE, as the standard’s ST body has it (M starts FALSE).

Counters count over their type’s range

CTU and CTUD count up while CV is below PVmax, CTD and CTUD down while CV is above PVmin, and the standard leaves both to the implementer (Table 45). In iecf they are the limits of CV’s own type: a CTU (CV an INT) stops at 32767, a CTD at -32768, a CTD_UDINT at 0. A counter never wraps. Q compares CV with PV as the table says, whatever PV is.

Siemens counts the same way, to the range limit of the type. CODESYS and TwinCAT give CV the type WORD, so their CTD stops at 0; Rockwell’s CTUD wraps. matiec stops CTU at PV and CTD at 0, which the standard does not say.

CounterType of CVCounts up toCounts down to
CTU, CTD, CTUDINT32767-32768
CTU_DINT, CTD_DINT, CTUD_DINTDINT2147483647-2147483648
CTU_LINT, CTD_LINT, CTUD_LINTLINT2⁶³-1-2⁶³
CTU_UDINT, CTD_UDINT, CTUD_UDINTUDINT42949672950
CTU_ULINT, CTD_ULINT, CTUD_ULINTULINT2⁶⁴-10

Timers

TP, TON and TOF (Table 46) time against the task’s clock, ctx->now_ns, which the platform sets for each run of a task: a timer sees time pass between scans, not during one. ET counts from 0 to PT and stops there. A negative PT counts as 0: the timer’s output follows its input at once. The LTIME forms (TP_LTIME, TON_LTIME, TOF_LTIME) time in nanoseconds.

Starts and restarts: RETAIN

Ed.3 6.5.6 has two ways to start: a cold start, which sets every variable to its initial value, and a warm start, which keeps the retentive ones. The platform chooses: <p>_init is a cold start, <p>_warm a warm one (The C output §3 and §7).

What a warm start keeps

DeclaredCold startWarm start
VAR RETAIN, VAR_GLOBAL RETAINinitial valuekept
VAR NON_RETAINinitial valueinitial value
neitherinitial valueinitial value — Ed.3 leaves it to the implementer; CODESYS, TwinCAT and Siemens set it anew
VAR RETAIN x AT %MW0, at an addressinitial valuekept: not written; the platform keeps the image bytes

Function blocks

A function block’s own declarations decide for its variables; the section an instance is declared in decides for the rest (Ed.3 6.5.6.2):

Variable of the blockInstance in a plain sectionInstance in VAR RETAIN
in its VAR RETAINkeptkept
in its VAR NON_RETAINinitial valueinitial value
neitherinitial valuekept
a block inside itits own type and section decide, not the outer instancethe same

An instance of a block that keeps anything — or that contains a block that does — lives whole in the retained region, ctx->retain, as on CODESYS; at a warm start iecf sets the parts it does not keep.

An array or a structure of instances keeps as its instances do, each by the table above. In VAR RETAIN its other parts are kept too, and a standard block in it (a timer, a counter) keeps its state. A global instance follows its VAR_GLOBAL section the same way.

What the platform does

A RETAIN variable at an address lives in the process image, which is the platform’s: iecf writes its initial value at a cold start only, and at a warm start leaves the bytes as the platform hands them back (decided 2026-11-12, florin). The platform keeps those bytes of %M or %Q across the restart, as it keeps ctx->retain; the symbol table marks them IECF_SYM_RETAIN with their address. The harness case retainloc runs one of a program, a global and a block.

The platform keeps the bytes of ctx->retain across a restart — in a file, FRAM, flash — and hands them back before <p>_warm. Their size and place are sizeof(<p>_retain_t) and offsetof(<p>_t, retain); the symbol table marks each retained variable IECF_SYM_RETAIN, so a platform can also move them by name to a changed program.

SFC

A PROGRAM or FUNCTION_BLOCK may be a Sequential Function Chart (Ed.3 6.7): steps, transitions between them, and actions associated with the steps. iecf compiles the textual form — STEP, TRANSITION, ACTION — which is also what a graphical editor writes. A POU is either a chart or statements, not both.

One scan

Each time the POU runs, in this order:

  1. Step times. Each active step’s T is the time since it was entered, in whole milliseconds, from the task’s clock.
  2. Action control. Each action’s qualifier logic — the standard’s ACTION_CONTROL — is evaluated with the steps active now: its Q (the action flag) and whether it runs (A).
  3. Actions. A BOOL variable used as an action takes Q. Action bodies run in two passes, as CODESYS and TwinCAT run them: first those that are ending (their final scan, a P1 or P0 pulse), then those whose Q is TRUE, each pass in source order. An action that is ending cannot overwrite what an active one writes.
  4. Transitions. Against the steps active when the scan began: a transition whose preceding steps are all active, and whose condition is TRUE, is cleared — its preceding steps left, then its following steps entered.

A step entered in this scan runs its actions from the next one: every step’s actions run at least once before it can be left (“the duration of a step activity can never be considered to be zero”, Ed.3 6.7.5). CODESYS and TwinCAT work the same way; matiec tests the transitions first. (Implementer choices)

Steps

S.X is TRUE while step S is active, S.T the time since it was entered; when S is left, S.T keeps its last value, and entering it again starts it at T#0s. Neither can be assigned (Ed.3 6.7.2). S.T stops at TIME’s largest value, after 24 days. The initial step is active from the start; its time starts at the first scan.

Actions and qualifiers

An action runs under the qualifiers of its active associations (Ed.3 Table 59), with final scan (Table 60, feature 1): once its Q falls, it runs one more time with Q FALSE, so it can tidy up. Inside the action, ACTION.Q tells which: IF NOT Fill.Q THEN valve := FALSE;.

QualifierThe action’s QRuns
N (or none)while the step is activewhile Q, and one final scan
Sfrom the step’s start, stored after ituntil an R, then one final scan
Rresets a stored action (S, SD, DS, SL) and overrides N—
L tfrom the step’s start, for at most twhile Q, and one final scan
D tafter t, if the step is still activewhile Q, and one final scan
Pfor one scan, on entrytwice: with Q, then its final scan
P1neveronce, on entry, Q FALSE
P0neveronce, on leaving, Q FALSE
SD tt after the step’s start, storeduntil an R, then one final scan
DS tafter t if the step is still active, then storeduntil an R, then one final scan
SL tfrom the step’s start, stored, for at most twhile Q, and one final scan

P runs twice. The standard’s action control gives every qualifier but P1 and P0 its final scan, P too (Ed.3 6.7.4.6, NOTE 3), and CODESYS documents that its P action is “executed exactly two times”. For an action that should run once on entry — counting entries — use P1.

The final scan and P0 follow a falling edge that happened: the standard’s figure draws them with F_TRIG, which would also fire on the very first scan; no vendor does that, and iecf does not.

Transitions and their priority

Of the transitions leaving the same active step, the first true one is cleared: those with (PRIORITY := n) by the lowest n (Ed.3 Table 61, 2b), then in source order (2a, as the leftmost branch first). The others wait. Transitions leaving different steps clear together, so a simultaneous divergence TO (S2, S3) activates both branches, and a convergence FROM (S2b, S3) waits for every one of its steps.

Errors

Ed.3 asks the system to treat these as errors. iecf flags them SFC (code 11) and goes on:

  • An “unsafe” chart: a step entered while it is still active (Ed.3 Figure 26 a). It stays active, once; its time goes on.
  • More than one time-related association active for one action (6.7.4.6 d): the last one’s duration is used.
  • SD while the action’s SL is stored, or SL while its SD is (6.7.4.6 d).

A chart that can lock up (“unreachable”, Figure 26 b) is the program’s own logic; nothing at run time can tell it from one that waits.

Restarts

A cold start, and a warm one, set every chart to its initial step. A FUNCTION_BLOCK instance kept whole across a warm start (RETAIN) keeps its steps, times and action states, as Ed.3 6.7.2 has it for a retentive instance.

Not yet: an indicator variable on an association (deprecated, Ed.3 Annex B), and steps and actions in the symbol table.

The C iecf emits, and what a platform supplies

PROPOSAL.md §2: iecf’s output is portable C, as generic as C can be. This is the contract between that C and the platform that compiles and runs it. Step 2 (§5) builds to it. Where the standard leaves a choice, the choice is made after checking CODESYS, Beckhoff TwinCAT and Siemens, and recorded in docs/dependencies.md.

Status: DECIDED 2026-11-05 (florin), the address numbering and byte order included. Built in slices from 2026-11-06 (PROPOSAL.md §5 step 2): what is built is said in each section.

1. The language of the output

  • ISO C99, nothing else. No compiler extensions, no built-ins (__builtin_*), no attributes, no #pragma. A build with -std=c99 -pedantic -Werror and every warning on is clean. A static function nothing calls - a block declared and never called - is named once in the start function, (void)B_SPARE;, so no compiler warns of it.
  • Freestanding. The output and iecf.h include only the headers a freestanding C99 implementation must have: <stdint.h>, <stddef.h>, <float.h>, <limits.h>. No libc is taken for granted: strings, conversions and time arithmetic are iecf’s own C. The one exception is the mathematics of Table 28 (SQRT, SIN, LN, …) and EXPT, which come from <math.h> unless the platform defines IECF_SQRT, IECF_EXPT(x, y) and the others first; a program that calls none of them includes no <math.h>.
  • No assumption about the target: not the word size, not the endianness, not the alignment, not that int is 32 bits. The types are <stdint.h>’s exact widths; byte order matters only at the process image, which is read and written byte by byte (§4).
  • No undefined behaviour, on any compiler. Signed overflow, a shift by the width or more, a division by zero, an index out of range, a float-to-integer conversion out of range: each is checked with plain C comparisons before the operation and, instead of happening, faults or is flagged (§5). A signed overflow wraps in unsigned arithmetic, where C defines it.
  • No global state. Everything a program has lives in one context structure the platform allocates; every generated function takes a pointer to it. Two instances of one program can run in one process (a simulator beside a live view, a test harness comparing two compilers).
  • Tested on two very different targets in CI: GCC on a 64-bit host, and a 32-bit freestanding wasm32 target with no libc.

2. What iecf emits

For a project named plant (the CONFIGURATION’s name):

FileWhat
plant.hthe context type plant_t, the entry points (§3), the image sizes
plant.cevery POU, the tasks, initialisation, the symbol table (§6)
iecf.hthe runtime header, the same for every project: types, faults, image access, string and time helpers. Shipped with iecf, copied next to the output
plant.symbols.jsonthe symbol table (§6) as JSON, for tools

A C identifier is the IEC name upper-cased (IEC names ignore case) with a prefix that keeps it clear of C keywords and of other kinds of names: P_ for a POU, V_ for a variable, T_ for a type. An enumerated value is written as its number (§8).

An element of a namespace (Ed.3 6.9) is named by its whole path, its parts joined by two underscores: Standard.Timers.TON is B_STANDARD__TIMERS__TON_t. No IEC name has two underscores together (Ed.3 6.1.2), so no other name can be the same. Where a name is text for the platform - <p>_pou_names, the type names of the symbol table (§6) - it is the path with dots, STANDARD.TIMERS.TON.

  • A PROGRAM is a structure P_<NAME>_t, its instances fields of the context, and two functions, P_<NAME>_init and P_<NAME> (built). Its inputs and outputs are fields too: a connection in the CONFIGURATION (PROGRAM a WITH fast : counter(inc := 1, total => sum), Ed.3 6.8.2) sets each input from a literal, a global or an address before the instance runs, and writes each output to a global or an address after it, in the task’s function. An input or output that is an array, a structure or a function block instance is copied whole from or to a global of its type, iecf_bytes(&(ctx->V_R__M.V_A), &(ctx->V_GA), sizeof ...). A VAR_IN_OUT is a pointer field, counter(n := g) setting it to the global g before each run, the body reading and writing g through it; every in-out must be connected (Ed.3 6.6.1.4, E0016). An in-out of any type - a structure, an array, a STRING, an instance, a subrange - is a pointer of that type, bound to a global of it or to one field of one. A global at an address, which no C pointer of its type may point into (§4), is given a copy made before the run and stored back after it, not if the run faults; so is a subrange global of other bounds than the in-out’s, stored back clamped and flagged (built).
  • A located variable without a name, AT %QW4 : INT := 5; (Ed.3 Loc_Var_Decl), has no field and no symbol: its address is given its initial value as the program or block is set up, and the body reaches it by its address (built).
  • An R_EDGE or F_EDGE input X of a block or a PROGRAM has, beside its field V_X, the given value, a field E_X, an iecf_R_TRIG_t or iecf_F_TRIG_t, called with V_X as the body starts; the body reads E_X.Q (built). The trigger is not RETAIN, the input may be.
  • A FUNCTION is static <result> F_<NAME>(plant_t *ctx, <inputs>...), void for one without a result (Ed.3 6.6.2.1, built): every input passed, the caller giving an input’s initial value where the call does not name it; its variables are C locals, initialised at each call, as a function has no memory (Ed.3 6.6.2.2). One whose body uses ENO without declaring it takes IEC_BOOL *eno after ctx, sets it to 1 as it starts and writes the body’s ENO through it (built). Its outputs follow the inputs as pointers, IEC_INT *O_Q: the body works on a local of each, its initial value at each call, and writes it through the pointer as it returns - at its end or a RETURN, not on a fault; the caller passes NULL for an output it does not take. A call that takes outputs is written on a line of its own before its statement, into a temporary, its outputs stored after the check that stops the statement if it faulted (built). An in-out is a pointer among the inputs, in their declared order, IEC_INT *V_V, the body reading and writing (*V_V); the caller passes the variable’s address, an element by a computed index found on a line of its own first (built). A variable at an address, which no C pointer of its type may point at (§4), or of a subrange, which the function could write outside it, is passed as the address of a copy, IEC_INT q3 = ...;, stored back after the call as an output is: clamped into the subrange, and not on a fault (built). A VAR_EXTERNAL is the global itself, ctx->V_G, as in any POU (built).
  • A FUNCTION’s or method’s input, output, in-out or result of a STRING, array or structure type is a pointer, which the function reaches through a cast to its exact C type, (*(char (*)[7])R_): an input a const void *P_<NAME>, copied into the local V_<NAME> as the function starts (a STRING cut to its length and flagged, the flag noted at the input’s line), NULL when the call does not give it, the local then its initial value; an in-out a void *V_<NAME>, the variable itself, in C memory and of its type exactly; an output a void *O_<NAME>, copied to whole as the function returns, then stored by the caller into its target, a STRING cut there. The result is a buffer of the calling statement’s, void *R_ after ENO, set to the type’s initial value as the function starts; a STRING function also returns it, static const char *F_<NAME>(...), so its call is a string expression, and an array’s or structure’s call is a line of its own, the buffer then copied (built).
  • A statement whose expression calls a FUNCTION with effects - an output taken, an in-out, a VAR_EXTERNAL not CONSTANT, or a call of one that has - is written one operand to a line, IEC_INT s5 = ...;, left to right, each that can fault checked before the next runs: Ed.3 7.3.2 evaluates the leftmost operand first, and C does not order a call’s arguments or an operator’s operands. Any other expression stays one C expression (built).
  • A FUNCTION_BLOCK is a structure B_<NAME>_t - its inputs, outputs and variables as fields V_<NAME>, a VAR_IN_OUT a pointer set at each call to the variable passed, or to a copy of one at an address or of a subrange, stored back as a FUNCTION’s is - and B_<NAME>_init and B_<NAME> (built). One whose body uses ENO without declaring it has a field eno, set to 1 as each run starts (built). An input, or a PUBLIC variable, set from outside (f.i := 3;) is its field, which the next call reads (built).
  • An instance’s initial value, t : TON := (PT := T#500ms) (Ed.3 6.6.3.3, 6.6.5.3), is its _init (B_<NAME>_init, or iecf.h’s iecf_<TYPE>_init) followed by a store to each input, output or PUBLIC variable it names, under the guard its type’s own initial value of that variable has (§7). An instance is also an array’s element, B_<NAME>_t V_TS[50], or a structure’s field (Table 11 rows 5a to 7b); a structure that holds one is written after the block types (T_<NAME> follows the B_ it holds). A call on one, ts[i](...), or a method’s, an interface or an in-out given one, takes its address first, B_<NAME>_t *o5 = &(self->V_TS[...]);, the index checked there, so one out of bounds stops the statement before anything runs. An element is an instance of exactly its type: no table is read. A VAR_GLOBAL instance is a field of the context, set at every start (built). A whole instance assigned, or an array or structure that holds one, is copied as bytes, iecf_bytes(&(self->V_B), &(self->V_A), sizeof (self->V_B));. An input or output of a block’s type is an instance in the block that declares it, B_<NAME>_t V_C;, a call copying into it or out of it; a FUNCTION’s is passed as a structure is, const void *P_C, and copied into a local as it starts; its output, result and own instances are locals or the caller’s buffer, set up by B_<NAME>_init at each call (built). A part of a result read at the call, MKP(k).x, is read from the statement’s buffer the call wrote, T_PT d1; F_MKP(ctx, &d1, ...); ... d1.V_X, one call however many times the expression is looked at (built).
  • A CLASS (Ed.3 6.6.5) is a structure B_<NAME>_t and B_<NAME>_init, as a FUNCTION_BLOCK, with no body function: it has none. A method of a CLASS or FUNCTION_BLOCK is M_<TYPE>__<METHOD>(ctx, B_<TYPE>_t *self, ...), its parameters as a FUNCTION’s, its type’s variables self->V_<NAME>; inst.m(...) and THIS.m(...) call it with &inst and self (OO.md step A; built). A method may write its instance, so its call is one of a statement’s calls with effects: on a line of its own, checked for a fault before the next operand. The fault record names it TYPE.METHOD.
  • A type that EXTENDS another (Ed.3 6.6.5.5, 6.6.7.2.7) starts its structure with its base’s, B_<BASE>_t base;, so a pointer to it is a pointer to its base (C99 6.7.2.1); what it inherits is self->base.V_X, and its _init calls its base’s first. The root of a hierarchy (a type with a base or a derived type) starts with const iecf_otype_t *type, which each _init sets to its own TY_<NAME> (iecf.h: its base’s, its name, its table). VT_<NAME> is the table: a structure of function pointers, its base’s first, then the body of a hierarchy’s first FUNCTION_BLOCK, then each method a type declares that can be overridden (not PRIVATE, not FINAL), each taking the declaring type’s self; a derived type’s implementation is reached through a thunk M_<TYPE>__<METHOD>__vt (or B_<TYPE>__vt for a body) that converts it back (built).
  • A call through THIS, a VAR_IN_OUT or a reference reads its slot from the instance’s table, so it runs the method, or the block’s body, of the instance’s own type (Ed.3 6.6.5.6, 6.6.7.2.9 rule 4); SUPER.m(), SUPER(), a call on a named instance, and one of a method no derived type overrides call the function directly. A reference is checked before a method is called through it: NULL faults and the statement stops, the method not called (OO.md D8). A derived instance is given to a base-typed VAR_IN_OUT, of a block, FUNCTION or method, as the address of its base part (built).
  • A variable of an INTERFACE (Ed.3 6.6.6) is an iecf_itf_t (iecf.h): self, the instance it references, and table, that instance’s type’s table for the interface; NULL when self is. The table’s C type is IT_<ITF>_t, a function pointer m_<METHOD> for each method of the interface and of those it extends, each taking void *self. A type that implements an interface, itself or through a base, carries its type as a type in a hierarchy does, and has a table of its own for each, I_<ITF>__<TYPE>, of thunks IM_<ITF>__<TYPE>__<METHOD> to the method it has; its TY_<NAME> lists them (itfs, by the interface’s ITY_<ITF>). A named instance given to an interface takes its table directly; THIS, an in-out, what a reference references and another interface find it in the instance’s type (iecf_itf_of, iecf_itf_as). A method called through an interface is checked first, NULL faulting and stopping the statement (OO.md D8), then called through the slot. a ?= b is iecf_itf_as between interfaces, iecf_itf_ref into a REF_TO a type, and iecf_ref_as between references, each NULL when the instance is not of a type a holds (OO.md step C; built). An interface is an iecf_itf_t wherever it is: a VAR_GLOBAL, an array’s element, a structure’s field, a block’s input or output; a FUNCTION’s or method’s output and result, and an input with an initial value, pass as a structure does (§2: void *O_<NAME>, the caller’s void *R_, const void *P_<NAME>), a plain input by value. An initial value naming an instance is set in the _init once the POU’s variables are all placed, as REF()’s. A PROGRAM’s input or output of one is a field connected as an elementary one is: set before the run, stored after it (built).
  • VAR_TEMP, in a PROGRAM or FUNCTION_BLOCK, is C locals of its body function, set to their initial values at each call, not fields of the instance (built).
  • The standard function blocks are iecf.h’s: iecf_TON_t, iecf_TON_init, iecf_TON(&fb, now_ns), their inputs and outputs by the standard’s names; iecf_CTU_DINT_t for the typed forms (built).
  • A STRUCT is a structure T_<NAME> with a field V_<FIELD> for each of its own; an ARRAY is a C array of as many dimensions, each counted from its lower bound (ARRAY [-1..1] OF INT is IEC_INT V_A[3], a[-1] is V_A[0]). Every index that is not a constant is checked (iecf_index): out of its bounds it is a fault (§5), and an index of the variable assigned to is checked before anything is stored. A whole array or structure is assigned by copying its bytes (built).
  • An ARRAY[*] in-out (Ed.3 6.5.3) is a pointer to an iecf_flex_t (iecf.h): p the caller’s array, b each dimension’s lower and upper bound in turn, int64_t. A call gives a compound literal of one, &(iecf_flex_t){(void *)&(self->V_X), (const int64_t[]){INT64_C(0), INT64_C(4)}}, which lives until the calling statement’s block ends; one passed on is its own pointer. An element is ((IEC_INT (*))V_A->p)[flat], row-major, each index checked against b, a constant one too; LOWER_BOUND(a, 1) is a->b[0], and with a computed dimension iecf_bound, a fault outside the array’s dimensions. On an array of fixed bounds with a constant dimension it is a constant (built). A whole one assigned is if (iecf_flex_n(a, 0u) != iecf_flex_n(b, 0u)) iecf_fail(&ctx->fault, IECF_FAULT_INDEX);, the statement stopped there, then iecf_move of its bytes; from an array of fixed bounds, the copy that array’s type has (built).
  • An ARRAY[*] input or output of a FUNCTION or a method is the same pointer, const void *P_A and void *O_A: the caller’s array, no copy in or back. The output’s elements are set to their initial value as the function starts (built).
  • An in-out or an ARRAY[*] given an array or a structure at an address or through a reference gets a local of the statement’s, T a7;, filled from the image before the call and stored back after it with the call’s outputs, not at all if the call faults (built).
  • A FUNCTION_BLOCK’s in-out of an array, a structure or a STRING is a pointer field of the variable’s own type, IEC_INT (*V_F)[3], set by each call to the caller’s variable; an ARRAY[*] one an iecf_flex_t * (built).

3. The entry points

void plant_init(plant_t *ctx);            /* cold start: every variable to its initial value */
void plant_warm(plant_t *ctx);            /* warm start: RETAIN kept, the rest initial (Ed.3 6.5.6) */
void plant_task_FAST(plant_t *ctx, int64_t now_ns);   /* one per TASK */
  • A task function runs the program instances associated with that TASK, in the order the configuration lists them, once. The platform calls it: at the TASK’s interval, or on its SINGLE trigger’s rising edge. Interval, priority and trigger are in the symbol table (§6) for the platform’s scheduler; iecf schedules nothing.
  • Time comes in as an argument: now_ns, nanoseconds of a monotonic clock with any origin. Timers (TON, TOF, TP) measure against it. Nothing in the output reads a clock, so a test harness or a simulator decides what time it is.
  • A program instance with no TASK runs in a task function of its own, plant_task__default, which the platform calls cyclically.
  • A TASK of a named RESOURCE is plant_task_<RESOURCE>__<TASK>, upper case: plant_task_R__FAST.
  • The tasks are also a table, for a scheduler that reads it (built):
    typedef struct { const char *name; void (*run)(plant_t *, int64_t);
                     int64_t interval_ns; int64_t priority;
                     IEC_BOOL (*single)(const plant_t *ctx);
                     int64_t (*interval)(const plant_t *ctx); } plant_task_t;
    extern const plant_task_t plant_tasks[];
    extern const uint32_t plant_task_count;
    
    A TASK’s SINGLE and INTERVAL are each a constant, a global or one field of one, a program’s output (P1.OUT) or an address (Ed.3 6.8.2, Data_Source). A constant INTERVAL is worked out into interval_ns; one that is a variable is interval, which gives it in nanoseconds from the context as it is now (0 for a negative one), interval_ns then 0. single, NULL without one, gives SINGLE’s value now. The platform schedules by Ed.3 6.8.2’s rules a and b: the task runs on each rising edge of SINGLE; at its interval as long as SINGLE stands at FALSE; and a task with neither SINGLE nor an interval (interval_ns 0, both pointers NULL) every cycle. Reading them per context keeps the table one for every instance of the program (§1). (Decided 2026-11-12, florin.) The time of the running task is ctx->now_ns.
  • Tasks of different priorities may preempt one another only if the platform says so: by default the output assumes one task runs at a time. Preemption and the §6.9 mutexes and semaphores of Ed.4 come with step 4.

4. The process image (directly represented variables)

A located variable (start AT %IX0.3 : BOOL) is read and written in the platform’s process image at each use - there is no copy of it in the context. The image is three byte arrays, I, Q and M, which the platform owns and points the context at:

typedef struct { uint8_t *i, *q, *m; uint32_t i_size, q_size, m_size; } iecf_image_t;

plant.h says how many bytes of each the program addresses (PLANT_I_BYTES, …). plant_init faults (§5) if the platform gives fewer.

  • The number is a byte offset. %IX3.5 is bit 5 of byte 3; %IB3 is byte 3; %IW3 is bytes 3 and 4; %ID3 bytes 3 to 6; %IL3 bytes 3 to 10. So %IW0 and %IW1 overlap, and %IX1.0 is in both. Beckhoff (“addressing is always bytewise”) and Siemens count this way, and so does Prokop’s process image; CODESYS counts in units of the size (%MD48 is bytes 192 to 195). Ed.3 6.5.5.2 leaves it to the implementer.
  • Little-endian. A word’s first byte is its low byte, as CODESYS and TwinCAT have it on their usual targets, as EtherCAT carries it, and as Prokop’s image is; Siemens is big-endian. It is assembled byte by byte, so it is the same on a big-endian host.
  • A variable takes its own type’s width from its address, whatever the size prefix (Ed.3 6.5.5.3; W0004 warns when they differ): t AT %MD0 : TIME is bytes 0 to 3, a signed count of milliseconds; an LTIME, LDATE, LTOD or LDT 8 bytes; r AT %IB12 : REAL bytes 12 to 15. A BOOL at a byte or wider takes that whole byte, 1 or 0, and reads TRUE for any value but 0 (docs/dependencies.md).
  • An array, structure or STRING at an address lies from that byte, its parts back to back with no gap, each its own width, little-endian, a BOOL a byte, a STRING[n] n + 1 bytes: p AT %MB0 : pt, pt a BOOL, an INT and a TIME, is bytes 0 to 6, p.t bytes 3 to 6. It is read and written there part by part, never through a C object, so its layout does not depend on the C compiler.
  • A reference to a variable at an address (REF(x), Ed.3 6.4.4.10) is its area, byte and bit, not a pointer: the platform may point the context at other image buffers between runs. Every reference is an iecf_ref_t, a C pointer for a variable in the context (area 0, NULL for NULL) or those three for one in the image; r^ reads and writes through iecf_rget_<T> and iecf_rset_<T>, which look at where it is and treat the image as the variable itself does; a WSTRING through iecf_wref_get, its units in place in C memory or copied out of the image, and iecf_wref_put, which writes a stored one back. A reference to a type that cannot lie in the image (a function block, a structure holding a reference) is always a C pointer. Dereferencing NULL is a fault (§5); while its statement stops, the check gives ctx->nowhere, scratch memory as big as any type a reference reaches, so no C pointer is NULL when used.
  • One-level addresses only, for now: %IX1.2.3 (hierarchical) is refused at build time, as Prokop’s image does.
  • An input’s initial value (x AT %IX0.0 : BOOL := TRUE) is not written by plant_init: the input image is the platform’s. Those of %Q and %M variables are.
  • When the image changes is the platform’s business: the standard’s model (IEC 61131-1) is that inputs are read before a task runs and outputs written after, and a platform that copies its I/O around each task function call gives exactly that.

5. Faults

A fault is an error the program meets while running: Ed.3 calls division by zero and overflow errors and leaves their handling to the implementer (PROPOSAL.md §6.3). iecf’s handling: division by zero, and the errors no value can carry on from, stop the task where it is; an overflow carries on, wrapped, and is flagged.

  • The generated code checks before every operation that could go wrong, and on a fault records it in the context and returns, from every function up to the task function. No setjmp, no abort, no signal.

  • After a task function returns, the platform reads ctx->fault:

    typedef struct { uint16_t code; uint16_t pou; uint32_t line; } iecf_fault_t;
    

    code says what (division by zero, index out of range, MUX’s K out of range, a negative shift, an image too small, a NULL reference dereferenced: IECF_FAULT_NULL), pou which POU (an index into plant_pou_names), line the source line of the statement. A flag raised by a CONFIGURATION’s connection code - a STRING cut, a value clamped into a subrange - is recorded at the CONFIGURATION, whose name plant_pou_names then holds too, and the line of the connection.

  • Not a fault, as Ed.3 defines it: x MOD 0 is 0 (Table 29 e); SHL and SHR by the width or more give 0, ROL and ROR go round (Table 30).

  • REAL and LREAL: division by zero is a fault.

  • Flagged, not faulted (decided 2026-11-06, docs/dependencies.md): the value carries on, the task goes on, and the context’s second record, ctx->flag (an iecf_fault_t), takes the first, its POU and line:

    • an integer result beyond its type, from + - * / NEG ABS: it wraps, two’s complement (32767 + 1 is -32768), IECF_FAULT_OVERFLOW;
    • an SFC step entered while still active, or an action’s associations in conflict (Ed.3 6.7.5, 6.7.4.6 d): IECF_FAULT_SFC, the step staying active once (docs/behaviour/sfc.md);
    • a DATE or DT outside 1970-01-01 .. 2106-02-07-06:28:15, or a difference of two outside TIME: wrapped, IECF_FAULT_OVERFLOW (a TOD wraps at midnight, unflagged; docs/dependencies.md);
    • an integer converted to one that cannot hold it: its low bits are kept (INT_TO_SINT(4223) is 127), IECF_FAULT_CONVERSION;
    • an infinite or NaN real, from + - * /, Table 28 (SQRT(-1.0)) or an LREAL beyond REAL’s range made a REAL (the infinity of its sign): it carries on as IEEE 754 has it, IECF_FAULT_OVERFLOW for an infinity, IECF_FAULT_NAN for a NaN. The program can test a value with IS_VALID (Ed.3 6.6.2.5.15);
    • a value stored into a subrange outside its bounds: it becomes the bound it passed, IECF_FAULT_CONVERSION (a FOR over a subrange stops at the bound);
    • a real converted to an integer it does not fit, an infinity or a NaN among them, IECF_FAULT_CONVERSION: there is no integer to carry on with, so the statement is left undone (docs/dependencies.md). An assignment keeps its target’s value; IF and CASE run no branch; FOR does not run; WHILE and REPEAT end; a function block’s input keeps its value and the block is called;
    • a STRING too long for the variable it is stored in: cut to fit, IECF_FAULT_STRING_LENGTH. A function’s result never is: its buffer holds the most its inputs can make;
    • text that is no literal of the type, or one the type cannot hold, converted from a STRING: 0, IECF_FAULT_CONVERSION;
    • a string function’s length or position out of its string - negative, 0 or past the end where the standard calls it an error (Ed.3 6.6.2.5.11): what Siemens gives, an empty string or the part there is, IECF_FAULT_STRING_POSITION (docs/dependencies.md).

    The flag stays until the platform clears it (code to 0, line to 0); plant_init clears it.

  • A faulted statement changes nothing: its value is computed and checked before it is stored. While it is set, every task function returns at once: the program is stopped until plant_init or plant_warm.

  • What the platform does then - stop the controller, show the error, restart - is the platform’s.

6. The symbol table

The platform needs to find variables by name: to watch them, write them, keep the RETAIN ones. The output carries its own table, in C, described in iecf.h (built):

extern const iecf_symbol_t plant_symbols[];   /* plant_symbol_count */
extern const iecf_type_t plant_types[];       /* plant_type_count */
  • A symbol for each top-level variable: a global (G; R.G for resource R’s), and each variable of each program instance (R.P.X for X of instance P on resource R) - the paths VAR_CONFIG uses (Ed.3 6.5.6), upper-cased. Each gives its type, its offset in the context, and flags: IECF_SYM_CONSTANT, IECF_SYM_RETAIN when it is in the region (§7) - a function block instance kept whole there is too. A located variable gives its area ('I', 'Q', 'M'), byte and bit instead: the bit of a BOOL at a bit, IECF_NO_BIT for any other (a BOOL there takes the whole byte). An array, structure or STRING there has the flag IECF_SYM_PACKED: its parts are back to back from its byte (§4), not at the offsets of the type table, which are the C compiler’s. A variable of an INTERFACE has no symbol: it is a reference to an instance, which has its own. Nor does an array of them, nor a structure’s or block’s field of one, which its type’s members leave out.
  • Each type once: an elementary type by its IECF_T_*, a STRING[n] as IECF_T_STRING of size n + 1, a WSTRING[n] as IECF_T_WSTRING of size 2(n + 1) (JSON kind wstring); an enumeration by its base and its values; a structure or function block
    • the user’s and the standard ones - by its members, each with its type, offset and flags (IECF_SYM_INPUT, IECF_SYM_OUTPUT, IECF_SYM_RETAIN for a block’s RETAIN variable); an array by its element type and each dimension’s bounds; a reference, IECF_KIND_REF, by the type it references (of), its size an iecf_ref_t’s. A VAR_IN_OUT, a pointer set at each call, is not a member; nor is a program’s a symbol: the global it is bound to is.

A platform finds R.P.PTS[2].X by walking them: the symbol R.P.PTS, the array’s element type and its bounds, the structure’s member X. Beckhoff’s ADS symbols and CODESYS’s symbol configuration are built the same way, and for the same reason: an array of 10000 is one entry. The offsets and sizes are the C compiler’s own, through offsetof and sizeof, so the table is right for whatever compiler and target built it - which a table iecf computed itself could not promise for portable C. VAR_TEMP and a FUNCTION’s variables live only during a call, and are not in it. Tasks (name, interval, priority) and POU names are tables beside it.

Beside the C, iecf build writes the same table as JSON, plant.symbols.json, for the tools that need the names and types but not the binary - an editor’s watch list, a platform’s online view:

{
  "configuration": "plant",
  "symbols": [
    {"path": "R.P.PTS", "type": 0, "flags": []},
    {"path": "R.P.K", "type": 3, "flags": ["RETAIN"]},
    {"path": "R.P.LAMP", "type": 4, "flags": [], "area": "Q", "byte": 1, "bit": 2}
  ],
  "types": [
    {"name": "ARRAY [0..9] OF POINT", "kind": "array", "of": 1, "dims": [[0, 9]]},
    {"name": "POINT", "kind": "struct", "members": [{"name": "X", "type": 2, "flags": []}]},
    {"name": "INT", "kind": "elementary"},
    {"name": "DINT", "kind": "elementary"},
    {"name": "BOOL", "kind": "elementary"}
  ]
}

A symbol’s and a member’s type index types, in the order of plant_types. A type’s kind is elementary (its name the IEC type), string (with its length), enum (its base type and its values, name and number), struct and block (their members: name, type, flags), array (of the element type, dims each dimension’s bounds), or reference (of the type it references). flags name INPUT, OUTPUT, CONSTANT, RETAIN, PACKED; a located variable adds its area and byte, and a BOOL at a bit its bit. Offsets and sizes are not in the JSON: they are the C compiler’s, in the C table. The harness reads this file to compare iecf’s variables with matiec’s (harness/README.md).

7. RETAIN

VAR RETAIN and VAR_GLOBAL RETAIN variables are gathered into one region of the context, ctx->retain, a structure of its own, plant_retain_t (built). The platform keeps those bytes across a restart (a file, FRAM, flash): offsetof(plant_t, retain) and sizeof(plant_retain_t) are where and how many.

  • plant_init is a cold start: every variable to its initial value, the RETAIN ones too.
  • plant_warm is a warm start (Ed.3 6.5.6): every variable to its initial value but the RETAIN ones, which keep what the region holds. A variable that is neither RETAIN nor NON_RETAIN is set anew, as on CODESYS, TwinCAT and Siemens.
  • A program’s RETAIN variables are a structure P_<NAME>_retain_t in the region, one per instance, which the instance reaches by its retain pointer.
  • A FUNCTION_BLOCK instance is kept whole, in the region, when it is declared RETAIN or its type keeps something - a RETAIN variable of its own, or an instance of a block that does. CODESYS does the same (“The entire instance of the function block with all of its data is located in the retain memory area. Only the declared retain variable is protected”, CODESYS help, retain variables) (decided 2026-11-07, florin; a structure per instance with a pointer to it was the other way). B_<NAME>_init(ctx, self, keep) then says what a start sets anew, as Ed.3 6.5.6 does: keep 0 at a cold start, everything; 1 at a warm start, all but the block’s RETAIN variables; 2 at a warm start of an instance declared RETAIN, only its NON_RETAIN ones. A block inside a block follows its own type and the section it is declared in, not the outer instance (“the retain declaration of the used function block type is applied”, Ed.3 6.5.6.2).
  • An array or a structure of instances is kept as its instances are: whole in the region when it is declared RETAIN or an instance in it keeps something; each instance’s _init given the keep of its section, and, in a RETAIN one, its other parts set at a cold start only, a standard block’s state with them. A VAR_GLOBAL instance, or an array or structure of them, is kept the same way.
  • The symbol table marks each IECF_SYM_RETAIN and gives its offset in the context, so a platform can also move them by name across a change of program.

Not yet: a RETAIN variable at an address, and RETAIN on a program instance in the CONFIGURATION.

8. Representations

As docs/dependencies.md: BOOL is one byte, 0 or 1 (a packed bit only in the process image); the integers and bit strings their exact widths; REAL and LREAL IEEE 754 binary32 and binary64 (checked when iecf.h is compiled); TIME signed 32-bit milliseconds, LTIME signed 64-bit nanoseconds; DATE and DT unsigned 32-bit seconds since 1970, TOD unsigned 32-bit milliseconds since midnight, the long ones signed 64-bit nanoseconds; STRING(n) n+1 bytes, 0-terminated, Latin-1; CHAR a byte, a Latin-1 code (IECF_T_CHAR in the symbol table); WSTRING(n) n+1 UTF-16 units, IEC_WCHAR[n + 1], its literals C99 compound literals ((const IEC_WCHAR[]){97u, 0u}); WCHAR one unit (IECF_T_WCHAR). A WSTRING at an address is its n + 1 units little-endian from its byte, at any byte, read and written there byte by byte (iecf_wimg_get, iecf_wimg_put), and through a reference by iecf_wref_get and iecf_wref_put. A WCHAR at an address is 16 bits, as a WORD. An enumerated type is held as its base integer, INT when it names none, its values numbered from 0 in their order unless the declaration gives them (Ed.3 6.4.4.3); a subrange as its base type, clamped where it is stored (§5). A reference is an iecf_ref_t (§4).

9. Not yet

EN and ENO on a function block’s call, ENO set in a POU’s own body, SFC steps and actions in the symbol table, hierarchical addresses, preemptive tasks (Ed.4 §6.9), USTRING (Ed.4), online change (a new program taking over a running one’s state by the symbol table) - each in its step of PROPOSAL.md §5.

Implementer specific dependencies

IEC 61131-3 Ed.3 clause 5 d asks an implementation for “a document that specifies all Implementer specific dependencies”: the choices the standard leaves open, and its limits. This is that document for iecf, growing as the compiler does.

Each choice is made after checking what the big vendors do (CODESYS and the systems built on it, Beckhoff TwinCAT, Siemens), and says so: a program that compiles there should, as far as the standard lets it, mean the same here.

Implicit conversions (Ed.3 6.6.1.6)

Figure 11’s implicit conversions, which keep the value, apply in an assignment, to an input and an output (=>) of a block or function, and between the operands of an operator or overloaded function (rules 3-5, 7); never to an in-out (rule 6). A date or time with a long operand is widened to the long type: DT + LTIME is an LDT.

  • An operation is done in its operands’ type, the narrower operand widened to the wider one’s, and its result widened only where it is used: Ed.3 6.6.1.6 Example 3, myUDInt := myUInt1 * myUInt2, “The multiplication has a UINT result which is then implicitly converted to an UDINT at the assignment”. So d := i * j of two INTs overflows as an INT, flagged. CODESYS “computes temporary results always with the native size that is defined by the target device”, at least 32 bits, and TwinCAT “always calculates intermediate results with the native size specified by the target system”: there the same product is not truncated below 32 bits, but lint := dint * dint is. RuSTy widens every integer narrower than DINT to 32 bits first. iecf keeps the standard’s rule, which is the same on every target.
  • Two integers neither of which widens to the other are both widened to the narrowest signed type that holds them both: SINT and USINT to INT, INT and UINT to DINT, DINT and UDINT to LINT, and so for other pairs (SINT and UINT to DINT). ULINT and LINT have none, and are refused (E0014). It holds wherever operands are made one type: an operator, a comparison, an overloaded function such as MAX. Ed.3 says nothing on it. Decided 2026-11-09 (florin): as Siemens SCL, which is reported (by third-party restatements of the TIA help, not its own pages) to give “the next larger signed integer type”, SINT + USINT an INT. Others: RuSTy computes in a signed type of 32 bits or more and warns that a large unsigned value may turn negative; CODESYS and TwinCAT compute in the target’s register width, and CODESYS warns (C0195) on a signed value made unsigned; PLCnext reports anything but a widening as an error (SEM1189). The common type keeps both values, as Figure 11’s implicit conversions must.
  • An overloaded function’s generic class is not entered by a conversion: SQRT(i) of an INT is E0014 (see Untyped literals).

Typed functions (Ed.3 6.6.2.4, Table 21)

Table 21, footnote a: “If feature 2 is supported, the Implementer provides an additional table showing which functions are overloaded and which are typed in the implementation.” iecf supports it: every overloaded standard function whose inputs share one type has a typed form for each type of its class, ADD_INT, MUX_UINT_REAL. The table is in the book (Typed functions). A name a table of the standard gives a function of its own - ADD_TIME, MUL_TIME, CONCAT_DT - is that function, not a typed form.

Untyped literals (Ed.3 6.6.1.6, rule 8)

“The Implementer shall define the rules for non-typed literals.”

  • An integer literal without a type (5, 16#FF) takes the type the place it is used needs, if its value fits: any integer or bit-string type, and also REAL or LREAL. SQRT(2) is SQRT(2.0); r := 2 is r := 2.0. A value that does not fit is E0020.
  • A real literal without a type (2.5) goes only where REAL or LREAL goes.
  • Between two untyped literals, an operation keeps them untyped until a typed operand or the target decides.
  • A standard function given untyped literals alone - MAX(10, 11), SQRT(2.0), SEL(TRUE, 1, 2) - stays untyped the same way, and so does a quotient of real literals, 1.0 / 3.0. It is computed as the program runs, in the type the place needs: v := ADD(30000, 30000) is 60000 for a DINT v, and for an INT v wraps to -5536 and is flagged OVERFLOW, as v := a + b of two INTs is. Integers stay integers where a real is wanted: r := 7 / 2 is 3.0, and so is r := MAX(7, 2) / 2, computed in LINT. Where nothing decides - both sides of a comparison, GT(MAX(1, 2), 1) - integers are computed in LINT and reals in LREAL. (2026-11-12; florin has not decided this, and no vendor’s documentation was checked for it. The harness case litcalls runs each.)

Vendors: CODESYS documents SQRT as taking “any numeric base type” and gives q := SQRT(16) as its example. It goes further and takes integer variables too; iecf does not: an INT variable where ANY_REAL is required is E0014 (convert it, INT_TO_REAL).

SINGLE and an INTERVAL that is a variable (Ed.3 6.8.2)

A TASK’s SINGLE and INTERVAL are a Data_Source: a constant, a global or one field of one, a program’s output, an address. iecf schedules nothing (docs/c-output.md §3), so the task table gives the platform a function per task that reads each from the context as it is now, and the platform runs the task on SINGLE’s rising edges and at the interval while SINGLE is FALSE, as Ed.3’s rules a and b say. Implemented 2026-11-12 at florin’s word; the shape of the table (single, interval, each NULL when unused) is my choice. Until then the checker refused a variable there, and a SINGLE task was E0009.

RETAIN at an address (Ed.3 6.5.6, Loc_Var_Decls)

Ed.3’s grammar lets a located variable be RETAIN ('VAR' ( 'CONSTANT' | 'RETAIN' | 'NON_RETAIN' )? ( Loc_Var_Decl ';' )*). Such a variable is in the process image, which the platform owns, not in ctx->retain: its initial value is written at a cold start only, and at a warm start iecf leaves the bytes, which the platform keeps across the restart. Decided 2026-11-12 (florin).

Program connections (Ed.3 6.8.2)

What a connection names is Ed.3’s grammar, Prog_Data_Source and Data_Sink: a constant, an enumerated value, a global or one field of one (Global_Var_Access), or an address; an element or an expression is E0016 (2026-11-12; until then the checker took any expression there). iecf’s own choices there (2026-11-12; florin has not decided these):

  • A STRING input or output is copied and cut to what it is stored in, a value given to a subrange input, or taken into a subrange global, is clamped - flagged, as any store. Such a flag is recorded at the CONFIGURATION and the connection’s line: there is no POU’s statement to record it at.
  • A program’s in-out connected to a global at an address is given a copy, made before the run and stored back after it, as a FUNCTION’s in-out given one is; so is a subrange global of other bounds than the in-out’s, stored back clamped and flagged.

SEL, MUX and MOVE of user-defined types (Ed.3 Table 32, footnote d)

Table 32 gives MOVE and SEL inputs of ANY and MUX’s of ANY_ELEMENTARY, and footnote d, on all three: “It is allowed, but not required, that the Implementer support selection among variables of user-defined data types”. iecf supports it for all three, MUX too (2026-11-12; florin has not decided this): arrays, structures, instances, interfaces and references, the input selected where it is, NULL beside an interface or a reference, an instance beside an interface it implements. MIN, MAX and LIMIT are ANY_ELEMENTARY in the table and have no such footnote: they stay elementary. No vendor’s documentation was checked for this.

LEN and FIND give INT (Ed.3 Table 34)

The standard gives their result as ANY_INT. iecf gives INT.

Vendors: INT in the CODESYS Standard library, in Beckhoff’s Tc2_Standard, and in Siemens S7-SCL (FC21, FC11). (CODESYS’s separate StrFunc library has a FIND of its own that gives UDINT.)

DAY_OF_WEEK gives USINT (Ed.3 Table 36)

The standard gives the result as ANY_INT, 0 for Sunday to 6 for Saturday, and leaves the type to the implementer (NOTE 2). iecf gives USINT, the type of the standard’s own example (myDoW: USINT).

Vendors: none of those checked has the standard’s DAY_OF_WEEK; each has a function of its own (Beckhoff F_GetDayOfWeek giving WORD, CODESYS Util DayOfWeek giving an enumeration), so none sets a precedent.

TRUNC without a type gives DINT (Ed.3 Table 22, 2a)

The “old” overloaded TRUNC, ANY_REAL to ANY_INT, is deprecated by Ed.3, which keeps TRUNC_INT and REAL_TRUNC_INT, forms that name the result. iecf reads it, warns (W0003), and gives DINT. Decided 2026-11-06 (florin).

Vendors: all three read it and give DINT. TwinCAT 3: “converts from the data type REAL to the data type DINT”; TwinCAT 2 gave INT, and TRUNC_INT stands in for that TRUNC when a TwinCAT 2 project is imported. CODESYS V3: the same, TRUNC_INT standing in for V2.3’s TRUNC. Siemens S7-1200/1500: DINT unless the instruction’s type is changed. matiec gives whatever integer the target is, the standard’s ANY_INT read literally; no vendor does.

The counters’ PVmax and PVmin are their type’s range (Ed.3 Table 45)

CTU and CTUD count up while CV < PVmax, CTD and CTUD down while CV > PVmin, and the standard leaves both to the implementer. In iecf they are the maximum and minimum of CV’s type: a CTU (INT) stops at 32767, a CTD (INT) at -32768, a CTD_UDINT at 0. A counter never wraps. Decided 2026-11-06 (florin).

Vendors: Siemens S7-1200/1500 counts “up to the range limit” of the type, and on a signed type “down to the negative integer limit” (S7-1200 System Manual), and stops there. CODESYS and TwinCAT give CV and PV the type WORD; TwinCAT documents CTD stopping at 0, WORD’s minimum, and neither documents the top. Rockwell’s CTUD wraps over. matiec stops CTU at PV and CTD at 0, which the table does not say (PROPOSAL.md §1b).

An infinite or NaN real is flagged, not a fault (Ed.3 Table 10 e, f)

“Results of arithmetic instructions with denormalized values, infinity, or not-a-number values are Implementer specific.” In iecf they carry on as IEEE 754 has them, and the first is flagged where the platform sees it (docs/c-output.md §5); the task does not stop. Division by zero stays a fault. Decided 2026-11-06 (florin).

Vendors: Siemens S7-1200 writes the infinity or NaN and sets ENO to FALSE; the CPU goes on (S7-1200 System Manual, Tables 8-46, 8-67). Rockwell Logix sets its overflow flag and a minor fault and goes on. CODESYS, as an OEM’s help documents it (ABB), carries infinities and NaNs on without a signal. TwinCAT stops the PLC on an invalid operation by default, a per-task “Floating Point Exception” setting, and warns that turned off, a NaN can reach the outputs. iecf takes the middle: on, but seen.

An integer overflow wraps and is flagged (Ed.3 7.3.2, Table 23)

Ed.3 calls a result out of its type’s range an error and leaves its handling to the implementer; a conversion to a type that cannot hold the value is implementer specific (Table 23). In iecf both carry on: the result wraps, two’s complement (32767 + 1 is -32768), a conversion keeps the value’s low bits (INT_TO_SINT(4223) is 127), and the first is flagged in ctx->flag with its POU and line, as an infinite or NaN real is (docs/c-output.md §5). The task does not stop. Division by zero stays a fault: no vendor agrees on a value to carry on with. An index out of range will be a fault: carrying on would write outside the array. Decided 2026-11-06 (florin), replacing the fault of 2026-11-05 (PROPOSAL.md §6.3).

A real converted to an integer it does not fit (an infinity and a NaN among them) has no low bits to keep: it is flagged and its statement is left undone - an assignment keeps its target’s value (docs/c-output.md §5). Decided 2026-11-06 (florin), and kept once Siemens’s tables were read: iecf’s own choice, which invents no value.

Undone means it faults on nothing it computes after the conversion (2026-11-12; florin has not decided this). Until then the conversion’s placeholder, 0, went on into the statement: 10 / REAL_TO_INT(x) with x out of range stopped the task on a division by zero, and a[REAL_TO_INT(x)] on an index outside a, though the statement was to be left undone. Now a fault the statement’s own checks raise is dropped once one of its conversions has failed (iecf.h iecf_undone); a fault inside a FUNCTION it calls stands, recorded with that function’s line.

Siemens S7-1200 (System Manual A5E02486680-AG, 03/2014, Table 7-75, CONV): “Result exceeds valid range for OUT data type” gives ENO 0 and “OUT is set to the IN value”; the 11/2011 edition (A5E02486680-05) says “OUT is set to the least-significant bytes of IN” - iecf’s rule for an integer conversion. “IN is +/- INF or +/- NaN” gives ENO 0 and “+/- INF or +/- NaN”, which an integer OUT cannot hold. ROUND and TRUNC (Table 7-85) and CEIL and FLOOR (Table 7-88) list only the INF and NaN row. A program execution error leaves the CPU in RUN (Table 4-14).

Vendors: none stops on an integer overflow. Siemens S7-1200 keeps the least significant part, sets ENO to FALSE and stays in RUN, and the same for a conversion out of range (S7-1200 System Manual, Table 8-46); a division by zero gives 0. Rockwell Logix wraps, sets its overflow flag S:V and a minor fault, and goes on; an overflow in an array subscript is a major fault. CODESYS and TwinCAT wrap without a signal, and truncate a conversion (TwinCAT: INT_TO_SINT(4223) is 127). TwinCAT stops the task on an integer division by zero; on CODESYS it depends on the target, and the optional CheckDiv functions replace a zero divisor with

  1. iecf takes Siemens’s and Rockwell’s way: on, but seen.

A subrange clamps a value outside it, and flags it (Ed.3 6.4.4.4)

A value stored into a subrange variable outside its bounds becomes the bound it passed (p := 150 into INT (0..100) stores 100), and the first is flagged IECF_FAULT_CONVERSION in ctx->flag with its POU and line (docs/c-output.md §5). It holds for every store: an assignment, an array’s element or a structure’s field of the type, a function block’s input and an output taken into one. A FOR over a subrange starts at its start clamped and stops at the bound it would pass; an end on the other side is left, so the loop does not run, as written. A constant initial value outside the subrange is an error. Decided 2026-11-06 (florin).

A subrange variable starts at its lower limit, as Ed.3 6.4.4.4.2 says: “The default initial values for data types with subrange shall be the first (lower) limit of the subrange.” Until 2026-11-12 iecf started one at 0, which is outside INT (2..5). As a FUNCTION’s input it is clamped where the input is declared, the flag’s POU and line the function’s, not the caller’s (2026-11-12; florin has not decided this: it follows a STRING input, cut and flagged there).

Vendors: CODESYS and TwinCAT do not check a subrange unless the project adds the implicit-check functions CheckRangeSigned and CheckRangeUnsigned: “If functions are not available, then the subrange is not checked for the respective variables at runtime” (CODESYS help, “POU CheckRangeSigned”; TwinCAT 3 InfoSys says the same). Their template returns the bound passed, with no error (i := 10*y with y = 1000 gives 4095 for INT (-4095..4095)), and warns that a FOR counter of the type running to the bound never ends; iecf’s FOR does. Siemens and Rockwell have no subrange types. matiec stores unchecked, except into a function block’s input, which it clamps. iecf clamps always, and is seen.

FOR: the variable after the loop (Ed.3 7.3.3.4.2)

“The value of the control variable after completion of the FOR loop is Implementer specific.” Decided 2026-11-07 (florin), found by comparing iecf’s variables with matiec’s on Prokop’s programs: one step past the end (FOR i := 1 TO 10 leaves 11), as the standard’s own examples assume (“terminates when the value of the variable I reaches 0”; Table 72’s J “will have the value 101”), as CODESYS (“at the end, i always will be one step outside the range”, its forum; its help: the loop runs “as long as the counter is not greater than the end value”) and as matiec. At the type’s limit the step past wraps and is flagged, and the loop ends; on CODESYS it never does (“an infinite loop results”). A subrange variable stays inside: the loop ends at its last value.

EN and ENO (Ed.3 6.6.1.5)

The standard has the implementer “specify the behavior in detail” when EN is FALSE (rule 1), leaves the outputs and the function result to the implementer when ENO is FALSE (rule 4), and lets the implementer specify another disposition of an error than ENO = FALSE (rule 3). Decided 2026-11-07 (florin), for a function call:

  • EN = FALSE: the call does not happen, Ed.3’s “external implementation” (its Example 2): the inputs are not evaluated, the function is not called, ENO is FALSE, and the statement the call is in is left undone - an assignment keeps its target’s value - unflagged. No value is invented for the result. CODESYS: “If EN has the value FALSE at the time of the call, the operations defined in the POU are not executed” (CODESYS help, FBD/LD box with EN/ENO). matiec assigns the function’s default result instead.
  • ENO is FALSE when the function’s own operation raised a flag (an overflow, a conversion that does not fit, a string length or position outside its string): rule 3’s default, and Siemens’s, whose instructions clear ENO on such errors. A flag raised while computing an argument is not the function’s. CODESYS’s ENO only mirrors EN.

A function block call, decided 2026-11-08 (florin), the same way:

  • EN = FALSE: the block is not called, Ed.3’s Example 4: its inputs are neither evaluated nor set in the instance, its in-outs not bound, its body does not run - a timer’s or counter’s state stays as it was - its outputs are not taken (Q => x leaves x), and ENO is FALSE, unflagged. CODESYS: the POU’s operations “are not executed”; Panasonic FPWIN GR: the block is skipped and the connected variable keeps its value; logi.CAD/Neuron: ENO FALSE and the outputs not updated. matiec takes the outputs anyway.
  • ENO is FALSE when a flag is raised while the block runs: in its body or anything it calls. A flag from computing its inputs, or from storing its outputs into the caller’s variables (a subrange clamped), is not the block’s.
  • A block that declares EN or ENO among its own parameters gets them as any other parameter: the body then decides (Example 1).

ENO set in a POU’s own body, decided 2026-11-08 (florin), after the vendors - none of which documents how the body’s ENO and an error combine:

  • ENO is there in every FUNCTION and FUNCTION_BLOCK without a declaration (Ed.3 rules 2 and 7), TRUE as each run starts; the body may read it and set it. A PROGRAM has none. A POU that declares ENO keeps it as an ordinary output (above).
  • The caller’s ENO is FALSE when the body set it FALSE or a flag was raised while the POU ran: a body can clear ENO, and cannot hide an error with ENO := TRUE (rule 3: on an error, ENO “shall be reset to FALSE by the programmable controller system”).
  • What the vendors document: Siemens S7-1200 “SCL also allows you to set the ENO parameter for a code block”, and its compiler sets ENO “when the code block finishes” with “Set ENO automatically” (S7-1200 System Manual 04/2012, 6.5.4); PLCnext lets a function “set ENO = FALSE on specific conditions” and leaves a user function block’s ENO to its body; logi.CAD/Neuron presets ENO TRUE, lets the block assign it, and sets the enclosing POU’s ENO FALSE on an index out of range or a division by 0; Panasonic: ENO is EN unless the body sets it; CODESYS and TwinCAT: “The ENO output has the same value as the EN input”. None says which wins when the body sets ENO TRUE after an error.

SFC (Ed.3 6.7)

Decided 2026-11-07 (florin), after CODESYS’s and TwinCAT’s documented processing order (“Processing order in SFC”, Beckhoff InfoSys; CODESYS help, SFC sequence of processing):

  • Actions, then transitions. Each scan runs the action control and the actions of the steps active when it began, then tests their transitions; a step entered in this scan runs its actions from the next. CODESYS and TwinCAT: IEC actions are executed, then “the transitions are evaluated”. matiec tests the transitions first and runs the actions of the steps active after, in the same scan.
  • With final scan (Table 60, feature 1, Figure 23 a): an action runs once more, its Q FALSE, after Q falls - P included, which therefore runs twice, as CODESYS documents (“executed exactly two times”). The final scan and P0 follow a falling edge that happened; the figure’s F_TRIGs, whose M starts FALSE (Table 44), would fire on the first scan too, which no vendor does.
  • Two passes, as CODESYS and TwinCAT: the actions ending (final scan, P1, P0) before those whose Q is TRUE, each in source order (CODESYS takes alphabetical order).
  • Selection: of the transitions leaving one step, the first true by PRIORITY (lowest first, Table 61 2b), then source order (2a, as CODESYS’s leftmost branch); unflagged.
  • Unsafe (a step entered while active), two time-related associations active, SD with SL stored: flagged IECF_FAULT_SFC, the task goes on; Ed.3 6.7.5 and 6.7.4.6 d call them errors.
  • S.T counts whole milliseconds from the task’s clock and stops at TIME’s largest.

A FUNCTION’s outputs (Ed.3 6.6.1.4, 6.6.2.2)

A FUNCTION may declare VAR_OUTPUT; a call takes one with =>, or in order after the inputs (6.6.1.4), negated with NOT q => for a BOOL, widened as an assignment is (6.6.1.6, rule 5).

  • An output starts at its initial value at every call: a FUNCTION has no memory (6.6.2.2), so an output the body does not set gives its initial value, not what the last call left.
  • The outputs are stored when the call returns, at its end or at a RETURN, with the values they have then. A call that faults stores nothing: the statement stops before any output or its own target is written, as for any statement that faults.
  • An output is stored before the statement’s own target: in x := f(a, q => x) x is f’s result. Ed.3 does not order them; the vendors were not checked.
  • An output the call does not take is dropped.
  • With EN = FALSE a FUNCTION with effects is not called, as any other (EN and ENO, above): its in-outs and the globals it writes stay as they were, and its outputs are not stored - each output’s variable keeps its value, as a SPLIT’s do and as PLCnext keeps “the last valid output value”. ENO is FALSE when the call raised a flag; storing its outputs afterwards is not the call, as for a function block, so an output clamped into a subrange leaves ENO TRUE.

A FUNCTION’s in-outs and externals (Ed.3 6.6.2.2)

A function “may change the value of in-out and VAR_EXTERNAL variable(s)” (6.6.2.1; 6.6.2.2 rules 4-6), so its calls are not pure:

  • The order of evaluation is the standard’s. “When an operator has two operands, the leftmost operand shall be evaluated first” (7.3.2, rule 3), and a function’s arguments in order. In x + f(x) with f changing its in-out x, x is read first; in f(w) - f(w) the left call runs first. A call that faults stops the statement before anything right of it runs.
  • An in-out takes a variable of its own type (6.6.1.6, rule 6), and every call must pass it (E0016).
  • An array or a structure at an address, or reached through a reference, is given to an in-out as a copy too (2026-11-11; florin has not decided this), and so to an ARRAY[*]: read before the call, stored back after it, not at all if the call faults. The reason is the one below: the image’s bytes are no C object. Siemens copies a structured in-out where the caller’s memory and the block’s differ: “If structures are transferred to a block as in/out parameters, they are transferred by default as pointers (Call by reference). However, this does not apply if the memory area of the transferred actual parameter and the called block have different optimization settings … the parameter is always transferred as copy (Call by value)”, and warns of what a copy means: “If the values are copied back again to the operands originally transferred as actual parameters after the block processing, the changes made asynchronously to these operands are overwritten.” CODESYS and TwinCAT document no rule for located arrays. Two in-outs of one call given the same variable there are two copies; the one stored back last stays. Siemens, copy or pointer.
  • A variable at an address or of a subrange is given as a copy, to a FUNCTION or a FUNCTION_BLOCK: read before the call, stored back after it as an output is - clamped into the subrange and flagged CONVERSION, as any store into one; not at all if the call faults; and after ENO is decided, so a clamp leaves ENO TRUE. An address’s bytes are no C variable a pointer may reach, and a pointer would let the function write outside the subrange. Siemens passes every elementary in-out so: “the value of the actual parameter is copied into the instance data or from the instance data to the actual parameter” (Siemens forum, quoting its help), copied back at the end of the call. The difference from a pointer shows only if the function also reaches the variable another way, as a VAR_EXTERNAL: it sees the value from before the call, and its writes there are overwritten by the store back.
  • VAR_EXTERNAL in a FUNCTION is Ed.3’s; Ed.2 had none, and the reference compiler refuses it.

A FUNCTION without a result (Ed.3 6.6.2.1) is called as a statement only: “A function without result shall not be called inside an expression” (E0016, E0014), and its name, which holds no result, is not assigned (E0015). Ed.2 had none, nor has the reference compiler.

Fixed 2026-11-09: a call taking an output was run before its statement’s operands left of it, so x + f(q => x) read x after f had stored it; now the order is the standard’s (below).

The reference compiler takes a FUNCTION’s outputs into variables; its C for one into an array element does not compile (“assignment to expression with array type”, 7949c0b), and NOT q => crashes it.

SPLIT_DATE, SPLIT_TOD, SPLIT_DT outputs (Ed.3 Table 36)

The outputs are ANY_INT and “the Implementer specifies the provided data types” (NOTE 2). iecf takes any integer variable for each; a part that does not fit it wraps and is flagged, as any integer conversion (PROPOSAL.md §6.3).

With EN and ENO (decided 2026-11-08, florin): EN FALSE leaves every output as it was, as EN FALSE leaves any statement undone here; ENO is FALSE when a part did not fit its output, the output’s type being the call’s choice. Vendors:

  • logi.CAD / Neuron (SPLIT_DT, a function with EN and ENO): ENO FALSE for an input past DT#2106-02-07-06:28:14.999, the other outputs then “in principle undefined”; with EN FALSE a function’s outputs get their initial value unless the start option lc3.fbdPreserveFunctionOutputs keeps them. Nothing on an output too small for its part.
  • PLCnext: with EN FALSE “the last valid output value is kept”.
  • No vendor documents whether a narrowed output clears ENO.

CONCAT_DATE, CONCAT_TOD, CONCAT_DT: a part out of range (Ed.3 Table 36)

The inputs are ANY_INT; what an impossible date or time gives is the implementer’s. Decided 2026-11-08 (florin), after the vendors: the type’s default value, flagged IECF_FAULT_CONVERSION, the task going on - D#1970-01-01, TOD#00:00:00, DT#1970-01-01-00:00:00. Out of range: a month not 1..12, a day not in its month (2023-02-29, 04-31), an hour not 0..23, a minute or second not 0..59, a millisecond not 0..999, any part negative, or a date or DT outside 1970-01-01 .. 2106-02-07-06:28:15. A DT’s millisecond is checked, then dropped, as any millisecond coming to a DT is.

  • CODESYS (CAA DTUtil, DateConcat, DTConcat, TODConcat): “In case of invalid entries D#1970-01-01 will be returned”; DT#1970-01-01-00:00 and TOD#00:00 the same, with an error through a pointer.
  • PLCnext (CONCAT_LDATE, CONCAT_LTOD): LD#1970-01-01 for a part out of range and for “Invalid date for month (e.g. 30.02.2021)”, the TOD at midnight, ENO FALSE.
  • matiec has none of Table 36’s CONCAT and SPLIT functions.

Rolling over (2026-02-31 as 03-03, as C’s mktime) and leaving the statement undone were rejected: no vendor documents either.

The time types: ranges and resolution (Ed.3 6.4.2, Table 10)

The standard leaves their range and resolution to the implementer, and allows negative durations (6.3.4, Table 8: T#-14ms). Decided 2026-11-05 (florin), after the vendors:

TypeiecfCODESYS / TwinCATSiemens S7-1200/1500
TIMEsigned 32-bit, ms: ±24d20h31m23s647msunsigned 32-bit, ms: 0 to 49 dayssigned 32-bit, ms
LTIMEsigned 64-bit, ns: ±292 years64-bit, nssigned 64-bit, ns
DATE, DTunsigned 32-bit, seconds since 1970-01-01the sameDATE: days since 1990
TODunsigned 32-bit, ms since midnightthe samethe same
LDATE, LTOD, LDTsigned 64-bit, ns (since 1970, since midnight): 1677-09-21 to 2262-04-1164-bit, ns: the same rangeLTOD, LDT: ns

TIME is signed because the standard has negative durations; the unit is every vendor’s. A TIME result outside the range wraps and is flagged, as an integer’s does (PROPOSAL.md §6.3).

LDATE, LTOD and LDT

Decided 2026-11-08 (florin), after the vendors:

  • The range is all of the signed 64 bits: LDT from 1677-09-21-00:12:43.145224192 to 2262-04-11-23:47:16.854775807, LDATE from LD#1677-09-22 to LD#2262-04-11 (its midnights), LTOD up to 23:59:59.999999999. Ed.3 Table 10 notes n to p make each “a signed 64-bit integer with unit of nanoseconds”; the data type pages of CODESYS, TwinCAT and PLCnext give this range. CODESYS’s and Beckhoff’s literal pages give 1970 to 2554 instead, the unsigned range, against their own data type pages.
  • A long value made a short one drops what the short type is too coarse for, rounding down, unflagged - LDT_TO_DT the fraction of a second, LTOD_TO_TOD, LDT_TO_TOD and LTIME_TO_TIME what is below a millisecond; LTIME#-1500us is T#-2ms. As a DT drops milliseconds (above).
  • A long value the short type cannot hold gives the short type’s default value, flagged IECF_FAULT_CONVERSION: LDT_TO_DT, LDT_TO_DATE and LDATE_TO_DATE outside 1970-01-01 .. 2106-02-07-06:28:15 give 1970-01-01; LTIME_TO_TIME beyond TIME gives T#0s; LDT_TO_LDATE on 1677-09-21, which has no midnight an LDATE holds, gives LD#1970-01-01. As CONCAT_DATE’s part out of range. Ed.3 Table 22: “Value range errors give an Implementer specific result”; Beckhoff’s conversion page: “Undefined result if the value range is exceeded”. No vendor documents a value.
  • Following the short types’ rules already decided, without a new choice: an LTOD wraps at midnight, unflagged; an LDT, or a difference, outside its type wraps and is flagged IECF_FAULT_OVERFLOW; DT, DATE, TOD and TIME widen exactly (Figure 11’s implicit conversions).

No vendor page found documents conversion rounding, LTOD wrapping or LDT overflow (CODESYS, TwinCAT and PLCnext data type and conversion pages). matiec has none of the L types.

A duration times or divided by a number (Ed.3 Table 30)

MUL_TIME and DIV_TIME take ANY_NUM; how the result becomes a TIME is the implementer’s. Decided 2026-11-07 (florin):

  • By an integer: exact, an overflow wrapped and flagged IECF_FAULT_OVERFLOW, a division toward 0 as DINT’s (T#-7ms / 2 is T#-3ms); by 0 a fault.
  • By a REAL or LREAL: computed in LREAL, rounded to whole milliseconds half to even, as REAL_TO_DINT rounds: T#1s * 2.5 is T#2s500ms, T#3ms * 1.5 is T#4ms. A result outside TIME, or infinite or NaN, is flagged IECF_FAULT_CONVERSION and its statement left undone, as any real made an integer (PROPOSAL.md §6.3); by 0.0 a fault. An LTIME is rounded to whole nanoseconds, in LREAL’s 53 bits.

The vendors found keep less: Panasonic’s MUL_TIME_REAL rounds the REAL to a whole number before multiplying (“The REAL value is rounded off to the nearest whole number”), PLCnext’s MUL converts it to TIME first; CODESYS and TwinCAT document neither. matiec holds nanoseconds in a TIME, so its T#10s / 3 is 3.333333333s.

DATE, TOD and DT at their edges

Decided 2026-11-07 (florin). No vendor documents these in a primary source. PLCnext keeps a TOD of 24 hours or more as plain nanoseconds, with no error (ADD_LTOD_TIME); CODESYS’s TOD is an unchecked UDINT of milliseconds; secondary sources say Siemens wraps.

  • A TOD wraps at midnight, unflagged: TOD#23:00:00 + T#2h is TOD#01:00:00, TOD#01:00:00 - T#2h is TOD#23:00:00. A time of day is a clock, and each TOD stays one a literal can write.
  • A DATE or DT outside 1970-01-01 .. 2106-02-07-06:28:15 wraps and is flagged IECF_FAULT_OVERFLOW, as an integer’s overflow; so is a difference (SUB_DT_DT, SUB_DATE_DATE, SUB_TOD_TOD) outside TIME’s ±24d20h31m23s647ms. Two dates 25 days apart already are: a longer span wants LTIME and LDT.
  • A DT holds whole seconds: milliseconds that come to one - DT + T#1500ms, CONCAT_DATE_TOD of a TOD with them - are dropped, rounding down, unflagged, as CODESYS’s DT (seconds) drops them. A DT literal with a fraction other than 0, or a TOD literal finer than a millisecond, is E0020, since iecf cannot hold it.
  • DATE and DT start at 1970-01-01, so that is their default initial value (D#1970-01-01, DT#1970-01-01-00:00:00): Ed.3 Table 10 leaves it to the implementer whose first day is not 0001-01-01, and CODESYS’s is the same.

STRING and WSTRING (Ed.3 6.4.2, 6.3.3)

The maximum length is the implementer’s. iecf follows CODESYS and TwinCAT, decided 2026-11-05 (florin):

  • STRING is STRING(80); STRING(n) holds up to n characters in n+1 bytes, the last a 0 after the characters. One byte per character, read as Latin-1 (ISO 8859-1), as CODESYS reads it by default. '$00' ends the string where it stands.
  • WSTRING is WSTRING(80); WSTRING(n) is n+1 16-bit units, UTF-16, 0-terminated.
  • The longest n is 65535.

Siemens differs: 254 characters by default, after a two-byte header of maximum and current length.

CHAR (Ed.3 6.4.2, Tables 6, 25, 27)

A CHAR is one Latin-1 character, its code in a byte, '$00' at start, as a STRING’s characters are. Of the vendors checked (2026-11-09) only Siemens S7-1200/1500 (“CHAR 8 ASCII character set”, its S7-1500 comparison table) and RuSTy have a CHAR; CODESYS, TwinCAT and PLCnext list none, and give a string’s character as a BYTE.

  • An untyped 'X' of one character is a CHAR where a CHAR is wanted - given to one, or compared with one - as Ed.3 Table 6 row 1b has it (“String of length one or character CHAR”). Siemens writes varChar := 'A'; RuSTy takes it as an initial value but wants CHAR#'a' in a comparison.
  • A CHAR goes where a STRING goes (Figure 11: implicit), a string of that one character; '$00' gives the empty string, as '$00' ends any string. RuSTy refuses CHAR to STRING; iecf follows Figure 11.
  • STRING_TO_CHAR of an empty string gives '$00', flagged CONVERSION. Ed.3 Table 27: “if the string is empty the target variable is undefined”. No vendor page says; RuSTy’s library reads the string’s first byte, so it gives '$00' with no flag. iecf gives the same value and flags it, as it does for any value a target cannot hold (an LDT before 1970 made a DT) (decided, florin, 2026-11-09).
  • CHAR_TO_BYTE, BYTE_TO_CHAR and CHAR_TO_WORD, _DWORD, _LWORD are Table 25’s binary transfers, CHAR_TO_USINT (Figure 11) the code; RuSTy has the first five and copies the byte.
  • A STRING’s character, s[i], is Ed.3’s: from 1 (“starting with position 1”, 6.4.2), a CHAR. Ed.3 6.4.2 Example 1 d) gives its equivalents, STRING_TO_CHAR(MID(s, 1, i)) and REPLACE(s, c, 1, i), and leaves a position outside the string open. A read there gives '$00', flagged STRING_POSITION, as that MID gives. A write there leaves the string as it is, flagged STRING_POSITION: REPLACE’s results for a bad P (appended past the end, empty below 1) are not taken - a write to a character the string has not changes nothing (decided, florin, 2026-11-09). Writing '$00' cuts the string there, as '$00' ends any string and as CODESYS and TwinCAT do, whose STRING ends with a 0 byte as iecf’s (“value 0 will be the end terminator”, CODESYS forum); Siemens, which keeps the length in a header byte, keeps the '$00' as a character inside (decided, florin, 2026-11-09). CODESYS and TwinCAT count from 0 and give a BYTE (CODESYS’s “Index access to pointers” page, TwinCAT’s on infosys: “STRINGs are zero-based”); Siemens counts from 1 and gives a CHAR; RuSTy has none. No vendor documents a position outside the string.

$N, the newline, is a line feed, as matiec has it; a character a STRING cannot hold, one above U+00FF, is refused (E0020), as is an initial value longer than its STRING[n] (CODESYS error C0198; its STRING page says such a literal is cut instead).

When a string does not fit (Ed.3 6.6.2.5.11)

The standard calls three things errors and leaves their handling to the implementer: a negative length or position, a character position that does not exist, a result longer than the maximum. Decided 2026-11-06 (florin), after the vendors: neither stops the task; both are flagged in ctx->flag, as an overflow is (PROPOSAL.md §6.3).

  • Too long: cut to fit, IECF_FAULT_STRING_LENGTH. Every vendor cuts: CODESYS’s CONCAT “will be silently truncated. No error is produced” (its Standard library, CONCAT); Siemens copies “the part of the IN string which can fit in the OUT string” (S7-1200 System Manual A5E02486680, 8.2.2) and sets ENO to 0 for a function’s result. A function’s result in iecf is never cut on its way (its buffer holds the most its inputs make, at most 65535), only where it is stored.

  • A length or position out of its string: what Siemens gives, which is the one vendor that documents every case (the same manual, 8.2.4, its ENO tables), and IECF_FAULT_STRING_POSITION:

    Result
    LEFT, RIGHT: L < 0empty
    LEFT, RIGHT: L past the endall of IN
    MID: L < 0, P < 1, P past the endempty
    MID: L past the endfrom P to the end
    DELETE: L < 0, P < 1empty
    DELETE: P past the endIN unchanged
    DELETE: L past the enddeleted to the end
    INSERT: P < 0empty
    INSERT: P past the endIN2 appended
    REPLACE: L < 0, P < 1empty
    REPLACE: P past the endIN2 appended
    REPLACE: L past the endreplaced to the end

    A length of 0 takes nothing and is no error; INSERT at P = 0 puts IN2 first, as CODESYS and Siemens do. FIND gives 0 when IN2 is not in IN1, and for an empty IN2. CODESYS documents only INSERT (“If POS is > 255 OR < 0, the result equals STR1”) and keeps a DELETE and REPLACE at P = 0 it calls not correct; TwinCAT’s Tc2_Standard documents none.

Conversions to and from STRING

The text is the implementer’s. Decided 2026-11-06 (florin), after the vendors; where none documents a case, it says so.

To STRINGTextWhy
BOOLTRUE, FALSETwinCAT: “the result is ‘TRUE’ or ‘FALSE’” (tc3_plc_intro, Boolean conversion); CODESYS BOOL_TO_STRING(TRUE) is 'TRUE'
integersdecimal, - only: '-123456'Siemens S_CONV “do[es] not use a leading “+” sign“; CODESYS and TwinCAT silent
BYTE … LWORDdecimal: '255'no vendor documents it; CODESYS users report decimal (forum, unverified). matiec writes 16#ff
REAL, LREALC’s %g, 7 and 15 significant digits, correctly rounded, trailing zeros dropped: '2.5', '1', '1.677722e+07', '1e-05', 'inf', 'nan'CODESYS and TwinCAT: a REAL’s “number of decimal places of the mantissa is limited to 6”, “rounded to the 6th digit”. matiec writes 10 digits ('0.1000000015')
TIME, LTIMEthe literal, lower-case units, those that are not 0: 'T#1d2h3m4s500ms', 'T#0ms', 'T#-1s500ms', 'LTIME#1s5us'TwinCAT TIME_TO_STRING(T#12ms) is 'T#12ms'; CODESYS shows 'T#0MS'
DATE, TOD, DT (with those types)'D#2026-10-03', 'TOD#12:34:56', .123 when the milliseconds are not 0, 'DT#2026-10-03-12:34:56'TwinCAT: 'D#2002-08-18', 'TOD#14:01:05.123', DT_TO_STRING(DT#1998-02-13-14:20) is 'DT#1998-02-13-14:20:00'

From STRING, the text is read as CODESYS and TwinCAT read it: spaces before it skipped (“Spaces before” are allowed, CODESYS STRING_TO), then a literal of the type - a type prefix (INT#5), a sign, a based integer (16#FF, 2#1111_1111), underscores between digits, a REAL’s fraction and exponent, a duration with its T# or LTIME# (any case; a TIME keeps whole milliseconds), TRUE/FALSE in any case or 1/0 - and what follows ignored: '123abc' is 123 (CODESYS: “These are truncated”; TwinCAT: STRING_TO_WORD('34abc') is 34). No literal, or a value its type cannot hold, gives 0 and is flagged IECF_FAULT_CONVERSION, as Siemens gives 0 with ENO = FALSE (“OUT is set to 0 and ENO is set to FALSE”, S_CONV); the task goes on. A REAL is read exactly and rounded once, half to even, to its own 24 bits, not twice through an LREAL.

Neither direction uses a C library: the digits come from big-integer arithmetic in iecf.h, the same on every target (checked against glibc’s %g and strtod/strtof on millions of values, and on wasm32).

WCHAR and WSTRING’s characters (Ed.3 6.4.2, Tables 6, 25, 27)

Ed.3 Table 10 gives WCHAR 16 bits, footnote l the character set ISO/IEC 10646, and Table 27 rows 3 and 8 name UTF-16. Decided 2026-11-10 (florin), after the vendors:

  • A WCHAR is one UTF-16 unit; a character above U+FFFF is two units in a WSTRING, a surrogate pair, and LEN, the positions of Table 34 and ws[i] count units. A WCHAR literal holds one unit, so WCHAR#"${1F600}" is E0020; a lone surrogate, ${D800}, is no character, E0020. CODESYS’s WSTRING page: “According to the IEC 61131-3 standard, the data type WSTRING follows UCS-2 encoding … exactly the fixed length 2 bytes”, U+0000-U+D7FF and U+E000-U+FFFF: the same units for those, and silent above them.
  • A character a STRING cannot hold, one above U+00FF, is skipped by WSTRING_TO_STRING - a surrogate pair whole - and gives '$00' from WCHAR_TO_CHAR; both flagged CONVERSION. Ed.3 Table 27 rows 1 and 6: “converted in an Implementer-dependent way”. Beckhoff’s Tc2_Utilities: WSTRING_TO_STRING2, “Characters that cannot be converted are skipped during the conversion”; WCHAR_TO_CHAR, “The conversion is possible only if the WSTRING character corresponds to the STRING character. Otherwise no character is returned.” CODESYS’s conversion pages say nothing; no Siemens page was found (third-party pages disagree: ‘?’ or dropped). iecf skips as Beckhoff, and flags, as it flags any value its target cannot hold. STRING_TO_WSTRING and CHAR_TO_WCHAR give each Latin-1 code as its unit.
  • WSTRING_TO_WCHAR is Table 27’s, though Figure 11 has none. Ed.3 contradicts itself: Table 27 row 2 defines it (“The first character of the string is transferred; if the string is empty the target variable is undefined”), and Figure 11 marks WSTRING to WCHAR “-” where it marks STRING to CHAR “e”. iecf takes Table 27’s (decided, florin, 2026-11-10): the first unit; an empty string "$0000", flagged CONVERSION, as STRING_TO_CHAR.
  • An untyped "X" of one unit is a WCHAR where a WCHAR is wanted (Table 6, 2b), as 'X' is a CHAR; 'X' is never a WCHAR, nor "X" a CHAR. WSTRING#'..' and WCHAR#'X' (Table 6, 4c and 4d) read $hh, as single quotes do; double quotes read $hhhh (Table 6, 2h). WCHAR#16#263A is the unit of that code, as CHAR#16#41.
  • WCHAR_TO_WORD, _DWORD, _LWORD (Table 25) and WORD_TO_WCHAR (Figure 11) are binary transfers.
  • A WSTRING’s text read as a number (WSTRING_TO_INT) is read as a STRING’s; a character outside ASCII as one no literal has. <T>_TO_WSTRING gives the STRING’s text, each byte a unit.
  • A WSTRING at an address is laid out as any part there (below): its units back to back, each little-endian, at an odd byte too; a WCHAR there is 16 bits, as a WORD. A reference to one reads and writes it there the same way (iecf_wref_get, iecf_wref_put).

Directly represented variables: numbering and byte order (Ed.3 6.5.5)

The standard leaves the mapping of an address to memory to the implementer. Decided 2026-11-05 (florin), after the vendors:

  • The number counts bytes. %IX3.5 is bit 5 of byte 3, %IW3 bytes 3 and 4, %MD48 bytes 48 to 51; %MW0 and %MW1 overlap. As Beckhoff (“addressing is always bytewise”) and Siemens; CODESYS counts in units of the size (%MD48 is bytes 192 to 195).
  • Little-endian: a word’s first byte is its low byte, as CODESYS and TwinCAT on their usual targets, and EtherCAT; Siemens and PROFINET are big-endian.
  • One-level addresses only for now; %IX1.2.3 is refused.

A variable’s size at its address (Ed.3 6.5.5.3)

Ed.3 6.5.5.3: the address “defines the start address of the memory location and does not need to be of equal or bigger size than the given direct representation, i.e. empty memory or overlapping is permitted”; its own examples put a REAL at %IB12. CODESYS writes “The data type should match the size prefix”, and puts a BOOL at %QW0 in its own example; its pages say nothing of a mismatch. Decided 2026-11-08 (florin):

  • A variable starts at its address and takes its own type’s size from there, little-endian: BOOL a bit; BYTE, SINT, USINT 1 byte; WORD, INT, UINT 2; DWORD, DINT, UDINT, REAL, TIME, DATE, TOD, DT 4; LWORD, LINT, ULINT, LREAL, LTIME, LDATE, LTOD, LDT 8. A time or date type is its value as iecf holds it (TIME milliseconds, DATE seconds since 1970, …, above), the bytes CODESYS gives (its TIME, DATE, TOD and DT are a DWORD, the long ones an LWORD). A size prefix other than the type’s is warned (W0004), not refused.
  • A BOOL at a byte, word or longer takes that whole byte, as CODESYS documents: TRUE written as 1, FALSE as 0, and any value but 0 read as TRUE. Only a BOOL starts at a bit: a wider type at %MX0.3 is E0022.

Arrays, structures and strings at an address

Ed.3 6.5.5.3 places them too (OUTARY AT %QW6: ARRAY[0..9] OF INT) and says nothing of how they lie. CODESYS and TwinCAT pad a structure to natural alignment by default (pack mode 4 or 8, “depending on the device description”), and {attribute 'pack_mode' := '1'} packs it with “no memory gaps”; a BOOL in a structure takes a byte (“BOOL types, which reserve 8 bits each”), bits are packed only in their own BIT type, which an array may not hold; a STRING(n) is n + 1 bytes. Decided 2026-11-08 (florin):

  • No gaps: from its address, an array’s elements and a structure’s fields back to back, in order, each its own size, as CODESYS’s pack_mode 1 - the same on every target, and as a fieldbus lays out its process data. A multi-dimensional array goes row by row, its last index fastest.
  • A BOOL inside takes a byte, 1 or 0, read TRUE for any value but 0, as a BOOL at a byte does. ARRAY[0..15] OF BOOL is 16 bytes.
  • A STRING[n] is n + 1 bytes, its characters then a 0; bytes after the 0 are left as they were. A WSTRING[n] is 2(n + 1) bytes, its units little-endian then a 0 unit; units after the 0 are left as they were too.
  • W0004 compares the size prefix with the first element or field: ARRAY[0..9] OF INT at %QW6 is silent, at %QB6 warned. An array, structure or string starts at a byte: at a bit it is E0022.
  • Its initial value, if the declaration or its type gives one, is written at the start (not for %I, the platform’s).
  • Not yet: a RETAIN one, a FUNCTION_BLOCK parameter at an address (E0009).

A bit string to BOOL (Ed.3 Figure 11, Table 25)

Figure 11 defines no conversion from BYTE, WORD, DWORD or LWORD to BOOL; Table 25 lists them as “Binary transfer of the rightmost bit into the target”. CODESYS and TwinCAT accept them and give TRUE for any value but 0. Decided 2026-11-08 (florin, PROPOSAL.md §6.8): as CODESYS and TwinCAT, and an extension, off by default as every extension is (bit-string-to-bool, extensions); without it, E0013.

References (Ed.3 6.4.4.10)

REF_TO, REF(), ^ and NULL. Ed.3 calls dereferencing NULL “an error” and leaves the check to the implementer (“at compile time, by the runtime system, or by the application program”). CODESYS and TwinCAT have no REF_TO: their POINTER TO and REFERENCE TO are set with ADR() and REF=; on an invalid pointer TwinCAT’s runtime “is stopped as soon as any access takes place via this pointer”, unless the project’s CheckPointer function, which has “no default implementation”, catches it; CODESYS cautions not to “return Pointer-TO variables of functions and methods to the caller or assign them to global variables”, and warns (“invalid assignment target”) when a reference is taken to an input. The reference compiler accepts REF_TO with its -r option and dereferences a raw C pointer. Siemens’ pages were not reached. Decided 2026-11-08 (florin):

  • A NULL reference dereferenced is a fault, IECF_FAULT_NULL, as an index out of range is: the statement stores nothing and the task stops.
  • REF() of a temporary is an error (E0026), as Ed.3 says: a VAR_TEMP, or any variable of a FUNCTION but a VAR_EXTERNAL. So is REF() of EN or ENO (Ed.3 6.6.1.5, rule 8).
  • REF() of what cannot be assigned is an error (E0026): a CONSTANT, a function block’s output taken from outside. Ed.3 says nothing; a reference would write past the checker.
  • REF() of a variable at an input address warns (W0005), as CODESYS does: the next inputs overwrite what is written through it.
  • A reference into the process image is built: its area, byte and bit, read and written as the variable is there (docs/c-output.md §4).
  • A RETAIN reference is an error (E0027), and so is a retained structure, array or function block instance that holds one: after a restart it would reference nothing. So is a reference at an address, and, as Ed.3 says, a reference as a VAR_IN_OUT.
  • Two references compare with = and <>, equal when they reference the same variable, and either with NULL. Table 12 lists only the comparison with NULL; the reference compiler compares two as well. Ordering them would be the reference arithmetic Ed.3 calls “not recommended”.
  • A reference takes only its own type: arrays of other bounds, or a subrange and its base, are other types (E0013). That holds for any array assignment too, which iecf used to let through for arrays of one element type and number of dimensions.
  • CODESYS’s REFERENCE TO is built as an extension, off by default as every extension is (reference-to, extensions): a REFERENCE TO is a REF_TO dereferenced wherever it is used, so an unbound one used faults as NULL does. What the CODESYS and TwinCAT pages do not say, iecf infers: a call that does not pass a function block’s REFERENCE TO input leaves it bound as it was (an IEC input keeps its value), and r.f, r[i] and r(...) go through it.
  • Not yet: a FUNCTION’s input or result of a reference type (E0009), as for every type that is not elementary.

Interfaces kept and connected (Ed.3 6.6.6.5)

Decided 2026-11-11 (florin), after the vendors:

  • A RETAIN interface is an error (E0027), as a RETAIN reference is, and so is a retained structure, array or block instance that holds one: Ed.3 calls an interface “a reference to an instance”, and CODESYS “always a reference to the assigned function block instance”. After a new build the instance may be elsewhere. CODESYS and TwinCAT only warn of a POINTER TO in retained memory (“their addresses could change”), and move interface references themselves at an online change, which iecf does not do; neither documents an interface in RETAIN. An interface in an instance kept whole for a RETAIN variable of its own is kept across a warm start of the same build, as a reference there is.
  • A PROGRAM’s input or output of an interface is connected as an elementary one is (6.8.2): an input set before each run from a global interface, a global instance or NULL; an output stored after it into a global interface, of its own or a base interface. CODESYS and TwinCAT give a program’s inputs by a call (PLC_PRG(in1 := 2)) and have no CONFIGURATION connections, so no vendor says more.

Sources: CODESYS, interfaces, CODESYS, PERSISTENT, Beckhoff, remanent variables, CODESYS, program.

Array bounds and ARRAY[*] (Ed.3 6.4.4.5, 6.5.3)

Chosen 2026-11-11, after the vendors, where Ed.3 leaves it open:

  • A constant in a bound is a POU’s own VAR CONSTANT, or a VAR_GLOBAL CONSTANT through its VAR_EXTERNAL CONSTANT (Ed.3 6.6.1.2.4: “constant variables”). A TYPE declaration sees the global constants directly: Ed.3 does not say what a TYPE outside a CONFIGURATION sees, and CODESYS takes a GVL’s VAR_GLOBAL CONSTANT in any declaration’s bounds (its error C0161, “Border … of array is no constant value”, for a plain VAR_GLOBAL). A name the POU declares hides a global constant of that name.
  • Bounds are within DINT, as CODESYS documents (“maximum of the data type DINT”): LOWER_BOUND and UPPER_BOUND give a DINT, as PLCnext’s do.
  • ARRAY[*] is an in-out, of a FUNCTION, a method or a FUNCTION_BLOCK: where CODESYS and TwinCAT allow it (“only allowed in VAR_IN_OUT”, Beckhoff).
  • ARRAY[*] is also a FUNCTION’s or method’s input or output, the caller’s array itself and not a copy (decided 2026-11-11, florin: “the Siemens way”). Ed.3 allows the declaration and says nothing of how it is passed. Siemens’ Programming Guideline for S7-1200/S7-1500 (V1.5, Table 3-5, “Overview for transfer of parameters”) has an FC’s Input, Output and InOut of a structured data type all passed as “Reference”, and an FB’s Input and Output as “Copy”, its InOut as “Reference”; its 3.6.3 has Array [*] “transferred to functions and function blocks”. So in iecf: the input is read only (E0015 to write it), each is given an array at every call (E0016), and a FUNCTION_BLOCK takes one as an in-out only, its inputs and outputs being kept in the instance. Beyond Siemens, iecf sets the output’s elements to their initial value as the call starts, as Ed.3 6.6.2.2 has a FUNCTION’s variables. Siemens’ help says the same of where it is declared: “In functions (FC) you can use ARRAY[] in all declaration subsections. In function blocks (FB) you can declare ARRAY[] only as an in-out parameter in the “InOut” section.“
  • A constant expression in an initial value is worked out by the compiler (2026-11-11; florin has not decided this), with the constants a bound names; a method also names its type’s and its type’s bases’, and a TYPE’s and a VAR_GLOBAL’s initial value the global constants directly, as a TYPE’s bound does. Ed.3 Figure 6 has “constant expression” for each type and its grammar says only “a constant expression must evaluate to a constant value at compile time”. So: an integer one is worked out exactly, as a bound is, and must fit the variable (E0020) - K * 200 with K an INT of 300 is 60000, refused for an INT and taken by a DINT, where the same expression in a statement wraps in INT and is flagged; a REAL one in single precision, an infinite result E0020; a division by zero and a constant that names itself E0032. No vendor’s documentation was checked for these.
  • A standard function of constants in an initial value is worked out too, and a variable there is refused (2026-11-12; florin has not decided this). Those whose result is exact - ABS, MIN, MAX, LIMIT, SEL, MUX, the shifts, the conversions between numbers, a duration times a number - are worked out as the operators are, what the run would flag being E0020 (INT_TO_SINT(300)) and what it would fault on E0032 (MUX(2, 5, 6)). The mathematics of Table 28 and ** come from the target’s <math.h> or the platform’s own (docs/c-output.md §1), so the compiler cannot give their value: it works them out only to tell that the result is a number the type holds - refusing one within a factor of four of the type’s largest, E0020 - and the target computes it as the variable is set up. A variable or a FUNCTION of the program’s in an initial value is E0032: Ed.3’s grammar has a constant expression there. CODESYS takes any expression (“Initialization with … any valid ST expression”, and computes it as the application starts) and so does TwinCAT; that could be an extension, and is not one yet. Quoted from memory of the CODESYS help, not checked against it.
  • A part of a constant array or structure is a constant in an initial value (2026-11-12; florin has not decided this): KA[2], KP.x, the index a constant expression. Ed.3’s grammar does not say whether a constant’s element is a constant expression; CODESYS and TwinCAT take one (from memory, not checked). The index is checked by the compiler: outside the array is E0032, where the run would fault.
  • A whole ARRAY[*] is assigned from another or from an array of fixed bounds; never to an array of fixed bounds (2026-11-11; florin has not decided this). Ed.3 says nothing of it. Siemens: “The assignment of ARRAY[] to ARRAY[] is permitted if the number of dimensions and the data type match”, “The assignment of an ARRAY with known limits to an ARRAY[] is permitted”, “The assignment of ARRAY[] to ARRAYs with fixed limits is not permitted” (said of supplying parameters), and of two arrays compared, “The exact ARRAY limits do not have to match”. CODESYS and TwinCAT document no assignment of one. What Siemens does when the sizes differ as it runs is not documented; iecf faults (INDEX) and copies nothing, as for an index out of bounds.
  • Arrays, structures and instances are not compared (E0014): Ed.3 Table 33 compares ANY_ELEMENTARY. CODESYS (“All elementary data types are permitted as data types for the operands”; C0069 “Compare not possible on objects of type …” for arrays), TwinCAT (the same sentence) and PLCnext (IN1, IN2 “ANY_(SAFE)ELEMENTARY”) refuse it. Siemens SCL alone compares them, with = and <> only (“PLC data types; ARRAY; STRUCT”, S7-1500 firmware 2.0 on). iecf follows the standard; Siemens’ comparison would be an extension (docs/extensions.md), not built.
  • An array indexed in fewer dimensions than it has is the array of the others: A2[2] of an ARRAY [1..20, -2..2] is an ARRAY [-2..2] (decided 2026-11-11, florin). Ed.3’s Example 2 writes SUM(A2[2]); no rule of 6.4.4.5 states it, and CODESYS, which has both this form and the array of arrays (“an alternative syntax for multidimensional arrays”), documents a row of neither. iecf takes the example as the rule, for both forms, and anywhere an array is used, not only as an argument.
  • A dimension out of the array’s, LOWER_BOUND(a, 3) of a two-dimensional a, is an error when it is a constant (Ed.3 Table 15: “error”), and a fault INDEX when it is computed, as an index out of bounds is. PLCnext gives 0 and ENO FALSE; with no ENO on LOWER_BOUND in Ed.3, iecf stops the statement as any out-of-range access.

Sources: CODESYS, ARRAY, CODESYS, C0161, Beckhoff, declaring an array, PLCnext, LOWER_BOUND, Siemens, Programming Guideline for S7-1200/S7-1500, Siemens, basic information on ARRAY, Siemens, transferring ARRAY tags, Siemens, relational expressions, CODESYS, EQ, CODESYS, C0069, Beckhoff, EQ, PLCnext, EQ.

Function block instances as values (Ed.3 6.6.1.4, 6.6.3.4, Table 72)

Chosen 2026-11-11, after the vendors (florin has not decided these). They are for a plain function block; an instance of a class or of a block with methods, a base or an interface is neither copied nor an input (the objects proposal D11).

  • b := a copies every variable of the instance, the instances in it too. Ed.3 has the assignment as a feature (Table 72, 1d, “Instances of function block type”) and says nothing of what it does. CODESYS: “When an instance is assigned to another instance, all elements and subelements are copied from the one instance to the other instance” (rule SA0014, a performance warning; an error only under its no_assign attribute). Beckhoff: “When such an assignment is made, a copy of the object is made”, and advises against it for a block that holds addresses. PLCnext refuses it (“The assignment of function blocks is not supported”, SemError 1134), and Siemens does not claim the feature (its Ed.3 compliance list of 2014 leaves 1d unticked). iecf copies, as the two that allow it do, and so copies an array or a structure that holds instances.
  • An instance given as a VAR_INPUT is a copy, read in the POU that takes it, not written and not called. The read-only part is Ed.3’s (6.6.1.4 rule 4: “if declared as VAR_INPUT the function block variables can only be read”; 6.6.3.4 rule 7 lets only a VAR_IN_OUT or VAR_EXTERNAL one be called). That it is a copy is CODESYS’s (“The transfer method used is by value”, “the callee receives a private copy”) and TwinCAT’s, and PLCopen’s FAQ says the same of the standard: “Function block instances should be passed as VAR_IN_OUT parameters otherwise the values produced within the internal invocation will not be preserved”. Siemens passes an instance as an InOut only (“Instances of called blocks can be defined as InOut parameters”, Programming Guideline 3.2.6).
  • Two instances are not compared: no vendor documents a = b for them (CODESYS’s EQ takes “all elementary data types”), so it is E0014.

Sources: CODESYS, SA0014, CODESYS, no_assign, CODESYS, parameters, Beckhoff, use of function blocks, PLCnext, SemError 1134, Siemens, IEC 61131-3 compliance, PLCopen, FAQ.

Enumerations and named values (Ed.3 6.4.4.2, 6.4.4.3)

Chosen 2026-11-11, after the vendors, where Ed.3 leaves it open (florin has not decided these):

  • A named value not given a number is one more than the value before it, the first 0. Ed.3’s grammar lets the number be left out and says nothing of what it is. CODESYS: “Starting at 0, the values are continuously incremented by 1”; Beckhoff’s example eRed, eYellow, eGreen := 10, eBlue, eBlack gives “eBlue = 11, eBlack = 12”.
  • The base type is an integer or a bit string type but BOOL, as CODESYS lists them (INT to ULINT, BYTE to LWORD). With none named it is INT, as CODESYS and TwinCAT take it (decided 2026-11-11, florin; it was DINT before, for no reason but being iecf’s first choice), for an enumeration and for a type with named values alike. A value an INT cannot hold is E0020, and needs the type named: DINT (far := 100000).
  • A name two in-place declarations with named values share is used without a type’s name where both give it the same number and base type (2026-11-11; florin has not decided this): an in-place declaration has no name to write before #, and used as the base type the value is the same whichever is meant. Otherwise it is the usual error, “a value of 2 enumerations”.
  • A type with named values is used as its base type (6.4.4.3: “other constants can be assigned, or can arise through calculations”), which is the standard’s text. CODESYS and TwinCAT refuse arithmetic and other constants under their strict attribute, which both now add by default; iecf follows the standard, and its plain enumerations (6.4.4.2) are the strict kind.

Sources: CODESYS, enumerations, Beckhoff, enumerations.

Namespaces (Ed.3 6.9)

Decided 2026-11-09 (florin), each against the vendors in the proposal §2: Siemens TIA Portal V21 and PLCnext Engineer, the two that put namespaces in programs, write the global namespace _.; CODESYS and TwinCAT have only a library’s namespace and qualified_only; RuSTy and matiec have none. Neither Siemens nor PLCnext documents what a short name two namespaces have means, so iecf takes C#’s rules, whose wording Ed.3 6.9.4 follows.

  • A PROGRAM may be in a namespace (6.9.2’s text; its grammar leaves programs out), and a USING may follow a PROGRAM’s name (6.9.4: “a POU”).
  • A short name is found from the inside out: the POU, its namespace, each one around it, the global namespace; at each, a namespace’s own element before what its USINGs bring in. Two USINGs bringing in one name is an error where it is used (E0029), as C#. A dotted name’s first part is found the same way, as Siemens’s “searches relative to the calling block”.
  • A USING at the top of a file is that file’s, as C#’s.
  • A namespace holds no variables (Annex A); N.x is E0028.
  • A namespace and an element of one name side by side is E0012, as Siemens (“Do not give names to program elements that you already use as a namespace”) and CODESYS (a namespace may not be a data type’s name).
  • Ed.3’s PUBLIC.TON for the standard timer cannot be parsed; _., as Siemens and PLCnext, is the extension global-namespace (extensions).

The harness: matiec’s C and iecf’s, run side by side

(It also runs any program on both compilers, side by side, and compares every elementary variable of its symbol table tick by tick, not only the Q and M bytes: iecf-harness compare --reference DIR FILE.st..., on pseudo-random inputs, the same for both and at every run (src/compare.rs). A host given vars 0 0 00 as the script’s first line prints the variables after each line: iecf’s from the symbol table, matiec’s through a vars.c the harness writes from its VARIABLES.csv (src/vars.rs), for the same leaves: every array element (the first 64 of a big array), structure field and function block member, named from iecf’s <config>.symbols.json. CI runs it with --expect-same on the cases compare-cases lists: random inputs reach where iecf decided differently in the others, and that file says where.

It also checks the book’s examples: iecf-harness book builds and runs every row of each table marked <!-- checked --> under docs/, and compares the result and the flag with the row; src/book.rs.)

PROPOSAL.md §5 step 2: iecf’s C reaches matiec’s parity, compared in CI. This directory is that comparison. matiec is the reference compiler: the harness’s files, commands, options and CI steps say “reference” (reference_host.c, reference-skip, iecf-harness reference --reference DIR), and only these documents name it.

A case

cases/<name>.st is a program and its CONFIGURATION, plain IEC 61131-3, the same file for both compilers. cases/<name>.cycles says how many ticks to run and what the inputs are:

extension reference-to  # optional: an extension iecf builds it with
ticks 6                 # one tick is the CONFIGURATION's base interval
0 %IB0 16#03            # at tick 0, before it runs
3 %IX1.2 TRUE
4 %IW2 -5
5 warm                  # a warm restart before tick 5: iecf's <p>_warm,
                        # matiec's config_init__ (it has no warm one)

An extension NAME line switches on an extension of docs/extensions.md for iecf; the case is then not IEC 61131-3, and in reference-skip.

A case can be more than one file: each .st in cases/<name>.d/ is built with <name>.st, in name order, as iecf build takes a project of several files. Only iecf builds those (namespaces: a namespace two files add to); such a case is in reference-skip.

The program shows its results by writing them to %Q and %M. cases/<name>.trace is the trace: one line after initialisation and one per tick, the Q and M bytes in hex, as far as the program addresses them. Addresses count bytes and are little-endian (docs/c-output.md §4).

The trace is matiec’s, written by the harness and checked by hand. A case where iecf decides differently from matiec on purpose - overflow, the counters’ limits (PROPOSAL.md §1b) - has cases/<name>.iecf.trace beside it, and the reason in the case’s comment: overflow and narrow (an integer overflow or conversion out of range flagged; the bytes are matiec’s), edges (the counters count over their type’s range), infinity (an infinite or NaN real flagged) and unconverted (a real that does not fit an integer leaves its statement undone, where matiec’s C is undefined), subrange (a value outside a subrange clamped and flagged, where matiec stores it), strings (a string position past the end flagged), dates (a TOD wrapped at midnight, where matiec’s runs past 24 hours), durations (a TIME in whole milliseconds, where matiec’s holds nanoseconds), sfcdemo (SFC: actions before transitions and a final scan, where matiec tests transitions first), enable (EN = FALSE leaves the statement undone, where matiec assigns 0; ENO FALSE on the function’s own overflow), fbenable (a block with EN = FALSE leaves its outputs untaken, where matiec takes them; ENO FALSE on an overflow in its body), enobody (a body’s ENO := TRUE cannot hide an overflow, where matiec has no flag), and retain and fbretain (RETAIN kept across a warm restart, which matiec’s host does as a cold one). A flag ends the line with flag CODE POU LINE. A fault ends the line with fault CODE POU LINE (docs/c-output.md §5).

The cases cover the Ed.2 core a slice at a time: image (each size of address), arith, bits, control (every ST statement), timers, edges (counters, edges, bistables), pous (user FUNCTIONs and FUNCTION_BLOCKs, VAR_IN_OUT, globals), types (enumerations, subranges, arrays, structures), real, functions (the operators written as functions, SEL, EXPT), strings (STRING and Table 34’s functions), dates (DATE, TOD and DT, Table 30), parts (CONCAT_DATE, CONCAT_TOD, CONCAT_DT and the SPLITs, Table 36; reference-skip: matiec has none of them), splitwrap, splitindex, splitclamp (a SPLIT’s part too big for its output, wrapped and flagged; an output element out of bounds, nothing stored; a part clamped into a subrange; reference-skip too), fbenable (EN and ENO on a function block call; matiec takes the outputs with EN FALSE), fbskip (EN FALSE evaluates no input; reference-skip: matiec cannot compile NOT ENO =>), progio (a program’s VAR_IN_OUT bound to a global in the CONFIGURATION; reference-skip, as connections), enobody (ENO set in a FUNCTION’s and a FUNCTION_BLOCK’s body), splitenable (EN and ENO on a SPLIT; reference-skip, as parts), ldates (LDATE, LTOD, LDT and their conversions; reference-skip: matiec has no L dates), located (time and date types at an address, each its own width, a BOOL at a byte; reference-skip: matiec refuses an LTIME there, and its TIME is a structure), locdata (arrays, structures and a STRING at an address, packed; reference-skip: matiec refuses an array there), refs (REF_TO, REF(), ^, NULL and comparisons, on both compilers: matiec’s iec2c is given -r, which switches Ed.3’s references on), refimage (references into the image, to function blocks and to references, REF() as an initial value, a NULL reference dereferenced; reference-skip: matiec’s C for r^ := ... does not compile and REF_TO a function block crashes it), refto (CODESYS’s REFERENCE TO: REF= to a variable, another REFERENCE TO, an element, an address, a structure and a block, a block’s REFERENCE TO input, __ISVALIDREF, an unbound one used; the extension reference-to, reference-skip), widen (implicit conversions in an assignment, to a block’s input and from its output, to a function’s input, between operands; INT * INT overflowing as an INT into a DINT; reference-skip), fnout (a FUNCTION’s outputs into variables, at an address, their initial values at each call, RETURN; on both compilers), fnoutx (negated, widened, in order, into an element, a call that faults, a FUNCTION without a result; reference-skip), fninout (a FUNCTION’s in-outs, passed on to another; on both compilers), fnorder (a FUNCTION’s externals, and the order of evaluation once calls have effects: left operand first, a fault before a call stops it; reference-skip), chars (CHAR: literals, at an address, in an array and a structure, in calls, to and from STRING and BYTE; reference-skip: Ed.3’s), implicit (the implicit-externals extension in a PROGRAM, a FUNCTION_BLOCK and a FUNCTION; reference-skip), fneffen (EN and ENO on a FUNCTION with an in-out, an output, a VAR_EXTERNAL, and without a result; reference-skip, as fnorder), inoutcopy (an in-out given a variable at an address or of a subrange, a copy stored back, clamped; reference-skip), stringchars (s[i] read and written, past the end and below 1, in an array of strings; reference-skip: Ed.3’s), wstrings (WSTRING and WCHAR: a surrogate pair, Table 34’s functions, ws[i], in an array, a structure and a block, to and from STRING, CHAR, WORD and INT, a character a STRING cannot hold skipped and flagged; reference-skip: Ed.3’s), wlocated (a WSTRING at an address, at an odd byte, in a structure and an array there; reference-skip), methods (CLASS and METHOD: Ed.3’s COUNTER, THIS, a PUBLIC variable, a block with methods and a body, an in-out, an input set from outside, a method of an instance in an instance, EN; reference-skip), inherit (EXTENDS, OVERRIDE, SUPER: Ed.3’s CIRCLE and LIGHT2ROOM, Figure 21’s SUPER() chain, a derived block behind a base-typed in-out and reference, an ABSTRACT class as a FUNCTION’s in-out, a method through a NULL reference; reference-skip), interfaces (INTERFACE, IMPLEMENTS, ?=: Ed.3’s ROOM and ROOM_CTRL, THIS given to an interface, an interface extending another, an ABSTRACT block leaving a method to its derived type, an interface as a FUNCTION’s input, ?= into an interface and a reference and between references, a method through a NULL interface; reference-skip), itfplaces (interfaces wherever a variable can be: array elements and structure fields, a VAR_GLOBAL, initial values that are a local’s and a global’s instance, a FUNCTION’s result, outputs and an input left to its initial value, a block’s output taken into an element and read from outside, a method’s result through an interface’s table and a method’s output, ?= into an element, = of elements, NULL in a field faulting; reference-skip), itfprog (a PROGRAM’s interface inputs and outputs connected in the CONFIGURATION: from a global instance, a global interface and NULL, into a global interface of a base type; reference-skip), flexarrays (ARRAY[] in-outs of a FUNCTION, a method and a FUNCTION_BLOCK, Ed.3’s SUM and a two-dimensional one, passed on, LOWER_BOUND and UPPER_BOUND, a dimension out of range faulting; a block’s in-outs of an array, a structure and a STRING; bounds, a STRING’s length, a subrange and a CASE’s labels naming constants; a row of an array of arrays and of an array of two dimensions given to an ARRAY[], copied, at an address, and taken of an ARRAY[, ]; reference-skip), flexio (ARRAY[] inputs and outputs of a FUNCTION and a method, the caller’s array itself: read, passed on, an output’s elements set to their initial value first, given by name and in order, a constant array and a row given, an index out of range faulting; reference-skip), instvalues (a function block instance as a value: assigned, in a structure and an array copied, an input of a FUNCTION and of a block, a block’s output, a standard block copied, an input set apart from a call, a FUNCTION’s result, output and own instance; reference-skip: matiec refuses an instance in a structure, as a FUNCTION’s result and as a FUNCTION’s variable), callparts (a part of a FUNCTION’s result read at the call: a field, an element by a constant and a computed index, a variable of an instance, in an expression and a condition, a call with effects made once, an index out of range faulting; reference-skip: matiec refuses a part of a call), holders (a structure and an array that hold instances as a block’s and a FUNCTION’s input, output, in-out and result, an ARRAY[] of instances, a PROGRAM’s connections of an array, a structure and an instance; reference-skip), flexcopy (a whole ARRAY[] assigned from another and from an array of fixed bounds, in two dimensions, onto itself, sizes that differ faulting; reference-skip), inoutdata (an array and a structure at an address, a row of one, and one through a reference, given to an in-out and to an ARRAY[] input, output and in-out of a FUNCTION and a block; reference-skip), fbargs (a block’s in-out given and its output taken into an element by a constant and a computed index, a field, a variable through a reference and one at an address, widened and negated; an enumeration in place with named values; reference-skip), constinit (initial values that are constant expressions: constants of each type, of constants, global, a block’s in its method and in a derived block, in a TYPE; reference-skip), constfns (initial values that are standard functions of constants: worked out by the compiler, the mathematics of Table 28 checked by it and computed by the target as the variable is set up - in a program, a TYPE, a field, an array, a global, a block’s and a FUNCTION’s input; a part of a constant array or structure; reference-skip), single (a SINGLE-triggered task, one with SINGLE and an interval, one whose INTERVAL is a variable changed as it runs; reference-skip), retainloc (RETAIN variables at addresses, of a program, a global and a block, across a warm restart; reference-skip), enointo (a block’s ENO and outputs taken into an element, a field and a bit; the index checked before the block runs; reference-skip), locmore (located variables without a name; a FOR variable at an address, ending, left by EXIT, moved by its body, not run; reference-skip), partaccess (bits, bytes, words and double words of bit strings, read and written, of variables, elements, a variable at an address and through a reference; reference-skip), edgeinputs (R_EDGE and F_EDGE inputs of a block and a program over a toggling input; reference-skip), nesten (calls with EN nested in another’s arguments, ENO on each; EN on a FUNCTION giving an array or a structure; reference-skip), connect2 (program connections of strings, subranges and fields, and in-outs of every kind - structure, array, STRING, instance, at an address, a subrange of other bounds; reference-skip), targetidx (an assignment’s target indexed by a FUNCTION with effects and by a real converted; a statement whose conversion fails faulting on nothing after it; a real fault last; reference-skip), refmore (a FUNCTION giving a reference, stored and called through; a block’s input and a whole instance set through a reference; a standard block’s input set from outside; REF() of an interface variable; a NULL dereferenced last; reference-skip), selectdata (SEL, MUX and MOVE of arrays, structures, instances, interfaces and references, NULL among them; MUX’s K out of range last; reference-skip), extcompare (GT, GE, EQ, LE, LT of more than two inputs, a FUNCTION with effects among them called once; the negation of a TIME; the shifts of a BOOL; reference-skip), subparams (a subrange as a FUNCTION’s input, output, in-out and result and a block’s in-out; a subrange starting at its lower limit; reference-skip), stdinout (a standard block’s instance as a VAR_IN_OUT of a FUNCTION, one that passes it on, and a FUNCTION_BLOCK; reference-skip), litcalls (a standard function given literals alone, a quotient of real literals: computed in the type wanted, integers staying integers where a real is wanted; reference-skip), enums (types with named values used as their base type, a value not given, a WORD base, INT when none is named; an enumeration as a FUNCTION’s input, output and result and a block’s in-out, a result not set and an input not given the type’s initial value; one declared in place; reference-skip), fnparams (a FUNCTION’s and a method’s STRING, array and structure inputs, outputs, in-outs and results: a STRING input cut and flagged where it is declared, an input left to its initial value, an array result given to another’s input, a STRING result through an interface and an override, a WSTRING one; reference-skip), instances (instances besides a POU’s own variable: an ARRAY OF TON given [2(PT := T#20ms)], arrays of a block and of a class each element its own initial value, a STRUCT with a TOF field, called and read there, an element given to an in-out and an interface, an index out of bounds faulting before the call, a block holding an array of instances, global instances through VAR_EXTERNAL, RETAIN and plain arrays of a block that keeps a count across a warm restart; reference-skip), wrefs (REF_TO a WSTRING at an odd byte and in C memory, one reference switching between them, read, stored, by r^[i], a structure holding one copied through a reference; reference-skip), durations (TIME times and divided by a number), enable (EN and ENO on calls), sfcdemo (Prokop’s SFC demo), sfc (every qualifier, divergence and convergence, PRIORITY; reference-skip: matiec reads no action’s Q nor PRIORITY), blockdata (arrays, structures and STRINGs in and out of a FUNCTION_BLOCK), convert (to and from STRING, where matiec’s text is iecf’s), tasks (two intervals), temp (VAR_TEMP), fbretain (RETAIN and NON_RETAIN inside a FUNCTION_BLOCK), overflow and narrow (where iecf flags), infinity, unconverted, subrange. Apart from those five they stay inside what both compilers define: no overflow, no conversion out of range. A test runs iecf check on each: errors fail it, warnings (a deprecated form both compilers read) do not.

Running it

cargo run -p iecf-harness -- reference --reference ~/matiec          # compare
cargo run -p iecf-harness -- reference --reference ~/matiec --bless  # write the traces
cargo run -p iecf-harness -- reference --reference ~/matiec timers   # one case
cargo run -p iecf-harness -- iecf                                    # iecf's C
cargo run -p iecf-harness -- iecf --bless overflow                   # write its .iecf.trace

--reference (or $IECF_REFERENCE_DIR) is a built beremiz/matiec checkout: iec2c at its top, lib/ beside it. CI builds commit 7949c0b, the one the traces were written with. $CC is the C compiler, cc by default.

How matiec’s C is run: reference_host.c keeps matiec’s located variables in its own variables, copies the image into them before a tick and the changed ones back after (matiec has no process image of its own), sets __CURRENT_TIME to the tick times the base interval, and calls config_run__. reference_fixes.h covers two gaps between matiec’s code generator and its library, as Prokop does (PROPOSAL.md §1b).

The iecf side builds each case with iecf (the library, as iecf build does), compiles the C as strict C99 with every warning an error (docs/c-output.md §1), and runs it under iecf_host.c: the platform side of the contract in a page - the image, the context, a scheduler that runs each task on the ticks its interval divides, by priority. CI runs it with the sanitizers on. No matiec is needed.

reference-skip lists the cases matiec cannot compile (PROPOSAL.md §1b): the reference side skips them, and each has only a .iecf.trace, checked by hand - connections, a program’s inputs and outputs connected in the CONFIGURATION; sfc; parts and the four split* cases, Table 36’s CONCAT and SPLIT functions, which matiec lacks; fbskip, a negated output, which crashes it; progio, program connections again; ldates, the L dates, which matiec lacks; located, an LTIME at an address, and locdata, an array at an address, which matiec refuses; refimage, r^ := ... and REF_TO a function block, which it cannot compile; refto, REFERENCE TO, an extension it does not have; widen, implicit conversions, which matiec (Ed.2) does not make; fnoutx, whose NOT q => crashes it and whose output into an array element it cannot compile; fnorder, VAR_EXTERNAL in a FUNCTION, which it (Ed.2) refuses; chars, CHAR, which it (Ed.2) has not; implicit, the implicit-externals extension; namespaces, NAMESPACE and USING (Ed.3), which it has not, over two files; globalns, the global-namespace extension; fneffen, EN on a FUNCTION with effects, with VAR_EXTERNAL in a FUNCTION again; inoutcopy, whose array element given as an in-out the reference compiler’s C cannot compile; stringchars, a STRING’s character s[i] (Ed.3); wstrings, WCHAR and ws[i] (Ed.3); wlocated, the same and an array at an address.

The generator is built in slices; iecf-pending lists the cases it does not cover yet. Each must fail with E0009 alone, and the run fails when one of them builds - it comes off the list then, and its trace must match.

The survey: Prokop’s programs through iecf

Prokop is iecf’s first user. Its toolchain tests feed matiec’s iec2c every program they build; survey.py capture runs them with a stand-in iec2c that keeps a copy of each, and survey.py run builds each with matiec and with iecf build, says where iecf stops, and runs each that both build side by side (iecf-harness compare), every variable compared for 40 scans on pseudo-random inputs.

survey.py capture ~/Workspace/Repos/prokop ~/Workspace/External/matiec WORK
survey.py run ~/Workspace/External/matiec WORK > table.md

The programs are Prokop’s, and stay out of this repository: a program is named by the first 12 hex digits of its SHA-1, which capture gives again from the same Prokop commit. results.md is the last run. Some programs are tests that give matiec a wrong one on purpose; those are “reference fails” (matiec, the reference compiler, refused it) and do not count.

2026-11-07, Prokop e61378c9, iecf (every variable, array element, field and block member compared), matiec 7949c0b.

What does not build: IL (il_div, il_mod), not iecf’s - Ed.4 without IL (PROPOSAL.md §6.1).

Of those both build, each that does not run the same, and why - each where iecf decided differently, or matiec’s side cannot run it:

  • c583e73f52a4, Prokop’s SFC demo: iecf runs the actions before the transitions, with a final scan (docs/dependencies.md, SFC).
  • 7d80e41fddc8: dout0 AT %QX0.0 and pv AT %QW0 overlap; iecf’s image is one memory, matiec keeps a variable per address.
  • d779cf508890: its test expects matiec’s text, '16#ff' and 'TOD#12:34:56.500000'; iecf writes '255' and 'TOD#12:34:56.500' (docs/dependencies.md, conversions to and from STRING).
  • de13ecfb543e: divides by zero; iecf faults, matiec goes on.
  • 255308ff121c: divides by zero; matiec’s C dies of SIGFPE.
  • 64523234ff8c: declares one address twice, which the harness’s matiec host cannot hold.

55 programs; 51 build with the reference compiler; 50 of those iecf builds; 44 of those run the same, every variable compared

programiecffirst thing it stops at, or how it runs beside matiec
0510d587a215E0005an expression on its own is not a statement: assign it with ‘:=’, or call a function or block
179bdd6fe9b3reference fails179bdd6fe9b3.st:7: error: invalid specification in global variable declaration.
1ce81a4aa3fcreference fails1ce81a4aa3fc.st:4: error: invalid variable(s) declaration.
248d0c3dc6e5reference fails248d0c3dc6e5.st:4: error: invalid expression after ‘:=’ in ST assignment statement.
2c61deb63dc2reference fails2c61deb63dc2.st:4: error: invalid expression after ‘:=’ in ST assignment statement.
7d80e41fddc8buildsdiffers at tick 0: matiec RES0.INSTANCE0.DOUT0 0, iecf RES0.INSTANCE0.DOUT0 1
d779cf508890buildsdiffers at tick 0: matiec RES0.INSTANCE0.OK_DWORD 1, iecf RES0.INSTANCE0.OK_DWORD 0
c583e73f52a4buildsdiffers at tick 4: matiec 4 Q 01 01 00 M, iecf 4 Q 00 00 00 M
de13ecfb543ebuildsiecf faults at tick 0: 0 Q M fault 1 RATIO 12
64523234ff8cbuildsreference fails: cc
255308ff121cbuildsreference fails: the host (signal: 8 (SIGFPE) (core dumped))
230769e20509buildssame (40 ticks, 1 variables)
3ad71432cf5cbuildssame (40 ticks, 1 variables)
18c8c52b215ebuildssame (40 ticks, 10 variables)
5d9cc678a7bbbuildssame (40 ticks, 10 variables)
1e6bcc1d3c5abuildssame (40 ticks, 11 variables)
2a0e80d6c30cbuildssame (40 ticks, 11 variables)
bfb5e586cc47buildssame (40 ticks, 11 variables)
982a05cc407fbuildssame (40 ticks, 15 variables)
0c84b3c7c398buildssame (40 ticks, 2 variables)
0ef1d1027ab4buildssame (40 ticks, 2 variables)
497b4eace0babuildssame (40 ticks, 2 variables)
74423056b107buildssame (40 ticks, 2 variables)
7833ba53e5f5buildssame (40 ticks, 2 variables)
794d4d8bf3d7buildssame (40 ticks, 2 variables)
a6b137a4922cbuildssame (40 ticks, 2 variables)
ad18002b9ab6buildssame (40 ticks, 2 variables)
e083218e28b5buildssame (40 ticks, 2 variables)
e2e500751e1abuildssame (40 ticks, 2 variables)
fb5e1709002abuildssame (40 ticks, 2 variables)
a08a5c3a375ebuildssame (40 ticks, 23 variables)
5ea551f0ca8cbuildssame (40 ticks, 24 variables)
7ceadb39147fbuildssame (40 ticks, 29 variables)
03a46577ef84buildssame (40 ticks, 32 variables)
34cd70a978b2buildssame (40 ticks, 4 variables)
3e9f8d035dfcbuildssame (40 ticks, 4 variables)
464b42244ca3buildssame (40 ticks, 4 variables)
74940fbd8c9dbuildssame (40 ticks, 4 variables)
91702e819e6cbuildssame (40 ticks, 4 variables)
a938c7dd8ee2buildssame (40 ticks, 4 variables)
f1251103eedabuildssame (40 ticks, 4 variables)
2f3e6e7f563abuildssame (40 ticks, 5 variables)
adc5e97901d9buildssame (40 ticks, 5 variables)
aea623321632buildssame (40 ticks, 5 variables)
51ec13ab65f5buildssame (40 ticks, 6 variables)
f4297b60aff7buildssame (40 ticks, 6 variables)
ff2742d2d583buildssame (40 ticks, 6 variables)
8b5aba97b8e3buildssame (40 ticks, 69 variables)
4fc729c06cf9buildssame (40 ticks, 7 variables)
51fc2c5b2de6buildssame (40 ticks, 7 variables)
a2a5a26b53e9buildssame (40 ticks, 7 variables)
d3f6f71ca368buildssame (40 ticks, 7 variables)
f751e9830fc3buildssame (40 ticks, 8 variables)
c0169b32e6e3buildssame (40 ticks, 9 variables)
33310c6a515abuildssame (40 ticks, 99 variables)

iecf - the IEC Forge, an IEC 61131-3 compiler for Prokop

PROPOSED 2026-11-05. Decided the same day: the name, the repository, the edition, the implementation language, the semantics and RuSTy first (§6); open: the IEC name policy (§6.5). florin asked whether Prokop could have a matiec-like compiler of its own, what matiec’s limitations have been during Prokop’s development, and said it must be IEC 61131-3 compliant. This file answers with the limitations as the repository recorded them (§1), what the compiler would be (§2-3), what it costs (§4), an order (§5), and florin’s decisions (§6).

Working name: iecf, the IEC Forge (florin, 2026-11-05), in its own repository (§6.6). Free on crates.io, npm and PyPI; the GitHub account iecf is a dormant personal one, so the repository would be raspihats/iecf. IECF is also the Independent Electrical Contractors Foundation, a US trade charity. Every name starting with “iec” carries one open question: IEC is the International Electrotechnical Commission’s name, and its policy on use of the name has not been read (§6.5).

Paths such as spec/abi.md, toolchain/MATIEC.md and README.md are in the Prokop repository (raspihats/prokop), not this one.

Statements about the standard’s text that come from memory, not from the text or a measurement in this repository, are marked (check). None of them may become a compliance claim until checked against the purchased standard. Ed.2 (2003) and Ed.3 (2013) are in docs/, kept locally and git-ignored (copyright); clause numbers below are theirs. Ed.4 is not bought yet.

1. matiec’s limitations, as Prokop met them

Sources: toolchain/MATIEC.md §7, spec/standard-functions.md, spec/abi.md §6, spec/io-tags.md §7, spec/graphical-languages.md §1, spec/debugging.md, and the Workbench’s generators and pre-checks. matiec is beremiz/matiec, measured on 7949c0b.

1a. IEC 61131-3 it does not implement

matiec implements the standard’s 2nd edition (2003).

MissingAs met
LTIME, LDATE, LTOD, LDTEd.3 types; absent (standard-functions §2a, abi §6)
CONCAT_DATE, CONCAT_TOD, CONCAT_DT, SPLIT_DATE, SPLIT_TOD, SPLIT_DTEd.3 Table 36; not in its library, so the parser refuses a call (found 2026-11-08, harness parts)
STRING[n]Parsed, never finished: the type checker gives it no type and the C generator dies - an internal error as a global, a segfault as a local (io-tags §7). Every STRING is 126 characters (STR_MAX_LEN)
Characters above 127The lexer refuses 'héllo' in UTF-8 and in Latin-1; programs write '$E9'. There is no encoding (abi §6)
Ed.3 object orientationMETHOD, INTERFACE, CLASS, EXTENDS, namespaces - none; all are Ed.3 (Ed.3 Annex B: “Class, including method, interface”, “Namespaces”), absent from Ed.2. No namespaces is why tags and globals.gvl share one flat namespace (io-tags §8)
Implicit widening conversionsINT + DINT and r := 42 into a REAL are refused, so Blockly writes 42.0 and refuses mixed types (lblk-generate.ts). Right for Ed.2, which wants the conversion functions (2.5.1.4, figures 7-8). Ed.3 6.6.1.6 adds implicit conversion that “shall keep the value and accuracy” (Figures 11-12; Example 6 divides an INT by a REAL)
LD and FBDNo textual front end: both exist only inside a comment in iec_bison.yy, since 2003 (graphical-languages §1). Prokop generates ST from both, so neither has its own diagnostics or debug mapping
An unnamed ARRAY[..] OF across VAR_EXTERNAL“Data type mismatch”: each side is a different anonymous type (io-tags §7)

1b. Bugs

  • A duplicate POU is “Internal compiler error in file ../util/symtable.cc at line 120”, no file, no line. The Workbench refuses it first (build-files.ts, duplicatePouFiles).
  • TIME arithmetic did not build: the ST printer writes _____time_add, the library defines __time_add. matiec_overrides.h maps the four names.
  • Error lines and words that mislead: a located and a plain variable in one block is reported on the plain line, with words that change with the order; a variable named dt draws the same words. matiec_hints.py exists to explain them.
  • iec_lib_* left undefined: the generated code calls math, printf and memcmp wrappers matiec does not define. Each failed at link until musl was vendored (toolchain/libm/, toolchain/printf/).
  • REAL_TO_STRING prints %.10g, '0.1000000015' for 0.1; overridden to 7 and 15 significant digits.

Found by the harness (§5 step 2, harness/), 2026-11-06, matiec 7949c0b:

  • The counters stop early. CTU and CTUD count up only while CV < PV, CTD down only while CV > 0; Ed.3 Table 45 has CV < PVmax and CV > PVmin. A CTU with PV 3 stops at 3 after four pulses (harness/cases/edges.st).
  • Program connections in a CONFIGURATION: a literal given to a program input, PROGRAM a WITH fast : counter(inc := 1);, crashes iec2c (SIGSEGV); a global given to it, or an output connected with =>, gives C that does not compile (B.INC = *TEN). Ed.2 allows both.
  • An array in VAR_TEMP gives C that does not compile (__SET_VAR(data__->,ARR,,)).
  • A number times a duration, n * T#1s (Ed.2 Table 30 allows either order), gives C that does not compile: __time_mul is passed its arguments the wrong way round. Ed.3’s MUL_TIME and DIV_TIME are unknown; matiec has Ed.2’s MULTIME and DIVTIME.
  • A negated output, NOT Q => x or NOT ENO => x (Ed.3 6.6.1.4, a call’s output inverted as it is taken), is “Internal compiler error in file function_call_param_iterator.cc at line 588”, for a function block’s output or a function’s ENO alike (found 2026-11-08, harness/cases/fbskip.st).
  • A function block called with EN FALSE still has its outputs taken: fb(EN := FALSE, Q => x) sets x from the instance (harness/cases/fbenable.st). Ed.3 leaves it to the implementer, but Example 4, the external form, takes nothing.

1c. Behaviour that differs by how a thing is written

Both editions call division by zero and a result out of its type’s range errors (Ed.2 Annex E, Table E.1; Ed.3 7.3.2 and the numerical functions), and leave what happens on an error to the implementer, who must document it (Ed.2 1.5.1; Ed.3 5 e). matiec documents nothing and is inconsistent (standard-functions §2b):

  • Integer division by zero: ST / and IL DIV trap (FAULT); ST MOD gives 0; the DIV()/MOD() functions - and so FBD and LD - give 0 with ENO FALSE; IL MOD traps.
  • Overflow: INT and SINT wrap through C’s promotion to int; DINT and LINT trap on the smallest value divided by -1. Signed overflow in the generated C is undefined behaviour, which clang may optimise on.
  • DINT_TO_TIME(1500) is 1500 seconds; CODESYS and Siemens count milliseconds. Neither edition defines an integer-to-TIME conversion: each is an implementer extension.
  • An infinite REAL converts through LINT to a quietly wrong integer.
  • Array indexes are not checked: an index out of range writes a neighbour, inside the wasm sandbox.

1d. What it does not give a platform

  • No symbol table. VARIABLES.csv is paired with LOCATED_VARIABLES.h by position, checked by count (matiec_symbols.py). It lists no array, structure, enumeration or function block internals, so matiec_aux.py and matiec_retain.py rebuild them from the program text.
  • RETAIN is a flag. __INIT_VAR resets the value on every init; the RETAIN of an array or structure is dropped silently. matiec_retain.py keeps retain field by field (abi §6).
  • Contracts that are only observed: the instance global’s name (RES0__INST) and POUS.c included twice, both load-bearing.
  • One file, one pass: the CONFIGURATION last; {#include} only at the top level; every identifier uppercased (Python bindings, toolchain/PYTHON.md); one PROGRAM per RESOURCE at least, so a Python-only project gets a hidden idle PROGRAM.
  • No statement-level line map. IEC languages step a scan at a time; Python tasks have breakpoints (debugging §3).
  • Errors only at Build. The Workbench re-implements matiec’s rules in TypeScript to say them earlier: located-vars.ts, empty blocks, reserved words, type mixing, duplicate POUs.
  • GPLv3, so it stays an external fork, invoked, its output not captured (README.md, ARCHITECTURE.md §11).

1e. Not matiec’s fault

A compliant compiler keeps these and only says them better:

  • An empty VAR ... END_VAR - Ed.2’s grammar wants at least one declaration (B.1.4.3, var_declarations). Ed.3 allows it: its grammar repeats ( Var_Decl_Init ';' )* (Annex A). So iecf, on Ed.3 or later, accepts it.
  • DT, TOD as names - Ed.2 Table C.2 lists “Data type names” and “Function names” as keywords, which “shall not be used for any other purpose, for example, variable names” (2.1.3), so LT, ADD, MOD are refused too. Ed.3 6.1.3 keeps the rule.
  • Located and plain declarations in one block - separate productions in both grammars: Ed.2 located_var_declarations and var_declarations (B.1.4.3), Ed.3 Loc_Var_Decls and Var_Decls (Annex A). CODESYS relaxes it.

2. What iecf is

  • Input: ST, SFC’s textual form, the declarations, CONFIGURATION / RESOURCE / TASK - written against the normative text of IEC 61131-3, Ed.4 (2025) (§3a, §6.1). No IL, which Ed.4 removed.
  • Output: portable C, as generic as C can be (florin, 2026-11-05). ISO C99, no compiler extensions or built-ins, no target assumptions: <stdint.h> fixed-width types, no word size, endianness or libc taken for granted. What a platform must supply - the fault hook, the time base, the process image access - is a small header of functions and macros the platform implements, nothing more. Any C99 compiler for any target builds the output; a program that compiles but behaves differently on two targets is an iecf bug. Prokop is one consumer: it compiles the C with zig/clang to wasm32 as today, and the prokop-1 ABI, the process image, the retain and aux regions, matiec_glue.c’s role, the musl vendoring and the MicroPython link stay on Prokop’s side of that header.
  • A symbol table and layout it emits itself, with arrays, structures, enumerations and function block members, replacing VARIABLES.csv, the positional pairing and most of the aux and retain bridges (“the compiler-emitted symbol table replaces it”, abi §6).
  • Semantics it defines, not C’s (§6.3): an index out of range and a division by zero FAULT, the same in every language; an integer overflow wraps and is flagged, as the vendors wrap; each checked by plain C comparisons before each operation (no __builtin_*_overflow, which is GCC/clang only), so the generated C has no undefined behaviour on any compiler; integer-to-TIME counts milliseconds. Each is in the compliance statement and its error document (Ed.3 clause 5 d, e).
  • Its own front end in the browser (§2a, §6.7): the compiler itself - parser and checker, not its output - built to wasm so it runs in the editor and gives errors on the right file and line as the user types. This is about where iecf runs, not what it emits.
  • Standard text as its input (§2b): a set of IEC 61131-3 source files, never text a caller has edited first, and no project format of its own; IEC 61131-10 XML as a later import.
  • A statement-to-C line map, so ST breakpoints are the tier-2 work Python already had (debugging §2b).
  • Apache-2.0 OR MIT, like the rest of the repository; the GPLv3 boundary goes away with matiec.

2a. Where the checking runs: in the browser

Decided 2026-11-05 (§6.7), on a general ground rather than on how Prokop is built today: a hosted service with thousands of users.

the server checks as one typesthe browser checks (wasm)
Server loada request per user per pause in typing - thousands a minute, scaled and paid fornone: each user’s machine checks its own code
Delaya network round trip, 50-300 ms, plus queueingmicroseconds per POU: errors as one types
Server down or slowno errors shownstill works, offline too
The server still doeseverythingbuilds, which give the authoritative verdict

Typing is what users do most; checking it in the browser costs the service nothing. Measured 2026-11-05: the parser and checker of dbfd009+7c1d847 as a wasm module are 189 KB, 71 KB gzipped; the browser’s module of step 1 complete (all of the checker, the standard functions’ tables, the JSON), built for size, 269 KB, 94 KB gzipped, reported by CI on every push - a complete compiler several times that is still smaller than the code editor a web IDE loads anyway.

Built: crates/iecf-driver is the one entry point, files in and diagnostics out, that iecf check and the browser both call; crates/iecf-wasm exports it with no JavaScript glue generated and no dependency, and iecf.js wraps it in one call, iecf.check([{name, text}], {extensions}), which returns what iecf check --format json prints. CI runs that module in Node against the same expectations.

What it asks of iecf, and why each is good design regardless:

  1. A core without I/O. iecf-syntax, iecf-sema and what follows take text and return trees and diagnostics: no files, no threads, no network, no native libraries. CI builds the core for wasm32-unknown-unknown on every push, so the rule cannot erode. The same library serves the browser, a Language Server for desktop editors, CI, and the build.
  2. One version everywhere. The browser’s wasm and the build’s binary come from one commit, released together; a web IDE loads the wasm of the iecf its server builds with. Otherwise the editor says fine and the build says error.
  3. The same input as the build - §2b.
  4. Diagnostics as JSON, as iecf check --format json gives them; the browser entry point is a thin wrapper around that.

The build stays the authority: the browser’s check is advice given early, by the same code.

2b. The input is IEC 61131-3 itself

Decided 2026-11-05 (florin: generic, not tied to Prokop, or a standard). A compiler must not need its input text edited first, and iecf needs no project format of its own: the standard already says, in its own text, everything a project description would.

What a platform must tell the compilerThe standard’s own wayEd.3
which programs run, on which tasks, how oftenCONFIGURATION, RESOURCE, TASK, program instances6.8, Annex A
the global variablesVAR_GLOBAL in the configuration or a resource6.5.2
the I/Oa variable at an address: start AT %IX0.0 : BOOL;6.5.5, Table 16
a POU reused across machines, wired per instanceAT %I* in the POU, the full address in the configuration’s VAR_CONFIG6.5.5.4
a POU’s use of a globalVAR_EXTERNAL6.5.2.2

So:

  1. iecf’s input is a set of IEC 61131-3 source files, in any order; a name declared in one is seen in the others (as iecf check does already). There is no manifest.
  2. The configuration is ordinary IEC 61131-3 source. iecf needs to know which programs run on which tasks, and the names, types and addresses of the I/O; the standard’s CONFIGURATION says exactly that, and is part of the program like any POU. Who writes it is not iecf’s business: the user writes it by hand, or a tool generates it from its own interface (a task editor, an I/O table). A tool that generates it puts it in a file of its own rather than splicing it into the user’s text: the user’s files stay as written, diagnostics point at lines the user recognises, the generated file can be regenerated freely, and the browser and the build (§2a) read the same files. Any compiler that follows the standard reads them too.
  3. The hardware stays outside the compiler. IEC 61131-3 stops at the direct address: %IX0.0 is all a compiler sees. Which card, bus or pin it is belongs to the runtime, as do the fieldbus device descriptions (PROFINET GSDML, EtherCAT ESI, IO-Link IODD). Prokop’s io_map.json is one such mapping, and stays in Prokop.
  4. IEC 61131-10 (2019), the PLCopen XML exchange format, is the standard project file: POUs, data types, configurations, resources, tasks and global variables, its XML schema in Annex A. iecf imports it, later, so projects move between iecf and other tools. Text stays the main input: what people write and what version control diffs. Part 10 must be bought too before it is relied on.
  5. VAR_EXTERNAL is required, as Ed.3 6.5.2.2 says: global variables are “only accessible to a program organization unit via a VAR_EXTERNAL declaration”. Using a global without one is an implementer extension: off by default, switched on by an explicit option (--extension implicit-externals), listed in the document of extensions (docs/extensions.md, Ed.3 clause 5 c). Whether a platform switches it on, or has its editor write the declarations where the user sees them, is the platform’s choice, not iecf’s.
  6. The implementation’s own limits - default STRING length, the ranges of the time types, identifier length - are fixed by iecf and documented in the compliance statement (Ed.3 clause 5 d), not set per project. A setting exists only where the standard leaves a choice to the user’s target.

AT %I* with VAR_CONFIG is read and checked (E0022-E0024): a * address stands only in VAR of a PROGRAM or FUNCTION_BLOCK (Ed.3 Annex A, as CODESYS), or a structure field declared there, and every * of every program instance gets its address, in the same area. CODESYS’s form - VAR_CONFIG in a list of its own, paths from the PROGRAM’s name

  • is read with --extension var-config-list (E0025 without it); Beckhoff’s, linking %I* in its I/O configuration, is outside the source text and not read. The VAR_EXTERNAL extension is in, off by default. VAR_ACCESS is still reported as not yet compiled (E0009).

Written in Rust (§6.2): one native binary for the build service - the same pattern as iec2c today - and the same front end as wasm for the Workbench.

Why C out and not Rust out (florin asked, 2026-11-05): IEC’s memory model is shared mutable state - globals, VAR_EXTERNAL, VAR_IN_OUT by reference, REF_TO, function block instances reached from several places - which is static mut and raw pointers in Rust, so the generated code would be mostly unsafe. rustc on large generated code is several times slower than clang; the module links C anyway (glue, musl, MicroPython); IL’s JMP has no goto in Rust. The wasm sandbox already contains a fault, and the checks Rust would add, iecf can emit in C. If C is ever left, the step is LLVM IR or wasm directly, not Rust.

3. Compliance

IEC 61131-3 asks an implementation to state which features it supports and its implementation-dependent parameters. Ed.3 clause 5 asks a tool claiming compliance to provide: a compliance statement (5.3: “any consistent subset of the features listed in the feature tables”, each by table and feature number and language, Figure 4 an example); documents of its extensions, of its implementation dependencies and limits, and of every error it detects and how. Ed.2 had the same in 1.5.1, its parameters in Annex D and its errors in Annex E. Ed.4 keeps 5.3 and Figure 4 (IEC preview, contents). iecf ships the statement and those documents, generated from its own tables:

  • the feature tables supported, by number;
  • the parameters: STRING length, TIME range and resolution, division by zero, overflow, index out of range, identifier length;
  • every deliberate difference from matiec, while matiec is still the comparison (§4);
  • the implementer’s choices the standard asks for (clause 5 d), each made after checking what the big vendors do, with the evidence: docs/dependencies.md;
  • the readings taken where the standard’s own text disagrees. One so far: conversions between a string and a non-character type (INT_TO_STRING, STRING_TO_LREAL). Ed.2 defines them (Table 22, note e: the string holds the value as its literal is written); Ed.3 Figure 11 marks them “-”, none defined, yet Ed.3 states it is “fully compatible” with Ed.2 and does not list them among its deprecations (Annex B). iecf accepts them as Ed.2’s, kept. Every other “-” of Figure 11 (TIME_TO_DINT) is an error. To be read again against Ed.4.

The outside checks: PLCopen’s conformance material and the TC6 XML exchange format (check what PLCopen offers today). There is no formal certification of a compiler to rely on.

The standard itself must be bought - Ed.4 (2025), and Ed.3 (2013) to read Annex B against - and is the source for every rule. Nothing is taken from CODESYS’s documentation as if it were the standard.

3a. The editions

An edition is a version of the standard; each cancels and replaces the one before.

EditionYearIn short
Ed.11993The five languages: IL, ST, LD, FBD, SFC
Ed.22003Revision. matiec implements this one
Ed.32013“a compatible extension of the second edition”: object orientation (classes, OO function blocks, methods, interfaces), namespaces, references (REF_TO), new types (LTIME, LDATE, LTOD, LDT), variable-length arrays, implicit conversions, CONTINUE. Deprecated: octal literals, overloaded TRUNC, IL, directly represented variables in POU bodies, the action block “indicator” variable (Ed.3 Annex B)
Ed.42025-05“This fourth edition cancels and replaces the third edition published in 2013” (IEC preview). UTF-8 strings; Annex B lists every addition, deletion and deprecation against Ed.3

What Ed.4 changes against Ed.3. Verified from the IEC preview (foreword and table of contents) where marked preview; the rest from Stefan Henneken’s comparison (2025-06-11, read from the standard but not tested on any tool), a second-hand source until the text is bought:

  • Added:
    • UTF-8 strings: USTRING, UCHAR, literals U#'...', their conversions, LEN_MAX, LEN_CODE_UNIT (preview: “inclusion of UTF-8 strings and their associated functions”).
    • Character codes in literals: '${1F579}'.
    • Synchronization of concurrent execution, §6.9 (preview): MUTEX and SEMA function blocks with methods, and the functions MUTEX_LOCK/_UNLOCK/_TRYLOCK, SEMA_ACQUIRE/_RELEASE/ _TRY_ACQUIRE.
    • Properties: PROPERTY_GET/PROPERTY_SET on CLASS, FUNCTION_BLOCK and INTERFACE.
    • ASSERT(IN).
    • STRING/WSTRING/USTRING to and from ARRAY OF BYTE; some new *_TO_BOOL and *_TO_CHAR conversions.
  • Removed:
    • Instruction List, deprecated since Ed.3 (Annex B). Ed.4 clause 7 has only 7.2 Structured text (preview); “no longer included in Edition 4” (Henneken).
    • Octal literals (8#267).
    • TRUNC(x) with no type in the name; TRUNC_DINT(x) and LREAL_TRUNC_DINT(x) stay.
  • Deprecated: the BCD functions (IS_VALID_BCD, *_BCD_TO_*, *_TO_BCD_*).
  • Changed: STRING to WSTRING and CHAR to WCHAR are implicit; ARRAY[1..1] and ARRAY[1..0] defined.
  • Still not in Ed.4 (Henneken): optional parameters, method overloading, implicit enumerations.

Who implements Ed.4, as searched 2026-11-05:

VendorEd.4Source
Beckhoff TwinCAT PLC++“almost fully compliant with the fourth edition of IEC 61131-3”; “we have dispensed with Instruction List” (Hans Beckhoff, 2024-10-30). No public feature list foundbeckhoff.com product page and news
Beckhoff TwinCAT 3 PLCNo Ed.4 claim; documentation lists all five languages, IL includedinfosys.beckhoff.com
CODESYSNo Ed.4 claim found; “OOP according to 61131-3 3rd Edition”. CODESYS 4 page not read (HTTP 429)codesys.com
Siemens TIA PortalNo statement found-
PLCopen“The current version is 4.0 from 2025”; no Ed.4 conformance of any toolplcopen.org

So the market is Ed.3 plus vendor extensions, and the programs users bring will be written that way. Ed.4 keeps all of Ed.3 but IL, octal literals and untyped TRUNC, so targeting Ed.4 covers that code too.

Sources: the IEC 61131-3:2025 preview (foreword and contents, webstore.iec.ch); stefanhenneken.net/2025/06/11/iec-61131-3-comparison-of-edition-3-and-edition-4/; beckhoff.com/en-us/products/automation/twincat-plc/; beckhoff.com/en-us/company/news/with-energy-and-optimism-into-the-future.html; us.codesys.com/products/engineering/development-system/; plcopen.org/standards/logic/.

4. Cost and risk

  • The bulk: the type checker, overload resolution of the ANY_* generic functions, and the standard library - conversions (large, mechanical), TON/TOF/TP, CTU/CTD/CTUD, R_TRIG/F_TRIG, SR/RS, the string functions. A project of months, not weeks.
  • matiec is the oracle while it lasts: every program in toolchain/examples/, every program in docs/usage.md and the pipeline tests (toolchain/tests/test_pipeline.py, test_division_by_zero.py, …) build with both, run on a real scan engine, and must agree - except the differences decided and written down.
  • Behaviour changes are allowed: pre-alpha, no field devices. The “stick to matiec” decisions of 2026-10-04 (abi §6: 12-byte TIME, seconds in DINT_TO_TIME; standard-functions §2b: division by zero) were made with matiec as the compiler; iecf decides them again (§6.3), and the matiec comparison lists them as expected differences.
  • An existing compiler to read first: RuSTy (PLC-lang/rusty), ST in Rust over LLVM, with some object orientation; could reach wasm32. LGPL-3.0 (GitHub, 2026-11-05), active (last push 2026-11-04). iecf is Apache-2.0 OR MIT, so ideas are taken from it, not code (§6.4). Read in RUSTY.md: CODESYS dialect rather than the standard as written; no IL, SFC, configurations, namespaces or runtime checks; its parser needs LLVM, so it cannot serve the Workbench as wasm.

5. Order, if accepted

  1. Read RuSTy DONE 2026-11-05: RUSTY.md, what iecf takes as design and what it avoids. Read, not run: Prokop’s examples were not put through it (no Rust toolchain or LLVM 21 here). No code taken.
  2. Front end only - parser and checker, the Workbench’s live errors. DONE 2026-11-05: crates/iecf-syntax (lexer, parser, syntax tree, no dependencies, builds for wasm32), crates/iecf-diagnostics (codes, each explained in docs/errors/, short and JSON output), iecf check. Reads the Ed.2 core, SFC and CONFIGURATION; Ed.3 object orientation, namespaces and properties are reported as not yet compiled (E0009). The checker, crates/iecf-sema, same day: names across files, VAR_EXTERNAL against VAR_GLOBAL, Ed.3 Figure 11 transcribed as data for implicit conversions, the standard function blocks of Tables 43-46, calls by Ed.3 6.6.1.4, conditions, literal ranges, reserved names, SFC and tasks (E0010-E0021). Silent on all 18 Prokop example programs as the build hands them to matiec. Then AT %I* with VAR_CONFIG (E0022-E0024, Ed.3 6.5.5.4 and 6.8.2). Then the VAR_EXTERNAL extension behind --extension implicit-externals (§2b), and docs/extensions.md, the document of extensions Ed.3 clause 5 c asks for, its sections checked by a test. Then the standard functions in full (Ed.3 Tables 22-37): inputs by position or by their names (IN1, G, MN, K, …), the type class of each, the common type of the overloaded ones, the typed forms (ADD_INT, MUX_UINT_REAL, 6.6.2.4), SPLIT’s outputs, and conversions against Figure 11. Silent again on the 18 examples. Then the browser entry point (§2a): iecf-driver, shared with the command line, and iecf-wasm with iecf.js. Step 1 is complete. Useful at once, no risk to a running program. The TypeScript re-implementations of matiec’s rules (§1d) retire as it covers them.
  3. C at matiec’s parity, Ed.2, compared against matiec in CI. The default once it agrees; matiec behind a flag for a while, then gone. STARTED 2026-11-05: the contract first, docs/c-output.md - entry points, time passed in, the process image read byte by byte, faults that return instead of jumping, a symbol table by offsetof, RETAIN in a region of its own - and runtime/iecf.h, tested in CI with GCC and run on wasm32. The representations decided after the vendors (docs/dependencies.md). The harness, 2026-11-06 (harness/): ten cases of the Ed.2 core, each run for a number of ticks with scripted inputs, its Q and M bytes traced; matiec’s traces checked by hand and kept, CI building matiec 7949c0b to confirm them. It found two more matiec bugs (§1b). Then the generator in slices, each turning cases on. The first, 2026-11-06 (crates/iecf-codegen, iecf build): PROGRAM instances on tasks, variables of the integer, bit-string and BOOL types, located or not, every ST statement, the integer operators, conversions, shifts, ABS, MIN, MAX, LIMIT; every overflow, division by zero and conversion out of range a fault (since changed, §6.3). The checker now hands the generator what it worked out (iecf_sema::analyze). image, arith, bits, control and tasks match matiec’s traces; overflow shows the fault where matiec wraps. The C is checked as strict C99 with every warning an error and run under the sanitizers. The second, the same day: the standard function blocks (bistables, edges, counters, timers) in runtime/iecf.h, TIME and LTIME, and the user’s FUNCTIONs; the counters count over their type’s range (decided, docs/dependencies.md). timers matches matiec’s trace; edges differs where matiec stops its counters at PV and 0 (edges.iecf.trace). The third: the user’s FUNCTION_BLOCKs - inputs, outputs, VAR_IN_OUT bound by address, locals, instances of other blocks, VAR_EXTERNAL - and pous matches matiec’s trace. The fourth: REAL and LREAL - arithmetic, division by zero a fault, conversions (to integers half to even, out of range a fault; DWORD_TO_REAL as bits), TRUNC, the Table 28 functions through <math.h> only when called - and real matches matiec’s trace. Then an infinite or NaN real was decided flagged, not faulted (infinity), and an integer overflow and an integer conversion out of range the same: wrapped and flagged (§6.3), so overflow and the new narrow match matiec’s bytes, with the flag; a real converted to an integer it does not fit is flagged and leaves its statement undone (unconverted). The fifth: derived types - enumerated, subrange (a value outside it clamped and flagged, decided; subrange), ARRAY (any dimensions, bounds not at 0, every index checked, an index out of bounds a fault) and STRUCT, their initial values, whole arrays and structures assigned - and types matches matiec’s trace: every case passes. The sixth: VAR_TEMP in PROGRAMs and FUNCTION_BLOCKs, C locals set at each call, and derived types among a FUNCTION’s locals (temp). The seventh: the symbol table - a symbol per top-level variable, each type described once (decided, florin: as ADS and CODESYS do), offsets by offsetof - which the harness checks holds together in every case. The eighth: RETAIN - one region, ctx->retain, that a warm start (plant_warm) leaves as it is - and retain shows it across a warm restart (the harness’s scripts gained TICK warm). The ninth: program connections - a program’s inputs and outputs, given and taken in the CONFIGURATION, checked as a function block call’s - which matiec cannot compile, so connections has a hand-checked trace alone (harness/reference-skip). Step 2’s slices are done. Then Prokop’s own programs (2026-11-06): every source its toolchain tests hand matiec, 56, caught by a stand-in iec2c. matiec takes 52; iecf built 35 of them, and those that write %Q or %M matched matiec’s trace in the harness, but for addresses that overlap (iecf’s one image aliases them, matiec’s host keeps a cell each) and a division by zero (a fault in iecf; matiec’s C traps). What stopped the rest: STRING (5), the operators written as functions (3, and Prokop’s LD, FBD and benchmark examples), RETAIN in a FUNCTION_BLOCK (2), IL (2, not iecf’s), TOD, SFC, an array as a block’s parameter (1 each); and iecf took a VAR block that mixes located and plain declarations, which §1e says to refuse. The tenth slice: the operators as functions - ADD, MUL, AND, OR, XOR with any number of inputs, SUB, DIV, MOD, NOT, the comparisons, MOVE, SEL (both inputs evaluated, as any function’s) - and EXPT and ** through pow; the mixed block is E0022. functions matches matiec’s trace, and 39 of the 52 build. The eleventh: STRING - char[n + 1], assignment, comparison, Table 34’s functions into buffers sized for the most their inputs make; a string too long cut and flagged, a length or position out of its string flagged with Siemens’ result (decided, florin, docs/dependencies.md) - and strings matches matiec’s bytes; 41 of the 52 build. The twelfth: conversions to and from STRING, their text decided (florin, docs/dependencies.md) - %g for the reals from exact digits, decimal for the bit strings, the literal for a duration; text read as CODESYS reads it, none or out of range 0 and flagged - and convert matches matiec where their text agrees; 42 of the 52 build. The thirteenth: RETAIN and NON_RETAIN inside a FUNCTION_BLOCK, Ed.3 6.5.6 as written, an instance of a block that keeps anything kept whole as on CODESYS (decided, florin, docs/c-output.md §7) - and fbretain shows a warm restart. The fourteenth: DATE, TOD and DT - Table 30’s arithmetic, DT_TO_TOD, DT_TO_DATE, CONCAT_DATE_TOD, DAY_OF_WEEK, their text both ways; a TOD wraps at midnight, a DATE, DT or difference out of range wraps and is flagged, a DT drops milliseconds (decided, florin, docs/dependencies.md) - and dates matches matiec but where its TOD runs past midnight. Prokop’s programs again (survey/, 2026-11-07): 46 of the 51 matiec builds build; TIME * and / by a number stop two, EN and ENO, an array as a block’s parameter, SFC and IL one each (survey/results.md). The fifteenth: a duration times or divided by a number - by an integer exact, wrapped and flagged; by a real rounded to whole milliseconds half to even, as REAL_TO_DINT (decided, florin, docs/dependencies.md) - and durations; 47 of the 51 build. The sixteenth: EN and ENO on a function call - EN FALSE skips the call and leaves its statement undone, ENO FALSE on the function’s own flag (decided, florin, docs/dependencies.md) - and arrays, structures and STRINGs as a FUNCTION_BLOCK’s inputs and outputs, copied; MUX; enable and blockdata; 49 of the 51 build, SFC and IL left. The seventeenth: SFC in a PROGRAM or FUNCTION_BLOCK - actions then transitions each scan, with final scan, two passes, the first true transition by PRIORITY then source order, an unsafe entry flagged (decided, florin, docs/dependencies.md) - and sfcdemo, sfc; 50 of the 51 build, the IL one left, which iecf does not take. Then the harness compares every variable, not only the Q and M bytes (iecf-harness compare): 44 of the 50 run the same as matiec for 40 scans on random inputs, and the 6 others differ where iecf decided so; it found a FOR leaving its variable at the end, now one step past, as the standard’s examples (decided, florin). Then the symbol table as JSON beside the C, which the harness reads to compare every array element, field and block member too: still 44 of the 50. Then CONCAT_DATE, CONCAT_TOD, CONCAT_DT and the SPLITs (Table 36, which matiec lacks): a part out of range gives the type’s default value, flagged, as CODESYS and PLCnext give it (decided, florin, docs/dependencies.md); a SPLIT’s parts converted to its outputs’ types; parts, a hand-checked trace. Then EN and ENO on a function block’s call, as on a function’s: EN FALSE calls nothing - inputs neither evaluated nor set, outputs not taken - and ENO is FALSE when a flag is raised while the block runs (decided, florin, docs/dependencies.md); fbenable, fbskip. Then a program’s VAR_IN_OUT: a pointer the CONFIGURATION binds to a global, each in-out required connected (Ed.3 6.6.1.4 rule 2); progio. Then ENO in a POU’s own body: there without a declaration in every FUNCTION and FUNCTION_BLOCK, the caller’s ENO FALSE when the body set it so or a flag was raised (decided, florin, docs/dependencies.md); enobody. Then EN and ENO on a SPLIT: EN FALSE keeps every output, a part too big for its output clears ENO (decided, florin, docs/dependencies.md); splitenable. Then LDATE, LTOD and LDT, and TIME and LTIME converted: signed 64-bit nanoseconds over all their range, a long value made a short one rounding down, one the short type cannot hold its default value, flagged (decided, florin, docs/dependencies.md); ldates. Step 2 is done.
  4. Ed.3: STRING[n], the time and date types at an address, implicit widening, REF_TO, namespaces, then METHOD/INTERFACE/CLASS. STRING[n] came with step 2. The time and date types at an address, DONE 2026-11-08: a variable starts at its address and takes its own type’s size, warned when the size prefix differs (W0004), a BOOL at a byte the whole byte (decided, florin, docs/dependencies.md, after Ed.3 6.5.5.3 and CODESYS); a program’s time or date output connected to a longer type converted; located. Arrays, structures and STRINGs at an address, DONE 2026-11-08: their parts back to back from the address, a BOOL a byte, W0004 on the first part (decided, florin, docs/dependencies.md, after CODESYS’s pack_mode); locdata. REF_TO, DONE 2026-11-08: a NULL reference dereferenced faults; REF() of a temporary (E0026, as Ed.3), of a CONSTANT or of a block’s output from outside refused, of an input warned (W0005); references into the process image built; a RETAIN reference refused (E0027); = and <> between two (decided, florin, docs/dependencies.md); arrays of other bounds made other types; refs on both compilers, and refimage. CODESYS’s REFERENCE TO as the extension reference-to, DONE 2026-11-09 (decided, florin): REF=, __ISVALIDREF, a block’s REFERENCE TO input bound by a call; generated as the REF_TO it stands for; refto, iecf’s only. Implicit widening audited 2026-11-09: Figure 11 checked against the 2013 text; a block’s output taken into a wider type (rule 5) and a short date with a long operand built, DT + LTIME typed LDT (it was DT, and let an LTIME narrow); widen, iecf’s only. Two integers that both widen to a third, INT + UINT: that third, the narrowest signed one, as Siemens SCL (decided, florin, 2026-11-09, docs/dependencies.md). Typed functions (ADD_INT, Table 21) generated 2026-11-09, their table in the book; with Table 30’s ADD_TIME, SUB_TIME, ADD_LTIME and SUB_LTIME. A FUNCTION’s outputs generated 2026-11-09: each at its initial value per call, given back on return, nothing stored on a fault; fnout on both compilers and the compare list, fnoutx iecf’s only. A FUNCTION’s in-outs and externals 2026-11-09, with the standard’s order of evaluation once calls have effects (7.3.2: the leftmost operand first), which also fixed outputs stored before a read left of the call; fninout on both compilers, fnorder iecf’s only. CHAR 2026-11-09: literals (CHAR#'X', CHAR#16#41, 'X' where a CHAR is wanted), to STRING, Table 25 and 27’s conversions, an empty string to CHAR '$00' flagged (docs/dependencies.md); chars. A FUNCTION without a result 2026-11-09, void, a statement only; in fnoutx. The implicit-externals extension compiled in every POU 2026-11-09 (the checker allowed it, the generator gave E0009); implicit. Namespaces, DONE 2026-11-09 (NAMESPACES.md, its eight decisions florin’s): NAMESPACE, INTERNAL and USING; a short name found from inside out as C#‘s, whose wording Ed.3 6.9.4 follows; a namespace’s element named in the C by its path joined by __; _. for the global namespace as the extension global-namespace, as Siemens and PLCnext write it; namespaces over two files, and globalns, both iecf’s only. WSTRING and WCHAR, DONE 2026-11-10: a WCHAR one UTF-16 unit, a character above U+FFFF a surrogate pair that LEN counts as two; WSTRING_TO_STRING and WCHAR_TO_CHAR skip a character a STRING cannot hold, flagged, as Beckhoff skips; WSTRING_TO_WCHAR as Ed.3 Table 27 has it, though Figure 11 has none (all three decided, florin, docs/dependencies.md); the runtime’s string functions made for both widths by one macro; wstrings, iecf’s only. Then SEL, MUX, MOVE, MIN, MAX and LIMIT of strings, and a WSTRING at an address, its units little-endian at any byte (wlocated), and REF() of one there (wrefs), with k := r^ of a string and cp := rp^ of a structure, 2026-11-10. METHOD, CLASS and INTERFACE PROPOSED 2026-11-10 in OO.md, in three steps (methods; inheritance and dynamic binding; interfaces), its twelve decisions florin’s, as recommended, the same day. Step A, methods and classes bound statically, BUILT 2026-11-10: methods, iecf’s only. Step B, inheritance and dynamic binding (EXTENDS, OVERRIDE, FINAL, ABSTRACT, SUPER, a call through THIS, an in-out or a reference run from the instance’s table), BUILT 2026-11-10: inherit, iecf’s only. Step C, interfaces (INTERFACE, IMPLEMENTS, an interface’s variable and its methods’ calls, =, ?=), BUILT 2026-11-10: interfaces, iecf’s only. Function block and class instances besides a POU’s own variable, DONE 2026-11-10: an instance’s initial value (its inputs, outputs and PUBLIC variables, Ed.3 6.6.3.3), an instance as an array’s element or a structure’s field (Table 11 rows 5a-7b) called and read there (Figure 8 d, e), the index checked before the call, and VAR_GLOBAL instances; each kept across a warm start as an instance in its section is; OO.md D11 applied to what holds an object; instances, iecf’s only. A FUNCTION’s and a method’s inputs, outputs, in-outs and results of a STRING, array or structure type, DONE 2026-11-10: passed by pointer, an input copied in (a STRING cut and flagged at its declaration), a result into a buffer of the caller’s statement, a STRING one a string expression; through interfaces and overrides; fnparams, iecf’s only (a method, an interface and WSTRING in it). An interface wherever a variable can be (Ed.3 6.6.6.5.1), DONE 2026-11-11: an array’s element, a structure’s field, a VAR_GLOBAL, a block’s output, a FUNCTION’s and a method’s output and result, an initial value that is an instance; itfplaces, iecf’s only. A PROGRAM’s input or output of one, connected in the CONFIGURATION, and a RETAIN one refused (E0027) as a RETAIN reference is (decided, florin, docs/dependencies.md); itfprog, iecf’s only. Array bounds, STRING lengths, subrange limits and initial values naming a CONSTANT (Ed.3 6.6.1.2.4; E0032 for a bound that is not constant), ARRAY[*] in-outs of a FUNCTION, a method and a FUNCTION_BLOCK with LOWER_BOUND and UPPER_BOUND (Ed.3 6.5.3; E0033 elsewhere), and a FUNCTION_BLOCK’s in-outs of an array, a structure or a STRING, DONE 2026-11-11 (docs/dependencies.md); flexarrays, iecf’s only. A CASE’s labels constant expressions naming a CONSTANT (Ed.3 7.3.3.3.3), compared by value, a variable there E0032, DONE 2026-11-11; in flexarrays. Part of an array given to an ARRAY[*]: a row of an array of arrays, aa[2], and Example 2’s A2[2], an array indexed in its first dimensions only (decided, florin, docs/dependencies.md); in flexarrays. Types with named values used as their base type (Ed.3 6.4.4.3), a value not given one more than the one before, bit string base types, INT when none is named (decided, florin: as CODESYS and TwinCAT); an enumeration as a FUNCTION’s input, output and result and a block’s in-out; one declared in place, DONE 2026-11-11 (docs/dependencies.md, florin to confirm the choices); enums, iecf’s only. An ARRAY[*] input or output of a FUNCTION or a method, the caller’s array and not a copy, as Siemens passes an FC’s (decided, florin, docs/dependencies.md), DONE 2026-11-11; flexio, iecf’s only. A plain block’s instance as a value: b := a a copy of every variable, an array or structure holding instances copied, an instance as a VAR_INPUT of a FUNCTION, method or block (a copy, read only, not called) and as a block’s VAR_OUTPUT, two instances not compared, DONE 2026-11-11 (docs/dependencies.md, florin to confirm the choices); an instance as a FUNCTION’s output, result or own variable, set up at each call, DONE 2026-11-11; instvalues. A part of a FUNCTION’s result read at the call, F(x).field, F(x)[i], DONE 2026-11-11; callparts. A structure or array that holds instances as any parameter or result, and a PROGRAM’s connections of an array, a structure and an instance, DONE 2026-11-11; holders. A whole ARRAY[*] assigned, its size checked as it runs (docs/dependencies.md, florin to confirm), and arrays and structures not compared (E0014, Ed.3 Table 33), DONE 2026-11-11; flexcopy. An array or structure at an address or through a reference given to an in-out or an ARRAY[*], as a copy stored back, DONE 2026-11-11; inoutdata. A block’s in-out given, and its output taken into, any variable (it was a plain name only); an enumeration declared in place with named values; an enumeration bounded by its INT, DONE 2026-11-11; fbargs.
  5. Ed.4 additions: USTRING/UCHAR, the §6.9 mutexes and semaphores, properties, ASSERT; BCD last, marked deprecated.
  6. ST breakpoints and stepping from the line map.
  7. LD and FBD compiled with their own network mapping, if the ST route (graphical-languages) is not enough by then.

6. Decisions

  1. Which edition? DECIDED 2026-11-05 (florin): Ed.4 (2025), no IL (§3a). IL has been deprecated since 2013 and Ed.4 removed it; Beckhoff’s PLC++ made the same choice, and Ed.4’s UTF-8 strings fix matiec’s 7-bit limit. Prokop’s IL (toolchain/examples/demo_il.il) stays on matiec while matiec lasts, or its examples are rewritten in ST. The build order is still Ed.2 core, Ed.3, then Ed.4 additions (§5).
  2. Written in what? DECIDED 2026-11-05 (florin): Rust. One native binary for the build service, the same front end as wasm in the Workbench.
  3. matiec’s semantics or new ones? DECIDED 2026-11-05 (florin): decided anew. Division by zero and an index out of range FAULT in every language; integer-to-TIME counts milliseconds, as CODESYS and Siemens do. Both editions call division by zero and overflow errors and leave the handling to the implementer, who documents it; iecf’s compliance statement does. Revised 2026-11-06 (florin), after the vendors (docs/dependencies.md): an integer overflow and an integer conversion out of range wrap, two’s complement, and are flagged in ctx->flag, as an infinite or NaN real is; the task goes on. A real converted to an integer it does not fit is flagged and leaves its statement undone (docs/dependencies.md).
  4. RuSTy first? DECIDED 2026-11-05 (florin): read it, then build (§5 step 0). LGPL-3.0, so ideas, not code.
  5. The IEC’s policy on its name, read before a public release - or a name without “iec”. OPEN. Searched 2026-11-05:
    • iec.ch refuses automated reading (HTTP 403 on every page), so the IEC’s own policy text is still unread; it needs a browser, or a question to the IEC Central Office.
    • USPTO (via Justia’s mirror) lists, owned by the IEC: the word mark “INTERNATIONAL ELECTROTECHNICAL COMMISSION”; “IEC” as a design mark (reg. 3643788, the logo with lines and a circle), not as a plain word; and IECEE, IECEx, IECQ, IECRE. No plain “IEC” word mark was found; EUIPO and WIPO were not searched.
    • Naming a standard to state conformity (“IEC 61131-3 compliant”) is what every vendor does, and Ed.3 clause 5 asks for exactly that statement. Proposed: keep iecf, never use the IEC logo, never say “certified” or imply endorsement, and add to the README: “iecf is not affiliated with or endorsed by the International Electrotechnical Commission (IEC). IEC 61131-3 is a standard of the IEC.” Ask the IEC before a 1.0 release.
  6. The repository? DECIDED 2026-11-05 (florin: “the IEC Forge will have its own repo”): its own repository, raspihats/iecf. Prokop consumes it as a pinned input, as it does matiec today (README.md), and toolchain/build.py finds the binary the same way.
  7. Check in the browser or on the server? DECIDED 2026-11-05 (florin: the in-browser check “would probably be extremely helpful in case of a hosted server with thousands of users”, and the decision “should not involve how Prokop works today”): in the browser, the same code as the build, from one release (§2a); and therefore no edited text as iecf’s input: standard IEC 61131-3 files only (§2b).
  8. Bit string to BOOL? DECIDED 2026-11-08 (florin): as the vendors, an extension. Ed.3 contradicts itself: Figure 11 defines no conversion from BYTE, WORD, DWORD or LWORD to BOOL, and Table 25 lists BYTE_TO_BOOL and the others as “Binary transfer of the rightmost bit into the target”. CODESYS and TwinCAT accept them and give TRUE for any value but 0; iecf gives the same with --extension bit-string-to-bool, and E0013 without it, as Figure 11 (docs/extensions.md).

Reading RuSTy - what iecf takes, and what it avoids

Step 0 of PROPOSAL.md §5, decided in §6.4. Read 2026-11-05 at PLC-lang/rusty 6356681 (2026-11-04), files only: nothing was built or run (no Rust toolchain and no LLVM 21 here). RuSTy is LGPL-3.0; iecf is Apache-2.0 OR MIT, so what follows is design in our own words, never code. path:line is RuSTy’s. Where a note says inferred, it was reasoned, not read.

RuSTy’s own mdBook (book/) is frank about its deviations and its regrets, and is the best single source.

1. What it is

An ST compiler in Rust over LLVM 21 (inkwell), about 90k lines: parse, index, resolve (annotate), validate, then a chain of lowering passes, then LLVM codegen and link. A stdlib (libs/stdlib) declares its functions in ST and implements most of them in Rust behind a C ABI.

It cannot be iecf’s dependency, license aside:

  • The parser is in the root crate, and the root crate links LLVM. No front end builds for wasm32 without LLVM; only the lexer, AST, source and diagnostics crates are free of it.
  • Not built for a long-running process. File names are leaked to 'static (compiler/plc_source/src/lib.rs:55) and node ids come from one global counter, so every re-parse in an editor leaks and renumbers.
  • A parse error in one file stops the project before indexing (src/parser.rs:48-63 and the driver): the editor would lose every semantic error in the other files.
  • No wasm, no LSP, no JSON diagnostics.

2. Coverage against IEC 61131-3

FeatureRuSTy
ST, ACTIONS, JMP/labels, CONTINUEyes
IL, SFCno (no tokens at all)
LD / FBDno; CFC only, from PLCopen XML, transpiled to ST
CONFIGURATION / RESOURCE / TASK, VAR_ACCESSno; programs are *_instance globals
Namespaces / USINGno (src/parser.rs:325: “TODO: Parse USING directives”)
Located AT %IX..., VAR_CONFIG, AT %I*yes
RETAINpartly: moved to a .retain section, but the initial value is written at every start
STRING[n], WSTRINGyes (UTF-8 and UTF-16), assignment truncates silently
USTRING / UCHAR, mutexes and semaphores (Ed.4)no
LTIME, LDATE, LTOD, LDTyes, 64-bit signed ns
TIME, DATE, TOD, DT32-bit unsigned: TIME ms (T#500us is 0, about 49 days, negative wraps), DATE/DT s since 1970 (book/user/language/time.md)
Integer-to-TIMEmilliseconds (as iecf §6.3)
REF_TO, REF, ^, NULL, REFERENCE TOyes, plus non-standard POINTER TO, ADR
VLA ARRAY[*]yes, fat struct {pointer, bounds}
CLASS, METHOD, INTERFACE, EXTENDS, IMPLEMENTSyes, vtables and itables
THIS in a CLASSrejected, contrary to Ed.3
PUBLIC/PRIVATE/PROTECTED/INTERNAL, ABSTRACT, FINALparsed and ignored (book/user/language/inheritance.md:155-161)
Propertiesyes, lowered to __get_x/__set_x methods
EN/ENOin CFC only
R_EDGE / F_EDGE declarationsno
Implicit conversion per Ed.3 Fig. 11/12no: C-like promotion to DINT; every numeric conversion allowed, narrowing only warns
Division by zero, overflow, index, NULLno checks: plain sdiv/srem (src/codegen/generators/expression_generator.rs:2275-2290), wrapping arithmetic, inbounds GEPs; subranges only checked if the user writes CODESYS-style CheckRange* functions
Timersread the host wall clock (libs/stdlib/src/timers.rs:48), state in an opaque byte buffer in the FB
Generic ANY_* functionsname mangling only (MAX__DINT); a missing instance fails at link time

So RuSTy aims at the CODESYS dialect, not at the standard as written. Little of iecf’s Ed.4 compliance work is done there.

3. Take for iecf

Front end

  1. A hand-written recursive-descent parser with region-stack error recovery. Each construct with a known end pushes its closing tokens (END_VAR, ), ;); on an error the parser skips to the nearest token that closes this or an outer region, reports the skipped text once, and puts a placeholder node where an operand was missing (compiler/plc_lexer/src/lexer.rs:178-232). Several errors in one run, no cascade.
  2. Dialect spellings mapped to the standard token with a warning (ENDVAR is END_VAR, warning E013). iecf can accept what CODESYS users type and still say what the standard spells.
  3. Declaration and body apart. A POU’s declaration holds its variables, base, interfaces and properties; bodies are separate, and actions, methods and property accessors are all just bodies named Parent.member.
  4. Node ids with side tables. Types and resolutions are recorded off the tree, keyed by node id; the tree is never changed to record a type.
  5. A location that can name a diagram element (CodeSpan::Block { diagram, order, pin }, compiler/plc_source/src/source_location.rs:81-90). Prokop’s LD, FBD and Blockly generate ST; iecf’s errors on that ST should point at the element, not at generated text.

Diagnostics

  1. One markdown file per error code (165 of them, each with the mistake and the fix) feeding the registry, a plc explain E037 command and the published error book. That is the Ed.3 clause 5 e document (“all errors that are detectable and reported”), generated.
  2. User-remappable severities per code (--error-config), and a clang-style one-line format beside the rich one.

Middle

  1. Fixpoint constant evaluation that keeps the reason a constant failed (overflow, not constant), turned into a diagnostic later. Needed for STRING[n], array bounds and subranges.
  2. Name every anonymous type early (an inline array gets a generated type name). Done with names stable across files, that ends matiec’s “anonymous ARRAY across VAR_EXTERNAL” mismatch.
  3. Built-in signatures declared in ST, parsed like user code; implementations native.
  4. A resolved type and an expected type per expression (RuSTy’s annotation and hint), with the conversion decided between them.
  5. The lowering catalogue, as a checklist: loops to WHILE with EXIT; ELSIF to nested IF; properties to get/set methods; inheritance as an embedded __Base first member, SUPER to it; interfaces as a fat pointer {data, itable}; a STRING, array or struct result as a hidden caller-owned out-parameter; per-type initializer functions; a function returning a reference with a hidden result. After them the program is structs, function pointers, plain functions, WHILE and IF - which is C.
  6. vtables and itables. A hidden table pointer in each root FB or class, one table struct per type, one itable per (interface, POU) pair, fat pointers for interface variables; calls through SUPER direct. Plain C structs of function pointers, call_indirect in wasm.
  7. The calling convention. FUNCTION: inputs by value, IN_OUT and OUTPUT by pointer. FB and PROGRAM: void f(Instance *), the caller writes inputs into the instance and copies => outputs after. Method: instance pointer first.

Outputs

  1. A map from instance path to symbol to address (--hwmap-file, JSON or TOML, book/technical/outputs/01-hardware-map.md). iecf extends it to every variable, with type, offset, size and retain: the symbol table matiec lacks (PROPOSAL §1d).
  2. A C header of the instance layouts (compiler/plc_header_generator), emitted with the C, as the ABI contract for Prokop’s glue.
  3. Stop points and dumps: --check after validation, the tree after parsing and after lowering, for snapshot tests.

Runtime and tests

  1. One no_std literal and conversion core shared by the compiler and the runtime library (compiler/plc_literals), so a literal that compiles to a value converts to the same value at run time (STRING_TO_TIME). iecf: the same core, compiled into the runtime or generated as C.
  2. Three test tiers: snapshot tests (insta) of trees and of rendered diagnostics; one-ST-file-per-test that compiles, runs and checks printed output (their LLVM lit, about 800 files); and the same suite at two optimisation levels to expose undefined behaviour. iecf adds UBSan on the emitted C.

4. Avoid

  • Lowering by rewriting the AST in place and re-indexing and re-annotating after each pass (nine times a build). Their own book calls it technical debt and says a dedicated IR would have fitted better. iecf: AST, then a typed HIR (where validation runs), then a C-oriented IR.
  • Conversions kept only in a side table. iecf makes every implicit conversion an explicit node after resolution - where the Ed.3 Fig. 11/12 rules are enforced and where FAULT checks are emitted.
  • One fact computed twice. Their validator and resolver bind mixed named and positional arguments differently, a documented bug (book/technical/pipeline/04-validation.md). Bind once, store it.
  • String-keyed types lowercased at every lookup: intern names, use ids.
  • A parse error that stops the project, leaked file names, a global id counter: iecf is per-file and incremental-ready from the start, because it runs in the editor.
  • Parse and ignore. Access specifiers, ABSTRACT and FINAL accepted and meaningless is worse than refused.
  • Undefined behaviour at run time. C has the same undefined behaviour as LLVM on division by zero and INT_MIN / -1; iecf emits the checks (§6.3).
  • TIME as 32-bit unsigned. Keep milliseconds for integer-to-TIME; store TIME signed and 64-bit (matiec’s is 12 bytes, spec/abi.md §6).
  • Semantics left to the host or the linker: wall-clock timers (iecf’s timers read the scan’s time from the runtime, deterministic and testable), state in opaque byte buffers (breaks retain and the symbol table), missing generic instances found at link time (iecf instantiates or refuses at compile time), retain values overwritten at every start.
  • Comments and pragmas thrown away by the lexer. iecf keeps them as trivia, for attribute pragmas and an editor-grade syntax tree.
  • Mangled addresses without the size letter: their %QW2.5 and %QX2.5 collide (stated in their hardware-map doc).

5. What it changes in the proposal

  • The pipeline in PROPOSAL §2 gains its stages: parse (per file, recovering) → index → resolve → typed HIR + validation → lowering (§3 item 12) → C IR → C, plus the symbol table, the layout header and the error document as outputs.
  • A C backend for RuSTy instead of iecf (contributing, rejected in §6.4) would still leave IL, SFC, configurations, namespaces, the runtime checks, Ed.4 and the wasm front end to do, under LGPL, on an AST-rewriting pipeline its authors want to replace. Building iecf stands.

Namespaces: the proposal

Ed.3 6.9 (Tables 64 to 66) adds NAMESPACE ... END_NAMESPACE, INTERNAL and USING. This proposed how to build them, and asked for the decisions the standard leaves open (§6). DECIDED 2026-11-09 (florin): every recommendation of §6 as written. BUILT 2026-11-09, as §4 says; what a program gets is in the book’s Namespaces page.

1. What Ed.3 says

  • A namespace groups types, functions, function blocks, classes, interfaces and namespaces (Annex A, Namespace_Elements); 6.9.2’s text lists programs too. What has no namespace is in the global namespace, the standard functions and function blocks among it (6.9.1).
  • Namespaces nest, lexically or by a dotted name: NAMESPACE Standard.Timers ... END_NAMESPACE. Several declarations of one name add to the same namespace, “e.g. in different Structured Text program files” (Table 65 NOTE).
  • An element is public by default. INTERNAL makes it visible only inside its own namespace; NAMESPACE INTERNAL N makes N’s public elements visible only in the namespace one level above (Figure 29, Example 3). An outer namespace can restrict access further, never widen it.
  • From outside, an element is named with its namespaces in front: Standard.Timers.TON (6.9.3). Inside, the short name does; the long one is allowed too.
  • USING Standard.Timers; makes a namespace’s elements usable by their short names: in a file at global level, in a namespace after its name, in a function block after its name, in a function after its result type (6.9.4, Annex A). It does not bring in the namespaces nested in it: after USING Standard;, Timers.TON is an error (6.9.4 Example 2).

6.9.4’s wording follows the C# specification’s using-directive section nearly word for word: “enables the types contained in the given namespace, but specifically does not enable types contained in nested namespaces … results in a compile-time error because no members named … are in scope”. Where Ed.3 is silent, this proposal takes C#’s rule (§6).

2. What the vendors do

NAMESPACE in sourceUSINGglobal namespace writtenINTERNAL
PLCnext Engineeronly around a TYPE block; a POU’s namespace is a setting, or its name prefixed NamespaceA.MyFBa POU’s “Usings” setting; namespace-level USING mentioned, not documented_.: “DERIVAT or _.DERIVAT”not documented
Siemens TIA Portal V21 (S7-1500 software units)none: a block’s or PLC data type’s property, a unit’s defaultnone_.: <Namespace>.<Block> “searches relative to the calling block”, _.<Namespace>.<Block> “searches globally”not documented
CODESYSnone: a library’s namespace, set in the library manager (CAA.HANDLE)nonenot documenteda POU keyword: “only allowed for the members of the current library”
TwinCAT 3none: as CODESYS; {attribute 'qualified_only'} makes a GVL’s variables GVL.nVar and an enumeration’s values E_Sample.eMembernonenoneas CODESYS
RuSTynonenonenonenone
matiec (the reference compiler)nonenonenonenone

Sources: engineer.plcnext.help (Namespaces_General, Namespaces_Define_POU, Namespaces_Define_DataTypes, Namespaces_Use); docs.tia.siemens.cloud (v21, “Categorizing program elements in namespaces (S7-1500)”); content.helpme-codesys.com (LibDevSummary/namespaces, visibility); infosys.beckhoff.com (attribute ‘qualified_only’). Both vendors that document namespaces written in programs, Siemens and PLCnext, write the global namespace _.. Neither documents what happens when a short name could mean two things.

3. Where Ed.3 contradicts itself or is silent

  1. Programs in a namespace. 6.9.2 lists programs, and Prog_Type_Access (the type a CONFIGURATION names) takes namespaces in front; Namespace_Elements leaves Prog_Decl out.
  2. The global namespace’s name. 6.9.3’s example writes Ton2: PUBLIC.TON; (* uses the standard timer *). PUBLIC is a keyword (an access specifier), the grammar has no such form, and no other line mentions it. Without some spelling, a namespace that declares its own TON cannot reach the standard one.
  3. USING in a PROGRAM. 6.9.4 says USING may follow “the name of … a POU”; Prog_Decl has no Using_Directive.
  4. Which one a short name means, when it could be a local variable, an element of the enclosing namespace, one brought in by USING, one of a parent namespace, or a global one; and whether two USINGs bringing in the same name is an error.
  5. A dotted name’s first part: Counters.CUP written inside namespace Infeed (6.9.4 Example 1) finds the global Counters; whether an Infeed.Counters would come first is not said.
  6. Variables. Symbolic_Variable takes namespaces in front (N.x), but a namespace holds no variables: Namespace_Elements has no Global_Var_Decls.
  7. What a global-level USING reaches: “the enclosing namespace … might be the global namespace”, so either the file it is written in or the whole project.
  8. A namespace and an element of the same name side by side (NAMESPACE Motor and FUNCTION_BLOCK Motor) is not addressed. Siemens forbids it (“Do not give names to program elements that you already use as a namespace”), and CODESYS forbids a namespace named as a data type.

4. What iecf builds

  • Parser: Item::Namespace { name, internal, using, items }, nested, and Item::Using at a file’s top; INTERNAL (or PUBLIC) before a POU’s name, read as a specifier only there, so both stay names elsewhere; USING where §1 and §6 say. Dotted names in a type and a CONFIGURATION’s program type; a call’s a.b.c stays a dotted expression, told from a variable by looking its first part up. Ed.3 gives a type no place for INTERNAL, so iecf’s types are public.
  • Before the checker (crates/iecf-sema/src/namespaces.rs): every element known by its path; each element taken out of its namespace and named by that path, its parts joined by __ (Standard__Timers__TON), which no IEC name can be (the lexer now refuses two underscores together inside a name, Ed.3 6.1.2); each type name, called function, enumerated value’s type and program type rewritten to the element it means, in the order of D2, access checked as Figure 29. New errors: E0028 (not in the namespace, not visible from here, or a namespace where a type, function or variable is meant), E0029 (a short name two USINGs bring in). The checker and the generator see no namespace at all, and changed in nothing but showing the path with dots in a message, the symbol table and <p>_pou_names.
  • C: a namespaced element’s C name is its path upper-cased, each part joined by __: B_STANDARD__TIMERS__TON_t. An IEC identifier cannot hold two underscores in a row (Ed.3 6.1.2), so no other name can collide with it. The symbol table and the fault record’s POU names give the dotted path, Standard.Timers.TON.
  • Tests: a test for each rule of §6 and each error (namespaces.rs); the book page behaviour/namespaces.md, without checked rows, which evaluate expressions and cannot declare a namespace; harness cases namespaces, over two files (the harness now takes a case’s other files from cases/<name>.d/), and globalns for D4, both with traces worked by hand. The reference compiler has no namespaces, so both are iecf’s only (reference-skip).

Prokop needs nothing changed: namespaces are optional, and a project without one compiles as today. The flat namespace of tags and globals.gvl (Prokop’s spec/io-tags.md §8) is a different thing: Ed.3’s namespaces hold no variables. A GVL.x qualifier, like qualified_only, would be an extension of its own, later.

The cost was smaller than a REF_TO’s: one commit, the lookup most of it.

5. Not in this step

  • VAR PUBLIC / VAR INTERNAL on a function block’s variables and METHOD INTERNAL (Table 64 rows 3 and 4) come with classes and methods, which iecf does not have yet.
  • USING inside a METHOD, CLASS or INTERFACE: the same.

6. Decisions

All eight DECIDED 2026-11-09 (florin: “ok, with the recommendations”).

D1. Programs in a namespace: allowed. 6.9.2’s text and Prog_Type_Access both want them; only the element list leaves them out. A CONFIGURATION names one by its full path.

D2. Which one a short name means, as C#, innermost first:

  1. the POU’s own variables, then the namespace elements its USINGs bring in;
  2. the elements of the namespace the POU is in, then those its USINGs bring in;
  3. the same for each parent namespace, outwards;
  4. the global namespace, then the file’s global-level USINGs.

At each step, a namespace’s own element hides one brought in by USING; two USINGs of one step bringing in the same name is an error only where the name is used (E0029), as C#. The standard functions and function blocks are in the global namespace (6.9.1), so a namespace’s own TON hides the standard one inside it, as 6.9.2 Example 2 means (“defines an implementation of TON with a new name”).

D3. A dotted name’s first part is found by D2 (C#): inside Infeed, Counters.CUP is Infeed.Counters.CUP if there is one, the global Counters.CUP otherwise. Siemens does the same (“searches relative to the calling block”).

D4. The global namespace written _., as an extension. As Siemens and PLCnext write it: _.TON is the standard timer from inside a namespace that has its own. Ed.3’s PUBLIC.TON cannot be parsed (PUBLIC is a keyword) and is accepted by no vendor. As every iecf extension it is opt-in (docs/extensions.md): --extension global-namespace. Without it, the standard TON cannot be reached from a namespace with its own TON, as Ed.3 leaves it.

D5. USING in a PROGRAM: allowed, after its name, as in a function block: the text says “a POU”, and a PROGRAM is one.

D6. A global-level USING is the file’s, as in C#: a project of several files brings in what each file says. Prokop builds one file today, so for Prokop it is the project’s.

D7. No variables in a namespace; N.x refused where N is a namespace (E0028). The grammar’s (Namespace_Name '.')+ before a variable has nothing to name: a namespace holds no VAR_GLOBAL.

D8. A namespace and an element of the same name in the same place: an error, as Siemens and CODESYS have it. Otherwise Motor.Start could be read two ways.

Object orientation: the proposal

Ed.3 6.6.5 to 6.6.8 (Tables 48 to 53) adds classes, methods, interfaces, inheritance and polymorphism, for a new POU, CLASS, and for function blocks. iecf parses none of it today: CLASS, INTERFACE, METHOD, EXTENDS and IMPLEMENTS are skipped with E0009. This proposes how to build them, in three steps (§4), and asks for the decisions the standard leaves open or gets wrong (§6). DECIDED 2026-11-10 (florin): all twelve of §6 as recommended. Steps A, B and C BUILT 2026-11-10, as §4 says; what a program gets is in the book’s Classes and methods page.

1. What Ed.3 says

Classes (6.6.5.1 to 6.6.5.3, Tables 48 and 49)

  • CLASS [FINAL | ABSTRACT] name [EXTENDS base] [IMPLEMENTS i1, i2], then VAR and VAR_EXTERNAL sections, then methods, then END_CLASS. A class has no body, no VAR_INPUT, VAR_OUTPUT, VAR_IN_OUT or VAR_TEMP; it is never called, only its methods are.
  • Its variables may be RETAIN or NON_RETAIN, initialized, and AT %I*. Each VAR section has an access specifier, VAR PUBLIC: PUBLIC, PRIVATE, INTERNAL or PROTECTED, PROTECTED by default (Table 48 10a-10d). A PUBLIC variable is read from outside as an output is, inst.v.
  • An instance is declared as a structured variable is, its PUBLIC variables initialized by a list: c2 : CCounter := (m_iUpperLimit := 20000, ...) (Table 49). Instances of classes and function blocks may be declared in a class’s VAR and VAR_EXTERNAL.

Methods (6.6.5.4)

  • METHOD [access] [FINAL | ABSTRACT] [OVERRIDE] name [: result], its VAR_INPUT, VAR_OUTPUT, VAR_IN_OUT, VAR and VAR_TEMP (VAR and VAR_TEMP mean the same: temporary), a body, END_METHOD. Listed after a class’s variables; in a function block, between its declarations and its body.
  • Every method variable and the result are temporary, “like the variables of a function”: nothing is kept from one call to the next. Reading a method’s output outside its call (y := inst.outm1) or setting its input (inst.inm1 := a) is an error (6.6.5.4.7).
  • A method reads and writes its instance’s variables and externals; in a function block, its inputs (not R_EDGE or F_EDGE ones), outputs, VARs and externals, but not its VAR_TEMP or VAR_IN_OUT (6.6.7.2.3). A method’s variable may not have a name the class already uses.
  • A method with a result is called in an expression, one without in a statement only; formal or not, EN/ENO as a function’s (Table 50). Overloading is out of scope.
  • A method of the same instance is called THIS.m(...); of another, inst.m(...) (6.6.5.4.4).
  • Access: PUBLIC anywhere the class can be used; PRIVATE only in the class itself; PROTECTED in the class and its derivations; INTERNAL in its namespace. PROTECTED by default (6.6.5.4.3 rule 5, 6.6.5.9). “All improper uses shall be treated as errors.”

Inheritance (6.6.5.5 to 6.6.5.8)

  • EXTENDS: one base only. A class extends a class; a function block extends a function block or a class; a class never extends a function block (Table 48 row 6, Table 53 rows 7a, 7b).
  • The derived type inherits every variable and every method but the PRIVATE ones (and INTERNAL ones outside the namespace). Declaring a variable its base has (unless PRIVATE there), a method named as a base variable, or deriving from itself is an error.
  • OVERRIDE replaces a base method: same signature (name, result, every parameter’s name, type and order), same access; FINAL may be added. OVERRIDE where no base method is is an error.
  • FINAL on a method: not overridden; on a type: not a base.
  • ABSTRACT on a type: never instantiated, holds at least one abstract method; an abstract method is implemented by every non-abstract derived type. Not with OVERRIDE; only in an abstract type. An abstract type may be the type of an input or in-out.
  • Dynamic binding (6.6.5.6): a call through THIS, an in-out, a reference or an interface runs the method of the instance’s actual type. 6.6.5.6’s example: CIRCLE’s CF calls THIS.PI(); in a CIRCLE2, which overrides PI, CF gives the more accurate value.
  • SUPER.m() calls the base’s m, bound statically. Not inst.SUPER.m, not SUPER.SUPER.m.
  • THIS is a reference to the own instance; it may be given to an interface variable (ctrl(RM := THIS)).

Function blocks (6.6.7)

  • A function block may have methods, a body, or both; with methods only, “this function block can also be declared as a class”.
  • The body is not inherited: a derived block’s body is its own, empty if it has none. SUPER() in it runs the base’s body, “once … and shall not be in a loop”, never in a method (6.6.7.2.9).
  • A call of a block through a base-typed in-out or reference runs the derived block’s body: “the call of the function block shall be bound dynamically”.
  • Inputs and outputs are always PUBLIC, outputs read-only from outside; VAR_EXTERNAL is always PROTECTED; a block’s VAR is PROTECTED by default, as a class’s.

Interfaces (6.6.6, Tables 51 and 52)

  • INTERFACE name [EXTENDS i1, i2], method prototypes (a name, a result, VAR_INPUT, VAR_OUTPUT, VAR_IN_OUT; no body, no access specifier: always PUBLIC), END_INTERFACE. An interface extends any number of interfaces; redeclaring a base’s prototype, or deriving from itself, is an error.
  • A class or block IMPLEMENTS one or more interfaces, and implements each prototype with the same signature, PUBLIC or INTERNAL; one that leaves any out is ABSTRACT.
  • An interface is the type of a variable: a reference to an instance of a type implementing it, or NULL, its initial value. It may be given an instance, an instance of a derived type, another interface variable of the same or a derived interface, or NULL; compared with = to another, equal when the same instance or both NULL. Calling a method of a NULL one “a runtime error will occur”. Not an in-out (6.6.6.2).
  • Assignment attempt a ?= b (6.6.6.7): a gets b if what b references is of a type a can hold, NULL otherwise. Between interfaces, from an interface to a reference to a type, and from a reference to a base type to a reference to a derived one (downcast).

Polymorphism (6.6.8): through an interface, a VAR_IN_OUT, a reference, and THIS. A derived block passed to a base-typed in-out or reference must add no in-out of its own (it would be unset when the base’s call runs its body); whether it may add inputs or outputs is “Implementer specific” (6.6.8.3, 6.6.8.4; Table 53 rows 12a-12d).

Grammar (Annex A): Class_Decl, Method_Decl, Interface_Decl, Method_Prototype, Access_Spec; THIS.x in Symbolic_Variable; Invocation takes m(...), THIS(...), THIS.m(...) and a.b.m(...); SUPER() a statement; Assignment_Attempt; Interface_Spec_Init.

2. What the vendors do

CLASSTHIS, SUPERmethod access, none writtenOVERRIDENULL interfacedowncast
CODESYS V3.5none: methods, EXTENDS and IMPLEMENTS on function blocksTHIS^.x, SUPER^.m()PUBLICnone found0, “methods can be called only after” assignment__QUERYINTERFACE, __QUERYPOINTER
TwinCAT 3none, as CODESYSTHIS^, SUPER^; SUPER^() “Call of FB-body of base class”PUBLIC (“equivalent to specifying no access modifier”)none found0; check “<> 0” first__QUERYINTERFACE
Siemens SIMATIC AXyes, CLASS ABSTRACT, IMPLEMENTS a listTHIS.x, as Ed.3not documented (pages not readable)METHOD PUBLIC OVERRIDE, FINAL OVERRIDEcompared with NULLnot found
PLCnext Engineernone: methods of function blocks only, no inheritance or interfacesTHIS.m()none: “an access specifier must be specified”, Public or Private only---
RuSTyyes; no body, THIS rejected in oneTHIS^, SUPER^specifiers parsed and ignoredoptionalfat pointer, NULL unchecked (a crash)none
Siemens S7-1500 SCLno OO documented by Siemens
  • Dynamic binding. CODESYS and TwinCAT dispatch a call through a pointer, a reference, an interface, a base-typed VAR_IN_OUT, and a method’s call of a method of its own block. RuSTy the same, through a vtable; SUPER^.m() and a call on a named instance are direct.
  • A method’s variables are reinitialized at every call (CODESYS, TwinCAT, RuSTy), as Ed.3. CODESYS and TwinCAT add VAR_INST, a method variable kept in the instance. PLCnext keeps a method’s array or structure result between calls, which Ed.3 does not.
  • A derived block’s body: RuSTy runs its own only, SUPER^(); for the base’s, as Ed.3. TwinCAT has SUPER^(); neither it nor CODESYS says whether the base’s body runs without one.
  • ABSTRACT on function blocks and their methods: CODESYS and TwinCAT (build 4024); RuSTy parses it and an abstract method “returns the default value”. FINAL on blocks and methods: TwinCAT.
  • Interfaces extend several interfaces (CODESYS, TwinCAT, RuSTy), hold function block instances (CODESYS, TwinCAT; they have no classes) or both (RuSTy). RuSTy refuses an interface as VAR_IN_OUT (E126), as Ed.3.
  • Initialization methods FB_init, FB_reinit, FB_exit (CODESYS, TwinCAT; RuSTy a parameterless FB_INIT). Ed.3 has none: an instance starts at its declared initial values.
  • What none documents: Ed.3’s PROTECTED default, ?=, and what calling a method through a NULL interface does.

Sources: content.helpme-codesys.com (CODESYS Development System: _cds_obj_method, _cds_obj_interface, _cds_extending_function_block, _cds_implementing_interface, _cds_method_call, _cds_pointer_this, _cds_pointer_super, _cds_vartypes_var_inst, _cds_operator_queryinterface, _cds_operator_querypointer, _cds_method_fb_init_fb_reinit); infosys.beckhoff.com (tc3_plc_intro: methods 2530307467, calls 2527348875, SUPER 2528837771, function block specifiers 2530268811, interface extension 3537333899, ABSTRACT 6413748235, interface pointer 4256428299 and 5680748299, __QUERYINTERFACE 2529176459, FB_init 5482088715); engineer.plcnext.help (2026.6, POU_Method) and plcnext.help (PLCnext Engineer features); Siemens’ open-source SIMATIC AX library github.com/simatic-ax/statemachine (the AX documentation is behind a login); RuSTy at 11e8107 (book/user/language/inheritance.md, methods-and-properties.md, book/technical/participants/03-polymorphism.md, tests/lit/single/polymorphism). Siemens’ and PLCnext’s OO for classic SCL was not found.

3. Where Ed.3 contradicts itself or is silent

  1. Is an access specifier required? Method_Decl requires one ('METHOD' Access_Spec ...); 6.6.5.4.3 says the declaration “shall contain one” and in the next sentence that without one it is PROTECTED; 6.6.5.9 says PROTECTED “may be omitted”.
  2. The order of the specifiers. The grammar has access, then FINAL or ABSTRACT, then OVERRIDE; the examples write METHOD OVERRIDE mb and METHOD PUBLIC OVERRIDE DAYTIME, and 6.6.5.10 lets a VAR section’s specifiers come “in any order”.
  3. A method of the own instance without THIS. 6.6.5.4.3 rule 13 and 6.6.5.4.4 say it “shall be called using the THIS keyword”; Invocation takes a bare Method_Name(...). A bare m() could also be a FUNCTION of that name (Table 48 row 14 only says “should not”).
  4. THIS.x or THIS^.x. THIS is “a reference to the own class instance”, and a reference is dereferenced with ^ (6.4.4.10); yet every example writes THIS.x, THIS.m(), never THIS^.
  5. SUPER.m() is not in the grammar. Invocation has THIS. and instance names, Subprog_Ctrl_Stmt only SUPER(); the text and its examples use SUPER.DAYTIME().
  6. A method’s result in an expression is not in the grammar. Primary_Expr has Func_Call (a function’s name only); the text says a method with a result “shall be called as an operand of an expression” (VALUE := CT.UP(INC := 5)).
  7. A method of the base’s name without OVERRIDE. Only OVERRIDE without a base method is listed as an error; a derived method that has a base method’s name and no OVERRIDE is not addressed (an error, a hidden base method, or an override anyway).
  8. What a function block’s body is when the derived one has none. 6.6.7.2.9: “not automatically inherited … It is empty by default”; Figure 21’s right column shows each derived block as if it had the base’s variables written again, which rule 3 (“names … shall be unique”) forbids.
  9. Additional inputs and outputs of a derived block behind a base in-out or reference: “Implementer specific” (6.6.8.3, 6.6.8.4), Table 53 offering both.
  10. An interface as an in-out. 6.6.6.2: “Interfaces shall not be used as in-out variables”; 6.6.6.5.3’s explanation passes an instance to one “as function block input, output, in-out, or result”.
  11. What an interface references. 6.6.6.2 and 6.6.6.5: “instances of classes”; Table 51 row 4c and Table 53 row 6c: “a function block instance”; 6.6.6.7: both.
  12. An interface method implemented INTERNAL (6.6.6.4.2 error 3 allows PUBLIC or INTERNAL), though a prototype is “always PUBLIC”: called through the interface from outside the namespace, an INTERNAL method is reached where its specifier forbids it.
  13. What the runtime error is, for a NULL interface’s method call, and for ?= there is none (it gives NULL). 6.6.6.5.2 suggests a “dummy” method as one way.
  14. Copying an instance, a := b of a class or an OO block, is not addressed; nor whether an instance may be an input (by value) or a function’s variable.
  15. 6.6.8.4’s only example shows no dynamic binding: its ROOM_CTRL sets RM^.DAYTIME and never calls the block RM references, so neither body runs.
  16. An abstract type as an input. 6.6.5.8.2: “An abstract class may be used as a type of an input or in-out parameter”; an input is passed by value, and an abstract class “cannot be instantiated”.
  17. Mistakes in the examples: Table 49’s initialization list separates with ; (Struct_Init uses ,); Table 50 calls COUNTER.UP on the type and passes a START UP does not have; 6.6.6.7.3 Example 3 declares pinstBase2 and uses rinstBase2, and uses an ITF3 never declared; 6.6.8.3 Example 2 and 6.6.8.4 Example 1 write VAR_INPUT: DAYTIME; a SUPER.NIGHTTIME() without its ;; END_VAR; and END_PROGRAM;. None changes a rule.

4. What iecf builds

In three steps, each a commit with its tests and book page, each usable alone. What Ed.3 states plainly is built as written; where it does not, as §6 decides.

Step A: methods and classes, bound statically.

  • Parser: a CLASS is a POU of kind Class, each method a POU of kind Method in its type’s methods (between a block’s declarations and its body); access specifiers on a method and on a VAR section (VAR PUBLIC, VAR PRIVATE RETAIN), FINAL, ABSTRACT and OVERRIDE, in any order (D2). Each is a word only where a name follows it, so a variable may still be named final; THIS and SUPER are reserved (E0018). THIS.x, THIS.m(...) and inst.m(...) are member expressions and calls as before.
  • Checker: a class’s sections (only VAR and VAR_EXTERNAL); a method’s signature, its result only in an expression and a method without one only as a statement (6.6.5.4.5); a method’s output read or its input set outside its call (6.6.5.4.7); a method’s names apart from its instance’s (rule 10); in a function block, no VAR_TEMP or VAR_IN_OUT of the block in a method (6.6.7.2.3); access, for methods and variables, from where the use is: the type, a derived type, the namespace; a class instance never called. A method is known by TYPE__METHOD, as a FUNCTION by its name, and checked as one, its type’s variables in scope; each call is recorded for the generator. New errors: E0030 (access), E0031 (a part where Ed.3 does not allow it, an instance copied or made an input, D11).
  • C: a class is a function block without a body, B_<NAME>_t and B_<NAME>_init, so a block may extend one. A method is a C function M_<TYPE>__<METHOD>(ctx, B_<TYPE>_t *self, ...), its parameters as a FUNCTION’s (inputs by value, outputs and in-outs by pointer, EN/ENO as a FUNCTION’s), its VAR and VAR_TEMP C locals set at each call, its result returned. inst.m() and THIS.m() call it directly: in step A no type has a derived one. THIS.x is generated as x. Setting an instance’s input or PUBLIC variable from outside, f.i := 3, which the generator had refused for any block, is built with it.
  • Symbols: a class instance’s variables, as a block’s.

Step B: inheritance and dynamic binding.

  • Checker: EXTENDS (one base; a class from a class, a block from a block or a class), the inherited variables and methods but PRIVATE ones, OVERRIDE’s signature and access, FINAL, ABSTRACT (never instantiated, its abstract methods all implemented below), SUPER, SUPER() once in a block’s body and not in a loop, the errors of 6.6.5.5.5; a derived instance given to a base-typed VAR_IN_OUT or REF_TO (§6 D7).
  • C: a derived type’s structure starts with its base’s, B_<BASE>_t base;, so a pointer to it is a pointer to its base (C99 6.7.2.1); a base’s variable is self->base.V_X. The root of a hierarchy starts with const iecf_otype_t *type, which each _init sets to its own TY_<NAME>: the instance knows what it is, for the whole run. An iecf_otype_t (runtime/iecf.h; iecf_type_t is the symbol table’s) holds its base, its name and its table of methods, VT_<NAME>; step C adds its interfaces’ tables. The table is a C structure of function pointers, the body first for a block, then each method that can be overridden (not PRIVATE, not FINAL); a derived type’s starts with its base’s. Each slot takes the declaring type’s self; an override is reached through a thunk, M_<TYPE>__<METHOD>__vt, which converts it back. BUILT 2026-11-10: harness case inherit.
  • Which call is bound how: through THIS, a VAR_IN_OUT, a reference, or a block’s call through either, from the instance’s table (6.6.5.6, 6.6.7.2.9 rule 4); SUPER.m(), SUPER() and a call on a named instance directly, its type known. A FINAL method, or one no type overrides, directly too (6.6.8.5 NOTE).
  • A NULL reference is checked before the call, as r^ is: a fault NULL, the call not made, the statement stopped (§6 D8).

Step C: interfaces.

  • Checker: INTERFACE and its EXTENDS (any number, no prototype declared twice, no cycle), IMPLEMENTS (every prototype, same signature, PUBLIC or INTERNAL, or the type ABSTRACT), an interface variable’s values (6.6.6.5.1) and =/<>, ?= (6.6.6.7).
  • C: an interface variable is iecf_itf_t { void *self; const void *table; }, NULL when self is. Given a named instance, its table is known (&I_<ITF>__<TYPE>); given THIS, an in-out, what a reference references or another interface, it is found at run time in the instance’s type (iecf_itf_of). Each type that implements an interface, itself or through a base, carries its type, as a type in a hierarchy does, and has its own table for each interface, its bases’ and theirs: a derived type’s override is in its own, so the type’s list is searched alone, not its bases’. ?= looks the same way: iecf_itf_as for an interface, iecf_itf_ref for a reference from an interface, iecf_ref_as (by iecf_otype_is) for a reference to a derived type. Interfaces never reach the process image nor the symbol table; instances never reach the image.
  • A method called through a NULL interface: as a NULL reference (D8). BUILT 2026-11-10: harness case interfaces. An interface as an output, a result, a global, in an ARRAY or STRUCT, and with an instance as its initial value, BUILT 2026-11-11: itfplaces. A PROGRAM’s input or output, connected, BUILT 2026-11-11: itfprog; a RETAIN one is E0027, as a RETAIN reference (docs/dependencies.md, decided florin).

Tests, each step: a test for each rule and each error (crates/iecf-sema/tests/objects.rs); the book page behaviour/objects.md, without checked rows, which evaluate an expression and cannot declare a class; harness cases with traces worked by hand: methods (A), inherit (B: CIRCLE, LIGHT2ROOM with SUPER, Figure 21’s SUPER() chain, a base-typed in-out and reference), interfaces (C: ROOM_CTRL, ?= as 6.6.6.7’s examples). The reference compiler has no OO, so all are iecf’s only (reference-skip).

Prokop needs nothing changed: a project without OO compiles as today, and the Workbench gets the new errors live. The cost is the largest yet: step A about a namespace’s (a parser, checker and generator each touched), B and C each about as much again, B the most (the tables, the initializers, the dispatch).

5. Not in this step

  • CODESYS’s spelling: THIS^, SUPER^, overriding without OVERRIDE, PUBLIC when no specifier is written. CODESYS, TwinCAT and RuSTy write OO so; Ed.3 does not. As an opt-in extension later (docs/extensions.md), if Prokop users bring CODESYS code.
  • VAR_INST (CODESYS, TwinCAT): a method variable kept in the instance. Not Ed.3.
  • FB_init, FB_reinit, FB_exit (CODESYS, TwinCAT) and __QUERYINTERFACE, __QUERYPOINTER: not Ed.3; ?= does the second’s work.
  • Properties (PROPERTY, GET and SET): Ed.4, with the step 4 additions.
  • Overloading of methods: “not in the scope” of Ed.3 (6.6.5.4.3 NOTE 1).
  • Methods in LD or FBD: iecf compiles ST and SFC.

6. Decisions

All twelve DECIDED 2026-11-10 (florin: “All as recommended!”).

D1. A method or VAR section without an access specifier is PROTECTED, as Ed.3 says three times (6.6.5.4.3 rule 5, 6.6.5.9, Table 48 10d). CODESYS and TwinCAT make it PUBLIC; PLCnext requires one; no vendor documents PROTECTED. Calling a PROTECTED method from outside is an error whose message says to write METHOD PUBLIC. The other way: PUBLIC, as CODESYS, which would make code written for CODESYS compile unchanged and code written for Ed.3 more open than its author meant.

D2. The specifiers in any order, each at most once: METHOD OVERRIDE PUBLIC m and METHOD PUBLIC OVERRIDE m both, as the examples write them and as 6.6.5.10 allows for a VAR section, though the grammar gives one order.

D3. A method of the own instance is called THIS.m(); a bare m() inside a method or body is a FUNCTION’s call, an error if there is none, the message saying to write THIS.m(). As 6.6.5.4.3 rule 13 and 6.6.5.4.4 say, though Invocation allows the bare form; it keeps a method and a FUNCTION of one name apart (Table 48 row 14). The other way: a bare m() is the method if the type has one, as RuSTy and (by their examples) CODESYS and TwinCAT.

D4. THIS.x and SUPER.m(), as Ed.3 and SIMATIC AX write them, though THIS is called a reference; THIS^ refused, until the CODESYS spelling (§5).

D5. SUPER.m() and a method’s result in an expression, though the grammar has neither: the text and every example use them, and without them SUPER and results have no use.

D6. A method with a base method’s name and no OVERRIDE is an error, the message saying to write OVERRIDE: Ed.3 asks OVERRIDE of every override, and without the error the method would override silently or hide its base’s, whichever iecf chose. CODESYS has no OVERRIDE; RuSTy does not require it.

D7. A derived block behind a base-typed in-out or reference may add inputs and outputs, not in-outs. The added inputs keep the values they have (the call through the base sets only the base’s); an added in-out is an error where the instance is given or its reference taken (6.6.8.3, 6.6.8.4, which leave the first “Implementer specific”, the second an error). Table 53 rows 12b and 12d.

D8. A method or block called through a NULL reference or interface is a fault NULL: not called, the statement stopped, as r^ of a NULL reference (decided 2026-11-08). Ed.3 says “a runtime error”; CODESYS and TwinCAT say only to check first; RuSTy crashes.

D9. An interface is not an in-out, and references class and block instances both: 6.6.6.2 forbids the in-out, as RuSTy (E126); 6.6.6.7 and Table 51 row 4c take block instances, as CODESYS and TwinCAT, whose interfaces hold nothing else.

D10. An interface’s method may be implemented INTERNAL, as 6.6.6.4.2 allows, and is then reachable through the interface from anywhere: the interface’s prototypes are PUBLIC. The other way: PUBLIC only.

D11. An instance of a class, or of a block with methods, a base or an interface, is not copied with :=, not an input and not a FUNCTION’s or method’s variable. A copy into a base-typed variable would cut the derived part and keep the derived type’s table; an input is a copy. An abstract type is then an in-out, a reference or an interface only, which reads 6.6.5.8.2’s “input” as the in-out it can only be. A plain block keeps what it has today. An array or a structure that holds such an instance, anywhere in it, is refused the same way, as it would be copied with it (applied 2026-11-10, when instances in arrays and structures were built).

D12. An ABSTRACT method has no statements: its body would never run. RuSTy runs one, giving the result’s default.