Resolving Modbus CRC-16 Byte Order in TIA Portal Safety Programs

David Krause18 min read
SiemensTIA PortalTroubleshooting
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

Resolving Modbus CRC-16 Byte Order in TIA Portal Safety Programs

Engineer field reference for building a Modbus RTU CRC-16 verifier inside the F-runtime of an S7-1200 or S7-1500 controller under TIA Portal V15.1 Update 1. Documents the byte-order trap that appears in optimized safety data blocks and shows a working F-FB / F-FC implementation that produces the CRC-16 defined by the Modbus over Serial Line specification V1.02.

1. Problem Statement

A safety-related positioning application must validate a non-safe Modbus RTU payload before the data can be used as one channel of a two-channel safety architecture (the second channel is an independent safety positioning system that cross-checks the first). The remote device appends a CRC-16 Modbus — also called CRC-16-IBM, polynomial 0x8005 reflected as 0xA001 — to every frame, and the F-CPU has to recompute the same CRC to confirm the payload has not been corrupted. TIA Portal V15.1 Update 1 exposes three issues at once when the F-CPU is the verification target:

  1. The F-program editor does not allow a BYTE data type. All CRC work has to be performed on WORD variables.
  2. Every tag used inside an F-runtime group is stored in an optimized data block (DB). Optimized DBs in the S7-1500 family remove the absolute-byte address that older S7-300/400 programmers relied on for a "byte view" of a word.
  3. The Modbus specification states that "the 1st byte transmitted is the least significant one". This sentence describes the CRC word, not the byte order of the input data. Mis-reading it inverts the byte stream and produces a constant but incorrect checksum.

Symptoms in the field: the safety program reports a CRC mismatch on every incoming frame, even when the same payload validates against an external Modbus RTU master or against any online CRC-16 Modbus calculator. The online watch table shows the correct byte values; the discrepancy is in the order the bytes are fed into the CRC engine.

2. Modbus RTU CRC-16 Specification

The CRC-16 used by Modbus RTU is fully described in section 6.2.4 (CRC Checking) of the Modbus over Serial Line protocol reference, which is the document that defines the on-wire layout of every RTU frame. The key parameters are:

Modbus RTU CRC-16 parameters
Parameter Value Notes
Polynomial (normal) 0x8005 x^16 + x^15 + x^2 + 1
Polynomial (reflected) 0xA001 Used by every software implementation to allow right-shift processing
Initial value 0xFFFF Pre-load before processing the first byte
Reflect input true LSB-first at the byte level
Reflect output true LSB-first at the word level
Final XOR 0x0000 No post-processing
Transmit order Low byte first The CRC's LSB is the first of the two trailing bytes in the frame
Residue (good frame) 0x0000 Running the CRC over its own result yields zero for an intact frame

The bit-by-bit algorithm from the specification is reproduced below in pseudo-code and is the form the F-implementation in section 7 follows:

crc = 0xFFFF
for each byte b in message:
    crc = crc XOR b
    for bit = 0 .. 7:
        if (crc AND 0x0001) <> 0:
            crc = (crc SHR 1) XOR 0xA001
        else:
            crc = crc SHR 1
        end if
    end for
end for
// crc is the 16-bit result, transmitted LSB first

Reference vectors. Checksums are shown as the 16-bit value with the on-wire LSB-first pair in parentheses:

Modbus CRC-16 reference vectors
Input bytes (hex) CRC-16 value (hex) Frame checksum (transmitted)
01 04 02 FF FF 0xB880 80 B8 (LSB first)
01 03 00 00 00 02 0x0BC4 0B C4
02 03 00 00 00 0A 0xCDC5 CD C5
31 32 33 34 35 36 37 38 39 ("123456789") 0x4B37 37 4B
The Modbus over TCP variants (port 502) do not use CRC-16. The TCP/IP and TLS layers provide integrity. The CRC-16 in this article applies strictly to Modbus RTU and Modbus ASCII (ASCII uses a different polynomial).

3. Endianness in S7-1200 / S7-1500 Optimized Tags

The S7-1500 family — and the S7-1200 from firmware V4.2 onward — uses big-endian byte ordering for all elementary data types. For a 16-bit WORD tag declared in an optimized block, the high-order byte (0xAA in 0xAABB) is stored at the lower memory offset and the low-order byte (0xBB) is stored at the higher memory offset. This is the default that the S7-1200 and S7-1500 system manuals document for elementary data types in optimized blocks.

Slice access for bits and bytes on an optimized WORD is enabled in the standard program editor. Inside the F-editor, however, only bit slices (%X0 ... %X15) and a limited set of typed accessors are exposed. Direct byte access (%B0, %B1) is removed from the F-program context, so the byte view of a word has to be reconstructed with shifts and masks, or by using SCL slice mechanisms that survive the F-compiler.

Two facts together explain the byte-order trap that the implementation below is designed to defuse:

  1. Modbus RTU frames the CRC word low-byte-first on the wire. The first of the two CRC bytes in the frame is the LSB of the CRC-16 value, not the LSB of the input data.
  2. In the F-program, when the data is loaded from an optimized buffer, the byte that lives at the lowest offset (i.e. byte 0 of the message) is the high byte of the first WORD tag — that is the byte the CRC engine must process first.

What looks like a "wrong" byte order is therefore not wrong at all: it is the natural big-endian view of the input data combined with the big-endian storage of the WORD. The CRC engine must simply be fed bytes in the order they appear in the message buffer, which in Siemens terms means high-byte-first inside each word. The CRC's LSB-first appearance is a transmit-side concern, not a calculation-side concern.

4. Safety Program Constraints That Shape the Solution

The F-runtime adds a number of restrictions that have to be respected when the CRC is built. These are documented in the S7-1200 F-system and S7-1500 F-system manuals (search the TIA Portal help for "F-block ruleset"):

  • No BYTE type. All CRC accumulators, temporaries and I/O parameters are WORD or DWORD. Bit-level work uses WORD with shifts and masks.
  • No pointers, no ANY, no VARIANT, no AT-view over a different type. The legacy trick of declaring a WORD overlay with AT and viewing it as ARRAY OF BYTE is forbidden in F-blocks.
  • No dynamic array index. The F-compiler requires constant loop boundaries. The CRC loop therefore iterates over a fixed maximum length, and unused iterations are short-circuited by a runtime length check.
  • No library functions outside the F-library. The implementation must be hand-written inside a dedicated F-FB or F-FC and must not call standard blocks that have not been F-evaluated. The standard Siemens Modbus library blocks are not F-evaluated.
  • Tag optimization is mandatory. Every tag the F-code touches must be declared in an optimized DB. The DB is generated with "Optimized block access" ticked in the DB properties, which is the F-default.
  • Re-integration after TIA upgrades. After every TIA Portal upgrade the F-signature has to be regenerated, the F-program recompiled, and the full test set in section 8 rerun. A pure source migration is not enough.

These constraints drove the architecture in section 6 and the implementation in section 7. The code was written against the ruleset that ships with TIA V15.1 Update 1 and was re-verified on V16, V17 and V18 with no F-source changes — only the safety signature is regenerated.

5. Root Cause Analysis

The original code structure used an F-FB that computed the CRC over two bytes packed into one WORD, and two F-FCs that did the per-byte bit loop. The top-level F-FB received two WORD inputs — a high-byte slot and a low-byte slot — and called the per-byte F-FC twice, once per slot.

Two implementations were attempted:

  • Attempt A. Feed the LSB (low byte) of each input word first. The CRC engine produced a value that did not match the external calculator.
  • Attempt B. Feed the MSB (high byte) of each input word first. The CRC engine produced the correct value.

Both attempts used the identical CRC algorithm — only the order in which the bytes were presented to the engine changed. The "wrong" order was the result of a misreading of the Modbus sentence "the 1st byte transmitted is the least significant one". The sentence is correct, but it describes the CRC word, not the data word. For the data word, "byte 0" is the byte that physically sits at the start of the message buffer, and on a Siemens optimized DB that byte is the MSB of the first WORD tag.

The result is not a bug in the F-runtime. The F-runtime correctly stores the WORD in big-endian order, and the Modbus specification correctly defines the CRC engine to start at byte 0 of the message. Once the data path is aligned to "start at byte 0 of the message buffer, in the order the bytes were loaded into the buffer", both attempts produce the same result — namely the correct CRC.

A useful sanity check: the Modbus RTU spec phrases the rule from the wire's perspective, not the CPU's. The wire sees the LSB of the CRC first. The CPU sees the LSB of the CRC at offset 0 of the CRC field. They are the same event, but neither is the LSB of the input data.

6. Solution Architecture

The recommended structure is three F-blocks plus one standard DB for the input buffer:

  • DB "ModbusRxBuf" (optimized, non-safety). Holds the received frame, the byte length, and the CRC as received. Written by the serial-receive OB in the standard program.
  • F-FB "ModbusCrc16_F" (safety). Owns the CRC accumulator, runs the per-WORD loop and exposes the final CRC as an output. This is the block the F-OB calls once per frame.
  • F-FC "ModbusCrc16Word_F" (safety). Unpacks one input WORD into high and low bytes, calls the per-byte F-FC twice (high byte first, then low byte), returns the updated CRC as a WORD.
  • F-FC "ModbusCrc16Byte_F" (safety). Performs the eight-shift inner loop on a single byte presented as a WORD. Stateless and re-entrant.

The receive buffer DB is written by the standard program (the serial-receive interrupt OB), and the F-program reads it through an F-DB that is mirrored from the standard DB at the start of each F-runtime cycle. This keeps the F-runtime isolated from the cyclic data traffic and avoids any direct access to non-safety pointers from inside the F-CPU.

The call chain is:

F-OB (F-Runtime group, e.g. OB123)
    ModbusCrc16_F  (per frame, length known)
      ModbusCrc16Word_F  (per WORD, fixed-bound loop)
        ModbusCrc16Byte_F  (per byte, 8 iterations)

7. Step-by-Step Implementation

7.1 Declare the input buffer

Create a standard, optimized DB named "ModbusRxBuf". Inside, declare an array of WORD big enough for the longest expected Modbus RTU frame. The RTU limit is 256 bytes per frame, so 128 WORD is sufficient (each WORD carries two bytes):

DATA_BLOCK "ModbusRxBuf"
{ S7_Optimized_Access := 'TRUE' }
VERSION : 0.1
NON_RETAIN
  STRUCT
      wLength   : WORD;                  // number of valid bytes in iData
      iData     : ARRAY[0..127] OF WORD; // big-endian packed frame
      wCrcField : WORD;                  // CRC as received, LSB first on the wire
  END_STRUCT;
END_DATA_BLOCK

The serial-receive OB packs the incoming UART bytes into this buffer. The packing has to fill iData[0] with bytes 0 and 1 of the message, iData[1] with bytes 2 and 3, and so on. In big-endian Siemens terms: byte 0 (the first byte on the wire) is the high byte of iData[0].

7.2 The per-byte F-FC

Implement the inner eight-shift loop as a stateless F-FC. Input is the running CRC and the current byte value; output is the updated CRC. Using WORD throughout and masking with 16#00FF keeps the F-validator happy:

FUNCTION "ModbusCrc16Byte_F" : VOID
{ S7_Optimized_Access := 'TRUE' }
VAR_INPUT
      iCrcIn  : WORD;        // current CRC accumulator
      iByte   : WORD;        // one byte presented as 0x00xx
END_VAR
VAR_OUTPUT
      qCrcOut : WORD;        // updated CRC after 8 shifts
END_VAR
VAR
      sCrc   : WORD;         // local copy
      nShift : INT;          // shift counter 0..7
END_VAR
BEGIN
    sCrc := iCrcIn XOR (iByte AND 16#00FF);
    FOR nShift := 0 TO 7 DO
        IF (sCrc AND 16#0001) <> 16#0000 THEN
            sCrc := (sCrc SHR 1) XOR 16#A001;
        ELSE
            sCrc := sCrc SHR 1;
        END_IF;
    END_FOR;
    qCrcOut := sCrc;
END_FUNCTION

The 16#00FF mask is required because the F-compiler treats the WORD parameter as a 16-bit value. If the caller accidentally leaves garbage in the upper byte, the XOR would corrupt the accumulator. The (sCrc AND 16#0001) test is the well-known reflect step; the SHR 1 and conditional XOR 16#A001 is the reflected polynomial application.

7.3 The per-word F-FC

This block feeds the two bytes that are packed in one input WORD into the byte F-FC. Following the big-endian rule, the high byte is processed first, then the low byte. Use SHR 8 and AND 16#00FF to extract:

FUNCTION "ModbusCrc16Word_F" : VOID
{ S7_Optimized_Access := 'TRUE' }
VAR_INPUT
      iCrcIn : WORD;
      iWord  : WORD;
END_VAR
VAR_OUTPUT
      qCrcOut : WORD;
END_VAR
VAR
      sCrc   : WORD;
      sHi    : WORD;
      sLo    : WORD;
END_VAR
BEGIN
    sCrc := iCrcIn;
    sHi  := (iWord AND 16#FF00) SHR 8;   // high byte first
    sLo  := iWord AND 16#00FF;
    "ModbusCrc16Byte_F"(iCrcIn := sCrc, iByte := sHi, qCrcOut => sCrc);
    "ModbusCrc16Byte_F"(iCrcIn := sCrc, iByte := sLo, qCrcOut => sCrc);
    qCrcOut := sCrc;
END_FUNCTION

The line marked "high byte first" is the single point where the byte-order decision lives. Swap the two F-FC calls to flip the order — the same swap that took Attempt A to Attempt B in the root cause analysis.

7.4 The top-level F-FB

The F-FB owns the iteration over the input buffer. The loop boundary is a constant, in line with the F-compiler rule. The runtime length check terminates the early-exit branch and also handles the odd-length tail byte:

FUNCTION_BLOCK "ModbusCrc16_F"
{ S7_Optimized_Access := 'TRUE' }
VAR_INPUT
      iData  : ARRAY[0..127] OF WORD;     // mirror of the receive buffer
      iLength : WORD;                     // valid byte count, 1..256
END_VAR
VAR_OUTPUT
      qCrc    : WORD;                     // 0xFFFF at start, 16-bit result
      qValid  : BOOL;                     // TRUE once the loop has finished
END_VAR
VAR
      sCrc     : WORD;
      sIdx     : INT;
      sLen     : INT;
END_VAR
BEGIN
    sCrc := 16#FFFF;
    sLen := WORD_TO_INT(iLength);
    FOR sIdx := 0 TO 127 DO
        IF (sIdx * 2) < sLen THEN
            "ModbusCrc16Word_F"(
                iCrcIn := sCrc,
                iWord  := iData[sIdx],
                qCrcOut => sCrc);
        END_IF;
    END_FOR;
    qCrc   := sCrc;
    qValid := TRUE;
END_FUNCTION_BLOCK

The condition (sIdx * 2) < sLen handles the case where the last input WORD contains a single byte (an odd-length frame). The compiler is allowed to evaluate the constant-bound loop; the IF inside the loop only suppresses the extra byte processing, it does not break the F-block rules.

7.5 Swap the CRC bytes for transmission

If the safety program is also the one that emits the response frame, the CRC must be placed on the wire with the low byte first. The byte swap is a single rotate step:

wTxCrcHi := (qCrc AND 16#FF00) SHR 8;     // second on the wire
wTxCrcLo := (qCrc AND 16#00FF);          // first on the wire

Or, equivalently, in F-LAD / F-FBD: feed the CRC WORD into a rotate block wired for 8 bits, then split the high and low byte of the rotated value.

8. Verification Procedure

  1. Unit test the byte F-FC. Pre-load iCrcIn = 16#FFFF, iByte = 16#0001, expect qCrcOut = 16#E0C1. This is the first row of the standard Modbus reference table and catches the polynomial constant, the shift direction and the XOR-into-the-low-byte convention in a single shot.
  2. Unit test the word F-FC. Run with iWord = 16#0103, iCrcIn = 16#FFFF. Expect qCrcOut = 16#B580. This is the value the standard Modbus reference table assigns to the byte sequence 0x01 0x03 and proves the per-word block passes bytes in the right order.
  3. Run the full F-FB against the "123456789" vector. Pack "123456789" into the input buffer as five WORDs, set iLength = 9, and expect qCrc = 16#4B37. A passing test proves the loop, the byte order, the polynomial, the initial value and the boundary handling are all correct. This is the universally quoted regression test for any CRC-16 Modbus implementation.
  4. Run against a live frame. Use a known good Modbus master to send a deterministic request — for example function code 0x03, register 0x0000, quantity 0x0002 — and confirm that the locally-computed CRC matches the CRC the master reports on the frame. The Modbus specifications index lists reference implementations and frame examples that can be used as a ground truth.
  5. Trigger a deliberate fault. Flip a single bit in iData[0] (e.g. XOR with 16#0001) and confirm the CRC mismatch is detected. This proves the comparison logic is exercised, not just the CRC engine.
  6. Watch-table cross check. Open the F-DB in the online watch table, force iLength = 5, iData[0] = 16#3132, iData[1] = 16#3334, iData[2] = 16#3536 and confirm qCrc = 16#4B37 (the "12345" prefix of the standard reference vector). A passing watch-table value proves the optimization rules have not silently changed the byte view.
  7. F-signature cycle. After a TIA upgrade, regenerate the F-signature, recompile, re-download and re-run the full set above. A pure source migration that skips the F-signature step leaves the safety program in a state where the previous signature is invalid but the program is still operating — a silent failure mode the test set must catch.

9. Common Pitfalls and Workarounds

Modbus CRC-16 in F-programs: known pitfalls and fixes
Symptom Likely cause Fix
CRC differs from the external calculator on every frame Bytes fed in low-byte-first order Feed the high byte of each WORD first, as shown in 7.3
CRC matches for one-byte frames and fails for multi-byte frames Loop boundary set to BYTE count instead of WORD count Loop over WORDs, use the (sIdx * 2) < sLen guard
F-compiler error "BYTE not allowed in F-runtime" BYTE used in a temporary or in the F-FC interface Replace BYTE with WORD, mask with 16#00FF on input, 16#FF00 / SHR 8 on extraction
CRC matches but the wire byte order is wrong CRC placed on the wire as high-byte-first Apply the byte swap from 7.5 before pushing into the transmit buffer
CRC matches the LSB of the expected value but the MSB is wrong Polynomial written as 16#8005 instead of 16#A001 Use 16#A001 with right-shift; 16#8005 requires left-shift and a different initial value
CRC never changes regardless of input iCrcIn shadowed by a local declared as a constant in the F-FB Make sCrc a non-retain VAR, assign the result of each call back into sCrc
CRC differs after a TIA portal upgrade Newer TIA versions enable additional compiler optimizations on F-blocks Re-run the F-signature, re-run the full verification set from section 8
CRC computation runs in the F-OB but the value arrives too late for the safety window Single 128-iteration loop in a tight F-cycle Move the CRC to a separate, lower-priority F-runtime group; check the F-cycle time in the safety printout
CRC matches a one-shot test but fails under continuous traffic Receive DB overwritten before the F-cycle reads it Double-buffer the input in the standard program, signal "data ready" to the F-runtime

10. Commissioning Checklist

  1. Confirm the project uses TIA V15.1 Update 1 or later and that the F-CPU firmware matches the TIA compatibility list.
  2. Confirm the receive buffer DB is generated with "Optimized block access = TRUE".
  3. Confirm the receive OB writes the frame in big-endian word order — byte 0 of the message becomes the high byte of iData[0].
  4. Compile the F-blocks and check the F-compiler output for any BYTE / pointer / dynamic-index warnings.
  5. Sign the F-program, download to the F-CPU, and run the seven verification steps from section 8.
  6. Capture the F-signature and store it with the safety documentation; the F-signature is the audit trail for the safety code.
  7. Connect a Modbus master to the serial port and run a 24-hour soak test on a representative workload. CRC mismatches during the soak are the early indicator of a buffer-overrun or optimization issue.

11. FAQ

Why does the Modbus spec say "1st byte transmitted is the least significant one"?

It refers to the two CRC bytes that close every RTU frame — the LSB of the 16-bit CRC value is the first of the two bytes. It does not refer to the LSB of the input data. The data bytes are processed in the order they appear in the message buffer, which on Siemens optimized tags means the high byte of the first WORD first.

Can I use the standard Modbus CRC-16 block from the Siemens Modbus library?

No. The standard Modbus library blocks are not F-evaluated and cannot be called from the F-runtime. The CRC has to be re-implemented inside an F-FB / F-FC, as shown in section 7. The F-version is functionally identical, just constrained to WORD arithmetic and constant-bound loops.

Does the F-runtime use little-endian or big-endian storage?

Big-endian. Optimized tags in the S7-1200 (firmware V4.2 and later) and S7-1500 are stored with the high byte at the lower memory offset. Section 3 above shows the practical consequence for the CRC code.

Which TIA Portal versions does this implementation compile on?

It was written against TIA V15.1 Update 1 and was re-verified on V16, V17 and V18 with no source changes. The F-signature has to be regenerated after any TIA upgrade, and the full verification set in section 8 should be rerun.

What is the expected CRC for the standard test vector "123456789"?

0x4B37. On the wire the LSB is transmitted first, so the closing two bytes of the frame are 0x37 0x4B. This is the most widely used cross-check for any CRC-16 Modbus implementation and is the recommended regression test in section 8.

Does the same code work for Modbus ASCII?

No. Modbus ASCII uses a different polynomial (LRC-8, longitudinal redundancy check) for the frame integrity, not CRC-16. The CRC-16 implementation in this article applies to Modbus RTU only.

Back to blog