简介:基于C#的RFID卡识别与读写项目源码,面向需要掌握RFID非接触式自动识别技术的C#开发者,可应用于门禁系统、物流跟踪和商品防伪等场景。压缩包共29个文件,以6个C#源文件(包含主程序、窗体逻辑与设计器代码)为核心,附有3个exe可执行程序便于直接运行,另有解决方案sln、窗体资源resx、调试符号pdb以及编译中间文件等,整体仅66KB,属于轻量级示例工程。已有4660人学习下载,说明该示例在RFID入门学习中具有一定热度。项目完整演示了通过SerialPort类建立串行连接、监听RFID标签事件、按EPC协议解析返回数据、执行读写操作并处理通信错误的全过程,同时包含Windows窗体界面交互逻辑,可据此扩展为完整的业务模块。对于希望快速上手C#与硬件交互、梳理串口通信与数据解析流程的开发者,是一份可直接对照学习的实用参考代码。
1. RFID卡识别读写:从串口联调到业务落地的一次完整实战
RFID 低频、高频、超高频的差异先不提,单说高频 13.56MHz 这一条线,C# 开发者遇到最多的就是 M1 卡(S50)和读卡器模块的串口指令交互。某公司内部有个工位巡检的模拟项目X,原先人工扫码一天能错七八条记录,后来换成 RFID 刷卡 + C# 上位机,读写一张卡从按下到返回不超过 800ms,差错清零。这条链路里最磨人的不是 UI 也不是数据库,而是串口通讯里那些防不胜防的字节序、校验位和卡状态机问题。这篇笔记就用一个可运行的 C# 例程讲透高频 RFID 卡的识别、读取、写入与数据联动,适合正在做门禁、巡检、资产盘点或者食堂消费机的开发者抄作业。
读卡选型上,桌面式 USB 读卡器和嵌入式串口模块是两条主流路线。前者即插即用,SDK 自带动态库,适合快速出原型;后者通过串口发送 16 进制指令控制,适合嵌入到已有设备。这里展开的是国产 RC522 串口模块 + C# SerialPort 类,省略封装库,直接拿字节数组做指令拼接,能让新手看清指令结构和返回帧的含义,也让熟手知道自定义协议时要注意哪些坑。
2. 读卡器选型与串口工装:为什么先定字节协议再写代码
2.1 RC522 串口模块的通讯边界
RC522 是 NXP 系的读卡芯片,工作在 13.56MHz,支持 ISO14443A。市场上有两种形态:一种是纯 SPI/I2C 接口的芯片,需要自己接单片机;另一种是已打包好的 UART 串口透传模块,模块内部用单片机把 RC522 的寄存器操作封装成串口指令。C# 开发者只需要选后者,因为不需要关心射频天线匹配和寄存器时序,只需要处理串口收发。这类模块常见的波特率是 9600、19200、38400、115200,默认大多数是 9600 或 115200,拿到模块后第一件事就是确认波特率表。
模块和 PC 连接的物理链路通常是 USB 转 TTL,驱动装好后会在设备管理器里出现一个 COM 口。这里有一个常见的选型误区:不要买那种模块上不带电压转换的纯 TTL 版本,因为部分电脑主板串口电平是 RS232 标准,直接接 TTL 模块会烧芯片。用 USB 转 TTL 小板时也注意接线顺序:发送端接接收端,接收端接发送端,地线必须共地。
// 串口初始化示例,Visual Studio 2022 + .NET Framework 4.8 SerialPort sp = new SerialPort(); sp.PortName = "COM3"; sp.BaudRate = 115200; sp.DataBits = 8; sp.Parity = Parity.None; sp.StopBits = StopBits.One; sp.ReadTimeout = 1000; sp.WriteTimeout = 1000; sp.Open();代码逻辑说明:SerialPort 实例化后逐个设置串口参数,DataBits 8、无校验、1 停止位是绝大多数 RFID 模块的默认格式。ReadTimeout 和 WriteTimeout 必须设置,否则程序卡在串口读取时永远不会返回。模块手册如果没说校验位,就按 None 处理,这是最安全的起点。
参数说明:BaudRate 要跟模块的拨码开关或 AT 指令配置一致,新模块默认 115200 的在室内短距离测试时完全够用;如果后续要走长线或者接 PLC 周边设备,降到 19200 以下能减少误码。端口名不固定,需要枚举 SerialPort.GetPortNames() 动态选择,线上工控机换个 USB 口 COM 号就变,写死 COM3 是新手最容易翻车的行为。
2.2 指令协议的帧格式设计
串口 RFID 模块的指令帧通常分几个区域:命令字、数据长度、数据域、校验和。虽然每家厂商的指令集有差异,但绝大多数逃不出「长度 + 命令 + 参数 + 校验」的结构。举例来说,寻卡指令的请求帧可能是 AA 00 03 01 00 00,其中 AA 是帧头,00 03 是数据总长度,01 是寻卡命令,后面跟的是寻卡模式参数。
接收帧的分析要更小心。模块返回的寻卡结果帧往往包含卡片序列号,比如 4 字节 UID。不同模块对 UID 字节序的处理不一样,有的直接返回低位在前,有的返回高位在前,C# 程序里必须依据手册确认,否则显示出的卡号和卡片背面印刷不一致,排查半天也找不到原因。
// 拼接 CRC/校验字节的通用方法 public byte CalculateLRC(byte[] data) { byte lrc = 0x00; for (int i = 0; i < data.Length; i++) { lrc ^= data[i]; } return lrc; } // 发送寻卡命令 byte[] command = new byte[] { 0xAA, 0x00, 0x03, 0x01, 0x00 }; byte lrc = CalculateLRC(command); byte[] fullFrame = command.Concat(new byte[] { lrc }).ToArray(); sp.Write(fullFrame, 0, fullFrame.Length);逻辑说明:CalculateLRC 方法对整帧做异或校验,返回的校验字节拼在帧尾。异或校验比累加和校验在硬件实现上更常见,也更容易排查。寻卡命令的 0x00 参数代表只寻一张卡,实际场景中如果卡片放在天线范围内多张同时存在,模块可能返回错误或者只返回一张,这取决于模块的防碰撞策略。
参数说明:数据长度字段是「命令 + 参数」的总长度,不包含帧头和校验字节。有的模块手册里写的是总长度包含校验,写代码之前必须针对具体模块做一次验证,拿一个已知返回值的命令比对,不然数据长度计算错了会导致模块毫无响应。
2.3 读卡器连接自检流程
刷了半天没反应,最大的概率不是代码问题,而是物理链路问题。破解思路是把整个链路分层排查:串口号是否存在、串口能否打开、模块是否上电、天线是否在工作、指令是否发出去、返回帧是否乱码。每层都有各自的诊断方法。
// 枚举可用串口,并依次发送软复位指令检测应答 string[] ports = SerialPort.GetPortNames(); foreach (string port in ports) { try { SerialPort testPort = new SerialPort(port, 115200); testPort.Open(); testPort.Write(new byte[] { 0xAA, 0x00, 0x01, 0x00, 0xAB }, 0, 5); System.Threading.Thread.Sleep(100); byte[] response = new byte[testPort.BytesToRead]; testPort.Read(response, 0, response.Length); if (response.Length > 0) { Console.WriteLine(port + " 有响应"); } testPort.Close(); } catch (Exception ex) { Console.WriteLine(port + " 打开失败: " + ex.Message); } }逻辑说明:这段代码把系统里所有 COM 口遍历一遍,逐个尝试打开,发送软复位指令后读取返回字节。有应答的端口基本可以判定是 RFID 模块,但注意这里不能保证指令正确,只能确认链路通。有的 USB 转串口芯片(比如常见的 CH340、CP2102)在没有设备连接时也能打开 COM 口,所以仅凭 Open 成功不能说明模块存在,必须有返回数据。
参数说明:Sleep 100ms 是为了等模块内部处理和串口缓冲填充,时间太短可能只读到半包数据。BytesToRead 是串口接收缓冲区当前的字节数,有的模块上电后会主动上报版本号,所以这里 Read 出来的可能不全是复位应答。这个自检脚本通常保留在工程里,每次现场部署时先跑一遍,能省掉一半的排障时间。
3. M1 卡的读卡流程:寻卡、防碰撞、选卡、认证的完整链路
3.1 M1 卡的分区结构与密钥体系
M1 卡(S50)有 16 个扇区,每个扇区有 4 个块。扇区 0 的块 0 固化了厂商信息和 UID,出厂只读,用户能改的是从块 1 开始的内容。每个扇区的块 3 是控制块,存放该扇区的 KeyA、KeyB 和访问控制字节,俗称密码块。这一层结构决定了写卡之前必须搞清楚:读写哪个扇区、用哪个密钥、控制位允不允许当前操作。
默认出厂密钥是 FFFFFFFFFFFF,KeyA 和 KeyB 一样。大量门禁系统上线后不修改默认密钥,带来的风险是拿一张白卡配合任意读卡器就能把扇区数据全部读出来。防水防尘不重要,密码才是第一道防线。换密时有一个必须留意的点:修改 KeyA 本身需要先通过 KeyA 认证,但是控制位和 KeyB 的修改权限受当前控制块约束,一旦把控制位改成「KeyB 不可读」,后续再想找回密码只能整卡报废。
// 认证一个扇区的封装 public bool Authenticate(int sector, byte[] key, bool useKeyA) { byte sectorKey = (byte)(sector * 4 + 3); // 每个扇区的密码块索引 byte[] cmd = new byte[] { 0xAA, 0x00, 0x09, 0x02, // 认证命令 sectorKey, // 块地址 (byte)(useKeyA ? 0x00 : 0x01), // 密钥类型 key[0], key[1], key[2], key[3], key[4], key[5], // 6字节密钥 0x00 // 校验占位 }; cmd[cmd.Length - 1] = CalculateLRC(cmd.Take(cmd.Length - 1).ToArray()); sp.Write(cmd, 0, cmd.Length); byte[] resp = ReadResponse(); return resp != null && resp[2] == 0x00; // 状态码0x00代表成功 }逻辑说明:Authenticate 方法传入扇区号、6 字节密钥和密钥类型。扇区号换算为密码块地址是扇区号乘以 4 再加 3,这个换算关系对应 M1 卡的物理块分布。模块的认证指令内部会执行 ISO14443A 的三重认证流程,C# 侧看不到认证细节,只需要等待返回帧的状态码。返回帧第三字节为 0x00 表示认证通过。
参数说明:useKeyA 参数设置为 true 时表示用 KeyA 做认证,false 用 KeyB。控制位如果限制 KeyA 不可读但可写,认证依然能通过,因为认证用的是密钥本身,不需要读出来。这意味着控制位设计不合理时,即使持有 KeyB 也无法读 KeyA,只能按权限表操作数据块。
3.2 寻卡与防碰撞机制解析
M1 卡的寻卡操作分两步:先发送 REQA 命令让天线场内的卡片应答,再执行防碰撞循环拿到完整的 4 字节 UID。串口模块一般把这两步封装成一条寻卡指令,返回的 UID 每个字节都是 BCD 码或十六进制码,按模块手册转换显示格式。
防碰撞(Anti-collision)解决的是多张卡同时进入射频场时的冲突问题。M1 卡内部的防碰撞机制用逐位比较的方式筛选出单张卡,但前提是模块支持启动防碰撞循环。RC522 模块的寻卡命令一般返回单张卡的 UID,如果返回错误码,通常是因为场内有多张卡同时应答,此时需要先把其他卡拿开或者让读卡距离小于最小隔离距离。
public string SearchCard() { byte[] cmd = new byte[] { 0xAA, 0x00, 0x03, 0x01, 0x00 }; cmd = AppendLrc(cmd); sp.Write(cmd, 0, cmd.Length); byte[] resp = ReadResponse(); if (resp == null || resp.Length < 8) { return null; } // 假定返回帧中UID从索引4开始,长度4字节 byte[] uid = new byte[4]; Array.Copy(resp, 4, uid, 0, 4); Array.Reverse(uid); // 部分模块返回LSB first,需反转 return BitConverter.ToString(uid).Replace("-", ""); }逻辑说明:SearchCard 方法发送寻卡指令后,从应答帧的偏移位置提取 UID 字节数组。Array.Reverse 用来调整字节序,因为某些模块返回的 UID 是反序的。实际项目里能不能看到正确的卡号,取决于这一步有没有跟手册对照。
参数说明:Resonse 帧的长度不同模块返回的不一样,有的带扇区数据,有的只有 UID。返回的 UID 长度必须严格校验,CRC 校验不过的帧要直接丢弃。卡号显示格式上,有的系统用 10 进制显示,有的用 16 进制,建议统一用 16 进制大写字符串存储,数据库里可以配一个转换函数适应不同前端展示需求。
3.3 读数据块与字节序处理
M1 卡每个数据块有 16 字节。read 命令需要指定块地址,认证通过后可以连续读同一扇区内的 4 个块。读出来的是原始字节数组,具体字段含义由业务层定义。比如巡检项目里块 0 存工号,块 1 存时间戳,块 2 存累计次数。
public byte[] ReadBlock(byte blockAddress) { byte[] cmd = new byte[] { 0xAA, 0x00, 0x04, 0x03, blockAddress, 0x00 }; cmd[cmd.Length - 1] = CalculateLRC(cmd.Take(cmd.Length - 1).ToArray()); sp.Write(cmd, 0, cmd.Length); byte[] resp = ReadResponse(); if (resp == null || resp.Length < 18) { throw new Exception("读块失败,响应长度不足"); } byte[] data = new byte[16]; Array.Copy(resp, 4, data, 0, 16); return data; }逻辑说明:ReadBlock 拼装读块指令,块地址是扇区内偏移(0 到 3)。返回帧里 16 字节数据在固定偏移位置,直接拷贝出来。这里的指令格式基于常见 RC522 模块的封装定义,但不同厂商可能把状态码和数据长度放在不同位置,所以要拿到模块手册核对偏移量。
参数说明:读块之前必须先做 Authenticate,否则模块返回错误码。如果读的是扇区 0 的块 0(即厂商块),返回的数据前 4 字节是 UID,后面是厂商自定义数据区,这个块虽然物理可读,但写操作是全部禁止的。业务数据结构设计上,一定不要把 UID 当唯一的业务标识,因为 M1 卡存在 UID 可改的国产兼容卡,同一张卡的 UID 可能在出厂后被改写,严谨的系统还要附加扇区校验字段。
3.4 读写超时与半包处理
串口通讯最怕的就是半包和粘包。模块返回的数据帧可能一次只到了半截,也可能两帧粘在一起。处理方案在我这边一般分两层:物理层用事件驱动 + 缓存,数据层用状态机解析。简单的做法是定时轮询 BytesToRead 累计到一个缓冲区,然后按帧头帧尾截取完整帧。
private byte[] ReadResponse() { List<byte> buffer = new List<byte>(); DateTime start = DateTime.Now; while ((DateTime.Now - start).TotalMilliseconds < 300) { if (sp.BytesToRead > 0) { int count = sp.BytesToRead; byte[] temp = new byte[count]; sp.Read(temp, 0, count); buffer.AddRange(temp); if (buffer.Count >= 4 && buffer[0] == 0xAA) { int len = buffer[2] + 3; // 帧长度 = 长度字段 + 固定头 if (buffer.Count >= len) { return buffer.Take(len).ToArray(); } } } Thread.Sleep(10); } return null; }逻辑说明:ReadResponse 采用时间片轮询方式,300ms 内持续读取串口缓冲,直到拿满一帧。帧长度计算方式是固定头和长度字段相加,这个公式基于常见协议,如果是自己的模块按手册调整。代码里的 buffer[2] 在大多数协议里代表数据段长度,但极少数模块把它定义为整帧长度,这会导致截断,解决办法是用固定帧头之后的两个字节做全帧长度判断。
参数说明:300ms 超时是经验值,足够覆盖低速串口的数据传输,又不会让 UI 线程卡太久。如果需要用在 WPF 界面里要改成异步方法,或者放到后台线程,否则刷一次卡界面卡顿一下就非常影响体验。Read 方法里没有做校验和验证,正式工程里必须对 CRC 错帧做重发机制,重发 2 次仍失败再向用户报错。
4. 写卡与数据初始化:M1 卡扇区写入的完整操作序列
4.1 数据块写入流程与值块的差异
写 M1 卡有两种形态:普通数据块写入和值块操作。普通数据块直接用 write 命令,把 16 字节数据覆盖写进指定块;值块是用于电子钱包场景的特殊格式,比如食堂消费机需要直接加钱扣钱,M1 卡内部支持加减法指令,能避免「先读再写」被中途断电破坏数据的风险。但大多数管理类场景用普通写入就够了。
值块格式包含数值、备份数值和地址字节,结构比普通数据块复杂。写值块时如果数据格式不正确,直接用 read 读出来的内容会是一堆乱码,甚至导致后续加值减值指令失败。所以值块初始化建议用模块自带的初始化命令而不是手动拼 16 字节数组。
public bool WriteBlock(byte blockAddress, byte[] data) { if (data.Length != 16) { throw new ArgumentException("数据长度必须为16字节"); } byte[] cmd = new byte[21]; cmd[0] = 0xAA; cmd[1] = 0x00; cmd[2] = 0x12; // 数据长度 18字节: 命令字 + 块地址 + 16字节数据 cmd[3] = 0x04; // 写块命令 cmd[4] = blockAddress; Array.Copy(data, 0, cmd, 5, 16); cmd[20] = CalculateLRC(cmd.Take(20).ToArray()); sp.Write(cmd, 0, cmd.Length); byte[] resp = ReadResponse(); return resp != null && resp[2] == 0x00; }逻辑说明:WriteBlock 把 21 字节的写入帧发送到模块,其中数据区长度 18 字节是命令字 1 字节加块地址 1 字节加数据 16 字节。LRC 校验覆盖除校验字节以外的全部字节。写入返回的状态码 0x00 表示写入成功。
参数说明:块地址不要传扇区密码块地址(扇区号为 n 的密码块是 n*4+3),写入密码块会导致整个扇区权限变化,操作前建议加一层业务判断:凡是地址对 4 取余等于 3 的都拦截掉。数据内容在写入前最好做一次备份,平时开发调试时,把每个扇区数据 dump 出来保存为 .bin 文件,相当于后悔药。
4.2 全卡初始化:批量写卡时的扇区规划
批量初始化卡片时,需要先确定整个卡片的存储规划。比如:扇区 0 保留厂商信息,扇区 1 存基础身份信息,扇区 2 存业务记录,扇区 3 留作扩展。每个扇区的密钥可以独立设置,建议设置不同的 KeyA 和 KeyB:KeyA 给发卡器管理端,KeyB 给消费终端,这样终端可以正常读写数据块但无法修改密钥配置。
批量写卡时最容易出的问题是一张卡写一半断电,导致扇区数据不完整。解决思路有两种:一个是写卡前先检查卡片当前状态,只有卡内标记为未初始化的才能写入;另一个是写完后回读关键扇区校验。后者在产线上虽然慢几十毫秒,但值得,杜绝出厂不良品。
public string InitializeCard(byte[] keyA, byte[] keyB) { // 默认密钥认证后修改密钥 byte[] defaultKey = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }; bool auth = Authenticate(1, defaultKey, true); if (!auth) return "默认密钥认证失败"; // 修改扇区1的KeyA和KeyB byte[] sector1Block3 = new byte[16]; Array.Copy(keyA, 0, sector1Block3, 0, 6); Array.Copy(keyB, 0, sector1Block3, 10, 6); sector1Block3[6] = 0xFF; sector1Block3[7] = 0x07; sector1Block3[8] = 0x80; sector1Block3[9] = 0x69; WriteBlock(7, sector1Block3); // 写入初始业务数据 WriteBlock(4, Encoding.Default.GetBytes("EMP0010000000000")); WriteBlock(5, Encoding.Default.GetBytes("20250101-000000")); return "OK"; }逻辑说明:InitializeCard 先用默认密钥认证扇区 1,然后构造该扇区密码块。密码块的前 6 字节是 KeyA,接下来 4 字节是访问控制位,再往后 6 字节是 KeyB。控制位 0xFF078069 是常用的配置,表示 KeyA 不可读、KeyB 可读,数据块用 KeyA 或 KeyB 都能访问。不同的控制位组合对应不同的权限矩阵,写错之后卡可能直接报废。
参数说明:WriteBlock(7, ...) 中的 7 对应扇区 1 的密码块地址。修改密码块之前,该块的访问控制位还处于出厂默认状态,此时用默认 KeyA 可以任意写入。一旦控制位改成非默认值,当前的 KeyA 就变成新值,旧默认密钥全部失效。所以密码块写入是整卡初始化里最危险的步骤,必须保证写入过程中电源稳定,写完之后立刻用新密钥重新认证验证。
4.3 卡片应用数据写入策略
数据块的字段设计比想象中更讲究。M1 卡单块 16 字节,如果业务字段是变长的字符串,直接把字符串塞进去会浪费空间,也可能越界截断。常见做法是固定字段长度,用 ASCII 编码,末尾用 0x00 填充。汉字需要用 UTF-8 编码,一个汉字 3 字节,16 字节只能存 5 个汉字加 1 位长度标识,设计字段时如果发现放不下,就该考虑拆到多个块。
时间戳的存储也有讲究。字符串形式的时间戳可读性好,但长度固定要写 14 位(yyyyMMddHHmmss),加上分隔符会超;Unix 时间戳是 4 字节整数,省空间但可读性差。目前项目里常用的是 4 字节二进制的 Unix 时间戳加 1 字节时区标识,读出来后再转成 DateTime,这样一块能存多条时间记录。
public byte[] BuildTimestampData(DateTime time) { int unixTime = (int)(time - new DateTime(1970, 1, 1)).TotalSeconds; byte[] bytes = BitConverter.GetBytes(unixTime); byte[] result = new byte[16]; Array.Copy(bytes, 0, result, 0, 4); result[4] = 0x08; // 时区标识,UTC+8 return result; }逻辑说明:BuildTimestampData 把 DateTime 转成 Unix 时间戳并写入 16 字节块的第一段。BitConverter.GetBytes 在 .NET 里默认按小端序排列,写入 M1 卡后再读出来时要用同样的方式解析,避免字节序不一致导致的日期错误。
参数说明:Unix 时间戳在 2038 年会溢出 Int32,如果有长期服役的系统建议改用 Int64 或者直接用字符串时间。这里大部分场景下 5 字节不够,但巡检记录这种时效性没那么强,uint 32 足够撑到 2106 年,只是代码里别用 int 直接用 uint 可能踩负数坑。稳妥起见,数据块里同时保存 BCD 编码的日期,比如用 7 个字节存年、月、日、时、分、秒,读出来直接显示不用转换,调错也方便。
4.4 写卡成功率提升手段
写卡失败率在实验室很低,到了现场往往飙升。原因多半是卡片放置距离或角度导致射频场不稳定,写入过程中载波脱落。应对办法一个是从硬件上要求员工把卡贴近天线区域停顿 1 秒,另一个是在软件层做写后回读校验,回读失败自动重写。
public bool WriteWithRetry(byte block, byte[] data, int maxRetry) { for (int i = 0; i < maxRetry; i++) { bool ok = WriteBlock(block, data); if (ok) { byte[] readBack = ReadBlock(block); if (readBack != null && readBack.SequenceEqual(data)) { return true; } } Thread.Sleep(50); } return false; }逻辑说明:WriteWithRetry 是写卡器的兜底逻辑。先写一次,再读回来做字节级比对,因为 M1 卡数据块的写入是按 16 字节整体操作的,所以 SequenceEqual 可以直接判断。50ms 的重试间隔是给模块留出内部状态机复位的时间,太快会导致模块还在忙就发下一条指令,反而更容易写失败。
参数说明:maxRetry 设置 3 次比较合理,超过 3 次基本不是瞬时干扰而是卡或者模块出了问题,再硬试也没意义。此时要把异常卡记录下来,单独投放,同时提示用户检查卡片是否弯折或退磁。现场写卡器每天写入几百张卡片,有这套重试逻辑能把成功率从 98% 拉到 99.9% 以上。
5. 上位机业务联动:刷卡记录、防重逻辑与常见问题排查
5.1 从读卡事件到业务入库的窄接口设计
RFID 读卡器返回的只是一个 UID,业务系统要的是「这张卡对应哪个员工」「这次刷卡属于什么事件」。所以读卡模块和业务模块之间需要一层窄接口:刷卡线程只负责拿到 UID 推给事件总线,业务层订阅事件后去查数据库或者调用 Web API。好处是读卡模块可以单独测试,要换读卡器硬件不需要动业务代码。
public event Action<string> CardDetected; private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { byte[] frame = ReadResponse(); if (frame != null) { string uid = ParseUid(frame); if (uid != null) { // 串口线程触发事件,业务层需自行处理线程切换 CardDetected?.Invoke(uid); } } }逻辑说明:OnDataReceived 是 SerialPort.DataReceived 事件的处理方法,每次串口缓冲有数据就触发。ReadResponse 在这个上下文里是阻塞读取可能有重复,实际工程中应该用独立缓冲区缓存到一帧结束再解析,避免触发事件次数太多导致业务重复消费。CardDetected 事件把 UID 传递给上层,WinForms 里收到事件后要 Invoke 到 UI 线程再更新界面。
参数说明:SerialDataReceived 事件在 .NET Framework 里由后台线程触发,直接操作控件会抛异常。用 SynchronizationContext 或者在事件订阅时就包装为 Control.BeginInvoke 都可以解决。另一个容易忽略的点是 DataReceived 触发频率高,事件里不要做数据库操作,只做数据入队列,由消费线程批量落库。
5.2 刷卡防重与去抖
一张卡在读写器前晃一下就触发了两次刷卡,这是高频场景里最常见的投诉。物理上卡片和天线之间会有一次「接触-分离-再接触」过程,软件上必须有去抖机制。最简单的实现是记录最后一次刷卡时间和卡号,在一定时间窗口内重复卡号直接丢弃。
private Dictionary<string, DateTime> _lastSeen = new Dictionary<string, DateTime>(); private readonly TimeSpan _debounceWindow = TimeSpan.FromMilliseconds(500); public bool IsDuplicate(string uid) { if (_lastSeen.TryGetValue(uid, out DateTime last)) { if (DateTime.Now - last < _debounceWindow) { return true; } _lastSeen[uid] = DateTime.Now; } else { _lastSeen.Add(uid, DateTime.Now); } return false; }逻辑说明:IsDuplicate 使用字典维护每个 UID 的最后出现时间,500ms 内重复的卡号直接拦截。字典在长时间运行后会越积越多,需要定期清理超过几分钟没再次出现的 UID。这种方案的好处是不需要依赖 UI 或者数据库,线程安全通过 lock 包一层可以搞定。
参数说明:500ms 是经验值。如果刷卡机装在人流密集的通道闸机上,窗口太短会导致一人刷卡连续两次计费;太长则可能出现「刷完卡紧接着被别人刷」被吞掉的错觉。闸机场景建议 700ms 到 1000ms,巡检点名场景 300ms 就够。
5.3 常见问题排查:模块无响应、乱码、重复刷
模块无响应
现象:串口打开成功,写指令后没有返回任何字节。
原因:最常见的是 TX/RX 接反,别信线的颜色,先看丝印;其次是模块波特率与代码不一致,或者模块供电电流不够,RC522 射频场启动瞬间电流能到 80mA,USB 口供电不稳时可能直接自动复位。
解决:用逻辑分析仪或者串口助手手动收发测试,先排除硬件链路。如果串口助手能收到上电自检帧但程序收不到,检查程序是否占用了同一个 COM 口,多实例同时 Open 会直接抛异常。
返回帧乱码
现象:读到的字节和手册对比完全对不上,帧头不是 AA。
原因:波特率不一致的概率最大。模块烧录的波特率可能不是你预期的那个,9600 和 115200 之间隔了好几个档位,收码率低时就会出现大量丢弃字节或错位。
解决:用串口助手逐个波特率扫描,每次发 0x55(二进制 01010101),如果波特率匹配能看到稳定重复的字节序列。找到正确波特率后把代码里的配置同步修改。
卡片认证失败
现象:Authenticate 返回非 0 状态码,读块操作被拒绝。
原因:密钥错误或者扇区控制位限制了当前认证方式。很多时候是代码里把 KeyA 和 KeyB 的字节序搞反了,写入时用逆序密钥,读卡时用正序密钥,自然不通过。
解决:先用默认密钥手工发指令验证,如果能通过说明卡本身没问题,再看代码里的密钥数组输入顺序。写入新密钥之后用新密钥重新认证一次,通过后再继续操作。
重复读卡
现象:UI 上刷卡记录一条变两条,数据库中同一 UID 连续入两条。
原因:模块开了连续寻卡模式或者程序里 DataReceived 事件在缓冲未读完时又被触发,导致一帧数据被拆成两次消费。另外一些模块的寻卡指令一旦卡片停留在天线范围就会持续返回,需要手动切换为单次触发模式。
解决:加防重列表(如上文代码),同时把模块的寻卡模式配置为「单次寻卡」,读取到 UID 后自动关闭射频场,直到业务层再次发送寻卡命令。
6. 进阶调试技巧:串口抓帧、日志埋点与国产兼容卡的边界识别
串口抓帧是调试 RFID 模块最直接的手段。不要直接在 C# 工程里打断点看变量,干扰时序还可能错过字节。我一般习惯用串口监控工具做中间人,把 COM 口虚拟出来,一端连着模块一端连着程序,所有指令和返回全部可视化。这样能快速区分是协议问题还是程序解析问题。
日志埋点也要做对。RFID 模块的返回帧里每个字节都可能隐含状态信息,日志里如果只记录「写入成功」四个字,出了问题根本没法定位。我在实际项目里会把收发的原始字节以 hex 字符串记到日志文件里,同时在关键节点记录耗时。比如寻卡耗时、认证耗时、写块耗时,一旦某个阶段耗时突然变成平时的两倍,大概率是天线场强下降或卡片品质退化。
public void LogHex(string tag, byte[] data) { StringBuilder sb = new StringBuilder(); foreach (byte b in data) { sb.Append(b.ToString("X2")).Append(" "); } File.AppendAllText(_logPath, $"{DateTime.Now:HH:mm:ss.fff} [{tag}] {sb.ToString().Trim()}{Environment.NewLine}"); }代码逻辑说明:LogHex 把字节数组转成大写 hex 字符串并带时间戳写入文件。一个完整的日志记录会标记消息方向(TX 表示上位机发给模块,RX 表示模块返回),配合时间戳能还原整个操作的时序。比如 TX 寻卡指令之后 500ms 才收到 RX 帧,说明模块响应变慢,可能是卡片没有贴近天线或者模块天线匹配失调。
参数说明:日志文件如果长期运行会膨胀得很快,一天刷卡几千次的话日志文件可能到几十 MB。解决方案是日志按天滚动,或者只记录最近 N 次交易帧,超过后自动清理旧记录。调试阶段可以全量记录,正式上线后只记错误帧和异常事件。
国产兼容卡的边界识别是个实际存在的坑。市面上有一种叫 UID 可改卡的兼容卡,和原厂 M1 卡在指令层面基本兼容,但厂商块可以被重写,UID 可以被修改。这类卡能正常读写扇区数据和认证,但是一旦设备端的逻辑依赖 UID 做唯一性校验,就可能出现拿一张改过 UID 的卡伪装成另一张卡的场景。识别方法之一是尝试写扇区 0 的块 0,原厂卡会返回错误,兼容卡写入会成功,但注意这个测试本身会破坏卡的数据,不要放在生产环境的逻辑里,只能在发卡器上做。
项目里还遇到过一种场景:卡片能读不能写。排查到最后是卡片的访问控制位被上一次写卡写坏了,控制位变成了只读权限。这种卡没有修复办法,只能报废。所以正式环境的写卡流程一定要把「修改控制位」和「修改密码」分开做,先用默认密钥认证,写完业务数据最后再改控制位,降低中间环节的风险。
高频 RFID 的项目规模不大但链路很长,从串口物理参数、指令帧格式到 M1 卡的密钥体系再到业务数据结构,任何一环出问题都会直接表现为「刷不了卡」「读卡乱码」「写卡失败」。从那以后我每次做这类读写器对接都会强制走一遍三层验证:串口能通、指令能回、读写能验。希望帮到你。
本文还有配套的精品资源,点击获取