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 | ||||||
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:
-
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#0FFFto clear the sign nibble and keep all three millisecond digits intact. The original code usesW#16#FFF0because it pre-clears the low nibble to make the right shift safe without bleeding sign bits in. -
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 UUUUthatBCD_TO_INTcan 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.
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
SFC1resolution. TIA Portal V18 and later may auto-promote the block to version 2.0/2.1 with the symbolic access mode that renamesRET_VALtoRET_VALbut changes the input parameter type for some FBs. Verify by cross-compiling and checking the watch table for theDTs.msvalue. -
Watch tables in TIA Portal: the HMI variable picker exposes
DTs.msasWORD. 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
AToverlay is set to "optimised block access" (the default in TIA Portal), the absolute address ofDTNowis no longer fixed andLAR1 P##hdwill not work in inline STL. Use the SCLATform, 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:
- Force
DTNowto a known value such asDT#2024-01-15-14:23:07.456and confirmmsTimeshows 456. - 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. - Force the milliseconds word to
W#16#8123. The expected result is 123. If the result is negative, the shift is missing. - Force the milliseconds word to
W#16#0000. The expected result is 0, which is the normal clock-synchronised idle reading. - Force the milliseconds word to
W#16#1234(digit overflow). The expected result is 0 because BCD1234is invalid andBCD_TO_INTreturns 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
msas aWORD, but if a programmer mistakenly usesBYTEand readsDTs.msas the high byte, the result is the hundreds digit only (0-9). Use theWORDtype on the overlay to cover both bytes. -
Forgetting the sign nibble:
BCD_TO_INTreturns 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 4for word operations andSRD 4only for double word operations onDWORDvalues. -
BCD overflow: the millisecond field rolls over at 1000.
BCD_TO_INTdoes 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:
AToverlay must be applied to a variable whose address is contiguous and byte-aligned. LocalTEMPvariables 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.