Ten structured text programming examples for PLC: start/stop, timers, counters, CASE state machines, FOR loops, scaling and alarms — explained line by line.

The fastest way to learn Structured Text is to read working programs, so here are ten structured text examples that cover what real ST code actually does on machines: latching, timing, counting, sequencing, scaling and alarming. Each is short, complete, and explained line by line; most have a ladder or FBD twin linked so you can see the same logic graphically. If ST syntax itself is new to you, skim Structured Text in PLC Programming first — this post assumes you know := from =.

All ten run as written in the browser simulator — paste, toggle inputs, watch.

Example 1 — Start/Stop Seal-In

Motor := (Start OR Motor) AND StopOK;

One line, three ideas: Start OR Motor is the seal (the motor holds itself on), AND StopOK is the drop-out, and reading Motor on the right while writing it on the left is a deliberate feedback through last scan's value. This is the rung every electrician knows, spelled as an expression — Ladder Logic for Beginners shows it the other way.

Example 2 — On-Delay Start (TON)

SealAir(IN := Motor, PT := T#5s);
Pump := SealAir.Q AND Motor;

SealAir : TON is called once per scan, unconditionally — bury the call in an IF and the timer freezes. The AND Motor on line 2 is cheap insurance: the pump can never outlive the motor even for the scan where Q is dropping.

Example 3 — Flasher (Two Timers in a Ring)

T1(IN := NOT T2.Q, PT := T#500ms);
T2(IN := T1.Q,     PT := T#500ms);
Beacon := T1.Q;

T1 times out, starts T2; T2 times out, resets T1. A 1 Hz flash with no edge logic at all — change the two PTs for asymmetric on/off.

Example 4 — One Count per Press (R_TRIG + CTU)

Edge(CLK := PartSensor);
Batch(CU := Edge.Q, RESET := BoxChanged, PV := 24);
BoxFull := Batch.Q;

Without Edge : R_TRIG, a part sitting on the sensor for half a second counts ~50 times at a 10 ms scan. The whole trap is dissected in PLC Counters Explained.

Example 5 — Motor Starts Totalizer

StartEdge(CLK := MotorRunning);
IF StartEdge.Q THEN
    LifetimeStarts := LifetimeStarts + 1;
END_IF;

Same edge idea, homemade counter. LifetimeStarts should be a DINT and declared retentive (VAR RETAIN) so it survives power cycles — totalizers that reset to zero on every outage are a classic commissioning complaint.

Example 6 — Analog Scaling (4–20 mA to Engineering Units)

TempC := (INT_TO_REAL(RawCounts) - 4000.0) * 150.0 / 16000.0;
SignalOK := (RawCounts >= 3600) AND (RawCounts <= 20400);

Line 1 maps 4000–20000 counts onto 0–150 °C. Line 2 is the part people skip: a healthy 4–20 mA loop never reads below ~3.6 mA, so counts under 3600 mean a broken wire — flag it instead of displaying a confidently wrong temperature.

Example 7 — Alarm with Hysteresis

IF TempC > 90.0 THEN
    HiAlarm := TRUE;
ELSIF TempC < 85.0 THEN
    HiAlarm := FALSE;
END_IF;

Note what's missing: no ELSE. Between 85 and 90 the alarm keeps its previous state — that memory gap is the hysteresis, and it's what stops the alarm chattering at the threshold. An ELSE here would destroy the behaviour; in ST, what you don't write is sometimes the design.

Example 8 — CASE State Machine (Fill–Mix–Drain)

CASE Step OF
    0: (* idle *)
        IF StartCmd AND TankEmpty THEN Step := 10; END_IF;
    10: (* fill *)
        FillValve := TRUE;
        IF LevelHigh THEN FillValve := FALSE; Step := 20; END_IF;
    20: (* mix *)
        Mixer := TRUE;
        MixT(IN := TRUE, PT := T#30s);
        IF MixT.Q THEN Mixer := FALSE; MixT(IN := FALSE); Step := 30; END_IF;
    30: (* drain *)
        DrainValve := TRUE;
        IF TankEmpty THEN DrainValve := FALSE; Step := 0; END_IF;
END_CASE;

The standard sequence pattern: one INT holds the step, each step does its outputs and names its exit condition. Gaps in the numbering (0, 10, 20…) leave room to insert steps later without renumbering — an old PLC habit worth keeping.

Example 9 — FOR Loop: Hottest of Eight Zones

MaxTemp := ZoneTemp[1];
MaxZone := 1;
FOR i := 2 TO 8 DO
    IF ZoneTemp[i] > MaxTemp THEN
        MaxTemp := ZoneTemp[i];
        MaxZone := i;
    END_IF;
END_FOR;

The loop runs to completion within one scan — loops iterate over data, never over time (a WHILE waiting on a physical input will watchdog-fault the CPU). This is also the construct ladder simply doesn't have, which is why array logic belongs in ST.

Example 10 — Debounced Input as a Reusable Pattern

FiltT(IN := RawSwitch, PT := T#50ms);
CleanSwitch := FiltT.Q;

A TON as a noise filter: the switch must hold TRUE for 50 ms before the logic believes it. Wrap patterns like this (debounce, scaling, alarm-with-hysteresis) into custom function blocks and you write them once per career instead of once per project.

Where Next

Ten examples cover most of the daily vocabulary; fluency comes from modifying them. Paste any of these into the simulator, flip to the ladder or FBD view to watch the same logic as rungs and blocks — the translation mechanics are explained in Structured Text to Ladder Logic Converter — and then break something on purpose: add the ELSE to Example 7 and watch the chatter, or drop the R_TRIG from Example 4 and watch the count run away. Wrong behaviour you caused and understood is the lesson that sticks.