从零开始使用C++完成串口助手(一)
一. 引子
学了C++也有一段时间了,还是可以完成一些“简单”的小任务来巩固一下对于C++的理解,之前博主老早之前就想着自己做一个串口助手,阴差阳错的现在接触到了C++,先不加入QT,直接用控制台实现一个简单的串口助手,开动
二. 新建工程文件
这次代码软件我们使用Visual Studio2022,没有什么原因非常方便而已,所以就使用这个软件了,点开后然后选择,创建新项目
可以选择控制台应用,这里就直接选择控制台应用
项目名称可以随便起,中文名也可以,Visual Studio软件对于中文软件的兼容性做的还是不错,博主为了规范就直接以串口起名
新建文件后就是这样的模板,然后将代码全部删掉
先新建一个头文件,右键点击添加新建项,显示所有模板
点击头文件,然后下面还是填写头文件名字
新建效果如下,到时候把#pragma once 删掉
.h头文件通常用于声明类、结构体、枚举、函数原型和全局变量。它们提供了一个接口,让其他的源文件(.cpp),与实体进行交互,但不需要了解实现的细节
.cpp文件(源文件),用于实现类和函数的定义,包含实际的代码逻辑,负责执行声明在头文件中的功能
目前创建一个.cpp和头文件,对于串口这种百来行的项目,代码体量并不少,通过代码声明和实现的分离,使代码更容易阅读和理解。并且对于维护性,当我们要修改某个功能的时候,更容易找到相关的代码并且减少对其他部分的影响
接下来就先开始编写函数
三. 代码实现
先在刚刚创建好的头文件中,应用我们需要的文件,需要引用的除了C++的标准库iostream以外,还有Windows.h用于Windows开发的重要头文件,其中包含了大量的函数和宏定义等结构,同于实现Windows中的各种效果,我们的串口本来就是属于电脑上的一个硬件设施,自然也由Windows系统软件控制
#include <iostream>
#include <windows.h>
然后在头文件中定义一个串口的类,写了过后Vs的快速补全会帮我们创建一个模板,看个人是否保留,也可以将他们全部删掉,自己自定义添加
#include <iostream>
#include <windows.h>
class Serialport
{
public:
Serialport();
~Serialport();
private:
};
Serialport::Serialport()
{
}
Serialport::~Serialport()
{
}
博主在这里就先代码全部删掉,然后先在类中编写构造函数,因为也只有构造函数能够和类的名字相同,构造函数主要也是用于对于变量二队初始化,这里我们的构造作用不仅是初始化要使用的变量,并且也是开启指定串口和设定串口助手波特率
//Serialport.h
#include <iostream>
#include <windows.h>
class Serialport
{
public:
Serialport(const char* portName, DWORD Baudrate);
private:
};
然后到源文件编写函数逻辑
//Serialport.cpp
#include "Serialport.h"
Serialport::Serialport(const char* port, DWORD Baudrate)
{
}
下一步我们需要创建一个串口句柄,在头文件的Serialport类的私有变量中
#include <iostream>
#include <windows.h>
class Serialport
{
public:
Serialport(const char* portName, DWORD Baudrate);
private:
HANDLE hserial;
};
然后在.c源文件中编写函数的逻辑
#include "Serialport.h"
Serialport::Serialport(const char* portName, DWORD Baudrate)
{
hserial = CreateFileA(portName, GENERIC_READ | GENERIC_WRITE, 0, NULL,OPEN_EXISTING, 0, NULL);
}
1. 函数CreateFileA
先解释一下CreateFileA函数,这是Windows API中用于打开或者创建文件、设备和输入输出(I\O)端口的函数,函数原型是CreateFile,加上的A是表示该函数使用ANSI字符集,还有对应的CreateFile函数使用Unicode字符集
(1) _In_ LPCSTR lpFileName:
_In_是一个注释,表示这个是输入参数。LPCSTR是一个宏,展开为 Const char*表示这是一个指向常量字符的指针,lpFileName表示要打开或者创建的文件或设备的名称
(2) _In_ DWORD dwDesiredAccess
还是表示一个输入参数,DWORD是一个32位无符号整数类型,表示请求的访问权限,参数包括不限于GENERIC_READ、GENERIC_WRITE、GENERIC_EXECUTE或者它们的组合
(3)_In_DWORD dwShareMode
前面同样是输入参数,表示文件或设备的共享模式,参数可以填写0不共享,也可以是FILE_SHARE_READ
、FILE_SHARE_WRITE
或 FILE_SHARE_DELETE
(4)_In_opt_ LPSECURITY_ATTRIBUTES lpSecurityAttributes
_In_opt_ 表示这是一个可选的输入参数。LPSECURITY_ATTRIBUTES 是一个指向 SECURITY_ATTRIBUTES 结构的指针,用于指定安全描述符和是否继承句柄。lpSecurityAttributes
是参数的名称。
(5)_In_DWORD dwCreationDisposition
dwCreationDisposition表示如何创建或者打开,文件或设备,可以是 CREATE_NEW、CREATE_ALWAYS、OPEN_EXISTING、OPEN_ALWAYS 或 TRUNCATE_EXISTING
(6)_In_DWORD dwFlagesAndAttributes
表示文件或设备的属性和标志,可以是 FILE_ATTRIBUTE_NORMAL
(7)_In_opt_ HANDLE hTemplateFile
HANDLE是句柄类型,用于标识操作系统对象 ,表示一个模板文件句柄,用于复制其属性
那么这段函数的意思就是,要打开的串口设备的名称,下一个参数是GENRIC_READ | GENRIC_WRITE,指定访问模式,表示既可读取也可写入串口设备,0表示不共享设备,也就是其他进程无法同时访问该串口,NULL表示使用默认的安全属性,OPEN_EXISTING,创建disposition,表示打开一个已存在的设备,0表示使用默认属性
2. 访问串口为何本质上是访问文件
在Unix和类Unix系统中,几乎所有的硬件设备都被抽象位文件,这种设计简化了I/O操作的编程接口,使得我们可以使用标准的文件操作函数来读写设备
而Windows操作系统也继承了这一理念,将串口,打印机、磁盘驱动器等设备都视为文件系统的一部分。所以就意味着在这里要与串口通信,我们可以使用与文件操作相同的API例如CreateFile等
在我们使用CreateFile函数来打开串口时,lpFilename参数是一个以"COM"开头的字符串,后跟串口的编号,例如"COM1" "COM2"。通过这种方式,Windows将串口映射为一个文件句柄,应用程序可以通过这个句柄进行读写操作
(1) 访问成功
当CreateFileA()成功的时候,Windows在内核中创建或打开一个串口对象,然后返回HANDLE,我们就可以HANDLE操作这个串口,hserial只是一个标识符(句柄),表示Windows内部的串口对象,但是我们并不能直接修改hserial的值,只能用Windows API进行读写,这个值是Windows分配的内部资源编号,可能是某个内存地址的指针,但无法直接访问
(2) 访问失败
如果失败会返回INVAILID_HANDLE_VALUE
(3) Handle是如何存储的
在Windows.h中,HANDLE本质上是一个void*,当并不能使用指针操作HANDLE,只能用API,Windows通过Handle让我们间接操作资源,而不让我们直接访问资源数据
typedef void* HANDLE;
当函数成功,Windows把这个要是放进hserial,可以用它来打开、读写和关闭串口
3. 是否打开串口
既然我们向系统申请打开串口,也知道如何访问成功或者访问失败,CreateFileA()函数都会返回参数,那么我们可以写一段if判断,确认是否能够正常访问到串口,那么代码如下
逻辑非常简单唯一需要讲解的就是GetLastError,如果串口无法正常打开,它会根据错误的原因返回相关的数字,可以对照数字查询串口无法打开的原因是什么
Serialport::Serialport(const char* portName, DWORD Baudrate)
{
hserial = CreateFileA(portName, GENERIC_READ | GENERIC_WRITE, 0, NULL,OPEN_EXISTING, 0, NULL);
if (hserial == INVALID_HANDLE_VALUE)
{
std::cerr << "串口打开失败" << std::endl << "错误原因:" << GetLastError()<< std::endl;
return;
}
}
4. 设置结构体大小
DCB是Windows系统中用于配置串口通信参数的数据结构,我们需要调用这个结构体对串口的硬件参数进行配置
那么在头文件中先创建一个结构体变量
class Serialport
{
public:
Serialport(const char* portName, DWORD Baudrate);
private:
HANDLE hserial;
DCB dcbSerialParams;
};
然后在源文件中设置结构体的大小,以便Windows API识别它的版本和格式,DCB.length就是结构体的第一个成员变量,所以将结构体大小的参数传给这个结构体成员,所以一般必须先设置DCB.length,不然就无法Windows就无法正确处理这个结构体
通过GetCommState这个函数来获取串口当前的配置,如果失败就使用Closehandle这个函数,关闭句柄释放资源,并返回数据
这个步骤通俗来讲就是用于读取当前串口的默认配置,在之后方便修改并重新配置
if (!GetCommState(hserial, &dcbSerialParams))
{
std::cerr << "无法访问到串口" << std::endl;
CloseHandle(hserial);
return;
}
那一步就开始配置串口的几个主要参数,这里和串口相关的几个参数就不过多描述了,主要数据宽度以及波特率等串行通信常用的参数
dcbSerialParams.BaudRate = Baudrate;
dcbSerialParams.ByteSize = 8;
dcbSerialParams.StopBits = ONESTOPBIT;
dcbSerialParams.Parity = NOPARITY;
5. 串口参数设置失败
串口参数设置后还是需要检测参数是否成功,这个函数获取成功后会返回非0,也表示dcbSerialParams被正确填充,如果获取失败就返回零
一般参数其实都会被正常设置,如果存在错误可能是,如hserial句柄无效,或者串口不存在或占用,但是这些错误会在刚开始打开串口的地方排查出来
if (!GetCommState(hserial, &dcbSerialParams))
{
std::cerr << "串口参数设置失败" << std::endl;
CloseHandle(hserial);
return;
6. 设置超时时间
(1) 为什么要设置超时时间
在之后我们会编写ReadFile()和WriteFile()串口读写函数的时候防止函数无限等待,如果不设置超时,调用函数可能会一直等待数据,而不会返回,导致程序卡死
也可以适应不同的传输速率,如果串口通信速率高(115200),可以减少有些参数,确保数据连续传输,所以一个通信速率低,适当增加某些特定参数,也可以防止过快
还是通过Windows.h头文件中提供的结构体来配置,结构体名称为COMMTIMECONFIG,那么需要配置结构体成员,还是先声明头文件中声明结构体变量
class Serialport
{
public:
Serialport(const char* portName, DWORD Baudrate);
private:
HANDLE hserial;
DCB dcbSerialParams;
COMMCONFIG timeouts;
};
然后回到源文件中配置结构体成员,下面解释一下这些结构体成员的变量
timeouts.ReadIntervalTimeout = 50;
timeouts.ReadTotalTimeoutConstant = 50;
timeouts.ReadTotalTimeoutMultiplier = 10;
timeouts.WriteTotalTimeoutConstant = 50;
timeouts.WriteTotalTimeoutMultiplier = 10;
SetCommTimeouts(hserial, &timeouts);
(2)timeouts.ReadIntervalTimeout
读间隔超时,连续读取字符的最大间隔,如果超过这个时间还没有接收到新的字符,读取操作就会立刻返回。
所以也是控制单个字符之间的最大时间间隔,这里我们将值设置为了50ms,如果两个字符的时间间隔超过了50ms,数据传输或者读取函数就不会等待,立即返回读取的数据
特殊情况:ReadIntervalTimeout = MAXDWORD(0xFFFFFFFF),直接忽略间隔超时,只使用 ReadTotalTimeoutConstant
和 ReadTotalTimeoutMultiplier
计算超时,适用于轮询模式(非阻塞读取)。ReadIntervalTimeout = 0
→ 仅当串口有数据时,ReadFile()
才会返回,否则会一直阻塞。
(3)ReadTotalTimeoutMultiplier
(读总超时(字节计算))
计算 ReadFile() 读取数据的超时: 超时时间=(ReadTotalTimeoutMultiplier×读取字节数)+ReadTotalTimeoutConstant\text
这个值按每字节计算,适用于低速通信或流式数据传输。
特殊情况:ReadTotalTimeoutMultiplier = 0 → 只依赖 ReadTotalTimeoutConstant 进行超时计算,不按字节数调整。ReadTotalTimeoutMultiplier = MAXDWORD → ReadFile() 会无限等待,直到读取到所需字节才返回(慎用)。
(4)ReadTotalTimeoutConstant
(读总超时(固定值))
无论数据大小,保证ReadFile()最少等待这么长时间,与ReadTotalTimeoutMultiplier结合计算总超时,当前50ms,如果ReadFile()读取10个字节超时 = (10 *10)+ 50 = 150ms,如果ReadFile()读取0个字节(串口无数据)超时50ms,也就是说即使没有数据,ReadFile()也会等待50ms,然后返回
特殊情况:ReadTotalTimeoutConstant = 0 → 只有 ReadTotalTimeoutMultiplier 决定超时时间,避免无数据时长时间等待。ReadTotalTimeoutConstant = MAXDWORD→ ReadFile() 不会超时,会一直等到数据到达(慎用)。
(5)WriteTotalTimeoutMultiplier
(写总超时(字节计算))
计算 WriteFile() 写入数据的超时时间,超时时间=(WriteTotalTimeoutMultiplier×写入字节数)+WriteTotalTimeoutConstant,适用于低速设备,确保写入不会卡死。当前值 10ms/字节,
如果 WriteFile()
需要写入 10 个字节,超时=(10×10)+50=150 ms
WriteTotalTimeoutMultiplier = 0 → 只依赖 WriteTotalTimeoutConstant 计算超时。
(6)WriteTotalTimeoutConstant
(写总超时(固定值))
无论写入多少字节,保证 WriteFile()
最少等待这么长时间.当前写入的值是50ms,如果 WriteFile()
需要写入 10 个字节,超时=(10×10)+50=150 ms。如果 WriteFile()
需要写入 0 个字节,超时50ms
(7)SetCommTimeouts()的作用
SetCommTimeouts(hserial, &timeouts);
将 timeouts 结构体中的超时配置应用到串口设备 hserial。 影响 ReadFile() 和 WriteFile() 的行为,避免它们无限阻塞。如果 SetCommTimeouts()
失败,可能是因为无效的句柄(hserial
为空或未打开)
7. Serialport::~Serialport()
作用解析
这是 Serialport
类的析构函数,用于在对象销毁时释放资源,确保程序不会泄露系统资源(如文件句柄)
Serialport::~Serialport()
{
CloseHandle(hserial);
}
~Serialport()→ Serialport 类的析构函数,当 Serialport 对象超出作用域或被 delete 时自动调用。CloseHandle(hserial);→ 关闭 hserial 句柄,释放串口资源
CloseHandle()
是 Windows API,用于关闭各种系统对象(文件、进程、线程、串口等),函数原型如下
BOOL CloseHandle(HANDLE hObject);
8. writeData()
和 readData()
这两个函数分别用于向串口写入数据和串口读取数据,风封装了Windows API中的WriteFile()和ReadFile()
先看writeData()
bool SerialPort::writeData(const char* data, DWORD size)
{
DWORD bytesWritten;
return WriteFile(hserial, data, size, &bytesWritten, NULL);
}
该函数向串口发送数据。函数第一个参数表示要发送的数据,DWORD size表示需要发送的数据大小
DWORD bytesWritten;变量 bytesWritten 存储实际写入的字节数。因为最后WriteFile返回的值是0或者非0,所以刚开始前面是bool值
这个是函数要填写的参数,hserial就是串口句柄,data要发送的字节数据,size要发送的数据大小,&bytesWritten用于存储实际写入的字节数,NULL表示不使用重叠I/O(同步写入)
如果return返回非零则写入成功,返回0就是写入失败
那么下一个就是readData函数,函数从串口读取数据,DWORD size期望读取的字节数。对于DWORD bytesRead存储实际读取的字节数
bool Serialport::readData(char* buffer, DWORD size)
{
DWORD bytesRead;
return ReadFile(hserial, buffer, size, &bytesRead, NULL);
}
那么ReadFile函数,第一个参数仍然是句柄,buffer用于存储读取到的数据,size希望读取的字节数,&bytesRead存储实际读取的字节数(可能小于size),NULL还是表示不使用重叠I/O(同步参数)
return返回值的原理和上面一样,非零就读取成功,为0就是读取失败
9. 编写主函数
函数的逻辑我们在源文件中编写完成了,函数的声明也在头文件中完成,下一步就是编写主函数,那么还需要再创建一个文件,添加一个main.cpp
然后将串口插在电脑上
然后打开设备管理器
电脑检查到是COM20端口,那么就开始编写主函数的代码,函数逻辑很简单但是有几个点需要注意一下
#include "Serialport.h"
int main()
{
Serialport serial("\\\\.\\COM20", CBR_115200);
const char* msg = "Hello Serial Port";
serial.writeData(msg, strlen(msg));
char buffer[100] = { 0 };
if (serial.readData(buffer, sizeof(buffer)))
{
std::cout << "接收到数据" << buffer << std::endl;
}
return 0;
}
为什么不能直接填写COM20,做一个解释如果串行端口COM1-9就可以正常填写,但是如果是COM10(包括10)以上,就需要使用Windows串口命名格式,加上\\\\.\\
从上面的图片可以看到,我们将串口的RX和TX引脚连接在了一起,这个叫回环测试,相当于电脑从串口TX引脚发出数据,然后再由串口的RX接收
所以代码的意思就是发送一段函数,然后再定义一个数组用来接收数据,如果接收到数据,就打印接收到数据到终端,并且后面还会有接收的数据,下面看看效果
四. 结语
效果就成功实现了,那么我们目前一个简易的串口助手就完成了,下一章节我们会继续更改串口终端的内容
5. 代码展示
这里会将工程代码完整的展现出来
// Serialport.h
#include <iostream>
#include <windows.h>
class Serialport
{
public:
Serialport(const char* portName, DWORD Baudrate);
~Serialport();
bool writeData(const char* data, DWORD size);
bool readData(char* buffer, DWORD size);
private:
HANDLE hserial;
DCB dcbSerialParams;
COMMTIMEOUTS timeouts;
};
//Serialport.cpp
#include "Serialport.h"
Serialport::Serialport(const char* portName, DWORD Baudrate)
{
hserial = CreateFileA(portName, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL);
if (hserial == INVALID_HANDLE_VALUE)
{
std::cerr << "串口打开失败" << std::endl << "错误原因:" << GetLastError() << std::endl;
return;
}
dcbSerialParams.DCBlength = sizeof(dcbSerialParams);
if (!GetCommState(hserial, &dcbSerialParams))
{
std::cerr << "无法访问到串口" << std::endl;
CloseHandle(hserial);
return;
}
dcbSerialParams.BaudRate = Baudrate;
dcbSerialParams.ByteSize = 8;
dcbSerialParams.StopBits = ONESTOPBIT;
dcbSerialParams.Parity = NOPARITY;
if (!GetCommState(hserial, &dcbSerialParams))
{
std::cerr << "串口参数设置失败" << std::endl;
CloseHandle(hserial);
return;
}
timeouts.ReadIntervalTimeout = 50;
timeouts.ReadTotalTimeoutConstant = 50;
timeouts.ReadTotalTimeoutMultiplier = 10;
timeouts.WriteTotalTimeoutConstant = 50;
timeouts.WriteTotalTimeoutMultiplier = 10;
SetCommTimeouts(hserial, &timeouts);
}
Serialport::~Serialport()
{
CloseHandle(hserial);
}
bool Serialport::writeData(const char* data, DWORD size)
{
DWORD bytesWritten;
return WriteFile(hserial, data, size, &bytesWritten, NULL);
}
bool Serialport::readData(char* buffer, DWORD size)
{
DWORD bytesRead;
return ReadFile(hserial, buffer, size, &bytesRead, NULL);
}
// main.cpp
#include "Serialport.h"
int main()
{
Serialport serial("\\\\.\\COM20", CBR_115200);
const char* msg = "Hello Serial Port";
serial.writeData(msg, strlen(msg));
char buffer[100] = { 0 };
if (serial.readData(buffer, sizeof(buffer)))
{
std::cout << "接收到数据" << buffer << std::endl;
}
return 0;
}