资讯详情

资讯详情

串口通信程序实战:从UART原理到上位机与下位机联调

简介这是一份经过实际项目验证的串口通信程序工程包面向工业自动化、物联网及嵌入式开发人员解决PLC与上位机、下位机之间基于RS-232协议的双向数据交换问题。资源共22个文件以C源文件.cpp和头文件.h为主另含Visual Studio工程配置.sln、.vcxproj及对话框界面资源压缩包仅137KB结构紧凑便于直接打开、编译和二次开发。目前已有589人学习与下载。程序涵盖串口打开、波特率/数据位/停止位/校验位参数配置、数据发送与接收、错误处理等核心流程并配有基于MFC的对话框程序示例适合学习串口编程和上位机界面开发也可作为实际项目的通信基础模块。 串口通信这个活儿你说它老吧工业现场、嵌入式开发、设备调试里它还是无处不在你说它简单吧我上周刚帮一个同事排查了一个串口乱码问题折腾了两个小时最后发现是USB转串口芯片的驱动版本太老。写串口通信程序难点从来不在打开端口、发数据、收数据这三板斧而在于你没看到的那些物理层细节、时序问题、电平标准和工具链配合。这篇文章我就把串口通信程序从原理到实战从宿主机到虚拟机从Python上位机到STM32和51单片机下位机完整拆一遍把我这些年踩过的坑和验证过的方案都写出来给正要入门或者已经被串口折磨过的朋友做个参考。1. 串口通信程序到底在解决什么问题1.1 现在都无线了为什么还要碰串口很多人第一次接触串口是大学里玩51单片机或者STM32用一根USB转TTL的线把开发板连到电脑然后在串口助手里看到 Hello World 那一刻挺兴奋的。但真正工作之后你会发现串口这东西的生命力比想象中强得多。举几个我实际接触过的场景工业控制柜里的PLC和触摸屏之间走的是串口协议老式称重仪表、扫码枪、打印机十有八九支持串口通信嵌入式设备的调试串口是系统起不来时唯一的救命通道甚至很多物联网网关的本地配置口本质上也就是一个串口。技术迭代了几十年无线通信、CAN总线、以太网都在抢地盘但串口因为协议简单、实现成本低、抗干扰能力在短距离内足够可靠至今仍然是最底层的保底通信方式。所以串口通信程序这个需求覆盖的其实是这样一个范围上位机软件PC端怎么通过串口和外部设备交换数据下位机单片机/嵌入式怎么解析和执行上位机的指令中间还夹着各种转发、隔离、虚拟化链路。搞懂这一整条链路你才算是真的会写串口程序。1.2 串口程序的典型应用场景按我个人的经验串口通信程序大致可以分成三个层面的需求调试型设备输出日志上位机接收显示。最常见的是嵌入式开发时通过串口打印调试信息这时候程序只要能收就行对协议要求不高。控制型上位机发送指令下位机执行动作并返回状态。比如控制云台转动、设置传感器参数、读取仪表数据这种场景对指令格式、应答超时、校验机制都有要求。数据采集型设备周期性上报数据上位机需要持续接收、解析、存储。比如GPS模块、温湿度采集器、心率传感器这种场景对丢包率、粘包处理和缓冲区设计要求比较高。这三种场景对程序的复杂度要求差别很大但底层机制是一样的。我下面讲的很多东西都是围绕这三类需求展开的你在实际项目里可以先判断自己属于哪一类再决定要花多少精力在协议设计上。2. 写程序前必须搞清楚的UART帧格式和电平标准2.1 一帧数据是怎么发出去的串口通信程序本质上是把你要发的数据交给UART外设由UART按约定好的时序把每个字节拆成帧发出去。一个标准的UART数据帧长这样首先是1个起始位低电平然后是5到8个数据位低位在前接着是可选的1个校验位最后是1个或2个停止位高电平。最常见的配置是8-N-1也就是8个数据位、无校验、1个停止位。这个概念看起来简单但很多乱码问题就出在收发双方的帧格式配置不一致上。我曾经遇到过一台设备默认是8-E-1偶校验我按8-N-1去收结果每个字节都错位显示出来全是乱码排查了很久才想到看设备手册。还有一个关键参数是波特率它决定了每秒钟传输多少个位单位是bps。9600、115200是串口世界最常用的两个档位。注意收发双方的波特率误差不能超过一定范围否则就会出现间歇性乱码。具体误差容忍度和采样点数有关一般要求在±2%以内比较保险但如果你想长期稳定跑最好把误差控制在±1%以下。2.2 TTL、RS232、RS485三种电平别搞混这是新手最容易栽跟头的地方。同样是串口物理层还有三种主流标准。TTL电平0V表示逻辑03.3V或5V表示逻辑1。单片机引脚、USB转TTL模块输出的就是这种电平传输距离一般也就几十厘米。RS232电平用正负电压表示逻辑一般是-3V到-15V表示逻辑13V到15V表示逻辑0是反逻辑的。DB9接口的电脑串口就是RS232电平传输距离能到15米左右。RS485电平用两根线A/B的差分电压表示数据抗共模干扰能力强传输距离能到1200米工业现场最常用但它是半双工的。写程序的时候你得先搞清楚设备的串口到底输出的是哪种电平。电脑的DB9串口和单片机之间不能直接连中间必须加MAX232这类电平转换芯片USB转串口线也分USB转TTL和USB转RS232两种买错了连上要么没反应要么可能烧芯片。我见过不止一次有人把TTL线直接接到RS232设备上结果收发都不正常还以为是程序写错了。2.3 波特率误差怎么计算才稳妥说到波特率误差这里给个具体的计算方法。以51单片机常用的方式为例如果用定时器1作为波特率发生器工作在8位自动重装模式波特率计算公式是波特率 (2^SMOD / 32) × (晶振频率 / (12 × (256 - TH1)))假设晶振频率是11.0592MHzSMOD0想要9600波特率反推得到TH10xFD。为什么大家做51单片机串口都喜欢用11.0592MHz的晶振因为这个频率能把9600、19200、115200这些常用波特率整除得很干净误差为0。如果你用了12MHz晶振算出来的TH1只能取整实际波特率和目标值之间就有误差积累多了数据就容易出错。STM32那边的情况好一些因为STM32的串口时钟来自APB总线时钟树配置灵活配合PCLK1或PCLK2的分频可以做到很低的误差。但也不是完全不用管尤其当你把系统主频超频或者改了时钟配置之后最好用逻辑分析仪实测一下实际波特率别等到通信不稳定了再回头查。3. Python串口程序实战从安装pyserial到组一个能用的工具3.1 端口枚举与参数配置写上位机串口程序我首选Python因为这个生态里pyserial库足够成熟跨平台表现也稳定。安装就是一条命令的事pip install pyserial拿到一个串口设备第一步不是直接收发而是先枚举出设备对应的端口名。Windows上通常是COM3、COM4这种Linux上通常是/dev/ttyUSB0、/dev/ttyS0。这里有个很实用的技巧用下面的代码可以列出所有可用串口并顺便读取设备描述信息import serial.tools.list_ports ports serial.tools.list_ports.comports() for port in ports: print(port.device, port.description, port.hwid)在Windows上通过hwid里的VID和PID你能看出来这个USB转串口用的是CH340还是CP2102还是FT232的芯片这对后面判断驱动问题非常有用。我之前遇到过一台工控机设备管理器里能看到COM口但Python打开时报权限错误排查一圈发现是另一个软件后台占用了这个串口用这个枚举方法配合设备管理器才定位到元凶。打开串口的参数配置看起来就是几行代码的事import serial ser serial.Serial( portCOM3, baudrate115200, bytesizeserial.EIGHTBITS, parityserial.PARITY_NONE, stopbitsserial.STOPBITS_ONE, timeout1, write_timeout1 )注意timeout这个参数很多人不重视但它是串口程序不卡死的关键。读超时设成1秒意味着如果缓冲区里没有数据最多等1秒就返回这样你的主循环不会被阻塞住。3.2 串口读取的正确姿势线程超时缓冲区串口程序最常见的架构问题是在主线程里死循环读数据一旦读到一半出现异常整个界面或者业务逻辑就卡住了。我自己的做法是专门开一个读取线程把收到的原始数据放到一个队列里主逻辑从这个队列取数据解析。import threading import queue import serial rx_queue queue.Queue() def read_serial(ser): while True: try: if ser.in_waiting 0: data ser.read(ser.in_waiting) rx_queue.put(data) else: # 没有数据就歇一会避免空转 threading.Event().wait(0.01) except serial.SerialException as e: print(f串口异常: {e}) break # 启动读取线程 read_thread threading.Thread(targetread_serial, args(ser,), daemonTrue) read_thread.start()这里有个细节用ser.in_waiting配合ser.read(n)一次性把缓冲区里已有的数据全部读出来比每次只读一个字节要好很多因为串口的数据是连续到达的一次读多个可以减少系统调用次数降低丢数据概率。但注意这个方案只解决了读取层面的问题还没解决粘包问题。串口本身没有包的概念它只是一条字节流。设备发送10个字节你的程序可能一次收到10个也可能先收到3个再收到7个这取决于系统调度。所以真正的解析逻辑不能依赖一次读完整包而要靠你自己定义的帧协议来判断这个下面细说。3.3 自定义协议的封包与CRC校验如果把串口比作一条快递运输线那UART只负责把每个包裹安全送到但包裹里装的东西对不对、完整不完整得你自己定义规则。我的建议是哪怕只是自己做的调试工具也尽量用一个简单可靠的帧格式比如帧头(0xAA 0x55) 长度(1字节) 命令字(1字节) 数据(N字节) CRC16(2字节)帧头用来找包的起点长度用来确定这一帧该有多长CRC16用来校验数据有没有传错。解析端维护一个接收缓冲区不断从里面按这个格式切包def parse_packet(buffer): # 找帧头 while len(buffer) 2: if buffer[0] 0xAA and buffer[1] 0x55: break buffer.pop(0) if len(buffer) 4: return None length buffer[2] total 4 length 2 # 帧头2 长度1 数据 CRC2 if len(buffer) total: return None packet bytes(buffer[:total]) del buffer[:total] payload packet[3:-2] crc int.from_bytes(packet[-2:], big) if crc ! crc16(payload): print(CRC校验失败) return None return packetCRC16的实现网上有很多现成代码查表法最快。我习惯用Modbus CRC16算法因为它在工业设备里用得太多了很多仪表、采集模块都支持Modbus协议会这一套以后兼容性会好很多。核心原则是宁可丢掉一个校验失败的包也不要把它当成有效数据去处理否则一个错包可能会导致整个设备状态错乱。4. 嵌入式端串口收发STM32和51单片机的典型写法4.1 STM32用HAL库做中断接收上位机写好了下位机也得给力。STM32是目前最主流的嵌入式平台用HAL库写串口接收思路和裸机轮询不太一样。我推荐的做法是中断接收一个字节一个字节地收每收一个字节进一次中断在中断回调里把字节放进自己的缓冲区。uint8_t rx_byte; uint8_t rx_buffer[256]; volatile uint16_t rx_index 0; // 启动接收中断接收1个字节 HAL_UART_Receive_IT(huart1, rx_byte, 1); // 中断回调 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { rx_buffer[rx_index] rx_byte; // 简单防溢出 if (rx_index 256) rx_index 0; // 重新开启接收 HAL_UART_Receive_IT(huart1, rx_byte, 1); } }这里有个非常重要的细节别漏了使用HAL_UART_Receive_IT接收完成后必须再次调用它否则外设只接收一次就停了后续数据进不来。这个坑坑过很多刚接触HAL库的初学者。另外中断回调里的代码要尽量精简不要在中断里做耗时的解析操作更不要在中断里调用HAL_UART_Transmit去回数据阻塞发送会拖死中断响应。正确的做法是把收到的数据放进环形缓冲区在主循环里做解析和回应。我见过不少人把复杂的协议解析写在中断里结果中断耗时太长直接影响了其他外设的实时性得不偿失。4.2 51单片机串口驱动LCD1602显示51单片机虽然老但教学和简单控制场景里用量还是很大。串口配合LCD1602显示是个很经典的组合常见于各种课设和简易设备。基本思路是串口中断接收上位机发来的字符主循环里把收到的字符刷新到LCD1602上。void UART_Init(void) { SCON 0x50; // 模式18位数据允许接收 TMOD 0x20; // 定时器1工作在模式28位自动重装 TH1 0xFD; // 11.0592MHz晶振9600波特率 TL1 0xFD; TR1 1; // 启动定时器1 ES 1; // 使能串口中断 EA 1; // 使能总中断 } void UART_ISR(void) interrupt 4 { if (RI) { RI 0; received_char SBUF; // 读取接收到的字符 display_flag 1; // 设置标志位主循环处理显示 } }注意51单片机的串口中断里RI和TI都要手动清零这一点和STM32的HAL库不一样。另外SBUF这个寄存器读它就是接收数据写它就是发送数据同一个地址读写是两个不同的物理寄存器这个设计让很多刚入门的人懵过。LCD1602的显示要做的事情是维护一个字符数组作为屏幕缓冲区每次接收到新字符就追加到缓冲区然后整屏刷新。如果只是简单显示可以只在收到\n时刷新一次这样效率高一些。更复杂一点的做法是支持上下翻页、清屏指令那就要定义自己的串口协议了比如收到0x01表示清屏收到0x02表示第二行显示等等。4.3 波特率和晶振的关系51的坑上面代码里用了0xFD这个初值前提是晶振必须是11.0592MHz。如果开发板上用的是12MHz晶振同样要9600波特率算出来的TH1初值就是0xE8附近但实际产生的是精确的9600吗不是误差大概有0.16%短帧通信问题不大但长帧或者要求高的场合就会出乱码。所以我的建议是玩51单片机串口直接用11.0592MHz晶振所有常用波特率零误差省去一堆麻烦。如果你用的是某些开发板自带的12MHz晶振那就老老实实把波特率设成4800或者2400这两个值用12MHz也能做到误差比较小通信稳定性反而比硬凑9600更好。这里也顺便回答很多新手的问题为什么别人的例程里TH10xFD能出9600我照抄却乱码先检查晶振频率多半是这里不一样。5. 宿主机Windows与VMware里Linux的串口互通5.1 VMware串口三种连接方式怎么选这个场景我猜是看了热搜词宿主机windows如何通过串口与vmware中linux通信后最想知道的重点。做嵌入式开发的人经常在Windows上跑虚拟机里面装Linux来交叉编译、跑调试脚本但虚拟机里的Linux怎么访问宿主机上的串口设备确实是个拦路虎。VMware Workstation里面打开虚拟机的虚拟机设置添加串行端口有三种连接方式使用物理串口直接把宿主机的一个COM口映射给虚拟机。如果宿主机插了一个USB转串口设备在列表里选中它就行虚拟机里的Linux会把它识别成/dev/ttyS0或者/dev/ttyUSB0。使用命名管道宿主机和虚拟机通过一个管道文件通信这种方式不需要物理串口硬件适合宿主机上另一个程序和虚拟机里的Linux做虚拟串口会话。输出到文件把虚拟机串口输出写到一个文件里主要用于调试内核启动日志不涉及双向通信。大部分实际需求用物理串口直通就够了配置最简单。但有一个坑宿主机上这个COM口一旦被映射给了虚拟机宿主机自己的程序就不能再开这个串口了会冲突。5.2 命名管道方式的具体配置步骤如果你不想占用物理串口想在Windows宿主机上跑一个Python程序和虚拟机里的Linux通过虚拟串口通信那就用命名管道。配置步骤如下第一步在虚拟机设置里添加串行端口连接方式选择使用命名管道。管道命名Windows下一般是\\.\pipe\com_1然后设置该端是服务器和另一端是应用程序。第二步为了让管道另一端能被宿主机程序访问你需要一个工具把命名管道映射成一个虚拟COM口。常用的方案是用com0com这类虚拟串口软件创建一对互联的COM口比如CNCA和CNCB再用一个管道转发工具把\\.\pipe\com_1和CNCA对接起来。这一步本质上是做了一个数据桥让Windows应用感觉自己在操作一个普通串口。第三步虚拟机里的Linux启动之后在虚拟机设置里看到串口已经连接Linux系统里一般会多出/dev/ttyS0这个设备。可以用dmesg | grep tty确认内核是否识别到。第四步Linux侧用minicom或者我们自己写的Python程序打开/dev/ttyS0宿主机侧用Python打开虚拟出来的COM口比如COM3两边就能通信了。这个配置第一次做确实繁琐但好处是它不需要任何物理硬件纯粹在软件层面模拟了一条串口链路特别适合在开发机上模拟上位机-下位机的通信过程。5.3 打通之后虚拟机里看不到串口怎么办根据我的经验这四步走完虚拟机里还是没有串口设备原因基本是以下几种虚拟机设置里串口没有勾选启动时连接或者添加的是输出到文件而不是命名管道/物理串口。物理串口模式下宿主机上这个COM口被其他程序占用了。自己检查一下是不是开着串口助手没关。命名管道模式下管道名称两端的服务器/客户端角色设置反了也会导致连接不上。Linux内核没开启8250串口支持。VMware虚拟出来的串口在Linux下通常走的是8250/16550驱动有些精简内核把CONFIG_SERIAL_8250关了设备节点就不会出现。排查的时候按照这个顺序捋一遍基本都能解决。我不止一次帮人远程看这个问题最后都是启动时连接没勾选这种低级原因。6. 串口调试的排查链路和常用工具6.1 乱码的第一排查顺序串口通信出乱码是大家问得最多的问题。我的排查顺序基本是固定的按概率从高到低来波特率不一致。这个占总乱码问题的六成以上。先确认设备到底是多少波特率别凭感觉看手册或者用逻辑分析仪抓一下。数据格式不匹配。8-N-1和8-E-1的区别乱码表现很接近容易忽略。收发接反。TXD接TXD、RXD接RXD是错的必须交叉连接。这个错一般不是乱码而是完全收不到数据但如果是半双工的RS485接反会出现既有乱码又时通时断的诡异现象。电平标准不匹配。TTL设备接RS232设备或者接RS485设备都需要转换器。地线没共地。USB转串口和单片机模块都从USB取电如果两边供电隔离地电位不一致数据线就可能出现随机错误。提到地线这是个非常容易被忽略的问题。我用一个外接电源给单片机系统供电、但USB转TTL模块从电脑取电的时候如果两个电源的地没有接在一起串口数据就会飘经常收到一些莫名其妙的字节。解决办法很简单把两边的GND连在一起就行。6.2 USB转串口芯片的选择写串口程序的人手边一定得有几根靠谱的USB转串口线。芯片选择上我按推荐程度排个序芯片特点适用场景CP2102免驱系统自带驱动日常调试首选CH340便宜国产需要装驱动开发板自带最常FT232稳定兼容性好略贵工业现场、长时间运行CH34x老版本驱动兼容性差尽量避免容易出奇怪问题很多人遇到串口打开失败或者收不到数据其实是USB转串口线的芯片驱动没装好。判断方法很简单Windows设备管理器里如果能看到这个COM口说明驱动正常如果显示黄色感叹号说明驱动有问题。CH340芯片在Windows 10以后系统基本自带驱动但如果你的线是某些山寨方案芯片型号可能被磨掉或者乱标这种情况建议直接换一根大厂的线省得排查浪费时间。6.3 那些让人抓狂的隐性故障开发串口程序时间长了你会遇到一些文档里查不到的隐性故障。这里分享几个我印象最深的。一个是缓冲区溢出导致的看似随机丢失。有些设备的串口发送缓冲区很小上位机读得慢或者主循环里解析耗时太长下位机已经把数据发完了缓冲区被新数据覆盖丢的就是旧数据。这种问题表现得很像偶尔丢一帧很难复现。解决办法是下位机发送时做流控或者加帧间隔上位机则确保读取线程优先级足够高。另一个是中文编码问题。有的设备发的是GBK编码的汉字你按UTF-8去解码显示出来就是乱码。这个和通信本身的乱码不是一回事但看起来很像。我建议上位机程序里做一个编码选择功能GBK、UTF-8、ASCII都列出来排查问题的时候切换一下一秒就能判断是不是编码问题。还有一个很经典的是串口助手和自研程序抢端口。你开着串口助手测试完忘了关闭再打开自己写的Python程序就会报端口被占用。这个不算什么技术难题但每周都有人问所以我拿出来提醒一下。调试工具方面我的工作台上常备三样串口助手里最常用的是开源的支持HEX显示和定时发送逻辑分析仪用来抓UART波形查看真实波特率和帧格式再就是虚拟串口工具主要是模拟没有硬件时的调试环境。说实话逻辑分析仪这个东西虽然很多人觉得是硬件工程师才用的但我们写软件的人在排查诡异串口问题时用它抓一次波形就能直接看到数据位、停止位到底对不对比瞎猜快十倍。7. 串口程序设计的最后几个建议我在用串口通信生产线上的经验有一条特别想说不管你的程序以后跑在上位机还是下位机从一开始就要把日志和异常处理当成正式功能来做而不是调试完就删掉。串口通信天生是数据和时序敏感的场景没有日志出问题的时候你只能看着屏幕发呆。另外关于参数配置尽量把波特率、数据位、校验位、超时时间都做成可配置的哪怕你心里知道默认就是9600,8-N-1。因为设备千奇百怪换一种设备就要改一遍参数如果你把配置硬编码在代码里每次都要重新编译。我自己的工具都是把这些参数放在一个配置文件里命令行也能临时指定开发和现场联调都省事不少。还有一个小技巧发送数据时尽量对特殊命令做一个确认发送机制特别是控制型指令。下位机收到指令后返回一个应答包上位机等不到应答就重发或者报警。这个机制能挡掉很多因为线松了、设备重启导致的通信中断问题。串口程序在所有通信程序里算是最容易上手的但想写得稳、跑得好需要照顾到的细节一点都不少。我这篇里写的这些内容都是反复踩过坑之后沉淀下来的希望能帮你少走一些弯路。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →