中断和poll,是Linux驱动开发里两个绕不开的大件。硬件信号来了,内核靠中断机制优先响应;应用层想等数据,又不愿意傻乎乎地 read 成天阻塞,于是就有了 poll/select/epoll 这一族多路复用接口。真正把它俩打通,你会发现:中断是事件的源头,poll 是事件的通知器,中间靠等待队列和唤醒机制连接。这篇文章适合写过简单字符设备驱动、但被阻塞读和非阻塞读绕晕的同学,也适合准备深入内核机制、想搞懂 poll 回调背后调度逻辑的开发者。文章不绑特定硬件平台,示例基于通用字符设备框架,主线内核 4.x 到 6.x 都可以编译验证。
1. 先把中断机制拆开看
1.1 从硬件信号到内核执行:一条中断到底走了多远
中断不是一个函数调用,而是一条完整的硬件-内核协作链路。当外部设备产生事件,比如串口收到一帧数据、按键被按下一角、DMA搬运结束,硬件会把对应的中断请求线拉高,中断控制器(常见如GIC)经过优先级仲裁后,向CPU核发出异常信号。CPU响应异常,会暂停当前执行的指令流,跳到内核预置的中断向量入口,接着进入体系结构相关的底层代码,最后分发到do_IRQ或generic_handle_irq。
从驱动开发者的视角看,最终落到我们手里的是一个irqreturn_t (*handler)(int irq, void *dev_id)回调。这个回调运行在所谓中断上下文里,它不是任何进程的执行现场,没有current指向的那个用户进程可供调度,也不允许发生睡眠。这一点是理解后面所有坑的地基。很多人一上来就纠结“为什么不能在中断里调用mutex_lock”,答案就是:内核根本无法为中断上下文做进程切换,睡眠动作没有可依托的任务对象,硬要睡下去,系统直接 panic 或者无限卡死。
1.2 上半部和下半部:为什么一个中断要拆成两截
中断处理讲究“快”,因为CPU响应中断时通常会把本地中断关掉或屏蔽相关优先级,拖太久会导致别的中断被延迟,甚至造成数据丢失。但实际设备处理往往不只是清除pending标志那么简单:可能要读硬件FIFO、搬数据、通知等待者、解析协议。这些都放在硬中断回调里,系统延迟会很难看。
所以内核把中断处理拆成上半部(hardirq)和下半部(deferred work)。上半部只做最关键、必须立即做的操作:确认中断源、清中断标志、把设备状态快速读走或记录一下。其余事情交给下半部,在中断开启的情况下补做。Linux提供的主要下半部机制有几种,我按实践场景做了个对照:
| 机制 | 执行上下文 | 能否睡眠 | 典型场景 |
|---|---|---|---|
| tasklet | softirq上下文 | 不能 | 短小数据搬移、快速状态更新 |
| workqueue | 内核线程(进程上下文) | 可以 | 耗时处理、依赖锁或可睡眠API |
| threaded irq | 中断线程 | 可以,受线程调度 | 大量使用通用内核API的中断处理 |
tasklet用起来最轻,但注意它跑在软中断环境,不能睡眠。workqueue 交给一个内核线程执行,你可以放心用mutex、kmalloc(GFP_KERNEL),代价是执行时机有调度延迟,可能有几十微秒到几毫秒的波动。threaded irq 则把整个中断 handler 放进专用内核线程,常见于需要长时间操作且不想阻塞其他中断的设备,现代内核里甚至可以把所有中断都默认线程化。
需要强调一个我碰过多次的事情:tasklet 虽然是“下半部”,但它并非完全无所顾忌。它在同一个CPU上以软中断方式运行,如果软中断被禁用,它也会延迟;而且不同的tasklet之间可能存在依赖关系时,要特别小心执行顺序。
1.3 中断上下文里的“禁区”清单
中断上下文能做什么、不能做什么,我有几个铁律:
- 不能睡眠:不能调用
wait_event、mutex_lock、down_interruptible,也不能使用copy_to_user/copy_from_user,因为用户态页可能不在内存里,访问它需要调度等待。 kmalloc只能带GFP_ATOMIC,不能带GFP_KERNEL。前者允许在无法睡眠时从紧急内存池分配。- 自旋锁要使用
spin_lock_irqsave/spin_unlock_irqrestore,不能只用普通spin_lock。原因普通锁可能在持有期间被同一CPU上的硬中断打断,如果中断处理里也尝试获取同一把锁,就死锁了。irqsave可以在加锁时保存当前中断标志、关闭本地中断,解锁时恢复。 - 可以做的:读写寄存器、更新内存变量、操作链表、调度tasklet/workqueue、设置定时器,甚至安全地
wake_up。
这些限制不是教条。我见过一次现场:某驱动在硬中断回调里用copy_to_user回传数据,开发板跑着跑着就报BUG: sleeping function called from invalid context,然后系统hang住。问题定位不难,但如果不清楚中断上下文边界,会白折腾好几天。
2. poll机制到底在干什么
2.1 阻塞读的痛点与多路复用的价值
先回顾一个基础场景:应用层read(fd, buf, len)打开一个字符设备,默认情况下如果没有数据可读,进程会睡在驱动内部的等待队列上,直到设备有数据把它唤醒。单设备单进程时,这个模型很好。但真实程序往往不只面对一个设备,比如一个终端程序同时要处理键盘输入、串口数据和网络报文,如果对每个fd都做阻塞读,一个fd没数据就把整个进程卡住,其他fd的处理全部停了。
解决办法是poll:它让你先检查一组文件描述符是否有事件发生,有就读,没有就统一等待,时间到了或者被信号中断再回来。事件包括可读、可写、出错、挂断等。从驱动角度说,任何实现了file_operations里.poll回调的字符设备,都能被poll/select/epoll统一监管。这也是为什么“会写字符设备驱动但还没写poll回调”的人,一到应用层对接就发现通知不灵。
2.2 poll系统调用的内核执行路径
用户态一个poll(fds, nfds, timeout),内核里的大致路径是:
- 进入
do_sys_poll,处理fds数组在内核态的分批拷贝。 - 进入
do_poll主循环,把当前任务状态置为TASK_INTERRUPTIBLE。 - 对每个fd调用
do_pollfd,最终落到vfs_poll,再调用file->f_op->poll(file, wait)。 - 驱动poll回调里调用
poll_wait把自己暴露的等待队列挂到当前poll等待机制上,然后返回剩余事件掩码。 - 如果没有任何fd返回有效事件,并且timeout没到,调用
schedule_timeout真正睡眠。 - 驱动在事件到来时调用
wake_up_interruptible唤醒这个睡眠者。 - 内核醒来后重新遍历fd,再次调用驱动poll回调,检查事件是否成立;成立就返回,不成立则继续睡。
有个认知最关键:驱动里的poll_wait并不等价于睡眠,它只是让“这个进程在等待这个设备的等待队列”这件事被登记下来。真正睡着是在poll系统调用层级的schedule_timeout里。所以,不要说成“驱动调用poll_wait让进程睡眠了”,这个说法是把两个层次混在一起了。
2.3 驱动poll回调:mask、poll_wait与等待队列
驱动侧要实现的回调长这样:
static unsigned int demo_poll(struct file *filp, poll_table *wait) { unsigned int mask = 0; poll_wait(filp, &dev->rwq, wait); if (有数据可读) mask |= POLLIN | POLLRDNORM; return mask; }poll_table是内核传递下来的等待机制描述。poll_wait(filp, &queue, wait)会把当前等待项挂到queue这个wait_queue_head_t上。你必须在条件可能从“不满足”变成“满足”之前注册这个等待队列,否则中断来了执行wake_up,poll进程不在队列上,自然不会被唤醒。
返回值mask是一组位掩码,驱动要根据自己当前的真实状态填充。常用的几个:
| 掩码 | 含义 | 什么时候置位 |
|---|---|---|
| POLLIN | 有紧急或普通数据可读 | 缓冲区非空,read不会阻塞 |
| POLLRDNORM | 普通数据可读 | 通常与POLLIN一起置位 |
| POLLOUT | 可写 | 缓冲区有空间,write不会阻塞 |
| POLLWRNORM | 普通数据可写 | 通常与POLLOUT一起置位 |
| POLLERR | 设备错误 | 硬件故障、异常状态 |
| POLLHUP | 设备挂断 | 连接断开、设备消失 |
| POLLNVAL | fd无效 | 一般由VFS设置,驱动不用主动置位 |
常见新手误区是:在poll回调里只判断缓冲区并返回POLLIN,却忘了poll_wait;或者poll_wait挂在“读写”等待队列上,而中断里wake_up用的是另一个队列,结果就是中断发生了,poll进程却一直睡到超时。
2.4 poll、select与epoll,驱动层为何无差别
应用层常纠结用poll还是epoll,但驱动开发者要明白一点:无论上层是poll、select还是epoll,最终都会调用设备驱动的.poll回调。三者只是用户态等待机制不同:
| 机制 | fd组织方式 | 触发效率 | fd数量限制 | 适合场景 |
|---|---|---|---|---|
| select | fd_set位图 | 每次全量扫描 O(n) | 受FD_SETSIZE限制,通常1024 | 少量fd、简单场景 |
| poll | pollfd数组 | 每次全量扫描 O(n) | 理论上可超过1024,内核分批处理 | 中等数量fd |
| epoll | 内核红黑树+回调 | 只返回就绪fd,O(事件数) | 海量fd | 高并发网络服务 |
对设备驱动来说,你的.poll回调要做得足够“快”和“准确”:快,是因为select/poll全量扫描时,每个fd都要调一次;准确,是因为epoll通过回调事件驱动,如果mask混乱,上层会出现漏唤醒或空唤醒。很多网络服务在连接数上来之后性能暴跌,可能不全在协议栈,也可能和某个设备驱动poll回调里的低效锁有关系。
3. 中断和poll怎么配合
3.1 一条完整的数据时间线
把中断和poll串起来看,真实的数据流是这样的:
- 硬件事件发生,中断信号到达CPU。
- 内核进入中断处理,执行驱动的硬中断回调
demo_isr。 - 硬中断回调快速读取设备状态、清中断标志,调用
tasklet_schedule调度下半部,然后返回IRQ_HANDLED。 - 稍后tasklet在软中断上下文执行,把数据从硬件FIFO挪到驱动的软件环形缓冲区。
- tasklet在修改完数据之后,执行
wake_up_interruptible(&dev->rwq)。 - 这时如果有一个进程正睡眠在
poll系统调用里,它会被唤醒,并从schedule_timeout返回。 - 内核重新遍历pollfd数组,再次调用驱动的
.poll回调。 - poll回调检查环形缓冲区,发现有数据,返回
POLLIN。 - 系统调用返回用户态,用户程序看到
revents & POLLIN,执行read。 - read从环形缓冲区取出数据,返回应用层。
这条链路里,中断和poll本身并不直接通信,中间介质的两个核心东西是:驱动里的等待队列头,以及共享的环形缓冲区。等得对、放得对、唤得对,链路就通。任何一环对不上,现象就是“中断计数在涨,应用层却等不到poll返回”。
3.2 什么时候wake_up更稳妥
原则上不要在硬中断上半部直接做复杂唤醒操作。虽然内核允许在中断上下文调用wake_up_interruptible,但它可能引发调度器重调度,延长硬中断处理时间,还可能带来额外的锁竞争。更常见的做法是把数据搬移放到tasklet里,搬完再唤醒。
如果数据搬移本身很轻量,tasklet没问题;如果需要做较多数据处理,或者需要调用mutex_lock、操作用户态缓冲区等,应该改用workqueue。不过workqueue延迟更高,如果设备要求低延迟事件通知,可以优先考虑threaded irq,把整个handler放进专用内核线程,既不影响其他中断,又能在这个线程里安全睡眠等待某些资源。
这里有取舍:tasklet延迟低但不能睡,workqueue能睡但延迟不保证。真实工程里,按键、传感器这类对延迟敏感的设备,我用tasklet+立即wake_up多一些;而协议解析、大数据量搬移,我会丢给workqueue。
3.3 虚假唤醒:为什么poll被唤醒后还要重新查一遍状态
等待队列的唤醒只是“通知你去重新检查一下条件”,并不保证条件一定已经成立。举个例子:两个进程都poll同一个设备,tasklet里wake_up把两个都叫醒,但缓冲区的数据只够一个进程读走,另一个进程重新执行poll回调时发现缓冲区又空了,于是返回mask 0,继续睡眠。这就叫虚假唤醒(spurious wakeup)。
这个特性也要求驱动poll回调必须是“状态驱动”的:每次调用都重新查看缓冲区、设备状态、错误标志,不能因为上次被唤醒就一定置POLLIN。反过来说,应用层代码也不要假设poll返回就一定能read到数据,可能被其他进程抢先,也可能设备状态刚好变化。严谨的应用层逻辑是:poll返回后,read如果返回EAGAIN或EINTR,就回到poll继续等待。
3.4 锁与并发:中断上下文、软中断与进程上下文的三方竞争
环形缓冲区会被三处访问:硬中断回调可能读状态、tasklet写数据、read/poll读数据。它们可能在同一个CPU上交叉打断,也可能在不同CPU上同时执行。因此对缓冲区索引和数据的每次修改,都要用锁保护。
我习惯用一把自旋锁保护环形缓冲区的所有索引更新和读写,并且在tasklet、read、poll回调里都使用spin_lock_irqsave/spin_unlock_irqrestore。这样即使某段代码本身在进程上下文,也能避免因硬中断打断同一CPU导致死锁。注意不要在持有锁的时候调用wait_event或者wake_up里可能引起睡眠的东西,正确顺序是:先解锁,再唤醒,或者先唤醒,再锁?通常推荐在完整修改状态后,解锁,然后执行wake_up。
写代码时,口头禅就是:中断路径要短,锁粒度要小,唤醒动作放到状态更新之后。这三点做到,后续调试并发的概率会低很多。
4. 实操:写一个带poll的字符设备驱动
4.1 设备结构与初始化
下面这个示例完全为了讲机制,不依赖具体硬件。我虚构一个“数据采集字符设备”:硬件中断到来时产生一个采样值,驱动把采样值放进环形缓冲区;应用层用poll等待数据。为了让没有开发板的人也能验证,我让write操作模拟一次硬件中断,主动调度tasklet,这样你只需要一个普通Linux机器也能跑通整个链路。
先看设备结构:
#include <linux/module.h> #include <linux/init.h> #include <linux/miscdevice.h> #include <linux/fs.h> #include <linux/poll.h> #include <linux/interrupt.h> #include <linux/wait.h> #include <linux/slab.h> #include <linux/uaccess.h> #include <linux/spinlock.h> #define DEMO_BUF_LEN 64 struct demo_dev { wait_queue_head_t rwq; /* 读等待队列 */ wait_queue_head_t wwq; /* 写等待队列 */ spinlock_t lock; unsigned char buf[DEMO_BUF_LEN]; unsigned int in; /* 写索引 */ unsigned int out; /* 读索引 */ unsigned int irq; struct tasklet_struct tlet; }; static struct demo_dev *g_dev;环形缓冲区留一个空位来区分“空”和“满”,所以最多存放DEMO_BUF_LEN - 1个字节。这个细节在write和read的索引判断里能看出来。
初始化函数注册一个misc设备,避免写一大堆字符设备注册样板:
static int demo_open(struct inode *inode, struct file *filp) { filp->private_data = g_dev; return 0; } static int demo_release(struct inode *inode, struct file *filp) { return 0; } static struct file_operations demo_fops = { .owner = THIS_MODULE, .open = demo_open, .release = demo_release, .read = demo_read, .write = demo_write, .poll = demo_poll, }; static struct miscdevice demo_misc = { .minor = MISC_DYNAMIC_MINOR, .name = "demo_poll_dev", .fops = &demo_fops, }; static int __init demo_init(void) { int ret; g_dev = kzalloc(sizeof(*g_dev), GFP_KERNEL); if (!g_dev) return -ENOMEM; init_waitqueue_head(&g_dev->rwq); init_waitqueue_head(&g_dev->wwq); spin_lock_init(&g_dev->lock); tasklet_init(&g_dev->tlet, demo_tasklet_handler, (unsigned long)g_dev); /* 真实项目中这里用platform_get_irq/gpiod_to_irq等获取中断号 */ g_dev->irq = 0; ret = misc_register(&demo_misc); if (ret) { kfree(g_dev); return ret; } return 0; } static void __exit demo_exit(void) { tasklet_kill(&g_dev->tlet); misc_deregister(&demo_misc); kfree(g_dev); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL");我在驱动初始化里把g_dev->irq = 0先占位,没有真正request_irq。因为虚拟示例里没有真实硬件中断,后续用write模拟。如果读者有真实设备,用request_irq把硬中断回调demo_isr注册上,并把中断号换成实际获取到的irq即可。这样示例代码在任何机器上都能加载测试,不会因为缺硬件而跑不起来。
4.2 中断处理与下半部实现
真实环境下的硬中断回调大致是这个形状:
static irqreturn_t demo_isr(int irq, void *dev_id) { struct demo_dev *dev = dev_id; /* 读取中断状态寄存器,确认是否属于本设备 */ /* 如果不是自己的中断,返回IRQ_NONE */ /* 清除硬件pending标志 */ tasklet_schedule(&dev->tlet); return IRQ_HANDLED; }共享中断场景特别要注意:如果你注册成IRQF_SHARED,而中断触发后没有读取状态寄存器判源,直接返回IRQ_HANDLED,会把别人的中断也吞掉。我见过因为多设备共享一条GPIO中断线,某个驱动乱返回IRQ_HANDLED,导致另一个设备的中断完全失效,这种问题很难查。
下半部tasklet实现:
static void demo_tasklet_handler(unsigned long data) { struct demo_dev *dev = (struct demo_dev *)data; unsigned long flags; unsigned int next; /* 模拟从硬件FIFO中读到一个采样值 */ spin_lock_irqsave(&dev->lock, flags); next = (dev->in + 1) % DEMO_BUF_LEN; if (next != dev->out) { dev->buf[dev->in] = 0xA5; dev->in = next; } spin_unlock_irqrestore(&dev->lock, flags); wake_up_interruptible(&dev->rwq); }注意顺序:先修改缓冲区,再唤醒。如果把wake_up放在修改之前,poll进程被唤醒后会立刻重新检查状态,那时数据还没写进缓冲区,就会继续睡,然后因为唤醒事件已经消耗掉,可能出现一次“假唤醒但不读数据”的损耗。虽然循环会再次睡眠,但多了一次无谓的调度。
4.3 read/write与poll回调的配合
read实现采用“条件不足则睡眠”的标准写法,同时支持非阻塞模式:
static ssize_t demo_read(struct file *filp, char __user *ubuf, size_t size, loff_t *off) { struct demo_dev *dev = filp->private_data; unsigned long flags; ssize_t cnt = 0; while (dev->out == dev->in) { if (filp->f_flags & O_NONBLOCK) return -EAGAIN; if (wait_event_interruptible(dev->rwq, dev->out != dev->in)) return -ERESTARTSYS; } spin_lock_irqsave(&dev->lock, flags); while (cnt < size && dev->out != dev->in) { if (put_user(dev->buf[dev->out], ubuf + cnt)) { spin_unlock_irqrestore(&dev->lock, flags); return -EFAULT; } dev->out = (dev->out + 1) % DEMO_BUF_LEN; cnt++; } spin_unlock_irqrestore(&dev->lock, flags); wake_up_interruptible(&dev->wwq); return cnt; }wait_event_interruptible在等待期间允许被信号打断,返回非0值时,驱动返回-ERESTARTSYS,让系统调用有机会被信号重启。O_NONBLOCK模式下不等待,直接返回EAGAIN,这是非阻塞IO的标准语义。
write这里我简化成模拟一次硬件中断:
static ssize_t demo_write(struct file *filp, const char __user *ubuf, size_t size, loff_t *off) { /* 为了无硬件环境演示,把write当作一次伪中断触发源 */ tasklet_schedule(&g_dev->tlet); return size; }真实项目里,write应该按设备语义处理用户数据,这行纯粹是让你在普通PC上也能看到poll被唤醒的效果。
poll回调是本篇的核心:
static unsigned int demo_poll(struct file *filp, poll_table *wait) { struct demo_dev *dev = filp->private_data; unsigned int mask = 0; unsigned long flags; poll_wait(filp, &dev->rwq, wait); poll_wait(filp, &dev->wwq, wait); spin_lock_irqsave(&dev->lock, flags); if (dev->out != dev->in) mask |= POLLIN | POLLRDNORM; if ((dev->in + 1) % DEMO_BUF_LEN != dev->out) mask |= POLLOUT | POLLWRNORM; spin_unlock_irqrestore(&dev->lock, flags); return mask; }poll_wait先把读、写两个等待队列都挂上。之后判断缓冲区状态:有数据读就置POLLIN;还有空间写就置POLLOUT。注意poll回调不能睡眠,所以锁用的是自旋锁,不是互斥锁。
4.4 用户态测试程序
编写一个简单的用户态测试程序poll_test.c:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <fcntl.h> #include <poll.h> int main(void) { int fd; struct pollfd pfd; char buf[128]; int ret; fd = open("/dev/demo_poll_dev", O_RDWR); if (fd < 0) { perror("open"); return 1; } memset(&pfd, 0, sizeof(pfd)); pfd.fd = fd; pfd.events = POLLIN; printf("waiting for data...\n"); ret = poll(&pfd, 1, 5000); if (ret <= 0) { perror("poll"); close(fd); return 1; } printf("poll returns %d, revents=0x%x\n", ret, pfd.revents); if (pfd.revents & POLLIN) { ret = read(fd, buf, sizeof(buf)); if (ret > 0) printf("read %d bytes, first=0x%02x\n", ret, (unsigned char)buf[0]); } close(fd); return 0; }编译和加载步骤:
gcc -o poll_test poll_test.c make -C /lib/modules/$(uname -r)/build M=$(pwd) modules sudo insmod demo_poll.ko加载后,如果系统有udev,会自动创建/dev/demo_poll_dev。如果没有,用以下方式手工建节点:
cat /sys/class/misc/demo_poll_dev/dev # 输出形如 "10 238" sudo mknod /dev/demo_poll_dev c 10 238先开一个终端运行:
./poll_test这时它会阻塞在poll上等待数据。再开一个终端执行:
echo 1 > /dev/demo_poll_devwrite会触发tasklet,tasklet写入采样数据并唤醒读等待队列,poll测试程序随即返回POLLIN,read读到字节。整个“中断->下半部->唤醒->poll返回->read”的闭环就验证完了。
4.5 没有真实硬件也能玩的替代方案
如果连write模拟都不想用,想更接近真实中断,可以用内核高精度定时器周期性触发tasklet。在初始化时:
static struct hrtimer demo_timer; static enum hrtimer_restart demo_timer_handler(struct hrtimer *timer) { tasklet_schedule(&g_dev->tlet); hrtimer_forward_now(timer, ms_to_ktime(100)); return HRTIMER_RESTART; }不过这是个临时办法。真实设备驱动应该用request_irq或devm_request_irq注册中断,并把tasklet/threaded handler接上。核心机制不变:中断来了,数据处理完,唤醒等待队列,poll就醒了。
5. 常见问题与查错实录
5.1 poll永不返回,先查这几处
这是问得最多的现象,应用层poll卡住,超时都不返回。我的排查顺序固定如下:
- 先看
cat /proc/interrupts里对应中断号计数有没有增长。如果没增长,问题在硬件触发侧:中断号对不对、触发边沿/电平配置对不对、设备休眠与否、中断控制器使能没使能。 - 中断增长了,但tasklet没跑,看看是不是有别的软中断把TASKLET长时间延后,或者驱动里反复 disable_irq 忘了 enable_irq。
- tasklet跑了,但 poll 还是没反应,老规矩检查两把等待队列的地址:poll回调里
poll_wait挂的是&dev->rwq,wake_up用的也是&dev->rwq吗?不是同一个就白唤。 - 用户态
fds[0].events有没有置 POLLIN?很多人只设了events = 0,poll返回后自然什么都没有。 - timeout是不是设成了0?0表示只轮询不等待,可能你以为在等,其实它立即返回了。
5.2 中断次数正常但pol就是没反应
除了等待队列不匹配,还有一个隐蔽点:如果你在硬中断回调里需要某些条件才返回IRQ_HANDLED,而条件判断写错,比如一直返回IRQ_NONE,内核会认为该中断无人处理,可能会在多次无主中断后自动禁用它。现象就是/proc/interrupts里计数在涨,但驱动一个tasklet都没排上。共享中断尤其容易踩这个坑。写共享中断回调时,务必先读取硬件状态寄存器,确定是不是本设备事件;是自己的就处理并返回IRQ_HANDLED,不是自己的返回IRQ_NONE。
还有一种可能是你注册了多个设备实例,每个实例有各自的wait_queue_head_t,但中断回调里用的是全局g_dev,而应用层open的是另一个设备节点对应的实例。多实例设备在open里按次设备号区分private_data,不能一股脑塞全局变量。
5.3 中断上下文里睡觉的典型翻车现场
内核日志出现这么一行,基本可以断定驱动在中断上下文里踩了睡眠函数:
BUG: sleeping function called from invalid context at kernel/...常见动作是:在硬中断回调或tasklet里调用wait_event、mutex_lock、kmalloc(GFP_KERNEL)、copy_to_user。解决办法是把这些动作挪到workqueue或threaded irq里。tasklet里也不能睡,这不是说tasklet可以随便睡,它运行在软中断关闭调度的环境,一样不具备睡眠条件。
我调试时会在疑似路径开头加一句printk(KERN_DEBUG "%s: in_interrupt=%d\n", __func__, in_interrupt());快速确认上下文。也可以用ftrace的wakeup_rttracer观察延迟,但多数时候in_interrupt()足够定位。
5.4 POLLIN/POLLOUT语义陷阱
poll返回的事件mask不要凭感觉写。比如POLLIN代表“现在read一定不会阻塞”,如果数据正被另一个进程read拿走,你的poll回调应重新检查缓冲区,而不是凭着一个旧标志返回POLLIN。否则用户态read返回EAGAIN,这就是典型空唤醒,应用层会觉得驱动行为怪异。
POLLOUT同理,代表“现在write有空间”。如果缓冲区已经满,write会阻塞,那你就不应该返回POLLOUT。我见过一些驱动图省事,无条件返回POLLIN | POLLOUT,导致上层select/poll每次都立即返回,CPU空转,应用层忙等,根本收不到中断唤醒的事件语义。
POLLERR不要随意置位。它表示设备发生错误,应该和硬件故障、协议异常、链路断开等真实状态绑定。滥用POLLERR会让应用层误判设备不可用。
5.5 推荐的排查工具组合
/proc/interrupts:确认硬中断是否到达,计数是否增长。/proc/softirqs:看TASKLET列是否和中断次数对应。cat /sys/kernel/debug/tracing/trace配合function_graph:追踪demo_isr到demo_tasklet_handler再到唤醒函数的执行链路。strace -e poll,read ./poll_test:站在应用层看系统调用行为,确认poll返回值和read返回值是否契合。devmem或debugfs读取硬件寄存器状态:确认设备是否真的把数据FIFO填上了。
这些工具组合起来,把“硬件事件->硬中断->下半部->唤醒->poll重扫->应用层read”每一段都打上探针,就能快速定位问题在哪一环断掉。
6. 最后的经验小结
早几年我做驱动调试时,遇到“中断明明在触发,应用层却收不到”的问题,上来就改代码,改来改去反而引入新问题。后来养成了按时间线逐段验证的习惯,先确认硬件中断有没有到,再看下半部有没有执行,再看等待队列有没有被唤,最后才看mask返回得对不对。按这个顺序,绝大多数poll异常都能在半小时内定位。
还有一个值得提的小技巧:新驱动第一次跑poll测试时,不要把中断触发方式搞得太复杂。先用一个固定的软件事件(比如write触发tasklet)把机制调通,再接入真实硬件中断。这样可以把“机制问题”和“硬件问题”隔离,少走很多弯路。希望这篇把中断和poll拆开又合并的梳理,能帮你少踩一次这个领域的坑。