简介面向 VC/MFC 开发者的非接触式射频IC 卡读写程序源码包基于 dcic32.dll 动态库实现完整卡片读写功能。代码组织清晰包含主程序框架、对话框界面、颜色列表控件及字符串处理等工具模块适合需要快速掌握射频卡读写流程、推进门禁或身份识别类项目的开发人员参考。压缩包内共 64 个文件以 h 头文件、cpp 源文件为主体辅以 dll 动态库、lib 静态库、exe 演示程序以及 rc 资源、ico 图标等项目配置类文件整体仅 2.05MB结构紧凑便于下载后直接核对工程依赖并复用核心读写逻辑。目前已有 240 人学习可作为理解非接触式 IC 卡底层交互的实例。资源中保留了 Release/Debug 工程配置、Printer 打印模块、Ok/Error/Warning 图标资源与多个备份文件能够帮助读者从界面、工具类到动态库调用逐层拆解快速搭建自己的读卡程序骨架。1. 这个项目是干什么的把射频IC卡的黑匣子打开有一次在项目现场调一套门禁读卡器插上卡只看到红灯闪卡片里存的什么编号、哪个扇区、用的什么Key一概不知。后来拿到这套VC版读写非接触式射频IC卡程序才明白读卡器和卡片之间的对话本质是一串标准APDU指令核心读写链路三百行内能说清。这是基于Visual C和MFC对话框的一个工程通过PC/SC标准接口连接USB读卡器完成非接触式射频IC卡最常见的就是13.56MHz的Classic 1K这类卡的寻卡、扇区认证、数据块读写和日志回显。适合两类人一类做一卡通、门禁、会员卡终端想把读卡逻辑嵌入自己的桌面程序另一类是刚接手射频卡项目的开发想弄明白读卡器、驱动、卡三者之间到底怎么配合。下面先讲技术选型和工程结构再拆核心代码最后落避坑和二次开发思路。2. 技术栈与工程结构从PC/SC标准到MFC对话框的分层设计2.1 技术选型为什么是PC/SC而不是厂家SDK很多新手拿到读卡器第一反应是装厂家SDK跟着示例代码点读卡。这个思路的问题在于SDK把读卡器API和界面控件绑在一起换一个品牌的读卡器接口全变代码要重写而且SDK里的封装层次很深出了协议问题根本不知道中间哪一步出错。还有一种做法是串口直接下发十六进制指令给读卡模块指令格式没有统一标准不同模块文档各写各的写出来基本锁死硬件。这套程序选的是第三条路PC/SC规范。PC/SC是Windows系统提供的智能卡访问标准只要读卡器厂商写了符合PC/SC的驱动应用层代码就不用关心读卡器的内部实现。程序通过winscard.dll暴露的API建立上下文、枚举读卡器、连接卡槽、收发APDU指令读卡器是USB、串口还是其他通道都不影响上层逻辑。这样做的好处很实际同一个读写程序可以配不同品牌的读卡器二次开发时读卡核心逻辑可以抽成独立模块不跟界面耦合出问题也容易定位因为PC/SC把每一步的返回值都暴露出来了错误码直接查表就能知道是驱动、卡槽还是卡本身的问题。方案跨读卡器能力调试手段适用场景厂家SDK差换品牌重写API返回码黑匣子快速开发、绑死厂家串口指令差每款模块一套串口抓包嵌入式集成、简单模块PC/SC标准强驱动层屏蔽错误码APDU日志桌面工具、跨设备方案做门禁、食堂消费机这类集成项目的经验是只要读卡器能用PC/SC驱动一律走PC/SC只有碰到冷门模块或超低功耗场景才考虑串口直连。这个取舍能帮你省下大量换设备后的适配时间。2.2 工程目录与界面布局三层结构怎么分工工程本身是一个MFC对话框程序打开后是一个参数面板加一个滚动日志框。代码组织分三层对话框层负责控件取值、按钮事件和日志展示CReader类负责跟PC/SC API打交道封装连接、断开、收发指令ApduHelper和ByteUtils负责拼APDU、解析返回、做字节序和十六进制字符串互转。下面这个目录结构是工程里的实际组织方式去掉具体文件名也能看出分层。NfcReaderDemo/ ├── NfcReaderDemo.h / .cpp // MFC应用入口 ├── NfcReaderDemoDlg.h / .cpp // 主对话框界面交互与逻辑编排 ├── CReader.h / .cpp // PC/SC读卡器封装连接、断开、状态 ├── ApduHelper.h / .cpp // APDU拼装、响应解析、扇区地址换算 ├── ByteUtils.h / .cpp // 字节流与十六进制字符串互转 └── resource.h // 对话框资源定义主对话框的控件布局大概是顶部是读卡器下拉框和连接/断开按钮中部是卡参数区Key类型下拉、Key值输入框、扇区号/块号输入下部是读块和写块按钮最下面是数据输入框写块内容和滚动日志框。数据输入框一次接收16字节也就是32个十六进制字符写块前会做长度校验截断或补齐到16字节避免越界写脏数据。日志框是调试的核心每次收发APDU都会打印完整的发送帧、接收帧和SW1SW2状态字。界面和后台的一次完整读块操作是这样串起来的用户点“读块”对话框先调用CReader的EnsureConnected确认连接再通过当前块号和Key参数拼出一条认证APDU认证返回0x9000后拼出读块APDU收到的16字节数据以十六进制字符串显示在日志里。如果你要看卡里的数据内容直接选中块号点读块日志里会同时出现认证和读块两条指令的往返记录这是排查读写异常最直接的证据。3. 核心模块拆解一次读卡请求从进入到返回的完整链路3.1 连接读卡器SCardEstablishContext与SCardConnect读卡的第一步不是发指令而是建立PC/SC上下文并连接卡槽。下面这段是CReader类里Connect的核心实现也是整个程序唯一跟硬件打交道的入口之一。bool CReader::Connect(CString strErr) { LONG lRet SCardEstablishContext(SCARD_SCOPE_SYSTEM, NULL, NULL, m_hContext); if (lRet ! ERROR_SUCCESS) { strErr.Format(_T(建立上下文失败错误码: 0x%08X), lRet); return false; } DWORD dwLen 0; lRet SCardListReadersA(m_hContext, NULL, NULL, dwLen); if (lRet ! ERROR_SUCCESS || dwLen 0) { strErr _T(未找到PC/SC读卡器请检查驱动和连接); return false; } BYTE* pBuf new BYTE[dwLen]; lRet SCardListReadersA(m_hContext, NULL, (char*)pBuf, dwLen); if (lRet ! ERROR_SUCCESS) { delete[] pBuf; strErr.Format(_T(枚举读卡器失败错误码: 0x%08X), lRet); return false; } // 读卡器列表以 \0 分隔末尾双 \0 结束 m_strReader (CString)(char*)pBuf; delete[] pBuf; lRet SCardConnect(m_hContext, m_strReader, SCARD_SHARE_SHARED, SCARD_PROTOCOL_T0 | SCARD_PROTOCOL_T1, m_hCard, m_dwActiveProtocol); if (lRet ! ERROR_SUCCESS) { strErr.Format(_T(连接读卡器失败错误码: 0x%08X), lRet); return false; } return true; }这段代码有几个关键点。SCardEstablishContext的第一个参数SCARD_SCOPE_SYSTEM表示上下文是系统级的同一进程内多个连接共用这个句柄后面两个NULL分别表示不限定服务提供者和不传额外参数。SCardListReadersA用了Windows API经典的两段式缓冲第一次调用传入NULL拿需要的缓冲区长度第二次把分配好的缓冲区填上实际列表。列表内容是多个以\0分隔的字符串代码里只取了第一个多读卡器场景可以把整个缓冲区拆开填到下拉框里。SCardConnect参数里SCARD_SHARE_SHARED是共享模式意思是允许其他进程同时使用读卡器如果你要跑抓包工具同时调试必须用共享模式如果要做原子交易类操作可以用独占模式但平时调试别开独占不然关进程不干净就会占用卡槽下个程序打不开。协议位同时开放T0和T1让驱动协商激活的协议会回填到m_dwActiveProtocol后面SCardTransmit要拿它决定用哪个协议控制块。常见失败码0x8010001C表示读卡器不可用0x80100011表示卡槽被独占0x8010001D表示Smart Card服务没启动这些在避坑章节还会具体展开。3.2 收发APDUSCardTransmit与SW1SW2状态字PC/SC把每一次跟卡的数据交换抽象成SCardTransmit调用输入一条完整APDU输出一帧响应。这套程序把它封装成SendApdu所有上层读写命令都走这一个函数。bool CReader::SendApdu(const BYTE* pCmd, DWORD dwCmdLen, BYTE* pResp, DWORD* pdwRespLen, CString* pLog) { DWORD dwRet SCardTransmit(m_hCard, SCARD_PCI_T0, pCmd, dwCmdLen, NULL, pResp, pdwRespLen); if (dwRet ! ERROR_SUCCESS) { if (pLog) pLog-Format(_T(SCardTransmit失败: 0x%08X), dwRet); return false; } // 响应帧最后两个字节是状态字 SW1SW2 DWORD dwSwOffset (*pdwRespLen 2) ? (*pdwRespLen - 2) : 0; m_wSW1SW2 (WORD)((pResp[dwSwOffset] 8) | pResp[dwSwOffset 1]); if (pLog) pLog-Format(_T(发送: %s, 响应: %s, SW1SW20x%04X), ByteToHex(pCmd, dwCmdLen).c_str(), ByteToHex(pResp, *pdwRespLen).c_str(), m_wSW1SW2); return true; }这里要说明两点。一是协议控制块SCARD_PCI_T0它是PC/SC定义的分组头结构跟激活协议对应工程里激活的如果是T1理想做法是用SCARD_PCI_T1实际调试中发现多数驱动不挑但我建议还是按m_dwActiveProtocol动态选择避免在冷门读卡器上翻车。二是响应数据里最后两个字节是SW1SW2ISO 7816-4规定每个响应的末尾都要有状态字前面的数据区才是卡真正返回的内容。0x9000是标准成功0x6300是认证失败0x6A86是参数不对或指令不支持这三个是射频卡调试里最常碰到的状态字。写块时如果返回值不是0x9000先看SW1SW2再判断是协议问题还是权限问题不要直接判定为硬件故障。日志里把发送帧和响应帧都打出来遇到0x6700这类长度错误多半是Lc/Le字段没算对对照一下发送帧长度就能发现。3.3 MIFARE扇区认证与数据块读写进入数据区之前必须先过认证这一关。MIFARE Classic 1K在逻辑上分成16个扇区每扇区4块每块16字节。每个扇区的块3放的是密钥和访问控制位不是普通数据。认证APDU负责把指定块所在扇区“解锁”后续读块/写块指令才能生效。bool CReader::AuthBlock(BYTE bBlock, BYTE bKeyType, const BYTE* pKey6, CString* pLog) { BYTE cmd[16] {0}; BYTE resp[256] {0}; DWORD dwRespLen sizeof(resp); // PC/SC MIFARE 认证伪APDU cmd[0] 0xFF; // CLA 伪APDU读卡器解析不发给卡 cmd[1] 0x82; // INS 认证指令 cmd[2] 0x00; // P1 cmd[3] 0x00; // P2 cmd[4] 0x06; // Lc6 cmd[5] bKeyType; // 0x60Key A, 0x61Key B cmd[6] bBlock; // 要认证的块号 memcpy(cmd 7, pKey6, 6); // 6字节密钥 if (!SendApdu(cmd, 13, resp, dwRespLen, pLog)) return false; // 认证成功时读卡器返回 0x9000 return (m_wSW1SW2 0x9000); }认证指令CLA是0xFF表示这不是标准应用APDU而是PC/SC定义的伪APDU由读卡器固件消化掉不发给卡。INS0x82正是MIFARE认证命令P100表示认证不做参数修改P200保留Lc06表示后面跟6字节密钥第5字节是Key类型第6字节是目标块号。密钥输错或者Key类型选错返回SW1SW2就是0x6300。注意认证是“按扇区”生效的认证了Block 5只代表Sector 1打开了跳到Sector 2的块必须重新认证。读块和写块是同一个套路区别只在INS指令和数据区。bool CReader::ReadBlock(BYTE bBlock, BYTE* pOut16, CString* pLog) { BYTE cmd[5] { 0xFF, 0xB0, 0x00, 0x00, 0x10 }; BYTE resp[272] { 0 }; DWORD dwRespLen sizeof(resp); cmd[3] bBlock; // P2 块号 cmd[4] 0x10; // Le 16字节 if (!SendApdu(cmd, 5, resp, dwRespLen, pLog)) return false; if (m_wSW1SW2 ! 0x9000) return false; memcpy(pOut16, resp, 16); // 前16字节是块数据 return true; } bool CReader::WriteBlock(BYTE bBlock, const BYTE* pData16, CString* pLog) { BYTE cmd[21] { 0 }; cmd[0] 0xFF; // CLA cmd[1] 0xD6; // INS 写块 cmd[2] 0x00; // P1 cmd[3] bBlock; // P2 块号 cmd[4] 0x10; // Lc 16 memcpy(cmd 5, pData16, 16); BYTE resp[256] { 0 }; DWORD dwRespLen sizeof(resp); if (!SendApdu(cmd, 21, resp, dwRespLen, pLog)) return false; return (m_wSW1SW2 0x9000); }读块指令FF B0 00 00 10P2放块号Le0x10表示期望读16字节写块指令FF D6 00 00 10后面直接跟16字节数据Lc0x10。这里有个教训不要对块0执行写操作块0存的是UID和厂商数据写它会把卡永久锁死属于物理层强制约束跟权限无关。响应数据在SW1SW2之前读块成功时dwRespLen应该是18稳妥做法是判断dwRespLen18再取前16字节防止某些驱动把状态字外多返回一个字节的补齐数据。扇区和块的换算就一句话块号除以4就是扇区号块3是尾块。比如Block 7属于Sector 1要读写它认证目标是Sector 1密钥用该扇区块3里的密钥。程序里如果做“读整卡”功能每换一个扇区就重新认证一次千万别图省事只认证一次然后跨扇区狂读那样后半段全是0x6300。注意认证是按扇区生效的跨扇区操作必须重新认证这是MIFARE读写里最容易翻车的地方。4. 避坑指南编译、驱动与认证协议的三类翻车现场4.1 从源码到跑通环境配置与四步验证拿源码跑起来其实很快但有个前提条件Windows的Smart Card服务必须可用。先装读卡器的PC/SC驱动设备管理器里出现“智能卡读卡器”节点然后在VC工程里链接winscard.lib最简单的方式是在CReader.cpp开头加一行#pragma comment(lib, winscard.lib)省去在工程属性里翻配置。编译通过后插一张Classic 1K卡点连接点读卡日志里出现ATR和UID就算通了。验证顺序我建议固定成四步第一步读ATR确认读卡器和卡握手成功第二步用出厂默认Key A认证块4第三步读块4第四步写块4再读回。这四步走通整个链路里驱动、PC/SC服务、APDU封装、界面取值四个环节就都没问题。后面再做业务逻辑心里有底。如果你在公司内网环境驱动装完但SCardListReaders返回空还要检查USB驱动是否被安全软件拦截这类问题不在代码里在系统服务上。用系统自带的事件查看器能看到智能卡相关错误记录比盲目改代码有效。4.2 踩坑记录四条高频翻车现场坑1SCardEstablishContext返回0x8010001D 现象程序一启动连接读卡器立刻报错错误码0x8010001D。 原因Windows的Smart Card服务没启动。这个服务挂载PC/SC驱动服务停着应用层上下文根本建不起来。很多优化软件会顺手关掉它。 解决services.msc里找到Smart Card服务启动并把启动类型改为自动再重启程序。另外确认读卡器驱动是PC/SC类型如果装的是厂家私有驱动SCardListReaders会返回空列表。坑2SCardConnect返回0x8010001C但设备管理器里设备正常 现象USB灯亮着、设备管理器里读卡器也在就是连不上报0x8010001C。 原因最常见是读卡器被另一个进程独占比如厂家的抓包分析工具、故障后没释放句柄的旧进程其次是共享模式没开程序用了独占连接后异常退出句柄没归还。 解决关掉抓包工具和其他占用读卡器的程序代码里确认SCardConnect的share mode用SCARD_SHARE_SHARED实在不行拔插一次USB读卡器让驱动重新枚举设备即可。坑3认证一直返回0x6300 现象AuthBlock返回false日志里SW1SW20x6300。 原因密钥不对或Key类型选错。出厂卡默认Key A和Key B都是FFFFFFFFFFFF但很多门禁卡已经改过密钥部分扇区的访问控制位会限制只能用Key B认证界面里却一直选Key A。 解决先用空白卡跑默认密钥验证整条链路目标卡不行时Key类型分别用0x60和0x61各试一次最后确认工具读到的块号确实属于当前扇区。别把“认证失败”误判成读卡器损坏0x6300是卡在拒绝你的密钥不是通道断了。坑4读块返回0xFF一片写块后数据不落盘 现象认证成功了读回来全0xFF写块后立刻读还是一片F。 原因大概率不是卡坏了而是“扇区切换没重新认证”。认证是按扇区生效的你认证了Sector 1接着去读Sector 2的块读卡器就不干了轻则返回0xFF重则返回0x6300。 解决在读写函数入口强制执行AuthBlock并维护一个“当前认证扇区号”的成员变量目标扇区变化时自动重新认证。这是生产代码里最常见的做法。5. 二次开发把读卡Demo改造成通用备份组件跑通之后要往产品里集成第一步就是把CReader从MFC里剥出来。做法很简单把CString换成std::string日志回调用函数指针或回调接口替代去掉对对话框资源的依赖。改完的CReader就是一个纯C类可以打进DLL也可以直接被控制台工具、Windows服务甚至别的语言通过CLI调用。这套工程源码打包在资源里了拿到后先别急着改界面按第四章的顺序跑通再动业务逻辑。我实际改过的一个方向是“整卡备份”。Classic 1K一共16个扇区每个扇区4块、每块16字节总共1024字节但每扇区尾块放的是密钥和访问位直接当数据处理有锁卡风险。安全的备份策略是每扇区只读前3块共768字节数据区逐扇区认证、逐块读取结构化成二进制文件。写入时同样逐扇区认证后写回不碰尾块。核心循环代码大致是这样bool BackupCard(CReader reader, BYTE keyType, const BYTE* key, BYTE* pImage /* 768字节数据区 */) { int sector, i; for (sector 0; sector 16; sector) { BYTE bBlock (BYTE)(sector * 4); if (!reader.AuthBlock(bBlock, keyType, key, nullptr)) return false; for (i 0; i 3; i) // 跳过每扇区的尾块密钥区 { BYTE b bBlock i; if (!reader.ReadBlock(b, pImage b * 16, nullptr)) return false; } } return true; }这个循环的容量是16×3×16768字节如果要做完整镜像需要把尾块也纳入但写入尾块前必须重新计算访问位。尾块里KeyA、访问位、KeyB三者之间有关联校验改错一个bit就可能把卡锁死所以非必要不做完整镜像。做数据备份时用上面这个跳过尾块的版本读写两边循环结构一致还原时按同样的扇区顺序写回即可。如果要把这个能力开放给上层系统最省事的封装是把CReader和密钥管理包成DLL导出两个接口Reader_ReadAll返回768字节数据区Reader_WriteAll回写。业务层只需要面对两个函数不用关心APDU和扇区细节。从那以后我每次新接一台读卡器都会先把这套程序跑一遍ATR、默认密钥认证、写块再读回三步走完才敢动业务代码。希望帮到你。本文还有配套的精品资源点击获取