Mapping Physical I/O to Siemens UDT Tags in TIA Portal V14+

David Krause12 min read
SiemensTIA PortalTutorial / How-to
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

Mapping Physical I/O to Siemens UDT Tags in TIA Portal V14+

User-Defined Data Types (UDTs) in Siemens TIA Portal are the cleanest way to group repeated process signals (thermal-relay confirms, contactor confirms, coil outputs, timers) into a single data template. They shine on S7-1200 and S7-1500 controllers where you have identical "device records" - the example below uses five chemical-addition routes that all share the same field signature. The catch shows up the moment you try to bind a real %I0.0 or %Q0.0 to a tag inside a UDT instance, and the temptation is to reach for a MOVE block. That box is the wrong tool, and a deep dive into why - and what to use instead - follows.

Why a Direct MOVE from I/O to UDT Tag Fails

The TIA Portal compiler treats every BOOL tag the same way: it occupies one bit. A MOVE box on a BOOL source, however, is not legal in STEP 7 - the IEC 61131-3 MOVE is defined for word-level operands (BYTE, WORD, DWORD, LWORD, INT, DINT, REAL, etc.). You will see the editor grey it out or refuse to download with a "Type conflict in operand" message. Engineers therefore fall into one of two traps:

  1. They wrap the BOOL into a temporary WORD, MOVE it, and unwrap - producing a flurry of useless tags.
  2. They search for a "MOVE_BLK" that magically couples a UDT to the process image - but MOVE_BLK and FILL_BLK are byte-oriented, not BOOL-typed, so they too cannot address a UDT element bit by bit.

The right mental model is the opposite of MOVE: instead of pushing the I/O into the UDT, you expose each UDT element as a parameter of a Function Block (FB) and let the FB caller wire the physical address to the parameter at the call site. The UDT never owns a fixed physical address; it owns the shape of the data, and the FB owns the behaviour.

Prerequisites

  • TIA Portal V14 SP1 (V15, V15.1, V16, V17, V18, or V19 behaviour is identical for this pattern).
  • S7-1200 (any CPU from V2.0 firmware up - S7-1200 family page) or S7-1500 (any CPU from V1.0 firmware up).
  • Configured hardware with the digital input and output module already added to the device view so that the system tags %I0.0 and %Q0.0 are reserved.
  • Symbolic I/O access enabled (default on S7-1200/1500, configurable in PLC properties > General > Symbolic addressing).
  • Basic knowledge of LAD/FBD editor and the SCL snippet editor.

Step 1 - Define the UDT

In the project tree, right-click PLC_x > PLC data types > Add new data type. Name it OutputDevice (or Dispositivo_De_Salida as the original poster did) and build the structure that matches the repeated field signature. A typical chemical-route element looks like the table below.

Element Data type Meaning
ThermalRelayConfirm BOOL Hard-wired NO contact of the thermal overload
ContactorConfirm BOOL Auxiliary contactor confirm
BitOutput BOOL Command sent to valve or pump coil
ActivationTime TIME On-time accumulator
CycleCounter DINT Number of completed dosing events
LastFault WORD Bit-packed fault word
Tip. Order the UDT so the BOOL members come first, then BYTE/WORD groups, then larger scalars. This keeps the absolute byte offset predictable if you ever need to scatter the structure onto a digital slice (see the Scatter/Gather section below).

Step 2 - Build the Global Data Block from the UDT

Right-click Program blocks > Add new block > Data block. Choose Type: my UDT "OutputDevice". Open the DB and add five rows, each row's Data type being OutputDevice. Name them Route_1 through Route_5. The DB now has symbolic access "MyDB".Route_1.ThermalRelayConfirm through "MyDB".Route_5.LastFault. The UDT does not know which real I/O it will receive - the binding is still abstract.

Step 3 - Solution A: Function Block with IN/OUT Parameters (Recommended)

Create a Function Block called FB_ChemRoute with the interface shown below.

Section Name Type Direction Comment
Input i_bThermalConfirm BOOL IN Hard-wired thermal relay NO
Input i_bContactorConfirm BOOL IN Auxiliary contactor confirm
InOut io_Device OutputDevice IN_OUT The whole UDT instance for this route
Output o_bValveCommand BOOL OUT Physical output to valve or pump
Temp ton_Pulse TON_TIME STAT Local pulse timer

Inside the FB body (SCL view, drop down from LAD if you prefer), the assignment is the textbook one-liner the discussion thread calls out:

// SCL body of FB_ChemRoute
io_Device.ThermalRelayConfirm := i_bThermalConfirm;     // input binds to UDT element
io_Device.ContactorConfirm   := i_bContactorConfirm;
ton_Pulse(IN := i_bThermalConfirm,
         PT := T#2s);
io_Device.ActivationTime := ton_Pulse.ET;

// Decide coil command from accumulated time and confirmation bits
IF io_Device.BitOutput AND ton_Pulse.Q THEN
    o_bValveCommand := TRUE;
    io_Device.CycleCounter := io_Device.CycleCounter + 1;
ELSE
    o_bValveCommand := FALSE;
END_IF;

The crucial line is the first one: io_Device.ThermalRelayConfirm := i_bThermalConfirm;. It is an assignment, not a MOVE, and the editor is perfectly happy because both sides are BOOL. The assignment runs every scan, so the UDT inside the DB always mirrors the live input.

Step 4 - Call the FB Once per Route

Drop FB_ChemRoute into OB1 five times. Each call becomes a multi-instance (or you can mark them as single-instance with separate DBs - the multi-instance form is preferred because it shares one IDB and keeps the project tree clean). The parameter wiring is what actually fixes the I/O mapping:

// OB1 call wiring - LAD view (SCL equivalent shown for clarity)
// Call 1
FB_ChemRoute_1(
    i_bThermalConfirm   := %I0.0,                 // physical input I0.0
    i_bContactorConfirm := %I0.1,                 // physical input I0.1
    io_Device           := "MyDB".Route_1,        // UDT instance
    o_bValveCommand     := %Q0.0                  // physical output Q0.0
);

// Call 2
FB_ChemRoute_2(
    i_bThermalConfirm   := %I0.2,
    i_bContactorConfirm := %I0.3,
    io_Device           := "MyDB".Route_2,
    o_bValveCommand     := %Q0.1
);

// ...continue through Call 5 with %I0.6, %I0.7, %I1.0, %I1.1, %Q0.4

Note that %I0.0 is the absolute I/O address (decimal I0.0 in STEP 7 notation) - the leading percent sign is the TIA Portal symbol for "direct hardware access". You may also wire a symbolic tag that is mapped to %I0.0 in the PLC tags table. Either is valid; direct addresses are easier to read on first commissioning.

Common pitfall. Forgetting to tick the Snapshot of actual parameters checkbox in the FB properties disables the ability to change the wired address in RUN mode. For commissioning leave it unticked so that you can point i_bThermalConfirm to a different input from the watch table without recompiling.

Step 5 - Why This Pattern Beats Every Alternative

  • Reusability. Drop a sixth route anywhere, wire the new %I and %Q to the FB inputs, point io_Device at "MyDB".Route_6 - done. The same FB body runs for every route.
  • Symbolic debugging. In a watch table you can type "MyDB".Route_1.ThermalRelayConfirm and the value updates live. No need to remember which input pin a particular route uses.
  • Testability. Force i_bThermalConfirm := TRUE from the watch table to prove the interlock chain without the real relay being present - the FB does not know or care that the value is forced.
  • Portability. Move the program to a new CPU with a different slot layout, change the absolute I/O addresses at the call sites, the FB body does not change.

Solution B - Bulk-Transfer the UDT Image with MOVE_BLK

There is a legitimate case for moving the whole UDT in one go: a Profibus DP slave or a Profinet device hands you a 16-byte input slice, and you want that slice stored into a UDT instance. Use MOVE_BLK or, more idiomatically, the S7-1500 Serialize / Deserialize instructions. The caveat is the same: the destination must be byte-oriented, not BOOL-by-BOOL.

// Capture a 16-byte Profinet input slice into the UDT of Route_1
// Source: %I200..%I215 (16 bytes starting at PIB 200)
// Dest : "MyDB".Route_1 starting at byte 0
MOVE_BLK(IN  := P#I 200.0 BYTE 16,
         OUT := P#DB 100 DBW 0 BYTE 16);

Once the bytes are inside the DB you can scatter them back into symbolic names:

// Scatter a packed word into individual flags
"MyDB".Route_1.ThermalRelayConfirm := %IW200.%X0;    // bit 0 of input word 200
"MyDB".Route_1.ContactorConfirm   := %IW200.%X1;
"MyDB".Route_1.BitOutput           := %QW200.%X0;

The %X slicing operator on a word is legal in LAD/FBD/SCL and is the most compact way to fan a byte-or-word I/O region into individual UDT bits without an intermediate BOOL tag.

Solution C - Serialize / Deserialize for S7-1500

On S7-1500 firmware V1.8 and later (TIA Portal V14+ is the minimum) the Serialize block converts a structured tag (a UDT, an FB instance, a PLC data type) into a BYTE array, while Deserialize is the inverse. This is the cleanest way to push a complete UDT to a Profinet slot or to a partner CPU over PUT/GET.

// Serialize a UDT into a 32-byte buffer for OPC UA publishing
"fbSerialize"(SRC := "MyDB".Route_1,
              DST := "CommBuffer".Route1Raw);

Note that the source must be a structured tag, not a scalar - which is exactly what a UDT provides. The destination receives the byte image with no MOVE box and no manual %X slicing.

Scatter / Gather When a UDT Maps onto a Process Image

Sometimes the UDT element names mirror an existing slice of the process image, and you do not want to wire the FB at all - the UDT is the I/O. In that case the Scatter and Gather blocks available in the SCL instruction set (TIA Portal V15+) are useful. Scatter explodes a byte array into individual BOOL/DINT/REAL members of a UDT, and Gather recomposes them. Used correctly, the UDT looks as if it were a direct I/O image, but the underlying data lives in the DB and can be archived, forced, or traced.

Watch out. The Scatter/Gather blocks work on contiguous memory and will not tolerate packing gaps. If the UDT is not laid out with BOOLs first, then BYTE/WORD, then DWORD/REAL, the SCL compiler inserts hidden alignment bytes and your offsets drift. Laying the UDT out manually as in Step 1 is the only safe option.

Why the Original "Assign by Move" Question Fails

The original poster was essentially asking how to take I0.0 and MOVE it into a BOOL UDT element. The answer, already in the thread, is simply: do not MOVE it, assign it. The smallest correct unit in IEC 61131-3 is the BOOL, and the only legal operation to put a BOOL somewhere is the assignment :=. Anything else - a MOVE, a block move, a Variant cast - is fighting the type system. The UDT is a value, the FB is the function that consumes the value, and the call site is the place where value meets the real world.

Verification and Commissioning Checklist

  1. Compile the program (Project tree > PLC_x > Compile > Software). The build must complete with zero errors and zero warnings - any "uninitialised UDT access" or "parameter missing" warning indicates an FB instance was called without wiring the UDT element.
  2. Download to the CPU. In the Online > Diagnostics view confirm the operating mode is RUN and the status LED is green.
  3. Open a watch table and force %I0.0 := TRUE. Verify "MyDB".Route_1.ThermalRelayConfirm follows within one scan. Reset force.
  4. Toggle %I0.0 through the watch table. Verify %Q0.0 follows the logic in FB_ChemRoute. Use a logic analyser on the physical output to confirm the 24 V coil pulse.
  5. Monitor the CycleCounter in "MyDB".Route_1 - it should increment on every completed pulse.
  6. Repeat for routes 2 through 5 to confirm the assignment pattern generalises.
  7. From the Online > Back-up tab, save the project archive so the tested mapping is locked in.

Troubleshooting Matrix

Symptom Likely cause Fix
UDT element never updates FB is not being called, or io_Device wired to the wrong DB row Confirm five FB instances in OB1, check io_Device parameter shows "MyDB".Route_X
Compiler "MOVE not allowed for BOOL" Engineer tried to use MOVE on a single-bit tag Replace with assignment :=, or use %X slice on a word
Inputs are inverted vs. expected polarity NO/NC wired at the wrong point in the FB Move the inversion inside the FB (i_bThermalConfirm := NOT i_bThermalConfirm at the top of the body) so the UDT always holds the logical sense
CycleCounter wraps at 32767 Counter declared as INT instead of DINT Change UDT member to DINT - covers 2.1 billion cycles
Force on %I0.0 does not propagate to UDT FB instance is being called from an OB with lower priority, or symbolic access is disabled Check the OB1 call, enable symbolic I/O access in PLC properties
Download fails with "UDT length mismatch" UDT was edited in the project after download, PLC holds the old image Do a full download, not a delta, or perform a memory reset of the CPU first
Other routes flicker randomly when Route_1 is forced Both routes share the same UDT instance by accident Open the DB and check that each route row is a distinct UDT member, not the same instance

Field-Proven Caveats

  • Multi-instance FBs share their parent FB's IDB; if you make any UDT member RETAIN (e.g. CycleCounter), the entire IDB becomes retain - which is usually what you want, but be aware of it.
  • On S7-1200 firmware V4.0 and earlier, the Serialize / Deserialize blocks are not available - downgrade to MOVE_BLK + %X slicing.
  • Avoid using time-of-day or date-and-time in a UDT that is fed to Serialize; the layout is platform-specific and breaks cross-CPU compatibility.
  • Always declare a default value for every UDT element. TIA Portal will initialise the DB on first download, but a subsequent online delta download that changes the UDT structure may leave new members uninitialised until the next cold start.
  • If the controller is on Profinet and the device is wired through a Profisafe slice, never force %I addresses that belong to the F-slave - the safety stack will reject the force and may shut the device down.

FAQ

Why can't I use a MOVE box to copy a BOOL input into a UDT element?

The IEC 61131-3 MOVE instruction operates on word-level data types (BYTE, WORD, DWORD, INT, DINT, REAL). For BOOL, the only legal operation is the assignment operator :=. Wire the I/O to an FB input and write io_Device.ThermalRelayConfirm := i_bThermalConfirm; in SCL, or use a simple = coil in LAD to assign the value into a UDT-typed tag.

Do I have to write a separate Function Block for every UDT I create?

No. One FB body can drive five (or fifty) UDT instances through the IN_OUT parameter. The FB is a behaviour template; the UDT is a data template. Call the FB once per route, wire each call to a different io_Device element ("MyDB".Route_1 through "MyDB".Route_5), and the same body handles all five.

Can I bind an entire UDT to a process image partition in one go?

Not directly - a UDT is a structured tag and the process image is byte-addressed. The closest single-instruction pattern is Serialize on S7-1500 (V14+), or a MOVE_BLK plus %X slicing for S7-1200. Both are described in the Bulk-Transfer section above.

Does the same pattern work on S7-300/400 with STEP 7 V5.x?

Yes, the assignment := on BOOL works identically. The only difference is the editor: in STEP 7 V5.x the UDT is edited in the data-type editor and called "UDT1", "UDT2", etc. The FB pattern is unchanged. Serialize/Deserialize are not available there - use MOVE_BLK.

What happens to UDT tag values during a CPU warm restart?

Non-retain UDT members are zeroed. Members declared Retain keep their last value, but the FB is still called from OB1 on the first scan, so the input assignments io_Device.ThermalRelayConfirm := i_bThermalConfirm; are re-executed on that first scan and the live I/O overwrites the retained value. Counters and accumulators should be declared DINT and Retain; real-time inputs should not.

Back to blog