PLC Error Handling & Exception Management: Building Fault-Tolerant Structured Text

PLCs don't have try/catch — so how do you handle errors? Build fault-tolerant automation with defensive coding patterns, hierarchical fault propagation, and graceful degradation strategies.

Why PLC code needs its own error handling

High-level languages have try/catch, exceptions and stack traces. The core IEC 61131-3 Structured Text language has no equivalent. When something goes wrong in a PLC program (a sensor reads garbage, a calculation divides by zero, a communication link drops), the program either produces wrong outputs without telling anyone or the CPU faults and everything stops.

Neither is acceptable on a running plant. A paint line that stops mid-coat ruins a $50,000 car body. A chemical reactor that ignores a failed sensor could cause a runaway reaction. (The last line of protection against that is the reactor's safety system; the control code below does not replace it.) Defensive programming in Structured Text is what turns a failed input into a minor alarm instead of a stopped line or a bad output.

Five principles to settle before you write code

  • Detect problems early: check every input before you use it.
  • Fail safe, because wrong outputs are worse than no outputs.
  • Propagate faults clearly, so every FB reports its health status.
  • Degrade gracefully. Losing one sensor should not stop the whole plant.
  • Log everything, since you cannot fix what you cannot diagnose.
  • A standard error interface for every FB

    Every function block you write should carry the same error structure:

    TYPE FB_ErrorStatus :
    STRUCT
        Error       : BOOL;          // TRUE = this FB has an active error
        ErrorID     : DINT;          // Unique error code
        ErrorMsg    : STRING[80];    // Human-readable description
        Warning     : BOOL;          // TRUE = degraded but operational
        WarningID   : DINT;
        FirstErrorTime : STRING[20]; // When the error first occurred
    END_STRUCT;
    END_TYPE
    

    // Apply to every FB you write
    FUNCTION_BLOCK FB_TemperatureControl
    VAR_INPUT
        Enable      : BOOL;
        SP_Temp     : REAL;
        PV_Temp     : REAL;
        PV_Valid    : BOOL;          // Sensor health from I/O diagnostics
    END_VAR
    VAR_OUTPUT
        HeatingCmd  : REAL;          // 0–100%
        Status      : FB_ErrorStatus;
    END_VAR
    VAR
        pid         : FB_PIDController;
        lastGoodPV  : REAL;
        pvFrozenTimer : TON;
        prevPV      : REAL;
    END_VAR
    
    // Clear previous errors
    Status.Error := FALSE;
    Status.Warning := FALSE;
    
    // === INPUT VALIDATION ===
    
    // Check 1: Is the sensor signal valid?
    IF NOT PV_Valid THEN
        Status.Error := TRUE;
        Status.ErrorID := 1001;
        Status.ErrorMsg := 'Temperature sensor fault — signal invalid';
        HeatingCmd := 0.0;     // Fail safe: stop heating
        RETURN;
    END_IF;
    
    // Check 2: Is the value physically plausible?
    IF PV_Temp < -50.0 OR PV_Temp > 500.0 THEN
        Status.Error := TRUE;
        Status.ErrorID := 1002;
        Status.ErrorMsg := 'Temperature out of physical range';
        HeatingCmd := 0.0;
        RETURN;
    END_IF;
    
    // Check 3: Is the sensor frozen (stuck at same value)?
    pvFrozenTimer(IN := (ABS(PV_Temp - prevPV) < 0.01), PT := T#60S);
    prevPV := PV_Temp;
    
    IF pvFrozenTimer.Q THEN
        Status.Warning := TRUE;
        Status.WarningID := 2001;
        Status.ErrorMsg := 'Temperature sensor may be frozen — no change for 60s';
        // Continue operating but flag the issue
    END_IF;
    
    // Check 4: Rate-of-change limit (physical impossibility check)
    IF ABS(PV_Temp - lastGoodPV) > 50.0 THEN  // >50°C in one scan = impossible
        Status.Warning := TRUE;
        Status.WarningID := 2002;
        Status.ErrorMsg := 'Temperature spike detected — using last good value';
        PV_Temp := lastGoodPV;   // Use last known good value
    END_IF;
    
    lastGoodPV := PV_Temp;
    
    // === NORMAL PROCESSING ===
    pid(Enable := Enable, SP := SP_Temp, PV := PV_Temp);
    HeatingCmd := pid.Output;
    
    // Check 5: Validate output is sane
    IF HeatingCmd < 0.0 OR HeatingCmd > 100.0 THEN
        HeatingCmd := FC_Clamp(HeatingCmd, 0.0, 100.0);
        Status.Warning := TRUE;
        Status.WarningID := 2003;
        Status.ErrorMsg := 'PID output clamped to safe range';
    END_IF;
    

    Division-safe math

    Division by zero is one of the most common runtime errors in PLC programs, so never trust a denominator. The same goes for array indices, which should be clamped before you use them:

    FUNCTION FC_SafeDivide : REAL
    VAR_INPUT
        Numerator   : REAL;
        Denominator : REAL;
        DefaultVal  : REAL;    // Return this if denominator is zero
    END_VAR
    
    IF ABS(Denominator) < 1.0E-9 THEN
        FC_SafeDivide := DefaultVal;
    ELSE
        FC_SafeDivide := Numerator / Denominator;
    END_IF;
    

    // Usage patterns
    FlowRate := FC_SafeDivide(Volume, TimeDelta, 0.0);
    Efficiency := FC_SafeDivide(OutputPower, InputPower, 0.0);
    Speed := FC_SafeDivide(Distance, ElapsedTime, 0.0);
    
    // Array index safety
    FUNCTION FC_SafeArrayIndex : INT
    VAR_INPUT
        Index    : INT;
        MinIndex : INT;
        MaxIndex : INT;
    END_VAR
    
    IF Index < MinIndex THEN
        FC_SafeArrayIndex := MinIndex;
    ELSIF Index > MaxIndex THEN
        FC_SafeArrayIndex := MaxIndex;
    ELSE
        FC_SafeArrayIndex := Index;
    END_IF;
    

    Passing faults up the hierarchy

    Errors have to travel up through the system hierarchy. A sensor fault in a temperature loop should reach the process unit, then the area, then the plant overview. Here the process unit counts its children's errors and warnings and reports the worst case:

    FUNCTION_BLOCK FB_ProcessUnit
    VAR
        tempLoop1    : FB_TemperatureControl;
        tempLoop2    : FB_TemperatureControl;
        pressLoop    : FB_PressureControl;
        motorPump    : FB_MotorControl;
    
        UnitStatus   : FB_ErrorStatus;
        SubErrors    : INT := 0;
        SubWarnings  : INT := 0;
    END_VAR
    
    // Run all sub-FBs
    tempLoop1(Enable := TRUE, SP_Temp := 80.0, PV_Temp := AI_Temp1, PV_Valid := AI_Temp1_OK);
    tempLoop2(Enable := TRUE, SP_Temp := 65.0, PV_Temp := AI_Temp2, PV_Valid := AI_Temp2_OK);
    pressLoop(Enable := TRUE, SP_Press := 3.5, PV_Press := AI_Press1, PV_Valid := AI_Press1_OK);
    motorPump(Start := CMD_Start, Stop := CMD_Stop);
    
    // Aggregate errors from children
    SubErrors := 0;
    SubWarnings := 0;
    
    IF tempLoop1.Status.Error THEN SubErrors := SubErrors + 1; END_IF;
    IF tempLoop2.Status.Error THEN SubErrors := SubErrors + 1; END_IF;
    IF pressLoop.Status.Error THEN SubErrors := SubErrors + 1; END_IF;
    IF motorPump.Status.Error THEN SubErrors := SubErrors + 1; END_IF;
    
    IF tempLoop1.Status.Warning THEN SubWarnings := SubWarnings + 1; END_IF;
    IF tempLoop2.Status.Warning THEN SubWarnings := SubWarnings + 1; END_IF;
    
    // Propagate worst-case status
    UnitStatus.Error := SubErrors > 0;
    UnitStatus.Warning := SubWarnings > 0 AND NOT UnitStatus.Error;
    
    IF SubErrors >= 2 THEN
        UnitStatus.ErrorID := 5001;
        UnitStatus.ErrorMsg := 'Multiple sub-system faults — unit shutdown';
        // Trigger safe shutdown sequence
    ELSIF SubErrors = 1 THEN
        UnitStatus.ErrorID := 5002;
        UnitStatus.ErrorMsg := 'Single sub-system fault — degraded operation';
        // Continue with degraded capability
    END_IF;
    

    Watchdog pattern

    A watchdog checks that a process is still alive and making progress. If a monitored bit stops changing for longer than the set time, something is wrong:

    FUNCTION_BLOCK FB_Watchdog
    VAR_INPUT
        Enable       : BOOL;
        MonitoredBit : BOOL;     // Should change/pulse periodically
        MaxSilenceTime : TIME;   // How long before alarm
    END_VAR
    VAR_OUTPUT
        Healthy      : BOOL;
        TimedOut     : BOOL;
        SilenceSecs  : REAL;
    END_VAR
    VAR
        tmrSilence   : TON;
        lastState    : BOOL;
        stateChanged : BOOL;
    END_VAR
    
    IF NOT Enable THEN
        Healthy := FALSE;
        TimedOut := FALSE;
        RETURN;
    END_IF;
    
    // Detect any change in the monitored bit
    stateChanged := (MonitoredBit <> lastState);
    lastState := MonitoredBit;
    
    // Reset timer on any change
    IF stateChanged THEN
        tmrSilence(IN := FALSE);
    END_IF;
    
    tmrSilence(IN := NOT stateChanged, PT := MaxSilenceTime);
    TimedOut := tmrSilence.Q;
    Healthy := NOT TimedOut;
    

    // Usage: monitor a communication heartbeat
    VAR
        wdgSCADA : FB_Watchdog;
        wdgPLC2  : FB_Watchdog;
    END_VAR
    
    wdgSCADA(Enable := TRUE, MonitoredBit := SCADA_Heartbeat, MaxSilenceTime := T#10S);
    wdgPLC2(Enable := TRUE, MonitoredBit := PLC2_Heartbeat, MaxSilenceTime := T#5S);
    
    IF wdgSCADA.TimedOut THEN
        // SCADA link lost — switch to local control
    END_IF;
    
    IF wdgPLC2.TimedOut THEN
        // Peer PLC not responding — safe state
    END_IF;
    

    Degrading gracefully when a component fails

    When one component fails, keep running in a reduced mode where that is safe, instead of stopping everything:

    FailureBad ResponseGood Response
    Temperature sensor failsShut down processUse last known good value + alarm + reduced rate
    Communication to VFD lostStop conveyorHold last speed command + alarm + timeout to safe stop
    One of two redundant pumps faultsStop flowRun single pump at higher speed + maintenance alarm
    HMI link lostOperators blindPLC continues on last setpoints + auto-mode
    Recipe download failsHalt batchRe-request + retry + hold at current step
    FUNCTION_BLOCK FB_RedundantSensor
    VAR_INPUT
        PV_Primary    : REAL;
        PV_Backup     : REAL;
        Primary_Valid : BOOL;
        Backup_Valid  : BOOL;
        DeviationMax  : REAL := 5.0;
    END_VAR
    VAR_OUTPUT
        PV_Output     : REAL;
        ActiveSensor  : INT;      // 1=Primary, 2=Backup, 0=None
        SensorMismatch: BOOL;
        Status        : FB_ErrorStatus;
    END_VAR
    
    IF Primary_Valid AND Backup_Valid THEN
        // Both healthy — check agreement
        IF ABS(PV_Primary - PV_Backup) > DeviationMax THEN
            SensorMismatch := TRUE;
            Status.Warning := TRUE;
            Status.WarningID := 3001;
            Status.ErrorMsg := 'Sensor mismatch — deviation exceeds limit';
        END_IF;
        PV_Output := PV_Primary;    // Primary is preferred
        ActiveSensor := 1;
    
    ELSIF Primary_Valid THEN
        PV_Output := PV_Primary;
        ActiveSensor := 1;
        Status.Warning := TRUE;
        Status.WarningID := 3002;
        Status.ErrorMsg := 'Backup sensor offline — no redundancy';
    
    ELSIF Backup_Valid THEN
        PV_Output := PV_Backup;
        ActiveSensor := 2;
        Status.Warning := TRUE;
        Status.WarningID := 3003;
        Status.ErrorMsg := 'Primary sensor failed — running on backup';
    
    ELSE
        ActiveSensor := 0;
        Status.Error := TRUE;
        Status.ErrorID := 3004;
        Status.ErrorMsg := 'Both sensors failed — no valid measurement';
    END_IF;
    

    Numbering error codes by source

    Group error codes in ranges so a technician can tell the source from the number alone:

    RangeSourceExample
    1000–1999Analog inputs / sensors1001 = Sensor signal invalid
    2000–2999Control loop warnings2001 = Sensor frozen
    3000–3999Redundancy / diagnostics3003 = Primary sensor failed
    4000–4999Communication4001 = Modbus timeout
    5000–5999Process unit level5001 = Multiple sub-faults
    6000–6999Motor / actuator6001 = VFD fault
    7000–7999Sequence / batch7001 = Step timeout
    8000–8999Safety system8001 = E-stop activated
    9000–9999System / infrastructure9001 = CPU overload
    // Error code lookup — useful for HMI display
    FUNCTION FC_ErrorCodeToText : STRING[80]
    VAR_INPUT
        ErrorCode : DINT;
    END_VAR
    
    CASE ErrorCode OF
        1001: FC_ErrorCodeToText := 'Sensor signal invalid — check wiring';
        1002: FC_ErrorCodeToText := 'Value out of physical range';
        2001: FC_ErrorCodeToText := 'Sensor frozen — no change detected';
        2002: FC_ErrorCodeToText := 'Spike detected — rate-of-change exceeded';
        3001: FC_ErrorCodeToText := 'Sensor mismatch — check calibration';
        4001: FC_ErrorCodeToText := 'Communication timeout — check network';
        5001: FC_ErrorCodeToText := 'Multiple faults — unit shutdown initiated';
        6001: FC_ErrorCodeToText := 'Drive fault — check VFD display';
        7001: FC_ErrorCodeToText := 'Sequence step timeout — check process';
        8001: FC_ErrorCodeToText := 'Emergency stop activated';
    ELSE
        FC_ErrorCodeToText := 'Unknown error code';
    END_CASE;
    

    Defensive programming checklist

    Apply these checks to every function block you write:

  • Validate all inputs with a range check, sensor health and physical plausibility.
  • Guard all divisions with FC_SafeDivide or explicit zero checks.
  • Bound all array indices by clamping them before access.
  • Limit all outputs by clamping actuator commands to safe ranges.
  • Put a timeout on every wait. Every state that waits for something needs a watchdog.
  • Report all anomalies through the standard error interface.
  • Fail to the safe state. When in doubt, stop the output.
  • Log the first occurrence, so you capture when the error first happened.
  • Patterns at a glance

    PatternPurpose
    Standard error interfaceEvery FB reports Error, ErrorID, ErrorMsg, Warning
    Input validationRange, plausibility, frozen-sensor, rate-of-change checks
    Safe mathFC_SafeDivide, FC_SafeArrayIndex; never trust denominators
    Fault propagationErrors bubble up through FB hierarchy to plant overview
    WatchdogDetect stalled processes and lost communication
    Graceful degradationRedundant sensors, last-known-good values, reduced-rate operation
    Error code systemNumbered ranges by source for fast troubleshooting
    You cannot prevent every error. What your code can do is detect each one immediately, respond safely and give the operator the information needed to fix it. Every error your PLC code does not handle is an incident waiting for the right conditions.