全部博文(32)
2012年(32)
分类:
2012-10-17 11:48:44
原文地址:Linux设备操作及驱动分析 作者:sjj0412
(一)从应用程序用户角度谈设备操作。
我们都知道,用户是最霸道的,是上帝,他们想用最少的字母(code)完成最多的动作,他希望只要他知道设备文件的路径,他就要象操作文件一样用Open,write,read,ioctl等函数操作设备。下面我们就来讲讲用户使用这些函数后设备是怎么动起来的.地球人都知道用户一般采用如下方式操作设备。
Int fd=open(“/dev/funk”,O_RDWR);
Write(fd,buff,count);
Read(fd,buff,count);
讲到这里,大家可能会说了,/dev/funk哪里冒出来的啊,这个当然是创造出来的啊,那是何时何地以何种方式创造出来的呢,我们后面再分析,先飘过。
(1)先讲讲open.
Open后就会调用sys_open(),sys_open()会做什么事情呢,首先当然是找到/dev/funk这个文件,至于是如何找到这个文件的,我这里就不说了,大家可以看看文件系统章节. 到什么程度才算是找到文件了呢,当然是获得了这个文件的Inode了。从这个时候开始,一般文件和设备文件分道扬镳了,设备文件与文件系统暂时脱离关系了。找到设备文件究竟要干什么呢?设备文件同一般文件的区别在哪里啊?设备文件和一般文件的区别是设备文件没有内容有设备号,设备号这个东西好,设备号是设备的身份证,以后的旅途可都指望它带路了,现在大家知道找到设备文件的作用了吧----就是获取设备的设备号。刚才我说过获得设备文件的inode就算是找到文件了,那看来这个Inode中肯定有设备号,事实上那确实!
那这个设备号是如何到inode里的呢,这个就是在找文件的过程中得到的。我们知道在找到设备文件的最后一步是读取设备文件的内容,即用设备文件的原始inode--- ext3_inode填充VFS层的inode,是在ext3_read_inode中实现的。
Open->………..->ext3_read_inode
void ext3_read_inode(struct inode * inode)
{
struct ext3_iloc iloc;
struct ext3_inode *raw_inode;
struct ext3_inode_info *ei = EXT3_I(inode);
struct buffer_head *bh;
int block;
//获取原始inode
if (__ext3_get_inode_loc(inode, &iloc, 0))
goto bad_inode;
bh = iloc.bh;
raw_inode = ext3_raw_inode(&iloc);
//填充VFS层的Inode
inode->i_mode = le16_to_cpu(raw_inode->i_mode);
inode->i_uid = (uid_t)le16_to_cpu(raw_inode->i_uid_low);
inode->i_gid = (gid_t)le16_to_cpu(raw_inode->i_gid_low);
if(!(test_opt (inode->i_sb, NO_UID32))) {
inode->i_uid |= le16_to_cpu(raw_inode->i_uid_high) << 16;
inode->i_gid |= le16_to_cpu(raw_inode->i_gid_high) << 16;
}
inode->i_nlink = le16_to_cpu(raw_inode->i_links_count);
inode->i_size = le32_to_cpu(raw_inode->i_size);
inode->i_atime.tv_sec = le32_to_cpu(raw_inode->i_atime);
inode->i_ctime.tv_sec = le32_to_cpu(raw_inode->i_ctime);
inode->i_mtime.tv_sec = le32_to_cpu(raw_inode->i_mtime);
inode->i_atime.tv_nsec = inode->i_ctime.tv_nsec = inode->i_mtime.tv_nsec = 0;
//这中间省略了代码
//重头戏开始,i_blok[]数组是文件内容,对于设备文件内容就是设备号。
for
(block = 0; block < EXT3_N_BLOCKS; block++)
ei->i_data[block]
= raw_inode->i_block[block];
//下面是判断文件类型,我们这里只关注设备文件,这个就会调用init_special_inode
if (S_ISREG(inode->i_mode)) {
inode->i_op = &ext3_file_inode_operations;
inode->i_fop = &ext3_file_operations;
ext3_set_aops(inode);
} else if (S_ISDIR(inode->i_mode)) {
inode->i_op =
&ext3_dir_inode_operations;
inode->i_fop = &ext3_dir_operations;
} else if (S_ISLNK(inode->i_mode)) {
if (ext3_inode_is_fast_symlink(inode))
inode->i_op =
&ext3_fast_symlink_inode_operations;
else {
inode->i_op =
&ext3_symlink_inode_operations;
ext3_set_aops(inode);
}
} else {
inode->i_op =
&ext3_special_inode_operations;
if (raw_inode->i_block[0])
init_special_inode(inode, inode->i_mode,
old_decode_dev(le32_to_cpu(raw_inode->i_block[0])));
else
init_special_inode(inode,
inode->i_mode,
new_decode_dev(le32_to_cpu(raw_inode->i_block[1])));
}
..................
.........................
}
此时我们已经解决一座大山,那就是设备文件的设备号,但是有设备号又怎么了,别忘了我们的终极目标,获取设备文件的操作函数集f_ops给设备文件在应用层的代表----file结构,(f_ops有write.read等等),这就要看ext3_read_inode 中的init_special_inode函数了。
Open->………..->ext3_read_inode-> init_special_inode
void
init_special_inode(struct inode *inode, umode_t mode, dev_t rdev)
{
inode->i_mode = mode;
if (S_ISCHR(mode)) {
inode->i_fop
= &def_chr_fops;
inode->i_rdev = rdev;
} else if (S_ISBLK(mode)) {
inode->i_fop
= &def_blk_fops;
inode->i_rdev = rdev;
} else if (S_ISFIFO(mode))
inode->i_fop
= &def_fifo_fops;
else if (S_ISSOCK(mode))
inode->i_fop = &bad_sock_fops;
else
printk(KERN_DEBUG
"init_special_inode: bogus i_mode (%o)\n",
mode);
}
看到这个函数,各位估计会呵呵乐了吧,这么短,这么干脆,不可思议吧,这在Linux家族可不常见啊,大家可以乘机在此休息休息。
上面着色的部分就是处理设备操作函数有关的,大家别看现在函数是赋给Inode,与file无关,但有倒是一步一个脚印嘛,慢慢来不着急。说不着急你肯定跟我急,现在就说说函数怎么关联给file,其实当获得文件的inode(注意inode->i_fop已经赋值)后,在返回时会执行inode->i_fop->open();如果设备是字符设备,由上面可知i_fop是def_chr_fops,此时就会调用chrdev_open。
const struct file_operations def_chr_fops = {
.open = chrdev_open,
};
上面可以看出def_chr_fops这个函数集只有一个open函数,没有write,read等具体操作函数,肯定是不能直接赋给file->f_ops的,所以它只是个桥梁,用来给file->f_ops赋予真正的函数集值,下面来看看chrdev_open是不是这样实现的。
Open->………..->ext3_read_inode-> init_special_inode->…->
chrdev_open
int chrdev_open(struct
inode * inode, struct file * filp)
{
struct cdev *p;
struct cdev *new = NULL;
int ret = 0;
spin_lock(&cdev_lock);
p =
inode->i_cdev;
if (!p) {
struct
kobject *kobj;
int idx;
spin_unlock(&cdev_lock);
//由设备号找到代表该设备的内核代表cdev中的成员kobj
kobj = kobj_lookup(cdev_map,
inode->i_rdev, &idx);
if (!kobj)
return -ENXIO;
//由上面找到的kobj找到cdev,cdev中有函数集f_ops
new = container_of(kobj, struct
cdev, kobj);
spin_lock(&cdev_lock);
p = inode->i_cdev;
if (!p) {
inode->i_cdev = p = new;
inode->i_cindex = idx;
list_add(&inode->i_devices,
&p->list);
new = NULL;
} else if (!cdev_get(p))
ret = -ENXIO;
} else if (!cdev_get(p))
ret = -ENXIO;
spin_unlock(&cdev_lock);
cdev_put(new);
if (ret)
return ret;
//给file的函数集赋值
filp->f_op = fops_get(p->ops);
if (!filp->f_op) {
cdev_put(p);
return -ENXIO;
}
if (filp->f_op->open) {
lock_kernel();
ret =
filp->f_op->open(inode,filp);
unlock_kernel();
}
if (ret)
cdev_put(p);
return ret;
}
到此设备文件在应用层的代表file的重要成员f_ops就找到了,Open函数也就告一段落了。可能你又会问了,cdev是何时创建,它的f_ops是何时赋值的呢,这个到后面再说。
其实在出现cdev以前,chrdev_open非常简单。
int chrdev_open(struct inode * inode, struct file * filp)
{
int i;
i = MAJOR(inode->i_rdev);
if (i >= MAX_CHRDEV || !chrdevs[i].fops)
return -ENODEV;
filp->f_op = chrdevs[i].fops;
if (filp->f_op->open)
return filp->f_op->open(inode,filp);
return 0;
}
是用chrdevs[]数组.fops存储函数集,其实在有cdev的linux2.6版本中,chrdevs[]数组依然存在,只不过英雄也有老的时候,它已经不再具有存储file->fops功能,仅仅是在注册设备号的时候判断这个设备号是否已经被占用了,是通过chrdev[]==null来判断的。
for (i = ARRAY_SIZE(chrdevs)-1; i > 0; i--) {
if (chrdevs[i] == NULL)
break;
}
所以说趁现在年轻,多做做事,否则老了??英雄也变狗熊了。
(2)再讲讲write。
当调用write时,就会转到系统调用sys_write,
Write->sys-write,这时file->ops要发挥作用了。
asmlinkage
ssize_t sys_write(unsigned int fd, const char __user * buf, size_t count)
{
struct file *file;
ssize_t ret = -EBADF;
int fput_needed;
//由文件号得到file结构,它里面可都是宝贝,其中ops是最重要的。
file =
fget_light(fd, &fput_needed);
if (file) {
//file结构体中的f_ops
loff_t pos = file_pos_read(file);//这个函数就是 return file->f_pos;
//这个即将调用具体设备的write函数了
ret =
vfs_write(file, buf, count, &pos);
file_pos_write(file, pos);
fput_light(file, fput_needed);
}
return ret;
}
vfs_write是很重要的。
ssize_t
vfs_write(struct file *file, const char __user *buf, size_t count, loff_t *pos)
{
ssize_t ret;
//判断文件读写权限
if (!(file->f_mode & FMODE_WRITE))
return -EBADF;
if (!file->f_op ||
(!file->f_op->write && !file->f_op->aio_write))
return -EINVAL;
if (unlikely(!access_ok(VERIFY_READ, buf,
count)))
return -EFAULT;
ret = rw_verify_area(WRITE, file, pos,
count);
if (ret >= 0) {
count = ret;
ret = security_file_permission
(file, MAY_WRITE);
if (!ret) {
//我们期待已久的场景出现了,设备的具体操作函数调用了
if
(file->f_op->write)
ret = file->f_op->write(file, buf, count, pos);
else
ret =
do_sync_write(file, buf, count, pos);
if (ret > 0) {
fsnotify_modify(file->f_path.dentry);
current->wchar +=
ret;
}
current->syscw++;
}
}
return ret;
}
此时write函数就完成任务了,具体的写操作就交给设备的write函数去操作了。
(3)read函数也不能忘了
Read调用后也是调用系统调用,进入sys_read.
asmlinkage
ssize_t sys_read(unsigned int fd, char __user * buf, size_t count)
{
struct file *file;
ssize_t ret = -EBADF;
int fput_needed;
//由文件号得到file结构。
file =
fget_light(fd, &fput_needed);
if (file) {
//file结构体中的f_ops
loff_t pos = file_pos_read(file);//这个函数就是 return file->f_pos;
//这个即将调用具体设备的read函数了
ret =
vfs_read(file, buf, count, &pos);
file_pos_write(file, pos);
fput_light(file, fput_needed);
}
return ret;
}
和vfs_write差不多,只是它调用f_ops->read
ssize_t
vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos)
{
ssize_t ret;
if (!(file->f_mode & FMODE_READ))
return -EBADF;
if (!file->f_op ||
(!file->f_op->read && !file->f_op->aio_read))
return -EINVAL;
if (unlikely(!access_ok(VERIFY_WRITE,
buf, count)))
return -EFAULT;
ret = rw_verify_area(READ, file, pos,
count);
if (ret >= 0) {
count = ret;
ret = security_file_permission
(file, MAY_READ);
if (!ret) {
//调用设备的read操作函数
if (file->f_op->read)
ret = file->f_op->read(file, buf, count, pos);
else
ret =
do_sync_read(file, buf, count, pos);
if (ret > 0) {
fsnotify_access(file->f_path.dentry);
current->rchar +=
ret;
}
current->syscr++;
}
}
return ret;
}
此时read函数就完成任务了,具体的写操作就交给设备的read函数去操作了。
总结一下,设备文件操作关键是获得设备的设备号和操作函数。其实blk块设备也差不多,只是它的内核代表是block_device结构,而字符设备是cdev。在出现gendisk前,block_device包含了设备的操作集,但linux2.6后出现,又变了,由gendisk结构里面的fops保存设备操作集。
(二)从内核开发人员看设备操作。
刚才前面提到了两个问题,
1)一个是设备文件怎么创造的。
2)设备文件的内核代表及他们中操作函数集fops什么时候赋值的,分两种:
linxu2.6前即kobject(cdev)出现以前是:
内核代表:chrdev[](字符设备),block_device(块设备) 。
linux2.6后:
内核代表: cdev (字符设备), gendisk(块设备)
其实这两个问题都是驱动开发人员解决,因为这些要和具体硬件打交道,否则要应用程序员做那他们就不是在天堂了,去地狱了,当然也不能全交给操作系统,因为设备是各式各样的,且多,操作系统能忙的过来不,操作系统只提供公共的接口,特殊的就得驱动开发人员搞定了。所以地球上就多了一项职业叫驱动工程师,嵌入式也就有了发展。
那么驱动开发人员是如何做到的呢。
我们一个一个问题来解。首先看第一个问题。
(1)
设备文件怎么创造的。
我酷爱Linux,也被它整的没脾气,它提供开放系统,大家都好,你好我好大家好,但是发展快,内核一天一个样,这不设备文件创建方式就经历了好几代。
1) 通过mknod创建,估计这是大家最为熟悉的,因为大家写过模块的都干过这事,记忆忧心啊,其方式一般是 mknod funk c 10 20 ,funk是设备名,10是主设备号,20是次设备号。 大家可以明显看出这种方式的优点,那就是明明白白,应用程序员都可以看到。缺点大家也是很容易想到的,就是你在创建这个设备文件时,你得知道你的驱动申请的设备号,否则,当你打开这个设备,用设备号去找驱动时就会出错。所以写过模块或驱动的人都知道,当用insmod载入模块是,要建设备文件前要看cat /proc/devices中你的驱动申请的设备号,然后再根据这个创建设备文件。
还有是,/dev下的设备文件对应的设备可能根本就没有,因为可能驱动模块给卸载,而对应的设备文件没有删除,这样就有了下面的两种方式,即动态创建设备文件。
2) 在Linux2.4里于是就出现了devfs这种文件系统,这个文件系统是在启动时创建的,然后挂载到/dev文件夹,不知道大家看到挂载到/dev文件夹这句话有何感想。反正我是觉得差别可大多了,首先,我们访问/dev中的文件时就不是访问ext3格式的文件,而是在访问devfs文件系统中的文件,要以devfs文件系统的操作来看文件访问,这一角度是很重要的。然后我们来讲这一方式是如何动态创建设备文件。
是通过在模块载入时调用:devfs_handle_t devfs_register
devfs_handle_t devfs_register
(devfs_handle_t dir, const char *name,
unsigned int flags,
unsigned int major, unsigned int minor,
umode_t mode, void *ops, void *info)
{
char devtype = S_ISCHR (mode) ? DEVFS_SPECIAL_CHR : DEVFS_SPECIAL_BLK;
int err;
kdev_t
devnum = NODEV;
struct devfs_entry *de;
if
(name == NULL)
{
PRINTK
("(): NULL name pointer\n");
return
NULL;
}
if
(ops == NULL)
{
if
( S_ISBLK (mode) ) ops = (void *) get_blkfops (major);
if
(ops == NULL)
{
PRINTK ("(%s): NULL ops
pointer\n", name);
return NULL;
}
PRINTK
("(%s): NULL ops, got %p from major table\n", name, ops);
}
if
( S_ISDIR (mode) )
{
PRINTK
("(%s): creating directories is not allowed\n", name);
return
NULL;
}
if
( S_ISLNK (mode) )
{
PRINTK
("(%s): creating symlinks is not allowed\n", name);
return
NULL;
}
if
( ( S_ISCHR (mode) || S_ISBLK (mode) ) &&
(flags & DEVFS_FL_AUTO_DEVNUM) )
{
if
( kdev_none ( devnum = devfs_alloc_devnum (devtype) ) )
{
PRINTK ("(%s): exhausted %s device
numbers\n",
name, S_ISCHR (mode) ? "char" :
"block");
return NULL;
}
major
= major (devnum);
minor
= minor (devnum);
}
if
( ( de = _devfs_prepare_leaf (&dir, name, mode) ) == NULL )
{
PRINTK
("(%s): could not prepare leaf\n", name);
if
( !kdev_none (devnum) ) devfs_dealloc_devnum (devtype, devnum);
return
NULL;
}
if
( S_ISCHR (mode) || S_ISBLK (mode) )
{
de->u.fcb.u.device.major
= major;
de->u.fcb.u.device.minor
= minor;
de->u.fcb.autogen
= kdev_none (devnum) ? FALSE
: TRUE;
}
else if ( !S_ISREG (mode) )
{
PRINTK
("(%s): illegal mode: %x\n", name, mode);
devfs_put
(de);
devfs_put
(dir);
return
(NULL);
}
de->info = info;
if
(flags & DEVFS_FL_CURRENT_OWNER)
{
de->inode.uid
= current->uid;
de->inode.gid
= current->gid;
}
else
{
de->inode.uid
= 0;
de->inode.gid
= 0;
}
de->u.fcb.ops = ops;
de->u.fcb.auto_owner = (flags & DEVFS_FL_AUTO_OWNER) ? TRUE :
FALSE;
de->u.fcb.aopen_notify = (flags & DEVFS_FL_AOPEN_NOTIFY) ? TRUE :
FALSE;
de->hide = (flags & DEVFS_FL_HIDE) ? TRUE : FALSE;
................
..................
} /* End Function devfs_register */
这个函数长吧,其实这个函数就是在devfs文件系统中中创建一个文件(设备文件),(如果大家看看ext3文件系统中文件创建过程,保证你会呼啸长叹,还是devfs好)注意这个文件是devfs中的文件,所以用的devfs中操作函数,由于是挂在/dev下,所以用户就会在/dev下看到创建的设备文件。
当模块注销时会调用static void _devfs_unregister (struct devfs_entry *dir, struct
devfs_entry *de),就会删除相应设备文件。从上面可知这些都是在驱动中实现的,所以对应用程序用户而言,这些设备文件是自动生成和删除的。
但是后来,不知道是谁认为这个又不好,说什么设备名字对同一设备在不同时刻插入不同计算机很大几率会得到不定的名称(什不是有点拗口,I think so),并且希望在用户级实现设备文件自动生成,删除,所以又搞了个sysfs文件系统。
3) sysfs文件系统下设备文件创建:
当一个设备发现匹配的驱动或驱动发现匹配的设备时就会调用这个设备的kset的hotplug函数创建一些事件环境变量,然后运行在用户级的udev会察觉到,然后就会根据环境变量值创建具体设备的设备文件,详细过程大家可以看看udev机制,这个估计又得讲一大把啊。
说完第一个问题,来说第二个问题。
(2)
设备文件的内核代表创建及设备操作函数绑定。
前面已经提到有两种情况,我们就分两种情况讨论:
1) kobject出现前(linux2.6前):
字符设备通过register_chrdev
int register_chrdev(unsigned int
major, const char * name, struct file_operations *fops)
{
if
(major == 0) {
write_lock(&chrdevs_lock);
for
(major = MAX_CHRDEV-1; major > 0; major--) {
//具体设备号的chrdevs赋值包括fops
if (chrdevs[major].fops == NULL) {
chrdevs[major].name = name;
chrdevs[major].fops = fops;
write_unlock(&chrdevs_lock);
return major;
}
}
write_unlock(&chrdevs_lock);
return
-EBUSY;
}
if
(major >= MAX_CHRDEV)
return
-EINVAL;
write_lock(&chrdevs_lock);
if
(chrdevs[major].fops && chrdevs[major].fops != fops) {
write_unlock(&chrdevs_lock);
return
-EBUSY;
}
chrdevs[major].name
= name;
chrdevs[major].fops
= fops;
write_unlock(&chrdevs_lock);
return
0;
}
块设备通过
int register_blkdev(unsigned int major, const
char * name, struct block_device_operations *bdops)
{
if
(major == 0) {
for
(major = MAX_BLKDEV-1; major > 0; major--) {
if (blkdevs[major].bdops == NULL) {
blkdevs[major].name = name;
blkdevs[major].bdops = bdops;
return major;
}
}
return
-EBUSY;
}
if
(major >= MAX_BLKDEV)
return
-EINVAL;
if
(blkdevs[major].bdops && blkdevs[major].bdops != bdops)
return
-EBUSY;
blkdevs[major].name
= name;
blkdevs[major].bdops
= bdops;
return
0;
}
但到了Linux2.6时代,Kobject时代,一切都变了,所有的设备在设备模型里是由kobject代表的,然后在字符设备是在此基础加上fops及相关成员的构成了cdev,然后在块设备是在此基础加上fops及相关成员的构成了gendisk,我们来看看他们的结构体。
struct cdev {
struct kobject kobj;
struct
module *owner;
//字符设备操作函数集存储指针
const struct file_operations *ops;
struct
list_head list;
dev_t
dev;
unsigned
int count;
};
struct gendisk {
int
major; /* major number of
driver */
int
first_minor;
int
minors; /* maximum number of minors,
=1 for
*
disks that can't be partitioned. */
char
disk_name[32]; /* name of
major driver */
struct
hd_struct **part; /* [indexed by minor] */
int
part_uevent_suppress;
//块设备操作函数集存储指针
struct block_device_operations *fops;
struct
request_queue *queue;
void
*private_data;
sector_t
capacity;
int
flags;
struct
device *driverfs_dev;
struct kobject kobj;
struct
kobject *holder_dir;
struct
kobject *slave_dir;
struct
timer_rand_state *random;
int
policy;
atomic_t
sync_io; /* RAID */
unsigned
long stamp;
int
in_flight;
#ifdef CONFIG_SMP
struct
disk_stats *dkstats;
#else
struct
disk_stats dkstats;
#endif
};
这两个结构体一般在驱动的初始化中被分配和赋值:对于字符设备如下
int init_module(void)
{
int
err;
dev_t
devid ;
//分配设备号
alloc_chrdev_region(&devid, 0, 1, "chardev");
major =
MAJOR(devid);
//注册cdev,及给设备注册操作函数,因为cdev中又设备号,和操作函数,由设备号就可以得到操作函数。并且将是以kobject为单位挂在kcobj_map链上,而块设备
my_cdev
= cdev_alloc();
//注意fops
cdev_init(my_cdev, &fops);
err = cdev_add(my_cdev, devid, 1);
if
(err)
{
printk(KERN_INFO "I was assigned major number %d.\n", major);
return -1;
}
printk("major number is %d\n", MAJOR(devid));
return
SUCCESS;
}
块设备我就不说了,大家看看。
struct file_operations *fops)
{
struct char_device_struct *cd;
struct cdev *cdev;
char *s;
int err = -ENOMEM;
cd = __register_chrdev_region(major, 0, 256, name);
if (IS_ERR(cd))
return PTR_ERR(cd);
cdev = cdev_alloc();
if (!cdev)
goto out2;
cdev->owner = fops->owner;
cdev->ops = fops;
kobject_set_name(&cdev->kobj, "%s", name);
for (s = strchr(kobject_name(&cdev->kobj),'/'); s; s = strchr(s, '/'))
*s = '!';
err = cdev_add(cdev, MKDEV(cd->major, 0), 256);
if (err)
goto out;
cd->cdev = cdev;
return major ? 0 : cd->major;
out:
kobject_put(&cdev->kobj);
out2:
kfree(__unregister_chrdev_region(cd->major, 0, 256));
return err;
}
总结:
上面讲的都是应用程序能够直接访问(即通过设备文件访问的到)的设备,其实还有很多不能直接访问的设备如usb_hcd,pci设备等这些设备能在sysfs中看到。还有就是一个设备有很多逻辑设备,如usb串口设备,对应程序而言你只能操作它作为串口设备的逻辑设备,它作为usb设备的逻辑设备你就操作不了,因为它只是以device存在,应用程序也没有接口直接去访问这些逻辑设备。在linux2.6系统中设备,驱动是非常复杂的,设备分了总线,类设备,还分了层次,如输入子系统等等,所以要想详细了解设备的操作需要下一番工夫,我也正在学习,希望大家一起进步。