3、Modbus协议栈集成:libmodbus移植与配置、RTU与TCP模式实现、多从站轮询调度、异常处理机制
Modbus 在工控圈子里,说它是“通用语言”一点不过分。不管是 PLC、变频器、传感器,还是各种仪表,基本都支持它。我这些年经手的网关项目,十有八九都要跟 Modbus 打交道。
这一章,我们就来啃下这块硬骨头。我会带着你从 libmodbus 的移植开始,一步步把 RTU 和 TCP 模式跑起来,再搞定多从站轮询和异常处理。嗯,都是实战里躲不过的坎儿。
3.1 libmodbus 移植与配置
libmodbus 是个轻量级库,移植起来不算复杂。但有几个细节,不注意就会踩坑。
3.1.1 源码获取与交叉编译
先下载源码。我个人习惯用 git clone 拉取最新稳定版:
git clone https://github.com/stephane/libmodbus.git
cd libmodbus
然后配置交叉编译。这里以 ARM Cortex-A7 平台为例:
./autogen.sh
./configure --host=arm-linux-gnueabihf \
--prefix=/usr/local/arm/modbus \
--enable-static \
--disable-shared
make -j4
make install
CFLAGS 里加上 -msoft-float。我曾经在一个老款 ARM9 上忘了加,结果程序一跑就段错误,排查了半天。
3.1.2 关键配置选项
| 配置选项 | 说明 | 我的建议 |
|---|---|---|
--enable-static |
编译静态库 | 嵌入式环境推荐,避免动态库依赖问题 |
--disable-shared |
禁用动态库 | 减少根文件系统体积 |
--with-serial |
启用串口支持 | RTU 模式必须开启 |
--with-tcp |
启用 TCP 支持 | TCP 模式必须开启 |
移植完成后,把生成的 libmodbus.a 和头文件拷贝到你的工程目录里。记得在 Makefile 里链接 -lmodbus。
3.2 RTU 与 TCP 模式实现
Modbus 有两种主流传输模式:RTU(串口)和 TCP(以太网)。说白了,RTU 走的是物理串口,TCP 走的是网络 socket。两者的 API 用法很相似,但细节上有差异。
3.2.1 RTU 模式初始化
RTU 模式需要指定串口设备、波特率、校验方式等参数。看代码:
#include <modbus/modbus.h>
modbus_t *ctx = NULL;
// 初始化 RTU 上下文
ctx = modbus_new_rtu("/dev/ttyS0", 9600, 'N', 8, 1);
if (ctx == NULL) {
fprintf(stderr, "Unable to create RTU context: %s\n", modbus_strerror(errno));
return -1;
}
// 设置从站地址(本机作为主站时,这个设置的是目标从站地址)
modbus_set_slave(ctx, 1);
// 设置串口超时时间(单位:秒)
modbus_set_response_timeout(ctx, 1, 0); // 1秒超时
3.2.2 TCP 模式初始化
TCP 模式就简单多了,只需要 IP 地址和端口号:
modbus_t *ctx = NULL;
// 初始化 TCP 上下文
ctx = modbus_new_tcp("192.168.1.100", 502);
if (ctx == NULL) {
fprintf(stderr, "Unable to create TCP context: %s\n", modbus_strerror(errno));
return -1;
}
// TCP 模式不需要设置从站地址(由连接决定)
// 但如果你要通过同一个上下文访问多个从站,需要调用 modbus_set_slave()
modbus_set_slave(ctx, 1);
这里有个坑:TCP 模式下,modbus_set_slave() 设置的是报文中的单元标识符(Unit ID)。有些设备不检查这个字段,但有些会。我遇到过一台施耐德的 PLC,Unit ID 不对就直接拒绝响应。
3.2.3 数据读写示例
不管是 RTU 还是 TCP,读写 API 是统一的。下面是一个读取保持寄存器的例子:
uint16_t tab_reg[64];
int rc;
// 读取从站地址 1,起始地址 0,读取 10 个保持寄存器
rc = modbus_read_registers(ctx, 0, 10, tab_reg);
if (rc == -1) {
fprintf(stderr, "Read failed: %s\n", modbus_strerror(errno));
} else {
for (int i = 0; i < rc; i++) {
printf("Reg[%d] = %d\n", i, tab_reg[i]);
}
}
modbus_new_* 函数,后续的读写操作完全一样。这大大降低了代码的维护成本。
3.3 多从站轮询调度
实际项目中,一个网关往往要同时采集多个从站的数据。比如我一个项目里,网关要轮询 32 个电表,每个电表有 50 多个寄存器。怎么调度才高效?
3.3.1 轮询策略设计
我常用的策略是“时间片轮询 + 优先级队列”。每个从站分配一个时间片,超时或出错就跳过,不阻塞整个链路。
下面是一个简单的轮询框架:
typedef struct {
int slave_id;
int start_addr;
int num_regs;
uint16_t *data;
int interval_ms; // 轮询间隔
int last_poll_ms; // 上次轮询时间
} poll_item_t;
poll_item_t poll_list[] = {
{1, 0, 10, regs_1, 1000, 0},
{2, 0, 20, regs_2, 2000, 0},
{3, 0, 5, regs_3, 500, 0},
};
void poll_scheduler(modbus_t *ctx) {
int now = get_tick_ms();
for (int i = 0; i < sizeof(poll_list)/sizeof(poll_list[0]); i++) {
poll_item_t *item = &poll_list[i];
// 检查是否到了轮询时间
if (now - item->last_poll_ms < item->interval_ms) {
continue;
}
// 切换从站地址
modbus_set_slave(ctx, item->slave_id);
// 读取数据
int rc = modbus_read_registers(ctx, item->start_addr,
item->num_regs, item->data);
if (rc == -1) {
// 记录错误,但不阻塞
log_error("Poll slave %d failed: %s",
item->slave_id, modbus_strerror(errno));
} else {
item->last_poll_ms = now;
}
}
}
modbus_set_slave() 必须在每次读写前调用。RTU 模式下,切换从站意味着总线上的设备地址变化;TCP 模式下,则是报文中的 Unit ID 变化。忘记切换,你会读到错误的数据。
3.3.2 轮询性能优化
轮询效率直接影响网关的吞吐量。我总结了几条优化经验:
- 合并读取: 如果同一个从站的寄存器地址连续,尽量一次读取多个,而不是分多次。减少总线上的报文交互次数。
- 动态调整间隔: 对于变化不频繁的数据(如温度),可以适当拉长轮询间隔;对于实时性要求高的数据(如报警状态),缩短间隔。
- 异步 I/O: 如果硬件支持,可以用多线程或 select/poll 机制,让串口通信不阻塞主循环。
3.4 异常处理机制
Modbus 通信不可能永远一帆风顺。线缆松动、从站掉电、电磁干扰……各种幺蛾子都会出现。异常处理做不好,网关就是个摆设。
3.4.1 常见异常码
libmodbus 在读写失败时,会返回 -1,并通过 errno 或 modbus_strerror() 给出具体原因。常见的异常码如下:
| 异常码 | 含义 | 常见原因 |
|---|---|---|
EMBMDATA |
数据地址错误 | 读取的寄存器地址超出从站范围 |
EMBBADSLAVE |
从站无响应 | 从站地址错误或设备离线 |
EMBTIMEOUT |
通信超时 | 串口或网络延迟过高 |
EMBCRC |
CRC 校验错误 | 线路干扰或波特率不匹配 |
3.4.2 重试与降级策略
我的做法是:遇到临时性错误(如超时、CRC 错误),自动重试 3 次;如果连续失败超过阈值,就把该从站标记为“离线”,不再轮询,直到手动恢复或定时探测。
int read_with_retry(modbus_t *ctx, int slave_id,
int addr, int nb, uint16_t *dest) {
int retries = 3;
int rc;
while (retries-- > 0) {
modbus_set_slave(ctx, slave_id);
rc = modbus_read_registers(ctx, addr, nb, dest);
if (rc != -1) {
return rc; // 成功
}
// 记录错误,等待 100ms 后重试
log_warn("Retry %d for slave %d", 3 - retries, slave_id);
usleep(100 * 1000);
}
// 所有重试都失败
log_error("Slave %d is offline after 3 retries", slave_id);
return -1;
}
3.4.3 异常恢复机制
对于标记为“离线”的从站,我通常会启动一个“探测线程”,每隔 30 秒尝试读取一次。如果连续 3 次成功,就恢复该从站的正常轮询。这样既避免了频繁报错,又能自动恢复。
void probe_offline_slaves(modbus_t *ctx) {
for (int i = 0; i < offline_count; i++) {
int slave_id = offline_list[i];
modbus_set_slave(ctx, slave_id);
uint16_t dummy;
int rc = modbus_read_registers(ctx, 0, 1, &dummy);
if (rc != -1) {
// 恢复成功
log_info("Slave %d recovered", slave_id);
remove_from_offline_list(slave_id);
}
}
}
3.5 本章知识体系
下面这张图,把本章的核心内容串起来了。你可以把它当作一个快速索引。
这张图把本章的四个核心模块串在了一起。你可以看到,移植配置是基础,RTU/TCP 是通信手段,多从站轮询是业务核心,异常处理是保障。四者缺一不可。