本文源码:https://github.com/Philon/rpi-drivers/tree/master/03-device_io
本文是上一篇《GPIO驱动之按键中断》的扩展,之前的文章侧重于中断原理及Linux/IRQ基础,用驱动实现了一个按键切换LED灯色的功能。但涉及中断的场景,就会拔出萝卜带出泥要实现IO的同步/异步访问方式。归根结底,驱动要站在(用户层)接口调用的角度,除了实现自身的功能,还要为用户层提供一套良好的访问机制。本文主要涉及以下知识点:
- 机制与策略原则
- IO阻塞/非阻塞——read/write
- IO多路复用——select/epoll
- 信号异步通知——signal
机制与策略
Linux的核心思想之一就是“一切皆文件”,而这其中最重要的原则便是“提供机制,而非策略”。文件——内核层与用户层分水岭,通过文件,用户可以用简单而统一的方式去访问复杂而多样的设备,且不必操心设备内部的具体细节。然而哪些部分该驱动实现,哪些部分又该留给用户实现,这是需要拿捏的,我个人理解:
- 机制,相当于怎么做,提供某个功能范围的实现形式、框架、标准
- 策略,相当于做什么,提供某种功能的具体实现方法和细节
以上一篇按键中断的驱动为例——“按一下切换一种灯色”,显然,这根本不符合驱动设计原则。首先,驱动把两种设备打包进一个程序;其次,驱动实现了“切换灯色”这个具体业务功能(策略);最后,驱动根本没有提供用户层的访问机制。
还是回到需求——“按一下切换一种灯色”,理想情况下应该是这样的:
- LED驱动——向用户层提供灯色切换机制
- 按键驱动——向用户层提供“按下事件”通知/获取机制
- 由用户层自行决定收到按键事件后,如何切换灯色
从以上分析看,原本一个驱动代码分拆分成led驱动、按键驱动、切灯app三个部分:led驱动已经在第1章《GPIO驱动之LED》里实现了,因此现在还差两件事:
- 按键驱动,砍掉原有的led功能实现,增加中断标志获取的访问机制
- 切灯app,这个就无所谓了,只要有了机制,策略想怎么写就怎么写
IO的阻塞/非阻塞访问
阻塞/非阻塞,是访问IO的两种不同机制,即同步和异步获取。
所谓阻塞访问,就是当用户层read/write
设备文件时,如果预期的结果还没准备好,进程就会被挂起睡眠,让出CPU资源,直到要读写的资源准备好后,重新唤醒进程执行操作。以本文实际的按键中断来说,当用户层读按键设备节点时,只要按键没有被按下,进程就应该一直阻塞在read函数,直到触发中断后才返回。
所谓非阻塞访问,就是调用open(file, O_NONBLOCK)
或者ioctl(fd, F_SETFL, O_NONBLOCK)
,将文件句柄设置为非阻塞模式,此时,如果要读写的资源还没准备好,read/write
会立刻返回-EAGAIN
错误码,进程不会被挂起。
简而言之:
- 阻塞模式:read/write时会被挂起,让出CPU,无法及时响应后续业务
- 非阻塞模式:read/write时不会挂起,占有CPU,不影响后续程序执行
显然,通过以上介绍,驱动需要在自己的xxx_read() xxx_write()
等文件接口里实现阻塞功能,在Linux内核中主要通过“等待队列”来实现进程的阻塞与唤醒的。
等待队列
等待队列是基于链表实现的数据结构,用来表示某个事件上所有等待的进程。内核接口如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| #include <linux/wait.h>
struct wait_queue_head { spinlock_t lock; struct list_head head; }; typedef struct wait_queue_head wait_queue_head_t;
#define init_waitqueue_head(wq_head)
#define DECLARE_WAITQUEUE(name, tsk)
void add_wait_queue(struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry); void remove_wait_queue(struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry);
#define wait_event(wq_head, condition) #define wait_event_interruptible(wq_head, condition) #define wait_event_timeout(wq_head, condition, timeout) #define wait_event_interruptible_timeout(wq_head, condition, timeout)
#define wake_up(x) #define wake_up_interruptible(x)
|
上边的API比较难理解的就是wait_event_xxx
和wake_up_xxx
,其实很简单,一般在驱动的读写接口里调用wait_xxx
让进程切换到阻塞状态等待唤醒,然后在中断或其他地方调用wake_up_xxx
即可唤醒队列。再有,通常情况下建议使用interruptible
模式,否则进程将无法被系统信号中断。
最简单的按键阻塞实现
下面来实现按键的阻塞访问,当用cat /dev/key
命令去读按键是否按下时,应该会被阻塞,直到按键真的被按下。为此,需要实现:
- 在
init
函数中初始化gpio按键相关,以及一个“等待队列”
- 在
read
函数中创建一个“等待”,然后进入阻塞,直到被唤醒
- 在中断响应函数中,唤醒整个“等待队列”
PS:以下驱动也顺便实现了“非阻塞”访问模式,这个其实很简单,无非就是判断以下文件标识是否为O_NONBLOCK
即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101
| #include <linux/module.h> #include <linux/fs.h> #include <linux/miscdevice.h> #include <linux/gpio.h> #include <linux/interrupt.h> #include <linux/uaccess.h>
#define KEY_GPIO 17
MODULE_LICENSE("Dual BSD/GPL"); MODULE_AUTHOR("Philon | https://ixx.life");
static wait_queue_head_t r_wait;
static ssize_t gpiokey_read(struct file *filp, char __user *buf, size_t len, loff_t * off) { int data = 1; DECLARE_WAITQUEUE(wait, current); add_wait_queue(&r_wait, &wait);
if ((filp->f_flags & O_NONBLOCK) && !gpio_get_value(KEY_GPIO)) { return -EAGAIN; }
wait_event_interruptible(r_wait, gpio_get_value(KEY_GPIO)); remove_wait_queue(&r_wait, &wait);
len = sizeof(data); if ((data = copy_to_user(buf, &data, len)) < 0) { return -EFAULT; } *off += len;
return 0; }
static int press_irq = 0; static struct timer_list delay;
static irqreturn_t on_key_pressed(int irq, void* dev) { mod_timer(&delay, jiffies + (HZ/20)); return IRQ_HANDLED; } static void on_delay50(struct timer_list* timer) { if (gpio_get_value(KEY_GPIO)) { wake_up_interruptible(&r_wait); } }
struct file_operations fops = { .owner = THIS_MODULE, .read = gpiokey_read, };
struct miscdevice gpiokey = { .minor = 1, .name = "gpiokey", .fops = &fops, .nodename = "mykey", .mode = 0700, };
static int __init gpiokey_init(void) { timer_setup(&delay, on_delay50, 0); add_timer(&delay);
init_waitqueue_head(&r_wait);
gpio_request_one(KEY_GPIO, GPIOF_IN, "key"); press_irq = gpio_to_irq(KEY_GPIO); if (request_irq(press_irq, on_key_pressed, IRQF_TRIGGER_RISING, "onKeyPress", NULL) < 0) { printk(KERN_ERR "Failed to request irq for gpio%d\n", KEY_GPIO); }
misc_register(&gpiokey); return 0; } module_init(gpiokey_init);
static void __exit gpiokey_exit(void) { misc_deregister(&gpiokey); free_irq(0, NULL); gpio_free(KEY_GPIO); del_timer(&delay); } module_exit(gpiokey_exit);
|
多路复用IO模型-poll
如果程序只监听一个设备,那用阻塞或非阻塞足够了,但如果设备数量繁多呢?比如我们的键盘,有一百多个键,难道每次都要全部扫描一遍有没有被按下?(当然,键盘事件有另外的机制,这里只是举个例子)
这个时候轮询操作就非常有用了,据我所知有很多小型的网络服务正是用此机制实现的高性能并发访问,简单来说,就是把成千上万个socket句柄放到一种名叫fd_set
的集合里,然后通过select()/epoll()
同时监听集合里的句柄状态,其中任何一个socket可读写时就唤醒进程并及时响应。
综上,设备驱动要做的,便是实现select/epoll
的底层接口。而有关select的应用层开发这里就不介绍了,网上一大堆。
驱动模块的多路复用实现其实非常简单,和读写接口一样,你只需实现file_operations
里的poll接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| #include <linux/poll.h>
__poll_t (*poll) (struct file *, struct poll_table_struct *);
void poll_wait(struct file * filp, wait_queue_head_t * wait_address, poll_table *p);
#define POLLIN 0x0001 #define POLLPRI 0x0002 #define POLLOUT 0x0004 #define POLLERR 0x0008 #define POLLHUP 0x0010 #define POLLNVAL 0x0020
#define POLLRDNORM 0x0040 #define POLLRDBAND 0x0080 #define POLLWRNORM 0x0100 #define POLLWRBAND 0x0200 #define POLLMSG 0x0400 #define POLLREMOVE 0x1000 #define POLLRDHUP 0x2000
|
结合第二小结的等待队列,实现gpio按键的多路复用IO就非常简单了,只需要在原有的代码里加入下面这段:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| static __poll_t gpiokey_poll(struct file *filp, struct poll_table_struct *wait) { __poll_t mask = 0; poll_wait(filp, &r_wait, wait); if (gpio_get_value(KEY_GPIO)) { mask = POLLIN | POLLRDNORM; }
return mask; }
struct file_operations fops = { .owner = THIS_MODULE, .read = gpiokey_read, .poll = gpiokey_poll, };
|
异步通知-信号
不论IO阻塞、非阻塞还是多路复用,都是由应用程序主动向设备发起访问,有没有一种机制,就像邮箱一样,当有信息来临时再通知用户,用户仅仅是被动接收——答:信号!
信号本质上来说,就是软件层对中断的一种模拟。正如常见的Ctrl+C
、kill
等,都是向进程发送信号的手段。所以信号也可以理解为是一种特殊的中断号或事件ID。其实在Linux应用开发中,会涉及很多的“终止/定时/异常/掉电”等信号捕获,我们写的程序之所以能被Ctrl+C
终止,就是因为在应用接口里已经实现了相关信号的捕获处理。
为了更好地理解设备驱动有关信号机制的实现,必须先站在用户层的角度看看信号是如何被调用的。有关Linux常用的标准信号这里也不展开讨论,请用好互联网。这里仅仅是看一个应用程序如何接收指定设备的SIGIO
信号的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| #include <stdio.h> #include <unistd.h> #include <signal.h> #include <sys/fcntl.h>
void on_keypress(int sig) { printf("key pressed!!\n"); }
int main(int argc, char* argv[]) { int oflags = 0; int fd = open("/dev/mykey", O_RDONLY); fcntl(fd, F_SETOWN, getpid()); oflags = fcntl(fd, F_GETFL) | O_ASYNC; fcntl(fd, F_SETFL, oflags);
signal(SIGIO, on_keypress);
printf("I'm doing something ...\n"); getchar(); close(fd); return 0; }
|
从以上代码来看,应用程序要实现信号捕获需要操作:
- 用
F_SETOWN
让设备文件指向自己,确保信号的传输目的地
- 用
O_ASYNC
或者FASYNC
标志告诉驱动(即调用驱动的xxx_fasync
接口),我要去做其他事了,有情况请主动通知我
- 设置信号捕获及相关处理handler
所以对应的,内核模块也需要实现信号的发送也需要三个步骤:
filp->f_onwer
指向进程ID,这点已经又内核完成,不用再实现
- 实现
xxx_fasync()
接口,在里面初始化一个fasync_struct
用于信号处理
- 当有情况时,使用
kill_fasync()
发送信号给进程
内核具体接口如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| struct file_operations { int (*fasync) (int fd, struct file *filp, int mode); ... };
int fasync_helper(int fd, struct file *filp, int mode, struct fasync_struct **fa);
void kill_fasync(struct fasync_struct **fa, int sig, int band);
static int xxx_close(struct inode *node, struct file *filp) { ... xxx_fasync(-1, filp, 0); }
|
搞清楚了内核关于异步信号的机制,下面用让gpiokey支持SIGIO信号吧!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| static struct { int irq; struct timer_list delay; wait_queue_head_t r_wait; struct fasync_struct* fa; } dev;
static int gpiokey_fasync(int fd, struct file *filp, int mode) { return fasync_helper(fd, filp, mode, &dev.fa); }
static int gpiokey_close(struct inode *node, struct file *filp) { gpiokey_fasync(-1, filp, 0); return 0; }
static irqreturn_t on_key_pressed(int irq, void* dev_id) { mod_timer(&dev.delay, jiffies + (HZ/20)); return IRQ_HANDLED; } static void on_delay50(struct timer_list* timer) { if (gpio_get_value(KEY_GPIO)) { wake_up_interruptible(&dev.r_wait); kill_fasync(&dev.fa, SIGIO, POLL_IN); } }
struct file_operations fops = { ... .fasync = gpiokey_fasync, .release = gpiokey_close, };
|
从输出结果中可以看到,程序启动并执行后续,完全没有监听设备,当按键被按下时,信号传回进程并触发了on_keypress()
函数。
1 2 3 4 5
| philon@rpi:~/modules $ sudo insmod gpiokey.ko philon@rpi:~/modules $ ./signal_test I'm doing something ... key pressed!! key pressed!!
|
异步IO
自Linux2.6以后,IO的异步访问又多了一种新方式——aio
,此方式在实际开发中并不多见,尤其是嵌入式领域!因此本文不打算深入讨论,这里作为知识扩展仅做个简单介绍。
异步IO的核心思想就是——回调,例如aio_read(struct aiocb *cb)
和aio_write(truct aiocb *cb)
,程序调用该函数后不会阻塞,当文件读写就绪后,会自动根据cb
描述进行回调。
此外,AIO有应用层基于线程的glibc实现,以及内核层的fops接口实现,甚至还有类型libuv、libevent这样的事件驱动的第三方框架可供使用。
就我个人而言,技术是把双刃剑,回调是一种看似美妙的骚操作,但如果你编写的业务具有强逻辑性,那回调在时序上的失控,以及返回状态的多样化,会随着代码的壮大而进入回调陷阱,深深地无法自拔。给我这种感受的并非C语言,而是JavaScript。
总之,没有最优秀的技术,只有最适用的场景!我的原则是:用回调,远离嵌套回调。
小结
- Linux内核模块应当“提供机制,而非策略”
- 阻塞IO是在用户层读写访问时,是进程睡眠,由驱动来唤醒
- 非阻塞IO是有
IO_NONBLOCK
标记时,当资源不可访问时,直接返回-EAGAIN
- 多路复用IO是通过
select/epoll
进行多个设备监听,驱动须实现对应的fops->poll
接口
- 异步IO即信号,由设备驱动作为信号源,主动向进程发送通知
- 不同的IO同步/异步访问机制无优劣之分,而是取决于具体的应用场景
- 务必搞懂
等待队列
,它贯穿以上几种IO访问机制