简介:基于C#的RFID卡识别与读写是一份面向.NET开发者的射频识别入门实战资源,涵盖串口通信、事件驱动编程、数据解析及读写器交互等核心环节,适用于门禁系统、物流跟踪等场景。资源共29个文件,压缩包约66KB,以cs源码为主,配合sln工程、csproj配置、resx界面资源、exe可执行程序及pdb调试符号,构成完整的Windows Forms演示项目。已有4660人学习。包内工程结构清晰,含Form1主窗体、Program入口及Properties配置等模块,读者可据此快速搭建RFID实验环境,并通过阅读源码掌握SerialPort串口配置、标签事件监听与EPC数据解析等关键技巧。对于希望将C#与硬件打通的初学者而言,这份资源提供了可运行的参照实现,能有效缩短上手周期。
1. 基于C#的RFID卡识别和读写:先搞清楚这张卡到底藏了什么
很多开发者在 Windows 下第一次接触 RFID 读卡器时,都以为插上 USB 就能像 U 盘一样直接读到卡号,结果一调设备返回的却是错误帧、乱码,甚至毫无反应。RFID 模块不是即插即用设备,读卡只是一系列状态机的流转,尤其是基于 ISO14443A 协议的高频卡,一次完整操作涉及寻卡、防冲突、选卡、认证、读写块五个步骤,任何一个环节时序不对,数据就读不出来。这份基于 C# 的 RFID 卡识别和读写资源,适合做门禁、仓储盘点、会员卡系统的开发者,直接解决了从串口收发到 M1 卡数据块读写这一整条链路,而不只是给你一个“读卡号”的 Demo。
2. 硬件与协议层:高频卡不认识“读卡号”这种指令
2.1 读卡器不是扫描枪:为什么必须先理解 ISO14443A 时序
高频 RFID 读卡器虽然看起来像扫码枪,但它的工作方式完全不同。扫码枪主动成像,RFID 读卡器则是通过电磁场感应供电并交换数据。M1 卡(MF1ICS50)这类高频卡遵循 ISO14443A 协议,读卡器和卡片之间通过 13.56MHz 的载波通信,传输速率默认 106kbps,整个交互过程是半双工的:读卡器先发请求,卡片返回应答,一次完整的操作由多条 APDU 指令串联完成。
在 C# 开发时,如果你直接把厂商提供的“读卡号”函数当成黑盒调用,通常能拿到 UID(4 字节卡号),但一旦需要读写用户数据区,就绕不开底层时序。最常见的情况是调用读块函数返回超时,原因就是前一步没有完成选卡操作,卡片还停留在“防冲突状态”。正确顺序永远是:Request(请求)→ Anticollision(防冲突)→ Select(选卡)→ Authentication(认证)→ Read/Write(读写),跳过任何一环都会失败。
读卡器硬件层面还有一个关键参数:天线驱动电流。很多国产读卡器默认功率偏低,卡片贴近天线边缘时场强不稳定,会导致重传和 CRC 错误。这一点在后面的排错章节会专门展开,开发阶段我一般建议先固定卡片在读写区域中心再调试,排除物理位置干扰。
2.2 M1 卡的数据结构:十六个扇区的存储器映射
M1 卡内部有 1K 字节 EEPROM,分为 16 个扇区,每个扇区 4 个块(Block),每个块 16 字节。第 0 扇区的第 0 块是厂家固化的 UID 和校验字节,出厂只读,任何写入都会失败。每个扇区的第 3 块是控制块(Trailer),存放该扇区的 KeyA、Access Bits、KeyB,控制块的读写权限受 Access Bits 自己管理,改错了整个扇区就废了。
这个结构的直接后果是:你在写数据时,能自由使用的是扇区中第 0、1、2 块,每块 16 字节,共 48 字节。如果业务需要存更多数据,就得规划占用多个扇区。很多新人把卡当成无限存储用,写超了或者越界写入控制块,卡片直接报错,这是读写模块开发中最容易翻车的地方之一。
这张表是 M1 卡的存储布局,开发时我常打印出来贴在工位旁边:
| 扇区 | 块号 | 内容 | 可用性 |
|---|---|---|---|
| 0 | 0 | UID(4字节)+ 校验 + 厂商数据 | 只读 |
| 0 | 1-2 | 用户数据 | 可读写 |
| 0 | 3 | KeyA + Access Bits + KeyB | 受权限控制 |
| 1-15 | 每个扇区第0-2块 | 用户数据 | 可读写 |
| 1-15 | 每个扇区第3块 | 控制块 | 受权限控制 |
默认出厂时,所有扇区的 KeyA 和 KeyB 都是 FFFFFFFFFFFF,Access Bits 允许任何密钥认证后用任意密钥读写。如果你拿到批量卡后改了密钥,务必自己留存记录表,卡丢了还能找回数据,密钥忘了就只能当废卡处理。
3. 串口通信与数据帧:把 C# 和读卡器对接的底层约定
3.1 厂商协议帧格式:一条指令的头发、身体、尾巴
市面上大部分串口 RFID 读卡器采用类似的帧结构,我接触过的几家国产模块基本都遵循“帧头 + 设备地址 + 命令字 + 数据长度 + 数据 + 校验”的格式。以我常用的某厂家模块为例,帧头固定 2 字节 0xAA 0xBB,命令字 1 字节,长度 1 字节,数据区和校验字 1 字节异或校验。下面是一个完整的读取块数据请求帧示例:
AA BB 00 03 01 01 00 04逐字节解释:
- AA BB:固定帧头,不是每个厂牌都一样,但功能相同
- 00:读卡器地址,单设备通常填 00,多设备级联时区分用
- 03:命令字,0x03 定义为寻卡请求
- 01:数据区长度,表示后面跟一个字节的寻卡模式
- 01:寻卡模式,1 表示只寻未休眠的卡(对应 ISO14443A 的 Request All 指令)
- 00 04:异或校验,把前面所有字节异或后得到的结果
异或校验的计算逻辑在 C# 里写出来就一行:
public static byte CalculateChecksum(byte[] data, int startIndex, int length) { byte checksum = 0x00; for (int i = startIndex; i < startIndex + length; i++) { checksum ^= data[i]; } return checksum; }这段代码把帧头到数据区的每一个字节依次异或,结果作为最后一个字节发送。不要把整帧从帧头开始异或,校验区间是到数据区结束为止,发出去的帧末字节是校验位本身。
3.2 SerialPort 配置与超时策略:参数错了连不上
C# 端使用 System.IO.Ports.SerialPort 类连接读卡器,最常见的错误是认为读卡器像串口打印机一样即插即用。实际上,你需要先确认设备在系统里的 COM 口号,以及波特率、数据位、校验位和停止位,四个参数任何一项不匹配都会导致读到的是乱码帧。
我最常用的一套参数组合是波特率 9600,8 数据位,无校验,1 停止位。部分高速模块支持 115200,但仓库门禁这类场景我坚持用低波特率,抗干扰能力更强。SerialPort 初始化代码:
SerialPort sp = new SerialPort("COM3", 9600, Parity.None, 8, StopBits.One); sp.ReadTimeout = 500; sp.WriteTimeout = 500; sp.Open();ReadTimeout 设置尤其重要。RFID 模块在寻卡时如果天线前没有卡片,会一直不返回数据,默认超时 -1 会无限阻塞线程。500 毫秒超时加上 try-catch 捕获 TimeoutException,是避免界面卡死的底线。
读卡器响应的帧结构里,第一个字节一般是 0xBB,表示帧头响应。很多模块会在响应的数据区返回卡片 UID,具体偏移位置每个厂牌不同,我建议你拿到模块后先用串口助手手动发送寻卡指令,把返回的十六进制数据抄下来再写解析代码,不要直接套网上的例子。曾经有位同事直接照搬另一家模块的偏移量来解析,UID 全是乱的,最后抓包发现响应数据区前两个字节是返回码和长度信息。
4. C# 读写实战:从寻卡到块数据的完整模块化封装
4.1 核心类设计:把设备指令封装成五个方法
拿到模块指令表后,我一般会写一个 RfidReader 类,把重复性高的指令封装成公开方法,业务层只需要调用ReadUid()、ReadBlock()、WriteBlock()就够了。类初始化的时候传入串口号,构造器里完成端口打开,释放时自动关闭,避免资源泄漏。
public class RfidReader : IDisposable { private SerialPort _serialPort; public RfidReader(string portName, int baudRate = 9600) { _serialPort = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); _serialPort.Open(); } public void Dispose() { if (_serialPort != null && _serialPort.IsOpen) { _serialPort.Close(); _serialPort.Dispose(); } } }构造器直接在 new 实例时打开串口,如果串口被其他程序占用,这里就会抛异常,调用方可以根据异常类型给出友好提示。Dispose 模式保证即使业务代码内部出现异常,串口资源也会被释放,避免占用导致的后续重启失败。
4.2 防冲突与寻卡:同时出现两张卡怎么办
开门禁场景最常见的情况是:人员手上同时拿着门禁卡和饭卡,两张都是 M1 卡,同时进入天线场区。如果不做防冲突处理,读卡器会随机锁定一张卡,另外一张读不到。ISO14443A 协议的防冲突机制是通过 CT 级联和 UID 逐位比较来实现的,读卡器发送防冲突指令后,两张卡各自返回 UID 的冲突位,读卡器根据冲突位决定保留哪一张。
模块层面的表现为:发送寻卡指令后返回两段 UID 数据,或者返回防冲突错误。C# 代码里处理的方式就是循环执行寻卡,直到只锁定一张卡:
public byte[] RequestCard() { byte[] requestCmd = { 0xAA, 0xBB, 0x00, 0x03, 0x01, 0x01 }; byte checksum = CalculateChecksum(requestCmd, 0, requestCmd.Length); byte[] fullCmd = requestCmd.Concat(new byte[] { checksum }).ToArray(); _serialPort.Write(fullCmd, 0, fullCmd.Length); byte[] response = ReadResponse(12); if (response[1] == 0x00) // 返回码 0x00 表示成功 { byte[] uid = new byte[4]; Array.Copy(response, 5, uid, 0, 4); return uid; } return null; }这段代码发送寻卡请求,然后读取 12 字节响应,返回码为 0x00 时提取第 5 到第 8 字节作为 UID。ReadResponse 方法内部处理超时和数据帧完整性校验,如果返回码为 0x02(表示检测到多卡冲突),就需要重新发送寻卡指令,可加一个循环重试,一般重试两三次就能稳定锁定单一卡片。
4.3 认证与写卡:密钥错了就报错 0xE9
读写 M1 卡的数据块,先要认证。认证指令需要一个 6 字节的密钥,常见默认密钥是FF FF FF FF FF FF。认证成功后,才能执行读块或写块命令。写块之前必须确认目标块不是控制块,否则在默认访问位下虽然能写进去,但可能导致控制位参数异常,卡片直接报废。
public bool WriteBlock(byte sector, byte block, byte[] data) { byte blockNumber = (byte)(sector * 4 + block); byte[] authCmd = { 0xAA, 0xBB, 0x00, 0x05, 0x07, blockNumber, 0x60, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }; byte authChecksum = CalculateChecksum(authCmd, 0, authCmd.Length); byte[] authFull = authCmd.Concat(new byte[] { authChecksum }).ToArray(); _serialPort.Write(authFull, 0, authFull.Length); byte[] authResp = ReadResponse(8); if (authResp[1] != 0x00) { return false; } byte[] writeCmd = { 0xAA, 0xBB, 0x00, 0x04, 0x11, blockNumber }; writeCmd = writeCmd.Concat(data).ToArray(); byte writeChecksum = CalculateChecksum(writeCmd, 0, writeCmd.Length); byte[] writeFull = writeCmd.Concat(new byte[] { writeChecksum }).ToArray(); _serialPort.Write(writeFull, 0, writeFull.Length); byte[] writeResp = ReadResponse(8); return writeResp[1] == 0x00; }这里要注意认证指令的数据区,第一个字节是块号,第二个字节 0x60 表示用 KeyA 认证。如果改成 0x61 就是用 KeyB 认证,业务上如果授信给不同部门,可以一个扇区用 KeyA,一个扇区用 KeyB,互不干扰。data 参数必须是 16 字节,调用方传入不足 16 字节时,要补零再发,否则帧长度和数据区对不上,模块会丢弃整帧。
写卡成功与否的判定,有些模块返回码是 0x00,有些是 0xFF,最好以自己手边模块的说明书为准。返回码为 0xE9 时表示认证失败,一半情况是密钥写错或扇区控制块损坏。
5. 避坑指南:串口通信与 M1 卡读写常见问题排查
5.1 现象:寻卡偶尔成功偶尔超时,重试后又稳定了
原因:最常见的是卡片处于天线场区边缘,场强不稳定导致卡片上电复位延迟,卡片和读卡器的通信握手失败。天线的有效读取范围其实很窄,尤其小型桌面模块,有效高度通常不超过 5 厘米。
解决:把卡片固定在天线中心区域,保持静止,不要悬空拿在手上。如果必须在移动场景用,比如产线过卡,就要调高模块发射功率,或者选用天线面积更大的模块,单纯靠代码加重试只会掩盖物理问题。另外注意,卡片和读卡器之间有金属物体时,涡流会吸收电磁能量,导致读卡距离骤降,调试时排除工作台上的金属工具。
5.2 现象:读块返回数据全是 0x00,写块返回成功但读出来不对
原因:没有先认证就直接读了块。很多模块对未认证的读操作不返回错误码,而是返回全零或者上一次缓存的数据,这个设计很坑,因为它不会直接告诉你失败。你的流程跳过了 authentication,或者认证时 KeyA 选错(应该用 KeyA 结果用了 KeyB)。
解决:严格按状态机顺序执行。封装方法时把认证和读写绑定在一个函数里,不要分开暴露给业务层单独调用。认证成功后应立刻进行读写操作,不要穿插其他指令,有些模块认证状态会在其他指令执行后被清除。
5.3 现象:C# 调用 SerialPort.Write 后立即 Read 会抛超时
原因:SerialPort 的 ReadTimeout 生效条件是“在指定时间内没有收到任何字节”。如果读卡器响应的帧被拆成了两段到达,第一段到了但第二段还没到,你按帧长度去读就会阻塞。这个情形在 USB 转串口线缆上特别常见,底层缓冲导致延迟。
解决:读取响应时不要一次性按帧长读,先读固定长度的帧头部分,解析出数据区长度,再根据长度读出剩余部分。就像前面代码里 ReadResponse(int length) 方法,内部建议用两个循环,第一次读 2 字节判断长度,第二次读剩余字节。经验值是把 SerialPort.ReadBufferSize 调大到 4096,减少底层缓冲抖动。
5.4 现象:多个程序同时打开同一 COM 口,直接报 Access Denied
原因:一个串口在同一时刻只允许一个进程独占访问。调试时开着串口助手再启动自己的程序,必然冲突。这不是代码问题,是资源竞争问题。
解决:调试阶段不要同时开多个串口工具,程序崩溃后要确认进程已完全退出。如果程序异常退出后串口没释放,可以在任务管理器里结束残留进程,或者把串口重新插拔一次。我在项目里加了一个硬性规则:程序启动时先尝试打开串口,失败就提示用户关闭其他占用程序,而不是静默重试。
5.5 现象:写块返回成功,但用读卡器再读时数据里多了几个字节 0x00
原因:C# 端发送的 data 数组长度超过 16 字节,或者发送帧长度和实际数据不一致。模块按帧头里的长度字段解析数据区,如果你把 16 字节数据后面还跟了回车换行符,会把换行符解析为下一条指令的帧头,直接造成指令错乱。
解决:发送前检查 data.Length == 16,同时把所有通过 SerialPort.Write 发送的字节统一经过一个指令构造器,在内部强制校验帧长度。这个构造器是我封装 RfidReader 类时最重要的一层,所有命令帧都从它流出,从源头避免手工拼帧的低级错误。
6. 进阶技巧:把 UID 读取和业务数据写入做成事务式操作
很多系统上线后才发现一个典型问题:发卡环节读到了 UID,但业务数据没写进去,导致卡作废重发。根本原因是读 UID 和写数据是两个独立的指令,中间任何失败都会造成数据不一致。我后来把所有发卡动作封装成一个事务式方法,核心思路是写卡之前先读一遍目标块,和期望值比较,写卡成功后再读一次回验,三次确认才返回成功。
public bool IssueCard(int sector, int block, byte[] userData, byte[] fallbackData) { byte targetBlock = (byte)(sector * 4 + block); byte[] oldData = ReadBlock(sector, block); bool hasOldData = oldData != null && oldData.Any(b => b != 0x00); if (hasOldData) { return false; } bool writeOk = WriteBlock(sector, block, userData); if (!writeOk) { return false; } byte[] verifyData = ReadBlock(sector, block); bool verifyOk = verifyData != null && verifyData.SequenceEqual(userData); if (!verifyOk && fallbackData != null) { WriteBlock(sector, block, fallbackData); return false; } return verifyOk; }这段代码先读旧数据判断块是否已使用,已使用的卡拒绝重复发卡,避免覆盖存量数据。写入成功后立即回读比对,若回读数据和写入数据不一致,判断为硬件写失败,尝试写回 fallbackData 恢复现场。fallbackData 是调用方预留的出厂数据或者空白数据,相当于给写卡操作上了后悔药。这样设计后,发卡系统里再也没出现过写着写着数据丢了的情况。
这个事务式思路对门禁系统尤其重要。读到 UID 后写卡失败,如果直接入库,门禁卡就是一张空卡,发到员工手里根本开不了门。写成回验式之后,写入失败会直接抛异常给前端,提示重新放卡,不给半成品卡入库的机会。
从那以后我每接到一个 RFID 项目,不管是门禁还是仓储,都会强制要求写卡操作带一次回读验证,哪怕模块手册上说写卡成功率 99.9%,剩下那 0.1% 落到真实用户身上就是一次投诉。做开发调试时也习惯了每一步指令前后都打印十六进制帧,帧一乱先比对格式,再查时序,比对着硬件猜快得多。这套流程帮我在若干个模拟项目里少踩了不少坑,希望帮到你。
本文还有配套的精品资源,点击获取