串口通信工具:让硬件和软件“说上话”

做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;
}
小技巧: 串口名在Windows上是 "COM1"、"COM2" 这样的格式。如果端口号大于9,要写成 "\\\\.\\COM10"。这个坑我踩过,分享给你。

读写操作的细节

读数据时,我一般用循环读取,因为串口数据是流式的,不一定一次就能读完。写数据相对简单,但要注意检查返回值,确保所有字节都发送出去了。

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);
}
警告: 千万不要在主线程里做阻塞式的串口读写。否则界面会卡死。我建议开一个独立线程来处理数据收发。

串口通信的整体流程

为了让你看得更清楚,我画了一张流程图。它展示了从打开串口到关闭串口的完整生命周期。

开始 打开串口 (CreateFile) 配置参数 (SetCommState) 读写数据 (Read/Write) 关闭串口 (CloseHandle) 结束

多线程接收数据

实际项目中,我从来不会在主线程里直接读串口。原因很简单:你不知道数据什么时候来。如果一直等,程序就卡死了。

我的做法是开一个工作线程,循环读取数据。读到数据后,通过回调函数或者消息队列通知主线程。

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毫秒发一次数据。我一开始用阻塞读取,结果界面完全没法操作。后来改成线程+回调,问题就解决了。说白了,异步处理是串口通信的标配

总结一下

串口通信工具,核心就是三件事:打开、配置、读写。封装成类之后,用起来特别顺手。多线程接收数据是必须的,别偷懒。参数匹配是命根子,错了就全白搭。

嗯,今天就聊到这儿。代码你可以直接拿去用,但记得根据实际设备调整参数。动手试试,你会发现串口通信其实没那么神秘。

公众号:蓝海资料掘金营,微信deep3321