news 2026/9/13 14:21:54

Android读写Ntag21x:从NfcA到JNI So库的完整实现指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android读写Ntag21x:从NfcA到JNI So库的完整实现指南

简介:面向Android NFC开发者的Ntag21x芯片读写示例工程,适合需要对接NXP Ntag21x系列标签、理解So库调用与JNI机制的移动端工程师,对底层NFC协议不熟悉的开发者尤其友好。压缩包共471个文件、8.85MB,包含16个so动态库、4个Java源码、6个txt说明文件,以及大量flat/json/xml等构建配置与中间资源,整体是一个结构完整的Android工程,便于直接导入分析。已有五百一十四人学习,具有一定的实践参考性。示例围绕Android NFC基础能力展开,涉及NfcAdapter与Tag对象的获取、System.loadLibrary加载本地库、JNI接口声明与C/C++实现;针对Ntag21x芯片的I2C与ISO 14443A协议,可看到初始化连接、读取特定区域、写入数据以及异常处理等代码路径,也包含权限申请和So库调用前的准备逻辑,并延伸涉及P2P、卡模拟等NFC模式的So库调用思路。适合在移动支付、数据交换、智能标签等物联网场景下,帮助开发者快速搭建NFC读写能力,少走弯路。

1. 为什么 Android 读 Ntag21x 不能只靠 NfcAdapter

做 NFC 读写最容易被卡住的一步,是拿着NfcAdapterIntent里取出Tag对象后,发现只能读到 ID,却读不出标签里的业务数据。Ntag21x 这类芯片本身基于 ISO 14443A,Android 系统只保证把底层NfcAIsoDep技术实例交给你,至于怎么把一个 4 字节的READ命令发给标签、怎么解析 ACK,官方 API 并不会替你封装。示例源码把 NXP 的这套指令集封装进了 So 库,通过 JNI 暴露给 Java,正好补上这一层。适合正在做门禁、智能海报或设备配对的 Android 开发者,尤其是需要同时处理多种 Ntag21x 变体、又不想从零啃协议的人。

2. Ntag21x 芯片与 Android NFC 技术栈:从协议到 Tag 对象

2.1 Ntag21x 系列特性:I²C、ISO 14443A 与存储结构

Ntag21x 是 NXP 推出的 NTAG 系列中的一个分支,常见型号包括 Ntag213、Ntag215 和 Ntag216。它们都符合 ISO 14443A,工作频率 13.56MHz,这也是 Android NFC 默认支持的主要协议。这几个型号最大的差异在于用户内存容量:Ntag213 有 144 字节用户可用空间,Ntag215 是 504 字节,Ntag216 则能达到 888 字节。所谓“用户可用空间”,并不是从页 0x00 开始一路畅通,而是要从页 0x04 的序列号之后开始算起,标签头部的厂商信息、序列号、锁定位和配置字占用了前几页。

实际工程里,我更关心的是它的页寻址机制。Ntag21x 每个页固定 4 字节,读写都以页为单位。写入时一次能写 4 字节,读时可以一次连续读多页,但 NXP 官方协议要求读取命令一次最多返回 16 字节(4 页)。如果你用transceive发送READ命令时指定地址为 0x30,返回的就是页 0x30 到 0x33 的 16 字节原始数据。这种结构对 So 库的设计影响很大:底层 C 代码需要维护一个“页偏移 + 缓存”的状态机,而不是像普通文件 I/O 那样直接按字节流处理。

辅助供电也是一个容易忽略的点。Ntag21x 和普通的 Ntag213 不同,部分型号(如 Ntag21x 系列中的带 I²C 版本)支持通过 I²C 接口与 MCU 通信,在无 RF 场时也能读写。但在 Android 示例中,我们通常只走 NFC 通道,I²C 只是芯片多出来的一条旁路。真正要移植到生产环境时,你必须在代码里区分当前是通过NfcA还是IsoDep建立的连接,因为不同连接拿到的传输层句柄不同,直接混用会让 So 库内部的状态指针失效。

2.2 Android NFC API 核心:NfcAdapter 与 Tag 对象的获取

Android 的 NFC 架构是围绕NfcAdapterTag两个类展开的。系统在检测到 NFC 标签时,会创建一个Intent,Activity 通过NfcAdapter.ACTION_TAG_DISCOVEREDACTION_NDEF_DISCOVEREDACTION_TECH_DISCOVERED来接收。其中ACTION_TECH_DISCOVERED最精确,因为它的 intent filter 可以指定<tech-list>,比如android.nfc.tech.NfcA,这样只有支持 NfcA 的标签才会触发你的代码。

拿到Intent后,通过NfcAdapter.getTag()拿到Tag对象。Tag对象不是一个可以用来收发数据的句柄,而是一个描述标签技术类型的容器。真正能用来交换数据的是Tag.getTechList()返回的技术实例,例如NfcANfcBIsoDepNdef。对于非 NDEF 格式的 Ntag21x 标签,最常用的是NfcA,因为Ntag21x不会像 Ndef 那样自动把 NDEF 封装信息暴露出来,需要你自己发原生命令。

代码上常见做法是这样的:

NfcAdapter adapter = NfcAdapter.getDefaultAdapter(context); if (adapter == null) { /* 设备不支持 NFC */ } PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, new Intent(this, getClass()).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), PendingIntent.FLAG_MUTABLE); adapter.enableForegroundDispatch(this, pendingIntent, new IntentFilter[]{ new IntentFilter(NfcAdapter.ACTION_TECH_DISCOVERED) }, new String[][]{ new String[]{ NfcA.class.getName() } });

这段代码里,enableForegroundDispatch让当前 Activity 优先接收 NFC 事件,而不是弹出系统标签选择器。PendingIntent.FLAG_MUTABLE在 Android 12+ 是必须的,否则会抛SecurityExceptionNfcA.class.getName()表示只希望捕获支持 NfcA 技术的标签。注意这里并没有限制具体芯片型号,所以 Ntag21x、MIFARE Classic、甚至其他 ISO 14443A 卡片都会进入这个回调,真正的区分要在onNewIntent里通过芯片应答的 SAK 或 ATQA 来判断。

2.3 为什么需要 So 库:JNI 的边界与性能

很多人会问:Java 层直接用NfcA收发命令不行吗?行,但有两个问题。第一,NFC 的transceive每次调用都有系统层开销,特别是在并发场景下,若每个 page 读都从 Java 层发起,线程切换和 GC 会拖慢整体节奏。第二,NXP 的 Ntag21x 并非只有简单的读写命令,它还包含配置扇区的锁定、NFC counter 功能、以及原厂签名验证,这些命令往往带 CRC 和特殊状态机处理,Java 层写起来啰嗦且易错。放进 So 库后,C 代码可以直接复用 NXP 官方 LibNFC 的逻辑,或者自行实现紧凑的状态机,Java 层只保留一个薄的封装。

JNI 的典型分工是:Java 声明 native 方法,C++ 实现后编译成 .so,运行时通过System.loadLibrary("ntag21x")加载。一个常见的设计是让 native 方法持有一个 long 类型的指针,指向 C 层创建的上下文结构体:

public class Ntag21xNative { static { System.loadLibrary("ntag21x"); } public static native long nativeConnect(NfcA nfcA); public static native int nativeReadPage(long handle, int page, byte[] data); public static native int nativeWritePage(long handle, int page, byte[] data); public static native void nativeClose(long handle); }

nativeConnect负责把NfcA对象的内部句柄传给 C 层,C 层再调用NfcA对应的 JNI 函数执行连接。这里有个容易被忽略的细节:C 层不能直接保存NfcA的 Java 引用,因为跨线程访问会导致 JNI 全局引用泄漏。正确做法是在nativeConnect里通过GetObjectField拿到NfcA内部mHandle,把它存成intlong,之后所有读写都靠这个底层句柄,不再碰 Java 对象。

3. 示例工程结构与 So 库加载路径

3.1 解压源码包后,先认识这几个目录

拿到Ntag21x芯片读写Android示例源码.rar,解压后通常能看到一个标准的 Android Studio 工程结构。app/src/main/java下会有入口 Activity 和 JNI 封装类,app/src/main/jnicpp目录下是 C/C++ 源码,libsjniLibs下则放着编译好的libntag21x.so。这个 So 库一般按 ABI 拆成armeabi-v7aarm64-v8ax86三个目录,如果你只在真机上跑,x86可以删掉,但模拟器调式时需要保留。

关键文件里,NfcActivity.java负责处理 NFC 意图和权限,Ntag21xManager.java封装了各页读写的业务逻辑,nfc_ntag21x.c实现了命令帧的拼装与 CRC。还有一个容易被忽略的ndkBuildCMakeLists.txt。如果工程是早期的jni目录,它会用ndk-build;如果是在cpp目录,通常用CMake。两者都行,但我们在 Android Studio 新版本里更推荐 CMake,因为增量编译和调试都更方便。

3.2 System.loadLibrary 与 JNI 函数映射:一个 native 方法怎么落到 C

先看 Java 侧的声明:

public class Ntag21xJni { static { System.loadLibrary("ntag21x"); // 加载 libntag21x.so } public static native int Ntag21x_ReadPage(int handle, byte[] pageData); }

C 侧的实现要遵循 JNI 命名规范:Java_包名_类名_方法名。如果包名是com.example.ntag,类名是Ntag21xJni,那么函数名就是Java_com_example_ntag_Ntag21xJni_Ntag21x_1ReadPage。这里的_1是 JNI 对 Java 方法名中下划线的转义。很多初学者在这里栽跟头,看到 UnsatisfiedLinkError 却找不到原因,其实只要用javah生成头文件就能避免。

System.loadLibrary("ntag21x")会在应用的 native library 搜索路径里找libntag21x.so。在 Android Studio 里,默认搜索路径是jniLibs/abi/libntag21x.so,或者由 CMake 构建产物自动打包进 APK。如果你把 .so 放在libs目录,必须在build.gradlesourceSets里指定:

android { sourceSets { main { jniLibs.srcDirs = ['libs'] } } }

不加这行,系统会报Couldn't load ntag21x。用 CMake 构建的话,则需要把 .so 的输出路径指到jniLibs,或者在app/src/main/cpp下直接编译源码,最终仍然会被打包到 APK 的 lib 目录。

3.3 Android Studio 集成 NDK 时的常见坑

在 Android Studio 里打开这个示例工程时,如果之前没装过 NDK,IDE 会提示Failed to find NDK。不要急着点下载,先确认项目是否真的需要从源码编译 C 代码。如果只是引用预编译 .so,只需要在local.propertiesANDROID_NDK_HOME里配置一个匹配的 NDK 版本即可。如果项目包含jni目录,并且build.gradle里有externalNativeBuild { ndkBuild { path 'src/main/jni/Android.mk' } },那就必须安装 NDK 和 CMake。

这里的常见问题是 ABI 不匹配。现在的手机普遍是arm64-v8a,而一些旧 So 库只有armeabi-v7a。系统在安装 APK 时会自动选择兼容的 ABI,不会出错。但如果你的 So 库同时缺失两个目录,就会在运行时抛UnsatisfiedLinkError。我在接入这种老示例时,通常会检查jniLibs里的文件目录,再用Build.SUPPORTED_ABIS输出设备支持的列表,对比一下。

android { defaultConfig { ndk { abiFilters 'armeabi-v7a', 'arm64-v8a' } } }

abiFilters的作用是只打包指定 ABI,避免把 x86 也塞进 APK 导致包体膨胀。不过要注意,如果你在模拟器上调试,x86是不能随便删的,除非你用的是 ARM 架构的模拟器镜像。

4. 核心读写流程:从 Tag 到 Ntag21x 命令

4.1 建立连接:通过 NfcA 实例与标签完成握手

先用NfcA.get(tag)拿到技术实例,然后调用connect()connect()不是简单地建立一个 Socket,它会执行 ISO 14443A 的防碰撞和选择流程,成功后transceive才能正常收发命令。示例中一般把connect()放在子线程,因为 NFC 栈对超时很敏感,主线程上做几十毫秒的 I/O 同样会触发NetworkOnMainThreadException这类保护,更严重的是会让系统 NFC 服务觉得当前进程无响应。

NfcA nfcA = NfcA.get(tag); if (nfcA == null) { return; } nfcA.connect(); byte[] cmd = new byte[]{ (byte)0x30, 0x04 }; // READ 页 0x04 byte[] resp = nfcA.transceive(cmd); nfcA.close();

这段代码的0x30是 Ntag21x 的读取命令码,第二个字节是页地址。页 0x04 开始是用户内存的第一页,前 4 字节是序列号的一部分。但要注意,transceive返回的数据前面还有一个长度字节,这是 NFC 栈自动添加的,实际的有效载荷要去掉第一个字节。我第一次调试时直接拿返回数组转十六进制,结果多出了一个0x10,浪费了半天时间。

4.2 读取用户内存区:多页读取与数据解析

对于 Ntag21x,单次最多读取 4 页,也就是 16 字节。要读 Ntag216 的全部 888 字节用户空间,需要循环发 222 次READ命令。示例 So 库通常会提供一个大块读取函数,内部用循环或 burst 模式处理。但模拟题里更常见的是单页读:让 Java 层传入页号和输出 Buffer,C 层负责构造命令和处理 ACK。

推荐实现一个公共工具方法,专门处理“读 16 字节”的完整事务:

public byte[] readPages(NfcA nfcA, int startPage) throws IOException { byte[] cmd = new byte[]{ (byte)0x30, (byte)startPage }; byte[] resp = nfcA.transceive(cmd); if (resp == null || resp.length < 17) { throw new IOException("response length invalid: " + (resp == null ? "null" : resp.length)); } byte[] data = new byte[16]; System.arraycopy(resp, 1, data, 0, 16); return data; }

第 0 个字节是协议层状态位,不是标签数据。resp.length < 17的检查很重要,因为 Ntag21x 在非法页地址时会返回 NAK(0x00 或 0x04),长度只有 1 到 2 字节。如果你不做长度保护,ArrayIndexOutOfBoundsException就会在你调试到一半时突然冒出来。读取时还要注意,Ntag213 只有页 0x04 到 0x43 共 64 页用户空间,Ntag216 则有 236 页,读越界会直接 NAK。

4.3 写入操:WRITE 命令、ACK 与容错

写入 Ntag21x 用户页用WRITE命令,命令码是0xA2。每条命令带一个页地址和 4 字节数据。写入后标签会返回 ACK(0x0A),失败则返回 NAK。ACK 的长度是 1,NAK 的长度也是 1,但值不同。所以判成功不能只看长度,还要看内容。

public boolean writePage(NfcA nfcA, int page, byte[] data) throws IOException { if (data.length != 4) { throw new IllegalArgumentException("Ntag21x write requires 4 bytes per page"); } byte[] cmd = new byte[]{ (byte)0xA2, (byte)page, data[0], data[1], data[2], data[3] }; byte[] resp = nfcA.transceive(cmd); return resp != null && resp.length == 1 && (resp[0] & 0xFF) == 0x0A; }

这里有个工程细节:Ntag21x 的写操作不是原子的,当页地址在配置区(如页 0xE8 之后)时,可能会因长度字段校验失败而拒绝。更头疼的是,如果上一次写入的 NAK 处理不当,后续连续写入会全部失败。我的建议是在写完后追加一次读回验证:读到数据与写入数据逐字节比较,相等才认为成功。示例 So 库一般没有自动验证,你在上层业务里加一段 3 次重试逻辑会实用很多。

5. 实战:封装 Ntag21xManager 并集成到 Android 项目

5.1 设计一个可复用的 Ntag21xManager

把 native 读写函数和 Java 层 NfcA 连接逻辑组合起来,封装成Ntag21xManager,可以避免业务代码与协议细节耦合。这个类应该持有当前NfcA实例,并且把读页、写页、连页封装成同步方法。

public class Ntag21xManager { private NfcA nfcA; public boolean connect(Tag tag) { try { nfcA = NfcA.get(tag); nfcA.connect(); return true; } catch (IOException e) { return false; } } public byte[] readPage(int page) throws IOException { return Ntag21xJni.Ntag21x_ReadPage(nfcA, page); } public boolean writePage(int page, byte[] data) throws IOException { return Ntag21xJni.Ntag21x_WritePage(nfcA, page, data); } public void close() { try { if (nfcA != null) nfcA.close(); } catch (IOException ignored) { } } }

在 Java 层调 So 库函数时,nfcA参数在这种情况下其实并不直接传给 C,而是通过Ntag21x_ReadPage内部调用 JNI 的NfcA句柄。示例源码里Ntag21x_ReadPage的第二个参数是nfcA对象本身,C 层通过GetObjectField取出mHandle。这种设计可以让你在 Java 层不感知底层句柄,但代价是每次调用都要重新解析 JNI 对象,性能略差。更优的方案是在connect时就把句柄转成 long 存起来,之后只传 long。

5.2 前台调度、权限与 Android 版兼容

NFC 权限只需要在 Manifest 里声明android.permission.NFC,但这个权限是普通权限,不需要运行时动态申请。真正影响体验的是前台调度:如果不调用enableForegroundDispatch,扫码后系统可能会弹出一个选择器,让用户选择用哪个应用打开,这对工具型 App 来说不可接受。

配置项内容说明
intent filterandroid.nfc.tech.NfcA只响应 NfcA 技术
PendingIntent flagFLAG_MUTABLEAndroid 12+ 必需
回调入口onNewIntent(Intent)获取并处理 tag
页读写线程Executors.newSingleThreadExecutor避免阻塞 UI

onResume里启用前台调度,onPause里禁用。如果不按这个生命周期走,Activity 退到后台时仍然会抢占 NFC 事件,导致系统原生“快速标签”工具无法工作。Android 13 及以上没有额外行为变化,但如果你适配到 Android 14,需要确认PendingIntent的可见性声明,否则可能崩溃。

5.3 错误处理:TagLostException 与 So 库加载异常

NFC 通信和网络不同,不会给你优雅的 RST 响应,更多的是TagLostException——标签被移走、信号弱、或者被另一个 NFC 应用抢占。这个异常必须在transceive调用点捕获,不能让它冲到 UI 层。

try { byte[] data = manager.readPage(0x05); } catch (TagLostException e) { // 重试或提示用户重新贴卡 retryCount++; if (retryCount > 3) { showMessage("请重新贴近标签"); } } catch (IOException e) { // 关闭连接并重新初始化 manager.close(); }

另一个典型异常是UnsatisfiedLinkError,通常发生在 So 库与系统 ABI 不匹配时。遇到这个错误,先用adb shell getprop ro.product.cpu.abi查看设备 ABI,再检查 APK 内lib/arm64-v8a是否存在该 .so。示例源码里的 So 库如果只提供了 32 位版本,就必须在abiFilters里把arm64-v8a排除掉,或者重新编译 64 位版本。

6. 验证读写结果与降低 NFC 延迟的实用技巧

6.1 用日志和第三方 TagInfo 应用交叉验证

拿到读出的数据后,先用十六进制打印,再用支持 Ntag21x 的第三方 App(如 NXP TagInfo)扫同一标签,比对页 0x04 到 0x07 的内容。如果两者一致,说明协议解析正确;如果不同,优先检查是否把协议层长度字节误当成数据。在 Logcat 里输出时,用HexDump或简单循环拼接都可,但记得去掉应变删的resp[0]。我常用一行:Log.d("TAG", "page=" + page + " hex=" + bytesToHex(data)),把 page 和 hex 放一起,方便在长日志里检索。

6.2 降低读写延迟:复用连接与调整超时

NFCtransceive的默认超时在部分机型上比较长,标签信号不稳定时,一次失败会卡住 200ms 以上。可以在connect()后调用nfcA.setTimeout(100),把超时缩到 100ms。这样快速扫码场景下,即使标签未贴稳也能很快抛出异常并进入重试,而不是长时间卡死。连接本身不建议每次都关,连续读写时保持同一个NfcA连接,可以减少重新防碰撞的时间。等整个页面操作完成后再close()

6.3 避免在主线程做 NFC 事务,注意页地址边界

最后提醒两个高频坑:第一,transceive必须放在子线程,否则会触发NetworkOnMainThreadException或让系统 NFC 进程崩溃。用协程或线程池都行,但一定要确保同一个标签的连接在同一线程内操作,跨线程传递NfcA对象会导致内部句柄失效。第二,写页时对0x000x03的厂商区没有写权限,某些型号的配置区(如页 0xE8)需要先解锁才能写入。我看到很多人在调试时把页 0x00 当作用户数据入口,写上就报 NAK,其实只要从页 0x04 开始规划数据布局,问题就解决了。

验证读写是否成功,最终标准是读回一致。对于 Ntag215,推荐把产品序列号写在页 0x10 ~ 0x13,把状态标志放在页 0x14,每次写完立即读回并校验。这个操作顺序能让你在开发阶段就区分出“数据没写进去”和“读错页”两种问题,比盲调 So 库命令高效得多。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 14:20:36

iptables防火墙完全解读:从Netfilter原理到NAT端口转发实战

1. 先搞清楚iptables管的是哪一段网络路径 先说一个很多人问过我的问题&#xff1a;iptables到底是个防火墙&#xff0c;还是一个命令&#xff1f;严格来说&#xff0c;iptables是用户态的管理工具&#xff0c;真正干活的是Linux内核里的Netfilter框架。你输入的每一条iptables…

作者头像 李华
网站建设 2026/9/13 14:13:09

Sa-Token 踢人下线详解:强制注销、踢人下线与顶人下线的原理与实践

Sa-Token 踢人下线详解&#xff1a;强制注销、踢人下线与顶人下线的原理与实践 【免费下载链接】Sa-Token ✨ 开源、免费、一站式 Java 权限认证框架&#xff0c;让鉴权变得简单、优雅&#xff01;—— 登录认证、权限认证、分布式 Session 会话、微服务网关鉴权、SSO 单点登录…

作者头像 李华