
做Linux驱动开发时间长了你会慢慢发现应用层和内核态之间最常用的交互方式其实就三样read、write、ioctl说白了就是“你问我答”的同步模式。但有一类场景下这种一问一答的效率非常低比如网卡收到数据包、串口来了一帧数据、GPIO电平跳变、底层硬件异常报警……这些事件发生的时间完全不可预期如果应用层靠轮询去查要么延迟大要么白白消耗CPU。更合理的做法是驱动在事件发生的那一刻主动“喊”应用层一声——“嘿有情况了”由应用层决定什么时候来取数据。这个“喊一声”的机制在Linux下最轻量、最直接的方式就是信号。这一讲我们专门聊清楚一件事字符设备驱动里如何通过fasync异步通知机制给应用程序发送信号。我假设你已经会写基本的字符设备驱动熟悉file_operations结构体、open/read/write这些回调的注册流程所以下面会直接进入主题把信号发送这条链路上的每个环节拆开讲包括驱动端怎么写、应用端怎么配合、实测中哪些坑最容易踩。1. 为什么驱动要主动“推”信号几类典型场景与机制取舍1.1 轮询的痛点和异步通知的定位先从一个真实场景说起。某项目里我写了一个采集卡的驱动硬件上有8路AD通道数据由FPGA侧异步往驱动里的DMA缓冲区灌灌满一帧就触发一次中断。最初应用层用的是最朴素的方案起一个线程每隔20ms调用一次read读取当前累积的数据。结果发现两个问题一是20ms的轮询间隔在高吞吐场景下CPU占用率窜到30%以上二是如果数据帧刚好在上一次轮询之后到达应用层要等下一个20ms才能感知到延迟不稳定。后来改成阻塞式read加上内核等待队列CPU占用问题解决了但应用层为了同时监控多个设备不得不给每个设备开一个线程堵在read上代码结构变得很别扭。这两种模式之外Linux其实还提供了第三种选择异步通知。驱动在事件发生的瞬间向应用进程发送一个SIGIO信号应用层收到信号后再去read拿数据。设备空闲时应用层可以睡大觉CPU占用为零事件到来时又能做到及时响应。这种“设备主动喊人”的模型正是fasync机制的设计初衷也是本讲标题里“驱动程序发送信号给应用程序”的标准落地方式。1.2 信号、等待队列与poll/select该怎么选这里需要把三种机制放在一起比一比免得你以后选型时纠结。机制通信方向应用层使用方式典型延迟适合场景等待队列 阻塞read内核通知应用可读read线程阻塞在fd上实时但每设备一线程单设备、单线程的简单采集poll/select/epoll内核上报可读状态应用层多路复用等待实时多fd统一管理多设备、多fd集中管理fasync SIGIO内核主动推信号注册signal handler实时单线程可处理多fd事件驱动、需要主动通知的场景实测下来的结论是如果只是单设备采集阻塞read配合等待队列最省事如果是要同时管理几十个socket或者多个设备fdepoll更合适但如果你的应用是事件驱动的架构比如硬件中断到来时需要立刻改变应用层状态机、或者需要在信号handler里做轻量级处理fasyncSIGIO是最贴合“推送”语义的方案。本讲的标题既然点明了“发送信号”那我们就围绕fasync这条完整的链路来展开后面也会讲到它和阻塞IO混用的方式。2. 驱动端完整实现fasync注册、信号触发与数据保护2.1 异步通知的三个回调函数与数据结构先说驱动端要做的事。要想让驱动具备“发信号”的能力核心就三个东西一个用于保存异步通知队列的指针通常是struct fasync_struct *async_queue放在设备的私有结构体里。一个实现file_operations里.fasync回调的函数这个函数是应用层调用fcntl(fd, F_SETFL, O_ASYNC)时内核回调进来的用于注册或注销异步通知。在事件发生处调用kill_fasync()真正把信号发给应用的地方。注意file_operations里的.fasync不是必须实现的如果你的驱动不打算做异步通知这个回调留空完全没问题。一旦你要做上面三个组件就必须配合起来缺一个信号都发不出去。先看一个典型的实现骨架。假设我们的设备是个简单的数据采集模块私有结构体长这样struct my_dev { struct cdev cdev; struct mutex lock; struct fasync_struct *async_queue; // 异步通知队列 int data_ready; // 数据就绪标志 };.fasync回调函数的标准写法如下static int my_fasync(int fd, struct file *filp, int mode) { struct my_dev *dev filp-private_data; return fasync_helper(fd, filp, mode, dev-async_queue); }fasync_helper是内核帮我们封装好的辅助函数它会根据mode的值F_SETFL传入O_ASYNC时是FASYNC_ON清除时是FASYNC_OFF自动把当前打开文件的struct file挂入或移出异步通知队列同时处理好引用计数。你只需要把队列指针的地址传进去剩下的事情内核全包了。2.2 kill_fasync触发信号什么时候调、怎么调队列注册好之后接下来就是在合适的时机调用kill_fasync。这个函数原型是void kill_fasync(struct fasync_struct **fp, int sig, int band);参数解释fp指向异步通知队列指针的指针也就是dev-async_queue。sig要发送的信号号驱动里几乎总是填SIGIO。band读写方向标记常用的是POLL_IN和POLL_OUT。这个值会被编码到信号附带的信息里应用层通过siginfo结构可以读出是“可读”还是“可写”。我们在数据到达的中断处理或者工作队列里调用它static void my_irq_handler(struct my_dev *dev) { // 硬件数据已经搬运到缓冲区 mutex_lock(dev-lock); dev-data_ready 1; mutex_unlock(dev-lock); // 唤醒阻塞读的进程如果应用是阻塞模式 wake_up_interruptible(dev-wq); // 如果应用注册了异步通知发SIGIO信号 if (dev-async_queue) kill_fasync(dev-async_queue, SIGIO, POLL_IN); }有同学会问这里既调用了wake_up_interruptible又调用了kill_fasync是不是重复了其实不冲突。kill_fasync只对调用过fcntl(fd, F_SETFL, O_ASYNC)的进程发信号而wake_up_interruptible只唤醒阻塞在等待队列上的进程。一个应用既可以选择阻塞在read上等数据也可以选择注册异步通知收信号甚至可以两个都做平时阻塞在read上收到信号只是作为一种“提前唤醒”的补充手段。驱动这边两个都调是给应用层留足选择空间。2.3 锁与并发kill_fasync该不该持锁调用驱动开发里但凡涉及共享数据就绕不开锁异步通知队列也不例外。dev-async_queue会在.fasync回调里被修改在kill_fasync里被读取而这两者可能发生在不同的进程上下文甚至kill_fasync还可能在中断上下文被调用。所以最简单也最安全的做法是用设备自带的mutex保护async_queue的注册与撤销操作在进程上下文调用kill_fasync前也读一下队列是否为空。实际项目中我见过一种偷懒写法直接在.fasync回调里不加任何锁然后某次多个进程同时打开设备节点的时候队列指针被写乱信号随机发给缺省进程组调试起来非常痛苦。所以我的建议是.fasync回调里的参数检查可以简化但async_queue指针的读写必须保证原子性。如果你用mutex保护注意在.release回调里也要清理static int my_release(struct inode *inode, struct file *filp) { struct my_dev *dev filp-private_data; // 进程退出时把fd从异步通知队列中移除避免悬空指针 if (dev-async_queue) fasync_helper(-1, filp, 0, dev-async_queue); return 0; }这里fasync_helper(-1, ...)是标准的注销手段内核会在队列对应的进程退出时自动调用但我们自己在.release里显式注销一遍更保险能避免进程异常退出导致队列里留下失效项。3. 应用层要做的三件事注册handler、设置属主、打开异步开关3.1 一句话概括fcntl三连驱动端准备就绪后应用层想要收到SIGIO信号必须按顺序完成下面三个动作signal(SIGIO, my_signal_handler); // 1. 注册信号处理函数 fcntl(fd, F_SETOWN, getpid()); // 2. 告诉内核信号发给当前进程 fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_ASYNC); // 3. 开启异步通知开关顺序不能乱。如果先开O_ASYNC再设F_SETOWN可能会短时间出现信号不知道该发给谁的情况虽然实际生产中不一定每次都触发问题但规范起见一定按上面这个顺序走。第二步的F_SETOWN是整个机制里最容易忽略的一环。它告诉内核这个文件描述符上产生的SIGIO信号指名道姓发给getpid()返回的这个进程。如果漏掉这一步内核会尝试把信号发给打开fd的进程组前台进程组在多进程、多会话的复杂场景下信号经常发错地方表现就是“驱动明明调了kill_fasync应用却一个信号都没收到”。3.2 用sigaction而不是signal新手喜欢用signal()注册handler代码短看着省事。但我建议在生产代码里统一改用sigaction因为signal()在不同glibc版本和内核上的语义有差异信号处理期间的掩码行为在不同平台上不一致容易埋雷。更好的写法是这样static void my_sigio_handler(int sig, siginfo_t *si, void *context) { // 从 si-si_band 可以拿到 POLL_IN / POLL_OUT 等方向信息 // 这里只做标记实际读取数据放到主循环里做 g_flag_data_ready 1; } int setup_sigio(int fd) { struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_sigaction my_sigio_handler; sa.sa_flags SA_SIGINFO | SA_RESTART; sigemptyset(sa.sa_mask); if (sigaction(SIGIO, sa, NULL) 0) { perror(sigaction); return -1; } if (fcntl(fd, F_SETOWN, getpid()) 0) { perror(F_SETOWN); return -1; } if (fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_ASYNC) 0) { perror(F_SETFL O_ASYNC); return -1; } return 0; }两个细节这里重点说。第一SA_SIGINFO标志让handler能拿到si_band信息你可以据此判断这次信号是因为数据可读POLL_IN还是可写POLL_OUT触发的从而决定是去read还是write。如果用普通signal()这个信息就丢了。第二SA_RESTART标志的意义在于如果进程正阻塞在某个系统调用上比如read等待数据此时一个SIGIO信号打过来内核在信号处理完返回后会自动重启被中断的系统调用而不是返回EINTR错误。没有这个标志你的read会莫名其妙返回-1且errno等于EINTR很多应用没处理这个返回值就直接当成硬件错误排查起来非常绕。3.3 信号handler里到底能不能读数据这是另一个高频问题收到SIGIO信号后能不能直接在handler里调用read读数据我的建议是不要。信号handler是在进程上下文但处于用户态返回路径上执行的虽然理论上可以调用read但如果在信号处理期间又来一个SIGIO两个handler可能嵌套执行重入问题很难控制。更稳妥的做法是handler里只做两件事——置一个标志位或者往一个自写的管道里写一个字节self-pipe trick真正读数据的逻辑放到主循环里看到标志位置位后再去read。这样既能避免重入又不会因为handler里耗时的IO操作拖慢信号处理流程。应用主循环大致是while (1) { if (g_flag_data_ready) { g_flag_data_ready 0; n read(fd, buf, sizeof(buf)); if (n 0) { // 处理数据 } } // 其他非阻塞任务... }你也可以把read换成阻塞模式配合SA_RESTART收到信号后从read里被唤醒直接读到数据。哪种方案都行但一定记得不要在handler里做复杂业务逻辑否则后续排查问题时会疯掉。4. 实测中的典型坑信号丢失、进程被杀、方向判断失误4.1 应用层一收信号就退出SIGIO的默认动作是终止进程这是初学者最崩溃的坑。明明驱动里调了kill_fasync结果应用层没进自定义的handler反而整个进程直接退出了。原因很简单SIGIO的默认信号动作是终止进程。也就是说如果应用层没有调用任何注册函数内核收到SIGIO后直接清掉这个进程。所以代码检查的第一优先级就是确认sigaction(SIGIO, ...)有没有被执行、执行成功与否。我遇到过一例是因为应用层在fcntl(fd, F_SETFL, ...)之前先调了setup_sigio里的sigaction逻辑没问题但另一个线程抢先一步开了O_ASYNC时序上把信号先放了出来handler还没来得及注册进程当场就没了。解决办法很简单在一开始就把sigaction和fcntl放到同一个初始化函数里按顺序执行不要拆分到不同线程。4.2 驱动发了信号但应用老收不到逐步定位链路如果你确认应用层注册无误、进程还是收不到信号就需要按下面这个顺序逐段排查了。先查驱动读侧。在调用kill_fasync之前加一句printk看中断或工作队列是否真的触发了。如果连这句都没打印说明你的事件产生路径有问题信号压根没发出去。再查驱动里async_queue是否为空。fasync_helper注册成功之后这个指针应该非空。可以在.fasync回调里加打印同时用cat /proc/interrupts这种手段确认应用层执行了fcntl(F_SETFL, O_ASYNC)。如果.fasync回调没被调用说明应用层的O_ASYNC设置环节没生效。接着查进程属主。在应用层打印fcntl(fd, F_GETOWN)的返回值确认它等于你的进程PID。如果你 fork 过子进程或者fd被多个进程共享属主可能被覆盖成最后一个设置F_SETOWN的进程信号就会发到那个进程去。多进程场景下每个打开同一设备节点的进程都应设置自己的属主但驱动端的async_queue实际上会把多个进程的struct file都挂进来kill_fasync会向所有注册进程发信号这里不会互相覆盖前提是你的async_queue存在设备私有结构体里而不是全局变量。最后再用strace跟一下系统调用。strace -e tracefcntl,sigaction ./your_app如果看到F_SETOWN/O_ASYNC设置成功但SIGIO没进handler可以用strace -e signalSIGIO -p PID在另一个终端观察信号是否到达进程。我实际排过的一个案例最终定位到的问题是驱动里的kill_fasync调用被放在一个条件判断里那个判断条件因为一个寄存器读错而永远为假。这类问题不做链路排查真的很难发现所以别一上来就怀疑内核API先从“事件有没有发生”查起。4.3 POLL_IN和POLL_OUT混用导致业务逻辑错乱还有一个相对隐蔽的坑跟kill_fasync的第三个参数有关。如果你的设备既可以读又可以写比如一个双向通信的串口设备那么读方向事件用POLL_IN写方向事件用POLL_OUT驱动端必须严格区分。但如果你的驱动比较“偷懒”不管什么事件都传POLL_IN应用层用SA_SIGINFO解析si_band时就会误判方向原本该去写数据的逻辑跑去读数据缓冲区空时read直接返回0业务上表现为“莫名其妙的空读”。对比一下正确与错误的方向标记事件类型正确的band错误示例后果数据可读POLL_INPOLL_OUT应用误以为可写可能写空数据缓冲可写POLL_OUTPOLL_IN应用误读read返回0或阻塞异常/挂断POLL_ERR / POLL_HUPPOLL_IN方向信息丢失逻辑混乱如果确实不需要区分方向应用层就不要依赖si_band收到信号后统一去read一次读到什么算什么反倒简单。5. 一个可复现的最小Demo驱动定时发信号到应用层5.1 驱动代码用内核定时器模拟事件讲再多不如一个能跑的Demo。下面这个驱动里我用一个内核定时器每隔3秒模拟一次“数据到达”事件然后调用kill_fasync发信号。代码非常精简只保留了异步通知相关的路径方便你在自己的环境里快速验证。#include linux/module.h #include linux/kernel.h #include linux/init.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/slab.h #include linux/timer.h #include linux/uaccess.h #include linux/fcntl.h #include linux/sched/signal.h #define DEV_NAME fasync_demo struct fasync_demo_dev { struct cdev cdev; struct fasync_struct *async_queue; struct timer_list timer; int data_ready; }; static struct fasync_demo_dev *demo_dev; static int major; static void demo_timer_handler(struct timer_list *t) { struct fasync_demo_dev *dev from_timer(dev, t, timer); dev-data_ready; // 模拟数据就绪发SIGIO给注了册的应用进程 if (dev-async_queue) kill_fasync(dev-async_queue, SIGIO, POLL_IN); // 重新启动定时器3秒后再来一次 mod_timer(dev-timer, jiffies msecs_to_jiffies(3000)); } static int demo_open(struct inode *inode, struct file *filp) { filp-private_data demo_dev; return 0; } static int demo_fasync(int fd, struct file *filp, int mode) { struct fasync_demo_dev *dev filp-private_data; return fasync_helper(fd, filp, mode, dev-async_queue); } static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *pos) { struct fasync_demo_dev *dev filp-private_data; if (copy_to_user(buf, dev-data_ready, sizeof(dev-data_ready)) 0) return -EFAULT; return sizeof(dev-data_ready); } static int demo_release(struct inode *inode, struct file *filp) { struct fasync_demo_dev *dev filp-private_data; if (dev-async_queue) fasync_helper(-1, filp, 0, dev-async_queue); return 0; } static struct file_operations fops { .owner THIS_MODULE, .open demo_open, .read demo_read, .release demo_release, .fasync demo_fasync, }; static int __init demo_init(void) { dev_t devno; int ret; ret alloc_chrdev_region(devno, 0, 1, DEV_NAME); if (ret 0) return ret; major MAJOR(devno); demo_dev kzalloc(sizeof(*demo_dev), GFP_KERNEL); if (!demo_dev) { unregister_chrdev_region(devno, 1); return -ENOMEM; } cdev_init(demo_dev-cdev, fops); demo_dev-cdev.owner THIS_MODULE; ret cdev_add(demo_dev-cdev, devno, 1); if (ret) { kfree(demo_dev); unregister_chrdev_region(devno, 1); return ret; } timer_setup(demo_dev-timer, demo_timer_handler, 0); mod_timer(demo_dev-timer, jiffies msecs_to_jiffies(3000)); pr_info(fasync_demo: major%d\n, major); return 0; } static void __exit demo_exit(void) { del_timer_sync(demo_dev-timer); if (demo_dev-async_queue) kill_fasync(demo_dev-async_queue, SIGIO, POLL_IN); cdev_del(demo_dev-cdev); kfree(demo_dev); unregister_chrdev_region(MKDEV(major, 0), 1); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL);这里提两个写驱动的小细节。第一timer_setup这个API是内核高版本的标准写法如果你的内核比较旧可能需要用setup_timer两代API在回调参数上有差异编译报错时检查一下内核版本。第二.exit里也调了一次kill_fasync是在模块卸载时给还挂着的进程一个收尾信号实际产品里不一定需要但多这一行能让Demo在反复insmod/rmmod时行为更干净。5.2 应用代码收信号、读计数应用层代码保持最小化验证逻辑就是收到一次SIGIO从驱动读一次int计数值打印到屏幕。#include stdio.h #include stdlib.h #include string.h #include fcntl.h #include unistd.h #include signal.h #include sys/types.h static int fd; static void sigio_handler(int sig, siginfo_t *si, void *context) { int count 0; (void)sig; (void)context; if (si-si_band POLL_IN) { read(fd, count, sizeof(count)); printf([handler] SIGIO received, current count %d\n, count); } } int main(void) { struct sigaction sa; fd open(/dev/fasync_demo, O_RDONLY); if (fd 0) { perror(open); return 1; } memset(sa, 0, sizeof(sa)); sa.sa_sigaction sigio_handler; sa.sa_flags SA_SIGINFO | SA_RESTART; sigemptyset(sa.sa_mask); sigaction(SIGIO, sa, NULL); fcntl(fd, F_SETOWN, getpid()); fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_ASYNC); printf(app started, pid%d, waiting for SIGIO...\n, getpid()); while (1) pause(); return 0; }应用编译后先别急着跑。手动创建设备节点或者写一个简单的udev规则自动创建。手动方式mknod /dev/fasync_demo c 240 0 # 这里的major号以你的内核实际分配为准然后insmod fasync_demo.ko运行编译好的app终端上每隔3秒就应该打印一行[handler] SIGIO received, current count 1 [handler] SIGIO received, current count 2 ...这个Demo把整条信号链路完整跑通之后你可以再自己改改把read从handler里挪到主循环把POLL_IN改成POLL_OUT观察si_band的变化或者在驱动里故意不加.fasync回调然后对比一下“收不到信号”时的表现。把这些排列组合都试一遍你对异步通知机制的理解会比看十遍文档都深。5.3 扩展思路把信号机制和epoll结合最后再聊一个比较进阶的玩法。很多产品的应用层不会只用信号这一种通知方式而是把信号和epoll结合主要事件循环跑在epoll_wait上SIGIO信号通过signalfd变成一个fd塞进epoll里统一管理。这样就不用在信号handler里做任何实际工作主循环里一次性处理所有事件源代码结构非常干净也不存在重入问题。具体做法是用sigprocmask屏蔽SIGIO然后sigwaitinfo或signalfd读取信号再把signalfd的fd加入epoll。这套方案在实际工程里我用过几次稳定性很好尤其适合那种既要监听硬件事件又要处理网络消息的复杂程序。信号在这里只是一个“事件源头”真正的调度全交给epoll算是把本讲的机制发挥出了更大的价值。信号机制在驱动开发里属于那种“看文档五分钟、调试俩小时”的知识点最大的难点反而不是API怎么用而是整条链路涉及多个模块任何一环断了都会表现为同一个症状应用收不到信号。所以我建议你把驱动端、应用端的代码分开验证先确保kill_fasync被调用了再确认应用层的注册没漏步骤最后再怀疑方向标记和并发问题。按这个顺序来基本半小时内就能把问题定位清楚。