1. livepatch机制全景:core.c在内核热补丁里的位置
做内核开发这么久,我越来越觉得livepatch是个很容易被忽视但又极其精巧的子系统。很多运维同学听说它能不重启就打补丁,第一反应是"那不就是改改内核模块再insmod一下?"——完全不是一回事。livepatch做的是函数级的在线替换,它修改的是正在运行的内核代码路径,而不是单纯给内核加一个新功能。当你在处理生产环境的关键业务节点时,能在一到两毫秒内把一个有bug的函数指针改成指向修复后的实现,这个价值怎么强调都不过分。
我这次花了不少时间读Linux 6.19版本下kernel/livepatch/core.c的源码,这个文件是整个livepatch功能的核心枢纽。它不涉及具体arch层面的指令级补丁操作(那是livepatch_arch或ftrace模块干的活),它管的是更高一层的东西:补丁怎么注册、状态怎么流转、旧补丁怎么被替换、回调函数什么时机被触发、sysfs接口怎么暴露给用户态。一句话概括就是,core.c是livepatch的"调度中枢",而arch层代码只是执行终端。
适合读这篇分析的人,我认为有三类:一类是在做内核模块开发、想了解livepatch扩展机制的驱动开发者;一类是做系统工具链或者SRE相关、需要排查线上内核热补丁问题的运维工程师;还有一类就是纯粹想通过一个具体子系统把Linux内核的模块化设计、状态机管理、并发控制思路串起来的进阶学习者。读完你至少能回答这几个问题:一个.dbg的livepatch补丁模块加载进系统后,内核到底做了什么?为什么补丁能"叠加"还能"原子替换"?那些回调函数到底按什么顺序被调用?以及出了问题该从哪里下手查。
先说我的总体印象:core.c的设计非常克制,逻辑极其线性,没有花哨的技巧,但它对状态的管理和并发切换的细节是整段代码的精华所在。接下来我会从数据结构、状态机、注册流程、替换机制、回调时机到问题排查,一条线拆到底。
2. 核心数据结构与设计意图
2.1 嵌套的层次模型:patch、object、func
livepatch的模型是一个严格的四层嵌套:每个补丁(klp_patch)可以包含多个待修补的object(klp_object),每个object对应一个内核模块(或者vmlinux主内核),每个object里包含多个需要修补的函数(klp_func)。这个嵌套关系直接决定了你在写一个livepatch补丁时,要按"哪个模块、哪个函数、原地址多少、新地址多少"这样的粒度去描述。
struct klp_patch { struct list_head list; // 挂在 klp_patches 全局链表上 struct klp_object objs[]; // 柔性数组,存放每模块的补丁信息 struct klp_state *states; // 补丁级状态,用于多次加载补偿 unsigned long replace:1; // 是否启用原子替换模式 unsigned long forced:1; // 是否已经被强制转换过 struct work_struct work; // 用于延迟注册 struct completion enabled; // 注册完成的同步信号 enum klp_patch_state state; // 补丁当前所在状态 bool post_unfreeze; // 冻结回调是否已完成 }; struct klp_object { const char *name; // 模块名,vmlinux 为 NULL struct klp_func funcs[]; // 函数补丁列表 enum klp_object_state state; // 这个object的状态 bool patched; // 是否已经应用替换 bool dynamic; // 是否vmlinux主内核对象 struct klp_patch *patch; // 反向引用 }; struct klp_func { const char *old_name; // 被替换的函数名 void *old_func; // 解析出的原函数地址 void *new_func; // 替换后的新函数地址 const char *old_sympos; // 同名符号的序号 bool immediate; // 是否跳过过渡状态强制启用 bool transition; // 当前是否处于过渡中 unsigned long old_size; // 原函数大小(非必须,arch差异) };这组结构体最大的设计特点是把"变化点"收敛到了klp_func这一层。object和patch只是容器和组织者,真正和ftrace打交道的、真正会被动态修改的,只有函数这一级。你在写补丁的时候不用关心某个模块整体怎么被"覆盖",只要描述清楚每个函数的替换关系就行。好处是灵活、精确;代价是如果某个模块有多个函数要修,你得一个个写进去,漏一个就等于没补全。
2.2 为什么需要old_sympos这种"土办法"来解决符号重名
这里有个平时很容易被忽略的坑:内核里同名函数并不罕见。特别是static函数,在多个编译单元里完全可以同名,链接后虽然local符号不会暴露,但如果通过kallsyms能看到,会有多个地址对应同一个名字。这时候光靠old_name去匹配就是二义性的。
老版本(5.7之前的内核)里有old_sympos字段,它表示:如果old_name对应的符号有多个定义,那就取第几个。源码里会从/proc/kallsyms或kall_symbols表里按名字逐个数,数到第old_sympos个为止。这个方法很直白,但确实也"土",因为在某些情况下符号顺序会因内核配置变化而漂移。新版内核在madhavan Tendulkar的补丁系列之后,引入了更严谨的方式——直接写地址或通过MODULE_INFO里的livepatch元数据来精确锚定,但如果你还在看老代码,遇到解析不到old_func的问题,第一反应就应该是检查old_sympos。
2.3 klp_state:处理"同一个模块被多次补丁叠打"的场景
klp_state是很多分析文章容易跳过、但实际很重要的一块。它的设计目标是解决一个具体问题:如果补丁A给模块M的f1函数打了补丁,补丁B又给同一个模块的f2函数打补丁,同时补丁B希望撤销A中对f1的修改(因为f2的行为依赖f1的原语义),那系统怎么知道"现在该回到原始f1,还是继续使用补丁A的f1"?
klp_state提供的不只是计数器,它是一个按ui(universe)推进的版本机制。每个补丁定义自己引入的state,注册时注册到某个universe编号上,usage值表示当前有多少活跃补丁引用了这个状态。只有当usage降为0的时候,这个state才会被真正释放,相关的回调universe解冻等操作才会执行。翻译成生活化的类比:这就像你电脑上装了两个版本的插件管理器,旧插件管理器A占用某个全局配置键,新插件管理器B想改这个键的值,但B得先礼让地等所有还在用旧值的程序退出。内核里用usage和universe这套机制,本质上就是解决"同一个资源上多个补丁的交错依赖"。
3. 状态机与补丁生命周期管理
3.1 六个状态与转换约束
core.c里维护着补丁级别的状态机,一个klp_patch从出生到废弃,一共经历了这几个状态:
enum klp_patch_state { KLP_PATCH_STATE_DISABLED, // 初始状态,什么也没干 KLP_PATCH_STATE_REGISTERED, // 注册完成,但还没启用 KLP_PATCH_STATE_ENABLED, // 已启用,补丁生效中 KLP_PATCH_STATE_REPLACED, // 被新补丁替换,等待回收 KLP_PATCH_STATE_UNRELIABLE, // 不稳定状态,自动禁用 KLP_PATCH_STATE_REMOVED // 已移除,补丁退出 };这些状态不能随便跳。比如你要从ENABLED直接跳到REMOVED?不行,必须先经过REPLACED或UNRELIABLE。为什么?因为从"生效中"到"移除"之间必然有一个阶段需要把所有的函数调用点从新函数恢复到旧函数,这个过程如果直接做,可能出现一个CPU还站在补丁函数里、另一个CPU已经把新函数都拔掉了。内核的做法是先标记为REPLACED,让整个系统先完成一次全量的ftrace切换同步,安全之后才允许最后的清理。同样的,UNRELIABLE状态也不是用来表示"补丁跑挂了",而是表示"这个补丁的目标模块被卸载或出现无法恢复的冲突,不能再信任它的补丁路径",内核必须立刻停用它。
3.2 enabled到transition,再到replaced的细节
有人看到klp_func里有transition标志位,但不知道这个标志位的意义。在livepatch的模型里,"启用一个补丁"这个动作并不是瞬间完成的——它是一个多跳的过渡过程。内核默认的策略是:当一个补丁启用时,先让新代码和旧代码共存一段时间,调度器会逐步把每个任务的栈状态推进,确保没有哪个任务正阻塞在旧函数的栈帧上。这个过程叫transition,只有过渡结束后,旧的函数指针才能被安全地摘除。
这个"共存期"具体怎么做?想想ftrace机制:livepatch实际是把旧函数入口替换为一个跳转指令,跳到new_func。如果所有CPU当前都在这个old_func的路径上,而你立刻把跳转目标换成新的new_func,那对正在执行的线程来说没问题(指令指针已经在函数体内),但如果此时另一个CPU正通过某个外部调用点新建一个栈帧指向旧函数地址,就可能混入新老两段逻辑。所以内核引入过渡阶段,期间同步所有任务,确保它们一致地进入新世界。这个思路和数据库做滚动升级时的流量切换理论几乎一样——先灰度,再全量,最后删旧系统。
3.3 unreliable这个"灰姑娘"状态
很多初学livepatch的人看到UNRELIABLE会以为这是个错误状态,其实这是个保护状态。它通常在以下几种情况被设置:
- 补丁目标模块被卸载了,比如你打补丁的某个ko模块被rmmod了,补丁保留的意义立刻丧失。
- 补丁的某个object解析失败或模块重新加载后函数地址变化,无法再保证old_func有效。
- 内核配置了CONFIG_LIVEPATCH但当前的模块加载路径不满足一致性检查,比如模块签名不匹配。
一旦一个补丁变成UNRELIABLE,它不能再被启用,同时sysfs里会暴露一个属性告诉你这个补丁不可靠。排查线上问题时,如果看到一个补丁状态是UNRELIABLE,不要直接以为是它"补错了",更要紧的是去看dmesg里跟这个补丁相关联的模块加载/卸载记录。很多真实事故其实是先用rmmod卸载了业务模块,结果发现内核日志里ksplice/livepatch子系统报了一串错误,然后把补丁踢掉了。
4. 启用与替换:core.c的核心流程逐段拆解
4.1 klp_enable_patch的入口逻辑
补丁的启用入口是klp_enable_patch(),这个函数接收一个已经注册好的klp_patch,然后启动一个工作队列去异步执行真正的工作。为什么异步?因为启用过程可能要等所有CPU完成一次抢占同步,这在有些关中断、关抢占的环境里无法立刻完成。为了不阻塞当前进程(比如modprobe进程),直接用schedule_work塞进内核工作队列。
int klp_enable_patch(struct klp_patch *patch) { int ret; if (!patch || !patch->objs) return -EINVAL; mutex_lock(&klp_mutex); ret = klp_validate_patch(patch); if (!ret) ret = __klp_enable_patch(patch); mutex_unlock(&klp_mutex); return ret; }这里有个细节:整个操作被klp_mutex保护。很多新手第一次读内核代码,会不理解为什么注册、启用、替换都要拿同一把锁。想想看,如果有两个补丁同时要被加载,一个要启用f1的新版本,另一个要启用同一个函数的老版本(虽然听起来不太可能,但确实可以在不同模块场景下发生)——如果不加锁,ftrace对同一个函数的mcount入口的修改就会互相打架。这把大锁让所有livepatch操作串行化,本质上是对ftrace修改操作的"写锁"。性能上不用担心,补丁操作本身就是低频操作,没必要做细粒度锁。
4.2 逐个object应用补丁:klp_patch_object的流程
__klp_enable_patch最终会遍历patch->objs,对每个object调用klp_patch_object(),这才是真正干重活的地方。每个object都要走一遍"检查是否正在过渡"、"逐个函数注册ftrace"、"确认函数地址"的流程。下面是这个流程最核心的一段伪代码式梳理:
static int klp_patch_object(struct klp_object *obj) { struct klp_func *func; int ret; if (WARN_ON(obj->patched)) return -EINVAL; if (klp_is_module(obj)) ret = klp_find_object_module(obj); // 根据name找module指针 else obj->mod = NULL; // vmlinux for_each_klp_func(obj, func) { ret = klp_find_func(func); // 解析old_func地址 if (ret) goto err; ret = klp_apply_func(func); // 注册ftrace ops if (ret) goto err; } obj->patched = true; return 0; err: // 回滚已注册的函数 while (func-- != obj->funcs) klp_revert_func(func); return ret; }注意一个容易被忽略的细节:klp_find_object_module里,如果目标是vmlinux主内核,obj->name是NULL,此时obj->mod也设为NULL。但如果目标是某个ko模块,core.c会调用find_module(obj->name)。这个调用有很严格的时间窗口——模块必须先被加载成功,补丁才能找到它;反过来,如果补丁已经注册但模块还没加载,那它会先报错或者标记为unreliable。所以加载顺序问题一直是livepatch实操里的一个大坑。我个人的经验是:打livepatch前一定要先保证所有目标模块都已经在内存里,否则宁可把补丁拆成两个独立的补丁文件,一个专门打vmlinux,一个打ko模块,分开加载,避免串扰。
4.3 原子替换(atomic replace)到底是怎么做到的
6.19版本的core.c里,atomic replace的实现已经非常成熟了。它的核心思路是:新补丁注册时打上replace标志,在启用之前,先把所有已注册但还没被替换的旧补丁的enabled状态全部检查一遍,如果某个旧补丁包含的目标函数在新补丁里也有,那就把旧补丁标记为REPLACED。
举个最直观的例子:你有补丁A,修改了f1和f2两个函数;现在写了一个超级补丁B,只修改了f1但没有修改f2。如果B带replace标志,那B启用后会完全替代A,这意味着f2的修改也会被撤销——因为A彻底退休了。这个行为很多人初用时会出乎意料:以为replace只替换"和新补丁重合的部分",结果发现它把整个旧补丁都干掉了。这其实是刻意的设计,目的是确保系统的补丁集合始终是"新增式叠加"而不是"互相覆盖"。用replace功能时要特别小心,你要确保新补丁包含了所有还想保留的修改,漏一个就等于回退了一个修复。
4.4 回调函数的调用顺序:pre、post、universe
core.c里管理着一组回调,在补丁生命周期的重要节点触发。这个设计源自ftrace结合module notify机制的需求。最常用的几个回调边界如下:
- klp_pre_patch:在补丁应用前被调用,一般用来做状态校验,比如确认某些全局变量的值。
- klp_post_patch:在补丁应用后,过渡完成前调用,适合做依赖初始化。
- klp_pre_unpatch:在补丁回滚前调用,适合做某些资源的抢占释放。
- klp_post_unpatch:回滚完成后调用,适合做最终清理。
但这里有个隐含顺序问题:如果同时启用多个object,回调的调用顺序是按objs[]数组顺序还是按依赖关系?源码里没有做拓扑排序,完全按补丁文件里声明的顺序走。所以你在设计多个object之间的回调依赖时,顺序必须自己控制。我的建议是:一个补丁文件里尽量只打一个object;如果确实要打多个,把相互之间有依赖关系的object放到数组前面,依赖别人的放后面,不要指望内核帮你排序。
5. 实操经验:读代码过程中遇到的坑与调试方法
5.1 用sysfs观测补丁状态
core.c里暴露了非常完整的sysfs接口,位置在/sys/kernel/livepatch/。每个补丁目录下能看到:
- enabled:当前是否启用
- transition:是否在过渡中
- forced:是否启动了强制忽略不一致的过渡
- replace:是否原子替换模式
- 每个object目录下还有fn目录,可以看每个函数的替换状态
调试时我最常用的就是看transition值。如果你发现一个补丁加载后,transition长期为1,说明有的任务一直停留在旧栈帧上,没有完成切换。这时候排查的方向是:是不是有任务处于D状态或者长时间spin在中断上下文?因为livepatch的过渡完成需要一个"所有任务都被观察并标记"的过程,如果某个任务一直不可见,就会卡住。常见解决办法是再次触发一次full transition(往sysfs的transition属性写1),或者检查是否有内核线程被affinity绑死。这里没什么银弹,只能逐个排查。
5.2 补丁加载用modprobe还是insmod,区别很大
这个坑我在实际环境里踩过不止一次。livepatch补丁本质上是个内核模块,但它又很特殊——它带的是.klp后缀或者包含livepatch标签的特殊section。如果直接用insmod加载,kernel会正常走module_init,但因为livepatch模块的init函数做的是补丁注册和启用,很多老版本里insmod路径下无法在加载时自动应用补丁设置,还需要额外触发一次"注册后启用"的逻辑;而用modprobe加载时,系统会走完整的模块依赖和livepatch辅助检查路径,可以自动匹配到module name和livepatch name的一致性校验。
我在实际生产中还遇到过这样一种情况:补丁模块成功insmod了,但补丁没有生效,检查sysfs里根本没有对应目录。最后发现就是因为insmod绕过了modprobe的alias解析,补丁对象里的module名字和实际module名字对不上,klp_find_object_module在注册阶段就失败了。所以现场操作时,标准动作应该是modprobe,而不是innsmod。
5.3 读过代码之后会发现的设计启示
如果你不是专门写livepatch,而是做普通内核模块或者驱动开发,core.c里的设计也很有借鉴价值:
- 状态机不要拍脑袋设计,像它一样把每个状态的进入条件和退出条件写死。
- 凡是涉及修改全局共享资源的操作,宁可拿一把大锁串行化,也不要为了微优化去搞复杂并发模型——补丁应用这个操作足够低频,锁的代价完全可接受。
- 给用户态提供的观测手段一定得完整,sysfs里能暴露多少状态就暴露多少,别让运维只能靠dmesg猜状态。
- 注意异步化。不是所有操作都必须在当前进程上下文中同步完成,合理使用工作队列可以把不可控的阻塞时间隔离出去。
另外6.19的core.c里,klp_mutex已经替换过一版,早期版本用的是mutex_lock两个分别的锁(一个管patch list,一个管state),后来统一成一把klp_mutex。这个变化其实就是设计者意识到,livepatch操作序列之间本身就不允许并发,拆成两把锁反而引入死锁风险。这个取舍值得每个写内核模块的人借鉴:锁粒度不是越细越好,关键看操作语义是否需要并行。
5.4 一个值得再多读两遍的部分:模块卸载通知链
最后我建议大家在读core.c时,把处理MODULE_GOING通知的那段代码多看两遍。它解决的问题是:当一个目标模块被卸载时,补丁系统必须同步把针对该模块的函数替换状态清掉。这个处理牵扯到很多细节:模块卸载事件发生时,可能有其他CPU正站在补丁函数里,必须等到这些CPU退出临界区;而且如果模块是动态卸的,还要考虑补丁模块自身的引用计数——你得拿着目标模块的引用才能安全地读取和修改它的代码段。core.c对这种场景的处理虽然不是最复杂的内核代码,但绝对是一份活的"动态代码修改与模块生命周期交互"的教材。
说句实话,读这类核心文件,光靠看注释和函数名是不够的,真正的收获来自那些"为什么这里要拿这把锁""为什么这个状态不能直接跳"的思考。我通常会在read完之后,把自己想象成作者,重新设计一遍,再和代码里的方案对比差异。这种对比训练积累多了,自己对系统级并发、生命周期、状态转换的把控能力会有很明显的提升。6.19这个版本结构清晰,注释也算友好,作为学习样本比老版本适合得多。如果你也正在往内核动态修改、安全热修复这个方向深挖,强烈建议把这篇core.c吃透,再往arch层代码延伸。