☰
C#异步TCP读取SICK RFID读卡器:帧解析、LRC校验与断线重连实践
2026/10/21 3:20:06 网站建设 项目流程

简介:德国SICK RFID读卡器RFU630的C#上位机通信示例工程,面向工业自动化、物流与生产场景中需要对接RFID硬件的中高级.NET开发者。资源以TCP客户端建立双向通信,重点演示async/await异步读取、Task多线程,以及用委托跨线程安全更新UI,是理解设备交互与线程模型的完整样例。压缩包为rar格式,共32个文件,体积60KB,含10个cs源码和3个exe,以及sln/config/resources等工程配套文件,可清晰看到从TcpClient连接到NetworkStream读取、再到UI回显的完整路径。目前已有1309人学习。这套精简示例能快速掌握工业RFID通信关键套路,可移植到读卡、扫码或PLC通讯项目中,缩短上位机开发调试周期。

1. SICK RFID读卡器只用过配置软件?这套C# TCP异步读取框架让标签数据直接进你的业务代码

产线立库的托盘工位、AGV接驳点、高值耗材柜,大量场景是SICK读卡器挂网口,PLC和MES都要同一路标签数据。这个品牌的读卡器默认推流式上报,C#上位机绕不开TCP客户端这层。很多人拿到读卡器第一件事是打开配置软件改IP,然后在WinForm里new一个TcpClient,却发现读到的数据要么几帧黏在一起,要么校验位对不上,要么设备重启后上位机压根不会自动重连。这篇把SICK RFID读卡器的C#异步读取完整走一遍:连接参数、收发时的状态机切帧、LRC校验、EPC提取、断线重连,全程按可直接复制改用的思路写。适合用C#对接SICK读卡器做数据采集的上位机开发,也适合现场调试时被数据格式折磨的PLC工程师。

2. 先搞懂SICK读卡器到底在网络上说什么:端口、推流模式与帧结构

2.1 推流式上报:读卡器不是等你来问,而是不停往外吐数据

SICK读卡器接入以太网后,有两种常见工作形态:一种是主机查询模式,上位机发命令读一次回一次;另一种是自动上报模式,只要有标签进入天线场区,读卡器就把标签数据主动推到TCP连接的另一端。实际项目里绝大多数上位机用的是后者,省去轮询,实时性也好。

自动上报模式默认走的是TCP服务端角色还是客户端角色,要分清楚。SICK读卡器很多出厂角色是服务端,也就是它监听一个端口,上位机作为TCP客户端主动去连。也有场景把它配成客户端主动连上位机,但用C#开发时,前者更常见:读卡器监听,上位机连过去,之后什么都不用发,标签数据就一条接一条过来。

连接参数上,主要核对三样:IP地址、端口号、以及是否开了TCP直接输出。SICK读卡器默认IP一般是192.168.0.1这类网段,如果连不上先Ping一下,Ping不通就先查电脑本地IP是不是跟它在同一网段。端口号需要在配置软件里看,不同型号出厂值不一定一样,常见的有20107、21107、3000这类,以实际设备的服务配置为准。上位机做连接超时判断就行,不要无限等。

搞清楚通信角色之后,还有个容易忽略的点:读卡器推上来的数据是持续不断的。只要天线不停扫,TCP连接里就是连续的字节流,不会因为上位机没发请求就安静下来。这直接引出下一节的帧结构问题。

2.2 帧结构:STX开头、长度域、ETX收尾,校验在最后

SICK读卡器通过TCP上报的数据不是裸的EPC编号,而是按帧封装的。常见帧格式如下:

字段长度说明
STX1字节0x02,帧起始
长度域4字节ASCII从数据域到ETX的字节数,用十六进制ASCII表示
数据域可变命令响应或标签数据内容
ETX1字节0x03,帧结束
LRC校验2字节ASCII从STX之后到ETX之前的字节异或值,以十六进制ASCII形式存放

举个实际例子,读卡器上报一帧EPC数据:

02 30 34 45 32 38 30 36 38 39 34 30 30 30 30 34 30 32 36 31 30 43 39 33 41 30 31 03 37 39

对照表格拆解一下。第一个字节0x02是STX。紧随其后四个ASCII字符"30 34"是长度,表示后面数据域加ETX再加LRC一共4个字节,不对,这不是完整的。让我把结构和协议对齐。

这里需要说细一点。长度域是ASCII编码的十六进制数,它统计的是从它自己之后到LRC之前的所有字节数。比如"30 34"是ASCII字符"04",表示后面数据域加ETX加LRC总共04个字节。当EPC为12字节的UHF标签时,EPC以ASCII十六进制表示占24字节,长度算出来就不是04了。所以严格来讲,长度域的计算范围是什么,不同固件略有差异。常见做法是长度域只覆盖数据域,ETX和LRC不计入长度。你在写解析器的时候,不必预先假定长度覆盖范围,而是先把STX和ETX找到,用ETX做帧结束标志,再用LRC验证完整性,这是最稳的。

LRC校验算法则是将STX之后、ETX之前的字节全部做异或,得到一个单字节结果,再把这个结果转成大写十六进制ASCII两个字符放到ETX后面。这个校验很容易算错的地方是STX本身不参与,ETX也不参与,只算中间的部分。

曾经遇到一个现场问题:上位机收到的帧LRC偶尔校验不过,排查半天发现是配置软件里在帧尾多开了一个"追加CR LF"的选项,导致ETX之后多了两个字节0x0D 0x0A,解析器把CR也当成LRC的一部分去比对,自然对不上。这类和帧格式相关的坑,写TCP解析时都得提前考虑进去。

3. C#异步TCP客户端核心实现:连接、接收循环与粘包状态机

3.1 带超时控制的TCP连接与异步接收循环

SICK读卡器的TCP连接不需要什么特别的握手,上位机侧就是一个普通TcpClient。唯一的工程化要求是异步化,不要用同步阻塞读挂在UI线程里。借助NetworkStream的ReadAsync可以让接收循环变得非常干净。下面给出连接和主接收循环的骨架。

public class SickTcpReader { private TcpClient _tcpClient; private NetworkStream _stream; private CancellationTokenSource _cts; private readonly object _sendLock = new object(); public async Task ConnectAsync(string ip, int port, int timeoutSeconds = 3) { _tcpClient = new TcpClient(); using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); try { await _tcpClient.ConnectAsync(ip, port, timeoutCts.Token); _stream = _tcpClient.GetStream(); _cts = new CancellationTokenSource(); _ = Task.Run(() => ReceiveLoopAsync(_cts.Token)); } catch (OperationCanceledException) { throw new TimeoutException($"连接SICK读卡器超时: {ip}:{port}"); } } private async Task ReceiveLoopAsync(CancellationToken token) { var buffer = new byte[4096]; while (!token.IsCancellationRequested) { try { int count = await _stream.ReadAsync(buffer, token); if (count == 0) { // 对端主动关闭连接,触发重连 OnDisconnected(); break; } OnDataReceived(buffer, count); } catch (OperationCanceledException) { break; } catch (IOException) { OnDisconnected(); break; } catch (Exception ex) { OnDisconnected(); break; } } } }

ConnectAsync这里用CancellationTokenSource实现超时,比直接在外面套Task.WhenAny要简洁,3秒连不上直接抛TimeoutException。实际调试SICK读卡器时,连接超时大概率是IP没配对或者读卡器配置成了客户端模式没监听端口,遇到超时先检查这两个方向。

ReceiveLoopAsync是核心循环,ReadAsync每次最多读4096字节,网络流里没有消息边界,所以读出来的buffer可能包含半帧、一帧或多帧。count为0代表对端关了连接,此时要立刻通知上层释放资源并准备重连。OnDataReceived每次拿到原始字节数组,统一交给帧状态机处理,不在接收循环里做业务解析,避免处理慢的时候阻塞读取导致内核缓冲区溢出丢包。

3.2 帧状态机:从无边界字节流里切出完整帧

处理SICK读卡器的TCP推流,最忌讳的是按"收到一次数据就是一帧"来写。TCP是流协议,底层可能合并也可能拆分,必须自己维护一个字节缓冲区,并且用状态机切帧。切帧逻辑以STX和ETX为锚点,关键点在LRC校验时要回溯帧的完整边界。

public class FrameBuffer { private readonly List<byte> _buffer = new List<byte>(); public event Action<byte[]> FrameReady; public void Append(byte[] data, int count) { for (int i = 0; i < count; i++) { _buffer.Add(data[i]); } TryExtractFrames(); } private void TryExtractFrames() { while (true) { int start = _buffer.IndexOf(0x02); if (start < 0) { _buffer.Clear(); // 一段垃圾数据里连STX都没有,直接清空 return; } if (start > 0) { _buffer.RemoveRange(0, start); // 丢弃STX之前无意义字节 } int end = _buffer.IndexOf(0x03, 1); if (end < 0) { return; // 还没等够一帧,继续收 } // 到这里,缓冲区里至少有一帧完整的 [STX...ETX],但不含LRC if (_buffer.Count < end + 3) { return; // LRC两个字节还没到齐,再等等 } var frame = _buffer.GetRange(0, end + 3); // 取出STX到LRC结束 _buffer.RemoveRange(0, end + 3); FrameReady?.Invoke(frame.ToArray()); } } }

这个状态机每次追加数据后循环尝试切帧。Index找STX,再从STX后面找第一个ETX,找到后不会立即结算整帧,还要确认LRC两个字节也到了。LRC不到就继续等,不急着切。如果缓冲区前面出现乱字节,先清掉STX之前的部分,这样即使半路接入TCP流也能快速对齐帧边界。缓冲区无限增长是个隐患,实际要加个上限:如果缓冲区长度超过比如4096还没切出帧,强制清空重新对齐,避免异常数据撑爆内存。

SICK读卡器在自动上报模式下数据密度高,这个状态机配合事件向外抛完整帧即可。事件抛出的帧由下一层的LRC校验和EPC解析消费,后续把校验不过的帧交给日志统计,方便看链路质量。

4. 从原始帧到标签数据:LRC校验、EPC提取与格式化解析

4.1 LRC校验与脏帧容错策略

帧切出来之后,第一件事不是解析内容,是校验。LRC算法很简单,但很多人容易在"从哪个字节开始、到哪个字节结束"上翻车。SICK读卡器常见范围是STX之后、ETX之前,也就是STX和ETX本身的字节不参与异或。

public static bool VerifyLrc(byte[] frame) { if (frame.Length < 5) return false; byte xor = 0; for (int i = 1; i < frame.Length - 2; i++) { // 跳过STX(0),走到ETX之前(倒数第3个字节) if (frame[i] == 0x03) break; xor ^= frame[i]; } string lrcHex = Encoding.ASCII.GetString(frame, frame.Length - 2, 2); return lrcHex == xor.ToString("X2"); }

计算LRC时循环到frame.Length - 2,也就是把ETX身后的两个LRC字节排除在外。遇到ETX直接break也能起到相同效果,但是考虑到万一帧内容里出现0x03会提前中断,这里更稳妥的做法是不break,直接循环到ETX前一位。帧格式在设计时如果长度域已经声明了数据长度,完全可以用长度域来界定异或区间,就不怕内容里混入0x03了。之后的解析代码里还应该允许LRC校验失败但帧内容能读出EPC的情况,把"校验失败"和"EPC可读"分开统计,这样现场排查是天线干扰还是配置问题就有依据了。

校验不通过的帧直接丢是最省事的,但其实最好还是留一条日志。之前在某仓库项目中遇到过,连续读头几十帧都是好的,突然冒出一帧LRC错,再往后又正常了。这种单帧错大概率是RF信号碰撞导致的位翻转,不是设备坏了,重试机制也没必要做,统计计数即可。

4.2 提取EPC编号:从ASCII的Hex串里还原标签编码

SICK读卡器上报的EPC数据在帧的数据域里以ASCII十六进制字符串形式存在,比如标签EPC实际字节是E2 80 69 40 00 00 40 26,在帧里就会变成"E280694000004026"这样的可打印字符。提取时先定位数据区起始位置,再从里面找连续的子串,转换到二进制字节数组。

public static string ExtractEpcFromFrame(byte[] frame) { string payload = Encoding.ASCII.GetString(frame); int start = payload.IndexOf("02"); int end = payload.IndexOf("03"); if (start < 0 || end < 0 || end < start) return null; string body = payload.Substring(start + 2, end - start - 2); // 过滤掉空格/制表符等分隔符 string hexOnly = Regex.Replace(body, @"[^0-9A-Fa-f]", ""); if (hexOnly.Length < 4) return null; // 常见长度:12字节/24个hex字符,但也有16字节/32个hex字符 int byteLen = hexOnly.Length / 2; byte[] epc = new byte[byteLen]; for (int i = 0; i < byteLen; i++) { epc[i] = Convert.ToByte(hexOnly.Substring(i * 2, 2), 16); } return BitConverter.ToString(epc).Replace("-", ""); }

这个提取逻辑的关键是把数据域里的ASCII字符串整体先转出来,然后用正则剥离可能出现的空格、横杠、逗号等分隔符。SICK读卡器在配置软件里可以设置数据域的输出格式,有的版本会默认在EPC中间插入空格方便人眼阅读,这时候不做正则过滤就会提取出一串带空格的错误EPC。

我在某跨平台系统的项目里就踩过这个坑:读卡器输出的是"E2 80 69 40 00 00 40 26 10 C9 3A 01",每个两个字符之间带空格,第一版解析直接按字符串内容拼起来,结果EPC前后多了四个空格字符,WMS拿这个EPC去数据库比对永远匹配不上。后来改成统一走"去除非十六进制字符"这步,问题一次性解决。

还有一点,EPC转成字符串后尽量统一用大写。某些标签的EPC里有字母a到f,如果数据库里存的是大写而程序输出小写,就会产生一批看不见的重复数据。Convert.ToString的X2格式天然产生大写十六进制,反序列化后不要小写化。

5. SICK RFID开发中的常见问题与排查记录

5.1 连接超时:Ping得通但TCP连不上

现象:上位机Ping读卡器IP能通,但TcpClient.ConnectAsync就是3秒超时抛异常。

原因:读卡器虽然在线,但它没有开启TCP服务端口。SICK读卡器的以太网口默认可能有多个服务并行,比如网页配置端口和TCP数据端口是分开的,两者不是同一个监听。设备固件里如果部署了自动上报但没有勾选TCP输出选项,TCP端口就不会监听。

解决:把设备管理页面打开,找到以太网设置或数据输出协议配置,确认TCP输出被启用,并记录实际端口号。改完配置后建议直接断电重启读卡器,不要只做软重启,有些固件版本软重启不会重新绑定端口。改完验证方法是在电脑上用Telnet连接那个端口,连得上再进上位机调试。

5.2 帧内容全是乱码:数据域字节对不上EPC长度

现象:能收到帧,LRC也校验通过,但解析出来的EPC位数不是预期值,有些还是非十六进制字符。

原因:读卡器输出编码配置里数据域可能被设置成ASCII文本模式而不是纯Hex模式。有的设备支持"ASCII可见字符"和"二进制"两种输出格式,如果选成ASCII文本模式,EPC会被编码成人的可打印字符串,甚至可能包含小写字母和特殊字符。

解决:回到配置软件里把数据输出格式改成HEX或二进制流模式,让每条EPC以规整的十六进制ASCII出现。同时把帧里的长度域与期望EPC字节长度对照,如果长度域数值正好是EPC字节数的两倍再加固定头尾,那一定是HEX ASCII模式。

5.3 高频读取丢帧:标签经过天线时读卡器明明闪灯但TCP没数据

现象:标签从天线旁快速滑过时,读卡器面板指示灯闪烁表示读到了,但上位机的一条完整帧都没收到。

原因:读卡器内部缓冲和TCP堆栈之间丢数据。SICK这类设备在快速触发时会先把标签写入内部缓冲区,再通过网络上传,如果上位机接收速度跟不上,缓冲溢出就会丢帧。另一个常见原因是前端切帧状态机把半截帧清掉了,比如标签经过时TCP一次只推了STX和半个长度域,后面一个包隔了很久才到,缓冲区超时清空就把这半帧丢了。

解决:接收循环里加大缓冲区到8192,同时把帧状态机里的清空条件改成"超过200ms没有ETX再做超时清空"而不是收到半帧就丢,可用一个计时器扫描缓冲区最后写入时间。上位机进程里还要保证接收线程优先级不被GUI操作拖累,使用专用后台线程跑接收循环。

5.4 校验偶尔失败:帧尾多了回车换行

现象:某条产线上的读卡器帧校验偶尔失败,失败频率大概5%上下,但重读成功率100%。

原因:配置软件里帧尾追加了CR LF,也就是0x0D 0x0A。网络调试助手里看不到,但C#字节数组里实打实能数到。LRC校验是按固定位置取的,CR LF插进来之后,帧尾的LRC位置就错位了。

解决:把配置软件里"帧尾追加CR LF"或"帧尾追加换行"的选项关掉,或者解析时容忍性地从帧尾向前找两个ASCII十六进制字符做LRC比对,忽略最后可能出现的0x0D/0x0A。更通用一点的做法是解析前先做一次尾部Trim,把0x0D/0x0A全部去掉。

5.5 断线后无法自动恢复

现象:读卡器重启或网线瞬时松动后,C#上位机再也没有收到数据,日志里什么都没打。

原因:接收循环抛异常后直接退出,但没有任何重连逻辑。TcpClient在底层连接断开后不会自动恢复,必须重新ConnectAsync。

解决:在OnDisconnected里放入重连循环,指数退避,每2秒尝试一次重连,重连成功后重新初始化缓冲区并发一条查询命令验证链路可用。重连期间不能阻塞业务逻辑,把重连逻辑也放进独立任务里。后面第6章会给出一种结合心跳检测的自动重连设计。

6. 进阶技巧:多标签持续上报时如何做去重与链路保活

6.1 标签去重:时间窗口字典代替一次性清空

自动上报模式下,只要标签停留在天线场区,读卡器会以一定的刷新率反复上报同一个EPC,这意味着上位机会收到大量重复标签。如果直接用Dictionary永远不清理,内存会缓慢增长。常见做法是维护一个"最近N秒出现的标签集合",超过时间窗口没有再次出现的标签才允许移除。

public class EpcDeduplicator { private readonly TimeSpan _window = TimeSpan.FromMilliseconds(1500); private readonly Dictionary<string, DateTime> _lastSeen = new Dictionary<string, DateTime>(); public bool IsDuplicate(string epc) { bool duplicate = false; var now = DateTime.UtcNow; if (_lastSeen.TryGetValue(epc, out var last)) { duplicate = (now - last) < _window; } _lastSeen[epc] = now; return duplicate; } public void Cleanup() { var cutoff = DateTime.UtcNow - _window; foreach (var kvp in _lastSeen.Where(x => x.Value < cutoff).ToList()) { _lastSeen.Remove(kvp.Key); } } }

去重窗口的大小取决于读卡器上报间隔。如果设备设置200ms上报一次,一个标签在读写器场区内停留5秒,窗口1.5秒足够过滤到"同一张卡只上抛一次到业务层"。Cleanup方法在定时器里每分钟调用一次,避免字典无限膨胀。这个去重只挡TCP上层的重复上报,不影响RFID底层识别的灵敏度。

6.2 链路保活:把心跳查询当成连接健康探针

SICK读卡器在自动上报模式下如果场区长时间没有标签,TCP连接上会非常安静,没有任何数据流动。这时候如果链路中间被交换机静默切断,上位机可能一直以为连接是好的。给接收循环加一个N秒无数据自动重连的兜底是最省事的方案。也可以在上位机里定时发送一条查询命令,有正常数据或明确错误码返回就认为链路活着。

我一般会在接收循环里维护一个LastDataTime,每次收到数据更新它。一个独立心跳任务每隔5秒检查一次,如果LastDataTime超过10秒没有更新,就主动Close掉TcpClient并触发重连。这个做法在两次标签交接的空档能快速发现假死连接。从那以后我每次对接这类推流式网口设备,都会强制走一遍"数据保活重连"的设计,而不是等现场报障再补。这套框架调完基本可以直接部署到类似SICK RFID读卡器的TCP设备上,希望帮到你。

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

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

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

立即咨询