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
- Part 1 β What Modbus Is (and Stubbornly Isn’t)
- Part 2 β The Data Model: Four Little Tables
- Part 3 β Addressing: The Off-By-One That Has Ruined Careers
- Part 4 β Function Codes: One Byte to Rule Them
- Part 5 β Framing: RTU, ASCII, and TCP
- Part 6 β The PDU, Request by Request
- Part 7 β Exceptions: When “No” Is a Valid Answer
- Part 8 β Data Types: The 90% That Isn’t Protocol
- Part 9 β Endianness: The Silent Value-Corrupter
- Part 10 β Designing a Register Map & Building a Complete System
- Appendix β Hands-On with
gomodbus+ Glossary
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:
- Client (master) β initiates every transaction. Nothing happens on the bus unless the client asks. In our reference code, the poller plays this role.
- Server (slave) β owns the data and answers requests. It never speaks unprompted. In our code, the slave is the server.
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.
- It is not typed. The wire carries bits and 16-bit words. “Float,” “signed,” “32-bit” are agreements between client and server, not anything the protocol enforces. (Part 8 is entirely about this.)
- It has no built-in security. No authentication, no encryption. Anyone who can reach the port can read and write. A Modbus/TCP Security variant (TLS) exists but is rare. This is why finding port 502 exposed to the open internet β and people do, constantly β is a genuine industrial-safety incident, not a theoretical one.
- It has no discovery. There is no “list your registers” request. You need the device’s documentation: its register map. No map, no integration. (There’s a partial exception β Read Device Identification β which we’ll meet in Part 4.)
- It has no notion of engineering units or scaling. A register holding
235might mean 23.5 Β°C, 235 kPa, or 2.35 V. Only the map tells you which. - It is not event-driven. The server cannot push. To see a change, you must poll β hence the name of our client, the poller. Want alarms the instant they trip? You poll fast and hope.
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:
- Coils β on/off things the device can switch and that you’re allowed to command: a relay, a pump, an LED, a solenoid. Read and write.
- Discrete inputs β on/off things the device senses but you cannot set: a limit switch, a door contact, an alarm bit, an E-stop status. Read-only.
- Input registers β numeric things the device measures: temperature, pressure, RPM, current. Read-only.
- Holding registers β numeric things you configure or that hold state: a setpoint, a calibration constant, a mode selector. Read and write. This is the catch-all bank, and here’s a dirty industry secret: many real devices dump almost everything into holding registers and barely touch the other three tables, because it’s the one bank you can both read and write. Cheap energy meters in particular love to expose live measurements as holding registers even though “morally” they’re input registers.
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:
- Holding register
40001(docs) β function code03, wire address0. - Holding register
40108(docs) β function code03, wire address107. - Input register
30005(docs) β function code04, wire address4.
Two traps live in that translation, and both are worth their own warning:
Trap 1 β The β1 offset.
4xxxxdocumentation is 1-based; the wire is 0-based.40001is wire address0, not1. 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
40001into the address field. Send function03, address0. 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
- There is no “write input register” or “write discrete input” code. Those tables are read-only by definition β the absence of a write function code is how “read-only” is enforced. A device physically cannot let you write them over Modbus.
- A response echoes the function code with the high bit clear on success, or set (
FC | 0x80) on error. That single bit is the entire error-signaling mechanism (Part 7). - Per-request limits matter. A single read returns at most 125 registers (
0x03/0x04) or 2000 bits (0x01/0x02). A multi-write takes at most 123 registers (0x10) or 1968 coils (0x0F). These aren’t arbitrary β they fall out of the 253-byte PDU limit we’ll meet in Part 5. Exceed them and you’ll get an exception or a truncated frame. Our slave enforces these caps and the poller checks them before sending, which is exactly what a good implementation on both ends should do.
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
- PDU (Protocol Data Unit) =
function code (1 byte) + data. Transport-independent. Maximum 253 bytes. This is the payload that never changes. - ADU (Application Data Unit) = addressing/framing + PDU + (sometimes) a checksum. Transport-specific. This is the envelope.
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) ]
- One byte slave address (
1β247;0= broadcast). - A CRC-16 (Modbus polynomial) trailer for integrity β critical, because a noisy factory floor with 100 m of cable and a nearby VFD is an electrically hostile place.
- Frame boundaries are defined by silence of β₯3.5 character times. This is the sneaky one: RTU has no start/stop delimiter, just quiet. A slow OS, a laggy USB-to-serial adapter, or an over-buffered driver can smear that silence and corrupt framing. If you’ve ever had RTU work on one laptop and fail on another, this timing sensitivity is usually why.
- Max ADU 256 bytes.
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 β ... β
ββββββββββββ΄βββββββββββ΄βββββββββββ΄βββββββ ββββββββββββ΄ββββββββββ
- Transaction ID β echoed by the server. Lets a client match responses to requests and have several in flight at once. This is Modbus/TCP’s one real upgrade over serial.
- Protocol ID β always
0x0000for Modbus. If it’s ever nonzero, something is wrong. - Length β number of following bytes (Unit ID + PDU).
- Unit ID β the RTU slave address, used when a TCP gateway fronts serial devices. For a native TCP device it’s often
1(or ignored). This field is a bridge back to the serial world, which matters enormously in Part 10.
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:
- 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.)
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:
- Protocol exceptions (
*ModbusError) β the server answered. Fix the request: address, count, unit, function. Retrying the identical request will fail identically. - Transport errors β timeout, connection refused, connection reset. Reconnect and retry with backoff. These are the retryable ones.
- 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
float32read 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:
- Big-endian word order (“ABCD”, high word first) β register N = high word. Often called “standard” and the most common.
- Little-endian word order (“CDAB”, word-swapped) β register N = low word. Extremely common on PLCs; Modicon β the inventors of Modbus β historically did this, so you can’t even call it wrong.
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:
- 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.
- Keep multi-register values aligned and contiguous. A
float32at registersN, N+1should never be split by an unrelated value, and a client should be able to read the whole logical struct in one go. - 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.
- 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.
- Use input registers for measurements, holding registers for settings. It communicates intent and stops a client from accidentally writing to a sensor.
- Reserve gaps for future fields so you don’t have to renumber everything in v2.
- 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 ... |
+---------+ |
+
- A native TCP device answers on port 502 directly; its unit id is usually
1. - A gateway bridges TCP to an RS-485 multidrop bus, and the unit id selects which serial device it forwards to. Getting all zeros or
0x0Bexceptions? Ninety percent of the time it’s the wrong unit id. - On RS-485, devices share one pair of wires. Only one client, addresses
1..247, and β this is the part software people forget β proper termination and biasing resistors matter physically. A bus that works with three devices can fail when you add the fourth if termination is wrong.
Polling strategy
Modbus can’t push (Part 1), so the client polls β and polling deserves real design thought, not a while True loop:
- Batch reads. Coalesce adjacent registers into one request (respecting the 125-reg / 2000-bit caps from Part 4). One read of 50 registers beats 50 reads of one, every time.
- Tier by rate. Poll fast signals (alarms, live values) often; poll slow config rarely. Nobody needs to re-read a static nameplate string every 100 ms.
- Serialize per connection. A single TCP socket should have one request in flight at a time β or use distinct transaction ids and match responses carefully. The reference poller serializes with a mutex so transaction ids stay matched (see
transactioninpoller.go). - Set timeouts. Every request needs a deadline; a silent device must never hang the whole poller. The reference tool sets a per-request deadline via
Poller.Timeout. - Reconnect with backoff. Connections drop β that’s normal, not exceptional. Retry, but don’t busy-loop and turn a blip into a self-inflicted DDoS on your own device.
Writing safely
- Writes are opt-in and dangerous on live equipment β you may be commanding a real actuator, opening a real valve, starting a real motor. The reference poller never writes unless
-writeis explicitly given, and that’s the right default. - Prefer Write Multiple Registers (
0x10) for multi-word values so both halves of a float land atomically (Part 6). Writing a float as two separate single-register writes is asking for a torn value on the device side. - After a critical write, read back to confirm the device took the value you meant.
- Mind coil semantics:
0xFF00/0x0000only (Part 6).
Robustness & operations
- Validate every response: length, transaction/unit id, byte count, function code. Reject torn or mismatched frames rather than parsing them (Part 7).
- Classify errors β protocol vs transport vs framing β and react appropriately. Only retry what’s actually retryable.
- Log at the wire level while commissioning β raw TX/RX plus a decode β so you can diff byte-for-byte against the device manual. That’s precisely what
-tracegives you. - Security is on you. Modbus is unauthenticated and unencrypted (Part 1). Never expose port 502 to the internet β Shodan is full of exposed Modbus devices, and that’s not a hall of fame. Put devices on an isolated OT network or VLAN, gate access with a firewall, and use a VPN (or Modbus/TCP-Security TLS) for anything remote.
- Health & observability. Track per-device poll latency, timeout rate, and exception counts. A slowly rising timeout rate is often the first sign of a failing cable, a dying adapter, or a device about to fall off the bus β days before it actually does.
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
- ADU β Application Data Unit: the complete on-wire frame (addressing + PDU + checksum).
- Coil β a single read/write bit (digital output).
- Discrete input β a single read-only bit (digital input).
- Endianness β byte/word ordering of multi-byte values (Part 9).
- Exception β an error response:
FC|0x80plus an exception code (Part 7). - Function code (FC) β the operation-plus-table selector byte (Part 4).
- Gateway β bridges Modbus/TCP to serial RTU devices; routed by unit id.
- Holding register β a 16-bit read/write word (setpoints, config).
- Input register β a 16-bit read-only word (measurements).
- MBAP β the 7-byte Modbus/TCP header (transaction/protocol/length/unit).
- PDU β Protocol Data Unit: function code + data, transport-independent.
- Register map β the device’s documentation of address β meaning/type. The contract that makes typeless registers usable.
- RTU β the compact binary serial framing (with CRC-16).
- Scaling β interpreting an integer register as a fixed-point real value.
- Unit ID / Slave address β identifies a device, chiefly behind a gateway.
- Word order β which register holds the high half of a multi-register value (ABCD vs CDAB).
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.