Press "Enter" to skip to content

串口服务搭建

串口服务搭建

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

img

打开串口的参数

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: 测试

当我们启动主程序时,控制台会打印串口开启相关内容已经健康检测内容

1763964866337

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

  • 我们使用VSPE创建一对虚拟连接的串口

1763965106906

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

1763965157664

1763965185358

  • 设置SSOM客户端

    1763965299937

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

  • 测试收发

    1763965780378

工具可正常往程序发送,也收到了来着程序的响应

部署到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

启动后,串口监听成功

1763966194741

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

1763966407674

后由现场同事配合接线,正常收发数据了

心得:只要程序提示串口开启并正常监听即可