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 一次性定位所有片段,因为:
- 协商指令本身可以出现在业务数据中间任意位置
- 协商指令的长度不固定(DO/DONT 是 3 字节,子协商是 3 + N + 2 字节)
- 子协商数据内部同样存在 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 就对了。