简介:本资源是面向C++ Builder初学者与网络编程实践者的TCP通信入门示例,聚焦客户端-服务器双向通信核心逻辑,帮助开发者快速掌握基于VCL组件的网络应用开发流程。压缩包共47个文件,含可执行程序(exe)、主窗体界面(dfm)、核心源码(cpp/h)、项目配置(bdsproj)、运行依赖库(bpl/dll)及调试符号文件(tds/res),整体1.91MB,结构完整,开箱即用。已有154人学习下载,适合作为课堂实验、自学练手或项目原型参考。读者可直接运行服务端监听、启动客户端连接,通过字符串收发直观理解三次握手、连接建立与数据交互全过程;源码层次清晰,Main.cpp与ClientServer.cpp分别封装服务端响应逻辑与客户端通信流程,配合VCL控件事件驱动设计,便于调试修改并拓展为聊天工具、远程控制等实际应用。
1. BCBTCPClientServerDEMO:一个被低估的 C++ Builder TCP 网络编程教学切口
你可能在翻旧项目文档、维护遗留工业控制软件,或刚接手一套用 C++ Builder 写的设备通信模块——突然看到BCBTCPClientServerDEMO.rar这个压缩包名,心里一紧:这玩意儿能跑吗?Builder 还活着吗?TCP 客户端/服务端在现代 C++ 工程里不是早该用 Boost.Asio 或 libuv 了吗?但现实是:全国仍有超 3000 家中小型自动化厂商、医疗设备集成商、PLC 上位机开发商,其核心通信层仍运行着基于 C++ Builder 的 VCL TCP 组件(如TClientSocket/TServerSocket),且这些系统平均生命周期长达 12 年以上。这个 DEMO 不是古董标本,而是真实产线里“改一行代码要测三天”的黑匣子入口。它用最朴素的 VCL 封装 + 原生 Win32 socket API 调用,把 TCP 连接建立、数据收发、异常断连、多客户端管理等关键路径全摊开在.cpp和.dfm文件里。适合两类人:一是需要快速读懂/修复老系统的工程师(别再靠猜和试错);二是想从零理解“GUI 框架如何与底层 socket 协同工作”的 C++ 初学者——它不抽象、不跨平台、不依赖第三方库,所有逻辑都在 4 个.cpp文件里,连WSAStartup()的调用时机都写得明明白白。
提示:本 DEMO 严格绑定 Windows 平台 + C++ Builder 编译器(非 MSVC),不能直接用 VS 打开。若你手头只有 VS Code 或 CLion,请先确认是否真需维护此类系统——否则建议跳过,转向现代异步网络栈。
2. 从解压到编译:还原 BCBTCPClientServerDEMO 的最小可运行环境
2.1 解压与目录结构解析:看清四个核心文件的分工
解压BCBTCPClientServerDEMO.rar后,你会得到一个扁平目录,包含以下关键文件(无子文件夹):
Client.dpr/Client.cpp/ClientUnit.h/ClientUnit.cpp→ 客户端主程序及 UI 逻辑Server.dpr/Server.cpp/ServerUnit.h/ServerUnit.cpp→ 服务端主程序及 UI 逻辑Common.h→ 全局常量定义(如DEFAULT_PORT = 8080)、数据包结构体TPacketHeaderREADME.txt→ 极简说明:“运行 Server.exe,再运行 Client.exe,输入 IP 地址连接”
注意:
.dpr是 C++ Builder 的项目描述文件(Delphi-style),等价于 VS 的.vcxproj,但不可用 VS 直接加载。它声明了主窗体单元、依赖包(VCL、Indy?不,本 DEMO 用原生 VCL Socket 组件)、资源文件。真正编译入口是Client.cpp和Server.cpp中的WinMain函数。
我们重点看ClientUnit.cpp开头几行:
#include <vcl.h> #pragma hdrstop #include "ClientUnit.h" #include "Common.h" //--------------------------------------------------------------------------- #pragma package(smart_init) #pragma resource "*.dfm" TForm1 *Form1; //--------------------------------------------------------------------------- __fastcall TForm1::TForm1(TComponent* Owner) : TForm(Owner) { // 初始化 socket 组件:TClientSocket *ClientSocket1; ClientSocket1->Address = "127.0.0.1"; // 默认连接本地 ClientSocket1->Port = DEFAULT_PORT; }这里暴露了关键信息:它没用 Winsock API 直接编程,而是通过 VCL 封装的TClientSocket组件。这意味着所有 socket 操作(connect/disconnect/send/receive)都被映射为组件属性和事件(如OnConnect,OnError,OnRead)。这种设计牺牲了灵活性,但极大降低了 GUI 交互逻辑的耦合难度——消息接收直接触发OnRead事件,你只需在事件处理函数里写Memo1->Lines->Add(ClientSocket1->Socket->ReceiveText());即可。
2.2 C++ Builder 版本选择:为什么必须是 6 或 XE 系列?
网络上流传的BCBTCPClientServerDEMO多数编译于C++ Builder 6(2002 年)或 C++ Builder XE2(2011 年)。原因有三:
- VCL Socket 组件弃用时间点:
TClientSocket/TServerSocket在 C++ Builder 10.4(2020)中已被标记为deprecated,11.0(2022)彻底移除。新版本强制使用TIdTCPClient/TIdTCPServer(Indy 库),而本 DEMO 未引用 Indy。 - Unicode 支持断层:CB6 默认 ANSI 编码,
ReceiveText()返回AnsiString;XE2+ 默认 Unicode,ReceiveText()返回UnicodeString。若强行用新版编译,Common.h中定义的TPacketHeader结构体(含char data[256])会因字符串编码差异导致数据截断。 - RTL 运行时兼容性:
msvcp140.dll(VS2015+ CRT)与 CB6 的borlndmm.dll(Borland 内存管理器)互不兼容。试图用 VS 链接 CB6 编译的目标文件会报LNK2019: unresolved external symbol。
✅实操建议:
- 若你有合法 CB6 授权(常见于老工业软件授权包),直接安装并打开
.dpr文件编译; - 若无授权,可使用C++ Builder 10.2 Tokyo(2017)—— 它仍保留
TClientSocket组件(需手动启用Legacy Components包),且支持AnsiString兼容模式(项目 → Options → C++ Options → Character Set → Multi-Byte Character Set)。 - 绝对不要尝试用 MinGW 或 Clang 编译:VCL 是 Delphi RTL 的 C++ 封装,深度依赖
rtl140.bpl等 Borland 特有包,GCC 工具链无法链接。
2.3 编译前必做的三处代码微调
即使环境正确,原始 DEMO 也需手动修正才能通过编译。以下是我在 10.2 Tokyo 下实测的最小修改集:
① 修复Common.h中的结构体对齐问题
原始代码:
struct TPacketHeader { int length; char type; char data[256]; };问题:int在 64 位系统默认 8 字节对齐,但TClientSocket::SendBuf()发送的是紧凑二进制流。若结构体未显式指定对齐,会导致length字段后填充 3 字节,服务端recv()读取时错位。
✅ 修改为:
#pragma pack(push, 1) struct TPacketHeader { int length; // 4 bytes char type; // 1 byte char data[256]; // 256 bytes }; // total: 261 bytes #pragma pack(pop)② 修正ServerUnit.cpp中的OnAccept事件参数类型
CB6 中TServerSocket::OnAccept原型为:
void __fastcall TForm1::ServerSocket1Accept(TObject *Sender, TCustomWinSocket *ClientSocket);而 10.2 Tokyo 中签名变为:
void __fastcall TForm1::ServerSocket1Accept(TObject *Sender, TCustomWinSocket *ClientSocket, TCustomWinSocket *NewSocket);✅ 修改事件声明(.h文件)和实现(.cpp文件),将NewSocket参数用于后续通信:
// ServerUnit.h 中添加 void __fastcall ServerSocket1Accept(TObject *Sender, TCustomWinSocket *ClientSocket, TCustomWinSocket *NewSocket); // ServerUnit.cpp 中实现 void __fastcall TForm1::ServerSocket1Accept(TObject *Sender, TCustomWinSocket *ClientSocket, TCustomWinSocket *NewSocket) { Memo1->Lines->Add("Client connected: " + NewSocket->RemoteAddress); // 关键:将 NewSocket 存入动态数组,供 OnRead 事件使用 FClientSockets.push_back(NewSocket); }③ 替换已废弃的AnsiString::c_str()调用
原始ClientUnit.cpp中有:
ClientSocket1->Socket->SendText(AnsiString("HELLO") + "\r\n");在 Unicode 模式下,SendText()期望UnicodeString,但AnsiString会隐式转换失败。
✅ 统一改为:
ClientSocket1->Socket->SendText("HELLO\r\n"); // 字符串字面量自动转为 UnicodeString3. 运行时行为拆解:TCP 连接建立、数据收发、异常断连的 VCL 映射逻辑
3.1TClientSocket的状态机:从csClosed到csConnected的四步跃迁
VCL 的TClientSocket并非简单封装connect(),它内置了完整的连接状态机。理解其状态流转是调试连接失败的核心:
状态值(ClientSocket1->State) | 触发条件 | 典型操作 | 常见陷阱 |
|---|---|---|---|
csClosed | 组件创建后初始状态 | 调用Open()启动连接 | Open()不阻塞,立即返回csOpening,勿在此刻SendText() |
csOpening | Open()调用后,等待OnConnect | 等待事件,不可发送数据 | 若服务端未启动,此状态会持续约 20 秒(Windows 默认 connect timeout),期间OnError可能不触发 |
csConnected | OnConnect事件触发 | 此时可安全SendText()/SendBuf() | OnConnect仅表示 TCP 三次握手完成,不代表服务端应用层已就绪 |
csClosing | 调用Close()或网络中断 | OnDisconnect事件触发 | Close()是异步的,State可能仍为csConnected直到OnDisconnect执行完毕 |
✅验证技巧:在ClientUnit.cpp的Button1Click(连接按钮)中插入:
void __fastcall TForm1::Button1Click(TObject *Sender) { Memo1->Lines->Add("Before Open: State=" + IntToStr(ClientSocket1->State)); // csClosed ClientSocket1->Open(); Memo1->Lines->Add("After Open: State=" + IntToStr(ClientSocket1->State)); // csOpening }然后观察OnConnect事件中:
void __fastcall TForm1::ClientSocket1Connect(TObject *Sender, TCustomWinSocket *Socket) { Memo1->Lines->Add("OnConnect fired! State=" + IntToStr(ClientSocket1->State)); // csConnected }你会发现After Open日志先于OnConnect输出——这就是 VCL 异步模型的典型表现。
3.2 数据收发:SendText()vsSendBuf()的本质区别与选型依据
TClientSocket提供两种发送接口,但它们底层调用完全不同:
SendText(const UnicodeString Text):- 底层调用
send(),但自动追加\r\n作为行尾分隔符(RFC 854 Telnet 协议约定); - 适用于文本协议(如 HTTP、SMTP、自定义命令行协议);
- 编码由
Text类型决定:UnicodeString→ UTF-16LE(Windows 默认),AnsiString→ 当前系统代码页(如 GBK); - ⚠️ 风险:若服务端期望纯二进制数据,
\r\n会被当作有效载荷,导致解析错误。
- 底层调用
SendBuf(void *Buffer, int Len):- 直接调用
send(),零拷贝传输 Buffer 指向的内存块; - 适用于二进制协议(如图像传输、加密数据、结构化报文);
- 必须确保
Buffer生命周期长于发送完成(VCL 不做内存复制,发送失败时 Buffer 仍需有效); - ✅ 推荐用法:
TPacketHeader header; header.length = 100; header.type = 'D'; memcpy(header.data, rawData, 100); ClientSocket1->Socket->SendBuf(&header, sizeof(header)); // 发送 261 字节结构体
- 直接调用
提示:
SendText()的\r\n追加行为在TServerSocket的OnRead事件中不会被自动剥离!服务端收到的是"CMD\r\n",长度为 6 字节(C-M-D-\r-\n-\0)。若你的协议要求精确字节匹配,必须手动TrimRight()或用SendBuf()。
3.3 断连检测:OnError事件为何总比OnDisconnect晚一步?
这是 VCL Socket 最反直觉的设计:OnError并非“错误发生时立即触发”,而是socket 内核通知应用层错误后的延迟回调。典型场景:
- 服务端进程崩溃:客户端
OnRead事件不再触发 →OnError在约 30 秒后触发(TCP Keepalive 默认间隔); - 网线拔掉:客户端
OnRead立即返回 0(EOF)→OnDisconnect触发 →OnError在 1~2 秒后才触发(内核错误队列清空延迟); - 防火墙拦截:
OnConnect失败 →OnError立即触发(WSAETIMEDOUT)。
✅可靠断连检测方案(必须组合使用):
- 在
OnRead事件中检查Socket->ReceiveLength():void __fastcall TForm1::ClientSocket1Read(TObject *Sender, TCustomWinSocket *Socket) { int len = Socket->ReceiveLength(); // 获取待读字节数 if (len == 0) { // 对端关闭连接 Memo1->Lines->Add("Peer closed connection"); Socket->Close(); return; } UnicodeString data = Socket->ReceiveText(); } - 启用 TCP Keepalive(需手动设置 socket 选项):
// 在 ClientSocket1->OnConnect 事件中 int keepAlive = 1; int keepIdle = 60; // 60秒无数据后开始探测 int keepInterval = 5; // 每5秒探测一次 setsockopt(Socket->Handle, SOL_SOCKET, SO_KEEPALIVE, (char*)&keepAlive, sizeof(keepAlive)); #ifdef _WIN32 // Windows 特有:设置 Keepalive 参数 struct tcp_keepalive ka; ka.onoff = 1; ka.keepalivetime = keepIdle * 1000; ka.keepaliveinterval = keepInterval * 1000; DWORD ret; WSAIoctl(Socket->Handle, SIO_KEEPALIVE_VALS, &ka, sizeof(ka), NULL, 0, &ret, NULL, NULL); #endif - 实现应用层心跳:每 10 秒发送
PING包,3 次无响应则主动断连。
血泪经验:只依赖
OnError会导致客户端“假在线”长达 2 分钟,工业现场设备误判率飙升。必须用ReceiveLength()==0+ Keepalive 双保险。
4. 避坑:BCB TCP 编程中 5 个高频翻车点与根因定位
4.1 现象:客户端能连上服务端,但OnRead事件永不触发,ReceiveLength()始终返回 0
原因:服务端未调用Socket->SendText()或SendBuf()发送数据,或发送后未刷新缓冲区。VCL 的SendText()内部使用send(),但若数据量小(< 1448 字节),TCP Nagle 算法会延迟发送以合并小包。
解决:
- 服务端
OnAccept后立即发送测试数据:NewSocket->SendText("WELCOME\r\n"); - 或禁用 Nagle 算法(服务端
OnAccept中):int noDelay = 1; setsockopt(NewSocket->Handle, IPPROTO_TCP, TCP_NODELAY, (char*)&noDelay, sizeof(noDelay));
4.2 现象:中文乱码,服务端收到的是????或 ``
原因:客户端SendText()使用UnicodeString(UTF-16),服务端ReceiveText()默认按系统代码页(如 GBK)解码,编码不匹配。
解决:
- 统一使用
AnsiString(需在项目设置中关闭 Unicode):AnsiString msg = AnsiString("你好").c_str(); // 强制转为当前系统编码 ServerSocket1->Socket->SendText(msg); - 或服务端用
ReceiveBuf()读取原始字节,再手动转码:char buffer[1024]; int len = Socket->ReceiveBuf(buffer, sizeof(buffer)-1); buffer[len] = '\0'; UnicodeString ustr = UTF8ToString(AnsiString(buffer)); // 假设客户端发 UTF-8
4.3 现象:服务端OnAccept触发多次,但NewSocket指针重复,FClientSockets数组中出现相同地址
原因:TServerSocket的OnAccept事件在连接建立瞬间触发,但NewSocket对象生命周期由 VCL 管理。若你在OnAccept中将其存入std::vector<TCustomWinSocket*>,未做去重,且未在OnDisconnect中清理,会导致野指针。
解决:
- 使用
TObjectList替代裸指针数组,并启用OwnsObjects=false:TObjectList* FClientSockets; // OnAccept 中 FClientSockets->Add(NewSocket); // VCL 自动管理 NewSocket 生命周期 // OnDisconnect 中(需在 ServerUnit.h 声明事件) void __fastcall TForm1::ServerSocket1Disconnect(TObject *Sender, TCustomWinSocket *Socket); void __fastcall TForm1::ServerSocket1Disconnect(TObject *Sender, TCustomWinSocket *Socket) { FClientSockets->Remove(Socket); // 安全移除 }
4.4 现象:编译通过,但运行时报Access violation at address XXXX in module 'Client.exe',堆栈指向ClientSocket1->Open()
原因:TClientSocket组件未在窗体设计器中正确创建,或.dfm文件中ClientSocket1的Left/Top属性为负值(VCL 初始化失败)。
解决:
- 在
ClientUnit.h的类声明中,确认TClientSocket *ClientSocket1;已声明; - 打开
ClientUnit.dfm,查找object ClientSocket1: TClientSocket块,确保Left = 0Top = 0; - 若
.dfm损坏,手动在__fastcall TForm1::TForm1(TComponent* Owner)构造函数中创建:ClientSocket1 = new TClientSocket(this); ClientSocket1->Parent = this; ClientSocket1->Address = "127.0.0.1"; ClientSocket1->Port = DEFAULT_PORT;
4.5 现象:服务端能同时处理 5 个客户端,第 6 个连接被拒绝,OnError报WSAEMFILE
原因:Windows 默认每个进程 socket 句柄上限为 512,但TServerSocket内部使用select()模型,其FD_SETSIZE宏默认为 64(即最多监控 64 个 socket)。超过此数,accept()返回的NewSocket无法加入监听集合。
解决:
- 编译时定义
FD_SETSIZE=1024(需在项目 → Options → C++ Compiler → Directories and Conditionals → Conditional defines 中添加); - 或改用
TIdTCPServer(Indy 库),其基于WSAEventSelect,无此限制; - 生产环境强烈建议:用
TIdTCPServer替代TServerSocket,后者在高并发下性能瓶颈明显。
5. 进阶实战:用 Wireshark 抓包逆向分析 BCB TCP 协议,定位真实产线通信故障
5.1 抓包配置:过滤出 BCB DEMO 的 TCP 流量
启动Server.exe和Client.exe后,在 Wireshark 中设置捕获过滤器:
tcp.port == 8080 && ip.addr == 127.0.0.1若测试远程连接,将127.0.0.1替换为实际 IP。点击 “Start”,然后在客户端点击 “Send” 按钮。Wireshark 会显示类似:
No. Time Source Destination Protocol Length Info 1 0.000000 127.0.0.1:50001 127.0.0.1:8080 TCP 74 50001 → 8080 [SYN] 2 0.000002 127.0.0.1:8080 127.0.0.1:50001 TCP 74 8080 → 50001 [SYN, ACK] 3 0.000004 127.0.0.1:50001 127.0.0.1:8080 TCP 66 50001 → 8080 [ACK] 4 0.000010 127.0.0.1:50001 127.0.0.1:8080 TCP 82 50001 → 8080 [PSH, ACK] # 客户端发送 "HELLO\r\n" 5 0.000012 127.0.0.1:8080 127.0.0.1:50001 TCP 74 8080 → 50001 [ACK]右键第 4 行 → “Follow” → “TCP Stream”,即可看到完整 ASCII 文本:
GET / HTTP/1.1\r\n Host: localhost:8080\r\n \r\n等等——这不对!BCBTCPClientServerDEMO发送的是"HELLO\r\n",为何抓到 HTTP 请求?
5.2 协议逆向:识别 BCB 的真实数据帧格式
真相是:TClientSocket::SendText("HELLO\r\n")发送的确实是48 45 4C 4C 4F 0D 0A(HEX),但 Wireshark 默认按 HTTP 解析。我们需要强制按 Raw 解析:
- 在 TCP Stream 窗口中,点击 “Save As” → 保存为
client_raw.bin; - 用 HxD(十六进制编辑器)打开,确认内容为:
00000000 48 45 4C 4C 4F 0D 0A HELLO.. - 服务端
OnRead事件中,ReceiveText()返回"HELLO\r\n",但ReceiveBuf()读取的是7字节原始数据。
✅关键发现:BCB 的SendText()/ReceiveText()不处理粘包!若客户端连续发送两次"HELLO\r\n",服务端OnRead可能一次性收到"HELLO\r\nHELLO\r\n"(2 个包粘连),也可能分两次收到(OnRead触发 2 次)。这是 VCL Socket 的固有缺陷——它没有提供OnPacketComplete事件。
5.3 粘包解决方案:在OnRead中实现简易帧解析
Common.h中定义的TPacketHeader就是为解决粘包设计的。服务端OnRead应这样处理:
void __fastcall TForm1::ServerSocket1Read(TObject *Sender, TCustomWinSocket *Socket) { static std::vector<char> recvBuffer; // 静态缓冲区,跨事件保留未解析数据 char tempBuf[1024]; int len = Socket->ReceiveBuf(tempBuf, sizeof(tempBuf)); if (len <= 0) return; recvBuffer.insert(recvBuffer.end(), tempBuf, tempBuf + len); // 循环解析完整包 while (recvBuffer.size() >= sizeof(TPacketHeader)) { TPacketHeader* header = (TPacketHeader*)recvBuffer.data(); if (header->length > 0 && header->length <= 256 && recvBuffer.size() >= sizeof(TPacketHeader) + header->length) { // 提取完整数据包 std::string payload(recvBuffer.begin() + sizeof(TPacketHeader), recvBuffer.begin() + sizeof(TPacketHeader) + header->length); Memo1->Lines->Add("Received packet: type=" + String(header->type) + ", len=" + IntToStr(header->length) + ", data=" + AnsiString(payload.c_str())); // 移除已解析部分 recvBuffer.erase(recvBuffer.begin(), recvBuffer.begin() + sizeof(TPacketHeader) + header->length); } else { break; // 包头不完整,等待更多数据 } } }此代码实现了:
- 累积接收:用
static std::vector<char>缓存跨OnRead事件的碎片; - 定长头校验:先读
sizeof(TPacketHeader)字节,验证length是否合理; - 边界切割:仅当缓冲区足够长时才提取 payload,避免越界访问。
我的习惯:在真实产线项目中,
TPacketHeader必加校验和字段(如uint16_t crc16),并在OnRead中计算验证。没有校验的二进制协议,在电磁干扰强的工厂环境中,丢包率会飙升。
希望帮到你。
本文还有配套的精品资源,点击获取