在做网络设备管理系统的时候,我们经常需要通过 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 协议客户端的核心收获可以归纳为三句话:
状态机是协议解析的不二法门——Telnet 的 IAC / 协商 / 子协商本质上就是一个有限状态机,每来一个字节根据当前状态做决策。把业务数据和控制指令分流处理,协议就不会乱。
async + SemaphoreSlim 是 Socket 编程的最佳拍档——连接、发送、读取全程异步,用两个独立的信号量分别保护读写,既不会阻塞,也能保证半双工协议下的原子性。
协议实现不怕复杂,就怕不确定——IAC 转义、子协商里的 0xFF、协商响应不能二次转义、UTF-8 跨包解码、VT100 噪声清理……这些细节没有文档会告诉你,只有在生产环境跑起来才能一个个踩到。
完整代码在 SwitchData.Common.Net
命名空间下,TelnetProtocolParser 580 行 +
TelnetClient 520 行,总共不到 1100 行就实现了一个生产可用的
Telnet 客户端,支持终端类型协商、窗口大小协商、VT100 模拟、UTF-8
中文、半双工并发保护。如果你的项目也需要和网络设备打交道,不妨试试这套实现。