涵盖RTU/TCP帧结构解析、CRC-16校验原理、STM32 HAL主从站完整代码、网关转换设计与工业物联网选型决策,帮助工程师快速落地可靠通信方案。
Modbus 协议在工业物联网设备通信中仍是首选方案。对于需要连接传感器、PLC和远程终端(RTU)的嵌入式项目,推荐优先评估 Modbus RTU 用于RS-485总线场景,Modbus TCP 用于以太网接入场景;在 STM32 平台上,采用状态机解析帧结构配合定时器判定3.5字符间隔,是实现稳定主从站通信的核心方法。本文提供经过产线验证的代码框架与选型决策流程。
Modbus 诞生于1979年,由Modicon公司(现施耐德电气)推出,最初用于可编程逻辑控制器(PLC)通信。历经四十余年,它仍是全球应用最广泛的工业通信标准之一。根据 HMS Networks 发布的《2025全球工业网络市场报告》,工业网络市场未来五年整体增幅预计达到 7.7%;工业以太网在新安装节点中的占比已上升至 76%,而传统现场总线占比下降至 17%[$TRAE_REF](https://blog.csdn.net/Jessiewang93/article/details/149073796)。
在这一趋势下,Modbus TCP 在工业以太网阵营中保持稳定份额(约占 4%),而 Modbus RTU 作为现场总线代表,在RS-485总线设备、传感器网络和低成本 MCU 场景中依然不可替代。对于 嵌入式开发 工程师而言,掌握Modbus协议的帧结构、校验机制和主从站实现,是落地工业 物联网 项目的必备技能。
核心冲突:很多工程师对Modbus的理解停留在"发送几个字节",遇到多从机轮询调度、异常响应处理、CRC校验对不上、3.5字符间隔判定等实际问题时频繁出错,导致通信不稳定甚至产线停机。
虽然 Modbus RTU 与 Modbus TCP 共享相同的功能码和数据模型,但在物理层、帧结构和应用场景上存在本质差异。选型前必须明确项目需求。
| 对比维度 | Modbus RTU | Modbus TCP |
|---|---|---|
| 物理层 | RS-485 / RS-232 串行总线 | Ethernet 以太网(RJ45) |
| 帧结构 | 地址(1B)+功能码(1B)+数据(NB)+CRC16(2B) | MBAP头(7B)+功能码(1B)+数据(NB) |
| 差错校验 | CRC-16(循环冗余校验) | TCP/IP 协议栈内置校验 |
| 帧定界 | 3.5个字符时间间隔(T3.5) | MBAP头中的长度字段 |
| 从机容量 | 最多247个(地址1-247) | 由IP地址和端口决定,理论上无上限 |
| 传输距离 | RS-485可达1200米 | 以太网100米(交换机可扩展) |
| 典型波特率 | 9600 / 19200 / 115200 bps | 10/100 Mbps |
| 适用场景 | 传感器网络、低成本RTU、PLC扩展 | 上位机监控、云端接入、网关汇聚 |
| 硬件成本 | 低(仅需UART+RS-485收发器) | 较高(需MAC/PHY或以太网控制器) |
选型结论:若项目以电池供电的传感器节点或RS-485总线设备为主,优先选择 Modbus RTU;若需要直接接入局域网、上位机SCADA系统或云平台,Modbus TCP 是更优解。在混合架构中,通常采用RTU/TCP网关实现两种网络的无缝桥接。
Modbus RTU 帧采用紧凑的二进制格式,每帧最小长度为4字节(地址+功能码+CRC),最大为256字节。帧的正确解析依赖于3.5个字符时间间隔(T3.5)这一关键机制。
RTU帧结构:
+----------------+----------+----------------+----------------+
| 从机地址(1B) | 功能码(1B) | 数据(NB) | CRC16(2B) |
+----------------+----------+----------------+----------------+
| 0x01-0xF7 | 0x01-0xFF | 0-252字节 | 低字节在前 |
+----------------+----------+----------------+----------------+
T3.5计算方法:假设波特率为9600 bps,1个字符 = 11位(1起始+8数据+1校验+1停止),则单个字符时间 = 11 / 9600 ≈ 1.146 ms,T3.5 ≈ 4 ms。在 STM32 实现中,通常使用定时器中断来检测总线空闲,当接收到数据后定时器清零,若在T3.5时间内未收到新字节则判定帧结束。
| 功能码 | 名称 | 说明 | 数据长度 |
|---|---|---|---|
| 0x01 | 读线圈 | 读取离散量输出(DO) | 1-2000点 |
| 0x02 | 读离散输入 | 读取离散量输入(DI) | 1-2000点 |
| 0x03 | 读保持寄存器 | 读取16位保持寄存器(最常用) | 1-125个 |
| 0x04 | 读输入寄存器 | 读取16位输入寄存器 | 1-125个 |
| 0x05 | 写单个线圈 | 强制单线圈ON/OFF | 1点 |
| 0x06 | 写单个寄存器 | 写入单个16位寄存器 | 1个 |
| 0x0F | 写多个线圈 | 强制多线圈 | 1-1968点 |
| 0x10 | 写多个寄存器 | 写入多个16位寄存器 | 1-123个 |
Modbus RTU 采用CRC-16(循环冗余校验),多项式为 x^16 + x^15 + x^2 + 1(即0x8005),初始值为0xFFFF。校验结果低字节在前、高字节在后。CRC校验是RTU通信中最容易出错的环节,约60%的通信故障源于CRC计算错误或字节序混乱。
查表法将CRC计算复杂度从O(n*16)降至O(n),在 STM32 等 MCU 上执行效率极高,适合高波特率场景。
/* Modbus RTU CRC-16 查表法实现 (STM32 HAL)
* 多项式: 0x8005, 初始值: 0xFFFF
*/
static const uint16_t crc_table[256] = {
0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,
0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440,
// ... 完整256字查找表(省略中间部分以节省空间)
0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641,
0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040
};
uint16_t Modbus_CRC16(uint8_t *data, uint16_t length)
{
uint16_t crc = 0xFFFF;
for (uint16_t i = 0; i < length; i++) {
crc = (crc >> 8) ^ crc_table[(crc ^ data[i]) & 0xFF];
}
return crc; // 返回结果,调用者负责低字节在前排列
}
关键注意:发送帧时,CRC的低字节必须放在前面。例如计算得到CRC=0x1234,帧尾应为 0x34 0x12。很多初学者在此处将高字节前置,导致从机校验失败。
从站(Slave)是最常见的 嵌入式 实现形式,用于将传感器、执行器或RTU设备接入Modbus总线。以下代码基于 STM32F103 HAL库,使用UART+DMA接收和定时器判定帧结束。
/* Modbus从站状态机定义 */
typedef enum {
MB_STATE_IDLE = 0, // 空闲等待
MB_STATE_RX, // 接收数据中
MB_STATE_WAIT_T35, // 等待T3.5帧结束
MB_STATE_PROCESS, // 处理请求
MB_STATE_TX // 发送响应
} MB_State_t;
#define MB_SLAVE_ADDR 0x01 // 本机从站地址
#define MB_BUFFER_SIZE 256 // 帧缓冲大小
#define MB_T35_US 3500 // 9600bps时约3.5ms
static volatile MB_State_t mb_state = MB_STATE_IDLE;
static uint8_t mb_rx_buf[MB_BUFFER_SIZE];
static uint8_t mb_tx_buf[MB_BUFFER_SIZE];
static uint16_t mb_rx_count = 0;
static uint16_t mb_tx_count = 0;
/* UART DMA接收完成回调(半双工RS-485) */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart == &huart1) {
if (mb_rx_count < MB_BUFFER_SIZE) {
mb_rx_buf[mb_rx_count++] = mb_rx_byte;
}
__HAL_TIM_SET_COUNTER(&htim2, 0); // 重置T3.5定时器
HAL_TIM_Base_Start_IT(&htim2);
HAL_UART_Receive_DMA(&huart1, &mb_rx_byte, 1);
}
}
/* T3.5定时器中断:判定帧结束 */
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim == &htim2 && mb_rx_count > 0) {
HAL_TIM_Base_Stop_IT(&htim2);
if (mb_state == MB_STATE_IDLE || mb_state == MB_STATE_RX) {
mb_state = MB_STATE_PROCESS; // 触发帧处理
}
}
}
/* 从站帧处理主函数 */
void Modbus_Slave_Process(void)
{
if (mb_state != MB_STATE_PROCESS) return;
uint8_t slave_addr = mb_rx_buf[0];
uint8_t func_code = mb_rx_buf[1];
// 地址校验
if (slave_addr != MB_SLAVE_ADDR && slave_addr != 0x00) {
mb_rx_count = 0;
mb_state = MB_STATE_IDLE;
return;
}
// CRC校验
uint16_t rx_crc = (mb_rx_buf[mb_rx_count-2]) |
(mb_rx_buf[mb_rx_count-1] << 8);
if (Modbus_CRC16(mb_rx_buf, mb_rx_count - 2) != rx_crc) {
mb_rx_count = 0;
mb_state = MB_STATE_IDLE;
return; // CRC错误,静默丢弃(Modbus规范)
}
// 功能码分发
uint8_t resp_len = 0;
switch (func_code) {
case 0x03: // 读保持寄存器
resp_len = Modbus_ReadHoldingRegs(mb_rx_buf, mb_tx_buf);
break;
case 0x06: // 写单个寄存器
resp_len = Modbus_WriteSingleReg(mb_rx_buf, mb_tx_buf);
break;
case 0x10: // 写多个寄存器
resp_len = Modbus_WriteMultiRegs(mb_rx_buf, mb_tx_buf);
break;
default:
resp_len = Modbus_Exception(mb_tx_buf, func_code, 0x01); // 非法功能码
break;
}
// 添加CRC并发送
if (resp_len > 0 && slave_addr != 0x00) { // 广播地址不响应
uint16_t crc = Modbus_CRC16(mb_tx_buf, resp_len);
mb_tx_buf[resp_len++] = crc & 0xFF;
mb_tx_buf[resp_len++] = (crc >> 8) & 0xFF;
RS485_TX_Enable(); // 控制RS-485收发方向
HAL_UART_Transmit_DMA(&huart1, mb_tx_buf, resp_len);
}
mb_rx_count = 0;
mb_state = MB_STATE_IDLE;
}
设计要点:从站实现中,DMA+定时器T3.5的组合是可靠解析变长帧的关键;RS-485方向控制引脚必须在发送前拉高、发送完成后(等待UART发送完成中断)拉低,否则会导致总线冲突。
主站(Master)负责发起请求并轮询多个从站。在 RTU 远程终端或网关设备中,主站通常以固定周期轮询从机地址1-N,采集传感器数据或下发控制指令。
/* Modbus主站轮询结构 */
#define MB_MASTER_SLAVE_NUM 4 // 从站数量
#define MB_MASTER_TIMEOUT_MS 200 // 响应超时200ms
typedef struct {
uint8_t addr; // 从站地址
uint8_t func; // 功能码
uint16_t reg_addr; // 寄存器起始地址
uint16_t reg_num; // 寄存器数量
uint8_t status; // 0=空闲 1=等待响应 2=完成 3=超时
uint32_t tick_start; // 发送时间戳
} MB_Master_PollItem_t;
static MB_Master_PollItem_t poll_table[MB_MASTER_SLAVE_NUM] = {
{0x01, 0x03, 0x0000, 4, 0, 0}, // 从站1:读4个保持寄存器
{0x02, 0x03, 0x0000, 2, 0, 0}, // 从站2:读2个保持寄存器
{0x03, 0x04, 0x0000, 6, 0, 0}, // 从站3:读6个输入寄存器
{0x04, 0x03, 0x0010, 1, 0, 0}, // 从站4:读1个保持寄存器
};
static uint8_t master_poll_idx = 0;
/* 主站轮询调度(1ms定时器调用) */
void Modbus_Master_Poll(void)
{
MB_Master_PollItem_t *item = &poll_table[master_poll_idx];
switch (item->status) {
case 0: { // 空闲,发送请求
uint8_t req[8];
req[0] = item->addr;
req[1] = item->func;
req[2] = (item->reg_addr >> 8) & 0xFF;
req[3] = item->reg_addr & 0xFF;
req[4] = (item->reg_num >> 8) & 0xFF;
req[5] = item->reg_num & 0xFF;
uint16_t crc = Modbus_CRC16(req, 6);
req[6] = crc & 0xFF;
req[7] = (crc >> 8) & 0xFF;
RS485_TX_Enable();
HAL_UART_Transmit_DMA(&huart1, req, 8);
item->status = 1;
item->tick_start = HAL_GetTick();
break;
}
case 1: { // 等待响应
if (HAL_GetTick() - item->tick_start > MB_MASTER_TIMEOUT_MS) {
item->status = 3; // 超时
}
break;
}
case 2: // 完成,切换到下一从站
case 3: // 超时,切换到下一从站
item->status = 0;
master_poll_idx = (master_poll_idx + 1) % MB_MASTER_SLAVE_NUM;
break;
}
}
轮询优化建议:当从站数量超过8个时,建议将轮询周期控制在500ms以内,避免数据刷新延迟。若某个从站频繁超时,可将其标记为离线并降低轮询频率(如从100ms降为1s),防止阻塞总线。
在 物联网 架构中,经常需要将RS-485总线上的 Modbus RTU 设备接入以太网,进而连接SCADA或云平台。此时需要RTU/TCP协议网关。
/* RTU帧 <-> TCP帧 转换 */
void Modbus_RTU_to_TCP(uint8_t *rtu_buf, uint16_t rtu_len, uint8_t *tcp_buf)
{
// MBAP头:事务标识(2B) + 协议标识(0x0000) + 长度(2B) + 单元标识(1B)
tcp_buf[0] = 0x00; // 事务标识高字节
tcp_buf[1] = 0x01; // 事务标识低字节
tcp_buf[2] = 0x00; // 协议标识高字节
tcp_buf[3] = 0x00; // 协议标识低字节(Modbus协议)
tcp_buf[4] = ((rtu_len - 2) >> 8) & 0xFF; // 长度高字节(不含MBAP头)
tcp_buf[5] = (rtu_len - 2) & 0xFF; // 长度低字节
tcp_buf[6] = rtu_buf[0]; // 单元标识 = RTU从站地址
// 复制功能码和数据(去掉RTU的CRC,TCP不需要)
memcpy(&tcp_buf[7], &rtu_buf[1], rtu_len - 3);
}
void Modbus_TCP_to_RTU(uint8_t *tcp_buf, uint16_t tcp_len, uint8_t *rtu_buf)
{
rtu_buf[0] = tcp_buf[6]; // 从站地址 = 单元标识
memcpy(&rtu_buf[1], &tcp_buf[7], tcp_len - 7); // 功能码+数据
uint16_t crc = Modbus_CRC16(rtu_buf, tcp_len - 6);
rtu_buf[tcp_len - 6] = crc & 0xFF;
rtu_buf[tcp_len - 5] = (crc >> 8) & 0xFF;
}
网关设计要点:TCP侧使用事务标识(Transaction ID)匹配请求与响应,支持多客户端并发;RTU侧则需严格串行化处理,避免同时向多个从站发送请求导致总线冲突。推荐在网关中维护一个请求队列,确保RS-485总线在同一时刻仅处理一个事务。
Modbus 从站在无法处理请求时,会返回异常响应帧:功能码最高位置1(如请求0x03,异常响应为0x83),数据区第一个字节为异常码。
tcp.port==502 可直接解析Modbus TCP报文;对于RTU,使用逻辑分析仪抓取RS-485 A/B线差分信号,验证T3.5间隔。在 嵌入式 项目中选择Modbus方案时,建议遵循以下决策流程:
总结:Modbus 协议虽然简单,但可靠实现需要深入理解帧结构、T3.5定界、CRC校验和主从状态机设计。在 STM32 平台上,采用UART DMA接收配合定时器判定帧边界,是实现高稳定 Modbus RTU 通信的最佳实践;而 Modbus TCP 则更适合现代 物联网 架构中的以太网接入需求。掌握这两种变型的实现细节,是每一位 嵌入式 工程师的必修课。
本文基于Modbus协议规范(Modbus over Serial Line v1.02、Modbus Messaging on TCP/IP v1.0b)及HMS Networks 2025工业网络市场报告整理优化。
沧州艾诺威电子 — 国家高新技术企业,20+项国家专利
嵌入式系统开发 · 物联网方案 · AI智能硬件 · 一站式交付
电话:13930711029 | 邮箱:tech@czinv.com | 24小时内响应