串口,一种古老的设备,虽然能力有限,但因其简单,在计算机与单片机通信场合,有着较多应用。本文介绍的串口类,并非本人独自完成,框架思路继承了一位国际友人的源代码,在此基础上扩充了功能,修正了bug。一并列出原文代码,以示敬意。现在说一下如何使用。
Part one:简单使用
新建一个对话框程序,包含SerialPort.h头文件,加入一个类变量,如m_Port。在对话框的初始化函数中加入如下代码:
m_Port.m_PortParameter.SetPortNumber(4);
m_Port.m_PortParameter.SetPortBaudRate(9600);
m_Port.SetCaptureReceive(false); //无视此行
m_Port.SetCaptureOnly(false); //无视此行
m_Port.SetBatchReceive(false); //无视此行
m_Port.SetDynamicLengthReceive(false); //无视此行
if( m_Port.OpenPort(this) == false )
{
/*
错误处理函数
*/
return TRUE;
}
m_Port.StartMonitoring();
return TRUE; // 除非将焦点设置到控件,否则返回TRUE
额外加入的代码含义是,配置串口4,波特率9600,中间4行暂时无视。OpenPort(this)函数需要一个CWnd指针参数,代表用于接收消息的主界面,填入this。检查一下返回值,如果是false,则不能继续后续操作,表明你配置的参数有问题,或者打算使用的串口被占用等。如果是true,调用StartMonitoring()开启一个工作线程,串口在后台进行收发,有数据到来就发送消息到主界面。是多线程的处理,主界面不会被阻塞。
按以上代码,默认的行为是当串口有数据到来就会接收(哪怕只有一个)。当有数据到来的时候,工作线程会向主界面投递消息(刚才提到的OpenPort的那个参数),消息是WM_PORT_RXCHAR,拦截一下消息即可(以下所有消息都是自定义的)。
LRESULT CTestDlg::OnChar(WPARAM wParam,LPARAM lParam)
{
/*
处理函数
*/
return 0;
}
消息处理函数有两个参数,第一个wParam包含接收到的字符,只有最低一个字节有效,强行转换为char即可,每次只能传送一个字节。第二个参数lParam代表从哪个串口接收到数据,如果只开启一个串口,则这个参数没什么意义。
最基本的操作就这么简单啦,到此结束。
Part two:基本使用方法的补充说明。
首先要说明的是,我加入了很多错误检查,一旦你配置的参数或者程序运行出现错误,我会采取四种方法通知:
第一种:发生错误的那个函数返回值是非正常的(如果有返回值的话),需要在代码中检查返回值才能决定下一步做什么。如果函数的返回值是bool类型,那么true代表正确,如果函数返回一个整型,那么0代表正确(多数情况下是如此,自己看源代码)。这种错误提示方法总是生效的。
第二种:弹出一个对话框,告诉你发生了错误。这种错误提示方法大多数情况下是生效的。如果想屏蔽讨人厌的对话框,在SerialPort.h头文件有一个宏定义,设置为0,重新编译即可,如下,
#define PMB_ONOFF 1
但是,有些对话框是无法屏蔽的,我指的是特别重要的那些信息。
第三种:向主界面投递消息WM_SERIOUS_ERROR,拦截一下消息即可,消息的wParam代表了不同的错误类型,lParam代表发生错误的串口号。这种方法不是总有效的,多数对应串口接收和发送的意外错误(但是这类错误基本不发生)。如果想屏蔽,头文件同样有一个宏定义PWM_ONOFF。屏蔽只针对WM_SERIOUS_ERROR消息,不针对任何串口正常的事件。
第四种:我调用SetLastError()设置错误码,见SerialPort.h头文件我声明的错误码。这种方法大多数下有效。
其次,除了串口号和波特率,还有哪些参数可以设置。CSerialPort类含有一个类型为CParameter的变量m_PortParameter,其含有一个public型的DCB结构m_PortDcb,诸如奇偶校验、停止位长度等都可以设定,因为是public型,所以可以直接修改。默认是串口1,9600波特,8bit长度、一个停止位、无奇偶校验,有数据到来即投递消息。另外还可以设置握手协议SetHandShakeType,这个用于较为复杂的场合,我按照多数下合理的逻辑做了默认设置,默认有三种协议,无握手、软件握手、硬件握手。
最后,这个串口类还可以处理哪些串口事件呢?硬件有一种功能,可以设定一个标志字节FLAG,当串口在数据流中检测到有这个匹配字节时,发生中断。这个类继承了此功能,在OpenPort之前,调用
m_Port.m_PortParameter.SetPortEvent(EV_RXCHAR | EV_RXFLAG);
凡是想监控的事件都可以用或运算设置。当检测到这个标志字符时,这个类会向主界面投递WM_PORT_RXFLAG消息。因为这个FLAG是已知的,所以不被接收,消息处理函数的wParam参数总是0(这个要特别注意),lParam参数是发生此事件的串口号。如果同时设定EV_RXCHAR和EV_FLAG,则会收到两个消息。使用这个功能前还要设定m_PortParameter里的m_PortDcb里的EvtChar,即想要监控的标志字节。
微软定义了九个事件(其实是硬件具有的),都有对应消息,事件集见MSDN。我定义的消息参见头文件声明。对这些事件只发送消息,因为我无法预见,需要自行处理。一共有九个事件,我只能说说自己的理解。
EV_BREAK:帧中断错误,比如串口正在发送一个字符,突然间线路故障或者人为拔掉串口,则接收方会检测到,判定为线路中断。
EV_CTS:检测到串口的CTS(清除发送)引脚产生一个有效电平。
EV_DSR:检测到串口的DSR(数据设备准备好)引脚产生一个有效电平。
EV_ERR:线路故障,包括奇偶校验错误、超速(收发速度不一样)和帧格式错误(收发格式不一样)。
EV_RING:振铃指示引脚产生一个有效电平。
EV_RLSD:RLSD线改变状态,不懂,是否类似载波检测?
EV_RXCHAR:有字符到来。
EV_RXFLAG:有匹配的标志字符到来。
EV_TXEMPTY:发送缓冲器空。
BUG的补救
北航龚建伟老师,写过一本串口的著作,同样引用了这位外国友人的原始代码。提到这个代码不能同时运行2个串口,或者只运行一个串口,在程序运行中途更换串口号会发生错误。我进行了补救。目测,补救之后可以同时开2个或者更多串口,也可以中途更换串口号。我忘了改动了什么函数,可以使得同时运行两个串口。这个代码是我三年前改进的,现在才写说明。看来及时写注释非常必要,否则自己也回忆不起来。
补救后的BUG
我喜欢穷举和恶意攻击,补救之后,好像我又检测到一些错误。大约是同时开启两个串口,然后各自同时收发数据,好像会丢数据。如果两个不同时接收,则不会发生丢包现象。这个问题,现在没时间研究,看看哪位高手给补救吧,顺便告诉我。
Part three:增强的数据接收。
一个土豆吃不饱。
微软的串口控件有种能力,可以设定一个接收门限值,比如100,那么每到100个字节才发送消息,而不是每个字节到来都发送消息,这样可以批量接收,提高消息处理函数的效率。这个功能我山寨上了。
但是,我们考虑一下,如果我设定100个字节的门限,当100个字节到来时,我去处理它,但是windows是非实时的,有可能(虽然可能性很小)系统还没来得及处理,新的数据又从串口接收到了,用户的目标处理程序和这个类的工作线程同时操作缓冲区,要么发生同步阻塞,要么还未及时处理的老数据可能被覆盖。所以,我启用了两个缓冲区,以下有一幅很漂亮的图示。
(我不会粘贴图片,在我上传的资料里有)
每个缓冲区的大小相同,每个最大容量用宏定义MAXINPUTBUFFERSIZE定义,默认8192字节。如果设定接收门限为100,则当第一个缓冲区达到100个字节时,投递消息WM_DATA_RTHRESHOLD,然后工作线程使用缓冲区2继续接收,用户程序操作缓冲区1处理,当第二个缓冲区达到100个字节时,再次投递消息WM_DATA_RTHRESHOLD,然后工作线程使用缓冲区1继续接收,用户程序操作缓冲区2处理,如此循环。这样做虽然不敢保证100%不发生数据覆盖,但是总比一个缓冲区好。最好的办法是在消息处理函数中,及时拷贝数据再处理,而不是用我提供的缓冲区。这种方法,我称之为批量接收,注意此时投递的消息是WM_DATA_RTHRESHOLD,拦截消息即可。
LRESULT CTestDlg::OnBatchReceive(WPARAM wParam,LPARAM lParam)
{
/*
处理函数
*/
return 0;
}
消息函数的wParam参数如果非零,代表第一个缓冲区有数据,需要处理。为零则代表第二个,lParam代表发生此消息的串口号。那么缓冲区在哪里?CSerialPort类有一个CInputBuffer类型的成员m_PortInputBuffer,pubilc型。
class CInputBuffer
{
public:
CInputBuffer(void);
public:
virtual ~CInputBuffer(void);
public:
void ClearBuffer(void);
public:
char* m_pBuffer1;
public:
char* m_pBuffer2;
};
使用批量接收,需要在打开串口之前,执行以下配置(请与最开始的对比不同点):
m_Port.SetCaptureReceive(false);
m_Port.SetCaptureOnly(false);
m_Port.SetBatchReceive(true);
m_Port.SetRThresholdrSize(100);
m_Port.SetDynamicLengthReceive(false);
注意批量接收门限不能为零,也不能超过最大缓冲区长度。如果门限值为1,则是每到一个数据就发送消息,但是和前面提到非批量方式的有所不同,首先消息不同,其次批量接收时数据放在双缓冲区里,非批量接收,随着消息参数发送。
我好像还想写点什么,一时间忘了,好像是我对临界区的理解有些不明白。
继续前行。
考虑以下两种情况:
第一种:假设我的串口总线挂上很多串口,其中一个始终发,其他的始终收,接收的怎么区分呢?对了,需要配一个地址码或者其他什么码,每个从机地址码都不同。然后主机每次发送数据都附加上地址码,从机在数据流中不断搜索,有与自己地址匹配者,开始接收,没有则放弃。
第二种:数据流就是要有一个固定的前缀作为起始(比如我的名字),即使它没有意义。
所以,我增加了捕捉接收方式(匹配接收、捕获接收,叫什么都行)。但是有一种可能,真正的数据流中可能包含与地址码相同的数据,如何处理呢?我规定了,使用捕捉接收方式,真正的数据必须要有固定长度,代码在数据流中不断匹配,直到发生与设定的地址码一致的时候,开始接收固定长度的数据(如果这期间再出现地址码,则忽略),直到接收完成,然后开始下一次匹配。启用捕捉方式,使用以下配置(请对比):
m_Port.SetCaptureReceive(true);
m_Port.SetCaptureOnly(false);
m_Port.SetBatchReceive(true);
m_Port.SetRThresholdrSize(100);
m_Port.SetDynamicLengthReceive(false);
m_Port.SetMatchBuffer("your address");
最后一行代表设定的匹配前缀字符串,最大256字节(最好不要等于这个数量),最小1字节。接收的门限仍旧用SetRThresholdrSize设定。当数据接收到时(正确匹配和收到指定长度数据),发送WM_DATA_RTHRESHOLD消息,拦截即可。消息函数的参数与批量接收时含义相同,wParam代表缓冲区指示,lParam代表串口号。使用捕捉接收,匹配前缀不包含在接收缓冲区中,因为这是已知的,不是真正数据。
在捕捉方式下,可以是批量接收,也可以是非批量接收(来一个数据就收下),根据SetBatchReceive()的参数而定,非批量的时候忽略门限值。这两种方式投递的消息不同。批量接收是WM_DATA_RTHRESHOLD消息,非批量是WM_PORT_RXCHAR。
再接再厉。
刚才提到,硬件有一种能力,可以拦截和你设定的标志相同的字符,然后发送消息,但是只能拦截一个字节,如果我只想检测串口数据流有无我感兴趣的字符串,但我不接收任何数据(相当于一种通知提示),那么这就是仅捕捉接收方式,CaptrueOnly,如下配置:
m_Port.SetCaptureReceive(true);
m_Port.SetCaptureOnly(true);
m_Port.SetBatchReceive(true);
m_Port.SetDynamicLengthReceive(false);
这时,当发生匹配时,发送WM_DATA_CAPTURE消息作为通知,但是没有任何数据放入缓冲区。注意上述代码第三行和第四行的参数是true或者false无关紧要。
有点乱,整理一下。
首先捕捉接收方式具有最高流程优先级。如果是捕捉方式,则有三种可能:仅捕捉,批量接收和非批量接收。在程序流程判断上,仅捕捉优先级更高,非批量接收最低,程序只采用一种方式。
如果不是捕捉接收方式,则有两种可能:批量接收和非批量接收,批量接收具有较高优先级。
还没完。
刚才的捕捉接收方式很好,你觉得呢?不过我觉得有缺陷,每次的数据长度是固定的(我指的是真正想要的数据),如果数据长度不同,需要每次调用SetRThresholdrSize设定门限值。我希望数据长度能够写在数据流中,程序动态改正。这样,我在捕捉接收方式之上又设定了是否启用动态长度接收。先说一下,动态长度接收必须是捕捉接收方式下才可启用(想一下就知道,没有帧头,我怎么知道那个数据代表长度)。第二,在捕捉接收方式下,如果启用动态长度接收,则优先级低于仅捕捉方式,高于批量接收方式,程序流程进行不二选择。也就是按优先级排列是:仅捕捉,动态长度批量接收、固定长度批量接收和非批量接收
如上图,启用动态长度接收,必须遵循以下格式:帧头是你随意设定的(长度不超过256字节,最少一字节),然后两个字节是长度指示,表明这一次要接收多少字节(不包括帧头和长度指示符),最后是数据。只有真正数据放入缓冲区。两个字节长度指示符可以表示最大65535个字节,好像足够了吧。对,足够了,即使TCP数据包也没有这许多。长度指示符不能是零,否则意味着动态长度是65536,而不是没有数据,这是一种错误方式。这个类的代码
无法做检查,因为检查出来也为时已晚。
这个类允许在程序运行期间更改接收方式。但是仅仅更改接收方式不能正确执行,因为很多内部状态变量没有更新,还处于原先状态。所以我建议(或者说没有别的选择)按照以下方法:
1 调用CSerialPort的ResetSomeKeyValue函数,复位关键变量,包括清空缓冲区,指示位置和迭代搜索的变量归零。
2 设定新的方式。
在更换方式下,串口不能正确收发数据,这一点要自己保证。
如果中途更换串口号和波特率,那么必须按照以下步骤进行:
1 先关闭串口ClosePort。
2 执行Initialization函数
3 因为串口号和波特率等所有变量复位,需要重新设定所有变量和工作方式
4 调用OpenPort函数
5 如果没有错误,调用StartMonitor函数
这个类支持物理串口和虚拟串口,虚拟串口,我接触过两类,把USB弄成串口(笔记本),纯软件虚拟的,在一台机器上完成类似仿真的任务。把USB弄成串口,我没有测试过,其他两种测试过。有一个宏定义SCANIO_ONOFF,如果非零,则每次构造实例的时候或者调用Initialization函数的时候,搜索一下计算机上所有的串口,写在一个变量数组里m_bPortNumberExist[254];
如果取值为true,则有对应串口,否则没有。下标为0的元素代表串口1,下标为1的元素代表串口2等等,最多支持254个。但是这个类无法区分物理串口和纯虚拟串口的重叠使用,另外我判定的方式就是循环打开所有串口,这样需要1-2秒,主界面会卡住。因为我不会其他方法。如果不需要这个辅助功能,把SCANIO_ONOFF定义为0即可。按理说,可以支持255个串口,我人为去掉了最后一个。
完结。
Part four:增强的数据发送。
不知道你有这经验没,发送永远比接收简单,就像花钱永远比赚钱容易一样。
如果发送字符串,调用以下函数即可。
public:
void WriteStringToPort(char* strText,
bool bWithCapture = false, bool bWithDynamicLength = false,
char* strCapture = "tjpu_spl", bool bInstruction = false, _int8 nInstruction = 0);
strText是发送的字符串,其他的参数待会解释。
这个类可以发送汉字,只能发送多字节汉字,Unicode汉字需要自己转换一下。
这个类不能接收汉字。解释一下,汉字无非是多个字节流,没有什么可以接收与不可以接收的,我的意思是串口只能一个字节一个字节的发送与接收,如果是汉字,需要自己拼在一起,而不是靠我拼装,程序只收发数据不处理。
这个类可以发送浮点数和整型数,有多个WriteToPort函数,名称一样,参数类型不同。我特意和发送字符串的函数名区分开了,仅举一例:
public:
void WriteToPort(double* pValue, unsigned int nCount,
bool bWithCapture = false, bool bWithDynamicLength = false,
char* strCapture = "tjpu_spl", bool bInstruction = false, _int8 nInstruction = 0);
其中pValue代表要发送的多个浮点数,nCount表示要发送多少个,这个得自己如实填写,pValue必须有这么多数据才行,否则崩溃,而且数据量不得超过最大输出缓冲区容量,默认8192字节,用宏定义MAXOUTPUTBUFFERSIZE表示。nCount以发送的类型计算,不以字节计算,我在代码中进行换算,不超过最大发送缓冲区容量以 发送的数量*发送类型 大小计算,这要自己保证,如果检测到超出容量,拒绝发送。
现在来看看其他参数的含义。
刚才提到,有一种捕捉的接收方式,那么就得有一种带前缀(地址码)的发送才对称,bWithCapture表明发送的时是否附加前缀,str_Capture表示前缀字符串。字符串不得超过256字节,最好少点。默认前缀是tjpu_spl,tjpu是天津工业大学的缩写,spl是我名字汉语拼音首字母缩写。
刚才提到,有一种动态长度的捕捉接收方式,那么就得有一种带长度的发送才对称,bWithDynamicLength表示是否附加长度码(2字节,小端字序),最大可以指示65535字节,但是我限定了最大32768字节,我限定它肯定有道理。长度指示必须在bWithCapture为true时才生效,否则无视。无前缀,怎么界定长度指示符在哪里?
你马上就要获得解放了。
我设想,从串口来的数据可能表示不同含义,比如2点10分的时候代表温度,2点11分的时候表示压力等。我可否在发送数据流中插入这种含义指示符呢,这样一同发送,让接收方在数据起始的时候先取出含义指示,然后采取不同对策,这个我称为指令码。bInstruction表示是否附加指令码,nInstruction是一字节指令码,一个字节对我最够了,对你不够的话,自己改代码。
这个代码我继承了老外的框架,就是多线程的方式。我不是搞软件的,实话说,没有这个框架,我不知道该怎么弄串口。但是我做了巨大修改,增加了很多功能,军功章上有我的一半,这不为过。出于对原作者敬意,我的代码头部仍旧有这个老外的名字。这个类允许教学用、学习用,自己私人用,拒绝商用。
有什么BUG,请反馈 tjpu_spl@126.com