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 a function block is
A function block (FB) is the IEC 61131-3 unit for logic that has to remember something. It wraps code together with persistent internal state, so its variables keep their values from one scan cycle to the next. A plain function is stateless and starts fresh on every call.
You can think of an FB as a reusable mini-program. Values come in through its inputs, results go out through its outputs, and its internal variables hold the state that persists between calls.
Writing a function block
This is a basic motor controller FB. It has a 2 s start delay, a fault reset input and a run-hour counter:
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
One instance per machine part
Every time you declare a variable of an FB type you create an instance, and each instance has its own independent state. Two pumps and a conveyor can share one block definition and still track their run hours separately:
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 or 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 |
Habits for writing FBs
FB_ prefix, for example FB_ValveControl or FB_PIDLoop.Example: valve control with a travel timeout
The valve FB reports open, closed and in-transit from its two feedback sensors, and raises a fault if the valve does not reach a position within Timeout:
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
What the call 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. Most of the confusing behaviour engineers run into with FBs follows from that.
You pay for that copy 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 cost you should know about.
Outputs live in the instance too, and hold their value until the next call. If you read myFB.Done from code that executes above the call, you get last scan's answer. That is the classic "my done pulse is one scan late" bug, and it comes from the order of your code rather than from the FB.
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, while others push the declared initial value. Do not rely on either behaviour. Assign every input your logic depends on, on every call.
VAR_IN_OUT passes a reference
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, because the caller must supply a real variable at every call. Use it for anything large, or for anything the FB must physically drive: I/O images, buffers, arrays, and tuning structures you want to edit live from the HMI.
The manual glosses over two details. The first is that there is no copy-out step. The FB writes straight into the caller's variable as it executes. If it takes an early exit on a fault path, or a higher-priority task preempts it mid-body, other code can end up reading a structure in which some fields are from this scan and some from the last. VAR_OUTPUT does not cure that, since outputs are written incrementally too and reading inst.Done reads live instance memory, not a snapshot. What VAR_OUTPUT does give 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.
The second detail applies to CODESYS and TwinCAT, which store the reference as a pointer inside the instance. The compiler only guarantees that pointer is assigned when the FB body is called. If you call a method on an instance whose body has never executed, the pointer is still unassigned and you get an access violation at runtime, usually on a machine and usually at night. Bind the FB with a full call before you call 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.
FB instances in VAR_TEMP lose their state
An FB instance declared in VAR_TEMP is re-initialised on every call. So is one declared as a plain local VAR inside a FUNCTION or METHOD, where that storage is temporary. A TON declared there never elapses, because its internal start timestamp is wiped before it can be compared. On some runtimes it is worse: temp memory holds whatever the previously executing POU left behind, so the symptom is intermittent and shifts when you add unrelated code elsewhere.
Moving the instance into VAR inside a FUNCTION does not help, because a function has no instance memory at all. CODESYS-family runtimes offer VAR_STAT, but the proper fix is to make the code a function block.
The mistake is common enough that both CODESYS Static Analysis and Beckhoff's TE1200 have a dedicated rule for it: SA0167, temporary function block instances. Turn it on and promote it to an error.
Designing an FB you can reuse in another project
The conveyor section FB below keeps its configuration and its I/O in two structures, passed by reference, so the same block drops into any project unchanged:
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
You call it against parallel arrays, with one instance per physical section:
anyFault := FALSE;
FOR i := 1 TO 6 DO
sect[i](Enable := lineRun AND NOT eStop,
Reset := hmiResetPB,
IO := sectIO[i],
Cfg := sectCfg[i]);
// read outputs AFTER the call - above it you get last scan's values
anyFault := anyFault OR sect[i].Error;
END_FOR;
To keep a block portable, hold it to three rules. It must not read global variables, because one GVL reference ties 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; otherwise your accumulated run hours 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 and never insert them in the middle: shifting offsets is what turns an online change into a full download, and it can discard retained instance data. New inputs go last, with defaults that reproduce the old behaviour. UseJamDetect : BOOL := FALSE in the example above follows that rule. Anything that changes units, polarity or timing semantics gets a new type name, not just 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 has to record it. In three years the phone call will be about that machine, not about your newest release.
Practice with function blocks
Try writing your own FBs in the ST editor. Start with a simple counter block, then build up to a full motor controller with fault handling and run-time tracking.