All posts
plc-programming6 min read

10 PLC Programming Best Practices for Clean, Maintainable Code

Best practices for writing professional PLC code in Structured Text. Covers naming conventions, state machines, error handling, modularity, and testing strategies used by experienced automation engineers.

PLC Assist Team

10 PLC Programming Best Practices for Clean, Maintainable Code

Every automation engineer has inherited a PLC project with cryptic variable names, spaghetti logic, and zero comments. It's painful, time-consuming, and sometimes dangerous.

Here are 10 best practices that separate professional PLC code from the unmaintainable mess. These apply to Structured Text in CODESYS, TIA Portal, or any IEC 61131-3 platform.

1. Use a Consistent Naming Convention

The Hungarian notation adapted for PLC programming is the most widely used:

| Prefix | Type | Example | |--------|------|---------| | b | BOOL | bMotorRunning | | n | INT, DINT, UINT | nStepNumber | | r | REAL, LREAL | rTemperature | | s | STRING | sRecipeName | | t | TIME | tCycleTime | | dt | DATE_AND_TIME | dtLastMaintenance | | a or ar | ARRAY | arSensorValues | | st | STRUCT | stMotorData | | e | ENUM | eMachineState | | fb | Function Block instance | fbMotor1 | | ton | TON timer | tonStartDelay | | tof | TOF timer | tofCooldown | | rtrig | R_TRIG | rtrigStart |

Why it matters: When you see bDO_Valve3 in a 2000-line program, you instantly know it's a boolean digital output for valve 3. Without the prefix, Valve3 could be anything.

2. Structure Your Program with Function Blocks

Don't write all your logic in a single MAIN program. Break it into function blocks by machine section or function:

MAIN (Program)
  |-- fbConveyor1     : FB_Conveyor
  |-- fbConveyor2     : FB_Conveyor
  |-- fbMixer         : FB_Mixer
  |-- fbAlarmManager  : FB_AlarmManager
  |-- fbRecipes       : FB_RecipeManager

Each function block should:

  • Have one clear responsibility
  • Be testable in isolation
  • Be reusable (same FB_Conveyor for all conveyors)
  • Communicate through inputs/outputs, not global variables

3. Use State Machines for Sequential Logic

Nested IF-ELSE chains become unreadable fast. State machines are clearer:

// Bad -- nested conditions
IF bStep1Done AND NOT bStep2Done THEN
    IF bSensorOK THEN
        bValve := TRUE;
        IF bTimerDone THEN
            bStep2Done := TRUE;
        END_IF
    END_IF
END_IF

// Good -- state machine
CASE nState OF
    10: // Wait for step 1
        IF bStep1Done THEN
            nState := 20;
        END_IF

    20: // Check sensor
        IF bSensorOK THEN
            bValve := TRUE;
            tonStep(IN := TRUE, PT := T#5S);
            nState := 30;
        END_IF

    30: // Wait for timer
        IF tonStep.Q THEN
            bValve := FALSE;
            tonStep(IN := FALSE);
            nState := 40;
        END_IF
END_CASE

Use enum types instead of magic numbers for states (see example in our function block examples).

4. Never Use Global Variables for Inter-FB Communication

Global Variable Lists (GVLs) should only be used for:

  • I/O mappings
  • System-wide constants
  • HMI interface variables

For everything else, pass data through function block inputs and outputs:

// Bad -- hidden dependency via global
FUNCTION_BLOCK FB_Heater
    IF GVL.rTemperature > rSetpoint THEN  // Where does this come from?
        bHeaterOn := FALSE;
    END_IF

// Good -- explicit dependency via input
FUNCTION_BLOCK FB_Heater
VAR_INPUT
    rActualTemp : REAL;  // Caller provides this
    rSetpoint   : REAL;
END_VAR
    IF rActualTemp > rSetpoint THEN
        bHeaterOn := FALSE;
    END_IF

This makes dependencies visible and function blocks testable.

5. Handle Errors at Every Level

Every function block should report its status:

FUNCTION_BLOCK FB_Pump
VAR_OUTPUT
    bRunning  : BOOL;
    bError    : BOOL;
    nErrorID  : INT;    // 0 = no error
    sErrorMsg : STRING(80);
END_VAR

Common error handling pattern:

IF bOverpressure THEN
    bError := TRUE;
    nErrorID := 101;
    sErrorMsg := 'Overpressure detected';
    nState := STATE_ERROR;
ELSIF bSensorFault THEN
    bError := TRUE;
    nErrorID := 102;
    sErrorMsg := 'Pressure sensor fault';
    nState := STATE_ERROR;
END_IF

The calling program should check errors:

fbPump1(bStart := bStartCmd, rPressure := rPressureSensor);
IF fbPump1.bError THEN
    // Log, notify operator, take safe action
END_IF

6. Use Constants Instead of Magic Numbers

// Bad
IF rTemperature > 85.0 THEN
    nState := 30;
END_IF

// Good
VAR CONSTANT
    TEMP_HIGH_ALARM : REAL := 85.0;  // Degrees C
END_VAR

IF rTemperature > TEMP_HIGH_ALARM THEN
    nState := E_State.ERROR;
END_IF

When you need to change the threshold, you change it in one place. When someone reads the code, they understand the intent.

7. Comment the Why, Not the What

// Bad -- describes what the code does (obvious from the code itself)
// Set bValve1 to TRUE
bValve1 := TRUE;

// Good -- explains WHY
// Open bypass valve to prevent water hammer during pump startup
bValve1 := TRUE;

// Also good -- documents a non-obvious constraint
// Must wait 2s for pressure to stabilize before reading sensor
// (sensor datasheet specifies 1.5s settling time)
tonSettle(IN := TRUE, PT := T#2S);

8. Design for Safe Defaults

When your PLC starts up, or when a function block initializes, all outputs should be in a safe state:

FUNCTION_BLOCK FB_GasValve
VAR_OUTPUT
    bOpen : BOOL;  // Defaults to FALSE = valve closed = safe
END_VAR

Think about what happens if:

  • The PLC restarts unexpectedly
  • Communication to HMI is lost
  • A function block enters an error state

In all cases, outputs should default to the safest state (usually: motors off, valves closed, heaters off).

9. Keep I/O Mapping Separate

Create a dedicated mapping layer between your physical I/O and your logic:

// GVL_IO -- maps physical addresses to named variables
VAR_GLOBAL
    // Inputs
    bDI_EmergencyStop    AT %IX0.0 : BOOL;  // NC contact
    bDI_MotorFeedback    AT %IX0.1 : BOOL;
    rAI_Temperature      AT %IW0   : REAL;

    // Outputs
    bDO_MotorContactor   AT %QX0.0 : BOOL;
    bDO_AlarmLight       AT %QX0.1 : BOOL;
    rAO_SpeedSetpoint    AT %QW0   : REAL;
END_VAR

Your function blocks should never reference %IX or %QX addresses directly. Always go through the mapping GVL.

Benefits:

  • Change I/O addresses in one place
  • Test FBs without hardware (just set the GVL variables)
  • Clear overview of all I/O points

10. Version Control Your Code

Modern PLC projects should be in Git, just like any other software project. CODESYS supports exporting projects to XML or using the PLCopenXML format.

Version control gives you:

  • History of every change
  • Ability to roll back mistakes
  • Branch-based development for new features
  • Code review before deploying to production
  • Blame tracking (who changed what and when)

If your PLC platform doesn't support text-based project formats natively, at minimum export and commit a snapshot before every commissioning trip.

Putting It All Together

A well-structured PLC project looks like this:

Project
  |-- GVL_IO           (I/O mapping only)
  |-- GVL_Constants     (System-wide constants)
  |-- GVL_HMI           (HMI interface variables)
  |
  |-- MAIN              (Program -- calls FBs, no logic here)
  |
  |-- FB_Conveyor       (Reusable: start/stop, speed, jam detection)
  |-- FB_Motor          (Reusable: interlock, overcurrent, runtime)
  |-- FB_Alarm          (Reusable: debounce, acknowledge, latch)
  |-- FB_Sequence       (Reusable: state machine with timeout)
  |
  |-- DUT_MachineState  (Enum for states)
  |-- DUT_Recipe        (Struct for recipe data)
  |-- DUT_AlarmEntry    (Struct for alarm info)

Every variable has a meaningful name. Every function block has one job. Every state machine uses enums. Every error is handled. Every threshold is a constant.

Speed Up with AI

Following these best practices is important, but it takes time. AI code generation tools can help you scaffold function blocks, generate state machines, and add documentation -- all following these patterns automatically.

PLC Assist is built for CODESYS developers who want to write clean, professional Structured Text faster. The AI understands IEC 61131-3 patterns and generates code that follows the best practices outlined in this article.


PLC Assist is an AI-powered engineering assistant for industrial automation. Start free -- no credit card required.