☰
C# RFID读写方案:从串口通信到稳定读写的工程实践
2026/10/28 8:09:48 网站建设 项目流程

简介:这份资源面向具备一定C#基础、希望入门RFID硬件交互的开发者,围绕“基于C#的RFID卡识别和读写”这一主题,提供一套可运行的桌面示例工程,帮助理解串口通信、事件驱动编程与标签数据解析的完整链路,适用于门禁、物流跟踪、商品防伪等场景的原型验证。压缩包共29个文件,约66KB,以6个cs源码文件为核心,配合sln解决方案、csproj项目文件、resx资源与settings配置,另有exe、pdb等编译产物及缓存日志文件,结构紧凑,便于直接打开调试。目前已有4662人学习下载。工程将设备连接、事件监听、数据解析与读写操作串联起来,读者可据此掌握SerialPort参数配置、标签ID与状态信息处理及通信异常的错误处理思路,并参考其分层封装方式,把硬件交互部分抽离为独立类库,为后续更换阅读器或扩展业务功能留出空间。

1. 从一张“读不出来”的卡说起:这套 C# RFID 读写方案到底解决什么

接手一个 C# 上位机项目,最容易被低估的模块就是 RFID 读写。表面看只是“连上读卡器、读一张卡、写一段数据”,真做起来才发现坑全在细节里:串口一断程序就卡死、同一张卡反复触发导致业务重复入库、写块数据时校验位算错把卡写废、不同厂商读卡器的协议帧格式还不一样。这套基于 C# 的 RFID 卡识别与读写方案,要解决的就是把“识别”和“读写”这两件事做成一个稳定、可复用、能直接嵌进上位机业务里的模块,而不是一段跑一次就扔的 Demo 代码。它适合正在做课程设计、毕业设计里带硬件交互部分的学生,也适合需要给产线、门禁、仓储类上位机补一套读写能力的开发者。下面按需求、架构、实现、避坑、验收的顺序,把整套东西拆开讲清楚。

2. 需求与选型:先定清楚读什么卡、走什么口、写多少数据

2.1 把模糊需求翻译成可验收的功能点

很多人一上来就写代码,结果做到一半发现需求根本没定。RFID 这个场景,需求至少要落到四个维度上:卡类型(低频 125kHz 的 ID 卡、高频 13.56MHz 的 IC 卡/Mifare 卡,两者协议完全不同)、通信接口(串口 RS232/RS485、USB 转串口、网口)、读写范围(只读卡号,还是要读写扇区数据块)、业务动作(识别后是开门、登记、扣费还是写标签)。我一般会先让需求方把这几项填成一张表,填不满的地方就是后面要翻车的地方。

需求维度常见取值对实现的影响
卡类型ID 卡 / IC 卡(Mifare Classic)决定是否需要密钥认证、扇区读写
通信接口串口 / USB 虚拟串口 / 网口决定用 SerialPort 还是 Socket
读写范围仅卡号 / 指定扇区数据块决定帧协议复杂度与校验方式
业务动作识别、登记、写标签、扣费决定是否需要事务与去重

这张表不是走形式,它直接决定后面协议解析写多深。只读卡号的项目,帧解析往往就是固定头尾加卡号;要读写扇区的项目,就得处理密钥认证、块地址、读写命令和返回状态码,工作量差好几倍。

2.2 为什么选 C# 而不是别的

选 C# 不是因为它“高级”,而是这个场景下它刚好合适。上位机大多跑在 Windows 上,C# 的System.IO.Ports.SerialPort对串口的封装足够直接,WinForm/WPF 做界面快,业务层和界面层分离也容易。更关键的是,读卡器厂商给的 SDK 或协议文档,绝大多数都能用 C# 调,串口通信、字节数组处理、定时器轮询这些基础能力开箱即用。如果项目本身已经是 C# 上位机,硬塞一个别的语言写的读写服务,反而要多维护一层进程通信,得不偿失。

技术栈上,核心就三块:SerialPort负责物理通信,自定义的帧协议解析类负责把字节流翻译成业务对象,一个读写服务类对外暴露ReadCard()、WriteBlock()这类方法。界面层只调服务层,不碰串口,这样以后换读卡器只改协议层,业务代码不动。

2.3 通信协议先摸清楚再动手

不同厂商的读卡器帧格式不一样,但套路类似:帧头 + 长度 + 命令字 + 数据 + 校验 + 帧尾。动手前必须拿到协议文档,把“读卡号命令”“读块命令”“写块命令”的请求帧和应答帧各抄一遍,标出哪些字节是地址、哪些是数据、校验怎么算。这一步偷懒,后面调试就是纯玄学。通常的做法是先用厂商自带的调试工具发一条命令,用串口助手抓下原始字节,再对照文档逐字节核对,确认自己理解没跑偏,再开始写解析代码。

3. 总体架构设计:三层结构与四个核心模块的职责划分

3.1 分层:界面、服务、通信各管一摊

整套东西我一般拆成三层。界面层(WinForm/WPF)只负责展示卡号、触发读写按钮、显示结果,不写任何串口逻辑。服务层是核心,包含RfidService,对外提供识别、读块、写块、连接、断开这些方法,内部负责命令组装、超时控制、结果校验。通信层封装SerialPort,只管收发字节,不关心字节是什么意思。这样分层的好处是,读卡器换型号时,通信层和服务层的协议部分改,界面层一行不动;测试时也可以用一个模拟的通信层注入假数据,不用真接硬件。

3.2 四个核心模块的职责

  • 连接管理模块:负责打开/关闭串口,设置波特率、数据位、停止位、校验位,处理串口异常断开后的重连。
  • 帧协议模块:负责请求帧组装和应答帧解析,包含校验计算、帧头帧尾识别、粘包拆包。
  • 读写业务模块:把“读卡号”“读块”“写块”翻译成具体命令,处理密钥认证(IC 卡场景)、块地址、数据长度。
  • 事件与去重模块:识别到卡后触发事件,同时做短时间内的重复卡过滤,避免同一张卡在感应区停留时被反复处理。

3.3 数据流走一遍

一次完整的识别流程是这样的:界面点“开始识别” → 服务层启动轮询定时器(比如每 200ms 发一次读卡号命令)→ 通信层把命令帧写进串口 → 读卡器返回应答帧 → 通信层收到字节触发DataReceived→ 帧协议模块从缓冲区里拆出完整帧 → 校验通过后解析出卡号 → 服务层判断是否重复卡 → 不重复则触发CardDetected事件 → 界面更新显示。写块流程类似,只是命令换成写块命令,并且要等读卡器返回“写成功”状态码才算完成,不能发出去就当成功。

提示:轮询间隔不是越小越好。间隔太小会把串口缓冲区塞满,反而导致粘包和丢帧;间隔太大识别延迟明显。通常 150~300ms 之间比较稳,具体看读卡器响应速度。

4. 关键模块实现:串口通信、帧解析与读写逻辑的代码落地

4.1 串口连接与异常重连

串口这块最容易出的问题是“打开成功但收不到数据”和“拔插后程序卡死”。前者多半是波特率或校验位和读卡器不一致,后者是没有处理SerialPort的异常。下面这段是连接管理的骨架,重点看异常处理和重连标记。

public class SerialConnection { private SerialPort _port; private readonly object _lock = new object(); public bool IsConnected => _port != null && _port.IsOpen; // 打开串口,参数与读卡器说明书保持一致 public void Open(string portName, int baudRate = 9600) { lock (_lock) { if (IsConnected) return; _port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One) { ReadTimeout = 500, WriteTimeout = 500 }; _port.DataReceived += OnDataReceived; _port.ErrorReceived += OnErrorReceived; // 串口硬件错误要单独接 _port.Open(); } } private void OnErrorReceived(object sender, SerialErrorReceivedEventArgs e) { // 记录错误类型,标记需要重连,交给上层定时器处理 ConnectionFaulted?.Invoke(this, e.EventType); } public event EventHandler<SerialError> ConnectionFaulted; public event EventHandler<byte[]> DataReceived; private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { var buffer = new byte[_port.BytesToRead]; _port.Read(buffer, 0, buffer.Length); DataReceived?.Invoke(this, buffer); // 只负责搬运字节,不解析 } }

这段代码的关键点有三个:一是DataReceived只把原始字节抛出去,解析交给上层,职责不混;二是单独接了ErrorReceived,串口硬件错误(比如帧错误、溢出)不会静默吞掉;三是所有打开操作加锁,避免界面线程和后台线程同时调Open导致状态错乱。参数上,ReadTimeout和WriteTimeout设 500ms 是经验值,太短容易误判超时,太长会让界面卡顿感明显。

4.2 帧协议解析:粘包拆包与校验

串口是字节流,一次DataReceived可能收到半帧,也可能收到两帧粘在一起。正确做法是维护一个接收缓冲区,按帧头帧尾找完整帧,校验通过才交给业务层。下面是一个简化版的解析器,假设协议是0x02 + 长度 + 命令 + 数据 + 校验 + 0x03。

public class FrameParser { private readonly List<byte> _buffer = new List<byte>(); // 追加新收到的字节,返回本次能解析出的完整帧 public List<byte[]> Append(byte[] incoming) { _buffer.AddRange(incoming); var frames = new List<byte[]>(); while (true) { int start = _buffer.IndexOf(0x02); int end = _buffer.IndexOf(0x03); if (start < 0 || end < 0 || end <= start) break; // 帧不完整,等下次 int len = end - start + 1; var frame = _buffer.GetRange(start, len).ToArray(); _buffer.RemoveRange(0, end + 1); if (CheckSum(frame)) frames.Add(frame); // 校验不过直接丢弃 } return frames; } // 校验:除帧头帧尾和校验位外逐字节异或,具体算法以协议文档为准 private bool CheckSum(byte[] frame) { byte sum = 0; for (int i = 1; i < frame.Length - 2; i++) sum ^= frame[i]; return sum == frame[frame.Length - 2]; } }

这里有两个设计说明。第一,缓冲区用List<byte>而不是固定数组,是因为帧长不固定,动态追加更省事;第二,校验失败直接丢弃而不是抛异常,因为串口噪声导致的坏帧是常态,抛异常会把整个轮询打断。参数上,帧头帧尾的取值、校验算法必须严格按协议文档来,不同厂商可能用累加和、异或或者 CRC,抄错一个字节就是“读得到但解析不出”的经典翻车现场。

4.3 读卡号与写块的业务封装

业务层把命令组装和结果判断包起来,对外只暴露干净的方法。读卡号相对简单,写块要处理密钥认证和写成功确认。

public class RfidService { private readonly SerialConnection _conn; private readonly FrameParser _parser = new FrameParser(); public RfidService(SerialConnection conn) { _conn = conn; _conn.DataReceived += (s, bytes) => { foreach (var frame in _parser.Append(bytes)) HandleFrame(frame); // 解析出的完整帧交给统一处理 }; } // 读卡号:组装读卡命令并等待应答,超时返回 null public string ReadCardId(int timeoutMs = 800) { var cmd = BuildCommand(0x20, Array.Empty<byte>()); // 0x20 为读卡号命令字 var tcs = new TaskCompletionSource<string>(); PendingRead = tcs; _conn.Write(cmd); if (tcs.Task.Wait(timeoutMs)) return tcs.Task.Result; PendingRead = null; return null; // 超时视为无卡 } // 写块:先认证再写,返回是否成功 public bool WriteBlock(byte blockAddr, byte[] data) { if (data.Length != 16) throw new ArgumentException("块数据必须 16 字节"); var auth = BuildCommand(0x41, new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }); // 默认密钥 if (!SendAndCheck(auth)) return false; var write = BuildCommand(0x42, new[] { blockAddr }.Concat(data).ToArray()); return SendAndCheck(write); } }

读卡号用TaskCompletionSource配合超时等待,是为了把异步的串口应答转成同步调用,界面层写起来简单;超时返回null而不是抛异常,是因为“没卡”是正常状态,不该当错误处理。写块方法里先认证再写,认证失败直接返回,避免对未认证的卡发写命令。参数上,块数据固定 16 字节是 Mifare Classic 的块大小,密钥默认值FF FF FF FF FF FF是出厂常见值,实际项目要改成业务密钥,并且密钥不能硬编码在界面层。

注意:写块前一定要确认块地址没选错。Mifare 卡每个扇区的第 3 块是控制块,写错会把卡锁死,这是血泪经验里最常见的一种“把卡写废”。

5. 避坑与排查:五条最容易翻车的点

5.1 现象:程序能连上串口,但一直读不到卡

原因通常是波特率、校验位和读卡器不一致,或者读卡器处于“主动上传”模式而程序在“轮询”模式,两边节奏对不上。解决方法是先用厂商调试工具确认能读到卡,记下它的串口参数和工作模式,再对照改代码;如果读卡器是主动上传模式,程序就不该定时发读卡命令,而应改成被动接收并解析。

5.2 现象:同一张卡被连续处理多次

原因是卡在感应区停留时,轮询每次都能读到,业务层没做去重。解决办法是在服务层记录“上次卡号 + 上次时间”,同一卡号在 1~2 秒内不重复触发事件。这个时间窗口要可配置,太短去重不彻底,太长会影响连续刷不同卡的体验。

5.3 现象:写块返回成功,但读回来数据不对

原因多半是块地址算错或者数据字节序反了。Mifare 的扇区、块地址换算容易差一位,写之前先用读块命令把原数据读出来核对地址;字节序则要对照协议文档确认是高位在前还是低位在前。我一般会在写之前强制读一次目标块,把原值打日志,写完再读一次比对,两次都对才算成功。

5.4 现象:串口拔插后程序无响应或崩溃

原因是SerialPort在底层句柄失效后,Close或Read可能抛异常甚至卡住。解决办法是把串口操作全部放到后台线程,DataReceived里不做耗时操作;监听ErrorReceived和连接状态,发现异常就关闭并重建SerialPort实例,而不是复用旧对象。重连逻辑要加退避,避免疯狂重试把 CPU 打满。

5.5 现象:界面在读写时卡顿

原因是把串口等待放在了 UI 线程。ReadCardId里的Wait如果直接在按钮事件里调,界面就会假死。解决办法是读写操作统一走Task.Run或后台线程,结果通过事件或回调回传 UI 线程更新,UI 线程只做展示。

6. 验收与答辩:怎么证明这套读写真的稳

验收这套东西,不能只看“能读出一张卡”。我一般按四个层次走。第一层是功能验收:准备 ID 卡和 IC 卡各若干张,验证读卡号、读块、写块、写后回读比对,每项至少重复 20 次,记录成功率。第二层是异常验收:运行中拔串口、换一张未认证的卡、写一个非法块地址,看程序是否给出明确提示而不是崩溃或静默。第三层是并发与去重验收:同一张卡在感应区停留 5 秒,确认业务只触发一次;连续刷 10 张不同卡,确认每张都能正确识别。第四层是长时间稳定性:连续跑 2 小时轮询,观察内存是否持续增长、串口是否出现假死。

验收项方法合格标准
读卡号20 张卡各读 20 次成功率 ≥ 99%
写块回读写 10 个块后逐个回读数据完全一致
异常拔插运行中拔串口再插回程序不崩溃,能重连
重复卡去重单卡停留 5 秒业务只触发 1 次
长稳连续 2 小时无内存泄漏、无假死

答辩时最容易被问的是“你怎么保证写进去的数据是对的”和“串口断了怎么办”。前一个问题用“写前读原值、写后回读比对”的日志回答,后一个问题用重连状态机和异常验收记录回答,比空口说“我做了异常处理”有说服力得多。还有个小技巧:把每次读写的原始帧用十六进制打到日志里,答辩现场演示一次坏帧被丢弃的过程,比讲十页 PPT 都直观。

从那以后我每次做带硬件的上位机项目,都强制先把协议帧抓一遍、把异常场景列成验收表再动手写业务,这个习惯帮我省掉了大量返工。希望这套结构能帮到你,需要完整工程和协议解析代码的,可以按项目说明获取后对照着改。

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

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

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

立即咨询