S7-1500 SCL: Iterating a DB to Identify UDT Types at Runtime

David Krause12 min read
S7-1200SiemensTechnical Reference
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

Problem Statement

On a SIMATIC S7-1500 (for example CPU 1517-3 PN/DP, firmware V2.9 or later, programmed in TIA Portal V17+), a globally generated data block is populated by a code-generation pipeline (Excel macros, Python scripts, or a TIA Openness exporter) with N elements, each of which is exactly 4 bytes wide and is randomly assigned to one of roughly 15 different UDT types (UDT_Motor, UDT_Photocell, UDT_Scanner, and so on). At startup the application needs to walk the entire DB, identify every element whose UDT type equals UDT_Motor, and store its byte offset into an internal array. Later, on each operator request, the FB has to read a specific error bit located at a fixed intra-UDT offset and write it into a status array.

The intuitive approach is to combine PEEK_DWORD with a Variant and the TypeOf() instruction, sweeping the DB at i * 4 byte intervals. This article documents why that path is closed on an optimized-access block, what the runtime type system can and cannot see through DB_ANY / Variant, and the four production-grade workarounds that replace it.

Why Runtime UDT Identification Fails on S7-1500

The S7-1500 loader stores two distinct artefacts for every data block:

  1. The payload image in the work memory / load memory, which is exactly what PEEK / POKE and the area code 16#84 read and write byte-by-byte.
  2. The symbolic type descriptor (the compile-time interface), which is what the SCL compiler uses to resolve Variant and TypeOf.

For an optimized block (the default for any DB created from V14 onward, attribute { S7_Optimized_Access := 'TRUE' }) the runtime layout uses symbolic names and aligned slot addresses that the firmware re-computes during download. The four raw bytes that PEEK_DWORD returns at offset k carry no type information, no tag header, and no pointer to the UDT they were originally instantiated from. TypeOf() on a Variant filled by PEEK_DWORD therefore yields the synthetic type DWORD, never the original UDT name. The instruction cannot traverse the DB on its own; the SCL compiler would have to know at compile time which offset belongs to which symbol, which is exactly the data the loop is trying to discover.

For a non-optimized block (attribute { S7_Optimized_Access := 'FALSE' }) the absolute layout is fixed, but the bytes on the wire are still just a flat bit image. There is no header, no length prefix, and no type tag between the 4-byte slots. Even there, nothing on the runtime side can recover the original UDT name from a PEEK_DWORD.

Reference: SIMATIC S7-1500 SCL programming and operating manual (09/2022), section 6.3 "Variant data type and TypeOf()".

Variant, TypeOf, and the Symbolic-Addressing Requirement

The Variant datatype in S7-1500 SCL is a tagged pointer that holds (a) a pointer to the live value and (b) the full type descriptor of the variable it points to. The descriptor is built by the compiler at compile time when a symbolic tag is assigned. TypeOf() is a compile-time-resolvable intrinsic: it returns the type of the operand the Variant is currently bound to, and the SCL pre-processor emits a CASE over a DINT constant per branch. It is therefore not a runtime inspection of memory; it is a static dispatch table.

This is why the original snippet cannot be made to work by simply reading a Variant from an indexed DB slot:

BEGIN
  FOR #i := 0 TO #Number_of_iterations DO
    // Even with a symbolic index, e.g. "MyDB".Element[#i],
    // TypeOf() resolves to the declared type of "MyDB".Element (the array element type),
    // never the per-instance UDT behind each slot.
  END_FOR;
END_FUNCTION_BLOCK

If the array is declared as Array[0..19] of UDT_Motor, every element is statically UDT_Motor; TypeOf() returns UDT_Motor twenty times, even when fifteen of those slots were meant to be UDT_Photocell at generation time. The runtime cannot detect a mismatch because there is no mismatch in the compiled metadata.

If the array is instead declared as Array[0..19] of Variant, every slot is statically a Variant; TypeOf() returns Variant twenty times. The application would have to manually VariantGet into a tag of an assumed type, which is exactly the decision the loop is trying to automate.

Documentation: S7-1500 SCL manual, sections 6.3 (Variant), 6.4 (TypeOf / TypeOfElements); S7-1500 system manual, section on optimized block access.

What DB_ANY, ATTR_DB, and DB_LENGTH Actually Give You

Before any iteration the FB can call ATTR_DB (Extended instructions > Distributed I/O > ... > Attributes) to read the total byte length of the DB and its attributes, and DB_ANY_TO_UINT to translate the DB_ANY handle into the numeric DB number required by the absolute-access PEEK_BOOL. Both calls work on optimized blocks and give the application only the following:

Output Meaning Useful for the loop?
DB_LENGTH (UDInt) Total bytes of the DB work-memory image Yes — bounds for the outer FOR
ATTRIB (Byte) Bit 0 = optimized, bit 1 = unlinked, etc. Diagnostic only
DB_NUMBER (UInt) Numeric DB number after download Yes — input to PEEK_BOOL

None of these expose the per-offset type table. DB_LENGTH therefore only sets the upper bound of the iteration; it cannot shrink the loop to just the UDT_Motor slots.

PEEK / POKE on Optimized Blocks: Do's and Don'ts

The classic area codes used by the legacy S7-300/400 still apply on the S7-1500, but with restrictions:

Area code (byte 1) Meaning Works on optimized DB?
16#80 Inputs (PII) Yes
16#81 Outputs (PIQ) Yes
16#82 Merker / M Yes
16#83 Process image inputs Yes
16#84 DB Yes for optimized DBs as long as DB_ANY is converted to the correct DB number

The call in the original snippet is correct:

Status_Error := PEEK_BOOL(area := 16#84,
                             dbNumber := #DB_Number_DINT,
                             byteOffset := #Offset_to_apply,
                             bitOffset := #Bit_Offset);

What is missing is any mechanism to populate #Offset_to_apply with a value that was discovered by the loop, because the loop cannot perform the discovery. Reference: S7-1500 SCL manual, section "PEEK and POKE".

Workaround A — Pre-Computed Offset Array (smallest change)

The pattern already in use in the original code is the most pragmatic fix for a DB that is generated offline and never changes at runtime: the generator emits, alongside the DB instance, a second constant data block (or a ARRAY of constants) that contains the byte offset of every UDT_Motor element in numeric order. The runtime code becomes a pure index lookup with no type discovery.

// Generated constant DB: "DB_MotorOffsets"
{ S7_Optimized_Access := 'TRUE' }
VERSION : 0.1
VAR CONSTANT
  Cnt : UInt := 7;          // number of UDT_Motor slots in the main DB
  Off : Array[0..6] of UInt := [0, 64, 128, 256, 320, 512, 768];
END_VAR
// FB body
IF #New_Request AND (#Number_of_request < "DB_MotorOffsets".Cnt) THEN
  #Offset_to_apply := INT_TO_DINT("DB_MotorOffsets".Off[#Number_of_request]);
  #Status_Error := PEEK_BOOL(area := 16#84,
                             dbNumber := #DB_Number_DINT,
                             byteOffset := #Offset_to_apply,
                             bitOffset := #Bit_Offset);
END_IF;

Pros: zero CPU cost at startup, no scan of the DB, easy to verify by inspection. Cons: every regeneration of the main DB must regenerate the constant DB; the two are versioned together.

Workaround B — Self-Identifying UDT with a Type Tag

If the generator is allowed to wrap each 4-byte payload in a small typed envelope, the runtime can walk the DB without external metadata. The wrapping UDT is defined in the project, not in the data:

TYPE "UDT_Envelope"
VERSION : 0.1
  STRUCT
    TypeTag  : UInt;   // 1 = UDT_Motor, 2 = UDT_Photocell, 3 = UDT_Scanner, ...
    Pad      : UInt;   // reserved, ensures 4-byte payload alignment
    Payload  : DWord;  // opaque 4 bytes, re-interpreted per TypeTag
  END_STRUCT;
END_TYPE

The generator emits 8 bytes per logical element. At startup, the FB uses PEEK_WORD at i * 8 to read the TypeTag, increments a counter when it equals the constant for UDT_Motor, and records i * 8 + 4 as the payload offset. The inner bit of interest is then reached with PEEK_BOOL at the same relative position used in the original code.

FOR #i := 0 TO (#DB_Lenght / 8) - 1 DO
  #Tag := PEEK_WORD(area := 16#84,
                    dbNumber := #DB_Number_DINT,
                    byteOffset := #i * 8);
  IF #Tag = #CONST_TYPE_MOTOR THEN
    #Array_of_offset[#j] := #i * 8 + 4;   // payload starts here
    #j := #j + 1;
  END_IF;
END_FOR;

Pros: the DB remains the single source of truth, the loop actually iterates, future UDT types only need a new constant. Cons: doubles the DB footprint and requires that every consumer read the payload through the same re-interpretation. Reference for the type-tag concept: Microsoft Learn: Retrieving UDT data (CLR UDT pattern of self-describing header) — the same self-identification pattern used by SQL Server CLR UDTs is applied here to the PLC envelope.

Workaround C — Separate Index DBs per UDT Type

The DB is left untouched, but the generator also writes one Array of UInt per UDT type into a dedicated index DB. The runtime just reads the right array directly:

// DB generated alongside the main DB
VAR
  Motor_Index   : Array[0..49] of UInt;
  Motor_Cnt     : UInt;
  Photocell_Idx : Array[0..49] of UInt;
  Photocell_Cnt : UInt;
  // ... one pair per UDT type ...
END_VAR

This is essentially Workaround A with the indices grouped by UDT type. It scales better when the number of UDT types grows past ~5 and the consumer code is selecting by name rather than iterating. It also survives a re-download that re-orders the main DB, because the index arrays are regenerated atomically.

Workaround D — TIA Openness Generator with Symbolic Catalogue

The most robust pipeline is to generate the project with the TIA Openness API (C# / VB.NET) and have the generator emit a single XML or JSON sidecar file that lists every UDT_Motor symbol, its full qualified name, and its starting offset. The FB loads that sidecar at first scan (e.g. via FileReadC from a memory card) into an internal array, and the rest of the logic is identical to Workaround A. The advantage is that the runtime code never has to be touched when the DB layout changes — only the generator script and the sidecar change.

// Excerpt of sidecar (conceptual)
<MotorOffsets>
  <Slot Symbol="MainDB".Motors[0]" Offset="0"/>
  <Slot Symbol="MainDB".Motors[1]" Offset="64"/>
  ...
</MotorOffsets>

Reference: SIMATIC S7-1500 automation system, system manual (2022 ed.) and the TIA Portal Openness help, which is part of the TIA Portal installation under \Hilfe\Openness\en.

Performance and Memory Considerations

For an S7-1500 CPU 1517-3 PN/DP, a single PEEK_BOOL call takes roughly 1–2 µs and a PEEK_WORD call roughly 0.5–1 µs, depending on the data consistency setting. The bottleneck is the loop count, not the peek itself. With Workaround B (8-byte envelope) and 200 envelope slots, a full scan is on the order of 0.5 ms and can be done once in OB100 (warm restart) or the first scan of OB1, then cached.

The internal Array_of_offset[0..200] of Int from the original code allocates 402 bytes of work memory for a 200-entry array; Int is sufficient only when the DB is smaller than 32 KB. For larger DBs the array should be DInt or UInt with an explicit range that matches the worst-case count of UDT_Motor slots reported by the generator.

If the DB is non-optimized and the FB later writes back into a known UDT_Motor slot with a POKE, be aware that S7-1500 firmware V2.6 and later allow POKE into optimized DBs only when the offset points to a slot that the compiler knows is a complete data unit. For partial-byte writes the area code 16#84 may return a non-fatal alignment warning in the diagnostic buffer. Always wrap POKE calls with explicit byte-offset and bit-offset checks.

Verification Procedure

  1. Add a watch table with the Array_of_offset and the loop counter. Force the count to zero, trigger first scan, and confirm the array entries equal the offsets you would compute by hand for a known test DB.
  2. Toggle one UDT_Motor error bit from a second FB. Trigger New_Request for the matching index and observe Status_Error change.
  3. Toggle a UDT_Photocell error bit at the same byte/bit position; confirm Status_Error does not change because the index is not in the offset array.
  4. Re-download the project after editing the DB layout; verify that the generator regenerates the index (Workaround A/C/D) or that the type-tag offsets are still read correctly (Workaround B).

References: S7-1500 system manual — diagnostics and watch tables; S7-1500 SCL manual — testing with the SCL debugger.

Common Pitfalls

Symptom Likely cause Fix
Loop never finds any element Number_of_iterations = 0 because DB_LENGTH returned zero on first scan of an unlinked DB Check ATTRIB for the unlinked bit; call ATTR_DB from OB100 only
TypeOf() always returns DWORD Variant is bound to a PEEK_DWORD result, which has no symbolic UDT descriptor Switch to Workaround A, B, C, or D
Offsets are wrong after a download Project was rebuilt without regenerating the index DB Make the index DB a build artefact of the same script that emits the main DB
PEEK_BOOL returns the wrong bit Bit offset was 0–7 but the UDT stores the error bit at bit 2 inside byte 2 (so byte offset = payload start + 2, bit offset = 2) Confirm the intra-UDT bit position against the UDT definition; do not assume byte 0

FAQ

Can TypeOf() tell me the UDT type of a slot inside a global DB on an S7-1500?

No. TypeOf() resolves the static type the compiler assigned to the symbol used in the Variant. When the slot is read with PEEK_DWORD the Variant contains a DWORD, so TypeOf() returns DWORD, not the original UDT. The only way to recover a per-slot UDT name is to use one of the workarounds (pre-computed offset array, type-tagged envelope, per-type index DB, or generator sidecar).

Does PEEK_DWORD work on an optimized DB?

Yes. Area code 16#84 (DB) and a numeric DB number from DB_ANY_TO_UINT let PEEK_DWORD read four bytes at any byte offset, optimized block or not. The limitation is that the four bytes are a flat image with no type header, which is why TypeOf() cannot recover a UDT from them.

What is the smallest change to make the original FB work?

Switch to a pre-computed array of UInt offsets generated alongside the main DB, and drop the inner FOR loop. The original first-scan block becomes a constant lookup, and the New_Request branch stays exactly as written. No PEEK loop is needed.

Can I keep the DB layout variable and still have the runtime find UDT_Motor slots?

Yes, by adding a 2-byte TypeTag to every element (Workaround B). The runtime then peeks a UInt at every Nth byte, compares it to a constant for UDT_Motor, and records the payload offset. This costs 100% more memory in the DB but lets the FB iterate without an external index.

Is there a firmware version that exposes the per-slot UDT descriptor at runtime?

No. S7-1500 firmware through V3.1 (current as of the S7-1500 system manual 2022 edition) does not expose a runtime UDT catalogue through standard SCL. TIA Openness can read the project offline, but the controller only ships the compiled bit image and the symbol table for tagged access, never a per-offset type table that the application can walk with PEEK.

Back to blog