PLC Programming with IEC 61131-3: Structured Text and Function Blocks

When I commissioned my first Siemens S7-1200 line fifteen years ago, I spent two weeks wrestling with Ladder Diagram for what should have been simple analog scaling and recipe handling. A senior controls engineer looked at my sprawling rungs, rewrote the core logic in twenty lines of Structured Text, and wrapped it in a reusable Function Block. That moment changed how I approach PLC work. IEC 61131-3 is not just a standard on paper — it is the common language that lets a program written for a Beckhoff CX today run conceptually the same on an Allen-Bradley ControlLogix or a CODESYS-based Wago tomorrow. For anyone coming from embedded C or Python into Industrial IoT, understanding Structured Text (ST) and Function Blocks (FBs) is the fastest path from toggling an output to building maintainable, scalable automation that talks cleanly to higher-level systems.

Why IEC 61131-3 Still Governs Factory Floor Logic After 30 Years

In my experience, newcomers ask why we still use a standard first published in 1993 when we have modern languages and edge computing. The answer is determinism, longevity, and portability. A PLC must guarantee that an emergency stop is evaluated within a few milliseconds, every single scan, for twenty years in a dusty cabinet. IEC 61131-3 defines not only five programming languages but a complete software model: configurations, resources, tasks, and Program Organization Units (POUs) that make that determinism explicit and vendor-independent.

The standard's third edition (2013) clarified object-oriented extensions and tightened data typing, but the core model remains. I've ported a bottling line sequence from a Schneider Modicon M241 to a Beckhoff TwinCAT 3 system with only minor syntax adjustments because both adhered to the POU model. Try doing that with a proprietary microcontroller framework. That portability protects the asset owner; the plant runs for decades, while the IT hardware above it churns every three years.

How Tasks and Scan Cycles Dictate Real-Time Behavior

Every IEC 61131-3 runtime executes tasks cyclically: read inputs, execute program, write outputs, handle communication, repeat. This scan cycle is typically 1-50 ms depending on hardware and program size. Unlike the event-driven world of FreeRTOS or Zephyr where you manage your own scheduling, the PLC scheduler is fixed. I've seen beginners treat ST like a free-running C program and create unbounded WHILE loops that trigger watchdog faults. Structured Text inside a PLC task must always complete within the task interval. If your code needs 5 ms to execute and your cyclic task is set to 10 ms, you are fine; if it sometimes needs 12 ms, you will get jitter or a CPU halt.

Where IEC 61131-3 Fits in the Industrial IoT Stack

The PLC is no longer an isolated island. It is the deterministic edge of your Industrial IoT architecture. It handles the sub-50 ms control loop, while an IIoT gateway or edge PC handles MQTT publishing, historical logging, and cloud connectivity. Keeping that separation clean is critical. I always keep time-critical interlocks and PID inside the PLC and push analytics and OPC UA for Industrial Communication: Information Models and Security mapping to a non-real-time task or external gateway. That way a cloud disconnect never stops a conveyor.

Decoding PLC Program Organization Units: Programs, Function Blocks and Functions

The most confusing concept for developers coming from embedded C is the difference between a Function (FUN), a Function Block (FB), and a Program (PRG). They look similar in the editor, but they behave very differently in memory and execution, and choosing the wrong one creates subtle bugs that are hard to commission.

A Function is stateless. Think of it like a pure C function: give it inputs, it returns a single output value, no memory between calls. Standard examples are ADD, SQRT, or your own SCALE_ANALOG function. Every time you call it with the same inputs you get the same result, and it allocates no persistent instance data.

A Function Block, by contrast, has memory. It maintains internal state across scans. This is essential for anything with history: a motor starter with feedback, a debounce timer, a PID controller. When you declare a Function Block, you must instantiate it — create a named instance with its own data block. I've found that beginners who call a TON timer function block without instantiating it correctly end up with timers that interfere with each other because they reused the same instance data unintentionally. On Siemens TIA Portal or CODESYS, each FB instance gets its own DB or struct in memory.

A Program is the top-level POU assigned to a task. You can have multiple Programs linked to different tasks (e.g., a fast 10 ms motion task and a slow 500 ms HMI handling task). In CODESYS and TwinCAT, you might have PRG_Main called by MainTask, which then calls instances of your FBs. Understanding this hierarchy prevents the common mistake of putting all logic in one monolithic Program that becomes impossible to test.

Variable Scoping That Prevents Plant-Floor Side Effects

IEC 61131-3 enforces strong typing and explicit VAR sections, which I've come to appreciate on large projects. Variables declared in VAR live only within that POU. VAR_INPUT and VAR_OUTPUT define the interface. VAR_STAT (or just VAR inside an FB) retains value between calls. VAR_TEMP is temporary and re-initialized each scan. Use VAR_GLOBAL sparingly. In my early projects I used global flags for everything, and tracing a faulty interlock meant searching 40 POUs. Now I pass data explicitly through FB interfaces; it makes unit testing and Modern SCADA Architecture: From Legacy to Cloud-Connected Systems integration far cleaner because the SCADA can map directly to named FB outputs.

Structured Text Syntax That Actually Compiles on Real Hardware

Structured Text looks like Pascal, and if you know C or Python you will be productive in a day. But certain habits from IT programming will not compile or will behave unexpectedly on a PLC runtime. The syntax is deliberately constrained to be deterministic.

Data types are stricter than in Python. You cannot implicitly mix INT and REAL. You must use conversion functions like INT_TO_REAL or REAL_TO_INT. BOOL, BYTE, WORD, DWORD, INT, DINT, REAL, LREAL, TIME, DATE_AND_TIME, and STRING are all distinct. I've seen a line of production down because someone added two INTs that overflowed at 32767 and rolled over to negative. Use DINT for counters and LREAL for high-precision calculations unless memory is extremely constrained on a small brick PLC.

Control flow is standard but with PLC-specific semantics:

// Example: Analog input scaling and alarm generation
// Runs every scan in a 50ms task
VAR
    rawInput    : INT := 0;          // 0..27648 from 4-20mA card
    scaledValue : REAL := 0.0;       // Engineering units 0..100.0 %
    highAlarm   : BOOL := FALSE;
    lowAlarm    : BOOL := FALSE;
END_VAR

// Scale raw ADC to engineering units
scaledValue := INT_TO_REAL(rawInput) / 27648.0 * 100.0;

// Hysteresis-based alarming - avoids chatter on noisy signals
IF scaledValue > 90.0 THEN
    highAlarm := TRUE;
ELSIF scaledValue < 88.0 THEN
    highAlarm := FALSE;
END_IF;

IF scaledValue < 10.0 THEN
    lowAlarm := TRUE;
ELSIF scaledValue > 12.0 THEN
    lowAlarm := FALSE;
END_IF;

// FOR loops must have constant bounds on some platforms
FOR i := 1 TO 5 DO
    recipeBuffer[i] := recipeBuffer[i] * 1.05;
END_FOR;

A few practical rules I've learned from compilation errors on real targets: Always terminate with a semicolon. Assignment is := not =. Equality is = not ==. CASE statements require an ELSE branch if you want to handle unexpected values safely. And avoid dynamic array indexing with non-constant indices on SIL-rated controllers — some safety PLCs will reject it during verification.

Edge Cases Around Floating Point and String Handling

On many small PLCs (e.g., Allen-Bradley Micro820, Siemens S7-1200), REAL operations are software-emulated and 5-10x slower than DINT. I once saw a 20 ms task overrun because a well-meaning engineer added a complex REAL polynomial for temperature linearization in the fast motion task. Move floating-point math to a slower task if possible. Strings are also expensive. Avoid using CONCAT or FIND in a fast cyclic task; pre-allocate STRING[80] with a fixed length rather than using unbounded STRING types, which can fragment the PLC's limited heap on CODESYS-based runtimes.

Designing Reusable Function Blocks for Motors, Valves and PID Loops

The real power of IEC 61131-3 is not writing long ST programs, but building a library of well-tested Function Blocks that you instantiate dozens of times. When I design a material handling system with 30 identical conveyors, I write one FB_Conveyor and create 30 instances: fbConveyor1 through fbConveyor30. Each instance holds its own state, auto/manual mode, fault timers, and hour meter.

A good Function Block has a clear interface, internal state management, and no hidden dependency on globals. Here is a simplified motor starter FB I use as a teaching example that includes start permissive, feedback monitoring, and unified fault handling:

FUNCTION_BLOCK FB_MotorStarter
VAR_INPUT
    startCmd    : BOOL;     // Command from sequence or HMI
    stopCmd     : BOOL;     // Stop command
    feedback    : BOOL;     // Contactor auxiliary / VFD running feedback
    enable      : BOOL := TRUE; // Permissive from safety
END_VAR
VAR_OUTPUT
    runOutput   : BOOL;     // Physical output to contactor/VFD
    isRunning   : BOOL;
    fault       : BOOL;
    faultCode   : INT;
END_VAR
VAR
    feedbackTimer : TON;    // Standard on-delay timer FB
    runState      : BOOL := FALSE;
END_VAR

// Main logic
IF NOT enable THEN
    runState := FALSE;
    fault := FALSE;
ELSIF stopCmd THEN
    runState := FALSE;
ELSIF startCmd AND NOT fault THEN
    runState := TRUE;
END_IF;

// Drive output
runOutput := runState;

// Feedback monitoring with 2s timeout
feedbackTimer(IN := runState AND NOT feedback, PT := T#2S);

IF feedbackTimer.Q THEN
    fault := TRUE;
    faultCode := 16#0001; // Feedback timeout
    runState := FALSE;
ELSIF feedback THEN
    isRunning := TRUE;
ELSE
    isRunning := FALSE;
END_IF;

// Reset fault when stopped and feedback OK
IF NOT runState AND NOT feedback THEN
    feedbackTimer(IN := FALSE);
    IF NOT startCmd THEN
        fault := FALSE;
        faultCode := 0;
    END_IF;
END_IF;

Notice the use of the standard TON timer Function Block as a nested instance. This is normal and powerful — FBs can contain other FBs. The key practice is to declare feedbackTimer inside the VAR block of FB_MotorStarter so each motor gets its own timer. I've audited code where a single global timer was reused for 10 motors, causing random faults because the .Q bit was overwritten each scan.

Extending Function Blocks for Analog Control

For analog loops, don't reinvent the wheel. Use the vendor's PID Function Block (e.g., PID_Compact on Siemens, FB_PID on CODESYS) and wrap it in your own FB that handles mode switching, setpoint ramping, and bumpless transfer. In one wastewater project, I wrapped the native PID in an FB_AerationControl that added dissolved oxygen averaging, anti-windup, and fallback to manual speed if the DO sensor went bad. That wrapper meant operators saw one consistent faceplate on the HMI for all eight aeration basins, and the SCADA needed only one tag structure.

When you build a library, document the failure modes. What does your FB do if the feedback wire breaks? What if someone sets a TIME parameter to zero? Defensive programming at the FB boundary saves hours of field debugging. I always initialize outputs in the declaration and add a first-scan check using a BOOL firstScan flag that resets timers on warm restart.

Connecting PLC Logic to SCADA and IIoT Gateways Without Breaking Determinism

A modern line does not end at the PLC's I/O rack. The same PLC that controls the filler must expose clean, structured data for SCADA, historians, and IIoT gateways. IEC 61131-3's structured data types (STRUCT, ARRAY, ENUM) are your bridge. Define a STRUCT for each asset type and use it consistently.

I define types like this in a shared TYPES file:

TYPE ST_MotorStatus :
STRUCT
    running     : BOOL;
    fault       : BOOL;
    faultCode   : WORD;
    runHours    : REAL;
    autoMode    : BOOL;
    speedFeedback : REAL;
END_STRUCT
END_TYPE

VAR_GLOBAL
    line1_Motors : ARRAY[1..15] OF ST_MotorStatus;
    line1_Alarms : ARRAY[1..50] OF ST_Alarm;
END_VAR

With this structure, mapping to OPC UA becomes trivial. Instead of exposing 200 flat tags like Motor1_Run, Motor1_Fault, you expose an array of structured objects. When referencing OPC UA for Industrial Communication: Information Models and Security, you can model each ST_MotorStatus as an OPC UA ObjectType, which gives you browsing, alarming, and historical access out of the box. I usually run the OPC UA server on the PLC itself (on Beckhoff or newer Siemens S7-1500) or on a dedicated gateway PLC that polls via Industrial Ethernet: PROFINET, EtherCAT and TSN for Real-Time Control and publishes upstream via MQTT. For MQTT payloads, I follow the MQTT Specification for clean JSON encoding and keep the publish in a low-priority 1-second task so it never blocks the motion task.

The rule I enforce on every project: no SCADA or IIoT system should write directly to a physical output. All writes go through the PLC's FB interface with interlocks. The HMI sets fbConveyor5.startCmd, not %Q0.3. That way the PLC retains authority over safety and sequencing, and you can simulate the entire line without hardware.

IEC 61131-3 Language Best For Strengths Limitations for IIoT Integration
Structured Text (ST) Complex calculations, recipes, data handling, string parsing Compact, familiar to C/Pascal devs, excellent for loops and math Harder for electricians to troubleshoot online without ST experience
Ladder Diagram (LD) Discrete interlocks, safety logic, relay replacement Visual, widely understood by maintenance, fast online monitoring Verbose for analog scaling; poor for structured data models
Function Block Diagram (FBD) Signal flow, process control, drive logic Graphical signal flow, good for PID and analog chains Becomes messy with large networks; version control difficult
Sequential Function Chart (SFC) State machines, batch sequences, startup/shutdown Explicit states and transitions, ideal for ISA-88 phases Not all runtimes support robust SFC; hidden actions can be confusing
Instruction List (IL) - Deprecated Legacy code maintenance only Very compact on old hardware Removed in 3rd edition; do not use for new projects

I've learned to be pragmatic: use LD for the e-stop chain so any technician can see it, SFC for the main bottle-filling sequence where states are explicit, and ST inside Function Blocks for the heavy lifting. The IEC languages are designed to interoperate; a Program written in SFC can call an FB written in ST that internally uses LD for interlocks. Choose by readability, not ideology.

Troubleshooting Structured Text: Scan Cycle Pitfalls I See Beginners Hit

Most ST bugs I debug are not syntax errors but scan-cycle misunderstandings. The PLC does not execute continuously; it executes your code once per scan and then sleeps until the next tick.

First pitfall: edge detection done wrong. Writing IF startButton THEN counter := counter + 1; will add one per scan while the button is held — 50 increments in one second on a 20 ms task. Use the standard R_TRIG and F_TRIG Function Blocks for one-shot detection:

VAR
    btnRTrig : R_TRIG;
    pulseCount : DINT := 0;
END_VAR

btnRTrig(CLK := startButton);
IF btnRTrig.Q THEN
    pulseCount := pulseCount + 1;
END_IF;

Second pitfall: initializing inside the cyclic code. If you write myArray[1] := 0; at the top of your Program unconditionally, you will overwrite it every scan and wonder why it never counts. Initial values belong in the VAR declaration or behind a firstScan check. On CODESYS, I use:

IF firstScan THEN
    firstScan := FALSE;
    initRecipes();
    resetTimers();
END_IF;

Third pitfall: blocking calls and infinite loops. A WHILE loop that waits for a sensor will freeze the entire task. Never write WHILE NOT sensor DO; END_WHILE; Instead, use a state machine or SFC step with a transition condition sensor = TRUE and a timeout alarm via TON. I've seen a temperature ramp WHILE loop stall a safety task for 800 ms and trip the CPU watchdog.

Debugging tips that have saved me: use the PLC's cross-reference to see where a variable is written — if more than one place writes to an output, you have a coil conflict. Use watch tables with consistent triggering (force the task to SINGLE CYCLE if your IDE supports it). And always leave a free 20-30% task margin; if your MainTask averages 8 ms on a 10 ms cycle, you have no room for a firmware update that adds jitter.

Frequently Asked Questions

Should I learn Structured Text or Ladder Diagram first as a beginner?

Learn both, but start with Ladder for discrete logic and then move to Structured Text for anything analog, string, or data-heavy. In my experience, technicians debug Ladder faster on the plant floor, while engineers maintain Structured Text faster for complex algorithms. Most modern plants require you to read and write both, as IEC 61131-3 projects mix languages by task. If you come from Arduino or embedded C, ST will feel more natural initially.

What is the difference between a Function and a Function Block in IEC 61131-3?

A Function (FUN) is stateless and returns a single value — like SQRT or a custom scaling calculation — and has no memory between calls. A Function Block (FB) has persistent memory (instance data) and can have multiple outputs, internal timers, and state. You must instantiate an FB (create fbMotor1 : FB_MotorStarter) before calling it, and each instance retains its values across scans. Use Functions for calculations and FBs for equipment control with history.

Can Structured Text handle real-time motion or safety-critical logic?

Yes, but with discipline. Structured Text can run in a high-priority cyclic task (e.g., 1-4 ms) for motion, but avoid floating-point, strings, and loops in that task. For safety, use certified safety Function Blocks and Ladder in a dedicated safety runtime (e.g., Siemens Fail-Safe, Beckhoff TwinSAFE) rather than writing your own ST safety logic. The safety runtime is independently certified and executed separately from standard logic.

How does PLC Structured Text integrate with MQTT and cloud platforms?

Do not publish MQTT directly from the fast control task. Expose your process data via structured types or OPC UA, then have a slower task (500 ms to 1 s) or an external gateway (like a Raspberry Pi with Node-RED or a dedicated IIoT gateway) read those values and publish JSON payloads to the broker per the MQTT Specification. This keeps deterministic control decoupled from non-deterministic networks, so a broker outage never stops your machine.

Related Articles

References & Standards: FreeRTOS Documentation · Zephyr Project Documentation · MQTT Specification