Write for the engineer at 3 a.m.
Every program you write will be read by someone standing in front of a stopped machine. A palletiser has dropped a layer, the line manager is on the phone, and a maintenance technician with a laptop has just gone online with your code. They did not write it. They have never seen your design notes. They have twenty minutes before the shift supervisor starts asking whether to call the integrator.
That reader is the person your style is for. The compiler accepts x1 := y AND z7; as happily as Conveyor_Run := StartPermit AND NOT JamDetected;. The technician does not. In Structured Text, style decides whether a fault takes ten minutes or two hours to find, and whether the next engineer adds a feature cleanly or bolts on a second copy of the logic because they could not find the first.
The rules in this chapter come from commissioning and fault-finding real lines. None of them is clever, and every one of them is broken somewhere every day. They cover names, numbers, comments, who writes each output, the order of a scan, how to cut a machine into programs and blocks, and what changes when the same logic moves between CODESYS, TwinCAT, TIA Portal and Studio 5000. The chapter ends with a review checklist, because a style that nobody checks lasts about as long as the first deadline.
Names that carry type, direction and unit
A good variable name answers three questions without scrolling to the declaration: what is it, which way does it flow, and what unit is it in.
Start with what it is. Name the thing on the machine, not the programming idea. InfeedConveyor_Run beats Motor3, and Motor3 beats Q3. If the electrical drawings call the pump P-101, put P101 in the name so the tag and the drawing can be matched with a search. The technician at 3 a.m. has the drawing open on one screen and your code on the other; a shared word between them saves a phone call.
Then say which way it flows. Many shops prefix physical I/O so you can tell at a glance what is wired: DI_ / DO_ / AI_ / AO_, or i_ / q_ in the IEC style. Internal memory gets no prefix. Any letters will do, as long as a reader can tell instantly whether writing to the variable switches a contactor.
Finally, put the unit at the end of every analog value: Level_mm, Flow_m3h, Pressure_bar, Temp_degC, FillWeight_g. This is the cheapest bug prevention in automation. A setpoint of 1.5 is fine for Pressure_bar and a disaster for Pressure_mbar, and only the name tells you which one you are looking at. Times get a unit too when they are stored as numbers: Dwell_ms : DINT says what Dwell : DINT hides.
The PLCopen coding guidelines and Beckhoff's own TwinCAT conventions go further and add a type prefix to every variable: bStart (BOOL), nCount (integer), fLevel or rLevel (REAL), tDelay (TIME), sMessage (STRING), stTank (a STRUCT), eMode (an ENUM), fbPump (a function-block instance), aLevels (an array). Types get their own prefix: ST_Alarm, E_Mode, FB_Pump. The exact letters differ between guidelines. They all insist on consistency.
| Kind | Example | Why it helps |
|---|---|---|
| Physical input | DI_GuardClosed | Writing to it is pointless; it is overwritten every scan |
| Physical output | DO_FillValve | Writing to it moves metal |
| Analog with unit | AI_Level_mm, Speed_rpm | Unit mistakes become visible |
| FB type / instance | FB_Valve / fbInletValve | Separates the template from the copy |
| Constant | MAX_FILL_TIME, TANK_SPAN_MM | Capitals say "nobody writes this at runtime" |
This course uses plain PascalCase names in most examples so the logic stays readable for beginners. On a real project, pick one convention, write it on a single page, and enforce it in code review. Mixed conventions are worse than any single one, because a reader who learns that b means BOOL in one block and then meets bPressure : REAL in the next stops trusting every name.
PROGRAM Main
VAR CONSTANT
RAW_FULL_SCALE : REAL := 27648.0; (* S7 analog card: 4-20 mA -> 0..27648 *)
TANK_SPAN_MM : REAL := 2000.0; (* level transmitter range 0..2000 mm *)
HIGH_LEVEL_MM : REAL := 1800.0; (* 200 mm below the overflow lip *)
END_VAR
VAR
AI_Level_Raw : INT := 13824; (* input: raw counts from the card *)
Level_mm : REAL; (* internal: engineering value *)
Level_pct : REAL;
HighLevel : BOOL;
END_VAR
Level_mm := INT_TO_REAL(AI_Level_Raw) * TANK_SPAN_MM / RAW_FULL_SCALE;
Level_pct := Level_mm / TANK_SPAN_MM * 100.0;
HighLevel := Level_mm >= HIGH_LEVEL_MM;
END_PROGRAMType 24883 into AI_Level_Raw and HighLevel comes on at exactly 1800 mm. Nothing in the body is a bare number except the 100 that converts a fraction to a percentage, and nobody needs a comment to understand that. Every other number has a name, a unit and a comment saying where it came from.
What a name is allowed to be
IEC 61131-3 sets the outer rules for an identifier: letters, digits and underscores, starting with a letter or an underscore, with no spaces. Case does not count, so FillValve, FILLVALVE and fillvalve are the same variable. Underscores do count, which means Fill_Valve and FillValve are two different variables that a tired reader will take for one. The standard also forbids two underscores in a row and a leading double underscore.
The vendors build on that. Rockwell applies the IEC rule in Studio 5000 and adds that a trailing underscore is not allowed either; most component names, tags included, are limited to 40 characters. That limit bites when a plant standard stacks an area, a device tag and a function into one name: Area3_Infeed_Conveyor_M101_RunFeedback is already 38. TIA Portal is the most permissive of the four, because the editor puts quotes around global names and so accepts spaces and most special characters inside them: "Pump 1 run" is a legal tag. Do not use that freedom. A name with a space in it cannot be pasted into CODESYS or Logix, cannot be used in most external tools without escaping, and splits in two in every search box.
CODESYS and TwinCAT preserve the case you typed and match without it, the same as the standard. That has a practical side effect: you can tidy capitalisation across a project without changing a single reference, but you cannot use case to tell two variables apart.
Hungarian prefixes: for and against
Type prefixes are the most argued-about convention in PLC code, and both sides have a point.
For: in a long FB body, bRun := nCount > 3 tells you the types at the point of use, and a prefix on an instance (fbPump) stops anyone mistaking a block for a value. Beckhoff's own libraries use the prefixes, so a TwinCAT project that follows them reads like the vendor code it calls.
Against: the prefix repeats what the declaration and the IDE tooltip already say, and it goes stale. Change nLevel : INT to REAL and you must rename it everywhere or the name lies. TIA Portal shows the type of every tag in the editor as you hover, and Studio 5000 shows it in the tag browser, so much Siemens and Rockwell code drops type prefixes and keeps only units and direction.
My own rule: prefix the things a reader can confuse (instances, enums, structures, arrays), never let a prefix and a type disagree, and always write the unit. A name like fbFillValve and Level_mm tells the reader more than rLevel ever will. Whatever you decide, decide it once for the project.
A naming standard on one page
The standard that gets followed is the one that fits on a sheet of A4 taped inside the panel door. A workable one for a packaging line looks like this:
| Part | Rule | Example |
|---|---|---|
| Device | Tag from the electrical drawing, without hyphens | M101, YV204, LT301 |
| Physical I/O | DI_ DO_ AI_ AO_ then the device and its signal | DI_M101_Running, DO_YV204_Open |
| Analog values | Engineering unit as the last part | LT301_Level_mm |
| Instances | fb plus the device | fbM101, fbYV204 |
| Types | FB_, ST_, E_ plus a noun | FB_Valve, ST_Alarm, E_FillStep |
| Constants | Capitals, unit last | FILL_TIMEOUT_MS, TANK_SPAN_MM |
| Booleans | A state or a question that reads TRUE | GuardClosed, AtTemp, never GuardStatus |
The last row catches more bugs than it looks as if it should. GuardStatus gives no hint which value means safe. GuardClosed is TRUE when the guard is closed, so IF GuardClosed THEN reads as English and IF NOT GuardClosed THEN reads as an alarm. Where the wiring is normally closed, the input name says so (DI_StopNC) and the read section converts it to a positive name before anything else uses it.
Constants instead of magic numbers
A magic number is a literal in the body whose meaning lives only in the head of the person who typed it. IF Level > 1800.0 is a magic number. So is T#4s7ms in a timer call, or 27648 repeated in nine places.
Magic numbers cause three predictable failures. The value changes on site (the tank gets a new transmitter with a 0–2500 mm range) and someone updates six of the nine copies. Two numbers happen to be equal for different reasons, and a search-and-replace changes both. And nobody can tell whether 1800 was calculated or guessed, so nobody dares touch it.
Declare them once in a VAR CONSTANT block, in capitals, with a comment that says where the number came from: the datasheet, the P&ID, a commissioning measurement. A constant cannot be written at runtime, so the compiler stops anyone from "temporarily" changing it from the HMI.
There is one judgement call. A value the operator or process engineer is expected to tune, such as a fill target or a mixing time per recipe, is not a constant. It is a parameter, and it belongs in retained memory or a recipe structure that the HMI can write and the program limits with LIMIT. Constants are for physics and hardware: scaling ranges, card resolutions, mechanical travel limits. Chapter 26 comes back to where project-wide constants live, and chapter 30 to parameters and recipes.
What the compiler does with a constant
On most platforms a constant costs nothing at runtime. CODESYS has a project-wide compile option, Replace constants, which is on by default: the compiler writes the value straight into the code wherever the constant is used, so no memory cell is read. The side effect surprises people when they first go online. A replaced constant cannot be written or forced, and its address cannot be taken with ADR. The pragma {attribute 'const_non_replaced'} keeps a single constant as a real memory cell, for the rare case where an external tool must read it.
TIA Portal has two places for constants. A block's interface has a Constant section for values local to that block, and the PLC tag table has a User constants tab for global ones. Both are fixed at compile time; to change one you change the project and download.
Studio 5000 has no constant section in an ST routine, because routines have no declarations at all. Plants keep such values in a controller-scoped tag, usually a structure of configuration values, and set its External Access attribute to Read Only so an HMI or another controller cannot write it. That protects it from outside; it does not stop your own logic writing to it, so a code review still has to look.
The comment syntax itself has moved over the years. The second edition of IEC 61131-3 knew only (* … *). The third edition, published in 2013, added // for a comment to the end of the line and /* … */ for a block. CODESYS, TwinCAT, TIA Portal SCL and Studio 5000 ST all accept // today, and it has become the normal choice for short notes at the end of a line. Keep (* … *) for headers and for commenting out a block while you test, and never commit code with large commented-out sections: version control already remembers the old code, and dead code in the listing is something the next reader has to read.
A short header at the top of every program or function block pays for itself the first time someone opens it at night: what the block controls, what it assumes about the hardware, and a dated change log of the changes that altered behaviour. Version control holds the full history; the header holds the warnings.
(* FB_FillHead: one filling head on line 2 (drawing 2-E-014, sheets 3 to 5)
Controls : clamp valve YV211, fill valve YV212, reads load cell WT210
Assumes : WT210 weight already in grams and tared by the scale terminal
clamp has no position feedback; 300 ms stroke measured at SAT
Changes that altered behaviour:
2026-03-12 fill timeout raised 2.5 s -> 3 s after supply pump change
2026-05-02 release now waits for BottleInPlace off for 200 ms (eye bounce)
*)
That is a plain listing, not a live program. The change log is short on purpose. It records the two things a fault-finder needs to know and could not guess from the code: a timeout that was changed on purpose, and a delay that exists because of a sensor problem. Without the second line, someone will delete the 200 ms as "unnecessary" within a year.
One owner per output
In chapter 1 you saw that if a program writes an output twice in one scan, only the last write reaches the terminal. That rule is behind one of the most common bugs in PLC code: two parts of the program both writing the same output, each written by a different person who did not know about the other.
Here is a drain pump with an automatic level control and a manual button, written the way it usually grows, one IF per feature:
PROGRAM Main
VAR
HighLevel : BOOL; (* input: level switch, TRUE = sump high *)
ManualDrain : BOOL; (* input: panel push button *)
DrainPump : BOOL; (* output *)
END_VAR
(* Automatic: pump while the sump is high *)
IF HighLevel THEN
DrainPump := TRUE;
ELSE
DrainPump := FALSE;
END_IF;
(* Added later: manual drain from the panel *)
IF ManualDrain THEN
DrainPump := TRUE;
ELSE
DrainPump := FALSE;
END_IF;
END_PROGRAMSwitch HighLevel on. The pump does nothing. The second IF runs after the first and its ELSE branch overwrites the automatic decision on every scan, so automatic mode is dead and only the manual button works. On a real machine this sump would overflow the first night nobody stood next to it.
A scan table shows what the variable does inside each scan, which a watch window never can, because the watch only ever shows the value at the end:
| Scan | HighLevel | ManualDrain | DrainPump after IF 1 | DrainPump after IF 2 | Output terminal |
|---|---|---|---|---|---|
| 1 | FALSE | FALSE | FALSE | FALSE | off |
| 2 | TRUE | FALSE | TRUE | FALSE | off |
| 3 | TRUE | TRUE | TRUE | TRUE | on |
| 4 | FALSE | TRUE | FALSE | TRUE | on |
On scan 2 the variable is TRUE for the few microseconds between the two IFs. On most controllers that never reaches the terminal, because outputs are copied out after the whole program has run. Rockwell is the exception worth knowing. A ControlLogix or CompactLogix controller updates I/O asynchronously to the logic scan, sending output data to each module at its requested packet interval (RPI). If that transmission happens to fall between the two writes, the module receives the intermediate value. On a Logix controller a double write can therefore produce a real, random, one-RPI pulse on the terminal, which is far harder to find than an output that is simply wrong.
The structural fix is a rule: every output is assigned in exactly one place. Each feature produces a request, an internal BOOL with a descriptive name, and one line at the end combines the requests with the permissives into the output. Anyone looking for "why is the pump on?" reads one line.
PROGRAM Main
VAR
HighLevel : BOOL; (* input: sump high level switch *)
ManualDrain : BOOL; (* input: panel push button *)
PumpTripped : BOOL; (* input: motor protection relay tripped *)
AutoRequest : BOOL;
ManualRequest : BOOL;
DrainPump : BOOL; (* output: the only line that writes it is below *)
END_VAR
AutoRequest := HighLevel;
ManualRequest := ManualDrain;
(* Single owner: requests OR-ed, permissives AND-ed *)
DrainPump := (AutoRequest OR ManualRequest) AND NOT PumpTripped;
END_PROGRAMNow each feature can be as complicated as it needs to be without touching the others. The trip is a permissive applied once, at the output, so no feature can bypass it. When you search the project for DrainPump := you get one hit. Most IDEs have a cross-reference view (CODESYS and TwinCAT call it Cross Reference List, TIA Portal has Cross-references, Studio 5000 has Cross Reference); with one owner per output, that list is short enough to trust.
Here is what "as complicated as it needs to be" looks like after a season on site. The sump gets a second level switch so the pump stops low instead of cycling on the high switch, the high switch must stay made for 2 s so a wave does not start the pump, and manual drain is only allowed while there is water above the low switch, because the pump's seal does not survive running dry.
PROGRAM Main
VAR CONSTANT
WAVE_DELAY : TIME := T#2s; (* high switch must stay made this long *)
END_VAR
VAR
LevelHigh : BOOL; (* input: high switch, TRUE = water at the high mark *)
LevelLow : BOOL; (* input: low switch, TRUE = water above the low mark *)
ManualDrain : BOOL; (* input: panel push button, hold to run *)
PumpTripped : BOOL; (* input: motor protection relay *)
HighDelay : TON;
AutoRequest : BOOL;
ManualRequest : BOOL;
DrainPump : BOOL; (* output *)
END_VAR
(* Auto: start on a steady high level, stop when the water drops below the low switch *)
HighDelay(IN := LevelHigh, PT := WAVE_DELAY);
IF HighDelay.Q THEN
AutoRequest := TRUE;
ELSIF NOT LevelLow THEN
AutoRequest := FALSE;
END_IF;
(* Manual: hold to run, never dry *)
ManualRequest := ManualDrain AND LevelLow;
(* Command: still one line *)
DrainPump := (AutoRequest OR ManualRequest) AND NOT PumpTripped;
END_PROGRAMSwitch on LevelLow, then LevelHigh, and watch HighDelay.ET run to 2 s before AutoRequest sets. Switch LevelHigh off: the pump keeps going, because the level is still above the low switch. Switch LevelLow off and it stops. Try ManualDrain with LevelLow off and nothing happens. The auto request grew a timer and a hysteresis band and the manual request grew a permissive, and the command line did not change at all.
AutoRequest is written in two places inside one IF, which looks as if it breaks the rule. It doesn't, because the rule is about outputs and about decisions scattered through a program. A latch needs a place where it sets and a place where it resets, and keeping both in one IF … ELSIF, a few lines apart, is the clearest way to write one. What you must never do is set a memory bit in one program and reset it in another.
Read inputs, calculate, decide, command
Chapter 1 ended with a rule: calculate before you decide, decide before you command. In a full program that becomes a layout. Every well-organised ST program or function block body reads in four sections, in this order:
- Read inputs. Copy or condition raw signals: debounce, invert normally-closed contacts into positive logic, scale analog counts to engineering units. After this section, nothing below touches a raw input.
- Calculate. Derived values: totals, averages, rates, targets from the recipe.
- Decide. Modes, permissives, state machines and alarm detection, everything that turns facts into intentions. Requests are produced here.
- Command. The single owner of each output, combining requests with interlocks. Nothing but output assignments.
PROGRAM Main
VAR CONSTANT
TEMP_SPAN_DEGC : REAL := 150.0; (* PT100 transmitter 0..150 degC *)
RAW_FULL_SCALE : REAL := 27648.0;
TEMP_TARGET : REAL := 65.0; (* recipe value, fixed for this example *)
TEMP_MAX : REAL := 90.0; (* above this the product scorches *)
END_VAR
VAR
DI_StopNC : BOOL := TRUE; (* input: stop button, normally closed *)
DI_Start : BOOL; (* input: start button *)
AI_Temp_Raw : INT := 8000; (* input: raw analog counts *)
StopPressed : BOOL;
Temp_degC : REAL;
Running : BOOL;
TooHot : BOOL;
HeatRequest : BOOL;
Heater : BOOL; (* output *)
Agitator : BOOL; (* output *)
END_VAR
(* --- 1. Read inputs ------------------------------------------- *)
StopPressed := NOT DI_StopNC; (* NC wiring: a broken wire also stops *)
Temp_degC := INT_TO_REAL(AI_Temp_Raw) * TEMP_SPAN_DEGC / RAW_FULL_SCALE;
(* --- 2. Calculate --------------------------------------------- *)
TooHot := Temp_degC >= TEMP_MAX;
(* --- 3. Decide ------------------------------------------------ *)
Running := (DI_Start OR Running) AND NOT StopPressed;
HeatRequest := Running AND Temp_degC < TEMP_TARGET;
(* --- 4. Command ----------------------------------------------- *)
Agitator := Running;
Heater := HeatRequest AND Agitator AND NOT TooHot; (* never heat a still tank *)
END_PROGRAMPress DI_Start briefly and the mixer latches on. At 8000 counts the tank is about 43 °C, so the heater runs. Type 13000 (about 70 °C) and the heater drops out. Switch DI_StopNC off, which is what pressing a normally-closed stop button does, and everything stops. Questions about this program are easy to answer. Why is the heater off? Look at the one line that writes it, then at its three ingredients, each computed in an earlier section.
The section banners are comments here. TIA Portal SCL has a real REGION … END_REGION statement and TwinCAT 3 has {region "…"} pragmas; both let the editor fold sections closed. The syntax is a convenience. The order is the part that prevents bugs.
Why "read inputs" is a section of its own
On most controllers the runtime already takes a snapshot of the inputs for you. An S7-1500 updates its process image of inputs before the cyclic OB starts and writes the process image of outputs after it ends; CODESYS and TwinCAT copy a task's mapped inputs at the start of each task cycle and its outputs at the end. Within one scan, an input variable cannot change under your feet.
Logix is different, and it is the reason many Rockwell plant standards have a dedicated input routine. ControlLogix and CompactLogix controllers update I/O data asynchronously with the execution of logic. An input module's data arrives at its RPI, whenever that falls, so a tag such as Local:1:I.Data.3 can change between line 10 and line 200 of the same routine. Code that reads the raw tag twice can see two different values in one scan and take both branches of a decision that should have been exclusive. Copying every input into an internal tag once, at the top of the first routine, gives Logix code the same snapshot that other platforms provide. It costs a few microseconds and removes a whole class of bug that only ever appears on a fast machine.
Two more jobs belong in this section. Normally-closed contacts are converted to positive names here (StopPressed := NOT DI_StopNC;), so nothing further down needs to remember the wiring. And signal quality is decided here: a wire break on an analog input, a module that has dropped off the network, a feedback that disagrees with its command. Chapter 19 showed the analog checks. The style point is that every later section works only with values that have already been judged believable.
Initialisation on the first scan also sits at the top, before anything reads the values it sets. TIA Portal gives you a startup OB (OB100) that runs once before the cyclic program. Logix has the first-scan status bit S:FS, TRUE for the first scan after the controller enters Run. In CODESYS the common idiom is a BOOL declared := TRUE, used once and then cleared. Wherever it lives, first-scan code should be short and boring: set default modes, clear run latches, never start anything.
Programs, function blocks and functions
A program that does everything in one 900-line body is as hard to maintain as one split into 60 tiny blocks. The split that works on real machines follows the physical plant.
A program per machine area or unit: Infeed, Filler, Capper, Palletiser. Each one is assigned to a task. Fast, critical logic (a 2 ms registration cut) gets its own fast task; HMI housekeeping can run at 100 ms. Programs talk to each other through a small, named interface, either a global structure or a handful of documented variables, and do not reach into each other's internals.
A function block per type of equipment that has memory: every motor, valve, analog input and alarm. Write FB_Valve once, test it once, and instantiate it forty times. Chapter 18 covers function blocks in depth; the style point is that if you copy and paste the same ten lines for a second valve, you have just written the second half of a future bug.
A function for anything that is a pure calculation with no memory: scaling, unit conversion, limit checks, a checksum. Same inputs, same result, every time. See chapter 17.
FUNCTION F_Scale : REAL
VAR_INPUT
Raw : INT;
SpanEU : REAL; (* engineering value at full scale *)
END_VAR
(* Clamp first: an over-range card reads up to 32767 on overload *)
F_Scale := INT_TO_REAL(LIMIT(0, Raw, 27648)) * SpanEU / 27648.0;
END_FUNCTION
PROGRAM Main
VAR CONSTANT
LEVEL_SPAN_MM : REAL := 2000.0;
PRESS_SPAN_BAR : REAL := 10.0;
END_VAR
VAR
AI_Level_Raw : INT := 13824;
AI_Press_Raw : INT := 5530;
Level_mm : REAL;
Pressure_bar : REAL;
END_VAR
Level_mm := F_Scale(Raw := AI_Level_Raw, SpanEU := LEVEL_SPAN_MM);
Pressure_bar := F_Scale(Raw := AI_Press_Raw, SpanEU := PRESS_SPAN_BAR);
END_PROGRAMThe clamp lives in one place. When someone discovers that the card actually reads 32767 on a wire break and wants a fault for it, they change one function and every analog in the plant gets the fix. Try typing 30000 into AI_Level_Raw: the level stops at 2000 mm instead of reporting a tank fuller than its walls.
Call function blocks and functions with named arguments (Raw := …), as above. Positional calls work for functions, but they break silently when someone inserts a new input in the middle of the declaration.
The same split in each product
Each vendor has its own names for these pieces, and porting a design is much easier once you see that the pieces line up:
| Idea | CODESYS / TwinCAT 3 | TIA Portal (S7-1200/1500) | Studio 5000 (Logix) |
|---|---|---|---|
| Unit of the machine | PROGRAM, assigned to a task | FB with its instance DB, called from a cyclic OB (OB1, or a cyclic interrupt OB such as OB30) | Program inside a task, entered through its main routine |
| Reusable block with memory | FUNCTION_BLOCK | FB, as a single instance or a multi-instance | Add-On Instruction with a backing tag |
| Pure calculation | FUNCTION | FC | No user functions; an AOI, or a routine called with JSR |
| User data type | DUT (STRUCT, ENUM) | PLC data type | User-defined data type (UDT) |
| Project-wide data | GVL | Global data block | Controller-scoped tags |
| Data local to a unit | VAR in the program | Static section of the FB | Program-scoped tags, or program parameters |
Studio 5000 deserves a word on that last row. Up to version 23, program-scoped tags were private to their program, and data shared between programs had to be controller-scoped. Version 24 added program parameters: a program can declare Input, Output, InOut and Public parameters, and other programs connect to those instead of to its internals. That is the IEC idea of a program interface arriving in Logix, and on a new project it is the way to keep areas apart.
How big should a block be?
There is no number in any standard, so here is what has worked for me. A program body that does not fit on two screens is a candidate for splitting along a physical boundary. A function block with more than about fifteen inputs is usually doing two jobs, and the second job wants its own block. A function block that only ever has one instance and no internal state probably wants to be part of the program that calls it. And a block of three lines that is called from one place is noise: inline it.
The other test is the sentence. If you cannot describe a block in one sentence that names equipment ("controls one filling head: clamp, valve, weight check"), it is shaped around the code and not around the machine.
A project laid out this way reads like the machine. In a CODESYS or TwinCAT project tree it might look like this:
Application
DUTs E_Mode, E_FillStep, ST_Alarm, ST_FillRecipe
GVLs GVL_Line (interface between areas), GVL_Hmi
POUs
Devices FB_Motor, FB_Valve, FB_AnalogIn, FB_Alarm
Functions F_Scale, F_InBand
Areas PRG_Infeed, PRG_Filler, PRG_Capper, PRG_Outfeed
Tasks FastTask 2 ms: PRG_Filler
MainTask 10 ms: PRG_Infeed, PRG_Capper, PRG_Outfeed
HmiTask 100 ms: PRG_Hmi
Device blocks never know which area they are in, area programs never call each other, and the only shared data is in two named lists. When the customer orders a second filler, you add an instance and a program, not a copy of the first one with every name edited by hand.
ST across vendors
IEC 61131-3 standardises the language and leaves the product to each vendor. The statements (IF, CASE, FOR, :=) move between platforms almost unchanged. Declarations, I/O access, timers and the way code is packaged do not. Here is one piece of logic in three dialects: a transfer pump that runs while a start latch is set, and trips if it has run 10 s without the downstream flow switch closing (a dry-run or closed-valve fault).
CODESYS and Beckhoff TwinCAT 3 follow the standard closely. Variables are declared in VAR blocks in the same file as the code; physical I/O is linked to variables declared AT %I* / AT %Q* (TwinCAT) or mapped in the device tree (CODESYS).
PROGRAM P_Transfer
VAR
bStartPB AT %I* : BOOL; // linked to the EL1008 input terminal
bStopPB AT %I* : BOOL; // NC contact
bFlowOK AT %I* : BOOL;
bPumpRun AT %Q* : BOOL;
bRunLatch : BOOL;
bDryRunFault : BOOL;
fbDryRun : TON;
END_VAR
VAR CONSTANT
DRY_RUN_TIME : TIME := T#10S;
END_VAR
{region "Decide"}
bRunLatch := (bStartPB OR bRunLatch) AND bStopPB AND NOT bDryRunFault;
fbDryRun(IN := bRunLatch AND NOT bFlowOK, PT := DRY_RUN_TIME);
IF fbDryRun.Q THEN
bDryRunFault := TRUE; // reset from the HMI, see chapter 22
END_IF
{endregion}
bPumpRun := bRunLatch;
Siemens SCL in TIA Portal (S7-1200/S7-1500) has the same statements with three visible differences. Local variables and instances are written with a # prefix, which the editor adds as you type. Global tags from the PLC tag table and data blocks are written in double quotes: "Transfer_StartPB", "DB_Line".PumpCount. And REGION is part of the language. An IEC timer is declared as a multi-instance in the block's Static section.
// FB "FB_Transfer", Static: statRunLatch : Bool; statDryRunFault : Bool;
// instDryRun : TON_TIME;
REGION Decide
#statRunLatch := ("Transfer_StartPB" OR #statRunLatch)
AND "Transfer_StopPB" AND NOT #statDryRunFault;
#instDryRun(IN := #statRunLatch AND NOT "Transfer_FlowOK",
PT := T#10S);
IF #instDryRun.Q THEN
#statDryRunFault := TRUE;
END_IF;
END_REGION
REGION Command
"Transfer_PumpRun" := #statRunLatch;
END_REGION
Rockwell Studio 5000 Logix Designer (ControlLogix, CompactLogix) is the biggest jump. An ST routine contains statements only: no VAR blocks, no PROGRAM header. Every tag is created in the tag database, either controller-scoped (global) or program-scoped (local to the program that contains the routine). I/O appears as module tags such as Local:1:I.Data.0, usually aliased to a readable name. The ST timer is TONR, which works on a tag of type FBD_TIMER with a preset in milliseconds as a DINT. BOOL tags are normally written 1 and 0.
// Routine "Transfer" (Structured Text). Tags are program-scoped:
// Start_PB, Stop_PB, Flow_OK (aliases to Local:1:I.Data.x),
// Pump_Run (alias to Local:2:O.Data.0), Run_Latch, DryRun_Fault : BOOL
// DryRun_TMR : FBD_TIMER
Run_Latch := (Start_PB OR Run_Latch) AND Stop_PB AND NOT DryRun_Fault;
DryRun_TMR.TimerEnable := Run_Latch AND NOT Flow_OK;
DryRun_TMR.PRE := 10000; // ms
TONR(DryRun_TMR);
IF DryRun_TMR.DN THEN
DryRun_Fault := 1;
END_IF;
Pump_Run := Run_Latch;
Reusable logic in Studio 5000 is an Add-On Instruction (AOI), Rockwell's equivalent of a function block: input, output and in-out parameters plus local tags, with a backing tag per instance that holds its memory. In ST you call it by name with the backing tag as the first argument, followed by any required parameters. AOIs cannot be edited online while the controller runs, which shapes how people use them: keep them small, stable and well tested. Studio 5000 also has a non-retentive assignment, [:=], which clears its target when the controller enters Run, useful for latches that must not survive a mode change.
A porting checklist
When you carry logic from one platform to another, the statements are the easy part. These are the differences that cost time, roughly in the order you will meet them:
| Topic | CODESYS / TwinCAT 3 | TIA Portal SCL | Studio 5000 ST |
|---|---|---|---|
| Declarations | VAR blocks in the POU | Block interface table (Input, Output, InOut, Static, Temp, Constant) | Tags in the tag database; none in the routine |
| On-delay timer | TON instance, PT : TIME | TON_TIME or IEC_TIMER instance, PT : TIME | TONR on an FBD_TIMER tag, .PRE in ms as DINT |
| Rising edge | R_TRIG instance | R_TRIG instance | OSRI on an FBD_ONESHOT tag |
Default STRING length | 80 characters | 254 characters | 82 characters (STRING data type) |
| Natural integer | Any; 32-bit CPUs handle all types at full speed | INT for analog raw values, DINT for counts | DINT; SINT and INT are converted before arithmetic |
| Global names in code | Plain identifier, or GVL.Name | In double quotes: "DB_Line".Count | Plain tag name |
| BOOL literals | TRUE / FALSE | TRUE / FALSE | 1 / 0 by habit; TRUE / FALSE are not used in older code |
The string length is the one that causes silent damage. A recipe name declared STRING in CODESYS holds 80 characters; the same declaration in TIA Portal holds 254, and a Logix STRING holds 82. Move a structure that contains strings across a gateway or a data exchange, and the byte offsets of everything after the first string no longer match. Declare string lengths explicitly (STRING[32]) in any structure that leaves the controller, and chapter 16 has the rest of the string story.
Which dialect you work in is rarely your choice; the plant standard decides. The habits in this chapter (named units, constants, one owner per output, read-calculate-decide-command) carry across all of them unchanged, and a reader who knows them can find their way round a project in any of the four products.
Look at the last case: the belt starts again the moment EStopOK returns, because the auto request is still there. That is acceptable for a grading exercise and wrong on a real conveyor, where resetting the safety circuit must not by itself restart anything. Chapter 22 shows how to drop the run request on a stop so a fresh start command is needed. Keep in mind too that EStopOK here is only the standard program's view of the safety circuit. The emergency stop itself is done by a safety relay or safety controller that removes power from the drive, designed to ISO 13849 or IEC 62061; the PLC reads its status and reacts.
The one-case lag in the starter is worth a moment, because it shows up on real machines as "the valve reacts late, sometimes". At a 10 ms scan a lag of one scan is 10 ms and nobody notices. In a 100 ms task on a tank whose level rises 50 mm per second, it is 5 mm of extra level on every close; harmless here, and a real error on a dosing valve that adds 20 g per scan. Code that runs in the wrong order always works, just a scan late, so it passes a casual test and fails on the day the timing gets tight.
Reviewing ST for style
A style that nobody checks stops being followed the first time a deadline is close. A code review does not need a meeting room; it needs a second engineer, the diff, and a short list of questions. This is the list I use on a PLC project, and each line has caught a real bug at least once:
| Question | How to check it |
|---|---|
| Does every physical output have exactly one writer? | Cross-reference each DO_ and AO_ tag; more than one write location is a finding |
| Is any input read raw below the read section? | Search for the DI_ and AI_ prefixes; they should appear only at the top |
| Are there bare numbers in the body? | Look for literals other than 0, 1 and 100; each needs a constant with a source |
| Does every analog value carry its unit in the name? | Read the declarations; Level without _mm or _pct is a finding |
| Is every timer and FB instance called on every scan? | Look for instance calls inside IF or CASE branches |
| Does each IF that sets an output also clear it? | An IF with := TRUE and no matching FALSE is a latch; is it meant to be? |
| Can the code restart something on its own after a trip, a mode change or power-up? | Follow every run latch back to what clears it |
| Are vendor-specific features isolated? | Retentive attributes, system calls and diagnostics belong in a few named places |
| Would the technician at 3 a.m. understand the comments? | Read only the comments; do they explain reasons, sources and warnings? |
Review the diff, not the whole project, and review it before it reaches the machine. A finding on paper costs a minute. The same finding during a site acceptance test costs an afternoon with the customer watching. The design book covers how to organise a whole project so that reviews like this stay small.
Some of the list can be automated. CODESYS Static Analysis checks naming rules and finds things like multiple writes to one output, and Siemens sells TIA Portal Test Suite Advanced, which includes a style-guide checker for project-specific naming rules. Tools catch the mechanical findings. The questions about restarts and reasons still need a person who knows the machine.
From tidy code to safe code
Every habit in this chapter shortens the time between a fault and its cause. A name with a unit stops a bad setpoint before it is typed. A constant with a source can be changed with confidence. A single owner means "why is it on?" has a one-line answer, and the read-calculate-decide-command order means every output is computed from this scan's facts and not the last one's. None of these habits is tied to a vendor, which is why they survive a move from CODESYS to TIA Portal to Studio 5000 when the syntax does not.
The single owner also gives you one place to make every output go safe, which is the subject of the next chapter. Alarms and fault handling covers detecting faults, latching alarms until an operator acknowledges them, recording which fault came first, and deciding per output what "safe" means when the machine stops being healthy.