拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Linux内核通知链:从订阅广播机制到驱动事件回调的实战指南

前阵子调试一块网卡驱动的热插拔适配我发现系统里网络设备一多靠轮询去盯着设备状态完全不是办法。后来翻老同事留下的代码看到 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、内核 panicpanic_notifier_list都有通知链的身影。你随便翻一个子系统大概率都能找到 notifier 相关函数。影响范围非常广这也是它值得花时间彻底弄懂的原因。如果你在嵌入式开发或者做网络设备相关驱动几乎不可能绕开它。1.2 核心结构体只看 notifier_block 就够了先记住最核心的 struct notifier_block定义在 include/linux/notifier.hstruct 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_chainblocking读写信号量允许但建议短小自定义事件总线、部分模块加载通知raw无内部锁调用者自保证看调用者上下文netdev_chain通常持 RTNL 调用srcuSRCU允许长时间睡眠读多写少、需要长临界区的场合atomic 链内部用自旋锁保护链表回调会在持锁状态下执行所以回调里绝对不允许睡眠也不允许调用任何可能阻塞的函数。典型例子是 panic_notifier_list、die_chain适合中断或紧急路径。你如果在写驱动时注册到这类链上回调里基本只适合做置标志位、记录状态这类轻量操作。blocking 链内部用读写信号量保护回调可以睡眠但你不能在中断上下文里触发它因为你无法在中断里获取信号量一拿就睡死。它有很好的易用性适合进程上下文的事件分发。自己封装内核事件总线时blocking 链是最常见的起点本书后续自定义链的示例就用的是它。raw 链内部不加任何锁好处是快坏处是调用者必须自己保证同步。比如 netdev_chain触发路径上通常已经持有 RTNL 锁回调里再尝试拿 RTNL 锁就会死锁。srcu 链基于 SRCU适合读多写少、回调需要长时间睡眠的场景但使用门槛高一些普通驱动开发中用得少。有一点必须强调具体某个链属于哪种类型不同内核版本可能有差别写代码前先确认你手上的源码。我刚接触时看了一篇老文章照搬注册到某个链后回调里用了一个可能睡眠的函数结果模块一加载就崩教训很深刻。2. 注册、触发、注销一抹就通的三个 API2.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 三种玩法各试一次整个机制就基本焊死在记忆里了。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门