简介:这是《Visual C++/Turbo C串口通信编程实践(第2版)》一书的完整配套源代码,面向学习串口通信编程的VC++6.0开发者及嵌入式爱好者。全书由龚建伟、熊光明编著,电子工业出版社2007年出版,资源包内共有787个文件,大小约16.57MB。其中以147个h头文件、118个cpp源文件、23个dsp及dsw工程文件为主,另含可独立运行的exe、配套ico图标和bmp位图,方便读者直接打开工程查看实现或编译运行验证。内容覆盖常规串口调试助手、基于对话框的通信程序、多线程收发以及Turbo C环境下的示例,适合逐步阅读源码理解串口API调用、协议解析和界面编写方法。该资源已吸引361人学习下载,作为经典教材的原始代码,特别适合入门者对照书籍逐章练习,也适合开发者作为串口通信模块的设计参考。
1. 这本书的源代码,今天翻出来还值得逐行读吗
说实话,我第一次拿到《Visual C++/Turbo C串口通信编程实践(第2版)》配套源码时,第一反应是“怎么全是VC++ 6.0工程和Turbo C老文件”。工程文件后缀是.dsw、.dsp,源码里到处是CString和char*混用,注释风格一看就是二十年前工程师的手笔。但如果你现在还在做上位机、嵌入式调试或者工业控制,这本书的源代码反而是我这些年翻得最勤的参考资料之一。
原因很简单:串口通信这套东西,底层几乎没变过。RS-232标准是1960年代定下来的,UART的工作机制到今天还是一样,起始位、数据位、校验位、停止位,4个基本概念吃遍所有场景。书里用Visual C++和Turbo C两条路线分别演示了串口怎么收发数据,恰好覆盖了Windows环境下的API级开发和DOS环境下直接操作寄存器级的开发。对现在做STM32、ESP32这类单片机的同学来说,Turbo C那部分代码几乎可以直接平移成单片机上的串口驱动思路;而Visual C++部分的多线程串口类,换成C#或Qt重写一下,依然能用在工控上位机上。
它的适用范围很明确:刚接触串口通信的入门开发者,想搞懂收发原理而不是只会调用SerialPort控件;被串口丢数据、乱码、打不开设备折磨的调试老手;以及需要把老工程迁移到新版Visual Studio的人。这套源码的价值不是“原样编译运行”,而是“看明白里面每行代码在硬件层面做了什么”。下面我从底层原理、VC++实现、Turbo C实现、源码阅读和踩坑经验五个部分展开聊。
1.1 这套源码的构成与时代背景
第2版比第1版最大的变化,是增加了多线程串口类、更完整的串口调试助手示例,以及一批面向实际项目的通信协议例子。配套源码大致可以分成几块:基于MSComm控件的收发例程、用Win32 API封装的串口类、多线程+事件驱动收发的完整工程,以及Turbo C环境下直接操作8250/16550 UART寄存器的DOS程序。
这些代码有很明显的时代痕迹。MFC工程在VC++ 6.0下编译时,默认字符集是单字节的,很多地方直接用char数组当缓冲区;到了Visual Studio 2022里,工程默认是Unicode,光这个差异就能让老代码编译出一堆C2664错误。另外那个年代的程序员普遍不重视资源释放,new出来的内存经常不delete,在窗口关闭时也不做串口关闭清理。读这套源码的时候不能照着抄,要有“考古式阅读”的心态——先把能用、该用的部分拆出来,再把有年代问题的部分改掉。
2. 串口通信的底层基础:寄存器、帧格式与流控
2.1 一次串口传输到底发生了什么
一条完整的串口发送链路是:CPU把数据写到UART的发送保持寄存器(THR),UART自动在字节前后加上起始位和停止位,根据配置决定要不要插校验位,然后按设定的波特率逐位发送到TX引脚。接收端UART采样RX引脚,剥离起始位/停止位/校验位,把还原出来的字节放进接收缓冲寄存器(RBR),同时置位线路状态寄存器里的“接收数据就绪”标志,触发中断或者等待程序轮询。
所以串口通信本身不复杂,复杂的全是匹配问题:两边波特率得一致,帧格式得一致,流控方式得一致,电平标准也得一致。这就像两个人打电话,都说中文但一个用的是V.90调制解调器、一个用光纤,物理层不通,协议层再好也没用。书里VC++部分重点讲的就是软件层怎么配置这些参数,Turbo C部分则更底层,直接演示了往寄存器里写值来控制UART芯片。
2.2 可编程的寄存器与初始化逻辑
PC上传统的串口基于16550 UART芯片,每个串口占用一组I/O端口地址,配置和收发全靠读写这些寄存器。以COM1为例,基地址是0x3F8,COM2是0x2F8。初始化串口的本质,就是把这组寄存器设置成我们要的工作状态。
| 寄存器名 | 端口偏移 | 作用 |
|---|---|---|
| THR/RBR | 基地址+0 | 发送保持寄存器/接收缓冲寄存器,写发送、读接收 |
| DLL/DLM | 基地址+0/1 | 波特率除数锁存器,需先置位LCR的DLAB位 |
| IER | 基地址+1 | 中断使能寄存器,控制哪些中断被允许 |
| IIR/FCR | 基地址+2 | 中断标识寄存器/FIFO控制寄存器 |
| LCR | 基地址+3 | 线路控制寄存器,设置帧格式和波特率除数锁存 |
| MCR | 基地址+4 | 调制解调器控制寄存器,控制RTS/DTR |
| LSR | 基地址+5 | 线路状态寄存器,查询发送/接收状态 |
| MSR | 基地址+6 | 调制解调器状态寄存器,查询CTS/DSR等引脚状态 |
波特率除数的计算方法是:除数 = 115200 / 目标波特率。比如9600波特率,除数就是12,以十六进制写就是0x0C。写除数之前,必须先把LCR的最高位(DLAB)置1,写完后再恢复LCR配置。这个步骤漏了,波特率配置就会写进别的寄存器,后果是串口能打开但收发全是乱码,而且排查起来非常隐蔽。
2.3 帧格式、波特率与流控的匹配
帧格式常用“数据位-校验位-停止位”描述,比如8N1就是8个数据位、无校验、1个停止位。LCR寄存器用低两位表示数据位长度、第3位表示停止位数量、第4到第5位表示校验模式。8N1对应的LCR值就是0x03。两个设备通信前必须把帧格式和波特率设置完全一致,否则接收端采样时找不到正确的字节边界,乱码是必然的。
流控则是很多人忽略的地方。RS-232有两种流控:硬件流控(RTS/CTS)和软件流控(XON/XOFF)。硬件流控靠引脚电平来控制对方是否继续发送,适合大数据量、高速率传输;软件流控靠发送特定字符来控制,缺点很明显——如果传输的是二进制数据,数据里蹦出一个0x13或0x11就可能被误判成流控命令,导致通信卡死。所以做二进制协议传输时,我强烈建议关闭软件流控,直接用硬件流控或者干脆不用流控,靠协议层的应答机制保证可靠性。
3. Visual C++串口实现的三条路线:控件、API与多线程
3.1 MSComm控件:上手最快,分发最麻烦
书里VC++部分最早出现的例子就是MSComm控件。用法真的很简单:拖个控件到对话框上,设置CommPort属性指定串口号,Settings属性设置波特率和帧格式,比如"9600,n,8,1",然后打开串口,在OnComm事件里处理数据。
// 初始化示例 m_mscomm.SetCommPort(3); // 使用COM3 m_mscomm.SetSettings(_T("9600,n,8,1")); m_mscomm.SetInputMode(1); // 1表示二进制模式 m_mscomm.SetRThreshold(1); // 接收缓冲区每有1个字节就触发OnComm m_mscomm.SetInputLen(0); m_mscomm.SetPortOpen(TRUE); // 打开串口 // 接收事件 void CMyDlg::OnCommMscomm() { VARIANT variant_input = m_mscomm.GetInput(); // 从variant_input中提取字节 }好处是代码量极低,适合快速验证硬件。坏处也很现实:MSComm是ActiveX控件,部署到别的机器上要注册mscomm32.ocx,64位系统还要注意注册到SysWOW64目录,否则控件创建失败。另外MSComm的接收机制本质上是事件驱动的缓冲区回调,数据量一大、事件一多,MFC消息泵忙不过来时丢数据非常常见。所以我用它做教学演示可以,但做正式项目我会绕开它。
3.2 Win32 API封装类:适合学习底层
书里真正有价值的是基于Win32 API封装的串口类。核心流程就是:CreateFile打开串口设备,GetCommState拿到当前DCB,修改DCB里的波特率、数据位、校验位、停止位,SetCommState写回去,然后SetupComm分配收发缓冲区,SetCommTimeouts设置超时,最后用ReadFile和WriteFile收发数据。
HANDLE hCom = CreateFile(_T("\\\\.\\COM3"), GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL); if (hCom == INVALID_HANDLE_VALUE) { // 检查GetLastError,通常是ERROR_ACCESS_DENIED表示串口被占用 } DCB dcb; memset(&dcb, 0, sizeof(dcb)); dcb.DCBlength = sizeof(dcb); GetCommState(hCom, &dcb); dcb.BaudRate = 9600; dcb.ByteSize = 8; dcb.Parity = NOPARITY; dcb.StopBits = ONESTOPBIT; SetCommState(hCom, &dcb);这套流程到今天依然适用,C#的SerialPort底层、Qt的serialport模块底层,干的事和这段代码一模一样。区别只在API的壳。所以认真看懂书里的CSerialPort类,对你后头用任何语言写串口程序都有帮助。我见过很多工作三五年的人,调串口只会用现成控件或库,一旦遇到库存版本差异、平台差异就抓瞎,就是因为没看过这一层。
3.3 多线程加事件驱动:工程化的正确姿势
书里第2版增加的多线程串口类,是整本书技术含量最高的部分。设计思路是:创建一个专门的工作线程,线程里用WaitCommEvent等待串口事件,收到EV_RXCHAR事件后用ReadFile读数据,再把读到的内容通过自定义消息发给主界面;发送则是主线程直接WriteFile,或者通过队列让工作线程去发。
我觉得这套设计在工控上位机里依然是标准答案。它避免了MSComm控件那种消息驱动的天花板:不占主线程的消息泵,接收缓冲区被及时读取,数据量大时也不容易丢。唯一的门槛是要处理好界面跨线程更新问题。书里用得比较多的方法是PostMessage,把收到的字节打包成结构体指针传过去;实际操作中要注意消息参数的释放,不然用PostMessage投递的指针在主窗口还没处理的时候又被第二次收到的数据覆盖了,就会出现偶发性乱码或崩溃。
3.4 老VC工程迁到新版Visual Studio的改造清单
如果你把书里VC++ 6.0的源码直接塞进Visual Studio 2022,大概率报错。我自己迁过一次,总结了一份必改清单:
- 工程文件重新生成:.dsw/.dsp是老格式,VS2022不认,用“打开”时会自动转成.sln,转完编译一堆错是正常的,一个一个来。
- 字符集问题:把工程的“字符集”从Unicode改成“使用多字节字符集”。老代码基本按char和CString单字节思路写的,不改的话所有CString转char*的地方都会爆C2440。
- 头文件引用:老代码喜欢#include "stdafx.h",新VS默认生成的是pch.h,要么改代码,要么在预编译头设置里把路径指过去。
- 过时的API:比如StdAfx.h里可能用到的一些库,换成Windows.h就行;GdiTransparentBlt这类老接口如果有用到,改成TransparentBlt。
- 安全函数警告:老代码里的sprintf、strcpy在VS2022下会报C4996,我习惯在预处理器里加_CRT_SECURE_NO_WARNINGS,简单,也不影响学习。
经过这些改造之后,书里的串口API封装类基本上能跑在新版VS上,实测在Win10、Win11下收发正常。
4. Turbo C下直接操作硬件寄存器的老派做法
4.1 为什么DOS时代只能操作寄存器
如果你接触编程的时间不够早,可能不理解写串口为什么要操作寄存器。DOS环境下没有Windows那种统一抽象的串口API,应用程序想用串口,只有两条路:调用BIOS的INT 14H中断,或者绕过BIOS直接访问UART芯片的寄存器。BIOS中断功能有限,波特率只支持到9600,很多扩展功能也做不了,所以真正的性能敏感或功能完整的程序都是直接操作寄存器。
Turbo C的inp/outp函数就是为这种硬件访问提供的基础能力。这种方式的好处是让你把串口看得清清楚楚:每个字节怎么进FIFO、中断怎么产生、状态位怎么变化,全都暴露在面前。坏处是要自己处理很多边界条件和硬件时序,一个寄存器配置错,整个串口就废了。不过恰恰是这种“手写驱动”的经历,能把串口原理吃透。
4.2 初始化代码的骨架与关键参数
下面是一段简化的COM1初始化代码,波特率9600,8N1格式,开启接收中断,在Turbo C里可以直接编译:
#define COM1_BASE 0x3F8 #define LCR 0x03 #define DLL 0x00 #define DLM 0x01 #define THR 0x00 #define RBR 0x00 #define IER 0x01 #define IIR 0x02 #define FCR 0x02 #define LSR 0x05 void uart_init(void) { outportb(COM1_BASE + LCR, 0x80); /* 置DLAB,准备写波特率除数 */ outportb(COM1_BASE + DLL, 0x0C); /* 115200 / 9600 = 12 */ outportb(COM1_BASE + DLM, 0x00); outportb(COM1_BASE + LCR, 0x03); /* 8N1,同时清除DLAB */ outportb(COM1_BASE + FCR, 0x07); /* 启用FIFO并清空 */ outportb(COM1_BASE + IER, 0x01); /* 允许接收数据就绪中断 */ }这段代码的逻辑放到单片机上完全适用,只要把outportb换成寄存器地址赋值就行。我在STM32上写串口驱动时,脑海里对应的就是这套流程:先配波特率、再配帧格式、再配中断使能,只是寄存器的名字从LCR变成了CR1、CR2而已。
4.3 中断服务程序与环形缓冲区
DOS下写串口中断服务程序,要挂中断向量。COM1的IRQ是IRQ4,对应中断向量号是0x0C;COM2是IRQ3,对应0x0B。挂接中断前要关中断保护,收完数据后要向8259A中断控制器发EOI命令:
outportb(0x20, 0x20); /* 发送EOI到主8259 */中断服务程序里面用环形缓冲区收数据,是一个很经典的设计。中断每次触发,检查线路状态寄存器LSR有没有接收就绪位,有就读RBR,写进缓冲区,移动写指针;主程序只读缓冲区,移动读指针,判断空和满。这里最容易出的问题有两个:一是缓冲区大小只有256字节,处理不及时就会溢出;二是在中断服务程序里调用了printf或memcpy这类非重入函数,导致程序跑飞。书里那段环形缓冲区代码是精华,建议抄到自己的项目里。
5. 源码怎么读、怎么移植到自己的项目
5.1 推荐的阅读顺序
拿到整套源码,我建议不要按书的目录顺序读,而是按我总结的这条路线:
- 先读Turbo C那部分最简单的轮询收发例子,搞清楚波特率、帧格式在寄存器层面到底怎么配置。
- 再读VC++部分最简单的MSComm收发例程,体会Windows下串口编程的“配置参数-打开-事件回调-收发”框架。
- 然后读Win32 API封装类,理解同样的配置在API层是怎么实现的,重点关注DCB结构体和超时设置。
- 最后读多线程串口类,学习WaitCommEvent加事件驱动的工作线程写法。
这套顺序的本质是从底向上,从寄存器到API再到线程模型,每一步都在解释上一步“为什么那么写”。如果你反过来先读多线程代码,大概率被线程同步和消息传递绕晕,碰到问题也不知道回哪里查。
5.2 二次开发时的重构切入点
把书里代码用到自己的项目时,直接改比重新写快得多。我会做这几个重构动作:
- 把接收线程的裸数据改成回调函数或事件接口,界面层订阅数据事件,而不是靠PostMessage满天飞,这样数据可以同时送给协议解析模块和显示模块。
- 把单个串口实例化改成串口管理器,多串口设备场景下每个串口一个线程实例,统一管理,这在工业设备上下位机通信时非常实用。
- 把协议解析从UI代码里剥离出来,书里的例子经常是收到数据直接在OnReceive里处理,正式项目应拆成一个带状态机的协议层,不然帧校验、粘包拆包逻辑和界面代码混在一起,后期没法维护。
- 引入数据队列,发送命令多的时候用异步队列,避免多个线程同时调用WriteFile导致数据交错。
5.3 老代码里常见的“时间炸弹”
读这套源码时会碰到几个很有年代感的问题,我提一下,避免你踩坑:
- 裸指针和内存泄漏:MFC代码里new出来的对象在窗口析构时没delete的不少,长时间跑内存只增不减。移植的时候顺手改成智能指针,特别是CString和char*混用产生的临时缓冲区泄漏。
- 硬编码串口号:老代码里经常直接写死COM1、COM2。现在的电脑USB转串口经常是COM5、COM7甚至更高,建议改成枚举现有串口让用户选择。
- 缓冲区大小固定:书里例子的接收缓冲常用1024或4096字节,高帧率大流量下会溢出丢数。移植时按实际波特率和协议帧长重新估算,我一般至少给4KB,配合FIFO使用。
- 没有超时处理:API版的例子有时候ReadFile不设超时或超时时间设置不合理,一旦对方不回复,程序就卡死在读线程里。SetCommTimeouts的ReadIntervalTimeout设为50毫秒,总超时用MAXDWORD,是比较稳的组合。
6. 串口调试踩坑实录:从乱码到丢数据
6.1 打不开串口:权限与占用问题
串口程序跑不起来的首要原因不是代码,是串口根本没打开成功。CreateFile返回INVALID_HANDLE_VALUE时,旧工程师第一反应是查参数,我第一反应是查GetLastError。实测下来最常见的是ERROR_ACCESS_DENIED(错误码5),意思是串口已经被别的程序占用了。Windows的串口默认不支持多进程同时打开,调试助手开着没关,你的程序再打开肯定失败。
设备管理器和注册表里的COM编号还有一种情况:USB转串口设备拔插后Windows会记住老编号,设备管理器里看着是COM3,实际可能被系统分配到了高位编号。我建议写代码时串口号别写死,枚举的时候带上USB VID/PID信息显示,很多调试痛苦都是“配置的COM号和实际设备不对应”造成的。
6.2 乱码与丢帧的常见根因
乱码的排查路径我一般固定按下面这张表走:
| 现象特征 | 优先排查方向 | 常见根因 |
|---|---|---|
| 全部乱码,偶尔蹦出正确字符 | 波特率 | 发送端和接收端波特率不一致,或除数寄存器没正确锁存 |
| 字符串前面正常,后面越来越乱 | 帧格式 | 数据位或校验位设置不一致,停止位数量不对 |
| 偶尔丢一个字符或整帧丢失 | 缓冲区溢出 | 接收缓冲太小,RThreshold事件阈值过高 |
| 收发一多就卡死 | 流控 | 打开了XON/XOFF但传输二进制数据,0x13/0x11被误判 |
| 10分钟内必定乱码一次 | USB转串口芯片海量发送 | 芯片缓存不足,需要流控或降低波特率 |
这里补一个亲身经历:有个项目在Windows 7上一切正常,换到Windows 10后同一套代码偶发乱码。后来发现是USB转串口芯片驱动变了,新驱动默认把FIFO开到最大,而我的接收线程用WaitCommEvent等事件,结果批量数据一次性灌进来,ReadFile一次没读完,剩下的数据留在系统缓存里跟下次数据粘包。解决方式是收到事件后循环ReadFile直到缓冲区读空,而不是读一次就完事。
6.3 USB转串口芯片带来的兼容性差异
现在做串口调试,真正面对的不再是主板上物理COM口,大概率是CH340、FTDI FT232、CP210x这类USB转串口芯片。它们在基本收发上都是标准的UART行为,但细节差异还是很影响体验的:
- FTDI的驱动性能最好,缓存也大,高速传输首选。
- CH340兼容性不错,价格便宜,但芯片本身的FIFO比较小,19200以下波特率稳定,高速大数据流偶尔会丢。
- CP2102功耗低,但驱动对某些国产系统的适配没有CH340那样无脑,流控引脚的默认状态各家也不一样。
所以我现在的习惯是:任何串口程序的头版逻辑里都加一个“串口自检”功能,打开后读取Modem状态寄存器,返回真实电气状态,然后让上位机回一个特定帧,通不通一目了然。如果怀疑是芯片差异,就用逻辑分析仪抓波形,波形对就是软件问题,波形不对才去查硬件电气连接。
最后分享一个我个人的体会:书里这套源码的真正价值不在于某个类直接拿来用,而在于它把串口通信从“叫几下API就完事”的层面,拉到了“你知道每秒有几万比特在线上流、每个字节在哪个寄存器里进出”的层面。啃完这些代码之后再回去用任何语言写串口,心里都有一张完整的底层的图。这套旧书,尤其第2版里新增的线程和协议部分,在我看来值得至少通读两遍,第二遍最好是在你被串口丢数据折腾到想摔设备的时候再读。
本文还有配套的精品资源,点击获取