Skip to content

Structured Text (ST)

Structured Text is IEC 61131-3’s text language, and in nautilus it sits under the other three. A .st file holds a program, a library of types and blocks, or both. Ladder lowers to FBD, FBD lowers to ST, and an SFC file is an ST POU with a chart body, so all four languages reach the same compiler and the same typed IR. What ST accepts is what a controller runs.

ST fits arithmetic, loops, state machines, and string work: a PI calculation, a totalizer, a mode word driving a CASE. Ladder reads better for interlocks and permissives, FBD for signal flow, SFC for step sequences. ST is also the only language that declares reusable units today, so a project’s FUNCTION and FUNCTION_BLOCK definitions are ST even when the callers are diagrams.

PROGRAM Main
(* Heated surge tank: pump holds level by hysteresis, a PI loop holds
temperature. VAR_EXTERNAL tags are bound by the runtime each scan —
field inputs written before, outputs collected after. VAR is retained. *)
VAR_EXTERNAL
LevelPct : REAL; (* field inputs, from Plant.ReadInputs *)
TempC : REAL;
ScanDtS : REAL;
TempSP : REAL; (* operator setpoints *)
Kp : REAL;
Ki : REAL;
PumpStartLevel : REAL;
PumpStopLevel : REAL;
PumpRun : BOOL; (* field outputs, to Plant.WriteOutputs *)
Heater : REAL;
END_VAR
VAR
integral : REAL;
err : REAL;
END_VAR
(* P-101: level hysteresis latch (the seal-in) *)
IF LevelPct <= PumpStartLevel THEN
PumpRun := TRUE;
ELSIF LevelPct >= PumpStopLevel THEN
PumpRun := FALSE;
END_IF;
(* TIC-101: temperature PI with anti-windup clamp *)
err := TempSP - TempC;
integral := integral + Ki * err * ScanDtS;
integral := LIMIT(0.0, integral, 100.0);
Heater := LIMIT(0.0, Kp * err + integral, 100.0);
END_PROGRAM

That is examples/go-sdk/program.st, abridged — nautilus’s one Go-tier example; everywhere else in examples/ the plant is a manifest project, no Go, and this same hysteresis-plus-PI shape shows up again as examples/lift-station’s permissives.ld/level.fbd split across two graphical languages instead. PROGRAM Main … END_PROGRAM names the POU, and that name is the program’s identity for download, diff, and pull. VAR holds program-local state that persists between scans. Statements evaluate top to bottom, so the integral written on one line is what the next line reads in the same scan.

The VAR_EXTERNAL block here is the explicit form: in a manifest project every tag in nautilus.yaml is in scope in every program without it, typed by its type: (or its init:). A VAR_EXTERNAL that names a tag with a type: must agree with it. A tag typed only by its init: takes the declared type instead (VAR_EXTERNAL Count : DINT with init: 0 is a DINT), and the init: must be a value of that type: init: TRUE against VAR_EXTERNAL Speed : REAL is an error. (This Go-tier example’s tags are declared in Go with no type, so the program states them.) Either way, naming a tag binds it in the tag store rather than creating it, and reading a tag nothing has ever written faults the scan with undefined tag (an input from a communications driver is seeded, so it reads as zero until its device answers). A FUNCTION_BLOCK sees no tag it does not declare in its own VAR_EXTERNAL. See the tag model for the rules and which role fits which name.

The sections are VAR, VAR_INPUT, VAR_OUTPUT, VAR_IN_OUT, VAR_TEMP, VAR_GLOBAL, and VAR_EXTERNAL, each closed by END_VAR, with RETAIN or CONSTANT allowed after the section keyword. VAR_GLOBAL and VAR_EXTERNAL resolve identically: both name a tag in the store. A declared initial value (Gain : REAL := 2.0;) is where the variable starts, in a PROGRAM, in every FUNCTION_BLOCK instance, and on every FUNCTION call. A tag has no such start in the source: := on a VAR_GLOBAL or VAR_EXTERNAL is a compile error, and the tag’s init: in the manifest sets it instead.

VAR_TEMP is scratch for one execution: it starts every scan of a program, and every call of a function block or function, at its initial value (or zero), never at what the last one left. VAR CONSTANT is a named value, folded at compile time; assigning one is a compile error. VAR_GLOBAL CONSTANT in a library file declares project constants that every POU sees (a Codesys GVL of constants); a constant is not a tag. The language reference has the details.

A VAR_GLOBAL block may also stand on its own at the top of a library file — a Codesys-style gvl.st. It declares those globals for every program; one the manifest does not declare becomes a state tag starting at the zero of its type, and a program may still name it in its own VAR_EXTERNAL.

TYPE
Motor : STRUCT
Running : BOOL;
Fault : BOOL;
Speed : REAL;
END_STRUCT;
END_TYPE
VAR CONSTANT
CapacityL : REAL := 1000.0;
END_VAR
VAR
history : ARRAY[1..10] OF REAL;
grid : ARRAY[0..4, 0..4] OF REAL;
i, j : INT;
dwell : TIME := T#2m30s;
tag : STRING := 'TIC-101';
m : Motor;
END_VAR

Elementary types are BOOL; the integer kinds SINT, USINT, INT, UINT, DINT, UDINT, LINT, ULINT, BYTE, WORD, DWORD, LWORD; REAL and LREAL; TIME and LTIME; STRING, WSTRING, CHAR, and WCHAR. WSTRING, CHAR and WCHAR are accepted as declarations but are held as STRING. Integer kinds share one 64-bit representation, so a declared width documents intent without narrowing: arithmetic does not wrap (an INT at 32767 plus 1 is 32768), by design, so a counter or totaliser never turns silently wrong. A conversion such as TO_INT wraps to its target when you want the PLC behaviour. Mixing signed and unsigned widens to a signed type that holds both (INT + UINT is a DINT) rather than being refused. TIME counts milliseconds.

ARRAY[lo..hi] OF T takes several comma-separated dimensions and any element type, including a UDT or a function block. Bounds are integer constants: a literal, a named constant, or arithmetic on them (ARRAY[1..N_TANKS]). TYPE … END_TYPE declares a STRUCT, an alias for another type, or an enumeration: TYPE Mode : (Idle, Run := 10, Fault) := Idle; END_TYPE, used as Mode#Run or plain Run, shown by name in live values. An initial value after := is a constant expression. A struct-typed VAR_EXTERNAL tag is written one field at a time (P101.Running := TRUE) or whole, and each field write reaches the tag store as a member write.

Literals cover decimals and exponents (1.5e3), based integers (16#FF, 2#1010, 8#777), typed forms (INT#42, BOOL#TRUE, STRING#'hi'), and durations (T#5s, T#500ms, T#1h30m). Comments are (* … *), which nest, or // to end of line.

RETAIN parses and changes nothing at runtime today. Every program VAR and function block instance already keeps its value from scan to scan, and a warm swap carries that state across an online edit by name and type. Persistence across a restart is configured per tag on the retain store.

IF … ELSIF … ELSE … END_IF, CASE … OF … ELSE … END_CASE, FOR … TO … BY … DO … END_FOR, WHILE … DO … END_WHILE, REPEAT … UNTIL … END_REPEAT, plus EXIT, CONTINUE, and RETURN. A CASE label is a constant (a literal, a named constant, or an enumeration member), a comma list, or an inclusive lo..hi range; two labels with the same value are a compile error. Assignment is :=, equality is =, and inequality is <>. REGION name … END_REGION groups statements for folding and the Outline, as in TIA SCL; it opens no scope.

CASE Mode OF
0: Running := FALSE;
1, 2: Running := TRUE;
10..19: Running := Manual;
ELSE
Running := FALSE;
END_CASE;
dwell(IN := Running, PT := T#5s, ET => Elapsed);
Total := 0.0;
FOR i := 1 TO 10 BY 1 DO
IF history[i] < 0.0 THEN CONTINUE; END_IF;
Total := Total + history[i];
END_FOR;

Operator precedence runs, tightest first: unary NOT and -; then *, /, MOD; then + and -; then the comparisons <, <=, >, >=, =, <>; then AND; then XOR; then OR. An INT widens to REAL where one is expected; the other direction is a compile error, so narrowing goes through a conversion: REAL_TO_DINT (or TO_DINT) rounds to nearest with ties to even (2.5 becomes 2, 3.5 becomes 4, as on Codesys, Logix and TIA), and TRUNC_DINT truncates toward zero. Every IEC <A>_TO_<B> between BOOL, the integer kinds, REAL, LREAL, TIME, LTIME and STRING exists (there are no WSTRING_TO_…, CHAR_TO_… or WCHAR_TO_… functions); integer narrowing wraps to the target width (DINT_TO_INT(70000) is 4464, as on Codesys), and a REAL or a STRING with no value in the target faults the scan. The full matrix and its rules are in the language reference.

A function block instance is declared like a variable (dwell : TON;) and invoked as its own statement with named arguments. => copies an output into a target at the call site, and dwell.Q reads one anywhere. Struct fields and array elements use .field and [i] on either side of an assignment; an index outside the declared bounds faults the scan.

Every built-in function and function block, PID included, is in the language reference.

FUNCTION is stateless and returns one value, assigned to the function’s own name. FUNCTION_BLOCK is stateful, and each instance owns its slots. Pins may be any type, a user TYPE included, and a VAR_IN_OUT pin is a reference: the caller binds a variable with :=, the block’s writes land in that variable when the call returns, and a VAR_EXTERNAL UDT bound this way round-trips through the tag store as one whole-struct write. Every VAR_IN_OUT must be bound at every call, to an assignable of exactly the pin’s type.

TYPE
PumpStats : STRUCT
Starts : INT; (* start count *)
Hours : REAL; (* run hours *)
LastRunSec : REAL; (* length of the most recently completed run *)
END_STRUCT;
END_TYPE
FUNCTION_BLOCK RuntimeMeter
(* Counts a start on Running's rising edge, accumulates run hours while
Running, and captures the length of the run that just ended. Bound to
the UDT tag by VAR_IN_OUT so two calls (P101_Stats, P102_Stats) each
read and write their own copy. *)
VAR_INPUT
Running : BOOL;
Dt : REAL; (* seconds since the last call *)
END_VAR
VAR_IN_OUT
Stats : PumpStats;
END_VAR
VAR
edgeUp : R_TRIG;
edgeDown : F_TRIG;
runSec : REAL;
END_VAR
edgeUp(CLK := Running);
edgeDown(CLK := Running);
IF edgeUp.Q THEN
Stats.Starts := Stats.Starts + 1;
runSec := 0.0;
END_IF;
IF Running THEN
Stats.Hours := Stats.Hours + Dt / 3600.0;
runSec := runSec + Dt;
END_IF;
IF edgeDown.Q THEN
Stats.LastRunSec := runSec;
END_IF;
END_FUNCTION_BLOCK

That is examples/lift-station/lib/pump.st’s RuntimeMeter, complete — called once per pump from stats.st (RuntimeMeter(Running := P101_Running, Dt := StatsDtS, Stats := P101_Stats)), each call’s Stats a VAR_EXTERNAL UDT tag round-tripped whole through the VAR_IN_OUT pin.

A .st file with no PROGRAM keyword is a library: TYPE, FUNCTION, and FUNCTION_BLOCK declarations, in scope for every program in the same directory. Blocks may also be written in ladder or FBD, in .ld or .fbd libraries; ST libraries compose first, because that is where the TYPE declarations a graphical block’s pins may name live. The runtime, the language server, naut check, download, and pull all compose libraries the same way, so a program round-trips losslessly. See function blocks, libraries, and tasks.

sim.st open in VS Code: every identifier carries its live value from the running controller in a green pill beside it, so the flow and energy balance read as numbers, not just expressions

The VS Code extension highlights .st with no setup, from keyword lists generated out of the compiler. With the CLI installed it also runs naut lsp, the same compiler over stdio: diagnostics as you type for parse and type errors, go-to-definition from an identifier to its declaration, find all references (Shift+F12: a tag in every program that binds it and in the manifest, a local only in its POU), hover showing the declared type and var section, and completion over in-scope variables, keywords, types, and the builtin registries.

Verified 29 Oct 2026, 11:50 UTC on b3fbc32 · VS Code 1.139.1 · PASS · rig nightly · X18 on the proof page

Point nautilus.runtimeUrl at a running controller and the extension renders live tag values inline beside every identifier, with a Live Values panel and a Set Live Value command.

Verified 29 Oct 2026, 11:50 UTC on b3fbc32 · VS Code 1.139.1 · PASS · rig nightly · C06 on the proof page

The program commands work on .st directly: Download Program to Controller, Diff Program with Controller, Rollback Controller Program, and Pull Program from Controller.

Terminal window
naut check # compile every .st, .fbd, .ld, .sfc under the path
naut test # *_test.yaml acceptance tests, on a virtual clock
naut pull --host c1 # write a controller's running source back to the file

naut check prints gcc-style file:line:col: message diagnostics and exits non-zero when it finds any, which is what CI gates on. Tests are YAML fixtures asserting on tags over virtual time; see Testing. Downloading a program warm-swaps it into a running controller with state carried across, a failed compile leaves the running program untouched, and naut pull --check fails a build when a controller holds edits nobody pulled back. Online edits has the whole loop.

  • Pointers and references. No REF_TO, no dereference operator.
  • VAR_ACCESS, CONFIGURATION, RESOURCE, and TASK declarations. Tasks come from nautilus.yaml or the Go composition.
  • Subrange types. TYPE declares a STRUCT, an enumeration, or an alias.
  • Date and time-of-day. DATE, TOD, TIME_OF_DAY, DT, and DATE_AND_TIME are rejected as unknown scalar types, and their literals do not evaluate. Durations are fine.
  • Exponentiation with **. Use EXPT(base, exp) instead.
  • Length-qualified strings. STRING[80] is a parse error; STRING holds any length.
  • Aggregate initializers. := [1, 2, 3] in a declaration is a parse error; assign the elements in the body.
  • SCL’s # local prefix. #state is rejected with a message saying to drop the #.