Text and time in a controller
Most of a PLC program deals in bits and numbers. Two other kinds of value turn up on every real machine, and both are easy to misuse. Text is what the operator reads: "P3 OVERLOAD 12.4 A" on the alarm banner, a batch ID on a label, an article code from a barcode reader. Durations are what the process is made of: a 90-second mix, a 2-second settle, the 7.3 seconds the last filling cycle took, the 4,120 hours a pump has run.
This chapter covers the STRING type and its functions, then the TIME type and its arithmetic. The two have one thing in common. They both describe the machine to people — and in the case of strings, that is all they should do.
STRING and its length
A STRING holds a sequence of characters. Literals are written in single quotes: 'FILLER 2'. Every string variable has a maximum length, fixed when it is declared:
VAR
Msg : STRING; (* default maximum length *)
BatchId : STRING[12]; (* at most 12 characters *)
END_VAR
The default depends on the platform, and it is worth knowing because it decides how much memory every string costs:
| Platform | Default STRING | Notes |
|---|---|---|
| CODESYS / TwinCAT 3 | 80 characters | Null-terminated; STRING(n) or STRING[n] for other lengths |
| Siemens S7-1200/1500 | 254 characters | 2 header bytes: maximum and actual length, so STRING takes 256 bytes |
| Rockwell Studio 5000 | 82 characters | A structure with a .LEN field and a .DATA array of SINT |
An array of 200 alarm texts at Siemens' default length is 51 KB of work memory, most of it empty. Declare the length you need: STRING[40] fits on almost any HMI alarm line. The other half of the rule is what happens when text does not fit — the extra characters are silently cut off. No fault, no warning:
PROGRAM Main
VAR
Line : INT := 3; (* input: line number *)
Station : INT := 7; (* input: station number *)
Tag : STRING[12]; (* label printed on the station's HMI button *)
TagLen : INT;
END_VAR
Tag := CONCAT(CONCAT('L', INT_TO_STRING(Line)), CONCAT('-ST', INT_TO_STRING(Station)));
TagLen := LEN(Tag);
END_PROGRAMLine 3, station 7 gives L3-ST7, six characters. Now type 32000 for Line and 12345 for Station: the full text would be L32000-ST12345, fourteen characters, and Tag holds only the first twelve. LEN reports the actual length, never more than the maximum. On a real HMI that is how a batch number loses its last two digits, and why a label that "always worked" fails the day the plant numbers its lines differently.
INT_TO_STRING converts a number to its text form. There is one for each type — DINT_TO_STRING, REAL_TO_STRING, TIME_TO_STRING — and STRING_TO_INT and friends go the other way. Nesting CONCAT two arguments at a time looks clumsy, and it is deliberate: IEC 61131-3 lets CONCAT take any number of inputs, and this engine accepts three or more, but the CODESYS and TwinCAT standard-library CONCAT takes exactly two. Two-argument calls port everywhere.
The string functions
The standard functions all count characters from 1, and all of them return a new string rather than changing their input:
| Function | Result | Example | Gives |
|---|---|---|---|
LEN(s) | number of characters | LEN('PUMP-101') | 8 |
LEFT(s, n) | first n characters | LEFT('PUMP-101', 4) | 'PUMP' |
RIGHT(s, n) | last n characters | RIGHT('PUMP-101', 3) | '101' |
MID(s, n, p) | n characters starting at position p | MID('PUMP-101', 3, 2) | 'UMP' |
FIND(s1, s2) | position of s2 in s1, 0 if absent | FIND('PUMP-101', '-') | 5 |
CONCAT(a, b) | a followed by b | CONCAT('P', '7') | 'P7' |
INSERT(s1, s2, p) | s2 inserted after position p | INSERT('ABEF', 'CD', 2) | 'ABCDEF' |
DELETE(s, n, p) | n characters removed from position p | DELETE('ABCDEF', 2, 3) | 'ABEF' |
REPLACE(s1, s2, n, p) | n characters at p replaced by s2 | REPLACE('ABCDEF', 'zz', 2, 3) | 'ABzzEF' |
Mind the argument order of MID: length first, then position. It catches everyone once, because most other languages put the position first. Siemens SCL follows the IEC order; Rockwell's string instructions have their own operand names (Source, Qty, Start) and write the result to a destination tag, so check the help before you port.
A barcode reader on a goods-in conveyor sends codes made of three fields separated by dashes: article, description, quantity. Splitting one apart uses most of the table:
PROGRAM Main
VAR
Which : INT := 1; (* input: pick a test barcode, 1..3 *)
Code : STRING[40];
Rest : STRING[40];
Dash1 : INT;
Dash2 : INT;
Article : INT; (* first field as a number *)
Qty : INT; (* last field as a number *)
END_VAR
CASE Which OF
1: Code := '4120-PUMPSEAL-75';
2: Code := '87-GASKET DN50-1200';
ELSE
Code := 'NOREAD';
END_CASE;
Dash1 := FIND(Code, '-');
IF Dash1 > 1 THEN
Article := STRING_TO_INT(LEFT(Code, Dash1 - 1));
Rest := RIGHT(Code, LEN(Code) - Dash1); (* everything after the first dash *)
Dash2 := FIND(Rest, '-');
IF Dash2 > 0 THEN
Qty := STRING_TO_INT(RIGHT(Rest, LEN(Rest) - Dash2));
ELSE
Qty := 0;
END_IF;
ELSE
Article := 0; (* no dash: not a valid code *)
Qty := 0;
Dash2 := 0;
END_IF;
END_PROGRAMCode 1 gives article 4120 and quantity 75; code 2 gives 87 and 1200, even though its description contains a space. Choose 3 and FIND returns 0, so the program reports article 0 instead of trying to cut a string at position −1. That IF Dash1 > 1 is the string version of the array index check from chapter 13: every position that comes out of FIND must be checked before it goes into LEFT, RIGHT or MID.
Building HMI messages and alarm texts
The commonest job for strings in a PLC is assembling a message from fixed text and live values. A tank farm shows one status line per tank on the overview screen:
PROGRAM Main
VAR
TankNo : INT := 2; (* input *)
LevelPct : INT := 87; (* input: level, % *)
HighAlarm : BOOL; (* input: high-level switch *)
StatusText : STRING[40]; (* output to the HMI *)
END_VAR
StatusText := CONCAT('TANK ', INT_TO_STRING(TankNo));
StatusText := CONCAT(StatusText, ' LEVEL ');
StatusText := CONCAT(StatusText, INT_TO_STRING(LevelPct));
StatusText := CONCAT(StatusText, ' %');
IF HighAlarm THEN
StatusText := CONCAT(StatusText, ' HIGH');
END_IF;
END_PROGRAMBuilding the text one piece per line, always starting from a fresh assignment, keeps it readable and makes it impossible for last scan's text to linger — the string version of clearing a total before the loop. Change the level or switch HighAlarm and the line updates on the next scan.
Decimals need one more step. REAL_TO_STRING exists everywhere, but its output varies: one platform gives 12.4, another exponent notation such as 1.240000E+01, and a value that should read 12.0 may come out as 12. For anything an operator reads, scale to an integer first and format the digits yourself. A motor current in tenths of an amp becomes text like this:
Whole := Amps10 / 10; (* 124 -> 12 *)
Tenth := Amps10 MOD 10; (* 124 -> 4 *)
Text := CONCAT(CONCAT(INT_TO_STRING(Whole), '.'), INT_TO_STRING(Tenth)); (* '12.4' *)
The result always has exactly one decimal, which keeps an alarm list aligned. (Negative values need their sign handled separately, because −12.4 gives −12 and −4.)
Two cautions from the field. First, string handling is slow next to integer maths — a CONCAT copies every character — so building forty status lines every 2 ms scan is a real cost on a small CPU. Rebuild a text only when its inputs change, or in a slower task. Second, alarm texts that the operator sees usually belong in the HMI, not in the PLC. WinCC, FactoryTalk View and TwinCAT HMI all hold alarm texts per alarm number and translate them; the PLC sends the number and the live values. Build strings in the PLC for what genuinely starts there: a batch ID for a label printer, a message to a serial device, a line for a production log.
Strings must not drive control decisions
Sooner or later someone writes this:
IF StatusText = 'RUNNING' THEN (* never do this *)
DownstreamEnable := TRUE;
END_IF;
It works on the day it is written. Then a technician changes the HMI wording to "Running", or a translation turns it into "EN MARCHE", or a longer status gets truncated, or a trailing space sneaks in from a serial device. The comparison quietly becomes FALSE and the downstream conveyor stops, with nothing in the logic that looks wrong. String comparison is also exact and case-sensitive on every PLC: 'Running' = 'RUNNING' is FALSE.
The rule: decide with BOOLs, numbers and enums; describe with strings. The text is generated from the state, never the other way round. If the state lives in an E_State enum (chapter 15), the program compares State = E_State.Running and a separate line turns the state into whatever text the operator wants to read.
TIME values and arithmetic
A TIME holds a duration. Literals start with T# and combine days, hours, minutes, seconds and milliseconds: T#250ms, T#1m30s, T#2h, T#1d2h. Underscores are allowed for readability on most platforms (T#1m_30s). Internally a TIME is a count of milliseconds in 32 bits, which sets its range: on Siemens it is signed, so up to about 24 days 20 hours; on CODESYS and TwinCAT it is unsigned, up to about 49 days. For longer spans CODESYS and TwinCAT offer LTIME (64-bit nanoseconds) and S7-1500 has LTime too.
TIME values add, subtract and compare like numbers, and multiplying or dividing by an integer scales them. A mixer's recipe time grows with batch size:
PROGRAM Main
VAR
BatchKg : INT := 400; (* input: batch size, kg *)
MixTime : TIME;
HalfTime : TIME;
MixMs : DINT;
LongMix : BOOL;
END_VAR
MixTime := T#2m + T#15s * (BatchKg / 100); (* 2 min base + 15 s per 100 kg *)
HalfTime := MixTime / 2; (* reverse the agitator at half time *)
MixMs := TIME_TO_DINT(MixTime);
LongMix := MixTime > T#4m;
END_PROGRAMA 400 kg batch mixes for 3 minutes: 180,000 ms. Try 1000 kg and the mix becomes 4 min 30 s, which sets LongMix. TIME_TO_DINT gives the duration in milliseconds, which is what you need when a time goes to an HMI as a number or into a calculation with REALs. DINT_TO_TIME goes the other way: an operator enters seconds on the HMI, and DINT_TO_TIME(Secs * 1000) turns it into a PT for a timer.
Subtraction needs care. T#2s - T#5s is −3 s on a Siemens CPU, a value near 49 days on CODESYS (unsigned arithmetic wraps round), and 0 s on the engine on this page. A time difference that could go negative should be compared first and subtracted second.
Measuring a cycle with a timer
A timer's ET output is a stopwatch. Run a TON with a long PT while something is happening and ET tells you how long it has been happening. A bottle filler records how long each fill took, so the operator can see the valve slowing down long before it fails:
PROGRAM Main
VAR
Filling : BOOL; (* input: fill valve open *)
Stopwatch : TON;
WasFilling : BOOL;
LastFill : TIME; (* duration of the last complete fill *)
LastMs : DINT;
SlowFill : BOOL;
END_VAR
(* Capture BEFORE calling the timer: ET still holds last scan's value. *)
IF WasFilling AND NOT Filling THEN
LastFill := Stopwatch.ET;
LastMs := TIME_TO_DINT(LastFill);
SlowFill := LastFill > T#4s;
END_IF;
Stopwatch(IN := Filling, PT := T#1h);
WasFilling := Filling;
END_PROGRAMSwitch Filling on, wait a couple of seconds, switch it off: LastFill holds the duration. The order of the lines is the whole trick. A TON resets ET to zero in the same call that sees IN go FALSE. If the capture came after Stopwatch(...), it would read 0 every time. Capturing first reads the value from the end of the previous scan — the last scan on which the valve was open.
For running hours, the same stopwatch is not enough, because a TIME overflows after 24 or 49 days and a pump runs for years. The standard pattern is a one-second tick that increments a DINT seconds counter while the pump runs, with hours derived from it: Hours := RunSecs / 3600;. A DINT of seconds lasts 68 years. Make the counter VAR RETAIN so a power cut does not reset a pump's service history.
DATE, TIME_OF_DAY and the wall clock
IEC 61131-3 also defines DATE (D#2026-10-23), TIME_OF_DAY (TOD#14:30:00) and DATE_AND_TIME (DT#2026-10-23-14:30:00) for calendar values. You need them for shift reports, timestamps on batch records and "clean-in-place every day at 02:00". Reading the controller's real-time clock is vendor-specific: RD_LOC_T in TIA Portal, a GSV of the WallClockTime object in Studio 5000, NT_GetTime or the FB_LocalSystemTime block in TwinCAT. Arithmetic on these types also varies between vendors, so this course leaves them out of the live examples.
One firm opinion to finish: never measure a duration with the wall clock. The clock is synchronised by NTP, corrected by an operator and moved an hour twice a year for daylight saving. A cycle "measured" as end-time minus start-time across a clock correction comes out negative or an hour long. Timers count the controller's own milliseconds and are not affected. Use the clock for when, and timers for how long.
The data part, finished
You can now keep text in a STRING of the right length, take it apart and build it up with the standard functions, and turn numbers into operator-friendly messages without letting text near a control decision. You can compute with TIME values, convert them to milliseconds, measure a cycle with a timer's ET — capturing it before the timer call clears it — and bank run time across many runs.
That completes the data types. Arrays, structures, enumerations, strings and times are the raw material; the next part is about packaging logic around them. Chapter 17 starts with the simplest package of all, the FUNCTION: inputs in, one result out, and no memory between calls — the right home for that current-to-text formatting, so it is written once instead of in every pump's code.