前阵子调试一块网卡驱动的热插拔适配,我发现系统里网络设备一多,靠轮询去盯着设备状态完全不是办法。后来翻老同事留下的代码,看到 netdev_chain 这个符号,才真正开始理解 Linux 内核的通知链(notifier chain)。它不复杂,就是一个“订阅-广播”机制,但弄懂它之后,再看像设备热插拔、模块加载、系统重启这些内核事件的处理逻辑,心里会清楚很多。这篇准备按我自己的学习路径来讲:先说它解决什么问题,再讲注册、触发、注销三个核心 API,接着给几个真实可跑的场景,最后把我在回调里踩过的坑完整摊开。适合正在啃内核源码、写内核模块,或者面试前突击“内核同步机制”的朋友。
1. 通知链到底解决什么问题
1.1 一个“订阅-广播”机制为什么会出现在内核里
内核子系统之间经常需要知道彼此动态发生的事。比如网卡被插入、被拔出、IP 地址变更,这些事件会同时影响到路由模块、防火墙模块、虚拟化网络模块、网卡驱动本身,甚至用户态的网络管理服务。最笨的办法是每个关心的人自己轮询设备状态,但轮询有时间差、有额外开销,事件一多就白白浪费 CPU。另一种办法是被观察方直接调用关心者的函数,但那样网络核心层就得知道所有关注者的符号地址,代码之间硬耦合,还要用一堆 ifdef 编译选项去适配不同模块,维护成本极高。
通知链其实就是观察者模式在内核里的落地。事件源维护一张注册表,谁关心某个事件,就把自己的 notifier_block 挂到链表上;事件发生时,事件源遍历这个链表,挨个调用注册时留下的回调函数。整个过程很像餐厅叫号:客人先登记,后厨出餐后按登记表通知,后厨完全不用知道每个客人长什么样、坐在哪一桌。它解决的正是“事件生产者”和“事件消费者”之间的解耦问题,也顺带解决了“多个消费者关心同一事件”的广播问题。
这个机制在内核里用得极其普遍。网络设备事件(netdev_chain)、IP 地址事件(inetaddr_chain)、模块加载卸载(module_notify_list)、系统重启(reboot_notifier_list)、内核 panic(panic_notifier_list)都有通知链的身影。你随便翻一个子系统,大概率都能找到 notifier 相关函数。影响范围非常广,这也是它值得花时间彻底弄懂的原因。如果你在嵌入式开发或者做网络设备相关驱动,几乎不可能绕开它。
1.2 核心结构体:只看 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 是回调函数,next 是指向下一个节点的链表指针,priority 是优先级,数字越大越先被调用。notifier_call 的原型是一个接受三个参数的函数:
typedef int (*notifier_fn_t)(struct notifier_block *nb, unsigned long action, void *data);看着简单,但这三个字段背后有讲究。notifier_call 是在事件发生时被自动调用的,函数内部能拿到三样东西:自己所在这个节点的地址 nb、事件类型 action、事件附带的私有数据 data。后面我们在讲“自定义一条链”时,会利用 nb 指针通过 container_of 找回自己的外层结构体,那是内核里非常常用的扩展手法。
除了节点结构,还需要一个“链头”。不同类型通知链的链头结构不一样,有的带自旋锁,有的带读写信号量,有的根本没锁。这个差异直接决定了你在回调里能不能睡眠,所以必须搞清楚。
1.3 四种通知链怎么选
Linux 内核中常见的通知链有四种:atomic_notifier_chain、blocking_notifier_chain、raw_notifier_chain、srcu_notifier_chain。名字里的锁类型就是它们最大的区别。我通常用下面这张表来记忆:
| 通知链类型 | 内部同步机制 | 回调允许睡眠 | 典型场景/链 |
|---|---|---|---|
| atomic | 自旋锁 | 完全不允许 | panic_notifier_list、die_chain、inetaddr_chain |
| blocking | 读写信号量 | 允许,但建议短小 | 自定义事件总线、部分模块加载通知 |
| raw | 无内部锁,调用者自保证 | 看调用者上下文 | netdev_chain(通常持 RTNL 调用) |
| srcu | SRCU | 允许长时间睡眠 | 读多写少、需要长临界区的场合 |
atomic 链内部用自旋锁保护链表,回调会在持锁状态下执行,所以回调里绝对不允许睡眠,也不允许调用任何可能阻塞的函数。典型例子是 panic_notifier_list、die_chain,适合中断或紧急路径。你如果在写驱动时注册到这类链上,回调里基本只适合做置标志位、记录状态这类轻量操作。
blocking 链内部用读写信号量保护,回调可以睡眠,但你不能在中断上下文里触发它,因为你无法在中断里获取信号量,一拿就睡死。它有很好的易用性,适合进程上下文的事件分发。自己封装内核事件总线时,blocking 链是最常见的起点,本书后续自定义链的示例就用的是它。
raw 链内部不加任何锁,好处是快,坏处是调用者必须自己保证同步。比如 netdev_chain,触发路径上通常已经持有 RTNL 锁,回调里再尝试拿 RTNL 锁就会死锁。srcu 链基于 SRCU,适合读多写少、回调需要长时间睡眠的场景,但使用门槛高一些,普通驱动开发中用得少。
有一点必须强调:具体某个链属于哪种类型,不同内核版本可能有差别,写代码前先确认你手上的源码。我刚接触时看了一篇老文章,照搬注册到某个链后回调里用了一个可能睡眠的函数,结果模块一加载就崩,教训很深刻。
2. 注册、触发、注销:一抹就通的三个 API
2.1 注册和注销:往链表里加节点和摘节点
注册的通用逻辑藏在 notifier_chain_register 这个内部函数里,它做两件事:检查链表里有没有重复的 notifier_block,有就返回 -EBUSY;没有就按 priority 从大到小插入。所以如果你希望自己的回调比别人先执行,就把 priority 设大,负数也没问题。实际代码里我们调用的是封装后的接口,比如 atomic_notifier_chain_register、blocking_notifier_chain_register,还有 netdev 场景里的 register_netdevice_notifier。
注销逻辑完全对称,调用 unregister 系列接口。多数情况下这个函数只会在模块卸载时调用一次,但因为它返回 int,很多人习惯性忽略返回值。我要提醒的是:如果链表里根本没有这个节点,它会返回 -EINVAL 或 -ENOENT;如果模块卸载时注销失败,说明链表里还挂着一个指向已释放内存的节点,下一次事件触发就是内核直接崩溃。这是我在真实项目里见过最隐蔽的问题之一,后文会展开。
2.2 触发通知:遍历链表并处理返回值
事件发生时,事件源调用 notifier_call_chain 遍历链表,逐个执行回调。内部逻辑大概是:从头部开始,先调这个节点的 notifier_call,然后根据返回值决定继续还是停止。如果回调返回 NOTIFY_STOP,遍历立刻中断;如果返回 NOTIFY_BAD,同样中断,并且调用方可以感知到事件处理出错。
返回值宏主要有 NOTIFY_DONE、NOTIFY_OK、NOTIFY_BAD、NOTIFY_STOP。对大多数调用者来说,返回 NOTIFY_OK 表示“处理成功”,返回 NOTIFY_DONE 表示“这事跟我无关”,都可以正常继续。触发方是否理会返回值,由具体子系统的策略决定。例如某些 reboot 流程中,如果回调返回 NOTIFY_STOP,可能让重启流程被打断,所以别再乱用这些魔法值。
还有一点值得注意:不要在一个事件里乱用返回值。比如本来该返回 NOTIFY_OK,你返回了一个负数错误码,notifier_call_chain 有可能把它当成“终止”信号,导致后面的模块再也收不到事件。这类错误非常隐蔽,因为看起来你的模块工作正常,实际整个链路已经被你截断了。
2.3 完整走一遍:一个监听网卡事件的模块
直接来一个可以编译的模块。思路是注册到 netdev_chain,每当网络设备注册、注销、up、down 时打印一条日志,这是验证通知链机制最直观的起点。
#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_REGISTER: pr_info("notify: device %s registered\n", dev->name); break; case NETDEV_UNREGISTER: pr_info("notify: device %s unregistered\n", dev->name); break; case NETDEV_UP: pr_info("notify: device %s is up\n", dev->name); break; case NETDEV_DOWN: pr_info("notify: device %s is down\n", dev->name); break; } return NOTIFY_OK; } static struct notifier_block my_nb = { .notifier_call = my_netdev_event, .priority = 0, }; static int __init my_init(void) { int ret = register_netdevice_notifier(&my_nb); if (ret) pr_err("register netdevice notifier failed: %d\n", ret); return ret; } static void __exit my_exit(void) { unregister_netdevice_notifier(&my_nb); } module_init(my_init); module_exit(my_exit); MODULE_LICENSE("GPL");代码里的 register_netdevice_notifier 就是往 netdev_chain 挂节点。事件处理函数通过 netdev_notifier_info_to_dev 拿到 struct net_device,然后根据事件类型分别打印。这里有个版本细节想说一下:不同内核版本对事件回调的第三个参数封装不一样。老版本里 ptr 直接就是 struct net_device *,后来内核改成了 struct netdev_notifier_info *,所以用 helper 函数最稳妥,跨版本也不会翻车。
编译运行也简单:
make -C /lib/modules/$(uname -r)/build M=$PWD modules sudo insmod netnotify.ko ip link add veth0 type veth peer name veth1 ip link set veth0 up sudo rmmod netnotify执行 ip link add 后,dmesg 里能看到注册事件;ip link set up 后能看到 up 事件;rmmod 前也能看到卸载过程。这个例子虽然小,但能帮你把“注册、回调、注销”整条链路一次性打通。我建议新手第一步就做这个小实验,做完再去看复杂子系统,理解成本会低很多。
3. 实战场景:为什么说它是内核事件总线
3.1 网卡热插拔、容器 veth 创建与虚拟化网络
netdev_chain 是应用最广的链之一。物理网卡热插拔、docker 容器创建 veth 对、openvswitch 创建内部端口、虚拟化平台创建虚拟网卡,这些动作都会触发 NETDEV_REGISTER 或 NETDEV_UNREGISTER。如果你在做网卡 failover、网络策略同步、安全审计,这类事件几乎绕不开。
实操提醒:netdev_chain 是 raw 通知链,触发路径通常持 RTNL 锁。所以回调里严禁再去拿 RTNL 锁,也严禁长时间睡眠。你在回调里想做耗时操作,正确的姿势是先把 net_device 的名字和其他必要字段拷贝出来,然后丢给工作队列去处理,回调自己做到短平快。这个原则适用于绝大多数内核事件回调。
除了网卡设备本身,IP 地址变化走的是 inetaddr_chain。比如你要监听一个网卡是否被配了某个地址,就可以注册到 inetaddr_chain。这个链是 atomic 链,回调更不能睡眠。很多负载均衡和智能网卡的用户态方案,内核侧就是靠这些链感知地址变化,再通过其他通道上报给用户态的。你只要在宿主机的 dmesg 里开上调试,插拔一次虚拟网卡就能看到一长串事件,那是理解“内核事件风暴”的最好现场。
3.2 模块加载、系统重启与 panic 事件的监听
模块加载卸载这类事件,看起来简单,但实际可以玩出很多花样。比如你想在某个驱动被加载或卸载时自动同步配置,监听模块事件链就很方便。回调里会收到 struct module *,通过 module_name 就能拿到模块名。
static int my_module_event(struct notifier_block *nb, unsigned long action, void *data) { struct module *mod = data; switch (action) { case MODULE_STATE_COMING: pr_info("module %s is loading\n", module_name(mod)); break; case MODULE_STATE_LIVE: pr_info("module %s loaded\n", module_name(mod)); break; case MODULE_STATE_GOING: pr_info("module %s is going\n", module_name(mod)); break; } return NOTIFY_OK; }注册时要看内核导出的接口。有的版本直接 register_module_notifier,有的需要注册到 module_notify_list,建议写之前先在内核源码里搜一下这个符号,别盲目套老文章。系统重启和 panic 的回调则更加危急。panic 通知链的回调是在 panic 流程里执行的,任何一次多余的 printk 或者锁操作都可能把事情搞复杂。所以 panic 回调只适合做最紧急、最简短的动作,比如把关键寄存器数据写到某个固定内存区域,供下一轮启动后的分析使用。
3.3 自定义一条自己的链:把事件总线能力握在手里
学会使用现成链只是第一步。很多复杂驱动或内核服务,需要自己有“多订阅者”的事件分发能力。这种时候,自己封装一条通知链是最稳的做法,内核源码里到处都是这种模式,相当于你顺手实现了一条轻量级事件总线。
先定义链头和三个导出函数。以阻塞链为例,放到一个头文件里:
/* my_event.h */ #ifndef MY_EVENT_H #define MY_EVENT_H #include <linux/notifier.h> int my_event_register(struct notifier_block *nb); int my_event_unregister(struct notifier_block *nb); void my_event_raise(unsigned long event, void *data); #define MY_EVENT_LINK_UP 0x0001 #define MY_EVENT_LINK_DOWN 0x0002 #endif对应实现:
/* my_event.c */ #include <linux/module.h> #include <linux/notifier.h> #include "my_event.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_raise(unsigned long event, void *data) { blocking_notifier_call_chain(&my_event_chain, event, data); } EXPORT_SYMBOL(my_event_raise);消费方怎么用?最优雅的姿势是把 notifier_block 嵌进自己的结构体里,这样不仅可以保存私有数据,还能在回调里用 container_of 找回外层对象。
struct my_consumer { struct notifier_block nb; int id; }; static int my_consumer_cb(struct notifier_block *nb, unsigned long event, void *data) { struct my_consumer *mc = container_of(nb, struct my_consumer, nb); pr_info("consumer %d got event 0x%lx\n", mc->id, event); return NOTIFY_OK; } static struct my_consumer consumer = { .nb = { .notifier_call = my_consumer_cb, .priority = 0, }, .id = 1, };这个模式的核心价值在于:事件生产方完全不知道消费方是谁,消费方要关注什么、要怎么处理,全在自己注册的结构里解决。内核里大量子系统都是这么组织代码的,你一旦习惯,再看 qdisc、netfilter、cpufreq 这些模块的通知逻辑,会轻松很多。
4. 避坑实录:回调里每一条都是经验换来的
4.1 回调里的“不能做清单”
我最早踩的坑,是在一个 atomic 通知链的回调里直接调用了 msleep。结果模块一加载,测试事件一触发,系统直接报 scheduling while atomic,然后整机就卡死了。原因很简单:atomic 链遍历时持有自旋锁,此时你是活在一个不允许睡眠的上下文里,任何一次睡眠都是对内核调度器的严重冒犯。
所以写回调前,第一件事是搞清楚当前回调属于哪种链。如果拿不准,就默认不能睡眠。不要在回调里调 kmalloc(GFP_KERNEL)、mutex_lock、msleep、wait_event 这类函数。就算你在阻塞链里可以睡眠,也要克制,回调应该是“通知”而不是“干活”,真正的干活交给 workqueue。
调试时可以用 might_sleep() 在你怀疑的代码路径上炸出上下文。也可以在回调里临时加打印调用栈的先头部队:
WARN_ON_ONCE(1); dump_stack();这样能直观看到回调是在什么路径上被调用的,是软中断、进程上下文还是持锁状态。反正比盯着 panic 日志猜要快,这是我最常用的定位手段。
4.2 死锁与递归:别让通知链变成连环电话
第二种常见问题是递归和死锁。假设你在 netdev 事件的回调里,为了完成配置又去创建了一个虚拟网卡,创建虚拟网卡又触发一次 netdev 事件,事件又走到你的回调……轻则递归调用爆栈,重则尝试获取被占用的 RTNL 锁,系统直接卡死。这就是通知链最常见的“连环电话”问题。
我曾经在回调里尝试通过 rtnl_link_ops 创建 veth,结果就是死锁。netdev_chain 触发时 RTNL 锁已经握在调用者手里,你再进 netlink 路径去拿同一把锁,等于自己锁自己。这种问题不会百分百复现,通常只在特定事件组合下爆,排查起来特别费劲,因为现场往往只是一句“网络卡住了”。
成熟的解法是延迟处理。回调只做两件事:标记状态和把必要数据塞进自己的队列,然后 schedule_work 或者 queue_work 交给内核工作队列去处理真正耗时的逻辑。工作队列在进程上下文运行,锁、睡眠、创建设备都安全。这个套路在内核代码里非常多,几乎可以说是通知链回调和业务逻辑之间的标准“缓冲区”。
4.3 注册注销里容易忽略的细节
重复注册是一个容易踩的坑。同一个 notifier_block 结构体不能在同一链上注册两次。第一次注册成功,第二次会返回 -EBUSY。别看这个简单,实际操作中容易因为模块重载逻辑没写对,导致 init 时重复注册,然后把链表结构搞乱,触发时甚至可能出现内核 panic。
注销失败的问题更需要重视。注销接口返回错误码时一定不要忽略。我在一个驱动里就遇到过:模块 exit 时注销失败,但代码没看返回值,后面 rmmod 后网卡状态一变化,内核立刻访问已释放的内存,整个系统直接挂掉。内存错误最难排查,这类预防的成本很低,看一眼返回值的事。
还有 priority 的语义,数字越大越先调用,这点和很多人的直觉相反。当初我以为优先级数字越大越靠后,把自己的回调设成了负数,结果在批量处理事件时硬生生慢了半拍。设置 priority 之前,先确认你希望回调在所有消费者里排第几,再去看同一个链上已有的其他注册者通常用什么值,保持团队内的一致性。
4.4 一个真实 Bug 的排查过程
去年帮朋友排查过一个问题,现象是:嵌入式设备上,USB 网卡频繁拔插时,系统偶尔会死机,而且不是马上死,过一两分钟后死。dmesg 里没有任何报错,只有日志在设备拔插后戛然而止。这种问题在嵌入式环境里很典型,内核源码裁剪得厉害,很多调试选项都是关的,查起来尤其费劲。
一开始怀疑内存越界,后来加了 ftrace 看调用链,发现死机前最后一段调用栈在网络设备注册路径上,且有 notifier_call_chain。继续往里查,发现是某厂商驱动在 NETDEV_REGISTER 回调里开了一个内核线程,去等待一个 firmware 下载结果,等待时是睡眠的。问题是触发这个回调的路径并不总在进程上下文,某些热插拔路径里带着 spinlock,睡眠就把锁睡死了。
修复方案其实很简单:回调里不做等待,只把设备名放到队列,然后 schedule_work,交给工作队列去处理 firmware。整个问题从“改驱动”变成“改回调逻辑”,一点都不复杂,但定位过程花了两天。事后我们把所有回调都过了一遍,凡是碰到可能睡眠的函数,全部改成异步。这个教训就是:通知链回调代码的第一原则是“快进快出”,任何可能阻塞的逻辑都别放进去。
5. 从通知链看到的同步设计,以及面试怎么答
5.1 一次看懂内核同步在这里的落地
通知链其实是一扇观察 Linux 内核同步机制的窗口。atomic 链用自旋锁,blocking 链用读写信号量,raw 链干脆不加锁由调用者保证,srcu 链用 SRCU。你经常听到的“Linux内核同步的方法”,在这一个机制里就能看到好几种典型实现。
为什么会这样?因为不同事件被触发的上下文差别太大。有的在中断里,有的在持锁路径上,有的在进程上下文。同一个同步方法不可能同时适合所有场景。自旋锁适合短临界区,但没法睡眠;信号量适合进程上下文,但不能在中断里获取;RCU 适合读多写少,但回调繁忙时会累积写侧延迟。通知链通过选择不同的锁实现,让每种上下文都能找到自己的安全区。
这个思路对你写自己的内核模块也是一个暗示:不要一开始就照抄一个锁实现,先想清楚你的回调会在什么上下文执行,再决定保护方式。我见过很多人写内核模块,不管三七二十一先上一把 mutex,结果在中断路径里直接炸;也见过有人明明只是进程上下文,却用 spin_lock 保护几百行的临界区,性能差到离谱。模块设计的文档里把“回调上下文”这一条写清楚,能省掉后面大量的排查时间。
5.2 从内核事件到用户态:完整的技术链路
通知链解决的是内核内部的事件分发,但很多业务最终要落到用户态。比如 udev 是怎么知道新设备插上的?内核里有 kobject_uevent_env,把事件通过 netlink socket 发出去,用户态的 udev 收到后再做节点创建。网络地址变更通知用户态,走的则是 rtnetlink 的 RTM_NEWADDR/RTM_DELADDR。通知链在这里并不是终点,而是“内部分发中枢”。
如果你自己在写一个内核模块,又想让用户态知道某个事件发生,思路也是一样:内核侧先用通知链或直接调用把事件收集好,再通过 netlink、misc device 或 procfs 暴露出去。通知链负责多模块之间的解耦,用户态通道负责对外出口。两者配合,才是一条完整的事件通路。
我做透明加密、文件监控这类业务时,经常要把文件系统事件和用户态策略联动,内核侧先用 inode 上的钩子做事件标记,再通过消息机制批量上报。通知链在其中负责多模块共享事件,避免每个模块各自写一套轮询逻辑。代码量省下来的同时,事件时序也统一了,后续维护起来舒服得多。
5.3 面试题视野:常见问题怎么答不翻车
如果你在准备 Linux 内核相关岗位面试,通知链几乎是必问。这里整理几个高频问题,我按自己的理解给参考思路。
Q1:通知链是什么,解决什么问题?答题思路:它是一种“一推多播”的订阅机制,事件源不直接回调消费者,而是维护链表,消费者注册后由事件源遍历链表统一通知。核心价值是解耦合和动态扩展。最好再补一句:观察者模式在内核里的落地,很多子系统的事件分发都用它。
Q2:通知链有哪几种,区别是什么?答题思路:atomic、blocking、raw、srcu 四种。主要区别是链表保护方式不同,决定了回调能否睡眠、能否在中断上下文触发。只答“四种”而说不出区别,那是不合格的,最好再举一个典型链。
Q3:回调里能不能睡眠?为什么?答题思路:atomic 链绝对不能,raw 链接调用者上下文,blocking 链可以但也要谨慎。原因是链表遍历时持有锁,在锁保护的临界区里睡眠会导致原子上下文调度、死锁等问题。如果你还能补一句“netdev_chain 是 raw 链,触发时通常拿 RTNL 锁,所以回调里也不要再去拿 RTNL 锁”,面试官会觉得你真的看过源码。
Q4:如果一个回调里需要做耗时操作怎么办?答题思路:不要在回调里做,拷贝必要数据后,交给工作队列、tasklet 或单独的内核线程处理。回调做到快进快出。这个思路在面试里非常加分,因为大多数人只背结论,实际项目经验才是真正的分水岭。
Q5:为什么很多驱动喜欢自封装通知链?答题思路:自封装可以把私有数据、上下文和回调解耦,使用 container_of 找回对应数据结构,同时把事件总线的能力复用到多个消费者,结构清晰,符合内核一贯设计风格。这里面最关键的是要让面试官看到你对 container_of 这个手法的熟练度。
最后补充一点个人习惯:我每次要往一个链上挂回调时,会先去源码里搜这个链是在什么上下文下 call 的,再决定回调里敢不敢碰锁和睡眠。搜不到就自己加动态调试,或者用 WARN_ON_ONCE 打印栈。这样看起来多花十分钟,但比掉进 panic 里花两天排查舒服多了。如果你也想验证通知链,建议照前面 2.3 节的最小模块跑一遍,然后把 priority、返回值、container_of 三种玩法各试一次,整个机制就基本焊死在记忆里了。