In industrial automation, supervisory control and data acquisition (SCADA), and programmable logic controller (PLC) engineering, Modbus Function Code 03 (Read Holding Registers / 0x03) is undeniably the most frequently transmitted message across factory floors, utility substations, and building management networks. Whether you are interrogating an energy meter for total active power, polling an analog pressure transmitter, or reading operational speed setpoints from a variable frequency drive (VFD), Function Code 03 is the primary vehicle for retrieving 16-bit analog data.
Despite its ubiquitous deployment since the original Modicon protocol definition in 1979, engineers and software developers regularly encounter integration headaches: 1-off register addressing offsets (40001 vs 0x0000), endianness byte-ordering mismatches on 32-bit floating point numbers, and unhandled exception responses.
This guide provides an exhaustive technical analysis of Function Code 03 under the official Modbus Application Protocol Specification V1.1b3. We examine the exact request and response byte formats on both Modbus RTU and Modbus TCP, examine packet hex captures, analyze IEEE 754 float conversion, and demonstrate how to simulate holding register tables using ModbusSimulator and Python.
1. Protocol Fundamentals: Holding Registers vs Other Data Tables
The Modbus data model categorizes memory into four distinct primary tables. Each table represents a specific combination of bit-level or word-level data, with either read-only or read/write access:
| Data Table Type | Object Type | Access Level | Classic Modicon Address | Standard Read Function Code |
|---|---|---|---|---|
| Discretes Input | Single bit | Read-Only | 10001 – 19999 (1xxxx) | FC 02 (Read Discrete Inputs / 0x02) |
| Coils | Single bit | Read-Write | 00001 – 09999 (0xxxx) | FC 01 (Read Coils / 0x01) |
| Input Registers | 16-bit word | Read-Only | 30001 – 39999 (3xxxx) | FC 04 (Read Input Registers / 0x04) |
| Holding Registers | 16-bit word | Read-Write | 40001 – 49999 (4xxxx) | FC 03 (Read Holding Registers / 0x03) |
Holding registers are 16-bit words (2 bytes, 65,536 discrete states or unsigned values from 0 to 65,535; signed -32,768 to +32,767). Because holding registers support both read and write operations (via FC 06 Write Single Register and FC 16 Write Multiple Registers), modern smart instrumentation commonly places both live process measurements and configurable operational parameters in the holding register space.
2. Addressing Schemes: Modicon 40001 vs Protocol Wire Address 0x0000
The single most widespread source of confusion in Modbus field integration is the difference between human-facing PLC addressing and binary protocol wire addressing.
- Modicon 1-Based Notation: Traditional PLCs (Schneider Modicon, GE, early Allen-Bradley integrations) assign the prefix "4" to designate holding registers. In user documentation, the first register is labelled
40001(or400001in extended 6-digit notation). - Protocol Wire Addressing (0-Based Offset): In the actual binary packet transmitted across the RS-485 serial bus or TCP/IP socket, the starting address is an unsigned 16-bit integer ranging from
0x0000to0xFFFF(0 to 65,535). Protocol address 0x0000 represents the first physical holding register.
PLC Documentation Address: 40001 --> Wire Protocol Hex Offset: 0x0000 (0 decimal)
PLC Documentation Address: 40002 --> Wire Protocol Hex Offset: 0x0001 (1 decimal)
PLC Documentation Address: 40100 --> Wire Protocol Hex Offset: 0x0063 (99 decimal)
PLC Documentation Address: 41001 --> Wire Protocol Hex Offset: 0x03E8 (1000 decimal)
If a field vendor's user manual instructs you to read "Holding Register 40050", your Modbus master software (such as ModbusSimulator or SCADA driver) must query starting address 49 (0x0031). If you enter 50 directly, you will read register 40051, causing an off-by-one data offset error or an Exception Code 02 (Illegal Data Address) if you exceed the end of the register table.
3. Function Code 03 Request and Response PDU Format
The Modbus Protocol Data Unit (PDU) is independent of the underlying communication layer (Serial RTU, Serial ASCII, or Ethernet TCP). The PDU consists strictly of the Function Code and Function-specific Data payload.
Request PDU Structure
A master initiates Function Code 03 by specifying the 16-bit starting address and the 16-bit quantity of consecutive registers to read:
| Field Name | Length | Hex Value / Range | Description |
|---|---|---|---|
| Function Code | 1 Byte | 0x03 |
Read Holding Registers function code |
| Starting Address | 2 Bytes | 0x0000 to 0xFFFF |
0-based register offset (Hi Byte, Lo Byte) |
| Quantity of Registers | 2 Bytes | 0x0001 to 0x007D |
1 to 125 registers (Hi Byte, Lo Byte) |
Constraint Rule: The maximum number of registers readable in a single request is 125 registers (0x007D). Requesting 0 registers or more than 125 registers violates protocol limits and causes the server to immediately return an Exception Code 03 (Illegal Data Value).
Normal (Success) Response PDU Structure
If the server successfully processes the request, it responds with the function code, a byte count indicating the size of the payload, and the register data:
| Field Name | Length | Hex Value / Range | Description |
|---|---|---|---|
| Function Code | 1 Byte | 0x03 |
Confirms successful FC 03 execution |
| Byte Count | 1 Byte | N * 2 (2 to 250) |
Number of data bytes following (2 bytes per register) |
| Register Values | N * 2 Bytes |
Variable | High Byte followed by Low Byte for each register |
4. Wire Frame Analysis: Modbus RTU vs Modbus TCP
To inspect how Function Code 03 travels across industrial networks, we analyze complete Application Data Units (ADUs) for both serial RS-485 and Ethernet connections.
Example Scenario
- Client Goal: Read 2 holding registers starting at address 40108 (Wire offset: 107 decimal =
0x006B). - Target Server ID: Unit 1 (
0x01). - Expected Data: Register 40108 contains
0x02AE(686 decimal); Register 40109 contains0x0014(20 decimal).
Modbus RTU Frame Breakdown (Serial RS-485 / RS-232)
In Modbus RTU, the PDU is framed by a 1-byte Slave ID and protected by a 16-bit Cyclic Redundancy Check (CRC-16):
Request Frame (8 Bytes):
[ 01 ] [ 03 ] [ 00 6B ] [ 00 02 ] [ B4 17 ]
│ │ │ │ └─ CRC-16 Checksum (Lo: 0xB4, Hi: 0x17)
│ │ │ └─ Quantity: 2 registers (0x0002)
│ │ └─ Starting Address: 107 (0x006B)
│ └─ Function Code: 0x03
└─ Slave Address: 0x01
Response Frame (9 Bytes):
[ 01 ] [ 03 ] [ 04 ] [ 02 AE ] [ 00 14 ] [ 3A 79 ]
│ │ │ │ │ └─ CRC-16 Checksum (Lo: 0x3A, Hi: 0x79)
│ │ │ │ └─ Reg 40109 Value: 0x0014
│ │ │ └─ Reg 40108 Value: 0x02AE
│ │ └─ Byte Count: 4 bytes (2 registers * 2)
│ └─ Function Code: 0x03
└─ Slave Address: 0x01
Modbus TCP Frame Breakdown (Ethernet Port 502)
In Modbus TCP, the serial CRC and slave framing are replaced by the 7-byte MBAP (Modbus Application Protocol) Header:
Request Frame (12 Bytes):
[ 00 01 ] [ 00 00 ] [ 00 06 ] [ 01 ] [ 03 ] [ 00 6B ] [ 00 02 ]
│ │ │ │ │ │ │
│ │ │ │ │ │ └─ Quantity: 2 (0x0002)
│ │ │ │ │ └─ Starting Address: 107 (0x006B)
│ │ │ │ └─ Function Code: 0x03
│ │ │ └─ Unit Identifier: 0x01
│ │ └─ Message Length: 6 bytes following (0x0006)
│ └─ Protocol Identifier: 0x0000 (Modbus Protocol)
└─ Transaction Identifier: 0x0001 (Echoed in response)
Response Frame (13 Bytes):
[ 00 01 ] [ 00 00 ] [ 00 07 ] [ 01 ] [ 03 ] [ 04 ] [ 02 AE ] [ 00 14 ]
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ └─ Reg 40109: 0x0014
│ │ │ │ │ │ └─ Reg 40108: 0x02AE
│ │ │ │ │ └─ Byte Count: 4 bytes
│ │ │ │ └─ Function Code: 0x03
│ │ │ └─ Unit Identifier: 0x01
│ │ └─ Length: 7 bytes following (0x0007)
│ └─ Protocol Identifier: 0x0000
└─ Transaction Identifier: 0x0001
5. Handling 32-Bit Floating Point & Multi-Register Data Types
Because standard Modbus registers are strictly 16-bit unsigned entities, storing a 32-bit integer or a single-precision 32-bit floating point number (IEEE 754) requires spanning two consecutive holding registers.
Unfortunately, the original Modbus standard does not specify byte or word order for 32-bit values. As a result, industrial vendors implemented different endianness schemes:
| Endianness Mode | Word Order | Byte Order | Common Vendor Deployments |
|---|---|---|---|
| Big-Endian (ABCD) | High Word First | High Byte First | Schneider Electric, Emerson DeltaV, Yokogawa |
| Little-Endian (CDAB) | Low Word First | High Byte First | Rockwell Allen-Bradley, Modicon Quantum, ProSoft |
| Byte-Swapped Big (BADC) | High Word First | Low Byte First | Omni Flow Computers, certain gas chromatographs |
| Byte-Swapped Little (DCBA) | Low Word First | Low Byte First | Legacy PC-based DAQ boards |
When reading float values using Function Code 03, always configure your master software or SCADA tags with the matching word-swapping option. In ModbusSimulator, you can toggle between Big-Endian, Little-Endian, and Swapped modes instantly to determine the correct representation of incoming live data.
6. Exception Handling: What Happens When FC03 Fails?
When a Modbus server receives a malformed or invalid Function Code 03 request, it responds with an Exception Response rather than a timeout. In an exception response:
- The Function Code byte has its highest-order bit (0x80) set:
0x03 + 0x80 = 0x83. - A single-byte Exception Code follows immediately, indicating the failure mode:
| Exception Code | Name | Common Cause During FC03 Execution |
|---|---|---|
0x01 |
Illegal Function | Target device does not support holding registers (e.g., discrete I/O module only). |
0x02 |
Illegal Data Address | Starting address does not exist, or Starting Address + Quantity exceeds physical memory table. |
0x03 |
Illegal Data Value | Quantity of registers is set to 0, or exceeds the maximum protocol threshold of 125 registers. |
0x04 |
Slave Device Failure | Unrecoverable hardware error or flash memory parity failure occurred while retrieving data. |
7. Python Implementation: Polling FC03 with pymodbus
To poll holding registers programmatically in automated test rigs or custom SCADA gateways, Python with the pymodbus library provides a fast, standardized implementation:
from pymodbus.client import ModbusTcpClient
from pymodbus.payload import BinaryPayloadDecoder
from pymodbus.constants import Endian
# Connect to Modbus TCP Server (ModbusSimulator running on localhost)
client = ModbusTcpClient(host='127.0.0.1', port=502)
if client.connect():
print("[+] Connected to Modbus Server.")
# Read 4 holding registers starting at wire address 100 (40101)
# slave=1 specifies Unit ID 1
response = client.read_holding_registers(address=100, count=4, slave=1)
if not response.isError():
raw_registers = response.registers
print(f"[*] Raw 16-bit register values: {raw_registers}")
# Decode registers 0 & 1 as a 32-bit IEEE 754 Float (Big-Endian)
decoder = BinaryPayloadDecoder.fromRegisters(
raw_registers[0:2],
byteorder=Endian.BIG,
wordorder=Endian.BIG
)
float_value = decoder.decode_32bit_float()
print(f"[*] Decoded Process Temperature: {float_value:.2f} °C")
else:
print(f"[-] Modbus Exception Received: {response}")
client.close()
else:
print("[-] Failed to connect to Modbus TCP Server.")
8. Simulating Holding Registers with ModbusSimulator
Testing PLC control sequences, SCADA dashboards, or Python data ingestion pipelines against live physical hardware in a running production plant introduces significant risk of accidental tripping, register corruption, or equipment downtime.
Using ModbusSimulator, you can spin up a fully compliant Modbus TCP server on port 502 or a Modbus RTU slave on any physical or virtual serial COM port in seconds:
- Pre-Populated 4xxxx Table: Configure up to 65,536 holding registers with realistic test values.
- Dynamic Signal Generators: Assign automated waveform generators (Sine, Ramp, Random Noise, Square Wave) to registers to test alarm high/low thresholds in SCADA.
- Real-Time Hex Sniffer: Inspect incoming Function Code 03 packets byte-for-byte to verify your PLC master's starting address offset and CRC calculation.
- Fault Injection Testing: Deliberately configure Exception 02 (Illegal Address) or Exception 04 (Device Failure) responses to verify your master application's failover recovery logic.