Intermediate
PLC Array Programming in Structured Text: FOR Loops, Sorting & Recipes
Master array handling in Structured Text — from basic FOR loops to recipe management and circular buffers used in real industrial applications.
Arrays in Structured Text
Arrays are essential for handling collections of data in PLC programs — sensor readings, recipe values, alarm logs, and production data. Structured Text provides powerful array handling that's far easier than equivalent Ladder Logic.
Declaring Arrays
VAR
// Simple array
Temperatures : ARRAY[1..10] OF REAL;
// Multi-dimensional array
RecipeTable : ARRAY[1..5, 1..8] OF REAL;
// Array of strings
AlarmMessages : ARRAY[0..19] OF STRING[80];
// Initialized array
Setpoints : ARRAY[1..4] OF REAL := [100.0, 200.0, 150.0, 175.0];
END_VAR
FOR Loop Basics
The FOR loop is the primary tool for processing arrays:
PROGRAM ArrayProcessing
VAR
SensorData : ARRAY[1..20] OF REAL;
Average : REAL := 0.0;
MaxVal : REAL := -1.0E38;
MinVal : REAL := 1.0E38;
Sum : REAL := 0.0;
i : INT;
END_VAR// Calculate average, min, max
Sum := 0.0;
MaxVal := SensorData[1];
MinVal := SensorData[1];
FOR i := 1 TO 20 DO
Sum := Sum + SensorData[i];
IF SensorData[i] > MaxVal THEN
MaxVal := SensorData[i];
END_IF;
IF SensorData[i] < MinVal THEN
MinVal := SensorData[i];
END_IF;
END_FOR;
Average := Sum / 20.0;
Recipe Management System
One of the most common industrial uses of arrays is recipe management:
PROGRAM RecipeManager
VAR
// Recipe storage: 10 recipes x 6 parameters
Recipes : ARRAY[1..10, 1..6] OF REAL;
ActiveRecipe : INT := 1;
// Active setpoints
Temperature_SP : REAL;
Pressure_SP : REAL;
Speed_SP : REAL;
Time_SP : REAL;
Flow_SP : REAL;
Ratio_SP : REAL;
LoadCmd : BOOL := FALSE;
END_VARIF LoadCmd THEN
Temperature_SP := Recipes[ActiveRecipe, 1];
Pressure_SP := Recipes[ActiveRecipe, 2];
Speed_SP := Recipes[ActiveRecipe, 3];
Time_SP := Recipes[ActiveRecipe, 4];
Flow_SP := Recipes[ActiveRecipe, 5];
Ratio_SP := Recipes[ActiveRecipe, 6];
LoadCmd := FALSE;
END_IF;
Circular Buffer for Data Logging
Circular buffers are essential for rolling data logs without memory overflow:
PROGRAM CircularBuffer
VAR
Buffer : ARRAY[0..99] OF REAL;
WriteIndex : INT := 0;
BufferFull : BOOL := FALSE;
NewReading : REAL := 0.0;
SampleTrigger : BOOL := FALSE;
i : INT;
MovingAverage : REAL := 0.0;
Sum : REAL := 0.0;
END_VARIF SampleTrigger THEN
Buffer[WriteIndex] := NewReading;
// Modulo wrap-around: WriteIndex stays bounded to [0..99] on every
// increment regardless of how many samples have arrived. This is the
// textbook circular-buffer idiom and is safer than the "increment, then
// IF >99 reset" pattern because the bound is enforced by arithmetic,
// not by a separate check that a future edit might forget. IEC 61131-3
// treats array bounds violations as a runtime fault — Siemens TIA traps
// OB121, Beckhoff TwinCAT raises a runtime exception, Rockwell sets a
// minor fault — so getting the wrap right is not optional.
WriteIndex := (WriteIndex + 1) MOD 100;
IF WriteIndex = 0 THEN
BufferFull := TRUE;
END_IF;
// Calculate moving average of last 10 samples
Sum := 0.0;
FOR i := 0 TO 9 DO
Sum := Sum + Buffer[(WriteIndex - 1 - i + 100) MOD 100];
END_FOR;
MovingAverage := Sum / 10.0;
END_IF;
Bubble Sort Example
Sorting is useful for finding medians or ranking data:
PROGRAM BubbleSort
VAR
Data : ARRAY[1..10] OF REAL := [5.2, 3.1, 8.7, 1.4, 9.3, 2.8, 7.6, 4.5, 6.9, 0.2];
Temp : REAL;
i, j : INT;
Swapped : BOOL;
Median : REAL;
END_VAR// Sort ascending.
//
// Bounds note: the inner loop's upper limit (10 - i) is what keeps the
// Data[j + 1] access in range. With i >= 1, j peaks at 10 - 1 = 9, so
// j + 1 peaks at 10 — exactly the last valid index of ARRAY[1..10]. If
// you generalize this snippet to ARRAY[1..N], change the bound to
// (N - i) — NOT (N + 1 - i) — to preserve the invariant. The tryplc
// engine — like Siemens (OB121), Beckhoff (runtime exception), and
// Rockwell (minor fault) — raises a runtime fault on out-of-bounds
// array access, so this invariant is not optional.
FOR i := 1 TO 9 DO
Swapped := FALSE;
FOR j := 1 TO 10 - i DO
IF Data[j] > Data[j + 1] THEN
Temp := Data[j];
Data[j] := Data[j + 1];
Data[j + 1] := Temp;
Swapped := TRUE;
END_IF;
END_FOR;
IF NOT Swapped THEN EXIT; END_IF;
END_FOR;
// Median of 10 values = average of 5th and 6th
Median := (Data[5] + Data[6]) / 2.0;
Shift Register Pattern
Useful for tracking items on a conveyor or production history:
PROGRAM ShiftRegister
VAR
Register : ARRAY[1..20] OF BOOL;
NewItem : BOOL := FALSE;
ShiftTrigger : BOOL := FALSE;
i : INT;
ItemCount : INT := 0;
END_VARIF ShiftTrigger THEN
// Shift all elements right
FOR i := 20 TO 2 BY -1 DO
Register[i] := Register[i - 1];
END_FOR;
// Insert new item at position 1
Register[1] := NewItem;
END_IF;
// Count items in register
ItemCount := 0;
FOR i := 1 TO 20 DO
IF Register[i] THEN
ItemCount := ItemCount + 1;
END_IF;
END_FOR;
Best Practices
Practice these patterns in our online ST editor and explore more in our structured text reference.
Recipe Tables: the Column-Index Trap
A recipe held as ARRAY[1..10, 1..6] OF REAL works until someone adds a seventh parameter. Nothing in the code says column 3 is line speed, so inserting a parameter mid-table silently shifts every stored recipe one column. ARRAY OF STRUCT makes the failure structural instead of silent.
Two disciplines matter more than the type. Latch a working copy — never point control logic at Recipes[Sel], or an operator editing recipe 4 while batch 4 runs moves setpoints under a live process. And validate before the subscript is evaluated: ST does not guarantee short-circuit AND, so IF (i >= 1) AND (i <= N) AND (Recipes[i].Version > 0) can still touch Recipes[i] with a bad i. Some vendors add an explicit short-circuit operator for this — TwinCAT has AND_THEN and OR_ELSE — but they are not portable, so gate the index in its own branch.
TYPE ST_Recipe :
STRUCT
Name : STRING[20];
Version : UINT; // 0 = empty slot, not "recipe of zeros"
Temp_SP : REAL; // degC
Dwell : TIME;
LineSpeed : REAL; // m/min
END_STRUCT
END_TYPEVAR CONSTANT
RCP_LO : INT := 1;
RCP_HI : INT := 32;
TEMP_MAX : REAL := 260.0;
END_VAR
VAR
Recipes : ARRAY[RCP_LO..RCP_HI] OF ST_Recipe; // retentive, HMI-writable
Working : ST_Recipe; // what the machine runs
HMI_Sel : INT; // untrusted
LoadReq : BOOL;
LoadEdge : R_TRIG;
LoadOK : BOOL;
LoadErr : UINT;
Running : BOOL;
END_VAR
LoadEdge(CLK := LoadReq);
IF LoadEdge.Q THEN
LoadOK := FALSE;
LoadErr := 0;
IF Running THEN
LoadErr := 1; // no swaps mid-batch
ELSIF (HMI_Sel < RCP_LO) OR (HMI_Sel > RCP_HI) THEN
LoadErr := 2; // gate the index first
ELSIF Recipes[HMI_Sel].Version = 0 THEN
LoadErr := 3;
ELSIF Recipes[HMI_Sel].Temp_SP > TEMP_MAX THEN
LoadErr := 4; // clamp at load, not at edit
ELSE
Working := Recipes[HMI_Sel]; // copy the whole struct once
LoadOK := TRUE;
END_IF;
END_IF;
Conveyor Tracking: Shift on Distance, Not on Events
The shift-register idea is right; the trigger usually is not. IF ShiftTrigger THEN shifts on every scan the trigger is true, so a photoeye held 30 ms in a 10 ms task advances the map three slots. R_TRIG is the minimum fix. The real fix is to shift on belt travel rather than events — one slot per fixed distance of encoder movement — so the map survives creep, jog, and mid-index stops.
Sizing bites at commissioning: slot length must exceed maximum belt speed times task period, or the belt outruns the register between scans. At 1.2 m/s in a 10 ms task the belt moves 12 mm per scan, so 10 mm slots lose position on a good day and bottles on a bad one. Freeze the map on reverse jog rather than shifting back — hand-pushing the belt does not un-eject a reject.
Retention is the last trap. The register says forty parts are in the zone; night shift cleared the line by hand. Gate auto-restart behind a zone-empty confirmation that zeroes the array.
TYPE ST_Part :
STRUCT
Present : BOOL;
Reject : BOOL;
END_STRUCT
END_TYPEVAR CONSTANT
SLOTS : INT := 240; // 2400 mm tracked zone
MM_PER_SLOT : LREAL := 10.0;
REJECT_SLOT : INT := 187; // measured on the line, not scaled off the drawing
END_VAR
VAR
Slot : ARRAY[1..240] OF ST_Part;
EncMM : LREAL; // absolute belt travel from the encoder.
// LREAL, not REAL: a 24-bit significand stops
// resolving millimetre steps long before the
// shift ever wraps.
LastEncMM : LREAL;
Residual : LREAL;
Delta : LREAL;
InfeedEye : BOOL;
InspectFail : BOOL;
FireRej : BOOL;
i : INT;
END_VAR
Delta := EncMM - LastEncMM;
LastEncMM := EncMM;
IF Delta > 0.0 THEN // reverse jog freezes, never shifts back
Residual := Residual + Delta;
END_IF;
IF Residual >= MM_PER_SLOT THEN
Residual := Residual - MM_PER_SLOT;
FireRej := Slot[REJECT_SLOT].Present AND Slot[REJECT_SLOT].Reject;
FOR i := SLOTS TO 2 BY -1 DO
Slot[i] := Slot[i - 1];
END_FOR;
Slot[1].Present := InfeedEye; // one write per slot, at the head only
Slot[1].Reject := InfeedEye AND InspectFail;
END_IF;
Moving Averages That Do Not Lie to You
A running sum is O(1): subtract the value leaving the window, add the one entering. The arithmetic is the easy half.
A boxcar average of N samples delays the signal by roughly (N−1)/2 sample periods, so sixteen samples on a 100 ms task is about 0.75 s of lag: tolerable on a trend screen, not inside a fast loop or a fill cut-off. Prime the buffer too: zeros make the output crawl up for N samples after every power-up, enough to trip a low-flow alarm on every start.
Gate on quality, because a broken-wire 0.0 or one burst spike stays in the average for N samples after the fault clears. And keep the accumulator in LREAL: a running sum in 32-bit REAL carries only a 24-bit mantissa and drifts over millions of updates, so resynchronise it with a full recompute periodically.
VAR CONSTANT
N : INT := 16;
END_VAR
VAR
Ring : ARRAY[0..15] OF REAL; // keep the bound and N in step
Idx : INT := 0;
RunSum : LREAL := 0.0;
Primed : BOOL := FALSE;
Resync : UDINT := 0;
PV : REAL;
PV_Good : BOOL; // from the analog module's quality / over-range bits
PV_Filt : REAL;
i : INT;
END_VARIF PV_Good THEN
IF NOT Primed THEN
FOR i := 0 TO N - 1 DO
Ring[i] := PV;
END_FOR;
RunSum := INT_TO_LREAL(N) * REAL_TO_LREAL(PV);
Primed := TRUE;
ELSE
RunSum := RunSum - REAL_TO_LREAL(Ring[Idx]) + REAL_TO_LREAL(PV);
Ring[Idx] := PV;
Idx := (Idx + 1) MOD N;
Resync := Resync + 1;
IF Resync >= 100000 THEN // kill accumulated float drift
RunSum := 0.0;
FOR i := 0 TO N - 1 DO
RunSum := RunSum + REAL_TO_LREAL(Ring[i]);
END_FOR;
Resync := 0;
END_IF;
END_IF;
PV_Filt := LREAL_TO_REAL(RunSum / INT_TO_LREAL(N));
END_IF; // bad quality: hold the last output
Device Tables: ARRAY OF STRUCT Plus ARRAY OF FB
The highest-leverage array in machine code is not data, it is instances. ARRAY[1..48] OF FB_Valve gives forty-eight instances, each with its own timers and edge states, driven from a parallel config array of tag, stroke time, and interlock mask. One block tested once; diagnostics and the HMI list become loops.
Two traps. Sort an array of indices, not the structs — every swap of a struct holding a STRING copies the whole block. And compilers pad the end of a struct so the next array element stays aligned, so element stride is not the sum of member sizes; anything overlaying it on a Modbus block or byte buffer must account for it. CODESYS and TwinCAT expose a pack_mode attribute for this.
Scan time is the other constraint, and the fix is a cursor: cheap per-device logic every scan, expensive work — string building, event logging — a few elements per scan, walking the table over many. Freshness traded for a flat task time.
VAR CONSTANT
VLV_N : INT := 48;
END_VAR
VAR
VlvCfg : ARRAY[1..48] OF ST_ValveCfg;
Vlv : ARRAY[1..48] OF FB_Valve; // 48 instances, 48 sets of internal timers
Cmd : ARRAY[1..48] OF BOOL;
DI_Open : ARRAY[1..48] OF BOOL;
Faulted : ARRAY[1..48] OF BOOL;
Cursor : INT := 1;
i, n : INT;
END_VARFOR i := 1 TO VLV_N DO // cheap: must survive every scan
Vlvi;
Faulted[i] := Vlv[i].Fault;
END_FOR;
FOR n := 1 TO 4 DO // expensive: 4 per scan, table every 12
BuildDiagText(Cursor);
Cursor := Cursor + 1;
IF Cursor > VLV_N THEN
Cursor := 1;
END_IF;
END_FOR;
Bounds Checking Is Neither Free Nor Automatic
On CODESYS-family runtimes the bounds check is an optional "POU for implicit checks" you add to the project, and by default it skips library function blocks. Worse, the generated CheckBounds clamps an out-of-range subscript to the nearest valid index and lets the scan run on: the bad write lands on the last element instead of faulting, so you get quiet corruption instead of a loud stop. Validate once, where the index enters the program, and keep every FOR bound constant — a bound read from a live tag is a watchdog trip waiting for the day that tag reads 32767.