3、高级Modbus:Modbus TCP协议、Modbus ASCII模式、多从机轮询策略、异常码处理

好,咱们继续往下聊。前面几章我们把Modbus RTU的基本功打扎实了,现在该上点硬菜了。这一章我打算聊聊Modbus的另外几种玩法——TCP、ASCII,还有实际项目中躲不开的多从机轮询和异常处理。

说实话,我最早接触Modbus TCP时,心里还嘀咕过:这不就是把串口数据包塞进TCP里吗?后来真上手了才发现,坑比想象的多。咱们一个一个来。

3.1 Modbus TCP协议——以太网上的Modbus

Modbus TCP,说白了就是把Modbus的报文封装在TCP/IP协议栈里传输。它去掉了RTU模式里的CRC校验,因为TCP/IP底层已经帮你做了差错控制。

报文结构对比

字段 RTU TCP
地址 1字节 1字节(单元标识符)
功能码 1字节 1字节
数据 N字节 N字节
校验 2字节CRC
额外 MBAP头(7字节)

这个MBAP头(Modbus Application Protocol Header)是TCP模式独有的。它包含:

  • 事务处理标识符(2字节):用来匹配请求和响应。我习惯用一个自增计数器,每次发请求加1。
  • 协议标识符(2字节):固定为0x0000,表示Modbus协议。
  • 长度(2字节):后续字节数,包括单元标识符。
  • 单元标识符(1字节):相当于RTU里的从机地址。

关键点:TCP模式下,一个连接可以承载多个从机。单元标识符用来区分不同设备。我在一个项目里见过有人把单元标识符全设成0xFF,结果所有从机都响应,总线直接乱套了。

代码示例:Modbus TCP请求封装

// 封装一个Modbus TCP读取保持寄存器的请求
uint8_t tcp_buffer[12];
uint16_t transaction_id = 0x0001;  // 事务ID,每次递增
uint16_t protocol_id = 0x0000;     // 协议ID,固定
uint16_t length = 0x0006;          // 后续字节数:单元ID(1)+功能码(1)+起始地址(2)+寄存器数量(2)
uint8_t unit_id = 0x01;            // 单元标识符
uint8_t func_code = 0x03;          // 读保持寄存器
uint16_t start_addr = 0x0000;      // 起始地址
uint16_t reg_count = 0x000A;       // 读取10个寄存器

// 填充MBAP头
tcp_buffer[0] = (transaction_id >> 8) & 0xFF;
tcp_buffer[1] = transaction_id & 0xFF;
tcp_buffer[2] = (protocol_id >> 8) & 0xFF;
tcp_buffer[3] = protocol_id & 0xFF;
tcp_buffer[4] = (length >> 8) & 0xFF;
tcp_buffer[5] = length & 0xFF;
tcp_buffer[6] = unit_id;

// 填充PDU
tcp_buffer[7] = func_code;
tcp_buffer[8] = (start_addr >> 8) & 0xFF;
tcp_buffer[9] = start_addr & 0xFF;
tcp_buffer[10] = (reg_count >> 8) & 0xFF;
tcp_buffer[11] = reg_count & 0xFF;

// 直接通过socket发送tcp_buffer,共12字节
// send(sock, tcp_buffer, 12, 0);

我的经验:TCP模式下,事务ID一定要唯一。我曾经在一个系统里忘了递增,结果从机返回的响应和请求对不上号,排查了整整一个下午。后来我养成了习惯:每次发请求前,先检查事务ID是否重复。

3.2 Modbus ASCII模式——文本化的Modbus

Modbus ASCII,嗯,这个模式现在用得少了。它把每个字节拆成两个ASCII字符发送,效率比RTU低一半。但它在某些老设备上还是能见到。

报文格式

  • 起始符:冒号 :(0x3A)
  • 地址:2个ASCII字符(如"01")
  • 功能码:2个ASCII字符(如"03")
  • 数据:每个字节转成2个ASCII字符
  • LRC校验:2个ASCII字符(纵向冗余校验)
  • 结束符:回车换行 \r\n(0x0D 0x0A)

LRC校验计算

// LRC计算:所有字节累加,取补码
uint8_t calc_lrc(uint8_t *data, uint16_t len) {
    uint8_t lrc = 0;
    for (uint16_t i = 0; i < len; i++) {
        lrc += data[i];
    }
    lrc = (~lrc) + 1;  // 取补码
    return lrc;
}

// 示例:读取从机地址01的保持寄存器(起始地址0x0000,数量0x000A)
// ASCII报文:":01030000000AF1\r\n"
// 其中F1是LRC校验值

注意:ASCII模式下的LRC校验只计算地址、功能码和数据部分,不包括起始符和结束符。我见过有人把冒号也算进去,结果校验永远对不上。

说实话,我个人不太推荐在新项目里用ASCII模式。除非你维护的是20年前的设备,否则直接用RTU或TCP更省心。ASCII唯一的优势是肉眼可读,调试时方便一点。但代价是传输效率砍半,而且解析起来也麻烦。

3.3 多从机轮询策略——别让总线闲着

实际项目中,一条总线上挂十几个从机是常事。怎么高效地轮询它们,这里面门道不少。

常见的轮询策略

策略 描述 适用场景
顺序轮询 按地址从小到大依次查询 从机数量少,实时性要求不高
优先级轮询 重要从机多查几次 关键设备需要高频监控
事件触发轮询 有变化时才查询 数据变化不频繁的场景
自适应轮询 根据响应时间动态调整频率 总线负载波动大

我常用的轮询框架

// 轮询状态机
typedef enum {
    POLL_IDLE,
    POLL_SEND,
    POLL_WAIT,
    POLL_PROCESS,
    POLL_ERROR
} poll_state_t;

typedef struct {
    uint8_t slave_addr;
    uint8_t func_code;
    uint16_t start_addr;
    uint16_t reg_count;
    uint32_t poll_interval;  // 轮询间隔(毫秒)
    uint32_t last_poll_time;
    uint8_t retry_count;
    uint8_t max_retries;
} poll_entry_t;

// 轮询主循环
void poll_master_loop(poll_entry_t *table, uint8_t entry_count) {
    uint32_t current_time = get_tick_ms();
    
    for (uint8_t i = 0; i < entry_count; i++) {
        // 检查是否到了轮询时间
        if (current_time - table[i].last_poll_time < table[i].poll_interval) {
            continue;
        }
        
        // 发送请求
        send_modbus_request(table[i].slave_addr, 
                           table[i].func_code,
                           table[i].start_addr,
                           table[i].reg_count);
        
        // 等待响应(带超时)
        uint8_t response[256];
        uint16_t resp_len = 0;
        if (wait_for_response(response, &resp_len, 100)) {
            // 处理响应
            process_response(&table[i], response, resp_len);
            table[i].last_poll_time = current_time;
            table[i].retry_count = 0;
        } else {
            // 超时处理
            table[i].retry_count++;
            if (table[i].retry_count >= table[i].max_retries) {
                // 标记从机离线
                mark_slave_offline(table[i].slave_addr);
                table[i].retry_count = 0;
            }
        }
    }
}

避坑指南:我曾经在一个项目里把所有从机的轮询间隔都设成100ms,结果总线直接爆了。后来我算了一下:10个从机,每个响应50ms,100ms轮询一次,总线利用率超过500%。正确的做法是:轮询间隔 > 从机数量 × 最大响应时间

3.4 异常码处理——别让错误变成灾难

Modbus协议定义了一套异常码,从机在遇到错误时会返回异常响应。很多开发者只处理正常情况,对异常码视而不见。嗯,这是大忌。

常见异常码

异常码 名称 含义 我的处理建议
0x01 非法功能码 从机不支持该功能 检查功能码配置,可能是设备型号不对
0x02 非法数据地址 寄存器地址超出范围 核对设备手册,地址可能偏移了
0x03 非法数据值 数据值超出允许范围 检查写入值是否在合理区间
0x04 从机设备故障 从机内部错误 记录日志,尝试重新初始化从机
0x05 确认 从机已接受请求,但需要时间处理 等待一段时间后重新查询
0x06 从机忙 从机正在处理其他任务 延迟后重试,不要连续发请求

异常响应解析

// 检查是否异常响应
uint8_t is_exception_response(uint8_t *response, uint16_t len) {
    if (len < 2) return 0;
    
    // 异常响应的功能码 = 请求功能码 + 0x80
    uint8_t req_func = response[0];  // 假设这是请求的功能码
    uint8_t resp_func = response[1]; // 实际响应的功能码
    
    if ((resp_func & 0x80) && ((resp_func & 0x7F) == req_func)) {
        return 1;  // 是异常响应
    }
    return 0;
}

// 获取异常码
uint8_t get_exception_code(uint8_t *response) {
    // 异常码在响应数据的第3个字节
    return response[2];
}

// 异常处理示例
void handle_exception(uint8_t slave_addr, uint8_t exception_code) {
    switch(exception_code) {
        case 0x01:
            log_error("从机 0x%02X 不支持该功能码", slave_addr);
            // 可能需要对不同型号的设备做功能码适配
            break;
        case 0x02:
            log_error("从机 0x%02X 地址越界", slave_addr);
            // 检查地址映射表
            check_address_map(slave_addr);
            break;
        case 0x04:
            log_error("从机 0x%02X 内部故障", slave_addr);
            // 发送复位命令或通知维护人员
            send_reset_command(slave_addr);
            break;
        case 0x06:
            log_warn("从机 0x%02X 忙,延迟重试", slave_addr);
            // 增加延迟时间
            increase_delay(slave_addr, 200);  // 增加200ms延迟
            break;
        default:
            log_error("未知异常码 0x%02X", exception_code);
            break;
    }
}

重要提醒:异常码0x06(从机忙)特别容易引发连锁反应。如果你连续重试,从机会越来越忙,最后整个总线都卡死。我的做法是:遇到0x06时,至少等待500ms再重试,而且最多重试3次。

好了,这一章的内容就到这里。Modbus TCP、ASCII、多从机轮询和异常码处理,这些都是实际项目中绕不开的坎。下一章我们聊聊Modbus的高级应用——网关和路由,那又是另一番天地了。