都说中断和poll机制是驱动开发的两座大山,但把它们分开看其实都不算难,真正让新手抓狂的是“中断来了之后,应用层的select/poll到底是怎么被唤醒的”这条链路。我在做某个模拟项目的外设驱动时,把这条链路完整捋了一遍,从硬件中断触发到read()返回数据,中间每一步都值得反复琢磨。这篇文章我不打算泛泛讲理论,直接从“现场”出发,把中断的下半部机制、驱动里poll函数的职责,以及中断和等待队列如何配合这件事拆开揉碎,顺便把我在实际调试中踩过的坑一并列出来。1. 中断处理机制:上半部和下半部为什么必须分开1.1 中断上下文里能做什么、不能做什么中断处理程序跑在一种特殊上下文里,它不属于任何进程,也不受进程调度器管理。这意味着你不能在里面调用会导致睡眠的函数,比如copy_to_user、kmalloc(..., GFP_KERNEL)、mutex_lock这类操作统统不行。一旦睡眠,系统根本没法调度你回来,整个 CPU 都可能被卡死。但硬件中断来了之后,你总得做点事:确认中断、读状态寄存器、把 FIFO 里的数据搬出来,否则数据可能被覆盖。实际操作中,我通常把“必须立即处理、否则会丢数据”的工作放在上半部,比如从硬件寄存器把数据搬到内存缓冲区。而真正耗时的事情,比如数据解析、协议处理、唤醒等待进程,我会丢给下半部。上半部必须快,这几乎是硬性要求。如果中断处理时间过长,不仅会阻塞同一条中断线上的其他设备,还可能造成中断丢失。我见过有人在上半部里做比较复杂的循环校验,结果一个中断把整个系统的实时性拖垮了。1.2 下半部三件套:tasklet、工作队列、线程化中断下半部的选择直接影响“中断唤醒 poll”这条链路的实现方式。这里有三条常见路线,我简单对比一下:机制运行上下文能否睡眠适合场景tasklet / softirq软中断上下文不能耗时短、要求低延迟的处理工作队列 (workqueue)内核线程(进程上下文)能耗时较长、可能需要睡眠的处理线程化中断 (threaded irq)内核线程能中断处理本身就要做大量工作的情况在实际驱动中,如果数据搬运很急,我一般选择在硬中断里做最小处理,然后通过tasklet或workqueue完成后续操作。如果中断处理本身就很重,直接用request_threaded_irq把整个处理过程放到内核线程里更省事。1.3 中断处理代码的一般形态拿一个虚拟的字符设备为例,中断处理函数通常长这样:static irqreturn_t xxx_irq_handler(int irq, void *dev_id) { struct xxx_dev *dev = dev_id; unsigned long flags; /* 读取硬件状态寄存器,确认中断来源 */ status = readl(dev->reg_base + REG_STATUS); /* 把硬件 FIFO 中的数据搬到内核缓冲区 */ spin_lock_irqsave(&dev->lock, flags); while (!(readl(dev->reg_base + REG_STATUS) & FIFO_EMPTY)) { dev->data_buf[dev->buf_wr_idx] = readl(dev->reg_base + REG_DATA); dev->buf_wr_idx = (dev->buf_wr_idx + 1) % BUF_SIZE; } spin_unlock_irqrestore(&dev->lock, flags); /* 通知等待队列,这里就是 poll 机制被触发的关键 */ wake_up_interruptible(&dev->read_wait); return IRQ_HANDLED; }这里最关键的一行就是wake_up_interruptible(&dev->read_wait)。当这个函数被调用时,所有在read_wait等待队列上睡眠的进程都会被唤醒。而poll/select正是靠这个唤醒来重新检查“数据是否就绪”的。注意:wake_up_interruptible可以在中断上下文调用,它只是把进程状态改为可运行,并不会真的在中断里运行应用层代码。很多新人担心这里会不会有问题,其实完全不用担心。2. poll机制:从系统调用到驱动的桥梁2.1 驱动里的 .poll 到底在做什么应用层调用select()或poll()时,内核最终会遍历所有被监视的文件描述符,并调用每个文件对应的file_operations.poll函数。这个函数在驱动里的职责很简单:向内核报告“当前这个设备有哪些事件已经就绪”。按 Linux 内核的约定,poll函数返回一个掩码(mask),比如有数据可读就返回POLLIN | POLLRDNORM,缓冲区可写就返回POLLOUT | POLLWRNORM,设备断开就返回POLLERR。内核拿到这些掩码后,会判断应用层关心的事件是否已经满足。需要注意的是,驱动里的poll函数并不负责睡眠。它只在被内核调用时“汇报情况”并注册唤醒回调,真正的睡眠发生在上层通用代码里。2.2 poll_wait 与等待队列:注册而非睡眠poll_wait是驱动里必须调用的一个函数,但不少初学者以为它会让当前进程睡在这里。这是一个非常关键的误解。实际上,poll_wait只是把当前进程挂到设备的等待队列上,相当于告诉内核:“如果这个队列被唤醒,请重新检查一下我的条件。”static unsigned int xxx_poll(struct file *filp, poll_table *wait) { struct xxx_dev *dev = filp->private_data; unsigned int mask = 0; /* 把当前进程挂到设备读等待队列,但不睡眠 */ poll_wait(filp, &dev->read_wait, wait); /* 检查是否有数据可读 */ if (dev->buf_rd_idx != dev->buf_wr_idx) mask |= POLLIN | POLLRDNORM; return mask; }当进程因select睡眠时,它其实睡在通用层,而不是睡在驱动里。驱动只负责把“睡眠时应该监听哪个队列”告诉内核。这样设计的好处是,驱动本身不用关心select的超时、多文件描述符管理这些复杂逻辑。2.3 掩码值的约定与含义poll的返回值可不是随便写的,内核和官方文档有明确约定。常见掩码如下:掩码含义POLLIN有数据可读POLLRDNORM普通数据可读,和 POLLIN 等价POLLOUT可以写数据POLLWRNORM普通数据可写,和 POLLOUT 等价POLLERR发生错误,只报告而不阻塞POLLHUP设备挂断实际开发中,字符设备驱动最常见的就是POLLIN。如果设备的接收缓冲区里还有数据,poll必须返回POLLIN,否则应用层即使被唤醒,内核重新检查时发现条件不满足,select还是会继续睡眠。3. 中断和poll的握手:一个数据包唤醒一条read3.1 完整链路拆解从硬件收到数据,到应用层select()返回,这条链路上的每一步都很关键。我把它拆成了这样:应用层调用select(),内核进入do_select循环。内核遍历所有 fd,调用驱动的xxx_poll。xxx_poll调用poll_wait,把当前进程挂到read_wait队列。xxx_poll发现缓冲区没有数据,返回 0,当前条件不满足。内核没有收集到任何事件,进程进入睡眠状态。硬件中断到来,驱动上半部读取数据,放到内核缓冲区。下半部(或硬中断尾部)调用wake_up_interruptible(&read_wait)。当前进程被唤醒,内核再次调用xxx_poll。这次xxx_poll发现缓冲区有数据,返回POLLIN。内核把这个事件写入用户空间,select()返回。应用层调用read(),驱动把缓冲区数据拷贝到用户空间。这个流程看起来简单,但中间有一个我当初一直没想明白的点:为什么进程睡眠后,硬件中断一来,内核就知道该去重新调用xxx_poll?答案就在于第 3 步的poll_wait。它把当前进程和read_wait队列绑定在了一次,wake_up_interruptible唤醒的是进程,而不是直接去调用poll函数。进程被唤醒后,内核为了确认条件,自然会重新跑一遍poll回调。3.2 关键代码的完整形态我把我当时写的模拟驱动核心部分贴出来,方便对照理解:static irqreturn_t xxx_irq_handler(int irq, void *dev_id) { struct xxx_dev *dev = dev_id; unsigned long flags; spin_lock_irqsave(&dev->lock, flags); /* 从硬件寄存器持续读取数据,直到 FIFO 空 */ while (!(readl(dev->reg_base + REG_STATUS) & FIFO_EMPTY)) { u32 val = readl(dev->reg_base + REG_DATA); dev->ring_buf[dev->wr] = val; dev->wr = (dev->wr + 1) % RING_SIZE; dev->data_count++; } spin_unlock_irqrestore(&dev->lock, flags); /* 唤醒 poll 等待队列中的进程 */ wake_up_interruptible(&dev->read_wait); return IRQ_HANDLED; } static unsigned int xxx_poll(struct file *filp, poll_table *wait) { struct xxx_dev *dev = filp->private_data; unsigned int mask = 0; poll_wait(filp, &dev->read_wait, wait); if (dev->data_count > 0) mask |= POLLIN | POLLRDNORM; return mask; } static ssize_t xxx_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { struct xxx_dev *dev = filp->private_data; unsigned long flags; int ret = 0; /* 如果没数据且是非阻塞,直接返回 */ if (filp->f_flags & O_NONBLOCK) { if (dev->data_count == 0) return -EAGAIN; } else { /* 阻塞读:等待数据 */ wait_event_interruptible(dev->read_wait, dev->data_count > 0); } spin_lock_irqsave(&dev->lock, flags); if (dev->data_count > 0) { /* 从环形缓冲区取出一个数据 */ ret = copy_to_user(buf, &dev->ring_buf[dev->rd], 1); if (ret == 0) { dev->rd = (dev->rd + 1) % RING_SIZE; dev->data_count--; } } spin_unlock_irqrestore(&dev->lock, flags); return ret ? ret : 1; }这个地方有个细节容易忽略:wait_event_interruptible和poll_wait用的是同一个等待队列read_wait。这样设计很巧妙,因为不管是select还是普通的阻塞read,它们睡眠时都挂在同一个队列上。中断来临时只需要一次wake_up_interruptible,就能把所有等待者全部唤醒,让各自去检查自己的条件。3.3 中断和poll握手时的时序窗口为什么这种“睡眠→唤醒→再检查”的方式不会丢事件?很多人会担心:万一事件在poll_wait注册之前就发生了怎么办?实际上内核已经考虑到了这一点。select在进入睡眠前会先调用一遍驱动的poll函数。如果在调用poll_wait之前数据就已经到了,poll会直接返回POLLIN,内核根本不会让进程睡下去。如果再检查条件时数据还没到,进程会带着已注册的等待队列进入睡眠,后面数据到来时wake_up一定能唤醒它。两者之间的窗口期被poll_wait的注册操作和条件检查的顺序安排得明明白白。不过这里有一个我需要特别提醒的坑:在驱动的poll函数里,条件检查和数据更新之间必须加锁保护。否则中断随时可能修改data_count,造成竞态条件。我自己就遇到过poll返回POLLIN,但紧接着read却发现缓冲区为空的情况,后来发现是没加锁导致的。4. 实操过程与核心环节实现4.1 设备节点注册与接口初始化要把这套机制跑起来,首先得有基本的字符设备框架。我当时的做法是:static int xxx_probe(struct platform_device *pdev) { struct xxx_dev *dev; int ret; dev = kzalloc(sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; spin_lock_init(&dev->lock); init_waitqueue_head(&dev->read_wait); /* 假设中断号来自设备树或平台资源 */ ret = request_irq(irq, xxx_irq_handler, IRQF_TRIGGER_RISING, "xxx_device", dev); if (ret) { pr_err("request_irq failed: %d\n", ret); kfree(dev); return ret; } /* 注册 misc device,自动生成 /dev/xxx */ dev->miscdev.minor = MISC_DYNAMIC_MINOR; dev->miscdev.name = "xxx"; dev->miscdev.fops = &xxx_fops; ret = misc_register(&dev->miscdev); if (ret) { free_irq(irq, dev); kfree(dev); return ret; } platform_set_drvdata(pdev, dev); return 0; }init_waitqueue_head千万别漏,等待队列没初始化就调用poll_wait或者wake_up,内核直接会出问题。4.2 应用层验证脚本驱动写完,我会先写一个非常简单的应用层测试程序,确认select能等来数据:#include <sys/select.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <stdio.h> #include <unistd.h> int main(void) { int fd = open("/dev/xxx", O_RDONLY); fd_set fds; struct timeval tv = {5, 0}; char buf[1]; int ret; while (1) { FD_ZERO(&fds); FD_SET(fd, &fds); ret = select(fd + 1, &fds, NULL, NULL, &tv); if (ret < 0) { perror("select"); break; } if (ret == 0) { printf("timeout\n"); continue; } if (FD_ISSET(fd, &fds)) { if (read(fd, buf, 1) > 0) printf("got data: 0x%02x\n", (unsigned char)buf[0]); } } close(fd); return 0; }如果驱动、中断、poll 机制都正常,每次硬件产生中断后,这个程序都会在极短时间内打印got data。如果一直打印timeout,基本可以断定中断没触发、等待队列没唤醒、或者poll条件检查有问题,三者必居其一。4.3 手动模拟中断来验证链路有些场景下硬件不方便触发真实中断,我会在驱动里加一个调试用的ioctl,手动调用一次中断处理函数,省去硬件配合的麻烦:static long xxx_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct xxx_dev *dev = filp->private_data; switch (cmd) { case XXX_IOCTL_SIMULATE_IRQ: /* 模拟硬件数据到达 */ spin_lock(&dev->lock); dev->ring_buf[dev->wr] = 0xAA; dev->wr = (dev->wr + 1) % RING_SIZE; dev->data_count++; spin_unlock(&dev->lock); wake_up_interruptible(&dev->read_wait); return 0; default: return -ENOTTY; } }这样测起来非常快,可以绕过硬件直接验证“数据更新→唤醒→poll 重新检查”这个软件链路。5. 常见问题与排查技巧实录5.1 select 永远超时,像没收到中断一样这种情况我见得太多了。排查思路一般是:先看/proc/interrupts,确认中断号对应的计数有没有增长。如果一直在涨,说明中断确实进了内核。在wake_up_interruptible前后加打印,确认唤醒代码有没有执行。检查poll_wait用的等待队列和wake_up用的是不是同一个队列。我犯过最蠢的错误就是把两个队列取成了不同名字。检查poll函数里是否在条件满足时正确返回了POLLIN。如果条件判断写反了,即使被唤醒,内核也会继续睡过去。5.2 数据没读完,但 poll 不再返回 POLLIN这个问题的根源多半是poll和read之间的条件同步没做好。比如read把数据取走了,但data_count没有正确更新,或者poll里读到的data_count是旧的。在我的项目里,解决方法是统一用自旋锁保护data_count,并且在read取出数据后立即递减,不能等到函数末尾再处理。5.3 唤醒丢失唤醒丢失是一个经典竞态问题。假设条件在进程真正睡下去之前就已经满足,进程却因为别的原因进入了睡眠,那它可能永远等不到下一次唤醒。内核的wake_up并不会保存“历史事件”,它只唤醒当前正在等待的人。解决这个问题的标准做法是使用wait_event_interruptible这类宏,它在睡眠前会再检查一次条件;select/poll本身的设计也保证了进入睡眠前会先调用poll回调检查条件,所以只要驱动严格按照“先poll_wait,再检查条件并返回掩码”的顺序写,一般不会丢唤醒。5.4 在中断上下文里做了不允许的操作我自己早期写过一个驱动,在硬中断处理函数里直接调用了copy_to_user,结果系统日志立刻报错。中断上下文里只能做原子操作,如果确有耗时逻辑,优先考虑tasklet或workqueue。如果中断处理本身就很复杂,直接使用request_threaded_irq的线程化处理是最省心的。5.5 竞态条件与加锁poll返回条件是基于共享数据data_count的,而中断处理函数也会修改这个字段。如果不加锁,可能出现poll读到旧值、read取不到数据等各种奇怪现象。我推荐在中断处理函数、poll、read三处共用一把spinlock_t,并且统一用spin_lock_irqsave处理。虽然性能会有一点损耗,但稳定性和可维护性都会大幅提升。6. 优化思路与扩展小技巧6.1 减少一次POLLIN空唤醒很多设备实际处理数据时,中断会连续触发多次,每次都wake_up一次。如果select唤醒后发现poll条件短暂不满足,就会造成“虚假唤醒”。减少这种空转的一个简单办法是:在wake_up前确认data_count确实大于 0,而不是无条件唤醒。6.2 把等待队列拆分成读和写如果设备同时支持读写,建议准备两个独立的等待队列。比如read_wait和write_wait。中断处理中数据到达时只唤醒read_wait,发送完成时只唤醒write_wait。这样可以避免读写互相影响,也是 Linux 驱动里的常规做法。6.3 多队列的 epoll 场景epoll的底层机制和select/poll不完全一样,但对驱动来说,它依然是通过poll_wait注册等待队列。所以驱动代码基本不用改,应用层把select换成epoll也能正常工作。区别在于内核层的回调管理和 fd 扫描方式更高效,适合大量连接场景。驱动开发者只要保证poll回调写得规范,epoll自然能兼容。6.4 使用 ftrace 观察唤醒时机调试“中断是否准时唤醒select”时,我习惯用 ftrace 来观察wake_up和schedule的时序。在嵌入式环境里,加打印日志往往会影响时序,ftrace 的开销要小得多。具体做法是:echo wake_up_interruptible > /sys/kernel/tracing/set_ftrace_filter echo function > /sys/kernel/tracing/current_tracer echo 1 > /sys/kernel/tracing/tracing_on cat /sys/kernel/tracing/trace这样可以精确看到wake_up_interruptible何时被调用,以及调用时进程状态。很多“明明中断了但 select 不返回”的疑难杂症,用 ftrace 一看就能定位。回看整个链路,最核心的体会就是一句话:驱动里的poll不产生事件,它只负责描述事件;唤醒事件的真正发动机是中断处理函数里的wake_up。理解了这条“中断→唤醒→再检查”的路径,select、poll、epoll在驱动层的行为就全都串起来了。下次再有人问我“中断怎么通知 select 的”,我一般会让他先去看等待队列,搞明白poll_wait不会睡眠这件事,基本就通了。
都说中断和poll机制是驱动开发的两座大山,但把它们分开看其实都不算难,真正让新手抓狂的是“中断来了之后,应用层的select/poll到底是怎么被唤醒的”这条链路。我在做某个模拟项目的外设驱动时,把这条链路完整捋了一遍,从硬件中断触发到read()返回数据,中间每一步都值得反复琢磨。这篇文章我不打算泛泛讲理论,直接从“现场”出发,把中断的下半部机制、驱动里poll函数的职责,以及中断和等待队列如何配合这件事拆开揉碎,顺便把我在实际调试中踩过的坑一并列出来。1. 中断处理机制:上半部和下半部为什么必须分开1.1 中断上下文里能做什么、不能做什么中断处理程序跑在一种特殊上下文里,它不属于任何进程,也不受进程调度器管理。这意味着你不能在里面调用会导致睡眠的函数,比如copy_to_user、kmalloc(..., GFP_KERNEL)、mutex_lock这类操作统统不行。一旦睡眠,系统根本没法调度你回来,整个 CPU 都可能被卡死。但硬件中断来了之后,你总得做点事:确认中断、读状态寄存器、把 FIFO 里的数据搬出来,否则数据可能被覆盖。实际操作中,我通常把“必须立即处理、否则会丢数据”的工作放在上半部,比如从硬件寄存器把数据搬到内存缓冲区。而真正耗时的事情,比如数据解析、协议处理、唤醒等待进程,我会丢给下半部。上半部必须快,这几乎是硬性要求。如果中断处理时间过长,不仅会阻塞同一条中断线上的其他设备,还可能造成中断丢失。我见过有人在上半部里做比较复杂的循环校验,结果一个中断把整个系统的实时性拖垮了。1.2 下半部三件套:tasklet、工作队列、线程化中断下半部的选择直接影响“中断唤醒 poll”这条链路的实现方式。这里有三条常见路线,我简单对比一下:机制运行上下文能否睡眠适合场景tasklet / softirq软中断上下文不能耗时短、要求低延迟的处理工作队列 (workqueue)内核线程(进程上下文)能耗时较长、可能需要睡眠的处理线程化中断 (threaded irq)内核线程能中断处理本身就要做大量工作的情况在实际驱动中,如果数据搬运很急,我一般选择在硬中断里做最小处理,然后通过tasklet或workqueue完成后续操作。如果中断处理本身就很重,直接用request_threaded_irq把整个处理过程放到内核线程里更省事。1.3 中断处理代码的一般形态拿一个虚拟的字符设备为例,中断处理函数通常长这样:static irqreturn_t xxx_irq_handler(int irq, void *dev_id) { struct xxx_dev *dev = dev_id; unsigned long flags; /* 读取硬件状态寄存器,确认中断来源 */ status = readl(dev->reg_base + REG_STATUS); /* 把硬件 FIFO 中的数据搬到内核缓冲区 */ spin_lock_irqsave(&dev->lock, flags); while (!(readl(dev->reg_base + REG_STATUS) & FIFO_EMPTY)) { dev->data_buf[dev->buf_wr_idx] = readl(dev->reg_base + REG_DATA); dev->buf_wr_idx = (dev->buf_wr_idx + 1) % BUF_SIZE; } spin_unlock_irqrestore(&dev->lock, flags); /* 通知等待队列,这里就是 poll 机制被触发的关键 */ wake_up_interruptible(&dev->read_wait); return IRQ_HANDLED; }这里最关键的一行就是wake_up_interruptible(&dev->read_wait)。当这个函数被调用时,所有在read_wait等待队列上睡眠的进程都会被唤醒。而poll/select正是靠这个唤醒来重新检查“数据是否就绪”的。注意:wake_up_interruptible可以在中断上下文调用,它只是把进程状态改为可运行,并不会真的在中断里运行应用层代码。很多新人担心这里会不会有问题,其实完全不用担心。2. poll机制:从系统调用到驱动的桥梁2.1 驱动里的 .poll 到底在做什么应用层调用select()或poll()时,内核最终会遍历所有被监视的文件描述符,并调用每个文件对应的file_operations.poll函数。这个函数在驱动里的职责很简单:向内核报告“当前这个设备有哪些事件已经就绪”。按 Linux 内核的约定,poll函数返回一个掩码(mask),比如有数据可读就返回POLLIN | POLLRDNORM,缓冲区可写就返回POLLOUT | POLLWRNORM,设备断开就返回POLLERR。内核拿到这些掩码后,会判断应用层关心的事件是否已经满足。需要注意的是,驱动里的poll函数并不负责睡眠。它只在被内核调用时“汇报情况”并注册唤醒回调,真正的睡眠发生在上层通用代码里。2.2 poll_wait 与等待队列:注册而非睡眠poll_wait是驱动里必须调用的一个函数,但不少初学者以为它会让当前进程睡在这里。这是一个非常关键的误解。实际上,poll_wait只是把当前进程挂到设备的等待队列上,相当于告诉内核:“如果这个队列被唤醒,请重新检查一下我的条件。”static unsigned int xxx_poll(struct file *filp, poll_table *wait) { struct xxx_dev *dev = filp->private_data; unsigned int mask = 0; /* 把当前进程挂到设备读等待队列,但不睡眠 */ poll_wait(filp, &dev->read_wait, wait); /* 检查是否有数据可读 */ if (dev->buf_rd_idx != dev->buf_wr_idx) mask |= POLLIN | POLLRDNORM; return mask; }当进程因select睡眠时,它其实睡在通用层,而不是睡在驱动里。驱动只负责把“睡眠时应该监听哪个队列”告诉内核。这样设计的好处是,驱动本身不用关心select的超时、多文件描述符管理这些复杂逻辑。2.3 掩码值的约定与含义poll的返回值可不是随便写的,内核和官方文档有明确约定。常见掩码如下:掩码含义POLLIN有数据可读POLLRDNORM普通数据可读,和 POLLIN 等价POLLOUT可以写数据POLLWRNORM普通数据可写,和 POLLOUT 等价POLLERR发生错误,只报告而不阻塞POLLHUP设备挂断实际开发中,字符设备驱动最常见的就是POLLIN。如果设备的接收缓冲区里还有数据,poll必须返回POLLIN,否则应用层即使被唤醒,内核重新检查时发现条件不满足,select还是会继续睡眠。3. 中断和poll的握手:一个数据包唤醒一条read3.1 完整链路拆解从硬件收到数据,到应用层select()返回,这条链路上的每一步都很关键。我把它拆成了这样:应用层调用select(),内核进入do_select循环。内核遍历所有 fd,调用驱动的xxx_poll。xxx_poll调用poll_wait,把当前进程挂到read_wait队列。xxx_poll发现缓冲区没有数据,返回 0,当前条件不满足。内核没有收集到任何事件,进程进入睡眠状态。硬件中断到来,驱动上半部读取数据,放到内核缓冲区。下半部(或硬中断尾部)调用wake_up_interruptible(&read_wait)。当前进程被唤醒,内核再次调用xxx_poll。这次xxx_poll发现缓冲区有数据,返回POLLIN。内核把这个事件写入用户空间,select()返回。应用层调用read(),驱动把缓冲区数据拷贝到用户空间。这个流程看起来简单,但中间有一个我当初一直没想明白的点:为什么进程睡眠后,硬件中断一来,内核就知道该去重新调用xxx_poll?答案就在于第 3 步的poll_wait。它把当前进程和read_wait队列绑定在了一次,wake_up_interruptible唤醒的是进程,而不是直接去调用poll函数。进程被唤醒后,内核为了确认条件,自然会重新跑一遍poll回调。3.2 关键代码的完整形态我把我当时写的模拟驱动核心部分贴出来,方便对照理解:static irqreturn_t xxx_irq_handler(int irq, void *dev_id) { struct xxx_dev *dev = dev_id; unsigned long flags; spin_lock_irqsave(&dev->lock, flags); /* 从硬件寄存器持续读取数据,直到 FIFO 空 */ while (!(readl(dev->reg_base + REG_STATUS) & FIFO_EMPTY)) { u32 val = readl(dev->reg_base + REG_DATA); dev->ring_buf[dev->wr] = val; dev->wr = (dev->wr + 1) % RING_SIZE; dev->data_count++; } spin_unlock_irqrestore(&dev->lock, flags); /* 唤醒 poll 等待队列中的进程 */ wake_up_interruptible(&dev->read_wait); return IRQ_HANDLED; } static unsigned int xxx_poll(struct file *filp, poll_table *wait) { struct xxx_dev *dev = filp->private_data; unsigned int mask = 0; poll_wait(filp, &dev->read_wait, wait); if (dev->data_count > 0) mask |= POLLIN | POLLRDNORM; return mask; } static ssize_t xxx_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { struct xxx_dev *dev = filp->private_data; unsigned long flags; int ret = 0; /* 如果没数据且是非阻塞,直接返回 */ if (filp->f_flags & O_NONBLOCK) { if (dev->data_count == 0) return -EAGAIN; } else { /* 阻塞读:等待数据 */ wait_event_interruptible(dev->read_wait, dev->data_count > 0); } spin_lock_irqsave(&dev->lock, flags); if (dev->data_count > 0) { /* 从环形缓冲区取出一个数据 */ ret = copy_to_user(buf, &dev->ring_buf[dev->rd], 1); if (ret == 0) { dev->rd = (dev->rd + 1) % RING_SIZE; dev->data_count--; } } spin_unlock_irqrestore(&dev->lock, flags); return ret ? ret : 1; }这个地方有个细节容易忽略:wait_event_interruptible和poll_wait用的是同一个等待队列read_wait。这样设计很巧妙,因为不管是select还是普通的阻塞read,它们睡眠时都挂在同一个队列上。中断来临时只需要一次wake_up_interruptible,就能把所有等待者全部唤醒,让各自去检查自己的条件。3.3 中断和poll握手时的时序窗口为什么这种“睡眠→唤醒→再检查”的方式不会丢事件?很多人会担心:万一事件在poll_wait注册之前就发生了怎么办?实际上内核已经考虑到了这一点。select在进入睡眠前会先调用一遍驱动的poll函数。如果在调用poll_wait之前数据就已经到了,poll会直接返回POLLIN,内核根本不会让进程睡下去。如果再检查条件时数据还没到,进程会带着已注册的等待队列进入睡眠,后面数据到来时wake_up一定能唤醒它。两者之间的窗口期被poll_wait的注册操作和条件检查的顺序安排得明明白白。不过这里有一个我需要特别提醒的坑:在驱动的poll函数里,条件检查和数据更新之间必须加锁保护。否则中断随时可能修改data_count,造成竞态条件。我自己就遇到过poll返回POLLIN,但紧接着read却发现缓冲区为空的情况,后来发现是没加锁导致的。4. 实操过程与核心环节实现4.1 设备节点注册与接口初始化要把这套机制跑起来,首先得有基本的字符设备框架。我当时的做法是:static int xxx_probe(struct platform_device *pdev) { struct xxx_dev *dev; int ret; dev = kzalloc(sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; spin_lock_init(&dev->lock); init_waitqueue_head(&dev->read_wait); /* 假设中断号来自设备树或平台资源 */ ret = request_irq(irq, xxx_irq_handler, IRQF_TRIGGER_RISING, "xxx_device", dev); if (ret) { pr_err("request_irq failed: %d\n", ret); kfree(dev); return ret; } /* 注册 misc device,自动生成 /dev/xxx */ dev->miscdev.minor = MISC_DYNAMIC_MINOR; dev->miscdev.name = "xxx"; dev->miscdev.fops = &xxx_fops; ret = misc_register(&dev->miscdev); if (ret) { free_irq(irq, dev); kfree(dev); return ret; } platform_set_drvdata(pdev, dev); return 0; }init_waitqueue_head千万别漏,等待队列没初始化就调用poll_wait或者wake_up,内核直接会出问题。4.2 应用层验证脚本驱动写完,我会先写一个非常简单的应用层测试程序,确认select能等来数据:#include <sys/select.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <stdio.h> #include <unistd.h> int main(void) { int fd = open("/dev/xxx", O_RDONLY); fd_set fds; struct timeval tv = {5, 0}; char buf[1]; int ret; while (1) { FD_ZERO(&fds); FD_SET(fd, &fds); ret = select(fd + 1, &fds, NULL, NULL, &tv); if (ret < 0) { perror("select"); break; } if (ret == 0) { printf("timeout\n"); continue; } if (FD_ISSET(fd, &fds)) { if (read(fd, buf, 1) > 0) printf("got data: 0x%02x\n", (unsigned char)buf[0]); } } close(fd); return 0; }如果驱动、中断、poll 机制都正常,每次硬件产生中断后,这个程序都会在极短时间内打印got data。如果一直打印timeout,基本可以断定中断没触发、等待队列没唤醒、或者poll条件检查有问题,三者必居其一。4.3 手动模拟中断来验证链路有些场景下硬件不方便触发真实中断,我会在驱动里加一个调试用的ioctl,手动调用一次中断处理函数,省去硬件配合的麻烦:static long xxx_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct xxx_dev *dev = filp->private_data; switch (cmd) { case XXX_IOCTL_SIMULATE_IRQ: /* 模拟硬件数据到达 */ spin_lock(&dev->lock); dev->ring_buf[dev->wr] = 0xAA; dev->wr = (dev->wr + 1) % RING_SIZE; dev->data_count++; spin_unlock(&dev->lock); wake_up_interruptible(&dev->read_wait); return 0; default: return -ENOTTY; } }这样测起来非常快,可以绕过硬件直接验证“数据更新→唤醒→poll 重新检查”这个软件链路。5. 常见问题与排查技巧实录5.1 select 永远超时,像没收到中断一样这种情况我见得太多了。排查思路一般是:先看/proc/interrupts,确认中断号对应的计数有没有增长。如果一直在涨,说明中断确实进了内核。在wake_up_interruptible前后加打印,确认唤醒代码有没有执行。检查poll_wait用的等待队列和wake_up用的是不是同一个队列。我犯过最蠢的错误就是把两个队列取成了不同名字。检查poll函数里是否在条件满足时正确返回了POLLIN。如果条件判断写反了,即使被唤醒,内核也会继续睡过去。5.2 数据没读完,但 poll 不再返回 POLLIN这个问题的根源多半是poll和read之间的条件同步没做好。比如read把数据取走了,但data_count没有正确更新,或者poll里读到的data_count是旧的。在我的项目里,解决方法是统一用自旋锁保护data_count,并且在read取出数据后立即递减,不能等到函数末尾再处理。5.3 唤醒丢失唤醒丢失是一个经典竞态问题。假设条件在进程真正睡下去之前就已经满足,进程却因为别的原因进入了睡眠,那它可能永远等不到下一次唤醒。内核的wake_up并不会保存“历史事件”,它只唤醒当前正在等待的人。解决这个问题的标准做法是使用wait_event_interruptible这类宏,它在睡眠前会再检查一次条件;select/poll本身的设计也保证了进入睡眠前会先调用poll回调检查条件,所以只要驱动严格按照“先poll_wait,再检查条件并返回掩码”的顺序写,一般不会丢唤醒。5.4 在中断上下文里做了不允许的操作我自己早期写过一个驱动,在硬中断处理函数里直接调用了copy_to_user,结果系统日志立刻报错。中断上下文里只能做原子操作,如果确有耗时逻辑,优先考虑tasklet或workqueue。如果中断处理本身就很复杂,直接使用request_threaded_irq的线程化处理是最省心的。5.5 竞态条件与加锁poll返回条件是基于共享数据data_count的,而中断处理函数也会修改这个字段。如果不加锁,可能出现poll读到旧值、read取不到数据等各种奇怪现象。我推荐在中断处理函数、poll、read三处共用一把spinlock_t,并且统一用spin_lock_irqsave处理。虽然性能会有一点损耗,但稳定性和可维护性都会大幅提升。6. 优化思路与扩展小技巧6.1 减少一次POLLIN空唤醒很多设备实际处理数据时,中断会连续触发多次,每次都wake_up一次。如果select唤醒后发现poll条件短暂不满足,就会造成“虚假唤醒”。减少这种空转的一个简单办法是:在wake_up前确认data_count确实大于 0,而不是无条件唤醒。6.2 把等待队列拆分成读和写如果设备同时支持读写,建议准备两个独立的等待队列。比如read_wait和write_wait。中断处理中数据到达时只唤醒read_wait,发送完成时只唤醒write_wait。这样可以避免读写互相影响,也是 Linux 驱动里的常规做法。6.3 多队列的 epoll 场景epoll的底层机制和select/poll不完全一样,但对驱动来说,它依然是通过poll_wait注册等待队列。所以驱动代码基本不用改,应用层把select换成epoll也能正常工作。区别在于内核层的回调管理和 fd 扫描方式更高效,适合大量连接场景。驱动开发者只要保证poll回调写得规范,epoll自然能兼容。6.4 使用 ftrace 观察唤醒时机调试“中断是否准时唤醒select”时,我习惯用 ftrace 来观察wake_up和schedule的时序。在嵌入式环境里,加打印日志往往会影响时序,ftrace 的开销要小得多。具体做法是:echo wake_up_interruptible > /sys/kernel/tracing/set_ftrace_filter echo function > /sys/kernel/tracing/current_tracer echo 1 > /sys/kernel/tracing/tracing_on cat /sys/kernel/tracing/trace这样可以精确看到wake_up_interruptible何时被调用,以及调用时进程状态。很多“明明中断了但 select 不返回”的疑难杂症,用 ftrace 一看就能定位。回看整个链路,最核心的体会就是一句话:驱动里的poll不产生事件,它只负责描述事件;唤醒事件的真正发动机是中断处理函数里的wake_up。理解了这条“中断→唤醒→再检查”的路径,select、poll、epoll在驱动层的行为就全都串起来了。下次再有人问我“中断怎么通知 select 的”,我一般会让他先去看等待队列,搞明白poll_wait不会睡眠这件事,基本就通了。