Siemens S7 DATE_AND_TIME: Extract Milliseconds Integer in SCL/STL

David Krause14 min read
HMI ProgrammingSiemensTechnical Reference
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

The Siemens S7-300/400 DATE_AND_TIME (DT) data type stores the system clock as eight bytes of BCD-encoded data, retrieved at runtime through the system function block SFC1 "READ_CLK" and written back through SFC0 "SET_CLK". Although the data type is technically defined as eight bytes, the milliseconds component is not stored as a plain integer. The low word of byte 7 contains a 4-bit sign nibble followed by three BCD digits (hundreds, tens, ones) representing 0-999. A direct read therefore returns a value with the sign nibble still in place, which causes BCD_TO_INT to interpret the high nibble as a sign bit and produce negative results for any value greater than W#16#7999.

This reference covers the correct bit-level conversion of the milliseconds field into a usable 0-999 integer in both STL and SCL, the rationale for the W#16#FFF0 mask and 4-bit right shift, and the modern DTL alternative on S7-1200/1500 controllers. It also documents common error patterns such as missing the sign nibble, accidental reads of the reserved high byte, and the difference between WORD and INT operand width when the result crosses the 32767 boundary.

DATE_AND_TIME Byte Layout

The eight-byte structure follows the IEC 61131-3 DT definition. Bytes are stored in big-endian order, with the year byte at the lowest address. Each numeric field occupies one byte in BCD format except the milliseconds, which occupies a full 16-bit word to leave room for the sign nibble reserved by the standard.

Byte Bit 7 Bit 6 Bit 5 Bit 4 Bit 3 Bit 2 Bit 1 Bit 0 Field Range (BCD)
0 Year tens (80-99) Year ones (00-99) Year (1990-2089) 90-89
1 Month tens (00-01) Month ones (01-12) Month 01-12
2 Day tens (00-03) Day ones (01-31) Day 01-31
3 Hour tens (00-02) Hour ones (00-23) Hour 00-23
4 Minute tens (00-05) Minute ones (00-59) Minute 00-59
5 Second tens (00-05) Second ones (00-59) Second 00-59
6-7 Sign nibble (reserved) Hundreds of ms (0-9) Millisecond word (high byte) 0-9
7 (low byte) Tens of ms (0-9) Ones of ms (0-9) Millisecond word (low byte) 0-9
Bytes 6 and 7 share the milliseconds field as a single 16-bit word. Byte 6 holds the sign nibble and the hundreds digit; byte 7 holds the tens and ones. Reading the word with an AT overlay in SCL returns both bytes as a single WORD whose low 12 bits contain the BCD millisecond value.

BCD Encoding of the Milliseconds Field

The milliseconds word is structured as SSSS HHHH TTTT UUUU where S is a reserved sign nibble, H is the hundreds digit, T is the tens digit, and U is the ones digit. Because the sign nibble occupies the high four bits, the value present in DTs.ms is technically a 16-bit word whose top nibble is not part of the millisecond value. For a system time of 14:23:07.456 the milliseconds word reads W#16#0456; the leading 0 is the sign nibble.

Direct application of BCD_TO_INT to W#16#0456 would still yield 456 because the high nibble is 0. The problem appears when the BCD result is interpreted as a signed 16-bit integer. The function BCD_TO_INT checks bit 15; if it is set, the result is treated as a two's-complement negative number. Any time the sign nibble in the source word is non-zero, the output is negative. Because the sign nibble defaults to 0 on a healthy PLC, the bug is often intermittent and only surfaces on a CPU whose clock has been re-initialised with SFC0 "SET_CLK" using a non-zero sign indicator.

Why the Mask and Shift Are Required

Two operations restore the milliseconds to a clean 0-999 range:

  1. Mask with W#16#FFF0: clears the lowest nibble, which on a BCD source word is not part of the digit data. In practice the sign nibble is what pollutes the result, so a more selective approach is to mask with W#16#0FFF to clear the sign nibble and keep all three millisecond digits intact. The original code uses W#16#FFF0 because it pre-clears the low nibble to make the right shift safe without bleeding sign bits in.
  2. Shift right by 4: moves the hundreds digit from its BCD position (bits 11-8) into the tens position (bits 7-4), producing a 12-bit BCD number 0000 HHHH TTTT UUUU that BCD_TO_INT can decode correctly to a 0-999 integer.

Equivalently, swapping the mask for W#16#0FFF removes the sign nibble in one step and the right shift still realigns the digits. The two-step form using W#16#FFF0 plus SRW 4 is preserved in legacy code because some early firmware revisions on S7-300 CPUs (firmware V2.0 and earlier) did not handle a non-zero high nibble cleanly in BCD_TO_INT.

STL Implementation Using SFC1 READ_CLK

The classic STEP 7 implementation uses pointer arithmetic on the output of SFC1. The temporary DATE_AND_TIME variable #hd is passed by reference, and the address register is loaded with the pointer to that variable. The milliseconds word sits at offset 6 (byte 6) of the structure, so L W[AR1,P#6.0] reads the 16-bit millisecond word directly.

CALL "READ_CLK"         // SFC1
     RET_VAL := #ret1
     CDT     := #hd      // temporary DATE_AND_TIME, 8 bytes

     LAR1    P##hd       // load address of #hd into AR1
     L       W[AR1,P#6.0]  // load the milliseconds WORD
     T       MW 100      // optional: store raw word for diagnostics

     L       W#16#FFF0   // mask low nibble to clean digits
     AW                  // AND word with accumulator 1
     SRW     4           // shift right by 4 (word width, 16 bits)
     BTI                 // BCD to integer (signed 16-bit)
     T       MW 102      // "MILLI SECONDS" result, INT range 0-999

The BTI instruction (BCD to Integer, 16-bit) is functionally equivalent to BCD_TO_INT in SCL. Because the pre-shifted BCD value is at most 0999, the result always fits in the positive range of a 16-bit signed integer. BTD is not required unless the result is to be used as a 32-bit value downstream.

Use SRW (shift right word, 16-bit) and not SRD (shift right double word, 32-bit). A SRD 4 on the same accumulator will produce W#16#0xxx but the BCD digits land in bits 16-19 of the double word, which BTI will not interpret correctly. STL's strict distinction between word and double word operations is the most common source of off-by-four errors in this snippet.

SCL Implementation

In SCL, the same conversion is expressed with the AT view overlay against a DATE_AND_TIME variable. The overlay exposes the byte structure as a STRUCT, and the milliseconds field is read as a 16-bit WORD. The conversion to INT uses the same mask and shift, this time with the SHR function and the bitwise AND operator.

VAR
    DTNow      : DATE_AND_TIME;
    DTs AT DTNow : STRUCT
        year    : BYTE;
        month   : BYTE;
        day     : BYTE;
        hour    : BYTE;
        minutes : BYTE;
        seconds : BYTE;
        ms      : WORD;     // covers bytes 6 and 7
    END_STRUCT;
    msTime     : INT;       // 0..999
END_VAR

BEGIN
    // Read the system clock (CALL may be inlined as SCL statement)
    DTNow := READ_CLK(RET_VAL := retVal);

    // Two approaches, both valid:

    // Approach A: clear low nibble, shift right 4, convert BCD to INT
    msTime := BCD_TO_INT(SHR(IN := (DTs.ms AND W#16#FFF0), N := 4));

    // Approach B: clear sign nibble only, no shift needed
    // (cleaner, but mask must be W#16#0FFF to strip the sign, not the low digit)
    msTime := BCD_TO_INT(DTs.ms AND W#16#0FFF);
END_FUNCTION_BLOCK

Approach A is the form documented in the legacy FAQ; it produces a 12-bit BCD value at bits 0-11, which BCD_TO_INT reads directly. Approach B is shorter and avoids the shift, but it relies on the sign nibble being zero. In production code on a healthy S7-300/400 CPU the sign nibble is always 0, so Approach B is acceptable. For portable code that may run on CPUs whose SFC0 "SET_CLK" was used to set a non-zero sign, use Approach A.

Adding the Return Value Check

SFC1 returns a 16-bit status word in RET_VAL. For the system clock, the only documented non-zero return is W#16#0000 on success, but defensive code should still check the value to satisfy quality-of-implementation audits and to make watchdogs aware of any unexpected behaviour.

VAR
    retVal : INT;   // W#16#0000 expected on success
END_VAR
BEGIN
    DTNow := READ_CLK(RET_VAL := retVal);
    IF retVal <> 0 THEN
        // handle error: clock not yet synchronised, hardware fault, etc.
        msTime := -1;
        RETURN;
    END_IF;

    msTime := BCD_TO_INT(SHR(IN := (DTs.ms AND W#16#FFF0), N := 4));
END_FUNCTION_BLOCK

Writing the Milliseconds Field with SFC0 SET_CLK

The same bit layout applies in reverse when writing the clock. A typical use case is to inject a deterministic time stamp into a log record. The word-level edit must preserve the sign nibble as 0 and the low nibble as 0 to remain a valid BCD value before the system function hands the structure to the operating system.

VAR_TEMP
    hd : DATE_AND_TIME;
    msWord : WORD;
END_VAR
BEGIN
    // Build the milliseconds word from an integer 0..999
    msWord := SHL(IN := INT_TO_BCD(456), N := 4) AND W#16#FFF0;
    // msWord is now W#16#4560; sign nibble = 0, low nibble = 0

    // Build the rest of the structure, then call SFC0
    hd.year    := BCD#16#20;          // 2020 example
    hd.month   := BCD#16#10;          // October
    hd.day     := BCD#16#07;
    hd.hour    := BCD#16#14;
    hd.minutes := BCD#16#23;
    hd.seconds := BCD#16#07;
    hd.ms      := msWord;

    SET_CLK(PDT := hd);
END_FUNCTION_BLOCK

Note that INT_TO_BCD is the inverse of BCD_TO_INT and returns a WORD. A left shift by 4 moves the integer's BCD digits from the low 12 bits to bits 4-15, leaving a 0 in the low nibble. The AND W#16#FFF0 defensively re-zeros the low nibble in case the source integer was above 999, which would push a non-zero value into bits 16-19 of an extended BCD representation.

Step 7 Classic vs TIA Portal S7-300/400

The DATE_AND_TIME data type, SFC0 "SET_CLK", and SFC1 "READ_CLK" remain valid on S7-300/400 projects that are migrated to TIA Portal V13 and later. The AT overlay syntax is identical. The only TIA-specific caveats are:

  • Block version compatibility: when copying an FB from a STEP 7 V5.x source into TIA Portal, set the block version to 1.0 and the access mode to "standard" to keep SFC1 resolution. TIA Portal V18 and later may auto-promote the block to version 2.0/2.1 with the symbolic access mode that renames RET_VAL to RET_VAL but changes the input parameter type for some FBs. Verify by cross-compiling and checking the watch table for the DTs.ms value.
  • Watch tables in TIA Portal: the HMI variable picker exposes DTs.ms as WORD. The lower 4 bits will display as 0 after a fresh read. The HMI scalar will not show the sign nibble; for diagnostics, change the display format to hexadecimal.
  • Optimised blocks: if the FB containing the AT overlay is set to "optimised block access" (the default in TIA Portal), the absolute address of DTNow is no longer fixed and LAR1 P##hd will not work in inline STL. Use the SCL AT form, or mark the block as non-optimised for STL pointer arithmetic.

Modern S7-1200/1500 Alternative: DTL

The S7-1200/1500 generation replaced DATE_AND_TIME with the DTL data type, which stores year, month, day, hour, minute, second, and nanoseconds in a 12-byte structure. The millisecond field is implicit inside the nanoseconds component (a 32-bit unsigned integer with 0-999999999). A simple READ_DTL from the IEC_TIMER or a direct read via RD_SYS_T returns a populated DTL value whose MILLISECOND property is already an integer.

VAR
    sysTime : DTL;
    ms      : INT;        // 0..999, already an integer
END_VAR
BEGIN
    RD_SYS_T(OUT => sysTime);   // SFC 1 on S7-1500 (different name)
    ms := sysTime.MILLISECOND;  // direct integer access, no BCD conversion
END_FUNCTION_BLOCK

For S7-1200/1500 programs being ported to S7-300/400 hardware, a wrapper that reads DATE_AND_TIME and produces a DTL is a common pattern. The inverse direction is rarer; the typical migration is 300/400 to 1200/1500, not the other way.

Verification

After loading the code, perform the following checks in the watch table or online monitor:

  1. Force DTNow to a known value such as DT#2024-01-15-14:23:07.456 and confirm msTime shows 456.
  2. Force the milliseconds word to W#16#7999 (largest positive BCD with non-zero sign nibble). The expected result is 999 because the sign nibble is still 7 but the AND mask clears bit 15 in Approach A. If the result is negative, the mask is wrong.
  3. Force the milliseconds word to W#16#8123. The expected result is 123. If the result is negative, the shift is missing.
  4. Force the milliseconds word to W#16#0000. The expected result is 0, which is the normal clock-synchronised idle reading.
  5. Force the milliseconds word to W#16#1234 (digit overflow). The expected result is 0 because BCD 1234 is invalid and BCD_TO_INT returns 0 on invalid BCD.
Test ms Word Pre-shift (masked) Post-shift Expected msTime Pass Criterion
W#16#0000 W#16#0000 W#16#0000 0 Zero on idle
W#16#0456 W#16#0450 W#16#0045 456 (after BCD decode) Matches wall clock
W#16#7999 W#16#7990 W#16#0799 799 High sign nibble tolerated
W#16#8123 W#16#8120 W#16#0812 123 (BCD 0x812 invalid, returns 0) BCD invalid handling

Common Pitfalls

  • Reading the high byte only: the original FAQ overlay exposes ms as a WORD, but if a programmer mistakenly uses BYTE and reads DTs.ms as the high byte, the result is the hundreds digit only (0-9). Use the WORD type on the overlay to cover both bytes.
  • Forgetting the sign nibble: BCD_TO_INT returns a signed 16-bit integer. A non-zero sign nibble in the high four bits will be interpreted as a negative BCD value and the result will be negative. The mask prevents this.
  • SRD vs SRW: a 32-bit shift on a 16-bit word is a no-op that produces the original value. A 16-bit shift on a 32-bit accumulator will leave the high word intact. Use SRW 4 for word operations and SRD 4 only for double word operations on DWORD values.
  • BCD overflow: the millisecond field rolls over at 1000. BCD_TO_INT does not perform a modulo-1000 wrap; a value of 1000 BCD is invalid and returns 0. The wall clock driver never produces 1000, but a corrupted or manually edited value can.
  • Endianness on HMI panels: when displaying the raw word on a WinCC or HMI panel, the value may appear reversed. Set the display to hexadecimal and verify that bit 15 is the sign nibble and bit 0 is the ones-of-milliseconds digit.
  • Local vs instance data: AT overlay must be applied to a variable whose address is contiguous and byte-aligned. Local TEMP variables in optimised FBs are not necessarily byte-aligned; use static instance variables for overlays that are read by pointer arithmetic.

Diagnostic Step: Cross-Check with Time-of-Day INT

For runtime diagnostics, build a single 32-bit millisecond counter from the wall clock and compare to the result of the BCD conversion. This validates the BCD path end-to-end.

VAR
    now   : DATE_AND_TIME;
    DTs AT now : STRUCT
        year    : BYTE;
        month   : BYTE;
        day     : BYTE;
        hour    : BYTE;
        minutes : BYTE;
        seconds : BYTE;
        ms      : WORD;
    END_STRUCT;
    msTime : INT;     // BCD path result
    msCalc : INT;     // TOD path result (alternative check)
    tod    : INT;     // Time of day as INT (seconds * 1000 + ms from SFC1)
END_VAR
BEGIN
    now := READ_CLK(RET_VAL := retVal);
    msTime := BCD_TO_INT(SHR(IN := (DTs.ms AND W#16#FFF0), N := 4));
    msCalc := tod MOD 1000;   // low 3 decimal digits of the millisecond counter
END_FUNCTION_BLOCK

If msTime and msCalc differ by exactly a factor of 10 (e.g., 45 vs 450), the shift is missing one position. If they differ by a factor of 16, the BCD-to-int conversion is being applied before the AND mask. If msTime is negative and msCalc is positive, the sign nibble is reaching the BCD conversion.

Standards and Documentation References

The 8-byte DATE_AND_TIME layout is defined by IEC 61131-3. The Siemens-specific implementation is documented in the S7-300/400 System and Standard Functions reference manual. The DTL type on S7-1200/1500 is documented in the S7-1500 System Manual. The SCL syntax for the AT overlay is covered in the S7-300/400 SCL Programming Manual and the TIA Portal SCL help system.

FAQ

Why does BCD_TO_INT return a negative value for milliseconds?

The sign nibble in the high four bits of the millisecond word is non-zero. BCD_TO_INT checks bit 15 and treats the value as a signed BCD number. Apply the mask W#16#0FFF (or W#16#FFF0 followed by a 4-bit right shift) before the conversion to clear the sign nibble.

Do I need to shift the millisecond word right by 4 bits before BCD_TO_INT?

Yes if you used W#16#FFF0 as the mask. The mask clears the low nibble but leaves the digits in their original BCD positions (bits 15-4). A 4-bit right shift realigns the three BCD digits to bits 11-0 so that BCD_TO_INT produces the correct 0-999 integer. If you instead use W#16#0FFF as the mask, the digits are already in bits 11-0 and no shift is needed.

What is the difference between BTI and BCD_TO_INT in S7-300/400?

None functionally. BTI is the STL instruction, BCD_TO_INT is the SCL function. Both convert a 16-bit BCD value in the low 16 bits of the accumulator to a 16-bit signed integer. Use BTD or DWORD_BCD_TO_INT for 32-bit BCD values.

Can I use DATE_AND_TIME on S7-1200/1500 controllers?

The legacy DATE_AND_TIME type, SFC0 "SET_CLK", and SFC1 "READ_CLK" are still available on S7-1500 for compatibility, but the preferred type is DTL with the RD_SYS_T system block. The DTL.MILLISECOND property returns a 0-999 integer directly, with no BCD conversion required.

How do I check the milliseconds field in a watch table?

Open the watch table, add the DATE_AND_TIME variable, and expand the structure view. The ms field shows as a WORD. Switch the display format to hexadecimal to see the raw BCD digits, or to binary to see the sign nibble at bit 15. To monitor the integer result, add the msTime INT variable and switch to decimal format.

Back to blog