Modbus网关设计:ESP32实现RTU到TCP协议转换的完整方案
在工业物联网领域,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网关开发的同行有所帮助。
更多推荐


所有评论(0)