← Back to Home
✍️ Yantratmika Solutions πŸ“… 2026-07-02 ⏱️ 35 min read

Understanding MODBUS

Modbus, From the Wire Up: A Blog Series for People Who Have to Make It Work

A ten-part deep dive into the protocol that quietly runs the physical world β€” what it is, every entity type, every data-type convention, framing on the wire, error handling, and how to assemble it all into something you’d actually trust in production. Every example is runnable against the gomodbus utility, and I’ve thrown in Python (pymodbus) equivalents so you can follow along in whatever’s already on your machine.


Series map

If you’re impatient, here’s the whole thing in one breath: Modbus moves bits and 16-bit words, split into four tables. It carries no data types, no units, no scaling. A “temperature of 23.5 Β°C” is just two 16-bit words on the wire that you and the device agreed to read as a float. Everything hard about Modbus is convention, not protocol. The rest of this series is me proving that sentence and showing you how to survive it.


Part 1 β€” What Modbus Is (and Stubbornly Isn’t)

There’s a protocol you’ve almost certainly relied on today without knowing it. The elevator that brought you up. The rooftop chiller keeping your office at 22 Β°C. The solar inverter feeding the grid. The pump station that moved your city’s water. Odds are good that somewhere in the guts of each, a controller was speaking Modbus β€” a protocol Modicon published in 1979 to talk to programmable logic controllers.

Nineteen seventy-nine. The Walkman came out that year. Modbus has outlived the Walkman, the fax machine, and several empires of “modern” fieldbus standards that were supposed to replace it. Why? Because it is brutally simple, and brutal simplicity is a superpower in industrial environments where a firmware update might mean sending an engineer up a wind turbine.

Two roles, and only two

Modbus is a strict request/response protocol with exactly two roles:

The industry is migrating its vocabulary from master/slave to client/server. They mean the same thing. I’ll use both, because you’ll see both in the wild; the code uses “poller” and “slave.”

That’s it. A conversation is always: client asks, server answers. The server is a polite houseguest who never starts a conversation and never raises its voice.

What Modbus is NOT β€” and why each “not” bites people

This is the part worth tattooing somewhere visible, because almost every Modbus headache traces back to expecting a feature the protocol simply doesn’t have.

Here’s the mindset shift that makes everything downstream click:

A Modbus integration is 10% protocol and 90% convention.

The protocol you can learn in an afternoon (this part and the next four). The convention β€” the register maps, the endianness quirks, the vendor-specific scaling, the gateway routing β€” is where you’ll spend your actual career. This series front-loads the easy 10% so we can spend real time on the 90%.


Part 2 β€” The Data Model: Four Little Tables

Every Modbus server exposes up to four independent tables. Two hold single bits, two hold 16-bit words. Two are read-only, two are read/write. That 2Γ—2 grid is the entire data model. Internalize this grid and half of Modbus is already yours.

Entity Size Access Typical source Classic prefix
Coil 1 bit Read/Write Digital output, relay 0xxxx
Discrete Input 1 bit Read-only Digital input, switch 1xxxx
Input Register 16-bit Read-only Analog input, sensor 3xxxx
Holding Register 16-bit Read/Write Setpoint, config, output 4xxxx

The trick to remembering it is to think from the device’s point of view:

The one gotcha that surprises everyone

Each table is independently addressed 0..65535. That means:

Coil 0, discrete input 0, input register 0, and holding register 0 are four different storage locations.

There is no shared address space and no “type” field in the request. The function code is what selects the table β€” the operation implies which table you’re touching. Read Holding Registers and Read Input Registers are different function codes precisely because address 0 means something different in each.

What this looks like in code

Our reference server, slave.go, models exactly these four banks and nothing more:

type Slave struct {
    coils            []bool   // 0x read/write bits
    discreteInputs   []bool   // 1x read-only bits
    holdingRegisters []uint16 // 4x read/write words
    inputRegisters   []uint16 // 3x read-only words
    ...
}

Four slices. bool for the bit tables, uint16 for the word tables. That’s a faithful model of the whole Modbus universe. When a device vendor hands you a 40-page manual, somewhere inside it is describing exactly these four arrays and what each index means.

Industry example: A Schneider PM5000-series power meter exposes voltage, current, power, and energy as blocks of holding/input registers; its relay outputs are coils; its digital status inputs are discrete inputs. Different device, same four tables. Once you’ve integrated one, the shape of every other one is familiar β€” only the map changes.


Part 3 β€” Addressing: The Off-By-One That Has Ruined Careers

If you take one thing from this entire series, take this part. There are two numbering schemes in Modbus, and conflating them is the single most common mistake. I have watched senior engineers lose an afternoon to it. I have lost an afternoon to it.

Scheme 1: Entity (data model) numbers β€” the documentation view

Vendor manuals often list registers with a leading digit that encodes the table:

Documented number Table Wire (protocol) address
00001–09999 Coils 0–9998
10001–19999 Discrete Inputs 0–9998
30001–39999 Input Registers 0–9998
40001–49999 Holding Registers 0–9998

The leading digit (0/1/3/4) tells you the table. The rest is a 1-based index within that table. So 40108 means “holding register, the 108th one, one-based.”

Scheme 2: Protocol (wire) addresses β€” what actually travels

On the wire, the address field is a 0-based 16-bit number with no table digit β€” because the function code already picked the table. So the translation is:

Two traps live in that translation, and both are worth their own warning:

Trap 1 β€” The βˆ’1 offset. 4xxxx documentation is 1-based; the wire is 0-based. 40001 is wire address 0, not 1. Get this wrong and every value you read is shifted by one register. This is the classic “my temperature reading is actually the pressure reading” bug, and it’s insidious because the data looks real.

Trap 2 β€” The table digit is not part of the address. Do not shove 40001 into the address field. Send function 03, address 0. Some tools and PLCs helpfully expose 1-based or 5-/6-digit “extended” addressing in their UI and quietly convert for you. Others don’t. Always know which layer you’re staring at.

Why “always check” is not optional advice

Our reference tool works in raw protocol (0-based) addresses: -reg 0 is the first holding register, i.e. documented 40001.

But here’s a real wrinkle from the emulator’s own scan log. Its data points are labeled 1001, 1002, …, and those map to wire addresses 0x03E9 = 1001 directly β€” 1:1, no offset. That device is documenting raw addresses, not the 1-based 4xxxx convention. Two devices on the same bus can document their addresses two different ways.

There is no universal rule. There is only: read the manual, find one register whose value you know, and confirm the mapping before you trust anything else. More on that method in Part 9, because endianness makes it even more important.


Part 4 β€” Function Codes: One Byte to Rule Them

The function code (FC) is a single byte that answers two questions at once: what operation, and which table. Here are the public codes you’ll actually use:

FC (hex) FC (dec) Name Table R/W
0x01 1 Read Coils Coils Read
0x02 2 Read Discrete Inputs Discrete Inputs Read
0x03 3 Read Holding Registers Holding Registers Read
0x04 4 Read Input Registers Input Registers Read
0x05 5 Write Single Coil Coils Write
0x06 6 Write Single Register Holding Registers Write
0x0F 15 Write Multiple Coils Coils Write
0x10 16 Write Multiple Registers Holding Registers Write

Those eight cover the overwhelming majority of real traffic. A few more are standard but rarer: 0x07 Read Exception Status, 0x16 Mask Write Register, 0x17 Read/Write Multiple Registers, and 0x2B Encapsulated Interface β€” whose sub-function 0x2B/0x0E, Read Device Identification, is the closest Modbus ever gets to self-description (vendor name, product code, revision). If you were wondering whether the “no discovery” rule from Part 1 had any escape hatch: this is it, and it’s a narrow one.

Key facts worth memorizing

In the reference code, the codes are just named constants β€” clarity over cleverness:

const (
    FuncReadCoils              = 0x01
    FuncReadDiscreteInputs     = 0x02
    FuncReadHoldingRegisters   = 0x03
    FuncReadInputRegisters     = 0x04
    FuncWriteSingleCoil        = 0x05
    FuncWriteSingleRegister    = 0x06
    FuncWriteMultipleCoils     = 0x0F
    FuncWriteMultipleRegisters = 0x10
)

Industry example: When you configure a SCADA tag in a system like Ignition or a historian polling a VFD (variable-frequency drive), you’re picking exactly one of these codes per tag β€” “FC3, address 40001, uint16” is a sentence a controls engineer says out loud. The function code is the verb of that sentence.


Part 5 β€” Framing: RTU, ASCII, and TCP

Here’s the elegant bit of the design. The same function codes and the same four-table data model ride over three different transports unchanged. The function-code-plus-data part is identical; only the wrapper differs. Learn the core once, and RTU-vs-TCP is just packaging.

PDU vs ADU β€” the two words that make framing make sense

ADU = [ address / header ] [ PDU = function + data ] [ error check ]

Everything that differs between RTU, ASCII, and TCP is the stuff around the PDU.

Modbus RTU (serial)

The original, still everywhere. Runs over RS-485/RS-232. Compact binary framing:

[ Slave Addr (1) ][ Function (1) ][ Data (...) ][ CRC-16 (2) ]

Modbus ASCII (serial)

A human-readable, less efficient serial variant. Each byte is sent as two ASCII hex characters, frames start with : and end with CR/LF, and integrity is an LRC instead of a CRC. It’s rare today β€” twice the bytes for the same payload. Know it exists so you recognize it; reach for RTU or TCP instead.

Modbus TCP/IP

Modbus wrapped in TCP, almost always on port 502. There’s no CRC β€” TCP already guarantees integrity β€” but there is a 7-byte MBAP header in front of the PDU:

MBAP header (7 bytes)                          PDU
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Trans ID β”‚ Proto ID β”‚  Length  β”‚ Unit β”‚  β”‚ Function β”‚  Data   β”‚
β”‚  2 bytes β”‚  2 bytes β”‚  2 bytes β”‚ 1 B  β”‚  β”‚   1 B    β”‚   ...   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Building that header is unglamorous byte-poking, exactly as it should be:

binary.BigEndian.PutUint16(req[0:2], txID)               // transaction id
binary.BigEndian.PutUint16(req[2:4], 0)                  // protocol id (always 0)
binary.BigEndian.PutUint16(req[4:6], uint16(len(pdu)+1)) // length: unit + pdu
req[6] = p.UnitID                                        // unit id
copy(req[7:], pdu)                                       // the PDU

You can watch a live MBAP + PDU breakdown with the poller’s -trace flag:

TX 00 01 00 00 00 06 01 03 03 E9 00 01  | TID:1 SID:1 FC:3 Read Holding Registers ADR:1001 CNT:1
   |TID| |Pro| |Len| U  |----PDU-----|

The gotcha transport nobody warns you about

RTU-over-TCP is a fourth, non-standard combination some gateways use: raw RTU framing (with its CRC) shoved into a TCP stream, without the MBAP header. If a device you were told speaks “Modbus TCP” replies with garbage or mysterious trailing bytes, suspect this. You’re parsing an MBAP header out of what is actually a CRC. Our reference code speaks proper Modbus/TCP β€” MBAP, no CRC β€” but this is a genuine field trap on cheap gateways.

Industry example: A common topology is a Moxa or Advantech serial gateway sitting between an Ethernet SCADA network and a daisy-chain of RS-485 meters. The SCADA speaks clean Modbus/TCP; the gateway strips the MBAP, uses the Unit ID to pick which serial device on the multidrop bus to talk to, re-frames as RTU with a CRC, and relays the answer back. Every field we just described earns its keep in that one hop.


Part 6 β€” The PDU, Request by Request

Now the payload itself. Two ground rules before we start:

  1. Every multi-byte field is big-endian. “Modbus is big-endian.” High byte first. (This is the byte order; word order across registers is a separate and much messier story β€” Part 9.)
  2. Hi = high byte, Lo = low byte throughout.

These are the PDUs implemented in poller.go (client) and slave.go (server).

Read Coils (0x01) / Read Discrete Inputs (0x02)

Request: FC | AddrHi AddrLo | QtyHi QtyLo β€” start address plus how many bits.

Response: FC | ByteCount | Bytes… β€” bits packed 8 per byte, LSB first. Bit 0 of the first byte is the first coil. Unused high bits of the last byte are zero.

Read 10 coils at address 20:
  REQ:  01 00 14 00 0A
  RSP:  01 02 CD 01        -> byte0=0xCD=11001101, byte1=0x01
        coils 20..27 = 1,0,1,1,0,0,1,1   coils 28,29 = 1,0

The LSB-first packing trips people up because it reads “backwards” from how you’d write the byte. The decode is a one-liner:

bits[i] = resp[2+i/8]&(1<<(uint(i)%8)) != 0

Read Holding Registers (0x03) / Read Input Registers (0x04)

Request: FC | AddrHi AddrLo | QtyHi QtyLo β€” start plus count (1..125).

Response: FC | ByteCount | Reg0Hi Reg0Lo Reg1Hi Reg1Lo … β€” each register two bytes, big-endian, with ByteCount = 2 Γ— count.

Read 1 holding register at 1001 (0x03E9), value 0x06A2:
  REQ:  03 03 E9 00 01
  RSP:  03 02 06 A2        -> register = 0x06A2 = 1698

Write Single Coil (0x05)

Request: FC | AddrHi AddrLo | ValueHi ValueLo. The value is 0xFF00 for ON or 0x0000 for OFF β€” only those two are legal. Send anything else and a well-behaved device returns an Illegal Data Value exception. The response echoes the request.

Write Single Register (0x06)

Request: FC | AddrHi AddrLo | ValueHi ValueLo β€” a full 16-bit value. The response echoes the request. The echo is your write confirmation.

Write Multiple Coils (0x0F)

Request: FC | AddrHi AddrLo | QtyHi QtyLo | ByteCount | Bytes… (same LSB-first bit packing as the read). Response: FC | AddrHi AddrLo | QtyHi QtyLo β€” it echoes address + quantity, not the data.

Write Multiple Registers (0x10)

Request: FC | AddrHi AddrLo | QtyHi QtyLo | ByteCount | Reg0Hi Reg0Lo … where ByteCount = 2 Γ— Qty. Response: FC | AddrHi AddrLo | QtyHi QtyLo.

This is the workhorse for writing 32-bit and float values, because it spans two (or four) registers and updates them atomically in a single request β€” which, as we’ll see in Part 10, is the only sane way to write a float you care about:

func (p *Poller) WriteMultipleRegisters(addr uint16, values []uint16) error {
    if len(values) < 1 || len(values) > 123 { ... }
    pdu := make([]byte, 6+2*len(values))
    pdu[0] = FuncWriteMultipleRegisters
    binary.BigEndian.PutUint16(pdu[1:], addr)
    binary.BigEndian.PutUint16(pdu[3:], uint16(len(values)))
    pdu[5] = byte(2 * len(values))
    for i, v := range values {
        binary.BigEndian.PutUint16(pdu[6+2*i:], v)
    }
    ...
}

Notice the guard clause enforcing the 123-register cap from Part 4. Good clients fail loudly before putting a malformed frame on the wire.

If Go isn’t your language, the same read in Python with pymodbus is refreshingly short β€” proof that the PDU really is this simple once someone’s written the framing for you:

from pymodbus.client import ModbusTcpClient

client = ModbusTcpClient("127.0.0.1", port=1502)
client.connect()
rr = client.read_holding_registers(address=1001, count=1, slave=1)  # FC 0x03
print(rr.registers)  # -> [1698]
client.close()

Part 7 β€” Exceptions: When “No” Is a Valid Answer

When a server can’t honor a request, it doesn’t hang up or crash. It returns an exception response: the function code with its high bit set (FC | 0x80), followed by a one-byte exception code.

Request FC 0x03 fails with "illegal data address":
  RSP:  83 02      -> 0x83 = 0x03|0x80, exception 0x02

The exception codes you’ll actually see:

Code Name Usual cause
0x01 Illegal Function Device doesn’t support that FC/table.
0x02 Illegal Data Address Address (or address+count) outside the mapped range.
0x03 Illegal Data Value Quantity out of range, bad value (e.g. coil != 0/FF00).
0x04 Server Device Failure Unrecoverable error while processing.
0x05 Acknowledge Long operation accepted; poll later (rare).
0x06 Server Device Busy Try again later.
0x0A Gateway Path Unavailable Gateway can’t route to the unit id.
0x0B Gateway Target No Resp. Gateway reached, but the serial device didn’t answer.

Our reference client turns an exception PDU into a typed Go error rather than a magic number floating around the codebase:

if fc := respPDU[0]; fc&0x80 != 0 {
    return nil, &ModbusError{Function: fc &^ 0x80, Exception: respPDU[1]}
}

The mindset that separates hobby code from production code

An exception is a valid answer, not a failure of the protocol.

0x01 Illegal Function just means “I don’t have that table or don’t support that operation.” 0x0B Gateway Target No Response almost always means a wrong unit id behind a gateway β€” the gateway is fine, but nobody at that address answered. These are diagnoses, not disasters.

Robust clients distinguish three failure classes, and reacting to the wrong one is how you build a system that retries things it shouldn’t:

  1. Protocol exceptions (*ModbusError) β€” the server answered. Fix the request: address, count, unit, function. Retrying the identical request will fail identically.
  2. Transport errors β€” timeout, connection refused, connection reset. Reconnect and retry with backoff. These are the retryable ones.
  3. Framing errors β€” a short response, a transaction-id mismatch, a bad protocol id. Something is desynchronized on the wire. Drop the connection and resync. Never try to “parse around” a framing error; you’ll turn one corrupt frame into a cascade of misaligned ones.

The reference poller validates the protocol id, length, and transaction id on every single response and surfaces a distinct error for each β€” because you cannot classify a failure you didn’t detect.

Industry example: A monitoring system that treats an Illegal Data Address exception as a transport timeout will hammer a device with retries forever, filling your logs and possibly tripping the device’s built-in overload protection β€” all for a request that was never going to succeed. The fix was in the register map, not the network. Correct error classification turns an all-night incident into a one-line config change.


Part 8 β€” Data Types: The 90% That Isn’t Protocol

This is the heart of real-world Modbus, and everything so far has been building to it. The wire carries only bits and 16-bit words. Everything richer β€” floats, signed integers, 64-bit counters, strings β€” is an application-layer agreement between client and server. There is nothing in a frame that says “this is a float.” Not a flag, not a hint, nothing.

Bit-sized values

Coils and discrete inputs are single booleans, packed eight-to-a-byte (LSB first) for transport. But watch for a common trick: a vendor packs 16 independent flags into one holding register and documents each bit’s meaning. You read the register, then mask individual bits (reg & (1<<n)). This is a status word or bitfield β€” one register might carry “pump running / fault / over-temp / door open / …” as sixteen separate booleans.

Word-sized and multi-word values

A single 16-bit register natively holds:

Type Registers Range / meaning
uint16 1 0 … 65535
int16 1 βˆ’32768 … 32767 (two’s complement)

For anything bigger, consecutive registers are concatenated:

Type Registers Notes
uint32 2 32-bit unsigned; e.g. an energy counter.
int32 2 32-bit signed.
float32 2 IEEE-754 single precision; the common analog type.
uint64 4 64-bit unsigned; large totalizers.
int64 4 64-bit signed.
float64 4 IEEE-754 double precision.

The number of registers a value occupies is fixed by its type. Our reference tool encodes exactly this table as data:

var (
    TypeUint16  = DataType{"uint16", 1}
    TypeInt16   = DataType{"int16", 1}
    TypeUint32  = DataType{"uint32", 2}
    TypeInt32   = DataType{"int32", 2}
    TypeFloat32 = DataType{"float32", 2}
    TypeUint64  = DataType{"uint64", 4}
    TypeInt64   = DataType{"int64", 4}
    TypeFloat64 = DataType{"float64", 4}
    TypeHex     = DataType{"hex", 1}   // raw 0xXXXX, no interpretation
)

That TypeHex at the bottom is the honest one: “give me the raw word, I’ll decide later.” When commissioning an unfamiliar device, reading in hex first and eyeballing the bytes is often how you discover what type a register really is.

Scaled integers β€” the float alternative

Many devices avoid floats entirely and send a scaled integer: they store an int16/int32 and document a scale factor. 235 with “scale = 0.1” means 23.5. It’s cheaper on bandwidth and β€” crucially β€” dodges the entire endianness mess that floats drag in (Part 9). The scale lives only in the documentation; the wire just shows 235. Some devices go further and put the scale, or a gain/exponent, in a neighboring register you also have to read. Cheap sensors and legacy PLCs overwhelmingly prefer scaled integers.

Strings

Occasionally a device stores ASCII text packed two characters per register β€” a device name across registers 100–109, say. You read the block and unpack the bytes. And of course you have to confirm which character lands in the high byte versus the low byte. Yet another convention to nail down.

The cardinal rule

A register read returns numbers you chose how to interpret. The device’s register map is the contract. Get the type, the register count, the word order (Part 9), and any scale factor from it β€” never guess. A float32 read with the wrong word order yields plausible-looking garbage, which is far more dangerous than an obvious error, because it sails straight past your sanity checks and into your historian.

In the reference tool you pick the interpretation with -type, and it reads the right number of registers automatically:

gomodbus poller -read input -reg 8 -type float32              # 2 regs -> one float
gomodbus poller -read holding -reg 100 -count 4 -type int32   # 4 regs -> two int32s

Industry example: SunSpec β€” the standardized register map that solar inverters from SMA, Fronius, SolarEdge and others implement on top of Modbus β€” exists precisely because the base protocol has no types. SunSpec is a giant published convention: “at this register you’ll find a float32 AC power, at that one a scaled int16 with the scale factor in the register two down.” It’s the industry collectively agreeing on the 90% that Modbus leaves undefined. When you hear “Modbus map,” picture SunSpec: pages of address-to-meaning-to-type-to-scale.


Part 9 β€” Endianness: The Silent Value-Corrupter

Multi-register values raise two separate ordering questions, and devices disagree on both. This is the number-one source of the worst kind of bug: the value is wrong, but it isn’t zero and it isn’t an error. It just quietly lies to you.

Byte order (within a register)

Modbus registers are big-endian on the wire β€” the high byte travels first. This is fixed by the spec and rarely the problem. A handful of devices swap the two bytes within each register (“byte-swapped”); file that under vendor quirk and move on.

Word order (across registers) β€” the real troublemaker

When a 32-bit value spans registers N and N+1, which register holds the high 16 bits? The spec doesn’t say. So both conventions exist in the wild:

Watch what happens to the float 23.5 = 0x41BC0000:

Word order Register N Register N+1 Reads back as
ABCD (big) 0x41BC 0x0000 23.5 βœ“
CDAB (little/swap) 0x0000 0x41BC 2.36e-41 (garbage)

Same bytes. Different grouping. Wildly different number. And neither one raises an error β€” both are perfectly valid IEEE-754 floats. You only catch it by reading a value you can independently sanity-check.

Our reference tool exposes this as -wordorder big|little, and the combine/split logic is tiny:

func combineWords(words []uint16, swap bool) uint64 {
    if swap { /* reverse register order: CDAB instead of ABCD */ }
    var v uint64
    for _, w := range words { v = v<<16 | uint64(w) }
    return v
}

Which means “the value looks wrong” has a two-command diagnosis:

# Same registers, two interpretations β€” flip -wordorder if a float looks wrong:
gomodbus poller -read holding -reg 10 -count 2 -type float32 -wordorder big
gomodbus poller -read holding -reg 10 -count 2 -type float32 -wordorder little

The commissioning method that saves your sanity

When commissioning a device, read a register whose true value you already know β€” a serial number, a nameplate float, a value you can physically change and watch move. Try the four combinations (ABCD / CDAB, and the byte-swapped variants BADC / DCBA if it comes to that) until the number is right. Then lock that convention in for the whole device and never touch it again.

Industry example: A power-quality analyzer reports a facility drawing 2.36e-41 kW. Nobody’s factory is that efficient. That number is the universal tell of a word-order mismatch on a float32 β€” the moment you see a suspiciously tiny scientific-notation value where you expected a normal reading, flip the word order first and ask questions later. I’ve seen this exact figure in three different projects. It’s practically Modbus’s error message for “you guessed the endianness.”


Part 10 β€” Designing a Register Map & Building a Complete System

Designing the map (if you’re the server)

If you’re building the device side β€” real hardware or a simulator like our reference slave β€” you get to define the register map. A thoughtful one prevents years of integration pain for everyone downstream:

  1. Group by access and rate. Put fast-changing measurements together and slow config together, so clients can grab each group in one request. Reading a contiguous block of 20 registers is one round-trip; reading 20 scattered registers is 20 round-trips.
  2. Keep multi-register values aligned and contiguous. A float32 at registers N, N+1 should never be split by an unrelated value, and a client should be able to read the whole logical struct in one go.
  3. Document everything a client cannot infer: table, 0-based wire address, type, word order, scale/units, valid range, and read/write-ability. The client can’t discover any of this β€” you are the only source of truth.
  4. Pick one word order and one byte order for the entire device and state it plainly. Don’t mix conventions within a device; that’s a special cruelty.
  5. Use input registers for measurements, holding registers for settings. It communicates intent and stops a client from accidentally writing to a sensor.
  6. Reserve gaps for future fields so you don’t have to renumber everything in v2.
  7. Beware non-atomic reads of multi-register values. If a 32-bit counter ticks between the client reading the low word and the high word in two separate requests, the client sees a torn value β€” a number that never actually existed. Either expose it so it’s read in a single request, or provide a “latch/snapshot” coil. On the server side, update both registers under one lock. (Our reference slave does exactly this via SetHoldingRegisters.)

A worked example map β€” the kind of table your device manual should contain:

Wire addr Table Type Words Scale Access Meaning
0 Input float32 2 β€” R Temperature (Β°C)
2 Input float32 2 β€” R Pressure (kPa)
4 Input uint32 2 β€” R Uptime (s)
100 Holding int16 1 0.1 R/W Setpoint (Β°C)
101 Holding uint16 1 β€” R/W Mode (enum)
102 Holding uint16 1 bits R/W Control flags
0 (coil) Coil bool β€” β€” R/W Pump enable
0 (disc.) Discrete bool β€” β€” R Lid-closed switch

Notice how the float32s sit at even, contiguous addresses; the settings cluster at 100+; and coil 0 and discrete input 0 coexist without conflict because they’re different tables (Part 2).

Building the complete system (if you’re the client)

A production integration is far more than encode/decode. Here’s the whole picture.

Topology

                          +------------ Modbus/TCP (port 502) -----------+
   +----------+           |                                              |
   |  Client  |---TCP-----+  +----------+    RS-485 (RTU)   +---------+   |
   | (poller/ |           +--| TCP<->RTU|---+------+--------| Device  |   |
   |  SCADA)  |              | Gateway  |   |      |        | unit 1  |   |
   +----------+              +----------+   |      |        +---------+   |
        |                                   |   +---------+               |
        +-- native TCP device (unit 1) -----+   | Device  | unit 2 ...    |
                                                +---------+               |
                                                                          +

Polling strategy

Modbus can’t push (Part 1), so the client polls β€” and polling deserves real design thought, not a while True loop:

Writing safely

Robustness & operations


Appendix β€” Hands-On with gomodbus + Glossary

Everything in this series is runnable. Start the simulator in one terminal, drive it with the client in another, and map each command back to the part it demonstrates.

# Terminal 1 β€” start the server (simulator).
# It seeds holding[0]=100, a float32 23.5 at holding[10..11], drives a live
# counter on input[0], and updates "scan tags" holding[1001..1005] once per second.
go run . slave -addr 127.0.0.1:1502
# Terminal 2 β€” the client (poller). Each example maps to a part above.

# Parts 2 & 4 β€” read 4 holding registers (the default), values as uint16:
go run . poller -read holding -reg 0 -count 4

# Part 8 β€” read the seeded float32 (2 registers, auto-counted):
go run . poller -read holding -reg 10 -type float32

# Part 9 β€” same registers, wrong word order -> garbage (proves the point):
go run . poller -read holding -reg 10 -type float32 -wordorder little

# Parts 3 & 10 β€” sweep a scattered tag list round-robin, like a scanner:
go run . poller -regs 1001,1002,1003,1005,1004

# Parts 5/6/7 β€” watch the raw MBAP+PDU frames decoded on the wire:
go run . poller -regs 1001,1002,1003,1005,1004 -trace

# Part 10 β€” write opt-in: set holding[0]=1234 each cycle (then read it back):
go run . poller -reg 0 -write 1234

A single -trace line shows the full stack from Parts 5 and 6 at once:

TX 00 01 00 00 00 06 01 03 03 E9 00 01  | TID:1 SID:1 FC:3 Read Holding Registers ADR:1001 CNT:1
RX 00 01 00 00 00 05 01 03 02 06 A2     | TID:1 SID:1 FC:3 Read Holding Registers BYTES:2 DATA:06 A2

Read it left to right: transaction id 0001, protocol 0000, length 0006, unit 01, then the PDU β€” function 03, address 03E9 (1001), count 0001. The reply carries 02 bytes of data: 06A2 = 1698. That one line is Parts 3, 5, 6, and 8 all visible on the wire simultaneously β€” which is exactly why wire-level tracing is the fastest way to learn (and debug) Modbus.

Glossary


That’s the series. If you remember nothing else: Modbus moves bits and words, the register map is the contract, and when a float reads back as 2.36e-41, flip the word order before you flip out.