☰
C# RFID读卡器串口通信与扇区读写实战:从卡号识别到数据落库
2026/10/28 8:03:05 网站建设 项目流程

简介:这份资源面向具备一定C#基础、希望入门RFID硬件交互的开发者,围绕“基于C#的RFID卡识别和读写”这一主题,提供一套可运行的桌面示例工程,帮助读者理解阅读器连接、标签数据解析与读写命令下发等核心环节,适用于门禁、物流跟踪、商品防伪等场景的原型验证。压缩包共29个文件,约66KB,以6个cs源码文件为主体,辅以sln解决方案、csproj项目文件、resx资源与manifest清单,另有exe、pdb等编译产物及缓存、日志类文件,结构完整、便于直接打开调试。目前已有4662人学习下载。工程将串口通信、事件驱动接收、协议数据解析与错误处理串联起来,读者可据此梳理RFID读写的调用流程,并参考其分层思路把硬件交互封装为独立类库,为后续替换设备或扩展业务界面留出空间。

1. 从一张“读不出来”的卡说起:这套 C# RFID 方案到底解决什么

做门禁、考勤、仓储盘点这类项目,最容易被低估的环节不是业务逻辑,而是读卡。某同学拿着读卡器接上电脑,串口助手能收到一串十六进制,可一放进 C# 程序里就时好时坏,卡靠近没反应、换一张卡又读错字节,最后卡在“硬件明明亮灯了,软件就是不认”的玄学里。这套基于 C# 的 RFID 卡识别与读写方案,瞄准的就是这个断层:把读卡器的串口通信、卡号识别、扇区读写、数据校验和上层业务调用串成一条可复现的链路。它适合正在做课程设计、毕业设计的学生,也适合要快速交付一个门禁或资产登记原型的开发者。下面按需求、架构、实现、避坑、验收的顺序,把整套东西拆开讲清楚,你拿到手能直接对着改。

2. 需求与选型:为什么是 C# 加串口读卡器

2.1 先把功能边界划清楚

RFID 项目最容易失控的地方,是需求一开始就写得太满。我一般会把第一版功能压到四条主线:识别卡号、读取卡内指定扇区数据、向指定扇区写入数据、把操作结果落库并给出界面反馈。识别是只读,读写是带权限的写操作,落库是为了让“谁在什么时候刷了哪张卡”有据可查。这四条主线对应到界面上就是四个动作按钮加一个日志区,对应到代码里就是通信层、协议层、业务层、界面层四块。边界划清楚之后,后面选型才有依据:如果只是识别卡号,很多读卡器上电即主动上报,程序只要被动接收;一旦涉及扇区读写,就必须走“发命令—等应答—校验”的请求响应模式,通信模型完全不同。这一点在动手前想明白,能省掉后面大量返工。

2.2 技术栈的取舍:C# + SerialPort + 分层

选 C# 不是因为它时髦,而是这个场景下它省事。桌面端要快速出界面,WinForms 或 WPF 都行;串口通信有现成的System.IO.Ports.SerialPort,不用自己啃底层;数据落库用 SQLite 或 SQL Server 都轻量。真正要设计的是分层,我通常分成三层:通信层只管打开串口、收发字节、处理超时;协议层负责把字节流拼成完整帧、解析命令字和卡号、做校验;业务层负责“读卡—判断权限—写库—刷新界面”的流程编排。三层之间用事件或回调传递数据,界面层不直接碰串口。这样做的直接好处是:换一款读卡器,只改协议层;换数据库,只改业务层;界面重做,通信和协议一行不动。很多课设翻车就翻在把串口读写、协议解析、界面刷新全塞进一个按钮事件里,读卡一卡顿整个窗口假死,调试时连日志都打不出来。

2.3 读卡器与卡型的匹配关系

选型里最容易被忽略的是读卡器和卡片的频段匹配。常见的有 125kHz 的低频卡和 13.56MHz 的高频卡,前者通常只存一个只读卡号,后者才有可读写的扇区结构。如果你的需求里有“往卡里写数据”,就必须选支持高频卡、且读卡器固件开放写命令的型号;如果只是识别身份,低频卡成本更低、抗干扰也更省心。协议上,高频读卡器多走类 ISO14443 的帧格式,命令字、长度、数据、校验各占固定位置,具体字节定义以读卡器手册为准,不要照搬网上随手抄的帧结构。我一般会在协议层先写一个“原始帧打印”开关,把收到的每个字节按十六进制打出来,对着手册逐位核对,确认无误后再写解析逻辑。这一步看着笨,但它是后面所有读写功能的地基。

3. 总体架构设计:四层结构与一条数据链路

3.1 分层职责与调用关系

整套方案的骨架是四层:界面层、业务层、协议层、通信层。界面层只负责采集用户动作和展示结果,不持有串口对象;业务层拿到“读卡请求”后调用协议层组帧,再交给通信层发送,收到应答后反向解析,最后写库并通知界面刷新。数据链路是单向发起、双向返回:界面发起动作,业务层编排,协议层编解码,通信层收发,结果沿原路回传。这条链路里,协议层是唯一知道“字节含义”的地方,通信层只认字节不认语义,业务层只认对象不认字节。职责一旦这样切开,调试时就能分段定位:串口打不开是通信层问题,收到乱码是协议层问题,读到了卡号但没写库是业务层问题。下面这张表是我一般会写进设计文档的模块职责表,答辩时直接能讲。

层级核心职责关键类/对象出错时的典型表现
界面层采集动作、展示卡号与日志主窗体、日志控件界面假死、按钮无响应
业务层流程编排、权限判断、落库读卡服务、数据访问读到卡但不写库、重复记录
协议层组帧、解析、校验帧构造器、帧解析器卡号错位、校验失败
通信层串口开关、收发、超时串口封装类打不开串口、收不到数据

3.2 通信层的串口封装

通信层我一般封装成一个类,把打开、关闭、发送、接收、超时全部收进去,对外只暴露“发送一帧、等一帧回来”的方法。关键点是接收不能靠死等,要用DataReceived事件加缓冲区,再配合超时判断。下面这段是通信层的核心骨架,参数和注释都写清楚了,可以直接对照改。

public class SerialPortWrapper { private readonly SerialPort _port = new SerialPort(); private readonly List<byte> _buffer = new List<byte>(); private readonly object _lock = new object(); // 打开串口:波特率、数据位、停止位、校验位必须与读卡器手册一致 public void Open(string portName, int baudRate = 9600) { _port.PortName = portName; _port.BaudRate = baudRate; // 常见 9600 / 115200,以手册为准 _port.DataBits = 8; _port.StopBits = StopBits.One; _port.Parity = Parity.None; _port.DataReceived += OnDataReceived; _port.Open(); } private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { var bytes = new byte[_port.BytesToRead]; _port.Read(bytes, 0, bytes.Length); lock (_lock) { _buffer.AddRange(bytes); } // 先入缓冲,避免粘包丢帧 } // 发送一帧并等待应答,超时返回 null,由上层决定重试还是报错 public byte[] SendAndReceive(byte[] frame, int timeoutMs = 500) { lock (_lock) { _buffer.Clear(); } _port.Write(frame, 0, frame.Length); var deadline = DateTime.Now.AddMilliseconds(timeoutMs); while (DateTime.Now < deadline) { lock (_lock) { if (_buffer.Count >= 2) // 至少拿到长度字节才尝试取帧 { var len = _buffer[1]; if (_buffer.Count >= len + 2) { var result = _buffer.GetRange(0, len + 2).ToArray(); _buffer.RemoveRange(0, len + 2); return result; } } } Thread.Sleep(10); } return null; // 超时,上层据此提示“读卡无响应” } }

这段代码里有两个设计点值得说。第一,接收走事件加缓冲区,而不是在发送后同步Read,因为读卡器的应答可能分几次到达,同步读很容易只读到半帧。第二,SendAndReceive里先清缓冲再发送,避免上一次的残留数据污染本次应答;超时返回null而不是抛异常,是为了让业务层能统一处理“没读到卡”这种正常情况,而不是把它当程序错误。波特率默认给 9600,但实际以读卡器手册为准,改这一个参数就能适配大部分型号。

3.3 协议层的帧结构与校验

协议层要做两件事:把业务意图编成字节帧,把收到的字节帧还原成卡号或数据。帧结构通常包含帧头、长度、命令字、数据域、校验和。校验我一般用累加和或异或,具体用哪种看手册,但一定要在解析时校验,不能收到就信。下面是一个典型的组帧与解析示例,命令字和数据域按实际手册替换。

public static class RfidProtocol { // 组帧:帧头 0xAA + 长度 + 命令 + 数据 + 校验(累加和取低字节) public static byte[] BuildFrame(byte cmd, byte[] data) { var len = (byte)(data.Length + 2); // 命令字 + 数据 var frame = new List<byte> { 0xAA, len, cmd }; frame.AddRange(data); byte sum = 0; foreach (var b in frame) sum += b; // 从帧头累加到数据域 frame.Add(sum); return frame.ToArray(); } // 解析应答:校验通过才返回卡号,否则返回 null 交由上层记日志 public static string ParseCardNo(byte[] frame) { if (frame == null || frame.Length < 4 || frame[0] != 0xAA) return null; byte sum = 0; for (int i = 0; i < frame.Length - 1; i++) sum += frame[i]; if (sum != frame[frame.Length - 1]) return null; // 校验失败直接丢弃 var dataLen = frame[1] - 2; var cardBytes = frame.Skip(3).Take(dataLen).ToArray(); return BitConverter.ToString(cardBytes).Replace("-", ""); // 转成十六进制卡号 } }

组帧时长度字段算的是“命令字加数据域”,校验从帧头累加到数据域末尾,这两处是最容易和手册对不上的地方,写完先拿一张已知卡号的卡验证。解析时先判帧头和长度,再验校验,任何一步不过就返回null,由业务层记一条“无效帧”日志。卡号转十六进制字符串是为了界面展示和入库统一,实际比对权限时用字符串或字节数组都行,但全项目要统一一种表示,别一半用字节一半用字符串,后面查重会很难受。

4. 核心模块实现:识别、读写与落库

4.1 卡号识别与去重

识别模块的流程是:界面点“开始读卡”,业务层循环或单次调用协议层组“读卡号”命令,通信层发出后等应答,解析出卡号后先做去重再展示。去重不是可选项,读卡器在卡停留期间可能连续上报同一张卡,不去重日志会被刷屏,入库也会产生大量重复记录。我一般用一个“最近卡号 + 时间窗”的简单策略:同一卡号在 1 秒内只处理一次。下面这段是业务层的识别入口,把去重和异常提示都收在一起。

public string ReadCardOnce() { var frame = RfidProtocol.BuildFrame(0x01, Array.Empty<byte>()); // 0x01 为读卡号命令 var resp = _serial.SendAndReceive(frame, 500); if (resp == null) return null; // 超时:没放卡或读卡器无响应 var cardNo = RfidProtocol.ParseCardNo(resp); if (cardNo == null) { _logger.Warn("无效应答帧"); return null; } if (cardNo == _lastCardNo && (DateTime.Now - _lastReadTime).TotalSeconds < 1) return null; // 时间窗内重复卡,直接忽略 _lastCardNo = cardNo; _lastReadTime = DateTime.Now; return cardNo; }

这里的命令字0x01是示例,实际以手册为准。超时返回null后,界面提示“请将卡片靠近读卡区”,而不是弹一个异常框,因为没放卡是正常操作,不是错误。去重时间窗设 1 秒是个经验值,卡停留时间长可以调到 2 秒,但别设太大,否则连续刷两张不同卡时第二张会被误吞。

4.2 扇区读写与权限控制

读写模块比识别复杂,因为写操作有风险:写错扇区可能把卡锁死,写错密钥可能再也读不出来。我的做法是三条硬约束。第一,读写前必须先认证密钥,认证不过直接拒绝,不尝试“猜”密钥。第二,写操作前把原数据读出来备份到日志或临时表,写失败还能对照。第三,界面上的“写入”按钮要有二次确认,且只对已识别且权限校验通过的卡开放。下面是一个写扇区的流程示意,重点在“先读后写、失败可回滚”的顺序。

public bool WriteSector(string cardNo, byte sector, byte[] newData) { if (!Authenticate(cardNo, sector)) return false; // 密钥认证不过,直接退出 var backup = ReadSector(cardNo, sector); // 先备份原数据 if (backup == null) return false; _logger.Info($"扇区{sector}原数据:{BitConverter.ToString(backup)}"); var frame = RfidProtocol.BuildFrame(0x02, BuildWritePayload(sector, newData)); var resp = _serial.SendAndReceive(frame, 800); // 写命令超时给长一点 if (resp == null || !IsAck(resp)) { _logger.Error($"写入失败,原数据已备份:{BitConverter.ToString(backup)}"); return false; // 失败不重试,交由人工判断 } return true; }

写命令超时给到 800ms,是因为写卡比读卡慢,超时太短会误判失败。失败后不自动重试,是因为写操作重试可能造成半写状态,宁可让人看到备份数据后手动处理。扇区号和密钥不要硬编码在业务代码里,放到配置文件,换项目时只改配置。权限控制放在业务层,读卡号后先查这张卡在不在授权表里,不在就只允许读不允许写,这条规则答辩时经常被问到。

4.3 数据落库与操作日志

落库用 SQLite 就够,单文件、免安装、方便随项目拷走。表设计不用复杂,两张表:一张卡信息表存卡号、持卡人、权限等级、最近操作时间;一张操作日志表存卡号、操作类型、扇区、结果、时间。日志表是排查问题的黑匣子,读卡失败、校验失败、写入失败都往里记,答辩演示出问题时能当场翻日志定位。下面是对应的建表语句,字段类型按 SQLite 写,换 SQL Server 时把TEXT改NVARCHAR即可。

CREATE TABLE CardInfo ( CardNo TEXT PRIMARY KEY, -- 十六进制卡号,与程序内表示一致 HolderName TEXT, AuthLevel INTEGER DEFAULT 0, -- 0 只读,1 可写 LastActive TEXT ); CREATE TABLE OperationLog ( Id INTEGER PRIMARY KEY AUTOINCREMENT, CardNo TEXT, OpType TEXT, -- READ / WRITE / AUTH Sector INTEGER, Result TEXT, -- OK / FAIL / TIMEOUT OpTime TEXT );

卡号做主键,天然防重复;权限等级用整数,扩展时加枚举映射即可。日志表不设外键,避免卡信息被删后日志丢失,日志本来就是独立追溯用的。写入成功后更新LastActive,读卡时如果卡不在CardInfo里,提示“未登记卡”,而不是直接报错,这样演示时可以先读一张新卡再登记,流程更顺。

5. 避坑与排查:读卡项目里最容易翻车的五件事

5.1 串口能打开但收不到数据

现象是程序里串口打开成功,读卡器也亮灯,但缓冲区一直是空的。原因通常有三个:波特率或校验位和读卡器不一致;读卡器处于“主动上报”模式而程序在等“请求应答”;或者串口被别的软件占用。解决顺序是先换串口助手用同样参数验证硬件是否出数据,再核对程序里的波特率、数据位、停止位、校验位四项是否和手册完全一致,最后检查设备管理器里串口有没有被其他进程占用。我一般会在通信层打开串口后先打一行“串口已打开,参数:…”的日志,出问题时一眼能对上。

5.2 卡号读出来是反的或错位

现象是读到的卡号和读卡器厂商工具显示的卡号对不上,或者高低字节颠倒。原因是协议层解析时字节序或起始偏移搞错了,常见于把长度字段也算进了数据域。解决办法是拿厂商工具读一张已知卡,把原始帧逐字节打印出来,对着手册标出帧头、长度、命令、数据、校验各占第几位,再改解析代码。不要凭感觉调偏移,一定要以原始帧为准。改完后用三张不同卡验证,确认不是碰巧对上。

5.3 写卡失败后卡读不出来了

现象是执行一次写操作后,原来能读的卡再也读不到。原因是写错了扇区或密钥,触发了卡的锁定机制。这类问题没有后悔药,只能预防:写前必须认证、必须备份原数据、写命令失败不自动重试。如果已经发生,先换一张新卡继续调试,别在原卡上反复试。我一般会在写功能开发阶段只用空白测试卡,确认流程稳定后才碰正式卡,这条习惯能避免大部分“卡废了”的事故。

5.4 界面假死、按钮点不动

现象是点“读卡”后窗口卡住,转圈或白屏。原因是串口等待放在了 UI 线程上,SendAndReceive里的循环阻塞了消息泵。解决办法是把读卡动作放到后台线程或async方法里,等待期间界面显示“读卡中”,完成后通过回调或Invoke回 UI 线程刷新。注意跨线程更新控件必须走Invoke,否则会抛跨线程异常,这个异常在调试时容易被忽略成“偶发崩溃”。

5.5 重复记录刷屏

现象是同一张卡放上去,日志表里瞬间多出十几条记录。原因是读卡器在卡停留期间连续上报,而业务层没做去重。解决办法就是 4.1 里的时间窗去重,同一卡号在设定时间窗内只处理一次。时间窗别设太大,否则连续刷两张卡会漏;也别设太小,否则去重不彻底。1 到 2 秒是常用区间,按实际读卡器上报频率调。

6. 验收与答辩:怎么证明这套方案真的能跑

验收这件事,我一般不看“功能列表打勾”,而是看三条链路能不能当场走通。第一条是识别链路:拿一张已登记卡和一张未登记卡分别刷,界面要能正确显示卡号,未登记卡给出明确提示而不是报错。第二条是读写链路:对授权卡写一个扇区,写完再读回来比对,数据一致才算通过;对未授权卡尝试写,必须被拒绝且日志有记录。第三条是异常链路:拔掉读卡器再点读卡,程序要提示“设备未连接”而不是崩溃;写入过程中拔设备,要有失败日志和备份数据。这三条链路覆盖了正常、权限、异常三类场景,答辩时按这个顺序演示,评委问什么都能接住。

演示前有个技巧:把操作日志窗口放在界面显眼位置,每做一步都让日志滚动一条。评委看到“认证成功—读取备份—写入成功—校验一致”这一串日志,比你说十句“实现了读写”都有说服力。另外准备一张“故障卡”或直接拔线演示异常处理,很多人只演示成功路径,一被问“失败了怎么办”就卡壳,提前把失败路径跑一遍,反而成了加分项。

下面这张验收对照表可以直接放进文档,逐项打勾。

验收项操作步骤通过标准
卡号识别刷已登记卡界面显示卡号,日志记 READ/OK
未登记卡处理刷未登记卡提示未登记,不崩溃
扇区写入授权卡写指定扇区写后读回数据一致
权限拦截未授权卡尝试写拒绝并记 FAIL 日志
设备异常拔读卡器后读卡提示设备未连接,不崩溃
重复读卡同一卡停留 3 秒日志只记一条

从那以后我每次做读卡类项目,都会在动手写业务之前先把“原始帧打印”和“操作日志”两个开关做出来,血泪经验是:读卡问题八成不是业务逻辑错,而是帧没对上或线程没切对,有了这两个开关,排查时间能从半天压到十分钟。希望这套结构能帮到你,照着改就能交出一份讲得清、跑得动、经得起问的方案。

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

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

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

立即咨询