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. The tools (watch tables, forcing, simulators, trace recorders) come second. They 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 instead of 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.
Watching has one limit that catches everybody once. The IDE does not see every scan. It polls the controller over the network and refreshes the display a few times a second, so what you see is a sample of the variable, taken whenever the next poll happened to arrive. A level, a count or a step number changes slowly enough for that to be fine. A BOOL that is TRUE for one 10 ms scan will almost never appear in a watch window at all. When a fault "never shows up online", suspect a pulse shorter than the refresh, and reach for a trace (later in this chapter).
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.
The platforms also differ in what they let you force at all:
| Platform | What can be forced | Where forces live | How you know one is active |
|---|---|---|---|
| S7-1200 / S7-1500 (TIA Portal) | Peripheral inputs and outputs only (addresses with :P, such as %I0.3:P); not DB variables or memory | In the CPU; the force job survives power off and stays until it is stopped in the force table | Force table, CPU status, diagnostic buffer |
| ControlLogix / CompactLogix (Studio 5000) | I/O tags (and aliases to them), not internal tags | In the controller and saved with the project | FORCE LED on the controller and the force status in the toolbar |
| CODESYS / TwinCAT 3 | Practically any variable, internal ones included | In the runtime, while the session lasts or as long as you choose to keep them on logout | Forced values are marked in the editor and listed in the watch views |
The Siemens and Rockwell restriction is deliberate: a force is a test of the wiring and the field, applied at the I/O boundary. CODESYS's freedom to force any internal variable is convenient on the bench and a trap on a machine, because a forced internal latch is invisible to anyone who only checks the I/O.
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.
Breakpoints on a running machine
CODESYS and TwinCAT support breakpoints and single-stepping in ST, and TIA Portal supports breakpoints in SCL on S7-1500 CPUs. Studio 5000 has none: Logix code is debugged by watching, trending and adding logic. On the bench, a breakpoint is a fine tool. On a running machine it is a bad one, because when the task stops, nothing in that task is computed any more: not the timers, not the interlocks, not the outputs. Depending on the platform and the configuration, the outputs freeze at their last value or the I/O goes to its configured stop state, while drives, robots and air cylinders that do not need the PLC to keep moving carry on doing whatever they were last told.
CODESYS and TwinCAT have a safer tool for most of what people use breakpoints for, called flow control. With it switched on, the editor marks which lines of the POU actually executed in the current cycle and shows the values at each point of execution, while the task keeps running. It answers "did this IF branch run?" without stopping anything. It costs some cycle time, so switch it off when you have your answer.
Trace: seeing every scan
A trace records values inside the controller, scan by scan or at a fixed sample rate, and uploads the record afterwards. It sees what the watch window misses. Each platform has one: the Trace object in CODESYS, TwinCAT Scope, the trace function of the S7-1200 and S7-1500 in TIA Portal, and Trends in Studio 5000. A trace has a trigger ("start recording when Count changes by more than 1") and records some time before and after it, so you capture the moments around a fault that happens once an hour without sitting there watching for it.
When the vendor trace is not available, too slow, or not allowed on a production controller, build a small recorder in ST. It is a few lines of code and it lives in the program until the fault is found.
PROGRAM Main
VAR CONSTANT
DEPTH : INT := 6; (* scans kept; a real recorder keeps hundreds *)
POST_SCANS : INT := 2; (* scans still recorded after the trigger *)
END_VAR
VAR
Eye : BOOL; (* input: the signal under suspicion *)
Trigger : BOOL; (* input: the symptom, e.g. "count jumped by 2" *)
Rearm : BOOL; (* input: start recording again *)
Scan : DINT;
Hist : ARRAY[1..6] OF BOOL; (* Hist[1] = newest scan *)
Frozen : BOOL;
FrozenAt : DINT;
PostLeft : INT := -1; (* -1 = waiting for a trigger *)
TrigEdge : R_TRIG;
i : INT;
END_VAR
Scan := Scan + 1;
TrigEdge(CLK := Trigger);
IF Rearm THEN
Frozen := FALSE;
PostLeft := -1;
END_IF;
IF NOT Frozen THEN
(* Shift the history one place and store this scan's value *)
FOR i := DEPTH TO 2 BY -1 DO
Hist[i] := Hist[i - 1];
END_FOR;
Hist[1] := Eye;
(* After the trigger, record a few more scans, then freeze *)
IF TrigEdge.Q AND PostLeft < 0 THEN
PostLeft := POST_SCANS;
ELSIF PostLeft > 0 THEN
PostLeft := PostLeft - 1;
END_IF;
IF PostLeft = 0 THEN
Frozen := TRUE;
FrozenAt := Scan;
END_IF;
END_IF;
END_PROGRAMToggle Eye on and off a few times, then switch Trigger on. Two scans later the record freezes: Hist[1] is the last scan recorded, Hist[6] the oldest, and FrozenAt says which scan the record ends on. You now have the three scans before the symptom and the two after it, which is exactly what you could not see online. Switch Rearm on and off to record again.
A production version keeps a few hundred samples of several signals in an array of structures, stores a timestamp per sample, and uses a ring index instead of shifting the whole array every scan, because copying 500 entries every scan costs cycle time. The trigger is the symptom stated as logic: a count that jumps by two, a step that changes twice in one second, a valve output that turns on while its permissive is FALSE. Writing the trigger is often the moment the bug becomes obvious.
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 } }
]
The fields work like this:
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, and it should be decided before anyone writes the implementation. 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.
Each case is a given, when, then sentence with the words taken out. "Given the motor is running, when the overload is present for 2.1 s, then the motor stops and Tripped is TRUE." Write the sentence first if the JSON feels abstract; a customer can read the sentence and tell you it is wrong before anything is built. Number the requirements in the specification and put the requirement number in each case description, and you have the traceability table that a factory acceptance test asks for. The design book goes further into building that table and keeping tests independent of the implementation.
A test that cannot fail is not a test
Every challenge in this course checks one more thing you may not have noticed: the starter code must fail the cases. If the starter passed, the cases would not be testing anything. Apply the same rule to your own tests. Before you trust a new case, break the code on purpose (flip a >= to >, delete the seal-in) and watch the case go red. A suite that has never failed has never been tested itself.
The same trap shows up in a quieter form. A test that only checks outputs which happen to start at their default value passes against an empty program: if Tripped is FALSE by default, a case expecting Tripped = FALSE proves nothing until another case has made it TRUE first. The grader on these pages rejects a challenge where an output has the same expected value in every case, for exactly that reason.
Unit test frameworks on real platforms
On a real project the same cases go into the factory acceptance test (FAT) document, run against a simulator before the machine ships. IEC 62381 describes the FAT, the site acceptance test (SAT) and the site integration test for process automation systems, and many customer specifications refer to it. For code-level tests, each platform has its own tooling:
| Platform | Test tooling | Runs against |
|---|---|---|
| TwinCAT 3 | TcUnit, an open-source framework: test suites are function blocks that extend FB_TestSuite, with assertions such as AssertEquals | The TwinCAT runtime on the engineering PC or a target |
| CODESYS | CODESYS Test Manager (a commercial add-on); CfUnit, an open-source framework built the same way as TcUnit | CODESYS Control Win or a target |
| TIA Portal | TIA Portal Test Suite Advanced: an application test for S7-1500 blocks, plus a style-guide checker | S7-PLCSIM Advanced (S7-1500 only) |
| Studio 5000 | No built-in unit test framework; tests are usually written as test routines and run on an emulated controller | FactoryTalk Logix Echo (ControlLogix 5580, CompactLogix 5380 and their GuardLogix versions) |
A TcUnit test looks like this. It is vendor syntax, built on the object-oriented extensions of IEC 61131-3 third edition (EXTENDS, METHOD), so it is shown here as a listing and cannot run on these pages:
// TwinCAT 3 + TcUnit, vendor syntax (not runnable on this site)
FUNCTION_BLOCK FB_PackCounter_Test EXTENDS TcUnit.FB_TestSuite
VAR
fbCounter : FB_PackCounter; // the block under test
END_VAR
ThreeBottlesFillAPack(); // the body calls each test method
METHOD ThreeBottlesFillAPack
VAR
i : INT;
END_VAR
TEST('ThreeBottlesFillAPack');
FOR i := 1 TO 3 DO
fbCounter(Eye := TRUE);
fbCounter(Eye := FALSE);
END_FOR
AssertEquals(Expected := TRUE, Actual := fbCounter.PackFull,
Message := 'pack not full after three bottles');
TEST_FINISHED();
The shape is the same as a plant case: set inputs, call the block, check the outputs. Each test is a method that starts with TEST() and ends with TEST_FINISHED(), which lets a single test span several PLC cycles when it has to wait for a timer. A program calls TcUnit.RUN(), and the results appear in the TwinCAT error list. Whatever the tool, the hard part is the same: deciding the cases.
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.
Cover every state and every way out of it
For a state machine (chapter 12), boundary testing means coverage of the graph. Draw the states and transitions, and write at least one case for each of these:
| Coverage item | Example for a fill, mix, drain sequence |
|---|---|
| Every state entered | Idle, Filling, Mixing, Draining, Faulted each reached at least once |
| Every transition taken | Filling to Mixing on the high level; Mixing to Draining on the timer |
| Every timeout fired | Filling with the inlet blocked runs into the fill timeout |
| Every abort from every state | Stop pressed in Filling, in Mixing and in Draining |
| Every recovery | From Faulted, through reset, back to Idle, and a fresh start |
| Inputs that arrive early or twice | The high-level switch already made when Filling starts |
The abort row is the one that gets skipped. Testing "stop while filling" and assuming "stop while mixing" behaves the same misses the agitator that keeps running because its output is written from the Mixing branch. Five states and four aborts is twenty minutes of extra cases, and it is twenty minutes that catches real bugs.
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.
A model that can misbehave
Here is the same tank with two fault switches in the model and a fill timeout in the controller. A normal fill with the drain open takes about 20 s (300 L at a net 15 L/s), so the timeout is set to 30 s.
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;
FILL_TIMEOUT : TIME := T#30s; (* normal fill with the drain open: about 20 s *)
END_VAR
VAR
AutoFill : BOOL; (* input: operator selects automatic fill *)
DrainOpen : BOOL; (* input: downstream draws from the tank *)
FaultHighStuck : BOOL; (* model fault: high switch never makes *)
FaultHalfFlow : BOOL; (* model fault: blocked strainer, half inflow *)
LowSwitch : BOOL;
HighSwitch : BOOL;
InletValve : BOOL; (* output *)
FillT : TON;
FillTimeout : BOOL; (* alarm, closes the inlet *)
Level_L : REAL := 250.0;
Inflow_L : REAL;
Overflow : BOOL; (* model: product on the floor *)
END_VAR
(* ===== Controller ===== *)
FillT(IN := InletValve, PT := FILL_TIMEOUT);
IF FillT.Q THEN
FillTimeout := TRUE;
END_IF;
IF NOT AutoFill THEN
FillTimeout := FALSE;
END_IF;
IF AutoFill AND LowSwitch AND NOT FillTimeout THEN
InletValve := TRUE;
ELSIF HighSwitch OR NOT AutoFill OR FillTimeout THEN
InletValve := FALSE;
END_IF;
(* ===== Plant model with fault injection ===== *)
IF SIMULATION THEN
Inflow_L := INFLOW_L_SCAN;
IF FaultHalfFlow THEN
Inflow_L := INFLOW_L_SCAN / 2.0;
END_IF;
IF InletValve THEN
Level_L := Level_L + Inflow_L;
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 AND NOT FaultHighStuck;
Overflow := Level_L >= TANK_MAX_L;
END_IF;
END_PROGRAMStart with AutoFill and DrainOpen on and let the tank cycle normally. Then switch on FaultHalfFlow: the inflow drops below the outflow, the level falls even with the inlet open, and after 30 s FillTimeout closes the inlet and raises the alarm. The timeout does its job.
Now switch FaultHalfFlow and DrainOpen off, switch on FaultHighStuck, and toggle AutoFill off and on to clear the alarm. The inlet opens and never closes. With nothing drawing from the tank it fills at 40 L/s, and Overflow goes TRUE about 12 s later, long before the 30 s timeout. The drain does not save it either: with the drain open, a fill that starts at the low switch reaches 500 L about 27 s in, three seconds before the timeout would have closed the inlet.
That is a design finding, and the model found it in two minutes. A timeout sized for the slowest normal fill cannot protect against a stuck high switch on a tank that can fill fast. The protection that works is a second, independent high-high level switch wired to close the inlet, and on a hazardous product a separate protection layer outside the PLC. You would rather discover that on a laptop than on a Saturday night with a mop.
For bigger machines, the same idea scales up. S7-PLCSIM simulates S7-1200 and S7-1500 CPUs on the engineering PC, and S7-PLCSIM Advanced adds an API so a separate plant simulation can read outputs and write inputs. FactoryTalk Logix Echo emulates ControlLogix 5580 and CompactLogix 5380 controllers for Studio 5000 projects; the older Studio 5000 Logix Emulate covered the 5570 family, and Logix Designer version 37 and later no longer support emulated 5560 and 5570 controllers. TwinCAT runs its real-time on the engineering PC itself, and CODESYS has CODESYS Control Win, a soft PLC for Windows. Each of them runs your real code, so a model built for one of them is still in the same project, behind the same SIMULATION switch.
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. A debounce timer added "just in case" is not progress: it hides the real problem for three months.
When the scan is the suspect
Some faults depend on the scan time itself, and the hypothesis has to include a number. Here is one from a bottling line.
A conveyor carries 65 mm bottles at 0.5 m/s, nearly touching, with gaps of about 10 mm between them. A photo-eye counts bottles on the falling edge of the gap, and the count is used to group them into packs. The eye sees each gap for 10 mm at 0.5 m/s, which is 20 ms. The counting program runs in the main task, which normally takes 8 ms. Once every few hundred bottles, two are counted as one.
The hypothesis names the scan: "when the main task scan exceeds the gap time, the PLC can read the eye blocked on two consecutive scans and never see the gap." The prediction is checkable: the controller's maximum cycle time should be above 20 ms, and the miscounts should cluster at moments when the scan is long.
To check it, read the cycle-time statistics. TIA Portal shows the current, shortest and longest cycle time in the CPU's online and diagnostics view. In Studio 5000, each task's properties have a Monitor tab with the last and maximum scan time, and logic can read the same values with GSV from the Task object (LastScanTime and MaxScanTime, in microseconds). The CODESYS task configuration has a monitor view with each task's cycle time and jitter when you are online. Reset the maximum, run the line for an hour, and compare it with the shortest signal you have to see. A table like this one, filled in for the fast signals on a machine, belongs in the commissioning record:
| Signal | Shortest pulse | Task | Max scan + I/O update | Safe? |
|---|---|---|---|---|
| Bottle gap on the count eye | 20 ms | Main | 26 ms + 2 ms | No: move to the fast task or a counter module |
| Reject confirm sensor | 60 ms | Main | 26 ms + 2 ms | Yes, with margin |
| Encoder mark on the capper | 3 ms | Fast (2 ms) | 2 ms + 1 ms | No: needs a hardware latch |
The I/O update column is there for networked I/O: a PROFINET or EtherNet/IP device updates at its own cycle (or RPI), and the worst-case time for an input change to reach the program is roughly the device's cycle plus the task's cycle.
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, which is 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.
7. The state that writes an output
An output assigned inside one branch of a CASE keeps its value when the sequence moves to a branch that does not mention it. The valve opened in Filling stays open through Mixing and Draining until some other branch happens to close it. Chapter 12 showed the fix: compute each output after the CASE, from the step, in one line.
8. A forced or written value nobody removed
Before you hunt a bug in the code, check the force table and ask who was last online. A setpoint someone wrote during commissioning, a forced input left on from last week's I/O check, or a simulation switch left TRUE all look exactly like logic faults.
On a real machine you would leave a diagnostic like this in the program after the fault is found, with its count on a maintenance screen. A glitch count that starts rising again next year is the first sign of a loose connector or a failing sensor, long before the machine stops.
Ready to build something whole
A commissioning engineer's toolkit is now in your hands: thinking in scans, watching without disturbing, forcing inputs rather than outputs, tracing what the watch window cannot show, writing plant cases before the code and making sure they can fail, attacking the boundaries and every path out of every state, simulating the plant with faults on demand, and debugging from a hypothesis that names a variable, a scan and sometimes a scan time. You also have eight bugs you will now spot in other people's code within seconds.
The final chapter of this part puts all of it to work on one machine. The capstone is 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.