两个比较简单的字符设备驱动来解析一下。一个是输入字符设备,鼠标。代码在 drivers/input/mouse/logibm.c 这里。另外一个是输出字符设备,打印机,代码 drivers/char/lp.c 这里。
设备驱动程序是一个内核模块,以 ko 的文件形式存在,可以通过 insmod 加载到内核中。那我们首先来看一下,怎么样才能构建一个内核模块呢?
第一部分,头文件部分。一般的内核模块,都需要 include 下面两个头文件#include <linux/module.h> #include <linux/init.h>
如果你去看上面两个驱动程序,都能找到这两个头文件。当然如果需要的话,我们还可以引入更多的头文件。
第二部分,定义一些函数,用于处理内核模块的主要逻辑。例如打开、关闭、读取、写入设备的函数或者响应中断的函数。
例如,logibm.c 里面就定义了 logibm_open。logibm_close 就是处理打开和关闭的,定义了 logibm_interrupt 就是用来响应中断的。再如,lp.c 里面就定义了 lp_read,lp_write 就是处理读写的。
第三部分,定义一个 file_operations 结构。前面我们讲过,设备是可以通过文件系统的接口进行访问的。咱们讲文件系统的时候说过,对于某种文件系统的操作,都是放在 file_operations 里面的。例如 ext4 就定义了这么一个结构,里面都是 ext4_xxx 之类的函数。设备要想被文件系统的接口操作,也需要定义这样一个结构。
第四部分,定义整个模块的初始化函数和退出函数,用于加载和卸载这个 ko 的时候调用。
例如 lp.c 就定义了 lp_init_module 和 lp_cleanup_module,logibm.c 就定义了 logibm_init 和 logibm_exit。
第五部分,调用 module_init 和 module_exit,分别指向上面两个初始化函数和退出函数。
第六部分,声明一下 lisense,调用 MODULE_LICENSE。
打开字符设备
字符设备可不是一个普通的内核模块,它有自己独特的行为。接下来,我们就沿着打开一个字符设备的过程,看看字符设备这个内核模块做了哪些特殊的事情。
要使用一个字符设备,我们首先要把写好的内核模块,通过 insmod 加载进内核。这个时候,先调用的就是 module_init 调用的初始化函数。
在字符设备驱动的内核模块加载的时候,最重要的一件事情就是,注册这个字符设备。注册的方式是调用 __register_chrdev_region,注册字符设备的主次设备号和名称,然后分配一个 struct cdev 结构,将 cdev 的 ops 成员变量指向这个模块声明的 file_operations。然后,cdev_add 会将这个字符设备添加到内核中一个叫作 struct kobj_map *cdev_map 的结构,来统一管理所有字符设备。
其中,MKDEV(cd->major, baseminor) 表示将主设备号和次设备号生成一个 dev_t 的整数,然后将这个整数 dev_t 和 cdev 关联起来。
在 logibm.c 中,我们在 logibm_init 找不到注册字符设备,这是因为 input.c 里面的初始化函数 input_init 会调用 register_chrdev_region,注册输入的字符设备,会在 logibm_init 中调用 input_register_device,将 logibm.c 这个字符设备注册到 input.c 里面去,这就相当于 input.c 对多个输入字符设备进行统一的管理。
内核模块加载完毕后,接下来要通过 mknod 在 /dev 下面创建一个设备文件,只有有了这个设备文件,我们才能通过文件系统的接口,对这个设备文件进行操作。
mknod 也是一个系统调用,我们可以在这个系统调用里看到,在文件系统上,顺着路径找到 /dev/xxx 所在的文件夹,然后为这个新创建的设备文件创建一个 dentry。这是维护文件和 inode 之间的关联关系的结构。
接下来,如果是字符文件 S_IFCHR 或者设备文件 S_IFBLK,我们就调用 vfs_mknod。
这里需要调用对应的文件系统的 inode_operations。应该调用哪个文件系统呢?如果我们在 linux 下面执行 mount 命令,能看到下面这一行devtmpfs on /dev type devtmpfs (rw,nosuid,size=3989584k,nr_inodes=997396,mode=755)
也就是说,/dev 下面的文件系统的名称为 devtmpfs,我们可以在内核中找到它。从这里可以看出,devtmpfs 在挂载的时候,有两种模式,一种是 ramfs,一种是 shmem 都是基于内存的文件系统。这两个 mknod 虽然实现不同,但是都会调用到用一个函数 init_special_inode。
显然这个文件是个特殊文件,inode 也是特殊的。这里这个 inode 可以关联字符设备、块设备、FIFO 文件、Socket 等。我们这里只看字符设备。
这里的 inode 的 file_operations 指向一个 def_chr_fops,这里面只有一个 open,就等着你打开它。
另外,inode 的 i_rdev 指向这个设备的 dev_t。还记得 cdev_map 吗?通过这个 dev_t,可以找到我们刚在加载的字符设备 cdev。
到目前为止,我们只是创建了 /dev 下面的一个文件,并且和相应的设备号关联起来。但是,我们还没有打开这个 /dev 下面的设备文件。
打开字符设备,打开文件的进程的 task_struct 里,有一个数组代表它打开的文件,下标就是文件描述符 fd,每一个打开的文件都有一个 struct file 结构,会指向一个 dentry 项。dentry 可以用来关联 inode。这个 dentry 就是咱们上面 mknod 的时候创建的。
在进程里面调用 open 函数,最终对调用到这个特殊的 inode 的 open 函数,也就是 chrdev_open。
static int chrdev_open(struct inode *inode, struct file *filp)
{
const struct file_operations *fops;
struct cdev *p;
struct cdev *new = NULL;
int ret = 0;
p = inode->i_cdev;
if (!p) {
struct kobject *kobj;
int idx;
kobj = kobj_lookup(cdev_map, inode->i_rdev, &idx);
new = container_of(kobj, struct cdev, kobj);
p = inode->i_cdev;
if (!p) {
inode->i_cdev = p = new;
list_add(&inode->i_devices, &p->list);
new = NULL;
}
}
......
fops = fops_get(p->ops);
......
replace_fops(filp, fops);
if (filp->f_op->open) {
ret = filp->f_op->open(inode, filp);
......
}
......
}
在这个函数里面,我们首先看这个 inode 的 i_cdev,是否已经关联到 cdev。如果第一次打开,当然没有。没有没关系,inode 里面有 i_rdev 呀,也就是有 dev_t。我们可以通过它在 cdev_map 中找 cdev。咱们上面注册过了,所以肯定能够找到。找到后我们就将 inode 的 i_cdev,关联到找到的 cdev new。
找到 cdev 就好办了。cdev 里面有 file_operations,这是设备驱动程序自己定义的。我们可以通过它来操作设备驱动程序,把它付给 struct file 里面的 file_operations。这样以后操作文件描述符,就是直接操作设备了。
最后,我们需要调用设备驱动程序的 file_operations 的 open 函数,真正打开设备。对于打印机,调用的是 lp_open。对于鼠标调用的是 input_proc_devices_open,最终会调用到 logibm_open。
写入字符设备
写入一个字符设备,就是用文件系统的标准接口 write,参数文件描述符 fd,在内核里面调用的 sys_write,在 sys_write 里面根据文件描述符 fd 得到 struct file 结构。接下来再调用 vfs_write。
在 __vfs_write 里面,我们会调用 struct file 结构里的 file_operations 的 write 函数。上面我们打开字符设备的时候,已经将 struct file 结构里面的 file_operations 指向了设备驱动程序的 file_operations 结构,所以这里的 write 函数最终会调用到 lp_write。
这个设备驱动程序的写入函数的实现还是比较典型的。先是调用 copy_from_user 将数据从用户态拷贝到内核态的缓存中,然后调用 parport_write 写入外部设备。这里还有一个 schedule 函数,也即写入的过程中,给其他线程抢占 CPU 的机会。然后,如果 count 还是大于 0,也就是数据还没有写完,那我们就接着 copy_from_user,接着 parport_write,直到写完为止。
中断
鼠标就是通过中断,将自己的位置和按键信息,传递给设备驱动程序。要处理中断,需要有一个中断处理函数。定义如下:
irqreturn_t (*irq_handler_t)(int irq, void * dev_id);
/**
* enum irqreturn
* @IRQ_NONE interrupt was not from this device or was not handled
* @IRQ_HANDLED interrupt was handled by this device
* @IRQ_WAKE_THREAD handler requests to wake the handler thread
*/
enum irqreturn {
IRQ_NONE = (0 << 0),
IRQ_HANDLED = (1 << 0),
IRQ_WAKE_THREAD = (1 << 1),
};
其中,irq 是一个整数,是中断信号。dev_id 是一个 void * 的通用指针,主要用于区分同一个中断处理函数对于不同设备的处理。
这里的返回值有三种:IRQ_NONE 表示不是我的中断,不归我管;IRQ_HANDLED 表示处理完了的中断;IRQ_WAKE_THREAD 表示有一个进程正在等待这个中断,中断处理完了,应该唤醒它。
logibm_interrupt 这个中断处理函数,先是获取了 x 和 y 的移动坐标,以及左中右的按键,上报上去,然后返回 IRQ_HANDLED,这表示处理完毕。
其实,写一个真正生产用的中断处理程序还是很复杂的。当一个中断信号 A 触发后,正在处理的过程中,这个中断信号 A 是应该暂时关闭的,这样是为了防止再来一个中断信号 A,在当前的中断信号 A 的处理过程中插一杠子。但是,这个暂时关闭的时间应该多长呢?
如果太短了,应该原子化处理完毕的没有处理完毕,又被另一个中断信号 A 中断了,很多操作就不正确了;如果太长了,一直关闭着,新的中断信号 A 进不来,系统就显得很慢。所以,很多中断处理程序将整个中断要做的事情分成两部分,称为上半部和下半部,或者成为关键处理部分和延迟处理部分。在中断处理函数中,仅仅处理关键部分,完成了就将中断信号打开,使得新的中断可以进来,需要比较长时间处理的部分,也即延迟部分,往往通过工作队列等方式慢慢处理。
有了中断处理函数,接下来要调用 request_irq 来注册这个中断处理函数。request_irq 有这样几个参数:
- unsigned int irq 是中断信号;
- irq_handler_t handler 是中断处理函数;
- unsigned long flags 是一些标识位;
- const char *name 是设备名称;
- void *dev 这个通用指针应该和中断处理函数的 void *dev 相对应。
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);
}
中断处理函数被注册到哪里去呢?让我们沿着 request_irq 看下去。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 irq_desc {
......
struct irqaction *action; /* IRQ action list */
......
struct module *owner;
const char *name;
};
/**
* struct irqaction - per interrupt action descriptor
* @handler: interrupt handler function
* @name: name of the device
* @dev_id: cookie to identify the device
* @percpu_dev_id: cookie to identify the device
* @next: pointer to the next irqaction for shared interrupts
* @irq: interrupt number
* @flags: flags (see IRQF_* above)
* @thread_fn: interrupt handler function for threaded interrupts
* @thread: thread pointer for threaded interrupts
* @secondary: pointer to secondary irqaction (force threading)
* @thread_flags: flags related to @thread
* @thread_mask: bitmask for keeping track of @thread activity
* @dir: pointer to the proc/irq/NN/name entry
*/
struct irqaction {
irq_handler_t handler;
void *dev_id;
void __percpu *percpu_dev_id;
struct irqaction *next;
irq_handler_t thread_fn;
struct task_struct *thread;
struct irqaction *secondary;
unsigned int irq;
unsigned int flags;
unsigned long thread_flags;
unsigned long thread_mask;
const char *name;
struct proc_dir_entry *dir;
};
每一个中断处理动作的结构 struct irqaction,都有以下成员:
- 中断处理函数 handler;
- void *dev_id 为设备 id;
- irq 为中断信号;
- 如果中断处理函数在单独的线程运行,则有 thread_fn 是线程的执行函数,thread 是线程的 task_struct。
在 request_threaded_irq 函数中,irq_to_desc 根据中断信号查找中断描述结构。如何查找呢?这就要区分情况。一般情况下,所有的 struct irq_desc 都放在一个数组里面,我们直接按下标查找就可以了。如果配置了 CONFIG_SPARSE_IRQ,那中断号是不连续的,就不适合用数组保存了,
我们可以放在一棵基数树上。我们不是第一次遇到这个数据结构了。这种结构对于从某个整型 key 找到 value 速度很快,中断信号 irq 是这个整数。通过它,我们很快就能定位到对应的 struct irq_desc。
#ifdef CONFIG_SPARSE_IRQ
static RADIX_TREE(irq_desc_tree, GFP_KERNEL);
struct irq_desc *irq_to_desc(unsigned int irq)
{
return radix_tree_lookup(&irq_desc_tree, irq);
}
#else /* !CONFIG_SPARSE_IRQ */
struct irq_desc irq_desc[NR_IRQS] __cacheline_aligned_in_smp = {
[0 ... NR_IRQS-1] = {
}
};
struct irq_desc *irq_to_desc(unsigned int irq)
{
return (irq < NR_IRQS) ? irq_desc + irq : NULL;
}
#endif /* !CONFIG_SPARSE_IRQ */
为什么中断信号会有稀疏,也就是不连续的情况呢?这里需要说明一下,这里的 irq 并不是真正的、物理的中断信号,而是一个抽象的、虚拟的中断信号。因为物理的中断信号和硬件关联比较大,中断控制器也是各种各样的。
作为内核,我们不可能写程序的时候,适配各种各样的硬件中断控制器,因而就需要有一层中断抽象层。这里虚拟中断信号到中断描述结构的映射,就是抽象中断层的主要逻辑。
下面我们讲真正中断响应的时候,会涉及物理中断信号。可以想象,如果只有一个 CPU,一个中断控制器,则基本能够保证从物理中断信号到虚拟中断信号的映射是线性的,这样用数组表示就没啥问题,但是如果有多个 CPU,多个中断控制器,每个中断控制器各有各的物理中断信号,就没办法保证虚拟中断信号是连续的,所以就要用到基数树了。
接下来,request_threaded_irq 函数分配了一个 struct irqaction,并且初始化它,接着调用 、__setup_irq。在这个函数里面,如果 struct irq_desc 里面已经有 struct irqaction 了,我们就将新的 struct irqaction 挂在链表的末端。如果设定了以单独的线程运行中断处理函数,setup_irq_thread 就会创建这个内核线程,wake_up_process 会唤醒它。
至此为止,request_irq 完成了它的使命。总结来说,它就是根据中断信号 irq,找到基数树上对应的 irq_desc,然后将新的 irqaction 挂在链表上。
接下来,我们就来看,真正中断来了的时候,会发生一些什么。
真正中断的发生还是要从硬件开始。这里面有四个层次。
- 第一个层次是外部设备给中断控制器发送物理中断信号。
- 第二个层次是中断控制器将物理中断信号转换成为中断向量 interrupt vector,发给各个 CPU。
- 第三个层次是每个 CPU 都会有一个中断向量表,根据 interrupt vector 调用一个 IRQ 处理函数。注意这里的 IRQ 处理函数还不是咱们上面指定的 irq_handler_t,到这一层还是 CPU 硬件的要求。
- 第四个层次是在 IRQ 处理函数中,将 interrupt vector 转化为抽象中断层的中断信号 irq,调用中断信号 irq 对应的中断描述结构里面的 irq_handler_t。