简介:这份资源是面向C#初学者与物联网开发入门者的完整课程设计项目包,以智能公交刷卡系统为场景,演示如何用C#对接RFID读卡器完成刷卡乘车、身份验证与扣费流程。项目覆盖C#语言基础、RFID非接触识别原理、串行通信与USB硬件接口调用、事件驱动编程、ADO.NET数据库操作、WinForms/WPF界面设计、异常处理、多线程与异步编程、数据加密及单元测试等知识点,适合作为C#高级应用与IoT系统集成的实战练习。压缩包共57个文件,约465KB,包含6个cs源码、6个exe可执行程序、6个pdb调试符号、2个mdb数据库、2个dll动态库、1个csproj工程文件、1个sln解决方案以及resx资源、manifest清单、application配置等,另附课程设计文档,便于直接运行与二次开发。目前已有744人学习下载,可帮助读者理解硬件交互与软件架构的衔接方式,掌握从读卡事件到数据库落库的完整链路,并积累调试与排错经验。
1. 从一张公交卡到一台工控机:C# 做 RFID 智能公交刷卡系统到底在做什么
早高峰的公交车上,乘客把卡片往读卡器前一贴,"滴"一声扣费成功,整个过程不到 300 毫秒。这个场景背后是一套典型的 C# 上位机 + RFID 读写器 + 本地/远程数据库的架构。标题里的"C#实现基于RFID的智能公交刷卡系统",说的就是用 C# 写一个能跟 RFID 读卡器通信、识别卡片、扣减余额、记录流水的桌面程序。它解决的核心问题是:把一张 13.56MHz 的 IC 卡里的扇区数据读出来,经过业务逻辑判断后写回去,同时把交易记录落库。适合谁看?做 C# 上位机开发的、接单片机/嵌入式设备做考勤或消费终端的、以及想拿一个完整项目练手 WinForm 或 WPF 的工程师。下面我按实际做过的路径,从选型、通信、读写、扣费、避坑一路讲透。
2. 选型先定死:读卡器、卡片和 C# 通信层怎么配
2.1 读卡器与卡片的匹配逻辑
公交刷卡系统最常用的频段是 13.56MHz,对应 ISO 14443 Type A/B 协议。市面上常见的读卡器模块有 MFRC522(低成本,SPI 接口,适合嵌入式)、PN532(支持 NFC,串口/I2C/SPI)、以及各类 USB 免驱读卡器(厂商封装好 DLL,直接调 API)。如果你做的是 PC 端上位机,我一般会选 USB 免驱读卡器,因为它把底层协议栈封装好了,C# 侧只需要调用厂商提供的动态库。卡片方面,公交场景多用 Mifare Classic 1K(S50),有 16 个扇区,每个扇区 4 个块,每块 16 字节。第 0 扇区第 0 块是厂商固化数据,不可改写;每个扇区的第 3 块是密钥块,存放 Key A 和 Key B 以及访问控制位。
选型时要注意一个关键点:读卡器的 SDK 是否提供 C# 的 DLL 导入示例。有些厂商只给 C++ 的 .h 和 .lib,你需要自己用 P/Invoke 封装。常见做法是先用DllImport把读卡器的初始化、寻卡、防冲突、选卡、验证密钥、读块、写块这几个函数映射进来。
2.2 C# 通信层的三种接入方式
第一种是串口通信。读卡器通过 RS232 或 USB 转串口连接,C# 用System.IO.Ports.SerialPort类收发字节流。这种方式最灵活,但需要自己解析协议帧。第二种是 HID 免驱。读卡器模拟键盘输入,卡片号直接当键盘字符敲进文本框。这种方式开发最快,但只能读卡号,不能读写扇区数据。第三种是厂商 DLL。调用封装好的函数,传入卡号或块号,返回字节数组。公交刷卡系统需要扣费和写记录,必须能写扇区,所以第一种或第三种才可行。
我一般会优先选厂商 DLL 方案,因为稳定性和开发效率兼顾。下面是一个典型的 P/Invoke 封装示例:
using System; using System.Runtime.InteropServices; public class RfidReader { // 读卡器厂商提供的DLL名称,实际以厂商文档为准 private const string DllName = "RFIDReader.dll"; // 初始化读卡器,返回句柄 [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] public static extern int RFID_Init(); // 寻卡,返回卡片序列号 [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] public static extern int RFID_Request(byte mode, byte[] cardSn); // 验证扇区密钥,keyType: 0=KeyA, 1=KeyB [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] public static extern int RFID_Auth(byte sector, byte keyType, byte[] key); // 读块,blockNo: 绝对块号 0-63 [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] public static extern int RFID_ReadBlock(byte blockNo, byte[] data); // 写块 [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] public static extern int RFID_WriteBlock(byte blockNo, byte[] data); // 关闭读卡器 [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] public static extern int RFID_Close(); }这段代码的关键在于CallingConvention要和厂商 DLL 的导出约定一致,否则会出现栈不平衡导致程序崩溃。RFID_Request的mode参数通常 0x52 表示请求所有卡,0x26 表示只请求未进入休眠的卡。RFID_Auth的key是 6 字节数组,Mifare Classic 默认密钥全是 0xFF。RFID_ReadBlock的blockNo是绝对块号,扇区 0 的块 0 是 0,扇区 1 的块 0 是 4,以此类推。读出来的data是 16 字节。
2.3 卡片数据区的规划
公交卡的钱包数据不能随便放。我一般把扇区 1 用作钱包区,块 4 存余额和交易计数,块 5 存最近一笔交易记录,块 6 留作扩展。块 4 的 16 字节可以这样分配:前 4 字节存余额(单位分,小端序),第 5 字节存交易序号,第 6-10 字节存最后交易时间戳,剩余字节保留。这样设计的好处是读写原子性好,一次读块就能拿到扣费所需的全部信息。
注意:Mifare Classic 的密钥块一旦写错,整个扇区就锁死了,没有后悔药。调试阶段先用默认密钥 0xFFFFFFFFFFFF,确认逻辑跑通后再改密钥,改之前一定备份原密钥。
3. 把卡读明白:寻卡、防冲突、选卡和密钥验证的 C# 实现
3.1 寻卡与防冲突的完整流程
读卡器上电后,卡片进入射频场会触发寻卡。如果有两张以上的卡同时进入,就需要防冲突算法选出唯一一张。C# 侧要做的就是把厂商 DLL 的返回值逐层判断。下面是一个完整的寻卡到选卡的方法:
public string DetectCard() { byte[] cardSn = new byte[4]; // mode=0x26 只寻未休眠的卡,避免重复触发 int ret = RfidReader.RFID_Request(0x26, cardSn); if (ret != 0) { // 没有卡或寻卡失败,直接返回空 return null; } // 防冲突,获取完整序列号 byte[] fullSn = new byte[8]; ret = RfidReader.RFID_Anticoll(fullSn); if (ret != 0) { return null; } // 选卡,进入就绪状态 ret = RfidReader.RFID_Select(fullSn); if (ret != 0) { return null; } // 把序列号转成十六进制字符串,方便日志和数据库存储 return BitConverter.ToString(fullSn).Replace("-", ""); }RFID_Request的返回值 0 表示成功,非 0 表示失败。RFID_Anticoll返回的fullSn是 8 字节,但实际有效的是前 4 字节,后 4 字节是校验位。RFID_Select成功后卡片进入就绪状态,才能进行后续的密钥验证和读写。这段代码要放在一个循环里,每隔 200-300 毫秒调用一次,避免频繁寻卡导致读卡器发热。
3.2 密钥验证与扇区读取
寻卡成功后,必须对目标扇区进行密钥验证,否则读写操作会被拒绝。验证时要注意:一次验证只对一个扇区有效,换扇区要重新验证。下面是验证扇区 1 并读取块 4 的代码:
public byte[] ReadWalletBlock() { // 扇区1的KeyA,默认全FF,实际项目要改成自己的密钥 byte[] keyA = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }; // 验证扇区1的KeyA,keyType=0表示KeyA int ret = RfidReader.RFID_Auth(1, 0, keyA); if (ret != 0) { throw new Exception("扇区1密钥验证失败,返回值:" + ret); } // 读取块4,块4是扇区1的第一个数据块 byte[] data = new byte[16]; ret = RfidReader.RFID_ReadBlock(4, data); if (ret != 0) { throw new Exception("读取块4失败,返回值:" + ret); } return data; }RFID_Auth的第二个参数 0 表示用 KeyA 验证,1 表示用 KeyB。RFID_ReadBlock的块号是绝对块号,扇区 1 的范围是块 4 到块 7,其中块 7 是密钥块,不能当数据块读。读出来的 16 字节里,前 4 字节是余额,用BitConverter.ToInt32(data, 0)转成整数。如果余额是 0 或者负数,说明卡片数据异常,要拒绝交易并记录日志。
3.3 余额解析与交易计数
余额的存储格式要和写卡逻辑严格对应。我一般用小端序存 int32,单位是分。比如余额 12.50 元,存进去就是 1250。交易计数用一个字节,每扣费一次加 1,溢出后回绕到 0。下面是一个解析方法:
public class WalletInfo { public int Balance { get; set; } // 余额,单位分 public byte TxCount { get; set; } // 交易计数 public DateTime LastTxTime { get; set; } // 最后交易时间 } public WalletInfo ParseWallet(byte[] blockData) { var info = new WalletInfo(); // 前4字节是余额,小端序 info.Balance = BitConverter.ToInt32(blockData, 0); // 第5字节是交易计数 info.TxCount = blockData[4]; // 第6-10字节是时间戳,用Unix秒存储 int unixSeconds = BitConverter.ToInt32(blockData, 5); info.LastTxTime = DateTimeOffset.FromUnixTimeSeconds(unixSeconds).LocalDateTime; return info; }这里有个细节:BitConverter.ToInt32默认就是小端序,和 x86/x64 的存储一致,不需要额外转换。时间戳用 Unix 秒存储,4 字节够用到 2038 年,公交卡场景完全够用。如果卡片是空白的,读出来的数据全是 0,余额为 0,这时候要提示"请先充值"。
4. 扣费与写卡:一次完整交易的原子性怎么保证
4.1 扣费逻辑的状态机设计
公交刷卡不是简单的"读余额减一块钱写回去"。要考虑重复刷卡、余额不足、写卡失败、断电等异常。我一般用一个状态机来管理:空闲 -> 寻卡 -> 读卡 -> 校验 -> 扣费 -> 写卡 -> 记录 -> 空闲。每个状态都有超时和回滚逻辑。下面是一个简化的状态机实现:
public enum TxState { Idle, CardDetected, WalletRead, BalanceChecked, Writing, Completed, Failed } public class BusCardTransaction { private TxState _state = TxState.Idle; private string _cardSn; private WalletInfo _wallet; private int _fare = 100; // 票价1元,单位分 public void ProcessCard(string cardSn) { // 防止同一张卡在短时间内重复扣费 if (_state != TxState.Idle) { return; } _cardSn = cardSn; _state = TxState.CardDetected; try { // 读钱包 byte[] block = ReadWalletBlock(); _wallet = ParseWallet(block); _state = TxState.WalletRead; // 校验余额 if (_wallet.Balance < _fare) { _state = TxState.Failed; LogError("余额不足,卡号:" + _cardSn); return; } _state = TxState.BalanceChecked; // 扣费 _wallet.Balance -= _fare; _wallet.TxCount++; _wallet.LastTxTime = DateTime.Now; // 写回卡片 _state = TxState.Writing; byte[] newBlock = BuildWalletBlock(_wallet); int ret = RfidReader.RFID_WriteBlock(4, newBlock); if (ret != 0) { _state = TxState.Failed; LogError("写卡失败,卡号:" + _cardSn + ",返回值:" + ret); return; } // 记录交易流水 SaveTransaction(_cardSn, _fare, _wallet.Balance); _state = TxState.Completed; } catch (Exception ex) { _state = TxState.Failed; LogError("交易异常:" + ex.Message); } finally { // 无论成功失败,都要把状态复位,准备下一张卡 _state = TxState.Idle; } } }这个状态机的核心是finally块里的状态复位,保证一次异常不会让系统卡死。_fare是票价,实际项目里可以从配置文件或数据库读取。SaveTransaction方法把流水写入本地 SQLite 或 SQL Server,字段包括卡号、扣费金额、扣费后余额、交易时间、设备编号。
4.2 写卡失败的补偿机制
写卡失败是公交场景最头疼的问题。卡片可能因为移动太快离开射频场,或者写的过程中断电。我的做法是:写卡前先把原始块数据备份到内存,写卡失败后立即尝试重写一次;如果重写也失败,就把这笔交易标记为"待补偿",写入本地补偿表,等卡片下次刷卡时先检查补偿表,如果有未完成的交易,先补写再扣费。
private void SaveCompensation(string cardSn, byte[] originalBlock, int fare) { // 补偿表结构:卡号、原始块数据、应扣金额、状态 string sql = "INSERT INTO Compensation (CardSn, OriginalBlock, Fare, Status) VALUES (@sn, @block, @fare, 0)"; using (var conn = new SqlConnection(_connStr)) { conn.Open(); using (var cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@sn", cardSn); cmd.Parameters.AddWithValue("@block", originalBlock); cmd.Parameters.AddWithValue("@fare", fare); cmd.ExecuteNonQuery(); } } }补偿表的Status字段 0 表示待处理,1 表示已补偿。每次寻卡成功后,先查补偿表里有没有这张卡的待处理记录,有就先执行补偿写卡,成功后再走正常扣费流程。这个机制能覆盖 99% 的写卡失败场景,剩下的 1% 需要人工介入。
4.3 交易流水的批量入库
公交刷卡系统的流水量很大,一辆车一天可能产生几千条记录。如果每条都单独INSERT,数据库压力大。我一般用SqlBulkCopy批量写入,每 100 条或每 5 秒刷一次。下面是一个批量入库的封装:
public void BulkSaveTransactions(List<TransactionRecord> records) { if (records == null || records.Count == 0) return; var dt = new DataTable(); dt.Columns.Add("CardSn", typeof(string)); dt.Columns.Add("Fare", typeof(int)); dt.Columns.Add("Balance", typeof(int)); dt.Columns.Add("TxTime", typeof(DateTime)); dt.Columns.Add("DeviceId", typeof(string)); foreach (var r in records) { dt.Rows.Add(r.CardSn, r.Fare, r.Balance, r.TxTime, r.DeviceId); } using (var conn = new SqlConnection(_connStr)) { conn.Open(); using (var bulk = new SqlBulkCopy(conn)) { bulk.DestinationTableName = "TransactionLog"; bulk.BatchSize = 100; bulk.WriteToServer(dt); } } }SqlBulkCopy的BatchSize设为 100 是个经验值,太小频繁提交,太大内存占用高。DestinationTableName要和数据库表名一致,列名也要对应,否则会报错。如果数据库表结构变了,比如加了字段,DataTable的列也要同步加,否则WriteToServer会失败。
提示:
SqlBulkCopy不触发触发器,如果流水表上有触发器做统计,要改用普通INSERT或者手动补统计逻辑。
5. 避坑与排查:公交刷卡系统上线后最容易翻车的 5 个点
5.1 寻卡成功但验证密钥失败
现象:读卡器能读到卡号,但RFID_Auth一直返回非 0,换几张卡都一样。原因通常是密钥类型搞反了。Mifare Classic 的密钥块里 Key A 和 Key B 是分开的,有些卡出厂时 Key A 可读可写,Key B 只读。如果你用 KeyA 验证失败,试试 KeyB。另一个原因是扇区号传错了,扇区 1 的密钥验证要传 1,不是传块号 4。解决方法是先用厂商提供的演示程序确认密钥和扇区号,再回到 C# 代码里对照。
5.2 写卡后余额没变
现象:扣费逻辑执行了,流水也记录了,但卡片再刷一次余额还是原来的。原因是写卡后没有等待卡片确认,或者写卡函数返回成功但实际没写入。Mifare Classic 的写操作需要卡片返回 ACK 才算成功,有些 DLL 封装层把 ACK 判断省略了。解决方法是写卡后立即读回同一块,比对数据是否一致。如果不一致,重写一次。如果重写还不一致,把卡片移开再放回来,重新寻卡验证后再写。
5.3 多张卡同时进入射频场导致扣费错乱
现象:乘客把两张卡叠在一起贴上去,系统扣了其中一张的钱,但另一张也显示扣费。原因是防冲突算法选了一张卡,但业务层没有锁定卡号,后续写卡时又选了另一张。解决方法是在DetectCard返回卡号后,立即把卡号存入一个_currentCardSn变量,后续所有读写操作都校验当前卡号是否一致。如果不一致,直接放弃本次交易。
5.4 串口通信偶发超时
现象:读卡器用串口连接时,偶尔出现SerialPort读超时,程序卡死。原因是SerialPort的ReadTimeout默认是InfiniteTimeout,读不到数据就一直等。解决方法是设置ReadTimeout = 500,并在catch (TimeoutException)里做重试或复位读卡器。另外,串口打开和关闭要在同一个线程里,避免跨线程操作导致InvalidOperationException。
5.5 数据库连接池耗尽
现象:系统运行几个小时后,报"超时时间已到,但是尚未从池中获取连接"。原因是每次交易都new SqlConnection但没有Dispose,连接池被占满。解决方法是所有数据库操作都用using包裹,确保连接释放。如果用了SqlBulkCopy,也要放在using里。另外,连接字符串里可以加Max Pool Size=100提高上限,但根本解决还是及时释放。
6. 进阶技巧:用配置化和日志把系统做成能远程运维的形态
6.1 把票价、密钥、设备编号抽到配置文件
硬编码的票价和密钥是运维的噩梦。改一次票价要重新编译发版,司机等不起。我一般用appsettings.json存这些参数,C# 侧用Microsoft.Extensions.Configuration读取。下面是一个配置示例:
{ "BusCard": { "Fare": 100, "SectorKeyA": "FFFFFFFFFFFF", "SectorKeyB": "FFFFFFFFFFFF", "DeviceId": "BUS-001", "DbConnStr": "Server=.;Database=BusCard;Trusted_Connection=True;" } }读取代码:
using Microsoft.Extensions.Configuration; var config = new ConfigurationBuilder() .SetBasePath(AppDomain.CurrentDomain.BaseDirectory) .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) .Build(); int fare = config.GetValue<int>("BusCard:Fare"); string keyA = config.GetValue<string>("BusCard:SectorKeyA"); string deviceId = config.GetValue<string>("BusCard:DeviceId");reloadOnChange: true让配置文件修改后自动生效,不用重启程序。密钥用十六进制字符串存储,读取后用Convert.FromHexString转成字节数组。注意Convert.FromHexString是 .NET 5 以上才有的,老版本用Enumerable.Range(0, hex.Length / 2).Select(i => Convert.ToByte(hex.Substring(i * 2, 2), 16)).ToArray()。
6.2 日志分级与远程上报
公交车的运行环境没有专职运维,出了问题只能靠日志回溯。我一般用NLog或Serilog做分级日志:Info记录每次交易,Warn记录余额不足和写卡重试,Error记录异常和补偿。日志文件按天切割,保留 30 天。如果车辆有 4G 网络,再加一个远程上报通道,把Error级别的日志实时推到服务端。
// NLog配置示例(NLog.config) // <target name="file" xsi:type="File" fileName="logs/${shortdate}.log" /> // <logger name="*" minlevel="Info" writeTo="file" /> private static readonly NLog.Logger Logger = NLog.LogManager.GetCurrentClassLogger(); Logger.Info("交易成功,卡号:{0},扣费:{1},余额:{2}", cardSn, fare, balance); Logger.Warn("余额不足,卡号:{0},余额:{1}", cardSn, balance); Logger.Error(ex, "写卡失败,卡号:{0}", cardSn);日志里不要打完整的卡号,做脱敏处理,只保留后 4 位。这既是安全要求,也能减少日志体积。远程上报用HttpClient异步发送,不要阻塞主线程。
6.3 用看门狗线程监控读卡器状态
读卡器长时间运行可能掉线,表现为寻卡一直失败。我一般起一个后台线程,每 30 秒发一次寻卡命令,连续 3 次失败就调用RFID_Close和RFID_Init重新初始化。这个线程用System.Threading.Timer实现,注意回调里访问 UI 控件要Invoke。
private System.Threading.Timer _watchdog; private void StartWatchdog() { _watchdog = new System.Threading.Timer(state => { byte[] sn = new byte[4]; int ret = RfidReader.RFID_Request(0x52, sn); if (ret != 0) { _failCount++; if (_failCount >= 3) { RfidReader.RFID_Close(); System.Threading.Thread.Sleep(500); RfidReader.RFID_Init(); _failCount = 0; Logger.Warn("读卡器重新初始化"); } } else { _failCount = 0; } }, null, 30000, 30000); }RFID_Request的mode用 0x52 是请求所有卡,包括休眠的,这样能检测到读卡器是否正常工作。_failCount是连续失败计数,成功一次就清零。重新初始化前先Close再Sleep500 毫秒,给硬件放电时间。
这套系统我前后调了三个月,最大的教训是:不要相信读卡器返回的"成功",一定要读回验证。写卡后读回比对,扣费后查流水确认,补偿表每天对账。公交场景的容错率要求极高,一次扣费错误就可能引发投诉。希望帮到你。
本文还有配套的精品资源,点击获取