How to Create Function Blocks in Structured Text: A Practical Tutorial

Function Blocks are the key to reusable, modular PLC code. Learn to create and use them effectively in Structured Text.

What Are Function Blocks?

Function Blocks (FBs) are one of the most powerful features in IEC 61131-3 PLC programming. They encapsulate logic with persistent internal state, meaning they remember their values between scan cycles — unlike plain Functions, which are stateless.

Think of a Function Block as a reusable "mini-program" with:

  • Inputs: Values fed into the block
  • Outputs: Results produced by the block
  • Internal variables: State that persists between calls
  • Creating a Function Block

    Here's a basic motor controller Function Block:

    FUNCTION_BLOCK FB_MotorController
    VAR_INPUT
        Start     : BOOL;
        Stop      : BOOL;
        FaultReset : BOOL;
    END_VAR

    VAR_OUTPUT Running : BOOL; Faulted : BOOL; RunHours : REAL; END_VAR

    VAR startDelay : TON; hourCounter : REAL := 0.0; scanTime : REAL := 0.01; // 10ms scan END_VAR

    // Fault handling IF FaultReset THEN Faulted := FALSE; END_IF;

    // Motor control logic IF NOT Faulted THEN startDelay(IN := Start AND NOT Stop, PT := T#2s); Running := startDelay.Q; ELSE Running := FALSE; END_IF;

    // Track run hours IF Running THEN hourCounter := hourCounter + (scanTime / 3600.0); RunHours := hourCounter; END_IF; END_FUNCTION_BLOCK

    Using Function Block Instances

    Each Function Block usage creates an instance with its own independent state:

    PROGRAM Main
    VAR
        pump1 : FB_MotorController;
        pump2 : FB_MotorController;
        conveyor : FB_MotorController;
    END_VAR

    // Each instance tracks its own state pump1(Start := btn_pump1_start, Stop := btn_pump1_stop); pump2(Start := btn_pump2_start, Stop := btn_pump2_stop); conveyor(Start := autoMode, Stop := NOT autoMode);

    // Read outputs IF pump1.Running AND pump2.Running THEN systemReady := TRUE; END_IF;

    totalRunHours := pump1.RunHours + pump2.RunHours + conveyor.RunHours; END_PROGRAM

    Function Block vs. Function

    FeatureFunction Block (FB)Function (FC)
    StatePersistent (remembers values)Stateless (resets each call)
    InstancesMultiple independent copiesSingle definition
    Use caseTimers, controllers, state machinesMath, conversions, utilities
    MemoryEach instance uses memoryShared memory

    Best Practices

  • One responsibility per FB: A motor controller shouldn't also handle HMI display
  • Clear naming: Prefix with FB_ — e.g., FB_ValveControl, FB_PIDLoop
  • Document interfaces: Comment what each input/output does
  • Limit internal state: Only keep state that truly needs to persist
  • Test individually: Verify each FB in isolation before using in a larger program
  • Real-World Pattern: Valve Control

    FUNCTION_BLOCK FB_ValveControl
    VAR_INPUT
        OpenCmd   : BOOL;
        CloseCmd  : BOOL;
        OpenFB    : BOOL;  // Open feedback sensor
        CloseFB   : BOOL;  // Closed feedback sensor
        Timeout   : TIME := T#10s;
    END_VAR

    VAR_OUTPUT IsOpen : BOOL; IsClosed : BOOL; InTransit : BOOL; Fault : BOOL; END_VAR

    VAR faultTimer : TON; END_VAR

    IsOpen := OpenFB; IsClosed := CloseFB; InTransit := (OpenCmd OR CloseCmd) AND NOT OpenFB AND NOT CloseFB;

    // Fault if valve doesn't reach position in time faultTimer(IN := InTransit, PT := Timeout); Fault := faultTimer.Q; END_FUNCTION_BLOCK

    Practice with Function Blocks

    Try creating your own Function Blocks in our ST editor. Start with a simple counter block, then build up to a full motor controller with fault handling and run-time tracking.

    What the Call Actually Does to Your Data

    Every VAR_INPUT is copied into the instance's own memory at the moment of the call, and it stays there. That one fact explains most of the confusing behaviour engineers hit with FBs.

    That copy is paid every scan, per instance: a 400-byte recipe structure passed as VAR_INPUT into forty instances is roughly 16 kB of copying every scan, before a single line of your logic runs. On a big controller that is noise; on a small one with a 5 ms task it is a line item worth knowing about.

    Outputs live in the instance too, and hold their value until the next call. Read myFB.Done from code that executes above the call and you get last scan's answer — the classic "my done pulse is one scan late" bug. That is a code-ordering fault, not an FB fault.

    An input you omit at the call is not necessarily reset either. Omitted-input behaviour is one of the least portable corners of the language: some compilers leave the stored value untouched, others push the declared initial value. Don't build on either — assign every input that matters, every call.

    VAR_IN_OUT Is a Pointer Wearing a Suit

    VAR_IN_OUT binds by reference. Nothing is copied, you cannot pass a literal or an expression, and you cannot give it an initial value — the caller must supply a real variable at every call. Use it for anything large, or anything the FB must physically drive: I/O images, buffers, arrays, tuning structures you want editable live from the HMI.

    Two things the manual glosses over. First, there is no copy-out step. The FB writes straight into the caller's variable as it executes, so if it takes an early exit on a fault path — or if a higher-priority task preempts it mid-body — other code can end up reading a structure where some fields are this scan's and some are last scan's. VAR_OUTPUT does not magically fix that (outputs are written incrementally too, and reading inst.Done reads live instance memory, not a snapshot). What it does buy you is ownership: the data sits inside the instance, where nothing else in the project can alias it or write to it behind your back.

    Second, on CODESYS and TwinCAT the reference is stored as a pointer inside the instance, and the compiler only guarantees it is assigned when the FB body is called. Call a method on an instance whose body has never executed and that pointer is still unassigned — you get an access violation at runtime, usually on a machine, usually at night. Bind the FB with a full call before calling any of its methods.

    If you only need zero-copy reads, VAR_IN_OUT CONSTANT (a CODESYS/TwinCAT extension) hands over the reference without granting write access.

    The VAR_TEMP Trap

    An FB instance declared in VAR_TEMP — or as a plain local VAR inside a FUNCTION or METHOD, where that storage is temporary — is re-initialised on every call. A TON there never elapses, because its internal start timestamp is wiped before it can be compared. Worse, on some runtimes temp memory is whatever the previously executing POU left behind, so the symptom is intermittent and shifts when you add unrelated code elsewhere.

    VAR inside a FUNCTION is not the escape hatch — a function has no instance memory at all. CODESYS-family runtimes offer VAR_STAT, but the honest fix is to make it a function block.

    This mistake is common enough that both CODESYS Static Analysis and Beckhoff's TE1200 ship a dedicated rule for it: SA0167, temporary function block instances. Turn it on and promote it to an error.

    Designing an FB You Can Ship Twice

    TYPE ST_ConveyorCfg :
    STRUCT
        RunUpTime    : TIME := T#3s;    // drive must confirm healthy within this
        JamTime      : TIME := T#8s;    // discharge eye blocked this long = jam
        UseJamDetect : BOOL := FALSE;   // added v1.2 - default reproduces v1.1
    END_STRUCT
    END_TYPE

    TYPE ST_ConveyorIO : STRUCT DriveRun : BOOL; // --> output card DriveHealthy : BOOL; // <-- drive run relay PhotoEye : BOOL; // <-- discharge photo-eye END_STRUCT END_TYPE

    FUNCTION_BLOCK FB_ConveyorSection VAR_INPUT Enable : BOOL; Reset : BOOL; END_VAR VAR_IN_OUT IO : ST_ConveyorIO; // by reference: the FB drives the real channel END_VAR VAR_IN_OUT CONSTANT Cfg : ST_ConveyorCfg; // by reference, read-only, no per-scan copy END_VAR // (VAR_IN_OUT CONSTANT: CODESYS/TwinCAT) VAR_OUTPUT Running : BOOL; Error : BOOL; ErrorID : DWORD; // 0 = healthy END_VAR VAR // static instance memory - NEVER VAR_TEMP runUp : TON; jam : TON; resetEdge : R_TRIG; END_VAR VAR CONSTANT cErrNoDriveFeedback : DWORD := 16#0101; cErrJam : DWORD := 16#0102; END_VAR

    resetEdge(CLK := Reset); IF resetEdge.Q THEN Error := FALSE; ErrorID := 0; END_IF;

    // A latched fault stays latched until an operator reset IF Error THEN IO.DriveRun := FALSE; Running := FALSE; runUp(IN := FALSE, PT := Cfg.RunUpTime); jam(IN := FALSE, PT := Cfg.JamTime); RETURN; END_IF;

    IO.DriveRun := Enable;

    runUp(IN := Enable AND NOT IO.DriveHealthy, PT := Cfg.RunUpTime); IF runUp.Q THEN Error := TRUE; ErrorID := cErrNoDriveFeedback; END_IF;

    jam(IN := Cfg.UseJamDetect AND Enable AND IO.PhotoEye, PT := Cfg.JamTime); IF jam.Q THEN Error := TRUE; ErrorID := cErrJam; END_IF;

    Running := Enable AND IO.DriveHealthy AND NOT Error; END_FUNCTION_BLOCK

    Called against parallel arrays, one instance per physical section:

    anyFault := FALSE;
    FOR i := 1 TO 6 DO
        secti;
        // read outputs AFTER the call - above it you get last scan's values
        anyFault := anyFault OR sect[i].Error;
    END_FOR;
    

    Three rules keep a block portable. It must not read global variables — one GVL reference welds it to the project that defines that GVL. It must not contain hardcoded I/O addresses. And it must never assume a scan time: anything time-based arrives as TIME or is derived from the task cycle, or your accumulated run hours quietly halve the day someone drops the block into a 20 ms task.

    Versioning across projects

    Treat the instance memory layout as part of the published contract. Append new VAR declarations at the end, never insert them — shifting offsets is what turns an online change into a full download and can discard retained instance data. New inputs go last, with defaults that reproduce the old behaviour; UseJamDetect : BOOL := FALSE above is that rule made concrete. Anything that changes units, polarity, or timing semantics earns a new type name, not a new revision number.

    Pin library versions per project instead of resolving "latest" at build time. Once a machine passes FAT, that exact library version is frozen with it, and your source-control tag needs to say so — in three years the phone call will be about that machine, not about your newest release.