Implementing One-Shot Counting in SCL for Siemens S7-1200/1500

David Krause11 min read
SiemensTIA PortalTutorial / How-to
Licensed PE Working through this on a live machine? A Maine-licensed engineer can take it from here — included with IMD hardware, by the hour for everything else. Book an engineer

Overview

Counting the rising edges of Boolean inputs scattered across an array is a recurring requirement in machine automation: alarm aggregation, batch event logging, fault tallying, and pick-and-light stack-light diagnostics all reduce to a single pattern: count a bit once when it transitions from FALSE to TRUE, ignore it while it stays TRUE, and recount it only on the next FALSE→TRUE transition.

In ladder logic, this is the classic one-shot (POS edge) instruction. In Siemens SCL (Structured Control Language) running on the S7-1200 and S7-1500 families inside TIA Portal, the same behavior is built from a memory bit that holds the previous scan's state. This article walks through a production-ready implementation, including the array-iteration logic, the difference between the FOR and WHILE constructs on each CPU class, a 32-bit batched variant that runs orders of magnitude faster, and the reset/initialization rules that prevent the very common "counter increments on every scan" bug.

Reference platform: S7-1516-3 PN/DP with firmware V2.9 (TIA Portal V17) and S7-1214C DC/DC/DC with firmware V4.5 (TIA Portal V17). Both target a standard FB cycle time of 1 ms in OB1. The pattern is identical on older STEP 7 V5.x SCL targets with minor syntax adjustments documented inline.

Prerequisites

Before opening TIA Portal, confirm the following:

  • Programming environment: TIA Portal V16 or later. SCL was unbundled from the basic package starting with V15 and ships as a separate license. SCL is fully integrated in TIA Portal V17/V18.
  • CPU firmware: S7-1200 firmware ≥ V4.2 (for fully indexed array access). S7-1500 firmware ≥ V1.8 for optimized access semantics on VARIANT parameters; firmware ≥ V2.0 recommended for the DWORD mask trick shown later.
  • Block type: A function block (FB) is required because the one-shot needs static memory to hold the previous array state. An FC cannot keep the previous scan's value across cycles without an additional instance DB.
  • Array storage: The boolean source array can live in a global DB, an instance DB, or an InOut parameter. For S7-1500 with optimized block access, ensure non-optimized access is selected if you intend to use slice addressing such as DB1.Array.%X0.

The One-Shot Pattern in SCL

The core Boolean identity behind every one-shot is:

RisingEdge = Input AND NOT PreviousState

When this expression is TRUE, the input transitioned from 0 to 1 between scans. The next scan, the memory bit is updated to the new input state, so the expression becomes 1 AND NOT 1 = 0 and the counter is not re-incremented until the input returns to 0 and then transitions back to 1.

Applying this to each element of a Boolean array #Array[0..N-1] requires a parallel memory array of the same length, conventionally named #Mem. The canonical SCL loop is:

#Counter_temp := 0;
FOR #i := #DB_Start TO #DB_End BY 1 DO
    IF (#Array[#Counter_temp] AND NOT #Mem[#Counter_temp]) THEN
        #k := #k + 1;
    END_IF;
    #Mem[#Counter_temp] := #Array[#Counter_temp];
    #Counter_temp += 1;
END_FOR;

Two details in this snippet are easy to get wrong:

  1. The memory update must happen on every scan, not only on the rising edge. If you place #Mem := #Array inside the IF branch, the memory never records the steady-TRUE state, and the next FALSE→TRUE edge will be missed.
  2. The read order in (#Array[i] AND NOT #Mem[i]) matters: the expression is short-circuit evaluated in SCL, so when #Array[i] is FALSE the NOT clause is not evaluated. This is functionally correct and slightly faster than writing the conjunction in the opposite order.

Step-by-Step Implementation

Step 1 — Declare the FB interface

Create a new FB named FB_OneShotArray with the following interface in TIA Portal:

Section Name Type Comment
Input reset Bool Reset counter and memory array
Input DB_Start Int Lower array bound (usually 0)
Input DB_End Int Upper array bound (array length − 1)
InOut Array Array[*] of Bool Source bit array (VARIANT-friendly)
Static Mem Array[0..1023] of Bool Previous-scan memory
Static i Int Loop index
Static Counter_temp Int Indirect index into Array
Static k DInt Edge-count result
Static initialized Bool First-scan flag
Output Count DInt Public count value

Sizing the static Mem to 1024 elements covers the largest alarm/event table in practice; reduce it to the actual maximum to conserve load memory. The initialized flag prevents the FB from counting bits that were already TRUE before the first scan — see the initialization section below.

Step 2 — Write the FB body

// FB_OneShotArray — body (SCL, TIA Portal V17)
#Count := #k;

// --- Reset path ----------------------------------------------------------
IF #reset THEN
    #k := 0;
    FOR #i := 0 TO 1023 DO
        #Mem[#i] := FALSE;
    END_FOR;
    #initialized := FALSE;
    RETURN;   // skip edge logic this scan
END_IF;

// --- First-scan initialization ------------------------------------------
IF NOT #initialized THEN
    #initialized := TRUE;
    FOR #i := 0 TO 1023 DO
        #Mem[#i] := FALSE;
    END_FOR;
END_IF;

// --- Edge detection loop -------------------------------------------------
#Counter_temp := 0;
FOR #i := #DB_Start TO #DB_End BY 1 DO
    IF (#Array[#Counter_temp] AND NOT #Mem[#Counter_temp]) THEN
        #k := #k + 1;
    END_IF;
    #Mem[#Counter_temp] := #Array[#Counter_temp];
    #Counter_temp += 1;
END_FOR;

#Count := #k;

The early RETURN on the reset path guarantees the memory array is cleared before the loop runs, so a reset followed by a true input on the very next scan correctly increments #k.

Step 3 — Wire the FB in OB1

"DB_AlarmWord"        // global DB containing 156 Bool flags
"FB_OneShotArray_DB"  // instance DB, multi-instance capable
"DB_OneShotCount".Count

Call site:

"FB_OneShotArray_DB"(reset := "DB_Alarm".ResetAll,
                     DB_Start := 0,
                     DB_End := 155,
                     Array := "DB_AlarmWord".AlarmFlags,
                     Count => "DB_OneShotCount".ActiveCount);

Step 4 — Verify in PLCSIM

Force "DB_AlarmWord".AlarmFlags[0] to TRUE, observe that Count rises from 0 to 1 on the first transition and stays at 1 with the bit still TRUE. Force the bit back to FALSE, then TRUE again, and confirm that Count rises to 2. This is the minimum test that proves the Mem update is functioning.

S7-1200 vs S7-1500 Differences

The pattern compiles on both families, but performance and indexing rules diverge:

Aspect S7-1200 (FW ≥ V4.2) S7-1500 (FW ≥ V2.0)
Loop indexing INT only; index with care for arrays > 32767 DINT preferred; array size practically unlimited
Optimized array access Restricted; %DBx.Array[%X0] not available Full slice access with optimized blocks
FOR loop bound Evaluated once on entry; do not modify inside loop Same rule, but compiler warns on violation
Indirect addressing Variable index supported only with non-optimized access Variable index fully supported in optimized blocks
Cycle budget for 1024-element loop ~280 µs (CPU 1214C, FW V4.5) ~14 µs (CPU 1516, FW V2.9)

On S7-1200, the indirect #Array[#Counter_temp] access requires the array to be either an InOut parameter with non-optimized access, or a temporary that points to a global DB with standard access. S7-1500 handles both seamlessly, which is why the discussion example specifically targets an S7-1500.

For pre-V14 firmware (S7-300/400 SCL), the same syntax compiles; the only difference is the missing += operator — use #k := #k + 1 instead.

DWORD-Batch Optimization

When the boolean array represents alarms that are fundamentally bit-packed (a flag-word or process-image word), iterating bit-by-bit is wasteful. Group the booleans into a multiple of 32 and use DWORD masked comparison instead. The trick: a rising edge in a packed DWORD is detected by:

RisingMask := Current DWORD AND NOT Previous DWORD
EdgeCount   := popcount(RisingMask)   // number of 1-bits set

SCL does not have a built-in popcount, but a small constant-time implementation using a lookup table or a simple Brian Kernighan loop is sufficient. A clean inline version:

// Count 1-bits in a DWORD (Brian Kernighan)
#tmp := RisingMask;
WHILE #tmp <> 0 DO
    #tmp := #tmp AND (#tmp - 1);
    #k  := #k + 1;
END_WHILE;

This executes in 1 + (number of edges) iterations. For an array of 32 booleans containing a single edge, the loop runs exactly twice instead of 32 times. On an S7-1516, scanning 1024 bits this way takes roughly 8 µs — about 35× faster than the bit-by-bit version, and the comparison happens in a single CPU instruction.

Allocate the source data so its length is always a multiple of 32. If you need 50 flags, allocate 64 and ignore the upper 14. The performance gain is worth the few unused bits.

Reset and Initialization

The most common field bug is "the counter increments on every scan as long as the input is TRUE." It is almost always caused by failing to update #Mem when the FB is first instantiated, so on scan 1 the previous state defaults to FALSE and every TRUE input is treated as a rising edge.

Two reliable remedies:

  1. First-scan flag. The initialized static bit shown above pre-clears #Mem on the first execution. This is the approach recommended for alarm systems, where ignoring pre-existing alarms at startup is the correct semantic.
  2. Initialize at restart. In the FB's Properties → Initialization column (TIA Portal), tick the Mem array and set its restart value to 0. The FB then behaves identically on cold restart, warm restart, and download-to-target.
Retention. If you want #Mem to survive a CPU STOP→RUN transition, mark it as retentive in the FB properties. Otherwise, a CPU stop during a high-priority alarm will lose the rising-edge memory and re-fire the edge on the next RUN.

Troubleshooting Matrix

Symptom Likely Cause Fix
Counter climbs by 1 each scan while input is TRUE Memory array never written; initialization skipped Add initialized flag or restart-initialization property on Mem
Counter never increments #Mem updated before the IF test (effectively samples current = previous) Move #Mem := #Array to the bottom of the loop body
Counter increments by 1 only on first scan, never again #Mem mistakenly stored in Temp instead of Static Move Mem declaration to Static section
Compiler error: "Variable index not allowed" (S7-1200) Optimized block access conflicts with indirect indexing Set array's parent block to non-optimized access, or move array into an InOut parameter of standard-access FB
Counter increments on the FALSE→TRUE transition but also once more after a reset Reset clears #k but not #Mem Add the FOR #i := 0 TO 1023 DO #Mem[#i] := FALSE; loop inside the reset branch
Different results in PLCSIM vs real CPU PLCSIM starts with all bits = FALSE; real CPU preserves last scan Always force initialized := FALSE on download and on STOP→RUN
Array index out of bounds when DB_End > actual array length No runtime bound check on S7-1200 optimized access Validate DB_End in a pre-loop IF, or use AT view with explicit length

Verification Checklist

Before handing the FB over to commissioning, run the following test sequence on PLCSIM or a real CPU in single-step:

  1. Cold restart the CPU. Force all 156 alarms to FALSE. Confirm Count = 0.
  2. Force alarm[0] = TRUE. Confirm Count = 1 within one cycle. Force alarm[0] = FALSE. Confirm Count stays at 1.
  3. Force alarm[0] = TRUE again. Confirm Count = 2.
  4. Repeat steps 2–3 for alarms at positions 31, 32, 155 to verify array bounds.
  5. Force multiple alarms to TRUE simultaneously. Confirm Count rises by exactly the number of distinct transitions, not by the number of bits set.
  6. Trigger reset = TRUE. Confirm Count = 0 and Mem is all FALSE.
  7. With reset returned to FALSE, force alarm[5] = TRUE. Confirm Count = 1 (not 2) — this verifies the reset cleared Mem.
  8. Stop the CPU, wait 5 s, run it again. Confirm Count does not spuriously increment on restart, verifying the initialized flag.

Performance and Sizing Notes

For a typical machine with 64 alarms scanned in OB1 at 1 ms:

  • S7-1214C FW V4.5: 64-bit loop adds ~18 µs per scan. Cycle-budget impact is negligible (< 2 %).
  • S7-1516 FW V2.9, bit-by-bit: 64-bit loop adds ~1 µs per scan.
  • S7-1516 FW V2.9, DWORD batched: Two DWORD iterations add < 0.5 µs.

For arrays > 8192 bits, consider calling the FB from a cyclic OB with a longer interval (OB30 = 100 ms) rather than OB1, to avoid jitter on time-critical motion or PID tasks.

Documentation References

FAQ

Why does my SCL one-shot counter increment on every scan while the input is TRUE?

The Mem (previous-state) array is never written, so every scan treats the input as a rising edge. Move the #Mem[i] := #Array[i] assignment outside the IF branch and ensure Mem is declared in the Static section, not Temp.

Can I implement the one-shot without a parallel memory array?

Only for a single bit — use the built-in edge flag (Signal_%X0: P; in LAD/FBD). For an array you need storage equal in length to the array. On S7-1500 firmware ≥ V2.0 you can compress this into one DWORD per 32 inputs.

What is the fastest way to count edges in 1024 boolean alarms on an S7-1500?

Pack the booleans into DWORDs, compute RisingMask := Current AND NOT Previous, then count 1-bits with the Brian Kernighan loop. This runs in roughly 8 µs on an S7-1516 versus ~280 µs for the bit-by-bit version.

Do I need to initialize Mem on cold restart?

Yes, unless you intentionally want rising edges on bits that were already TRUE before the first scan. The recommended approach is the initialized static flag combined with the FB's restart value property set to 0 for Mem.

Does the pattern work in STEP 7 V5.x SCL on S7-300/S7-400?

Yes. Replace #k += 1 with #k := #k + 1 (the compound assignment was introduced with SCL in TIA Portal), and use INT indexing throughout. Avoid InOut VARIANT parameters — use standard IN_OUT ARRAY[*] OF BOOL syntax instead.

Back to blog