
做内核模块开发的人迟早会遇到一类问题你的模块怎么知道别的子系统什么时候状态变了。我印象很深的一次当时在做一个和透明加密相关的工作文件系统层要对读写做处理同时业务上又想感知网卡上下线来联动切换加解密策略。最开始图省事写了个内核线程每 200ms 轮询一次/sys/class/net结果 CPU 占用上去了状态变化还偶发丢失搞得我很狼狈。后来翻内核源码看到netdev_chain才意识到这类一个模块变化、多方需要感知的场景Linux 内核早就给出了标准答案——通知链notifier chain。通知链本质上是内核态的发布-订阅模型事件源维护一个回调链表事件发生时按优先级逐个调用链上的回调关心事件的模块把自己的回调注册到链上。这样做的好处很直接事件源不依赖任何具体监听者模块之间不产生编译期耦合加载顺序也不受影响。这篇文章我会从数据结构、API 讲到可编译的完整示例再把我实际踩过的高并发、重入、卸载崩溃这些坑完整复盘一遍适合刚开始写内核模块、或者已经在做字符驱动想扩展事件感知能力的读者。1. 通知链解决什么问题内核子系统的广播电台模型1.1 模块之间为什么不能直接互相调用很多新手想到的第一个方案是A 模块把函数导出B 模块直接调用或者用函数指针互相注册。这在实验代码里确实能跑规模一大就全是坑。首先是编译期和运行期的强依赖。B 模块链接了 A 模块的符号那么 A 必须先于 B 加载否则 B 的insmod直接失败。多模块之间如果互相依赖加载顺序就成了一个拓扑排序问题模块一多就非常痛苦。其次是符号爆炸每个模块都把内部函数EXPORT_SYMBOL出来全局命名空间迟早乱掉。最麻烦的是不知道自己不知道A 模块状态变了它根本不知道有哪些模块关心这个变化如果以后新增一个 C 模块也要关心 A 的状态变化A 的代码又得改一遍。这就像你在一间屋子里喊一声开饭了但你不知道谁会来你也不可能挨个去敲门通知。你需要的是一个通知机制而不是一堆定向呼叫。1.2 发布-订阅模型就是那个广播电台通知链的工作流程可以拆成四步事件源初始化一个链头相当于架好一个广播电台。关心事件的模块构造自己的struct notifier_block回调节点注册到链头上相当于收音机调好频率。业务事件发生时事件源调用通知函数遍历链上的每个回调节点把事件类型和一个数据指针传过去。每个回调自己决定怎么处理处理完返回一个状态值。整个过程中事件源只维护一个链表它不需要知道谁在听只需要在合适的时候广播。监听方也不需要知道事件源内部逻辑只要提供回调函数并完成注册。链表的增删是动态的模块可以随时注册、随时注销完全符合内核模块化的设计哲学。1.3 哪些场景适合用通知链通知链在内核里被大量使用最典型的有这么几类网络设备状态变化网卡注册、注销、up、down、改名、MTU 变化通过netdev_chain广播。系统级事件重启、关机、panic通过reboot_notifier_list、panic_notifier_list广播驱动可以在系统关键节点做最后的清理或状态保存。地址变更IP 地址的添加和删除通过inetaddr_chain通知。内存热插拔、密钥管理等子系统也都有自己的通知链。需要提醒的是通知链适合低频事件广播不适合高频数据通道。如果你需要的是每收到一个包就通知一次或者每读一个扇区都同步通知那应该考虑tracepoint、perf_event、网络过滤钩子这类更专门的机制。通知链的语义更偏向告诉大家发生了一件事它不保证回调成功也不保证回调之间的执行结果一致这一点在选型时要想清楚。2. 数据结构与API全景五种通知链的选择边界2.1 核心数据结构通知链的核心数据结构非常简洁一个回调节点一个链头定义都在include/linux/notifier.h里。typedef int (*notifier_call_t)(struct notifier_block *nb, unsigned long action, void *data); struct notifier_block { notifier_call_t notifier_call; struct notifier_block __rcu *next; int priority; };notifier_call是回调函数指针action是事件类型枚举data是事件附带的数据指针。priority是优先级数值大的排在链表前面会被优先调用。同优先级的情况下先注册的回调先被调用。这个排序逻辑在kernel/notifier.c的notifier_chain_register里体现得很清楚插入的逻辑就是遍历链表找到第一个priority小于自己的节点前插入。next指针带__rcu标记说明链表的遍历走的是 RCU 读路径注册和注销走的是 RCU 写路径。这意味着通知链的遍历是无锁读性能开销非常小。2.2 四种标准链和一张选择表Linux 内核提供四种标准的通知链原子链、可阻塞链、原始链、SRCU 链。它们的区别主要在锁机制和能否睡眠上这直接决定了你能在什么上下文中使用。通知链类型链头结构锁机制回调能否睡眠典型使用场景原子链atomic_notifier_head自旋锁否中断上下文、panic、reboot 路径可阻塞链blocking_notifier_head读写信号量是进程上下文中普通驱动事件原始链raw_notifier_head无锁靠调用者保证取决于调用上下文netdev 等由大锁保护的子系统SRCU 链srcu_notifier_head互斥锁 SRCU是对通知方临界区开销敏感的高频场景原子链在通知时会持有自旋锁所以回调里绝对不能睡眠也不能调用任何可能睡眠的函数。可阻塞链在注册和注销时拿写锁在通知时拿读锁读锁允许并发多个通知但写锁会等所有读者退出所以回调想做什么就做什么只要别自己把自己锁死。原始链本身不带任何锁它只负责链表链接遍历时靠 RCU 保护锁语义完全交给调用方。比如网络子系统的netdev_chain就是原始链它在派发事件时通常已经持有 RTNL 全局锁所以不需要再来一层锁。SRCU 链比阻塞链更轻量通知方持有的临界区更短但使用复杂度高普通驱动里其实很少见我一般在评估性能敏感场景才会考虑它。2.3 注册、注销、通知的API对应关系每种链的 API 命名非常规律记住一个就能推出一串类型初始化宏注册注销通知原子ATOMIC_NOTIFIER_HEAD(name)atomic_notifier_chain_registeratomic_notifier_chain_unregisteratomic_notifier_call_chain可阻塞BLOCKING_NOTIFIER_HEAD(name)blocking_notifier_chain_registerblocking_notifier_chain_unregisterblocking_notifier_call_chain原始RAW_NOTIFIER_HEAD(name)raw_notifier_chain_registerraw_notifier_chain_unregisterraw_notifier_call_chainSRCUSRCU_NOTIFIER_HEAD(name)srcu_notifier_chain_registersrcu_notifier_chain_unregistersrcu_notifier_call_chain这里有个细节值得注意注册和注销 API 只是做链表操作不会分配内存所以绝大多数情况下返回值为 0。但养成检查返回值的习惯没有坏处尤其生产代码里万一接了未来行为变化的版本不至于懵。action参数和data参数的语义由事件源和回调方共同约定内核里常见的做法是action传事件枚举值比如NETDEV_UP、NETDEV_DOWNdata传事件相关的对象指针。data指针在回调里使用前一定要先确认事件类型否则强转后就是访问野内存。2.4 回调返回值容易被忽视的语义回调函数的返回值有一套约定的宏新手最容易在这里出问题。先看定义#define NOTIFY_DONE 0x0000 #define NOTIFY_OK 0x0001 #define NOTIFY_BAD (NOTIFY_STOP_MASK | 0x0002) #define NOTIFY_STOP (NOTIFY_OK|NOTIFY_BAD) #define NOTIFY_STOP_MASK 0x8000内核遍历通知链时kernel/notifier.c里的核心逻辑简化成这样ret nb-notifier_call(nb, val, v); if (ret NOTIFY_STOP_MASK) break;关键点在于判断是否继续遍历看的不是返回值是否为 0而是ret NOTIFY_STOP_MASK是否为真。也就是说返回NOTIFY_DONE0告诉事件源我不关心遍历继续。返回NOTIFY_OK1表示我处理好了遍历继续。返回NOTIFY_BAD表示这个事件被我否决了遍历停止调用方会得到非零值。返回NOTIFY_STOP表示不要再调用后面的回调了遍历停止。很多新手以为返回NOTIFY_STOP就行但如果没有同时置上NOTIFY_STOP_MASK位这个停止根本不会生效。想中途掐断遍历必须返回NOTIFY_BAD或NOTIFY_STOP而不是随便返回一个小整数。最常见的误用就是把NOTIFY_OK当成执行成功并停止结果后面所有监听者都被漏掉了。3. 从零动手一个可编译的最小通知链模块说了这么多原理不如直接写代码。这里我做一个事件源模块和一个监听模块事件源提供一个简单的 proc 文件往里面写数据就触发一次事件广播。完整代码在 5.x/6.x 内核下编译验证过。3.1 模块一事件源事件源模块负责定义链头、提供注册注销通知的导出函数并在业务事件发生时广播。// my_event_notify.c #include linux/notifier.h #include linux/module.h #include linux/kernel.h #include linux/init.h #include linux/proc_fs.h #include linux/uaccess.h static BLOCKING_NOTIFIER_HEAD(my_event_chain); int my_event_register(struct notifier_block *nb) { return blocking_notifier_chain_register(my_event_chain, nb); } EXPORT_SYMBOL(my_event_register); int my_event_unregister(struct notifier_block *nb) { return blocking_notifier_chain_unregister(my_event_chain, nb); } EXPORT_SYMBOL(my_event_unregister); void my_event_notify(unsigned long action, void *data) { blocking_notifier_call_chain(my_event_chain, action, data); } EXPORT_SYMBOL(my_event_notify); static ssize_t my_event_trigger_write(struct file *file, const char __user *buf, size_t len, loff_t *off) { char cmd[16] {0}; if (len sizeof(cmd) - 1) len sizeof(cmd) - 1; if (copy_from_user(cmd, buf, len)) return -EFAULT; my_event_notify(0x01, cmd); return len; } static const struct file_operations my_event_trigger_fops { .owner THIS_MODULE, .write my_event_trigger_write, }; static int __init my_event_init(void) { proc_create(my_event_trigger, 0200, NULL, my_event_trigger_fops); pr_info(my_event_notify chain initialized\n); return 0; } static void __exit my_event_exit(void) { remove_proc_entry(my_event_trigger, NULL); pr_info(my_event_notify chain exit\n); } module_init(my_event_init); module_exit(my_event_exit); MODULE_LICENSE(GPL);这里我特意选择了blocking_notifier而不是原子链。因为这个示例的事件源是 proc 写入触发的运行在进程上下文回调里可能还需要访问文件系统、分配内存这些操作都可能睡眠所以必须用可阻塞链。如果用原子链回调里一个kmalloc(GFP_KERNEL)就把系统搞崩了。导出函数的设计也有一点讲究我导出的是一组register/unregister/notify函数而不是直接导出链头变量。这样监听模块不需要知道链头内部细节事件源也可以在未来对注册逻辑加自己的过滤或统计接口更稳定。3.2 模块二监听方监听模块构造自己的回调节点在初始化和退出时注册、注销。// my_event_listener.c #include linux/notifier.h #include linux/module.h #include linux/kernel.h #include linux/init.h extern int my_event_register(struct notifier_block *nb); extern int my_event_unregister(struct notifier_block *nb); static int my_listener_handler(struct notifier_block *nb, unsigned long action, void *data) { switch (action) { case 0x01: pr_info(listener: config changed, data%s\n, (char *)data); break; default: pr_info(listener: unknown action %lu\n, action); break; } return NOTIFY_OK; } static struct notifier_block my_listener_nb { .notifier_call my_listener_handler, .priority 0, }; static int __init my_listener_init(void) { int ret; ret my_event_register(my_listener_nb); if (ret 0) { pr_err(listener register failed, ret%d\n, ret); return ret; } pr_info(listener registered\n); return 0; } static void __exit my_listener_exit(void) { my_event_unregister(my_listener_nb); pr_info(listener unregistered\n); } module_init(my_listener_init); module_exit(my_listener_exit); MODULE_LICENSE(GPL);这里有一点要注意my_listener_nb必须定义成全局变量不能是栈上的局部变量。因为通知链持有的是这个结构体的指针如果函数返回后栈空间被回收链上就是一个悬空指针等到触发事件时必定 oops。3.3 编译、加载和验证编译命令是标准的内核模块编译方式make -C /lib/modules/$(uname -r)/build M$PWD modules加载和验证sudo insmod my_event_notify.ko sudo insmod my_event_listener.ko echo test /proc/my_event_trigger dmesg | tail正常情况下dmesg里会出现listener: config changed, datatest每次往 proc 文件写内容my_event_notify就会遍历链上所有回调把data指针传过去。多加载几个监听模块所有模块的回调都会被执行这就验证了一对多广播的效果。3.4 查看链上到底挂了哪些回调如果在一台繁忙的机器上调试可能想知道链上注册了多少回调、都是谁。最直接的思路是在事件源里加一段遍历代码利用 RCU 读锁安全遍历rcu_read_lock(); for (nb rcu_dereference(my_event_chain.head); nb; nb rcu_dereference(nb-next)) { pr_info(nb%pS priority%d\n, nb-notifier_call, nb-priority); } rcu_read_unlock();%pS是内核里打印函数符号的专用格式符可以把函数指针直接转成可读的函数名。这段代码在调试阶段非常有用建议保留在一个 debugfs 接口里线上排查时能省不少事。4. 实战挂接用netdev_chain感知网卡上下线4.1 netdev_chain内核网络子系统的现成电台自己写事件源适合理解原理但真实项目里更多是挂接内核已有的通知链。netdev_chain是最典型、也是我最常用的一条链它的定义在net/core/dev.c里是一个原始通知链static RAW_NOTIFIER_HEAD(netdev_chain);任何模块都可以通过register_netdevice_notifier()注册回调感知网卡设备的生命周期变化。常用的事件枚举在include/linux/netdevice.h里和我们的业务最相关的几个事件触发时机NETDEV_UP网卡被启用NETDEV_DOWN网卡被禁用NETDEV_REGISTER网卡设备注册到内核NETDEV_UNREGISTER网卡设备注销NETDEV_CHANGENAME网卡改名NETDEV_CHANGEMTUMTU 变化NETDEV_PRE_UP网卡启用前触发可 veto我当时的透明加密业务需要感知网卡 down 和 up 来决定是否切换密钥协商通道挂这条链再合适不过。4.2 完整代码网卡上下线监听模块// netdev_watch.c #include linux/netdevice.h #include linux/notifier.h #include linux/module.h #include linux/kernel.h #include linux/init.h static int netdev_event_handler(struct notifier_block *nb, unsigned long event, void *ptr) { struct net_device *dev netdev_notifier_info_to_dev(ptr); if (!dev) return NOTIFY_DONE; switch (event) { case NETDEV_UP: pr_info(netdev: %s up\n, dev-name); break; case NETDEV_DOWN: pr_info(netdev: %s down\n, dev-name); break; case NETDEV_REGISTER: pr_info(netdev: %s registered\n, dev-name); break; case NETDEV_UNREGISTER: pr_info(netdev: %s unregistered\n, dev-name); break; case NETDEV_CHANGENAME: pr_info(netdev: name changed to %s\n, dev-name); break; default: break; } return NOTIFY_DONE; } static struct notifier_block netdev_nb { .notifier_call netdev_event_handler, .priority 0, }; static int __init netdev_watch_init(void) { int ret register_netdevice_notifier(netdev_nb); if (ret) return ret; pr_info(netdev_watch registered\n); return 0; } static void __exit netdev_watch_exit(void) { unregister_netdevice_notifier(netdev_nb); pr_info(netdev_watch unregistered\n); } module_init(netdev_watch_init); module_exit(netdev_watch_exit); MODULE_LICENSE(GPL);编译加载后随便对一个接口执行ip link set eth0 updmesg里就能看到netdev: eth0 up。不需要修改任何网络驱动代码也不需要知道网卡驱动是谁写的这就是通知链解耦的威力。4.3 data指针的版本差异与解析ptr参数在不同内核版本里的含义变化是一个大坑。在较老的 4.x 内核里事件回调的ptr通常直接就是struct net_device *很多旧代码会直接强转struct net_device *dev (struct net_device *)ptr;但从某个版本开始内核引入了struct netdev_notifier_info包装结构ptr不再一定指向net_device本身官方推荐使用辅助函数struct net_device *dev netdev_notifier_info_to_dev(ptr);netdev_notifier_info_to_dev内部会根据事件类型做校验兼容性更好。写新代码千万不要再直接强转ptr升级内核时最容易在这里翻车。尤其是做嵌入式、长期维护老 BSP 的朋友代码要同时兼容多版本内核时建议用辅助函数并做空指针判断。4.4 为什么netdev链是raw链RTNL带来的硬约束netdev_chain选择原始链是有原因的。网络子系统的核心操作几乎都受一把大锁保护——RTNL路由 Netlink 锁。call_netdevice_notifiers在派发事件时调用方通常已经持有 RTNL也就是说链的并发安全由 RTNL 保证了不需要再叠一层锁。如果再用阻塞链的读写信号量反而会造成锁嵌套的复杂度和性能损耗。这直接带来两条硬约束回调里不能再尝试获取 RTNL。比如在NETDEV_PRE_UP回调里调用dev_open()去开另一个网卡而dev_open()内部要拿 RTNL当前线程已经持有了 RTNL就会自死锁。回调里不能睡眠。虽然原始链本身不禁止但 RTNL 保护下的路径很多处于不允许调度的状态强行睡眠轻则触发调度器告警重则直接 panic。所以在 netdev 回调里只做记录、置标志位、统计这类轻量操作把真正耗时的业务处理丢到工作队列去执行。这条经验后面避坑章节还会再展开。5. 避坑实录高并发、重入与卸载崩溃的完整排查链路5.1 原子链里睡眠从oops开始反推有一次我在一个原子链的回调里写了kmalloc(GFP_KERNEL)结果系统在特定操作下直接抛出了类似这样的错误BUG: sleeping function called from invalid context at mm/slub.c:XXXX in_atomic(): 1, irqs_disabled(): 0看到这个 oops排查链路是这样的第一步确认in_atomic(): 1说明当前处于原子上下文不能睡眠。第二步看调用栈找到触发点确认是哪个回调函数在哪个链上被调用。第三步翻链的定义确认用的是ATOMIC_NOTIFIER_HEAD这类链在中断、软中断、panic 等关键路径上被调用回调里睡眠等于踩地雷。第四步把耗时操作重构成工作队列。修复后的回调模式长这样static void my_work_handler(struct work_struct *work); static DECLARE_WORK(my_work, my_work_handler); static int my_atomic_handler(struct notifier_block *nb, unsigned long action, void *data) { schedule_work(my_work); return NOTIFY_DONE; }schedule_work本身是原子的可以安全调用真正的业务放到进程上下文的工作队列里执行。这个模式我后来在多个驱动里重复使用稳定可靠。如果你的系统打了 PREEMPT_RT 实时补丁原子上下文的判断会更严格原来以为安全的临界区也可能报警这类问题在开发阶段最好就打开CONFIG_DEBUG_ATOMIC_SLEEP和CONFIG_PROVE_LOCKING让 lockdep 帮你提前暴露隐患。5.2 回调重入与自锁一个修改网卡状态引发的死锁这是我在 netdev 监听模块里踩过最深的坑。业务诉求很简单检测到某块网卡 down 之后另一块备用网卡需要同步 up。于是我在回调里直接调用了dev_change_flags()去操作备用网卡。结果系统直接死锁。排查过程先看dmesg有BUG: spinlock lockup suspected或者possible circular locking dependency detected这类信息再打开 lockdep 的依赖报告能看到netdev_chain的派发路径持有了 RTNL而我的回调又试图去获取 RTNL形成了典型的自我重入。这类问题的标准解法是不在回调里做可能再次触发同一事件的动作把所有动作异步化static unsigned long event_pending; static void netdev_work_handler(struct work_struct *work); static DECLORK_WORK(netdev_work, netdev_work_handler); // 实际是 DECLARE_WORK static int netdev_safe_handler(struct notifier_block *nb, unsigned long event, void *ptr) { if (test_and_set_bit(0, event_pending)) return NOTIFY_DONE; schedule_work(netdev_work); return NOTIFY_DONE; }用test_and_set_bit做防重入标记保证同一时间只有一个工作在排队避免事件风暴把工作队列挤爆。工作函数里再去获取 RTNL、修改网卡状态就是安全的了因为工作队列是进程上下文没有锁嵌套问题。5.3 模块卸载后的野指针注册残留这个坑的现场很经典模块加载期间一切正常一旦rmmod卸载模块再拔插网卡系统直接 oops栈顶打印的是已卸载模块的函数地址比如netdev_watch_exit0x...或者干脆?号。根因就是模块在 exit 里忘了调用unregister_netdevice_notifier或者虽然调用了但卸载和事件派发存在并发竞争。通知链内部不会自动维护模块引用计数链上只保存了回调函数指针它不会因为你的模块卸载了就自动清空。对于阻塞链blocking_notifier_chain_unregister内部持有写锁返回时能保证链上已经删掉你的节点也不会再有正在执行的回调访问你的模块相对安全。但对于原始链比如netdev_chainraw_notifier_chain_unregister只是做了 RCU 指针更新不会等待正在遍历链的读者退出。如果读者的遍历刚好发生在模块卸载之后、RCU 宽限期结束之前它仍然可能跳进一个已经被卸载的模块代码段然后 oops。如果你的模块可能被热卸载又必须挂原始链稳妥做法是在 unregister 之后补一道 RCU 屏障unregister_netdevice_notifier(netdev_nb); synchronize_net();synchronize_net()是网络子系统提供的 RCU 同步屏障确保在返回前所有正在使用网络相关 RCU 读端的路径已经离开临界区。然后再安全释放回调引用的私有数据。单纯依赖rmmod的运气是不行的我在测试环境里就被这种竞态咬过不止一次。5.4 返回值、重复注册与其他小坑除了上面三个大坑还有几个细节性错误我见过不少同事踩进去。第一个是返回值误判。很多刚接触通知链的人以为返回非 0 就会停止遍历于是回调里return NOTIFY_OK结果后面的监听者全被漏掉了。正如 2.4 节所述只有NOTIFY_BAD和NOTIFY_STOP这类带NOTIFY_STOP_MASK位的返回值才能终止遍历NOTIFY_OK和NOTIFY_DONE都会继续。第二个是同一节点重复注册。notifier_chain_register不会检查节点是否已经在链上同一个struct notifier_block注册两次就在链上出现两次触发一次事件你的回调就被执行两次。排查这类问题时调一次 3.4 节的链遍历函数看看同一个%pS是否出现了多次一眼就能确认。第三个是不检查回调里的data指针。事件源传进来的data指针往往有很强的上下文依赖比如NETDEV_UNREGISTER事件里的dev在回调返回后可能就被释放了如果你把它存到全局变量里供后续使用后面访问就是野指针。正确处理是回调里完成引用计数管理或者只提取你确实需要的字段做拷贝。5.5 调试工具lockdep、动态调试、ftrace、crash通知链的问题难在看不到所以调试工具的价值极大。我日常排查的固定组合是这样的开发版内核一定开CONFIG_DEBUG_ATOMIC_SLEEP、CONFIG_PROVE_LOCKING、CONFIG_KASAN大多数睡眠违规和越界访问在开发阶段就能暴露。动态调试看kernel/notifier.c的执行路径echo file kernel/notifier.c p /sys/kernel/debug/dynamic_debug/control再触发事件dmesg会打印通知链遍历的细节。ftrace 跟踪具体函数echo notifier_call_chain /sys/kernel/tracing/set_ftrace_filter配合trace_printk能看清楚回调之间的调用顺序。线上问题用 crash 工具直接查链crash netdev_chain能打印整条链的内容也可以手动跟随next指针遍历struct notifier_block快速确认链上有没有残留节点。这些工具配合使用绝大多数通知链问题半小时内都能定位到根因比瞎改代码试错高效得多。说实话通知链这块机制本身真不算难核心就是一张链表加几个锁变体。我踩过的所有大坑没有一个是看不懂 API导致的全是上下文问题——在原子上下文里睡了觉、在持锁路径上又去抢同一把锁、在模块卸载后还留了回调指针。如果你在写文件系统过滤、透明加密这类驱动迟早还会碰到fsnotify、security hook这些机制它们的核心思想和通知链一模一样都是事件驱动 回调注册只是各自的保护锁和调用时机不同。把通知链的上下文意识练扎实了再去看那些机制会轻松很多。