安卓APP通过JNI调用ATSHA204A加密芯片实战指南
1. 项目概述与核心需求解析在安卓应用开发领域尤其是涉及物联网、金融支付、版权保护等高安全要求的场景单纯依靠软件层面的加密算法已经不足以应对日益复杂的攻击手段。硬件加密芯片如ATSHA204A以其物理隔离、密钥不可读取等特性成为构建安全防线的关键一环。我最近在基于瑞芯微RK3568平台开发一款工业物联网应用时就深度集成了这颗芯片。很多开发者初次接触硬件加密往往卡在如何让安卓APP与这颗小小的芯片“对话”上。本文将结合这次实战拆解从环境搭建、JNI接口开发到最终封装成库的完整流程手把手带你打通安卓APP调用加密芯片的任督二脉。核心需求很明确我们需要在安卓APP中实现对ATSHA204A加密芯片的读写操作例如读取其唯一的序列号USID、对指定存储区Page进行数据读写并且最终需要将核心的C操作代码编译成动态库.so进行保护避免密钥和算法逻辑泄露。这要求我们不仅要熟悉安卓应用开发还要掌握JNIJava Native Interface和NDKNative Development Kit开发以及对硬件I2C/SPI通信有一定了解。2. 开发环境准备与工程创建2.1 Android Studio与NDK配置工欲善其事必先利其器。我们的主战场是Android Studio但要让Java代码能调用操作硬件的C代码必须依赖NDK。很多新手在这一步容易迷糊其实NDK就是一个工具集允许你在Android应用中使用C和C代码。具体配置步骤打开Android Studio进入File - Settings(Windows/Linux) 或Android Studio - Preferences(macOS)。在设置窗口中找到Appearance Behavior - System Settings - Android SDK。切换到SDK Tools标签页。在这里你会看到一长串可安装的工具。找到NDK (Side by side)和CMake勾选它们。CMake是一个跨平台的编译构建工具Android Studio用它来编译你的C/C代码。建议选择相对稳定的版本而非最新版以避免兼容性问题。我这次使用的是NDK 25.x 和 CMake 3.22.1。点击“Apply”进行安装。注意国内网络环境下载可能较慢可以配置Android SDK的代理或使用国内镜像源。安装成功后NDK的默认路径通常在Android SDK目录下的ndk文件夹里。2.2 创建Native C项目配置好环境后我们从一个最贴合需求的工程模板开始能省去大量基础配置工作。点击File - New - New Project...。在新建项目模板选择中找到并选择Native C模板。这个模板会自动为你配置好基本的JNI和CMake环境非常方便。点击“Next”后像创建普通安卓项目一样填写项目名如CryptoApp、包名、保存位置和开发语言Kotlin或Java。这里我选择Java。再次点击“Next”进入Customize C Support页面。这里是关键C Standard选择Toolchain Default通常是最稳妥的它会使用NDK默认的C库。如果你需要C11/14/17的特定特性也可以下拉选择。对于加密芯片操作Toolchain Default完全足够。Exceptions Support 和 Runtime Type Information Support这两项通常保持默认勾选即可。它们分别支持C异常处理和RTTI运行时类型识别除非你明确知道你的代码不需要否则建议勾选。点击“Finish”Android Studio会自动创建项目并构建。项目创建完成后你会看到一个标准的安卓项目结构但多了一个cpp目录。这就是我们编写与加密芯片通信的C代码的地方。模板还自动生成了一个示例JNI函数stringFromJNI在MainActivity中调用它会在屏幕上显示“Hello from C”。这是一个很好的验证证明你的JNI环境已经跑通了。3. JNI原理与硬件操作层设计3.1 为什么必须用JNI和C安卓应用主要用Java/Kotlin编写运行在Java虚拟机上。而加密芯片如ATSHA204A是通过I2C或SPI这类硬件总线与主控芯片RK3568通信的。Java虚拟机无法直接操作硬件寄存器。这时就需要“翻译官”——JNI。JNIJava Native Interface是Java平台的一个特性它定义了Java代码与本地Native代码通常是C/C相互调用的规则。我们的策略是Java层上层负责UI交互、业务逻辑。例如用户点击“读取”按钮Java层捕获这个事件。JNI层中间层提供Java可调用的本地方法声明。它像是一个协议接口。Native C层底层实际实现硬件操作。这里包含具体的I2C/SPI读写时序、ATSHA204A命令封装、数据加密解密等。RK3568的Linux内核已经提供了标准的I2C设备驱动我们的C代码通常通过操作/dev/i2c-*设备文件或使用内核提供的I2C用户态API如ioctl来进行通信。流程概括为用户点击按钮 - Java调用JNI方法 - JNI调用C函数 - C函数通过Linux系统调用操作I2C - 读写加密芯片 - 结果按原路返回至UI显示。3.2 硬件抽象层设计思路在C层不建议把所有的逻辑都堆在一个巨大的函数里。良好的设计是分层提高代码可读性和可维护性。我通常采用三层结构硬件接口层HAL这一层只关心最底层的I2C读写。它提供一个简单的函数如i2c_write_read(int dev_fd, uint8_t slave_addr, uint8_t *write_buf, int write_len, uint8_t *read_buf, int read_len)。它的职责就是打开I2C设备文件组装I2C消息调用ioctl完成一次传输。这一层需要对RK3568的I2C控制器编号如I2C-1和ATSHA204A的从机地址7位地址例如0x64有明确的配置。芯片驱动层Driver这一层封装ATSHA204A芯片的具体命令。ATSHA204A有一套自己的命令集例如唤醒Wake、休眠Sleep、读Read、写Write、计算MAC等。这一层实现诸如atsha204a_wakeup()atsha204a_read_page(uint8_t page_id, uint8_t *buffer)这样的函数。每个函数内部会按照芯片手册的时序要求调用硬件接口层的函数发送特定的命令字节和数据。JNI接口层JNI Wrapper这一层是给Java调用的。它接收来自Java的JNI调用参数是JNIEnv, jobject等将参数转换为C原生类型如jstring转为char*然后调用芯片驱动层的相应函数获取结果后再转换回Java类型如将uint8_t数组转为jbyteArray最后返回。这样的分层使得如果未来更换加密芯片型号只需要替换芯片驱动层如果更换硬件平台I2C控制器不同也只需修改硬件接口层其他部分代码可以最大程度复用。4. 核心开发流程详解4.1 定义Java Native接口类首先我们在Java层定义一个类来声明所有需要调用的本地方法。这个类不包含实现实现是在C层。package com.yourcompany.cryptoapp; public class ATSHA204A { // 加载最终的动态库名字在CMakeLists.txt中定义如 native-lib static { System.loadLibrary(native-lib); } // 声明本地方法 /** * 初始化加密芯片通信 * return 成功返回0失败返回负值错误码 */ public native int init(); /** * 获取芯片唯一序列号USID * return 16字节的USID数组 */ public native byte[] getUsid(); /** * 读取指定页Page的内容 * param pageId 页ID (0-15) * return 32字节的页数据 */ public native byte[] readPage(int pageId); /** * 更新指定页Page的内容 * param pageId 页ID * param data 要写入的32字节数据 * return 成功返回0失败返回负值错误码 */ public native int updatePage(int pageId, byte[] data); /** * 关闭芯片通信释放资源 */ public native void deinit(); }4.2 生成JNI头文件Java的native方法声明好后我们需要生成对应的C/C函数原型。这是通过JDK自带的javah旧版或javac -h新版命令完成的。打开Android Studio的终端Terminal导航到你的Java源文件根目录通常是app/src/main/java。执行命令javac -h ./jni com/yourcompany/cryptoapp/ATSHA204A.java这条命令做了两件事编译Java类并根据native方法生成对应的C头文件。-h ./jni指定头文件输出目录为当前目录下的jni文件夹需要先创建你也可以输出到cpp目录。执行后会在jni目录下生成一个名为com_yourcompany_cryptoapp_ATSHA204A.h的头文件。这个文件包含了所有你需要实现的C函数原型函数名很长格式如Java_com_yourcompany_cryptoapp_ATSHA204A_init。这个函数名必须一字不差地复制到你的C源文件中进行实现。4.3 实现C Native层代码现在我们打开项目自动生成的native-lib.cpp文件清空模板内容开始实现。首先包含必要的头文件和生成的头文件#include jni.h #include string #include unistd.h #include fcntl.h #include sys/ioctl.h #include linux/i2c-dev.h #include com_yourcompany_cryptoapp_ATSHA204A.h // 生成的头文件 // 你的硬件接口层和芯片驱动层函数声明 namespace crypto { int i2c_init(const char* device, int slave_addr); int i2c_transfer(uint8_t *write_buf, int write_len, uint8_t *read_buf, int read_len); void i2c_deinit(); int atsha204a_init(); int atsha204a_wakeup(); int atsha204a_read_page(uint8_t page_id, uint8_t *buffer); int atsha204a_write_page(uint8_t page_id, const uint8_t *data); int atsha204a_get_usid(uint8_t *usid); }然后实现JNI函数。这里以init和getUsid为例extern C JNIEXPORT jint JNICALL Java_com_yourcompany_cryptoapp_ATSHA204A_init(JNIEnv* env, jobject /* this */) { // 调用底层的初始化函数 int ret crypto::atsha204a_init(); return (jint)ret; } extern C JNIEXPORT jbyteArray JNICALL Java_com_yourcompany_cryptoapp_ATSHA204A_getUsid(JNIEnv* env, jobject /* this */) { uint8_t usid[16] {0}; // ATSHA204A USID通常是16字节 int ret crypto::atsha204a_get_usid(usid); if (ret ! 0) { // 处理错误可以抛出Java异常 return nullptr; } // 将C数组转换为Java的byte数组 jbyteArray result env-NewByteArray(16); env-SetByteArrayRegion(result, 0, 16, reinterpret_castjbyte*(usid)); return result; }关键点解析extern C防止C编译器对函数名进行修饰mangling确保JVM能根据生成的头文件中的名字找到这个函数。JNIEXPORT和JNICALLJNI约定的宏用于指定函数调用约定和导出属性。JNIEnv* env指向JNI环境的指针是所有JNI函数的一等公民。通过它你才能调用如NewByteArray、SetByteArrayRegion这样的函数来在Java和Native之间传递数据。jobject代表调用这个native方法的Java对象实例即ATSHA204A类的实例。如果native方法是静态的static native则这里是jclass。类型转换JNI有一套自己的基本类型jint,jbyte,jbyteArray等需要与C/C类型int,uint8_t,uint8_t[]进行正确转换。env-SetByteArrayRegion是拷贝数据到Java数组的常用方法。4.4 配置CMakeLists.txtCMakeLists.txt文件告诉CMake如何编译你的C代码。模板生成的通常已经够用但我们需要根据实际情况调整。cmake_minimum_required(VERSION 3.22.1) # 指定CMake最低版本 # 定义项目名称和动态库名称 project(cryptoapp-native) # 添加你的C源文件 add_library( # 设置库的名字即最终生成的 libnative-lib.so native-lib # 设置库的类型SHARED 代表动态库 SHARED # 提供源文件的相对路径 native-lib.cpp # 可以继续添加其他.cpp文件比如 hal_i2c.cpp, atsha204a_driver.cpp hal_i2c.cpp atsha204a_driver.cpp ) # 查找log库方便在C中使用 __android_log_print 输出日志到Logcat find_library( log-lib log ) # 链接你的库所需要的其他库 target_link_libraries( # 指定目标库 native-lib # 链接log库 ${log-lib} )4.5 实现UI逻辑MainActivity最后在MainActivity中我们创建ATSHA204A类的实例并调用其native方法。public class MainActivity extends AppCompatActivity { private ATSHA204A cryptoChip; private TextView usidTextView; private EditText pageDataEditText; private Spinner pageSpinner; private int selectedPageId 0; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); cryptoChip new ATSHA204A(); usidTextView findViewById(R.id.tv_usid); pageDataEditText findViewById(R.id.et_page_data); pageSpinner findViewById(R.id.spinner_page); // 1. 初始化芯片 int ret cryptoChip.init(); if (ret ! 0) { Toast.makeText(this, 加密芯片初始化失败: ret, Toast.LENGTH_LONG).show(); return; } // 2. 读取并显示USID new Thread(() - { final byte[] usid cryptoChip.getUsid(); runOnUiThread(() - { if (usid ! null) { usidTextView.setText(bytesToHex(usid)); // 将字节数组转为十六进制字符串显示 } else { usidTextView.setText(读取失败); } }); }).start(); // 3. 配置页选择Spinner ArrayAdapterCharSequence adapter ArrayAdapter.createFromResource(this, R.array.page_array, android.R.layout.simple_spinner_item); adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); pageSpinner.setAdapter(adapter); pageSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { Override public void onItemSelected(AdapterView? parent, View view, int position, long id) { selectedPageId position; // 假设数组顺序与Page ID对应 } Override public void onNothingSelected(AdapterView? parent) { } }); // 4. 绑定读取按钮事件 Button btnRead findViewById(R.id.btn_read); btnRead.setOnClickListener(v - { new Thread(() - { final byte[] pageData cryptoChip.readPage(selectedPageId); runOnUiThread(() - { if (pageData ! null) { pageDataEditText.setText(bytesToHex(pageData)); } else { pageDataEditText.setText(读取失败); } }); }).start(); }); // 5. 绑定更新按钮事件注意写操作通常需要授权这里仅为示例流程 Button btnUpdate findViewById(R.id.btn_update); btnUpdate.setOnClickListener(v - { String input pageDataEditText.getText().toString(); // 这里需要将十六进制字符串转换回byte数组并做长度校验ATSHA204A Page为32字节 // byte[] data hexStringToByteArray(input); // int ret cryptoChip.updatePage(selectedPageId, data); // ... 处理结果 }); } Override protected void onDestroy() { super.onDestroy(); cryptoChip.deinit(); // 释放资源 } // 辅助方法字节数组转十六进制字符串 private static String bytesToHex(byte[] bytes) { StringBuilder sb new StringBuilder(); for (byte b : bytes) { sb.append(String.format(%02X , b)); } return sb.toString().trim(); } }5. 项目重构保护核心源码为动态库当所有功能开发调试完成后出于商业保护和代码安全考虑我们必须将包含密钥和核心算法的C代码隐藏起来。目标是将cpp目录下的源码编译成.so动态库然后移除源码让项目只依赖这个库文件。5.1 编译生成多架构动态库默认情况下Android Studio的Debug构建可能只生成当前模拟器或真机架构如arm64-v8a的.so文件。我们需要生成适配主流CPU架构的库。修改app/build.gradle文件中的defaultConfig块下的ndk配置android { ... defaultConfig { ... ndk { // 指定需要生成的ABI应用二进制接口版本 abiFilters armeabi-v7a, arm64-v8a, x86, x86_64 } } }点击Build - Make Project或Build - Build Bundle(s) / APK(s) - Build APK(s)。构建成功后你可以在app/build/intermediates/cmake/debug/obj/Debug版或release目录下找到各个ABI子目录如arm64-v8a里面就有libnative-lib.so文件。5.2 转换为纯JNI库项目这是关键一步我们将从“源码项目”转变为“库项目”。创建jniLibs目录在app/src/main/目录上右键选择New - Directory创建一个名为jniLibs的文件夹。这是Android Studio默认查找预编译动态库的目录。拷贝.so文件将上一步编译生成的各个ABI目录如arm64-v8a,armeabi-v7a整个文件夹复制到app/src/main/jniLibs/目录下。最终结构应该是app/src/main/jniLibs/ ├── arm64-v8a/ │ └── libnative-lib.so ├── armeabi-v7a/ │ └── libnative-lib.so └── ...删除cpp源码目录在项目视图中右键点击app/src/main/cpp目录选择Delete将其彻底移除。同时也可以删除.cxx等中间文件目录。修改构建配置打开app/build.gradle文件找到android块下的externalNativeBuild配置将其注释掉或删除。因为我们现在不再需要CMake从源码编译了。android { ... // 注释掉或删除以下整个 externalNativeBuild 块 // externalNativeBuild { // cmake { // path src/main/cpp/CMakeLists.txt // version 3.22.1 // } // } }确保Java代码正确加载库检查你的Java类如ATSHA204A中的静态代码块确保加载的库名与.so文件名匹配去掉lib前缀和.so后缀。static { System.loadLibrary(native-lib); // 对应 libnative-lib.so }清理并重新构建点击Build - Clean Project然后Build - Rebuild Project。如果一切顺利项目将成功构建并且你的APK中只包含预编译的.so库而不包含任何敏感的C源码。6. 实战避坑指南与高级技巧6.1 常见编译与运行时问题UnsatisfiedLinkError现象APP启动或调用native方法时崩溃日志报错java.lang.UnsatisfiedLinkError: No implementation found for...。排查库名不匹配Java中System.loadLibrary(“xxx”)的xxx必须与.so文件名去掉lib和.so完全一致且大小写敏感。ABI不匹配你的设备CPU架构如arm64-v8a在jniLibs下没有对应的.so文件。确保abiFilters包含了目标设备的架构并且.so文件已正确放入对应子目录。函数签名错误JNI函数名必须与javac -h生成的头文件中的名字完全一致包括包名、类名、方法名。一个空格或大小写错误都会导致链接失败。使用nm -D libxxx.so命令可以查看动态库中导出的符号核对函数名。I2C通信失败现象init()函数返回失败或读写数据全为0xFF/0x00。排查权限问题在Android上访问/dev/i2c-*设备文件需要root权限。在非root设备上这是行不通的。这是嵌入式Linux APP开发与普通安卓APP最大的不同。解决方案有系统级应用将你的应用预置到系统镜像中并申请android.permission.HARDWARE_TEST等系统权限需要系统签名。内核配置让内核驱动为你创建一个有权限访问的用户态接口如通过sysfs或自定义字符设备。使用HAL层这是Android标准做法为硬件编写HALHardware Abstraction Layer模块和JNI接口应用通过HIDL或AIDL与服务通信。这涉及系统开发更为复杂。从机地址错误确认ATSHA204A的I2C从机地址。通常需要根据芯片数据手册和硬件原理图如ADDR引脚的上拉下拉来确定是7位地址还是8位地址并注意读写位。时序问题ATSHA204A有严格的唤醒时序Wake-up pulse。在开始通信前必须先发送一个满足时长要求的低电平信号通过控制I2C的SCL线实现然后再进行正常的I2C读写。很多驱动失败是因为漏了这一步。6.2 性能与稳定性优化I2C句柄缓存不要在每次JNI调用时都打开 (open) 和关闭 (close) I2C设备文件。这非常低效。应该在init()函数中打开一次将文件描述符 (int fd) 保存在一个全局或静态变量中在后续的读写操作中复用最后在deinit()中关闭。错误处理与日志在C层使用__android_log_print(ANDROID_LOG_DEBUG, “TAG”, “message”)输出详细日志到Logcat这对于调试底层通信问题至关重要。同时设计清晰的错误码体系将底层I2C错误、芯片命令返回错误等逐层传递到Java层便于问题定位。线程安全如果你的APP可能从多个线程调用native方法而底层硬件操作如I2C不是线程安全的就需要在C层加锁如使用pthread_mutex_t来序列化访问。功耗考虑长时间不操作加密芯片时应调用其休眠Sleep命令以降低功耗。可以在deinit()中执行或者在APP进入后台时通过JNI调用休眠函数。6.3 安全增强建议密钥绝不硬编码即使代码编译成了.so库简单的逆向工程仍然可能从二进制文件中提取出字符串常量。绝对不要将加密密钥、密码等敏感信息以明文形式写在代码中。可以考虑运行时动态生成通过白盒密码学或密钥派生函数在运行时生成。分段存储与组合将密钥拆分成多个部分存储在代码、文件、甚至芯片的其他安全区域使用时再组合。使用芯片的安全存储ATSHA204A本身就有安全存储区可以将最核心的密钥存放在芯片内部使用时通过计算MAC等方式进行认证而不暴露密钥本身。代码混淆对Java代码进行ProGuard或R8混淆增加逆向难度。对于C代码编译时可以开启-O2/-O3优化并去除调试符号 (-s)使反编译后的汇编代码更难阅读。完整性校验在APP启动时可以校验自身.so库的哈希值防止被篡改。也可以利用ATSHA204A计算关键代码或数据的MAC进行运行时完整性验证。从源码开发到封装成库整个流程走下来最关键的是理解JNI的桥梁作用和Android的权限模型。在嵌入式安卓如RK3568上开发硬件相关应用更像是在做Linux系统开发需要开发者具备更深度的系统知识。希望这篇基于实战的总结能帮你绕过我踩过的那些坑顺利实现安卓APP与加密芯片的安全对话。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2630877.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!