简介:模拟串口是嵌入式开发中在无额外硬件串口时,通过软件模拟时序实现通信的常见技术;这份资料面向单片机初学者或电子工程方向学生,提供一套完整的模拟串口实现与配套工程。包内文件共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 伪终端对。
| 工具 | 平台 | 开源 | 配对方式 | 典型场景 |
|---|---|---|---|---|
| com0com | Windows | 是 | setupc.exe 命令行或 GUI | 开发调试、自动化测试 |
| VSPD | Windows | 否 | 图形界面一键创建 | 现场快速配置、教学演示 |
| tty0tty | Linux | 是 | 编译模块后生成 /dev/tnt* 设备 | 嵌入式 Linux 交叉调试 |
| socat | Linux/macOS | 是 | 创建 PTY 对 | 转发、临时管道、跨主机串口联调 |
提示:选型不用盲目追求性能,虚拟串口的吞吐上限远高于一般传感器数据量,重点看驱动稳定性、卸载干净程度和是否有配置入口。
3. 从安装到收发:在 Windows 上跑通第一对虚拟串口
这章直接带你在 Windows 环境把一对端口用起来。无论你最后选哪个工具,操作路径都是“装驱动 → 建配对 → 应用层验证”,区别只在命令和界面入口。
3.1 驱动安装与端口配对
以 com0com 为例,安装包下载后解压,先右键管理员身份运行安装脚本,或者直接在设备管理器里更新驱动。装完驱动后,系统里不会自动出现端口,需要手动创建配对。用命令行方式创建 COM3 到 COM4 的绑定:
cd "C:\Program Files (x86)\com0com" setupc.exe install setupc.exe create portname=COM3 portname=COM4第一条命令 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, timeout=1, write_timeout=1) b = serial.Serial("COM4", 115200, timeout=1) # A 写 B 收 a.write(b"ping from A") data = b.read(16) print("B 收到:", data) # B 写 A 收 b.write(b"pong from B") data = a.read(16) print("A 收到:", data) a.close() b.close()逻辑说明:这里两个端口在同一进程里分别打开,serial.Serial 第二个参数 115200 是波特率,timeout=1 表示读操作最多等 1 秒;write_timeout=1 防止写满缓冲区时程序卡死。如果脚本打印出两条收发消息,说明链路已经通了。
注意 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, timeout=0.1) b = serial.Serial("COM4", 9600, timeout=0.1) threading.Thread(target=watch, args=("A->", a), daemon=True).start() threading.Thread(target=watch, args=("B->", b), daemon=True).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, baud=9600): 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, timeout=0.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, timeout=0.5) ser.write(req) resp = ser.read(16) print("从站响应:", resp.hex())逻辑说明:0x01 是从站地址,0x03 是读保持寄存器功能码,后面两个字节是起始地址,再后面两个字节是寄存器数量,最后两个字节是 CRC16。把这条命令发出后,如果从站程序在 COM4 端正常响应,主站端会收到类似01030404D2F4的应答,其中010304是地址、功能码、字节数,后面是寄存器数据。
参数说明:这个自检脚本里 timeout=0.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, timeout=0.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 时增加exclusive=True参数,或在 close 后加time.sleep(0.1)让驱动释放句柄。
5.2 连续大数据收发,末尾丢十几字节
现象:主站发 1KB 数据,对端只收到约 900 字节,且丢失部分集中在末尾。原因:虚拟串口的内部缓冲区大小固定,默认较小的工具在高速写入时会出现生产者快于消费者的情况。另一个常见原因是应用层用了单次 read 固定长度,没有循环读取。解决:先确认工具的缓冲区配置,把两端缓冲区调到 16KB 以上;代码端用循环读取函数,而不是依赖一次 read 拿全:
import time def read_exact(ser, n, timeout=1): 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, timeout=0.1) conn, addr = srv.accept() threading.Thread(target=serial_to_tcp, args=(conn, ser), daemon=True).start() tcp_to_serial(conn, ser)逻辑说明:serial_to_tcp 线程每次循环读一个字节并立即发送,因为虚拟串口的 read(1) 配合 timeout=0.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 实际指向了另一条串口链路。从那以后,我每次换环境、换机器、驱动重装之后,都强制先走一遍上面三步清单,再开始协议联调。这套模拟串口调试思路和资料包里的工具脚本,能帮你避开我踩过的这些坑,希望帮到你。
本文还有配套的精品资源,点击获取