Linux 内核 Livepatch 的巧妙自举:如何给 `klp_try_complete_transition()` 自己打热补丁

前言

Linux 内核的 Livepatch(实时补丁)机制允许我们在不重启系统的情况下替换内核函数。但有一个非常有趣的问题:如果我们要给 Livepatch 自身的过渡检查函数 klp_try_complete_transition() 打补丁,这个过渡过程还能顺利完成吗?

答案是:。而且它的实现非常巧妙,完全依赖通用机制,不需要任何特殊豁免。


问题场景

当我们执行 insmod livepatch.ko 加载一个热补丁时,内核会触发以下调用链:

c 复制代码
insmod -> klp_enable_patch() -> klp_try_complete_transition()

klp_try_complete_transition() 的职责是遍历所有进程,检查它们的调用栈,确保没有任何进程正在执行即将被替换的旧函数。只有确认所有进程都安全后,它才会调用 klp_complete_transition() 完成过渡。

那么问题来了

如果这次热补丁恰好要替换 klp_try_complete_transition() 本身,那么在第一次检查时,insmod 进程的调用栈上必然存在这个函数的返回地址

此时 klp_check_stack() 会在栈上匹配到旧函数的地址范围,返回 -EAGAIN,导致当前进程无法被标记为"已过渡"。

c 复制代码
// 伪代码示意
for_each_process(task) {
    ret = klp_check_stack(task, patch);  // 检查栈上是否有旧函数
    if (ret == -EAGAIN)
        return -EAGAIN;  // 当前进程(insmod)还没准备好
}

按照直觉,这似乎是"自己给自己做手术"的死锁------当前进程因为正在执行这个函数,所以无法完成过渡;而过渡完不成,这个函数又一直执行不下去。


巧妙的设计:异步重试

Livepatch 的解决方式非常优雅:它从不阻塞等待,而是立即返回,并安排 kworker 稍后重试。

klp_try_complete_transition() 发现还有进程未过渡时,它会执行:

c 复制代码
err:
    schedule_delayed_work(&klp_transition_work, round_jiffies_relative(HZ));

这行代码安排了一个延迟工作项(约 1 秒后由 kworker 执行),然后当前函数直接返回。

关键转折

klp_try_complete_transition()void 函数,第一次调用失败后直接返回,上层 __klp_enable_patch() 也随之返回。此时:

  • insmod 的系统调用路径已经走完;
  • 进程要么已经退出,要么已返回用户态;
  • 无论哪种情况,它的内核栈上都不再保留 klp_try_complete_transition() 的调用帧。

当 kworker 在后续轮询中再次执行 klp_try_complete_transition() 时,检查就能顺利通过,最终调用 klp_complete_transition() 完成过渡。


这不是特例,而是通用机制

这个"巧妙"之处,本质上是 Livepatch 一致性模型的通用表现:

机制 说明
栈检查 任何被打补丁的函数,只要某个进程正在执行它(出现在栈上),该进程就不能被切换
异步重试 通过 workqueue 周期性重试,直到所有进程都离开旧函数的调用栈
自举能力 因此 Livepatch 可以安全地给自己打补丁,无需特殊豁免逻辑

换句话说,Livepatch 不需要对 klp_try_complete_transition() 做任何特殊处理------它依靠"异步重试 + 栈检查"的通用机制,自然地解决了自举问题。


补充:新版内核的优化

在较新的内核版本中,除了 workqueue 的定时重试,Livepatch 还会通过 klp_send_signals() 向未过渡的进程发送"假信号":

  • 对用户进程:调用 set_notify_signal(),促使其尽快到达检查点
  • 对内核线程:调用 wake_up_state(),唤醒它继续执行

这加快了过渡速度,但核心逻辑不变------最终还是依赖进程自然离开旧函数的调用栈


总结

Livepatch 的设计哲学非常清晰:不做特殊 case,只做强通用机制。

klp_try_complete_transition() 自身打补丁的场景,看似是一个"自举悖论",但实际上通过以下三步完美解决:

  1. 第一次检查insmod 栈上有该函数,返回 -EAGAIN
  2. 异步重试:安排 kworker 1 秒后再次检查,当前调用栈释放;
  3. 后续检查insmod 已退出或离开该流程,检查通过,过渡完成。