HomeUncategorizedSTM32 LWIP Modbus TCP Tutorial: Server Setup (Part 1)

STM32 Modbus TCP Server using LWIP – Part 1: Ethernet Setup and Basic Server

I have already covered Modbus TCP on STM32 before, but that series used the Mongoose networking library. This new series is built entirely on LWIP. We will configure STM32 as a Modbus server first, and later as a Modbus client too.

If you have not gone through the basics of the Modbus protocol yet, I would recommend reading my Modbus protocol introduction article first. It covers the ADU, the MBAP header, and the PDU in detail, and I will not repeat that explanation here.

In this first part, we will set up the STM32 Ethernet peripheral and build a basic Modbus TCP server. This server does not process any function code yet. It simply receives a request and replies with an exception, so we know the server itself is working. We will keep building on top of this same project as the series moves forward, adding function code handling one by one.

I am using the Nucleo H755 board for this series. The Ethernet configuration itself follows the same steps I used in my Ethernet configuration tutorial, so I will refer to that wherever the steps overlap.

STM32 Modbus TCP Server using LWIP – Part 1: Ethernet Setup and Basic Server

How the STM32 Modbus TCP Server Works

Before we start configuring the project in CubeMX, let’s understand how the pieces fit together. The server is built around three files, and each one has a specific job.

Project Files and Their Role

  • modbus.h — holds the function code definitions, exception codes, and the MB_Request_t structure used to store a parsed request.
  • Modbus_Server.c — sets up the TCP server itself. It creates the TCP control block, binds it to port 502, and registers the accept, receive, and error callbacks.
  • Modbus_Parser.c — parses the raw bytes received over TCP and decides what to do with them.

You can get these files after downloading the project from the end of this post. They are placed inside the Project Folder -> CM7 -> Drivers -> Modbus Folder.


Request-Response Flow

When a client connects, the accept callback fires first. It registers the receive and error callbacks on the new connection. From there, every time the client sends data, the receive callback runs.

Inside the receive callback, we copy the data out of the internal buffer into our own array. That array goes straight into the parser, which extracts the MBAP header and Protocol Data Unit (PDU), then moves on to process the function.

Since we are not handling any function code in today’s tutorial, the ProcessFunction will simply send an exception for all the requests received from the client.

The image below shows how a request moves through these callbacks, from the TCP accept stage to the exception response being sent back.

STM32 Modbus TCP server request flow diagram showing TCP accept, receive callback, parser, and exception response

STM32CubeMX Configuration for Modbus TCP

The Ethernet setup for this series follows the same pattern I used for the H745 Discovery board in my Ethernet configuration tutorial. I will go through the settings here as one block, since we will not touch these again once the project is generated.

Clock Configuration

I am using the internal HSI oscillator at 64 MHz to provide the clock. Using the PLL, we will run the system at a clock of 400 MHz.

STM32CubeMX clock configuration showing HSI oscillator and PLL set to 400 MHz system clock

Ethernet Configuration

Go to Connectivity -> Ethernet and set the mode to RMII. Nulceo H755 uses the RMII type connection as shown in the image from the schematics below.

STM32 Nucleo H755 Ethernet RMII pin connections from board schematic

CubeMX sometimes auto-assigns the wrong pins for Ethernet, so compare every pin against the schematic before moving on.

Open the Ethernet parameters and check the memory layout.

STM32CubeMX Ethernet parameters showing RX and TX DMA descriptor memory addresses

This is the same layout I explained in the Ethernet configuration tutorial:

  • RX DMA descriptors start at 0x30000000
  • TX DMA descriptors start at 0x30000080
  • RX buffers start at 0x30000100
  • Each RX buffer is 1536 bytes.

After RX Buffer, we can configure the memory for the LWIP Heap. The image below shows the memory layout for the Ethernet, covering the RX descriptors, TX descriptors, RX buffers, and where the LWIP heap begins.

STM32 D2 RAM memory layout diagram showing RX buffers and LWIP heap placement at 0x30004900

LWIP Setup (Static IP, Heap, PHY Settings)

Enable LWIP under Middleware for the Cortex-M7 core. The LWIP configuration is shown in the image below.

STM32CubeMX LWIP configuration showing static IP address, heap size, and PHY settings
  • Here we are going to disable the DHCP, and configure a static IP for our ethernet module. I have set the IP 192.168.1.100 for the board. Also set the Subnet Mask and Gateway address accordingly.
  • In the Key Option tab, I am using 10KB memory for the Heap. The location for this heap is defined as 0x30004900. This is the address where the memory occupied by RXBuffer ends.
  • In the Platform Settings tab, set the PHY as LAN8742.

Now the total memory Occupied by the Ethernet and LWIP together is a little over 28 KB. Out of this 256 bytes are occupied by the DMA Descriptors, 18 KB is occupied by the RXBuffer and 10 KB is occupied by the LWIP heap.

Note: The LWIP heap in CubeMX is often defaulted to 0x30004000, but based on the DMA descriptor layout (RxDescriptor at 0x30000000, TxDescriptor at 0x30000080, RxBuffer at 0x30000100 spanning ~18KB), the heap should start at 0x30004900 to avoid overlapping with the Rx buffer. For small projects this may not cause immediate problems, but it will in larger ones — set it correctly from the start.

MPU Configuration for Cortex-M7 (Cache Coherency Fix)

We have the DMA Descriptors in the SRAM Region. This is why we need to configure the MPU. This is a must for the Cortex-M7 devices, or else you will get hardfault.

We have set up everything in the SRAM (0x30000000). The complete memory structure is shown in the image below.

STM32 SRAM memory structure showing Ethernet DMA descriptors, RX buffers, and LWIP heap in RAM D2

Below is the image showing the MPU configuration for the above Region. Make sure to enable the Speculation Mode, and Instruction and Data cache as well.

STM32CubeMX MPU configuration for Cortex-M7 showing 32 KB non-cacheable memory region
  • Here I have selected the 32 KB region so that it will cover our total RAM region, which is around 28 KB.
  • The rest of the configuration is to set the region as non-cacheable region.
  • This would prevent the cache coherency issue between the CPU and the DMA.
  • This is explained in the cortex M7 playlist, so do check that out.

PC Network Configuration (Direct Connection)

If you are connecting the STM32 board to the Router, there is nothing you need to do at the computer end. But if you are connecting the ethernet cable directly to the computer, you need to configure your computer’s ethernet as per the images shown below.

Below is the configuration for a Windows computer.

Windows Ethernet adapter IP configuration for direct connection to STM32 Nucleo board

Below is the configuration for Mac.

Mac Ethernet network settings for direct connection to STM32 Nucleo board

UART Configuration for Logging

Next, enable UART3 in Asynchronous mode. On the Nucleo H755, PD8 and PD9 connect to the ST-Link virtual COM port, therefore we need to assign these pins for the UART3.

PD8 and PD9 of USART3 connected to the ST-Link virtual COM port.

Set the baud rate to 115200, 8-bit word length, no parity, one stop bit.

STM32 UART configuration for serial logging

STM32 Modbus TCP Code and Result

Now that we have configured the CubeMX, let’s proceed with implementing the Modbus server on STM32. CubeMX does not generate everything automatically, and the DMA descriptor sections are not placed in D2 RAM on their own.

Once the project is generated, open the LWIP -> Target -> ethernetif.c file. Here you will some memory locations that needs to be defined in the flash script file.

Ethernet DMA Descriptors defined in ethernet_if.c file

We need to define these memory locations in the flash script file, as per the configuration done in the cubeMX.

Below is the code we need to place in the STM32H755ZITX_FLASH.ld file. These definitions are as per the configuration in the CubeMX. If you are using some other development board, check out the Ethernet Configuration Article.

    .lwip_sec (NOLOAD) : {
    . = ABSOLUTE(0x30000000);
    *(.RxDescripSection)
    
    . = ABSOLUTE(0x30000080);
    *(.TxDescripSection)
    
    . = ABSOLUTE(0x30000100);
    *(.Rx_PoolSection) 
  } >RAM_D2

CubeMX only generates the LWIP initialization call — it does not generate the code that keeps LWIP running. Add MX_LWIP_Process() inside the infinite while loop:

while (1)
{
    MX_LWIP_Process();
}

We also need a custom _write function so printf output is routed through UART3 — this is what lets us view Modbus logs on a serial terminal.

int _write(int fd, unsigned char *buf, int len) {
  if (fd == 1 || fd == 2) {
    HAL_UART_Transmit(&huart3, buf, len, 999);
  }
  return len;
}

Modbus Driver Code

The final project (which you can download from the end of this post) contains the modbus files. They are placed inside the Project Folder -> CM7 -> Drivers -> Modbus Folder. Let’s understand what each file does.

Modbus.h

modbus.h holds everything shared across the driver — the port number, the function code definitions, the exception codes, and the MB_Request_t structure that stores a parsed request:

#define MB_TCP_PORT                     502

#define MB_FC_READ_COILS            0x01
#define MB_FC_READ_HOLDING_REGS     0x03
/* ...remaining function codes... */

#define MB_EX_ILLEGAL_FUNCTION        0x01
/* ...remaining exception codes... */

typedef struct
{
    uint16_t TransactionID;
    uint16_t ProtocolID;
    uint16_t Length;
    uint8_t  UnitID;
    uint8_t  FunctionCode;
    uint8_t *Data;
    uint16_t DataLength;
} MB_Request_t;

Modbus_Server.c

Modbus_Server.c file contains all the TCP related functions.

Modbus_Server_Init sets up the TCP server. It creates a new TCP control block with tcp_new, binds it to port 502 with tcp_bind, puts it into listening mode with tcp_listen, and registers tcp_server_accept as the accept callback:

err_t Modbus_Server_Init(void)
{
    struct tcp_pcb *tpcb = tcp_new();

    tcp_bind(tpcb, IP_ADDR_ANY, MB_TCP_PORT);
    tpcb = tcp_listen(tpcb);
    tcp_accept(tpcb, tcp_server_accept);

    return ERR_OK;
}

tcp_server_accept fires the moment a client connects. Its only job here is to register two more callbacks on the new connection — one for receiving data, one for errors:

static err_t tcp_server_accept(void *arg, struct tcp_pcb *newpcb, err_t err)
{
    tcp_setprio(newpcb, TCP_PRIO_MIN);
    tcp_recv(newpcb, tcp_server_recv);
    tcp_err(newpcb, tcp_server_error);

    return ERR_OK;
}

tcp_server_recv runs every time the client sends data. It calls tcp_recved to acknowledge the received bytes, then pbuf_copy_partial to pull the data out of the pbuf into a local rxBuf. That buffer goes straight into MB_Parser:

static err_t tcp_server_recv(void *arg, struct tcp_pcb *tpcb, struct pbuf *p, err_t err)
{
    uint8_t rxBuf[260];

    if((err != ERR_OK) || (p == NULL))
    {
        if(p != NULL) pbuf_free(p);
        tcp_server_connection_close(tpcb);
        return ERR_OK;
    }

    tcp_recved(tpcb, p->tot_len);
    pbuf_copy_partial(p, rxBuf, p->tot_len, 0);
    MB_Parser(tpcb, rxBuf, p->tot_len);

    pbuf_free(p);
    return ERR_OK;
}

tcp_server_error does nothing right now — it just needs to exist because lwIP requires an error callback to be registered.

static void tcp_server_error(void *arg, err_t err)
{
	LWIP_UNUSED_ARG(arg);
	LWIP_UNUSED_ARG(err);
}

Modbus_Parser.c

Modbus_Parser.c contains the Modbus related fuctions. This file connects the modbus layer to the TCP functions defined in the Modbus_Server.c file.

The function MB_Parser is the entry point called from tcp_server_recv. It first calls MB_ParseRequest to extract the MBAP header, then hands the parsed request to MB_ProcessFunction:

err_t MB_Parser(struct tcp_pcb *pcb, uint8_t *rxBuf, uint16_t length)
{
    MB_Request_t req;

    if(MB_ParseRequest(rxBuf, length, &req) != ERR_OK)
        return ERR_VAL;

    return MB_ProcessFunction(pcb, &req);
}

MB_ParseRequest reads the transaction ID, protocol ID, length, unit ID, and function code from the first 8 bytes, and points Data to whatever follows:

static err_t MB_ParseRequest(uint8_t *rxBuf, uint16_t length, MB_Request_t *req)
{
    if(length < 8) return ERR_VAL;

    req->TransactionID = ((uint16_t)rxBuf[0] << 8) | rxBuf[1];
    req->ProtocolID    = ((uint16_t)rxBuf[2] << 8) | rxBuf[3];
    req->Length        = ((uint16_t)rxBuf[4] << 8) | rxBuf[5];
    req->UnitID        = rxBuf[6];
    req->FunctionCode  = rxBuf[7];

    req->Data = &rxBuf[8];
    req->DataLength = length - 8;

    if(req->ProtocolID != 0) return ERR_VAL;

    return ERR_OK;
}

MB_ProcessFunction is where every function code will eventually be handled. Right now it has no case for any function code, so it always falls into default and sends an exception:

static err_t MB_ProcessFunction(struct tcp_pcb *pcb, MB_Request_t *req)
{
    switch(req->FunctionCode)
    {
        default:
            return MB_SendException(pcb, req, MB_EX_ILLEGAL_FUNCTION);
    }
}

MB_SendException builds the response. It follows the same layout as a normal Modbus TCP response, transaction ID, protocol ID, length, unit ID. Except the function code is OR’ed with 0x80 to mark it as an exception, and the last byte is the exception code itself:

static err_t MB_SendException(struct tcp_pcb *pcb, MB_Request_t *req, uint8_t exception)
{
    uint8_t tx[9];

    tx[0] = req->TransactionID >> 8;
    tx[1] = req->TransactionID;
    tx[2] = 0;
    tx[3] = 0;
    tx[4] = 0;
    tx[5] = 3;
    tx[6] = req->UnitID;
    tx[7] = req->FunctionCode | 0x80;
    tx[8] = exception;

    tcp_write(pcb, tx, sizeof(tx), TCP_WRITE_FLAG_COPY);
    tcp_output(pcb);

    return ERR_OK;
}

If the client asks for function code 3, this response goes back with function code 0x83. That is how the client knows the exception was sent for the function code 3.


main.c file

Inside the main file, we will simply initialise the Modbus Server after all the peripherals has been initialised. We call MX_LWIP_Process inside the infinite loop and this takes care of the rest.

#include "Modbus_Server.h"

int main(void)
{
    /* ... existing HAL, clock, and Ethernet init ... */

    Modbus_Server_Init();

    while (1)
    {
        MX_LWIP_Process();
    }
}

Testing with Simply Modbus TCP Client

Flash the project and open a serial terminal on the ST-Link virtual COM port at 115200 baud. On reset, the log should confirm the server is bound and listening on port 502.

STM32 serial terminal log showing Modbus TCP server initialized and listening on port 502

Open Simply Modbus TCP Client, and here we will request some data from the Server. Configure the Client as follows:

Simply Modbus TCP Client configuration showing IP address, port, slave ID, and function code
  • Mode: TCP
  • Port: 502 -> the server is listening on this port
  • IP Addr: 192.168.1.100 -> IP address of the server
  • Slave ID: 9 -> this is just the slave address and does not affect the response at this stage
  • Function Code: 3 -> Read Holding Registers stored on the Server
  • First Register: 40001 -> the offset maps this to register 0 on the server
  • Number of Reg: 10 -> client is requesting data for the10 registers

Request generated by the client:

Modbus TCP request frame generated by Simply Modbus client showing transaction ID, unit ID, and function code

The request generated by the client is as follows:

  • 00 01 -> Transaction ID for this particular Transaction
  • 00 00 -> Protocol ID for Modbus TCP (always 0)
  • 00 06 -> Length of the remaining data in Bytes
  • 09 -> Unit ID of the Client
  • 03 -> Function Code to Read Holding Registers
  • 00 00 -> Start Address of the Holding Register
  • 00 0A -> Number of Holding Registers client wants to read

On receiving the request from the client, the STM32 logs the request and response on the serial console. This is shown in the image below.

STM32 UART log showing decoded Modbus TCP request and exception response sent to client

You can see the STM32 decoded the Modbus data correctly. It then sent a response to the client and the response is also logged on the console. Since we are not handling any function code yet, the server is designed to send the Exception for all the requests received rom the client.

The image below shows the response received by the client.

Simply Modbus TCP Client showing Illegal Function exception response received from STM32 server

You can see the Modbus Client received the Exception as a response. This implies that our Mosbus server is working fine. It is able to receive the request from the client, decode the header and data, and send the response back to the client.

We will start implementing the function codes starting from the next tutorial in this series.

STM32 Modbus TCP Server using LWIP — Video Tutorial

This video walks through setting up the STM32 Ethernet peripheral and building a basic Modbus TCP server using the LWIP stack. We configure Ethernet, LWIP, and the MPU in CubeMX, then implement a server that parses incoming Modbus requests and responds with a Modbus exception — the foundation for the rest of this Modbus TCP series.

STM32 Modbus TCP Server – Frequently Asked Questions

Conclusion

In this part, we set up the STM32 Ethernet peripheral using LWIP and built a Modbus TCP server that responds to any request with an Illegal Function exception. The server correctly binds to port 502, accepts a client connection, parses the incoming MBAP header and PDU, and sends back a properly structured response.

This project forms the base for the rest of the series. In the next part, we will add function code handling for reading registers — both holding registers and input registers. After that, we will move on to writing registers and coils. The Ethernet and Modbus server code covered here will not change; we will keep extending it as we go.

Download STM32 Modbus TCP Server Setup Project

Open source CubeMX project files and HAL source code, tested on real hardware. Free to use — support the work if it helped you.

Open source CubeMX + HAL source

Browse More STM32 Modbus TCP Tutorials

About the Author
Arun Rawat
Arun Rawat
Embedded Systems Engineer · Founder, ControllersTech

Arun is an embedded systems engineer with 10+ years of experience in STM32, ESP32, and AVR microcontrollers. He created ControllersTech to share practical tutorials on embedded software, HAL drivers, RTOS, and hardware design — grounded in real industrial automation experience.

Subscribe
Notify of

0 Comments
Newest
Oldest Most Voted
×

Don’t Miss Future STM32 Tutorials

Join thousands of developers getting free guides, code examples, and updates.