在做网络设备管理系统的时候,我们经常需要通过 Telnet 协议连接到交换机、路由器、或者运营商的网元网关(比如华为的 ORDP、中兴的 NetNumen),下发查询命令、读取命令输出。市面上虽然有很多现成的 Telnet 库,但大多数要么是古董级的 .NET Framework 项目不支持 async/await,要么就是把协议细节全部藏起来了——当你需要处理终端类型协商、窗口大小、VT100 控制序列的时候,黑盒库往往靠不住。

本文从一个真实项目的代码出发,带你从零实现一个完整的 Telnet 协议客户端。我们会拆开 Telnet 协议的每一个字节,实现一套状态机来处理 IAC 协商和子协商,最后用 SemaphoreSlim 保证异步读写的线程安全。完整代码来自生产环境,已经稳定跑了一年多。

一、Telnet 协议:不止是 TCP + 文本

很多人以为 Telnet 就是”向一个 TCP 连接里发字符串”,这是最常见的误解。Telnet 协议(RFC 854)定义了一套带内协商机制:业务数据和控制指令共用同一条 TCP 连接,通过特殊的 IAC(Interpret As Command)字节 0xFF 来区分。

Telnet 命令字节表

字节值 常量 含义
255 IAC 解释为命令的转义前缀
253 DO 请开启这个选项
254 DONT 请关闭这个选项
251 WILL 我愿意开启这个选项
252 WONT 我不愿意开启这个选项
250 SB 子协商开始(Suboption Begin)
240 SE 子协商结束(Suboption End)

常见选项(Option)

编号 选项名 说明
1 Echo 回显
3 Suppress Go Ahead 抑制继续进行
24 Terminal Type 终端类型
31 Window Size 窗口大小

协商流程示例

服务端发来 IAC DO Terminal-Type (24) 意思是”请开启终端类型选项”,客户端回复 IAC WILL 24 意思是”我愿意”。然后服务端再发 IAC SB Terminal-Type SEND IAC SE,意思是”告诉我你的终端类型是什么”,客户端就回 IAC SB Terminal-Type IS VT100 IAC SE。整个流程串在同一个 TCP 字节流里,和正常业务输出(比如登录提示、命令回显)混在一起。

sequenceDiagram
    participant Client
    participant Server
    Note over Client,Server: TCP 连接已建立
    Server->>Client: IAC DO Terminal-Type
    Client->>Server: IAC WILL Terminal-Type
    Server->>Client: IAC SB TERMINAL-TYPE SEND IAC SE
    Client->>Server: IAC SB TERMINAL-TYPE IS VT100 IAC SE
    Server->>Client: IAC DO Window-Size
    Client->>Server: IAC WILL Window-Size
    Client->>Server: IAC SB WINDOW-SIZE 0x0050 0x0018 IAC SE
    Note over Client,Server: 协商完成,开始正常交互

二、状态机实现 TelnetProtocolParser

核心思想很简单:把收到的每一个字节喂给一个状态机,状态机区分”这是业务数据”还是”这是 Telnet 控制指令”,业务数据提取出来交给上层,控制指令则自动回复协商响应。

状态枚举

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

核心 Parse 方法

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

关键处理逻辑

IAC IAC 转义:业务数据里如果出现了 0xFF,Telnet 协议要求发送方转义为 0xFF 0xFF。所以在 IAC 状态下又遇到 IAC 时,说明这是业务数据中的 0xFF,不是控制指令。

case Iac:
    // IAC IAC → 业务数据中的 0xFF
    data.Add(Iac);
    _state = ParseState.Data;
    break;

HandleDo:响应服务端的 DO 请求

private void HandleDo(byte option, List<byte> responses)
{
    switch (option)
    {
        case SuppressGoAhead:
        case TerminalType:
        case WindowSize:
            // 支持的选项 → 回复 WILL
            AddCommand(responses, Will, option);
            // WindowSize 协商后主动把客户端窗口告诉服务端
            if (option == WindowSize)
                AddSubNegotiation(responses, WindowSize, CreateWindowSizeData());
            break;
        default:
            // 不认识的选项 → 明确拒绝
            AddCommand(responses, Wont, option);
            break;
    }
}

子协商解析(SB/SE):子协商是 Telnet 协议里最容易出 Bug 的地方。服务端发来 IAC SB <Option> <payload> IAC SE,子协商 payload 里如果恰好有 0xFF,也需要 IAC IAC 转义。所以解析子协商时有独立的 SubNegotiationIac 状态。

private void ParseSubNegotiationIacByte(byte value, List<byte> responses)
{
    if (value == Se)
    {
        // 子协商结束,根据 Option 做处理
        HandleSubNegotiation(responses);
        _state = ParseState.Data;
    }
    else if (value == Iac)
    {
        // 子协商 payload 里的 0xFF → 转义
        _subNegotiationData.Add(Iac);
        _state = ParseState.SubNegotiation;
    }
}

AddSubNegotiation:构造子协商响应时的 0xFF 转义

private static void AddSubNegotiation(List<byte> buffer, byte option, ReadOnlySpan<byte> data)
{
    buffer.Add(Iac); buffer.Add(Sb); buffer.Add(option);
    foreach (var value in data)
    {
        buffer.Add(value);
        if (value == Iac) buffer.Add(Iac); // 子协商 payload 里的 0xFF 也要转义
    }
    buffer.Add(Iac); buffer.Add(Se);
}

HandleTerminalType:回应终端类型请求

服务端发 IAC SB 24 (TerminalType) 1 (SEND) IAC SE → 客户端回复 IAC SB 24 0 (IS) "VT100" IAC SE:

private void HandleTerminalType(List<byte> responses)
{
    if (_subNegotiationData.Count == 1 && _subNegotiationData[0] == Send)
    {
        var terminalType = Encoding.ASCII.GetBytes(TerminalTypeName); // 默认 "VT100"
        var data = new byte[terminalType.Length + 1];
        data[0] = Is; // 0 表示 IS
        Buffer.BlockCopy(terminalType, 0, data, 1, terminalType.Length);
        AddSubNegotiation(responses, TerminalType, data);
    }
}

三、TelnetClient:异步 Socket 封装 + 并发保护

有了协议解析器,下一步是把它接到 Socket 上。TelnetClient 类封装了连接、发送、按条件读取三个核心操作,全程 async/await,并用 SemaphoreSlim 保护读写并发。

类结构概览

public sealed class TelnetClient : IAsyncDisposable
{
    private readonly TelnetProtocolParser _parser = new();
    private readonly SemaphoreSlim _readLock = new(1, 1);   // 同一时刻只允许一个 Read
    private readonly SemaphoreSlim _sendLock = new(1, 1);   // 同一时刻只允许一个 Send
    private readonly StringBuilder _pendingText = new();     // 未消费的业务文本
    private Socket _socket;
    private Encoding _encoding = Encoding.UTF8;

    public int ConnectTimeout { get; set; } = 10_000;
    public int ReceiveTimeout { get; set; } = 30_000;
    public int SendTimeout { get; set; } = 10_000;
}

ConnectAsync:带超时的 DNS 轮询连接

public async Task ConnectAsync(string host, int port, CancellationToken cancellationToken = default)
{
    Disconnect(); // 先清理旧连接
    _parser.Reset();
    _pendingText.Clear();

    // DNS 解析可能返回多个 IP,逐个尝试
    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);
            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);
}

SendAsync:自动 IAC 转义 + Semaphore 保护

public async Task SendAsync(string text, CancellationToken cancellationToken = default)
{
    var data = Encoding.GetBytes(text);
    data = EscapeIac(data); // 业务数据里的 0xFF → 0xFF 0xFF
    await SendBytesAsync(data, cancellationToken);
}

private async Task SendBytesAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
{
    await _sendLock.WaitAsync(cancellationToken);
    try
    {
        var offset = 0;
        while (offset < data.Length)
        {
            using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
            timeoutCts.CancelAfter(SendTimeout);
            var sent = await _socket.SendAsync(data[offset..], SocketFlags.None, timeoutCts.Token);
            if (sent <= 0) throw new IOException("Telnet 连接已断开。");
            offset += sent;
        }
    }
    finally { _sendLock.Release(); }
}

ReadUntilAsync:按条件读取,自动消费协商响应

这是 TelnetClient 最核心的方法。它不断从 Socket 接收数据,喂给 _parser.Parse() 解析,有协商响应就自动发送,业务数据解码后累积到 _pendingText,直到匹配到调用方指定的结束条件。

private async Task<string> ReadUntilAsync(Func<string, int> findEndIndex, CancellationToken cancellationToken)
{
    var socket = GetSocket();
    await _readLock.WaitAsync(cancellationToken);
    try
    {
        while (true)
        {
            // 先看看之前累积的数据里有没有已经匹配的
            var pendingResult = TryConsumePending(findEndIndex);
            if (pendingResult is not null) return pendingResult;

            var buffer = new byte[8192];
            using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
            timeoutCts.CancelAfter(ReceiveTimeout);
            var received = await socket.ReceiveAsync(buffer.AsMemory(), SocketFlags.None, timeoutCts.Token);
            if (received == 0) throw new IOException("Telnet 远端已关闭连接。");

            var parseResult = _parser.Parse(buffer.AsSpan(0, received));

            // 协商响应必须作为原始字节发送,不能再次 IAC 转义
            if (parseResult.Responses.Length > 0)
                await SendBytesAsync(parseResult.Responses, cancellationToken);

            if (parseResult.Data.Length == 0) continue;

            // 业务数据用 UTF-8 解码,累积到 _pendingText
            AppendDecodedText(parseResult.Data);
        }
    }
    finally { _readLock.Release(); }
}

Unicode 解码的坑:Telnet 传输的是字节流,业务数据可能是 UTF-8 中文(比如华为 ORDP 返回的告警消息)。直接用 Encoding.UTF8.GetString() 会有一个问题——如果一次 Socket 接收正好在一个多字节字符的中间截断了,就会产生乱码。

正确做法是用 Encoding.GetDecoder(),它内部维护状态,跨多次调用传递部分解码结果:

private Decoder _decoder = Encoding.UTF8.GetDecoder();

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

便捷读取方法

// 按字符串结束标记读取
public Task<string> ReadUntilAsync(string delimiter, CancellationToken cancellationToken = default)
{
    return ReadUntilAsync(text =>
    {
        var idx = text.IndexOf(delimiter, StringComparison.Ordinal);
        return idx < 0 ? -1 : idx + delimiter.Length;
    }, cancellationToken);
}

// 按正则表达式结束标记读取(更灵活)
public Task<string> ReadUntilAsync(Regex pattern, CancellationToken cancellationToken = default)
{
    return ReadUntilAsync(text =>
    {
        var m = pattern.Match(text);
        return !m.Success ? -1 : m.Index + m.Length;
    }, cancellationToken);
}

四、实战:连接华为 ORDP 网关

有了 TelnetClient,连接 ORDP 网关就变得非常简单。下面是一个完整的登录 + 命令下发示例:

using var client = new TelnetClient
{
    ReceiveTimeout = 30_000,
    SendTimeout = 10_000
};

// 1. 连接网关
await client.ConnectAsync("188.0.108.25", 28030);

// 2. 等登录提示出现(可能需要处理网关侧的 LGI/REG NE 命令)
var prompt = await client.ReadUntilAsync("Login:");
Console.WriteLine(prompt);

// 3. 发用户名,等密码提示
await client.SendLineAsync("sjs_wlwyzxt");
prompt = await client.ReadUntilAsync("Password:");

// 4. 发密码,等命令提示符
await client.SendLineAsync("your_password");
prompt = await client.ReadUntilAsync(new Regex(@"> $"));

// 5. 发查询命令,等 --- END 结束标志
await client.SendCrAsync("LST PCMCCSUBDATA:SRVNAME='iot_apn1';");
var result = await client.ReadUntilAsync("--- END");
Console.WriteLine(result); // 解析输出中的用户数、APN 等字段

五、生产踩过的坑

1. IAC 未转义导致命令被截断

如果业务命令里恰好包含字节 0xFF(比如二进制 APN 名),直接发送会被对方当成 Telnet 控制指令解析,导致命令截断。发送前必须用 EscapeIac 转义:

private static byte[] EscapeIac(ReadOnlySpan<byte> data)
{
    var count = 0;
    foreach (var v in data) if (v == 255) count++;
    if (count == 0) return data.ToArray();
    var result = new byte[data.Length + count];
    int idx = 0;
    foreach (var v in data)
    {
        result[idx++] = v;
        if (v == 255) result[idx++] = 255;
    }
    return result;
}

2. 协商响应不能二次 IAC 转义

TelnetProtocolParser.Parse() 返回的 Responses 已经是完整的 Telnet 指令字节串(如 IAC WILL 24),如果再用 SendAsync 发送就会被 EscapeIac 二次转义,变成 IAC IAC WILL 24,协议就乱了。所以必须走 SendBytesAsync 原始发送:

// ✅ 正确
if (parseResult.Responses.Length > 0)
    await SendBytesAsync(parseResult.Responses, cancellationToken);

// ❌ 错误:会二次转义
if (parseResult.Responses.Length > 0)
    await SendAsync(Encoding.ASCII.GetString(parseResult.Responses));

3. ReadLock / SendLock 的必要性

Telnet 是半双工协议——一端在发命令时,另一端可能在同时发协商指令。如果多个线程同时调用 SendAsync 或 ReadUntilAsync,字节会交错导致协议状态机卡死。必须用两个独立的 SemaphoreSlim 分别保护发送和接收:

private readonly SemaphoreSlim _readLock = new(1, 1);   // 读锁
private readonly SemaphoreSlim _sendLock = new(1, 1);  // 写锁

4. VT100 控制序列和 NUL 字符

实际生产中,华为 ORDP 的 Telnet 网关会在业务输出里注入 VT100 控制序列(比如 ESC[2J 清屏、ESC7/ESC8 光标保存恢复)和 NUL 空字符 \x00。这不是 Telnet 协议的问题,是服务端实现选择的终端模拟方式。上层业务需要加一个清理方法:

public static string CleanTerminalNoise(string raw)
{
    // 移除 VT100 光标控制序列
    raw = Regex.Replace(raw, @"\x1b\[[0-9;]*[A-Za-z]", "");
    raw = Regex.Replace(raw, @"\x1b\[[0-9]?[A-Za-z]", "");
    raw = Regex.Replace(raw, @"\x1b[78]", "");
    // 移除 NUL 空字符
    raw = raw.Replace("\x00", "");
    return raw;
}

5. 累积的 — END 截断首条命令

华为 UPF 网元登录 ORDP 时,LGI 和 REG NE 两个子命令各会产生一个 --- END 结束标志。如果客户端的 ReadUntilAsync("--- END") 只消费了第一个,第二个就残留在 _pendingText 里。当你再发第一条业务命令时,解析器读到残留的 --- END 就误以为命令已经结束了,导致响应被截断。

解决方案:登录完成后主动清空缓冲区:

// 登录后确保 LGI + REG NE 的两个 END 都被消费
await client.ReadUntilAsync("--- END");
await client.ReadUntilAsync("--- END");
// 清理可能残留的空字符和控制序列

或者简单粗暴地给 TelnetClient 加一个 ClearPendingText() 方法:

public void ClearPendingText() => _pendingText.Clear();

六、总结

从零实现 Telnet 协议客户端的核心收获可以归纳为三句话:

  1. 状态机是协议解析的不二法门——Telnet 的 IAC / 协商 / 子协商本质上就是一个有限状态机,每来一个字节根据当前状态做决策。把业务数据和控制指令分流处理,协议就不会乱。

  2. async + SemaphoreSlim 是 Socket 编程的最佳拍档——连接、发送、读取全程异步,用两个独立的信号量分别保护读写,既不会阻塞,也能保证半双工协议下的原子性。

  3. 协议实现不怕复杂,就怕不确定——IAC 转义、子协商里的 0xFF、协商响应不能二次转义、UTF-8 跨包解码、VT100 噪声清理……这些细节没有文档会告诉你,只有在生产环境跑起来才能一个个踩到。

完整代码在 SwitchData.Common.Net 命名空间下,TelnetProtocolParser 580 行 + TelnetClient 520 行,总共不到 1100 行就实现了一个生产可用的 Telnet 客户端,支持终端类型协商、窗口大小协商、VT100 模拟、UTF-8 中文、半双工并发保护。如果你的项目也需要和网络设备打交道,不妨试试这套实现。