简介串口通信程序是一套面向工业自动化、嵌入式与物联网开发者的RS-232串口通信工程源码适合需要实现上位机与PLC、单片机等下位机双向数据交换的初中级开发者参考。工程围绕串口打开与参数配置、数据发送、中断接收、校验与超时错误处理、通信结束后释放串口等环节展开并涉及波特率、数据位、停止位、校验位及握手协议等关键设置可用于远程数据采集、产线控制等场景的定制化通信搭建。压缩包共22个文件约137KB以h头文件和cpp源文件为主另含sln解决方案、vcxproj工程文件、rc资源脚本及ico图标等构成一套可直接查阅的MFC串口通信项目结构。该程序经实际项目验证已有592人学习读者可从中获取串口类封装、对话框交互与上下位机指令收发的完整实现思路便于二次开发与排错借鉴。1. 九针线两端串口通信程序在设备联调里的角色产线上常见的画面是一台下位机PLC 或者单片机控制板跑着动作逻辑旁边的工控机作为上位机要把实时点位、报警码、配方参数读上来写下去中间只连一根 RS232 九针线。串口通信程序就是夹在两头中间的那层东西它得把一串看不懂的字节变成温度值、压力值和状态位还得在对方不吭声的时候给出超时、重试和告警。这活儿听着简单真正写起来门槛全在细节里帧怎么切、CRC 怎么算、拔插之后怎么恢复、界面线程怎么不被卡死。这份程序适合做设备集成、上位机软件、产线数据采集的开发者拿去当底稿不管对面是哪个品牌的 PLC主从问答加帧解析这套骨架是通用的。2. 程序骨架上位机、下位机与 RS232 链路的分层设计2.1 主从问答谁先开口决定了整个通信模型RS232 是点对点、半双工的物理链路同一时刻只能有一个方向在传数据两边同时发就会撞在一起收到一堆错乱字节。所以串口通信程序的第一条设计原则是先定主从上位机做主机主动发起请求下位机做从机只应答不主动上报。所有实时性需求都靠轮询周期去逼近而不是指望下位机随时插话。这个选择带来两个直接后果。第一程序里必须有一个请求队列同一时刻只允许一个请求在途否则响应会和请求对不上号现场表现就是数值乱跳。第二下位机的寄存器区必须提前约定好地址表哪个地址是温度、哪个是压力、哪个是报警字写死在双方的约定里。地址表是整个项目的契约改地址比改代码更容易出事。有些现场确实需要下位机主动上报比如报警。稳妥做法是下位机把报警写进一个保持寄存器上位机每轮轮询顺带读回来而不是让从机抢话。真要做双向主动得上 RS485 或者换协议不在 RS232 这条链路的讨论范围内。2.2 物理层与串口参数八个参数错一个就跑不通RS232 用负逻辑电平-3V 到 -15V 表示逻辑 13V 到 15V 表示逻辑 0和 TTL 电平不兼容中间要过电平转换芯片。DB9 接头真正用到的只有三根2 脚 RXD、3 脚 TXD、5 脚 GND。两台设备直连时 RXD 和 TXD 要交叉自己焊线最容易在这翻车——线序接成直通现象就是发得出去收不回来。串口参数必须两头完全一致差一位就是一屏乱码参数常见取值现场说明波特率9600 / 19200 / 38400 / 115200线越长、干扰越大越要降速9600 是最稳的默认值数据位87 位只在部分老仪表上出现停止位1少数老设备用 2配置错了会固定丢最后一个字节校验位None / Even与下位机配置严格一致不能凭感觉流控None三线制接法必须关掉硬件流控否则会一直等 CTS工控机上跑的大多是 USB 转串口插上之后系统分配一个端口号。这个号码跟 USB 口的位置绑定换个 USB 口就变程序里写死端口号是很脆的做法后面避坑一节会专门讲。2.3 分层结构界面、业务、协议、传输各管一段把串口读写直接写在按钮点击事件里项目活不过三个月。合理的做法是四层拆开每层只对下一层负责SerialComm/ ├── App/ 界面与交互只调用业务层接口 │ ├── MainForm.cs │ └── PointGridView.cs ├── Business/ 业务编排轮询调度、点位缓存、告警判定 │ ├── PollingService.cs │ └── PointRepository.cs ├── Protocol/ 协议编解码纯函数方便单测 │ ├── FrameCodec.cs │ ├── Crc16.cs │ └── RegisterMap.cs └── Transport/ 串口生命周期、收发、重连 ├── SerialLink.cs └── PortLocator.cs传输层对外只暴露两个能力发一帧、收一帧。协议层不碰 SerialPort只接收 byte[] 返回对象所以能脱开硬件跑单元测试。业务层决定什么时候发、发什么、失败了怎么办。界面层只订阅业务层抛出的数据变更事件。这样拆分之后换一个下位机型号改动集中在 RegisterMap 和 FrameCodec界面一行不用动。2.4 时序预算轮询周期不是拍脑袋定的一个点位读一次请求帧按 10 字节算9600bps、8N1 每字节传 10 bit单向耗时约 10.4ms响应 13 字节约 13.5ms再叠加下位机一个扫描周期 10 到 50ms一次往返落在 60ms 上下。超时时间设 200ms 比较合理留了三倍余量重试 2 次意味着最坏情况下一个点位卡住 600ms。如果现场有 20 个点位逐个读就是 20 次往返周期起码 1.2 秒界面刷新会明显发涩。合格做法是合并读取把地址连续的寄存器一次性读回来20 个点位压缩成两三个请求往返次数降到 3 次以内200ms 的轮询周期就撑得住了。地址表设计阶段就要把点位按地址排好别东一个西一个这是后期能不能提速的关键。3. 核心模块拆解从打开串口到一帧数据解析完成3.1 串口初始化生命周期比参数更重要public sealed class SerialLink : IDisposable { private readonly SerialPort _port; private readonly ConcurrentQueuebyte _rx new(); private readonly SemaphoreSlim _writeLock new(1, 1); public SerialLink(string portName, int baudRate 9600) { _port new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One) { ReadTimeout 200, // 读超时单位毫秒 WriteTimeout 200, // 写超时单位毫秒 ReceivedBytesThreshold 1, // 收到 1 字节就触发事件别设太大 Handshake Handshake.None, // 三线制接法必须关掉流控 DtrEnable false, // 有些转换器拉高 DTR 会让从机复位 RtsEnable false }; _port.DataReceived OnDataReceived; } public void Open() { if (_port.IsOpen) return; _port.Open(); } public void Dispose() { _port.DataReceived - OnDataReceived; if (_port.IsOpen) _port.Close(); _port.Dispose(); // 不 Dispose 会残留句柄下次打不开 _writeLock.Dispose(); } }参数里最容易被忽略的是 DtrEnable 和 RtsEnable。不少 USB 转串口模块把 DTR 接到了从机的复位脚程序一打开串口就把下位机拉复位现场表现是一连上就重启。默认关掉这两个信号能规避大部分这类问题。Dispose 必须显式调用而且要在 Close 之后。只 Close 不 Dispose进程退出前那个端口会一直处于占用状态调试工具再想打开就是拒绝访问。这是血泪经验后面还会提到。3.2 收发模型事件里只做搬运解析交给后台线程private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { try { int available _port.BytesToRead; if (available 0) return; var buffer new byte[available]; int read _port.Read(buffer, 0, available); for (int i 0; i read; i) _rx.Enqueue(buffer[i]); // 只入队并唤醒解析线程事件线程里不做任何耗时操作 _frameReady.Set(); } catch (Exception ex) when (ex is IOException || ex is InvalidOperationException) { RaiseLinkBroken(ex); // 拔线、驱动异常都会走到这里 } }DataReceived 跑在串口驱动的内部线程上这个线程被堵住后面的字节就没人收直接丢。所以事件里只做两件事把字节搬进队列、唤醒解析线程。CRC 校验、对象构造、日志写盘全部放到后台循环里。解析循环按帧头切分不假设一次 Read 就是一帧private byte[] TryExtractFrame() { while (true) { if (_rx.Count 5) return null; // 最短帧都不够继续等 byte[] buf _rx.ToArray(); int head IndexOfHeader(buf); // 找 0xAA 0x55 if (head 0) { KeepTail(1); return null; } // 全是垃圾只留 1 字节防半个帧头 if (head 0) Drop(head); // 丢掉帧头前的噪声 buf _rx.ToArray(); int len buf[2]; // 长度字段 命令 1 字节 数据 N 字节 int total len 5; // 头 2 长度 1 数据 CRC 2 if (total 260) { Drop(1); continue; } // 长度离谱判定为假帧头 if (buf.Length total) return null; // 半包等下一批字节 byte[] frame buf.Take(total).ToArray(); ushort crc Crc16.Compute(frame, 0, total - 2); ushort got (ushort)(frame[total - 2] | (frame[total - 1] 8)); Drop(total); if (crc ! got) { RaiseCrcError(); continue; } // 校验失败丢帧继续找下一个头 return frame; } }这段代码处理了三种现场最常见的异常前面混进噪声字节、一帧被拆成两次到达、两个帧粘在一次读取里。帧头找不到时只保留 1 个字节是因为下一次读取可能刚好接上 0x55多留会错位。3.3 帧格式设计字段少一个排查就多一天协议帧定成下面这样简单但够用字段长度说明帧头2 字节固定 0xAA 0x55用于重同步长度1 字节命令字加数据区的总字节数命令1 字节0x03 读保持寄存器0x06 写单个寄存器数据N 字节地址、数量或写入值大端在前CRC162 字节覆盖帧头到数据区低字节在前CRC 建议用 Modbus 那套多项式 0xA001现成资料多下位机侧也容易找到实现。字节序要写进文档多字节数值一律高字节在前CRC 一律低字节在前。这两条不写清楚联调时会出现数值算出来是 65535 的倍数这种诡异现象。3.4 CRC16 与编解码public static class Crc16 { public static ushort Compute(ReadOnlySpanbyte data, int offset, int count) { ushort crc 0xFFFF; for (int i offset; i offset count; i) { crc ^ data[i]; for (int bit 0; bit 8; bit) { bool lsb (crc 0x0001) ! 0; crc 1; if (lsb) crc ^ 0xA001; // 反向多项式 } } return crc; // 发送时低字节在前 } }offset 和 count 参数是为了让同一个函数既能算整帧也能算去头去尾的片段。上位机和下位机必须用完全相同的初始值 0xFFFF 和多项式任何一侧用了 0x1021 的 CCITT 版本收到的就是全帧校验失败。3.5 请求与重试超时之后要清残留public async Taskbyte[] RequestAsync(byte cmd, byte[] payload, CancellationToken ct) { for (int attempt 0; attempt _retry; attempt) // _retry 默认 2 { _rx.Clear(); // 关键清掉上一轮的迟到响应 var pending new TaskCompletionSourcebyte[](TaskCreationOptions.RunContinuationsAsynchronously); _pending pending; byte[] frame FrameCodec.Build(cmd, payload); await _writeLock.WaitAsync(ct); try { _port.Write(frame, 0, frame.Length); } finally { _writeLock.Release(); } using var timeout CancellationTokenSource.CreateLinkedTokenSource(ct); timeout.CancelAfter(_timeoutMs); // 默认 200ms try { return await pending.Task.WaitAsync(timeout.Token); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { _logger.Warn($cmd0x{cmd:X2} 第 {attempt 1} 次超时准备重试); } } throw new CommTimeoutException(cmd, _retry); }每次重试前清空接收队列是必须的。超时的那一刻从机的响应可能还在路上不清掉的话下一轮请求会先读到上一轮的旧响应数据就整体错位一格。这种错误在现场表现为数值一直慢半拍很难从日志里看出来因为每一帧的 CRC 都是对的。写操作加信号量是因为串口写本身不是线程安全的界面线程和轮询线程同时调用 Write 会交错出半个帧。读操作用的是独立队列不需要同一把锁。4. 避坑清单串口通信里最容易翻车的五个地方4.1 现象跑几分钟界面就卡死或抛跨线程异常原因在 DataReceived 事件里直接给控件赋值。事件线程不是 UI 线程WinForm 和 WPF 都会抛 InvalidOperationException如果改成阻塞等待事件线程被卡住接收缓冲区溢出开始丢字节。解决事件里只入队UI 更新统一走 Invoke 或者把数据推到 BindingList 让数据绑定去处理。我一般会把刷新频率限制在 200ms 一次数据变化再快也没必要每帧刷界面肉眼也看不清。4.2 现象偶发数据错位CRC 偶尔报错重启就正常原因把一次 Read 的返回值当成一帧。串口是字节流一次读取可能拿到半帧也可能拿到一帧半取决于发送方节奏和驱动缓冲。按帧长切片这一步省掉迟早出问题。解决接收队列加帧头重同步加长度判断就是 3.2 那套逻辑。自测时把发送方拆成每次只发 1 个字节能稳定跑通再上真机。4.3 现象换台工控机或换个 USB 口程序报端口不存在原因USB 转串口的 COM 号跟物理口绑定重新枚举时会变程序里写死 COM3 就废了。解决启动时枚举可用端口按设备描述或者 VID/PID 匹配目标转换器匹配不到再让用户手选。枚举可以用 WMI 查 Win32_SerialPort也可以直接读注册表 HARDWARE\DEVICEMAP\SERIALCOMM。更稳的做法是给每台设备做一次端口绑定配置存在本地配置文件里。4.4 现象示波器能看到回波程序就是收不到正确数据原因参数不一致。波特率对了但校验位、停止位错收到的字节会整体偏移或者从站地址配错下位机根本不回应程序只能看到超时还有一种情况是线太长又靠近变频器误码率高CRC 频繁失败。解决按物理层、链路层、应用层的顺序查。先确认线序和接地再确认八位参数完全一致再确认从站地址和寄存器地址。寄存器地址还有 0-based 和 1-based 的坑手册写 40001 对应协议里的 0x0000差一位读出来就是隔壁寄存器的值。屏蔽双绞线、加磁环、降波特率这三招能解决大部分干扰问题。4.5 现象运行一段时间后端口打不开提示拒绝访问原因上一个进程没退干净或者调试时开了两个程序同时占着同一个串口。SerialPort 不 Dispose 时句柄会挂到进程退出才释放。解决所有串口对象实现 IDisposable窗体关闭事件里显式 Dispose程序启动时先尝试打开端口失败就提示端口被占用而不是静默重试。调试期间养成习惯改完代码先关掉旧的调试实例再启动新的。5. 跑起来环境准备、模拟从站与首次联调顺序5.1 环境与依赖上位机侧需要 .NET 6 或以上运行时、Visual Studio 或者任意能编译 C# 的编辑器工控机上还要装好 USB 转串口芯片的驱动。没有硬件也能开发装一对虚拟串口工具把 COM10 和 COM11 连起来程序开 COM10模拟从站开 COM11整条链路就通了。模拟从站用 Python 写最省事装个 pyserial 就行pip install pyserial5.2 用 Python 模拟下位机先把协议跑通import struct import serial def crc16(data: bytes) - int: crc 0xFFFF for b in data: crc ^ b for _ in range(8): crc (crc 1) ^ 0xA001 if crc 1 else crc 1 return crc ser serial.Serial(COM11, 9600, timeout1) # 与上位机成对的另一端 print(slave ready on, ser.port) while True: head ser.read(2) if head ! b\xAA\x55: continue # 帧头不对继续找 ln ser.read(1)[0] body ser.read(ln) # 命令 数据区 frame head bytes([ln]) body got struct.unpack(H, ser.read(2))[0] # CRC 低字节在前 if got ! crc16(frame): continue # 校验失败直接丢模拟真实从机 cmd, payload body[0], body[1:] if cmd 0x03: # 读保持寄存器 addr payload[0] 8 | payload[1] qty payload[2] 8 | payload[3] data b.join(struct.pack(H, (addr i) * 10) for i in range(qty)) out bytes([len(data)]) data # 长度 数据 resp b\xAA\x55 bytes([len(out)]) bytes([0x03]) out ser.write(resp struct.pack(H, crc16(resp)))这段脚本的关键点和上位机严格对称帧头两字节、长度字段只算命令加数据、CRC 覆盖整帧除 CRC 本身、CRC 低字节先发、寄存器值大端。跑通之后上位机读 0x0000 地址两个寄存器界面上应该稳定显示 0 和 10把 qty 改成 4 就能看到 0、10、20、30。先跑模拟从站的好处是能把协议层的问题一次暴露完不用在车间里举着笔记本猜是程序错了还是 PLC 错了。5.3 首次连接真实 PLC 的检查顺序按这个顺序走能省掉大量来回端口号设备管理器里确认转换器对应的 COM 号和程序配置一致。八位参数波特率、数据位、停止位、校验位逐项和下位机工程里的设置核对。从站地址PLC 侧站号设置和程序里的 station 参数一致多站总线更容易错。寄存器地址手册地址减掉 40001 的偏移落到协议地址上注意 0-based。线序与接地RXD/TXD 交叉GND 必接长线加屏蔽。单点试读先只读一个寄存器读通了再扩到整张地址表。每一步都单独验证别一次改三个地方出了错根本不知道是哪一个生效。5.4 日志与验收标准日志字段至少要有这些出问题时是唯一的黑匣子字段示例用途时间戳2024-05-11 14:02:31.482对齐 PLC 侧日志方向TX / RX区分请求与响应原始字节AA 55 06 03 00 00 00 02 CRC复现协议问题耗时47ms判断是超时还是抖动结果OK / CRC_ERR / TIMEOUT统计错误率验收别只看界面有数。连续跑 30 分钟统计三个指标超时率低于 0.5%、CRC 错误数为 0、重连次数为 0。再手动物理拔插一次串口线程序应该能在 5 秒内自动重连并恢复采集恢复后第一帧数据不能是旧缓存。6. 二次开发加一个采集点要动哪几个文件代码跑通之后真正的工作量在改。最常见的需求是加一个点位、换一台下位机、把单串口扩成多串口。前两个改配置就够第三个要动结构。加采集点的路径很短前提是地址表是数据驱动的public static class RegisterMap { // 地址、名称、系数、单位、读写类型 public static readonly PointDef[] Points { new(0x0000, 炉温, 0.1, ℃, PointType.Holding), new(0x0001, 压力, 0.01, MPa, PointType.Holding), new(0x0002, 报警字, 1.0, , PointType.Holding), }; }加一行界面表格和轮询逻辑自动跟上系数在解析时统一乘别在界面层做换算。地址表最好外置成 JSON 或者 CSV现场调地址不用重新编译。轮询合并也要跟着看新点位地址如果和已有的不连续往返次数会多一次200ms 周期可能就不够了得把相近地址归到一个请求里。多串口扩展要动的就多了。现在的 SerialLink 是单例思路扩成多台设备时建议一台设备一个 SerialLink 实例加一个独立的轮询任务不要共用一个接收队列。共用队列会让 A 设备的响应被 B 设备的解析线程抢走现象是两台设备的数据互相串。端口定位那层也要跟着改按配置里的设备编号去找对应的 COM 口。验证方法我一般分三步走先在模拟从站上改地址表跑通再把波特率调高一档压测半小时看错误率最后上真机做拔插测试。改动只涉及协议层的话单元测试里构造几个字节数组就能验证不用每次都连硬件。从那以后我每次改完帧格式或者地址表都强制自己先用模拟从站跑一遍全量点位再上现场。省下的不是调试时间是来回跑车间的路程。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?