news 2026/10/8 7:34:02

C++ Builder TCP网络编程实战:VCL Socket组件深度解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++ Builder TCP网络编程实战:VCL Socket组件深度解析

简介:本资源是面向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)、数据包结构体TPacketHeader
  • README.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 年)。原因有三:

  1. VCL Socket 组件弃用时间点:TClientSocket/TServerSocket在 C++ Builder 10.4(2020)中已被标记为deprecated,11.0(2022)彻底移除。新版本强制使用TIdTCPClient/TIdTCPServer(Indy 库),而本 DEMO 未引用 Indy。
  2. Unicode 支持断层:CB6 默认 ANSI 编码,ReceiveText()返回AnsiString;XE2+ 默认 Unicode,ReceiveText()返回UnicodeString。若强行用新版编译,Common.h中定义的TPacketHeader结构体(含char data[256])会因字符串编码差异导致数据截断。
  3. 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"); // 字符串字面量自动转为 UnicodeString

3. 运行时行为拆解:TCP 连接建立、数据收发、异常断连的 VCL 映射逻辑

3.1TClientSocket的状态机:从csClosed到csConnected的四步跃迁

VCL 的TClientSocket并非简单封装connect(),它内置了完整的连接状态机。理解其状态流转是调试连接失败的核心:

状态值(ClientSocket1->State)触发条件典型操作常见陷阱
csClosed组件创建后初始状态调用Open()启动连接Open()不阻塞,立即返回csOpening,勿在此刻SendText()
csOpeningOpen()调用后,等待OnConnect等待事件,不可发送数据若服务端未启动,此状态会持续约 20 秒(Windows 默认 connect timeout),期间OnError可能不触发
csConnectedOnConnect事件触发此时可安全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)。

✅可靠断连检测方案(必须组合使用):

  1. 在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(); }
  2. 启用 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
  3. 实现应用层心跳:每 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中计算验证。没有校验的二进制协议,在电磁干扰强的工厂环境中,丢包率会飙升。

希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/8 7:32:45

深度强化学习训练德州扑克AI:从Leduc到NFSP算法优化实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/8 7:31:52

context-mode是什么?一文讲透上下文感知机制与工程避坑指南

写在前头做开发这几年&#xff0c;我越来越觉得“上下文”这两个字才是效率的分水岭。你写代码、查问题、改Bug&#xff0c;真正耗时间的不是打字&#xff0c;而是反复把“现在到底改的是哪一段逻辑”“这个变量从哪来”“这段历史为什么要这么写”重新拼回来。前几天在整理工具…

作者头像 李华
网站建设 2026/10/8 7:31:29

基于OpenCV的数码管识别:七段码特征与图像处理全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/8 7:31:29

RK3588软硬件协同设计实战:从电源时序到VPU硬解调优

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/8 7:30:55

OpenShell 完全指南:为 Windows 11/10 定制经典开始菜单与高效工作流

如果你受够了 Windows 11 那个把“推荐项目”和“固定应用”混在一起、找程序要翻半天的新开始菜单&#xff0c;那 OpenShell 应该是今年最值得你折腾的一个开源小工具。它从当年几乎人手一份的 Classic Shell 改名而来&#xff0c;属于完全免费、源码开源的 Windows 界面恢复方…

作者头像 李华