需要预先学习:


1. 准备工作

这篇只用手写命令,不涉及 CMake、Meson、Bazel 这类构建系统。目标是把"每一步发生了什么"看清楚,而不是记住某个构建工具的配置写法。


1.1 示例工程

准备一个最小工程,目录结构如下:

project/
├── include/
│   └── add.h
├── src/
│   ├── add.cpp
│   └── main.cpp
└── build/

include/add.h:

#pragma once

int add(int a, int b);
int sub(int a, int b);

src/add.cpp:

#include "add.h"

int add(int a, int b)
{
    return a + b;
}

int sub(int a, int b)
{
    return a - b;
}

src/main.cpp:

#include <cstdio>
#include "add.h"

int main()
{
    std::printf("add(2, 3) = %d\n", add(2, 3));
    std::printf("sub(2, 3) = %d\n", sub(2, 3));
    return 0;
}

先确认工具链:

g++ --version
ar --version
ld --version

在 Windows 上确认 MSVC:

cl
lib
link
dumpbin

如果这些命令找不到,需要打开 “Developer Command Prompt for VS”,或者先运行 vcvars64.bat。

注:后面的命令默认在工程根目录 project/ 下执行。Linux 用 / 分隔路径,Windows 用 \;MSVC 的命令行参数用 / 开头,路径用 \ 更稳妥。两种写法不要混。


2. GCC / Linux 实操


2.1 静态库:从 .o 到 .a

编译目标文件

g++ -O2 -Wall -Wextra -Iinclude -c src/add.cpp -o build/add.o

参数说明:

参数 含义
-O2 优化级别。库的优化选项会影响 ABI 吗?通常不会,但 -fno-exceptions、-fno-rtti、-D_GLIBCXX_USE_CXX11_ABI 会影响
-Wall -Wextra 打开常用警告,早发现接口问题
-Iinclude 头文件搜索路径
-c 只编译,不链接
-o build/add.o 输出目标文件

检查目标文件:

file build/add.o
nm -C build/add.o
readelf -h build/add.o | head

nm -C 应该能看到:

0000000000000000 T add(int, int)
0000000000000020 T sub(int, int)

符号是全局的 T,说明其他翻译单元可以引用。

打包成静态库

ar rcs build/libadd.a build/add.o

ar 的命名规则是 lib + 库名 + .a。链接时用 -ladd,链接器会自动补上 lib 和 .a。

检查归档:

ar t build/libadd.a          # 列出成员
nm -s build/libadd.a         # 查看符号索引
size build/add.o             # 查看节区大小

输出类似:

$ ar t build/libadd.a
add.o

$ nm -s build/libadd.a
Archive index:
_Z3subii in add.o
_Z3addii in add.o

add.o:
0000000000000000 T _Z3addii
0000000000000020 T _Z3subii

链接静态库

g++ -O2 -Iinclude src/main.cpp -Lbuild -ladd -o build/app_static

参数说明:

参数 含义
src/main.cpp 先编译成 main.o,再参与链接
-Lbuild 库搜索路径
-ladd 链接 libadd.a 或 libadd.so
-o build/app_static 输出可执行文件

运行:

./build/app_static
add(2, 3) = 5
sub(2, 3) = -1

检查链接结果:

ldd build/app_static
nm -C build/app_static | grep -E 'add|sub'

ldd 里不应该出现 libadd,因为代码已经复制进可执行文件。nm 里能看到 add 和 sub 的本地定义:

0000000000001169 T add(int, int)
0000000000001181 T sub(int, int)

静态库与动态库同名时的选择

如果 build/ 下同时有 libadd.a 和 libadd.so,-ladd 默认选 .so。想强制静态链接,有两种写法:

# 写法一:在命令行上切换 Bstatic/Bdynamic
g++ -O2 -Iinclude src/main.cpp -Lbuild -Wl,-Bstatic -ladd -Wl,-Bdynamic -o build/app_static

# 写法二:直接指定文件名
g++ -O2 -Iinclude src/main.cpp build/libadd.a -o build/app_static

第一种写法里,-Wl,-Bstatic 之后的 -l 都按静态库找,直到 -Wl,-Bdynamic 恢复。注意 libc、libstdc++ 等系统库也要能静态找到,否则会报 cannot find -lstdc++ 之类。

第二种写法最直接,不依赖搜索顺序,适合脚本里固定链接某个文件。

完全静态链接

g++ -O2 -Iinclude src/main.cpp -Lbuild -ladd -static -o build/app_static_full

-static 会把 libc、libstdc++ 也静态链接进来。检查:

ldd build/app_static_full
not a dynamic executable

体积会明显变大:

ls -lh build/app_static build/app_static_full
-rwxr-xr-x 1 user user  17K build/app_static
-rwxr-xr-x 1 user user 2.2M build/app_static_full

完全静态链接的注意事项在前一篇讲过:getaddrinfo、dlopen、NSS 等功能会退化,glibc 的许可证也要确认。

只静态链接 C++ 运行时

更常见的折中是:

g++ -O2 -Iinclude src/main.cpp -Lbuild -ladd \
    -static-libgcc -static-libstdc++ -o build/app_static_runtime

这样只把 libgcc 和 libstdc++ 静态链进来,libc 仍然是动态的,部署时不用带 libstdc++.so.6。


2.2 动态库:从 .o 到 .so

编译位置无关代码

g++ -O2 -Wall -Wextra -fPIC -Iinclude -c src/add.cpp -o build/add_pic.o

-fPIC 是必须的。忘记加的报错:

/usr/bin/ld: build/add.o: relocation R_X86_64_32S against `.rodata' can not be used when making a shared object; recompile with -fPIC

-fPIC 和 -fpic 在 x86-64 上基本等价。嵌入式平台(ARM、MIPS)上 -fpic 的 GOT 更小,但有 32KB 限制,库大了要用 -fPIC。

生成动态库

g++ -shared -Wl,-soname,libadd.so.1 -o build/libadd.so.1.0.0 build/add_pic.o

参数说明:

参数 含义
-shared 生成共享库
-Wl,-soname,libadd.so.1 把 soname 写进 .dynamic;-Wl, 表示把后面的参数传给链接器
-o libadd.so.1.0.0 真实文件名,带完整版本号

检查:

readelf -d build/libadd.so.1.0.0 | grep -E 'SONAME|NEEDED'
0x0000000000000001 (NEEDED)             Shared library: [libstdc++.so.6]
0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
0x000000000000000e (SONAME)             Library soname: [libadd.so.1]

SONAME 是 libadd.so.1,将来链接这个库的程序,DT_NEEDED 里记录的就是它。

创建链接名

链接器用 -ladd 找的是 libadd.so,不是 libadd.so.1。所以要建两个符号链接:

ln -sf libadd.so.1.0.0 build/libadd.so.1
ln -sf libadd.so.1 build/libadd.so

目录结构:

build/
├── libadd.so       -> libadd.so.1
├── libadd.so.1     -> libadd.so.1.0.0
└── libadd.so.1.0.0
  • libadd.so:链接名,编译时需要,运行时不需要
  • libadd.so.1:soname,运行时需要
  • libadd.so.1.0.0:真实文件

链接动态库

g++ -O2 -Iinclude src/main.cpp -Lbuild -ladd -o build/app_shared

检查记录的是 soname 而不是文件名:

readelf -d build/app_shared | grep NEEDED
0x0000000000000001 (NEEDED)             Shared library: [libadd.so.1]
0x0000000000000001 (NEEDED)             Shared library: [libstdc++.so.6]
0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]

运行

直接运行会失败:

./build/app_shared
./build/app_shared: error while loading shared libraries: libadd.so.1: cannot open shared object file: No such file or directory

因为 build/ 不在默认搜索路径里。三种解决办法:

1. 临时设置 LD_LIBRARY_PATH:

LD_LIBRARY_PATH=build ./build/app_shared

2. 写入 rpath,把库目录固定在可执行文件里:

g++ -O2 -Iinclude src/main.cpp -Lbuild -ladd \
    -Wl,-rpath,'$ORIGIN' -o build/app_shared

$ORIGIN 表示可执行文件所在目录。单引号必须加,否则 shell 会把 $ORIGIN 展开成空。

检查:

readelf -d build/app_shared | grep -E 'RPATH|RUNPATH'
0x000000000000001d (RUNPATH)            Library runpath: [$ORIGIN]

3. 安装到系统目录并更新缓存:

sudo install -m 755 build/libadd.so.1.0.0 /usr/local/lib/
sudo ln -sf libadd.so.1.0.0 /usr/local/lib/libadd.so.1
sudo ln -sf libadd.so.1 /usr/local/lib/libadd.so
sudo ldconfig

然后直接运行:

./build/app_shared

soname 与版本升级

假设你要发布 1.1.0,只是修了 bug,ABI 没变:

g++ -shared -Wl,-soname,libadd.so.1 -o build/libadd.so.1.1.0 build/add_pic.o
ln -sf libadd.so.1.1.0 build/libadd.so.1

老程序不需要重新链接,因为 DT_NEEDED 里还是 libadd.so.1。

如果改了 ABI,比如删了一个导出函数、改了参数类型:

g++ -shared -Wl,-soname,libadd.so.2 -o build/libadd.so.2.0.0 build/add_pic.o
ln -sf libadd.so.2.0.0 build/libadd.so.2

新程序链接 libadd.so.2,老程序继续用 libadd.so.1,两个版本可以并存。

注:soname 只在你链接可执行文件时被读入 DT_NEEDED。如果库没有设置 soname,DT_NEEDED 记录的是你链接时给的文件名(可能是 libadd.so),升级时容易出问题。生产库一律设置 soname。

检查依赖树

ldd build/app_shared
linux-vdso.so.1 (0x00007ffd...)
libadd.so.1 => /home/user/project/build/libadd.so.1 (0x00007f...)
libstdc++.so.6 => /lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f...)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...)
/lib64/ld-linux-x86-64.so.2 (0x00007f...)

ldd 会递归列出所有依赖。注意 ldd 实际上会运行动态加载器,对不可信的可执行文件有风险;更安全的做法是:

objdump -p build/app_shared | grep NEEDED
readelf -d build/app_shared | grep NEEDED

查看加载器实际搜索了哪些路径:

LD_DEBUG=libs ./build/app_shared

输出会列出每次 search path、trying file、calling init,排查"为什么加载了错误的库"非常有用。

pkg-config

自己写 -I、-L、-l 很容易漏。pkg-config 把编译和链接参数记录在 .pc 文件里,使用方一行命令就能拿到:

pkg-config --cflags --libs add

一个最小的 add.pc:

prefix=/usr/local
libdir=${prefix}/lib
includedir=${prefix}/include

Name: add
Description: A tiny add/sub library
Version: 1.0.0
Libs: -L${libdir} -ladd
Libs.private: -lm
Cflags: -I${includedir}

安装到 $(pkg-config --variable pc_path pkg-config) 包含的目录,或者设置:

export PKG_CONFIG_PATH=/path/to/pc:$PKG_CONFIG_PATH

Libs.private 是静态链接时才需要的额外依赖,pkg-config --static --libs add 会把它加进来。


2.3 C++ 接口的导出

名称修饰

C++ 编译器会把函数名、参数类型、命名空间编码进符号名,这叫名称修饰(name mangling)。GCC 的规则是 Itanium ABI:

int add(int a, int b);                    // _Z3addii
double add(double a, double b);           // _Z3adddd
namespace math { int add(int, int); }     // _ZN4math3addEii
class Calc { public: int add(int, int); } // _ZN4Calc3addEii

用 nm 看:

nm -C build/add.o
nm build/add.o

-C 会把修饰名还原成可读形式。链接器只看修饰后的名字,所以:

  • C++ 调用 C 库:需要 extern "C",关闭修饰
  • C 调用 C++ 库:需要导出一个 extern "C" 的包装函数
  • 不同编译器之间:修饰规则不同,C++ 符号基本不能互操作

extern "C" 包装

把 C++ 实现包在 extern "C" 里:

// add.cpp
#include "add.h"

namespace detail
{
    int add_impl(int a, int b)
    {
        return a + b;
    }
}

extern "C" int add(int a, int b)
{
    return detail::add_impl(a, b);
}

头文件也要一致:

// add.h
#pragma once

#ifdef __cplusplus
extern "C" {
#endif

int add(int a, int b);

#ifdef __cplusplus
}
#endif

编译后符号是 add,没有修饰:

nm build/add.o | grep add
0000000000000000 T add

这样 C 程序、Python 的 ctypes、C# 的 DllImport、Rust 的 extern "C" 都能调用。

可见性控制

下面假设 add.cpp 已经按上一节改成 extern "C" 导出。如果保持 C++ 链接,导出符号会是 _Z3addii 这类修饰名,nm -D 的输出也会不一样。

Linux 的 ELF 默认把所有全局符号放进 .dynsym。一个 C++ 库可能导出几千个符号,包括模板实例、内联函数、内部辅助函数。这会带来:

  • .dynsym 变大,加载变慢
  • 符号冲突概率变高
  • 内部实现被外部看到,ABI 表面积失控

用 -fvisibility=hidden 把默认可见性改成隐藏,再给公开 API 加属性:

// add.h
#pragma once

#if defined(_WIN32)
    #ifdef ADD_EXPORTS
        #define ADD_API __declspec(dllexport)
    #else
        #define ADD_API __declspec(dllimport)
    #endif
#else
    #define ADD_API __attribute__((visibility("default")))
#endif

#ifdef __cplusplus
extern "C" {
#endif

ADD_API int add(int a, int b);
ADD_API int sub(int a, int b);

#ifdef __cplusplus
}
#endif

编译:

g++ -O2 -fPIC -fvisibility=hidden -fvisibility-inlines-hidden -Iinclude \
    -c src/add.cpp -o build/add_hidden.o
g++ -shared -Wl,-soname,libadd.so.1 -o build/libadd.so.1.0.0 build/add_hidden.o

检查导出符号:

nm -D --defined-only build/libadd.so.1.0.0 | c++filt
0000000000001109 T add
000000000000111b T sub

只剩两个公开函数。-fvisibility-inlines-hidden 会隐藏内联函数,减少模板实例的导出。

注:-fvisibility=hidden 也会隐藏异常类的 type_info。如果异常要跨 DSO 传播,异常类必须显式标记为 default 可见性,否则 catch 可能匹配不上。

版本脚本

可见性属性只能控制"默认可见/隐藏",不能给符号打版本。要给符号打版本、同时隐藏其余符号,用版本脚本(version script)。版本脚本里的名字是修饰后的符号名;下面的例子假设 add.cpp 已用 extern "C" 导出。

# build/libadd.map
LIBADD_1.0 {
    global:
        add;
        sub;
    local:
        *;
};

链接:

# 用默认可见性编译,导出哪些符号交给版本脚本决定
g++ -O2 -fPIC -Iinclude -c src/add.cpp -o build/add_capi.o
g++ -shared -Wl,-soname,libadd.so.1 \
    -Wl,--version-script=build/libadd.map \
    -o build/libadd.so.1.0.0 build/add_capi.o

检查:

readelf --dyn-syms -W build/libadd.so.1.0.0 | grep -E 'add|sub'
6: 0000000000001109    18 FUNC    GLOBAL DEFAULT   12 add@@LIBADD_1.0
7: 000000000000111b    18 FUNC    GLOBAL DEFAULT   12 sub@@LIBADD_1.0

@@ 表示默认版本。老程序如果链接的是 add@LIBADD_0.9,升级后仍然能绑定旧版本(前提是库里同时保留旧版本实现)。

版本脚本还有一个副作用:local: *; 会把所有没列出的符号藏起来,包括编译器生成的辅助符号。这正是我们想要的,但要确认没有被 dlsym 使用的符号被误藏。

导出 C++ 类的风险

直接导出 C++ 类,等于把下面这些东西都变成 ABI:

  • 类的内存布局(成员顺序、对齐、虚表指针位置)
  • 虚函数的顺序和数量
  • 内联函数的实现
  • 基类、虚继承、空基类优化
  • 异常规范和 noexcept
  • 成员函数的调用约定
  • 标准库成员的类型(std::string 的 ABI 可能随编译器版本变化)

只要其中任何一项变了,老二进制就可能崩溃。跨编译器(GCC 和 Clang 的 C++ ABI 大体兼容,但和 MSVC 完全不兼容)更是不要想。

如果必须导出类,至少做到:

  1. 用 Pimpl 把实现藏起来,头文件只暴露指针
  2. 所有成员函数非内联,定义放在 .cpp 里
  3. 不在接口里出现 STL 类型
  4. 用工厂函数创建对象,用虚析构或 destroy() 释放
  5. 固定编译器大版本和标准库 ABI

2.4 库的初始化与全局对象

库被加载时,除了映射代码和数据,还要执行初始化代码。初始化顺序出问题,往往表现为"单例是空的"“注册表少了一项”“程序在 main 之前就崩了”。

Linux 的构造函数

GCC 支持三种写法:

// 1. 属性写法
__attribute__((constructor))
void lib_init()
{
    // 库加载时执行
}

__attribute__((destructor))
void lib_fini()
{
    // 库卸载或进程退出时执行
}

// 2. 带优先级(0-100 保留给系统)
__attribute__((constructor(101)))
void early_init()
{
}

// 3. C++ 全局对象
struct Registry
{
    Registry()  { /* 构造 */ }
    ~Registry() { /* 析构 */ }
};

static Registry g_registry;

它们最终都会变成 .init_array / .fini_array 里的函数指针:

readelf -x .init_array build/libadd.so.1.0.0
Hex dump of section '.init_array':
  0x00003dd0 70100000 00000000 80100000 00000000

两个 8 字节指针,指向两个初始化函数。加载器处理完重定位后,按数组顺序调用;.fini_array 按相反顺序调用。

静态库里的构造函数

静态库的成员只有在被引用时才会被链接进来。如果一个 .o 里只有一个全局对象、没有任何被引用的函数,它不会被拉进来,构造函数自然也不会执行:

// registry.cpp —— 只有全局对象,没有导出函数
struct AutoRegister
{
    AutoRegister() { register_all(); }
};

static AutoRegister g_auto_register;
# 这样链接,registry.o 不会被拉进来
g++ main.o -lregistry -o app

# 强制拉入整个库
g++ main.o -Wl,--whole-archive -lregistry -Wl,--no-whole-archive -o app

更稳妥的做法是提供一个显式初始化函数,让主程序调用:

extern "C" void registry_init();

int main()
{
    registry_init();
    // ...
}

这样链接器能看到引用,成员一定会被拉进来,初始化时机也由你控制。

初始化顺序

C++ 标准只保证:

  • 同一个翻译单元内,全局对象按定义顺序初始化
  • 不同翻译单元之间,顺序未指定

静态链接时,实际顺序通常跟链接命令行上的目标文件顺序一致,但这不是标准保证的。动态链接时,顺序由依赖关系和加载顺序决定:

  • 如果 A 依赖 B,B 先初始化
  • 两个互不依赖的库,顺序取决于 DT_NEEDED 顺序或 dlopen 顺序
  • 循环依赖的顺序更不可预测

经典的静态初始化顺序问题(static initialization order fiasco):

// logger.cpp
Logger g_logger;              // 全局对象,构造时机不确定

Logger& logger()
{
    return g_logger;
}

// config.cpp
Config g_config(logger());    // 如果 g_logger 还没构造,这里拿到的是未初始化的对象

函数内静态对象(Meyers singleton)在 C++11 之后是线程安全且首次调用时构造的,能避开大部分顺序问题:

Logger& logger()
{
    static Logger instance;    // 第一次调用时构造,C++11 保证线程安全
    return instance;
}

另一种做法是用 std::call_once 或显式的 init() 函数,把初始化时机从"加载时"推迟到"第一次使用"。

Windows 的初始化顺序

MSVC 的 CRT 在 DllMain 之前执行全局 C++ 对象的构造函数。顺序大致是:

  1. 加载器映射 DLL
  2. 应用基址重定位
  3. 解析导入表
  4. 调用 TLS 回调(DLL_PROCESS_ATTACH)
  5. CRT 初始化,执行全局构造函数
  6. 调用用户 DllMain(DLL_PROCESS_ATTACH)

多个 DLL 之间,依赖者先初始化被依赖者。没有依赖关系的 DLL,顺序由加载顺序决定,不要依赖它。

DLL_PROCESS_DETACH 的顺序与初始化相反:后加载的先卸载。进程退出时,lpReserved 非空,表示进程正在终止,此时不要做复杂的清理。

注:在全局构造函数里调用 exit()、创建线程、加载其他 DLL,都是高风险操作。静态库里的全局对象还可能因为成员没被拉取而根本不执行。初始化的第一原则是"简单",复杂逻辑放到显式的 init() 里。


2.5 运行时加载:dlopen

注:下面的例子假设动态库导出的是未修饰的 add。如果按前面的基础示例编译(C++ 链接),符号是 _Z3addii,dlsym 要传 _Z3addii。可以用 nm -D --defined-only build/libadd.so.1 | grep add 确认真实符号名。

基本用法

// load.cpp
#include <dlfcn.h>
#include <cstdio>

int main()
{
    void* handle = dlopen("libadd.so.1", RTLD_NOW | RTLD_LOCAL);
    if (!handle)
    {
        std::fprintf(stderr, "dlopen failed: %s\n", dlerror());
        return 1;
    }

    dlerror();    // 清除之前的错误状态

    using AddFn = int (*)(int, int);
    AddFn add = reinterpret_cast<AddFn>(dlsym(handle, "add"));
    const char* error = dlerror();
    if (error)
    {
        std::fprintf(stderr, "dlsym failed: %s\n", error);
        dlclose(handle);
        return 1;
    }

    std::printf("add(2, 3) = %d\n", add(2, 3));

    dlclose(handle);
    return 0;
}

编译:

g++ -O2 load.cpp -ldl -o build/load

运行时用 LD_LIBRARY_PATH 指定库目录:

LD_LIBRARY_PATH=build ./build/load

glibc 2.34 之后 dlopen 等函数并入了 libc,-ldl 可以省略;老系统需要显式链接 libdl。

dlopen 的标志

标志 含义
RTLD_LAZY 延迟绑定,第一次调用时才解析函数符号
RTLD_NOW 立即绑定,加载时解析所有符号;推荐,错误早暴露
RTLD_GLOBAL 把库的符号加入全局作用域,后续加载的库可以用
RTLD_LOCAL 符号只在本库和依赖内可见(默认)
RTLD_NODELETE dlclose 时不卸载,保留代码和数据
RTLD_NOLOAD 不加载新库,只在已加载时返回句柄;常用来检测某个库是否已加载
RTLD_DEEPBIND 符号查找优先本库,避免被全局同名符号插入

RTLD_GLOBAL 常用于插件系统:主程序先加载基础库,插件再加载时能用到基础库的符号。但全局符号会互相污染,能不用就不用。

插件模式

插件接口用纯 C,避免 C++ ABI 问题:

// plugin_api.h
#pragma once

#ifdef __cplusplus
extern "C" {
#endif

typedef struct plugin plugin;

plugin* plugin_create(void);
void    plugin_destroy(plugin* p);
const char* plugin_name(plugin* p);
int     plugin_run(plugin* p, int value);

#ifdef __cplusplus
}
#endif

插件实现:

// plugin_add.cpp
#include "plugin_api.h"
#include <cstring>

struct plugin
{
    int calls;
};

extern "C" plugin* plugin_create(void)
{
    return new plugin{0};
}

extern "C" void plugin_destroy(plugin* p)
{
    delete p;
}

extern "C" const char* plugin_name(plugin*)
{
    return "adder";
}

extern "C" int plugin_run(plugin* p, int value)
{
    ++p->calls;
    return value + 1;
}

编译:

g++ -O2 -fPIC -shared -fvisibility=hidden -Iinclude \
    -o build/libplugin_add.so plugin_add.cpp

加载器:

void* handle = dlopen("./libplugin_add.so", RTLD_NOW | RTLD_LOCAL);
auto create  = reinterpret_cast<plugin* (*)(void)>(dlsym(handle, "plugin_create"));
auto destroy = reinterpret_cast<void (*)(plugin*)>(dlsym(handle, "plugin_destroy"));
auto run     = reinterpret_cast<int (*)(plugin*, int)>(dlsym(handle, "plugin_run"));

plugin* p = create();
std::printf("%d\n", run(p, 41));
destroy(p);
dlclose(handle);

主程序符号导出

如果插件需要回调主程序的函数,主程序的符号必须进入 .dynsym。默认情况下,可执行文件的符号不会被动态导出(除了少数被动态库引用的)。用 -rdynamic:

g++ -O2 -rdynamic main.cpp -ldl -o build/app

等价于 -Wl,--export-dynamic。检查:

nm -D build/app | grep my_callback

RTLD_DEFAULT 与 RTLD_NEXT

dlsym 的第一个参数不一定是 dlopen 返回的句柄,还可以是两个特殊值:

  • RTLD_DEFAULT:从全局作用域开始查找,等价于"按正常符号解析顺序找"
  • RTLD_NEXT:从当前库之后的库开始查找,常用于包装(interpose)系统函数
// 查找主程序或已加载库里的符号
auto fn = reinterpret_cast<AddFn>(dlsym(RTLD_DEFAULT, "add"));

RTLD_NEXT 的经典用法是写一个 malloc 包装:

#define _GNU_SOURCE
#include <dlfcn.h>
#include <cstddef>

extern "C" void* malloc(std::size_t size)
{
    using MallocFn = void* (*)(std::size_t);
    static MallocFn real_malloc = nullptr;
    if (!real_malloc)
    {
        real_malloc = reinterpret_cast<MallocFn>(dlsym(RTLD_NEXT, "malloc"));
    }
    return real_malloc(size);
}

编译成 .so,用 LD_PRELOAD 加载,就能在不修改程序的情况下统计或拦截内存分配。

注:真实的 malloc 包装要小心递归——dlsym 本身可能调用 malloc,第一次解析时可能又进入你的包装函数。常见做法是用 __libc_malloc 作为后备、用一个静态标志位跳过早期分配,或者准备一块静态缓冲区应对 dlsym 期间的内存请求。上面的代码只展示结构,直接用于生产需要补上这些防护。

需要绑定特定符号版本时,用 GNU 扩展 dlvsym:

void* p = dlvsym(handle, "foo", "VERS_1.0");

dladdr 可以反查一个地址属于哪个模块:

#include <dlfcn.h>
#include <cstdio>

Dl_info info;
if (dladdr(reinterpret_cast<void*>(add), &info))
{
    std::printf("module: %s\n", info.dli_fname);
    std::printf("symbol: %s\n", info.dli_sname ? info.dli_sname : "(null)");
}

排查"这个函数到底来自哪个库"时很有用。

卸载的坑

dlclose 只是递减引用计数,计数归零才真正卸载。卸载时:

  • 调用 .fini_array 里的析构函数
  • 解除映射
  • 库里的 thread_local 对象被销毁

不要在有线程还在库内执行时卸载。常见做法:

  • 主程序退出前统一卸载
  • 或者用 RTLD_NODELETE 不卸载
  • 或者在插件卸载前确保所有回调已注销、线程已 join

2.6 GCC 常见问题与诊断

undefined reference

build/main.o: In function `main':
main.cpp:(.text+0x1a): undefined reference to `add(int, int)'
collect2: error: ld returned 1 exit status

按顺序排查:

  1. 库没链接:命令行少了 -ladd
  2. 库顺序错:-ladd 放在了 main.o 前面
  3. 路径错:-Lbuild 没写或写错
  4. 名字修饰不匹配:C 头文件没有 extern "C",C++ 代码生成 _Z3addii,C 库只有 add
  5. 库版本不对:链接到了另一个 libadd.so

诊断命令:

# 看目标文件里缺什么符号
nm -C build/main.o | grep ' U '

# 看库提供了什么符号
nm -C build/libadd.a | grep ' T '
nm -D --defined-only build/libadd.so | c++filt

# 让链接器打印符号解析过程
g++ ... -Wl,--trace-symbol=add

# 生成链接 map,看每个符号来自哪个文件
g++ ... -Wl,-Map=build/app.map

cannot find -ladd

/usr/bin/ld: cannot find -ladd: No such file or directory

原因通常是:

  • -L 路径不对
  • 只有 libadd.so.1,没有链接名 libadd.so
  • 库名拼错(-ladd 找的是 libadd.so 或 libadd.a)

解决:

ls build/libadd*
ln -sf libadd.so.1 build/libadd.so

运行时找不到库

./app: error while loading shared libraries: libadd.so.1: cannot open shared object file: No such file or directory

排查:

# 看程序需要哪个 soname
readelf -d build/app_shared | grep NEEDED

# 看加载器实际搜了哪些路径
LD_DEBUG=libs ./build/app_shared 2>&1 | grep -E 'search path|trying file'

# 看库是否在缓存里
ldconfig -p | grep libadd

# 看 rpath
readelf -d build/app_shared | grep -E 'RPATH|RUNPATH'
patchelf --print-rpath build/app_shared

常见原因:

  • LD_LIBRARY_PATH 没设或设错
  • rpath 里的 $ORIGIN 没加引号,被 shell 展开成空
  • 库装到了 /usr/local/lib,但 /etc/ld.so.conf 没包含它,或者没跑 ldconfig
  • 程序要求的 soname 和你装的库文件名不一致

运行时 undefined symbol

./app: symbol lookup error: ./app: undefined symbol: _Z3addii

这说明库加载成功了,但某个符号在全局作用域里找不到。常见原因:

  • 库的版本太老,没有新加的符号
  • 符号被 -fvisibility=hidden 藏起来了
  • C++ 名称修饰不匹配
  • 依赖库没加载(DT_NEEDED 缺失)

诊断:

# 看库导出了什么
nm -D --defined-only build/libadd.so | c++filt

# 看可执行文件需要什么
nm -D build/app_shared | grep ' U ' | c++filt

# 让加载器打印绑定过程
LD_DEBUG=bindings ./build/app_shared 2>&1 | grep add

# 检查所有未解析符号
ldd -r build/app_shared

relocation R_X86_64_32S ... recompile with -fPIC

完整报错:

/usr/bin/ld: build/add.o: relocation R_X86_64_32S against `.rodata' can not be used when making a shared object; recompile with -fPIC

原因:目标文件不是位置无关代码,里面有绝对地址,无法放进可任意加载的 .so。

解决:

g++ -O2 -fPIC -c src/add.cpp -o build/add.o

relocation R_X86_64_32 ... can not be used when making a PIE object

/usr/bin/ld: build/libadd.a(add.o): relocation R_X86_64_32 against `.rodata' can not be used when making a PIE object; recompile with -fPIE

原因:现代发行版默认生成 PIE 可执行文件,而你链接的静态库是用 -fno-PIC 编译的。

解决:

# 方案一:重新编译静态库,加 -fPIE(或 -fPIC)
g++ -O2 -fPIE -c src/add.cpp -o build/add.o

# 方案二:不生成 PIE
g++ -O2 -no-pie src/main.cpp build/libadd.a -o build/app

方案二适合无法重新编译的第三方静态库。代价是可执行文件失去 ASLR 的一部分保护。

multiple definition

/usr/bin/ld: build/b.o: in function `foo()':
b.cpp:(.text+0x0): multiple definition of `foo()'; build/a.o:a.cpp:(.text+0x0): first defined here

原因:

  • 两个 .cpp 都定义了同一个非 inline 函数
  • 头文件里定义了非 inline 的全局变量或函数,被多个翻译单元包含
  • C 的暂定定义在 -fno-common 下变成强定义

解决:

  • 函数加 inline,或把定义移到 .cpp
  • 变量加 extern 声明,定义只放一个 .cpp
  • 用匿名命名空间或 static 限制内部链接
  • 模板和 inline 函数放进 COMDAT,链接器自动去重

GLIBC_2.xx not found

./app: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found

原因:程序在新版本 glibc 上编译,运行环境是旧版本。glibc 向后兼容(老程序能在新 glibc 上跑),不向前兼容(新程序不能在老 glibc 上跑)。

解决:

  • 在目标环境或更老的发行版上构建
  • 用容器固定构建环境
  • 静态链接 glibc(注意许可证和功能限制)
  • 避免使用新版本才有的符号

GLIBCXX_3.4.xx not found

./app: /usr/lib/x86_64-linux-gnu/libstdc++.so.6: version `GLIBCXX_3.4.29' not found

原因:编译用的 GCC 比运行环境的 libstdc++ 新。

解决:

# 方案一:静态链接 libstdc++
g++ ... -static-libstdc++ -static-libgcc

# 方案二:升级运行环境的 libstdc++
# 方案三:在旧环境编译

检查二进制需要的版本:

objdump -T build/app | grep GLIBCXX | sort -u
strings build/app | grep GLIBCXX

DSO missing from command line

/usr/bin/ld: build/app: undefined reference to `foo'
/usr/bin/ld: note: 'foo' is defined in DSO /lib/libbar.so so try adding it to the linker command line

原因:间接依赖没有显式链接。现代 ld 默认 --no-copy-dt-needed-entries,不会自动把依赖库的符号暴露给可执行文件。

解决:

g++ main.o -lbar -lfoo -o app

cannot allocate memory in static TLS block

./app: cannot allocate memory in static TLS block

原因:某个 dlopen 的库用了 initial-exec TLS 模型,但静态 TLS 空间不够。

解决:

  • 用 -ftls-model=global-dynamic 重新编译该库
  • 在程序启动时尽早加载该库
  • 增大 glibc 的静态 TLS 预留:
GLIBC_TUNABLES=glibc.rtld.optional_static_tls=8192 ./app

链接顺序与 --start-group

静态库有循环依赖时:

g++ main.o -Wl,--start-group -la -lb -Wl,--end-group -o app

链接器会在组内反复扫描。只对确实有循环依赖的库使用,否则链接时间会变长。

链接时包装:--wrap

如果不想用 LD_PRELOAD,而是想在链接时就替换某个符号,可以用 --wrap:

g++ main.o -Wl,--wrap=malloc -o app
#include <cstddef>

extern "C" void* __real_malloc(std::size_t size);

extern "C" void* __wrap_malloc(std::size_t size)
{
    // 统计、日志、调试
    return __real_malloc(size);
}

链接器把所有未定义的 malloc 引用改写成 __wrap_malloc,把 __real_malloc 解析成原始的 malloc。

几个限制:

  • 只影响参与这次链接的目标文件;已经编译好的 .so 内部的 malloc 调用不受影响
  • C++ 符号要写修饰后的名字,通常配合 extern "C" 使用
  • 和 LD_PRELOAD 的区别:--wrap 是链接时的引用重写,LD_PRELOAD 是运行时的全局符号插入

静态库里的符号没有被拉进来

静态库只按需取成员。如果库里的某个 .o 只包含静态构造函数(靠全局对象注册),没有任何符号被引用,它不会被链接进来。

解决:

g++ main.o -Wl,--whole-archive -lregistry -Wl,--no-whole-archive -o app

或者把注册入口做成显式函数,让主程序调用。

查看库的 ABI 信息

# 动态符号
nm -D --defined-only libadd.so | c++filt

# 动态段
readelf -d libadd.so

# 重定位
readelf -r libadd.so

# 程序头
readelf -l libadd.so

# 所有节区
readelf -S libadd.so

# 反汇编
objdump -d libadd.so

# 依赖
objdump -p libadd.so | grep NEEDED

# 符号版本
readelf --dyn-syms -W libadd.so | grep '@@'

-Wl,-Map=build/app.map 生成的 map 文件会列出:

  • 每个输入文件贡献了哪些节区
  • 每个符号最终落在哪个地址
  • 哪个库成员被拉取,为什么被拉取
  • 被丢弃的节区

排查"为什么可执行文件这么大""这个符号到底来自哪"时,map 文件比 nm 更直接。


3. MSVC / Windows 实操


3.1 工具链与命令

MSVC 的命令行工具主要有四个:

命令 作用
cl 编译器驱动,负责编译,也能调用链接器
lib 把 .obj 打包成静态库,或从 .def 生成导入库
link 链接器,生成 exe 或 dll
dumpbin 查看 PE/COFF 文件的结构、符号、导入导出表

这些命令需要在 Developer Command Prompt for VS 里运行,或者先执行对应的 vcvars64.bat。普通的 cmd 或 PowerShell 默认找不到它们。

where cl
where lib
where link
where dumpbin

确认是 x64 工具链:

cl

输出开头会显示:

用于 x64 的 Microsoft (R) C/C++ 优化编译器

如果显示 x86,说明打开的是 32 位开发者命令提示符。32 位编译器生成的 .obj 不能和 64 位库链接,会报 LNK1112。


3.2 静态库

编译 .obj

cl /nologo /c /EHsc /O2 /MD /Iinclude src\add.cpp /Fo:build\add.obj

参数说明:

参数 含义
/nologo 不打印版权信息
/c 只编译,不链接
/EHsc 启用 C++ 异常处理,假定 extern "C" 函数不抛异常
/O2 优化级别
/MD 动态链接 CRT(推荐)
/Iinclude 头文件搜索路径
/Fo:build\add.obj 输出目标文件

/EHsc 几乎是 C++ 项目的必备选项。不加它,编译器会警告 C4530: 使用了 C++ 异常处理程序,但未启用展开语义。

/EHa 会同时捕获 C++ 异常和 Windows 结构化异常(SEH),/EHsc 只捕获 C++ 异常。两者不要混用,同一个进程里的模块最好保持一致。/EHa 会让代码变慢,只有在确实需要捕获 SEH 时才用。

打包 .lib

lib /nologo /OUT:build\add.lib build\add.obj

检查:

lib /list build\add.lib
dumpbin /symbols build\add.lib

dumpbin /symbols 输出类似:

00A 00000000 SECT3  notype ()    External     | ?add@@YAHHH@Z (int __cdecl add(int,int))
00B 00000000 SECT3  notype ()    External     | ?sub@@YAHHH@Z (int __cdecl sub(int,int))

?add@@YAHHH@Z 是 MSVC 的名称修饰结果。YAHHH 里的 A 表示 __cdecl,H 表示 int。GCC 生成的是 _Z3addii,两套规则完全不同,所以 MSVC 和 GCC 的 C++ 目标文件不能直接互相链接。

链接静态库

cl /nologo /EHsc /O2 /MD /Iinclude src\main.cpp build\add.lib /Fe:build\app_static.exe

也可以在源码里写链接指令:

#pragma comment(lib, "add.lib")

#pragma comment(lib, ...) 会往目标文件的 .drectve 节区里写一条链接器指令,链接时等价于在命令行上加 add.lib。它的路径相对于当前工作目录或 /LIBPATH,不适合跨目录的复杂工程;优点是源码自带依赖声明,不会忘记链接。

cl 会把 main.cpp 编译成 main.obj,然后把 main.obj 和 add.lib 一起交给 link.exe。

运行:

build\app_static.exe
add(2, 3) = 5
sub(2, 3) = -1

检查依赖:

dumpbin /dependents build\app_static.exe

app_static.exe 不应该依赖 add.dll,只依赖 KERNEL32.dll、VCRUNTIME140.dll、ucrtbase.dll 等系统或 CRT 库。

cl 只是驱动,真正干活的是 link。可以分两步:

cl /nologo /c /EHsc /O2 /MD /Iinclude src\main.cpp /Fo:build\main.obj
link /nologo /OUT:build\app_static.exe build\main.obj build\add.lib

链接器参数:

参数 含义
/OUT:path 输出文件
/SUBSYSTEM:CONSOLE 控制台程序;默认根据入口点推断
/MACHINE:X64 目标架构
/LIBPATH:dir 库搜索路径
/VERBOSE:LIB 打印搜索了哪些库
/NODEFAULTLIB:name 排除某个默认库
/OPT:REF 删除未引用的函数和数据
/OPT:ICF 合并相同的 COMDAT
/INCREMENTAL:NO 关闭增量链接
/DEBUG 生成 PDB 调试信息

直接调用 link 的好处是参数清晰,排查链接问题时可以把 /VERBOSE:LIB 打开,看链接器到底在找什么。

强制链接整个静态库

和 GCC 的 --whole-archive 对应,MSVC 用 /WHOLEARCHIVE:

link /nologo /OUT:build\app.exe build\main.obj ^
    /WHOLEARCHIVE:build\add.lib

它会把静态库的所有成员都链进来,包括没有被引用的成员。适合静态注册、插件表、需要完整导出符号的场景。也可以只强制某个符号:

link /nologo /OUT:build\app.exe build\main.obj build\add.lib ^
    /INCLUDE:?add@@YAHHH@Z

/INCLUDE 强制把定义该符号的成员拉进来,比 /WHOLEARCHIVE 精确。

运行库选项 /MT 与 /MD

这是 MSVC 项目最容易出问题的地方之一。

选项 CRT 链接方式 每个模块的堆 部署
/MT 静态链接 LIBCMT.lib 各自一份 不需要 VC++ Redistributable
/MTd 静态链接 LIBCMTD.lib 各自一份 Debug 版
/MD 动态链接 MSVCRT.dll / ucrtbase.dll 共享 需要 VC++ Redistributable
/MDd 动态链接 MSVCRTD.dll / ucrtbased.dll 共享 Debug 版

规则:

  1. 同一个进程里所有模块的 /MT、/MD 必须一致。EXE 用 /MD,DLL 用 /MT,会在链接时得到 LNK2038,或者运行时堆崩溃。
  2. Debug 和 Release 不能混。/MTd 和 /MT 是两个不同的 CRT,_ITERATOR_DEBUG_LEVEL 也不同。
  3. 跨 DLL 传递 C++ 对象时,最好统一 /MD。这样 malloc/free、new/delete 走同一个 UCRT 堆。

LNK2038 的典型输出:

add.lib(add.obj) : error LNK2038: 检测到"RuntimeLibrary"的不匹配: 值"MT_StaticRelease"不匹配值"MD_DynamicRelease"

解决办法只有重新编译,不能靠链接选项绕过。

静态库的检查

lib /list build\add.lib
dumpbin /headers build\add.lib
dumpbin /symbols build\add.lib

dumpbin /headers 会显示 FILE HEADER VALUES 和 Archive member name。静态库的成员是普通 COFF 对象,带 .text、.data、.bss、.drectve、.debug$S 等节区。

dumpbin /symbols 里如果看到 __imp_ 前缀,说明这个 .lib 是导入库而不是静态库。


3.3 DLL

__declspec(dllexport)

Windows 的 DLL 默认不导出任何符号。必须在函数、变量或类上显式标记:

// add.cpp
__declspec(dllexport) int add(int a, int b)
{
    return a + b;
}

__declspec(dllexport) int sub(int a, int b)
{
    return a - b;
}

编译成 DLL:

cl /nologo /LD /EHsc /O2 /MD /Iinclude src\add.cpp /Fe:build\add.dll /link /IMPLIB:build\add.lib

/LD 的作用:

  • 定义 _WINDLL 宏
  • 让 cl 调用 link /DLL
  • 生成 .dll、导入库 .lib、导出文件 .exp

检查导出:

dumpbin /exports build\add.dll
ordinal hint RVA      name

      1    0 00001010 add
      2    1 00001020 sub

如果 dumpbin /exports 显示 no exports,说明忘了 __declspec(dllexport),或者 .def 文件没写对。

__declspec(dllimport)

使用 DLL 的一方需要 dllimport:

// add.h
#pragma once

#ifdef ADD_EXPORTS
#define ADD_API __declspec(dllexport)
#else
#define ADD_API __declspec(dllimport)
#endif

#ifdef __cplusplus
extern "C" {
#endif

ADD_API int add(int a, int b);
ADD_API int sub(int a, int b);

#ifdef __cplusplus
}
#endif
  • 编译 DLL 时定义 ADD_EXPORTS,ADD_API 展开为 dllexport
  • 编译使用者时不定义,ADD_API 展开为 dllimport

dllimport 的作用:

  • 函数:编译器知道这个函数在别的 DLL 里,可以直接生成 call QWORD PTR [__imp_add],少一次跳转。不加 dllimport 也能工作,但会走导入库提供的 thunk。
  • 数据:必须加。否则编译器会把 g_count 当成当前模块内的变量,生成直接寻址,链接时报未解析,或者运行时读到错误的地址。
  • 类:告诉编译器类的所有成员都在 DLL 里,可以优化访问;但类布局必须完全一致。

导出数据

// add.h
#ifdef __cplusplus
extern "C" {
#endif

ADD_API int add(int a, int b);
extern ADD_API int g_count;

#ifdef __cplusplus
}
#endif
// add.cpp
ADD_API int g_count = 0;

使用:

#include "add.h"

int main()
{
    g_count = 10;    // 通过 IAT 间接访问 DLL 里的变量
    return 0;
}

dumpbin /exports build\add.dll 会多一项:

3    2 00003000 g_count

导出类

// calculator.h
#pragma once
#include "add.h"

class ADD_API Calculator
{
public:
    Calculator();
    ~Calculator();

    int add(int a, int b);
    int sub(int a, int b);

private:
    int state_;
};

__declspec(dllexport) 加在类上,会导出:

  • 所有非内联成员函数
  • 静态数据成员
  • 虚函数表(vftable)和 RTTI 相关符号

这带来一堆 ABI 约束:类的成员布局、虚函数顺序、基类、std::string 等标准库成员的类型,都必须两边完全一致。所以跨 DLL 导出 C++ 类时,最好:

  1. 类里不要出现 STL 成员
  2. 成员函数不要内联
  3. 用工厂函数创建对象,用虚析构或 destroy() 释放
  4. 两边用同一个 MSVC 大版本和同一套 /MD 选项

如果只是想在 DLL 内部用 C++,对外暴露 C 接口,就不要导出类。用不透明句柄:

extern "C" {
    typedef struct calculator calculator;

    ADD_API calculator* calculator_create(void);
    ADD_API void calculator_destroy(calculator* c);
    ADD_API int calculator_add(calculator* c, int a, int b);
}

.def 文件

除了 __declspec(dllexport),还可以用模块定义文件:

LIBRARY add.dll
DESCRIPTION "add/sub library"
EXPORTS
    add @1
    sub @2
    g_count DATA
  • LIBRARY:DLL 的内部名字,影响导入库记录的名字
  • EXPORTS:导出列表
  • @1:固定序号,老客户端按序号导入时保持兼容
  • DATA:标记这是数据导出
  • NONAME:只按序号导出,不导出名字(可以减小导出表,但客户端只能用 GetProcAddress 序号)
  • add=?add@@YAHHH@Z:把修饰名导出成未修饰的别名

用 .def 生成导入库:

lib /nologo /def:build\add.def /machine:x64 /out:build\add.lib

lib /def 不会生成 DLL,只生成导入库。DLL 本身还是要用 link /DLL 生成。.def 的典型用途是:

  • 导出未修饰的名字(避免 GetProcAddress 找不到)
  • 固定序号
  • 给已有 DLL 补一个导入库

导入库与 .exp

链接 DLL 时,MSVC 会自动生成:

  • add.dll:实际 DLL
  • add.lib:导入库,给使用者链接
  • add.exp:导出文件,增量链接时用,通常不需要分发

导入库的内容不是代码,而是:

  • __imp_add 等符号,指向 IAT 槽位
  • add thunk,跳转到 [__imp_add]
  • __IMPORT_DESCRIPTOR_add 等链接器指令,告诉链接器把 add.dll 写进导入表

可以用 dumpbin /symbols build\add.lib 看到这些符号。

名字修饰与调用约定

MSVC 的修饰规则和 GCC 完全不同,x86 和 x64 也不同:

语言/约定 x86 符号 x64 符号
C __cdecl _add add
C __stdcall _add@8 add
C __fastcall @add@8 add
C++ __cdecl ?add@@YAHHH@Z 同左
C++ __stdcall ?add@@YGHHH@Z 同左

x64 只有一种调用约定,所以 C 函数没有前导下划线和 @N 后缀;但 C++ 名称修饰仍然存在。

extern "C" 的作用:

  • 关闭 C++ 名称修饰
  • x86 上仍然保留调用约定带来的修饰(_add、_add@8)
  • x64 上得到干净的 add

如果 GetProcAddress 拿不到函数,先用 dumpbin /exports 看真实导出名,再决定传什么字符串。常见错误是导出了 _add@8,却用 "add" 去取。

用 .def 可以做别名:

EXPORTS
    add=_add@8

这样导出名是 add,内部符号是 _add@8。

生成 DLL 的两种方式

方式一:__declspec(dllexport) + /LD

cl /nologo /LD /EHsc /O2 /MD /Iinclude /DADD_EXPORTS src\add.cpp ^
    /Fe:build\add.dll /link /IMPLIB:build\add.lib

方式二:.def 文件

cl /nologo /LD /EHsc /O2 /MD /Iinclude src\add.cpp ^
    /Fe:build\add.dll /link /DEF:build\add.def /IMPLIB:build\add.lib

方式一简单,适合纯 C++ 项目;方式二对导出名字和序号控制更精确,适合对外 SDK、跨语言调用、需要稳定序号的场景。


3.4 运行时加载:LoadLibrary

基本用法

#include <windows.h>
#include <cstdio>

int main()
{
    HMODULE h = LoadLibraryW(L"build\\add.dll");
    if (!h)
    {
        std::printf("LoadLibrary failed: %lu\n", GetLastError());
        return 1;
    }

    using AddFn = int (*)(int, int);
    auto add = reinterpret_cast<AddFn>(GetProcAddress(h, "add"));
    if (!add)
    {
        std::printf("GetProcAddress failed: %lu\n", GetLastError());
        FreeLibrary(h);
        return 1;
    }

    std::printf("add(2, 3) = %d\n", add(2, 3));

    FreeLibrary(h);
    return 0;
}
  • LoadLibraryW 接受宽字符路径;LoadLibraryA 接受窄字符
  • GetProcAddress 返回 FARPROC,需要转换成正确的函数指针类型
  • FreeLibrary 递减引用计数,归零时卸载
  • GetLastError 返回错误码,可以用 FormatMessage 转成文本

安全搜索路径

默认搜索顺序包含当前工作目录和 PATH,容易被 DLL 劫持。安全做法:

// 让 LoadLibraryEx 只搜索应用程序目录和 System32
SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_APPLICATION_DIR |
                         LOAD_LIBRARY_SEARCH_SYSTEM32);

HMODULE h = LoadLibraryExW(L"add.dll", nullptr,
                           LOAD_LIBRARY_SEARCH_APPLICATION_DIR |
                           LOAD_LIBRARY_SEARCH_SYSTEM32);

或者直接传完整路径:

HMODULE h = LoadLibraryExW(L"C:\\Program Files\\MyApp\\add.dll", nullptr,
                           LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR |
                           LOAD_LIBRARY_SEARCH_SYSTEM32);

LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR 表示搜索 DLL 所在目录,LOAD_LIBRARY_SEARCH_SYSTEM32 表示搜索 System32。两个标志都不能和 LOAD_WITH_ALTERED_SEARCH_PATH 混用。

GetProcAddress 与序号

按名字:

auto fn = reinterpret_cast<AddFn>(GetProcAddress(h, "add"));

按序号:

auto fn = reinterpret_cast<AddFn>(GetProcAddress(h, MAKEINTRESOURCEA(1)));

序号在 .def 里用 @1 固定。按序号导入的优点是快、名字不暴露;缺点是序号一旦变化,老客户端会调到错误的函数。成熟库只在末尾追加序号。

卸载

FreeLibrary 只是递减引用计数:

HMODULE h1 = LoadLibraryW(L"add.dll");
HMODULE h2 = LoadLibraryW(L"add.dll");   // 同一模块,计数 +1,返回同一个句柄
FreeLibrary(h1);                         // 计数 -1,DLL 还在
FreeLibrary(h2);                         // 计数归零,DLL 卸载

卸载时调用 DllMain(DLL_PROCESS_DETACH)。如果还有线程在 DLL 里执行,不要直接 FreeLibrary,用 FreeLibraryAndExitThread 从 DLL 内部退出。

如果希望模块常驻,可以把它钉在进程里:

HMODULE h = nullptr;
GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_PIN,
                   L"add.dll", &h);

带 PIN 标志加载的模块,之后调用 FreeLibrary 也不会卸载。适合那些注册了全局回调、不能被卸载的插件。


3.5 DllMain

四个通知

#include <windows.h>

BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved)
{
    switch (reason)
    {
    case DLL_PROCESS_ATTACH:
        DisableThreadLibraryCalls(hModule);
        break;

    case DLL_THREAD_ATTACH:
        break;

    case DLL_THREAD_DETACH:
        break;

    case DLL_PROCESS_DETACH:
        break;
    }

    return TRUE;
}
通知 触发时机
DLL_PROCESS_ATTACH DLL 第一次被加载进进程(静态导入或第一次 LoadLibrary)
DLL_THREAD_ATTACH 进程创建新线程,且 DLL 没有调用 DisableThreadLibraryCalls
DLL_THREAD_DETACH 线程退出
DLL_PROCESS_DETACH DLL 被卸载,或进程退出

DLL_PROCESS_ATTACH 返回 FALSE 会让 LoadLibrary 失败(错误码 1114,ERROR_DLL_INIT_FAILED);如果是静态导入,进程直接启动失败。

能做什么,不能做什么

DllMain 在加载器锁(loader lock)下执行。加载器锁保护模块列表、导入表、TLS 等全局结构。在 DllMain 里做下面这些事,可能死锁或崩溃:

  • 调用 LoadLibrary / FreeLibrary 加载另一个 DLL
  • 创建线程并等待它结束
  • 调用 COM 的 CoInitialize / CoCreateInstance
  • 等待一个可能正在加载 DLL 的线程
  • 抛出跨模块的 C++ 异常
  • 做耗时的初始化(文件 IO、网络、大量内存分配)

可以做的事:

  • 初始化简单的全局变量
  • 调用 DisableThreadLibraryCalls
  • 注册 TLS 回调
  • 保存 hModule 到全局变量

复杂初始化用延迟初始化:DLL 导出一个 init() 函数,由调用方在 LoadLibrary 之后显式调用;或者用 C++ 的 std::call_once 在第一次使用时初始化。

TLS 回调

如果需要在 DllMain 之前或线程创建时执行代码,可以用 TLS 回调:

void NTAPI MyTlsCallback(PVOID module, DWORD reason, PVOID reserved)
{
    // reason: DLL_PROCESS_ATTACH / DLL_THREAD_ATTACH / ...
}

#ifdef _M_X64
#pragma comment(linker, "/INCLUDE:_tls_used")
#pragma const_seg(".CRT$XLB")
EXTERN_C const PIMAGE_TLS_CALLBACK my_tls_callback = MyTlsCallback;
#pragma const_seg()
#else
#pragma comment(linker, "/INCLUDE:__tls_used")
#pragma data_seg(".CRT$XLB")
EXTERN_C PIMAGE_TLS_CALLBACK my_tls_callback = MyTlsCallback;
#pragma data_seg()
#endif

TLS 回调在 DllMain 之前执行,能拿到进程/线程附加通知。不要在里面做重活。

线程局部变量

MSVC 用 __declspec(thread) 声明线程局部变量:

__declspec(thread) int tls_counter = 0;

每个线程有自己的一份 tls_counter,互不干扰。C++11 的 thread_local 在 MSVC 上底层也是 TLS。

DLL 里的 __declspec(thread) 有几个限制:

  • Windows Vista 之前,运行时通过 LoadLibrary 加载的 DLL 不能可靠使用 __declspec(thread)
  • DLL 被加载时,已经存在的线程不一定能拿到新分配的 TLS 块,具体行为取决于系统版本和加载方式
  • TLS 变量的构造/析构顺序和线程创建/退出顺序相关,不要在线程局部对象的析构函数里依赖其他 TLS 对象

如果 DLL 要在运行时加载,并且需要线程局部状态,优先用 thread_local 配合显式的 init(),或者用 TLS 回调管理生命周期。


3.6 延迟加载

延迟加载让某个 DLL 在第一次调用它的函数时才加载,而不是进程启动时。

cl /nologo /EHsc /O2 /MD /Iinclude src\main.cpp build\add.lib ^
    /link /DELAYLOAD:add.dll delayimp.lib /Fe:build\app_delay.exe
  • /DELAYLOAD:add.dll 告诉链接器生成延迟导入表(.didat)
  • delayimp.lib 提供 __delayLoadHelper2 等运行时支持

第一次调用 add 时,helper 会:

  1. 调用 LoadLibrary("add.dll")
  2. 用 GetProcAddress 解析所有延迟导入
  3. 把地址写进 IAT
  4. 跳转到真实函数

如果加载失败,默认会抛出一个结构化异常。可以注册钩子自定义行为:

#include <delayimp.h>

FARPROC WINAPI MyDliNotify(unsigned reason, DelayLoadInfo* info)
{
    if (reason == dliNotePreLoadLibrary)
    {
        // 返回自定义的 HMODULE
    }
    return nullptr;
}

ExternC PfnDliHook __pfnDliNotifyHook2 = MyDliNotify;

延迟加载适合:

  • 可选功能(打印、更新、插件)
  • 启动时不需要的 DLL
  • 想缩短启动时间的场景

代价是第一次调用有额外开销,而且错误处理更复杂。


3.7 MSVC 常见问题与诊断

LNK2019

main.obj : error LNK2019: 无法解析的外部符号 "int __cdecl add(int,int)" (?add@@YAHHH@Z),函数 main 中引用了该符号

排查顺序:

  1. 是否把 add.lib 放进了链接命令行
  2. 导出名是否匹配(dumpbin /exports add.dll)
  3. 是否用了 extern "C",两边的调用约定是否一致
  4. 库和程序是否同为 x86 或 x64
  5. /MT 和 /MD 是否一致
  6. 是否链接了错误的库版本

诊断:

link /nologo /VERBOSE:LIB /OUT:build\app.exe build\main.obj build\add.lib

/VERBOSE:LIB 会打印链接器搜索了哪些库、加载了哪些成员。

LNK2001

main.obj : error LNK2001: 无法解析的外部符号 "int g_count" (?g_count@@3HA)

通常是数据成员或全局变量:

  • 忘了在头文件里加 extern,导致每个包含它的 .cpp 都以为要定义
  • 忘了 __declspec(dllimport)
  • 静态数据成员只声明没定义
  • 模板静态成员没有实例化

LNK2005

LIBCMT.lib(init.obj) : error LNK2005: _initterm 已经在 MSVCRT.lib(init.obj) 中定义

原因:同时链接了静态 CRT 和动态 CRT。常见于:

  • 一个库用 /MT 编译,另一个用 /MD
  • 项目属性里运行时库设置不一致
  • 第三方 .lib 自带 /MT 的 CRT

解决:统一 /MT 或 /MD,或者用 /NODEFAULTLIB 排除冲突的库(不推荐,容易掩盖问题)。

LNK1104

LINK : fatal error LNK1104: 无法打开文件 "add.lib"

原因:

  • 路径写错
  • 库还没生成
  • 文件被另一个进程占用
  • 权限不足
  • 路径里有空格但没加引号

诊断:

dir build\add.lib
where add.lib

LNK2038

error LNK2038: 检测到"RuntimeLibrary"的不匹配: 值"MT_StaticRelease"不匹配值"MD_DynamicRelease"

或:

error LNK2038: 检测到"_ITERATOR_DEBUG_LEVEL"的不匹配: 值"0"不匹配值"2"

这是 MSVC 在目标文件里埋的"编译选项指纹"。/MT、/MD、Debug/Release、_ITERATOR_DEBUG_LEVEL 不一致时,链接器直接拒绝,避免运行时堆崩溃。

解决:所有目标文件和库用同一套选项重新编译。

LNK1112

fatal error LNK1112: 模块计算机类型“x64”与目标计算机类型“x86”冲突

32 位和 64 位混链。检查:

dumpbin /headers build\add.lib | findstr machine
dumpbin /headers build\main.obj | findstr machine

x64 显示 8664 (x64),x86 显示 14C (x86)。统一工具链和库架构。

0xc000007b

程序启动时弹出"应用程序无法正常启动 (0xc000007b)"。

常见原因:

  • 32 位程序加载了 64 位 DLL,或反过来
  • DLL 依赖的某个系统 DLL 架构不对
  • DLL 文件损坏

诊断:

dumpbin /headers build\app.exe | findstr machine
dumpbin /headers build\add.dll | findstr machine

用 Dependencies 工具(Dependency Walker 的现代替代品)看依赖树和架构。

0x8007007e

LoadLibrary 返回 NULL,GetLastError() 是 126 (0x7E):找不到模块。

原因:

  • DLL 不在搜索路径里
  • DLL 依赖的另一个 DLL 找不到
  • 路径里有非法字符

诊断:

dumpbin /dependents build\add.dll

用 Process Monitor 过滤 Operation is Load Image,看加载器尝试了哪些路径、哪个返回 NAME NOT FOUND。

GetProcAddress 返回 NULL

GetLastError() 通常是 127(找不到指定过程)。原因:

  • 导出名不对(名称修饰、__stdcall 的 @N、大小写)
  • 函数根本没导出(dumpbin /exports 显示 no exports)
  • 加载了错误版本的 DLL
  • 用了序号但序号不对

诊断:

dumpbin /exports build\add.dll

把导出的名字原样复制到 GetProcAddress。如果是 C++ 修饰名,考虑用 extern "C" 或 .def 别名。

The procedure entry point ... could not be located

程序启动时弹窗:

无法定位程序输入点 add 于动态链接库 add.dll 上。

原因:EXE 链接时用的导入库和运行时加载的 DLL 版本不一致。EXE 期望一个新函数,旧 DLL 没有。

解决:

  • 确保 DLL 和导入库是同一版本
  • 用 dumpbin /exports 对比导入表
  • 部署时把正确的 DLL 放到应用目录

dumpbin 常用命令

dumpbin /headers build\add.dll       :: PE 头、节区、DLL 特性
dumpbin /exports build\add.dll       :: 导出表
dumpbin /imports build\app.exe       :: 导入表
dumpbin /dependents build\app.exe    :: 依赖的 DLL
dumpbin /symbols build\add.lib       :: COFF 符号
dumpbin /disasm build\add.dll        :: 反汇编
dumpbin /pdbpath build\add.dll       :: PDB 路径
dumpbin /all build\add.dll           :: 全部信息

dumpbin 的输出可以直接和 link /dump 互换,后者是同一个工具的不同入口。


4. 跨工具链与部署


4.1 MinGW 与 MSVC 互操作

MinGW-w64 可以生成 DLL,但它的导入库格式和 MSVC 不完全一样。常见做法:

MinGW 生成 DLL 和导入库:

g++ -O2 -shared -o build/add.dll src/add.cpp \
    -Wl,--out-implib,build/libadd.dll.a

MSVC 使用 MinGW 的 DLL:

MSVC 不能直接链接 libadd.dll.a(它是 ar 格式,不是 COFF 导入库)。需要生成 .lib:

gendef build/add.dll
dlltool -d add.def -l build/add.lib -D build/add.dll

gendef 从 DLL 的导出表生成 .def,dlltool 从 .def 生成 MSVC 可用的导入库。pexports 是另一个可选工具。

MinGW 使用 MSVC 的 DLL:

dlltool -d add.def -l build/libadd.dll.a -D build/add.dll

或者用 g++ 直接链接 .dll(MinGW ld 会读取导出表),但导入库更稳。


4.2 C ABI 是通用语言

跨编译器、跨语言、跨平台时,唯一稳定的接口是 C ABI:

extern "C" {
    int add(int a, int b);
}

注意:

  • 调用约定:x86 上 MSVC 默认 __cdecl,MinGW 也默认 __cdecl;如果用了 __stdcall,两边的修饰规则可能不同。跨编译器时优先 __cdecl。
  • 名字修饰:extern "C" 关闭 C++ 修饰,但 x86 上仍有前导下划线。用 .def 或 dlltool 处理别名。
  • 类型宽度:long 在 Windows 上是 32 位,在 Linux 64 位上是 64 位。接口里用 int32_t、uint64_t、size_t 时也要小心 size_t 的宽度。
  • 结构体布局:默认对齐、packing、位域顺序都可能不同。跨编译器时显式指定对齐,避免位域。
  • 内存分配:谁分配谁释放。不要把一个 CRT 的 malloc 指针交给另一个 CRT 的 free。
  • 异常:不要跨 C ABI 抛 C++ 异常。
  • STL 类型:不要出现在接口里。

4.3 32/64 位与运行库匹配

一个进程里所有模块的架构必须一致:

  • x64 进程只能加载 x64 DLL
  • x86 进程只能加载 x86 DLL
  • ARM64 进程只能加载 ARM64 DLL

WOW64 可以让 32 位进程运行在 64 位 Windows 上,但进程内的 DLL 仍然全是 32 位。System32 在 32 位进程眼里会被重定向到 SysWOW64。

运行库也要匹配:

  • /MD 的程序需要对应版本的 VC++ Redistributable
  • /MT 的程序自带 CRT,但每个模块一份,跨 DLL 传对象仍要小心
  • Debug 版需要 Debug CRT,不能发给最终用户

4.4 调试符号

Linux:

# 编译时带调试信息
g++ -g -O2 -fPIC -c src/add.cpp -o build/add.o

# 分离调试信息
objcopy --only-keep-debug build/libadd.so.1.0.0 build/libadd.so.debug
strip --strip-unneeded build/libadd.so.1.0.0
objcopy --add-gnu-debuglink=build/libadd.so.debug build/libadd.so.1.0.0

# GDB 里设置符号搜索路径
# (gdb) set debug-file-directory build
# (gdb) info sharedlibrary

Windows:

cl /nologo /c /EHsc /Od /MDd /Zi /Iinclude src\add.cpp /Fo:build\add.obj
link /nologo /DLL /DEBUG /OUT:build\add.dll build\add.obj /IMPLIB:build\add.lib
  • /Zi 生成 PDB 调试信息
  • /DEBUG 让链接器生成 PDB
  • /Od 关闭优化,方便调试
  • PDB 文件名和路径可以用 dumpbin /pdbpath 查看
  • 发布时保留 PDB,但不要发给最终用户

4.5 静态库与动态库混用

一个项目里同时出现静态库和动态库很常见,问题往往出在"同一份静态库被链接进了多个模块"。

两份全局变量

app
 ├── libutil.a          (复制一份 util 代码和数据)
 └── libplugin.so
      └── libutil.a     (又复制一份 util 代码和数据)

exe 和 libplugin.so 各有一份 util_state。插件往自己的注册表里写,exe 查自己的注册表,结果就是"注册了但找不到"。

这不是链接错误,运行时也不会报错,只是两份状态各自为政。排查时容易怀疑"注册逻辑写错了",其实是链接模型的问题。

RTTI 与异常的双份

如果静态库里定义了跨边界使用的类型:

// util.h
struct Error
{
    virtual ~Error() = default;
};

// util.cpp
void throw_error()
{
    throw Error{};
}

libutil.a 同时被 exe 和 libplugin.so 链接后,两份 Error 的 type_info 可能不是同一个对象。dynamic_cast 和 catch 依赖 typeinfo 的比较,跨边界时可能匹配失败。

ELF 上 typeinfo 是弱符号,动态加载器有机会把两份合并成一份;但如果符号被 -fvisibility=hidden 隐藏,或者两个模块各自静态链接了 libstdc++,合并不一定发生。Windows 上 /MT 会让每个模块持有一份独立的 RTTI 对象,风险更高。

处理办法

1. 只链接一次。 让 exe 链接 libutil.a,插件通过 exe 导出的函数访问 util:

g++ -rdynamic app.o libutil.a -ldl -o app

插件里用 dlsym(RTLD_DEFAULT, "util_register") 找到 exe 的符号。Windows 上需要 exe 用 .def 或 __declspec(dllexport) 导出这些函数,或者干脆把 util 放进一个 DLL。

2. 把 util 做成共享库。 这是最省事的做法。所有模块链接同一个 libutil.so,加载器只有一份全局状态。

3. 让静态库无状态。 纯函数、不依赖全局变量、不持有缓存,那么两份副本也无所谓。这是设计 C 库时的常用原则。

4. 隐藏静态库的符号。 把第三方静态库链进 .so 时,不希望它的符号被导出、参与全局插入:

g++ -shared -o libplugin.so plugin.o -Wl,--exclude-libs,ALL

--exclude-libs,ALL 把来自静态归档的符号标记为隐藏。它不能消除"两份副本",但能减少符号冲突和意外插入。

5. 避免用 -Bsymbolic 掩盖问题。 -Bsymbolic 让库内部引用绑定到自己的定义,能让"两份副本"各自自洽,但 exe 和插件之间的状态仍然不通。它解决的是符号绑定,不是设计问题。

注:Linux 上可以用 LD_PRELOAD 强制让某个共享库优先加载,从而"统一"符号;但这是运行时的权宜之计,不适合作为架构方案。静态库副本的问题最终要靠"只链接一次"或"无状态设计"解决。


4.6 LTO 与库

LTO(Link-Time Optimization,链接时优化)把优化从"单个翻译单元"推迟到"链接阶段",让编译器能看到跨文件、跨静态库的函数体,从而做内联、常量传播、死代码消除。

GCC 的 LTO

编译和链接都要加 -flto:

g++ -O2 -flto -Iinclude -c src/add.cpp -o build/add.o
g++ -O2 -flto -Iinclude src/main.cpp build/add.o -o build/app

静态库要用支持 LTO 的 ar:

gcc-ar rcs build/libadd.a build/add.o
# 或者
gcc-ranlib build/libadd.a

直接使用普通 ar 时,可能需要指定插件:

ar --plugin=$(gcc -print-file-name=liblto_plugin.so) rcs build/libadd.a build/add.o

如果库要发给不使用 LTO 的使用者,加 -ffat-lto-objects,让目标文件同时包含 LTO 中间表示和普通机器码:

g++ -O2 -flto -ffat-lto-objects -c src/add.cpp -o build/add.o

代价是目标文件变大,链接时仍然可以选择走 LTO 或普通路径。

-flto=auto 让链接器并行执行 LTO,适合大项目。

LTO 的边界

  • 跨静态库可以优化:静态库最终会被链接进同一个可执行文件或 .so,LTO 能看到所有 IR
  • 跨 .so 不能优化:每个 .so 是独立链接的,运行时才拼在一起
  • 跨 dlopen 的模块不能优化
  • 内联函数和模板在 LTO 下更容易被合并和裁剪

LTO 会让"只被 dlsym 使用的符号"变得危险。编译器看不到任何 C++ 引用,可能把它当死代码删掉。解决办法:

extern "C" __attribute__((used, visibility("default")))
void plugin_entry()
{
}

或者用链接器的 --export-dynamic-symbol、--undefined 保留符号。

LTO 与调试

g++ -O2 -flto -g -Iinclude -c src/add.cpp -o build/add.o
g++ -O2 -flto -g src/main.cpp build/add.o -o build/app

编译和链接都要 -g,否则调试信息可能不完整。LTO 后的行号映射可能和源码对不上,调试体验会比普通编译差一些。

MSVC 的 LTCG

MSVC 的对应机制是 /GL(编译时生成全程序优化信息)加 /LTCG(链接时优化):

cl /nologo /c /EHsc /O2 /MD /GL /Iinclude src\add.cpp /Fo:build\add.obj
lib /nologo /OUT:build\add.lib build\add.obj
cl /nologo /EHsc /O2 /MD /GL /Iinclude src\main.cpp build\add.lib /link /LTCG /Fe:build\app.exe

/GL 生成的目标文件不能直接被不支持的链接器使用;静态库里的 /GL 对象在链接时由 /LTCG 处理。

LTO 的坑

  • 编译器版本必须一致。GCC 的 LTO 中间表示不保证跨大版本兼容,用 GCC 12 编译的 LTO 对象不一定能被 GCC 13 链接
  • 调试信息变差。变量可能被优化掉,断点位置可能偏移
  • 链接变慢。LTO 把优化推迟到链接,链接时间会明显增加
  • 符号可见性变化。LTO 可能把没有外部引用的符号内部化,导致 dlsym 找不到
  • 和 --gc-sections 叠加。两者都做死代码消除,配置不当时可能删掉本该保留的入口

对第三方二进制库,不要假设它开了 LTO;对自己项目,先在 CI 里对比开启前后的体积、性能和调试体验,再决定是否默认开启。


4.7 库的裁剪与体积

库的体积来自四部分:代码、只读数据、异常/调试信息、符号表。裁剪要分别处理。

按函数切分节区

默认情况下,一个 .o 的所有函数在同一个 .text 节区里,链接器只能按"整个节区"决定去留。开启函数级节区:

g++ -O2 -ffunction-sections -fdata-sections -c src/add.cpp -o build/add.o
g++ -O2 -Wl,--gc-sections src/main.cpp build/add.o -o build/app
  • -ffunction-sections:每个函数一个 .text.funcname
  • -fdata-sections:每个变量一个 .data.varname
  • --gc-sections:从入口点出发做可达性分析,丢弃没被引用的节区

对静态库尤其有用:链接器拉进一个成员后,还能继续丢弃成员里没用的函数。

动态库的导出是根

--gc-sections 会把导出到 .dynsym 的符号当作根保留。但如果你用 -fvisibility=hidden 或版本脚本把符号藏起来,链接器可能认为某些函数没人用而删除。

需要保留的入口:

  • 加 __attribute__((used))
  • 在版本脚本里 global: 列出
  • 用 -Wl,--undefined=symbol 强制保留
  • 用 -Wl,--gc-keep-exported(较新的 binutils 支持)

删除调试信息和符号表

strip --strip-unneeded build/libadd.so.1.0.0
  • --strip-debug:只删调试节区
  • --strip-unneeded:删链接不需要的符号,保留 .dynsym
  • --strip-all:连 .dynsym 一起删,动态库会废掉,不要用

保留独立调试文件:

objcopy --only-keep-debug build/libadd.so.1.0.0 build/libadd.so.debug
strip --strip-unneeded build/libadd.so.1.0.0
objcopy --add-gnu-debuglink=build/libadd.so.debug build/libadd.so.1.0.0

编译选项的取舍

选项 效果 代价
-Os 优化体积 可能比 -O2 慢
-fno-exceptions 去掉异常表和展开代码 不能抛异常,ABI 变化
-fno-rtti 去掉 RTTI 不能用 dynamic_cast/typeid,ABI 变化
-fno-asynchronous-unwind-tables 去掉 .eh_frame 异常和栈回溯失效
-gz 压缩调试信息 调试器需要支持
-Wl,--build-id=none 去掉 build-id 调试符号定位变难
-s 链接时删符号 不能调试

-fno-exceptions 和 -fno-rtti 只适合内部库或纯 C 接口库。公开的 C++ 库不要开,否则使用者无法正常使用异常和 RTTI,且 ABI 不兼容。

查看体积构成

size build/libadd.so
nm --size-sort -C build/libadd.so | tail -n 20
readelf -SW build/libadd.so | sort -k6 -n

bloaty 是更直观的工具:

bloaty build/libadd.so

它会按编译单元、节区、符号列出体积占比,适合定位"哪个模板实例把库撑大了"。

MSVC 的裁剪

cl /nologo /c /EHsc /O2 /MD /Gy /Iinclude src\add.cpp /Fo:build\add.obj
link /nologo /DLL /OPT:REF /OPT:ICF /OUT:build\add.dll build\add.obj /IMPLIB:build\add.lib
  • /Gy:函数级链接,每个函数单独 COMDAT
  • /OPT:REF:删除未引用的函数和数据
  • /OPT:ICF:合并相同的 COMDAT
  • /GL + /LTCG:全程序优化,能进一步裁剪

/DEBUG 会保留调试信息,但不一定阻止 /OPT:REF;发布版可以同时开 /DEBUG 和 /OPT:REF,保留 PDB 用于事后分析。


4.8 部署清单

Linux

发布前逐项检查:

  1. soname 是否正确:readelf -d libadd.so.1.0.0 | grep SONAME
  2. 符号链接是否完整:libadd.so、libadd.so.1、libadd.so.1.0.0 三层
  3. 依赖是否齐全:ldd app 在干净环境里不出现 not found
  4. rpath 是否可移植:优先用 $ORIGIN,不要把构建机的绝对路径写死
  5. glibc 版本:构建机的 glibc 不能高于目标机
  6. libstdc++ 版本:同上,必要时 -static-libstdc++
  7. 调试信息:发布库 strip,调试文件单独归档
  8. 许可证:LGPL 库动态链接的分发义务
  9. 架构:file 确认 x86-64 / aarch64 正确
  10. 测试:在目标发行版的容器里跑一遍
# 检查依赖
readelf -d build/app | grep NEEDED
ldd build/app

# 检查 rpath
readelf -d build/app | grep -E 'RPATH|RUNPATH'

# 检查架构
file build/app build/libadd.so.1.0.0

# 检查符号
nm -D --defined-only build/libadd.so.1.0.0 | c++filt

如果发布后需要修改 rpath,可以用 patchelf:

patchelf --print-rpath build/app
patchelf --set-rpath '$ORIGIN/lib' build/app

chrpath 也能改,但只能把 rpath 改短,不能变长。

Windows

  1. 架构一致:exe 和所有 DLL 都是 x64 或都是 x86
  2. CRT 部署:/MD 需要 VC++ Redistributable,或改用 /MT
  3. 依赖齐全:dumpbin /dependents app.exe 里的每个 DLL 都能找到
  4. 不要依赖当前目录和 PATH:用应用目录 + SetDefaultDllDirectories
  5. Debug/Release 不混:发布包不能带 *d.dll、ucrtbased.dll
  6. PDB 单独归档:不要随安装包发给最终用户
  7. manifest/SxS:需要并行程序集时,manifest 要随 exe 一起部署
  8. 代码签名:对外分发的 exe/DLL 做签名
  9. 干净机器测试:在没有装 Visual Studio 的虚拟机上跑一遍
dumpbin /dependents build\app.exe
dumpbin /headers build\app.exe | findstr machine
dumpbin /imports build\app.exe

用 Dependencies 工具查看依赖树,能直观看到哪个 DLL 缺失、哪个架构不对。

版本检查脚本

Linux 上可以在 CI 里跑:

set -e
readelf -d build/app | grep NEEDED
! ldd build/app | grep 'not found'
objdump -T build/app | grep GLIBC | sort -u

Windows 上可以用 PowerShell:

dumpbin /dependents build\app.exe
if ($LASTEXITCODE -ne 0) { exit 1 }

5. 排错速查表

现象 平台 常见原因 处理
undefined reference to 'add(int, int)' Linux 没链接库、库顺序错、C/C++ 修饰不匹配 加 -ladd,调整顺序,加 extern "C"
cannot find -ladd Linux -L 路径错、缺 libadd.so 链接名 检查 -L,建符号链接
cannot open shared object file Linux 运行时找不到 soname 设 LD_LIBRARY_PATH、rpath、ldconfig
symbol lookup error: undefined symbol Linux 库版本旧、符号被隐藏、依赖缺失 nm -D、ldd -r、LD_DEBUG=bindings
version 'GLIBC_2.xx' not found Linux 构建环境 glibc 比运行环境新 老环境构建、容器、静态链接
version 'GLIBCXX_3.4.xx' not found Linux libstdc++ 版本旧 -static-libstdc++、升级运行库
relocation R_X86_64_32S ... -fPIC Linux 动态库目标文件不是 PIC 加 -fPIC 重编
relocation R_X86_64_32 ... PIE Linux 静态库不是 PIC/PIE 重编 -fPIE,或 -no-pie
multiple definition Linux 重复定义、头文件里定义变量 inline、extern、匿名命名空间
cannot allocate memory in static TLS block Linux dlopen 的库用了 initial-exec TLS 改 TLS 模型、提前加载、调大预留
LNK2019 Windows 没链接 .lib、名字/修饰不匹配、架构不对 检查导入库、dumpbin /exports、架构
LNK2001 Windows 数据符号未定义、缺 dllimport 定义变量、加 dllimport
LNK2005 Windows 重复定义、/MT 与 /MD 混用 统一运行库、删除重复定义
LNK1104 Windows 找不到 .lib 检查路径、先生成库
LNK2038 Windows RuntimeLibrary 或迭代器调试级别不匹配 统一 /MT//MD 和 Debug/Release
LNK1112 Windows x86/x64 混链 统一工具链和库架构
0xc000007b Windows 32/64 位 DLL 混用、DLL 损坏 dumpbin /headers 查架构
0x8007007e Windows LoadLibrary 找不到模块或依赖 dumpbin /dependents、Process Monitor
The procedure entry point ... Windows DLL 版本与导入库不匹配 部署匹配版本的 DLL
GetProcAddress 返回 NULL Windows 导出名不对、没导出、序号错 dumpbin /exports 对照
DLL 加载了错误版本 Windows 搜索路径里有同名 DLL 用完整路径、SetDefaultDllDirectories
跨 DLL 释放内存崩溃 Windows /MT 堆隔离、CRT 不一致 统一 /MD、谁分配谁释放
dlopen 后调用崩溃 Linux 库被 dlclose 卸载、线程还在库内 延迟卸载、RTLD_NODELETE、join 线程
静态库符号没被链接 两平台 成员没有被引用 --whole-archive / /WHOLEARCHIVE
可执行文件异常大 两平台 静态链接、调试信息、未裁剪 -ffunction-sections --gc-sections、/OPT:REF、strip

注:排错时先确定错误发生在哪个阶段——编译、链接、加载还是运行。四类错误的排查手段完全不同:编译看头文件和选项,链接看符号和库,加载看搜索路径和依赖,运行看 ABI 和内存。把阶段分清楚,能省掉大量猜测。


6. 完整命令清单

这一节把前面用到的命令集中列出,方便复制。所有命令都假设在工程根目录执行,源码结构同开头。


6.1 GCC 静态库

# 1. 编译
g++ -O2 -Wall -Wextra -Iinclude -c src/add.cpp -o build/add.o

# 2. 打包
ar rcs build/libadd.a build/add.o

# 3. 链接
g++ -O2 -Iinclude src/main.cpp -Lbuild -ladd -o build/app_static

# 4. 检查
ar t build/libadd.a
nm -C build/libadd.a
ldd build/app_static

6.2 GCC 动态库

# 1. 编译 PIC 目标文件
g++ -O2 -Wall -Wextra -fPIC -Iinclude -c src/add.cpp -o build/add.o

# 2. 生成动态库,写 soname
g++ -shared -Wl,-soname,libadd.so.1 -o build/libadd.so.1.0.0 build/add.o

# 3. 建链接名
ln -sf libadd.so.1.0.0 build/libadd.so.1
ln -sf libadd.so.1 build/libadd.so

# 4. 链接可执行文件,写 rpath
g++ -O2 -Iinclude src/main.cpp -Lbuild -ladd \
    -Wl,-rpath,'$ORIGIN' -o build/app_shared

# 5. 检查
readelf -d build/libadd.so.1.0.0 | grep SONAME
readelf -d build/app_shared | grep NEEDED
ldd build/app_shared
nm -D --defined-only build/libadd.so.1.0.0 | c++filt

6.3 GCC 运行时加载

g++ -O2 load.cpp -ldl -o build/load
LD_LIBRARY_PATH=build ./build/load

6.4 MSVC 静态库

:: 1. 编译
cl /nologo /c /EHsc /O2 /MD /Iinclude src\add.cpp /Fo:build\add.obj

:: 2. 打包
lib /nologo /OUT:build\add.lib build\add.obj

:: 3. 链接
cl /nologo /EHsc /O2 /MD /Iinclude src\main.cpp build\add.lib /Fe:build\app_static.exe

:: 4. 检查
lib /list build\add.lib
dumpbin /symbols build\add.lib
dumpbin /dependents build\app_static.exe

6.5 MSVC DLL

:: 1. 生成 DLL、导入库(需要源文件里用 __declspec(dllexport),或用 .def)
cl /nologo /LD /EHsc /O2 /MD /Iinclude /DADD_EXPORTS src\add.cpp ^
    /Fe:build\add.dll /link /IMPLIB:build\add.lib

:: 2. 链接使用者
cl /nologo /EHsc /O2 /MD /Iinclude src\main.cpp build\add.lib ^
    /Fe:build\app_shared.exe

:: 3. 检查
dumpbin /exports build\add.dll
dumpbin /imports build\app_shared.exe
dumpbin /dependents build\app_shared.exe

6.6 MSVC 运行时加载

cl /nologo /EHsc /O2 /MD load.cpp /Fe:build\load.exe
build\load.exe

6.7 关键检查点

检查项 GCC MSVC
目标文件类型 file add.o dumpbin /headers add.obj
静态库成员 ar t libadd.a lib /list add.lib
符号 nm -C、nm -D dumpbin /symbols、/exports
动态依赖 readelf -d、ldd dumpbin /dependents、/imports
rpath readelf -d、patchelf 无(用应用目录/清单)
架构 file dumpbin /headers
初始化顺序 readelf -x .init_array DllMain、全局构造
符号可见性 nm -D --defined-only dumpbin /exports