本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:本文详细介绍了如何使用C++实现电子秤与PC之间的串口通信,涵盖工业自动化和数据采集系统中的核心通信技术。通过C++强大的系统级编程能力,结合libserialport或boost.asio等库,完成串口的枚举、初始化、配置、数据读写及异常处理。利用enumserial功能自动识别连接设备,实现稳定的数据传输,并通过封装SerialPort类提升代码可维护性。本项目帮助开发者掌握嵌入式设备与上位机通信的关键流程,适用于实际工业场景中的实时数据采集与控制应用。

串口通信的艺术:从物理层到智能设备识别的全栈实战

你有没有遇到过这种情况——一台电子秤明明插上了,但软件就是读不到数据?🤔
或者更糟,系统运行得好好的,突然某个时刻开始疯狂报错,日志里全是乱码和超时……

别急,这背后往往不是“玄学”,而是 串口通信的底层逻辑没被真正吃透 。在工业自动化、嵌入式开发甚至物联网项目中,RS-232虽然看起来“老掉牙”,但它依然是连接PLC、电子秤、温控仪等设备的 生命线 。毕竟,在电磁干扰严重的车间里,一根简单的双绞线比Wi-Fi稳定多了。

今天我们就来一场深度探险:不讲空话,不堆术语,带你从 信号电平 一路走到 自动识别电子秤 的完整闭环。你会看到——原来那些看似神秘的 COM3 /dev/ttyUSB0 背后,藏着一套极其优雅又务实的工程哲学。

准备好了吗?我们出发!🚀


🧱 一、串行通信的本质:为什么是“逐位”传输?

想象一下,你要把一本书从A地搬到B地。有两种方式:

  1. 并行搬运 :雇8个人,每人拿一页,同时走。
  2. 串行搬运 :一个人一页一页地搬。

听起来是不是并行更快?但在现实世界里,并行通信就像多条电线一起跑信号——布线复杂、成本高、容易互相干扰。尤其是在长距离(比如15米以上)或噪声环境(电机、变频器旁边),并行总线简直就是灾难。

于是,“串行”成了工业界的首选方案: 只用一对差分线(TXD/RXD)就能完成双向通信 ,抗干扰强、布线简单、成本低。

异步串行帧结构:数据是怎么打包的?

在异步通信中,数据是以“帧”为单位发送的。每帧包含以下几个部分:

[起始位][D0][D1][D2][D3][D4][D5][D6][D7][校验位?][停止位]
   1bit    8bits                 0/1bit     1~2bits
  • 起始位 :固定为低电平(0),告诉接收方:“注意了,我要发数据了!”
  • 数据位 :5~8位,常见的是8位(一个字节)。传输顺序通常是LSB(最低有效位)优先。
  • 校验位 :可选,用于检测单比特错误(奇校验/偶校验)。
  • 停止位 :高电平(1),标志这一帧结束,持续1或2个比特时间。

最常见的配置叫 8-N-1 :8位数据、无校验、1位停止位。

⚠️ 小贴士:如果收发双方的波特率、数据格式不一致,就会出现“雪崩式错位”——第一个字节对不上,后面全乱套!

而这一切的基础,就是那个古老的电气标准: RS-232


🔌 二、RS-232 接口详解:不只是 TXD 和 RXD

很多人以为 RS-232 就是两根线(TXD 发送、RXD 接收)加地线 GND。其实远不止如此!它是一整套完整的物理层规范。

负逻辑电平:+12V 是 “0”?!

没错!RS-232 使用的是 负逻辑

逻辑值 电压范围
0 +5V ~ +15V
1 -5V ~ -15V

这种设计有两大好处:
1. 抗噪能力强:±5V 的噪声容限意味着普通干扰很难翻转信号;
2. 支持长距离传输:早期通过驱动器可达百米级别。

常见的 DB9 接口引脚定义如下:

引脚 名称 方向 功能说明
2 RXD 输入 接收数据
3 TXD 输出 发送数据
7 RTS 输出 请求发送(主动)
8 CTS 输入 允许发送(响应)

💡 实际接线时,PC 的 TXD 要接到电子秤的 RXD,反之亦然——典型的交叉连接。

硬件流控:RTS/CTS 如何防止缓冲区溢出?

假设你的程序正在处理一堆任务,来不及读取串口数据,而电子秤却以每秒10次的速度狂发重量值……很快,接收缓冲区就满了,新来的数据只能被丢弃。

这时候就需要 硬件流控(RTS/CTS) 来协调节奏:

  • PC 设置 RTS=0 → 表示“我准备好接收”
  • 电子秤检测到 CTS=0 → 开始发送数据
  • 如果 PC 忙不过来,设置 RTS=1 → 电子秤暂停发送

这种机制不需要额外的数据包控制,纯粹靠电平变化实现,效率极高。

尽管现在 USB 满天飞,但 RS-232 凭借其 协议简洁、驱动稳定、抗干扰强 的特点,在工业现场依旧不可替代。


🛠️ 三、C++ 中如何玩转串口?三大主流方案对比

当你决定用 C++ 写一个上位机程序来读取电子秤数据时,第一个问题来了: 用哪个库?

这里有三条路可走:

方案 跨平台 易用性 异步支持 学习曲线 适用场景
libserialport ⭐⭐⭐☆ 平缓 快速原型、小型设备
boost.asio ⭐⭐ 陡峭 高并发、服务端应用
原生 API(Win/Linux) 复杂 性能极致优化、专用固件

我们一个个来看。

3.1 libserialport:轻量级跨平台王者

如果你想要一个 零依赖、编译即用、API 清晰 的小型库,那 libserialport 绝对是首选。

它是 Sigrok 项目的一部分,纯 C 实现,MIT 许可证,可以无缝集成进任何 C/C++ 工程。

架构图:统一接口背后的魔法
graph TD
    A[Application] --> B[libserialport]
    B --> C{OS Layer}
    C --> D[Windows: CreateFile(), SetCommState()]
    C --> E[Linux: open(), tcsetattr()]
    C --> F[macOS: POSIX serial calls]

你看,开发者只需要调用 sp_open() sp_set_baudrate() 这样的函数,剩下的交给它去适配不同操作系统。

示例代码:打开 COM3,设置 9600 波特率
#include <libserialport.h>
#include <iostream>

int main() {
    struct sp_port *port = nullptr;

    // 获取端口对象
    if (sp_get_port_by_name("COM3", &port) != SP_OK) {
        std::cerr << "无法找到指定串口\n";
        return -1;
    }

    // 打开端口
    if (sp_open(port, SP_MODE_READ_WRITE) != SP_OK) {
        std::cerr << "打开失败,请检查权限或是否被占用\n";
        sp_free_port(port);
        return -1;
    }

    // 设置参数
    sp_set_baudrate(port, 9600);
    sp_set_bits(port, 8);
    sp_set_stopbits(port, 1);
    sp_set_parity(port, SP_PARITY_NONE);

    std::cout << "✅ 串口已成功配置\n";

    // 别忘了关闭资源
    sp_close(port);
    sp_free_port(port);
    return 0;
}

这个库的优点很明显: 代码短、逻辑清、移植方便 。唯一的短板是不支持异步 I/O——所有操作都是阻塞的。

那怎么办?两种选择:要么自己开线程轮询,要么上更强的武器: boost.asio


3.2 boost.asio:高性能异步引擎,但也够“重”

当你的系统需要同时监控 10 台电子秤、5 个传感器、还要做网络上报时, boost.asio 就成了必选项。

它基于 Proactor 模式,使用事件循环( io_context )来调度所有异步操作,主线程不会因为等待数据而卡住。

异步读取流程图
sequenceDiagram
    participant App
    participant io_context
    participant SerialPort
    participant OS_Kernel

    App->>io_context: post(async_write)
    App->>SerialPort: async_read_some(buffer, handler)
    SerialPort->>OS_Kernel: 请求数据读取
    OS_Kernel-->>SerialPort: 数据到达中断
    SerialPort->>io_context: 将完成事件入队
    io_context->>App: 调用回调函数处理数据

整个过程完全非阻塞,适合构建高并发、低延迟的服务端程序。

示例代码:异步监听 COM3 数据
#include <boost/asio.hpp>
#include <iostream>
#include <thread>

using namespace boost::asio;

int main() {
    io_context io;
    serial_port port(io, "COM3");

    port.set_option(serial_port_base::baud_rate(9600));
    port.set_option(serial_port_base::character_size(8));
    port.set_option(serial_port_base::stop_bits(serial_port_base::stop_bits::one));
    port.set_option(serial_port_base::parity(serial_port_base::parity::none));

    std::array<uint8_t, 256> buffer;

    port.async_read_some(buffer.data(), [&](const error_code& ec, size_t bytes) {
        if (!ec) {
            std::cout << "📡 收到 " << bytes << " 字节数据:\n";
            for (size_t i = 0; i < bytes; ++i) {
                printf("%02X ", buffer[i]);
            }
            std::cout << "\n";
        } else {
            std::cerr << "❌ 读取出错: " << ec.message() << "\n";
        }
    });

    std::thread t([&]{ io.run(); });  // 启动事件循环

    std::cout << "👂 正在监听... 按回车退出\n";
    std::cin.get();

    io.stop();
    t.join();

    return 0;
}

这段代码启动后立即返回,不会卡主界面。一旦有数据到来,就会触发 lambda 回调。

缺点也很明显: 依赖大、编译慢、学习曲线陡峭 。对于资源受限的嵌入式设备来说,可能有点“杀鸡用牛刀”。


3.3 原生 API:直接操控系统的“裸奔”方式

最后一种方式,就是绕过一切中间层,直接调用操作系统原生接口。

Windows 上怎么写?
HANDLE hSerial = CreateFile(TEXT("COM3"),
                            GENERIC_READ | GENERIC_WRITE,
                            0, NULL, OPEN_EXISTING, 0, NULL);

DCB dcb = {0};
dcb.DCBlength = sizeof(DCB);
GetCommState(hSerial, &dcb);
dcb.BaudRate = 9600;
dcb.ByteSize = 8;
dcb.StopBits = ONESTOPBIT;
dcb.Parity = NOPARITY;
SetCommState(hSerial, &dcb);
Linux 上呢?
int fd = open("/dev/ttyUSB0", O_RDWR | O_NOCTTY);
struct termios tty;

tcgetattr(fd, &tty);
cfsetospeed(&tty, B9600);
cfsetispeed(&tty, B9600);
tty.c_cflag |= (CLOCAL | CREAD);
tty.c_cflag &= ~PARENB;
tty.c_cflag &= ~CSTOPB;
tty.c_cflag &= ~CSIZE;
tty.c_cflag |= CS8;
tcsetattr(fd, TCSANOW, &tty);

这两种方式性能最高、控制最精细,但代价是 平台绑定严重、代码冗长易错 ,不适合跨平台项目。


🔍 四、难题来了:如何自动识别哪一个是“电子秤”?

现在有个棘手的问题:用户可能把电子秤插在任意 USB 口上,每次重启都可能是不同的 COMx /dev/ttyUSB*

难道要让用户手动选端口号?显然不行!我们要的是 即插即用

这就引出了一个核心需求: 串口设备自动检测机制

4.1 自动识别的四大挑战

挑战类型 具体表现 解法思路
平台差异 Windows 是 COM3,Linux 是 /dev/ttyUSB0 抽象统一接口
权限问题 Linux 下非管理员无法访问 提示加入 dialout 组或设 udev 规则
设备响应延迟 有些设备需数秒初始化 设置合理超时 + 重试机制
误识别风险 多个相似设备(如两个 CH340 模块) 多重验证:VID/PID + 协议探测

我们的目标是构建这样一个流程:

graph TD
    A[开始] --> B{是否有新设备接入?}
    B -- 否 --> C[等待事件]
    B -- 是 --> D[获取新设备端口名]
    D --> E[打开串口连接]
    E --> F{连接成功?}
    F -- 否 --> G[标记为无效设备]
    F -- 是 --> H[发送握手命令]
    H --> I{收到预期响应?}
    I -- 否 --> J[关闭连接, 标记为非目标]
    I -- 是 --> K[确认为电子秤]
    K --> L[触发设备就绪事件]
    L --> M[结束]

下面我们就一步步实现它。


🔬 五、实战:基于 libserialport 的设备枚举与智能匹配

5.1 枚举所有串口设备

先来看看当前系统有哪些串口可用。

#include <libserialport.h>
#include <vector>
#include <string>

struct SerialPortInfo {
    std::string name;
    std::string description;
    std::string transport;
    int vid = 0, pid = 0;
};

std::vector<SerialPortInfo> enumerate_serial_ports() {
    std::vector<SerialPortInfo> result;
    struct sp_port **ports;

    if (sp_get_port_list(&ports) != SP_OK) {
        fprintf(stderr, "Failed to enumerate ports\n");
        return result;
    }

    for (int i = 0; ports[i]; ++i) {
        struct sp_port *port = ports[i];
        SerialPortInfo info;

        info.name = sp_get_port_name(port);
        info.description = sp_get_port_description(port);
        info.transport = sp_get_port_transport(port);

        int vid_val, pid_val;
        if (sp_get_port_usb_vid_pid(port, &vid_val, &pid_val) == SP_OK) {
            info.vid = vid_val;
            info.pid = pid_val;
        }

        result.push_back(info);
    }

    sp_free_port_list(ports);  // 必须释放!
    return result;
}

运行结果可能像这样:

名称 描述 VID PID
COM3 USB Serial Port 0x1A86 0x7523
/dev/ttyUSB0 FTDI USB Device 0x0403 0x6001

这些信息太重要了!我们可以根据 VID/PID 白名单 快速筛选候选设备。

5.2 智能匹配算法:不止看硬件 ID

但光靠 VID/PID 不够保险。比如两个同型号电子秤插在一起怎么办?所以我们还得加上 行为验证

第一步:基于 VID/PID 和描述符模糊匹配
bool is_scale_candidate(const SerialPortInfo& info) {
    static const std::set<std::pair<int, int>> valid_scales = {
        {0x1A86, 0x7523},  // CH340
        {0x067B, 0x2303}   // PL2303
    };

    if (info.vid && info.pid) {
        return valid_scales.count({info.vid, info.pid});
    }

    std::string desc = info.description;
    std::transform(desc.begin(), desc.end(), desc.begin(), ::tolower);

    return desc.find("scale") != std::string::npos ||
           desc.find("balance") != std::string::npos;
}
第二步:试探性通信验证

即使名字像,也得试试能不能“对话”。

bool probe_for_weight_response(const std::string& port_name) {
    struct sp_port *port;
    if (sp_get_port_by_name(port_name.c_str(), &port) != SP_OK)
        return false;

    if (sp_open(port, SP_MODE_READ_WRITE) != SP_OK) {
        sp_free_port(port);
        return false;
    }

    sp_set_baudrate(port, 9600);
    sp_set_bits(port, 8);
    sp_set_parity(port, SP_PARITY_NONE);
    sp_set_stopbits(port, 1);

    const char* cmd = "R\r\n";  // 请求重量
    sp_blocking_write(port, cmd, strlen(cmd), 500);

    char buffer[256];
    int n = sp_blocking_read(port, buffer, sizeof(buffer)-1, 1000);
    if (n > 0) {
        buffer[n] = '\0';
        bool valid = strstr(buffer, "W+") || strstr(buffer, "ST,GS");
        sp_close(port);
        sp_free_port(port);
        return valid;
    }

    sp_close(port);
    sp_free_port(port);
    return false;
}

💡 提示:不同品牌电子秤协议不同:

  • Mettler Toledo: IP\r IP S/N:...
  • Ohaus: PR\r PR W=...
  • 通用 ASCII: R\r\n W+12.3kg

所以你可以维护一个“探测指令集”,逐一尝试。


🧩 六、封装成类:SerialDeviceDetector —— 让一切自动化

为了复用上面的逻辑,我们把它封装成一个面向对象的类。

class SerialDeviceDetector {
public:
    using DeviceCallback = std::function<void(const SerialPortInfo&)>;

    void set_match_timeout(int ms) { timeout_ms = ms; }

    void add_vid_pid_filter(int vid, int pid) {
        vid_pid_whitelist.emplace(vid, pid);
    }

    void set_description_keyword(const std::string& kw) {
        desc_keyword = kw;
    }

    void on_device_found(DeviceCallback cb) {
        callback = cb;
    }

    void start_detection() {
        auto ports = enumerate_serial_ports();
        for (const auto& info : ports) {
            if (is_scale_candidate(info)) {
                if (probe_for_weight_response(info.name)) {
                    if (callback) callback(info);
                    break;
                }
            }
        }
    }

private:
    int timeout_ms = 500;
    std::set<std::pair<int,int>> vid_pid_whitelist;
    std::string desc_keyword;
    DeviceCallback callback;
};

使用起来超级简单:

SerialDeviceDetector detector;
detector.add_vid_pid_filter(0x1A86, 0x7523);
detector.on_device_found([](const SerialPortInfo& info){
    std::cout << "🎉 找到电子秤:" << info.name << "\n";
    // 自动连接该端口...
});

detector.start_detection();

还可以结合热插拔事件监听(Windows 的 WM_DEVICECHANGE 或 Linux 的 udevadm monitor ),实现真正的“即插即用”。


⚙️ 七、建立可靠通信链路:不只是打开端口那么简单

你以为打开了串口就万事大吉?Too young.

真正的挑战才刚开始: 参数配置、状态管理、断线重连、心跳保活……

7.1 关键参数配置详解

参数 常见值 影响说明
波特率 9600, 19200, 38400 必须与设备一致,否则数据错乱
数据位 8 大多数设备使用 8 位
停止位 1 或 2 长线建议用 2 位提高同步容错
校验位 None/Odd/Even 工业环境建议启用 Even 校验
流控 None/RTS-CTS/XON-XOFF 高速传输建议启用硬件流控

我们可以把这些参数存进 JSON 文件:

{
  "port": "COM3",
  "baudrate": 9600,
  "data_bits": 8,
  "stop_bits": 1,
  "parity": "none",
  "flow_control": "none",
  "timeout_ms": 1000
}

然后用 nlohmann/json 加载:

bool load_config(const std::string& path, SerialConfig& cfg) {
    std::ifstream f(path);
    json j; f >> j;

    cfg.port_name = j.value("port", "");
    cfg.baudrate = j.value("baudrate", 9600);
    // ...其他字段
    return true;
}

7.2 断线自动重连 + 指数退避

设备突然断开怎么办?不能让它死掉!

bool retry_with_backoff(SerialConnection& conn, int max_retries = 5) {
    int delay_ms = 100;
    for (int i = 0; i < max_retries; ++i) {
        if (conn.openPort("COM3")) {
            std::cout << "🔁 成功重连!\n";
            return true;
        }
        std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms));
        delay_ms *= 2;  // 指数增长:100ms → 200ms → 400ms...
    }
    return false;
}

避免短时间内疯狂重试,造成系统负载飙升。

7.3 心跳机制:保持链路活跃

长时间无通信可能导致设备休眠。定期发个心跳唤醒它:

void send_heartbeat(SerialConnection& conn) {
    const char heartbeat[] = "\x05";  // ENQ 字符
    if (conn.isOpen()) {
        int ret = sp_blocking_write(conn.getPort(), heartbeat, 1, 1000);
        if (ret <= 0) {
            std::cout << "💔 心跳失败,准备重连...\n";
            conn.closePort();
        }
    }
}

每隔 5 秒发一次,连续三次失败就触发重连。


📊 八、非阻塞接收与原始协议解析:让数据变得有意义

终于到了最关键的一步: 怎么高效收数据,并从中提取出真实的重量值?

8.1 阻塞 vs 非阻塞:别让你的 UI 卡住!

千万别在主线程里写这种代码:

while (true) {
    sp_blocking_read(port, buf, len, 1000);  // 直接卡住!
}

正确的做法是: 单独开一个接收线程

class DataReceiver {
    void reading_loop() {
        while (running_) {
            uint8_t buf[512];
            int n = sp_nonblocking_read(port_, (char*)buf, sizeof(buf));
            if (n > 0) {
                std::lock_guard<std::mutex> lock(mutex_);
                queue_.insert(queue_.end(), buf, buf + n);
            }
            std::this_thread::sleep_for(std::chrono::milliseconds(10));
        }
    }
};

再配合环形缓冲区处理粘包/断包问题:

class CircularBuffer {
    void write(const uint8_t* data, size_t len);
    bool find_frame_start(size_t& offset);  // 查找 "W+" 开头
};

8.2 协议逆向解析:ASCII 与 二进制帧

ASCII 文本流
WeightData parse_ascii_weight(const std::string& line) {
    if (line.substr(0, 2) == "W+") {
        double val = std::stod(line.substr(2));  // 提取数字
        return {val, "kg", time(nullptr)};
    }
    return {};
}
二进制帧(带校验)
bool validate_frame(const uint8_t* frame, size_t len) {
    if (len != 9 || frame[0] != 0x02 || frame[8] != 0x03) return false;
    uint8_t checksum = 0;
    for (int i = 0; i < 8; ++i) checksum ^= frame[i];
    return checksum == frame[8];
}

最终整合成一个通用解析器:

class ProtocolParser {
    enum State { WAITING_STX, READING_DATA, CHECKING_FRAME };
    void feed(const uint8_t* data, size_t len);
};

🛡️ 九、异常处理与健壮性设计

最后一步: 打造坚如磐石的系统

常见故障类型

故障类型 原因 应对策略
端口被占用 多进程争抢 提示用户关闭其他程序
权限不足 Linux 下未授权 添加 udev 规则或 sudo 启动
电缆松动 物理连接不稳定 心跳检测 + 自动重连
帧错误/校验失败 波特率不匹配或干扰 日志记录 + 重新同步
缓冲区溢出 处理速度跟不上 增大缓冲区或启用流控

错误码映射 + 日志追踪

enum class SerialError {
    None, PortNotFound, PermissionDenied, OpenFailed, ...
};

SerialError errno_to_error(int sp_result) {
    switch (sp_result) {
        case SP_ERR_ARG: return SerialError::PortNotFound;
        case SP_ERR_FAIL: 
            if (errno == EACCES) return SerialError::PermissionDenied;
            break;
    }
    return SerialError::OpenFailed;
}

配合 spdlog 输出详细日志:

LOG_INFO("Opening port {} at {} bps", cfg.port_name, cfg.baudrate);
LOG_DEBUG("Received raw data: {}", hex_dump(data, len));

✅ 十、总结:我们构建了什么?

经过这一趟旅程,我们已经完成了从 物理层 应用层 的完整打通:

  1. 理解了 RS-232 的电气特性与帧结构
  2. 掌握了三种主流 C++ 串口库的选型与使用
  3. 实现了跨平台设备自动检测机制
  4. 封装了可复用的 SerialDeviceDetector
  5. 建立了可靠的参数配置、连接管理、断线恢复体系
  6. 设计了高效的非阻塞接收与协议解析流程
  7. 加入了全面的日志、监控与异常处理能力

这样的系统不仅适用于电子秤,也可以轻松迁移到条码枪、温湿度传感器、PLC 控制器等各种工业外设。


🎯 写在最后

技术从来不是孤立存在的。每一个 sp_open() 调用背后,都有工程师对稳定性、兼容性、用户体验的深思熟虑。

下次当你看到一台电子秤安静地传回重量数据时,不妨想一想:这短短几毫秒的背后,是多少层抽象、多少次重试、多少份耐心打磨的结果。

“简单的事情做到极致,就是伟大。”
—— 致每一位默默耕耘的系统程序员 💻❤️

如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、转发!也欢迎在评论区分享你的串口踩坑经历~我们一起成长!🌟

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:本文详细介绍了如何使用C++实现电子秤与PC之间的串口通信,涵盖工业自动化和数据采集系统中的核心通信技术。通过C++强大的系统级编程能力,结合libserialport或boost.asio等库,完成串口的枚举、初始化、配置、数据读写及异常处理。利用enumserial功能自动识别连接设备,实现稳定的数据传输,并通过封装SerialPort类提升代码可维护性。本项目帮助开发者掌握嵌入式设备与上位机通信的关键流程,适用于实际工业场景中的实时数据采集与控制应用。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐