Telnet 是网络设备调试、交换机管理、ORDP 网元直连中最常见的协议之一。一个完整的 Telnet 客户端至少包含两层:上层业务客户端(负责登录、发命令、读响应)和底层协议解析器(负责剥离协议控制字节、处理 Telnet 协商)。

本文聚焦后者——如何用 C# 写一个零依赖、高性能、正确处理 Telnet 完整协议栈的协议解析器 TelnetProtocolParser。你将看到:

  • 为什么 Telnet 需要用状态机来解析(而不是简单的字符串查找)
  • Telnet 四向握手(DO / DONT / WILL / WONT)的完整协商流程
  • 子协商(Subnegotiation)的数据收发机制
  • 业务数据中 IAC(0xFF)字节的转义与还原
  • 如何与上层 Socket 客户端配合实现”解析 + 自动协商响应”

所有代码来自生产环境项目 SwitchData 实际运行的 Telnet 客户端底层实现。


一、Telnet 协议关键概念(先搞懂再写代码)

Telnet 协议的核心难点在于:数据和控制指令共享同一个字节流。服务器可能在你发送 MML 命令的过程中,突然插入一条 Telnet 协商指令(比如”请问你支持窗口大小协商吗?“)。如果直接把原始字节按 UTF8 解码成字符串,这些控制字节会污染业务数据;如果不理会协商,终端的回显、窗口大小、终端类型等功能就会异常。

1. IAC 标记与命令字节

Telnet 规定字节 0xFF 为 IAC(Interpret As Command),意思是”下面这个字节是 Telnet 命令,不是业务数据”。

字节值 含义 说明
255 (0xFF) IAC 命令起始标记
253 (0xFD) DO 客户端,请开启这个选项
254 (0xFE) DONT 客户端,请关闭这个选项
251 (0xFB) WILL 服务器愿意开启这个选项
252 (0xFC) WONT 服务器不愿意开启这个选项
250 (0xFA) SB 子协商开始(Subnegotiation Begin)
240 (0xF0) SE 子协商结束(Subnegotiation End)

2. 四向握手机制

DO / WILL 四个命令组成了 Telnet 的能力协商双方:

服务器                         客户端
  │                             │
  │── IAC DO TerminalType ────▶│  "你支持终端类型选项吗?"
  │                             │
  │◀── IAC WILL TerminalType ──│  "我愿意支持"
  │                             │
  │── IAC SB TERMINAL-TYPE     │
  │    SEND IAC SE ───────────▶│  "那告诉我你的终端类型吧"
  │                             │
  │◀── IAC SB TERMINAL-TYPE    │
  │    IS VT100 IAC SE ────────│  "我是 VT100"

客户端收到 DO 时要回复 WILL 或 WONT(我支持 / 我不支持);收到 WILL 时要回复 DO 或 DONT(我要 / 我不要)。两边意见不一致最终总是 DONT / WONT(放弃)。

3. IAC 转义

如果业务数据(比如你用 Telnet 发一条 MML 命令)里本身含有字节 0xFF,发送方必须把它转成 FF FF(两个 IAC),接收方看到两个连续 IAC 就知道是一个普通的 0xFF 字节,不是命令的开始标记。


二、为什么必须用状态机

Telnet 的控制指令和业务数据在同一条 TCP 流里交错出现:

[业务数据 A] [IAC DO 24] [业务数据 B] [IAC IAC] [业务数据 C 中的 0xFF]
            ↑ 协商指令   ↑ 两个 IAC = 业务数据里的一个 0xFF

你无法用正则表达式或 IndexOf 一次性定位所有片段,因为:

  1. 协商指令本身可以出现在业务数据中间任意位置
  2. 协商指令的长度不固定(DO/DONT 是 3 字节,子协商是 3 + N + 2 字节)
  3. 子协商数据内部同样存在 IAC 转义(嵌套的状态)

因此 Telnet 协议解析器必须逐字节扫描,并且每次扫描时根据当前所在阶段(状态)决定:

  • 这个字节是业务数据,交给上层
  • 还是 Telnet 命令的一部分,需要进入某个子状态继续读
  • 还是需要构造一个协商响应发回去

这就是有限状态机(FSM)的典型应用场景。


三、状态定义与字段设计

先定义枚举,把 Telnet 协议解析拆成 8 个互斥的状态:

private enum ParseState
{
    Data,                  // 普通业务数据
    Iac,                   // 刚收到一个 IAC(0xFF),等下一个命令字节
    Do,                    // 等 DO 后面的选项字节
    Dont,                  // 等 DONT 后面的选项字节
    Will,                  // 等 WILL 后面的选项字节
    Wont,                  // 等 WONT 后面的选项字节
    SubNegotiation,        // 正在进行子协商(SB 之后、SE 之前)
    SubNegotiationIac      // 子协商内部刚收到 IAC,等下一个字节判断是 SE 还是转义
}

然后是核心字段:

private ParseState _state = ParseState.Data;
private byte _subNegotiationOption;    // 当前子协商的选项(如 TerminalType=24)
private bool _hasSubNegotiationOption; // 是否已拿到子协商的 option 字节
private readonly List<byte> _subNegotiationData = new(); // 子协商的 payload

没有任何字符串正则、没有反射、没有 async/await——纯同步逐字节扫描,性能瓶颈就是 Socket 的接收速度。


四、主解析循环

解析器的 Parse 方法接收一个 ReadOnlySpan byte(Socket 刚收到的一段字节),返回两个数组:业务数据(去掉所有 Telnet 控制字节)和 协商响应(需要立即发回给服务器的字节)。

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

    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 = Data; break;
            case ParseState.Dont:  HandleDont(value, responses); _state = Data; break;
            case ParseState.Will:  HandleWill(value, responses); _state = Data; break;
            case ParseState.Wont:  HandleWont(value, responses); _state = Data; break;
            case ParseState.SubNegotiation:       ParseSubNegotiationByte(value); break;
            case ParseState.SubNegotiationIac:    ParseSubNegotiationIacByte(value, responses); break;
        }
    }

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

每处理完一个字节,要么切换状态继续读,要么回到 Data 状态处理下一个字节。子协商相关的两个状态(SubNegotiation 和 SubNegotiationIac)形成了一个独立的小状态机嵌套在主状态机里。


五、各状态处理逻辑

1. Data 状态:普通业务数据

private void ParseDataByte(byte value, List<byte> data)
{
    if (value == Iac)  // 0xFF
    {
        _state = ParseState.Iac; // 切到 IAC 状态等下一个命令字节
        return;
    }
    data.Add(value);  // 普通字节,直接收集
}

简单直接——遇到 0xFF 就切换状态,否则累积。

2. Iac 状态:判断是命令还是转义

收到一个 IAC 后,看下一个字节是什么:

private void ParseIacByte(byte value, List<byte> data, List<byte> responses)
{
    switch (value)
    {
        case Iac:   // IAC IAC = 业务数据里的一个 0xFF
            data.Add(Iac);
            _state = Data;
            break;
        case Do:    _state = Do;    break;
        case Dont:  _state = Dont;  break;
        case Will:  _state = Will;  break;
        case Wont:  _state = Wont;  break;
        case Sb:    // 开始子协商,清空临时数据
            _subNegotiationData.Clear();
            _hasSubNegotiationOption = false;
            _state = SubNegotiation;
            break;
        case Se:    _state = Data;   break;  // 意外的 SE,忽略
        default:    _state = Data;   break;  // 未知单字节命令,忽略
    }
}

这里的关键分支是 case Iac——这就是 IAC 转义还原的位置。两个连续 0xFF 出现在数据流里时,业务数据部分收到一个 0xFF,协议控制部分全部被丢弃。

3. Do / Dont / Will / Wont:构造协商响应

以 DO(服务器请求客户端开启某个选项)为例:

private const byte Echo = 1;
private const byte SuppressGoAhead = 3;
private const byte TerminalType = 24;
private const byte WindowSize = 31;

private void HandleDo(byte option, List<byte> responses)
{
    switch (option)
    {
        case SuppressGoAhead:
            AddCommand(responses, Will, option);          // 我愿意支持
            break;
        case TerminalType:
            AddCommand(responses, Will, option);          // 我愿意支持终端类型
            break;
        case WindowSize:
            AddCommand(responses, Will, option);          // 我愿意支持窗口大小
            AddSubNegotiation(responses, WindowSize, CreateWindowSizeData()); // 主动上报
            break;
        default:
            AddCommand(responses, Wont, option);          // 不认识,明确拒绝
            break;
    }
}

HandleWill(服务器说它愿意开启某个选项)只对 Echo 和 SuppressGoAhead 回复 DO,其他一律 DONT——这个保守策略是 Telnet 客户端稳定运行的关键:

private static void HandleWill(byte option, List<byte> responses)
{
    switch (option)
    {
        case SuppressGoAhead:
        case Echo:
            AddCommand(responses, Do, option);   // 好的,我要你开启
            break;
        default:
            AddCommand(responses, Dont, option);  // 其他的都不要
            break;
    }
}

四个 handler 处理完都会让主状态切回 Data,等待下一个字节。

4. 子协商:SB → option → payload → IAC SE

子协商的结构是固定的:

IAC SB <option> [payload] IAC SE
         ↑         ↑
      第一个字节  后续字节
      是 option  是 payload
private void ParseSubNegotiationByte(byte value)
{
    if (!_hasSubNegotiationOption)
    {
        _subNegotiationOption = value;
        _hasSubNegotiationOption = true;
        return;
    }
    if (value == Iac)
    {
        _state = SubNegotiationIac;  // 子协商内部也需要 IAC 转义
        return;
    }
    _subNegotiationData.Add(value);
}

子协商内部遇到 IAC 就切到 SubNegotiationIac,在那个子状态里判断:下一个字节是 SE(子协商结束)还是另一个 IAC(转义成 payload 里的 0xFF):

private void ParseSubNegotiationIacByte(byte value, List<byte> responses)
{
    if (value == Se)
    {
        HandleSubNegotiation(responses);  // 子协商结束,处理 payload
        _state = Data;
        return;
    }
    if (value == Iac)
    {
        _subNegotiationData.Add(Iac);  // 转义:payload 里的 0xFF
        _state = SubNegotiation;
        return;
    }
    _state = SubNegotiation;  // 异常情况不卡死
}

5. HandleSubNegotiation:对接收到的子协商 payload 做响应

服务器可能发 IAC SB TERMINAL-TYPE SEND IAC SE(让客户端上报终端类型),我们要回复 IAC SB TERMINAL-TYPE IS VT100 IAC SE:

private void HandleSubNegotiation(List<byte> responses)
{
    switch (_subNegotiationOption)
    {
        case TerminalType:
            HandleTerminalType(responses);
            break;
    }
}

private void HandleTerminalType(List<byte> responses)
{
    // _subNegotiationData 里是 SEND (0x01) 这个单字节
    if (_subNegotiationData.Count == 1 && _subNegotiationData[0] == Send)
    {
        var terminalType = Encoding.ASCII.GetBytes("VT100");
        var data = CreateTerminalTypeData(terminalType);  // [IS] + "VT100"
        AddSubNegotiation(responses, TerminalType, data); // 完整的 IAC SB ... IAC SE
    }
}

6. 辅助方法:AddCommand 与 AddSubNegotiation

把构造 Telnet 字节序列的逻辑集中在两个工具方法里:

private static void AddCommand(List<byte> buffer, byte command, byte option)
{
    buffer.Add(Iac); buffer.Add(command); buffer.Add(option);
    // IAC + DO + 24 → 三个字节
}

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

注意 AddSubNegotiation 内部对 payload 又做了一次 IAC 转义——这是正确实现 Telnet 子协商的细节,很多开源 Telnet 客户端在这一步就漏了。


六、与上层 TelnetClient 的配合

解析器本身只负责字节流的语义拆解和协商响应字节的生成,不涉及任何 Socket 操作。上层 TelnetClient 的 ReadUntilAsync 循环里这样配合:

// 从 Socket 收到一段字节
var received = await socket.ReceiveAsync(buffer, ...);
var parseResult = _parser.Parse(buffer.AsSpan(0, received));

// 协商响应字节必须立刻发回(不能再做 IAC 转义)
if (parseResult.Responses.Length > 0)
{
    await socket.SendAsync(parseResult.Responses, ...);
}

// 业务数据喂给解码器变成字符串
AppendDecodedText(parseResult.Data);

解析器返回的 Responses 已经是完整的 Telnet 指令序列,可以直接 Send,不能再调用 EscapeIac(那是业务数据发送前才做的)。这是 TelnetClient 设计时的一个关键细节,注释里专门标出来了。


七、为什么这套设计能跑生产

SwitchData 项目里的 TelnetProtocolParser 每天在生产环境处理上百万条 Telnet 连接(连接华为 ORDP 网元、中兴交换机、各种 MML 命令执行),线上稳定零崩溃。它的设计要点值得总结:

设计点 好处
纯同步逐字节扫描 没有 async/await 开销,单线程处理,无锁
ReadOnlySpan 输入 零拷贝,直接消费 Socket 接收缓冲区
8 状态覆盖所有协议场景 每个分支职责单一,代码可读性高
子协商用嵌套状态机 主状态 + 子状态彻底隔离,不会相互污染
状态异常时自动回退 Data 不会因收到非法字节序列而卡死
Reset 方法 + 无静态字段 线程安全、可复用、连接重建时零状态残留
协商响应与业务数据物理分离 上层调用方清楚知道哪部分是业务内容

八、完整使用示例

var parser = new TelnetProtocolParser();
parser.TerminalTypeName = "VT100";
parser.WindowWidth = 160;
parser.WindowHeight = 40;

// Socket 收到数据
byte[] raw = await socket.ReceiveAsync(...);

var result = parser.Parse(raw);
// result.Data    → 剥离 Telnet 控制字节后的业务数据,可以 UTF8 解码
// result.Responses → 服务器请求协商时我们的回复,需要立刻 Send 回去

if (result.Responses.Length > 0)
{
    await socket.SendAsync(result.Responses, ...);
}

var text = Encoding.UTF8.GetString(result.Data);  // 业务 MML 命令响应

连接关闭时别忘了 parser.Reset() 清空所有内部状态,下次连接从零开始。


九、总结

Telnet 协议解析器是典型的”状态机 + 逐字节扫描 + 上下文隔离”问题:

  • 状态机处理控制指令在数据流中的任意位置出现
  • 逐字节扫描配合 switch 分发,性能极致、代码清晰
  • 上下文隔离(子协商状态机嵌套在主状态机里)让复杂协议也能写出可读性很高的代码

这份实现覆盖了 Telnet 协议中最关键的协商流程——DO/WILL 四向握手 + TerminalType/WindowSize 子协商 + IAC 转义还原。它是整个 Telnet 客户端架构里的”地基”,如果这层实现有漏洞,上层所有”读响应卡住”“命令结果被截断”“终端不回显”等诡异问题都会出现。

类似的状态机设计模式也适用于 HTTP chunked transfer 解析、WebSocket 帧解析、SSH 通道复用解析等”同一流中多种帧类型交错出现”的场景。下次遇到这种需求,想想 TelnetProtocolParser 就对了。