Part 3 of the guide moves beyond basic link verification and introduces practical Modbus RTU transactions in which the master issues read requests to the STM32-based slave. This section focuses on implementing and validating standard read function codes, register addressing, and response formatting so the emulated sensor can return meaningful data in a fully protocol-compliant manner which will be covered next.
In this guide, we shall cover the following:
- Master Request to Read.
- Firmware Development.
- Testing and Results.
1. Master Request to Read:
When the master request to read data, the data frame as following:

- First byte is the address of the slave.
- Second byte is the function code.
- Third and fourth bytes are the register address high and low byte respectively.
- Fifth and sixth bytes are the number of points high and low byte respectively.
- The fifth and last are the CRC ow and high byte respectively.
This is the typical frame for master request to read from slave.
Related course
Make every Modbus frame deliberate
Connect the read-frame layout to RS-485 bus ownership, the Modbus register model, RTU timing and CRC validation. The course develops the transport and framing foundations using bare-metal STM32, complementing the HAL-based examples in this article.
2. Firmware Development:
First, we shall create header and source file for crc calculation.
Create header and and source file with name of modbus_crc.h and modbus_crc.c respectively as follows:

In modbus_crc.h file, within headerfile guard, declare the following function:
uint16_t crc16(uint8_t *buffer, uint16_t buffer_length);
This function shall calculate the CRC value on the fly.
Hence, the entire header file as follows:
#ifndef INC_MODBUS_CRC_H_ #define INC_MODBUS_CRC_H_ uint16_t crc16(uint8_t *buffer, uint16_t buffer_length); #endif /* INC_MODBUS_CRC_H_ */
Next, in modbus_crc, include the following header files:
#include "stdint.h" #include "modbus_crc.h"
Declare the following crc high table:
static const uint8_t table_crc_hi[] = {
0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0,
0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41,
0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0,
0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40,
0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1,
0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41,
0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1,
0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41,
0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0,
0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40,
0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1,
0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40,
0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0,
0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40,
0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0,
0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40,
0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0,
0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41,
0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0,
0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41,
0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0,
0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40,
0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1,
0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41,
0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0,
0x80, 0x41, 0x00, 0xC1, 0x81, 0x40
};For the low:
static const uint8_t table_crc_lo[] = {
0x00, 0xC0, 0xC1, 0x01, 0xC3, 0x03, 0x02, 0xC2, 0xC6, 0x06,
0x07, 0xC7, 0x05, 0xC5, 0xC4, 0x04, 0xCC, 0x0C, 0x0D, 0xCD,
0x0F, 0xCF, 0xCE, 0x0E, 0x0A, 0xCA, 0xCB, 0x0B, 0xC9, 0x09,
0x08, 0xC8, 0xD8, 0x18, 0x19, 0xD9, 0x1B, 0xDB, 0xDA, 0x1A,
0x1E, 0xDE, 0xDF, 0x1F, 0xDD, 0x1D, 0x1C, 0xDC, 0x14, 0xD4,
0xD5, 0x15, 0xD7, 0x17, 0x16, 0xD6, 0xD2, 0x12, 0x13, 0xD3,
0x11, 0xD1, 0xD0, 0x10, 0xF0, 0x30, 0x31, 0xF1, 0x33, 0xF3,
0xF2, 0x32, 0x36, 0xF6, 0xF7, 0x37, 0xF5, 0x35, 0x34, 0xF4,
0x3C, 0xFC, 0xFD, 0x3D, 0xFF, 0x3F, 0x3E, 0xFE, 0xFA, 0x3A,
0x3B, 0xFB, 0x39, 0xF9, 0xF8, 0x38, 0x28, 0xE8, 0xE9, 0x29,
0xEB, 0x2B, 0x2A, 0xEA, 0xEE, 0x2E, 0x2F, 0xEF, 0x2D, 0xED,
0xEC, 0x2C, 0xE4, 0x24, 0x25, 0xE5, 0x27, 0xE7, 0xE6, 0x26,
0x22, 0xE2, 0xE3, 0x23, 0xE1, 0x21, 0x20, 0xE0, 0xA0, 0x60,
0x61, 0xA1, 0x63, 0xA3, 0xA2, 0x62, 0x66, 0xA6, 0xA7, 0x67,
0xA5, 0x65, 0x64, 0xA4, 0x6C, 0xAC, 0xAD, 0x6D, 0xAF, 0x6F,
0x6E, 0xAE, 0xAA, 0x6A, 0x6B, 0xAB, 0x69, 0xA9, 0xA8, 0x68,
0x78, 0xB8, 0xB9, 0x79, 0xBB, 0x7B, 0x7A, 0xBA, 0xBE, 0x7E,
0x7F, 0xBF, 0x7D, 0xBD, 0xBC, 0x7C, 0xB4, 0x74, 0x75, 0xB5,
0x77, 0xB7, 0xB6, 0x76, 0x72, 0xB2, 0xB3, 0x73, 0xB1, 0x71,
0x70, 0xB0, 0x50, 0x90, 0x91, 0x51, 0x93, 0x53, 0x52, 0x92,
0x96, 0x56, 0x57, 0x97, 0x55, 0x95, 0x94, 0x54, 0x9C, 0x5C,
0x5D, 0x9D, 0x5F, 0x9F, 0x9E, 0x5E, 0x5A, 0x9A, 0x9B, 0x5B,
0x99, 0x59, 0x58, 0x98, 0x88, 0x48, 0x49, 0x89, 0x4B, 0x8B,
0x8A, 0x4A, 0x4E, 0x8E, 0x8F, 0x4F, 0x8D, 0x4D, 0x4C, 0x8C,
0x44, 0x84, 0x85, 0x45, 0x87, 0x47, 0x46, 0x86, 0x82, 0x42,
0x43, 0x83, 0x41, 0x81, 0x80, 0x40
};Next, the function shall calculate the crc value as follows:
uint16_t crc16(uint8_t *buffer, uint16_t buffer_length)
{
uint8_t crc_hi = 0xFF; /* high CRC byte initialized */
uint8_t crc_lo = 0xFF; /* low CRC byte initialized */
unsigned int i; /* will index into CRC lookup */
/* pass through message buffer */
while (buffer_length--) {
i = crc_lo ^ *buffer++; /* calculate the CRC */
crc_lo = crc_hi ^ table_crc_hi[i];
crc_hi = table_crc_lo[i];
}
return (crc_hi << 8 | crc_lo);
}This function computes a 16-bit Modbus CRC (CRC-16/IBM) over a byte buffer, which is mandatory for Modbus RTU frames to detect transmission errors.
Here is what each part does:
Function Purpose
uint16_t crc16(uint8_t *buffer, uint16_t buffer_length)
Takes:
buffer→ pointer to the data bytesbuffer_length→ number of bytes to process
Returns:
- The final 16-bit CRC value appended to Modbus frames.
CRC Initialization
uint8_t crc_hi = 0xFF; uint8_t crc_lo = 0xFF;
Modbus RTU specifies that the CRC register starts at 0xFFFF, so both high and low bytes are initialized to 0xFF.
Processing Each Byte
while (buffer_length--) {
Iterates through every byte in the message.
Index Calculation
i = crc_lo ^ *buffer++;
The incoming data byte is XORed with the current low CRC byte.
The result forms an index into the lookup tables.
This avoids doing slow bit-by-bit polynomial calculations.
Lookup Table Update
crc_lo = crc_hi ^ table_crc_hi[i]; crc_hi = table_crc_lo[i];
Two precomputed tables are used:
table_crc_hi[]table_crc_lo[]
They contain the pre-calculated CRC effects for all 256 possible byte values.
The new CRC:
crc_lobecomes the old high byte XORed with a lookup value.crc_hibecomes the corresponding lookup value.
This updates the CRC register for the processed byte.
Final Return Value
return (crc_hi << 8 | crc_lo);
Combines the two bytes into a 16-bit result:
CRC = (high_byte << 8) | low_byte
Summary
This is a table-driven Modbus CRC-16 implementation that:
✔ Starts with 0xFFFF
✔ Processes each byte using XOR + lookup tables
✔ Produces the standard Modbus CRC
✔ Runs much faster than bitwise CRC algorithm.
Next, create new header and source file with name of modbus.h and modbus.h respectively as follows:

In the header file, we start by including the following header files:
#include "main.h" #include "stdint.h"
Next, declare a data structure to hold the following:
- Slave address.
- Function code.
- Register address.
- Number of points.
typedef struct
{
uint8_t Slave_Address;
uint8_t Function_Code;
uint16_t Register_Address;
uint16_t Number_of_Data;
}modbusTypedefStruct;Next, the function that send the master request to read frame as follows:
void Modbus_ReadRequest(modbusTypedefStruct * ModbusFrame);
Hence, the entire header file as follows:
#ifndef INC_MODBUS_H_
#define INC_MODBUS_H_
#include "main.h"
#include "stdint.h"
typedef struct
{
uint8_t Slave_Address;
uint8_t Function_Code;
uint16_t Register_Address;
uint16_t Number_of_Data;
}modbusTypedefStruct;
void Modbus_ReadRequest(modbusTypedefStruct * ModbusFrame);
#endif /* INC_MODBUS_H_ */Next, in modbus.c file, we start by including the following:
#include <modbus.h> #include "modbus_crc.h"
Next, declare the serial instant that shall be used, USART1 in this case:
extern UART_HandleTypeDef huart1;
Note: It needed to be defined as external since it has been declared in the main.c file.
Next, the function that will send the frame as follows:
void Modbus_ReadRequest(modbusTypedefStruct * ModbusFrame)
{
uint8_t address = ModbusFrame->Slave_Address;
uint8_t FunCode = ModbusFrame->Function_Code;
uint16_t regAdd= ModbusFrame->Register_Address;
uint16_t numData= ModbusFrame->Number_of_Data;
uint8_t buffer[8];
buffer[0]=address;
buffer[1]=FunCode;
buffer[2]=(regAdd>>8)&0xFF;
buffer[3]=(regAdd)&0xFF;
buffer[4]= (numData>>8)&0xFF;
buffer[5]= (numData)&0xFF;
uint16_t tmp_crc=crc16(buffer,6);
buffer[6]=tmp_crc&0xFF;
buffer[7]=(tmp_crc>>8)&0xFF;
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET);
HAL_UART_Transmit(&huart1, buffer, 8, 100);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_RESET);
}This function builds and transmits a Modbus RTU read request frame from a master to a slave over UART using RS-485.
It extracts the slave address, function code, register address, and number of registers from the ModbusFrame structure, then places them into an 8-byte buffer following the Modbus RTU format. A CRC-16 is calculated over the first six bytes and appended in low-byte-first order, as required by Modbus. Before transmission, GPIOA pin 0 is set high to enable the RS-485 driver (transmit mode), the frame is sent using HAL_UART_Transmit, and the pin is cleared afterward to return the transceiver to receive mode.
Hence, the source file as follows:
#include <modbus.h>
#include "modbus_crc.h"
extern UART_HandleTypeDef huart1;
void Modbus_ReadRequest(modbusTypedefStruct * ModbusFrame)
{
uint8_t address = ModbusFrame->Slave_Address;
uint8_t FunCode = ModbusFrame->Function_Code;
uint16_t regAdd= ModbusFrame->Register_Address;
uint16_t numData= ModbusFrame->Number_of_Data;
uint8_t buffer[8];
buffer[0]=address;
buffer[1]=FunCode;
buffer[2]=(regAdd>>8)&0xFF;
buffer[3]=(regAdd)&0xFF;
buffer[4]= (numData>>8)&0xFF;
buffer[5]= (numData)&0xFF;
uint16_t tmp_crc=crc16(buffer,6);
buffer[6]=tmp_crc&0xFF;
buffer[7]=(tmp_crc>>8)&0xFF;
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET);
HAL_UART_Transmit(&huart1, buffer, 8, 100);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_RESET);
}
Thats all for the firmware.
3. Testing and Results:
In main.c, we shall start by including the following header file:
#include "modbus.h"
Next, declare the data structure as follows:
modbusTypedefStruct DataFrame;
Next, in user code begin 2 in main function, populate the data structure with values, the following is for example. Later, a proper values shall be used.
DataFrame.Slave_Address=0x11; DataFrame.Function_Code=0x03; DataFrame.Register_Address=0x01; DataFrame.Number_of_Data=0x02;
In while 1 loop, in user code begin 3, transmit the data each 100ms as follows:
Modbus_ReadRequest(&DataFrame); HAL_Delay(100);
Save, build and run the project as follows:

By probing the AB pins of the RS485, you should get the following:

Note: Values are in hex.
Next, we shall develop the firmware to response to request to read by following one of the sensor documentation.
Stay tuned.
Happy coding 😉



Discussion