在网络设备管理、运维自动化等场景中,我们经常需要通过 Telnet 协议连接到交换机、路由器或网元设备,执行命令并获取回显。虽然 .NET 没有内置 Telnet 客户端,但基于底层 System.Net.Sockets.Socket 实现一个完整的 Telnet 客户端并不困难。

本文将带你深入源码,剖析一个工业级 C# Telnet 客户端的完整实现,涵盖以下核心知识点:

  • Telnet 协议规范:IAC 转义、DO/DONT/WILL/WONT 协商、子协商机制
  • Socket 异步编程:ConnectAsync / SendAsync / ReceiveAsync 全链路异步
  • 状态机模式:协议解析器的状态驱动设计
  • 并发控制:SemaphoreSlim 读写锁保护 Socket 安全
  • 超时与取消:CancellationTokenSource.CreateLinkedTokenSource 组合超时
  • 多字节字符解码:Decoder.Convert 处理 TCP 粘包导致的 UTF-8 分割问题

为什么不直接用 TcpClient?

TcpClient 只是对 Socket 的简单封装,提供了 NetworkStream 让你像读写文件一样操作网络。但 Telnet 不是普通的文本协议,它有一套完整的选项协商机制

`mermaid sequenceDiagram participant Client as TelnetClient participant Server as 远端设备

Note over Client,Server: TCP 三次握手建立
Client->>Server: Socket.ConnectAsync()
Server-->>Client: 连接成功

Note over Client,Server: Telnet 选项协商阶段
Server->>Client: IAC DO ECHO (255 253 1)
Client->>Server: IAC WILL ECHO (255 251 1)
Server->>Client: IAC DO TERMINAL-TYPE (255 253 24)
Client->>Server: IAC WILL TERMINAL-TYPE (255 251 24)
Server->>Client: IAC SB TERMINAL-TYPE SEND IAC SE
Client->>Server: IAC SB TERMINAL-TYPE IS VT100 IAC SE

Note over Client,Server: 业务数据交互阶段
Client->>Server: "login: admin\r\n"
Server-->>Client: 回显 + 提示符

`

如果不处理这些协商报文,很多设备会直接断开连接,或者把协商报文当成业务数据返回给你,导致命令解析失败。

Telnet 协议核心规范速览

Telnet 协议定义了几个关键的控制字节(IAC = Interpret As Command):

字节值 名称 含义
255 (0xFF) IAC 命令转义,后续紧跟 Telnet 命令字节
253 (0xFD) DO 请求对方启用某选项
254 (0xFE) DONT 请求对方禁用某选项
251 (0xFB) WILL 我方将启用某选项(同意 DO)
252 (0xFC) WONT 我方不启用某选项(拒绝 DO)
250 (0xFA) SB 子协商开始(带参数的选项)
240 (0xF0) SE 子协商结束

数据中的 0xFF 必须转义为 0xFF 0xFF(两个 IAC 字节),这一点很容易遗漏。

整体架构设计

我们的 Telnet 客户端由两个核心类协作完成:

mermaid graph TD A[TelnetClient] -->|持有| B[TelnetProtocolParser] A -->|使用| C[Socket] A -->|使用| D[SemaphoreSlim 读写锁] A -->|管理| E[StringBuilder 接收缓冲] B -->|状态机| F[8 种解析状态] B -->|处理| G[协商请求/响应] B -->|处理| H[子协商]

  • TelnetProtocolParser:纯协议层,无 I/O,只负责字节解析和协商响应生成
  • TelnetClient:网络层,负责 Socket 连接、发送数据、读取直到匹配结束条件

TelnetProtocolParser:状态机协议解析

协议解析器采用有限状态机(FSM)设计,这是处理 Telnet 这类带协商机制协议的经典模式。

状态定义

csharp private enum ParseState { Data, // 普通业务数据 Iac, // 已接收 IAC,等待命令字节 Do, Dont, // 等待 DO/DONT 后面的选项字节 Will, Wont, // 等待 WILL/WONT 后面的选项字节 SubNegotiation, // 正在解析子协商 SubNegotiationIac // 子协商中遇到 IAC }

核心解析循环

`csharp public TelnetParseResult Parse(ReadOnlySpan input) { var data = new List(); // 业务数据(去掉协商报文) var responses = new List(); // 协商响应(需要发回给服务端)

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());

} `

解析结果 TelnetParseResult 包含两部分:Data 是纯净的业务数据,Responses 是需要立即发送回服务端的协商响应。

IAC 转义处理

在 ParseIacByte 中,当遇到连续两个 IAC(xFF 0xFF)时,说明这是业务数据中的 0xFF 字节,需要还原:

csharp case Iac: // 收到 IAC,然后又收到 IAC data.Add(Iac); // 业务数据中的 0xFF _state = ParseState.Data; break;

自动协商响应

服务端发来 DO(请求我启用某选项)时,客户端需要回应 WILL 或 WONT。解析器自动处理了几个常用选项:

csharp private void HandleDo(byte option, List<byte> responses) { switch (option) { case SuppressGoAhead: // 抑制继续进行 case TerminalType: // 终端类型 AddCommand(responses, Will, option); // 同意启用 break; case WindowSize: // 窗口大小 AddCommand(responses, Will, option); // 主动上报窗口大小 AddSubNegotiation(responses, WindowSize, CreateWindowSizeData()); break; default: AddCommand(responses, Wont, option); // 不支持的选项明确拒绝 break; } }

设计要点:对不支持的选项必须回复 WONT,而不是忽略。有些设备如果长时间收不到响应会认为客户端不兼容而断开。

子协商处理(终端类型)

子协商是协商机制中最复杂的部分,以终端类型为例:

  1. 服务端:IAC SB TERMINAL-TYPE SEND IAC SE(请求我告诉它我的终端类型)
  2. 客户端:IAC SB TERMINAL-TYPE IS VT100 IAC SE(我用的是 VT100)

csharp private void HandleTerminalType(List<byte> responses) { if (_subNegotiationData.Count == 1 && _subNegotiationData[0] == Send) { var terminalType = Encoding.ASCII.GetBytes(TerminalTypeName); // 默认 "VT100" var data = CreateTerminalTypeData(terminalType); // [IS, 'V', 'T', '1', '0', '0'] AddSubNegotiation(responses, TerminalType, data); } }

TelnetClient:异步 Socket 客户端

TelnetClient 基于 System.Net.Sockets.Socket 实现,全程使用 sync/await 异步编程模型。

并发安全:SemaphoreSlim 读写锁

Socket 是全双工的,但同时调用 SendAsync 和 ReceiveAsync 在不同线程上并不安全。我们使用两个 SemaphoreSlim(初始计数都是 1)分别保护读和写:

csharp private readonly SemaphoreSlim _readLock = new(1, 1); private readonly SemaphoreSlim _sendLock = new(1, 1);

每次发送或读取前先 WaitAsync,完成后在 inally 中 Release:

`csharp public async Task SendAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default) { var socket = GetSocket(); await _sendLock.WaitAsync(cancellationToken); try { // 循环发送直到所有字节都发送完 var offset = 0; while (offset < data.Length) { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(SendTimeout); // 默认 10 秒超时

        var sent = await socket.SendAsync(data[offset..], SocketFlags.None, timeoutCts.Token);
        if (sent <= 0) throw new IOException("Telnet 连接已断开。");
        offset += sent;
    }
}
finally
{
    _sendLock.Release();
}

} `

连接建立:DNS 解析 + 多地址尝试

`csharp public async Task ConnectAsync(string host, int port, CancellationToken cancellationToken = default) { var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);

Exception lastException = null;
foreach (var address in addresses)
{
    var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
    try
    {
        using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        timeoutCts.CancelAfter(ConnectTimeout); // 默认 10 秒

        await socket.ConnectAsync(new IPEndPoint(address, port), timeoutCts.Token);
        _socket = socket;
        return; // 连接成功
    }
    catch (Exception ex)
    {
        lastException = ex;
        socket.Dispose();
    }
}
throw new SocketException((int)SocketError.NotConnected);

} `

设计要点:CancelAfter 是 .NET 6+ 的 API,之前版本需要 Task.Delay + Task.WhenAny 实现超时。

读取直到匹配:ReadUntilAsync

这是最核心的方法,支持三种结束条件——字符串、正则表达式、自定义判断函数:

csharp public Task<string> ReadUntilAsync(string delimiter, CancellationToken cancellationToken = default) { return ReadUntilAsync(text => { var index = text.IndexOf(delimiter, StringComparison.Ordinal); return index < 0 ? -1 : index + delimiter.Length; }, cancellationToken); }

内部实现是一个循环:先检查 _pendingText(之前收到但未消费的数据),不够再从 Socket 收新数据,用解析器过滤掉协商报文,再追加到 _pendingText:

`csharp private async Task ReadUntilAsync(Func<string, int> findEndIndex, CancellationToken cancellationToken) { await _readLock.WaitAsync(cancellationToken); try { while (true) { // 1. 先检查已有缓冲 var pendingResult = TryConsumePending(findEndIndex); if (pendingResult is not null) return pendingResult;

        // 2. 从 Socket 读取
        var buffer = new byte[8192];
        using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        timeoutCts.CancelAfter(ReceiveTimeout); // 默认 30 秒

        var received = await socket.ReceiveAsync(buffer.AsMemory(), SocketFlags.None, timeoutCts.Token);
        if (received == 0) throw new IOException("远端已关闭连接。");

        // 3. 协议解析,剥离协商报文
        var parseResult = _parser.Parse(buffer.AsSpan(0, received));

        // 4. 如果有协商响应,立即发回(原始 Telnet 数据,不能再次 IAC 转义)
        if (parseResult.Responses.Length > 0)
            await SendRawAsync(parseResult.Responses, cancellationToken);

        // 5. 业务数据追加到缓冲
        if (parseResult.Data.Length > 0)
            AppendDecodedText(parseResult.Data);
    }
}
finally
{
    _readLock.Release();
}

} `

多字节字符解码:处理 TCP 粘包的 UTF-8 分割

TCP 是字节流,一次 ReceiveAsync 可能截到 UTF-8 多字节字符的中间。如果每次收到数据都 Encoding.UTF8.GetString(),会导致乱码或异常。

正确做法是使用 Decoder,它会记住未完成的字符序列,下次接收时继续解码:

`csharp private readonly Decoder _decoder; // 在 Encoding setter 中初始化

private void AppendDecodedText(ReadOnlySpan data) { var charCount = Encoding.GetMaxCharCount(data.Length); var chars = new char[charCount];

// convert 会保留不完整的多字节序列在 Decoder 内部状态中
_decoder.Convert(data, chars, false, out var bytesUsed, out var charsUsed, out _);

if (bytesUsed != data.Length)
    throw new InvalidOperationException("Telnet 数据解码失败。");

if (charsUsed > 0)
    _pendingText.Append(chars, 0, charsUsed);

} `

对比:Encoding.GetString(new byte[]{0xE4}) 会丢数据(假设 0xE4 是”你”的第一个字节),而 Decoder.Convert 会缓存 0xE4,等下次收到 0xBD 0xA0 时拼成完整的”你”。

发送数据的 IAC 转义

业务数据中如果包含 0xFF 字节,必须发送为 xFF 0xFF(两个 IAC):

`csharp private static byte[] EscapeIac(ReadOnlySpan data) { var count = 0; foreach (var value in data) if (value == 255) count++; if (count == 0) return data.ToArray();

var result = new byte[data.Length + count];
var index = 0;
foreach (var value in data)
{
    result[index++] = value;
    if (value == 255) result[index++] = 255; // IAC -> IAC IAC
}
return result;

} `

使用示例

`csharp using var telnet = new TelnetClient(); await telnet.ConnectAsync(“192.168.1.1”, 23);

// 登录 await telnet.SendLineAsync(“admin”); var prompt = await telnet.ReadUntilAsync(“>”); Console.WriteLine(prompt);

await telnet.SendLineAsync(“password123”); prompt = await telnet.ReadUntilAsync(“>”);

// 执行命令 await telnet.SendLineAsync(“display version”); var output = await telnet.ReadUntilAsync(new Regex(@“[<[].*[>]]“)); Console.WriteLine(output); `

完整流程图

`mermaid flowchart TD Start([开始]) –> Connect[ConnectAsync
DNS 解析 + 多地址尝试] Connect –> Loop{命令循环}

Loop --> Send[SendLineAsync<br/>1. IAC 转义<br/>2. _sendLock 锁<br/>3. 循环发送]
Send --> Read[ReadUntilAsync<br/>1. _readLock 锁<br/>2. 检查 _pendingText<br/>3. Socket.ReceiveAsync<br/>4. Parser.Parse 过滤协商<br/>5. 发送协商响应<br/>6. Decoder 持续解码]
Read --> Match{匹配结束符?}
Match -- 否 --> Read
Match -- 是 --> Output[返回业务文本]
Output --> Loop

Loop -- 用户退出 --> Disconnect[DisposeAsync<br/>1. Interlocked.Exchange<br/>2. Socket.Shutdown<br/>3. 释放锁<br/>4. GC.SuppressFinalize]

`

关键设计总结

设计点 方案 原因
协议解析 有限状态机 + 纯函数 无 I/O 依赖,可单独测试
并发安全 双 SemaphoreSlim Socket 全双工但 Send/Recv 不保证线程安全
超时控制 CreateLinkedTokenSource + CancelAfter 既响应外部 CancellationToken,又有独立超时
字符解码 Decoder.Convert 持续解码 处理 TCP 粘包导致的 UTF-8 分割
IAC 转义 发送时转义、解析时还原 Telnet 协议规范要求
资源释放 IAsyncDisposable + Interlocked.Exchange 防止多次 Dispose 异常

常见坑点

坑 1:用 Encoding.GetString 解码每次收到的字节。TCP 粘包导致多字节字符被截断,会出现 � 乱码。

坑 2:忽略协商响应。有些设备会在 Do 之后短暂等待 WILL/WONT 响应,如果客户端一直不回复,设备会认为连接异常而断开。

坑 3:忘记对发送数据做 IAC 转义。如果业务数据碰巧包含 0xFF 字节,后面两个字节会被服务端当作命令解析,导致不可预期的行为。

坑 4:Socket 同时被多个线程调用。虽然 Send 和 Receive 可以并行,但两个 Send 或两个 Receive 并发就可能出问题,必须加锁。

扩展方向

  • SSH 支持:Telnet 是明文协议,生产环境建议升级到 SSH,可以用 SSH.NET 库或基于 SshNet 实现
  • 日志集成:在 ReadUntil 和 SendLine 中增加日志输出,方便调试设备交互
  • 连接池:高频场景下可以维护 Telnet 连接池,避免频繁 TCP 握手
  • 命令队列:如果需要串行执行多条命令,可以在 TelnetClient 外层封装一个命令队列服务

结语

实现 Telnet 客户端看似简单,实则涉及协议解析、异步编程、并发控制、字符编码等多个方面的细节。本文展示的实现模式可以推广到其他二进制协议(如 SMTP、FTP、私有设备协议等)——核心思路都是:协议解析器与 I/O 解耦、用状态机管理协议状态、用锁保证并发安全

SwitchData 项目中这个 TelnetClient 已经在多个华为/中兴网元设备上稳定运行多年,每天处理成千上万条 MML 命令交互,证明了这套设计的可靠性。