
简介串口通信程序是一套面向工业自动化、嵌入式开发初学者的RS-232串行通信实例定位于帮助读者理解并实现上位机与PLC、单片机等下位机之间的双向数据交换。程序由作者自行编写并经实际项目验证可直接用于短距离设备通信的调试与二次开发。资源包共22个文件约137KB以C工程源码为主体包含6个h头文件、4个cpp源文件以及sln解决方案、vcxproj项目配置、rc资源脚本和ReadMe说明等分别承载串口参数设置、数据收发、界面逻辑与工程编译配置结构完整便于对照学习。目前已有592人学习下载。读者可从中获取打开串口、波特率与校验位配置、中断方式接收数据、错误处理和关闭串口等关键环节的实现思路并参考上下位机通信的工程组织方式快速搭建自己的串行通信调试环境提升设备联调与定制化交互的效率。1. 串口通信程序值不值得单独拆一版RS232 接 PLC 前先想清楚三件事很多人第一次接 PLC都会以为串口通信程序无非就是打开端口、把字节丢出去、再读回来。真到现场才发现同一段代码在实验室跑得好好的接到下位机的 RS232 口上就开始丢帧、错位、校验失败甚至跑一整夜之后直接卡死在读操作上。串口通信程序真正要解决的不是“能不能发出去”而是“对方凭什么认定这一帧完整属于你以及一帧没收全的时候怎么办”。这套资源把上位机与下位机之间的 RS232 链路拆成几层端口参数对齐、帧结构编码、CRC 校验、超时重试、轮询调度每一层都给了能直接运行的示例。适合正在做设备联调的上位机开发也适合刚接手一套老 PLC 系统、手上只有一根串口线和一份寄存器表的人。2. 把链路先跑通虚拟串口对、六个必设参数与最小收发闭环在动手写业务逻辑之前最省时间的做法是把“能通”这件事单独验证掉。这里的目标很具体不接任何真实设备用一对虚拟串口在两分钟内确认上位机代码的打开、写、读、关闭四个动作都没有问题把物理层的变量彻底隔离开。2.1 物理层先对齐DB9 三线制与上电前的 30 秒自检RS232 用的是负逻辑电平空闲状态下 TXD 相对 GND 是负压一般在 -3V 到 -15V 之间。这个特性给了一个非常好用的快速判据万用表直流档黑表笔接 5 脚GND红表笔点 3 脚TXD空闲时读数应该是负值读到正压或者接近 0基本可以断定线接反了、没共地或者对方根本没上电。九针口里真正要用的其实只有三根这也是所谓的“三线制”最小系统引脚信号相对上位机的方向接法2RXD输入接对端 TXD交叉3TXD输出接对端 RXD交叉5GND—必须共地不能省7RTS输出三线制下悬空8CTS输入三线制下悬空或本地短接 7 脚新手最容易翻车的一步是把 2 脚对 2 脚、3 脚对 3 脚直连。串口是交叉连接本端的发送必须落到对端的接收上。共地这一条也别嫌啰嗦两根设备各自接不同的电源时不共地会出现“能收到但每个字节都偏移”的玄学现象查半天查不出所以然。提示接线前先把两端都断电。带电插拔 DB9 接头烧掉过不少转换芯片这类损坏往往表现为“偶尔能通、过一会儿断”极难排查。2.2 没有 PLC 怎么开发一对虚拟串口把链路先搭起来设备还没到位、或者 PLC 被别的同事占着调试是常态。这时候不要干等用一对虚拟串口先把代码跑起来。常见做法是找一款能创建 COM 口对的驱动工具装完之后设备管理器里会多出两个互连的串口比如 COM11 和 COM12往 COM11 写的数据会原样从 COM12 出来反过来也一样。这对口的价值在于一端跑你的上位机程序另一端写一个几十行的“假下位机”脚本按真实协议回帧。这样协议层的边界条件回错功能码、回半帧、回延迟 800ms你都能人为造出来比接真设备还方便。真实设备是不会配合你复现偶发故障的。先把端口枚举跑通确认程序看到的是哪个口import serial from serial.tools import list_ports # 列出系统里所有串口及其硬件标识 for p in sorted(list_ports.comports()): print(f{p.device:8s} {p.description:30s} {p.hwid})这段代码的关键在hwid这一列。带VID:PID的是 USB 转串口线说明是外接设备带BTHENUM的是蓝牙虚拟串口通常不是你想要的两个相邻编号、hwid里带虚拟串口驱动标识的就是上一步建的口对。上位机程序里硬编码COM3是后面“换台电脑就不通”的头号元凶正确做法是按hwid里的 VID/PID 匹配匹配不到再退回配置文件指定的端口号。2.3 最小收发闭环pyserial 的六个必设参数下面是能直接抄的最小骨架。重点不在代码多少而在参数为什么这么取import time import serial PORT COM11 # 上位机侧实际按 hwid 匹配后填入 BAUD 9600 ser serial.Serial( portPORT, baudrateBAUD, bytesizeserial.EIGHTBITS, # 数据位 8PLC 侧几乎都是 8 parityserial.PARITY_NONE, # 无校验必须与下位机一致 stopbitsserial.STOPBITS_ONE, # 停止位 1 timeout0.2, # 单次 read 的阻塞上限绝不设 None write_timeout0.5, # 写阻塞上限防止缓冲区满时死等 xonxoffFalse, # 软件流控关掉 rtsctsFalse, # 硬件流控关掉三线制下必须关 ) # 打开端口的瞬间线上可能残留上电噪声或对方主动发的数据 ser.reset_input_buffer() ser.reset_output_buffer()timeout这个参数是整段代码里最容易误解的一个。它约束的是单次 read 调用不是“整帧超时”。设成 0.2 意味着ser.read(8)最多等 200ms收到 3 个字节就返回 3 个字节。所以真正读一帧必须在外层自己加截止时间def send_and_read(ser, frame: bytes, expect_len: int, total_timeout: float 1.0) - bytes: 发一帧按期望长度收一帧收不满就返回已收到的部分由调用方判定 ser.reset_input_buffer() # 发之前清空避免上一次的残帧混进来 ser.write(frame) ser.flush() # 等待数据真正写入驱动不是写进 Python 缓冲就完事 buf bytearray() deadline time.monotonic() total_timeout while len(buf) expect_len and time.monotonic() deadline: chunk ser.read(expect_len - len(buf)) if chunk: buf.extend(chunk) return bytes(buf)逻辑上有两个细节值得单独说。一是flush()pyserial 的write会先放进操作系统缓冲不 flush 的话你无法确定数据什么时候真正上了线排查“发送延迟”类问题时会误导判断。二是先清空再发送很多现场故障其实是上一轮的残帧被当成这一轮的应答表现出来就是“帧头对不上”而实际上是时序错位。2.4 参数表对齐波特率、校验位、流控三处最常见的错配把上位机和下位机的参数逐项对一遍是联调第一天就该做完的事。下面这张表可以直接拿去和 PLC 侧的通信参数配置页逐行打勾参数上位机常见取值错配后的典型现象备注波特率9600 / 19200 / 38400 / 115200全是乱码或整帧只收到 0x00、0xFF两端必须完全相同数据位8少数老设备用 7 位字节高位被吃掉8 位是默认值校验位None / Even / Odd校验位错配会导致其后所有字节整体错位一位最隐蔽的一类停止位1少数用 2每帧尾部多出或少掉半个位时间与校验位一起看流控全部关闭开启 RTS/CTS 后程序卡在写操作三线制必关校验位那一行值得展开如果 PLC 侧配的是 8E1偶校验上位机发 8N1两边的位边界就对不上接收端会把校验位当成数据位的一部分读进去于是从第二个字节开始全错位。表现出来不是“校验失败”而是“数据能收到但全是歪的”很容易被误判成协议解析写错了。3. 帧协议是对齐的前提RTU 帧结构、CRC16 与半包处理链路通了只是解决了“字节能过去”接下来才是真正花时间的部分让双方对“什么算一帧”达成一致。串口是字节流没有消息边界边界必须靠协议自己定义。工业现场最常见的是 Modbus RTU 风格地址 功能码 数据 CRC长度靠功能码推算。3.1 一帧 RTU 报文长什么样以读保持寄存器为例从站地址 0x01、功能码 0x03、起始地址 0x0000、读 2 个寄存器请求帧是 8 个字节字段字节数示例值说明从站地址10x01总线上多台设备时靠它区分功能码10x03读保持寄存器起始地址高字节10x00大端序高位在前起始地址低字节10x00寄存器数量高字节10x00寄存器数量低字节10x02读 2 个CRC 低字节10xC4注意低字节在前CRC 高字节10x0B应答帧是01 03 04 xx xx xx xx CRC CRC比请求多了 1 个字节计数字段数据区长度为寄存器数乘 2。这个“应答长度可以由请求推算出来”的性质非常关键——上位机不依赖字符间隔判帧而是直接算出该收多少字节收不满就判超时。这比依赖 3.5 字符间隔要可靠得多因为 USB 转串口芯片的批量传输会把字符间隔压扁靠间隔判帧在 PC 上基本不可行。注意请求和应答里所有多字节数值都是大端序高字节在前唯独 CRC 是低字节在前。这两个规则方向相反是协议层最常见的笔误来源。3.2 CRC16逐位法与查表法以及为什么不能随手改移位方向CRC 算错会表现为“帧看起来完全正常但下位机就是不回”。现场用 Modbus 的 CRC-16多项式 0xA001这是 0x8005 反转后的形式初值 0xFFFFdef crc16_modbus(data: bytes) - int: 逐位法便于理解生产环境建议换查表法速度快一个数量级 crc 0xFFFF for b in data: crc ^ b for _ in range(8): if crc 0x0001: # 最低位为 1 才异或多项式 crc (crc 1) ^ 0xA001 else: crc 1 return crc def build_frame(payload: bytes) - bytes: 给裸数据补上 CRC注意低字节在前 crc crc16_modbus(payload) return payload bytes([crc 0xFF, (crc 8) 0xFF]) def check_frame(frame: bytes) - bool: 校验整帧把含 CRC 的完整帧再算一遍结果为 0 才算通过 return crc16_modbus(frame) 0这里有个漂亮的性质值得记住check_frame不需要把 CRC 拆出来单独比对把包含 CRC 的整帧再算一次 CRC正确的话结果恒为 0。这个特性省掉了一次字节拆解也避免了“我到底算到第几个字节”的困惑。最常见的两个错误一是把 0xA001 写成 0x8005。0x8005 是未反转的多项式配合左移crc 1使用0xA001 是它的镜像必须配右移。两者混用算出来的值看着也是 4 位十六进制但完全对不上。二是把 CRC 也算进计算范围算出来的永远是错的。写完之后立刻拿一个已知向量验证01 03 00 00 00 02的 CRC 应该是C4 0B这条不通就别往下写。3.3 半包、粘包与“收到一半就返回”的超时设计串口的本质是字节流ser.read(n)完全可能只返回一半。指望一次 read 拿到完整帧是新手最普遍的假设错误。正确的做法是先算出这一帧该有多长然后循环收def expected_len(func: int, qty: int) - int: 根据请求的功能码和寄存器数量推算应答帧的完整长度 if func in (0x01, 0x02): # 读线圈/离散量按位打包 data_len (qty 7) // 8 return 1 1 1 data_len 2 if func in (0x03, 0x04): # 读保持/输入寄存器按字打包 return 1 1 1 qty * 2 2 if func in (0x05, 0x06, 0x0F, 0x10): # 写单点/写多点长度固定或按请求算 return 8 raise ValueError(f未覆盖的功能码: {func:#04x})有了期望长度收帧逻辑就变成“凑够就返回、凑不够就超时”而不是“读到什么算什么”。粘包的处理方式也同样简单如果缓冲里剩余字节数大于等于下一帧期望长度就把多出来的部分留着作为下一帧的开头继续解析千万不要每次读之前都reset_input_buffer()那会把下一帧的头部一起清掉表现出来的就是“每隔一次请求失败一次”。这个错位规律非常有辨识度见到就基本能定位。期望长度算错还有一个隐蔽后果如果实际应答比预期长比如 PLC 固件在应答里多塞了字节你会把多出来的字节当成下一帧的开头从此整个会话永久错位直到重启程序。这也是下面第 5 章要专门讲的原因。3.4 用单元测试把协议层冻结改代码时不至于推倒重来协议层是整个程序里最不该“凭感觉改”的部分。常见做法是给 CRC 和帧构造函数写一组参数化测试把这些已知向量固化下来任何人改动都要先过这一关用例输入不含 CRC期望 CRC用途读 2 个保持寄存器01 03 00 00 00 02C4 0B校验常规读请求写单个寄存器01 06 00 01 00 0398 0B校验写请求空数据(空)FF FF校验初值处理单字节0180 80校验移位方向这几个用例跑通CRC 实现基本就不会错了。写测试的成本远低于在现场用示波器抓波形。4. 上位机与 PLC 的交互模型轮询队列、状态机与断线重连协议层正确程序仍然可能在跑上十几分钟后崩掉。原因通常不在协议而在并发模型界面上有三个地方同时要读数据后台还有一个定时器在轮询三股请求挤同一个串口帧和帧在时间上叠在了一起下位机收到的就是一团乱码。4.1 为什么“点一次按钮发一帧”的写法撑不过十分钟串口的语义是独占的一次写进去的字节会连续上线中间不会给别人让路。所以只要有两个线程同时调ser.write两帧的数据就会交织在一起对方看到的是两个合法帧拼成的非法帧。现场表现就是“单点测试都正常一开自动运行就错”。解决办法是单写者模型整个进程只允许一个线程碰ser其他所有地方都只往队列里投递请求。这条路走通了后面加多少功能都不会破坏链路。4.2 轮询队列给读请求排优先级别让慢请求饿死快请求请求分优先级是必要的。急停状态、报警字这些要高频读累计产量、设备型号这类可以低频读。下面是一个可用的骨架import queue import threading import time class SerialWorker(threading.Thread): def __init__(self, ser, on_frame): super().__init__(daemonTrue) self.ser ser self.on_frame on_frame # 回调收到完整帧后交给上层 self._q queue.PriorityQueue() self._seq 0 # 自增序号保证同优先级 FIFO self._lock threading.Lock() def submit(self, level: int, payload: bytes, expect: int): with self._lock: self._seq 1 self._q.put((level, self._seq, payload, expect)) def run(self): while True: level, _, payload, expect self._q.get() try: frame build_frame(payload) resp send_and_read(self.ser, frame, expect) self.on_frame(level, payload, resp) except Exception as exc: self.on_frame(level, payload, exc) # 失败也要回传别静默吞掉 # 轮询不是越快越好留出帧间隔给下位机处理 time.sleep(0.02)逻辑上有三个点值得说清。level数字越小优先级越高急停类请求用 0常规轮询用 5配置读写用 9。_seq的存在是为了避免同优先级请求被PriorityQueue按字节内容排序导致某些请求永远排在后面这是一种很隐蔽的饿死。time.sleep(0.02)不是凑数下位机的扫描周期通常在毫秒到几十毫秒量级你连发得太密它会来不及处理表现为“请求都发出去了应答却少了几个”。这个间隔要根据 PLC 的实际扫描周期调宁慢勿乱。4.3 断线检测与重连状态机串口最麻烦的地方是“断线”没有事件通知。USB 线被拔掉ser.is_open仍然是 True只是之后所有读写都报错。所以必须自己维护状态import enum class LinkState(enum.Enum): DISCONNECTED 0 CONNECTING 1 ONLINE 2 FAULT 3 def health_check(state: LinkState, consecutive_fail: int) - LinkState: 连续失败 3 次才判故障单次失败只降级重试避免抖动误判 if state is LinkState.ONLINE and consecutive_fail 3: return LinkState.FAULT if state is LinkState.FAULT: return LinkState.CONNECTING return state为什么是 3 次而不是 1 次工业现场存在偶发的电磁干扰单次失败很常见一次失败就断开重连会让程序整天在重连和在线之间抖。但也不能设得太大否则线真断了之后界面上还显示“在线”操作员按了按钮没反应反而更糟。3 次是实践里比较平衡的取值具体可以按轮询周期换算成时间轮询 100ms、阈值 3 次就是 300ms 内感知到故障。重连动作本身要做三件事关掉旧句柄、重新打开端口号可能变了要重新枚举、清空输入缓冲。尤其第三件不能省否则重连后第一次读很可能拿到断线前残留的半帧立刻又被判失败陷入反复重连的死循环。5. 排查与避坑五条在现场反复出现的记录下面这几条都按“现象 → 原因 → 解决”写每一条都能对应到前面某段代码。遇到问题时先按现象对号入座比从代码第一行读起要快得多。5.1 现象偶发收到 0x00 或整段 0xFF原因这类现象几乎都出在物理层。0xFF 常见于对端根本没在驱动线路悬空被上拉0x00 常见于 A/B 或 2/3 线接反、或者两端未共地导致电平判据失效。还有一个容易被忽略的来源USB 转串口线质量差波特率 115200 时误码率明显上升。解决先用 2.1 节的方法量空闲电平确认方向和共地。再把波特率降到 9600 试一轮——如果降速后错误消失基本可以判定是转换线或线缆质量问题换线或缩短线长。最后检查是否误开了硬件流控rtsctsTrue在三线制下会让写操作卡住表现出来的也是“数据发不出去”。5.2 现象CRC 校验永远不过但字节内容肉眼可见是对的原因按出现频率排序一是 CRC 高低字节顺序写反协议要求低字节在前二是计算范围把 CRC 字段本身也算了进去三是多项式与移位方向不匹配0xA001 配左移。这三种在打印出来看的时候都很像“帧是对的”。解决拿 3.4 节表格里的已知向量直接对拍先确认 CRC 函数本身正确再看拼帧顺序。check_frame那个“整帧再算一遍结果为 0”的写法可以帮你排除计算范围的歧义——用这个判定方式只要能过就说明范围没问题。5.3 现象程序跑一整夜之后卡死界面无响应原因timeout用了默认值None。None意味着 read 会无限阻塞直到有数据一旦对端掉电或线被拔这个线程就永久挂在那里。如果这个线程还持有锁主线程也会跟着卡。解决所有Serial实例强制指定timeout和write_timeout不允许出现默认值。同时在send_and_read里用time.monotonic()做外层截止时间形成双保险。再加一条读循环里捕获serial.SerialException这个异常是 USB 被拔掉时的典型信号捕到就转 FAULT 状态走重连。5.4 现象换一台电脑就通信不上代码一个字没改原因端口号写死。USB 转串口的 COM 号是系统分配的换机器、换 USB 口都可能变。另外驱动版本不同也会导致某些参数被静默忽略。解决改成按hwid里的 VID/PID 匹配设备匹配不上时退到配置文件读取端口名并在界面上把当前实际打开的端口显示出来。这条改动很小但能省掉大量“现场装完跑不起来”的返工。5.5 现象PLC 报帧错误上位机这边只显示超时原因上位机轮询太快把 RTU 依赖的帧间间隔压没了下位机把两帧连成了一帧于是报帧错误上位机这边等不到应答报超时。两边的报错看起来毫无关联其实是同一件事的两面。解决在SerialWorker里加固定帧间隔4.2 节里的time.sleep并且给一帧内的连续写加flush()。如果是 RS485 半双工还要额外考虑收发方向切换的延时通常需要 1 到 2 个字符时间。判断方法很简单把轮询间隔从 20ms 调到 100ms如果错误率明显下降就是这个问题。6. 进阶把串口程序做成能录能回放的调试器前面所有排查都依赖一个前提你能看到真实的收发字节。一旦问题只在客户现场出现、现场又不允许你装开发环境没有原始报文就等于盲人摸象。所以我在这一层加了个很划算的功能把所有收发按行落盘事后可以离线回放。import json import time class RecordingSerial: 包一层真实串口原样录下每次写和读便于事后复现 def __init__(self, ser, path: str): self._ser ser self._fp open(path, a, encodingutf-8) def _log(self, direction: str, data: bytes): self._fp.write(json.dumps({ ts: round(time.time(), 3), dir: direction, # tx 或 rx hex: data.hex(), # 十六进制避免编码丢失 }, ensure_asciiFalse) \n) self._fp.flush() # 当场落盘崩溃也不丢 def write(self, data: bytes) - int: self._log(tx, data) return self._ser.write(data) def read(self, n: int) - bytes: data self._ser.read(n) if data: self._log(rx, data) return data def flush(self): self._ser.flush()hex()而不是直接存字符串是为了避开不可打印字节被转义或丢弃flush()每条都调是因为现场断电往往毫无征兆缓冲区里留着的最后几条恰恰是最有价值的。回放时把RecordingSerial换成一个按时间戳吐数据的假对象就能在没有硬件的情况下把故障重演一遍——这套思路我用了很久比任何日志框架都直接。上线前建议过一遍这张检查表检查项判定标准不通过的常见原因端口匹配按 VID/PID 命中不依赖 COM 号写死端口号超时read 与 write 都有上限使用默认 None帧完整性收满期望长度或明确超时读到什么算什么断线感知连续失败计数触发重连只看 is_open原始报文收发全量落盘可回放只记异常不记正常那张表我一般放在交付文档第一页。因为踩过太多次“明明本地好好的”这种坑后来养成一个硬习惯任何串口程序在交给现场之前都先断网、拔线、连发一千帧、再插回去把断线重连和残帧清理完整走一遍——这一遍走完剩下的问题基本就只剩寄存器地址对不对了。希望帮到你。本文还有配套的精品资源点击获取