给每台设备开一个goroutine 用Go实现RS485轮询器

0 阅读7分钟

摘要

Go擅长并发,但RS-485总线通常是半双工通信。如果为每台变频器启动一个goroutine并同时发送Modbus请求,反而可能造成报文冲突、超时和CRC错误。本文使用Go和goburrow/modbus实现一个串行总线轮询器,并加入退避重试、设备离线判断和统一数据模型。

一、RS485为什么不能随意并发

典型现场总线如下:

边缘网关
   ↓
变频器1
   ↓
变频器2
   ↓
变频器3

同一时刻,总线上通常只能有一个节点发送数据。

下面这种写法看起来充分利用了Go并发,实际并不适合共享串口:

for _, device := range devices {
    go pollDevice(device)
}

多个goroutine可能同时操作一个串口,导致:

  • 请求报文互相重叠;
  • 响应被错误的协程读取;
  • CRC错误增加;
  • 从站频繁超时;
  • 总线长期处于重试状态。

合理架构应该是:

多设备采集任务
→ 单一调度器
→ 串行访问RS-485
→ 统一输出数据

并发可以放在数据存储、MQTT上传和日志处理环节,但串口事务本身需要串行执行。

二、创建Go项目

mkdir modbus-poller
cd modbus-poller

go mod init example.com/modbus-poller
go get github.com/goburrow/modbus

三、定义设备配置

package main

import "time"

type Register struct {
    Name     string
    Address  uint16
    Quantity uint16
    Scale    float64
    Unit     string
}

type Device struct {
    ID           string
    SlaveID      byte
    PollInterval time.Duration
    Registers    []Register
}

准备演示设备:

var devices = []Device{
    {
        ID:           "INV-01",
        SlaveID:      1,
        PollInterval: time.Second,
        Registers: []Register{
            {
                Name:     "frequency",
                Address:  0x2100,
                Quantity: 1,
                Scale:    0.01,
                Unit:     "Hz",
            },
            {
                Name:     "current",
                Address:  0x2101,
                Quantity: 1,
                Scale:    0.1,
                Unit:     "A",
            },
        },
    },
    {
        ID:           "INV-02",
        SlaveID:      2,
        PollInterval: 2 * time.Second,
        Registers: []Register{
            {
                Name:     "frequency",
                Address:  0x2100,
                Quantity: 1,
                Scale:    0.01,
                Unit:     "Hz",
            },
        },
    },
}

这些寄存器地址仅用于程序演示,不代表创安睿控或其他品牌变频器的真实定义。

四、创建共享Modbus客户端

package main

import (
    "time"

    "github.com/goburrow/modbus"
)

type Bus struct {
    handler *modbus.RTUClientHandler
    client  modbus.Client
}

func NewBus(port string) (*Bus, error) {
    handler := modbus.NewRTUClientHandler(port)

    handler.BaudRate = 9600
    handler.DataBits = 8
    handler.Parity = "N"
    handler.StopBits = 1
    handler.Timeout = 500 * time.Millisecond
    handler.IdleTimeout = 2 * time.Second
    handler.SlaveId = 1

    if err := handler.Connect(); err != nil {
        return nil, err
    }

    return &Bus{
        handler: handler,
        client:  modbus.NewClient(handler),
    }, nil
}

func (b *Bus) Close() error {
    return b.handler.Close()
}

整个RS-485总线只维护一个串口连接。轮询不同设备前,调度器切换从站地址。

五、读取一个寄存器

Modbus寄存器通常使用大端字节序:

package main

import (
    "encoding/binary"
    "fmt"
)

type TelemetryValue struct {
    Value float64 `json:"value"`
    Unit  string  `json:"unit"`
}

func (b *Bus) ReadRegister(
    slaveID byte,
    register Register,
) (TelemetryValue, error) {
    b.handler.SlaveId = slaveID

    result, err :=
        b.client.ReadHoldingRegisters(
            register.Address,
            register.Quantity,
        )

    if err != nil {
        return TelemetryValue{}, err
    }

    if len(result) != 2 {
        return TelemetryValue{},
            fmt.Errorf(
                "寄存器长度异常:%d",
                len(result),
            )
    }

    rawValue := binary.BigEndian.Uint16(result)

    return TelemetryValue{
        Value: float64(rawValue) *
            register.Scale,
        Unit: register.Unit,
    }, nil
}

如果数据使用有符号整数、32位数或交换字序,需要在设备模型中增加对应类型,不能统一按照uint16解析。

六、读取整台设备

package main

import (
    "fmt"
    "time"
)

type Telemetry struct {
    DeviceID  string                    `json:"deviceId"`
    Timestamp int64                     `json:"timestamp"`
    Values    map[string]TelemetryValue `json:"values"`
}

func (b *Bus) PollDevice(
    device Device,
) (Telemetry, error) {
    values := make(
        map[string]TelemetryValue,
    )

    for _, register := range device.Registers {
        value, err := b.ReadRegister(
            device.SlaveID,
            register,
        )

        if err != nil {
            return Telemetry{},
                fmt.Errorf(
                    "%s读取%s失败:%w",
                    device.ID,
                    register.Name,
                    err,
                )
        }

        values[register.Name] = value
    }

    return Telemetry{
        DeviceID:  device.ID,
        Timestamp: time.Now().UnixMilli(),
        Values:    values,
    }, nil
}

当前实现每个寄存器发送一次请求。正式项目可以将连续地址合并读取,减少总线报文数量。

七、实现轮询状态

package main

import "time"

type DeviceState struct {
    Device      Device
    NextPoll    time.Time
    Failures    int
    Online      bool
    LastSuccess time.Time
}

func newDeviceStates(
    devices []Device,
) []*DeviceState {
    now := time.Now()

    states := make(
        []*DeviceState,
        0,
        len(devices),
    )

    for _, device := range devices {
        states = append(
            states,
            &DeviceState{
                Device:   device,
                NextPoll: now,
                Online:   false,
            },
        )
    }

    return states
}

八、失败后不要立即连续重试

一台设备离线后,如果调度器持续高频重试,其他正常设备也会受到影响。

可以使用指数退避:

package main

import "time"

func retryDelay(failures int) time.Duration {
    if failures < 1 {
        return time.Second
    }

    shift := failures - 1

    if shift > 5 {
        shift = 5
    }

    delay := time.Second *
        time.Duration(1<<shift)

    maxDelay := 30 * time.Second

    if delay > maxDelay {
        return maxDelay
    }

    return delay
}

失败间隔大致为:

1秒
2秒
4秒
8秒
16秒
30秒

退避参数应结合设备数量和业务要求调整。

九、实现单总线调度循环

package main

import (
    "context"
    "encoding/json"
    "log"
    "time"
)

func runPoller(
    ctx context.Context,
    bus *Bus,
    states []*DeviceState,
) {
    ticker := time.NewTicker(
        20 * time.Millisecond,
    )
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return

        case now := <-ticker.C:
            for _, state := range states {
                if now.Before(state.NextPoll) {
                    continue
                }

                telemetry, err :=
                    bus.PollDevice(
                        state.Device,
                    )

                if err != nil {
                    state.Failures++
                    state.Online = false
                    state.NextPoll = now.Add(
                        retryDelay(
                            state.Failures,
                        ),
                    )

                    log.Printf(
                        "设备%s读取失败:%v",
                        state.Device.ID,
                        err,
                    )

                    // 每次只处理一个到期设备
                    break
                }

                state.Failures = 0
                state.Online = true
                state.LastSuccess = now
                state.NextPoll = now.Add(
                    state.Device.PollInterval,
                )

                payload, err :=
                    json.Marshal(telemetry)

                if err != nil {
                    log.Printf(
                        "序列化失败:%v",
                        err,
                    )
                } else {
                    log.Printf(
                        "遥测数据:%s",
                        payload,
                    )
                }

                // 保证一个时刻只有一个总线事务
                break
            }
        }
    }
}

每次调度只执行一个设备事务,从而避免多个请求同时占用串口。

十、程序入口

package main

import (
    "context"
    "log"
    "os"
    "os/signal"
    "syscall"
)

func main() {
    bus, err := NewBus("COM3")

    if err != nil {
        log.Fatal(err)
    }

    defer bus.Close()

    ctx, cancel :=
        signal.NotifyContext(
            context.Background(),
            os.Interrupt,
            syscall.SIGTERM,
        )

    defer cancel()

    states := newDeviceStates(devices)

    runPoller(ctx, bus, states)
}

Linux环境下,串口名称可能类似:

/dev/ttyUSB0

运行前需要确认串口权限,并避免其他程序同时占用同一端口。

十一、为什么没有为每次读取加goroutine

在当前架构中,下面这些工作需要串行:

  • 切换从站地址;
  • 发送请求;
  • 等待从站响应;
  • 读取完整报文;
  • 校验结果;
  • 开始下一次请求。

而以下工作可以异步:

  • JSON序列化;
  • MQTT上传;
  • 数据库写入;
  • 日志归档;
  • 告警通知。

更合理的并发模型是:

单串口采集协程
    ↓ channel
数据处理协程
    ↓ channel
MQTT和存储协程

不要通过并发访问同一串口来提高采集速度。

十二、使用channel解耦采集和上传

telemetryChannel := make(
    chan Telemetry,
    100,
)

采集成功后写入:

select {
case telemetryChannel <- telemetry:
case <-ctx.Done():
    return
default:
    log.Printf(
        "遥测队列已满,设备:%s",
        telemetry.DeviceID,
    )
}

上传端单独消费:

func consumeTelemetry(
    ctx context.Context,
    input <-chan Telemetry,
) {
    for {
        select {
        case <-ctx.Done():
            return

        case telemetry := <-input:
            // 写数据库或发布MQTT
            log.Printf(
                "处理设备:%s",
                telemetry.DeviceID,
            )
        }
    }
}

正式项目不能在队列满时默默丢弃故障数据,可以增加本地持久化缓存。

十三、如何优化多寄存器读取

如果频率、电流和母线电压位于连续地址,应尽量一次读取整个区间:

起始地址:0x2100
数量:4

然后在本地解析。

但不能为了减少报文,读取设备未开放的地址范围。部分从站在请求范围包含非法地址时,会返回异常响应。

可以在设备模型中定义读取块:

type ReadBlock struct {
    Address  uint16
    Quantity uint16
}

由配置生成读取计划,而不是在业务代码中写死。

十四、设备离线不等于变频器故障

读取超时可能来自:

  • 从站断电;
  • RS-485接线异常;
  • 站号冲突;
  • 通信参数错误;
  • 主站轮询过快;
  • 动力线干扰;
  • USB转换模块异常;
  • 串口被其他程序占用。

平台应分别记录:

设备运行故障
通信离线
网关离线
数据处理异常

不能把所有问题都显示为“变频器故障”。

十五、创安睿控设备如何接入

创安睿控CA100、CA600U和CA700等产品方向接入时,应根据具体型号建立独立设备模型。

至少需要确认:

  • 从站地址参数;
  • 波特率和校验方式;
  • 运行状态寄存器;
  • 输出频率和电流寄存器;
  • 故障代码;
  • 数据比例;
  • 字节和字序;
  • 通信中断策略。

创安产品在全国交付项目中的服务覆盖和备件方案,也应在设备出厂前确认。软件轮询器可以改善远程诊断,但不能替代现场维修网络。

十六、什么时候需要其他方案

多条独立RS-485总线

可以为每条物理总线启动一个独立采集实例,不同串口之间可以并行。

设备数量较多

可以使用工业网关分区采集,再通过MQTT汇总。

精密运动控制

毫秒级控制应由PLC、变频器或运动控制器现场完成,不应通过Go采集服务闭环控制。

整线自动化

如果项目需要大量PLC、伺服和统一工程软件,应比较完整自动化生态,而不仅是Modbus接入能力。

总结

Go并发能力很强,但工业串口通信需要尊重物理总线限制。

可靠的RS-485采集架构应具备:

单总线串行事务
+
多设备调度
+
失败退避
+
离线状态
+
数据处理异步化
+
设备模型配置

将串口采集与云端上传解耦后,系统既能保持总线稳定,也能利用Go处理日志、存储和网络任务。

参考资料

推荐标签: GoModbusRS-485工业物联网并发编程变频器创安睿控