当前位置: 首页 > article >正文

从零开始使用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_READFILE_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),直接忽略间隔超时,只使用 ReadTotalTimeoutConstantReadTotalTimeoutMultiplier 计算超时,适用于轮询模式(非阻塞读取)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;
}


http://www.kler.cn/a/594188.html

相关文章:

  • mapbox基础,加载marker点位,测试大数据量加载性能问题
  • 分布式中间件:RabbitMQ死信队列和延迟队列
  • Android Opengl(九)FBO帧缓冲示例
  • UI设计中的对比与统一:构建和谐界面的原则
  • PyTorch模型转ONNX例子
  • Unity URP 实现场景和UI添加后处理
  • 知识库--Milvus
  • WordPress靶场攻略
  • php 要达到go的性能,应该如何优化php
  • 【蓝桥杯python研究生组备赛】005 数学与简单DP
  • 【CXX-Qt】2.1 构建系统
  • Python 编程题 第十一节:选择排序、插入排序、删除字符、目标移动、尾部的0
  • 如何通过 SQLyog 连接远程 MySQL 数据库?(附工具下载)
  • pdf文件分页按需查看
  • 【VolView】纯前端实现CT三维重建-CBCT
  • 数据结构-----队列
  • LM Studio、ollama本地部署运行多个AI
  • 玩转物联网-4G模块如何快速将数据上传到巴法云(TCP篇)
  • Java解析多层嵌套JSON数组并将数据存入数据库示例
  • 软考中级-软件设计师 准备