摘要
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处理日志、存储和网络任务。
参考资料
推荐标签: Go、Modbus、RS-485、工业物联网、并发编程、变频器、创安睿控