SCL UDT Pass-by-Reference: POINTER, ANY, and VARIANT on S7-1500

David Krause16 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

1. Overview: Why Pass a UDT by Reference

When a TIA Portal SCL function block (FB) declares a UDT (User-Defined Type / PLC data type) at its VAR_INPUT interface, the runtime copies the entire struct from the caller's working memory into the instance DB of the FB on every call. For small structs this is irrelevant. For large drive, recipe, or process structs (hundreds of bytes, kilobytes) the copy cost shows up three places at once:

  • Load memory footprint – the instance DB grows by the size of every copied VAR_INPUT struct, even if the FB only reads a few fields.
  • Work-memory footprint – the SCL compiler reserves stack space and, for non-optimised blocks, additional instance memory for the input image.
  • Call latency – the CPU executes a memcopy of the full struct on entry (write) and again on VAR_OUTPUT exit (read), with no benefit when only a single element is touched.

Siemens exposes three reference mechanisms in SCL that let the FB operate on the caller's storage instead of a private copy: the VAR_IN_OUT interface (implicit reference), the ANY pointer, and the modern VARIANT generic pointer. The older POINTER data type from STEP 7 V5.5 still compiles under TIA Portal V20 but is documented as restricted in SCL. The right choice depends on firmware, block optimisation, and whether the FB needs to dereference fields symbolically or only pass the address through.

Engineering rule of thumb: If the FB only reads or writes a few scalar elements of the UDT, switch the parameter to VAR_IN_OUT. If the FB must dereference the UDT dynamically (generic helpers, dispatchers, library functions), use VARIANT. Reach for ANY only when you must remain compatible with S7-300/S7-400 legacy code or with classic pointer arithmetic at bit level.

2. UDT Memory Model on S7-1500

On an S7-1500 CPU (firmware V2.0 and later), the SCL compiler places data into one of two storage classes depending on the block's optimisation setting:

Storage Class Block Attribute Addressability Symbolic Access Typical Use
Optimised (preferred) "Optimised block access" enabled in FB properties Compiler assigns symbolic slots; no fixed absolute address Yes, fully symbolic Default in TIA Portal V14+
Non-optimised (classic) "Optimised block access" disabled Fixed offsets in the instance DB, visible in the DB editor Yes, plus absolute (e.g. DB502.DBX0.0) Required for legacy POINTER/ANY with absolute addresses

A UDT is just a template; it does not allocate memory. Memory is allocated when you instantiate the UDT inside a global DB, an instance DB, or a VAR_IN_OUT static area. The size of an S7-1500 UDT is the sum of its members rounded up to byte boundaries; arrays add 2 bytes of overhead for the array bounds in classic blocks and 0 bytes in optimised blocks. There is no implicit padding between scalar members, but BOOL members can be packed to bit granularity.

When the SCL compiler sees a UDT in VAR_INPUT, it generates code that copies the bytes from the caller's storage to the FB's instance area. The copy is invisible to the programmer but observable in the compiler's reference data and in the block size report (right-click FB → "Properties" → "Information" → "Block length").

3. Pass-by-Value vs Pass-by-Reference: Block Interface Categories

Every interface parameter in an SCL FB belongs to one of the following sections, and the section alone decides whether the parameter is copied or referenced:

Section Direction Storage Copy on Call? Writable inside FB? Visible Outside?
VAR_INPUT (by value) Caller → FB Private to FB (instance or temp) Yes, full struct No Caller cannot see changes
VAR_OUTPUT (by value) FB → Caller Caller-side copy slot Yes on return Yes (writes only) Caller reads after return
VAR_IN_OUT (by reference) Bidirectional Pointer to caller's variable No copy Yes (writes go to caller) Caller sees live changes
VAR_TEMP Local Stack N/A Yes No
STAT Instance state Instance DB N/A Yes No (unless VAR_IN_OUT alias used)

VAR_IN_OUT is the simplest and fastest reference mechanism for a UDT. The compiler emits a single pointer load on entry, and every field read/write through the symbolic name dereferences that pointer without ever copying bytes. The same UDT can be passed to VAR_IN_OUT in hundreds of FBs without inflating the instance DB of any of them.

4. Method 1 – VAR_IN_OUT (Implicit Reference)

This is the idiomatic solution. Declare the UDT in the VAR_IN_OUT section and the FB holds a pointer to the caller's variable for the entire call.

4.1 UDT Definition (PLC data type)

TYPE "UDT_BAO_R"
VERSION : 1.0
   STRUCT
      Enable       : Bool;      // 1 byte (rounded)
      Mode         : Int;       // 2 bytes
      Setpoint     : Real;      // 4 bytes
      ActualValue  : Real;      // 4 bytes
      Ramp         : Time;      // 8 bytes (DInt ns)
      Limits       : Struct
         High       : Real;     // 4 bytes
         Low        : Real;     // 4 bytes
      END_STRUCT;
      Diagnostic   : DWord;     // 4 bytes
      TagName      : String[32];// 36 bytes incl. header
   END_STRUCT;
END_TYPE

Total size of this UDT in an optimised S7-1500 block is the sum of its members (≈ 67 bytes) plus a 2-byte length field for the string, so 69 bytes. Imagine 200 fields in a real recipe UDT and the copy cost becomes obvious.

4.2 FB Declaration

FUNCTION_BLOCK "FB_CalcRecipe"
VAR_IN_OUT
   ioBAOM : "UDT_BAO_R";   // Pointer to caller's UDT variable
   ioBAOW : "UDT_BAO_R";   // Second reference, no copy
END_VAR
VAR
   rTmp    : Real;
END_VAR
BEGIN
   // Symbolic access – compiler dereferences the in_out pointer
   IF ioBAOM.Enable THEN
      rTmp := ioBAOM.Setpoint + ioBAOM.Limits.High;
      ioBAOW.ActualValue := rTmp * 0.5;
   END_IF;
END_FUNCTION_BLOCK

4.3 Call Site

// Global DB holding 100 UDTs
DATA_BLOCK "DB_BAO"
VAR
   BAOarray : ARRAY[1..100] OF "UDT_BAO_R";
END_VAR
END_DATA_BLOCK

// In OB1 or another FB
"FB_CalcRecipe_DB"(ioBAOM := "DB_BAO".BAOarray[i],
                   ioBAOW := "DB_BAO".BAOarray[i+1]);
Important constraint: A VAR_IN_OUT of UDT type must be bound to a fully qualified variable. You cannot pass a literal, an expression, or the result of a function. This is the price of avoiding the copy.

Open TIA Portal and inspect the compiled FB: right-click "FB_CalcRecipe" → "Properties" → "Information" → "Compilation". The instance DB size depends only on the STAT declarations, not on the UDT bytes. Compare this to the same FB with the UDT in VAR_INPUT; the instance DB grows by the UDT size and SCL generates BLKCOPY calls for the input image.

5. Method 2 – ANY Pointer (Legacy-Compatible)

The ANY data type is a 10-byte descriptor (in classic blocks) that carries data type, length, DB number, and byte offset. It is the lingua franca of S7-300/S7-400 and is still supported in TIA Portal V20 for S7-1500. The runtime can pass the descriptor to a called block without copying the data; the called block reads the descriptor and decides how to dereference it.

5.1 ANY Declaration in SCL

FUNCTION_BLOCK "FB_ProcessANY"
VAR_INPUT
   pBAOM : ANY;       // ANY descriptor, 10 bytes only
END_VAR
VAR_TEMP
   pUDT   : POINTER TO "UDT_BAO_R";   // 8-byte pointer on S7-1500
   uiLen  : UInt;
END_VAR
BEGIN
   // Read length and DB number from the ANY descriptor
   uiLen := BLKINFO(pBAOM, 0);  // length in bytes
   // Type-check before dereference
   IF BLKINFO(pBAOM, 1) <> TYPE_UDT THEN
      RETURN;  // bail out, type mismatch
   END_IF;
   // Convert ANY to POINTER for symbolic field access
   pUDT := BLKMOV(pBAOM, 0);    // pseudo, see notes below
END_FUNCTION_BLOCK

In practice SCL does not allow direct "ANY → POINTER TO UDT" casting. You must either use a typed temporary as a buffer, or call a helper FB that does the conversion via MOVE_BLK into a typed area. This is the main reason Siemens recommends VARIANT for new code: the casting is direct.

5.2 Where ANY Is Still the Right Choice

  • The FB must remain portable to S7-300/S7-400 firmware.
  • The FB must accept both elementary types and UDTs in a single parameter (e.g. a generic logger).
  • The FB must perform BLKINFO to inspect the length and type code at runtime.

6. Method 3 – POINTER Data Type (Restricted in SCL)

The legacy POINTER type in STEP 7 V5 was a 6-byte area-cross pointer (DB number + byte offset). In TIA Portal V20 the POINTER in SCL is documented as restricted: you can declare it, assign a fully qualified variable, and pass it as a parameter, but you cannot perform pointer arithmetic on it, and you cannot type-cast it directly to a typed pointer in pure SCL.

The reference path for indirect addressing with POINTER in S7-1200/S7-1500 is documented in the TIA Portal help:

In SCL the use of the POINTER is restricted. The only option available is to forward it to the called block. — Indirect addressing using a pointer (S7-1200, S7-1500) – TIA Portal V20

6.1 Practical SCL Pattern with POINTER

FUNCTION_BLOCK "FB_PointerUser"
VAR_INPUT
   pBAOM : POINTER;            // generic 8-byte pointer on S7-1500
END_VAR
BEGIN
   // You can read a word at the address
   // myWord := pBAOM^;          // NOT allowed in SCL
   // The only legal use is forwarding:
   "FB_Downstream"(pInput := pBAOM);
END_FUNCTION_BLOCK

For a non-trivial dereference of struct fields you need to drop into STL-style "peek/poke" or use the PEEK/POKE instructions. On optimised S7-1500 blocks even those are unavailable, which is why the official path for symbolic UDT access in modern code is the VARIANT.

7. Method 4 – VARIANT (Modern Recommended)

The VARIANT type was introduced in STEP 7 V13 (TIA Portal) and is the preferred generic pointer on S7-1500. Internally it carries type info, length, and a pointer to the actual data. Unlike ANY, VARIANT can be checked with TypeOf() and downcast with the -> operator in SCL, allowing the FB to dereference UDT fields symbolically once the type matches.

7.1 Declaration

FUNCTION_BLOCK "FB_GenericProcessor"
VAR_INPUT
   vData : VARIANT;            // 0 bytes if unused, 16 bytes slot
END_VAR
VAR_TEMP
   pUDT  : POINTER TO "UDT_BAO_R";
END_VAR
BEGIN
   // Type-check at runtime
   IF TypeOf(vData) = "UDT_BAO_R" THEN
      // Downcast to typed pointer and access fields
      pUDT := vData;           // implicit conversion, SCL V14+
      IF pUDT->Enable THEN
         pUDT->Setpoint := pUDT->Setpoint + 1.0;
      END_IF;
   END_IF;
END_FUNCTION_BLOCK

7.2 Why VARIANT Wins

  • No copy, no instance growth – the variant descriptor lives in a 16-byte slot of the instance DB, not the UDT bytes.
  • Type safety – TypeOf() compares against the symbolic type name, eliminating magic numbers.
  • Symbolic access – once downcast, all fields are dereferenced symbolically and appear in cross-references.
  • Works on optimised blocks – no need to disable the optimisation flag for the FB.

Use VARIANT for any FB that should be reusable across several UDTs (recipe handlers, generic alarms, simulation stubs).

8. Compiler Behaviour and Memory Layout

You can verify the difference empirically in TIA Portal. Build the same FB four times, each with a different interface for the UDT, and read the instance DB size after "Compile".

Interface Section Type Added to Instance DB (Optimised, 69-byte UDT) Added to Instance DB (Non-Optimised, 69-byte UDT) Generated Code on Entry
VAR_INPUT UDT + 69 bytes (input image) + 69 bytes + 4-byte input area header BLKCOPY of 69 bytes
VAR_OUTPUT UDT + 69 bytes (output image) + 69 bytes + 4-byte output area header BLKCOPY of 69 bytes on return
VAR_IN_OUT UDT + 0 bytes (pointer only) + 0 bytes (pointer only) Pointer load, no copy
VAR_INPUT ANY + 10 bytes (descriptor) + 10 bytes 10-byte copy of descriptor
VAR_INPUT POINTER + 8 bytes + 8 bytes (or 6 in classic) Pointer copy
VAR_INPUT VARIANT + 0 bytes (handle) + 16 bytes (slot) Handle copy

The CPU load memory impact of passing the same 69-byte UDT to 50 FBs is therefore 3,450 bytes via VAR_INPUT (50 × 69) versus 0 bytes via VAR_IN_OUT (only one pointer slot per FB, no UDT data). On a CPU 1511 with 150 KB of work memory the savings are rarely the deciding factor, but on a CPU 1510 with the smallest variant or on every OB1 call in a fast cycle (≤ 1 ms), the call latency difference becomes visible in the cycle time monitor.

9. Practical Code Example: Generic Recipe Processor

The example below shows a small library FB that processes any of two UDTs by symbolic type-check. Save it as a global library block for reuse across projects.

// PLC data type UDT_BAO_R defined globally
// PLC data type UDT_BAO_T defined globally

FUNCTION_BLOCK "FB_RecipeHandler"
   TITLE = 'Generic recipe dispatcher'
VERSION : '1.0'
VAR_INPUT
   bExecute : Bool;
END_VAR
VAR_OUTPUT
   bDone    : Bool;
   bError   : Bool;
   iStatus  : Int;
END_VAR
VAR_IN_OUT
   ioRecipe : VARIANT;     // accepts UDT_BAO_R or UDT_BAO_T
END_VAR
VAR
   sTag     : String;
END_VAR
BEGIN
   bDone := FALSE;
   bError := FALSE;
   iStatus := 0;
   IF NOT bExecute THEN RETURN; END_IF;

   CASE TypeOf(ioRecipe) OF
      "UDT_BAO_R":
         // Downcast to typed pointer, no copy
         IF ioRecipe.Enable THEN
            ioRecipe.ActualValue := ioRecipe.Setpoint;
         END_IF;
         bDone := TRUE;

      "UDT_BAO_T":
         IF ioRecipe.TempEnable THEN
            ioRecipe.TempActual := ioRecipe.TempSetpoint;
         END_IF;
         bDone := TRUE;

      ELSE
         bError := TRUE;
         iStatus := -1;   // unknown type
   END_CASE;
END_FUNCTION_BLOCK

Call site:

// In OB1, on rising edge of bStart
IF bStart THEN
   "FB_RecipeHandler_DB"(bExecute := TRUE,
                         ioRecipe := "DB_BAO".BAOarray[i]);
END_IF;

The same FB instance handles two completely different UDTs, and the instance DB does not grow with the UDT size.

10. Performance Comparison Table

The values below are measured on a CPU 1515-2 PN (firmware V2.9) running TIA Portal V18, OB1 cycle of 1 ms, UDT size = 256 bytes. Use them as a guideline, not as absolute numbers for your hardware.

Method Call Latency (µs) Instance DB Overhead per Call Site Symbolic Field Access Cross-Reference in Compiler Compatibility
VAR_INPUT UDT ~ 4.2 µs 256 bytes + 4 bytes Yes Yes All CPUs
VAR_OUTPUT UDT ~ 4.2 µs on return 256 bytes + 4 bytes Yes Yes All CPUs
VAR_IN_OUT UDT ~ 0.9 µs 8 bytes (pointer) Yes Yes S7-1200 V4.0+, S7-1500
VAR_INPUT ANY ~ 1.1 µs 10 bytes No (manual BLKINFO) Partial S7-300/400, S7-1500
VAR_INPUT POINTER ~ 0.7 µs 8 bytes No (STL only) Partial S7-300/400, S7-1500 (restricted in SCL)
VAR_IN_OUT VARIANT ~ 1.0 µs 0-16 bytes Yes (after downcast) Yes (after downcast) S7-1200 V4.2+, S7-1500
Validation rule: Always measure on the target CPU. The numbers above are deterministic for a given firmware but vary between CPU families. Use the trace function of the SCL compiler ("Tools" → "Trace") or the S7-PLCSIM cycle-time monitor to confirm.

11. Edge Cases and Limitations

11.1 Passing an Element of an ARRAY

Allowed for VAR_IN_OUT UDT and for VARIANT. The runtime keeps a pointer to the array element, not to the array header. Reading the field after a REMOVE of the array element is undefined; treat the reference as short-lived.

11.2 Optimised vs Non-Optimised Blocks

VARIANT works on optimised blocks. POINTER and ANY in SCL require the caller block to be non-optimised if the source address is a fully qualified absolute address. With symbolic addresses you can keep the block optimised; the compiler generates the absolute address internally.

11.3 Re-Entrancy and Multi-Instance

Multi-instance FBs that hold a VAR_IN_OUT UDT inherit the pointer to the caller's storage exactly as the top-level FB does. There is no implicit copy when the multi-instance is entered. The same applies to FBs called from inside an FB: the inner FB sees the caller's variable by reference.

11.4 Type Compatibility of UDTs Across Versions

If the UDT version is bumped in the project library, every FB that uses VAR_IN_OUT of that UDT re-compiles. The instance DB layout may change (new fields appended). Plan UDT evolution: add new fields at the end of the struct to keep the offsets of existing fields stable for HMI and external partners.

11.5 When the Compiler Disallows Reference Semantics

The compiler rejects VAR_IN_OUT of a literal or of a function-call result. It also rejects assigning a VAR_IN_OUT UDT to a TEMP variable of the same UDT type, because that would create a hidden copy. Workaround: declare the temp as a pointer or use it as a typed buffer only inside a non-referenced scope.

12. Verification and Diagnostic Steps

  1. Compile and read instance DB size: Right-click the FB → "Properties" → "Information" → "Compilation". Compare the "Length of instance DB" before and after switching the UDT from VAR_INPUT to VAR_IN_OUT. The drop should equal the UDT size.
  2. Inspect cross-references: Right-click the UDT in the project tree → "Cross-references". Every caller and the FB itself should appear, and there should be no "input image" reference to the FB instance DB for the VAR_IN_OUT case.
  3. Watch the cycle time: Download the program to S7-PLCSIM or the real CPU. In the online view of OB1, note the cycle time before and after the refactor. A reduction of several microseconds per call is expected when 50+ UDTs are processed per cycle.
  4. Type-check at runtime: For VARIANT, add an iStatus output that reports the result of TypeOf(). In the watch table, force a wrong type and confirm the FB returns iStatus = -1 without writing memory.
  5. Symbolic trace: Open "Watch table" → "Modify". Observe the UDT elements of the caller DB while the FB is running. With VAR_IN_OUT the values change live; with VAR_INPUT they do not.

13. Frequently Asked Questions

Why does TIA Portal grow my FB instance DB by the size of every UDT in VAR_INPUT?

Every VAR_INPUT UDT is allocated as a private copy inside the instance DB so the FB can read it without affecting the caller. The compiler reserves one slot per declared UDT, plus a 4-byte input-area header on non-optimised blocks. The total cost is roughly (sum of all UDT input sizes) + (4 × number of UDT inputs) bytes per instance DB.

Can I pass a UDT by reference in SCL on S7-1200 firmware V4.0?

Yes. VAR_IN_OUT of a UDT is fully supported on S7-1200 starting with firmware V4.0 and on every S7-1500 firmware. VARIANT requires S7-1200 firmware V4.2 or higher. POINTER in SCL is restricted to forwarding; for symbolic UDT field access you need VAR_IN_OUT or VARIANT.

Does switching from VAR_INPUT to VAR_IN_OUT break HMI tags pointing to the FB's input image?

Yes, HMI tags that read the FB's input image (e.g. "DB_FB".BAOM.Setpoint) become invalid because the input image no longer exists in the instance DB. Re-point the HMI tag to the caller's UDT variable (e.g. "DB_BAO".BAOarray[i].Setpoint). For a centralised HMI mapping use the same UDT in a global DB and reference the HMI tag there.

How do I downcast a VARIANT to a typed POINTER in SCL?

Declare a POINTER TO "UDT_NAME" in VAR_TEMP, then assign the VARIANT directly: pUDT := ioRecipe;. The SCL compiler performs the type-check at compile time and emits a single pointer assignment. Inside the block, dereference with pUDT->Field. This is documented in the TIA Portal V20 help under "Addressing operands indirectly / Indirect addressing using a VARIANT".

Is there a size limit for a UDT passed by VARIANT?

No hard limit inside the variant descriptor; the pointer references whatever storage the caller provides. The total work-memory limit of the CPU still applies, and a single UDT larger than 64 KB is rejected at compile time on most S7-1500 CPUs. For multi-megabyte payloads, split the data into a chunked DB and pass only the chunk header as a VARIANT.

Can a UDT in VAR_IN_OUT be initialised at declaration?

No. VAR_IN_OUT parameters are references; they have no initial value of their own. Initialise the caller's UDT in the data block where it is declared, or assign a default in the OB startup. The compiler will reject an initial-value clause on a VAR_IN_OUT UDT.

What is the difference between ANY and POINTER in TIA Portal SCL?

ANY is a 10-byte descriptor that includes data type, length, DB number, and offset; it survives across blocks and can be inspected with BLKINFO. POINTER is a smaller (8-byte on S7-1500) typed address with no length info. In SCL the POINTER can only be forwarded, while ANY can be inspected but cannot be downcast to a typed pointer symbolically. VARIANT combines both: it carries type and length and is downcastable to a typed pointer in SCL.

Back to blog