目录

你有没有想过,一个跑在线上的游戏服务器,能不能在不重启的情况下修一个 bug?一个正在处理百万并发请求的后端服务,能不能在用户无感知的情况下升级某个模块?

答案是:可以,这就是热更新技术要做的事。

C++ 作为一门编译型语言,天然不具备像 Python、JavaScript 那样的”改完代码直接跑”的能力。但在 Linux 平台上,借助操作系统的动态链接机制和内存管理能力,C++ 同样可以实现热更新,只是路线更多、坑也更多。

今天这篇文章,就从原理到实现方案,再到应用场景和优缺点对比,把 Linux 平台上的 C++ 热更新技术一次性讲清楚。


一、什么是热更新?

热更新(Hot Update / Hot Patch / Hot Reload),指的是在程序运行期间,不终止进程的情况下,对程序的代码逻辑、数据或配置进行修改并使之生效的技术。

打个比方:你的汽车正在高速行驶,热更新就是在不停车的情况下,换一个更高效的发动机零件,车还在跑,但性能已经变了。

与热更新容易混淆的几个概念先厘清一下:

  • 热修复(Hot Fix):侧重于 bug 修复,通常改动量小,目标是”修完就生效”。
  • 热加载(Hot Load):侧重于加载新的模块或资源(如新配置、新插件),不一定替换旧逻辑。
  • 热替换(Hot Swap):侧重于把旧模块的逻辑替换为新模块的逻辑,旧模块被卸载。
  • 动态更新 / 在线更新:更宽泛的术语,有时也包含需要短暂停机的滚动更新。

本文统一使用热更新,涵盖以上所有场景。


二、Linux 平台 C++ 热更新的实现原理

C++ 是编译型语言,源码经过编译、链接后变成机器码,加载到内存后由 CPU 直接执行。

要在运行时替换逻辑,本质上要解决一个问题:

如何让进程在运行中,把内存里的一段旧机器码替换为新机器码,并让后续的执行流跳到新代码上去?

Linux 操作系统提供了几个关键机制,正是这些机制让热更新成为可能:

1. 动态链接与 dlopen/dlsym

Linux 的 ELF 可执行文件分两种:静态链接和动态链接。

动态链接的可执行文件在运行时会加载 .so(共享对象)文件,而 dlopen / dlsym 这组 API 允许程序在运行时主动加载新的 .so 文件,并查找其中的符号。

这是热更新最基础的操作系统支撑:新代码编译成 .so,旧进程在运行时把它加载进来。

2. 符号重定位与 GOT/PLT

动态链接的程序调用外部函数时,并不直接跳到目标地址,而是通过 GOT(Global Offset Table) 和 PLT(Procedure Linkage Table) 这两层间接跳转:

首次调用(延迟绑定):
  调用方
    │   call func@PLT
    ▼
  PLT 条目
    │   jmp *GOT[func]
    ▼
  GOT[func] 初始指向 PLT 中的 push index 指令
    │   push 符号在 GOT 中的索引 index
    │   jmp PLT[0](动态链接器入口)
    ▼
  动态链接器
    │   1. 根据 index 查找 func 的实际地址
    │   2. 回填 GOT[func] = 真实地址
    │   3. 跳转到 func 真实入口
    ▼
  func 的真实入口

后续调用(直接跳转):
  调用方
    │   call func@PLT
    ▼
  PLT 条目
    │   jmp *GOT[func]        ← GOT 已被回填为 func 实际地址
    ▼
  func 的真实入口

GOT 是可以修改的,如果我们在运行时把 GOT 里某个函数的地址改成新函数的地址,那么所有通过 PLT 调用该函数的代码,都会自动跳到新函数上,这就是”函数级热替换”的核心原理。

3. 进程内存布局与 mprotect

Linux 进程的内存布局中,代码段(.text)通常是只读可执行的(R-X 权限,即 PROT_READ | PROT_EXEC,不可写)。

要直接修改代码段里的指令,需要先用 mprotect 把对应内存页的权限加上可写(从 R-X 改为 RWX,即 PROT_READ | PROT_WRITE | PROT_EXEC),写完新指令后再改回只读(R-X)。

这种方式很底层,也很危险,改错了指令进程直接崩溃,但在某些场景下(如单函数补丁),它却是最轻量的方案。

4. ptrace 与进程注入

ptrace 是 Linux 的进程跟踪机制,调试器(gdb)就是基于它实现的。

一个进程可以通过 ptrace attach 到另一个进程,修改其内存、寄存器,甚至注入新的代码段。

这种方式的权限要求高,但可以实现跨进程的热更新,补丁进程不需要是目标进程本身。

5. 信号与执行流中断

SIGUSR1 / SIGUSR2 等自定义信号,可以用来通知进程”该做热更新了”。

进程在信号处理器中设置一个标记,主循环检测到标记后,在安全的同步点执行加载新模块、替换逻辑等操作。注意:dlopen 等 API 不是 async-signal-safe 函数,不能在信号处理器中直接调用。


三、Linux 平台 C++ 热更新的实现方案

基于上述原理,业界演化出以下几条主流实现路线。

按”实现复杂度低 → 高”,“侵入性低 → 高”排列:

方案 A:动态库加载替换(dlopen + dlsym)

思想:将需要热更新的模块编译成独立的 .so 文件。主程序通过 dlopen 加载该 .so,用 dlsym 查找模块入口函数并调用。需要更新时,dlclose 卸载旧 .so,再 dlopen 加载新 .so。

实现骨架:

// ============================================================
// 文件 1: hot_module.h — 热更新模块的统一接口
// (主程序与 .so 共享此头文件)
// ============================================================
// 原理:主程序只依赖此抽象接口类,不依赖任何具体实现。
// 新旧 .so 各自提供实现,通过工厂函数创建实例,
// 主程序通过虚函数表(vtable)分发调用——这是跨 .so
// 边界做运行期多态的唯一安全方式。
#include <string>

class HotModule {
public:
    // 虚析构:通过基类指针 delete 时
    // 能正确调用派生类析构函数
    virtual ~HotModule() = default;

    // 业务接口:处理输入,返回结果
    virtual std::string process(
        const std::string& input) = 0;

    // 版本号:用于验证热更新是否生效
    virtual int version() const = 0;
};

// ============================================================
// 文件 2: hot_module_v1.cpp → 编译为 module_v1.so
//   g++ -shared -fPIC -o module_v1.so hot_module_v1.cpp
// ============================================================
#include "hot_module.h"

class HotModuleV1 : public HotModule {
public:
    std::string process(
        const std::string& input) override
    {
        // 旧逻辑:简单前缀
        return "v1: " + input;
    }

    int version() const override {
        return 1;
    }
};

// 工厂函数:extern "C" 防止 C++ name mangling
// 原理:C++ 编译器会对函数名做名称修饰(如
// _Z13create_modulev),extern "C" 强制使用 C 链接
// 约定,符号名保持 create_module,这样主程序
// dlsym(handle, "create_module") 才能找到它。
extern "C" HotModule* create_module() {
    // 在 .so 内部 new,确保跨边界安全
    return new HotModuleV1();
}

// 必须由 .so 提供销毁函数,避免主程序的 delete
// 与 .so 的 new 跨 ABI 不匹配
extern "C" void destroy_module(HotModule* p) {
    delete p;
}

// ============================================================
// 文件 3: main.cpp — 主程序
//   g++ -rdynamic -o main main.cpp -ldl
//   -rdynamic: 导出主程序符号供 .so 反向调用
//   -ldl: 链接 libdl(dlopen/dlsym/dlclose 所在库)
// ============================================================
#include "hot_module.h"
#include <dlfcn.h>
#include <iostream>
#include <string>

class ModuleLoader {
    // dlopen 返回的句柄,dlclose 时需要
    void* _handle = nullptr;
    // 工厂函数创建的模块实例
    HotModule* _module = nullptr;

    // 函数指针类型别名
    // 对应 .so 中的 create_module / destroy_module
    using CreateFn  = HotModule* (*)();
    using DestroyFn = void (*)(HotModule*);

    // 缓存 create_module 的地址
    CreateFn  _create  = nullptr;
    // 缓存 destroy_module 的地址
    DestroyFn _destroy = nullptr;

public:
    bool load(const char* path) {
        // 如果已有旧模块,先卸载
        // ——热更新的"替换"语义
        if (_handle) unload();

        // RTLD_NOW: 立即解析所有符号(而非延迟)
        // 选择 RTLD_NOW 而非 RTLD_LAZY:尽早发现
        // 符号缺失问题,避免运行时崩溃
        _handle = dlopen(path, RTLD_NOW);
        if (!_handle) {
            // dlerror() 返回上次错误描述
            std::cerr << "dlopen: "
                      << dlerror() << "\n";
            return false;
        }

        // 清空残留错误标记
        // (dlsym 无法区分"找不到符号"
        //   和"符号恰为 NULL")
        dlerror();
        // 查找工厂函数地址
        _create = (CreateFn)dlsym(
            _handle, "create_module");
        // 查找销毁函数地址
        _destroy = (DestroyFn)dlsym(
            _handle, "destroy_module");
        if (!_create || !_destroy) {
            std::cerr << "dlsym: "
                      << dlerror() << "\n";
            // 清理:关闭已打开的 .so
            dlclose(_handle);
            _handle = nullptr;
            return false;
        }

        // 调用工厂函数,创建模块实例
        _module = _create();
        return true;
    }

    void unload() {
        // 顺序很重要:先销毁模块实例,
        // 再关闭 .so 句柄
        // 否则 destroy_module 的代码已被卸载,
        // 调用会段错误
        if (_module) {
            _destroy(_module);
            _module = nullptr;
        }
        if (_handle) {
            dlclose(_handle);
            _handle = nullptr;
        }
        _create  = nullptr;
        _destroy = nullptr;
    }

    // 获取当前模块实例指针
    HotModule* module() const {
        return _module;
    }
};

int main() {
    ModuleLoader loader;
    // 初始加载 v1 模块
    loader.load("./module_v1.so");

    // 输出: v1: hello
    std::cout << loader.module()
                 ->process("hello") << "\n";
    // 输出: version: 1
    std::cout << "version: "
              << loader.module()->version()
              << "\n";

    // 热更新:传入新 .so 路径
    // load() 内部先 unload 旧模块再加载新模块
    loader.load("./module_v2.so");

    // 输出: v2: hello(新逻辑生效)
    std::cout << loader.module()
                 ->process("hello") << "\n";
    // 输出: version: 2
    std::cout << "version: "
              << loader.module()->version()
              << "\n";

    // 清理:销毁模块实例并关闭 .so
    loader.unload();
    return 0;
}

优点:

  • 实现简单,几十行代码就能跑起来。
  • 模块边界清晰,每个 .so 是独立的编译单元,更新只影响对应模块。
  • 不修改主程序代码段,安全性高。
  • Linux 原生支持,无需第三方库。

缺点:

  • 模块必须通过接口类(虚函数)访问,因为 dlopen 加载的新 .so 里类的符号名与旧 .so 不同,不能直接按符号名替换对象。工厂函数 + 虚函数是唯一的安全桥梁。
  • 全局/静态变量会丢失,dlclose 卸载旧 .so 时,其全局和静态变量的内存被释放;新 .so 重新加载后,全局变量从头初始化。如果模块有需要持久化的状态,必须外部保存。
  • dlclose 不一定真正卸载,如果旧模块的符号被其他 .so 引用,Linux 的动态链接器可能不会真正卸载它,导致新旧模块并存。
  • ABI 兼容性风险,新 .so 必须与主程序使用相同的编译器版本和 ABI 约定;接口类的虚函数表顺序不能变(不能增删虚函数、不能改变基类顺序),否则虚调用会跳到错误的函数。
  • 不支持正在执行的函数热替换,如果旧模块的某个函数正在被执行(栈帧还在),dlclose 后栈上的返回地址指向已卸载的代码段,进程会崩溃。

方案 B:GOT/PLT 劫持(函数级热替换)

思想:不改代码段,改 GOT 表。动态链接的程序调用外部函数时,通过 GOT 间接跳转。我们在运行时找到 GOT 中目标函数的条目,把地址改成新函数的地址,后续所有调用自动跳到新函数。

实现要点:

#include <link.h>      // ElfW, Dyn — ELF 动态段结构
#include <sys/mman.h>  // mprotect
#include <unistd.h>    // sysconf
#include <stdint.h>    // uintptr_t
#include <string.h>    // memcpy

// ============================================================
// 步骤 1:找到目标函数在 GOT 中的位置
// (伪代码——仅说明原理,不可直接编译运行)
// ============================================================
// 原理:ELF 文件中,.rela.plt 段记录了所有需要延迟绑定
//       的函数重定位项。每个条目包含:被修正的 GOT 地址、
//       目标符号名。遍历这些条目,匹配符号名即可找到
//       目标函数对应的 GOT 条目。
//
//       实际实现需要:
//       1. 通过 dl_iterate_phdr 获取本进程的 link_map 链表
//       2. 遍历每个 .so 的 DT_JMPREL(.rela.plt)段
//       3. 逐条检查 Elf64_Rela 条目,
//          用 elf64_sym + strtab 查符号名
//       4. 匹配到目标符号后,
//          rela.r_offset 即为 GOT 条目地址
//
//       可参考 glibc 的 dl_lookup_symbol 或
//       livepatch 框架源码实现。
void* find_got_entry(const char* func_name) {
    // 遍历 .rela.plt / .rel.dyn 段,匹配符号名
    // 返回对应的 GOT 条目地址
    // (存放函数指针的内存位置)
    return nullptr;  // 简化示意
}

// ============================================================
// 步骤 2:修改 GOT 条目
// 将旧函数地址替换为新函数地址
// ============================================================
// 原理:GOT(Global Offset Table)是一张函数指针表,
//       动态链接器在加载 .so 时把每个外部函数的实际地址
//       填入对应 GOT 条目。程序通过 PLT 调用外部函数时,
//       间接读取 GOT 条目跳转。
//       修改 GOT 条目的值 = 改变所有 PLT 调用的跳转目标。
void patch_got(void* got_entry,
               void* new_func_addr)
{
    // 获取内存页大小(通常 4096)
    long page_size = sysconf(_SC_PAGESIZE);

    // 将 got_entry 向下对齐到页边界
    // mprotect 以页为单位操作权限
    void* page = (void*)(
        (uintptr_t)got_entry & ~(page_size - 1));

    // 保存原始权限
    // (GOT 段可能是 PROT_READ,
    //   也可能是 PROT_READ|PROT_WRITE,
    //   取决于是否启用了 full RELRO;
    //   恢复时应还原原始权限而非硬编码)
    // 实际实现需读取 /proc/self/maps
    // int orig_prot = get_page_protection(page);

    // GOT 所在页默认只读(尤其开了 RELRO 时)
    // 需要先改为可写
    mprotect(page, page_size,
             PROT_READ | PROT_WRITE);

    // 写入新函数地址
    // 后续所有通过 PLT 的调用都会跳转到新函数
    // 使用 memcpy 而非直接赋值,
    // 避免严格别名违规和对齐问题
    memcpy(got_entry, &new_func_addr,
           sizeof(void*));

    // 恢复原始权限
    // (此处简化为 PROT_READ,
    //   实际应还原 orig_prot)
    mprotect(page, page_size, PROT_READ);
}

优点:

  • 不需要卸载/重载 .so,新旧代码可以共存。
  • 不影响正在执行的函数(只有后续调用才走新逻辑)。
  • 侵入性极低,不需要模块化改造,任何动态链接的函数都能被劫持。
  • 对进程的执行流影响极小,一个指针替换就完成。

缺点:

  • 只对动态链接的函数有效,静态链接的函数、内部函数(同一 .so 内的直接调用)不走 GOT,无法劫持。
  • GOT 修改涉及内存权限变更,mprotect 操作页级权限,如果同一页上有其他 GOT 条目,可能影响其他函数的调用安全性。
  • 需要解析 ELF 结构,找到 GOT 中目标函数的位置需要遍历 .rela.plt / .rel.dyn 段,实现复杂度不低。
  • 多线程安全隐患,修改 GOT 和恢复只读之间,如果有其他线程调用同一 GOT 条目,可能读到半写入的地址。
  • 调试困难,GOT 被修改后,gdb 的符号解析可能指向错误的地址。

方案 C:代码段直接补丁(mprotect + 机器码改写)

思想:最硬核的方案,直接修改进程代码段中的机器指令。把目标函数的前几条指令改成一个跳转(jmp),跳到新函数的地址。这和 kpatch / livepatch 内核热补丁的原理相同。

实现要点:

#include <sys/mman.h>  // mprotect, PROT_*
#include <unistd.h>    // sysconf
#include <stdint.h>    // uint8_t, uint64_t
#include <string.h>    // memcpy

// ============================================================
// patch_function:用跳转指令覆盖目标函数入口
// 劫持执行流到新函数
// ============================================================
// 原理:x86-64 的无条件间接跳转指令
//       `jmp [rip+0]`(编码 FF 25 00 00 00 00)
//       会从紧随其后的 8 字节内存中读取目标地址并跳转。
//       将这 14 字节写入目标函数入口,所有调用该函数的
//       代码都会先执行这条 jmp 指令,跳转到新函数——
//       这就是"函数级热补丁"的底层机制。
//
//       与 kpatch / livepatch 内核热补丁同源,
//       区别仅在内核空间 vs 用户空间。
void patch_function(void* old_func,
                    void* new_func)
{
    // 获取内存页大小(通常 4096)
    long page_size = sysconf(_SC_PAGESIZE);

    // 将函数地址向下对齐到页边界
    // mprotect 以页为单位
    void* page = (void*)(
        (uintptr_t)old_func & ~(page_size - 1));

    // 代码段(.text)默认权限是
    // PROT_READ | PROT_EXEC,不可写。
    // 要修改其中的指令,必须先通过 mprotect
    // 添加 PROT_WRITE 权限。
    // 注意:PaX / Grsecurity 等安全加固内核
    // 会拒绝此操作,导致热补丁失效。
    mprotect(page, page_size,
             PROT_READ | PROT_WRITE | PROT_EXEC);

    // 构造跳转指令——使用字节数组而非结构体,
    // 避免内存对齐 padding 问题
    // (结构体 uint8_t[2] + uint32_t 之间会有
    //   2 字节对齐填充,破坏指令编码)
    //
    // 指令编码布局(共 14 字节):
    //   字节 0-1: FF 25
    //     → jmp [rip+disp32] 操作码
    //   字节 2-5: 00 00 00 00
    //     → disp32=0,地址紧跟在 disp32 之后
    //   字节 6-13: 新函数地址
    //     → 8 字节绝对地址,jmp 读取后跳转
    uint8_t patch[14] = {
        0xFF, 0x25, 0x00, 0x00, 0x00, 0x00,
        0, 0, 0, 0, 0, 0, 0, 0
    };
    // 新函数地址
    uint64_t target = (uint64_t)new_func;
    // 写入新函数地址到字节 6-13
    memcpy(patch + 6, &target, sizeof(target));

    // 备份原始指令(回滚时需要!)
    // 此处省略备份代码,实际工程必须实现
    // uint8_t backup[14];
    // memcpy(backup, old_func, 14);

    // 用跳转指令覆盖目标函数入口的前 14 字节
    // 注意:如果目标函数本身不足 14 字节,
    // 会溢出到下一个函数——必须检查
    memcpy(old_func, patch, sizeof(patch));

    // 恢复代码段为只读+可执行权限
    mprotect(page, page_size,
             PROT_READ | PROT_EXEC);

    // 指令缓存刷新(架构相关):
    // x86-64 CPU 自动维护 I-cache / D-cache
    // 一致性,无需手动刷新。
    // 但 ARM / RISC-V 等架构的 I-cache 与
    // D-cache 独立,修改代码后必须调用
    // __builtin___clear_cache 刷新指令缓存,
    // 否则 CPU 可能仍执行旧的缓存指令。
    // __builtin___clear_cache(
    //     (char*)old_func,
    //     (char*)old_func + sizeof(patch));
}

优点:

  • 最通用,不管是动态链接还是静态链接、不管是外部函数还是内部函数,只要能拿到函数地址就能补丁。
  • 最轻量,不需要加载新 .so,不需要工厂函数,不需要接口类。
  • 即时生效,修改完成后,所有调用都会跳到新逻辑。
  • 内核级方案的同源技术,kpatch / kGraft / livepatch 就是用的这套原理。

缺点:

  • 极度危险,改错了指令进程直接崩溃,没有回退机制(原始指令已被覆盖)。
  • 多线程安全隐患更大,写入跳转指令期间,其他线程执行被覆盖区域的指令会 SIGILL。
  • 需要保存原始指令,回滚必须事先备份被覆盖的原始指令。
  • 架构相关,x86、ARM、RISC-V 的跳转指令格式不同,每种架构都要单独实现。
  • 函数长度限制,如果目标函数本身小于跳转指令长度(x86-64 上 14 字节),补丁会溢出到下一个函数。
  • 与安全机制冲突,PaX / Grsecurity 等加固内核禁止对代码段的 mprotect 写操作。

方案 D:信号驱动的模块替换

思想:方案 A 的变体,用信号(SIGUSR1 等)作为触发器。进程在信号处理器中标记”需要更新”,主循环中检查标记并执行 dlclose + dlopen + 状态恢复。

实现要点:

#include <signal.h>   // sigaction, SIGUSR1
#include <atomic>     // std::atomic
#include <iostream>

// 注意:此示例依赖方案 A 中的 ModuleLoader 和
//       hot_module.h,需配合使用才能编译。
//       以下为信号驱动热更新的核心逻辑。

class HotUpdater {
    // 原子标志:信号处理器写 true,主循环读并清零
    //
    // 原理:std::atomic<bool> 的 store/load 是原子
    //       的,信号处理器和主循环可以安全地并发
    //       访问此变量,无需额外锁。
    //       注意:严格来说 C++ 标准不保证 atomic 在
    //       信号处理器中的安全性,但在 Linux +
    //       GCC/Clang 上,lock-free 的 atomic 操作
    //       是 async-signal-safe 的。
    std::atomic<bool> _pending_update{false};
    // 复用方案 A 的 ModuleLoader
    ModuleLoader _loader;

public:
    // 信号处理器调用此函数
    // 仅设置标志,不做任何 I/O 或内存分配
    void request_update() {
        _pending_update.store(true);
    }

    // 在主循环的安全同步点检查标志
    // 并执行实际热更新
    //
    // 原理:dlopen / dlclose / new / cout 都不是
    //       async-signal-safe 函数,如果在信号处理
    //       器中直接调用,可能导致死锁或堆损坏。
    //       正确做法:信号处理器只设标记,主循环在
    //       安全点检查标记后执行。
    void check_update() {
        // exchange(false):原子地读取当前值
        // 并设为 false
        // 返回旧值——如果旧值为 true
        // 表示有待处理的更新
        if (!_pending_update.exchange(false))
            return;

        std::cout << "[hot-update] "
                     "reloading module...\n";
        // 在主线程安全地执行 dlopen + dlsym
        _loader.load("./module_v2.so");
        std::cout << "[hot-update] version="
                  << _loader.module()->version()
                  << "\n";
    }

    // 主循环——在每次迭代中检查热更新标记
    void run() {
        while (true) {
            // 检查是否有待处理的热更新
            check_update();
            if (_loader.module()) {
                // 正常业务逻辑
                _loader.module()->process("tick");
            }
            // 注意:如果主循环阻塞在 epoll_wait
            // 等系统调用中,check_update 可能迟迟
            // 不被执行。
            // 解决方案:用 signalfd 把信号转为 fd,
            // 纳入 epoll 监听。
        }
    }
};

// 全局指针,供信号处理器访问
HotUpdater* g_updater = nullptr;

// SIGUSR1 信号处理器——只做标记
// 不做实际更新操作
//
// 原理:信号处理器中只能调用 async-signal-safe
//       函数(见 man 7 signal-safety)。
//       dlopen、malloc、cout 均不在此列,
//       因此只设置原子标志。
void sigusr1_handler(int) {
    if (g_updater) {
        // 仅设置 _pending_update = true
        g_updater->request_update();
    }
}

// ============================================================
// 在 main 中注册信号处理器(伪代码)
// ============================================================
// struct sigaction sa{};
// sa.sa_handler = sigusr1_handler;
// sigemptyset(&sa.sa_mask);
// // 被信号中断的系统调用自动重启
// sa.sa_flags = SA_RESTART;
// sigaction(SIGUSR1, &sa, nullptr);

优点:

  • 更新时机由外部信号触发,运维友好。
  • 信号处理器只做标记、主循环做实际操作,避免 async-signal-safe 问题。
  • 与方案 A 的模块化架构兼容。

缺点:

  • 继承方案 A 的所有缺点(ABI 兼容性、全局变量丢失等)。
  • 信号可能丢失,标准信号可能只递送一次。
  • 主循环必须有空闲检查点,阻塞在 epoll_wait 等操作中时,检查可能迟迟不执行。
  • 状态保存/恢复复杂,模块的全局状态需要序列化到外部存储。

方案 E:进程级热替换(优雅重启)

思想:不修改单个函数或模块,而是启动一个新进程,把旧进程的状态迁移过去,然后让旧进程优雅退出。这不是传统意义上的”热更新”,但在很多场景下是最实用、最安全的方案。

实现要点:

#include <sys/mman.h>  // shm_open, mmap, munmap
#include <fcntl.h>     // O_CREAT, O_RDWR, O_RDONLY
#include <unistd.h>    // ftruncate, close
#include <string.h>    // memcpy
#include <iostream>

// 编译: g++ -o state_demo state_demo.cpp -lrt
// -lrt: POSIX 共享内存库(某些系统需要)

// 需要在新旧进程间迁移的运行状态
//
// 原理:旧进程退出前把状态写入共享内存,
// 新进程启动后从共享内存读取,实现状态"接力"。
// 共享内存(POSIX shm)在创建进程退出后仍然
// 存活,直到被 shm_unlink 显式删除。
struct SharedState {
    // 当前活跃会话数
    int  session_count;
    // 累计处理的请求数
    long total_processed;
    // 当前配置快照
    char config[256];
};

// ============================================================
// 旧进程调用:将状态保存到 POSIX 共享内存
// ============================================================
void save_state_to_shm(const SharedState& state) {
    // shm_open 创建/打开一个命名共享内存对象
    // (路径在 /dev/shm/ 下)
    // O_CREAT: 不存在则创建
    // O_RDWR: 可读写
    // 0666: 所有用户可读写
    int fd = shm_open("/hot_update_state",
                      O_CREAT | O_RDWR, 0666);
    if (fd < 0) {
        perror("shm_open");
        return;
    }

    // 设置共享内存大小为新进程需要的字节数
    // 新创建的 shm 对象大小为 0,
    // 必须 ftruncate 才能写入
    ftruncate(fd, sizeof(SharedState));

    // 将共享内存映射到本进程地址空间
    // MAP_SHARED: 修改写回底层对象
    //             (其他映射者也可见)
    // PROT_WRITE: 可写入
    void* ptr = mmap(nullptr, sizeof(SharedState),
                     PROT_WRITE, MAP_SHARED, fd, 0);
    if (ptr == MAP_FAILED) {
        perror("mmap");
        close(fd);
        return;
    }

    // 将状态拷贝到共享内存
    memcpy(ptr, &state, sizeof(SharedState));

    // 解除映射并关闭 fd
    // 共享内存对象本身仍然存活,供新进程读取
    munmap(ptr, sizeof(SharedState));
    close(fd);
}

// ============================================================
// 新进程调用:从 POSIX 共享内存恢复状态
// ============================================================
SharedState load_state_from_shm() {
    // 默认初始化,防止读取失败时返回垃圾值
    SharedState state{};

    // 打开已存在的共享内存对象
    // O_RDONLY 只读即可,不需 O_CREAT
    // mode 参数在未指定 O_CREAT 时被忽略,传 0 即可
    int fd = shm_open("/hot_update_state",
                      O_RDONLY, 0);
    if (fd < 0) {
        std::cerr << "No shared state, "
                     "starting fresh.\n";
        // 没有共享状态,以全新状态启动
        return state;
    }

    // 映射为只读
    void* ptr = mmap(nullptr, sizeof(SharedState),
                     PROT_READ, MAP_SHARED, fd, 0);
    if (ptr == MAP_FAILED) {
        perror("mmap");
        close(fd);
        return state;
    }

    // 从共享内存拷贝状态到本地变量
    memcpy(&state, ptr, sizeof(SharedState));

    // 解除映射并关闭 fd
    munmap(ptr, sizeof(SharedState));
    close(fd);

    // 删除共享内存对象
    // 读取一次后即清理,避免残留
    // 如果新进程也崩溃了,
    // 下次启动会走"全新状态"路径
    shm_unlink("/hot_update_state");

    return state;
}

// ============================================================
// 完整流程示意(伪代码)
// ============================================================
// 旧进程(收到更新信号后):
//   1. SharedState state = collect_current_state();
//   2. save_state_to_shm(state);
//   3. 停止接收新请求,
//      等待存量请求处理完毕
//   4. fork() + exec("./new_binary")
//      — 启动新进程
//   5. 旧进程优雅退出
//
// 新进程(启动后):
//   1. SharedState state = load_state_from_shm();
//   2. restore_from_state(state);
//      — 恢复会话、计数器、配置
//   3. 开始接收新请求

优点:

  • 最安全,新进程全新启动,没有旧进程的内存碎片、泄漏、状态残留。
  • 无 ABI 兼容性风险,新进程甚至可以用不同编译器版本或不同语言。
  • 彻底清理,旧进程退出后,所有资源被操作系统回收。
  • 运维成熟,Nginx upgrade、Kubernetes Rolling Update 都是这套思路。

缺点:

  • 不是”热更新”,严格来说是”热迁移”,有新旧进程并存的过渡期。
  • 状态迁移复杂,需要把全部运行状态序列化传递,对有大量内存状态的服务成本高。
  • 过渡期处理复杂,需要协调谁接收新请求、谁处理存量请求。
  • 有短暂服务能力下降,过渡期间系统总资源占用翻倍。

四、方案对比总表

维度方案 A:dlopen方案 B:GOT劫持方案 C:代码段补丁方案 D:信号驱动方案 E:优雅重启
实现复杂度
侵入性中(需模块化)低极低中(需模块化+信号)低
更新粒度模块级函数级函数级模块级进程级
多线程安全需协调卸载时机有窗口风险风险最大需协调卸载时机天然安全
正在执行函数不能替换不影响有崩溃风险不能替换新进程
ABI 稳定性要求严格不涉及不涉及要求严格不涉及
回滚能力重载旧 .so恢复 GOT需备份原始指令重载旧 .so启动旧进程
适用架构所有x86/ARMx86(需适配ARM)所有所有

五、热更新技术的应用场景

热更新技术在以下场景中特别有用:

1. 游戏服务器

游戏服务器是热更新需求最强烈的场景之一。

一个在线游戏服务器可能有数万玩家同时在线,重启意味着所有玩家断线、游戏进度丢失、玩家体验急剧下降。

  • 典型做法:方案 A(dlopen + 虚函数接口)或方案 D(信号驱动),游戏逻辑层编译成 .so,修复 bug 后替换对应 .so。
  • 业界案例:网易游戏、腾讯游戏的服务端框架大多内置了 .so 热加载机制;Skynet(云风开源的 Lua 游戏服务框架)的热更新机制也被大量 C++ 游戏服务借鉴。

2. 高可用后端服务

金融交易、实时通信、流媒体等 7×24 小时运行的后端服务,停机一分钟都可能造成巨额损失。

  • 典型做法:方案 E(优雅重启)最常见,Nginx、HAProxy、Redis 的在线升级都是这套思路。
  • 偶尔使用方案 B/C:对关键单函数做紧急补丁(如修复一个死锁 bug),用 GOT 劫持或代码段补丁快速止血,后续再走正常升级流程。

3. 嵌入式 / IoT 设备

嵌入式设备往往部署在难以接触的物理位置(卫星、深海传感器、工厂车间设备),停机重启代价极高。

  • 典型做法:方案 C(代码段补丁)最常见,对单个函数做最小化修改,不需要加载新模块。
  • 业界案例:Linux 内核的 livepatch 机制(基于方案 C 的内核空间版本)已被大量嵌入式厂商采用;车载系统的 OTA 更新也借鉴了类似思路。

4. 开发调试加速

开发阶段频繁修改代码、重启进程、等待初始化,非常浪费时间。热更新可以让开发者”改完代码秒生效”。

  • 典型做法:方案 A,开发时把核心逻辑编译成 .so,修改后只需重新编译并重载 .so,主程序不重启。
  • 工具支持:Chromium 的 component build 就是把各模块编译成 .so,方便开发阶段快速迭代;gdb 的 call 命令可以在调试时通过 ptrace 注入函数调用,本质上属于前文原理第 4 节的 ptrace 进程注入机制。

5. 安全补丁紧急推送

当 0-day 漏洞被发现时,需要在最短时间内修复线上服务,传统”发版→部署→重启”流程可能需要数小时。

  • 典型做法:方案 C(代码段补丁),最小改动、最快生效,一个函数级补丁就能堵住漏洞。
  • 业界案例:Linux 内核 livepatch 是官方支持的内核热补丁机制;Solaris 的 kernel patching、Windows 的 hotpatching 也是同类技术。

六、工程实践中的关键问题

不管选哪个方案,工程落地时都会碰到以下共性问题:

1. 状态保存与恢复

热更新的核心矛盾是:代码换了,但数据不能丢。

  • 方案 A/D:模块卸载前,必须把模块内部的全局状态序列化到外部存储(主程序的成员变量、共享内存、文件),新模块加载后反序列化恢复。
  • 方案 E:旧进程退出前,把全部运行状态通过共享内存 / socket / 文件传给新进程。
  • 方案 B/C:不涉及状态丢失,因为只替换函数逻辑,数据仍在原内存位置。

建议:在设计时就区分”逻辑层”和”状态层”,逻辑层尽量无状态或有明确的状态保存接口,状态层用独立的数据结构管理。

2. 多线程安全

几乎所有方案都面临多线程安全问题,在更新过程中,其他线程可能正在执行旧代码或访问旧数据。

  • 方案 A/D:卸载旧模块前,必须确保没有线程在执行旧模块的代码。做法包括:暂停所有工作线程、等待所有旧模块的函数调用返回、用读写锁保护模块访问。
  • 方案 B:修改 GOT 时需要原子写。x86-64 上对齐的 8 字节写入是原子的,GOT 条目通常是对齐的,因此单次 GOT 条目写入在 x86-64 上多数情况是原子的;但跨架构(如 32 位平台)或对齐不良时不保证。实际工程中仍应先暂停所有工作线程再修改,避免竞态窗口。
  • 方案 C:写入跳转指令的 14 字节期间,必须暂停所有可能执行目标函数的线程,否则正在执行被覆盖区域的线程会触发 SIGILL。此外,ARM / RISC-V 等架构修改代码后还需调用 __builtin___clear_cache 刷新指令缓存。
  • 方案 E:过渡期间需要用锁或原子变量协调新旧进程的请求接管。

建议:方案 A/D 最安全,在主循环的空闲检查点做更新,配合线程池的暂停/恢复机制。方案 B/C 需要 pause_all_threads() + resume_all_threads() 的配合。

3. ABI 稳定性

方案 A/D 对 ABI 稳定性要求最严:

  • 虚函数表顺序不能变,不能增删虚函数,不能调整虚函数的声明顺序,不能改变基类继承顺序。
  • 类的大小不能变,不能增删成员变量(除非加在末尾且新成员有默认值)。
  • 编译器版本需一致,不同版本的 GCC 对同一 C++ 代码可能生成不同的虚函数表布局。

建议:接口类设计时只暴露最少必要的虚函数;成员变量放在 Impl 类里,接口类只持有指针。这样接口类本身几乎不变,Impl 类可以随意演进。

4. 符号冲突与命名管理

多个版本的 .so 可能包含同名符号,导致链接混乱。

  • 建议:每个版本的 .so 使用版本化路径(如 module_v1.so / module_v2.so),工厂函数名保持不变(create_module),内部类名可以用命名空间隔离(如 module::v1::HotModuleImpl)。

5. 测试与回滚

热更新一旦失败,必须有快速回滚的手段。

  • 方案 A/D:回滚 = 重载旧版本 .so,前提是旧 .so 文件还在磁盘上。
  • 方案 B:回滚 = 恢复 GOT 中旧函数的地址,前提是旧地址被记录了。
  • 方案 C:回滚 = 把备份的原始指令写回代码段,前提是备份还在。
  • 方案 E:回滚 = 启动旧版本进程,前提是旧二进制还在磁盘上。

建议:每次热更新前,确保回滚所需的数据(旧 .so、旧符号地址、原始指令备份)都准备好了。


七、选型指南

最后给一个实战选型口诀:

  • 模块化架构、改动涉及多个函数 → 方案 A(dlopen)
  • 最简单、最安全、最容易回滚。前提是你的代码已经按模块化设计。
  • 单函数紧急补丁、不改代码架构 → 方案 B(GOT劫持)
  • 侵入性最低,不需要模块化改造。但只对动态链接的跨 .so 调用有效。
  • 最硬核、最通用、最危险 → 方案 C(代码段补丁)
  • 任何函数都能补丁,但多线程安全风险最大。适合嵌入式、内核补丁等”必须最小改动”的场景。
  • 需要外部触发更新、运维自动化 → 方案 D(信号驱动)
  • 在方案 A 的基础上加信号触发,运维友好。
  • 最安全、最彻底、但不是真正的”热更新” → 方案 E(优雅重启)
  • 不改代码段、不卸载模块,启动新进程迁移状态。适合对安全性要求极高、且状态可序列化的场景。

一个常见组合是:开发阶段用方案 A 快速迭代,线上紧急补丁用方案 B/C 快速止血,日常升级用方案 E 稳妥推进。


八、小结

维度说明
核心问题运行中的进程如何替换旧机器码、让执行流跳到新代码
关键机制dlopen/dlsym、GOT/PLT、mprotect、ptrace、信号
五条路线A. dlopen 模块替换;B. GOT劫持;C. 代码段补丁;D. 信号驱动模块替换;E. 优雅重启
最简单方案 A — 适合模块化架构的日常热更新
最轻量方案 B — 单函数补丁,不改代码架构
最通用方案 C — 任何函数都能补丁,但风险最大
最安全方案 E — 新进程启动,无 ABI 风险
工程关键状态保存恢复、多线程安全、ABI 稳定性、回滚机制

C++ 热更新不像 Python 那样”改完就生效”,它需要在编译型语言的约束下,借助操作系统机制做”外科手术级”的代码替换。

每条路线都有它的适用场景和代价,没有银弹,只有选对了路线的工程师。