目录
一、什么是串口通信(零基础入门)
1.1 串行 vs 并行:一条路还是八条路
设备之间要传数据,本质是把一个个字节(8 个二进制位)从 A 搬到 B。搬运方式有两种:
通俗比喻
并行传输 = 8 个人并排站成一排,每人拿一个字母,同时喊出来,一次喊出 8 个字母的单词。
串行传输 = 1 个人按顺序一个字母一个字母地喊,虽然慢,但只要有 1 张嘴(1 根线)就行,而且喊得可以很快。
并行传输 = 8 个人并排站成一排,每人拿一个字母,同时喊出来,一次喊出 8 个字母的单词。
串行传输 = 1 个人按顺序一个字母一个字母地喊,虽然慢,但只要有 1 张嘴(1 根线)就行,而且喊得可以很快。
1.2 单工、半双工、全双工
记忆口诀
· 单工 = 单行道,只进不出
· 半双工 = 对讲机,"over" 之后对方才能说
· 全双工 = 打电话,可以边听边说
· 单工 = 单行道,只进不出
· 半双工 = 对讲机,"over" 之后对方才能说
· 全双工 = 打电话,可以边听边说
1.3 同步 vs 异步
接收方怎么知道每一位数据从什么时候开始?两种办法:
- 同步通信:收发双方共享一根时钟线,接收方按时钟节拍采样。典型:SPI、I2C。
- 异步通信:没有时钟线!双方事先约定好速率(波特率),靠帧格式中的起始位对齐节拍。典型:UART(RS-232/RS-485 底层都是 UART)。
本文的主角
RS-232 和 RS-485 本质上都是电气标准(定义电压、线缆、距离),它们传输的数据都由 UART(通用异步收发器)产生。记住这个公式:
UART(帧格式)+ 电气标准(RS-232 或 RS-485)= 完整的物理层方案
RS-232 和 RS-485 本质上都是电气标准(定义电压、线缆、距离),它们传输的数据都由 UART(通用异步收发器)产生。记住这个公式:
UART(帧格式)+ 电气标准(RS-232 或 RS-485)= 完整的物理层方案
二、RS-232 协议详解
2.1 概念与历史
RS-232(Recommended Standard 232)是美国电子工业协会(EIA)在 1960 年代发布的串行通信标准,最初用于连接电传打字机和调制解调器(Modem)。你可能在老电影里见过的"拨号上网的猫",它们之间就是用 RS-232 连接的。
虽然已经 60 多岁了,RS-232 至今仍活跃在:
- 工业设备的调试控制台(Console 口)
- PLC、变频器等老设备的编程口
- GPS 模块、气体传感器等模块的输出接口
- 医疗设备、测量仪器(示波器、万用表)的通信口
2.2 电气特性:负逻辑的"怪脾气"
RS-232 最大的特点有两个:电压高和负逻辑。
为什么电压这么高?
±3V 以上的摆幅是为了抗干扰:信号传了几十米后会被噪声淹没,普通 5V/3.3V 的 TTL 电平余量太小,而 ±12V 的大摆幅即使衰减和叠加噪声后,接收端仍能明确判断是 0 还是 1。
代价:不能直接连 MCU 引脚!必须经过电平转换芯片(如 MAX3232),否则烧片子。
±3V 以上的摆幅是为了抗干扰:信号传了几十米后会被噪声淹没,普通 5V/3.3V 的 TTL 电平余量太小,而 ±12V 的大摆幅即使衰减和叠加噪声后,接收端仍能明确判断是 0 还是 1。
代价:不能直接连 MCU 引脚!必须经过电平转换芯片(如 MAX3232),否则烧片子。
2.3 接口与引脚:DB9 连接器
最常见的 RS-232 接口是 DB9(9 针 D 型接口)。老台式机后面的"串口"就是它。
最经典的接错方式:TXD 接 TXD
串口是"你发我收":A 的 TXD 必须接 B 的 RXD,A 的 RXD 接 B 的 TXD,GND 直连。这叫交叉连接。
如果两个都是 TXD 接 TXD,双方都在"说"没人"听",现象是:端口能打开但收不到任何数据。
记住:发对收,收对发,地接地。
串口是"你发我收":A 的 TXD 必须接 B 的 RXD,A 的 RXD 接 B 的 TXD,GND 直连。这叫交叉连接。
如果两个都是 TXD 接 TXD,双方都在"说"没人"听",现象是:端口能打开但收不到任何数据。
记住:发对收,收对发,地接地。
2.4 RS-232 的能力与局限
| 指标 | 参数 | 说明 |
|---|---|---|
| 通信方式 | 全双工 | TXD/RXD 各一根线,可同时收发 |
| 拓扑 | 点对点 | 只能一对一,不能挂多机 |
| 最高速率 | 115200 bps(实用) | 标准上限理论 1Mbps,实用 115200 |
| 传输距离 | 15 米(典型) | 低波特率下可到 30~50 米 |
| 电平 | ±3V ~ ±15V(负逻辑) | 需 MAX3232 等芯片转换 |
| 抗干扰 | 较弱 | 单端信号(对地测电压),噪声全算数 |
| 节点数 | 1 发 1 收 | 驱动器只能带 1 个接收器 |
单端信号为什么抗干扰差?
RS-232 测量的是"信号线相对地的电压"。如果线路上耦合了 2V 的噪声,接收端就会把这 2V 错算进电平判断里——噪声直接叠加在信号上。
而 RS-485 的差分信号测的是"两根线之间的电压差",噪声同时叠加到两根线上,相减后被抵消。这就是下一章的内容。
RS-232 测量的是"信号线相对地的电压"。如果线路上耦合了 2V 的噪声,接收端就会把这 2V 错算进电平判断里——噪声直接叠加在信号上。
而 RS-485 的差分信号测的是"两根线之间的电压差",噪声同时叠加到两根线上,相减后被抵消。这就是下一章的内容。
三、串口帧格式:数据是怎么排队的
RS-232 和 RS-485 共用同一套 UART 帧格式。一个字节不是裸着上线,而是被包装成一帧:
3.1 帧格式四要素详解
| 要素 | 作用 | 取值 |
|---|---|---|
| 起始位 | 线从高变低,通知接收方"数据来了",并作为采样对齐的时间基准 | 固定 1 位,逻辑 0 |
| 数据位 | 真正的数据,低位(LSB)先发 | 5/6/7/8 位(常用 8) |
| 校验位 | 简单的错误检测:让 1 的个数为奇数(奇校验)或偶数(偶校验) | 无 / 奇 / 偶(常用无) |
| 停止位 | 线拉回高电平,标志帧结束,给硬件留恢复时间 | 1 / 1.5 / 2 位(常用 1) |
3.2 波特率:每秒传多少位
波特率(Baud Rate)
每秒传输的位数(bit/s)。常见值:9600、19200、38400、57600、115200。
例:115200 bps,8N1 格式,每帧实际 10 位(1起始+8数据+1停止),则每秒最多传 115200 ÷ 10 = 11520 字节。
发送 1KB 数据耗时约 1000 ÷ 11520 ≈ 87 毫秒——串口不快,够用就好。
每秒传输的位数(bit/s)。常见值:9600、19200、38400、57600、115200。
例:115200 bps,8N1 格式,每帧实际 10 位(1起始+8数据+1停止),则每秒最多传 115200 ÷ 10 = 11520 字节。
发送 1KB 数据耗时约 1000 ÷ 11520 ≈ 87 毫秒——串口不快,够用就好。
四、RS-485 协议详解
4.1 概念:为工业而生
RS-485(TIA/EIA-485)是 1983 年发布的差分串行通信标准,专门解决 RS-232 的三大痛点:距离短、速率低、不能多机组网。
4.2 电气特性:A 线与 B 线
RS-485 用两根线(通常称为 A 和 B)传输一路差分信号:
| 指标 | 参数 | 说明 |
|---|---|---|
| 信号线 | A(正相/D+)、B(负相/D-) | 双绞线,推荐带屏蔽层 |
| 逻辑 1(Mark) | V(A) - V(B) ≥ +200mV | A 比 B 高至少 200mV |
| 逻辑 0(Space) | V(A) - V(B) ≤ -200mV | A 比 B 低至少 200mV |
| 共模电压范围 | -7V ~ +12V | 两线相对地的电压允许波动范围 |
| 摆幅 | ±1.5V ~ ±5V | 比 RS-232(±12V)小得多 |
A/B 线没有全球统一的命名!
有的芯片标 A=正相,有的标 A=负相;不同厂家设备对接时若"同相相接"反而正确。
实用口诀:先随便接,收不到数据就把 A、B 对调——这是 485 调试第一定律。
有的芯片标 A=正相,有的标 A=负相;不同厂家设备对接时若"同相相接"反而正确。
实用口诀:先随便接,收不到数据就把 A、B 对调——这是 485 调试第一定律。
五、差分信号:RS-485 抗干扰的秘密武器
5.1 单端 vs 差分:测电压的方式不同
数学上为什么噪声会被抵消?
设发送端让 A = +2.5V、B = 0V(表示逻辑 1),此时 A − B = 2.5V。
线路上耦合了噪声 n(比如电机启动干扰,n = 1V):A 变成 3.5V,B 变成 1V。
接收端计算:A − B = 3.5 − 1 = 仍是 2.5V!噪声 n 在减法中被完全消除。
条件只有一个:噪声必须"同时、等量"地叠加到两根线上(共模噪声)——所以两根线必须双绞(扭在一起),保证它们挨得一样近、受干扰一样多。
设发送端让 A = +2.5V、B = 0V(表示逻辑 1),此时 A − B = 2.5V。
线路上耦合了噪声 n(比如电机启动干扰,n = 1V):A 变成 3.5V,B 变成 1V。
接收端计算:A − B = 3.5 − 1 = 仍是 2.5V!噪声 n 在减法中被完全消除。
条件只有一个:噪声必须"同时、等量"地叠加到两根线上(共模噪声)——所以两根线必须双绞(扭在一起),保证它们挨得一样近、受干扰一样多。
5.2 为什么用双绞线
六、RS-485 总线拓扑与多机通信
6.1 半双工两线制与全双工四线制
6.2 一主多从:谁先说话?
半双工总线上所有设备共用一对线,两个设备同时发送会导致数据碰撞(总线冲突)。工业上普遍采用主从轮询(Master-Slave Polling):
为什么 RS-485 天生适合 Modbus?
Modbus RTU 协议的主从架构(一主多从、地址寻址、请求-应答)与 RS-485 的总线半双工特性完全契合:
· 主站 = 轮询发起者(SCADA、组态软件、PLC)
· 从站 = 传感器/仪表/驱动器(地址 1~247)
· 9600/19200 bps 的速率对"读几个寄存器"绰绰有余
这就是为什么 90% 的 Modbus 设备都跑在 RS-485 上。
Modbus RTU 协议的主从架构(一主多从、地址寻址、请求-应答)与 RS-485 的总线半双工特性完全契合:
· 主站 = 轮询发起者(SCADA、组态软件、PLC)
· 从站 = 传感器/仪表/驱动器(地址 1~247)
· 9600/19200 bps 的速率对"读几个寄存器"绰绰有余
这就是为什么 90% 的 Modbus 设备都跑在 RS-485 上。
6.3 终端电阻:消除信号反射
终端电阻使用经验
· 短线(<10 米)、低速(9600bps)时,通常不接也能正常通信
· 长线 + 高速时必须接,否则误码率飙升
· 数量:只在两端各 1 个。接多了会加重驱动器负载,通信反而变差
· 判断线缆特征阻抗:RVVP 屏蔽双绞线典型 100~120Ω,接 120Ω 即可
· 短线(<10 米)、低速(9600bps)时,通常不接也能正常通信
· 长线 + 高速时必须接,否则误码率飙升
· 数量:只在两端各 1 个。接多了会加重驱动器负载,通信反而变差
· 判断线缆特征阻抗:RVVP 屏蔽双绞线典型 100~120Ω,接 120Ω 即可
七、UART:软件层与电气层的分界线
7.1 三层结构:一个字节从 MCU 到线缆的旅程
三层分工
· UART 层:管帧格式(起始位/数据位/停止位),不管电压高低 → 软件配置的对象
· 电气层(RS-232 / RS-485):管电压、线缆、距离、节点数 → 硬件电路的事
· 协议层(Modbus 等):管数据内容格式、地址、CRC 校验 → 软件解析的对象
对程序员来说:写代码时只面对 UART(QSerialPort),电气层是硬件工程师的事。
· UART 层:管帧格式(起始位/数据位/停止位),不管电压高低 → 软件配置的对象
· 电气层(RS-232 / RS-485):管电压、线缆、距离、节点数 → 硬件电路的事
· 协议层(Modbus 等):管数据内容格式、地址、CRC 校验 → 软件解析的对象
对程序员来说:写代码时只面对 UART(QSerialPort),电气层是硬件工程师的事。
7.2 TTL 电平直连:什么时候不需要转换芯片
TTL 串口(UART 直连)
如果两块 PCB 距离很近(<1 米),可以直接用 MCU 的 TX/RX 引脚连接(共地,交叉连接),这就是"TTL 串口"。
GPS 模块、蓝牙模块(HC-05)、ESP8266 等模块的引脚标着 TX/RX 的,通常就是 TTL 电平。
千万别把 TTL 直接接到 ±12V 的 RS-232 口上——一接就烧。
如果两块 PCB 距离很近(<1 米),可以直接用 MCU 的 TX/RX 引脚连接(共地,交叉连接),这就是"TTL 串口"。
GPS 模块、蓝牙模块(HC-05)、ESP8266 等模块的引脚标着 TX/RX 的,通常就是 TTL 电平。
千万别把 TTL 直接接到 ±12V 的 RS-232 口上——一接就烧。
八、RS-232 vs RS-485 全面对比
| 对比项 | RS-232 | RS-485 |
|---|---|---|
| 发布年份 | 1962(EIA-232) | 1983(TIA/EIA-485) |
| 信号方式 | 单端(对地电压) | 差分(两线压差) |
| 逻辑电平 | ±3V ~ ±15V(负逻辑) | ±200mV 阈值(差压) |
| 线缆 | 3 线(TXD/RXD/GND) | 2 线(A/B)或 4 线 |
| 通信模式 | 全双工 | 半双工(2 线)/ 全双工(4 线) |
| 拓扑 | 点对点 | 总线型(可挂 32~256 节点) |
| 最大距离 | 15 米 | 1200 米(100kbps 下) |
| 最高速率 | 115200 bps(实用) | 10 Mbps(12 米内) |
| 抗干扰能力 | 弱(单端) | 强(共模噪声抵消) |
| 典型应用 | 调试口、老仪器、GPS 模块 | Modbus、PLC 网络、楼宇自控 |
| 典型芯片 | MAX3232、SP3232 | MAX485、SP3485、SN65HVD75 |
| 是否需要方向控制 | 不需要(全双工) | 2 线制需要(DE/RE 引脚) |
九、Qt/C++ 实战:QSerialPort 串口编程
9.1 环境准备
# .pro 文件(qmake) QT += serialport # CMakeLists.txt(CMake) find_package(Qt6 REQUIRED COMPONENTS SerialPort) target_link_libraries(myapp PRIVATE Qt6::SerialPort)
QSerialPort 模块的两个核心类
· QSerialPortInfo:枚举系统里有哪些串口(COM3、COM4、ttyUSB0...)
· QSerialPort:一个 QIODevice 子类——串口就是一个"文件",open/read/write/close 全套操作和 QFile 如出一辙
· QSerialPortInfo:枚举系统里有哪些串口(COM3、COM4、ttyUSB0...)
· QSerialPort:一个 QIODevice 子类——串口就是一个"文件",open/read/write/close 全套操作和 QFile 如出一辙
9.2 枚举与打开串口
#include <QSerialPortInfo> #include <QSerialPort> // 1. 枚举系统所有串口 for (const auto &info : QSerialPortInfo::availablePorts()) { qDebug() << "端口:" << info.portName() // COM3 / ttyUSB0 << "描述:" << info.description() // "USB-SERIAL CH340" << "厂商:" << info.manufacturer(); } // 2. 创建并配置串口 QSerialPort *serial = new QSerialPort(this); serial->setPortName("COM3"); // Windows 用 COMx,Linux 用 /dev/ttyUSB0 // 3. 五大参数:波特率、数据位、校验、停止位、流控 —— 必须与对方一致! serial->setBaudRate(QSerialPort::Baud115200); // 波特率 serial->setDataBits(QSerialPort::Data8); // 8 位数据 serial->setParity(QSerialPort::NoParity); // 无校验 serial->setStopBits(QSerialPort::OneStop); // 1 位停止位 serial->setFlowControl(QSerialPort::NoFlowControl); // 无流控 // 4. 以读写方式打开 if (serial->open(QIODevice::ReadWrite)) { qDebug() << "串口打开成功"; } else { qDebug() << "打开失败:" << serial->errorString(); }
五大参数口诀:"波特数位停流"
收发双方的波特率、数据位、校验位、停止位必须完全一致,否则收到的全是乱码(比如想发 0x41 收到 0x82)。
流控(Flow Control)一般用 NoFlowControl;少数老设备需要硬件流控(RTS/CTS)。
收发双方的波特率、数据位、校验位、停止位必须完全一致,否则收到的全是乱码(比如想发 0x41 收到 0x82)。
流控(Flow Control)一般用 NoFlowControl;少数老设备需要硬件流控(RTS/CTS)。
9.3 异步读写:readyRead 信号
// 发送:write() 像 QFile 一样简单 QByteArray data = "Hello RS485\r\n"; serial->write(data); // 异步写入,数据进入系统发送缓冲区 // serial->flush(); // 可选:强制立即发出(一般不需要) // 接收:绝不推荐用死等(waitForReadyRead),推荐信号槽 connect(serial, &QSerialPort::readyRead, this, &MainWindow::onReadyRead); void MainWindow::onReadyRead() { // readyRead 只保证"有数据",不保证"来的是一整帧"! QByteArray chunk = serial->readAll(); m_buffer.append(chunk); // 先攒到缓冲区 // 按协议帧格式拆包(示例:以 \r\n 结尾的 ASCII 帧) while (true) { int idx = m_buffer.indexOf("\r\n"); if (idx < 0) break; QByteArray frame = m_buffer.left(idx); m_buffer.remove(0, idx + 2); processFrame(frame); // 处理完整一帧 } }
串口"粘包/断帧"是新手第一大坑
UART 是字节流,没有"消息边界"。对方发 10 个字节,readyRead 可能触发 1 次(10 字节全到),也可能触发 3 次(4+5+1 字节)。
正确做法:每次收到的数据先 append 到成员缓冲区,再按协议特征(帧头帧尾、固定长度、超时间隔)拆出完整帧。
UART 是字节流,没有"消息边界"。对方发 10 个字节,readyRead 可能触发 1 次(10 字节全到),也可能触发 3 次(4+5+1 字节)。
正确做法:每次收到的数据先 append 到成员缓冲区,再按协议特征(帧头帧尾、固定长度、超时间隔)拆出完整帧。
9.4 完整示例:温湿度传感器串口读取
// SerialReader.h #include <QObject> #include <QSerialPort> class SerialReader : public QObject { Q_OBJECT public: explicit SerialReader(QObject *parent = nullptr); bool openPort(const QString &portName); signals: void frameReceived(const QByteArray &frame); private slots: void onReadyRead(); void onError(QSerialPort::SerialPortError error); private: QSerialPort m_serial; QByteArray m_buffer; }; // SerialReader.cpp SerialReader::SerialReader(QObject *parent) : QObject(parent) { connect(&m_serial, &QSerialPort::readyRead, this, &SerialReader::onReadyRead); connect(&m_serial, &QSerialPort::errorOccurred, this, &SerialReader::onError); // 监听拔线等异常 } bool SerialReader::openPort(const QString &portName) { m_serial.setPortName(portName); m_serial.setBaudRate(QSerialPort::Baud9600); m_serial.setDataBits(QSerialPort::Data8); m_serial.setParity(QSerialPort::NoParity); m_serial.setStopBits(QSerialPort::OneStop); return m_serial.open(QIODevice::ReadWrite); } void SerialReader::onReadyRead() { m_buffer.append(m_serial.readAll()); // 协议帧:AA 55 [len] [data...] [checksum] while (m_buffer.size() >= 4) { if (static_cast<quint8>(m_buffer[0]) != 0xAA || static_cast<quint8>(m_buffer[1]) != 0x55) { m_buffer.remove(0, 1); // 丢掉非法字节,重新找帧头 continue; } quint8 len = static_cast<quint8>(m_buffer[2]); if (m_buffer.size() < 4 + len) break; // 帧还没收全,等下次 QByteArray frame = m_buffer.left(4 + len); m_buffer.remove(0, 4 + len); // 校验和验证:帧头到数据字节求和取低 8 位 quint8 sum = 0; for (int i = 0; i < 3 + len; ++i) sum += static_cast<quint8>(frame[i]); if (sum == static_cast<quint8>(frame[3 + len])) emit frameReceived(frame); // 校验通过,上抛完整帧 } } void SerialReader::onError(QSerialPort::SerialPortError error) { if (error == QSerialPort::ResourceError) { // 设备被拔出 m_serial.close(); qWarning() << "串口异常断开,尝试重连..."; } }
十、RS-485 方向控制与 Modbus 轮询实战
10.1 半双工的方向切换:DE/RE 引脚
RS-485 两线制是半双工,MAX485 类芯片用两个引脚控制收发方向(两个引脚通常短接在一起,用一个 IO 控制):
10.2 Qt 实现:用 RTS 信号自动/手动控制方向
// ============ 方式一:硬件自动方向(推荐)============ // 很多 USB-RS485 转换器和工业级 485 芯片带自动收发切换, // 程序完全不用管方向,像 RS-232 一样直接用! serial->setRequestToSend(false); // 自动方向芯片时无需任何处理 // ============ 方式二:RTS 信号控制(QSerialPort 内置支持)============ // Qt 支持用 RTS 线自动控制方向:设置后 Qt 会在写数据时自动拉高 RTS serial->setFlowControl(QSerialPort::NoFlowControl); serial->setRequestToSend(true); // RTS 信号 = DE 使能(视转换器接法) // ============ 方式三:GPIO 手动控制(嵌入式直连 MAX485 时)============ QByteArray ModbusMaster::transceive(const QByteArray &request) { // 1. 切到发送方向 m_dirGpio.setHigh(); // DE=1(Linux 下可能是 GPIO sysfs,或 RTS) m_serial.write(request); m_serial.flush(); // 等待数据全部写入驱动 if (!m_serial.waitForBytesWritten(100)) qWarning() << "发送超时"; // 2. 关键:等待最后 1.5 个字节的移位时间,再切回接收! // 波特率 9600 时一个字符约 1.04ms,这里保守等 2ms QThread::msleep(2); m_dirGpio.setLow(); // DE=0,释放总线,切回接收 // 3. 带超时等待从站应答 QByteArray response; QElapsedTimer timer; timer.start(); while (timer.elapsed() < 200) { // Modbus 应答超时一般 100~500ms if (m_serial.waitForReadyRead(50)) { response.append(m_serial.readAll()); // Modbus RTU:3.5 字符静默 = 帧结束(简化:150ms 无新数据认为收完) if (!response.isEmpty() && !m_serial.waitForReadyRead(20)) break; } } return response; }
尾字节截断:485 开发最隐蔽的坑
write() 返回 ≠ 数据已从芯片发出!数据可能还在 UART 移位寄存器里。
如果发完立即把 DE 拉低,最后一个字节还没移出去就被切断了,从站收到的 CRC 必然错误。
解决:flush + waitForBytesWritten + 延时 1~2 字符时间,再切方向。
write() 返回 ≠ 数据已从芯片发出!数据可能还在 UART 移位寄存器里。
如果发完立即把 DE 拉低,最后一个字节还没移出去就被切断了,从站收到的 CRC 必然错误。
解决:flush + waitForBytesWritten + 延时 1~2 字符时间,再切方向。
10.3 Modbus RTU 主站轮询示例
// Modbus RTU 主站:轮询 3 个从站的温湿度寄存器 class ModbusPoller : public QObject { Q_OBJECT public: void start(QSerialPort *s) { m_serial = s; pollNext(); } private: void pollNext() { quint8 addr = m_slaves[m_index]; // 当前轮询的从站地址 QByteArray req; req.append(char(addr)); // 从站地址 req.append(char(0x03)); // 功能码:读保持寄存器 req.append(char(0x00)); req.append(char(0x00)); // 起始地址 0 req.append(char(0x00)); req.append(char(0x02)); // 读 2 个寄存器(温度+湿度) quint16 crc = crc16Modbus(req); // 计算 CRC16 req.append(char(crc & 0xFF)); // CRC 低字节在前 req.append(char(crc >> 8)); m_serial->write(req); // 发送请求(自动方向转换器无需管 DE) QTimer::singleShot(200, this, &ModbusPoller::onTimeout); // 200ms 超时 } void onTimeout() { QByteArray resp = m_serial->readAll(); if (resp.size() >= 7 && checkCrc(resp)) { // 解析:温度 = 寄存器0 / 10.0,湿度 = 寄存器1 / 10.0 short tempRaw = quint8(resp[3]) << 8 | quint8(resp[4]); short humRaw = quint8(resp[5]) << 8 | quint8(resp[6]); qDebug() << "从站" << m_slaves[m_index] << "温度:" << tempRaw / 10.0 << "湿度:" << humRaw / 10.0; } else { qWarning() << "从站" << m_slaves[m_index] << "无应答"; } m_index = (m_index + 1) % m_slaves.size(); QTimer::singleShot(50, this, &ModbusPoller::pollNext); // 帧间隔后轮询下一站 } QSerialPort *m_serial = nullptr; QVector<quint8> m_slaves = {1, 2, 3}; // 三个从站地址 int m_index = 0; };
十一、原生 C++ API:Windows 与 Linux
11.1 Linux:termios
// Linux 串口 = 一个设备文件 /dev/ttyUSB0,用 termios 配置 #include <fcntl.h> #include <unistd.h> #include <termios.h> int open_serial(const char *dev) { int fd = open(dev, O_RDWR | O_NOCTTY); // 串口就是文件! struct termios tio{}; tcgetattr(fd, &tio); cfsetispeed(&tio, B115200); // 输入波特率 cfsetospeed(&tio, B115200); // 输出波特率 tio.c_cflag &= ~PARENB; // 无校验 tio.c_cflag &= ~CSTOPB; // 1 位停止位 tio.c_cflag &= ~CSIZE; tio.c_cflag |= CS8; // 8 位数据 tio.c_cflag |= CLOCAL | CREAD; // 忽略调制解调器线,启用接收 tio.c_lflag &= ~(ICANON | ECHO | ISIG); // 原始模式(非行编辑) tio.c_iflag &= ~(IXON | IXOFF); // 关闭软件流控 tio.c_cc[VMIN] = 0; // read 不阻塞等最小字节数 tio.c_cc[VTIME] = 5; // 0.5 秒超时 tcsetattr(fd, TCSANOW, &tio); return fd; } // 之后 read(fd, buf, n) / write(fd, buf, n) 与普通文件一致
11.2 Windows:Win32 API
// Windows 串口 = 一个设备 \\.\COM3,用 CreateFile 打开 #include <windows.h> HANDLE open_serial(const wchar_t *port) { // L"\\\\.\\COM3" HANDLE h = CreateFileW(port, GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr); DCB dcb{}; dcb.DCBlength = sizeof(dcb); GetCommState(h, &dcb); dcb.BaudRate = CBR_115200; // 波特率 dcb.ByteSize = 8; // 数据位 dcb.Parity = NOPARITY; // 无校验 dcb.StopBits = ONESTOPBIT; // 1 位停止位 SetCommState(h, &dcb); COMMTIMEOUTS to{}; to.ReadTotalTimeoutConstant = 500; // 读超时 500ms SetCommTimeouts(h, &to); return h; } // 之后 ReadFile(h,...) / WriteFile(h,...)
为什么不直接用原生 API,而用 Qt?
· termios / Win32 API 两套代码完全不兼容,跨平台要写两份
· QSerialPort 把两者封装成统一接口,且与 Qt 事件循环天然集成(信号槽异步通知)
· 建议:Qt 项目用 QSerialPort;无 Qt 依赖的嵌入式底层程序用 termios
· termios / Win32 API 两套代码完全不兼容,跨平台要写两份
· QSerialPort 把两者封装成统一接口,且与 Qt 事件循环天然集成(信号槽异步通知)
· 建议:Qt 项目用 QSerialPort;无 Qt 依赖的嵌入式底层程序用 termios
十二、应用场景与选型建议
选型决策树
① 需要挂多个设备(>2 台)?→ RS-485(RS-232 无法组网)
② 距离超过 15 米?→ RS-485
③ 现场有大功率电机/变频器干扰?→ RS-485(差分抗扰)
④ 只是短距离点对点调试,或设备只有 232 口?→ RS-232(更简单)
⑤ 需要同时双向大数据量(如固件下载)?→ RS-232 或 RS-485 四线制
① 需要挂多个设备(>2 台)?→ RS-485(RS-232 无法组网)
② 距离超过 15 米?→ RS-485
③ 现场有大功率电机/变频器干扰?→ RS-485(差分抗扰)
④ 只是短距离点对点调试,或设备只有 232 口?→ RS-232(更简单)
⑤ 需要同时双向大数据量(如固件下载)?→ RS-232 或 RS-485 四线制
十三、调试技巧与常见问题排查
13.1 常见故障排查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 完全收不到数据 | TX/RX 没交叉;A/B 接反;端口/波特率配错 | 交换 TX/RX 或 A/B;核对参数 |
| 收到乱码 | 波特率/校验/停止位不匹配;地线未接 | 统一"波特数位停"四参数;共地 |
| 偶尔丢字节 | 波特率高+长线无终端电阻;软件处理不及时 | 加 120Ω;改中断/线程收包 |
| 485 总线时通时断 | 多设备同时发送(冲突);终端电阻过多 | 严格主从轮询;两端各一个电阻 |
| 尾字节 CRC 错误 | DE 切回太早,尾字节被截断 | 发完后延时 1~2 字符时间再切方向 |
| USB 转 485 时好时坏 | 劣质转换器无隔离,地电位差灌电流 | 换带光耦隔离的转换器 |
| 雷雨天后设备损坏 | 线缆感应雷击浪涌 | 加 TVS 管 + 隔离芯片(ADM2483) |
13.2 调试工具链
// 1. 万用表快速判 485 空闲/活动:测 A-B 间电压 // 空闲态:差压通常 -0.2V ~ +0.2V 附近(可能悬浮) // 有设备在发:差压在 ±1.5V ~ ±5V 之间跳变 // 2. 双串口自环测试(验证本机串口收发):TX 接 RX serial->write("LOOPBACK"); // 收到 "LOOPBACK" → 本机串口栈正常,问题在对端/线缆 // 3. 用两个 USB 转 TTL 模块对接,模拟两端设备联调 // 4. 抓包工具:串口监听(Windows: Serial Port Monitor / Device Monitoring Studio) // 5. Modbus 调试:Modbus Poll / Modbus Slave(模拟主站/从站)
485 调试三板斧(工程师经验总结)
① 对调 A/B:收不到先换线序,一半问题在这一步解决
② 共地:485 两线制也建议拉一根 GND 参考线,消除地电位差
③ 降速:10kbps 都不通就不是速率问题,通 115200 丢包就加终端电阻/换双绞线
① 对调 A/B:收不到先换线序,一半问题在这一步解决
② 共地:485 两线制也建议拉一根 GND 参考线,消除地电位差
③ 降速:10kbps 都不通就不是速率问题,通 115200 丢包就加终端电阻/换双绞线
十四、总结
本文知识全景
- 串口基础:串行 vs 并行、单工/半双工/全双工、同步 vs 异步
- RS-232:负逻辑电平(±3~15V)、DB9 引脚(TXD/RXD/GND)、全双工点对点、15 米
- UART 帧格式:起始位 + 数据位 + 校验位 + 停止位;波特率约定
- RS-485:差分信号(A−B ≥ ±200mV)、双绞线、1200 米、32~256 节点总线
- 差分抗扰原理:共模噪声在 A−B 相减中抵消;双绞保证噪声等量耦合
- 总线多机:半双工 + 主从轮询;终端电阻 120Ω 只接两端
- 分层结构:UART(帧)→ 电气层(RS-232/485)→ 协议层(Modbus)
- Qt 编程:QSerialPort = QIODevice;readyRead + 缓冲区拆帧;RTS 方向控制
- 原生 API:Linux termios、Windows CreateFile + DCB
- 调试经验:对调 A/B、必须共地、尾字节延时、隔离防雷
最后的话
RS-232 和 RS-485 是嵌入式工程师的"母语":看似过时,却支撑着全球数以亿计的工业设备通信。
掌握它们的电平原理、帧格式、差分抗扰与 主从轮询,再去学 CAN、RS-422、Profibus 等其他总线,你会发现思想一脉相承。
动手建议:买两个 USB-RS485 转换器(十元一个),用本 Qt 示例代码在自己电脑上搭一条 485 总线联调,比读十遍文章更有效。
RS-232 和 RS-485 是嵌入式工程师的"母语":看似过时,却支撑着全球数以亿计的工业设备通信。
掌握它们的电平原理、帧格式、差分抗扰与 主从轮询,再去学 CAN、RS-422、Profibus 等其他总线,你会发现思想一脉相承。
动手建议:买两个 USB-RS485 转换器(十元一个),用本 Qt 示例代码在自己电脑上搭一条 485 总线联调,比读十遍文章更有效。