Chapter 2 / 24 · Part 1 · First contact

Variables and data types

Declare BOOL, INT, DINT, REAL, TIME and STRING variables, give them initial values and choose the type that fits the signal.

Every variable has a size

In the last chapter ScanCount was a DINT and Switch was a BOOL, and you took both on trust. Now look at what those three or four letters actually decide. A variable in Structured Text is a named piece of controller memory with a fixed size and a fixed meaning. The type you write after the colon tells the compiler how many bits to reserve, which values fit in them, and which operations make sense. A BOOL holds one decision. An INT holds a whole number between −32,768 and 32,767 in 16 bits. A REAL holds a measurement with a decimal point. None of them grows when a value gets too big, and none of them warns you at runtime when it does.

In a scripting language a number simply gets larger. In a PLC the memory map is fixed when you download the program, so you choose the size up front — and if you choose badly the program still builds, still runs, and then produces a wrong number on the day the plant gets busy.

Declarations live in VAR … END_VAR blocks, one variable per line, in the form Name : TYPE := initial value;. The initial value is optional. Several names of the same type can share a line, separated by commas:

PROGRAM Main
VAR
    Running      : BOOL;                    (* input: line is running *)
    FillValve    : BOOL;                    (* output: filling valve solenoid *)
    BottlesToday : DINT := 41250;           (* a count that passes 32,767 by lunchtime *)
    BottleSize_l : REAL := 0.5;             (* litres per bottle *)
    Volume_l     : REAL;                    (* litres filled this shift *)
    FillTime     : TIME := T#2s500ms;       (* valve open time per bottle *)
    Recipe       : STRING[20] := 'Still 0.5 l';
    LowLevel, HighLevel : BOOL;             (* two tank switches, one line *)
END_VAR

FillValve := Running AND NOT HighLevel;
Volume_l  := DINT_TO_REAL(BottlesToday) * BottleSize_l;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: Running.

Each type here is there for a reason: the valve is on or off, the bottle count is a whole number that gets large, the bottle size is a measurement, the fill time is a duration, the recipe is text for the operator panel. The rest of this chapter is about making those choices on purpose.

BOOL and the integer family

BOOL is the most common type in any machine program: every push button, limit switch, contactor coil and pilot lamp is a BOOL, either TRUE or FALSE.

Whole numbers come in a family. The letter in front says how big, a U says unsigned (no negative values):

TypeBitsRangeTypical use
SINT8−128 … 127rarely; small signed offsets
USINT80 … 255a byte from a serial device
INT16−32,768 … 32,767analog raw values, small setpoints
UINT160 … 65,535Modbus registers, recipe numbers
DINT32−2,147,483,648 … 2,147,483,647counts, totals, positions in µm
UDINT320 … 4,294,967,295large unsigned counters
LINT64about ±9.2 × 10¹⁸energy totals, nanosecond timestamps

Two vendor facts are worth knowing before you choose. On a Siemens S7-1500, analog input modules deliver their nominal range as an INT from 0 to 27,648, so raw analog values are INT by convention in TIA Portal. On a Rockwell ControlLogix or CompactLogix, the native type is DINT: the controller converts SINT and INT to DINT before doing arithmetic on them, so using INT to "save memory" actually costs execution time. CODESYS and TwinCAT run on 32- or 64-bit processors and handle all of these at full speed.

Overflow wraps, it does not stop

When an integer is already at its maximum and you add one, it does not stop at the maximum and it does not raise an alarm. The bit pattern rolls over and the value jumps to the most negative number the type can hold. Step this program one scan at a time and watch the two counters part ways:

PROGRAM Main
VAR
    CountINT  : INT  := 32760;   (* 16 bits *)
    CountDINT : DINT := 32760;   (* 32 bits, same starting value *)
END_VAR

CountINT  := CountINT + 1;
CountDINT := CountDINT + 1;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it.

On the eighth scan CountINT goes from 32,767 to −32,768. CountDINT carries on to 32,768 without noticing anything. A real controller behaves the same way: Siemens, CODESYS and TwinCAT wrap silently, and Rockwell wraps too, but also sets a minor fault bit (S:V) that hardly anyone checks. Treat wrapping as something your choice of type must prevent, not something the controller will catch.

This bug has a typical shape. A part counter declared as INT works through commissioning, where the line makes a few hundred parts. Months later a sorting conveyor running at 10 parts a second passes 32,767 in under an hour, the counter goes negative, and a downstream comparison like "batch complete when count ≥ 500" stays false forever.

Unsigned types wrap at the bottom as well as at the top. 0 - 1 in a UINT is 65,535. That catches people who use unsigned types for "values that can't be negative", like a remaining quantity:

REAL and LREAL: measurements, not counts

REAL is a 32-bit floating-point number, the type for anything you measure: temperatures, pressures, flows, speeds, levels, weights. It handles decimals and a huge range (up to about 3.4 × 10³⁸), but only about 7 significant digits. LREAL is the 64-bit version with about 15 to 16 digits. Siemens S7-1500, CODESYS and TwinCAT support both; on Logix, LREAL exists only on the newer 5380/5580 controllers and with a restricted instruction set, so REAL is the everyday choice there.

Seven digits is plenty for a tank level of 1,234.567 litres. It is not enough for a totaliser. A REAL can represent every whole number up to 16,777,216 exactly, and above that the gaps between representable values become larger than 1. A flow totaliser in litres that adds 1.0 per pulse will stop increasing at about 16.8 million litres, because 16777216.0 + 1.0 rounds back to 16777216.0. Nothing faults. The number just stops moving, which is worse than a fault.

(* Static listing: a 32-bit REAL totaliser that silently stalls *)
Total_l := Total_l + 1.0;   (* stuck at 16777216.0 once it gets there *)

The fix is to count in an integer type (DINT litres, or LINT millilitres) and convert to REAL only for display, or to use LREAL. The same 7-digit limit is why you never compare two REAL values with =; chapter 3 shows that bug in action.

TIME, DATE and TIME_OF_DAY

Durations have their own type. A TIME literal is written with T# and units from days down to milliseconds: T#250ms, T#2s500ms, T#1m30s, T#8h. You can add and subtract durations and compare them, and every timer in this course takes its preset as a TIME. Using TIME instead of an INT called Delay removes the classic question "is that milliseconds or seconds?" from your program forever.

PROGRAM Main
VAR
    CycleTime    : TIME := T#1m30s;    (* one mixer batch *)
    FillTime     : TIME := T#45s;
    MixTime      : TIME := T#20s;
    DrainTime    : TIME;               (* whatever is left of the cycle *)
    CycleTime_ms : DINT;
    ShiftStart   : TIME_OF_DAY := TOD#06:00:00;
    BatchDate    : DATE := D#2026-10-23;
END_VAR

DrainTime    := CycleTime - FillTime - MixTime;
CycleTime_ms := TIME_TO_DINT(CycleTime);

END_PROGRAM
Live example — open this page with JavaScript to run and edit it.

Change FillTime in the editor to T#1m and watch DrainTime go negative: durations can be negative, and a negative drain time is a recipe error your program should check for.

Under the hood a TIME is an integer count of milliseconds, and that is where the vendors differ. On Siemens it is a signed 32-bit value, so the largest duration is about 24 days 20 hours. In CODESYS and TwinCAT it is unsigned 32-bit, about 49 days 17 hours. Both offer LTIME, a 64-bit count of nanoseconds, for anything longer or finer. Rockwell Logix has no TIME type at all: a Logix TIMER has .PRE and .ACC members that are plain DINT milliseconds, which is worth remembering when you move code between platforms.

Calendar values are separate types: DATE (D#2026-10-23), TIME_OF_DAY or TOD (TOD#06:00:00) and DATE_AND_TIME or DT (DT#2026-10-23-06:00:00). You will use them for shift reports, batch records and timestamps on alarms, and almost never for control. The controller's clock drifts, gets corrected by NTP and jumps at daylight-saving changes; a delay that must be 20 seconds belongs in a timer, never in a subtraction of two clock readings.

STRING: text for people

A STRING holds text: recipe names, operator messages, barcode contents. The length is fixed at declaration. STRING[20] reserves room for 20 characters; plain STRING gets a vendor default — 80 characters in CODESYS and TwinCAT, 254 on Siemens. Rockwell's STRING is a predefined structure with a .LEN member and an 82-character .DATA array. Assign something longer than the declared length and the end is cut off without a warning: 'CONVEYOR 12' stored in a STRING[8] reads 'CONVEYOR'.

Text is for operators and logs. Decisions should be made on numbers, booleans and enumerations, which compare in one instruction and cannot be misspelled. A program that runs the "dark roast" sequence when Recipe = 'Dark Roast' will one day meet 'Dark roast' from a new HMI screen. String functions and time arithmetic get their own chapter 16.

BYTE, WORD and DWORD: bits, not numbers

BYTE (8 bits), WORD (16 bits), DWORD (32 bits) and LWORD (64 bits) are bit strings. They have the same sizes as USINT, UINT, UDINT and ULINT, but they mean something different: a collection of individual on/off flags packed together, not a quantity. You meet them wherever a device sends status as a packed word. A SINAMICS G120 drive on PROFINET, for example, reports its state in status word 1 (ZSW1, per the PROFIdrive profile): bit 0 "ready to switch on", bit 1 "ready to operate", bit 2 "operation enabled", bit 3 "fault present", bit 7 "warning present", and so on. Hexadecimal literals such as 16#0437 make the bit pattern readable: every hex digit is exactly four bits.

You read a single bit with a dot and the bit number, counting from zero:

PROGRAM Main
VAR
    ZSW1          : WORD := 16#0437;   (* input: drive status word 1 *)
    ReadyToSwitch : BOOL;              (* bit 0 *)
    ReadyToRun    : BOOL;              (* bit 1 *)
    Running       : BOOL;              (* bit 2 *)
    DriveFault    : BOOL;              (* bit 3 *)
    DriveWarning  : BOOL;              (* bit 7 *)
END_VAR

ReadyToSwitch := ZSW1.0;
ReadyToRun    := ZSW1.1;
Running       := ZSW1.2;
DriveFault    := ZSW1.3;
DriveWarning  := ZSW1.7;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: ZSW1.

Type 8 into ZSW1 (only bit 3 set) and only DriveFault lights. Type 183 (16#00B7) and you have a running drive with a warning. CODESYS, TwinCAT and Logix accept the .n syntax shown here; in TIA Portal SCL the same bit is written ZSW1.%X3. The idea is identical.

The rule of thumb: if you would ever add two of these values, it is a number and belongs in an integer type; if you only test or set individual bits, it is a bit string. Masking several bits at once with AND comes in chapter 4.

Initial values, constants and retained memory

Every variable has a value from the moment the program starts. If you write := 1250.0, that is the start value; if you write nothing, numbers start at 0, BOOL at FALSE, TIME at T#0s and strings empty. As you saw in chapter 1, the initial value is applied once at start-up, not every scan.

Values that must never change at runtime go in a VAR CONSTANT block. The compiler then refuses any assignment to them, which turns a whole class of "someone overwrote the setpoint" bugs into build errors. Constants also give magic numbers a name: BottlesPerCase explains itself, 12 in the middle of a formula does not.

Values that must survive a power failure go in VAR RETAIN. A retained variable is stored in memory that keeps its content when the controller loses power: battery-backed RAM on older hardware, flash or capacitor-buffered memory on current ones.

PROGRAM Main
VAR CONSTANT
    BottlesPerCase : DINT := 12;
END_VAR
VAR RETAIN
    LifetimeBottles : DINT := 0;    (* survives power cycles: service interval counter *)
END_VAR
VAR
    Running       : BOOL;           (* input: filler running, one bottle per scan here *)
    CasesFull     : DINT;
END_VAR

LifetimeBottles := LifetimeBottles + BOOL_TO_DINT(Running);
CasesFull       := LifetimeBottles / BottlesPerCase;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: Running.

Switch Running on and the lifetime count climbs. Try adding BottlesPerCase := 6; to the body: the editor refuses to build, which is exactly the point. (BOOL_TO_DINT turns TRUE into 1 and FALSE into 0. Conversions are the next chapter's subject; here it just lets the counter add one per scan while the filler runs.)

What survives depends on how the controller restarts, and IEC 61131-3 defines two cases:

EventNormal VARVAR RETAIN
Warm restart (power returns, or a warm reset)back to initial valuekeeps its value
Cold restart (cold reset, memory reset)back to initial valueback to initial value

The vendors map onto that table differently. In CODESYS and TwinCAT, a warm reset or power cycle keeps RETAIN variables, while a cold reset, a reset to origin or a full download reinitialises them; VAR PERSISTENT goes one step further and survives a download as well. On a PC-based TwinCAT controller, persistent data is written to disk at shutdown, which is why those cabinets have a UPS. In TIA Portal you tick "Retain" per variable or per data block, and an S7-1500 only performs warm restarts. Rockwell Logix keeps every tag through a power cycle with no keyword at all; values only revert when you download the project.

Naming with units and choosing the type

A name should tell the reader what the signal is, and for anything physical, in which unit. Level invites mistakes; TankLevel_mm or TankLevel_pct does not. Put the unit at the end in a consistent style: _mm, _bar, _degC, _rpm, _l, _kW. A TIME carries its own unit, so MixTime is enough. Booleans read best as a statement that is true when the variable is TRUE: DoorClosed, PumpRunning, HighLevel. A boolean called Door or PumpStatus leaves the reader guessing which state TRUE means.

Choosing the type is choosing what the signal is:

SignalTypeWhy
Push button, limit switch, coilBOOLtwo states
Analog raw value from an S7 moduleINTthe module delivers 0 … 27,648
Temperature, pressure, flow in unitsREALmeasurement with decimals
Parts counted, cycles, totalsDINTwhole numbers that grow
Energy or flow totaliserDINT/LINT, or LREALbeyond REAL's 7 digits
Delay, cycle time, timeoutTIMEunit is part of the type
Drive or module status wordWORD/DWORDpacked flags, not a quantity
Recipe name, operator messageSTRING[n]text for people

Two questions settle most choices: what is the largest value this can reach on the worst day of the year, and is it a quantity, a set of flags or a duration?

What to take into the next chapter

A type is a promise about size and meaning. Integers wrap silently at both ends of their range, so pick the size from the worst-case value, and default to DINT for anything that counts. REAL is for measurements and runs out of digits at about seven; totals belong in integers or LREAL. TIME carries its unit, WORD and DWORD carry flags, STRING carries text for people. Constants make the compiler guard your fixed values, and RETAIN keeps what the plant cannot rebuild after a power cut — but never the commands that would restart a machine by themselves.

You have already seen hints of the next problem: INT_TO_DINT, DINT_TO_REAL, BOOL_TO_DINT. As soon as an expression mixes types, someone has to decide what the result is. The next chapter covers arithmetic, precedence and conversion, and the integer division that turns 37 out of 50 into zero percent.

Practise this chapter