ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Linux内核通知链(notifier chain)机制详解与实战

2026/9/15 10:27:20 拓冰建站 浏览量
Linux内核通知链(notifier chain)机制详解与实战 1. 先搞懂通知链到底在解决什么问题我在看内核代码的时候经常碰到一种尴尬的情况明明两个内核模块属于不同子系统一个要告诉另一个“我这边状态变了你赶紧去处理一下”但两者之间又不能直接互相调用函数。直接耦合吧编译依赖、加载顺序、符号依赖全来了代码丑得没法看用轮询去查状态吧效率低得离谱实时性也完全没有保证。内核开发者早就遇到了这个痛点所以搞出了一套叫通知链的机制英文叫 notifier chain。你可以把通知链理解成内核里的一根“广播总线”。事件发生方只需要把消息往总线上一扔任何注册在这条总线上的接收方都会收到通知然后执行各自的回调函数。事件源根本不需要知道有多少人关心这个消息也不需要知道那些关心消息的模块究竟是谁、什么时候注册的。这种发布-订阅模式是内核里模块间解耦通信的经典方案之一。通知链在内核里的使用频率非常高网络子系统、电源管理、文件系统、热插拔、键鼠输入这些场景全都在用它。比如网络设备一旦插拔netdev 通知链就会广播 NETDEV_REGISTER、NETDEV_UNREGISTER、NETDEV_DOWN 这类事件让上层协议栈、网卡驱动、用户态相关的子系统都能第一时间感知。再比如宕机前的 panic 处理、CPU 热插拔、内存在线切换也都有对应的通知链在做协调。这篇文章不是单纯给你翻译内核文档我会从使用者的角度把通知链的数据结构、四种类型怎么选、注册回调怎么写以及我在实际项目里踩过的坑全部掰开揉碎了讲。不管你是做驱动开发、内核模块二次开发还是想理解内核事件传播机制的原理这篇文章都值得你花点时间细看。2. 通知链的四种类型与选型判断2.1 四种类型到底长什么样内核里的通知链一共有四种定义在include/linux/notifier.h里。它们的基本逻辑是一样的只是加锁方式和调用上下文有区别我先把这四种类型和它们对应的头结构列出来类型头结构加锁方式适用场景原子通知链atomic_notifier_head自旋锁中断上下文、原子上下文可阻塞通知链blocking_notifier_head信号量进程上下文原始通知链raw_notifier_head无锁由使用者自己加锁特殊场景、自己管理锁SRCU 通知链srcu_notifier_headSRCU 读锁读多写少、对读侧延迟敏感这里面最常用的就是前两种原子通知链和可阻塞通知链。而 raw 通知链属于“三不管地带”内核里只有少数几个地方在用比如reboot_notifier_list早期启动阶段或者一些对锁策略有特殊需求的子系统。它不是给你普通驱动直接用的。SRCU 通知链是基于 SRCU 机制实现的一种变体读者可以获得无锁的读侧体验但写侧需要同步等待读侧结束适合那种读侧极其频繁、写侧非常稀少的场景。2.2 怎么选才不会被坑这个选型问题几乎是我在社区里回答得最多的问题。很多初学者根本不看上下文就随便选一个结果要么是 sleepy function called from invalid context 直接 panic要么是明明可以用自旋锁的地方非要搬出信号量白白浪费性能。我给出一个比较实用的判断顺序如果回调函数可能在中断上下文、软中断、local_bh_disable临界区里被调用那必须用原子通知链。因为这种上下文根本不允许睡眠而信号量相关的操作可能会让调用者睡眠一睡就出事。原子的通知链内部用自旋锁保护链表自旋锁本身就适合中断上下文。如果回调函数只会在进程上下文执行能保证没有锁嵌套问题那就选可阻塞通知链。它用信号量做保护允许在回调函数里做比较重的操作比如msleep、wait_event、获取另一个信号量等。如果回调需要读某个 RCU 保护的数据结构或者你对延迟特别敏感那就考虑 SRCU 通知链。内核里的srcu_notifier_head实际上也是基于 SRCU 机制的它能让读侧非常快写侧则负责同步等待。raw 通知链我劝你在没有十足把握的情况下别碰。它连链表锁都不给你加所有并发保护要自己搞定一旦出问题就是数据竞争还特别难排查。选型时还有一个很容易忽略的角度事件源所在上下文并不是唯一的判断标准你得看完整条链路。比如某个事件源注册的时候是在进程上下文调用的但事件触发时会进入中断处理函数那还是得用原子通知链。我见过太多因为只看了注册位置、没追事件触发路径结果线上环境直接 panic 的案例。3. 核心数据结构与API逐个拆解3.1 两个你必须背下来的结构体通知链的核心数据结构其实就两个方向一个描述“订阅者”一个描述“发布会场”。订阅者结构体定义如下struct notifier_block { int (*notifier_call)(struct notifier_block *nb, unsigned long action, void *data); struct notifier_block *next; int priority; };这个结构体里notifier_call是核心它是订阅方在事件发生时被回掉的函数指针。priority是优先级数字越大越先被调用。为 0 表示按注册顺序调用为负数表示排在后面。next是内核维护的链表指针你基本不需要手动操作它。发布会场的头结构就看类型了。如果是原子通知链struct atomic_notifier_head { spinlock_t lock; struct notifier_block __rcu *head; };如果是可阻塞通知链struct blocking_notifier_head { struct rw_semaphore rwsem; struct notifier_head head; };注意可阻塞通知链用的是读写信号量rwsem这意味着多个读者可以同时遍历回调链表而写者注册/注销需要独占。3.2 注册和注销的完整操作每种通知链头都有配套的注册、注销、发送通知的 API。虽然函数名不同但套路完全一样只是内部锁的机制不同。原子通知链的函数是void atomic_notifier_chain_register(struct atomic_notifier_head *nh, struct notifier_block *n) void atomic_notifier_chain_unregister(struct atomic_notifier_head *nh, struct notifier_block *n) int atomic_notifier_call_chain(struct atomic_notifier_head *nh, unsigned long val, void *v)可阻塞通知链则是int blocking_notifier_chain_register(struct blocking_notifier_head *nh, struct notifier_block *n) int blocking_notifier_chain_unregister(struct blocking_notifier_head *nh, struct notifier_block *n) int blocking_notifier_call_chain(struct blocking_notifier_head *nh, unsigned long val, void *v)还有带_rcu后缀的变体比如blocking_notifier_chain_register_rcu()。这些变体在读侧遍历时使用 RCU 保护适合在遍历期间持有 RCU 锁的场景。这里我不展开讲所有变体你真用到的时候再看头文件里都有注释。发送通知的时候核心函数会根据优先级对回调链表排序然后依次调用。内核源码里notifier_call_chain()这个函数是最终执行者static int notifier_call_chain(struct notifier_block **nl, unsigned long val, void *v, int nr_to_call, int *nr_calls)它会把nb-next链表按优先级从高到低排列好然后逐个调用notifier_call同时把上一个回调的返回值传递给下一个回调这点在后面避坑部分我会详细说明。3.3 回调函数的返回值语义回调函数的返回值由定义好的宏常量表示这些常量在include/linux/notifier.h中定义#define NOTIFY_DONE 0x0000 #define NOTIFY_OK 0x0001 #define NOTIFY_BAD (NOTIFY_OK | 0x0002) #define NOTIFY_STOP_MASK 0x8000 #define NOTIFY_STOP (NOTIFY_OK | NOTIFY_STOP_MASK)简单理解就是NOTIFY_DONE表示“我处理完了但这事我不关心”。回调正常结束不阻断后续回调。NOTIFY_OK表示“我处理成功”。继续执行后续回调。NOTIFY_BAD表示“处理出错了”。此时整条通知链会停止后续回调不再执行并且错误码会向上层返回。NOTIFY_STOP表示“不需要继续通知了”相当于事件传播的中断信号。你写回调函数时默认情况下都应该返回NOTIFY_DONE或者NOTIFY_OK。只有真的发现问题、需要终止链路时才返回NOTIFY_BAD。很多人习惯性地直接return 0虽然 0 宏定义正好是NOTIFY_DONE但这样写语义不清晰我在 review 代码时都会要求别人显式使用宏后期维护能省不少事。4. 完整实战从事件源到订阅者的驱动示例4.1 用最典型的内核事件来演示理论讲太多容易飘我直接用一个实际的内核模块来演示整个流程。假设我在维护一个网络相关的内核模块需要感知网络设备的上线与下线。这里最标准的做法就是使用register_netdevice_notifier()注册一个设备通知块。在 Linux 内核里netdev_chain就是一个全局的原子通知链头网络子系统向它发送设备状态变化事件。我的模块首先定义一个struct notifier_block结构体初始化回调函数和优先级然后在模块初始化函数里调用注册接口。大概代码如下#include linux/module.h #include linux/notifier.h #include linux/netdevice.h static int my_netdev_event(struct notifier_block *nb, unsigned long event, void *ptr) { struct net_device *dev netdev_notifier_info_to_dev(ptr); switch (event) { case NETDEV_UP: pr_info(my_net: device %s is up\n, dev-name); break; case NETDEV_DOWN: pr_info(my_net: device %s is down\n, dev-name); break; case NETDEV_REGISTER: pr_info(my_net: device %s registered\n, dev-name); break; case NETDEV_UNREGISTER: pr_info(my_net: device %s unregistered\n, dev-name); break; default: break; } return NOTIFY_DONE; } static struct notifier_block my_netdev_nb { .notifier_call my_netdev_event, .priority 0, }; static int __init my_notifier_init(void) { int ret; ret register_netdevice_notifier(my_netdev_nb); if (ret) { pr_err(my_net: register_netdevice_notifier failed, ret%d\n, ret); return ret; } pr_info(my_net: netdevice notifier registered\n); return 0; } static void __exit my_notifier_exit(void) { unregister_netdevice_notifier(my_netdev_nb); pr_info(my_net: netdevice notifier unregistered\n); } module_init(my_notifier_init); module_exit(my_notifier_exit); MODULE_LICENSE(GPL);这段代码或者基于它改出来的逻辑我写过无数次。关键是注意两点回调函数里不能用dev-name直接打印而是要先用netdev_notifier_info_to_dev(ptr)把ptr转换回struct net_device *事件处理要保持轻量不要在热路径上做太重的事。4.2 自定义一条通知链的实现如果说上面还只是“使用别人的通知链”那真正的“从入门到进阶”就在于你自己定义一条全新的通知链让别人来订阅你的事件。假设我的驱动需要向上层模块广播电压、温度等传感器数据变化事件。我可以自己定义一个原子通知链头#include linux/notifier.h static ATOMIC_NOTIFIER_HEAD(sensor_event_chain); void sensor_event_publish(enum sensor_event_type type, void *data) { atomic_notifier_call_chain(sensor_event_chain, (unsigned long)type, data); } EXPORT_SYMBOL(sensor_event_publish); int sensor_event_register(struct notifier_block *nb) { return atomic_notifier_chain_register(sensor_event_chain, nb); } EXPORT_SYMBOL(sensor_event_register); int sensor_event_unregister(struct notifier_block *nb) { return atomic_notifier_chain_unregister(sensor_event_chain, nb); } EXPORT_SYMBOL(sensor_event_unregister);其他模块如果关心传感器事件就提供自己的notifier_block调用sensor_event_register()完成订阅。事件源和订阅方彼此不需要知道对方的导出符号只要事件类型的定义稳定即可。这种接口设计可以减少模块间的编译依赖。这里我用ATOMIC_NOTIFIER_HEAD()宏定义链头好处是一行代码完成初始化不用在模块初始化函数里手动调用spin_lock_init。如果是在代码中间动态初始化可以用ATOMIC_INIT_NOTIFIER_HEAD(head)宏或者在运行时调用atomic_notifier_chain_init()这些都是内核提供的便捷方式。4.3 回调函数执行的优先级策略很多初学内核的朋友不知道priority的实际价值在所有notifier_block里都写 0。这在事件订阅方只有一两个时确实看不出问题一旦订阅方变多顺序不稳定就会引发隐性 bug。我在真实项目里遇到过一个问题有两个模块同时关注网络设备的启动事件模块 A 需要先初始化硬件资源模块 B 要在 A 初始化完成后才能安全地获取设备信息。当时两个模块的priority都是 0回调顺序完全由注册顺序决定加载顺序一变B 就在 A 之前被回调结果设备资源还没准备好B 拿到一堆空指针。内核里对priority的排序逻辑是同一通知链上priority越大的notifier_block越先被调用。如果两个人优先级相同则按注册先后顺序调用先注册的先被调用。所以我的建议是如果回调之间存在先后依赖务必显式设置priority。比如 A 设成 1B 设成 0这样无论加载顺序怎样A 总是先执行。千万不能依赖“我只要改一下 insmod 顺序就能绕过去”这种脆弱做法因为运行环境下一次加载顺序可能完全不受你控制。5. 回调函数里能做什么不能做什么5.1 上下文决定一切既然通知链类型决定了回调函数执行的上下文那你写回调函数时就必须严格遵循这个上下文的规则。如果是原子通知链的回调执行环境可能是中断上下文或者持有自旋锁的临界区。此时你要记住的大忌是不能调用任何可能睡眠的函数比如kmalloc(..., GFP_KERNEL)、mutex_lock、msleep、wait_event等。不能直接调用printk家族中可能触发控制台调度睡眠的函数虽然printk在多数时候正常但在原子上下文里一旦控制台刷新被阻塞就可能出问题。不能随便使用spin_lock时因为有死锁风险。如果确实需要分配内存必须用原子上下文安全的分配方式struct my_data *data kmalloc(sizeof(*data), GFP_ATOMIC);那可阻塞通知链的回调就宽松多了因为它在进程上下文执行可以正常使用GFP_KERNEL分配内存、可以拿mutex、甚至可以做些耗时操作。但也要小心别长时间持有信号量因为整个回调链表是在rwsem的读锁保护下遍历的你阻塞太久其他想注册或注销这条链的人就要等你。5.2 防止递归与死锁这是一个容易忽略的隐蔽问题。假设你的回调函数里又去触发了一次同样的事件发布那就可能出现递归调用。比如回调函数里调用了一个函数这个函数内部又向同一通知链发送事件轻则栈溢出重则死锁。你在打断点或者加日志时特别容易莫名其妙触发类似的二次递归排查起来相当头痛。另外还有一个死锁场景阻塞通知链的回调函数里如果同一个任务试图再次去注册或注销同一个链表项就可能会死锁。因为遍历链表时持有读锁注册动作需要写锁如果同一个进程既持有读锁又要申请写锁信号量的互斥特性就可能把你卡死。解决方法是不要在回调里操作同一条通知链。5.3 回调的实时性内核中有些通知链的事件发生频率特别高比如 CPUfreq 调频、内核内存防碎片等场景事件可能在极短时间内爆发。如果回调函数处理太慢会直接拖慢整个系统。我见过有人把磁盘 I/O 和用户态通信这种重操作直接塞进原子通知链回调里的性能直接是灾难级的。正确的做法是回调函数里只做必要的事比如置一个标志位、唤醒一个内核线程、往队列里塞一个任务并触发schedule_work把真正的重活交给工作队列或专用内核线程去处理。6. 避坑指南我实际踩过的那些坑6.1 注册失败和注销顺序问题atomic_notifier_chain_register()的返回值只有 0 和负值0 表示成功。但是有一类特殊失败是重复注册同一个struct notifier_block结构体被重复注册到同一条链上。内核并不会帮你检查这一点它只是简单地把这个节点挂到链表上重复注册会导致回调被调用两次或者注销时可能出现意外。我自己的习惯是每个notifier_block只在模块初始化时注册一次把unregister放在模块退出函数里。如果模块可能被多次加载卸载还要确保每次重新加载使用全新分配的结构体避免残留的链表节点在新一轮加载里被误用。注销时的顺序也有讲究。如果你的模块在注销时释放了某些资源比如kfree掉了回调数据但另一个模块还持有该事件的引用那后续事件到达时回调函数就可能访问已释放的内存。安全做法是先unregister再释放回调里用到的所有资源保证没有并发回调在访问数据。6.2 返回值传递陷阱你写回调的时候notifier_call_chain()内部会维护一个返回值状态。如果前一个回调返回了NOTIFY_BAD后续回调根本不会被执行。但很多人的回调里随手返回了NOTIFY_BAD原因只是“我这个分支出错了我表示一下”结果把整条链上的其他模块全部干掉了。正确的心态是NOTIFY_BAD表示“这个事件我处理不了而且我强烈要求整个链路停止”。一般只有那种“事件处理失败会造成后续严重问题”的场景才用它。大部分业务逻辑的错误处理应该记录日志、设置错误标志然后继续正常返回NOTIFY_DONE或NOTIFY_OK让事件继续传递。另外还有个容易忽略的细节触发通知时notifier_call_chain()会把上一个回调的返回值传给下一个回调的入参。注意看notifier_call_chain()函数实现的内部机制它把val保持为事件类型但是把v这个 data 指针原样传递返回值并不会自动替换掉v。这里很多人会混淆以为下一个回调拿到的v是上一个回调的返回值其实不是。返回值是独立的v一直是最初传入的事件数据指针。6.3 锁顺序与死锁真实案例我去年排查过一个实时性很强的系统问题。模块 A 在回调里拿了自旋锁 L1然后调用了一个函数这个函数内部又尝试拿自旋锁 L2而模块 B 的回调恰好在另一个 CPU 上拿了 L2然后又要注册一个新的 notifier block注册动作会尝试拿链表的锁。链路变成了 A 持 L1 等 L2、B 持 L2 等链表锁、链表锁被 C 持有并在等 L1。一圈下来就形成了经典的锁顺序死锁。要避免这类问题核心原则是保证全局的锁顺序一致拿锁永远从外层到内层不要交叉拿锁。写回调函数时尽量把需要加锁的临界区压到最小避免在持锁状态下发起新的通知。如果实在需要通知其他模块那就先释放当前锁再发送通知。6.4 调试手段与观测方法我调试通知链问题时最常用的是这几种手段用printk在注册、注销、回调三个位置打印关键信息。原子通知链的回调如果从中断上下文打日志建议用pr_info加fmt时注意避免在中断里用%p之类的特殊格式会影响日志输出速度。在/proc或 debugfs 里暴露通知链的注册状态。我写驱动时习惯加一个 debugfs 节点把链上每个notifier_block的回调函数地址打印出来通过/proc/kallsyms找到对应符号就能直观看到“究竟谁注册了这条链、什么时候注册的”。用 ftrace 跟踪notifier_call_chain的调用路径。echo notifier_call_chain /sys/kernel/tracing/set_ftrace_filter再加上function_graph模式可以看到整个回调链的调用顺序。这在回调非常频繁、日志刷屏的场景下特别有用。最后还有一个建议如果你要在一个可能会被热插拔的模块里注册通知链退出时一定要确保在所有可能触发事件的路径上都已经同步完成后再注销。注销后再发来的事件链表上已经没有你的回调这没问题但就怕你注销完没等正在执行的回调退完就开始释放资源那才是真正的竞争问题。7. 常见问题速查现象可能原因解决办法注册后回调一直没有被调用回调链表优先级比对错误或注册到了错误的链头通过 /proc 或代码打印检查链上节点确认事件确实发了insmod 时直接崩溃提示调度函数在原子上下文在原子通知链回调里调用了睡眠函数改用 blocking_notifier_head或把耗时操作移到工作队列回调执行两次同一个 notifier_block 被重复注册检查模块加载路径确保一个 block 只注册一次模块卸载时死锁注销时链上还有其他节点正在执行回调先注销再释放资源检查锁顺序事件触发了但回调打印的 data 不对回调里错误地假设了 data 类型或事件类型匹配错误对照事件定义检查action和data的语义其他模块事件处理被中断前面的回调返回了 NOTIFY_BAD 或 NOTIFY_STOP检查前面节点的返回值明确你的返回语义8. 除了 netdev还可以接哪几根链内核里现成的、可以直接订阅的通知链其实非常多我整理几个最常用的网络设备通知链netdev_chain通过register_netdevice_notifier()注册事件包括 NETDEV_REGISTER、NETDEV_UP、NETDEV_DOWN、NETDEV_UNREGISTER、NETDEV_CHANGENAME 等。做网络管理、虚拟网卡、策略路由相关功能时几乎必用。电源管理通知链power_supply_chain通过power_supply_reg_notifier()注册电池插拔、容量变化会及时通知相关驱动。笔记本驱动、UPS 管理都有应用场景。内存热插拔通知链memory_chain通过register_memory_notifier()订阅内存块上线、下线事件适合做内存资源感知的驱动。还有 CPU 频率、CPU 热插拔、重启通知链等。重启通知链比较特殊它是 raw 通知链。你写驱动时如果在设备驱动里注册reboot_notifier_list的回调机器重启/关机前会通知驱动做收尾这在高可用存储设备中非常常见。文件系统也有相关的事件链比如fsnotify这类不过它的使用方式比标准通知链复杂一些可能还需要配合其他机制。9. 实操心得从模块接口设计看通知链最后说点我这么多年的经验感受。通知链虽然是内核里的老机制但用得好不好直接影响驱动或子系统的可扩展性。设计一个新的事件传播接口时我会至少想清楚几件事第一事件源与订阅方的接口要稳定。把事件类型定义成一个独立的头文件里的枚举把事件数据封装成一个固定的结构体不要在回调里临时发明数据结构。第二尽量只暴露“注册/注销/发布事件”这几个函数不要暴露链头本身。这样外部模块就不容易绕开你的 API 干出些毁链的骚操作。第三明确链的使用上下文。如果一开始没想清楚是给中断上下文用还是只给进程上下文用那宁可选可阻塞的通知链因为它在进程上下文使用最自由。如果后期真需要中断上下文支持再改造成原子通知链工作量也不会太大。我早期帮团队设计过一个传感器融合的内核模块最初每个传感器驱动直接调用融合模块的导出函数上报数据结果传感器模块和融合模块的编译依赖、加载顺序耦合得非常紧后来加新传感器时特别痛苦。重构之后改成一条原子通知链每个传感器只是在初始化时注册一个notifier_block上报数据时发一个事件融合算法模块只管订阅事件。从此加新传感器只需要注册和上报主模块一行不改整个架构清爽了很多。这也是通知链最根本的价值让你的内核模块之间既能互相对话又不用互相认识。如果你在做模块化开发时也遇到了这种“互相认识太深、改动牵一发动全身”的困境那通知链就是你的答案。