ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C# IC卡硬件读写实战:用PC/SC标准直接驱动读卡器

C# IC卡硬件读写实战:用PC/SC标准直接驱动读卡器 简介这份C# IC卡读写实例源码面向需要开发智能卡桌面应用的.NET开发者演示了基于PC/SC与APDU命令实现读卡器连接、选卡、数据读写等完整过程。资源包共49个文件以cs源码为核心包含Form1/Form2/Form3等窗体逻辑、baseClass封装类、db1.mdb数据库及可执行程序exe等配套dll动态库与pdb调试符号压缩包仅1023KB轻量易用。源码中涉及ISO 7816协议、SerialPort串行通信、PCSC-sharp库调用以及SELECT、READ BINARY、UPDATE BINARY等APDU指令并处理了异常与安全校验。已有737人学习下载适合初学IC卡通信或需要快速搭建读卡器读写功能的C#开发者参考借鉴。通过阅读工程目录和实际运行可理解从读卡器初始化到断开连接的完整流程直接复用于门禁、会员卡等硬件交互场景。1. 先把这个“C# IC卡读写 实例源码(硬件读写)”看透如果你接触过考勤机、门禁控制器或者食堂消费机大概率会碰到一个让人头疼的问题设备厂家只给一个 DLL 或 OCX里面封装好了读卡、扣费等函数你在 C# 里 P/Invoke 或引用 Com 组件就能用。但一旦换读卡器品牌、换 USB 口、换操作系统的 64/32 位这套第三方封装就可能失灵。所谓“C# IC卡读写 实例源码(硬件读写)”核心价值不在于那些卡号读取界面或 SQL 语句而在于直接驱动读卡器硬件本身枚举设备、建立连接、发 APDU 指令、认证扇区、读写块数据。这些动作全部走 Windows 的 PC/SC 标准接口不依赖某个厂商的处理。适合谁写 C# 上位机、需要对接 M1 卡做门禁或会员系统、或者一直在用厂家 DLL 想换成标准方案的开发者。我打算直接给你一套能在市面上常见读卡器上跑通的硬件读写最小实现连 APDU 指令格式和踩坑点都拆开讲。新手照着敲能通熟手可以直接拿走替换自己的兼容层。2. 设备选型与通信框架串口读卡器和 PC/SC 标准的差别2.1 为什么我不推荐直接用厂家 DLL十多年前做 IC 卡硬件读写最典型的方案是读卡器通过 RS232 串口连接厂家提供一个 SendCommand 函数C# 调用 SerialPort 发送一帧十六进制指令返回的字节数组就是结果。这个方案逻辑简单但坑非常固定帧协议每家不同波特率、校验字节、握手顺序都可能不一样换一体机时整个通信层重写而且很多 DLL 是 VC6 写的32 位和 64 位进程混用还会出内存问题。现在 Windows 自带 PC/SC 标准接口应用的默认中文名就叫“智能卡读卡器”。系统层面把读卡器的 USB 或串口 HID 统一暴露成逻辑设备你只需要调用 winscard.dll 里的函数SCardListReaders 枚举读卡器名SCardConnect 建立连接SCardTransmit 发送 APDU 命令SCardControl 做直通指令。这与品牌无关市面上的 ACR122U、明华、明基、FCR 这类主流读卡器都支持 PC/SC。做 C# 上位机通讯时我一般只用 PC/SC厂商自己的动态库只在特殊需求比如固件升级时才去碰。上述连接的稳定性和兼容性值得优先投入。常见做法是在项目里建立三个文件CardReader.cs 负责 SCard 调用CardApdu.cs 负责组织 APDU 指令CardMifare.cs 负责具体的 M1 卡认证读写逻辑。这样不管是 USB 还是串口读卡器只要它是 PC/SC 兼容上层代码一行不用改。2.2 用 P/Invoke 枚举读卡器节点的 C# 代码下面这段代码不需要引用第三方 NuGet 包直接 P/Invoke 原生 winscard.dll 即可枚举到系统当前所有智能卡读卡器。这是整个硬件读写流程的第一步也是排查“为什么我插上读卡器程序找不到设备”最有效的手段。using System; using System.Runtime.InteropServices; using System.Text; public static class PcScReader { [DllImport(winscard.dll, CharSet CharSet.Unicode)] private static extern int SCardEstablishContext(uint dwScope, IntPtr pvReserved1, IntPtr pvReserved2, out IntPtr phContext); [DllImport(winscard.dll, CharSet CharSet.Unicode)] private static extern int SCardListReaders(IntPtr hContext, byte[] mszGroups, byte[] mszReaders, ref uint pcchReaders); [DllImport(winscard.dll)] private static extern int SCardReleaseContext(IntPtr hContext); private const uint SCARD_SCOPE_USER 0; public static string[] ListReaders() { IntPtr ctx IntPtr.Zero; SCardEstablishContext(SCARD_SCOPE_USER, IntPtr.Zero, IntPtr.Zero, out ctx); uint bufferLen 0; SCardListReaders(ctx, null, null, ref bufferLen); byte[] readersBuf new byte[bufferLen]; SCardListReaders(ctx, null, readersBuf, ref bufferLen); string raw Encoding.ASCII.GetString(readersBuf); string[] list raw.Split(new char[] { \0 }, StringSplitOptions.RemoveEmptyEntries); SCardReleaseContext(ctx); return list; } }第一段 SCardListReaders 调用传入 null 缓冲区只拿所需缓冲区长度第二段调用才把读卡器名列表填充进 readersBuf这是 PC/SC 接口惯用的“先查长度再分配”模式很多新手会跳过第一步导致返回空数组。返回值是 0SCARD_S_SUCCESS表示成功。如果读卡器支持 USB一般出现在这里的是一个类似“USB Reader”或“ACS ACR122U PICC Interface”的字符串。CharSet.Unicode 对应 winscard.dll 的 ANSI 版本在 64 位进程下也能正常工作因为 P/Invoke 的 DllImport 会自动匹配进程位数。如果你把这串代码放到 Form_Load 里程序启动后能立刻在 ListBox 中看到所有物理读卡器连接层就通了。在这个基础上再做连接与收发包就是水到渠成的事。2.3 建立会话SCardConnect 与事务边界枚举只是“看见”读卡器真正要操作卡片还必须建立会话连接。关键是 SCardConnect 的共享模式参数一般连接时要指定 SCARD_SHARE_DIRECT 或者 SCARD_SHARE_EXCLUSIVE。并发环境中两个线程同时打开同一个读卡器很容易返回 0x8010000D共享违例。我的习惯是连接后只允许一个工作线程负责收发 APDU其他线程通过队列投递指令不直接碰卡。[DllImport(winscard.dll, CharSet CharSet.Unicode)] private static extern int SCardConnect(IntPtr hContext, string szReader, uint dwShareMode, uint dwPreferredProtocols, out IntPtr phCard, out uint pdwActiveProtocol); public static IntPtr CardHandle { get; private set; } public static bool Connect(string readerName) { IntPtr ctx IntPtr.Zero; uint protocol 0; SCardEstablishContext(SCARD_SCOPE_USER, IntPtr.Zero, IntPtr.Zero, out ctx); int rv SCardConnect(ctx, readerName, 1, 0, out IntPtr card, out protocol); if (rv ! 0) return false; CardHandle card; return true; }dwPreferredProtocols 通常设为 0由系统自动协商 T0 或 T1 协议这对 IC 卡读写来说够了。另一个要点是事务必须配套在发一条多段的复合指令比如某些卡片的密钥下载时先 SCardBeginTransaction 后 SCardEndTransaction 保证命令不被打断。这样集成到 C# 多线程的刷卡并发环境里才能避免竞态。3. 认识 M1 卡的扇区结构IC卡硬件读写绕不开的地方3.1 扇区-块-字节三层存储绝大多数门禁、食堂消费系统用的 IC 卡都是 NXP 的 Mifare Classic 1K一般叫 M1 卡。它的存储布局是固定的16 个扇区每个扇区 4 个块每块 16 字节总共 1K 字节。某些卡片是 4K 版本区别只是多出后 40 个扇区。块编号记忆口诀块号 扇区号16扇区内编号。扇区明文存储空间其实是每扇区 3 块48 字节因为第 4 块是控制信息区。扇区内编号名称内容0数据块数据或厂商信息扇区 0 块 0 是厂商代码与 UID5 字节1数据块用户数据固定区2数据块用户数据固定区3控制块秘钥A(6) 访问位(4) 秘钥B(6)数据块既可存数值也可以存普通二进制。写消费系统时如果要求钱包跨机器通用常见做法是把金额放在扇区 10-14并同时写卡号到一个固定偏移这样发卡、充值、扣费程序能级联校验。3.2 为什么每次读写卡之前都要先过认证M1 卡的认证机制是 ISO 14443-A 类型 A 的 crypto-1 密码流。读卡器发送 60 命令指定要认证的块号卡片内部返回随机挑战字双方用 48 位密钥做三轮握手。认证通过后卡片进入开放状态后续对该扇区内的块可以按访问控制位来读写。这也就是为什么很多硬件读写源码里读取卡号会失败——卡号UID存储在扇区 0 的块 0要想读取这一块必须先对扇区 0 所在块做认证。一个常见的误解是只要发一个 READ 命令就能读回 UID。严格来说 UID 是防碰撞阶段由卡片无条件发出的但 PC/SC 框架下需要用到 GET_UID 之类的厂商扩展 APDU如 ACR122U 的 FF CA 00 00 00如果读卡器不支持就只能认证读块 0。所以刚上手做硬件读写源码时第一步不要纠结怎么读 UID而是先验证“能不能对某个块完成认证”。认证成功了后面不过是读写的问题。3.3 M1 卡访问位怎么影响读写权限每一扇区的块 3 最后 4 字节是访问条件它决定每个块用 KeyA 还是 KeyB 能读、能写。很多开发者在这翻车数据写不进去明明用的默认密码 FFFFFFFFFFFF对块 0 却仍然报“密码错误”。因为出厂 M1 卡的块 3 访问位是默认值常见配置是 KeyA 可读块、KeyB 可写块但块 0 的厂商数据区是不可写的。这是硬件保护和密码无关。你在做“读写一卡通测试”时不要把数据写到块 0会破坏卡片甚至锁死 UID。4. 从零复现C# 去完成一次完整的 IC 卡硬件读写4.1 读卡号UID的最稳做法认证扇区后读取块 0下面的代码是读卡号最保险的方式兼容性最好连接读卡器后先选中卡片然后对扇区 0 块 0 发起认证再读取块 0 的前 4 字节。public static byte[] GetCardUid(byte[] keyA) { // 1. 检查卡是否存在发一个 FF 00 00 00 04 D4 4A 01 00 01 byte[] selectCmd new byte[] { 0xFF, 0x00, 0x00, 0x00, 0x04, 0xD4, 0x4A, 0x01, 0x00, 0x01 }; byte[] selectResp Transmit(selectCmd); if (selectResp.Length 2) return null; // 2. 认证扇区0块0命令格式 FF 82 00 00 06 6字节密钥 byte[] authCmd new byte[10]; authCmd[0] 0xFF; authCmd[1] 0x82; authCmd[2] 0x00; authCmd[3] 0x00; authCmd[4] 0x06; Array.Copy(keyA, 0, authCmd, 5, 6); byte[] authResp Transmit(authCmd); if (authResp.Length ! 2 || authResp[0] ! 0x90) return null; // 3. 读取块0命令格式 FF B0 00 块号 长度 byte[] readCmd new byte[] { 0xFF, 0xB0, 0x00, 0x00, 0x10 }; byte[] readResp Transmit(readCmd); if (readResp.Length 7) return null; byte[] uid new byte[4]; Array.Copy(readResp, 0, uid, 0, 4); return uid; }Transmit 方法是 PC/SC 的 SCardTransmit 包装把命令发给卡片并读回响应。这里的 FF 开头是读卡器的扩展 APDU 指令括号外的命令是读卡器芯片自己解释的不是卡片的原生 APDU。这也是为什么要强调“硬件读写源码”必须连读卡器芯片一起关注只研究卡协议远远不够。0xFF 82 00 00 06 后面跟 6 字节密钥这组指令格式在 ACR122U 上非常通用。如果你是串口读卡器帧格式里额外加包头包尾但 APDU 内容相同。返回的响应头 90 00 表示成功。如果收到 63 00说明密码错误收到 69 85说明访问条件不允许。第一次能做到这里就代表你的 PC/SC 通信链路已经完整打通。剩下的事情把数据组织好、把逻辑和业务边界理清楚。4.2 把用户数据写进扇区写块命令与参数边界写块是 IC 卡读写最敏感的操作因为数据一旦覆盖没有后悔药。下面的例子演示往扇区 1 的块 4即扇区1第0块写入 16 字节用户数据写入前先认证该扇区第 0 块。public static bool WriteDataBlock(byte[] keyA, int sector, byte[] data) { if (data.Length ! 16) return false; int blockNumber sector * 4; // 认证目标扇区 byte[] authCmd new byte[] { 0xFF, 0x82, 0x00, 0x00, 0x06, keyA[0], keyA[1], keyA[2], keyA[3], keyA[4], keyA[5] }; byte[] authResp Transmit(authCmd); if (authResp.Length ! 2 || authResp[0] ! 0x90) return false; // 构造写块指令FF D6 00 块号 长度 16字节数据 byte[] writeCmd new byte[5 16]; writeCmd[0] 0xFF; writeCmd[1] 0xD6; writeCmd[2] 0x00; writeCmd[3] (byte)blockNumber; writeCmd[4] 0x10; Array.Copy(data, 0, writeCmd, 5, 16); byte[] resp Transmit(writeCmd); return resp.Length 2 resp[0] 0x90; }逻辑上注意两点。其一块号是从全局角度算的扇区 1 的四块编号为 4、5、6、7所以 sector * 4 返回的正是该扇区第一块。其二必须确认目标是数据块还是控制块。块 3 的 16 字节里前 6 字节是密钥区一旦写坏这个扇区就废了。我一般只允许固定数据块sector % 4 ! 3写入从代码层面杜绝误碰控制块。数据写入之前我的习惯是先读一次块内容看是不是全 0 或预期旧值再决定是否覆盖。这个习惯曾在一个项目中帮我挡住了管理员把“充值操作”重复提交两次导致的金额翻倍事故。4.3 用 C# 的高级特性模块化指令流C# 里做这件事有个很舒服的写法用自定义特性定义 APDU 命令模板再用反射把所有命令一次性加载到字典。这样硬件读写部分与业务代码分离将来做发卡、读卡、充值、查余额只是配置命令模板的问题。比如定义枚举 CardCommand 和关联的 byte[] 模板。这个做法对团队效率的提升很明显。之前在一个 C# 上位机项目中命令散落在各个窗体里改一个指令动五个文件。后来我把所有 APDU 集中到一个 CommandTable 里界面层只调 CardService.ReadUid()、CardService.WriteMemberBalance()。谁接手都容易找到入口而不是从几十个函数里猜。5. 硬件读写避坑5 个直接让人翻车的典型问题5.1 现象读卡器已枚举但 SCardConnect 返回 0x8010000D现象程序能列出读卡器Connect 却报“Card is not responding”或者干脆报共享违例。原因最常见的是上一次连接没有释放另一种是杀毒软件或系统自带的 Windows 智能卡服务占用了读卡器。更隐蔽的原因是你用 SCardConnect 时用了独占模式dwShareMode0而其它进程正保持默认共享连接。解决连接前先 SCardReleaseContext 释放旧句柄开发调试时检查后台是不是开着 NFC 工具软件或别的 IC 卡测试程序把共享模式设为 SCARD_SHARE_SHARED值 2只用一个工作线程收发命令就可以规避大部分冲突。5.2 现象读卡号时返回 6D 00不支持的命令现象使用 0xFF 82 认证命令卡片响应 0x6D 00。原因并不是每款读卡器都支持扩展 APDU。ACR122U 支持但某些串口一体机只支持原生的 ISO 7816 命令头或者认证命令只能用厂商私有封装函数。解决先查读卡器是否支持 PC/SC 扩展指令如果不支持改用厂商自带的动态库做底层转发。还有一个退路用 SCardControl 发厂商的 IOCTL 直通命令把厂商私有格式包起来。总之开发前一定花十分钟查看设备规格不然前期搭建的“标准读写框架”会因为这一步整个推翻。5.3 现象认证成功提示 90 00但写入后读出来数据不对现象写块返回成功之后读回来却有 3-4 个字节被篡改或者整个块全 0。原因一是地址写错比如想写块 4 却写了扇区 1 的块 7二是数据长度计数错误FF D6 命令里的字节数跟实际数据长度不一致三是卡片本身是复制的“半加密卡”虽然显示写成功但实际没有写入。解决单步调试时把块号、长度和数据用日志打出来对比。写完之后立刻读回做相等比较这是兜底方案。我在验收代发卡功能时只认“写后即时读回一致”才算通过。5.4 现象多线程轮询刷卡时程序卡顿甚至无响应现象用 Timer 或线程池每隔几百毫秒轮询一次卡是否存在程序运行几个小时后 UI 变卡最终读卡器脱机。原因PC/SC 的 SCardTransmit 是阻塞调用如果卡片在认证时恰好被移开读卡器内部可能进入等待状态。轮询线程没有加超时控制线头越积越多C# 的线程池被 IO 阻塞线程耗尽。解决为收发命令统一加 500ms 超时把整个卡片操作放进信号量控制的单线程队列释放连接放在 finally 块。C# 的 async/await 配合 Task.Run 可以防止 UI 线程被阻塞但底层必须保持同一个 COM 对象只被一个线程访问。5.5 现象32 位系统上开发正常64 位系统上 P/Invoke 返回 0x80070057现象同样的代码换到 64 位 Windows 后 SCardListReaders 返回参数错误。原因winscard.dll 缓冲区大小参数类型。原 API 是 ULONG 指针C# 里如果你用 uint 传 ref在 64 位下内存布局会错位。解决改用 ref uint 并确保 DllImport 的 CharSet 与系统一致。更直接的教训是凡是 P/Invoke 的 API把结构体和指针声明统一成 IntPtr/uint并且先声明变量再传入引用不要在 new 出来的数组上直接取地址这能避免多数内存错位问题。6. 进阶技巧写后回读校验与数值钱包的并发安全读写的最常见业务是把“余额”写进卡里。单纯写一次并展示“成功”实际上有隐患如果写过程中卡片被抽走、写入数据不完整客户就会在消费时发现余额丢失。所以我会有意识地给每条写入记录追加自增序号和异或校验字节。异或校验的算法很简单取 16 字节中前 14 字节逐一异或放入第 15 字节作为校验第 16 字节保留为状态位。读回来时用同样的算法比对校验码不一致视为脏数据。另一个容易被忽略的是“扣费”的原子性。比如用户余额是 50 元门禁扣费要改成 49 元。千万不要先读出来、再在原值上减一、再写回去因为多线程下最后写回的值可能覆盖另一个线程已更新的数据。我处理时会把数值操作放到服务端的单例对象里用 lock 或 SemaphoreSlim 把读、减、写、回读串行化。这种并发控制在 C# 红包考勤系统、排队消费机项目里都不可少。最后一个建议是写块之前先备份。M1 卡没有固件回收站数据一旦被覆写几百张卡重新发卡的人力成本远大于写一行日志的成本。我的做法是发卡时把 UID、旧扇区内容、写卡时间一起写入 SQLite 日志表。设备故障时凭日志完全可以追溯相当于给硬件读写操作上了后悔药。这套组合拳打下来IC 卡硬件读写才真正做到了可控、可验证、可恢复。希望这篇实战笔记帮你在 C# 上位机里直接驱动读卡器少踩几个我当年掉进去的坑。本文还有配套的精品资源点击获取
返回列表