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.
When to use it
Section titled “When to use it”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.
A program, top to bottom
Section titled “A program, top to bottom”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_VARVAR 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_PROGRAMThat 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.
Declarations
Section titled “Declarations”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_VARVAR 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_VARElementary 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.
Statements and expressions
Section titled “Statements and expressions”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.
Functions and function blocks
Section titled “Functions and function blocks”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_VARVAR_IN_OUT Stats : PumpStats;END_VARVAR edgeUp : R_TRIG; edgeDown : F_TRIG; runSec : REAL;END_VARedgeUp(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_BLOCKThat 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.
In the editor
Section titled “In the editor”
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.
b3fbc32 · VS Code 1.139.1 · PASS · rig nightly · X18 on the proof pagePoint 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.
b3fbc32 · VS Code 1.139.1 · PASS · rig nightly · C06 on the proof pageThe program commands work on .st directly:
Download Program to Controller, Diff Program with Controller, Rollback
Controller Program, and Pull Program from Controller.
Tooling
Section titled “Tooling”naut check # compile every .st, .fbd, .ld, .sfc under the pathnaut test # *_test.yaml acceptance tests, on a virtual clocknaut pull --host c1 # write a controller's running source back to the filenaut 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.
Not supported
Section titled “Not supported”- Pointers and references. No
REF_TO, no dereference operator. VAR_ACCESS,CONFIGURATION,RESOURCE, andTASKdeclarations. Tasks come fromnautilus.yamlor the Go composition.- Subrange types.
TYPEdeclares aSTRUCT, an enumeration, or an alias. - Date and time-of-day.
DATE,TOD,TIME_OF_DAY,DT, andDATE_AND_TIMEare rejected as unknown scalar types, and their literals do not evaluate. Durations are fine. - Exponentiation with
**. UseEXPT(base, exp)instead. - Length-qualified strings.
STRING[80]is a parse error;STRINGholds any length. - Aggregate initializers.
:= [1, 2, 3]in a declaration is a parse error; assign the elements in the body. - SCL’s
#local prefix.#stateis rejected with a message saying to drop the#.
See also
Section titled “See also”- Language reference — types, operators, and every built-in.
- Function blocks, libraries, and tasks
- The tag model
- Online edits
- Testing
- Ladder (LD), Function Block Diagram (FBD), Sequential Function Chart (SFC)