简介面向需要开发USB HID读卡器上位机的C#开发者该源码完整覆盖CPU卡与IC卡读写操作涵盖USB设备枚举识别、HID通信协议实现、卡片读写指令与错误处理等关键环节适合门禁系统、身份验证等安全敏感场景。压缩包共52个文件以16个C#源码文件为核心配合dll动态库、exe可执行程序、config配置文件及pdb调试符号等整体仅1.03MB结构紧凑便于移植与二次开发。目前已有222人学习浏览源码方案具备较好的参考价值。借助C#语言丰富的类库与IDE支持可显著简化USB通信和卡操作复杂性直接集成到更大应用程序中同时兼顾跨平台兼容性与扩展性有助于开发者深入理解智能卡技术与上位机软件的结合。1. C# USB HID 读卡器上位机为什么这个方案值得自己做做上位机绕不开读卡器。C# USB HID 读卡器CPU卡和IC卡的读和写上位机源码听起来像是拿现成 DLL 调一圈就能交差真把设备插上才发现读卡器被 Windows 识别成 HID 设备免驱是免驱了但你的 C# 程序不知道该往哪个端点发什么字节。这个方案的现实定位是硬件通过 USB HID 报表和你对话C# 上位机要完成寻卡、认证、读写 CPU 卡和 IC 卡比如 M1的整条链路还要处理拔插重连、卡片异常和厂商私有协议。它适合两类人一类在做一卡通、门禁、会员系统需要把读写逻辑掌握在自己手里另一类刚接触非接触卡想搞清楚上位机到底是怎么把数据写进卡的。下面这套做法不依赖具体品牌照着改成你手上那台即可。2. C# 与 USB HID 通信选对类库先跑通最小收发2.1 为什么是 HID 而不是串口免驱背后的协议代价前几年的读卡器多是 USB 转串口方案CP2102、CH340 这类芯片上位机打开 COM 口发指令看起来简单但坑在于驱动要装、串口号会漂移、换一台工控机就要重装。现在不少读卡器直接枚举成 HID 人机接口设备Windows 自带 hidusb.sys 和 hidclass.sys插上就能用。代价是通信模型变成了“报表”应用程序通过输出报表Output Report发指令设备通过输入报表Input Report回数据报表长度由设备描述符里的报告描述符决定常见配置是 8、16、32、64 字节发送节奏还受端点轮询周期限制。这意味着上位机必须做两件事。第一件事是把业务指令塞进固定长度的报表里数据不够就补 0数据过长要拆包重传。第二件事是自己定义帧协议因为 HID 报表本身没有“命令字”“长度”“校验”的概念读卡器固件把 HID 字节流当成它的私有协议来解析这块固件就是个黑匣子很多说明书只给“读卡返回多少字节”不给完整的帧定义。另外要区分一条路线如果读卡器枚举出来带的是 CCID 智能卡读卡器接口Windows 下走 WinSCardPC/SC更省事ATR 和 APDU 都是标准的本文针对的是枚举为纯 HID 设备的读卡器这也是标题里“USB HID”所指的实现路径。2.2 C# 侧 HID 类库怎么选三个常用封装对比C# 没有内置的 HID 通信 API 能直接用在 WinForms/WPF 上实际项目里常见三种选择列个对比。类库定位适用场景HidLibraryNuGet 老牌封装基于 Win32 HID APIP/InvokeAPI 简单WinForms/WPF 老项目、快速原型HidSharp跨平台、抽象层级更高、异步 API 顺手新项目、后续可能要跨平台Linux/WindowsWindows.Devices.HumanInterfaceDevice微软官方 WinRT APIUWP 应用、需要清单声明能力桌面项目引用别扭我一般倾向正式交付的 WinForms 项目用 HidLibrary代码量最少事件模型也够用如果目标是 .NET 6 且考虑跨平台用 HidSharp。踩过的坑是有的同事为了“官方”选了 WinRT 那套结果 WPF 里要引 Windows Runtime 扩展打包和权限声明折腾半天不如前者省心。无论选哪个底层都是 USB 中断传输类库差异只在枚举方式和回调封装上不影响你对设备协议的理解。2.3 用 HidLibrary 跑通最小读写打开设备与双向收发先不要想复杂把目标压到最小打开设备、发一帧出去、收一帧回来。以下代码用的是 HidLibrary 的经典用法。using HidLibrary; using System; using System.Linq; class HidDemo { private const int VENDOR_ID 0x1234; // 换成你读卡器的 VID private const int PRODUCT_ID 0x5678; // 换成你读卡器的 PID public static void Main() { var device HidDevices.Enumerate(VENDOR_ID, PRODUCT_ID).FirstOrDefault(); if (device null) { Console.WriteLine(未找到 USB HID 读卡器检查 VID/PID 或重新插拔); return; } if (!device.Open()) { Console.WriteLine(设备打开失败可能被其他进程占用); return; } device.Read(OnInput); // 注册输入报表回调 // 输出报表第 1 字节是报告 ID无编号报告填 0x00 // 长度必须等于设备的输出报表长度不够补 0超长会报错 byte[] output new byte[16]; output[0] 0x00; output[1] 0x01; // 这里开始才是你自定义协议的字节 device.Write(output); Console.ReadLine(); device.Close(); } private static void OnInput(HidReport report) { // report.Data[0] 是报告 ID业务数据从 Data[1] 开始 byte[] data report.Data; Console.WriteLine(收到 {0} 字节, data.Length); // HidLibrary 的回调是一次性的处理完要再挂一次否则只收第一帧 // 正式项目里这里需要持有 device 引用示例略 } }这里有三点很容易翻车。第一Write的字节数组长度必须等于设备报告描述符里的输出报表长度不是“少于等于”是必须刚好等于长度不够的部分要补 0多出来的直接抛异常。第二Read回调拿到的HidReport.Data是含报告 ID 的完整数组业务数据从Data[1]开始解析别把报告 ID 当帧头。第三Read是一次性回调处理完当前帧要再次调用device.Read(OnInput)才能持续接收我见过不少同事只挂一次读到第一帧就再也不进回调。正式项目里读写要放到后台线程或async/await里做HID 读卡器响应不稳定拔卡瞬间同步等待会让 UI 线程假死。3. IC 卡M1读写从寻卡到写块的完整命令链3.1 M1 卡读写流程四个阶段在做什么IC 卡里最典型的是 M1 卡Mifare Classic 1K1KB 存储分成 16 个扇区每扇区 4 块每块 16 字节扇区的最后一块是 trailer存 KeyA、访问位和 KeyB用户数据区实际是每扇区 3 块。M1 的读写流程是固定的四段缺一段后面都白搭。第一段是寻卡读卡器发 REQA 或 WUPA探测射频场里有没有卡片。第二段是防碰撞现场可能有多张卡读卡器按 UID 逐张撞出拿到 4 字节的 UID。第三段是选卡锁定刚防碰撞出来的那张卡之后命令都指向它。第四段是认证用当前扇区 trailer 里的 KeyA 或 KeyB 做认证密钥不对该扇区后面所有读写都会失败。认证通过后才能对扇区内的块发 READ读 16 字节和 WRITE写 16 字节。要特别注意M1 的认证是“按扇区”的。你要读写扇区 5必须先对扇区 5 再认证一次换扇区不重新认证是新手最常见的逻辑错误。上位机实现上这四段应该做成有状态的会话流程而不是把每个命令都当成独立请求——比如你在写块之前重新发了寻卡前面认证的“会话状态”可能就丢掉了写块照样失败。3.2 封装读卡指令把流程变成可调用的 C# 方法读卡器硬件把上面这些 ISO14443 操作封装成私有指令上位机按它的帧格式组包。不同厂家的帧格式不统一但结构基本是这个套路包头 卡类型 命令字 数据长度 业务数据 校验。以下帧格式是示意实际命令字以你的读卡器说明书为准。位置字节说明00xAA帧头固定值1cardType0x01IC(M1)0x02CPU2cmd命令字3len业务数据长度4..4len-1payload业务数据最后1字节xor前面所有字节异或组帧和发送的代码可以这样写public byte[] BuildFrame(byte cardType, byte cmd, byte[] payload) { int len payload?.Length ?? 0; byte[] frame new byte[5 len]; frame[0] 0xAA; frame[1] cardType; frame[2] cmd; // 例如 0x10寻卡, 0x14认证, 0x16读块 frame[3] (byte)len; if (len 0) { Array.Copy(payload, 0, frame, 4, len); } frame[4 len] XorChecksum(frame, 0, 4 len); return frame; } public byte[] Transact(HidDevice device, byte[] frame, int timeoutMs 500) { if (device null || !device.IsOpen) throw new InvalidOperationException(设备未打开检查是否已拔插); device.Write(frame); byte[] response WaitInputReport(device, timeoutMs); if (response null) throw new TimeoutException(读卡器无响应检查卡片是否放好); return response; }WaitInputReport在正式实现里建议用AutoResetEvent或TaskCompletionSource等回调信号量不要在循环里Thread.Sleep空转。响应回来后不要只判断“收到了”要逐字节检查帧头、长度、校验和状态码。很多卡片异常不是不回复而是回复里带了错误状态码比如认证失败、块不可写这些状态码会在响应帧的固定位置解析函数里要把它们和正常数据分开抛到上层让业务逻辑看得到。只判断“收到了”会把所有错误吞掉这是踩坑最狠的地方。3.3 扇区块地址与密钥参数配置怎么落到代码里M1 1K 的块地址换算很简单扇区号 × 4 块偏移。块偏移 0 是数据块1 和 2 也是数据块偏移 3 是 trailer密钥和访问位。例如扇区 5 的块 1绝对块地址是 5 × 4 1 21。下面列几个常见换算值。扇区号块偏移绝对块地址用途000厂商块含 UID一般不要写011数据块5121数据块5323trailer密钥/访问位不要乱写15363最后一个 trailer我把这部分配置封装成一个类业务代码里只给扇区号和块偏移不直接写绝对地址减少算错的风险public class MifareConfig { public byte Sector { get; set; } // 扇区号 0..15 public byte BlockOffset { get; set; } // 0..2 数据块3 是 trailer 不要写业务数据 public byte KeyType { get; set; } 0x60; // 0x60KeyA, 0x61KeyB public byte[] Key { get; set; } new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }; // M1 出厂默认密钥 public byte AbsoluteBlock (byte)(Sector * 4 BlockOffset); }密钥参数是最容易踩的一个点。M1 出厂默认 KeyA 和 KeyB 都是 6 个 0xFF看起来没毛病但很多工程卡在发卡前就被写卡器改过密钥或者出厂时就烧了厂商密钥。拿到一批卡先别急着写业务逻辑先拿一张空卡做四段流程验证确认默认密钥能用再往下走。还有一个血泪经验不要把业务数据写到块偏移 3trailer那里是密钥和访问位区写错一个字节扇区直接锁死密钥错了就再也认证不过这张卡基本就废了。要改密钥可以先用读写器工具备份整张卡再在代码里做一次性的密钥变更流程别在业务循环里来回改访问位。4. CPU 卡读写APDU 命令才是它的语言4.1 CPU 卡为什么不能用扇区块方式读写CPU 卡和 M1 卡在架构上是两类东西。M1 是存储卡加逻辑加密数据分布在扇区块里操作方式是寻卡、认证、读写块CPU 卡内部是芯片加操作系统COS数据按文件组织访问受文件权限控制不存在“扇区块”这种概念。你要读 CPU 卡里的数据路径是这样的先选择应用SELECT DF/AID再验证权限VERIFY PIN然后对文件执行命令READ BINARY / UPDATE BINARY。上层协议是 ISO 7816-4 里定义的 APDU 命令结构一条 APDU 由 CLA、INS、P1、P2、Lc、数据、Le 组成。响应则是数据体加两个字节的状态字 SW1、SW2成功通常返回 90 00。读卡器在这里的角色是透传它把 APDU 按接触式 T0/T1 或非接触 ISO14443-4 协议交给卡片再把卡的响应原样返回给上位机。所以 C# 侧不用关心传输层细节但必须会组装 APDU、会解析状态字。这和 M1 的思维差异很大M1 的命令是“读某块”“写某块”CPU 卡的命令是“选择文件”“校验口令”“读二进制”“更新二进制”。命令的动作要靠 CLA/INS 指定目标文件要靠 P1/P2 和前面的 SELECT 来定位权限要靠 VERIFY 来满足。写 CPU 卡的核心是理解“有状态”这三个字。4.2 APDU 封装与响应解析一套能复用的 C# 代码APDU 组装的代码没有太多花样关键是处理有数据和无数据两种情况。我习惯这样封装public byte[] BuildApdu(byte cla, byte ins, byte p1, byte p2, byte[] data null, byte le 0) { using var ms new MemoryStream(); ms.WriteByte(cla); ms.WriteByte(ins); ms.WriteByte(p1); ms.WriteByte(p2); if (data ! null data.Length 0) { ms.WriteByte((byte)data.Length); // Lc ms.Write(data, 0, data.Length); } if (le 0) { ms.WriteByte(le); // 期望返回字节数 } return ms.ToArray(); }参数含义CLA 一般是 0x00表示标准命令INS 是指令码比如 0xA4 是 SELECT、0x20 是 VERIFY、0xB0 是 READ BINARY、0xD6 是 UPDATE BINARYP1/P2 是参数按指令和卡片定义来。Lc 是后面数据的字节数Le 是期望返回的字节数注意 Le 不是必须的不期望返回数据就不发。响应解析必须做状态字提取public (byte[] Data, byte Sw1, byte Sw2) ParseResponse(byte[] apduResponse) { if (apduResponse.Length 2) throw new InvalidDataException(APDU 响应不足 2 字节缺少状态字); int swIndex apduResponse.Length - 2; var body new byte[swIndex]; Array.Copy(apduResponse, 0, body, 0, swIndex); return (body, apduResponse[swIndex], apduResponse[swIndex 1]); } public void EnsureSuccess(byte sw1, byte sw2) { if (sw1 0x90 sw2 0x00) return; throw new SmartCardException($APDU 返回状态字 {sw1:X2}{sw2:X2}); }ParseResponse把数据体和状态字拆开EnsureSuccess把“不是 90 00”的响应直接变成异常。这一步一定要做很多上位机只取数据不管状态字结果指令失败却当成成功继续走流程写卡数据都丢完了还一脸茫然。另一个要注意的是超时参数CPU 卡内部要做加密运算特别是一次两次 PIN 验证错误后卡片响应时间会明显变长这条命令的等待时间建议给到 1 到 3 秒别用 M1 那种 500 毫秒一刀切。耗时操作放到Task.Run里跑完再回调 UI 线程界面才不会卡住。4.3 SELECT / VERIFY / READ 一条链路把 CPU 卡写入做成业务流程把 APDU 串成流程才是 CPU 卡读写真正落地的地方。以“读某个应用文件”为例完整链路是 SELECT 应用、VERIFY PIN、READ BINARY 三步每一步都要检查状态字public byte[] ReadCpuCardFile(string aidHex, string pinHex) { // 1. SELECT按应用标识符AID选择应用 var select Transmit(BuildApdu(0x00, 0xA4, 0x04, 0x00, HexHelper.ToBytes(aidHex))); var selectResp ParseResponse(select); EnsureSuccess(selectResp.Sw1, selectResp.Sw2); // 2. VERIFY验证 PIN满足文件读取权限 var pinData HexHelper.ToBytes(pinHex); var verify Transmit(BuildApdu(0x00, 0x20, 0x00, 0x01, pinData)); var verifyResp ParseResponse(verify); EnsureSuccess(verifyResp.Sw1, verifyResp.Sw2); // 3. READ从文件当前指针读 32 字节 var read Transmit(BuildApdu(0x00, 0xB0, 0x00, 0x00, null, 0x20)); var readResp ParseResponse(read); EnsureSuccess(readResp.Sw1, readResp.Sw2); return readResp.Data; }这个流程有两点必须强调。第一SELECT 的 0xA4 指令里P10x04 表示“按 AID 选择”P2 通常 0x00 或 0x04具体看卡片要求。第二步 VERIFY 里 P20x01 表示 PIN 编号很多卡从 01 开始但不同卡厂定义可能不同一定要拿卡商或发卡方的技术文档核对。第二状态是“记住”的SELECT 之后 VERIFY 才作用于当前应用如果先 VERIFY 再 SELECT或者换了一张卡没重新走全流程返回 6982 安全状态不满足就是必然的。实际项目里读卡器要同时支持 CPU 卡和 IC 卡我一般会在寻卡响应里拿到卡类型字段后做一次分派switch (cardType) { case 0x01: WriteM1Block(config, data); // IC 卡块读写链路 break; case 0x02: WriteCpuCardFile(config, data); // CPU 卡APDU 链路 break; default: throw new NotSupportedException($不支持的卡类型 0x{cardType:X2}); }分派的粒度尽量统一到“读卡号”“写业务数据”“改密钥”这些业务动作上上层调不到协议细节后续换读卡器品牌时只需替换底层实现这是把两个协议并存的架构最稳的做法。5. USB HID 读卡器踩坑记录设备打不开、写不进、读不对5.1 插上读卡器被系统识别成“未知 USB 设备”上位机怎么兜底现象是设备管理器里出现黄色感叹号“未知 USB 设备设备描述符请求失败”代码里枚举不到 VID/PID程序直接报“未找到设备”。原因第一名是供电不足前置 USB 口、劣质 HUB、过长或太细的 USB 延长线都会让设备枚举失败第二名是线材接触不良换根线就好。这不是代码问题是硬件问题但上位机要有兜底。解决方式是在启动时做“设备等待循环”而不是启动时枚举一次就放弃public HidDevice WaitForDevice(int vid, int pid, int timeoutSeconds 30) { var deadline DateTime.UtcNow.AddSeconds(timeoutSeconds); while (DateTime.UtcNow deadline) { var dev HidDevices.Enumerate(vid, pid).FirstOrDefault(); if (dev ! null) { return dev; } Thread.Sleep(500); } return null; }注意不要无限等待用户需要知道当前卡在读卡器还是没插。我会在界面上提示“请确认读卡器已连接”并给 30 秒重试窗口。如果换了后置 USB 口还是枚举不到用 USBDeview 或设备管理器详细信息里的硬件 ID 确认 VID/PID 是否和你代码里的一致很多国产读卡器用通用芯片方案VID 不是厂商标的那个。供电问题是 HID 读卡器第一大玄学先排除硬件再调代码。5.2 M1 卡写块成功但读出来不是预期数据现象写块命令返回成功读回来发现数据对不上——字符串顺序不对、末尾缺字节、或者整块是 0。原因是多方面的M1 一块固定 16 字节应用层要写入的数据不足 16 字节没有补 0补的是默认的空数组把 byte[] 直接转成字符串时用了错的编码或者是把固定 16 字节的数据写进去读出来看着不对其实是没按卡片存储格式解析。解决方法是先给数据做“块填充”再写然后立刻回读校验public byte[] PadBlock(byte[] data) { if (data.Length 16) return data; if (data.Length 16) throw new ArgumentException(数据超过一块容量需要拆块); var padded new byte[16]; Array.Copy(data, padded, data.Length); return padded; }写入前先读一次目标块确认当前块可读、内容不是全 0xFF新卡出厂数据块是全 0 或全 0xFF写入后再读回来做对比。还有一类坑更隐蔽如果你是在往 M1 的“值块”里存金额M1 对值块有专用格式数据要以数值加反码加地址的方式存储用普通写块命令直接写字符串后面用增量、减量指令操作就会失败。业务上建议把金额、卡号这类数据当普通二进制块存储自定义编码格式别去启用 M1 的“钱包”功能省下 FF 位和补码的坑。5.3 拔插一次后重连不上设备实例与事件重订阅现象程序第一次打开设备读写都正常把读卡器拔掉再插回去再发命令要么超时要么抛“设备句柄无效”。原因是 HidLibrary 返回的设备对象绑定的是打开时的实例拔插后内核对象已经失效更隐蔽的是DeviceInserted/DeviceRemoved事件是静态的如果只订阅一次重插后代码里还拿着旧对象引用。解决方式是监控设备事件重插后重建会话HidDevices.DeviceRemoved dev { if (dev.VendorID VENDOR_ID dev.ProductID PRODUCT_ID) { _device?.Close(); _device null; // 让业务层回到“无设备”状态 } }; HidDevices.DeviceInserted dev { if (dev.VendorID VENDOR_ID dev.ProductID PRODUCT_ID) { _device?.Dispose(); _device dev; _device.Open(); _device.Read(OnInput); // 重新挂回调否则设备回来了也收不到数据 } };这个坑我生产环境里翻车过两次都是“重连后没数据”。重连后必须重新挂Read回调因为旧回调绑定的是旧设备对象。另一个细节是如果程序里多处订阅了静态事件注意别重复订阅导致一次插拔触发两次回调、重复打开设备。事件里做短操作重连逻辑要和业务层的状态机联动设备断开了就停掉读写任务设备回来了再自动恢复。5.4 CPU 卡返回 6982 / 6985状态字没查就重发的代价现象CPU 卡写数据写不进去上位机只看到“写失败”不知道具体原因有的程序不看状态字直接重发发几次之后 PIN 错误计数耗尽卡片被锁死。原因基本是两个命令流程顺序不对比如没 SELECT 应用就 VERIFY或没 VERIFY 就 UPDATE以及 PIN 本身错误或卡片访问权限设置更高比如写文件要求管理态。解决思路是把状态字速查表直接做到日志和异常信息里让一线问题直接可见SW1 SW2含义90 00成功6A 82文件未找到6A 86P1/P2 参数错误69 82安全状态不满足没验证或没选对应用69 85条件不满足访问权限不足69 83认证失败 / PIN 错误63 C0~C9PIN 剩余重试次数 n代码里EnsureSuccess抛异常时要带上完整状态字日志里记成0x6982而不是“写失败”。一旦看到 63 CX 系列基本说明 PIN 错了几次剩余次数是 C 后面那个数这种情况不要盲目重发先让用户确认 PIN。CPU 卡 PIN 重试次数通常只有 3 次连续错完只能用 PUK 解锁这个代价比 M1 密钥错大得多。血泪经验CPU 卡调试阶段先把状态字表打印出来贴在工位上比什么文档都好使。5.5 设备行为说不清时直接用 USB 抓包对证据现象读卡器厂商自带的 Demo 软件能正常读写我写的程序死活不行或者说明书没写帧格式只给了一堆字节定义。原因多是厂商协议版本差异、报告长度配置不一致、实发帧和文档对不上。与其反复看文档猜不如直接抓 USB 总线上的真实报文。用 USBPcapWindows 下 Wireshark 抓包选到读卡器对应的 USB 控制器操作一次 Demo 软件发卡抓 interrupt out 端点上的数据就是你程序中“该发出去的字节”再操作一次读卡抓 interrupt in 端点上的数据就是设备实际返回的字节。把抓到的报文和你代码里组出来的byte[]逐字节对比重点看前几位是不是多了报告 ID长度是不是补位差异校验算法是不是 CRC 而不是异或。这个手段能一次性解决“说明书没写”“Demo 能跑我看不懂”“厂商客服也说不清”三类问题。抓包是 HID 调试最后一张底牌比反复试错高效得多。6. 让读写结果可验收回读校验、重试策略与一次实用技巧6.1 回读校验写进去的卡必须能原样读回来无论 IC 卡还是 CPU 卡写入操作都不能只信任设备返回的“成功”。我把回读校验写成标配方法写卡后立即读回并逐字节比对public bool WriteBlockWithReadback(MifareConfig cfg, byte[] data) { WriteBlock(cfg, PadBlock(data)); var readBack ReadBlock(cfg); return readBack.SequenceEqual(PadBlock(data)); }顺序是写前读一次记录原值写后读一次新值和预期比对。原值要留着万一写错了还有后悔药可回滚比对前先把数据统一做块填充两边长度一致再比。对于金额、卡号这种关键数据我一般要求读回两次结果一致才算过防止偶发的电荷保持问题。6.2 超时与重试策略别让脚本无脑重发超时参数要按卡类型区别M1 单条命令 300 到 500 毫秒足够CPU 卡 APDU 给 1 到 3 秒。认证失败不要立刻重试M1 密钥错重发多少次都白搭CPU 卡 PIN 错会直接消耗重试计数。重试次数上限 3 次间隔递增 100、200、400 毫秒都失败就抛到业务层让操作员介入。批量测试时每条命令间隔至少 50 毫秒避免设备端缓冲区堆积。6.3 一个实用技巧先读 UID 再定卡类型很多读卡器在寻卡响应里会同时返回卡类型和 UID。CPU 卡和 IC 卡在寻卡阶段就能区分拿到这个字段再做协议分派比“让用户手动选卡类型”靠谱得多。可以在界面状态栏实时显示“IC 卡M1UIDxx”或“CPU 卡 ATRxx”测试时一眼能看出有没有认对卡。最早我做这个方向时“写成功”只看到设备返回成功后来发现写在卡里的数据顺序不对才把回读校验变成强制动作。另一个习惯是每个命令函数都带完整日志发送字节、响应字节、状态字全记下来出了问题翻日志比重新抓发卡快得多。希望帮到你。本文还有配套的精品资源点击获取