简介:面向Android底层开发者的示例代码,演示如何通过JNI调用C/C++实现Direct I/O(O_DIRECT)与加密TF卡直接通信,适合需要优化数据库、媒体处理或安全存储场景中数据传输效率的读者,也是学习NDK与底层设备交互的实用参考。资源仅包含2个文件,分别为C++与C源码:C++文件负责封装JNI方法供Java层调用,C文件实现O_DIRECT打开、读写、对齐校验及错误回退等底层逻辑,压缩包大小仅2KB,代码精简可直接阅读。已有555人学习下载,建议具备Android NDK和Linux I/O基础的开发者使用。内容展示了如何安全地绕过内核缓冲区直接与加密TF卡交换数据,同时处理缓存一致性、数据对齐、加密逻辑落在用户空间时的注意事项,以及Direct IO失败时回退到标准I/O模式的容错设计,可作为基础框架快速移植到实际项目中,帮助理解底层存储优化的核心细节。
1. 为什么要在安卓里绕过 page cache:这份 JNI 直写源码包解决什么
同一张加密 T 卡,普通 FileOutputStream 连写一万次不掉数据,换 O_DIRECT 直写反而第一步就撞上一排 EINVAL。这份 android direct IO 源码包解决的,正是「加密 T 卡上的 directio 读写」这一整条链路:用 JNI 把 open、read、write、close 下沉到 C 层,以 O_DIRECT 标志绕过内核 page cache,再叠加 AES 块加密完成扇区级加解密。适合做数据采集、掉电保护日志与安全存储的安卓开发者:新手能照着编出 so 并跑通真机,熟手可以直接复用它的对齐策略和加密布局做二次改造。
2. 项目骨架与 JNI 桥接设计:Java 层与 C 层的握手方式
2.1 目录结构与模块划分
拿到压缩包先别急着开 IDE,我习惯先把「Java 侧做什么、C 侧做什么」按目录分一遍。这套工程是典型的三段式:Java 层只留最薄的入口类,真正干活的 open/read/write 全在 cpp 目录里,中间用一层 JNI 桥接隔开,两边尽量不互相掺和。
android-direct-io/ ├── app/ │ ├── src/main/ │ │ ├── java/com/example/directio/ │ │ │ ├── DirectIO.java // native 方法声明与 so 加载 │ │ │ ├── TCardManager.java // T 卡挂载点探测、密钥装载 │ │ │ └── BlockDevice.java // 扇区偏移换算的薄封装 │ │ ├── cpp/ │ │ │ ├── CMakeLists.txt // so 的构建脚本 │ │ │ ├── jni_bridge.c // JNI_OnLoad 动态注册与类型转换 │ │ │ ├── direct_io.c // O_DIRECT 打开、对齐读写核心 │ │ │ └── aes_xts.c // XTS 模式扇区加解密 │ │ └── AndroidManifest.xml │ └── build.gradle └── README.mdJava 层只做三件事:System.loadLibrary 加载 so、声明 native 方法、准备好 T 卡路径和密钥。密钥不硬编码进代码,而是由 TCardManager 从独立的小文件读出来再传给 C 层。direct_io.c 和 aes_xts.c 刻意拆成两个文件,是为了换加密算法时完全不动 I/O 路径,后面讲二次开发时会回到这个拆分。
2.2 native 方法声明与 JNI 动态注册
Java 侧入口非常薄,四个 native 方法对应 open/read/write/close 四个系统调用:
public class DirectIO { static { System.loadLibrary("directio"); } // 打开设备节点,返回 native 侧维护的 fd;失败返回负数错误码 public native long nativeOpen(String path, int flags); // 从 offset 处读 len 字节到 ByteBuffer;len 必须是扇区整数倍 public native int nativeRead(long fd, ByteBuffer buf, int len, long offset); // 写同理,写盘前会先加密 public native int nativeWrite(long fd, ByteBuffer buf, int len, long offset); // 关闭 fd 并释放 native 侧对齐缓冲 public native void nativeClose(long fd); }C 侧没有用默认的 Java_com_example_directio_DirectIO_nativeOpen 静态命名,而是在 JNI_OnLoad 里做动态注册:
static JNINativeMethod gMethods[] = { {"nativeOpen", "(Ljava/lang/String;I)J", (void *) native_open}, {"nativeRead", "(JLjava/nio/ByteBuffer;IJ)I", (void *) native_read}, {"nativeWrite", "(JLjava/nio/ByteBuffer;IJ)I", (void *) native_write}, {"nativeClose", "(J)V", (void *) native_close}, }; JNIEXPORT jint JNICALL JNI_OnLoad(JNIEnv *env, void *reserved) { jclass clazz = (*env)->FindClass(env, "com/example/directio/DirectIO"); if (clazz == NULL) { return JNI_ERR; // 类找不到,加载直接失败,方便排查 } int count = sizeof(gMethods) / sizeof(gMethods[0]); if ((*env)->RegisterNatives(env, clazz, gMethods, count) < 0) { return JNI_ERR; // 签名串写错会在这里暴露 } return JNI_VERSION_1_6; }这段有两个细节值得说。签名串 (Ljava/lang/String;I)J 表示「参数是 String 和 int,返回值是 long」,后面读写方法的签名里 J 是 long(fd),Ljava/nio/ByteBuffer; 是缓冲对象,一个字符都不能错,错了 RegisterNatives 直接失败。另一个是动态注册的好处:Java 类名或方法名重构时,不用去改 C 函数的符号名,只要保证 gMethods 表里的名字和签名同步更新就行;而且 so 一加载就能发现签名不匹配,而不是等到方法真正被调用的那一刻才崩。
2.3 Direct Buffer 与跨层内存传递
读写缓冲统一用 ByteBuffer.allocateDirect 在 Java 侧创建,C 层用 GetDirectBufferAddress 拿内存地址:
void *addr = (*env)->GetDirectBufferAddress(env, buf); if (addr == NULL) { return -EINVAL; // 传进来的不是 DirectBuffer,不偷偷拷贝,直接报错 }但这里有一个特别容易被忽略的点:allocateDirect 只保证内存是 direct 的,不保证地址按 512 或 4096 对齐。O_DIRECT 的硬性要求是「缓冲区地址、读写偏移、读写长度」三者都必须是逻辑块大小的整数倍,只要有一个不满足,pread/pwrite 就返回 EINVAL。所以工程里真正的读写路径,是在 native 侧再分配一块对齐内存,做一次 memcpy 中转:
void *aligned = NULL; size_t aligned_len = (len + 4095) & ~4095UL; // 向上取整到 4096 if (posix_memalign(&aligned, 4096, aligned_len) != 0) { return -ENOMEM; } memcpy(aligned, addr, len); ssize_t n = pread(fd, aligned, aligned_len, offset);逻辑是:先按 4096 对齐分配一块临时缓冲,把 Java 传上来的数据拷进去,再用这块缓冲去做 O_DIRECT 读写。参数上 aligned_len 向上取整保证了长度条件,posix_memalign 保证了地址条件,offset 由上层调用方保证是扇区整数倍——三个条件齐了,O_DIRECT 才会放行。多一次 memcpy 换来的对齐保证,在块设备场景下比偶发的 EINVAL 重试划算得多。
3. 核心实现拆解:从 O_DIRECT 打开到加密写盘的完整链路
3.1 open 阶段:O_DIRECT 标志与挂载点的坑
C 层打开设备的代码非常短:
int fd = open(path, O_RDWR | O_DIRECT | O_CLOEXEC); if (fd < 0) { return -errno; // EINVAL:文件系统不支持 O_DIRECT,最常见 }O_DIRECT 是 Linux 通用标志,但落到 Android 移动设备上,能不能用完全取决于目标文件系统。工程里默认不碰 /storage/XXXX-XXXX 那种 FUSE 挂载点,而是直接用 /dev/block/mmcblk1p1 这类块设备节点,或者单独格式化成 ext4/f2fs 的私有分区。vfat/exFAT 对 O_DIRECT 的支持非常差,open 直接就 EINVAL,这属于文件系统层面的限制,代码里绕不过去。
判断一张 T 卡当前能不能直读,先看两样东西:
adb shell "cat /sys/block/mmcblk1/queue/logical_block_size" # 逻辑块大小 adb shell "cat /proc/self/mountinfo | grep mmcblk1" # 挂载方式和路径logical_block_size 决定后面对齐要用 512 还是 4096;mountinfo 里能看到分区是以 vfat 挂还是 ext4 挂。如果看到 fuseblk 或者 vfat,趁早换分区方案,别跟 EINVAL 较劲——这是我在这类项目上踩过最深的坑之一。
3.2 对齐内存分配与扇区长度换算
对齐值不写死,而是从内核 sysfs 动态读:
long block_size = 512; // 默认值,拿到真实值会被覆盖 FILE *fp = fopen("/sys/block/mmcblk1/queue/logical_block_size", "r"); if (fp != NULL) { fscanf(fp, "%ld", &block_size); fclose(fp); } void *buf = NULL; if (posix_memalign(&buf, block_size, block_size * sector_count) != 0) { return -ENOMEM; }posix_memalign 的第一个参数是对齐值,这里直接用 logical_block_size,保证内存地址和设备的物理块对齐。sector_count 是本次要读写的扇区数,所以分配的总长度一定是扇区大小的整数倍。注意 posix_memalign 要求对齐值是 2 的幂且是 sizeof(void*) 的倍数,512 和 4096 都满足。另外 pread 的偏移参数也必须是块大小的整数倍,工程里所有 offset 都先换算成扇区号、再乘回字节偏移,从源头杜绝奇数偏移——只对齐内存忘了对齐偏移,同样会换来一排 EINVAL。
3.3 加密读写流程:XTS 模式与扇区独立性
读写路径的核心,是把「加密」和「直写」拼在一起:
// 读路径:先按扇区读密文,再在内存里解密 ssize_t n = pread(fd, cipher_buf, block_size, byte_offset); if (n == block_size) { aes_xts_decrypt(key, iv, cipher_buf, plain_buf, block_size); memcpy(out_addr, plain_buf, block_size); } else { return (n < 0) ? -errno : -EIO; // 短读直接按错误处理 } // 写路径:先加密,再直写盘 aes_xts_encrypt(key, iv, plain_buf, cipher_buf, block_size); ssize_t m = pwrite(fd, cipher_buf, block_size, byte_offset); if (m != block_size) { return (m < 0) ? -errno : -EIO; // 短写要暴露给上层,不能吞 }加密选的是 XTS 模式而不是常见的 CBC,原因很实际:CBC 的 IV 是链式依赖的,前一个密文块是后一个的输入,这在顺序读写的文件场景没问题,但 O_DIRECT 是随机扇区访问,每次写一个扇区都要重算整条链,代价完全不可接受。XTS 的 tweak 由扇区索引生成,每个扇区独立加解密,天然匹配块设备的随机读写模型。工程里 IV 取自扇区号,类似 dm-crypt 的做法,这样同一个逻辑扇区在不同偏移位置加密结果不同,能防密文挪移攻击。
参数说明:key 是 256 位主密钥,iv 在工程里由两个 64 位 tweak 组成并在扇区级派生,aes_xts_encrypt/decrypt 的最后一个参数是扇区字节数,必须是 block_size 的整数倍。这部分逻辑封在 aes_xts.c 里,和 direct_io.c 完全解耦,后面改造时可以直接替换。
3.4 短写循环与 EINTR 处理
块设备直写时,pwrite 返回的字节数不一定等于请求长度,所以不能只调一次就完事:
ssize_t total = 0; while (total < len) { ssize_t r = pwrite(fd, buf + total, len - total, offset + total); if (r < 0) { if (errno == EINTR) { continue; // 被信号打断,重试本次 } return -errno; // 真正的 I/O 错误 } total += r; }这个循环的逻辑是处理两种情况:一是短写,比如坏块或底层驱动只写了一半,total 累加后继续写剩余部分;二是 EINTR,系统调用被信号打断时返回 -1 但数据没损坏,直接重试即可。很多第一次写 O_DIRECT 的人会漏掉 EINTR 分支,导致压测时偶发失败却找不到原因——这种「玄学」最后多半都是没处理信号中断。
4. 避坑排查:加密 T 卡直写的五个典型翻车点
4.1 open 返回 EINVAL:先查文件系统,别先怀疑代码
现象是本机调试一切正常,换一张 T 卡,open 立刻返回 -1,errno 是 EINVAL。第一反应通常会去检查 flags 拼写,但真正的原因往往是这张卡的分区是 vfat/exFAT,或者系统把它挂成了 FUSE。O_DIRECT 对这类文件系统支持很差,open 阶段就直接拒绝。解决方式是按上面的命令先查 /proc/self/mountinfo 和 logical_block_size,确认目标分区是 ext4/f2fs 或裸块设备;如果必须用现有 vfat 分区,唯一靠谱的办法是改用块设备节点绕过文件系统层。
4.2 pread 返回 -1:长度不是扇区整数倍
现象是读写 1000、2000 这种非对齐长度时稳定复现 EINVAL,改成 4096 就好。原因很简单,O_DIRECT 的约束是地址、偏移、长度三者全是块大小整数倍,长度不满足就直接拒绝,不像普通 read 那样按实际可读字节数返回。解决是把请求长度统一向上取整到 block_size 的整数倍再发起 I/O,读出来后在 Java 层截取需要的部分;写路径同理,不足一个扇区的数据先补齐再加密落盘。
4.3 Java 堆数组地址不可控:对齐校验不能省
现象是同一个测试用例,用 ByteBuffer.allocateDirect 通过,换成普通 byte[] 传上去就偶发 EINVAL。原因是 GetByteArrayElements 返回的指针指向 Java 堆内存,GC 和 JVM 实现都可能影响它的对齐特性,没有任何保证。解决是在 native 侧拿到地址后先做一次(uintptr_t)addr % block_size校验,不等于 0 就走「对齐缓冲 + memcpy」的兜底路径。这道校验看着多余,实际是这工程里防止「换一台手机就翻车」的关键防线。
4.4 掉电后文件尾部出现半个扇区
现象是写完 1MB 数据后突然断电,重新上电读回,尾部多了半截乱码或少了半扇区内容。原因是加密按整扇区做,但最后一次 I/O 如果长度不满一个扇区,填充策略和加密逻辑没对上,密文块的边界就错位了。解决是把写入单位固定为整扇区,不足部分在 Java 层先补零再调用 nativeWrite;另外写完关键数据后补一次 fsync,避免掉电丢刷盘。简短流程:补零 → 加密整扇区 → pwrite → fsync,四步缺一不可。
4.5 个别机型拿不到 T 卡路径
现象是 /storage/XXXX-XXXX 在个别机型上不存在,或者存在但打不开。原因是厂商定制了 vold 存储策略,路径随机型变化。解决是别硬编码路径,启动时去解析 /proc/self/mountinfo,找到包含卷序列号或 mmcblk 的挂载点;更省事的做法是直接遍历 /dev/block/by-name/ 按分区名定位。把这段逻辑放进 TCardManager 里做一次并缓存结果,整个 native 层就不用关心路径怎么来。
5. 编译部署与真机验证:从 CMake 到 so 落盘跑通
5.1 CMake 与 NDK 构建脚本
工程在 cpp 目录下放了一个极简 CMakeLists.txt,把三个 C 文件编成一个动态库:
cmake_minimum_required(VERSION 3.18) project(directio C) add_library(directio SHARED jni_bridge.c direct_io.c aes_xts.c ) find_library(log-lib log) target_link_libraries(directio ${log-lib}) target_compile_options(directio PRIVATE -O2 -Wall -fno-stack-protector)这里注意两点:project 指定了 C 而不是 C++,三个文件都是纯 C,没必要引入 cpp 工具链;-O2 和 -Wall 是常规配置,-fno-stack-protector 是某些低端板子上的性能妥协,如果不需要可以去掉。aes_xts.c 如果换成 OpenSSL 实现,就在 target_link_libraries 里追加 crypto 库,其他不用动。
5.2 Gradle 集成与 ABI 过滤
app/build.gradle 里只需配 externalNativeBuild 和 abiFilters:
android { defaultConfig { externalNativeBuild { cmake { cppFlags "" } } ndk { abiFilters 'arm64-v8a', 'armeabi-v7a' } } externalNativeBuild { cmake { path "src/main/cpp/CMakeLists.txt" } } }abiFilters 只保留 arm64-v8a 和 armeabi-v7a,去掉 x86 可以显著缩短构建时间;如果是模拟器调试,临时把 x86 加回来即可。编译命令就是常规的./gradlew assembleDebug,产物在 app/build/intermediates/cxx/Debug/ 下按 ABI 分目录,用 adb push 推到 /data/local/tmp,或者直接打进 APK 里跑。块设备节点读写需要 root 或对应 SELinux 权限,测试阶段一般直接用 userdebug 版本系统。
5.3 真机验证:对齐、加解密、掉电三条线
装到真机上之后,最小验证代码可以这么写:
DirectIO io = new DirectIO(); long fd = io.nativeOpen("/dev/block/mmcblk1p1", 0); if (fd < 0) { // 先看这里,大概率是权限或文件系统不支持 return; } ByteBuffer buf = ByteBuffer.allocateDirect(4096); buf.putInt(0, 0x12345678); // 写入一个可识别的魔数 int rc = io.nativeWrite(fd, buf, 4096, 0); io.nativeClose(fd);这段代码验证的是最小闭环:打开、写一个扇区、关闭。rc 返回 4096 说明整扇区直写成功,返回负数就把 errno 打出来对照上面的避坑清单。我一般按三条线继续压。第一条是对齐:用 nativeRead 读一个扇区,再故意传非对齐长度,确认返回 EINVAL 且错误码正确,证明 O_DIRECT 真的在起作用。第二条是数据一致性:先 nativeWrite 写入一段已知内容,再 nativeRead 读回,比对 md5;再写一段明文到普通分区,确认落盘的是密文而不是明文,这一步直接验证加密链路没被绕过。
第三条是掉电,这个只能在开发板上做:写满 4MB 数据后在写入中途断电,重新上电读出全文,确认最后一个扇区要么是完整的旧数据、要么是完整的新数据,绝不能出现半扇区。顺带验证 fsync 后断电必然读到新数据。这三条跑完,这个工程才算真正在其机上通了。检验缓存穿透有一个偷懒但有效的办法:写完后用adb shell cat /proc/meminfo | grep Cached观察页缓存变化,再对比普通 write 时的增长,O_DIRECT 路径下缓存基本不动。
提示:调试阶段优先用开发板而不是量产机,掉电测试和块设备节点权限,量产机上都不好搞。
6. 二次开发改造:把私有 T 卡实现抽成通用 Direct I/O 工具
6.1 把块大小从写死改为自适应
当前代码里 4096 散落在内存分配、长度取整、加密调用三处,改造第一步是收敛成一个从 logical_block_size 读出的变量,通过 nativeOpen 的返回值或一个单独的 nativeGetBlockSize 暴露给 Java 层。上层拿到真实块大小后,所有 ByteBuffer 分配都按它来,避免在 512 字节物理块的卡上浪费一倍内存。
6.2 加密层做成可插拔的 ops 表
aes_xts.c 目前是编译期绑定的,二次开发最实用的改法是把加解密抽成函数指针结构:
typedef struct crypto_ops { int (*encrypt)(const uint8_t *in, uint8_t *out, size_t len, uint64_t sector_id); int (*decrypt)(const uint8_t *in, uint8_t *out, size_t len, uint64_t sector_id); } crypto_ops; static const crypto_ops g_xts_ops = { aes_xts_encrypt, aes_xts_decrypt };读写路径里所有 aes_xts_encrypt 调用替换成 g_xts_ops->encrypt,换算法时实现一个 sm4_ops 或透传 ops,一行赋值就切过去。sector_id 参数保留不动,因为任何块加密都需要扇区索引派生 tweak。从那以后我每次接块设备直读的活,都会先花十分钟确认三件事:分区文件系统是什么、逻辑块多大、挂载路径从哪来——这三件事确认完,后面基本不会翻车。希望帮到你。
本文还有配套的精品资源,点击获取