ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

VC++ Telnet客户端源码拆解:Winsock编程与IAC协议实现

VC++ Telnet客户端源码拆解:Winsock编程与IAC协议实现 简介面向初学网络编程的VC开发者这套源码实现了一个类似于Windows Telnet的远程登录客户端基于Winsock库完成TCP/IP通信涵盖WSAStartup初始化、socket创建、gethostbyname/getaddrinfo域名解析、connect建连、send/recv收发数据、Telnet IAC命令处理以及closesocket与WSACleanup清理等关键环节有助于理解网络编程的整体流程。压缩包共18个文件大小仅14KB包含7个头文件、6个C源文件以及VC工程文件dsw/dsp、类向导文件clw、资源脚本rc和一份说明文本头文件与源文件对应Socket收发、Telnet协议解析等模块便于按模块阅读。已有272人学习下载适合需要参考实际代码来掌握Telnet客户端实现的读者。借助这份代码可以直观看到Winsock编程中缓冲区的使用方式、阻塞与非阻塞收发对界面响应的影响以及关闭连接时应注意的清理顺序说明文本又补充了实现思路适合在此基础上扩展命令历史、自动补全等功能作为从基础API走向完整应用的过渡练习。1. 把 VC Telnet 客户端源码包拆开它和 Windows Telnet 差在哪Windows 自带的 telnet.exe 临时登一下设备当然够用可一旦要做自动化巡检、改界面、对接内部系统你面对的就是一个黑匣子。这份资源正好把黑匣子打开了一个用 Visual C 写成的 Telnet 客户端完整工程包含 Winsock 封装、收/发线程、Telnet 协议解析IAC和 MFC 界面基本实现了 Windows Telnet 相似的功能。它适合两类人一类是刚学 Winsock 想找完整例子的新手一类是要在 Windows 上定制 Telnet 客户端的熟手——在这个工程上改比从 socket() 写到 connect() 快得多。下面按我拆工程时的顺序过一遍文件职责、连接建立、协议协商、常见踩坑和验证方法。2. 工程文件梳理看清 TelnetClient 的骨架和编译顺序把压缩包解压后你看到的第一反应应该是“一堆名字看不懂的文件”。这不是一个庞大的工程但文件命名并不直观尤其是 SocketDx、SocketRx、SocketTx 这三个光看名字很难猜谁负责干什么。我一般先把文件职责映射出来再决定从哪里下手改。2.1 源码文件职责SocketRx/SocketTx 与 ProtocolRx 的分层关系先看一份文件清单这个包里的核心源文件就这么几个文件职责Telnet.cpp / Telnet.hMFC 主窗口类处理菜单、按钮、回显显示和线程调度SocketDx.cpp / SocketDx.hWinsock 底层封装socket()、connect()、closesocket()SocketRx.cpp / SocketRx.h接收线程入口阻塞 recv() 后把字节流交给上层SocketTx.cpp / SocketTx.h发送逻辑组织用户输入并调用 send()ProtocolRx.cpp / ProtocolRx.hTelnet 协议解析把 IAC 控制序列从普通文本中分离Telnet.dsw / Telnet.dspVC6 的工程文件workspace 和 projectTelnet.rc / Resource.hMFC 对话框资源窗口布局和控件 IDStdAfx.cpp / StdAfx.h预编译头整个工程的头文件都在这里聚合这个分层思路是合理的网络收发和协议解析分离。SocketRx/SocketTx 只负责“把字节流稳定地搬进来、送出去”ProtocolRx 只负责“从字节流里认出 Telnet 控制命令”。你在 Telnet.cpp 里加菜单、加按钮不用动协议层反过来要改 Telnet 协商逻辑也不会碰界面代码。打开 StdAfx.h你会看到 Winsock 的引入方式。常见写法是这样// StdAfx.h 头部 #define WIN32_LEAN_AND_MEAN #include winsock2.h #include ws2tcpip.h #include windows.hWIN32_LEAN_AND_MEAN的作用是让 windows.h 不再自动包含老版的 winsock.h否则 winsock2.h 里的定义会和它冲突。这个宏在 VC6 时代还不那么必要到新版 Visual Studio 里几乎是必须处理的。如果你在 Telnet.h 或 Telnet.cpp 里看到类似的代码意思是一样的。2.2 从 .dsw/.dsp 看编译入口和库依赖ws2_32.lib 不能漏VC6 的工程是 .dsw 套 .dsp.dsw 是工作区.dsp 才是真正的项目文件。新版 Visual Studio 对 VC6 工程的直接支持很弱所以第一步我会用文本编辑器打开 Telnet.dsp搜索ws2_32确认网络库有没有进链接参数findstr /i ws2_32 Telnet.dsp正常你会看到类似# ADD LINK32 ... ws2_32.lib ...的一行。如果搜不到编译时就会在链接阶段报unresolved external symbol __imp__WSAStartup8因为 WSAStartup、socket、connect 这些函数都在 ws2_32.lib 里。这个库不是默认链接的必须显式加进去。顺带说一句VC6 工程里常见的运行时库是单线程版/ML在 Win7 以上的系统里可能因为运行库缺失而启动报错。我后来基本不用 VC6 IDE而是把源码抽出来放进新项目链接参数改成多线程 DLL/MD并显式链接 ws2_32.lib稳定得多。2.3 编译前必须做的三处小手术VC6 工程迁移新环境的处理如果你手头没有 VC6直接双击 Telnet.dsw 会弹出“不支持的工程格式”。我的做法是新建一个空项目把源码文件按原目录结构拖进去再做三处调整。第一项目属性里把“使用 MFC”设为“在共享 DLL 中使用 MFC”否则 Telnet.cpp 里依赖的对话框类无法解析。第二预处理器定义里加上WIN32_LEAN_AND_MEAN避免 winsock.h 和 winsock2.h 冲突。第三链接器输入里补上 ws2_32.lib。如果你想用 CMake 快速验证一版可以这样组织cmake_minimum_required(VERSION 3.20) project(telnetclient) add_executable(telnetclient WIN32 Telnet.cpp SocketDx.cpp SocketRx.cpp SocketTx.cpp ProtocolRx.cpp StdAfx.cpp ) target_link_libraries(telnetclient ws2_32)WIN32参数告诉 CMake 这是一个 Windows GUI 程序不弹控制台。target_link_libraries里的ws2_32是 Winsock 库的 CMake 写法对应 VC6 里的 ws2_32.lib。这样一改源码包就能脱离 VC6 环境在较新的工具链上编出可执行文件。3. 连接一台 Telnet 服务器Winsock 初始化到 connect 的代码路径文件结构和编译通道打通后接下来关心的是程序怎么和设备建立连接。Telnet 是标准的 TCP 客户端服务端模型这一段的代码路径基本固定初始化 Winsock → 解析地址 → 创建 socket → connect → 收发数据 → 关闭清理。下面按这个顺序拆解。3.1 初始化 Winsock为什么必须指定 2.2 而不是 1.1WSAStartup 是所有 Winsock 程序的第一步它的作用不是“打开网络”而是向系统申请一个可用的 Winsock 实现版本。常见的错误是随手写MAKEWORD(1, 1)在运行库老的机器上没什么问题但在新版 Windows 里很多扩展函数和 IPv6 支持都依赖 2.2。int InitWinsock() { WSADATA wsa; // 请求 2.2 版本 int rc WSAStartup(MAKEWORD(2, 2), wsa); if (rc ! 0) { // rc 本身是错误码配合 WSAGetLastError() 看更具体 return -1; } // 系统实际返回的版本 if (LOBYTE(wsa.wVersion) ! 2 || HIBYTE(wsa.wVersion) ! 2) { WSACleanup(); return -2; } return 0; }MAKEWORD(2, 2)的高低位分别代表主版本和次版本。WSADATA.wVersion是系统实际支持的版本校验这一步的目的是防止你在只支持 1.1 的老系统上硬用 2.2 的函数。如果 WSAStartup 返回WSAEINPROGRESS说明系统里已经有一个阻塞操作在运行这种情况在单线程初始化里很少见。我一般会在调试时直接把wsa.szDescription打到输出窗口确认系统加载的是哪个版本的实现。3.2 地址解析与 connectgetaddrinfo 比 gethostbyname 好在哪里工程里如果用的是老 API gethostbyname在 IPv6 和 DNS 返回多个地址的场景下会显得很笨。新代码我习惯用 getaddrinfo它把“解析地址”和“创建 socket 的线索”合在一起返回值直接就能传给 socket() 和 connect()少踩很多坑。struct addrinfo hints { 0 }; hints.ai_family AF_INET; // 只用 IPv4需要 IPv6 可改 AF_UNSPEC hints.ai_socktype SOCK_STREAM; // TCP 字节流 hints.ai_protocol IPPROTO_TCP; struct addrinfo* res NULL; int rc getaddrinfo(192.168.1.1, 23, hints, res); if (rc ! 0) { // rc 是 EAI_ 开头的错误码gai_strerror(rc) 能看到原因 return SOCKET_ERROR; } SOCKET s socket(res-ai_family, res-ai_socktype, res-ai_protocol); if (s INVALID_SOCKET) { freeaddrinfo(res); return SOCKET_ERROR; } int cr connect(s, res-ai_addr, (int)res-ai_addrlen); freeaddrinfo(res); if (cr SOCKET_ERROR) { closesocket(s); return SOCKET_ERROR; }hints初始化为全零再设置三个字段避免残留数据影响解析结果。getaddrinfo的res是一个链表在支持多 DNS 结果的系统里可能返回多个地址这里只取了第一个如果你想做容错可以遍历 res逐个尝试 connect直到成功。connect 的第三个参数要强转成 int这是 Winsock 和 POSIX 在类型上的一个差异VC6 编译时不给提示新版编译器会较真写上比较稳妥。3.3 收数据与发数据阻塞 recv 线程和发送缓冲的设计连接建立后真正的 Telnet 交互是持续的。我见过很多新手把 recv 直接放在主窗口的定时器里结果一收到数据界面就卡。常见做法是起一个线程专门 recv收到数据后通过 PostMessage 通知窗口刷新。UINT RecvThreadProc(LPVOID param) { SOCKET s (SOCKET)param; char buf[4096]; for (;;) { int n recv(s, buf, sizeof(buf), 0); if (n 0) break; // 对端正常关闭 if (n SOCKET_ERROR) { int err WSAGetLastError(); if (err WSAEWOULDBLOCK) continue; // 非阻塞模式 break; // 其余错误按断线处理 } // 交给协议解析层塞进接收缓冲区 ProtocolRx::Instance()-Push(buf, n); // PostMessage 通知主窗口刷新显示 } return 0; }recv的返回值是收到字节数n 为 0 表示对端优雅关闭可以退出线程。WSAEWOULDBLOCK只在非阻塞模式下出现意思是“当前没有数据”继续等就行。这里的ProtocolRx::Push是一个线程安全的接口因为 recv 线程和 UI 线程同时操作同一个缓冲区不加锁的话数据会被撕碎。发送侧相对简单但也要注意 send 并不保证一次发完需要记录剩余字节数循环发送直到全部写完。3.4 断开与清理closesocket 和 WSACleanup 的执行顺序断开连接时有个容易踩的坑先关 socket 再等线程退出或者反过来顺序错了程序就崩溃。正确顺序是先通知线程退出比如用一个volatile bool标志位等线程把最后一批数据解析完并退出后再调用 closesocket最后 WSACleanup。g_bRunning false; WaitForSingleObject(hThread, 3000); closesocket(sock); WSACleanup();这里WaitForSingleObject的 3000 毫秒是超时时间防止线程被卡死在 recv 里。如果 recv 线程还阻塞着closesocket 会把 socket 置为不可用阻塞的 recv 会立即返回 SOCKET_ERROR线程自然退出。所以两个顺序其实相互依赖一个稳妥的做法是先关闭 socket 让 recv 返回再等待线程结束最后 WSACleanup。资源工程里如果按“先 WSACleanup 再 closesocket”写后一次调用会失败错误码是 WSAENETDOWN。4. Telnet 协议细节IAC 协商与回显模式代码里怎么落地TCP 连接只是管道真正让一个客户端像 Windows Telnet 的是 Telnet 协议本身。这一章我围绕协议解析层展开重点说 IAC 命令和回显协商这是大多数自制 Telnet 客户端最粗糙的地方。4.1 Telnet 的带内命令0xFF 转义和命令字含义Telnet 的数据和控制命令走同一条 TCP 流靠一个字节 0xFFIACInterpret As Command作为标记。凡是 0xFF 开头的序列都要当作命令处理而不是普通文本。标准命令字如下字节值名称含义0xFFIAC命令起始标记0xFBWILL发送方希望启用某选项0xFCWONT发送方拒绝启用某选项0xFDDO请求对方启用某选项0xFEDONT请求对方关闭某选项还有一个更隐蔽的规则普通文本流里如果真的出现 0xFF发送方必须把它打成两个 0xFF接收方看到两个连续的 0xFF要主动丢掉一个保留一个作为正文。这个设计是为了在二进制传输时不把数据误判成命令。如果你在协议解析里忘了做这个还原收到的内容就会凭空多出来一个字符。4.2 最小协商状态机WILL/WONT/DO/DONT 的解析实现协议解析层要做的事情可以看成一个有限状态机。状态在“文本数据”和“IAC 命令”之间切换。我提供一个最小实现思路和 SocketRx 的字节流对接enum ParseState { ST_TEXT, ST_IAC, ST_CMD, ST_OPT }; int ParseStateMachine(const char* data, int len) { for (int i 0; i len; i) { unsigned char ch (unsigned char)data[i]; switch (state_) { case ST_TEXT: if (ch IAC) { state_ ST_IAC; } else { OnText(ch); // 普通文本交给显示层 } break; case ST_IAC: if (ch IAC) { OnText(0xFF); // 连续两个 0xFF还原为正文 state_ ST_TEXT; } else { cmd_ ch; // WILL / WONT / DO / DONT state_ ST_CMD; } break; case ST_CMD: opt_ ch; // 选项编号 state_ ST_OPT; break; case ST_OPT: OnCommand(cmd_, opt_); // 凑齐 命令 选项 state_ ST_TEXT; break; } } return 0; }状态机的好处是无论收到的数据是半个命令还是横跨两个 TCP 包都能完整解析。state_是跨 recv 调用保存的所以必须在类成员变量里定义不能是局部变量。这个实现没有处理“不知道的选项”的情况但基本框架够用。如果你的工程里还处理了选项子协商IAC SB ... IAC SE需要再套一层状态这类子协商通常用于终端类型和窗口大小。4.3 ECHO 协商与本地回显DO ECHO 来了该回 WILL 还是 WONT回显协商是 Telnet 客户端最影响手感的部分。很多 Linux 服务端连接建立后会向客户端发 DO ECHO意思是“请你把用户敲的字符回显到屏幕上”。客户端如果完全不管会出现“敲命令看不到字”的诡异现象。正确的最小应答逻辑是客户端本地如果已经打开回显就回 WILL ECHO如果要隐藏输入比如输入密码时不希望本地回显就回 WONT ECHO。反过来服务端发 DONT ECHO 时客户端回 WONT ECHO表示“行我不回显了”。void OnCommand(unsigned char cmd, unsigned char opt) { switch (opt) { case TELNET_OPT_ECHO: // 选项 1回显 if (cmd DO) { if (g_bLocalEcho) { SendIac(WILL, TELNET_OPT_ECHO); } else { SendIac(WONT, TELNET_OPT_ECHO); } } else if (cmd DONT) { SendIac(WONT, TELNET_OPT_ECHO); } break; default: // 未知选项统一回 WONT 或 DONT SendIac(WONT, opt); break; } }SendIac的实现就是组装两个或三个字节发出去比如char iac[] {0xFF, WILL, opt}。这里的关键是默认分支回 WONT避免客户端承诺了某个根本没实现的选项。许多自制 Telnet 客户端连上设备后一切正常唯独方向键出现乱码就是因为没有正确响应终端选项的协商服务端认为客户端支持某项能力结果客户端根本没处理。5. 避坑排查从编译失败到登录无回显的常见问题清单从源码包拿到手到真正能连上设备并稳定交互中间会遇到几个高频率问题。我按实际排错顺序写下来每个都是“现象 → 原因 → 解决”的完整链路。5.1 编译winsock2.h 和 windows.h 的包含顺序冲突现象编译时报 “winsock.h 已经包含” 或 “fatal error C1189: WINSOCK2 定义冲突”。原因windows.h 在默认配置下会先展开旧版 winsock.h随后代码再引入 winsock2.h两个头文件里的同名类型定义互相覆盖编译器直接罢工。解决在预编译头里统一控制顺序。我的固定套路是在 StdAfx.h 最前面加#define WIN32_LEAN_AND_MEAN它会禁止 windows.h 拉入老版 winsock.h然后才按 winsock2.h、ws2tcpip.h、windows.h 的顺序包含。注意WIN32_LEAN_AND_MEAN必须在包含任何 Windows 头之前定义。5.2 连接connect 返回 10061或 Telnet 测试卡在 10060现象在客户端输入 IP 和端口后点“连接”界面提示“连接失败错误 10061”或者一直转圈最后超时先用命令行telnet 192.168.1.1 23也一样连不上。原因10061 是 WSAECONNREFUSED表示目标服务器上的端口没有监听或者被防火墙直接拒绝10060 是 WSAETIMEDOUT往往是防火墙丢包或路由不通。还有一个常见原因是 Windows 自己没启用 Telnet 客户端功能命令行的 telnet 不存在的直接表现是“不是内部或外部命令”。解决先确认 Windows Telnet 功能已经打开用管理员权限执行dism /online /Enable-Feature /FeatureName:TelnetClient再用 PowerShell 的 Test-NetConnection 做一次端口连通性检查Test-NetConnection 192.168.1.1 -Port 23如果 TcpTestSucceeded 为 False基本可以断定问题在服务端或网络而不是你这份源码。这一步把自制客户端和服务端问题分开能省不少排错时间。5.3 数据有去无回IAC 被当普通文本显示导致协议死等现象能 connect但连上后界面没有任何提示敲回车也没反应或者屏幕上出现一堆ÿý之类的乱码字符。原因ÿý是 0xFF 0xFD 的 ASCII 显示结果。0xFF 是 IAC没有经过协议解析层处理直接把协商命令当作普通文本显示了。更严重的情况是服务端发出 DO ECHO 后等客户端回 WILL ECHO客户端不回服务端服务端状态一直挂在协商阶段后续任何数据都不会正常进入。解决确认接收路径确实经过了 ProtocolRx 的解析而不是从 recv 之后直接送到了编辑框。调试时我给 ProtocolRx 加了一个十六进制输出开关把所有收到的原始字节打印出来肉眼识别FF FD 01、FF FB 01这类序列。只要看到原始字节流就说明协议层没过滤干净。另一个自查点是状态机是否跨 recv 保存了状态如果每次 recv 都用局部变量初始化 state_多包数据永远解不对。5.4 中文乱码GBK、UTF-8、ANSI 的转换边界现象连 Windows 设备时中文显示正常连 Linux 服务器后中文全变问号或者从本地粘贴中文命令设备端看到的是一串乱码。原因Windows 默认使用 GBK/CP936 代码页Linux 等许多设备使用 UTF-8。Telnet 协议本身不指定字符编码乱码问题完全是两端代码页不一致导致的。输入和输出还可能不对称输出要按服务端编码解码输入要先把自己编辑器里的编码转成服务端能认的编码。解决在发送和接收两个方向分别加一个编码开关。接收方向把 UTF-8 转成 GBK 再交给显示控件发送方向把界面上的 GBK 转成 UTF-8 再 send。Windows 上可以用两层转换int GBKToUTF8(const char* gbk, char* out, int outLen) { int wLen MultiByteToWideChar(CP_ACP, 0, gbk, -1, NULL, 0); wchar_t* wbuf new wchar_t[wLen]; MultiByteToWideChar(CP_ACP, 0, gbk, -1, wbuf, wLen); int uLen WideCharToMultiByte(CP_UTF8, 0, wbuf, wLen, NULL, 0, NULL, NULL); WideCharToMultiByte(CP_UTF8, 0, wbuf, wLen, out, uLen, NULL, NULL); delete[] wbuf; out[uLen] 0; return uLen; }这里MultiByteToWideChar(CP_ACP,...)把当前 ANSI 代码页转成 UTF-16WideCharToMultiByte(CP_UTF8,...)再转到 UTF-8。实际工程里我不会无条件转而是提供一个接口按连接设备的品牌类型设置编码模式。老式网络设备多半要 GBK 或 ASCIILinux 设备才用 UTF-8这个不能一刀切。6. 进阶验证用 Python 模拟服务器验证 IAC 协商再把 Nagle 关掉连真实设备调试协议回显最大的问题是设备行为不可控。我后来养成了一个习惯先用本地 Python 脚本模拟一个 Telnet 服务器把协商过程固定下来脚本跑通了再连真实设备。import socket srv socket.socket() srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((127.0.0.1, 2323)) srv.listen(1) conn, addr srv.accept() conn.sendall(b\xff\xfd\x01) # IAC DO ECHO while True: data conn.recv(4096) if not data: break print(RX:, data.hex()) if data.find(b\xff\xfb\x01) 0: # 客户端回 WILL ECHO conn.sendall(becho ok\r\n)这个脚本只监听本地 2323 端口收到客户端发来的 WILL ECHO 后给一个提示响应。你先用命令行telnet 127.0.0.1 2323跑一遍记录它发出的 IAC 序列再用自己的客户端连一遍比较两次的应答是否一致。WILL 和 WONT 的选择很能说明问题如果脚本收到 WONT ECHO说明客户端当前处于隐藏输入模式符合预期。另外一个小技巧是 connect 成功后关闭 Nagle 算法。Telnet 是交互式协议用户每敲一个键就发一个包如果 Nagle 算法等待前面的 ACK按键就会明显延迟。关闭方法很简单BOOL nodelay TRUE; setsockopt(s, IPPROTO_TCP, TCP_NODELAY, (const char*)nodelay, sizeof(nodelay));TCP_NODELAY让每个 send 都立刻发出不攒小包本地交互的手感立刻接近 Windows 自带 telnet。这个选项在局域网里几乎无副作用公网远程登录时可以考虑按需开关。从那以后我每次改完协议解析都会强制用这套 Python 脚本把 IAC 协商跑一遍确认回显应答和预期一致再去连真实设备省掉了大量在设备端试错的时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表