串口通信工具:让硬件和软件“说上话”
做C++项目这么多年,串口通信一直是个绕不开的话题。我记得刚入行那会儿,调试一个嵌入式设备,折腾了一整天就是收不到数据。后来才发现,原来是波特率配错了。嗯,从那以后,我对串口通信就多了一份敬畏。
说白了,串口通信就是让计算机和外部设备交换数据的一种方式。你想想看,鼠标、打印机、工业传感器,很多设备都靠它来传输信息。今天我们就来手写一个串口通信工具,把底层原理和实战代码都捋一遍。
串口通信的核心概念
串口通信,全称是串行通信接口。它一次只传一个比特,像排队一样一个一个地走。虽然速度比不上USB,但胜在简单可靠,工业领域用得特别多。
我个人习惯把串口通信拆成三个要素:
- 波特率:每秒传输的比特数。常见的有9600、115200。两边必须一致,否则就是鸡同鸭讲。
- 数据位:一次传输多少个比特。通常是8位。
- 停止位和校验位:用来保证数据完整性的辅助位。
我在项目中遇到过最坑的事,就是设备默认用偶校验,而我代码里写的是无校验。结果数据读出来全是乱码。所以,通信前一定要确认参数完全匹配。
Windows下串口编程的底层逻辑
在Windows上操作串口,其实和读写文件差不多。系统把串口当作一个特殊的文件来处理。你打开它、读写它、最后关闭它。核心API就几个:
CreateFile:打开串口ReadFile/WriteFile:读写数据SetCommState:配置参数CloseHandle:关闭串口
听起来简单吧?但实际写起来,细节特别多。我曾经因为忘记设置超时时间,导致程序在读取数据时卡死。嗯,这里要注意,SetCommTimeouts 这个函数一定要调。
代码实战:封装一个串口类
我建议把串口操作封装成一个类,这样用起来清爽。下面是我常用的一个框架:
class SerialPort {
public:
SerialPort();
~SerialPort();
bool Open(const std::string& portName, int baudRate);
void Close();
int ReadData(char* buffer, int bufferSize);
int WriteData(const char* data, int dataSize);
bool IsOpen() const;
private:
HANDLE m_hComm;
bool m_isOpen;
};
构造函数里把 m_hComm 初始化为 INVALID_HANDLE_VALUE,析构函数里确保关闭串口。这是基本素养,防止资源泄漏。
打开串口的实现,我习惯这样写:
bool SerialPort::Open(const std::string& portName, int baudRate) {
m_hComm = CreateFileA(
portName.c_str(),
GENERIC_READ | GENERIC_WRITE,
0,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL
);
if (m_hComm == INVALID_HANDLE_VALUE) {
return false;
}
// 配置参数
DCB dcb = {0};
dcb.DCBlength = sizeof(DCB);
GetCommState(m_hComm, &dcb);
dcb.BaudRate = baudRate;
dcb.ByteSize = 8;
dcb.StopBits = ONESTOPBIT;
dcb.Parity = NOPARITY;
SetCommState(m_hComm, &dcb);
// 设置超时
COMMTIMEOUTS timeouts = {0};
timeouts.ReadIntervalTimeout = 50;
timeouts.ReadTotalTimeoutConstant = 50;
timeouts.ReadTotalTimeoutMultiplier = 10;
SetCommTimeouts(m_hComm, &timeouts);
m_isOpen = true;
return true;
}
读写操作的细节
读数据时,我一般用循环读取,因为串口数据是流式的,不一定一次就能读完。写数据相对简单,但要注意检查返回值,确保所有字节都发送出去了。
int SerialPort::ReadData(char* buffer, int bufferSize) {
DWORD bytesRead = 0;
if (!ReadFile(m_hComm, buffer, bufferSize, &bytesRead, NULL)) {
return -1;
}
return static_cast<int>(bytesRead);
}
int SerialPort::WriteData(const char* data, int dataSize) {
DWORD bytesWritten = 0;
if (!WriteFile(m_hComm, data, dataSize, &bytesWritten, NULL)) {
return -1;
}
return static_cast<int>(bytesWritten);
}
串口通信的整体流程
为了让你看得更清楚,我画了一张流程图。它展示了从打开串口到关闭串口的完整生命周期。
多线程接收数据
实际项目中,我从来不会在主线程里直接读串口。原因很简单:你不知道数据什么时候来。如果一直等,程序就卡死了。
我的做法是开一个工作线程,循环读取数据。读到数据后,通过回调函数或者消息队列通知主线程。
void SerialPort::StartReceiveThread() {
m_receiveThread = std::thread([this]() {
char buffer[1024];
while (m_isOpen) {
int bytesRead = ReadData(buffer, sizeof(buffer));
if (bytesRead > 0) {
// 调用回调函数,把数据传出去
if (m_onDataReceived) {
m_onDataReceived(buffer, bytesRead);
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
});
m_receiveThread.detach();
}
sleep,否则CPU占用率会飙到100%。我见过有人没加,结果程序跑起来风扇呼呼转。
常见问题与避坑指南
做串口通信,有几个问题特别容易遇到。我列出来,你心里有个数:
| 问题 | 原因 | 解决办法 |
|---|---|---|
| 打不开串口 | 端口被占用或不存在 | 检查设备管理器,确认端口号 |
| 数据乱码 | 波特率或校验位不匹配 | 核对双方参数,保持一致 |
| 读取超时 | 超时设置不合理 | 调整 ReadIntervalTimeout |
| 程序卡死 | 主线程阻塞读取 | 改用独立线程接收 |
我曾经在一个项目里,设备每隔100毫秒发一次数据。我一开始用阻塞读取,结果界面完全没法操作。后来改成线程+回调,问题就解决了。说白了,异步处理是串口通信的标配。
总结一下
串口通信工具,核心就是三件事:打开、配置、读写。封装成类之后,用起来特别顺手。多线程接收数据是必须的,别偷懒。参数匹配是命根子,错了就全白搭。
嗯,今天就聊到这儿。代码你可以直接拿去用,但记得根据实际设备调整参数。动手试试,你会发现串口通信其实没那么神秘。