在日常开发中,我们常常需要通过 Telnet 协议连接各类网络设备——交换机、路由器、防火墙、5G 网元……你可能会说:“直接用第三方库不就行了?”

但如果让你从零手写一个 Telnet 客户端,你知道需要处理哪些细节吗?TCP Socket 怎么封装?协议协商(IAC/DO/DONT/WILL/WONT)怎么解析?业务数据中的 xFF 字节怎么转义?粘包/半包怎么处理?UTF-8 多字节字符跨包截断怎么办?

本文基于 SwitchData 项目中的 TelnetClient 和 TelnetProtocolParser 实现,带你完整走一遍 Telnet 协议客户端的从零搭建过程。

一、Telnet 协议核心概念

先快速回顾一下 Telnet(RFC 854)的核心设计:

1. 控制字符 IAC

Telnet 是一种带内协商协议(in-band negotiation),协议控制命令和业务数据走同一条 TCP 连接。特殊之处在于:当 TCP 流中出现字节 xFF(十进制 255,IAC = Interpret As Command)时,它不再是普通业务数据,而是一个协议命令的开始。

2. 四命令协商机制

命令 含义 方向
IAC DO 请求对方启用选项 客户端→服务端 或 服务端→客户端
IAC DONT 请求对方禁用选项 同上
IAC WILL 声明自己愿意启用选项 响应 DO
IAC WONT 声明自己不愿启用选项 响应 DO 或 DONT

协商逻辑很简单:对方发 DO Echo 说”请你回显”,你如果同意就回 WILL Echo,不同意就回 WONT Echo。

3. 常见 Telnet 选项

  • Echo (1):回显
  • Suppress Go Ahead (3):抑制 Go Ahead 信号
  • Terminal Type (24):终端类型(如 VT100)
  • Window Size (31):终端窗口大小

4. 子协商(Sub-Negotiation)

某些选项需要传递具体参数,用子协商机制:IAC SB

服务端 → 客户端:IAC SB TERMINAL-TYPE SEND IAC SE (请告诉我你的终端类型) 客户端 → 服务端:IAC SB TERMINAL-TYPE IS VT100 IAC SE (我的终端是 VT100)

二、架构设计

我们把实现分成两层:

mermaid graph TD A[TelnetClient] --> B[TelnetProtocolParser] A --> C[Socket] B -->|解析协议数据| D[业务字节流] B -->|生成协商响应| E[Telnet命令] D -->|Decoder连续解码| F[业务文本] E -->|SendRawAsync| A

  • TelnetProtocolParser:纯无状态解析器,把 TCP 字节流拆成”业务数据”和”Telnet 协商命令”两部分
  • TelnetClient:封装 Socket 连接、发送/接收、超时控制、线程安全、缓冲区管理

三、Telnet 协议解析器

协议解析是整个 Telnet 客户端最复杂的部分。我们用状态机来实现:

状态机设计

Data ──遇到 IAC──▶ Iac Iac ──IAC IAC──▶ Data (转义的 0xFF) Iac ──DO──▶ Do ──▶ Data Iac ──DONT──▶ Dont ──▶ Data Iac ──WILL──▶ Will ──▶ Data Iac ──WONT──▶ Wont ──▶ Data Iac ──SB──▶ SubNegotiation ──遇到 IAC──▶ SubNegotiationIac ──SE──▶ Data Iac ──SE──▶ Data (意外的 SE)

对应代码中的枚举:

csharp private enum ParseState { Data, // 普通业务数据 Iac, // 已接收到 IAC (0xFF) Do, Dont, Will, Wont, // 等待后面的选项字节 SubNegotiation, // 正在进行子协商 SubNegotiationIac // 子协商中遇到 IAC }

逐字节解析

解析器的核心就是一个 oreach 循环,每收到一个字节就根据当前状态做路由:

`csharp public TelnetParseResult Parse(ReadOnlySpan input) { var data = new List(); // 业务数据输出 var responses = new List(); // Telnet 协商响应输出

foreach (var value in input)
{
    switch (_state)
    {
        case ParseState.Data:
            if (value == Iac) _state = ParseState.Iac;  // 遇到 0xFF 切换状态
            else data.Add(value);                        // 普通字节收进业务数据
            break;

        case ParseState.Iac:
            switch (value)
            {
                case Iac:     data.Add(Iac); _state = ParseState.Data; break;  // IAC IAC = 转义的 0xFF
                case Do:      _state = ParseState.Do; break;
                case Will:    _state = ParseState.Will; break;
                case Sb:      _state = ParseState.SubNegotiation; break;
                default:      _state = ParseState.Data; break;
            }
            break;

        case ParseState.Do:
            HandleDo(value, responses);  // 生成 WILL 或 WONT 响应
            _state = ParseState.Data;
            break;
        // ... DONT/WILL/WONT/子协商 类似处理
    }
}

return new TelnetParseResult(data.ToArray(), responses.ToArray());

} `

协商响应生成

以服务器发 DO Terminal-Type 为例,我们需要: 1. 回复 WILL Terminal-Type 表示支持 2. 主动发子协商声明自己的终端类型

csharp private void HandleDo(byte option, List<byte> responses) { switch (option) { case TerminalType: // 24 AddCommand(responses, Will, option); // IAC WILL 24 // 后续服务端发 SB 24 SEND,客户端回复 SB 24 IS VT100 break; case WindowSize: // 31 AddCommand(responses, Will, option); // IAC WILL 31 AddSubNegotiation(responses, WindowSize, CreateWindowSizeData()); break; default: AddCommand(responses, Wont, option); // 不支持的选项回复 WONT break; } }

设计亮点:解析器和响应生成完全解耦。Parse 方法返回的 esponses 是待发送的原始字节,调用方决定何时发送(通常是在一次读取循环中立即发送,避免协商超时)。

四、TelnetClient 客户端实现

线程安全的读写分离

Telnet 是全双工协议,但实际使用中我们不需要真正的并发读写——业务模式通常是:发送一条命令 → 读取直到响应结束标志。所以我们用两个独立的 SemaphoreSlim 来保证”同一时刻只有一个发送者”和”同一时刻只有一个接收者”:

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

核心读取循环:ReadUntilAsync

这是 TelnetClient 最核心的方法——从 Socket 持续读取数据,直到匹配到指定的结束标志(字符串、正则或自定义条件函数)。

`csharp private async Task ReadUntilAsync(Func<string, int> findEndIndex, CancellationToken ct) { await _readLock.WaitAsync(ct); try { while (true) { // 1️⃣ 先检查待消费缓冲区 var pending = TryConsumePending(findEndIndex); if (pending != null) return pending;

        // 2️⃣ 从 Socket 读取(带超时)
        var buffer = new byte[8192];
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
        cts.CancelAfter(ReceiveTimeout);
        var received = await socket.ReceiveAsync(buffer.AsMemory(), SocketFlags.None, cts.Token);
        if (received == 0) throw new IOException("连接已关闭");

        // 3️⃣ 协议解析
        var parseResult = _parser.Parse(buffer.AsSpan(0, received));

        // 4️⃣ 发送协商响应(不能用带 IAC 转义的 SendAsync)
        if (parseResult.Responses.Length > 0)
            await SendRawAsync(parseResult.Responses, ct);

        // 5️⃣ 业务数据送入解码器
        if (parseResult.Data.Length > 0)
            AppendDecodedText(parseResult.Data);
    }
}
finally { _readLock.Release(); }

} `

处理的三大难题

难题 1:粘包/半包 → 待消费缓冲区

TCP 是面向流的协议,ReceiveAsync 返回的数据可能: - 一个包里包含多条完整消息(粘包) - 一个消息被拆成多个包(半包)

我们用 _pendingText(StringBuilder)累积已接收但未消费的文本。每次进入循环先检查缓冲区里有没有完整消息:

`csharp private string TryConsumePending(Func<string, int> findEndIndex) { if (_pendingText.Length == 0) return null; var text = _pendingText.ToString(); var endIndex = findEndIndex(text); if (endIndex < 0) return null; // 还没凑齐完整消息

var result = text[..endIndex];
_pendingText.Clear();
if (endIndex < text.Length)
    _pendingText.Append(text[endIndex..]);  // 多余的留到下次
return result;

} `

难题 2:UTF-8 跨包截断 → Decoder 连续解码

如果 Socket 接收到的最后几个字节刚好是 UTF-8 多字节字符的前半部分,直接 Encoding.UTF8.GetString() 会把它丢弃或替换成 (乱码替换符)。

解决方案:用 Encoding.GetDecoder(),Decoder 内部会保留不完整的字节,等下一次喂数据时再拼起来解码:

`csharp private readonly Decoder _decoder;

private void AppendDecodedText(ReadOnlySpan data) { var charCount = Encoding.GetMaxCharCount(data.Length); var chars = new char[charCount]; decoder.Convert(data, chars, false, out , out var charsUsed, out _); if (charsUsed > 0) _pendingText.Append(chars, 0, charsUsed); } `

难题 3:业务数据中的 0xFF → IAC 转义

Telnet 协议规定:业务数据中的 xFF 必须转义成 xFF 0xFF,否则对端会把它当成协议命令的开始。

发送前转义:

`csharp private static byte[] EscapeIac(ReadOnlySpan data) { var count = data.Count(b => b == 255); 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;
}
return result;

} `

协议解析器已经负责反向操作了——IAC IAC 在 Data 状态下被还原成单个 xFF 放进业务数据。所以 TelnetClient 的使用者完全不用关心转义细节。

连接建立:DNS 多地址重试

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

foreach (var address in addresses)
{
    var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
    try
    {
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
        cts.CancelAfter(ConnectTimeout);
        await socket.ConnectAsync(new IPEndPoint(address, port), cts.Token);
        _socket = socket;
        return;  // 连上了就返回
    }
    catch (Exception ex)
    {
        lastException = ex;
        socket.Dispose();
    }
}
throw lastException!;  // 所有地址都连不上才抛异常

} `

五、实战:用 TelnetClient 连接 ORDP 网关

SwitchData 项目中的 OrdpClient 就是建立在 TelnetClient 之上的业务客户端,用来连接华为 ORDP 网关、登录网元、执行 MML 命令:

`csharp public class OrdpClient : IAsyncDisposable { private readonly TelnetClient _telnetClient;

public async Task LoginAsync(string username, string password, CancellationToken ct = default)
{
    await _telnetClient.ReadUntilAsync("login:", ct);
    await _telnetClient.SendLineAsync(username, ct);
    await _telnetClient.ReadUntilAsync("password:", ct);
    await _telnetClient.SendLineAsync(password, ct);
    // 等待完整响应
    var response = await ReadOrdpMessageAsync(ct);
    if (!response.IsSuccess) throw new OrdpException(response.Code, response.Message);
}

public async Task<string> ExecuteMmlAsync(string command, CancellationToken ct = default)
{
    await _telnetClient.SendLineAsync(command, ct);
    // 不同网元的命令结束标志不同,从 ORDP 返回的 NE-EXINFO 中动态获取
    return await _telnetClient.ReadUntilAsync(_neInfo.CommandPrompt, ct);
}

} `

整个调用链:业务代码 → OrdpClient → TelnetClient → TelnetProtocolParser → Socket

六、踩过的坑

问题 原因 解决方案
首条 MML 响应被截断 Telnet 登录协商后 Socket 里可能残留 — END 发送前调用 ClearPendingText() 清空 _pendingText
中文乱码 Socket 接收到的字节刚好把 UTF-8 字符拆开 用 Decoder.Convert 代替 Encoding.GetString
协商超时 解析器返回的 responses 没及时发出去 在读取循环内 Parse 之后立即 SendRawAsync
发送丢失 IAC SendAsync 直接用带 IAC 转义的方法发协商命令 专门提供 SendRawAsync 跳过转义
连接偶发失败 DNS 返回多个 IP,第一个不可达直接抛异常 遍历所有 IP 依次重试

七、总结

从零实现 Telnet 协议客户端,核心要点就三件事:

  1. 协议解析器用状态机:把字节流拆成业务数据和控制命令两条线,自动生成协商响应
  2. 接收端要处理边界:粘包靠 _pendingText 缓冲区,UTF-8 截断靠 Decoder 连续解码
  3. 发送端要处理转义:业务数据中的 xFF 必须双写,协商命令中的 xFF 不能双写

这三大难题解决了,Telnet 客户端基本就稳了。剩下的就是根据具体业务场景(交换机 CLI、网元 MML、IoT 设备 AT 指令)封装业务层客户端。

本文源码来自 SwitchData 项目,完整实现可在 SwitchData.Common/Net/TelnetClient.cs 和 TelnetProtocolParser.cs 中查看。