简介:基于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.dll | Windows 桌面、企业级 | 中 |
| 厂商专用 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 0x60=KeyA 0x61=KeyB private bool Authenticate(byte blockAddr, byte[] key, byte keyMode) { if (key == null || key.Length != 6) throw new ArgumentException("M1卡密钥必须为6字节"); // 通用 APDU:FF 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}"); // 共享模式连接,优先级给 T=0|T=1,由系统决定实际协议 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.SizeOf<WinSCard.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/SC:
var reader = new PcscReader(); reader.Connect(); try { // 认证扇区:块地址 4,KeyA 默认 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 固定为 2(T1)再对比效果。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 卡读写这条路走顺,少交点“学费”。
本文还有配套的精品资源,点击获取