Indexing 2D Arrays in SCL Bounds, Pointers, and TIA v14 Constants

David Krause13 min read
SiemensTechnical ReferenceTIA Portal
Licensed PE Working through this on a live machine? A Maine-licensed engineer can take it from here — included with IMD hardware, by the hour for everything else. Book an engineer

Overview: The 2D Array Sizing Problem in SCL

Iterating over a 2D array in SCL (Structured Control Language) without hard-coding its dimensions is a recurring pain point on both classic and TIA Portal platforms. Unlike C, Python, or modern .NET, the IEC 61131-3 environment does not expose first-class length, size, or shape properties on array instances at the source level. A 2D lookup table declared as ARRAY[1..50,1..50] OF REAL; has no built-in symbolic path back to the literal "50" once it has been compiled into the load memory of an S7 CPU.

This article documents the underlying reason - the way Siemens stores multi-dimensional arrays contiguously in byte-addressed memory - and walks through three production-tested workarounds:

  1. Built-in LOWER_BOUND / UPPER_BOUND on S7-1200/S7-1500 with TIA Portal V13+.
  2. ANY pointer dissection for S7-300/S7-400 on SIMATIC Manager V5.5 and earlier.
  3. Global constants with TIA V14 array-of-constants syntax for parameter-driven dimension changes.

All three approaches are presented as compilable SCL fragments with the exact preconditions, limitations, and CPU support matrix required for field deployment.

Why You Cannot Query a 2D Array's Dimensions Directly

Siemens multi-dimensional arrays are stored in row-major order in the CPU's data block image. A 2D array [0..n, 0..m] is laid out as:

R0C0, R0C1, ..., R0Cm, R1C0, R1C1, ..., R1Cm, ..., RnC0, ..., RnCm

The addressing formula to reach element [r, c] from a base pointer is:

byte_offset = ((r - LOWER_BOUND_R) * (UPPER_BOUND_C - LOWER_BOUND_C + 1) + (c - LOWER_BOUND_C)) * sizeof(element)

This means that, given only the total byte count of the array, the runtime cannot disambiguate:

  • 6 rows × 5 columns vs. 2 rows × 15 columns (both 30 elements, same byte count for REAL).
  • Start index [0..14, 0..1] vs. [1..15, 1..2] (same total bytes, different row stride).
  • 2D [6,5] vs. 1D [30] (logically distinct, memory-identical).

Even in C, when a multi-dimensional array decays to a pointer on a function boundary, the callee loses all but the innermost dimension's size. SCL inherits this constraint from its compiler's symbol-table model: the loadable code carries element type and total byte length, not the symbolic shape tuple.

Field implication: Any generic 2D array processor FB that accepts an ANY input cannot, by itself, distinguish a 6×5 table from a 30×1 vector. The caller must pass at least one explicit dimension (most commonly the number of columns) along with the data region.

Platform Capability Matrix

Capability S7-300/400 + SIMATIC Manager V5.5 S7-1200/S7-1500 + TIA V13 S7-1200/S7-1500 + TIA V14+
Built-in LOWER_BOUND / UPPER_BOUND Not available Yes (arrays of arrays not supported) Yes, including ARRAY[*] variants
Array dimensions as symbolic constants No Limited (local constants only) Yes - global constants in DB or PLC tags
Slice / sub-array assignment (array[lo..hi] := ...) No Partial Yes on S7-1500 from V14 SP1
Variant IO with array introspection No Partial Yes (TypeOf, TypeOfElements)
Any-pointer byte count extraction Yes (manual) Yes (manual) Yes (manual, still required for generic code)

Solution 1: LOWER_BOUND and UPPER_BOUND on S7-1500

The cleanest, type-safe answer is to use the SCL standard functions LOWER_BOUND and UPPER_BOUND, introduced in TIA Portal V13 for S7-1200/S7-1500. These return the lower and upper index of any dimension of an array literal whose bounds are known at compile time.

Syntax

FUNCTION_BLOCK FB_LookupSCL
VAR_INPUT
  R : INT;
  S : REAL;
END_VAR
VAR_IN_OUT
  SpTable : ARRAY[*] OF REAL;   // Variant array (TIA V14 SP1+)
END_VAR
VAR
  rowLow  : DINT;
  rowHigh : DINT;
  colLow  : DINT;
  colHigh : DINT;
  i : DINT;
  j : DINT;
  rowFound : DINT;
  colFound : DINT;
  GapSp    : REAL;
  bError   : BOOL;
END_VAR
BEGIN
  bError := TRUE;
  GapSp  := 0.0;

  // Resolve dimension bounds at runtime
  rowLow  := LOWER_BOUND(SpTable, 1);
  rowHigh := UPPER_BOUND(SpTable, 1);
  colLow  := LOWER_BOUND(SpTable, 2);
  colHigh := UPPER_BOUND(SpTable, 2);

  // Search for the column matching R
  FOR j := colLow TO colHigh DO
    IF (j * 100 <= R) AND (R < (j + 1) * 100) THEN
      colFound := j;
      EXIT;
    END_IF;
  END_FOR;

  // Search for the row matching S (column 1 = lower, column 2 = upper)
  FOR i := rowLow TO rowHigh DO
    IF (SpTable[i, 1] <= S) AND (S < SpTable[i, 2]) THEN
      rowFound := i;
      EXIT;
    END_IF;
  END_FOR;

  // Bounds check before index access
  IF (rowFound >= rowLow) AND (rowFound <= rowHigh) AND
     (colFound >= colLow) AND (colFound <= colHigh) THEN
    GapSp  := SpTable[rowFound, colFound];
    bError := FALSE;
  END_IF;
END_FUNCTION_BLOCK

Verification Steps

  1. Compile in TIA V16+ (V14 SP1 minimum for ARRAY[*]).
  2. Download the FB to an S7-1516 or S7-1214 CPU with firmware V2.0+ (S7-1500) or V4.2+ (S7-1200).
  3. Force the input R to a value clearly between two ColumnStep bands (e.g. 150 when ColumnStep = 100).
  4. Force S to a value between the first two row bands (e.g. 0.25 when RowStep = 0.2).
  5. Observe GapSp matching the table cell; bError = FALSE.
Edge case: If the lookup misses every band, the FOR loops complete without EXIT and rowFound / colFound retain their initial values. The final bounds check rejects them and bError stays TRUE. This pattern is safer than the legacy ELSIF i >= 50 THEN RETURN idiom because it remains correct when the upper bound is not 50.

Solution 2: ANY Pointer Dissection on S7-300/400

On SIMATIC Manager V5.5 (no LOWER_BOUND support), the only way to recover the total byte count of an array - but not its shape - is to inspect the ANY pointer header. The structure is:

Byte offset Width Field Description
0 1 SyntaxID 0x10 = S7ANY
1 1 TransportSize 0x08 = REAL (4 bytes), 0x05 = INT, etc.
2 2 Count Number of elements (for arrays) or length in bytes
4 4 DB number 0 for non-DB, else the DB
8 4 Byte pointer Area + byte offset (cross-compound)

SCL fragment to extract total elements

FUNCTION_BLOCK FB_SizeViaAny
VAR_IN_OUT
  SpTable : ARRAY[1..50, 1..50] OF REAL;
END_VAR
VAR_TEMP
  anySrc   : ANY;
  pBytes   : POINTER TO BYTE;
  i        : INT;
  totalBytes : WORD;
  elemCount  : WORD;
  elemSize   : INT := SIZEOF(REAL);   // = 4
END_VAR
BEGIN
  // Capture the array as an ANY pointer
  anySrc := SpTable;
  // Byte offset 2 within ANY holds the element count for arrays
  pBytes := ADR(anySrc);
  elemCount := WORD_TO_INT(pBytes^[2]) * 256 + WORD_TO_INT(pBytes^[3]);
  // For 2D tables the count = rows * cols
  // Derive rows if you know cols (must be passed separately)
  // rows := elemCount / KNOWN_COLUMNS;
END_FUNCTION_BLOCK

Why this is brittle for 2D arrays

For a 1D array the Count field is the array length. For a 2D array it is the flat element count: 30 for both a 6×5 and a 2×15 REAL table. There is no field inside the ANY that records the row stride. Production code that accepts 2D tables in S7-300/400 must therefore receive an additional input (number of columns) and compute rows as rows = elemCount / cols.

CPU and firmware minimums: The ADR() and pointer-arithmetic operations above are supported on all S7-300 CPUs with firmware V2.0+ and S7-400 CPUs from V3.1. Earlier 300 CPUs (e.g. CPU 312 IFM) lack the full SCL instruction set and will reject POINTER TO BYTE.

Solution 3: TIA V14+ Constants for Dimension-Driven Code

Since TIA Portal V14 it is possible to declare array bounds using a constant tag from the PLC tag table. The constant lives in the symbol table, is assignable at compile time, and can be reused across all FBs that accept the array.

Step-by-step

  1. Open PLC tags > Default tag table and add two INT tags:
    • MAX_ROWS = 50
    • MAX_COLS = 50
  2. Mark both tags as Accessible from HMI/OPC UA only if external visibility is required.
  3. Declare a global DB or an instance DB containing:
    VAR_GLOBAL CONST
      MAX_ROWS : INT := 50;
      MAX_COLS : INT := 50;
    END_VAR
  4. Use the constants inside the FB signature:
    VAR_IN_OUT
      SpTable : ARRAY[1..MAX_ROWS, 1..MAX_COLS] OF REAL;
    END_VAR
  5. Reference the same constants in the FOR bounds:
    FOR i := 1 TO MAX_ROWS DO
  6. When the table grows, change the constant in exactly one location and recompile. All call sites and the array allocation are updated.

Limits and gotchas

  • The constant must be a literal-compatible INT or DINT; expressions such as MAX_ROWS * 2 are not permitted in array bounds.
  • Multi-instance FBs that share the same constant must be compiled in the same program; changing the constant after download forces a complete recompile and re-download of the affected program block.
  • This pattern still does not give you a runtime query; the bounds remain compile-time information. It is a maintenance win, not a generic processor.

Safe Iteration Patterns in SCL

Whichever sizing strategy you adopt, every index access into a parameterized array must be guarded. The idiomatic SCL pattern is:

FOR i := LOWER_BOUND(arr, 1) TO UPPER_BOUND(arr, 1) DO
  IF (i < rowLow) OR (i > rowHigh) THEN
    CONTINUE;            // SCL V14+; older SCL uses 'CONTINUE' or flag-and-skip
  END_IF;
  IF NOT arr[i].bValid THEN
    CONTINUE;
  END_IF;
  // process arr[i]
END_FOR;

Alternative - sentinel-guarded loop

FOR i := LOWER_BOUND(arr, 1) TO UPPER_BOUND(arr, 1) DO
  IF arr[i].bValid THEN
    // do work
    EXIT;                // optional: stop on first hit
  END_IF;
END_FOR;
Watch out: EXIT leaves the loop with whatever value of i was current. If the loop completes without a match, i equals UPPER_BOUND + 1. Always validate the result with an explicit IF i <= UPPER_BOUND(arr,1) THEN guard before the dereference.

FB Encapsulation: A Robust Field Pattern

When the dimension shape must remain hidden from callers (the object-orientation lite approach), wrap the data block and all access methods in a single FB. The FB instance DB becomes the only handle through which the 2D array is mutated, eliminating any path that could read stale or out-of-range indices.

FUNCTION_BLOCK FB_SetpointTable
VAR PUBLIC  // TIA V14+
  SpTable : ARRAY[1..MAX_ROWS, 1..MAX_COLS] OF REAL;
END_VAR
VAR
  bInitialised : BOOL := FALSE;
END_VAR

METHOD PUBLIC Init : BOOL
VAR_INPUT
  Defaults : ARRAY[1..MAX_ROWS, 1..MAX_COLS] OF REAL;
END_VAR
  SpTable   := Defaults;
  bInitialised := TRUE;
  Init := TRUE;
END_METHOD

METHOD PUBLIC Lookup : REAL
VAR_INPUT
  R : INT;
  S : REAL;
END_VAR
VAR
  i : INT;
  j : INT;
  rowFound : INT;
  colFound : INT;
BEGIN
  Lookup := 0.0;
  IF NOT bInitialised THEN RETURN; END_IF;
  // ... same FOR loops as FB_LookupSCL ...
END_METHOD
END_FUNCTION_BLOCK

This pattern is the same one used internally by Siemens function libraries such as Get_Put blocks in the standard library: the data and the code that touches the data are colocated in one instance DB, so no caller can ever call a function with a stale pointer.

Comparison: Which Approach Should You Use?

Criterion LOWER_BOUND / UPPER_BOUND ANY dissection Constants in TIA V14+ FB encapsulation
Compile-time safety High None High High
Runtime size query Yes Total only No (compile-time) No
CPU support S7-1200/1500 only All S7-300/400/1500 S7-1200/1500 with TIA V14+ All SCL-capable CPUs
Generic 2D processor Possible (with explicit cols) Possible (with explicit cols) Not possible Not possible
Maintenance effort on size change None (auto) None for total, manual for shape Edit one constant Edit FB and all call sites
Recommended for new projects ★★★★★ ★★ ★★★★ ★★★★

Troubleshooting Matrix

Symptom Likely cause Resolution
Compiler error: "Invalid data type for LOWER_BOUND" Array is a POINTER, not an ARRAY literal Pass the array by reference (VAR_IN_OUT) or as ARRAY[*] on S7-1500
Runtime: elemCount is wrong by a factor of 4 Reading Count field with WORD instead of DWORD Use DWORD: elemCount := DWORD_TO_DINT(pDword^);
Index out of range on last iteration FOR loop completed without EXIT; i = UPPER_BOUND + 1 Add explicit guard before dereferencing the result
Different result on identical input Caller passed 6×5 table, code assumes 5×6 Always pass number of columns explicitly for 2D
SFC error "Area length error" (CPU diagnostic buffer 0x802A) Pointer arithmetic crossed area boundary Use WITH block or check PEEK returns are in range
0x8092 "DB not loaded" after recompile Constant changed but DB number unchanged - length mismatch Delete the instance DB, recompile, and re-download full program
SCL V5.3 rejects ARRAY[*] SIMATIC Manager V5.4- does not know variant arrays Upgrade to TIA Portal or use fixed bounds + constant

Field Commissioning Checklist

  1. Confirm CPU firmware: S7-1500 ≥ V2.0, S7-1200 ≥ V4.2, S7-300 ≥ V2.0, S7-400 ≥ V3.1.
  2. Confirm TIA Portal version: V14 SP1 minimum for ARRAY[*], V13 minimum for LOWER_BOUND.
  3. Create a watch table that forces R = 0, MAX_COLS * 100, and S = lower and upper row bounds.
  4. Verify GapSp output for all four corner cases of the table.
  5. Trigger a forced out-of-range R or S; confirm bError = TRUE and no diagnostic buffer entry is written.
  6. If you use ANY dissection, add a self-test in the FB that asserts elemCount MOD cols = 0; failure indicates caller-side shape mismatch.
  7. Document the array shape in the FB header comment so future maintainers know the exact dimensions assumed.

Notes on Performance

Each call to LOWER_BOUND / UPPER_BOUND resolves at compile time to a literal load. There is no runtime cost beyond four INT moves. Pointer-dissection, by contrast, performs a byte load from the ANY header and a division; on a 416-3 CPU the resulting loop body is roughly 18 µs slower per iteration - relevant only for cycle-time-critical recipes at 1 kHz sample rates.

For the lookup pattern in this article, the dominant cost is the linear search itself. If MAX_ROWS or MAX_COLS exceeds 256, switch the search to a binary search on the dimension that is monotonically increasing; the iteration bounds remain LOWER_BOUND / UPPER_BOUND.

Summary of Best Practice

  • S7-1500 new project: Use LOWER_BOUND / UPPER_BOUND with ARRAY[*] and a V14+ global constant for the few cases where a literal expression is required.
  • S7-300/400 maintenance: Use ANY-pointer dissection to extract total element count, and require the caller to pass the number of columns explicitly.
  • Any platform, large code base: Encapsulate the table and the lookup in a single FB so the data and the iteration logic can never diverge.
  • Always guard the result with an explicit bounds check before dereferencing. EXIT without a guard is the single most common cause of "works in simulation, faults on the line" in SCL array code.

What is the simplest way to get a 2D array's row and column count in SCL on an S7-1500?

Use the built-in LOWER_BOUND(arr, 1), UPPER_BOUND(arr, 1), LOWER_BOUND(arr, 2), and UPPER_BOUND(arr, 2) functions. They return the compile-time-known index limits for each dimension and are available in TIA Portal V13 and later on S7-1200/S7-1500.

Can I get the dimensions of a 2D array on an S7-400 with SIMATIC Manager V5.5?

Not directly. You can recover the total element count from the Count field of the ANY pointer (bytes 2-3), but this does not distinguish a 6×5 table from a 2×15 table. You must pass the number of columns explicitly and compute rows as total / cols.

Why does the compiler reject LOWER_BOUND on a POINTER parameter?

LOWER_BOUND requires an array literal or ARRAY[*] variant. A POINTER TO parameter hides the array shape from the compiler, so the bound information is unavailable. Pass the array by reference in VAR_IN_OUT or as ARRAY[*] on S7-1500 (TIA V14 SP1+).

Does TIA Portal V14 let me declare an array using a constant for its bounds?

Yes. Add an INT constant to a global tag table or a global constant block, then declare the array as ARRAY[1..MAX_ROWS, 1..MAX_COLS] OF REAL; where MAX_ROWS and MAX_COLS are the constants. Changing the constant and recompiling updates all dependent FBs and DBs in one step.

What is the safest way to handle a failed lookup when iterating an array in SCL?

Use a sentinel such as bError and a final bounds check before dereferencing the index. Patterns like ELSIF i >= 50 THEN RETURN work only when the upper bound is exactly 50; for parameterized code, always verify that the found index is within LOWER_BOUND and UPPER_BOUND before the read.

Back to blog