这是个经典题。方法有很多,但只有两种是标准 C++,其余都是平台扩展。


一、在 main 之前运行

方法 1:全局/静态对象的构造函数 —— 标准做法

#include <cstdio>

struct Init {
    Init()  { puts("before main"); }
    ~Init() { puts("after main");  }   // 顺带解决"之后运行"
};
static Init init_;                     // 命名空间作用域

int main() { puts("main"); }
before main
main
after main

唯一可移植的方案。 原理见 memory-layout.md:这些构造函数的指针被放进 .init_array,由 C 运行时在 __libc_start_main 里遍历调用。

方法 2:用全局变量的初始化式调用函数

不想定义类时的简写:

static int dummy_ = (init_something(), 0);          // 逗号表达式
static const bool ok_ = (init_something(), true);

更现代的写法是 IIFE + lambda

static const int dummy_ = []{
    init_something();
    load_config();
    return 0;
}();

C++17 起可以放进头文件inline 变量保证全程序只有一份,只初始化一次):

// header.h
inline const int auto_init_ = []{ init_something(); return 0; }();

方法 3:GCC / Clang 的 constructor 属性

__attribute__((constructor))
void before_main() { puts("ctor attribute"); }

__attribute__((constructor(101)))          // 优先级:数字越小越早
void even_earlier() { puts("earlier"); }

__attribute__((destructor(101)))
void after_main() { puts("dtor attribute"); }
优先级 说明
0–100 保留给实现,不要用
101–65535 用户可用,数值越小越先执行(析构则相反)
不指定 视为最低优先级,按链接顺序

本质也是往 .init_array 里塞函数指针,和 C++ 全局对象走同一套机制。它的优势是可以用在纯 C 代码里。

方法 4:MSVC 的 .CRT$XCU

MSVC 不支持 __attribute__,等价物是往 CRT 初始化段里塞函数指针:

#include <cstdio>

static void before_main() { puts("msvc before main"); }

#pragma section(".CRT$XCU", read)
extern "C" __declspec(allocate(".CRT$XCU"))
void (*p_before_main)(void) = before_main;

原理:链接器把 .CRT$XCA.CRT$XCZ 的所有段按字母序合并成一个函数指针数组,CRT 启动时遍历调用。XCU 里的 U 表示 User。

顺带一提,_MSC_VER 下还有一个更简单的排序工具:

#pragma init_seg(compiler)   // 最早(保留给 CRT)
#pragma init_seg(lib)        // 其次(库用)
#pragma init_seg(user)       // 最后(默认)

写在 .cpp 顶部,控制这个 TU 里所有全局对象的初始化档次。

方法 5:动态库的加载钩子

// Linux
__attribute__((constructor)) void on_load()   { }
__attribute__((destructor))  void on_unload() { }
// 老式写法:定义 _init / _fini(已不推荐)

// Windows
BOOL WINAPI DllMain(HINSTANCE, DWORD reason, LPVOID) {
    switch (reason) {
    case DLL_PROCESS_ATTACH: /* 加载时 */ break;
    case DLL_PROCESS_DETACH: /* 卸载时 */ break;
    }
    return TRUE;
}

注意 DllMain 有严格限制(loader lock):不能调用 LoadLibrary、不能创建线程、不能等待同步对象,否则死锁。

跨平台封装

#if defined(_MSC_VER)
    #define ON_STARTUP(fn)                                          \
        static void fn(void);                                       \
        __pragma(section(".CRT$XCU", read))                         \
        extern "C" __declspec(allocate(".CRT$XCU"))                 \
            void (*fn##_ptr)(void) = fn;                            \
        static void fn(void)
#else
    #define ON_STARTUP(fn)                                          \
        __attribute__((constructor)) static void fn(void);           \
        static void fn(void)
#endif

ON_STARTUP(my_init) { puts("runs before main"); }

(不过在 C++ 里,直接用方法 1 或 2 更简单也更可移植,这套宏主要是给 C 代码用的。)


二、在 main 之后运行

方法 1:全局对象的析构函数 —— 标准做法

如上面的 Init::~Init()。按构造完成的反序执行。

方法 2:atexit() —— C 标准

#include <cstdlib>

void cleanup1() { puts("cleanup1"); }
void cleanup2() { puts("cleanup2"); }

int main() {
    atexit(cleanup1);
    atexit(cleanup2);
    return 0;
}
// 输出:cleanup2  cleanup1   ← LIFO(后进先出)

限制:

  • 只能注册无参无返回的函数void(*)())—— 想传数据只能靠全局变量
  • 标准只保证至少能注册 32 个(glibc 实际是动态扩展的)
  • 注册顺序的反序执行

方法 3:__attribute__((destructor))(GCC/Clang)

见上,塞进 .fini_array

方法 4:__cxa_atexit —— 底层机制

这是 C++ 全局对象析构的实际实现。它比 atexit 多带一个对象指针参数:

extern "C" int __cxa_atexit(void (*f)(void*), void* arg, void* dso_handle);

编译器为每个需要析构的静态对象自动调用它。关键点:atexit 注册的函数和全局对象的析构函数在同一条链上,按注册的反序统一执行

struct A { ~A() { puts("~A"); } };
void f() { puts("f"); }

A a1;                          // 构造时注册 ~a1
int main() {
    atexit(f);                 // 注册 f(在 ~a1 之后)
    static A a2;               // 构造时注册 ~a2(最后)
}
// 输出:~A(a2)  f  ~A(a1)     ← 严格按注册的反序

方法 5:std::at_quick_exit(C++11)

配合 std::quick_exit() 使用,用于"快速退出但仍做少量清理"的场景:

std::at_quick_exit(fast_cleanup);
std::quick_exit(0);            // 【不】调用全局析构和 atexit,只调 at_quick_exit

三、完整的执行顺序

程序启动
  │
  ├─ 【静态初始化】零初始化 + 常量初始化(无代码执行)
  │
  ├─ 【动态初始化】遍历 .init_array / .CRT$XC*
  │     ├─ __attribute__((constructor)) 按优先级
  │     └─ 全局对象构造函数(同 TU 内按定义顺序,跨 TU 【顺序不确定】)
  │        每个有析构的对象在此时 __cxa_atexit 注册自己的析构
  │
  ├─ main()
  │     └─ 块作用域 static 对象在【首次执行到声明处】构造并注册析构
  │
  ├─ main 返回  →  exit(返回值)
  │
  ├─ 【退出处理】__run_exit_handlers:按【注册的反序】统一执行
  │     ├─ atexit / __cxa_atexit 注册的函数
  │     ├─ 全局与局部 static 对象的析构函数
  │     └─ .fini_array(__attribute__((destructor))、库的卸载钩子)
  │
  ├─ _IO_cleanup():flush 所有 stdio 流
  │
  └─ exit_group() 系统调用 → 进程终止

四、六个必须知道的陷阱

陷阱 1:跨 TU 的初始化顺序未定义

static initialization order fiasco —— 最经典的一个:

// a.cpp
Logger logger;
// b.cpp
Config config;              // 构造函数里用了 logger —— 可能还没构造!

标准只保证同一 TU 内按定义顺序,跨 TU 完全不确定(取决于链接顺序)。

解法:Meyers' Singleton —— 用块作用域 static 的惰性初始化:

Logger& getLogger() {
    static Logger instance;      // 首次调用时才构造,顺序由调用关系自然保证
    return instance;             // C++11 起还保证线程安全
}

陷阱 2:析构顺序 fiasco

反过来同样成立 —— A 的析构函数里用了 B,而 B 可能已经析构了。

解法:故意不销毁(leaky singleton)

Logger& getLogger() {
    static Logger* p = new Logger();   // 【永不 delete】
    return *p;
}

进程退出时操作系统会回收全部内存,所以这不是真正的泄漏。Google C++ Style Guide 明确推荐这种做法(用 absl::NoDestructor 或裸 new),理由就是避免不可控的析构顺序

陷阱 3:这几种退出方式不会触发析构和 atexit

方式 全局析构 atexit stdio flush
return from main / std::exit()
std::quick_exit() ✗(只跑 at_quick_exit
std::_Exit() / _exit()
std::abort() / 未捕获异常 / 信号

所以 abort() 或崩溃时,没带 \nprintf 输出全部丢失(行缓冲没 flush)。调试打印要用 stderr 或手动 fflush

陷阱 4:exit() 在多线程程序里很危险

std::exit 只终止调用它的线程的执行流,其他线程仍在运行。此时全局对象正在被析构 —— 其他线程访问它们就是 use-after-destruction。

C++11 之后推荐从 main 正常返回,并在此之前 join 所有线程。

陷阱 5:main 之前能用 std::cout

能。 <iostream> 里藏着一个静态对象:

static std::ios_base::Init __ioinit;   // 每个包含 <iostream> 的 TU 都有一份

它保证在任何包含了该头文件的 TU 的全局对象构造之前,cout/cin/cerr 已可用。这是标准明确规定的([ios::Init])。

printf 就没有这个保证 —— 虽然实践中 glibc 的 stdio 在更早的阶段就初始化好了。

陷阱 6:优先级只在模块内可靠

__attribute__((constructor(101))) void f();   // a.cpp
__attribute__((constructor(102))) void g();   // b.cpp

同一个可执行文件里,链接器会把 .init_array 按优先级排序,所以有效。但跨动态库就不保证了 —— 库的加载顺序由依赖关系决定,与优先级无关。


五、实战:自注册工厂

这是"main 之前运行"最常见的真实用途 —— 让新类型的注册代码和类型定义放在一起,不用改中央的注册表:

// factory.h
class Factory {
public:
    using Creator = std::function<std::unique_ptr<Shape>()>;
    static bool reg(std::string name, Creator c) {
        registry()[std::move(name)] = std::move(c);
        return true;
    }
    static std::unique_ptr<Shape> create(const std::string& name) {
        auto it = registry().find(name);
        return it != registry().end() ? it->second() : nullptr;
    }
private:
    // ★ 必须用 Meyers' Singleton —— 否则可能在 registry 构造前就有人来注册
    static std::map<std::string, Creator>& registry() {
        static std::map<std::string, Creator> m;
        return m;
    }
};

// circle.cpp —— 新增类型时只改这一个文件
namespace {
    const bool registered_ = Factory::reg("Circle", []{
        return std::make_unique<Circle>();
    });
}

这里有个大坑:静态库会把它优化掉

如果 circle.cpp 被编译进静态库 libshapes.a,链接器扫描时发现没有任何符号引用 circle.o,就根本不会把它链进来 —— 注册代码从未执行,create("Circle") 返回 nullptr

(回忆 memory-layout.md 第四章:静态库链接是按需拉取 .o,不是全部拷贝。)

解法:

# GCC/Clang
g++ main.o -Wl,--whole-archive -lshapes -Wl,--no-whole-archive

# MSVC
link main.obj /WHOLEARCHIVE:shapes.lib

# CMake
target_link_libraries(app PRIVATE "$<LINK_LIBRARY:WHOLE_ARCHIVE,shapes>")   # 3.24+

或者干脆改用动态库.so/.dll 整个加载,不做按需裁剪),或者在某个必然被链接的地方强制引用一下那个符号。

这个坑在插件架构、测试框架(Google Test 的 TEST() 宏就是这个套路)、序列化库里反复出现。


六、速查表

需求 方法 可移植性
main 前,最标准 全局对象构造函数 ✅ 标准
main 前,简写 static const int _ = []{...}(); ✅ 标准
main 前,头文件里 inline const int _ = []{...}(); ✅ C++17
main 前,纯 C __attribute__((constructor)) GCC/Clang
main 前,MSVC .CRT$XCU#pragma init_seg MSVC
main 前,控制顺序 优先级参数(仅模块内可靠 平台扩展
main 后,最标准 全局对象析构函数 ✅ 标准
main 后,注册回调 atexit() ✅ 标准(C)
main 后,带参数 __cxa_atexit(编译器自动用) Itanium ABI
main 后,快速退出 std::at_quick_exit + quick_exit ✅ C++11
避免顺序问题 Meyers' Singleton(构造)/ leaky singleton(析构) ✅ 标准

一句话回答

标准做法只有一条:利用静态存储期对象的构造函数(main 前)和析构函数(main 后)。 编译器把构造函数指针放进 .init_array,由 C 运行时在 __libc_start_main 里遍历调用;析构则通过 __cxa_atexit 注册,退出时与 atexit 的回调在同一条链上按注册的反序执行。 GCC/Clang 的 __attribute__((constructor/destructor)) 和 MSVC 的 .CRT$XCU 是同一套机制的非标准入口,主要给 C 代码用。 最大的坑是跨 TU 顺序不确定 —— 构造侧用 Meyers' Singleton 解决,析构侧用"故意不销毁"解决。另外 abort / _exit / quick_exit不会触发这些清理。