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 Tis a reference that stands for what it references wherever it is used:r := 5,x := r + 1,r.f,r[i]andr(...)for a function block go through it, with no^.r REF= xbinds it tox, whichREF(x)could take (E0026);r REF= r2binds it to what the REFERENCE TOr2references;r REF= 0unbinds it. It starts unbound; it takes no initial value (E0013).__ISVALIDREF(r)is TRUE whenris bound.- A function block’s
VAR_INPUTof 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]orr(...)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 NULLREF_TOdereferenced is (dependencies, References). - Where a
REF_TOcannot 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, aREF_TOa 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::).
_.TONis the standard timer, from inside a namespace with its ownTON;_.Plant.Mainis the top-level namespacePlant’sMain, even inside a namespace that has aPlantof its own;_.g, a global variable, isg.
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.
| Code | What |
|---|---|
| E0001 | a character that has no place in IEC 61131-3 text |
| E0002 | a comment that is never closed |
| E0003 | a string literal that is never closed |
| E0004 | a literal that is not well formed |
| E0005 | the text does not fit the grammar here |
| E0006 | a block whose END keyword never comes |
| E0007 | a $ escape that a string literal does not have |
| E0008 | Instruction List, removed from IEC 61131-3 in Ed.4 |
| E0009 | a part of IEC 61131-3 that iecf does not compile yet |
| E0010 | a name that nothing declares |
| E0011 | a type that nothing declares |
| E0012 | a name declared twice |
| E0013 | a value whose type does not convert implicitly |
| E0014 | operands an operator or index does not take |
| E0015 | an assignment to what cannot be assigned |
| E0016 | a call that does not fit what it calls |
| E0017 | a VAR_EXTERNAL that matches no VAR_GLOBAL |
| E0018 | a name the standard reserves for a type, function or block |
| E0019 | a condition that is not BOOL |
| E0020 | a literal outside its type’s range |
| E0021 | EXIT or CONTINUE outside a loop |
| E0022 | an address where the standard does not allow one |
| E0023 | a * address that VAR_CONFIG does not complete |
| E0024 | a VAR_CONFIG entry that does not fit the variable it names |
| E0025 | a VAR_CONFIG outside a CONFIGURATION |
| E0026 | REF() of what cannot be referenced |
| E0027 | a reference where it cannot be |
| E0028 | a name a namespace does not have, or does not let this code see |
| E0029 | a short name that two USINGs both bring in |
| W0001 | an octal literal, removed from IEC 61131-3 in Ed.4 |
| W0002 | a direct address in a body, deprecated since Ed.3 |
| W0003 | TRUNC without a type, deprecated since Ed.3 |
| W0004 | an address whose size prefix is not its variable’s size |
| W0005 | REF() 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
VARof 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%Qaddress), and is complete; - an initial value does not go to a
VAR_TEMP,VAR_EXTERNAL,VAR_IN_OUTorCONSTANTvariable.
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 aVAR_EXTERNAL. Ed.3: “Applying the REF() operator to a temporary variable … is not permitted”; it is gone when the POU returns; - not
ENorENO(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 variableAT %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_TOone, and not a REFERENCE TO a reference, as in CODESYS and TwinCAT. UseREF_TOfor 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, orUSING Standard.Timer;for a namespace that does not exist; - INTERNAL: an element declared
FUNCTION INTERNAL, orFUNCTION_BLOCK INTERNAL, is seen only inside its own namespace; the public elements of aNAMESPACE INTERNALare 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); USINGof 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
VARandVAR_EXTERNALsections; its methods have the inputs and outputs (6.6.5.2); - an access specifier stands on a CLASS’s or FUNCTION_BLOCK’s
VARsection, not on its inputs, outputs or externals, nor on a method’s variables (6.6.5.10, 6.6.7.2.6); EXTENDSof 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);OVERRIDEon 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); FINALandABSTRACTtogether; anABSTRACTmethod in a type that is not ABSTRACT, withOVERRIDE, 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 aVAR_IN_OUT(6.6.5.8);SUPERoutside 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-typedREF_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
INTERFACEwith 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);EXTENDSof an interface orIMPLEMENTSnaming 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, aREF_TOa 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);THISoutside 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 aVAR_IN_OUTinstead (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:and1..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 task | The program sees | The platform sees | |
|---|---|---|---|
| Result | goes on | the value | nothing |
| Flag | goes on | a value, as the page for the operation says | ctx->flag: the first flag’s code, POU and line, until the platform clears it |
| Fault | stops | nothing more: the task returns at once | ctx->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):
| Code | Name | Kind | When |
|---|---|---|---|
| 1 | DIVISION_BY_ZERO | fault | an integer, real or duration divided by zero |
| 2 | OVERFLOW | flag | an integer, TIME, DATE or DT result outside its type, wrapped; an infinite real |
| 3 | INDEX | fault | an array index outside its bounds |
| 4 | CONVERSION | flag | a conversion to a type that cannot hold the value; text that is no literal of the type; a subrange store outside its bounds |
| 5 | MUX | fault | MUX’s K outside 0 .. n-1 |
| 6 | NEGATIVE | fault | a negative shift or rotate count |
| 7 | STRING_LENGTH | flag | a string cut to fit the variable it is stored in |
| 8 | IMAGE | fault | the platform’s process image smaller than the program addresses |
| 9 | NAN | flag | a real result that is not a number |
| 10 | STRING_POSITION | flag | a string function’s length or position outside its string |
| 11 | SFC | flag | an SFC step entered while still active; an action’s associations in conflict (SFC) |
| 12 | NULL | fault | a 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
rholds afterwards, as an IEC literal;NaN,+infand-inffor those reals; unchanged when the statement was left undone; — when the operation faulted. - Flag is
flag NAMEorfault 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:
| Type | Range | Type | Range |
|---|---|---|---|
| SINT | -128 .. 127 | USINT | 0 .. 255 |
| INT | -32768 .. 32767 | UINT | 0 .. 65535 |
| DINT | -2³¹ .. 2³¹-1 | UDINT | 0 .. 2³²-1 |
| LINT | -2⁶³ .. 2⁶³-1 | ULINT | 0 .. 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).
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
INT#32767 + INT#1 | INT | -32768 | flag OVERFLOW | |
INT#-32768 - INT#1 | INT | 32767 | flag OVERFLOW | |
SINT#100 * SINT#2 | SINT | -56 | flag OVERFLOW | 200 - 256 |
UINT#0 - UINT#1 | UINT | 65535 | flag OVERFLOW | |
USINT#200 + USINT#100 | USINT | 44 | flag OVERFLOW | 300 - 256 |
LINT#9223372036854775807 + LINT#1 | LINT | -9223372036854775808 | flag OVERFLOW | |
ULINT#18446744073709551615 + ULINT#1 | ULINT | 0 | flag OVERFLOW | |
ABS(INT#-32768) | INT | -32768 | flag OVERFLOW | 32768 is no INT |
ABS(INT#-5) | INT | 5 | — | |
INT#30000 + INT#2767 | INT | 32767 | — | 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.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
DINT#7 / DINT#2 | DINT | 3 | — | |
DINT#-7 / DINT#2 | DINT | -3 | — | toward zero |
DINT#7 / DINT#0 | DINT | — | fault DIVISION_BY_ZERO | |
UDINT#7 / UDINT#0 | UDINT | — | fault DIVISION_BY_ZERO | |
INT#-32768 / INT#-1 | INT | -32768 | flag OVERFLOW | 32768 is no INT |
DINT#7 MOD DINT#3 | DINT | 1 | — | |
DINT#-7 MOD DINT#3 | DINT | -1 | — | the dividend’s sign |
DINT#7 MOD DINT#-3 | DINT | 1 | — | |
DINT#7 MOD DINT#0 | DINT | 0 | — | Ed.3 Table 29 e |
Selection
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
MAX(INT#3, INT#-4, INT#2) | INT | 3 | — | |
MIN(UINT#3, UINT#7) | UINT | 3 | — | |
LIMIT(INT#0, INT#150, INT#100) | INT | 100 | — | MN, IN, MX |
LIMIT(INT#0, INT#-5, INT#100) | INT | 0 | — | |
SEL(FALSE, INT#1, INT#2) | INT | 1 | — | G = FALSE takes IN0 |
MUX(INT#1, INT#10, INT#20, INT#30) | INT | 20 | — | K counts from 0 |
MUX(INT#3, INT#10, INT#20, INT#30) | INT | — | fault MUX | no 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).
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
GT(INT#5, INT#2, INT#1) | BOOL | TRUE | — | 5 > 2 > 1 |
GT(INT#5, INT#6, INT#1) | BOOL | FALSE | — | |
GE(INT#5, INT#5, INT#5, INT#1) | BOOL | TRUE | — | |
LT(INT#1, INT#2, INT#2) | BOOL | FALSE | — | 2 < 2 does not hold |
LE(INT#1, INT#2, INT#2) | BOOL | TRUE | — | |
EQ('ab', 'ab', 'ab') | BOOL | TRUE | — | 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.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
WORD#16#F0F0 AND WORD#16#FF00 | WORD | 16#F000 | — | |
NOT BYTE#16#0F | BYTE | 16#F0 | — | |
BYTE#16#0F XOR BYTE#16#FF | BYTE | 16#F0 | — | |
SHL(BYTE#16#81, 1) | BYTE | 16#02 | — | the top bit falls out |
SHL(BYTE#1, 8) | BYTE | 16#00 | — | the width or more: 0 |
SHR(WORD#16#8000, 15) | WORD | 16#0001 | — | zeros in, also on top |
ROL(BYTE#16#81, 1) | BYTE | 16#03 | — | |
ROR(BYTE#16#01, 9) | BYTE | 16#80 | — | 9 is 1, modulo 8 |
SHL(BYTE#1, -1) | BYTE | — | fault NEGATIVE | |
SHL(TRUE, 1) | BOOL | FALSE | — | one bit: it falls out |
ROL(TRUE, 5) | BOOL | TRUE | — |
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)
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
REAL#1.0E38 * REAL#10.0 | REAL | +inf | flag OVERFLOW | |
REAL#-1.0E38 * REAL#10.0 | REAL | -inf | flag OVERFLOW | |
REAL#1.0 / REAL#0.0 | REAL | — | fault DIVISION_BY_ZERO | not +inf |
SQRT(REAL#-1.0) | REAL | NaN | flag NAN | |
LN(REAL#0.0) | REAL | -inf | flag OVERFLOW | |
LREAL_TO_REAL(LREAL#1.0E300) | REAL | +inf | flag OVERFLOW | beyond REAL |
EXPT(REAL#2.0, 10) | REAL | 1024.0 | — | |
REAL#2.0 ** REAL#0.5 | REAL | 1.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.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
REAL#0.1 + REAL#0.2 | REAL | 0.3 | — | the nearest REAL to each is the same |
LREAL#0.1 + LREAL#0.2 | LREAL | 0.30000000000000004 | — | not LREAL’s 0.3 |
REAL#16777216.0 + REAL#1.0 | REAL | 16777216.0 | — | 16777217 is no REAL |
REAL#1.0 / REAL#3.0 | REAL | 0.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.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
DINT#-3 + UINT#40000 | DINT | 39997 | — | the UINT widened |
UINT#40000 > DINT#-3 | BOOL | TRUE | — | |
SINT#-1 | LINT | -1 | — | the sign kept |
USINT#255 | INT | 255 | — | |
BYTE#16#FF OR WORD#16#0100 | WORD | 16#01FF | — | a bit string: no sign |
INT#3 / REAL#2.0 | REAL | 1.5 | — | Ed.3 Example 6 |
MAX(SINT#-1, DINT#-3) | DINT | -1 | — | |
INT#30000 * DINT#2 | DINT | 60000 | — | |
INT#30000 * INT#2 | DINT | -5536 | flag OVERFLOW | multiplied as INT, then widened |
INT#30000 + UINT#40000 | DINT | 70000 | — | both widened to DINT |
SINT#-1 + USINT#255 | INT | 254 | — | both to INT |
DINT#-1 + UDINT#4000000000 | LINT | 3999999999 | — | both to LINT |
INT#-1 < UINT#40000 | BOOL | TRUE | — | compared as DINTs |
MAX(INT#-1, UINT#40000) | DINT | 40000 | — |
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.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
INT_TO_DINT(INT#-5) | DINT | -5 | — | |
INT_TO_SINT(INT#100) | SINT | 100 | — | |
INT_TO_SINT(INT#200) | SINT | -56 | flag CONVERSION | 16#C8 |
INT_TO_SINT(INT#4223) | SINT | 127 | flag CONVERSION | 16#107F: its low byte |
DINT_TO_UINT(DINT#-1) | UINT | 65535 | flag CONVERSION | |
UINT_TO_INT(UINT#40000) | INT | -25536 | flag CONVERSION | |
ULINT_TO_LINT(ULINT#18446744073709551615) | LINT | -1 | flag 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.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
REAL_TO_INT(REAL#2.6) | INT | 3 | — | |
REAL_TO_INT(REAL#2.5) | INT | 2 | — | half to even |
REAL_TO_INT(REAL#3.5) | INT | 4 | — | half to even |
REAL_TO_INT(REAL#-2.5) | INT | -2 | — | |
REAL_TO_UINT(REAL#-0.4) | UINT | 0 | — | rounds to 0, which UINT holds |
REAL_TO_INT(REAL#40000.0) | INT | unchanged | flag CONVERSION | statement left undone |
REAL_TO_UINT(REAL#-1.0) | UINT | unchanged | flag CONVERSION | |
INT#10 / REAL_TO_INT(REAL#40000.0) | INT | unchanged | flag CONVERSION | no division by zero |
LREAL_TO_DINT(SQRT(LREAL#-1.0)) | DINT | unchanged | flag NAN | SQRT’s flag came first |
TRUNC_INT(REAL#-2.7) | INT | -2 | — | toward zero |
REAL_TRUNC_DINT(REAL#2.7) | DINT | 2 | — |
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.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
DINT_TO_REAL(DINT#16777216) | REAL | 16777216.0 | — | |
DINT_TO_REAL(DINT#16777217) | REAL | 16777216.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.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
BYTE_TO_BOOL(BYTE#16#02) | BOOL | TRUE | — | not the rightmost bit: any value but 0 |
BYTE_TO_BOOL(BYTE#0) | BOOL | FALSE | — | |
WORD_TO_BOOL(WORD#16#8000) | BOOL | TRUE | — | |
DWORD_TO_BOOL(DWORD#1) | BOOL | TRUE | — | |
LWORD_TO_BOOL(LWORD#16#8000000000000000) | BOOL | TRUE | — | |
TO_BOOL(BYTE#16#10) | BOOL | TRUE | — | the overloaded form |
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
INT_TO_WORD(INT#-1) | WORD | 16#FFFF | — | |
WORD_TO_INT(WORD#16#8000) | INT | -32768 | — | |
DWORD_TO_BYTE(DWORD#16#12345678) | BYTE | 16#78 | — | the rightmost byte |
BYTE_TO_DWORD(BYTE#16#FF) | DWORD | 16#000000FF | — | zeros on the left |
BOOL_TO_WORD(TRUE) | WORD | 16#0001 | — | |
BYTE#2 <> 0 | BOOL | TRUE | — | any bit set |
Between a 32 or 64-bit string and REAL or LREAL the bits are the IEEE 754 encoding:
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
DWORD_TO_REAL(DWORD#16#3F800000) | REAL | 1.0 | — | |
REAL_TO_DWORD(REAL#-2.0) | DWORD | 16#C0000000 | — | |
LREAL_TO_LWORD(LREAL#1.0) | LWORD | 16#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.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
ADD_DINT(INT#30000, INT#30000) | DINT | 60000 | — | added as DINTs |
ADD_INT(SINT#100, SINT#100, SINT#100) | INT | 300 | — | |
MUL_INT(INT#300, INT#300) | INT | 24464 | flag OVERFLOW | 90000 wrapped |
SUB_LINT(UDINT#0, UDINT#1) | LINT | -1 | — | |
DIV_REAL(INT#7, INT#2) | REAL | 3.5 | — | |
MOD_DINT(DINT#7, INT#3) | DINT | 1 | — | |
ABS_LINT(SINT#-128) | LINT | 128 | — | |
NOT_WORD(BYTE#16#0F) | WORD | 16#FFF0 | — | widened, then inverted |
SHL_WORD(BYTE#16#81, 1) | WORD | 16#0102 | — | SHL(BYTE#16#81, 1) is 16#02 |
AND_DWORD(BYTE#16#FF, WORD#16#0F0F) | DWORD | 16#0000000F | — | |
MAX_LREAL(REAL#1.5, INT#2) | LREAL | 2.0 | — | |
LIMIT_DINT(INT#0, DINT#-5, INT#10) | DINT | 0 | — | |
SEL_LREAL(TRUE, INT#1, REAL#2.5) | LREAL | 2.5 | — | |
MUX_UINT_DINT(UINT#1, INT#-5, DINT#7) | DINT | 7 | — | K a UINT, the inputs DINT |
GT_DINT(INT#-1, DINT#0) | BOOL | FALSE | — | |
EQ_LREAL(REAL#1.5, LREAL#1.5) | BOOL | TRUE | — | |
SQRT_LREAL(REAL#2.25) | LREAL | 1.5 | — | |
EXPT_REAL(REAL#2.0, 3) | REAL | 8.0 | — | |
MOVE_LINT(SINT#-7) | LINT | -7 | — | |
CONCAT_STRING('ab', 'cd') | STRING | 'abcd' | — | |
LEFT_STRING('abc', 2) | STRING | 'ab' | — | |
FIND_STRING('abcd', 'cd') | INT | 3 | — | |
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.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
ADD_TIME(T#1s, T#500ms) | TIME | T#1s500ms | — | |
SUB_LTIME(LTIME#1s, T#1ms) | LTIME | LTIME#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:
| Function | Typed for |
|---|---|
| ABS | the integer and real types |
| SQRT, LN, LOG, EXP, SIN, COS, TAN, ASIN, ACOS, ATAN, ATAN2, EXPT | REAL, LREAL |
| ADD, SUB, MUL, DIV | the integer and real types, TIME, LTIME |
| MOD | the integer types |
| SHL, SHR, ROL, ROR, AND, OR, XOR, NOT | the bit strings and BOOL |
| MOVE, SEL, MAX, MIN, LIMIT, MUX, GT, GE, EQ, LE, LT, NE | every elementary type |
| LEFT, RIGHT, MID, INSERT, DELETE, REPLACE, FIND | STRING, WSTRING |
| CONCAT | STRING, 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.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
CONCAT('ab', 'cd') | STRING | 'abcd' | — | |
CONCAT('ab', 'cd') | STRING[3] | 'abc' | flag STRING_LENGTH | cut where it is stored |
CONCAT('ab', 'cd', 'ef') | STRING | 'abcdef' | — | CONCAT takes any number |
'Z' > 'AZ' | BOOL | TRUE | — | |
'ab' < 'abc' | BOOL | TRUE | — | |
LEN('') | INT | 0 | — |
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.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
LEFT('abc', 2) | STRING | 'ab' | — | |
LEFT('abc', 0) | STRING | '' | — | no error |
LEFT('abc', 5) | STRING | 'abc' | flag STRING_POSITION | L past the end: all of IN |
LEFT('abc', -1) | STRING | '' | flag STRING_POSITION | L < 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_POSITION | L past the end: from P to the end |
MID('abc', 2, 0) | STRING | '' | flag STRING_POSITION | P < 1: empty |
MID('abc', 1, 4) | STRING | '' | flag STRING_POSITION | P past the end: empty |
DELETE('abcde', 2, 2) | STRING | 'ade' | — | DELETE(IN, L, P) |
DELETE('abc', 2, 5) | STRING | 'abc' | flag STRING_POSITION | P past the end: IN unchanged |
DELETE('abcde', 9, 2) | STRING | 'a' | flag STRING_POSITION | L 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_POSITION | P 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_POSITION | P past the end: appended |
FIND('abcbc', 'bc') | INT | 2 | — | the first |
FIND('abc', 'x') | INT | 0 | — | not there |
FIND('abc', '') | INT | 0 | — |
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.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
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 MUX | no 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_LENGTH | cut 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).
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
CHAR#'a' | CHAR | 'a' | — | |
CHAR#16#41 | CHAR | 'A' | — | |
BYTE_TO_CHAR(BYTE#16#42) | CHAR | 'B' | — | |
CHAR_TO_BYTE(CHAR#'a') | BYTE | 16#61 | — | |
CHAR_TO_USINT('a') | USINT | 97 | — | 'a' a CHAR here |
CHAR_TO_WORD(CHAR#'$FF') | WORD | 16#00FF | — | |
CHAR#'a' < CHAR#'b' | BOOL | TRUE | — | by code |
CHAR#'a' = 'a' | BOOL | TRUE | — | |
MAX(CHAR#'a', CHAR#'b') | CHAR | 'b' | — | |
STRING_TO_CHAR('xyz') | CHAR | 'x' | — | the first character |
STRING_TO_CHAR('') | CHAR | '$00' | flag CONVERSION | undefined 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', flaggedSTRING_POSITION, as that MID and STRING_TO_CHAR give; - a write changes only a character the string has: below 1 or past its
end,
sis left as it is, flaggedSTRING_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':
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
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_POSITION | past the end, inside STRING[10] |
s[LEN(s) - 4] | CHAR | '$00' | flag STRING_POSITION | 0 |
s[1] = 'A' | BOOL | TRUE | — | |
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.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
LEN("a$263Ab") | INT | 3 | — | |
LEN("${1F600}") | INT | 2 | — | a surrogate pair |
CONCAT("ab", "cd") | WSTRING[3] | "abc" | flag STRING_LENGTH | cut where it is stored |
MID("a$263Ab", 1, 2) | WSTRING | "$263A" | — | |
FIND("a$263Ab", "b") | INT | 3 | — | |
"Z" > "AZ" | BOOL | TRUE | — | |
WCHAR#16#263A > WCHAR#'z' | BOOL | TRUE | — | by unit |
WCHAR#"x" = "x" | BOOL | TRUE | — | "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 CONVERSION | skipped |
WSTRING_TO_STRING("a${1F600}b") | STRING | 'ab' | flag CONVERSION | a pair skipped whole |
STRING_TO_WSTRING('caf$E9') | WSTRING | "caf$00E9" | — | |
WCHAR_TO_CHAR(WCHAR#16#E9) | CHAR | CHAR#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 CONVERSION | undefined in Ed.3 |
WCHAR_TO_WORD(WCHAR#16#263A) | WORD | 16#263A | — | |
INT_TO_WSTRING(-7) | WSTRING | "-7" | — | |
WSTRING_TO_INT("42") | INT | 42 | — |
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:
TRUEorFALSE. - Integers and bit strings: decimal, a
-for a negative number and no+. - REAL and LREAL: C’s
%gwith 7 and 15 significant digits, correctly rounded, trailing zeros dropped —inf,-infandnanfor 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).
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
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 OVERFLOW | the 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.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
STRING_TO_INT(' 42') | INT | 42 | — | spaces before are skipped |
STRING_TO_INT('INT#-5') | INT | -5 | — | a type prefix |
STRING_TO_INT('16#FF') | INT | 255 | — | |
STRING_TO_DWORD('2#1111_0000') | DWORD | 16#F0 | — | |
STRING_TO_INT('123abc') | INT | 123 | — | the rest is ignored |
STRING_TO_INT('abc') | INT | 0 | flag CONVERSION | no literal |
STRING_TO_INT('') | INT | 0 | flag CONVERSION | |
STRING_TO_LDT('LDT#1677-09-21-00:12:43.145224192') | LDT | LDT#1677-09-21-00:12:43.145224192 | — | the first LDT |
STRING_TO_LDATE('LD#1677-09-21') | LDATE | LD#1970-01-01 | flag CONVERSION | no midnight LDATE holds |
STRING_TO_SINT('200') | SINT | 0 | flag CONVERSION | not a SINT |
STRING_TO_BOOL('true') | BOOL | TRUE | — | any case |
STRING_TO_BOOL('1') | BOOL | TRUE | — | |
STRING_TO_BOOL('yes') | BOOL | FALSE | flag CONVERSION | |
STRING_TO_REAL('2.5e3') | REAL | 2500.0 | — | |
STRING_TO_REAL('1e39') | REAL | 0.0 | flag CONVERSION | beyond REAL |
STRING_TO_TIME('T#1m30s') | TIME | T#1m30s | — | |
STRING_TO_TIME('t#1.5s') | TIME | T#1s500ms | — | |
STRING_TO_TIME('T#1.0005s') | TIME | T#1s | — | below a millisecond dropped |
STRING_TO_TIME('90') | TIME | T#0s | flag CONVERSION | no T# |
STRING_TO_DATE('D#2024-02-29') | DATE | D#2024-02-29 | — | |
STRING_TO_DATE('D#2023-02-29') | DATE | D#1970-01-01 | flag CONVERSION | no such day |
STRING_TO_TOD('TOD#24:00:00') | TOD | TOD#00:00:00 | flag CONVERSION | |
STRING_TO_DT('DT#2026-11-07-12:34:56.9') | DT | DT#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->flagas 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.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
DIV(EN := TRUE, IN1 := INT#7, IN2 := INT#2, ENO => eno) | INT | 3, ENO TRUE | — | |
DIV(EN := FALSE, IN1 := INT#7, IN2 := INT#2, ENO => eno) | INT | unchanged, ENO FALSE | — | the assignment is left undone |
DIV(EN := FALSE, IN1 := INT#7, IN2 := INT#0, ENO => eno) | INT | unchanged, ENO FALSE | — | not called: no fault |
DIV(EN := TRUE, IN1 := DINT#7, IN2 := DINT#0, ENO => eno) | DINT | — | fault DIVISION_BY_ZERO | a fault still stops the task |
ADD(IN1 := INT#32767, IN2 := INT#1, ENO => eno) | INT | -32768, ENO FALSE | flag OVERFLOW | its own overflow |
ADD(IN1 := INT#32767 + INT#1, IN2 := INT#1, ENO => eno) | INT | -32767, ENO TRUE | flag OVERFLOW | the argument’s overflow, not ADD’s |
ADD(IN1 := INT#1, IN2 := INT#1, NOT ENO => eno) | INT | 2, ENO FALSE | — | ENO negated |
REAL_TO_INT(IN := REAL#40000.0, ENO => eno) | INT | unchanged, ENO FALSE | flag CONVERSION | |
LEFT(IN := 'abc', L := 5, ENO => eno) | STRING | 'abc', ENO FALSE | flag STRING_POSITION | |
CONCAT(EN := FALSE, IN1 := 'a', IN2 := 'b') | STRING | unchanged | — | |
MAX(EN := TRUE, IN1 := INT#1, IN2 := INT#2) | INT | 2 | — | 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
fneffenruns 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.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
ADD(EN := TRUE, IN1 := ADD(EN := TRUE, IN1 := INT#1, IN2 := INT#2), IN2 := INT#3) | INT | 6 | — | nested |
ADD(EN := TRUE, IN1 := ADD(EN := FALSE, IN1 := INT#1, IN2 := INT#2), IN2 := INT#3) | INT | unchanged | — | 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 (
qkeeps its value), andokis 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
ENorENOitself 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]withiout of bounds does not fault - every output keeps its value, andokis 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.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
T#1s + T#500ms | TIME | T#1s500ms | — | |
T#1s - T#1500ms | TIME | T#-500ms | — | negative durations exist |
T#24d20h31m23s647ms + T#1ms | TIME | T#-24d20h31m23s648ms | flag OVERFLOW | |
LTIME#1s + LTIME#1ns | LTIME | LTIME#1s1ns | — | |
-(T#1s500ms) | TIME | T#-1s500ms | — | a negation |
-(LTIME#2ns) | LTIME | LTIME#-2ns | — | |
-(T#-24d20h31m23s648ms) | TIME | T#-24d20h31m23s648ms | flag OVERFLOW | its 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).
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
T#-1500ms + LTIME#0s | LTIME | LTIME#-1500ms | — | widened |
LTIME_TO_TIME(LTIME#1ms999us) | TIME | T#1ms | — | rounding down |
LTIME_TO_TIME(LTIME#-1500us) | TIME | T#-2ms | — | down, not toward zero |
LTIME_TO_TIME(LTIME#25d) | TIME | T#0s | flag CONVERSION | beyond 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
CONVERSIONand 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.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
T#1s * 3 | TIME | T#3s | — | |
3 * T#1s | TIME | T#3s | — | either order |
T#10s / 3 | TIME | T#3s333ms | — | toward zero |
T#-7ms / 2 | TIME | T#-3ms | — | toward zero |
T#1s * REAL#2.5 | TIME | T#2s500ms | — | |
T#3ms * REAL#1.5 | TIME | T#4ms | — | 4.5: half to even |
T#5ms * REAL#1.5 | TIME | T#8ms | — | 7.5: half to even |
T#1s / REAL#3.0 | TIME | T#333ms | — | 333.33 |
T#1s * REAL#1.0E10 | TIME | unchanged | flag CONVERSION | beyond TIME: left undone |
T#1s / 0 | TIME | — | fault DIVISION_BY_ZERO | |
T#1s / REAL#0.0 | TIME | — | fault DIVISION_BY_ZERO | |
T#24d * 2 | TIME | T#-1d17h2m47s296ms | flag OVERFLOW | wrapped |
MUL_TIME(T#2s, 2) | TIME | T#4s | — | |
DIV_TIME(T#1m, 4) | TIME | T#15s | — | |
LTIME#1us * 1500 | LTIME | LTIME#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
| Type | Held as | Range |
|---|---|---|
| DATE | seconds since 1970-01-01, at midnight; unsigned 32-bit | D#1970-01-01 .. D#2106-02-07 |
| TOD | milliseconds since midnight; unsigned 32-bit | TOD#00:00:00 .. TOD#23:59:59.999 |
| DT | seconds since 1970-01-01 00:00; unsigned 32-bit | DT#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
- 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.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
TOD#23:00:00 + T#2h | TOD | TOD#01:00:00 | — | wraps at midnight |
TOD#01:00:00 - T#2h | TOD | TOD#23:00:00 | — | |
TOD#01:00:00 - TOD#23:00:00 | TIME | T#-22h | — | |
DT#2026-11-07-22:00:00 + T#7h | DT | DT#2026-11-08-05:00:00 | — | |
DT#2026-11-07-12:00:00 + T#1500ms | DT | DT#2026-11-07-12:00:01 | — | ms dropped, rounding down |
DT#2026-11-07-12:00:00 - T#1500ms | DT | DT#2026-11-07-11:59:58 | — | down, not toward zero |
DT#1970-01-01-00:00:00 - T#1s | DT | DT#2106-02-07-06:28:15 | flag OVERFLOW | wrapped |
D#2026-12-01 - D#2026-11-07 | TIME | T#24d | — | |
D#2026-12-07 - D#2026-11-07 | TIME | T#-19d17h2m47s296ms | flag OVERFLOW | 30 days: beyond TIME |
SUB_DT_DT(DT#2026-11-07-12:00:00, DT#2026-11-07-11:00:00) | TIME | T#1h | — | |
DT_TO_TOD(DT#2026-11-07-12:34:56) | TOD | TOD#12:34:56 | — | |
DT_TO_DATE(DT#2026-11-07-12:34:56) | DATE | D#2026-11-07 | — | |
CONCAT_DATE_TOD(D#2026-11-07, TOD#12:34:56.789) | DT | DT#2026-11-07-12:34:56 | — | ms dropped |
DAY_OF_WEEK(D#2026-11-07) | USINT | 6 | — | 0 is Sunday (Ed.3 Table 36) |
DAY_OF_WEEK(D#1970-01-01) | USINT | 4 | — | a Thursday |
MAX(D#2026-11-07, D#2025-01-01) | DATE | D#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.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
CONCAT_DATE(2026, 11, 8) | DATE | D#2026-11-08 | — | |
CONCAT_DATE(2024, 2, 29) | DATE | D#2024-02-29 | — | a leap year |
CONCAT_DATE(2023, 2, 29) | DATE | D#1970-01-01 | flag CONVERSION | no such day |
CONCAT_DATE(2026, 13, 1) | DATE | D#1970-01-01 | flag CONVERSION | |
CONCAT_DATE(1969, 12, 31) | DATE | D#1970-01-01 | flag CONVERSION | before the range |
CONCAT_TOD(12, 34, 56, 789) | TOD | TOD#12:34:56.789 | — | |
CONCAT_TOD(24, 0, 0, 0) | TOD | TOD#00:00:00 | flag CONVERSION | |
CONCAT_TOD(12, 0, 0, 1000) | TOD | TOD#00:00:00 | flag CONVERSION | |
CONCAT_DT(2026, 11, 8, 12, 34, 56, 789) | DT | DT#2026-11-08-12:34:56 | — | ms dropped |
CONCAT_DT(2106, 2, 7, 6, 28, 16, 0) | DT | DT#1970-01-01-00:00:00 | flag CONVERSION | past 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
| Type | Held as | Range |
|---|---|---|
| LDATE | nanoseconds since 1970-01-01, at midnight; signed 64-bit | LD#1677-09-22 .. LD#2262-04-11 |
| LTOD | nanoseconds since midnight; signed 64-bit | LTOD#00:00:00 .. LTOD#23:59:59.999999999 |
| LDT | nanoseconds since 1970-01-01 00:00; signed 64-bit | LDT#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 isT#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 + LTIMEis an LDT, never a DT, which could not hold the LTIME’s range.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
LTOD#23:00:00 + LTIME#2h | LTOD | LTOD#01:00:00 | — | wraps at midnight |
LTOD#01:00:00 - LTIME#2h1ns | LTOD | LTOD#22:59:59.999999999 | — | |
LTOD#01:00:00 - LTOD#23:00:00 | LTIME | LTIME#-22h | — | |
LDT#2026-11-08-22:00:00 + LTIME#7h | LDT | LDT#2026-11-09-05:00:00 | — | |
LDT#1970-01-01-00:00:00 - LTIME#1ns | LDT | LDT#1969-12-31-23:59:59.999999999 | — | before 1970 |
LDT#2262-04-11-23:47:16.854775807 + LTIME#1ns | LDT | LDT#1677-09-21-00:12:43.145224192 | flag OVERFLOW | wrapped |
LDT#2026-11-08-12:00:00 + T#1500ms | LDT | LDT#2026-11-08-12:00:01.5 | — | a TIME widened |
DT#2026-11-08-12:00:00 + LTIME#1500ms | LDT | LDT#2026-11-08-12:00:01.5 | — | the DT widened |
LDT#2026-11-08-12:00:01.5 - DT#2026-11-08-12:00:00 | LTIME | LTIME#1500ms | — | |
TOD#10:00:00 + LTIME#1500ms | LTOD | LTOD#10:00:01.5 | — | |
LD#2026-11-09 - D#2026-11-08 | LTIME | LTIME#1d | — | |
LD#2026-12-08 - LD#2026-11-08 | LTIME | LTIME#30d | — | |
SUB_LDT_LDT(LDT#2262-04-11-00:00:00, LDT#1677-09-22-00:00:00) | LTIME | LTIME#-1d23h34m33s709ms551us616ns | flag OVERFLOW | beyond LTIME: wrapped |
DT_TO_LDT(DT#2106-02-07-06:28:15) | LDT | LDT#2106-02-07-06:28:15 | — | |
LDT_TO_DT(LDT#2026-11-08-12:00:00.999999999) | DT | DT#2026-11-08-12:00:00 | — | rounding down |
LDT_TO_DT(LDT#1969-12-31-23:59:59) | DT | DT#1970-01-01-00:00:00 | flag CONVERSION | before DT’s range |
LDT_TO_DATE(LDT#2106-02-08-00:00:00) | DATE | D#1970-01-01 | flag CONVERSION | after it |
LDATE_TO_DATE(LD#2026-11-08) | DATE | D#2026-11-08 | — | |
LDT_TO_LDATE(LDT#1969-12-31-23:59:59.999999999) | LDATE | LD#1969-12-31 | — | down is earlier |
LDT_TO_LTOD(LDT#1969-12-31-23:59:59.999999999) | LTOD | LTOD#23:59:59.999999999 | — | |
LDT_TO_TOD(LDT#2026-11-08-12:34:56.789999999) | TOD | TOD#12:34:56.789 | — | |
LTOD_TO_TOD(LTOD#23:59:59.999999999) | TOD | TOD#23:59:59.999 | — | |
DT_TO_LTOD(DT#2026-11-08-12:34:56) | LTOD | LTOD#12:34:56 | — | |
CONCAT_DATE_LTOD(D#2026-11-08, LTOD#12:34:56.000000001) | LDT | LDT#2026-11-08-12:34:56.000000001 | — | |
CONCAT_LDT(1677, 9, 21, 0, 12, 43, 146) | LDT | LDT#1677-09-21-00:12:43.146 | — | |
CONCAT_LDT(1677, 9, 21, 0, 12, 43, 145) | LDT | LDT#1970-01-01-00:00:00 | flag CONVERSION | before the range |
CONCAT_LTOD(23, 59, 59, 999) | LTOD | LTOD#23:59:59.999 | — | |
MAX(LD#1969-12-31, LD#1677-09-22) | LDATE | LD#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 aVAR_GLOBAL’s name theVAR_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 * 10000is 40000 thoughN2is 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 andTRUNC, 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
MUXselecting an input it has not, an index outside a constant array, and a constant whose initial value names itself, have no value: E0032.MODby 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.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
INT#50 | INT(0..100) | 50 | — | |
INT#150 | INT(0..100) | 100 | flag CONVERSION | the bound it passed |
INT#-5 | INT(0..100) | 0 | flag CONVERSION | |
INT#100 | INT(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.
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
rz^ | INT | 0 | — | z, through rz := REF(z) |
rz^ + INT#5 | INT | 5 | — | |
rz^ OR TRUE | BOOL | TRUE | — | |
nr^ | INT | — | fault NULL | nr is NULL |
nr^ + INT#1 | INT | — | fault NULL | nothing is stored |
CONCAT(rz^, 'ab') | STRING | 'ab' | — | z is '' |
rz^ | WSTRING | "" | — | |
CONCAT(rz^, "a$263A") | WSTRING | "a$263A" | — | |
CONCAT(nr^, "a") | WSTRING | — | fault NULL | nothing 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^:
| Expression | Type | Result | Flag | Note |
|---|---|---|---|---|
__ISVALIDREF(ur) | BOOL | FALSE | — | ur unbound |
ur | INT | — | fault NULL | |
ur + INT#1 | INT | — | fault NULL | nothing 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.xis never a variable (E0028, D7). NAMESPACE A.BisNAMESPACE AwithNAMESPACE Binside. 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
Motorand a NAMESPACEMotor- is E0012 (D8):Motor.Startcould 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 ownTONhides the standard one inside it (6.9.2 Example 2). INTERNALbefore a function’s, function block’s or program’s name keeps it inside its namespace and the namespaces in that one;NAMESPACE INTERNAL Nshows 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):
| Where | Written | For |
|---|---|---|
| at the top of a file, before its declarations | USING Lib.Shapes; | that file (D6) |
| after a namespace’s name | NAMESPACE Plant USING Lib.Shapes; | that declaration of the namespace |
| after a POU’s name, a function’s result type | FUNCTION_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:
- the POU’s own variables, then what the POU’s USINGs bring in;
- the namespace the POU is in, then what its USINGs bring in;
- each namespace around that one, outwards, the same;
- 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
VARandVAR_EXTERNALsections; 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 barem()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
PUBLICwritten (E0030). - Specifiers come in any order:
METHOD PUBLIC FINAL morMETHOD FINAL PUBLIC m(D2). They are words only there: a variable may still be namedfinal. 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
EXTENDSnames 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).OVERRIDEreplaces 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.FINALon 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, andSUPER.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
ABSTRACTtype 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 aREF_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 mayEXTENDany number of interfaces (6.6.6.3), declaring none of their methods again. - A CLASS or FUNCTION_BLOCK
IMPLEMENTSany 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), givesawhatbreferences if it is of a typeacan hold, NULL otherwise: an interface from another (d ?= r: the instance, if it implements DIMMED), aREF_TOa CLASS or FUNCTION_BLOCK from an interface (the instance, if it is of that type or one derived from it), and aREF_TOsuch a type from aREF_TOits 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’s50((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 methodaccs[i].Add(...), an in-out or an interface giventons[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 := acopies 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.bthen runs on from whereawas, 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, andwatch.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 casestdinoutruns 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.nreads 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 caseholders). - 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.
| Counter | Type of CV | Counts up to | Counts down to |
|---|---|---|---|
| CTU, CTD, CTUD | INT | 32767 | -32768 |
| CTU_DINT, CTD_DINT, CTUD_DINT | DINT | 2147483647 | -2147483648 |
| CTU_LINT, CTD_LINT, CTUD_LINT | LINT | 2⁶³-1 | -2⁶³ |
| CTU_UDINT, CTD_UDINT, CTUD_UDINT | UDINT | 4294967295 | 0 |
| CTU_ULINT, CTD_ULINT, CTUD_ULINT | ULINT | 2⁶⁴-1 | 0 |
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
| Declared | Cold start | Warm start |
|---|---|---|
VAR RETAIN, VAR_GLOBAL RETAIN | initial value | kept |
VAR NON_RETAIN | initial value | initial value |
| neither | initial value | initial value — Ed.3 leaves it to the implementer; CODESYS, TwinCAT and Siemens set it anew |
VAR RETAIN x AT %MW0, at an address | initial value | kept: 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 block | Instance in a plain section | Instance in VAR RETAIN |
|---|---|---|
in its VAR RETAIN | kept | kept |
in its VAR NON_RETAIN | initial value | initial value |
| neither | initial value | kept |
| a block inside it | its own type and section decide, not the outer instance | the 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:
- Step times. Each active step’s
Tis the time since it was entered, in whole milliseconds, from the task’s clock. - 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).
- 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.
- 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;.
| Qualifier | The action’s Q | Runs |
|---|---|---|
| N (or none) | while the step is active | while Q, and one final scan |
| S | from the step’s start, stored after it | until an R, then one final scan |
| R | resets a stored action (S, SD, DS, SL) and overrides N | — |
| L t | from the step’s start, for at most t | while Q, and one final scan |
| D t | after t, if the step is still active | while Q, and one final scan |
| P | for one scan, on entry | twice: with Q, then its final scan |
| P1 | never | once, on entry, Q FALSE |
| P0 | never | once, on leaving, Q FALSE |
| SD t | t after the step’s start, stored | until an R, then one final scan |
| DS t | after t if the step is still active, then stored | until an R, then one final scan |
| SL t | from the step’s start, stored, for at most t | while 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 -Werrorand 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.hinclude 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, …) andEXPT, which come from<math.h>unless the platform definesIECF_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
intis 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):
| File | What |
|---|---|
plant.h | the context type plant_t, the entry points (§3), the image sizes |
plant.c | every POU, the tasks, initialisation, the symbol table (§6) |
iecf.h | the 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.json | the 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>_initandP_<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 globalgbefore each run, the body reading and writinggthrough 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
Xof a block or a PROGRAM has, beside its fieldV_X, the given value, a fieldE_X, aniecf_R_TRIG_toriecf_F_TRIG_t, called withV_Xas the body starts; the body readsE_X.Q(built). The trigger is not RETAIN, the input may be. - A FUNCTION is
static <result> F_<NAME>(plant_t *ctx, <inputs>...),voidfor 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 takesIEC_BOOL *enoafterctx, 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 aconst void *P_<NAME>, copied into the localV_<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 avoid *V_<NAME>, the variable itself, in C memory and of its type exactly; an output avoid *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 fieldsV_<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 - andB_<NAME>_initandB_<NAME>(built). One whose body uses ENO without declaring it has a fieldeno, 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, oriecf.h’siecf_<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 theB_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 byB_<NAME>_initat 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>_tandB_<NAME>_init, as a FUNCTION_BLOCK, with no body function: it has none. A method of a CLASS or FUNCTION_BLOCK isM_<TYPE>__<METHOD>(ctx, B_<TYPE>_t *self, ...), its parameters as a FUNCTION’s, its type’s variablesself->V_<NAME>;inst.m(...)andTHIS.m(...)call it with&instandself(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 itTYPE.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 isself->base.V_X, and its_initcalls its base’s first. The root of a hierarchy (a type with a base or a derived type) starts withconst iecf_otype_t *type, which each_initsets to its ownTY_<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’sself; a derived type’s implementation is reached through a thunkM_<TYPE>__<METHOD>__vt(orB_<TYPE>__vtfor 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, andtable, that instance’s type’s table for the interface; NULL whenselfis. The table’s C type isIT_<ITF>_t, a function pointerm_<METHOD>for each method of the interface and of those it extends, each takingvoid *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 thunksIM_<ITF>__<TYPE>__<METHOD>to the method it has; itsTY_<NAME>lists them (itfs, by the interface’sITY_<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 ?= bisiecf_itf_asbetween interfaces,iecf_itf_refinto aREF_TOa type, andiecf_ref_asbetween references, each NULL when the instance is not of a typeaholds (OO.md step C; built). An interface is aniecf_itf_twherever 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’svoid *R_,const void *P_<NAME>), a plain input by value. An initial value naming an instance is set in the_initonce the POU’s variables are all placed, asREF()’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_tfor the typed forms (built). - A STRUCT is a structure
T_<NAME>with a fieldV_<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 INTisIEC_INT V_A[3],a[-1]isV_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 aniecf_flex_t(iecf.h):pthe caller’s array,beach 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 againstb, a constant one too;LOWER_BOUND(a, 1)isa->b[0], and with a computed dimensioniecf_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 isif (iecf_flex_n(a, 0u) != iecf_flex_n(b, 0u)) iecf_fail(&ctx->fault, IECF_FAULT_INDEX);, the statement stopped there, theniecf_moveof 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_Aandvoid *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; anARRAY[*]one aniecf_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):
A TASK’s SINGLE and INTERVAL are each a constant, a global or one field of one, a program’s output (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;P1.OUT) or an address (Ed.3 6.8.2, Data_Source). A constant INTERVAL is worked out intointerval_ns; one that is a variable isinterval, which gives it in nanoseconds from the context as it is now (0 for a negative one),interval_nsthen 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_ns0, 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 isctx->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.5is bit 5 of byte 3;%IB3is byte 3;%IW3is bytes 3 and 4;%ID3bytes 3 to 6;%IL3bytes 3 to 10. So%IW0and%IW1overlap, and%IX1.0is 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 (%MD48is 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 : TIMEis bytes 0 to 3, a signed count of milliseconds; an LTIME, LDATE, LTOD or LDT 8 bytes;r AT %IB12 : REALbytes 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,pta BOOL, an INT and a TIME, is bytes 0 to 6,p.tbytes 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 aniecf_ref_t, a C pointer for a variable in the context (area0, NULL for NULL) or those three for one in the image;r^reads and writes throughiecf_rget_<T>andiecf_rset_<T>, which look at where it is and treat the image as the variable itself does; a WSTRING throughiecf_wref_get, its units in place in C memory or copied out of the image, andiecf_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 givesctx->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 byplant_init: the input image is the platform’s. Those of%Qand%Mvariables 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, noabort, 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;codesays 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),pouwhich POU (an index intoplant_pou_names),linethe 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 nameplant_pou_namesthen holds too, and the line of the connection. -
Not a fault, as Ed.3 defines it:
x MOD 0is 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(aniecf_fault_t), takes the first, its POU and line:- an integer result beyond its type, from + - * / NEG ABS: it wraps,
two’s complement (
32767 + 1is-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_OVERFLOWfor an infinity,IECF_FAULT_NANfor 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 (
codeto 0,lineto 0);plant_initclears it. - an integer result beyond its type, from + - * / NEG ABS: it wraps,
two’s complement (
-
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_initorplant_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.Gfor resource R’s), and each variable of each program instance (R.P.Xfor 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_RETAINwhen 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_BITfor any other (a BOOL there takes the whole byte). An array, structure or STRING there has the flagIECF_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_*, aSTRING[n]asIECF_T_STRINGof size n + 1, aWSTRING[n]asIECF_T_WSTRINGof size 2(n + 1) (JSON kindwstring); 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_RETAINfor 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 aniecf_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.
- the user’s and the standard ones - by its members, each with its
type, offset and flags (
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_initis a cold start: every variable to its initial value, the RETAIN ones too.plant_warmis 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_tin the region, one per instance, which the instance reaches by itsretainpointer. - 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:keep0 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
_initgiven thekeepof 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_RETAINand 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”. Sod := i * jof 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, butlint := dint * dintis. 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)isSQRT(2.0);r := 2isr := 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 DINTv, and for an INTvwraps to -5536 and is flagged OVERFLOW, asv := a + bof two INTs is. Integers stay integers where a real is wanted:r := 7 / 2is 3.0, and so isr := 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 caselitcallsruns 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
- 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 => xleaves 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.Tcounts 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; inf(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.fbdPreserveFunctionOutputskeeps 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:00andTOD#00:00the 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:
| Type | iecf | CODESYS / TwinCAT | Siemens S7-1200/1500 |
|---|---|---|---|
| TIME | signed 32-bit, ms: ±24d20h31m23s647ms | unsigned 32-bit, ms: 0 to 49 days | signed 32-bit, ms |
| LTIME | signed 64-bit, ns: ±292 years | 64-bit, ns | signed 64-bit, ns |
| DATE, DT | unsigned 32-bit, seconds since 1970-01-01 | the same | DATE: days since 1990 |
| TOD | unsigned 32-bit, ms since midnight | the same | the same |
| LDATE, LTOD, LDT | signed 64-bit, ns (since 1970, since midnight): 1677-09-21 to 2262-04-11 | 64-bit, ns: the same range | LTOD, 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#-1500usisT#-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 givesT#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 / 2isT#-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.5isT#2s500ms,T#3ms * 1.5isT#4ms. A result outside TIME, or infinite or NaN, is flaggedIECF_FAULT_CONVERSIONand 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#2hisTOD#01:00:00,TOD#01:00:00 - T#2hisTOD#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_TODof 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):
STRINGisSTRING(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.WSTRINGisWSTRING(80);WSTRING(n)is n+1 16-bit units, UTF-16, 0-terminated.- The longest
nis 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 writesvarChar := 'A'; RuSTy takes it as an initial value but wantsCHAR#'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_CHARof 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_CHARandCHAR_TO_WORD,_DWORD,_LWORDare 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))andREPLACE(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 < 0 empty LEFT, RIGHT: L past the end all of IN MID: L < 0, P < 1, P past the end empty MID: L past the end from P to the end DELETE: L < 0, P < 1 empty DELETE: P past the end IN unchanged DELETE: L past the end deleted to the end INSERT: P < 0 empty INSERT: P past the end IN2 appended REPLACE: L < 0, P < 1 empty REPLACE: P past the end IN2 appended REPLACE: L past the end replaced 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 STRING | Text | Why |
|---|---|---|
| BOOL | TRUE, FALSE | TwinCAT: “the result is ‘TRUE’ or ‘FALSE’” (tc3_plc_intro, Boolean conversion); CODESYS BOOL_TO_STRING(TRUE) is 'TRUE' |
| integers | decimal, - only: '-123456' | Siemens S_CONV “do[es] not use a leading “+” sign“; CODESYS and TwinCAT silent |
| BYTE … LWORD | decimal: '255' | no vendor documents it; CODESYS users report decimal (forum, unverified). matiec writes 16#ff |
| REAL, LREAL | C’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, LTIME | the 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, soWCHAR#"${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#'..'andWCHAR#'X'(Table 6, 4c and 4d) read$hh, as single quotes do; double quotes read$hhhh(Table 6, 2h).WCHAR#16#263Ais the unit of that code, asCHAR#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_WSTRINGgives 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.5is bit 5 of byte 3,%IW3bytes 3 and 4,%MD48bytes 48 to 51;%MW0and%MW1overlap. As Beckhoff (“addressing is always bytewise”) and Siemens; CODESYS counts in units of the size (%MD48is 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.3is 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.3is 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 INTat%QW6is silent, at%QB6warned. 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 aREF_TOdereferenced 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), andr.f,r[i]andr(...)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 aVAR_GLOBAL CONSTANTthrough itsVAR_EXTERNAL CONSTANT(Ed.3 6.6.1.2.4: “constant variables”). ATYPEdeclaration sees the global constants directly: Ed.3 does not say what a TYPE outside a CONFIGURATION sees, and CODESYS takes a GVL’sVAR_GLOBAL CONSTANTin any declaration’s bounds (its error C0161, “Border … of array is no constant value”, for a plainVAR_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 hasArray [*]“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 aVAR_GLOBAL’s initial value the global constants directly, as aTYPE’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 * 200with 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 anARRAY [1..20, -2..2]is anARRAY [-2..2](decided 2026-11-11, florin). Ed.3’s Example 2 writesSUM(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-dimensionala, is an error when it is a constant (Ed.3 Table 15: “error”), and a faultINDEXwhen 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 := acopies 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 itsno_assignattribute). 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 = bfor 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, eBlackgives “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
strictattribute, 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.xis 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.TONfor the standard timer cannot be parsed;_., as Siemens and PLCnext, is the extensionglobal-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.0andpv AT %QW0overlap; 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
| program | iecf | first thing it stops at, or how it runs beside matiec |
|---|---|---|
| 0510d587a215 | E0005 | an expression on its own is not a statement: assign it with ‘:=’, or call a function or block |
| 179bdd6fe9b3 | reference fails | 179bdd6fe9b3.st:7: error: invalid specification in global variable declaration. |
| 1ce81a4aa3fc | reference fails | 1ce81a4aa3fc.st:4: error: invalid variable(s) declaration. |
| 248d0c3dc6e5 | reference fails | 248d0c3dc6e5.st:4: error: invalid expression after ‘:=’ in ST assignment statement. |
| 2c61deb63dc2 | reference fails | 2c61deb63dc2.st:4: error: invalid expression after ‘:=’ in ST assignment statement. |
| 7d80e41fddc8 | builds | differs at tick 0: matiec RES0.INSTANCE0.DOUT0 0, iecf RES0.INSTANCE0.DOUT0 1 |
| d779cf508890 | builds | differs at tick 0: matiec RES0.INSTANCE0.OK_DWORD 1, iecf RES0.INSTANCE0.OK_DWORD 0 |
| c583e73f52a4 | builds | differs at tick 4: matiec 4 Q 01 01 00 M, iecf 4 Q 00 00 00 M |
| de13ecfb543e | builds | iecf faults at tick 0: 0 Q M fault 1 RATIO 12 |
| 64523234ff8c | builds | reference fails: cc |
| 255308ff121c | builds | reference fails: the host (signal: 8 (SIGFPE) (core dumped)) |
| 230769e20509 | builds | same (40 ticks, 1 variables) |
| 3ad71432cf5c | builds | same (40 ticks, 1 variables) |
| 18c8c52b215e | builds | same (40 ticks, 10 variables) |
| 5d9cc678a7bb | builds | same (40 ticks, 10 variables) |
| 1e6bcc1d3c5a | builds | same (40 ticks, 11 variables) |
| 2a0e80d6c30c | builds | same (40 ticks, 11 variables) |
| bfb5e586cc47 | builds | same (40 ticks, 11 variables) |
| 982a05cc407f | builds | same (40 ticks, 15 variables) |
| 0c84b3c7c398 | builds | same (40 ticks, 2 variables) |
| 0ef1d1027ab4 | builds | same (40 ticks, 2 variables) |
| 497b4eace0ba | builds | same (40 ticks, 2 variables) |
| 74423056b107 | builds | same (40 ticks, 2 variables) |
| 7833ba53e5f5 | builds | same (40 ticks, 2 variables) |
| 794d4d8bf3d7 | builds | same (40 ticks, 2 variables) |
| a6b137a4922c | builds | same (40 ticks, 2 variables) |
| ad18002b9ab6 | builds | same (40 ticks, 2 variables) |
| e083218e28b5 | builds | same (40 ticks, 2 variables) |
| e2e500751e1a | builds | same (40 ticks, 2 variables) |
| fb5e1709002a | builds | same (40 ticks, 2 variables) |
| a08a5c3a375e | builds | same (40 ticks, 23 variables) |
| 5ea551f0ca8c | builds | same (40 ticks, 24 variables) |
| 7ceadb39147f | builds | same (40 ticks, 29 variables) |
| 03a46577ef84 | builds | same (40 ticks, 32 variables) |
| 34cd70a978b2 | builds | same (40 ticks, 4 variables) |
| 3e9f8d035dfc | builds | same (40 ticks, 4 variables) |
| 464b42244ca3 | builds | same (40 ticks, 4 variables) |
| 74940fbd8c9d | builds | same (40 ticks, 4 variables) |
| 91702e819e6c | builds | same (40 ticks, 4 variables) |
| a938c7dd8ee2 | builds | same (40 ticks, 4 variables) |
| f1251103eeda | builds | same (40 ticks, 4 variables) |
| 2f3e6e7f563a | builds | same (40 ticks, 5 variables) |
| adc5e97901d9 | builds | same (40 ticks, 5 variables) |
| aea623321632 | builds | same (40 ticks, 5 variables) |
| 51ec13ab65f5 | builds | same (40 ticks, 6 variables) |
| f4297b60aff7 | builds | same (40 ticks, 6 variables) |
| ff2742d2d583 | builds | same (40 ticks, 6 variables) |
| 8b5aba97b8e3 | builds | same (40 ticks, 69 variables) |
| 4fc729c06cf9 | builds | same (40 ticks, 7 variables) |
| 51fc2c5b2de6 | builds | same (40 ticks, 7 variables) |
| a2a5a26b53e9 | builds | same (40 ticks, 7 variables) |
| d3f6f71ca368 | builds | same (40 ticks, 7 variables) |
| f751e9830fc3 | builds | same (40 ticks, 8 variables) |
| c0169b32e6e3 | builds | same (40 ticks, 9 variables) |
| 33310c6a515a | builds | same (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).
| Missing | As met |
|---|---|
| LTIME, LDATE, LTOD, LDT | Ed.3 types; absent (standard-functions §2a, abi §6) |
| CONCAT_DATE, CONCAT_TOD, CONCAT_DT, SPLIT_DATE, SPLIT_TOD, SPLIT_DT | Ed.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 127 | The lexer refuses 'héllo' in UTF-8 and in Latin-1; programs write '$E9'. There is no encoding (abi §6) |
| Ed.3 object orientation | METHOD, 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 conversions | INT + 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 FBD | No 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.hmaps 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
dtdraws the same words.matiec_hints.pyexists to explain them. iec_lib_*left undefined: the generated code calls math, printf andmemcmpwrappers matiec does not define. Each failed at link until musl was vendored (toolchain/libm/,toolchain/printf/).REAL_TO_STRINGprints%.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 whileCV > 0; Ed.3 Table 45 hasCV < PVmaxandCV > 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_mulis 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 => xorNOT 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 ILDIVtrap (FAULT); STMODgives 0; theDIV()/MOD()functions - and so FBD and LD - give 0 with ENO FALSE; ILMODtraps. - Overflow:
INTandSINTwrap through C’s promotion toint;DINTandLINTtrap 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.csvis paired withLOCATED_VARIABLES.hby position, checked by count (matiec_symbols.py). It lists no array, structure, enumeration or function block internals, somatiec_aux.pyandmatiec_retain.pyrebuild them from the program text. - RETAIN is a flag.
__INIT_VARresets the value on every init; the RETAIN of an array or structure is dropped silently.matiec_retain.pykeeps retain field by field (abi §6). - Contracts that are only observed: the instance global’s name
(
RES0__INST) andPOUS.cincluded 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,TODas 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), soLT,ADD,MODare 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_declarationsandvar_declarations(B.1.4.3), Ed.3Loc_Var_DeclsandVar_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 types | the browser checks (wasm) | |
|---|---|---|
| Server load | a request per user per pause in typing - thousands a minute, scaled and paid for | none: each user’s machine checks its own code |
| Delay | a network round trip, 50-300 ms, plus queueing | microseconds per POU: errors as one types |
| Server down or slow | no errors shown | still works, offline too |
| The server still does | everything | builds, 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:
- A core without I/O.
iecf-syntax,iecf-semaand what follows take text and return trees and diagnostics: no files, no threads, no network, no native libraries. CI builds the core forwasm32-unknown-unknownon every push, so the rule cannot erode. The same library serves the browser, a Language Server for desktop editors, CI, and the build. - 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.
- The same input as the build - §2b.
- Diagnostics as JSON, as
iecf check --format jsongives 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 compiler | The standard’s own way | Ed.3 |
|---|---|---|
| which programs run, on which tasks, how often | CONFIGURATION, RESOURCE, TASK, program instances | 6.8, Annex A |
| the global variables | VAR_GLOBAL in the configuration or a resource | 6.5.2 |
| the I/O | a variable at an address: start AT %IX0.0 : BOOL; | 6.5.5, Table 16 |
| a POU reused across machines, wired per instance | AT %I* in the POU, the full address in the configuration’s VAR_CONFIG | 6.5.5.4 |
| a POU’s use of a global | VAR_EXTERNAL | 6.5.2.2 |
So:
- 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 checkdoes already). There is no manifest. - 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
CONFIGURATIONsays 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. - The hardware stays outside the compiler. IEC 61131-3 stops at
the direct address:
%IX0.0is 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’sio_map.jsonis one such mapping, and stays in Prokop. - 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.
VAR_EXTERNALis 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.- 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. TheVAR_EXTERNALextension is in, off by default.VAR_ACCESSis 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.
| Edition | Year | In short |
|---|---|---|
| Ed.1 | 1993 | The five languages: IL, ST, LD, FBD, SFC |
| Ed.2 | 2003 | Revision. matiec implements this one |
| Ed.3 | 2013 | “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.4 | 2025-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, literalsU#'...', 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):
MUTEXandSEMAfunction blocks with methods, and the functionsMUTEX_LOCK/_UNLOCK/_TRYLOCK,SEMA_ACQUIRE/_RELEASE/_TRY_ACQUIRE. - Properties:
PROPERTY_GET/PROPERTY_SETon CLASS, FUNCTION_BLOCK and INTERFACE. ASSERT(IN).- STRING/WSTRING/USTRING to and from
ARRAY OF BYTE; some new*_TO_BOOLand*_TO_CHARconversions.
- UTF-8 strings:
- 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)andLREAL_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]andARRAY[1..0]defined. - Still not in Ed.4 (Henneken): optional parameters, method overloading, implicit enumerations.
Who implements Ed.4, as searched 2026-11-05:
| Vendor | Ed.4 | Source |
|---|---|---|
| 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 found | beckhoff.com product page and news |
| Beckhoff TwinCAT 3 PLC | No Ed.4 claim; documentation lists all five languages, IL included | infosys.beckhoff.com |
| CODESYS | No Ed.4 claim found; “OOP according to 61131-3 3rd Edition”. CODESYS 4 page not read (HTTP 429) | codesys.com |
| Siemens TIA Portal | No statement found | - |
| PLCopen | “The current version is 4.0 from 2025”; no Ed.4 conformance of any tool | plcopen.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
Read RuSTyDONE 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.- 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 indocs/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. ThenAT %I*withVAR_CONFIG(E0022-E0024, Ed.3 6.5.5.4 and 6.8.2). Then theVAR_EXTERNALextension behind--extension implicit-externals(§2b), anddocs/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, andiecf-wasmwithiecf.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. - 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 byoffsetof, RETAIN in a region of its own - andruntime/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,controlandtasksmatch matiec’s traces;overflowshows 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) inruntime/iecf.h, TIME and LTIME, and the user’s FUNCTIONs; the counters count over their type’s range (decided,docs/dependencies.md).timersmatches matiec’s trace;edgesdiffers 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 - andpousmatches 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 - andrealmatches 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), sooverflowand the newnarrowmatch 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 - andtypesmatches 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 byoffsetof- 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 - andretainshows it across a warm restart (the harness’s scripts gainedTICK 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, soconnectionshas 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-iniec2c. matiec takes 52; iecf built 35 of them, and those that write%Qor%Mmatched 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**throughpow; the mixed block is E0022.functionsmatches 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) - andstringsmatches 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 - andconvertmatches 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) - andfbretainshows 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) - anddatesmatches 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) - anddurations; 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;enableandblockdata; 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) - andsfcdemo,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. - 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;refson both compilers, andrefimage. CODESYS’s REFERENCE TO as the extensionreference-to, DONE 2026-11-09 (decided, florin): REF=, __ISVALIDREF, a block’s REFERENCE TO input bound by a call; generated as theREF_TOit 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 + LTIMEtyped 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;fnouton both compilers and the compare list,fnoutxiecf’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;fninouton both compilers,fnorderiecf’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; infnoutx. 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 extensionglobal-namespace, as Siemens and PLCnext write it;namespacesover two files, andglobalns, 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), withk := r^of a string andcp := 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; inflexarrays. Part of an array given to anARRAY[*]: a row of an array of arrays,aa[2], and Example 2’sA2[2], an array indexed in its first dimensions only (decided, florin, docs/dependencies.md); inflexarrays. 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. AnARRAY[*]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 := aa 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 wholeARRAY[*]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 anARRAY[*], 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. - Ed.4 additions: USTRING/UCHAR, the §6.9 mutexes and semaphores,
properties,
ASSERT; BCD last, marked deprecated. - ST breakpoints and stepping from the line map.
- LD and FBD compiled with their own network mapping, if the ST route (graphical-languages) is not enough by then.
6. Decisions
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).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.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 inctx->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).RuSTy first?DECIDED 2026-11-05 (florin): read it, then build (§5 step 0). LGPL-3.0, so ideas, not code.- 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.
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), andtoolchain/build.pyfinds the binary the same way.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).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 listsBYTE_TO_BOOLand 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-63and 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
| Feature | RuSTy |
|---|---|
| ST, ACTIONS, JMP/labels, CONTINUE | yes |
| IL, SFC | no (no tokens at all) |
| LD / FBD | no; CFC only, from PLCopen XML, transpiled to ST |
| CONFIGURATION / RESOURCE / TASK, VAR_ACCESS | no; programs are *_instance globals |
| Namespaces / USING | no (src/parser.rs:325: “TODO: Parse USING directives”) |
Located AT %IX..., VAR_CONFIG, AT %I* | yes |
| RETAIN | partly: moved to a .retain section, but the initial value is written at every start |
| STRING[n], WSTRING | yes (UTF-8 and UTF-16), assignment truncates silently |
| USTRING / UCHAR, mutexes and semaphores (Ed.4) | no |
| LTIME, LDATE, LTOD, LDT | yes, 64-bit signed ns |
| TIME, DATE, TOD, DT | 32-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-TIME | milliseconds (as iecf §6.3) |
REF_TO, REF, ^, NULL, REFERENCE TO | yes, plus non-standard POINTER TO, ADR |
VLA ARRAY[*] | yes, fat struct {pointer, bounds} |
| CLASS, METHOD, INTERFACE, EXTENDS, IMPLEMENTS | yes, vtables and itables |
| THIS in a CLASS | rejected, contrary to Ed.3 |
| PUBLIC/PRIVATE/PROTECTED/INTERNAL, ABSTRACT, FINAL | parsed and ignored (book/user/language/inheritance.md:155-161) |
| Properties | yes, lowered to __get_x/__set_x methods |
| EN/ENO | in CFC only |
| R_EDGE / F_EDGE declarations | no |
| Implicit conversion per Ed.3 Fig. 11/12 | no: C-like promotion to DINT; every numeric conversion allowed, narrowing only warns |
| Division by zero, overflow, index, NULL | no 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 |
| Timers | read the host wall clock (libs/stdlib/src/timers.rs:48), state in an opaque byte buffer in the FB |
| Generic ANY_* functions | name 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
- 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. - Dialect spellings mapped to the standard token with a warning
(
ENDVARisEND_VAR, warning E013). iecf can accept what CODESYS users type and still say what the standard spells. - 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. - 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.
- 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
- One markdown file per error code (165 of them, each with the
mistake and the fix) feeding the registry, a
plc explain E037command and the published error book. That is the Ed.3 clause 5 e document (“all errors that are detectable and reported”), generated. - User-remappable severities per code (
--error-config), and a clang-style one-line format beside the rich one.
Middle
- 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. - 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.
- Built-in signatures declared in ST, parsed like user code; implementations native.
- A resolved type and an expected type per expression (RuSTy’s annotation and hint), with the conversion decided between them.
- The lowering catalogue, as a checklist: loops to WHILE with
EXIT; ELSIF to nested IF; properties to get/set methods;
inheritance as an embedded
__Basefirst member,SUPERto 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. - 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
SUPERdirect. Plain C structs of function pointers,call_indirectin wasm. - 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
- 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). - A C header of the instance layouts (
compiler/plc_header_generator), emitted with the C, as the ABI contract for Prokop’s glue. - Stop points and dumps:
--checkafter validation, the tree after parsing and after lowering, for snapshot tests.
Runtime and tests
- One
no_stdliteral 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. - 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.5and%QX2.5collide (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.
INTERNALmakes it visible only inside its own namespace;NAMESPACE INTERNAL Nmakes 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: afterUSING Standard;,Timers.TONis 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 source | USING | global namespace written | INTERNAL | |
|---|---|---|---|---|
| PLCnext Engineer | only around a TYPE block; a POU’s namespace is a setting, or its name prefixed NamespaceA.MyFB | a 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 default | none | _.: <Namespace>.<Block> “searches relative to the calling block”, _.<Namespace>.<Block> “searches globally” | not documented |
| CODESYS | none: a library’s namespace, set in the library manager (CAA.HANDLE) | none | not documented | a POU keyword: “only allowed for the members of the current library” |
| TwinCAT 3 | none: as CODESYS; {attribute 'qualified_only'} makes a GVL’s variables GVL.nVar and an enumeration’s values E_Sample.eMember | none | none | as CODESYS |
| RuSTy | none | none | none | none |
| matiec (the reference compiler) | none | none | none | none |
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
- Programs in a namespace. 6.9.2 lists programs, and
Prog_Type_Access(the type a CONFIGURATION names) takes namespaces in front;Namespace_ElementsleavesProg_Declout. - 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. - USING in a PROGRAM. 6.9.4 says USING may follow “the name of …
a POU”;
Prog_Declhas noUsing_Directive. - 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.
- A dotted name’s first part:
Counters.CUPwritten inside namespaceInfeed(6.9.4 Example 1) finds the globalCounters; whether anInfeed.Counterswould come first is not said. - Variables.
Symbolic_Variabletakes namespaces in front (N.x), but a namespace holds no variables:Namespace_Elementshas noGlobal_Var_Decls. - 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.
- A namespace and an element of the same name side by side
(
NAMESPACE MotorandFUNCTION_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, andItem::Usingat a file’s top;INTERNAL(orPUBLIC) before a POU’s name, read as a specifier only there, so both stay names elsewhere;USINGwhere §1 and §6 say. Dotted names in a type and a CONFIGURATION’s program type; a call’sa.b.cstays 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 pagebehaviour/namespaces.md, without checked rows, which evaluate expressions and cannot declare a namespace; harness casesnamespaces, over two files (the harness now takes a case’s other files fromcases/<name>.d/), andglobalnsfor 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 INTERNALon a function block’s variables andMETHOD 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:
- the POU’s own variables, then the namespace elements its USINGs bring in;
- the elements of the namespace the POU is in, then those its USINGs bring in;
- the same for each parent namespace, outwards;
- 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], thenVARandVAR_EXTERNALsections, then methods, thenEND_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.
OVERRIDEreplaces 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.FINALon a method: not overridden; on a type: not a base.ABSTRACTon 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 callsTHIS.PI(); in a CIRCLE2, which overrides PI, CF gives the more accurate value. SUPER.m()calls the base’s m, bound statically. Notinst.SUPER.m, notSUPER.SUPER.m.THISis 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
IMPLEMENTSone 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):agetsbif whatbreferences is of a typeacan 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
| CLASS | THIS, SUPER | method access, none written | OVERRIDE | NULL interface | downcast | |
|---|---|---|---|---|---|---|
| CODESYS V3.5 | none: methods, EXTENDS and IMPLEMENTS on function blocks | THIS^.x, SUPER^.m() | PUBLIC | none found | 0, “methods can be called only after” assignment | __QUERYINTERFACE, __QUERYPOINTER |
| TwinCAT 3 | none, as CODESYS | THIS^, SUPER^; SUPER^() “Call of FB-body of base class” | PUBLIC (“equivalent to specifying no access modifier”) | none found | 0; check “<> 0” first | __QUERYINTERFACE |
| Siemens SIMATIC AX | yes, CLASS ABSTRACT, IMPLEMENTS a list | THIS.x, as Ed.3 | not documented (pages not readable) | METHOD PUBLIC OVERRIDE, FINAL OVERRIDE | compared with NULL | not found |
| PLCnext Engineer | none: methods of function blocks only, no inheritance or interfaces | THIS.m() | none: “an access specifier must be specified”, Public or Private only | - | - | - |
| RuSTy | yes; no body, THIS rejected in one | THIS^, SUPER^ | specifiers parsed and ignored | optional | fat pointer, NULL unchecked (a crash) | none |
| Siemens S7-1500 SCL | no 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 hasSUPER^(); 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
- Is an access specifier required?
Method_Declrequires 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”. - The order of the specifiers. The grammar has access, then FINAL
or ABSTRACT, then OVERRIDE; the examples write
METHOD OVERRIDE mbandMETHOD PUBLIC OVERRIDE DAYTIME, and 6.6.5.10 lets a VAR section’s specifiers come “in any order”. - 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”;
Invocationtakes a bareMethod_Name(...). A barem()could also be a FUNCTION of that name (Table 48 row 14 only says “should not”). THIS.xorTHIS^.x. THIS is “a reference to the own class instance”, and a reference is dereferenced with^(6.4.4.10); yet every example writesTHIS.x,THIS.m(), neverTHIS^.SUPER.m()is not in the grammar.InvocationhasTHIS.and instance names,Subprog_Ctrl_StmtonlySUPER(); the text and its examples useSUPER.DAYTIME().- A method’s result in an expression is not in the grammar.
Primary_ExprhasFunc_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)). - 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).
- 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.
- 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.
- 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”.
- 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.
- 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.
- 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. - Copying an instance,
a := bof a class or an OO block, is not addressed; nor whether an instance may be an input (by value) or a function’s variable. - 6.6.8.4’s only example shows no dynamic binding: its
ROOM_CTRL sets
RM^.DAYTIMEand never calls the blockRMreferences, so neither body runs. - 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”.
- Mistakes in the examples: Table 49’s initialization list
separates with
;(Struct_Init uses,); Table 50 callsCOUNTER.UPon the type and passes aSTARTUP does not have; 6.6.6.7.3 Example 3 declarespinstBase2and usesrinstBase2, and uses anITF3never declared; 6.6.8.3 Example 2 and 6.6.8.4 Example 1 writeVAR_INPUT: DAYTIME; aSUPER.NIGHTTIME()without its;;END_VAR;andEND_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 kindMethodin its type’smethods(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 namedfinal; THIS and SUPER are reserved (E0018).THIS.x,THIS.m(...)andinst.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>_tandB_<NAME>_init, so a block may extend one. A method is a C functionM_<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()andTHIS.m()call it directly: in step A no type has a derived one.THIS.xis generated asx. 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 isself->base.V_X. The root of a hierarchy starts withconst iecf_otype_t *type, which each_initsets to its ownTY_<NAME>: the instance knows what it is, for the whole run. Aniecf_otype_t(runtime/iecf.h;iecf_type_tis 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’sself; an override is reached through a thunk,M_<TYPE>__<METHOD>__vt, which converts it back. BUILT 2026-11-10: harness caseinherit. - 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 whenselfis. 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_asfor an interface,iecf_itf_reffor a reference from an interface,iecf_ref_as(byiecf_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.