C# RFID读卡器方案:串口通信与M1卡读写实战

📅 发布时间:2026/10/20 18:04:19
C# RFID读卡器方案:串口通信与M1卡读写实战
简介这份基于C#的RFID卡识别与读写示例工程面向需要开发RFID应用的.NET开发者覆盖设备连接、事件驱动编程、数据解析、读写操作与错误处理等完整流程可应用于门禁系统、物流跟踪、商品防伪等场景。工程共29个文件压缩包仅66KB包含C#源文件、窗体设计文件、Visual Studio解决方案与工程文件、可执行程序及调试符号等其中Form1.cs与Form1.Designer.cs展示了图形界面与逻辑实现RFID.csproj与RFID.sln可用于直接加载编译。已有4660人学习下载。通过该工程可掌握System.IO.Ports串口通信、RFID标签数据解析、事件监听等关键技能并理解EPC Global Gen2等协议的解析思路与读写指令交互方式同时了解如何将硬件交互封装为独立类库以便业务复用是一份适合入门RFID二次开发的轻量级参考资料。1. 项目定位一套能直接上手的 C# RFID 读写方案这套基于 C# 的 RFID 卡识别与读写项目解决的是很具体的一类问题你手里有一台串口 RFID 读卡器想在自己的管理系统里完成寻卡、读卡号、写扇区数据这些操作却不想从协议文档里一个个抠命令字。我最早接触它是在某仓储项目的改造现场需要把几千张卡片批量授权到新系统里人工抄卡号根本不现实。这个 Demo 直接把读卡器封装成了一个类库把底层串口帧封装成几个方法调一下就能拿到卡片 UID也能往指定扇区写入自定义数据。它适合两类人一类是做门禁、食堂、会员系统需要把 RFID 读写功能集成到现有 C# 项目里的开发者另一类是刚接触 RFID、手里有硬件却不知道怎么把数据读出来的新手。整个工程麻雀虽小但通信帧封装、扇区寻址、密钥校验这些关键环节都有完整代码顺着读一遍你对 RFID 读写的整个链路就不再是黑匣子。2. 系统结构与通信链路从串口指令到卡片应答的完整协议栈2.1 技术选型为什么是 C# 串口读卡器先讲技术选型的逻辑。项目用的是 .NET Framework 的 WinForms读卡器是常见的 USB 转串口虚拟 COM 口设备工作在 13.56MHz 高频段。这种组合在工业现场非常普遍WinForms 开发效率高串口读卡器便宜且稳定不需要装额外驱动插上就有 COM 口。为什么不用 HID 免驱方案免驱读卡器确实方便但很多场景下只能读 UID不能写扇区数据。而 ISO14443 协议的 M1 卡也就是 Mifare Classic 1K支持分区读写你要做食堂扣费、门禁时效授权就得靠串口命令往扇区里写数据。所以串口方案不是退而求其次是功能上真正够用。另外C# 的 System.IO.Ports.SerialPort 类把串口收发封装得非常好事件驱动模型适合 WinForms 的 UI 线程模型。后台开一个线程持续读串口收到完整帧就抛事件界面不会卡。这个项目用的就是这种设计不是简单粗暴地 Sleep 后 ReadExisting。2.2 目录结构一个类库项目带一个 WinForms Demo拿到源码包后目录结构是这样的RfidSolution/ ├── RfidLib/ │ ├── Reader.cs # 串口封装发送接收帧 │ ├── CardCommand.cs # 命令字定义与帧构建 │ ├── CardData.cs # 扇区/块地址计算与数据模型 │ └── ReaderEvent.cs # 事件参数定义 ├── RfidDemo/ │ ├── MainForm.cs # 主窗体操作界面 │ ├── MainForm.Designer.cs │ └── Program.cs └── RfidLib.slnRfidLib 是类库项目不依赖 UI可以直接被你自己的 WinForms、WPF 甚至控制台程序引用。RfidDemo 是使用示例展示了如何实例化 Reader、订阅事件、发送命令。这个分层最大的好处是如果你要做二次开发只需要把 RfidLib 引到自己的项目里把串口名改一下就能用不必把 UI 逻辑和通信逻辑捆在一起。2.3 通信帧格式读卡器与 PC 之间交换的是什么串口通信的第一步不是写代码而是搞清楚帧格式。不同厂商的读卡器协议不同但大体都是「帧头 命令字 数据 校验 帧尾」的结构。这个项目用的协议格式如下字段长度说明帧头1 字节固定 0xAA命令字1 字节0x01 寻卡0x02 读块0x03 写块数据长度1 字节数据字段的字节数数据N 字节命令参数或返回数据校验1 字节前面所有字节异或值帧尾1 字节固定 0x55理解了这个格式读卡器在你的眼里就不再是黑匣子了。你发送 12 个字节给读卡器它返回一串字节你按帧头帧尾截断、校验、解析就得到了卡片 UID 或者块数据。后面所有代码都绕不开这套帧结构。3. 核心模块拆解寻卡、读块、写块的代码实现3.1 串口初始化参数不对就是乱码和超时串口初始化是整个项目里最容易翻车的点。波特率、数据位、停止位、校验位任何一个不对读卡器返回的就是乱码或者干脆没响应。这个项目默认配置是波特率 57600、数据位 8、停止位 1、无校验这是很多串口读卡器的出厂默认值。public void Initialize(string portName, int baudRate 57600) { _serial new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); _serial.DataReceived new SerialDataReceivedEventHandler(DataReceivedHandler); _serial.Open(); } private void DataReceivedHandler(object sender, SerialDataReceivedEventArgs e) { int bytesToRead _serial.BytesToRead; byte[] buffer new byte[bytesToRead]; _serial.Read(buffer, 0, bytesToRead); AppendToReceiveBuffer(buffer); }这里有两个细节值得注意。第一DataReceived 事件在后台线程触发收到的数据要先存到一个公共缓冲池里等待攒齐一帧再解析不能收到几个字节就马上当完整帧处理。第二串口读卡器返回数据很快但 Windows 串口驱动的缓冲区是分段的一帧命令的响应可能被拆成两三次 DataReceived 事件触发。所以要做缓冲池而不是一次性读取。参数说明portName 就是设备管理器里的 COM 号比如 COM3baudRate 必须和读卡器固件配置一致改错了就是乱码。如果你手里的读卡器手册写的是 9600就把这里改成 9600别的都不用动。3.2 命令封装把功能码变成可发送的字节帧有了串口基础下一步就是把「我要读卡号」翻译成 12 个字节发出去。这里我一般会把帧构建封成一个统一的方法所有命令都走同一个通道避免每写一个功能就复制一遍组帧代码。public byte[] BuildFrame(byte command, byte[] data null) { data data ?? new byte[0]; int length data.Length 4; // 命令字 数据长度 校验 帧尾 byte[] frame new byte[length 1]; // 加帧头 frame[0] 0xAA; frame[1] command; frame[2] (byte)data.Length; Array.Copy(data, 0, frame, 3, data.Length); byte crc 0; for (int i 0; i frame.Length - 2; i) crc ^ frame[i]; frame[frame.Length - 2] crc; frame[frame.Length - 1] 0x55; return frame; }逻辑说明先算数据区长度然后按 帧头-命令字-长度-数据-校验-帧尾 的次序填充字节数组。校验用的是异或把除了校验和帧尾之外的每个字节逐个异或一遍得到一个字节。这个算法简单但有效对串口传输的单比特翻转非常敏感。参数说明command 是命令字0x01 寻卡、0x02 读块、0x03 写块data 是命令所需的参数比如读块命令需要块地址写块命令需要块地址加 16 字节数据。你只需要关注这两层抽象帧的细节被封装在内部这是读卡器集成项目里非常自然的做法。3.3 寻卡与读卡号返回帧怎么解析寻卡命令是 RFID 操作的第一步目的是让读卡器感应区内的卡片返回它的 UID。这个操作在工程上叫 Anticollision防冲突因为感应区内可能有多张卡读卡器需要先选中一张。public async Taskstring RequestCardUidAsync() { byte[] frame BuildFrame(0x01); // 寻卡命令无数据参数 _serial.Write(frame, 0, frame.Length); byte[] response await WaitForFrameAsync(0x01, TimeSpan.FromSeconds(2)); if (response null) return null; // 响应数据卡片类型(1字节) UID长度(1字节) UID(N字节) int uidLength response[1]; byte[] uid new byte[uidLength]; Array.Copy(response, 2, uid, 0, uidLength); return BitConverter.ToString(uid).Replace(-, ); }这里有一个明显的坑串口收到完整帧需要时间所以发送命令后必须异步等待完整响应帧而不是同步读。WaitForFrameAsync 内部会轮询缓冲池按命令字匹配等到超时或者数据满足条件才返回。超时时间设 2 秒因为读卡器寻卡通常 50ms 内就应答了超过 2 秒基本可以判定卡没放好或者命令格式错了。参数说明返回的 UID 是 4 字节或 7 字节十进制序列在门禁系统里通常作为持卡人的唯一标识存到数据库。注意M1 卡有 4 字节和 7 字节两种 UID 长度代码里要先读长度字段再取 UID不能写死长度。3.4 读块与写块M1 卡的扇区寻址与密钥校验M1 卡内部有 16 个扇区每个扇区 4 个块每个块 16 字节。但读写之前必须先做密钥认证否则读卡器会返回错误码。public bool Authenticate(byte sector, byte keyType, byte[] key) { // 扇区号转块地址每个扇区4个块块0是厂商固件区 byte block (byte)(sector * 4 3); byte[] data new byte[] { block, keyType, key[0], key[1], key[2], key[3], key[4], key[5] }; byte[] frame BuildFrame(0x04, data); // 0x04 是认证命令 _serial.Write(frame, 0, frame.Length); byte[] response WaitForFrame(0x04, TimeSpan.FromSeconds(2)); return response ! null response[0] 0x00; } public byte[] ReadBlock(byte block) { byte[] frame BuildFrame(0x02, new byte[] { block }); _serial.Write(frame, 0, frame.Length); byte[] response WaitForFrame(0x02, TimeSpan.FromSeconds(2)); if (response null || response[0] ! 0x00) return null; byte[] blockData new byte[16]; Array.Copy(response, 1, blockData, 0, 16); return blockData; }逻辑说明认证命令里的 keyType 是 0x60 或 0x61分别对应密钥 A 和密钥 B。M1 卡出厂默认是全长 F 的密钥但很多场景下系统管理员会修改密钥这时候你必须先用旧密钥认证才能再写新密钥。块地址的计算是一切的基础扇区 N 的第一个块是 N*4但注意每个扇区的最后一个块是控制块存密钥和访问条件不是普通数据块别往那里写业务数据。参数说明sector 范围 0~15key 是 6 字节数组block 在 0~63 之间。很多新手把扇区号和块号混在一起导致明明读到了 UID 却读写不了数据多半就是地址算错了。4. 批量授权实战从读取 UID 到写入扇区数据的完整流程4.1 批量发卡的入门流程这个项目不仅做了单个读卡操作还演示了批量发卡的完整流程类似于某高校图书馆的门禁授权场景。流程分三步第一步放卡第二步读 UID 并展示到界面上第三步输入持卡人编号、有效截止日期写入扇区 1 的块 4 和块 5。第三步为什么要写扇区 1 而不是扇区 0因为扇区 0 的块 0 是厂商固件区不能改写。扇区 0 的剩余三个块虽然可以写但考虑到数据隔离和后续扩展把业务数据放在扇区 1 是更合理的做法。这样 扇区 0 保持出厂状态将来要做卡片类型的区分也可以靠读厂商区判断。4.2 从 UID 到扇区写入的完整代码理解了流程直接看 Demo 写操作的实现。这里把认证、读块、写块串成了一个完整的方法。public bool WriteUserDataToCard(string uid, string userId, DateTime expireDate) { try { // 1. 寻卡确认还在感应区 string currentUid RequestCardUidAsync().Result; if (string.IsNullOrEmpty(currentUid) || !currentUid.Equals(uid, StringComparison.OrdinalIgnoreCase)) { Log(读卡不一致请重新放卡); return false; } // 2. 用密钥 A 认证扇区 1 byte[] defaultKey new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }; if (!Authenticate(1, 0x60, defaultKey)) { Log(密钥认证失败扇区可能被改过密钥); return false; } // 3. 构造写入帧块4存用户ID块5存过期日期 byte[] userBytes Encoding.ASCII.GetBytes(userId.PadRight(16, \0)); byte[] expireBytes Encoding.ASCII.GetBytes(expireDate.ToString(yyyyMMdd).PadRight(16, \0)); WriteBlock(4, userBytes); WriteBlock(5, expireBytes); Log($卡片 {uid} 授权成功); return true; } catch (Exception ex) { Log($写入异常: {ex.Message}); return false; } }这段代码里最关键的一点是写入前主动寻了一次卡确认感应区内的卡和你要写的是同一张。这个验证在真实场景中必须做因为用户可能拿到一张新卡替换了原来的旧卡如果你不校验就会把数据写到错误的卡上。数据格式上用户 ID 和过期日期都固定按 16 字节存储不足的部分补 \0读出来的时候再 Trim 掉即可。参数说明userId 是持卡人编号比如「20190001」expireDate 是过期时间业务上每次刷卡时读出来和系统时间比较就能决定是否放行。这种设计避免了刷卡时频繁查数据库卡片本身就是一个轻量级凭证。4.3 失败重试与日志记录批量授权最怕写到一半断电或者卡被拿走。这个 Demo 在循环发卡时每张卡最多重试 3 次每次重试前先重新寻卡失败后记录日志并弹窗提示不会静默跳过。public void BatchGrant(string[] userIds, string[] expireDates, int retryCount 3) { foreach (var userId in userIds) { bool success false; for (int attempt 1; attempt retryCount; attempt) { string uid RequestCardUidAsync().Result; if (string.IsNullOrEmpty(uid)) { Log($第 {attempt} 次尝试未检测到卡片); Thread.Sleep(1000); continue; } success WriteUserDataToCard(uid, userId, expireDates[0]); if (success) break; } if (!success) { Log($卡片授权失败用户编号 {userId}需手动处理); // 写入待处理队列后续重新发起授权 } } }为什么要独立处理失败而不直接抛异常因为批量发卡的过程中卡片接触不良是常态一张卡失败不应该中断整个批次。失败卡片进待处理队列操作结束后再单独补发一次这才是现场能用的逻辑。5. 常见问题排查协议对得上却读不出的踩坑记录5.1 现象一串口能打开但发送命令永远没有响应这是我遇到的第一个坑。现象是 SerialPort.Open 不报错命令发出去了读卡器一个字节都不回。原因读卡器的 USB 转串口芯片在电脑上枚举出的虚拟串口号和设备实际使用的物理串口号不是同一个。多数读卡器出厂默认通信串口是 COM3但如果你电脑上接了其他 USB 转串口设备Windows 可能把读卡器分配成 COM5。解决打开设备管理器拔插读卡器 USB看哪个新出现的 COM 口就是它。把这个口填到程序里。从那以后我每次接手新读卡器都强制走一遍「拔插 — 看设备管理器 — 确认 COM 号」的过程插着 USB 却不核对口号就写代码纯属给自己埋雷。5.2 现象二发送命令后偶尔收到完整帧偶尔收到半截现象是 10 次读卡有 8 次正常2 次返回的数据解析不出来。原因DataReceived 事件触发时机不稳定收到几个字节就触发一次不会刚好一整帧。如果你在事件处理函数里直接按字节流解析就会把一帧拆成两半。解决像前面代码那样加一个接收缓冲池每次收到数据先追加到 Listbyte 里再从缓冲池里寻找完整的「帧头 长度 帧尾」结构找到后再提取。解析完的字节从缓冲池移除剩余字节留着等下帧拼接。5.3 现象三寻卡模块卡死界面无响应现象是程序启动后第一次寻卡正常第二次开始整个窗体卡住点击按钮没有反应。原因同步等待响应帧的代码写在 UI 线程里。发送命令后UI 线程阻塞在 WaitForFrame 循环里一直等到超时中间的串口事件虽然触发了但由于 UI 线程被占住数据无法及时处理。这其实是 WinForms 开发里最经典的线程问题。解决所有串口发送操作放到 Task.Run 里或者使用 async/await 模式。UI 线程只负责显示结果。这个 Demo 代码里用的是 asyncTask 模式你如果把它改成同步版本放到按钮点击事件里100% 会复现这个问题。5.4 现象四密钥认证总是失败但确定密钥是正确的现象是使用了正确的 6 字节密钥认证命令却返回非零错误码。原因大多数 M1 卡出厂默认已经有密钥你可以用默认密钥读出所有扇区。但如果这张卡之前被其他系统改过密钥出厂密钥就失效了。更隐蔽的问题是认证命令的操作顺序错乱必须先选定扇区再认证最后读写块。有人把 sel 命令和认证命令合并成一个命令发出读卡器就翻脸。解决严格按「寻卡 — 选定扇区 — 认证 — 读写块」的顺序分步发送每一步等上一步返回成功后再发送下一条。卡内密钥是否被改过可以用软件尝试读取 16 个扇区的密钥字节来确认。5.5 现象五写块成功但读回的数据和写入的不一样现象是 WriteBlock 返回成功紧接着 ReadBlock 读回来的却是全 F 或者全 0。原因写操作的对象是扇区尾块每扇区第 4 个块这个块也叫控制块它的数据结构不是普通数据而是密钥 A、访问控制位、密钥 B 的组合。往里写业务数据会破坏访问条件导致该扇区变得不可读或被格式化成出厂状态。解决代码里已经做了块地址映射把扇区号乘 4 再加偏移但你要记住业务数据只写块 0、块 1、块 2绝不写块 3。如果确实需要改密钥或访问条件请先完整理解 M1 卡的访问控制位逻辑再动手否则扇区很可能直接报废。6. 二次开发思路从单体 Demo 走向多读卡器服务如果你要把它用到生产环境我的建议是把 RfidLib 里的核心逻辑再往上抽象一层做成一个后台服务而不是一个 WinForms 窗体。第一个改造点是把串口操作封装成独立的 RfidService支持多读卡器实例管理。每个读卡器一个 Reader 对象用 Dictionary 以 COM 口号为键管理起来。这样你在一个车间部署 8 台发卡终端时每台终端只需要配置不同的 COM 口共用同一套业务逻辑。第二个改造点是数据落库。Demo 里授权数据直接写进卡片扇区这是不够的生产环境必须同时写数据库不然卡片丢了就无法补发数据库里应该记录 uid、持卡人编号、密钥版本、发卡时间、操作员。这样出了问题还能追溯是硬件故障还是人为写错。第三个改造点是卡片密钥管理。Demo 用的是出厂默认密钥这在开发测试阶段没问题但真实环境没人用默认密钥。我一般会做一个密钥管理的页面支持每个扇区独立设置密钥并支持密钥备份与恢复。注意备份的密钥文件要用加密方式保存因为密钥泄露等于门禁失效。最后一个建议是不管你怎么改一定要保留一个硬件在环的自动化验证脚本。我自己的习惯是每次改完代码不打开窗体而是用一个控制台程序自动跑一遍「寻卡 — 认证 — 写块 — 回读比对」的完整性流程全部通过再交出去。这套流程能把你从大量的手工点击测试里解放出来也能在回归测试时第一时间暴露问题。这个项目本身不长但它把 RFID 读写的关键链路完整地走了一遍。对于想快速集成 RFID 能力的人来说把 RfidLib 引进来改个串口名就能跑对于想深入理解帧协议和扇区操作的人来说代码里每一层抽象都值得慢慢拆开看。希望你在这套基础上改出自己的系统少走我当年踩过的那些坑。本文还有配套的精品资源点击获取