Every bug happens on a particular scan
A machine misbehaves "sometimes". The capper skips a bottle once every few hundred, the pump restarts "on its own", the batch total is wrong "after a recipe change". Those descriptions are true and useless. Underneath every one of them is a specific scan on which a specific variable took a value you did not expect.
Testing and debugging Structured Text is the craft of finding that scan. It is not about tools first. The tools — watch tables, forcing, simulators, trace recorders — only help if you already think of the program as a sequence of scans, each one reading a snapshot, executing top to bottom, and writing outputs. Chapter 1 asked you to step a program one scan at a time. In this chapter that habit becomes a method.
The method has three parts. Before the code exists, write down what the machine must do as plant cases: given these inputs, after this much time, these outputs. While the code is being written, run those cases against it, including the awkward ones at the boundaries. And when something fails — on the bench or on the plant floor — debug from a hypothesis you can check, not from a change you hope will help.
Online watch, write and force
Every IDE lets you go online with a running controller and watch variables change: CODESYS and TwinCAT show live values next to every variable in the editor, TIA Portal has watch tables and the glasses icon for monitoring SCL, and Studio 5000 shows tag values in the Monitor Tags view and inline in the ST editor. Watching is safe. Use it constantly.
Changing values online comes in two very different strengths.
A write (CODESYS calls it Write values, TIA Portal Modify) puts a value into a variable once. If the program assigns that variable on every scan, your value lasts until the next assignment — a few milliseconds — and you will never see it. Writes are for variables the program only reads or only changes occasionally: a setpoint, a recipe number, a latch.
A force pins a value. The controller re-applies it on every scan, overriding both the input terminal and anything your program writes. It is the most powerful debugging tool in the IDE and the most dangerous.
Forcing a physical output bypasses every interlock you wrote. Force a fill valve open and it stays open when the tank is full, when the E-stop logic in the PLC drops, when the high-level alarm trips. Force a conveyor motor on and the jam detection is irrelevant. People have been injured by forced outputs that someone forgot to remove.
Forces also outlive the debugging session. On an S7-1500 a force job stays active in the CPU after you close TIA Portal and even through a power cycle. In Studio 5000, I/O forces are stored in the controller and the project, and the controller's FORCE LED is the only hint on the cabinet door that one is active. CODESYS asks when you log out whether to keep forced values; the easy answer is the wrong one.
On these pages, the input panels beside every live program are forces: the program reads them but cannot overwrite them. That is exactly how the graded challenges test your code.
Write the plant cases before the code
A plant case is a requirement you can run. Each one says: set these inputs, let this many scans and this much time pass, then check these outputs. A list of them, run in order on one controller, is a test of the whole behaviour — including memory, because state carries from one case to the next.
Every challenge in this course is graded exactly like that. The cases are JSON objects with five fields:
[
{ "description": "Start pressed",
"setInputs": { "Start": true, "Stop": false },
"expect": { "Motor": true } },
{ "description": "Start released — still running (seal-in)",
"setInputs": { "Start": false },
"scans": 5,
"expect": { "Motor": true } },
{ "description": "Overload for 2.1 s trips it",
"setInputs": { "Overload": true },
"scans": 21, "advanceMs": 2100,
"expect": { "Motor": false, "Tripped": true } }
]
setInputsforces inputs. They stay forced until a later case changes them, so the second case above still hasStopFALSE.scans(default 1) is how many scans run before checking;advanceMsspreads that much virtual time across them (default 100 ms per scan), so a timer sees its elapsed time grow scan by scan.expectlists the outputs to compare after the last scan. Anything not listed is not checked.- All cases run in order on one engine. The third case only makes sense because the first one started the motor.
Writing these before the program does three things. It forces you to decide behaviour you would otherwise leave vague — "what does the motor do if Start is held while Stop is pressed?" is a requirement, not an implementation detail. It gives you a definition of done that does not depend on how confident you feel. And it is a regression suite: after every change, run all the cases again, because the change that fixes case 7 is often the one that breaks case 3.
On a real project the same cases go into the factory acceptance test (FAT) document, run against a simulator before the machine ships. CODESYS has a unit-test framework (CODESYS Test Manager), TwinCAT users commonly use the open-source TcUnit, and TIA Portal projects are tested against S7-PLCSIM Advanced. The format differs; the thinking is the same.
Boundary cases
Most logic bugs live at the edges of a range, not in the middle. A comparison written > that should be >=, a counter that fires one part late, a timer case that checks at 0.99 s and passes by luck. For every threshold, count and time in your requirements, write cases at the boundary and one step either side of it.
| Thing | Cases worth writing |
|---|---|
| Threshold 80.0 °C | 79.9, exactly 80.0, 80.1, and the way back down through the hysteresis band |
| Counter preset 12 | 11 (not yet), 12 (done), 13 (what now?), and reset in the middle |
| Timer 2 s | just under (1.9 s), just over (2.1 s), and input dropping at 1.9 s then returning |
| Analog input | 0, full scale, below 0 (wire break), above full scale (overload) |
| Integers | 0, 1, the maximum (32,767 for INT), negative values |
| Sequence | the first scan after power-up, and every input arriving in the "wrong" order |
Timers deserve particular care, because their boundary depends on when they started.
Simulate the plant inside the program
You will rarely get the real machine to test on. It is still being built, or it is in production, or it is a 20-tonne press nobody will let you cycle for fun. The answer is to simulate it: a small piece of code that behaves like the plant, reading your outputs and writing your inputs.
For a tank, that is two lines: the level rises while the inlet valve is open and falls while the outlet is open, limited between empty and full. Put the model in its own clearly marked section, enabled by a constant, so it can never run on the real machine by accident.
PROGRAM Main
VAR CONSTANT
SIMULATION : BOOL := TRUE; (* FALSE on the real machine *)
TANK_MAX_L : REAL := 500.0;
INFLOW_L_SCAN : REAL := 4.0; (* 40 L/s at 100 ms per scan *)
OUTFLOW_L_SCAN : REAL := 2.5;
END_VAR
VAR
AutoFill : BOOL; (* input: operator selects automatic fill *)
DrainOpen : BOOL; (* input: downstream draws from the tank *)
LowSwitch : BOOL; (* plant input: level switch at 100 L *)
HighSwitch : BOOL; (* plant input: level switch at 400 L *)
InletValve : BOOL; (* output *)
Level_L : REAL := 250.0; (* simulated plant state *)
END_VAR
(* ===== Controller: the code that would ship ===== *)
IF AutoFill AND LowSwitch THEN
InletValve := TRUE;
ELSIF HighSwitch OR NOT AutoFill THEN
InletValve := FALSE;
END_IF;
(* ===== Plant model: only while simulating ===== *)
IF SIMULATION THEN
IF InletValve THEN
Level_L := Level_L + INFLOW_L_SCAN;
END_IF;
IF DrainOpen THEN
Level_L := Level_L - OUTFLOW_L_SCAN;
END_IF;
Level_L := LIMIT(0.0, Level_L, TANK_MAX_L);
LowSwitch := Level_L <= 100.0;
HighSwitch := Level_L >= 400.0;
END_IF;
END_PROGRAMSwitch on AutoFill and DrainOpen. The level falls until the low switch makes, the inlet opens, and because inflow beats outflow the level climbs back to 400 L and the valve closes. The controller section has no idea it is talking to a model. Turn AutoFill off and you can watch the tank drain to zero — the kind of test you would never be allowed to run on a real product tank.
A plant model does not need to be accurate; it needs to be qualitatively right and able to misbehave on demand. Add an input that sticks the high switch, a parameter that halves the inflow to simulate a blocked strainer, a fault that freezes the level. Those are the cases where controllers fail, and a model lets you produce them at will. For bigger machines, the same idea scales up into tools such as S7-PLCSIM Advanced with a co-simulation, Rockwell's Logix Echo, TwinCAT running on the engineering PC, or a separate simulation program in the same project.
Debug from a hypothesis
When the bench or the plant shows a failure, the tempting move is to change something and see if it helps. On a live machine that is how one bug becomes three. Work like this instead:
- Observe precisely. Which output did what, on which event? "The case packer counted 13 bottles into a 12-pack" is a fact you can work with.
- Form a hypothesis that names a variable and a scan. "
Countincremented twice for one bottle because the eye flickered at the bottle's edge." - Predict what you would see if it were true. "If I trace the eye input, I will see two rising edges less than 20 ms apart for one bottle."
- Check it — with a watch, a trace (TwinCAT Scope, the CODESYS trace, TIA Portal's trace function, or a Studio 5000 trend), or by reproducing it in a plant case.
- Change one thing, then run all the cases again.
If the prediction is wrong, the hypothesis was wrong, and you have learned something without touching the code. That is still progress. What is not progress is a debounce timer added "just in case" that hides the real problem for three months.
The usual suspects
Most ST bugs you will ever meet are one of a handful of patterns. Learn to recognise them on sight.
1. The missing ELSE. An IF that sets an output TRUE and never sets it FALSE has built an accidental latch. Chapter 5 covered it; it is still the most common bug in code reviews.
PROGRAM Main
VAR
Level_High : BOOL; (* input: high level switch *)
Beacon : BOOL; (* output: should follow the switch *)
END_VAR
IF Level_High THEN
Beacon := TRUE; (* no ELSE: once on, nothing switches it off *)
END_IF;
END_PROGRAMSwitch Level_High on and off. The beacon stays on forever. Either add the ELSE or, better, write Beacon := Level_High; — a plain assignment cannot latch.
2. The timer inside an IF. A timer only updates when it is called. Put the call inside an IF and, when the condition goes FALSE, the timer is simply not called: it never sees IN go FALSE, never resets, and its Q sticks.
3. Edge versus level. A counter that adds one "while the eye is blocked" counts scans, not bottles. At 10 ms per scan, a bottle that blocks the eye for 80 ms counts as eight. Count on an R_TRIG edge (chapter 8 and chapter 11).
4. The total that is never cleared. A FOR loop that sums an array must start from zero on every scan. Forget the Sum := 0; line and the sum grows by the whole array total every scan — a bug that looks right on the very first scan and nowhere else.
PROGRAM Main
VAR
Weights : ARRAY[1..4] OF INT := [250, 248, 252, 251]; (* grams *)
Sum : INT; (* bug: never cleared *)
SumOk : INT;
i : INT;
END_VAR
FOR i := 1 TO 4 DO
Sum := Sum + Weights[i];
END_FOR;
SumOk := 0; (* cleared before every pass *)
FOR i := 1 TO 4 DO
SumOk := SumOk + Weights[i];
END_FOR;
END_PROGRAMPause and step: after one scan both read 1001 g. After two, Sum reads 2002. After 33 scans the INT overflows and goes negative. A test that runs only one scan will never catch it — another reason the cases run several.
5. Comparing REALs with =. Floating-point values are almost never exactly equal to a decimal literal. Ten additions of 0.1 do not make exactly 1.0 on a PLC's 32-bit REAL, and they do not here either.
PROGRAM Main
VAR
Total : REAL;
ExactlyOne : BOOL;
CloseToOne : BOOL;
i : INT;
END_VAR
Total := 0.0;
FOR i := 1 TO 10 DO
Total := Total + 0.1;
END_FOR;
ExactlyOne := Total = 1.0; (* never use this *)
CloseToOne := ABS(Total - 1.0) < 0.0001; (* compare with a tolerance *)
END_PROGRAMThe digits you see here come from this engine's 64-bit arithmetic; a 32-bit PLC REAL misses by a different amount. The lesson is the same on both: compare REALs with >=, <= or a tolerance, never =. A fill that stops "when weight = 500.0" may never stop.
6. Wrong precedence. AND binds tighter than OR, so Run := Start OR Run AND NOT Stop; means Start OR (Run AND NOT Stop). While Start is held, Stop does nothing. Write Run := (Start OR Run) AND NOT Stop; and the stop wins. When in doubt, add parentheses; they cost nothing and the next reader does not have to remember the table.
Ready to build something whole
You now have the tools a commissioning engineer actually uses: thinking in scans, watching without disturbing, forcing inputs rather than outputs, writing plant cases before the code, attacking the boundaries, simulating the plant when it does not exist yet, and debugging from a hypothesis you can check. You also have a list of six bugs you will now spot in other people's code within seconds.
The final chapter puts all of it to work on one machine: a bottle filling line with modes, a fill sequence, a simulated scale, counters, a fill timeout, alarms with acknowledge — and a set of plant cases that grade the whole cell.