Resolving Modbus RTU Negative Integer Read Errors in VB.NET

Daniel Price10 min read
ModbusOther ManufacturerTroubleshooting
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

Problem Overview

Reading negative values from a Modbus RTU slave is a recurring failure mode in PC-based HMI/SCADA applications, custom .NET drivers, and protocol bridges. The symptom is consistent: positive values display correctly, but as soon as the process variable crosses zero into the negative range, the application returns 0, 32768, -32768, 2147483647, or a saturated limit. The root cause is rarely the Modbus transport - the CRC, framing, and Function Codes (FC03 holding registers, FC04 input registers) are correct. The defect sits in the signed-integer interpretation layer that converts the raw response payload (bytes) into a usable numeric value.

This reference documents the canonical encoding rules for signed 16-bit (INT16) and signed 32-bit (INT32) Modbus registers, the byte-order variants that produce silently corrupted results, and a corrected VB.NET conversion stack using System.BitConverter. Procedures are verified against AdvancedHMI, AutomationDirect Click PLCs, and CANopen-to-Modbus gateways (for example, Lenze i550/i950 inverters exposing _n_act at Modbus register 7696 / CANopen 0x606C sub 0).

Modbus RTU Signed-Integer Encoding

Modbus does not define a numeric data type. Per the Modbus Organization specification (Modbus Application Protocol V1.1b3), every register is an unsigned 16-bit value transported in big-endian order on the wire. Signed interpretation is a master-side responsibility governed by the device profile.

Two's Complement Rules

Signed values are encoded as two's complement. The width of the value dictates how many Modbus registers must be read contiguously:

Type Width Registers Negative Example Hex Encoding
INT16 16-bit 1 -1 0xFFFF
INT16 16-bit 1 -32768 0x8000
INT32 32-bit 2 -1 0xFFFFFFFF
INT32 32-bit 2 -1000 0xFFFFFC18
INT64 64-bit 4 -1 0xFFFFFFFFFFFFFFFF

A master that reads two bytes containing 0xFF 0xFF and interprets them as CInt(65535) in VB.NET instead of -1 is mathematically correct only for unsigned types. For signed types, the same two bytes must be sign-extended. The failure pattern is reproducible: a register that reads -100 at the slave terminal shows 65436 (65536 - 100) on the master side, or 0 when the integer overflows into the next variable's buffer slot.

Critical: VB.NET's CInt is a signed 32-bit conversion with range -2,147,483,648 to 2,147,483,647. Performing bit-shift accumulation directly into a 32-bit signed accumulator using CInt(... * 2^24) silently overflows whenever the high byte is set, which is exactly what happens for every negative number with magnitude greater than 127. Use CLng for the accumulator and sign-correct at the end.

Byte-Order Conventions (Endianness)

Modbus-on-the-wire is always big-endian. However, when a multi-register value such as INT32 is assembled, the device may transmit its bytes in either big-endian (ABCD) or little-endian (CDAB / DCBA) order, depending on the slave firmware. Common conventions are summarized below.

Order Wire Sequence (low to high register) Typical Source
Big-Endian (BE) Reg_hi, Reg_lo (ABCD) Schneider Modicon, generic spec
Byte-Swapped Word (BSW) Reg_lo, Reg_hi within each word (BADC) Some Allen-Bradley MicroLogix mappings
Little-Endian Word (LEW) Reg_lo first, then Reg_hi (CDAB) Many CANopen-to-Modbus gateways
Full Reverse Byte 3, 2, 1, 0 (DCBA) Legacy Wago 750 Series, some Beckhoff EL terminals

For the Lenze _n_act value at Modbus 7696, the slave returns the two registers in CDAB order, meaning the master must reorder bytes 1, 0, 3, 2 before invoking BitConverter.ToInt32, which expects little-endian. This byte-swap is the single most common source of the "all zeros" symptom reported in VB.NET HMI projects.

Root Cause Analysis: Where Signed Values Break

The original AdvancedHMI conversion routine contained three defects that combined to fail every negative number:

  1. The byte-shift accumulator was declared as Integer (CInt). Multiplying a byte value by 2^24 and storing the result in CInt wraps on overflow, producing a positive number that loses the high-order sign bit.
  2. The fallback path for floating-point (F4) and long-integer (L4) addresses used BitConverter.ToSingle and BitConverter.ToInt32 directly without applying the required byte swap, so all CDAB slaves returned 0.
  3. The dispatch logic tested IndexOf("F4") twice and never matched the L4 branch correctly, so a long-integer address silently fell through to the unsigned Result accumulator.

Together these defects produce a stable symptom: every signed value reads as 0, regardless of polarity, even though the raw byte buffer contains the correct payload. Confirmed payloads in the field include {255, 255, 255, 255} (which is the two's-complement encoding of INT32 = -1) and {0, 0, 0, 128} (INT32 = -2147483648).

Corrected VB.NET Conversion Patterns

The replacement code below resolves each defect. It separates the unsigned byte-shift accumulator from the sign-correction step, dispatches correctly between INT, FLOAT, and LONG paths, and applies the byte-swap needed for CDAB-order slaves.

Integer (INT16 / INT32) with Sign Correction

' rawData is a List(Of Byte) holding the Modbus response payload.
' startByte is the byte offset into rawData where the value begins.
' BytesPerElement is 2 for INT16, 4 for INT32.
Dim Result As Long = 0
For i As Integer = 0 To BytesPerElement - 1
    Result += CLng(rawData(startByte + ResultingValuesIndex * BytesPerElement + i)) * _
              (2L ^ ((BytesPerElement - 1 - i) * 8))
Next
' Sign-extend a two's-complement value into a signed Int32.
If BytesPerElement = 4 AndAlso Result >= 2147483648L Then
    Result = Result - 4294967296L
ElseIf BytesPerElement = 2 AndAlso Result >= 32768 Then
    Result = Result - 65536
End If
ResultingValues(ResultingValuesIndex) = CStr(Result)

Long Integer (L4 / INT32) via BitConverter with Byte Swap

Dim FloatBytes(3) As Byte
For i As Integer = 0 To 3
    FloatBytes(i) = rawData(startByte + ResultingValuesIndex * BytesPerElement + i)
Next
' Swap word order: CDAB -> ABCD so BitConverter sees little-endian.
SwapBytes(FloatBytes, 0)
SwapBytes(FloatBytes, 2)

If address.Address.IndexOf("F4", 0, StringComparison.InvariantCultureIgnoreCase) >= 0 Then
    ResultingValues(ResultingValuesIndex) = CStr(BitConverter.ToSingle(FloatBytes, 0))
ElseIf address.Address.IndexOf("L4", 0, StringComparison.InvariantCultureIgnoreCase) >= 0 Then
    ResultingValues(ResultingValuesIndex) = CStr(BitConverter.ToInt32(FloatBytes, 0))
End If

Private Sub SwapBytes(ByRef b() As Byte, ByVal i As Integer)
    Dim t As Byte = b(i)
    b(i) = b(i + 1)
    b(i + 1) = t
End Sub

Floating Point (F4 / REAL) via BitConverter

IEEE-754 single-precision conversion uses the same byte-swap as INT32 because BitConverter.ToSingle is little-endian on x86/x64 .NET Framework. For big-endian (Modicon) slaves, omit the SwapBytes calls.

SwapBytes(FloatBytes, 0)
SwapBytes(FloatBytes, 2)
ResultingValues(ResultingValuesIndex) = CStr(BitConverter.ToSingle(FloatBytes, 0))
BitConverter platform note: BitConverter.IsLittleEndian returns true on x86/x64 .NET Framework 4.x. The documented contract is in the Microsoft .NET BitConverter reference. Always target little-endian on PC platforms and apply the word-swap if the slave is CDAB.

Step-by-Step Implementation Procedure

  1. Identify the slave endianness. Read a known register and capture the raw byte buffer with a tool such as modpoll, the Modbus Poll diagnostic, or Wireshark with the modbus dissector. Compare the byte order to the vendor documentation.
  2. Verify the address prefix. Address prefixes F4, L4, LF, IF, and HL indicate FLOAT, LONG, LONG_FLOAT, INT_FLOAT, and HIGH/LOW register pairs respectively. Match the prefix to the device profile - AutomationDirect Click uses L4 for INT32 (DS-addressable), while Allen-Bradley MicroLogix uses L for long-integer file elements.
  3. Read 2x the registers for INT32/FLOAT. Function Code 03 (Read Holding Registers) returns N registers where each register is 2 bytes. A single INT32 value requires N = 2 registers = 4 bytes.
  4. Apply byte-swap conditionally. Only call SwapBytes when the slave is documented as CDAB or DCBA. Modicon, Schneider M340, and most ABB drives use ABCD and do not require a swap.
  5. Sign-extend signed multi-byte values. For INT16 and INT32 values transmitted unsigned across the wire, subtract 65536 or 4294967296 respectively when the high-order bit is set.
  6. Cache the parsed result per scan. Avoid re-parsing the same register on every HMI tick; bind the tag to a local variable refreshed on poll completion.

Multi-Register Read Optimization Pitfalls

Most Modbus masters (AdvancedHMI, Ignition, WinCC, FactoryTalk) coalesce contiguous tag reads into a single FC03 request. When two INT32 tags are within the optimized block but their length or alignment differs, the optimizer may miscount bytes and return 0 for every tag after the first. Symptoms and fixes:

Symptom Cause Fix
First INT32 correct, subsequent INT32 = 0 Optimizer under-counts bytes per element when prefixes mix Force each tag to its own read transaction or update the driver to the latest release
INT32 returns swapped bytes for tag N only Start-address misalignment across the block Pad with dummy registers or split reads
FLOAT = 0 but INT32 = correct Prefix dispatch falls through to integer branch Verify F4 literal in source - case-insensitive compare required
LONG = 0 even though raw buffer is valid Sign extension missing on 4-byte unsigned accumulator Replace CInt with CLng and subtract 2^32 on overflow

Verification and Diagnostics

After applying the corrected conversion, run the following checks to confirm signed values render correctly across the full negative range.

  1. Boundary write/read test. At the slave, write the canonical test values -1, -32768, -2147483648, and the positive equivalents. Read back through the HMI and confirm bit-exact parity.
  2. Hex-dump the response buffer. Use a serial tap (e.g., RTU capture) or a passive RS-485 sniffer. For a value of -1, the buffer must be FF FF FF FF for INT32 and FF FF for INT16.
  3. Range sweep. Drive the source register through a slow ramp from -10000 to +10000 in increments of 1000. The HMI display must match the source exactly with no missing zero crossing or saturation.
  4. Breakpoint the swap call. In the VB.NET debugger, set a breakpoint on SwapBytes(FloatBytes, 0). Inspect FloatBytes in the Locals window to confirm the byte order matches the documented slave convention.

Vendor-Specific Address-Mapping Examples

Vendor / Device Tag Modbus Address Native Type Byte Order Prefix
Lenze i550 / i950 _n_act (actual speed) 7696 INT32 (CANopen 0x606C.0) CDAB L47696
AutomationDirect Click DS register pair 16385 INT32 ABCD L416385
Schneider Modicon M340 %MW Configurable INT16 / INT32 ABCD Depends on profile
Wago 750 Series PFC Process image word 0x0000 - 0x0FFF INT16 / INT32 DCBA Depends on profile

The Lenze mapping confirms the most common failure case: a slave that exposes CANopen objects over Modbus in CANopen-default little-endian-with-word-swap (CDAB) order, which .NET interprets as big-endian unless the swap is applied. Always cross-check the address map against the device-specific Modbus implementation guide published by the manufacturer rather than inferring from CANopen defaults alone.

Field-Commissioning Checklist

  • Verify FC03 / FC04 response byte count matches 2 * Quantity.
  • Confirm CRC-16 (Modbus RTU) passes for every transaction in the trace.
  • Match the address prefix in the HMI tag to the slave data type - case-insensitive comparison is required because L4, l4, and L416385 must all resolve identically.
  • For multi-register values, validate the wire order against the device's Modbus reference manual.
  • Test negative-range inputs before declaring the link production-ready.
  • Document the byte-swap convention in the project file so future engineers do not strip it as "redundant code".

Why does my Modbus register return 0 only for negative values?

The high byte of a negative two's-complement integer is 0xFF, and when the byte-shift accumulator in your parser is declared as a 32-bit signed type (CInt in VB.NET), the multiplication overflows and collapses to zero or to a positive value. Switch the accumulator to a 64-bit signed (CLng) and subtract 2^32 when the result is greater than or equal to 2,147,483,648 to recover the sign.

How do I read a Modbus INT32 value that returns bytes in the wrong order?

Modbus is big-endian on the wire, but many slaves (Lenze, Wago, CANopen gateways) transmit the high and low words in reverse order (CDAB). Swap the words using a routine such as SwapBytes(buf, 0); SwapBytes(buf, 2) before passing the buffer to BitConverter.ToInt32, which is little-endian on x86 .NET Framework.

What is the difference between F4 and L4 address prefixes in AdvancedHMI?

F4 designates an IEEE-754 single-precision FLOAT (REAL) and routes the byte buffer through BitConverter.ToSingle. L4 designates a 32-bit signed long integer (INT32) and routes through BitConverter.ToInt32. Both require the same word-swap on CDAB slaves but use different conversion methods. A dispatch that branches on IndexOf("F4") twice and ignores L4 will return 0 for every long-integer read.

Why does only the first tag in my multi-read block return a value?

Most Modbus masters coalesce contiguous tag reads into one FC03 transaction. If the driver's byte-per-element calculation differs from the actual data widths (for example, when one tag is INT16 and the next is INT32), the offset pointer drifts and subsequent tags parse from the wrong buffer position, yielding 0. Either upgrade the driver, separate the tags into individual transactions, or align them on even register boundaries.

Can I use BitConverter for Modbus FLOAT values on big-endian slaves?

Yes, but omit the byte-swap calls. BitConverter.ToSingle always assumes the host endianness, which is little-endian on x86/x64 .NET Framework per the Microsoft BitConverter reference. If the slave is documented as Modicon/Schneider ABCD big-endian, feed the bytes directly without swapping.

Back to blog