1. 先说结论:HCE到底能模拟什么卡,不能模拟什么卡
去年冬天加班到晚上十一点,我站在公司门口翻遍口袋才想起来门禁卡落在工位上,物业下班了、同事也走光了,最后只能蹲在消防通道等保安巡逻。那种"明明门就在眼前却进不去"的憋屈,让我下定决心把门禁卡塞进手机里。
我第一时间想到的就是Android HCE(Host-based Card Emulation,主机卡模拟)。这项技术从Android 4.4开始就内置在系统里,可以让手机在没有SE安全芯片的情况下,通过App直接响应NFC读卡器的指令——也就是说,你的手机可以在读卡器面前伪装成一张卡。
不过这里必须先泼一盆冷水:HCE确实能模拟卡,但它不是万能的。网上教你"刷机顶盒模拟Mifare卡"的教程十有八九都没说清楚一个核心限制——HCE工作在ISO 14443-4协议层,而市面上大量门禁系统用的Mifare Classic走的是ISO 14443-3加上NXP私有加密协议,这两套协议根本不兼容。这就好比你用普通话对着一个只会说粤语的保安喊了半天,对方完全听不懂。
这篇文章主要写给三类人看:一是想搞懂HCE原理的Android开发者,二是想把自家门禁卡装进手机的折腾党,三是做NFC相关产品选型、需要评估HCE方案可行性的工程师。我会把HCE能做什么、不能做什么、怎么判断你手上的门禁卡能不能被模拟、以及实测中会踩的坑一次说清楚。
1.1 为什么5分钟就能搞定HCE,但你仍要先读懂这张"协议表"
先说为什么"5分钟就能搞定"。如果你只是想写一个最小可用的HCE App,在Android Studio里新建工程、写一个继承HostApduService的类、在Manifest里注册一下、再配置一个AID过滤规则,差不多就是这个工作量。不夸张,熟练的话五分钟确实够。
但"写完App"和"刷开门禁"之间隔着一条巨大的鸿沟,那就是协议的兼容性。NFC底层协议并不是只有一种,一张卡或者一个读卡器,可能工作在完全不同的协议栈上。我整理了一张表,方便你快速定位:
| 协议/卡型 | ISO 14443-3 | ISO 14443-4 | HCE可模拟性 | 常见场景 |
|---|---|---|---|---|
| Mifare Classic (M1/S50) | 支持 | 不支持 | 不可直接模拟 | 多数小区门禁、公司门禁 |
| Mifare Ultralight | 支持 | 不支持 | 不可直接模拟 | 地铁单程票、小面额储值卡 |
| Mifare DESFire | 支持 | 支持(通过封装) | 部分可模拟(需协议授权) | 交通卡、电子钱包 |
| ISO 14443-4 Type A/B CPU卡 | 支持 | 支持 | 可模拟 | 银行卡、部分高端门禁 |
| NFC Type 2/3/4 Tag | 支持 | 视具体Tag而定 | 视协议而定 | 标签贴纸、蓝牙配对 |
注意看第二行和第三行的区别:Mifare Classic和Mifare Ultralight工作在ISO 14443-3这一层,它们用的是NXP私有的帧格式和加密认证方式,而Android的HCE实现直接建立在ISO 14443-4之上,卡在物理层就不通。这就像两条独立的地铁线路,轨道宽度不一样,列车没法在两条线路上互相跑。
我见过不少人拿着手机去刷单位门禁,读卡器完全没反应,就是栽在这个协议差异上。所以第一步,优先搞清楚你手上的卡是哪种协议,比什么都重要。
1.2 HCE能模拟Mifare Classic吗?答案一句话:不能
直接说结论:HCE在Android系统上不能直接模拟Mifare Classic卡。这跟破解不破解没关系,是物理层和传输层的兼容性问题。
Mifare Classic卡(常见的就是M1 S50卡)的通信过程分为几个阶段:读卡器先发送REQA/WUPA请求唤醒卡片,然后发送ANTICOLLISION命令获得卡的UID,接着执行三轮认证(authenticate),认证通过后才能读写块数据。这个认证算法叫Crypto1,是NXP的私有加密算法,它的帧格式是NXP自定义的、基于ISO 14443-3的比特级帧。
而HCE的工作方式是:读卡器发出SELECT APDU指令,系统在已注册的AID里查找匹配项,找到后把APDU请求交给对应的App处理,App返回APDU响应。整个过程跑在ISO 14443-4的块传输层上,完全没有Mifare的那套认证握手接口。
有人会问:那我把Crypto1的算法用软件实现在HCE里不就行了吗?问题在于你的代码根本接触不到那一层帧数据——Android系统没有向上层App暴露ISO 14443-3的比特级帧接口。App能拿到的已经是ISO 14443-4块内的APDU内容,而Mifare Classic的整个通信流程根本就不走APDU。
所以,如果在网上看到有人说"用HCE模拟Mifare Classic成功刷开小区门禁",要么是门禁读卡器其实支持ISO 14443-4的CPU卡,要么就是他用了别的硬件方案(比如外接PN532)而不是纯Android HCE。别被标题党带偏了。
1.3 HCE与门禁读卡器之间的"对话"到底长什么样
理解HCE的通信方式,用一句人话概括就是:读卡器是老板,你的App是员工,两者之间靠一张张"任务纸条"(APDU指令)来沟通。
一张APDU请求大概长这样:
00 A4 04 00 07 A0 00 00 00 03 00 00 00翻译成人话就是:老板递过来一张纸条,上面写着"我要选择AID编号为A0000000030000的这个应用,请你做好准备"。你的App收到这张纸条后,需要回复一句"收到,我准备好了",也就是返回一个状态码90 00。
再看一个实际门禁系统可能的交互流程:
读卡器 ->: 00 A4 04 00 07 A0 00 00 00 03 00 00 00 (选择应用) App <-: 90 00 (应用选择成功) 读卡器 ->: 00 B0 01 00 00 (读取数据块) App <-: 61 10 01 02 03 ... 90 00 (返回16字节数据)这整套机制看起来不复杂,但难点在于:你并不知道对方门禁系统具体会发出什么指令。每个门禁厂商的协议都不太一样,有的厂商用的是通用的读卡指令,有的则是私有协议。这也是为什么HCE做Demo容易、做真实对接难的根本原因。
所以,"5分钟搞定NFC门禁卡模拟"这个标题其实可以拆成两层:5分钟搞定的是"HCE模拟卡的骨架",真正费时间的是"让骨架匹配你那个门禁系统的具体协议"。
2. Android工程里最容易被忽略的4个关键配置
HCE项目本身不复杂,但配置步骤里有几个细节一旦搞错,问题会非常难排查。我在第一次跑通HCE的时候,就因为在Manifest里少写了一个属性,导致整个Service压根不会被系统识别,手机贴上去读卡器毫无反应,而且Logcat里还没有任何提示。这节我把关键配置拆开讲,照抄即可。
2.1 Manifest清单:Service注册与BIND_NFC_SERVICE缺一不可
HostApduService本质是一个Service,但它跟普通Service的注册方式有点不一样。直接在<application>标签里加这样一段:
<service android:name=".service.CardEmulationService" android:exported="true" android:permission="android.permission.BIND_NFC_SERVICE"> <intent-filter> <action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" /> </intent-filter> <meta-data android:name="android.nfc.cardemulation.host_apdu_service" android:resource="@xml/apduservice" /> </service>这里有两个关键点:
第一,android:permission="android.permission.BIND_NFC_SERVICE"不能省。这个权限的作用是:只有系统NFC服务才能绑定到你这个Service上。如果漏了,系统在路由APDU的时候找不到合法的Service,手机靠近读卡器就完全没有反应。
第二,android:exported="true"必须显式声明。从Android 12开始,系统要求显式指定exported,否则编译阶段就直接报错。
还有一个容易被忽略的点:@xml/apduservice这个XML文件要放在res/xml/目录下,如果你建项目时没有这个目录,记得手动创建。文件内容下面会详细讲。
2.2 AID配置:让读卡器"敲门"时系统能找到你
apduservice.xml是HCE的灵魂文件,它告诉系统:你的App负责处理哪些AID对应的卡片模拟。AID全称Application ID,是NFC读卡器选择应用时用的唯一标识,由ISO/IEC 7816-5规范定义。读卡器发送SELECT APDU指令后,系统会根据指令里的AID到所有已注册的Service里查找匹配项。
一个最基础的apduservice.xml长这样:
<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android" android:description="@string/app_name" android:requireDeviceUnlock="false"> <aid-group android:description="@string/app_name" android:category="other"> <aid-filter android:name="F222222222" /> <!-- 这里是测试用AID,实际项目中替换为你需要响应设备的标准AID --> </aid-group> </host-apdu-service>AID的格式要求是:十六进制字符串,偶数位长度,最少2位,最多32位。常见的有两类:
- 路径AID:例如
F222222222,以F开头,一般用于私有应用,自定义比较自由。 - 完整AID:例如
A000000151000000,前缀由ISO分配,通常对应某个标准应用(如GP规范)。
这里有一个很常见的误解:AID不是你想怎么定就怎么定,读卡器端的SELECT指令必须带上同样AID,你的Service才会被路由到。所以如果你是对接真实门禁系统,AID必须跟门禁读卡器配发的一致,这个信息通常需要向门禁厂商或系统集成商索取,不是猜出来的。
2.3 前台调度(Foreground Dispatch):抢回NFC事件处理权
当你写完HCE Service,把手机贴近门禁读卡器时,系统默认的逻辑是"先看前台有没有App在等NFC事件,再看有没有匹配的HCE Service"。如果在测试过程中手机弹出"打开NFC标签阅读器"之类的系统页面,或者被手机自带的NFC工具App拦截了,那你需要启用前台调度来抢占NFC事件。
前台调度的核心代码写在Activity里:
private fun enableNfcForegroundDispatch() { val nfcAdapter = NfcAdapter.getDefaultAdapter(this) ?: return val pendingIntent = PendingIntent.getActivity( this, 0, Intent(this, javaClass).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) val filters = arrayOf( IntentFilter(NfcAdapter.ACTION_TECH_DISCOVERED), IntentFilter(NfcAdapter.ACTION_TAG_DISCOVERED) ) val techLists = arrayOf( arrayOf(IsoDep::class.java.name), arrayOf(NfcA::class.java.name), arrayOf(MifareClassic::class.java.name) ) nfcAdapter.enableForegroundDispatch(this, pendingIntent, filters, techLists) }在onResume()里调用enableNfcForegroundDispatch,在onPause()里调用disableForegroundDispatch,否则会导致泄漏或者事件处理混乱。
实际测试中我发现,同一个App里同时注册HCE Service和前台调度时,普通读卡器并不会触发前台调度的回调,而是会走HCE的Service逻辑。前台调度主要影响的是"NFC标签发现"这一类事件。如果你测试时手机弹出的不是你预期的弹窗,先检查一下是否有其他App(比如各种NFC读取工具)也注册了相同或类似的NFC过滤规则,在"设置-连接设备-NFC-默认钱包/默认应用"里把默认NFC应用切换成你的App。
2.4 最容易踩坑:Android 12+的NFC默认应用选区变化
这个问题网上搜不到多少中文资料,但我在Android 14的真机上踩得很疼:从Android 10开始,系统对NFC默认应用的逻辑做了调整,用户在"设置-NFC-默认应用"里选择某个App后,该App会成为NFC读卡器的首选路由目标。如果你在测试时发现手机贴上去系统总是弹出"是否打开某某应用",而你的Service明明配置没问题,那就去检查一下默认应用是不是被系统重置了。
另外,android:requireDeviceUnlock这个属性我也踩过一回。它的含义是:当设备锁屏时,是否允许HCE Service被唤起。如果设成true,锁屏状态下手机贴近读卡器,系统不会唤醒你的Service,门禁当然刷不开。某些门禁系统要求"锁屏也能刷",这时候记得把这个属性设成false:
<host-apdu-service android:description="@string/app_name" android:requireDeviceUnlock="false">3. APDU服务实现:你写的是"应答逻辑",不是卡
配置搞定后,接下来就是核心代码。我先给出一份最小可用的实现,然后再解释里面的门道。
3.1 一个最小可用的HostApduService实现
用Kotlin写的话,大概长这样:
package com.example.hcecard import android.nfc.cardemulation.HostApduService import android.os.Bundle import android.util.Log class CardEmulationService : HostApduService() { companion object { private const val TAG = "CardEmulation" // 测试用AID,正式环境请替换为你需要处理的标准AID val SELECTABLE_AIDS = listOf("F222222222") } override fun processCommandApdu(commandApdu: ByteArray, extras: Bundle?): ByteArray { Log.d(TAG, "收到APDU指令: ${commandApdu.toHexString()}") // 判断是否 SELECT 指令 val isSelect = commandApdu.size >= 5 && commandApdu[0] == 0x00.toByte() && commandApdu[1] == 0xA4.toByte() return if (isSelect) { // 应用选择成功后返回成功状态 byteArrayOf(0x90.toByte(), 0x00.toByte()) } else { // 其他指令先统一返回不支持 byteArrayOf(0x6A.toByte(), 0x82.toByte()) } } override fun onDeactivated(reason: Int) { Log.d(TAG, "卡片模拟被取消,原因: $reason") } private fun ByteArray.toHexString(): String = joinToString(separator = " ") { byte -> "%02X".format(byte) } }这段代码做了什么?简单来说:当读卡器发出SELECT指令选择你的AID时,你的服务回复一个"选择成功"(90 00);当读卡器发出其他指令时,你的服务回复"文件未找到"(6A 82)。
这么简单的代码当然刷不开真实门禁,它的意义在于让你确认"系统路由通了、Service活了、APDU能走起来了"。先跑通这个最小环,再往里面填业务逻辑,这是HCE开发的正确顺序。
3.2 读懂APDU请求与返回码:9000不是唯一答案
APDU指令分为两部分:头部(Header)和正文(Body)。头部通常是4个字节:
CLA INS P1 P2每个字节的含义分别是:
- CLA:指令类别,常用
00表示ISO 7816标准指令。 - INS:指令类型,比如
A4表示SELECT选择、B0表示READ BINARY读取二进制、D0表示WRITE BINARY写入、C0表示GET RESPONSE获取响应。 - P1/P2:参数,具体含义取决于指令类型。
后面的字节是Lc(正文长度)、正文数据、Le(期望返回长度)。比如:
00 A4 04 00 07 A0 00 00 00 03 00 00 00拆开来看:
| 字段 | 值 | 含义 |
|---|---|---|
| CLA | 00 | 标准指令 |
| INS | A4 | SELECT(选择) |
| P1 | 04 | 按AID名称选择 |
| P2 | 00 | 第一个或唯一一个匹配项 |
| Lc | 07 | 后面跟着7个字节的AID |
| Data | A0 00 00 00 03 00 00 | 要选择的AID |
返回的状态码(SW1 SW2)则需要记住几个最常用的值:
| 状态码 | 含义 |
|---|---|
| 90 00 | 成功 |
| 6A 82 | 文件/应用未找到 |
| 6A 86 | 参数P1/P2错误 |
| 6D 00 | 指令不支持(INS错误) |
| 6E 00 | 类别不支持(CLA错误) |
| 63 00 | 认证失败 |
| 69 85 | 使用条件不满足 |
调试HCE服务时,最直观的方式就是在Logcat里打印收到的commandApdu,然后对照这张表去判断自己返回的状态码合不合理。我自己的经验是:先把所有未知指令统一返回6A 82,然后逐个根据日志增加响应逻辑。这比一开始就试图实现完整协议要高效得多。
3.3 针对"只读UID"型的门禁系统:HCE为什么无能为力
很多小区门禁的验证逻辑特别简单:读卡器只读取卡片的UID(唯一标识符),然后跟后台数据库比对。这种系统用"Mifare Classic卡"居多,但也不是绝对,有些CPU卡门禁系统也可能只读UID。
问题在于:HCE模式下,Android系统没有向App暴露设置或获取UID的接口。在HCE的通信模型里,卡片模拟方的UID由NFC控制器硬件决定,而且通常每次会话还会随机变化(部分设备是这样),App根本控制不了这一个值。
如果你拿HCE去模拟一张Mifare Classic卡的门禁,读卡器那边在ANTICOLLISION阶段就失败了——因为你的手机模拟出来的UID跟门禁系统里登记的那张卡的UID对不上。这也是为什么很多"把门禁卡模拟到手机里"的方案,实际上用的是"手机系统自带钱包的NFC模拟功能"或者"额外硬件"(比如ACR122U、PN532),因为它们可以做到在更底层去模拟整张卡或者固定UID。
所以,当你确认门禁系统是"只读UID"型,而你又坚持要用HCE,那基本就是死路一条。正确的做法是换方案,而不是在HCE这条路上死磕。
4. 实测判断:手上的门禁卡能不能被HCE模拟
这里我要教你一个在动手前就能完成的三步判断法。很多人一上来就写代码,写到一半才发现协议不兼容,白白浪费半天。先花十分钟判断一下,比你写两百行代码都有用。
4.1 用手机查卡类型:两分钟判断Mifare Classic还是ISO14443-4
首先,你需要一台带NFC的Android手机,然后在应用商店搜索"NFC Tag Info"或者类似的NFC读卡工具。这类工具的基本逻辑是:手机靠近卡片后,会读取Tag的协议信息和内容。
操作步骤如下:
- 打开NFC Tag Info应用,把门禁卡紧贴手机背面(通常在摄像头附近的主线圈区域)。
- 应用会弹出卡片类型和技术列表,主要看两项:
- NFC-A(ISO 14443-3)
- ISO 14443-4 / ISO-DEP
- 如果列表里出现了
MifareClassic或者NXP MIFARE Classic,那这张卡就是Mifare Classic。 - 如果列表里出现了
IsoDep或者ISO-DEP,说明这张卡走的是ISO 14443-4,HCE有机会可模拟。
这里有个小技巧:如果手机贴近卡片时NFC Tag Info根本读不到卡,只有"已检测到NFC标签"的提示但无法解析标签类型,那大概率是一张非标准的私有卡或者加密卡,HCE方案基本可以放弃。
我自己拿了一张食堂卡和一张公司门禁卡试过,结果很典型:食堂卡显示的是MifareClassic,公司门禁卡显示的是IsoDep。所以结论很明确:公司门禁可以尝试HCE,食堂卡没戏。
4.2 通过读卡器型号判断门禁系统类型
如果你手边正好能看到门禁读卡器(就是墙上那个刷卡的设备),可以看一眼型号和品牌,然后做初步判断。常见的几类读卡器如下:
| 读卡器形态 | 常见品牌/型号 | 倾向协议 | 是否适合HCE |
|---|---|---|---|
| 老式蓝色/黑色长方形读卡器 | 中控、ZKTeco | 多为Mifare Classic | 不适合 |
| 支持CPU卡的高端读卡器 | HID iCLASS SE、Desfire读卡器 | ISO 14443-4 | 适合 |
| 带键盘的密码+刷卡一体机 | 海康、大华门禁一体机 | 不确定,需查型号 | 视具体型号 |
| 圆柱形读卡器(常用于宿舍门) | 常见杂牌 | 多为Mifare Classic | 不适合 |
这个方法不是100%准确,因为很多读卡器同时支持多种协议(比如某些高档读卡器能同时读Mifare和ISO 14443-4的卡)。但它能帮你筛掉最典型的情况:如果读卡器上印着"MIFARE"字样,那基本就没戏了。
这里要特别提醒:不要因为读卡器长得老就觉得它一定不支持CPU卡。我见过一个小区用的读卡器外观特别复古,但拆开看参数表才发现它同时支持ISO 14443-4。所以型号判断只能作为辅助,最终以卡片的协议检测结果为准。
4.3 可行的替代方案:当HCE走不通时的三条路
如果你的卡是Mifare Classic,HCE这条路走不通,那还有几个方向可以尝试。这里我只讨论"你有合法权限"的场景,比如这是你自己的门禁卡,或者你获得了小区物业的授权。
方案一:使用NFC读写模块+刷写UID卡。买一张可写UID的空白卡(比如UID卡、CUID卡、FUID卡),用PN532或者ACR122U把原卡数据复制到新卡上。这个方案的缺点是需要在手机上装额外的读写App,优点是成本低、成功率极高,Mifare Classic的扇区数据都能完整复制(前提是你的门禁卡没有启用特殊的防复制机制,比如滚动码或者UID白名单)。
方案二:更换门禁系统方案。如果你能联系到物业或者公司行政,沟通是否能升级门禁读卡器,让它支持ISO 14443-4的CPU卡。这个方案的成本最高,但也是唯一能够从底层解决"HCE模拟门禁卡"问题的正路。
方案三:使用支持HCE的第三方门禁平台。近年来有些智能门禁平台(比如某些支持蓝牙+NFC手机开门的系统)已经内置了HCE支持,你只需要在App里绑定门禁卡,手机就能直接刷。这个方案虽然不是你自己写HCE代码,但原理是一样的,而且由厂商做好了协议适配。
我个人试验下来,宿舍楼的Mifare门禁卡最省事的方案是换一张CUID卡,因为大部分宿舍门禁只校验UID不校验数据,写个空UID卡就能刷。而公司门禁卡则因为走的是CPU卡协议,HCE方案反而有戏。你手头的卡属于哪种,先按4.1的方法查一遍再说。
5. 从"完全不行"到"成功刷卡":我的排查记录
这一节我把实际操作中踩过的坑按"症状-排查-解决"的方式记录下来。如果你是照着前面代码写得差不多了但刷不开门,大概率能在下面找到对应的问题。
5.1 问题一:手机刷上去门禁毫无反应
这个现象最让人崩溃:手机贴上去,读卡器什么动静都没有,Logcat里连一条APDU日志都没打出来。
排查思路按照优先级排列:
- 检查Manifest的Service配置:
BIND_NFC_SERVICE权限有没有漏,exported有没有设成true,action名是不是android.nfc.cardemulation.action.HOST_APDU_SERVICE。漏任何一个,系统都不会路由。 - 检查AID是否匹配:模拟卡时,读卡器发出了SELECT指令,但你的Service里注册的AID不在读卡器的选择列表里,系统一样不会路由。
- 检查系统NFC设置里的默认应用:在"设置-连接设备-NFC-默认应用"里,把你的App设为默认NFC应用,避免被其他App抢占。
- 检查手机支持度:部分旧机型或某些定制ROM对HCE支持不完善,需要在系统设置里打开"卡模拟"开关(一般在NFC设置页面里,不同品牌叫法不一样)。
我自己的排查顺序是:先重新读一遍Manifest,确认没问题后再用adb shell cmd nfc命令查看NFC服务状态。这里有个实用命令:
adb shell dumpsys nfc | grep -A 20 "mCardEmulationManager"输出里能看到系统注册的AID列表和当前路由状态,如果里面没有你的F222222222,说明系统压根没把你的Service注册进去,问题一定出在前面三步。
5.2 问题二:服务能收到APDU但总是返回失败
这种情况意味着系统路由已经到了你的Service,但APDU交互没有走通。常见现象是:读卡器亮了一下红灯或者发出"滴滴"的报错声。
我的做法是先在Logcat里打全日志,观察具体是哪一条APDU导致失败。比如我碰到过的一个场景:读卡器发来00 A4 04 00 07 A0 00 00 00 03 00 00 00,我的代码返回了90 00,然后读卡器又发来00 B0 01 00 00,我原样返回6A 82,然后门禁就报错了。
这就说明:读卡器在SELECT之后还有一个"读取卡内数据"的步骤,你的Service必须对这个指令返回正确的数据,而不能一概返回"不支持"。至于应该返回什么数据,这就要看门禁系统的协议文档了。如果你是跟门禁厂商合作的,向对方索要APDU协议文档;如果是自己逆向测试的,就得靠抓包和尝试了。
这里有一个调试技巧:用一个支持监听NFC通信的读卡器(比如ACR122U配合PC端的NFC读取软件)去唤起你的HCE服务,然后抓取读卡器发出的所有APDU指令,这样比在手机上盲猜要高效得多。
5.3 问题三:一次成功一次失败,刷十次有三四次失败
这种"时灵时不灵"的问题最让人抓狂。我遇到过的情况是:手机贴近读卡器后,有时候能刷开,有时候没反应,间隔时间还不固定。
排查之后发现原因有三类:
第一类是NFC天线位置问题。手机背面真正的NFC线圈区域并不是整个后盖,而是一个小的矩形区域,通常在摄像头附近。很多人在刷手机时习惯性用手机全身去贴读卡器,结果NFC线圈没有对准读卡器的感应区,导致信号不稳定。解决办法是:多试几个角度和位置,找到自己手机的最佳刷卡点。
第二类是NFC天线兼容性问题。部分读卡器对手机NFC天线的灵敏度要求较高,哪怕位置对上了偶尔也会握手失败。这属于硬件兼容性问题,无解,但可以通过调整刷卡位置缓解。
第三类是系统路由优先级不稳定。Android系统在路由NFC事件时,如果同时有多个App注册了NFC服务,可能会出现优先级竞争。把其他无关的NFC应用卸载掉或者禁用,能明显降低失败率。
5.4 排查HCE小技巧汇总
再总结几个实用技巧,都是我实际用过的:
- 用Logcat打印所有APDU收发:这是最高效的调试方式,每次刷卡都能看到完整的指令流。
- 用NFC读卡器工具模拟读卡器端:PC端的读卡工具比手机调试方便得多,能精确控制指令内容。
- 在
apduservice.xml里多配置几个AID:如果你不确定读卡器会使用哪个AID,可以同时配置多个,系统会逐个尝试。 - 留意系统NFC服务有没有自动重启:开发过程中如果改过Manifest或者AID相关代码,最好杀掉App进程重新安装再测试,避免旧配置残留。
还有一个我自己当时没注意到的细节:HCE的onDeactivated回调一定要打印日志。每次刷卡结束后,系统都会回调这个方法,原因值代表不同的取消原因(DEACTIVATION_LINK_LOSS表示读卡器移开,DEACTIVATION_DESELECTED表示读卡器主动取消选择,DEACTIVATION_TIMEOUT表示超时)。如果你发现刷一次门禁会连续收到多次onDeactivated回调,说明读卡器那边其实已经完成了好几轮APDU交互,只是你的日志没打全。
6. 最后的经验与可扩展方向
说点实在的。HCE做门禁模拟这件事,技术本身不算难,难在对"边界"的理解。我在做这个项目的过程中最大的收获是:搞清楚"能不能做"比"怎么做"重要得多。协议不兼容,代码写得再漂亮也白搭。
如果你手头的门禁卡确认是ISO 14443-4协议的CPU卡,那恭喜你,HCE方案有很大概率能跑通。我建议你从最简单的"SELECT+READ"应答逻辑开始,先用最小Demo验证路由,再逐步扩展指令响应。如果你手头的卡是Mifare Classic,也别灰心,换用UID卡复制的方案反而能更快达成目标。
最后分享一个扩展方向:HCE还能用来做很多有趣的事,不只是门禁。比如你可以用HCE实现一个简单的"数字名片"——手机贴近别人的NFC读卡器时自动传递你的联系方式;或者做一个"NFC签到系统"——手机靠近签到机自动完成打卡。这些应用场景的本质都是一样的:让Android设备在ISO 14443-4协议下扮演一张CPU卡的角色。
我最近就在尝试把一个简单的"NFC电子票"Demo跑通,用HCE模拟一张包含票务信息的卡片,让检票机可以读取。相比门禁系统,这类场景的协议往往更标准化,HCE的落地成功率也更高。如果你对HCE有兴趣,完全可以顺着这个方向继续深入。