简介:这份资源是一套面向C#初学者与物联网开发入门者的完整课程设计项目,以智能公交刷卡场景为载体,演示如何用C#对接RFID读卡器完成身份识别与扣费流程。项目覆盖串口通信、事件驱动编程、ADO.NET数据库操作、WinForms界面设计、异常处理与多线程等核心知识点,适合作为课程设计参考或C#硬件交互练手案例。压缩包共57个文件,约465KB,包含6个cs源码、6个exe可执行程序、6个pdb调试符号、2个mdb数据库、2个dll类库以及sln解决方案、csproj工程文件、resx资源与课程设计文档等,源码、可执行文件与说明文档齐备,便于直接运行与二次修改。目前已有744人学习下载。读者可借此理解RFID刷卡系统的整体架构,掌握C#与硬件通信、数据持久化及界面交互的实现思路,并参考文档完成自己的课程设计或扩展为更完整的公交管理应用。
1. 从一张公交卡到一台工控机:RFID 智能公交刷卡系统到底在做什么
早高峰的公交车上,乘客把卡片往读卡器前一晃,不到 0.3 秒就完成了扣费和记录。这个动作背后,是一套典型的 C# 上位机 + RFID 读写器 + 业务数据库的联动系统。标题里的「C#实现基于RFID的智能公交刷卡系统」,说白了就是用 C# 写一个能跟 RFID 读卡器通信、识别乘客卡片、完成扣费逻辑、把交易记录落库的桌面程序。它解决的核心问题是:把物理卡片的唯一 ID 读出来,跟数据库里的账户信息匹配,完成一次可靠的扣款并留下可追溯的流水。适合谁看?做 C# 上位机开发的、接 RFID 硬件做考勤或门禁的、想从零搭一套刷卡消费原型的,都能从这套结构里拿到可复用的东西。公交刷卡听起来简单,但涉及串口通信、防冲突、事务一致性、断网缓存,坑一点不比工业上位机少。
2. 选型先定死:读卡器、卡片和通信协议怎么配
2.1 公交场景为什么优先选 13.56MHz 而非 125kHz
RFID 按频段分低频(125kHz)、高频(13.56MHz)、超高频(860-960MHz)。公交刷卡这个场景,我一般直接锁定 13.56MHz 高频方案,原因有三条。第一,高频卡支持防冲突机制,多张卡同时靠近读卡器时能逐张识别,不会像低频卡那样互相干扰导致读不到;第二,高频卡的读写距离在 2-10cm,正好匹配「乘客主动靠近刷卡」的动作,不会误读旁边人的卡;第三,13.56MHz 生态成熟,M1 卡、CPU 卡、NFC 手机模拟卡都能兼容,后续升级不用换硬件。
低频 125kHz 卡虽然便宜,但它的读写距离能到 10cm 以上,公交车上人挤人的时候容易误读,而且低频卡基本没有加密能力,卡号容易被复制。超高频 860-960MHz 更适合物流仓储那种远距离批量盘点,放到公交场景反而因为距离太远导致误刷。所以选型结论很明确:读卡器选 13.56MHz 高频模块,卡片选 ISO 14443 Type A 或 Type B 协议,如果要支持手机 NFC 刷卡,读卡器还得支持 ISO 18092 协议。
2.2 读卡器与 C# 上位机的三种通信方式对比
读卡器和 C# 程序之间怎么连,直接决定了代码结构。常见做法有三种:
| 通信方式 | 典型接口 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 串口(RS232/RS485) | COM 口 | 稳定、抗干扰、线缆长 | 需要转接、速率低 | 车载固定安装 |
| USB HID | 免驱 | 即插即用、开发快 | 线缆短、供电受限 | 桌面发卡器 |
| 网络(TCP/IP) | 网口/WiFi | 远程管理、多机联网 | 依赖网络、延迟波动 | 站台闸机 |
公交车上震动大、电磁环境复杂,我一般选 RS485 串口方案,走 Modbus RTU 协议跟读卡器通信。如果是做原型验证,USB HID 读卡器最省事,插上就能用,C# 里用HidLibrary或者厂商 SDK 直接读卡号。网络方案适合固定站台,但车载环境网络不稳定,不建议作为主通道。
2.3 卡片数据区的规划:卡号放哪里、余额放哪里
M1 卡有 16 个扇区,每个扇区 4 个块,每块 16 字节。公交刷卡系统一般这样规划:
- 第 0 扇区块 0:厂商固化,存 UID,只读
- 第 1 扇区:存卡片业务信息,块 0 存卡号(4 字节)+ 卡类型(1 字节)+ 发卡日期(4 字节),块 1 存余额(4 字节,单位分),块 2 存最近一次交易时间戳
- 第 2 扇区往后:预留扩展,比如月卡有效期、优惠类型
余额用「分」做单位存整数,避免浮点数精度问题。这个细节很多人翻车:用 float 存余额,扣几次之后出现 0.01 的误差,对账的时候对不上。血泪经验,金额一律用 int 或 decimal。
// 卡片数据结构定义:余额用 int 存分,避免浮点误差 public class BusCard { public string CardUid { get; set; } // 卡片唯一ID,十六进制字符串 public int CardType { get; set; } // 1=普通卡 2=学生卡 3=老年卡 public int BalanceInCents { get; set; } // 余额,单位:分 public DateTime LastTradeTime { get; set; } // 最近交易时间 public bool IsValid { get; set; } // 卡片是否有效 }上面这个类是整个系统的数据核心。CardUid从读卡器读出来,BalanceInCents用整数存分,扣费时直接做整数减法。CardType决定扣费折扣,比如学生卡五折、老年卡免费。LastTradeTime用来做防重复刷卡的判断——同一张卡在 3 秒内连续刷两次,第二次直接拒绝,防止乘客误触。
2.4 读卡器 SDK 接入的最小验证步骤
拿到读卡器之后,别急着写业务逻辑,先跑通「读卡号」这一个动作。以常见的串口读卡器为例:
using System.IO.Ports; public class RfidReader { private SerialPort _port; public bool Open(string portName, int baudRate = 115200) { _port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); _port.ReadTimeout = 500; // 读超时500ms,避免界面卡死 _port.WriteTimeout = 500; try { _port.Open(); return true; } catch (Exception ex) { // 常见失败:COM口被占用、驱动未装、波特率不匹配 Console.WriteLine($"打开串口失败: {ex.Message}"); return false; } } // 发送寻卡命令,返回卡号;读不到返回null public string ReadCardUid() { byte[] cmd = new byte[] { 0xAA, 0xBB, 0x01, 0x00, 0x00 }; // 厂商协议寻卡指令 _port.Write(cmd, 0, cmd.Length); try { byte[] buffer = new byte[16]; int len = _port.Read(buffer, 0, buffer.Length); if (len >= 7 && buffer[0] == 0xAA && buffer[1] == 0xBB) { // 卡号在buffer[3]到buffer[6],转成十六进制字符串 return BitConverter.ToString(buffer, 3, 4).Replace("-", ""); } } catch (TimeoutException) { // 超时说明没有卡片靠近,正常情况,不报错 } return null; } }这段代码的关键点:ReadTimeout必须设,否则没有卡片的时候Read会一直阻塞,界面直接假死。寻卡指令的字节序列每个厂商不一样,0xAA 0xBB只是示例,实际要查读卡器手册。返回的卡号做十六进制转换时注意字节序,有些读卡器返回的是低位在前,需要反转。验证阶段就用一个简单的 WinForm,放个按钮,点一下读一次,能在文本框里看到卡号就算通了。
3. 扣费逻辑与数据落库:从一次刷卡到一条流水
3.1 一次完整刷卡的业务流程拆解
刷卡不是「读卡-扣钱」两步就完事。完整的流程是这样的:
- 读卡器读到卡号,C# 程序拿到 UID
- 查本地缓存或数据库,确认卡片是否已发卡、是否在黑名单
- 检查是否重复刷卡(同一卡号 3 秒内二次触发直接忽略)
- 根据卡类型计算本次扣费金额
- 检查余额是否足够,不足则提示并记录异常交易
- 执行扣费:更新数据库余额 + 写交易流水
- 把新余额写回卡片(如果卡片支持读写)
- 语音播报 + 界面显示扣费结果
这八步里,第 6 步是事务核心。数据库更新和流水写入必须在同一个事务里,否则出现「扣了钱没记录」或者「记录了没扣钱」,对账就是一场灾难。
3.2 用 SqlBulkCopy 批量写入交易流水的正确姿势
公交系统的交易流水量很大,一辆车一天可能产生几千条记录。如果每条都走INSERT,数据库压力大。常见做法是本地先攒一批,比如 50 条或者每 5 秒,用SqlBulkCopy批量写入。
using System.Data; using System.Data.SqlClient; public class TradeLogWriter { private DataTable _buffer; private const int FlushThreshold = 50; // 攒够50条就写一次 public TradeLogWriter() { _buffer = new DataTable(); _buffer.Columns.Add("CardUid", typeof(string)); _buffer.Columns.Add("Amount", typeof(int)); // 扣费金额,单位分 _buffer.Columns.Add("BalanceAfter", typeof(int)); // 扣费后余额 _buffer.Columns.Add("TradeTime", typeof(DateTime)); _buffer.Columns.Add("DeviceId", typeof(string)); } public void AddRecord(string cardUid, int amount, int balanceAfter, string deviceId) { _buffer.Rows.Add(cardUid, amount, balanceAfter, DateTime.Now, deviceId); if (_buffer.Rows.Count >= FlushThreshold) { Flush(); } } public void Flush() { if (_buffer.Rows.Count == 0) return; using (var conn = new SqlConnection("Server=.;Database=BusSystem;Trusted_Connection=True;")) { conn.Open(); using (var bulk = new SqlBulkCopy(conn)) { bulk.DestinationTableName = "TradeLog"; bulk.BatchSize = 100; bulk.BulkCopyTimeout = 30; // 列映射必须显式指定,否则列顺序不一致会写错数据 bulk.ColumnMappings.Add("CardUid", "CardUid"); bulk.ColumnMappings.Add("Amount", "Amount"); bulk.ColumnMappings.Add("BalanceAfter", "BalanceAfter"); bulk.ColumnMappings.Add("TradeTime", "TradeTime"); bulk.ColumnMappings.Add("DeviceId", "DeviceId"); bulk.WriteToServer(_buffer); } } _buffer.Clear(); } }SqlBulkCopy的坑在于列映射。如果目标表的列顺序跟 DataTable 不一致,又不写ColumnMappings,数据会串列——卡号写到金额字段里,这种错误排查起来很痛苦。另外BulkCopyTimeout默认 30 秒,数据量大的时候要调大。还有一点:SqlBulkCopy不走触发器,如果目标表上有触发器做额外逻辑,批量写入不会触发,需要改用其他方式。
3.3 断网缓存与补传:车载环境的后悔药
公交车开在路上,4G 信号时有时无。如果每次刷卡都要实时写远程数据库,网络一断整个系统就瘫了。我一般用 SQLite 做本地缓存,刷卡记录先写本地,网络恢复后再补传到中心数据库。
using System.Data.SQLite; public class LocalCache { private string _connStr = "Data Source=bus_cache.db;Version=3;"; public void Init() { using (var conn = new SQLiteConnection(_connStr)) { conn.Open(); string sql = @"CREATE TABLE IF NOT EXISTS PendingTrade ( Id INTEGER PRIMARY KEY AUTOINCREMENT, CardUid TEXT NOT NULL, Amount INTEGER NOT NULL, BalanceAfter INTEGER NOT NULL, TradeTime TEXT NOT NULL, DeviceId TEXT, Synced INTEGER DEFAULT 0 )"; using (var cmd = new SQLiteCommand(sql, conn)) { cmd.ExecuteNonQuery(); } } } public void SaveTrade(string cardUid, int amount, int balanceAfter, string deviceId) { using (var conn = new SQLiteConnection(_connStr)) { conn.Open(); string sql = "INSERT INTO PendingTrade (CardUid,Amount,BalanceAfter,TradeTime,DeviceId) VALUES (@c,@a,@b,@t,@d)"; using (var cmd = new SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@c", cardUid); cmd.Parameters.AddWithValue("@a", amount); cmd.Parameters.AddWithValue("@b", balanceAfter); cmd.Parameters.AddWithValue("@t", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); cmd.Parameters.AddWithValue("@d", deviceId); cmd.ExecuteNonQuery(); } } } }本地缓存的表结构里有个Synced字段,默认 0。后台起一个定时器,每 30 秒检查一次网络,通了就把Synced=0的记录批量传到中心库,传成功后更新为 1。这个机制是车载系统的后悔药——网络断了不影响刷卡,恢复后自动补数据。注意 SQLite 在多线程写入时会锁库,刷卡线程和补传线程要错开,或者用WAL模式提升并发。
3.4 余额扣减的并发安全:别让两张卡同时扣同一笔钱
如果系统支持在线扣费(余额存在中心数据库),必须考虑并发。两个读卡器同时读到同一张卡(比如前后门各一个),同时发起扣费请求,不加锁就会扣两次。常见做法是在数据库层用乐观锁:
UPDATE CardAccount SET Balance = Balance - @amount, Version = Version + 1, LastTradeTime = GETDATE() WHERE CardUid = @cardUid AND Balance >= @amount AND Version = @expectedVersion;执行后检查@@ROWCOUNT,如果返回 0,说明版本号变了或者余额不足,扣费失败,需要重新读卡再试。Version字段每次更新加 1,读的时候一起读出来,更新的时候作为条件带上。这个模式比SELECT ... FOR UPDATE轻量,适合读多写少的公交场景。
4. 避坑与排查:公交刷卡系统上线后最容易翻车的五个地方
4.1 读卡距离忽远忽近,同一张卡有时读得到有时读不到
现象:乘客刷卡时,有时候卡片贴上去没反应,要挪个位置才行。换了读卡器还是一样。
原因:天线匹配电路没调好,或者读卡器周围有金属干扰。公交车的刷卡面板后面往往是金属支架,金属会吸收 13.56MHz 的电磁场,导致读卡距离从 5cm 缩到 1cm。另外,读卡器供电不稳也会影响射频功率。
解决:读卡器天线和金属面之间垫一层铁氧体隔磁片,厚度 0.5-1mm 就行。供电单独走一路 LDO,别跟电机、喇叭共用。软件层面加一个「寻卡重试」逻辑,一次读不到就连续发三次寻卡命令,间隔 50ms,成功率能明显提升。
4.2 扣费成功但流水没写进去,对账时少了几笔
现象:乘客反映扣了钱,但后台查不到交易记录。月底对账,流水总额跟实际收款对不上。
原因:扣费和写流水没放在同一个事务里。代码先更新了余额,然后写流水的时候数据库连接断了或者程序崩了,余额扣了但流水丢了。
解决:把「更新余额」和「插入流水」包在同一个SqlTransaction里,要么都成功,要么都回滚。如果余额在卡片上、流水在数据库,那就先写流水再写卡,写卡失败就冲正流水。顺序很重要:先落库,后写卡,因为数据库有事务,卡片写入失败可以重试。
4.3 串口打开失败,提示「拒绝访问」
现象:程序启动时报UnauthorizedAccessException,COM 口打不开。
原因:COM 口被其他程序占用了。常见的是调试助手没关,或者上一次程序异常退出没释放串口句柄。
解决:在Open之前先枚举可用串口,用SerialPort.GetPortNames()确认目标口存在。打开失败时给明确提示,别直接崩。程序退出时在FormClosing事件里确保_port.Close()和_port.Dispose()都调了。如果还是占用,用设备管理器看是哪个进程占的,实在不行重启。另外注意:USB 转串口线拔了再插,COM 口号可能会变,代码里别写死,做成配置项。
4.4 界面卡死,刷卡后要等好几秒才响应
现象:点「开始刷卡」之后界面就卡住了,乘客刷完卡要等 3-5 秒才显示结果。
原因:串口读取和数据库操作都跑在 UI 线程上。SerialPort.Read是阻塞的,数据库查询也是同步的,UI 线程被占住,界面自然不刷新。
解决:把读卡循环放到后台线程,用Task.Run或者BackgroundWorker。读到的卡号通过Invoke回到 UI 线程更新界面。数据库操作也异步化,用async/await配SqlConnection.OpenAsync。注意:SerialPort的DataReceived事件在后台线程触发,直接在里面更新控件会抛跨线程异常,必须Invoke。
4.5 同一张卡连续刷两次,扣了两笔钱
现象:乘客刷完卡没听到提示音,又刷了一次,结果扣了两次费。
原因:没有做重复刷卡判断。读卡器读到卡就触发扣费,乘客连续靠近两次就扣两次。
解决:在内存里维护一个「最近刷卡记录」字典,key 是卡号,value 是上次刷卡时间。每次读到卡先查字典,如果距上次刷卡小于 3 秒,直接忽略并提示「请勿重复刷卡」。字典要定期清理,避免内存泄漏。另外,语音播报要快,扣费成功后 200ms 内出声音,乘客听到声音就不会再刷了。
5. 进阶技巧:把刷卡响应压到 200ms 以内的三个调优手段
5.1 预加载卡片白名单到内存
每次刷卡都查数据库确认卡片有效性,这一趟往返至少 10-20ms。如果车载环境网络不好,可能到 100ms 以上。我的做法是:车辆启动时把该车线路允许的卡片白名单一次性加载到内存字典里,刷卡时直接查内存,命中率 99% 以上。只有内存里没有的卡才去查数据库。白名单字典用ConcurrentDictionary<string, BusCard>,支持多线程并发读。
// 启动时预加载,刷卡时O(1)查询 private ConcurrentDictionary<string, BusCard> _cardCache = new(); public void PreloadCards(List<BusCard> cards) { foreach (var card in cards) { _cardCache[card.CardUid] = card; } } public BusCard GetCard(string uid) { // 内存命中直接返回,未命中才查库 if (_cardCache.TryGetValue(uid, out var card)) return card; return QueryFromDatabase(uid); }这个优化的效果立竿见影:刷卡响应从平均 80ms 降到 15ms 以内。注意缓存要设过期策略,比如每 10 分钟刷新一次,否则后台改了黑名单,车上不知道。
5.2 串口读取用事件驱动替代轮询
很多示例代码用while(true)循环发寻卡命令,CPU 占用高不说,响应还有延迟。更好的方式是用SerialPort.DataReceived事件,读卡器读到卡主动上报,C# 收到数据再解析。这样 CPU 占用从 15% 降到 1% 以下,响应也更快。
_port.DataReceived += OnCardReceived; private void OnCardReceived(object sender, SerialDataReceivedEventArgs e) { int len = _port.BytesToRead; byte[] buffer = new byte[len]; _port.Read(buffer, 0, len); // 解析卡号,触发业务逻辑 string uid = ParseUid(buffer); if (uid != null) { // 抛到线程池处理,不阻塞串口接收线程 Task.Run(() => ProcessCard(uid)); } }关键点:DataReceived事件里不要做耗时操作,解析出卡号后立刻丢给线程池,否则会阻塞下一次接收。
5.3 用状态机管理刷卡流程,避免逻辑混乱
刷卡流程有多个状态:等待刷卡、读卡中、扣费中、写卡中、完成、异常。如果用一堆if-else判断,代码很快就乱了。我一般用一个简单的状态机:
| 当前状态 | 触发事件 | 下一状态 | 动作 |
|---|---|---|---|
| 等待刷卡 | 读到卡号 | 校验中 | 查缓存/数据库 |
| 校验中 | 卡片有效 | 扣费中 | 计算金额、更新余额 |
| 校验中 | 卡片无效 | 等待刷卡 | 语音提示无效卡 |
| 扣费中 | 扣费成功 | 写卡中 | 更新卡片余额 |
| 扣费中 | 余额不足 | 等待刷卡 | 语音提示余额不足 |
| 写卡中 | 写卡成功 | 等待刷卡 | 语音播报扣费金额 |
| 写卡中 | 写卡失败 | 异常处理 | 记录异常、冲正 |
状态机的好处是每个状态的职责清晰,异常分支不会漏。用 C# 的enum加switch就能实现,不需要引入状态机框架。
5.4 验证方法:用一张卡跑 1000 次看稳定性
系统调完之后怎么验证?我的习惯是写一个压力测试脚本,用一张卡连续刷 1000 次,每次间隔 500ms,统计成功率、平均响应时间、失败原因分布。重点看三个指标:成功率要 99.9% 以上,平均响应时间 200ms 以内,失败记录里不能有「扣费成功但流水丢失」的情况。如果成功率不达标,先查串口通信,再查数据库事务,最后查并发锁。这个测试跑一晚上,第二天看结果,比手动刷几十次靠谱得多。
做这类系统,我最大的教训是:别信「理论上没问题」,一定要在真实车厢环境里测。实验室里读卡距离 5cm,装到车上可能只有 1cm。数据库在本地跑得飞快,连上 4G 模块延迟就上来了。每次我觉得「差不多了」的时候,上车一测总能发现新问题。所以留足现场调试的时间,比什么都重要。希望帮到你。
本文还有配套的精品资源,点击获取