Skip to content

Language reference: evaluation flow, functions, and function blocks

This is the reference for what runs inside a nautilus controller: how a scan evaluates your logic in each IEC 61131-3 language, and what every built-in operator, function, and function block does — its arguments, its result type, and its behavior. The compiler enforces everything described here; when logic doesn’t type-check (say, a numeric function used where a BOOL must flow), the result is a compile diagnostic on the offending line/rung, never a silent coercion.

Every task runs the classic PLC cycle on its interval:

read inputs → evaluate the program top to bottom → write outputs

All four languages end up as the same intermediate form, one hop at a time:

.st ─────────────────────────► IR (compiled, type-checked)
.fbd ── transpile ──► ST ──────► IR
.ld ── transpile ──► FBD ──► ST ─► IR
.sfc ── transpile ──► ST ──────► IR

Because each hop preserves line maps, a diagnostic anywhere in that chain lands back on your source — an .ld error points at the rung, an .fbd error at the statement. The text file is always the source of truth; the graphical editors are projections that edit it structurally.

Statements evaluate top to bottom within a scan, so a value written by one rung/statement is visible to the ones below it in the same scan, and to everything on the next scan (that’s what makes the seal-in idiom work).

A rung is a boolean expression that reads left to right:

ElementTextMeaning
NO contactTagpasses power when Tag is TRUE
NC contact/Tagpasses power when Tag is FALSE
Rising-edge contact+Tagone scan TRUE on Tag’s 0→1 transition (an implicit R_TRIG)
Falling-edge contact-Tagone scan TRUE on Tag’s 1→0 transition (an implicit F_TRIG)
Parallel branch[ a | b ]OR of its legs; legs are series (AND) and nest freely
Function contactFN(args)passes power when the call yields TRUE — the function must return BOOL
Negated function contact/FN(args)passes power when the call yields FALSE — / negates any BOOL-yielding contact term: a plain ref, an accessor chain (/t1.Q), or a call (/GT(a, b))
Function blockinst:TYPE(args)power drives the block’s power-in pin; power continues from its power-out pin (table below)
Coil( Tag )Tag := the rung condition, every scan
Set coil( S Tag )latch: Tag := Tag OR condition
Reset coil( R Tag )unlatch: Tag := Tag AND NOT condition
Rising-edge coil( P Tag )Tag := TRUE for one scan when the rung condition rises (an implicit R_TRIG)
Falling-edge coil( N Tag )Tag := TRUE for one scan when the rung condition falls (an implicit F_TRIG)

Series elements AND together; a rung with only a coil is driven by the rail (TRUE). Multiple coils on one rung share the same condition. A rung’s only output may be a function block instance — no trailing coil is required — its own output (inst.Q, inst.DN, …) is read elsewhere, the same way an inst.Q fed to a coil would be: A B t1:TON(PT := T#5S) is a complete, legal rung.

Edge instances (+Tag, -Tag, and the P/N coils) are unnamed in the text — the compiler derives a stable instance name from the rung name, the reference, and the occurrence’s position among repeats of the same (rung, ref, edge kind) within that rung (contacts key on the watched tag, P/N coils key on the coil’s own tag), so the name — and the R_TRIG’s retained state — survives an unrelated edit elsewhere in the program. Two edge contacts on the same tag in one rung are independent instances.

A rung’s trailing output zone (coils, or a rung-final function block) must be contiguous at the rung’s right end — coils ahead of a later function block in the same rung aren’t supported (coils must sit at the rung's right end). Split such a rung into one nautilus rung per output leg instead.

The BOOL rule. Power is boolean, so anything that gates it must yield BOOL. Comparisons do: GT(TempC, 90.0) is a fine contact. Numeric functions don’t: ADD(TempC, 0.0) as a contact is a compile error (operator AND on BOOL and REAL, or cannot assign REAL to BOOL when it’s alone on the rung) — ADD does not “pass through” its input. Numeric functions belong inside a comparison’s arguments — GE(ADD(Base, Bias), Limit) — or in an FBD/ST program, where values rather than power flow between elements.

Reading FB outputs: any instance output is addressable as inst.Pin everywhere — GE(t2.ET, T#2S) as a contact, t2.Q as an operand, or from another task’s FBD/ST program. To capture an output into a variable at the call site, use the standard’s output binding: t2:TON(PT := T#5S, ET => Elapsed) — the one way ladder stores a non-BOOL (coils only assign BOOL). => works in ST and FBD calls too.

BOOL, INT, DINT, UINT, UDINT, WORD, REAL, LREAL, TIME, STRING, plus ARRAY[lo..hi] OF T and user TYPE ... STRUCT.

  • Integer kinds share one 64-bit runtime representation; REAL/LREAL are float64.
  • TIME counts milliseconds internally. Literals: T#500MS, T#5S, T#2M30S. TIME values compare with the ordinary comparisons.
  • Mixed numeric arguments promote to REAL; comparing or combining a STRING with a number is an error, not a coercion.
  • A TYPE is a first-class type: a UDT can be a variable, a tag, a struct field, an array element, a FUNCTION argument or return, and a FUNCTION_BLOCK pin. Structs nest.
  • Assigning a struct or an array copies it. b := a; b.F := 1 leaves a.F alone, and so does a struct passed to a VAR_INPUT pin. The one pin that writes back to the caller is VAR_IN_OUT, below.
  • A field or element of a VAR_EXTERNAL tag assigns directly — P101.Running := TRUE, Levels[2] := 41.0. The tag store holds the whole aggregate, so the VM reads it, writes the field, and puts it back.

These are the FBD/ladder block names that lower to ST operators. “n-ary” means the block accepts 2+ inputs (the + pin in the FBD editor).

NameArgumentsResultBehavior
AND, OR, XORn-ary BOOL (or INT for bitwise)samelogical/bitwise
NOT1 BOOL/INTsamenegation/complement
ADDn-ary numericcommon typesum
SUB2 numericcommon typedifference
MULn-ary numericcommon typeproduct
DIV2 numericcommon typequotient; integer ÷0 yields 0 (scan keeps running)
MOD2 INTINTremainder; ÷0 yields 0
MOVE1 anysamepass-through assignment (FBD wiring aid)
GT, GE, LT, LE2 comparableBOOLordering (numeric or TIME)
EQ, NE2 comparableBOOLequality

The comparison row is the ladder-relevant one: those six are the functions that can gate power directly.

Stateless, callable from ST, FBD blocks, and (where they return BOOL — or inside arguments) ladder function contacts.

NameSignatureResultBehavior
SELSEL(G: BOOL, IN0, IN1)type of IN0/IN1binary selector: G=FALSE → IN0, G=TRUE → IN1
MUXMUX(K: INT, IN0, …, INn)common typeK picks the K-th input (0-based); an out-of-range K faults the scan — clamp K with LIMIT if it can wander
MIN, MAXn-ary numericcommon typesmallest / largest
LIMITLIMIT(MN, IN, MX)common typeIN clamped into [MN, MX]
NameSignatureResultBehavior
ABS1 numericsame typeabsolute value
SQRT, LN, LOG, EXP1 numericREALroot, ln, log₁₀, eˣ
EXPTEXPT(base, exp)REALbaseᵉˣᵖ
TRUNC1 REALINTtoward-zero truncation
SIN, COS, TAN1 numeric (radians)REALtrigonometry
ASIN, ACOS, ATAN1 numericREALinverse trig
ATAN2ATAN2(Y, X)REALquadrant-correct arctangent
NameSignatureResultBehavior
SHL, SHR(IN: INT/WORD, N: INT)same as INshift left / logical shift right (zero-fill)
ROL, ROR(IN, N)same as INrotate left / right

Caveat: the runtime’s integers are 64-bit and declared widths aren’t tracked, so rotates operate over 64 bits — a WORD you think of as 16 bits rotates as a 64-bit value.

All positions are 1-based (IEC convention); length/position arguments clamp to the string instead of faulting.

NameSignatureResultBehavior
LENLEN(IN)INTlength
LEFT, RIGHT(IN, L)STRINGfirst / last L characters
MIDMID(IN, L, P)STRINGL characters starting at position P
CONCATn-ary STRINGSTRINGconcatenation
INSERTINSERT(IN1, IN2, P)STRINGIN2 inserted into IN1 after position P
DELETEDELETE(IN, L, P)STRINGL characters removed starting at P
REPLACEREPLACE(IN1, IN2, L, P)STRINGL characters at P replaced by IN2
FINDFIND(IN1, IN2)INT1-based position of IN2 in IN1; 0 when absent or IN2 empty

Explicit, in the standard’s X_TO_Y naming — there are no implicit conversions across kinds:

ConversionNotes
INT_TO_REAL, REAL_TO_INTREAL→INT rounds to nearest
BOOL_TO_INT, INT_TO_BOOL0 ↔ FALSE, nonzero → TRUE
INT_TO_TIME, TIME_TO_INTthe INT is milliseconds
REAL_TO_TIME, TIME_TO_REALmilliseconds, rounded to nearest
INT_TO_STRING, REAL_TO_STRING, BOOL_TO_STRING, TIME_TO_STRINGformatting
STRING_TO_INT, STRING_TO_REAL, STRING_TO_BOOLparse; a non-parsing string is a runtime scan fault, so validate upstream

Stateful — declare an instance (VAR t1 : TON; END_VAR, or inline in a rung as t1:TON(...)), and each instance keeps its own state between scans. Outputs read as inst.Pin from any language.

TypeInputsOutputsBehavior
TONIN: BOOL, PT: TIMEQ: BOOL, ET: TIMEon-delay: Q rises after IN has been TRUE for PT; ET is elapsed
TOFIN, PTQ, EToff-delay: Q stays TRUE for PT after IN drops
TPIN, PTQ, ETpulse: rising IN produces a PT-wide TRUE pulse
CTUCU: BOOL, R: BOOL, PV: INTQ: BOOL, CV: INTcount rising CU edges; Q when CV ≥ PV; R resets
CTDCD: BOOL, LD: BOOL, PV: INTQ, CVcount down from PV (LD loads); Q when CV ≤ 0
CTUDCU, CD, R, LD, PVQU, QD, CVup/down counter
R_TRIGCLK: BOOLQ: BOOLQ for exactly one scan on CLK’s rising edge
F_TRIGCLKQone-scan pulse on the falling edge
SRS1: BOOL, R: BOOLQ1: BOOLset-dominant latch
RSS: BOOL, R1: BOOLQ1: BOOLreset-dominant latch
PIDsee belowsee belowclosed-loop control — proportional/integral/derivative with anti-windup and bumpless auto/manual

When a block sits in a rung, rung power drives one input and continues from one output; every other pin is passed (or read) by name in the parentheses:

TypePower inPower out
TON, TOF, TPINQ
CTUCUQ
CTDCDQ
R_TRIG, F_TRIGCLKQ
SRS1Q1
RSSQ1
user FUNCTION_BLOCKEN, else the first BOOL VAR_INPUT the call doesn’t bind by nameENO, else the first BOOL VAR_OUTPUT

Passing the power pin explicitly in the argument list (t1:TON(IN := x)) is an error — power owns it.

A user block’s pins are resolved from the whole compile — this file’s own FUNCTION_BLOCKs plus every project library — so the rung lands power where the block actually declares it. Two shapes have no pin to use, and both are meaningful:

  • No power-in — every BOOL input is bound by name, or the block has none. The block is still called, unconditionally, so it may only sit on a rung whose condition is the rail itself. A rung with contacts ahead of it is a compile error (no free BOOL input for the rung's power): say which pin the gate lands on, or give the block an EN.
  • No power-out — the block has no BOOL output. Power passes through unchanged, so whatever conditioned the block still conditions the coils to its right.

A block whose type nothing in the compile declares falls back to IN/Q.

PID is a positional (non-velocity) three-term controller in the IEC/OSCAT spirit — no ladder power pin (like CTUD, it has no single input that means “run”), so instantiate it from ST or an FBD diagram, the way the heated-tank-nogo example wires its own hand-rolled PI today.

PinKindTypeMeaning
AUTOinputBOOLTRUE = closed loop; FALSE = manual — CV tracks CV_MAN and the integral free-wheels for a bumpless return to AUTO
PVinputREALprocess value
SPinputREALsetpoint
KPinputREALproportional gain. KP = 0 disables the whole controller, not just the P term — see below
KIinputREALintegral gain, repeats/second (1/TI); 0 disables integral action
KDinputREALderivative gain, seconds (TD); 0 disables derivative action
CV_MANinputREALmanual output, used when AUTO = FALSE
CV_MINinputREALoutput clamp floor. Default 0 (see below)
CV_MAXinputREALoutput clamp ceiling. Default 100 (see below)
DIRECTinputBOOLFALSE = reverse acting, error = SP − PV (e.g. a heater — raise CV when PV is low); TRUE = direct acting, error = PV − SP (e.g. a cooling valve)
DTinputREALseconds since the last call. Left at 0 (unbound), the block measures elapsed time itself from the scan clock — bind a task’s dt-tag when one is available, same as any hand-written loop
DBinputREALdeadband on error — within ±DB the P and I terms see zero error (chatter suppression); the D term still sees every PV move
RESETinputBOOLTRUE zeroes the integral this scan
CVoutputREALcontroller output, clamped to [CV_MIN, CV_MAX]
ERRoutputREALerror, before the deadband
SAT_HI, SAT_LOoutputBOOLTRUE when CV is clamped at its ceiling/floor
P_TERM, I_TERM, D_TERMoutputREALthe three contributions to CV, for trending/diagnostics

Algorithm. ISA standard form: CV = KP·(error + KI·∫error·dt + KD·d(PV)/dt). The derivative acts on PV, not on error (so a setpoint step never “kicks” D_TERM — only a PV change does), through a fixed first-order filter (time constant KD/10) that keeps sensor noise from being amplified into a noisy CV. Anti-windup is conditional integration: the integral only accumulates when doing so wouldn’t push the unclamped output further past a rail it has already reached — cheap, and unlike back-calculation it needs no extra tracking-gain to tune. AUTO/MANUAL is bumpless both ways: in MANUAL, the integral is continuously back-solved every scan so P_TERM + I_TERM + D_TERM already equals CV_MAN, so the instant AUTO goes TRUE, CV continues from exactly where CV_MAN left off instead of jumping. Leaving CV_MIN/CV_MAX both unbound (they default to 0) falls back to the IEC 0..100 range, since an explicit 0..0 clamp would otherwise pin CV at zero.

(* LIC-101: tank level, reverse acting — open the inlet valve more as
level falls below setpoint, close it as level rises to setpoint. *)
VAR lic : PID; END_VAR
lic(AUTO := TRUE, PV := LevelPct, SP := LevelSP,
KP := 1.5, KI := 0.05, KD := 0.0,
CV_MAN := 0.0, CV_MIN := 0.0, CV_MAX := 100.0,
DIRECT := FALSE, DB := 0.5, DT := ScanDtS);
InletValve := lic.CV;
IF lic.SAT_HI THEN InletMaxedAlm := TRUE; END_IF;

User FUNCTIONs and FUNCTION_BLOCKs written in library files participate everywhere the built-ins do; see “Structuring logic” in the main README. Two things about their pins are worth stating outright, because they decide how a block’s signature is shaped.

A pin may be any type, including a user TYPE

Section titled “A pin may be any type, including a user TYPE”

VAR_INPUT, VAR_OUTPUT, VAR_IN_OUT and VAR declarations inside a FUNCTION_BLOCK (and a FUNCTION’s inputs, locals, and return type) resolve against the whole compile: this file’s TYPE block plus every project library joined ahead of it. So a block can take the UDT its site model already defines, nested structs and all:

FUNCTION_BLOCK FB_Scale
VAR_INPUT IN : AnalogInput; END_VAR (* a user TYPE, nested structs fine *)
VAR_OUTPUT OUT : AnalogInput; END_VAR
OUT := IN;
OUT.VALUE := IN.SCALE.LO + (IN.SCALE.HI - IN.SCALE.LO) * INT_TO_REAL(IN.RAW) / 32767.0;
END_FUNCTION_BLOCK

At the call site the struct output reads like any other pin, one field at a time (s.OUT.VALUE) or whole (Scaled := s.OUT). A pin naming a type nothing declares is still a compile error that names the type.

A VAR_IN_OUT pin is bound at the call site to a variable, and what the block writes into it is visible to the caller when the call returns. That is what collapses a block whose UDT already names its own inputs and outputs from thirty scalar pins to one:

FUNCTION_BLOCK FB_Starter
VAR_IN_OUT M : Motor; END_VAR
VAR edge : R_TRIG; END_VAR
edge(CLK := M.Cmd);
IF edge.Q THEN M.Starts := M.Starts + 1; END_IF;
M.Running := M.Cmd;
END_FUNCTION_BLOCK
VAR_EXTERNAL P101 : Motor; END_VAR
VAR s : FB_Starter; END_VAR
s(M := P101); (* P101 carries the block's writes afterwards *)

The rules, all of them enforced at compile time:

RuleWhy
Bound with :=, like an input — s(M := P101)it is an input; the => form binds outputs, and an IN_OUT already writes back
The argument must be assignable: a variable, a struct field, an array element, or a VAR_EXTERNAL tagthe block writes back to it; an expression has nowhere to write
The argument’s type must match the pin exactlya reference cannot convert, so an INT variable does not stand in for a REAL pin
Every VAR_IN_OUT must be bound at every call sitethere is no default for a reference
The pin’s own type may not be a function blockan instance is retained state, not a value; nothing in the language copies one

Mechanically the pin is copied in before the block’s body runs and copied back to the same variable after it — the observable behaviour of “by reference” for scan code, and what makes a VAR_IN_OUT bound to a VAR_EXTERNAL UDT round-trip through the tag store as one whole-struct write. Two calls in one scan bound to different variables each see and update their own.

FUNCTIONs have no VAR_IN_OUT (or VAR_OUTPUT): an IEC function is a single return value, and the compiler says so.

VAR_IN_OUT pins are ST/FBD-callable; the FBD and ladder editors expose a block’s VAR_INPUT/VAR_OUTPUT pins only, so a block meant to be wired graphically should keep its interface on those.

A .ld file may define FUNCTION_BLOCKs as well as (or instead of) a PROGRAM. Each one is an ordinary IEC POU whose body happens to be rungs:

FUNCTION_BLOCK PumpSeq
VAR_INPUT Start : BOOL; Stop : BOOL; Level : REAL; StopLevel : REAL; END_VAR
VAR_OUTPUT Run : BOOL; Warm : BOOL; END_VAR
VAR t1 : TON; END_VAR
LD
RUNG seal [ Start | Run ] /Stop /GE(Level, StopLevel) ( Run )
RUNG warm Run t1:TON(PT := T#5S) ( Warm )
END_LD
END_FUNCTION_BLOCK

This is what ladder has instead of a JSR: a subroutine with a real interface — pins, not shared tags — and its own retained state per instance. Two pumps are two instances of one block, each with its own seal-in and its own t1.

The VAR_* sections are ordinary POU declarations, VAR_IN_OUT included, so a ladder block can take a UDT by reference the same way an ST one does (see VAR_IN_OUT is a reference pin). The LD body lowers through the usual single hop — LD → FBD netlist → ST — so by the time the compiler sees it, it is an ordinary FUNCTION_BLOCK … END_FUNCTION_BLOCK. There is no special case anywhere downstream.

From ladder, with the rung’s power on the block’s power-in pin and => capturing outputs (the same inst:TYPE(args) syntax the standard blocks use — see Power pins in ladder for where power lands on a user block):

RUNG lead
P101Start p101:PumpSeq(Stop := P101Stop, Level := LevelPct,
StopLevel := StopLevel,
Run => P101Run, Warm => P101Warm)

From ST or FBD, like any other block:

VAR p : PumpSeq; END_VAR
p(Start := Cmd, Stop := Halt, Level := LevelPct, StopLevel := 80.0);
PumpRun := p.Run;

An FB instance a rung declares inline (t1:TON(...)) needs no separate VAR entry — but writing one, as the block above does, is fine and often clearer: a declaration in the POU’s own header wins, and the rung is then a call on it rather than a second declaration.

A .ld file with no PROGRAM is a project library, exactly like a PROGRAM-less .st file. Its blocks join the prelude ahead of every task, so any program in the project — in any language — can instantiate them. .fbd libraries work the same way.

Composition order. Every .st library first, in file-name order, then every transpiled .ld / .fbd library, in file-name order. ST leads because that is where a project’s TYPE declarations live and a graphical block’s pin may name a UDT. Order never decides whether a call resolves: the ST front-end registers every FUNCTION_BLOCK signature in the composed source before it lowers any body, so blocks may reference each other in either direction, across files. What order decides is only which declaration a duplicate-name collision reports.

Two names, one error: declaring the same FUNCTION_BLOCK twice in one .ld file is refused with the second declaration’s line, before anything is transpiled.

examples/ladder-subroutines is the whole feature in four small files.

The ladder view renders a file’s blocks as rung groups, each under its own FUNCTION_BLOCK heading with its pins, and every rung in them edits like any other. Two limits worth knowing: an edit op addresses a rung by name, so two rungs with the same name in different POUs of one file resolve to the first — name them distinctly; and addRung with no after appends before the file’s first END_LD. The language server analyses a multi-POU .ld fully, diagnostics landing on the offending rung.