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.
What arrays do in a PLC program
A PLC program handles lists of values all the time: sensor readings, recipe values, alarm logs, production data. Structured Text handles those as arrays, and processing an array in ST takes far less code than the equivalent in 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
Walking an array with a FOR loop
Most array processing is a FOR loop over the index. This one finds the sum, minimum and maximum of 20 readings and then the average:
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 storage in a two-dimensional array
Recipe management is one of the most common uses of arrays in industrial code. Here each row is a recipe and each column a parameter:
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_VAR
IF 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
A circular buffer keeps a rolling log in a fixed block of memory, so the log never overflows however long the machine runs:
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_VAR
IF 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 and a median
Sorting lets you find a median or rank a set of values:
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 for conveyor tracking
A shift register tracks items along a conveyor, or keeps a short production history:
PROGRAM ShiftRegister
VAR
Register : ARRAY[1..20] OF BOOL;
NewItem : BOOL := FALSE;
ShiftTrigger : BOOL := FALSE;
i : INT;
ItemCount : INT := 0;
END_VAR
IF 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;
Rules for array code
You can try these patterns in the online ST editor and look up the syntax in the 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 in the middle of the table shifts every stored recipe one column and nobody gets an error. With ARRAY OF STRUCT each value has a name, so that mistake cannot happen silently.
Two habits do more for you than the choice of type. First, latch a working copy. Never point control logic at Recipes[Sel]: if an operator edits recipe 4 while batch 4 runs, the setpoints move under a live process. Second, validate the index 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 those are not portable, so check 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_TYPE
VAR 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 <shift> shifts on every scan the trigger is true, so a photoeye held for 30 ms in a 10 ms task advances the map three slots. R_TRIG is the minimum fix. The proper fix is to shift on belt travel instead of on events, one slot per fixed distance of encoder movement, so the map survives creep, jogging and stops in the middle of an index.
Sizing catches people 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 with 10 mm slots you lose position on a good day and bottles on a bad one. On reverse jog, freeze the map instead of shifting it back; pushing the belt back by hand does not un-eject a reject.
Retention is the last trap. The register says forty parts are in the zone, but night shift cleared the line by hand. Put auto-restart behind a zone-empty confirmation that zeroes the array.
TYPE ST_Part :
STRUCT
Present : BOOL;
Reject : BOOL;
END_STRUCT
END_TYPE
VAR 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 and add the one entering. That arithmetic is the easy half.
A boxcar average of N samples delays the signal by roughly (N−1)/2 sample periods. Sixteen samples on a 100 ms task gives about 0.75 s of lag, which is fine on a trend screen and too much inside a fast loop or a fill cut-off. Prime the buffer as well. If it starts full of zeros, the output crawls up for N samples after every power-up, and that is enough to trip a low-flow alarm on every start.
Only add samples with good quality, because a broken-wire 0.0 or a single burst spike stays in the average for N samples after the fault clears. 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 every so often.
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_VAR
IF 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 most useful array in machine code usually holds function block instances, not data. ARRAY[1..48] OF FB_Valve gives you forty-eight instances, each with its own timers and edge states, driven from a parallel config array holding tag, stroke time and interlock mask. You test one block once, and diagnostics and the HMI list become loops.
There are two traps. Sort an array of indices instead of the structs, because every swap of a struct that holds a STRING copies the whole block. And compilers pad the end of a struct so the next array element stays aligned, which means the element stride is not the sum of the member sizes. Any code that overlays the array on a Modbus block or a byte buffer has to allow for that padding. CODESYS and TwinCAT expose a pack_mode attribute for it.
Scan time is the other limit, and the fix is a cursor. Run the cheap per-device logic every scan. Do the expensive work, such as building strings or logging events, for a few elements per scan, walking the table over many scans. You give up some freshness and get 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_VAR
FOR i := 1 TO VLV_N DO // cheap: must survive every scan
Vlv[i](Cfg := VlvCfg[i], OpenCmd := Cmd[i], FbOpen := DI_Open[i]);
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" that 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 carry on. The bad write lands on the last element instead of faulting the PLC, so you get silent corruption where you wanted a loud stop. Validate the index once, where it enters the program, and keep every FOR bound constant. A bound read from a live tag will trip the watchdog on the day that tag reads 32767.