串口服务搭建
背景:由于客户方需要根据我们环境监测设备采集的温湿度来通过PLC设备调控模拟现场的温湿度,我们需要将采集的数据传递给PLC设备,客户方只能通过Modbus 232串口来接收数据,因此,我们需要在原服务的基础上搭建一套支持串口传递数据的服务【9针插卡连接的是2,3,5针】

打开串口的参数
type SerialConf struct {
IsOpenSerial int `mapstructure:"is_open_serial"` // 是否打开串口【串口开关】
PortName string `mapstructure:"port_name"` // 串口【windows中的COM1 -> Linux中ttys0】
BaudRate int `mapstructure:"baud_rate"` // 波特率【一般为9600】
DataBits int `mapstructure:"data_bits"` // 数据位,每个字节包含的数据位数,通常8
StopBits int `mapstructure:"stop_bits"` // 停止位,每个字节的停止位数,通常为1
Parity string `mapstructure:"parity"` // 校验位,常见值none(无校验)
ReadTimeout int `mapstructure:"read_timeout"` // 读取超时时间
WriteTimeout int `mapstructure:"write_timeout"` // 写入超时时间
SimulateMode bool `mapstructure:"simulate_mode"` // 调试模式,若为false表示不真正打开串口
AllowedClients []string `mapstructure:"allowed_clients"` // 允许访问的客户端列表
APIKey string `mapstructure:"api_key"` // api访问秘钥
}
step1: 开启串口
func NewSerialBus(cfg config.SerialConf) SerialBus {
// 检查是否启用模拟模式
if cfg.IsOpenSerial == 0 {
logger.Info(context.Background(), "serial service is not open")
return &SerialBusImpl{
isOpen: true,
simulate: true,
}
}
serialConfig := &serial.Config{
Name: cfg.PortName,
Baud: cfg.BaudRate,
Size: byte(cfg.DataBits),
StopBits: serial.StopBits(cfg.StopBits),
Parity: serial.Parity(cfg.Parity[0]),
ReadTimeout: time.Second * time.Duration(cfg.ReadTimeout),
}
port, err := serial.OpenPort(serialConfig)
if err != nil {
logger.Info(context.Background(), "failed to open serial port", logger.Any("err: ", err))
log.Printf("警告: 无法打开串口 %s: %v,使用模拟模式", cfg.PortName, err)
return &SerialBusImpl{
isOpen: true,
simulate: true,
config: serialConfig,
}
}
logger.Info(context.Background(), "serial port open success", logger.Any("port: ", cfg.PortName))
log.Printf("成功打开串口: %s", cfg.PortName)
return &SerialBusImpl{
port: port,
config: serialConfig,
isOpen: true,
}
}
step2: 创建监听器
// NewSerialListener 创建串口监听器
func NewSerialListener(serialBus SerialBus, config *config.Config) *SerialListener {
return &SerialListener{
serialBus: serialBus,
config: config,
stopChan: make(chan struct{}),
}
}
step3: 启动串口监听
err := serialListener.Start(ctx)
// Start 启动监听服务
func (sl *SerialListener) Start(ctx context.Context) error {
if sl.isRunning {
logger.Info(context.Background(), "串口监听服务已经在运行")
return fmt.Errorf("串口监听服务已经在运行")
}
sl.isRunning = true
log.Printf("启动串口监听服务,端口: %s", sl.config.Serial.PortName)
logger.Info(context.Background(), "启动串口监听服务", logger.Any("端口:", sl.config.Serial.PortName))
// 启动监听循环
go sl.listenLoop(ctx)
logger.Info(context.Background(), "串口监听服务启动成功")
return nil
}
// listenLoop 监听主循环
func (sl *SerialListener) listenLoop(ctx context.Context) {
logger.Info(context.Background(), "*** 串口监听循环启动 ***",
logger.Any("port", sl.config.Serial.PortName),
logger.Any("baud", sl.config.Serial.BaudRate))
buffer := make([]byte, 256)
eofCount := 0
lastLogTime := time.Now()
for {
select {
case <-sl.stopChan:
return
case <-ctx.Done():
return
default:
// 读取串口数据
n, err := sl.readSerialData(buffer)
if err != nil {
if err == io.EOF {
eofCount++
// 每10秒记录一次EOF统计,避免日志过多
if time.Since(lastLogTime) > 10*time.Second && eofCount > 0 {
logger.Info(ctx, "EOF统计",
logger.Any("count", eofCount),
logger.Any("period", "10s"))
eofCount = 0
lastLogTime = time.Now()
}
} else {
sl.errorCount++
log.Printf("读取串口数据失败: %v", err)
logger.Info(context.Background(), "readSerialData", logger.Any("error: ", err))
}
time.Sleep(100 * time.Millisecond)
continue
}
if n > 0 {
sl.requestCount++
// 处理接收到的数据
go sl.processReceivedData(buffer[:n])
}
time.Sleep(100 * time.Millisecond) // 防止CPU占用过高
}
}
}
step4: 优雅关闭
// 注册优雅关闭
go func() {
<-ctx.Done()
serialListener.Stop()
serialBus.Close()
}()
// Stop 停止监听服务
func (sl *SerialListener) Stop() {
if sl.isRunning {
close(sl.stopChan)
sl.isRunning = false
log.Printf("串口监听服务已停止")
}
}
// Close 关闭串口
func (s *SerialBusImpl) Close() error {
if s.port != nil {
return s.port.Close()
}
return nil
}
step5: 开启健康监测
// 串口健康检查
healthChecker := serial.NewSerialHealthChecker(serialBus, &mqttConf.Conf.SConf)
healthChecker.Start(context.Background())
func NewSerialHealthChecker(serialBus SerialBus, config *config.SerialConf) *SerialHealthChecker {
return &SerialHealthChecker{
serialBus: serialBus,
config: config,
checkInterval: 30 * time.Second,
isHealthy: true,
}
}
func (shc *SerialHealthChecker) Start(ctx context.Context) {
go shc.healthCheckLoop(ctx)
}
func (shc *SerialHealthChecker) healthCheckLoop(ctx context.Context) {
ticker := time.NewTicker(shc.checkInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
shc.performHealthCheck()
case <-ctx.Done():
return
}
}
}
func (shc *SerialHealthChecker) performHealthCheck() {
// 检查串口状态
if shc.serialBus.IsOpen() {
shc.isHealthy = true
log.Printf("串口健康检查: 正常")
} else {
shc.isHealthy = false
log.Printf("串口健康检查: 异常 - 串口未打开")
// 可以在这里添加自动重连逻辑
if shc.config.IsOpenSerial == 1 {
log.Printf("尝试重新初始化串口...")
// 重新初始化串口的逻辑
}
}
shc.lastCheck = time.Now()
}
func (shc *SerialHealthChecker) IsHealthy() bool {
return shc.isHealthy
}
step5: 测试
当我们启动主程序时,控制台会打印串口开启相关内容已经健康检测内容

模拟工具:在windows上我们采用虚拟串口连接工具【Virtual Serial Ports Emulator,简称VSPE】和串口收发客户端【SSCOM】
- 我们使用VSPE创建一对虚拟连接的串口

点击下一页,设置虚拟COM端口,然后点击保存 :\Users\李灿\AppData\Roaming\Typora\typora-user-images


-
设置SSOM客户端

我的golang程序配置监听的是COM1, SSOM客户端配置的是COM2,使用VSPE将COM1和COM2虚拟连接并配置相同的波特率9600, 然后启动golang程序和在SSOM工具点击打开串口,并设置HEX显示,HEX发送
-
测试收发

工具可正常往程序发送,也收到了来着程序的响应
部署到centos上
由于linux和windows定义的串口不一样,仅仅修改golang程序监听的串口还不行,接下来,描述一下我部署时遇到的坑
我的golang程序使用docker-compose.yaml搭建的,启动时报错,提示容器内没有/dev/ttyS0, 原因是串口是在宿主机上,容器内没有挂载,修改后,提示没有权限,最终修改
brewing:
image: golang:1.15
container_name: "brewing_yjy"
ports:
- 30013:30013
volumes:
- ./brewing:/app/
working_dir: /app
command: >
sh -c "chmod 777 /dev/ttyS0 && /app/brewing_app"
restart: "always"
environment:
- TZ=Asia/Shanghai
devices:
- /dev/ttyS0:/dev/ttyS0
- /dev/ttyS1:/dev/ttyS1
- /dev/ttyS2:/dev/ttyS2
- /dev/ttyS3:/dev/ttyS3
networks:
- www
logging: *loki-logging
启动后,串口监听成功

到这一步后,我一直模拟往golang监听的/dev/ttyS0串口中发送数据,全都失败了,在网上搜了很多教程,例如使用socat创建两个虚拟串口,使用screen监听,都是成功的,让我再次陷入了程序不对的困扰,直到我搜到一篇文章,开启物理串口后,需要使用串口线连接后,方可收发数据。

后由现场同事配合接线,正常收发数据了
心得:只要程序提示串口开启并正常监听即可