简介一套基于C Builder实现的串行通信示例工程面向通讯编程初学者或需要快速搭建两机串口通信的开发者。工程演示了利用TSerialPort组件完成串口初始化、参数配置、数据收发与错误处理的全过程并配有操作界面便于观察交互效果。压缩包共33个文件大小1.71MB主要包含Comm、RecThread等单元的C源码.cpp/.h、工程文件.bpr/.ddp/.dfm、编译生成的程序.exe以及用户说明书.txt也保留了obj、tds、res等中间文件适合对照分析构建流程。目前已有125人学习下载作为入门串口通信的参考项目具有实用价值。通过阅读源码和运行程序可以掌握TSerialPort组件属性设置与读写方法了解异步线程接收数据的处理思路并能将界面控件与通信逻辑结合为开发更复杂的网络通讯程序打下基础。1. 通讯编程里的“translate”为什么 C Builder 还在做协议转换车间里有一台老式仪表只留了一个 RS485 串口而采集服务器只要 TCP 网络数据。中间需要一个“翻译官”从串口读字节按协议拆出温度、压力、状态位再重新组包发给服务器。这类项目在很多交接记录里就叫 translate压缩包名就叫 translate.rar里面通常是 C Builder 写的一整套通讯编程源码。C Builder 到今天仍适合做这种 Windows 桌面协议转换工具因为 VCL 组件把串口、Socket、界面、定时器串成一条链一两天就能给你一个带界面的可执行程序。这里不是讲网络分层理论而是走一遍我用 C Builder 落地这类工具时的完整路径组件怎么选、线程怎么排、拆包状态机怎么写以及那些不跑到现场根本撞不上的坑。2. 拆解 translate 的核心串口与网络通信的选型和架构2.1 C Builder 下串口通信的三种常见方案C Builder 本身不提供串口组件所以第一步就是选方案。最常见的做法有三条路我用一个对比表先说明白。方案优点缺点适用场景MSComm ActiveX老工程常用拖上去就能用需要注册 ocx64 位和新系统兼容性差维护旧代码临时工具TComPort 等 VCL 组件事件驱动读写封装完整代码量少依赖 VCL控制台程序用起来别扭带窗体的桌面工具Win32 API CreateFile无依赖完全可控线程、缓冲、错误处理全要自己写后台服务、对稳定性要求高的程序我实际项目里的选择标准很简单程序有界面优先用 TComPort 这类组件目标是无人值守的后台服务就直接写 API避免第三方组件在无窗口环境里的初始化问题。本质上所有方案都走同一个串口驱动差别只在事件通知和缓冲模型。以 API 方案为例打开串口并设置参数的关键代码长这样HANDLE hCom CreateFile(L\\\\.\\COM3, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); if (hCom INVALID_HANDLE_VALUE) { // 打开失败检查端口号、占用或驱动问题 } DCB dcb {0}; dcb.DCBlength sizeof(DCB); GetCommState(hCom, dcb); dcb.BaudRate 9600; // 波特率常用 9600/19200/115200 dcb.ByteSize 8; // 数据位常见 8也有 7 dcb.Parity NOPARITY; // 校验无/奇/偶 dcb.StopBits ONESTOPBIT; // 停止位1 或 2 dcb.fDtrControl DTR_CONTROL_DISABLE; if (!SetCommState(hCom, dcb)) { // 参数不被驱动接受通常要查设备手册 }这里最容易翻车的不是代码而是参数组合。很多设备手册写的是 9600、8、N、1但实际要求硬件流控 RTS/CTS。如果你的设备只接了 TX、RX、GND 三根线却把流控设成硬件流控大概率收不到任何数据。所以打开串口前先确认设备接线再决定 FlowControl 是 NONE 还是 HARDWARE。2.2 网络通信TClientSocket、Indy 还是原生 Winsock网络那一端C Builder 的选择比串口还多。早年自带的 TClientSocket 和 TServerSocket 封装了 Socket但阻塞模型让 UI 线程很容易卡死而且在新版本里不再积极维护我不建议新项目碰它。Indy 是 C Builder 内置的跨平台网络组件库TIdTCPClient、TIdTCPServer 用起来直接超时可控。原生 Winsock 则是自己调 socket 接口灵活但代码量上去了。我一般优先用 Indy因为它和 VCL 配合自然连接断开的异常能直接捕获适合 translate 这种“发个包就走”的场景。一个最小连接示例IdTCPClient1-Host 192.168.1.100; IdTCPClient1-Port 9000; IdTCPClient1-ConnectTimeout 3000; IdTCPClient1-ReadTimeout 3000; try { IdTCPClient1-Connect(); } catch (const Exception e) { // 连接失败记录日志触发器重试 }Indy 的 ConnectTimeout 和 ReadTimeout 一定要设。不设的话默认可能陷入很长时间的阻塞现场一断网整个线程就挂在那里。发送二进制数据用 IOHandler 的 Write 重载传指针和长度IdTCPClient1-IOHandler-Write(frame.data(), frame.size());这样写的好处是绕过字符串编码直接落字节。很多通讯程序乱码的根源就是用了 AnsiString 或 UnicodeString 去塞二进制流系统帮你做了一次编码转换帧全毁了。2.3 模块划分采集、转换、转发、日志各司其职一个 translate 程序如果全堆在一个 OnData 事件里串口收到数据直接塞 Socket现场加日志就乱成一团。我习惯把它拆成四块模块之间用队列连接。采集模块只负责从串口往一个全局字节缓冲里灌数据不判断业务。转换模块从缓冲取数据按协议拆包、校验、重组产出标准帧。转发模块把标准帧写到网络或另一个串口处理失败和重连。日志模块把启动参数、收发摘要、异常帧单独记录下来不影响主链路。这样分完最大的好处是排错有明确方向。现场说数据不对先看日志里采集到的是什么再看转换后是什么最后看发出去了什么。卡在哪一环一目了然。很多看起来是“网络丢包”的问题最后发现是拆包边界没控制好数据错位后一直没恢复。3. 把 translate 跑起来最小可用的串口转发网络程序3.1 用 TComPort 初始化串口参数与代码如果走组件路线最常见的第三方串口组件是 TComPort。在窗体上放一个 TComPort写一个初始化方法void __fastcall TFormMain::InitSerial() { if (ComPort1-Connected) { ComPort1-Close(); } ComPort1-BaudRate br9600; // 波特率 9600 ComPort1-DataBits db8; // 数据位 8 ComPort1-StopBits sbOne; // 停止位 1 ComPort1-Parity pNone; // 无校验 ComPort1-FlowControl fcNone; // 无流控根据设备接线调整 ComPort1-Open(); }TComPort 的枚举值在不同版本里可能叫法略有差异但含义一致。关键是 BaudRate、DataBits、StopBits、Parity、FlowControl 五个参数。Open 失败会抛异常调用处要用 try/catch 包住否则程序直接弹错误框。打开串口后OnRxData 事件是数据入口。事件参数里有个 Count表示当前缓冲里有多少字节可读。注意这个事件运行在界面线程的消息循环里不能在事件里做同步网络发送否则 UI 会卡甚至丢数据。3.2 建立 TCP 发送端用 TIdTCPClient 保持连接并发送缓冲发送端我用 Indy 的 TIdTCPClient 封装一个发送函数。这个函数要处理“连接不存在”的情况void __fastcall TFormMain::SendFrame(const std::vectorunsigned char frame) { if (!IdTCPClient1-Connected()) { IdTCPClient1-Connect(); } IdTCPClient1-IOHandler-Write(frame.data(), frame.size()); }这段代码看着简单但现场很容易漏掉两点。一是 Connect 必须先设置 ConnectTimeout不然断线时可能卡几十秒二是 Write 不一定一次写完Indy 内部会循环发但如果对端接收缓冲区满了这个调用会阻塞。所以 SendFrame 应该在独立线程里调不能放在 UI 线程。如果程序简单可以先用一个 TThread 队列把发送请求串起来。3.3 数据缓冲与线程模型别让串口事件卡住 UI串口事件里只做一件事把字节拷贝到共享缓冲。用一个 Windows 事件对象通知工作线程// 串口事件中 void __fastcall TFormMain::ComPort1RxData(TObject *Sender, int Count) { TByteDynArray bytes; ComPort1-ReadBytes(bytes, Count); { TGuard guard(g_bufferLock); g_buffer.insert(g_buffer.end(), bytes.begin(), bytes.end()); } g_hasDataEvent-SetEvent(); // 唤醒转换线程 }这里 ReadBytes 指定读 Count 字节一次取走当前所有数据。g_buffer 是全局 std::vector 锁用临界区或 TCriticalSection 都行。SetEvent 后转换线程从阻塞中醒来void __fastcall TTranslateThread::Execute() { while (!Terminated) { g_hasDataEvent-WaitFor(INFINITE); std::vectorunsigned char chunk; { TGuard guard(g_bufferLock); chunk.swap(g_buffer); } m_parser.Append(chunk); } }swap 是取出整个缓冲的好办法既减少锁竞争又避免多次拷贝。到这里一个最小可用的 translate 骨架已经跑通了串口收、缓冲传、线程拆包、网络发。4. 协议转换的核心字节流拆包与状态机4.1 为什么不能只按字符串处理二进制帧与粘包串口和 TCP 都只保证“字节流”不保证帧边界。设备可能分两次发来一帧也可能一次发来两帧半如果程序按“收到一次数据就当成一帧”处理必然翻车。更不能用字符串函数去匹配帧头因为二进制数据里可能包含 0x00字符串会把 0 当结束符帧直接被截断。拆包器的本质是一个状态机记住当前在等什么字节、已经收了多少、完整帧需要多长。这样无论数据是分几次到达都能正确拼出一帧。4.2 定长帧与变长帧的拆包策略我把常见协议分成三类拆包策略各不相同。帧类型拆包依据优点缺点定长帧固定 N 字节实现简单直接切噪声后错位恢复慢长度字段帧内某字节/两字节表示长度可靠能快速找回边界长度字段可能跨包到达结束符如 0x0D 0x0A 或 0x7E直观适合文本协议数据里出现结束符要转义定长帧可以很简单先找帧头找到后固定取 N 字节取出来就是完整帧。变长帧里最稳妥的是“帧头 长度字段 数据 校验”。长度字段先告诉程序要等多少字节程序就按这个数量收收满即帧结束。这个设计最抗干扰也是我写 translate 时的首选。4.3 一个可复用的状态机拆包代码实现下面是一个应对“帧头 单字节长度 数据”的完整拆包类可以直接嵌进前面的转换线程class FrameParser { public: void Append(const std::vectorunsigned char data) { m_buf.insert(m_buf.end(), data.begin(), data.end()); Process(); } private: void Process() { size_t pos 0; while (pos m_buf.size()) { unsigned char ch m_buf[pos]; switch (m_state) { case SEARCH_HEAD1: if (ch 0xAA) m_state SEARCH_HEAD2; pos; break; case SEARCH_HEAD2: if (ch 0x55) m_state READ_LENGTH; else m_state SEARCH_HEAD1; // 第二帧头不对重新找 pos; break; case READ_LENGTH: m_frameLen ch; if (m_frameLen 0) { m_state SEARCH_HEAD1; } else { m_frame.assign(m_frameLen, 0); m_framePos 0; m_state READ_DATA; } pos; break; case READ_DATA: { size_t need m_frameLen - m_framePos; size_t avail m_buf.size() - pos; size_t copyN avail need ? avail : need; memcpy(m_frame[m_framePos], m_buf[pos], copyN); m_framePos copyN; pos copyN; if (m_framePos m_frameLen) { HandleFrame(m_frame); // 完整帧交给业务 m_state SEARCH_HEAD1; // 复位继续拆下一帧 } break; } } } m_buf.erase(m_buf.begin(), m_buf.begin() pos); } void HandleFrame(const std::vectorunsigned char frame) { // 在这里做 CRC 校验、字段解析、重组成网络帧 } enum State { SEARCH_HEAD1, SEARCH_HEAD2, READ_LENGTH, READ_DATA }; State m_state SEARCH_HEAD1; unsigned char m_frameLen 0; std::vectorunsigned char m_frame; size_t m_framePos 0; std::vectorunsigned char m_buf; };这个状态机的关键在于m_state、m_framePos 都是成员变量跨多次 Append 调用会保留。比如长度字段还没等到下一次 Append 来了新字节状态还是 READ_LENGTH字节会接着被消费。READ_DATA 阶段如果数据不够pos 会跑到 m_buf.size() 退出但 m_framePos 保留在 frame 里的位置下一次 Append 后继续填。每次处理完m_buf 里已消费的字节会被 erase 掉避免重复读取。用这个拆包器粘包、半包都能扛住。唯一要注意的是 HandleFrame 里不要做耗时长长的转发逻辑最好再丢给转发队列保证拆包线程不被拖慢。5. 通讯编程避坑串口丢字节、网络粘包与 C Builder 的玄学5.1 现象串口接收丢字节、出现乱码现象是日志里数据一段正常一段全是小方块或者干脆收到一半就断了。原因是参数没对上或者 USB 转串口的驱动不稳也可能是 OnRxData 里做了耗时的字符串处理导致驱动缓冲区溢出。解决方法是先核对设备手册里的波特率、数据位、停止位、校验和流控五个参数然后用串口助手单独测设备排除程序问题最后把 OnRxData 里的事件代码降到最少只做入队。如果你发现换一个 USB 转串口线就好了那基本就是硬件问题程序里只需要把读取改成更积极的方式。5.2 现象TCP 粘包/半包导致解析错位现象是服务器收到的数据时多时少同一批设备数据解析出来忽大忽小。原因是 TCP 根本不管你的帧边界应用层必须自己切分。很多新人把每次 recv 到的缓冲区直接当一帧处理偶尔对一次压力一上来就错位。解决方法是引入本章前面那套状态机拆包或者协议设计时就带长度字段。不要试图用 sleep 去“等数据到齐”这是玄学现场网络抖动会让你怀疑人生。拆包状态机出来后粘包半包基本免疫。5.3 现象UI 线程卡死程序无响应现象是运行一段时间后窗口白屏鼠标转圈最后只能任务管理器强杀。原因是串口事件或者网络事件里做了同步连接、等待、写文件这些操作都可能在网络不畅时卡几十秒而 VCL 界面线程在同一线程里整个窗口就冻结了。解决方法是把所有可能阻塞的操作全部移到工作线程。串口事件只入队网络发送由专用线程从队列取数据日志文件也由定时器统一落盘。记住一句话UI 线程只碰界面和数据入队其他全是线程池的事。5.4 现象C Builder 自带的 TClientSocket / TServerSocket 在新系统上连不上现象是程序在 Windows 7 上好好的到了 Windows 10 或更新的系统上客户端连接超时服务器收不到连接。原因是自带 Socket 组件底层模型老线程事件在系统更新后出现兼容问题官方已不再积极维护。解决方法是迁移到 Indy 或原生 Winsock。我遇过一次现场把 TClientSocket 改成 TIdTCPClient 后同样的代码逻辑立刻恢复。迁移成本不大主要是把 OnConnect、OnReceive 事件模型改成阻塞读取模型建议新工程直接避开旧组件。5.5 现象Unicode 与 AnsiString 混用出现乱码现象是协议里明明是 A 开头的 ASCII 字段发到服务器变成带问号的乱码。原因是 C Builder 2009 以后默认字符串是 UnicodeString如果你用 String 去接二进制数据再把 String 发给网络编译器会做编码转换二进制帧就被污染了。解决方法是二进制协议里一律用 unsigned char、std::vector、TByteDynArray 这类纯字节容器不要碰 String。只有明确是文本字段时才用 Utf8ToAnsi 或指定编码转换。这条我吃过亏一开始图省事用 String最后排查了一整天。6. 让 translate 更稳断线重连、心跳与看门狗6.1 串口掉线检测与自动恢复串口不会永远在线尤其是 USB 转串口松一下或者驱动重置就没了。掉线后读写函数会返回错误但程序不一定立刻知道。我用一个看门狗定时器每秒检测一次串口状态如果是断开就尝试重开void __fastcall TFormMain::TimerWatchdogTimer(TObject *Sender) { if (!ComPort1-Connected) { try { InitSerial(); // 复用 3.1 的初始化方法 Log(串口已重连); } catch (...) { // 记录重连失败等下一轮 } } }重连前最好加一个几百毫秒的延时让驱动完成资源释放。连续重连失败时要告警不能默默地刷日志。6.2 网络断线重连与心跳包TCP 连接即使对端消失本地也可能不知道自己“已经死了”。解决方法是发送前检查连接状态断开就重连同时定时发心跳包如果长时间没有任何对端响应主动断开重连。发送函数里加重连逻辑void __fastcall TFormMain::SendFrameSafe(const std::vectorunsigned char frame) { if (!IdTCPClient1-Connected()) { try { IdTCPClient1-Connect(); } catch (const Exception e) { Log(重连失败 e.Message); return; } } IdTCPClient1-IOHandler-Write(frame.data(), frame.size()); }心跳包可以放在同一个看门狗定时器里发送一个特殊帧对端回包即认为在线。注意心跳间隔不要太短30 到 60 秒比较常见避免把服务器打爆。6.3 数据日志与现场排错技巧最后一步是日志。日志不能只记业务消息要记十六进制收发摘要。我在 translate 里写了一个简单的 LogHex 函数把每一帧转成十六进制字符串并加 tagvoid LogHex(const std::string tag, const std::vectorunsigned char data) { std::string hex; for (unsigned char ch : data) { char buf[4]; snprintf(buf, sizeof(buf), %02X , ch); hex buf; } // 写入日志文件按天滚动 }排错时先看日志里有没有重连记录、校验失败、超时再对比十六进制收发帧判断是设备没发、拆包丢数据、还是网络中转错了。这套组合拳我在现场用得很顺手稳定性和可维护性都靠它。还记得最早维护一个类似 translate 的程序时我只写了发送逻辑忘了断线重连。现场跑了个把月某次交换机重启程序再也连不上服务器所有采集数据全丢在内存里。后来才把发送函数改成先探活、失败就重连加了看门狗和十六进制日志之后才真正睡得着觉。通讯编程里功能跑起来只是开始稳定跑一年才是本事。希望这篇笔记能给你一条少踩坑的路希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?