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 TypeInstancesStateI/O AccessUse Case
PROGRAMOne (task-bound)Yes (persistent)DirectMain execution, task binding
FUNCTION_BLOCKMultipleYes (per instance)NoReusable logic with memory
FUNCTIONN/A (stateless)NoNoPure 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:

LayerContainsDepends On
Hardware AbstractionI/O scaling, device driversNothing
EquipmentMotors, valves, instrumentsHardware layer
ProcessUnit operations, sequencesEquipment layer
CoordinationProduction management, recipesProcess layer
HMI InterfaceData blocks for operator screensAll layers
The hardware layer hides the physical I/O behind engineering units and diagnostics:

// 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:

ElementPatternExample
Function Block TypeFB_[Category]_[Type]FB_Motor_VFD
FunctionFC_[Verb][Noun]FC_ScaleLinear
ProgramPRG_[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

PrefixMeaning
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)
Hungarian notation, where the prefix gives the type, is also common in PLC code: 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:

  • Keep inputs minimal: only what the FB needs to do its job.
  • Make outputs complete: status, diagnostics, error codes.
  • Use VAR_IN_OUT sparingly, because it creates hidden coupling.
  • Group related parameters, with a STRUCT for any configuration of 5 or more fields.
  • Document units: "Setpoint : REAL; // Temperature in °C".
  • 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:

  • Power-up: does everything initialize safely?
  • Sensor failures: what happens if an input goes to zero or to maximum?
  • Simultaneous commands: start and stop pressed together?
  • Communication loss: the HMI disconnects mid-operation?
  • Sequence interruptions: stop pressed mid-cycle?
  • Test on a simulator before you download to a live PLC.

    Version control and backups

  • Export the project after every significant change.
  • Use meaningful names: Project_v2.3_AddedPumpFaultLogic.
  • Keep a change log that records what changed and why.
  • 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

  • Default to FUNCTION_BLOCK; use FUNCTION only for stateless math.
  • Keep PROGRAMs thin: I/O mapping and FB wiring only.
  • Build equipment-centric FBs on top of a hardware abstraction layer.
  • Use consistent prefixes (CMD_, STS_, SP_, PV_, ALM_).
  • Give interfaces minimal inputs and complete outputs, and use VAR_IN_OUT rarely.
  • Pass data through FB interfaces, not globals.
  • Design each FB for the equipment type, not for one specific machine, and test it in isolation.
  • 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.