itop-3568开发板驱动学习笔记(3) 字符设备(上)

《【北京迅为】itop-3568开发板驱动开发指南.pdf》 学习笔记

字符设备简介

字符设备是指在I/O传输过程中以字符为单位进行传输的设备,例如键盘,打印机等。在UNIX系统中,字符设备以特别文件方式在文件目录树中占据位置并拥有相应的结点。

——百度百科

字符设备可以使用与普通文件相同的文件操作命令(如打开、关闭、读和写等)来操作字符设备,它是 Linux 最基本的一类设备驱动。

申请字符设备号

Linux 驱动中可以使用下面两个函数来申请设备号,

#include <uapi/linux/fs.h>

extern int alloc_chrdev_region(dev_t *, unsigned, unsigned, const char *);
extern int register_chrdev_region(dev_t, unsigned, const char *);

alloc_chrdev_region() 用来动态申请设备号,register_chrdev_region() 用来静态申请设备号。

alloc_chrdev_region()

动态申请字符设备号

/**
 * alloc_chrdev_region() - register a range of char device numbers
 * @dev: output parameter for first assigned number
 *       获取到的设备号
 * @baseminor: first of the requested range of minor numbers
 *       请求的次设备号的最小值
 * @count: the number of minor numbers required
 *        申请设备的数量  
 * @name: the name of the associated device or driver
 *        申请设备的名称
 *
 * Allocates a range of char device numbers.  The major number will be
 * chosen dynamically, and returned (along with the first minor number)
 * in @dev.  Returns zero or a negative error code.
 *        申请成功返回0,失败返回负数
 */
int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count,
			const char *name)
{
    
    
	struct char_device_struct *cd;
	cd = __register_chrdev_region(0, baseminor, count, name);
	if (IS_ERR(cd))
		return PTR_ERR(cd);
	*dev = MKDEV(cd->major, cd->baseminor);
	return 0;
}

__register_chrdev_region 其实就是静态申请字符设备号的函数,所以动态申请字符设备号相当于调用了 register_chrdev_region(0, baseminor, count, name);

register_chrdev_region()

(静态)注册字符设备号

/**
 * register_chrdev_region() - register a range of device numbers
 * @from: the first in the desired range of device numbers; must include
 *        the major number.
 *         要申请设备号的最小值(需要包含主设备号)
 * @count: the number of consecutive device numbers required
 * 	      (连续的)设备号的数量
 * @name: the name of the device or driver.
 *         申请设备的名称
 * Return value is zero on success, a negative error code on failure.
 *         申请成功返回0,失败返回负数
 */
int register_chrdev_region(dev_t from, unsigned count, const char *name)
{
    
    
	struct char_device_struct *cd;
	dev_t to = from + count;
	dev_t n, next;

	for (n = from; n < to; n = next) {
    
    
		next = MKDEV(MAJOR(n)+1, 0);
		if (next > to)
			next = to;
		cd = __register_chrdev_region(MAJOR(n), MINOR(n),
			       next - n, name);
		if (IS_ERR(cd))
			goto fail;
	}
	return 0;
fail:
	to = n;
	for (n = from; n < to; n = next) {
    
    
		next = MKDEV(MAJOR(n)+1, 0);
		kfree(__unregister_chrdev_region(MAJOR(n), MINOR(n), next - n));
	}
	return PTR_ERR(cd);
}

dev_t 类型其实就是 unsigned int,即设备号的类型 32 位无符号整型,其中高 12 位表示主设备号,低 20 位表示次设备号,

#include <uapi/linux/kdev_t.h>

#define MINORBITS	20   // 次设备号位数
#define MINORMASK	((1U << MINORBITS) - 1)  // 次设备号掩码

#define MAJOR(dev)	((unsigned int) ((dev) >> MINORBITS)) // dev 右移 20 位得到主设备号
#define MINOR(dev)	((unsigned int) ((dev) & MINORMASK))  // dev 按位与 MINORMASK 得到次设备号
#define MKDEV(ma,mi)	(((ma) << MINORBITS) | (mi))      // MKDEV 用于合成设备号

unregister_chrdev_region()

unregister_chrdev_region() 是字符设备号的注销函数,原型如下,参数与 register_chrdev_region() 类似,

void unregister_chrdev_region(dev_t from, unsigned count)

注册字符设备

注册字符设备分为两步:初始化字符设备和添加字符设备,分别对应 cdev_init() 和 cdev_add()。

这两个函数都有一个叫 struct cdev 的参数,该结构体包含字符设备的一些属性,

struct cdev {
    
    
	struct kobject kobj;
	struct module *owner;
	const struct file_operations *ops;  //文件操作集合
	struct list_head list;              //(系统所有)字符设备链表
	dev_t dev;                          //设备号
	unsigned int count;                 //同一主设备的次设备号个数
};

file_operations 结构体包含文件操作的各种接口,如设备(文件)打开、读、写、关闭等操作。

cdev_init()

该函数初始化的其实是 cdev 结构体,主要是申请内容然后将 fops 参数绑定到 cdev 参数,

/**
 * cdev_init() - initialize a cdev structure
 * @cdev: the structure to initialize
 * @fops: the file_operations for this device
 *
 * Initializes @cdev, remembering @fops, making it ready to add to the
 * system with cdev_add().
 */
void cdev_init(struct cdev *cdev, const struct file_operations *fops)
{
    
    
	memset(cdev, 0, sizeof *cdev);
	INIT_LIST_HEAD(&cdev->list);
	kobject_init(&cdev->kobj, &ktype_cdev_default);
	cdev->ops = fops;   //初始化 cdev 的 ops 成员
}

cdev_add()

该函数的作用是将字符设备添加到系统,

/**
 * cdev_add() - add a char device to the system
 * @p: the cdev structure for the device
 * @dev: the first device number for which this device is responsible
 *      设备号(如果有多个项目,就填第一个设备的设备号
 * @count: the number of consecutive minor numbers corresponding to this
 *         device
 *      连续的次设备号数量
 *
 * cdev_add() adds the device represented by @p to the system, making it
 * live immediately.  A negative error code is returned on failure.
 *      返回0表示添加成功,返回负数表示添加失败
 */
int cdev_add(struct cdev *p, dev_t dev, unsigned count)
{
    
    
	int error;

	p->dev = dev;     //第一个设备号
	p->count = count; //设备号数量

	error = kobj_map(cdev_map, dev, count, NULL,
			 exact_match, exact_lock, p);
	if (error)
		return error;

	kobject_get(p->kobj.parent);

	return 0;
}

cdev_del()

cdev_del() 是 cdev_add() 的反向操作,原型如下,

void cdev_del(struct cdev *p)

创建设备节点

手动创建设备节点

手动创建设备节点的命令格式为: mknod NAME TYPE MAJOR MINOR

  • NAME :设备节点名
  • TYPE :设备类型,如:b–块设备,c–字符设备
  • MAJOR:主设备号
  • MINOR:次设备号

手动创建设备节点前,可以使用 cat /proc/devices 查看当前系统的设备号占用情况(避免设备号冲突),

在这里插入图片描述

这里我使用 mknod /dev/mknod_test c 200 0 创建了一个名为 mknod 的设备节点:

在这里插入图片描述
由于没有编写任何驱动,所以上面创建的设备节点只是一个空壳。

自动创建设备节点

设备节点的自动创建需要用到 udev,udev 简介如下:

udev 是Linux kernel 2.6系列的设备管理器。它主要的功能是管理/dev目录底下的设备节点。

Linux 传统上使用静态设备创建方法,因此大量设备节点在 /dev 下创建(有时上千个),而不管相应的硬件设备是否真正存在。通常这由一个MAKEDEV脚本实现,这个脚本包含了许多通过世界上(有幽默意味,注)每一个可能存在的设备相关的主设备号和次设备号对mknod程序的调用。采用udev的方法,只有被内核检测到的设备才会获取为它们创建的设备节点。因为这些设备节点在每次系统启动时被创建,他们会被贮存在ramfs(一个内存中的文件系统,不占用任何磁盘空间).设备节点不需要大量磁盘空间,因此它使用的内存可以忽略。

——百度百科

struct class

class 结构体包含了设备类的基本信息,创建设备节点前,必须先创建设备类。

struct class {
    
    
	const char		*name; //类名
	struct module		*owner; //所属模块

	struct class_attribute		*class_attrs; //类的属性
	struct device_attribute		*dev_attrs; //类的设备属性
	struct bin_attribute		*dev_bin_attrs; //二进制属性
	struct kobject			*dev_kobj; //用于标识类的设备属于字符设备还是块设备

	int (*dev_uevent)(struct device *dev, struct kobj_uevent_env *env);
	char *(*devnode)(struct device *dev, umode_t *mode);

	void (*class_release)(struct class *class);
	void (*dev_release)(struct device *dev);

	int (*suspend)(struct device *dev, pm_message_t state);
	int (*resume)(struct device *dev);

	const struct kobj_ns_type_operations *ns_type;
	const void *(*namespace)(struct device *dev);

	const struct dev_pm_ops *pm;

	struct subsys_private *p;
};

class_create()

class_create() 函数用来创建设备类,该类存放于 /sys/class/ 目录下。

#define class_create(owner, name)		\
({
      
      						\
	static struct lock_class_key __key;	\
	__class_create(owner, name, &__key);	\
})

参数 owner 为 struct module 类型的指针,用于指向要创建类的模块,一般赋值为 THIS_MODULE,name 为即将创建的类名。

函数返回 struct class * 类型的结构体。

class_destroy()

class_destroy() 用于删除设备类。

void class_destroy(struct class *cls)
{
    
    
	if ((cls == NULL) || (IS_ERR(cls)))
		return;

	class_unregister(cls);
}

参数 cls 为 class_create() 返回的指针。

struct device

device 结构体包含了设备文件的基本属性,

struct device {
    
    
	struct device		*parent; //父设备,一般情况下,是一些 bus 或 host,如果为 NULL,则该设备是顶层设备

	struct device_private	*p;

	struct kobject kobj; 
	const char		*init_name; /* initial name of the device */
	const struct device_type *type;

	struct mutex		mutex;	/* mutex to synchronize calls to
					 * its driver.
					 */

	struct bus_type	*bus;		/* type of bus device is on */
	struct device_driver *driver;	/* which driver has allocated this
					   device */
	void		*platform_data;	/* Platform specific data, device
					   core doesn't touch it */
	struct dev_pm_info	power;
	struct dev_pm_domain	*pm_domain;

#ifdef CONFIG_PINCTRL
	struct dev_pin_info	*pins;
#endif

#ifdef CONFIG_NUMA
	int		numa_node;	/* NUMA node this device is close to */
#endif
	u64		*dma_mask;	/* dma mask (if dma'able device) */
	u64		coherent_dma_mask;/* Like dma_mask, but for
					     alloc_coherent mappings as
					     not all hardware supports
					     64 bit addresses for consistent
					     allocations such descriptors. */

	struct device_dma_parameters *dma_parms;

	struct list_head	dma_pools;	/* dma pools (if dma'ble) */

	struct dma_coherent_mem	*dma_mem; /* internal for coherent mem
					     override */
#ifdef CONFIG_DMA_CMA
	struct cma *cma_area;		/* contiguous memory area for dma
					   allocations */
#endif
	/* arch specific additions */
	struct dev_archdata	archdata;

	struct device_node	*of_node; /* associated device tree node */
	struct acpi_dev_node	acpi_node; /* associated ACPI device node */

	dev_t			devt;	/* dev_t, creates the sysfs "dev" */
	u32			id;	/* device instance */

	spinlock_t		devres_lock;
	struct list_head	devres_head;

	struct klist_node	knode_class;
	struct class		*class;
	const struct attribute_group **groups;	/* optional groups */

	void	(*release)(struct device *dev);
	struct iommu_group	*iommu_group;
};

device_create()

device_create() 函数用于创建设备节点(在 class 类中创建一个设备属性文件,udev 会自动识别然后创建设备节点)

struct device *device_create(struct class *class, struct device *parent,
			     dev_t devt, void *drvdata, const char *fmt, ...)
{
    
    
	va_list vargs;
	struct device *dev;

	va_start(vargs, fmt);
	dev = device_create_vargs(class, parent, devt, drvdata, fmt, vargs);
	va_end(vargs);
	return dev;
}

参数:

  • class 指定设备的所属类
  • parent 指定设备的父设备,如果没有就指定为 NULL
  • devt 指定设备的设备号
  • drvdata 回调数据,没有就指定为 NULL
  • fmt 设备节点名称

返回值:struct device * 类型结构体

device_destroy()

device_destroy() 用来删除 class 类中的设备属性文件,udev 会自动识别然后将设备节点删除。

void device_destroy(struct class *class, dev_t devt)
{
    
    
	struct device *dev;

	dev = class_find_device(class, NULL, &devt, __match_devt);
	if (dev) {
    
    
		put_device(dev);
		device_unregister(dev);
	}
}

class 指定设备的所属类,devt 指定设备的设备号

字符设备驱动框架(实验)

文件操作集合

在上文”注册字符设备“部分提到的 cdev_init() 函数中,对 struct cdev 和 struct file_operations (文件操作集合)进行了链接,这里的 struct file_operations 是系统调用和驱动程序之间的桥梁,它的每一个成员对应了一个系统调用,其定义如下:

struct file_operations {
    
    
	struct module *owner;
	loff_t (*llseek) (struct file *, loff_t, int);
	ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
	ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
	ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
	ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
	int (*readdir) (struct file *, void *, filldir_t);
	unsigned int (*poll) (struct file *, struct poll_table_struct *);
	long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
	long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
	int (*mmap) (struct file *, struct vm_area_struct *);
	int (*open) (struct inode *, struct file *);
	int (*flush) (struct file *, fl_owner_t id);
	int (*release) (struct inode *, struct file *);
	int (*fsync) (struct file *, loff_t, loff_t, int datasync);
	int (*aio_fsync) (struct kiocb *, int datasync);
	int (*fasync) (int, struct file *, int);
	int (*lock) (struct file *, int, struct file_lock *);
	ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
	unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
	int (*check_flags)(int);
	int (*flock) (struct file *, int, struct file_lock *);
	ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
	ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
	int (*setlease)(struct file *, long, struct file_lock **);
	long (*fallocate)(struct file *file, int mode, loff_t offset,
			  loff_t len);
	int (*show_fdinfo)(struct seq_file *m, struct file *f);
	/* get_lower_file is for stackable file system */
	struct file* (*get_lower_file)(struct file *f);
};

在这个结构体中包含了我们常见的文件操作函数,如 open、read、write等,其中最基本的成员为下面这五个:

static struct file_operations fops = {
    
    
	.owner = THIS_MODULE, //将 owner 成员指向本模块,可以避免在模块的操作正在被使用时卸载该模块
	.open = chrdev_open,  //将 open 成员指向 chrdev_open()函数
	.read = chrdev_read,  //将 read 成员指向 chrdev_read()函数
	.write = chrdev_write,//将 write 字段指向 chrdev_write()函数
	.release = chrdev_release,//将 release 字段指向 chrdev_release()函数
	};

release() 对应 close() 系统调用函数。

驱动程序框架

驱动代码:

#include <linux/init.h>
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/kdev_t.h>
#include <linux/cdev.h>

// open()
static int  chrdev_open(struct inode *inode , struct file *file )
{
    
    
	printk("chrdev_open.\n");
	return 0;
}

// close()
static int chrdev_release(struct inode *inode, struct file *file)
{
    
    
	printk("chrdev_release.\n");
	return 0;
}

// write()
static ssize_t chrdev_write(struct file *file , const char __user *buf, size_t size, loff_t *off)
{
    
    
	printk("chrdev_write.\n");
	return 0;
}

// read()
static  ssize_t chrdev_read(struct file *file, char __user *buf, size_t size, loff_t *off)
{
    
    
	printk("chrdev_read.\n");
	return 0;
}

static dev_t dev_num; // 设备号
static struct cdev st_cdev; // 要注册的字符设备
static struct class *st_class; // 要创建的设备类

static struct file_operations chrdev_fops = {
    
    
	.owner = THIS_MODULE, //将 owner 成员指向本模块,可以避免在模块的操作正在被使用时卸载该模块
	.open = chrdev_open,  //将 open 成员指向 chrdev_open()函数
	.read = chrdev_read,  //将 read 成员指向 chrdev_read()函数
	.write = chrdev_write,//将 write 字段指向 chrdev_write()函数
	.release = chrdev_release,//将 release 字段指向 chrdev_release()函数
};
// 驱动入口函数
static int __init chrdev_init(void)
{
    
    
	int ret, major, minor;

	// 自动获取设备号(只申请一个,次设备号从 0 开始)
	ret = alloc_chrdev_region(&dev_num, 0, 1, "chrdev_test");
	if(ret < 0)
	{
    
    
		printk("alloc chrdev region failed.\n");
		return 0;
	}
	printk("alloc chrdev region successfully.\n");
	major = MAJOR(dev_num); // 获取主设备号
	minor = MINOR(dev_num); // 获取次设备号
	printk("major is %d.\nminor is %d\n", major, minor);
	cdev_init(&st_cdev, &chrdev_fops); // 初始化字符设备
	st_cdev.owner = THIS_MODULE; // 将 owner 成员指向本模块,可以避免模块 st_cdev 被使用时卸载模块
	ret = cdev_add(&st_cdev, dev_num, 1); // 将字符设备添加到系统
	if(ret < 0)
	{
    
    
		printk("cdev add failed.\n");
		return 0;
	}
	printk("cdev add successfully.\n");
	st_class = class_create(THIS_MODULE, "chrdev_class"); // 创建设备类
	device_create(st_class, NULL, dev_num, NULL, "chrdev_device"); // 创建设备
	return 0;
}

// 驱动出口函数
static void __exit chrdev_exit(void)
{
    
    
	device_destroy(st_class, dev_num); // 删除设备
	class_destroy(st_class); //删除设备类
	cdev_del(&st_cdev); // 删除字符设备
	unregister_chrdev_region(dev_num, 1); // 注销设备号
	printk("chrdev_exit.\n");
}

module_init(chrdev_init);  //注册入口函数
module_exit(chrdev_exit);  //注册出口函数
MODULE_LICENSE("GPL v2");  //同意GPL协议
MODULE_AUTHOR("xiaohui");  //作者信息

APP 测试代码(应用层)

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

#define DEV_FILE "/dev/chrdev_device"
int main()
{
    
    
	int fd;
	char buf[1024];

	// 打开设备文件
	fd = fd = open(DEV_FILE, O_RDWR);
	if(fd < 0)
	{
    
    
		printf("%s open failed.\n", DEV_FILE);
		return 0;
	}
	printf("%s open successfully.\n", DEV_FILE);
	
	// 调用 write()
	write(fd, "test", 4);

	// 调用 read()
	read(fd, buf, 1024);

	// 关闭设备文件
	close(fd);
	return 0;
}

Makefile 文件:

我选择在 x86 机器上测试,

#目标文件,与驱动源文件同名,编译成模块
obj-m := chrdev_test.o

#架构平台选择
#export ARCH=arm64

#编译器选择
#export CROSS_COMPILE=aarch64-linux-gnu-

#内核目录
#KDIR := /home/topeet/Linux/rk356x_linux/kernel/
KDIR := /lib/modules/$(shell uname -r)/build

#编译模块
all:
	make -C $(KDIR) M=$(shell pwd) modules
	gcc app.c -o app	
	
#清除编译文件
clean:
	make -C $(KDIR) M=$(shell pwd) clean
	rm app

测试结果:

安装驱动后,内核打印了驱动里申请的设备号

在这里插入图片描述

在 /dev 目录下生成了设备节点,同时在 /syc/class/misc 目录下也有对应的设备信息,

在这里插入图片描述

运行应用层程序后,内核层成功运行 open()、write()等函数,

在这里插入图片描述

杂项设备

简介

常见的字符设备都能分为不同的类别,比如:LED、蜂鸣器、按键、键盘、摄像头、液晶屏等。在 Linux 系统的 /sys/class 下有以设备类命名的文件夹,比如 gpio/、tty/(串口)、input/(键盘鼠标等输入设备)等,

在这里插入图片描述
而在上面的文件夹中,还有一个叫 “misc” 的文件夹(miscellaneous 的缩写),这里面存放的就是当前系统的杂项设备文件,

在这里插入图片描述

简单的说,我们可以把无法明确归类的设备定义为杂项设备。

相对于字符设备,杂项设备有以下两个特点:

  1. 杂项设备的主设备号固定为 10,而普通字符设备申请设备号时都需要额外消耗主设备号。
  2. 杂项设备不需要创建字符设备类(class),注册后可以直接在 /dev 下生成设备节点。

杂项设备的结构体定义如下:

struct miscdevice {
    
    
	int minor; // 子设备号
	const char *name; // 设备名
	const struct file_operations *fops; // 文件操作集 
	struct list_head list;
	struct device *parent;
	struct device *this_device;
	const struct attribute_group **groups;
	const char *nodename;
	umode_t mode;
};

定义一个 misc 设备,一般只需要填充 minor、name、fops 三个成员变量。

minor 这个成员如果为 MISC_DNAMIC_MINOR,则表示自动分配子设备号。

杂项设备的注册和注销

杂项设备的注册与注销相对字符设备而言,更加简便,只需要使用 misc_deregister()misc_deregister() 即可,

int misc_register(struct miscdevice *misc);
int misc_deregister(struct miscdevice *misc);

参数:misc 为 杂项设备的结构体指针

返回值:操作成功返回 0,失败返回负数

杂项设备驱动框架(实验)

驱动代码:

#include <linux/init.h>
#include <linux/module.h>
#include <linux/miscdevice.h>
#include <linux/fs.h>


// open()
static int  misc_open(struct inode *inode , struct file *file )
{
    
    
	printk("misc_open.\n");
	return 0;
}

// close()
static int misc_release(struct inode *inode, struct file *file)
{
    
    
	printk("misc_release.\n");
	return 0;
}

struct file_operations misc_fops =
{
    
    
	.owner = THIS_MODULE, //将 owner 成员指向本模块,可以避免在模块的操作正在被使用时卸载该模块
	.open = misc_open,  //将 open 成员指向 misc_open()函数
	.release = misc_release,//将 release 字段指向 misc_release()函数
};

struct miscdevice misc_dev = 
{
    
    
	.minor = MISC_DYNAMIC_MINOR, // 动态申请次设备号
	.name = "misc_test",
	.fops = &misc_fops,
};

// 驱动入口函数
static int __init misc_init(void)
{
    
    
	int ret;
	// 注册杂项设备
	ret = misc_register(&misc_dev);
	if(ret < 0)
	{
    
    
		printk("misc register failed.\n");
		return 0;
	}
	printk("misc register successfully.\n");
	return 0;
}

static void __exit misc_exit(void)
{
    
    
	// 注销杂项设备
	misc_deregister(&misc_dev);
	printk("misc_exit.\n");
}

module_init(misc_init);  //注册入口函数
module_exit(misc_exit);  //注册出口函数
MODULE_LICENSE("GPL v2");  //同意GPL协议
MODULE_AUTHOR("xiaohui");  //作者信息

Makefile:

#目标文件,与驱动源文件同名,编译成模块
obj-m := misc_test.o

#架构平台选择
#export ARCH=arm64

#编译器选择
#export CROSS_COMPILE=aarch64-linux-gnu-

#内核目录
#KDIR := /home/topeet/Linux/rk356x_linux/kernel/
KDIR := /lib/modules/$(shell uname -r)/build

#编译模块
all:
	make -C $(KDIR) M=$(shell pwd) modules
	
#清除编译文件
clean:
	make -C $(KDIR) M=$(shell pwd) clean

测试结果:

安装和卸载驱动,

在这里插入图片描述

驱动加载后,在 /dev 目录下生成了设备节点,同时在 /syc/class/misc 目录下也有对应的设备信息,

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/weixin_43772810/article/details/129160810