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的高级应用——网关和路由,那又是另一番天地了。