简介:这份资源是一套基于C#开发的RFID读写器自动读卡程序,面向具备一定C#基础、希望学习串口通信与RFID协议解析的开发者,可用于IC卡数据读取、展示与管理的实践场景。压缩包共37个文件,约169KB,以cs源码、exe可执行程序、config配置、pdb调试符号、dll依赖库及resx资源文件为主,另含sln解决方案与csproj工程文件,结构完整,便于直接打开编译与调试。项目采用Windows Forms构建可视化界面,通过SerialPort类完成串口通信,并涉及MIFARE Classic、UID等RFID协议的数据解析,同时包含SqlHelper等数据库操作辅助类,覆盖界面设计、设备通信、协议处理与数据存储多个环节。目前已有628人学习下载,适合作为C#与RFID技术综合练手的参考案例,帮助读者理解读卡器通信流程与工程组织方式。
1. 自动读卡版 C# RFID 读写器:从串口收包到数据库落盘的完整拆解
手上拿到一个叫「自动读卡版」的 C# RFID 读写器源码包,第一反应不是急着双击 sln,而是先看它到底把哪几件事串起来了。这个项目用 Windows Forms 做界面,核心链路是串口收 RFID 读卡器上报的原始字节流,按协议解析出 UID 或扇区数据,再通过 SqlHelper 落到本地数据集里。它解决的不是「怎么造读卡器」,而是「读卡器已经在吐数据了,上位机怎么稳定接住、解析、存下来、还能查」。适合两类人:一类是做 C# 上位机、考勤、门禁、资产管理这类需要对接 RFID 硬件的开发者;另一类是想找一个能跑通的串口加数据库综合案例来练手的人。包里带了 RFIDLabDataSet.xsd 和 SqlHelper.cs,说明作者已经把数据访问层和强类型数据集搭好了,不是那种只贴个 SerialPort 示例的半成品。
2. 串口通信层:SerialPort 参数配置与 DataReceived 收包
2.1 为什么用 SerialPort 而不是厂商 SDK
RFID 读卡器常见的对接方式有两种:厂商提供 DLL 或 SDK,或者直接走串口。这个项目走的是串口路线,原因很实际——串口是通用接口,换一个品牌的读卡器,只要通信协议对得上,上位机代码基本不用大改。厂商 SDK 的问题是绑定硬件型号,换设备就得换库,维护成本高。用 System.IO.Ports 下的 SerialPort 类,波特率、数据位、停止位、校验位这些参数在代码里显式配置,调试时能直接看到原始字节,出问题好定位。
常见做法是:读卡器出厂默认波特率 9600 或 115200,数据位 8,停止位 1,无校验。但不同厂家会改,所以参数不能写死,最好做成配置文件或界面可调。这个项目里 app.config 存在,说明作者留了配置入口。
2.2 串口初始化的关键参数
// 串口初始化:参数必须和读卡器实际配置一致,否则收到的全是乱码 private SerialPort _serialPort; private void InitSerialPort(string portName) { _serialPort = new SerialPort(); _serialPort.PortName = portName; // 如 "COM3",从配置或下拉框读取 _serialPort.BaudRate = 9600; // 常见 9600 / 115200,必须与读卡器一致 _serialPort.DataBits = 8; // 数据位,绝大多数读卡器为 8 _serialPort.StopBits = StopBits.One; // 停止位,1 最常见 _serialPort.Parity = Parity.None; // 校验位,None 最常见 _serialPort.ReadTimeout = 500; // 读超时,避免阻塞线程 _serialPort.WriteTimeout = 500; // 写超时 _serialPort.DataReceived += new SerialDataReceivedEventHandler(DataReceivedHandler); _serialPort.Open(); }这段代码里每个参数都有实际意义。BaudRate 不匹配是最常见的翻车点,表现为收到的字节全是 0x00 或乱码。ReadTimeout 设 500ms 是为了防止在读操作上无限等待,导致界面卡死。DataReceived 是事件驱动,串口有数据进来时自动触发,不需要开线程轮询。
2.3 DataReceived 里不能直接操作 UI
// DataReceived 在非 UI 线程触发,直接更新控件会抛跨线程异常 private void DataReceivedHandler(object sender, SerialDataReceivedEventArgs e) { try { int bytesToRead = _serialPort.BytesToRead; byte[] buffer = new byte[bytesToRead]; _serialPort.Read(buffer, 0, bytesToRead); // 通过 Invoke 切回 UI 线程再更新界面 this.Invoke(new Action(() => { string hex = BitConverter.ToString(buffer).Replace("-", " "); txtRawData.AppendText(hex + Environment.NewLine); ParseRfidFrame(buffer); // 解析逻辑单独抽出来 })); } catch (Exception ex) { // 串口异常要记录,不能吞掉 LogHelper.Write("串口接收异常: " + ex.Message); } }这里有个血泪经验:DataReceived 回调运行在 ThreadPool 线程上,直接碰 TextBox 会报「线程间操作无效」。用 this.Invoke 切回 UI 线程是标准做法。另外 BytesToRead 返回的是当前缓冲区里的字节数,读的时候要按这个长度读,不能固定读一个大数组,否则会读到脏数据。解析逻辑抽成 ParseRfidFrame 单独方法,方便后面按协议改。
3. RFID 协议解析:从原始字节到 UID 和扇区数据
3.1 帧结构决定解析方式
RFID 读卡器上报的数据不是裸 UID,通常带帧头、长度、命令字、数据和校验。不同厂家帧格式不同,但结构类似。常见的一种是:帧头(如 0x02)+ 长度 + 命令 + 数据 + 校验 + 帧尾(如 0x03)。解析第一步是找到帧头,然后按长度字段截取完整帧,再校验,最后提取数据段。
如果项目里读的是 MIFARE Classic 卡,UID 是 4 字节或 7 字节,扇区数据是 16 字节一块。解析时要区分「读 UID」和「读扇区」两种命令的返回格式。这个项目叫「自动读卡版」,说明它可能是循环读 UID 模式,读到卡就上报,不需要上位机主动发指令。
3.2 一个可复用的帧解析方法
// 按帧头帧尾截取完整帧,再解析 UID private void ParseRfidFrame(byte[] raw) { // 假设帧格式:0x02 + LEN + CMD + DATA... + CHECK + 0x03 const byte FRAME_HEAD = 0x02; const byte FRAME_TAIL = 0x03; int headIndex = Array.IndexOf(raw, FRAME_HEAD); if (headIndex < 0) return; // 没找到帧头,丢弃 int tailIndex = Array.IndexOf(raw, FRAME_TAIL, headIndex); if (tailIndex < 0) return; // 帧不完整,等下次数据 int frameLen = tailIndex - headIndex + 1; byte[] frame = new byte[frameLen]; Array.Copy(raw, headIndex, frame, 0, frameLen); // 校验:常见为异或校验,从 LEN 到 DATA 逐字节异或 byte check = 0; for (int i = 1; i < frameLen - 2; i++) check ^= frame[i]; if (check != frame[frameLen - 2]) { LogHelper.Write("校验失败,丢弃帧"); return; } // 提取 UID:假设 DATA 段前 4 字节为 UID byte[] uid = new byte[4]; Array.Copy(frame, 3, uid, 0, 4); string uidStr = BitConverter.ToString(uid).Replace("-", ""); // 落盘到数据集 SaveCardRecord(uidStr); }这段代码的关键点:先找帧头再找帧尾,确保拿到完整帧;校验不通过直接丢弃,不往数据库写脏数据;UID 提取位置按实际协议调整,不同读卡器 DATA 段偏移不一样。参数上,FRAME_HEAD 和 FRAME_TAIL 要根据读卡器手册改,校验算法也可能是累加和或 CRC16,这里用异或只是常见做法之一。
3.3 自动读卡模式下的去重
自动读卡意味着同一张卡放在感应区会连续上报。如果每报一次就写一条数据库记录,表会迅速膨胀。常见做法是加一个时间窗口去重:同一 UID 在 2 秒内只记一次。
// 简单去重:同一 UID 在 2 秒内不重复入库 private Dictionary<string, DateTime> _lastSeen = new Dictionary<string, DateTime>(); private bool ShouldRecord(string uid) { DateTime now = DateTime.Now; if (_lastSeen.ContainsKey(uid)) { if ((now - _lastSeen[uid]).TotalSeconds < 2) return false; // 2 秒内重复,跳过 } _lastSeen[uid] = now; return true; }这个字典会随卡片数量增长,长期运行要考虑清理过期项,否则内存会慢慢涨。简单做法是定时清理超过 10 分钟没出现的 UID。
4. 数据落盘:SqlHelper 与强类型数据集的配合
4.1 为什么项目里同时有 SqlHelper 和 DataSet
打开源码包能看到 SqlHelper.cs 和 RFIDLabDataSet.xsd 并存。SqlHelper 是典型的 ADO.NET 封装,提供 ExecuteNonQuery、ExecuteScalar 这类方法,适合做增删改查。RFIDLabDataSet 是强类型数据集,配合 .xsc 和 .xss 文件,说明作者用设计器定义了表结构,界面上可能用 DataGridView 直接绑定。两者配合的方式通常是:SqlHelper 负责写库,DataSet 负责界面展示和缓存。
这种组合在 WinForms 项目里很常见,好处是界面绑定快,缺点是数据集和数据库之间需要手动同步。如果读卡频率高,建议直接用 SqlHelper 写库,然后刷新 DataGridView,而不是往 DataSet 里塞。
4.2 插入读卡记录的 SqlHelper 调用
// 插入一条读卡记录,参数化查询防注入 public void SaveCardRecord(string uid) { string sql = "INSERT INTO CardRecord (Uid, ReadTime) VALUES (@Uid, @ReadTime)"; SqlParameter[] paras = new SqlParameter[] { new SqlParameter("@Uid", uid), new SqlParameter("@ReadTime", DateTime.Now) }; SqlHelper.ExecuteNonQuery(connStr, CommandType.Text, sql, paras); }参数说明:connStr 从 app.config 的 connectionStrings 读,不要硬编码在代码里。@Uid 和 @ReadTime 用参数化传递,避免拼接字符串。如果数据库是 SQL Server,表结构里 Uid 建议加唯一索引或至少加普通索引,因为后面查询「某张卡最近一次读卡时间」会频繁用到。
4.3 数据集与数据库的同步边界
RFIDLabDataSet 里的表如果和数据库表同名同结构,可以用 TableAdapter 做 Fill 和 Update。但自动读卡场景下,数据是持续写入的,用 TableAdapter 的 Update 反而容易冲突。我一般会:写库走 SqlHelper,界面展示走 DataGridView 绑定 DataTable,每次插入后重新查询最近 N 条刷新。这样逻辑清晰,不会出现数据集和数据库不一致的玄学问题。
5. 避坑与排查:串口读卡项目最容易翻车的五个点
5.1 现象:串口打开成功但收不到任何数据
原因:波特率、数据位、停止位、校验位中至少一项和读卡器不一致;或者串口线是只供电不传数据的劣质线;或者读卡器处于被动模式,需要上位机先发指令才上报。
解决:先用串口调试助手单独测读卡器,确认参数和主动/被动模式。如果调试助手能收到,再对比代码里的参数。线材问题换一根带屏蔽的 USB 转串口线。
5.2 现象:收到的数据偶尔多几个字节或少几个字节
原因:DataReceived 触发时数据还没收完,BytesToRead 只反映了当前缓冲区里的字节数,一帧可能被拆成两次事件。
解决:不要假设一次事件就是一帧。维护一个接收缓冲区,每次把新数据追加进去,然后循环查找完整帧。找到完整帧就解析并从缓冲区移除,剩余不完整的留着等下次数据。
5.3 现象:界面卡死,点按钮没反应
原因:在 UI 线程里做了同步串口读或数据库操作,或者 DataReceived 里直接更新控件导致跨线程异常后线程挂起。
解决:串口读用事件驱动,数据库写用短连接快速执行,耗时操作放 BackgroundWorker 或 Task。所有 UI 更新走 Invoke。
5.4 现象:数据库里出现重复 UID,同一张卡刷一次记了十几条
原因:自动读卡模式下读卡器连续上报,代码没有去重。
解决:加时间窗口去重,如 5.3 节所示。更严谨的做法是读卡器端配置「只上报一次」模式,但很多读卡器不支持,只能上位机做。
5.5 现象:换了一台电脑或换了一个 USB 口,程序报串口不存在
原因:串口号写死在代码里,换环境后 COM 号变了。
解决:串口号从 app.config 读,或者界面上做下拉框枚举可用串口。枚举用 SerialPort.GetPortNames(),启动时自动填充。
6. 进阶技巧:把读卡记录做成可查询的考勤流水
6.1 从裸记录到考勤逻辑
读卡记录表里只有 Uid 和 ReadTime,这还不是考勤。要变成考勤流水,需要一张人员表把 Uid 映射到人名,再按天聚合出「最早一次读卡」作为上班时间、「最晚一次」作为下班时间。这个项目本身没带人员管理,但 SqlHelper 和 DataSet 的结构留了扩展空间。
我一般会加两张表:Person(Uid, Name, Dept)和 Attendance(PersonId, Date, FirstIn, LastOut)。读卡记录插入后,用 SQL 的 GROUP BY 按人按天聚合。
-- 按人按天聚合出上下班时间 SELECT p.Name, CAST(r.ReadTime AS DATE) AS WorkDate, MIN(r.ReadTime) AS FirstIn, MAX(r.ReadTime) AS LastOut FROM CardRecord r JOIN Person p ON r.Uid = p.Uid GROUP BY p.Name, CAST(r.ReadTime AS DATE) ORDER BY WorkDate DESC, p.Name;这个查询在数据量到几十万条时仍然很快,前提是 CardRecord 的 ReadTime 和 Uid 上有索引。如果没索引,全表扫描会明显变慢。
6.2 验证解析是否正确的一个笨办法
协议解析对不对,不要靠猜。我习惯在解析方法里加一段临时日志,把原始字节和解析结果同时写到一个文本文件里,跑一天后拿几张已知卡号的卡去比对。如果原始字节里能看到卡号的 ASCII 或十六进制,但解析结果对不上,就是偏移量或字节序搞错了。字节序问题在 UID 解析里特别常见,有的读卡器低字节在前,有的高字节在前,差一个 Array.Reverse 结果就完全不同。
6.3 长期运行的内存与串口稳定性
自动读卡程序可能一开就是几个月。两个地方要注意:一是去重字典要定期清理,二是串口在异常后要能自动重连。我一般会加一个定时器,每 30 秒检查串口 IsOpen,如果断了就尝试重新打开,并记录日志。数据库连接用 using 包起来,确保每次用完就释放,不要保持长连接。
从那以后我每次拿到串口类项目,都强制先跑一遍「串口调试助手对照测试」,确认硬件和参数没问题再动代码。这个习惯帮我省掉了至少一半的无效调试时间。希望帮到你。
本文还有配套的精品资源,点击获取