
简介模拟串口是嵌入式开发中在无额外硬件串口时通过软件模拟时序实现通信的常见技术这份资料面向单片机初学者或电子工程方向学生提供一套完整的模拟串口实现与配套工程。包内文件共23个以C语言源码、Keil工程配置、编译链接产物以及Proteus仿真设计文件为主压缩包整体仅39KB覆盖从源码编写、工程构建、编译生成到仿真验证的完整流程适合作为课程设计或入门练习的参照。目前已有47人学习。通过发送与接收两个核心源文件可快速理解软件模拟串口的时序与引脚控制方法烧录文件可用于直接验证运行效果备份文件则便于还原工程原始状态和对比调试参数。对希望掌握软件串口原理、熟悉单片机开发工具链的读者来说这是一份轻量而完整的参考案例。1. 模拟串口一台电脑仿真出两条能互通的 COM 链路做上位机开发最头疼的往往不是代码而是硬件没到货、设备只有一台、协议对拍缺对端。模拟串口就是为解决这类问题而生的软件方案在驱动层虚拟出一对互联的 COM 端口往 COM3 写数据能从 COM4 读出来不需要任何物理串口线就能把收发流程、超时重连、协议帧解析全部跑通。这份模拟串口资料包适合嵌入式工程师、上位机开发新手以及做工控协议验证的人把串口调试从“等硬件”变成“开箱即调”。下面按我实际操作时走过的路径把原理、配置、代码和踩过的坑一次讲清。2. 一对虚拟端口是怎么造出来的串口模型、驱动机制与选型对照要玩转模拟串口不能只知道点“安装、创建端口”得先搞明白它到底在模拟什么。只有理解了这条虚拟链路的边界后面出问题时才知道去哪里找原因。2.1 先理清串口链路的五个要素真实串口通信由五层要素共同决定结果物理电平RS232/RS485/TTL、帧格式起始位、数据位、停止位、波特率9600 还是 115200、流控RTS/CTS 是否启用、以及字节流的解析方式。应用层通过 COM 口抽象屏蔽了前两层我们平时操作的就是“打开端口、设波特率、读写字节”这个抽象接口。模拟串口软件做的是用驱动虚拟出这个 COM 口抽象层让两个端口在驱动内部通过缓冲区直接“管道互联”。它不关心物理电平也不太在意波特率——哪怕两端设置不一致数据照样能穿透这既是方便也是坑后面避坑章节会展开。从字节流的角度看虚拟串口两端的行为完全等价于一根直连线。A 端写入的字节进入驱动缓冲区B 端从自己端口读到的就是这个字节反之亦然。也就是说它模拟的是“物理链路已经连通”的理想状态专门用来排除硬件不确定性把调试焦点集中到应用层协议上。2.2 虚拟串口的驱动级实现机制主流的模拟串口工具比如 Windows 下的 com0com 和 VSPD都是通过内核驱动注册虚拟串行设备。驱动会为一个 COM 对创建两个设备对象并在内部维护一对环形缓冲区。应用层调用 CreateFile 打开 COM3、COM4 时系统把请求派发到虚拟设备驱动写操作写入发送缓冲区读操作从接收缓冲区取数另外一组 IOCTL 处理波特率、数据位等串口属性查询。这套机制里真正影响使用的参数有三个缓冲区大小、流控策略、配对关系。多数工具默认缓冲区是 8KB-16KB可以通过设备配置界面调整。流控一般默认关闭但如果你在代码里启用了硬件流控而驱动不支持就会出现“端口打开但数据不出去”的诡异现象。配对关系存于注册表或配置文件卸载不干净会残留也是后面排查的重点。另外模拟串口全双工能力取决于驱动实现。好的驱动支持同时双向收发性能差一些的会出现读写竞争导致丢字节。所以在选型时不光看免费不免费还要看它内部的缓冲处理方式。2.3 主流通用工具对照com0com、VSPD、tty0tty我实际用过三套方案各自的适用场景差别挺大。com0com 是开源免费的经典选择功能稳定但安装步骤偏手动适合学习和日常调试VSPD 是商业化工具图形界面做得好一键创建端口对适合需要频繁切换配置的测试环境Linux 下常用 tty0tty 编译内核模块或者用 socat 直接生成 PTY 伪终端对。工具平台开源配对方式典型场景com0comWindows是setupc.exe 命令行或 GUI开发调试、自动化测试VSPDWindows否图形界面一键创建现场快速配置、教学演示tty0ttyLinux是编译模块后生成 /dev/tnt* 设备嵌入式 Linux 交叉调试socatLinux/macOS是创建 PTY 对转发、临时管道、跨主机串口联调提示选型不用盲目追求性能虚拟串口的吞吐上限远高于一般传感器数据量重点看驱动稳定性、卸载干净程度和是否有配置入口。3. 从安装到收发在 Windows 上跑通第一对虚拟串口这章直接带你在 Windows 环境把一对端口用起来。无论你最后选哪个工具操作路径都是“装驱动 → 建配对 → 应用层验证”区别只在命令和界面入口。3.1 驱动安装与端口配对以 com0com 为例安装包下载后解压先右键管理员身份运行安装脚本或者直接在设备管理器里更新驱动。装完驱动后系统里不会自动出现端口需要手动创建配对。用命令行方式创建 COM3 到 COM4 的绑定cd C:\Program Files (x86)\com0com setupc.exe install setupc.exe create portnameCOM3 portnameCOM4第一条命令 install 是注册驱动服务第二次运行时会跳过重复安装第二条命令 create 创建一对虚拟串口。参数 portname 指定两边端口名可以用默认 CNCA0/CNCB0但业务代码里用 COM 号更直观。创建成功后打开设备管理器会看到“端口 (COM 和 LPT)”下多出 COM3 和 COM4。这里提醒一个细节如果系统里已经存在物理串口 COM3创建会失败或覆盖原有映射。稳妥的做法是先把端口改到未占用号码比如 COM3/COM4 被占用就改用 COM20/COM21。改完后可在设备管理器里逐个确认状态状态正常的端口才能被应用层正常打开。3.2 用 Python 验证 COM3-COM4 全双工链路驱动装好后用 Python 的 pyserial 库验证是最快的方式。这个验证脚本要覆盖两件事一是 A 端写 B 端能收到二是 B 端写 A 端能收到也就是全双工。import serial import time # 打开两端端口timeout 控制读超时避免 read 无限阻塞 a serial.Serial(COM3, 115200, timeout1, write_timeout1) b serial.Serial(COM4, 115200, timeout1) # A 写 B 收 a.write(bping from A) data b.read(16) print(B 收到:, data) # B 写 A 收 b.write(bpong from B) data a.read(16) print(A 收到:, data) a.close() b.close()逻辑说明这里两个端口在同一进程里分别打开serial.Serial 第二个参数 115200 是波特率timeout1 表示读操作最多等 1 秒write_timeout1 防止写满缓冲区时程序卡死。如果脚本打印出两条收发消息说明链路已经通了。注意 read(16) 并不保证一次读满 16 字节因为虚拟串口属于流式接口数据分批到达。真要判断收发是否匹配最好发包时带上长度前缀或者用下面的监听线程方式逐字节读取并打印十六进制。import serial import threading import time stop_flag False def watch(name, port): while not stop_flag: data port.read(1) # 每次读 1 字节配合 timeout 轮询 if data: print(name, data.hex()) a serial.Serial(COM3, 9600, timeout0.1) b serial.Serial(COM4, 9600, timeout0.1) threading.Thread(targetwatch, args(A-, a), daemonTrue).start() threading.Thread(targetwatch, args(B-, b), daemonTrue).start() time.sleep(5) stop_flag True这里 read(1) 每次只取一个字节配合 0.1 秒超时形成轮询适合观察数据流向和时序daemon 线程保证主程序退出时不残留。全部打印以 hex 形式输出方便对照十六进制协议帧。3.3 接进业务代码打开、监听与异常重连验证链路之后把它接进你自己的业务代码。串口程序和 TCP 程序最大的不同是串口没有连接状态拔掉线或驱动异常时应用层不会立刻感知需要在读写异常时触发重建逻辑。常见做法是封装一个带自动重连的串口链路类import serial import serial.tools.list_ports class VirtualSerialLink: def __init__(self, port, baud9600): self.port port self.baud baud self.ser None self.open_port() def open_port(self): try: self.ser serial.Serial(self.port, self.baud, timeout0.2) print(打开端口, self.port, 成功) except serial.SerialException as e: print(打开失败:, e) def send(self, data): if self.ser and self.ser.is_open: self.ser.write(data) return True return False def ensure_open(self): if not self.ser or not self.ser.is_open: self.open_port()逻辑说明每次发送前先检查 is_open发送时捕获 SerialTimeoutException 和 SerialException一旦发现端口失效在下一轮业务重试里重新打开。之所以做成独立类是为了方便在多个模块里复用同一份异常处理策略。提示虚拟串口和物理串口一样同一端口同一时刻只能被一个进程打开。调试期间如果调试助手占着 COM3业务代码就打开失败这是最常见的初学翻车点。4. 拿来调协议用模拟串口把 Modbus RTU 从站完整跑起来虚拟串口最有价值的用法不是简单的 A 发 B 收而是把它当成协议联调的仿真环境。这套流程可以反复注入异常帧、断帧、错帧可重复性远超物理设备。下面以 Modbus RTU 为例从回环自检到从站实现完整走一遍。4.1 先做一次回环自检排除环境和驱动问题在跑业务协议之前先发一帧标准 Modbus 请求看看主站发送、从站接收链路是否正常。Modbus RTU 帧由地址、功能码、数据区和两个字节 CRC16 组成下面这个报文是读取从站 1 的保持寄存器起始地址 0、数量 2 的完整请求帧。import serial req bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x02, 0xC4, 0x0B]) ser serial.Serial(COM3, 9600, timeout0.5) ser.write(req) resp ser.read(16) print(从站响应:, resp.hex())逻辑说明0x01 是从站地址0x03 是读保持寄存器功能码后面两个字节是起始地址再后面两个字节是寄存器数量最后两个字节是 CRC16。把这条命令发出后如果从站程序在 COM4 端正常响应主站端会收到类似01030404D2F4的应答其中010304是地址、功能码、字节数后面是寄存器数据。参数说明这个自检脚本里 timeout0.5 表示最多等待 500ms 响应。如果你的从站程序需要更长处理时间可以先调大到 1 秒但调大后要注意如果主站逻辑里还有另一层超时判断两边要匹配否则会出现“程序等 1 秒但业务层已经判超时”的伪故障。4.2 实现一个最小 Modbus RTU 从站从站程序监听 COM4收到完整请求帧后解析功能码并回响应。这里我实现一个只支持功能码 0x03 的最小从站重点展示 CRC16 算法和响应帧构造。import serial import struct def crc16_modbus(data): crc 0xFFFF for byte in data: crc ^ byte for _ in range(8): if crc 0x01: crc (crc 1) ^ 0xA001 else: crc 1 return crc def build_response(req): if len(req) 8: return None dev_addr req[0] func req[1] if func 0x03: regs [1234, 5678] resp bytes([dev_addr, 0x03, 4]) struct.pack(HH, regs[0], regs[1]) else: resp bytes([dev_addr, func | 0x80, 0x01]) # 异常响应 crc crc16_modbus(resp) return resp struct.pack(H, crc) ser serial.Serial(COM4, 9600, timeout0.5) print(从站已启动等待请求帧...) while True: req ser.read(8) # 固定读 8 字节请求帧 if len(req) 8: resp build_response(req) if resp: ser.write(resp) print(收到请求:, req.hex(), 回复:, resp.hex())逻辑说明crc16_modbus 是标准 Modbus CRC16 算法初始值 0xFFFF校验和 0xA001 异或build_response 里 struct.pack(HH) 用大端序打包两个寄存器值和协议要求一致异常响应把功能码最高位置 1 返回 0x83让主站端能识别出异常。参数说明这里的ser.read(8)按最短请求帧长度读取实际上 Modbus RTU 请求帧并不都是 8 字节如果后续要支持写寄存器功能码得按功能码判断长度。生产级从站还要处理读数量超过 125、起始地址越界等边界这属于协议完整性范畴调试时可以先不处理但心里要有数。4.3 从站的边界参数与主从对拍时的观察点跑通基础功能后对拍测试要关注几个边界寄存器读取数量上限 125超过要返回异常码0x02广播地址 0 作为从站地址时从站要处理但不响应从站响应时间要小于主站超时否则主站会误报超时。这些边界在物理设备联调时问题会被硬件时序掩盖但用虚拟串口时可以精确复现。观察项期望行为异常时的排查方向请求帧 CRC 错误从站丢弃不响应检查主站 CRC 算法初始值和多项式寄存器数量超限返回 0x83 0x02 异常响应检查请求帧数据区第二字节响应时间过长主站报超时检查从站处理循环是否被阻塞地址不匹配从站不响应检查从站地址配置和请求帧首字节对拍时我把主站请求和从站响应都打印 hex 到终端两边时间戳对齐一旦出现某帧没走到对应端口立刻就能定位是发送端问题还是接收端解析问题。这套方法在真实硬件调试时同样适用只是虚拟串口环境更容易制造“错误帧”来验证协议鲁棒性。5. 模拟串口常见问题排查五个高频翻车现场用模拟串口的人十有八九会在同一个地方卡壳。这里整理五条我见过最多的故障记录每条按现象、原因、解决三个步骤展开里面不少是虚拟串口本身特性导致的不是代码 bug。5.1 端口能打开但写失败应用层报拒绝访问现象serial.Serial 打开成功send 后立刻抛SerialException: Write timeout或系统层报“拒绝访问”。原因同一个端口被两个程序占用或驱动在端口打开后进入了错误状态。虚拟串口驱动对并发访问的处理不如物理串口严格一个进程没正常关闭端口另一个进程再打开就可能收到错误码 5。解决先检查任务管理器里是否有残留进程占着端口再在设备管理器中禁用并重新启用该端口。代码层面open 时增加exclusiveTrue参数或在 close 后加time.sleep(0.1)让驱动释放句柄。5.2 连续大数据收发末尾丢十几字节现象主站发 1KB 数据对端只收到约 900 字节且丢失部分集中在末尾。原因虚拟串口的内部缓冲区大小固定默认较小的工具在高速写入时会出现生产者快于消费者的情况。另一个常见原因是应用层用了单次 read 固定长度没有循环读取。解决先确认工具的缓冲区配置把两端缓冲区调到 16KB 以上代码端用循环读取函数而不是依赖一次 read 拿全import time def read_exact(ser, n, timeout1): buf b deadline time.time() timeout while len(buf) n and time.time() deadline: buf ser.read(n - len(buf)) return buf参数说明timeout 秒内如果没读满 n 字节就返回当前内容调用方拿到短帧后要做超时处理。这个函数在物理串口上也适用建议直接沉淀进自己的串口工具库。5.3 两端参数一致却全乱码现象COM3 和 COM4 都配置为 115200、8N1收到的字节全是随机字符。原因虚拟串口不会像物理串口那样因为波特率不匹配而完全卡死数据是直通的如果应用层在 A 端发送时把字符串编码成了 UTF-8而 B 端按 GBK 解码或者发送方用了文本模式而接收方按二进制处理就会表现为乱码。解决不要只看串口参数先确认数据编码一致。调试阶段强制两端全部用 hex 收发排除文本编码干扰后再切换业务格式。5.4 卸载软件后端口残留设备管理器幽灵项现象卸载 com0com 后设备管理器里仍有 COM3 和 COM4删除后重启又出现。原因虚拟串口驱动的配对关系写入了注册表卸载程序没有清理干净系统启动时驱动重新加载配置。解决卸载前先在设备管理器里删掉所有虚拟端口再运行卸载程序如果残留在设备管理器菜单栏开启“查看 → 显示隐藏的设备”把灰色半透明的端口项连同驱动一起删除再清掉注册表HKLM\HARDWARE\DEVICEMAP\SERIALCOMM里对应的键值。动注册表前先备份操作失误会影响整个串口系统。5.5 虚拟机里的虚拟串口完全不通现象在 VMware 或 VirtualBox 里装了驱动、创建了端口对但两边数据互相收不到。原因虚拟机的串口设备本身是虚拟化的com0com 这类内核驱动在虚拟机里对中断和 DMA 的处理可能与宿主机不同部分版本会直接初始化失败。解决优先在宿主机上运行模拟串口驱动虚拟机里的业务通过 TCP 方式连接到宿主机做转发或者改用寄生设备的串口映射方式把宿主机的 COM3 直通给虚拟机使用。这套路在工业现场经常用到记住一条原则虚拟串口软件和业务程序最好跑在同一系统内核里跨系统转发交给网络层。6. 再进一步把虚拟串口当转发节点做多设备仿真走到这一步你已经能用虚拟串口做单链路调试了。最后一章把它当一个基础设施节点串联 TCP 转发和多从站联调这是现场排查问题前我必做的验证步骤。6.1 串口转 TCP一组端口变成网络串口典型的应用场景是把虚拟串口接到 TCP 服务上让远程设备能通过局域网访问这个串口。常见做法是写一个双向转发脚本一端监听 TCP一端读写串口串口数据流和网络数据流互相泵送。import socket import serial import threading def serial_to_tcp(conn, ser): while True: data ser.read(1) if data: conn.sendall(data) def tcp_to_serial(conn, ser): while True: data conn.recv(1024) if not data: break ser.write(data) srv socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.bind((0.0.0.0, 5000)) srv.listen(1) ser serial.Serial(COM3, 9600, timeout0.1) conn, addr srv.accept() threading.Thread(targetserial_to_tcp, args(conn, ser), daemonTrue).start() tcp_to_serial(conn, ser)逻辑说明serial_to_tcp 线程每次循环读一个字节并立即发送因为虚拟串口的 read(1) 配合 timeout0.1 不会长期阻塞tcp_to_serial 主线程接收 TCP 数据写入串口。这个结构在任何串口数据业务里都通用改成 read(1024) 批量发送会更快但要处理半包粘包问题。6.2 多从站联调两对端口模拟一主多从如果业务是一主多从比如一个网关采集多个仪表可以用两对虚拟串口分别连接两个从站程序。第一对 COM3-COM4 给从站 A第二对 COM5-COM6 给从站 B主站程序同时打开 COM3 和 COM5 轮询。这里的重点不是代码而是端口规划把业务端口号和设备逻辑地址做成配置文件切换现场时只改配置不改代码。6.3 我习惯的三步验证清单每次新环境配置好模拟串口后我强制自己走一遍三步验证第一步回环测试确认两端能互发互收第二步参数核对检查波特率、数据位、校验位的配置在两端一致第三步压力测试连续发 1000 帧并统计丢帧数。三步全过再开始真正的业务联调。验证项做法通过标准回环测试A 发固定帧 B 收B 再回发两边内容一致参数核对打印两端 serial 配置波特率、数据位、校验位完全一致压力测试主站连发 1000 帧从站逐帧响应无丢帧、无 CRC 错误以前我在调试一台扫码设备时拿到新虚拟串口环境就直接上业务代码结果乱码来回折腾了快两个小时最后发现只是驱动端口映射残留导致 COM3 实际指向了另一条串口链路。从那以后我每次换环境、换机器、驱动重装之后都强制先走一遍上面三步清单再开始协议联调。这套模拟串口调试思路和资料包里的工具脚本能帮你避开我踩过的这些坑希望帮到你。本文还有配套的精品资源点击获取