简介:基于Visual C++打造的Telnet客户端工程,面向希望从零掌握Winsock网络编程的开发者,可用来理解并复现Windows Telnet的远程登录与命令交互机制。项目围绕TCP/IP协议栈展开,完整覆盖WSAStartup环境初始化、socket套接字创建、gethostbyname域名解析、connect服务端连接、send/recv数据收发等核心流程,并实现Telnet协议中IAC命令的解析处理,对熟悉底层通信原理和Windows API调用都有很好的实战价值。压缩包共包含18个文件,体积仅14KB,以7个.h头文件与6个.cpp源文件为主干,构成SocketRx、SocketTx、ProtocolRx等清晰模块,同时附带.dsw、.dsp、.clw、.rc等Visual C++工程文件,可直接导入开发环境进行编译调试和二次修改。已有272人学习浏览,此份代码量精简、结构完整,是网络编程课程设计或Socket入门实践不可多得的参考范例,后续还可据此扩展命令历史、输入自动补全等交互功能。
1. 用 VC++ 重写 Telnet:老协议工具为什么还要自己造一遍
Windows 自带的 Telnet 客户端够用,但当你需要把"telnet ip 端口 命令"这种连通性探测写进脚本、或者要自定义终端类型和回显行为时,自带客户端就是个黑匣子。这个标题的落点很直接:用 Visual C++ 的 Winsock API 从零实现一个和 Windows Telnet 类似的远程终端工具,能连接 23 端口的 Telnet 服务,也能用来探测任意 TCP 端口是否通。适合做运维脚本、内网调试和网络工具开发的人。我自己做这个功能,最早只是为了批量检查一批交换机的 23 端口是否开放,后来把协议协商理清后,连中文乱码和回显异常这些自带客户端的毛病也顺手解决了。
2. 从 RFC 854 到 Winsock:Telnet 的两层骨架与协议映射
写 Telnet 客户端之前先要分清两层东西:上层是 RFC 854 定义的 Telnet 应用协议,下层是 Winsock 提供的 TCP 套接字。很多人把"用 socket 连 23 端口"当成实现了 Telnet,结果连上后服务器不发数据、或者发来一堆带 0xFF 的乱码,问题都出在协议层。标题里"实现 Telnet 的 window socket 调用",指的就是把这两层都补齐,而不是只调几个 socket API。
2.1 NVT 虚拟终端:Telnet 传输的字节约定
RFC 854 里有个核心概念叫 NVT(Network Virtual Terminal,网络虚拟终端)。客户端和服务器不再直接交换终端字节流,而是都先映射成 NVT 规定的中间格式。这个设计是为了让不同系统(Unix、Windows、大型机)能互相通信,代价是两端都得多做一层转换。
NVT 的字节约定要记住三点:数据是 8 位无校验字节流;行结束符统一用 CRLF(0x0D 0x0A)表示,发送方把本机换行转成 CRLF 发出,接收方再把 CRLF 转回本机换行;从 NVT 到本机的转换是接收方的责任。这三点直接决定你的显示逻辑:收数据后是原样打印,还是做换行修正。
实际坑在于,很多 Telnet 服务器(尤其 Linux 的 telnetd 和各类网络设备)只发 LF,不发 CRLF;还有的 ECHO 回显时把 CRLF 拆开。客户端如果做严格校验,看到 0x0D 后必须等 0x0A,很容易把输入卡住。我一般做宽松处理:0x0A 一律当换行打印,0x0D 如果是独立的也当回车处理,不强制配对。这个"宽容接收"原则后面解析 IAC 时同样适用。
2.2 选项协商:IAC/WILL/DO 应答是通信的前戏
Telnet 连接建立后,服务器会先发一串选项协商,然后才发横幅或登录提示。协商字节以 IAC(0xFF)开头,后面跟命令:WILL(0xFB)表示"我要启用某选项",DO(0xFD)表示"请你启用某选项",WONT(0xFC)表示"我不启用",DONT(0xFE)表示"请你别用"。再后面跟一个选项码,所以最短的协商包是 3 字节:IAC + 命令 + 选项码。
常见选项码可以参考下表:
| 选项码 | 选项名 | 含义 |
|---|---|---|
| 0x01 | ECHO | 回显,请求对方把输入回显出来 |
| 0x03 | SUPPRESS-GO-AHEAD | 取消轮询信号,现代实现都支持 |
| 0x05 | STATUS | 查询当前协商状态 |
| 0x18 | TERMINAL TYPE | 终端类型,如 xterm、vt100 |
| 0x1F | NAWS | 窗口尺寸协商 |
Windows 自带 Telnet 客户端连上后,服务器通常会发 WILL ECHO(0xFF 0xFB 0x01),意思是"我这边来做回显"。如果你不回 DO 或 DONT,服务器可能停在协商阶段,横幅一直不来。我们的策略很简单:客户端收到 WILL 就回 DONT,收到 DO 就回 WONT,把除裸终端通道以外的选项全拒掉。
全拒不是偷懒。只要双方同意某个选项,后续就必须遵守它;全拒等于回到最朴素的 NVT 模式,收发都由本地控制台处理,代码量最小、行为最可控。终端类型这种显式请求,后续要做特殊交互时可以单独应答,最小实现里先全拒。
还要注意 0xFF 本身的转义:数据里连续出现两个 0xFF 表示一个真实数据字节 0xFF。解析时不能把第二个 0xFF 当成新 IAC 的开头。这个细节容易漏,漏了会导致终端颜色控制序列解析错位,屏幕上冒出一堆 "^["。
2.3 Winsock 初始化与套接字生命周期:先搞清楚 API 调用顺序
Winsock 的调用顺序有严格约定,用 VC++ 写时记得链接 ws2_32.lib。VC6 到 VS2022 接口都没变过,工程里加一句#pragma comment(lib, "ws2_32.lib")最省事,也可以在项目属性里手动附加依赖。
标准调用顺序是:先调 WSAStartup(MAKEWORD(2,2), &wsaData) 完成版本协商;再用 socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) 创建 TCP 套接字;然后 connect 建立连接;之后 send/recv 收发数据;结束用 closesocket 关闭套接字;最后全局调 WSACleanup 释放 Winsock 资源。
几个容易踩的点。WSAStartup 必须在任何 socket 调用之前,且返回值必须判 0。每次调用返回 SOCKET_ERROR 时用 WSAGetLastError() 拿错误码。阻塞模式 connect 的默认超时是系统级的,经常要等 20 秒以上,端口不通时体验极差,我一般先把套接字切非阻塞再用 select 等可写事件,配合 3 秒超时。closesocket 只关单个套接字,WSACleanup 是全局清理,程序里创建了多个套接字时,要全部 close 后再调 WSACleanup。
3. 最小 Telnet 客户端落地:从 connect 到命令交互的完整代码
这一章把骨架落成 VC++ 控制台程序。我的做法是 main 里完成 Winsock 初始化和连接,然后创建接收子线程,主线程循环读键盘输入并发送。完整代码分三段讲,每段都能单独编译验证。
3.1 套接字建立与带超时的 connect
先看连接部分。这里用非阻塞 connect + select 实现 3 秒超时,解决端口不通时无限等待的问题。
#include <winsock2.h> #include <ws2tcpip.h> #include <stdio.h> #pragma comment(lib, "ws2_32.lib") SOCKET connect_with_timeout(const char* ip, int port, int timeout_sec) { WSADATA wsa; if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) { printf("WSAStartup failed: %d\n", WSAGetLastError()); return INVALID_SOCKET; } SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (s == INVALID_SOCKET) { printf("socket failed: %d\n", WSAGetLastError()); WSACleanup(); return INVALID_SOCKET; } struct sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_port = htons((u_short)port); inet_pton(AF_INET, ip, &addr.sin_addr); // 先切非阻塞,再发起连接 u_long mode = 1; ioctlsocket(s, FIONBIO, &mode); int ret = connect(s, (struct sockaddr*)&addr, sizeof(addr)); if (ret == SOCKET_ERROR) { int err = WSAGetLastError(); if (err == WSAEWOULDBLOCK) { // 连接仍在进行,用 select 轮询可写状态 fd_set wset; FD_ZERO(&wset); FD_SET(s, &wset); struct timeval tv; tv.tv_sec = timeout_sec; tv.tv_usec = 0; int sel = select(0, NULL, &wset, NULL, &tv); if (sel == 0) { printf("connect timeout after %d seconds\n", timeout_sec); closesocket(s); WSACleanup(); return INVALID_SOCKET; } // 可写不代表一定成功,必须读 SO_ERROR 确认 int err2 = 0; int len2 = sizeof(err2); getsockopt(s, SOL_SOCKET, SO_ERROR, (char*)&err2, &len2); if (err2 != 0) { printf("connect failed: %d\n", err2); closesocket(s); WSACleanup(); return INVALID_SOCKET; } } else { printf("connect failed immediately: %d\n", err); closesocket(s); WSACleanup(); return INVALID_SOCKET; } } // 恢复阻塞模式,方便后续 recv 等待数据 mode = 0; ioctlsocket(s, FIONBIO, &mode); return s; }逻辑说明:非阻塞 connect 发起后立即返回 WSAEWOULDBLOCK,select 等待可写事件;select 返回 1 后还要用 getsockopt 读 SO_ERROR,因为连接失败时套接字也可能变成可写。最后恢复阻塞模式,让 recv 像普通 TCP 那样阻塞在等待数据上。
参数说明:timeout_sec 控制探测超时,连通性检测场景一般设 3 秒;ip 支持点分十进制,inet_pton 在 Windows 7 及以上都可用。这段代码把 WSAStartup 和 WSACleanup 的配对也做在了里面,实际工程里最好把初始化拆到 main 里,避免反复调用。
为什么不直接阻塞 connect?Windows 的 TCP 连接超时由注册表 TcpMaxConnectRetransmissions 控制,往往要等 21 秒。做批量端口巡检时,每台设备等 20 多秒不现实,3 秒超时才能接受。
3.2 收发循环与 IAC 序列过滤
连接建立后是收数据。recv 拿到的是原始字节流,中间混着 IAC 协商序列,不能直接交给 printf。下面是过滤函数:
void process_recv_data(SOCKET s, char* buf, int len) { int i = 0; while (i < len) { unsigned char c = (unsigned char)buf[i]; if (c == 0xFF) { // IAC 起始字节 if (i + 1 < len) { unsigned char cmd = (unsigned char)buf[i + 1]; if (cmd == 0xFF) { // 0xFF 0xFF 表示真实数据 0xFF putchar(0xFF); i += 2; continue; } if (cmd == 0xFB) { // WILL,拒绝对方启用 char reply[3] = { (char)0xFF, (char)0xFC, buf[i + 2] }; send(s, reply, 3, 0); i += 3; continue; } if (cmd == 0xFD) { // DO,拒绝对方请求 char reply[3] = { (char)0xFF, (char)0xFC, buf[i + 2] }; send(s, reply, 3, 0); i += 3; continue; } if (cmd == 0xFC || cmd == 0xFE) { // WONT / DONT 可以不应答 i += 3; continue; } if (cmd == 0xF9 || cmd == 0xFA) { // 子协商 IAC SB ... IAC SE,长度不定,跳过 i += 2; while (i < len) { if ((unsigned char)buf[i] == 0xFF && i + 1 < len && (unsigned char)buf[i + 1] == 0xF0) { i += 2; break; } i++; } continue; } } // 无法解析的 IAC 序列,跳过起始字节 i++; continue; } // 普通字节:把裸 LF 转成换行打印 if (c == 0x0A) { putchar('\n'); } else { putchar(c); } i++; } fflush(stdout); }逻辑说明:函数按字节遍历,以 0xFF 为界识别 IAC 序列。WILL 和 DO 都回 WONT 拒绝,子协商段 SB...SE 直接跳过,其余字节原样打印。收到 0x0A 输出换行,避免服务器只发 LF 时出现阶梯显示。
参数说明:回复包 reply 固定 3 字节:IAC + WONT + 对方请求的选项码。放心全拒的原因是最小实现不依赖回显、终端类型这些能力,本地控制台自己处理回显,服务器只需透传数据。这个函数要在多个 recv 之间保持状态,半包时 IAC 头到了尾巴没到,下一次 recv 继续处理即可。
3.3 用两个线程维持交互:主线程发、子线程收
Telnet 交互是同时发生的双向通信:一边收服务器数据,一边读本地键盘。最简单可靠的结构是接收子线程加主线程发送循环。接收线程调 process_recv_data,主线程用 fgets 读一行输入再 send。
#include <windows.h> SOCKET g_sock; volatile int g_running = 1; DWORD WINAPI recv_thread(LPVOID param) { char buf[4096]; while (g_running) { int n = recv(g_sock, buf, sizeof(buf), 0); if (n > 0) { process_recv_data(g_sock, buf, n); } else if (n == 0) { printf("\n[connection closed by server]\n"); g_running = 0; break; } else { int err = WSAGetLastError(); if (err != WSAEWOULDBLOCK) { printf("\n[recv error: %d]\n", err); g_running = 0; break; } Sleep(10); } } return 0; } int main(int argc, char* argv[]) { // 演示用固定地址,实际工程见第 5 章参数解析 g_sock = connect_with_timeout("127.0.0.1", 23, 3); if (g_sock == INVALID_SOCKET) { return 1; } HANDLE h = CreateThread(NULL, 0, recv_thread, NULL, 0, NULL); if (h == NULL) { closesocket(g_sock); WSACleanup(); return 1; } CloseHandle(h); printf("[connected, type exit to quit]\n"); char line[2048]; while (g_running && fgets(line, sizeof(line), stdin) != NULL) { int len = (int)strlen(line); // 去掉 fgets 带进来的换行符 if (len > 0 && line[len - 1] == '\n') { line[len - 1] = '\0'; } if (strcmp(line, "exit") == 0) break; // Telnet 协议要求行结束符是 CRLF send(g_sock, line, (int)strlen(line), 0); char crlf[2] = { (char)0x0D, (char)0x0A }; send(g_sock, crlf, 2, 0); // 因为拒绝了服务器 ECHO,需要本地回显 printf("%s\r\n", line); } g_running = 0; closesocket(g_sock); WSACleanup(); return 0; }逻辑说明:接收线程阻塞在 recv 上,服务器关闭连接或出错时通过 g_running 标志通知主线程退出。主线程把每行输入送到服务器并附 CRLF 作为行结束,由于拒绝了服务器 ECHO,本地要做回显。
参数说明:recv 缓冲 4096 字节对横幅和菜单足够,做文件传输时要加大。Sleep(10) 只在非阻塞模式下有意义,阻塞模式下 recv 不会返回 WSAEWOULDBLOCK。这段代码编译后连 127.0.0.1:23,配合第 5 章的验证服务,可以完整跑通一次登录、命令、退出流程。
4. 避坑排查:Telnet 测试不显示数据、乱码与粘包的 5 个经典现场
自己写 Telnet 客户端,最容易翻车的不是 connect,而是连上之后的表现。下面 5 个问题我全踩过,按"现象、原因、解决"整理。前三条基本覆盖了搜索里"telnet 测试不显示数据""telnet ip 端口 命令怎么看通不通"的大多数场景。
4.1 WSAStartup 返回 10093:初始化顺序的坑
现象:程序一跑就输出 failed: 10093,或者 socket 创建直接返回 INVALID_SOCKET。
原因:10093 是 WSANOTINITIALISED,意思是 Winsock 没初始化就调用了 socket、connect。常见于 WSADATA 声明成局部变量但初始化代码被条件分支跳过,或者基类初始化顺序没控制好,静态库提前触发了 socket 调用。
解决:在 main 入口最前面无条件调 WSAStartup 并判返回值,退出路径统一调 WSACleanup。多线程程序不要在子线程里重复调用 WSAStartup,全局一次即可。这是最基础的一条,却一直是新手提问最多的问题。
4.2 connect 报 10061/10060:端口没开还是防火墙拦截
现象:connect 失败,错误码 10061(目标主动拒绝)或 10060(连接超时)。
原因:10061 说明收到 TCP RST,目标端口没监听,或者监听程序不允许地址访问;10060 说明 SYN 发出后没人应答,中间设备丢包或防火墙 DROP 策略最常见。
解决:先在本机 netstat -ano | findstr :23 确认服务在听。再用 Windows 自带 Telnet 客户端做对照,如果自带也超时,基本是网络路径或防火墙问题,不用再查代码。写程序探测大量端口时,我的习惯是把 10061 记为 closed、10060 记为 filtered,两者在巡检报告里含义不一样。
4.3 连上后不显示数据:选项协商没人应答导致服务器空转
现象:connect 成功但屏幕全黑,横幅和登录提示一直不来。用自带 telnet 正常,用自己的程序黑屏。
原因:这是实现 Telnet 最常见的问题。服务器建连后先发 IAC 协商(比如 WILL ECHO),然后等服务端应答才继续。客户端如果直接忽略 IAC,不回任何内容,服务器会一直卡在协商阶段,应用层数据根本不会发。
解决:按第 3 章的 process_recv_data 处理,收到 WILL/DO 就回 WONT。有的服务器还要等终端类型子协商,遇到 SB...SE 直接跳过。另外别用 printf 直接打印含 0xFF 的缓冲,会把 IAC 当可见字符输出,画面直接花掉。
4.4 中文乱码与控制台代码页:用 SetConsoleOutputCP 收服 GBK
现象:服务器返回的中文变成"鎴戠殑"一类乱码,或者全部变成问号。
原因:Telnet 本身只传字节流,不保证字符编码。Windows 中文版控制台默认代码页是 936(GBK),而很多 Linux 服务器和网络设备输出 UTF-8;反过来,设备输出 GBK、控制台被设成 65001 也会乱。两边编码对不上,显示必然出问题。
解决:拿到字节不做编码转换时,把控制台输出代码页设成和服务器一致。服务器发 UTF-8 时,main 开头调 SetConsoleOutputCP(CP_UTF8);设备发 GBK 时保持 936。建议加命令行参数 --encoding utf8/gbk 动态设置,程序换到别的 Windows 版本也不用重新编译:
if (strcmp(encoding, "utf8") == 0) { SetConsoleOutputCP(65001); } else { SetConsoleOutputCP(936); }代码页设置只影响控制台显示,不影响网络字节流。在 Windows Terminal 里渲染效果比传统控制台好,配合 UTF-8 基本能解决大部分乱码场景。
4.5 粘包半包:TCP 是流不是消息,按字节啃 IAC 序列
现象:横幅显示不全、菜单停在一半,或者一条 IAC 被拆进两次 recv 里,下一段逻辑漏处理。
原因:TCP 是字节流,recv 一次拿到的长度与发送方的 write 长度完全无关。Telnet 没有消息边界,必须自己按 IAC 序列格式逐字节解析,不能假设一次 recv 就是一个完整包。
解决:process_recv_data 按字节状态机处理,天然能应对半包——IAC 起始字节到了、后续字节没到,就等下一次 recv 再补。粘包也不会出错,逐字节解析不会把两次 send 的数据看成一条。缓冲溢出要注意,收到超长数据时循环处理,不要靠单次 recv 长度做判断。
5. 把客户端变成能用的命令行工具:参数解析、端口检测与验证手法
5.1 从固定 IP 到命令行参数:像 telnet ip 端口 命令一样去调用
第 3 章的演示代码把 IP 硬编码了,真要用起来得支持命令行参数。main 里加 argc/argv 判断:第一个参数是 IP,第二个是端口,可选 --timeout 和 --encoding。调用方式对齐 Windows 自带 telnet:mytelnet 192.168.1.1 23。IP 先用 inet_pton 校验,失败直接打印用法退出;端口 atoi 后检查 0 到 65535 区间,超界报错。这样脚本里能用返回值判断连接成败,而不是靠肉眼盯屏幕。
5.2 用 select 做批量端口连通性检测
"telnet ip 端口 命令怎么看通不通"这种需求,用命令行工具一次查一个太慢。运维场景通常要批量查几十台设备,我一般写一个循环,每台设备调 connect_with_timeout,成功打印 ip:port open,失败打印错误码。超时 3 秒,100 台设备串行最坏 300 秒,能接受;要更快就开线程池,但注意 WSAStartup 全局一次,别在子线程里重复初始化。批量检测的结果我会导成 csv,方便后续和资产清单比对。
5.3 对照验证手法:Windows 自带 telnet 与 Python 测试服务
验证客户端是否正确,关键是找一个可控的服务器。我平时用两个对照:一是 Windows 自带 Telnet 服务,控制面板里启用 Telnet Server 后用自带客户端连 localhost 对比表现;二是在本地起一个最小 Python TCP 服务,手动发 IAC 序列来测试客户端的应答逻辑。前者验证真实兼容性,后者验证协议细节。
import socket, threading def handle(c): c.send(b"\xff\xfb\x01") # WILL ECHO while True: d = c.recv(1024) if not d: break c.send(b"echo:" + d) s = socket.socket() s.bind(("127.0.0.1", 2323)) s.listen(5) while True: c, a = s.accept() threading.Thread(target=handle, args=(c,)).start()这段服务监听 2323 端口,客户端连上后会收到 WILL ECHO。如果客户端正确回了 WONT,本地会显示 echo: 加输入内容;如果黑屏,说明协商应答有问题。我的习惯是先跑通这个最小场景再上真实设备,拿交换机试错代价高。回想当年第一版没处理 IAC,连路由器一直黑屏,我还以为是固件兼容问题,折腾一晚上,把协商应答加上后几秒就通了。做这类 TCP 工具,协议状态机比 socket 调用更值得花时间。希望帮到你。
本文还有配套的精品资源,点击获取