七、Linux创建自己的proc文件
文章目录
- 一、前言
- 二、实现代码
- 1.包含必要的头文件
- 2.定义序列操作函数
- 2.1.代码逐行解析
- 2.2.总结
- 3.绑定序列操作函数和文件操作函数
- 3.1.代码逐行解析
- 3.2.总结与关系图
- 4.创建与销毁/proc文件
- 4.1.代码逐行解析
- 4.2.总结与调用时机
- 三、编译和加载
- 1.Makefile文件
- 2.scull_load和scull_unload文件
- 3.编译和加载模块
- 四、验证模块
- 1.确认模块已成功加载
- 2.确认`/proc/scullseq`文件是否创建
- 3.确认`/proc/scullseq`文件读取功能正常
一、前言
内核模块通过 proc 文件系统向用户空间提供信息或接口是一种非常常见且相对简单的方法
可以实现以下需求:
-
提供运行时信息:让用户空间程序能够方便地读取模块的内部状态、统计信息、调试数据等。例如,一个驱动可以输出其中断次数、数据包计数等
-
接收用户空间控制命令:允许用户空间程序通过写入文件来改变模块的行为、设置参数或触发特定操作。例如,开启/关闭调试模式、重置计数器等
二、实现代码
下列代码基于博客 https://blog.csdn.net/weixin_51019352/article/details/151870691 的代码,可以参考其详细解释,在原先scull,c
代码文件中新添加如下代码,在vim
中粘贴文件记得使用粘贴模式:set paste
1.包含必要的头文件
#include <linux/proc_fs.h>
#include <linux/seq_file.h> // 用于安全地输出大量数据
2.定义序列操作函数
#ifdef SCULL_DEBUG
static void *scull_seq_start(struct seq_file *s, loff_t *pos)
{if (*pos >= scull_nr_devs)return NULL;return scull_devices + *pos;
}static void *scull_seq_next(struct seq_file *s, void *v, loff_t *pos)
{(*pos)++;if (*pos >= scull_nr_devs)return NULL;return scull_devices + *pos;
}static void scull_seq_stop(struct seq_file *s, void *v)
{}static int scull_seq_show(struct seq_file *s, void *v)
{struct scull_dev *dev = (struct scull_dev *) v;struct scull_qset *d;int i;seq_printf(s, "\nDevice %i: qset %i, q %i, sz %li\n",(int) (dev - scull_devices), dev->qset,dev->quantum, dev->size);for (d = dev->data; d; d = d->next) {seq_printf(s, " item at %p, qset at %p\n", d, d->data);if (d->data && !d->next)for (i = 0; i < dev->qset; i++) {if (d->data[i]) {seq_printf(s, " % 4i: %8p\n",i, d->data[i]);}}}return 0;
}
2.1.代码逐行解析
#ifdef SCULL_DEBUG
解释:
这是一个预处理器指令。它检查是否定义了宏 SCULL_DEBUG
。只有在编译模块时定义了此宏(例如,通过 gcc -DSCULL_DEBUG
或在源文件开头使用 #define SCULL_DEBUG
),#ifdef
和 #endif
之间的代码才会被包含在编译过程中
static void *scull_seq_start(struct seq_file *s, loff_t *pos)
解释:
static
:限定该函数的作用域仅在当前文件内,避免与其他内核文件中的同名函数发生命名冲突void *
:该函数的返回类型是一个无类型指针(泛型指针)。它将返回一个指向要迭代的序列中某个元素的指针struct seq_file *s
:指向seq_file
结构的指针。这个结构由内核管理,代表正在被读取的序列文件,内部包含了缓冲区、状态等信息loff_t *pos
:指向“长偏移量(long long)”类型的指针。它表示序列中的当前位置(一个整数索引)。函数会读取*pos
的值来决定从哪里开始,也可能会修改*pos
的值
if (*pos >= scull_nr_devs)
解释:
*pos
:解引用pos
指针,获取当前的迭代位置值scull_nr_devs
:一个全局变量,表示系统中共有多少个scull
设备。- 整体:检查当前请求的位置是否已经超出或等于设备的总数。如果是,说明已经越界,没有更多设备可遍历
return NULL;
解释:
- 整体:如果位置越界,则向
seq_file
框架返回NULL
,表示迭代已经结束,没有更多的数据项了
return scull_devices + *pos;
解释:
scull_devices
:这是一个全局数组的起始地址,该数组包含了所有scull_dev
设备结构+ *pos
:这是一个指针运算。scull_devices + *pos
等价于&scull_devices[*pos]
- 整体:计算并返回指向第
*pos
个scull
设备结构的指针。这个指针将被传递给后续的_show
和_next
函数
static void *scull_seq_next(struct seq_file *s, void *v, loff_t *pos)
解释:
static void *
:同上,静态函数,返回一个泛型指针struct seq_file *s
:同上,指向序列文件结构的指针void *v
:指向前一个由_start
或_next
返回的数据项指针(在这个例子中,就是指向scull_dev
的指针)loff_t *pos
:指向当前位置的指针。这个函数必须修改这个值
(*pos)++;
解释:
(*pos)
:解引用pos
指针,获取当前的位置值++
:自增运算符。它将位置值加一,移动到序列中的下一个项目- 整体:将迭代位置推进到下一个设备索引
if (*pos >= scull_nr_devs)
解释:与 _start
函数中的检查相同。判断新的位置是否已经越界
return NULL;
解释:如果越界,返回 NULL
表示迭代结束
return scull_devices + *pos;
解释:返回指向下一个(第 *pos
个)scull
设备结构的指针
static void scull_seq_stop(struct seq_file *s, void *v)
解释:
void *v
:指向最后被迭代的数据项的指针(通常是在_start
或_next
中返回的最后一个有效指针,或者是NULL
)- 整体:这个函数在迭代序列结束(无论是否完成)时被调用,用于执行任何必要的清理工作,例如释放锁或资源
解释:一个空行,没有任何操作。这表明对于 scull
设备,在迭代结束时没有任何需要特别清理的资源。锁的获取和释放在 _show
函数中完成
static int scull_seq_show(struct seq_file *s, void *v)
解释:
struct seq_file *s
:指向序列文件结构,所有输出都将通过它提供的函数(如seq_printf
)写入到这个结构内部的缓冲区中void *v
:指向当前需要被“展示”或格式化的数据项的指针。这个指针是之前由_start
或_next
返回的(在这里是指向scull_dev
的指针)- 整体:该函数的核心职责是将
v
所指向的数据内容,以人类可读的文本形式,输出到序列文件s
中
struct scull_dev *dev = (struct scull_dev *) v;
解释:
struct scull_dev *dev
:声明一个指向scull_dev
结构的指针变量dev
(struct scull_dev *) v
:这是一个类型转换(强制类型转换)。它将泛型指针v
转换为我们已知的、具体的scull_dev
结构指针类型
struct scull_qset *d;
解释:声明一个指向 scull_qset
结构的指针变量 d
。scull_qset
是 scull
设备用来管理内存数据的链表节点结构
int i;
解释:声明一个整型变量 i
,将在后面的循环中用作计数器
seq_printf(s, "\nDevice %i: qset %i, q %i, sz %li\n", (int) (dev - scull_devices), dev->qset, dev->quantum, dev->size);
解释:
-
seq_printf
:这是seq_file
接口提供的函数,类似于用户空间的printf
。它将格式化后的字符串输出到seq_file
缓冲区s
中,而不是标准输出 -
s
:目标序列文件。 -
"\nDevice %i: qset %i, q %i, sz %li\n"
:格式字符串\n
:换行符%i
:整数格式化占位符(用于设备索引)%i
:整数格式化占位符(用于qset
值,即每个量子集有多少个指针)%i
:整数格式化占位符(用于quantum
值,即每个量子的大小)%li
:长整数格式化占位符(用于size
值,即设备数据的总大小)
-
(int) (dev - scull_devices)
dev - scull_devices
:这是一个指针减法运算。它计算当前设备指针dev
与设备数组起始指针scull_devices
之间的元素个数差,结果就是当前设备的索引号(int)
:将结果强制转换为int
类型
-
dev->qset
:访问dev
结构体的qset
成员(每个量子集的指针数量) -
dev->quantum
:访问dev
结构体的quantum
成员(每个量子的大小) -
dev->size
:访问dev
结构体的size
成员(设备当前存储的数据总大小)
for (d = dev->data; d; d = d->next) {
解释:用于遍历设备的量子集链表。
d = dev->data
:初始化。将指针d
指向设备的数据链表的头节点(dev->data
)d
:循环条件。只要d
不是NULL
,循环就继续d = d->next
:循环步进。在每次迭代后,将d
指向链表中的下一个节点(d->next
)
seq_printf(s, " item at %p, qset at %p\n", d, d->data);
解释:为每个量子集节点输出一行信息。
seq_printf(s, ...)
:再次使用格式化输出函数。" item at %p, qset at %p\n"
:格式字符串。%p
:指针格式化占位符,用于输出地址。
d
:第一个参数,对应第一个%p
,是当前量子集节点本身的地址d->data
:第二个参数,对应第二个%p
,是该节点中“量子指针数组”的起始地址
if (d->data && !d->next)
解释:
d->data
:检查当前量子集节点的数据指针数组是否存在(非NULL
)!d->next
:检查当前节点是否是链表中的最后一个节点(d->next
为NULL
,!
取反后为真)- 整体:这个条件的意思是:“如果当前节点有数据数组,并且它是链表的最后一个节点,那么……”
for (i = 0; i < dev->qset; i++) {
解释:一个内嵌的 for
循环。只有在满足外层 if
条件时才会执行
i = 0
:初始化计数器i
为 0i < dev->qset
:循环条件。i
要小于一个量子集中量子的数量(dev->qset
)i++
:每次迭代后计数器加一
if (d->data[i])
解释:
d->data[i]
:访问当前量子集节点的量子指针数组的第i
个元素- 整体:检查第
i
个指针是否有效(非NULL
)。只有有效的指针才会打印
seq_printf(s, " % 4i: %8p\n", i, d->data[i]);
解释:
-
seq_printf(s, ...)
:再次输出 -
" % 4i: %8p\n"
:格式字符串% 4i
:一个宽度为4的整数格式化占位符。前面的空格表示正数前面用空格填充,负数用负号,这保证了数字的对齐%8p
:一个宽度为8的指针格式化占位符,用于输出指针地址,同样是为了对齐
-
i
:第一个参数,对应% 4i
,是当前指针在数组中的索引 -
d->data[i]
:第二个参数,对应%8p
,是第i
个指针本身的值(它指向的内存地址)
2.2.总结
这段代码定义了四个函数(start
, next
, stop
, show
),它们共同实现了 seq_file
接口。这个接口允许内核高效、安全地将一个复杂的数据结构(这里是 scull
设备的链表)逐步输出到用户空间(通过 /proc
文件)
-
_start
和_next
定义了如何遍历数据集合(按设备索引遍历设备数组) -
_show
定义了如何格式化输出一个数据项(一个scull
设备的所有信息),包括其内部链表 -
_stop
是一个清理钩子,这里什么也没有做 -
所有输出都使用安全的
seq_printf
函数,由内核管理缓冲区,避免了传统read_proc
方法中复杂的缓冲区管理和溢出问题
3.绑定序列操作函数和文件操作函数
static struct seq_operations scull_seq_ops = {.start = scull_seq_start,.next = scull_seq_next,.stop = scull_seq_stop,.show = scull_seq_show
};
static int scull_proc_open(struct inode *inode, struct file *file)
{return seq_open(file, &scull_seq_ops);
}
static struct file_operations scull_proc_ops = {.owner = THIS_MODULE,.open = scull_proc_open,.read = seq_read,.llseek = seq_lseek,.release = seq_release
};
3.1.代码逐行解析
static struct seq_operations scull_seq_ops =
解释:
static
:限定这个结构体变量的作用域仅在当前文件内struct seq_operations
:这是一个内核定义的结构体类型(在<linux/seq_file.h>
中)。它充当一个“接口”或“契约”,包含了驱动必须实现的四个函数指针,用于控制序列的迭代和显示
static int scull_proc_open(struct inode *inode, struct file *file)
解释:
static int
:静态函数,返回一个整数(通常成功为0,失败为负的错误码)struct inode *inode
:指向内核内部表示文件的inode
结构的指针。包含文件的元数据(如设备号、权限)struct file *file
:指向内核文件结构的指针,代表一个打开的文件实例。这个结构将被后续的读、写、定位等操作使用- 整体:这是为
/proc
文件定义的文件打开操作函数。当用户空间程序执行open(“/proc/scullseq”, O_RDONLY)
时,内核最终会调用这个函数
return seq_open(file, &scull_seq_ops);
解释:
-
seq_open(file, &scull_seq_ops)
:这是一个内核提供的通用函数(在seq_file.c
中实现)&scull_seq_ops
:取地址符,获取我们上面定义的scull_seq_ops
结构体的地址
-
整体:
seq_open()
函数执行以下关键操作:-
它分配并设置一个
struct seq_file
结构体,该结构体将管理与序列操作相关的所有状态信息 -
它将我们提供的
scull_seq_ops
结构体存储在这个新分配的seq_file
中 -
它将
file->private_data
字段指向这个seq_file
结构体。这样,后续的seq_read
,seq_lseek
等函数就能通过file->private_data
找到seq_file
,进而找到scull_seq_ops
-
static struct file_operations scull_proc_ops = {
解释:
struct file_operations
:这是内核中极其重要的一个结构体类型(在<linux/fs.h>
中定义)。它包含了一组函数指针,这些指针定义了可以对文件执行的所有操作(VFS 接口)。驱动或内核模块通过实现这个结构体的成员来定义其文件的行为
.owner = THIS_MODULE,c
解释:
.owner
:file_operations
的成员。这是一个指向模块拥有者的指针THIS_MODULE
:一个内核宏,它扩展为一个指向当前正在编译的模块结构(struct module
)的指针- 重要性:设置
.owner
字段是至关重要的。它确保了只要文件仍然打开着(即有用户空间程序在使用这个/proc
文件),内核就不会允许卸载这个模块。这防止了在文件操作还在进行时模块被意外移除,从而避免内核崩溃
.open = scull_proc_open,
解释:
.open
:file_operations
的成员。函数指针,指向文件打开操作scull_proc_open
:我们上面定义的函数的地址。这是我们提供的唯一一个自定义的文件操作函数
.read = seq_read,
解释:
.read
:file_operations
的成员。函数指针,指向文件读取操作seq_read
:这是seq_file
框架提供的通用读取函数的地址。我们不需要自己实现read
- 工作原理:当用户空间调用
read()
时,seq_read
会被调用。它通过file->private_data
找到seq_file
结构体和我们的scull_seq_ops
。然后它自动调用我们的_start
,_next
,_show
,_stop
函数来逐步生成数据,并处理所有复杂的缓冲区管理和分页逻辑
.llseek = seq_lseek,
解释:
.llseek
:file_operations
的成员。函数指针,指向文件定位(lseek
)操作seq_lseek
:这是seq_file
框架提供的通用定位函数的地址。我们不需要自己实现llseek
- 功能:它实现了对序列文件的随机访问。用户空间程序可以使用
lseek()
来跳转到文件的不同位置,seq_lseek
会高效地处理这些请求,可能通过调用我们的_start
和_next
函数来快速跳过前面的项
.release = seq_release
解释:
.release
:当文件描述符的最后一个引用被关闭时调用seq_release
:这是seq_file
框架提供的通用释放函数的地址。我们不需要自己实现release
- 功能:它负责释放在
scull_proc_open
中由seq_open
分配的所有资源(主要是那个seq_file
结构体)
3.2.总结与关系图
这段代码完成了从“序列操作”到“文件操作”的桥接:
-
struct seq_operations
:定义了数据如何被遍历和显示(驱动实现逻辑) -
seq_open
:在打开文件时,将seq_operations
和file
关联起来(内核提供的粘合函数) -
struct file_operations
:定义了文件的行为(.open
,.read
,.llseek
,.release
)。除了.open
是我们自定义的(用于调用seq_open
),其他所有操作都直接使用seq_file
框架提供的现成、强大的通用函数
关系流程如下:
用户调用 open() -> 内核调用 scull_proc_open()|vscull_proc_open() 调用 seq_open()|| (关联)vfile->private_data -> struct seq_file -> struct seq_operations -> (scull_seq_start, ...)||
用户调用 read() -> 内核调用 seq_read()|vseq_read() 使用 file->private_data 找到 seq_file 和 seq_operations|v循环调用: start -> show -> next -> show -> next -> ... -> stop|v数据通过 seq_printf 输出到用户缓冲区
4.创建与销毁/proc文件
static void scull_create_proc(void)
{struct proc_dir_entry *entry;entry = create_proc_entry("scullseq", 0, NULL);if (entry)entry->proc_fops = &scull_proc_ops;
}static void scull_remove_proc(void)
{remove_proc_entry("scullseq", NULL);
}
#endif
4.1.代码逐行解析
static void scull_create_proc(void)
解释:
static
:限定该函数的作用域仅在当前文件内。void
:该函数没有返回值。(void)
:明确表示该函数不接受任何参数。- 整体:这个函数的唯一职责就是在内核的 proc 文件系统中注册并创建文件
struct proc_dir_entry *entry;
解释:
struct proc_dir_entry *entry;
:声明一个指向proc_dir_entry
结构的指针变量entry
struct proc_dir_entry
:这是内核中用于表示/proc
文件系统中一个条目(文件或目录)的核心数据结构。它包含了条目的名称、权限、父目录、以及最重要的——指向文件操作函数集 (proc_fops
) 的指针- 作用:这个指针将用于存储
create_proc_entry
函数的返回值,成功后它指向新创建的 proc 条目
entry = create_proc_entry("scullseq", 0, NULL);
解释:这是创建 proc 条目的核心函数调用
entry =
:将函数的返回值赋值给刚才声明的指针entry
create_proc_entry(...)
:一个内核函数,用于在/proc
文件系统中动态创建一个新条目"scullseq"
:第一个参数,是一个字符串,指定要在/proc
中创建的文件名。用户空间将通过/proc/scullseq
路径访问这个文件0
:第二个参数,是文件的权限模式(mode)。0
表示使用内核的默认权限NULL
:第三个参数,是一个指向struct proc_dir_entry
的指针,指定新文件的父目录。NULL
表示父目录是/proc
的根目录本身。如果你想在/proc
下创建子目录(例如/proc/mydriver/scullseq
),你需要先用proc_mkdir
创建子目录,然后在这里传递子目录对应的proc_dir_entry
指针- 返回值:如果创建成功,函数返回一个指向新条目的指针;如果失败(例如内存不足),则返回
NULL
if (entry)
解释:
entry
:检查entry
指针的值。- 整体:这是一个至关重要的错误检查。它确保只有在
create_proc_entry
成功返回一个有效指针(非NULL
)的情况下,才执行后续操作。如果创建失败,直接跳过,避免内核解引用空指针导致崩溃
entry->proc_fops = &scull_proc_ops;
解释:这是将我们定义的文件操作连接到 proc 条目的关键步骤
entry->proc_fops
:访问entry
所指向的proc_dir_entry
结构体的proc_fops
成员。这是一个struct file_operations
类型的指针。&scull_proc_ops
:取地址符,获取我们之前定义并初始化的scull_proc_ops
结构体的地址- 整体:这行代码建立了最终的连接。它告诉内核:“当用户空间程序对
/proc/scullseq
文件执行任何操作(如 open, read, seek)时,请使用scull_proc_ops
中定义的函数来响应这些操作。” 至此,用户对/proc/scullseq
的访问终于和我们模块的实现逻辑联系起来了
static void scull_remove_proc(void)
解释:
- 整体:这个函数是
scull_create_proc
的逆操作,其职责是在模块卸载时清理/proc
文件系统中的条目,防止留下“僵尸”文
remove_proc_entry("scullseq", NULL);
解释:这是删除 proc 条目的核心函数调用。
-
remove_proc_entry(...)
:一个内核函数,用于移除之前通过create_proc_entry
创建的条目 -
"scullseq"
:第一个参数,是要移除的条目的名称。必须与创建时使用的名称完全一致 -
NULL
:第二个参数,是条目的父目录。必须与创建时指定的父目录一致。因为创建时父目录是NULL
(/proc
根目录),所以这里也传递NULL
-
重要性:在模块的退出函数中调用此函数是强制性的。如果不清理,当模块被卸载后:
-
/proc/scullseq
文件依然存在 -
但该文件背后的操作函数 (
scull_proc_ops
) 所在的模块已经被移除,代码内存已被释放 -
如果用户空间尝试访问这个文件,内核会尝试调用一个不存在的内存地址,导致内核崩溃
-
4.2.总结与调用时机
这两个函数通常会在模块的初始化和退出流程中被调用
清除函数新增删除/proc文件逻辑
void scull_cleanup_module(void)
{int i;dev_t devno = MKDEV(scull_major, scull_minor);if (scull_devices) {for (i = 0; i < scull_nr_devs; i++) {scull_trim(scull_devices + i);cdev_del(&scull_devices[i].cdev);}kfree(scull_devices);}#ifdef SCULL_DEBUGscull_remove_proc();
#endifunregister_chrdev_region(devno, scull_nr_devs);
}
初始化函数新增创建/proc文件逻辑
int scull_init_module(void)
{int result, i;dev_t dev = 0;if (scull_major) {dev = MKDEV(scull_major, scull_minor);result = register_chrdev_region(dev, scull_nr_devs, "scull");} else {result = alloc_chrdev_region(&dev, scull_minor, scull_nr_devs,"scull");scull_major = MAJOR(dev);}if (result < 0) {printk(KERN_WARNING "scull: can't get major %d\n", scull_major);return result;}scull_devices = kmalloc(scull_nr_devs * sizeof(struct scull_dev), GFP_KERNEL);if (!scull_devices) {result = -ENOMEM;goto fail;}memset(scull_devices, 0, scull_nr_devs * sizeof(struct scull_dev));for (i = 0; i < scull_nr_devs; i++) {scull_devices[i].quantum = scull_quantum;scull_devices[i].qset = scull_qset;scull_setup_cdev(&scull_devices[i], i);}#ifdef SCULL_DEBUGscull_create_proc();
#endifreturn 0;fail:scull_cleanup_module();return result;
}
三、编译和加载
1.Makefile文件
详细解释,请参考博客 https://blog.csdn.net/weixin_51019352/article/details/151835068 模块编译 一节
DEBUG = yifeq ($(DEBUG),y)DEBFLAGS = -O -g -DSCULL_DEBUG
elseDEBFLAGS = -O2
endifCFLAGS += $(DEBFLAGS)ifneq ($(KERNELRELEASE),)obj-m := scull.o
elseKERNELDIR ?= /lib/modules/$(shell uname -r)/buildPWD := $(shell pwd)default:$(MAKE) -C $(KERNELDIR) M=$(PWD) modulesendifclean:$(MAKE) -C $(KERNELDIR) M=$(PWD) cleanrm -f *.ko *.mod.c *.mod.o *.o .tmp_versions
2.scull_load和scull_unload文件
详细解释,请参考博客 https://blog.csdn.net/weixin_51019352/article/details/151870691 模块加载和卸载脚本 一节
scull_load文件
#!/bin/sh
module="scull"
device="scull"
mode="666"if grep -q '^staff:' /etc/group; thengroup="staff"
elsegroup="wheel"
fi/sbin/insmod ./$module.ko $* || exit 1major=$(awk "\$2==\"$module\" {print \$1}" /proc/devices)rm -f /dev/${device}[0-3]
mknod /dev/${device}0 c $major 0
mknod /dev/${device}1 c $major 1
mknod /dev/${device}2 c $major 2
mknod /dev/${device}3 c $major 3
ln -sf ${device}0 /dev/${device}
chgrp $group /dev/${device}[0-3]
chmod $mode /dev/${device}[0-3]
scull_unload文件
#!/bin/sh
module="scull"
device="scull"/sbin/rmmod $module $* || exit 1rm -f /dev/${device} /dev/${device}[0-3]
3.编译和加载模块
# 编译模块,默认DEBUG = y
make
# 赋予加载脚本执行权限
sudo chmod 744 scull_load
# 执行脚本
sudo ./scull_load
卸载模块
sudo chmod 744 scull_unload
sudo ./scull_unload
四、验证模块
1.确认模块已成功加载
# 确认模块是否已被加载
lsmod | grep scull
# scull 7308 0 预期输出# 确认内核是否已经有该设备
cat /proc/devices | grep scull
# 252 scull 预期输出# 确认用户空间是否已经创建相关设备文件
ls /dev/scull*
# /dev/scull /dev/scull0 /dev/scull1 /dev/scull2 /dev/scull3 预期输出
2.确认/proc/scullseq
文件是否创建
ls /proc/scullseq
3.确认/proc/scullseq
文件读取功能正常
echo "test" > /dev/scull0
echo "test" > /dev/scull1
echo "test" > /dev/scull2
echo "test" > /dev/scull3cat /proc/scullseq
预期输出
Device 0: qset 1000, q 4000, sz 5item at eabb6e34, qset at e95930000: e98b8000Device 1: qset 1000, q 4000, sz 5item at e9b1ac24, qset at e97330000: e9828000Device 2: qset 1000, q 4000, sz 5item at f163f59c, qset at e95380000: e959f000Device 3: qset 1000, q 4000, sz 5item at e8f57728, qset at e94fa0000: e9891000