☰
C# RFID读卡器方案:串口通信与M1卡读写实战
2026/10/20 18:04:09 网站建设 项目流程

简介:这份基于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.sln

RfidLib 是类库项目,不依赖 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 号,比如 COM3;baudRate 必须和读卡器固件配置一致,改错了就是乱码。如果你手里的读卡器手册写的是 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 Task<string> 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~15;key 是 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 事件触发时机不稳定,收到几个字节就触发一次,不会刚好一整帧。如果你在事件处理函数里直接按字节流解析,就会把一帧拆成两半。

解决:像前面代码那样加一个接收缓冲池,每次收到数据先追加到 List<byte> 里,再从缓冲池里寻找完整的「帧头 + 长度 + 帧尾」结构,找到后再提取。解析完的字节从缓冲池移除,剩余字节留着等下帧拼接。

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 引进来改个串口名就能跑;对于想深入理解帧协议和扇区操作的人来说,代码里每一层抽象都值得慢慢拆开看。希望你在这套基础上改出自己的系统,少走我当年踩过的那些坑。

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

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

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

立即咨询