ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Linux内核通知链(notifier chain)机制详解:从原理到实战

Linux内核通知链(notifier chain)机制详解:从原理到实战 写内核代码之前我一直觉得通知链notifier chain属于那种“名字听过、原理大概知道、真到自己写的时候又不知道从哪下手”的机制。第一次去翻 include/linux/notifier.h看到一排结构体和那么多 NOTIFY_XXX 宏说实话我是懵的。后来在几个驱动项目里反复用、反复踩坑才慢慢把这条链子的脾气摸清楚。这篇文章就拿我自己的学习路径来写从“它到底解决什么问题”开始到四种通知链怎么选再到动手写一个能跑通的完整模块最后把我在实际项目中撞过的坑、帮别人排过的雷一条条摊开说。无论你是做内核驱动、搞外设热插拔还是只是想看懂网络子系统和内存热插拔那几段经典源码这篇文章都能给你一条比较顺的路。看完之后你不仅能读懂 netdev_chain 和 reboot_notifier_list 这类真实存在的链条也能在自己的模块里像模像样地拉起一条通知链。1. 通知链到底解决什么问题1.1 一个“群发消息”的内核版机制先说个生活化的例子。小区物业要通知住户明天停水最简单的办法是挨家挨户敲门。住户少没问题但小区大了、住户多了物业不可能认识所有人住户也可能中途搬走。于是物业让每个住户主动登记一个手机号停水的时候群发短信谁也不用等着。Linux 内核里的通知链就是这套逻辑。某个子系统比如网卡驱动在自身状态发生变化时不想自己去找所有感兴趣的模块挨个调用而是维护一张登记表——“谁关心我的事件谁就先把回调函数挂上来”。事件发生时挂在上面的回调函数会被依次调用。这就是“事件源 订阅者”的模型在内核里这套模型落地成 notifier chain。内核里这种场景比你想的要多得多。网卡插拔、网卡状态 down/up协议栈要第一时间知道靠的是 netdev_chain系统要 reboot 或 panic各模块要抢在最后关头保存数据靠的是 reboot_notifier_list还有内存热插拔、密钥环事件、文件系统事件…… 凡是你看到“哪个驱动只要注册一下就能收到核心模块通知”的地方背后基本都是通知链。1.2 为什么不能直接互相调用函数有朋友会问我直接把函数指针导出或者把函数写进一个全局数组事件发生时遍历调用行不行技术上当然能行但放在内核这种动辄几百个模块协作的环境里会出现几个很现实的问题。第一是依赖方向。如果协议栈直接持有网卡驱动的函数指针那么协议栈编译的时候就依赖驱动驱动没加载系统就没法工作驱动反过来可能又要调用协议栈接口最后变成一团乱麻。通知链把依赖方向扭转了核心事件源只暴露“注册接口”不关心具体哪个模块来响应谁要关心事件谁自己挂进来核心模块不需要认识任何下游模块。第二是扩展性。使用全局函数数组每加一个响应者就要改数组还得改调用逻辑使用通知链新增一个订阅者只需要写一个模块把自己注册进去事件源的代码一行都不用动。这在内核这种以模块化为基本组织方式的系统里价值非常明显。第三是生命周期。内核模块是可以动态 insmod/rmmod 的。如果事件源持有另一个模块的函数指针那个模块被卸载了指针就变成悬空指针一调用就崩溃。通知链要求订阅者在退出时从链上把自己摘掉配合引用计数能把这个风险压到最低。所以通知链本质上是内核里“解耦 动态扩展”的标准答案。它不解决性能极致优化的问题解决的是模块协作的秩序问题。这个定位理解清楚后面看代码心里就有底了。2. 四种通知链先看懂再选型2.1 四兄弟的区别打开内核源码目录 kernel/notifier.c你会看到 linux 一共实现了四种通知链atomic、raw、blocking、srcu。它们的核心逻辑是一样的都是“注册一个 notifier_block 到链头事件来了遍历执行回调”区别在并发保护和锁粒度。我把它们的特征整理成一张表方便对比类型同步/保护机制回调能否睡眠典型使用场景atomic_notifier_chain自旋锁 RCU 读锁不能中断上下文、软中断、不可阻塞路径raw_notifier_chain不加锁纯链表操作不能调用方自己保证并发安全blocking_notifier_chain信号量rwsem可以进程上下文、允许阻塞的场景srcu_notifier_chainSRCUSleepable RCU可以读多写少、回调较重、需要并发读性能atomic 链用的是自旋锁保护链表的修改通知时会进入 RCU 读临界区。RCU 读临界区不允许睡眠所以注册在 atomic 链上的回调函数里不能调用任何可能睡眠的函数比如 kmalloc(GFP_KERNEL)、mutex_lock、msleep 这些都不能碰。常见用法是中断处理、底半部处理以及各种不能阻塞的内核路径。blocking 链则是用读写信号量保护遍历链表时持有读锁回调函数可以放心睡眠甚至可以调那些很慢的函数。代价是每次事件通知的开销更大而且同一时刻可以有一个 CPU 在读临界区里慢慢执行别的 CPU 上的通知请求会排队。听起来不够“并发”但大多数进程上下文的场景根本不在乎这点延迟。srcu 链是 blocking 链的升级版。它使用 SRCU在“事件通知频繁、注册/注销极少”的场景下并发读性能更好。如果你写的回调逻辑比较重又希望多个 CPU 能同时进回调srcu_notifier_chain 是更合理的选择。raw 链比较特殊它什么锁都不加纯粹是一个无锁链表。听起来很危险但它的定位是“调用方比通知链更懂自己的同步需求”。比如 netdev_chain 底层就用的是 raw因为协议栈层面有自己的锁机制double locking 没必要。新手不建议直接从 raw 上手。2.2 选型建议我在实际项目中总结过一个比较省心的经验只要你确定回调只会在进程上下文执行优先用 blocking 或 srcu。只要你的回调可能跑在中断、软中断、原子上下文里用 atomic。如果你在写一个全新的“事件总线”没有特殊理由不要碰 raw。别一开始就纠结性能。先用 blocking 把功能跑通再根据压测结果换 srcu比一上来就锁不定、优化不定的方案靠谱得多。顺便说一句很多人会把通知链和等待队列搞混。等待队列解决的是“线程等你变成某个状态你变了唤醒我”它本质是阻塞与唤醒通知链解决的是“你变了主动告诉我我的回调跑一下”它本质是事件回调。两个东西经常一起出现但是完全不同的维度别混着用。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);几个参数的含义分别是nb 指向当前这个 notifier_block 自身action 是事件类型/状态值data 是事件相关的数据指针。回调返回一个整数告诉调用链“我处理得怎么样”这个在第 3.3 节细说。next 是链表指针标记为 __rcu意味着链表在通知者一侧是通过 RCU 访问的。priority 是优先级决定回调在链表中的位置这个字段经常会被人忽略后面避坑部分我会专门展开。你完全可以基于 notifier_block 做一个“扩展版本”——把它作为自己结构体的第一个字段然后通过 container_of 找回你自己的数据结构。内核里很多驱动就这么干相当于给回调函数附带了一个“上下文指针”。四种通知链各自维护自己的链头struct atomic_notifier_head { spinlock_t lock; struct notifier_block __rcu *head; }; struct blocking_notifier_head { struct rw_semaphore rwsem; struct notifier_block __rcu *head; };看到没有差别就在链头的保护锁上。atomic 链用自旋锁blocking 链用读写信号量srcu 链用 struct srcu_struct。数据结构的差异直接决定了它们适用场景的差异。3.2 注册和通知的内部逻辑我们以 atomic 链为样板把注册和通知的流程过一遍。注册函数是 atomic_notifier_chain_register核心逻辑大致如下获取链锁自旋锁。从链头开始遍历链表。找到第一个 priority 小于等于新节点的位置把新节点插进去。释放锁。优先级大的排前面如果优先级相同后注册的排在后面。这个排序规则决定了回调的执行顺序。实际内核里注册函数的实现也确实是这么写的while (*nl ! NULL) { if (n-priority (*nl)-priority) break; nl ((*nl)-next); } n-next *nl; rcu_assign_pointer(*nl, n);事件通知的过程也很有代表性。以 atomic_notifier_call_chain 为例进入 RCU 读临界区rcu_read_lock。用 rcu_dereference 拿到链表头。do-while 遍历链表逐个执行 notifier_call。每次调用后检查返回值如果返回值带了 NOTIFY_STOP_MASK 标记就停止继续向后调用。退出 RCU 读临界区。这就是为什么 atomic 链的回调里不能睡眠的根本原因——你执行回调的时候整个代码路径正处在一个 RCU 读临界区里而 RCU 读临界区之间禁止睡眠否则会触发内核的“sleeping function called from invalid context”检查。3.3 回调函数的返回值到底怎么用notifier 相关的返回宏定义在 include/linux/notifier.h 里最常用的几个返回值宏数值含义NOTIFY_DONE0x0000处理完但不关心结果继续NOTIFY_OK0x0001处理成功继续NOTIFY_BAD0x0020处理出错调用链继续但返回值携带错误NOTIFY_STOP0x8000停止调用链后续回调不再执行NOTIFY_STOP_MASK0x8000判断标记用很多初学者会把 NOTIFY_OK 和 NOTIFY_DONE 搞混其实影响不大两者都是“继续走”的意思唯一的区别是语义上 OK 表示“我成功消化了这个事件”DONE 表示“这事我看到了但不在乎”。真正要小心的是 NOTIFY_BAD 和 NOTIFY_STOP。NOTIFY_BAD 不会中断调用链它只是把负值通过 notifier_to_errno() 转换成错误码让调用者知道“有处理器失败了”。内核里典型的处理是static inline int notifier_to_errno(int ret) { return ret 0 ? -ret : ret; }如果把 NOTIFY_BAD0x20传给这个函数得到的就是 -32。所以如果某段代码调用 notify_chain 之后检查到负值说明链上某个回调返回了 NOTIFY_BAD但并不代表整个链失败了。NOTIFY_STOP 才是真正砍断链子的那个值。某些事件链很怕“后一个回调覆盖前一个的处理结果”所以允许回调用 NOTIFY_STOP 表达“后面的不用看了我已经处理完了”。这种场景在 reboot 事件里很典型。但如果你在一个普通事件里误返回了 NOTIFY_STOP后面的订阅者全都收不到通知排查起来相当隐蔽。4. 从零动手写一个通知链模块4.1 环境准备开始写代码之前你机器上需要装好内核头文件。以 Ubuntu/Debian 为例sudo apt install linux-headers-$(uname -r) build-essential如果你的内核是自己编译的那 headers 目录自然就在 linux source tree 下Makefile 里 KDIR 指向源码树即可。我下面的示例直接用发行版内核头文件方便复现。通知链涉及三个内核 APIint atomic_notifier_chain_register(struct atomic_notifier_head *nh, struct notifier_block *nb); int atomic_notifier_chain_unregister(struct atomic_notifier_head *nh, struct notifier_block *nb); int atomic_notifier_call_chain(struct atomic_notifier_head *nh, unsigned long val, void *v);这三个函数在内核里都有导出符号模块可以直接调用。4.2 事件源模块注册链、发通知我先写一个事件源模块它做两件事一是导出一个“注册/注销接口”让其他模块能把回调挂上来二是提供一个 /proc/trigger 文件往里面写数字就能触发一次事件通知。文件 event_source.c#include linux/module.h #include linux/notifier.h #include linux/proc_fs.h #include linux/uaccess.h #include linux/kernel.h static ATOMIC_NOTIFIER_HEAD(example_chain_head); int example_notifier_register(struct notifier_block *nb) { return atomic_notifier_chain_register(example_chain_head, nb); } EXPORT_SYMBOL(example_notifier_register); int example_notifier_unregister(struct notifier_block *nb) { return atomic_notifier_chain_unregister(example_chain_head, nb); } EXPORT_SYMBOL(example_notifier_unregister); void example_notifier_call(unsigned long val, void *v) { atomic_notifier_call_chain(example_chain_head, val, v); } EXPORT_SYMBOL(example_notifier_call); static ssize_t trigger_write(struct file *file, const char __user *buf, size_t count, loff_t *ppos) { char kbuf[16]; unsigned long event; int ret; if (count sizeof(kbuf)) count sizeof(kbuf) - 1; if (copy_from_user(kbuf, buf, count)) return -EFAULT; kbuf[count] \0; ret kstrtoul(kbuf, 10, event); if (ret) return ret; example_notifier_call(event, NULL); return count; } static const struct proc_ops trigger_proc_ops { .proc_write trigger_write, }; static int __init example_source_init(void) { if (!proc_create(trigger, 0666, NULL, trigger_proc_ops)) return -ENOMEM; pr_info(example notifier source loaded\n); return 0; } static void __exit example_source_exit(void) { remove_proc_entry(trigger, NULL); pr_info(example notifier source unloaded\n); } module_init(example_source_init); module_exit(example_source_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(Example notifier chain source);把事件源拆成独立模块是为了模拟真实内核子系统的样子事件源和订阅者可以是两个完全独立加载的模块事件源只见过 notifier_block从来不需要知道订阅者是谁。这和你去读 netdev_chain 的实现时看到的结构是一样的——dev.c 导出 register_netdevice_notifier驱动注册自己双方互不认识。4.3 接收者模块注册回调、响应事件接收者模块就简单多了。定义自己的 notifier_block实现回调函数在 init 里注册到事件源的链上在 exit 里注销。文件 notifier_receiver.c#include linux/module.h #include linux/notifier.h extern int example_notifier_register(struct notifier_block *nb); extern int example_notifier_unregister(struct notifier_block *nb); static int receiver_callback(struct notifier_block *nb, unsigned long event, void *data) { pr_info(receiver callback: event%lu data%px\n, event, data); return NOTIFY_OK; } static struct notifier_block receiver_nb { .notifier_call receiver_callback, .priority 0, }; static int __init receiver_init(void) { int ret; ret example_notifier_register(receiver_nb); if (ret) pr_err(failed to register receiver: %d\n, ret); else pr_info(receiver registered to example chain\n); return ret; } static void __exit receiver_exit(void) { example_notifier_unregister(receiver_nb); pr_info(receiver unregistered from example chain\n); } module_init(receiver_init); module_exit(receiver_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(Example notifier chain receiver);这里有一个细节值得注意receiver_init 返回的是注册函数的返回值。如果注册失败加载模块时 insmod 会直接报错而不是“加载了但实际没挂上”。这种错误处理习惯最好从最开始就养成很多内核 bug 都是因为模块加载成功后才发现自己没注册成功。4.4 加载验证写一个简单的 Makefileobj-m event_source.o obj-m notifier_receiver.o KDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean然后编译make编译通过后依次加载模块。注意顺序先加载事件源再加载接收者。sudo insmod event_source.ko sudo insmod notifier_receiver.ko用 dmesg 确认注册成功sudo dmesg | tail应该能看到 receiver registered to example chain 之类的输出。接下来触发事件echo 100 | sudo tee /proc/trigger再看 dmesgsudo dmesg | tail如果一切正常你会看到receiver callback: event100 data0000000000000000说明事件确实从事件源一路分发到了接收者回调里。整个数据通路你都可以用 ftrace 或 kprobe 去验证但对我们这次的目标来说看到回调打印已经说明通知链的注册、遍历、注销全流程都通了。这个实验虽然简单但它把通知链的“注册→等待→通知→回调→注销”五个阶段完整跑了一遍。你以后把 example_chain_head 换成内核里任何一个真实的链把 example_notifier_register 换成 register_netdevice_notifier逻辑一模一样。5. 实战避坑我踩过的和帮别人排查过的5.1 在回调里睡眠直接触发内核 BUG这是大家第一次写通知链时最容易踩的坑也是最“感人”的。有人在 atomic 通知链的回调里调用 kmalloc(GFP_KERNEL)或者顺手加了个 mutex_lock结果一触发事件dmesg 里立刻刷出一段BUG: sleeping function called from invalid context at kernel/locking/rwsem.c in_atomic(): 1, irqs_disabled(): 0这里 in_atomic(): 1 表示当前处于原子上下文不能睡眠。因为 atomic 通知链在遍历回调时正持有 RCU 读锁你在这条路径上走到任何可能睡眠的函数都会触发这个检查。解决办法有两个方向。如果回调不复杂改用不睡眠的版本比如 kmalloc(GFP_ATOMIC)用 spinlock 替代 mutex如果回调确实需要完成有阻塞的操作比如等待某个硬件完成初始化那就别用 atomic 链改用 blocking_notifier_chain 或 srcu_notifier_chain。我个人的经验是写新模块时先想清楚“我这个回调到底会不会调度”。连自己都不确定就先从 blocking 链起步它允许睡眠踩坑概率小很多。5.2 priority 设得太高打乱执行顺序priority 字段在大多数场景下保持 0 就够了但有些驱动会为了“我要第一个得到通知”把它设成很大的值。这本身没问题问题在于你很可能不知道链上还有谁在那个优先级上。比如我之前排查过一个网卡绑定问题某个驱动把 priority 设成了 INT_MAX结果它总是排在 netdev_chain 最前面把另一个关键模块的初始化逻辑挤到了后面导致链路 up 时先做的准备工作反过来覆盖了后做的配置。这种问题非常难定位因为它没有崩溃、没有报错只是行为顺序不对。我的建议是除非你明确知道这个事件链的先后顺序约定否则 priority 放 0。内核里有些特殊链对顺序有强依赖比如 reboot 通知链这类链通常会有注释说明“注册顺序即执行顺序”你自己新引入的模块别去跟它抢优先级。5.3 注销竞态模块都 unload 了回调还在跑notifier_chain_unregister 会保证在返回时所有 CPU 上正在执行的回调都已经结束了。这句话听起来很安全但它只保证“回调函数本身没有被执行”不保证“回调里用到的数据是安全的”。举个真实场景。某个模块在回调里读取了一个全局指针而该指针指向的数据结构在模块退出时被 kfree 释放了。虽然 unregister 等回调执行完毕但如果另一个模块还持有这个模块的引用或者你的回调被转手放进了 workqueue 延迟执行那这个释放就可能发生在回调使用数据的过程中直接 UAF。更稳妥的做法是在模块 exit 函数里第一件事就注销通知链然后等一个 grace period比如调用 synchronize_rcu 或在 blocking 链的写锁临界区里最后再释放回调依赖的数据。顺序搞反了隐患就埋下了。5.4 返回值错误导致后续订阅者“集体失聪”这个坑特别隐蔽。某个回调在某些分支返回了 NOTIFY_STOP你以为只是“我不继续了”实际上整个通知链在当前位置就截断了排在后面的所有订阅者都收不到这个事件。内核里有些事件链是允许、甚至鼓励用 NOTIFY_STOP 做“短路”的比如某些需要互斥处理的事件。但大多数普通事件链并不希望被短路。如果你在维护一个通用通知链一定要在文档里明确约定除非你是这条链的设计者否则回调统一返回 NOTIFY_OK。我自己排查过一个非常诡异的问题文件系统事件在某些机器上总是少收到几个通知最后用 ftrace 一查发现某个第三方模块在回调里对某种特定情况返回了 NOTIFY_STOP直接把后面所有订阅者全部屏蔽了。这种问题靠肉眼看代码几乎不可能定位只能靠工具。5.5 调试通知链的几个实用技巧通知链的调试难点在于它不是显式的函数调用而是多个模块间“隔空”协作。我常用的手段是这么几类一是 ftrace。跟踪 notifier_call_chain 和具体的 notifier_call 函数可以清楚看到一条事件从触发到分发的完整路径。对于自定义的链直接在回调里加 trace_printk 也很方便它在调试时开销比 printk 小很多不会因为打印延迟掩盖时序问题。二是检查注册结果。很多驱动拿到注册函数的返回值后只看是否小于 0忽略了“负数 errno”也有不同含义。建议在调试阶段把注册失败的具体错误码打出来再结合内核日志判断是不是链本身还没初始化。三是复现条件。通知链的事件触发往往依赖特定硬件状态或特定时序不好复现。我的习惯是在事件源模块里加一个 debugfs 或 proc 接口手动注入事件。就像上面示例里的 /proc/trigger 一样它让验证和调试变得极其可控也方便自动化测试脚本去跑回归。四是看回调函数本身。如果怀疑某个回调节点出了问题可以用 crash 工具在事件触发时用 kgdb/crash dump 查看链表内容确认每个节点的 notifier_block 地址和 priority 是否符合预期。链表被写坏的情况光靠打印日志很难看出来。这些技巧组合起来绝大多数通知链相关的问题都能在一个可接受的时间范围内定位到根因。调试这类“软协作”机制最重要的其实不是技巧本身而是先建立对链表遍历顺序、锁区间、睡眠约束这些基础模型的清晰认知——认知对了工具只是辅助认知偏了工具越多反而越乱。最后分享一个我个人的习惯在新内核里第一次用通知链我会先打开 kernel/notifier.c 把四个链头的初始化宏和注册函数各读一遍花不了二十分钟但之后写代码时心里会非常踏实。通知链这个机制的内核版本兼容性其实相当好核心 API 十几年没有大的变动你在一台老内核上写的模块拿到新内核上稍微修一下头文件基本就能用。把基础模型吃透收益是长期的。
返回列表