在工业物联网领域,Modbus协议仍然是使用最广泛的通信协议之一。大量工业设备——PLC、变频器、温湿度控制器、电表、流量计——都支持Modbus协议。但这些设备绝大多数使用Modbus RTU(串口通信),无法直接接入现代化的物联网平台。

我们沧州虎王科技技术团队在多个工业监测项目中,需要将车间内几十台只支持Modbus RTU的设备接入云端平台。解决方案是用ESP32作为协议转换网关,将Modbus RTU转换为Modbus TCP,让设备数据通过以太网/WiFi上送到云端。

本文分享这套Modbus网关的完整设计方案,涵盖RTU主站实现、TCP服务器实现、协议转换逻辑、多设备轮询策略等工程细节。

一、Modbus协议基础

在进入实现之前,先明确Modbus RTU和TCP的区别:

Modbus RTU

  • 传输介质:RS-485串口
  • 帧格式:从站地址(1) + 功能码(1) + 数据(N) + CRC16(2)
  • 特点:简单可靠,但只能串行通信,一个总线上挂多个从站
  • 典型波特率:9600/19200 bps

Modbus TCP

  • 传输介质:以太网
  • 帧格式:MBAP头(7) + 功能码(1) + 数据(N)
  • MBAP头:事务ID(2) + 协议ID(2=0) + 长度(2) + 单元ID(1)
  • 特点:无CRC(TCP自带校验),支持多客户端并发访问
  • 典型端口:502
┌──────────────┐    RS-485    ┌──────────┐   WiFi/以太网   ┌──────────┐
│ Modbus RTU   │ ←──────────→ │          │ ←─────────────→ │ Modbus   │
│ 设备(PLC等)  │              │ ESP32    │                  │ TCP客户端 │
└──────────────┘              │ 网关     │                  │(SCADA/   │
┌──────────────┐    RS-485    │ RTU→TCP  │ ←─────────────→ │ 云平台)   │
│ Modbus RTU   │ ←──────────→ │ 转换     │                  └──────────┘
│ 设备(电表等)  │              └──────────┘
└──────────────┘                 ↓ MQTT/HTTP
                             ┌──────────┐
                             │ 沧州虎王  │
                             │ 物联网平台│
                             └──────────┘

网关的核心工作是双向协议转换:

  • TCP客户端请求 → 网关解析TCP帧 → 构建RTU帧 → 通过串口发送给设备 → 接收RTU响应 → 构建TCP响应 → 返回给客户端
  • 同时,网关自身也可以作为RTU主站主动轮询设备数据,通过MQTT/HTTP上报到物联网平台

二、ESP32网关硬件设计

2.1 硬件接口

ESP32              RS-485收发器(MAX485)
GPIO4  (TX)  ────→ DI (Driver Input)
GPIO5  (RX)  ←──── RO (Receiver Output)
GPIO18 (DE/RE)──── DE + RE (使能控制)
                   A  ────→ RS-485总线A
                   B  ────→ RS-485总线B
GND   ────→ GND
3.3V  ────→ VCC

DE/RE引脚的控制是RS-485通信的关键:发送时拉高DE使能发送,接收时拉低DE使能接收。

2.2 UART配置

ESP32的UART2用于Modbus RTU通信,UART0保留给调试日志:

// modbus_rtu.c
#include "driver/uart.h"
#include "driver/gpio.h"

#define MB_UART_NUM     UART_NUM_2
#define MB_TX_PIN       4
#define MB_RX_PIN       5
#define MB_DE_PIN       18
#define MB_BUF_SIZE     256
#define MB_READ_TOUT    10  // 读取超时(tick数)

#define MB_BAUDRATE     9600
#define MB_PARITY       UART_PARITY_NONE  // Modbus RTU常用None/Even
#define MB_STOP_BITS    UART_STOP_BITS_1
#define MB_DATA_BITS    UART_DATA_8_BITS

static QueueHandle_t s_mb_event_queue;

esp_err_t modbus_rtu_init(void) {
    uart_config_t uart_config = {
        .baud_rate = MB_BAUDRATE,
        .data_bits = MB_DATA_BITS,
        .parity = MB_PARITY,
        .stop_bits = MB_STOP_BITS,
        .flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
        .source_clk = UART_SCLK_APB,
    };
    
    ESP_ERROR_CHECK(uart_driver_install(MB_UART_NUM, MB_BUF_SIZE * 2, 
                                          MB_BUF_SIZE * 2, 20, &s_mb_event_queue, 0));
    ESP_ERROR_CHECK(uart_param_config(MB_UART_NUM, &uart_config));
    ESP_ERROR_CHECK(uart_set_pin(MB_UART_NUM, MB_TX_PIN, MB_RX_PIN, 
                                   MB_DE_PIN, UART_PIN_NO_CHANGE));
    
    // 设置RS-485半双工模式
    ESP_ERROR_CHECK(uart_set_mode(MB_UART_NUM, UART_MODE_RS485_HALF_DUPLEX));
    
    // 设置读取超时(t3.5字符时间)
    // 9600bps下t3.5 = 3.5 * 11bits / 9600 = 4.0ms
    uart_set_rx_timeout(MB_UART_NUM, MB_READ_TOUT);
    
    ESP_LOGI(TAG_MB, "Modbus RTU initialized: baud=%d, parity=%d", 
             MB_BAUDRATE, MB_PARITY);
    return ESP_OK;
}

三、Modbus RTU主站实现

网关需要作为Modbus RTU主站,主动轮询从站设备的数据。以下是RTU主站的核心实现:

3.1 CRC16校验

Modbus RTU的CRC16校验是协议的基础:

// CRC16 (Modbus)
static uint16_t crc16(const uint8_t *data, size_t len) {
    uint16_t crc = 0xFFFF;
    
    for (size_t i = 0; i < len; i++) {
        crc ^= (uint16_t)data[i];
        for (int j = 0; j < 8; j++) {
            if (crc & 0x0001) {
                crc >>= 1;
                crc ^= 0xA001;
            } else {
                crc >>= 1;
            }
        }
    }
    return crc;
}

3.2 RTU请求发送与响应接收

// 发送Modbus RTU请求并等待响应
// 返回值: 响应数据长度(不含地址和功能码和CRC), 负值表示错误
typedef enum {
    MB_OK = 0,
    MB_ERR_TIMEOUT = -1,
    MB_ERR_CRC = -2,
    MB_ERR_EXCEPTION = -3,
    MB_ERR_LENGTH = -4,
} mb_error_t;

// Modbus异常码
static const char *mb_exception_str(uint8_t code) {
    switch (code) {
        case 1:  return "Illegal Function";
        case 2:  return "Illegal Data Address";
        case 3:  return "Illegal Data Value";
        case 4:  return "Slave Device Failure";
        case 5:  return "Acknowledge";
        case 6:  return "Slave Device Busy";
        default: return "Unknown Exception";
    }
}

// 发送RTU帧
static esp_err_t mb_rtu_send(uint8_t slave_addr, uint8_t func_code,
                               const uint8_t *data, size_t data_len) {
    uint8_t frame[256];
    int idx = 0;
    
    frame[idx++] = slave_addr;
    frame[idx++] = func_code;
    if (data && data_len > 0) {
        memcpy(&frame[idx], data, data_len);
        idx += data_len;
    }
    
    // 计算并追加CRC16
    uint16_t crc = crc16(frame, idx);
    frame[idx++] = crc & 0xFF;        // CRC低字节
    frame[idx++] = (crc >> 8) & 0xFF; // CRC高字节
    
    // 清空接收缓冲区
    uart_flush_input(MB_UART_NUM);
    
    // 发送
    int sent = uart_write_bytes(MB_UART_NUM, frame, idx);
    if (sent != idx) {
        ESP_LOGE(TAG_MB, "UART write failed: sent=%d, expected=%d", sent, idx);
        return ESP_FAIL;
    }
    
    // 等待发送完成(RS-485需要确保最后一字节完全发送)
    uart_wait_tx_done(MB_UART_NUM, pdMS_TO_TICKS(100));
    
    ESP_LOGD(TAG_MB, "TX [%02X]: ", slave_addr);
    ESP_LOG_BUFFER_HEX_LEVEL(TAG_MB, frame, idx, ESP_LOG_DEBUG);
    
    return ESP_OK;
}

// 接收RTU响应帧
static mb_error_t mb_rtu_recv(uint8_t *buf, size_t buf_size, size_t *recv_len,
                                 uint32_t timeout_ms) {
    size_t total = 0;
    TickType_t start = xTaskGetTickCount();
    
    while (1) {
        // 检查超时
        if ((xTaskGetTickCount() - start) * portTICK_PERIOD_MS > timeout_ms) {
            if (total == 0) return MB_ERR_TIMEOUT;
            break;
        }
        
        // 读取数据
        int len = uart_read_bytes(MB_UART_NUM, buf + total, 
                                    buf_size - total, pdMS_TO_TICKS(20));
        if (len > 0) {
            total += len;
            // RTU帧之间有t3.5间隔,UART的rx_timeout会在帧间间隔时触发
            // 继续读一小段时间看是否有更多数据
            continue;
        } else if (total > 0) {
            // 已有数据且当前无新数据,认为帧接收完成
            break;
        }
    }
    
    if (total < 4) {  // 最小帧: 地址(1) + 功能码(1) + CRC(2)
        ESP_LOGW(TAG_MB, "Response too short: %d bytes", total);
        return MB_ERR_LENGTH;
    }
    
    // 验证CRC
    uint16_t recv_crc = (buf[total - 1] << 8) | buf[total - 2];
    uint16_t calc_crc = crc16(buf, total - 2);
    
    if (recv_crc != calc_crc) {
        ESP_LOGE(TAG_MB, "CRC mismatch: recv=0x%04X, calc=0x%04X", recv_crc, calc_crc);
        ESP_LOG_BUFFER_HEX(TAG_MB, buf, total);
        return MB_ERR_CRC;
    }
    
    *recv_len = total;
    
    ESP_LOGD(TAG_MB, "RX [%d bytes]: ", total);
    ESP_LOG_BUFFER_HEX_LEVEL(TAG_MB, buf, total, ESP_LOG_DEBUG);
    
    return MB_OK;
}

3.3 Modbus功能码实现

基于发送/接收基础函数,实现常用的Modbus功能码:

// 读保持寄存器 (功能码 0x03)
mb_error_t mb_read_holding_registers(uint8_t slave_addr, 
                                        uint16_t start_addr, 
                                        uint16_t reg_count,
                                        uint16_t *out_regs) {
    // 构建请求数据: 起始地址(2) + 寄存器数量(2)
    uint8_t req_data[4];
    req_data[0] = (start_addr >> 8) & 0xFF;
    req_data[1] = start_addr & 0xFF;
    req_data[2] = (reg_count >> 8) & 0xFF;
    req_data[3] = reg_count & 0xFF;
    
    // 发送请求
    esp_err_t err = mb_rtu_send(slave_addr, 0x03, req_data, 4);
    if (err != ESP_OK) return MB_ERR_TIMEOUT;
    
    // 接收响应
    uint8_t resp[256];
    size_t resp_len;
    mb_error_t ret = mb_rtu_recv(resp, sizeof(resp), &resp_len, 1000);
    if (ret != MB_OK) return ret;
    
    // 检查是否为异常响应
    if (resp[1] & 0x80) {
        ESP_LOGE(TAG_MB, "Modbus exception: slave=%d, code=%d (%s)",
                 slave_addr, resp[2], mb_exception_str(resp[2]));
        return MB_ERR_EXCEPTION;
    }
    
    // 验证响应: 地址(1) + 功能码(1) + 字节数(1) + 数据(N) + CRC(2)
    if (resp_len < 5) return MB_ERR_LENGTH;
    
    uint8_t byte_count = resp[2];
    if (byte_count != reg_count * 2) {
        ESP_LOGW(TAG_MB, "Byte count mismatch: expected %d, got %d",
                 reg_count * 2, byte_count);
    }
    
    // 解析寄存器值(大端序)
    for (int i = 0; i < reg_count && i * 2 + 4 <= resp_len; i++) {
        out_regs[i] = (resp[3 + i * 2] << 8) | resp[4 + i * 2];
    }
    
    return MB_OK;
}

// 写单个寄存器 (功能码 0x06)
mb_error_t mb_write_single_register(uint8_t slave_addr, 
                                      uint16_t reg_addr, 
                                      uint16_t value) {
    uint8_t req_data[4];
    req_data[0] = (reg_addr >> 8) & 0xFF;
    req_data[1] = reg_addr & 0xFF;
    req_data[2] = (value >> 8) & 0xFF;
    req_data[3] = value & 0xFF;
    
    esp_err_t err = mb_rtu_send(slave_addr, 0x06, req_data, 4);
    if (err != ESP_OK) return MB_ERR_TIMEOUT;
    
    uint8_t resp[32];
    size_t resp_len;
    mb_error_t ret = mb_rtu_recv(resp, sizeof(resp), &resp_len, 1000);
    if (ret != MB_OK) return ret;
    
    // 写单个寄存器的响应是请求的原样回显
    if (resp[1] & 0x80) {
        ESP_LOGE(TAG_MB, "Write exception: %s", mb_exception_str(resp[2]));
        return MB_ERR_EXCEPTION;
    }
    
    return MB_OK;
}

// 读输入寄存器 (功能码 0x04)
mb_error_t mb_read_input_registers(uint8_t slave_addr,
                                     uint16_t start_addr,
                                     uint16_t reg_count,
                                     uint16_t *out_regs) {
    uint8_t req_data[4];
    req_data[0] = (start_addr >> 8) & 0xFF;
    req_data[1] = start_addr & 0xFF;
    req_data[2] = (reg_count >> 8) & 0xFF;
    req_data[3] = reg_count & 0xFF;
    
    esp_err_t err = mb_rtu_send(slave_addr, 0x04, req_data, 4);
    if (err != ESP_OK) return MB_ERR_TIMEOUT;
    
    uint8_t resp[256];
    size_t resp_len;
    mb_error_t ret = mb_rtu_recv(resp, sizeof(resp), &resp_len, 1000);
    if (ret != MB_OK) return ret;
    
    if (resp[1] & 0x80) return MB_ERR_EXCEPTION;
    
    uint8_t byte_count = resp[2];
    for (int i = 0; i < reg_count && i * 2 + 4 <= resp_len; i++) {
        out_regs[i] = (resp[3 + i * 2] << 8) | resp[4 + i * 2];
    }
    
    return MB_OK;
}

// 写多个寄存器 (功能码 0x10)
mb_error_t mb_write_multiple_registers(uint8_t slave_addr,
                                         uint16_t start_addr,
                                         uint16_t reg_count,
                                         const uint16_t *values) {
    uint8_t req_data[256];
    int idx = 0;
    
    // 起始地址(2) + 寄存器数量(2) + 字节数(1) + 数据(N)
    req_data[idx++] = (start_addr >> 8) & 0xFF;
    req_data[idx++] = start_addr & 0xFF;
    req_data[idx++] = (reg_count >> 8) & 0xFF;
    req_data[idx++] = reg_count & 0xFF;
    req_data[idx++] = reg_count * 2;
    
    for (int i = 0; i < reg_count; i++) {
        req_data[idx++] = (values[i] >> 8) & 0xFF;
        req_data[idx++] = values[i] & 0xFF;
    }
    
    esp_err_t err = mb_rtu_send(slave_addr, 0x10, req_data, idx);
    if (err != ESP_OK) return MB_ERR_TIMEOUT;
    
    uint8_t resp[32];
    size_t resp_len;
    mb_error_t ret = mb_rtu_recv(resp, sizeof(resp), &resp_len, 1000);
    if (ret != MB_OK) return ret;
    
    if (resp[1] & 0x80) return MB_ERR_EXCEPTION;
    
    return MB_OK;
}

四、Modbus TCP服务器实现

网关同时需要作为Modbus TCP服务器,接受TCP客户端的请求,转发给RTU从站。这要求ESP32同时运行TCP Server和RTU Master。

4.1 TCP服务器核心

// modbus_tcp.c
#include "lwip/sockets.h"

#define MB_TCP_PORT     502
#define MB_TCP_MAX_CONN 4
#define MB_TCP_BUF_SIZE 256

// Modbus TCP帧头 (MBAP Header)
typedef struct {
    uint16_t transaction_id;
    uint16_t protocol_id;   // 0 = Modbus
    uint16_t length;        // 后续字节数
    uint8_t  unit_id;       // 从站地址
} __attribute__((packed)) mbap_header_t;

// TCP客户端连接上下文
typedef struct {
    int sockfd;
    bool active;
    TaskHandle_t task_handle;
} tcp_client_t;

static tcp_client_t s_clients[MB_TCP_MAX_CONN];
static SemaphoreHandle_t s_rtu_mutex;  // RTU总线互斥锁

// 处理单个TCP客户端请求
static void handle_tcp_client(void *arg) {
    tcp_client_t *client = (tcp_client_t *)arg;
    uint8_t rx_buf[MB_TCP_BUF_SIZE];
    uint8_t tx_buf[MB_TCP_BUF_SIZE];
    
    ESP_LOGI(TAG_TCP, "Client connected, fd=%d", client->sockfd);
    
    while (client->active) {
        // 读取MBAP头 (7字节)
        int recv_len = 0;
        int header_len = 7;
        
        while (recv_len < header_len) {
            int n = recv(client->sockfd, rx_buf + recv_len, 
                        header_len - recv_len, 0);
            if (n <= 0) {
                ESP_LOGW(TAG_TCP, "Client disconnected: fd=%d", client->sockfd);
                goto cleanup;
            }
            recv_len += n;
        }
        
        // 解析MBAP头
        mbap_header_t *header = (mbap_header_t *)rx_buf;
        uint16_t pdu_len = ntohs(header->length) - 1;  // 减去unit_id
        
        // 读取PDU部分 (功能码 + 数据)
        while (recv_len < 7 + pdu_len) {
            int n = recv(client->sockfd, rx_buf + recv_len, 
                        7 + pdu_len - recv_len, 0);
            if (n <= 0) goto cleanup;
            recv_len += n;
        }
        
        uint8_t func_code = rx_buf[7];
        uint8_t unit_id = header->unit_id;
        
        ESP_LOGD(TAG_TCP, "TCP Req: unit=%d, func=0x%02X, pdu_len=%d",
                 unit_id, func_code, pdu_len);
        
        // 获取RTU总线锁(多个TCP客户端可能同时请求RTU总线)
        if (xSemaphoreTake(s_rtu_mutex, pdMS_TO_TICKS(2000)) != pdTRUE) {
            ESP_LOGW(TAG_TCP, "RTU bus busy, rejecting request");
            // 构建异常响应: Slave Busy (0x06)
            goto send_exception;
        }
        
        // 将TCP请求转换为RTU请求并发送
        // RTU帧: unit_id(1) + PDU(pdu_len) + CRC(2)
        uint8_t rtu_req[256];
        rtu_req[0] = unit_id;
        memcpy(&rtu_req[1], &rx_buf[7], pdu_len);
        uint16_t crc = crc16(rtu_req, 1 + pdu_len);
        rtu_req[1 + pdu_len] = crc & 0xFF;
        rtu_req[2 + pdu_len] = (crc >> 8) & 0xFF;
        
        // 发送RTU请求
        uart_flush_input(MB_UART_NUM);
        uart_write_bytes(MB_UART_NUM, rtu_req, 3 + pdu_len);
        uart_wait_tx_done(MB_UART_NUM, pdMS_TO_TICKS(100));
        
        // 接收RTU响应
        uint8_t rtu_resp[256];
        size_t rtu_resp_len;
        mb_error_t mb_ret = mb_rtu_recv(rtu_resp, sizeof(rtu_resp), 
                                         &rtu_resp_len, 1000);
        xSemaphoreGive(s_rtu_mutex);
        
        if (mb_ret != MB_OK) {
            ESP_LOGW(TAG_TCP, "RTU error: %d", mb_ret);
            goto send_exception;
        }
        
        // 将RTU响应转换为TCP响应
        // TCP帧: MBAP头(7) + PDU(rtu_resp_len - 1(addr) - 2(CRC))
        uint16_t tcp_pdu_len = rtu_resp_len - 3;  // 去掉地址和CRC
        uint16_t tcp_total_len = 7 + tcp_pdu_len;
        
        // 填充MBAP头
        mbap_header_t *resp_header = (mbap_header_t *)tx_buf;
        resp_header->transaction_id = header->transaction_id;  // 回显事务ID
        resp_header->protocol_id = 0;
        resp_header->length = htons(tcp_pdu_len + 1);
        resp_header->unit_id = rtu_resp[0];
        
        // 复制PDU
        memcpy(&tx_buf[7], &rtu_resp[1], tcp_pdu_len);
        
        // 发送TCP响应
        int sent = send(client->sockfd, tx_buf, tcp_total_len, 0);
        if (sent < 0) {
            ESP_LOGW(TAG_TCP, "Send failed: errno=%d", errno);
            goto cleanup;
        }
        
        ESP_LOGD(TAG_TCP, "TCP Resp: %d bytes sent", sent);
        continue;
        
    send_exception:
        // 构建异常响应
        tx_buf[0] = rx_buf[0];  // 事务ID
        tx_buf[1] = rx_buf[1];
        tx_buf[2] = 0; tx_buf[3] = 0;  // 协议ID
        tx_buf[4] = 0; tx_buf[5] = 3;  // 长度
        tx_buf[6] = unit_id;
        tx_buf[7] = func_code | 0x80;  // 异常功能码
        tx_buf[8] = 0x06;              // Slave Device Busy
        send(client->sockfd, tx_buf, 9, 0);
    }
    
cleanup:
    close(client->sockfd);
    client->active = false;
    client->sockfd = -1;
    ESP_LOGI(TAG_TCP, "Client handler exit: fd=%d", client->sockfd);
    vTaskDelete(NULL);
}

// TCP服务器主任务
void modbus_tcp_server_task(void *arg) {
    int listen_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_IP);
    if (listen_sock < 0) {
        ESP_LOGE(TAG_TCP, "Socket create failed: errno=%d", errno);
        vTaskDelete(NULL);
        return;
    }
    
    int opt = 1;
    setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    
    struct sockaddr_in server_addr = {
        .sin_family = AF_INET,
        .sin_port = htons(MB_TCP_PORT),
        .sin_addr.s_addr = htonl(INADDR_ANY),
    };
    
    if (bind(listen_sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
        ESP_LOGE(TAG_TCP, "Bind failed: errno=%d", errno);
        close(listen_sock);
        vTaskDelete(NULL);
        return;
    }
    
    if (listen(listen_sock, MB_TCP_MAX_CONN) < 0) {
        ESP_LOGE(TAG_TCP, "Listen failed: errno=%d", errno);
        close(listen_sock);
        vTaskDelete(NULL);
        return;
    }
    
    ESP_LOGI(TAG_TCP, "Modbus TCP server listening on port %d", MB_TCP_PORT);
    
    s_rtu_mutex = xSemaphoreCreateMutex();
    
    while (1) {
        struct sockaddr_in client_addr;
        socklen_t addr_len = sizeof(client_addr);
        int client_sock = accept(listen_sock, (struct sockaddr *)&client_addr, &addr_len);
        
        if (client_sock < 0) {
            ESP_LOGE(TAG_TCP, "Accept failed: errno=%d", errno);
            continue;
        }
        
        // 查找空闲连接槽
        int slot = -1;
        for (int i = 0; i < MB_TCP_MAX_CONN; i++) {
            if (!s_clients[i].active) {
                slot = i;
                break;
            }
        }
        
        if (slot < 0) {
            ESP_LOGW(TAG_TCP, "Max connections reached, rejecting");
            close(client_sock);
            continue;
        }
        
        // 设置TCP keepalive
        int keepalive = 1;
        int keepidle = 30;
        int keepintvl = 5;
        int keepcnt = 3;
        setsockopt(client_sock, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive));
        setsockopt(client_sock, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle));
        setsockopt(client_sock, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl));
        setsockopt(client_sock, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt));
        
        // 设置发送超时
        struct timeval tv = {.tv_sec = 5, .tv_usec = 0};
        setsockopt(client_sock, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
        
        s_clients[slot].sockfd = client_sock;
        s_clients[slot].active = true;
        
        char addr_str[16];
        inet_ntoa_r(client_addr.sin_addr, addr_str, sizeof(addr_str));
        ESP_LOGI(TAG_TCP, "New connection: %s:%d -> slot %d", 
                 addr_str, ntohs(client_addr.sin_port), slot);
        
        // 创建客户端处理任务
        xTaskCreate(handle_tcp_client, "mb_tcp_client", 4096, 
                     &s_clients[slot], 5, &s_clients[slot].task_handle);
    }
}

五、多设备轮询策略

除了作为TCP协议转换网关,ESP32还需要作为RTU主站主动轮询设备数据,上报到沧州虎王科技物联网平台。多设备轮询的核心挑战是:如何在有限的串口带宽下高效地轮询所有设备。

5.1 轮询配置

// polling_config.c

typedef struct {
    uint8_t slave_addr;
    uint8_t func_code;
    uint16_t start_addr;
    uint16_t reg_count;
    uint32_t poll_interval_ms;
    uint32_t last_poll_time;
    uint8_t  consecutive_errors;
    bool     online;
} poll_item_t;

#define MAX_POLL_ITEMS 32
static poll_item_t s_poll_items[MAX_POLL_ITEMS];
static int s_poll_count = 0;

// 从JSON配置加载轮询列表
esp_err_t poll_config_load(const char *json_str) {
    cJSON *root = cJSON_Parse(json_str);
    if (!root) return ESP_ERR_INVALID_ARG;
    
    cJSON *items = cJSON_GetObjectItem(root, "poll_items");
    cJSON *item;
    cJSON_ArrayForEach(item, items) {
        if (s_poll_count >= MAX_POLL_ITEMS) break;
        
        poll_item_t *p = &s_poll_items[s_poll_count];
        memset(p, 0, sizeof(poll_item_t));
        
        p->slave_addr = cJSON_GetObjectItem(item, "slave_addr")->valueint;
        p->func_code = cJSON_GetObjectItem(item, "func_code")->valueint;
        p->start_addr = cJSON_GetObjectItem(item, "start_addr")->valueint;
        p->reg_count = cJSON_GetObjectItem(item, "reg_count")->valueint;
        p->poll_interval_ms = cJSON_GetObjectItem(item, "interval_ms")->valueint;
        p->online = true;
        
        s_poll_count++;
        ESP_LOGI(TAG_POLL, "Added poll: slave=%d, func=0x%02X, addr=%d, count=%d, interval=%lums",
                 p->slave_addr, p->func_code, p->start_addr, p->reg_count,
                 (unsigned long)p->poll_interval_ms);
    }
    
    cJSON_Delete(root);
    return ESP_OK;
}

配置JSON示例:

{
  "poll_items": [
    {
      "name": "温湿度传感器1",
      "slave_addr": 1,
      "func_code": 3,
      "start_addr": 0,
      "reg_count": 4,
      "interval_ms": 5000
    },
    {
      "name": "电表1",
      "slave_addr": 2,
      "func_code": 4,
      "start_addr": 0,
      "reg_count": 10,
      "interval_ms": 10000
    },
    {
      "name": "变频器1",
      "slave_addr": 3,
      "func_code": 3,
      "start_addr": 100,
      "reg_count": 8,
      "interval_ms": 2000
    }
  ]
}

5.2 轮询任务

// 轮询任务
void polling_task(void *arg) {
    uint16_t reg_values[32];
    
    while (1) {
        uint32_t now = xTaskGetTickCount() * portTICK_PERIOD_MS;
        
        for (int i = 0; i < s_poll_count; i++) {
            poll_item_t *p = &s_poll_items[i];
            
            // 检查是否到轮询时间
            if (now - p->last_poll_time < p->poll_interval_ms) continue;
            
            p->last_poll_time = now;
            
            // 获取RTU总线锁(与TCP服务器共享)
            if (xSemaphoreTake(s_rtu_mutex, pdMS_TO_TICKS(2000)) != pdTRUE) {
                ESP_LOGW(TAG_POLL, "RTU bus busy, skip poll: slave=%d", p->slave_addr);
                continue;
            }
            
            mb_error_t ret;
            if (p->func_code == 0x03) {
                ret = mb_read_holding_registers(p->slave_addr, p->start_addr, 
                                                 p->reg_count, reg_values);
            } else if (p->func_code == 0x04) {
                ret = mb_read_input_registers(p->slave_addr, p->start_addr,
                                               p->reg_count, reg_values);
            } else {
                xSemaphoreGive(s_rtu_mutex);
                continue;
            }
            
            xSemaphoreGive(s_rtu_mutex);
            
            if (ret == MB_OK) {
                p->consecutive_errors = 0;
                if (!p->online) {
                    p->online = true;
                    ESP_LOGI(TAG_POLL, "Device back online: slave=%d", p->slave_addr);
                }
                
                // 构建数据上报JSON
                cJSON *root = cJSON_CreateObject();
                cJSON_AddNumberToObject(root, "slave_addr", p->slave_addr);
                cJSON_AddNumberToObject(root, "timestamp", now / 1000);
                cJSON *regs = cJSON_CreateArray();
                for (int j = 0; j < p->reg_count; j++) {
                    cJSON_AddItemToArray(regs, cJSON_CreateNumber(reg_values[j]));
                }
                cJSON_AddItemToObject(root, "registers", regs);
                
                char *json_str = cJSON_PrintUnformatted(root);
                // 通过MQTT上报到沧州虎王科技物联网平台
                mqtt_publish("device/modbus_gateway/telemetry", json_str, 0);
                
                ESP_LOGD(TAG_POLL, "Poll OK: slave=%d, regs[0]=%d", p->slave_addr, reg_values[0]);
                
                free(json_str);
                cJSON_Delete(root);
                
            } else {
                p->consecutive_errors++;
                if (p->consecutive_errors >= 3 && p->online) {
                    p->online = false;
                    ESP_LOGW(TAG_POLL, "Device offline: slave=%d, errors=%d",
                             p->slave_addr, p->consecutive_errors);
                    
                    // 上报设备离线事件
                    cJSON *alert = cJSON_CreateObject();
                    cJSON_AddStringToObject(alert, "event", "device_offline");
                    cJSON_AddNumberToObject(alert, "slave_addr", p->slave_addr);
                    char *alert_str = cJSON_PrintUnformatted(alert);
                    mqtt_publish("device/modbus_gateway/alert", alert_str, 0);
                    free(alert_str);
                    cJSON_Delete(alert);
                }
            }
        }
        
        // 短暂休眠
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

六、协议转换中的关键问题处理

6.1 RTU总线互斥

Modbus RTU是半双工总线,同一时刻只能有一个主站请求。当TCP客户端请求和自动轮询任务同时访问总线时,需要互斥。我们用一个互斥锁 s_rtu_mutex 保证总线访问的串行化。所有访问RTU总线的代码(TCP转发和自动轮询)都必须先获取这个锁。

6.2 帧间隔与超时

Modbus RTU要求帧之间有至少3.5个字符时间的间隔。在9600bps下约4ms。ESP32的UART驱动配置了 uart_set_rx_timeout 可以在检测到帧间间隔时通知应用层。但在实际使用中,我们发现某些设备的响应间隔不稳定,导致帧被截断。解决方案是增加一个"读后等"策略:

// 改进的接收函数:先读取已有数据,再等待一小段时间确认无更多数据
static mb_error_t mb_rtu_recv_enhanced(uint8_t *buf, size_t buf_size, 
                                          size_t *recv_len, uint32_t timeout_ms) {
    size_t total = 0;
    TickType_t start = xTaskGetTickCount();
    
    while (1) {
        if ((xTaskGetTickCount() - start) * portTICK_PERIOD_MS > timeout_ms) {
            return (total > 0) ? MB_OK : MB_ERR_TIMEOUT;
        }
        
        int len = uart_read_bytes(MB_UART_NUM, buf + total, 
                                    buf_size - total, pdMS_TO_TICKS(50));
        if (len > 0) {
            total += len;
            // 读到数据后,再等50ms确认帧结束
            int extra;
            while ((extra = uart_read_bytes(MB_UART_NUM, buf + total, 
                                             buf_size - total, pdMS_TO_TICKS(50))) > 0) {
                total += extra;
            }
            break;  // 无更多数据,帧接收完成
        }
    }
    
    if (total < 4) return MB_ERR_LENGTH;
    
    uint16_t recv_crc = (buf[total - 1] << 8) | buf[total - 2];
    if (recv_crc != crc16(buf, total - 2)) return MB_ERR_CRC;
    
    *recv_len = total;
    return MB_OK;
}

6.3 设备地址映射

Modbus TCP的Unit ID不一定直接对应RTU的从站地址。某些场景下需要地址映射,比如TCP客户端请求Unit ID=100,实际需要访问RTU从站地址=1。我们用一个映射表实现:

typedef struct {
    uint8_t tcp_unit_id;
    uint8_t rtu_slave_addr;
} unit_map_t;

static unit_map_t s_unit_map[] = {
    {1, 1}, {2, 2}, {3, 3},
    {100, 1}, {101, 2}, {102, 3},  // TCP Unit ID 100+映射到RTU从站
};

uint8_t map_tcp_to_rtu(uint8_t tcp_id) {
    for (int i = 0; i < sizeof(s_unit_map)/sizeof(s_unit_map[0]); i++) {
        if (s_unit_map[i].tcp_unit_id == tcp_id) {
            return s_unit_map[i].rtu_slave_addr;
        }
    }
    return tcp_id;  // 无映射则直通
}

七、调试与部署

Modbus网关的调试需要同时关注串口侧和网络侧。我们团队开发的ESP32工具箱V2.0集成了Modbus主站模拟功能,可以直接发送读寄存器请求并查看响应。在网关开发阶段,先用工具箱直接测试目标设备的Modbus通信是否正常,确认设备响应格式和寄存器地址正确后,再在网关固件中配置轮询参数。

在现场部署时,随身WiFi硬件调试工具(hardware.czkree.com)用来查看网关ESP32的实时日志。在车间里通过手机就能看到哪个从站设备超时了、哪个寄存器读到的值异常,不需要带笔记本电脑到现场。

几个调试技巧:

技巧一:用Modbus Poll软件验证TCP通道。在电脑上运行Modbus Poll(Modbus TCP客户端模拟器),连接到ESP32网关的502端口,读取设备数据。如果Modbus Poll能正常读到数据,说明TCP→RTU的协议转换链路是通的。

技巧二:用串口抓包验证RTU帧。在RS-485总线上并联一个USB转RS-485适配器到电脑,用串口调试软件抓取实际传输的RTU帧,与预期帧格式对比。

技巧三:观察响应时间。用ESP32的日志记录每次轮询从发送到响应的时间差。正常情况下9600bps读取10个寄存器的响应时间应小于100ms。如果响应时间经常超过500ms,检查总线上是否有干扰或设备数量过多。

八、踩坑经验

坑一:RS-485总线终端电阻

在一条RS-485总线上挂了8台设备后,通信开始不稳定,随机出现CRC错误。原因是总线两端没有加终端电阻。RS-485规范要求总线两端各接一个120欧姆终端电阻。加上终端电阻后,通信恢复稳定。

坑二:波特率不一致

不同厂家的设备默认波特率不一致,有的9600,有的19200。在一条总线上混用不同波特率的设备,只有波特率匹配的那个能正常通信。解决方案是统一配置所有设备到相同波特率,或者将不同波特率的设备分到不同串口(ESP32有3个UART端口)。

坑三:功能码与寄存器类型混淆

Modbus有保持寄存器(功能码0x03/0x06/0x10)和输入寄存器(功能码0x04)之分。有些设备的数据在保持寄存器,有些在输入寄存器。用错误的功码读取会返回异常码02(Illegal Data Address)。需要仔细阅读设备通信手册确认寄存器类型。

坑四:大小端序问题

Modbus协议中多字节数据使用大端序(高字节在前),但某些设备会在手册中标注小端序地址。读取到的两个寄存器值拼成一个32位浮点数时,字节序搞错会导致值完全不对。我们封装了一个通用的寄存器值解析函数:

// 从两个16位寄存器值解析32位浮点数
// order: 0=AB CD(大端), 1=CD AB(字交换), 2=BA DC(字节交换), 3=DC BA(小端)
float mb_regs_to_float(uint16_t reg0, uint16_t reg1, int order) {
    uint8_t bytes[4];
    switch (order) {
        case 0: // ABCD (大端)
            bytes[0] = reg0 >> 8; bytes[1] = reg0 & 0xFF;
            bytes[2] = reg1 >> 8; bytes[3] = reg1 & 0xFF;
            break;
        case 1: // CDAB (字交换)
            bytes[0] = reg1 >> 8; bytes[1] = reg1 & 0xFF;
            bytes[2] = reg0 >> 8; bytes[3] = reg0 & 0xFF;
            break;
        case 2: // BADC (字节交换)
            bytes[0] = reg0 & 0xFF; bytes[1] = reg0 >> 8;
            bytes[2] = reg1 & 0xFF; bytes[3] = reg1 >> 8;
            break;
        case 3: // DCBA (小端)
            bytes[0] = reg1 & 0xFF; bytes[1] = reg1 >> 8;
            bytes[2] = reg0 & 0xFF; bytes[3] = reg0 >> 8;
            break;
    }
    float result;
    memcpy(&result, bytes, 4);
    return result;
}

九、总结

ESP32实现Modbus RTU到TCP的协议转换,核心是三个模块的协同工作:RTU主站驱动负责串口通信、TCP服务器负责网络通信、协议转换逻辑负责两种帧格式的互相转换。加上多设备轮询和数据上报功能,就构成了一个完整的工业物联网网关。

在我们沧州虎王科技的技术体系中,这套网关已经部署在多个工厂车间,单台网关最多管理32台Modbus设备,同时支持4个TCP客户端并发访问。网关采集的数据通过MQTT上报到沧州虎王科技物联网平台,平台提供数据存储、可视化看板、告警通知等功能,形成从设备到应用的完整闭环。

配合ESP32工具箱V2.0的Modbus调试功能和随身WiFi硬件调试工具的现场便携性,整个从开发到部署的效率大幅提升。希望这些经验对正在做Modbus网关开发的同行有所帮助。

更多推荐