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 POU Architecture Matters
Every PLC programmer has inherited a project that's a single 5,000-line PROGRAM with variables named Temp1, Flag23, and bTest_DoNotRemove. It works. Nobody understands it. Nobody wants to modify it. When the original programmer leaves, the code becomes a liability.
Program Organization Units (POUs) are the IEC 61131-3 solution to this problem. They're the building blocks of modular, reusable, testable PLC code. Understanding when and how to use each type is the difference between a junior and senior controls engineer.
The Three POU Types
IEC 61131-3 defines three types of POUs, each with specific 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's the top-level container that the runtime calls. Key properties:
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;
Best practice: Keep PROGRAMs thin. They should map I/O, instantiate function blocks, and wire them together — not contain business logic.
FUNCTION_BLOCK — The Workhorse
Function blocks are the most important POU type for building maintainable systems. Each instance maintains 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;
Why FBs are powerful: You define the logic once, then create instances for every motor in your 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 inputs with no memory of previous calls. Same inputs always produce the same output:
FUNCTION FC_LinearScale : REAL
VAR_INPUT
RawValue : INT;
RawMin : INT;
RawMax : INT;
EUMin : REAL;
EUMax : REAL;
END_VARIF (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_VARIF Value < MinVal THEN
FC_Clamp := MinVal;
ELSIF Value > MaxVal THEN
FC_Clamp := MaxVal;
ELSE
FC_Clamp := Value;
END_IF;
When to use FUNCTION vs FUNCTION_BLOCK: If the logic needs to remember something between scans (a timer, a counter, a latch, a state machine), it must be a FUNCTION_BLOCK. If it's pure math or conversion, use a FUNCTION.
The Decision Framework
Use this flowchart for every piece of logic you write:
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)
Default to FUNCTION_BLOCK. It's almost always the right choice. The overhead is negligible, and you'll thank yourself when the machine gets duplicated.
Architectural Patterns for Real Projects
Pattern 1: Equipment-Centric Architecture
The most common and most maintainable pattern — 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
For large projects, organize 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 < 1000); // Wire break (< ~3.5mA)
// 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);
Naming Conventions That Scale
Consistent naming is half the battle. Here's a proven convention:
| 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) |
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_VARVAR_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 clean interfaces:
Testing POUs in Isolation
The biggest advantage of modular POUs: you can test them independently:
// 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;
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: Pass data through FB interfaces (VAR_INPUT/VAR_OUTPUT). Globals should only be used for I/O mapping and a small number of truly system-wide values.
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: Create motor instances at the PROGRAM level and pass their status/commands through the FB interface.
Summary
| Principle | Guideline | | Default POU type | FUNCTION_BLOCK — use FUNCTION only for stateless math | | PROGRAM scope | Keep thin — I/O mapping and FB wiring only | | Architecture | Equipment-centric with layered abstraction | | Naming | Consistent prefixes (CMD_, STS_, SP_, PV_, ALM_) | | Interfaces | Minimal inputs, comprehensive outputs, rare VAR_IN_OUT | | Globals | Minimize — pass data through FB interfaces instead | | Testing | Each FB should be testable in isolation | | Reusability | Design FBs for the equipment type, not the specific machine |
The best PLC programs look boring. They're made of small, well-named, well-tested building blocks snapped together like LEGO. The complexity is managed, not eliminated — and that's what makes them maintainable for decades of production.