Eight tanks, one name
A tank farm has eight storage tanks, each with a level transmitter. Without arrays you declare Level1, Level2 … Level8, then write the high-level check eight times, the total eight times and the average eight times. When the plant adds two tanks, someone edits twenty-four lines and misses one. Every ladder programmer has inherited a program like that.
An array is one variable that holds many values of the same type, numbered by an index:
VAR
Level : ARRAY[1..8] OF REAL; (* tank levels in %, one per tank *)
Zone : ARRAY[0..11] OF INT; (* 12 oven zone temperatures *)
END_VAR
ARRAY[1..8] OF REAL reads exactly as it sounds: eight REALs, indexed 1 to 8. Level[3] is tank 3. The bounds are yours to choose, and the choice is worth a moment's thought. Number the array the way the machine is numbered: if the P&ID says tanks T-101 to T-108 and the operators say "tank 1" to "tank 8", declare [1..8] so that Level[3] is tank 3. Zero-based arrays are a habit from C; on a PLC they create a permanent off-by-one between the code and the drawings.
Vendors differ here. CODESYS, TwinCAT and TIA Portal let you pick any lower bound, including negative ones. Studio 5000 tag arrays always start at 0 — a DINT[8] is elements 0 to 7 — so Rockwell programmers either live with zero-based numbering or declare one spare element and ignore index 0. Both are common. Neither is wrong if the whole project does the same thing.
Initialisers and indexing
An array can be given starting values in its declaration with a list in square brackets. A filling machine that runs five bottle sizes keeps its fill weights in a small table, and the product number from the HMI selects one:
PROGRAM Main
VAR
Product : INT := 2; (* input: product selected on the HMI, 1..5 *)
FillGrams : ARRAY[1..5] OF INT := [250, 500, 750, 1000, 2000];
Target : INT; (* fill target sent to the weigh controller *)
END_VAR
Target := FillGrams[Product];
END_PROGRAMChange Product to 1, 4 or 5 and the target follows. The index does not have to be a literal: any integer expression works, and that is the whole point. One line of code serves every product.
Now type 6 into Product. The engine stops the scan with an array out-of-bounds fault, because FillGrams[6] does not exist. Real controllers react in very different ways, and you need to know which one you are standing in front of:
| Platform | What an out-of-range index does |
|---|---|
| Siemens S7-1500 | Programming error. The CPU calls OB121; if that OB is not in the project, it goes to STOP. |
| Rockwell ControlLogix / CompactLogix | Major fault, type 4 code 20 ("array subscript too large"). The controller faults the whole program. |
| CODESYS / TwinCAT 3 | By default, nothing is checked. The write lands in whatever memory sits next to the array. Add the CheckBounds implicit-check function during commissioning to catch it. |
The CODESYS row is the dangerous one. A stopped CPU is loud; a silently overwritten neighbour variable is a bug that appears weeks later in a part of the program nobody touched. Whatever the platform, the rule is the same: an index that comes from outside the program must be checked before it is used. An HMI field, a barcode, a recipe number from a SCADA system — all of them will one day hold 0 or 99.
IF Product >= 1 AND Product <= 5 THEN
Target := FillGrams[Product];
ELSE
Target := 0; (* and raise a "bad product number" alarm *)
END_IF;
Two smaller rules about initialisers. First, the list must match the array length on this engine; IEC 61131-3 also allows a short list (the rest are zero) and a repeat factor such as [8(0)], which CODESYS and TwinCAT accept, but don't rely on either in portable code. Second, like any initial value, the list is applied once when the controller starts the program, not on every scan. If the program writes FillGrams[2] := 520;, the table keeps 520 until the next cold start.
FOR … TO … BY
Arrays become powerful when you combine them with a loop. The FOR loop runs its body once for each value of a counter:
FOR i := 1 TO 8 DO
(* body runs with i = 1, 2, 3 ... 8 *)
END_FOR;
The counter i must be an integer variable you declare like any other. Add BY to change the step: BY 2 visits every second element, BY -1 counts down. A rotary filler with eight heads can run all of them, or only the odd ones during a half-speed changeover:
PROGRAM Main
VAR
OddOnly : BOOL; (* input: use heads 1, 3, 5, 7 only *)
HeadOn : ARRAY[1..8] OF BOOL; (* one valve command per filling head *)
StepSize : INT;
i : INT;
Active : INT; (* heads switched on this scan *)
Scans : DINT;
Passes : DINT; (* loop iterations since start *)
END_VAR
Scans := Scans + 1;
FOR i := 1 TO 8 DO
HeadOn[i] := FALSE; (* start from a known state every scan *)
END_FOR;
IF OddOnly THEN StepSize := 2; ELSE StepSize := 1; END_IF;
Active := 0;
FOR i := 1 TO 8 BY StepSize DO
HeadOn[i] := TRUE;
Active := Active + 1;
Passes := Passes + 1;
END_FOR;
END_PROGRAMWatch Passes against Scans. With all heads in use, Passes is exactly eight times Scans: the loop runs all eight iterations every scan, then the scan ends. Flip OddOnly and Passes grows by four per scan. Notice the first loop too. Before choosing which heads to switch on, the program switches them all off. That is the array version of "every output gets a value on every scan", and it stops a head from staying on after it was dropped from the pattern.
Three rules for FOR loops that keep you out of trouble on every platform:
- Never write to the counter inside the loop. IEC 61131-3 forbids it, and controllers disagree on what happens if you do. CODESYS honours the change and skips iterations; this engine ignores it. Code that depends on either is a trap.
- Don't use the counter after the loop. Whether
iends at 8 or 9 afterFOR i := 1 TO 8is implementation-dependent. CODESYS leaves 9; this engine leaves 8. If you need a result from the loop, store it in a variable of its own. - Never compute a step that can be zero.
BY 0is an endless loop on CODESYS (this engine quietly treats it as 1). A step taken from an HMI value must be clamped first.
Totals: clear before you add
The most common array bug in PLC code is also the simplest. Here is a total of four tank levels, with a switch that lets you remove the line that clears it:
PROGRAM Main
VAR
ClearFirst : BOOL := TRUE; (* input: switch off to see the bug *)
Level : ARRAY[1..4] OF INT := [120, 340, 95, 410]; (* litres *)
Total : DINT;
i : INT;
END_VAR
IF ClearFirst THEN
Total := 0;
END_IF;
FOR i := 1 TO 4 DO
Total := Total + Level[i];
END_FOR;
END_PROGRAMWith ClearFirst on, Total sits at 965 litres. Switch it off and Total climbs by 965 every scan — ten times a second here, a hundred times a second on a 10 ms task. In a desktop program, a sum written this way runs once and is correct. On a PLC the same lines run again on the next scan, and the next, adding the same tanks on top of yesterday's answer.
The fix is one line, and it belongs immediately above the loop: Total := 0;. Not in the declaration, where it runs once at start-up; not behind a reset button; right before the FOR. The same applies to every accumulator — counts of faulted sensors, sums for averages, "any alarm active" flags (start them at FALSE, then OR into them). The bug survives testing surprisingly often, because a programmer who looks at the value just after a download sees the right number for the first scan.
Average, minimum and maximum
Six probes in a blast freezer give six temperatures. The control system needs the average for display, the warmest reading for the alarm, and the coldest one to spot an iced-up evaporator:
PROGRAM Main
VAR CONSTANT
PROBES : INT := 6;
END_VAR
VAR
Temp : ARRAY[1..6] OF REAL := [-18.5, -19.2, -17.8, -21.0, -18.9, -16.4]; (* °C *)
Sum : REAL;
Avg : REAL;
MinT : REAL;
MaxT : REAL;
Warmest : INT; (* probe number of the warmest reading *)
i : INT;
END_VAR
Sum := 0.0;
MinT := Temp[1]; (* start from a real reading, not from 0 *)
MaxT := Temp[1];
Warmest := 1;
FOR i := 1 TO PROBES DO
Sum := Sum + Temp[i];
IF Temp[i] < MinT THEN
MinT := Temp[i];
END_IF;
IF Temp[i] > MaxT THEN
MaxT := Temp[i];
Warmest := i;
END_IF;
END_FOR;
Avg := Sum / INT_TO_REAL(PROBES);
END_PROGRAMType a new value for probe 3 — try −12.0, then −25.0 — and watch the three results move together. (The average shows more decimals than a real PLC would: this engine calculates REAL in 64-bit, where a controller's 32-bit REAL carries about seven significant digits.)
Three details carry the whole technique. All three results are reset before the loop, every scan, for exactly the reason in the previous section. The minimum and maximum start from the first element, not from a made-up value. And Warmest is updated in the same IF as MaxT, so the index and the value can never disagree. Because the comparison is >, a tie keeps the lower probe number; write >= and it keeps the higher one. Decide which one the operator expects and write it down.
The made-up starting value is a bug worth seeing once:
Finding an index
Often you don't need a value but a position: which tank is low, which station holds the reject, which heating zone failed. Search for the first match and remember where it was:
PROGRAM Main
VAR CONSTANT
TANKS : INT := 6;
LOW_PCT : INT := 15; (* low-level alarm threshold, % *)
END_VAR
VAR
Level : ARRAY[1..6] OF INT := [62, 48, 71, 33, 90, 55];
FirstLow : INT; (* first tank below LOW_PCT, 0 = none *)
LowCount : INT;
i : INT;
END_VAR
FirstLow := 0;
LowCount := 0;
FOR i := 1 TO TANKS DO
IF Level[i] < LOW_PCT THEN
LowCount := LowCount + 1;
IF FirstLow = 0 THEN
FirstLow := i; (* keep the first one only *)
END_IF;
END_IF;
END_FOR;
END_PROGRAMSet tank 5 to 10 and FirstLow becomes 5. Now set tank 2 to 8: FirstLow moves to 2, LowCount reads 2. Raise both back above 15 and FirstLow returns to 0, because it is cleared before every search. That "0 means none" convention works only because the array starts at 1 — another quiet argument for numbering arrays like the plant.
The loop still visits all six tanks after it has found the first one. For six elements that costs nothing. For a 500-entry lookup table it is wasted work, and the next chapter's EXIT statement lets you leave the loop as soon as you have the answer.
The loop finishes inside one scan
A FOR loop is not a background job. All of its iterations run inside the scan, one after another, before the next line of the program. The outputs are not written, the inputs are not refreshed and no other part of the program runs until the loop is done. That is what makes the totals above trustworthy — every tank is read from the same input snapshot — and it also means every iteration is paid for in scan time.
The cost is small but real. Siemens rates an S7-1511 at roughly 0.4 µs per floating-point instruction; a loop body with five REAL operations over 1,000 elements costs about 2 ms. On a 10 ms task that is a fifth of your budget for one loop. Run it over 20,000 logged samples and you exceed the 150 ms cycle-time watchdog an S7-1500 uses by default. Rockwell and CODESYS tasks have watchdogs too, and all of them stop the controller when one trips.
The practical rules:
- Loop over the I/O-sized arrays you have — 8 tanks, 24 zones, 64 alarms — without a second thought.
- For thousands of elements, ask whether the work must be done every scan. A daily total can be built a little at a time: process 100 elements per scan with an index that survives between scans, and set a
Doneflag when it wraps round. - Never put a timer, an edge detector or a wait inside a loop expecting it to progress. The machine does not move while your loop runs. Chapter 14 shows what happens when people try.
Constants for bounds, and two-dimensional arrays
The arrays above repeat their size in two places: in the declaration and in the TO of every loop. When the plant grows from six tanks to eight, both must change. The tidy solution is a named constant used for both:
VAR CONSTANT
TANKS : INT := 8;
END_VAR
VAR
Level : ARRAY[1..TANKS] OF REAL;
END_VAR
FOR i := 1 TO TANKS DO ...
CODESYS, TwinCAT and TIA Portal (with a global user constant) all accept a constant as an array bound. This engine does not yet, so the live examples write the literal size in the declaration and use the constant for the loop — two places to edit instead of one per loop. Forget the declaration and nothing warns you at build time; the first scan that reaches the new tank faults. When you resize an array, search the project for the old number. Studio 5000 array sizes are fixed numbers in the tag editor, and Rockwell code usually reads the size from the array's dimension instead. CODESYS and TwinCAT also offer UPPER_BOUND(Level, 1) and LOWER_BOUND(Level, 1), which ask the array itself.
When data has rows and columns — a pallet layer, a tray of 4 × 6 vials, a 2-axis lookup table — use a two-dimensional array and nest two loops:
PROGRAM Main
VAR
Row1Full : BOOL; (* input: last carton of row 1 placed *)
Layer : ARRAY[1..2, 1..3] OF BOOL; (* 2 rows x 3 cartons on a pallet layer *)
Row : INT;
Col : INT;
Placed : INT;
LayerDone : BOOL;
END_VAR
Layer[1, 1] := TRUE;
Layer[1, 2] := TRUE;
Layer[2, 1] := TRUE;
Layer[2, 2] := TRUE;
Layer[2, 3] := TRUE;
Layer[1, 3] := Row1Full;
Placed := 0;
FOR Row := 1 TO 2 DO
FOR Col := 1 TO 3 DO
IF Layer[Row, Col] THEN
Placed := Placed + 1;
END_IF;
END_FOR;
END_FOR;
LayerDone := Placed = 6;
END_PROGRAMSwitch Row1Full on and the count reaches six, completing the layer. The inner loop runs completely for each pass of the outer one: six iterations in total, Layer[1,1] through Layer[2,3]. Keep the outer loop on the slower-changing index — rows outside, columns inside — so the code reads like the layer looks from above. Two dimensions cover almost everything on a machine; if you find yourself reaching for three, an array of structures (chapter 15) usually describes the data better.
Where this leads
You can now hold a whole row of equipment in one variable and process it with a FOR loop: totals that are cleared before they are summed, averages, minimum and maximum searches that start from a real reading, and index searches that report "none" honestly. You also know that the loop runs to completion inside one scan, and that a bad index can stop a CPU or silently corrupt memory, depending on whose hardware you are using.
FOR is the loop to reach for when you know how many elements there are. The next chapter covers the two loops that run until a condition changes — WHILE and REPEAT — together with EXIT for leaving early, and the one way of using them that brings a running machine to a halt.