Overview
Computing a moving average on a Siemens SIMATIC S7-300/S7-400 controller in TIA Portal requires a different approach than on an S7-1200/1500 because legacy CPU firmware does not allow indexed access to ARRAY elements. The standard approach—shifting every sample one position through the buffer on each cycle—is wasteful and time-bounded by the buffer length. The recommended pattern is a circular buffer with a running sum: overwrite the oldest element in place, update the running sum by adding the new sample and subtracting the displaced old sample, and advance a pointer that wraps at the buffer length. This gives an O(1) average update regardless of the window size, which matters for an analog input (REAL) read at typical 100 ms–1 s intervals when the window may contain 16, 32, 64, or 128 samples.
This article covers:
- Why a circular buffer is preferred over a block shift on S7-300/400
- Building the buffer as a DB of REAL elements
- STL pointer arithmetic with
AR1,P##Buffer, andP#4.0 - Maintaining a running sum to avoid the O(N) recompute penalty
- An equivalent SCL implementation for S7-1200/1500 for comparison
- Initialization, overflow handling, and OB execution timing
Why a Circular Buffer, Not a Block Shift
The original SCL attempt to clear the buffer with FILL_BLK works on S7-1500 but is not available on S7-300/400. Manually shifting every element of an N-sample REAL buffer requires N loads and N transfers, executes every scan, and risks scan-time overrun. The circular-buffer pattern avoids shifting entirely:
- Keep a single index/pointer
Pinto a fixed-length arrayBuffer[0..N-1]. - Read
Old = Buffer[P](the element being overwritten). - Write
Buffer[P] = NewSample. - Update the running sum:
Sum = Sum + NewSample - Old. - Compute
Average = Sum / N. - Advance
P = P + 1; wrap to 0 whenP >= N.
Each cycle performs exactly 2 REAL loads, 2 REAL stores, 2 REAL adds, 1 REAL divide, and 1 integer increment. Scan time is independent of N.
+AR1 P#4.0. For INT it would be P#2.0; for DINT and DWORD, P#4.0; for BOOL, P#0.1 (bit granularity, not byte). The element size must be calculated once and hard-coded; mismatches corrupt the buffer silently.Prerequisites
- STEP 7 V5.x or TIA Portal V13+ with the S7-300/400 CPU target installed
- A CPU 31x or 41x that supports indirect addressing via AR1/AR2 (any standard CPU 312/314/315/317/319 or 412/414/416/417)
- An analog input module (SM 331 / SM 431) configured and scaled to REAL engineering units in the same cycle, OR an existing REAL tag carrying the input value
- A data block (DB) of sufficient length to hold the sample window
- OB1 (or a cyclic OB such as OB35) for the averaging logic
Step 1: Build the Buffer Data Block
Create a global DB (e.g., DB100 "BufferDB") and define the layout exactly as follows. The order matters because STL will reference the symbols by absolute address if you write pure STL, or by symbolic name if you declare them as STAT in an FB.
| Symbol | Type | Initial Value | Comment |
|---|---|---|---|
| Buffer | ARRAY[0..63] OF REAL | 0.0 | 64-sample ring buffer (change to your N) |
| Sum | REAL | 0.0 | Running sum of all elements |
| Average | REAL | 0.0 | Moving average output |
| Index | INT | 0 | Current write position |
| Length | INT | 64 | Window size (N) |
| NewValue | REAL | 0.0 | Latest analog sample |
| OldValue | REAL | 0.0 | Value being overwritten this cycle |
Disable optimized block access on this DB so STL can address it as DB100.DBD0, DB100.DBD4, etc. In TIA Portal: right-click the DB → Properties → Attributes → uncheck "Optimized block access".
Step 2: Pointer Arithmetic Rules for S7-300/400
A pointer in classic STEP 7 is a 32-bit double-word with the structure P#Byte.Bit stored in the low 24 bits. P##Buffer loads the area-internal pointer to the symbol Buffer in the currently opened DB. The cross-area pointer P#DB100.DBX0.0 BYTE 4 includes DB number and byte/bit offsets and is what you store in AR1 for area-crossing walks.
For an intra-DB walk, use the area-internal pointer because it is faster and works on all S7-300/400 CPUs:
L P##Buffer // Pointer to first element (DBD0 of Buffer)
T MD 100 // Store in AR1's static backup
Each REAL element is 4 bytes, so incrementing the index by 1 means incrementing AR1 by 4 bytes:
+AR1 P#4.0 // Walk one REAL forward
To wrap, test the index against Length before the increment, reset Index to 0, and reload the pointer with L P##Buffer. Do not wrap by subtracting on the pointer itself—the cross-area pointer math is safe, but the area-internal pointer is faster and the wrap should be done on the integer index for clarity.
Step 3: STL Implementation in OB1 / OB35
The following STL works on any S7-300/400 CPU. Paste it as a network inside OB1 (free scan) or OB35 (100 ms cyclic interrupt; recommended for time-based averaging). Replace DB100 with your DB number and IW 512 with your analog input PIW (or feed DB100.DBD 24 ("NewValue") from your scaling FB).
NETWORK 1 // Wrap check + reload pointer at buffer boundary
L DB100.DBW 10 // Index (INT)
L DB100.DBW 12 // Length (INT)
>=I
JCN SKIP // If Index < Length, do not reload
L 0
T DB100.DBW 10 // Index := 0
L P##DB100.Buffer // Pointer to Buffer[0]
T MD 100 // Pointer_To_Target (saved AR1 image)
SKIP: LAR1 MD 100 // AR1 := Pointer_To_Target
OPN DB 100
L DBD [AR1, P#0.0] // OldValue := Buffer[Index]
T DB100.DBD 28 // stash in OldValue tag
NETWORK 2 // Write the new sample into the buffer slot
L DB100.DBD 24 // NewValue (REAL, pre-scaled)
T DBD [AR1, P#0.0] // Buffer[Index] := NewValue
NETWORK 3 // Update running sum: Sum := Sum + New - Old
L DB100.DBD 4 // Sum (REAL)
L DB100.DBD 24 // + NewValue
+R
L DB100.DBD 28 // - OldValue
-R
T DB100.DBD 4 // Sum := updated
NETWORK 4 // Average := Sum / Length
L DB100.DBD 4 // Sum
L DB100.DBW 12 // Length (INT)
DTR // Convert to REAL
/R
T DB100.DBD 8 // Average
NETWORK 5 // Increment index and pointer
L DB100.DBW 10 // Index
+ 1
T DB100.DBW 10
+AR1 P#4.0 // Walk AR1 by 4 bytes (one REAL)
TAR1 MD 100 // Pointer_To_Target := AR1
Symbolic address offsets in the snippet above assume the DB layout from Step 1:
| Tag | Offset |
|---|---|
| Buffer[0] | DBD0 |
| Sum | DBD4 (but Buffer is 64×4 = 256 bytes, so Sum actually starts at offset 256) |
Important: the offset table is illustrative. Because Buffer is an array of 64 REALs, Buffer occupies bytes 0–255. Sum, Average, Index, Length, NewValue, OldValue follow starting at byte 256. Recompute the offsets based on the array length you actually use, or use symbolic access in TIA Portal so the compiler resolves them.
Step 4: Symbolic-Access Variant (Recommended in TIA Portal)
If the DB is non-optimized, you can still reference symbols by name. This version is more readable and survives DB layout changes:
L "BufferDB".Index
L "BufferDB".Length
>=I
JCN SKIP
L 0
T "BufferDB".Index
L P##"BufferDB".Buffer
T #Pointer_To_Target
SKIP: LAR1 #Pointer_To_Target
OPN "BufferDB"
L DBD [AR1, P#0.0]
T "BufferDB".OldValue
L "BufferDB".NewValue
T DBD [AR1, P#0.0]
L "BufferDB".Sum
L "BufferDB".NewValue
+R
L "BufferDB".OldValue
-R
T "BufferDB".Sum
L "BufferDB".Sum
L "BufferDB".Length
DTR
/R
T "BufferDB".Average
L "BufferDB".Index
+ 1
T "BufferDB".Index
+AR1 P#4.0
TAR1 #Pointer_To_Target
Step 5: SCL Equivalent for S7-1200/1500
On S7-1200/1500 with TIA Portal V13+, you can use direct array indexing and SCL. This is the same algorithm in a fraction of the code, and it is why most new projects migrate to S7-1500 for signal conditioning:
// FB "MovingAverage" — SCL for S7-1200/1500
VAR
Buffer : ARRAY[0..63] OF REAL;
Sum : REAL := 0.0;
Index : INT := 0;
Length : INT := 64;
Filled : BOOL := FALSE;
END_VAR
BEGIN
Sum := Sum + #NewValue - #Buffer[#Index];
#Buffer[#Index] := #NewValue;
#Index := #Index + 1;
IF #Index >= #Length THEN
#Index := 0;
#Filled := TRUE;
END_IF;
IF #Filled THEN
#Average := #Sum / INT_TO_REAL(#Length);
ELSE
#Average := #Sum / INT_TO_REAL(#Index + 1);
END_IF;
END_FUNCTION_BLOCK
The Filled flag avoids the partial-window bias on cold start. On S7-1500, enable optimized block access and use AT views only if you need byte-level reinterpretation.
Step 6: Sampling-Rate Configuration
The averaging rate (samples per second) must match the analog input update rate. Place the FB in:
- OB35 (Cyclic Interrupt, default 100 ms) — best practice; decouples averaging from OB1 scan jitter.
- OB1 (free cycle) — acceptable if the analog input is read in OB1 and the process tolerates scan-time variation.
- Hardware interrupt OB40 — use only if the analog module is configured to signal "conversion complete" and you need sample-accurate timing.
Configure OB35 in the CPU Properties → Cyclic Interrupts. Set the OB35 execution time so that N × OB35_period = desired window length. Example: OB35 = 100 ms, N = 64 → 6.4 s window, which smooths mains-frequency noise from a 4–20 mA pressure loop effectively.
Step 7: Pointer Increment by Element Size Reference
| Data Type | Bytes | Pointer Increment |
|---|---|---|
| BOOL | 0.1 (bit) | P#0.1 |
| BYTE / CHAR | 1 | P#1.0 |
| WORD / INT / S5TIME | 2 | P#2.0 |
| DWORD / DINT / REAL / TIME / TOD | 4 | P#4.0 |
| LREAL / LWORD / LINT / DTL | 8 | P#8.0 (not on S7-300, only S7-400) |
Hard-coding the wrong increment is the #1 cause of "my buffer values look random" in the field. If you see junk, suspect the increment first.
Verification
- Static buffer test. Force Buffer[0..N-1] to a known pattern (e.g., 0.0, 1.0, 2.0, ...), set Sum manually, set Index = 0, and single-cycle OB35. Average should match the arithmetic mean of the forced pattern within rounding.
- Step response. Apply a step change to the analog input. Average should reach 63.2% of the step in N/2 cycles (exponential response of a box filter). Watch the trace in TIA Portal's "Monitor & Force Table".
-
Sum sanity check. Add a debug network that computes
Sum_Recomputeby looping the buffer and compare to the runningSum. They must match exactly at all times; any drift indicates a wrap/pointer bug. - Watchdog check. Monitor OB1/OB35 cycle time in the CPU diagnostic buffer. The added logic typically adds < 200 µs per execution on a CPU 315-2 PN/DP.
- Cold-start verification. Power-cycle the CPU. Confirm that the Filled flag (or your equivalent) clears, the buffer is zeroed, and the average is not used in downstream control until the window has filled once.
Troubleshooting Matrix
| Symptom | Likely Cause | Fix |
|---|---|---|
| Average stuck at 0.0 | Sum not initialized; NewValue never latched | Initialize Sum in OB100; verify scaling FB writes NewValue each cycle |
| Average jumps wildly | Pointer increment wrong for element size (e.g., P#2.0 on REAL) | Change to P#4.0 for REAL/DWORD |
| Average drifts downward over time | Sum updated with wrong sign (subtracting new value instead of old) | Verify +R and -R sequence: Sum + New - Old |
| First N samples show zero | Buffer uninitialized, Filled flag missing | Zero-init Buffer in OB100; gate Average on Filled |
| Compiler error: "Area-internal pointer required" | Used cross-area pointer inside OPN DB context | Use P##Symbol after OPN DBxxx |
| SF (System Fault) LED on CPU | AR1 not loaded before DBD[AR1,P#0.0] access | Always LAR1 before the indexed load |
| Average uses wrong DB after L instruction | L changed the opened DB; OPN must be reissued before DBD[] | Re-issue OPN DBxxx after each accumulator-changing L |
Performance Notes
On a CPU 315-2 PN/DP, the six networks above execute in roughly 90–150 µs per call, dominated by the REAL divide. The 4-byte +AR1 P#4.0 and the LAR1/TAR1 pair are essentially free. The running-sum trick saves N-1 REAL adds per cycle, which is the difference between 6.4 µs and 410 µs at N=64 on the same CPU. For N above ~1000, consider switching to a CIC filter or an exponential moving average, both of which are O(1) and need no buffer at all.
Migration to S7-1500
If you can move the application to an S7-1500 (e.g., CPU 1511-1 PN, 6ES7511-1AK02-0AB0), the entire STL block collapses into the SCL FB shown in Step 5. Optimized block access becomes the default, symbolic access is implicit, and you can use DYN array bounds (VARIANT input) to make the FB generic over any window length. See the S7-1500 SCL programming guideline and the STEP 7 TIA Portal V18 system manual for details on optimized access and the AT view on slices.
Safety and Operational Notes
Can I use SCL on an S7-300 to do the same moving average with a sliding window?
Yes, but with a caveat. SCL on S7-300/400 supports the FOR loop and indexed array access, but the array must be declared in a non-optimized DB. The running-sum pattern still wins on cycle time. Use SCL if readability matters more than the last 50 µs.
Why does my average read 0.0 for the first N samples?
The buffer is uninitialized, Sum is 0.0, and the running-sum equation Sum := Sum + New - Old is correct, but the cold start has not yet produced a meaningful mean. Either gate the output on a Filled flag, or pre-fill Buffer and Sum in OB100 warm restart.
What pointer increment do I use for INT, DINT, and BOOL arrays?
INT and WORD use P#2.0; DINT, DWORD, and REAL use P#4.0; BOOL uses P#0.1 (bit, not byte). Mismatching the increment corrupts the buffer silently. The reference table in Step 7 lists all common types.
How do I change the window length without recompiling the program?
Store Length in the DB instead of a literal, and parameterize Buffer as ARRAY[0..255] OF REAL with a runtime cap. Limit the actual walk to Length using the wrap check. The running-sum divide uses the current Length value, so the average tracks the active window automatically.
Is the running sum numerically stable for long windows?
For 32-bit REAL and windows up to ~10,000 samples, yes. Beyond that, REAL quantization accumulates (REAL has ~7 significant digits). For very long windows, switch to LREAL on S7-400/S7-1500, or use an exponential moving average: Average := Average + (New - Average) / N, which is unconditionally stable and uses no buffer.