4. 阻塞与非阻塞I/O:等待队列机制详解、poll/select/epoll在驱动中的实现
阻塞与非阻塞I/O,是驱动开发里绕不开的核心话题。很多刚入行的朋友问我:为什么我的应用程序读按键,CPU占用率那么高?答案往往就出在这里——你用的是阻塞还是非阻塞,差别巨大。
我个人习惯把阻塞I/O比作「排队等号」,非阻塞则是「拿了号去逛街,过号了再回来问」。这两种模式在驱动层的实现,全靠一套叫「等待队列」的机制撑着。
4.1 等待队列机制详解
等待队列(wait queue)是Linux内核里最基础的休眠唤醒设施。说白了,它就是一组「想睡但还没睡」的进程链表。
核心数据结构:
struct wait_queue_head {
spinlock_t lock;
struct list_head head;
};
struct wait_queue_entry {
unsigned int flags;
void *private;
wait_queue_func_t func;
struct list_head entry;
};
我刚开始写驱动时,总觉得这东西很玄乎。后来在项目里调试一个触摸屏驱动,发现中断来了但进程就是醒不过来,查了两天才发现是等待队列初始化漏了。嗯,从那以后我再也不敢跳过 init_waitqueue_head() 了。
4.1.1 等待队列的典型用法
驱动里用等待队列,通常就三步:
- 初始化:在驱动probe或open时调用 init_waitqueue_head()
- 休眠:在read函数里调用 wait_event_interruptible()
- 唤醒:在中断处理或数据就绪时调用 wake_up_interruptible()
避坑指南:我曾经在多个中断源共用一个等待队列时,忘记加条件判断,结果一个中断唤醒了所有等待进程。正确的做法是用 wait_event_interruptible() 的第二个参数做条件检查,避免「假唤醒」。
4.2 poll/select/epoll在驱动中的实现
应用层用 select 或 epoll 监听多个文件描述符时,驱动层需要实现 .poll 回调。这个回调的核心任务只有一个:告诉内核「我当前的数据状态是什么」。
4.2.1 驱动中的poll回调实现
static unsigned int key_poll(struct file *filp,
struct poll_table_struct *wait)
{
unsigned int mask = 0;
struct key_dev *dev = filp->private_data;
poll_wait(filp, &dev->wq, wait);
if (atomic_read(&dev->key_value) != 0)
mask |= POLLIN | POLLRDNORM;
return mask;
}
你看,核心就两件事:
- 调用 poll_wait() 把进程挂到等待队列上
- 返回当前可读/可写的事件掩码
我记得有一次调试一个网络驱动,应用层 epoll_wait 总是超时返回0,查了半天发现是 poll 回调里忘了调用 poll_wait。内核文档里写得清楚:不调用 poll_wait,进程就不会被正确挂入等待队列,自然也就不会被唤醒。
4.2.2 poll/select/epoll的差异对比
| 机制 | 驱动实现复杂度 | 适用场景 | 性能特点 |
|---|---|---|---|
| select | 低 | 监听数量少(<1024) | 每次全量拷贝fd集合 |
| poll | 低 | 中等数量fd | 链表管理,无上限 |
| epoll | 中 | 大量fd(万级) | 回调机制,只返回就绪fd |
对驱动开发者来说,好消息是:不管应用层用 select 还是 epoll,驱动层的 .poll 回调写法完全一样。内核帮你把差异消化掉了。
4.3 非阻塞模式下的数据读取
非阻塞读,驱动里怎么判断?看 file->f_flags 有没有 O_NONBLOCK 标记。
static ssize_t key_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos)
{
struct key_dev *dev = filp->private_data;
if (filp->f_flags & O_NONBLOCK) {
// 非阻塞模式:没数据直接返回
if (atomic_read(&dev->key_value) == 0)
return -EAGAIN;
} else {
// 阻塞模式:没数据就休眠
if (wait_event_interruptible(dev->wq,
atomic_read(&dev->key_value) != 0))
return -ERESTARTSYS;
}
// 读取数据
atomic_set(&dev->key_value, 0);
return count;
}
注意:非阻塞模式下返回 -EAGAIN 是标准做法。我曾经见过有人返回 -1 或者 0,结果应用层 select 判断逻辑全乱了。记住,-EAGAIN 告诉应用层「现在没数据,你过会儿再来问」。
4.4 驱动中的信号发送与接收
有时候我们不希望应用层一直 poll,而是想让驱动主动通知应用层「数据来了」。这就用到信号(signal)机制了。
4.4.1 驱动发送信号到用户态
static void key_send_signal(struct key_dev *dev)
{
struct task_struct *task = dev->task;
if (task) {
send_sig(SIGIO, task, 0);
}
}
应用层需要先调用 fcntl(fd, F_SETOWN, getpid()) 注册信号接收者,然后驱动才能在中断里发 SIGIO。我做过一个项目,用这种方式替代了轮询,CPU占用率从 30% 直接降到 1% 以下。
4.5 实战:按键驱动的阻塞与非阻塞实现
好了,理论说完了,咱们动手写一个完整的按键驱动。这个驱动支持阻塞读、非阻塞读、poll 和信号通知四种模式。
4.5.1 驱动框架
struct key_dev {
int irq;
struct timer_list timer;
struct wait_queue_head wq;
atomic_t key_value;
struct task_struct *task;
struct fasync_struct *fasync;
};
static int key_open(struct inode *inode, struct file *filp)
{
struct key_dev *dev = container_of(inode->i_cdev,
struct key_dev, cdev);
filp->private_data = dev;
return 0;
}
static int key_fasync(int fd, struct file *filp, int mode)
{
struct key_dev *dev = filp->private_data;
return fasync_helper(fd, filp, mode, &dev->fasync);
}
4.5.2 中断处理与唤醒
static irqreturn_t key_irq_handler(int irq, void *dev_id)
{
struct key_dev *dev = dev_id;
// 消抖处理
mod_timer(&dev->timer, jiffies + msecs_to_jiffies(20));
return IRQ_HANDLED;
}
static void key_timer_callback(struct timer_list *t)
{
struct key_dev *dev = from_timer(dev, t, timer);
int value = gpio_get_value(KEY_GPIO);
atomic_set(&dev->key_value, value);
// 唤醒阻塞读的进程
wake_up_interruptible(&dev->wq);
// 发送信号给应用层
if (dev->fasync)
kill_fasync(&dev->fasync, SIGIO, POLL_IN);
}
关键点:为什么用定时器消抖?因为按键按下时 GPIO 电平会抖动,直接读可能读到多次跳变。我试过不加消抖,结果按一次键触发了七八次中断,应用层收到一堆重复数据。
4.5.3 完整的read和poll实现
static ssize_t key_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos)
{
struct key_dev *dev = filp->private_data;
int value;
if (filp->f_flags & O_NONBLOCK) {
value = atomic_read(&dev->key_value);
if (!value)
return -EAGAIN;
} else {
if (wait_event_interruptible(dev->wq,
atomic_read(&dev->key_value)))
return -ERESTARTSYS;
value = atomic_read(&dev->key_value);
}
atomic_set(&dev->key_value, 0);
if (copy_to_user(buf, &value, sizeof(value)))
return -EFAULT;
return sizeof(value);
}
static unsigned int key_poll(struct file *filp,
struct poll_table_struct *wait)
{
struct key_dev *dev = filp->private_data;
unsigned int mask = 0;
poll_wait(filp, &dev->wq, wait);
if (atomic_read(&dev->key_value))
mask |= POLLIN | POLLRDNORM;
return mask;
}
4.5.4 应用层测试代码
// 阻塞模式
int fd = open("/dev/key", O_RDWR);
int val;
while (1) {
read(fd, &val, sizeof(val));
printf("key value: %d\n", val);
}
// 非阻塞模式
int fd = open("/dev/key", O_RDWR | O_NONBLOCK);
int val;
while (1) {
if (read(fd, &val, sizeof(val)) > 0)
printf("key value: %d\n", val);
usleep(1000);
}
// epoll模式
int epfd = epoll_create(1);
struct epoll_event ev = {.events = EPOLLIN, .data.fd = fd};
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
while (1) {
epoll_wait(epfd, &ev, 1, -1);
read(fd, &val, sizeof(val));
printf("key value: %d\n", val);
}
个人经验:这个按键驱动框架我用了好多年,从 GPIO 按键到矩阵键盘,从触摸屏到传感器,核心逻辑几乎没变过。你只要把中断处理里的数据来源换一下,就能适配各种输入设备。
阻塞与非阻塞的选择,说白了就是「省CPU」和「响应快」之间的权衡。阻塞模式让进程休眠,CPU占用率几乎为0;非阻塞模式配合epoll,能同时监听几百个设备。实际项目中我通常的做法是:默认提供阻塞接口,同时支持O_NONBLOCK和poll,让应用层自己选。