Siemens STL Code Analysis: Tracing a Compiled SCL Function Block
Overview
Statement List (STL) is the text-based, low-level IEC 61131-3 programming language for SIMATIC S7-300, S7-400, S7-1200 and S7-1500 controllers. It is the native machine representation of the STEP 7 runtime. Structured Control Language (SCL) is the higher-level, Pascal-like language that compiles down to STL networks. When a project is delivered without the SCL source — for example, when blocks were compiled and then have the source encrypted, deleted, or simply never checked into the archive — you are left with STL listings that must be reverse-engineered by hand.
This document walks line-by-line through a real compiled STL block that the SCL compiler emits for a common "integer-to-packed-BCD with digit-count overflow" function. It explains every instruction, the contents of ACCU1 and ACCU2 at each step, the meaning of the labels M001 through M004, and how to recover an equivalent SCL source. The same techniques apply to any other compiled SCL block you encounter in STEP 7 V5.x, TIA Portal V15–V18, or the current V19 release.
Before continuing, ensure you have access to the official SIMATIC references for the instruction set: the STEP 7 Programming Guideline and the SCL for S7-300/400 Programming Manual from Siemens Industry Online Support. For TIA Portal V18/V19 users, the S7-1200/1500 SCL Programming Manual documents the same instruction semantics.
Prerequisites
- Familiarity with STEP 7 / TIA Portal block structure (OB / FB / FC / DB / IN / OUT / TEMP / STAT).
- Basic understanding of the S7-300/400 or S7-1500 two-accumulators (
ACCU1,ACCU2) execution model. - STEP 7 V5.5 SPx, TIA Portal V16, or any newer TIA Portal release with the STL editor enabled (note: in TIA Portal V18+ STL is opt-in and may need to be activated in the project options).
- Watch table or STL Monitor for verification.
Why SCL Compiles to STL Networks
The SCL compiler does not generate native machine code. It parses the high-level statements, allocates temporaries, and emits a sequence of STL instructions equivalent to the abstract syntax tree. The block you see in the source snippet is therefore a faithful translation of an SCL function with the body roughly equivalent to:
FUNCTION_BLOCK digits_packed : WORD
VAR_INPUT
IN0 : DINT;
END_VAR
VAR_TEMP
TEMP1 : DINT;
TEMP2 : DINT;
END_VAR
BEGIN
TEMP1 := 0;
TEMP2 := IN0;
IF IN0 < 0 OR IN0 > 9990000 THEN
digits_packed := 16#0000;
ENO := FALSE;
RETURN;
END_IF;
WHILE TEMP2 > 999 DO
TEMP2 := TEMP2 / 10;
TEMP1 := TEMP1 + 1;
END_WHILE;
digits_packed := SHL(WORD#16#0,0) OR (DTB(TEMP2) AND 16#0FFF) OR SHL(DTB(TEMP1),12);
END_FUNCTION_BLOCK
The labels M001, M002, M003, M004 are synthesized by the compiler as jump targets that encode the IF / WHILE control flow.
Symbolic Variable Map
| Symbol | Type | Scope | Role |
|---|---|---|---|
| IN0 | DINT (L#) | VAR_INPUT | Input integer whose digit structure is to be packed. |
| TEMP1 | DINT | VAR_TEMP | Loop counter: number of times TEMP2 was divided by 10 before reaching ≤ 999. Equals digit-count minus 3. |
| TEMP2 | DINT | VAR_TEMP | Working copy of IN0 that is repeatedly integer-divided by 10 until it fits in 3 BCD digits. |
| RET_VAL | WORD | Return value (function result) | Packed BCD result: bits 0–11 = BCD(TEMP2), bits 12–15 = BCD(TEMP1). |
| ENO | BOOL | Status output (BR bit) | Set FALSE on input range violation; otherwise TRUE. |
The Source Listing in STL
The block under analysis is reproduced below with line numbers. The right column is the human-readable comment that a programmer would normally see if they opened the SCL source in TIA Portal.
1 SET ; RLO := 1
2 SAVE ; BR := 1 (ENO will be TRUE unless cleared)
3 L 0 ; ACC1 := 0
4 T #TEMP1 ; TEMP1 := 0
5 L #IN0 ; ACC1 := IN0
6 T #TEMP2 ; TEMP2 := IN0
7 L 0 ; ACC1 := 0
8 <D ; ACC2 (IN0) < ACC1 (0) ?
9 JC M001 ; if TRUE jump to M001 (error path)
10 TAK ; swap ACC1/ACC2 (restore IN0 to ACC1)
11 L L#9990000 ; ACC1 := 9990000
12 <=D ; ACC2 (IN0) <= ACC1 (9990000) ?
13 JC M002 ; if TRUE jump to M002 (main path)
14 L W#16#3999 ; ACC1 := W#16#3999
15 JC M001 ; unconditional-looking jump to M001 (dead code path)
16 M002: L #TEMP2
17 L L#10
18 /D ; ACC1 := TEMP2 / 10
19 T #TEMP2
20 L L#999
21 <=D ; ACC2 (TEMP2) <= ACC1 (999) ?
22 JC M003 ; if TRUE jump to M003 (done)
23 L #TEMP1
24 INC 1
25 T #TEMP1
26 JU M002 ; unconditional loop back
27 M003: TAK
28 DTB ; ACC1 := BCD of TEMP2 (TEMP2 was in ACC1)
29 L #TEMP1
30 SLW 12 ; ACC1 := TEMP1_shl_12
31 OW ; ACC1 := ACC2 OR ACC1
32 JU M004
33 M001: CLR
34 SAVE ; BR := 0 (ENO := FALSE)
35 M004: T #RET_VAL
36 BE ; block end
M001…M004 are compiler-generated jump targets. They are not user symbols; they map to internal branch IDs in the compiled network. If you switch the block to SCL the labels disappear.ACCU1 / ACCU2 State Trace
The table below shows ACCU1 and ACCU2 after every executed instruction for two representative inputs. RLO is the binary result bit used by JC / JCN / JU. Negative values of IN0 or values above L#9990000 short-circuit through M001 and return 16#0000 with ENO cleared.
Case A: IN0 = L#12345 (positive, in range)
| Line | Instruction | ACCU1 | ACCU2 | RLO / BR | TEMP1 | TEMP2 | Comment |
|---|---|---|---|---|---|---|---|
| 1 | SET | - | - | RLO=1 | - | - | Initial RLO |
| 2 | SAVE | - | - | BR=1 | - | - | ENO := TRUE |
| 3 | L 0 | 0 | old ACC1 | - | - | - | Load zero |
| 4 | T #TEMP1 | 0 | old | - | 0 | - | Init counter |
| 5 | L #IN0 | 12345 | 0 | - | 0 | - | Load input |
| 6 | T #TEMP2 | 12345 | 0 | - | 0 | 12345 | Copy input |
| 7 | L 0 | 0 | 12345 | - | 0 | 12345 | Prepare comparison |
| 8 | <D | 0 | 12345 | RLO=0 (12345<0 false) | 0 | 12345 | Sign check |
| 9 | JC M001 | not taken | Sign test passed | ||||
| 10 | TAK | 12345 | 0 | - | 0 | 12345 | Swap back |
| 11 | L L#9990000 | 9990000 | 12345 | - | 0 | 12345 | Range upper |
| 12 | <=D | 9990000 | 12345 | RLO=1 (12345<=9990000) | 0 | 12345 | Range test |
| 13 | JC M002 | taken | Enter main path | ||||
| 16 | L #TEMP2 | 12345 | 9990000 | - | 0 | 12345 | Loop start |
| 17 | L L#10 | 10 | 12345 | - | 0 | 12345 | Divisor |
| 18 | /D | 1234 | 10 | - | 0 | 12345 | Integer divide |
| 19 | T #TEMP2 | 1234 | 10 | - | 0 | 1234 | Store quotient |
| 20 | L L#999 | 999 | 1234 | - | 0 | 1234 | Threshold |
| 21 | <=D | 999 | 1234 | RLO=0 (1234<=999 false) | 0 | 1234 | Continue loop |
| 22 | JC M003 | not taken | More divisions needed | ||||
| 23 | L #TEMP1 | 0 | 999 | - | 0 | 1234 | Load counter |
| 24 | INC 1 | 1 | 999 | - | 0 | 1234 | Increment |
| 25 | T #TEMP1 | 1 | 999 | - | 1 | 1234 | Store counter |
| 26 | JU M002 | taken | Loop | ||||
| 16 | L #TEMP2 | 1234 | 1 | - | 1 | 1234 | Loop iter 2 |
| 17 | L L#10 | 10 | 1234 | - | 1 | 1234 | Divisor |
| 18 | /D | 123 | 10 | - | 1 | 1234 | Integer divide |
| 19 | T #TEMP2 | 123 | 10 | - | 1 | 123 | Store |
| 20 | L L#999 | 999 | 123 | - | 1 | 123 | Threshold |
| 21 | <=D | 999 | 123 | RLO=1 (123<=999) | 1 | 123 | Loop exit |
| 22 | JC M003 | taken | Exit loop | ||||
| 27 | TAK | 123 | 999 | - | 1 | 123 | Bring TEMP2 to ACC1 |
| 28 | DTB | 16#0123 | 999 | - | 1 | 123 | Convert to BCD |
| 29 | L #TEMP1 | 1 | 16#0123 | - | 1 | 123 | Load counter |
| 30 | SLW 12 | 16#1000 | 16#0123 | - | 1 | 123 | Shift to high nibble |
| 31 | OW | 16#1123 | 16#0123 | - | 1 | 123 | Bitwise OR |
| 32 | JU M004 | taken | Skip error path | ||||
| 35 | T #RET_VAL | 16#1123 | 16#0123 | - | 1 | 123 | Store result |
| 36 | BE | - | - | BR=1 | 1 | 123 | Block end, ENO=TRUE |
Final RET_VAL = W#16#1123. High nibble 1 is the BCD-encoded digit count above 3 (here, 5 digits total → 5 − 3 = 2? See note below). Low 12 bits 123 are the BCD-encoded last three digits of the input.
1 after only two loop iterations. That does not represent "total digit count" — it represents the number of times the value was divided by 10 until it fit in three decimal digits. For a 5-digit input that is two divisions. Whether the function interprets that nibble as overflow digits, page selector, or 7-segment digit index depends entirely on the calling context. Treat the nibble as an opaque count and consult the calling block.Case B: IN0 = L#−1 (negative, out of range)
| Line | Instruction | ACCU1 | ACCU2 | RLO / BR | Branch |
|---|---|---|---|---|---|
| 7 | L 0 | 0 | -1 | - | - |
| 8 | <D | 0 | -1 | RLO=1 (-1<0) | - |
| 9 | JC M001 | taken | To error path | ||
| 33 | CLR | - | - | RLO=0 | - |
| 34 | SAVE | - | - | BR=0 | ENO := FALSE |
| 35 | T #RET_VAL | (previous ACC1) | - | - | Stores whatever was in ACC1 |
| 36 | BE | - | - | BR=0 | Block end |
For IN0 = -1, RET_VAL retains the value from ACCU1 at the moment of the T instruction. In this trace ACCU1 is still 0 from line 3 (TEMP1 initial), so RET_VAL = W#16#0000. ENO is FALSE. This is the documented "input invalid" contract.
Case C: IN0 = L#10000000 (above upper bound)
| Line | Instruction | RLO | Branch |
|---|---|---|---|
| 10 | TAK | - | After <D RLO=0, IN0 restored to ACC1 |
| 11 | L L#9990000 | - | - |
| 12 | <=D | RLO=0 (10000000 > 9990000) | Continue past JC |
| 13 | JC M002 | not taken | Fall through |
| 14 | L W#16#3999 | ACC1 := 16#3999 | Constant loaded (dead-strip hint) |
| 15 | JC M001 | not taken — RLO=0 | Fall through to M001 anyway |
| 33 | CLR + SAVE | BR=0 | Error path |
Line 14–15 are a defensive pattern the compiler emits whenever an IF/ELSIF chain has an unbounded range. The JC M001 after the L W#16#3999 will never be taken because the previous comparison set RLO=0 and L does not change RLO, but the compiler emits the conditional jump anyway as a safe fall-through. The constant W#16#3999 is effectively dead code.
Reconstructing Equivalent SCL
From the trace above you can write back the original SCL source. The block returns a 16-bit word whose high nibble is a BCD digit counter and whose low 12 bits hold the BCD of the last three digits:
FUNCTION "FC_digits_packed" : WORD
{ S7_Optimized_Access := 'FALSE' }
VERSION : 0.1
VAR_INPUT
IN0 : DINT;
END_VAR
VAR_TEMP
TEMP1 : DINT;
TEMP2 : DINT;
END_VAR
BEGIN
TEMP1 := 0;
TEMP2 := IN0;
IF (IN0 < 0) OR (IN0 > L#9990000) THEN
"FC_digits_packed" := W#16#0;
ENO := FALSE;
RETURN;
END_IF;
WHILE TEMP2 > L#999 DO
TEMP2 := TEMP2 / L#10;
TEMP1 := TEMP1 + L#1;
END_WHILE;
"FC_digits_packed" := SHL(IN:=DWORD_TO_WORD(DTB(TEMP1)),
N:=12) OR (DTB(TEMP2) AND W#16#0FFF);
ENO := TRUE;
END_FUNCTION
Round-trip this against a known STL compile in your TIA Portal installation: the byte layout of the compiled STL must match the source listing exactly.
Why TAK Appears Twice
The TAK (toggle accumulator) instruction is the SCL compiler's way of preserving a value across a comparison. After L #IN0 in line 5, IN0 sits in ACCU1. The <D comparison in line 8 needs IN0 in ACCU2 and 0 in ACCU1, hence L 0 in line 7. Now ACCU1 holds the constant 0 and ACCU2 holds IN0, but the next step (loading L#9990000 for the upper bound check) needs IN0 back in ACCU2. TAK swaps them. The same pattern repeats at line 27 after the loop: the comparison leaves L#999 in ACCU1 and the just-divided TEMP2 in ACCU2, and DTB needs TEMP2 in ACCU1.
If you mentally remove both TAK instructions you can see that the compiler could have used TAR1/TAR2 AR manipulation or a second T to a scratch TEMP, but TAK is cheaper and side-effect free.
The Role of SET / SAVE / CLR
SET sets RLO to 1 unconditionally. SAVE copies the current RLO into the binary result bit (BR). The status output ENO of every STEP 7 block is wired to BR. The pattern SET; SAVE; at the top of a block therefore sets ENO to TRUE at block start. The mirror pattern CLR; SAVE; clears it. This is the standard STEP 7 idiom for explicit ENO control. See STEP 7 Programming Guideline, section 5.4 "EN/ENO handling".
SAVE is sometimes emitted in places where it does not actually have any effect on ENO — for example, right after a comparison that sets RLO=1 inside a network that subsequently falls through without branching. This is benign. The SCL compiler may emit redundant SAVE instructions because its intermediate representation tracks ENO state conservatively.Common Pitfalls When Reading Compiled SCL
-
Assuming JC vs JCN vs JU is meaningful.
JConly jumps on RLO=1;JUjumps unconditionally. Compilers almost always useJCfor IF/ELSIF andJUfor loops, but you should always verify by inspecting RLO. -
Ignoring the side effect of
Lon ACCU2. EveryLinstruction pushes the old ACCU1 down into ACCU2. If you only track ACCU1 you will miss implicit dependencies. -
Forgetting that integer comparison is signed.
<D,<=D,>D,>=Dare 32-bit signed. For unsigned comparisons use<DW,<=DW, etc. -
Misreading
/Das floating-point division./Dis integer division. The remainder is dropped. For floating point use/R(REAL) or/LREALon S7-1500. -
Forgetting that
SLWis a 16-bit shift. The accumulator is 32 bits wide on S7-300/400, butSLWtruncates to 16 bits and shifts within that 16-bit word. On S7-1500 the same semantics apply but the underlying word width is 16 bits by definition.
Verification in the Online Watch Table
- Compile and download the block to the PLC.
- Open an online watch table and add
DB_digits.IN0andFC_digits_packedas points to monitor. - Force
IN0to12345. ObserveRET_VAL = W#16#1123and ENO = TRUE. - Force
IN0to-1. ObserveRET_VAL = W#16#0000and ENO = FALSE. - Force
IN0to9990000. ObserveRET_VAL = W#16#0999(TEMP1=0 after one division would make 9990000→999000, then 99900, then 9990, then 999 — four iterations; TEMP1=4, so RET_VAL = W#16#4999). Trace by hand to confirm. - Force
IN0to10000001. ObserveRET_VAL = W#16#0000and ENO = FALSE.
Tools for Further Reverse-Engineering
| Tool | Use case | Source |
|---|---|---|
| TIA Portal "Switch programming language" | Convert STL block back to SCL when source is missing but symbolic info is intact. | Siemens TIA Portal |
| STEP 7 V5.5 SourceCompare | Diff a re-compiled SCL source against an STL block to validate equivalence. | Siemens STEP 7 V5.5 |
| PLCSIM / PLCSIM Advanced | Single-step execution and ACCU inspection. | Siemens |
| S7-PLCSIM STL Watch | Inspect ACCU1/ACCU2, AR1, AR2, DB, DI registers per instruction. | Siemens |
| Library "LGF" (Library of General Functions) | Open-source Siemens reference library with idiomatic SCL source to compare against. | Siemens LGF GitHub mirror |
Edge Cases and Boundary Behaviour
-
IN0 = 0: Loop body not entered, TEMP1=0, TEMP2=0,
DTB(0)=W#16#0000, RET_VAL =16#0000, ENO=TRUE. -
IN0 = 999: Loop body not entered (TEMP2=999 satisfies ≤999 check before division), RET_VAL =
16#0999, ENO=TRUE. -
IN0 = 1000: One division, TEMP2=100, TEMP1=1, RET_VAL =
16#1100. -
IN0 = 9990000: Four divisions (9990000→999000→99900→9990→999), TEMP1=4, TEMP2=999, RET_VAL =
16#4999. -
IN0 = 9990001: Falls through to M001 error path, RET_VAL=
16#0000, ENO=FALSE. - IN0 = L#-2147483648 (DINT min): Signed comparison catches it on the <D test, error path, ENO=FALSE.
- IN0 = L#2147483647 (DINT max): Greater than L#9990000, error path, ENO=FALSE.
- IN0 = real value passed to a DINT IN: Conversion happens at the call site, not inside the FB. If the caller passes an out-of-range REAL the FB sees an undefined DINT and behaviour depends on the runtime.
Standards Compliance Notes
The function implements a strict subset of the IEC 61131-3 BCD conversion family. The behaviour is conformant with the BCD-to-Integer and Integer-to-BCD standard functions for inputs within the documented range. For inputs outside the range, the SCL compiler emits the explicit error-flag pattern (CLR/SAVE) that is the STEP 7 idiom for ENO-driven error propagation. When re-implementing the function in pure SCL, use RETURN early to keep the compiled STL close to this shape; do not use nested IFs that would produce additional labels.
If you need to certify the function for SIL 2/3 applications on an S7-1500F, consult the S7-1500F Programming Manual and re-run the converter through the F-channel compiler — some unsafe constructs are rejected.
Frequently Asked Questions
What does the DTB instruction do in Siemens STL?
DTB (Double Integer to BCD) converts the 32-bit signed integer in ACCU1 into its Binary-Coded Decimal representation, placing the result back into ACCU1. ACCU2 is unchanged. The result occupies only the low 24 bits (up to 8 BCD digits); out-of-range values set the OV bit. See the STEP 7 STL instruction reference for the full encoding.
Why does the function pack the result with SLW 12 and OW?
The output is a 16-bit word that holds two pieces of information: the digit counter in the high nibble (bits 12–15) and the BCD of the last three digits in the low 12 bits (bits 0–11). SLW 12 moves the counter into the high nibble; OW merges the BCD value into the low 12 bits using bitwise OR. This is the standard STEP 7 pattern for packing multiple BCD nibbles into a single word.
How do I view the SCL source for a block that only has STL in the project?
If the symbolic information and SCL source are still in the offline program, right-click the block in the project tree and choose Switch programming language → SCL. The editor will attempt to regenerate SCL from STL. If the source was deleted or encrypted, this fails — you must use the method in this document to reconstruct it manually.
What happens if I pass a negative number to a function like this?
The <D comparison on line 8 of the trace detects IN0 < 0 and jumps to M001, which executes CLR; SAVE; to clear ENO and stores W#16#0000 in RET_VAL. The calling block must check ENO before using the return value.
Is there a standard Siemens FC that produces this exact packed-BCD output?
No Siemens standard FC in the IEC library produces this exact format. The closest standard function is DTB (DINT-to-BCD) which converts the whole 32-bit value; the function in this article performs a digit truncation to 3 BCD digits plus a separate overflow counter. It looks like a customer or OEM-specific utility, possibly for a 7-segment display driver that selects digit groups via the high nibble. Always verify against the calling code's documentation.