C#异步TCP客户端对接SICK RFID读卡器:从组帧到拆包重连全解析
2026/9/8 13:46:12 网站建设 项目流程

简介:面向需要与德国SICK RFU630 RFID读卡器联调的C#开发者,这份代码工程将TCP客户端与工业RFID读取场景结合,集中演示了异步IO、多线程调度和委托跨线程更新UI三个核心问题。压缩包内共有32个文件,体积约60KB,核心是10个C#源码文件,配合解决方案文件与工程配置,另有3个exe程序可直接运行验证;resx/resources界面资源与pdb调试符号也一应俱全。已有1296人学习使用。工程详细展示如何使用TcpClient建立稳定连接,如何在NetworkStream上通过async/await实现非阻塞读取,以及怎样借助Task或Thread维护独立读卡线程;同时通过委托和Control.BeginInvoke安全回传数据,解决跨线程访问控件的常见异常。对于准备接入工业RFID设备、需要快速理解C#异步网络编程实战套路的开发者,这份小巧但完整的示例覆盖了从IP端口配置、连接、发送指令到接收数据的完整链路,具备很高的参考与移植价值,尤其适合做二次开发时的最小可用原型。 前不久接了个产线MES对接的活儿:一台德国SICK的RFID读卡器,要求上位机用C#写一个TCP客户端,把读卡器读到的标签数据异步取回来,实时入库。项目本身不大,但真做起来发现坑不少——协议帧怎么组、C#异步Socket怎么写、半包粘包怎么拆、断线怎么重连,每一个点都能让人卡上半天。我想把这套SICK RFID读卡器读卡程序的完整实现思路写出来,包括TCP客户端的骨架代码、通信协议解析、异步收发的关键细节,以及现场调试时最容易翻车的几个地方。如果你正在做RFID、扫码枪、工业设备的上位机对接,这篇文章应该能帮你少走不少弯路。

1. 为什么用TCP客户端连SICK读卡器,而不是串口或SDK

1.1 工业读卡器的网口化趋势

SICK的RFID读卡器在自动化产线上用得很多,典型的如RFU系列,基本都带了以太网接口,支持TCP/IP通信。很多老工程师习惯性想到串口,觉得简单、稳定,但新设备、新产线里串口的比例越来越低。原因很现实:串口传输距离有限,波特率再高也就115200,而且一台工控机要接多台读卡器时,串口资源根本不够用。走以太网之后,一根网线就能解决通信距离和组网问题,通过交换机能同时接十几台设备,数据量也大得多。

另外,SICK这类工业读卡器通常还支持多种工业协议,比如PROFINET、EtherNet/IP、Modbus TCP,但纯上位机做数据采集时,最通用、最灵活的方式反而是直接开一个TCP Socket,走读卡器原生的Host Protocol(主机协议),或者把读卡器配置成TCP Server模式,让C#上位机作为TCP客户端去连。这种方式的优点是依赖最少,不需要装任何SDK、不需要额外授权,只要知道IP、端口和报文格式就能干活。

1.2 动手前先确认三件事,能省三天事

写代码之前,我强烈建议先确认三件事:

  • 读卡器的IP地址和端口号。SICK读卡器一般可以在配置软件(比如SOPAS)里或网页配置界面里看。默认端口不一定相同,常见的有2112、8090之类,一定要按你手里这台设备的实际配置来,不要凭经验猜。
  • 当前运行的协议模式。很多SICK读卡器出厂可能默认是PROFINET或Modbus TCP,而不是纯TCP Host Protocol。如果你直接发ASCII命令过去,设备可能只会回总线协议的报文,通信根本对不上。需要先在配置软件里把通信接口切到TCP/IP Host模式。
  • 报文格式是ASCII还是十六进制。有的读卡器配置成文本模式时,命令是可读字符串;配置成二进制模式时,就需要按字节组帧。这一点直接决定你后面所有代码怎么写。

我把串口和TCP的差异整理成一张表,方便你选型时做判断:

对比项串口TCP/IP
传输距离一般十几米就衰减明显网线100米,加交换机可扩展
速率115200bps以内百兆/千兆,远高于串口
同时接入数一台工控机串口有限可接交换机,多设备并发
工程布线专用线缆,抗干扰要求高标准网线,方便维护
上位机实现难度简单,SerialPort即可需要处理Socket、拆包粘包
适用场景老设备、点位少新建线、集中采集、多读卡器

结论很简单:如果读卡器有以太网口,优先走TCP。虽然写起来比串口多了点技术门槛,但后期扩展和维护舒服得多。

2. 报文结构:必须先吃透SICK读卡器的帧格式和校验方式

2.1 一个典型的请求/响应帧长什么样

SICK读卡器的Host Protocol在不同型号上可能有差异,但大多逃不开一种结构:起始符、设备地址、长度、命令、数据、校验、结束符。我以自己调试过的二进制帧形式为例,通用结构如下:

字段字节数说明
STX1起始符,通常为0x02
ADR1设备地址,单机时一般0x00
LEN1从命令字到数据末尾的总长度
CMD1命令字,比如读标签、读配置
DATAN参数或标签数据
LRC1校验字节
ETX1结束符,通常为0x03

比如发送一条“读ID”命令,命令字假设是0x20,数据字段为0x00,那么帧会是:02 00 02 20 00 LRC 03。其中LEN=0x02表示CMD+ DATA一共2个字节。总帧长就是5 + LEN,也就是7字节。不同设备可能有其他变体,千万别把这个结构当铁律,但理解思路后换到别的设备也快。

提示:开始写代码前,一定先把设备手册里的帧结构拍下来,对着逐字段核对。工业设备不像PC外设,很多自定义协议,靠猜会死得很惨。

2.2 LRC校验的手工计算法

很多SICK读卡器的二进制协议使用LRC(纵向冗余校验)作为帧校验。计算方式不复杂:从ADR开始,一直到DATA的最后一个字节,把这些字节逐个异或,再取反,得到LRC字节。用C#写就是一小段:

static byte CalcLrc(ReadOnlySpan<byte> data) { byte xor = 0; foreach (var b in data) { xor ^= b; } return (byte)~xor; }

以刚才的命令帧为例,对00 02 20 00这四个字节做LRC:

  • 0x00 XOR 0x02 = 0x02
  • 0x02 XOR 0x20 = 0x22
  • 0x22 XOR 0x00 = 0x22

取反得 0xDD,所以完整帧是02 00 02 20 00 DD 03

有些设备可能直接异或不去反,有的甚至用累加和;就算同样叫LRC,细节也能差出十万八千里。所以加校验函数的时候,最好用一个配置项区分“异或取反”还是“直接异或”,现场切换方便。我吃过这个亏:手册上写的是BCC校验,我按惯用LRC去算,整整折腾了两个小时才发现校验方式少了一步取反。

2.3 先抓帧,再写解析器

很多同事拿到代码任务是直接开写TCP客户端,我反而建议先做一步“不写代码”的动作:用TCP调试助手,或者干脆用C#写个最简单的Socket客户端,手动连接读卡器,发一条命令,把读卡器返回的原始字节流完整看一遍。

这个动作能帮你确认三件事:设备响应是否正常、返回的帧里命令字段和长度字段的位置、以及标签数据藏在哪个字节区间。等原始数据抓清楚了,你再动手写解析器,成功率会高很多。我见过太多人上来直接写正式程序,结果一边写一边猜协议,写完全是Bug,最后还搞不清是代码问题还是设备问题。

3. C#异步TCP客户端骨架:连接、发送、接收三层分离

3.1 连接和断线重连的基本框架

C#里做TCP客户端,最直接的是TcpClient类。异步读取用async/await非常顺手,关键是不要把所有逻辑堆在事件里,而是分成连接管理、发送、接收循环三块,各自独立。下面是一个简化的骨架代码:

public class SickRfidTcpClient : IDisposable { private TcpClient _tcpClient; private NetworkStream _stream; private CancellationTokenSource _connectCts; private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); public event Action<byte[]> FrameReceived; public event Action<string> Disconnected; public async Task ConnectAsync(string ip, int port) { _connectCts?.Cancel(); _connectCts = new CancellationTokenSource(); _tcpClient = new TcpClient(); _tcpClient.ReceiveTimeout = 3000; _tcpClient.SendTimeout = 3000; await _tcpClient.ConnectAsync(ip, port); _stream = _tcpClient.GetStream(); // 接收循环放后台跑,不阻塞主流程 _ = ReceiveLoopAsync(_stream, _connectCts.Token); } public bool IsConnected => _tcpClient?.Connected ?? false; }

这里要特别说明:ReceiveTimeout只对同步读写有效,异步ReadAsync并不会自动受它控制。所以真正的超时控制,要么靠CancellationTokenSource.CancelAfter,要么在接收循环自己做判断。如果后续要用异步发送,建议发送方法里套一层带超时的等待,否则命令丢失或设备停机时,任务可能一直挂在那里。

3.2 发送命令与等待应答的任务管道

工业读卡器很多是“请求-响应”型:上位机发一条命令,读卡器回一条结果。如果每个命令都裸写WriteAsync再等接收,代码会很散。我习惯用TaskCompletionSource做命令和响应的匹配:发送时登记一个等待对象,接收循环解到对应命令帧后,把结果写回这个Task。

private readonly ConcurrentDictionary<byte, TaskCompletionSource<byte[]>> _pendingAwaits = new(); public async Task<byte[]> SendCommandAsync(byte command, byte[] payload, CancellationToken ct = default) { // 组帧、校验 var frame = BuildFrame(command, payload); var tcs = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously); _pendingAwaits[command] = tcs; await _sendLock.WaitAsync(ct); try { await _stream.WriteAsync(frame, ct); } finally { _sendLock.Release(); } using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(3000); return await tcs.Task.WaitAsync(timeoutCts.Token); }

有几处细节:

  • _sendLock用来保证同一时间只发送一条命令,避免读卡器不支持并发命令时互相干扰。
  • _pendingAwaits按命令字做匹配,收到响应帧后,从字典里取出TaskCompletionSource,调用TrySetResult
  • 超时触发后,记得把TaskCompletionSource从字典移除,否则会积累内存或导致后续响应匹配错乱。

如果你的读卡器支持“主动上报”标签(比如感应到标签后实时推送,不需要每次请求),那么接收分发的逻辑还要再拆一层:一部分帧是“请求-响应”,另一部分是“事件推送”,两者要分别处理。

3.3 基于NetworkStream的异步接收循环

接收循环是整个程序的发动机,核心就是一个不断从NetworkStream读数据的循环:

private async Task ReceiveLoopAsync(NetworkStream stream, CancellationToken ct) { var buffer = new byte[4096]; var receiveBuffer = new List<byte>(); try { while (!ct.IsCancellationRequested) { int count = await stream.ReadAsync(buffer, ct); if (count == 0) { break; // 连接关闭 } for (int i = 0; i < count; i++) { receiveBuffer.Add(buffer[i]); } // 尝试从缓存中拆出完整帧,见下一节 while (TryExtractFrame(receiveBuffer, out byte[] frame)) { FrameReceived?.Invoke(frame); } } } catch (OperationCanceledException) { } catch (Exception ex) { // 记录异常 } finally { // 触发断线事件,供上层重连 Disconnected?.Invoke(); } }

ReadAsync会挂起等待数据,C#的async/await在这里基本没有额外线程阻塞,很适合长时间运行的采集应用。要注意的是,网络断开时不同情况下表现不一样:正常关闭会返回0,异常断开可能直接抛IOException,所以catch里除了OperationCanceledException,还要兜底记录异常,然后触发重连逻辑。

4. 拆包粘包处理,和EPC标签数据的提取

4.1 为什么必须处理半包和粘包

TCP是字节流协议,不像UDP那样一条消息一个包。底层可能一次收到半条命令,也可能一次收到好几条完整命令连在一起。如果不管三七二十一直接按一次ReadAsync的结果去解析,大概率会出错。所以必须在接收缓存里做帧边界识别。

我们前面定了帧结构,拆包逻辑就可以按“找STX -> 读LEN -> 判断总长是否足够 -> 校验ETX”的顺序来。我给一个通用实现:

private bool TryExtractFrame(List<byte> buffer, out byte[] frame) { frame = null; if (buffer.Count == 0) return false; // 找到STX;如果STX之前有脏数据,全部丢弃 int stxIndex = buffer.IndexOf(0x02); if (stxIndex < 0) { buffer.Clear(); return false; } if (stxIndex > 0) { buffer.RemoveRange(0, stxIndex); // 重新判断,下一轮再取 if (buffer.Count == 0) return false; } // 至少要能读到 LEN if (buffer.Count < 3) return false; int len = buffer[2]; int totalLen = len + 5; // STX + ADR + LEN + CMD+DATA + LRC + ETX if (buffer.Count < totalLen) { // 半包,继续等 return false; } // 检查ETX if (buffer[totalLen - 1] != 0x03) { // 帧尾不对,可能是噪声,丢弃第一个字节再重试 buffer.RemoveAt(0); return false; } frame = buffer.GetRange(0, totalLen).ToArray(); buffer.RemoveRange(0, totalLen); return true; }

这段代码的核心思想就一句话:凑够了再取,取完就切,没凑够就等List<byte>虽然简单,但数据量大时频繁增删有开销;工业现场数据量一般不大,足够用。如果追求极致性能,可以改成MemoryStream或环形缓冲区,但逻辑是一样的。

4.2 校验和命令分发

拆出完整帧后,先做LRC校验,再按命令字分发。如果校验失败,这一帧要么是网络错误,要么是设备协议里面还有隐藏字段,最好记录下来而不是直接丢弃,方便排查。

private void DispatchFrame(byte[] frame) { byte adr = frame[1]; byte len = frame[2]; byte cmd = frame[3]; // 校验LRC(从ADR到数据末尾) byte expectedLrc = frame[frame.Length - 2]; byte actualLrc = CalcLrc(frame.AsSpan(1, frame.Length - 3)); if (expectedLrc != actualLrc) { // 记录异常帧,继续等下一帧 return; } if (_pendingAwaits.TryRemove(cmd, out var tcs)) { tcs.TrySetResult(frame); } else { // 可能是主动上报的标签数据帧 HandleTagReport(frame); } }

需要注意:_pendingAwaits.TryRemove(cmd, ...)这种按命令字匹配的方式,前提是同一时间不会同时发两条相同命令。对于很多读卡器,一次只发一条命令是够用的。如果你真遇到需要并发请求的场景,就得改成递增序列号做匹配,实现复杂度会高一个量级。

4.3 EPC标签数据到底怎么抠出来

当读到一条标签上报帧时,EPC(电子产品码)通常以字节形式存在数据字段里。有些协议帧里会有一个长度字节说明EPC占多少字节,有些则固定按某个偏移读取。以“第一个数据字节放EPC长度,后面跟着EPC内容”的常见结构为例子:

private void HandleTagReport(byte[] frame) { if (frame.Length < 6) return; int offset = 4; // 从数据字段开始 int epcLength = frame[offset]; offset++; if (frame.Length < offset + epcLength) return; byte[] epcBytes = frame.AsSpan().Slice(offset, epcLength).ToArray(); string epc = Convert.ToHexString(epcBytes); TagDataReceived?.Invoke(epc); // 记录最近一次标签,后续查询可以快速返回 _lastEpc = epc; }

Convert.ToHexString是.NET 5以后的用法,输出就是大写十六进制字符串,比如E280116060000204B7C4E624这样。如果你还在用.NET Framework,可以用BitConverter.ToString(epcBytes).Replace("-", "")替代。

注意:别把EPC数据长度和帧长度搞混。帧长度是整条报文的字节数,EPC长度只是标签数据区里某段内容的长度。解析时先确认偏移量,不然读出来的EPC永远是乱的。建议调试时先把返回帧用十六进制字符串整体打印出来,人工对比着看,确认EPC位置后再固化解析逻辑。

5. 现场调通必备的三个坑,和一种调试习惯

5.1 坑一:异步任务卡死,命令超时没有兜底

TaskCompletionSource等响应最大的坑,就是设备没回复时任务会永远挂住。尤其现场电磁干扰强,偶尔一帧报文丢失很正常,如果没有超时兜底,几十秒后整个发送线程池都能被卡满。

解决办法就是上面代码里已经写到的:timeoutCts.CancelAfter(3000),并且给等待任务加上WaitAsync(timeoutCts.Token)。超时触发后,记得把对应的TaskCompletionSource从字典里移除,否则后面即使收到响应,也没人消费它。

5.2 坑二:事件回调里直接操作UI控件,直接抛异常

C#上位机里,接收循环跑在后台线程,FrameReceived事件也是在后台线程触发的。如果你想在这个事件里更新WinForm或WPF的界面,直接写label.Text = epc大概率会崩,因为UI控件只能在UI线程操作。

简单处理方式是拿到控件的时候判断一下InvokeRequired,或者在程序启动时捕获SynchronizationContext

private readonly SynchronizationContext _syncContext; // 在UI线程构造函数里保存: // _syncContext = SynchronizationContext.Current; private void OnTagDataReceived(string epc) { _syncContext.Post(_ => label1.Text = epc, null); }

如果只是写数据库或日志,就不需要切上下文,保持后台线程执行即可,别因为不必要的调度影响吞吐。

5.3 坑三:设备主动上报和命令应答混在一起,导致响应错配

有的SICK读卡器配置后,读卡器检测到标签会主动往TCP连接里推数据,不一定要上位机先发询问。这个“主动上报”帧里没有你的请求标记,如果用请求/响应模型硬匹配,就会解析错乱。

我的做法是:在DispatchFrame里先明确区分“应答帧”和“上报帧”。如果这个帧的命令字能在_pendingAwaits里找到对应等待,就按应答处理;找不到,就按主动上报处理。这样既能支持一问一答,也能支持设备实时推送,两条通道各走各的。

5.4 一种调试习惯:原始字节流日志永远别省

最后分享一个我个人坚持了很久的习惯:上位机程序里一定要留一个“原始字节流日志”开关,打开之后,把TCP收发的所有字节以十六进制形式写入日志文件。平时可以关掉,调试时打开,排查问题会快很多。

现场很多问题根本不是代码逻辑错,而是设备固件版本不同、协议配置不对、或者某次网络丢包导致帧错位。有原始字节流,你可以直接翻日志,看到底发出去什么、收回来什么,所有疑问用数据说话。如果没有这个日志,就只能盲猜,效率低到令人抓狂。

写这套SICK RFIID读卡器C# TCP客户端时,我自己的流程也慢慢固定成了一套:先确认设备IP、端口、协议模式,再用调试助手抓原始帧,然后写C#的收发骨架,接着处理拆包和校验,最后补超时、重连和日志。这套思路不光适用SICK,换到其他厂家的RFID读卡器、扫码枪、PLC网关,基本也是同样的套路。把底子打好,换设备只是改一改帧解析的事。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询