Linux内核热补丁livepatch核心机制:core.c状态机与原子替换解析
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、funclivepatch的模型是一个严格的四层嵌套每个补丁klp_patch可以包含多个待修补的objectklp_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的f1klp_state提供的不只是计数器它是一个按uiuniverse推进的版本机制。每个补丁定义自己引入的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、universecore.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层代码延伸。