Telnet 协议诞生于 1969 年,比 TCP/IP 协议栈还要早几年。虽然在今天的互联网上 Telnet 已经被 SSH 全面取代,但在网络设备管理、嵌入式开发、自动化运维等领域,Telnet 依然是不可或缺的底层通信方式。本文将基于一个真实的 C#/.NET 项目,带你从零实现一个完整的 Telnet 客户端,并深入剖析协议实现中的核心设计模式与工程化细节。
为什么需要自己实现 Telnet 客户端?
在 SwitchData 项目中,我们需要连接华为、中兴等通信设备厂商的 ORDP(Open Router Development Platform)网关,通过 Telnet 协议发送 MML(Man-Machine Language)命令查询网元数据,例如 PCF 策略签约数统计、UPF 用户数查询等。市面上虽然有开源的 Telnet 库,但往往存在以下问题:
- 协议解析不完整,无法正确处理子协商(Subnegotiation)
- 缺乏并发控制,多线程场景下容易死锁
- 不支持自定义编码(UTF-8、GBK 等)
- 无超时保护,网元响应慢时阻塞主线程
- 日志可控性差,难以集成到企业级监控体系
因此,我们决定自己实现一个轻量但完整的 Telnet 客户端,核心代码量仅约 600 行,分为两个类:TelnetProtocolParser(协议解析器)和 TelnetClient(客户端封装)。
Telnet 协议核心机制
在动手写代码之前,我们需要理解 Telnet 协议的核心设计。Telnet 是一种带内信令协议——控制指令和业务数据在同一个字节流中传输,这就需要一套转义和协商机制来区分”这是数据”还是”这是控制命令”。
IAC 转义
Telnet 使用 0xFF(十进制 255)作为 IAC(Interpret As
Command)标志字节。当客户端收到 0xFF
时,意味着接下来的字节是控制命令而非业务数据。如果业务数据本身包含
0xFF,需要发送连续两个 0xFF 来表示转义后的
255。
0xFF 0xFF -> 业务数据中的 0xFF
0xFF 0xFB -> WILL 命令(服务器声明要启用某个选项)
0xFF 0xFA ... 0xFF 0xF0 -> 子协商开始/结束
DO/DONT/WILL/WONT 四向协商
Telnet 的选项协商遵循一套对称的四向握手:
| 命令 | 含义 | 方向 |
|---|---|---|
| WILL (0xFB) | 我愿意启用选项 | A → B |
| DO (0xFD) | 我同意你启用 | B → A |
| WONT (0xFC) | 我不想启用 | A → B |
| DONT (0xFE) | 我不同意你启用 | B → A |
协商的目标是让双方就以下选项达成共识: - Echo (1):是否回显输入字符 - Suppress Go Ahead (3):是否抑制”继续”信号 - Terminal Type (24):客户端声明自己是 VT100、VT220 等 - Window Size (31):客户端告诉服务器终端窗口的行列数
子协商(Subnegotiation)
有些选项(如终端类型、窗口大小)需要传递额外的参数值,这就是子协商:
服务器 → 客户端: IAC SB TERMINAL-TYPE SEND IAC SE
客户端 → 服务器: IAC SB TERMINAL-TYPE IS VT100 IAC SE
其中 SB (0xFA) 是子协商开始,SE (0xF0)
是子协商结束。Telnet 规范允许 IAC SE 中间再出现
IAC,同样需要转义处理。
状态机设计:TelnetProtocolParser
Telnet 协议解析的核心难点在于:TCP 流是无边界的,数据可能被拆分成多个 TCP 段到达,也可能多个协议消息粘在同一个 TCP 段中。我们需要一个能够处理流式输入、状态连续的解析器——这正是状态机(State Machine)的用武之地。
ParseState 状态定义
private enum ParseState
{
Data, // 普通业务数据
Iac, // 已收到 0xFF,等待下一字节
Do, // 等待 DO 后的选项字节
Dont, // 等待 DONT 后的选项字节
Will, // 等待 WILL 后的选项字节
Wont, // 等待 WONT 后的选项字节
SubNegotiation, // 正在接收子协商数据
SubNegotiationIac // 子协商中遇到 IAC,等待 SE
}
字节驱动的解析循环
解析器的核心是一个 switch-case 驱动的字节处理循环。每收到一个字节,根据当前状态决定如何处理,并推进到下一个状态:
public TelnetParseResult Parse(ReadOnlySpan<byte> input)
{
var data = new List<byte>(); // 业务数据输出
var responses = new List<byte>(); // 需要回复的协商命令
foreach (var value in input)
{
switch (_state)
{
case ParseState.Data:
ParseDataByte(value, data);
break;
case ParseState.Iac:
ParseIacByte(value, data, responses);
break;
case ParseState.Do:
HandleDo(value, responses);
_state = ParseState.Data;
break;
// ... 其他状态
}
}
return new TelnetParseResult(data.ToArray(), responses.ToArray());
}
这个设计的优雅之处在于:
- 无缓冲担忧:解析器不关心输入是一次性到达还是分多次到达,每次调用都会记住上次的状态
- 职责分离:解析器只负责把 Telnet 控制指令剥离出来,输出纯净的业务数据 + 待回复的协商命令
- 性能友好:
ReadOnlySpan<byte>+foreach的组合在 .NET 中是零堆分配的热路径
DO 选项处理策略
当服务器发送 IAC DO option 时,客户端需要决定是回 WILL
还是 WONT。我们的策略是:只对业务必需的选项说 YES,其余一律说 NO:
private void HandleDo(byte option, List<byte> responses)
{
switch (option)
{
case SuppressGoAhead: // 抑制 Go Ahead,必须启用
AddCommand(responses, Will, option);
break;
case TerminalType: // 声明 VT100 终端
AddCommand(responses, Will, option);
break;
case WindowSize: // 同时发 Will 和窗口大小子协商
AddCommand(responses, Will, option);
AddSubNegotiation(responses, WindowSize, CreateWindowSizeData());
break;
default: // 不认识的选项一律拒绝
AddCommand(responses, Wont, option);
break;
}
}
TelnetClient:异步客户端封装
解析器解决了”理解协议”的问题,而 TelnetClient
则解决了”稳定连接、可靠收发”的问题。
并发控制:双 SemaphoreSlim
一个健壮的 Telnet
客户端必须处理多线程同时发送命令和读/写交叉的场景。我们使用两个
SemaphoreSlim 分别保护读和写操作:
private readonly SemaphoreSlim _readLock = new(1, 1);
private readonly SemaphoreSlim _sendLock = new(1, 1);
发送命令流程:
public async Task SendLineAsync(string text, CancellationToken ct = default)
{
var data = Encoding.GetBytes(text + "\\r\\n");
data = EscapeIac(data); // 业务数据中的 0xFF 需要转义
await _sendLock.WaitAsync(ct);
try
{
// 带超时保护的发送
while (offset < data.Length)
{
var sent = await socket.SendAsync(...);
offset += sent;
}
}
finally { _sendLock.Release(); }
}
读取直到匹配:ReadUntilAsync
这是 Telnet 客户端最常用的
API——“发送一个命令,然后读到什么位置才算完成?”。ReadUntilAsync
提供了三种匹配方式:
// 1. 按字符串匹配(最常用,匹配提示符如 "$ ")
await telnet.ReadUntilAsync("$ ");
// 2. 按正则表达式匹配(灵活,匹配命令结束标志)
await telnet.ReadUntilAsync(new Regex(@"--- END[\\r\\n]+"));
// 3. 自定义条件函数(最灵活)
await telnet.ReadUntilAsync(text => {
var idx = text.IndexOf("Command complete");
return idx >= 0 ? idx + 16 : -1;
});
核心实现是一个”带缓冲的循环读取”:
private async Task<string> ReadUntilAsync(Func<string, int> findEndIndex, CancellationToken ct)
{
await _readLock.WaitAsync(ct);
try
{
while (true)
{
// 先从之前未消费的数据中查找
var pending = TryConsumePending(findEndIndex);
if (pending != null) return pending;
// 从 Socket 读取新数据
var received = await socket.ReceiveAsync(buffer, ct);
if (received == 0) throw new IOException("连接断开");
// Telnet 解析:剥离协商指令,回复服务器需要的命令
var result = _parser.Parse(buffer.AsSpan(0, received));
if (result.Responses.Length > 0)
await SendBytesAsync(result.Responses, ct);
// 将业务数据解码为文本追加到缓冲区
AppendDecodedText(result.Data);
}
}
finally { _readLock.Release(); }
}
UTF-8 多字节解码的坑
TCP 流按字节传输,而 UTF-8 编码一个中文字符需要 2~3 个字节。如果一个
3 字节的 UTF-8 字符被拆在两个 TCP 包中到达,直接用
Encoding.UTF8.GetString(bytes) 会在分割处产生乱码。
解决方案是使用 Decoder.Convert 进行连续解码:
private Decoder _decoder = Encoding.UTF8.GetDecoder();
private void AppendDecodedText(ReadOnlySpan<byte> data)
{
var charCount = Encoding.UTF8.GetMaxCharCount(data.Length);
var chars = new char[charCount];
_decoder.Convert(data, chars, false,
out var bytesUsed, out var charsUsed, out _);
// Decoder 内部维护状态机,记住上次末尾不完整的多字节序列
_pendingText.Append(chars, 0, charsUsed);
}
Decoder
内部维护了一个状态机,记住”上次最后收到了几个不完整的字节”,下次解码时会先拼上再处理。这是
.NET Encoding 体系中一个非常实用但容易被忽视的 API。
实战:连接 ORDP 网元执行 MML 命令
有了 Telnet 客户端,连接网元就变成了一个”登录 → 发送命令 → 读响应”的固定流程:
var telnet = new TelnetClient();
await telnet.ConnectAsync("188.0.108.25", 28030);
// 登录阶段
await telnet.ReadUntilAsync("login:");
await telnet.SendLineAsync("sjs_wlwyzxt");
await telnet.ReadUntilAsync("Password:");
await telnet.SendLineAsync("xxxxxx");
await telnet.ReadUntilAsync(">"); // 等到提示符
// 发送 MML 命令查询 PCF 策略
await telnet.SendLineAsync("LST PCMCCSUBDATA;");
var response = await telnet.ReadUntilAsync(new Regex(@"--- END[\\r\\n]+"));
// response 就是完整的命令输出
Console.WriteLine(response);
踩坑经验总结
VT100 终端控制序列
Telnet 连接网络设备时,服务器可能输出 VT100 终端控制序列(如
ESC[2J 清屏、ESC[H
定位光标)。这些不是业务数据,需要清理:
private static readonly Regex Vt100Pattern = new(
@"\\x1B\\[[0-9;]*[A-Za-z]",
RegexOptions.Compiled);
public static string CleanVt100(string input)
=> Vt100Pattern.Replace(input, "");
NUL 空字符
Telnet 协议中,CR (\\r) 后面跟的 NUL
(\\x00) 表示”回车但不换行”,ORDP 网关也可能填充 NUL
作为对齐字符。这些 NUL
会残留在业务数据中,导致生成的文件开头出现乱码:
public static string StripNul(string input)
=> input.Replace("\\x00", "");
命令回显处理
Telnet 的 Echo 选项会让服务器回显客户端发送的每一个字节。因此你发出的
MML 命令会在响应中先出现一遍(这就是 Echo
回显),然后才是真正的执行输出。实际业务中需要根据行尾的
\\r(单回车标记,无后续换行)来识别并剔除 Echo 回显行。
ORDP 直连的命令清理缓冲区
ORDP 网关在 LGI(登录)和
REG NE(注册网元)阶段,会各输出一个 --- END
提示符。如果不及时清理缓冲区,这些残留的提示符会被误当成业务命令的结束标志,导致响应被截断。
解决方案是在发送第一条 MML 命令前清理所有积压的数据:
public void ClearPendingText()
{
_parser.Reset();
_pendingText.Clear();
_decoder = Encoding.GetDecoder();
}
总结
从零实现一个 Telnet 客户端看似复杂,本质上是三个核心设计:
- 状态机解析器:用一个枚举 + switch-case 驱动的字节循环,把带内信令的控制指令从业务数据中剥离出来
- 异步 Socket + SemaphoreSlim 并发控制:确保多线程场景下连接安全,每个操作都有超时保护
- Decoder.Convert 连续解码:正确处理 UTF-8 等多字节编码被 TCP 拆包导致的乱码问题
这个 Telnet 客户端已经在 SwitchData 项目中稳定运行多年,连接着数十台华为 PGW、中兴 UPF 网元,每日执行数千次 MML 命令查询。它的价值不在于”实现了一个协议”,而在于它是状态机 + 异步编程 + 编码细节三者融合的一个完整工程化案例——这三者恰恰是 .NET 后端开发中最核心也最容易被忽视的技能组合。
完整源码见项目
SwitchData.Common/Net/TelnetClient.cs和TelnetProtocolParser.cs。