ARTICLE DETAIL

资讯详情

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

C# IC卡读写实例源码:串口与PC/SC选型及避坑指南

C# IC卡读写实例源码:串口与PC/SC选型及避坑指南 简介基于C#的IC卡硬件读写实例源码面向需要在Windows桌面应用中集成智能卡读写功能的开发者。压缩包共49个文件体积约1023KB包含13个C#源码文件、9个动态库、3个可执行程序以及项目工程文件、窗体设计界面、资源配置和数据库mdb文件覆盖从读卡器初始化、卡片选择到APDU命令收发与响应的完整链路。目前已有737人学习下载。源码以职工IC卡管理场景为示例通过Form窗体演示了实际交互流程并结合baseClass基础类与db1.mdb本地数据库便于理解卡片数据与业务数据之间的映射关系。研读工程还能掌握PC/SC标准接口在.NET环境下的封装用法以及异常处理、资源释放等工程化细节对有硬件编程经验的C#开发者尤为实用可直接二次开发或迁移至其他智能卡项目。1. 先看一张 CDM 卡你的 C# 上位机到底要跟谁说话很多做 C# 上位机的朋友第一次接触 IC 卡读写都是因为要给食堂刷卡器、门禁、会员卡或设备授权做配套。硬件买回来了厂家给了一个几十 KB 的“实例源码”打开一看串口打开、发指令、收返回、再解析代码量不大但自己一跑就是读不到卡、认证不过、返回全是 FF。问题基本不在 C# 语法而在你还没搞清楚读卡器内部是怎么工作的。这个标题“C# IC卡读写 实例源码(硬件读写)”想讲清楚的正是这条从芯片到桌面的链路。IC 卡本身不存“文本”只存字节读卡器负责把卡里的字节搬到串口或 USB 上C# 程序要做的是把字节按扇区、块、密钥的规则组织成指令发出去。适合的人群很明确写上位机、做设备集成、搞工控或者刚转 C# 不久、想用自己的代码把一张 M1 卡读明白的人。源码只是地图真正的路在协议和参数里。接下来我把这条路上最容易被卡住的三个环节拆开讲选硬件接口、跑通串口、再过渡到 PC/SC 标准封装。2. 先分清读卡器的三种接口方式代码照着哪个写2.1 串口透明命令读卡器最省事的学习路线常见做法是买一块串口输出的 RFID 读卡模块模块上已经集成了天线和射频芯片MCU 固件把卡片的交互封装成了串口指令。C# 这边只面对 COM 口发一串字节收一串字节不用关心 13.56MHz 调制解调也不用管 ISO14443 的时序。这就是“透明命令”的意思你发给它 APDU它原样转发给卡再把卡的回答原样返回。识别方法很简单插上 USB 转串口的读卡器打开设备管理器能看到“端口 (COM 和 LPT)”下面多出一个 COM 号多半就是这种类型。老式的 DB9 串口读卡器更直接但很多笔记本没有串口需要用 USB 转串口线。选模块时留意供电有些模块板载 3.3V 稳压有些得外部供 5V只靠串口的 DTR/RTS 取电不太靠谱容易读写到一半掉压。对入门者我一般会建议先用这种串口模块。原因是代码模型简单一问一答像极了在 C# 里调用一个远程方法。你只需要处理 SerialPort 读写调试时打开串口助手就能看到原始数据比 PC/SC 那套 P/Invoke 好理解得多。等把扇区、密钥、块地址这套概念跑通了再往 PC/SC 甚至厂商 DLL 上迁移都不迟。2.2 PC/SC 标准读卡器Windows 上最稳的路线如果你在办公环境或企业项目里用 USB 插拔的读卡器插上后设备管理器里出现的是“智能卡读卡器”而不是 COM 口那它走的是 PC/SC 标准。PC/SC 是 Windows 内置的智能卡服务系统通过 winscard.dll 统一管理所有品牌读卡器C# 可以用 P/Invoke 调用 SCardEstablishContext、SCardConnect、SCardTransmit 这一组 API 完成通信。PC/SC 的优势是标准化换一个品牌的读卡器代码基本不用改只要驱动装好、服务在跑。它天然支持接触式 CPU 卡和非接触式卡也是 Windows 登录、数字证书这类场景的唯一选择。缺点也明显API 比较底层要做不少 DllImport 声明和内存管理出错时返回的是 SCARD_E_XXX 这种错误码不像串口能看到原始帧那么直观。2.3 三种路线怎么选先看设备管理器我整理了一个对比表方便你根据手上设备快速定位类型设备管理器表现C# 侧主要工作适合场景上手难度串口透明命令模块出现 COM 端口SerialPort 收发按帧解析工控机、批量设备、学习低PC/SC 标准读卡器出现“智能卡读卡器”P/Invoke winscard.dllWindows 桌面、企业级中厂商专用 DLL 读卡器出现专属设备名/驱动图标DllImport 或引用厂商程序集特定品牌、复杂功能中高判断步骤就三步先插上设备看设备管理器出现 COM 口就是串口型出现智能卡设备就去看“服务”里 SCardSvr 是否启动启动了就是 PC/SC如果设备管理器里既没有新增 COM 口系统服务里也没有智能卡设备那多半是厂商自带驱动需要找厂家要 SDK 和 DLL。从网上找“实例源码”时第一步不是看 C# 代码而是看它用的是 System.IO.Ports 还是 winscard.dll或者是某厂商的 DLL。代码结构完全不同硬套必翻车。另有一个容易忽略的点串口读卡器市场品牌杂很多模块的命令格式并不完全遵循标准 APDU有的模块还会在 APDU 外包一层帧头、长度和校验所以后文的实现我会先讲通用的串口帧封装再给 APDU 层。3. 用串口把 IC 卡的扇区读写跑通最小命令与参数3.1 打开串口波特率、校验位与超时设置串口读卡器最常见的就是经典 M1 卡S50它把存储分成 16 个扇区每个扇区 4 个块每块 16 字节。C# 端要做的无非三件事打开串口发认证命令发读写块命令。先看打开串口这一段代码并不长但坑都藏在参数里。// 与读卡器通信用的串口 private SerialPort _serial; private void OpenReader(string portName, int baudRate) { _serial new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One) { ReadTimeout 500, WriteTimeout 500, Handshake Handshake.None, DtrEnable true, RtsEnable true }; _serial.Open(); }逻辑说明这里创建了一个 8 数据位、无校验、1 停止位的串口连接这几乎是所有 IC 卡读卡器模块的默认配置。ReadTimeout 和 WriteTimeout 设成 500 毫秒避免读卡器没回应时线程卡死。DtrEnable 和 RtsEnable 要看模块手册有些串口模块靠 DTR 引脚的电压给板载电路供电置 true 才能工作但也有模块不接这两根线保持 false 也能跑。参数说明波特率不是越高越好。模块出厂常见 9600 或 115200如果你的上位机软件打开串口后发命令无回应先怀疑波特率不对用串口助手扫一遍常见波特率。串口打开失败还要检查是否被占用尤其是调试时上一个进程没退出串口会被 C# 进程锁住必须杀掉进程才能释放。3.2 封装 SendCommand给串口加一层“契约”真正写上位机时不会每次读写都去裸调 Write 和 Read那样代码会散成一堆。我习惯把所有命令封装成一个 SendCommand 方法它负责“清空缓冲、发送、等待、收满数据”调用方只关心语义。private readonly object _serialLock new object(); // 发送一帧完整命令并等待模块返回 // frame 是完整指令expectMinLen 是期望返回的最小字节数 private byte[] SendCommand(byte[] frame, int expectMinLen) { lock (_serialLock) { _serial.DiscardInBuffer(); // 清掉上一次的残留数据 _serial.Write(frame, 0, frame.Length); // 给模块一个处理时间这是串口上位机常用的“稳一稳”的土办法 Thread.Sleep(50); using var buffer new MemoryStream(); var deadline DateTime.Now.AddMilliseconds(300); while (DateTime.Now deadline) { int b _serial.ReadByte(); if (b 0) break; buffer.WriteByte((byte)b); if (buffer.Length expectMinLen) break; } return buffer.ToArray(); } }逻辑说明加锁是为了防止多个线程同时调用串口导致指令交叉。Write 发送完整帧后不立即收而是 Sleep 50 毫秒这个“小停顿”是很多 C# 串口上位机项目里的习惯因为读卡器固件需要时间处理射频通信立即去读很容易只收到半包。收数据用 ReadByte 逐个读按期望长度退出避免无线程阻塞。参数说明expectMinLen 至少要等于“状态字长度 真正数据长度”。M1 读块典型返回是 16 字节数据加 2 字节状态字也就是 18 字节。如果你只传 16会把状态字留在缓冲区里影响下一轮命令。这里用绝对值所以调用方必须清楚自己要收多长。3.3 读块与写块从二进制到字符串的转换M1 卡的扇区认证是读写的先决条件。发送认证命令时要指定密钥类型KeyA 或 KeyB和扇区号。常见模块支持标准 APDU比如 0xFF 0x86 0x00 0x00 0x05 0x01 0x00 0x09 0x60 0x00 就是一次 KeyA 认证最后一位换成 0x61 就是 KeyB。不同模块的认证指令可能带厂商前缀但结构类似。private static readonly byte[] DefaultKeyA { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }; // 认证扇区blockAddr 是目标块地址keyType 0x60KeyA 0x61KeyB private bool Authenticate(byte blockAddr, byte[] key, byte keyMode) { if (key null || key.Length ! 6) throw new ArgumentException(M1卡密钥必须为6字节); // 通用 APDUFF 86 00 00 05 [密钥类型] [块地址] 60 00 var apdu new byte[10]; apdu[0] 0xFF; apdu[1] 0x86; apdu[2] 0x00; apdu[3] 0x00; apdu[4] 0x05; apdu[5] keyMode; apdu[6] blockAddr; Array.Copy(key, 0, apdu, 7, 6); byte[] resp SendCommand(apdu, 2); return resp.Length 2 resp[resp.Length - 2] 0x90 resp[resp.Length - 1] 0x00; }逻辑说明把块地址和 6 字节密钥拼进 APDU 后发给模块模块返回 0x90 0x00 代表认证通过。这里没有处理模块自定义帧头的问题如果你的模块手册要求额外加帧头、长度和校验可以在 SendCommand 之前再做一次包装后面第 5 章会说到半包和帧格式的坑。认证通过后才能发读写块命令。读块用 0xFF 0xB0写块用 0xFF 0xD0块地址直接放在 P2 位置一次读写固定 16 字节。// 读一个数据块返回16字节 private byte[] ReadBlock(byte blockAddr) { byte[] cmd { 0xFF, 0xB0, 0x00, blockAddr, 0x10 }; byte[] resp SendCommand(cmd, 18); if (resp.Length 18 || resp[16] ! 0x90 || resp[17] ! 0x00) throw new InvalidOperationException($读块 {blockAddr} 失败: BitConverter.ToString(resp)); return resp.Take(16).ToArray(); } // 写一个数据块data 必须正好16字节 private void WriteBlock(byte blockAddr, byte[] data) { if (data.Length ! 16) throw new ArgumentException(写块数据必须为16字节); var cmd new byte[21]; cmd[0] 0xFF; cmd[1] 0xD0; cmd[2] 0x00; cmd[3] blockAddr; cmd[4] 0x10; Array.Copy(data, 0, cmd, 5, 16); byte[] resp SendCommand(cmd, 2); if (resp.Length 2 || resp[0] ! 0x90 || resp[1] ! 0x00) throw new InvalidOperationException($写块 {blockAddr} 失败: BitConverter.ToString(resp)); }逻辑说明ReadBlock 的期望长度是 18其中前 16 字节是卡内数据最后 2 字节是状态字。如果返回的 SW1SW2 不是 0x9000常见原因就是没有先认证。WriteBlock 也是同一套思路只是把 16 字节数据拼在指令后面。写操作非常怕掉电中途断电极可能把整块数据写坏工业场景里最好在 WriteBlock 外层加一次读回校验。关于字节和字符串的转换M1 卡里存的是原始二进制不是 UTF-8 文本。存卡号时常用 BCD 编码比如“12345678”这 8 个数字在卡里对应 0x12 0x34 0x56 0x78。很多新人直接把字符串转 ASCII 写进去读出来再按 ASCII 转字符串碰到纯数字就会得到一堆乱码。错误的源头在这里你存的不是“文本”而是“字节”。C# 里要做的只是在写入前把十进制字符串转成 byte[]读出后把 byte[] 转回字符串。public static byte[] StringToBcd(string number) { if (number.Length % 2 ! 0) number 0 number; byte[] result new byte[number.Length / 2]; for (int i 0; i result.Length; i) { result[i] (byte)((number[i * 2] - 0) 4); result[i] | (byte)(number[i * 2 1] - 0); } return result; } public static string BcdToString(byte[] data) { var sb new StringBuilder(data.Length * 2); foreach (byte b in data) { sb.Append((char)(0 ((b 4) 0x0F))); sb.Append((char)(0 (b 0x0F))); } return sb.ToString(); }逻辑说明BCD 编码把一个字节拆成高 4 位和低 4 位各表示一个 0~9 的数字。StringToBcd 里如果传入奇数位数字就在前面补一个 0保证两个数字占一个字节。BcdToString 则是反向操作。这套转换在 IC 卡存卡号、金额、次数这类十进制数时特别实用比 ASCII 省一半空间。到这里串口路线的最小闭环已经跑通开串口、认证、读块、写块、BCD 转换。把这几段代码整理好就是一个能对 M1 卡做基础读写的“实例源码”。但你还差一环很多读者实际用的读卡器是 PC/SC 标准设备串口代码完全跑不起来下一章补上这条路线。4. 用 PC/SC 做标准封装上线前要过的关4.1 P/Invoke 声明 SCard 核心函数PC/SC 的用法跟串口完全不同。串口是“你发什么模块回什么”PC/SC 则是“你问系统系统帮你找读卡器”。这个差异直接决定了代码结构PC/SC 代码要先枚举读卡器再连接再传输 APDU最后释放连接。using System; using System.Runtime.InteropServices; internal static class WinSCard { public const int SCARD_S_SUCCESS 0; public const uint SCARD_PROTOCOL_T0 1; public const uint SCARD_PROTOCOL_T1 2; [StructLayout(LayoutKind.Sequential)] public struct SCARD_IO_REQUEST { public uint dwProtocol; public uint cbPciLength; } [DllImport(winscard.dll, CharSet CharSet.Unicode)] public static extern int SCardEstablishContext( uint dwScope, IntPtr pvReserved1, IntPtr pvReserved2, out IntPtr phContext); [DllImport(winscard.dll, CharSet CharSet.Unicode)] public static extern int SCardListReaders( IntPtr hContext, byte[] mszGroups, byte[] mszReaders, ref uint pcchReaders); [DllImport(winscard.dll, CharSet CharSet.Unicode)] public static extern int SCardConnect( IntPtr hContext, string szReader, uint dwShareMode, uint dwPreferredProtocols, out IntPtr phCard, out uint pdwActiveProtocol); [DllImport(winscard.dll, CharSet CharSet.Unicode)] public static extern int SCardTransmit( IntPtr hCard, ref SCARD_IO_REQUEST pioSendPci, byte[] pbSendBuffer, uint cbSendLength, IntPtr pioRecvPci, byte[] pbRecvBuffer, ref uint pcbRecvLength); [DllImport(winscard.dll, CharSet CharSet.Unicode)] public static extern int SCardDisconnect(IntPtr hCard, uint dwDisposition); [DllImport(winscard.dll, CharSet CharSet.Unicode)] public static extern int SCardReleaseContext(IntPtr hContext); }逻辑说明SCardEstablishContext 建立应用与智能卡服务的上下文返回值 0 表示成功。SCardListReaders 传入一个 byte[] 接收读卡器名列表Windows 用“双 null”结尾的多字符串。SCardConnect 连接某个读卡器。SCardTransmit 是所有 APDU 的入口。参数说明dwShareMode 连接方式里常用共享模式值为 2这样多个进程能同时打开一个读卡器独占模式值为 1容易冲突。SCARD_PROTOCOL_T0 和 T1 分别对应接触式 CPU 卡的两种传输协议M1 非接触卡一般走 T1。SCARD_IO_REQUEST 结构体用来存放协议和结构大小传输时必须把它的引用传进去。4.2 建立上下文与连接读卡器枚举读卡器有个惯用套路第一次传 null 的 byte[] 拿到需要的缓冲区大小第二次传入真正的缓冲区再读取。这样能处理多读卡器的情况也避免了字符串数组解析的麻烦。public class PcscReader : IDisposable { private IntPtr _context; private IntPtr _card; private uint _activeProtocol; public void Connect() { int ret WinSCard.SCardEstablishContext(0, IntPtr.Zero, IntPtr.Zero, out _context); if (ret ! WinSCard.SCARD_S_SUCCESS) throw new InvalidOperationException($建立PC/SC上下文失败: 0x{ret:X8}); uint len 0; ret WinSCard.SCardListReaders(_context, null, null, ref len); if (ret ! WinSCard.SCARD_S_SUCCESS) throw new InvalidOperationException(枚举读卡器失败); byte[] readerBuf new byte[len]; ret WinSCard.SCardListReaders(_context, null, readerBuf, ref len); if (ret ! WinSCard.SCARD_S_SUCCESS || len 0) throw new InvalidOperationException(未找到读卡器请检查驱动); string readerName ParseReaderName(readerBuf); System.Diagnostics.Debug.WriteLine($使用读卡器: {readerName}); // 共享模式连接优先级给 T0|T1由系统决定实际协议 ret WinSCard.SCardConnect( _context, readerName, 2, WinSCard.SCARD_PROTOCOL_T0 | WinSCard.SCARD_PROTOCOL_T1, out _card, out _activeProtocol); if (ret ! WinSCard.SCARD_S_SUCCESS) throw new InvalidOperationException($连接读卡器失败: 0x{ret:X8}); } private static string ParseReaderName(byte[] buffer) { int i 0; while (i buffer.Length buffer[i] ! 0) { i; } return System.Text.Encoding.Unicode.GetString(buffer, 0, i); } public void Dispose() { if (_card ! IntPtr.Zero) { WinSCard.SCardDisconnect(_card, 0); _card IntPtr.Zero; } if (_context ! IntPtr.Zero) { WinSCard.SCardReleaseContext(_context); _context IntPtr.Zero; } } }逻辑说明Connect 方法按标准流程走完“建上下文、枚举、连接”三个步骤。ParseReaderName 的解析逻辑是针对 Unicode 版的 SCardListReaders它返回的是一串以双 null 结尾的 UTF-16 字符串数组这里只取第一个读卡器实际多读卡器场景要遍历到双 null 结束。提示SCardListReaders 的第一次调用传的 len 是 0系统返回需要的大小。有些教程忽略这个两段式调用直接给固定 256 字节缓冲区遇到长读卡器名称会返回错误码 0x8010002E缓冲区太小。这个细节最容易让新手困惑。4.3 通过 SCardTransmit 发送 APDU 并解析返回值PC/SC 下读 M1 卡的块先要发送认证 APDU再发送读 APDU。认证命令本身是非标准的厂商扩展命令ACR122 这类读卡器支持透传但不是所有 PC/SC 读卡器都支持。如果你的读卡器不支持认证会返回 0x6A 0x81这时只能换串口模块或者用厂商 SDK。public byte[] TransmitApdu(byte[] apdu) { var sendPci new WinSCard.SCARD_IO_REQUEST { dwProtocol _activeProtocol, cbPciLength (uint)Marshal.SizeOfWinSCard.SCARD_IO_REQUEST() }; byte[] recvBuf new byte[300]; uint recvLen (uint)recvBuf.Length; int ret WinSCard.SCardTransmit( _card, ref sendPci, apdu, (uint)apdu.Length, IntPtr.Zero, recvBuf, ref recvLen); if (ret ! WinSCard.SCARD_S_SUCCESS) throw new InvalidOperationException($APDU传输失败: 0x{ret:X8}); Array.Resize(ref recvBuf, (int)recvLen); return recvBuf; }调用方式跟串口版很像只是底层换成了 PC/SCvar reader new PcscReader(); reader.Connect(); try { // 认证扇区块地址 4KeyA 默认 FF FF FF FF FF FF byte[] authApdu { 0xFF, 0x86, 0x00, 0x00, 0x05, 0x01, 0x00, 0x04, 0x60, 0x00 }; byte[] authResp reader.TransmitApdu(authApdu); // 检查 authResp 最后两字节是否为 0x90 0x00 // 读块 4 byte[] readApdu { 0xFF, 0xB0, 0x00, 0x04, 0x10 }; byte[] blockData reader.TransmitApdu(readApdu); } finally { reader.Dispose(); }逻辑说明SCardTransmit 的 pioSendPci 参数在原理上应该指向系统预定义的 SCARD_PCI_T0 或 SCARD_PCI_T1 全局结构但 C# 侧很难直接拿到那些非托管全局变量所以不少封装直接传协议号和结构大小。这样在多数读卡器上能工作如果遇到返回值异常或连接被拒绝可以尝试把 dwProtocol 固定为 2T1再对比效果。recvBuf 给 300 字节足够容纳常见返回读块时实际内容只有 18 字节由 recvLen 返回真实长度。PC/SC 和串口的 APDU 内容完全可以复用认证、读块、写块三条指令在两种传输方式下只有发送通道的差异。这也是为什么我说先用串口把概念学明白再迁移到 PC/SC 只是换壳。但 M1 卡这种非接触卡走 PC/SC 透传有个限制不是所有读卡器都开放透传尤其是接触式读卡器拿到手发现根本不支持非接卡这很正常选型时就要确认。5. 避坑IC卡读写最常翻车的五个现象与排查5.1 现象密钥A全填 FF 也认证失败返回 0x63 00新买的 M1 空卡出厂默认 KeyA 和 KeyB 都是 FF FF FF FF FF FF但用 0x60 认证时却返回 0x63 00。0x63 00 代表密钥验证失败也就是卡不认你给的这 6 个字节。原因最常见的不是密钥错而是块地址和扇区概念混了。认证时指令里填的是“扇区号”还是“块地址”取决于模块固件。标准 APDU 的认证指令里的 P2 是块地址读块指令里的也是块地址但有的串口模块把认证指令里的参数定义为扇区号第一次用的人照抄读块地址就会错位。另一种可能是这张卡已经被别人改过密钥不是出厂状态。解决先用一个已知能读全卡的写卡器或者串口助手发“读块 0”命令确认卡的型号。如果连块 0 都读不出来说明卡压根没进入射频场或被锁死。如果块 0 能读再确认认证指令参数是扇区还是块。测试时用单独一张新卡别拿正式卡试改过密钥的卡很难恢复。5.2 现象读出来全是 FF写进去再读还是 FF数据块读出来 16 个字节全是 0xFF这看起来像“空数据”但写入后依旧全 FF读回不变。原因这种情况通常是卡没放对位置或者天线功率不足。串口模块的天线范围很小卡片稍微偏一点就只响应部分命令。另一个原因是写操作实际失败了但代码没检查状态字以为写成功。还有一个隐蔽原因你写入的是数据块但把地址指到了该扇区的控制块每扇区第 4 块也就是块地址对 4 取模等于 3 的块那个块保存的是密钥和控制条件普通写操作会被卡拒绝。解决把卡片平贴在天线正中央保持不动。代码里写完必须立刻读回比对别只看 WriteBlock 不抛异常就当成功。排查时先切换到读块 0能读到 UID 就说明物理链路 OK再读目标块如果全 FF 就检查该块是不是控制块换到块 4、块 5 这类普通数据块重试。5.3 现象块 0 能读不能写强行写之后卡废了有读者看到块 0 是可读的就把卡号或自己的数据写进去结果写不进去于是尝试各种特殊命令硬写最后整张卡连读都读不了。原因M1 卡块 0 是厂商块前 4 字节是 UID出厂唯一编号后面是厂商数据和扇区控制信息。大部分标准 M1 卡出厂时块 0 是只读的这是硬件层面的保护不是软件能随便改的。强行写块 0 会破坏卡片的访问控制条件导致整卡失效。解决U ID 属于只读数据业务上要存卡号请存到别的扇区用读块 0 的 UID 作为唯一标识同时把 UID 复制到扇区 1 的数据块里做备份。如果你确实需要可改 UID 的卡比如门禁系统测试去买专门的 UID 卡它允许对块 0 使用特殊命令直写但这类卡不一定是标准 M1换到别的读写器上兼容性可能会出问题。写控制块前先把原值读出来备份最好用一张测试卡反复确认控制位的逻辑。5.4 现象串口返回半包命令明明对但数据不完整用串口模块时经常遇到一种情况第一次发认证命令返回正常紧接着发读块命令只回来了几个字节程序就报超时实际回包被切成了两段。原因模块返回数据时串口物理层是逐字节发送的C# 的 SerialPort 按字节读取可能在循环判断时已经把某一小段数据判成了完整包。尤其是波特率高、模块在内部处理完射频后才开始吐数据时收发之间容易产生半包。解决SendCommand 里不要依赖“读一次就收全”要加帧长度判断和超时重试。我已经习惯在发送后固定等 50 毫秒再开始收收到长度不足时继续等直到超时。如果重试仍然半包把波特率从 115200 降到 9600慢速串口虽然吞吐低但对 IC 卡这种小数据量交互来说完全够用稳定性高很多。调试这种问题时用串口助手抓包很直接C# 代码里看不到的数据串口助手里一眼就知道模块到底吐了什么。5.5 现象PC/SC 连接后立刻失败读卡器提示被占用PC/SC 读卡器在 C# 里调用 SCardConnect 返回错误码常见的是 0x8010000F共享冲突或 0x8010001A读卡器已被独占。原因读卡器被其它进程独占比如杀毒软件自带的智能卡组件、Adobe 的证书服务或者你自己上一次调试时进程没退出。PC/SC 的共享模式允许多个应用同时打开但有的读卡器驱动只支持独占另一个进程占住后你的代码自然连不上。解决先检查 Windows 服务里 Smart Card 服务SCardSvr是否启动没启动就手动启动再用 Process Explorer 或任务管理器关掉占用读卡器的进程。代码层面确保 Dispose 里调用了 SCardDisconnect且异常路径也要释放连接。调试阶段最好在 main 函数开头先枚举一次读卡器如果枚举都失败问题多半在服务或驱动不在你的 APDU 指令。6. 验证扇区数据的正确姿势与一个自用习惯读写能跑通只是开始真正让你在项目里不翻车的是验证。我每写完一块数据会立刻读回并比对绝不只看写命令返回 0x9000 就认为成功。下面这段是我常用的验证函数public static void VerifyBlock(byte blockAddr, byte[] expected, PcscReader reader) { byte[] actual reader.TransmitApdu(new byte[] { 0xFF, 0xB0, 0x00, blockAddr, 0x10 }); // 返回的最后两字节是状态字先去掉 byte[] dataOnly actual.Take(16).ToArray(); if (!dataOnly.SequenceEqual(expected)) { Console.WriteLine($块 {blockAddr} 验证失败); Console.WriteLine($期望: {BitConverter.ToString(expected)}); Console.WriteLine($实际: {BitConverter.ToString(dataOnly)}); } else { Console.WriteLine($块 {blockAddr} 验证通过); } }这个函数无论对接串口还是 PC/SC 都能用核心思想是把“写”和“读”分开看写入成功只能说明卡收到指令读回一致才能证明数据落盘。对需要掉电保存的块至少连续读写三次确认稳定。除了验证我还养成了一个习惯把所有卡片的扇区布局、密钥类型、块地址收敛到一个配置类里不散落在业务代码中。比如定义一个记录包含扇区号、数据块号、密钥类型和 6 字节密钥。这样换卡测试时只需要改配置文件不用到处找硬编码的 0xFF 0xFF。最后说一个我自己的血泪经验测试过程中很容易把密钥写乱一旦控制块被改整张卡报废。所以现在我在项目里留了一个“恢复默认”的小工具专门把测试卡重置回出厂状态有时也会把密钥备份到一个单独的 JSON 文件里避免手滑。卡片本身就是个小数据库动手前想清楚你要动的是数据块还是控制块。希望这些经验能帮你把 IC 卡读写这条路走顺少交点“学费”。本文还有配套的精品资源点击获取
返回列表