不用买NFC空卡贴也不用盯着各种“模拟门禁卡”App里那一大堆收费功能Android手机本身就能做不少事。很多朋友一上来就搜“Android NFC模拟门禁卡”结果要么被教程绕晕要么跟着别人的App刷半天还是提示“不支持”最后不了了之。这篇文章把我自己做NFC调试的真实流程和完整代码整理出来从原理到环境搭建从读卡号到HCE模拟尽量把每一步为什么这么做讲清楚代码可以直接抄。这期内容适合三类人刚接触Android NFC开发、想做个读卡小工具的移动开发者不想折腾第三方破解工具、只想用手机和系统能力解决门禁问题的普通玩家以及准备做门禁对接、考勤系统原型验证的产品同学。读完你至少能跑通一条完整链路手机识别门禁卡 → 读取并解析卡号 → 用代码方式模拟近场交互。1. 先弄清NFC和门禁卡的本质再谈模拟1.1 RFID与NFC的关系一张卡片的身份从哪来门禁卡本质是一张RFID射频识别卡片里面有一块芯片和一组线圈。读卡器在刷卡瞬间给线圈供能芯片被唤醒后把身份信息通过电磁波回传。RFID按频率分好几类门禁行业最常见的是125kHz低频卡和13.56MHz高频卡。手机NFC的工作频率固定在13.56MHz所以很多老式低频门禁卡没法直接用手机刷这是硬件频率决定的和软件无关。NFC可以理解为RFID在13.56MHz频段上的一个增强版本它继承了“读卡”能力还额外支持双向通信。也就是说手机既能当读卡器去读别人也能模拟成一张卡让别人读。Android里把这两种行为分别称为Reader Mode和Card Emulation Mode后面要做的“模拟门禁卡”就属于后者。判断手里的卡能不能进入“手机模拟”的流程先看卡面上有没有标着13.56MHz再看读卡时App反馈的技术类型。现在办公楼和小区用的IC卡大部分是NXP的Mifare ClassicS50/S70或者兼容卡频率就是13.56MHz这类卡和手机NFC在频率上是能对上话的。而EM4100这类125kHz的ID卡手机根本读不出UID直接放弃老老实实继续用实体卡。1.2 手机NFC的三种工作模式为什么读卡容易、模拟难Android手机里的NFC硬件通常包含NFC控制器和安全单元SE系统在上面抽象出三种工作模式读卡器模式Reader/Writer手机主动靠近标签读取或写入数据日常的NFC标签读写都走这个模式。点对点模式P2P两台设备之间交换数据主要用于文件传输和数据交换现在用得越来越少。卡模拟模式Card Emulation手机把自己当成一张卡让外部读卡器读取。这个模式又分两种实现方式一种依赖硬件SE另一种叫HCEHost Card Emulation主机卡模拟完全依靠手机CPU和应用代码处理。读卡之所以简单是因为Android把读卡器模式封装得很成熟系统帮你处理了RFID协议细节。但模拟难是因为手机里的NFC控制器在默认情况下不会把“读卡器发来的底层命令”直接透传给App尤其是Mifare Classic这类使用私有协议MIFARE Classic protocol基于ISO/IEC 14443-3的卡片驱动层和SE层通常不开放普通Java/Kotlin代码根本没有机会介入。很多网上说的“用手机模拟门禁卡”最终只能靠手机厂商在系统层开放模拟接口才能实现这也是为什么刷门禁时经常出现“只有某品牌手机能模拟成功”的原因。1.3 你的门禁卡到底能不能模拟先判断卡型做技术方案前先给卡分个类。常见门禁卡可以粗略分成以下几种卡类型常见频率手机能否读取手机能否模拟ID卡EM4100等125kHz不能不能IC卡Mifare Classic S50/S7013.56MHz能读到UID和部分数据受UID、加密扇区限制通常难以模拟Mifare Ultralight/NTAG系列13.56MHz能读也能写有机会通过HCE做协议模拟CPU卡金融级/一卡通13.56MHz取决于是否支持ISO-DEP可以实现HCE模拟但需要匹配卡内部应用滚动码门禁卡13.56MHz能读但每次验证码都在变几乎无法模拟除非完整复现算法最核心的判断依据有三个频率、技术协议、加密情况。频率决定物理层是否兼容协议决定Android能不能识别加密情况决定你就算读到了数据也无法在手机里产出一个能通过验证的响应。我见过不少人折腾半天最后发现那张卡是滚动码卡读到的卡号每次都不同那就是没有意义的方向。此处加一句非常必要的提醒整个NFC调试过程请只针对你自己名下、有权限使用的门禁卡和测试设备。复制他人门禁卡属于你所在地区可能涉及民事责任甚至更严重后果的行为本文只讨论技术教学和自有设备调试。2. 开发环境准备Android Studio、真机与工程配置2.1 需要的工具和硬件做NFC开发先准备一套像样的环境。软件方面需要Android Studio建议用较新的稳定版本下载地址直接在官网拿旧版也没问题核心API没有太大变化以及一台Android真机。NFC模拟器在PC上跑不了Android模拟器也不支持NFC硬件穿透所以“必须真机”这条没有商量余地。真机选择上有几个经验可以参考优先选NFC能力完整的手机不要用那种“只支持NFC支付不开放读写”的阉割版本系统版本尽量在Android 8.0以上对HCE和动态前台分发的支持更稳定如果手头是国产定制ROM还要注意权限管理是否默认拦截了NFC相关的后台扫描。我自己测试时比较偏爱一加和Pixel系列因为它们更接近原生Android行为调试时少踩系统定制的坑。电脑端建议装一个NFC Reader Tool或者系统自带的卡调试工具也可以在Play商店里装一些免费的标签读取App来辅助判断。不过它们是辅助定位问题的真正常用的是自己写的日志后面会说到。2.2 AndroidManifest配置权限、intent-filter与features新建一个空项目后先在AndroidManifest.xml里声明NFC权限和硬件特性。uses-permission android:nameandroid.permission.NFC / uses-feature android:nameandroid.hardware.nfc android:requiredtrue /uses-feature加requiredtrue的意思是没有NFC硬件的设备在应用市场里搜不到这个App避免装到不支持的设备上。如果是做工具类应用也建议加上用户体验会好很多。接下来配置Activity的intent-filter。NFC读卡分发有三种ActionACTION_NDEF_DISCOVERED、ACTION_TECH_DISCOVERED、ACTION_TAG_DISCOVERED。门禁卡通常没有NDEF消息所以核心是TECH_DISCOVERED。我们让入口Activity监听这几种事件同时把tech过滤文件挂上去。activity android:name.MainActivity android:launchModesingleTop intent-filter action android:nameandroid.nfc.action.TECH_DISCOVERED / category android:nameandroid.intent.category.DEFAULT / /intent-filter intent-filter action android:nameandroid.nfc.action.NDEF_DISCOVERED / action android:nameandroid.nfc.action.TAG_DISCOVERED / category android:nameandroid.intent.category.DEFAULT / /intent-filter meta-data android:nameandroid.nfc.action.TECH_DISCOVERED android:resourcexml/nfc_tech_filter / /activity在res/xml目录下创建nfc_tech_filter.xml把可能用到的技术类型全部列进去。resources tech-list techandroid.nfc.tech.NfcA/tech techandroid.nfc.tech.NfcB/tech techandroid.nfc.tech.NfcF/tech techandroid.nfc.tech.NfcV/tech techandroid.nfc.tech.IsoDep/tech techandroid.nfc.tech.MifareClassic/tech techandroid.nfc.tech.MifareUltralight/tech techandroid.nfc.tech.Ndef/tech /tech-list /resources2.3 前台分发与Activity配置只配置Manifest还不够。当手机贴卡时系统会选一个最匹配的Activity把它带到前台但如果你的App已经打开并且系统认为当前界面不能处理这个标签很可能事件就被后台吞掉或者直接弹出一个无响应。解决办法是用前台分发Foreground Dispatch让App在处于前台且无遮挡时优先拿到NFC事件。前台分发需要在onStart里注册onStop里注销。核心代码如下class MainActivity : AppCompatActivity() { private lateinit var nfcAdapter: NfcAdapter private var pendingIntent: PendingIntent? null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) nfcAdapter NfcAdapter.getDefaultAdapter(this) if (nfcAdapter null) { Log.e(NFCDemo, 设备不支持NFC) return } val intent Intent(this, javaClass) pendingIntent PendingIntent.getActivity( this, 0, intent, if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_UPDATE_CURRENT ) } override fun onStart() { super.onStart() startForegroundDispatch() } override fun onStop() { super.onStop() stopForegroundDispatch() } private fun startForegroundDispatch() { val filters arrayOf( IntentFilter(NfcAdapter.ACTION_TECH_DISCOVERED), IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED), IntentFilter(NfcAdapter.ACTION_TAG_DISCOVERED) ) val techLists arrayOf( arrayOf(android.nfc.tech.NfcA), arrayOf(android.nfc.tech.NfcB), arrayOf(android.nfc.tech.IsoDep), arrayOf(android.nfc.tech.MifareClassic) ) nfcAdapter?.enableForegroundDispatch(this, pendingIntent, filters, techLists) } private fun stopForegroundDispatch() { nfcAdapter?.disableForegroundDispatch(this) } }这段代码里最关键的是pendingIntent的Flag。低版本Android用FLAG_UPDATE_CURRENTAndroid 12开始必须显式声明FLAG_IMMUTABLE否则在部分机型上PendingIntent创建直接抛异常。这类兼容问题在NFC开发里很常见遇到“一开App就崩溃但日志又没有明显报错”时优先检查这里的Flag。3. 核心代码做一个能读取门禁卡信息的小工具3.1 读取卡号UID的完整实现拿到Tag对象后第一个要读的就是UID也就是卡号。门禁系统在授权时通常把这个串作为设备的唯一标识很多纯不加密的门禁刷卡的瞬间就是读卡号比对的过程。在Activity中增加一个方法private fun handleTag(tag: Tag) { val uid tag.id val uidHex uid.joinToString() { byte - String.format(%02X, byte) } Log.i(NFCDemo, UID: $uidHex) val sb StringBuilder() tag.techList.forEach { tech - sb.append(tech).append(\n) } Log.i(NFCDemo, TechList:\n$sb) }handleTag在onNewIntent和onResume两个地方调用。用singleTop启动模式后Activity在栈顶收到NFC Intent时会回调onNewIntent而不是重新onCreate所以这里必须把intent更新到当前Activity防止界面状态不一致。另外有一些国产ROM存在“息屏状态贴卡直接唤醒到桌面而不是进入App”的问题这种情况通常出现在没有启用前台分发的场景。解决办法是再加一个前台通知栏或保持屏幕常亮更优雅的方案是在onResume里检测到Intent后立即处理并恢复前台。3.2 读取卡型与技术细节MifareClassic、IsoDep、NfcAUID只是最表层的信息真正决定能不能做深度操作的是卡片的技术类型。通过tag.getTechList()可以看到这张卡对外暴露了哪些技术接口。常见组合大约是这样的普通IC门禁卡NfcA、MifareClassic。卡内扇区可能加密需要用密钥验证后才能读数据。银行卡、公交卡IsoDep、NfcA、NfcB。这类卡往往支持ISO-DEP协议能通过APDU指令通信。电子标签NfcA、Ndef。贴上就有响应一般用来存网址或文本。读取MifareClassic卡的时候还要拿到扇区数量、块数量、容量这些基础属性方便后续判断卡内有没有可用数据区。private fun inspectMifareClassic(tag: Tag) { val mfc MifareClassic.get(tag) ?: return try { mfc.connect() Log.i(NFCDemo, Sector Count: ${mfc.sectorCount}) Log.i(NFCDemo, Block Count: ${mfc.blockCount}) Log.i(NFCDemo, Type: ${mfc.type}) Log.i(NFCDemo, Size: ${mfc.size}) mfc.close() } catch (e: IOException) { Log.e(NFCDemo, 读取MifareClassic失败: ${e.message}) } }这里要解释一个关键点connect成功不代表能读全部内容。Mifare Classic的每个扇区有独立密钥默认出厂密钥可能是全FF但很多门禁厂商已经改过密钥。如果没有密钥读扇区数据会抛出权限异常。所以不要指望靠读扇区就能“复制”一张卡在这类卡上UID能不能模拟才是关键而恰恰在Android原生层UID模拟几乎是禁区。有读者会问那App怎么判断能不能模拟简单回答是不能单纯靠代码判断只能实机测试。因为Android没有提供一个“查询本机NFC控制器是否支持UID模拟”的公共API只有厂商SDK或系统签名应用才能碰底层。3.3 记录与展示卡信息在实际开发中读卡号通常只是第一步。为了后面排查方便建议把最近刷过的卡记录本地保存下来便于对比测试。下面给一个简单版本用SharedPreferences存储够用且不引入额外依赖。private fun saveCardRecord(uidHex: String, techList: String) { val prefs getSharedPreferences(nfc_records, Context.MODE_PRIVATE) val timestamp SimpleDateFormat(yyyy-MM-dd HH:mm:ss, Locale.getDefault()).format(Date()) val records prefs.getString(records, ) val newRecord $timestamp | UID$uidHex | Tech$techList\n prefs.edit().putString(records, newRecord records).apply() }有精力的话可以换Room数据库把每次刷卡的时间、位置、UID、协议类型存成表后面做门禁使用频率分析或者自动提醒会方便很多。但这个Demo阶段没必要过度设计SharedPreferences够用。4. 实现“模拟”系统方案与HCE方案全解读4.1 为什么系统自带的“模拟门禁卡”经常刷不上先聊聊很多人的经历手机钱包App里有“模拟门禁卡”功能但试了十几次系统提示“复制失败”或“不支持该卡片”。原因要从手机钱包App实现模拟的方式说起。手机钱包App尤其是国产手机厂商自带的NFC服务主要依赖手机里的安全单元SE也就是一颗独立的安全芯片。模拟操作实际是把卡的信息写入SE里的某个安全域再由SE直接和读卡器完成交互。问题在于门禁卡里的数据涉及扇区密钥、UID只读属性SE为了安全不会允许普通用户随便读改写这些敏感信息。厂商提供的“模拟门禁卡”能力如果遇到非标准卡、加密卡、UID卡往往直接拒绝就算模拟成功也经常碰上读卡器认UID的场合因为很多门禁读卡器校验的不只是数据显示的卡号还包含卡片在耦合阶段的硬件特性。这不是开发能力问题而是芯片与协议边界问题。还有一点容易混淆一些第三方App声称自己能直接模拟门禁卡但背后用的往往是手机厂商的系统模拟接口或者老版本系统的漏洞一旦手机升级系统或者换设备马上失效。正经做技术方案不建议把希望全部寄托在这些App上。4.2 HCE主机卡模拟的工作原理HCE是Android 4.4开始引入的系统级卡模拟框架它允许开发者在没有SE参与的情况下让NFC控制器把收到的APDU指令转发给手机CPU由App自己处理应答。这在门禁开发里是一个可用的技术方向但它有明确边界。手机在卡模拟模式下工作时NFC控制器会先读取一张AIDApplication ID应用标识符路由表。当外部读卡器发起SELECT AID指令时控制器会根据路由表判断这个AID是本机SE的、SIM卡的还是某个App的。HCE做的就是注册自己的AID列表让控制器命中后把后续指令全部交给App。HCE真正能模拟的是遵循ISO 7816-4 APDU规范的智能卡协议也就是读卡器侧通常走ISO-DEPISO/IEC 14443-4通道。很多CPU卡门禁系统就是这种模式读卡器发APDU卡片回APDU非常适合用HCE接住。但跑Mifare Classic私有协议的门禁读卡器在物理层用的是ISO/IEC 14443-3的MIFARE Classic协议不通过ISO-DEP发APDU也不走AID选择流程HCE在这种情况下是无能为力的。所以如果门禁系统只是读一个固定UID的Mifare卡HCE很难做成直接替代品如果是自建门禁系统、实验室项目或者需要兼容标准智能卡协议的场景HCE是完全可行的方案。4.3 HCE完整代码注册AID并响应门禁读卡器先注册AID路由表。在res/xml目录创建aid_list.xmlhost-apdu-service xmlns:androidhttp://schemas.android.com/apk/res/android android:descriptionstring/hce_description android:requireDeviceUnlockfalse aid-group android:descriptionstring/hce_aid_group android:categoryother aid-filter android:nameF000000001 / aid-filter android:nameF000000002 / /aid-group /host-apdu-serviceAID的格式是16进制字符串全局唯一团队内部可以自行约定但注意不要和别家的应用冲突。接下来写HCE服务import android.nfc.cardemulation.HostApduService import android.os.Bundle import android.util.Log class GateCardService : HostApduService() { override fun processCommandApdu(commandApdu: ByteArray, extras: Bundle?): ByteArray { Log.d(HCE, 收到APDU: ${bytesToHex(commandApdu)}) if (isSelectAidApdu(commandApdu)) { return byteArrayOf(0x90.toByte(), 0x00) } // 根据实际门禁读卡器的协议定义处理逻辑 // 这里演示一个最简单的场景读卡器请求卡号我们返回固定卡号 val cardNumber A0B1C2D3E4 val response hexToByteArray(cardNumber) byteArrayOf(0x90.toByte(), 0x00) return response } override fun onDeactivated(reason: Int) { Log.d(HCE, 卡模拟通道关闭reason$reason) } private fun isSelectAidApdu(apdu: ByteArray): Boolean { return apdu.size 7 apdu[0] 0x00.toByte() apdu[1] (0xA4).toByte() apdu[2] 0x04.toByte() } private fun bytesToHex(bytes: ByteArray): String { return bytes.joinToString() { String.format(%02X, it) } } private fun hexToByteArray(hex: String): ByteArray { val result ByteArray(hex.length / 2) for (i in result.indices) { val index i * 2 result[i] hex.substring(index, index 2).toInt(16).toByte() } return result } }最后在AndroidManifest.xml里注册服务service android:name.GateCardService android:exportedtrue android:permissionandroid.permission.BIND_NFC_SERVICE intent-filter action android:nameandroid.nfc.cardemulation.action.HOST_APDU_SERVICE / /intent-filter meta-data android:nameandroid.nfc.cardemulation.host.apdu.service android:resourcexml/aid_list / /serviceexported设置为true是因为NFC系统服务需要绑定这个Servicepermission必须写成BIND_NFC_SERVICE这是系统绑定HCE服务时的强制权限写错了服务起不来。isSelectAidApdu的判断用了一个简化逻辑真机上如果读卡器发来的SELECT指令带不同参数可能需要更精确的判断最好把收到的APDU完整打日志再根据读卡器协议调整。4.4 不同模拟方案的对比与选型建议方案依赖优点缺点系统钱包“模拟门禁卡”厂商SE芯片刷起来稳定系统级优化好只支持特定卡型加密卡/UID卡经常失败第三方App模拟厂商系统接口操作简单门槛低兼容性差换设备可能失效HCE自研服务Android原生框架可控性强适合标准APDU协议不支持Mifare Classic私有协议外购NFC卡贴/手环实体硬件线圈兼容性强直接复制UID依赖物理卡失去了“用手机刷”的意义如果你真的想把日常门禁刷到手机上最省心的路径其实是手机钱包里的“模拟门禁卡”功能如果那张卡做不了再考虑HCE方案去对接自建门禁系统。千万不要先买一堆NFC卡贴去“破解”加密数据那既是市场乱象也容易把你带进坑。5. 实测踩坑实录NFC调试中的常见问题与排查方法5.1 App收不到NFC标签的Intent怎么办这是NFC开发最常见的起步问题。代码照着写了贴卡却没有任何反应。排查顺序一般是先确认手机NFC开关确实打开部分手机还能区分“NFC”和“NFC读卡”两个开关都要打开确认系统没把NFC扫描权限关到后台确认Activity用了singleTop并且前台分发注册成功确认nfc_tech_filter.xml里技术的包名没有拼写错误比如MifareClassic多写个r、NfcA写成NfcA这类低级错误直接导致TECH_DISCOVERED不触发最后用“NFC TagInfo”之类的第三方工具读一遍卡如果第三方工具能读出UID而你的App收不到Intent那问题基本出在filter或者前台上。有一种很隐蔽的情况Android系统在同一个Intent里可能同时匹配到多个Activity如果你的手机装了多个NFC相关应用系统可能会弹选择框或者把事件分给别的应用。处理方式是不要光靠intent-filter而是用前台分发“抢”事件同时把App设成某个NFC标签的默认应用这样系统就不会乱分了。5.2 HCE服务已经启动读卡器没反应怎么办写完HCE服务把手机靠近自己的NFC读卡器或者另一台手机的读卡App结果没有响应。这里有个前提要先说清楚HCE能否触发取决于读卡器是否发起SELECT AID。读卡器如果连AID都不选只是不断地感应卡片的UID那HCE根本不会启动。这是协议层面的不匹配不是代码问题。排错步骤是先看日志。把手机连上Android Studio过滤“HCE”关键字确认processCommandApdu有没有被调用。日志里完全没有“收到APDU”说明AID路由没走到App检查aid_list.xml里的AID是否被其他App抢占有些系统自带钱包会占用默认路由需要把本App设为前台优先或者关闭系统钱包的卡模拟。日志里有“收到APDU”但返回值没有效果说明读卡器收到了响应但不认可需要核对APDU指令格式。还要提一个细节HCE需要屏幕解锁和NFC开启部分机型还要求“NFC支付默认应用”设置为自己的App。路径一般在设置-连接与共享-NFC-默认付款应用不一定每个品牌都一样但功能是存在的。5.3 常见问题速查表现象可能原因解决办法贴卡无反应NFC开关关闭打开设置里的NFC和相关开关App没收到Intent前台分发没注册或filter错误检查enableForegroundDispatch和tech-list读取MifareClassic失败扇区密钥错误或卡片类型不匹配换自己可访问的测试卡不要尝试破解读卡时频繁断连手机晃动或读卡器功率不足固定手机位置不要移动HCE日志没有任何输出AID路由未命中检查aid_list.xml、默认路由、系统钱包占用HCE返回数据但刷不开APDU响应格式不匹配抓包分析读卡器指令按协议精确返回某些手机App正常但模拟失败厂商NFC方案限制换设备验证用系统钱包方案5.4 我的安全建议与合规提醒网上有“NFC中继攻击”的说法原理是把读卡器的信号通过无线方式转发到远端的卡片不是真的复制卡而是“延长刷卡距离”。这种技术在安防领域通常被用来测试门禁系统的防中继能力但从门禁用户的角度看它绕过了授权验证属于对门禁系统的未授权操作我不建议技术爱好者在这上面试水。做开发调试时请务必使用自己可控的读卡器和自己名下的测试卡。还有一个合规细节HCE服务的AID如果有人恶意模仿有可能被其他应用拦截。商用项目中最好对自己的AID做申请和管理在服务端校验卡号和会话随机数而不是像Demo这样返回固定卡号。固定卡号适合验证链路不适合上线。6. 后续还能怎么扩展从读卡到门禁联动跑通上面的读卡和HCE流程之后这个项目已经具备一个NFC工具的基础形态。你可以继续加以下几类功能。第一类是门禁使用记录分析。把每次刷卡的UID、时间、地点记录下来配合地图或考勤系统能做出员工到岗统计、访客进出报表这些在企业场景里可以直接对接后台API。第二类是自动化联动比如读到某张卡后自动触发HomeAssistant的开门动作或者联动智能家居场景让手机贴近NFC标签时完成一个操作。第三类是协议适配层把HCE部分抽象成服务支持多套AID和不同APDU指令集这样以后换门禁系统只要改配置不用重写代码。如果你打算继续深入建议把官方文档里的NFC基础知识册翻一遍尤其是关于IsoDep和APDU那部分。做NFC开发需求真正难的不是写代码而是理解协议背后的物理世界。我自己的体会是这类项目第一次做完最大的收获不是“能让手机刷开门”那一刻的成就感而是终于搞懂了平时那些门禁卡、电梯卡、饭卡在系统视角里到底是什么样的存在。而且一旦有了读卡和HCE这条链路后面接考勤、接访客系统、接实验环境都是水到渠成的事。最后再分享一个小技巧调试HCE时准备一台支持ISO-DEP的桌面读卡器能大幅提升效率。很多门禁读卡器是单向输出你根本不知道它往卡里发了什么指令而桌面读卡器可以把APDU收发日志完整dump出来对照着调整你的HCE返回逻辑比盲调省太多时间。希望这篇能帮你在NFC这条路上少走弯路。
阅读完成 · 觉得有帮助?