Implementing Modbus in C: RTU and TCP Master/Slave Code

Daniel Price9 min read
ModbusOther ManufacturerTechnical Reference
Licensed PE Working through this on a live machine? A Maine-licensed engineer can take it from here — included with IMD hardware, by the hour for everything else. Book an engineer

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 */
Critical: The Modbus serial specification fixes the timers at 1.750 ms (t1.5) and 1.750 ms x2 (t3.5 = 1.750 ms) for baud rates above 19200 rather than scaling them down, because sub-millisecond timers are impractical on general-purpose OSes. Confirm the exact fixed values against the current Modbus Organization serial-line specification before shipping; do not hand-derive them.

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:

  1. Set VMIN=0, VTIME=0 (non-blocking) and drive a select()/poll() loop with a computed microsecond timeout, timestamping each read with clock_gettime(CLOCK_MONOTONIC, ...).
  2. On a bare-metal target, run a hardware timer reloaded on every received byte; the timer ISR flags end-of-frame.
  3. 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.

Modbus TCP has no CRC. A common porting bug is carrying the RTU CRC into the TCP frame - the server will reject or misparse it. Equally common: assuming one TCP read returns exactly one frame. TCP is a byte stream; buffer until you have 6 bytes, read 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
Addressing trap: A device documented as holding register 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:

  1. Build PDU, prepend address, append CRC.
  2. Flush the receive buffer (tcflush(fd, TCIFLUSH)) to discard echo and stale bytes.
  3. Assert DE, write, tcdrain(), release DE.
  4. Start the response timeout - typically 300-1000 ms depending on slave scan time and any gateway hops.
  5. Accumulate bytes until t3.5 idle or the expected byte count is reached.
  6. Validate: length >= 4, address matches request, CRC matches, FC matches or equals FC|0x80.
  7. 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

  1. 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.
  2. Loopback framing test. Short TX to RX on RS-232 and confirm your receiver detects frame boundaries by timing alone.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

Back to blog