唔,现在网络上有各种各样的串口调试助手,但是别人的再好,不如自己写一个拿过来方便是吧,在做专门的上位机的时候也需要一个自己编写的上位机,实现更多的独特的功能。
对这些操作,我也自己写了一个类用于自己以后的使用,下面的技术大家可以了解之后可以写一个自己用的类,我写好的类上传到这个上面了,这个是访问链接:
https://download.csdn.net/download/weixin_47232366/19591260
一、背景介绍
虽然这些背景介绍之类的东西大家不爱看,但是看看也无妨,了解更多的串口助手的思路也不是一件坏事是吧,现在讲串口调试助手的有很多,大概的几种思路简单给大家介绍一下。
其一是通过阻塞的方式采用多线程进行数据串口数据读取,但是这样子容易造成线程堵塞,并且无法主动的关闭工作线程,如果采用强制关闭线程的方法可能会造成资源释放充分,容易造成程序不稳定。
另外一种方式是采用定时查询的方式,这种方法是通过一个定时器,例如10ms或者50ms查询串口是否有数据,如果有就进行接收,如果没有直接返回,这种思路来说的话,由于是定时查询,所以对于串口消息的相应可能不会太及时,对程序时间要求严格的场景下可能会有点力不从心。
前段时间看书提到了一种通过事件驱动(串口有专门的事件函数)多线程的方式来编写串口助手的内容,不仅可以满足时效性要求,也可以比较灵活的操作串口数据,接下来我将为大家介绍一下这种方法的具体实现过程。
二、技术简介
1.事件
事件是windows的一种用于线程之间同步的内容,事件告诉线程何时去执行某一给定的任务,可以使得多个线程之间切换更平滑。事件主要有四个API函数,它们分别用于创建、销毁、设置事件有效和设置事件无效功能,接下来我们一个一个介绍,这里为了文章的面向不同人群方便性,前面介绍会官方一点,后面的用法不用纠结那个么多参数是什么意思的。
首先是创建一个事件的函数,CreateEvent,具体的参数介绍如下:
HANDLE CreateEvent( LPSECURITY_ATTRIBUTES lpEventAttributes, // pointer to security attributes BOOL bManualReset, // flag for manual-reset event BOOL bInitialState, // flag for initial state LPCTSTR lpName // pointer to event-object name );
lpEventAttributes (咱也没看懂,用的时候默认用NULL就行了)指向SECURITY_ATTRIBUTES结构的指针,该结构确定返回的句柄是否可以被子进程继承。如果lpEventAttributes为空,则无法继承句柄。
结构的lpSecurityDescriptor成员为新事件指定一个安全描述符。如果lpEventAttributes为空,事件将获得默认的安全描述符。
bManualReset 表示是否自动复位,如果是TRUE,在该事件被线程释放后会自动设置状态无效,否则需要手动调用ResetEvent来设置该事件无效。
bInitialState 表示事件的初始状态是有效还是无效
lpName (咱也没看懂,用的时候默认用 0 就行了)指向指定事件对象名称的空终止字符串的指针。该名称仅限于最大路径字符,可以包含除反斜杠路径分隔符(\)之外的任何字符。名称比较区分大小写。
如果lpName与现有的命名事件对象的名称匹配,此函数请求EVENT_ALL_ACCESS访问现有的对象。在这种情况下,将忽略bManualReset和bInitialState参数,因为它们已经由创建过程进行了设置。如果lpEventAttributes参数不为空,它将确定句柄是否可以被继承,但是它的安全描述符成员将被忽略。
如果lpName为空,则创建的事件对象没有名称。
如果lpName与现有信号量、互斥体、可等待计时器、作业或文件映射对象的名称匹配,函数将失败,GetLastError函数将返回ERROR_INVALID_HANDLE。这是因为这些对象共享相同的名称空间。
返回值
WAIT_ABANDONED 指定的对象是一个互斥对象,它不是在拥有互斥对象的线程终止之前由拥有互斥对象的线程释放的。互斥体对象的所有权被授予调用线程,互斥体被设置为无信号。 WAIT_OBJECT_0 指定对象的状态已发出信号。 WAIT_TIMEOUT 超时间隔已过,对象的状态没有信号。
然后是释放事件句柄,也就是销毁事件了,这个函数很简单,CloseHandle(事件句柄);
设置和重置事件状态是SetEvent(事件句柄); 和 ResetEvent(事件句柄);
好了事件的介绍就到这里了,接下来我们来聊聊如何用这些事件。
2.事件的使用
给大家举一个例子说一下事件的用法:将事件比作钱包,一个线程(儿子)对主线程(爸爸)说:“我会等着你给我钱包(等待事件有效),但是我只会等一个小时(等待事件有效的时间),如果一个小时后你把钱给我了,我就去上学(等待到事件有效,进行预定的操作);如果等不到,我就去告诉老师(如果等待时间内事件没有有效状态,进行其他处理)。”那么我们该如何实现这个“等待父亲一个小时送钱包”呢?接下来我们来介绍实现这个功能的函数。
WaitForSingleObject函数,参数介绍如下:
DWORD WaitForSingleObject( HANDLE hHandle, // handle to object to wait for DWORD dwMilliseconds // time-out interval in milliseconds );
hHandle 事件的句柄
dwMilliseconds 等待的最长事件,如果是无限等待,可以设置该数值为 INFINITE。
但是大家有没有想过,如果上面的那个儿子的妈妈送来钱包是不是也可以呢,所以此处便是一种并列关系,等待一个小时父亲或母亲送来钱包,不管谁送来钱包,儿子都可以去上学。针对多事件等待,用到的函数是WaitForMultipleObjects,下面是参数介绍:
DWORD WaitForMultipleObjects( DWORD nCount, // number of handles in the handle array CONST HANDLE *lpHandles, // pointer to the object-handle array BOOL fWaitAll, // wait flag DWORD dwMilliseconds // time-out interval in milliseconds );
nCount 表示同时等待的事件数量
lpHandles 在这里对多个对象我们采用的是数组存到一起,这个参数是数组的首地址
fWaitAll 表示是否全部等待,如果是 TRUE ,必须全部的对象有效才会进行下一步操作,如果为 FALSE ,只要有一个有效便会返回。
dwMilliseconds 等待时间,同上。
返回值:WATE_OBJECT_0 + 数组的第几个事件有效(下标从 0 开始)
3.线程的开始和关闭
首先包含头文件:#include <process.h>
开始新线程的函数 _beginthreadex ,参数介绍:
unsigned long _beginthreadex( void *security, unsigned stack_size, unsigned ( __stdcall *start_address )( void * ), void *arglist, unsigned initflag, unsigned *thrdaddr );
security 设个0就行,新线程的安全描述,不知道干嘛的
stack_size 新线程的堆栈大小,这个设为 0 ,操作系统使用与为主线程指定的堆栈相同的值。
start_address 线程函数的地址,线程函数的原型为unsigned __stdcall Thread_Coms(void* pParam); 填写该函数的函数名的地址就行。
arglist 上面的函数原型的 pParam 数值,这个根据自己喜好(需要)设置吧。
initflag 表示线程创建后是开始运行还是阻塞,运行==0,阻塞==CREATE_SUSPEND
thrdaddr 指向接收线程标识符的32位变量(我也没太懂,设置为 0 就行)
关闭线程函数_endthreadex,参数为线程的退出参数(随便吧,如果对退出参数没啥要求的话),注意,它不会自动释放线程句柄,线程句柄需要手动释放,也可以在创建线程后就释放
PLUS(2021/6/12):
哎,其实创建线程除了这一种方式还有另外一种方式的,下面我悄悄的告诉你另一种方式CreateThread函数,下面是简单的使用函数示例:
//如果不需要线程id,可以为NULL
DWORD threadId;
m_hThread = CreateThread(NULL, //第一个参数是新线程的安全描述,,设为 NULL 就行
0, //第二个参数是新线程的堆栈大小,这个用 0 就行,堆栈大小和主线程一致
thread_ReadData, /*线程地址*/
this, /*线程传递的参数*/
0 /*控制线程创建的标志,为0表示立即激活,CREATE_SUSPENDED | THREAD_SUSPEND_RESUME 创建一个挂起的进程,唤醒时调用*/,
&threadId /*线程的 id*/
);
//判断是否创建成功
if (m_hThread)
{
CloseHandle(m_hThread); //记得手动释放线程句柄
return true; //线程创建成功
}
else
return false;
//线程的回调函数原型为:
//注意和上面的不太一样,但是大概的样子是一致的,只有一个输入参数是上面创建的时候传入的信息
//DWORD WINAPI thread_ReadData(LPVOID lparam)
三、实现思路
有时间再写吧,困了。。。(2021/6/12 0:18)
1.串口的打开和关闭
开始之前为了让大家对串口打开操作具有一个全面的认识,先给大家做了一个思维导图,接下来我们将会按照思维导图中的顺序依次为大家讲解各步骤的内容。
a.打开串口
打开串口使用的函数是 CreateFile ,为什么这个函数和操作文件的函数一样呢,因为对串口的读写和对文件的读写其实是差不多的,均是针对数据流的操作,下面是一段示例的打开串口的代码:
m_hCom = CreateFile((LPTSTR)sCom,
GENERIC_READ | GENERIC_WRITE,//对串口操作包含读写,所以此处写两个参数内容
0, //不共享COM口
0, //无安全策略
OPEN_EXISTING, //打开已有的文件
FILE_FLAG_OVERLAPPED, //I/0 重叠,异步读写 IO 口
0); //不为COM口生成临时文件
//串口打开失败
if (m_hCom == INVALID_HANDLE_VALUE)
{
//串口打开失败,该串口可能被其他设备占用
return false;
}
//具体的参数介绍其实后面注释已经很详细了,如果还是有不太懂的可以问一下我或者百度一下更加官方的说法。
b,设置串口掩码
首先要明白掩码是干嘛的,掩码是在发生多个事件收到通知消息后判断是哪个事件的作用,串口事件主要由两个,一个是 EV_RXCHAR (表示串口接收到了一个字节数据,具体情况下你处理这个消息的时候可能不止一个字节数据,因为是异步接受的么),另一个是 EV_TXEMPTY (表示缓冲区的最后一个字节被发送出去了,也就是你要发的消息已经成功的发完了)。好了,然后我们来看看怎么用吧:
//COM口掩码参数设置
if (!SetCommMask(m_hCom, EV_RXCHAR | EV_TXEMPTY))
{
//很简单啦,返回是 0 表示设置掩码失败了
return false;
}
c.获得和设置串口的参数
对于串口参数而言,windows 有一个特别多参数的结构体,DCB——大家可以看一下介绍的英文版的内容:
DCB结构定义了串行通信设备的控制设置。
typedef struct _DCB { // dcb DWORD DCBlength; // sizeof(DCB) DWORD BaudRate; // current baud rate DWORD fBinary: 1; // binary mode, no EOF check DWORD fParity: 1; // enable parity checking DWORD fOutxCtsFlow:1; // CTS output flow control DWORD fOutxDsrFlow:1; // DSR output flow control DWORD fDtrControl:2; // DTR flow control type DWORD fDsrSensitivity:1; // DSR sensitivity DWORD fTXContinueOnXoff:1; // XOFF continues Tx DWORD fOutX: 1; // XON/XOFF out flow control DWORD fInX: 1; // XON/XOFF in flow control DWORD fErrorChar: 1; // enable error replacement DWORD fNull: 1; // enable null stripping DWORD fRtsControl:2; // RTS flow control DWORD fAbortOnError:1; // abort reads/writes on error DWORD fDummy2:17; // reserved WORD wReserved; // not currently used WORD XonLim; // transmit XON threshold WORD XoffLim; // transmit XOFF threshold BYTE ByteSize; // number of bits/byte, 4-8 BYTE Parity; // 0-4=no,odd,even,mark,space BYTE StopBits; // 0,1,2 = 1, 1.5, 2 char XonChar; // Tx and Rx XON character char XoffChar; // Tx and Rx XOFF character char ErrorChar; // error replacement character char EofChar; // end of input character char EvtChar; // received event character WORD wReserved1; // reserved; do not use } DCB;
看起来就眼花缭乱,不过不用慌,我们需要的几个参数很少,其他的默认就行。但是这个也太多了吧,写代码一个一个数据赋值那要累死了吧!不用担心,windows 有一个函数叫做 GetCommState(m_hCom, &dcb); 这个函数第二个参数是 DCB 结构体的地址,通过这个函数我们可以获得系统当前对串口的设置信息,它会自动填充 DCB 结构体中的所有参数,我们只需要改动一下我们需要设置的几个内容就行了。我们常用的几个参数如下:
dcb.BaudRate = dwBaudRate; //波特率,这个具体的自己上网可以查一下常用的设置是多少
dcb.ByteSize = byBytesize; //数据尺寸,
dcb.Parity = byParity; //校验选择,奇校验(ODDPARITY),偶校验(EVENPARITY),无校验(NOPARITY)等
switch (byStopBits) //停止位的位数 ONESTOPBIT TWOSTOPBITS ONE5STOPBITS等,自己可以在 visual studio 中转到定义自己去找
所有串行通信的设置里面都有,其他的不怎恶魔常用的我就不在这里多说了。设置完之后调用SetCommState(m_hCom, &dcb); 来设置对应的串口信息。注意在串口使用过程中要更改串口的某些设定例如波特率、校验方式等也是这种方法。
c.串口超时设置
串口超时设置主要内容在一个结构体里面——COMMTIMEOUTS,先看一下官方的介绍吧:
通信超时结构用于设置通信超时和获取通信超时功能,以设置和查询通信设备的超时参数。这些参数决定了设备上读文件、写文件、读文件和写文件操作的行为。
typedef struct _ COMMTIMEOUTS {
DWORD ReadIntervalTimeout
DWORD ReadTotalTimeoutMultiplier;
DWORD ReadTotalTimeoutConstant
DWORD WriteTotalTimeoutMultiplier;
DWORD WriteTotalTimeoutConstant
} COMMTIMEOUTS,* LPCOMMTIMEOUTS成员
ReadIntervalTimeout
指定通信线路上两个字符到达之间允许经过的最长时间(以毫秒为单位)。在读文件操作期间,时间周期从接收到第一个字符开始。如果任意两个字符到达的时间间隔超过此值,则读取文件操作完成,并返回任何缓冲的数据。零值表示不使用间隔超时。
MAXDWORD值与ReadTotalTimeOutStation和ReadTotalTimeoutConstant成员的零值相结合,指定读取操作将立即返回已经接收到的字符,即使没有接收到字符。ReadTotalTimeoutMultiplier
指定乘数,以毫秒为单位,用于计算读取操作的总超时周期。对于每个读取操作,该值乘以请求读取的字节数。
ReadTotalTimeoutConstant
指定常数,以毫秒为单位,用于计算读取操作的总超时周期。对于每个读操作,该值被加到ReadTotalTimeoutMultiplier成员和请求的字节数的乘积上。
ReadTotalTimeoutConstant和ReadTotalTimeoutConstant成员的零值表示总超时不用于读取操作。WriteTotalTimeoutMultiplier
指定乘数,以毫秒为单位,用于计算写操作的总超时周期。对于每个写操作,该值乘以要写入的字节数。
WriteTotalTimeoutConstant
指定用于计算写操作总超时周期的常数(以毫秒为单位)。对于每个写操作,该值被加到WriteTotalTimeoutMultiplier成员和要写入的字节数的乘积上。
WriteTotalTimeoutConstant和WriteTotalTimeoutConstant成员的值为零表示总超时不用于写操作。
别怀疑了,有的地方我也看的一头雾水,这段设置书上也没讲太多,它是这么设置的,我就照搬了:
//COM口超时参数设置
COMMTIMEOUTS timeouts;
timeouts.ReadIntervalTimeout = MAXDWORD;
timeouts.ReadTotalTimeoutMultiplier = 0;
timeouts.ReadTotalTimeoutConstant = 0;
timeouts.WriteTotalTimeoutMultiplier = 0;
timeouts.WriteTotalTimeoutConstant = 0;
if (!SetCommTimeouts(m_hCom, &timeouts))
{
//串口超时状态设置错误
return false;
}
d.启动检测线程
启动线程我觉得就像是主线程生孩子一样,并不是生出来就不管了,而是生出孩子之后剪掉脐带,然后洗一洗才算是真正的成功,在剪脐带等这段过程中主线程要等着,一切完成后才能报告生孩子成功了。这里需要创建一个事件来用于同步,具体过程如下:
主线程生了一个孩子,然后并不急着报告孩子生成功了,而是等着给孩子剪脐带,会哭了,洗澡(等待启动线程的事件有效,事件的有效状态通过线程函数执行),等到孩子脐带剪了,会哭了,洗完澡了(线程设置了事件的有效状态),然后主线程高兴的告诉自己的对象,我成功的生了一个孩子耶(返回主程序,然后在界面显示提示信息给用户看)。
接下来是代码内容:
m_hThreadstarted = CreateEvent(0, 0, 0, 0);
m_hThread = (HANDLE)_beginthreadex(0, 0, &Thread_Coms, (void*)this, 0, 0);
if (!m_hThread)
{
//线程创建失败
return false;
}
//这里便是等待孩子出生的各种事情做完,等待时间可以自己设,这里用的是无限等待,主线程永远不死心
DWORD dwwait = WaitForSingleObject(m_hThreadstarted, INFINITE);
return true; //好耶,成功的生了一个小生命
/
//线程函数具体的操作
//线程回调函数
unsigned __stdcall Thread_Coms(void* pParam)
{
//许许多多线程初始化需要的操作
//....................
//孩子告诉老爹,我出生成功了
SetEvent(pThisCom->m_hThreadstarted); //设置线程开始事件句柄有效
//进行其他的许许多多的操作
//...................
}
e.恭喜你,成功的打开了一个串口
这里千万要注意,在窗口关闭的时候关闭串口句柄,其他的也没啥好交代的了,我们进入下一步吧。(2021/6/12 14:51)
2.串口数据的读取和写入
3.串口数据读取线程的具体实现思路
四、其他技术
1.通过注册表查询有效串口
2.注册热插拔事件自动更新串口信息
3.串口数据编码问题