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_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

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_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 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_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;

Best Practices

  • Always check array bounds — Out-of-range access causes PLC faults
  • Use constants for array sizes — Makes maintenance easier
  • Avoid large arrays in fast tasks — FOR loops consume scan time
  • Initialize arrays on startup — Don't assume default values
  • Use circular buffers for logs — Prevents memory overflow
  • 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_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 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_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, 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_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 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_VAR

    FOR 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.