news 2026/11/2 19:08:41

C#实现RFID卡识别与M1扇区读写工具

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C#实现RFID卡识别与M1扇区读写工具

简介:这份压缩包面向希望用 C# 完成 RFID 卡识别与读写实践的开发者,聚焦串口通信、事件驱动、协议解析、标签读写与异常处理等关键环节,适合作为门禁、物流跟踪、商品防伪等场景的入门示例与课程练习参考。包内共 29 个文件,以 6 个 cs 源码文件为核心,配合 sln、csproj 等工程配置,以及 exe、pdb、resources、resx 等编译与界面资源,整体约 66KB,体积轻巧,便于快速打开、编译和调试。资源以小型桌面工程形态呈现,便于对照代码理解从连接设备到处理返回数据的完整链路。已有 4664 人学习,说明该主题具备一定关注度。读者可据此了解 C# 与 RFID 阅读器交互的基本结构,观察数据接收、解析和读写命令组织方式,并借鉴错误处理与界面展示思路;也可将硬件交互部分抽离为类库,为后续替换设备或扩展业务保留空间。

1. 需求先落地:为什么RFID卡识别和读写要单独做成一个C#桌面程序

某实验室的门禁与耗材领用改造,最初买了一套成品一体机,卡能刷、门能开,但卡号只存在厂商后台里,导出的字段固定,想按工号绑定、想记录领用明细、想把读写日志接进自己的数据库,全被卡住。最后决定自己用 C# 写一个 RFID 卡识别与读写工具:既能读卡号做人员绑定,也能对 M1 卡做扇区级读写,把数据落在自己可控的库里。这套方案适合正在做相关毕设、课程设计的学生,也适合需要快速搭一套读写验证工具交付现场的从业者。它不追求做成商用门禁平台,追求的是链路清晰、每一条指令都能在代码里找到出处、出问题时能定位到是硬件、协议还是业务层。

2. 硬件与协议选型:三类卡片、三种链路怎么对上号

选型这一步翻车的人最多,因为不少人拿到一张卡就开始写代码,写到一半才发现这张卡根本不能被写。RFID 项目里,"读"和"读写"是两种完全不同的需求,卡型和读写器的能力必须先在纸面上对齐。

2.1 先分清卡型:M1、CPU 卡、ID 卡的差异决定功能边界

常见的三类卡,能力差别很大。

卡类型工作频率存储结构能否写典型用途
ID 卡(EM4100 类)125 kHz出厂固化只读序列号不能写考勤打卡、简单门禁
M1 卡(Mifare Classic 1K/4K)13.56 MHz16 扇区 × 4 块 × 16 字节可读写,需密钥认证一卡通、储值、领用记录
CPU 卡(ISO14443A/B)13.56 MHz内部 COS 文件系统可写,走 APDU 指令金融、社保、高安全场景

M1 1K 卡的结构必须记牢,后面所有读写代码都是围绕这张表写的:

区域块号内容
第 0 扇区 第 0 块块 0厂商块,存 UID、BCC、SAK、ATQA,只读
第 0 扇区 第 1~2 块块 1、2数据块,可读写
第 0 扇区 第 3 块块 3控制块:KeyA(6) + 访问位(4) + KeyB(6)
第 1~15 扇区每扇区 4 块前 3 块数据,第 4 块仍是控制块

判断手上是什么卡,通常的做法是先发寻卡指令,看返回的卡类型字段;读不到类型字段的读写器,就用 UID 长度辅助判断——4 字节 UID 大概率是 M1,7 或 10 字节更可能是 CPU 卡。这个判断不是绝对可靠的,所以程序里不要把卡型写死,留成可配置项。ID 卡最容易被误判,因为它压根不响应 13.56 MHz 的寻卡命令,表现为"读写器一直没反应",很多人第一反应是驱动坏了,其实是频率不对。

提示:项目立项时先拿三张实物卡——一张 ID、一张 M1、一张 CPU——挨个测一遍。这一步花二十分钟,能省后面两天。

2.2 三种通信链路的取舍:串口、USB HID、以太网

读写器和上位机之间的链路,主流就三种。

串口(RS232 / RS485 / USB 转串口)是最通用的选择。指令集是透传的,厂商手册写什么就发什么,调试的时候拿串口助手就能看到原始字节,出问题能一层层剥。代价是你要自己处理粘包、半包、超时重发,这部分代码后面第 4 章会完整给出来。RS485 还能一条总线上挂多台读写器,用设备地址区分,适合多点位部署。

USB HID 的最大优势是免驱,插上就出一个"键盘口",卡一贴,卡号直接当成键盘输入打进焦点窗口。缺点是它基本只能拿到厂商封装好的卡号,扇区级读写做不了,而且焦点窗口一丢就收不到数据。只做"读卡号"的场景用它最省事,要做块读写就别选了。

以太网(TCP Client/Server)适合读写器分散、距离远、或者要走现有网络的场合。多一道网络层,就多一层要处理的东西:心跳保活、断线重连、粘包。它的粘包处理和串口是同一套思路,只是底层换成了 Socket。

链路开发量能否做块读写多点组网现场调试难度
串口中能RS485 可以低,串口助手可直读
USB HID低一般不能不方便中,看不到原始字节
以太网中高能方便中,需要抓包

这个项目的实际选型是串口为主。理由是读写指令需要可视、可复现,答辩或者交付时能当场拿串口助手对着日志复现一帧数据,说服力比什么都强。

2.3 开发环境与依赖:.NET 版本、串口库与数据存储

框架选 .NET Framework 4.7.2 还是 .NET 6/8,取决于部署环境。实验室里还有一批 Windows 7 的老机器,那就老老实实 4.7.2;新机器一律 .NET 8 + WinForms 或 WPF。两个版本的代码 95% 是一样的,差别主要在System.IO.Ports的引用方式——.NET Core 之后串口被拆成独立的 NuGet 包,需要显式安装。

依赖清单控制得很克制,越少越好部署:

<!-- 项目文件里的关键 PackageReference --> <PackageReference Include="System.IO.Ports" Version="8.0.0" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" /> <PackageReference Include="NLog" Version="5.2.8" />

串口用官方System.IO.Ports而不用第三方封装,是为了降低部署时的 DLL 依赖;数据库用 SQLite 而不是装一整套 SQL Server,是因为这个场景的数据量在十万级以内,SQLite 单文件拷走就能备份,答辩现场也不用担心数据库服务没起来。日志用 NLog 按天切分,读写指令的原始十六进制尽量都记下来——现场排查时这些字节就是唯一的黑匣子。

UI 不引第三方控件库,用原生 DataGridView 加 BindingSource 就够。少一个依赖,就少一个在别人电脑上跑不起来的理由。

3. 总体架构设计:四层结构与四个核心模块的职责划分

分层的目的是让"换一台读写器"这件事只影响一个文件,而不是把整个工程翻一遍。这个项目按四层切,每层职责单一。

3.1 四层职责:表现层、业务层、设备通信层、数据层

表现层只做三件事:展示、收集输入、把结果画出来。它不碰串口对象,不拼字节数组,也不写 SQL。界面上的"连接设备""读卡""写入"按钮,点击后一律调业务层的方法,拿到结果对象再渲染。

业务层是整条链路的中枢,负责编排:收到一帧数据后判断是寻卡响应还是读块响应,决定下一步发认证还是发读块,读到的字节怎么解析成卡号,解析完要不要落库、要不要去重。它依赖设备通信层的接口和数据层的仓储接口,但不依赖它们的具体实现。

设备通信层封装了串口、帧编解码、超时重试、设备状态,对外只暴露"发一条命令,等一个响应"这样的语义。这一层是整个项目最容易出 Bug 的地方,也是唯一一个需要跟着读写器型号改的地方。

数据层负责 SQLite 的建表、插入、查询和事务。它只管数据,不关心数据是从串口来的还是从文件导入的。

四层之间的数据流是单向的:界面 → 业务 → 通信/数据 → 返回结果对象 → 界面。中间没有反向回调穿透,除了"卡片到达"这个事件。

3.2 卡片与指令的抽象:把厂商差异关进一个类

不同厂商的读写器,指令字不一样、校验算法不一样、响应格式也不一样。如果把这些散落在业务代码里,换一台设备就是灾难。标准的做法是定义一个接口,把所有厂商相关的细节关进实现类。

/// <summary>读写器统一抽象,业务层只认这个接口</summary> public interface ICardReader : IDisposable { string DeviceName { get; } bool IsOpen { get; } bool Open(); void Close(); /// <summary>寻卡并返回卡片信息,未寻到返回 null</summary> CardInfo RequestCard(int timeoutMs = 500); /// <summary>对指定扇区做密钥认证</summary> bool AuthSector(byte sector, byte[] key, bool useKeyA); /// <summary>读一个数据块,返回 16 字节;失败返回 null</summary> byte[] ReadBlock(byte blockAddr, int timeoutMs = 500); /// <summary>写一个数据块,内部必须拦截控制块</summary> bool WriteBlock(byte blockAddr, byte[] data16); event EventHandler<CardEventArgs> CardArrived; } /// <summary>卡片信息模型,UID 统一用大写十六进制字符串</summary> public class CardInfo { public string Uid { get; set; } // 例:"A1B2C3D4" public string CardType { get; set; } // "M1" / "CPU" / "ID" public int UidLength { get; set; } // 字节数,用于卡型辅助判断 public DateTime FirstSeen { get; set; } } public class CardEventArgs : EventArgs { public CardInfo Card { get; set; } }

把SerialPortCardReader作为ICardReader的一个实现,业务层用构造注入拿接口。这样以后接以太网读写器,只需要再写一个TcpCardReader,业务层一行不改。这套抽象在答辩时也是个加分项——面试官或者老师最爱问的就是"你这套代码换设备怎么办"。

CardInfo.Uid用字符串而不是字节数组,是因为卡号最终要入库、要显示、要跟工号绑定,字符串在整个链路里最省心。转成字符串时统一ToString("X2")拼成大写,避免大小写混用导致查库查不到。

3.3 线程模型:DataReceived 不能干重活

SerialPort.DataReceived事件是在一个后台线程池线程上触发的,这个线程很宝贵。如果在里面做数据库写入、弹窗、或者任何超过几十毫秒的事情,串口接收缓冲区就会溢出,表现为"偶发丢帧",而且是那种一天出一次、复现不了的类型。

正确的做法是把DataReceived里的工作量压到最小:读字节、丢进缓冲区、拼帧、把完整帧塞进队列,然后立刻返回。所有解析、落库、界面更新都在另一个消费线程上做。

// 生产者:串口线程只负责收字节和拼帧 private readonly BlockingCollection<byte[]> _frameQueue = new(new ConcurrentQueue<byte[]>(), 256); private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { var sp = (SerialPort)sender; int n = sp.BytesToRead; if (n <= 0) return; var tmp = new byte[n]; sp.Read(tmp, 0, n); lock (_rxBuffer) _rxBuffer.AddRange(tmp); // 接收缓冲,处理粘包 ExtractFrames(); // 只做拼帧,不做业务 } // 消费者:独立后台线程,串行处理每一帧 private void ConsumeLoop(CancellationToken token) { foreach (var frame in _frameQueue.GetConsumingEnumerable(token)) { try { _dispatcher.Handle(frame); // 业务层根据命令字分发 } catch (Exception ex) { _logger.Error(ex, "帧处理异常: {0}", BitConverter.ToString(frame)); } } }

BlockingCollection的容量设成 256,是为了防止消费线程卡住时内存无限增长。投递时用TryAdd而不是Add,队列满了就记一条日志丢帧,宁可丢也不能让串口线程阻塞——串口线程一旦阻塞,后面的帧会全部积压在驱动缓冲区里,等消费线程恢复时一次性涌进来,时间戳全乱。

界面更新这一侧,消费者线程不能直接改控件属性,必须回到 UI 线程:

// 消费线程里更新界面,必须回到 UI 线程 private void OnCardHandled(CardInfo card) { if (this.IsHandleCreated && !this.IsDisposed) this.BeginInvoke(new Action(() => RenderCard(card))); }

用BeginInvoke而不是Invoke,是因为Invoke会同步等待 UI 线程处理完才返回,如果 UI 线程此刻正忙(比如在弹一个模态框),消费线程就会挂住,队列跟着积压。BeginInvoke是异步投递,投完就走。

4. 核心链路实现:组帧、寻卡、认证读写到落库的完整代码路径

这一章把整条链路拆成四段,每段给关键代码和参数说明。不同型号读写器的指令字和校验算法不一样,这里给的是一种很常见的自定义帧格式,接具体设备时按手册替换命令字和校验算法即可,骨架是不变的。

4.1 帧的组装与校验:长度域、命令字与CRC

自定义帧格式是55 AA | LEN | CMD | DATA... | CRC_H CRC_L。55 AA是帧头,用于在一串字节里定位帧的起点;LEN是长度域,等于"命令字 1 字节 + 数据域 N 字节 + 校验 2 字节",接收方靠它判断这一帧有多长;CMD是命令字;后面的数据域长度由命令字决定;最后两字节是 CRC16-CCITT,初始值 0xFFFF,多项式 0x1021。

public static class FrameCodec { public const byte Head0 = 0x55; public const byte Head1 = 0xAA; /// <summary>组帧:帧头 + 长度 + 命令字 + 数据 + CRC16</summary> public static byte[] Build(byte cmd, params byte[] data) { int dataLen = data?.Length ?? 0; var frame = new byte[4 + dataLen + 2]; frame[0] = Head0; frame[1] = Head1; frame[2] = (byte)(dataLen + 3); // 命令字1 + 数据N + 校验2 frame[3] = cmd; if (dataLen > 0) Buffer.BlockCopy(data, 0, frame, 4, dataLen); // 校验范围:从长度域开始,算到数据域末尾 ushort crc = Crc16(frame, 2, dataLen + 2); frame[4 + dataLen] = (byte)(crc >> 8); frame[5 + dataLen] = (byte)(crc & 0xFF); return frame; } /// <summary>CRC16-CCITT,初值 0xFFFF,多项式 0x1021</summary> public static ushort Crc16(byte[] buf, int offset, int len) { ushort crc = 0xFFFF; for (int i = offset; i < offset + len; i++) { crc ^= (ushort)(buf[i] << 8); for (int b = 0; b < 8; b++) crc = (crc & 0x8000) != 0 ? (ushort)((crc << 1) ^ 0x1021) : (ushort)(crc << 1); } return crc; } }

长度域写成dataLen + 3而不是dataLen + 2,是因为它自己不算自己,但命令字和两个校验字节都要算进去。这个"长度域到底含不含自己"的问题是组帧时最典型的错误来源,一旦算错,接收方永远等不到完整帧。参数说明里之所以强调 CRC 的起点是offset = 2,是因为帧头两个字节不参与校验,从长度域开始才算。

接收侧必须处理粘包和半包。做法是维护一个_rxBuffer,每次收到新字节就追加,然后循环从缓冲区头部找帧头:找不到就清空;找到但长度不够就等待下一次数据;长度够且校验通过就取出这一帧,剩下的字节留着继续找。

private void ExtractFrames() { while (true) { int idx = -1; // 1. 在缓冲区里找帧头 55 AA for (int i = 0; i < _rxBuffer.Count - 1; i++) if (_rxBuffer[i] == 0x55 && _rxBuffer[i + 1] == 0xAA) { idx = i; break; } if (idx < 0) { _rxBuffer.Clear(); return; } // 全是垃圾数据,丢掉 if (idx > 0) _rxBuffer.RemoveRange(0, idx); // 帧头之前的字节丢掉 if (_rxBuffer.Count < 4) return; // 连长度域都没收全 int len = _rxBuffer[2]; int total = 2 + 1 + len; // 帧头2 + 长度1 + 长度域覆盖的内容 if (_rxBuffer.Count < total) return; // 半包,等下一次 DataReceived var frame = _rxBuffer.GetRange(0, total).ToArray(); _rxBuffer.RemoveRange(0, total); // 2. 校验,不通过直接丢,不抛异常 ushort crc = FrameCodec.Crc16(frame, 2, len); if (frame[total - 2] != (byte)(crc >> 8) || frame[total - 1] != (byte)(crc & 0xFF)) continue; // 3. 投递给消费线程,投不进去也不能阻塞串口线程 if (!_frameQueue.TryAdd(frame)) _logger.Warn("帧队列已满,丢弃一帧: {0}", BitConverter.ToString(frame)); } }

_rxBuffer的清理逻辑很关键:找不到帧头就直接清空,说明之前收到的全是不完整的干扰字节,留着只会让缓冲区越来越脏。校验失败的帧用continue而不是return,因为后面可能还有合法帧在缓冲区里,丢一个继续找下一个。

4.2 寻卡流程:从寻卡命令到拿到 UID

寻卡在读写器这一层通常是一步完成的——读写器内部已经做完了防冲突和选卡,上位机发一条寻卡命令,返回的就是 4 字节 UID 加卡类型。真正的请求-响应时序是:发命令 → 等响应 → 超时就重发,重发三次还不响应就报故障。

/// <summary>寻卡:命令字 0x10,数据域为空,响应首字节 0x00 表示成功</summary> public CardInfo RequestCard(int timeoutMs = 500) { var resp = SendAndWait(0x10, Array.Empty<byte>(), timeoutMs, retry: 3); if (resp == null || resp.Length < 6 || resp[0] != 0x00) return null; // 响应格式:状态(1) + 卡类型(1) + UID长度(1) + UID(n) byte cardType = resp[1]; int uidLen = resp[2]; if (resp.Length < 3 + uidLen) return null; var uid = new byte[uidLen]; Buffer.BlockCopy(resp, 3, uid, 0, uidLen); return new CardInfo { Uid = BitConverter.ToString(uid).Replace("-", ""), // 转成 "A1B2C3D4" CardType = cardType == 0x01 ? "M1" : (cardType == 0x02 ? "CPU" : "ID"), UidLength = uidLen, FirstSeen = DateTime.Now }; } /// <summary>发送并等待特定命令字的响应,带超时和重试</summary> private byte[] SendAndWait(byte cmd, byte[] data, int timeoutMs, int retry = 1) { for (int attempt = 1; attempt <= retry; attempt++) { var frame = FrameCodec.Build(cmd, data); lock (_writeLock) _port.Write(frame, 0, frame.Length); // 写必须串行 var sw = Stopwatch.StartNew(); while (sw.ElapsedMilliseconds < timeoutMs) { if (_responseSlot.TryTake(out var resp, 50) && resp[3] == cmd) return resp.Skip(4).ToArray(); // 去掉帧头和命令字 } } _logger.Warn("命令 0x{0:X2} 重试 {1} 次仍未响应", cmd, retry); return null; }

几个参数值得解释。timeoutMs给 500 毫秒,是因为寻卡本身应该在 100 毫秒内完成,超时设长了只会让界面卡顿。retry = 3是现场经验值——现场电磁环境复杂的时候,单次寻卡失败很正常,重试三次能把成功率拉到可接受范围,但重试次数再多就会拖慢交互节奏。_writeLock是必须的,因为串口写操作不是线程安全的,多线程同时写会导致帧字节交错,发出去的就是一坨垃圾。

_responseSlot是一个单槽的BlockingCollection,由消费线程在分发时把响应投进去,SendAndWait在这里取。用单槽而不是队列,是为了防止历史响应堆积导致后续命令错配——每次发命令前先清空这一槽,保证拿到的肯定是这次命令的响应。

4.3 密钥认证与块读写:扇区结构必须记牢

M1 卡读数据前必须先认证。认证的对象是整个扇区,不是单个块:认证通过后,这个扇区下的四个块都"解锁"了,可以直接读。默认密钥是六个 0xFF,实际部署时肯定要改。

/// <summary>密钥认证:命令字 0x20,数据域 = 扇区号(1) + 密钥类型(1) + 密钥(6)</summary> public bool AuthSector(byte sector, byte[] key, bool useKeyA = true) { if (key == null || key.Length != 6) throw new ArgumentException("M1 密钥必须 6 字节"); var payload = new byte[8]; payload[0] = sector; payload[1] = (byte)(useKeyA ? 0x60 : 0x61); // 0x60 认证 KeyA,0x61 认证 KeyB Buffer.BlockCopy(key, 0, payload, 2, 6); var resp = SendAndWait(0x20, payload, 800); return resp != null && resp[0] == 0x00; } /// <summary>读块:命令字 0x21,数据域 = 块地址(1),响应 = 状态(1) + 16字节</summary> public byte[] ReadBlock(byte blockAddr, int timeoutMs = 500) { var resp = SendAndWait(0x21, new[] { blockAddr }, timeoutMs); if (resp == null || resp.Length < 17 || resp[0] != 0x00) return null; var data = new byte[16]; Buffer.BlockCopy(resp, 1, data, 0, 16); return data; } /// <summary>写块:命令字 0x22,数据域 = 块地址(1) + 16字节数据</summary> public bool WriteBlock(byte blockAddr, byte[] data16) { if (data16 == null || data16.Length != 16) throw new ArgumentException("块数据必须正好 16 字节"); if (IsProtectedBlock(blockAddr)) throw new InvalidOperationException($"块 {blockAddr} 是厂商块或控制块,禁止写入"); var payload = new byte[17]; payload[0] = blockAddr; Buffer.BlockCopy(data16, 0, payload, 1, 16); var resp = SendAndWait(0x22, payload, 1500); // 写操作超时给宽一点 return resp != null && resp[0] == 0x00; } /// <summary>1K 卡每扇区 4 块,块号 % 4 == 3 是控制块;块 0 是厂商块</summary> private static bool IsProtectedBlock(byte blockAddr) => blockAddr == 0 || (blockAddr % 4) == 3;

WriteBlock里那行IsProtectedBlock判断,是整个项目里最不能省的一行代码,原因在第 5 章展开。这里的逻辑是:块 0(厂商块)绝对只读,写它会直接失败甚至损坏卡片;每扇区最后一块(块号 % 4 == 3)是控制块,写错访问位会把整个扇区锁死,而且是不可逆的。

AuthSector里的 0x60/0x61 不是随意的数字,是 ISO14443 里 KeyA 和 KeyB 的认证指令码,很多读写器直接透传这个值,所以这里保留原值最不容易出错。

实际读一整个扇区的数据,是把认证加四次读块串起来:

/// <summary>读取整个扇区的三个数据块,返回 48 字节</summary> public byte[] ReadSector(int sector, byte[] key) { if (sector < 0 || sector > 15) throw new ArgumentOutOfRangeException(nameof(sector)); if (!AuthSector((byte)sector, key)) return null; // 认证失败就停,别硬读 var result = new byte[48]; for (int i = 0; i < 3; i++) // 只读前 3 块,跳过控制块 { int blockAddr = sector * 4 + i; var block = ReadBlock((byte)blockAddr); if (block == null) return null; Buffer.BlockCopy(block, 0, result, i * 16, 16); } return result; }

循环只跑三次,是因为第 4 块是控制块,读它返回的密钥和访问位信息在业务上没有意义,还容易让人误以为可以拿来存数据。

4.4 数据落库与去重:两张表撑起整个业务

数据结构不复杂,两张表就够。一张存人员与卡的绑定关系,一张存刷卡流水。SQLite 建表语句如下:

-- 人员与卡绑定表 CREATE TABLE CardHolder ( HolderId INTEGER PRIMARY KEY AUTOINCREMENT, Uid TEXT NOT NULL UNIQUE, -- 卡 UID,大写十六进制 HolderName TEXT NOT NULL, DeptCode TEXT, CardType TEXT, -- M1 / CPU / ID Status INTEGER NOT NULL DEFAULT 1, -- 1 正常,0 挂失 CreateTime TEXT NOT NULL DEFAULT (datetime('now','localtime')) ); -- 刷卡流水表 CREATE TABLE SwipeLog ( LogId INTEGER PRIMARY KEY AUTOINCREMENT, Uid TEXT NOT NULL, DeviceNo TEXT NOT NULL, -- 读写器编号,多点位时区分 SwipeTime TEXT NOT NULL, -- yyyy-MM-dd HH:mm:ss Direction INTEGER, -- 0 进,1 出 RawHex TEXT, -- 原始块十六进制,排查用 UNIQUE (Uid, DeviceNo, SwipeTime) -- 同卡同机同秒只留一条 );

Uid上的UNIQUE约束和流水表的复合唯一索引,是防重复入库的第一道防线。但如果只靠数据库约束,重复插入会抛异常,程序里到处都是 try-catch 很难看,所以业务层还要再做一层内存去抖:

private readonly Dictionary<string, DateTime> _lastSwipe = new(); private readonly object _lastSwipeLock = new(); /// <summary>同一张卡 2 秒内重复读到,视为同一次刷卡</summary> private bool IsDuplicate(string uid, string deviceNo, int windowMs = 2000) { string key = uid + "|" + deviceNo; lock (_lastSwipeLock) { if (_lastSwipe.TryGetValue(key, out var last) && (DateTime.Now - last).TotalMilliseconds < windowMs) return true; _lastSwipe[key] = DateTime.Now; return false; } }

2 秒这个窗口是根据现场调的:卡片放在读写器感应区上不动,读写器会连续上报,间隔通常在 200 到 500 毫秒;人正常刷一下就走,两次真实刷卡间隔不会小于 2 秒。窗口设太小挡不住重复上报,设太大又会把正常的连续两次刷卡合并掉。这个值做成了参数,不同项目可以现场标定。

RawHex字段存原始块数据(BitConverter.ToString的结果),平时没人看,但一旦出现"卡读出来了但数据不对"的问题,它就是唯一的证据。多存 32 个字符的成本,换来的是排查时不用再跑一趟现场。

5. 避坑与排查:现场最容易翻车的五个点

下面五条都是从实际调试里攒出来的,按出现频率从高到低排。

5.1 现象:能读到卡号,但读块一直返回失败

原因几乎总是卡型不匹配或者密钥不对。读到卡号说明寻卡链路是通的,问题出在后面的认证上。三种可能:一是这张卡根本不是 M1,是 CPU 卡,不响应 M1 的认证指令;二是密钥被改过,还在用默认的六个 0xFF;三是认证的扇区选错了,比如数据在第 2 扇区,代码去认证第 1 扇区。

解决顺序是:先把卡的 UID 长度和卡类型字段打到日志里,确认是不是 M1;再用密钥逐个扇区试认证,把能通过的扇区号和密钥类型记下来,做一张"卡片画像"表;最后才是读块。密钥这块建议做成界面上的可配置项,不要写死在代码里,现场改密钥的概率比想象中高得多。

5.2 现象:串口Open()成功,但一帧数据都收不到

Open()成功只说明端口被占用了,不代表数据能通。最常见的是 TX/RX 接反,其次是波特率对不上,第三是 USB 转串口线用的是劣质芯片,驱动装上了但收发不稳。

排查的标准动作是:先关掉自己的程序,打开串口助手,用同样的波特率发一条寻卡命令,看有没有字节回来。这一步能立刻把问题切成两半——串口助手也没数据,那是硬件或接线的问题;串口助手有数据,那是自己代码的问题(多半是没订阅DataReceived或者ReadTimeout设得太短)。

提示:现场一定带一根 USB 转串口线和一台装着串口助手的笔记本,这是我个人认为性价比最高的一项准备。

5.3 现象:程序跑两三个小时后卡死,必须重启

这类问题几乎都是线程阻塞。前面说过DataReceived里不能做重活,现实里最常见的写法是在事件里直接while循环等响应、或者直接调数据库插入,跑一会儿串口接收缓冲区就满了,然后整个界面卡住。

另一个隐蔽的原因是跨线程更新界面用了Invoke而不是BeginInvoke。UI 线程在等其他事情时,Invoke会一直等,消费线程挂着,队列越积越满,最后TryAdd全部失败,看起来就是"程序不动了"。

解决方向是彻底改成生产者-消费者模型,并且给队列设上限、给每个命令设超时。再补一条:串口对象在Close()之前必须先退订事件再Dispose,否则可能出现Close()卡住不动的情况。

5.4 现象:写卡之后这张卡彻底废了,读也读不出来

这个最严重,因为它不可逆。原因是写到了控制块,或者写到了厂商块。M1 卡的访问位一旦被写成不规则的值,整个扇区就锁死,只能报废。

防这个问题的做法就一条:代码层面加硬拦截,WriteBlock里先判断块地址,blockAddr == 0或blockAddr % 4 == 3一律抛异常,不给任何绕过的口子。别指望"操作人员不会输错块号",块号是手输的就一定会错。

另外,写操作要幂等设计:先把原始 16 字节读出来,改需要改的字节,再整体写回,不要写半截。写之前把原始数据打印到日志里,万一真出事,至少知道之前是什么。

5.5 现象:一张卡刷一次,流水表里出现七八条记录

读卡器天线的感应区有一定范围,卡放上去不动,读写器会以几十毫秒的间隔反复上报同一张卡。如果没有去抖,每一次上报都入库,就变成一堆重复记录。

解法是第 4.4 节里的双层防护:内存里用Uid|DeviceNo做键记时间戳,2 秒内不重复处理;数据库层面再用UNIQUE (Uid, DeviceNo, SwipeTime)兜底。两层缺一不可——内存那层挡住 99% 的重复,数据库那层防止程序重启后内存状态丢失导致的重复。

还有一个小细节:去抖的键里必须带设备号。如果是多点位部署,同一个人从 A 门走到 B 门,两次刷卡间隔可能真的不到 2 秒,不带设备号就会把 B 门那次合法记录吞掉。

6. 验收与答辩:用一张检查表把整条链路验到位

交付前我会拿这张表走一遍,每一项都要有可复现的操作和明确的判定标准,不接受"应该没问题"。

验收项判定标准验证方法
串口连接与断开连续开关 20 次不抛异常、不残留句柄点连接/断开按钮循环,任务管理器看进程
寻卡成功率同一张卡连续寻卡 100 次,成功 ≥ 99 次写个小循环脚本自动跑
读块正确性读出的数据与写入的一致,逐字节比对先写已知数据,再读回比对
认证失败处理错误密钥返回失败,程序不崩、界面提示明确故意用错密钥跑一遍
写块保护写块 0 和控制块时被拦截,卡片仍可正常读在界面上手输块号 3 试写
去抖效果卡片压在感应区 10 秒,流水表只增 1 条压住不动,数数据库行数
长时间稳定性连续运行 4 小时,无内存增长、无卡死挂着跑,定时读一下进程内存
断电恢复拔掉读写器再插回,程序能自动重连拔插串口线观察

答辩演示的流程建议控制在 5 分钟以内:先插上读写器,界面上点连接,状态栏变绿;贴一张 M1 卡,屏幕显示 UID 和卡型;切到读写页,选扇区 1,读一次,把那 48 字节的十六进制显示出来;改其中 4 个字节写回去,再读一次,证明写入生效;最后贴一张 ID 卡,界面明确提示"当前卡型为 ID 卡,不支持扇区读写"——这个失败演示往往比成功演示更能说明你对卡型的理解。

老师或者评委常问的三个问题,提前准备好答案:为什么不用成品 SDK?——因为要自己控制指令时序和异常处理,成品 SDK 出错时是黑匣子;M1 卡的安全性够不够?——不够,密钥可以被暴力破解,所以它只适合做内部管理,不适合做金融;换一台读写器要改多少代码?——只改ICardReader的一个实现类,业务层零改动。

有个血泪教训我一直记着:早期版本我把WriteBlock里的块地址保护写成了"块号大于 63 就拦截",结果有人在界面上输了个 3,一次就把测试卡的扇区 0 写死了,赔了一张卡不说,那批测试数据全没了。从那以后我每次写涉及硬件写操作的代码,都强制走一遍"先在代码里拦、再在界面里二次确认、最后在日志里留原始数据"这三步,一步都不省。希望这套结构能帮到你,少走一点弯路。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/2 18:58:01

选型对比:政务项目 LoRa 智能锁为什么选择新巢天诚

在苏州市政办公楼及后勤配套这类数千套规模的政务项目中&#xff0c;LoRa 智能锁选型不能只看硬件单价&#xff0c;要重点考察私有化部署能力、混合组网、离线容错、完整产品线、项目交付与售后能力。市面上主流有两类竞品&#xff1a;竞品 A&#xff08;公寓型 LoRa 锁厂商&am…

作者头像 李华
网站建设 2026/11/2 18:54:14

Jasna 视频马赛克修复选型指南:一张表配齐硬件、许可与模型

Jasna 视频马赛克修复选型指南&#xff1a;一张表配齐硬件、许可与模型 【免费下载链接】jasna Video restoration tool 项目地址: https://gitcode.com/GitHub_Trending/ja/jasna Jasna 是一款在本机 GPU 上运行的视频和图片马赛克修复工具&#xff1a;它逐帧找出马赛克…

作者头像 李华
网站建设 2026/11/2 18:52:33

Proton-GE 实战:非Steam游戏在Lutris上跑起来的3种接入方式

Proton-GE 实战&#xff1a;非Steam游戏在Lutris上跑起来的3种接入方式 【免费下载链接】proton-ge-custom Compatibility tool for Steam Play based on Wine and additional components 项目地址: https://gitcode.com/gh_mirrors/pr/proton-ge-custom Proton-GE 是在…

作者头像 李华
网站建设 2026/11/2 18:52:32

Scala String操作:从插值器到正则的完整指南

Scala的String就是java.lang.String&#xff0c;但Scala给它装上了强大的"扩展方法"。插值器、多行字符串、集合操作化——让字符串处理从Java的繁琐中解放出来。如果你从Java转到Scala&#xff0c;处理字符串时可能会有两个疑问&#xff1a;Scala的String和Java的St…

作者头像 李华
网站建设 2026/11/2 18:35:56

QML调用JS

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/2 18:24:26

从零搭建VoiceStudio:录音环境、设备调试与后期处理全攻略

从零搭一个能用的 VoiceStudio&#xff1a;录音、调试到后期的完整实操记录标题里那个 VoiceStudio&#xff0c;说白了就是一套"从声音进到声音出"的完整链路。很多人一提录音就以为是买个好麦克风的事&#xff0c;实际上话筒只是最前端的一环&#xff0c;环境、声卡…

作者头像 李华