
简介一份面向C#开发者的SICK RFID读卡器通信示例工程基于TCP客户端实现RFU630设备的异步读取与多线程数据交互适合自动化、物流及生产流程中需要对接工业RFID设备的开发人员参考。压缩包共32个文件大小约60KB其中包含10个C#源文件、3个可执行程序、项目配置与资源文件等可直接打开.sln解决方案查看完整工程结构。重点演示了TcpClient连接建立、async/await异步读取、Task多线程处理以及委托配合Control.BeginInvoke安全更新UI控件等核心写法并附带相关代码注释和说明文档。其中委托部分有专门示例帮助理解跨线程通信的关键场景同时工程结构清晰适合初学者快速上手C#网络编程与工业设备交互。已有1309人学习下载对正在接触RFID设备接入或TCP通信开发的程序员具有较为直接的借鉴与参考价值。1. 工位上的那个读卡器为什么一个异步TCP客户端比轮询可靠自动化产线的某个工位电脑上网线连着一台SICK RFID读卡器贴着RFID标签的工件托盘从天线下方滑过去上位机要接住那一串标签ID作为物料绑定、工序点检或者MES追溯的源头。我第一次接这类设备时用的还是老式写法开一个BackgroundWorker死循环去轮询读卡器结果连续过料时UI卡成幻灯片、标签偶尔丢一两个查半天查不出是设备问题还是程序问题。把整套逻辑拆掉重写成C#异步TCP客户端之后事情变得非常干净读卡器是TCP服务端程序连上去以后开一个异步接收循环数据到达自动拆帧、校验、回调程序挂一整天内存和CPU都很稳定。下面几章我把这套程序的连接、收数、拆帧、重连、对接逐段拆开新手能照着跑通熟手可以直接拿走改造成自己的读卡服务。2. 通信链路与工程结构先看懂SICK读卡器在等什么2.1 TCP会话模型服务端是设备客户端是上位机SICK的RFID读卡器在以太网模式下工作形态大致分两种一种是主动上报设备内部配好IP和端口读到标签就自己把数据推给上位机另一种是命令应答程序发一条“读标签”指令设备收到后才返回当前天线下的标签数据。不管是哪一种TCP链路的角色都是固定的——读卡器是服务端上位机程序是客户端。这和很多人默认的“设备主动连上位机”正好相反我第一次就搞反了。第一版我把设备当客户端让程序开一个TcpListener监听结果设备根本没有主动连过来的行为现场电气工程师看了半天说设备那边不需要配客户端地址。后来翻设备手册的Ethernet Interface章节才明白SICK这类工业读卡器出厂就开着监听你要做的是让上位机去connect它的IP和端口。血泪经验是接到项目第一步先确认谁listen谁connect端口号和IP由设备侧配置现场调试时会有一张IP分配表程序里放到配置文件可改就行。注意设备的IP如果不在工位机同一个网段要先去设备网页配置页把网络参数改好不是只改程序里的连接字符串就能通的。2.2 工程目录与模块职责六个文件撑起一条完整链路整个工程用.NET Core控制台项目做原型跑在Windows工位机上也完全没问题TCP这块的API和.NET Framework 4.7.2基本一致。目录结构拆得非常简单核心类只有三个连接管理、异步接收循环、帧解析器。这样分布的目的是换一台不同型号的设备时改协议解析那一个文件就行网络部分完全不动。RfidTcpClient/ ├─ Program.cs // 入口读配置、启动客户端、挂事件 ├─ RfidTcpClient.cs // 连接管理 异步接收循环 断线重连 ├─ FrameParser.cs // 字节流按帧拆解、CRC校验、提取标签数据 ├─ RfidTagData.cs // 标签数据模型TagId、天线号、时间戳 ├─ AppSettings.config // IP、端口、重连间隔、日志路径 └─ LogHelper.cs // 简单文件日志按天写一个txt配置项长这样现场换设备只需要改前两行配置项示例值说明RfidIp192.168.0.110读卡器IPRfidPort1000读卡器TCP端口ReconnectIntervalMs3000断线重连间隔LogPathlogs日志目录Program.cs做的事情很少读配置、new一个RfidTcpClient、订阅OnTagReceived事件、调用RunAsync启动。事件回调里只打印日志真正跑业务是后面二次开发的事情。在原型阶段保持这个纯度排查问题的时候你不会在“网络层出错”和“业务代码出错”之间来回猜。2.3 帧协议半包粘包怎么从字节流里切出TagIdSICK不同型号的帧格式有差异但工业RFID通过以太网传输的帧结构基本都遵循一个套路帧头 长度 命令码 数据域 校验。以这类设备常见的一种上报帧为例结构长这样字节偏移字段名长度说明0帧头2固定标识比如 0x02 0x022数据长度2从命令码到校验之前的所有字节数4命令码11表示读标签上报2表示写标签5数据域N标签TID、EPC或UUID5N校验2CRC校验计算范围从帧头到数据域末尾凡是通过TCP收到的字节流一定会遇到两个经典问题半包和粘包。一次ReadAsync可能只读回半个帧也可能把两帧数据粘在一起返回来。TCP协议本身不保证一次Receive对应一帧数据所以FrameParser存在的意义就是维护字节缓冲不断从缓冲里“尝试”切出完整帧——切不出来的字节留在原地等下一包到了再拼接。这就是后面核心代码里那个pending缓冲列表的由来。3. 核心代码拆解连接、异步读、断线重连的完整实现3.1 连接管理器ConnectAsync里为什么要做超时兜底连接这一步最大的坑是TcpClient.ConnectAsync本身没有超时参数直接用await等它返回设备不在线时这个await会卡很久工位机操作员一旦点了启动却发现界面像死了一样体验非常差。我一般用Task.WhenAny做一个“赛跑”式超时连接Task和一个Delay Task同时跑谁先完成谁说了算。public async Taskbool ConnectAsync(string ip, int port, int timeoutMs 3000) { using (var cts new CancellationTokenSource(timeoutMs)) { try { _client new TcpClient(); // ConnectAsync 没有内置超时借 Task.WhenAny 手动控制 var connectTask _client.ConnectAsync(ip, port); var doneTask await Task.WhenAny(connectTask, Task.Delay(timeoutMs, cts.Token)); if (doneTask ! connectTask) { LogHelper.Error($连接超时: {ip}:{port}); _client.Close(); return false; } await connectTask; // 到这里才算真正连上 _stream _client.GetStream(); _stream.ReadTimeout 5000; return true; } catch (Exception ex) { LogHelper.Error(连接异常: ex.Message); return false; } } }逻辑说明Task.WhenAny返回先完成的那个Task如果返回的是connectTask说明连接成功了如果返回的是Delay说明3秒内没连上立刻关掉TcpClient。这里有个细节CancellationTokenSource用using包着否则每次重连都会产生一个Timer对象程序长时间运行会积累大量定时器。ReadTimeout设成5秒是给后面的接收循环兜底防止网络假死时ReadAsync永远不返回。参数说明timeoutMs暴露给调用方现场设备不在子网里时这个值调大不如调小——工位机等3秒已经够煎熬了3秒连不上基本就是网络配置有问题直接暴露日志就行。重连循环就简单了失败后睡ReconnectIntervalMs毫秒再试这里不用异步等待直接Task.Delay。3.2 异步接收循环一个永不阻塞的while循环连接成功后进入接收循环这是整台设备数据进来的唯一入口。很多人会在这里写while(true)加Thread.Sleep那是把异步当成同步用。正确做法是用ReadAsync挂起当前线程让操作系统在数据到达时唤醒它程序挂一天CPU占用几乎为零。public async Task RunAsync(CancellationToken token) { var buffer new byte[4096]; var pending new Listbyte(); // 残帧缓冲攒不满一个帧就留在这里 while (!token.IsCancellationRequested) { try { int read await _stream.ReadAsync(buffer, 0, buffer.Length, token); if (read 0) { LogHelper.Warn(对端关闭连接进入重连流程); break; } lock (pending) { pending.AddRange(buffer.Take(read)); FrameParser.TryExtractFrames(pending, out Listbyte[] frames); } foreach (var frame in frames) { var tag FrameParser.ParseFrame(frame); if (tag ! null) OnTagReceived?.Invoke(this, tag); } } catch (OperationCanceledException) { break; // 程序退出 } catch (IOException ex) { LogHelper.Error(读流异常: ex.Message); break; // 网络断了交给外层重连 } } } public event EventHandlerRfidTagData OnTagReceived;逻辑说明pending这个List是处理半包的关键。ReadAsync读回的字节先全部塞进pending然后TryExtractFrames尝试从里面切出完整帧。切得出来的拿出来处理切不出来的字节留在pending里等下一轮ReadAsync的数据进来再拼接。lock是防多线程——OnTagReceived的订阅方如果丢数据到后台队列回调线程可能和接收循环有并发访问锁一下pending最稳妥。这里一个反复出现的坑不要在OnTagReceived回调里同步写数据库、写文件、刷新UI。接收循环被阻塞一秒钟高速过料时就会丢帧设备侧的FIFO会被塞满。回调里应该只做最快的事真正耗时的操作交给另一个消费线程。3.3 帧解析器从半包粘包里抠出完整帧FrameParser分成两个方法TryExtractFrames负责从字节流里切帧ParseFrame负责把一帧数据解析成RfidTagData。拆开的原因是“切帧”和“解析”是两个关注点前者处理TCP传输的不确定性后者处理设备协议的内容。public static class FrameParser { private const int HeaderLen 2; // 帧头长度 private const int LenField 2; // 长度字段长度 public static void TryExtractFrames(Listbyte pending, out Listbyte[] frames) { frames new Listbyte[](); while (pending.Count HeaderLen LenField) { // 扫描帧头设备上电瞬间会发脏字节不是帧头直接丢弃 if (pending[0] ! 0x02 || pending[1] ! 0x02) { pending.RemoveAt(0); continue; } int len (pending[2] 8) | pending[3]; // 大端长度 int totalLen HeaderLen LenField len; if (pending.Count totalLen) return; // 数据不够留着等下一波 var frame pending.GetRange(0, totalLen).ToArray(); pending.RemoveRange(0, totalLen); frames.Add(frame); } } public static RfidTagData ParseFrame(byte[] frame) { if (frame.Length 7) return null; ushort crc Crc16.Modbus(frame, frame.Length - 2); ushort recvCrc (ushort)((frame[frame.Length - 2] 8) | frame[frame.Length - 1]); if (crc ! recvCrc) { LogHelper.Warn(CRC校验失败丢弃该帧); return null; } byte cmd frame[4]; if (cmd 0x01 frame.Length 9) { int tagOffset 5; int tagLen frame.Length - tagOffset - 2; // 去掉末尾2字节校验 string uuid BitConverter.ToString(frame, tagOffset, tagLen).Replace(-, ); return new RfidTagData { TagId uuid, Antenna frame[5], // 多天线设备的数据域首位是天口号 Timestamp DateTime.Now }; } return null; } }逻辑说明TryExtractFrames里的while循环会一直切直到pending里剩下的字节不够一个帧头加长度字段或者不够长度字段声明的总长度为止。GetRange和RemoveRange的顺序不能反先取走再清掉。帧头扫描那段代码是现场逼出来的读卡器上电瞬间会吐一串无意义的字节如果不逐字节扫描帧头而直接按偏移取值整个缓冲会错位解析结果全是乱码。CRC校验放在这层做是因为工业现场电磁环境复杂设备偶尔会发出被干扰的坏帧程序侧能做的是把它记一条日志然后丢弃不往业务层传。CRC16算法是标准Modbus CRC16网上有现成实现几十行代码贴进去就能用。3.4 数据分发事件回调把读卡器和业务解耦RfidTagData模型只有三个字段够用就行。事件的方式比在接收循环里直接写业务灵活上层MES对接代码只管订阅OnTagReceived完全不碰网络逻辑。public class RfidTagData { public string TagId { get; set; } // 标签唯一标识 public int Antenna { get; set; } // 触发天线编号 public DateTime Timestamp { get; set; } // 读取时间 } // Program.cs 里的接法 var reader new RfidTcpClient(); reader.OnTagReceived (s, e) { Console.WriteLine($读到标签: {e.TagId} 天线: {e.Antenna} 时间: {e.Timestamp:HH:mm:ss.fff}); // 这里放业务入数据库、发MES、亮灯提示 };订阅方直接拿到强类型的RfidTagData对象不用关心字节怎么来、帧怎么切。等你要对接MES的时候这段回调里的内容就是唯一改动点。订阅事件之后必须记得程序退出时取消订阅否则控制台程序反复启动/停止会挂着一堆事件处理器吞吐量会慢慢下降。4. 避坑与运行验证连不上、丢标签、界面卡顿的排查清单4.1 现象程序连上了但一帧都收不到现象日志显示TCP连接成功OnTagReceived一次都没触发拿测试标签在读写器天线上来回划也没反应。原因最常见的是读卡器工作模式是命令应答光建立TCP通道没用设备在等你发读命令它不会主动吐数据。解决进设备的网页配置页浏览器输入设备IP就行把数据输出模式切成主动上报如果切不了就在程序里按手册发一条读标签命令。另一个隐蔽原因是程序连错了端口设备有调试口和数据口两个TCP端口数据口才是读标签数据的通道。4.2 现象过料速度快的时候偶尔丢标签现象产线节拍快了以后100个标签过去只读到97个读卡日志里隔几个就空一个。原因先区分是设备丢还是程序丢。设备侧天线切换、标签防碰撞仲裁都需要时间传送带太快时设备自身读取周期跟不上程序侧丢数据几乎都是回调阻塞——OnTagReceived里同步写了数据库把接收循环堵住了。解决在日志里对比“设备上报帧数”和“程序解析帧数”两边一样就是设备侧问题拉长天线驻留时间或放慢节拍程序侧丢了就改成消费队列。注意不要自己加Thread.Sleep限流那只会让设备FIFO溢出。4.3 现象UI界面在连续读卡时卡成幻灯片现象程序带个WinForm界面实时显示标签速度正常时没事标签密集的时候点击按钮半秒才响应。原因OnTagReceived回调直接在接收循环所在线程执行如果在这里更新listBox.Items.Add等于把UI操作塞进网络线程密集时UI消息泵被挤满。解决回调里一律不碰UI元素把RfidTagData丢进ConcurrentQueue界面用System.Windows.Forms.Timer定时去队列取数据刷新。DataGridView这类控件批量Add时包一层BeginUpdate/EndUpdate重绘开销能降一大截。4.4 现象程序跑一晚上之后断线再也重连不上现象第二天早上发现程序还活着日志显示凌晨两点断线之后一直重连但都失败重启程序立刻能连上。原因典型的句柄泄漏。检查代码发现重连分支里每次ConnectAsync失败都new了一个TcpClient但没有Dispose旧连接没有关闭处于TIME_WAIT的socket堆积把本地端口资源耗尽新连接就分配不到端口了。解决ConnectAsync里TcpClient统一用using或finally中调用Close每次重连前检查之前实例是否释放干净在重连日志里加一条socket计数发现持续上涨基本就是这一段的问题。4.5 运行验证从启动到第一条标签记录把AppSettings.config里的IP改成现场设备地址启动程序。正常日志走向是连接成功→等待数据→划卡→看到一条“读到标签”记录。如果连不上先用ping命令确认链路通再用Test-NetConnection -Port确认TCP端口通这样能快速把问题定位到“网络层”还是“协议层”。验证通过后在日志里搜“CRC校验失败”关键字如果频繁出现说明现场电磁干扰严重最常见的是变频器干扰把设备侧屏蔽线可靠接地能解决大部分问题。5. 二次开发把RFID数据接进MES或PLC网关5.1 最小改造标签转HTTP上报对接MES最直接的方式是HTTP回调。在OnTagReceived里把标签数据POST给MES的接口几行代码的事但有个关键点必须用HttpClient的异步Post不能用WebClient的同步UploadString否则又回到了阻塞接收循环的坑里。private static readonly HttpClient Http new HttpClient(); private async void OnTagReceived(object sender, RfidTagData e) { var payload JsonSerializer.Serialize(new { tag_id e.TagId, antenna e.Antenna, ts e.Timestamp }); // 异步POST不阻塞接收线程 await Http.PostAsync(http://your-mes-server/api/trace, new StringContent(payload, Encoding.UTF8, application/json)); }逻辑说明async void只用在事件处理器上普通方法别这么写异常会直接抛到同步上下文里。HttpClient用静态单例避免频繁new导致socket资源浪费。如果MES接口偶尔超时这里要做失败重试——POST失败后把标签写进本地队列后台线程定期补传防止追溯数据缺口。5.2 扩展点多读卡器、写卡、掉线缓存多读卡器场景把RfidTcpClient包一层ReaderContext每个实例对应一个读卡器用字典按工位名索引超过十台设备建议用线程池统一调度。写卡功能需要给RfidTcpClient加一个WriteTagAsync方法构造写卡命令帧用SemaphoreSlim保证同一时刻只有一个写请求在飞——多线程同时写设备数据域会互相覆盖。掉线缓存用SQLite最简单断线期间标签入本地表重连后批量补传SQLite不用装服务一个文件搞定。5.3 关键习惯业务永远别进网络线程改造这些功能时最核心的一条主线接收线程只做拆帧数据只进队列业务消费走独立的后台通道。我在一个改造成品追溯的项目里把标签去重逻辑直接写进回调用了一个普通Dictionary记录已经上报过的TagId结果多线程并发改同一个字典直接抛异常半夜在生产现场翻车最后改成ConcurrentDictionary才消停。还有一次在回调里调用了一个同步的第三方接口对方接口偶发慢响应直接把接收循环堵死整条产线追溯数据断了十分钟。从那以后我每次对接新厂家的读卡器都强制自己先走一遍“连接、收数、拆帧、落日志、验证CRC”这条最小闭环确认链路完全可靠之后才允许碰任何业务代码这个习惯帮我省掉了后面无数个半夜电话。希望帮到你。本文还有配套的精品资源点击获取