← 返回博客列表

一、什么是串口通信(零基础入门)

1.1 串行 vs 并行:一条路还是八条路

设备之间要传数据,本质是把一个个字节(8 个二进制位)从 A 搬到 B。搬运方式有两种:

并行传输 vs 串行传输 并行传输(Parallel) 发送方 接收方 8 条数据线同时传 1 个字节 快,但线多、成本高、易互相干扰 典型:老式打印机并口、内存总线 长距离时 8 条线延迟不一致 → 数据错乱 串行传输(Serial) 发送方 接收方 1 条数据线,一位一位依次传 慢一点,但线少、成本低、抗干扰好 典型:RS-232、RS-485、USB、以太网 线少 → 距离可以做得很远
并行像八车道同时过 8 辆车;串行像单车道排队一辆辆过
通俗比喻
并行传输 = 8 个人并排站成一排,每人拿一个字母,同时喊出来,一次喊出 8 个字母的单词。
串行传输 = 1 个人按顺序一个字母一个字母地喊,虽然慢,但只要有 1 张嘴(1 根线)就行,而且喊得可以很快。

1.2 单工、半双工、全双工

三种通信方向模式 单工(Simplex) A B 只能单向传输 典型:广播、遥控器 半双工(Half-Duplex) A B 可以双向,但不能同时 典型:对讲机、RS-485 两线制 全双工(Full-Duplex) A B 双向同时传输 典型:电话、RS-232、RS-485 四线制
单工(单向)、半双工(双向轮流)、全双工(双向同时)
记忆口诀
· 单工 = 单行道,只进不出
· 半双工 = 对讲机,"over" 之后对方才能说
· 全双工 = 打电话,可以边听边说

1.3 同步 vs 异步

接收方怎么知道每一位数据从什么时候开始?两种办法:

本文的主角
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 至今仍活跃在:

2.2 电气特性:负逻辑的"怪脾气"

RS-232 最大的特点有两个:电压高和负逻辑。

RS-232 电平定义(负逻辑) 0V +3V ~ +15V → 逻辑 0 -15V ~ -3V → 逻辑 1 正电压 = 逻辑 0(空号 Space) 负电压 = 逻辑 1(传号 Mark) -3V ~ +3V 之间是过渡区(无效电平) 注意:与 TTL 相反!TTL 高电平(5V)=1,RS-232 负电压=1,且线上空闲时保持负电压(常1)
RS-232 用正负电压表示逻辑,且是负逻辑:负电压才是 1
为什么电压这么高?
±3V 以上的摆幅是为了抗干扰:信号传了几十米后会被噪声淹没,普通 5V/3.3V 的 TTL 电平余量太小,而 ±12V 的大摆幅即使衰减和叠加噪声后,接收端仍能明确判断是 0 还是 1。
代价:不能直接连 MCU 引脚!必须经过电平转换芯片(如 MAX3232),否则烧片子。

2.3 接口与引脚:DB9 连接器

最常见的 RS-232 接口是 DB9(9 针 D 型接口)。老台式机后面的"串口"就是它。

DB9 引脚定义(以公头针脚为基准) 1 2 3 4 5 6 7 8 9 DB9 公头(正面视图) 常用 3 线(最小系统): ② TXD — 发送数据(本方→对方) ③ RXD — 接收数据(对方→本方) ⑤ GND — 信号地(必须共地!) 握手/流控(可不用): ⑦ RTS 请求发送 / ⑧ CTS 允许发送 ① DCD / ④ DTR / ⑥ DSR / ⑨ RI 实际项目 90% 只接 2、3、5 三根线
DB9 引脚:最小系统只需 TXD、RXD、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 和 RS-485 共用同一套 UART 帧格式。一个字节不是裸着上线,而是被包装成一帧:

UART 异步帧格式(以 8N1 + 偶校验为例) 时间→ 起始位 = 0 D0 D1 D2 D3 D4 D5 D6 D7 校验位 停止位 = 1 拉低线表示开始 唤醒接收方 8 个数据位 低位(LSB)先发 可选:奇/偶校验 拉高线,至少 1 位 空闲时线保持高 常用参数组合「8N1」: 8 = 8 个数据位 N = 无校验(None) 1 = 1 位停止位 双方必须配置一致!
一帧 = 起始位 + 数据位(5~9位) + 校验位(可选) + 停止位

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 毫秒——串口不快,够用就好。
发送字符 'A'(0x41 = 01000001B)的实际波形 起始位(0) D0=1 D1=0 D2=0 D3=0 D4=0 D5=0 D6=1 停止位(1) 空闲(线保持高) 低位先发:1 → 0 → 0 → 0 → 0 → 0 → 0 → 1,正好是 0x41 的二进制反序
字符 'A' (0x41) 在示波器上的真实样子:起始位拉低 → 数据位 → 停止位拉高

四、RS-485 协议详解

4.1 概念:为工业而生

RS-485(TIA/EIA-485)是 1983 年发布的差分串行通信标准,专门解决 RS-232 的三大痛点:距离短、速率低、不能多机组网。

RS-485 相比 RS-232 的三大升级 ① 传得更远 15 米 → 1200 米 低速率下甚至可达 数公里 差分信号 + 双绞线 ② 传得更快 115200 → 10 Mbps 距离与速率成反比: 10Mbps@12m, 100kbps@1200m 速率越快,可用距离越短 ③ 挂得更多 1 对 1 → 32~256 节点 标准驱动器带 32 个节点 1/8 负载芯片可达 256 个 总线式拓扑,一主多从
RS-485 三大升级:更远、更快、能组网

4.2 电气特性:A 线与 B 线

RS-485 用两根线(通常称为 A 和 B)传输一路差分信号:

指标参数说明
信号线A(正相/D+)、B(负相/D-)双绞线,推荐带屏蔽层
逻辑 1(Mark)V(A) - V(B) ≥ +200mVA 比 B 高至少 200mV
逻辑 0(Space)V(A) - V(B) ≤ -200mVA 比 B 低至少 200mV
共模电压范围-7V ~ +12V两线相对地的电压允许波动范围
摆幅±1.5V ~ ±5V比 RS-232(±12V)小得多
A/B 线没有全球统一的命名!
有的芯片标 A=正相,有的标 A=负相;不同厂家设备对接时若"同相相接"反而正确。
实用口诀:先随便接,收不到数据就把 A、B 对调——这是 485 调试第一定律。

五、差分信号:RS-485 抗干扰的秘密武器

5.1 单端 vs 差分:测电压的方式不同

单端信号 vs 差分信号:噪声是"叠加"还是"抵消" 单端信号(RS-232) 0V 信号只有 1 根线,以地为参考 噪声(橙色)直接叠加在信号上 → 接收端无法区分信号和噪声 红色实线 = 发送的理想信号 深红 = 叠加噪声后的波形 差分信号(RS-485) A 线 B 线 两根线电压相反,测的是 A−B 的差 噪声(橙色)同时叠加到两根线上 → 相减后噪声抵消,只留下真实信号 绿 = A 线,蓝 = B 线(互为镜像) A−B 恒为 ±固定值,噪声被减掉了
差分传输的核心:有用信号 = A−B,噪声是共模的,相减抵消
数学上为什么噪声会被抵消?
设发送端让 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 为什么用双绞线

双绞线:让两根线平均分摊干扰 A 线(绿) B 线(蓝) 绞合后任意时刻: 两根线到干扰源的距离几乎相等 → 耦合的噪声几乎相同(共模) → 差分接收端将噪声彻底抵消 工业现场推荐:带屏蔽层的双绞线(如 RVSP 2×0.5),屏蔽层单端接地
双绞保证两线噪声一致(共模),差分接收才能将其抵消

六、RS-485 总线拓扑与多机通信

6.1 半双工两线制与全双工四线制

RS-485 两线制(半双工)与四线制(全双工) 两线制(2-Wire)— 最常用 主站 Host 从站1 从站2 从站N A 线 B 线 GND(可选,强烈建议接) 所有设备挂同一对线,同一时刻只能一方说 半双工:需要方向控制(DE/RE 引脚) 四线制(4-Wire,RS-422 兼容) 主站 从站1 从站2 主站TX+ → 从站RX+ 主站TX- → 从站RX- 收发各一对线,物理上全双工 无需方向切换,适合高速持续通信 实际是 RS-422 标准,RS-485 芯片大多兼容
两线制半双工(一主多从轮询)与四线制全双工(主从专用通道)

6.2 一主多从:谁先说话?

半双工总线上所有设备共用一对线,两个设备同时发送会导致数据碰撞(总线冲突)。工业上普遍采用主从轮询(Master-Slave Polling):

主从轮询时序(Modbus RTU 经典模式) 主站 呼叫从站1 等待 呼叫从站2 等待 呼叫从站3… 从站1 (静默) 应答1 从站2 应答2 从站3 一直静默,直到被点名才发言 规则:主站点名 → 指定从站应答 → 其他从站闭嘴 → 循环下一站 每个从站有唯一地址(1~247),帧里带地址,广播地址 0 = 所有从站
主从轮询:主站点名,被点名的从站应答,其余保持静默
为什么 RS-485 天生适合 Modbus?
Modbus RTU 协议的主从架构(一主多从、地址寻址、请求-应答)与 RS-485 的总线半双工特性完全契合:
· 主站 = 轮询发起者(SCADA、组态软件、PLC)
· 从站 = 传感器/仪表/驱动器(地址 1~247)
· 9600/19200 bps 的速率对"读几个寄存器"绰绰有余
这就是为什么 90% 的 Modbus 设备都跑在 RS-485 上。

6.3 终端电阻:消除信号反射

120Ω 终端电阻:吸收反射波 主站 从站1 从站2 从站N A B 120Ω 120Ω 仅在总线两端各接一个 120Ω 电阻(A-B 之间),中间节点不接 不接终端电阻:信号到达线缆末端反射回来,与原信号叠加 → 波形毛刺 → 误码 有反射的波形(高频/长线时明显)↑
终端电阻匹配线缆特性阻抗(双绞线约 120Ω),吸收能量防止反射
终端电阻使用经验
· 短线(<10 米)、低速(9600bps)时,通常不接也能正常通信
· 长线 + 高速时必须接,否则误码率飙升
· 数量:只在两端各 1 个。接多了会加重驱动器负载,通信反而变差
· 判断线缆特征阻抗:RVVP 屏蔽双绞线典型 100~120Ω,接 120Ω 即可

七、UART:软件层与电气层的分界线

7.1 三层结构:一个字节从 MCU 到线缆的旅程

UART + 电平转换芯片 + 线缆:完整信号链路 MCU / CPU UART 外设 TX = 3.3V/5V (TTL) → MAX3232 电平转换芯片 TTL ↔ ±12V (RS-232) → DB9 / RS-232 线缆 ±3V ~ ±15V 负逻辑 15 米内,点对点 ──── RS-232 链路 ──── MCU / CPU UART 外设 TX = 3.3V/5V (TTL) → MAX485 差分收发芯片 TTL ↔ 差分 (A/B 线) → 双绞线 / RS-485 总线 差分 ±200mV 阈值判断 1200 米内,32~256 节点 ──── RS-485 链路 ────
UART 产生 TTL 电平的帧数据 → 转换芯片翻译成不同电气标准 → 上线传输
三层分工
· 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 口上——一接就烧。

八、RS-232 vs RS-485 全面对比

对比项RS-232RS-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、SP3232MAX485、SP3485、SN65HVD75
是否需要方向控制不需要(全双工)2 线制需要(DE/RE 引脚)
距离-速率关系:速率越高,可用距离越短 0 1200m 10Mbps 0 10 Mbps @ 12 米 1 Mbps @ 120 米 100 kbps @ 1200 米 RS-232 上限 115200bps(≤15m) 选型经验:短距离低速率用哪个都行;远距离 / 多设备 / 强干扰 → 必须 RS-485
RS-485 速率与距离的跷跷板关系(RS-232 只能工作在左下角小区域)

九、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 如出一辙
QSerialPort 类结构:串口即文件(QIODevice) QIODevice read / write / open / close QSerialPort setBaudRate / setDataBits / setStopBit setParity / setFlowControl QSerialPortInfo availablePorts() 枚举串口 readyRead 信号 数据到达即触发 异步非阻塞读
QSerialPort 继承自 QIODevice,像操作文件一样操作串口

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)。

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 到成员缓冲区,再按协议特征(帧头帧尾、固定长度、超时间隔)拆出完整帧。

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 控制):

MAX485 收发方向控制(DE/RE) MCU TX ──────┐ RX ──────┤ GPIO(方向脚)──┐ GPIO=1 → 发送模式 GPIO=0 → 接收模式 MAX485 DI(数据入)← TX RO(数据出)→ RX DE=1 RE=0 → 发送 DE=0 RE=1 → 接收 (DE/RE 短接用 1 个 IO) A B 发送前拉高 DE,发完最后一个字节后延时 1~2 字符时间再拉低,否则尾字节被截断!
GPIO 控制 DE/RE:发送时拉高,接收时拉低

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 字符时间,再切方向。

10.3 Modbus RTU 主站轮询示例

Modbus RTU 读保持寄存器:请求帧与应答帧结构 主站请求(8 字节) 地址 0x01 功能码0x03 读保持 起始地址0x0000 寄存器数0x0002 CRC16 从站应答(9 字节) 地址 0x01 功能码 0x03 字节数 0x04 数据 4B CRC16 一次轮询流程: ① 主站发 8 字节请求 ② 等待应答(超时 200ms) ③ 收到 9 字节应答 → 校验 CRC ④ 解析数据 → 轮询下一个从站 ⑤ 超时无应答 → 记录故障 → 继续 CRC16-MODBUS:每帧都带,接收方校验失败则丢弃(从站不会应答错误帧)
Modbus RTU 帧结构:地址 + 功能码 + 数据 + CRC16
// 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

十二、应用场景与选型建议

典型应用场景 RS-232 适用场景 · 设备调试控制台(Console 口) · GPS 模块、GPRS 模块输出 · 示波器 / 万用表等仪器控制 · 老式 PLC / 变频器编程口 · 电子秤、条码枪等单机外设 特点:点对点、距离近(≤15m)、 全双工、只求简单可靠 接口:DB9 或 3.5mm 排针 RS-485 适用场景 · Modbus RTU 工业仪表网络 · 楼宇自控(门禁/抄表/空调) · 变频器群控、伺服驱动器总线 · 太阳能逆变器监控 · 安防云台(Pelco-D/P 协议) 特点:一主多从、距离远(≤1200m)、 抗强干扰(变频器/电机现场) 接口:接线端子 A/B/GND
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 丢包就加终端电阻/换双绞线

十四、总结

本文知识全景

  1. 串口基础:串行 vs 并行、单工/半双工/全双工、同步 vs 异步
  2. RS-232:负逻辑电平(±3~15V)、DB9 引脚(TXD/RXD/GND)、全双工点对点、15 米
  3. UART 帧格式:起始位 + 数据位 + 校验位 + 停止位;波特率约定
  4. RS-485:差分信号(A−B ≥ ±200mV)、双绞线、1200 米、32~256 节点总线
  5. 差分抗扰原理:共模噪声在 A−B 相减中抵消;双绞保证噪声等量耦合
  6. 总线多机:半双工 + 主从轮询;终端电阻 120Ω 只接两端
  7. 分层结构:UART(帧)→ 电气层(RS-232/485)→ 协议层(Modbus)
  8. Qt 编程:QSerialPort = QIODevice;readyRead + 缓冲区拆帧;RTS 方向控制
  9. 原生 API:Linux termios、Windows CreateFile + DCB
  10. 调试经验:对调 A/B、必须共地、尾字节延时、隔离防雷
一张图记住 RS-232 与 RS-485 RS-232 点对点 · 全双工 · 15 米 · ±12V 负逻辑 A B RS-485 总线 · 半双工 · 1200 米 · 差分抗扰 · 一主多从 主站 从1 从2 从N 同为 UART 帧格式,只是"嗓子"(电气标准)不同 —— 软件层面几乎无差别
RS-232 与 RS-485 一图速记
最后的话
RS-232 和 RS-485 是嵌入式工程师的"母语":看似过时,却支撑着全球数以亿计的工业设备通信。
掌握它们的电平原理、帧格式、差分抗扰与 主从轮询,再去学 CAN、RS-422、Profibus 等其他总线,你会发现思想一脉相承。
动手建议:买两个 USB-RS485 转换器(十元一个),用本 Qt 示例代码在自己电脑上搭一条 485 总线联调,比读十遍文章更有效。