Architecture
PLC Program Organization Units: Structuring Code That Scales — IEC 61131-3 Best Practices
Stop writing monolithic PLC programs. Learn the IEC 61131-3 POU architecture — when to use PROGRAM vs FUNCTION vs FUNCTION_BLOCK, and how to structure projects that scale from 10 I/O to 10,000.
Why you split a PLC program into POUs
Every PLC programmer has inherited a project that is a single 5,000-line PROGRAM with variables named Temp1, Flag23 and bTest_DoNotRemove. It works, but nobody understands it and nobody wants to modify it. When the original programmer leaves, the code becomes a liability. PLC code controls real machinery, so badly organized code does more than cause bugs: it can cause safety hazards, production downtime and maintenance trouble for years.
Program Organization Units (POUs) are the IEC 61131-3 answer to this. They are the building blocks of modular, reusable, testable PLC code, and knowing when and how to use each type is a large part of what separates a senior controls engineer from a junior one. This article covers the three POU types, how to choose between them, project architectures, naming and interfaces, the coding habits that keep each POU readable, and how to test them.
The three POU types
IEC 61131-3 defines three types of POU, each with its own characteristics:
| POU Type | Instances | State | I/O Access | Use Case |
|---|---|---|---|---|
| PROGRAM | One (task-bound) | Yes (persistent) | Direct | Main execution, task binding |
| FUNCTION_BLOCK | Multiple | Yes (per instance) | No | Reusable logic with memory |
| FUNCTION | N/A (stateless) | No | No | Pure calculations, conversions |
PROGRAM: the entry point
A PROGRAM is bound to a task (cyclic, event-driven or freewheeling). It is the top-level container that the runtime calls. On most platforms it runs as a single instance per task assignment, its variables keep their values between scans, and it can read and write physical I/O directly (how depends on the platform).
PROGRAM Main
VAR
// I/O mapping
DI_StartButton AT %IX0.0 : BOOL;
DI_StopButton AT %IX0.1 : BOOL;
DO_MotorContactor AT %QX0.0 : BOOL;
AI_Temperature AT %IW0 : INT;
// Internal logic
MotorRunning : BOOL := FALSE;
ScaledTemp : REAL;
// Function block instances
fbMotor1 : FB_MotorControl;
fbTempScale : FB_AnalogScale;
END_VAR
// Scale temperature
fbTempScale(RawInput := AI_Temperature, EUMin := 0.0, EUMax := 150.0);
ScaledTemp := fbTempScale.ScaledOutput;
// Motor control
fbMotor1(
Start := DI_StartButton,
Stop := DI_StopButton,
OverTemp := ScaledTemp > 85.0
);
DO_MotorContactor := fbMotor1.Running;
Keep PROGRAMs thin. They should map I/O, instantiate function blocks and wire them together. The process logic belongs in the function blocks.
FUNCTION_BLOCK: the workhorse
Function blocks are the POU type you build a maintainable system from. Each instance keeps its own state:
FUNCTION_BLOCK FB_MotorControl
VAR_INPUT
Start : BOOL;
Stop : BOOL;
OverTemp : BOOL;
Reset : BOOL;
END_VAR
VAR_OUTPUT
Running : BOOL;
Faulted : BOOL;
RunHours : REAL;
END_VAR
VAR
Latched : BOOL := FALSE;
FaultCode : INT := 0;
tmrRuntime : TON;
END_VAR
// Fault detection
IF OverTemp AND Running THEN
Faulted := TRUE;
FaultCode := 1; // Over temperature
Latched := FALSE;
END_IF;
// Fault reset
IF Reset AND Faulted THEN
Faulted := FALSE;
FaultCode := 0;
END_IF;
// Start/stop with latch
IF Start AND NOT Faulted THEN
Latched := TRUE;
ELSIF Stop OR Faulted THEN
Latched := FALSE;
END_IF;
Running := Latched AND NOT Faulted;
// Run-hour tracking
tmrRuntime(IN := Running, PT := T#1H);
IF tmrRuntime.Q THEN
RunHours := RunHours + 1.0;
tmrRuntime(IN := FALSE);
END_IF;
You define the logic once and create an instance for every motor in the plant. Change the FB, and every motor gets the improvement:
// 50 motors, one function block definition
VAR
Motor_Pump_P101 : FB_MotorControl;
Motor_Pump_P102 : FB_MotorControl;
Motor_Mixer_M201 : FB_MotorControl;
Motor_Fan_F301 : FB_MotorControl;
// ... 46 more
END_VAR
FUNCTION: pure calculations
Functions are stateless. They compute a result from their inputs with no memory of previous calls, so the same inputs always give the same output:
FUNCTION FC_LinearScale : REAL
VAR_INPUT
RawValue : INT;
RawMin : INT;
RawMax : INT;
EUMin : REAL;
EUMax : REAL;
END_VAR
IF (RawMax - RawMin) = 0 THEN
FC_LinearScale := EUMin;
RETURN;
END_IF;
FC_LinearScale := EUMin + (INT_TO_REAL(RawValue - RawMin) / INT_TO_REAL(RawMax - RawMin)) * (EUMax - EUMin);
FUNCTION FC_Clamp : REAL
VAR_INPUT
Value : REAL;
MinVal : REAL;
MaxVal : REAL;
END_VAR
IF Value < MinVal THEN
FC_Clamp := MinVal;
ELSIF Value > MaxVal THEN
FC_Clamp := MaxVal;
ELSE
FC_Clamp := Value;
END_IF;
If the logic has to remember something between scans (a timer, a counter, a latch, a state machine), it must be a FUNCTION_BLOCK. If it is pure math or a conversion, use a FUNCTION.
Choosing the POU type
Run every piece of logic you write through this decision tree:
Does it need to remember state between scans?
├── NO → Is it a pure calculation/conversion?
│ ├── YES → FUNCTION
│ └── NO → Probably still a FUNCTION
└── YES → Will you need multiple independent instances?
├── YES → FUNCTION_BLOCK
└── NO → Could be PROGRAM (if it's top-level)
or FUNCTION_BLOCK (for future reuse)
When in doubt, default to FUNCTION_BLOCK; it is almost always the right choice. The overhead is negligible, and you will be glad of it when the machine gets duplicated.
Architectural patterns for real projects
Pattern 1: equipment-centric architecture
The most common pattern, and the easiest to maintain, is one FB per equipment type:
// Equipment layer — reusable across projects
FUNCTION_BLOCK FB_Motor_DOL ... END_FUNCTION_BLOCK // Direct-on-line motor
FUNCTION_BLOCK FB_Motor_VFD ... END_FUNCTION_BLOCK // Variable speed motor
FUNCTION_BLOCK FB_Valve_OnOff ... END_FUNCTION_BLOCK // On/off valve
FUNCTION_BLOCK FB_Valve_Modulating... END_FUNCTION_BLOCK // Analog valve
FUNCTION_BLOCK FB_PIDController ... END_FUNCTION_BLOCK // PID loop
FUNCTION_BLOCK FB_AnalogInput ... END_FUNCTION_BLOCK // Scaled analog input
// Process layer — application-specific
FUNCTION_BLOCK FB_MixingStation ... END_FUNCTION_BLOCK // Uses motors + valves
FUNCTION_BLOCK FB_HeatingSystem ... END_FUNCTION_BLOCK // Uses PID + valves
FUNCTION_BLOCK FB_ConveyorSection ... END_FUNCTION_BLOCK // Uses VFD motors
// Program layer — task entry point
PROGRAM Main
// Instantiate process FBs
// Map I/O
// Wire together
END_PROGRAM
Pattern 2: layered architecture
On large projects, organize the POUs into layers:
| Layer | Contains | Depends On |
|---|---|---|
| Hardware Abstraction | I/O scaling, device drivers | Nothing |
| Equipment | Motors, valves, instruments | Hardware layer |
| Process | Unit operations, sequences | Equipment layer |
| Coordination | Production management, recipes | Process layer |
| HMI Interface | Data blocks for operator screens | All layers |
// Layer 1: Hardware — abstract the physical I/O
FUNCTION_BLOCK FB_AI_4to20mA
VAR_INPUT
RawInput : INT; // From analog input module
EUMin : REAL;
EUMax : REAL;
FilterCoeff : REAL := 0.1;
END_VAR
VAR_OUTPUT
EngineeringValue : REAL;
OutOfRange : BOOL;
SensorFault : BOOL;
END_VAR
VAR
Filtered : REAL;
END_VAR
// Scale raw to engineering units
EngineeringValue := FC_LinearScale(
RawValue := RawInput,
RawMin := 5530, // 4mA
RawMax := 27648, // 20mA
EUMin := EUMin,
EUMax := EUMax
);
// First-order low-pass filter
Filtered := Filtered + FilterCoeff * (EngineeringValue - Filtered);
EngineeringValue := Filtered;
// Diagnostics
OutOfRange := (RawInput < 3000) OR (RawInput > 28000);
SensorFault := (RawInput < -864); // Below ~3.5 mA on a 4-20 mA card (0 = 4 mA, 27648 = 20 mA)
The equipment layer then builds on it and works in engineering units:
// Layer 2: Equipment — uses hardware abstraction
FUNCTION_BLOCK FB_TempControlLoop
VAR_INPUT
Enable : BOOL;
Setpoint : REAL;
END_VAR
VAR_OUTPUT
PV : REAL;
Output : REAL;
Alarm_High : BOOL;
END_VAR
VAR
aiTemp : FB_AI_4to20mA; // Hardware abstraction
pid : FB_PIDController; // Reusable PID
END_VAR
// Read and scale temperature
aiTemp(RawInput := (* mapped I/O *) 0, EUMin := 0.0, EUMax := 200.0);
PV := aiTemp.EngineeringValue;
// PID control
pid(Enable := Enable, SP := Setpoint, PV := PV);
Output := pid.Output;
Alarm_High := PV > (Setpoint + 10.0);
Keeping the HMI interface thin
The HMI interface layer should be a thin set of HMI-facing tags. The logic FB owns the real state, and the PROGRAM copies values across in both directions:
PROGRAM Main
VAR
// HMI-facing tags (Modbus/OPC UA mapped)
hmi_tankLevel : REAL;
hmi_pumpStatus : BOOL;
hmi_alarmActive : BOOL;
hmi_startButton : BOOL;
hmi_stopButton : BOOL;
// Logic owns the real state — ProcessController is your own FB
processController : ProcessController;
END_VAR
// ✅ Good: Clear boundary between HMI and logic
// HMI interface variables (read by HMI)
hmi_tankLevel := processController.CurrentLevel;
hmi_pumpStatus := processController.PumpRunning;
hmi_alarmActive := processController.HasFault;
// HMI commands (written by HMI)
processController.StartCmd := hmi_startButton;
processController.StopCmd := hmi_stopButton;
That gives you one clear boundary. Everything the HMI reads or writes (here over Modbus or OPC UA) is in one place, and the HMI does not reach into the logic FB directly. With the prefix convention below, you would name these tags STS_ and CMD_ instead of hmi_.
Naming conventions that scale
Consistent naming solves half the maintenance problem. This convention works on large projects:
| Element | Pattern | Example |
|---|---|---|
| Function Block Type | FB_[Category]_[Type] | FB_Motor_VFD |
| Function | FC_[Verb][Noun] | FC_ScaleLinear |
| Program | PRG_[Area/Function] | PRG_WaterTreatment |
| Instance | [Equipment Tag] | Pump_P101 |
| Input | [Prefix]_[Name] | SP_Temperature |
| Output | [Prefix]_[Name] | PV_Temperature |
| Internal | [camelCase or descriptive] | lastScanPV |
Prefix conventions
| Prefix | Meaning |
|---|---|
| CMD_ | Command (from HMI or higher-level logic) |
| STS_ | Status (to HMI or higher-level logic) |
| SP_ | Setpoint |
| PV_ | Process Variable |
| ALM_ | Alarm |
| CFG_ | Configuration parameter |
| DI_ | Digital Input (physical) |
| DO_ | Digital Output (physical) |
| AI_ | Analog Input (physical) |
| AO_ | Analog Output (physical) |
bRunning (BOOL), iCount (INT), rTemperature (REAL). Whichever scheme you adopt, use it across every project, and never leave a name like temp that could mean temperature or temporary.Interface design: VAR_INPUT, VAR_OUTPUT, VAR_IN_OUT
The interface is the contract your FB makes with the rest of the program:
FUNCTION_BLOCK FB_WellDesigned
VAR_INPUT // Read-only inside the FB — caller provides these
Enable : BOOL;
Setpoint : REAL;
Config : ConfigStruct; // Group related configs into a STRUCT
END_VAR
VAR_OUTPUT // Written by the FB — caller reads these
Active : BOOL;
Value : REAL;
ErrorID : DINT;
END_VAR
VAR_IN_OUT // Passed by reference — FB can read AND write
SharedData : DataBuffer; // Use sparingly — harder to trace
END_VAR
VAR // Internal — hidden from the caller
state : INT;
tmr : TON;
END_VAR
Rules for a clean interface:
Coding habits inside each POU
Good structure still needs careful code inside each block. These habits make Structured Text safer, easier to read and easier to maintain.
Handle bad inputs explicitly
Never assume an input is valid or that the equipment will behave. Check the signal range, raise a fault, and decide what the logic uses in the meantime:
// ✅ Good: Explicit error handling
IF sensorValue < SENSOR_MIN OR sensorValue > SENSOR_MAX THEN
sensorFault := TRUE;
processValue := lastGoodValue; // Use last known good value
ELSE
sensorFault := FALSE;
lastGoodValue := sensorValue;
processValue := sensorValue;
END_IF;
Use named constants, not magic numbers
// ❌ Bad: What do these numbers mean?
IF temperature > 85.0 THEN
output := 4095;
END_IF;
// ✅ Good: Self-documenting
VAR CONSTANT
TEMP_HIGH_ALARM : REAL := 85.0;
DAC_MAX_OUTPUT : INT := 4095;
END_VAR
IF temperature > TEMP_HIGH_ALARM THEN
output := DAC_MAX_OUTPUT;
END_IF;
The constant name tells the next engineer what 85.0 and 4095 are, and you change each value in one place.
Write sequences as CASE state machines
Use a CASE statement with defined states for any sequential process. The declarations:
PROGRAM Main
VAR
machineState : INT;
startCommand : BOOL;
motorRunning : BOOL;
startMotor : BOOL;
stopCommand : BOOL;
faultReset : BOOL;
END_VAR
And the logic:
CASE machineState OF
0: // IDLE
IF startCommand THEN machineState := 10; END_IF;
10: // STARTING
startMotor := TRUE;
IF motorRunning THEN machineState := 20; END_IF;
20: // RUNNING
// Production logic
IF stopCommand THEN machineState := 30; END_IF;
30: // STOPPING
startMotor := FALSE;
IF NOT motorRunning THEN machineState := 0; END_IF;
99: // FAULT
startMotor := FALSE;
IF faultReset THEN machineState := 0; END_IF;
END_CASE;
Number the states in multiples of 10 so you can insert intermediate steps later without renumbering the rest.
Comment the reason, not the statement
// ❌ Bad: Restates the obvious
// Set motorRun to TRUE
motorRun := TRUE;
// ✅ Good: Explains WHY
// Bypass start delay during manual jog mode for maintenance
motorRun := TRUE;
To run this fragment, declare motorRun : BOOL; in the VAR panel.
Keep scan time predictable
Avoid operations that spike the scan time unpredictably, such as processing a large array in one go. The declarations:
PROGRAM Main
VAR
i, chunkStart : INT;
processData, rawData : ARRAY[1..10000] OF REAL;
scaleFactor : REAL;
END_VAR
And the logic, first the bad version and then the same work spread over many scans:
// ❌ Bad: Processing entire array every scan
FOR i := 1 TO 10000 DO
processData[i] := rawData[i] * scaleFactor;
END_FOR;
// ✅ Good: Process a chunk per scan
FOR i := chunkStart TO chunkStart + 99 DO
IF i <= 10000 THEN
processData[i] := rawData[i] * scaleFactor;
END_IF;
END_FOR;
chunkStart := chunkStart + 100;
IF chunkStart > 10000 THEN chunkStart := 1; END_IF;
The chunked loop handles 100 elements per scan and wraps back to the start of the array. Give chunkStart an initial value of 1, since the arrays are indexed from 1.
Testing POUs in isolation
The biggest advantage of modular POUs is that you can test each one on its own:
// Test program for FB_MotorControl — run in a separate task
PROGRAM TestMotorFB
VAR
uut : FB_MotorControl; // Unit Under Test
// Test stimuli
TestStep : INT := 0;
TestPassed : BOOL := FALSE;
TestFailed : BOOL := FALSE;
tmrDelay : TON;
END_VAR
CASE TestStep OF
0: // TEST 1: Motor should not run without start command
uut(Start := FALSE, Stop := FALSE, OverTemp := FALSE, Reset := FALSE);
IF uut.Running THEN
TestFailed := TRUE; // FAIL: Running without start
ELSE
TestStep := 1;
END_IF;
1: // TEST 2: Motor should start
uut(Start := TRUE, Stop := FALSE, OverTemp := FALSE, Reset := FALSE);
IF uut.Running THEN
TestStep := 2;
END_IF;
2: // TEST 3: Motor should stop on stop command
uut(Start := FALSE, Stop := TRUE, OverTemp := FALSE, Reset := FALSE);
IF NOT uut.Running THEN
TestStep := 3;
END_IF;
3: // TEST 4: Motor should fault on overtemp while running
uut(Start := TRUE, Stop := FALSE, OverTemp := FALSE, Reset := FALSE);
IF uut.Running THEN
uut(Start := FALSE, Stop := FALSE, OverTemp := TRUE, Reset := FALSE);
IF uut.Faulted AND NOT uut.Running THEN
TestStep := 4;
END_IF;
END_IF;
4: // TEST 5: Should not restart until reset
uut(Start := TRUE, Stop := FALSE, OverTemp := FALSE, Reset := FALSE);
IF NOT uut.Running AND uut.Faulted THEN
// Correct — can't start while faulted
uut(Start := FALSE, Stop := FALSE, OverTemp := FALSE, Reset := TRUE);
TestStep := 5;
ELSIF uut.Running THEN
TestFailed := TRUE; // FAIL: Started while faulted
END_IF;
5: // ALL TESTS PASSED
IF NOT uut.Faulted THEN
TestPassed := TRUE;
END_IF;
END_CASE;
Before commissioning, also check how the whole program handles the edge cases:
Test on a simulator before you download to a live PLC.
Version control and backups
Project_v2.3_AddedPumpFaultLogic.Common anti-patterns
Anti-pattern 1: the god program
// DON'T: 3000 lines of logic in one PROGRAM
PROGRAM Main
VAR
// 200 variables...
END_VAR
// Motor 1 logic (lines 1–80)
// Motor 2 logic (lines 81–160) — copy-pasted from motor 1
// Valve logic (lines 161–300)
// Temperature control (lines 301–500)
// Alarm handling (lines 501–800)
// ... 2200 more lines
Anti-pattern 2: global variable soup
// DON'T: Everything talks through global variables
VAR_GLOBAL
gMotor1Running : BOOL;
gMotor1Start : BOOL;
gTemp1 : REAL;
gValve1Cmd : BOOL;
gAlarm7 : BOOL;
// 500 more globals...
END_VAR
The fix is to pass data through FB interfaces (VAR_INPUT/VAR_OUTPUT). Use globals only for I/O mapping and a small number of values that really are system-wide.
Anti-pattern 3: nested FB instance creation
// DON'T: Creating FB instances inside other FBs without good reason
FUNCTION_BLOCK FB_ProcessStep
VAR
motor : ARRAY[1..20] OF FB_MotorControl; // 20 motors per step?!
// Each instance of FB_ProcessStep creates 20 motor instances
// 10 process steps = 200 motor FB instances
END_VAR
The fix is to create the motor instances at PROGRAM level and pass their status and commands through the FB interface.
POU checklist
Programs built this way look boring: small, well-named, well-tested blocks wired together. The complexity is still there, but it is managed, which is what keeps the code maintainable through decades of production.
Practice these patterns in the PLC simulator, and look through the project templates, which use many of them in real-world scenarios.