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秒超时
小技巧: 波特率不要盲目追求高速。我在一个工厂里遇到过,线缆长度超过 500 米,19200 波特率下数据全是乱码。降到 9600 就稳了。现场环境复杂时,稳定比速度重要。

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]);
    }
}
核心要点: libmodbus 的 API 对 RTU 和 TCP 是透明的。你只需要在初始化时选择不同的 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;
}
避坑指南: 我曾经在一个项目中,重试间隔设得太短(10ms),结果从站还没恢复,重试报文又把总线占满了,导致其他从站也跟着超时。后来我把重试间隔改成 500ms,问题就解决了。重试不是越快越好,要给对方留喘息时间。

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 本章知识体系

下面这张图,把本章的核心内容串起来了。你可以把它当作一个快速索引。

Modbus 协议栈集成知识体系 libmodbus 集成 移植与配置 RTU 与 TCP 模式 多从站轮询调度 交叉编译 静态/动态库 RTU 初始化 TCP 初始化 时间片轮询 性能优化 异常处理机制 异常码识别 重试与降级 离线探测恢复 日志记录 核心原则:统一 API 接口 + 灵活调度策略 + 健壮异常处理 RTU 与 TCP 切换只需修改初始化函数,业务代码无需改动

这张图把本章的四个核心模块串在了一起。你可以看到,移植配置是基础,RTU/TCP 是通信手段,多从站轮询是业务核心,异常处理是保障。四者缺一不可。

总结一下: libmodbus 的集成并不难,难的是把它用对、用好。移植时注意交叉编译选项,RTU 和 TCP 模式初始化有差异但 API 统一,多从站轮询要设计合理的调度策略,异常处理要兼顾重试、降级和自动恢复。把这些都做到位,你的网关才能在现场稳定运行。