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_Conveyorfor 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.