专栏前言

上一篇我们掌握了工业级低功耗物联网节点设计,实现了电池供电设备的超长续航与可靠采集,构建了终端侧的低功耗体系。

而在工业自动化场景中,设备互联互通的核心是标准工业总线协议——PLC、触摸屏、传感器、变频器、仪表几乎都支持Modbus协议,它是工业现场的“通用语言”。

很多新手做工业设备集成时,对Modbus帧格式、主从逻辑、字节序、异常处理理解不深,导致通信不稳、兼容性差、对接第三方设备失败,影响系统集成进度。

ESP32可基于UART外设轻松实现Modbus RTU主机与从机,配合RS485隔离接口,可满足工业现场的通信要求;

也可基于WiFi实现Modbus TCP,作为以太网网关接入工业系统。

无需专用协议芯片,纯软件即可实现标准兼容的Modbus协议栈,配合之前讲的EMC防护、电源管理,可打造工业级的通信终端与采集节点。

本篇一次性讲透ESP-IDF下的Modbus工业总线协议:
Modbus协议基础与架构选型 → RTU帧格式与CRC校验详解 → Modbus从机实现与寄存器映射 → Modbus主机与轮询策略 → RS485硬件与可靠性设计 → 最佳实践与踩坑汇总
全程配可运行代码、帧结构解析与工程化方案,零基础也能掌握标准Modbus协议开发,实现工业设备互联互通与系统集成。


一、Modbus核心基础与ESP32方案选型

1. 协议定位与三种传输模式

Modbus是Modicon公司推出的工业现场总线协议,是目前应用最广的工业通信标准,架构简单、开放免费、兼容性强,几乎所有工业设备都支持。根据传输介质与格式分为三种模式:

传输模式物理层编码方式效率典型应用场景
RTURS485/RS232二进制工业现场总线、设备级通信
ASCIIRS485/RS232可打印ASCII字符调试场景、极少量产使用
TCP以太网TCP/IP封装上位机通信、网关、系统集成

工业现场90%以上采用Modbus RTU + RS485的组合,是本篇的核心重点。

2. 主从架构与半双工机制

Modbus采用一主多从的主从架构,严格半双工通信,是协议最核心的规则:

  • 总线上只有1个主机,最多247个从机,每个从机分配唯一地址(1~247)
  • 通信只能由主机发起,从机收到匹配地址的请求后才应答,从机之间不能直接通信
  • 同一时间总线只能有一个方向传输,收发需要硬件切换方向
  • 主机按顺序轮询各个从机,避免总线冲突

3. ESP32实现方案对比

实现方案开发量标准兼容性稳定性适用场景
原生自研实现较大可按需裁剪取决于实现质量资源极受限、功能简单的从机
FreeModbus开源库完整标准兼容高,工业级验证绝大多数量产工业场景
ESP-IDF官方组件中等基础功能一般快速原型验证、简单场景

工程提示:量产工业级项目优先选用FreeModbus开源栈,经过多年工业现场验证,第三方设备兼容性好;简单从机场景可自行实现最小功能集,减少资源占用。

在这里插入图片描述

【配图1:Modbus RTU总线架构与帧结构图】
配图说明:展示主机→RS485总线→多节点从机的总线拓扑;下方展开RTU帧的完整字段结构,标注各字段长度、含义与字节序;侧边标注物理层、协议层、应用层的对应关系,直观呈现总线架构与帧格式。


下面是 Modbus 主从架构与三种传输模式的整体关系图:

从机侧

传输介质

主机侧

Modbus 主机
PLC / 触摸屏 / 上位机

RTU
RS485/RS232

ASCII
RS485/RS232

TCP
以太网

从机1
仪表/传感器

从机2
变频器

从机3
采集节点

二、RTU帧格式与CRC校验详解

1. 标准RTU帧结构

Modbus RTU帧由4部分组成,除CRC字段外均为大端序(高字节在前),CRC字段为小端序(低字节在前),这是新手最容易踩坑的点。

字段长度(字节)说明
从站地址1目标从机地址,1~247;0为广播地址,从机收到不应答
功能码1指令类型,如03读寄存器、06写寄存器
数据段N具体指令数据,长度由功能码决定
CRC16校验2整帧校验值,低字节在前,高字节在后

帧间隔规则:两帧之间至少间隔3.5个字符的传输时间,接收方以此判断一帧结束。例如9600波特率、10位/字符(1起始+8数据+1停止),3.5字符时间≈3.6ms。

2. 常用标准功能码

工业现场8个核心功能码覆盖95%以上的应用场景:

功能码功能描述数据类型操作权限
0x01读线圈状态位(线圈)
0x02读离散输入状态位(离散输入)只读
0x03读保持寄存器16位寄存器
0x04读输入寄存器16位寄存器只读
0x05写单个线圈位(线圈)
0x06写单个保持寄存器16位寄存器
0x0F写多个线圈位(线圈)
0x10写多个保持寄存器16位寄存器

工业仪表、传感器最常用0x03读保持寄存器0x10写多个保持寄存器,是对接第三方设备的核心功能码。

3. CRC16-Modbus标准实现

CRC是通信可靠性的基础,校验错误的帧直接丢弃。Modbus CRC16标准参数:

  • 初始值:0xFFFF
  • 多项式:0xA001(对应0x8005多项式的逆序)
  • 输出:16位校验值,低字节先发、高字节后发
#include <stdint.h>

// 标准Modbus CRC16计算函数
uint16_t modbus_crc16(const uint8_t *data, uint16_t len)
{
    uint16_t crc = 0xFFFF;
    for (uint16_t i = 0; i < len; i++)
    {
        crc ^= data[i];
        for (uint8_t j = 0; j < 8; j++)
        {
            if (crc & 0x0001)
                crc = (crc >> 1) ^ 0xA001;
            else
                crc >>= 1;
        }
    }
    return crc;
}

使用示例:计算完CRC值后,将低字节放入帧的倒数第2字节,高字节放入最后1字节。


下面是 RTU 帧结构与 CRC 校验流程的示意图:

CRC 计算流程

RTU 帧结构

从站地址
1字节

功能码
1字节

数据段
N字节

CRC16
2字节

初始值 0xFFFF

逐字节异或

右移8次
多项式 0xA001

输出16位校验值

三、Modbus从机实现与寄存器映射

ESP32最常用作Modbus从机:作为工业仪表、采集节点,响应PLC、触摸屏的读写请求。

1. 四大存储区地址映射

Modbus协议定义了四个独立的数据存储区,从机需用内存模拟对应地址空间:

存储区数据类型访问权限地址范围典型用途
线圈可读可写00001~09999开关输出、控制标志位
离散输入只读10001~19999开关输入、状态检测
输入寄存器16位字只读30001~39999采集数据、只读参数
保持寄存器16位字可读可写40001~49999配置参数、控制变量

实际开发中通常用数组模拟存储区,主机通过功能码访问对应偏移地址。

2. 从机核心处理流程

  1. 串口接收数据,通过3.5字符超时判断一帧接收完成
  2. 校验帧长度与CRC,错误则直接丢弃,不做任何响应
  3. 匹配从站地址,非本机地址或广播地址则丢弃
  4. 解析功能码,执行对应读写操作,校验地址边界
  5. 操作成功则构造应答帧,附加CRC后发送回主机
  6. 功能码不支持、地址越界、数据非法,返回标准异常应答帧

实战1:极简Modbus RTU从机

实现功能:支持0x03读保持寄存器、0x06写单个寄存器、0x10写多个寄存器,GPIO控制RS485收发方向。

#include <stdio.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/uart.h"
#include "driver/gpio.h"
#include "esp_log.h"

#define TAG "MODBUS_SLAVE"

#define UART_NUM      UART_NUM_1
#define BAUD_RATE     9600
#define TX_PIN        GPIO_NUM_17
#define RX_PIN        GPIO_NUM_16
#define RS485_DE_PIN  GPIO_NUM_4    // RS485收发控制:高发送低接收

#define SLAVE_ADDR    0x01          // 本机从站地址
#define REG_NUM       32            // 保持寄存器数量

static uint16_t g_holding_reg[REG_NUM] = {0}; // 保持寄存器数组

// RS485发送模式切换
static void rs485_send_mode(void)
{
    gpio_set_level(RS485_DE_PIN, 1);
}

// RS485接收模式切换
static void rs485_recv_mode(void)
{
    gpio_set_level(RS485_DE_PIN, 0);
}

// 发送应答帧
static void send_response(const uint8_t *data, uint16_t len)
{
    rs485_send_mode();
    uart_write_bytes(UART_NUM, data, len);
    uart_wait_tx_done(UART_NUM, pdMS_TO_TICKS(10));
    rs485_recv_mode();
}

// 处理0x03读保持寄存器
static uint16_t handle_read_holding(const uint8_t *req, uint8_t *resp)
{
    uint16_t start_addr = ((uint16_t)req[2] << 8) | req[3];
    uint16_t reg_cnt = ((uint16_t)req[4] << 8) | req[5];
    
    // 地址越界检查
    if (start_addr + reg_cnt > REG_NUM) {
        return 0; // 返回异常
    }
    
    // 构造应答
    resp[0] = SLAVE_ADDR;
    resp[1] = 0x03;
    resp[2] = reg_cnt * 2; // 字节数
    
    for (uint16_t i = 0; i < reg_cnt; i++) {
        uint16_t val = g_holding_reg[start_addr + i];
        resp[3 + i*2] = (val >> 8) & 0xFF;   // 高字节在前
        resp[3 + i*2 + 1] = val & 0xFF;        // 低字节在后
    }
    
    uint16_t crc = modbus_crc16(resp, 3 + reg_cnt*2);
    resp[3 + reg_cnt*2] = crc & 0xFF;         // CRC低字节
    resp[3 + reg_cnt*2 + 1] = (crc >> 8) & 0xFF; // CRC高字节
    
    return 5 + reg_cnt*2;
}

// 处理0x06写单个保持寄存器
static uint16_t handle_write_single(const uint8_t *req, uint8_t *resp)
{
    uint16_t addr = ((uint16_t)req[2] << 8) | req[3];
    uint16_t value = ((uint16_t)req[4] << 8) | req[5];
    
    if (addr >= REG_NUM) {
        return 0;
    }
    
    g_holding_reg[addr] = value;
    
    // 写操作应答原样返回请求帧
    memcpy(resp, req, 6);
    uint16_t crc = modbus_crc16(resp, 6);
    resp[6] = crc & 0xFF;
    resp[7] = (crc >> 8) & 0xFF;
    
    return 8;
}

void modbus_slave_task(void *pvParameters)
{
    uint8_t recv_buf[256];
    uint8_t resp_buf[256];
    
    // UART初始化
    uart_config_t uart_cfg = {
        .baud_rate = BAUD_RATE,
        .data_bits = UART_DATA_8_BITS,
        .parity = UART_PARITY_DISABLE,
        .stop_bits = UART_STOP_BITS_1,
        .flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
    };
    uart_param_config(UART_NUM, &uart_cfg);
    uart_set_pin(UART_NUM, TX_PIN, RX_PIN, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
    uart_driver_install(UART_NUM, 1024, 1024, 0, NULL, 0);
    
    // RS485控制引脚初始化
    gpio_config_t io_cfg = {
        .pin_bit_mask = 1ULL << RS485_DE_PIN,
        .mode = GPIO_MODE_OUTPUT,
    };
    gpio_config(&io_cfg);
    rs485_recv_mode();
    
    ESP_LOGI(TAG, "Modbus从机启动,地址%d,波特率%d", SLAVE_ADDR, BAUD_RATE);
    
    while (1) {
        int len = uart_read_bytes(UART_NUM, recv_buf, sizeof(recv_buf), pdMS_TO_TICKS(50));
        if (len < 4) continue; // 最短帧4字节
        
        // CRC校验
        uint16_t recv_crc = ((uint16_t)recv_buf[len-1] << 8) | recv_buf[len-2];
        uint16_t calc_crc = modbus_crc16(recv_buf, len - 2);
        if (recv_crc != calc_crc) continue;
        
        // 地址匹配
        if (recv_buf[0] != SLAVE_ADDR && recv_buf[0] != 0) continue;
        
        uint16_t resp_len = 0;
        switch (recv_buf[1]) {
            case 0x03:
                resp_len = handle_read_holding(recv_buf, resp_buf);
                break;
            case 0x06:
                resp_len = handle_write_single(recv_buf, resp_buf);
                break;
            default:
                // 异常应答:不支持功能码
                resp_buf[0] = SLAVE_ADDR;
                resp_buf[1] = recv_buf[1] | 0x80;
                resp_buf[2] = 0x01; // 异常码1:不支持功能码
                uint16_t crc = modbus_crc16(resp_buf, 3);
                resp_buf[3] = crc & 0xFF;
                resp_buf[4] = (crc >> 8) & 0xFF;
                resp_len = 5;
                break;
        }
        
        if (resp_len > 0 && recv_buf[0] != 0) { // 广播不应答
            send_response(resp_buf, resp_len);
        }
    }
}

void app_main(void)
{
    xTaskCreate(modbus_slave_task, "mb_slave", 4096, NULL, 3, NULL);
}

关键说明

  • 3.5字符超时:本例通过读取超时间接判断帧结束,高波特率场景建议使用串口空闲中断,帧边界判断更精准
  • 寄存器字节序:所有16位寄存器严格按大端序传输,高字节在前、低字节在后,和ESP32原生小端相反
  • 广播地址:地址0为广播,从机执行指令但不应答,避免多个从机同时应答造成总线冲突

在这里插入图片描述

【配图2:Modbus从机软件处理流程图】
配图说明:展示从串口接收→超时判帧→CRC校验→地址匹配→功能码分发→读写寄存器→构造应答→发送的完整流程;标注异常分支的处理逻辑,直观呈现从机的完整执行路径。


下面是 Modbus 从机核心处理流程的完整流程图:

错误

正确

非本机地址

匹配

0x03

0x06

0x10

其他

越界

正常

广播地址不应答

正常应答

串口接收数据

3.5字符超时
判断帧结束

CRC校验

丢弃帧
不响应

地址匹配

丢弃帧

解析功能码

功能码分发

读保持寄存器

写单个寄存器

写多个寄存器

返回异常应答
功能码|0x80

地址越界?

构造应答帧
附加CRC

发送应答

结束

四、Modbus主机实现与轮询策略

当ESP32作为采集网关,对接多个Modbus从机设备时,需要实现Modbus主机功能。

1. 主机核心工作流程

  1. 构造请求帧:指定从机地址、功能码、数据,计算CRC
  2. 切换RS485为发送模式,发送请求帧
  3. 发送完成切换为接收模式,启动超时定时器
  4. 等待接收应答帧,超时则触发重发
  5. 收到应答后校验CRC与地址,解析数据
  6. 异常应答或连续超时,记录该从机故障
  7. 轮询下一个从机,循环执行

2. 工业级轮询策略

  • 轮询周期:根据实时性要求设置,兼顾总线负载,一般100ms~数秒
  • 超时时间:设为100~500ms,大于从机最大响应时间,避免误判
  • 重发次数:单帧失败重发2~3次,仍失败则标记设备离线
  • 故障恢复:离线设备定时重试,恢复后自动加入轮询

实战2:简易主机轮询读取

实现功能:轮询指定从机的保持寄存器,超时重发,解析数据。

// 读取指定从机的保持寄存器
static esp_err_t modbus_read_holding(uint8_t slave_addr, uint16_t start_addr, 
                                      uint16_t reg_cnt, uint16_t *out_buf)
{
    uint8_t req[8];
    req[0] = slave_addr;
    req[1] = 0x03;
    req[2] = (start_addr >> 8) & 0xFF;
    req[3] = start_addr & 0xFF;
    req[4] = (reg_cnt >> 8) & 0xFF;
    req[5] = reg_cnt & 0xFF;
    uint16_t crc = modbus_crc16(req, 6);
    req[6] = crc & 0xFF;
    req[7] = (crc >> 8) & 0xFF;
    
    // 发送请求
    rs485_send_mode();
    uart_write_bytes(UART_NUM, req, 8);
    uart_wait_tx_done(UART_NUM, pdMS_TO_TICKS(10));
    rs485_recv_mode();
    
    // 等待应答
    uint8_t resp[256];
    int len = uart_read_bytes(UART_NUM, resp, sizeof(resp), pdMS_TO_TICKS(200));
    if (len < 5) return ESP_ERR_TIMEOUT;
    
    // 校验
    uint16_t recv_crc = ((uint16_t)resp[len-1] << 8) | resp[len-2];
    uint16_t calc_crc = modbus_crc16(resp, len - 2);
    if (recv_crc != calc_crc) return ESP_ERR_INVALID_CRC;
    if (resp[0] != slave_addr) return ESP_ERR_INVALID_RESPONSE;
    if (resp[1] & 0x80) return ESP_ERR_NOT_SUPPORTED; // 异常应答
    
    // 解析数据
    for (uint16_t i = 0; i < reg_cnt; i++) {
        out_buf[i] = ((uint16_t)resp[3 + i*2] << 8) | resp[3 + i*2 + 1];
    }
    
    return ESP_OK;
}

下面是 Modbus 主机轮询多个从机的完整工作流程图:

超时

收到应答

错误

正确

不匹配

匹配

初始化主机

进入轮询循环

选择下一个从机

构造请求帧
地址+功能码+数据+CRC

切换RS485发送模式
发送请求帧

切换接收模式
启动超时定时器

等待应答

重发次数
≤2~3次?

标记设备离线
记录故障

CRC校验

地址匹配

异常应答?

解析异常码
记录错误类型

解析数据
存入缓冲区

五、工程化:RS485硬件与可靠性设计

Modbus RTU的可靠性80%取决于RS485硬件与总线设计,是工业现场稳定运行的核心。

1. RS485硬件电路设计

  • 收发控制:GPIO控制DE/RE引脚,发送完成延时再切接收,避免帧尾丢失;工业级推荐GPIO控制,比自动流向更可靠
  • 电气隔离:必须使用隔离型RS485芯片或加数字隔离器,切断地环路,大幅提升抗干扰能力(对应EMC篇)
  • 终端匹配:总线首尾两端各接120Ω终端电阻,匹配特性阻抗,减少信号反射
  • 偏置电阻:节点较多时加上下拉偏置电阻(各1kΩ),保证空闲时总线电平确定
  • ESD防护:A/B线各接TVS管到保护地,静电泄放,防止接口损坏

2. 软件可靠性增强

  • 超时重传:单次通信失败自动重发2~3次,容忍临时干扰
  • 异常码解析:正确处理标准异常码,区分功能不支持、地址越界、数据非法,便于排查
  • 故障计数:每个从机独立统计失败次数,连续失败标记离线,避免占用总线时间
  • 总线空闲检测:发送前检测总线状态,避免冲突

3. 多字节数据对齐

Modbus协议为大端序,ESP32为小端序,多字节数据必须做转换:

  • 16位寄存器:拆分高低字节,高字节先发
  • 32位整型/浮点:分两个寄存器传输,需提前约定高低字顺序,不同厂家定义可能不同,对接时明确文档化

在这里插入图片描述

【配图3:工业RS485-Modbus可靠性设计全景图】
配图说明:从物理层硬件防护、协议层校验重传、应用层异常处理三个层级,展示完整的可靠性设计体系;每层标注核心措施与解决的问题,直观呈现工业级总线的可靠性保障。


下面是工业 RS485-Modbus 可靠性设计的全景架构图:

应用层异常处理

异常码解析
区分错误类型

故障计数
连续失败标记离线

故障恢复
定时重试自动恢复

协议层校验重传

CRC16校验
错误帧丢弃

超时重传
失败重发2~3次

帧间隔判断
3.5字符超时

物理层硬件防护

电气隔离
隔离芯片/数字隔离器

终端匹配
两端120Ω电阻

偏置电阻
上下拉各1kΩ

ESD防护
TVS管到保护地

六、工程最佳实践

1. 协议实现

  • 严格遵循标准帧格式与CRC字节序,优先保证第三方设备兼容性
  • 寄存器地址统一从0开始,避免0地址/1地址混淆,文档明确说明
  • 多字节数据定义字节序与寄存器顺序,浮点、32位数据约定清晰
  • 支持标准异常码,不随意自定义功能码与私有格式

2. 硬件设计

  • 工业级必须做电气隔离,总线侧与系统侧电源地完全隔离
  • 总线两端必须加终端电阻,超过10个节点增加偏置电阻
  • 收发切换留足延时,等待发送完成再切接收,避免帧尾丢失
  • 总线走线差分等长,远离高频电路,减少干扰耦合

3. 调试与验证

  • 用Modbus Poll、Modbus Slave等标准工具调试,验证兼容性
  • 先测从机再测主机,先短距离再长距离,先低波特率再高波特率
  • 用USB转485抓包对比标准帧,快速定位通信问题
  • 连续24小时压力测试,验证错误率与稳定性

七、新手高频踩坑汇总

  1. CRC字节序搞反,通信完全不通
    • 误将CRC高字节在前,导致所有帧校验失败,从机不应答
    • 解决:CRC低字节先发、高字节后发;用标准工具抓包对比校验值
  2. 大小端不匹配,读取数据全错乱
    • 直接发送ESP32原生小端数据,高低字节颠倒,数值完全错误
    • 解决:所有16位数据拆分高低字节,按大端序传输;接收时合并还原
  3. 收发切换不及时,丢帧头帧尾
    • 发送完立刻切接收,帧尾还未输出就切换方向,导致最后几个字节丢失
    • 解决:等待发送完成中断或加延时,确保数据全部发出再切接收
  4. 帧结束判断错误,帧拆分或合并
    • 超时设置太短,一帧拆成两帧;太长,多帧合并成一帧
    • 解决:按波特率准确计算3.5字符时间,配合串口空闲中断判断
  5. 地址冲突,总线乱码
    • 总线上存在相同地址从机,同时应答造成冲突,数据全错
    • 解决:出厂配置唯一地址,逐个设备接入测试,避免重址
  6. 忽略异常应答,故障无法定位
    • 收到异常功能码直接丢弃,不知道具体错误原因
    • 解决:解析标准异常码,记录错误类型,针对性排查问题

下一篇预告(45篇)

ESP-IDF保姆级入门45|边缘采集网关与协议转换全解:Modbus转MQTT/多设备接入/数据标准化/云端同步,打造工业级边缘采集网关,实现多协议设备数据汇聚与云边协同!

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐