在工业自动化、物联网设备通信中,Modbus 协议因 “简单可靠、兼容性强” 成为事实标准 —— 它无需复杂硬件支持,仅通过串口(RS485/RS232)即可实现设备间数据交互,广泛应用于传感器、PLC、单片机等嵌入式设备。本文从协议核心原理出发,手把手教你实现 STM32 与传感器的 Modbus RTU 通信,覆盖从机(数据上传)和主机(数据采集)双场景开发。
一、Modbus 核心认知:先搞懂 3 个关键问题
1. 为什么嵌入式优先选 Modbus RTU?
Modbus 协议有 3 种主流变体,嵌入式场景中RTU 模式是首选:
| 协议类型 | 传输介质 | 特点 | 适用场景 |
|---|---|---|---|
| Modbus RTU | RS485/RS232 串口 | 二进制传输,效率高、抗干扰强 | 工业现场、嵌入式设备通信 |
| Modbus ASCII | 串口 | 文本格式传输,易调试但效率低 | 短距离、低速率测试场景 |
| Modbus TCP | 以太网 | 基于 TCP/IP,适合网络通信 | 物联网网关、远程监控 |
嵌入式设备(如 STM32、ESP32)大多无以太网接口,但标配串口,且工业现场需要抗干扰能力,因此 Modbus RTU 成为嵌入式通信的 “黄金选择”。
2. Modbus RTU 的通信逻辑(主从架构)
Modbus 是典型的 “主从通信” 模式,核心规则:
-
主机(Master):主动发起请求(如读取传感器数据),同一总线只能有 1 个主机;
-
从机(Slave):被动响应请求(如返回温度数据),可多个从机(地址 1-247);
-
通信流程:主机发送带从机地址的指令 → 对应从机接收并处理 → 从机返回响应数据 → 主机解析数据。
3. 核心数据格式(RTU 帧结构)
Modbus RTU 帧是二进制格式,每个字节 8 位,无起始 / 停止字符,靠时间间隔(>3.5 个字符时间)分隔帧,完整结构:
| 字段 | 字节数 | 作用 | 示例值 |
|---|---|---|---|
| 从机地址(Slave Address) | 1 | 标识目标从机(1-247,0 为广播) | 0x01(1 号从机) |
| 功能码(Function Code) | 1 | 指令类型(如读 / 写寄存器) | 0x03(读取保持寄存器) |
| 数据域(Data) | N | 指令参数 / 响应数据 | 0x00 0x01 0x00 0x02(读寄存器地址 0x0001,长度 2) |
| CRC 校验(CRC) | 2 | 帧完整性校验(低字节在前) | 0x85 0x39(示例 CRC 值) |
常用功能码(嵌入式必备)
| 功能码 | 名称 | 用途 |
|---|---|---|
| 0x01 | 读取线圈状态 | 控制类设备(如继电器开关) |
| 0x03 | 读取保持寄存器 | 读取传感器数据(如温度、湿度) |
| 0x06 | 写入单个保持寄存器 | 控制从机参数(如设定阈值) |
| 0x10 | 写入多个保持寄存器 | 批量控制从机(如多通道配置) |
二、嵌入式 Modbus RTU 实战:STM32 从机开发(数据上传)
以 STM32F103C8T6 为例,实现 “从机采集电位器数据,响应主机读取请求”,全程硬件 + 代码落地。
1. 硬件准备(最小系统)
核心器件
-
主控:STM32F103C8T6 最小系统板(1 块);
-
通信模块:RS485 转 TTL 模块(如 MAX485,1 块);
-
传感器 / 输入:10K 电位器(模拟传感器数据);
-
辅助工具:USB 转 TTL 模块(调试用)、杜邦线、5V 电源。
硬件接线(关键!接错无法通信)
| STM32 引脚 | MAX485 模块 | 其他设备 |
|---|---|---|
| PA9(USART1_TX) | DI | - |
| PA10(USART1_RX) | RO | - |
| PA8 | DE、RE | 高电平 = 发送,低电平 = 接收(统一控制) |
| 3.3V | VCC | - |
| GND | GND | 电位器 GND、USB 转 TTL GND(共地!) |
| PA0(ADC1_IN0) | - | 电位器中间引脚 |
| 3.3V | - | 电位器一端 |
| GND | - | 电位器另一端 |
关键注意:MAX485 的 DE 和 RE 引脚必须短接后由 STM32 控制,通信时需切换 “发送 / 接收” 状态;所有设备必须共地,否则会出现数据乱码。
2. 软件环境
-
开发工具:STM32CubeMX 6.8.1 + Keil MDK 5.37;
-
调试工具:串口助手(如 SSCOM)、Modbus 主机测试工具(如 Modbus Poll)。
3. 代码实现(分模块开发)
步骤 1:STM32CubeMX 配置(基础外设)
-
新建工程,选择 “STM32F103C8T6”;
-
配置 RCC:选择 “Crystal/Ceramic Resonator”(外部晶振);
-
配置 USART1:
-
模式:Asynchronous(异步通信);
-
参数:波特率 9600、数据位 8、停止位 1、校验位 None(Modbus RTU 默认);
-
中断:使能 “USART1 global interrupt”(接收中断);
- 配置 GPIO:
-
PA8:输出模式(控制 MAX485 的 DE/RE),默认电平低(接收状态);
-
PA0:ADC1_IN0(采集电位器数据);
- 生成 Keil 工程(Toolchain/IDE 选择 MDK-ARM)。
步骤 2:核心代码实现(Modbus 从机协议栈)
在 Keil 工程中添加modbus_slave.c和modbus_slave.h文件,实现从机核心逻辑:
(1)头文件定义(modbus_slave.h)
\#ifndef \_\_MODBUS\_SLAVE\_H\#define \_\_MODBUS\_SLAVE\_H\#include "stm32f1xx\_hal.h"// Modbus从机地址(可修改为1-247)\#define SLAVE\_ADDR 0x01// 保持寄存器地址定义(模拟传感器数据存储)\#define REG\_POTENTIOMETER 0x0001 // 电位器数据寄存器(地址1)\#define REG\_TEMP 0x0002 // 温度数据寄存器(预留)// 保持寄存器数组(地址0对应REG\_POTENTIOMETER,地址1对应REG\_TEMP)extern uint16\_t holding\_regs\[2];// 函数声明void modbus\_slave\_init(UART\_HandleTypeDef \*huart); // 初始化void modbus\_slave\_rx\_handler(uint8\_t data); // 接收数据处理void modbus\_slave\_tx\_response(uint8\_t \*data, uint8\_t len); // 发送响应\#endif
(2)核心实现(modbus_slave.c)
\#include "modbus\_slave.h"\#include "crc16.h" // 需自行实现CRC16校验(下文提供)UART\_HandleTypeDef \*modbus\_huart;uint8\_t modbus\_rx\_buf\[256]; // 接收缓冲区uint8\_t modbus\_rx\_len = 0; // 接收数据长度uint16\_t holding\_regs\[2] = {0}; // 保持寄存器(初始化0)// CRC16校验(Modbus RTU标准)uint16\_t modbus\_crc16(uint8\_t \*data, uint8\_t len) {  uint16\_t crc = 0xFFFF;  for (uint8\_t i = 0; i ++) {  crc ^= data\[i];  for (uint8\_t j = 0; j {  if (crc & 0x0001) {  crc = (crc >> 1) ^ 0xA001;  } else {  crc >>= 1;  }  }  }  return crc;}// Modbus从机初始化(绑定串口)void modbus\_slave\_init(UART\_HandleTypeDef \*huart) {  modbus\_huart = huart;  // 开启串口接收中断(一次接收1字节)  HAL\_UART\_Receive\_IT(modbus\_huart, \&modbus\_rx\_buf\[modbus\_rx\_len], 1);}// 串口接收中断回调(每次接收1字节)void HAL\_UART\_RxCpltCallback(UART\_HandleTypeDef \*huart) {  if (huart == modbus\_huart) {  modbus\_rx\_len++;  // 缓冲区溢出保护(最大256字节)  if (modbus\_rx\_len ) {  HAL\_UART\_Receive\_IT(modbus\_huart, \&modbus\_rx\_buf\[modbus\_rx\_len], 1);  } else {  modbus\_rx\_len = 0; // 溢出重置  }  }}// 解析主机指令并生成响应void modbus\_parse\_cmd() {  uint8\_t response\_buf\[256];  uint8\_t response\_len = 0;  uint16\_t crc\_calc = 0;  // 1. 校验从机地址(是否匹配本机地址)  if (modbus\_rx\_buf\[0] != SLAVE\_ADDR && modbus\_rx\_buf\[0] != 0x00) {  modbus\_rx\_len = 0;  return;  }  // 2. 校验CRC(主机发送的CRC与计算的是否一致)  crc\_calc = modbus\_crc16(modbus\_rx\_buf, modbus\_rx\_len - 2);  uint16\_t crc\_rx = (modbus\_rx\_buf\[modbus\_rx\_len - 1] < | modbus\_rx\_buf\[modbus\_rx\_len - 2];  if (crc\_calc != crc\_rx) {  modbus\_rx\_len = 0;  return;  }  // 3. 处理功能码(重点实现0x03读取保持寄存器)  switch (modbus\_rx\_buf\[1]) {  case 0x03: // 读取保持寄存器  {  // 解析主机指令:寄存器起始地址(2字节)、寄存器数量(2字节)  uint16\_t reg\_addr = (modbus\_rx\_buf\[2] <) | modbus\_rx\_buf\[3];  uint16\_t reg\_num = (modbus\_rx\_buf\[4] < | modbus\_rx\_buf\[5];  // 校验寄存器地址和数量(仅支持地址1-2,数量1-2)  if (reg\_addr OMETER || reg\_addr + reg\_num > REG\_TEMP + 1 || reg\_num == 0) {  // 非法数据,返回异常响应(功能码+0x80,异常码0x02)  response\_buf\[0] = SLAVE\_ADDR;  response\_buf\[1] = 0x83;  response\_buf\[2] = 0x02;  response\_len = 3;  } else {  // 合法请求,生成响应数据  response\_buf\[0] = SLAVE\_ADDR;  response\_buf\[1] = 0x03;  response\_buf\[2] = reg\_num \* 2; // 数据长度(每个寄存器2字节)  response\_len = 3;  // 填充寄存器数据(高位在前,低位在后)  for (uint16\_t i = 0; i ++) {  response\_buf\[response\_len++] = (holding\_regs\[reg\_addr - REG\_POTENTIOMETER + i] >> 8) & 0xFF;  response\_buf\[response\_len++] = holding\_regs\[reg\_addr - REG\_POTENTIOMETER + i] & 0xFF;  }  }  break;  }  default: // 不支持的功能码,返回异常响应  response\_buf\[0] = SLAVE\_ADDR;  response\_buf\[1] = modbus\_rx\_buf\[1] | 0x80;  response\_buf\[2] = 0x01; // 异常码0x01(功能码未支持)  response\_len = 3;  break;  }  // 4. 计算响应帧CRC并添加到末尾  crc\_calc = modbus\_crc16(response\_buf, response\_len);  response\_buf\[response\_len++] = crc\_calc & 0xFF; // 低字节在前  response\_buf\[response\_len++] = (crc\_calc >> 8) & 0xFF;  // 5. 发送响应(切换MAX485为发送状态)  modbus\_slave\_tx\_response(response\_buf, response\_len);  // 6. 重置接收缓冲区  modbus\_rx\_len = 0;}// 发送Modbus响应(控制MAX485收发状态)void modbus\_slave\_tx\_response(uint8\_t \*data, uint8\_t len) {  // 切换MAX485为发送状态(PA8置高)  HAL\_GPIO\_WritePin(GPIOA, GPIO\_PIN\_8, GPIO\_PIN\_SET);  // 发送响应数据  HAL\_UART\_Transmit(modbus\_huart, data, len, 100);  // 等待发送完成  HAL\_Delay(1);  // 切换MAX485为接收状态(PA8置低)  HAL\_GPIO\_WritePin(GPIOA, GPIO\_PIN\_8, GPIO\_PIN\_RESET);}// 定时处理Modbus帧(需在main函数while循环中调用,周期10ms)void modbus\_slave\_process() {  static uint32\_t last\_rx\_time = 0;  // 检测是否有接收数据,且超过3.5个字符时间(9600波特率下约3.7ms)  if (modbus\_rx\_len > 0) {  if (HAL\_GetTick() - last\_rx\_time > 10) { // 留冗余,设10ms  modbus\_parse\_cmd(); // 解析指令  last\_rx\_time = HAL\_GetTick();  }  } else {  last\_rx\_time = HAL\_GetTick();  }}
(3)ADC 采集电位器数据(main.c 中添加)
\#include "modbus\_slave.h"// ADC采集函数(读取PA0电位器数据)uint16\_t adc\_read\_potentiometer() {  HAL\_ADC\_Start(\&hadc1);  HAL\_ADC\_PollForConversion(\&hadc1, 100);  return HAL\_ADC\_GetValue(\&hadc1);}int main(void) {  // CubeMX自动生成的初始化代码  HAL\_Init();  SystemClock\_Config();  MX\_GPIO\_Init();  MX\_USART1\_UART\_Init();  MX\_ADC1\_Init();  // Modbus从机初始化(绑定USART1)  modbus\_slave\_init(\&huart1);  while (1) {  // 1. 采集电位器数据(每100ms更新一次寄存器)  static uint32\_t adc\_update\_time = 0;  if (HAL\_GetTick() - adc\_update\_time > 100) {  holding\_regs\[0] = adc\_read\_potentiometer(); // 电位器数据存入寄存器1  adc\_update\_time = HAL\_GetTick();  }  // 2. 处理Modbus通信(每10ms调用一次)  modbus\_slave\_process();  }}
4. 调试与验证
步骤 1:硬件连接与供电
-
按接线图连接所有器件,确保 MAX485 的 DE/RE 接 PA8,所有设备共地;
-
用 USB 转 TTL 模块连接 STM32 的 USART1(PA9→TX,PA10→RX),用于调试。
步骤 2:主机测试工具配置(Modbus Poll)
-
下载安装 Modbus Poll(免费测试版);
-
配置通信参数:
-
点击 “Connection”→“Connect”;
-
选择 “Serial”,波特率 9600,数据位 8,停止位 1,校验位 None;
-
选择串口(USB 转 TTL 对应的 COM 口),点击 “OK”;
- 配置读取指令:
-
点击 “Setup”→“Read/Write Definition”;
-
功能码选 “03 Read Holding Registers”;
-
从机地址填 1,起始地址填 1,寄存器数量填 1;
-
点击 “OK”,工具将周期性发送读取请求。
步骤 3:验证通信效果
-
旋转电位器,Modbus Poll 中会实时显示寄存器 1 的数值(0-4095,对应 ADC 采集范围);
-
若数据实时更新,说明 Modbus RTU 从机通信成功。
三、进阶:STM32 Modbus RTU 主机开发(数据采集)
主机开发核心是 “主动发送指令→接收从机响应→解析数据”,以下是关键代码实现(基于上述硬件):
1. 主机核心代码(modbus_master.c)
\#include "modbus\_master.h"\#include "crc16.h"UART\_HandleTypeDef \*modbus\_huart;uint8\_t modbus\_rx\_buf\[256];uint8\_t modbus\_rx\_len = 0;// 初始化Modbus主机(绑定串口)void modbus\_master\_init(UART\_HandleTypeDef \*huart) {  modbus\_huart = huart;  HAL\_UART\_Receive\_IT(modbus\_huart, \&modbus\_rx\_buf\[modbus\_rx\_len], 1);}// 发送读取保持寄存器指令(从机地址、起始地址、寄存器数量)void modbus\_master\_read\_holding\_reg(uint8\_t slave\_addr, uint16\_t reg\_addr, uint16\_t reg\_num) {  uint8\_t cmd\_buf\[8];  uint16\_t crc;  // 填充指令帧  cmd\_buf\[0] = slave\_addr; // 从机地址  cmd\_buf\[1] = 0x03; // 功能码  cmd\_buf\[2] = (reg\_addr >> 8) & 0xFF; // 寄存器地址高位  cmd\_buf\[3] = reg\_addr & 0xFF; // 寄存器地址低位  cmd\_buf\[4] = (reg\_num >> 8) & 0xFF; // 寄存器数量高位  cmd\_buf\[5] = reg\_num & 0xFF; // 寄存器数量低位  // 计算CRC  crc = modbus\_crc16(cmd\_buf, 6);  cmd\_buf\[6] = crc & 0xFF; // CRC低字节  cmd\_buf\[7] = (crc >> 8) & 0xFF; // CRC高字节  // 发送指令(切换MAX485为发送状态)  HAL\_GPIO\_WritePin(GPIOA, GPIO\_PIN\_8, GPIO\_PIN\_SET);  HAL\_UART\_Transmit(modbus\_huart, cmd\_buf, 8, 100);  HAL\_Delay(1);  HAL\_GPIO\_WritePin(GPIOA, GPIO\_PIN\_8, GPIO\_PIN\_RESET);  // 重置接收缓冲区  modbus\_rx\_len = 0;}// 解析从机响应数据(返回寄存器值数组,len为寄存器数量)uint8\_t modbus\_master\_parse\_response(uint16\_t \*regs, uint8\_t reg\_num) {  uint16\_t crc\_calc = modbus\_crc16(modbus\_rx\_buf, modbus\_rx\_len - 2);  uint16\_t crc\_rx = (modbus\_rx\_buf\[modbus\_rx\_len - 1] < | modbus\_rx\_buf\[modbus\_rx\_len - 2];  // 校验CRC和数据长度  if (crc\_calc != crc\_rx || modbus\_rx\_buf\[2] != reg\_num \* 2) {  return 0; // 校验失败  }  // 解析寄存器数据(高位在前)  for (uint8\_t i = 0; i < reg\_num; i++) {  regs\[i] = (modbus\_rx\_buf\[3 + 2\*i] < modbus\_rx\_buf\[4 + 2\*i];  }  modbus\_rx\_len = 0;  return 1; // 解析成功}// 串口接收中断回调(同从机)void HAL\_UART\_RxCpltCallback(UART\_HandleTypeDef \*huart) {  if (huart == modbus\_huart) {  modbus\_rx\_len++;  if (modbus\_rx\_len 6) {  HAL\_UART\_Receive\_IT(modbus\_huart, \&modbus\_rx\_buf\[modbus\_rx\_len], 1);  } else {  modbus\_rx\_len = 0;  }  }}
2. main 函数调用示例
\#include "modbus\_master.h"uint16\_t sensor\_data\[1]; // 存储从机数据int main(void) {  HAL\_Init();  SystemClock\_Config();  MX\_GPIO\_Init();  MX\_USART1\_UART\_Init();  modbus\_master\_init(\&huart1);  while (1) {  // 每1秒读取1号从机的寄存器1(电位器数据)  modbus\_master\_read\_holding\_reg(0x01, 0x0001, 1);  // 等待从机响应(最多等待500ms)  HAL\_Delay(100);  // 解析响应数据  if (modbus\_master\_parse\_response(sensor\_data, 1)) {  // 解析成功,sensor\_data\[0]即为电位器数据  printf("电位器数据:%d\r\n", sensor\_data\[0]);  } else {  printf("通信失败\r\n");  }  HAL\_Delay(1000);  }}
四、嵌入式 Modbus 避坑指南(实测经验)
1. 硬件避坑:通信失败的核心原因
-
未共地:所有设备必须共地,否则 RS485 总线存在电位差,数据乱码;
-
MAX485 收发状态切换:发送数据时必须置高 DE/RE,发送完成后立即置低,否则无法接收响应;
-
串口参数不匹配:波特率、数据位、停止位、校验位必须与从机完全一致(Modbus RTU 默认 9600-8-N-1);
-
接线错误:MAX485 的 DI 接 STM32 TX,RO 接 STM32 RX,接反会导致无法收发。
2. 软件避坑:协议解析常见问题
-
CRC 校验错误:严格按照 Modbus RTU 的 CRC16 算法实现,低字节在前、高字节在后;
-
帧间隔判断:从机必须检测帧间隔(>3.5 个字符时间),否则会把多帧数据当成一帧解析;
-
寄存器地址混淆:Modbus 寄存器地址是 1-based(从 1 开始),代码中数组是 0-based,需注意偏移;
-
数据字节序:Modbus 协议规定数据为 “大端序”(高位在前),STM32 是小端序,需手动转换。
3. 工业场景优化:提升通信稳定性
-
添加终端电阻:在 RS485 总线两端(主机和最远从机)添加 120Ω 终端电阻,减少信号反射;
-
抗干扰措施:RS485 总线用屏蔽线,屏蔽层接地;远离动力线(如电机线);
-
超时重发:主机发送指令后,若 500ms 内未收到响应,自动重发(最多 3 次);
-
异常处理:从机需处理非法地址、非法功能码、寄存器越界等异常,返回对应的异常响应。