在运维和网管类系统中,通过 Telnet 协议直连设备执行命令是最常见的场景之一。.NET 生态虽然有第三方 Telnet 库,但生产环境中往往需要更可控的实现——既能精确处理协议协商、又能适配特定厂商设备的特殊行为。
本文基于 SwitchData 项目中自研的 TelnetClient 实现,带你深入剖析 Telnet 协议客户端的完整实现:
graph TD
A[业务层<br>HuaweiOrdpClient] --> B[TelnetClient<br>Socket异步+并发锁+解码]
B --> C[TelnetProtocolParser<br>协议状态机]
C --> D[Telnet协议规范<br>IAC转义/DO-DONT/WILL-WONT/子协商]
B --> E[SemaphoreSlim<br>读写分离锁]
B --> F[Decoder<br>连续解码缓冲]
一、Telnet 协议核心概念
Telnet 协议看似简单(就是 TCP + 文本),但有几个容易踩坑的地方:
1. IAC 转义
Telnet 用字节 255(0xFF)作为命令前缀,称为 IAC(Interpret As Command)。如果业务数据中刚好也包含 0xFF,就会被误解析为命令。解决方案很暴力:业务数据中的 0xFF 必须发送两个 0xFF(IAC IAC),接收端再还原。
2. 选项协商
Telnet 两端通过四种命令协商功能开关:
| 命令 | 含义 | 方向 |
|---|---|---|
| DO (253) | 请开启该选项 | 发起 → 响应方 |
| DONT (254) | 请关闭该选项 | 发起 → 响应方 |
| WILL (251) | 我愿意开启该选项 | 响应 → 发起方 |
| WONT (252) | 我不愿意开启该选项 | 响应 → 发起方 |
协商是双向的——服务器发 DO 3(请支持抑制继续),客户端回复 WILL 3(好的我支持)。
3. 子协商(Subnegotiation)
部分选项需要协商具体参数,比如终端类型(选项 24)和窗口大小(选项 31)。这时候用 IAC SB option data IAC SE 包裹。
二、协议状态机:TelnetProtocolParser
解析器采用八状态有限状态机,逐字节扫描输入流,同时产出两类输出:业务数据(去掉所有协议字节后的纯文本)和协商响应(需要直接发回给对端的原始 Telnet 字节)。
状态枚举
private enum ParseState
{
Data, // 普通业务数据
Iac, // 已接收到 IAC (0xFF),等待下一个字节
Do, Dont, // 已接收到 DO/DONT,等待选项字节
Will, Wont, // 已接收到 WILL/WONT,等待选项字节
SubNegotiation, // 正在进行子协商
SubNegotiationIac // 子协商中已接收 IAC
}
核心解析循环
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 分支处理
private void ParseIacByte(byte value, List<byte> data, List<byte> responses)
{
switch (value)
{
case Iac: // IAC IAC → 业务数据中的 0xFF,转义还原
data.Add(Iac);
_state = ParseState.Data;
break;
case Do: _state = ParseState.Do; break;
case Dont: _state = ParseState.Dont; break;
case Will: _state = ParseState.Will; break;
case Wont: _state = ParseState.Wont; break;
case Sb: // 开始子协商
_subNegotiationData.Clear();
_state = ParseState.SubNegotiation;
break;
default: _state = ParseState.Data; break;
}
}
自动协商响应
解析器内置了对三种常见选项的自动响应逻辑:
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;
}
}
设计哲学:不支持的选项明确回复 WONT,让服务器知道客户端的能力边界。
三、TelnetClient:异步 Socket + 并发安全
解析器只负责看懂协议,真正的网络通信由 TelnetClient 承载。它的设计重点是并发安全和流式读取。
并发控制:读写分离锁
一个 Telnet 连接上可能多个线程同时调用 SendAsync 和 ReadUntilAsync。用两把 SemaphoreSlim 分别保护发送和接收:
private readonly SemaphoreSlim _readLock = new(1, 1);
private readonly SemaphoreSlim _sendLock = new(1, 1);
信号量设为 1 就等价于异步互斥锁——同一时刻只有一个线程能进入读/写操作。相比 lock 语句,SemaphoreSlim 不阻塞线程池,真正支持 await。
ReadUntilAsync:条件驱动的流式读取
这是 Telnet 客户端的核心 API。调用者传入一个结束条件(可以是字符串、正则或自定义函数),客户端不断从 Socket 读取、解析、解码,直到满足条件才返回完整内容:
public Task<string> ReadUntilAsync(string delimiter, CancellationToken ct = default)
{
return ReadUntilAsync(text =>
{
var index = text.IndexOf(delimiter, StringComparison.Ordinal);
return index < 0 ? -1 : index + delimiter.Length;
}, ct);
}
内部循环逻辑:
private async Task<string> 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 转义)
if (parseResult.Responses.Length > 0)
await SendRawAsync(parseResult.Responses, ct);
if (parseResult.Data.Length == 0) continue;
// 5. 解码 + 追加到缓冲
AppendDecodedText(parseResult.Data);
// 6. 再次检查结束条件
var result = TryConsumePending(findEndIndex);
if (result != null) return result;
}
}
finally { _readLock.Release(); }
}
缓冲策略:_pendingText + TryConsumePending
Socket 读取是流式的——一次 ReceiveAsync 可能读到半条消息,也可能一次读到多条完整响应。用 StringBuilder 做累积缓冲:
private readonly StringBuilder _pendingText = new();
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;
}
实战坑点:华为 ORDP 网关登录后会残留 — END 标志在缓冲区,导致首条 MML 响应被截断。解决方案就是在发送命令前调用 ClearPendingText() 清空缓冲:
public void ClearPendingText()
{
_parser.Reset();
_pendingText.Clear();
_decoder = Encoding.GetDecoder();
}
Decoder 连续解码:处理 UTF-8 多字节边界
UTF-8 字符可能跨 Socket 读取边界(一次读到半个中文字节)。用 Encoding.GetDecoder() 可以保持解码状态,跨批次正确解码:
private Decoder _decoder; // 复用这个 decoder,它会维护内部状态
private void AppendDecodedText(ReadOnlySpan<byte> 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);
}
发送侧:IAC 转义 + 分片发送
发送前对业务数据中的 0xFF 做转义(0xFF → 0xFF 0xFF),确保 Telnet 协议层不会误判:
private static byte[] EscapeIac(ReadOnlySpan<byte> data)
{
var count = data.Count(b => b == 255);
if (count == 0) return data.ToArray();
var result = new byte[data.Length + count];
var i = 0;
foreach (var v in data)
{
result[i++] = v;
if (v == 255) result[i++] = 255;
}
return result;
}
TCP 发送也可能被操作系统分片,所以用循环确保所有字节都发出去:
while (offset < data.Length)
{
var sent = await socket.SendAsync(data[offset..], SocketFlags.None, cts.Token);
if (sent <= 0) throw new IOException("连接已断开");
offset += sent;
}
超时控制:CancellationTokenSource.CreateLinkedTokenSource
每个操作都有独立的超时,同时保留调用方的 CancellationToken:
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(ReceiveTimeout); // 默认 30 秒
await socket.ReceiveAsync(buffer.AsMemory(), SocketFlags.None, timeoutCts.Token);
DNS 解析 + 多 IP 重试
ConnectAsync 做了 DNS 解析后遍历所有 IP 尝试连接,避免单 IP 故障:
var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
foreach (var address in addresses)
{
var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
try
{
timeoutCts.CancelAfter(ConnectTimeout);
await socket.ConnectAsync(new IPEndPoint(address, port), timeoutCts.Token);
_socket = socket;
return;
}
catch { socket.Dispose(); }
}
四、实战:HuaweiOrdpClient
TelnetClient 提供了通用 Telnet 能力,上层 HuaweiOrdpClient 则是业务封装——连接华为 ORDP 网关、登录网元、执行 MML 命令。
public async Task<string> ExecuteMmlAsync(string command, CancellationToken ct = default)
{
await _telnetClient.SendLineAsync(command, ct);
var pattern = new Regex(_neInfo.CommandPrompt, RegexOptions.Compiled);
return await _telnetClient.ReadUntilAsync(pattern, ct);
}
完整调用链
sequenceDiagram
participant App as PcfService
participant Ordp as HuaweiOrdpClient
participant Telnet as TelnetClient
participant Parser as TelnetProtocolParser
participant Gateway as ORDP网关
App->>Ordp: ConnectAsync(host, port)
Ordp->>Telnet: ConnectAsync()
Telnet->>Gateway: Socket.Connect()
Gateway-->>Telnet: Telnet协商(IAC DO ...)
Telnet->>Parser: Parse(rawBytes)
Parser-->>Telnet: Data=[], Responses=[IAC WILL ...]
Telnet->>Gateway: SendRaw(responses)
App->>Ordp: LoginNeAsync(neName, inf)
Ordp->>Telnet: SendLineAsync(ne=xxx inf=ECOM_PGW)
Ordp->>Telnet: ReadUntilAsync(</ORDP>)
Telnet->>Gateway: 循环读取解析解码缓冲匹配
Gateway-->>Ordp: XML响应
App->>Ordp: ExecuteMmlAsync(LST PCMCCSUBDATA)
Ordp->>Telnet: SendLineAsync(cmd)
Ordp->>Telnet: ReadUntilAsync(/---\s+END/)
Gateway-->>Ordp: MML执行结果
字符集动态切换
华为不同网元可能返回 GBK 或 UTF-8 中文字面量。登录后 ORDP 会通过 NE-EXINFO 下发字符集,客户端动态切换:
_neInfo = ParseNeExInfo(neName, interfaceName, exInfoResponse.Message);
_telnetClient.Encoding = Encoding.GetEncoding(_neInfo.CharsetEncoding);
Encoding setter 会自动重建 _decoder,确保新字符集正确解码后续数据。
五、关键设计总结
| 关注点 | 方案 | 为什么 |
|---|---|---|
| 并发发送 | SemaphoreSlim(1,1) | 异步互斥锁,不阻塞线程池 |
| 并发读取 | SemaphoreSlim(1,1) + _pendingText | Socket 读不能并发,缓冲支持批量响应拆包 |
| 协议解析 | 八状态有限状态机 | 逐字节扫描,不依赖一次性完整帧 |
| IAC 处理 | 发送侧转义、接收侧还原 | 业务数据中的 0xFF 不会被误判为命令 |
| 字符解码 | Encoder.GetDecoder() 复用 | 跨 Socket 读取边界正确解码多字节字符 |
| 超时控制 | CancellationTokenSource 链接 | 同时支持调用方取消和操作超时 |
| 协商响应 | 解析器自动生成 | 业务代码无需关心 Telnet 协议细节 |
| 连接管理 | IAsyncDisposable + Interlocked.Exchange | Dispose 幂等、线程安全 |
六、踩坑记录
1. 登录残留 — END 导致首条命令被截断
华为网元登录完成后,网关会回显一条 — END 结束标志到 Socket 缓冲。紧接着业务代码发送 MML 命令并 ReadUntilAsync,读到的响应会把登录残留拼在前面,导致命令结果只返回后半段。
修复:每次发送 MML 命令前调用 ClearPendingText() 清空 _pendingText 和 Parser 状态。
2. ORDP 网关单连接能力有限
网关 188.0.108.25:28030 是单点,多连接并发(翻倍到 16 条)会导致网关过载,单条 MML 耗时增加 3~5 倍。实测总耗时反而没有改善。
结论:按设备分组、组内串行、设备间并发的策略更适合这种单点网关架构。
3. Socket 分片发送
TCP 不保证一次 Send 全部发出去。必须在循环中检查 sent < data.Length,否则大命令会被截断到对端,Telnet 协议层就等着下一次续包——而你已经超时了。
结语
实现一个生产级 Telnet 客户端,看起来是在和 Socket 打交道,实际上是在设计一个协议状态机 + 异步流式处理器。Telnet 规范(RFC 854)写了 70 年,但它处理的问题今天依然存在:传输层不保证消息边界、两端能力不对称、编码不统一。
SwitchData 项目中的 TelnetClient 完整实现了这套思路,支撑了华为 ORDP 网关、中兴 NetNumen 等多个厂商设备的接入。如果你也需要直连 Telnet 设备执行命令,可以直接复用这个设计——或者把它当作学习 .NET 网络编程的一个实战范例。