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

  • Check array bounds. An out-of-range access faults the PLC.
  • Use constants for array sizes, so a resize is one edit.
  • Keep large arrays out of fast tasks, because FOR loops cost scan time.
  • Initialize arrays at startup instead of assuming default values.
  • Use circular buffers for logs so they cannot overflow memory.
  • 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.