Intermediate
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:
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_VARVAR_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
| Feature | Function Block (FB) | Function (FC) |
|---|---|---|
| State | Persistent (remembers values) | Stateless (resets each call) |
| Instances | Multiple independent copies | Single definition |
| Use case | Timers, controllers, state machines | Math, conversions, utilities |
| Memory | Each instance uses memory | Shared memory |
Best Practices
FB_ — e.g., FB_ValveControl, FB_PIDLoopReal-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_VARVAR_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_TYPETYPE 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.