使用 Fuse 和 java 17 编写一个简单的文件系统
目标是探索 Project Panama 的外部链接器功能并创建我们的简单文件系统。我们将使用 Java 17 和 FUSE 来做到这一点。我们将研究如何进行向上调用、向下调用和使用内存地址来创建我们的内存文件系统。
文件系统能做什么?
它将完成您对文件系统的期望的基础知识。我们可以挂载它、创建/读/写文件、创建目录和卸载它。重点是外部链接器功能。为了使实现易于理解,我们不会实现子目录。您只需要创建一个跟踪文件创建位置的 Java 类;如果您想添加该功能。
什么是 Fuse 和 Project Panama
FUSE(用户空间中的文件系统)允许您在实现其接口时创建用户空间文件系统。 FUSE 项目由两个组件组成:FUSE 内核模块和 libfuse 用户空间库。我们的实现将使用 libfuse 的高级 API。它提供了挂载文件系统、卸载文件系统、从内核读取请求以及发回响应的功能。
Project Panama 是 Java 语言改进的集合。该项目的目标是丰富和改进 Java 和本地(外部)接口之间的连接,这些接口通常由用 C 编写的应用程序使用。
巴拿马由以下 JEP(JDK 增强提案)组成:
- Foreign-Memory Access API JEP:JEP-370、JEP-383
- 外部链接器 API JEP:JEP-389
- 矢量 API JEP:JEP-338
我们将专注于 Foreign Linker API,因为它提供对本机代码的纯 Java 访问。使用外部链接器的另一个好处是它应该具有可比的性能或比 JNI 更好。
设置
在开始之前,请确保您在 Linux/Mac 系统上安装了 FUSE(如果您使用的是 Windows,则可以使用 WSL1 或 WSL2 来跟随或任何 Linux VM)。我使用 libfuse 3.10.5 作为示例。如果您使用的是旧版本或新版本,可能会有细微差别。跑步 ldconfig -p | grep libfuse
在终端中将显示安装的 Libfuse 版本。如果未安装 Libfuse,您将不会得到任何输出。
我们还需要 Jextract,它是一个从 C 头文件生成 Java 文件的工具,并且仅在 Panama 早期访问版本中可用。去 https://jdk.java.net/panama/ 并为您的系统下载最新版本并解压缩。我们只需要这个特定版本的 Java 来生成 Java 文件。我们要构建的项目可以使用任何 Java 17 GA 版本。
此外,在 ( https://github.com/libfuse/libfuse/releases ) 我们将使用它作为 Jextract 的输入。
设置为运行和编译应用程序。
由于外部链接器仍处于孵化阶段,我们必须添加一些参数来运行和编译代码。如果您也在使用 IntelliJ,则需要添加 --add-modules jdk.incubator.foreign
到设置里面的Java编译器选项。并添加 --enable-native-access=ALL-UNNAMED --add-modules jdk.incubator.foreign
到运行配置中的 VM 选项。
开始吧!
首先,我们将从 Libfuse 源代码生成 Java 文件。我们需要先设置我们的 Java 版本才能做到这一点。在终端内运行:
导出 JAVA_HOME={JAVA_DOWNLOAD_LOCATION}/jdk-71
导出 PATH=$JAVA_HOME/bin:$PATH
来测试一下 提取物
正在运行:
提取 -h
这应该向您显示所有可用的命令行选项:
-C<String> - 指定要传递给底层 Clang 解析器的参数
-我<String>- 指定包含文件路径
-l<String> - 指定加载生成的 API 时应链接的库(名称或完整绝对路径)
-d<String> - 指定放置生成文件的位置
-t<String>为生成的类指定目标包
--include-function - 要包含的函数名称
--include-macro - 要包含的常量宏的名称
--include-struct - 要包含的结构定义的名称
--include-typedef - 要包含的类型定义的名称
--include-union - 要包含的联合定义的名称
--include-var - 要包含的全局变量的名称
--source - 生成 java 源代码而不是类文件
使用 Jextract 创建 Java 类
一切都设置好后,我们可以从 FUSE 源创建 Java 文件。在撰写本文时,我找不到让 Jextract 包含 FUSE_USE_VERSION
宏。为了解决这个问题,我添加了 #define FUSE_USE_VERSION 35
到 libfuse-fuse-3.10.5/include/ 目录中 fuse.h 的顶部。
完成后,您可以填写“LIBFUSE_SOURCE_DOWNLOAD_LOCATION”、“LIBFUSE_SOURCE_DOWNLOAD_LOCATION”并运行命令生成 java 文件。
jextract -C "-D_FILE_OFFSET_BITS=64" --source -d generated/src -t org.linux -I {LIBFUSE_SOURCE_DOWNLOAD_LOCATION}/libfuse-fuse-3.10.5/include/ {LIBFUSE_SOURCE_DOWNLOAD_LOCATION}/libfuse-fuse-3.10.5/包含/fuse.h
-C "-D_FILE_OFFSET_BITS=64"
将参数传递给 c 语言解析器--source -d 生成/src
我们希望 Jextract 输出文件的位置。-t org.linux
生成的 java 文件将具有的类路径。-I {LIBFUSE_SOURCE_DOWNLOAD_LOCATION}/libfuse-fuse-3.10.5/include/
包含文件路径{LIBFUSE_SOURCE_DOWNLOAD_LOCATION}/libfuse-fuse-3.10.5/include/fuse.h
我们要使用的头文件
实施 FUSE
我们现在已经有了所有的建筑部件,让我们开始吧!正如我们之前看到的,FUSE 只是我们需要实现的一个接口。
它不像实现一个 Java 接口。它是通过调用一个 C 函数并向其传递一个包含指向 Java 方法的指针的结构(类似于 Java Records)来完成的。您可以在 fuse.h 文件中找到结构;它看起来像这样:
结构熔断器操作{
int (*getattr) (const char *, struct stat *, struct fuse_file_info *fi);
int (*readlink) (const char *, char *, size_t);
int (*mknod) (const char *, mode_t, dev_t);
int (*mkdir) (const char *, mode_t);
int (*unlink) (const char *);
int (*rmdir) (const char *);
int (*symlink) (const char *, const char *);
int (*rename) (const char *, const char *, unsigned int flags);
int (*link) (const char *, const char *);
int (*chmod) (const char *, mode_t, struct fuse_file_info *fi);
int (*chown) (const char *, uid_t, gid_t, struct fuse_file_info *fi);
int (*truncate) (const char *, off_t, struct fuse_file_info *fi);
int (*open) (const char *, struct fuse_file_info *);
int (*read) (const char *, char *, size_t, off_t, struct fuse_file_info *);
int (*write) (const char *, const char *, size_t, off_t, struct fuse_file_info *);
int (*statfs) (const char *, struct statvfs *);
int (*flush) (const char *, struct fuse_file_info *);
int (*release) (const char *, struct fuse_file_info *);
int (*fsync) (const char *, int, struct fuse_file_info *);
int (*setxattr) (const char *, const char *, const char *, size_t, int);
int (*getxattr) (const char *, const char *, char *, size_t);
int (*listxattr) (const char *, char *, size_t);
int (*removexattr) (const char *, const char *);
int (*opendir) (const char *, struct fuse_file_info *);
int (*readdir) (const char *, void *, fuse_fill_dir_t, off_t,
结构 fuse_file_info *, 枚举 fuse_readdir_flags);
int (*releasedir) (const char *, struct fuse_file_info *);
int (*fsyncdir) (const char *, int, struct fuse_file_info *);
void *(*init) (struct fuse_conn_info *conn,struct fuse_config *cfg);
无效(*销毁)(无效*private_data);
int (*access) (const char *, int);
int (*create) (const char *, mode_t, struct fuse_file_info *);
int (*lock) (const char *, struct fuse_file_info *, int cmd,struct flock *);
int (*utimens) (const char *, const struct timespec tv[2],
结构 fuse_file_info *fi);
int (*bmap) (const char *, size_t blocksize, uint64_t *idx); #if FUSE_USE_VERSION < 35
int (*ioctl) (const char *, int cmd, void *arg,
struct fuse_file_info *, unsigned int flags, void *data);
#别的
int (*ioctl) (const char *, unsigned int cmd, void *arg,
struct fuse_file_info *, unsigned int flags, void *data);
#万一 int (*poll) (const char *, struct fuse_file_info *,
结构 fuse_pollhandle *ph, 无符号 *reventsp);
int (*write_buf) (const char *, struct fuse_bufvec *buf, off_t off,
结构 fuse_file_info *);
int (*read_buf) (const char *, struct fuse_bufvec **bufp,
size_t 大小,off_t 关闭,结构 fuse_file_info *);
int (*flock) (const char *, struct fuse_file_info *, int op);
int (*fallocate) (const char *, int, off_t, off_t,
结构 fuse_file_info *);
ssize_t (*copy_file_range) (const char *path_in,
结构 fuse_file_info *fi_in,
off_t offset_in, const char *path_out,
结构 fuse_file_info *fi_out,
off_t offset_out、size_t 大小、int 标志);
off_t (*lseek) (const char *, off_t off, int wherece, struct fuse_file_info *);
};
不要让长长的清单吓到你。我们只实施:
获取属性
当您读取文件的属性时调用读目录
读取目录时调用读
从文件中读取时调用mkdir
创建目录时调用诺德
创建文件时调用写
写入文件时调用
当文件系统内部发生某些事情时,例如创建目录,FUSE 将调用 mkdir
指着。读取文件时也会发生同样的情况。该方法 读
指向将被调用。
辅助方法
有一些代码我们会更频繁地使用。因此,将其放入几个方法中将使其余代码更清晰。
在类级别,我们添加了两个列表和一个地图。我们使用列表来跟踪我们创建的目录和文件。该映射用于检索文件的内容。
静态列表<String>目录 = 新的 ArrayList<>();
静态列表<String>文件 = 新的 ArrayList<>();
静态地图<String, String>文件内容 = 新的 HashMap<>();
这是添加文件或检查它是已知目录还是文件的三种小方法。
静态布尔 isDir(字符串路径){
返回目录.包含(路径);
} 静态无效添加文件(字符串文件名){
文件。添加(文件名);
文件内容.put(文件名,"");
} 静态布尔 isFile(字符串路径){
返回文件.包含(路径);
}
在 Java 中创建 fuse_operations
我们从这个类开始:
导入 jdk.incubator.foreign.*;
导入 org.linux.*; // 一个 导入 java.util.Arrays; 公共类SecondMain { 静态资源范围 rsScope = null; 公共静态无效主(字符串...参数){ System.load("/usr/lib64/libfuse3.so.3.10.5"); // 乙 args = new String[]{"-f", "-d", "/mnt/test/"}; // C 尝试 (var scope = ResourceScope.newSharedScope()) { // D
rsScope = 范围;
var arguments = Arrays.stream(args).map(s -> CLinker.toCString(s, scope)).toArray(MemorySegment[]::new); // E
var allocator = SegmentAllocator.ofScope(scope); // F
var argumentCount = args.length;
var argumentSpace = allocator.allocateArray(CLinker.C_POINTER, arguments); // G MemorySegment 操作MemorySegment = fuse_operations.allocate(scope); // H
} }
}
在“A”行,我们导入我们在前一步中生成的所有类。在“B”行,我们加载我们想要使用的 libfuse 库。您可以使用 ldconfig -p | grep libfuse
找到它在您的系统上的位置。
在“C”处,我们创建了一个数组,其中包含我们想要传递给 FUSE 的参数。 -F
就是将其保持在前台,这样我们就可以在控制台中看到任何输出。 -d
将使 FUSE 还将任何调试信息打印到控制台。 /mnt/测试/
是挂载点。
资源范围管理一个或多个资源的生命周期,例如内存段。我们在“D”处创建了一个 SharedScope,因为 Fuse 默认运行多线程。您可以使用 -s
如果你愿意,让它运行单线程。
在“E”行,我们获取一个 Java 字符串并将其转换为 C 函数可用的 C 字符串。结果是一个数组 内存段
.然后在“F”行,我们创建一个 段分配器
我们可以在“G”行使用来为我们的数组分配内存 内存段
.
“H”行向我们展示了我们如何分配熔断器操作。 fuse_operations
是 Jextract 为我们生成的类的名称。它有一个方法 分配
在共享范围内为自己分配内存。
实现 getAttr
这是我们所知道的签名 fuse_operations
.
int (*getattr) (const char *, struct stat *, struct fuse_file_info *fi);
这是来自 Jextract 生成的 Java 类的签名。它是功能接口的一部分,所以我们可以提供一个实现。
int apply(MemoryAddress x0, MemoryAddress x1, MemoryAddress x2);
对于我们的实现,我们不会添加 fuse_file_info fi
在签名中。因为我们不会使用它。
公共静态int getAttr(MemoryAddress路径,MemoryAddress mStat){
String jPath = CLinker.toJavaString(path); // 一个
MemorySegment statMemorySegment = stat.ofAddress(mStat, rsScope); // 乙 int S_IFDIR = 0040000; /* 目录 */
int S_IFREG = 0100000; /* 常规的 */ // 设置统计时间(最后访问时间)
现在瞬间 = Instant.now();
timespec.tv_sec$set(stat.st_atim$slice(statMemorySegment), now.getEpochSecond()); // C
timespec.tv_nsec$set(stat.st_atim$slice(statMemorySegment), now.getNano()); // 设置 stat mtim(最后修改时间)
现在 = Instant.now();
timespec.tv_sec$set(stat.st_mtim$slice(statMemorySegment), now.getEpochSecond());
timespec.tv_nsec$set(stat.st_mtim$slice(statMemorySegment), now.getNano()); stat.st_uid$set(statMemorySegment, 1000); // D
stat.st_gid$set(statMemorySegment,1000); if ("/".equals(jPath) || isDir(jPath.substring(1))) {
stat.st_mode$set(statMemorySegment, (short) (S_IFDIR | 0755)); // E
stat.st_nlink$set(statMemorySegment, 2); // F
} else if (isFile(jPath.substring(1))) {
stat.st_mode$set(statMemorySegment, (int)(S_IFREG | 0644));
stat.st_nlink$set(statMemorySegment, 1);
stat.st_size$set(statMemorySegment, filesContent.get(jPath.substring(1)).getBytes().length); // G
} 别的 {
返回-2; // H
} 返回0; // 我
}
在“A”行,我们将 C 字符串转换为 Java 字符串。我们从 Fuse 签名中知道,第一个参数是我们想要属性的文件路径。第二 内存地址
参数中是我们需要填充请求的文件或目录的属性的stat结构。要访问 stat 结构,我们需要在“B”行获得的 MemorySegment。
在“C”行,我们设置了最后一次访问时间。要设置时间,我们需要调用 timespec.tv_sec$set
并在我们通过调用获得的 stat 内存段的特定部分设置秒数 tat.st_mtim$slice(statMemorySegment)
.
用户 ID 和组 ID 设置在 D 行。为方便起见,我们现在将它们设置为 1000。在C中你会打电话 见证()
和 获取吉德()
获得真正的价值。
在 - 的里面 如果
在“E”行,我们设置 st_mode
what 指定它是目录还是普通文件,我们设置权限位。我们也对文件执行此操作。一个区别是我们设置 set_nlink
到两个目录。你可以在这里阅读为什么这样做。 ( https://unix.stackexchange.com/questions/101515/why-does-a-new-directory-have-a-hard-link-count-of-2-before-anything-is-added-to/101536# 101536) .
对于文件,我们还需要设置大小。这里我们只是将 String 转换为字节数组并使用它的大小。
在“H”行,我们返回 2,它等于 ENOENT
在 C 中,这意味着没有这样的文件或目录。指定路径名的组件不存在,或路径名是空字符串。
我们最后返回 0 让 FUSE 知道我们已经完成并且一切正常。
为什么我们需要做 Path.substring(1)?
Fuse 将为我们传递一条以 a 开头的路径 /
.当我们稍后创建实现时 mkdir
和 mknod,我们将只存储名称而不是它们的路径。所以,我们不需要 /
.
实现 readDir
我们要实现的下一件事是 readDir。当您想知道给定目录中有哪些文件和目录可用时,将调用此方法。
FUSE 签名如下所示:
int (*readdir) (const char *, void *, fuse_fill_dir_t, off_t, struct fuse_file_info *, enum fuse_readdir_flags);
Jextract 创建了这个。就像 闲话
它是功能接口的一部分,我们必须提供实现。
int apply(MemoryAddress x0, MemoryAddress x1, long x2, long x3, MemoryAddress x4);
我们这里有五个参数,我们只会使用前三个。填充物有点特别。是 FUSE 提供的一个辅助方法来填充 缓冲
.
public static int readDir(MemoryAddress 路径,MemoryAddress 缓冲区,MemoryAddress 填充,长偏移,MemoryAddress fileInfo) { String jPath = CLinker.toJavaString(path);
fuse_fill_dir_t fuse_fill_dir_t = org.linux.fuse_fill_dir_t.ofAddress(filler); // 一个
fuse_fill_dir_t.apply(buffer, CLinker.toCString(".", rsScope).address(), MemoryAddress.NULL, 0, 0); // 乙
fuse_fill_dir_t.apply(buffer, CLinker.toCString("..", rsScope).address(), MemoryAddress.NULL, 0, 0);
if ("/".equals(jPath)) { // C
对于(字符串 p:目录){
fuse_fill_dir_t.apply(buffer, CLinker.toCString(p, rsScope).address(), MemoryAddress.NULL, 0, 0);
} 对于(字符串 p:文件){
fuse_fill_dir_t.apply(buffer, CLinker.toCString(p, rsScope).address(), MemoryAddress.NULL, 0, 0);
}
} 返回0;
}
调用方法 填料
我们需要它的一个实例;这是在“A”行完成的。正如我们之前谈到的,目录在基于 Unix 的文件系统中有两个链接。在“B”,我们确保每个目录都有这两个链接。
在 C 行,我们填充我们循环遍历两个列表并使用添加创建的文件和目录 填料
.
实现读取
当我们要读取文件的内容时调用此方法。 Fuse 签名如下:
int (*read) (const char *, char *, size_t, off_t, struct fuse_file_info *);
Jextract 为我们创建了它作为功能接口的一部分:
int apply(MemoryAddress x0, MemoryAddress x1, long x2, long x3, MemoryAddress x4);
该方法传递了一个缓冲区,我们需要用所请求文件的内容填充该缓冲区。
public static int read(MemoryAddress path, MemoryAddress buffer, long size, long offset, MemoryAddress fileInfo) {
String jPath = CLinker.toJavaString(path).substring(1); 如果(!isFile(jPath)){
返回-1;
} byte[] selected = filesContent.get(jPath).getBytes(); ByteBuffer byteBuffer = buffer.asSegment(size, rsScope).asByteBuffer(); // 一个 byte[] src = Arrays.copyOfRange(selected, Math.toIntExact(offset), Math.toIntExact(size)); // 乙
byteBuffer.put(src); // C 返回 src.length; // D
}
在方法的第一部分,我们将 C 字符串转换为 Java 字符串,并检查我们是否知道文件。在“A”行,我们做一个 字节缓冲区
的 缓冲
首先获取它的内存段。接下来在“B”行,我们复制用户请求的部分。接下来,我们填充 字节缓冲区
使用复制的范围并返回长度;所以 FUSE 知道它有多长。
实现 doMkdir
这个方法在我们创建目录的时候被调用。
这是 FUSE 签名。
int (*mkdir) (const char *, mode_t);
Jextract 为我们创建了它作为功能接口的一部分:
int apply(MemoryAddress x0, int x1);
当 FUSE 调用该方法时,我们只是将 C 字符串转换为 Java 并将其添加到目录列表中。
static int doMkdir(MemoryAddress path, int mode) {
String jPath = CLinker.toJavaString(path);
目录.add(jPath.substring(1));
返回0;
}
实现 doMknod
这是我们要实现的保险丝签名。
int (*mknod) (const char *, mode_t, dev_t);
Jextract 生成方法:
int apply(MemoryAddress x0, int x1, long x2);
当 FUSE 调用这个方法时,我们调用 helper 方法 添加文件
将文件添加到文件列表并在文件内容映射中创建键值对。
static int doMknod(MemoryAddress path, int mode, long rdev) {
String jPath = CLinker.toJavaString(path);
addFile(jPath.substring(1));
返回0;
}
实现doWrite
保险丝签名:
int (*write) (const char *, const char *, size_t, off_t, struct fuse_file_info *);
Jextract 生成方法:
int apply(MemoryAddress x0, MemoryAddress x1, long x2, long x3, MemoryAddress x4);
do write 有一个 buffer 参数,其中包含我们需要保存在内存中的字节。
static int doWrite(MemoryAddress path, MemoryAddress buffer, long size, long offset, MemoryAddress info) {
byte[] array = buffer.asSegment(size, rsScope).toByteArray(); // 一个
String jPath = CLinker.toJavaString(path).substring(1);
filesContent.put(jPath, new String(array, java.nio.charset.StandardCharsets.UTF_8));
返回 Math.toIntExact(size);
}
在“A”行,我们使用缓冲区的内存地址和大小创建一个段。有了这些,我们创建了一个 字节数组
我们可以将其转换为字符串并存储在文件内容映射中。
填充保险丝操作和启动保险丝
我们已经实现了基本文件系统的所有方法。现在是时候将它们添加到熔断器操作结构中了。
公共静态无效主(字符串...参数){ System.load("/usr/lib64/libfuse3.so.3.10.5"); args = new String[]{"-f", "-d", "/mnt/test/"}; files.add("file54");
filesContent.put("file54", "file54 的内容"); 尝试 (var scope = ResourceScope.newSharedScope()) {
rsScope = 范围;
var arguments = Arrays.stream(args).map(s -> CLinker.toCString(s, scope)).toArray(MemorySegment[]::new);
var allocator = SegmentAllocator.ofScope(scope);
var argumentCount = args.length;
var argumentSpace = allocator.allocateArray(CLinker.C_POINTER, arguments); MemorySegment 操作MemorySegment = fuse_operations.allocate(scope); fuse_operations.getattr$set(operationsMemorySegment, fuse_operations.getattr.allocate((path, stat, fi) -> getAttr(path, stat), scope)); // 一个
fuse_operations.readdir$set(operationsMemorySegment, fuse_operations.readdir.allocate((路径, 缓冲区, 填充物, 偏移量, fileInfo, i) -> readDir(路径, 缓冲区, 填充物, 偏移量, fileInfo), 范围));
fuse_operations.read$set(operationsMemorySegment, fuse_operations.read.allocate((path, buffer, size, offset, fileInfo) -> read(path, buffer, size, offset, fileInfo), scope));
fuse_operations.mkdir$set(operationsMemorySegment, fuse_operations.mkdir.allocate((MemoryAddress x0, int x1) -> doMkdir(x0, x1), scope));
fuse_operations.mknod$set(operationsMemorySegment, fuse_operations.mknod.allocate((MemoryAddress x0, int x1, long x2) -> doMknod(x0, x1, x2), scope));
fuse_operations.write$set(operationsMemorySegment, fuse_operations.write.allocate((MemoryAddress x0, MemoryAddress x1, long x2, long x3, MemoryAddress x4) -> doWrite(x0, x1, x2, x3, x4), scope)); fuse_h.fuse_main_real(argumentCount, argumentSpace, operationsMemorySegment, operationsMemorySegment.byteSize(), MemoryAddress.NULL); // 乙
}
}
在上面的代码中,您可以看到完成的 main 方法。我们在资源范围内添加了六个方法调用,以将方法添加到熔断操作结构中。我们还添加了 fuse_h.fuse_main_real
挂载我们的文件系统。
在“A”行,您可以看到我们如何添加 获取属性
的方法 fuse_operations
.在这一行发生的事情是我们称 fuse_operations
类并告诉它我们要设置 获取属性
方法上我们的熔断操作 MemorySegment(第一个参数)。第二个参数创建生成的 Jextract 代码和 Fuse 可以使用的 lambda 方法的内存地址。最后一个参数是作用域,它是所用内存段和内存地址的所有者。
我们需要为我们想要 FUSE 调用的每个方法执行此操作。这六个调用共享相同的模式。我们只需要将 lambda 指向正确的函数并调用匹配的函数 fuse_operations
.
在“B”行,我们挂载我们的文件系统。我们称之为 fuse_main_real
在 fuse_h
带有参数的类 参数
,我们实现了六个方法的fuse_operations,以及结构体的大小。应用程序启动后,您可以在挂载点内创建文件和目录。该程序会一直运行,直到您停止它或卸载文件系统。您可以使用卸载它 fusermount -u {MOUNT_LOCATION}
. *注意如果您自己停止应用程序,您仍然需要卸载它。
结论
你做到了!我们使用外部链接器 API 在 Java 中创建了一个内存文件系统。我们使用 Jextract 从 C 头文件生成 Java 类。使用 Clinker 将 Java 字符串转换为 C 字符串,反之亦然。我们还调用了 C 函数 fuse_main_real
直接来自Java代码。创建了六个向上调用,当文件系统内发生事件时,FUSE 可以调用这些调用。
最初发表于 https://www.davidvlijmincx.com 2021 年 11 月 28 日。
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明
本文链接:https://www.qanswer.top/40126/37170109
标签:java,MemoryAddress,17,int,struct,char,fuse,Fuse,const From: https://www.cnblogs.com/amboke/p/16746800.html