设计一个串口装饰类(1)

      团队正在开发一个仪器控制软件的框架,希望该框架能兼容/容忍一些硬件的变换,以及灵活定制建立在该硬件平台之上的工作流。目标仪器使用了很多的串口通信(Serial Port),所以大家觉得应该设计/封装一个统一的串口类来管理串口通信的一致性。就我个人的意见来说,我不是建议在System.IO.Port.SerialPort上再做封装的。串口通信逻辑很简单,基本就是I/O。该类已经提供了同步阻塞模型、基于事件的异步模型,各种I/O快捷方法,所以不认为封装该类可以获得什么更多好处。但是面对框架的 一些其他需求,比如希望记录打开的串口的设备信息,或者在串口IO时,在一个点记录串口的工作状态也是一个不错的选择。记录打开的设备信息可以借由工厂模式打开串口,而IO的工作状态记录并不轻松——Serial POrt的IO已经很丰富,要完全包裹并不是一个好的注意。

我反对建立一个串口装饰类的原因:

  1. SerialPort工作模式为全双工,基于事件的工作模型。如果装饰类将这些封装,要么间接将工作模型全部暴露出来,要么将阉割工作模式,比如变成全阻塞模式,单工模式。
  2. 如果在装饰类中间接兼容SerialPort的工作模型,这等于重新发明轮子。
  3. 如果阉割工作模型,变为单工、应答模式,当设备存在主动上报状态(主动Send)时,主动上报信息将会丢失。

虽然如此,但团队似乎有意将串口工作在主从、应答式单工模式之下。其一是因为该工作方式简单,其二是因为仪器App需要与电子组自行设计的IO板进行串口通信,它们只支持主从-应答模式。在确定工作模型之后,还提出希望提供快捷I/O方法,方便设备类与设备进行通信。它们的期望是:

  1. 提供收发一体的阻塞函数。设备类只提供指令,通关快捷方法进行发送,并返回响应结果。
  2. 在收发一体的阻塞函数里实现响应结果的帧检测,如果遇到没有Read完全的帧进行继续读取,直到读取到完整的帧为止。
  3. 在收发一体的函数里实现命令的超时检测功能。当某一命令接收太慢时,抛出TimeoutException。
  4. 在收发一体的函数里实现失败重发的功能。

这些要求很适合通过串口的装饰类来实现,工作在主从-应答的单工模式之下实现起来需要考虑的较少,也比较简单。经过讨论之后,我觉得始终有 一个疑问,那就是之前老版本实现的“完整帧读取”的逻辑(关于2)。

为了保证失败重发,在于与IO板的I/O内容自定义了协议。但协议没有定义结束符号,只定义了开始符、头部、长度等。因为没有结束符,所以在收发 一体的快捷函数里,判断读取到的字节数组是否是一个完整的帧,就变成了一个问题。因为收发一体的函数逻辑是这样的:(并不是按照协议逐字段读取的,而是批量读取,虽然我觉得他们也够懒得,但确实这么做了...)

//伪代码 1
public static byte[] Talk(....)
{
    write();
    return read();
}

当没有结束符的时候,就没有办法在read的byte[]中判断一个命令的响应帧是否结束了。为了保证是一个完成的帧,又采用了完整帧检测的方法:

//伪代码 2
public static byte[] Talk(....)
{
    write();
    var str = new StringBuilder();
    do
    {
        str.append(read());
    }while(!Check(str)) //or timeout..      
}

伪代码的逻辑是,读取存在字节,然后检测是否是一个完整的可解析的协议帧,如果是,则返回;如果不是,则增量读取,直至超时。超时之后,读到的数据会被丢弃。(是吧,很诡异吧!)

面对这种形式,我个人始终有两个困惑:1,为了判断帧边界弄得如此复杂;2,当增量读取遇到两次连续实时响应时,将出错。先说问题2,这达成了一致,确实存在这种情况。但既然工作模型定义在主从-应答模式,就不存在连续两次的响应了。

对于问题1,我个人这始终有很多的疑问,但限于硬件开发的经验的单薄,只能向他人请教。首先初略看了一下SerialPort的函数,读取大概有这么几个:

public Read(Byte[], Int32, Int32)
public string ReadLine()
public string ReadExisting()

ReadLine()这个函数似乎很好理解,读取一行,即一直读直到遇到 (or )。在使用串口设备调试App时,一般可以选择结束符,应该就是这个意思。对于厂家的设备,如果采用DT模式,该函数毫无疑问很适合。但面对自定义的协议,不一定可靠,即自定义协议不一定发送 作为结束符,此函数不合适。

public Read(Byte[], Int32, Int32)我觉得也可以保证读取到完整的帧。因为默认情况下,接收缓冲区只有4096个字节。但是同事反驳的理由是读取缓冲区的内容不一定是全部,可能还有一部分在路上。我个人不太赞同这个观点,只是我觉得每次读取最大缓冲数量的字节都需要超时返回,通信时间太长,并不适合。

public string ReadExisting() 从名字来看,读取了内部缓冲区的所有数据,应该是完整的帧,但他仍然觉得还有部分数据仍然在路上。

最开始我想从一般的硬件通信流程来思考这个过程,比如当发送方发送了数据,这些数据是全部到了接收方的内部缓冲才通知Read开始工作呢,还是一有数据就开始工作。如果是前者,ReadExisting应该没有问题,如果是后者,好像也不行。当然,同事和我无法确定这个过程的逻辑。

我这个人真是倔强,始终认为伪代码2是一个不好的实现方法。只能再找方法,寻找确认一个帧已经读取完毕的方法。很不幸,ReadExisting这个方法的名字真是取得太烂了。关于ReadExisting方法, MSDN是这么描述的:

在编码的基础上,读取 SerialPort 对象的流和输入缓冲区中所有立即可用的字节。
Reads all immediately available bytes, based on the encoding, in both the stream and the input buffer of the SerialPort object.

中文、英文都贴上,意思是说该函数不光读取当前缓冲区上的所有字节,还将读完流(stream)上的所有字节。那么读完流上的字节是怎么处理呢?查看其源代码:

public string ReadExisting()
        {
            if (!IsOpen)
                throw new InvalidOperationException(SR.GetString(SR.Port_not_open));
 
            byte [] bytesReceived = new byte[BytesToRead];
 
            if (readPos < readLen)
            {           // stuff in internal buffer
                Buffer.BlockCopy(inBuffer, readPos, bytesReceived, 0, CachedBytesToRead);
            }
            internalSerialStream.Read(bytesReceived, CachedBytesToRead, bytesReceived.Length - (CachedBytesToRead));    // get everything
            // Read full characters and leave partial input in the buffer. Encoding.GetCharCount doesn't work because
            // it returns fallback characters on partial input, meaning that it overcounts. Instead, we use 
            // GetCharCount from the decoder and tell it to preserve state, so that it returns the count of full 
            // characters. Note that we don't actually want it to preserve state, so we call the decoder as if it's 
            // preserving state and then call Reset in between calls. This uses a local decoder instead of the class 
            // member decoder because that one may preserve state across SerialPort method calls.

该方法首先拷贝了当前缓冲区的所有内容,接着开始读取流上的内容。一共读取的字节数量为BytesToRead。BytesToRead属性的定义为:

     public int BytesToRead
        {
            get
            {
                if (!IsOpen)
                    throw new InvalidOperationException(SR.GetString(SR.Port_not_open));
                return internalSerialStream.BytesToRead + CachedBytesToRead; // count the number of bytes we have in the internal buffer too.
            }
        } 
// internalSerialStream.BytesToRead的定义:
// Fills comStat structure from an unmanaged function
        // to determine the number of bytes waiting in the serial driver's internal receive buffer.
        internal int BytesToRead {
            get
            {
                int errorCode = 0; // "ref" arguments need to have values, as opposed to "out" arguments
                if (UnsafeNativeMethods.ClearCommError(_handle, ref errorCode, ref comStat)  == false)
                {
                    InternalResources.WinIOError();
                }
                return (int) comStat.cbInQue;
            }
        }

看到此,可以确定ReadExisting一定能读取到至少一个发送方发送的一个完整的数组(Write(Byte[], Int32, Int32) OR Write(string)),而这个数组在绝大大多数情况下就是一个完整的帧。ReadExisting应该被认为是至少一次完整的IO过程,即使其最终读取到的数据可能是两个响应命令,因为它还读取了当前已缓冲的数据。所以在伪代码2中,Check()不应该用来做一个帧边界检查,而是应该对两个帧作边界检查。如果串口工作在主动-应答的模式下,ReadExisting读取到的一定是一个完整的响应帧。在双工模式下,它可能读到的是多个响应;在大数据分次传输时,它相对于大文件可能是不完整的。

所以,不要担心一个帧有没有收完,还是担心多个帧如何分离吧。:)

在解决了以上的疑问之后,可以开始实现串口的装饰类了。(待续)

***这不是一个好的主意,很失败。本来是私用的偷懒的写法,却引起许多新的问题或者争论。总的来说,这不够专业。***

原文地址:https://www.cnblogs.com/jjseen/p/5544330.html