Skip to content

github.com/simonvetter/modbus server.go源码分析

github.com/simonvetter/modbus v1.6.0

server.go

Modbus 服务器库代码片段,主要实现了 Modbus 通讯协议的服务器端功能。 这个版本中未实现 rtuovertcp 协议

以下是代码的主要内容和功能的中文翻译:

常量和结构:

  • modbusRoleOID: Modbus 角色的 PEM OID。
  • ServerConfiguration: 服务器配置对象,包括监听地址、超时、客户端连接的最大数量、TLS 证书等。
  • CoilsRequest, DiscreteInputsRequest, HoldingRegistersRequest, InputRegistersRequest: 这些结构描述了对 Modbus 服务器的不同类型的请求。
  • RequestHandler: 接口定义了如何处理上述请求的方法。

Modbus 服务器:

  • ModbusServer: 代表一个 Modbus 服务器,包括配置、日志、互斥锁、监听器、客户端连接等。
  • NewServer(): 创建一个新的 Modbus 服务器。
  • Start(): 开始接受客户端连接。
  • Stop(): 停止接受新的客户端连接并关闭所有活动的会话。
  • acceptTCPClients(): 接受新的客户端连接(如果配置允许)。
  • handleTCPClient(): 处理一个 TCP 客户端连接。
  • handleTransport(): 对于从传输读取的每个请求,执行解码和验证,调用用户提供的处理程序,然后编码并将响应写入传输。
  • startTLS(): 在 tcpSock 上执行完整的 TLS 握手(带有客户端认证)并返回一个适合 TCP 传输的 ‘包装’ 明文套接字。

此代码提供了一个基于 Go 的 Modbus 服务器的实现,允许客户端通过 Modbus 协议与服务器通信,支持 TCP 和 TLS 传输。

接下来我们分析一下主要的函数


NewServer 这个函数的目的是根据给定的配置创建一个新的 Modbus 服务器。它首先解析和验证URL,然后根据服务器类型配置服务器的传输方式。如果在配置过程中遇到任何错误,函数会立即返回错误。

1func NewServer(conf *ServerConfiguration, reqHandler RequestHandler) (
2	ms *ModbusServer, err error) {

这是函数的声明,函数名是 NewServer。它接收两个参数:

  1. 一个指向 ServerConfiguration 类型的指针,名为 conf
  2. 一个 RequestHandler 类型的参数,名为 reqHandler。 函数返回两个值:一个指向 ModbusServer 的指针和一个错误 err
1	ms = &ModbusServer{
2		conf:		*conf,
3		handler:	reqHandler,
4	}

初始化一个新的 ModbusServer 结构体并将其地址赋值给 ms。该结构体有两个字段:confhandler,分别设置为函数的输入参数。

1	splitURL = strings.SplitN(ms.conf.URL, "://", 2)

使用 SplitN 函数分割 ms.conf.URL,基于 “://” 分隔符。这通常用于解析URL。

1	if len(splitURL) == 2 {
2		serverType  = splitURL[0]
3		ms.conf.URL = splitURL[1]
4	}

如果 splitURL 切片有两个元素,则将第一个元素赋值给 serverType,并将第二个元素重新赋值给 ms.conf.URL

1	ms.logger = newLogger(
2		fmt.Sprintf("modbus-server(%s)", ms.conf.URL), ms.conf.Logger)

使用 newLogger 函数创建一个新的日志对象并将其赋值给 ms.logger

1	if ms.conf.URL == "" {
2		ms.logger.Errorf("missing host part in URL '%s'", conf.URL)
3		err = ErrConfigurationError
4		return
5	}

检查 ms.conf.URL 是否为空。如果为空,则记录错误并设置错误变量,然后返回。

以下的代码块都是基于 serverType 的值选择不同的服务器配置。

1	switch serverType {
2	case "tcp":
3		...

如果服务器类型是 “tcp”,则进行以下配置。

1		ms.transportType	= modbusTCP

设置传输类型为 modbusTCP

1	case "tcp+tls":
2		...

如果服务器类型是 “tcp+tls”,则进行以下配置。

 1		// expect a server-side certificate
 2		if ms.conf.TLSServerCert == nil {
 3			ms.logger.Errorf("missing server certificate")
 4			err = ErrConfigurationError
 5			return
 6		}
 7
 8		// expect a CertPool object containing at least 1 CA or
 9		// leaf certificate to validate client-side certificates
10		if ms.conf.TLSClientCAs == nil {
11			ms.logger.Errorf("missing CA/client certificates")
12			err = ErrConfigurationError
13			return
14		}

对于 “tcp+tls” 类型,代码检查是否提供了服务器端证书和客户端证书。如果没有,将记录错误并返回。

1		ms.transportType	= modbusTCPOverTLS

设置传输类型为 modbusTCPOverTLS

1	default:
2		err	= ErrConfigurationError
3		return
4	}

如果 serverType 不是 “tcp” 或 “tcp+tls”,则设置错误并返回。

1	return
2}

函数结束并返回 mserr


Start 方法,它属于 ModbusServer 结构体。该方法的目的是启动 Modbus 服务器。以下是对该方法的逐行分析:

1func (ms *ModbusServer) Start() (err error) {

这是方法的声明。它不接受任何参数,但返回一个错误 err

1	ms.lock.Lock()
2	defer ms.lock.Unlock()

这两行代码用于线程同步。ms.lock.Lock() 锁定 ms.lock,以确保在同一时刻只有一个线程可以执行以下的代码。defer ms.lock.Unlock() 确保在函数退出时,无论是正常退出还是因错误而退出,锁都会被释放。

1	if ms.started {
2		return
3	}

检查 ms.started 是否为真。如果是,表示服务器已经启动,所以方法直接返回,不执行任何操作。

1	switch ms.transportType {

基于 ms.transportType 的值选择不同的操作。

1	case modbusTCP, modbusTCPOverTLS:

如果传输类型是 modbusTCPmodbusTCPOverTLS,则进行以下操作:

1		// bind to a TCP socket
2		ms.tcpListener, err	= net.Listen("tcp", ms.conf.URL)

这行代码尝试在给定的URL(ms.conf.URL)上创建一个TCP监听器。如果成功,监听器对象会赋值给 ms.tcpListener;如果失败,错误会赋值给 err

1		if err != nil {
2			return
3		}

检查前面的操作是否导致错误。如果有错误,方法直接返回。

1		// accept client connections in a goroutine
2		go ms.acceptTCPClients()

在一个新的 goroutine 中调用 ms.acceptTCPClients() 方法。这意味着服务器会在后台并发地接受来自客户端的连接。

1	default:
2		err = ErrConfigurationError
3		return
4	}

如果 ms.transportType 的值不是 modbusTCPmodbusTCPOverTLS,则设置错误并返回。

1	ms.started = true

设置 ms.started 为真,表示服务器已经启动。

1	return
2}

方法结束并返回可能的错误。

这个方法的目的是启动 ModbusServer。它首先检查服务器是否已经启动,然后基于传输类型选择如何启动服务器。对于TCP和TCP+TLS传输类型,它将在给定的URL上创建一个TCP监听器,并在后台接受客户端连接。如果遇到任何错误,方法将返回该错误。


Stop 方法,它属于 ModbusServer 结构体。该方法的目的是停止 Modbus 服务器,停止接受新的客户端连接,并关闭任何活动的会话。以下是对该方法的逐行分析:

1	ms.lock.Lock()
2	defer ms.lock.Unlock()

这两行代码用于线程同步。ms.lock.Lock() 锁定 ms.lock,确保在同一时刻只有一个线程可以执行以下的代码。defer ms.lock.Unlock() 确保在函数退出时,无论是正常退出还是因错误而退出,锁都会被释放。

1	if !ms.started {
2		return
3	}

检查 ms.started 是否为假。如果是,表示服务器尚未启动或已停止,所以方法直接返回,不执行任何操作。

1	ms.started = false

设置 ms.started 为假,表示服务器将被停止。

1	if ms.transportType == modbusTCP || ms.transportType == modbusTCPOverTLS {

检查传输类型是否是 modbusTCPmodbusTCPOverTLS。如果是其中之一,进行以下操作:

1		// close the server socket if we're listening over TCP
2		err	= ms.tcpListener.Close()

尝试关闭服务器的 TCP 监听器。如果失败,错误会赋值给 err

1		// close all active TCP clients
2		for _, sock := range ms.tcpClients{
3			sock.Close()
4		}
5	}

循环遍历所有活动的 TCP 客户端连接,并尝试关闭每一个连接。

总结:这个 Stop 方法的目的是停止 ModbusServer,停止接受新的客户端连接,并关闭所有活动的会话。它首先检查服务器是否已经启动,然后基于传输类型选择如何停止服务器。对于 TCP 和 TCP+TLS 传输类型,它将关闭服务器的 TCP 监听器并关闭所有活动的客户端连接。如果遇到任何错误,方法将返回该错误。


acceptTCPClients 方法。目的是接受新的客户端连接,并根据配置的连接限制决定是否接受连接。每打开一个新的与客户端的连接,都会启动一个 goroutine 在其中运行handleTCPClient方法处理这个客户端 的连接,这样可以利用go协程的高并发优势。以下是对该方法的逐行分析:

方法的声明如下

1// Accepts new client connections if the configured connection limit allows it.
2// Each connection is served from a dedicated goroutine to allow for concurrent
3// connections.
4func (ms *ModbusServer) acceptTCPClients() {
1	var sock     net.Conn
2	var err      error
3	var accepted bool

声明三个局部变量:sock 是新客户端的连接,err 用于捕获错误,而 accepted 表示是否接受了新的连接。

1	for {

一个无限循环,用于持续地接受新的客户端连接。

1		sock, err = ms.tcpListener.Accept()

尝试从 ms.tcpListener 接受一个新的 TCP 客户端连接。

1		if err != nil {
2			// if the server socket has just been closed, return here as
3			// this goroutine isn't going to see any new client connection
4			if errors.Is(err, net.ErrClosed) {
5				return
6			}
7			ms.logger.Warningf("failed to accept client connection: %v", err)
8			continue
9		}

如果在接受连接时出现错误,并且该错误是由于服务器套接字被关闭,那么退出循环。否则,记录警告并继续下一个迭代。

1		ms.lock.Lock()

锁定 ms.lock,确保线程安全。

1		// apply a connection limit
2		if ms.started && uint(len(ms.tcpClients)) < ms.conf.MaxClients {
3			accepted = true
4			// add the new client connection to the pool
5			ms.tcpClients = append(ms.tcpClients, sock)
6		} else {
7			accepted = false
8		}

检查服务器是否已启动且当前客户端数量是否少于配置的最大客户端数量。如果是,则接受新连接并将其添加到客户端池中;否则,标记为不接受。

1		ms.lock.Unlock()

释放锁。

 1		if accepted {
 2			// spin a client handler goroutine to serve the new client
 3			go ms.handleTCPClient(sock)
 4		} else {
 5			ms.logger.Warningf("max. number of concurrent connections " +
 6					   "reached, rejecting %v", sock.RemoteAddr())
 7			// discard the connection
 8			sock.Close()
 9		}
10	}

如果接受了新的客户端连接,则启动一个新的 goroutine 来处理该连接。否则,记录警告信息,表明已达到最大并发连接数,并关闭该连接。

1	// never reached
2	return
3}

这是方法的结束。注释指出这个 return 语句永远不会被执行,因为我们有一个无限循环。

总结:acceptTCPClients 方法持续地从 ms.tcpListener 接受新的客户端连接,并根据配置的连接限制决定是否接受。如果接受了连接,则会启动一个新的 goroutine 来处理该连接。如果达到了最大并发连接数,则拒绝并关闭该连接。


handleTCPClient 该函数的目的是处理一个TCP客户端连接。 当handleTransport()函数返回时(也就是连接已经关闭、超时或发生了无法恢复的错误),TCP套接字将被关闭,并从活跃客户端连接列表中移除。:

1func (ms *ModbusServer) handleTCPClient(sock net.Conn) {
1var err        error
2var clientRole string
3var tlsSock    net.Conn

声明三个变量:错误err、客户端角色clientRole和TLS连接tlsSock

1switch ms.transportType {

根据ms.transportType的值来判断执行哪一段代码。

1case modbusTCP:

如果传输类型是普通的TCP(即不是TLS加密的TCP)。

1// serve modbus requests over the raw TCP connection
2ms.handleTransport(
3	newTCPTransport(sock, ms.conf.Timeout, ms.conf.Logger),
4	sock.RemoteAddr().String(), "")

在原始TCP连接上为Modbus请求提供服务。使用newTCPTransport函数创建一个新的TCP传输,并调用handleTransport函数来处理它。

1case modbusTCPOverTLS:

如果传输类型是经过TLS加密的TCP。

1// start TLS negotiation over the raw TCP connection
2tlsSock, clientRole, err = ms.startTLS(sock)

开始在原始TCP连接上进行TLS协商,并获取TLS连接、客户端角色和任何错误。

1if err != nil {
2	ms.logger.Warningf("TLS handshake with %s failed: %v",
3		sock.RemoteAddr().String(), err)

如果有错误发生(例如TLS握手失败),则记录警告。

1} else {
2	// serve modbus requests over the TLS tunnel
3	ms.handleTransport(
4		newTCPTransport(tlsSock, ms.conf.Timeout, ms.conf.Logger),
5		sock.RemoteAddr().String(), clientRole)

否则,通过TLS隧道为Modbus请求提供服务。

1default:
2	ms.logger.Errorf("unimplemented transport type %v", ms.transportType)

如果传输类型不是已知的类型,则记录错误。

1// once done, remove our connection from the list of active client conns
2ms.lock.Lock()

完成之后,从活跃的客户端连接列表中移除我们的连接。首先,对连接列表加锁,以确保线程安全。

1for i := range ms.tcpClients {
2	if ms.tcpClients[i] == sock {
3		ms.tcpClients[i] = ms.tcpClients[len(ms.tcpClients)-1]
4		ms.tcpClients	 = ms.tcpClients[:len(ms.tcpClients)-1]
5		break
6	}
7}

遍历活跃的客户端连接列表,找到并移除我们的连接。

1ms.lock.Unlock()

解锁连接列表。

1// close the connection
2sock.Close()

关闭TCP连接。

1return
2}

函数结束。

总体来说,该函数的主要目的是处理一个TCP客户端连接,无论它是普通的TCP还是经过TLS加密的TCP。如果连接已关闭或发生错误,它将从活跃的客户端连接列表中移除该连接。


handleTransport 函数分析:

对 transport 中读取的每个请求,执行解码和验证,调用用户提供的处理器,然后编码并将响应写入 transport (该方法中对应相应的tcp连接)。

需要注意的是 rtuovertcp 目前版本中并未支持

1func (ms *ModbusServer) handleTransport(t transport, clientAddr string, clientRole string) {

定义一个方法,名为handleTransport,它是ModbusServer类型的方法。它接收传输接口、客户端地址和客户端角色作为参数。

1var req		*pdu
2var res		*pdu
3var err		error
4var addr	uint16
5var quantity	uint16

声明五个变量:请求req、响应res、错误err、地址addr和数量quantity

1for {

开始一个无限循环,用于持续地处理客户端请求。从这里可以看出是支持长连接的。

1req, err = t.ReadRequest()

从 transport 中读取一个请求。

1if err != nil {
2	return
3}

如果读取请求时出错,退出函数。

1switch req.functionCode {

基于请求的功能码选择执行的代码块。

1case fcReadCoils, fcReadDiscreteInputs:

如果功能码是读取线圈或读取离散输入。

1var coils	[]bool
2var resCount	int

声明两个变量:线圈的数组和响应计数。

1if len(req.payload) != 4 {
2	err = ErrProtocolError
3	break
4}

如果请求的负载长度不是4,则设置错误为协议错误并退出当前的case代码块。

1// decode address and quantity fields
2addr		= bytesToUint16(BIG_ENDIAN, req.payload[0:2])
3quantity	= bytesToUint16(BIG_ENDIAN, req.payload[2:4])

解码地址和数量字段。

 1// ensure the reply never exceeds the maximum PDU length and we
 2// never read past 0xffff
 3if quantity > 2000 || quantity == 0 {
 4	err	= ErrProtocolError
 5	break
 6}
 7if uint32(addr) + uint32(quantity) - 1 > 0xffff {
 8	err	= ErrIllegalDataAddress
 9	break
10}

确保响应永远不会超过最大PDU长度,并且我们读取的地址不会超过0xffff。

 1// invoke the appropriate handler
 2if req.functionCode == fcReadCoils {
 3	coils, err	= ms.handler.HandleCoils(&CoilsRequest{
 4		ClientAddr: clientAddr,
 5		ClientRole: clientRole,
 6		UnitId:     req.unitId,
 7		Addr:       addr,
 8		Quantity:   quantity,
 9		IsWrite:    false,
10		Args:       nil,
11	})
12} else {
13	coils, err	= ms.handler.HandleDiscreteInputs(
14		&DiscreteInputsRequest{
15			ClientAddr: clientAddr,
16			ClientRole: clientRole,
17			UnitId:     req.unitId,
18			Addr:       addr,
19			Quantity:   quantity,
20		})
21}

根据功能码调用相应的处理器。

1resCount	= len(coils)

计算线圈的数量。

1// make sure the handler returned the expected number of items
2if err == nil && resCount != int(quantity) {
3	ms.logger.Errorf("handler returned %v bools, " +
4		         "expected %v", resCount, quantity)
5	err = ErrServerDeviceFailure
6	break
7}

确保处理器返回的项目数量与预期的数量匹配。

1if err != nil {
2	break
3}

如果有错误,退出当前的case代码块。

1// assemble a response PDU
2res = &pdu{
3	unitId:		req.unitId,
4	functionCode:	req.functionCode,
5	payload:	[]byte{0},
6}

组装一个响应PDU。

1// byte count (1 byte for 8 coils)
2res.payload[0]	= uint8(resCount / 8)
3if resCount % 8 != 0 {
4	res.payload[0]++
5}

计算字节计数(8个线圈为1字节)。

1// coil values
2res.payload	= append(res.payload, encodeBools(coils)...)

将线圈的值添加到负载。

1case fcWriteSingleCoil:

如果功能码是写单个线圈。

…以下部分遵循与上述类似的模式,对请求进行解码,调用相应的处理器,然后构建一个响应。

在所有case块的末尾:

1// if there was no error processing the request but the response is nil
2// (which should never happen), emit a server failure exception code
3// and log an error
4if err == nil && res == nil {
5	err = ErrServerDeviceFailure
6	ms.logger.Errorf("internal server error (req: %v, res: %v, err: %v)",
7			 req, res, err)
8}

如果处理请求时没有错误,但响应为nil(这种情况不应该发生),则发出服务器故障异常代码并记录错误。

 1// map go errors to modbus errors, unless the error is a protocol error,
 2// in which case close the transport and return.
 3if err != nil {
 4	if err == ErrProtocolError {
 5		ms.logger.Warningf(
 6			"protocol error, closing link (
 7
 8client address: '%s')",
 9			clientAddr)
10		t.Close()
11		return
12	} else {
13		res = &pdu{
14			unitId:		req.unitId,
15			functionCode:	(0x80 | req.functionCode),
16			payload:	[]byte{mapErrorToExceptionCode(err)},
17		}
18	}
19}

将Go错误映射到Modbus错误,除非错误是协议错误,在这种情况下,关闭传输并返回。

1// write the response to the transport
2err	= t.WriteResponse(res)
3if err != nil {
4	ms.logger.Warningf("failed to write response: %v", err)
5}

将响应写入 transport 。

1// avoid holding on to stale data
2req	= nil
3res	= nil
4}

避免保留过时的数据。

1// never reached
2return
3}

结束函数。这个return语句实际上是永远不会到达的,因为函数的主体是一个无限循环。

handleTransport函数主要负责从传输中读取Modbus请求,根据功能码进行解码和验证,调用用户提供的处理器,然后编码并将响应写回传输。


当然可以,以下是对这两个函数的逐行分析:

startTLS 函数

1// startTLS performs a full TLS handshake (with client authentication) on tcpSock
2// and returns a 'wrapped' clear-text socket suitable for use by the TCP transport.

注释:startTLS 函数在 tcpSock 上执行完整的 TLS 握手(带有客户端验证),并返回一个适合 TCP 传输使用的“封装”的明文套接字。

1func (ms *ModbusServer) startTLS(tcpSock net.Conn) (
2	tlsSock *tls.Conn, clientRole string, err error) {

定义一个方法,名为startTLS,它是ModbusServer类型的方法。它接受一个TCP套接字作为参数,并返回一个TLS套接字、客户端角色和一个错误。

1var connState tls.ConnectionState

声明一个TLS连接状态变量。

1// set a 30s timeout for the TLS handshake to complete
2err = tcpSock.SetDeadline(time.Now().Add(30 * time.Second))
3if err != nil {
4	return
5}

为TLS握手完成设置一个30秒的超时,如果设置失败,则直接返回错误。

 1// start TLS negotiation over the raw TCP connection
 2tlsSock = tls.Server(tcpSock, &tls.Config{
 3	Certificates: []tls.Certificate{
 4		*ms.conf.TLSServerCert,
 5	},
 6	ClientCAs: ms.conf.TLSClientCAs,
 7	// require a valid (verified) certificate from the client
 8	// (see R-06, R-08 and R-10 of the MBAPS spec)
 9	ClientAuth: tls.RequireAndVerifyClientCert,
10	// mandate TLSv1.2 or higher (see R-01 of the MBAPS spec)
11	MinVersion: tls.VersionTLS12,
12})

开始在原始TCP连接上进行TLS协商,配置TLS参数,要求客户端提供一个有效的证书,并且只支持TLSv1.2或更高版本。

1// complete the full TLS handshake (with client cert validation)
2err = tlsSock.Handshake()
3if err != nil {
4	return
5}

完成完整的TLS握手(包括客户端证书验证),如果握手失败,则返回错误。

1// look for and extract the client's role, if any
2connState = tlsSock.ConnectionState()
3if len(connState.PeerCertificates) == 0 {
4	err = errors.New("no client certificate received")
5	return
6}

查找并提取客户端的角色(如果有的话)。如果没有收到客户端证书,则返回错误。

1// From the tls.ConnectionState doc:
2// "The first element is the leaf certificate that the connection is
3// verified against."
4clientRole = ms.extractRole(connState.PeerCertificates[0])

从客户端的证书中提取角色信息。

1return
2}

返回TLS套接字、客户端角色和错误。

extractRole 函数

1// extractRole looks for Modbus Role extensions in a certificate and returns the
2// role as a string.
3// If no role extension is found, a nil string is returned (R-23).
4// If multiple or invalid role extensions are found, a nil string is returned (R-65, R-22).

注释:extractRole 函数在证书中查找Modbus Role扩展,并以字符串形式返回角色。如果没有找到角色扩展,则返回nil字符串。如果找到多个或无效的角色扩展,则返回nil字符串。

1func (ms *ModbusServer) extractRole(cert *x509.Certificate) (role string) {

定义一个方法,名为extractRole,它是ModbusServer类型的方法。它接收一个X.509证书作为参数,并返回一个角色字符串。

1var err error
2var found bool
3var badCert bool

声明三个变量:错误err,找到标志found和坏证书标志badCert

1// walk through all extensions looking for Modbus Role OIDs
2for _, ext := range cert.Extensions {

遍历证书中的所有扩展,查找Modbus Role OIDs。

1if ext.Id.Equal(modbusRoleOID) {

如果找到了Modbus Role OID。

1// there must be only one role extension per cert (R-65)
2if found {
3	ms.logger.Warning("client certificate contains more than one role OIDs")
4	badCert = true
5	break
6}

检查证书中是否有多个角色OID。如果有,则标记为坏证书并退出循环。

1found = true

设置找到标志为true。

1// the role extension must use UTF8String encoding (R-22)
2// (the ASN1 tag for UTF8String is 0x0c)
3if len(ext.Value) < 2 || ext.Value[0] != 0x0c {
4	badCert = true
5	break
6}

检查角色扩展是否使用UTF8String编码。如果不是,则

标记为坏证书并退出循环。

1// extract the ASN1 string
2_, err = asn1.Unmarshal(ext.Value, &role)
3if err != nil {
4	ms.logger.Warningf("failed to decode Modbus Role extension: %v", err)
5	badCert = true
6	break
7}

提取ASN1字符串。如果解码失败,则标记为坏证书并退出循环。

1}
2}

结束扩展的循环。

1// blank the role if we found more than one Role extension
2if badCert {
3	role = ""
4}

如果证书被标记为坏证书,则清空角色字符串。

1return
2}

返回角色字符串。

总结:startTLS函数执行TLS握手并返回经过验证的客户端角色。extractRole函数从X.509证书中提取Modbus角色。

Related Posts

  1. github.com/simonvetter/modbus client.go源码分析
  2. github.com/simonvetter/modbus rtu_transport.go源码分析
  3. github.com/simonvetter/modbus tcp_transport.go源码分析