物理连接示意图如下所示,每个串口挂接多个采集器。
通信协议:
包头(1B) + 地址码(1B) + 命令字(1B) + 数据长度(1B) + 校验码1(1B) + 数据正文(nB) + 校验码2(1B)。
其中,校验码1校验地址码、命令字、数据长度,校验码2校验数据正文。
1. 并发通信,性能能高。下发命令顺序与返回数据的顺序可能不一致,要保证并发能够正常通信有个前提——返回数据包在接收缓冲区中不会断包,即不会出现A包前6字节 + B包 + A包后10字节之类数据。可能存在问题:a>连续向发送缓冲区写多(如50)条命令,发送缓冲区会混乱吗? b>怎么计算某个采集设备超时? c>发送命令的速度与各路数据返回的速度的平衡能控制吗?
首先应该知道,多个采集设备通过串口R485总线连接在同一个串口上,而总线在某一时刻只能允许一路设备发送数据,其他各路设备都能收到此条数据。
采用串口并发通信有3个前提:a>使用R485总线,而不是R232;b>同一串口挂载了多路设备;c>下位机采集设备处理返回数据时间较长。如果挂接了20路设备,每路收到命令后需10ms(5ms通信+5数据准备)返回数据,则单步轮询一次共需200ms,这样的实时性应该可以接受。但是如果每路收到命令后需200ms(5ms通信+195ms数据准备)返回数据,则单步轮询一次共需3900ms,这样的实时性是很差的,往往满足不了需求。我们完全可以让195ms并发。
图4 串口通信数据流图
串口并发以及时间如下:
a> 一次轮询下发所有(20路)设备数据准备命令;
b> 各路设备收到自己地址码的查询命令后,开始准备数据(195ms并发);
c> 上位机等待300ms;
d> 上位机逐路请求采集器数据;
e> 某一个接收到请求自己数据命令后,返回b>准备好的数据(10ms);
f> 上位机接收到某路下位机数据后,处理并显示,再执行d>
共耗时:300(等各路完成195) + 10*20 = 500ms
图2串口并发流程其实是不可能出现的,因为串口总线的控制只能通过d-f步骤来。
串口同步以及时间如下:
a> 上位机逐路请求采集器数据;
b> 某一个接收到请求自己数据命令后,准备数据(195ms),返回数据(5);
c> 上位机接收到下位机数据后,处理并显示,再执行a>
共耗时:(195 + 5) *20 = 4000ms
2. 单步通信,单步通信性能稍差。逐路发送命令,a>怎么更有效的等待当前路数据返回?
代码段一(串口单步方案1,发送线程和接收事件线程通过IsWaitingReceive同步。请问IsWaitingReceive多线程控制,会不会有无问题?):
private bool IsWaitingReceive = false;
private void SendCmdThread()
{
while (true)
{
if (!IsWaitingReceive) //可以下发逐路轮询命令了
{
SendOneCmd();
IsWaitingReceive = true;
}
Thread.Sleep(50);
}
}
private int CurDeviceNo = 0;
private int TotalDeviceCnt = 20;
private void SendOneCmd()
{
//向地址码为CurDeviceNo设备发命令请求
m_SerialPort.Write(buf, offset, count);
//轮询设备
++CurDeviceNo;
if (CurDeviceNo >= TotalDeviceCnt) CurDeviceNo = 0;
}
private byte[] m_RcvBuf = new byte[512];
private int RcvCnt = 0;
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
int nLen = m_SerialPort.Read(m_RcvBuf, RcvCnt, m_SerialPort.BytesToRead);
RcvCnt += nLen;
//接下来在全局m_RcvBuf中查找合法完整数据包,解析、处理业务
······
if (CurDeviceNo == 当前数据包中地址码)
IsWaitingReceive = false;
}
代码段二(串口单步方案2,同一个线程处理发送和接收):
private int TotalDeviceCnt = 20;
private void ComCmdThread()
{
byte[] bArr = null;
while (true)
{
for (int i = 0; i < TotalDeviceCnt; i++ ) //轮询一轮
{
bArr = SendOneCmd(i);
if (bArr != null)
{
DealData(bArr);
}
}
Thread.Sleep(200);
}
}
private byte[] SendOneCmd(int curNo)
{
byte[] bRcv = null;
m_SerialPort.DiscardInBuffer(); //清空接收缓存区以前多余的数据
m_SerialPort.Write(buf, offset, count); //向curNo路设备发送命令
Thread.Sleep(50); //延时,等待下位机回数据
int nStartTime = Environment.TickCount;
while (true)
{
if (Environment.TickCount - nStartTime > 100) //超时
{
Console.WriteLine("等待超时...");
break;
}
if (m_SerialPort.BytesToRead >= 25) //如果一个完整的数据包固定25字节
{ //如果不是固定长度,可以进全局Buf,然后查找解析合法数据包
bRcv = new byte[25];
int nLen = m_SerialPort.Read(bRcv, 0, 25); //这三个25,可以用其他变量代替
break;
}
//Thread.Sleep(10); //看情况是否允许需要
}
return bRcv;
}
private void DealData(byte[] data)
{
//分析验证数据包的合法性
······
//处理合法包的业务数据
······
}
超时分串口读超时和串口写超时,主要是读超时,即ReadTimeout与Read方法之间的超时。下面谈谈他们之间的意义和实现。
Read方法是阻塞的,它一直在读串口接收缓冲区中的数据,如果接收缓冲区有数据,Read方法则返回一个或多个字节数据;如果Read方法在等待ReadTimeout毫秒时间内,串口接收区一直没有任何数据,则Read方法将甩ExceptionTimeout异常。注意,Read(outBuf, offset, count)阻塞读取的不是非等到count个字节数据,而是当前接收缓冲区大于等于1小于等于count个字节数据,即只要有数据Read方法就立刻返回。
由于Read方法的阻塞性,所以我们必须防止(如串口物理断开) Read永远不返回,而导致程序卡死。方法有:
1. 设置ReadTimeout属性为合理值,其默认值为-1,即Read永不可能因为ReadTimeout而超时返回。
2. 先判断serialPort.BytesToRead大于0,再调用Read方法,则Read肯定会返回。
代码段一:
int nStartTime = Environment.TickCount;
while(true)
{
int nNowTime = Environment.TickCount;
if(nNowTime – nStartTime > 360) //等待360ms
{
Console.WriteLine(“等待360ms后超时”);
break;
}
if(serialPort.BytesToRead > 35) //用户业务数据包长度
{
int nLen = serialPort.Read(outBuf, 0, 35);
DealData(outBuf, nLen); //验证合法包,然后处理业务
break;
}
//时间消耗在循环过程中,可在这加一行 Thread.Sleep(20);
}
代码段二:
serialPort.ReadTimeout = 1000; //等待1000ms 初始化
//接收处理函数
try
{
int nLen = serialPort.Read(outBuf, 0, serialPort. BytesToRead); //如果接收区一直没数据,时间消耗在这,等1000ms后甩TimeoutException异常
if(nLen > 0)
{
DealData(outBuf, nLen); //进全局数据队列,然后分析队列里的合法数据包
}
}
catch (TimeoutException ex)
{
Console.WriteLine("通信超时");
}
环境:Windows PC、本机虚拟COM2连接COM3、串口调试助手COM2发数据
图1
1> ReceivedBytesThreshold为默认值1;2> 一次发送41个字节;3> 每次只读6个字节;4> DataReceive事件被触发2次,读了2次6个字节即12字节数据;6> 一段时间后,有2个线程退出。
如上,把读取数据字节数6改成50,则DataReceive事件被触发1次,读了1次41字节数据。
结论:1>如果接收缓冲区数据被一次读完,则DataReceive事件仅被触发一次,如1次没读完,则事件会被触发2次。(一次发送200字节也是如此)
疑惑:1>DataReceive事件被触发时机? 2>这里的2个线程分别干了什么?
前提:此结论是在pc机上内连的,如果与下位机串口通信,发送端发送200字节,接收端不一定一帧收完,可能多帧,而每帧字节数大于ReceivedBytesThreshold时触发一次DataReceive事件吧。
图2
1> ReceivedBytesThreshold设为5;2> 一次发送2个字节;3> 每次所有个字节;4> DataReceive事件要发送3次2个字节才被触发,触发时一次读取所有字节数据。
综上所述,我猜DataReceive事件触发时机的代码如下:
Private void ListenThread.Listen()
{
while(true)
{
if(串口有接收数据)
{
DealRcvThread = new Thread(new ThreadStart(DealRcvFunc))
}
//Thread.Sleep(10);
}
}
Private void DealRcvFunc()
{
if(此接收帧数据字节数+接收缓冲区已存的字节数 > ReceivedBytesThreshold)
{
触发第一次DataReceive事件;
}
Thread.Sleep(延时);
If(接收缓冲区已存的字节数 > ReceivedBytesThreshold)
{
触发第二次DataReceive事件;
}
//Thread.Sleep(延时);
线程退出;
}
两个Thread.Sleep(延时)过程可能在某个while循环内。
RS-232串口,串口按位(bit)发送和接收字节。尽管比按字节(byte)的并行通信慢,但是串口可以在使用一根线发送数据的同时用另一根线接收数据。典型地,串口用于ASCII码字符的传输。通信使用3根线完成:(1)地线,(2)发送,(3)接收。
本文以c#中的SerialPort类为例,分析串口各参数及事件,其他平台串口库的操作类似。
专门串口通信的朋友,建议参看《Visual C++串口通信工程开发实例导航》。
一、属性
1. PortName 串口名 默认值COM1
串口对于操作系统来说是一个文件,如果设置PortName为本机不存在的串口名(即文件名),如“COM7”或“COMK”,Open()打开串口将失败,提示“端口COM7不存在”。
2. BaudRate 获取或设置串行波特率bit/s 默认值9600
比特率=波特率X单个调制状态对应的二进制位数。
RS232是要用在近距离传输上最大距离为30M
RS485用在长距离传输最大距离1200M
3. DataBits 获取或设置每个字节的标准数据位长度 默认值8
当计算机发送一个信息包,实际的数据不会是8位的,标准的值是5、7和8位。如何设置取决于你想传送的信息。比如,标准的ASCII码是0~127(7位)。扩展的ASCII码是0~255(8位)。如果数据使用简单的文本(标准 ASCII码),那么每个数据包使用7位数据。每个包是指一个字节,包括开始/停止位,数据位和奇偶校验位。由于实际数据位取决于通信协议的选取,术语“包”指任何通信的情况。
4. StopBits 获取或设置每个字节的标准停止位数 默认值One
用于表示单个包的最后一位。典型的值为1,1.5和2位。由于数据是在传输线上定时的,并且每一个设备有其自己的时钟,很可能在通信中两台设备间出现了小小的不同步。因此停止位不仅仅是表示传输的结束,并且提供计算机校正时钟同步的机会。适用于停止位的位数越多,不同时钟同步的容忍程度越大,但是数据传输率同时也越慢。
5. Parity 获取或设置奇偶校验检查协议 默认值None
在串口通信中一种简单的检错方式。有四种检错方式:偶、奇、高和低。当然没有校验位也是可以的。对于偶和奇校验的情况,串口会设置校验位(数据位后面的一位),用一个值确保传输的数据有偶个或者奇个逻辑高位。例如,如果数据是011,那么对于偶校验,校验位为0,保证逻辑高的位数是偶数个。如果是奇校验,
校验位位1,这样就有3个逻辑高位。高位和低位不真正的检查数据,简单置位逻辑高或者逻辑低校验。这样使得接收设备能够知道一个位的状态,有机会判断是否有噪声干扰了通信或者是否传输和接收数据是否不同步。
6. ParityReplace 获取或设置一个字节,该字节在发生奇偶校验错误时替换数据流中的无效字节 默认值63(?)
7. ReadBufferSize 获取或设置 SerialPort 输入缓冲区的大小 默认值4096
ReadBufferSize 属性忽略任何小于 4096 的值??由于 ReadBufferSize 属性只表示 Windows 创建的缓冲区,而
BytesToRead 属性除了表示 Windows 创建的缓冲区外还表示 SerialPort 缓冲区,所以 BytesToRead 属性可以返回一个比 ReadBufferSize 属性大的值。
8. ReadTimeout 获取或设置读取操作未完成时发生超时之前的毫秒数 默认值-1
确切的说ReadTimeout设置了Read(outBuf, offset, count)超时等待的时间,即如果接收缓冲区一直没有数据,则Read()等待ReadTimeout毫秒后甩TimeoutException异常。但是在ReadTimeout期间,只要有一个数据,则Read()立马返回,而不是等待count个字节数据。ReadTimeout不是为了Read()阻塞等待count个字节,那它有什么意义?
9. ReceivedBytesThreshold 获取或设置 DataReceived 事件发生前内部输入缓冲区中的字节数 默认值1
如果缓冲区一次性有4个字节数据,那么DataReceived事件是触发1次还是4次?
10. WriteBufferSize 获取或设置串行端口输出缓冲区的大小 默认值2048
ReadBufferSize 属性忽略任何小于 4096 的值。
由于 ReadBufferSize 属性仅表示 Windows 创建的缓冲区,所以它可以返回比
BytesToRead属性小的值,这表示
SerialPort 和 Windows 创建的缓冲区。
11. WreiteTimeout 获取或设置写入操作未完成时发生超时之前的毫秒数 默认值-1
写入超时值在 Win32 通信 API 中最初被设置为 500 毫秒。此属性允许您设置此值。此值可以设置为 0 以立即从写入操作返回,或设置为任意正值,也可以设置为默认的 InfiniteTimeout。此属性不影响
BaseStream 的
BeginWrite 方法。
12. BytesToRead 获取接收缓冲区中数据的字节数
由于
ReadBufferSize 属性只表示 Windows 创建的缓冲区,而 BytesToRead 属性除了表示 Windows 创建的缓冲区外还表示 SerialPort 缓冲区,所以 BytesToRead 属性可以返回一个比 ReadBufferSize 属性大的值。
13. BytesToWrite 获取发送缓冲区中数据的字节数
14. NewLine 获取或设置用于解释 ReadLine( )和WriteLine( )方法调用结束的值 默认值“\n”
15. DiscardNull 获取或设置一个值,指示 Null 字节在端口和接收缓冲区之间传输时是否被忽略 默认值false
正常情况下,特别是对于二进制传输而言,此值应该设置为 false。将此属性设置为 true 会使 UTF32 和 UTF16 编码字节产生意外结果。
16. Handshake 获取或设置串行端口数据传输的握手协议 默认值None
使用握手时,将指示连接到 SerialPort 对象的设备在缓冲区中至少有 (ReadBufferSize-1024) 个字节时停止发送数据。当缓冲区中的字节数小于等于 1024 时,将指示设备重新开始发送数据。如果设备在大于 1024 个字节的块中发送数据,可能会导致缓冲区溢出。
如果将 Handshake 属性设置为
RequestToSendXOnXOff 并将
CtsHolding 设置为 false,则不会发送 XOff 字符。如果后来将 CtsHolding 设置为 true,则必须发送更多的数据后才会发送 XOff 字符。
17. Encodeing 获取或设置传输前后文本转换的字节编码 默认为
ASCIIEncoding
ASCIIEncoding 不提供错误检测。出于安全原因,建议您使用
UTF8Encoding、UnicodeEncoding 或
UTF32Encoding 并启用错误检测。
ASCIIEncoding 仅支持 U+0000 和 U+007F之间的 Unicode 字符值。因此,UTF8Encoding、UnicodeEncoding 和 UTF32Encoding 可以更好地适应全球化的应用程序。
18. DtrEnable 获取或设置一个值,该值在串行通信过程中启用数据终端就绪 (DTR) 信号 默认值 false
在 XON/XOFF 软件握手、请求发送/可以发送 (RTS/CTS) 硬件握手和调制解调器通信的过程中通常启用数据终端就绪 (DTR)。
19. RtsEnable 获取或设置一个值,该值指示在串行通信中是否启用请求发送 (RTS) 信号 默认值false
请求发送 (RTS) 信号通常用在请求发送/可以发送 (RTS/CTS) 硬件握手中。
20. CDHolding 获取端口的载波检测行的状态
此属性可用于监视端口的载波检测行的状态。无载波通常表明接收方已挂断且载波已被丢弃。
21. CtsHolding 获取“可以发送”行的状态
在请求发送/可以发送 (RTS/CTS) 硬件握手中使用可以发送 (CTS) 行。发送数据之前端口会查询 CTS 行。
22. DsrHolding 获取数据设置就绪 (DSR) 信号的状态
在数据设置就绪/数据终端就绪 (DSR/DTR) 握手中使用此属性。通常由调制解调器将数据设置就绪 (DSR) 信号发送到端口,以表明它已经为数据传输或数据接收做好准备。
23. BufferSize 值1024
24. maxDataBits 值8
25. minDataBits 值5
26. SERIAL_NAME 值\\Device\\Serial
二、方法
1. Open() 打开一个新的串行端口连接
2. Close() 关闭端口连接,将
IsOpen 属性设置为 false,并释放内部
Stream 对象
3. Read(Byte[], int, int) 输入缓冲区读取一些字节并将那些字节写入字节数组中指定的偏移量处
4. ReadByte() 从
SerialPort 输入缓冲区中同步读取一个字节
5. ReadChar() 从
SerialPort 输入缓冲区中同步读取一个字符
6. ReadExisting() 在编码的基础上,读取
SerialPort 对象的流和输入缓冲区中所有立即可用的字节
6. ReadLine() 一直读取到输入缓冲区中的
NewLine 值
7. ReadTo() 一直读取到输入缓冲区中的指定 value 的字符串
8. Write(string) 将指定的字符串写入串行端口
9. Write(Byte[], int, int) 使用缓冲区的数据将指定数量的字符写入串行端口
10. WriteLine() 将指定字符串和NewLine值写入输出缓冲区
11. DiscardInBuffer() 丢弃接收缓冲区的数据
12. DiscardOutBuffer() 丢弃发送缓冲区的数据
12. static GetPortNanes() 获取当前计算机的串口名称数组
三、事件
1. DataReceive事件 数据接收事件的方法
不保证对接收到的每个字节引发 DataReceived 事件。 使用 BytesToRead 属性确定缓冲区中剩余的要读取的数据量。从 SerialPort 对象接收数据时,将在辅助线程上引发 DataReceived 事件。
2. PinChanged事件 串行管脚更改事件的方法
在 SerialPort 对象进入 BreakState 时引发,但在端口退出 BreakState 时不引发。将在辅助线程上引发 PinChanged 事件。
3. ErrorReceived事件 错误事件的方法
如果在流的尾字节上出现奇偶校验错误,将向输入缓冲区添加一个值为 126 的额外字节。将在辅助线程上引发 PinChanged 事件。
编辑
下面设计的串口通信协议用于完成双机互联程序的文件传输功能,简称SPCP。设计思想基于枕帧传输方式,即在向串口发送数据时是一帧一帧地发送。为了保证可靠传输,通过握手建立连接,在每一帧的传输中,采用发送/应答/重连/失败方式。
一、帧格式
双机互联采用3种帧:控制帧、数据帧与短语帧。控制帧与数据帧用于文件传输,短语帧用于短消息发送。
1. 数据帧
包括帧头、负载数据和校验和。帧头6个字节,如下图所示,其中count表示负载数据的字节数,Check1表示第2与第3字节校验和。
0 |
1 |
2 |
3 |
4 |
5 |
0x00 |
0x00 |
count |
Check1 |
图1 数据帧头
负载数据长count字节,最多不超过0x1000字节。校验和占2个字节,是对负载数据计算校验和的结构。
2. 控制帧
控制帧和控制信号合作完成通信同步与控制任务,它只有帧头,长为6字节。
0 |
1 |
2 |
3 |
4 |
5 |
0x00 |
0x01 |
nPack |
Check2 |
图2 传输起始控制帧
nPack表示本次传输共发送帧数,便于让接收方控制进度,Check2为第2,3字节的校验和。当nPack=Check2=0时,表示本次传输结束,当接收方收到该帧时,不管是否已收到应收的帧数,都将结束此次传输。在没有发生传输错误的情况下,一次传输只会出现两次控制帧,第一次在传输开始时,第二次在传输结束时。
3. 短语帧
短语帧中负载均为文本数据。发送与接收该帧不需要建立连接也没有错误控制,只是在帧头和帧尾插入了同步信号。
0x03 |
文本数据 |
0x03 |
图3 短语帧结构
二、控制信号
为提高通信效率,SPCP使用控制信号进行通信同步、纠错灯各种控制人物。SPCP定义了6中控制信号:
const BYTE SYN[1] = {0x1}; //请求
const BYTE ACK[1] = {0x2}; //响应
const BYTE RESEND[1] = {0x4}; //重发
const BYTE BUSY[1] = {0x7}; //忙
const BYTE BYE[3] = {6, 0, 6}; //断开连接
const BYTE STR[1] = {0x3}; //短信息同步信号
三、数据分帧及数据重组
应用程序发送过来的数据作为一个流按SPCP进行分帧,切割后为每帧加上帧头和校验和,放入SPCP内部缓冲区内准备发送;在接收端,分帧的数据去掉帧头重新归到接收缓冲区流,由应用程序接收。
图4 数据分帧过程
图5 数据重组过程
四、传输流程
在发送数据前,SPCP发送方将应用程序希望发送的数据进行分帧,然后按照下面的步骤通信。
1. 握手
- 由发送端发送SYN信号,等待反馈;
- 接收端收到SYN后返回ACK信号;
- 发送端接收到ACK信号后,由发送端发送控制首帧;
- 接收到收到控制首帧后,CheckSum错误则发送RESEND信号,然后重复步骤c~d;如果正确,发ACK信号;
- 发送端收到ACK信号后,转到2数据传输的步骤a。
2. 数据传输
- 由发送端发送第i帧帧头,等待反馈;若发送方发现该帧是控制结束帧,则转到3断开连接的步骤a;
- 接收端收到帧头后,帧长度校验和错误则发RESEND信号,然后重复步骤a~b。如果正确,发ACK信号;
- 若发送端收到ACK信号,则发送帧中数据和校验和;
- 接收端收到数据后,数据校验和错误则发RESEND信号,然后重复步骤c~d。如果正确,发ACK信号;
- 若发送端收到ACK信号,则该帧数据发送成功。发送端发送SYN信号,开始下一帧的握手过程;
- 若接收端收到SYN信号,则发送ACK信号进行确认;
- 若发送端接收到ACK信号,则重复a~e步骤进行下一帧的传输。
3. 断开连接
- 发送方发送控制结束帧,准备结束此次通信;
- 若接收端接收控制结束帧,则发送ACK信号,准备结束此次通信;
- 若发送端收到ACK信号,则发送BYE控制信号;
- 若接收方收到BYE信号,则拆除此次连接,同时发送ACK信号;
- 发送方收到ACK信号后,拆除连接。
注意:上述3个阶段的所有步骤中都存在超时处理,即若通信的某一方在限定时间内没有收到答复,则将断开连接,结束此次通信。此外,如果因为某错误而引起的重发次数超过3次,也就中断此次通信。
一、串口API
1. 打开串口
使用CreateFile函数可以打开串口。通常有两种方式可以打开,一种是同步方式(NonOverlapped),另外一种异步方式(Overlapped)。
HANDLE hComm;
hComm = CreateFile( gszPort, //串口名
GENERIC_READ|GENERIC_WRITE //读写
0, //注意:串口为不可共享设备,本参数须为0
0,
OPEN_EXISTING,
FILE_FLAG_OVERLAPPED, //异步方式
0);
if(hComm == INVALID_HANDLE_VALUE) //打开串口失败处理
······
2. 配置串口
DCB(Device Control Block)结构定义了串口通信设备的控制设置,有3种方式可以初始化DCB。
- 通过GetCommState函数得到DCB的初始值:
DCB dcb;
memset(&dcb, 0, sizeof(dcb));
if(!GetCommState(hComm, &dcb)) …… //错误处理
else …… //已准备就绪
- 用BuildCommDCB函数初始化DCB结构:
DCB dcb;
memset(&dcb, 0, sizeof(dcb));
dcb.DCBlength = sizeof(dcb);
if(!BuildCommDCB(“9600,n,8,1”, &dcb)) …… //参数配置错误
else …… //已准备就绪
- 用SetCommState函数手动设置DCB初值:
DCB dcb;
memset(&dcb, 0, sizeof(dcb));
if(!GetCommState(hComm, &dcb)) return FALSE;
dcb.BaudRate = CBR_9600;
3. 流控设备
流控制有如下两种设置:
- 硬件流控制:硬件流控有两种,DTE/DSR方式和RTS/CTS方式。这与DCB结构的初始化有关系,建议采用标准流行的流控方式,采用硬件流控时,DTE、DSR、RTS、CTS的逻辑位直接影响到数据的读写及收发数据的缓冲区控制。
- 软件流控制:串口通信中采用特殊字符XON和XOFF作为控制串口数据的收发。
注意:在不设置流控制方式或软件流控的情况下,基本上不会出现什么问题,但在硬件流控下,规范的RTS_CONTROL_HANDSHAKE流控方式的含义本来是当缓冲区快满的时候RTS会自动OFF通知对方暂停发送,当缓冲区重新空出来的时候,RTS会自动ON,但很多时候当RTS变OFF以后即使已经清空了缓冲区,RTS也不会自动的ON,造成对方停在那里不发送了。所以,如果要用硬件流控制的话,还要在接收后最好加上检测缓冲区大小的判断,具体做法是使用ClearCommError后返回COMSTAT.cbInQue,当缓冲区已经空出来的时候,要使用invoke(EscapeCommFunction,hComm,SETRTS)重新将RTS设置为ON。
4. 串口读写操作
串口读写有两种方式:同步方式(NonOverlapped)和异步方式(Overlapped)。同步方式指必须完成了读写操作,函数才返回,这可能会使程序无响应,因为如果在读写时发生了错误,永远不返回就会出错,可能线程将停在原地。而异步方式则灵活的多,一旦读写不成功,就将读写挂起,函数直接返回,可以通过GetLastError函数得知读写未成功的原因,所以串口读写常常采用异步方式操作。
ReadFile()函数用于完成读操作,异步方式的读操作为:
DWORD dwRead;
BOOL fWaitingOnRead = FALSE;
OVERLAPPED osReader;
memset(&osReader, 0, sizeof(osReader));
osReader.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
if(osReader.hEvent == NULL) …… //错误处理
if(!fWaitingOnRead)
{
if(!ReadFile(hComm, lpBuf, READ_BUF_SIZE, &dwRead, &osReader)) //读串口
{
if(GetLastError() != ERROR_IO_PENDING) …… //报告错误
else fWaitingOnRead = TRUE;
}
}
else
{
//读取完成,不必在调用GetOverlappedResults函数
HandleASuccessfulRead(lpBuf, dwRead);
}
//如果读操作被挂起,可以调用WaitForSingleObject()函数或
//WaitForMuntilpleObjects()等待读操作完成或者超时发生,
//再调用GetOverlappedResult()得到想要的信息。
if(fWaitingOnRead)
{
dwRes = WaitForSingleObject(osReader.hEvent, READ_TIMEOUT);
switch(dwRes)
{
case WAIT_OBJECT_0: //完成读操作
if(!GetOverlappedResult(hComm, &osReader, &dwRead, FALSE)) …… //错误
else …… //全部读取成功
HandleASuccessfulRead(lpBuf, dwRead);
fWaitintOnRead = FALSE;
break;
case WAIT_TIMEOUT: //操作尚未完成
……. //处理其他任务
break;
default:
…… //出现错误
break;
}
}
注意上述代码在处理多线程串口在windows系列下存在一些问题,修改完成后代码参考1.4节。
5. 关闭串口
程序结束或需要释放串口资源时,必须正确关闭串口。调用CloseHandle函数关闭串口的句柄即可,
CloseHandle(hComm);
值得注意的是,在关闭串口前必须保证读写串口线程已经退出,否则会引起误操作,一般采用的办法是使用事件驱动机制,启动一事件,通知串口读写线程强制退出。
6. 其他问题
串口通信中其他必须处理的问题主要有如下几个:
- 检测通信事件:用SetCommMask()设置想要得到的通信事件的掩码,再调用WaitCommEvent()检测通信事件的发生。可设置事件标志有EV_BREAK \ EV_VTS \ EV_DSR \ EV_ERR \ EV_RING \ EV_RLSD \ EV_RXCHAR \ EV_RXFLAG \ EV_TXEMPTY。
- 处理通信超时:在通信中,超时是一个很重要的考虑因素,因为数据接收过程中由于某种原因突然中断或停止,如果不采取超时控制机制,将会使得I/O线程被挂起或无限阻塞。超时设置分两步,首先设置COMMTIMEOUTS结构的5个变量,然后调用SetcommTimeouts()设置超时值,对于使用异步方式读写的操作,如果操作挂起后,异步成功完成了读写,WaitForSingleObject()或WaitForMultipleObjects()将返回WAIT_OBJECT_0,GetOverlappedResult()返回TRUE。其实还可以用GetCommTimeouts()得到系统初始值。
- 错误处理和通信状态:在串口通信中,可以会产生很多的错误,使用ClearCommError()可以检测错误并且清除错误条件。
- WaitCommEvent()返回时,只是指出了如CTS等等状态有变化。但要了解具体变化情况必须使用GetCommModemStatus()获得串口线路状态更详细的信息。
二、串口操作方式
1. 同步方式
同步(NonOverlapped)方式是比较简单的一种方式,编写代码长度明显少于异步(Overlapped)方式。同步方式中,读串口的函数试图在串口的接收缓冲区中读取规定数据的数据,直到规定数据的数据全部被读出或设定超时时间已到时才返回。例如:
COMMTIMEOUTS timeOver;
memset(&timeOver, 0, sizeof(timeOver));
DWORD timeMultiplier, timeConstant;
……
timeOver.ReadTotalTimeoutMultiplier = timeMultiplier;
timeOver.ReadTotalTimeoutConstant = timeConstant;
SetCommTimeouts(hComm, &timeOver);
……
ReadFile(hComm, inBuffer, nWantRead, &nRealRead, NULL); //NULL指采用同步文件读写
如果所规定的待读取数据的数目nWantRead较大且设定的超时时间较长,而接收缓冲区中数据较少,则可能引起线程阻塞。解决这一问题的方法是检查COSTAT结构的cbInQue成员,该成员的大小即为接收缓冲区中处于等待状态的实际个数。如果令nWantRead的值等于COMSTAT.cbInQue,就能很好的防止线程阻塞。
2. 异步方式
在异步方式中,利用Windows的多线程结构,可以让串口的读写操作在后台进行,而应用程序的其他部分在前台执行。例如:
OVERLAPPED wrOverlapped;
COMMTIMEOUTS timeOVer;
memset(&timeOver, 0, sizeof(timeOver));
DWORD timeMultiplier, timeConstant;
…… //给timeMultiplier, timeConstant赋值
timeOver.ReadTotalTimeoutMultiplier = timeMultiplier;
timeOver.ReadTotalTimeoutConstant = timeConstant;
SetCommTimeouts(hComm, &timeOver);
wrOverlapped.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
……
ReadFile(hComm, inBuffer, nWantRead, &nRealRead, &wrOverlapped);
GetOverlappedResult(hComm, &wrOverlapped, &nRealRead,TRUE);
……
ResetEvent(wrOverlapped.hEvent);
上面代码中的ReadFile由于采用了异步方式,所以只返回数据是否已经开始读入的状态,并不返回实际的读入数据,即ReadFile中的nRealRead无效。实际读入的数据由GetOverlappedResult返回的,该函数的最后一个参数值为TRUE,表示它等待异步操作结束后才返回到应用程序,此时,GetOverlappedResult与WaitForSingleObject函数无效。
3. 查询方式
即一个进程中的某一线程定时地查询串口的接收缓冲区,如果缓冲区中有数据,就读取数据;若缓冲区没有数据,该线程将继续执行,因此会占用大量的CPU时间,它实际上是同步方式的一种派生。例如:
COMMTIMEOUTS timeOver,
Memset(&timeOver, 0, sizeof(timeOver));
timeOver.ReadIntervalTimeout = MAXWORD;
SetCommTimeouts(hComm, &timeOver);
……ReadFile(hComm, inBuffer, nWantRead, &nRealRead, NULL);
除了COMMTIMEOUTS结构的变量timeOver设置不同外,查询方式与同步方式在程序代码方面很类似,但二者的工作方式却差别很大。尽管ReadFile采用的也是同步文件读写方式,但由于timeOver的区间超过时间设置为MAXWORD,所以ReadFile每次将读出接收队列中的所有处于等待状态的数据,一次最多可读出nWantRead个字节的数据。
4. 事件驱动方式
若对端口数据的响应时间要求较严格,可采用事件驱动方式。事件驱动方式通过设置事件通知,当所希望的事件发生时,Windows发出该事件已经发生的通知。Windows定义了9中串口通信事件,常用的有以下3中:
- EV_RXCHAR:接收到一个字节,并放入输入缓冲区。
- EV_TXEMPTY:输出缓冲区中的最后一个字符,发送出去。
- EV_RXFLAG:接收到事件字符(DCB结构中的EvtChar成员),放入输入缓冲区。
在用SetCommMask()制定了有用的事件后,应用程序可调用WaitCommEvent()来等待事件的发生。SetCommMask可使WaitCommEvent()中止。例如:
COMSTAT comStat;
DWORD dwEvent;
SetCommMask(hComm, EV_RXCHAR);
……
if(WaitCommEvent(hComm, &dwEvent, NULL))
if((dwEvent & EV_RXCHAR) && comstat.cbInQue)
ReadFile(hComm, inBuffer, comstat.cbInQue, &nRealRead, NULL);
5. 总结
一般要求情况下,查询方式是一种最直接的读串口的方式。但定时查询存在一个致命的弱点,即查询是定时发生的,可能发生的过早或过晚。在数据变化较快的情况下,特别是主控计算机的串口通过扩展板扩展多个时,需定时对所有串口轮流查询,容易发生数据的丢失。虽然定时间隔越小,数据的实时性越高,但系统的资源也被占用越多。
Windows中提出文件读写的异步方式,主要是针对文件IO相对较慢的速度而进行的改进,它利用了系统的多线程结构,虽然在Windows中没有实现任何对文件IO的异步操作,但它却能对串口进行异步操作。采用异步方式,可以提高系统整体性能,在对系统强壮性要求高的场合,建议采用这种方式。
事件驱动方式是一种高效的串口读方式。这种方式实时性较高,特别对扩展了多个串口的情况,并不要求像查询方式那样定时地对所有串口轮询,而像中断方式那样,只有当设定的事件发生时,应用程序得到windows操作系统发出的消息后,才进行相应处理,以免数据丢失。
这里的“数据接收”特指下位机发送给上位机的数据。其“时机”有两种方式:1>上位机请求下位机数据时,下位机被动“数据发送”给上位机;2>下位机主动“数据发送”给上位机。
下面分析这两种方式应用场合。
方式1>的实现方式有两种,a>在上位机界面,用户主动触发发送请求命令,如点击按钮;b>上位机定时发送请求命令。有下列情形之一,使用方式1>:
a> 使用方式2>数据发送频率过快,导致串口缓冲器压力过大;或没必要使用2>方式过频繁更新上位机数据。
b> 一台上位机挂载多个下位机,而且是单工串口通信,导致下位机无法掌控发送时机,所以必须上位机控制发送进度,采用请求下位机A数据,阻塞等待下位机A返回数据,然后请求下位机B数据······
方式2>实现方式有a>下位机数据改变时,主动发送数据给上位机;b>如果下位机是由控制板和采集器组成,而控制板可以控制定时,可以由下位机定时给上位机发送数据。有下列情形之一,使用方式2>:
a> 下位机数据更改频率较慢,而采用上位机定时请求定时过快数据浪费,定时过慢数据更新很不及时,所有采用下位机数据变化时主动上传数据。
情形一:
下位机数据有时1秒钟变化2-5次,有时5分钟变化一次。使用下位机数据改变时发送。
情形二:
下位机数据有时1秒钟变化20次。如果a>过快更新导致串口和上位机UI刷新压力;b>数据发送不是变化累计而是完整数据,使用上位机定时请求。
情形三:
上位机无法控制进度,容易导致串口压力过大等问题,请使用上位机定时请求或者用户触发时再请求。
如果通信物理设备连接如下图1所示,即计算机有1到多个串口,而每个串口设备下仅仅挂载1个采集器,那么协议就没必要地址码,协议可以是:同步头 + 命令字 + 数据长度 + 数据正文 + 校验码。此时各个串口通信是互不相关的。
接收数据可以采用一个队列,每当串口有数据,就直接进入数据队列,另一边再出队列,试图查找一个完整的合法数据包。接收数据时的进出队可以在一个线程里执行;也可以在两个线程处理,但得同步队列。
下面我贴出我的VC代码(进出数据队列在同一个线程):
//MSComm控件消息
void CSSRRDlg::OnOnCommMscommRadio()
{
int nEvent;
nEvent = m_msCom.GetCommEvent();
switch(nEvent)
{
case 2: //接收缓冲区有数据
g_bCommDataRcv = true; //为了控制数据接收线程
break;
case 3:
break;
default:
break;
}
}
每当串口接收缓冲区有数据时,触发g_bCommDataRcv = true告诉数据接收线程,可以读串口数据。
下面数据接收线程在开机或用户控制下启动,等待数据接收和处理。
UINT CSSRRDlg::FetchRadioComMsgThread(LPVOID p)
{
int i, flag=0;
unsigned char tmpQBuf[200];
CSSRRDlg *pThis = (CSSRRDlg *)p;
BOOL bHead = FALSE;
int Head;
UINT iCount;
int getLen;
int f, r, len, max, tt;
int test;
while(pThis->rcvComData) //一直成立,不停检测队列
{
if (pThis->nExitCode_Radio == 1) //主线程控制此线程退出标识
{
::AfxEndThread(pThis->nExitCode_Radio);
}
//从串口读数据到队列--检测同步头
if (pThis->g_bCommDataRcv) //OnOnCommMscommRadio事件设置的标识
{
pThis->ReadRadioCommData(pThis->hBuf); //读串口缓冲区当前所有数据到全局(字节)队列
pThis->g_bCommDataRcv = false;
}
//检测同步头
bHead = FALSE;
getLen = 0;
iCount = 0;
int bOne=TRUE;
while (pThis->hBuf->GetLength() > 4) //从全局数据队列hBuf查找一个完整的合法数据包
{
len=pThis->hBuf->GetLength(); //
f = pThis->hBuf->front;
r = pThis->hBuf->rear;
max = pThis->hBuf->maxSize;
tt = (f+1)%max;
//验证数据包的同步头
if (pThis->hBuf->GetElement(tt)==0x5D && pThis->hBuf->GetElement(tt+1)==0x5D
&& pThis->hBuf->GetElement(tt+2)==0x5D && pThis->hBuf->GetElement(tt+3)==0x02)
{
bHead = TRUE;
Head = tt-1; //记录同步头front位置
iCount = 0;
}
//防止多个合法的数据包,只取最后一个-----包内又可能新同步头,重新计算
if (bOne && bHead)
{
if (pThis->hBuf->GetElement(Head+1+4)==0x01) //命令字1
{
getLen = 86;
bOne = FALSE;
}
else if (pThis->hBuf->GetElement(Head+1+4)==0x54) //命令字2
{
getLen = 7;
bOne = FALSE;
}
}
if (iCount > (UINT)(getLen-1 -4))
{
if (pThis->hBuf->GetElement(Head+getLen)!=0x03) //尾不对
{
pThis->hBuf->DeSqueue(tmpQBuf, 4); //剔除此次不完整包的同步头5d 5d 5d 02
bHead = FALSE;
iCount = 0;
bOne = TRUE;
pThis->hBuf->front = Head+4;
continue;
}
else //包完整
{
break;
}
}
iCount++; //队列同步头检测移动了一步
pThis->hBuf->front++;
}
if (bHead && getLen)
{
pThis->hBuf->front = Head;
memset(tmpQBuf, 0, sizeof(tmpQBuf));
if ( (test=pThis->hBuf->DeSqueue(tmpQBuf, getLen)) == getLen) //出接收数据队列
{
pThis->CheckRadioCommData(tmpQBuf, getLen); //进一步检查数据合法性,并(通知界面)处理
}
else
{
Sleep(20);
}
}
}
return 0;
}
本程序把协议同步头 + 命令字 + 数据长度 + 数据正文 + 校验码中的校验码改成了同步尾,因为我的正文数据80字节,如果用CRC之类校验太耗时了。
上面代码的CheckRadioCommData函数就不贴了,涉及具体的业务处理,没必要贴出来。
//具体实现从串口m_msCom.GetInput数据
void CSSRRDlg::ReadRadioCommData(BtyeSqu *squ)
{
VARIANT vResponse;
int len, i;
unsigned char buf[200];
COleSafeArray Oledata;
BYTE data[200];
len =m_msCom.GetInBufferCount(); //获取串口接收缓冲区数据字节数
if (len > 0)
{
vResponse = m_msCom.GetInput(); //读取缓冲区数据
Oledata = vResponse;
len = Oledata.GetOneDimSize();
for(i=0; i<len; i++)
{