news 2026/8/11 12:25:27

C++ Socket编程实战:从零实现Telnet客户端

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++ Socket编程实战:从零实现Telnet客户端

1. 项目概述:从Socket到Telnet的实战之路

最近在整理一些网络编程的老项目,发现很多朋友对C++ Socket编程和实现一个简单的Telnet客户端感到头疼。这其实是一个非常好的练手项目,它能让你一次性把网络通信、字节流处理、协议解析这些核心概念都串起来。简单来说,Socket是网络通信的基石,而Telnet协议则是基于TCP的一个经典应用层协议,实现它就像是在TCP这个可靠的“高速公路”上,按照特定的“交通规则”(协议)来收发数据包。无论是想深入理解网络编程,还是为后续开发更复杂的网络服务(比如游戏服务器、远程控制工具)打基础,这个项目都绕不开。它适合有一定C++基础,想从控制台程序迈向网络世界的开发者,整个过程会涉及到Winsock/BSD Socket API的使用、TCP连接的生命周期管理、以及如何处理粘包、非阻塞IO等实际问题。

2. 核心思路与架构设计

2.1 为什么选择C++和原生Socket API?

首先得明确一点,用C++和原生Socket API来实现,而不是用现成的网络库(如Boost.Asio、POCO),目的是为了“知其然,更知其所以然”。网络库封装得很好,但同时也隐藏了底层细节。通过亲手调用socket(),bind(),connect(),send(),recv()这些函数,你能最直观地感受到TCP连接的建立、数据的收发、以及套接字的各种状态。这对于调试网络问题、理解性能瓶颈至关重要。Telnet协议本身足够简单(主要是基于NVT,即网络虚拟终端),协议数据单元小,逻辑清晰,非常适合作为第一个网络协议实现项目。

整个项目的核心架构可以划分为三层。最底层是Socket通信层,负责最基础的TCP连接建立、维护与关闭,以及原始字节流的发送和接收。中间层是Telnet协议解析层,它负责按照RFC 854等规范,解析从服务器发来的命令(如IAC, DO, WILL等),并构造相应的响应。最上层是用户交互与会话管理层,它处理用户输入(将本地键盘输入发送给服务器),并展示服务器返回的数据(通常是终端字符),同时管理整个连接会话的状态(登录、执行命令、退出)。这样的分层设计使得每一层的职责清晰,便于调试和扩展。例如,未来如果想支持SSH协议,只需要替换中间的协议解析层,底层的Socket通信和上层的用户交互可以较大程度复用。

2.2 关键组件与类设计

基于上述架构,我们可以设计几个核心的类。TelnetClient类将是整个客户端的门面,它对外提供Connect(),Disconnect(),SendData(),Run()等接口。在TelnetClient内部,它会聚合一个SocketWrapper类的实例。这个SocketWrapper是对原生Socket API的一个薄封装,目的是将平台相关的细节(Windows的Winsock和Linux/Unix的BSD Socket)隔离开来,并提供更易用的RAII(资源获取即初始化)风格接口,确保套接字资源不会泄漏。

另一个核心类是TelnetProtocolHandler,它专门负责协议解析。这个类会维护一些状态,比如当前协商的选项(如回显、行模式)。它的主要接口可能是ProcessIncomingData(std::vector<uint8_t>& data),输入是从网络接收到的原始字节流,输出则是剥离了Telnet命令后的纯应用数据,以及需要发送给服务器的协议响应。这种设计将复杂的协议状态机与IO逻辑分离,使得代码更清晰。

注意:在类设计初期,务必明确各个类的所有权和生命周期。例如,TelnetClient拥有SocketWrapperTelnetProtocolHandler,并在析构时按正确顺序释放资源,避免悬空指针或资源未释放的问题。

3. 环境准备与Socket基础封装

3.1 跨平台的Socket初始化与清理

无论在哪個平台,使用Socket编程的第一步都是初始化网络库。在Windows上,这通过WSAStartup函数完成;在类Unix系统(包括Linux和macOS)上,Socket API是系统调用的一部分,通常无需显式初始化。为了统一,我们可以在SocketWrapper的构造函数或某个初始化函数中处理这些差异。

// SocketWrapper.hpp 中的初始化思路 class SocketWrapper { public: SocketWrapper(); ~SocketWrapper(); bool Initialize(); // 可选的显式初始化 // ... 其他方法 private: #ifdef _WIN32 WSADATA wsaData_; bool winsockInitialized_ = false; #endif SOCKET socketHandle_ = INVALID_SOCKET; };

在实现中,Initialize()函数会检查平台。如果是Windows,则调用WSAStartup(MAKEWORD(2,2), &wsaData_)请求2.2版本的Winsock,并检查返回值。成功后将winsockInitialized_置为true。对应的,在析构函数~SocketWrapper()中,如果winsockInitialized_为true,则必须调用WSACleanup()进行清理。对于非Windows平台,这两个函数可以留空或直接返回成功。这就是RAII思想的体现:初始化在构造时或紧随其后完成,清理在析构时自动进行,用户无需关心。

3.2 核心Socket操作的封装

封装的核心目的是提供一组安全、易用、异常安全的接口。基础的封装至少包括:

  1. 创建套接字 (Create): 调用socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)创建TCP套接字。这里AF_INET表示IPv4,SOCK_STREAM表示流式套接字(TCP)。创建失败应抛出异常或返回错误码。
  2. 连接服务器 (Connect): 接受一个字符串格式的主机名(或IP地址)和端口号。内部需要使用getaddrinfo函数进行域名解析,它比旧的gethostbyname更推荐使用,因为它同时支持IPv4和IPv6,且是线程安全的。解析成功后,遍历返回的地址列表,尝试用connect函数进行连接,直到成功或全部失败。
  3. 发送数据 (Send): 封装send系统调用。关键点在于,send并不保证一次性发送完所有你给它的数据,它可能只发送了一部分。因此,封装函数内部需要一个循环,直到所有数据发送完毕或发生错误。同时,要处理EAGAINEWOULDBLOCK错误(在非阻塞模式下)。
  4. 接收数据 (Receive): 封装recv系统调用。同样,它可能只返回了当前可读的数据。我们的封装函数可以有两种设计:一种是读取直到填满用户提供的缓冲区;另一种是读取当前所有可用的数据并返回一个动态大小的容器(如std::vector<uint8_t>)。对于Telnet客户端,后者更常用,因为我们不知道服务器下一次会发多少数据过来。
  5. 关闭连接 (Close): 调用closesocket(Windows)或close(Unix)来关闭套接字。优雅的关闭应该先调用shutdown(SHUT_WR)通知对方“我没有数据要发了”,然后继续读取对方可能还在发送的数据,最后再完全关闭。

将这些操作封装好后,TelnetClient类的工作就简化为了调用这些高级接口,而不必每次都面对晦涩的句柄和错误码。

4. Telnet协议解析器的实现

4.1 Telnet协议命令格式与状态机

Telnet协议在TCP数据流中嵌入命令来进行控制和协商。所有命令都以一个特殊的IAC(Interpret As Command)字节(0xFF)开始。紧跟在IAC后面的字节决定了命令的类型。最常见的结构是:

  • IAC + 命令码: 例如,0xFF 0xF4(IAC IP)表示中断进程。
  • IAC + 命令码 + 选项码: 用于选项协商。例如,0xFF 0xFB 0x01(IAC WILL ECHO)表示客户端主动提议启用回显选项;0xFF 0xFD 0x01(IAC DO ECHO)表示服务器请求客户端启用回显。

我们的协议解析器需要像一个状态机一样工作。它逐个字节地处理输入数据。默认状态是“数据态”,直接将字节放入应用数据缓冲区。当读到0xFF(IAC)时,进入“命令态”,期待下一个字节。根据下一个字节判断是简单命令还是选项协商命令,可能需要再读取一个选项字节,完成解析后,生成响应(如果需要),并返回到“数据态”。

// TelnetProtocolHandler 状态机处理的核心逻辑简化示例 void TelnetProtocolHandler::ProcessByte(uint8_t byte) { switch (currentState_) { case State::DATA: if (byte == IAC) { currentState_ = State::IAC; } else { applicationDataBuffer_.push_back(byte); // 普通数据,存入缓冲区 } break; case State::IAC: if (byte == IAC) { // 双IAC转义,表示一个真正的0xFF数据字节 applicationDataBuffer_.push_back(byte); currentState_ = State::DATA; } else if (IsCommand(byte)) { currentCommand_ = byte; if (IsNegotiationCommand(currentCommand_)) { currentState_ = State::NEGOTIATION; } else { // 简单命令,立即处理 HandleCommand(currentCommand_); currentState_ = State::DATA; } } else { // 协议错误,可记录日志并重置状态 currentState_ = State::DATA; } break; case State::NEGOTIATION: optionCode_ = byte; HandleNegotiation(currentCommand_, optionCode_); currentState_ = State::DATA; break; } }

4.2 选项协商的处理与响应

选项协商是Telnet协议最灵活也最复杂的部分。服务器和客户端通过交换WILL, WONT, DO, DONT来开启或关闭某个功能(如回显、行模式、窗口大小)。我们的TelnetProtocolHandler需要维护一个本地选项状态表,记录哪些选项是开启的。

处理逻辑遵循一个基本原则:对于对方(服务器)的提议(DO/WILL),如果我们支持且愿意,就回答WILL/DO;如果不支持或不愿意,就回答WONT/DONT。对于我们主动发起的提议,则等待对方的应答。例如,当收到服务器发来的0xFF 0xFD 0x01(IAC DO ECHO)时,表示服务器要求我们开启回显。如果我们决定遵守,就应该回复0xFF 0xFB 0x01(IAC WILL ECHO)。这个回复需要由协议处理器生成,并交给TelnetClient发送回服务器。

实操心得:很多Telnet服务器在连接建立后会立即发起一系列选项协商。如果你的客户端没有正确响应这些协商,服务器可能会认为你的客户端能力不全,从而断开连接或表现异常。因此,实现一个最小集的选项协商(至少处理ECHO和SUPPRESS_GO_AHEAD)对于连接大多数标准Telnet服务器是必要的。

5. 客户端主循环与用户交互

5.1 多路复用IO:select/poll模型的选择

一个基本的Telnet客户端需要同时做两件事:1) 读取用户在本地终端的输入并发送给服务器;2) 读取服务器发来的数据并显示给用户。如果使用阻塞IO,那么recv会一直等待服务器数据,用户在此期间无法输入;反之亦然。因此,我们需要使用IO多路复用技术来同时监控标准输入(stdin)和网络套接字这两个“文件描述符”上的可读事件。

在跨平台场景下,select函数是一个经典且广泛可用的选择。它的原理是,你告诉操作系统一组你关心的描述符(读集合、写集合、异常集合),然后select会阻塞,直到其中任何一个描述符准备好进行IO操作,或者超时。虽然select在处理大量连接时有效率问题(描述符集合大小有限制且需要每次重新设置),但对于只有两个描述符的客户端来说,它完全够用且代码简单。

// 使用select的主循环伪代码 void TelnetClient::Run() { fd_set readfds; SOCKET sockfd = socketWrapper_.GetHandle(); int stdin_fd = fileno(stdin); // 获取标准输入的文件描述符 while (isConnected_) { FD_ZERO(&readfds); FD_SET(sockfd, &readfds); FD_SET(stdin_fd, &readfds); // 等待事件发生,超时设为1秒以便可以检查退出条件 struct timeval timeout = {1, 0}; int activity = select(std::max(sockfd, stdin_fd) + 1, &readfds, NULL, NULL, &timeout); if (activity < 0) { // 处理select错误 break; } else if (activity == 0) { // 超时,可以在这里处理一些周期性任务或检查连接状态 continue; } // 检查标准输入是否有数据(用户按键) if (FD_ISSET(stdin_fd, &readfds)) { HandleLocalInput(); } // 检查网络套接字是否有数据(服务器响应) if (FD_ISSET(sockfd, &readfds)) { HandleNetworkData(); } } }

5.2 本地输入处理与网络数据展示

HandleLocalInput()函数负责从stdin读取用户输入的字符。这里有一个细节:默认情况下,终端是行缓冲的,即用户需要按回车键,程序才能读到一整行输入。但对于Telnet这种需要实时交互的场景(比如玩命令行游戏、使用vi编辑器),我们需要将终端设置为原始模式,使得每个按键按下都能立即被程序捕获。在Unix-like系统可以使用tcsetattr修改终端属性,在Windows上可以使用_kbhit()_getch()。读取到的字符,如果不是本地控制命令(如Ctrl+C退出),就应该通过TelnetClient::SendData()发送给服务器。

HandleNetworkData()函数则调用SocketWrapper::Receive()接收数据,然后将原始字节流交给TelnetProtocolHandler::ProcessIncomingData()处理。处理器会解析掉Telnet命令,并将纯文本数据返回。客户端需要将这些数据输出到标准输出(stdout)。这里要注意字符编码,虽然传统Telnet使用7位ASCII,但现代终端和服务器可能支持UTF-8。为了正确显示,可能需要将接收到的字节流转换为本地控制台支持的编码。一个简单的开始是将其视为ASCII/Latin-1输出,对于大多数英文环境服务器足够了。

6. 连接、认证与会话管理

6.1 建立连接与初始协商流程

一个完整的Telnet连接建立过程不仅仅是TCP的三次握手。在TCP连接建立后,客户端和服务器会立即进入Telnet协议协商阶段。服务器通常会主动发送一系列DOWILL命令来试探客户端的支持能力。我们的客户端在Connect方法成功建立TCP连接后,不应该立即开始主循环,而应该先进入一个短暂的“初始协商”状态。在这个状态下,客户端可以主动发送一些它支持的选项(例如WILL ECHO),更重要的是,它必须准备好接收并响应服务器发来的任何协商命令。

这个过程可以在主循环开始前,通过一个短时间的、只处理网络数据的循环来完成,直到协商流量平息(例如,连续100毫秒没有收到新的IAC命令)。确保协商正确完成,是后续正常交互的基础。

6.2 模拟登录与命令执行

连接到服务器后,常见的流程是登录。这通常表现为服务器发送一个“login:”提示符,等待客户端输入用户名,然后发送“Password:”提示符等待密码。我们的客户端需要能够自动化或半自动化这个过程。可以在HandleNetworkData函数中,对接收到的数据进行简单的字符串匹配。当检测到包含“login:”或“Username:”的字符串时,自动从配置中或提示用户输入用户名并发送。对于密码提示,需要注意Telnet协议本身是明文传输的,密码也会以明文发送,这是Telnet的重大安全缺陷。在实际练习中,我们可以模拟这个过程,但必须清楚这绝不应用于生产环境。

对于命令执行,就是简单的“输入-输出”循环。用户输入命令字符串,客户端发送,服务器执行并返回结果,客户端显示。这里的关键在于处理服务器输出可能非常长,或者包含控制字符(如清屏、移动光标)的情况。一个健壮的客户端需要能够解析并至少安全地忽略这些ANSI转义序列,以免终端显示乱码。

7. 错误处理、调试与性能考量

7.1 全面的错误处理机制

网络编程中,错误无处不在。我们的代码必须对每一个Socket API调用(socket,connect,send,recv,select等)的返回值进行检查。不同的错误码意味着不同的问题,需要区别处理。

错误场景可能原因处理建议
connect()返回ECONNREFUSED目标端口没有服务监听检查服务器地址和端口,确认服务已启动。
send()返回EPIPEECONNRESET连接已被对端关闭关闭本地套接字,清理资源,通知用户连接已断开。
recv()返回0对端优雅地关闭了连接同上,这是正常的断开情况。
select()返回EINTR系统调用被信号中断通常可以忽略,重新调用select
WSAStartup失败Winsock库初始化失败检查系统网络功能是否正常,或DLL版本。

除了系统错误,还有应用层错误,比如协议解析错误(收到非法的IAC序列)、内存分配失败等。对于这些,合理的做法是记录详细的日志(包括时间、错误码、上下文),然后要么尝试恢复(比如重置协议解析状态),要么安全地关闭连接。

7.2 调试技巧与日志记录

调试网络程序比调试单机程序更复杂,因为涉及两个端点。以下是一些实用的技巧:

  1. 数据十六进制转储:在SendReceive函数的内部,可以添加条件编译的日志,将收发的每一个字节以十六进制和ASCII形式打印出来。这对于分析复杂的Telnet选项协商流程至关重要,你能清楚地看到每一个IAC命令的来往。
  2. 使用网络调试工具:在开发初期,可以同时运行一个成熟的Telnet客户端(如PuTTY或系统自带的telnet命令)连接到同一个服务器。通过对比成熟客户端和你的自制客户端的行为与网络流量,能快速定位是连接问题、协商问题还是数据显示问题。
  3. 模拟服务器:编写一个极简的TCP回显服务器,或者使用netcat(nc)命令模拟Telnet服务器。nc -l -p 2323可以监听2323端口,你发送什么它就回显什么,这能帮你隔离协议复杂性,先测试最基本的Socket收发功能。
  4. 分阶段测试:不要试图一次性写完所有功能。先让Socket连接和回显工作,再加入Telnet命令解析,最后处理选项协商和终端模式。

7.3 性能与资源管理考量

虽然一个简单的Telnet客户端对性能要求不高,但良好的习惯对任何项目都有益:

  • 缓冲区管理:避免在每次recv时都分配新的小缓冲区。可以预分配一个合理大小(如4KB)的缓冲区,循环使用。对于应用层数据,使用std::vector<uint8_t>::reserve预留空间,减少重新分配次数。
  • 非阻塞IO进阶select模型在描述符多时效率低。作为学习延伸,可以了解poll(Unix)或WSAPoll(Windows),以及更高效的epoll(Linux)和kqueue(BSD/macOS)模型。这对于将来编写高性能服务器端程序是必备知识。
  • 线程与并发:对于更复杂的客户端(比如需要同时保持多个会话,或在后台执行长时间任务),可以考虑使用多线程。一个线程专门负责网络IO(使用select),另一个线程处理用户界面。但要注意,多线程会引入数据同步的复杂性,初期项目不建议使用。

8. 常见问题排查与解决方案实录

在实际编写和运行Telnet客户端的过程中,你几乎一定会遇到下面这些问题。这里记录了我踩过的坑和解决方法。

8.1 连接失败类问题

问题1:connect()总是失败,返回 “Address family not supported by protocol”。

  • 排查:这通常发生在调用getaddrinfo之后,使用了错误的地址结构。getaddrinfo返回的是一个链表struct addrinfo *,其中的ai_familyai_socktypeai_protocol字段已经为你设置好了。你应该直接使用它返回的ai_addrai_addrlen来调用connect,而不是自己重新构造一个sockaddr_in
  • 解决:确保你的连接代码是遍历getaddrinfo返回的列表,并直接使用其成员。

问题2:能连接到本地服务器,但连不上远程服务器。

  • 排查:首先用pingtelnet命令测试网络连通性。如果远程可达,可能是防火墙阻止了出站连接。另外,检查代码中是否错误地将主机名解析为了IPv6地址(AF_INET6),而你的网络环境或服务器不支持IPv6。
  • 解决:在getaddrinfo的提示参数中,可以指定ai_family = AF_UNSPEC以获取所有地址,但在连接时优先尝试IPv4地址(ai_family == AF_INET)。

8.2 数据收发类问题

问题3:发送的数据服务器收不到,或者收到的数据不完整。

  • 排查:记住sendrecv是“可能部分完成”的。如果你的Send封装函数没有循环发送直到所有数据写完,那么当数据量大或网络忙时,就可能只发送了一部分。
  • 解决:实现一个SendAll函数,内部用一个循环调用send,并累计已发送的字节数,直到全部发送完毕或发生错误。Receive函数同理,可以循环读取直到对方关闭连接或发生错误,但更常见的做法是读取当前可用的所有数据并返回。

问题4:数据“粘包”了——多条服务器响应在客户端显示时连成了一行。

  • 排查:TCP是流式协议,没有消息边界。服务器可能分多次发送“Hello\n”和“World\n”,但客户端可能一次recv就收到了“Hello\nWorld\n”。这不是错误,而是TCP的正常行为。
  • 解决:应用层协议需要自己定义消息边界。Telnet协议本身以\r\n(CRLF)作为行结束符。你的客户端在显示数据时,应该以\r\n为分隔来换行。更简单的做法是,将接收到的所有数据直接追加到一个缓冲区,然后每次从缓冲区中查找\n,将之前的数据作为一行取出显示。

8.3 协议与显示类问题

问题5:连接后很快被服务器断开,或者显示一堆乱码。

  • 排查:这极有可能是Telnet选项协商失败。服务器发送了DO ECHOWILL SUPPRESS_GO_AHEAD等命令,但你的客户端没有回复正确的WILL/DO,或者回复了WONT/DONT。服务器可能因此认为客户端不兼容而断开连接。乱码则可能是服务器发送了ANSI颜色或光标控制序列,而你的客户端没有处理。
  • 解决:打开你的十六进制调试日志,仔细对比连接初期客户端和服务器交换的字节。确认你的协议解析器能识别并正确响应至少ECHO (1)SUPPRESS_GO_AHEAD (3)这两个常见选项。对于显示乱码,可以在输出前,过滤掉已知的控制序列(如以\033[开头的ANSI序列)。

问题6:在Windows控制台,退格键、方向键等输入产生的是多个字符序列,而不是单个键值。

  • 排查:Windows控制台在默认“行输入”模式下,会将方向键等翻译成转义序列(如0xE0 0x48代表向上箭头)。而很多Telnet服务器期望的是标准的VT100键码(如\033[A)。
  • 解决:这是一个比较棘手的问题。一种方法是使用Windows的Console API设置控制台为原始输入模式,并自行处理这些键盘事件,将其映射为标准的ANSI序列后再发送给服务器。对于学习项目,一个更简单的妥协是暂时不支持这些特殊键,或者告知用户使用PuTTY等成熟终端进行连接。

实现一个C++的Telnet客户端,就像亲手搭建一座连接本地与远程世界的小桥。从最底层的Socket API调用,到字节流的拆包组包,再到应用层协议的解析,每一步都充满了细节。这个过程里最大的收获不是最终能连接上一个服务器,而是在解决上述一个个具体问题的过程中,对网络编程的“手感”建立起来了。你会真正理解什么是阻塞与非阻塞,什么是粘包,什么是协议状态机。下次当你再使用任何网络库时,你会更清楚它帮你隐藏了什么,以及当问题出现时,该从哪个层面去思考。这个项目代码量不大,但涉及的知识点很密集,值得反复打磨。

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

Windows下OpenClaw养殖监测系统安装与配置指南

1. Windows环境下OpenClaw安装全指南 在渔业养殖数字化管理领域&#xff0c;OpenClaw作为开源的龙虾养殖监测系统&#xff0c;正逐步改变传统养殖模式。这个基于Python开发的工具包&#xff0c;通过环境传感器数据采集与分析&#xff0c;帮助养殖户实现溶解氧、水温等关键指标的…

作者头像 李华
网站建设 2026/8/11 12:22:59

Linux核心转储调试实战:从GDB技巧到生产环境分析

1. 调试技巧与核心转储分析实战指南作为一名在Linux系统调试领域摸爬滚打多年的老手&#xff0c;我处理过的核心转储文件可以堆满整个硬盘。今天想和大家分享些真正实用的调试技巧&#xff0c;特别是如何从核心转储&#xff08;core dump&#xff09;这个"系统遗书"中…

作者头像 李华
网站建设 2026/8/11 12:22:57

3步轻松下载国家中小学智慧教育平台电子课本:免费PDF获取终极指南

3步轻松下载国家中小学智慧教育平台电子课本&#xff1a;免费PDF获取终极指南 【免费下载链接】tchMaterial-parser 国家中小学智慧教育平台 电子课本下载工具&#xff0c;帮助您从智慧教育平台中获取电子课本的 PDF 文件网址并进行下载&#xff0c;让您更方便地获取课本内容。…

作者头像 李华
网站建设 2026/8/11 12:21:51

终极Windows风扇控制指南:3步打造完美散热静音系统

终极Windows风扇控制指南&#xff1a;3步打造完美散热静音系统 【免费下载链接】FanControl.Releases This is the release repository for Fan Control, a highly customizable fan controlling software for Windows. 项目地址: https://gitcode.com/GitHub_Trending/fa/Fa…

作者头像 李华
网站建设 2026/8/11 12:20:42

算法竞赛入门:牛客寒假训练营实战经验分享

1. 牛客寒假训练营初体验&#xff1a;从零开始的算法竞赛之旅去年冬天&#xff0c;我偶然在牛客网首页看到了寒假训练营的报名入口。作为一名计算机专业的大三学生&#xff0c;我正苦于找不到系统提升算法能力的途径&#xff0c;这个为期一个月的免费训练营立刻吸引了我的注意。…

作者头像 李华