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_head | SRCU 读锁 | 读多写少、对读侧延迟敏感 |
这里面最常用的就是前两种:原子通知链和可阻塞通知链。而 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 设成 1,B 设成 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,上报数据时发一个事件,融合算法模块只管订阅事件。从此加新传感器只需要注册和上报,主模块一行不改,整个架构清爽了很多。
这也是通知链最根本的价值:让你的内核模块之间,既能互相对话,又不用互相认识。如果你在做模块化开发时也遇到了这种“互相认识太深、改动牵一发动全身”的困境,那通知链就是你的答案。