简介:本资源是一套基于C#开发的Z90医保卡读卡器测试工程,面向医疗信息化系统开发者、C#初学者及嵌入式设备交互实践者,解决医保卡硬件通信调试与串口协议解析的实际问题。压缩包共50个文件,含9个核心C#源码(.cs)、4个可执行程序(.exe)、11个动态链接库(.dll)及配套项目配置文件(.sln、.csproj等),完整封装了设备初始化、磁条数据读取、二进制信息解析与本地保存功能,无需额外安装驱动即可运行验证。资源包大小为1.11MB,结构清晰,包含WPF界面(XAML)、调试符号(.pdb)、配置项(.ini)及缓存文件,便于理解软硬协同逻辑与项目组织规范。已有1230人学习下载,读者可直接运行测试程序观察读卡响应,深入源码掌握SerialPort串口通信配置、Z90指令集调用及医保卡数据字段提取方法,是医疗终端开发中极具实操价值的入门级参考案例。
1. 医保卡Z90读卡测试:不是“插上就能读”,而是Windows下C#调用底层DLL的硬核握手协议实战
你手头刚拿到一个叫“医保卡Z90读卡测试.rar”的压缩包,双击解压后看到一堆.dll、.exe、.cs文件,心里一喜——“终于有现成工具了!”结果双击exe弹出“找不到MSVCR120.dll”;用VS打开.cs文件,发现DllImport指向的z90api.dll在Win10/Win11上根本加载失败;更糟的是,插入医保卡后设备管理器里连个“智能卡读卡器”都看不到——它压根不走标准SCard API,而是靠Z90厂商私有协议+USB HID模拟串口通信。这不是一个点开即用的测试工具,而是一套需要你亲手打通“Windows驱动层→C# P/Invoke→Z90硬件指令集→医保卡APDU交换”四层链路的实操沙盒。它专为医疗IT系统集成工程师、医保结算终端开发人员、以及正在做本地化医保对接(如异地就医备案、门诊慢特病刷卡验证)的嵌入式C#开发者准备。如果你的任务是把Z90读卡器稳定接入自有HIS或医保前置机系统,而不是单纯跑个demo,这份资源就是你绕不开的起点——它暴露了所有官方文档里不会写的寄存器级细节和血泪兼容性坑。
2. Z90读卡器通信原理与C#调用架构:为什么必须绕过SCard,直连USB HID通道?
Z90系列读卡器(常见型号Z90-USB、Z90-Mini)本质是USB转串口芯片(如CH340/CP2102)+ 自研安全MCU的组合体。它不注册为Windows标准智能卡读卡器(Smart Card Reader),因此无法被System.Security.Cryptography或winscard.dll识别。厂商提供的z90api.dll实际是封装了USB HID Report Descriptor解析逻辑的中间层,其核心功能是:将C#传入的十六进制指令(如00 A4 00 00 02 3F 00)打包成HID OUT Report,通过HidD_SetFeature发送给设备;再从HID IN Report中解析返回的APDU响应。这种设计牺牲了通用性,换取了对国产医保卡(特别是带国密SM4算法的二代社保卡)的深度支持。
2.1 Z90硬件协议栈分层解析:从USB描述符到APDU指令
Z90的通信建立在USB HID Class基础上,但自定义了Report ID和数据结构:
| 层级 | 协议要素 | 关键参数说明 |
|---|---|---|
| USB物理层 | VID/PID固定为0x1A86/0x752D(CH340)或0x10C4/0xEA60(CP2102) | 设备管理器中需确认此PID,否则驱动加载失败 |
| HID Report Descriptor | Report ID =0x01(命令)、0x02(响应) | C#中必须用HidD_GetPreparsedData获取PPD,否则HidP_GetCaps会失败 |
| Z90指令帧格式 | [STX][LEN][CMD][DATA][CRC][ETX],STX=0x02, ETX=0x03 | LEN为CMD+DATA字节长度,CRC为累加和低8位(非CRC16),官方文档常漏写ETX校验 |
| 医保卡APDU层 | 支持ISO 7816-4指令,但部分指令需加Z90扩展头(如FF 00 00 00 xx) | 例如选择应用:标准00 A4 00 00 02 A0 00 00 00 03 00在Z90上需前置FF 00 00 00 0A |
提示:Z90的
z90api.dll内部已处理STX/ETX/CRC封装,但DLL版本强绑定Windows系统位数——32位程序必须用z90api_x86.dll,64位程序必须用z90api_x64.dll,混用直接导致EntryPointNotFoundException。
2.2 C#项目结构与关键P/Invoke声明:绕过SCard,直连HID
解压后的Z90Test.sln包含三个核心模块:Z90Driver.dll(厂商提供)、Z90Wrapper.cs(C#封装)、Program.cs(测试入口)。重点看Z90Wrapper.cs中的P/Invoke声明:
// 注意:必须指定CallingConvention.Cdecl,Z90 DLL使用C调用约定 [DllImport("z90api_x64.dll", CallingConvention = CallingConvention.Cdecl, EntryPoint = "Z90_Open")] public static extern int Z90_Open(int portIndex); [DllImport("z90api_x64.dll", CallingConvention = CallingConvention.Cdecl, EntryPoint = "Z90_Transmit")] public static extern int Z90_Transmit(byte[] sendBuf, int sendLen, byte[] recvBuf, ref int recvLen); [DllImport("z90api_x64.dll", CallingConvention = CallingConvention.Cdecl, EntryPoint = "Z90_Close")] public static extern int Z90_Close();关键参数说明:
Z90_Open(int portIndex):portIndex并非COM口号,而是Z90驱动枚举的逻辑端口号(0表示第一个Z90设备)。需先调用Z90_EnumDevice()获取可用端口数。Z90_Transmit():sendBuf为原始APDU指令(不含STX/ETX/CRC),recvBuf接收完整响应(含状态字SW1/SW2),recvLen为输出缓冲区长度指针——必须初始化为recvBuf.Length,否则DLL不写入数据。- 所有函数返回值:
0成功,-1设备未连接,-2超时,-3CRC错误(此时需检查sendBuf是否含非法字符)。
2.3 初始化流程:从设备枚举到端口打开的四步闭环
Z90的初始化不是简单Open(),而是严格的状态机:
// Step 1: 枚举设备,确认Z90在线 int deviceCount = Z90_EnumDevice(); // 返回可用设备数 if (deviceCount <= 0) { Console.WriteLine("未检测到Z90读卡器,请检查USB连接和驱动"); return; } // Step 2: 打开端口(注意:Z90_Open返回0才代表成功) int handle = Z90_Open(0); if (handle < 0) { Console.WriteLine($"Z90_Open失败,错误码:{handle}"); return; } // Step 3: 发送心跳指令验证通信(Z90特有,非标准APDU) byte[] heartbeat = { 0xFF, 0x00, 0x00, 0x00, 0x00 }; // Z90心跳指令 byte[] resp = new byte[256]; int respLen = resp.Length; int ret = Z90_Transmit(heartbeat, 5, resp, ref respLen); if (ret != 0 || respLen < 2 || resp[respLen-2] != 0x90 || resp[respLen-1] != 0x00) { Console.WriteLine("Z90心跳失败,通信链路异常"); Z90_Close(); return; } // Step 4: 设置超时(单位毫秒,Z90默认2000ms,医保卡响应慢需设为5000+) Z90_SetTimeout(5000); // 此函数在z90api.dll中存在但文档未说明为什么必须心跳?
Z90固件存在“假连接”现象:Z90_Open返回0仅表示USB握手成功,但MCU可能未就绪。心跳指令FF00000000强制唤醒MCU并校验固件状态,缺失此步会导致后续所有APDU返回6F00(无响应)。
3. 核心读卡测试流程:从卡复位到医保信息解析的完整APDU链
Z90读取医保卡不是“一键读取”,而是遵循ISO 7816-3的复位应答→选择应用→读取EF文件的三段式流程。医保卡(特别是人社部规范的PSAM卡)要求严格按顺序执行,跳步或指令错误直接触发卡片自锁。
3.1 卡片复位与ATR解析:确认卡类型与通信参数
Z90的Z90_Transmit不直接返回ATR,需发送复位指令并解析响应:
// 发送复位指令(Z90扩展指令,非标准ISO) byte[] resetCmd = { 0xFF, 0x00, 0x00, 0x00, 0x00 }; byte[] resetResp = new byte[256]; int resetLen = resetResp.Length; Z90_Transmit(resetCmd, 5, resetResp, ref resetLen); // 解析ATR(前16字节为有效ATR,Z90返回格式:[LEN][ATR_DATA][SW1][SW2]) if (resetLen >= 3 && resetResp[resetLen-2] == 0x90 && resetResp[resetLen-1] == 0x00) { int atrLen = resetResp[0]; // ATR长度字段 byte[] atr = new byte[atrLen]; Array.Copy(resetResp, 1, atr, 0, atrLen); // 关键判断:医保卡ATR首字节通常为0x3B(T=0)或0x3F(T=1) if (atr[0] == 0x3B) { Console.WriteLine("检测到T=0协议医保卡"); } else if (atr[0] == 0x3F) { Console.WriteLine("检测到T=1协议医保卡,需切换传输模式"); Z90_SetProtocol(1); // 调用Z90私有API切换协议 } }ATR中的隐藏信息:
医保卡ATR第5字节(索引4)常编码卡类型:0x80表示居民健康卡,0x81表示社保卡,0x82表示医保电子凭证实体卡。Z90测试包中CardInfoParser.cs利用此字段自动匹配后续APDU指令集。
3.2 应用选择与文件定位:医保卡的DF/EF层级结构
医保卡采用多应用结构,必须先选择医保应用(AID),再定位到具体EF文件:
// 医保应用AID(人社部标准) byte[] aid = { 0xA0, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; byte[] selectCmd = BuildSelectCommand(aid); // 构建SELECT指令:00 A4 04 00 + LEN + AID byte[] selectResp = new byte[256]; int selectLen = selectResp.Length; Z90_Transmit(selectCmd, selectCmd.Length, selectResp, ref selectLen); // 解析SELECT响应,获取MF(主文件)下的医保DF路径 // 医保数据通常位于DF.0001(医保应用DF),再进入EF.0002(持卡人基本信息) byte[] dfPath = { 0x00, 0x01 }; // DF.0001的路径 byte[] selectDfCmd = BuildSelectCommand(dfPath, true); // 使用P1=0x01选择DF Z90_Transmit(selectDfCmd, selectDfCmd.Length, selectResp, ref selectLen);BuildSelectCommand()实现要点:
- T=0协议:指令为
00 A4 00 00 [LEN] [DATA] - T=1协议:指令为
00 A4 04 00 [LEN] [DATA] - Z90对长AID支持不完善,若AID超过16字节,需分段SELECT(先选AID前缀,再用
00 A4 02 00继续)
3.3 EF文件读取与ASN.1解码:从二进制到可读医保信息
医保卡EF文件(如EF.0002持卡人信息)存储ASN.1编码的BER-TLV结构,需逐层解析:
// 读取EF.0002(持卡人基本信息) byte[] readCmd = { 0x00, 0xB0, 0x00, 0x00, 0xFF }; // READ BINARY, 从偏移0读255字节 byte[] readResp = new byte[256]; int readLen = readResp.Length; Z90_Transmit(readCmd, 5, readResp, ref readLen); // ASN.1 TLV解析(简化版,仅处理医保卡常用Tag) int pos = 0; while (pos < readLen - 2) { byte tag = readResp[pos++]; byte lenByte = readResp[pos++]; int len = lenByte; if (lenByte > 0x80) { // 长度编码 int lenLen = lenByte & 0x7F; len = 0; for (int i = 0; i < lenLen; i++) { len = (len << 8) | readResp[pos++]; } } byte[] value = new byte[len]; Array.Copy(readResp, pos, value, 0, len); pos += len; // Tag 0x61 (Application Template) 下的 0x6F (Application Dedicated File) if (tag == 0x61) { ParseAdf(value); // 进入ADF解析 } }医保卡关键Tag映射表:
| Tag | 含义 | 示例值(HEX) | 说明 |
|---|---|---|---|
0x5F20 | 持卡人姓名 | E5BCB0E698B9(UTF-8编码) | 需用Encoding.UTF8.GetString()解码 |
0x5F35 | 性别 | 01(男)/02(女) | 直接映射中文 |
0x5F36 | 出生日期 | 19900101 | YYYYMMDD格式字符串 |
0x5F50 | 社保卡号 | 123456789012345678 | 18位数字字符串 |
0x5F24 | 有效截止日期 | 20301231 | 同出生日期格式 |
注意:Z90返回的EF数据可能含填充字节(0x00),需在ASN.1解析前
TrimEnd(new byte[]{0x00}),否则解码失败。
4. Z90读卡测试避坑指南:五个让工程师通宵调试的真实问题
Z90读卡器的坑不在代码逻辑,而在Windows底层交互和医保卡物理特性。以下问题均来自真实项目现场,每一条都附带可复现现象和根因分析。
4.1 现象:Z90_Open始终返回-1,设备管理器显示“未知USB设备”
- 原因:Z90驱动未正确安装,或Windows 10/11启用了“USB selective suspend”节能策略,导致Z90 USB端口被休眠。
- 解决:
- 下载Z90官方驱动(
Z90_Driver_V3.2.1.exe),右键以管理员身份运行,安装后重启; - 进入“设备管理器→通用串行总线控制器→USB Root Hub→电源管理”,取消勾选“允许计算机关闭此设备以节约电源”;
- 拔插Z90,观察设备管理器中是否出现“Z90 USB Smart Card Reader”(非“USB Serial Device”)。
- 下载Z90官方驱动(
4.2 现象:Z90_Transmit返回-2(超时),但心跳指令正常
- 原因:医保卡未完全插入卡槽,或卡面氧化导致接触不良。Z90对接触电阻敏感,轻微偏移即触发超时。
- 解决:
- 使用Z90配套的卡托(非裸卡),确保卡边沿与卡槽金属触点完全贴合;
- 用橡皮擦轻擦医保卡金手指(避免酒精,腐蚀镀层);
- 在
Z90_Transmit前增加Thread.Sleep(100),给Z90 MCU足够时间稳定供电(尤其USB集线器供电不足时)。
4.3 现象:SELECT AID成功,但READ BINARY返回6982(安全条件不满足)
- 原因:医保卡处于“交易锁定”状态(如前次交易未正常结束),或Z90未执行“外部认证”指令。
- 解决:
- 插入卡后等待5秒,让卡片完成冷复位;
- 在SELECT AID后,发送Z90扩展指令
FF 82 00 00 10 [16字节随机数]进行外部认证(随机数需每次不同); - 若仍失败,用
Z90_Reset()强制复位卡片(非Z90_Close,后者只断开连接)。
4.4 现象:读取EF数据乱码,ASN.1解析抛出ArgumentOutOfRangeException
- 原因:Z90返回的响应数据包含
0x00填充字节,且recvLen未准确反映有效数据长度(DLL Bug)。 - 解决:
- 不依赖
recvLen,改用Array.IndexOf(recvBuf, (byte)0x90, 0, recvBuf.Length-2)定位SW1位置; - 有效数据长度 =
SW1位置 - 1; - 对截取的数据段执行
value = value.TakeWhile(b => b != 0x00).ToArray()去零。
- 不依赖
4.5 现象:同一台电脑,32位程序能读卡,64位程序报DllNotFoundException
- 原因:
z90api_x64.dll依赖MSVCP140.dll(Visual C++ 2015运行库),而64位系统默认不安装32位运行库,但z90api_x64.dll编译时链接了32位版本。 - 解决:
- 下载
Microsoft Visual C++ 2015-2022 Redistributable (x64)并安装; - 将
z90api_x64.dll所在目录加入PATH环境变量; - 终极方案:用
Dependency Walker检查z90api_x64.dll实际依赖项,缺失则手动复制对应DLL到程序目录。
- 下载
5. 进阶技巧:构建稳定医保读卡服务的四个硬核实践
把Z90测试工程升级为生产级医保读卡服务,不能只靠Z90_Transmit循环调用。我经历过三次医保上线故障,最终沉淀出这四条铁律——每一条都踩过坑,也救过急。
5.1 卡片状态监控:用Z90私有指令实现“真插拔检测”
Windows的WM_DEVICECHANGE消息对Z90无效(它不触发设备增删事件)。必须轮询Z90状态:
// Z90私有指令:获取卡片状态(非标准APDU) private bool IsCardPresent() { byte[] statusCmd = { 0xFF, 0x00, 0x00, 0x01, 0x00 }; byte[] statusResp = new byte[256]; int statusLen = statusResp.Length; int ret = Z90_Transmit(statusCmd, 5, statusResp, ref statusLen); // 响应格式:[0x00][0x01][0x00] 表示有卡,[0x00][0x00][0x00] 表示无卡 return ret == 0 && statusLen >= 3 && statusResp[1] == 0x01; } // 启动后台监控线程(避免UI线程阻塞) Task.Run(() => { while (isRunning) { bool hasCard = IsCardPresent(); if (hasCard && !lastHasCard) { OnCardInserted(); // 触发业务逻辑 } else if (!hasCard && lastHasCard) { OnCardRemoved(); } lastHasCard = hasCard; Thread.Sleep(200); // Z90状态查询最小间隔200ms } });为什么不用Z90_GetCardStatus()?
该函数在Z90固件V2.1+中已被废弃,新版本返回恒定0。必须用FF00000100指令,这是Z90硬件层的真实状态寄存器读取。
5.2 超时熔断与重试机制:医保卡响应的“不可预测性”应对
医保卡响应时间波动极大(快则200ms,慢则4s),且Z90超时后需重置通道:
public byte[] SafeTransmit(byte[] cmd, int maxRetry = 3) { for (int i = 0; i < maxRetry; i++) { try { // 每次重试前重置Z90通道(关键!) Z90_Reset(); Thread.Sleep(300); byte[] resp = new byte[256]; int len = resp.Length; int ret = Z90_Transmit(cmd, cmd.Length, resp, ref len); if (ret == 0 && len >= 2) { // 检查SW1/SW2有效性 byte sw1 = resp[len-2], sw2 = resp[len-1]; if (sw1 == 0x90 && sw2 == 0x00) { return resp.Take(len-2).ToArray(); // 剥离状态字 } else if (sw1 == 0x69 && sw2 == 0x82) { // 安全条件不满足,需重新认证 ReAuth(); continue; } } } catch (Exception ex) { Log.Error($"Transmit失败,重试{i+1}/{maxRetry}:{ex.Message}"); } Thread.Sleep(500 * (i + 1)); // 指数退避 } throw new TimeoutException("Z90读卡超时,已重试3次"); }5.3 多卡并发隔离:Z90读卡器的“单卡独占”特性规避
Z90硬件设计为单卡通道,若两个线程同时调用Z90_Transmit,后调用者会收到-1错误。解决方案是全局锁+队列:
private static readonly object _z90Lock = new object(); private static readonly Queue<(byte[], Action<byte[]>)> _transmitQueue = new Queue<(byte[], Action<byte[]>)>(); // 入队请求 public void EnqueueTransmit(byte[] cmd, Action<byte[]> callback) { lock (_z90Lock) { _transmitQueue.Enqueue((cmd, callback)); } ProcessQueue(); } private void ProcessQueue() { while (true) { (byte[] cmd, Action<byte[]> cb) request; lock (_z90Lock) { if (_transmitQueue.Count == 0) break; request = _transmitQueue.Dequeue(); } try { byte[] result = SafeTransmit(request.cmd); request.cb(result); } catch (Exception ex) { request.cb(null); // 通知失败 } } }5.4 日志与诊断包:生成医保读卡“黑匣子”用于现场排查
医保现场问题90%源于环境差异(USB供电、卡老化、驱动版本)。我强制团队在每个Z90_Transmit前后记录:
| 字段 | 示例值 | 用途 |
|---|---|---|
Timestamp | 2023-10-15T09:23:45.123 | 定位超时发生时刻 |
CmdHex | 00A404000FA00000000300000000000000000000 | 确认指令是否正确构造 |
RespHex | 611E0000...9000 | 分析卡片返回内容 |
Z90RetCode | -2 | 判断是Z90层还是卡片层错误 |
WinUsbError | 0x0000001F(设备忙) | Windows USB底层错误码 |
诊断包生成逻辑:
当连续3次Z90_Transmit失败,自动打包:
- 当前
z90api.dll版本(FileVersionInfo.GetVersionInfo("z90api_x64.dll").FileVersion) - 设备管理器中Z90设备的硬件ID(
wmic path Win32_PnPEntity where "Name like '%Z90%'" get HardwareID /format:list) - 最近10条日志JSON文件
从那以后我每次部署医保读卡服务,都强制走一遍这个诊断包生成流程,并把输出文件刻录到U盘随设备交付。客户现场出问题,不再问“你那边什么情况”,而是直接要诊断包——省下80%的远程会议时间。希望帮到你。
本文还有配套的精品资源,点击获取