Linux内核通知链完全指南:原理、四种类型与实战避坑
2026/9/20 10:19:43 网站建设 项目流程

1. 通知链到底在解决什么问题

我最早接触Linux内核通知链,是被一个问题逼着去读源码的:当时我在做一套嵌入式设备的网络状态监控,需要在上层感知网口的插拔和IP地址变化。最开始的想法很直白——拿到net_device对应的结构体,然后隔一段时间轮询一遍状态。轮询确实能跑,但体验很糟糕,事件发生后要等好几个周期才能被发现,而且为了抓一个瞬间的link down事件,我得把轮询周期调到非常短,CPU被白白吃掉不少。

后来同事提了一句:“内核里有现成的notifier chain,你注册一下就能收到事件。”我去翻了源码,才算是把这个机制彻底弄明白了。从那时起,通知链就成了我写内核模块时最常用的通信方式之一,无论是监听网络设备变化、reboot流程,还是给自己的子系统设计“插件式”回调接口,都离不开它。

通知链的字面意思就是“通知链条”,它本质上是一个内核内部的事件发布-订阅模型。某个核心子系统(比如网络协议栈、电源管理、内存管理)在关键节点上触发一个事件,所有注册了回调函数的模块可以立刻收到通知,并做出响应。这样一来,核心代码不需要知道自己下面挂了谁,扩展模块也不需要去改动核心代码,两边完全解耦。

如果你写过用户态程序,可以把通知链类比成Qt的信号槽,或者Linux桌面环境里的D-Bus广播。事件产生方只要往总线上一丢,谁关心这个事件谁就订阅,大家互不干扰。在内核这种“牵一发动全身”的环境里,这种解耦不是锦上添花,而是必需品——否则光是网络设备插拔这一个事件,就要在dev.c里堆满各种驱动模块的私有函数调用,层与层之间彻底乱套。

这篇文章我不打算只讲API怎么用,而是把四种通知链的区别、内核源码里的实现逻辑、实际编写模块时的步骤,以及我踩过的那些坑一次说清楚。无论你是准备在内核驱动里监听某个子系统的事件,还是想为自己的子系统设计通知接口,看完之后都可以直接上手。

2. 四种通知链,怎么选才不出事

2.1 一张表看清四兄弟

通知链不是只有一种,内核里一共定义了四种类型:原子通知链(atomic_notifier_chain)、可阻塞通知链(blocking_notifier_chain)、原始通知链(raw_notifier_chain)和SRCU通知链(srcu_notifier_chain)。我第一次看这些名字时一头雾水,后来在实际使用中才摸清它们的脾气。它们之间的核心差异,其实就是两个问题:回调函数能在什么上下文里执行,以及链表访问用什么锁来保护。

通知链类型自定义链头宏锁机制回调允许的上下文典型用途
atomic_notifier_chainATOMIC_NOTIFIER_HEAD自旋锁原子上下文,不可睡眠内存管理OOM、中断相关事件
blocking_notifier_chainBLOCKING_NOTIFIER_HEAD读写信号量进程上下文,可以睡眠网络设备事件、reboot通知
raw_notifier_chainRAW_NOTIFIER_HEAD无,调用者自己保护取决于调用者已有外部锁保护的特殊场景
srcu_notifier_chainSRCU_NOTIFIER_HEADmutex + SRCU进程上下文,可以睡眠对读路径延迟敏感的子系统

atomic链用的是自旋锁,这意味着它的回调函数运行在原子上下文里,绝对不能睡眠。你在回调里调用kmalloc(GFP_KERNEL)、mutex_lock这种会导致睡眠的函数,轻则内核警告,重则直接死锁系统。这类链适合处理中断上下文或者时间敏感的事件,比如内存紧张时的OOM通知。

blocking链用的是读写信号量,回调跑在进程上下文,可以在里面做比较重的事情,比如分配内存、等待I/O、调用那些本身就可能睡眠的函数。我用的最多的netdev链和reboot链都属于这一类。不过要注意,虽然能睡眠,但也不能在里面无限制地搞长时间阻塞操作,毕竟你阻塞的是整个事件通知流程。

raw链比较特殊,它连锁都不提供,完全靠调用者自己在外部保证并发安全。你如果去看内核里raw链的使用场景,会发现调用方一般在调用notifier_call_chain之前已经持有了某种锁,或者调用路径本身就保证了串行执行。对我们普通模块开发者来说,raw链的使用频率不高,更多是内核内部某些子系统的私有约定。

srcu链是后来加入的,它在读端使用SRCU机制,写端有mutex保护。这种链的优势是查询链头列表时不会因为锁竞争而阻塞读者,适合那些读操作非常频繁、写操作很少的场景。

2.2 从内核实际例子理解选择逻辑

光看表格可能还是有点抽象,我举几个内核里的具体例子你就明白了。

netdev链(网络设备通知链)是blocking链。原因很简单,netdev事件处理函数里经常需要做大量工作,比如根据设备上下线调整路由表、释放资源、触发用户态网络管理器等,这些操作都可能在回调里睡眠等待。我在自己的模块里监听NETDEV_REGISTER和NETDEV_UNREGISTER事件时,会在回调里直接申请内存、创建proc文件,跑起来完全没问题。

inetaddr链(IP地址通知链)却是atomic链。我在写一个ARP相关模块时曾经试图在这个链的回调里调用synchronize_rcu(),结果内核直接报“BUG: sleeping function called from invalid context”。后来看了源码才发现inetaddr_chain确实是用ATOMIC_NOTIFIER_HEAD声明的,因为它可能在软中断路径上被触发,不允许睡眠。如果你需要监听IP地址变化,同时又想做重量级操作,正确做法是在回调里只记录状态,然后把实际工作丢给workqueue去执行。

reboot链是blocking链,所以网上有些老代码说在reboot通知回调里不能调用printk,其实是不准确的。现代内核里printk本身在大多数路径上都不会睡眠,而且reboot链的上下文允许睡眠,你甚至可以在回调里做轻微的清理工作。但要注意,不要在reboot链里做太多耗时操作,我见过有人试图在reboot回调里同步刷新大量数据到磁盘,结果整个关机流程卡在那里,用户体验极其糟糕。

3. 核心数据结构与调用链条源码解析

3.1 notifier_block:回调函数的挂载点

不论哪种通知链,它们的基本元素都是同一个结构体:struct notifier_block。定义在include/linux/notifier.h中,内容非常简洁:

struct notifier_block { notifier_fn_t notifier_call; struct notifier_block __rcu *next; int priority; };

notifier_call是回调函数指针,原型是int (*notifier_fn_t)(struct notifier_block *nb, unsigned long action, void *data)。action是事件类型,比如NETDEV_REGISTER、NETDEV_UP、NETDEV_DOWN这些,data则携带事件相关的数据,不同类型的链、不同的事件,data的含义都不一样,这点后面实战部分我会专门提醒。

priority是优先级,数值越大,在链上的位置越靠前,越先被调用。如果你不关心调用顺序,置0就行。我实际用下来,多数场景下优先级确实不需要调,但有几种情况必须注意:如果A模块需要先于B模块初始化状态,而恰好你的回调逻辑对执行顺序敏感,那就必须显式设置优先级。比如某个事件期望“先通知准备类模块,再通知消费者模块”,你就得把前者priority设高。

next指针是链表节点,一般情况下你不需要直接操作它,注册函数内部会处理好。

3.2 注册、注销与触发流程

内核对外暴露的接口很对称,每种链都有一组register、unregister、call函数,内部最终都调用共同的底层逻辑。以blocking链为例:

int blocking_notifier_chain_register(struct blocking_notifier_head *nh, struct notifier_block *nb); int blocking_notifier_chain_unregister(struct blocking_notifier_head *nh, struct notifier_block *nb); int blocking_notifier_call_chain(struct blocking_notifier_head *nh, unsigned long val, void *v);

注册函数的内部逻辑是:拿着链头的锁,从head指针开始遍历,根据priority把当前block插入到合适的位置。整个插入是排序插入,也就是链上始终按priority从大到小排列。如果你注册的block和链上某个节点priority相同,新节点会插到已有节点后面。

触发函数内部核心是notifier_call_chain,我简化一下它的逻辑:

static int notifier_call_chain(struct notifier_block **nl, unsigned long val, void *v, int nr_to_call, int *nr_calls) { int ret = NOTIFY_DONE; struct notifier_block *nb, *next_nb; nb = rcu_dereference_raw(*nl); while (nb && nr_to_call) { next_nb = rcu_dereference_raw(nb->next); ret = nb->notifier_call(nb, val, v); if (nr_calls) (*nr_calls)++; if (ret & NOTIFY_STOP_MASK) break; nb = next_nb; nr_to_call--; } return ret; }

这段代码有几个关键点。第一,遍历时先保存next_nb再用回调,这是为了防止回调函数内部把自己注销掉导致悬空指针。第二,每个回掉后的返回值会和NOTIFY_STOP_MASK做与运算,如果命中了,就立即中止整条链的处理。第三,最终返回值是最后一个执行的回调的返回值。

所谓“避坑要从源码看起”,很多奇怪问题都是因为没理解这个简单循环导致的。比如有人发现通知链只收到部分事件,排查半天才发现是有个前置模块的回调返回了NOTIFY_STOP,强行使他的回调没有被执行。

3.3 返回值约定与NOTIFY_STOP的威力

通知链回调函数的返回值不是随意定义的,内核规定了几种语义,定义在include/linux/notifier.h中:

#define NOTIFY_DONE 0x00000000 #define NOTIFY_OK 0x00000001 #define NOTIFY_BAD (NOTIFY_OK | NOTIFY_STOP_MASK) #define NOTIFY_STOP_MASK 0x00008000 #define NOTIFY_STOP (NOTIFY_OK | NOTIFY_STOP_MASK)

NOTIFY_DONE表示“收到通知了,但我啥也不用做”;NOTIFY_OK表示“处理成功”;NOTIFY_BAD表示“处理失败,而且请停止进一步通知”。实现上它们都带有STOP语义,所以一旦某个回调返回NOTIFY_BAD或NOTIFY_STOP,链上排在后面的回调就不会再被调用了。

这是最容易踩坑的地方。我见过一个系统里多个模块共享同一条netdev链,某个模块在设备名不符合预期时直接返回NOTIFY_STOP,结果后面所有模块都用收不到任何网络事件了,系统表现就是莫名其妙丢了一堆状态变化。后来我们统一约定:非必要不用STOP,就算判断失败也返回NOTIFY_DONE,让事件继续往后传。如果你真的需要阻止后续通知,一定要想清楚对其他模块的影响。

4. 实战:手写一个网络设备事件监听模块

4.1 模块代码逐段讲解

光讲理论不过瘾,我直接带你写一个可以编译运行的内核模块,用来监听网络设备的注册、注销、up、down事件。这个模块本身就是我在实际工作中用来辅助调试的简化版,你把它编译加载后,用ip命令操作网卡,dmesg里就能看到事件记录。

先看代码,文件名叫my_netdev_notifier.c:

#include <linux/module.h> #include <linux/netdevice.h> #include <linux/notifier.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_REGISTER: pr_info("my_notifier: %s registered\n", dev->name); break; case NETDEV_UNREGISTER: pr_info("my_notifier: %s unregistered\n", dev->name); break; case NETDEV_UP: pr_info("my_notifier: %s is up\n", dev->name); break; case NETDEV_DOWN: pr_info("my_notifier: %s is down\n", dev->name); break; case NETDEV_CHANGE: pr_info("my_notifier: %s link changed\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_notifier: register_netdevice_notifier failed: %d\n", ret); return ret; } pr_info("my_notifier: registered successfully\n"); return 0; } static void __exit my_notifier_exit(void) { unregister_netdevice_notifier(&my_netdev_nb); pr_info("my_notifier: unregistered\n"); } module_init(my_notifier_init); module_exit(my_notifier_exit); MODULE_LICENSE("GPL");

这段代码里最核心的是回调函数中ptr参数的处理。我特别要提醒一句:netdev链回调的ptr参数到底是什么,取决于内核版本。在新版本内核中,ptr传入的不是struct net_device指针对应的简单指针,而是struct netdev_notifier_info结构体,你需要调用netdev_notifier_info_to_dev()函数拿到真正的net_device指针。在旧版本内核里,ptr就是直接强转的net_device *。如果你的代码在两个内核版本之间迁移,这地方特别容易出问题。

优先级我这里设为0,如果你想实验调用顺序,可以注册两个不同的notifier_block,分别设置priority为1和-1,然后在dmesg里观察调用顺序。注意:由于netdev链内部是blocking链,它会先执行priority大的那个回调。我们当时做一个联动模块时,就靠这个机制保证“先通知资源准备模块,再通知业务处理模块”。

4.2 Makefile与编译加载流程

内核模块的编译离不开Makefile,我一般写成这样:

obj-m += my_netdev_notifier.o KDIR ?= /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean

然后执行make,会生成my_netdev_notifier.ko。接着加载模块并查看日志:

sudo insmod my_netdev_notifier.ko dmesg | tail -5

再打开一个终端,执行:

sudo ip link set eth0 up sudo ip link set eth0 down

回到第一个终端,你会看到类似这样的输出:

my_notifier: eth0 is up my_notifier: eth0 is down

这就说明模块已经正常接收到网络设备事件了。用完之后记得卸载:

sudo rmmod my_netdev_notifier

整个编译加载流程我实测过了,前提是你的系统装了linux-headers包,并且内核开启了模块加载支持。如果你用的是嵌入式环境,交叉编译也没问题,把KDIR指向目标平台的内核源码目录就行。

4.3 做一个自己的自定义通知链

如果你不是只想“听”别人的事件,还想给自己的子系统做事件总线,方法也一样。假设你在写一个字符驱动,希望其他模块能在你的设备被打开或关闭时得到通知,可以这样写:

static BLOCKING_NOTIFIER_HEAD(my_driver_chain); int my_driver_register_notifier(struct notifier_block *nb) { return blocking_notifier_chain_register(&my_driver_chain, nb); } EXPORT_SYMBOL(my_driver_register_notifier); int my_driver_unregister_notifier(struct notifier_block *nb) { return blocking_notifier_chain_unregister(&my_driver_chain, nb); } EXPORT_SYMBOL(my_driver_unregister_notifier); void my_driver_notify(unsigned long event, void *data) { blocking_notifier_call_chain(&my_driver_chain, event, data); } EXPORT_SYMBOL(my_driver_notify);

然后在你的核心逻辑里,比如open函数中调用my_driver_notify(MY_DRIVER_OPEN, file),其他模块只要调用my_driver_register_notifier注册自己的回调,就能感知事件。

这里我特别说明一点:头文件里要声明好接口原型,如果你是编译为内核模块后提供给其他模块使用,一定要记得EXPORT_SYMBOL,并且头文件路径要让对方能找到。我自己犯过的错误是把EXPORT_SYMBOL放在了一个条件编译分支里,结果其他模块链接时总是报“undefined symbol”。排查了半天才发现是宏开关没打开,模块压根没导出现这个符号。

5. 避坑手册:这些问题我全踩过

5.1 回调函数里睡眠:最经典的翻车现场

对于atomic通知链,回调里不能睡眠这件事我再强调一万遍也不过分。问题是,什么操作算“可能睡眠”?很多人以为只有显式调用msleep、wait_event才算。实际上kmalloc(GFP_KERNEL)、mutex_lock、synchronize_rcu这类函数在特定路径下都会睡眠,你在atomic链回调里用它们,就会触发内核的“scheduling while atomic”警告,紧接着可能是死锁或者系统卡死。

解决办法有两个思路。第一,立即执行的路径上只用原子安全操作:用GFP_ATOMIC分配内存,用spinlock替代mutex。第二,也是我比较推荐的,回调里只做标记和简单记录,然后调用schedule_work或者queue_work把真正耗时的工作放到进程上下文中去执行。我写过的一个监听内存事件的小模块,就是在atomic回调里只置了一个标志位,然后唤醒一个等待队列,由内核线程去处理后续逻辑。

5.2 注册了不注销,卸载模块时系统直接崩溃

这个坑看起来很低级,但我在给同事review代码时遇到不止一次。你的notifier_block结构体是定义在模块里的,回调函数也是模块的代码段。如果模块被rmmod强制卸载,但notifier_block还链在内核的某个链上,一旦后续有事件触发,内核会跳到一个已经不存在的地址上去执行,结果是oops,而且这种oops非常难查,因为崩溃现场往往跟你的模块代码对不上。

正确做法是:在module_exit里先注销所有注册过的通知链,再清理其他资源。如果你在多个链上注册了block,需要逐个注销。另外要注意注销顺序:如果有两个模块互相依赖,比如A模块的回调里会调用B模块导出的函数,那么卸载时应该先把A从链上摘下来,再卸载B,否则A的回调一旦触发就会撞上已卸载的B。

我自己的习惯是在unregister之后再加一个延迟卸载的保护机制。比如用module_refcount检查模块是否还被引用,或者用try_module_get维护计数。不过对大多数简单模块来说,只要你规规矩矩注销,就不会有问题。

5.3 返回值滥用,拖垮整条链

前面说过NOTIFY_STOP可以中断整条链,但很多人不理解这个“中断”的影响范围。在内核代码里,有的调用方在notifier_call_chain返回后还会检查返回值。比如reboot链的处理逻辑中,如果某个回调返回NOTIFY_BAD,整个reboot流程可能会被判定为“有设备拒绝重启”,后续的关机步骤直接取消。你的一个简单返回值,可能就会让整个系统关不了机。

所以我的建议是:如果你是普通的事件订阅者,一律返回NOTIFY_DONE或者NOTIFY_OK,不要轻易用STOP。只有在你非常确定自己就是这个事件链条上的决策点时,才去使用STOP相关返回值。而且即使要用,最好在注释里写明影响范围,方便后面接手的人理解。

5.4 理解data参数:不同事件,data的含义完全不同

这条经验是我最想分享的。netdev链的data参数还算友好,新内核可以用netdev_notifier_info_to_dev转成net_device *。但有些链的data参数在不同事件下含义天差地别。举个例子,内存事件链里,OOM事件的data指向的是struct oom_control结构,而内存热插拔事件的data指向的又是另一个结构体。

所以你在写回调函数时,不要上来就强转,一定要先根据event类型判断data到底是什么。我在早期犯过一个错,就是因为想当然地把data强转为某个结构体,结果在某个不常触发的事件路径上访问了错误的内存地址,模块就oops了。后来我养成了习惯:每个case分支里先打印data的类型或关键字段,确认无误后再做正式逻辑处理。

5.5 调试技巧:ftrace和kprobe定位问题

当你遇到“事件没收到”或者“收到顺序不对”这类问题,不要摸着黑瞎猜,我建议直接用内核提供的动态追踪工具。

用ftrace跟踪notifier_call_chain的所有调用:

cd /sys/kernel/debug/tracing echo notifier_call_chain > set_ftrace_filter echo function > current_tracer echo 1 > tracing_on cat trace

这样能看到触发链的完整调用栈,有助于确认事件到底是在哪个阶段被断掉的。

如果你想看自己的回调函数到底有没有被调用,可以用kprobe动态挂载:

cd /sys/kernel/debug/tracing echo 'p:my_probe my_netdev_event' > kprobe_events echo 1 > events/kprobes/my_probe/enable

触发一次网络事件后查看trace输出,就能确认函数入口参数。这个方法我在排查一个“模块加载成功但收不到事件”的问题时立了大功,最终定位到是我在注册层次上搞错了,注册到了别的链上,回调函数自然永远不被触发。

6. 一点个人体会

做内核开发这几年,我越来越觉得通知链像是内核子系统之间的“对话频道”。它简单到只有几个结构体和接口函数,却承担着整个内核里大量事件解耦的职责。真正用得好的开发者,不会只满足于会调register和unregister,而是会把四种链的区别、返回值的语义、锁的上下文要求都吃透。

我自己每次在新内核版本上适配模块时,都会重新翻阅一下notifier.h和相关子系统的调用点,因为内核社区偶尔会调整链的类型定义,或者更新data参数的含义。版本之间行为有差异,这是内核开发的常态,别嫌麻烦。写模块前先花十分钟确认目标环境的链类型和事件语义,能帮你省下后面一整天的调试时间。

最后分享一个小习惯:我在自己的内核模块项目里,会给每个通知链回调函数一个统一的调试打印前缀,比如my_notifier:,这样dmesg里过滤起来特别方便。模块多了之后,你就知道这个小小前缀有多重要了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询