Chapter 21 / 24 · Part 6 · Professional ST

Program structure and style

Name, comment and organise ST so it reads like the machine, and carry it across CODESYS, TwinCAT, TIA and Studio 5000.

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 real customer of your style. The compiler accepts x1 := y AND z7; as happily as Conveyor_Run := StartPermit AND NOT JamDetected;. The technician does not. Style in Structured Text is not decoration: it 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 are clever. All of them are broken every day.

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.

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.

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. The point is not the letters; it is that a reader instantly knows whether writing to the variable switches a contactor.

What unit it is in. 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.

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; what they agree on is consistency.

KindExampleWhy it helps
Physical inputDI_GuardClosedWriting to it is pointless; it is overwritten every scan
Physical outputDO_FillValveWriting to it moves metal
Analog with unitAI_Level_mm, Speed_rpmUnit mistakes become visible
FB type / instanceFB_Valve / fbInletValveSeparates the template from the copy
ConstantMAX_FILL_TIME, TANK_SPAN_MMCapitals 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.

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_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: AI_Level_Raw.

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

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 — a fill target, 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.

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.

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 the source of 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_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: HighLevel, ManualDrain.

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

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_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: HighLevel, ManualDrain, PumpTripped.

Now each feature can be as complicated as it needs to be — the auto request can grow hysteresis and a run-on timer — 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.

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:

  1. 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.
  2. Calculate. Derived values: totals, averages, rates, targets from the recipe.
  3. Decide. Modes, permissives, state machines, alarm detection — everything that turns facts into intentions. Requests are produced here.
  4. 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_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: DI_StopNC, DI_Start, AI_Temp_Raw.

Press 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 — that is what pressing a normally-closed stop button does — and everything stops. Notice how easy it is to answer questions about this program. 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. Whatever the syntax, the order is what matters.

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 — a global structure or a handful of documented variables — not by reaching 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_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: AI_Level_Raw, AI_Press_Raw.

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

ST across vendors

IEC 61131-3 gives you a common language, not a common product. 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 — the editor adds it 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.

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 they matter more than any syntax difference.

From tidy code to safe code

You now have the habits that make ST readable on any platform: names that carry the unit and the direction, constants with a source, comments that explain why, one owner per output, and a scan that reads, calculates, decides and commands in that order. You have also seen that moving between CODESYS, TwinCAT, TIA Portal and Studio 5000 is mostly a matter of declarations, I/O and timers.

The one-owner rule pays off most when something goes wrong. If every output is written in one place, there is one place to force it safe. The next chapter builds on exactly that: detecting faults, latching alarms until an operator acknowledges them, recording which fault came first, and making sure every output knows what to do when the machine stops being healthy.

Practise this chapter