资讯详情 C#读写RFID卡实战:基于RC522串口通信的M1卡块读写完整解析
📅 2026/10/20 8:52:00
简介RFID技术结合C#开发是工业自动识别领域的常见需求。该示例工程围绕“基于C#的RFID卡识别和读写”提供完整实现面向C#初学者及物联网、自动识别方向的开发者帮助理解串口通信与事件驱动编程的核心思路。压缩包共29个文件、约66KB包含解决方案sln、主窗体及设计器代码Form1.cs、程序入口Program.cs、项目配置、界面资源及编译生成的exe、pdb等目录结构清晰可直接打开工程查看或运行验证。从设备连接、事件监听、数据解析到读写操作与错误处理工程完整覆盖C#操作RFID阅读器的核心流程并提供了可运行的桌面程序界面便于在此基础上扩展门禁、物流跟踪、商品防伪等实际应用场景。目前已有4660人学习下载。1. 一个C#读写RFID卡的小项目从串口通信到块读写的落地要点某实验室的设备管理需要刷卡签到输工号太慢刷卡一秒就完成识别。可真正把RFID卡的读、写、认账在C#里完整跑通不是调一个API那么简单串口字节流要自己组包断包要靠缓冲区兜底扇区密钥要自己认证任何一个环节失误结果就是读卡时灵时不灵。这篇博文拆解一个基于C#的RFID卡识别与读写程序它通过串口连接RC522读卡器完成寻卡、防碰撞、密钥认证、块读写并用WinForms把卡号和块数据显示出来。适合刚接触RFID开发、想快速跑通验证的新手也适合正在封装读卡器协议层、需要一份参考实现的开发者。下文从技术栈、目录结构、核心代码到常见故障逐步展开。2. 技术栈与工程结构读卡器选型、目录规划与通信协议基础2.1 读卡器选型RC522模块与M1卡的常见组合这个项目使用最常见的RC522读卡器模块工作频率13.56MHz支持ISO 14443A协议的M1卡也叫Classic 1K。模块通过USB转TTL串口与电脑连接C#侧只需操作串口不需要自己写USB底层或HID驱动。选这块芯片主要看中三点模块成本低、资料齐全、协议指令公开。对验证RFID读写流程或做产品原型这个组合够用且容易替换。M1卡内部存储按扇区划分1K容量的卡有16个扇区、每个扇区4个块每个块16字节。卡号UID固定存放在扇区0块0的厂商区域出厂后只读。业务数据通常写在扇区0的块1、块2或者后面空闲扇区的数据块里。这个内存布局后面会专门讲但选型时就要先知道不是所有块都能写。选型时也考虑过USB HID读卡器插上就用、免驱上层直接拿卡号。但这类设备往往把读写数据块的指令封闭了很多型号只开放读卡号接口。想自定义写数据就被绑死。串口方案的优点是全双工、指令透明读哪块、用什么密钥都由程序控制灵活性高代价是程序必须自己处理串口生命周期和帧协议。做产品原型我倾向串口方案做终端应用且只需要卡号HID方案更省事。这个项目要的是读写块数据所以最终落在串口RC522上。硬件上还有个容易被低估的细节USB转TTL模块质量参差供电不足会直接导致读写失败。常见做法是模块电源脚并联一个10uF电解电容和一个0.1uF陶瓷电容尽量别用长杜邦线从电脑USB口取电。2.2 工程目录与模块职责通信、协议、业务三层RFIDDemo/ ├─ RFIDDemo.sln ├─ RFIDDemo/ │ ├─ Program.cs │ ├─ MainForm.cs │ ├─ SerialPortManager.cs │ ├─ Crc16.cs │ ├─ RfidCommand.cs │ ├─ RfidPacketParser.cs │ └─ CardOperation.cs └─ docs/ └─ 协议说明.md这个结构刻意把硬件通信和业务逻辑分开。SerialPortManager只负责把字节流发出去、把字节流收完整它不认识RFID协议RfidCommand和RfidPacketParser处理协议帧的拼装与拆分CardOperation做的是“先认证、再读块、再写块”的流程编排。这样分离的最大好处是将来换读卡器型号比如从RC522换成PN532只需替换协议层两个类主窗体几乎不动。工程结构里还有一个约定所有协议相关常量集中在RfidCommand.cs顶部的静态类里比如命令字、成功状态码统一大写命名。代码里不出现魔法数字。docs下的协议说明.md建议把厂商指令表、返码含义、扇区占用情况全部记录在案。项目过三个月再回来看文档比记忆可靠尤其是扇区占用表多张卡混用时很容易写重叠。新建WinForms项目后先确认目标框架。如果是.NET Framework 4.7.2以上System.IO.Ports内置如果是.NET 6或8在csproj里添加System.IO.Ports的PackageReference即可。串口API在两套框架下命名空间相同主要区别在项目配置方式。2.3 通信协议基础帧结构、返码与默认参数读卡器与上位机之间的数据帧有固定格式本项目所用模块的应答帧结构如下起始字节0x00一字节命令字如0xCA寻卡、0xB1读块、0xD6写块、0x60认证A密钥数据长度一字节状态码一字节0x00表示成功数据区长度由数据长度字段决定最后跟一字节校验和。校验和是整个帧除校验和外所有字节相加后取低8位。帧格式里最容易忽略的是长度字段。寻卡命令发送00 CA 00 00 00其中第一个0x00是起始0xCA是寻卡命令第二个0x00是数据长度第三个0x00是模式最后一个0x00是校验和。应答帧通常长这样00 CA 04 00 04 3F 9C D2 00 F5。这里04表示数据区长度00是状态码数据区是04 3F 9C D2共4字节卡号最后F5是校验和。再补充一个帧校验的小例子。以寻卡应答00 CA 04 00 04 3F 9C D2 00 F5为例剔除最后一字节F5前面各字节相加得到的结果取低8位才是F5。调试时如果校验和总是差一点先检查是否漏掉了“长度”这一字节。校验范围是除校验和本身外的全部字节不是只对数据区做校验。M1卡默认密钥是FF FF FF FF FF FFA密钥和B密钥出厂相同。如果卡被改过密钥默认认证会失败返回状态码非0。密钥参数建议统一放在常量类或配置文件里不要散落在指令构造中。参数值说明波特率9600常见默认部分模块支持115200需先切换数据位8固定停止位1固定校验位None串口层无校验帧层靠校验和数据块长度16每块固定16字节默认密钥FF FF FF FF FF FFM1出厂默认调试时如果收到的一串数据全是0x00或0xFF多半是波特率或校验位不匹配。这类问题在串口联调里比协议出错更常见先排除硬件参数再查数据帧。3. 核心模块拆解串口通信层的组包、校验与同步等待3.1 SerialPort初始化参数、连接与释放public class SerialPortManager : IDisposable { private SerialPort _port; private readonly object _lock new object(); public void Connect(string portName, int baudRate 9600) { _port new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One) { ReadTimeout 500, WriteTimeout 500 }; _port.DataReceived OnDataReceived; _port.Open(); } private void OnDataReceived(object sender, SerialPortDataReceivedEventArgs e) { int n _port.BytesToRead; byte[] buffer new byte[n]; _port.Read(buffer, 0, n); Enqueue(buffer); } public void Dispose() { if (_port ! null _port.IsOpen) { _port.DataReceived - OnDataReceived; _port.Close(); _port.Dispose(); } } }参数说明portName从主窗体下拉框取比如COM3。波特率默认9600绝大多数RC522模块出厂就是这个值。数据位8、停止位1、无校验是固定组合改错会出现乱码或完全无响应。ReadTimeout和WriteTimeout各500毫秒防止串口卡死时线程一直等待超时后抛出异常由上层捕获提示。Dispose里先退订事件再关闭串口否则内存泄漏和端口占用两个问题一起来。DataReceived事件在后台线程触发任何进入UI的调用都要用Invoke封送。我在很多项目里见过直接在事件里改TextBox导致闪退的这是WinForms串口开发的经典翻车点。3.2 粘包与断包处理缓冲区组装合法帧串口和TCP类似数据可能一次来半包也可能一次来两个包叠加。如果收到多少就读多少直接解析多半拿到残缺帧或头部错位帧这是串口开发里翻车率最高的位置。解决思路是维护一个内部缓存凑够完整一帧再交给业务层public class PacketAssembler { private readonly Listbyte _buffer new Listbyte(); public byte[] TryParseFrame(byte[] chunk) { _buffer.AddRange(chunk); if (_buffer.Count 5) return null; int dataLength _buffer[2]; int totalLength 3 1 dataLength 1; if (_buffer.Count totalLength) return null; byte[] frame _buffer.Take(totalLength).ToArray(); _buffer.RemoveRange(0, totalLength); byte sum 0; for (int i 0; i frame.Length - 1; i) sum frame[i]; if (sum ! frame[frame.Length - 1]) return null; return frame; } }逻辑说明把新收到的chunk追加进缓存检查缓存是否能构成一帧。头部第3个字节是数据长度用它计算总帧长。够一帧就取出并移除不够则等下一次接收。取帧后做和校验校验不过说明数据被破坏或头部错位直接丢弃。这个逻辑不引入复杂状态机适配大多数读卡器应答格式。如果发现返回null的频率很高把frame的十六进制打印进日志。常见原因有两个读卡器上电时主动回一帧版本信息格式与普通应答不同会被校验干掉USB转串口芯片的流控不匹配时数据中间会插入干扰字节。应对方法是在SerialPort初始化里把Handshake设为None并忽略上电后的第一帧。3.3 命令等待模型AutoResetEvent同步发送命令后不能立刻读读卡器需要几十毫秒处理。最原始的做法是Thread.Sleep固定等100毫秒但这样既不快也不可靠模块处理快时浪费处理慢时没等够。用AutoResetEvent做事件通知更稳public class RfidTransport { private readonly object _sendLock new object(); private readonly AutoResetEvent _responseEvent new AutoResetEvent(false); private byte[] _lastFrame; public bool SendCommand(byte[] cmd, out byte[] response) { lock (_sendLock) { _lastFrame null; _port.Write(cmd, 0, cmd.Length); if (!_responseEvent.WaitOne(500)) { response null; return false; } response _lastFrame; return true; } } public void OnFrameReceived(byte[] frame) { _lastFrame frame; _responseEvent.Set(); } }参数说明_sendLock保证同一时间只有一个命令在飞防止并发命令导致应答错配。_responseEvent等待500毫秒读卡器正常60毫秒内回帧超时多半是线没接好或模块掉电。OnFrameReceived由组装器在收到合法帧后调用唤醒等待线程。超时后先查三样东西读卡器电源灯是否亮、串口号是否选对、线序是否交叉。串口通信是TXD接RXD、RXD接TXD地线必须共地。模块附带的杜邦线颜色经常不可靠接错一次后就再也不信线色了。3.4 十六进制日志联调期最有用的调试手段串口联调最怕的就是黑匣子发出命令后不知道读卡器回了什么。我一般会在SendCommand里加一行日志把发送和返回的字节都转成十六进制字符串public static string ToHex(byte[] bytes) { return BitConverter.ToString(bytes).Replace(-, ); } // 使用示例 Console.WriteLine($[TX] {ToHex(cmd)}); Console.WriteLine($[RX] {ToHex(response)});有了这个日志断包、校验失败、状态码非0都能很快定位。比如TX后完全没RX说明线没通或模块没供电RX回来全是FF先查波特率RX回来但校验和不对再查帧组装逻辑。这套日志在项目交付后也可以保留以DEBUG级别输出不影响正常使用。4. RFID指令链路寻卡、密钥认证与块读写的全过程4.1 寻卡从指令字节到卡号解析public byte[] FindCard() { byte[] cmd new byte[] { 0x00, 0xCA, 0x00, 0x00, 0x00 }; if (!_transport.SendCommand(cmd, out byte[] resp) || resp[3] ! 0x00) return null; byte[] uid new byte[resp[2] - 1]; Array.Copy(resp, 5, uid, 0, uid.Length); return uid; }逻辑说明0xCA是寻卡命令倒数第二个0x00是数据长度最后一个0x00是模式表示寻所有卡。应答帧索引3是状态码0是成功。数据区从索引5开始长度为resp[2]-1因为长度字段本身包含了状态码那1字节。返回值是uid数组转成十六进制字符串后类似“04 3F 9C D2”。卡号长度并非总是4字节。M1卡常见4字节UID但部分卡片是7字节。按resp[2]动态取长度比写死4字节健壮。拿7字节卡测试时如果发现卡号后半段是乱的多半就是这里写死了。4.2 密钥认证扇区地址计算与默认密钥public bool Authenticate(byte block, byte[] key null) { if (key null) key new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }; byte sector (byte)(block / 4); byte[] cmd new byte[] { 0x00, 0x60, (byte)(sector * 4), 0x00, 0x06, key[0], key[1], key[2], key[3], key[4], key[5] }; byte sum 0; for (int i 0; i cmd.Length; i) sum cmd[i]; cmd cmd.Append(sum).ToArray(); if (!_transport.SendCommand(cmd, out byte[] resp)) return false; return resp[2] 0x00 resp[3] 0x00; }说明0x60表示用A密钥认证。认证前必须指定块号读卡器按扇区处理权限。这里地址取sector*4即该扇区第一块认证成功后整扇区都可访问。如果认证失败状态码非0常见失败码0x63代表密码错误。默认密钥只在测试时使用正式产品必须支持从配置读取密钥。很多新手问认证块4还是块0其实同属一个扇区认证哪一块都一样。构造命令时0x06是密钥长度后面6字节是密钥最后补一字节校验和。如果手动拼命令时用0x00占位代替校验和发送出去一定失败排查起来非常痛苦。返码含义0x00成功0x63认证失败密钥错误或权限不足0x01参数长度错误0xFF命令不被支持或模块未就绪4.3 读块与写块16字节数据块的长度规整public byte[] ReadBlock(byte block) { byte[] cmd { 0x00, 0xB1, block, 0x00, 0x10 }; byte sum 0; for (int i 0; i cmd.Length; i) sum cmd[i]; cmd cmd.Append(sum).ToArray(); if (_transport.SendCommand(cmd, out byte[] resp) resp[3] 0x00) { byte[] data new byte[16]; Array.Copy(resp, 5, data, 0, 16); return data; } return null; } public bool WriteBlock(byte block, byte[] data) { byte[] payload new byte[16]; Array.Clear(payload, 0, 16); Array.Copy(data, payload, Math.Min(data.Length, 16)); byte[] cmd new byte[] { 0x00, 0xD6, block, 0x00, 0x10 }; cmd cmd.Concat(payload).ToArray(); byte sum 0; for (int i 0; i cmd.Length; i) sum cmd[i]; cmd cmd.Append(sum).ToArray(); return _transport.SendCommand(cmd, out byte[] resp) resp[3] 0x00; }参数说明读块命令0xB1写块0xD60x10是长度16。写块时数据不足16字节要补位我用0x00补齐看个人习惯。WriteBlock返回true只代表命令被接受不代表数据正确落盘。严谨流程必须写完后立即读回对比这也是后面避坑章的一个重点。写块时补位为什么不用0xFF而用0x000xFF在M1卡里常表示“未写入”状态新卡数据区初始值大多是0x00。用0x00填充读回时能直观区分哪些字节是空位。空字节在日志里显示为00不会被当成数据。提示块0只读。往块0写数据会返回状态码非0还可能干扰后续扫描。业务数据从块1开始写并按扇区建立索引表。4.4 主流程串联先认证再读写最后回验public bool WriteUserData(byte block, byte[] data) { byte[] uid FindCard(); if (uid null) return false; if (!Authenticate(block)) return false; if (!WriteBlock(block, data)) return false; byte[] verify ReadBlock(block); return verify ! null verify.SequenceEqual(data); }WriteUserData把三步串成原子操作寻卡确认卡存在认证拿权限写完读回校验。SequenceEqual逐字节比较。这里体现的是流程编排思维协议层不关心业务业务层不关心硬件。如果要写多条记录循环调用ReadBlock和WriteBlock即可但每次写块之间留一点时间间隔连续写太快可能触发模块保护逻辑。如果要在M1卡上存业务记录常见做法是把16字节数据块设计成固定结构前4字节存记录序号中8字节存文本或数字后4字节留作扩展。这样读回时先解析前4字节就知道是哪条记录不用整块扫描。5. 避坑记录五次常见故障的现象、原因与处理5.1 数据帧解析错位卡号时灵时不灵现象寻卡第一次总是失败第二次才成功。或者卡号前面多出几个字节比如正常“04 3F 9C D2”变成“00 CA 04 3F 9C D2”。原因上位机在应答帧还没收全时就尝试解析。串口是字节流操作系统会在多个时间点回调DataReceived一次应答帧经常被拆成两次到达。每次回调都独立解析第一段数据不够一帧就被误判。解决引入3.2节的组装队列所有字节先进缓冲区凑够一帧才弹出。验证方法很简单连续寻卡10次每次卡号都应一致。如果仍有偶发失败把缓冲区的打印日志打开看是否出现校验失败丢弃的帧。5.2 往块0写数据失败现象对刚买的新卡执行写块0返回状态码非0或ReadBlock读回的数据全是0x00。原因M1卡的块0是厂商数据区包含UID、校验字节和制造商信息出厂后只读。往上写会被模块拒绝。解决业务数据从块1开始规划。若确实需要覆盖块0只能购买特殊型号的空白卡且必须确认模块支持该指令。最稳妥的做法是写数据前先检查目标块号遇到块0直接提示不合法。5.3 写块“成功”但读回数据不变现象WriteBlock返回true紧接着ReadBlock读回的数据仍是旧值或全是0xFF。原因最常见的是控制块访问条件被改密钥B权限不足但程序仍用A密钥另一种可能是指令里数据长度不是16模块只写了部分字节还有少数模块在连续操作后需要等待写太频繁会吞指令。解决写完后立即读回对比。读回是旧值时先确认当前扇区控制块内容建议换一张未改过密钥的新卡重测读回是新数据但末尾缺字节检查写入前是否补满16字节。把resp状态码和读回数据都打进日志定位最快。5.4 COM口被占用或COM号变化现象读卡器插上USB程序打开SerialPort抛UnauthorizedAccessException。或者拔插USB后COM号从COM3变成COM5程序写死COM3就找不到设备。原因读卡器配套演示软件没退出独占串口。USB转串口芯片每次枚举时分配的COM号可能不同尤其是电脑上接了多个USB串口设备时。解决程序里不要写死COM号启动时枚举SerialPort.GetPortNames()放下拉框。打开失败时先看设备管理器里COM口是否被其他程序占用。产品化时还要加一个重连按钮用户拔插读卡器后不重启程序就能恢复。5.5 7字节UID卡识别错乱现象4字节卡能读7字节卡读出来的卡号后半段是乱的或者偶尔读成前4字节加尾部0xFF。原因寻卡应答里数据区长度不同代码里写死取4字节导致错位。解决如4.1节代码所示按resp[2]-1动态取UID长度。7字节UID转字符串时前4字节和后4字节可以拼接处理也可以只取前4字节做业务索引。关键是长度不能写死这个改动很小收益很明显。6. 二次开发把固定读写流程抽象成可配置的卡操作服务6.1 接口隔离硬件降低换读卡器成本基础功能跑通后再往产品级走第一件事就是把CardOperation拆到接口后面public interface ICardOperator { byte[] FindCard(); bool Authenticate(byte block); byte[] ReadBlock(byte block); bool WriteBlock(byte block, byte[] data); }RC522实现这个接口后主窗体只依赖ICardOperator。将来换PN532或USB HID读卡器新增一个类实现同一接口即可主窗体的UI、日志、权限判断完全不用动。这个抽象是二开最值得做的一步成本低、收益大。6.2 配置驱动密钥与块号不硬编码用JSON存放各扇区的密钥和块号避免每次换卡换扇区都要改代码重新编译{ cardType: M1, key: FFFFFFFFFFFF, dataBlocks: [1, 2], verifyAfterWrite: true }读取配置后业务层按dataBlocks数组遍历逐块写入并回验。配置放产品根目录下交付时可以直接改不用动代码。生产环境建议把verifyAfterWrite默认设为true宁可慢几十毫秒也要保证数据可靠。6.3 异步改造让刷卡不卡界面WinForms里直接在按钮事件中跑完整读写流程UI会假死几百毫秒体验很差。把SendCommand从AutoResetEvent改成TaskCompletionSource发送命令后await等待收到帧时SetResult。调用侧变成await cardOperator.ReadBlockAsync(block)界面保持响应。这个改动不大但对终端体验提升明显是二期优化的第一选择。验证改造是否到位我习惯用一张空白M1卡先读卡号写入16字节测试串拔出再贴卡读回确认逐字节一致。从那以后我每次接手新读卡器都强制走一遍“串口连通性→寻卡→认证→读写回验”的完整脚本再交给上位机业务。这个习惯帮我省过不少排查乱码的时间希望帮到你。本文还有配套的精品资源点击获取