Overview
Writing a Modbus stack from scratch is not hard, but it is time-consuming to get right. The protocol itself is trivial; the cost sits in framing timers, CRC correctness, exception handling, retry logic, and byte-order edge cases that only surface against a third-party device. This reference covers what a C implementation must handle, what to reuse instead of writing, and how to verify the result on the wire.
Three transports share one application-layer PDU:
| Transport | Frame envelope | Error check | Typical media |
|---|---|---|---|
| Modbus RTU | Address + PDU + CRC, delimited by silent intervals | CRC-16 (2 bytes, low byte first) | RS-485 / RS-232 |
| Modbus ASCII | ':' + hex-encoded frame + CR/LF | LRC (1 byte, ASCII hex) | RS-232, modem links |
| Modbus TCP | 7-byte MBAP header + PDU (no CRC) | TCP checksum | Ethernet, port 502 |
The PDU is identical across all three: one function code byte plus function-specific data. Structure your C code so the PDU builder/parser is transport-agnostic and only the framing layer changes. That single decision is what lets you reuse the same source for a serial slave and an Ethernet master.
Frame Layout and Timing Constraints
RTU framing
RTU has no start/stop delimiter. Frames are separated by idle line time, so the receiver is a timer-driven state machine, not a delimiter parser:
-
t3.5- inter-frame silent interval. A gap of at least 3.5 character times marks end of frame. -
t1.5- intra-frame gap limit. A gap larger than 1.5 character times inside a frame invalidates it.
Character time at 8 data bits, 1 parity, 1 stop = 11 bits per character:
t_char_us = 11 * 1000000 / baud
t3_5_us = 3.5 * t_char_us
t1_5_us = 1.5 * t_char_us
/* 9600 baud: t_char = 1146 us, t3.5 = 4010 us, t1.5 = 1719 us */
On Linux/POSIX this is the hardest part of a from-scratch port. Standard termios gives you VMIN/VTIME, but VTIME has 100 ms granularity - far too coarse for t3.5 at 9600 baud. Practical approaches:
- Set
VMIN=0, VTIME=0(non-blocking) and drive aselect()/poll()loop with a computed microsecond timeout, timestamping each read withclock_gettime(CLOCK_MONOTONIC, ...). - On a bare-metal target, run a hardware timer reloaded on every received byte; the timer ISR flags end-of-frame.
- As a slave, accept a relaxed t3.5 on receive but honor it strictly on transmit turnaround - lenient in, strict out.
RS-485 driver enable
Half-duplex RS-485 requires asserting DE/RE around the transmit. Release the driver only after the last stop bit has physically left the UART, not after write() returns. Use tcdrain() plus a guard delay of one character time, or the UART's TX-complete (not TX-empty) interrupt. Dropping DE early truncates the last byte and produces intermittent CRC errors that look like noise.
Modbus TCP MBAP header
struct mbap {
uint16_t transaction_id; /* echoed by server, big-endian */
uint16_t protocol_id; /* 0 for Modbus */
uint16_t length; /* bytes following: unit_id + PDU */
uint8_t unit_id; /* slave/gateway address */
}; /* 7 bytes on the wire - do NOT memcpy a packed struct blindly */
Serialize field by field with explicit shifts. Everything in the MBAP header and in Modbus register data is big-endian, so on x86 you must convert. Never rely on struct packing across compilers.
length, then wait for that many more bytes.CRC-16 and LRC Implementations
Modbus RTU uses CRC-16 with the reversed polynomial 0xA001, initial value 0xFFFF, no final XOR, transmitted low byte first. Bitwise version (small, ~8x slower):
uint16_t mb_crc16(const uint8_t *buf, size_t len)
{
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; i++) {
crc ^= (uint16_t)buf[i];
for (int b = 0; b < 8; b++) {
if (crc & 0x0001)
crc = (crc >> 1) ^ 0xA001;
else
crc >>= 1;
}
}
return crc; /* append as: buf[n] = crc & 0xFF; buf[n+1] = crc >> 8; */
}
Table-driven variants (256-entry, 512 bytes of ROM) are standard where CPU time matters. Validate either version against the canonical test vector in the Modbus serial specification before trusting it.
ASCII mode uses an 8-bit LRC: sum all bytes of the binary frame (address through data, excluding the colon and CRLF), take the two's complement:
uint8_t mb_lrc(const uint8_t *buf, size_t len)
{
uint8_t sum = 0;
for (size_t i = 0; i < len; i++) sum += buf[i];
return (uint8_t)(-(int8_t)sum);
}
Function Codes and Exception Handling
| FC (dec/hex) | Operation | Data unit |
|---|---|---|
| 01 / 0x01 | Read Coils | Bit, output |
| 02 / 0x02 | Read Discrete Inputs | Bit, input |
| 03 / 0x03 | Read Holding Registers | 16-bit R/W |
| 04 / 0x04 | Read Input Registers | 16-bit RO |
| 05 / 0x05 | Write Single Coil | 0xFF00 = ON, 0x0000 = OFF |
| 06 / 0x06 | Write Single Register | 16-bit |
| 15 / 0x0F | Write Multiple Coils | Bit block |
| 16 / 0x10 | Write Multiple Registers | 16-bit block |
| 23 / 0x17 | Read/Write Multiple Registers | Combined transaction |
An exception response sets the MSB of the function code (FC | 0x80) and returns one exception code byte:
| Code | Name | Usual cause in C code |
|---|---|---|
| 0x01 | Illegal Function | Slave does not implement that FC |
| 0x02 | Illegal Data Address | Off-by-one: PLC docs use 40001-style 1-based addressing, the wire uses 0-based offsets |
| 0x03 | Illegal Data Value | Quantity out of range - e.g. more than 125 holding registers or 2000 coils in one read |
| 0x04 | Slave Device Failure | Unrecoverable error in the slave application handler |
| 0x05 | Acknowledge | Long-running command accepted; poll with FC 0x0B/0x11 or retry |
| 0x06 | Slave Device Busy | Retry later; do not treat as a hard fault |
| 0x0B | Gateway Target Device Failed to Respond | Serial device behind a TCP-to-RTU gateway is offline |
40001 is offset 0x0000 with FC 0x03. Some vendors document the raw offset instead. If you get exception 0x02 on the first register and success on the second, you have a 1-off convention mismatch, not a broken stack.Master State Machine and Retry Policy
A serial master must be strictly half-duplex: one outstanding request at a time on the bus, per slave address. Structure it as:
- Build PDU, prepend address, append CRC.
- Flush the receive buffer (
tcflush(fd, TCIFLUSH)) to discard echo and stale bytes. - Assert DE, write,
tcdrain(), release DE. - Start the response timeout - typically 300-1000 ms depending on slave scan time and any gateway hops.
- Accumulate bytes until t3.5 idle or the expected byte count is reached.
- Validate: length >= 4, address matches request, CRC matches, FC matches or equals
FC|0x80. - On timeout or CRC error, retry 2-3 times, then mark the slave failed and back off so one dead node does not stall the poll loop for every other device.
For Modbus TCP, increment transaction_id per request and match it on the response. If you allow pipelining, the transaction ID is your only demultiplexer - discard responses whose ID is not in your outstanding table rather than assuming FIFO ordering.
Data type reassembly
Modbus defines only 16-bit registers. 32-bit values are vendor-defined register pairs, and word order is not standardized. Provide all four combinations and make it a configuration item, not a compile-time assumption:
/* big-endian words, big-endian bytes ("ABCD") */
uint32_t r_abcd(uint16_t hi, uint16_t lo) { return ((uint32_t)hi << 16) | lo; }
/* word-swapped ("CDAB") - common on many drives and power meters */
uint32_t r_cdab(uint16_t w0, uint16_t w1) { return ((uint32_t)w1 << 16) | w0; }
Build vs. Reuse
Unless you need certification-grade traceability or must fit a very small MCU, use an existing stack. Evaluate candidates against these criteria rather than feature-count marketing:
| Criterion | Why it matters |
|---|---|
| License | Permissive vs. copyleft decides whether you can ship it in closed firmware; commercial libraries with source and a redistribution license exist for exactly this case |
| Source included | You will need to patch timing or DE control for your UART - a binary-only library is a dead end on embedded targets |
| Transport coverage | RTU + ASCII + TCP in one API avoids maintaining two codebases |
| Role coverage | Many libraries implement master only; slave/server side is a separate effort |
| Platform portability | POSIX, Win32, and RTOS ports (VxWorks, QNX) matter if the product line spans targets |
| Blocking model | Blocking call-per-transaction is simple but needs a thread per link; non-blocking/event-driven scales to hundreds of TCP connections |
| Thread safety | Explicit statement of whether a context can be shared across threads |
Open-source Modbus code exists both as standalone C/C++ libraries and embedded inside larger open automation projects; commercial vendors sell master/slave C++ libraries with source for Linux, Win32, Solaris, QNX and VxWorks. The Modbus Organization publishes the application protocol and serial-line specifications plus links to implementations - treat those specifications as the authority when a library and a device disagree.
Verification
- CRC unit test. Run your CRC against the worked example in the serial specification before touching hardware. A wrong CRC produces silent, total non-response.
- Loopback framing test. Short TX to RX on RS-232 and confirm your receiver detects frame boundaries by timing alone.
-
Wire capture. Use a serial line monitor or
tcpdump -i eth0 port 502 -X. Verify byte order in the MBAP header and that no CRC is appended on TCP. - Reference peer. Point your master at a known-good slave simulator, then point a known-good master at your slave. Testing your own master against your own slave hides shared mistakes.
- Boundary registers. Read 1 register, 125 registers, and 126 registers. The last must return exception 0x03 from a conforming slave; if your master crashes instead, fix the length validation.
- Fault injection. Unplug a slave mid-poll and confirm timeout, retry count, and back-off behave as designed and that the loop continues polling healthy nodes.
Why does my Modbus RTU master get CRC errors only on the last byte?
Almost always an RS-485 driver-enable timing problem: DE is released before the final stop bit has shifted out of the UART. Release DE on the TX-complete interrupt, or after tcdrain() plus one character time of guard delay.
Do I need a CRC in Modbus TCP frames?
No. Modbus TCP replaces the RTU CRC with the 7-byte MBAP header and relies on TCP for integrity. Appending a CRC to a TCP frame corrupts the length field's meaning and the server will reject the request.
Why do I get exception code 0x02 reading register 40001?
The 4xxxx notation is 1-based documentation addressing; the wire protocol uses 0-based offsets. Read holding register 40001 as starting address 0x0000 with function code 0x03.
What is the maximum number of registers in one Modbus read?
125 holding or input registers (FC 0x03/0x04) and 2000 coils or discrete inputs (FC 0x01/0x02) per transaction. Exceeding these limits returns exception 0x03, Illegal Data Value.
How do I set t1.5 and t3.5 timers on Linux where VTIME has 100 ms resolution?
Set VMIN=0, VTIME=0 for non-blocking reads and drive the frame timer yourself with select()/poll() timeouts and clock_gettime(CLOCK_MONOTONIC, ...) timestamps on each received byte. Do not rely on termios inter-character timing.
How are 32-bit floats transferred over Modbus?
As two consecutive 16-bit registers, with vendor-dependent word order. Implement both ABCD and CDAB reassembly and make the order a runtime configuration parameter per device.