ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux内核fown_struct详解:从SIGIO信号到驱动异步通知的全链路排查

Linux内核fown_struct详解:从SIGIO信号到驱动异步通知的全链路排查 最近有个同事调驱动应用层明明用sigaction注册了SIGIOfcntl也调了F_SETOWN可信号就是不来。我看了一眼代码就发现问题file_operations里压根没实现fasync方法驱动也没有在任何事件点调用kill_fasync。这类问题我见过不止一次而每次排查到最后都会落到同一个结构体上——Linux内核里的struct fown_struct。fown_struct的大名叫“文件所有者结构体”挂在内核的struct file里。它是信号驱动I/O也就是SIGIO/O_ASYNC这套机制的中枢文件有事件了要向谁发信号发给进程还是进程组发哪个信号发之前要不要做权限校验这些问题的答案全都在这个结构体里。这篇文章从源码定义、操作函数、投递链路到真实踩坑场景把这个结构体彻底拆一遍。对写内核驱动、做嵌入式Linux、或者单纯想搞懂VFS与信号机制如何协作的读者应该都有用。1. 从“文件归谁”说起fown_struct出现的必然性1.1 用户态看到的三行代码内核要做多少事信号驱动I/O的使用方式非常简洁三行fcntl就能搞定fcntl(fd, F_SETOWN, getpid()); int flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_ASYNC);第一行告诉内核“这个文件的事件通知给哪个进程”第二、三行打开O_ASYNC开关。之后只要有数据到达内核就会向刚才指定的进程发送SIGIO信号。问题来了用户态只传了一个int类型的PID内核要怎么存直接存在struct file里不就行了如果你这么想说明还没看到后面那两个经典的坑PID复用以及文件描述符跨进程传递。正是这两个问题决定了内核必须设计一个比“int pid”强壮得多的结构体来承担这个职责。1.2 owner必须挂在struct file上而不是挂在fd上先理清一个基础概念用户态说的“文件描述符fd”是进程私有的而内核里的struct file是全局共享的。同一个struct file可以被同一个进程的不同fd指向也可以通过dup/fork共享给父子进程还能通过Unix域socket的SCM_RIGHTS机制整个传给另一个进程。异步通知的语义是“这个文件对象有事件了告诉当初说好要听通知的那个人”。注意监听关系绑定的是文件对象不是某个进程里那个数字编号的fd。如果把owner信息存在fd上dup一次就得重新设置fd被传递后新进程也拿不到通知这完全不符合使用直觉。所以内核把ownership信息直接内嵌到了struct file里struct file { ... struct fown_struct f_owner; ... };这样无论这个文件对象被多少进程引用、通过多少种方式共享owner信息始终跟着文件对象走。fd传递之后接收方拿到的确实是同一个struct file连owner都一起带过去了。这点既是便利也是坑后面会细说。1.3 为什么不是“int owner_pid”而是struct pid指针最容易想到的实现就是在fown_struct里存一个int owner_pid。但Linux内核没有这么做而是存了一个struct pid指针struct pid *pid;原因有两个核心第一是PID复用。用户态不加锁的getpid()是进程私有的但内核角度看PID编号是全局资源。进程A退出后它的PID可能被新进程B复用。如果fown_struct里直接存intA退出后B启动本来该发给A的SIGIO就会发给完全无关的B这就是“幽灵信号”。struct pid是内核的PID抽象层带引用计数生命周期可控。只要fown_struct持有一个引用这个struct pid就不会被释放投递时能准确判断“指向的进程还在不在”避免误投。第二是粒度问题。一个信号可以发给单个进程也可以发给整个进程组还可以精确到某个线程。这些不同的投递粒度需要用额外的字段区分而struct pid配合pid_type字段才能完整表达这种差异。单纯一个int既表达不了类型也管理不了生命周期。2. 逐字段拆解struct fown_struct小结构体里的大设计2.1 源码定义与第一眼印象以Linux 5.x/6.x内核源码为例include/linux/fs.h里的定义是这样struct fown_struct { rwlock_t lock; /* protects pid, uid, euid fields */ struct pid *pid; /* pid or pgrp owning the file */ enum pid_type pid_type; /* type of pid (PIDTYPE_PID or PIDTYPE_PGID) */ kuid_t uid, euid; /* uid, euid of the owner */ int signum; /* signal to send on IO: SIGIO/SIGURG */ };整个结构体只有6个字段。但它牵扯的机制横跨VFS、信号系统、驱动模型三个层面。我当初第一次读这段代码时也觉得“就这么点东西”直到把每个字段背后的设计意图都挖了一遍才发现这个结构体几乎是为异步通知量身定制的。2.2 rwlock_t lock一个被低估的字段lock字段保护的是pid、uid、euid这三个字段的读写。注意它用的是读写锁而不是普通的spinlock。原因是访问模式极度不对称写操作只在fcntl调用F_SETOWN/F_SETOWN_EX时发生属于低频操作读操作却发生在每次事件通知时比如网络包到达、串口收到数据、socket可读等高频路径。如果所有读操作都要拿一把独占锁中断上下文和软中断里会白白增加开销。读写锁允许多个读者共享临界区只有写者才需要独占恰好符合这个场景。另外这个锁在使用时基本都是irq变体read_lock_irqsave(fown-lock, flags); ... read_unlock_irqrestore(fown-lock, flags);原因很简单kill_fasync可能在硬件中断上下文被调用必须保存/恢复中断状态否则会把中断弄丢。2.3 pid与pid_type投递粒度由这两个字段决定pid_type的类型来自include/linux/pid.henum pid_type { PIDTYPE_PID, /* 一个具体进程/线程 */ PIDTYPE_PGID, /* 一个进程组 */ PIDTYPE_TGID, /* 一个线程组 */ PIDTYPE_MAX, };普通F_SETOWN正数参数对应的其实是线程组粒度负数参数对应进程组粒度。F_SETOWN_EX则可以精确指定PID、TGID、PGID三种粒度用户态传入一个struct f_owner_ex内核就能按需设置pid_type。这两个字段决定了信号最终投递给谁。比如一个驱动想让整个进程组都收到SIGIO就可以在F_SETOWN时传负的进程组ID如果只想让某个线程收到通知就得走F_SETOWN_EX。2.4 uid与euidSIGIO投递的权限闸门很多读内核源码的人会忽略uid/euid这两个字段但它们在安全上非常关键。SIGIO的发送者是驱动代码——一个运行在内核态、跟具体用户无关的执行上下文。如果没有身份记录任何进程都可以通过驱动触发SIGIO发给任意目标那就乱套了。fown_struct在F_SETOWN时会把调用进程的uid和euid固化下来。之后每次投递SIGIO内核都会检查“当前发起通知的进程”是否有权限代表这个文件owner。这等于把Unix传统的“信号发送权限”问题从用户态转移到了内核事件触发路径上。2.5 signum信号不是写死的signum默认是SIGIO但可以被改成其他信号。最典型的场景是socket带外数据当收到TCP紧急数据时内核希望进程能收到SIGURG而不是SIGIO以便区分普通数据和紧急数据。这个字段让fown_struct成了一个“可配置信号源”而不是死板地绑定SIGIO。驱动开发时如果想让某个特定事件用另一个信号通知应用层这个字段就是切入点。3. 一条完整链路从F_SETOWN系统调用到SIGIO到达用户态3.1 写入侧f_setown与__f_setownfcntl的F_SETOWN命令在fs/fcntl.c中对应f_setown函数int f_setown(struct file *filp, unsigned long arg, int force) { enum pid_type type; struct pid *pid NULL; int ret 0; if (arg 0) { type PIDTYPE_TGID; pid find_get_pid(arg); } else { type PIDTYPE_PGID; pid find_get_pid(-arg); } ... ret __f_setown(filp, pid, type, force); ... }find_get_pid会从PID编号在全局PID表里找到对应的struct pid并增加引用计数。之后__f_setown做真正的赋值static int __f_setown(struct file *filp, struct pid *pid, enum pid_type type, int force) { struct fown_struct *fown filp-f_owner; ... write_lock_irq(fown-lock); fown-pid pid; fown-pid_type type; fown-uid current_uid(); fown-euid current_euid(); write_unlock_irq(fown-lock); ... }注意这里权限检查普通进程只能设置自己进程或自己所在进程组想设置别的PID需要CAP_KILL能力。很多多线程程序在这里踩坑比如用gettid()作为F_SETOWN参数普通权限下会直接被拒。3.2 F_SETFL O_ASYNC驱动fasync回调的入口F_SETOWN只是记录了“通知谁”真正打开异步通知开关的是F_SETFL设置O_ASYNC。内核在改变这个标志时会调用文件对应的file_operations里的fasync方法static const struct file_operations mydev_fops { .fasync mydev_fasync, ... }; static int mydev_fasync(int fd, struct file *filp, int on) { return fasync_helper(fd, filp, on, mydev-async_queue); }如果驱动没实现fasync回调fcntl(F_SETFL, O_ASYNC)会返回-ENOTTY。很多应用层代码忽略了这次调用的返回值导致一路“成功”地设置完了却永远等不到信号。fasync_helper是内核提供的标准helper它维护一个struct fasync_struct链表。每个打开的fd对应链表里的一个节点节点里保存了struct file指针和fd号struct fasync_struct { struct fasync_struct *fa_next; struct file *fa_file; int fa_fd; ... };这个链表就是驱动后续通知事件时遍历的对象。3.3 事件到达kill_fasync把事件变成信号当设备检测到事件串口收到数据、socket可读等驱动调用kill_fasync(mydev-async_queue, SIGIO, POLL_IN);kill_fasync遍历驱动的fasync链表对每个登记过的文件取出fown_struct然后调用send_sigio。第三个参数是band值表示事件类型POLL_IN表示可读POLL_OUT表示可写POLL_ERR表示错误。这个band值最终会填进siginfo_t的si_band字段应用层通过它可以知道具体是什么事件。3.4 投递侧send_sigio构造siginfo并发出信号send_sigio是fs/fcntl.c里的静态函数它做的核心工作可以概括为几步第一从fown_struct中取出pid和pid_type这一步用读锁保护。第二如果是SIGIO信号做权限校验发起通知的进程uid/euid必须与fown_struct里记录的owner凭据匹配或者拥有CAP_KILL能力否则直接丢弃。第三填充siginfo_t包括si_signo、si_codeSI_SIGIO、si_fd对应哪个文件、si_band事件类型。第四根据pid_type选择kill_pid_info或者kill_pgrp_info投递信号。这整个流程可以看作一条流水线F_SETOWN确定了“target”O_ASYNCFASYNC注册了“listener”kill_fasync触发了“notification”。3.5 用户态接收端该注意什么应用层注册SIGIO handler时如果想拿到si_fd和si_band必须用SA_SIGINFO标志struct sigaction sa { 0 }; sa.sa_sigaction my_sigio_handler; sa.sa_flags SA_SIGINFO; sigaction(SIGIO, sa, NULL);然后在handler里通过siginfo_t读取fd和band。注意SIGIO是标准信号内核不会为它排队。事件来得特别密集时多个事件可能合并成一次信号所以handler里通常要循环读取文件直到返回EAGAIN不能默认“收到一个信号就只有一个事件”。4. 驱动开发中的实际排查为什么应用收不到SIGIO4.1 坑一驱动压根没实现fasync回调这是最基本的坑也是最常见的。症状很典型应用层F_SETOWN成功、O_ASYNC也设置成功或者根本没检查返回值但SIGIO永远不来。排查链路strace看fcntl系统调用返回值。如果F_SETFL返回ENOTTY说明驱动fops里没有.fasync。这是最直接的证据。检查驱动代码里有没有kill_fasync调用。有些驱动实现了fasync但中断处理或者数据到达路径里忘了调kill_fasync信号自然发不出来。用kprobe挂在kill_fasync上看有没有被命中。如果命中了但应用还是收不到再往send_sigio方向查权限和pid_type。我曾经见过一个驱动fasync回调实现得很标准但中断里调用的是另一个函数的静态局部async_queue和fasync_helper维护的链表根本不是同一个变量。这种问题不看汇编或者加日志很难一眼发现。4.2 坑二多线程程序给F_SETOWN传了线程ID现代服务端程序几乎都是多线程的很多人习惯在线程里调用fcntl顺手传一个gettid()作为F_SETOWN参数。结果发现返回EPERM或者信号来了但handler没执行。原因在于普通F_SETOWN的语义是“进程/线程组级别”。它的权限检查要求参数必须等于current的线程组ID也就是主线程tgid。传入线程ID后要么被拒要么因为find_get_pid找不到对应pid而返回ESRCH。正确做法是用F_SETOWN_EXstruct f_owner_ex ex { .type PIDTYPE_PID, .pid syscall(SYS_gettid), }; fcntl(fd, F_SETOWN_EX, ex);这样信号才会精确投递给指定线程。如果只是想像传统方式那样发给整个进程用getpid()即可。4.3 坑三fd被传递owner却还是旧进程这个坑隐蔽但杀伤力很大。进程A打开设备F_SETOWN设置成自己然后把fd通过SCM_RIGHTS传给进程B。A退出后B手里的fd仍然对应同一个struct filefown_struct里的pid还指向A之前的struct pid。A已经退出信号既不会发给A找不到接收者或者信号被丢弃也不会自动转移给B。B应用层表现就是文件明明能读写但SIGIO始终不来。解决方案也很简单B在收到fd后重新执行一次F_SETOWN。这也再次印证了一个观点fown_struct存储的是“当初约定接收通知的人”而不是“当前谁持有这个fd”。4.4 坑四锁的边界没守住fown_struct使用读写锁保护本身问题不大。容易出问题的反而是驱动自己不要在持有fown_struct.lock的情况下再去调用kill_fasync。一旦你在某个回调里先read_lock(fown-lock)然后又触发kill_fasync里的read_lock如果锁不是可重入设计自旋锁场景下直接死锁。正确思路是fown_struct的锁只用来保护owner字段本身信号投递过程一定是在锁外完成的。send_sigio内部会重新拿锁读取字段然后把pid引用取出、解锁后再发信号。驱动层完全没必要在这个锁上做文章。另外kill_fasync虽然设计上可以在中断上下文调用但信号投递路径在某些内核配置下涉及内存分配可能有睡眠风险。所以我的习惯是中断里只置标志位真正的kill_fasync放到tasklet或者workqueue里执行这样既安全又好调试。4.5 坑五release阶段忘了清理fasync链表标准的驱动模板里release回调通常也会调用一次static int mydev_release(struct inode *inode, struct file *filp) { mydev_fasync(-1, filp, 0); ... return 0; }虽然内核在__fput时也会自动调用fasync(-1, filp, 0)来清理但驱动release里显式清理更稳妥。特别是设备结构体马上要被释放时如果不把async_queue置空后续中断里kill_fasync就会遍历野指针导致内核崩溃。这个坑排查起来非常痛苦因为崩溃点往往离释放点十万八千里。5. 延伸fown_struct背后值得读透的异步通知生态5.1 fasync_helper kill_fasync是字符设备异步通知的通用模板理解了fown_struct和整个SIGIO链路后你会发现Linux驱动里几乎所有“异步事件通知”都是同一套模板/* 驱动结构体中 */ struct fasync_struct *async_queue; /* file_operations中 */ static int mydev_fasync(int fd, struct file *filp, int on) { return fasync_helper(fd, filp, on, mydev-async_queue); } /* 事件产生处 */ kill_fasync(mydev-async_queue, SIGIO, POLL_IN);这个模式在串口驱动、输入子系统、网络设备、USB gadget等地方反复出现。以后读任何驱动的异步通知代码你都能一眼认出“哦这就是fown_struct在背后支撑的机制”。5.2 epoll/io_uring和信号驱动I/O的取舍现在很多人习惯所有异步都用epoll但信号驱动I/O在少量文件场景下依然有它的位置没有fd集合的维护开销不需要事件循环线程CPU占用极低。缺点是标准信号不排队、信号处理函数可做的事情受限大量fd场景下管理不便。如果项目里用到了SIGIO务必意识到它和epoll/io_uring是平行方案不是替代关系。驱动实现fasync回调并不影响你同时使用epoll两者是可以共存的。5.3 在内核源码里快速追踪fown_struct的使用者想深入验证这篇文章里的结论建议你在内核源码里执行这几个搜索搜f_setown(看哪些子系统在设置owner网络、tty、io_uring都有分布。搜kill_fasync(看哪些驱动在触发通知数量非常多挑一个字符设备驱动跟着读一遍。搜fown_struct能看到所有引用这个结构体的位置包括VFS、fcntl、socket等。我推荐用elixir.bootlin.com在浏览器里跳转或者本地用cscope配合源码阅读。想动态验证的话可以用bpftrace挂send_sigio入口打印fown_struct里的uid、euid、signumbpftrace -e kprobe:send_sigio { printf(pid%d uid%d euid%d signum%d\n, ((struct fown_struct *)arg0)-pid-numbers[0].nr, ((struct fown_struct *)arg0)-uid.val, ((struct fown_struct *)arg0)-euid.val, ((struct fown_struct *)arg0)-signum); }不同内核版本对struct pid内部结构略有差异打不出来就适当调整进到numbers数组的方式但思路一样直接观察这个结构体的实际内容比死读源码直观得多。最后聊聊我的个人体会。第一次我把fown_struct当成“存pid的容器”直到被PID复用坑了一次才认真去看struct pid的引用计数和pid_type才明白为什么这个结构体“只是起点不是终点”。在内核里读一个结构体只看定义是永远不够的struct fown_struct牵着的线连着VFS、信号机制、驱动框架还连着用户态的每个fcntl。下次再有人问“SIGIO怎么不生效”你至少知道去file_operations里找.fasync去async_queue上找kill_fasync去struct file上找f_owner。顺着这条线索往下挖你对Linux整个异步通知体系的理解会比背一百个内核面试题都更扎实。
返回列表