31 输入与输出系统
输入输出设备很多,如键盘、鼠标、显示器、网卡、硬盘、打印机等。对于操作系统,这些用法、功能不同的设备怎么统一管理起来呢?
设备控制器屏蔽设备差异
cpu和设备通过设备控制器来打交道,如磁盘对应的磁盘控制器、USB对应的USB控制器、显示器的视频控制器等。
设备控制器像代理商一样屏蔽设备的差异性,且类似于小电脑,有自己的小cpu、寄存器,这样cpu可以对控制器下发指令、查看控制器对设备的操作状态。
输入输出设备可以大致分为两类:块设备、字符设备
块设备将信息存储在固定大小的块中,每个块都有自己的地址。硬盘就是常见的块设备。
字符设备发送或接收的是字节流。而不用考虑任何块结构,没有办法寻址。鼠标就是常见的字符设备。
由于块设备传输的数据量比较大,控制器里往往会有缓冲区。CPU写入缓冲区的数据攒够一部分,才会发给设备。CPU读取的数据,也需要在缓冲区攒够一部分,才拷贝到内存。
CPU 如何同控制器的寄存器和数据缓冲区进行通信呢?
每个控制寄存器被分配一个 I/O 端口,我们可以通过特殊的汇编指令(例如 in/out 类似的指令)操作这些寄存器。
数据缓冲区,可内存映射I/O,可以分配一段内存空间给它,就像读写内存一样读写数据缓冲区。内存空间有一个区域 ioremap,就是做这个的。
当cpu给设备发了一个指令,让它读取一些数据,它读完的时候,怎么通知cpu呢?
控制器的寄存器一般会有状态标志位,可以通过检测状态标志位,来确定输入或者输出操作是否完成。
第一种方式就是轮询等待,就是一直查,一直查,直到完成。当然这种方式很不好。
第二种方式,就是可以通过中断的方式,通知操作系统输入输出操作已经完成。
为了响应中断,我们一般会有一个硬件的中断控制器,当设备完成任务后触发中断到中断控制器,中断控制器就通知 CPU,一个中断产生了,CPU 需要停下当前手里的事情来处理中断。
中断有两种,一种软中断,例如代码调用INT指令触发,一种是硬件中断,就是硬件通过中断控制器触发的。
磁盘的DMA功能:
磁盘需要读取或者写入大量数据。如果所有过程都让 CPU 协调的话,就需要占用 CPU 大量的时间。所以磁盘支持 DMA 功能,也就是说,允许设备在 CPU 不参与的情况下,能够自行完成对内存的读写。
CPU 只需要对 DMA 控制器下指令,说它想读取多少数据,放在内存的某个地方就可以了,接下来 DMA 控制器会发指令给磁盘控制器,读取磁盘上的数据到指定的内存位置,传输完毕之后,DMA 控制器发中断通知CPU指令完成,CPU 就可以直接用内存里面现成的数据了。内存有个 DMA 区域,就是这个作用。
如图所示:
用驱动程序屏蔽设备控制器差异
由于每种设备控制器的寄存器、缓冲区等使用模式,指令都不同,所以对于操作系统来讲,需要有个专门对接的组件,即对接各个设备控制器的设备驱动程序。
设备驱动程序属于操作系统的一部分。操作系统的内核代码可以像调用本地代码一样调用驱动程序的代码,而驱动程序的代码需要发出特殊的面向设备控制器的指令,才能操作设备控制器。
设备驱动程序中是一些面向特殊设备控制器的代码。不同的设备不同。但是对于操作系统其它部分的代码而言,设备驱动程序应该有统一的接口。
设备做完了事情要通过中断来通知操作系统,设备驱动程序是用来对接设备控制器的,所以中断处理也应该在设备驱动里面完成。
一般的流程是,一个设备驱动程序初始化的时候,要先注册一个该设备的中断处理函数。咱们讲进程切换的时候说过,中断返回的那一刻是进程切换的时机。不知道你还记不记得,中断的时候,触发的函数是 do_IRQ。这个函数是中断处理的统一入口。在这个函数里面,我们可以找到设备驱动程序注册的中断处理函数 Handler,然后执行它进行中断处理。
因为块设备类型非常多,而 Linux 操作系统里面一切是文件。我们也不想文件系统以下,就直接对接各种各样的块设备驱动程序,这样会使得文件系统的复杂度非常高。所以,我们在中间加了一层通用块层,将与块设备相关的通用逻辑放在这一层,维护与设备无关的块的大小,然后通用块层下面对接各种各样的驱动程序。
用文件系统接口屏蔽驱动程序的差异
我们操作设备,都是基于文件系统的接口,会有一个统一的标准。
1 设备名称。所有设备都在/dev/文件夹下面创建一个特殊的设备文件。这个设备特殊文件也有 inode,但是它不关联到硬盘或任何其他存储介质上的数据,而是建立了与某个设备驱动程序的连接。
2
有了文件系统接口之后,我们不但可以通过文件系统的命令行操作设备,也可以通过程序,调用 read、write 函数,像读写文件一样操作设备。但是有些任务只使用读写很难完成,例如检查特定于设备的功能和属性,超出了通用文件系统的限制。所以,对于设备来讲,还有一种接口称为 ioctl,表示输入输出控制接口,是用于配置和修改特定设备属性的通用接口,
总结
输入输出设备,复杂多变,需要层层屏蔽差异化的部分,给上层提供标准化的部分,最终到用户态,给用户提供了基于文件系统的统一的接口。
如下图:
32 字符设备的打开与写入
两个实例:
鼠标输入设备,路径:代码在 drivers/input/mouse/logibm.c
打印机,输出字符设备,代码 drivers/char/lp.c
设备驱动程序是一个内核模块,那内核模块包括哪些部分呢?
1 头文件部分,如:#include <linux/module.h>
2 定义处理内核模块主逻辑的函数,包括打开、关闭、读取、写入、中断等。如logibm.c里面就定义了负责打开和关闭的logibm_open、logibm_close等。
3 定义file_operations结构。因为设备是通过文件系统的接口进行访问,二文件系统的操作都是放在file_operations之中的。
输入设备的操作被统一定义在 drivers/input/input.c 里面,如下:
static const struct file_operations input_devices_fileops = {
.owner = THIS_MODULE,
.open = input_proc_devices_open,
.poll = input_proc_devices_poll,
.read = seq_read,
.llseek = seq_lseek,
.release = seq_release,
};
4 定义整个模块的初始化函数和退出函数,用于加载和卸载这个ko的时候调用。例如 lp.c 就定义了 lp_init_module 和 lp_cleanup_module。
5 调用 module_init 和 module_exit,分别指向上面两个初始化函数和退出函数。
6 声明一下 lisense,调用 MODULE_LICENSE。
字符设备的打开
1 把写好的内核模块通过insmod加载进内核。如调用module_init
内核模块的加载,最终要的就是注册该字符设备,即将该字符设备添加到内核中一个叫作struct kobj_map *cdev_map 的结构,来统一管理所有字符设备。
注册的方式:调用 __register_chrdev_region,注册字符设备的主次设备号和名称,然后分配一个 struct cdev 结构,将 cdev 的 ops 成员变量指向这个模块声明的 file_operations.然后cdev_add将设备添加到*cdev_map中。
2 通过mknod在/dev下面创建一个设备文件,只有有了这个设备文件,我们才能通过文件系统的接口,对这个设备文件进行操作。
mknod:找到/dev/xxx所在的文件夹,为设备文件创建一个 dentry,这是维护文件和inode之间的关联关系的结构。
然后调用vfs_mknod->init_special_inode inode会关联到字符设备,且其file_operations指向一个 def_chr_fops。inode 的 i_rdev 指向这个设备的 dev_t。
目前为止,我们创建了 /dev 下面的一个文件,并且和相应的设备号关联起来。
3 打开这个 /dev 下面的设备文件
open函数,最终调用设备文件对应的inode的open函数,即chrdev_open。
通过inode里面的i_rdev,找到设备的dev_t,再对应到 cdev_map 中找 cdev。cdev 里面有驱动程序自定义的file_operations,对应到struct file里面的file_operations.以后对文件的操作就是直接操作设备了。
最终调用调用设备驱动程序的 file_operations 的 open 函数,真正打开设备。对于打印机,调用的是 lp_open。
字符设备的写入
用文件系统的标准接口 write,参数文件描述符 fd,在内核里面调用的 sys_write,在 sys_write 里面根据文件描述符 fd 得到 struct file 结构。接下来再调用 vfs_write。
在 __vfs_write 里面,我们会调用 struct file 结构里的 file_operations 的 write 函数,因为file中的file_operations已经指向了设备驱动程序的 file_operations 结构,所以write 函数最终会调用到 lp_write。
lp_write的主逻辑:
先是调用 copy_from_user 将数据从用户态拷贝到内核态的缓存中,然后调用 parport_write 写入外部设备。这里还有一个 schedule 函数,也即写入的过程中,给其他线程抢占 CPU 的机会。然后,如果 count 还是大于 0,也就是数据还没有写完,那我们就接着 copy_from_user,接着 parport_write,直到写完为止。
使用 IOCTL 控制设备
对于 I/O 设备来讲,我们前面也说过,除了读写设备,还会调用 ioctl,做一些特殊的 I/O 操作。
ioctl 也是一个系统调用,会调用 do_vfs_ioctl,这里面对于已经定义好的 cmd,进行相应的处理。如果不是默认定义好的 cmd,则执行默认操作。对于普通文件,调用 file_ioctl;对于其他文件调用 vfs_ioctl。
设备驱动程序,所以调用的是 vfs_ioctl。
调用的是 struct file 里 file_operations 的 unlocked_ioctl 函数。即设备驱动的 unlocked_ioctl。对于打印机程序来讲,调用的是 lp_ioctl。
总结:
一个字符设备要能够工作,需要三部分配合。第一,有一个设备驱动程序的 ko 模块,里面有模块初始化函数、中断处理函数、设备操作函数。这里面封装了对于外部设备的操作。加载设备驱动程序模块的时候,模块初始化函数会被调用。在内核维护所有字符设备驱动的数据结构 cdev_map 里面注册,我们就可以很容易根据设备号,找到相应的设备驱动程序。
第二,在 /dev 目录下有一个文件表示这个设备,这个文件在特殊的 devtmpfs 文件系统上,因而也有相应的 dentry 和 inode。这里的 inode 是一个特殊的 inode,里面有设备号。通过它,我们可以在 cdev_map 中找到设备驱动程序,里面还有针对字符设备文件的默认操作 def_chr_fops。
第三,打开一个字符设备文件和打开一个普通的文件有类似的数据结构,有文件描述符、有 struct file、指向字符设备文件的 dentry 和 inode。字符设备文件的相关操作 file_operations 一开始指向 def_chr_fops,在调用 def_chr_fops 里面的 chrdev_open 函数的时候,修改为指向设备操作函数,从而读写一个字符设备文件就会直接变成读写外部设备了。
33 字符设备的中断处理机制
如果一个设备有事情需要通知操作系统,会通过中断和设备驱动程序进行交互。
鼠标就是通过中断,将自己的位置和按键信息传递给设备驱动程序。
定义中断处理函数
中断函数的定义如下:
irqreturn_t (*irq_handler_t)(int irq, void * dev_id);
irq 是一个整数,是中断信号。dev_id 是一个 void * 的通用指针,主要用于区分同一个中断处理函数对于不同设备的处理。
返回值定义如下:
enum irqreturn { IRQ_NONE = (0 << 0), IRQ_HANDLED = (1 << 0), IRQ_WAKE_THREAD = (1 << 1),};
IRQ_NONE 表示不是我的中断,不归我管;
IRQ_HANDLED 表示处理完了的中断;
IRQ_WAKE_THREAD 表示有一个进程正在等待这个中断,中断处理完了,应该唤醒它。
中断处理函数难点:
为了防止信号混乱,当一个中断信号A触发后,正在处理的过程中,这个中断信号A是应该暂时关闭的,但是,这个暂时关闭的时间应该多长呢?
解决:在中断处理函数中,仅仅处理关键部分,完成了就将中断信号打开,使得新的中断可以进来,需要比较长时间处理的部分,也即延迟部分,往往通过工作队列等方式慢慢处理。
注册中断处理函数
调用request_irq来注册这个中断处理函数。定义如下
static inline int __must_check
request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags, const char *name, void *dev)
{
return request_threaded_irq(irq, handler, NULL, flags, name, dev);
}
参数说明:
unsigned int irq 是中断信号;
irq_handler_t handler 是中断处理函数;
unsigned long flags 是一些标识位;
const char *name 是设备名称;
void *dev 这个通用指针应该和中断处理函数的 void *dev 相对应。
request_irq 调用的是 request_threaded_irq,定义如下:
int request_threaded_irq(unsigned int irq, irq_handler_t handler,
irq_handler_t thread_fn, unsigned long irqflags,
const char *devname, void *dev_id)
{
struct irqaction *action;
struct irq_desc *desc;
int retval;
......
desc = irq_to_desc(irq);
......
action = kzalloc(sizeof(struct irqaction), GFP_KERNEL);
action->handler = handler;
action->thread_fn = thread_fn;
action->flags = irqflags;
action->name = devname;
action->dev_id = dev_id;
......
retval = __setup_irq(irq, desc, action);
......
}
每一个中断,都有一个对中断的描述结构struct irq_desc。它有一个变量是struct irqaction,表示处理这个中断的动作。
它里面有next指针,对于这个中断的所有处理动作,都串在这个链表上。
中断动作struct irqaction包含的成员:
中断处理函数 handler;
void *dev_id 为设备 id;
irq 为中断信号;
如果中断处理函数在单独的线程运行,则有 thread_fn 是线程的执行函数,thread 是线程的 task_struct。
request_threaded_irq 函数主要干了三件事:
根据中断信息查找中断描述结构irq_desc,由irq_to_desc实现,可能从数组或基数树上查找
为中断分配一个 struct irqaction,并且初始化它。
将新的 struct irqaction 挂在链表的末端,如果需要会创建内核线程。由__setup_irq函数实现
中断实际处理过程
真正中断发生的四个层次:
第一个层次是外部设备给中断控制器发送物理中断信号。
第二个层次是中断控制器将物理中断信号转换成为中断向量 interrupt vector,发给各个 CPU。
第三个层次是每个 CPU 都会有一个中断向量表,根据 interrupt vector 调用一个 IRQ 处理函数。注意这里的 IRQ 处理函数还不是咱们上面指定的 irq_handler_t,到这一层还是 CPU 硬件的要求。
第四个层次是在 IRQ 处理函数中,将 interrupt vector 转化为抽象中断层的中断信号 irq,调用中断信号 irq 对应的中断描述结构里面的 irq_handler_t。
如图:
CPU能够处理的中断总共256个,用宏NR_VECTOR或者FIRST_SYSTEM_VECTOR表示。
每一个cpu都有一个中断向量表,通过load_idt加载,里面记录每一个中断的处理方法。
cpu可以被处理的中断包括如下几类:
1 前0-31中断,是系统陷入或者系统异常,一定会处理。对应的中断的处理函数在系统初始化的时候,在 start_kernel 函数中调用过 trap_init()。
2 别的即设备中断,中断的处理函数在start_kernel 调用完毕 trap_init 之后,还会调用 init_IRQ() 来初始化,
硬件中断的处理函数是 do_IRQ 进行统一处理,在这里会让中断向量,通过 vector_irq 映射为 irq_desc
找到注册的中断处理 action 并执行
中断处理过程总结:
中断是从外部设备发起的,会形成外部中断。
外部中断会到达中断控制器,中断控制器会发送中断向量 Interrupt Vector 给 CPU。
对于每一个 CPU,都要求有一个 idt_table,里面存放了不同的中断向量的处理函数。中断向量表中已经填好了前 32 位,外加一位 32 位系统调用,其他的都是用于设备中断。
硬件中断的处理函数是 do_IRQ 进行统一处理,在这里会让中断向量,通过 vector_irq 映射为 irq_desc。irq_desc 是一个用于描述用户注册的中断处理函数的结构,为了能够根据中断向量得到 irq_desc 结构,会把这些结构放在一个基数树里面,方便查找。irq_desc 里面有一个成员是 irqaction,指向设备驱动程序里面注册的中断处理函数。
34 块设备上:块设备的 mknod和打开流程
创建块设备文件
mknod在/dev路径下创建块设备文件,并为块设备文件分配一个inode,且将inode里面的i_rdev设置成块设备的设备号dev_t.
/dev路径下面是devtmpfs文件系统。这是块设备遇到的第一个文件系统。
块设备文件mount到一个文件夹下面
调用与块设备格式一致的mount操作,这是块设备遇到的第二个文件系统,即向这个块设备读写文件的主流文件系统。
硬盘的mount操作详解
将一个硬盘的块设备 mount 成为 ext4 的时候,我们会调用 ext4_mount->mount_bdev。
mount_bdev主要做了两件事:
1 blkdev_get_by_path 根据 /dev/xxx 这个名字,找到相应的设备并打开它;
def_blk_fops 的默认打开设备函数 blkdev_open,它也是调用 blkdev_get 的。块设备的打开往往不是直接调用设备文件的打开函数,而是调用 mount 来打开的。
2 sget 根据打开的设备文件,填充 ext4 文件系统的 super_block,从而以此为基础,建立一整套体系。
总结:
所有的块设备被一个从dev_t到gendisk的映射map的结构管理;
所有的 block_device 表示的设备或者分区都在 bdev 文件系统的 inode 列表中;
mknod 创建出来的块设备文件在 devtemfs 文件系统里面,特殊 inode 里面有块设备号;
mount 一个块设备上的文件系统,调用这个文件系统的 mount 接口;
通过按照 /dev/xxx 在文件系统 devtmpfs 文件系统上搜索到特殊 inode,得到块设备号;
根据特殊 inode 里面的 dev_t 在 bdev 文件系统里面找到 inode;
根据 bdev 文件系统上的 inode 找到对应的 block_device,根据 dev_t 在 map 中找到 gendisk,将两者关联起来;
找到 block_device 后打开设备,调用和 block_device 关联的 gendisk 里面的 block_device_operations 打开设备;
创建被 mount 的文件系统的 super_block。
35 块设备I/O请求送达到外部设备
ext4文件系统的写入流程中最后调用的是ext4_file_write_iter。将IO的调用分成了两种情况:
1)直接IO,generic_file_direct_write->mapping->a_ops->ext4_direct_IO
2) 缓存IO,即先将数据从应用拷贝到内存缓存中,对应的页标记为脏页,最终调用wb_workfn写入硬盘。调用链:wb_workfn->wb_do_writeback->wb_writeback->writeback_sb_inodes->__writeback_single_inode->do_writepages->mapping->a_ops->ext4_writepages
直接IO:ext4_direct_IO将文件写入设备的过程
ext4_direct_IO_write 调用 __blockdev_direct_IO,其中有个参数
inode->i_sb->s_bdev:文件的inode,得到super_block,得到s_bdev,即block_device
__blockdev_direct_IO --> do_blockdev_direct_IO --> do_direct_IO,主要包含两层循环:
第一层循环是依次处理这次要写入的所有块。对于每一块,取出对应的内存中的页 page,在这一块中,有写入的起始地址from和终止地址to,
第二层循环就是依次处理from到to的数据,调用submit_page_section,提交到块设备层进行写入。
submit_page_section 会调用 dio_bio_submit,进而调用 submit_bio 向块设备层提交数据。
缓存I/O调用到ext4_writepages将文件写入设备过程
ext4_writepages->mpage_prepare_extent_to_map->mpage_prepare_extent_to_map->mpage_process_page_bufs->mpage_submit_page->ext4_bio_write_page->io_submit_add_bh->io_submit_init_bio
ext4_writepages中继续调用ext4_io_submit,提交 I/O。在这里我们又是调用 submit_bio
submit_bio如何想块设备提交请求
直接和缓存IO最终都到了submit_bio,怎么向块设备提交的请求呢?
submit_bio 会调用 generic_make_request,主要逻辑为:
do-while中,我们先是获取一个请求队列request_queue,然后调用这个队列的 make_request_fn函数。
块设备队列结构:
块设备的struct block_device 结构和 struct gendisk 结构,都有一个请求队列struct request_queue,在块设备的驱动程序初始化的时候生成。
总结:
块设备 I/O 请求送达到外部设备的过程如下:
对于块设备的 I/O 操作分为两种,一种是直接 I/O,另一种是缓存 I/O。无论是哪种 I/O,最终都会调用 submit_bio 提交块设备 I/O 请求。
对于每一种块设备,都有一个 gendisk 表示这个设备,它有一个请求队列,这个队列是一系列的 request 对象。每个 request 对象里面包含多个 BIO 对象,指向 page cache。所谓的写入块设备,I/O 就是将 page cache 里面的数据写入硬盘。
对于请求队列来讲,还有两个函数。
一个函数叫 make_request_fn 函数,用于将请求放入队列。submit_bio 会调用 generic_make_request,然后调用这个函数。
另一个函数往往在设备驱动程序里实现,我们叫 request_fn 函数,它用于从队列里面取出请求来,写入外部设备。