ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

C#调用Z90读卡器读取医保卡:APDU指令与文件结构实战解析

C#调用Z90读卡器读取医保卡:APDU指令与文件结构实战解析 简介这是一份面向医疗信息化开发者的Z90读卡器C#读卡测试工程演示了通过USB串口与医保卡读卡设备对接、读取磁条信息并解析持卡人数据的完整流程适合正在学习设备驱动、串口通信或构建医院/药店自助终端的初学者参考。压缩包仅1.11MB共50个文件以9个C#源码文件、Visual Studio解决方案和工程文件为核心同时内置11个dll动态库和4个exe可执行程序免去额外安装驱动的负担另有XAML界面、配置文件及少量缓存。项目已有1232人学习具备一定参考价值。测试程序覆盖设备初始化、读卡操作、数据解析、文件保存与错误处理等环节配合清晰的工程结构可直接用Visual Studio打开调试或运行直观理解Z90读卡器与C#串口通信的集成方法为医疗信息化系统的外设接入提供可复用的实践范本。1. 医保卡Z90读卡测试C#桌面端调通一张社保卡要过几道坎窗口里那台Z90读卡器红灯常亮、绿灯闪三下Windows提示“设备已就绪”可你写的C#程序调用ReadCard还是返回-1。这不是个例。我拆过的医保卡读卡项目里八成初次联调的失败都不在硬件而在你对读卡器工作模式的理解、对卡的目录结构认知以及DLL调用时那几个“看着对其实错”的参数。这份Z90读卡测试资源本质上是一套C#工程模板加调试方法解决的就是“一块Z90读卡器、一张医保卡、一段能读回姓名和卡号的代码”之间最后那一米的连接问题。适合做HIS窗口端、药店结算系统、社保自助终端的.NET开发人员也适合刚接手读卡器二次开发、还没被APDU和文件结构折磨过的新手照着搭第一版。2. 认识Z90读卡器与医保卡文件结构命令从哪来、数据存哪去2.1 Z90的三种工作模式与命令来源Z90读卡器在社保行业里很常见它遵循ISO/IEC 7816接触式IC卡标准负责两件事给卡供电并建立通信、把PC发送的APDU命令原样转发给卡片。很多人第一次拿到就急着调“读姓名”却没搞清楚这个读卡器的上位机协议是哪一类。常见工作模式有三种。第一种是底层指令模式通过串口或USB虚拟串口收发APDU命令格式类似“AA 00 01 00 ...校验码”这是最灵活的模式适合做深度定制。第二种是厂商封装模式厂商把读卡操作封装成一个大函数比如“调用一次返回整张卡的全部信息”开箱即用但调试空间小卡结构变了你也没辙。第三种是标准Windows API模式厂商提供一个动态库并暴露若干标准函数你用自己的主程序P/Invoke调用这也是这份资源里采用的方式。我一般建议项目初期就直接选厂商DLL加APDU自拼的模式而不能只依赖封装好的读卡函数。原因很简单医保卡上不同区域的数据必须先用SELECT命令选中对应文件再用READ BINARY命令按偏移量读回字节。封装函数能读取“当前卡”的信息但当你需要读到卡的扩展区域或者做兼容测试时只有自己拼APDU才能控制每一个字节。Z90内部其实就是一个APDU中转站你的C#代码把命令发过去它负责把卡片的响应原样送回来。读卡器本身不带业务逻辑谁发指令、发什么内容全是上位机的事。底层APDU的组成也不复杂。一条读命令是“CLA INS P1 P2 Lc Data Le”的格式比如选文件命令就是00 A4 00 00 02加两字节文件标识符读二进制命令就是00 B0 P1 P2 Le。与读普通接触式存储卡不同医保卡是CPU卡卡片内部有文件系统你的每个动作都得先“选中”再“读取”。Z90只是把这对命令送到卡片上剩下的由卡片的COS决定返回什么。2.2 医保卡的文件结构与有效数据位医保卡沿用的是社保卡规范里的目录结构卡片内部数据按三层组织主文件MF在根下下面挂若干专用文件DF每个DF底下才有实际存数据的EF文件。对大部分省份的医保应用来说个人信息存储在“医保专用”这个DF之下的若干二进制EF文件里。注意这里讲的是接触式医保卡。它的二进制文件通常以十六进制存储GB2312编码的汉字和ASCII编码的数字。比如姓名所在的EF文件读取返回32字节其中前部分字节按GB2312编码解释一个汉字占两字节。读出来直接按ASCII转字符串就会得到乱码。每次实际返回的数据长度受命令里的Le参数控制如果你只写Le00代表最多256字节卡片不会一次吐给你全部数据搞不好还会报错。正确做法是先读取文件控制信息里的文件大小再按16字节或者32字节一小段一小段地读回来拼装。Z90调试包里的初始化代码通常长这样[DllImport(YHZ90.dll, EntryPoint YHZ90_OpenPort)] public static extern int OpenPort(int port, int baud, int timeout); int ret OpenPort(4, 9600, 1000); if (ret ! 0) { Console.WriteLine(打开读卡器失败返回码 ret); return; }这是一个非常典型的读卡器初始化步骤。OpenPort的三个参数port是串口号或USB虚拟串口号常见枚举方式是从0开始编号而不是从1开始baud是波特率Z90通常用9600但也有批次出厂设置为19200如果调不通第一步就应该交叉试这两个波特率timeout是读卡器等待卡片响应的毫秒数。很多人在这里翻车误把timeout当成“整个读取操作的总超时”实际它只是单次APDU命令的等待上限。返回值0代表成功非0值各家不一通常1代表端口被占用2代表无设备响应。初始化失败时别急着查卡先看设备管理器里串口编号是否和代码里的编号对应。打开读卡器以后卡片插入检测通常也是封装函数返回一个布尔值或短整型。常见做法是循环检测每次间隔200毫秒直到卡片到位或超时。3. 构建C#读卡工程初始化、寻卡、读文件三步走3.1 移植YHZ90.dll调用静态方法封装与句柄管理拿到这份测试资源时我做的第一件事不是打开主窗体而是找到驱动的DLL导出表。Z90的厂商动态库通常叫YHZ90.dll或者ZT_IC.dll里面导出的函数一般包括打开端口、关闭端口、寻卡、读文件、写文件等。值得注意的是不同批次驱动导出的函数名并不完全一致有的版本把读卡和寻卡合并成一个函数有的版本单独拆开。移植前先用dumpbin或Dependencies工具看一眼导出表再决定你P/Invoke的方法签名。public static class Z90Wrapper { [DllImport(YHZ90.dll)] public static extern int YHZ90_OpenPort(int port, int baud, int timeout); [DllImport(YHZ90.dll)] public static extern int YHZ90_ClosePort(); [DllImport(YHZ90.dll)] public static extern int YHZ90_CheckCard(ref byte cardType); [DllImport(YHZ90.dll)] public static extern int YHZ90_Transmit(byte[] command, int cmdLen, byte[] response, ref int respLen); }最核心的是Transmit这个函数。它不做任何解析纯粹把一个字节数组形式的APDU命令发送给卡片再把卡片的应答字节原样拷贝到response数组里。cmdLen是命令长度respLen作为入参时填缓冲区容量作为出参时是实际应答长度。这里有个隐蔽的坑respLen传入的缓冲区大小要按你能承受的最长响应来给社保卡的响应一般不超过256字节但某些COS在错误状态下会返回比较长的状态说明缓冲区建议直接给512字节省得响应被截断后误判卡片异常。封装好这层以后再向上一层写专门读卡片的业务代码。读卡过程是串行依赖的选文件、读文件、再选下一个、再读。这一步的错误处理直接用Int32返回值判断就够了不要用异常流来承载业务分支因为读卡失败在窗口业务里属于“常见可重试”场景抛异常会影响性能。3.2 读卡流程串起来复位、选文件、读二进制、关句柄完整读一张医保卡的信息顺序一定要对。先复位卡片这一步不是硬件断电重启而是发送一条复位命令让卡片COS重新进入就绪状态。复位能解决一部分卡片“睡死”的问题特别是读了好几遍没成功后复位一下能让卡回到干净的初始状态。然后选主MF、选医保DF、逐条选EF并读取。public class IcCardReader { private const byte CLA 0x00; private const byte INS_SELECT 0xA4; private const byte INS_READ_BINARY 0xB0; // 选文件00 A4 00 00 02 文件ID public static bool SelectFile(byte[] fileId) { byte[] command new byte[] { CLA, INS_SELECT, 0x00, 0x00, 0x02, fileId[0], fileId[1] }; byte[] response new byte[512]; int respLen response.Length; int ret Z90Wrapper.YHZ90_Transmit(command, command.Length, response, ref respLen); if (ret ! 0 || respLen 2) { Console.WriteLine($选文件失败返回码{ret}, 响应长度{respLen}); return false; } // 最后两字节是状态字 SW1 SW20x9000 表示成功 return response[respLen - 2] 0x90 response[respLen - 1] 0x00; } // 读二进制00 B0 P1 P2 Le public static byte[] ReadBinary(int offset, int length) { byte[] command new byte[] { CLA, INS_READ_BINARY, (byte)(offset 8), (byte)(offset 0xFF), (byte)length }; byte[] response new byte[512]; int respLen response.Length; int ret Z90Wrapper.YHZ90_Transmit(command, command.Length, response, ref respLen); if (ret ! 0) { return null; } // 去掉末尾两个状态字其余是数据 byte[] data new byte[respLen - 2]; Array.Copy(response, data, data.Length); return data; } }选文件命令里的fileId就是EF文件的两位标识符比如某省份的医保目录里个人基本信息文件的ID是0x0001就诊记录文件的ID是0x0002。ReadBinary的offset参数从0开始但注意不能超出文件实际长度。如果你不知道文件有多大可以先读文件控制信息FCI。另外Le这个长度参数的取值在ISO 7816里有区间限制大部分COS支持1字节长度但个别老卡要求Le必须小于等于文件剩余长度。稳妥做法是每次先读文件剩余空间的大小不够的部分分两次读。设置超时时Transmit通信超时和前面OpenPort的timeout是两套机制。OpenPort的超时是物理链路等待读卡器响应的最长时间Transmit的超时在Z90内部固件里是固定值。实际联调时如果发现偶发读卡超时检查一下线程优先级窗口程序里读卡操作放在UI线程上一旦界面卡住App消息循环阻塞读卡器侧的超时已经先触发等你界面恢复过来响应缓冲区里早就是空的了。读卡务必放在后台线程或者用Task.Run包一层。3.3 数据拼装16字节原始数据拆出姓名、卡号、有效期读到原始字节以后最大的坑在编码和字段偏移上。医保卡的二进制EF文件不像文本文件那样有清晰的换行符它就是一个连续的字节区间。你需要一份字段表通常包含姓名、性别、卡号、有效期、证件号这些字段的起始偏移和长度。每个字段之间还可能填充了0x00或0xFF以保证对齐因此不能按固定长度硬切后连起来。public class CardInfo { public string Name { get; set; } public string CardNo { get; set; } public string ValidDate { get; set; } } public static CardInfo ParseCardData(byte[] raw) { CardInfo info new CardInfo(); // 假设字段定义姓名偏移2、长度10字节GB2312编码5个汉字卡号偏移16、长度16字节ASCII info.Name Encoding.GetEncoding(GB2312) .GetString(raw, 2, 10) .Trim(\0, , \xFF); info.CardNo Encoding.ASCII .GetString(raw, 16, 16) .Trim(\0, ); return info; }姓名读取时最典型的问题是“锟斤拷”乱码。这是典型的UTF-8解码GB2312字节导致的GB2312双字节的汉字字节被UTF-8错误组合成了Unicode替换字符。解决办法就是强制指定Encoding.GetEncoding(GB2312)不要用系统默认编码也不要用Encoding.Default因为窗口服务器上默认编码可能是GBK也可能被改成别的。卡号的读取相对简单ASCII字符集个别卡把数字0存成0x30存成BCD码的情况也有如果读出来发现卡号只有一半正确大概率是BCD到ASCII的转换问题。有效期字段通常是8字节的ASCII形式“20251231”或二进制压缩的BCD码。BCD压缩的情况下一个字节高四位存年份十位低四位存年份个位解包时需要单独转换。拿到这份Z90测试资源后建议先对照你本地省份的卡规范把字段偏移表填进去不要直接信任示例里的偏移值。不同省份的医保卡虽然文件结构大同小异但字段排列顺序和长度经常有差异做完以后拿真实卡逐字段比对一遍才算稳妥。4. 三大高频坑乱码、返回值异常与句柄泄漏4.1 现象读出的姓名是“锟斤拷”第一次跑通读取流程打开日志文件看到姓名一栏整整齐齐的“锟斤拷”。原因不复杂源数据是GB2312编码的双字节汉字序列而你的代码里用了Encoding.Default或者隐式按UTF-8解码。二者对中文双字节字符的映射方式不同导致每一对字节都被错误地解释成了别的字。解决方法是统一用Encoding.GetEncoding(GB2312)同时确认读卡器返回的字节里没有掺杂多余的控制字符。有些Z90驱动版本会在数据前插入一个字节的包标示比如0xAA开头解析前先跳过这个头。4.2 现象Transmit返回0但response里全是0x00读卡函数明明返回了成功可缓冲区里全是零字节。排查发现原因是缓冲区地址传错了。P/Invoke里byte[]参数默认是托管数组按引用传递没问题。但有人图省事把response参数改成了IntPtr然后手动Marshal.Copy结果IntPtr指向的是未初始化的内存区域里面的数据当然是零。解决方法是不要混用两种参数传递方式。要么全部用byte[]让.NET运行时自动封送要么先用Marshal.AllocHGlobal分配好内存再传IntPtr。混用时务必确认你读的是同一片内存。4.3 现象连续读卡几十次后读卡器彻底不响应现象是程序跑一段时间再调用OpenPort直接返回端口被占用。原因基本是句柄泄漏OpenPort每次调用都会申请一个底层通信句柄对应代码里如果没有在流程末尾调用ClosePort或者异常分支没有做finally释放句柄就会一直被占着。Windows不会因为你的进程还活着就自动回收这些句柄等到句柄耗尽或者驱动层不响应整个读卡器就废了。解决方法是把打开和关闭操作做成配对读卡流程无论成功失败都要走到ClosePort用try-finally包起来。public void SafeReadCard() { int ret Z90Wrapper.YHZ90_OpenPort(4, 9600, 1000); if (ret ! 0) return; try { // 执行选卡和读文件操作 } finally { Z90Wrapper.YHZ90_ClosePort(); } }ClosePort不是戴帽子的装饰动作。有的读卡器驱动在进程退出时会自动释放句柄让你前期开发时完全感知不到泄漏存在等项目上线跑一整天窗口端开始大面积“读卡器丢失”。那感觉比当场报错难受得多。4.4 现象新卡全都能读旧卡到固定位置ReadBinary报错有些老批次医保卡文件尾部没有补齐内容长度只有设计值的一半。你按规范字段表读后半段时卡片直接返回SW0x6B00地址出界。这不是读卡器坏了也不是代码逻辑错了而是卡本身的数据不完整。处理方式是在读取时先判断响应长度是否等于请求的Le值。如果响应短了把字段表更新一次后续读取按实际长度调整偏移。常见做法是在解析前对整份原始数据做一次长度校验不满足就直接标记这张卡“信息不完整”不强行解析。4.5 现象读卡器指示灯正常但CheckCard一直说没有卡芯片触点氧化或卡没完全插到位时读卡器的电信号检测并不稳定。此时YHZ90_CheckCard返回“无卡”是正常的。连续重试三五次仍然无卡不要继续空转把卡拔出来用橡皮擦一遍芯片触点再插。联调环境里经常遇到读卡器长期不用导致触点表面氧化信号幅度不够卡片有电但通信质量差。处理方式是在初始化成功后先用一个短APDU探测比如00 C0 00 00 00取复位信息能正常返回就说明链路是通的。5. 测试程序与日志落地把黑盒读取变成可回放的证据5.1 自写读写回环重试机制与状态机读卡器联调的难点在于它是硬件黑匣子只知道“失败”不知道“为什么失败”。我的做法是写一个小型状态机把一次完整读卡拆成“复位、选文件A、选文件B、读数据、关句柄”五个子步骤每一步独立记录返回值和耗时。五个步骤里哪个失败了日志一眼就能定位。重试策略上复位和选文件最多重试两次读数据阶段不重试直接报错。因为读数据失败往往意味着卡的数据异常重试也只是重复同样的错误。public enum ReadStage { Reset, SelectFile, ReadData, Done } public static ReadStage DoReadWithRetry() { for (int i 0; i 2; i) { if (SelectFile(new byte[] { 0x00, 0x01 })) { byte[] data ReadBinary(0, 32); if (data ! null data.Length 0) { return ReadStage.Done; } } // 失败后先复位再重试避免卡片停留在半选中状态 ResetCard(); } return ReadStage.SelectFile; }这里把选文件和读数据做成两个独立状态好处是失败后能从明确的状态点恢复而不是盲目地从头开始。个别Z90批次在选文件失败后直接重发选文件命令可能重复返回同样的错误码必须插入复位命令让卡片回到初始状态。这个小细节在我联调时解决了不少“第二次必失败”的诡异问题。5.2 日志格式与状态字解读日志必须包含时间、读卡器端口、步骤名称、APDU命令十六进制、响应十六进制、状态字、耗时这七项。状态字是卡片给你最直接的反馈0x9000是正常0x6A82表示文件未找到多半是文件ID写错了0x6700表示长度错误检查Le0x6985表示不满足使用条件通常是你还没选DF就直接去选EF了0x6B00是地址错误偏移越界。状态字不熟的时候每一条都去查对应ISO 7816-4的表格比猜测可靠得多。状态字含义常见原因0x9000命令正常完成无0x6A82文件未找到EF/DF文件标识符错误0x6700长度错误Le或Lc参数不符合规范0x6985使用条件不满足未选父文件就直接选子文件0x6B00地址错误读偏移量超出文件长度我一般会把所有响应字节存成持续追加的文本日志不覆盖。出现“偶发读不到卡”的报障时让现场人员把日志发回来对照状态字基本能定位到是机器触点问题还是代码问题。日志文件加一个简单的滚动逻辑超过10MB就重命名备份免得现场机器跑一年下来日志把C盘塞满。5.3 多读器并发与串口占用窗口业务有时候一台PC要连两台读卡器比如医保结算和银行代付各用一台。这时要注意Z90的DLL是否线程安全。测试发现部分驱动版本内部的全局缓冲区只有一份两个线程同时Transmit会互相污染数据。安全方案是加锁把Transmit包一层lock语句让同一时刻只有一个线程能发命令。private static readonly object TransmitLock new object(); public static int SafeTransmit(byte[] cmd, int cmdLen, byte[] resp, ref int respLen) { lock (TransmitLock) { return Z90Wrapper.YHZ90_Transmit(cmd, cmdLen, resp, ref respLen); } }多台读卡器的串口枚举也不省心。设备管理器里插拔顺序变了串口号也可能变。线上环境不要硬编码串口号做成配置项程序启动时自动检测设备管理器里的Z90设备并建立映射。很多现场人员不会理解“为什么两台读卡器换了个USB口程序就不认了”这个问题要在交付时就把自动枚举做好别指望实施人员现场改配置。6. 验证与收尾没读卡器也能验代码以及我那点读卡习惯联调最尴尬的情况是手边只有代码没有读卡器。为了不卡在硬件上我会在工程里保留一个“模拟卡模式”把Z90Wrapper里的函数做成接口线上用真实DLL实现本地调试用内存模拟实现。模拟卡会生成一段固定的字节数组按测试卡的字段表拼装好SelectFile和ReadBinary都从内存里取数据。这样至少能验证从APDU拼装到字段解析的全链路逻辑等真机到位再切回真实实现。public interface IZ90Driver { int OpenPort(int port, int baud, int timeout); int Transmit(byte[] command, int cmdLen, byte[] response, ref int respLen); } public class MockZ90Driver : IZ90Driver { private byte[] _fakeCardData new byte[256]; public int OpenPort(int port, int baud, int timeout) 0; public int Transmit(byte[] command, int cmdLen, byte[] response, ref int respLen) { // 只处理读二进制命令返回模拟数据 if (command[1] 0xB0) { Array.Copy(_fakeCardData, 0, response, 0, 32); respLen 34; response[32] 0x90; response[33] 0x00; return 0; } return -1; } }MOCK实现里要注意respLen的处理模拟返回值时仍要按真实响应格式填充状态字否则上层解析逻辑会踩到同一个空响应分支。有了这一层我可以在无硬件环境下跑通所有错误分支的单元测试比如模拟读卡器返回0x6A82验证代码是否走到了“提示换卡”的界面逻辑。这个做法让交付现场从“联调两小时”缩到“换真机跑十分钟”因为大部分代码逻辑早就在模拟环境里验证过了。从那以后我每次拿到新一批Z90读卡器都强制先跑一遍完整traverse流程开端口、复位、选MF、选医保DF、逐条读EF、关端口全部日志留存对比。批量到货时新卡和老卡的日志差异就是兼容性问题的第一手证据。读卡器这东西玄学很多但多数坑都埋在你看不到的字节流里把每个字节都记下来坑就变浅了。希望帮到你。本文还有配套的精品资源点击获取
返回列表