简介:面向Android平台需要高效安全数据读写的开发者,这套源码示例演示了如何通过JNI技术调用C/C++代码,以Direct IO直接输入输出模式与加密TF卡通信,适合具备JNI基础、从事底层存储或性能优化的中级以上开发者。传统输入输出依赖内核缓冲区,而Direct IO使用open函数的O_DIRECT标志绕过缓存,减少数据拷贝,满足数据库、媒体处理、嵌入式存储等低延迟场景的严格要求。压缩包仅2个文件、约2KB,其中C++文件负责封装JNI接口并提供native方法,C文件负责TF卡设备打开、读写与数据对齐等底层细节,结构紧凑,便于移植。目前已有555人学习。通过学习可掌握O_DIRECT启用前提、缓冲区对齐及缓存一致性管理,理解加密TF卡须在用户空间自行集成加解密的原因,并参考错误回退与CPU占用权衡思路,加快Android底层存储开发进程。
1. Android 设备上做 T 卡全盘备份,读到的却是旧数据
某次接到一个需求:对一台嵌入式设备的加密 T 卡做整卡镜像备份,要求备份文件和 T 卡上实际落盘的数据严格一致。设备跑的是 Android,存储走的是 TF 卡,量产时 T 卡做过分区加密。一开始用 FileInputStream 读块设备节点,结果备份镜像在二次校验时总对不上,排查了很久才定位到根因——Android 默认文件 IO 走了 page cache,应用层读到的是几十毫秒前内核缓存里的旧内容,而不是盘片上真实的数据。这个项目解的就是这个问题:通过安卓 JNI 实现 direct IO(O_DIRECT 方式)的块设备读写,绕过页缓存,让每次读写都直接命中设备盘片或加密层。包里带一套完整的 JNI 工程:Java 接口、native 层 C 实现、对齐缓冲区管理,以及加密 T 卡场景的接入 Demo。适合做 Android 底层存储、安全设备日志采集、整机备份还原的开发者,你要是只写应用层 UI,大概率用不上;但只要你的数据和盘片必须严格一致,这套代码就是一个能直接落地的底子。
2. 底层机制:O_DIRECT 到底关掉了什么,以及 JNI 怎么把 Java 世界送进内核
2.1 open() 里的 O_DIRECT:一份关于绕过缓存的直白解释
先看 Linux 的 open 系统调用,O_DIRECT 这个 flag 的作用。官方文档的说法叫“减少缓存影响”,翻译成人话:不带 O_DIRECT 时,read() 会先查内核 page cache,命中就直接从缓存拷贝给用户空间,没命中则缺页从磁盘读进缓存再返回。带上 O_DIRECT 之后,read() 绕开 page cache,直接从块设备或文件系统底层把数据拷贝到调用方指定的缓冲区,读写完成后页面也不留在缓存里。这套机制对 T 卡读写有一个很实际的收益:普通回写缓存下,write() 把数据放进内存页就返回了,真正落盘要等后台回写线程或 fsync;而 direct IO 的 write 要等数据交给设备层才返回。对加密 T 卡这种“断电后盘上数据必须可恢复”的场景,这一点决定了生产可用性。
// 典型的 O_DIRECT 打开方式 int fd = open("/dev/block/mmcblk1p1", O_RDWR | O_DIRECT, 0644); if (fd < 0) { // errno 最常见的是 EINVAL,通常是对齐或文件系统不支持 perror("open with O_DIRECT failed"); }逻辑说明:O_DIRECT 标志要求 open() 打开的路径支持直接 IO。块设备节点一般都能支持,但挂载在存储栈上的某些文件系统并不支持,所以把 O_DIRECT 用在普通文件路径上时经常拿到 EINVAL。参数说明:O_RDWR 表示可读可写,O_DIRECT 在 Android bionic 的 fcntl.h 里已有定义,不需要自己拼宏;0644 是权限位,块设备节点通常用不到它,实际访问控制靠 SELinux 策略。
在大文件吞吐层面,direct IO 并不总能比缓存 IO “更快”。缓存 IO 命中后速度接近纯内存拷贝,direct IO 少了这层羊毛可薅。但 direct IO 胜在可预期性——每次读的延迟都是稳定的设备 IO 延迟,不受缓存命中率影响。对块设备镜像备份来说,这个可预期性比吞吐峰值更值得关注,也是我最后选它而不是继续调大 FileInputStream 缓冲区的原因。
2.2 Android 存储栈对 direct IO 的隐藏限制
Android 的存储栈比桌面 Linux 多了一层。用户访问外置 T 卡,常规路径是 vold 进程挂载文件系统,再通过 FUSE(sdcardfs)给 App 提供权限受限的访问。FUSE 对 direct IO 的支持参差不齐:很多机型上对 FUSE 路径开 O_DIRECT 会直接报 EINVAL,部分内核版本即使接受了 flag,底层的 FUSE 请求也未必真正绕过缓存。所以要在 Android 上对 T 卡做 direct IO,普遍做法是绕过文件系统层,直接打开块设备路径。块设备路径怎么定位?先看挂载信息,再按主次设备号找 /dev/block 下的节点。
adb shell cat /proc/mounts # 找到 T 卡的挂载行,例如 /dev/block/mmcblk1p1 /storage/XXXX vfat rw,... adb shell ls -l /dev/block/mmcblk1p1 # brw------- 1 root root 179, 24 ...逻辑说明:拿到设备路径后,App 进程必须有对应节点的访问权限,否则 open 返回 EACCES。在 userdebug 工程机上可以临时调整策略验证,但正式产品里必须由有权限的守护进程代开,这属于 Android 系统权限模型的硬约束,绕不开。参数说明:mmcblk1p1 里的 1 代表外置 SD/TF 卡控制器编号,p1 是第一个分区,不同设备编号不同,要按实际挂载信息解析,不能写死。
2.3 JNI 是绕过 Java 层约束的通道
Java 层的 FileOutputStream 走 libcore 静态绑定,RandomAccessFile 虽然支持文件描述符操作,但接口里没有暴露 O_DIRECT 标志位。唯一干净的通道就是 JNI:Java 声明 native 方法,C 实现在 native 层 open、read、write,全程不经过 Java 的 IO 类库。JNI 同时解决了缓冲区的对齐问题。Java 的 byte[] 数组分配在 Art 堆上,起始地址往往不是 512 或 4096 对齐的,直接把这个数组的指针交给 O_DIRECT 的 read,轻则 EINVAL,重则读到错位数据。native 层用 posix_memalign 单独分一块对齐内存,再 memcpy 回 Java 数组,这是这个项目能够稳定跑起来的前提。
JNI 层还要处理两个 Java 侧的别扭点。一是 Java 没有 unsigned 类型,native 返回的指针被当成 long 时若最高位为 1,Java 侧会得出负数导致误判;二是 native 崩溃时 Java 层拿到的 SIGSEGV 堆栈信息有限,所以工程里把关键错误路径的日志都打在 native 层,出问题先看 logcat 里的 DirectIO-JNI 标签。这两点虽小,但在真机调试时能省下大量时间。
3. 核心代码拆解:一个 direct IO 的读写流程在 JNI 里长什么样
3.1 Java 侧接口:用 long 指针而不是 byte[]
工程里 Java 层有一个 DirectIO.java,对外提供块级读写能力。设计上所有 native 方法都传递 long 类型的指针地址,避免 JNI 层反复 GetByteArrayElements 的开销,也让缓冲区的生命周期管理完全收口在 native 侧。方法声明结构如下:
public class DirectIO { static { System.loadLibrary("directio"); } // 分配一块按 alignment 字节对齐的内存,返回内存地址 public static native long allocBuffer(int size, int alignment); // 释放 allocBuffer 分配出来的缓冲区 public static native void freeBuffer(long ptr); // 打开设备或文件,flags 透传 open() 的系统调用标志 public static native long open(String path, int flags, int mode); // 从指定偏移读 size 字节到 native 缓冲区,返回实际读到的字节数 public static native int read(long fd, long buf, int size, long offset); // 从 native 缓冲区写 size 字节到指定偏移 public static native int write(long fd, long buf, int size, long offset); // 落盘,等同 fsync(2) public static native void sync(long fd); // 关闭文件描述符 public static native void close(long fd); }逻辑说明:allocBuffer 返回 long 地址,Java 层不直接接触缓冲区内容;read/write 都带 offset 参数,因为这套接口主要被用于 T 卡分区的块级备份恢复,块设备没有文件游标的概念,用 pread/pwrite 按偏移访问更合理,也不会改变文件描述符的当前位置。参数说明:alignment 传 512 或 4096 即可。size 建议是 alignment 的整数倍。read 返回的不是期望字节数时,要么设备没那么多数据,要么对齐参数给错了。fd 透传 native 层 open() 的返回值,Java 层不持有真实的文件描述符,避免被垃圾回收器误处理关闭。
3.2 native 侧实现:从 posix_memalign 到 pread/pwrite
Native 层用了动态注册,函数名不遵循 Java_ 前缀的强制规则,实现起来更干净。核心函数有三个值得看:allocBuffer、open、read。先看内存分配:
#include <jni.h> #include <fcntl.h> #include <unistd.h> #include <stdlib.h> #include <string.h> #include <android/log.h> #define TAG "DirectIO-JNI" #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) // 对应动态注册表里的 native_allocBuffer static jlong native_allocBuffer(JNIEnv *env, jclass clazz, jint size, jint alignment) { void *ptr = NULL; // alignment 必须是 2 的幂,且是 sizeof(void *) 的倍数 int ret = posix_memalign(&ptr, alignment, size); if (ret != 0) { LOGE("posix_memalign failed, ret=%d", ret); return 0L; } // 顺手清零,避免 O_DIRECT 短读时把历史内容带回 Java 层 memset(ptr, 0, size); return (jlong)(uintptr_t)ptr; }逻辑说明:posix_memalign 返回的是已对齐的内存指针,把指针当作整型塞进 long 还给 Java。清零是防御性写法,因为 O_DIRECT 读不满 size 时,缓冲区剩余空间会带着旧内容,后续拷贝或比对容易出玄学问题。参数说明:alignment 有讲究,常见块设备逻辑块大小是 512 字节,但 eMMC 5.1、UFS 这类设备按 4K 对齐更稳。如果设备并发 IO 多,我一般直接把 alignment 设成 4096,通用性比 512 好。size 的取值看单次 IO 量,备份 T 卡时单次 64KB 的 buffer 效果不错,太大对内核内存压力大,容易触发 block 层的合并且造成延迟抖动。
再看打开设备的部分:
static jlong native_open(JNIEnv *env, jclass clazz, jstring path, jint flags, jint mode) { const char *path_cstr = (*env)->GetStringUTFChars(env, path, NULL); if (path_cstr == NULL) return -1L; // 默认带上 O_DIRECT,调用方也可以通过 flags 自行控制 int fd = open(path_cstr, flags | O_DIRECT, mode); (*env)->ReleaseStringUTFChars(env, path, path_cstr); if (fd < 0) { LOGE("open failed, errno=%d (%s)", errno, strerror(errno)); return -1L; } return (jlong)fd; }逻辑说明:GetStringUTFChars 把 Java 的 String 转成 C 的 char 数组,用完必须 ReleaseStringUTFChars,否则本地引用会泄漏。open 的 flags 由 Java 透传,对 T 卡块设备节点常用的组合是 O_RDWR | O_DIRECT。打开失败时返回 -1L,Java 层判断负数即可。参数说明:mode 在打开块设备时通常传 0,因为设备节点权限由 SELinux 管控,open 函数的 mode 参数只对新建文件有意义。
接着是 read 的实现:
static jint native_read(JNIEnv *env, jclass clazz, jlong fd, jlong buf, jint size, jlong offset) { ssize_t n = pread((int)fd, (void *)(uintptr_t)buf, (size_t)size, (off_t)offset); if (n < 0) { LOGE("pread failed, errno=%d (%s)", errno, strerror(errno)); return -1; } // 返回实际读到的字节数,Java 层据此判断是否重试 return (jint)n; }逻辑说明:pread 相比 read 的差异在于它接受 offset 参数且不改变文件位置指针。块设备上文件位置没有实际意义,按 offset 访问才是块级操作的正确姿势。读回来的数据在 native buffer 里,Java 层要读明文时,需要另一个 JNI 方法 memcpy 回 byte[] 数组。参数说明:size 单次不要超过 2GB-1,否则 jint 返回值会溢出,实际工程里单次 64KB 到 1MB 足够。offset 必须按对齐要求传入,否则 pread 返回 EINVAL,这是块设备 direct IO 最常见的错误。
3.3 对齐问题:为什么 JNI 层要自己管缓冲区
对齐是 direct IO 能不能跑起来的分水岭。O_DIRECT 要求三对齐:缓冲区地址、传输字节数、读写偏移,三者都必须是设备逻辑块大小的整数倍。用 Java 数组做 buffer 翻车就是因为这个——Java 堆对象地址从 Art 堆来,堆的基址和对象头的布局让对齐变成不可控的事。工程里做了个封装,把“分配对齐内存 → pread → 拷贝到 Java 数组 → 释放”合成一个 native 方法,省掉 Java 侧自己管 buffer 的负担:
static jbyteArray native_readBytes(JNIEnv *env, jclass clazz, jlong fd, jint size, jlong offset, jint alignment) { void *buf = NULL; if (posix_memalign(&buf, alignment, size) != 0) return NULL; ssize_t n = pread((int)fd, buf, size, offset); if (n < 0) { free(buf); return NULL; } jbyteArray arr = (*env)->NewByteArray(env, (jsize)n); (*env)->SetByteArrayRegion(env, arr, 0, (jsize)n, (jbyte *)buf); free(buf); return arr; }逻辑说明:这个封装把对齐分配、pread、拷贝、释放四个步骤收拢在一个 native 方法里,Java 侧拿到的就是干净的 byte[]。注意 SetByteArrayRegion 之后数据从 native 内存拷贝到了 Java 堆,这一层拷贝省不掉,但相比 Java IO 字节流经过的 BufferedInputStream 等多层缓冲,整体路径短得多。参数说明:alignment 按 4096 传,size 小于对齐值时部分设备会直接报 EINVAL,所以这个封装内部没有做大小纠偏,要求在调用方保证 size 是对齐值的整数倍。
3.4 加密 T 卡的接入方式
直接 IO 和 T 卡加密怎么结合?典型配置是 T 卡分区被 dm-crypt 或文件级加密保护,挂载之后是一层加密文件系统,但块设备节点本身存的是密文。这时候 direct IO 在块设备层读写,读出来的是加密后的数据块;要拿明文,要么 native 层自己加解密,要么打开解密映射之后的设备节点,也就是 /dev/block/dm-X。这个包的 Demo 给的是后一条路:先查设备的 dm 映射顺序,拿到解密后的块设备节点,再对该节点做 O_DIRECT 读写。整体链路就是“App JNI → dm-crypt 解密层 → 物理 T 卡”,页缓存照常被 O_DIRECT 绕过。
adb shell ls -l /dev/block/dm-* adb shell dmsetup table逻辑说明:dmsetup table 能看到设备映射表的 target 类型和参数,target 类型是 crypt 的就是加密层。需要读明文时打开对应的 /dev/block/dm-X 即可。注意线程安全:多个线程同时 pread 同一个 fd 是安全的,pread 不会改变文件位置;但一个设备节点建议每个进程只 open 一次,多次打开会重复引用计数,卸载设备时可能出现 busy 状态。
4. 编译与运行:从 NDK 交叉编译到真机效果确认
4.1 工程结构和 CMakeLists
源码包目录结构很清晰,顺着目录能找到所有关键代码:
directio/ ├── app/ │ ├── src/main/ │ │ ├── java/com/example/directio/DirectIO.java │ │ └── cpp/ # JNI C 代码 │ ├── CMakeLists.txt │ └── build.gradle ├── tools/ │ ├── bench.sh # 读写基准测试脚本 │ └── dump_block.sh # 块设备镜像导出工具 └── README.mdCMakeLists.txt 里三个关键配置:
cmake_minimum_required(VERSION 3.22.1) project(directio) add_library(directio SHARED src/main/cpp/directio_jni.c ) target_link_libraries(directio android log ) target_compile_options(directio PRIVATE -O2 -Wall)逻辑说明:链接 android 和 log 是为了使用 __android_log_print 打点,正式发布时可去掉 log 依赖再编一版。编译选项 -O2 是对 native 层代码的基础优化,-Wall 用来在编译阶段提前暴露类型不匹配等问题。参数说明:Android Studio 里 build.gradle 的 externalNativeBuild 配置指向这个 CMakeLists,abiFilters 建议只保留 arm64-v8a,因为 direct IO 本身对 32 位进程没有额外收益,而且 64 位下 long 正好容纳指针地址。
4.2 JNI 方法注册:静态命名与 RegisterNatives
上面核心代码段里函数名是 native_ 开头,靠 JNI_OnLoad 动态注册。动态注册的好处是不用管 C 符号的 JNI 命名规则,Java 侧改方法名时 C 函数名可以不动,缺点是签名描述符必须和 Java 声明一字不差,写错不会编译失败,只在运行期报 NoSuchMethodError。注册表长这样:
static const JNINativeMethod methods[] = { {"allocBuffer", "(II)J", (void *)native_allocBuffer}, {"freeBuffer", "(J)V", (void *)native_freeBuffer}, {"open", "(Ljava/lang/String;II)J", (void *)native_open}, {"read", "(JJIJ)I", (void *)native_read}, {"write", "(JJIJ)I", (void *)native_write}, {"sync", "(J)V", (void *)native_sync}, {"close", "(J)V", (void *)native_close}, }; JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) { JNIEnv *env = NULL; if ((*vm)->GetEnv(vm, (void **)&env, JNI_VERSION_1_6) != JNI_OK) { return JNI_ERR; } jclass clazz = (*env)->FindClass(env, "com/example/directio/DirectIO"); if (clazz == NULL) return JNI_ERR; (*env)->RegisterNatives(env, clazz, methods, sizeof(methods) / sizeof(methods[0])); (*env)->DeleteLocalRef(env, clazz); return JNI_VERSION_1_6; }逻辑说明:动态注册的签名描述符是另一套语法:J 表示 long,I 表示 int,V 表示 void,Ljava/lang/String; 表示 String 类型,后面的 [B 表示 byte[]。方法和签名的对应关系必须和 Java 侧完全一致。参数说明:GetEnv 是 JNI 规范写法,如果返回 JNI_OK 说明当前线程已绑定 JavaVM;JNI_VERSION_1_6 是兼容性最好的版本要求,Android 全版本都支持。
静态注册和动态注册可以做个对比:
| 对比项 | 静态命名 | 动态 RegisterNatives |
|---|---|---|
| 符号命名 | 必须按 Java_包名_类名_方法名格式 | 随便起名 |
| 签名维护 | 方法名和参数由命名隐式约束 | 签名描述符逐个手写 |
| 错误时机 | 链接期缺失符号直接编译失败 | 运行期 FindClass 失败才暴露 |
| 混淆兼容 | 包名和方法名混淆后找不到符号 | RegisterNatives 在 load 时执行不受混淆影响 |
实际工程建议用动态注册,尤其当多个 native 库配合混淆构建时,静态命名对混淆后的符号解析非常脆弱。
4.3 用基准测试确认 direct IO 生效
跑一个不依赖外部工具的基准,思路是比较普通 IO 与 O_DIRECT 在重复读同一偏移时的耗时差异。普通 IO 第二次读会命中 page cache,速度显著快于第一次;O_DIRECT 则每次都是真实设备访问,两次耗时基本持平。这种对比不需要专业工具,一台 adb 能连的真机就够了。
adb push app/build/outputs/apk/debug/app-debug.apk /data/local/tmp/ adb shell 'su -c mkdir -p /data/local/tmp/directiodemo' adb shell 'su -c cp /data/local/tmp/app-debug.apk /data/local/tmp/directiodemo/'更直接的验证方式是打开一个块设备节点,故意用 O_DIRECT 读一个长度不对齐的数据块,预期返回 EINVAL;再换成对齐的块,返回正常。能稳定复现这个现象,说明 O_DIRECT 真的被设备层接收了,而不是被内核静默忽略:
long buf = DirectIO.allocBuffer(4096, 4096); int n = DirectIO.read(fd, buf, 1024, 0); // 错误示范:size 不是 4096 的整数倍 // 预期返回 -1,errno=22 EINVAL这段故意写反的调用是一个很实用的自检方法。如果跑完返回值是 1024,说明设备或文件系统没有真正给 O_DIRECT 放行,要回头查 open 是否正确带上了 O_DIRECT 标志,以及中间层是否做了降级处理。
提示:自检时构造一个 size 为非对齐的读请求,返回 EINVAL 说明 O_DIRECT 链路真实生效。这是排查“开了等于没开”问题的标准动作。
5. 避坑记录:direct IO 最常见的五个翻车现场
这套 JNI 在 x86 模拟器、arm64 真机、不同内核版本上跑过一圈,能明显感觉到 direct IO 属于“能做但绝不省心”的机制。下面五个坑都是项目验证期间实际趟过的,按现象、原因、解决三行整理,方便直接对照。
5.1 open 直接报 EINVAL:文件系统不支持 O_DIRECT
现象:在 /storage/emulated/0 路径下创建一个测试文件,open("/storage/emulated/0/test.bin", O_RDWR | O_DIRECT) 在多数真机上直接返回 -1,errno 是 EINVAL。原因:FUSE 和 sdcardfs 对上层的 O_DIRECT 支持不完整,open 阶段就会拒绝;部分机型表面上接受了 flag,但底层 FUSE 请求没有真正穿透到存储设备。解决:不要对挂载点路径做 direct IO,要访问就打开底层块设备节点;如果业务上必须走文件系统,先确认文件系统类型和内核配置,再考虑用 O_DIRECT 的替代方案。
5.2 对齐条件没满足:open 成功但第一次 pread 就崩溃
现象:打开 /dev/block/mmcblk1p1 成功,但第一次 pread 返回 -1,errno=22。原因:设备逻辑块大小是 4096,而分配缓冲区时只按 512 对齐,size 或 offset 也不是 4096 的整数倍。open 阶段不会检查对齐,错误延后到了第一次读写才暴露。解决:先用命令确认设备逻辑块大小。
adb shell cat /sys/block/mmcblk1/queue/logical_block_size拿到结果后把 alignment、size、offset 全部按该值对齐。我一般写一个断言宏,在调用前校验指针地址和 size 取模结果,一秒钟就能发现对齐问题,不用等 pread 返回 EINVAL 再猜。
5.3 Java 数组直接当 buffer:地址不对齐加拷贝风暴
现象:用 GetByteArrayElements 拿到 byte[] 的指针后直接传给 pread,结果时好时坏,某些系统版本稳定 EINVAL,Release 之后偶尔崩溃。原因:Art 堆里的 byte 数组地址不受控制,即使 GetByteArrayElements 暂时固定了数组,也不能保证起始地址对齐;GC 压缩后对象移动还会让指针失效。解决:一律走 native 层 posix_memalign 加 memcpy 的两段式,不要省那一次拷贝。分配完 native buffer 后,对关键缓冲区做一次 mlock 锁定物理页,避免 IO 中途被换出。Android 虽然一般没有 swap,但这属于保底没坏处的防御习惯。
5.4 读不满:每次 pread 返回小于请求的字节数
现象:读加密 T 卡分区对应的块设备,请求读 64KB,返回经常只有 32KB 或更少,没有报错。原因:dm-crypt 映射层对单次 IO 有内部限制,设备自身的 multi-read 在某些固件下也会出现 short transfer。解决:把 read 包一层循环,累计已读字节,n 小于 0 时按 errno 决定重试或退出,n 等于 0 才认为到达末尾。这个循环逻辑应该放在 native 层,避免每次短读都跨 JNI 边界回到 Java 再发起下一次调用。
5.5 拿到块设备节点但 open 返回 EACCES
现象:已经通过 su 拿到 root shell,但 App 进程内 open("/dev/block/mmcblk1p1") 依然返回 EACCES。原因:Android 的 SELinux 策略对 App 域访问块设备节点做了明确限制,即使进程有 root 权限,安全上下文不对也无法访问。解决:先在工程机上 adb shell setenforce 0 临时验证是不是 SELinux 导致的;确认后,正式方案要么让 vold 这类有权限的系统进程打开 fd 通过 Binder 传给 App,要么给指定 APK 配置 SELinux 策略放行,而不是靠 root 在运行时改全局开关。
6. 二次开发:从直接读写到加密 IO 框架
6.1 改造点一:native 层加 AES-XTS 加解密
如果加密 T 卡的明文由文件系统层负责,而你想在文件系统之上再接一层自己的数据保护,可以在 read/write 中间塞入 AES-XTS。XTS 模式专为块存储设计,每个数据块用 tweak 区分,适合按块偏移独立加解密,读历史块时也能独立解出来。加解密的位置就放在 native 层 pread 之后、write 之前,对 Java 完全透明:
// 在 native_read 返回前对读出来的数据做解密 ssize_t n = pread(fd, buf, size, offset); if (n > 0 && cipher_enabled) { aes_xts_decrypt(buf, n, offset, key, tweak); // 按 offset 生成 tweak }逻辑说明:改这个位置时注意密钥不要硬编码进 so,优先从 Android Keystore 或 TEE 取出后通过 JNI 参数传入。参数说明:tweak 来自块号 offset,XTS 模式下每个 16 字节块的 tweak 是线性递增的,解密时直接拿偏移量计算即可。
6.2 改造点二:用 io_uring 替换 pread/pwrite
高版本 Android 内核已经带 io_uring,块设备 IO 用它做异步批量提交,比 pread 加线程池省掉大量系统调用开销。如果做的是高吞吐 T 卡备份引擎,这个替换值得投入:
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring); io_uring_prep_read(sqe, fd, buf, size, offset); io_uring_submit(&ring); // 等待 cqe 完成逻辑说明:io_uring 的优势是批量提交和完成事件收割,配合已有的对齐缓冲区可以做到零拷贝路径。改的时候保持对齐约束不变,O_DIRECT 的规则不会因为换 IO 引擎而放宽。
6.3 验证方法
改完第一件事:用包里 tools 下的 bench.sh 跑一组普通 IO、direct IO、加密 direct IO 的对比,看吞吐和 CPU 占用。第二件事是断电测试——写入后直接断电重启,再读盘验证数据完整。direct IO 的意义不在延迟低,而在数据同步性可靠,断电测试能把这个特性验证到位。
我在每一次改完这套 JNI 之后,都会强制走一遍冷启动加断电恢复加数据比对三件套,不通过就不把手里的二进制交出去。希望上面这些踩坑细节能帮你在接入 direct IO 时少走几趟弯路。
本文还有配套的精品资源,点击获取