RS485 Modbus RTU Communication Implementation with STM32

Protocol Overview

Modbus RTU is a widely adopted industrial serial communication standard that utilizes binary encoding for efficient data exchange. When paired with the RS485 physical layer, it creates a robust system ideal for noisy industrial environments, sensor networks, and PLC integrations.

RS485 employs differential signaling for noise immunity, operating in half-duplex mode where transmission and reception cannot occur simultaneously. It supports cable lengths up to 1200 meters and can accommodate up to 32 standard nodes (expandable to 256 with lower-load transceivers).

This guide details the implementation of a Modbus RTU node on an STM32 microcontroller (such as the F103 or F407 series), capable of operating as either a master or a slave. It covers reading/writing holding registers, reading input registers, and coil manipulation using the STM32 HAL library.

System Architecture

  • Master Node: The STM32 initiates communications by sending queries (e.g., polling sensor data, sending actuator commands) and processes the slave replies.
  • Slave Node: Peripheral devices that listen for queries addressed to them, executing the requested action and returning the appropriate response.
  • RS485 Transceiver: ICs like the MAX485 or SP3485 bridge the STM32's TTL logic levels to the RS485 differential bus. The DE and RE pins manage the direction of data flow on the bus.

Hardware Design

Component Selection

Component Specification Purpose
Microcontroller STM32F103C8T6 ARM Cortex-M3 core running at 72MHz
Bus Transceiver SP3485 / MAX485 3.3V/5V compatible half-duplex RS485 driver
Isolation (Optional) ADuM1201 Galvanic isolation to prevent ground loops
Termination 120Ω Resistor Impedance matching to prevent signal reflection
Power Supply 3.3V / 5V LDO Powering the MCU and transceiver

Wiring Diagram: STM32 to RS485 Transceiver

Transceiver Pin STM32 Pin Description
RO PA10 (USART1_RX) Receiver Output (TTL level)
DI PA9 (USART1_TX) Driver Input (TTL level)
DE / RE PB0 (GPIO) Direction Control (High=TX, Low=RX)
VCC 3.3V / 5V Power Supply
GND GND Common Ground
A Bus Line A Differential signal A (connects to slave A)
B Bus Line B Differential signal B (connects to slave B)

Bus Connection Note: All 'A' pins must be wired together, and all 'B' pins wired together. A 120Ω termination resistor should be placed at both extreme ends of the bus. Reversing A and B lines will result in communication failure. For long-distance runs, shielded twisted pair cabling is highly recommended.

Software Implementation

Development Setup

  • IDE: STM32CubeIDE
  • Framework: STM32 HAL Library
  • Protocol: Modbus RTU (Function Codes: 0x03 Read Holding Registers, 0x06 Write Single Register, 0x10 Write Multiple Registers)

Core Concepts

Modbus RTU Frame Format

[Device Address (1B)][Function Code (1B)][Payload (NB)][CRC16 Checksum (2B)]

  • Device Address: Target slave identifier (1-247, 0 is reserved for broadcast).
  • Function Code: Defines the requested operation (e.g., 0x03 for reading).
  • Payload: Contains parameters like register offset and quantity, or the data payload itself.
  • CRC16: Cyclic Redundancy Check for error detection (Little-endian: LSB first, MSB second).

RS485 Direction Logic

  • Transmit State: Set DE/RE pin HIGH. The UART shifts data out, and the transceiver converts TTL to a differential RS485 signal.
  • Receive State: Set DE/RE pin LOW. The transceiver converts incoming differential signals to TTL, feeding the STM32's UART RX line.

Source Code

UART & RS485 Initialization

#include "stm32f1xx_hal.h"

extern UART_HandleTypeDef huart1;
UART_HandleTypeDef *mb_port = &huart1;

uint8_t mb_rx_fifo[256];
uint8_t mb_tx_fifo[256];

void MB_GPIO_Init(void) {
    GPIO_InitTypeDef cfg = {0};
    cfg.Pin = GPIO_PIN_0;
    cfg.Mode = GPIO_MODE_OUTPUT_PP;
    cfg.Speed = GPIO_SPEED_FREQ_HIGH;
    HAL_GPIO_Init(GPIOB, &cfg);
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET); // Default to Receive
}

void MB_UART_Init(uint32_t baud) {
    huart1.Instance = USART1;
    huart1.Init.BaudRate = baud;
    huart1.Init.WordLength = UART_WORDLENGTH_8B;
    huart1.Init.StopBits = UART_STOPBITS_1;
    huart1.Init.Parity = UART_PARITY_NONE;
    huart1.Init.Mode = UART_MODE_TX_RX;
    huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE;
    HAL_UART_Init(&huart1);
    HAL_UART_Receive_IT(mb_port, mb_rx_fifo, 1);
}

CRC16 Calculation

Modbus RTU utilizes the CRC16-IBM algorithm using the polynomial 0xA001:

uint16_t Compute_CRC16(const uint8_t *pMsg, uint16_t size) {
    uint16_t checksum = 0xFFFF;
    uint16_t byte_idx;
    uint8_t bit_idx;

    for (byte_idx = 0; byte_idx < size; byte_idx++) {
        checksum ^= (uint16_t)pMsg[byte_idx];
        for (bit_idx = 0; bit_idx < 8; bit_idx++) {
            if (checksum & 1) {
                checksum = (checksum >> 1) ^ 0xA001;
            } else {
                checksum >>= 1;
            }
        }
    }
    return checksum;
}

Master Node Logic

The master actively queries slaves by building and transmitting frames:

void MB_Master_Transmit(uint8_t target_id, uint8_t op_code, uint16_t start_reg, uint16_t quantity, const uint16_t *payload) {
    uint16_t idx = 0;
    mb_tx_fifo[idx++] = target_id;
    mb_tx_fifo[idx++] = op_code;

    if (op_code == 0x03) { // Read Holding Registers
        mb_tx_fifo[idx++] = (start_reg >> 8) & 0xFF;
        mb_tx_fifo[idx++] = start_reg & 0xFF;
        mb_tx_fifo[idx++] = (quantity >> 8) & 0xFF;
        mb_tx_fifo[idx++] = quantity & 0xFF;
    } else if (op_code == 0x06) { // Write Single Register
        mb_tx_fifo[idx++] = (start_reg >> 8) & 0xFF;
        mb_tx_fifo[idx++] = start_reg & 0xFF;
        mb_tx_fifo[idx++] = (payload[0] >> 8) & 0xFF;
        mb_tx_fifo[idx++] = payload[0] & 0xFF;
    }

    uint16_t crc = Compute_CRC16(mb_tx_fifo, idx);
    mb_tx_fifo[idx++] = crc & 0xFF;          // CRC Low
    mb_tx_fifo[idx++] = (crc >> 8) & 0xFF;   // CRC High

    // Switch to TX mode
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET);
    HAL_UART_Transmit(mb_port, mb_tx_fifo, idx, 100);
    // Wait for shift register to empty before switching to RX
    HAL_Delay(1); 
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET);
}

uint8_t MB_Master_ParseResponse(uint8_t expected_id, uint16_t *out_data) {
    // Blocking receive for demonstration
    HAL_UART_Receive(mb_port, mb_rx_fifo, 256, 100);

    if (mb_rx_fifo[0] != expected_id || mb_rx_fifo[1] != 0x03) return 0;

    uint16_t frame_len = mb_rx_fifo[2] + 5; // Addr + FC + ByteCount + Data + CRC
    uint16_t recv_crc = (mb_rx_fifo[frame_len-1] << 8) | mb_rx_fifo[frame_len-2];
    if (recv_crc != Compute_CRC16(mb_rx_fifo, frame_len-2)) return 0;

    uint8_t num_bytes = mb_rx_fifo[2];
    for (uint8_t i = 0; i < num_bytes / 2; i++) {
        out_data[i] = (mb_rx_fifo[3 + 2*i] << 8) | mb_rx_fifo[4 + 2*i];
    }
    return num_bytes / 2;
}

Slave Node Logic

The slave passively processes incoming requests and formulates replies:

uint16_t slave_register_map[10] = {0}; // Emulated holding registers

void MB_Slave_HandleFrame(uint8_t *incoming_data, uint16_t frame_size) {
    uint8_t src_id = incoming_data[0];
    uint8_t op_code = incoming_data[1];

    uint16_t recv_crc = (incoming_data[frame_size-1] << 8) | incoming_data[frame_size-2];
    if (recv_crc != Compute_CRC16(incoming_data, frame_size-2)) return;

    uint16_t reply_idx = 0;
    
    if (op_code == 0x03) { // Read Holding
        uint16_t target_reg = (incoming_data[2] << 8) | incoming_data[3];
        uint16_t reg_qty = (incoming_data[4] << 8) | incoming_data[5];
        
        mb_tx_fifo[reply_idx++] = src_id;
        mb_tx_fifo[reply_idx++] = op_code;
        mb_tx_fifo[reply_idx++] = reg_qty * 2;
        
        for (uint16_t i = 0; i < reg_qty; i++) {
            mb_tx_fifo[reply_idx++] = (slave_register_map[target_reg + i] >> 8) & 0xFF;
            mb_tx_fifo[reply_idx++] = slave_register_map[target_reg + i] & 0xFF;
        }
    } else if (op_code == 0x06) { // Write Single
        uint16_t target_reg = (incoming_data[2] << 8) | incoming_data[3];
        uint16_t val = (incoming_data[4] << 8) | incoming_data[5];
        slave_register_map[target_reg] = val;
        
        // Echo the request frame as the response
        mb_tx_fifo[reply_idx++] = src_id;
        mb_tx_fifo[reply_idx++] = op_code;
        mb_tx_fifo[reply_idx++] = (target_reg >> 8) & 0xFF;
        mb_tx_fifo[reply_idx++] = target_reg & 0xFF;
        mb_tx_fifo[reply_idx++] = (val >> 8) & 0xFF;
        mb_tx_fifo[reply_idx++] = val & 0xFF;
    } else { // Exception: Illegal Function
        mb_tx_fifo[reply_idx++] = src_id;
        mb_tx_fifo[reply_idx++] = op_code | 0x80;
        mb_tx_fifo[reply_idx++] = 0x01; 
    }

    uint16_t crc = Compute_CRC16(mb_tx_fifo, reply_idx);
    mb_tx_fifo[reply_idx++] = crc & 0xFF;
    mb_tx_fifo[reply_idx++] = (crc >> 8) & 0xFF;

    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET);
    HAL_UART_Transmit(mb_port, mb_tx_fifo, reply_idx, 100);
    HAL_Delay(1);
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET);
}

Main Application Loop

#include "main.h"

int main(void) {
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    MX_USART1_UART_Init();

    MB_GPIO_Init();
    MB_UART_Init(9600);

    uint16_t sensor_vals[2];

    while (1) {
        // Master Operation: Read 2 registers from slave ID 1
        MB_Master_Transmit(0x01, 0x03, 0x0000, 0x0002, NULL);
        if (MB_Master_ParseResponse(0x01, sensor_vals) == 2) {
            // Process acquired sensor values
        }
        HAL_Delay(1000);
    }
}

Optimization and Validation

Configuration Parameters

  • Baud Rate: Standard rates include 9600, 19200, and 38400. Both master and slave must match. Ensure the MCU clock configuration yields a low UART error percentage (ideally <2%).
  • Data Format: 8-N-1 (8 data bits, No parity, 1 stop bit) is standard. For parity checks, 8-E-1 is also common.
  • Node Addressing: Assign a unique ID (1-247) to each slave. The master addresses them individually or uses 0 for broadcast.

Reliability Enhancements

  • Impedance Matching: Always install 120Ω termination resistors at the furthest endpoints of the trunk line to mitigate signal reflections.
  • Isolation: In noisy environments, integrate digital isolators (e.g., ADuM1201) between the STM32 and the transceiver to eliminate ground loop currents.
  • Timeout & Retry: Implement inter-frame delays (3.5 character times) and retry counters in the master logic to handle unresponsive slaves.

Testing Methodology

  • Software Simulation: Validate the STM32 slave stack using PC utilities like Modbus Poll. For testing STM32 master logic, configure Modbus Slave on a PC.
  • Hardware Verification: Attach an oscilloscope or logic analyzer to the A/B terminals to verify differential voltage levels (should be >200mV differential during active transmission) and confirm clean square waves free of ringing.

Tags: STM32 RS485 Modbus-RTU HAL-Library MAX485

Posted on Sat, 22 Aug 2026 16:41:26 +0000 by abgoosht